@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,193 @@
1
+ /** Durable FIFO follow-up queue helpers. */
2
+ import { atomicWriteJson, readJson } from "./atomic.mjs";
3
+ import { newFollowUpId } from "./ids.mjs";
4
+ import { truncate } from "./heuristics.mjs";
5
+ import { withViewLockSync } from "./locks.mjs";
6
+ import * as P from "./paths.mjs";
7
+
8
+ /** @param {string} viewId @param {number} [now] @returns {import("./types.mjs").FollowUpQueue} */
9
+ export function emptyFollowUpQueue(viewId, now = Date.now()) {
10
+ return { version: 1, viewId, nextSeq: 1, updatedAt: now, items: [] };
11
+ }
12
+
13
+ /** @param {string} root @param {string} viewId */
14
+ export function readFollowUpQueue(root, viewId) {
15
+ return normalizeQueue(readJson(P.followUpQueuePath(root, viewId), null), viewId);
16
+ }
17
+
18
+ /** @param {string} root @param {import("./types.mjs").FollowUpQueue} queue */
19
+ export function writeFollowUpQueue(root, queue) {
20
+ const normalized = normalizeQueue(queue, queue.viewId);
21
+ normalized.updatedAt = Date.now();
22
+ atomicWriteJson(P.followUpQueuePath(root, normalized.viewId), normalized);
23
+ return normalized;
24
+ }
25
+
26
+ /** @param {import("./types.mjs").FollowUpQueue} queue @returns {import("./types.mjs").FollowUpSummary} */
27
+ export function summarizeFollowUpQueue(queue) {
28
+ const q = normalizeQueue(queue, queue?.viewId ?? "");
29
+ const queued = q.items.filter((i) => i.status === "queued");
30
+ const claimed = q.items.filter((i) => i.status === "claimed");
31
+ const last = [...queued].sort((a, b) => b.createdAt - a.createdAt)[0] ?? null;
32
+ return {
33
+ queuedCount: queued.length,
34
+ claimedCount: claimed.length,
35
+ lastQueuedAt: last?.createdAt ?? null,
36
+ lastQueuedPreview: last ? truncate(last.text, 120) : null,
37
+ };
38
+ }
39
+
40
+ /** @param {string} root @param {string} viewId @param {string} text @param {{ kind?: import("./types.mjs").FollowUpKind, source?: string, delivery?: "auto"|"now"|"queue" }} [opts] */
41
+ export function enqueueFollowUp(root, viewId, text, opts = {}) {
42
+ const clean = String(text || "").trim();
43
+ if (!clean) return { ok: false, error: "Empty follow-up" };
44
+ return withViewLockSync(root, viewId, "queue", () => {
45
+ const queue = readFollowUpQueue(root, viewId);
46
+ const now = Date.now();
47
+ const item = {
48
+ id: newFollowUpId(),
49
+ seq: queue.nextSeq,
50
+ viewId,
51
+ kind: opts.kind ?? "reply",
52
+ text: clean,
53
+ createdAt: now,
54
+ updatedAt: now,
55
+ status: "queued",
56
+ source: opts.source ?? "user",
57
+ delivery: opts.delivery ?? "auto",
58
+ runId: null,
59
+ claimedAt: null,
60
+ completedAt: null,
61
+ attempts: 0,
62
+ error: null,
63
+ };
64
+ queue.nextSeq += 1;
65
+ queue.items.push(item);
66
+ writeFollowUpQueue(root, queue);
67
+ return { ok: true, item, summary: summarizeFollowUpQueue(queue) };
68
+ });
69
+ }
70
+
71
+ /** @param {string} root @param {string} viewId @param {{ runId?: string|null }} [opts] */
72
+ export function claimNextFollowUp(root, viewId, opts = {}) {
73
+ return withViewLockSync(root, viewId, "queue", () => {
74
+ const queue = readFollowUpQueue(root, viewId);
75
+ const item = queue.items.filter((i) => i.status === "queued").sort((a, b) => a.seq - b.seq)[0];
76
+ if (!item) return { ok: false, error: "No queued follow-up" };
77
+ item.status = "claimed";
78
+ item.claimedAt = Date.now();
79
+ item.updatedAt = item.claimedAt;
80
+ item.attempts = (item.attempts ?? 0) + 1;
81
+ item.runId = opts.runId ?? item.runId ?? null;
82
+ writeFollowUpQueue(root, queue);
83
+ return { ok: true, item, summary: summarizeFollowUpQueue(queue) };
84
+ });
85
+ }
86
+
87
+ /** @param {string} root @param {string} viewId @param {string} itemId @param {{ runId?: string|null }} [opts] */
88
+ export function completeFollowUp(root, viewId, itemId, opts = {}) {
89
+ return updateItem(root, viewId, itemId, (item) => {
90
+ item.status = "completed";
91
+ item.completedAt = Date.now();
92
+ item.updatedAt = item.completedAt;
93
+ if (opts.runId) item.runId = opts.runId;
94
+ });
95
+ }
96
+
97
+ /** @param {string} root @param {string} viewId @param {string} itemId @param {string} error */
98
+ export function failFollowUp(root, viewId, itemId, error) {
99
+ return updateItem(root, viewId, itemId, (item) => {
100
+ item.status = "failed";
101
+ item.error = String(error || "Follow-up failed");
102
+ item.updatedAt = Date.now();
103
+ });
104
+ }
105
+
106
+ /** @param {string} root @param {string} viewId @param {string} itemId */
107
+ export function releaseFollowUp(root, viewId, itemId) {
108
+ return updateItem(root, viewId, itemId, (item) => {
109
+ item.status = "queued";
110
+ item.claimedAt = null;
111
+ item.updatedAt = Date.now();
112
+ });
113
+ }
114
+
115
+ /** @param {string} root @param {string} viewId */
116
+ export function removeLastFollowUp(root, viewId) {
117
+ return withViewLockSync(root, viewId, "queue", () => {
118
+ const queue = readFollowUpQueue(root, viewId);
119
+ const queued = queue.items.filter((i) => i.status === "queued").sort((a, b) => b.seq - a.seq);
120
+ const last = queued[0];
121
+ if (!last) return { ok: false, error: "No queued follow-up" };
122
+ last.status = "cancelled";
123
+ last.updatedAt = Date.now();
124
+ writeFollowUpQueue(root, queue);
125
+ return { ok: true, item: last, summary: summarizeFollowUpQueue(queue) };
126
+ });
127
+ }
128
+
129
+ /** @param {string} root @param {string} viewId */
130
+ export function clearQueuedFollowUps(root, viewId) {
131
+ return withViewLockSync(root, viewId, "queue", () => {
132
+ const queue = readFollowUpQueue(root, viewId);
133
+ let cancelled = 0;
134
+ for (const item of queue.items) {
135
+ if (item.status === "queued") {
136
+ item.status = "cancelled";
137
+ item.updatedAt = Date.now();
138
+ cancelled += 1;
139
+ }
140
+ }
141
+ writeFollowUpQueue(root, queue);
142
+ return { ok: true, cancelled, summary: summarizeFollowUpQueue(queue) };
143
+ });
144
+ }
145
+
146
+ /** @param {string} root @param {string} viewId @param {string} itemId @param {(item: import("./types.mjs").FollowUpItem) => void} mutate */
147
+ function updateItem(root, viewId, itemId, mutate) {
148
+ return withViewLockSync(root, viewId, "queue", () => {
149
+ const queue = readFollowUpQueue(root, viewId);
150
+ const item = queue.items.find((i) => i.id === itemId);
151
+ if (!item) return { ok: false, error: "Unknown follow-up" };
152
+ mutate(item);
153
+ writeFollowUpQueue(root, queue);
154
+ return { ok: true, item, summary: summarizeFollowUpQueue(queue) };
155
+ });
156
+ }
157
+
158
+ /** @param {any} queue @param {string} viewId @returns {import("./types.mjs").FollowUpQueue} */
159
+ function normalizeQueue(queue, viewId) {
160
+ const base = emptyFollowUpQueue(viewId || queue?.viewId || "");
161
+ if (!queue || typeof queue !== "object") return base;
162
+ const items = Array.isArray(queue.items) ? queue.items.map((item, idx) => normalizeItem(item, viewId, idx + 1)) : [];
163
+ return {
164
+ version: 1,
165
+ viewId: typeof queue.viewId === "string" ? queue.viewId : base.viewId,
166
+ nextSeq: Math.max(Number(queue.nextSeq ?? 1) || 1, items.reduce((m, i) => Math.max(m, i.seq + 1), 1)),
167
+ updatedAt: Number(queue.updatedAt ?? Date.now()) || Date.now(),
168
+ items,
169
+ };
170
+ }
171
+
172
+ /** @param {any} item @param {string} viewId @param {number} seq */
173
+ function normalizeItem(item, viewId, seq) {
174
+ const now = Date.now();
175
+ const status = ["queued", "claimed", "completed", "failed", "cancelled"].includes(item?.status) ? item.status : "queued";
176
+ return {
177
+ id: typeof item?.id === "string" ? item.id : newFollowUpId(),
178
+ seq: Number(item?.seq ?? seq) || seq,
179
+ viewId: typeof item?.viewId === "string" ? item.viewId : viewId,
180
+ kind: ["reply", "plan_request", "plan_approval", "plan_change"].includes(item?.kind) ? item.kind : "reply",
181
+ text: String(item?.text ?? ""),
182
+ createdAt: Number(item?.createdAt ?? now) || now,
183
+ updatedAt: Number(item?.updatedAt ?? now) || now,
184
+ status,
185
+ source: typeof item?.source === "string" ? item.source : "user",
186
+ delivery: ["auto", "now", "queue"].includes(item?.delivery) ? item.delivery : "auto",
187
+ runId: typeof item?.runId === "string" ? item.runId : null,
188
+ claimedAt: Number.isFinite(item?.claimedAt) ? item.claimedAt : null,
189
+ completedAt: Number.isFinite(item?.completedAt) ? item.completedAt : null,
190
+ attempts: Number(item?.attempts ?? 0) || 0,
191
+ error: typeof item?.error === "string" ? item.error : null,
192
+ };
193
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Pure text heuristics: extracting assistant text, detecting questions/blockers,
3
+ * summarizing tool calls, and formatting relative times. No I/O, fully unit-tested.
4
+ */
5
+
6
+ /**
7
+ * Join the text content blocks of an assistant message into a single string.
8
+ * Accepts the message object as emitted in JSON mode (`message.content` is an array
9
+ * of `{type,...}` blocks, or, defensively, a plain string).
10
+ * @param {any} message
11
+ * @returns {string}
12
+ */
13
+ export function assistantText(message) {
14
+ if (!message) return "";
15
+ const content = message.content;
16
+ if (typeof content === "string") return content;
17
+ if (!Array.isArray(content)) return "";
18
+ return content
19
+ .filter((b) => b && b.type === "text" && typeof b.text === "string")
20
+ .map((b) => b.text)
21
+ .join("\n")
22
+ .trim();
23
+ }
24
+
25
+ /**
26
+ * Collect tool-call blocks from an assistant message.
27
+ * @param {any} message
28
+ * @returns {Array<{name:string, arguments:Record<string,any>}>}
29
+ */
30
+ export function toolCalls(message) {
31
+ const content = message?.content;
32
+ if (!Array.isArray(content)) return [];
33
+ return content
34
+ .filter((b) => b && b.type === "toolCall")
35
+ .map((b) => ({ name: b.name, arguments: b.arguments ?? {} }));
36
+ }
37
+
38
+ const QUESTION_PHRASES = [
39
+ "need your input",
40
+ "need some input",
41
+ "which option",
42
+ "should i",
43
+ "shall i",
44
+ "please confirm",
45
+ "could you confirm",
46
+ "can you confirm",
47
+ "let me know",
48
+ "do you want",
49
+ "would you like",
50
+ "how would you like",
51
+ "what would you like",
52
+ "which one",
53
+ "please clarify",
54
+ "can you clarify",
55
+ "waiting for your",
56
+ ];
57
+
58
+ /**
59
+ * Decide whether the latest assistant text is asking the user a question / is blocked,
60
+ * and extract the question sentence if so.
61
+ * @param {string} text
62
+ * @returns {{ needsInput: boolean, question: string|null }}
63
+ */
64
+ export function detectNeedsInput(text) {
65
+ const trimmed = (text || "").trim();
66
+ if (!trimmed) return { needsInput: false, question: null };
67
+
68
+ const lower = trimmed.toLowerCase();
69
+ const sentences = splitSentences(trimmed);
70
+ const last = sentences[sentences.length - 1] ?? trimmed;
71
+
72
+ // Strongest signal: the message ends on a question mark.
73
+ if (/\?\s*$/.test(trimmed)) {
74
+ const q = [...sentences].reverse().find((s) => s.includes("?")) ?? last;
75
+ return { needsInput: true, question: q.trim() };
76
+ }
77
+
78
+ for (const phrase of QUESTION_PHRASES) {
79
+ if (lower.includes(phrase)) {
80
+ const hit = sentences.find((s) => s.toLowerCase().includes(phrase)) ?? last;
81
+ return { needsInput: true, question: hit.trim() };
82
+ }
83
+ }
84
+
85
+ return { needsInput: false, question: null };
86
+ }
87
+
88
+ /**
89
+ * Split text into sentences (rough; good enough for previews/blocker extraction).
90
+ * @param {string} text
91
+ * @returns {string[]}
92
+ */
93
+ export function splitSentences(text) {
94
+ return (text || "")
95
+ .replace(/\s+/g, " ")
96
+ .trim()
97
+ .split(/(?<=[.!?])\s+/)
98
+ .map((s) => s.trim())
99
+ .filter(Boolean);
100
+ }
101
+
102
+ /**
103
+ * First sentence (or first line) of an assistant message, for compact previews.
104
+ * @param {string} text
105
+ * @returns {string}
106
+ */
107
+ export function firstSentence(text) {
108
+ const sentences = splitSentences(text);
109
+ return sentences[0] ?? "";
110
+ }
111
+
112
+ const PATH_KEYS = ["file_path", "path", "filePath", "file", "filename"];
113
+
114
+ /**
115
+ * Best-effort target path from a tool call's arguments.
116
+ * @param {Record<string,any>} args
117
+ * @returns {string|null}
118
+ */
119
+ export function toolPath(args) {
120
+ if (!args || typeof args !== "object") return null;
121
+ for (const k of PATH_KEYS) {
122
+ if (typeof args[k] === "string" && args[k]) return args[k];
123
+ }
124
+ if (typeof args.pattern === "string" && args.pattern) return args.pattern;
125
+ return null;
126
+ }
127
+
128
+ /**
129
+ * Human one-liner for an in-progress tool call, e.g. "Editing src/auth.ts".
130
+ * @param {string} name
131
+ * @param {Record<string,any>} args
132
+ * @returns {string}
133
+ */
134
+ export function toolSummary(name, args) {
135
+ const p = toolPath(args);
136
+ const base = p ? baseName(p) : null;
137
+ switch (name) {
138
+ case "edit":
139
+ return base ? `Editing ${base}` : "Editing files";
140
+ case "write":
141
+ return base ? `Writing ${base}` : "Writing files";
142
+ case "read":
143
+ return base ? `Reading ${base}` : "Reading files";
144
+ case "ls":
145
+ return base ? `Listing ${base}` : "Listing files";
146
+ case "find":
147
+ return p ? `Finding ${p}` : "Finding files";
148
+ case "grep":
149
+ return p ? `Searching /${p}/` : "Searching";
150
+ case "bash": {
151
+ const cmd = typeof args?.command === "string" ? args.command.trim() : "";
152
+ if (!cmd) return "Running command";
153
+ const kind = classifyCommand(cmd);
154
+ if (kind === "test") return "Running tests";
155
+ if (kind === "build") return "Building";
156
+ if (kind === "lint") return "Linting";
157
+ if (kind === "git") return `git ${cmd.replace(/^git\s+/, "").split(/\s+/)[0] ?? ""}`.trim();
158
+ return `Running ${truncate(cmd, 32)}`;
159
+ }
160
+ default:
161
+ return base ? `${capitalize(name)} ${base}` : capitalize(name);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Classify a shell command for review evidence.
167
+ * @param {string} command
168
+ * @returns {"test"|"build"|"lint"|"git"|"install"|"other"}
169
+ */
170
+ export function classifyCommand(command) {
171
+ const cmd = String(command || "").trim();
172
+ if (!cmd) return "other";
173
+ if (/\b(test|vitest|jest|pytest|go test|cargo test|npm test|pnpm test|yarn test)\b/.test(cmd)) return "test";
174
+ if (/\b(build|tsc|webpack|vite build|make|cargo build|go build)\b/.test(cmd)) return "build";
175
+ if (/\b(lint|eslint|biome|ruff|clippy)\b/.test(cmd)) return "lint";
176
+ if (/^(git|gh)\b/.test(cmd)) return "git";
177
+ if (/\b(npm install|npm ci|pnpm install|yarn install|bun install)\b/.test(cmd)) return "install";
178
+ return "other";
179
+ }
180
+
181
+ /** @param {string} command @param {number} [n] */
182
+ export function commandPreview(command, n = 120) {
183
+ return truncate(String(command || "").replace(/\s+/g, " ").trim(), n);
184
+ }
185
+
186
+ /**
187
+ * Detect file mutation operations from tool name/args.
188
+ * @param {string} name
189
+ * @param {Record<string,any>} args
190
+ * @returns {{ path:string, action:"edited"|"written"|"deleted"|"unknown" }|null}
191
+ */
192
+ export function toolFileOperation(name, args) {
193
+ const p = toolPath(args);
194
+ if (!p) return null;
195
+ if (["edit", "multi_edit", "apply_patch"].includes(name)) return { path: p, action: "edited" };
196
+ if (["write", "create_file"].includes(name)) return { path: p, action: "written" };
197
+ if (["delete", "rm", "remove_file"].includes(name)) return { path: p, action: "deleted" };
198
+ return null;
199
+ }
200
+
201
+ /** @param {string} p */
202
+ export function baseName(p) {
203
+ const parts = String(p).split(/[\\/]/).filter(Boolean);
204
+ return parts[parts.length - 1] ?? p;
205
+ }
206
+
207
+ /** @param {string} s */
208
+ function capitalize(s) {
209
+ return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
210
+ }
211
+
212
+ /**
213
+ * Truncate to `n` chars with an ellipsis (counts characters, not display width).
214
+ * @param {string} s
215
+ * @param {number} n
216
+ * @returns {string}
217
+ */
218
+ export function truncate(s, n) {
219
+ const str = String(s ?? "");
220
+ if (str.length <= n) return str;
221
+ return `${str.slice(0, Math.max(0, n - 1))}…`;
222
+ }
223
+
224
+ /**
225
+ * Compact relative age, e.g. "10s", "2m", "3h", "4d".
226
+ * @param {number} fromMs
227
+ * @param {number} [nowMs]
228
+ * @returns {string}
229
+ */
230
+ export function relativeTime(fromMs, nowMs = Date.now()) {
231
+ const diff = Math.max(0, nowMs - fromMs);
232
+ const s = Math.floor(diff / 1000);
233
+ if (s < 60) return `${s}s`;
234
+ const m = Math.floor(s / 60);
235
+ if (m < 60) return `${m}m`;
236
+ const h = Math.floor(m / 60);
237
+ if (h < 24) return `${h}h`;
238
+ const d = Math.floor(h / 24);
239
+ return `${d}d`;
240
+ }
@@ -0,0 +1,35 @@
1
+ /** ID generation for views and runs. */
2
+ import { randomBytes } from "node:crypto";
3
+
4
+ /** @param {string} prefix @returns {string} e.g. "view_8f3a1c2b". */
5
+ export function genId(prefix) {
6
+ return `${prefix}_${randomBytes(5).toString("hex")}`;
7
+ }
8
+
9
+ /** @returns {string} a new view (row) id. */
10
+ export const newViewId = () => genId("view");
11
+
12
+ /** @returns {string} a new run id. */
13
+ export const newRunId = () => genId("run");
14
+
15
+ /** @returns {string} a new follow-up queue item id. */
16
+ export const newFollowUpId = () => genId("follow");
17
+
18
+ /**
19
+ * Derive a short, filesystem-safe slug from a free-text task, for display names.
20
+ * @param {string} text
21
+ * @param {number} [maxWords]
22
+ * @returns {string}
23
+ */
24
+ export function slugifyTask(text, maxWords = 5) {
25
+ const words = String(text)
26
+ .toLowerCase()
27
+ .replace(/[`'"]/g, "")
28
+ .replace(/[^a-z0-9]+/g, " ")
29
+ .trim()
30
+ .split(/\s+/)
31
+ .filter(Boolean)
32
+ .slice(0, maxWords);
33
+ const slug = words.join("-");
34
+ return slug || "task";
35
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Resolve how to invoke the `pi` worker and the `node` runner across install shapes:
3
+ * - pi as `dist/cli.js` under system node → piCommand=node, piArgsPrefix=[cli.js]
4
+ * - pi as a bun-compiled binary → piCommand="pi" (on PATH)
5
+ * - generic node/bun runtime → piCommand=execPath
6
+ * Mirrors the logic in the official subagent example's getPiInvocation().
7
+ */
8
+ import { existsSync } from "node:fs";
9
+ import * as path from "node:path";
10
+
11
+ /** @returns {boolean} true when process.execPath is a generic node/bun runtime (not a compiled app). */
12
+ function isGenericRuntime() {
13
+ const name = path.basename(process.execPath).toLowerCase();
14
+ return /^(node|bun)(\.exe)?$/.test(name);
15
+ }
16
+
17
+ /**
18
+ * Compute `{ piCommand, piArgsPrefix }` for spawning the pi worker.
19
+ * @returns {{ piCommand: string, piArgsPrefix: string[] }}
20
+ */
21
+ export function resolvePiInvocation() {
22
+ const script = process.argv[1];
23
+ const isBunVirtual = typeof script === "string" && script.startsWith("/$bunfs/root/");
24
+ if (script && !isBunVirtual && existsSync(script)) {
25
+ // Running the pi cli script under a runtime — re-run the same script.
26
+ return { piCommand: process.execPath, piArgsPrefix: [script] };
27
+ }
28
+ if (!isGenericRuntime()) {
29
+ // Compiled pi binary invoked directly.
30
+ return { piCommand: process.execPath, piArgsPrefix: [] };
31
+ }
32
+ // Fallback: rely on `pi` being on PATH.
33
+ return { piCommand: "pi", piArgsPrefix: [] };
34
+ }
35
+
36
+ /**
37
+ * Resolve a real `node` executable to run the detached `.mjs` runner.
38
+ * @returns {string}
39
+ */
40
+ export function resolveNode() {
41
+ if (isGenericRuntime()) return process.execPath;
42
+ return "node";
43
+ }