@agwab/pi-workflow 0.1.0 → 0.1.2

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 (58) hide show
  1. package/README.md +14 -3
  2. package/agents/researcher.md +17 -7
  3. package/dist/artifact-graph-runtime.js +1 -0
  4. package/dist/compiler.js +2 -2
  5. package/dist/dynamic-generated-task-runtime.js +4 -3
  6. package/dist/dynamic-runtime-bundle.js +3 -2
  7. package/dist/extension.js +40 -1
  8. package/dist/subagent-backend.js +82 -27
  9. package/dist/tool-metadata.d.ts +1 -0
  10. package/dist/tool-metadata.js +13 -1
  11. package/dist/workflow-artifact-extension.js +3 -2
  12. package/dist/workflow-artifact-tool.js +84 -4
  13. package/dist/workflow-web-source-extension.d.ts +43 -0
  14. package/dist/workflow-web-source-extension.js +1194 -0
  15. package/dist/workflow-web-source.d.ts +171 -0
  16. package/dist/workflow-web-source.js +897 -0
  17. package/docs/usage.md +32 -45
  18. package/node_modules/@agwab/pi-subagent/package.json +1 -1
  19. package/node_modules/@agwab/pi-subagent/src/api.ts +245 -132
  20. package/node_modules/@agwab/pi-subagent/src/artifacts/result.ts +243 -163
  21. package/node_modules/@agwab/pi-subagent/src/core/constants.ts +117 -90
  22. package/node_modules/@agwab/pi-subagent/src/core/validation.ts +728 -475
  23. package/node_modules/@agwab/pi-subagent/src/orchestrate/run.ts +305 -209
  24. package/node_modules/@agwab/pi-subagent/src/runners/headless-model.ts +750 -439
  25. package/node_modules/@agwab/pi-subagent/src/runners/tmux.ts +422 -268
  26. package/package.json +3 -4
  27. package/skills/workflow-guide/scaffolds/object-tool-fallback/schemas/fetch-control.schema.json +1 -1
  28. package/skills/workflow-guide/scaffolds/object-tool-fallback/spec.json +4 -3
  29. package/src/artifact-graph-runtime.ts +1 -0
  30. package/src/compiler.ts +2 -1
  31. package/src/dynamic-generated-task-runtime.ts +4 -2
  32. package/src/dynamic-runtime-bundle.ts +3 -2
  33. package/src/extension.ts +46 -1
  34. package/src/subagent-backend.ts +121 -37
  35. package/src/tool-metadata.ts +22 -1
  36. package/src/workflow-artifact-extension.ts +3 -2
  37. package/src/workflow-artifact-tool.ts +96 -4
  38. package/src/workflow-web-source-extension.ts +1411 -0
  39. package/src/workflow-web-source.ts +1171 -0
  40. package/workflows/README.md +1 -1
  41. package/workflows/deep-research/helpers/claim-evidence-gate.mjs +474 -40
  42. package/workflows/deep-research/helpers/final-audit-packet.mjs +219 -0
  43. package/workflows/deep-research/helpers/normalize-input-packet.mjs +436 -0
  44. package/workflows/deep-research/helpers/render-executive.mjs +571 -198
  45. package/workflows/deep-research/schemas/deep-research-executive-render-control.schema.json +35 -8
  46. package/workflows/deep-research/schemas/deep-research-normalize-claims-control.schema.json +45 -4
  47. package/workflows/deep-research/schemas/deep-research-verify-claims-control.schema.json +0 -2
  48. package/workflows/deep-research/spec.json +36 -21
  49. package/workflows/deep-review/helpers/render-review-report.mjs +502 -0
  50. package/workflows/deep-review/schemas/deep-review-render-control.schema.json +50 -0
  51. package/workflows/deep-review/spec.json +22 -1
  52. package/docs/release.md +0 -89
  53. package/node_modules/@pondwader/socks5-server/.DS_Store +0 -0
  54. package/node_modules/commander/.DS_Store +0 -0
  55. package/node_modules/jiti/.DS_Store +0 -0
  56. package/node_modules/node-forge/.DS_Store +0 -0
  57. package/node_modules/shell-quote/.DS_Store +0 -0
  58. package/node_modules/zod/.DS_Store +0 -0
@@ -2,319 +2,473 @@ import { execFile } from "node:child_process";
2
2
  import { chmod, stat, unlink, writeFile } from "node:fs/promises";
3
3
  import { join, resolve } from "node:path";
4
4
  import { promisify } from "node:util";
5
- import { createAttemptArtifactStore, type ArtifactRef, type ResultEnvelope } from "../artifacts/index.ts";
5
+ import {
6
+ createAttemptArtifactStore,
7
+ type ArtifactRef,
8
+ type ResultEnvelope,
9
+ } from "../artifacts/index.ts";
6
10
  import type { ResultWorkspace } from "../artifacts/result.ts";
7
- import { sandboxAllowedDomains, type FailureKind, type SandboxInput, type Status } from "../core/constants.ts";
11
+ import {
12
+ sandboxAllowedDomains,
13
+ type FailureKind,
14
+ type SandboxInput,
15
+ type Status,
16
+ } from "../core/constants.ts";
8
17
  import { SandboxUnavailableError, withSandboxedArgv } from "../sandbox/srt.ts";
9
- import { buildPiArgv, detectContextLengthExceeded, parsePiJsonFile, parsePiJsonLines, type RunHeadlessModelOptions } from "./headless-model.ts";
18
+ import {
19
+ buildPiArgv,
20
+ detectContextLengthExceeded,
21
+ parsePiJsonFile,
22
+ parsePiJsonLines,
23
+ resolvePiJsonOutcome,
24
+ resultMetadataFromParse,
25
+ resultSessionMetadata,
26
+ type RunHeadlessModelOptions,
27
+ } from "./headless-model.ts";
10
28
 
11
29
  const execFileAsync = promisify(execFile);
12
30
  const POLL_INTERVAL_MS = 100;
13
31
 
14
32
  interface RunTmuxProcessOptions {
15
- argv: readonly string[];
16
- cwd?: string;
17
- artifactCwd?: string;
18
- runId?: string;
19
- attemptId?: string;
20
- runsDir?: string;
21
- timeoutMs?: number;
22
- signal?: AbortSignal;
23
- sandbox?: SandboxInput | null;
24
- workspace?: Partial<ResultWorkspace>;
33
+ argv: readonly string[];
34
+ cwd?: string;
35
+ artifactCwd?: string;
36
+ runId?: string;
37
+ attemptId?: string;
38
+ runsDir?: string;
39
+ timeoutMs?: number;
40
+ signal?: AbortSignal;
41
+ sandbox?: SandboxInput | null;
42
+ workspace?: Partial<ResultWorkspace>;
25
43
  }
26
44
 
27
45
  export type RunTmuxModelOptions = RunHeadlessModelOptions;
28
46
 
29
47
  interface WorkerMeta {
30
- status: Status;
31
- failureKind: FailureKind | null;
32
- exitCode: number | null;
33
- signal: string | null;
48
+ status: Status;
49
+ failureKind: FailureKind | null;
50
+ exitCode: number | null;
51
+ signal: string | null;
34
52
  }
35
53
 
36
54
  interface TmuxRunResult {
37
- meta: WorkerMeta;
38
- stderrRef: ArtifactRef;
39
- eventPath: string;
40
- tmux: {
41
- sessionName: string;
42
- sessionId: string | null;
43
- paneId: string | null;
44
- };
55
+ meta: WorkerMeta;
56
+ stderrRef: ArtifactRef;
57
+ eventPath: string;
58
+ tmux: {
59
+ sessionName: string;
60
+ sessionId: string | null;
61
+ paneId: string | null;
62
+ };
45
63
  }
46
64
 
47
- function assertRunnableArgv(argv: readonly string[]): asserts argv is readonly [string, ...string[]] {
48
- if (!Array.isArray(argv) || argv.length === 0) {
49
- throw new Error("argv must be a non-empty array of non-empty strings.");
50
- }
51
-
52
- for (const [index, value] of argv.entries()) {
53
- if (typeof value !== "string" || value.length === 0) {
54
- throw new Error(`argv[${index}] must be a non-empty string.`);
55
- }
56
- }
65
+ function assertRunnableArgv(
66
+ argv: readonly string[],
67
+ ): asserts argv is readonly [string, ...string[]] {
68
+ if (!Array.isArray(argv) || argv.length === 0) {
69
+ throw new Error("argv must be a non-empty array of non-empty strings.");
70
+ }
71
+
72
+ for (const [index, value] of argv.entries()) {
73
+ if (typeof value !== "string" || value.length === 0) {
74
+ throw new Error(`argv[${index}] must be a non-empty string.`);
75
+ }
76
+ }
57
77
  }
58
78
 
59
79
  function normalizeTimeoutMs(timeoutMs: number | undefined): number | undefined {
60
- if (timeoutMs === undefined) return undefined;
61
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
62
- throw new Error("timeoutMs must be a positive finite number when provided.");
63
- }
64
- return timeoutMs;
80
+ if (timeoutMs === undefined) return undefined;
81
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
82
+ throw new Error(
83
+ "timeoutMs must be a positive finite number when provided.",
84
+ );
85
+ }
86
+ return timeoutMs;
65
87
  }
66
88
 
67
89
  async function tmuxAvailable(): Promise<boolean> {
68
- try {
69
- await execFileAsync("tmux", ["-V"]);
70
- return true;
71
- } catch {
72
- return false;
73
- }
90
+ try {
91
+ await execFileAsync("tmux", ["-V"]);
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
74
96
  }
75
97
 
76
98
  async function pathBytes(path: string): Promise<number> {
77
- try {
78
- return (await stat(path)).size;
79
- } catch {
80
- return 0;
81
- }
99
+ try {
100
+ return (await stat(path)).size;
101
+ } catch {
102
+ return 0;
103
+ }
82
104
  }
83
105
 
84
106
  async function sleep(ms: number): Promise<void> {
85
- await new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
107
+ await new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
86
108
  }
87
109
 
88
110
  function shellQuote(value: string): string {
89
- return `'${value.replaceAll("'", `'\\''`)}'`;
111
+ return `'${value.replaceAll("'", `'\\''`)}'`;
90
112
  }
91
113
 
92
114
  async function readWorkerMeta(path: string): Promise<WorkerMeta | undefined> {
93
- try {
94
- const { readFile } = await import("node:fs/promises");
95
- const parsed = JSON.parse(await readFile(path, "utf8")) as Partial<WorkerMeta>;
96
- if (parsed.status !== "completed" && parsed.status !== "failed") return undefined;
97
- return {
98
- status: parsed.status,
99
- failureKind: parsed.failureKind ?? null,
100
- exitCode: typeof parsed.exitCode === "number" ? parsed.exitCode : null,
101
- signal: typeof parsed.signal === "string" ? parsed.signal : null,
102
- };
103
- } catch {
104
- return undefined;
105
- }
115
+ try {
116
+ const { readFile } = await import("node:fs/promises");
117
+ const parsed = JSON.parse(
118
+ await readFile(path, "utf8"),
119
+ ) as Partial<WorkerMeta>;
120
+ if (parsed.status !== "completed" && parsed.status !== "failed")
121
+ return undefined;
122
+ return {
123
+ status: parsed.status,
124
+ failureKind: parsed.failureKind ?? null,
125
+ exitCode: typeof parsed.exitCode === "number" ? parsed.exitCode : null,
126
+ signal: typeof parsed.signal === "string" ? parsed.signal : null,
127
+ };
128
+ } catch {
129
+ return undefined;
130
+ }
106
131
  }
107
132
 
108
133
  async function tmuxSessionAlive(sessionName: string): Promise<boolean> {
109
- try {
110
- await execFileAsync("tmux", ["has-session", "-t", sessionName]);
111
- return true;
112
- } catch {
113
- return false;
114
- }
134
+ try {
135
+ await execFileAsync("tmux", ["has-session", "-t", sessionName]);
136
+ return true;
137
+ } catch {
138
+ return false;
139
+ }
115
140
  }
116
141
 
117
142
  async function killTmuxSession(sessionName: string): Promise<void> {
118
- try {
119
- await execFileAsync("tmux", ["kill-session", "-t", sessionName]);
120
- } catch {
121
- // Session may already have exited; cleanup remains best-effort.
122
- }
143
+ try {
144
+ await execFileAsync("tmux", ["kill-session", "-t", sessionName]);
145
+ } catch {
146
+ // Session may already have exited; cleanup remains best-effort.
147
+ }
123
148
  }
124
149
 
125
- function workerScript(argv: readonly [string, ...string[]], cwd: string, eventPath: string, stderrPath: string, metaPath: string): string {
126
- return `import { spawn } from "node:child_process";\nimport { appendFileSync, closeSync, openSync, writeFileSync } from "node:fs";\nconst argv = ${JSON.stringify(argv)};\nconst cwd = ${JSON.stringify(cwd)};\nconst eventPath = ${JSON.stringify(eventPath)};\nconst stderrPath = ${JSON.stringify(stderrPath)};\nconst metaPath = ${JSON.stringify(metaPath)};\nconst messageUpdatePattern = /"type"\\s*:\\s*"message_update"/;\nconst maxStdoutLogLineChars = 64 * 1024 * 1024;\ncloseSync(openSync(eventPath, "w"));\ncloseSync(openSync(stderrPath, "w"));\nlet settled = false;\nlet stdoutBuffer = "";\nlet discardingOversizedLine = false;\nlet omittedMessageUpdates = 0;\nlet omittedMessageUpdateBytes = 0;\nlet omittedOversizedLines = 0;\nlet omittedOversizedBytes = 0;\nfunction writeStdoutLine(line) {\n if (messageUpdatePattern.test(line)) {\n omittedMessageUpdates += 1;\n omittedMessageUpdateBytes += Buffer.byteLength(line, "utf8");\n return;\n }\n appendFileSync(eventPath, line);\n process.stdout.write(line);\n}\nfunction handleStdoutChunk(chunk) {\n let text = chunk.toString("utf8");\n while (text.length > 0) {\n if (discardingOversizedLine) {\n const newline = text.indexOf("\\n");\n omittedOversizedBytes += Buffer.byteLength(newline < 0 ? text : text.slice(0, newline + 1), "utf8");\n if (newline < 0) return;\n discardingOversizedLine = false;\n text = text.slice(newline + 1);\n continue;\n }\n const newline = text.indexOf("\\n");\n const segment = newline < 0 ? text : text.slice(0, newline + 1);\n stdoutBuffer += segment;\n text = newline < 0 ? "" : text.slice(newline + 1);\n if (stdoutBuffer.length > maxStdoutLogLineChars) {\n omittedOversizedLines += 1;\n omittedOversizedBytes += Buffer.byteLength(stdoutBuffer, "utf8");\n stdoutBuffer = "";\n discardingOversizedLine = newline < 0;\n continue;\n }\n if (newline >= 0) {\n writeStdoutLine(stdoutBuffer);\n stdoutBuffer = "";\n }\n }\n}\nfunction finishStdoutFilter() {\n if (!discardingOversizedLine && stdoutBuffer.length > 0) writeStdoutLine(stdoutBuffer);\n stdoutBuffer = "";\n if (omittedMessageUpdates > 0 || omittedOversizedLines > 0) {\n appendFileSync(eventPath, JSON.stringify({ type: "pi-subagent.stdout_filter", omitted: { messageUpdateEvents: omittedMessageUpdates, messageUpdateBytes: omittedMessageUpdateBytes, oversizedLines: omittedOversizedLines, oversizedBytes: omittedOversizedBytes }, reason: "cumulative message_update snapshots are omitted from durable stdout artifacts; final assistant text is stored in output.log" }) + "\\n");\n }\n}\nfunction writeMeta(meta) {\n if (settled) return;\n settled = true;\n finishStdoutFilter();\n writeFileSync(metaPath, JSON.stringify(meta, null, 2) + "\\n");\n}\nconst env = { ...process.env };\ndelete env.TMUX;\nconst child = spawn(argv[0], argv.slice(1), { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"], env });\nchild.stdout?.on("data", handleStdoutChunk);\nchild.stderr?.on("data", (chunk) => { appendFileSync(stderrPath, chunk); process.stderr.write(chunk); });\nchild.on("error", () => { writeMeta({ status: "failed", failureKind: "spawn", exitCode: null, signal: null }); });\nchild.on("close", (exitCode, signal) => {\n const failureKind = exitCode === 0 ? null : "exit";\n writeMeta({ status: failureKind === null ? "completed" : "failed", failureKind, exitCode, signal });\n});\n`;
150
+ function workerScript(
151
+ argv: readonly [string, ...string[]],
152
+ cwd: string,
153
+ eventPath: string,
154
+ stderrPath: string,
155
+ metaPath: string,
156
+ ): string {
157
+ return `import { spawn } from "node:child_process";\nimport { appendFileSync, closeSync, openSync, writeFileSync } from "node:fs";\nconst argv = ${JSON.stringify(argv)};\nconst cwd = ${JSON.stringify(cwd)};\nconst eventPath = ${JSON.stringify(eventPath)};\nconst stderrPath = ${JSON.stringify(stderrPath)};\nconst metaPath = ${JSON.stringify(metaPath)};\nconst messageUpdatePattern = /"type"\\s*:\\s*"message_update"/;\nconst maxStdoutLogLineChars = 64 * 1024 * 1024;\ncloseSync(openSync(eventPath, "w"));\ncloseSync(openSync(stderrPath, "w"));\nlet settled = false;\nlet stdoutBuffer = "";\nlet discardingOversizedLine = false;\nlet omittedMessageUpdates = 0;\nlet omittedMessageUpdateBytes = 0;\nlet omittedOversizedLines = 0;\nlet omittedOversizedBytes = 0;\nfunction writeStdoutLine(line) {\n if (messageUpdatePattern.test(line)) {\n omittedMessageUpdates += 1;\n omittedMessageUpdateBytes += Buffer.byteLength(line, "utf8");\n return;\n }\n appendFileSync(eventPath, line);\n process.stdout.write(line);\n}\nfunction handleStdoutChunk(chunk) {\n let text = chunk.toString("utf8");\n while (text.length > 0) {\n if (discardingOversizedLine) {\n const newline = text.indexOf("\\n");\n omittedOversizedBytes += Buffer.byteLength(newline < 0 ? text : text.slice(0, newline + 1), "utf8");\n if (newline < 0) return;\n discardingOversizedLine = false;\n text = text.slice(newline + 1);\n continue;\n }\n const newline = text.indexOf("\\n");\n const segment = newline < 0 ? text : text.slice(0, newline + 1);\n stdoutBuffer += segment;\n text = newline < 0 ? "" : text.slice(newline + 1);\n if (stdoutBuffer.length > maxStdoutLogLineChars) {\n omittedOversizedLines += 1;\n omittedOversizedBytes += Buffer.byteLength(stdoutBuffer, "utf8");\n stdoutBuffer = "";\n discardingOversizedLine = newline < 0;\n continue;\n }\n if (newline >= 0) {\n writeStdoutLine(stdoutBuffer);\n stdoutBuffer = "";\n }\n }\n}\nfunction finishStdoutFilter() {\n if (!discardingOversizedLine && stdoutBuffer.length > 0) writeStdoutLine(stdoutBuffer);\n stdoutBuffer = "";\n if (omittedMessageUpdates > 0 || omittedOversizedLines > 0) {\n appendFileSync(eventPath, JSON.stringify({ type: "pi-subagent.stdout_filter", omitted: { messageUpdateEvents: omittedMessageUpdates, messageUpdateBytes: omittedMessageUpdateBytes, oversizedLines: omittedOversizedLines, oversizedBytes: omittedOversizedBytes }, reason: "cumulative message_update snapshots are omitted from durable stdout artifacts; final assistant text is stored in output.log" }) + "\\n");\n }\n}\nfunction writeMeta(meta) {\n if (settled) return;\n settled = true;\n finishStdoutFilter();\n writeFileSync(metaPath, JSON.stringify(meta, null, 2) + "\\n");\n}\nconst env = { ...process.env };\ndelete env.TMUX;\nconst child = spawn(argv[0], argv.slice(1), { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"], env });\nchild.stdout?.on("data", handleStdoutChunk);\nchild.stderr?.on("data", (chunk) => { appendFileSync(stderrPath, chunk); process.stderr.write(chunk); });\nchild.on("error", () => { writeMeta({ status: "failed", failureKind: "spawn", exitCode: null, signal: null }); });\nchild.on("close", (exitCode, signal) => {\n const failureKind = exitCode === 0 ? null : "exit";\n writeMeta({ status: failureKind === null ? "completed" : "failed", failureKind, exitCode, signal });\n});\n`;
127
158
  }
128
159
 
129
- async function runTmuxProcess(options: RunTmuxProcessOptions): Promise<{ result: TmuxRunResult | null; store: Awaited<ReturnType<typeof createAttemptArtifactStore>>; cwd: string; artifactCwd: string; startedAt: Date; failure?: WorkerMeta; stderr?: string }> {
130
- const argv = options.argv;
131
- assertRunnableArgv(argv);
132
- const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
133
- const cwd = resolve(options.cwd ?? process.cwd());
134
- const artifactCwd = resolve(options.artifactCwd ?? cwd);
135
- const startedAt = new Date();
136
- const store = await createAttemptArtifactStore({ cwd: artifactCwd, runId: options.runId, attemptId: options.attemptId, runsDir: options.runsDir });
137
-
138
- if (!(await tmuxAvailable())) {
139
- return {
140
- result: null,
141
- store,
142
- cwd,
143
- artifactCwd,
144
- startedAt,
145
- failure: { status: "failed", failureKind: "spawn", exitCode: null, signal: null },
146
- stderr: "tmux is not available on PATH; install tmux or choose backend \"headless\".\n",
147
- };
148
- }
149
-
150
- const sessionName = `pi-subagent-${store.runId}-${store.attemptId}`.replace(/[^A-Za-z0-9_-]/g, "-");
151
- const eventPath = join(store.taskDir, "pi-events.jsonl");
152
- const stderrPath = store.pathFor("stderr");
153
- const metaPath = join(store.taskDir, "tmux-worker-meta.json");
154
- const scriptPath = join(store.taskDir, "tmux-worker.mjs");
155
- const launchPath = join(store.taskDir, "tmux-launch.sh");
156
-
157
- await writeFile(scriptPath, workerScript(argv, cwd, eventPath, stderrPath, metaPath));
158
-
159
- await writeFile(launchPath, `#!/usr/bin/env bash\nset -euo pipefail\nunset TMUX\nexec ${shellQuote(process.execPath)} ${shellQuote(scriptPath)}\n`);
160
- await chmod(launchPath, 0o700);
161
-
162
- async function runSession(tmuxCommand: string, tmuxArgs: readonly string[], tmuxEnv?: NodeJS.ProcessEnv): Promise<{ result: TmuxRunResult | null; store: Awaited<ReturnType<typeof createAttemptArtifactStore>>; cwd: string; artifactCwd: string; startedAt: Date; failure?: WorkerMeta; stderr?: string }> {
163
- let sessionId: string | null = null;
164
- let paneId: string | null = null;
165
- try {
166
- const { stdout } = await execFileAsync("tmux", ["new-session", "-d", "-s", sessionName, "-P", "-F", "#{session_id}\t#{pane_id}", tmuxCommand, ...tmuxArgs], { cwd, ...(tmuxEnv === undefined ? {} : { env: tmuxEnv }) });
167
- const [rawSessionId, rawPaneId] = stdout.trim().split("\t");
168
- sessionId = rawSessionId || null;
169
- paneId = rawPaneId || null;
170
- } catch (error) {
171
- return {
172
- result: null,
173
- store,
174
- cwd,
175
- artifactCwd,
176
- startedAt,
177
- failure: { status: "failed", failureKind: "spawn", exitCode: null, signal: null },
178
- stderr: error instanceof Error ? `${error.message}\n` : `${String(error)}\n`,
179
- };
180
- }
181
-
182
- const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
183
- let stopKind: "timeout" | "abort" | null = null;
184
-
185
- while (true) {
186
- const meta = await readWorkerMeta(metaPath);
187
- if (meta !== undefined) {
188
- await killTmuxSession(sessionName);
189
- return {
190
- result: {
191
- meta,
192
- stderrRef: store.refFor("stderr", await pathBytes(stderrPath)),
193
- eventPath,
194
- tmux: { sessionName, sessionId, paneId },
195
- },
196
- store,
197
- cwd,
198
- artifactCwd,
199
- startedAt,
200
- };
201
- }
202
-
203
- if (options.signal?.aborted) stopKind = "abort";
204
- if (deadline !== undefined && Date.now() >= deadline) stopKind = "timeout";
205
- if (stopKind !== null) {
206
- await killTmuxSession(sessionName);
207
- return {
208
- result: {
209
- meta: { status: "failed", failureKind: stopKind, exitCode: null, signal: "SIGTERM" },
210
- stderrRef: store.refFor("stderr", await pathBytes(stderrPath)),
211
- eventPath,
212
- tmux: { sessionName, sessionId, paneId },
213
- },
214
- store,
215
- cwd,
216
- artifactCwd,
217
- startedAt,
218
- };
219
- }
220
-
221
- if (!(await tmuxSessionAlive(sessionName))) {
222
- return {
223
- result: {
224
- meta: { status: "failed", failureKind: "spawn", exitCode: null, signal: null },
225
- stderrRef: store.refFor("stderr", await pathBytes(stderrPath)),
226
- eventPath,
227
- tmux: { sessionName, sessionId, paneId },
228
- },
229
- store,
230
- cwd,
231
- artifactCwd,
232
- startedAt,
233
- };
234
- }
235
-
236
- await sleep(POLL_INTERVAL_MS);
237
- }
238
- }
239
-
240
- try {
241
- if (options.sandbox) {
242
- return await withSandboxedArgv([process.execPath, scriptPath], { sandbox: options.sandbox, cwd, writablePaths: [store.taskDir], allowPty: true, signal: options.signal }, async (launch) => {
243
- await writeFile(launchPath, `#!/usr/bin/env bash\nset -euo pipefail\nunset TMUX\nexec ${launch.argv.map(shellQuote).join(" ")}\n`);
244
- await chmod(launchPath, 0o700);
245
- return await runSession("/bin/bash", [launchPath], launch.env);
246
- });
247
- }
248
- return await runSession("/bin/bash", [launchPath]);
249
- } catch (error) {
250
- if (!(error instanceof SandboxUnavailableError)) throw error;
251
- return {
252
- result: null,
253
- store,
254
- cwd,
255
- artifactCwd,
256
- startedAt,
257
- failure: { status: "failed", failureKind: "sandbox", exitCode: null, signal: null },
258
- stderr: `${error.message}\n`,
259
- };
260
- }
160
+ async function runTmuxProcess(
161
+ options: RunTmuxProcessOptions,
162
+ ): Promise<{
163
+ result: TmuxRunResult | null;
164
+ store: Awaited<ReturnType<typeof createAttemptArtifactStore>>;
165
+ cwd: string;
166
+ artifactCwd: string;
167
+ startedAt: Date;
168
+ failure?: WorkerMeta;
169
+ stderr?: string;
170
+ }> {
171
+ const argv = options.argv;
172
+ assertRunnableArgv(argv);
173
+ const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
174
+ const cwd = resolve(options.cwd ?? process.cwd());
175
+ const artifactCwd = resolve(options.artifactCwd ?? cwd);
176
+ const startedAt = new Date();
177
+ const store = await createAttemptArtifactStore({
178
+ cwd: artifactCwd,
179
+ runId: options.runId,
180
+ attemptId: options.attemptId,
181
+ runsDir: options.runsDir,
182
+ });
183
+
184
+ if (!(await tmuxAvailable())) {
185
+ return {
186
+ result: null,
187
+ store,
188
+ cwd,
189
+ artifactCwd,
190
+ startedAt,
191
+ failure: {
192
+ status: "failed",
193
+ failureKind: "spawn",
194
+ exitCode: null,
195
+ signal: null,
196
+ },
197
+ stderr:
198
+ 'tmux is not available on PATH; install tmux or choose backend "headless".\n',
199
+ };
200
+ }
201
+
202
+ const sessionName = `pi-subagent-${store.runId}-${store.attemptId}`.replace(
203
+ /[^A-Za-z0-9_-]/g,
204
+ "-",
205
+ );
206
+ const eventPath = join(store.taskDir, "pi-events.jsonl");
207
+ const stderrPath = store.pathFor("stderr");
208
+ const metaPath = join(store.taskDir, "tmux-worker-meta.json");
209
+ const scriptPath = join(store.taskDir, "tmux-worker.mjs");
210
+ const launchPath = join(store.taskDir, "tmux-launch.sh");
211
+
212
+ await writeFile(
213
+ scriptPath,
214
+ workerScript(argv, cwd, eventPath, stderrPath, metaPath),
215
+ );
216
+
217
+ await writeFile(
218
+ launchPath,
219
+ `#!/usr/bin/env bash\nset -euo pipefail\nunset TMUX\nexec ${shellQuote(process.execPath)} ${shellQuote(scriptPath)}\n`,
220
+ );
221
+ await chmod(launchPath, 0o700);
222
+
223
+ async function runSession(
224
+ tmuxCommand: string,
225
+ tmuxArgs: readonly string[],
226
+ tmuxEnv?: NodeJS.ProcessEnv,
227
+ ): Promise<{
228
+ result: TmuxRunResult | null;
229
+ store: Awaited<ReturnType<typeof createAttemptArtifactStore>>;
230
+ cwd: string;
231
+ artifactCwd: string;
232
+ startedAt: Date;
233
+ failure?: WorkerMeta;
234
+ stderr?: string;
235
+ }> {
236
+ let sessionId: string | null = null;
237
+ let paneId: string | null = null;
238
+ try {
239
+ const { stdout } = await execFileAsync(
240
+ "tmux",
241
+ [
242
+ "new-session",
243
+ "-d",
244
+ "-s",
245
+ sessionName,
246
+ "-P",
247
+ "-F",
248
+ "#{session_id}\t#{pane_id}",
249
+ tmuxCommand,
250
+ ...tmuxArgs,
251
+ ],
252
+ { cwd, ...(tmuxEnv === undefined ? {} : { env: tmuxEnv }) },
253
+ );
254
+ const [rawSessionId, rawPaneId] = stdout.trim().split("\t");
255
+ sessionId = rawSessionId || null;
256
+ paneId = rawPaneId || null;
257
+ } catch (error) {
258
+ return {
259
+ result: null,
260
+ store,
261
+ cwd,
262
+ artifactCwd,
263
+ startedAt,
264
+ failure: {
265
+ status: "failed",
266
+ failureKind: "spawn",
267
+ exitCode: null,
268
+ signal: null,
269
+ },
270
+ stderr:
271
+ error instanceof Error ? `${error.message}\n` : `${String(error)}\n`,
272
+ };
273
+ }
274
+
275
+ const deadline =
276
+ timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
277
+ let stopKind: "timeout" | "abort" | null = null;
278
+
279
+ while (true) {
280
+ const meta = await readWorkerMeta(metaPath);
281
+ if (meta !== undefined) {
282
+ await killTmuxSession(sessionName);
283
+ return {
284
+ result: {
285
+ meta,
286
+ stderrRef: store.refFor("stderr", await pathBytes(stderrPath)),
287
+ eventPath,
288
+ tmux: { sessionName, sessionId, paneId },
289
+ },
290
+ store,
291
+ cwd,
292
+ artifactCwd,
293
+ startedAt,
294
+ };
295
+ }
296
+
297
+ if (options.signal?.aborted) stopKind = "abort";
298
+ if (deadline !== undefined && Date.now() >= deadline)
299
+ stopKind = "timeout";
300
+ if (stopKind !== null) {
301
+ await killTmuxSession(sessionName);
302
+ return {
303
+ result: {
304
+ meta: {
305
+ status: "failed",
306
+ failureKind: stopKind,
307
+ exitCode: null,
308
+ signal: "SIGTERM",
309
+ },
310
+ stderrRef: store.refFor("stderr", await pathBytes(stderrPath)),
311
+ eventPath,
312
+ tmux: { sessionName, sessionId, paneId },
313
+ },
314
+ store,
315
+ cwd,
316
+ artifactCwd,
317
+ startedAt,
318
+ };
319
+ }
320
+
321
+ if (!(await tmuxSessionAlive(sessionName))) {
322
+ return {
323
+ result: {
324
+ meta: {
325
+ status: "failed",
326
+ failureKind: "spawn",
327
+ exitCode: null,
328
+ signal: null,
329
+ },
330
+ stderrRef: store.refFor("stderr", await pathBytes(stderrPath)),
331
+ eventPath,
332
+ tmux: { sessionName, sessionId, paneId },
333
+ },
334
+ store,
335
+ cwd,
336
+ artifactCwd,
337
+ startedAt,
338
+ };
339
+ }
340
+
341
+ await sleep(POLL_INTERVAL_MS);
342
+ }
343
+ }
344
+
345
+ try {
346
+ if (options.sandbox) {
347
+ return await withSandboxedArgv(
348
+ [process.execPath, scriptPath],
349
+ {
350
+ sandbox: options.sandbox,
351
+ cwd,
352
+ writablePaths: [store.taskDir],
353
+ allowPty: true,
354
+ signal: options.signal,
355
+ },
356
+ async (launch) => {
357
+ await writeFile(
358
+ launchPath,
359
+ `#!/usr/bin/env bash\nset -euo pipefail\nunset TMUX\nexec ${launch.argv.map(shellQuote).join(" ")}\n`,
360
+ );
361
+ await chmod(launchPath, 0o700);
362
+ return await runSession("/bin/bash", [launchPath], launch.env);
363
+ },
364
+ );
365
+ }
366
+ return await runSession("/bin/bash", [launchPath]);
367
+ } catch (error) {
368
+ if (!(error instanceof SandboxUnavailableError)) throw error;
369
+ return {
370
+ result: null,
371
+ store,
372
+ cwd,
373
+ artifactCwd,
374
+ startedAt,
375
+ failure: {
376
+ status: "failed",
377
+ failureKind: "sandbox",
378
+ exitCode: null,
379
+ signal: null,
380
+ },
381
+ stderr: `${error.message}\n`,
382
+ };
383
+ }
261
384
  }
262
385
 
263
- export async function runTmuxModel(options: RunTmuxModelOptions): Promise<ResultEnvelope> {
264
- const sandbox = options.sandbox ? { enabled: true, allowedDomains: sandboxAllowedDomains(options.sandbox) } : { enabled: false };
265
- if (typeof options.agent !== "string" || options.agent.length === 0) {
266
- throw new Error("agent must be a non-empty string.");
267
- }
268
- if (typeof options.task !== "string" || options.task.length === 0) {
269
- throw new Error("task must be a non-empty string.");
270
- }
271
-
272
- const { result, store, cwd, artifactCwd, startedAt, failure, stderr } = await runTmuxProcess({ ...options, argv: buildPiArgv(options) });
273
- if (result === null) {
274
- const artifacts: ArtifactRef[] = [await store.writeTextArtifact("stderr", stderr ?? ""), await store.writeTextArtifact("output", "")];
275
- return await store.writeResult({
276
- backend: "tmux",
277
- status: failure?.status ?? "failed",
278
- failureKind: failure?.failureKind ?? "spawn",
279
- cwd: artifactCwd,
280
- startedAt,
281
- completedAt: new Date(),
282
- workspace: options.workspace ?? { mode: "shared", cwd },
283
- sandbox,
284
- exitCode: failure?.exitCode ?? null,
285
- signal: failure?.signal ?? null,
286
- artifacts,
287
- correlationId: options.correlationId,
288
- metadata: { contextLengthExceeded: detectContextLengthExceeded({ stderrText: stderr ?? "" }) },
289
- });
290
- }
291
-
292
- const stderrText = await import("node:fs/promises").then(({ readFile }) => readFile(store.pathFor("stderr"), "utf8").catch(() => ""));
293
- const parsed = await parsePiJsonFile(result.eventPath).catch(() => parsePiJsonLines(""));
294
- await unlink(result.eventPath).catch(() => undefined);
295
- const contextLengthExceeded = detectContextLengthExceeded({ stderrText, errors: parsed.errors });
296
- let meta = result.meta;
297
- if (meta.status === "completed" && parsed.parseErrors.length > 0 && parsed.finalAssistantText.length === 0) {
298
- meta = { ...meta, status: "failed", failureKind: "parse" };
299
- } else if (meta.status === "completed" && parsed.errors.length > 0) {
300
- meta = { ...meta, status: "failed", failureKind: "model" };
301
- }
302
-
303
- const outputRef = await store.writeTextArtifact("output", parsed.finalAssistantText);
304
- return await store.writeResult({
305
- backend: "tmux",
306
- status: meta.status,
307
- failureKind: meta.failureKind,
308
- cwd: artifactCwd,
309
- startedAt,
310
- completedAt: new Date(),
311
- workspace: options.workspace ?? { mode: "shared", cwd },
312
- sandbox,
313
- exitCode: meta.exitCode,
314
- signal: meta.signal,
315
- artifacts: [result.stderrRef, outputRef],
316
- tmux: result.tmux,
317
- correlationId: options.correlationId,
318
- metadata: { ...parsed.metadata, contextLengthExceeded },
319
- });
386
+ export async function runTmuxModel(
387
+ options: RunTmuxModelOptions,
388
+ ): Promise<ResultEnvelope> {
389
+ const sandbox = options.sandbox
390
+ ? { enabled: true, allowedDomains: sandboxAllowedDomains(options.sandbox) }
391
+ : { enabled: false };
392
+ if (typeof options.agent !== "string" || options.agent.length === 0) {
393
+ throw new Error("agent must be a non-empty string.");
394
+ }
395
+ if (typeof options.task !== "string" || options.task.length === 0) {
396
+ throw new Error("task must be a non-empty string.");
397
+ }
398
+
399
+ const sessionMetadata = await resultSessionMetadata(
400
+ resolve(options.cwd ?? process.cwd()),
401
+ options.sessionId,
402
+ );
403
+ const { result, store, cwd, artifactCwd, startedAt, failure, stderr } =
404
+ await runTmuxProcess({ ...options, argv: buildPiArgv(options) });
405
+ if (result === null) {
406
+ const artifacts: ArtifactRef[] = [
407
+ await store.writeTextArtifact("stderr", stderr ?? ""),
408
+ await store.writeTextArtifact("output", ""),
409
+ ];
410
+ return await store.writeResult({
411
+ backend: "tmux",
412
+ status: failure?.status ?? "failed",
413
+ failureKind: failure?.failureKind ?? "spawn",
414
+ cwd: artifactCwd,
415
+ startedAt,
416
+ completedAt: new Date(),
417
+ workspace: options.workspace ?? { mode: "shared", cwd },
418
+ sandbox,
419
+ exitCode: failure?.exitCode ?? null,
420
+ signal: failure?.signal ?? null,
421
+ artifacts,
422
+ correlationId: options.correlationId,
423
+ metadata: {
424
+ contextLengthExceeded: detectContextLengthExceeded({
425
+ stderrText: stderr ?? "",
426
+ }),
427
+ ...sessionMetadata,
428
+ ...(options.parentSessionId === undefined
429
+ ? {}
430
+ : { parentSessionId: options.parentSessionId }),
431
+ },
432
+ });
433
+ }
434
+
435
+ const stderrText = await import("node:fs/promises").then(({ readFile }) =>
436
+ readFile(store.pathFor("stderr"), "utf8").catch(() => ""),
437
+ );
438
+ const parsed = await parsePiJsonFile(result.eventPath).catch(() =>
439
+ parsePiJsonLines(""),
440
+ );
441
+ await unlink(result.eventPath).catch(() => undefined);
442
+ const contextLengthExceeded = detectContextLengthExceeded({
443
+ stderrText,
444
+ errors: parsed.errors,
445
+ });
446
+ const meta = resolvePiJsonOutcome(result.meta, parsed, contextLengthExceeded);
447
+
448
+ const outputRef = await store.writeTextArtifact(
449
+ "output",
450
+ parsed.finalAssistantText,
451
+ );
452
+ return await store.writeResult({
453
+ backend: "tmux",
454
+ status: meta.status,
455
+ failureKind: meta.failureKind,
456
+ cwd: artifactCwd,
457
+ startedAt,
458
+ completedAt: new Date(),
459
+ workspace: options.workspace ?? { mode: "shared", cwd },
460
+ sandbox,
461
+ exitCode: meta.exitCode,
462
+ signal: meta.signal,
463
+ artifacts: [result.stderrRef, outputRef],
464
+ tmux: result.tmux,
465
+ correlationId: options.correlationId,
466
+ metadata: {
467
+ ...resultMetadataFromParse(parsed, contextLengthExceeded, meta),
468
+ ...sessionMetadata,
469
+ ...(options.parentSessionId === undefined
470
+ ? {}
471
+ : { parentSessionId: options.parentSessionId }),
472
+ },
473
+ });
320
474
  }