docs(teardowns): cc tool and agent-fleet rendering reproduction spec plus Coder TUI port plan

f57737b51cd2 · Devin AI · · parent 2c15c6ed208d

docs(teardowns): cc tool and agent-fleet rendering reproduction spec plus Coder TUI port plan

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • added docs/teardowns/2026-08-23-cc-tool-and-agent-fleet-rendering-reproduction.md
  • added docs/teardowns/2026-08-23-openagents-coder-tui-agent-fleet-port-plan.md
  • modified docs/teardowns/README.md

Diff

3 files changed, +1390 -0

docs/teardowns/2026-08-23-cc-tool-and-agent-fleet-rendering-reproduction.md added +958

@@ -0,0 +1,958 @@

1
# cc tool rendering, delegation, and agent-fleet TUI reproduction spec — 2026-08-23
2
3
Subject: the commit-pinned `cc` source import (`AtlantisPleb/cc`, commit
4
`813c06acfa2d705076df6193b405c81eb11a18d1`, "Import cc sources") read as an
5
implementation reference for one question: how does a terminal agent host
6
define tools, run many child coding agents at once, and draw all of that on a
7
single screen without the transcript turning into noise?
8
9
Provenance and boundary:
10
11
- Everything in sections 1-8 is **observed source**, with file paths and
12
  verbatim shapes. The import is post-React-Compiler output, so component
13
  bodies contain `_c(n)` memo slots; the shapes and control flow are intact and
14
  every quoted type or string below is copied from the file named above it.
15
- Section 9 is **inferred mapping** to OpenAgents. It is a proposal, not a
16
  requirement, and it does not override `AGENTS.md`, `INVARIANTS.md`, or the
17
  Sol roadmap.
18
- Prior teardowns already cover the surrounding system:
19
  [Claude Code architecture](./2026-07-10-claude-code-teardown.md) (query loop,
20
  authority, persistence) and
21
  [Claude subagent histories](./2026-07-10-claude-subagents-rendering-analysis.md)
22
  (sidechain JSONL, topology reconstruction across versions). This document does
23
  not repeat them. It adds the layer neither covered at reproduction depth: the
24
  per-tool render contract, the task/progress state machine, and the compact
25
  multi-agent surfaces.
26
- The port plan that consumes this document is
27
  [OpenAgents Coder agent-fleet port plan](./2026-08-23-openagents-coder-tui-agent-fleet-port-plan.md).
28
29
## TL.DR
30
31
1. **A tool owns its own rendering.** `Tool` is a single object that carries
32
   the input schema, the permission check, execution, result serialization, and
33
   *eleven* render methods — use, progress, result, queued, rejected, error,
34
   grouped, tag, plus three text/summary accessors. There is no `switch` on tool
35
   name anywhere in the message renderer.
36
2. **Rendering is defined over partial input.** Every render method takes
37
   `Partial<z.infer<Input>>`, because the model streams arguments and the row
38
   must draw before the arguments are complete.
39
3. **Compact and full are separate outputs of the same tool**, selected by
40
   `verbose`, `style: 'condensed'`, `isTranscriptMode`, and
41
   `terminalSize`. Nothing re-derives a summary from the full rendering.
42
4. **The tool pool is assembled once, deterministically, and sorted.**
43
   Deny rules filter before the model ever sees a tool; built-ins beat MCP tools
44
   on name collision; partitions are sorted so the prompt prefix stays cacheable.
45
5. **Delegation is a tool, but its state lives in a task registry.** `AgentTool`
46
   spawns a child and immediately registers a `local_agent` task; the fleet
47
   surfaces (footer pill, dialog, per-agent line) read the registry, not the
48
   transcript.
49
6. **Progress is aggregated, not replayed.** A child streams normalized
50
   messages up as `agent_progress`; the parent keeps `toolUseCount`,
51
   `latestInputTokens + cumulativeOutputTokens`, and a `recentActivities` ring
52
   bounded at `MAX_RECENT_ACTIVITIES = 5`. The compact row shows the last
53
   activity; the transcript shows all of it.
54
7. **Every list surface degrades to one line.** N agents collapse to
55
   `3 local agents`; a too-small terminal collapses in-progress detail to
56
   `In progress… · 12 tool uses · 4.1k tokens · ctrl+o to expand`.
57
8. **Memoization is the load-bearing performance trick.** A resolved,
58
   non-streaming message row is memoized against width, screen, verbosity, and
59
   resolution; only unresolved or streaming rows re-render.
60
61
## 1. The tool contract (`Tool.ts`)
62
63
One interface couples execution, authority, and presentation. This coupling is
64
deliberate and is the single most portable idea in the file.
65
66
### 1.1 Execution and metadata
67
68
```ts
69
name: string
70
inputSchema: Input                 // zod, built lazily
71
prompt(...): Promise<string>       // model-facing description
72
description(...): Promise<string>
73
isEnabled(): boolean               // runtime availability
74
isReadOnly(input?): boolean
75
isDestructive(input?): boolean
76
isConcurrencySafe(input?): boolean // may run in parallel with siblings
77
checkPermissions(input, ctx?): Promise<PermissionResult>
78
toAutoClassifierInput(input?): string
79
validateInput?(input, ctx): Promise<{ result: boolean; message?: string; errorCode?: number }>
80
call(input, toolUseContext, canUseTool, parentMessage, onProgress): ...
81
mapToolResultToToolResultBlockParam(data, toolUseID): ToolResultBlockParam
82
maxResultSizeChars?: number
83
shouldDefer?: boolean
84
aliases?: string[]
85
searchHint?: string
86
```
87
88
`ToolUseContext` is what a tool is allowed to see. It carries the full option
89
set plus the mutable session handles:
90
91
```ts
92
export type ToolUseContext = {
93
  options: {
94
    commands: Command[]
95
    debug: boolean
96
    mainLoopModel: string
97
    tools: Tools
98
    verbose: boolean
99
    thinkingConfig: ThinkingConfig
100
    mcpClients: MCPServerConnection[]
101
    mcpResources: Record<string, ServerResource[]>
102
    isNonInteractiveSession: boolean
103
    agentDefinitions: AgentDefinitionsResult
104
    maxBudgetUsd?: number
105
    customSystemPrompt?: string
106
    appendSystemPrompt?: string
107
    querySource?: QuerySource
108
    refreshTools?: () => Tools
109
  }
110
  abortController: AbortController
111
  readFileState: FileStateCache
112
  getAppState: () => AppState
113
  setAppState: SetAppState
114
  messages: Message[]
115
  ...
116
}
117
```
118
119
Two fields matter for delegation: `querySource` (which is how a forked child
120
recognizes itself and refuses to fork again) and `messages` (which is how a
121
child inherits the parent's exact prefix).
122
123
Permission state is a separate immutable value, threaded rather than ambient:
124
125
```ts
126
export type ToolPermissionContext = DeepImmutable<{
127
  mode: PermissionMode
128
  additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
129
  alwaysAllowRules: ToolPermissionRulesBySource
130
  alwaysDenyRules: ToolPermissionRulesBySource
131
  alwaysAskRules: ToolPermissionRulesBySource
132
  isBypassPermissionsModeAvailable: boolean
133
  isAutoModeAvailable?: boolean
134
  strippedDangerousRules?: ToolPermissionRulesBySource
135
  shouldAvoidPermissionPrompts?: boolean
136
  awaitAutomatedChecksBeforeDialog?: boolean
137
  prePlanMode?: PermissionMode
138
}>
139
```
140
141
### 1.2 The render contract
142
143
Three text accessors and eight node renderers. Every input parameter is
144
`Partial`, and every renderer is optional except `renderToolUseMessage`.
145
146
```ts
147
userFacingName(input: Partial<z.infer<Input>> | undefined): string
148
getToolUseSummary?(input: Partial<z.infer<Input>> | undefined): string | null
149
getActivityDescription?(input: Partial<z.infer<Input>> | undefined): string | null
150
151
renderToolUseMessage(
152
  input: Partial<z.infer<Input>>,
153
  options: { theme: ThemeName; verbose: boolean; commands?: Command[] },
154
): React.ReactNode
155
156
renderToolUseProgressMessage?(
157
  progressMessagesForMessage: ProgressMessage<P>[],
158
  options: {
159
    tools: Tools
160
    verbose: boolean
161
    terminalSize?: { columns: number; rows: number }
162
    inProgressToolCallCount?: number
163
    isTranscriptMode?: boolean
164
  },
165
): React.ReactNode
166
167
renderToolResultMessage?(
168
  content: Output,
169
  progressMessagesForMessage: ProgressMessage<P>[],
170
  options: {
171
    style?: 'condensed'
172
    theme: ThemeName
173
    tools: Tools
174
    verbose: boolean
175
    isTranscriptMode?: boolean
176
    isBriefOnly?: boolean
177
    input?: unknown
178
  },
179
): React.ReactNode
180
181
renderToolUseQueuedMessage?(): React.ReactNode
182
renderToolUseRejectedMessage?(input, options): React.ReactNode
183
renderToolUseErrorMessage?(result, options): React.ReactNode
184
renderToolUseTag?(input: Partial<z.infer<Input>>): React.ReactNode
185
186
renderGroupedToolUse?(
187
  toolUses: Array<{
188
    param: ToolUseBlockParam
189
    isResolved: boolean
190
    isError: boolean
191
    isInProgress: boolean
192
    progressMessages: ProgressMessage<P>[]
193
    result?: { param: ToolResultBlockParam; output: unknown }
194
  }>,
195
  options: { shouldAnimate: boolean; tools: Tools },
196
): React.ReactNode | null
197
198
extractSearchText?(out: Output): string
199
isResultTruncated?(output: Output): boolean
200
```
201
202
Reproduction notes:
203
204
- `renderToolUseProgressMessage` receives **all** progress messages for that
205
  tool use plus the terminal size and the count of concurrently in-flight tool
206
  calls. That is what allows a tool to shrink itself when the screen is busy,
207
  instead of the frame owner truncating it from outside.
208
- `renderToolResultMessage` receives the progress history *as well as* the
209
  result, so a completed delegation can still render its nested transcript.
210
- `renderGroupedToolUse` is the multi-call surface: the presence of this method
211
  is what makes a tool eligible for grouping at all (section 4).
212
- Failure has three distinct renderings: rejected (user denied), error (tool
213
  threw), queued (not started). None of these is a special case of the result.
214
215
### 1.3 Conservative defaults
216
217
```ts
218
const TOOL_DEFAULTS = {
219
  isEnabled: () => true,
220
  isConcurrencySafe: (_input?: unknown) => false,
221
  isReadOnly: (_input?: unknown) => false,
222
  isDestructive: (_input?: unknown) => false,
223
  checkPermissions: (input, _ctx?) =>
224
    Promise.resolve({ behavior: 'allow', updatedInput: input }),
225
  toAutoClassifierInput: (_input?: unknown) => '',
226
  userFacingName: (_input?: unknown) => '',
227
}
228
229
export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
230
  return { ...TOOL_DEFAULTS, userFacingName: () => def.name, ...def } as BuiltTool<D>
231
}
232
```
233
234
A tool that says nothing is serial, mutating, and named after itself. The
235
scheduler can only parallelize what opted in.
236
237
## 2. Tool assembly (`tools.ts`)
238
239
One catalog function, feature-gated at the array level:
240
241
```ts
242
export function getAllBaseTools(): Tools {
243
  return [
244
    AgentTool,
245
    TaskOutputTool,
246
    BashTool,
247
    ...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
248
    ExitPlanModeV2Tool,
249
    FileReadTool, FileEditTool, FileWriteTool, NotebookEditTool,
250
    WebFetchTool, TodoWriteTool, WebSearchTool,
251
    TaskStopTool, AskUserQuestionTool, SkillTool, EnterPlanModeTool,
252
    ListMcpResourcesTool, ReadMcpResourceTool,
253
    ...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),
254
  ]
255
}
256
```
257
258
Deny filtering happens before the model sees anything:
259
260
```ts
261
export function filterToolsByDenyRules<
262
  T extends { name: string; mcpInfo?: { serverName: string; toolName: string } },
263
>(tools: readonly T[], permissionContext: ToolPermissionContext): T[] {
264
  return tools.filter(tool => !getDenyRuleForTool(permissionContext, tool))
265
}
266
```
267
268
And the pool is a deterministic merge:
269
270
```ts
271
export function assembleToolPool(
272
  permissionContext: ToolPermissionContext,
273
  mcpTools: Tools,
274
): Tools {
275
  const builtInTools = getTools(permissionContext)
276
  const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)
277
  const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)
278
  return uniqBy(
279
    [...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
280
    'name',
281
  )
282
}
283
```
284
285
Four properties to reproduce: (a) enablement (`isEnabled`) is *not* the same
286
axis as denial (permission rules); (b) built-ins win a name collision because
287
they are concatenated first and `uniqBy` keeps the first; (c) each partition is
288
sorted independently so the built-in block is byte-stable across sessions; (d)
289
`assembleToolPool` is called per *worker*, not once per process — a child agent
290
gets its own pool from its own permission context (section 5.3).
291
292
## 3. Message-row rendering (`components/MessageRow.tsx`)
293
294
The row coordinator decides which of the render paths above applies, and — more
295
importantly — decides whether to re-render at all.
296
297
```ts
298
export type Props = {
299
  message: RenderableMessage
300
  isUserContinuation: boolean
301
  hasContentAfter: boolean
302
  tools: Tools
303
  commands: Command[]
304
  verbose: boolean
305
  inProgressToolUseIDs: Set<string>
306
  streamingToolUseIDs: Set<string>
307
  screen: Screen
308
  canAnimate: boolean
309
  lastThinkingBlockId: string | null
310
  latestBashOutputUUID: string | null
311
  columns: number
312
  isLoading: boolean
313
  lookups: ReturnType<typeof buildMessageLookups>
314
}
315
```
316
317
Message kinds it dispatches on: ordinary tool use, `grouped_tool_use`,
318
`collapsed_read_search`, thinking, plain assistant/user text. Transcript mode is
319
`screen === 'transcript'`, and it is passed down as `isTranscriptMode` to the
320
tool's own renderers rather than changing which renderer runs.
321
322
Two predicates define liveness:
323
324
```ts
325
export function isMessageStreaming(
326
  msg: RenderableMessage,
327
  streamingToolUseIDs: Set<string>,
328
): boolean            // any member tool id for grouped/collapsed, else the one id
329
330
export function allToolsResolved(
331
  msg: RenderableMessage,
332
  resolvedToolUseIDs: Set<string>,
333
): boolean            // grouped rows resolve only when every member resolves
334
```
335
336
And the memo comparator is the frame budget:
337
338
```ts
339
export function areMessageRowPropsEqual(prev: Props, next: Props): boolean {
340
  if (prev.message !== next.message) return false
341
  if (prev.screen !== next.screen) return false
342
  if (prev.verbose !== next.verbose) return false
343
  if (prev.message.type === 'collapsed_read_search' && next.screen !== 'transcript') return false
344
  if (prev.columns !== next.columns) return false
345
  const prevIsLatestBash = prev.latestBashOutputUUID === prev.message.uuid
346
  const nextIsLatestBash = next.latestBashOutputUUID === next.message.uuid
347
  if (prevIsLatestBash !== nextIsLatestBash) return false
348
  if (prev.lastThinkingBlockId !== next.lastThinkingBlockId && hasThinkingContent(next.message)) return false
349
  const isStreaming = isMessageStreaming(prev.message, prev.streamingToolUseIDs)
350
  const isResolved = allToolsResolved(prev.message, prev.lookups.resolvedToolUseIDs)
351
  if (isStreaming || !isResolved) return false
352
  return true
353
}
354
```
355
356
The invalidation set is exactly: identity, screen mode, verbosity, width,
357
"is the newest shell output", "is the newest thinking block", streaming, and
358
resolution. Nothing else. Animation is likewise gated on unresolved content, so
359
a settled transcript costs no spinner ticks.
360
361
## 4. Grouping many calls into one row (`utils/groupToolUses.ts`)
362
363
```ts
364
const GROUPING_CACHE = new WeakMap<Tools, Set<string>>()
365
366
function getToolsWithGrouping(tools: Tools): Set<string> {
367
  let cached = GROUPING_CACHE.get(tools)
368
  if (!cached) {
369
    cached = new Set(tools.filter(t => t.renderGroupedToolUse).map(t => t.name))
370
    GROUPING_CACHE.set(tools, cached)
371
  }
372
  return cached
373
}
374
```
375
376
Rules, in order:
377
378
1. Grouping is disabled entirely in verbose mode.
379
2. Only tools implementing `renderGroupedToolUse` participate.
380
3. A group needs **two or more calls of the same tool from the same API
381
   message** — i.e. one assistant turn that fanned out.
382
4. Results are collected by `tool_use_id`.
383
5. The grouped row replaces the individual rows at the position of the first
384
   member; user messages that contain only grouped results are dropped from the
385
   stream.
386
6. The group retains its children for detail rendering:
387
388
```ts
389
const groupedMessage: GroupedToolUseMessage = {
390
  type: 'grouped_tool_use',
391
  toolName: info.toolName,
392
  messages: group,
393
  results,
394
  displayMessage: firstMsg,
395
  uuid: `grouped-${firstMsg.uuid}`,
396
  timestamp: firstMsg.timestamp,
397
  messageId: info.messageId,
398
}
399
```
400
401
The `WeakMap` keyed on the `Tools` array is why point 4 of section 2 matters: a
402
stable tool array is also a stable grouping cache.
403
404
## 5. Delegation (`tools/AgentTool/AgentTool.tsx`)
405
406
### 5.1 Input contract
407
408
```ts
409
const baseInputSchema = lazySchema(() => z.object({
410
  description: z.string().describe('A short (3-5 word) description of the task'),
411
  prompt: z.string().describe('The task for the agent to perform'),
412
  subagent_type: z.string().optional(),
413
  model: z.enum(['sonnet', 'opus', 'haiku']).optional(),
414
  run_in_background: z.boolean().optional(),
415
}))
416
```
417
418
plus, when multi-agent is on:
419
420
```ts
421
name: z.string().optional()        // makes the child addressable: SendMessage({to: name})
422
team_name: z.string().optional()
423
mode: permissionModeSchema().optional()
424
```
425
426
plus isolation:
427
428
```ts
429
isolation: z.enum(['worktree']).optional()
430
cwd: z.string().optional()
431
```
432
433
`description` exists **only** for display. It is the string every compact
434
surface shows, which is why the schema demands 3-5 words.
435
436
### 5.2 Output contract
437
438
```ts
439
const syncOutputSchema = agentToolResultSchema().extend({
440
  status: z.literal('completed'),
441
  prompt: z.string(),
442
})
443
444
const asyncOutputSchema = z.object({
445
  status: z.literal('async_launched'),
446
  agentId: z.string(),
447
  description: z.string(),
448
  prompt: z.string(),
449
  outputFile: z.string(),          // where the parent can watch progress
450
  canReadOutputFile: z.boolean().optional(),
451
})
452
```
453
454
Background launch returns immediately:
455
456
```ts
457
return {
458
  data: {
459
    isAsync: true as const,
460
    status: 'async_launched' as const,
461
    agentId: agentBackgroundTask.agentId,
462
    description,
463
    prompt,
464
    outputFile: getTaskOutputPath(agentBackgroundTask.agentId),
465
    canReadOutputFile,
466
  },
467
}
468
```
469
470
`canReadOutputFile` is computed from whether the *calling* agent has Read/Bash
471
in its own pool — the result text changes depending on what the caller can do
472
with it. A teammate spawn is a third, private shape:
473
474
```ts
475
type TeammateSpawnedOutput = {
476
  status: 'teammate_spawned'
477
  prompt: string
478
  teammate_id: string
479
  agent_id: string
480
  agent_type?: string
481
  model?: string
482
  name: string
483
  color?: string
484
  tmux_session_name: string
485
  tmux_window_name: string
486
  tmux_pane_id: string
487
  team_name?: string
488
  is_splitpane?: boolean
489
  plan_mode_required?: boolean
490
}
491
```
492
493
### 5.3 Execution order
494
495
Reproducible sequence, in the order the file performs it:
496
497
1. Read the current permission context and app state.
498
2. Filter visible agent definitions by MCP requirements and agent permission
499
   rules; resolve `subagent_type` against that filtered set.
500
3. Mint a **stable agent id before anything runs**. The same id becomes the
501
   worktree slug, the task id, the progress `agentId`, the transcript path, and
502
   the notification target.
503
4. Build the worker's pool from the worker's own context:
504
505
```ts
506
const workerPermissionContext = {
507
  ...appState.toolPermissionContext,
508
  mode: selectedAgent.permissionMode ?? 'acceptEdits',
509
}
510
const workerTools = assembleToolPool(workerPermissionContext, appState.mcp.tools)
511
```
512
513
5. Enforce the recursion guards:
514
515
```ts
516
if (toolUseContext.options.querySource === `agent:builtin:${FORK_AGENT.agentType}`
517
    || isInForkChild(toolUseContext.messages)) {
518
  throw new Error('Fork is not available inside a forked worker. Complete your task directly using your tools.')
519
}
520
if (isTeammate() && teamName && name) {
521
  throw new Error('Teammates cannot spawn other teammates — the team roster is flat. To spawn a subagent instead, omit the `name` parameter.')
522
}
523
if (isInProcessTeammate() && teamName && run_in_background === true) {
524
  throw new Error('In-process teammates cannot spawn background agents. Use run_in_background=false for synchronous subagents.')
525
}
526
```
527
528
6. Register the task **before** execution starts (`registerAsyncAgent` for
529
   background, `registerAgentForeground` for synchronous). A foreground task is
530
   registered too, so it can be promoted to background later.
531
7. Run the child. A fork reuses the parent's system prompt, message list, and
532
   tool array verbatim (prompt-cache prefix); an ordinary child gets its own
533
   system prompt and a single user message.
534
8. Stream progress up (section 6).
535
9. Transition task state **before** slow cleanup — completion is reported, then
536
   worktrees and notifications are handled.
537
10. Worktrees are removed when clean and retained when they contain changes.
538
11. Emit the completion/failure/kill notification.
539
540
## 6. Progress aggregation (`tasks/LocalAgentTask/LocalAgentTask.tsx`)
541
542
### 6.1 Shapes
543
544
```ts
545
export type ToolActivity = {
546
  toolName: string
547
  input: Record<string, unknown>
548
  activityDescription?: string
549
  isSearch?: boolean
550
  isRead?: boolean
551
}
552
553
export type AgentProgress = {
554
  toolUseCount: number
555
  tokenCount: number
556
  lastActivity?: ToolActivity
557
  recentActivities?: ToolActivity[]
558
  summary?: string
559
}
560
561
export type ProgressTracker = {
562
  toolUseCount: number
563
  latestInputTokens: number
564
  cumulativeOutputTokens: number
565
  recentActivities: ToolActivity[]
566
}
567
568
export function getTokenCountFromTracker(tracker: ProgressTracker): number {
569
  return tracker.latestInputTokens + tracker.cumulativeOutputTokens
570
}
571
```
572
573
`recentActivities` is a ring bounded by `MAX_RECENT_ACTIVITIES = 5`: activities
574
are pushed on tool use, then `while (length > 5) shift()`, and
575
`lastActivity` is simply its final element. The token rule is the non-obvious
576
part: provider input-token usage is already
577
cumulative for the child's context, so the tracker keeps the **latest** input
578
count and **accumulates** output. Summing both would multiply-count the prompt.
579
580
### 6.2 Task state
581
582
```ts
583
export type LocalAgentTaskState = TaskStateBase & {
584
  type: 'local_agent'
585
  agentId: string
586
  prompt: string
587
  selectedAgent?: AgentDefinition
588
  agentType: string
589
  model?: string
590
  abortController?: AbortController
591
  unregisterCleanup?: () => void
592
  error?: string
593
  result?: AgentToolResult
594
  progress?: AgentProgress
595
  retrieved: boolean
596
  messages?: Message[]
597
  lastReportedToolCount: number
598
  lastReportedTokenCount: number
599
  isBackgrounded: boolean
600
  pendingMessages: string[]
601
  retain: boolean
602
  diskLoaded: boolean
603
  evictAfter?: number
604
}
605
```
606
607
with the shared base (`Task.ts`):
608
609
```ts
610
export type TaskStateBase = {
611
  id: string
612
  type: TaskType
613
  status: TaskStatus
614
  description: string
615
  toolUseId?: string
616
  startTime: number
617
  endTime?: number
618
  totalPausedMs?: number
619
  outputFile: string
620
  outputOffset: number
621
  notified: boolean
622
}
623
624
export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'killed'
625
export function isTerminalTaskStatus(status: TaskStatus): boolean {
626
  return status === 'completed' || status === 'failed' || status === 'killed'
627
}
628
```
629
630
### 6.3 Transitions
631
632
| function | effect |
633
| --- | --- |
634
| `registerAsyncAgent` | create `status: 'running'`, init disk output, install cleanup, register |
635
| `registerAgentForeground` | same, `isBackgrounded: false` |
636
| `backgroundAgentTask` | flip to background, resolve the background signal so execution transfers |
637
| `unregisterAgentForeground` | drop a foreground task that finished without backgrounding |
638
| `updateAgentProgress` | write counters + activities, preserving any existing `summary` |
639
| `updateAgentSummary` | write the short background summary |
640
| `completeAgentTask` | `completed`, store result, schedule eviction |
641
| `failAgentTask` | `failed`, store error, schedule eviction |
642
| `killAsyncAgent` | abort, `killed`, evict task output |
643
| `enqueueAgentNotification` | build the XML `<task-notification>` for the parent |
644
645
The registry itself (`utils/task/framework.ts`) is deliberately boring and has
646
two properties worth copying:
647
648
```ts
649
const updated = updater(task)
650
if (updated === task) {
651
  // Updater returned the same reference (early-return no-op). Skip the
652
  // spread so s.tasks subscribers don't re-render on unchanged state.
653
  return prev
654
}
655
```
656
657
and re-registration (resume) carries forward UI-held state — `retain`,
658
`startTime`, `messages`, `diskLoaded`, `pendingMessages` — so a resumed agent
659
does not jump position in the panel or lose the transcript already on screen.
660
Timing constants: `POLL_INTERVAL_MS = 1000`, `STOPPED_DISPLAY_MS = 3_000` (a
661
killed task stays visible for three seconds), `PANEL_GRACE_MS = 30_000`.
662
663
### 6.4 Notification
664
665
```ts
666
const summary =
667
  status === 'completed'
668
    ? `Agent "${description}" completed`
669
    : status === 'failed'
670
      ? `Agent "${description}" failed: ${error || 'Unknown error'}`
671
      : `Agent "${description}" was stopped`
672
```
673
674
The enqueued notification carries: task id, parent tool-use id, output file
675
path, status, summary, final result, total tokens, tool uses, duration, and
676
worktree path/branch. A background agent's completion is therefore a *message
677
into the parent's queue*, not a UI event.
678
679
### 6.5 Progress wire format
680
681
Events are emitted through the tool's `onProgress` with a synthetic tool-use id
682
derived from the child's first assistant message:
683
684
```ts
685
onProgress({
686
  toolUseID: `agent_${assistantMessage.message.id}`,
687
  data: { message: normalizedFirstMessage, type: 'agent_progress', prompt, agentId: syncAgentId },
688
})
689
// subsequent
690
onProgress({
691
  toolUseID: `agent_${assistantMessage.message.id}`,
692
  data: { message: m, type: 'agent_progress', prompt: '', agentId: syncAgentId },
693
})
694
```
695
696
`Progress` for this tool is a union: `type Progress = AgentToolProgress | ShellProgress`
697
— a child's shell output can be forwarded to the parent's row directly.
698
699
## 7. Result retrieval (`tools/TaskOutputTool/TaskOutputTool.tsx`)
700
701
```ts
702
const inputSchema = lazySchema(() => z.strictObject({
703
  task_id: z.string(),
704
  block: semanticBoolean(z.boolean().default(true)),
705
  timeout: z.number().min(0).max(600000).default(30000),
706
}))
707
708
type TaskOutputToolOutput = {
709
  retrieval_status: 'success' | 'timeout' | 'not_ready'
710
  task: TaskOutput | null
711
}
712
```
713
714
Behavior worth reproducing:
715
716
- Blocking mode polls the registry every `100ms` until the status leaves
717
  `running`/`pending`, honoring the abort signal, then returns the task; a
718
  timeout returns `retrieval_status: 'timeout'` **with the current state**
719
  rather than an error.
720
- Waiting emits its own progress event: `{ type: 'waiting_for_task', taskDescription, taskType }`.
721
- Retrieval marks `notified: true`.
722
- For a `local_agent`, the in-memory result beats the on-disk transcript:
723
724
> Prefer the clean final answer from the in-memory result over the raw JSONL
725
> transcript on disk. The disk output is a symlink to the full session
726
> transcript (every message, tool use, etc.), not just the subagent's answer.
727
728
- The tool is now documented as deprecated in favor of `Read` on the returned
729
  `outputFile` path. That is the direction of travel: **the transcript path is
730
  the API**, and the ad-hoc retrieval tool is scaffolding.
731
- Result serialization is tagged text, not JSON: `<retrieval_status>`,
732
  `<task_id>`, `<task_type>`, `<status>`, `<exit_code>`, `<output>`, `<error>`.
733
734
## 8. The fleet surfaces
735
736
Five distinct densities exist for the same underlying registry. Reproducing the
737
*set* matters more than reproducing any one of them.
738
739
### 8.1 One line per child (`components/AgentProgressLine.tsx`)
740
741
```ts
742
type Props = {
743
  agentType: string
744
  description?: string
745
  name?: string
746
  descriptionColor?: keyof Theme
747
  taskDescription?: string
748
  toolUseCount: number
749
  tokens: number | null
750
  color?: keyof Theme
751
  isLast: boolean
752
  isResolved: boolean
753
  isError: boolean
754
  isAsync?: boolean
755
  shouldAnimate: boolean
756
  lastToolInfo?: string | null
757
  hideType?: boolean
758
}
759
```
760
761
Status text is three cases and nothing more:
762
763
```ts
764
const isBackgrounded = isAsync && isResolved
765
if (!isResolved) return lastToolInfo || "Initializing…"
766
if (isBackgrounded) return taskDescription ?? "Running in the background"
767
return "Done"
768
```
769
770
Layout: `├─` for members, `└─` for the last; agent type or `@name`; description;
771
tool-use and token counts **while running only** (a resolved background agent
772
hides them); last tool activity as the live line. `hideType` suppresses the
773
repeated type label when every child shares a type.
774
775
### 8.2 Last activity, rendered by the tool that produced it (`components/tasks/renderToolActivity.tsx`)
776
777
```ts
778
export function renderToolActivity(activity: ToolActivity, tools: Tools, theme: ThemeName) {
779
  const tool = findToolByName(tools, activity.toolName)
780
  if (!tool) return activity.toolName
781
  try {
782
    const parsed = tool.inputSchema.safeParse(activity.input)
783
    const parsedInput = parsed.success ? parsed.data : {}
784
    const userFacingName = tool.userFacingName(parsedInput)
785
    if (!userFacingName) return activity.toolName
786
    const toolArgs = tool.renderToolUseMessage(parsedInput, { theme, verbose: false })
787
    if (toolArgs) return <Text>{userFacingName}({toolArgs})</Text>
788
    return userFacingName
789
  } catch {
790
    return activity.toolName
791
  }
792
}
793
```
794
795
This is the whole point of section 1 in 25 lines: a *child agent's* activity
796
line inside a *parent's* progress panel is rendered by the child's tool's own
797
renderer, at `verbose: false`, with `safeParse` and a `try/catch` so a
798
malformed or unknown tool degrades to its raw name instead of breaking the
799
frame.
800
801
### 8.3 Compact in-progress panel (`tools/AgentTool/UI.tsx`)
802
803
Progress events are validated, then consecutive read/search/REPL events are
804
collapsed into a synthetic summary row:
805
806
```ts
807
type SummaryMessage = {
808
  type: 'summary'
809
  searchCount: number
810
  readCount: number
811
  replCount: number
812
  uuid: string
813
  isActive: boolean
814
}
815
```
816
817
Counts increment on tool *results* only, so a use/result pair is not counted
818
twice. Then:
819
820
- no progress yet → `Initializing…`
821
- terminal too small → one row:
822
823
```tsx
824
<Text dimColor>
825
  In progress… · <Text bold>{toolUseCount}</Text> tool {toolUseCount === 1 ? 'use' : 'uses'}
826
  {tokens && ` · ${formatNumber(tokens)} tokens`} ·{' '}
827
  <ConfigurableShortcutHint action="app:toggleTranscript" context="Global" fallback="ctrl+o" description="expand" parens />
828
</Text>
829
```
830
831
- normal → last few processed messages, plus `+N more tool uses`
832
- transcript mode → all processed messages, plus the child's prompt and final
833
  response, rendered with the same static nested message renderers as the main
834
  transcript.
835
836
Result states get distinct renderings: `remote_launched` (remote task id +
837
session URL), `async_launched` (backgrounded line; prompt in transcript mode),
838
`completed` (usage + duration + optional prompt/transcript/response), rejected,
839
and error — the last two rendering the progress they had before the fallback
840
message. Completion summary:
841
842
```ts
843
const result = [
844
  totalToolUseCount === 1 ? '1 tool use' : `${totalToolUseCount} tool uses`,
845
  formatNumber(totalTokens) + ' tokens',
846
  formatDuration(totalDurationMs),
847
]
848
const completionMessage = `Done (${result.join(' · ')})`
849
```
850
851
### 8.4 Footer pill (`tasks/pillLabel.ts`, `components/tasks/BackgroundTaskStatus.tsx`)
852
853
The always-visible one-liner. `getPillLabel` reduces N tasks to a phrase, and
854
the same function feeds the turn-duration transcript line so the two surfaces
855
cannot disagree:
856
857
```ts
858
case 'local_agent':
859
  return n === 1 ? '1 local agent' : `${n} local agents`
860
case 'in_process_teammate': { /* count distinct teamName */ return teamCount === 1 ? '1 team' : `${teamCount} teams` }
861
case 'local_bash': /* "2 shells, 1 monitor" */
862
case 'remote_agent': return n === 1 ? `${DIAMOND_OPEN} 1 cloud session` : `${DIAMOND_OPEN} ${n} cloud sessions`
863
...
864
// mixed types
865
return `${n} background ${n === 1 ? 'task' : 'tasks'}`
866
```
867
868
`pillNeedsCta` restricts the dimmed `· ↓ to view` call-to-action to genuine
869
attention states only, so a merely-running fleet does not nag.
870
871
Which tasks count is a single predicate (`tasks/types.ts`):
872
873
```ts
874
export type TaskState =
875
  | LocalShellTaskState | LocalAgentTaskState | RemoteAgentTaskState
876
  | InProcessTeammateTaskState | LocalWorkflowTaskState | MonitorMcpTaskState | DreamTaskState
877
878
export function isBackgroundTask(task: TaskState): task is BackgroundTaskState {
879
  if (task.status !== 'running' && task.status !== 'pending') return false
880
  if ('isBackgrounded' in task && task.isBackgrounded === false) return false
881
  return true
882
}
883
```
884
885
When every running task is an addressable teammate, the pill becomes a
886
horizontally scrolled row of `@name` pills instead — `main` first, then
887
teammates sorted by name (idle last when not selected), with
888
`calculateHorizontalScrollWindow(pillWidths, availableWidth, 2, selectedIdx)`
889
producing `startIndex/endIndex/showLeftArrow/showRightArrow` and `←`/`→`
890
markers. Selection, hover, "currently viewed" (bold), and idle (dim) are four
891
separate visual states of the same pill.
892
893
### 8.5 Per-task row and detail dialog
894
895
`components/tasks/BackgroundTask.tsx` renders one row per task type, each
896
truncated to `maxActivityWidth` (default 40) with
897
`truncate(text, activityLimit, true)`, then a status fragment:
898
`TaskStatusText status label="done" suffix=", unread"` where the `, unread`
899
suffix comes from `status === 'completed' && !task.notified`. A teammate row is
900
`@name: activity`; a shell row is `command <ShellProgress>`; a workflow row
901
shows `N agents` while running.
902
903
`components/tasks/AsyncAgentDetailDialog.tsx` is the expansion: title
904
`agentType › description`, subtitle `status icon + elapsed + tokens + tools`
905
(from `agent.result?.totalTokens ?? agent.progress?.tokenCount`, likewise tool
906
count), then a **Progress** block listing `recentActivities` through
907
`renderToolActivity` with the last one marked `›` and undimmed, then the prompt
908
(clipped to 300 chars, or the extracted `<plan>` block if present), then the
909
error. Keys: `←` back, `Esc/Enter/Space` close, `x` stop while running.
910
911
## 9. Inferred mapping to OpenAgents Coder (proposal)
912
913
This section is inference. It names what to adopt, what to reject, and what has
914
no counterpart yet. The implementation sequencing lives in the companion
915
[port plan](./2026-08-23-openagents-coder-tui-agent-fleet-port-plan.md).
916
917
Adopt:
918
919
- **Per-tool renderer registry** keyed by tool name, over partial arguments,
920
  with a `verbose`/`expanded`/`width` option bag — the `renderToolActivity`
921
  degradation path (`safeParse`, `try/catch`, fall back to the raw name) is the
922
  contract that makes an open tool set safe to render.
923
- **A task registry separate from the transcript.** Fleet state is not derivable
924
  from a message list, and `cc` proves the two surfaces need separate stores
925
  with one shared id.
926
- **Stable child id minted before launch**, reused as task id, transcript path,
927
  and progress key.
928
- **`description` as a display-only, 3-5-word field** on the delegation
929
  contract.
930
- **Aggregate-then-render progress**: counters plus a bounded recent-activity
931
  ring, with the latest-input/cumulative-output token rule.
932
- **One collapse per density**: N children → one phrase; one child → one line;
933
  expanded → nested transcript.
934
- **Terminal-size-aware self-collapse** inside the renderer, not truncation
935
  imposed from outside.
936
- **Explicit background/foreground promotion** and `, unread` until retrieved.
937
- **Recursion guards as errors returned to the model**, with remediation in the
938
  message text.
939
940
Reject or defer:
941
942
- React/Ink component composition. The Coder interface is an ANSI row painter
943
  by necessity (OpenTUI's FFI is Bun-only), so renderers must return
944
  `ReadonlyArray<string>` of styled rows, not nodes. This is a real constraint,
945
  not a preference, and it changes the render contract's return type
946
  everywhere.
947
- `tmux`-backed teammates and panes.
948
- The eleven-method tool interface in full. Five methods carry most of the
949
  value: activity label, use row, progress rows, result rows, grouped rows.
950
- Ambient app state (`getAppState`/`setAppState` inside tools). OpenAgents
951
  should thread an explicit store handle or Effect service instead.
952
953
No counterpart yet, and therefore new work:
954
955
- A durable per-child transcript path the parent can `Read`.
956
- A completion notification that enters the parent's message queue.
957
- Concurrency and quota accounting, which `cc` mostly does not do and which
958
  transcript 275 requires (per-chat active computers, budgeted fan-out).
docs/teardowns/2026-08-23-openagents-coder-tui-agent-fleet-port-plan.md added +430

@@ -0,0 +1,430 @@

1
# OpenAgents Coder TUI: agent-fleet port plan — 2026-08-23
2
3
Target: `packages/openagents-cli` (`@openagentsinc/cli` 0.3.2), the
4
`openagents coder` interface.
5
6
Short-term goal, from the owner: extend the Coder interface so it can delegate
7
to many coding agents at once, in the shape described in
8
[`docs/transcripts/275.md`](../transcripts/275.md) — a purpose-built chat
9
console that consumes the OpenRouter-backed chat API first, then launches
10
several coding-agent harnesses on sandbox computers and drives them against the
11
issue backlog, with a 15-way fan-out as the proof.
12
13
Source of the mechanics: the companion
14
[cc tool and agent-fleet rendering reproduction spec](./2026-08-23-cc-tool-and-agent-fleet-rendering-reproduction.md).
15
Section references below (`§`) point into it. Prior art for the delegation data
16
model is
17
[Claude subagent histories](./2026-07-10-claude-subagents-rendering-analysis.md)
18
and [OpenAgents subagent design](./2026-07-10-openagents-subagents-design.md);
19
this plan does not restate them.
20
21
Status: proposal. No product code is changed by this document.
22
23
## TL.DR
24
25
The Coder interface already has the hard parts of a terminal application — a
26
snapshot/renderer split, differential painting, an absolute scroll anchor,
27
resize handling, expansion state, and a `--plain` twin that renders the same
28
snapshot. What it lacks is a **second state store**: everything on screen is
29
derived from one flat `entries` array, and a fleet of coding agents is not
30
expressible as a flat array of settled/unsettled text entries.
31
32
The port is therefore three layers, in this order:
33
34
1. A `CoderTask` registry beside the transcript, with a stable child id, a
35
   status machine, and an aggregated progress record.
36
2. Fleet rendering at four densities (footer phrase → per-agent line → detail →
37
   nested transcript), all reading the registry.
38
3. A per-tool renderer registry, which the delegation renderer is then just the
39
   first client of.
40
41
Doing 3 first is the tempting order and the wrong one: the delegation surfaces
42
need state the transcript cannot hold, and the tool-renderer interface should be
43
designed against a real second client rather than guessed at.
44
45
## 1. What exists today (observed)
46
47
### 1.1 The session (`src/coder-session.ts`)
48
49
The reply source is an async iterable of four chunk kinds:
50
51
```ts
52
export type ReplyChunk =
53
  | { readonly type: "text"; readonly value: string }
54
  | { readonly type: "reasoning"; readonly value: string }
55
  | { readonly type: "tool_call"; readonly callId: string; readonly name: string; readonly arguments: string }
56
  | { readonly type: "tool_result"; readonly callId: string; readonly output: string | undefined; readonly error: string | undefined }
57
```
58
59
The transcript is a flat array of entries:
60
61
```ts
62
export interface CoderEntry {
63
  readonly role: "you" | "assistant" | "notice" | "tool" | "reasoning"
64
  text: string
65
  settled: boolean
66
  readonly tool?: CoderToolCall
67
}
68
69
export interface CoderToolCall {
70
  readonly callId: string
71
  readonly name: string
72
  readonly arguments: string
73
  output: string | undefined
74
  error: string | undefined
75
  status: "running" | "succeeded" | "failed"
76
}
77
```
78
79
and the renderer contract is one immutable snapshot:
80
81
```ts
82
export interface CoderSnapshot {
83
  readonly entries: ReadonlyArray<CoderEntry>
84
  readonly running: boolean
85
  readonly repository: string
86
  readonly branch: string
87
  readonly model: string
88
  readonly turns: number
89
}
90
```
91
92
`CoderSession` owns: one in-flight turn (`controller`), listener fan-out
93
(`onChange`), notices, backend cycling (refused mid-turn, with a notice), and
94
the chunk→entry state machine, including the withdrawn opening entry, settling
95
the previous entry when the chunk kind changes, `[interrupted]` on abort, a
96
`notice` entry on failure, and — in `finally` — marking every unsettled entry
97
settled and any still-`running` tool call `failed`.
98
99
### 1.2 The reply source (`src/coder-chat-api.ts`)
100
101
`ChatApiReplySource` submits `POST /api/v3/chat/turns` and then **polls**
102
`GET /api/v3/chat/events`, which returns the conversation's whole log, tracking
103
the highest delivered `sequence` per `run_id`. It maps
104
`text_delta` / `reasoning_delta` / `tool_call_started` /
105
`tool_call_completed` / `tool_call_failed` / `response_completed` /
106
`response_failed` onto `ReplyChunk`, preferring the server's own `tool_call`
107
projection (pretty-printed `arguments`, extracted `output`, structured `error`)
108
over the raw payload. `POLL_INTERVAL_MS = 250`, `TURN_TIMEOUT_MS = 300_000`.
109
110
Two shipped-contract constraints are load-bearing for this plan: one
111
conversation per account (`DATA-002`) and **one active turn per conversation**
112
(`TURN-001`) — a second concurrent turn is refused with `turn_in_progress`
113
(HTTP 409).
114
115
### 1.3 The interface (`src/coder-ui.ts`)
116
117
ANSI only, no rendering dependency; OpenTUI is unusable here because its FFI is
118
Bun-only and the CLI ships as an npm package run on Node. Layout is transcript /
119
status / composer, `STATUS_ROWS = 1`, `COMPOSER_ROWS = 3`, `GUTTER = 9`.
120
121
Established mechanics worth keeping unchanged:
122
123
- **Differential paint**: only changed rows are rewritten; nothing emits a
124
  newline, clears the screen, or writes past the last row, so the terminal never
125
  scrolls and scrollback is never polluted. Resize clears `painted` entirely
126
  because every row was laid out for the old width.
127
- **Absolute scroll anchor**: `anchor` holds a line index, not a distance from
128
  the bottom, so a scrolled-up reader stays put while output arrives;
129
  `anchor === undefined` means "follow".
130
- **Alternate scroll mode** (`\x1b[?1007h`) instead of mouse reporting, so text
131
  selection keeps working.
132
- **Byte-level key parsing** with a 40 ms `ESCAPE_WINDOW_MS` so a lone `Esc`
133
  interrupts and an arrow key does not.
134
- **Hints give way, state does not**: `hints()` drops key hints from the end
135
  until the row fits rather than dropping the counter.
136
- Only reachable keys are advertised: `tab to switch model` appears only when
137
  `session.canCycleBackend && !running`; `ctrl+o to expand` only when a tool
138
  call exists.
139
- A one-second ticker redraws the status line while a turn runs, so elapsed time
140
  advances between chunks.
141
142
Current tool rendering is a single local function, three glyphs
143
(`◐` yellow / `✗` red / `✓` green), and two densities (collapsed: name +
144
clipped args + clipped outcome; expanded: full argument lines and full output
145
with a `→` marker on the first line). Expansion is `Set<string>` of call ids,
146
and `focusedTool` is "the newest tool call in the transcript".
147
148
### 1.4 Gap table
149
150
| Capability | `cc` | Coder today |
151
| --- | --- | --- |
152
| Tool render contract | per-tool, 11 methods, partial input (§1.2) | one function, `switch`-free but also extension-free |
153
| Compact vs full | separate renderer outputs (§1.2, §8.3) | `open` boolean over the same text |
154
| Grouped fan-out row | `renderGroupedToolUse` + grouping pass (§4) | none |
155
| Task registry | `AppState.tasks`, 7 task types (§6.2) | none; transcript only |
156
| Child agent progress | counters + activity ring + summary (§6.1) | none |
157
| Fleet densities | 5 (§8) | 0 |
158
| Background/foreground promotion | `backgroundAgentTask` + signal (§6.3) | none |
159
| Durable child transcript | disk output path returned in the result (§5.2) | none |
160
| Completion notification | XML into the parent's queue (§6.4) | none |
161
| Concurrency accounting | mostly absent | absent |
162
| Row memoization | prop-equality gate (§3) | differential paint only |
163
164
## 2. Proposed data model
165
166
New module `src/coder-tasks.ts`, deliberately independent of both the transcript
167
and the renderer, mirroring `cc`'s registry (§6) with OpenAgents vocabulary.
168
169
```ts
170
/** Stable, minted before launch; also the transcript filename and progress key. */
171
export type CoderTaskId = string
172
173
export type CoderTaskStatus = "pending" | "running" | "completed" | "failed" | "stopped"
174
175
export interface CoderToolActivity {
176
  readonly toolName: string
177
  readonly input: Readonly<Record<string, unknown>>
178
  readonly label: string | undefined
179
}
180
181
export interface CoderTaskProgress {
182
  readonly toolUseCount: number
183
  /** latestInputTokens + cumulativeOutputTokens; see §6.1 for why. */
184
  readonly tokenCount: number
185
  readonly lastActivity: CoderToolActivity | undefined
186
  readonly recentActivities: ReadonlyArray<CoderToolActivity>
187
  readonly summary: string | undefined
188
}
189
190
export interface CoderTask {
191
  readonly id: CoderTaskId
192
  /** Display-only, 3-5 words, supplied by whoever requested the delegation. */
193
  readonly description: string
194
  readonly prompt: string
195
  readonly agent: string
196
  readonly model: string | undefined
197
  readonly startedAt: number
198
  readonly endedAt: number | undefined
199
  readonly status: CoderTaskStatus
200
  readonly background: boolean
201
  /** True until the result has been read by the requester. */
202
  readonly unread: boolean
203
  readonly progress: CoderTaskProgress
204
  readonly transcriptPath: string | undefined
205
  readonly result: string | undefined
206
  readonly error: string | undefined
207
}
208
```
209
210
The snapshot grows one field, and only one:
211
212
```ts
213
export interface CoderSnapshot {
214
  // ...existing
215
  readonly tasks: ReadonlyArray<CoderTask>
216
}
217
```
218
219
Rules carried over from `cc`, each with its reason:
220
221
1. **Register before launch.** A child that fails to start must still be
222
   visible, with a reason.
223
2. **Mint the id first.** One id for task, transcript path, progress events, and
224
   completion notice.
225
3. **No-op updates return the same reference**, so `onChange` does not repaint
226
   an unchanged fleet.
227
4. **Terminal tasks linger.** Completed tasks stay listed while `unread`;
228
   stopped tasks stay ~3 s (`cc`'s `STOPPED_DISPLAY_MS`) so the reader sees the
229
   transition.
230
5. **Counters are aggregated on write**, never recomputed from an event log at
231
   paint time.
232
6. **`recentActivities` is bounded** (a small ring, `cc` keeps a handful) —
233
   an unbounded list is a memory leak with a 15-way fan-out.
234
235
## 3. Proposed delegation contract
236
237
The console asks for N children; the request is one shape whether one or fifteen
238
are wanted.
239
240
```ts
241
export interface CoderDelegationRequest {
242
  readonly description: string        // 3-5 words, display only
243
  readonly prompt: string
244
  readonly agent?: string             // harness/agent definition id
245
  readonly model?: string
246
  readonly background?: boolean       // default true for fan-out
247
  readonly isolation?: "worktree" | "computer"
248
  readonly cwd?: string
249
}
250
```
251
252
and each launch answers with either the synchronous result or the async handle
253
(`cc` §5.2):
254
255
```ts
256
export type CoderDelegationResult =
257
  | { readonly status: "completed"; readonly taskId: CoderTaskId; readonly result: string }
258
  | { readonly status: "launched"; readonly taskId: CoderTaskId; readonly transcriptPath: string }
259
  | { readonly status: "refused"; readonly reason: string; readonly code: string }
260
```
261
262
`refused` is a first-class outcome rather than a thrown error, because the
263
refusals are expected and enumerable: concurrency cap reached, quota exhausted,
264
no sandbox computer available, recursion guard, unknown agent.
265
266
Guards to implement before any fan-out ships, following `cc` §5.3 (each returns
267
`refused` with remediation text, so the requesting agent can adapt):
268
269
- a child cannot fan out again beyond a configured depth;
270
- a fan-out cannot exceed the active-computer cap;
271
- abort propagates parent→child only, never child→parent.
272
273
## 4. Proposed rendering
274
275
### 4.1 The renderer contract, in rows not nodes
276
277
Because the interface paints ANSI rows, `cc`'s node-returning renderers become
278
row-returning functions. This is the one structural change the port must make:
279
280
```ts
281
export interface RenderContext {
282
  readonly width: number
283
  readonly expanded: boolean
284
  readonly verbose: boolean
285
  readonly transcript: boolean
286
  /** So a renderer can collapse itself rather than be truncated from outside. */
287
  readonly rows: number
288
  readonly busyCalls: number
289
}
290
291
export interface ToolRenderer {
292
  readonly name: string
293
  /** One-line label for an activity list: `Read(src/foo.ts)`. */
294
  label(input: Readonly<Record<string, unknown>>): string
295
  use(input: Readonly<Record<string, unknown>>, ctx: RenderContext): ReadonlyArray<string>
296
  progress?(events: ReadonlyArray<unknown>, ctx: RenderContext): ReadonlyArray<string>
297
  result?(output: string | undefined, error: string | undefined, ctx: RenderContext): ReadonlyArray<string>
298
  group?(calls: ReadonlyArray<CoderToolCall>, ctx: RenderContext): ReadonlyArray<string> | undefined
299
}
300
```
301
302
Registry rules, all from `cc` §8.2:
303
304
- lookup by name; **a missing renderer falls back to today's generic rows**, so
305
  an unknown or MCP tool still draws;
306
- arguments are parsed defensively and a parse failure renders the raw name;
307
- every renderer call is wrapped so a throwing renderer cannot break the frame;
308
- renderers see partial arguments (the chat API's `tool_call_started` may carry
309
  incomplete `arguments`).
310
311
### 4.2 Four densities
312
313
**(a) Status line phrase.** One `getPillLabel`-equivalent (`cc` §8.4) folded
314
into the existing status row, beside `working…`/`ready`:
315
316
```
317
  ● working… (1m 04s · streaming)   ⣿ 4 agents · 1 done, 1 unread    repo · main · model
318
```
319
320
Reduce N tasks to one phrase (`3 agents`, `1 agent`), append terminal counts,
321
and show a `· ↓ to view` call-to-action **only** for attention states —
322
`cc`'s `pillNeedsCta` exists precisely to stop a running fleet from nagging.
323
324
**(b) Fleet block in the transcript.** One `AgentProgressLine` equivalent per
325
child (`cc` §8.1), as a single transcript entry so it scrolls with the
326
conversation it belongs to:
327
328
```
329
  agents   4 running · 1 done
330
           ├─ coder  fix flaky auth test    ◐ Bash(pnpm vitest run) · 14 tools · 8.2k
331
           ├─ coder  port grep renderer     ◐ Read(src/coder-ui.ts) · 9 tools · 4.1k
332
           ├─ coder  update teardown index  ✓ Done (6 tool uses · 3.4k tokens · 1m 12s)
333
           └─ coder  reconcile issue 812    ✗ failed: worktree dirty
334
```
335
336
Rules, all from `cc` §8.1: three status cases only
337
(`lastActivity || "Initializing…"` / background summary / `Done`); counts shown
338
while running and hidden once a background child resolves; `├─`/`└─`; suppress
339
the repeated agent-type column when every child shares a type.
340
341
**(c) Detail.** `Esc`-dismissable overlay or a focused expansion of one child
342
(`cc` §8.5): `agent › description`, elapsed + tokens + tools, a **Progress**
343
list of `recentActivities` with the newest marked `›` and undimmed, the prompt
344
clipped to ~300 characters, the error, and `x` to stop while running.
345
346
**(d) Nested transcript.** The child's durable transcript, rendered with the
347
same entry renderers as the parent (`cc` §8.3 transcript mode). This is the
348
first real consumer of `transcriptPath`.
349
350
### 4.3 Interaction, extending what exists
351
352
- `ctrl+o` keeps its meaning (expand the focused thing) but `focusedTool` grows
353
  into a focus cursor over `tool | task`, defaulting to the newest.
354
- Fleet navigation reuses the pill-window arithmetic when the fleet is wider
355
  than the row (`cc` §8.4's `calculateHorizontalScrollWindow` →
356
  `startIndex/endIndex/showLeftArrow/showRightArrow`).
357
- New keys are advertised only when reachable, matching the existing rule:
358
  `↓ to view agents` only with tasks present, `x to stop` only on a running
359
  focused task.
360
- The existing one-second ticker already covers per-agent elapsed time; the
361
  fleet block must be re-rendered on it, so fleet rows must be cheap.
362
363
### 4.4 Repaint discipline
364
365
Differential painting handles *what changed on screen*, not *what was rebuilt in
366
memory*. With 15 children emitting progress, rebuilding every transcript row per
367
event is the predictable regression. Port `cc`'s memo gate (§3) as a row cache
368
keyed per entry, invalidated on exactly: entry identity, width, expansion,
369
verbosity, transcript mode, `settled`, and — for fleet rows — the child's
370
`(status, toolUseCount, tokenCount, lastActivity)` tuple. Settled text entries
371
must never be re-wrapped.
372
373
## 5. Execution: where the children actually run
374
375
Transcript 275's sequence is chat API first, then harnesses on sandbox
376
computers. That splits cleanly into two independently shippable backends behind
377
`CoderDelegationRequest`:
378
379
1. **Local child processes** (worktree isolation, same machine). Shortest path
380
   to a real fleet on screen, no new service, and it exercises the whole
381
   registry/rendering stack. `cc`'s worktree rules apply: remove the worktree
382
   when clean, retain it when it has changes.
383
2. **Sandbox computers** (the transcript's Firecracker / GKE Agent Sandbox
384
   isolation tiers, GCS checkpoints, recoverable commands). Same registry, a
385
   different launcher, plus quota state the local path does not need.
386
387
The `TURN-001` single-active-turn constraint (§1.2) is the first architectural
388
question this plan cannot answer from the source: N concurrent children cannot
389
each hold the account's single chat turn. Three options, needing an owner or
390
server-side decision rather than a CLI workaround:
391
392
- children do not use `/api/v3/chat` at all (they are harnesses with their own
393
  provider path, and the console's chat turn stays the operator's);
394
- the server admits a delegation turn kind that does not occupy the
395
  conversation's active-turn slot;
396
- children write into distinct nested threads, which is exactly the missing
397
  primitive the forge-side nested-thread delegation audit identified (a
398
  `threads` ledger plus `thread.spawn`/`resume`/`cancel`/`complete`).
399
400
The third is the coherent long-run answer and is not a CLI change. **Option one
401
is the honest first slice**: build the registry and rendering against local
402
child processes, keep the console's own turn on the chat API, and do not claim
403
durable nested-thread receipts until the ledger exists.
404
405
## 6. Proposed sequence
406
407
| Slice | Contents | Verification |
408
| --- | --- | --- |
409
| 1 | `src/coder-tasks.ts`: registry, status machine, progress aggregation, no-op-identity updates, eviction timers. `tasks` added to `CoderSnapshot`. | unit tests over transitions and the token rule; `coder-session.test.ts` unchanged |
410
| 2 | Fleet rendering: status-line phrase + fleet block; row cache. Fed by a fake launcher. | snapshot-style row assertions at several widths, in the style of `coder-ui.test.ts` |
411
| 3 | Local child launcher: worktree isolation, durable transcript path, completion notice into the transcript, concurrency cap and `refused` outcomes. | end-to-end 3-way fan-out against a trivial task |
412
| 4 | Detail view + nested transcript + `x to stop` + focus cursor over tool/task. | interaction tests; `--plain` parity |
413
| 5 | `ToolRenderer` registry with the generic fallback; port 3-4 renderers (shell, read, edit, delegation). | per-renderer row tests; no behavior change for unknown tools |
414
| 6 | Grouped fan-out row (`group()`), consuming several sibling delegations in one turn. | grouping-pass tests |
415
| 7 | Sandbox-computer launcher, quota/active-computer accounting, 15-way fan-out proof. | the transcript's 15-way proof, plus quota-refusal paths |
416
417
Slices 1-4 need no server change and no new authority. Slice 7 needs the
418
sandbox-computer path and the concurrency policy, and must reconcile with
419
whatever answers §5's turn question.
420
421
## 7. Explicit non-goals
422
423
- No React, Ink, or OpenTUI dependency. The FFI constraint recorded in
424
  `coder-ui.ts` stands until OpenTUI ships an N-API entry point.
425
- No `tmux`-backed teammates or pane splitting.
426
- No new claim of durable delegation receipts before the nested-thread ledger
427
  exists. Until then, a Coder task is process-local state plus an on-disk
428
  transcript, and the interface should not imply otherwise.
429
- No copying of `cc` source. The reproduction spec exists so this can be
430
  implemented from behavior and shapes.
docs/teardowns/README.md modified +2

@@ -29,6 +29,8 @@ state, relay authority, broad credentials, and copied product policy.

29 29
30 30
| Teardown                                                                                                                           | Subject                                                                                                                                                                                                                                                                                                                                                                                                                      | Central finding                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
31 31
| ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
32
| [cc tool and agent-fleet rendering](./2026-08-23-cc-tool-and-agent-fleet-rendering-reproduction.md) | Reproduction-grade read of the commit-pinned `cc` import (`813c06acf`): the per-tool render contract over partial streamed input, deterministic tool-pool assembly, the message-row memo gate, the grouped-fan-out pass, `AgentTool` delegation input/output/execution order with its recursion guards, the `local_agent` task registry and progress aggregation, `TaskOutputTool` retrieval, and all five fleet densities from footer phrase to nested child transcript | A tool owns its own rendering at every density, and delegation state lives in a task registry rather than the transcript. The portable core is: per-tool renderers over partial input with a raw-name fallback, a stable child id minted before launch, aggregate-then-render progress (latest input tokens plus cumulative output), one collapse per density, renderer-side self-collapse on small terminals, and a prop-equality gate so settled rows never repaint |
33
| [OpenAgents Coder agent-fleet port plan](./2026-08-23-openagents-coder-tui-agent-fleet-port-plan.md) | `packages/openagents-cli` `openagents coder` as it stands (snapshot/renderer split, differential ANSI paint, absolute scroll anchor, chat-API polling source) against the fan-out console described in `docs/transcripts/275.md`, with a proposed `CoderTask` registry, row-returning tool-renderer contract, four fleet densities, and a seven-slice sequence | The interface already has the hard terminal mechanics and is missing a second state store: a coding-agent fleet is not expressible as a flat array of settled text entries. Build the task registry and fleet rendering first and the generic tool-renderer registry after, against a real second client. One question needs an owner or server answer before high fan-out: one active turn per conversation (`TURN-001`) means N children cannot each hold the account's chat turn, so the honest first slice runs local child processes and defers durable nested-thread receipts to the missing thread ledger |
32 34
| [Entire ecosystem](./2026-08-23-entire-ecosystem-teardown.md) | Commit-pinned `entireio` public GitHub origin (CLI `b1e086253`, git-sync `2a61c8c79`, skills `2f9a8758e`, 31 clones in `projects/entire/`): Git-hook session capture, `Entire-Checkpoint` trailers, git-branch vs git-refs checkpoint stores, `git-remote-entire` + EntireDB regional mirrors, git-sync pack relay, forgemark, entire-graph, redaction, probed 401 on unauthenticated Entire clone | Treat Entire as a session-evidence and git-mirror peer, not a forge and not a receipt store. Harvest the commit-to-checkpoint join key, ephemeral-versus-persistent split, ff-only per-ref logs, remote-to-remote pack relay, remote-helper admission, and forgemark against owned forge cells. Reject Git as the durable store for prompts, default-public checkpoints, unredacted shadow-branch snapshots, `entire://` as origin, and GitHub-as-authority semantics. PostgreSQL turn receipts, the forge WAL, and MirrorWatch stay the OpenAgents authorities |
33 35
| [Wanix and Apptron](./2026-08-13-wanix-apptron-teardown.md) | Commit-pinned `tractordev/wanix` (`6594fe376`, MIT, npm `0.4.0-beta` / tag `v0.4-preview`) and `tractordev/apptron` (`c4cb2dba2`, unlicensed, `0.7.0`): a Plan 9 bind-table OS compiled to Wasm (custom elements, gojs/WASI/js task drivers, v86 Linux, 9P + Duplex/CBOR, OPFS/IDBFS/HTTP-FS/R2-FS, VS Code `wanix:` workbench) and the local-first Alpine-in-the-browser product on top (Hanko, IDBFS↔R2 sync of `/project` `/home` `/public`, one shared Cloudflare `go-netstack` NIC, unauthenticated `tcp-*-*.apptron.dev` ingress, `binfmt_misc` Wasm → host `#task`) | Treat Wanix as a pattern donor and optional MIT library for namespace experiments; treat Apptron as a pattern donor only. Harvest bind-table composition, the image-versus-workspace-versus-home-versus-ingress split, HTTP-FS/R2-FS, 9P as a guest/host pipe, and Wasm-on-the-host-kernel. Reject v86-as-sandbox, the shared NIC, query tokens, hardcoded admins, vscode-web as a surface, and Apptron source (no license). Firecracker/GCE managed sandboxes and Omega/Zed stay the isolation and IDE authorities |
34 36
| [Macro workspace monorepo](./2026-08-10-macro-teardown.md) | Commit-pinned `macro-inc/macro` (`dd1eee50f`), the AGPLv3 all-in-one workspace (email/channels/docs/tasks/agents/calls/CRM) from a ~15-person team: 37 Rust services + 173 crates on Axum/async-graphql/SQLx/Kafka, hexagonal boundaries with `EntityAccessReceipt<T>` capabilities, a sealed Kafka-topic registry that generates infra and CI workflows from Rust, Loro CRDT docs on a Cloudflare Durable Object with AI agents as genuine CRDT peers, a Rust wasm/SQLite normalized GraphQL client cache, an rmcp stateless MCP server + FusionAuth DCR-faking auth broker, one SolidJS bundle across web/Tauri-iOS (desktop unshipped, Android absent), LiveKit Cloud calls, and an admittedly aspirational self-host path | Treat Macro as a pattern donor, never a code donor: AGPL means study everything, copy nothing. Adapt the typed-registry/generated-infra discipline, receipt-typed authorization, the doubly-indexed any-entity-mentions-any-entity graph, agents-as-CRDT-peers with reserved identity, and the stateless internal-toolset→MCP projection (with strictly stronger token scoping — Macro hands raw FusionAuth user tokens to MCP clients) into the Rust gateway and All Work seams. Its unshipped Tauri desktop confirms rather than challenges the Omega/Zed decision, and Sarah's self-hosted explicit-dispatch LiveKit plane stays strictly stronger than Macro's LiveKit Cloud 6h broad-grant tokens |

This page updates live while a promote is in flight · changelog