Delegate coding work to a fleet of child agents from the CLI

70be9da3af70 · Devin AI · · parent 2c5b45d3efc2

Delegate coding work to a fleet of child agents from the CLI

The terminal session could hold one conversation and nothing else, so
parallel coding work meant parallel terminals. Delegation adds a second
kind of work the console owns: child coding agents, each its own process
under a harness, tracked apart from the transcript because they outlive
the line that launched them and keep changing after it settles.

- coder-tasks.ts: a task registry with stable ids, per-child lifecycle,
  aggregated tool and token progress, the five most recent activities,
  unread badges, and group stop.
- coder-delegate.ts: the `/delegate [<n>x] <prompt>` grammar, an
  opencode harness that normalizes `--format json` events, and a fleet
  scheduler with a concurrency cap, a bounded queue, and a durable
  JSONL transcript per child.
- coder-fleet.ts: the rendering contract, shared by the ANSI interface
  and plain output.
- coder-session.ts: `/delegate` launches without spending a chat turn or
  blocking the next prompt; each child is reported as it lands.
- coder-ui.ts: a bounded fleet block above the status line, a fleet
  phrase on it, and ctrl+x to stop every running child.
- cli.ts: `openagents delegate` for headless fan-out, and
  `--child-model/--child-command/--child-config/--child-approve/--concurrency`
  on both commands. The CLI holds no provider credential; a harness
  config file reaches the child as OPENCODE_CONFIG.

Verified against real children: three opencode agents on gemini-3.7-flash
through Vertex Express, concurrency 2, each writing the requested file and
returning its result.

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

  • modified packages/openagents-cli/README.md
  • modified packages/openagents-cli/src/cli.ts
  • added packages/openagents-cli/src/coder-delegate.ts
  • added packages/openagents-cli/src/coder-fleet.ts
  • modified packages/openagents-cli/src/coder-session.ts
  • added packages/openagents-cli/src/coder-tasks.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • added packages/openagents-cli/test/coder-delegate.test.ts

Diff

8 files changed, +1991 -13

packages/openagents-cli/README.md modified +45

@@ -247,6 +247,51 @@ Git bundles through durable storage without retaining the complete bundle in

247 247
application memory. Use `--wait-timeout 0` to return after acceptance; the
248 248
server import continues.
249 249
250
## Delegate to child coding agents
251
252
One prompt, many child coding agents, each in its own process. A child runs
253
under a harness — `opencode` today — and the CLI reports what every one of them
254
did:
255
256
```sh
257
openagents delegate "Add a regression test for the retry path, then say done" \
258
  --agents 3 --concurrency 2 \
259
  --child-model vertex-express/gemini-3.7-flash \
260
  --child-config ~/.config/openagents/delegate.json \
261
  --child-approve
262
```
263
264
`--agents` is how many children run the prompt and `--concurrency` is how many
265
run at once; the rest queue, so a fan-out of thirty does not become thirty
266
processes. `--child-approve` lets a child use its tools without asking, which a
267
child needs because there is nobody to ask. Add `--json` for the task records
268
and outcomes as data. The exit code is non-zero when any child did not finish.
269
270
The same fleet is available inside the terminal session. Start `openagents
271
coder` with `--child-model` and type `/delegate [<n>x] <prompt>`:
272
273
```sh
274
openagents coder --child-model vertex-express/gemini-3.7-flash \
275
  --child-config ~/.config/openagents/delegate.json --child-approve
276
```
277
278
```text
279
/delegate 4x survey the package for dead exports
280
```
281
282
Delegating does not spend a chat turn and does not block the next prompt. The
283
interface lists each child, what tool it is running, its tool and token counts,
284
and its result, and `ctrl+x` stops every running child. Each child's raw
285
harness transcript is kept as JSONL under
286
`$TMPDIR/openagents-coder-delegations`.
287
288
The child model has no default, because a child spends money under a provider
289
account. `--child-model`, `--child-command`, and `--child-config` fall back to
290
`OPENAGENTS_DELEGATE_MODEL`, `OPENAGENTS_DELEGATE_COMMAND`, and
291
`OPENAGENTS_DELEGATE_CONFIG`. The CLI never reads or stores a provider
292
credential: `--child-config` names a harness configuration file, which the CLI
293
passes to the child as `OPENCODE_CONFIG` and nothing else.
294
250 295
## Manage issues
251 296
252 297
```sh
packages/openagents-cli/src/cli.ts modified +246 -3

@@ -15,8 +15,13 @@ import {

15 15
} from "./api-passthrough.js";
16 16
import { ApiTransport } from "./api-transport.js";
17 17
import { BrowserLauncher } from "./browser-launcher.js";
18
import type { DelegationOutcome } from "./coder-delegate.js";
19
import { DelegateFleet, describePrompt, OpencodeHarness } from "./coder-delegate.js";
20
import { fleetPlainLines } from "./coder-fleet.js";
18 21
import { runCoderPlain } from "./coder-plain.js";
22
import type { CoderDelegation } from "./coder-session.js";
19 23
import { CoderSession, DummyReplySource } from "./coder-session.js";
24
import { CoderTaskRegistry } from "./coder-tasks.js";
20 25
import { runCoderUi } from "./coder-ui.js";
21 26
import { backendIds } from "./coder-backends.js";
22 27
import { openThread, ThreadUnavailable } from "./coder-thread.js";

@@ -1437,6 +1442,78 @@ const coderRefusal = (origin: string, cause: unknown) => {

1437 1442
  });
1438 1443
};
1439 1444
1445
/**
1446
 * Delegation flags, shared by `coder` and `delegate`.
1447
 *
1448
 * The child model is what turns delegation on. There is no default: a child
1449
 * spends money under a provider account, and a console that silently picked a
1450
 * model would spend it fifteen times over on a prompt the reader thought was
1451
 * going nowhere. Each flag falls back to an environment variable so a fleet can
1452
 * be configured once for a shell rather than typed on every launch.
1453
 */
1454
const childModelFlag = Flag.string("child-model").pipe(
1455
  Flag.optional,
1456
  Flag.withDescription(
1457
    "The model delegated children run, as `provider/model`. Defaults to " +
1458
      "OPENAGENTS_DELEGATE_MODEL. Delegation is unavailable without one",
1459
  ),
1460
);
1461
const childCommandFlag = Flag.string("child-command").pipe(
1462
  Flag.optional,
1463
  Flag.withDescription(
1464
    "The harness that runs a child. Defaults to OPENAGENTS_DELEGATE_COMMAND, or `opencode`",
1465
  ),
1466
);
1467
const childConfigFlag = Flag.string("child-config").pipe(
1468
  Flag.optional,
1469
  Flag.withDescription(
1470
    "A harness config file for children, passed as OPENCODE_CONFIG. This is how a " +
1471
      "provider credential reaches a child without being stored by the CLI",
1472
  ),
1473
);
1474
const childApproveFlag = Flag.boolean("child-approve").pipe(
1475
  Flag.withDescription(
1476
    "Let children use their tools without asking. A delegated child has nobody to " +
1477
      "ask, so a child that must edit files needs this",
1478
  ),
1479
);
1480
const concurrencyFlag = Flag.integer("concurrency").pipe(
1481
  Flag.withDefault(4),
1482
  Flag.withDescription("How many children may run at once. The rest queue"),
1483
);
1484
1485
/**
1486
 * Assemble delegation, or nothing when no child model was named.
1487
 *
1488
 * Returning undefined rather than throwing is what lets `coder` open without a
1489
 * child model and say so when `/delegate` is typed, instead of refusing to
1490
 * start over a feature the reader may not use.
1491
 */
1492
function buildDelegation(options: {
1493
  readonly model: string | undefined;
1494
  readonly command: string | undefined;
1495
  readonly configPath: string | undefined;
1496
  readonly autoApprove: boolean;
1497
  readonly concurrency: number;
1498
  readonly cwd: string;
1499
}): CoderDelegation | undefined {
1500
  const model = options.model ?? process.env["OPENAGENTS_DELEGATE_MODEL"];
1501
  if (model === undefined || model.trim().length === 0) return undefined;
1502
1503
  const harness = new OpencodeHarness({
1504
    model,
1505
    command: options.command ?? process.env["OPENAGENTS_DELEGATE_COMMAND"],
1506
    configPath: options.configPath ?? process.env["OPENAGENTS_DELEGATE_CONFIG"],
1507
    autoApprove: options.autoApprove,
1508
  });
1509
  const registry = new CoderTaskRegistry();
1510
  const fleet = new DelegateFleet(registry, harness, {
1511
    maxConcurrent: Math.max(1, options.concurrency),
1512
    cwd: options.cwd,
1513
  });
1514
  return { registry, fleet, label: `${harness.agent} (${model})` };
1515
}
1516
1440 1517
const coderCommand = Command.make(
1441 1518
  "coder",
1442 1519
  {

@@ -1445,8 +1522,24 @@ const coderCommand = Command.make(

1445 1522
    offline: coderOfflineFlag,
1446 1523
    reasoning: coderReasoningFlag,
1447 1524
    model: coderModelFlag,
1525
    childModel: childModelFlag,
1526
    childCommand: childCommandFlag,
1527
    childConfig: childConfigFlag,
1528
    childApprove: childApproveFlag,
1529
    concurrency: concurrencyFlag,
1448 1530
  },
1449
  ({ prompt, plain, offline, reasoning, model }) =>
1531
  ({
1532
    prompt,
1533
    plain,
1534
    offline,
1535
    reasoning,
1536
    model,
1537
    childModel,
1538
    childCommand,
1539
    childConfig,
1540
    childApprove,
1541
    concurrency,
1542
  }) =>
1450 1543
    Effect.gen(function* () {
1451 1544
      const flags = yield* rootCommand;
1452 1545
      const terminal = yield* TerminalSession;

@@ -1482,7 +1575,16 @@ const coderCommand = Command.make(

1482 1575
        : undefined;
1483 1576
1484 1577
      const source = thread ?? new DummyReplySource();
1485
      const session = new CoderSession(source, workspace.repository, workspace.branch);
1578
      const delegation = buildDelegation({
1579
        model: Option.getOrUndefined(childModel),
1580
        command: Option.getOrUndefined(childCommand),
1581
        configPath: Option.getOrUndefined(childConfig),
1582
        autoApprove: childApprove,
1583
        concurrency,
1584
        cwd: process.cwd(),
1585
      });
1586
1587
      const session = new CoderSession(source, workspace.repository, workspace.branch, delegation);
1486 1588
1487 1589
      if (Option.isNone(stored) && !offline) {
1488 1590
        session.notice(

@@ -1529,10 +1631,150 @@ const coderCommand = Command.make(

1529 1631
    }),
1530 1632
).pipe(
1531 1633
  Command.withDescription(
1532
    "Open a terminal coding session on a thread of its own. Replies come from the thread's grant through the inference proxy, so nothing typed here reaches /chat; --offline answers from a built-in stand-in instead",
1634
    "Open a terminal coding session on a thread of its own. Replies come from the thread's grant through the inference proxy, so nothing typed here reaches /chat; --offline answers from a built-in stand-in instead. With --child-model, `/delegate [<n>x] <prompt>` runs child coding agents and the interface shows the fleet",
1533 1635
  ),
1534 1636
);
1535 1637
1638
const delegatePrompt = Argument.string("prompt").pipe(
1639
  Argument.withDescription("The task every child performs"),
1640
);
1641
const delegateAgentsFlag = Flag.integer("agents").pipe(
1642
  Flag.withDefault(1),
1643
  Flag.withDescription("How many children run this prompt"),
1644
);
1645
const delegateDirFlag = Flag.string("dir").pipe(
1646
  Flag.optional,
1647
  Flag.withDescription("Where children work. Defaults to the current directory"),
1648
);
1649
const delegateDescriptionFlag = Flag.string("description").pipe(
1650
  Flag.optional,
1651
  Flag.withDescription("Three to five words naming the task. Defaults to the start of the prompt"),
1652
);
1653
1654
/**
1655
 * Run a fleet of children from the shell and report every one of them.
1656
 *
1657
 * This is the headless half of delegation, and it exists for the same reason
1658
 * `--plain` does: a fan-out has to be runnable where there is no terminal to
1659
 * draw into — a script, a CI job, another agent. It prints transitions as they
1660
 * happen rather than a repainted table, because a log that overwrites itself is
1661
 * unreadable once it is a file.
1662
 */
1663
const delegateCommand = Command.make(
1664
  "delegate",
1665
  {
1666
    prompt: delegatePrompt,
1667
    agents: delegateAgentsFlag,
1668
    dir: delegateDirFlag,
1669
    description: delegateDescriptionFlag,
1670
    childModel: childModelFlag,
1671
    childCommand: childCommandFlag,
1672
    childConfig: childConfigFlag,
1673
    childApprove: childApproveFlag,
1674
    concurrency: concurrencyFlag,
1675
  },
1676
  ({
1677
    prompt,
1678
    agents,
1679
    dir,
1680
    description,
1681
    childModel,
1682
    childCommand,
1683
    childConfig,
1684
    childApprove,
1685
    concurrency,
1686
  }) =>
1687
    Effect.gen(function* () {
1688
      const flags = yield* rootCommand;
1689
      const cwd = Option.getOrUndefined(dir) ?? process.cwd();
1690
      const delegation = buildDelegation({
1691
        model: Option.getOrUndefined(childModel),
1692
        command: Option.getOrUndefined(childCommand),
1693
        configPath: Option.getOrUndefined(childConfig),
1694
        autoApprove: childApprove,
1695
        concurrency,
1696
        cwd,
1697
      });
1698
1699
      if (delegation === undefined) {
1700
        return yield* new InputError({
1701
          message:
1702
            "No child model. Pass --child-model provider/model or set " +
1703
            "OPENAGENTS_DELEGATE_MODEL.",
1704
        });
1705
      }
1706
1707
      const count = Math.max(1, agents);
1708
      const label = Option.getOrUndefined(description) ?? describePrompt(prompt);
1709
      const registry = delegation.registry;
1710
1711
      const outcomes = yield* Effect.promise(async () => {
1712
        // Transitions only. A child that is working reports through its task,
1713
        // and reprinting every progress update would bury the four lines that
1714
        // say what happened.
1715
        const seen = new Map<string, string>();
1716
        const unsubscribe = flags.json
1717
          ? () => {}
1718
          : registry.onChange(() => {
1719
              for (const task of registry.list()) {
1720
                if (seen.get(task.id) === task.status) continue;
1721
                seen.set(task.id, task.status);
1722
                process.stderr.write(`${task.id} ${task.status} · ${task.description}\n`);
1723
              }
1724
            });
1725
1726
        try {
1727
          return await Promise.all(
1728
            Array.from({ length: count }, () =>
1729
              delegation.fleet.submit({ description: label, prompt, cwd, background: false }),
1730
            ),
1731
          );
1732
        } finally {
1733
          unsubscribe();
1734
        }
1735
      });
1736
1737
      if (flags.json) {
1738
        return yield* Console.log(
1739
          JSON.stringify(
1740
            {
1741
              agent: delegation.label,
1742
              cwd,
1743
              tasks: registry.list(),
1744
              outcomes,
1745
            },
1746
            null,
1747
            2,
1748
          ),
1749
        );
1750
      }
1751
1752
      for (const line of fleetPlainLines(registry.list(), 100)) {
1753
        yield* Console.log(line);
1754
      }
1755
      for (const outcome of outcomes) {
1756
        yield* Console.log("");
1757
        yield* Console.log(describeOutcome(outcome));
1758
      }
1759
      // A fleet where a child failed did not succeed, and a script that reads
1760
      // only the exit code has to be told.
1761
      if (outcomes.some((outcome) => outcome.status !== "completed")) {
1762
        process.exitCode = 1;
1763
      }
1764
    }),
1765
).pipe(
1766
  Command.withDescription(
1767
    "Run one prompt on many child coding agents at once and report each result",
1768
  ),
1769
);
1770
1771
function describeOutcome(outcome: DelegationOutcome): string {
1772
  if (outcome.status === "completed") return `${outcome.taskId} completed:\n${outcome.result}`;
1773
  if (outcome.status === "failed") return `${outcome.taskId} failed: ${outcome.error}`;
1774
  if (outcome.status === "stopped") return `${outcome.taskId} stopped.`;
1775
  return `refused (${outcome.code}): ${outcome.reason}`;
1776
}
1777
1536 1778
// The forum commands and their client were published in the CLI but their
1537 1779
// source was never committed. Both are reconstructed from the compiled
1538 1780
// artifacts of that build; see `forum-client.ts` and issue #153.

@@ -2640,6 +2882,7 @@ export const openagentsCommand = rootCommand.pipe(

2640 2882
    apiCommand,
2641 2883
    authCommand,
2642 2884
    coderCommand,
2885
    delegateCommand,
2643 2886
    computerCommand,
2644 2887
    forumCommand,
2645 2888
    issueCommand,
packages/openagents-cli/src/coder-delegate.ts added +560

@@ -0,0 +1,560 @@

1
/**
2
 * Delegation for `openagents coder`: run many coding agents at once.
3
 *
4
 * One console cannot do fifteen things, so it hands each of them to a child
5
 * coding agent and watches all of them. This module is the seam between the two
6
 * halves that makes that safe to build on:
7
 *
8
 * - A `DelegateHarness` is anything that can run a prompt to completion while
9
 *   reporting normalized events. `OpencodeHarness` is the first one, and it
10
 *   drives the `opencode` CLI in its JSON event mode. A fake harness in the
11
 *   tests drives the same interface, so scheduling, cancellation, and rendering
12
 *   are testable without a model.
13
 * - A `DelegateFleet` owns the concurrency cap, the queue, and the writes into
14
 *   `CoderTaskRegistry`. Nothing else starts children.
15
 *
16
 * Three decisions here are worth the words:
17
 *
18
 * A refusal is a result, not an exception. Hitting the cap, an unknown harness,
19
 * and a missing worktree are all ordinary outcomes of asking for massive
20
 * fan-out, and a caller that has to catch exceptions to find out cannot report
21
 * them per child.
22
 *
23
 * The child's raw event stream is written to a file as it arrives. That file is
24
 * the child's transcript, and it exists whether or not anyone was watching,
25
 * which is the difference between a fleet you can review afterwards and a fleet
26
 * that only existed on screen.
27
 *
28
 * The parser is a pure function over one line. A harness that changes its event
29
 * shape then breaks one small tested function rather than the scheduler.
30
 */
31
32
import { spawn } from "node:child_process";
33
import { createWriteStream, mkdirSync } from "node:fs";
34
import { tmpdir } from "node:os";
35
import { join } from "node:path";
36
import type { CoderTaskId, CoderTaskRegistry, CoderToolActivity } from "./coder-tasks.js";
37
38
/** What the console asks for. One shape whether it wants one child or fifteen. */
39
export interface DelegationRequest {
40
  /** Three to five words. This is what every compact surface shows. */
41
  readonly description: string;
42
  readonly prompt: string;
43
  /** Where the child works. Defaults to the console's own directory. */
44
  readonly cwd?: string | undefined;
45
  /** False to await the child inline; true to leave it running. */
46
  readonly background?: boolean | undefined;
47
}
48
49
/**
50
 * What a launch produced.
51
 *
52
 * `refused` carries a stable code so a caller can react to the reason rather
53
 * than to prose, and text so a person or a model reading it knows what to do
54
 * instead.
55
 */
56
export type DelegationOutcome =
57
  | { readonly status: "completed"; readonly taskId: CoderTaskId; readonly result: string }
58
  | { readonly status: "failed"; readonly taskId: CoderTaskId; readonly error: string }
59
  | { readonly status: "stopped"; readonly taskId: CoderTaskId }
60
  | { readonly status: "refused"; readonly code: RefusalCode; readonly reason: string };
61
62
export type RefusalCode = "fleet_full" | "empty_prompt" | "harness_unavailable";
63
64
/** A `/delegate` line the console typed, once understood. */
65
export interface DelegateCommand {
66
  /** How many children to launch with this prompt. */
67
  readonly count: number;
68
  readonly prompt: string;
69
  readonly description: string;
70
}
71
72
/** How many children one `/delegate` line may ask for. */
73
export const MAX_DELEGATE_COUNT = 32;
74
75
/**
76
 * Read a `/delegate` line.
77
 *
78
 * The grammar is `/delegate [<n>x] <prompt>`, so `/delegate 4x add tests to the
79
 * parser` launches four children on the same prompt. The count is a separate
80
 * leading token rather than a flag because the console is a chat box, not a
81
 * shell, and `--agents 4` in the middle of an English sentence reads as part of
82
 * the prompt.
83
 *
84
 * Returns undefined for anything that is not a delegate line, so an ordinary
85
 * prompt is unaffected.
86
 */
87
export function parseDelegateCommand(text: string): DelegateCommand | undefined {
88
  const match = /^\/delegate(?:\s+([\s\S]*))?$/.exec(text.trim());
89
  if (match === null) return undefined;
90
91
  let rest = (match[1] ?? "").trim();
92
  let count = 1;
93
  const fanout = /^(\d{1,3})x\s+([\s\S]+)$/.exec(rest);
94
  if (fanout !== null) {
95
    count = Math.min(MAX_DELEGATE_COUNT, Math.max(1, Number(fanout[1])));
96
    rest = (fanout[2] ?? "").trim();
97
  }
98
99
  return { count, prompt: rest, description: describePrompt(rest) };
100
}
101
102
/**
103
 * A short label for a prompt.
104
 *
105
 * Every compact surface shows this and nothing else, so it has to be short
106
 * enough to sit in a column: the first few words, which is what a person would
107
 * have typed as a title anyway.
108
 */
109
export function describePrompt(prompt: string): string {
110
  const words = prompt.replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
111
  if (words.length === 0) return "delegated task";
112
  return words.slice(0, 5).join(" ");
113
}
114
115
/** A child's activity, normalized away from any one harness's event shape. */
116
export type DelegateEvent =
117
  | { readonly type: "session"; readonly sessionId: string }
118
  | {
119
      readonly type: "tool";
120
      readonly callId: string;
121
      readonly name: string;
122
      readonly target: string | undefined;
123
    }
124
  | { readonly type: "text"; readonly value: string }
125
  | { readonly type: "tokens"; readonly input: number; readonly output: number }
126
  | { readonly type: "error"; readonly message: string };
127
128
/** What the fleet needs from a way of running children. */
129
export interface DelegateHarness {
130
  /** Shown in the fleet block, for example `opencode`. */
131
  readonly agent: string;
132
  /** The child model, shown beside the agent. */
133
  readonly model: string;
134
  /**
135
   * Run `prompt` in `cwd`, yielding events as they happen.
136
   *
137
   * The harness must return when the child is done, throw when it could not be
138
   * run, and stop promptly when `signal` aborts. Everything else — retries,
139
   * permissions, provider credentials — is the harness's business.
140
   */
141
  run(
142
    input: { readonly prompt: string; readonly cwd: string; readonly transcriptPath: string },
143
    signal: AbortSignal,
144
  ): AsyncIterable<DelegateEvent>;
145
}
146
147
/**
148
 * Read one line of `opencode run --format json` output.
149
 *
150
 * Returns undefined for blank lines, for lines that are not JSON, and for event
151
 * kinds the fleet does not track. A harness that adds an event kind must not be
152
 * able to stop a fleet, so anything unrecognized is dropped rather than raised.
153
 */
154
export function parseOpencodeEvent(line: string): DelegateEvent | undefined {
155
  const trimmed = line.trim();
156
  if (trimmed.length === 0 || !trimmed.startsWith("{")) return undefined;
157
158
  let event: Record<string, unknown>;
159
  try {
160
    event = JSON.parse(trimmed) as Record<string, unknown>;
161
  } catch {
162
    return undefined;
163
  }
164
165
  const type = typeof event["type"] === "string" ? (event["type"] as string) : undefined;
166
  const part = isRecord(event["part"]) ? event["part"] : undefined;
167
168
  if (type === "tool_use" && part !== undefined) {
169
    const name = stringField(part, "tool") ?? "tool";
170
    const callId = stringField(part, "callID") ?? `${name}-${String(event["timestamp"] ?? "")}`;
171
    const state = isRecord(part["state"]) ? part["state"] : undefined;
172
    return { type: "tool", callId, name, target: toolTarget(state) };
173
  }
174
175
  if (type === "text" && part !== undefined) {
176
    const value = stringField(part, "text");
177
    return value === undefined ? undefined : { type: "text", value };
178
  }
179
180
  if (type === "step_finish" && part !== undefined) {
181
    const tokens = isRecord(part["tokens"]) ? part["tokens"] : undefined;
182
    if (tokens === undefined) return undefined;
183
    return {
184
      type: "tokens",
185
      input: numberField(tokens, "input") ?? 0,
186
      output: numberField(tokens, "output") ?? 0,
187
    };
188
  }
189
190
  if (type === "error") {
191
    const message =
192
      stringField(event, "message") ??
193
      (isRecord(event["error"]) ? stringField(event["error"], "message") : undefined) ??
194
      "The child agent reported an error.";
195
    return { type: "error", message };
196
  }
197
198
  const sessionId = stringField(event, "sessionID");
199
  if (sessionId !== undefined && type === "step_start") {
200
    return { type: "session", sessionId };
201
  }
202
203
  return undefined;
204
}
205
206
/**
207
 * What the child was working on.
208
 *
209
 * The harness's own title is preferred because it is what the harness chose to
210
 * show; the input fields are a fallback for tools that set no title. A row that
211
 * says only `bash` is much less use than one that says the command.
212
 */
213
function toolTarget(state: Record<string, unknown> | undefined): string | undefined {
214
  if (state === undefined) return undefined;
215
  const title = stringField(state, "title");
216
  if (title !== undefined && title.length > 0) return title;
217
  const input = isRecord(state["input"]) ? state["input"] : undefined;
218
  if (input === undefined) return undefined;
219
  for (const key of ["filePath", "path", "command", "pattern", "query", "description", "url"]) {
220
    const value = stringField(input, key);
221
    if (value !== undefined && value.length > 0) return value;
222
  }
223
  return undefined;
224
}
225
226
export interface OpencodeHarnessOptions {
227
  /** `provider/model`, for example `vertex-express/gemini-3.7-flash`. */
228
  readonly model: string;
229
  /** Defaults to `opencode` on the path. */
230
  readonly command?: string | undefined;
231
  /**
232
   * A config file for the child, passed as `OPENCODE_CONFIG`.
233
   *
234
   * This is how a provider the harness is not configured for is supplied
235
   * without writing a key into the repository: the caller writes a config to a
236
   * private path and names it here.
237
   */
238
  readonly configPath?: string | undefined;
239
  /**
240
   * Approve the child's tool use without asking.
241
   *
242
   * A delegated child has nobody to ask, so a coding task that has to edit a
243
   * file or run a command needs this. It is off by default because the
244
   * decision belongs to whoever launches the fleet, and it should be paired
245
   * with worktree or sandbox isolation.
246
   */
247
  readonly autoApprove?: boolean | undefined;
248
  readonly env?: Readonly<Record<string, string | undefined>> | undefined;
249
}
250
251
/** Runs children as `opencode run --format json` subprocesses. */
252
export class OpencodeHarness implements DelegateHarness {
253
  readonly agent = "opencode";
254
  readonly model: string;
255
256
  constructor(private readonly options: OpencodeHarnessOptions) {
257
    this.model = options.model;
258
  }
259
260
  async *run(
261
    input: { readonly prompt: string; readonly cwd: string; readonly transcriptPath: string },
262
    signal: AbortSignal,
263
  ): AsyncIterable<DelegateEvent> {
264
    const command = this.options.command ?? "opencode";
265
    const args = ["run", "--format", "json", "--model", this.model, "--dir", input.cwd];
266
    if (this.options.autoApprove === true) args.push("--auto");
267
    args.push(input.prompt);
268
269
    const child = spawn(command, args, {
270
      cwd: input.cwd,
271
      env: {
272
        ...process.env,
273
        ...this.options.env,
274
        ...(this.options.configPath === undefined
275
          ? {}
276
          : { OPENCODE_CONFIG: this.options.configPath }),
277
      },
278
      stdio: ["ignore", "pipe", "pipe"],
279
    });
280
281
    // The transcript is written as the events arrive, not at the end, so a
282
    // child that is killed still leaves everything it had done behind.
283
    const transcript = createWriteStream(input.transcriptPath, { flags: "a" });
284
285
    const queue: DelegateEvent[] = [];
286
    let notify: (() => void) | undefined;
287
    const wake = () => {
288
      notify?.();
289
      notify = undefined;
290
    };
291
292
    let stderr = "";
293
    let exited = false;
294
    let failure: string | undefined;
295
296
    const onAbort = () => {
297
      child.kill("SIGTERM");
298
    };
299
    signal.addEventListener("abort", onAbort, { once: true });
300
301
    let pending = "";
302
    child.stdout.setEncoding("utf8");
303
    child.stdout.on("data", (chunk: string) => {
304
      transcript.write(chunk);
305
      pending += chunk;
306
      let newline = pending.indexOf("\n");
307
      while (newline >= 0) {
308
        const line = pending.slice(0, newline);
309
        pending = pending.slice(newline + 1);
310
        const event = parseOpencodeEvent(line);
311
        if (event !== undefined) queue.push(event);
312
        newline = pending.indexOf("\n");
313
      }
314
      wake();
315
    });
316
317
    child.stderr.setEncoding("utf8");
318
    child.stderr.on("data", (chunk: string) => {
319
      // Kept but not streamed: the harness prints progress noise here, and only
320
      // the tail matters, and only when the child failed.
321
      stderr = `${stderr}${chunk}`.slice(-4000);
322
    });
323
324
    child.on("error", (cause: Error) => {
325
      failure =
326
        (cause as NodeJS.ErrnoException).code === "ENOENT"
327
          ? `The \`${command}\` harness is not on the path.`
328
          : cause.message;
329
      exited = true;
330
      wake();
331
    });
332
333
    child.on("close", (code) => {
334
      const trailing = parseOpencodeEvent(pending);
335
      if (trailing !== undefined) queue.push(trailing);
336
      if (failure === undefined && code !== 0 && !signal.aborted) {
337
        failure = describeExit(code, stderr);
338
      }
339
      exited = true;
340
      wake();
341
    });
342
343
    try {
344
      while (true) {
345
        while (queue.length > 0) {
346
          const event = queue.shift();
347
          if (event !== undefined) yield event;
348
        }
349
        if (exited) break;
350
        await new Promise<void>((resolve) => {
351
          notify = resolve;
352
        });
353
      }
354
    } finally {
355
      signal.removeEventListener("abort", onAbort);
356
      transcript.end();
357
    }
358
359
    if (failure !== undefined) throw new Error(failure);
360
  }
361
}
362
363
function describeExit(code: number | null, stderr: string): string {
364
  const tail = stderr
365
    .split("\n")
366
    .map((line) => stripAnsi(line).trim())
367
    .filter((line) => line.length > 0)
368
    .slice(-2)
369
    .join(" ");
370
  const exit = code === null ? "was killed" : `exited with code ${code}`;
371
  return tail.length > 0 ? `The child ${exit}: ${tail}` : `The child ${exit}.`;
372
}
373
374
function stripAnsi(text: string): string {
375
  // eslint-disable-next-line no-control-regex
376
  return text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
377
}
378
379
export interface DelegateFleetOptions {
380
  /**
381
   * How many children may run at once.
382
   *
383
   * A cap is not a nicety. Each child is a provider client and a process, so
384
   * an uncapped fan-out is rate-limit errors and a machine that stops
385
   * responding, and both of those look like the fleet not working.
386
   */
387
  readonly maxConcurrent: number;
388
  /** How many may wait for a slot before further requests are refused. */
389
  readonly maxQueued?: number | undefined;
390
  /** Where child transcripts go. Defaults to a private directory under tmp. */
391
  readonly transcriptDirectory?: string | undefined;
392
  /** The console's own directory, used when a request names none. */
393
  readonly cwd?: string | undefined;
394
}
395
396
/**
397
 * The scheduler: the only thing that starts children.
398
 *
399
 * `submit` resolves when that child reaches a terminal state, so a caller can
400
 * await one child, await `Promise.all` of many, or ignore the promise for a
401
 * background child and read the registry instead.
402
 */
403
export class DelegateFleet {
404
  private readonly transcriptDirectory: string;
405
  private readonly waiting: Array<() => void> = [];
406
  private active = 0;
407
  private queued = 0;
408
  private sequence = 0;
409
410
  constructor(
411
    private readonly registry: CoderTaskRegistry,
412
    private readonly harness: DelegateHarness,
413
    private readonly options: DelegateFleetOptions,
414
  ) {
415
    this.transcriptDirectory =
416
      options.transcriptDirectory ?? join(tmpdir(), "openagents-coder-delegations");
417
    mkdirSync(this.transcriptDirectory, { recursive: true, mode: 0o700 });
418
  }
419
420
  get activeCount(): number {
421
    return this.active;
422
  }
423
424
  get queuedCount(): number {
425
    return this.queued;
426
  }
427
428
  /** Launch one child. Waits for a slot when the fleet is full. */
429
  async submit(request: DelegationRequest): Promise<DelegationOutcome> {
430
    if (request.prompt.trim().length === 0) {
431
      return { status: "refused", code: "empty_prompt", reason: "A child needs a prompt." };
432
    }
433
434
    const maxQueued = this.options.maxQueued ?? this.options.maxConcurrent * 8;
435
    if (this.active >= this.options.maxConcurrent && this.queued >= maxQueued) {
436
      return {
437
        status: "refused",
438
        code: "fleet_full",
439
        reason:
440
          `The fleet is full: ${String(this.active)} running and ` +
441
          `${String(this.queued)} queued, with a cap of ${String(this.options.maxConcurrent)}. ` +
442
          "Wait for a child to finish or raise the cap.",
443
      };
444
    }
445
446
    const cwd = request.cwd ?? this.options.cwd ?? process.cwd();
447
    // Registered before it can queue, so a child waiting for a slot is visible
448
    // as `pending` rather than as nothing at all.
449
    const task = this.registry.register({
450
      id: this.mintId(),
451
      description: request.description.trim().length > 0 ? request.description : "delegated task",
452
      prompt: request.prompt,
453
      agent: this.harness.agent,
454
      model: this.harness.model,
455
      cwd,
456
      background: request.background ?? true,
457
    });
458
459
    if (this.active >= this.options.maxConcurrent) {
460
      this.queued += 1;
461
      await new Promise<void>((resolve) => this.waiting.push(resolve));
462
      this.queued -= 1;
463
      // A child stopped while it waited must not start now.
464
      if (this.registry.get(task.id)?.status === "stopped") {
465
        return { status: "stopped", taskId: task.id };
466
      }
467
    }
468
469
    this.active += 1;
470
    try {
471
      return await this.execute(task.id, request, cwd);
472
    } finally {
473
      this.active -= 1;
474
      this.waiting.shift()?.();
475
    }
476
  }
477
478
  /** Launch several children at once, respecting the cap. */
479
  submitAll(requests: ReadonlyArray<DelegationRequest>): Promise<ReadonlyArray<DelegationOutcome>> {
480
    return Promise.all(requests.map((request) => this.submit(request)));
481
  }
482
483
  private async execute(
484
    id: CoderTaskId,
485
    request: DelegationRequest,
486
    cwd: string,
487
  ): Promise<DelegationOutcome> {
488
    const transcriptPath = join(this.transcriptDirectory, `${id}.jsonl`);
489
    const controller = new AbortController();
490
    this.registry.start(id, controller);
491
    this.registry.attachTranscript(id, transcriptPath);
492
493
    /** Tool calls already counted. A harness reports one call several times. */
494
    const counted = new Set<string>();
495
    let text = "";
496
    let reported: string | undefined;
497
498
    try {
499
      for await (const event of this.harness.run(
500
        { prompt: request.prompt, cwd, transcriptPath },
501
        controller.signal,
502
      )) {
503
        if (controller.signal.aborted) break;
504
        if (event.type === "tool") {
505
          if (counted.has(event.callId)) continue;
506
          counted.add(event.callId);
507
          const activity: CoderToolActivity = { toolName: event.name, target: event.target };
508
          this.registry.recordToolUse(id, activity);
509
        } else if (event.type === "tokens") {
510
          this.registry.recordTokens(id, { input: event.input, output: event.output });
511
        } else if (event.type === "text") {
512
          // Only the final assistant text is the child's answer, and a harness
513
          // emits one text part per step, so the last one wins.
514
          text = event.value;
515
        } else if (event.type === "error") {
516
          reported = event.message;
517
        }
518
      }
519
520
      if (controller.signal.aborted) {
521
        return { status: "stopped", taskId: id };
522
      }
523
      if (reported !== undefined) {
524
        this.registry.fail(id, reported);
525
        return { status: "failed", taskId: id, error: reported };
526
      }
527
528
      this.registry.complete(id, text);
529
      return { status: "completed", taskId: id, result: text };
530
    } catch (cause) {
531
      if (controller.signal.aborted) {
532
        return { status: "stopped", taskId: id };
533
      }
534
      const message = cause instanceof Error ? cause.message : String(cause);
535
      this.registry.fail(id, message);
536
      return { status: "failed", taskId: id, error: message };
537
    }
538
  }
539
540
  private mintId(): CoderTaskId {
541
    // Time first so ids sort in launch order, then a counter so two children
542
    // launched in the same millisecond cannot collide.
543
    this.sequence += 1;
544
    return `d${Date.now().toString(36)}${this.sequence.toString(36).padStart(2, "0")}`;
545
  }
546
}
547
548
function isRecord(value: unknown): value is Record<string, unknown> {
549
  return typeof value === "object" && value !== null && !Array.isArray(value);
550
}
551
552
function stringField(record: Record<string, unknown>, key: string): string | undefined {
553
  const value = record[key];
554
  return typeof value === "string" ? value : undefined;
555
}
556
557
function numberField(record: Record<string, unknown>, key: string): number | undefined {
558
  const value = record[key];
559
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
560
}
packages/openagents-cli/src/coder-fleet.ts added +175

@@ -0,0 +1,175 @@

1
/**
2
 * Rendering for a fleet of delegated children.
3
 *
4
 * This module turns tasks into rows and returns text. It writes no escapes and
5
 * touches no terminal, so the full-screen interface, `--plain`, and the
6
 * headless `openagents delegate` command all show the same fleet and cannot
7
 * disagree about it.
8
 *
9
 * The shape follows what a reader can actually use at each density:
10
 *
11
 * - One phrase for the status line, because a status line has one row no matter
12
 *   how many children are running.
13
 * - One row per child, because a child's whole state is its description, what
14
 *   it is doing now, and what it cost.
15
 * - The counters disappear once a child is done, because `Done` plus a total is
16
 *   the answer and a live tool count is not.
17
 *
18
 * A row never wraps. A fleet of fifteen children that wraps is thirty rows of
19
 * ragged text, and the transcript underneath it disappears.
20
 */
21
22
import type { CoderTask, CoderTaskStatus } from "./coder-tasks.js";
23
import { isTerminal } from "./coder-tasks.js";
24
25
/** One rendered child. The caller decides how `status` is coloured. */
26
export interface FleetRow {
27
  readonly status: CoderTaskStatus;
28
  /** `├─` for every child but the last, which gets `└─`. */
29
  readonly branch: string;
30
  readonly mark: string;
31
  readonly text: string;
32
}
33
34
const MARKS: Record<CoderTaskStatus, string> = {
35
  pending: "·",
36
  running: "◐",
37
  completed: "✓",
38
  failed: "✗",
39
  stopped: "■",
40
};
41
42
/**
43
 * The one-line summary for the status bar, or undefined when there is no fleet.
44
 *
45
 * Running children come first because they are what the reader is waiting on,
46
 * and the terminal counts follow only when there are any, so a plain fan-out
47
 * does not carry a trail of zeroes.
48
 */
49
export function fleetPhrase(tasks: ReadonlyArray<CoderTask>): string | undefined {
50
  if (tasks.length === 0) return undefined;
51
52
  const active = tasks.filter((task) => !isTerminal(task.status));
53
  const done = tasks.filter((task) => task.status === "completed");
54
  const failed = tasks.filter((task) => task.status === "failed");
55
  const unread = tasks.filter((task) => task.unread);
56
57
  const parts: string[] = [];
58
  if (active.length > 0) {
59
    parts.push(`${String(active.length)} ${active.length === 1 ? "agent" : "agents"}`);
60
  }
61
  if (done.length > 0) parts.push(`${String(done.length)} done`);
62
  if (failed.length > 0) parts.push(`${String(failed.length)} failed`);
63
  if (unread.length > 0) parts.push(`${String(unread.length)} unread`);
64
  if (parts.length === 0) return `${String(tasks.length)} agents finished`;
65
  return parts.join(" · ");
66
}
67
68
/**
69
 * What a child is doing, in one phrase.
70
 *
71
 * Three cases and no more, which is the whole reason a fleet of fifteen is
72
 * readable: it is either working on something, or it is done, or it went
73
 * wrong. A running child with no activity yet says `Initializing…` rather than
74
 * nothing, because an empty cell reads as a stalled child.
75
 */
76
export function taskActivity(task: CoderTask): string {
77
  if (task.status === "pending") return "Queued";
78
  if (task.status === "stopped") return "Stopped";
79
  if (task.status === "failed") return `Failed: ${collapse(task.error ?? "unknown error")}`;
80
  if (task.status === "completed") {
81
    const cost = [
82
      `${String(task.progress.toolUseCount)} ${task.progress.toolUseCount === 1 ? "tool use" : "tool uses"}`,
83
      `${formatTokens(task.progress.tokenCount)} tokens`,
84
      formatDuration((task.endedAt ?? task.startedAt) - task.startedAt),
85
    ].join(" · ");
86
    return `Done (${cost})`;
87
  }
88
89
  const activity = task.progress.lastActivity;
90
  if (activity === undefined) return "Initializing…";
91
  const target = activity.target === undefined ? "" : `(${collapse(activity.target)})`;
92
  return `${activity.toolName}${target}`;
93
}
94
95
/**
96
 * The counters, or an empty string once they no longer tell the reader
97
 * anything.
98
 *
99
 * A finished child's totals are already in its `Done` phrase, so repeating them
100
 * beside it is noise in the column a running child needs.
101
 */
102
export function taskCounters(task: CoderTask): string {
103
  if (isTerminal(task.status)) return "";
104
  const tools = task.progress.toolUseCount;
105
  if (tools === 0 && task.progress.tokenCount === 0) return "";
106
  return `${String(tools)} ${tools === 1 ? "tool" : "tools"} · ${formatTokens(task.progress.tokenCount)}`;
107
}
108
109
/**
110
 * One row per child, in launch order.
111
 *
112
 * `width` is the room available for the row text, and every row is cut to it.
113
 * The description column is padded to a common width so the activity column
114
 * lines up, which is what lets a reader scan fifteen children for the one that
115
 * failed.
116
 */
117
export function fleetRows(tasks: ReadonlyArray<CoderTask>, width: number): ReadonlyArray<FleetRow> {
118
  const room = Math.max(20, width);
119
  const descriptionRoom = Math.min(28, Math.max(12, Math.floor(room * 0.35)));
120
  const longest = tasks.reduce((most, task) => Math.max(most, task.description.length), 0);
121
  const column = Math.min(descriptionRoom, longest);
122
123
  return tasks.map((task, index) => {
124
    const branch = index === tasks.length - 1 ? "└─" : "├─";
125
    const description = pad(cut(task.description, column), column);
126
    const counters = taskCounters(task);
127
    const activity = taskActivity(task);
128
    const tail = counters.length > 0 ? `${activity} · ${counters}` : activity;
129
    return {
130
      status: task.status,
131
      branch,
132
      mark: MARKS[task.status],
133
      text: cut(`${description}  ${tail}`, room),
134
    };
135
  });
136
}
137
138
/** The fleet as plain text, for `--plain` and for the headless command. */
139
export function fleetPlainLines(
140
  tasks: ReadonlyArray<CoderTask>,
141
  width: number,
142
): ReadonlyArray<string> {
143
  return fleetRows(tasks, Math.max(20, width - 6)).map(
144
    (row) => `  ${row.branch} ${row.mark} ${row.text}`,
145
  );
146
}
147
148
/** `8.2k` rather than `8214`, because the exact number is never the point. */
149
export function formatTokens(count: number): string {
150
  if (count < 1000) return String(count);
151
  if (count < 1_000_000) return `${(count / 1000).toFixed(1).replace(/\.0$/, "")}k`;
152
  return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
153
}
154
155
export function formatDuration(ms: number): string {
156
  const seconds = Math.max(0, Math.round(ms / 1000));
157
  if (seconds < 60) return `${String(seconds)}s`;
158
  const minutes = Math.floor(seconds / 60);
159
  return `${String(minutes)}m ${String(seconds % 60)}s`;
160
}
161
162
function collapse(text: string): string {
163
  return text.replace(/\s+/g, " ").trim();
164
}
165
166
function cut(text: string, width: number): string {
167
  const glyphs = [...collapse(text)];
168
  if (glyphs.length <= width) return glyphs.join("");
169
  return `${glyphs.slice(0, Math.max(1, width - 1)).join("")}…`;
170
}
171
172
function pad(text: string, width: number): string {
173
  const length = [...text].length;
174
  return length >= width ? text : text + " ".repeat(width - length);
175
}
packages/openagents-cli/src/coder-session.ts modified +152 -2

@@ -11,8 +11,18 @@

11 11
 * order the source produced them. An earlier version yielded only text, so a
12 12
 * tool call left no entry at all and the sentences on either side of it were
13 13
 * appended to the same entry and read as one run-on sentence.
14
 *
15
 * Delegated children are deliberately not transcript entries. They outlive the
16
 * entry that launched them and they change after it settles, so they live in a
17
 * `CoderTaskRegistry` and reach renderers through `snapshot().tasks`. The
18
 * session owns the join between the two: it turns a `/delegate` line into
19
 * launches and reports each child's outcome back onto the transcript.
14 20
 */
15 21
22
import type { DelegationOutcome, DelegationRequest } from "./coder-delegate.js";
23
import { parseDelegateCommand } from "./coder-delegate.js";
24
import type { CoderTask, CoderTaskId, CoderTaskRegistry } from "./coder-tasks.js";
25
16 26
/** What a reply source produces. One entry kind per member. */
17 27
export type ReplyChunk =
18 28
  | { readonly type: "text"; readonly value: string }

@@ -77,6 +87,20 @@ export interface CoderSnapshot {

77 87
   * the first frame.
78 88
   */
79 89
  readonly budget: string | undefined;
90
  /**
91
   * Delegated children, oldest first. Empty when nothing was delegated, and
92
   * then no renderer draws a fleet at all.
93
   */
94
  readonly tasks: ReadonlyArray<CoderTask>;
95
}
96
97
/** What the session needs in order to delegate. Absent means it cannot. */
98
export interface CoderDelegation {
99
  readonly registry: CoderTaskRegistry;
100
  /** Usually a `DelegateFleet`. Narrow on purpose, so tests can stand in. */
101
  readonly fleet: { submit(request: DelegationRequest): Promise<DelegationOutcome> };
102
  /** Shown when the reader asks for help, and in the launch notice. */
103
  readonly label: string;
80 104
}
81 105
82 106
/** Where reply chunks come from. One implementation today; ACP is the next. */

@@ -212,12 +236,20 @@ export class CoderSession {

212 236
  private readonly listeners = new Set<() => void>();
213 237
  private controller: AbortController | undefined;
214 238
  private turnCount = 0;
239
  private unsubscribeTasks: (() => void) | undefined;
215 240
216 241
  constructor(
217 242
    private readonly source: ReplySource,
218 243
    private readonly repository: string,
219 244
    private readonly branch: string,
220
  ) {}
245
    private readonly delegation?: CoderDelegation,
246
  ) {
247
    // A child reporting progress has to reach the renderer, and the renderer
248
    // subscribes to the session rather than to the registry, so the session
249
    // forwards. Without this the fleet block only moved when a chat chunk
250
    // happened to arrive.
251
    this.unsubscribeTasks = delegation?.registry.onChange(() => this.emit());
252
  }
221 253
222 254
  snapshot(): CoderSnapshot {
223 255
    return {

@@ -228,9 +260,36 @@ export class CoderSession {

228 260
      model: this.source.model,
229 261
      turns: this.turnCount,
230 262
      budget: this.source.budget,
263
      tasks: this.delegation?.registry.list() ?? [],
231 264
    };
232 265
  }
233 266
267
  /** Whether `/delegate` does anything, which is what the interface reads. */
268
  get canDelegate(): boolean {
269
    return this.delegation !== undefined;
270
  }
271
272
  /**
273
   * Stop every running child.
274
   *
275
   * Children are stopped as a group because that is how they were launched and
276
   * how they are read. Stopping one of fifteen is what the detail view is for.
277
   */
278
  stopTasks(): number {
279
    const registry = this.delegation?.registry;
280
    if (registry === undefined) return 0;
281
    const running = registry.list().filter((task) => task.status === "running").length;
282
    if (running === 0) return 0;
283
    registry.stopAll();
284
    this.notice(`Stopped ${String(running)} ${running === 1 ? "child" : "children"}.`);
285
    return running;
286
  }
287
288
  /** Forget children nothing will look at again. Called on the interface tick. */
289
  pruneTasks(): void {
290
    this.delegation?.registry.prune();
291
  }
292
234 293
  /**
235 294
   * Whether this thread can change backend at all.
236 295
   *

@@ -284,9 +343,21 @@ export class CoderSession {

284 343
   * to send.
285 344
   */
286 345
  async submit(prompt: string): Promise<void> {
287
    if (this.controller !== undefined) return;
288 346
    if (prompt.trim().length === 0) return;
289 347
348
    // Delegation is not a turn: it does not go to the model, it does not block
349
    // the next prompt, and it is allowed while a reply is streaming. That is
350
    // the point of a fleet — the console keeps working while children run.
351
    const delegate = parseDelegateCommand(prompt);
352
    if (delegate !== undefined) {
353
      this.entries.push({ role: "you", text: prompt, settled: true });
354
      this.startDelegation(delegate.count, delegate.prompt, delegate.description);
355
      this.emit();
356
      return;
357
    }
358
359
    if (this.controller !== undefined) return;
360
290 361
    this.entries.push({ role: "you", text: prompt, settled: true });
291 362
    // An empty assistant entry from the start, so the interface shows a caret
292 363
    // rather than nothing while the first chunk is in flight. It is withdrawn

@@ -396,6 +467,76 @@ export class CoderSession {

396 467
    return true;
397 468
  }
398 469
470
  /** Release the registry subscription. Safe to call twice. */
471
  close(): void {
472
    this.unsubscribeTasks?.();
473
    this.unsubscribeTasks = undefined;
474
  }
475
476
  /**
477
   * Launch `count` children on one prompt and report each as it lands.
478
   *
479
   * Not awaited: the whole reason to delegate is that the console stays usable
480
   * while the children work, so this returns as soon as they are submitted and
481
   * every outcome arrives later as a notice. Failures are reported per child
482
   * rather than as one summary, because with fifteen children the reader needs
483
   * to know which one.
484
   */
485
  private startDelegation(count: number, prompt: string, description: string): void {
486
    const delegation = this.delegation;
487
    if (delegation === undefined) {
488
      this.notice(
489
        "This session cannot delegate. Start it with a child model, for example " +
490
          "`openagents coder --child-model vertex-express/gemini-3.7-flash`.",
491
      );
492
      return;
493
    }
494
    if (prompt.trim().length === 0) {
495
      this.notice("Usage: /delegate [<n>x] <prompt>. For example `/delegate 3x add tests`.");
496
      return;
497
    }
498
499
    this.notice(
500
      `Delegating ${String(count)} ${count === 1 ? "child" : "children"} to ${delegation.label}.`,
501
    );
502
503
    for (let index = 0; index < count; index += 1) {
504
      const request: DelegationRequest = {
505
        description,
506
        prompt,
507
        background: true,
508
      };
509
      void delegation.fleet.submit(request).then((outcome) => this.reportOutcome(outcome));
510
    }
511
  }
512
513
  private reportOutcome(outcome: DelegationOutcome): void {
514
    const registry = this.delegation?.registry;
515
    if (outcome.status === "refused") {
516
      this.notice(`Delegation refused (${outcome.code}): ${outcome.reason}`);
517
      return;
518
    }
519
520
    const task = registry?.get(outcome.taskId);
521
    const label = task === undefined ? outcome.taskId : `${outcome.taskId} ${task.description}`;
522
    if (outcome.status === "completed") {
523
      const first = firstLine(outcome.result);
524
      this.notice(first.length > 0 ? `${label} finished: ${first}` : `${label} finished.`);
525
    } else if (outcome.status === "failed") {
526
      this.notice(`${label} failed: ${outcome.error}`);
527
    } else {
528
      this.notice(`${label} stopped.`);
529
    }
530
531
    // Reported is read: the notice above is the delivery, so leaving the badge
532
    // on would ask the reader to go and find what they were just told.
533
    this.markRead(outcome.taskId);
534
  }
535
536
  private markRead(id: CoderTaskId): void {
537
    this.delegation?.registry.markRead(id);
538
  }
539
399 540
  private applyToolResult(chunk: Extract<ReplyChunk, { type: "tool_result" }>): void {
400 541
    for (let index = this.entries.length - 1; index >= 0; index -= 1) {
401 542
      const tool = this.entries[index]?.tool;

@@ -414,6 +555,15 @@ export class CoderSession {

414 555
  }
415 556
}
416 557
558
/** The first non-empty line, which is how a result is announced in one row. */
559
function firstLine(text: string): string {
560
  for (const line of text.split("\n")) {
561
    const trimmed = line.trim();
562
    if (trimmed.length > 0) return trimmed;
563
  }
564
  return "";
565
}
566
417 567
/** A renderer must not be able to mutate the transcript through its snapshot. */
418 568
function copyEntry(entry: CoderEntry): CoderEntry {
419 569
  return entry.tool === undefined ? { ...entry } : { ...entry, tool: { ...entry.tool } };
packages/openagents-cli/src/coder-tasks.ts added +360

@@ -0,0 +1,360 @@

1
/**
2
 * The task registry behind delegation in `openagents coder`.
3
 *
4
 * A fleet of child agents is not expressible as a transcript. The transcript in
5
 * `coder-session.ts` is an ordered list of settled and unsettled entries, which
6
 * is the right shape for one conversation and the wrong shape for fifteen
7
 * children running at once: each child has its own status, its own counters,
8
 * its own last activity, and its own transcript, and all of that keeps changing
9
 * after the entry that launched it has settled.
10
 *
11
 * So delegation state lives here instead, in one registry the renderers read
12
 * the same way they read a session snapshot: through immutable copies, with a
13
 * change callback. Nothing in this module knows how a task is drawn and nothing
14
 * in it knows how a child is executed. `coder-delegate.ts` runs children and
15
 * writes here; `coder-fleet.ts` reads here and returns rows.
16
 *
17
 * Two rules in here exist because of what they prevent:
18
 *
19
 * - An update that changes nothing returns the same object, so a child
20
 *   reporting identical progress does not wake every renderer.
21
 * - Counters are aggregated on write. Recomputing them from a stored event log
22
 *   at paint time is what turns a 15-way fan-out into a redraw cost that grows
23
 *   with the length of the run.
24
 */
25
26
/** Stable identity, minted before a child starts and never reused. */
27
export type CoderTaskId = string;
28
29
/**
30
 * Where a task is.
31
 *
32
 * `stopped` is a deliberate cancellation and is not `failed`: a child the
33
 * operator stopped did not go wrong, and reporting it as a failure teaches the
34
 * reader to ignore failures.
35
 */
36
export type CoderTaskStatus = "pending" | "running" | "completed" | "failed" | "stopped";
37
38
/** One thing a child did, in the shape a one-line status needs. */
39
export interface CoderToolActivity {
40
  readonly toolName: string;
41
  /**
42
   * What the child was working on, as the child's own harness described it —
43
   * a path, a command, a query. Undefined when the harness said nothing, and
44
   * then only the tool name is shown.
45
   */
46
  readonly target: string | undefined;
47
}
48
49
/** What a fleet row and a detail view read. Aggregated, never recomputed. */
50
export interface CoderTaskProgress {
51
  readonly toolUseCount: number;
52
  /**
53
   * Tokens attributable to this child so far.
54
   *
55
   * Providers report input usage cumulatively for the whole context, so the
56
   * latest input count replaces the previous one while output counts add up.
57
   * Summing both would multiply-count the prompt on every step, and a child
58
   * that read three files would appear to have spent five times what it did.
59
   */
60
  readonly tokenCount: number;
61
  readonly lastActivity: CoderToolActivity | undefined;
62
  /** Newest last, bounded by `MAX_RECENT_ACTIVITIES`. */
63
  readonly recentActivities: ReadonlyArray<CoderToolActivity>;
64
}
65
66
/** One delegated child agent. */
67
export interface CoderTask {
68
  readonly id: CoderTaskId;
69
  /** Three to five words. Display only, and the only text a fleet row shows. */
70
  readonly description: string;
71
  readonly prompt: string;
72
  /** The harness that runs the child, for example `opencode`. */
73
  readonly agent: string;
74
  readonly model: string;
75
  /** Where the child works. A worktree path when the child is isolated. */
76
  readonly cwd: string;
77
  readonly status: CoderTaskStatus;
78
  /** False while the caller is waiting on this child synchronously. */
79
  readonly background: boolean;
80
  /** True from completion until the result is read, so nothing lands silently. */
81
  readonly unread: boolean;
82
  readonly startedAt: number;
83
  readonly endedAt: number | undefined;
84
  readonly progress: CoderTaskProgress;
85
  /** The child's own transcript, once the harness has named it. */
86
  readonly transcriptPath: string | undefined;
87
  /** The child's final text, on completion. */
88
  readonly result: string | undefined;
89
  readonly error: string | undefined;
90
}
91
92
/** How many activities a task keeps. A fleet of 15 cannot keep every step. */
93
export const MAX_RECENT_ACTIVITIES = 5;
94
95
/** How long a stopped task stays listed, so the reader sees the transition. */
96
export const STOPPED_DISPLAY_MS = 3_000;
97
98
/** What `register` needs. Everything else is derived or arrives later. */
99
export interface CoderTaskInput {
100
  readonly id: CoderTaskId;
101
  readonly description: string;
102
  readonly prompt: string;
103
  readonly agent: string;
104
  readonly model: string;
105
  readonly cwd: string;
106
  readonly background: boolean;
107
}
108
109
interface TaskRecord {
110
  task: CoderTask;
111
  /** Kept out of the task so a snapshot cannot cancel a child. */
112
  controller: AbortController | undefined;
113
  /** Provider input usage is cumulative, so the latest reading replaces. */
114
  latestInputTokens: number;
115
  cumulativeOutputTokens: number;
116
}
117
118
/**
119
 * The registry. One per session.
120
 *
121
 * Every mutator is a no-op on an unknown id rather than a throw: a child that
122
 * outlives a cleared registry should not be able to crash the interface it can
123
 * no longer draw into.
124
 */
125
export class CoderTaskRegistry {
126
  private readonly records = new Map<CoderTaskId, TaskRecord>();
127
  /** Insertion order, so the fleet does not reshuffle as children finish. */
128
  private readonly order: CoderTaskId[] = [];
129
  private readonly listeners = new Set<() => void>();
130
131
  /**
132
   * Register a child before it starts.
133
   *
134
   * Registering first is what makes a child that fails to launch visible. If
135
   * the launcher registered on success, a harness that is not installed would
136
   * produce nothing at all on screen.
137
   */
138
  register(input: CoderTaskInput, nowMs = Date.now()): CoderTask {
139
    const task: CoderTask = {
140
      id: input.id,
141
      description: input.description,
142
      prompt: input.prompt,
143
      agent: input.agent,
144
      model: input.model,
145
      cwd: input.cwd,
146
      status: "pending",
147
      background: input.background,
148
      unread: false,
149
      startedAt: nowMs,
150
      endedAt: undefined,
151
      progress: {
152
        toolUseCount: 0,
153
        tokenCount: 0,
154
        lastActivity: undefined,
155
        recentActivities: [],
156
      },
157
      transcriptPath: undefined,
158
      result: undefined,
159
      error: undefined,
160
    };
161
162
    this.records.set(task.id, {
163
      task,
164
      controller: undefined,
165
      latestInputTokens: 0,
166
      cumulativeOutputTokens: 0,
167
    });
168
    this.order.push(task.id);
169
    this.emit();
170
    return task;
171
  }
172
173
  /** Mark a child running and store the handle that can cancel it. */
174
  start(id: CoderTaskId, controller: AbortController): void {
175
    const record = this.records.get(id);
176
    if (record === undefined) return;
177
    record.controller = controller;
178
    this.write(record, { status: "running" });
179
  }
180
181
  /** Note the child's own transcript, once its harness has named one. */
182
  attachTranscript(id: CoderTaskId, transcriptPath: string): void {
183
    const record = this.records.get(id);
184
    if (record === undefined) return;
185
    this.write(record, { transcriptPath });
186
  }
187
188
  /**
189
   * Count one tool use and remember what it was.
190
   *
191
   * Counted on the call rather than the result, because a fleet row has to say
192
   * what a child is doing now and a long-running command would otherwise leave
193
   * the row reading `Initializing…` for its whole duration.
194
   */
195
  recordToolUse(id: CoderTaskId, activity: CoderToolActivity): void {
196
    const record = this.records.get(id);
197
    if (record === undefined) return;
198
    const recent = [...record.task.progress.recentActivities, activity];
199
    while (recent.length > MAX_RECENT_ACTIVITIES) recent.shift();
200
    this.write(record, {
201
      progress: {
202
        ...record.task.progress,
203
        toolUseCount: record.task.progress.toolUseCount + 1,
204
        lastActivity: activity,
205
        recentActivities: recent,
206
      },
207
    });
208
  }
209
210
  /** Fold one usage report into the token count. See `tokenCount`. */
211
  recordTokens(id: CoderTaskId, usage: { readonly input: number; readonly output: number }): void {
212
    const record = this.records.get(id);
213
    if (record === undefined) return;
214
    record.latestInputTokens = usage.input;
215
    record.cumulativeOutputTokens += usage.output;
216
    this.write(record, {
217
      progress: {
218
        ...record.task.progress,
219
        tokenCount: record.latestInputTokens + record.cumulativeOutputTokens,
220
      },
221
    });
222
  }
223
224
  complete(id: CoderTaskId, result: string, nowMs = Date.now()): void {
225
    const record = this.records.get(id);
226
    if (record === undefined) return;
227
    record.controller = undefined;
228
    this.write(record, {
229
      status: "completed",
230
      endedAt: nowMs,
231
      result,
232
      // Unread only where someone has to come back for it. A synchronous child
233
      // was already awaited by its caller, so marking it unread would leave a
234
      // permanent badge for a result that has been read.
235
      unread: record.task.background,
236
    });
237
  }
238
239
  fail(id: CoderTaskId, error: string, nowMs = Date.now()): void {
240
    const record = this.records.get(id);
241
    if (record === undefined) return;
242
    record.controller = undefined;
243
    this.write(record, { status: "failed", endedAt: nowMs, error, unread: record.task.background });
244
  }
245
246
  /** Cancel a running child. Terminal tasks are left alone. */
247
  stop(id: CoderTaskId, nowMs = Date.now()): boolean {
248
    const record = this.records.get(id);
249
    if (record === undefined) return false;
250
    if (isTerminal(record.task.status)) return false;
251
    record.controller?.abort();
252
    record.controller = undefined;
253
    this.write(record, { status: "stopped", endedAt: nowMs, unread: false });
254
    return true;
255
  }
256
257
  /** Clear the unread badge once the result has been shown. */
258
  markRead(id: CoderTaskId): void {
259
    const record = this.records.get(id);
260
    if (record === undefined) return;
261
    this.write(record, { unread: false });
262
  }
263
264
  /**
265
   * Drop tasks nothing will look at again.
266
   *
267
   * Stopped tasks linger briefly so the reader sees them stop, and completed
268
   * tasks stay until read. Everything else terminal is forgotten, because a
269
   * long session that keeps every child forever grows a fleet block nobody
270
   * asked for.
271
   */
272
  prune(nowMs = Date.now(), graceMs = STOPPED_DISPLAY_MS): void {
273
    let changed = false;
274
    for (const id of [...this.order]) {
275
      const record = this.records.get(id);
276
      if (record === undefined) continue;
277
      const task = record.task;
278
      if (!isTerminal(task.status)) continue;
279
      if (task.unread) continue;
280
      const endedAt = task.endedAt ?? task.startedAt;
281
      if (nowMs - endedAt < graceMs) continue;
282
      this.records.delete(id);
283
      this.order.splice(this.order.indexOf(id), 1);
284
      changed = true;
285
    }
286
    if (changed) this.emit();
287
  }
288
289
  get(id: CoderTaskId): CoderTask | undefined {
290
    return this.records.get(id)?.task;
291
  }
292
293
  /** Every task, oldest first. The array is a copy; the tasks are frozen. */
294
  list(): ReadonlyArray<CoderTask> {
295
    const out: CoderTask[] = [];
296
    for (const id of this.order) {
297
      const record = this.records.get(id);
298
      if (record !== undefined) out.push(record.task);
299
    }
300
    return out;
301
  }
302
303
  /** How many children are in flight, which is what a concurrency cap reads. */
304
  get activeCount(): number {
305
    let count = 0;
306
    for (const record of this.records.values()) {
307
      if (!isTerminal(record.task.status)) count += 1;
308
    }
309
    return count;
310
  }
311
312
  onChange(listener: () => void): () => void {
313
    this.listeners.add(listener);
314
    return () => this.listeners.delete(listener);
315
  }
316
317
  /** Cancel everything still running, for shutdown. */
318
  stopAll(nowMs = Date.now()): void {
319
    for (const id of [...this.order]) this.stop(id, nowMs);
320
  }
321
322
  private write(record: TaskRecord, patch: Partial<CoderTask>): void {
323
    const next = { ...record.task, ...patch };
324
    if (isSameTask(record.task, next)) return;
325
    record.task = Object.freeze(next);
326
    this.emit();
327
  }
328
329
  private emit(): void {
330
    for (const listener of this.listeners) listener();
331
  }
332
}
333
334
export function isTerminal(status: CoderTaskStatus): boolean {
335
  return status === "completed" || status === "failed" || status === "stopped";
336
}
337
338
/**
339
 * Whether an update is worth telling anyone about.
340
 *
341
 * Compared field by field rather than by reference because every writer builds
342
 * a fresh object. Progress is compared on what a row shows, so a child
343
 * reporting the same counters and the same activity twice does not repaint the
344
 * fleet.
345
 */
346
function isSameTask(previous: CoderTask, next: CoderTask): boolean {
347
  return (
348
    previous.status === next.status &&
349
    previous.unread === next.unread &&
350
    previous.background === next.background &&
351
    previous.endedAt === next.endedAt &&
352
    previous.result === next.result &&
353
    previous.error === next.error &&
354
    previous.transcriptPath === next.transcriptPath &&
355
    previous.progress.toolUseCount === next.progress.toolUseCount &&
356
    previous.progress.tokenCount === next.progress.tokenCount &&
357
    previous.progress.lastActivity?.toolName === next.progress.lastActivity?.toolName &&
358
    previous.progress.lastActivity?.target === next.progress.lastActivity?.target
359
  );
360
}
packages/openagents-cli/src/coder-ui.ts modified +103 -8

@@ -29,8 +29,10 @@

29 29
 * own job instead.
30 30
 */
31 31
32
import { fleetPhrase, fleetRows } from "./coder-fleet.js";
32 33
import { renderMarkdown, visibleWidth, wrapStyled } from "./coder-markdown.js";
33 34
import type { CoderEntry, CoderSession, CoderSnapshot, CoderToolCall } from "./coder-session.js";
35
import type { CoderTaskStatus } from "./coder-tasks.js";
34 36
35 37
const ALT_SCREEN_ON = "\x1b[?1049h";
36 38
const ALT_SCREEN_OFF = "\x1b[?1049l";

@@ -57,6 +59,14 @@ const RED = "\x1b[31m";

57 59
58 60
const STATUS_ROWS = 1;
59 61
const COMPOSER_ROWS = 3;
62
/**
63
 * Rows the fleet block may take before it scrolls internally.
64
 *
65
 * A fleet is a status display, not the content: a 30-way fan-out must not push
66
 * the transcript off the screen. Past this many children the block shows the
67
 * ones that are still working and counts the rest.
68
 */
69
const FLEET_ROWS_MAX = 8;
60 70
/** Width of the role gutter, so every entry's text starts in one column. */
61 71
const GUTTER = 9;
62 72
/**

@@ -106,6 +116,20 @@ function hints(keys: ReadonlyArray<string>, right: string, width: number): strin

106 116
  return right;
107 117
}
108 118
119
/**
120
 * The colour a fleet row's mark takes, so the column can be scanned.
121
 *
122
 * Failure is the only one that is loud. A fleet of fifteen where every row is
123
 * coloured is a fleet where nothing stands out, and the row a reader is looking
124
 * for is the one that went wrong.
125
 */
126
function fleetColor(status: CoderTaskStatus): string {
127
  if (status === "running") return YELLOW;
128
  if (status === "completed") return GREEN;
129
  if (status === "failed") return RED;
130
  return DIM;
131
}
132
109 133
/** Human-readable elapsed time, in the shape a status line wants. */
110 134
function elapsed(sinceMs: number, nowMs: number): string {
111 135
  const seconds = Math.max(0, Math.round((nowMs - sinceMs) / 1000));

@@ -209,6 +233,11 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

209 233
        escapeTimer = undefined;
210 234
      }
211 235
      unsubscribe();
236
      // Leaving the interface ends the fleet with it: a child holds a process,
237
      // and a console that exits while fifteen of them keep spending would
238
      // leave the reader nothing to stop them with.
239
      session.stopTasks();
240
      session.close();
212 241
      stdin.off("data", onData);
213 242
      stdout.off("resize", onResize);
214 243
      if (stdin.isTTY) stdin.setRawMode(false);

@@ -308,6 +337,36 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

308 337
      return rows;
309 338
    };
310 339
340
    /**
341
     * The fleet block: one row per child.
342
     *
343
     * Drawn above the status line rather than in the transcript, because it is
344
     * live state and the transcript is a record. A reader who scrolled back to
345
     * an earlier tool call still needs to see what the fleet is doing now.
346
     */
347
    const fleetLines = (snapshot: CoderSnapshot, width: number): ReadonlyArray<string> => {
348
      const tasks = snapshot.tasks;
349
      if (tasks.length === 0) return [];
350
351
      // Working children first when there are more than fit: a finished child
352
      // has already been reported on the transcript, so it is the one to drop.
353
      const shown =
354
        tasks.length <= FLEET_ROWS_MAX
355
          ? tasks
356
          : [
357
              ...tasks.filter((task) => task.status === "running" || task.status === "pending"),
358
              ...tasks.filter((task) => task.status !== "running" && task.status !== "pending"),
359
            ].slice(0, FLEET_ROWS_MAX);
360
361
      const out = fleetRows(shown, Math.max(20, width - 8)).map((row) => {
362
        const color = fleetColor(row.status);
363
        return `  ${DIM}${row.branch}${RESET} ${color}${row.mark}${RESET} ${DIM}${row.text}${RESET}`;
364
      });
365
      const hidden = tasks.length - shown.length;
366
      if (hidden > 0) out.push(`  ${DIM}   +${String(hidden)} more${RESET}`);
367
      return out;
368
    };
369
311 370
    /** The newest tool call, which is the one ctrl+o expands. */
312 371
    const focusedTool = (snapshot: CoderSnapshot): string | undefined => {
313 372
      for (let index = snapshot.entries.length - 1; index >= 0; index -= 1) {

@@ -324,17 +383,23 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

324 383
      const height = stdout.rows ?? 24;
325 384
      const transcriptHeight = Math.max(1, height - STATUS_ROWS - COMPOSER_ROWS - 1);
326 385
386
      const fleet = fleetLines(snapshot, width);
387
      // The fleet takes its rows from the transcript, not from the chrome: the
388
      // status line and composer stay where the reader's hands expect them.
389
      const transcriptRows = Math.max(1, transcriptHeight - fleet.length);
390
327 391
      const lines = transcriptLines(snapshot, width);
328 392
      lineCount = lines.length;
329
      viewport = transcriptHeight;
393
      viewport = transcriptRows;
330 394
331
      const maxStart = Math.max(0, lines.length - transcriptHeight);
395
      const maxStart = Math.max(0, lines.length - transcriptRows);
332 396
      const start = anchor === undefined ? maxStart : Math.min(anchor, maxStart);
333 397
      const above = start;
334
      const below = Math.max(0, lines.length - start - transcriptHeight);
398
      const below = Math.max(0, lines.length - start - transcriptRows);
335 399
336 400
      const rows: string[] = [];
337
      for (let row = 0; row < transcriptHeight; row += 1) rows.push(lines[start + row] ?? "");
401
      for (let row = 0; row < transcriptRows; row += 1) rows.push(lines[start + row] ?? "");
402
      rows.push(...fleet);
338 403
339 404
      // Bottom chrome, in the order a reader scans it: what the session is
340 405
      // doing now, then where the typing goes, then what the keys do. The

@@ -343,13 +408,19 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

343 408
      const rule = `${DIM}${"─".repeat(Math.max(0, width))}${RESET}`;
344 409
      const inner = Math.max(10, width - 4);
345 410
411
      const phrase = fleetPhrase(snapshot.tasks);
346 412
      // The elapsed time and nothing else. This said `streaming` until the
347 413
      // reply source became the inference proxy, which builds the whole body
348 414
      // and sends it once: a turn that shows one block after four silent
349 415
      // seconds was never streaming, and the status line must not say it was.
350
      const activity = snapshot.running
416
      const chatActivity = snapshot.running
351 417
        ? `${YELLOW}●${RESET} working… ${DIM}(${elapsed(runningSince, Date.now())})${RESET}`
352 418
        : `${DIM}○ ready${RESET}`;
419
      // The fleet is named on the status line even though the block above lists
420
      // it, because the block is what gives way first on a short terminal and
421
      // the count is the part the reader is waiting on.
422
      const activity =
423
        phrase === undefined ? chatActivity : `${chatActivity} ${DIM}· ${phrase}${RESET}`;
353 424
      // Dropped from the left as the terminal narrows, because that is the
354 425
      // order of what a reader cannot recover elsewhere: they can see which
355 426
      // checkout they are in, they can ask git for the branch, and nothing on

@@ -383,8 +454,11 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

383 454
      // Only when there is another model to switch to, and only while nothing
384 455
      // is running: a turn already accepted keeps the backend it named.
385 456
      if (session.canCycleBackend && !snapshot.running) keys.push("tab to switch model");
386
      if (lines.length > transcriptHeight) keys.push("pgup/pgdn to scroll");
457
      if (lines.length > transcriptRows) keys.push("pgup/pgdn to scroll");
387 458
      if (focusedTool(snapshot) !== undefined) keys.push("ctrl+o to expand");
459
      if (snapshot.tasks.some((task) => task.status === "running")) {
460
        keys.push("ctrl+x to stop agents");
461
      }
388 462
389 463
      // `this run` is not decoration. The count is this process's, and a
390 464
      // source that is not the thread — the stand-in behind `--offline` — has

@@ -399,7 +473,7 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

399 473
            : `${DIM}${replies}${RESET}`;
400 474
      rows.push(`  ${hints(keys, counter, inner)}`);
401 475
402
      paint(rows, transcriptHeight + 3, 4 + composer.length + 1);
476
      paint(rows, transcriptRows + fleet.length + 3, 4 + composer.length + 1);
403 477
    };
404 478
405 479
    /**

@@ -444,9 +518,19 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

444 518
      // The elapsed time has to advance between chunks, not only when one
445 519
      // arrives, or a slow reply looks stalled.
446 520
      ticker ??= setInterval(() => {
447
        if (session.running) render();
521
        session.pruneTasks();
522
        if (session.running || session.snapshot().tasks.length > 0) render();
448 523
      }, 1000);
449 524
525
      // A delegate line is not a turn: it returns as soon as the children are
526
      // submitted and each one reports later, so nothing here waits on it and
527
      // the ticker above keeps the fleet rows moving.
528
      if (prompt.trimStart().startsWith("/delegate")) {
529
        void session.submit(prompt);
530
        render();
531
        return;
532
      }
533
450 534
      void session.submit(prompt).finally(() => {
451 535
        if (ticker !== undefined) {
452 536
          clearInterval(ticker);

@@ -553,6 +637,17 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

553 637
          continue;
554 638
        }
555 639
640
        // Ctrl+X stops the children. Escape is not overloaded to do it: escape
641
        // interrupts the reply the reader is watching, and a key that means
642
        // "stop this" sometimes and "stop those fifteen" other times is how a
643
        // fleet gets killed by accident.
644
        if (char === "\x18") {
645
          session.stopTasks();
646
          dirty = true;
647
          index += 1;
648
          continue;
649
        }
650
556 651
        // Tab is a printable character to the run scanner below, so it has to
557 652
        // be claimed here or it lands in the composer as literal whitespace.
558 653
        if (char === "\t" && session.canCycleBackend) {
packages/openagents-cli/test/coder-delegate.test.ts added +350

@@ -0,0 +1,350 @@

1
import { describe, expect, it } from "vitest";
2
3
import {
4
  DelegateFleet,
5
  type DelegateEvent,
6
  type DelegateHarness,
7
  describePrompt,
8
  parseDelegateCommand,
9
  parseOpencodeEvent,
10
} from "../src/coder-delegate.js";
11
import { fleetPhrase, fleetRows, formatTokens, taskActivity } from "../src/coder-fleet.js";
12
import { CoderSession, type ReplySource } from "../src/coder-session.js";
13
import { CoderTaskRegistry } from "../src/coder-tasks.js";
14
import { mkdtempSync } from "node:fs";
15
import { tmpdir } from "node:os";
16
import { join } from "node:path";
17
18
/** A harness whose events and timing the test controls. */
19
const harness = (
20
  events: ReadonlyArray<DelegateEvent>,
21
  options: { readonly fail?: string; readonly hold?: boolean } = {},
22
): DelegateHarness => ({
23
  agent: "fake",
24
  model: "fake/model",
25
  async *run(_input, signal) {
26
    for (const event of events) {
27
      if (signal.aborted) return;
28
      yield event;
29
    }
30
    if (options.hold === true) {
31
      await new Promise<void>((resolve) => {
32
        if (signal.aborted) return resolve();
33
        signal.addEventListener("abort", () => resolve(), { once: true });
34
      });
35
      return;
36
    }
37
    if (options.fail !== undefined) throw new Error(options.fail);
38
  },
39
});
40
41
const fleetOf = (
42
  agent: DelegateHarness,
43
  maxConcurrent = 4,
44
): { readonly registry: CoderTaskRegistry; readonly fleet: DelegateFleet } => {
45
  const registry = new CoderTaskRegistry();
46
  const transcriptDirectory = mkdtempSync(join(tmpdir(), "delegate-test-"));
47
  return {
48
    registry,
49
    fleet: new DelegateFleet(registry, agent, { maxConcurrent, transcriptDirectory }),
50
  };
51
};
52
53
const silent: ReplySource = {
54
  model: "scripted",
55
  // eslint-disable-next-line require-yield
56
  async *reply() {
57
    return;
58
  },
59
};
60
61
describe("parseDelegateCommand", () => {
62
  it("leaves an ordinary prompt alone", () => {
63
    expect(parseDelegateCommand("delegate this to someone")).toBeUndefined();
64
    expect(parseDelegateCommand("/delegates are people")).toBeUndefined();
65
  });
66
67
  it("reads a single child", () => {
68
    expect(parseDelegateCommand("/delegate add tests to the parser")).toEqual({
69
      count: 1,
70
      prompt: "add tests to the parser",
71
      description: "add tests to the parser",
72
    });
73
  });
74
75
  it("reads a fan-out count and keeps it off the prompt", () => {
76
    const parsed = parseDelegateCommand("/delegate 6x survey the repository for dead code");
77
    expect(parsed?.count).toBe(6);
78
    expect(parsed?.prompt).toBe("survey the repository for dead code");
79
    expect(parsed?.description).toBe("survey the repository for dead");
80
  });
81
82
  it("caps the count rather than launching what was asked for", () => {
83
    expect(parseDelegateCommand("/delegate 900x go")?.count).toBe(32);
84
  });
85
86
  it("reports a bare command so the caller can explain the grammar", () => {
87
    expect(parseDelegateCommand("/delegate")).toEqual({
88
      count: 1,
89
      prompt: "",
90
      description: "delegated task",
91
    });
92
  });
93
});
94
95
describe("describePrompt", () => {
96
  it("takes the first few words", () => {
97
    expect(describePrompt("fix the flaky retry test in the api client")).toBe(
98
      "fix the flaky retry test",
99
    );
100
  });
101
});
102
103
describe("parseOpencodeEvent", () => {
104
  it("ignores blank lines, prose, and unknown events", () => {
105
    expect(parseOpencodeEvent("")).toBeUndefined();
106
    expect(parseOpencodeEvent("thinking…")).toBeUndefined();
107
    expect(parseOpencodeEvent('{"type":"step_start","part":{}}')).toBeUndefined();
108
    expect(parseOpencodeEvent('{"type":"future_event","part":{"x":1}}')).toBeUndefined();
109
  });
110
111
  it("reads a tool use and prefers the harness's own title", () => {
112
    const line = JSON.stringify({
113
      type: "tool_use",
114
      part: {
115
        type: "tool",
116
        tool: "read",
117
        callID: "call-1",
118
        state: { status: "completed", title: "src/cli.ts", input: { filePath: "/tmp/x" } },
119
      },
120
    });
121
    expect(parseOpencodeEvent(line)).toEqual({
122
      type: "tool",
123
      callId: "call-1",
124
      name: "read",
125
      target: "src/cli.ts",
126
    });
127
  });
128
129
  it("falls back to an input field when the tool set no title", () => {
130
    const line = JSON.stringify({
131
      type: "tool_use",
132
      part: { tool: "bash", callID: "call-2", state: { input: { command: "pnpm test" } } },
133
    });
134
    expect(parseOpencodeEvent(line)).toMatchObject({ name: "bash", target: "pnpm test" });
135
  });
136
137
  it("reads text and token usage", () => {
138
    expect(parseOpencodeEvent('{"type":"text","part":{"text":"banana"}}')).toEqual({
139
      type: "text",
140
      value: "banana",
141
    });
142
    expect(
143
      parseOpencodeEvent('{"type":"step_finish","part":{"tokens":{"input":7913,"output":11}}}'),
144
    ).toEqual({ type: "tokens", input: 7913, output: 11 });
145
  });
146
147
  it("survives a truncated line", () => {
148
    expect(parseOpencodeEvent('{"type":"text","part":{"text":"half')).toBeUndefined();
149
  });
150
});
151
152
describe("DelegateFleet", () => {
153
  it("runs a child, aggregates its progress, and returns its answer", async () => {
154
    const { registry, fleet } = fleetOf(
155
      harness([
156
        { type: "tool", callId: "a", name: "read", target: "hello.txt" },
157
        { type: "tool", callId: "a", name: "read", target: "hello.txt" },
158
        { type: "tokens", input: 100, output: 5 },
159
        { type: "tokens", input: 200, output: 7 },
160
        { type: "text", value: "banana" },
161
      ]),
162
    );
163
164
    const outcome = await fleet.submit({ description: "read a file", prompt: "read hello.txt" });
165
    expect(outcome).toMatchObject({ status: "completed", result: "banana" });
166
167
    const task = registry.list()[0];
168
    // The same call reported twice is one tool use, and cumulative input usage
169
    // replaces rather than adds: 200 + (5 + 7).
170
    expect(task?.progress.toolUseCount).toBe(1);
171
    expect(task?.progress.tokenCount).toBe(212);
172
    expect(task?.progress.lastActivity?.toolName).toBe("read");
173
    expect(task?.status).toBe("completed");
174
    expect(task?.transcriptPath).toMatch(/\.jsonl$/);
175
  });
176
177
  it("reports a harness that cannot run as a failed child, not a throw", async () => {
178
    const { registry, fleet } = fleetOf(harness([], { fail: "opencode is not on the path" }));
179
    const outcome = await fleet.submit({ description: "x", prompt: "go" });
180
    expect(outcome).toMatchObject({ status: "failed", error: "opencode is not on the path" });
181
    expect(registry.list()[0]?.status).toBe("failed");
182
  });
183
184
  it("refuses an empty prompt without registering a child", async () => {
185
    const { registry, fleet } = fleetOf(harness([]));
186
    expect(await fleet.submit({ description: "x", prompt: "   " })).toMatchObject({
187
      status: "refused",
188
      code: "empty_prompt",
189
    });
190
    expect(registry.list()).toHaveLength(0);
191
  });
192
193
  it("holds children over the cap as pending rather than starting them", async () => {
194
    const { registry, fleet } = fleetOf(harness([], { hold: true }), 2);
195
    const running = [
196
      fleet.submit({ description: "one", prompt: "go" }),
197
      fleet.submit({ description: "two", prompt: "go" }),
198
      fleet.submit({ description: "three", prompt: "go" }),
199
    ];
200
201
    await new Promise((resolve) => setTimeout(resolve, 10));
202
    const statuses = registry.list().map((task) => task.status);
203
    expect(statuses.filter((status) => status === "running")).toHaveLength(2);
204
    expect(statuses.filter((status) => status === "pending")).toHaveLength(1);
205
206
    registry.stopAll();
207
    expect((await Promise.all(running)).every((outcome) => outcome.status === "stopped")).toBe(
208
      true,
209
    );
210
  });
211
212
  it("refuses once the queue is full so a fan-out cannot grow without bound", async () => {
213
    const registry = new CoderTaskRegistry();
214
    const transcriptDirectory = mkdtempSync(join(tmpdir(), "delegate-test-"));
215
    const fleet = new DelegateFleet(registry, harness([], { hold: true }), {
216
      maxConcurrent: 1,
217
      maxQueued: 1,
218
      transcriptDirectory,
219
    });
220
221
    const first = fleet.submit({ description: "one", prompt: "go" });
222
    const second = fleet.submit({ description: "two", prompt: "go" });
223
    await new Promise((resolve) => setTimeout(resolve, 10));
224
225
    expect(await fleet.submit({ description: "three", prompt: "go" })).toMatchObject({
226
      status: "refused",
227
      code: "fleet_full",
228
    });
229
230
    registry.stopAll();
231
    await Promise.all([first, second]);
232
  });
233
234
  it("stops a running child on request", async () => {
235
    const { registry, fleet } = fleetOf(harness([], { hold: true }));
236
    const pending = fleet.submit({ description: "one", prompt: "go" });
237
    await new Promise((resolve) => setTimeout(resolve, 10));
238
239
    const id = registry.list()[0]?.id ?? "";
240
    expect(registry.stop(id)).toBe(true);
241
    expect(await pending).toEqual({ status: "stopped", taskId: id });
242
    // A stopped child is terminal, so stopping it again does nothing.
243
    expect(registry.stop(id)).toBe(false);
244
  });
245
});
246
247
describe("CoderSession delegation", () => {
248
  it("launches children from a /delegate line without spending a turn", async () => {
249
    const { registry, fleet } = fleetOf(harness([{ type: "text", value: "done" }]));
250
    const session = new CoderSession(silent, "repo", "main", {
251
      registry,
252
      fleet,
253
      label: "fake (fake/model)",
254
    });
255
256
    await session.submit("/delegate 3x tidy the imports");
257
    await new Promise((resolve) => setTimeout(resolve, 20));
258
259
    const snapshot = session.snapshot();
260
    expect(snapshot.turns).toBe(0);
261
    expect(snapshot.tasks).toHaveLength(3);
262
    expect(snapshot.tasks.every((task) => task.status === "completed")).toBe(true);
263
    // Every child is reported, and reporting clears the badge.
264
    const notices = snapshot.entries.filter((entry) => entry.role === "notice");
265
    expect(notices.some((entry) => entry.text.includes("Delegating 3 children"))).toBe(true);
266
    expect(notices.filter((entry) => entry.text.includes("finished"))).toHaveLength(3);
267
    expect(snapshot.tasks.some((task) => task.unread)).toBe(false);
268
  });
269
270
  it("says so when the session was started without a child model", async () => {
271
    const session = new CoderSession(silent, "repo", "main");
272
    await session.submit("/delegate write the docs");
273
274
    expect(session.canDelegate).toBe(false);
275
    expect(session.snapshot().entries.at(-1)?.text).toContain("cannot delegate");
276
  });
277
278
  it("explains the grammar rather than launching an empty child", async () => {
279
    const { registry, fleet } = fleetOf(harness([]));
280
    const session = new CoderSession(silent, "repo", "main", { registry, fleet, label: "fake" });
281
    await session.submit("/delegate");
282
283
    expect(session.snapshot().tasks).toHaveLength(0);
284
    expect(session.snapshot().entries.at(-1)?.text).toContain("Usage: /delegate");
285
  });
286
287
  it("stops every running child at once", async () => {
288
    const { registry, fleet } = fleetOf(harness([], { hold: true }));
289
    const session = new CoderSession(silent, "repo", "main", { registry, fleet, label: "fake" });
290
    await session.submit("/delegate 2x go");
291
    await new Promise((resolve) => setTimeout(resolve, 10));
292
293
    expect(session.stopTasks()).toBe(2);
294
    await new Promise((resolve) => setTimeout(resolve, 10));
295
    expect(session.snapshot().tasks.every((task) => task.status === "stopped")).toBe(true);
296
    session.close();
297
  });
298
});
299
300
describe("fleet rendering", () => {
301
  const registry = new CoderTaskRegistry();
302
  const task = registry.register(
303
    {
304
      id: "d1",
305
      description: "fix the retry test",
306
      prompt: "fix it",
307
      agent: "opencode",
308
      model: "fake/model",
309
      cwd: "/tmp",
310
      background: true,
311
    },
312
    1000,
313
  );
314
  registry.start(task.id, new AbortController());
315
  registry.recordToolUse(task.id, { toolName: "bash", target: "pnpm test" });
316
  registry.recordTokens(task.id, { input: 8000, output: 214 });
317
318
  it("says what a running child is doing", () => {
319
    const running = registry.list()[0];
320
    expect(running).toBeDefined();
321
    if (running === undefined) return;
322
    expect(taskActivity(running)).toBe("bash(pnpm test)");
323
    expect(fleetPhrase([running])).toBe("1 agent");
324
    const rows = fleetRows([running], 80);
325
    expect(rows[0]?.branch).toBe("└─");
326
    expect(rows[0]?.mark).toBe("◐");
327
    expect(rows[0]?.text).toContain("bash(pnpm test)");
328
    expect(rows[0]?.text).toContain("8.2k");
329
  });
330
331
  it("replaces the counters with a total once the child is done", () => {
332
    registry.complete(task.id, "done", 4000);
333
    const done = registry.list()[0];
334
    expect(done).toBeDefined();
335
    if (done === undefined) return;
336
    expect(taskActivity(done)).toBe("Done (1 tool use · 8.2k tokens · 3s)");
337
    expect(fleetPhrase([done])).toBe("1 done · 1 unread");
338
  });
339
340
  it("shortens token counts", () => {
341
    expect(formatTokens(999)).toBe("999");
342
    expect(formatTokens(8214)).toBe("8.2k");
343
    expect(formatTokens(2_000_000)).toBe("2M");
344
  });
345
346
  it("cuts a row to the width it was given", () => {
347
    const rows = fleetRows(registry.list(), 30);
348
    expect(rows.every((row) => [...row.text].length <= 30)).toBe(true);
349
  });
350
});

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