@agwab/pi-workflow 0.2.1 → 0.3.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.
Files changed (70) hide show
  1. package/dist/compiler.js +6 -8
  2. package/dist/dynamic-decision.d.ts +0 -1
  3. package/dist/dynamic-decision.js +0 -7
  4. package/dist/dynamic-profiles.d.ts +0 -1
  5. package/dist/dynamic-profiles.js +0 -3
  6. package/dist/engine-run-graph.d.ts +1 -0
  7. package/dist/engine-run-graph.js +142 -2
  8. package/dist/engine.d.ts +5 -0
  9. package/dist/engine.js +112 -27
  10. package/dist/extension.d.ts +2 -1
  11. package/dist/extension.js +27 -6
  12. package/dist/index.d.ts +3 -3
  13. package/dist/index.js +2 -1
  14. package/dist/store.js +55 -11
  15. package/dist/subagent-backend.js +155 -29
  16. package/dist/types.d.ts +6 -0
  17. package/dist/workflow-runtime.js +10 -1
  18. package/dist/workflow-view.js +3 -1
  19. package/dist/workflow-web-source-extension.js +167 -48
  20. package/dist/workflow-web-source.d.ts +2 -1
  21. package/dist/workflow-web-source.js +84 -19
  22. package/node_modules/@agwab/pi-subagent/README.md +3 -3
  23. package/node_modules/@agwab/pi-subagent/api.mjs +1 -0
  24. package/node_modules/@agwab/pi-subagent/docs/usage.md +63 -12
  25. package/node_modules/@agwab/pi-subagent/package.json +2 -2
  26. package/node_modules/@agwab/pi-subagent/src/api.ts +54 -1
  27. package/node_modules/@agwab/pi-subagent/src/artifacts/registry.ts +9 -4
  28. package/node_modules/@agwab/pi-subagent/src/artifacts/result.ts +8 -0
  29. package/node_modules/@agwab/pi-subagent/src/core/constants.ts +9 -0
  30. package/node_modules/@agwab/pi-subagent/src/core/validation.ts +21 -0
  31. package/node_modules/@agwab/pi-subagent/src/index.ts +995 -573
  32. package/node_modules/@agwab/pi-subagent/src/orchestrate/async.ts +279 -156
  33. package/node_modules/@agwab/pi-subagent/src/orchestrate/interrupt.ts +165 -89
  34. package/node_modules/@agwab/pi-subagent/src/orchestrate/reconcile.ts +111 -65
  35. package/node_modules/@agwab/pi-subagent/src/orchestrate/run-ref.ts +219 -0
  36. package/node_modules/@agwab/pi-subagent/src/orchestrate/run.ts +88 -8
  37. package/node_modules/@agwab/pi-subagent/src/orchestrate/status.ts +614 -298
  38. package/node_modules/@agwab/pi-subagent/src/panel.ts +1352 -560
  39. package/node_modules/@agwab/pi-subagent/src/runners/headless-model.ts +53 -5
  40. package/node_modules/@agwab/pi-subagent/src/runners/tmux.ts +13 -6
  41. package/package.json +2 -2
  42. package/src/compiler.ts +14 -9
  43. package/src/dynamic-decision.ts +0 -11
  44. package/src/dynamic-profiles.ts +0 -4
  45. package/src/engine-run-graph.ts +185 -2
  46. package/src/engine.ts +145 -24
  47. package/src/extension.ts +33 -4
  48. package/src/index.ts +3 -1
  49. package/src/store.ts +74 -11
  50. package/src/subagent-backend.ts +201 -28
  51. package/src/types.ts +6 -0
  52. package/src/workflow-runtime.ts +18 -2
  53. package/src/workflow-view.ts +2 -1
  54. package/src/workflow-web-source-extension.ts +621 -228
  55. package/src/workflow-web-source.ts +118 -28
  56. package/workflows/deep-research/helpers/claim-evidence-gate.mjs +56 -16
  57. package/workflows/deep-research/helpers/final-audit-packet.mjs +1 -4
  58. package/workflows/deep-research/helpers/normalize-input-packet.mjs +1 -1
  59. package/workflows/deep-research/helpers/render-executive.mjs +8 -21
  60. package/workflows/deep-research/helpers/sanitize-verification-candidates.mjs +89 -15
  61. package/workflows/deep-research/schemas/deep-research-executive-render-control.schema.json +0 -1
  62. package/workflows/deep-research/schemas/deep-research-verify-claims-control.schema.json +4 -1
  63. package/workflows/impact-review/spec.json +3 -3
  64. package/workflows/spec-review/helpers/spec-review-pipeline.mjs +1 -8
  65. package/dist/dynamic-loader.d.ts +0 -25
  66. package/dist/dynamic-loader.js +0 -13
  67. package/src/dynamic-loader.ts +0 -49
  68. package/workflows/impact-review/schemas/docs-release-impact-control.schema.json +0 -42
  69. package/workflows/impact-review/schemas/security-performance-impact-control.schema.json +0 -42
  70. package/workflows/impact-review/schemas/state-data-impact-control.schema.json +0 -42
@@ -1,79 +1,111 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
- import { isAbsolute, join, relative, resolve, sep } from "node:path";
2
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
3
3
  import type { Component } from "@earendil-works/pi-tui";
4
4
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
5
- import type { ResultEnvelope } from "./artifacts/index.ts";
5
+ import type { ResultEnvelope, RunEvent } from "./artifacts/index.ts";
6
6
  import type { Status } from "./core/constants.ts";
7
+ import { listRunLocators, type RunRefLocator } from "./orchestrate/run-ref.ts";
8
+ import {
9
+ summarizeChildEvents,
10
+ type RunChildSummary,
11
+ } from "./orchestrate/status.ts";
7
12
 
8
13
  const DEFAULT_RUNS_DIR = ".pi/agent/runs";
9
14
  const LIVE_REFRESH_MS = 1_500;
10
15
  const LOG_TAIL_LINES = 5;
16
+ const STALE_RUN_AFTER_MS = 30_000;
17
+ const PANEL_MIN_LINES = 12;
18
+ const PANEL_MAX_LINES = 30;
19
+ const PANEL_RESERVED_TUI_LINES = 8;
20
+ const DEFAULT_RECENT_TERMINAL_LIMIT = 20;
21
+ const ALL_SCOPE_RECENT_TERMINAL_LIMIT = 50;
11
22
 
12
- type Filter = "all" | "failed" | "completed";
23
+ type ScopeFilter = "session" | "cwd" | "all";
24
+ type StatusFilter = "all" | "running" | "completed" | "failed";
25
+ type FocusGroup = "scope" | "status" | "detail";
13
26
 
14
27
  interface TaskRow {
15
- attemptId: string;
16
- status: Status;
17
- backend: string;
18
- failureKind: string | null;
19
- startedAt: string;
20
- completedAt: string | null;
21
- durationMs: number | null;
22
- resultPath: string;
23
- logPath: string | null;
24
- logTail: string[];
25
- workspace: string;
26
- worktreePath: string | null;
27
- modelLabel: string;
28
+ attemptId: string;
29
+ status: Status;
30
+ backend: string;
31
+ failureKind: string | null;
32
+ startedAt: string;
33
+ completedAt: string | null;
34
+ durationMs: number | null;
35
+ resultPath: string;
36
+ logPath: string | null;
37
+ logTail: string[];
38
+ workspace: string;
39
+ worktreePath: string | null;
40
+ modelLabel: string;
28
41
  }
29
42
 
30
43
  interface RunRow {
31
- runId: string;
32
- status: Status;
33
- backend: string;
34
- updatedMs: number;
35
- startedAt: string;
36
- completedAt: string | null;
37
- dependency: string | null;
38
- eventTail: string[];
39
- tasks: TaskRow[];
44
+ key: string;
45
+ runId: string;
46
+ sourceCwd: string;
47
+ runsDir: string;
48
+ status: Status;
49
+ backend: string;
50
+ updatedMs: number;
51
+ startedAt: string;
52
+ completedAt: string | null;
53
+ dependency: string | null;
54
+ eventTail: string[];
55
+ childSummary?: RunChildSummary;
56
+ tasks: TaskRow[];
40
57
  }
41
58
 
42
59
  interface PanelSnapshot {
43
- runs: RunRow[];
44
- totalRuns: number;
45
- loadedAt: Date;
60
+ runs: RunRow[];
61
+ totalRuns: number;
62
+ hiddenRuns: number;
63
+ loadedAt: Date;
64
+ staleLocators: number;
65
+ invalidLocators: number;
66
+ skippedLocators: number;
46
67
  }
47
68
 
48
69
  interface PanelTheme {
49
- // Method signatures are intentionally used so the host Theme (with a narrower
50
- // ThemeColor union) remains assignable under bivariant method checks.
51
- fg?(color: string, text: string): string;
52
- bg?(color: string, text: string): string;
53
- bold?(text: string): string;
70
+ // Method signatures are intentionally used so the host Theme (with a narrower
71
+ // ThemeColor union) remains assignable under bivariant method checks.
72
+ fg?(color: string, text: string): string;
73
+ bg?(color: string, text: string): string;
74
+ bold?(text: string): string;
54
75
  }
55
76
 
56
77
  interface PanelTui {
57
- requestRender?: () => void;
78
+ requestRender?: () => void;
79
+ }
80
+
81
+ interface LoadOptions {
82
+ cwd: string;
83
+ scope: ScopeFilter;
84
+ statusFilter: StatusFilter;
85
+ currentSessionId?: string;
86
+ showMorePages: number;
58
87
  }
59
88
 
60
89
  function isInsideOrEqual(parent: string, child: string): boolean {
61
- const childRelative = relative(parent, child);
62
- return childRelative === "" || (!childRelative.startsWith("..") && !isAbsolute(childRelative));
90
+ const childRelative = relative(parent, child);
91
+ return (
92
+ childRelative === "" ||
93
+ (!childRelative.startsWith("..") && !isAbsolute(childRelative))
94
+ );
63
95
  }
64
96
 
65
97
  function safeRelative(cwd: string, path: string): string {
66
- const rel = relative(cwd, path);
67
- if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return path;
68
- return rel.split(sep).join("/");
98
+ const rel = relative(cwd, path);
99
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return path;
100
+ return rel.split(sep).join("/");
69
101
  }
70
102
 
71
103
  function style(theme: PanelTheme, color: string, text: string): string {
72
- return theme.fg?.(color, text) ?? text;
104
+ return theme.fg?.(color, text) ?? text;
73
105
  }
74
106
 
75
107
  function bold(theme: PanelTheme, text: string): string {
76
- return theme.bold?.(text) ?? text;
108
+ return theme.bold?.(text) ?? text;
77
109
  }
78
110
 
79
111
  const ANSI_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
@@ -84,595 +116,1355 @@ const ANSI_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
84
116
  // host TUI measures lines, otherwise CJK-heavy text under-counts and overflows
85
117
  // the terminal width (which crashes the renderer).
86
118
  function charWidth(cp: number): number {
87
- // C0/C1 control characters render with no horizontal advance here.
88
- if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) return 0;
89
- // Zero-width and combining ranges.
90
- if (
91
- cp === 0x200b || // zero width space
92
- (cp >= 0x0300 && cp <= 0x036f) || // combining diacritical marks
93
- (cp >= 0x1ab0 && cp <= 0x1aff) ||
94
- (cp >= 0x1dc0 && cp <= 0x1dff) ||
95
- (cp >= 0x20d0 && cp <= 0x20ff) ||
96
- (cp >= 0xfe00 && cp <= 0xfe0f) || // variation selectors
97
- (cp >= 0xfe20 && cp <= 0xfe2f)
98
- ) {
99
- return 0;
100
- }
101
- // Wide (2-column) ranges.
102
- if (
103
- (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
104
- cp === 0x2329 ||
105
- cp === 0x232a ||
106
- (cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals, Kangxi, punctuation
107
- (cp >= 0x3041 && cp <= 0x33ff) || // Hiragana..CJK compatibility
108
- (cp >= 0x3400 && cp <= 0x4dbf) || // CJK ext A
109
- (cp >= 0x4e00 && cp <= 0x9fff) || // CJK unified
110
- (cp >= 0xa000 && cp <= 0xa4cf) || // Yi
111
- (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
112
- (cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
113
- (cp >= 0xfe10 && cp <= 0xfe19) || // vertical forms
114
- (cp >= 0xfe30 && cp <= 0xfe6f) || // CJK compatibility forms
115
- (cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
116
- (cp >= 0xffe0 && cp <= 0xffe6) ||
117
- (cp >= 0x1f300 && cp <= 0x1faff) || // emoji & pictographs
118
- (cp >= 0x20000 && cp <= 0x3fffd) // CJK ext B+
119
- ) {
120
- return 2;
121
- }
122
- return 1;
119
+ // C0/C1 control characters render with no horizontal advance here.
120
+ if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) return 0;
121
+ // Zero-width and combining ranges.
122
+ if (
123
+ cp === 0x200b || // zero width space
124
+ (cp >= 0x0300 && cp <= 0x036f) || // combining diacritical marks
125
+ (cp >= 0x1ab0 && cp <= 0x1aff) ||
126
+ (cp >= 0x1dc0 && cp <= 0x1dff) ||
127
+ (cp >= 0x20d0 && cp <= 0x20ff) ||
128
+ (cp >= 0xfe00 && cp <= 0xfe0f) || // variation selectors
129
+ (cp >= 0xfe20 && cp <= 0xfe2f)
130
+ ) {
131
+ return 0;
132
+ }
133
+ // Wide (2-column) ranges.
134
+ if (
135
+ (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
136
+ cp === 0x2329 ||
137
+ cp === 0x232a ||
138
+ (cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals, Kangxi, punctuation
139
+ (cp >= 0x3041 && cp <= 0x33ff) || // Hiragana..CJK compatibility
140
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK ext A
141
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK unified
142
+ (cp >= 0xa000 && cp <= 0xa4cf) || // Yi
143
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
144
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
145
+ (cp >= 0xfe10 && cp <= 0xfe19) || // vertical forms
146
+ (cp >= 0xfe30 && cp <= 0xfe6f) || // CJK compatibility forms
147
+ (cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
148
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
149
+ (cp >= 0x1f300 && cp <= 0x1faff) || // emoji & pictographs
150
+ (cp >= 0x20000 && cp <= 0x3fffd) // CJK ext B+
151
+ ) {
152
+ return 2;
153
+ }
154
+ return 1;
123
155
  }
124
156
 
125
157
  // Measure the visible terminal width of a string, ignoring ANSI escapes and
126
158
  // accounting for wide characters.
127
159
  function visibleLength(text: string): number {
128
- let width = 0;
129
- for (let index = 0; index < text.length; ) {
130
- if (text.charCodeAt(index) === 0x1b) {
131
- ANSI_PATTERN.lastIndex = index;
132
- const match = ANSI_PATTERN.exec(text);
133
- if (match && match.index === index) {
134
- index = ANSI_PATTERN.lastIndex;
135
- continue;
136
- }
137
- }
138
- const cp = text.codePointAt(index) ?? 0;
139
- width += charWidth(cp);
140
- index += cp > 0xffff ? 2 : 1;
141
- }
142
- return width;
160
+ let width = 0;
161
+ for (let index = 0; index < text.length; ) {
162
+ if (text.charCodeAt(index) === 0x1b) {
163
+ ANSI_PATTERN.lastIndex = index;
164
+ const match = ANSI_PATTERN.exec(text);
165
+ if (match && match.index === index) {
166
+ index = ANSI_PATTERN.lastIndex;
167
+ continue;
168
+ }
169
+ }
170
+ const cp = text.codePointAt(index) ?? 0;
171
+ width += charWidth(cp);
172
+ index += cp > 0xffff ? 2 : 1;
173
+ }
174
+ return width;
143
175
  }
144
176
 
145
177
  function clip(text: string, width: number): string {
146
- if (width <= 0) return "";
147
- if (visibleLength(text) <= width) return text;
148
- if (width <= 1) return "…";
149
-
150
- let output = "";
151
- let visible = 0;
152
- for (let index = 0; index < text.length; ) {
153
- if (text.charCodeAt(index) === 0x1b) {
154
- ANSI_PATTERN.lastIndex = index;
155
- const match = ANSI_PATTERN.exec(text);
156
- if (match && match.index === index) {
157
- output += match[0];
158
- index = ANSI_PATTERN.lastIndex;
159
- continue;
160
- }
161
- }
162
- const cp = text.codePointAt(index) ?? 0;
163
- const w = charWidth(cp);
164
- // Reserve one column for the ellipsis.
165
- if (visible + w > width - 1) break;
166
- output += String.fromCodePoint(cp);
167
- visible += w;
168
- index += cp > 0xffff ? 2 : 1;
169
- }
170
- return `${output}…`;
178
+ if (width <= 0) return "";
179
+ if (visibleLength(text) <= width) return text;
180
+ if (width <= 1) return "…";
181
+
182
+ let output = "";
183
+ let visible = 0;
184
+ for (let index = 0; index < text.length; ) {
185
+ if (text.charCodeAt(index) === 0x1b) {
186
+ ANSI_PATTERN.lastIndex = index;
187
+ const match = ANSI_PATTERN.exec(text);
188
+ if (match && match.index === index) {
189
+ output += match[0];
190
+ index = ANSI_PATTERN.lastIndex;
191
+ continue;
192
+ }
193
+ }
194
+ const cp = text.codePointAt(index) ?? 0;
195
+ const w = charWidth(cp);
196
+ // Reserve one column for the ellipsis.
197
+ if (visible + w > width - 1) break;
198
+ output += String.fromCodePoint(cp);
199
+ visible += w;
200
+ index += cp > 0xffff ? 2 : 1;
201
+ }
202
+ return `${output}…`;
171
203
  }
172
204
 
173
205
  function pad(text: string, width: number): string {
174
- const visible = visibleLength(text);
175
- return visible >= width ? clip(text, width) : text + " ".repeat(width - visible);
206
+ const visible = visibleLength(text);
207
+ return visible >= width
208
+ ? clip(text, width)
209
+ : text + " ".repeat(width - visible);
210
+ }
211
+
212
+ function sanitizeRunText(text: string, currentSessionId?: string): string {
213
+ let sanitized = text
214
+ .replace(ANSI_PATTERN, "")
215
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "")
216
+ .replace(/\r/g, "");
217
+ if (currentSessionId && currentSessionId.length > 0)
218
+ sanitized = sanitized.split(currentSessionId).join("[session]");
219
+ return sanitized;
176
220
  }
177
221
 
178
222
  function fmtAge(ms: number, now = Date.now()): string {
179
- const delta = Math.max(0, now - ms);
180
- if (delta < 1_000) return "now";
181
- if (delta < 60_000) return `${Math.floor(delta / 1_000)}s ago`;
182
- if (delta < 3_600_000) return `${Math.floor(delta / 60_000)}m ago`;
183
- return `${Math.floor(delta / 3_600_000)}h ago`;
223
+ const delta = Math.max(0, now - ms);
224
+ if (delta < 1_000) return "now";
225
+ if (delta < 60_000) return `${Math.floor(delta / 1_000)}s ago`;
226
+ if (delta < 3_600_000) return `${Math.floor(delta / 60_000)}m ago`;
227
+ return `${Math.floor(delta / 3_600_000)}h ago`;
184
228
  }
185
229
 
186
230
  function fmtElapsed(startedAt: string, completedAt: string | null): string {
187
- const start = Date.parse(startedAt);
188
- const end = completedAt === null ? Date.now() : Date.parse(completedAt);
189
- if (!Number.isFinite(start) || !Number.isFinite(end)) return "—";
190
- const seconds = Math.max(0, Math.floor((end - start) / 1_000));
191
- const mins = Math.floor(seconds / 60);
192
- const secs = seconds % 60;
193
- return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
231
+ const start = Date.parse(startedAt);
232
+ const end = completedAt === null ? Date.now() : Date.parse(completedAt);
233
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return "—";
234
+ const seconds = Math.max(0, Math.floor((end - start) / 1_000));
235
+ const mins = Math.floor(seconds / 60);
236
+ const secs = seconds % 60;
237
+ return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
194
238
  }
195
239
 
196
240
  function statusPriority(status: Status): number {
197
- if (status === "running") return 0;
198
- if (status === "pending") return 1;
199
- if (status === "failed") return 2;
200
- if (status === "cancelled") return 3;
201
- return 4;
241
+ if (status === "running") return 0;
242
+ if (status === "pending") return 1;
243
+ if (status === "failed") return 2;
244
+ if (status === "cancelled") return 3;
245
+ return 4;
202
246
  }
203
247
 
204
248
  function aggregateRunStatus(attempts: TaskRow[]): Status {
205
- if (attempts.some((attempt) => attempt.status === "running")) return "running";
206
- if (attempts.some((attempt) => attempt.status === "pending")) return "pending";
207
- if (attempts.some((attempt) => attempt.status === "failed")) return "failed";
208
- if (attempts.some((attempt) => attempt.status === "cancelled")) return "cancelled";
209
- return "completed";
249
+ if (attempts.some((attempt) => attempt.status === "running"))
250
+ return "running";
251
+ if (attempts.some((attempt) => attempt.status === "pending"))
252
+ return "pending";
253
+ if (attempts.some((attempt) => attempt.status === "failed")) return "failed";
254
+ if (attempts.some((attempt) => attempt.status === "cancelled"))
255
+ return "cancelled";
256
+ return "completed";
210
257
  }
211
258
 
212
259
  function isEscapeKey(data: string): boolean {
213
- return (
214
- data === "\u001b" ||
215
- data === "escape" ||
216
- data === "esc" ||
217
- data === "Esc" ||
218
- data === "ctrl+[" ||
219
- data.startsWith("escape") ||
220
- data.startsWith("esc") ||
221
- /^\u001b\[27(?:;\d+)?(?::\d+)?u$/.test(data)
222
- );
260
+ return (
261
+ data === "\u001b" ||
262
+ data === "escape" ||
263
+ data === "esc" ||
264
+ data === "Esc" ||
265
+ data === "ctrl+[" ||
266
+ data.startsWith("escape") ||
267
+ data.startsWith("esc") ||
268
+ /^\u001b\[27(?:;\d+)?(?::\d+)?u$/.test(data)
269
+ );
223
270
  }
224
271
 
225
272
  function isEnterKey(data: string): boolean {
226
- return data === "\r" || data === "\n" || data === "enter" || data === "return" || data === "\u001b[13u";
273
+ return (
274
+ data === "\r" ||
275
+ data === "\n" ||
276
+ data === "enter" ||
277
+ data === "return" ||
278
+ data === "\u001b[13u"
279
+ );
227
280
  }
228
281
 
229
282
  function isTabKey(data: string): boolean {
230
- return data === "\t" || data === "tab" || data === "\u001b[9u";
283
+ return data === "\t" || data === "tab" || data === "\u001b[9u";
231
284
  }
232
285
 
233
- function isArrowKey(data: string, direction: "up" | "down" | "left" | "right"): boolean {
234
- if (data === direction) return true;
235
- const legacy: Record<typeof direction, string[]> = {
236
- up: ["\u001b[A", "\u001bOA", "\u001b[a"],
237
- down: ["\u001b[B", "\u001bOB", "\u001b[b"],
238
- left: ["\u001b[D", "\u001bOD", "\u001b[d"],
239
- right: ["\u001b[C", "\u001bOC", "\u001b[c"],
240
- };
241
- if (legacy[direction].includes(data)) return true;
242
- const suffix: Record<typeof direction, string> = { up: "A", down: "B", right: "C", left: "D" };
243
- return new RegExp(`^\\u001b\\[1;\\d+(?::\\d+)?${suffix[direction]}$`).test(data);
286
+ function isPageKey(data: string, direction: "up" | "down"): boolean {
287
+ if (direction === "up")
288
+ return data === "pageup" || data === "pgup" || data === "\u001b[5~";
289
+ return data === "pagedown" || data === "pgdown" || data === "\u001b[6~";
290
+ }
291
+
292
+ function isArrowKey(
293
+ data: string,
294
+ direction: "up" | "down" | "left" | "right",
295
+ ): boolean {
296
+ if (data === direction) return true;
297
+ const legacy: Record<typeof direction, string[]> = {
298
+ up: ["\u001b[A", "\u001bOA", "\u001b[a"],
299
+ down: ["\u001b[B", "\u001bOB", "\u001b[b"],
300
+ left: ["\u001b[D", "\u001bOD", "\u001b[d"],
301
+ right: ["\u001b[C", "\u001bOC", "\u001b[c"],
302
+ };
303
+ if (legacy[direction].includes(data)) return true;
304
+ const suffix: Record<typeof direction, string> = {
305
+ up: "A",
306
+ down: "B",
307
+ right: "C",
308
+ left: "D",
309
+ };
310
+ return new RegExp(`^\\u001b\\[1;\\d+(?::\\d+)?${suffix[direction]}$`).test(
311
+ data,
312
+ );
244
313
  }
245
314
 
246
315
  function statusColor(status: Status): string {
247
- if (status === "completed") return "success";
248
- if (status === "running" || status === "pending") return "warning";
249
- if (status === "failed" || status === "cancelled") return "error";
250
- return "accent";
316
+ if (status === "completed") return "success";
317
+ if (status === "running" || status === "pending") return "warning";
318
+ if (status === "failed" || status === "cancelled") return "error";
319
+ return "accent";
251
320
  }
252
321
 
253
322
  function statusLabel(status: Status): string {
254
- if (status === "completed") return "done";
255
- return status;
323
+ if (status === "completed") return "done";
324
+ return status;
325
+ }
326
+
327
+ function childFailureCount(summary: RunChildSummary | undefined): number {
328
+ return (summary?.failed ?? 0) + (summary?.cancelled ?? 0);
329
+ }
330
+
331
+ function runHasFailure(run: Pick<RunRow, "status" | "childSummary">): boolean {
332
+ return (
333
+ run.status === "failed" ||
334
+ run.status === "cancelled" ||
335
+ childFailureCount(run.childSummary) > 0
336
+ );
337
+ }
338
+
339
+ function runStatusLabel(run: Pick<RunRow, "status" | "childSummary">): string {
340
+ const base = statusLabel(run.status);
341
+ return childFailureCount(run.childSummary) > 0 ? `${base}+child` : base;
342
+ }
343
+
344
+ function runStatusDetail(run: Pick<RunRow, "status" | "childSummary">): string {
345
+ const failures = childFailureCount(run.childSummary);
346
+ return failures > 0
347
+ ? `${statusLabel(run.status)} (child failures: ${failures})`
348
+ : statusLabel(run.status);
349
+ }
350
+
351
+ function runStatusColor(run: Pick<RunRow, "status" | "childSummary">): string {
352
+ return childFailureCount(run.childSummary) > 0
353
+ ? "error"
354
+ : statusColor(run.status);
256
355
  }
257
356
 
258
357
  function isActive(status: Status): boolean {
259
- return status === "pending" || status === "running";
358
+ return status === "pending" || status === "running";
359
+ }
360
+
361
+ function pidAlive(pid: number | undefined): boolean {
362
+ if (pid === undefined || !Number.isInteger(pid) || pid <= 0) return false;
363
+ try {
364
+ process.kill(pid, 0);
365
+ return true;
366
+ } catch (error) {
367
+ return (
368
+ error !== null &&
369
+ typeof error === "object" &&
370
+ "code" in error &&
371
+ error.code === "EPERM"
372
+ );
373
+ }
374
+ }
375
+
376
+ function timestampFresh(
377
+ value: string | undefined,
378
+ staleAfterMs = STALE_RUN_AFTER_MS,
379
+ ): boolean {
380
+ if (value === undefined) return false;
381
+ const time = Date.parse(value);
382
+ return Number.isFinite(time) && Date.now() - time <= staleAfterMs;
383
+ }
384
+
385
+ function runKey(cwd: string, runsDir: string, runId: string): string {
386
+ return `${cwd}\u0000${runsDir}\u0000${runId}`;
260
387
  }
261
388
 
262
389
  async function readJson(path: string): Promise<unknown | null> {
263
- try {
264
- return JSON.parse(await readFile(path, "utf8"));
265
- } catch {
266
- return null;
267
- }
390
+ try {
391
+ return JSON.parse(await readFile(path, "utf8"));
392
+ } catch {
393
+ return null;
394
+ }
268
395
  }
269
396
 
270
397
  function isResultEnvelope(value: unknown): value is ResultEnvelope {
271
- return (
272
- typeof value === "object" &&
273
- value !== null &&
274
- typeof (value as { runId?: unknown }).runId === "string" &&
275
- (typeof (value as { attemptId?: unknown }).attemptId === "string" || typeof (value as { taskId?: unknown }).taskId === "string")
276
- );
398
+ return (
399
+ typeof value === "object" &&
400
+ value !== null &&
401
+ typeof (value as { runId?: unknown }).runId === "string" &&
402
+ (typeof (value as { attemptId?: unknown }).attemptId === "string" ||
403
+ typeof (value as { taskId?: unknown }).taskId === "string")
404
+ );
277
405
  }
278
406
 
279
407
  interface RegistryTaskRecord {
280
- attemptId?: string;
281
- taskId?: string;
282
- status: Status;
283
- backend?: string;
284
- failureKind?: string | null;
285
- startedAt?: string;
286
- completedAt?: string | null;
287
- updatedAt?: string;
288
- artifactCwd?: string;
289
- resultPath?: string;
290
- outputPath?: string;
291
- stdoutPath?: string;
292
- stderrPath?: string;
293
- workspace?: { cwd?: string; worktreePath?: string | null };
408
+ attemptId?: string;
409
+ taskId?: string;
410
+ status: Status;
411
+ backend?: string;
412
+ failureKind?: string | null;
413
+ startedAt?: string;
414
+ completedAt?: string | null;
415
+ updatedAt?: string;
416
+ heartbeatAt?: string;
417
+ artifactCwd?: string;
418
+ resultPath?: string;
419
+ outputPath?: string;
420
+ stdoutPath?: string;
421
+ stderrPath?: string;
422
+ process?: { pid?: number; workerPid?: number };
423
+ workspace?: { cwd?: string; worktreePath?: string | null };
294
424
  }
295
425
 
296
426
  interface RegistryRunRecord {
297
- runId: string;
298
- mode?: string;
299
- status: Status;
300
- backend?: string;
301
- dependency?: string | null;
302
- startedAt: string;
303
- updatedAt: string;
304
- completedAt: string | null;
305
- attempts?: RegistryTaskRecord[];
306
- tasks?: RegistryTaskRecord[];
427
+ runId: string;
428
+ mode?: string;
429
+ status: Status;
430
+ backend?: string;
431
+ dependency?: string | null;
432
+ parentSessionId?: string;
433
+ startedAt: string;
434
+ updatedAt: string;
435
+ completedAt: string | null;
436
+ attempts?: RegistryTaskRecord[];
437
+ tasks?: RegistryTaskRecord[];
307
438
  }
308
439
 
309
440
  function isRegistryRunRecord(value: unknown): value is RegistryRunRecord {
310
- return (
311
- typeof value === "object" &&
312
- value !== null &&
313
- typeof (value as { runId?: unknown }).runId === "string" &&
314
- (Array.isArray((value as { attempts?: unknown }).attempts) || Array.isArray((value as { tasks?: unknown }).tasks))
315
- );
441
+ return (
442
+ typeof value === "object" &&
443
+ value !== null &&
444
+ typeof (value as { runId?: unknown }).runId === "string" &&
445
+ typeof (value as { startedAt?: unknown }).startedAt === "string" &&
446
+ typeof (value as { updatedAt?: unknown }).updatedAt === "string" &&
447
+ (Array.isArray((value as { attempts?: unknown }).attempts) ||
448
+ Array.isArray((value as { tasks?: unknown }).tasks))
449
+ );
450
+ }
451
+
452
+ function parseRunEvents(text: string): RunEvent[] {
453
+ return text
454
+ .split(/\r?\n/)
455
+ .filter(Boolean)
456
+ .map((line) => {
457
+ try {
458
+ return JSON.parse(line) as RunEvent;
459
+ } catch {
460
+ return null;
461
+ }
462
+ })
463
+ .filter((event): event is RunEvent => event !== null);
316
464
  }
317
465
 
318
- async function readLogTail(cwd: string, result: ResultEnvelope): Promise<{ path: string | null; tail: string[] }> {
319
- const artifact = result.artifacts.find((candidate) => candidate.type === "output") ?? result.artifacts.find((candidate) => candidate.type === "stdout") ?? result.artifacts.find((candidate) => candidate.type === "stderr");
320
- if (artifact === undefined) return { path: null, tail: [] };
321
- if (isAbsolute(artifact.path) || artifact.path.split("/").includes("..")) return { path: artifact.path, tail: [] };
322
- const path = resolve(cwd, artifact.path.split("/").join(sep));
323
- if (!isInsideOrEqual(cwd, path)) return { path: artifact.path, tail: [] };
324
- const text = await readFile(path, "utf8").catch(() => "");
325
- return { path: artifact.path, tail: text.split(/\r?\n/).filter(Boolean).slice(-LOG_TAIL_LINES) };
466
+ async function readTextTail(
467
+ path: string,
468
+ currentSessionId?: string,
469
+ ): Promise<string[]> {
470
+ const text = await readFile(path, "utf8").catch(() => "");
471
+ return text
472
+ .split(/\r?\n/)
473
+ .map((line) => sanitizeRunText(line, currentSessionId))
474
+ .filter(Boolean)
475
+ .slice(-LOG_TAIL_LINES);
476
+ }
477
+
478
+ async function readLogTail(
479
+ cwd: string,
480
+ result: ResultEnvelope,
481
+ loadTails: boolean,
482
+ currentSessionId?: string,
483
+ ): Promise<{ path: string | null; tail: string[] }> {
484
+ const artifact =
485
+ result.artifacts.find((candidate) => candidate.type === "output") ??
486
+ result.artifacts.find((candidate) => candidate.type === "stdout") ??
487
+ result.artifacts.find((candidate) => candidate.type === "stderr");
488
+ if (artifact === undefined) return { path: null, tail: [] };
489
+ if (isAbsolute(artifact.path) || artifact.path.split("/").includes(".."))
490
+ return { path: artifact.path, tail: [] };
491
+ const path = resolve(cwd, artifact.path.split("/").join(sep));
492
+ if (!isInsideOrEqual(cwd, path)) return { path: artifact.path, tail: [] };
493
+ const tail = loadTails ? await readTextTail(path, currentSessionId) : [];
494
+ return { path: artifact.path, tail };
326
495
  }
327
496
 
328
497
  function modelLabel(result: ResultEnvelope): string {
329
- const pieces: string[] = [];
330
- const maybeResult = result as ResultEnvelope & { model?: string; thinking?: string };
331
- if (typeof maybeResult.model === "string") pieces.push(maybeResult.model);
332
- if (typeof maybeResult.thinking === "string") pieces.push(maybeResult.thinking);
333
- return pieces.join(" · ");
334
- }
335
-
336
- async function readTask(cwd: string, resultPath: string, _mtimeMs: number): Promise<TaskRow | null> {
337
- const parsed = await readJson(resultPath);
338
- if (!isResultEnvelope(parsed)) return null;
339
- const log = await readLogTail(cwd, parsed);
340
- return {
341
- attemptId: parsed.attemptId ?? parsed.taskId ?? "unknown",
342
- status: parsed.status,
343
- backend: parsed.backend,
344
- failureKind: parsed.failureKind,
345
- startedAt: parsed.startedAt,
346
- completedAt: parsed.completedAt,
347
- durationMs: parsed.durationMs,
348
- resultPath: safeRelative(cwd, resultPath),
349
- logPath: log.path,
350
- logTail: log.tail,
351
- workspace: parsed.workspace.cwd,
352
- worktreePath: parsed.workspace.worktreePath,
353
- modelLabel: modelLabel(parsed),
354
- };
355
- }
356
-
357
- async function readTailFromRegistryPath(task: RegistryTaskRecord): Promise<{ path: string | null; tail: string[] }> {
358
- const artifactCwd = task.artifactCwd;
359
- const path = task.outputPath ?? task.stdoutPath ?? task.stderrPath;
360
- if (artifactCwd === undefined || path === undefined || isAbsolute(path) || path.split("/").includes("..")) return { path: path ?? null, tail: [] };
361
- const absolute = resolve(artifactCwd, path.split("/").join(sep));
362
- if (!isInsideOrEqual(resolve(artifactCwd), absolute)) return { path, tail: [] };
363
- const text = await readFile(absolute, "utf8").catch(() => "");
364
- return { path, tail: text.split(/\r?\n/).filter(Boolean).slice(-LOG_TAIL_LINES) };
365
- }
366
-
367
- async function readTaskFromRegistry(cwd: string, task: RegistryTaskRecord): Promise<TaskRow> {
368
- if (task.artifactCwd !== undefined && task.resultPath !== undefined && !isAbsolute(task.resultPath) && !task.resultPath.split("/").includes("..")) {
369
- const absolute = resolve(task.artifactCwd, task.resultPath.split("/").join(sep));
370
- if (isInsideOrEqual(resolve(task.artifactCwd), absolute)) {
371
- const statInfo = await stat(absolute).catch(() => null);
372
- if (statInfo !== null) {
373
- const parsed = await readTask(task.artifactCwd, absolute, statInfo.mtimeMs);
374
- if (parsed !== null) return parsed;
375
- }
376
- }
377
- }
378
- const log = await readTailFromRegistryPath(task);
379
- return {
380
- attemptId: task.attemptId ?? task.taskId ?? "unknown",
381
- status: task.status,
382
- backend: task.backend ?? "unknown",
383
- failureKind: task.failureKind ?? null,
384
- startedAt: task.startedAt ?? task.updatedAt ?? new Date().toISOString(),
385
- completedAt: task.completedAt ?? null,
386
- durationMs: null,
387
- resultPath: task.resultPath ?? "—",
388
- logPath: log.path,
389
- logTail: log.tail,
390
- workspace: task.workspace?.cwd ?? cwd,
391
- worktreePath: task.workspace?.worktreePath ?? null,
392
- modelLabel: "",
393
- };
394
- }
395
-
396
- async function loadRuns(cwd: string, filter: Filter): Promise<PanelSnapshot> {
397
- const runsDir = resolve(cwd, DEFAULT_RUNS_DIR);
398
- if (!isInsideOrEqual(cwd, runsDir)) return { runs: [], totalRuns: 0, loadedAt: new Date() };
399
- const runEntries = await readdir(runsDir, { withFileTypes: true }).catch(() => []);
400
- const runs: RunRow[] = [];
401
-
402
- for (const runEntry of runEntries) {
403
- if (!runEntry.isDirectory()) continue;
404
- const runDir = join(runsDir, runEntry.name);
405
- const registry = await readJson(join(runDir, "run.json"));
406
- if (isRegistryRunRecord(registry)) {
407
- const eventsText = await readFile(join(runDir, "events.jsonl"), "utf8").catch(() => "");
408
- const eventTail = eventsText.split(/\r?\n/).filter(Boolean).slice(-LOG_TAIL_LINES);
409
- const records = registry.attempts ?? registry.tasks ?? [];
410
- const tasks = await Promise.all(records.map((task) => readTaskFromRegistry(cwd, task)));
411
- if (tasks.length === 0) continue;
412
- tasks.sort((a, b) => a.attemptId.localeCompare(b.attemptId, undefined, { numeric: true }));
413
- runs.push({
414
- runId: registry.runId,
415
- status: registry.status,
416
- backend: registry.backend ?? tasks[0]?.backend ?? "unknown",
417
- updatedMs: Number.isFinite(Date.parse(registry.updatedAt)) ? Date.parse(registry.updatedAt) : Date.now(),
418
- startedAt: registry.startedAt,
419
- completedAt: registry.completedAt,
420
- dependency: registry.dependency ?? null,
421
- eventTail,
422
- tasks,
423
- });
424
- continue;
425
- }
426
-
427
- const taskEntries = await readdir(runDir, { withFileTypes: true }).catch(() => []);
428
- const attemptEntries = await readdir(join(runDir, "attempts"), { withFileTypes: true }).catch(() => []);
429
- const candidates = [
430
- ...attemptEntries.filter((entry) => entry.isDirectory()).map((entry) => join(runDir, "attempts", entry.name, "result.json")),
431
- ...taskEntries.filter((entry) => entry.isDirectory() && entry.name !== "attempts").map((entry) => join(runDir, entry.name, "result.json")),
432
- ];
433
- const tasks: TaskRow[] = [];
434
- let updatedMs = 0;
435
- for (const resultPath of candidates) {
436
- const resultStat = await stat(resultPath).catch(() => null);
437
- if (resultStat === null) continue;
438
- updatedMs = Math.max(updatedMs, resultStat.mtimeMs);
439
- const task = await readTask(cwd, resultPath, resultStat.mtimeMs);
440
- if (task !== null) tasks.push(task);
441
- }
442
- if (tasks.length === 0) continue;
443
- tasks.sort((a, b) => a.attemptId.localeCompare(b.attemptId, undefined, { numeric: true }));
444
- const status = aggregateRunStatus(tasks);
445
- runs.push({
446
- runId: runEntry.name,
447
- status,
448
- backend: tasks[0]?.backend ?? "unknown",
449
- updatedMs,
450
- startedAt: tasks.map((task) => task.startedAt).sort()[0] ?? new Date(updatedMs).toISOString(),
451
- completedAt: tasks.every((task) => task.completedAt !== null) ? tasks.map((task) => task.completedAt).sort().at(-1) ?? null : null,
452
- dependency: null,
453
- eventTail: [],
454
- tasks,
455
- });
456
- }
457
-
458
- const totalRuns = runs.length;
459
- runs.sort((a, b) => statusPriority(a.status) - statusPriority(b.status) || b.updatedMs - a.updatedMs);
460
- const filtered = runs.filter((run) => {
461
- if (filter === "failed") return run.status === "failed" || run.status === "cancelled";
462
- if (filter === "completed") return run.status === "completed";
463
- return true;
464
- });
465
- return { runs: filtered, totalRuns, loadedAt: new Date() };
498
+ const pieces: string[] = [];
499
+ const maybeResult = result as ResultEnvelope & {
500
+ model?: string;
501
+ thinking?: string;
502
+ };
503
+ if (typeof maybeResult.model === "string") pieces.push(maybeResult.model);
504
+ if (typeof maybeResult.thinking === "string")
505
+ pieces.push(maybeResult.thinking);
506
+ return pieces.join(" · ");
507
+ }
508
+
509
+ async function readTask(
510
+ cwd: string,
511
+ resultPath: string,
512
+ mtimeMs: number,
513
+ loadTails: boolean,
514
+ currentSessionId?: string,
515
+ ): Promise<TaskRow | null> {
516
+ const parsed = await readJson(resultPath);
517
+ if (!isResultEnvelope(parsed)) return null;
518
+ const log = await readLogTail(cwd, parsed, loadTails, currentSessionId);
519
+ const stale =
520
+ isActive(parsed.status) && Date.now() - mtimeMs > STALE_RUN_AFTER_MS;
521
+ return {
522
+ attemptId: parsed.attemptId ?? parsed.taskId ?? "unknown",
523
+ status: stale ? "failed" : parsed.status,
524
+ backend: parsed.backend,
525
+ failureKind: stale ? "stale" : parsed.failureKind,
526
+ startedAt: parsed.startedAt,
527
+ completedAt: stale ? new Date(mtimeMs).toISOString() : parsed.completedAt,
528
+ durationMs: parsed.durationMs,
529
+ resultPath: safeRelative(cwd, resultPath),
530
+ logPath: log.path,
531
+ logTail: log.tail,
532
+ workspace: parsed.workspace.cwd,
533
+ worktreePath: parsed.workspace.worktreePath,
534
+ modelLabel: modelLabel(parsed),
535
+ };
536
+ }
537
+
538
+ async function readTailFromRegistryPath(
539
+ task: RegistryTaskRecord,
540
+ loadTails: boolean,
541
+ currentSessionId?: string,
542
+ ): Promise<{ path: string | null; tail: string[] }> {
543
+ const artifactCwd = task.artifactCwd;
544
+ const path = task.outputPath ?? task.stdoutPath ?? task.stderrPath;
545
+ if (
546
+ artifactCwd === undefined ||
547
+ path === undefined ||
548
+ isAbsolute(path) ||
549
+ path.split("/").includes("..")
550
+ )
551
+ return { path: path ?? null, tail: [] };
552
+ const absolute = resolve(artifactCwd, path.split("/").join(sep));
553
+ if (!isInsideOrEqual(resolve(artifactCwd), absolute))
554
+ return { path, tail: [] };
555
+ const tail = loadTails ? await readTextTail(absolute, currentSessionId) : [];
556
+ return { path, tail };
557
+ }
558
+
559
+ function registryTaskStale(task: RegistryTaskRecord): boolean {
560
+ if (!isActive(task.status)) return false;
561
+ if (pidAlive(task.process?.pid) || pidAlive(task.process?.workerPid))
562
+ return false;
563
+ if (timestampFresh(task.heartbeatAt) || timestampFresh(task.updatedAt))
564
+ return false;
565
+ return true;
566
+ }
567
+
568
+ async function readTaskFromRegistry(
569
+ cwd: string,
570
+ task: RegistryTaskRecord,
571
+ loadTails: boolean,
572
+ currentSessionId?: string,
573
+ ): Promise<TaskRow> {
574
+ if (
575
+ task.artifactCwd !== undefined &&
576
+ task.resultPath !== undefined &&
577
+ !isAbsolute(task.resultPath) &&
578
+ !task.resultPath.split("/").includes("..")
579
+ ) {
580
+ const absolute = resolve(
581
+ task.artifactCwd,
582
+ task.resultPath.split("/").join(sep),
583
+ );
584
+ if (isInsideOrEqual(resolve(task.artifactCwd), absolute)) {
585
+ const statInfo = await stat(absolute).catch(() => null);
586
+ if (statInfo !== null) {
587
+ const parsed = await readTask(
588
+ task.artifactCwd,
589
+ absolute,
590
+ statInfo.mtimeMs,
591
+ loadTails,
592
+ currentSessionId,
593
+ );
594
+ if (parsed !== null) return parsed;
595
+ }
596
+ }
597
+ }
598
+ const log = await readTailFromRegistryPath(task, loadTails, currentSessionId);
599
+ const stale = registryTaskStale(task);
600
+ return {
601
+ attemptId: task.attemptId ?? task.taskId ?? "unknown",
602
+ status: stale ? "failed" : task.status,
603
+ backend: task.backend ?? "unknown",
604
+ failureKind: stale ? "stale" : (task.failureKind ?? null),
605
+ startedAt: task.startedAt ?? task.updatedAt ?? new Date().toISOString(),
606
+ completedAt:
607
+ task.completedAt ??
608
+ (stale
609
+ ? (task.updatedAt ?? task.heartbeatAt ?? new Date().toISOString())
610
+ : null),
611
+ durationMs: null,
612
+ resultPath: task.resultPath ?? "—",
613
+ logPath: log.path,
614
+ logTail: log.tail,
615
+ workspace: task.workspace?.cwd ?? cwd,
616
+ worktreePath: task.workspace?.worktreePath ?? null,
617
+ modelLabel: "",
618
+ };
619
+ }
620
+
621
+ async function readRunFromRegistry(
622
+ cwd: string,
623
+ runsDir: string,
624
+ runDir: string,
625
+ registry: RegistryRunRecord,
626
+ loadTails: boolean,
627
+ currentSessionId?: string,
628
+ ): Promise<RunRow | null> {
629
+ const eventsText = await readFile(join(runDir, "events.jsonl"), "utf8").catch(
630
+ () => "",
631
+ );
632
+ const eventTail = loadTails
633
+ ? eventsText
634
+ .split(/\r?\n/)
635
+ .map((line) => sanitizeRunText(line, currentSessionId))
636
+ .filter(Boolean)
637
+ .slice(-LOG_TAIL_LINES)
638
+ : [];
639
+ const childSummary = summarizeChildEvents(parseRunEvents(eventsText));
640
+ const records = registry.attempts ?? registry.tasks ?? [];
641
+ const tasks = await Promise.all(
642
+ records.map((task) =>
643
+ readTaskFromRegistry(cwd, task, loadTails, currentSessionId),
644
+ ),
645
+ );
646
+ if (tasks.length === 0) return null;
647
+ tasks.sort((a, b) =>
648
+ a.attemptId.localeCompare(b.attemptId, undefined, { numeric: true }),
649
+ );
650
+ return {
651
+ key: runKey(cwd, runsDir, registry.runId),
652
+ runId: registry.runId,
653
+ sourceCwd: cwd,
654
+ runsDir,
655
+ status: aggregateRunStatus(tasks),
656
+ backend: registry.backend ?? tasks[0]?.backend ?? "unknown",
657
+ updatedMs: Number.isFinite(Date.parse(registry.updatedAt))
658
+ ? Date.parse(registry.updatedAt)
659
+ : Date.now(),
660
+ startedAt: registry.startedAt,
661
+ completedAt: registry.completedAt,
662
+ dependency: registry.dependency ?? null,
663
+ eventTail,
664
+ ...(childSummary === undefined ? {} : { childSummary }),
665
+ tasks,
666
+ };
667
+ }
668
+
669
+ async function loadRunsFromCwd(
670
+ cwd: string,
671
+ options: Pick<LoadOptions, "currentSessionId"> & { sessionOnly?: string },
672
+ ): Promise<{
673
+ runs: RunRow[];
674
+ stale: number;
675
+ invalid: number;
676
+ skipped: number;
677
+ }> {
678
+ const runsDir = resolve(cwd, DEFAULT_RUNS_DIR);
679
+ if (!isInsideOrEqual(cwd, runsDir))
680
+ return { runs: [], stale: 0, invalid: 0, skipped: 0 };
681
+ const runEntries = await readdir(runsDir, { withFileTypes: true }).catch(
682
+ () => [],
683
+ );
684
+ const runs: RunRow[] = [];
685
+ let invalid = 0;
686
+
687
+ for (const runEntry of runEntries) {
688
+ if (!runEntry.isDirectory()) continue;
689
+ const runDir = join(runsDir, runEntry.name);
690
+ const registry = await readJson(join(runDir, "run.json"));
691
+ if (isRegistryRunRecord(registry)) {
692
+ if (
693
+ options.sessionOnly !== undefined &&
694
+ registry.parentSessionId !== options.sessionOnly
695
+ )
696
+ continue;
697
+ const row = await readRunFromRegistry(
698
+ cwd,
699
+ DEFAULT_RUNS_DIR,
700
+ runDir,
701
+ registry,
702
+ true,
703
+ options.currentSessionId,
704
+ ).catch(() => null);
705
+ if (row !== null) runs.push(row);
706
+ else invalid += 1;
707
+ continue;
708
+ }
709
+
710
+ if (options.sessionOnly !== undefined) continue;
711
+
712
+ const taskEntries = await readdir(runDir, { withFileTypes: true }).catch(
713
+ () => [],
714
+ );
715
+ const attemptEntries = await readdir(join(runDir, "attempts"), {
716
+ withFileTypes: true,
717
+ }).catch(() => []);
718
+ const candidates = [
719
+ ...attemptEntries
720
+ .filter((entry) => entry.isDirectory())
721
+ .map((entry) => join(runDir, "attempts", entry.name, "result.json")),
722
+ ...taskEntries
723
+ .filter((entry) => entry.isDirectory() && entry.name !== "attempts")
724
+ .map((entry) => join(runDir, entry.name, "result.json")),
725
+ ];
726
+ const eventsText = await readFile(
727
+ join(runDir, "events.jsonl"),
728
+ "utf8",
729
+ ).catch(() => "");
730
+ const eventTail = eventsText
731
+ .split(/\r?\n/)
732
+ .map((line) => sanitizeRunText(line, options.currentSessionId))
733
+ .filter(Boolean)
734
+ .slice(-LOG_TAIL_LINES);
735
+ const childSummary = summarizeChildEvents(parseRunEvents(eventsText));
736
+ const tasks: TaskRow[] = [];
737
+ let updatedMs = 0;
738
+ for (const resultPath of candidates) {
739
+ const resultStat = await stat(resultPath).catch(() => null);
740
+ if (resultStat === null) continue;
741
+ updatedMs = Math.max(updatedMs, resultStat.mtimeMs);
742
+ const task = await readTask(
743
+ cwd,
744
+ resultPath,
745
+ resultStat.mtimeMs,
746
+ true,
747
+ options.currentSessionId,
748
+ );
749
+ if (task !== null) tasks.push(task);
750
+ }
751
+ if (tasks.length === 0) continue;
752
+ tasks.sort((a, b) =>
753
+ a.attemptId.localeCompare(b.attemptId, undefined, { numeric: true }),
754
+ );
755
+ const status = aggregateRunStatus(tasks);
756
+ runs.push({
757
+ key: runKey(cwd, DEFAULT_RUNS_DIR, runEntry.name),
758
+ runId: runEntry.name,
759
+ sourceCwd: cwd,
760
+ runsDir: DEFAULT_RUNS_DIR,
761
+ status,
762
+ backend: tasks[0]?.backend ?? "unknown",
763
+ updatedMs,
764
+ startedAt:
765
+ tasks.map((task) => task.startedAt).sort()[0] ??
766
+ new Date(updatedMs).toISOString(),
767
+ completedAt: tasks.every((task) => task.completedAt !== null)
768
+ ? (tasks
769
+ .map((task) => task.completedAt)
770
+ .sort()
771
+ .at(-1) ?? null)
772
+ : null,
773
+ dependency: null,
774
+ eventTail,
775
+ ...(childSummary === undefined ? {} : { childSummary }),
776
+ tasks,
777
+ });
778
+ }
779
+ return { runs, stale: 0, invalid, skipped: 0 };
780
+ }
781
+
782
+ async function loadRunFromLocator(
783
+ locator: RunRefLocator,
784
+ options: Pick<LoadOptions, "scope" | "currentSessionId">,
785
+ ): Promise<{ row: RunRow | null; stale: boolean; invalid: boolean }> {
786
+ try {
787
+ const cwd = resolve(locator.cwd);
788
+ const runsDir = locator.runsDir ?? DEFAULT_RUNS_DIR;
789
+ const absoluteRunsDir = resolve(cwd, runsDir);
790
+ if (!isInsideOrEqual(cwd, absoluteRunsDir))
791
+ return { row: null, stale: false, invalid: true };
792
+ const runDir = join(absoluteRunsDir, locator.runId);
793
+ const runDirStat = await stat(runDir).catch(() => null);
794
+ if (runDirStat === null || !runDirStat.isDirectory())
795
+ return { row: null, stale: true, invalid: false };
796
+ const registry = await readJson(join(runDir, "run.json"));
797
+ if (!isRegistryRunRecord(registry))
798
+ return { row: null, stale: false, invalid: true };
799
+ if (options.scope === "session") {
800
+ if (
801
+ options.currentSessionId === undefined ||
802
+ registry.parentSessionId !== options.currentSessionId
803
+ )
804
+ return { row: null, stale: false, invalid: false };
805
+ }
806
+ const row = await readRunFromRegistry(
807
+ cwd,
808
+ runsDir,
809
+ runDir,
810
+ registry,
811
+ false,
812
+ options.currentSessionId,
813
+ );
814
+ return { row, stale: row === null, invalid: false };
815
+ } catch {
816
+ return { row: null, stale: false, invalid: true };
817
+ }
818
+ }
819
+
820
+ function statusMatches(run: RunRow, filter: StatusFilter): boolean {
821
+ if (filter === "all") return true;
822
+ if (filter === "running")
823
+ return run.status === "running" || run.status === "pending";
824
+ if (filter === "completed")
825
+ return run.status === "completed" && !runHasFailure(run);
826
+ return runHasFailure(run);
827
+ }
828
+
829
+ function recentTerminalLimit(
830
+ scope: ScopeFilter,
831
+ showMorePages: number,
832
+ ): number {
833
+ const base =
834
+ scope === "all"
835
+ ? ALL_SCOPE_RECENT_TERMINAL_LIMIT
836
+ : DEFAULT_RECENT_TERMINAL_LIMIT;
837
+ return base * Math.max(1, showMorePages + 1);
838
+ }
839
+
840
+ function compareRecentRuns(a: RunRow, b: RunRow): number {
841
+ return b.updatedMs - a.updatedMs || a.key.localeCompare(b.key);
842
+ }
843
+
844
+ function takeRecentRuns(runs: RunRow[], limit: number): RunRow[] {
845
+ if (runs.length <= limit) return runs;
846
+ const visibleKeys = new Set(
847
+ runs
848
+ .toSorted(compareRecentRuns)
849
+ .slice(0, limit)
850
+ .map((run) => run.key),
851
+ );
852
+ return runs.filter((run) => visibleKeys.has(run.key));
853
+ }
854
+
855
+ function limitRunsForPanel(
856
+ runs: RunRow[],
857
+ options: Pick<LoadOptions, "scope" | "statusFilter" | "showMorePages">,
858
+ ): { runs: RunRow[]; hiddenRuns: number } {
859
+ if (options.statusFilter === "running") return { runs, hiddenRuns: 0 };
860
+ const limit = recentTerminalLimit(options.scope, options.showMorePages);
861
+ if (options.statusFilter !== "all") {
862
+ const limited = takeRecentRuns(runs, limit);
863
+ return {
864
+ runs: limited,
865
+ hiddenRuns: Math.max(0, runs.length - limited.length),
866
+ };
867
+ }
868
+ const active = runs.filter((run) => isActive(run.status));
869
+ const terminal = runs.filter((run) => !isActive(run.status));
870
+ const limitedTerminal = takeRecentRuns(terminal, limit);
871
+ return {
872
+ runs: [...active, ...limitedTerminal],
873
+ hiddenRuns: Math.max(0, terminal.length - limitedTerminal.length),
874
+ };
875
+ }
876
+
877
+ async function loadRuns(options: LoadOptions): Promise<PanelSnapshot> {
878
+ const effectiveScope =
879
+ options.scope === "session" && options.currentSessionId === undefined
880
+ ? "cwd"
881
+ : options.scope;
882
+ const loaded = await loadRunsForScope({ ...options, scope: effectiveScope });
883
+ const unique = new Map<string, RunRow>();
884
+ for (const run of loaded.runs) unique.set(run.key, run);
885
+ const allRuns = [...unique.values()].sort(
886
+ (a, b) =>
887
+ statusPriority(a.status) - statusPriority(b.status) ||
888
+ b.updatedMs - a.updatedMs ||
889
+ a.key.localeCompare(b.key),
890
+ );
891
+ const filtered = allRuns.filter((run) =>
892
+ statusMatches(run, options.statusFilter),
893
+ );
894
+ const limited = limitRunsForPanel(filtered, options);
895
+ return {
896
+ runs: limited.runs,
897
+ totalRuns: filtered.length,
898
+ hiddenRuns: limited.hiddenRuns,
899
+ loadedAt: new Date(),
900
+ staleLocators: loaded.stale,
901
+ invalidLocators: loaded.invalid,
902
+ skippedLocators: loaded.skipped,
903
+ };
904
+ }
905
+
906
+ async function mergeLoadedRuns(
907
+ ...groups: Array<{
908
+ runs: RunRow[];
909
+ stale: number;
910
+ invalid: number;
911
+ skipped: number;
912
+ }>
913
+ ): Promise<{
914
+ runs: RunRow[];
915
+ stale: number;
916
+ invalid: number;
917
+ skipped: number;
918
+ }> {
919
+ return {
920
+ runs: groups.flatMap((group) => group.runs),
921
+ stale: groups.reduce((sum, group) => sum + group.stale, 0),
922
+ invalid: groups.reduce((sum, group) => sum + group.invalid, 0),
923
+ skipped: groups.reduce((sum, group) => sum + group.skipped, 0),
924
+ };
925
+ }
926
+
927
+ async function loadRunsForScope(options: LoadOptions): Promise<{
928
+ runs: RunRow[];
929
+ stale: number;
930
+ invalid: number;
931
+ skipped: number;
932
+ }> {
933
+ if (options.scope === "cwd") return loadRunsFromCwd(options.cwd, options);
934
+ if (options.scope === "session") {
935
+ const indexed = await loadRunsFromIndex(options);
936
+ if (options.currentSessionId === undefined) return indexed;
937
+ const local = await loadRunsFromCwd(options.cwd, {
938
+ currentSessionId: options.currentSessionId,
939
+ sessionOnly: options.currentSessionId,
940
+ });
941
+ return mergeLoadedRuns(indexed, local);
942
+ }
943
+ const indexed = await loadRunsFromIndex(options);
944
+ const local = await loadRunsFromCwd(options.cwd, options);
945
+ return mergeLoadedRuns(indexed, local);
946
+ }
947
+
948
+ async function loadRunsFromIndex(options: LoadOptions): Promise<{
949
+ runs: RunRow[];
950
+ stale: number;
951
+ invalid: number;
952
+ skipped: number;
953
+ }> {
954
+ const listed = await listRunLocators();
955
+ const runs: RunRow[] = [];
956
+ let stale = 0;
957
+ let invalid = listed.invalidCount;
958
+ for (const locator of listed.locators) {
959
+ const loaded = await loadRunFromLocator(locator, options);
960
+ if (loaded.row !== null) runs.push(loaded.row);
961
+ if (loaded.stale) stale += 1;
962
+ if (loaded.invalid) invalid += 1;
963
+ }
964
+ return { runs, stale, invalid, skipped: listed.skippedCount };
466
965
  }
467
966
 
468
967
  function splitLine(left: string, right: string, width: number): string {
469
- const gap = width - left.length - right.length;
470
- if (gap <= 1) return clip(`${left} ${right}`, width);
471
- return `${left}${" ".repeat(gap)}${right}`;
968
+ const gap = width - visibleLength(left) - visibleLength(right);
969
+ if (gap <= 1) return clip(`${left} ${right}`, width);
970
+ return `${left}${" ".repeat(gap)}${right}`;
472
971
  }
473
972
 
474
973
  function border(width: number): string {
475
- return "─".repeat(Math.max(1, width));
974
+ return "─".repeat(Math.max(1, width));
975
+ }
976
+
977
+ function panelLineBudget(): number {
978
+ const rows = process.stdout.rows;
979
+ if (typeof rows !== "number" || !Number.isFinite(rows) || rows <= 0)
980
+ return PANEL_MAX_LINES;
981
+ return Math.max(
982
+ PANEL_MIN_LINES,
983
+ Math.min(PANEL_MAX_LINES, rows - PANEL_RESERVED_TUI_LINES),
984
+ );
985
+ }
986
+
987
+ function currentSessionIdFromCtx(
988
+ ctx: ExtensionCommandContext,
989
+ ): string | undefined {
990
+ const raw = ctx as unknown as {
991
+ sessionManager?: { getSessionId?: () => unknown };
992
+ };
993
+ try {
994
+ const id = raw.sessionManager?.getSessionId?.();
995
+ return typeof id === "string" && id.length > 0 ? id : undefined;
996
+ } catch {
997
+ return undefined;
998
+ }
476
999
  }
477
1000
 
478
1001
  export class SubagentPanel implements Component {
479
- private snapshot: PanelSnapshot = { runs: [], totalRuns: 0, loadedAt: new Date() };
480
- private selectedRun = 0;
481
- private filter: Filter = "all";
482
- private timer: NodeJS.Timeout | undefined;
483
- private disposed = false;
484
- private loading = false;
485
-
486
- constructor(
487
- private readonly cwd: string,
488
- private readonly theme: PanelTheme,
489
- private readonly tui: PanelTui,
490
- private readonly done: () => void,
491
- ) {
492
- void this.refresh();
493
- this.timer = setInterval(() => void this.refresh(), LIVE_REFRESH_MS);
494
- }
495
-
496
- dispose(): void {
497
- this.disposed = true;
498
- if (this.timer !== undefined) clearInterval(this.timer);
499
- }
500
-
501
- invalidate(): void {
502
- // Stateless render; refresh loop owns data invalidation.
503
- }
504
-
505
- handleInput(data: string): void {
506
- if (data === "q" || isEscapeKey(data)) {
507
- this.dispose();
508
- this.done();
509
- return;
510
- }
511
- if (data === "r") {
512
- void this.refresh();
513
- return;
514
- }
515
- if (isTabKey(data) || isArrowKey(data, "right") || data === "l") {
516
- void this.cycleFilter(1);
517
- return;
518
- }
519
- if (data === "shift+tab" || data === "\u001b[Z" || isArrowKey(data, "left") || data === "h") {
520
- void this.cycleFilter(-1);
521
- return;
522
- }
523
- if (isEnterKey(data)) return;
524
- if (isArrowKey(data, "up") || data === "k") this.moveRun(-1);
525
- if (isArrowKey(data, "down") || data === "j") this.moveRun(1);
526
- }
527
-
528
- render(width: number): string[] {
529
- const safeWidth = Math.max(48, width);
530
- const lines: string[] = [];
531
- const active = this.snapshot.runs.filter((run) => isActive(run.status)).length;
532
- const failed = this.snapshot.runs.filter((run) => run.status === "failed" || run.status === "cancelled").length;
533
- const title = `${style(this.theme, "accent", "●")} ${bold(this.theme, "Subagents")}`;
534
- const status = `live · ${active} active · ${failed} failed · ${this.snapshot.runs.length}/${this.snapshot.totalRuns} shown · updated ${fmtAge(this.snapshot.loadedAt.getTime())}`;
535
- lines.push(splitLine(title, style(this.theme, "muted", status), safeWidth));
536
- lines.push(style(this.theme, "border", border(safeWidth)));
537
- lines.push(this.renderTabs(safeWidth));
538
- lines.push(style(this.theme, "border", border(safeWidth)));
539
-
540
- if (this.snapshot.runs.length === 0) {
541
- lines.push(style(this.theme, "muted", clip(`No subagent runs found under ${DEFAULT_RUNS_DIR}`, safeWidth)));
542
- lines.push(style(this.theme, "border", border(safeWidth)));
543
- lines.push(style(this.theme, "dim", "↑↓/j/k select run · tab/←→ filter · r refresh · q/esc close"));
544
- return lines.map((line) => clip(line, safeWidth));
545
- }
546
-
547
- let leftWidth = Math.max(30, Math.min(56, Math.floor(safeWidth * 0.34)));
548
- if (safeWidth - leftWidth - 3 < 30) leftWidth = Math.max(18, safeWidth - 33);
549
- const rightWidth = safeWidth - leftWidth - 3;
550
- const selectedRun = this.snapshot.runs[Math.min(this.selectedRun, this.snapshot.runs.length - 1)];
551
- const selectedTask = selectedRun.tasks[0];
552
- const runLines = this.renderRuns(leftWidth);
553
- const detailLines = this.renderDetail(selectedRun, selectedTask, rightWidth);
554
- const bodyLines = Math.max(runLines.length, detailLines.length);
555
- for (let index = 0; index < bodyLines; index += 1) {
556
- lines.push(`${pad(runLines[index] ?? "", leftWidth)} ${style(this.theme, "border", "")} ${pad(detailLines[index] ?? "", rightWidth)}`);
557
- }
558
- lines.push(style(this.theme, "border", border(safeWidth)));
559
- lines.push(style(this.theme, "dim", "↑↓/j/k select run · tab/←→ filter · r refresh · q/esc close"));
560
- return lines.map((line) => clip(line, safeWidth));
561
- }
562
-
563
- private async refresh(): Promise<void> {
564
- if (this.loading || this.disposed) return;
565
- this.loading = true;
566
- try {
567
- const snapshot = await loadRuns(this.cwd, this.filter);
568
- this.snapshot = snapshot;
569
- this.selectedRun = Math.min(this.selectedRun, Math.max(0, snapshot.runs.length - 1));
570
- this.tui.requestRender?.();
571
- } finally {
572
- this.loading = false;
573
- }
574
- }
575
-
576
- private async cycleFilter(delta: number): Promise<void> {
577
- const filters: Filter[] = ["all", "completed", "failed"];
578
- const current = filters.indexOf(this.filter);
579
- this.filter = filters[(current + delta + filters.length) % filters.length] ?? "all";
580
- this.selectedRun = 0;
581
- await this.refresh();
582
- }
583
-
584
- private moveRun(delta: number): void {
585
- this.selectedRun = Math.max(0, Math.min(this.snapshot.runs.length - 1, this.selectedRun + delta));
586
- this.tui.requestRender?.();
587
- }
588
-
589
-
590
- private renderTabs(width: number): string {
591
- const tabs: Array<[Filter, string]> = [
592
- ["all", "all"],
593
- ["completed", "completed"],
594
- ["failed", "failed"],
595
- ];
596
- return clip(
597
- tabs
598
- .map(([filter, label]) => (filter === this.filter ? style(this.theme, "accent", `[${label}]`) : style(this.theme, "dim", label)))
599
- .join(" "),
600
- width,
601
- );
602
- }
603
-
604
- private renderRuns(width: number): string[] {
605
- const maxVisible = 18;
606
- const maxStart = Math.max(0, this.snapshot.runs.length - maxVisible);
607
- const windowStart = Math.min(maxStart, Math.max(0, this.selectedRun - maxVisible + 1));
608
- return this.snapshot.runs.slice(windowStart, windowStart + maxVisible).map((run, index) => {
609
- const runIndex = windowStart + index;
610
- const marker = runIndex === this.selectedRun ? style(this.theme, "accent", "▸") : " ";
611
- const status = statusLabel(run.status);
612
- const elapsed = fmtElapsed(run.startedAt, run.completedAt);
613
- const age = fmtAge(run.updatedMs);
614
- const meta = `${elapsed} · ${age}`;
615
- const statusWidth = Math.max(4, Math.min(9, status.length));
616
- const idWidth = Math.max(6, width - 2 - statusWidth - 1 - meta.length - 1);
617
- const line = `${marker} ${pad(clip(run.runId, idWidth), idWidth)} ${style(this.theme, statusColor(run.status), pad(status, statusWidth))} ${style(this.theme, "muted", meta)}`;
618
- return clip(line, width);
619
- });
620
- }
621
-
622
- private renderDetail(run: RunRow, task: TaskRow, width: number): string[] {
623
- const lines: string[] = [];
624
- const labelWidth = Math.max(8, Math.min(12, Math.floor(width * 0.18)));
625
- const divider = (): void => {
626
- lines.push(style(this.theme, "border", "─".repeat(Math.max(1, width))));
627
- };
628
- const section = (title: string): void => {
629
- if (lines.length > 0) divider();
630
- lines.push(style(this.theme, "accent", title));
631
- };
632
- const field = (name: string, value: string | null | undefined, color = "muted"): void => {
633
- const rendered = value && value.length > 0 ? value : "—";
634
- const label = style(this.theme, "dim", pad(clip(name, labelWidth), labelWidth));
635
- lines.push(`${label} ${style(this.theme, color, clip(rendered, Math.max(1, width - labelWidth - 1)))}`);
636
- };
637
-
638
- section("RUN");
639
- field("Run ID", run.runId, "text");
640
- field("Status", statusLabel(run.status), statusColor(run.status));
641
- field("Dependency", run.dependency ?? "—");
642
- field("Elapsed", fmtElapsed(run.startedAt, run.completedAt));
643
- field("Updated", fmtAge(run.updatedMs));
644
-
645
- section("WORKSPACE");
646
- field("Path", safeRelative(this.cwd, task.workspace));
647
- field("Worktree", task.worktreePath === null ? "—" : safeRelative(this.cwd, task.worktreePath));
648
-
649
- section("ATTEMPTS");
650
- for (const candidate of run.tasks) {
651
- field("Attempt", `${candidate.attemptId} · ${statusLabel(candidate.status)} · ${fmtElapsed(candidate.startedAt, candidate.completedAt)}${candidate.modelLabel ? ` · ${candidate.modelLabel}` : ""}`, statusColor(candidate.status));
652
- field("Result", candidate.resultPath);
653
- field("Log", candidate.logPath ?? "—");
654
- field("Started", candidate.startedAt);
655
- field("Completed", candidate.completedAt ?? "running");
656
- if (candidate.failureKind !== null) field("Failure", candidate.failureKind, "error");
657
- }
658
-
659
- section(`LOG TAIL (${task.attemptId})`);
660
- field("Source", task.logPath ?? task.resultPath);
661
- const tail = task.logTail.length > 0 ? task.logTail : ["No log output yet."];
662
- for (const logLine of tail) lines.push(`${style(this.theme, "dim", "›")} ${clip(logLine, Math.max(1, width - 2))}`);
663
-
664
- if (run.eventTail.length > 0) {
665
- section("EVENTS");
666
- for (const eventLine of run.eventTail) lines.push(`${style(this.theme, "dim", "›")} ${clip(eventLine, Math.max(1, width - 2))}`);
667
- }
668
- return lines;
669
- }
670
- }
671
-
672
- export async function showSubagentPanel(ctx: ExtensionCommandContext): Promise<void> {
673
- if (ctx.mode !== "tui" || !ctx.hasUI) {
674
- ctx.ui.notify?.("/subagent panel is available only in the interactive TUI.", "warning");
675
- return;
676
- }
677
- await ctx.ui.custom<void>((tui: PanelTui, theme: PanelTheme, _keybindings: unknown, done: () => void) => new SubagentPanel(ctx.cwd, theme, tui, done));
1002
+ private snapshot: PanelSnapshot = {
1003
+ runs: [],
1004
+ totalRuns: 0,
1005
+ loadedAt: new Date(),
1006
+ hiddenRuns: 0,
1007
+ staleLocators: 0,
1008
+ invalidLocators: 0,
1009
+ skippedLocators: 0,
1010
+ };
1011
+ private selectedRun = 0;
1012
+ private showMorePages = 0;
1013
+ private scope: ScopeFilter;
1014
+ private statusFilter: StatusFilter = "all";
1015
+ private focus: FocusGroup = "scope";
1016
+ private detailOffset = 0;
1017
+ private timer: NodeJS.Timeout | undefined;
1018
+ private disposed = false;
1019
+ private loading = false;
1020
+
1021
+ constructor(
1022
+ private readonly cwd: string,
1023
+ private readonly theme: PanelTheme,
1024
+ private readonly tui: PanelTui,
1025
+ private readonly done: () => void,
1026
+ private readonly currentSessionId?: string,
1027
+ ) {
1028
+ this.scope = currentSessionId === undefined ? "cwd" : "session";
1029
+ void this.refresh({ preserveSelection: false });
1030
+ this.timer = setInterval(() => void this.refresh(), LIVE_REFRESH_MS);
1031
+ }
1032
+
1033
+ dispose(): void {
1034
+ this.disposed = true;
1035
+ if (this.timer !== undefined) clearInterval(this.timer);
1036
+ }
1037
+
1038
+ invalidate(): void {
1039
+ // Stateless render; refresh loop owns data invalidation.
1040
+ }
1041
+
1042
+ handleInput(data: string): void {
1043
+ if (data === "q" || isEscapeKey(data)) {
1044
+ this.dispose();
1045
+ this.done();
1046
+ return;
1047
+ }
1048
+ if (data === "r") {
1049
+ void this.refresh();
1050
+ return;
1051
+ }
1052
+ if (data === "m") {
1053
+ if (this.snapshot.hiddenRuns > 0) {
1054
+ this.showMorePages += 1;
1055
+ void this.refresh();
1056
+ }
1057
+ return;
1058
+ }
1059
+ if (isTabKey(data) || data === "shift+tab" || data === "\u001b[Z") {
1060
+ const groups: FocusGroup[] = ["scope", "status", "detail"];
1061
+ const direction = data === "shift+tab" || data === "\u001b[Z" ? -1 : 1;
1062
+ const current = groups.indexOf(this.focus);
1063
+ this.focus =
1064
+ groups[(current + direction + groups.length) % groups.length] ??
1065
+ "scope";
1066
+ this.tui.requestRender?.();
1067
+ return;
1068
+ }
1069
+ if (isArrowKey(data, "right") || data === "l") {
1070
+ void this.cycleFocused(1);
1071
+ return;
1072
+ }
1073
+ if (isArrowKey(data, "left") || data === "h") {
1074
+ void this.cycleFocused(-1);
1075
+ return;
1076
+ }
1077
+ if (isEnterKey(data)) return;
1078
+ if (this.focus === "detail") {
1079
+ if (isArrowKey(data, "up") || data === "k") this.scrollDetail(-1);
1080
+ if (isArrowKey(data, "down") || data === "j") this.scrollDetail(1);
1081
+ if (isPageKey(data, "up")) this.scrollDetail(-8);
1082
+ if (isPageKey(data, "down")) this.scrollDetail(8);
1083
+ return;
1084
+ }
1085
+ if (isArrowKey(data, "up") || data === "k") this.moveRun(-1);
1086
+ if (isArrowKey(data, "down") || data === "j") this.moveRun(1);
1087
+ }
1088
+
1089
+ render(width: number): string[] {
1090
+ const safeWidth = Math.max(48, width);
1091
+ const maxLines = panelLineBudget();
1092
+ const lines: string[] = [];
1093
+ const active = this.snapshot.runs.filter((run) =>
1094
+ isActive(run.status),
1095
+ ).length;
1096
+ const failed = this.snapshot.runs.filter((run) =>
1097
+ runHasFailure(run),
1098
+ ).length;
1099
+ const title = `${style(this.theme, "accent", "●")} ${bold(this.theme, "Subagents")}`;
1100
+ const stale = this.snapshot.staleLocators + this.snapshot.invalidLocators;
1101
+ const staleText =
1102
+ stale > 0 || this.snapshot.skippedLocators > 0
1103
+ ? ` · stale ${this.snapshot.staleLocators} · skipped ${this.snapshot.invalidLocators + this.snapshot.skippedLocators}`
1104
+ : "";
1105
+ const status = `live · ${active} active · ${failed} failed · ${this.snapshot.runs.length}/${this.snapshot.totalRuns} shown${staleText} · updated ${fmtAge(this.snapshot.loadedAt.getTime())}`;
1106
+ lines.push(splitLine(title, style(this.theme, "muted", status), safeWidth));
1107
+ lines.push(style(this.theme, "border", border(safeWidth)));
1108
+ lines.push(this.renderControls(safeWidth));
1109
+ lines.push(this.renderScopeHelp(safeWidth));
1110
+ lines.push(style(this.theme, "border", border(safeWidth)));
1111
+
1112
+ if (this.snapshot.runs.length === 0) {
1113
+ const bodyHeight = Math.max(1, maxLines - lines.length - 2);
1114
+ lines.push(
1115
+ style(this.theme, "muted", clip(this.emptyMessage(), safeWidth)),
1116
+ );
1117
+ for (let index = 1; index < bodyHeight; index += 1) lines.push("");
1118
+ lines.push(style(this.theme, "border", border(safeWidth)));
1119
+ lines.push(style(this.theme, "dim", this.footerHelp(false)));
1120
+ return lines.slice(0, maxLines).map((line) => clip(line, safeWidth));
1121
+ }
1122
+
1123
+ let leftWidth = Math.max(30, Math.min(64, Math.floor(safeWidth * 0.42)));
1124
+ if (safeWidth - leftWidth - 3 < 30)
1125
+ leftWidth = Math.max(18, safeWidth - 33);
1126
+ const rightWidth = safeWidth - leftWidth - 3;
1127
+ const selectedRun =
1128
+ this.snapshot.runs[
1129
+ Math.min(this.selectedRun, this.snapshot.runs.length - 1)
1130
+ ];
1131
+ const selectedTask = selectedRun.tasks[0];
1132
+ const bodyHeight = Math.max(1, maxLines - lines.length - 2);
1133
+ const runLines = this.renderRuns(leftWidth, bodyHeight);
1134
+ const detailLines = this.renderDetailWindow(
1135
+ this.renderDetail(selectedRun, selectedTask, rightWidth),
1136
+ rightWidth,
1137
+ bodyHeight,
1138
+ );
1139
+ const bodyLines = bodyHeight;
1140
+ for (let index = 0; index < bodyLines; index += 1) {
1141
+ lines.push(
1142
+ `${pad(runLines[index] ?? "", leftWidth)} ${style(this.theme, "border", "│")} ${pad(detailLines[index] ?? "", rightWidth)}`,
1143
+ );
1144
+ }
1145
+ lines.push(style(this.theme, "border", border(safeWidth)));
1146
+ lines.push(style(this.theme, "dim", this.footerHelp(true)));
1147
+ return lines.slice(0, maxLines).map((line) => clip(line, safeWidth));
1148
+ }
1149
+
1150
+ private emptyMessage(): string {
1151
+ if (this.scope === "session" && this.currentSessionId === undefined)
1152
+ return `No current session id; showing current cwd ${DEFAULT_RUNS_DIR}`;
1153
+ if (this.scope === "session")
1154
+ return "No subagent runs found for this session";
1155
+ if (this.scope === "all")
1156
+ return "No indexed or current-workspace subagent runs found";
1157
+ return `No subagent runs found under ${DEFAULT_RUNS_DIR}`;
1158
+ }
1159
+
1160
+ private async refresh(
1161
+ options: { preserveSelection?: boolean } = {},
1162
+ ): Promise<void> {
1163
+ if (this.loading || this.disposed) return;
1164
+ this.loading = true;
1165
+ try {
1166
+ const previousKey =
1167
+ options.preserveSelection === false
1168
+ ? undefined
1169
+ : this.snapshot.runs[this.selectedRun]?.key;
1170
+ const snapshot = await loadRuns({
1171
+ cwd: this.cwd,
1172
+ scope: this.scope,
1173
+ statusFilter: this.statusFilter,
1174
+ currentSessionId: this.currentSessionId,
1175
+ showMorePages: this.showMorePages,
1176
+ });
1177
+ this.snapshot = snapshot;
1178
+ const oldSelectedRun = this.selectedRun;
1179
+ const nextIndex =
1180
+ previousKey === undefined
1181
+ ? -1
1182
+ : snapshot.runs.findIndex((run) => run.key === previousKey);
1183
+ this.selectedRun =
1184
+ nextIndex >= 0
1185
+ ? nextIndex
1186
+ : Math.min(this.selectedRun, Math.max(0, snapshot.runs.length - 1));
1187
+ if (this.selectedRun !== oldSelectedRun) this.detailOffset = 0;
1188
+ this.tui.requestRender?.();
1189
+ } finally {
1190
+ this.loading = false;
1191
+ }
1192
+ }
1193
+
1194
+ private async cycleFocused(delta: number): Promise<void> {
1195
+ if (this.focus === "scope") {
1196
+ const scopes: ScopeFilter[] = ["session", "cwd", "all"];
1197
+ const current = scopes.indexOf(this.scope);
1198
+ this.scope =
1199
+ scopes[(current + delta + scopes.length) % scopes.length] ?? "cwd";
1200
+ this.detailOffset = 0;
1201
+ } else if (this.focus === "status") {
1202
+ const filters: StatusFilter[] = ["all", "running", "completed", "failed"];
1203
+ const current = filters.indexOf(this.statusFilter);
1204
+ this.statusFilter =
1205
+ filters[(current + delta + filters.length) % filters.length] ?? "all";
1206
+ this.detailOffset = 0;
1207
+ } else {
1208
+ this.scrollDetail(delta > 0 ? 1 : -1);
1209
+ return;
1210
+ }
1211
+ this.selectedRun = 0;
1212
+ this.showMorePages = 0;
1213
+ await this.refresh({ preserveSelection: false });
1214
+ }
1215
+
1216
+ private footerHelp(withDetailKeys: boolean): string {
1217
+ const detail = withDetailKeys ? " · PgUp/PgDn detail" : "";
1218
+ const more = this.snapshot.hiddenRuns > 0 ? " · m show more" : "";
1219
+ return `tab focus scope/status/detail · ←→ change · ↑↓/j/k select/scroll${detail} · r refresh${more} · q/esc close`;
1220
+ }
1221
+
1222
+ private moveRun(delta: number): void {
1223
+ const runCount = this.snapshot.runs.length;
1224
+ if (runCount === 0) return;
1225
+ const current = Math.max(0, Math.min(runCount - 1, this.selectedRun));
1226
+ const next = (current + delta + runCount) % runCount;
1227
+ if (next !== this.selectedRun) this.detailOffset = 0;
1228
+ this.selectedRun = next;
1229
+ this.tui.requestRender?.();
1230
+ }
1231
+
1232
+ private scrollDetail(delta: number): void {
1233
+ this.detailOffset = Math.max(0, this.detailOffset + delta);
1234
+ this.tui.requestRender?.();
1235
+ }
1236
+
1237
+ private renderControls(width: number): string {
1238
+ const focused = (group: FocusGroup, text: string): string =>
1239
+ this.focus === group
1240
+ ? style(this.theme, "accent", text)
1241
+ : style(this.theme, "dim", text);
1242
+ const scopeTabs = this.renderTabSet(
1243
+ [
1244
+ ["session", "session"],
1245
+ ["cwd", "cwd"],
1246
+ ["all", "all"],
1247
+ ],
1248
+ this.scope,
1249
+ );
1250
+ const statusTabs = this.renderTabSet(
1251
+ [
1252
+ ["all", "all"],
1253
+ ["running", "running"],
1254
+ ["completed", "completed"],
1255
+ ["failed", "failed"],
1256
+ ],
1257
+ this.statusFilter,
1258
+ );
1259
+ return clip(
1260
+ `${focused("scope", "scope:")} ${scopeTabs} ${focused("status", "status:")} ${statusTabs} ${focused("detail", "detail:")} scroll`,
1261
+ width,
1262
+ );
1263
+ }
1264
+
1265
+ private renderTabSet<T extends string>(
1266
+ tabs: Array<[T, string]>,
1267
+ current: T,
1268
+ ): string {
1269
+ return tabs
1270
+ .map(([value, label]) =>
1271
+ value === current
1272
+ ? style(this.theme, "accent", `[${label}]`)
1273
+ : style(this.theme, "dim", label),
1274
+ )
1275
+ .join(" ");
1276
+ }
1277
+
1278
+ private renderScopeHelp(width: number): string {
1279
+ return clip(
1280
+ style(
1281
+ this.theme,
1282
+ "dim",
1283
+ "session: this conversation · cwd: this workspace · all: global index + cwd legacy",
1284
+ ),
1285
+ width,
1286
+ );
1287
+ }
1288
+
1289
+ private renderRuns(width: number, maxVisible: number): string[] {
1290
+ const maxStart = Math.max(0, this.snapshot.runs.length - maxVisible);
1291
+ const windowStart = Math.min(
1292
+ maxStart,
1293
+ Math.max(0, this.selectedRun - maxVisible + 1),
1294
+ );
1295
+ const showCwd = this.scope !== "cwd" && width >= 46;
1296
+ return this.snapshot.runs
1297
+ .slice(windowStart, windowStart + maxVisible)
1298
+ .map((run, index) => {
1299
+ const runIndex = windowStart + index;
1300
+ const marker =
1301
+ runIndex === this.selectedRun
1302
+ ? style(this.theme, "accent", "▸")
1303
+ : " ";
1304
+ const status = runStatusLabel(run);
1305
+ const age = fmtAge(run.updatedMs);
1306
+ const cwdLabel = showCwd
1307
+ ? ` · ${basename(run.sourceCwd) || run.sourceCwd}`
1308
+ : "";
1309
+ const fullMeta = `${age}${cwdLabel}`;
1310
+ const statusWidth = Math.max(4, Math.min(13, status.length));
1311
+ const fullIdWidth = visibleLength(run.runId);
1312
+ let metaWidth = visibleLength(fullMeta);
1313
+ let idWidth = width - statusWidth - metaWidth - 4;
1314
+ if (showCwd && idWidth < fullIdWidth) {
1315
+ metaWidth = Math.max(
1316
+ visibleLength(age),
1317
+ width - statusWidth - fullIdWidth - 4,
1318
+ );
1319
+ idWidth = width - statusWidth - metaWidth - 4;
1320
+ }
1321
+ idWidth = Math.max(6, idWidth);
1322
+ const meta = clip(fullMeta, metaWidth);
1323
+ const line = `${marker} ${pad(clip(run.runId, idWidth), idWidth)} ${style(this.theme, runStatusColor(run), pad(status, statusWidth))} ${style(this.theme, "muted", meta)}`;
1324
+ return clip(line, width);
1325
+ });
1326
+ }
1327
+
1328
+ private renderDetailWindow(
1329
+ detailLines: string[],
1330
+ width: number,
1331
+ height: number,
1332
+ ): string[] {
1333
+ if (detailLines.length <= height) {
1334
+ this.detailOffset = 0;
1335
+ return detailLines;
1336
+ }
1337
+ const hintHeight = 1;
1338
+ const contentHeight = Math.max(1, height - hintHeight);
1339
+ const maxOffset = Math.max(0, detailLines.length - contentHeight);
1340
+ this.detailOffset = Math.min(this.detailOffset, maxOffset);
1341
+ const end = Math.min(detailLines.length, this.detailOffset + contentHeight);
1342
+ const hint = style(
1343
+ this.theme,
1344
+ this.focus === "detail" ? "accent" : "dim",
1345
+ `detail ${this.detailOffset + 1}-${end}/${detailLines.length} · ${this.focus === "detail" ? "↑↓/Pg scroll" : "tab to detail"}`,
1346
+ );
1347
+ return [...detailLines.slice(this.detailOffset, end), clip(hint, width)];
1348
+ }
1349
+
1350
+ private renderDetail(run: RunRow, task: TaskRow, width: number): string[] {
1351
+ const lines: string[] = [];
1352
+ const labelWidth = Math.max(8, Math.min(12, Math.floor(width * 0.18)));
1353
+ const divider = (): void => {
1354
+ lines.push(style(this.theme, "border", "─".repeat(Math.max(1, width))));
1355
+ };
1356
+ const section = (title: string): void => {
1357
+ if (lines.length > 0) divider();
1358
+ lines.push(style(this.theme, "accent", title));
1359
+ };
1360
+ const field = (
1361
+ name: string,
1362
+ value: string | null | undefined,
1363
+ color = "muted",
1364
+ ): void => {
1365
+ const rendered =
1366
+ value && value.length > 0
1367
+ ? sanitizeRunText(value, this.currentSessionId)
1368
+ : "—";
1369
+ const label = style(
1370
+ this.theme,
1371
+ "dim",
1372
+ pad(clip(name, labelWidth), labelWidth),
1373
+ );
1374
+ lines.push(
1375
+ `${label} ${style(this.theme, color, clip(rendered, Math.max(1, width - labelWidth - 1)))}`,
1376
+ );
1377
+ };
1378
+
1379
+ section("RUN");
1380
+ field("Run ID", run.runId, "text");
1381
+ field("Status", runStatusDetail(run), runStatusColor(run));
1382
+ field("Elapsed", fmtElapsed(run.startedAt, run.completedAt));
1383
+ field("Updated", fmtAge(run.updatedMs));
1384
+
1385
+ if (run.childSummary !== undefined) {
1386
+ const latest = run.childSummary.latestFailure;
1387
+ field(
1388
+ "Children",
1389
+ latest === null
1390
+ ? `total ${run.childSummary.total} · running ${run.childSummary.running} · failed ${run.childSummary.failed}`
1391
+ : `total ${run.childSummary.total} · failed ${run.childSummary.failed} · latest ${latest.childRunId}${latest.taskId ? `/${latest.taskId}` : ""}${latest.failureKind ? ` · ${latest.failureKind}` : ""}`,
1392
+ childFailureCount(run.childSummary) > 0 ? "error" : "muted",
1393
+ );
1394
+ }
1395
+
1396
+ section("ATTEMPT");
1397
+ field(
1398
+ "All",
1399
+ run.tasks
1400
+ .map(
1401
+ (candidate) =>
1402
+ `${candidate.attemptId}:${statusLabel(candidate.status)}`,
1403
+ )
1404
+ .join(" · "),
1405
+ );
1406
+ field(
1407
+ "Selected",
1408
+ `${run.tasks.indexOf(task) + 1}/${run.tasks.length} · ${task.attemptId} · ${statusLabel(task.status)} · ${fmtElapsed(task.startedAt, task.completedAt)}${task.modelLabel ? ` · ${task.modelLabel}` : ""}`,
1409
+ statusColor(task.status),
1410
+ );
1411
+ field("Started", task.startedAt);
1412
+ field("Completed", task.completedAt ?? "running");
1413
+ if (task.failureKind !== null) field("Failure", task.failureKind, "error");
1414
+
1415
+ section(`LOG TAIL (${task.attemptId})`);
1416
+ field("Source", task.logPath ?? task.resultPath);
1417
+ const tail =
1418
+ task.logTail.length > 0
1419
+ ? task.logTail
1420
+ : ["No log output loaded for this scope yet."];
1421
+ for (const logLine of tail)
1422
+ lines.push(
1423
+ `${style(this.theme, "dim", "›")} ${clip(sanitizeRunText(logLine, this.currentSessionId), Math.max(1, width - 2))}`,
1424
+ );
1425
+
1426
+ field("Result", task.resultPath);
1427
+ field("Log", task.logPath ?? "—");
1428
+
1429
+ section("WORKSPACE");
1430
+ field("Registry", safeRelative(this.cwd, run.sourceCwd));
1431
+ field("RunsDir", run.runsDir);
1432
+ field("Attempt", safeRelative(this.cwd, task.workspace));
1433
+ field(
1434
+ "Worktree",
1435
+ task.worktreePath === null
1436
+ ? "—"
1437
+ : safeRelative(this.cwd, task.worktreePath),
1438
+ );
1439
+
1440
+ if (run.eventTail.length > 0) {
1441
+ section("EVENTS");
1442
+ for (const eventLine of run.eventTail)
1443
+ lines.push(
1444
+ `${style(this.theme, "dim", "›")} ${clip(sanitizeRunText(eventLine, this.currentSessionId), Math.max(1, width - 2))}`,
1445
+ );
1446
+ }
1447
+ return lines;
1448
+ }
1449
+ }
1450
+
1451
+ export async function showSubagentPanel(
1452
+ ctx: ExtensionCommandContext,
1453
+ ): Promise<void> {
1454
+ if (ctx.mode !== "tui" || !ctx.hasUI) {
1455
+ ctx.ui.notify?.(
1456
+ "/subagent panel is available only in the interactive TUI.",
1457
+ "warning",
1458
+ );
1459
+ return;
1460
+ }
1461
+ const currentSessionId = currentSessionIdFromCtx(ctx);
1462
+ await ctx.ui.custom<void>(
1463
+ (
1464
+ tui: PanelTui,
1465
+ theme: PanelTheme,
1466
+ _keybindings: unknown,
1467
+ done: () => void,
1468
+ ) => new SubagentPanel(ctx.cwd, theme, tui, done, currentSessionId),
1469
+ );
678
1470
  }