@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,310 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Detached PTY host runner.
4
+ *
5
+ * Owns one long-lived interactive Pi child, captures raw terminal output, and exposes
6
+ * a small JSONL-over-Unix-socket protocol for live attach from the dashboard.
7
+ * Uses node-pty when available; falls back to stdio pipes so tests and installs without
8
+ * native deps still exercise the control protocol.
9
+ */
10
+ import { spawn } from "node:child_process";
11
+ import { createRequire } from "node:module";
12
+ import { createServer } from "node:net";
13
+ import { existsSync, unlinkSync } from "node:fs";
14
+ import { appendLine, readJson } from "../src/core/atomic.mjs";
15
+ import * as P from "../src/core/paths.mjs";
16
+ import { appendBoundedScreenLog, reconcileScreenLog } from "../src/core/screen-log.mjs";
17
+ import { encodePromptForCliArg } from "../src/core/prompt-transport.mjs";
18
+ import { readState, writeHost, writeState } from "../src/core/store.mjs";
19
+ import { ensureNodePtySpawnHelperExecutable } from "../src/core/pty-support.mjs";
20
+
21
+ const requireForPty = createRequire(import.meta.url);
22
+
23
+ /** @type {any|null} */
24
+ let pty = null;
25
+ try {
26
+ pty = await import("node-pty");
27
+ } catch {
28
+ pty = null;
29
+ }
30
+
31
+ const HEARTBEAT_MS = 1000;
32
+
33
+ function main() {
34
+ const configPath = process.argv[2];
35
+ if (!configPath) failEarly("pty-runner: missing config path");
36
+ /** @type {import("../src/core/types.mjs").HostConfig|null} */
37
+ const config = readJson(configPath, null);
38
+ if (!config) failEarly(`pty-runner: cannot read config ${configPath}`);
39
+
40
+ const socketPath = P.controlSocketPath(config.root, config.viewId);
41
+ const screenLog = P.screenLogPath(config.root, config.viewId);
42
+ // Optional per-install cap override from launch prefs (screenLogMaxSize).
43
+ // undefined → screen-log.mjs falls back to its built-in default.
44
+ const screenLogMaxBytes =
45
+ Number.isFinite(config.screenLogMaxBytes) && config.screenLogMaxBytes > 0
46
+ ? Math.floor(config.screenLogMaxBytes)
47
+ : undefined;
48
+ const screenLogLimits = { maxBytes: screenLogMaxBytes };
49
+ let screenLogBytes = reconcileScreenLog(screenLog, screenLogLimits);
50
+ try {
51
+ if (existsSync(socketPath)) unlinkSync(socketPath);
52
+ } catch {}
53
+
54
+ /** @type {Set<import("node:net").Socket>} */
55
+ const clients = new Set();
56
+ let childPid = null;
57
+ let exitCode = null;
58
+ let stopping = false;
59
+ /** @type {import("../src/core/types.mjs").HostStatus} */
60
+ let host = {
61
+ version: 1,
62
+ viewId: config.viewId,
63
+ mode: "pty",
64
+ runnerPid: process.pid,
65
+ childPid: null,
66
+ socketPath,
67
+ state: "starting",
68
+ startedAt: Date.now(),
69
+ lastSeenAt: Date.now(),
70
+ endedAt: null,
71
+ exitCode: null,
72
+ error: null,
73
+ cols: config.cols || 120,
74
+ rows: config.rows || 36,
75
+ attachedClients: 0,
76
+ attachedEver: false,
77
+ };
78
+ const persist = () => writeHost(config.root, host);
79
+ const broadcast = (msg) => {
80
+ const line = JSON.stringify(msg) + "\n";
81
+ for (const c of clients) c.write(line);
82
+ };
83
+ const update = (patch = {}) => {
84
+ host = { ...host, ...patch, lastSeenAt: Date.now(), attachedClients: clients.size };
85
+ persist();
86
+ broadcast({ type: "status", status: host });
87
+ };
88
+ persist();
89
+
90
+ const args = [...config.piArgsPrefix, "--session", config.sessionFile];
91
+ if (config.model) args.push("--model", config.model);
92
+ if (config.thinkingLevel) args.push("--thinking", config.thinkingLevel);
93
+ if (config.tools) args.push("--tools", config.tools);
94
+ if (config.initialPrompt) args.push(encodePromptForCliArg(config.initialPrompt));
95
+
96
+ const env = {
97
+ ...process.env,
98
+ ...(config.env || {}),
99
+ AGENT_BOARD_ROOT: config.root,
100
+ AGENT_BOARD_VIEW_ID: config.viewId,
101
+ AGENT_BOARD_CHILD: "1",
102
+ AGENT_BOARD_HOSTED: "pty",
103
+ // Legacy names are exported too so older child extension builds still behave.
104
+ AGENT_VIEW_ROOT: config.root,
105
+ AGENT_VIEW_VIEW_ID: config.viewId,
106
+ AGENT_VIEW_CHILD: "1",
107
+ AGENT_VIEW_HOSTED: "pty",
108
+ };
109
+
110
+ let child;
111
+ try {
112
+ child = spawnInteractive(config.piCommand, args, {
113
+ cwd: config.cwd,
114
+ env,
115
+ cols: host.cols,
116
+ rows: host.rows,
117
+ allowPipeFallback: config.env?.AGENT_BOARD_ALLOW_PIPE_FALLBACK === "1" || config.env?.AGENT_VIEW_ALLOW_PIPE_FALLBACK === "1",
118
+ });
119
+ } catch (err) {
120
+ const message = err instanceof Error ? err.message : String(err);
121
+ update({ state: "failed", endedAt: Date.now(), exitCode: 1, error: message });
122
+ markRowFailed(config.root, config.viewId, `PTY host failed: ${message}`);
123
+ process.exit(1);
124
+ }
125
+ childPid = child.pid ?? null;
126
+ update({ childPid, state: "alive" });
127
+
128
+ child.onData((data) => {
129
+ screenLogBytes = appendBoundedScreenLog(screenLog, data, screenLogBytes, screenLogLimits);
130
+ broadcast({ type: "output", data });
131
+ });
132
+ child.onExit((code) => {
133
+ exitCode = code ?? 0;
134
+ update({ state: stopping ? "exited" : "exited", endedAt: Date.now(), exitCode, childPid: null });
135
+ broadcast({ type: "exit", exitCode });
136
+ setTimeout(() => process.exit(exitCode ?? 0), 50).unref?.();
137
+ });
138
+ child.onError((err) => {
139
+ update({ state: "failed", endedAt: Date.now(), exitCode: 1, error: err instanceof Error ? err.message : String(err) });
140
+ broadcast({ type: "error", message: host.error || "child error" });
141
+ setTimeout(() => process.exit(1), 50).unref?.();
142
+ });
143
+
144
+ const server = createServer((socket) => {
145
+ clients.add(socket);
146
+ update({ attachedEver: true });
147
+ socket.write(JSON.stringify({ type: "hello", status: host }) + "\n");
148
+ let buffer = "";
149
+ socket.on("data", (chunk) => {
150
+ buffer += chunk.toString("utf8");
151
+ const lines = buffer.split("\n");
152
+ buffer = lines.pop() ?? "";
153
+ for (const line of lines) handleClientLine(line, socket);
154
+ });
155
+ socket.on("close", () => {
156
+ clients.delete(socket);
157
+ update();
158
+ });
159
+ socket.on("error", () => {
160
+ clients.delete(socket);
161
+ update();
162
+ });
163
+ });
164
+ server.on("error", (err) => {
165
+ update({ state: "failed", endedAt: Date.now(), error: err instanceof Error ? err.message : String(err), exitCode: 1 });
166
+ try { child.kill("SIGTERM"); } catch {}
167
+ process.exit(1);
168
+ });
169
+ server.listen(socketPath, () => update({ socketPath, state: "alive" }));
170
+
171
+ function handleClientLine(line, socket) {
172
+ if (!line.trim()) return;
173
+ let msg;
174
+ try { msg = JSON.parse(line); } catch { return send(socket, { type: "error", message: "invalid json" }); }
175
+ switch (msg.type) {
176
+ case "hello":
177
+ send(socket, { type: "hello", status: host });
178
+ break;
179
+ case "input":
180
+ if (typeof msg.data === "string") child.write(msg.data);
181
+ break;
182
+ case "resize": {
183
+ const cols = clampInt(msg.cols, 20, 300, host.cols);
184
+ const rows = clampInt(msg.rows, 5, 120, host.rows);
185
+ child.resize(cols, rows);
186
+ update({ cols, rows });
187
+ break;
188
+ }
189
+ case "interrupt":
190
+ child.write("\x1b");
191
+ break;
192
+ case "terminate":
193
+ stopping = true;
194
+ child.kill("SIGTERM");
195
+ setTimeout(() => child.kill("SIGKILL"), 4000).unref?.();
196
+ break;
197
+ case "detach":
198
+ socket.end();
199
+ break;
200
+ case "get_status":
201
+ send(socket, { type: "status", status: host });
202
+ break;
203
+ }
204
+ }
205
+
206
+ const heartbeat = setInterval(() => {
207
+ if (host.state === "alive") update();
208
+ }, HEARTBEAT_MS);
209
+ heartbeat.unref?.();
210
+
211
+ const shutdown = () => {
212
+ stopping = true;
213
+ try { server.close(); } catch {}
214
+ try { if (existsSync(socketPath)) unlinkSync(socketPath); } catch {}
215
+ try { child.kill("SIGTERM"); } catch {}
216
+ setTimeout(() => process.exit(0), 100).unref?.();
217
+ };
218
+ process.on("SIGTERM", shutdown);
219
+ process.on("SIGINT", shutdown);
220
+ }
221
+
222
+ function spawnInteractive(command, args, opts) {
223
+ ensureNodePtySpawnHelperExecutable(requireForPty);
224
+ if (pty?.spawn) {
225
+ try {
226
+ const proc = pty.spawn(command, args, {
227
+ name: "xterm-256color",
228
+ cols: opts.cols,
229
+ rows: opts.rows,
230
+ cwd: opts.cwd,
231
+ env: opts.env,
232
+ });
233
+ return {
234
+ pid: proc.pid ?? null,
235
+ write: (s) => proc.write(s),
236
+ resize: (cols, rows) => proc.resize(cols, rows),
237
+ kill: (signal) => proc.kill(signal),
238
+ onData: (fn) => proc.onData(fn),
239
+ onExit: (fn) => proc.onExit((e) => fn(e.exitCode ?? 0)),
240
+ onError: () => {},
241
+ };
242
+ } catch (err) {
243
+ if (!opts.allowPipeFallback) throw err;
244
+ }
245
+ }
246
+ if (!opts.allowPipeFallback) throw new Error("node-pty is unavailable");
247
+
248
+ const proc = spawn(command, args, { cwd: opts.cwd, env: opts.env, stdio: ["pipe", "pipe", "pipe"] });
249
+ return {
250
+ pid: proc.pid ?? null,
251
+ write: (s) => proc.stdin.write(s),
252
+ resize: () => {},
253
+ kill: (signal) => proc.kill(signal),
254
+ onData: (fn) => {
255
+ proc.stdout.on("data", (c) => fn(c.toString()));
256
+ proc.stderr.on("data", (c) => fn(c.toString()));
257
+ },
258
+ onExit: (fn) => proc.on("close", (code) => fn(code ?? 0)),
259
+ onError: (fn) => proc.on("error", fn),
260
+ };
261
+ }
262
+
263
+ function send(socket, msg) {
264
+ socket.write(JSON.stringify(msg) + "\n");
265
+ }
266
+
267
+ function clampInt(value, min, max, fallback) {
268
+ const n = Number(value);
269
+ if (!Number.isFinite(n)) return fallback;
270
+ return Math.max(min, Math.min(max, Math.floor(n)));
271
+ }
272
+
273
+
274
+ function markRowFailed(root, viewId, message) {
275
+ const now = Date.now();
276
+ const state = readState(root, viewId) ?? {
277
+ version: 1,
278
+ viewId,
279
+ currentRunId: null,
280
+ semanticState: "queued",
281
+ processState: "exited",
282
+ summary: "Queued",
283
+ lastActivityAt: now,
284
+ updatedAt: now,
285
+ needsInput: false,
286
+ hasError: false,
287
+ latestAssistantPreview: "",
288
+ latestTool: null,
289
+ question: null,
290
+ pendingQuestions: [],
291
+ error: null,
292
+ };
293
+ state.semanticState = "failed";
294
+ state.processState = "exited";
295
+ state.summary = message;
296
+ state.hasError = true;
297
+ state.needsInput = false;
298
+ state.error = message;
299
+ state.updatedAt = now;
300
+ state.lastActivityAt = now;
301
+ writeState(root, state);
302
+ }
303
+
304
+ function failEarly(message) {
305
+ try { appendLine("/tmp/pi-agent-board-pty-runner.err", message); } catch {}
306
+ process.stderr.write(`${message}\n`);
307
+ process.exit(2);
308
+ }
309
+
310
+ main();
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Detached auto-state classifier.
4
+ *
5
+ * Reads the latest assistant turn from evidence/state, asks a cheap model to classify
6
+ * the terminal bucket, and updates state.json (and status.json when a run id exists).
7
+ * Safe best-effort: on model failure it falls back to the same heuristic classifier.
8
+ */
9
+ import { spawn } from "node:child_process";
10
+ import { readJson } from "../src/core/atomic.mjs";
11
+ import { appendDiagnostic } from "../src/core/diagnostics.mjs";
12
+ import { applyAutoStateToStatus, applyAutoStateToViewState, autoStateEnabled, autoStateFromModelOrHeuristic, autoStateModel, buildAutoStatePrompt, heuristicAutoState } from "../src/core/auto-state.mjs";
13
+ import { finalizeEvidence, readEvidence, summarizeEvidence, writeEvidence } from "../src/core/evidence.mjs";
14
+ import { readState, readStatus, writeState, writeStatus } from "../src/core/store.mjs";
15
+
16
+ async function main() {
17
+ const configPath = process.argv[2];
18
+ if (!configPath) process.exit(2);
19
+ /** @type {import("../src/core/types.mjs").AutoStateConfig|null} */
20
+ const config = readJson(configPath, null);
21
+ if (!config || !autoStateEnabled()) process.exit(0);
22
+
23
+ const state = readState(config.root, config.viewId);
24
+ if (!state || state.processState === "alive" || state.semanticState === "failed" || state.semanticState === "stopped") process.exit(0);
25
+
26
+ const evidence = readEvidence(config.root, config.viewId);
27
+ const latest = latestEvidenceText(evidence) || state.latestAssistantPreview || state.summary || "";
28
+ if (!latest.trim()) process.exit(0);
29
+
30
+ if (state.autoState?.source === "model" && state.autoState.textHash === heuristicAutoState(latest).textHash) process.exit(0);
31
+
32
+ const model = autoStateModel();
33
+ let classification = heuristicAutoState(latest, { lastAgentActivityAt: state.lastAgentActivityAt ?? null });
34
+ if (model) {
35
+ const prompt = buildAutoStatePrompt(latest);
36
+ const out = await runOneShot(
37
+ config.piCommand,
38
+ [...config.piArgsPrefix, "--mode", "json", "-p", "--no-session", "--model", model, prompt],
39
+ { timeoutMs: 15000, cwd: config.cwd, env: sanitizedEnv() },
40
+ );
41
+ classification = autoStateFromModelOrHeuristic(out, latest, { lastAgentActivityAt: state.lastAgentActivityAt ?? null });
42
+ }
43
+
44
+ let changed = false;
45
+ if (config.runId) {
46
+ const status = readStatus(config.root, config.viewId, config.runId);
47
+ if (status) {
48
+ changed = applyAutoStateToStatus(status, classification, Date.now()) || changed;
49
+ status.evidenceSummary = summarizeEvidence(finalizeEvidence(evidence, status, Date.now()));
50
+ writeStatus(config.root, status);
51
+ }
52
+ }
53
+ const latestState = readState(config.root, config.viewId) ?? state;
54
+ changed = applyAutoStateToViewState(latestState, classification, Date.now()) || changed;
55
+ finalizeEvidence(evidence, { semanticState: latestState.semanticState, usage: null }, Date.now());
56
+ latestState.review = summarizeEvidence(evidence);
57
+ writeEvidence(config.root, evidence);
58
+ writeState(config.root, latestState);
59
+ if (changed) {
60
+ appendDiagnostic(config.root, config.viewId, { source: "service", runId: config.runId, code: "auto_state_classified", message: "Auto-state classifier updated row state", details: { kind: classification.kind, confidence: classification.confidence, source: classification.source, reason: classification.reason } });
61
+ }
62
+ }
63
+
64
+ /** @param {import("../src/core/types.mjs").EvidenceSnapshot} evidence */
65
+ function latestEvidenceText(evidence) {
66
+ return evidence.assistantEvidence?.[evidence.assistantEvidence.length - 1]?.text ?? "";
67
+ }
68
+
69
+ function sanitizedEnv() {
70
+ const env = { ...process.env };
71
+ delete env.AGENT_BOARD_CHILD;
72
+ delete env.AGENT_VIEW_CHILD;
73
+ delete env.AGENT_BOARD_VIEW_ID;
74
+ delete env.AGENT_VIEW_VIEW_ID;
75
+ delete env.AGENT_BOARD_HOSTED;
76
+ delete env.AGENT_VIEW_HOSTED;
77
+ return env;
78
+ }
79
+
80
+ /**
81
+ * @param {string} command
82
+ * @param {string[]} args
83
+ * @param {{ timeoutMs?: number, cwd?: string, env?: NodeJS.ProcessEnv }} [opts]
84
+ */
85
+ function runOneShot(command, args, opts = {}) {
86
+ return new Promise((resolve) => {
87
+ let out = "";
88
+ let settled = false;
89
+ const child = spawn(command, args, { cwd: opts.cwd, env: opts.env ?? process.env, stdio: ["ignore", "pipe", "ignore"] });
90
+ let buf = "";
91
+ const finish = () => {
92
+ if (settled) return;
93
+ settled = true;
94
+ resolve(out);
95
+ };
96
+ child.stdout.on("data", (c) => {
97
+ buf += c.toString();
98
+ const lines = buf.split("\n");
99
+ buf = lines.pop() ?? "";
100
+ for (const line of lines) {
101
+ try {
102
+ const e = JSON.parse(line);
103
+ if (e?.type === "message_end" && e.message?.role === "assistant") {
104
+ for (const b of e.message.content ?? []) if (b.type === "text") out += b.text;
105
+ }
106
+ } catch {
107
+ /* ignore */
108
+ }
109
+ }
110
+ });
111
+ child.on("close", finish);
112
+ child.on("error", finish);
113
+ setTimeout(() => {
114
+ try { child.kill("SIGKILL"); } catch {}
115
+ finish();
116
+ }, opts.timeoutMs ?? 20000).unref?.();
117
+ });
118
+ }
119
+
120
+ main().catch(() => process.exit(0));
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Detached title-runner shim.
4
+ *
5
+ * Best-effort only: generates a short GPT-4o title from the initial task prompt and updates
6
+ * the row's meta name if the user has not already renamed it.
7
+ */
8
+ import { spawn } from "node:child_process";
9
+ import { readJson } from "../src/core/atomic.mjs";
10
+ import { readMeta, writeMeta } from "../src/core/store.mjs";
11
+ import { DEFAULT_TITLE_MODEL, DEFAULT_TITLE_THINKING_LEVEL, normalizeGeneratedTitle, titlePrompt } from "../src/core/title.mjs";
12
+
13
+ function main() {
14
+ const configPath = process.argv[2];
15
+ if (!configPath) process.exit(2);
16
+ /** @type {import("../src/core/types.mjs").TitleConfig|null} */
17
+ const config = readJson(configPath, null);
18
+ if (!config?.viewId || !config.prompt) process.exit(2);
19
+
20
+ maybeGenerateTitle(config)
21
+ .catch(() => {})
22
+ .finally(() => process.exit(0));
23
+ }
24
+
25
+ async function maybeGenerateTitle(config) {
26
+ const configured = process.env.AGENT_BOARD_TITLE_MODEL ?? process.env.AGENT_VIEW_TITLE_MODEL;
27
+ if (configured === "off") return;
28
+ const model = config.model ?? configured ?? DEFAULT_TITLE_MODEL;
29
+ const thinking = process.env.AGENT_BOARD_TITLE_THINKING_LEVEL ?? process.env.AGENT_VIEW_TITLE_THINKING_LEVEL ?? DEFAULT_TITLE_THINKING_LEVEL;
30
+ const prompt = titlePrompt(config.prompt);
31
+ const args = [...config.piArgsPrefix, "--mode", "json", "-p", "--no-session", "--model", model];
32
+ if (thinking && thinking !== "off") args.push("--thinking", thinking);
33
+ args.push(prompt);
34
+ const out = await runOneShot(config.piCommand, args, 15000);
35
+ const title = normalizeGeneratedTitle(out.trim().split("\n").slice(-1)[0]?.trim(), config.fallbackName);
36
+ if (!title || title === config.fallbackName) return;
37
+
38
+ const meta = readMeta(config.root, config.viewId);
39
+ if (!meta) return;
40
+ if (meta.name !== config.fallbackName) return;
41
+ meta.name = title;
42
+ writeMeta(config.root, meta);
43
+ }
44
+
45
+ function runOneShot(command, args, timeoutMs = 20000) {
46
+ return new Promise((resolve) => {
47
+ let out = "";
48
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"] });
49
+ let buf = "";
50
+ child.stdout.on("data", (c) => {
51
+ buf += c.toString();
52
+ const lines = buf.split("\n");
53
+ buf = lines.pop() ?? "";
54
+ for (const line of lines) {
55
+ try {
56
+ const e = JSON.parse(line);
57
+ if (e?.type === "message_end" && e.message?.role === "assistant") {
58
+ for (const b of e.message.content ?? []) {
59
+ if (b.type === "text") out += b.text;
60
+ }
61
+ }
62
+ } catch {
63
+ /* ignore */
64
+ }
65
+ }
66
+ });
67
+ child.on("close", () => resolve(out));
68
+ child.on("error", () => resolve(""));
69
+ setTimeout(() => {
70
+ try {
71
+ child.kill("SIGKILL");
72
+ } catch {
73
+ /* ignore */
74
+ }
75
+ resolve(out);
76
+ }, timeoutMs).unref?.();
77
+ });
78
+ }
79
+
80
+ main();
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * postinstall: patch vulnerable transitive dependencies that are locked behind
4
+ * @earendil-works/pi-coding-agent's npm-shrinkwrap.json (which prevents npm
5
+ * "overrides" from taking effect).
6
+ *
7
+ * Vulnerabilities patched:
8
+ * - brace-expansion <=5.0.7 → 5.0.8 (GHSA-3jxr-9vmj-r5cp, GHSA-mh99-v99m-4gvg)
9
+ * - protobufjs 7.5.0–7.6.4 → 7.6.5 (GHSA-j3f2-48v5-ccww)
10
+ */
11
+ import { execSync } from "node:child_process";
12
+ import { existsSync, rmSync, mkdirSync } from "node:fs";
13
+ import { join, dirname } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
17
+ const nested = join(
18
+ root,
19
+ "node_modules",
20
+ "@earendil-works",
21
+ "pi-coding-agent",
22
+ "node_modules",
23
+ );
24
+
25
+ const patches = [
26
+ { name: "brace-expansion", version: "5.0.8" },
27
+ { name: "protobufjs", version: "7.6.5" },
28
+ ];
29
+
30
+ for (const { name, version } of patches) {
31
+ const target = join(nested, name);
32
+ if (!existsSync(target)) {
33
+ console.log(`[patch-vulns] ${name} not found nested, skipping`);
34
+ continue;
35
+ }
36
+
37
+ console.log(`[patch-vulns] patching ${name} → ${version}`);
38
+ rmSync(target, { recursive: true, force: true });
39
+ mkdirSync(target, { recursive: true });
40
+
41
+ // Install the patched version into a temp dir, then move it into place
42
+ const tmp = join(root, `.patch-tmp-${name}`);
43
+ rmSync(tmp, { recursive: true, force: true });
44
+ mkdirSync(tmp, { recursive: true });
45
+
46
+ try {
47
+ execSync(`npm install ${name}@${version} --prefix "${tmp}" --ignore-scripts`, {
48
+ stdio: "pipe",
49
+ cwd: root,
50
+ });
51
+ const installed = join(tmp, "node_modules", name);
52
+ rmSync(target, { recursive: true, force: true });
53
+ execSync(`mv "${installed}" "${target}"`);
54
+ } finally {
55
+ rmSync(tmp, { recursive: true, force: true });
56
+ }
57
+ }
58
+
59
+ console.log("[patch-vulns] done");