codedeck 0.7.0 → 0.7.2

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.
Files changed (127) hide show
  1. package/README.md +49 -34
  2. package/dist/cli/commands/claims.js +3 -2
  3. package/dist/cli/commands/claims.js.map +1 -1
  4. package/dist/cli/commands/doctor.js +8 -40
  5. package/dist/cli/commands/doctor.js.map +1 -1
  6. package/dist/cli/commands/logs.js +2 -2
  7. package/dist/cli/commands/logs.js.map +1 -1
  8. package/dist/cli/commands/open.js +8 -9
  9. package/dist/cli/commands/open.js.map +1 -1
  10. package/dist/cli/commands/ps.js +6 -6
  11. package/dist/cli/commands/ps.js.map +1 -1
  12. package/dist/cli/commands/review.js +36 -58
  13. package/dist/cli/commands/review.js.map +1 -1
  14. package/dist/cli/commands/run.js +27 -27
  15. package/dist/cli/commands/run.js.map +1 -1
  16. package/dist/cli/commands/setup.js +96 -314
  17. package/dist/cli/commands/setup.js.map +1 -1
  18. package/dist/cli/commands/show.js +2 -1
  19. package/dist/cli/commands/show.js.map +1 -1
  20. package/dist/cli/commands/ui.js +61 -0
  21. package/dist/cli/commands/ui.js.map +1 -0
  22. package/dist/cli/commands/usage.js +65 -42
  23. package/dist/cli/commands/usage.js.map +1 -1
  24. package/dist/cli/commands/wait.js +3 -2
  25. package/dist/cli/commands/wait.js.map +1 -1
  26. package/dist/cli/index.js +55 -39
  27. package/dist/cli/index.js.map +1 -1
  28. package/dist/cli/web-launch.js +126 -0
  29. package/dist/cli/web-launch.js.map +1 -0
  30. package/dist/config/config.js +0 -70
  31. package/dist/config/config.js.map +1 -1
  32. package/dist/config/paths.js +1 -0
  33. package/dist/config/paths.js.map +1 -1
  34. package/dist/config/setup.js +248 -0
  35. package/dist/config/setup.js.map +1 -0
  36. package/dist/config/web-host.js +31 -0
  37. package/dist/config/web-host.js.map +1 -0
  38. package/dist/config/web-port.js +24 -0
  39. package/dist/config/web-port.js.map +1 -0
  40. package/dist/core/claude-transcript.js +87 -0
  41. package/dist/core/claude-transcript.js.map +1 -1
  42. package/dist/core/errors.js +19 -39
  43. package/dist/core/errors.js.map +1 -1
  44. package/dist/core/models.js +4 -1
  45. package/dist/core/models.js.map +1 -1
  46. package/dist/core/pricing.js +73 -50
  47. package/dist/core/pricing.js.map +1 -1
  48. package/dist/core/roles.js +6 -5
  49. package/dist/core/roles.js.map +1 -1
  50. package/dist/core/run-skills.js +114 -0
  51. package/dist/core/run-skills.js.map +1 -0
  52. package/dist/core/run-usage.js +4 -1
  53. package/dist/core/run-usage.js.map +1 -1
  54. package/dist/core/session.js +6 -16
  55. package/dist/core/session.js.map +1 -1
  56. package/dist/core/usage-query.js +41 -0
  57. package/dist/core/usage-query.js.map +1 -0
  58. package/dist/daemon/build-id.js +30 -0
  59. package/dist/daemon/build-id.js.map +1 -0
  60. package/dist/daemon/daemon.js +1113 -799
  61. package/dist/daemon/daemon.js.map +1 -1
  62. package/dist/daemon/instance-lock.js +48 -0
  63. package/dist/daemon/instance-lock.js.map +1 -0
  64. package/dist/daemon/web-supervisor.js +251 -0
  65. package/dist/daemon/web-supervisor.js.map +1 -0
  66. package/dist/drivers/antigravity/parser.js +7 -10
  67. package/dist/drivers/antigravity/parser.js.map +1 -1
  68. package/dist/drivers/claude/driver.js +1 -0
  69. package/dist/drivers/claude/driver.js.map +1 -1
  70. package/dist/drivers/codex/driver.js +14 -3
  71. package/dist/drivers/codex/driver.js.map +1 -1
  72. package/dist/drivers/codex/host-skills.js +57 -0
  73. package/dist/drivers/codex/host-skills.js.map +1 -0
  74. package/dist/drivers/opencode/driver.js +6 -2
  75. package/dist/drivers/opencode/driver.js.map +1 -1
  76. package/dist/drivers/session-runtime.js +2 -0
  77. package/dist/drivers/session-runtime.js.map +1 -1
  78. package/dist/drivers/terminal.js +15 -0
  79. package/dist/drivers/terminal.js.map +1 -1
  80. package/dist/git/diff.js +29 -4
  81. package/dist/git/diff.js.map +1 -1
  82. package/dist/git/repository.js +0 -20
  83. package/dist/git/repository.js.map +1 -1
  84. package/dist/git/worktree.js +0 -14
  85. package/dist/git/worktree.js.map +1 -1
  86. package/dist/open/contract.js.map +1 -1
  87. package/dist/open/pty.js +330 -5
  88. package/dist/open/pty.js.map +1 -1
  89. package/dist/plugin/agents/general.md +33 -6
  90. package/dist/plugin/hooks/pane-ticker.ts +102 -0
  91. package/dist/plugin/hooks/register.tsx +33 -8
  92. package/dist/plugin/mods/agents/pane.ts +259 -136
  93. package/dist/plugin/mods/agents/types.ts +19 -3
  94. package/dist/plugin/statusline.sh +22 -34
  95. package/dist/store/database.js +5 -7
  96. package/dist/store/database.js.map +1 -1
  97. package/dist/store/sessions.js +114 -81
  98. package/dist/store/sessions.js.map +1 -1
  99. package/dist/utils/process.js +2 -49
  100. package/dist/utils/process.js.map +1 -1
  101. package/dist/web/brand.js +59 -0
  102. package/dist/web/brand.js.map +1 -0
  103. package/dist/web/child.js +67 -0
  104. package/dist/web/child.js.map +1 -0
  105. package/dist/web/home-page.js +48 -0
  106. package/dist/web/home-page.js.map +1 -0
  107. package/dist/web/review-page.js +12 -5
  108. package/dist/web/review-page.js.map +1 -1
  109. package/dist/web/security.js +170 -0
  110. package/dist/web/security.js.map +1 -0
  111. package/dist/web/server.js +198 -0
  112. package/dist/web/server.js.map +1 -0
  113. package/dist/web/setup-icons.js +37 -0
  114. package/dist/web/setup-icons.js.map +1 -0
  115. package/dist/web/setup-page.js +1795 -0
  116. package/dist/web/setup-page.js.map +1 -0
  117. package/dist/web/setup-routes.js +475 -0
  118. package/dist/web/setup-routes.js.map +1 -0
  119. package/dist/web/usage-page.js +609 -0
  120. package/dist/web/usage-page.js.map +1 -0
  121. package/dist/web/usage-routes.js +88 -0
  122. package/dist/web/usage-routes.js.map +1 -0
  123. package/dist/web/web-token.js +56 -0
  124. package/dist/web/web-token.js.map +1 -0
  125. package/package.json +1 -1
  126. package/dist/plugin/prompts/_partials/commit.md +0 -145
  127. package/dist/plugin/prompts/_partials/pr-writer.md +0 -186
@@ -7,6 +7,7 @@ import type { Register } from "claude-code";
7
7
  import { formatPane, paneButtonLabel, selectPane } from "../mods/agents/pane.js";
8
8
  import { parseRows } from "../mods/agents/parse.js";
9
9
  import type { PaneSnapshot } from "../mods/agents/types.js";
10
+ import { createPaneTicker, type PaneTicker } from "./pane-ticker.js";
10
11
  import { togglePane } from "./pane-toggle.js";
11
12
 
12
13
  // Stable pane id: 1 to 64 letters, digits, "_" or "-". open carries it into
@@ -51,14 +52,17 @@ const toggleAgentsPane = async ($: Engine$, isOpen: boolean): Promise<boolean> =
51
52
 
52
53
  // Passing $ into a helper is allowed, verified. What the engine refuses is
53
54
  // pulling a namespace off it: `const P = $.process` fails to load the module.
54
- // So refresh takes $ as a parameter and the state register owns travels beside
55
- // it in this object. paneOpen never leaves register, so it stays a bare let.
55
+ // refresh takes $ as a parameter; its state and paneOpen stay in this register.
56
56
  type PaneState = {
57
57
  // Last good snapshot; undefined until one refresh has fully succeeded, which
58
58
  // is how the pane draws nothing rather than a guess.
59
59
  snapshot: PaneSnapshot | undefined;
60
60
  inFlight: boolean;
61
61
  lastRefreshEndedAt: number;
62
+ ticker: PaneTicker | undefined;
63
+ paneOpen: boolean;
64
+ tickInvalidate: () => unknown;
65
+ tickRefresh: () => unknown;
62
66
  // True once session.start saw a CODEDECK_RUN_ID. Cached because ui.render
63
67
  // fires on every drawing pass and must not await an env read to decide
64
68
  // whether to draw one button.
@@ -67,6 +71,13 @@ type PaneState = {
67
71
 
68
72
  const TOOL_REFRESH_GAP_MS = 1500;
69
73
 
74
+ function syncTicker($: Engine$, state: PaneState, refreshCompleted = false): void {
75
+ state.tickInvalidate = () => $.ui.invalidate("ui.render");
76
+ state.tickRefresh = () => refresh($, state);
77
+ if (refreshCompleted) state.ticker?.refreshed();
78
+ state.ticker?.update(state.paneOpen, state.snapshot);
79
+ }
80
+
70
81
  const refresh = async ($: Engine$, state: PaneState): Promise<void> => {
71
82
  // Claim the slot before the first await. Guard and set must not straddle a
72
83
  // yield point: with the `$.env.get` read in between, two tool.call firings
@@ -103,6 +114,7 @@ const refresh = async ($: Engine$, state: PaneState): Promise<void> => {
103
114
  } finally {
104
115
  state.inFlight = false;
105
116
  state.lastRefreshEndedAt = Date.now();
117
+ syncTicker($, state, true);
106
118
  }
107
119
  };
108
120
 
@@ -112,10 +124,21 @@ export const register: Register = (on) => {
112
124
  inFlight: false,
113
125
  lastRefreshEndedAt: 0,
114
126
  hasRun: false,
127
+ ticker: undefined,
128
+ paneOpen: false,
129
+ tickInvalidate: () => undefined,
130
+ tickRefresh: () => undefined,
115
131
  };
116
- let paneOpen = false;
132
+ state.ticker = createPaneTicker({
133
+ now: () => Date.now(),
134
+ setInterval: (callback, milliseconds) => setInterval(callback, milliseconds),
135
+ clearInterval: (handle) => clearInterval(handle as ReturnType<typeof setInterval>),
136
+ invalidate: () => state.tickInvalidate(),
137
+ refresh: () => state.tickRefresh(),
138
+ });
117
139
 
118
140
  on("session.start", async ($, e, next) => {
141
+ syncTicker($, state);
119
142
  // Not "agents": the engine refuses it with `$.command.register: "/agents"
120
143
  // refused: it is the built-in /agents`. "band", "deck" and
121
144
  // "codedeck-agents" were each verified free. The description is load
@@ -145,10 +168,11 @@ export const register: Register = (on) => {
145
168
  // is the one call here not yet verified in a PTY session, paneOpen stays
146
169
  // true and the next /band retries the close, instead of the flag and the
147
170
  // pane desyncing for the rest of the session.
148
- paneOpen = await toggleAgentsPane($, paneOpen);
171
+ state.paneOpen = await toggleAgentsPane($, state.paneOpen);
172
+ syncTicker($, state);
149
173
  await $.ui.invalidate("ui.render");
150
- if (paneOpen) void refresh($, state);
151
- return { text: paneOpen ? "agents pane open" : "agents pane closed" };
174
+ if (state.paneOpen) void refresh($, state);
175
+ return { text: state.paneOpen ? "agents pane open" : "agents pane closed" };
152
176
  });
153
177
 
154
178
  on("turn.complete", async ($, e, next) => {
@@ -172,9 +196,10 @@ export const register: Register = (on) => {
172
196
  // A $ captured from a past render does work, verified, but it outlives the
173
197
  // event it came from and nothing promises how long.
174
198
  if ((e as { element?: string }).element === BUTTON_KEY) {
175
- paneOpen = await toggleAgentsPane($, paneOpen);
199
+ state.paneOpen = await toggleAgentsPane($, state.paneOpen);
200
+ syncTicker($, state);
176
201
  await $.ui.invalidate("ui.render");
177
- if (paneOpen) void refresh($, state);
202
+ if (state.paneOpen) void refresh($, state);
178
203
  }
179
204
  return await next(e);
180
205
  });
@@ -100,8 +100,28 @@ function text(value: unknown): string {
100
100
  return value;
101
101
  }
102
102
 
103
- function toPaneRow(row: SessionRow): PaneRow {
103
+ function displaySessionId(value: unknown): string {
104
+ // Mirrors SESSION_ID_DISPLAY_LENGTH in src/core/session.ts.
105
+ return text(value).slice(0, 8);
106
+ }
107
+
108
+ /** Shorten a leading home directory without relying on a process global. */
109
+ export function displayPath(path: string): string {
110
+ return path.replace(/^\/(?:home|Users)\/[^/]+(?=\/|$)/, "~");
111
+ }
112
+
113
+ // `workers` is the set of ids the snapshot keeps: a parent outside it (the
114
+ // run root, a row cut by the budget, a legacy row with no parent) leaves
115
+ // parentId undefined, which draws the card under the root.
116
+ function toPaneRow(row: SessionRow, workers: ReadonlySet<string>): PaneRow {
104
117
  const iso = typeof row.updatedAt === "string" ? row.updatedAt : undefined;
118
+ const parent = text(row.parentId);
119
+ const path =
120
+ typeof row.worktree === "string" && row.worktree !== ""
121
+ ? row.worktree
122
+ : typeof row.cwd === "string" && row.cwd !== ""
123
+ ? row.cwd
124
+ : undefined;
105
125
  return {
106
126
  id: row.id,
107
127
  status: row.status || EMPTY_CELL,
@@ -110,6 +130,10 @@ function toPaneRow(row: SessionRow): PaneRow {
110
130
  effort: text(row.effort) || undefined,
111
131
  name: row.name || EMPTY_CELL,
112
132
  updatedAt: iso,
133
+ createdAt: typeof row.createdAt === "string" ? row.createdAt : undefined,
134
+ parentId: parent !== "" && parent !== row.id && workers.has(parent) ? parent : undefined,
135
+ role: text(row.role) || undefined,
136
+ path,
113
137
  };
114
138
  }
115
139
 
@@ -118,7 +142,8 @@ function toPaneRow(row: SessionRow): PaneRow {
118
142
  *
119
143
  * Filters to `runId`, lifts the single `origin === "open"` row out as the
120
144
  * orchestrator, orders the rest by `updatedAt` descending with a total order,
121
- * and cuts to `budget`. With no explicit budget every matched row is kept and
145
+ * and cuts to `budget`. Each kept row carries the id of the kept row that
146
+ * dispatched it, so formatPane can draw who started whom. With no explicit budget every matched row is kept and
122
147
  * `hidden` is 0; dropping rows to fit the pane is formatPane's decision.
123
148
  */
124
149
  export function selectPane(rows: SessionRow[], runId: string, budget?: number): PaneSnapshot {
@@ -134,15 +159,17 @@ export function selectPane(rows: SessionRow[], runId: string, budget?: number):
134
159
  agent: text(orchestratorRow.agent) || EMPTY_CELL,
135
160
  model: text(orchestratorRow.model) || undefined,
136
161
  effort: text(orchestratorRow.effort) || undefined,
162
+ role: text(orchestratorRow.role) || undefined,
137
163
  }
138
164
  : undefined;
139
165
  const workers = matching.filter((row) => row.origin !== "open");
140
166
  const ordered = [...workers].sort(compareRows);
141
167
  const kept = ordered.slice(0, limit);
168
+ const keptIds = new Set(kept.map((row) => row.id));
142
169
  return {
143
170
  runId: text(runId),
144
171
  orchestrator,
145
- rows: kept.map(toPaneRow),
172
+ rows: kept.map((row) => toPaneRow(row, keptIds)),
146
173
  hidden: ordered.length - kept.length,
147
174
  total: matching.length,
148
175
  };
@@ -170,6 +197,17 @@ function fit(value: string, width: number, fill = " "): string {
170
197
  return (cut + "…").padEnd(width, fill);
171
198
  }
172
199
 
200
+ // Keep the tail of a value, never leave half a surrogate pair after the marker,
201
+ // and pad to the exact width used by the pane.
202
+ function fitStart(value: string, width: number, fill = " "): string {
203
+ if (width <= 0) return "";
204
+ if (value.length <= width) return value.padEnd(width, fill);
205
+ let start = value.length - (width - 1);
206
+ const first = value.charCodeAt(start);
207
+ if (first >= 0xdc00 && first <= 0xdfff) start += 1;
208
+ return ("…" + value.slice(start)).padEnd(width, fill);
209
+ }
210
+
173
211
  function glyph(agent: unknown): string {
174
212
  const key = text(agent);
175
213
  return HARNESS_GLYPH[key] ?? "□";
@@ -212,6 +250,32 @@ function age(iso: unknown, now: number): string {
212
250
  return `${Math.floor(minutes / 60)}h`;
213
251
  }
214
252
 
253
+ /** Stable elapsed-time text for a card detail line. */
254
+ export function elapsed(ms: number): string {
255
+ if (!Number.isFinite(ms) || ms < 0) return "0s";
256
+ const seconds = Math.floor(ms / 1000);
257
+ if (seconds < 60) return `${seconds}s`;
258
+ const minutes = Math.floor(seconds / 60);
259
+ const paddedSeconds = String(seconds % 60).padStart(2, "0");
260
+ if (minutes < 60) return `${minutes}m ${paddedSeconds}s`;
261
+ return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m ${paddedSeconds}s`;
262
+ }
263
+
264
+ function cardElapsed(row: Partial<PaneRow>, now: number): string {
265
+ if (typeof row.createdAt !== "string") return "";
266
+ const createdAt = Date.parse(row.createdAt);
267
+ if (!Number.isFinite(createdAt)) return "";
268
+
269
+ const live = LIVE.has(row.status ?? "") || WAIT.has(row.status ?? "");
270
+ const endAt = live
271
+ ? now
272
+ : typeof row.updatedAt === "string"
273
+ ? Date.parse(row.updatedAt)
274
+ : Number.NaN;
275
+ if (!Number.isFinite(endAt)) return "";
276
+ return elapsed(endAt - createdAt);
277
+ }
278
+
215
279
  function count(value: unknown): number {
216
280
  return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
217
281
  }
@@ -235,22 +299,30 @@ function rowAge(iso: unknown): number {
235
299
  return Number.isNaN(then) ? Number.NEGATIVE_INFINITY : then;
236
300
  }
237
301
 
238
- // One drawable piece of the pane: an orchestrator card, a worker card, or a
239
- // compact history section. `count` is how many sessions disappear if the block
240
- // is evicted to make room for the frame and footer. `size` counts only the
241
- // block's own lines; the assembled order adds any connector above it.
242
- type Block = {
243
- kind: "card" | "history";
244
- size: number;
245
- age: number;
302
+ // One card of the lineage tree. `children` holds only the cards drawn under
303
+ // it; finished leaves go to the history section instead of the tree.
304
+ type Card = {
246
305
  id: string;
247
- count: number;
248
- build: (connects: boolean, lead: boolean) => string[];
306
+ age: number;
307
+ body: (inner: number) => string[];
308
+ parent: Card | undefined;
309
+ children: Card[];
249
310
  };
250
311
 
312
+ function roleLabel(role: unknown): string {
313
+ const clean = cell(role);
314
+ return clean === EMPTY_CELL ? "" : clean.charAt(0).toUpperCase() + clean.slice(1);
315
+ }
316
+
251
317
  // One drawing pass, with the widths derived from the column budget. Kept inside
252
318
  // its own function so formatPane can wrap the whole thing in one guard.
253
319
  // `limit` is the usable body height in rows, or undefined for unbounded.
320
+ //
321
+ // The body is a tree: every card hangs from the session that dispatched it,
322
+ // so a reviewer a worker started is drawn under that worker rather than under
323
+ // the run root. A child card joins its parent through an elbow on the card's
324
+ // first body line; the parent's bottom edge carries a tee where the trunk
325
+ // leaves it.
254
326
  function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined): string[] {
255
327
  const source = (snapshot ?? {}) as Partial<PaneSnapshot>;
256
328
  const rows = (Array.isArray(source.rows) ? source.rows : []).map(
@@ -258,7 +330,7 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined
258
330
  );
259
331
  const orchestrator =
260
332
  source.orchestrator && typeof source.orchestrator === "object"
261
- ? (source.orchestrator as Partial<{ agent: string; model: string; effort: string }>)
333
+ ? (source.orchestrator as Partial<{ agent: string; model: string; effort: string; role: string }>)
262
334
  : undefined;
263
335
  const hidden = count(source.hidden);
264
336
  const total = count(source.total);
@@ -267,23 +339,16 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined
267
339
  if (rows.length === 0 && orchestrator === undefined) return [];
268
340
 
269
341
  const workerTotal = rows.length + hidden;
342
+ const isLive = (row: Partial<PaneRow>) => LIVE.has(row.status ?? "") || WAIT.has(row.status ?? "");
270
343
  const working = rows.filter((row) => LIVE.has(row.status ?? "")).length;
271
344
  const waiting = rows.filter((row) => WAIT.has(row.status ?? "")).length;
272
345
  // The overflow is reported by count only, its statuses are not in the
273
346
  // snapshot, so it is folded into "finished": the most recently touched
274
347
  // sessions are the live ones and stay inside the budget.
275
348
  const finished = Math.max(0, workerTotal - working - waiting);
276
- const finishedRows = rows.filter(
277
- (row) => !LIVE.has(row.status ?? "") && !WAIT.has(row.status ?? ""),
278
- );
279
349
 
280
350
  const W = Math.trunc(columns);
281
351
  const IN = W - 2;
282
- // Rule 8: the card box caps at MAX_CARD_COLUMNS no matter how wide the dock
283
- // gets; the outer frame still spans the full width. The two-space inset the
284
- // cards have always sat at inside the frame is kept.
285
- const CARD = Math.min(IN - 4, MAX_CARD_COLUMNS);
286
- const INNER = Math.max(0, CARD - 2);
287
352
  const now = Date.now();
288
353
 
289
354
  // Everything routed through frame* is exactly W wide.
@@ -293,90 +358,159 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined
293
358
  const frameTop = (label: string) =>
294
359
  "┌" + fit(("─ " + label + " ").replace(CONTROL, " "), IN, "─") + "┐";
295
360
 
296
- // A node card, indented inside the frame. The stem that joins it to whatever
297
- // sits above is drawn by the assembly, not by the block: the first block is
298
- // the root and never has one.
299
- const stem = () => frameRow(" " + " ".repeat(Math.floor(INNER / 2) + 1) + "│");
300
- const cardTop = () => frameRow(" ┌" + "─".repeat(INNER) + "┐");
301
- const cardRow = (s: string) => frameRow(" │" + fit(s, INNER) + "│");
302
- const stemAt = Math.floor(INNER / 2);
303
- const cardBottom = (connects: boolean) =>
304
- connects
305
- ? frameRow(" └" + "─".repeat(stemAt) + "┬" + "─".repeat(INNER - stemAt - 1) + "┘")
306
- : frameRow(" └" + "─".repeat(INNER) + "┘");
307
-
308
- const cardBlock = (ageValue: number, id: string, body: string[]): Block => ({
309
- kind: "card",
310
- size: body.length + 2,
311
- age: ageValue,
312
- id,
313
- count: 1,
314
- build: (connects, lead) => [
315
- ...(lead ? [stem()] : []),
316
- cardTop(),
317
- ...body.map(cardRow),
318
- cardBottom(connects),
319
- ],
320
- });
321
-
322
- const blocks: Block[] = [];
323
-
324
- if (orchestrator !== undefined) {
325
- blocks.push(
326
- cardBlock(Number.POSITIVE_INFINITY, "", [
327
- ` ${glyph(orchestrator.agent)} Orchestrator`,
328
- ` ${harnessLabel(orchestrator.agent)} · ${workerTotal} agents`,
329
- ...(text(orchestrator.model).trim() || text(orchestrator.effort).trim()
330
- ? [detailLine(orchestrator.model, orchestrator.effort, "", "", INNER)]
331
- : []),
332
- ]),
333
- );
334
- }
361
+ // Lineage. A row whose parent is not in the snapshot hangs from the root;
362
+ // a parent chain that loops is cut there, so every row is reachable once.
363
+ const byId = new Map<string, Partial<PaneRow>>();
364
+ for (const row of rows) if (typeof row.id === "string" && !byId.has(row.id)) byId.set(row.id, row);
365
+ const declared = (row: Partial<PaneRow>): Partial<PaneRow> | undefined => {
366
+ const id = typeof row.parentId === "string" ? row.parentId : undefined;
367
+ return id !== undefined && id !== row.id ? byId.get(id) : undefined;
368
+ };
369
+ // A row on a parent loop hangs from the root instead.
370
+ const onLoop = (row: Partial<PaneRow>): boolean => {
371
+ const seen = new Set<Partial<PaneRow>>();
372
+ for (let up = declared(row); up !== undefined && !seen.has(up); up = declared(up)) {
373
+ if (up === row) return true;
374
+ seen.add(up);
375
+ }
376
+ return false;
377
+ };
378
+ const parentOf = (row: Partial<PaneRow>): Partial<PaneRow> | undefined =>
379
+ onLoop(row) ? undefined : declared(row);
380
+ const ancestors = (row: Partial<PaneRow>): Partial<PaneRow>[] => {
381
+ const chain: Partial<PaneRow>[] = [];
382
+ const seen = new Set<Partial<PaneRow>>([row]);
383
+ for (let up = parentOf(row); up !== undefined && !seen.has(up); up = parentOf(up)) {
384
+ seen.add(up);
385
+ chain.push(up);
386
+ }
387
+ return chain;
388
+ };
335
389
 
390
+ // A card for every live row and for every ancestor of one, finished or
391
+ // not, so a live reviewer never floats free of the worker that started it.
392
+ const carded = new Set<Partial<PaneRow>>();
336
393
  for (const row of rows) {
394
+ if (!isLive(row)) continue;
395
+ carded.add(row);
396
+ for (const up of ancestors(row)) carded.add(up);
397
+ }
398
+ const historyRows = rows.filter((row) => !carded.has(row));
399
+
400
+ const workerBody = (row: Partial<PaneRow>) => (inner: number): string[] => {
401
+ const role = roleLabel(row.role);
337
402
  const status = row.status ?? "";
338
- // Live rows stay prominent because they need attention. Finished rows are
339
- // collected below into one compact history section.
340
- if (LIVE.has(status) || WAIT.has(status)) {
341
- const when = age(row.updatedAt, now);
342
- const word = statusWord(status);
343
- blocks.push(
344
- cardBlock(rowAge(row.updatedAt), cell(row.id), [
345
- ` ${glyph(row.agent)} ${cell(row.id)} ${harnessLabel(row.agent)}`,
346
- ` ${cell(row.name)}`,
347
- detailLine(row.model, row.effort, word, when, INNER),
348
- ]),
349
- );
350
- }
403
+ const path =
404
+ typeof row.path === "string" && row.path !== ""
405
+ ? " " + fitStart(displayPath(cell(row.path)), Math.max(0, inner - 3))
406
+ : undefined;
407
+ return [
408
+ ` ${glyph(row.agent)} ${cell(displaySessionId(row.id))} ${role ? `${role} · ` : ""}${harnessLabel(row.agent)}`,
409
+ ` ${cell(row.name)}`,
410
+ detailLine(row.model, row.effort, statusWord(status), cardElapsed(row, now), inner),
411
+ ...(path === undefined ? [] : [path]),
412
+ ];
413
+ };
414
+
415
+ const root: Card | undefined =
416
+ orchestrator === undefined
417
+ ? undefined
418
+ : {
419
+ id: "",
420
+ age: Number.POSITIVE_INFINITY,
421
+ parent: undefined,
422
+ children: [],
423
+ body: (inner) => [
424
+ ` ${glyph(orchestrator.agent)} ${roleLabel(orchestrator.role) || "Orchestrator"}`,
425
+ ` ${harnessLabel(orchestrator.agent)} · ${workerTotal} agents`,
426
+ ...(text(orchestrator.model).trim() || text(orchestrator.effort).trim()
427
+ ? [detailLine(orchestrator.model, orchestrator.effort, "", "", inner)]
428
+ : []),
429
+ ],
430
+ };
431
+
432
+ // Rows arrive newest first, so siblings keep that order.
433
+ const cards = new Map<Partial<PaneRow>, Card>();
434
+ for (const row of rows) {
435
+ if (!carded.has(row)) continue;
436
+ cards.set(row, { id: cell(row.id), age: rowAge(row.updatedAt), body: workerBody(row), parent: undefined, children: [] });
437
+ }
438
+ const tops: Card[] = root ? [root] : [];
439
+ for (const row of rows) {
440
+ const card = cards.get(row);
441
+ if (!card) continue;
442
+ const chain = ancestors(row);
443
+ const up = chain.length > 0 ? cards.get(chain[0]) : undefined;
444
+ card.parent = up ?? root;
445
+ if (card.parent) card.parent.children.push(card);
446
+ else tops.push(card);
351
447
  }
352
448
 
353
- if (finished > 0) {
354
- const preview = finishedRows.slice(0, HISTORY_PREVIEW_ROWS);
355
- const historyLines = [` ─ HISTORY · ${finished} finished`];
449
+ // Card width at a depth: capped at MAX_CARD_COLUMNS, shrinking as the tree
450
+ // indents, never so narrow the frame cannot clip it cleanly.
451
+ const cardWidth = (depth: number) => Math.max(10, Math.min(IN - 4 - 4 * depth, MAX_CARD_COLUMNS));
452
+
453
+ // A finished worker kept as a card for a live child is not history.
454
+ const historyCount = historyRows.length + hidden;
455
+ const historyLines = (): string[] => {
456
+ if (historyCount === 0) return [];
457
+ const preview = historyRows.slice(0, HISTORY_PREVIEW_ROWS);
458
+ const lines = [` ─ HISTORY · ${historyCount} finished`];
356
459
  for (const row of preview) {
357
460
  const when = age(row.updatedAt, now);
358
- historyLines.push(
359
- ` ${glyph(row.agent)} ${cell(row.id)} ${cell(row.name)}${when === "" ? "" : ` · ${when}`}`,
461
+ const up = parentOf(row);
462
+ lines.push(
463
+ ` ${glyph(row.agent)} ${cell(displaySessionId(row.id))} ${cell(row.name)}` +
464
+ (up ? ` ← ${cell(displaySessionId(up.id))}` : "") +
465
+ (when === "" ? "" : ` · ${when}`),
360
466
  );
361
467
  }
362
- const older = finished - preview.length;
363
- if (older > 0) historyLines.push(` +${older} earlier`);
364
-
365
- blocks.push({
366
- kind: "history",
367
- size: historyLines.length,
368
- age: rowAge(preview[0]?.updatedAt),
369
- id: "history",
370
- count: finished,
371
- build: (_connects, lead) => [...(lead ? [stem()] : []), ...historyLines.map(frameRow)],
372
- });
373
- }
468
+ const older = historyCount - preview.length;
469
+ if (older > 0) lines.push(` +${older} earlier`);
470
+ return lines;
471
+ };
374
472
 
375
- // Whether the block at index i hangs from the one above it by a stem. The
376
- // first block is the root and never leads with one. A card is always joined
377
- // to what sits above it; history only gets a stem when it follows a card.
378
- const stemAbove = (list: Block[], i: number): boolean =>
379
- i > 0 && (list[i].kind === "card" || list[i - 1].kind === "card");
473
+ // Draw the kept part of the tree. `kept` holds the cards still on screen;
474
+ // an evicted card's children were evicted before it, so the tree stays
475
+ // connected.
476
+ const render = (kept: Set<Card>, withHistory: boolean, hiddenTotal: number): string[] => {
477
+ const out: string[] = [];
478
+ // `rails[k]` says whether the ancestor at depth k+1 has a later sibling,
479
+ // which keeps its vertical rail running past this card.
480
+ const walk = (card: Card, depth: number, rails: boolean[], last: boolean) => {
481
+ const width = cardWidth(depth);
482
+ const inner = width - 2;
483
+ const shown = card.children.filter((child) => kept.has(child));
484
+ const lines = [
485
+ "┌" + "─".repeat(inner) + "┐",
486
+ ...card.body(inner).map((line) => "│" + fit(line, inner) + "│"),
487
+ shown.length > 0
488
+ ? "└─┬" + "─".repeat(Math.max(0, inner - 2)) + "┘"
489
+ : "└" + "─".repeat(inner) + "┘",
490
+ ];
491
+ lines.forEach((line, i) => {
492
+ let prefix = rails.map((rail) => (rail ? " │ " : " ")).join("");
493
+ let body = line;
494
+ if (depth > 0) {
495
+ if (i === 0) prefix += " │ ";
496
+ else if (i === 1) {
497
+ prefix += last ? " └─" : " ├─";
498
+ body = "┤" + line.slice(1);
499
+ } else prefix += last ? " " : " │ ";
500
+ }
501
+ out.push(frameRow(" " + prefix + body));
502
+ });
503
+ shown.forEach((child, i) =>
504
+ walk(child, depth + 1, depth > 0 ? [...rails, !last] : rails, i === shown.length - 1),
505
+ );
506
+ };
507
+ for (const top of tops) if (kept.has(top)) walk(top, 0, [], true);
508
+ const history = withHistory ? historyLines() : [];
509
+ if (history.length > 0 && out.length > 0) out.push(frameRow(""));
510
+ out.push(...history.map(frameRow));
511
+ if (hiddenTotal > 0) out.push(frameRow(` +${hiddenTotal} hidden agents`));
512
+ return out;
513
+ };
380
514
 
381
515
  // These frame lines are always drawn when the pane has content.
382
516
  const head = [
@@ -386,56 +520,45 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined
386
520
  frameSep(),
387
521
  frameRow(""),
388
522
  ];
389
- const harnessLegend = footerLegend(W);
390
- const tail = [
391
- frameSep(),
392
- frameRow(harnessLegend),
393
- frameBot(),
394
- ];
523
+ const tail = [frameSep(), frameRow(footerLegend(W)), frameBot()];
395
524
  const fixed = head.length + tail.length;
396
- // The hidden line keeps its own stem only when it hangs off a full card.
397
- const hiddenSize = (list: Block[]): number =>
398
- list.length > 0 && list[list.length - 1].kind === "card" ? 2 : 1;
399
525
 
400
- let kept = blocks;
526
+ const all = new Set<Card>();
527
+ const collect = (card: Card) => {
528
+ all.add(card);
529
+ card.children.forEach(collect);
530
+ };
531
+ tops.forEach(collect);
532
+
533
+ let kept = all;
534
+ let withHistory = true;
401
535
  let dropped = 0;
402
- let showHidden = false;
536
+ let hiddenTotal = 0;
403
537
  if (limit !== undefined) {
404
538
  // Rule 7: the frame alone does not fit, so nothing is drawn at all.
405
539
  if (limit < fixed) return [];
406
- // History leaves first, then full cards, with the oldest block leaving
407
- // first within each group. The hidden count follows the block's session
408
- // count rather than its number of rendered lines.
409
- const victims = [...blocks].sort(
410
- (a, b) =>
411
- Number(a.kind === "card") - Number(b.kind === "card") ||
412
- a.age - b.age ||
413
- (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
414
- );
415
- const sizeOf = (list: Block[]) =>
416
- list.reduce((sum, block, i) => sum + block.size + (stemAbove(list, i) ? 1 : 0), 0);
417
- const needed = () => fixed + sizeOf(kept);
418
- for (let vi = 0; needed() > limit && vi < victims.length; vi += 1) {
419
- kept = kept.filter((block) => block !== victims[vi]);
420
- dropped += victims[vi].count;
540
+ const needed = () => fixed + render(kept, withHistory, 0).length;
541
+ // History leaves first, then cards oldest first, and only a card with no
542
+ // child left on screen, so the tree never loses a link. The root carries
543
+ // +Infinity and is the last to go. The hidden count follows sessions,
544
+ // not rendered lines.
545
+ if (needed() > limit && historyLines().length > 0) {
546
+ withHistory = false;
547
+ dropped += historyCount;
548
+ }
549
+ kept = new Set(all);
550
+ while (needed() > limit && kept.size > 0) {
551
+ const leaves = [...kept].filter((card) => !card.children.some((child) => kept.has(child)));
552
+ leaves.sort((a, b) => a.age - b.age || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
553
+ const victim = leaves[0];
554
+ kept.delete(victim);
555
+ if (victim !== root) dropped += 1;
421
556
  }
422
557
  // A hidden line is useful only when it does not displace a live card.
423
- showHidden = dropped > 0 && fixed + sizeOf(kept) + hiddenSize(kept) <= limit;
558
+ if (dropped > 0 && fixed + render(kept, withHistory, dropped).length <= limit) hiddenTotal = dropped;
424
559
  }
425
560
 
426
- // `hidden` is already represented by the history block: the source does not
427
- // expose the statuses of rows that were cut before formatting.
428
- const hiddenTotal = showHidden ? dropped : 0;
429
- const hiddenRow = frameRow(` +${hiddenTotal} hidden agents`);
430
- const out: string[] = [...head];
431
- for (let i = 0; i < kept.length; i += 1) {
432
- const connects = i < kept.length - 1 || hiddenTotal > 0;
433
- out.push(...kept[i].build(connects, stemAbove(kept, i)));
434
- }
435
- if (hiddenTotal > 0) {
436
- if (kept.length > 0 && kept[kept.length - 1].kind === "card") out.push(stem(), hiddenRow);
437
- else out.push(hiddenRow);
438
- }
561
+ const out: string[] = [...head, ...render(kept, withHistory, hiddenTotal)];
439
562
  if (limit !== undefined) {
440
563
  const spare = limit - out.length - tail.length;
441
564
  for (let i = 0; i < spare; i += 1) out.push(frameRow(""));