jeo-code 0.8.43 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,8 +19,9 @@ import { parseInThreadConfigCommand } from "../agent/notify/config-commands";
19
19
 
20
20
  import { createTodoTool, TODO_TOOL_PROTOCOL_LINE } from "../agent/todo-tool";
21
21
  import { createJobTool, JOB_TOOL_PROTOCOL_LINE } from "../agent/job-tool";
22
- import { JobRegistry, type MonitorJobEvent } from "../agent/job-registry";
23
22
  import { createMonitorTool, MONITOR_TOOL_PROTOCOL_LINE } from "../agent/monitor-tool";
23
+ import { MonitorRegistry } from "../agent/monitor-registry";
24
+ import { JobRegistry } from "../agent/job-registry";
24
25
  import { createIrcTool, IRC_TOOL_PROTOCOL_LINE } from "../agent/irc-tool";
25
26
  import { createGoalTool, GOAL_TOOL_PROTOCOL_LINE } from "../agent/goal-tool";
26
27
  import { createApproveTool, APPROVE_TOOL_PROTOCOL_LINE } from "../agent/approve-tool";
@@ -186,7 +187,6 @@ import {
186
187
  gatedStdout,
187
188
  formatTaskSubEvent,
188
189
  logTaskSubEvent,
189
- formatMonitorJobEvent,
190
190
  createStreamEvents,
191
191
  shouldUseOneShotTui,
192
192
  } from "./launch/stream";
@@ -910,6 +910,33 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
910
910
  sessionNotifyEndpoint.sendIdentity({ repo: cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd, branch, cwd });
911
911
  }
912
912
 
913
+ // Session-scoped registries for DETACHED subagents, background jobs, and monitors (gjc parity).
914
+ // Created ONCE for the whole launch session lifetime (not per-turn) so detached
915
+ // runs persist across turns and remain controllable/inspectable.
916
+ const subagentRegistry = new SubagentRegistry();
917
+ const jobRegistry = new JobRegistry();
918
+ let activeTui: LaunchTui | null = null;
919
+ const monitorRegistry = new MonitorRegistry({
920
+ onLine: (record, line) => {
921
+ const notice = `[monitor ${record.id}] ${line}`;
922
+ if (activeTui) activeTui.events().onNotice?.(notice);
923
+ else console.log(notice);
924
+ },
925
+ });
926
+ sessionNotifyEndpoint?.attachRegistry(subagentRegistry);
927
+
928
+ let cleanedUp = false;
929
+ const cleanupSession = async (): Promise<void> => {
930
+ if (cleanedUp) return;
931
+ cleanedUp = true;
932
+ subagentRegistry.cancelAll();
933
+ jobRegistry.cancelAll();
934
+ monitorRegistry.cancelAll();
935
+ sessionNotifyEndpoint?.detachRegistry();
936
+ await sessionNotifyEndpoint?.stop();
937
+ await stopSessionNotifyEndpoint(subagentRegistry);
938
+ };
939
+
913
940
  // Persist the active per-session model into the session header so `/resume` restores
914
941
  // it (each session can carry its own model independent of the global default).
915
942
  // Best-effort: a header-rewrite failure must never abort the turn.
@@ -1215,6 +1242,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1215
1242
  const { provider: activeProvider } = await describeModel(activeModel, turnConfig);
1216
1243
  const turnDirtyCount = branch ? gitDirtyCount(cwd) : undefined;
1217
1244
  const tui = useTui ? new LaunchTui({ model: activeModel, provider: activeProvider, sessionId, maxSteps: initialStepLimit, cwd, branch, dirtyCount: turnDirtyCount, thinking: activeThinking, routedTier: routed?.tier }) : null;
1245
+ activeTui = tui;
1218
1246
  if (routeCredentialNotice) {
1219
1247
  if (tui) tui.events().onNotice?.(routeCredentialNotice);
1220
1248
  else console.log(routeCredentialNotice);
@@ -1272,7 +1300,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1272
1300
  },
1273
1301
  onHardExit: () => {
1274
1302
  if (tui) tui.finish("Cancelled.", { ok: false });
1275
- process.exit(130);
1303
+ forceExitFromCtrlC();
1276
1304
  },
1277
1305
  });
1278
1306
  let compRes: Awaited<ReturnType<typeof maybeCompact>>;
@@ -1418,40 +1446,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1418
1446
  },
1419
1447
  onHardExit: () => {
1420
1448
  if (tui) tui.finish("Cancelled.", { ok: false });
1421
- process.exit(130);
1449
+ forceExitFromCtrlC();
1422
1450
  },
1423
1451
  });
1424
1452
  const ac = harness.controller;
1425
- // #9: per-turn registry for DETACHED subagents (task{detached:true}); the
1426
- // `subagent` tool controls them and cancelAll() in finally prevents orphans.
1427
- const subagentRegistry = new SubagentRegistry();
1428
- // TUI callbacks are scoped to this registry. cancelAll() may trigger a detached
1429
- // run's final event after the turn is complete; do not let it paint a freshly
1430
- // reset later TUI. Non-TUI streams retain direct event logging.
1431
- let acceptingTuiSubagentEvents = true;
1432
- const onSubagentEvent = useTui
1433
- ? (e: TaskSubEvent) => {
1434
- if (acceptingTuiSubagentEvents) tui?.onSubagentEvent(e);
1435
- }
1436
- : (e: TaskSubEvent) => logTaskSubEvent(e);
1437
- const jobRegistry = new JobRegistry();
1438
- // Monitor output stays bounded and turn-scoped. It deliberately feeds the
1439
- // engine's observational inbox rather than the steering inbox.
1440
- const monitorInbox: string[] = [];
1441
- const MONITOR_INBOX_CAP = 32;
1442
- let acceptingMonitorEvents = true;
1443
- const onMonitorEvent = (e: MonitorJobEvent) => {
1444
- if (!acceptingMonitorEvents) return;
1445
- if (useTui) tui?.onMonitorEvent(e);
1446
- else console.log(formatMonitorJobEvent(e));
1447
- if (e.type !== "line" || monitorInbox.length >= MONITOR_INBOX_CAP) return;
1448
- const description = e.record.description.replace(/\s+/g, " ").trim().slice(0, 240);
1449
- const line = e.line.slice(0, 2_000);
1450
- monitorInbox.push(`[monitor ${e.record.id} · ${e.record.category} · ${description}]\n${line}`);
1451
- };
1452
- const unsubscribeMonitor = jobRegistry.subscribeMonitor(onMonitorEvent);
1453
- const drainMonitor = () => monitorInbox.splice(0, monitorInbox.length);
1454
- sessionNotifyEndpoint?.attachRegistry(subagentRegistry);
1455
1453
  currentTurnSteer = { push: line => steerInbox.push(line), flushCard: line => tui?.flushSteerCard(line) };
1456
1454
  if (sessionNotifyEndpoint && !notifyRedact) {
1457
1455
  const preview = userInput.length > 200 ? `${userInput.slice(0, 200)}…` : userInput;
@@ -1523,18 +1521,22 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1523
1521
  steer: drainSteer,
1524
1522
  registry: subagentRegistry,
1525
1523
  sessionEndpoint: sessionNotifyEndpoint,
1526
- onEvent: onSubagentEvent,
1524
+ onEvent: useTui
1525
+ ? (e => tui?.onSubagentEvent(e))
1526
+ : (e => logTaskSubEvent(e)),
1527
1527
  }),
1528
1528
  eval: createEvalTool({
1529
1529
  config: { ...turnConfig, defaultModel: activeModel },
1530
1530
  signal: ac.signal,
1531
1531
  steer: drainSteer,
1532
- onEvent: onSubagentEvent,
1532
+ onEvent: useTui
1533
+ ? (e => tui?.onSubagentEvent(e))
1534
+ : (e => logTaskSubEvent(e)),
1533
1535
  }),
1534
1536
  todo: createTodoTool({ onChange: items => { turnTodos = items; tui?.setTodos(items); } }),
1535
1537
  subagent: createSubagentTool(subagentRegistry),
1536
1538
  job: createJobTool(jobRegistry),
1537
- monitor: createMonitorTool(jobRegistry),
1539
+ monitor: createMonitorTool(monitorRegistry),
1538
1540
  irc: createIrcTool(subagentRegistry),
1539
1541
  goal: createGoalTool(),
1540
1542
  approve: createApproveTool(),
@@ -1640,7 +1642,6 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1640
1642
  signal: ac.signal,
1641
1643
  sessionKey: cacheKey,
1642
1644
  steer: drainSteer,
1643
- monitor: drainMonitor,
1644
1645
  rateLimitFallbackAvailable,
1645
1646
  safetyFallbackAvailable,
1646
1647
  events: wrapEvents(withStepPersistence({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, persistTurnTail), opik),
@@ -1774,7 +1775,6 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1774
1775
  signal: ac.signal,
1775
1776
  sessionKey: turnSessionKey,
1776
1777
  steer: drainSteer,
1777
- monitor: drainMonitor,
1778
1778
  // Deliberately NOT wiring rateLimitFallbackAvailable here: this repair
1779
1779
  // retry has no reroute loop around it (unlike runLoopWithModel's
1780
1780
  // routeFallbackAttempt loop) — bailing the retry ladder early on a 429
@@ -1799,14 +1799,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1799
1799
  await opik.endTurn({ done: result.done, steps: result.steps, output: result.doneReason });
1800
1800
  } finally {
1801
1801
  harness.dispose();
1802
- acceptingTuiSubagentEvents = false;
1803
- acceptingMonitorEvents = false;
1804
- subagentRegistry.cancelAll(); // #9: no detached run leaks past the turn
1805
- sessionNotifyEndpoint?.detachRegistry(); // this turn's registry is gone — subagent frames stop applying to it
1802
+ activeTui = null;
1806
1803
  currentTurnSteer = undefined; // a remote message after this point queues instead of steering a dead turn
1807
- await stopSessionNotifyEndpoint(subagentRegistry); // tear down the FALLBACK lazy endpoint, if one was started (no-op when sessionNotifyEndpoint owns the registry)
1808
- jobRegistry.cancelAll(); // background jobs are turn-scoped too — no orphaned processes
1809
- unsubscribeMonitor();
1810
1804
 
1811
1805
  // Steering typed but never drained (e.g. entered just after the final step)
1812
1806
  // must not be lost — fold it into the next prompt draft so it runs next.
@@ -2060,7 +2054,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2060
2054
  const harness = createInFlightAbortHarness({
2061
2055
  captureEsc: false,
2062
2056
  onAbortNotice: msg => console.log(msg),
2063
- onHardExit: () => process.exit(130),
2057
+ onHardExit: () => forceExitFromCtrlC(),
2064
2058
  });
2065
2059
  const ac = harness.controller;
2066
2060
 
@@ -2273,7 +2267,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2273
2267
  const harness = createInFlightAbortHarness({
2274
2268
  captureEsc: false,
2275
2269
  onAbortNotice: msg => console.log(msg),
2276
- onHardExit: () => process.exit(130),
2270
+ onHardExit: () => forceExitFromCtrlC(),
2277
2271
  });
2278
2272
  const ac = harness.controller;
2279
2273
 
@@ -3171,8 +3165,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3171
3165
  } catch {
3172
3166
  // Best-effort terminal restore; process exit is the contract.
3173
3167
  }
3174
- // Fire-and-forget — see the identical rationale at the main hard-exit path.
3168
+ subagentRegistry.cancelAll();
3169
+ jobRegistry.cancelAll();
3170
+ monitorRegistry.cancelAll();
3171
+ sessionNotifyEndpoint?.detachRegistry();
3175
3172
  void sessionNotifyEndpoint?.stop();
3173
+ void stopSessionNotifyEndpoint(subagentRegistry);
3176
3174
  process.exit(130);
3177
3175
  };
3178
3176
  // Prompt Ctrl+C: a single press clears a non-empty box; on an empty box it exits.
@@ -4994,11 +4992,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4994
4992
  disarmPreview();
4995
4993
  out.write("\x1b[?25h\n");
4996
4994
  } catch { /* best effort */ }
4997
- // Fire-and-forget: process.exit() below does not wait for this, but the OS
4998
- // tears down the socket on process death regardless, and a leaked discovery
4999
- // file is self-healing (the daemon's scanSessions() reaps dead-pid entries
5000
- // on its next 3s poll — see notify-telegram-daemon.test.ts).
5001
- void sessionNotifyEndpoint?.stop();
4995
+ await cleanupSession();
5002
4996
  process.removeListener("SIGINT", handleCtrlC);
5003
4997
  process.stdin.off("data", handleCtrlCByte);
5004
4998
  drainPromptListeners();
@@ -5015,7 +5009,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
5015
5009
  // gjc-parity resume pointer (logs/gjc-tui-study analysis Gap C): leave the exact
5016
5010
  // resume command in scrollback on exit, mirroring the --list handler's convention.
5017
5011
  if (sessionId && !flags.noSession) console.log(formatResumeHint(sessionId));
5018
- await sessionNotifyEndpoint?.stop();
5012
+ await cleanupSession();
5019
5013
  process.removeListener("SIGINT", handleCtrlC);
5020
5014
  process.stdin.off("data", handleCtrlCByte);
5021
5015
  drainPromptListeners();
package/src/tui/app.ts CHANGED
@@ -20,7 +20,6 @@ import { renderFooter, type FooterData } from "./components/footer";
20
20
  import { renderForgeMark, forgeMarkHeight, forgeMarkFrameCount, forgeBeat } from "./components/ascii-art";
21
21
  import { evolutionTrack, createStageProgress, type StageProgress, transitionMessage } from "./components/evolution";
22
22
  import type { TaskSubEvent } from "../agent/task-tool";
23
- import type { MonitorJobEvent } from "../agent/job-registry";
24
23
  import { supportsUnicode } from "./components/capability";
25
24
  import { centerBlock, padLineTo, boxBlock, BOX_ASCII, BOX_UNICODE } from "./components/layout";
26
25
  import { SECTION_GAP, sectionLabel, stackSections } from "./components/section";
@@ -275,15 +274,19 @@ export class LaunchTui {
275
274
  // Cumulative token usage for the live turn (engine onUsage event).
276
275
  private turnUsage: { inputTokens: number; outputTokens: number } | null = null;
277
276
  // True while at least one delegated subagent SLOT is in flight — drives the `(sub)`
278
- // status marker. Derived from `subagentLiveSlots.size` rather than a plain boolean
279
- // so one completed concurrent run cannot clear still-running siblings.
277
+ // status marker. Derived from `subagentLiveSlots.size` (see below) rather than a
278
+ // plain boolean so a fan-out batch where ONE worker finishes early no longer
279
+ // clears the marker while its siblings are still visibly running.
280
280
  private subagentActive = false;
281
- // Latest nested subagent activity, PER CONCURRENT SLOT. Detached runs use their
282
- // stable registry id, fan-out workers use their 1-based index, and legacy
283
- // synchronous runs share one slot. `subagentLiveOrder` tracks touch order so the
284
- // status row can show the MOST RECENTLY active slot plus a "+N more" count.
285
- private readonly subagentLiveSlots = new Map<string, string>();
286
- private readonly subagentLiveOrder: string[] = [];
281
+ // Latest nested subagent activity, PER CONCURRENT SLOT (fan-out `task` batches — both
282
+ // the executor and read-only roles — now run several workers at once; a single
283
+ // shared string clobbered on every event made concurrent subagents look sequential
284
+ // and, worse, cleared the whole `(sub)` marker the instant ANY one worker finished).
285
+ // Keyed by stable `detachedId` when present, else fan-out `index` (1-based),
286
+ // else fixed key 0 for single foreground runs. `subagentLiveOrder` tracks touch order
287
+ // so the status row can show the MOST RECENTLY active slot plus a "+N more" count.
288
+ private readonly subagentLiveSlots = new Map<number | string, string>();
289
+ private readonly subagentLiveOrder: (number | string)[] = [];
287
290
  // Bounded activity-history ring: one plain-text entry per ledger append, with a
288
291
  // turn-relative timestamp. Powers Ctrl+O's "recent activity" tail so the detail
289
292
  // view ALWAYS answers "what has been happening", even before the first reply.
@@ -1001,9 +1004,10 @@ export class LaunchTui {
1001
1004
  }
1002
1005
  // A delegated subagent's LATEST nested event beats the parent's static
1003
1006
  // "Task: <role> …" card title — a long task otherwise reads as a stall even
1004
- // though the subagent is actively reading/editing/running underneath. When
1005
- // multiple concurrent slots live, append a "+N more running" count so parallel
1006
- // work does not look like one subagent whose label keeps randomly changing.
1007
+ // though the subagent is actively reading/editing/running underneath. When a
1008
+ // fan-out batch has MULTIPLE concurrent slots live, append a "+N more running"
1009
+ // count so a parallel batch visibly reads as parallel instead of looking like
1010
+ // one subagent whose label keeps randomly changing.
1007
1011
  if (this.subagentActive && this.subagentLiveSlots.size > 0) {
1008
1012
  const mostRecent = this.subagentLiveOrder[this.subagentLiveOrder.length - 1];
1009
1013
  const line = (mostRecent !== undefined ? this.subagentLiveSlots.get(mostRecent) : undefined)
@@ -1058,8 +1062,11 @@ export class LaunchTui {
1058
1062
  if (this.finished) return;
1059
1063
  const color = this.theme.color;
1060
1064
  const role = e.role || "subagent";
1061
- const roleLabel = e.index && e.total ? `${role.toUpperCase()}[${e.index}/${e.total}]` : role.toUpperCase();
1062
- const detachedLabel = e.detached && e.id ? ` [${e.id}]` : "";
1065
+ const roleLabel = e.detachedId
1066
+ ? `${role.toUpperCase()}[${e.detachedId}]`
1067
+ : e.index && e.total
1068
+ ? `${role.toUpperCase()}[${e.index}/${e.total}]`
1069
+ : role.toUpperCase();
1063
1070
  const badge = categoryBadge("subagent", { color });
1064
1071
  const ok = this.unicode ? "✓" : "v";
1065
1072
  const bad = this.unicode ? "✗" : "x";
@@ -1067,14 +1074,10 @@ export class LaunchTui {
1067
1074
  const last = this.unicode ? "└─" : "`-";
1068
1075
  const detail = (e.detail ?? "").split("\n").find(l => l.trim().length > 0)?.trim().slice(0, 140) ?? "";
1069
1076
  const summary = e.summary ? ` — ${e.summary}` : "";
1070
- // Detached runs must retain their registry identity so concurrent detached events
1071
- // cannot collide. Fan-out indices remain separate, while legacy synchronous/eval
1072
- // events preserve their single shared slot.
1073
- const slot = e.detached && e.id
1074
- ? `detached:${e.id}`
1075
- : e.index !== undefined
1076
- ? `fanout:${e.index}`
1077
- : "single";
1077
+ // Slot key: stable `detachedId` when present (detached background task),
1078
+ // else fan-out 1-based `index` (concurrent batch worker),
1079
+ // else fixed key 0 (single foreground run).
1080
+ const slot: number | string = e.detachedId ?? e.index ?? 0;
1078
1081
  const touchSlot = (line: string) => {
1079
1082
  this.subagentLiveSlots.set(slot, line);
1080
1083
  const at = this.subagentLiveOrder.indexOf(slot);
@@ -1087,58 +1090,41 @@ export class LaunchTui {
1087
1090
  // activity readable in scrollback and visually separate from parent tools.
1088
1091
  switch (e.kind) {
1089
1092
  case "start":
1090
- touchSlot(`${roleLabel}${detachedLabel} ${this.unicode ? "▸" : ">"} ${detail || "starting"}`);
1091
- this.appendLedger(`${badge} ${this.unicode ? "▸" : ">"} ${roleLabel}${detachedLabel} · ${detail}\n`, "subagent");
1093
+ touchSlot(`${roleLabel} ${this.unicode ? "▸" : ">"} ${detail || "starting"}`);
1094
+ this.appendLedger(`${badge} ${this.unicode ? "▸" : ">"} ${roleLabel} · ${detail}\n`, "subagent");
1092
1095
  break;
1093
1096
  case "step":
1094
- touchSlot(`${roleLabel}${detachedLabel} ${this.unicode ? "·" : "-"} ${detail || "working"}`);
1095
- this.appendLedger(` ${badge} ${branch} ${roleLabel}${detachedLabel} · ${detail || "working"}\n`, "subagent");
1097
+ touchSlot(`${roleLabel} ${this.unicode ? "·" : "-"} ${detail || "working"}`);
1098
+ this.appendLedger(` ${badge} ${branch} ${roleLabel} · ${detail || "working"}\n`, "subagent");
1096
1099
  break;
1097
1100
  case "tool":
1098
- touchSlot(`${roleLabel}${detachedLabel} ${e.success === false ? bad : ok} ${detail || "tool"}`);
1099
- this.appendLedger(` ${badge} ${branch} ${roleLabel}${detachedLabel} ${e.success === false ? bad : ok} ${detail || "tool"}${summary}\n`, "subagent");
1101
+ touchSlot(`${roleLabel} ${e.success === false ? bad : ok} ${detail || "tool"}`);
1102
+ this.appendLedger(` ${badge} ${branch} ${roleLabel} ${e.success === false ? bad : ok} ${detail || "tool"}${summary}\n`, "subagent");
1100
1103
  break;
1101
1104
  case "error":
1102
- touchSlot(`${roleLabel}${detachedLabel} ${bad} ${detail || "error"}`);
1103
- this.appendLedger(` ${badge} ${branch} ${roleLabel}${detachedLabel} ${bad} ${detail || "error"}\n`, "subagent");
1105
+ touchSlot(`${roleLabel} ${bad} ${detail || "error"}`);
1106
+ this.appendLedger(` ${badge} ${branch} ${roleLabel} ${bad} ${detail || "error"}\n`, "subagent");
1104
1107
  break;
1105
1108
  case "thinking":
1106
1109
  // Live-only preview (mirrors the main turn's dimmed "Thinking" block) — never
1107
1110
  // persisted to the ledger; see TaskSubEvent.kind's doc comment for why.
1108
- touchSlot(`${roleLabel}${detachedLabel} ${this.unicode ? "…" : "..."} ${detail || "thinking"}`);
1111
+ touchSlot(`${roleLabel} ${this.unicode ? "…" : "..."} ${detail || "thinking"}`);
1109
1112
  break;
1110
1113
  case "done":
1111
- // Clear ONLY this slot — completing one concurrent run must leave its
1112
- // still-running siblings visible and keep the `(sub)` marker active.
1114
+ // Clear ONLY this slot — a fan-out batch where one worker finishes early
1115
+ // must keep showing its still-running siblings, not drop the `(sub)`
1116
+ // marker for the whole batch (the bug this per-slot map fixes).
1113
1117
  this.subagentLiveSlots.delete(slot);
1114
1118
  {
1115
1119
  const at = this.subagentLiveOrder.indexOf(slot);
1116
1120
  if (at !== -1) this.subagentLiveOrder.splice(at, 1);
1117
1121
  }
1118
1122
  this.subagentActive = this.subagentLiveSlots.size > 0;
1119
- this.appendLedger(`${badge} ${last} ${roleLabel}${detachedLabel} done${e.tokens ? ` (${e.tokens.input + e.tokens.output} tok)` : ""}${e.success === false ? " (incomplete)" : ""}: ${detail}\n`, "subagent");
1123
+ this.appendLedger(`${badge} ${last} ${roleLabel} done${e.tokens ? ` (${e.tokens.input + e.tokens.output} tok)` : ""}${e.success === false ? " (incomplete)" : ""}: ${detail}\n`, "subagent");
1120
1124
  break;
1121
1125
  }
1122
1126
  this.draw();
1123
1127
  }
1124
- /** Render turn-scoped monitor lifecycle/output separately from subagent activity. */
1125
- onMonitorEvent(e: MonitorJobEvent): void {
1126
- if (this.finished) return;
1127
- const badge = categoryBadge("progress", { color: this.theme.color });
1128
- const label = sanitizeForFrame(
1129
- `${e.record.id} · ${e.record.category} · ${e.record.description}`,
1130
- ).replace(/\s+/g, " ").trim().slice(0, 180);
1131
- const branch = this.unicode ? "│" : "|";
1132
- if (e.type === "start") {
1133
- this.appendLedger(`${badge} ${this.unicode ? "◉" : "o"} Monitor ${label} started\n`, "monitor");
1134
- } else if (e.type === "line") {
1135
- const line = sanitizeForFrame(e.line).split("\n").find(row => row.trim())?.trim().slice(0, 180) ?? "";
1136
- this.appendLedger(`${badge} ${branch} Monitor ${label}${line ? ` — ${line}` : ""}\n`, "monitor");
1137
- } else {
1138
- this.appendLedger(`${badge} ${this.unicode ? "└" : "`"} Monitor ${label} done\n`, "monitor");
1139
- }
1140
- this.draw();
1141
- }
1142
1128
 
1143
1129
  start(): void {
1144
1130
  this.startedAt = Date.now();
@@ -1502,12 +1488,13 @@ export class LaunchTui {
1502
1488
  * block shows only the most-recent lines, capped at ~30% of the screen height (a
1503
1489
  * ceiling guards a tall terminal), so it grows with the stream and shrinks with the
1504
1490
  * viewport. Returns [] when there is nothing to show. */
1505
- /** gjc-parity multi-line parallel-subagent panel: when MORE THAN ONE concurrent
1506
- * fan-out or detached slot is live, render one status line PER active slot
1491
+ /** gjc-parity multi-line parallel-subagent panel: when a fan-out `task` batch has
1492
+ * MORE THAN ONE concurrent slot live, render one status line PER active slot
1507
1493
  * (most-recently-touched first) instead of collapsing everything into a single
1508
- * "+N more running" summary line (see `currentActivity()`). A single subagent
1509
- * still uses the plain status-line path; this panel makes concurrent work visibly
1510
- * parallel while capping rows so a large batch cannot blow the frame. */
1494
+ * "+N more running" summary line (see `currentActivity()`). A single/detached
1495
+ * subagent still uses the plain status-line path — this panel exists specifically
1496
+ * to make a truly PARALLEL batch visibly read as parallel: every worker's latest
1497
+ * activity is on screen at once, capped so a large fan-out can't blow the frame. */
1511
1498
  private static readonly SUBAGENT_PANEL_MAX_ROWS = 6;
1512
1499
  private renderSubagentPanel(cols: number): string[] {
1513
1500
  if (this.subagentLiveSlots.size <= 1) return [];
@@ -1706,8 +1693,9 @@ export class LaunchTui {
1706
1693
  if (this.runningTool && this.liveToolOutput.trim()) {
1707
1694
  tail.push(...this.renderLiveBlock("Output", this.liveToolOutput, cols, rows, 8));
1708
1695
  }
1709
- // Parallel subagent panel: more than one fan-out or detached slot gets its own
1710
- // multi-line block so genuinely parallel work does not read as one rotating line.
1696
+ // Parallel subagent panel (gjc parity): a fan-out `task` batch with more than one
1697
+ // concurrent slot live gets its OWN multi-line block — one row per active worker —
1698
+ // so a genuinely parallel batch reads as parallel instead of a single rotating line.
1711
1699
  if (this.subagentActive) {
1712
1700
  tail.push(...this.renderSubagentPanel(cols));
1713
1701
  }