@zhuxixi/pi-agent-board 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 (65) hide show
  1. package/IMPLEMENTATION_PLAN.md +920 -0
  2. package/LICENSE +21 -0
  3. package/PRD.md +484 -0
  4. package/PROGRESS.md +127 -0
  5. package/README.md +131 -0
  6. package/VERIFY.md +113 -0
  7. package/docs/BATCH_SELECTION_READ_FLOW.md +277 -0
  8. package/docs/EXPLORATION.md +187 -0
  9. package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +579 -0
  10. package/docs/superpowers/plans/2026-08-15-screenlog-gc.md +704 -0
  11. package/docs/superpowers/plans/2026-08-16-attach-double-cursor-jiggle-retry.md +499 -0
  12. package/docs/superpowers/plans/2026-08-21-dashboard-keypress-lag.md +366 -0
  13. package/docs/superpowers/specs/2026-08-15-screenlog-gc-design.md +105 -0
  14. package/docs/superpowers/specs/2026-08-16-attach-double-cursor-jiggle-retry-design.md +142 -0
  15. package/docs/superpowers/specs/2026-08-21-dashboard-keypress-lag-design.md +59 -0
  16. package/index.ts +6 -0
  17. package/package.json +81 -0
  18. package/runner/job-runner.mjs +420 -0
  19. package/runner/pty-runner.mjs +310 -0
  20. package/runner/state-runner.mjs +120 -0
  21. package/runner/title-runner.mjs +80 -0
  22. package/scripts/patch-vulns.mjs +59 -0
  23. package/src/commands/agent-board.ts +318 -0
  24. package/src/commands/attach-flow.ts +231 -0
  25. package/src/commands/bg.ts +70 -0
  26. package/src/core/atomic.mjs +145 -0
  27. package/src/core/auto-state.mjs +320 -0
  28. package/src/core/dashboard-render.mjs +10 -0
  29. package/src/core/derive.mjs +114 -0
  30. package/src/core/diagnostics.mjs +109 -0
  31. package/src/core/events.mjs +268 -0
  32. package/src/core/evidence.mjs +242 -0
  33. package/src/core/follow-up-queue.mjs +193 -0
  34. package/src/core/heuristics.mjs +240 -0
  35. package/src/core/ids.mjs +35 -0
  36. package/src/core/invocation.mjs +43 -0
  37. package/src/core/launch-options.mjs +317 -0
  38. package/src/core/launch.mjs +116 -0
  39. package/src/core/locks.mjs +80 -0
  40. package/src/core/paths.mjs +86 -0
  41. package/src/core/pid.mjs +42 -0
  42. package/src/core/prewarm-schedule.mjs +41 -0
  43. package/src/core/prompt-transport.mjs +13 -0
  44. package/src/core/pty-attach-jiggle-retry.mjs +90 -0
  45. package/src/core/pty-attach-render.mjs +51 -0
  46. package/src/core/pty-input.mjs +15 -0
  47. package/src/core/pty-links.mjs +71 -0
  48. package/src/core/pty-scroll.mjs +155 -0
  49. package/src/core/pty-support.mjs +327 -0
  50. package/src/core/repo.mjs +47 -0
  51. package/src/core/rows.mjs +290 -0
  52. package/src/core/screen-log-gc.mjs +198 -0
  53. package/src/core/screen-log.mjs +160 -0
  54. package/src/core/session-view.mjs +174 -0
  55. package/src/core/steering-prompts.mjs +34 -0
  56. package/src/core/steering.mjs +133 -0
  57. package/src/core/store.mjs +308 -0
  58. package/src/core/title.mjs +43 -0
  59. package/src/core/types.mjs +380 -0
  60. package/src/core/worktree.mjs +64 -0
  61. package/src/index.ts +109 -0
  62. package/src/runtime/service.mjs +1194 -0
  63. package/src/ui/dashboard-evidence.mjs +85 -0
  64. package/src/ui/dashboard.ts +1952 -0
  65. package/src/ui/pty-attach.ts +1378 -0
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Shared node-pty support helpers:
3
+ * - best-effort self-heal for macOS spawn-helper permissions
4
+ * - user-facing diagnosis for common PTY startup failures
5
+ */
6
+ import { spawnSync } from "node:child_process";
7
+ import { chmodSync, existsSync, statSync } from "node:fs";
8
+
9
+ /** @param {import("node:module").Require} requireForPty */
10
+ export function resolveNodePtyPackageRoot(requireForPty) {
11
+ try {
12
+ const pkg = requireForPty.resolve("node-pty/package.json");
13
+ return pkg.slice(0, -"package.json".length);
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * @param {import("node:module").Require} requireForPty
21
+ * @param {string} [platform]
22
+ * @param {string} [arch]
23
+ */
24
+ export function nodePtySpawnHelperPaths(requireForPty, platform = process.platform, arch = process.arch) {
25
+ const root = resolveNodePtyPackageRoot(requireForPty);
26
+ if (!root) return [];
27
+ return [`${root}prebuilds/${platform}-${arch}/spawn-helper`, `${root}build/Release/spawn-helper`];
28
+ }
29
+
30
+ /**
31
+ * Best-effort chmod for node-pty's spawn-helper. This specifically heals the macOS/npm
32
+ * packaging issue where the helper lands as 0644 instead of 0755.
33
+ *
34
+ * Important: this must never create a false PTY failure for an already-working install.
35
+ * If the helper already has an execute bit, leave it alone. If chmod itself fails because
36
+ * the install directory is read-only, swallow that and let the real node-pty probe decide.
37
+ * @param {import("node:module").Require} requireForPty
38
+ * @param {string} [platform]
39
+ * @param {string} [arch]
40
+ * @returns {string[]} touched helper paths
41
+ */
42
+ export function ensureNodePtySpawnHelperExecutable(requireForPty, platform = process.platform, arch = process.arch) {
43
+ const touched = [];
44
+ for (const helper of nodePtySpawnHelperPaths(requireForPty, platform, arch)) {
45
+ if (!existsSync(helper)) continue;
46
+ try {
47
+ if (Boolean(statSync(helper).mode & 0o111)) continue;
48
+ } catch {
49
+ continue;
50
+ }
51
+ try {
52
+ chmodSync(helper, 0o755);
53
+ touched.push(helper);
54
+ } catch {
55
+ /* best effort: a read-only install should not disable PTY if the helper already works */
56
+ }
57
+ }
58
+ return touched;
59
+ }
60
+
61
+ const RESOLVE_HELPER_COMMAND =
62
+ `helper=$(node -p "const path=require('path'); const pkg=require.resolve('node-pty/package.json'); path.join(path.dirname(pkg),'prebuilds',process.platform+'-'+process.arch,'spawn-helper')")`;
63
+ const VERIFY_COMMAND =
64
+ `node -e "const p=require('node-pty').spawn('/bin/echo',['ok'],{name:'xterm-256color',cols:20,rows:5,cwd:process.cwd(),env:process.env}); console.log('node-pty OK'); p.kill()"`;
65
+ const TEMP_WORKAROUND = "AGENT_BOARD_DISABLE_PTY=1 pi /agent-board";
66
+
67
+ /** @param {string} value */
68
+ function shellQuote(value) {
69
+ return `'${String(value).replace(/'/g, `'"'"'`)}'`;
70
+ }
71
+
72
+ /** @param {string|null|undefined} helperPath @param {string} [label] */
73
+ function helperPathSteps(helperPath, label = "Detected helper path") {
74
+ if (!helperPath) return ["Resolve the helper path:", RESOLVE_HELPER_COMMAND];
75
+ return [`${label}:`, helperPath];
76
+ }
77
+
78
+ /** @param {string|null|undefined} helperPath */
79
+ function chmodHelperCommand(helperPath) {
80
+ return helperPath ? `chmod +x ${shellQuote(helperPath)}` : `chmod +x "$helper"`;
81
+ }
82
+
83
+ /** @param {string|null|undefined} helperPath */
84
+ function quarantineHelperCommand(helperPath) {
85
+ return helperPath ? `xattr -dr com.apple.quarantine ${shellQuote(helperPath)}` : `xattr -dr com.apple.quarantine "$helper"`;
86
+ }
87
+
88
+ /** @param {string|null|undefined} helperPath */
89
+ function quarantineHelperCommandBestEffort(helperPath) {
90
+ return helperPath
91
+ ? `xattr -dr com.apple.quarantine ${shellQuote(helperPath)} 2>/dev/null || true`
92
+ : `xattr -dr com.apple.quarantine "$helper" 2>/dev/null || true`;
93
+ }
94
+
95
+ /**
96
+ * @typedef {Object} PtyProbe
97
+ * @property {string|null} helperPath
98
+ * @property {boolean|null} helperExists
99
+ * @property {boolean|null} helperExecutable
100
+ * @property {boolean|null} helperQuarantined
101
+ */
102
+
103
+ /**
104
+ * @typedef {Object} PtyIssue
105
+ * @property {string} id
106
+ * @property {string} title
107
+ * @property {string} statusLabel
108
+ * @property {string} summary
109
+ * @property {string} fixHint
110
+ * @property {string[]} steps
111
+ * @property {string} rawReason
112
+ */
113
+
114
+ /**
115
+ * Inspect the installed node-pty helper on disk so we can separate "missing exec bit"
116
+ * from "quarantined by Gatekeeper" from "helper missing altogether".
117
+ * @param {import("node:module").Require} requireForPty
118
+ * @param {string} [platform]
119
+ * @param {string} [arch]
120
+ * @returns {PtyProbe}
121
+ */
122
+ export function probeNodePtyEnvironment(requireForPty, platform = process.platform, arch = process.arch) {
123
+ const candidates = nodePtySpawnHelperPaths(requireForPty, platform, arch);
124
+ const helperPath = candidates.find((p) => existsSync(p)) ?? candidates[0] ?? null;
125
+ if (!helperPath) {
126
+ return { helperPath: null, helperExists: null, helperExecutable: null, helperQuarantined: null };
127
+ }
128
+ const helperExists = existsSync(helperPath);
129
+ let helperExecutable = null;
130
+ let helperQuarantined = null;
131
+ if (helperExists) {
132
+ try {
133
+ helperExecutable = Boolean(statSync(helperPath).mode & 0o111);
134
+ } catch {
135
+ helperExecutable = null;
136
+ }
137
+ if (platform === "darwin") {
138
+ try {
139
+ const res = spawnSync("xattr", ["-p", "com.apple.quarantine", helperPath], { encoding: "utf8" });
140
+ helperQuarantined = res.status === 0 && Boolean(res.stdout?.trim());
141
+ } catch {
142
+ helperQuarantined = null;
143
+ }
144
+ }
145
+ }
146
+ return { helperPath, helperExists, helperExecutable, helperQuarantined };
147
+ }
148
+
149
+ /** @param {string|null|undefined} reason */
150
+ function cleanReason(reason) {
151
+ return String(reason || "").replace(/\s+/g, " ").trim();
152
+ }
153
+
154
+ /**
155
+ * Turn a raw node-pty startup failure into a user-facing explanation + fix steps.
156
+ * @param {string|null|undefined} reason
157
+ * @param {{ platform?: string, arch?: string, probe?: PtyProbe|null }} [opts]
158
+ * @returns {PtyIssue}
159
+ */
160
+ export function diagnoseNodePtyFailure(reason, opts = {}) {
161
+ const platform = opts.platform ?? process.platform;
162
+ const arch = opts.arch ?? process.arch;
163
+ const probe = opts.probe ?? null;
164
+ const helperPath = probe?.helperPath ?? null;
165
+ const rawReason = cleanReason(reason) || "unknown error";
166
+
167
+ if (/AGENT_BOARD_DISABLE_PTY=1|AGENT_VIEW_DISABLE_PTY=1/.test(rawReason)) {
168
+ return {
169
+ id: "disabled-env",
170
+ title: "PTY disabled by environment",
171
+ statusLabel: "disabled by env",
172
+ summary: "Live PTY is disabled because AGENT_BOARD_DISABLE_PTY=1 is set.",
173
+ fixHint: "Unset AGENT_BOARD_DISABLE_PTY and retry Agent Board.",
174
+ steps: ["Unset the env override.", "Retry /agent-board or attach again."],
175
+ rawReason,
176
+ };
177
+ }
178
+
179
+ if (/Cannot find module ['\"]node-pty['\"]|Cannot find package ['\"]node-pty['\"]|ERR_MODULE_NOT_FOUND/i.test(rawReason)) {
180
+ return {
181
+ id: "missing-module",
182
+ title: "node-pty dependency missing",
183
+ statusLabel: "node-pty missing",
184
+ summary: "Live PTY is disabled because the node-pty dependency is missing.",
185
+ fixHint: "Reinstall the package so production dependencies are present.",
186
+ steps: [
187
+ "For a local checkout, run: npm install",
188
+ "For an installed package, reinstall agent-board so node-pty is present.",
189
+ `Temporary workaround: ${TEMP_WORKAROUND}`,
190
+ ],
191
+ rawReason,
192
+ };
193
+ }
194
+
195
+ if (/(Could not locate the bindings file|No native build was found|Cannot find module ['\"].*pty\.node['\"]|No prebuilds found)/i.test(rawReason)) {
196
+ return {
197
+ id: "native-missing",
198
+ title: "node-pty native binary missing",
199
+ statusLabel: "native binary missing",
200
+ summary: "Live PTY is disabled because node-pty is installed but its native binary is missing for this runtime.",
201
+ fixHint: `Reinstall or rebuild node-pty under the same Node version and architecture (${platform}-${arch}) that Pi is using.`,
202
+ steps: [
203
+ `Confirm runtime: node -p \"process.version + ' ' + process.platform + ' ' + process.arch\"`,
204
+ "Reinstall dependencies or rebuild native modules under that same runtime.",
205
+ "For source installs, run npm rebuild node-pty (or npm install) in the package directory.",
206
+ `Temporary workaround: ${TEMP_WORKAROUND}`,
207
+ ],
208
+ rawReason,
209
+ };
210
+ }
211
+
212
+ if (
213
+ platform === "darwin" &&
214
+ /(posix_spawnp failed|spawn-helper|permission denied|operation not permitted|eacces|eperm)/i.test(rawReason)
215
+ ) {
216
+ if (probe?.helperExists === false) {
217
+ return {
218
+ id: "macos-spawn-helper-missing",
219
+ title: "node-pty helper missing",
220
+ statusLabel: "spawn-helper missing",
221
+ summary: "Live PTY is disabled because node-pty's macOS spawn-helper file is missing.",
222
+ fixHint: "Reinstall the package so the darwin helper is restored.",
223
+ steps: [
224
+ ...helperPathSteps(helperPath, "Expected helper path"),
225
+ "Reinstall agent-board or run npm install again so node-pty's darwin prebuilds are present.",
226
+ `Temporary workaround: ${TEMP_WORKAROUND}`,
227
+ ],
228
+ rawReason,
229
+ };
230
+ }
231
+ if (probe?.helperExecutable === false) {
232
+ return {
233
+ id: "macos-spawn-helper-mode",
234
+ title: "spawn-helper is not executable",
235
+ statusLabel: "spawn-helper not executable",
236
+ summary: "Live PTY is disabled because node-pty's spawn-helper does not have execute permission.",
237
+ fixHint: "Run chmod +x on spawn-helper.",
238
+ steps: [
239
+ ...helperPathSteps(helperPath),
240
+ "Grant execute permission:",
241
+ chmodHelperCommand(helperPath),
242
+ "Validate node-pty after the fix:",
243
+ VERIFY_COMMAND,
244
+ `Temporary workaround: ${TEMP_WORKAROUND}`,
245
+ ],
246
+ rawReason,
247
+ };
248
+ }
249
+ if (probe?.helperQuarantined) {
250
+ return {
251
+ id: "macos-spawn-helper-quarantine",
252
+ title: "spawn-helper is quarantined",
253
+ statusLabel: "spawn-helper quarantined",
254
+ summary: "Live PTY is disabled because macOS Gatekeeper quarantined node-pty's spawn-helper.",
255
+ fixHint: "Clear the quarantine xattr from spawn-helper.",
256
+ steps: [
257
+ ...helperPathSteps(helperPath),
258
+ "Remove the quarantine attribute:",
259
+ quarantineHelperCommand(helperPath),
260
+ "Validate node-pty after the fix:",
261
+ VERIFY_COMMAND,
262
+ `Temporary workaround: ${TEMP_WORKAROUND}`,
263
+ ],
264
+ rawReason,
265
+ };
266
+ }
267
+ return {
268
+ id: "macos-spawn-helper",
269
+ title: "macOS blocked spawn-helper",
270
+ statusLabel: "spawn-helper blocked",
271
+ summary: "Live PTY is disabled because macOS could not execute node-pty's spawn-helper.",
272
+ fixHint: "Run chmod +x on spawn-helper and clear quarantine if needed.",
273
+ steps: [
274
+ ...helperPathSteps(helperPath),
275
+ "Fix permissions and quarantine:",
276
+ chmodHelperCommand(helperPath),
277
+ quarantineHelperCommandBestEffort(helperPath),
278
+ "Validate node-pty after the fix:",
279
+ VERIFY_COMMAND,
280
+ `Temporary workaround: ${TEMP_WORKAROUND}`,
281
+ ],
282
+ rawReason,
283
+ };
284
+ }
285
+
286
+ if (
287
+ /(NODE_MODULE_VERSION|module version mismatch|module did not self-register|dlopen\(|mach-o|wrong architecture|incompatible architecture|no suitable image|invalid elf|symbol not found)/i.test(
288
+ rawReason,
289
+ )
290
+ ) {
291
+ return {
292
+ id: "native-mismatch",
293
+ title: "node-pty native binary mismatch",
294
+ statusLabel: "native binary mismatch",
295
+ summary: "Live PTY is disabled because node-pty's native binary does not match this Node or CPU architecture.",
296
+ fixHint: `Reinstall node-pty under the same Node version and architecture (${platform}-${arch}) that Pi is using.`,
297
+ steps: [
298
+ `Confirm runtime: node -p \"process.version + ' ' + process.platform + ' ' + process.arch\"`,
299
+ "Reinstall dependencies under that same runtime.",
300
+ "Avoid mixing Rosetta x64 and native arm64 installs on macOS.",
301
+ `Temporary workaround: ${TEMP_WORKAROUND}`,
302
+ ],
303
+ rawReason,
304
+ };
305
+ }
306
+
307
+ return {
308
+ id: "generic",
309
+ title: "node-pty startup probe failed",
310
+ statusLabel: "startup probe failed",
311
+ summary: "Live PTY is disabled because node-pty failed its startup probe.",
312
+ fixHint: "Inspect the raw error, reinstall node-pty, or temporarily disable PTY.",
313
+ steps: [
314
+ "Inspect the raw reason below for the underlying system error.",
315
+ "Reinstall the package dependencies if the problem persists.",
316
+ `Temporary workaround: ${TEMP_WORKAROUND}`,
317
+ ],
318
+ rawReason,
319
+ };
320
+ }
321
+
322
+ /** @param {{ ok: boolean, reason?: string|null, issue?: PtyIssue|null }} support */
323
+ export function nodePtyFallbackMessage(support) {
324
+ if (support.ok) return undefined;
325
+ const issue = support.issue ?? diagnoseNodePtyFailure(support.reason ?? null);
326
+ return `${issue.summary} Fix: ${issue.fixHint} Press ! for exact steps.`;
327
+ }
@@ -0,0 +1,47 @@
1
+ /** Git repository identity. Pure node (shells out to `git`); returns null off-repo. */
2
+ import { execFileSync } from "node:child_process";
3
+
4
+ /**
5
+ * Resolve the git repo root containing `cwd`, or null if not in a repo.
6
+ * @param {string} cwd
7
+ * @returns {string|null}
8
+ */
9
+ export function gitRepoRoot(cwd) {
10
+ try {
11
+ const out = execFileSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
12
+ encoding: "utf8",
13
+ stdio: ["ignore", "pipe", "ignore"],
14
+ });
15
+ const root = out.trim();
16
+ return root || null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Whether two directories resolve to the same git repo root.
24
+ * @param {string|null} rootA
25
+ * @param {string|null} rootB
26
+ * @returns {boolean}
27
+ */
28
+ export function sameRepo(rootA, rootB) {
29
+ return Boolean(rootA && rootB && rootA === rootB);
30
+ }
31
+
32
+ /**
33
+ * Whether the repo has uncommitted changes (dirty). Best-effort; false on error/off-repo.
34
+ * @param {string} repoRoot
35
+ * @returns {boolean}
36
+ */
37
+ export function isDirty(repoRoot) {
38
+ try {
39
+ const out = execFileSync("git", ["-C", repoRoot, "status", "--porcelain"], {
40
+ encoding: "utf8",
41
+ stdio: ["ignore", "pipe", "ignore"],
42
+ });
43
+ return out.trim().length > 0;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Pure dashboard logic: turn store Rows into display view-models, group them by
3
+ * semantic state, and filter them. No pi-tui / theme coupling (the dashboard component
4
+ * applies colors); everything here is unit-tested.
5
+ */
6
+ import { normalizeGenericStatusText } from "./derive.mjs";
7
+ import { baseName, relativeTime } from "./heuristics.mjs";
8
+ import { GROUP_LABELS, GROUP_ORDER, SEMANTIC_STATES } from "./types.mjs";
9
+
10
+ /** @typedef {import("./store.mjs").Row} Row */
11
+ /** @typedef {import("./types.mjs").SemanticState} SemanticState */
12
+
13
+ /**
14
+ * @typedef {Object} RowView
15
+ * @property {string} id
16
+ * @property {string} name
17
+ * @property {string} summary
18
+ * @property {string} age
19
+ * @property {string} place
20
+ * @property {string} folderKey
21
+ * @property {string} folderName
22
+ * @property {string} folderPath
23
+ * @property {boolean} pinned
24
+ * @property {SemanticState} state
25
+ * @property {boolean} alive
26
+ * @property {boolean} hostAlive
27
+ * @property {boolean} needsInput
28
+ * @property {boolean} hasError
29
+ * @property {boolean} worktree
30
+ * @property {boolean} unread
31
+ * @property {boolean} reviewReady
32
+ * @property {number} evidenceErrorCount
33
+ * @property {boolean} diagnosticStalled
34
+ * @property {number} diagnosticErrorCount
35
+ * @property {number} followUpCount
36
+ * @property {string|null} followUpPreview
37
+ * @property {string} steeringState
38
+ * @property {number} lastActivityAt
39
+ */
40
+
41
+ /** @param {Row} row @returns {SemanticState} */
42
+ export function rowState(row) {
43
+ return row.state?.semanticState ?? "queued";
44
+ }
45
+
46
+ /**
47
+ * State glyph (plain unicode; the dashboard colors it via theme).
48
+ * @param {SemanticState} state
49
+ * @param {boolean} alive
50
+ * @param {boolean} [hostAlive]
51
+ * @param {boolean} [unread]
52
+ * @returns {string}
53
+ */
54
+ export function stateGlyph(state, alive, hostAlive = false, unread = false) {
55
+ switch (state) {
56
+ case "needs_input":
57
+ return unread ? "◆" : "◇";
58
+ case "working":
59
+ return alive ? (unread ? "◉" : "●") : (unread ? "◕" : "◐");
60
+ case "queued":
61
+ return hostAlive ? (unread ? "◍" : "◌") : (unread ? "◎" : "○");
62
+ case "completed":
63
+ return hostAlive ? (unread ? "◍" : "◌") : (unread ? "✔" : "✓");
64
+ case "failed":
65
+ return unread ? "✖" : "✗";
66
+ case "idle":
67
+ return unread ? "●" : "·";
68
+ case "stopped":
69
+ return unread ? "■" : "▪";
70
+ default:
71
+ return "?";
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Theme color name appropriate for a state (consumed by the dashboard).
77
+ * @param {SemanticState} state
78
+ * @returns {string}
79
+ */
80
+ export function stateColor(state) {
81
+ switch (state) {
82
+ case "needs_input":
83
+ return "warning";
84
+ case "working":
85
+ return "accent";
86
+ case "queued":
87
+ return "muted";
88
+ case "completed":
89
+ return "success";
90
+ case "failed":
91
+ return "error";
92
+ case "idle":
93
+ return "dim";
94
+ case "stopped":
95
+ return "muted";
96
+ default:
97
+ return "text";
98
+ }
99
+ }
100
+
101
+ /**
102
+ * @param {Row} row
103
+ * @param {number} now
104
+ * @returns {RowView}
105
+ */
106
+ export function rowView(row, now) {
107
+ const state = rowState(row);
108
+ const summary = oneLine(normalizeGenericStatusText(state, row.state?.summary));
109
+ const lastActivityAt = row.state?.lastActivityAt ?? row.meta.updatedAt ?? row.meta.createdAt;
110
+ const worktree = row.meta.worktreeMode === "worktree";
111
+ const folderPath = row.meta.repoCwd || row.meta.cwd;
112
+ const folderName = baseName(folderPath) + (worktree ? "⌥" : "");
113
+ const folderKey = normalizeFolderKey(folderPath);
114
+ const place = folderName;
115
+ const lastVisitedAt = row.state?.lastVisitedAt ?? null;
116
+ const lastAgentActivityAt = row.state?.lastAgentActivityAt ?? null;
117
+ return {
118
+ id: row.meta.id,
119
+ name: row.meta.name,
120
+ summary,
121
+ age: relativeTime(lastActivityAt, now),
122
+ place,
123
+ folderKey,
124
+ folderName,
125
+ folderPath,
126
+ pinned: Boolean(row.meta.pinned),
127
+ state,
128
+ alive: Boolean(row.alive),
129
+ hostAlive: Boolean(row.hostAlive),
130
+ needsInput: state === "needs_input",
131
+ hasError: state === "failed",
132
+ worktree,
133
+ unread: lastAgentActivityAt !== null && (lastVisitedAt === null || lastAgentActivityAt > lastVisitedAt),
134
+ reviewReady: Boolean(row.state?.review?.ready),
135
+ evidenceErrorCount: row.state?.review?.errorCount ?? 0,
136
+ diagnosticStalled: Boolean(row.state?.diagnostics?.stalled),
137
+ diagnosticErrorCount: row.state?.diagnostics?.errorCount ?? 0,
138
+ followUpCount: row.state?.followUps?.queuedCount ?? 0,
139
+ followUpPreview: row.state?.followUps?.lastQueuedPreview ?? null,
140
+ steeringState: row.state?.steering?.status ?? "none",
141
+ lastActivityAt,
142
+ };
143
+ }
144
+
145
+ /** @param {string} text */
146
+ function oneLine(text) {
147
+ return String(text || "").replace(/\s+/g, " ").trim() || "—";
148
+ }
149
+
150
+ /**
151
+ * Group rows by semantic state in GROUP_ORDER. Within a group: pinned first, then most
152
+ * recently active first. Empty groups are omitted.
153
+ * @param {Row[]} rows
154
+ * @param {number} now
155
+ * @returns {Array<{ state: SemanticState, label: string, rows: RowView[] }>}
156
+ */
157
+ export function groupRows(rows, now) {
158
+ const views = rows.map((r) => rowView(r, now));
159
+ /** @type {Array<{state: SemanticState, label: string, rows: RowView[]}>} */
160
+ const groups = [];
161
+ for (const state of GROUP_ORDER) {
162
+ const inGroup = sortRowViews(views.filter((v) => v.state === state));
163
+ if (inGroup.length > 0) groups.push({ state, label: GROUP_LABELS[state], rows: inGroup });
164
+ }
165
+ return groups;
166
+ }
167
+
168
+ /**
169
+ * Group rows first by semantic state, then by repo/folder within that state.
170
+ * `showFolders` is true only when a stage spans more than one folder, so a stage backed by
171
+ * a single folder renders as a flat list (no redundant folder header).
172
+ * @param {Row[]} rows
173
+ * @param {number} now
174
+ * @returns {Array<{ state: SemanticState, label: string, rowCount: number, showFolders: boolean, folders: Array<{ key: string, name: string, path: string, rows: RowView[], lastActivityAt: number, pinned: boolean }> }>}
175
+ */
176
+ export function groupRowsByFolder(rows, now) {
177
+ const views = rows.map((r) => rowView(r, now));
178
+ const groups = [];
179
+ for (const state of GROUP_ORDER) {
180
+ const inState = views.filter((v) => v.state === state);
181
+ if (inState.length === 0) continue;
182
+ const byFolder = new Map();
183
+ for (const view of inState) {
184
+ const key = view.folderKey;
185
+ const existing = byFolder.get(key);
186
+ if (existing) existing.rows.push(view);
187
+ else byFolder.set(key, { key, name: view.folderName, path: view.folderPath, rows: [view], lastActivityAt: view.lastActivityAt, pinned: view.pinned });
188
+ }
189
+ const folders = [...byFolder.values()].map((folder) => {
190
+ folder.rows = sortRowViews(folder.rows);
191
+ folder.lastActivityAt = Math.max(...folder.rows.map((r) => r.lastActivityAt));
192
+ folder.pinned = folder.rows.some((r) => r.pinned);
193
+ return folder;
194
+ }).sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.lastActivityAt - a.lastActivityAt || a.name.localeCompare(b.name));
195
+ const showFolders = folders.length > 1;
196
+ groups.push({ state, label: GROUP_LABELS[state], rowCount: inState.length, showFolders, folders });
197
+ }
198
+ return groups;
199
+ }
200
+
201
+ /** @param {RowView[]} views */
202
+ function sortRowViews(views) {
203
+ return views.sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.lastActivityAt - a.lastActivityAt);
204
+ }
205
+
206
+ /** @param {string} path */
207
+ function normalizeFolderKey(path) {
208
+ return String(path || "").replace(/\\/g, "/").replace(/\/+$/, "") || "/";
209
+ }
210
+
211
+ /**
212
+ * Parse a filter query into a state filter + free-text terms.
213
+ * Supports `s:<state>` (state prefix) and bare words (AND substring match).
214
+ * @param {string} query
215
+ * @returns {{ states: SemanticState[], terms: string[], reviewReady: boolean, diagStalled: boolean, evidenceError: boolean, queued: boolean, steering: string|null }}
216
+ */
217
+ export function parseFilter(query) {
218
+ /** @type {Set<SemanticState>} */
219
+ const states = new Set();
220
+ /** @type {string[]} */
221
+ const terms = [];
222
+ let reviewReady = false;
223
+ let diagStalled = false;
224
+ let evidenceError = false;
225
+ let queued = false;
226
+ let steering = null;
227
+ for (const tok of String(query || "").trim().split(/\s+/).filter(Boolean)) {
228
+ const m = /^s:(.+)$/i.exec(tok);
229
+ if (m) {
230
+ const want = normalizeStateToken(m[1]);
231
+ for (const s of SEMANTIC_STATES) {
232
+ const aliases = [s, GROUP_LABELS[s]];
233
+ if (aliases.some((alias) => matchesStateToken(alias, want))) states.add(s);
234
+ }
235
+ } else if (/^review:ready$/i.test(tok)) {
236
+ reviewReady = true;
237
+ } else if (/^diag:stalled$/i.test(tok)) {
238
+ diagStalled = true;
239
+ } else if (/^evidence:error$/i.test(tok)) {
240
+ evidenceError = true;
241
+ } else if (/^queued:(true|yes|1)$/i.test(tok)) {
242
+ queued = true;
243
+ } else if (/^steer:/i.test(tok)) {
244
+ steering = normalizeStateToken(tok.replace(/^steer:/i, ""));
245
+ } else {
246
+ terms.push(tok.toLowerCase());
247
+ }
248
+ }
249
+ return { states: [...states], terms, reviewReady, diagStalled, evidenceError, queued, steering };
250
+ }
251
+
252
+ /** @param {string} value */
253
+ function normalizeStateToken(value) {
254
+ return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
255
+ }
256
+
257
+ /** @param {string} alias @param {string} want */
258
+ function matchesStateToken(alias, want) {
259
+ const normalized = normalizeStateToken(alias);
260
+ return normalized === want || normalized.startsWith(want);
261
+ }
262
+
263
+ /** @param {string} query @returns {boolean} whether the text is a filter expression. */
264
+ export function isFilterQuery(query) {
265
+ return /(^|\s)(s:|review:|diag:|evidence:|queued:|steer:)/i.test(query || "");
266
+ }
267
+
268
+ /**
269
+ * Filter rows by a query string.
270
+ * @param {Row[]} rows
271
+ * @param {string} query
272
+ * @returns {Row[]}
273
+ */
274
+ export function filterRows(rows, query) {
275
+ const { states, terms, reviewReady, diagStalled, evidenceError, queued, steering } = parseFilter(query);
276
+ if (states.length === 0 && terms.length === 0 && !reviewReady && !diagStalled && !evidenceError && !queued && !steering) return rows;
277
+ return rows.filter((row) => {
278
+ if (states.length > 0 && !states.includes(rowState(row))) return false;
279
+ if (reviewReady && !row.state?.review?.ready) return false;
280
+ if (diagStalled && !row.state?.diagnostics?.stalled) return false;
281
+ if (evidenceError && (row.state?.review?.errorCount ?? 0) <= 0) return false;
282
+ if (queued && (row.state?.followUps?.queuedCount ?? 0) <= 0) return false;
283
+ if (steering && normalizeStateToken(row.state?.steering?.status ?? "none") !== steering) return false;
284
+ if (terms.length === 0) return true;
285
+ const hay = [row.meta.name, row.state?.summary ?? "", row.meta.repoCwd, row.meta.cwd]
286
+ .join(" ")
287
+ .toLowerCase();
288
+ return terms.every((t) => hay.includes(t));
289
+ });
290
+ }