codedeck 0.6.0 → 0.7.1

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 (140) hide show
  1. package/README.md +50 -35
  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 +102 -11
  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-backfill.js +149 -0
  23. package/dist/cli/commands/usage-backfill.js.map +1 -0
  24. package/dist/cli/commands/usage.js +113 -45
  25. package/dist/cli/commands/usage.js.map +1 -1
  26. package/dist/cli/commands/wait.js +3 -2
  27. package/dist/cli/commands/wait.js.map +1 -1
  28. package/dist/cli/index.js +55 -39
  29. package/dist/cli/index.js.map +1 -1
  30. package/dist/cli/web-launch.js +126 -0
  31. package/dist/cli/web-launch.js.map +1 -0
  32. package/dist/config/config.js +0 -70
  33. package/dist/config/config.js.map +1 -1
  34. package/dist/config/paths.js +1 -0
  35. package/dist/config/paths.js.map +1 -1
  36. package/dist/config/setup.js +248 -0
  37. package/dist/config/setup.js.map +1 -0
  38. package/dist/config/web-host.js +31 -0
  39. package/dist/config/web-host.js.map +1 -0
  40. package/dist/config/web-port.js +24 -0
  41. package/dist/config/web-port.js.map +1 -0
  42. package/dist/core/claude-transcript.js +226 -0
  43. package/dist/core/claude-transcript.js.map +1 -0
  44. package/dist/core/errors.js +17 -0
  45. package/dist/core/errors.js.map +1 -1
  46. package/dist/core/models.js +4 -1
  47. package/dist/core/models.js.map +1 -1
  48. package/dist/core/pricing.js +87 -55
  49. package/dist/core/pricing.js.map +1 -1
  50. package/dist/core/roles.js +6 -5
  51. package/dist/core/roles.js.map +1 -1
  52. package/dist/core/run-skills.js +114 -0
  53. package/dist/core/run-skills.js.map +1 -0
  54. package/dist/core/run-usage.js +56 -10
  55. package/dist/core/run-usage.js.map +1 -1
  56. package/dist/core/session.js +6 -16
  57. package/dist/core/session.js.map +1 -1
  58. package/dist/core/usage-query.js +41 -0
  59. package/dist/core/usage-query.js.map +1 -0
  60. package/dist/core/usage-source.js +17 -0
  61. package/dist/core/usage-source.js.map +1 -0
  62. package/dist/daemon/build-id.js +30 -0
  63. package/dist/daemon/build-id.js.map +1 -0
  64. package/dist/daemon/daemon.js +1257 -700
  65. package/dist/daemon/daemon.js.map +1 -1
  66. package/dist/daemon/instance-lock.js +48 -0
  67. package/dist/daemon/instance-lock.js.map +1 -0
  68. package/dist/daemon/ipc.js +4 -3
  69. package/dist/daemon/ipc.js.map +1 -1
  70. package/dist/daemon/web-supervisor.js +251 -0
  71. package/dist/daemon/web-supervisor.js.map +1 -0
  72. package/dist/drivers/claude/driver.js +1 -0
  73. package/dist/drivers/claude/driver.js.map +1 -1
  74. package/dist/drivers/codex/driver.js +14 -3
  75. package/dist/drivers/codex/driver.js.map +1 -1
  76. package/dist/drivers/codex/host-skills.js +57 -0
  77. package/dist/drivers/codex/host-skills.js.map +1 -0
  78. package/dist/drivers/opencode/driver.js +6 -2
  79. package/dist/drivers/opencode/driver.js.map +1 -1
  80. package/dist/drivers/session-driver.js +7 -1
  81. package/dist/drivers/session-driver.js.map +1 -1
  82. package/dist/drivers/session-runtime.js +2 -0
  83. package/dist/drivers/session-runtime.js.map +1 -1
  84. package/dist/drivers/terminal.js +15 -0
  85. package/dist/drivers/terminal.js.map +1 -1
  86. package/dist/git/diff.js +29 -4
  87. package/dist/git/diff.js.map +1 -1
  88. package/dist/open/contract.js.map +1 -1
  89. package/dist/open/launchers/claude.js +78 -2
  90. package/dist/open/launchers/claude.js.map +1 -1
  91. package/dist/open/link-watcher.js +44 -0
  92. package/dist/open/link-watcher.js.map +1 -0
  93. package/dist/open/pty.js +330 -5
  94. package/dist/open/pty.js.map +1 -1
  95. package/dist/open/runtime.js +15 -12
  96. package/dist/open/runtime.js.map +1 -1
  97. package/dist/plugin/agents/general.md +33 -6
  98. package/dist/plugin/hooks/pane-ticker.ts +102 -0
  99. package/dist/plugin/hooks/register.tsx +39 -18
  100. package/dist/plugin/hooks/session-id.sh +1 -1
  101. package/dist/plugin/mods/agents/pane.ts +285 -156
  102. package/dist/plugin/mods/agents/types.ts +23 -3
  103. package/dist/plugin/statusline.sh +57 -32
  104. package/dist/store/database.js +49 -1
  105. package/dist/store/database.js.map +1 -1
  106. package/dist/store/native-links.js +80 -0
  107. package/dist/store/native-links.js.map +1 -0
  108. package/dist/store/sessions.js +166 -51
  109. package/dist/store/sessions.js.map +1 -1
  110. package/dist/store/usage-ledger.js +153 -0
  111. package/dist/store/usage-ledger.js.map +1 -0
  112. package/dist/utils/process.js +2 -16
  113. package/dist/utils/process.js.map +1 -1
  114. package/dist/web/brand.js +59 -0
  115. package/dist/web/brand.js.map +1 -0
  116. package/dist/web/child.js +67 -0
  117. package/dist/web/child.js.map +1 -0
  118. package/dist/web/home-page.js +48 -0
  119. package/dist/web/home-page.js.map +1 -0
  120. package/dist/web/review-page.js +12 -5
  121. package/dist/web/review-page.js.map +1 -1
  122. package/dist/web/security.js +170 -0
  123. package/dist/web/security.js.map +1 -0
  124. package/dist/web/server.js +198 -0
  125. package/dist/web/server.js.map +1 -0
  126. package/dist/web/setup-icons.js +37 -0
  127. package/dist/web/setup-icons.js.map +1 -0
  128. package/dist/web/setup-page.js +1795 -0
  129. package/dist/web/setup-page.js.map +1 -0
  130. package/dist/web/setup-routes.js +475 -0
  131. package/dist/web/setup-routes.js.map +1 -0
  132. package/dist/web/usage-page.js +609 -0
  133. package/dist/web/usage-page.js.map +1 -0
  134. package/dist/web/usage-routes.js +88 -0
  135. package/dist/web/usage-routes.js.map +1 -0
  136. package/dist/web/web-token.js +56 -0
  137. package/dist/web/web-token.js.map +1 -0
  138. package/package.json +1 -1
  139. package/dist/plugin/prompts/_partials/commit.md +0 -145
  140. package/dist/plugin/prompts/_partials/pr-writer.md +0 -186
@@ -47,13 +47,8 @@ function vocab(entries: Record<string, string>): Record<string, string> {
47
47
  return Object.assign(Object.create(null) as Record<string, string>, entries);
48
48
  }
49
49
 
50
- const STATUS_DOT = vocab({
51
- working: "●",
52
- starting: "●",
53
- needs_input: "◉",
54
- });
55
50
  const STATUS_WORD = vocab({
56
- working: "Working now",
51
+ working: "Working",
57
52
  starting: "Starting",
58
53
  needs_input: "Waiting for you",
59
54
  completed: "Completed",
@@ -105,14 +100,40 @@ function text(value: unknown): string {
105
100
  return value;
106
101
  }
107
102
 
108
- 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 {
109
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;
110
125
  return {
111
126
  id: row.id,
112
127
  status: row.status || EMPTY_CELL,
113
128
  agent: row.agent || EMPTY_CELL,
129
+ model: text(row.model) || undefined,
130
+ effort: text(row.effort) || undefined,
114
131
  name: row.name || EMPTY_CELL,
115
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,
116
137
  };
117
138
  }
118
139
 
@@ -121,7 +142,8 @@ function toPaneRow(row: SessionRow): PaneRow {
121
142
  *
122
143
  * Filters to `runId`, lifts the single `origin === "open"` row out as the
123
144
  * orchestrator, orders the rest by `updatedAt` descending with a total order,
124
- * 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
125
147
  * `hidden` is 0; dropping rows to fit the pane is formatPane's decision.
126
148
  */
127
149
  export function selectPane(rows: SessionRow[], runId: string, budget?: number): PaneSnapshot {
@@ -133,15 +155,21 @@ export function selectPane(rows: SessionRow[], runId: string, budget?: number):
133
155
  );
134
156
  const orchestratorRow = matching.find((row) => row.origin === "open");
135
157
  const orchestrator = orchestratorRow
136
- ? { agent: text(orchestratorRow.agent) || EMPTY_CELL }
158
+ ? {
159
+ agent: text(orchestratorRow.agent) || EMPTY_CELL,
160
+ model: text(orchestratorRow.model) || undefined,
161
+ effort: text(orchestratorRow.effort) || undefined,
162
+ role: text(orchestratorRow.role) || undefined,
163
+ }
137
164
  : undefined;
138
165
  const workers = matching.filter((row) => row.origin !== "open");
139
166
  const ordered = [...workers].sort(compareRows);
140
167
  const kept = ordered.slice(0, limit);
168
+ const keptIds = new Set(kept.map((row) => row.id));
141
169
  return {
142
170
  runId: text(runId),
143
171
  orchestrator,
144
- rows: kept.map(toPaneRow),
172
+ rows: kept.map((row) => toPaneRow(row, keptIds)),
145
173
  hidden: ordered.length - kept.length,
146
174
  total: matching.length,
147
175
  };
@@ -169,6 +197,17 @@ function fit(value: string, width: number, fill = " "): string {
169
197
  return (cut + "…").padEnd(width, fill);
170
198
  }
171
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
+
172
211
  function glyph(agent: unknown): string {
173
212
  const key = text(agent);
174
213
  return HARNESS_GLYPH[key] ?? "□";
@@ -179,16 +218,11 @@ function harnessLabel(agent: unknown): string {
179
218
  return HARNESS_LABEL[key] ?? cell(agent);
180
219
  }
181
220
 
182
- function dot(status: unknown): string {
183
- return STATUS_DOT[text(status)] ?? "○";
184
- }
185
-
186
221
  function statusWord(status: unknown): string {
187
222
  return STATUS_WORD[text(status)] ?? cell(status);
188
223
  }
189
224
 
190
- function footerLegend(columns: number): [string, string] {
191
- const fullStatus = " ● working ◉ waiting ○ finished";
225
+ function footerLegend(columns: number): string {
192
226
  const fullHarness =
193
227
  " " +
194
228
  glyph("claude") +
@@ -199,15 +233,11 @@ function footerLegend(columns: number): [string, string] {
199
233
  " Codex " +
200
234
  glyph("omp") +
201
235
  " OMP";
202
- if (fullStatus.length <= columns - 2 && fullHarness.length <= columns - 2) {
203
- return [fullStatus, fullHarness];
204
- }
236
+ if (fullHarness.length <= columns - 2) return fullHarness;
205
237
 
206
- const compactStatus = " ● wk ◉ wait ○ done";
207
238
  const iconHarness =
208
239
  " " + glyph("claude") + " " + glyph("opencode") + " " + glyph("codex") + " " + glyph("omp");
209
- if (compactStatus.length + 4 <= columns - 2) return [compactStatus, iconHarness];
210
- return [" ● ◉ ○", iconHarness];
240
+ return iconHarness;
211
241
  }
212
242
 
213
243
  function age(iso: unknown, now: number): string {
@@ -215,15 +245,51 @@ function age(iso: unknown, now: number): string {
215
245
  const then = Date.parse(iso);
216
246
  if (Number.isNaN(then)) return "";
217
247
  const minutes = Math.floor((now - then) / 60000);
218
- if (minutes < 1) return "agora";
248
+ if (minutes < 1) return "now";
219
249
  if (minutes < 60) return `${minutes}m`;
220
250
  return `${Math.floor(minutes / 60)}h`;
221
251
  }
222
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
+
223
279
  function count(value: unknown): number {
224
280
  return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
225
281
  }
226
282
 
283
+ function detailLine(model: unknown, effort: unknown, status: string, when: string, width: number): string {
284
+ const state = status ? `${status}${when ? ` · ${when}` : ""}` : "";
285
+ const suffix = [text(effort).trim() ? `${cell(effort)} effort` : "", state]
286
+ .filter(Boolean)
287
+ .join(" · ");
288
+ const modelWidth = width - 3 - suffix.length - (suffix ? 3 : 0);
289
+ const modelText = text(model).trim() && modelWidth > 0 ? fit(cell(model), modelWidth).trimEnd() : "";
290
+ return ` ${[modelText, suffix].filter(Boolean).join(" · ")}`;
291
+ }
292
+
227
293
  // How old a row looks for eviction: an unparseable timestamp is the oldest
228
294
  // possible age, matching how compareRows parks such rows at the end of the
229
295
  // display. The orchestrator pins itself to +Infinity so it is the very last
@@ -233,22 +299,30 @@ function rowAge(iso: unknown): number {
233
299
  return Number.isNaN(then) ? Number.NEGATIVE_INFINITY : then;
234
300
  }
235
301
 
236
- // One drawable piece of the pane: an orchestrator card, a worker card, or a
237
- // compact history section. `count` is how many sessions disappear if the block
238
- // is evicted to make room for the frame and footer. `size` counts only the
239
- // block's own lines; the assembled order adds any connector above it.
240
- type Block = {
241
- kind: "card" | "history";
242
- size: number;
243
- 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 = {
244
305
  id: string;
245
- count: number;
246
- build: (connects: boolean, lead: boolean) => string[];
306
+ age: number;
307
+ body: (inner: number) => string[];
308
+ parent: Card | undefined;
309
+ children: Card[];
247
310
  };
248
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
+
249
317
  // One drawing pass, with the widths derived from the column budget. Kept inside
250
318
  // its own function so formatPane can wrap the whole thing in one guard.
251
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.
252
326
  function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined): string[] {
253
327
  const source = (snapshot ?? {}) as Partial<PaneSnapshot>;
254
328
  const rows = (Array.isArray(source.rows) ? source.rows : []).map(
@@ -256,7 +330,7 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined
256
330
  );
257
331
  const orchestrator =
258
332
  source.orchestrator && typeof source.orchestrator === "object"
259
- ? (source.orchestrator as Partial<{ agent: string }>)
333
+ ? (source.orchestrator as Partial<{ agent: string; model: string; effort: string; role: string }>)
260
334
  : undefined;
261
335
  const hidden = count(source.hidden);
262
336
  const total = count(source.total);
@@ -265,23 +339,16 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined
265
339
  if (rows.length === 0 && orchestrator === undefined) return [];
266
340
 
267
341
  const workerTotal = rows.length + hidden;
342
+ const isLive = (row: Partial<PaneRow>) => LIVE.has(row.status ?? "") || WAIT.has(row.status ?? "");
268
343
  const working = rows.filter((row) => LIVE.has(row.status ?? "")).length;
269
344
  const waiting = rows.filter((row) => WAIT.has(row.status ?? "")).length;
270
345
  // The overflow is reported by count only, its statuses are not in the
271
346
  // snapshot, so it is folded into "finished": the most recently touched
272
347
  // sessions are the live ones and stay inside the budget.
273
348
  const finished = Math.max(0, workerTotal - working - waiting);
274
- const finishedRows = rows.filter(
275
- (row) => !LIVE.has(row.status ?? "") && !WAIT.has(row.status ?? ""),
276
- );
277
349
 
278
350
  const W = Math.trunc(columns);
279
351
  const IN = W - 2;
280
- // Rule 8: the card box caps at MAX_CARD_COLUMNS no matter how wide the dock
281
- // gets; the outer frame still spans the full width. The two-space inset the
282
- // cards have always sat at inside the frame is kept.
283
- const CARD = Math.min(IN - 4, MAX_CARD_COLUMNS);
284
- const INNER = Math.max(0, CARD - 2);
285
352
  const now = Date.now();
286
353
 
287
354
  // Everything routed through frame* is exactly W wide.
@@ -291,90 +358,161 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined
291
358
  const frameTop = (label: string) =>
292
359
  "┌" + fit(("─ " + label + " ").replace(CONTROL, " "), IN, "─") + "┐";
293
360
 
294
- // A node card, indented inside the frame. The stem that joins it to whatever
295
- // sits above is drawn by the assembly, not by the block: the first block is
296
- // the root and never has one.
297
- const stem = () => frameRow(" " + " ".repeat(Math.floor(INNER / 2) + 1) + "│");
298
- const cardTop = () => frameRow(" ┌" + "─".repeat(INNER) + "┐");
299
- const cardRow = (s: string) => frameRow(" │" + fit(s, INNER) + "│");
300
- const stemAt = Math.floor(INNER / 2);
301
- const cardBottom = (connects: boolean) =>
302
- connects
303
- ? frameRow(" └" + "─".repeat(stemAt) + "┬" + "─".repeat(INNER - stemAt - 1) + "┘")
304
- : frameRow(" └" + "─".repeat(INNER) + "┘");
305
-
306
- const cardBlock = (ageValue: number, id: string, body: string[]): Block => ({
307
- kind: "card",
308
- size: body.length + 2,
309
- age: ageValue,
310
- id,
311
- count: 1,
312
- build: (connects, lead) => [
313
- ...(lead ? [stem()] : []),
314
- cardTop(),
315
- ...body.map(cardRow),
316
- cardBottom(connects),
317
- ],
318
- });
319
-
320
- const blocks: Block[] = [];
321
-
322
- if (orchestrator !== undefined) {
323
- blocks.push(
324
- cardBlock(Number.POSITIVE_INFINITY, "", [
325
- ` ${glyph(orchestrator.agent)} ● Orchestrator`,
326
- ` ${harnessLabel(orchestrator.agent)} · ${workerTotal} agents`,
327
- ]),
328
- );
329
- }
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
+ };
330
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>>();
331
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);
332
402
  const status = row.status ?? "";
333
- // Live rows stay prominent because they need attention. Finished rows are
334
- // collected below into one compact history section.
335
- if (LIVE.has(status) || WAIT.has(status)) {
336
- const when = age(row.updatedAt, now);
337
- const word = statusWord(status);
338
- blocks.push(
339
- cardBlock(rowAge(row.updatedAt), cell(row.id), [
340
- ` ${glyph(row.agent)} ${dot(row.status)} ${cell(row.id)} ${harnessLabel(row.agent)}`,
341
- ` ${cell(row.name)}`,
342
- when === "" ? ` ${word}` : ` ${word} · ${when}`,
343
- ]),
344
- );
345
- }
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: [] });
346
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);
447
+ }
448
+
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));
347
452
 
348
- if (finished > 0) {
349
- const preview = finishedRows.slice(0, HISTORY_PREVIEW_ROWS);
350
- const historyLines = [` ─ HISTORY · ${finished} finished`];
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`];
351
459
  for (const row of preview) {
352
460
  const when = age(row.updatedAt, now);
353
- historyLines.push(
354
- ` ${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}`),
355
466
  );
356
467
  }
357
- const older = finished - preview.length;
358
- if (older > 0) historyLines.push(` +${older} earlier`);
359
-
360
- blocks.push({
361
- kind: "history",
362
- size: historyLines.length,
363
- age: rowAge(preview[0]?.updatedAt),
364
- id: "history",
365
- count: finished,
366
- build: (_connects, lead) => [...(lead ? [stem()] : []), ...historyLines.map(frameRow)],
367
- });
368
- }
468
+ const older = historyCount - preview.length;
469
+ if (older > 0) lines.push(` +${older} earlier`);
470
+ return lines;
471
+ };
369
472
 
370
- // Whether the block at index i hangs from the one above it by a stem. The
371
- // first block is the root and never leads with one. A card is always joined
372
- // to what sits above it; history only gets a stem when it follows a card.
373
- const stemAbove = (list: Block[], i: number): boolean =>
374
- 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
+ };
375
514
 
376
- // Rule 3: these ten lines are the frame. Whenever anything is drawn at all
377
- // they are drawn, and they are never the lines that the fit cuts.
515
+ // These frame lines are always drawn when the pane has content.
378
516
  const head = [
379
517
  frameTop(`Run canvas ${cell(source.runId)}`),
380
518
  frameRow(` ${total} sessions · ${working} working`),
@@ -382,57 +520,48 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined
382
520
  frameSep(),
383
521
  frameRow(""),
384
522
  ];
385
- const [statusLegend, harnessLegend] = footerLegend(W);
386
- const tail = [
387
- frameRow(""),
388
- frameSep(),
389
- frameRow(statusLegend),
390
- frameRow(harnessLegend),
391
- frameBot(),
392
- ];
523
+ const tail = [frameSep(), frameRow(footerLegend(W)), frameBot()];
393
524
  const fixed = head.length + tail.length;
394
- // The hidden line keeps its own stem only when it hangs off a full card.
395
- const hiddenSize = (list: Block[]): number =>
396
- list.length > 0 && list[list.length - 1].kind === "card" ? 2 : 1;
397
525
 
398
- 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;
399
535
  let dropped = 0;
400
- let showHidden = false;
536
+ let hiddenTotal = 0;
401
537
  if (limit !== undefined) {
402
538
  // Rule 7: the frame alone does not fit, so nothing is drawn at all.
403
539
  if (limit < fixed) return [];
404
- // History leaves first, then full cards, with the oldest block leaving
405
- // first within each group. The hidden count follows the block's session
406
- // count rather than its number of rendered lines.
407
- const victims = [...blocks].sort(
408
- (a, b) =>
409
- Number(a.kind === "card") - Number(b.kind === "card") ||
410
- a.age - b.age ||
411
- (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
412
- );
413
- const sizeOf = (list: Block[]) =>
414
- list.reduce((sum, block, i) => sum + block.size + (stemAbove(list, i) ? 1 : 0), 0);
415
- const needed = () => fixed + sizeOf(kept);
416
- for (let vi = 0; needed() > limit && vi < victims.length; vi += 1) {
417
- kept = kept.filter((block) => block !== victims[vi]);
418
- 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;
419
556
  }
420
557
  // A hidden line is useful only when it does not displace a live card.
421
- showHidden = dropped > 0 && fixed + sizeOf(kept) + hiddenSize(kept) <= limit;
558
+ if (dropped > 0 && fixed + render(kept, withHistory, dropped).length <= limit) hiddenTotal = dropped;
422
559
  }
423
560
 
424
- // `hidden` is already represented by the history block: the source does not
425
- // expose the statuses of rows that were cut before formatting.
426
- const hiddenTotal = showHidden ? dropped : 0;
427
- const hiddenRow = frameRow(` +${hiddenTotal} hidden agents`);
428
- const out: string[] = [...head];
429
- for (let i = 0; i < kept.length; i += 1) {
430
- const connects = i < kept.length - 1 || hiddenTotal > 0;
431
- out.push(...kept[i].build(connects, stemAbove(kept, i)));
432
- }
433
- if (hiddenTotal > 0) {
434
- if (kept.length > 0 && kept[kept.length - 1].kind === "card") out.push(stem(), hiddenRow);
435
- else out.push(hiddenRow);
561
+ const out: string[] = [...head, ...render(kept, withHistory, hiddenTotal)];
562
+ if (limit !== undefined) {
563
+ const spare = limit - out.length - tail.length;
564
+ for (let i = 0; i < spare; i += 1) out.push(frameRow(""));
436
565
  }
437
566
  out.push(...tail);
438
567
  return out;
@@ -1,7 +1,8 @@
1
1
  // Shared shapes for the orchestrator agents band.
2
2
  //
3
- // Owned by the interface, not by any one slice: parse, select and format are
4
- // written against this file and none of them may change it.
3
+ // Parse, select and format share this interface. The optional directory fields
4
+ // are a deliberate exception to the frozen interface, per the agents-pane path
5
+ // decision, so a worker's worktree or cwd can reach its card.
5
6
  //
6
7
  // SessionRow mirrors one element of `codedeck ps --all --json`. Every field
7
8
  // past `id` is optional on purpose: the band reads a CLI it does not version
@@ -11,10 +12,18 @@ export interface SessionRow {
11
12
  id: string;
12
13
  runId?: string;
13
14
  origin?: string | null;
15
+ /** Session that dispatched this one; absent on rows older than lineage. */
16
+ parentId?: string | null;
17
+ role?: string | null;
14
18
  name?: string;
15
19
  agent?: string;
20
+ model?: string;
21
+ effort?: string;
16
22
  status?: string;
17
23
  updatedAt?: string;
24
+ createdAt?: string;
25
+ worktree?: string | null;
26
+ cwd?: string | null;
18
27
  }
19
28
 
20
29
  /**
@@ -26,15 +35,26 @@ export interface PaneRow {
26
35
  id: string;
27
36
  status: string;
28
37
  agent: string;
38
+ model?: string;
39
+ effort?: string;
29
40
  name: string;
30
41
  updatedAt?: string;
42
+ createdAt?: string;
43
+ /**
44
+ * Id of the card this one hangs from, or undefined when it hangs from the
45
+ * run root. A parent outside the run (or a legacy row with none) is folded
46
+ * onto the root so every row stays reachable.
47
+ */
48
+ parentId?: string;
49
+ role?: string;
50
+ path?: string;
31
51
  }
32
52
 
33
53
  /** Everything one drawing of the pane needs, already narrowed to one run. */
34
54
  export interface PaneSnapshot {
35
55
  runId: string;
36
56
  /** The orchestrator's own row, the only one whose origin is "open". */
37
- orchestrator: { agent: string } | undefined;
57
+ orchestrator: { agent: string; model?: string; effort?: string; role?: string } | undefined;
38
58
  rows: PaneRow[];
39
59
  /** Rows that matched the run but fell outside the budget. */
40
60
  hidden: number;