@kushalkhemka/opensec-pi-subagents 0.20.5 → 0.20.6

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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.20.6] - 2026-10-17
11
+
12
+ Restores four fixes the Bun build had in 0.19.5-bun.6 to bun.8 (and 0.19.4-bun.0) that were lost when 0.20.x was
13
+ cut from the Node sources. Both builds have them again.
14
+
15
+ ### Added
16
+
17
+ - **Codemode scripts get a deadline.** Pi runs a `codemode` script with no time limit unless its first line sets
18
+ `timeout_ms`, so one stuck call held the agent forever. Scripts that set none now get `codemodeTimeoutSeconds`
19
+ (setting; default 1800, 0 = off), in the main session and in subagents; a deadline the script sets is kept.
20
+ - **Codemode audit trail.** Every call a codemode script makes is written to
21
+ `$PI_CODEMODE_AUDIT_DIR/<session>/<call id>.log` (default `<agent dir>/opensec/codemode/`): the structured output
22
+ the script received (bash: up to 1 MiB), the exit code and the full-output path, capped per script by
23
+ `PI_CODEMODE_AUDIT_MAX_BYTES` (default 16 MiB). The script's result names the file and says that its filtered
24
+ output is not proof that something is absent; the script's structured result is kept.
25
+
26
+ ### Fixed
27
+
28
+ - **A subagent's `bash` calls made by another tool are bounded too.** The subagent tool timeout only reached calls
29
+ the model made directly; calls a codemode script, `ctx.executeTool()` or a deferred tool made ran without a limit.
30
+ Since 0.20.3 loads codemode in every subagent, that was most of them. The subagent's tool-scope guard now sets the
31
+ timeout on every call path.
32
+ - **Workflow scripts report the right line numbers on Bun.** Bun ignores a negative `lineOffset`, so every stack
33
+ frame pointed one line below the line the author wrote. The wrapper now needs no offset on either runtime.
34
+
10
35
  ## [0.20.5] - 2026-10-16
11
36
 
12
37
  Orchestration results the main agent can act on: failed runs are error results with what they produced, unfinished
package/README.md CHANGED
@@ -313,6 +313,7 @@ To have them follow Pi's project trust decision instead, set `"projectTrust": "p
313
313
  | `backgroundKey` (`subagents.json`) | `ctrl+b` | Key that sends running foreground `Agent` calls to the background (see [Sending a foreground agent to the background](#sending-a-foreground-agent-to-the-background)); `"off"` disables it. |
314
314
  | Model override (`/agents → Settings`, `allowModelOverride`) | `false` | Whether a spawn's own `model` / `thinking` (Agent tool, nested tool, workflow `agent()`, scheduled jobs, RPC) may choose the subagent's model and thinking level. Off: subagents run on the parent's current model and level unless their agent file sets one, and the tools do not offer the parameters. |
315
315
  | Tool timeout (`/agents → Settings`, `toolTimeoutSeconds`) | `1200` | Seconds one subagent tool call may run (`0` = no limit). A `bash` call is killed at the limit and the agent sees the timeout; any other tool still running a minute later is stopped and the agent is told to continue another way (up to 3 times per run). The main agent's `bash` calls get the same timeout when they set none; a timeout the main agent asks for is kept. |
316
+ | `codemodeTimeoutSeconds` (`subagents.json`) | `1800` | Deadline in seconds for a Pi `codemode` script whose first line sets no `timeout_ms` (`0` = none), in the main agent and in subagents; a deadline the script sets is kept. Every call a script makes is logged raw to `$PI_CODEMODE_AUDIT_DIR/<session>/<call id>.log` (default `<agent dir>/opensec/codemode/`, capped at `PI_CODEMODE_AUDIT_MAX_BYTES`, default 16 MiB), and the script's result names the file. |
316
317
 
317
318
  ## Notes
318
319
 
@@ -90,7 +90,8 @@ export interface ToolScopeGuard {
90
90
  setScope(inScope: () => Set<string>): void;
91
91
  }
92
92
  /**
93
- * A `tool_call` handler that blocks out-of-scope tools for every call path.
93
+ * A `tool_call` handler that blocks out-of-scope tools for every call path, and
94
+ * gives in-scope `bash` calls the tool timeout (see `toolTimeoutSeconds`).
94
95
  *
95
96
  * Replacing `session.agent.beforeToolCall` only covers calls the model makes
96
97
  * directly. Pi 1.0 also lets tools call tools (`ctx.executeTool()`, used by
@@ -203,7 +204,10 @@ export declare function boundBashTimeout(args: unknown, limitSeconds: number, ca
203
204
  /**
204
205
  * Bound a subagent session's tool calls (see `toolTimeoutSeconds`).
205
206
  *
206
- * `bash` calls without a timeout, or with a longer one, get the limit. The
207
+ * `bash` calls without a timeout, or with a longer one, get the limit: through
208
+ * the inline `tool_call` guard when the session loaded extensions (every call
209
+ * path), otherwise by wrapping `session.agent.beforeToolCall` (a session without
210
+ * extensions loads no built-ins either, so no tool calls tools). The
207
211
  * watchdog covers every tool: past the limit (plus slack) it aborts the run,
208
212
  * which kills a running command's process tree, and `takeHung()` then reports
209
213
  * the call so the caller can resume the agent with `hungToolPrompt()`. When the
@@ -7,6 +7,7 @@ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
7
7
  import { createAgentSession, createCodemodeExtension, createMcpExtension, createToolSearchExtension, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
8
8
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
9
9
  import { runInChildSessionContext } from "./child-context.js";
10
+ import { boundCodemodeTimeout, CODEMODE_TOOL_NAME, createCodemodeAudit, getCodemodeTimeoutSeconds } from "./codemode-guard.js";
10
11
  import { buildParentContext, extractText } from "./context.js";
11
12
  import { DEFAULT_AGENTS } from "./default-agents.js";
12
13
  import { detectEnv } from "./env.js";
@@ -227,7 +228,8 @@ const TOOL_SCOPE_GUARD_NAME = "subagent-tool-scope";
227
228
  /** The loader path pi gives that inline extension. */
228
229
  export const TOOL_SCOPE_GUARD_PATH = `<inline:${TOOL_SCOPE_GUARD_NAME}>`;
229
230
  /**
230
- * A `tool_call` handler that blocks out-of-scope tools for every call path.
231
+ * A `tool_call` handler that blocks out-of-scope tools for every call path, and
232
+ * gives in-scope `bash` calls the tool timeout (see `toolTimeoutSeconds`).
231
233
  *
232
234
  * Replacing `session.agent.beforeToolCall` only covers calls the model makes
233
235
  * directly. Pi 1.0 also lets tools call tools (`ctx.executeTool()`, used by
@@ -243,11 +245,23 @@ export function createToolScopeGuard() {
243
245
  name: TOOL_SCOPE_GUARD_NAME,
244
246
  hidden: true,
245
247
  factory: (pi) => {
246
- pi.on("tool_call", (event) => {
247
- if (!inScope || inScope().has(event.toolName))
248
- return undefined;
249
- return { block: true, reason: `Tool "${event.toolName}" is not available to this subagent.` };
248
+ const audit = createCodemodeAudit();
249
+ pi.on("tool_call", (event, ctx) => {
250
+ if (inScope && !inScope().has(event.toolName)) {
251
+ return { block: true, reason: `Tool "${event.toolName}" is not available to this subagent.` };
252
+ }
253
+ // Pi executes the same input object, so this bounds the running call,
254
+ // including calls another tool makes (codemode, ctx.executeTool), which
255
+ // never pass through session.agent.beforeToolCall.
256
+ if (event.toolName === "bash")
257
+ boundBashTimeout(event.input, toolTimeoutSeconds, true);
258
+ if (event.toolName === CODEMODE_TOOL_NAME)
259
+ boundCodemodeTimeout(event.input, getCodemodeTimeoutSeconds());
260
+ audit.recordCall(event, ctx);
261
+ return undefined;
250
262
  });
263
+ // Keeps the raw output of every call a codemode script makes; see codemode-guard.ts.
264
+ pi.on("tool_result", (event) => audit.onToolResult(event));
251
265
  },
252
266
  },
253
267
  setScope(fn) {
@@ -470,10 +484,19 @@ export function boundBashTimeout(args, limitSeconds, cap) {
470
484
  call.timeout = limitSeconds;
471
485
  }
472
486
  }
487
+ /**
488
+ * Sessions whose `bash` calls are already bounded by the inline `tool_call`
489
+ * guard. Those must not also get the `beforeToolCall` patch: the guard covers
490
+ * nested calls too, and pi's own `beforeToolCall` is what runs the guard.
491
+ */
492
+ const toolCallBoundSessions = new WeakSet();
473
493
  /**
474
494
  * Bound a subagent session's tool calls (see `toolTimeoutSeconds`).
475
495
  *
476
- * `bash` calls without a timeout, or with a longer one, get the limit. The
496
+ * `bash` calls without a timeout, or with a longer one, get the limit: through
497
+ * the inline `tool_call` guard when the session loaded extensions (every call
498
+ * path), otherwise by wrapping `session.agent.beforeToolCall` (a session without
499
+ * extensions loads no built-ins either, so no tool calls tools). The
477
500
  * watchdog covers every tool: past the limit (plus slack) it aborts the run,
478
501
  * which kills a running command's process tree, and `takeHung()` then reports
479
502
  * the call so the caller can resume the agent with `hungToolPrompt()`. When the
@@ -485,12 +508,15 @@ export function installToolTimeouts(session, limitSeconds = toolTimeoutSeconds)
485
508
  return { takeHung: () => undefined, stuck: new Promise(() => { }), dispose: () => { } };
486
509
  let resolveStuck;
487
510
  const stuck = new Promise(resolve => { resolveStuck = resolve; });
511
+ const patchBeforeToolCall = !toolCallBoundSessions.has(session);
488
512
  const priorBeforeToolCall = session.agent.beforeToolCall;
489
- session.agent.beforeToolCall = async (context, signal) => {
490
- if (context.toolCall.name === "bash")
491
- boundBashTimeout(context.args, limitSeconds, true);
492
- return priorBeforeToolCall?.(context, signal);
493
- };
513
+ if (patchBeforeToolCall) {
514
+ session.agent.beforeToolCall = async (context, signal) => {
515
+ if (context.toolCall.name === "bash")
516
+ boundBashTimeout(context.args, limitSeconds, true);
517
+ return priorBeforeToolCall?.(context, signal);
518
+ };
519
+ }
494
520
  const running = new Map();
495
521
  let hung;
496
522
  let hungAt = 0;
@@ -535,7 +561,8 @@ export function installToolTimeouts(session, limitSeconds = toolTimeoutSeconds)
535
561
  dispose() {
536
562
  clearInterval(timer);
537
563
  unsubscribe();
538
- session.agent.beforeToolCall = priorBeforeToolCall;
564
+ if (patchBeforeToolCall)
565
+ session.agent.beforeToolCall = priorBeforeToolCall;
539
566
  },
540
567
  };
541
568
  }
@@ -1084,6 +1111,8 @@ export async function runAgent(ctx, type, prompt, options) {
1084
1111
  sessionOpts.thinkingLevel = thinkingLevel;
1085
1112
  }
1086
1113
  const { session } = await runInChildSessionContext(() => createAgentSession(sessionOpts));
1114
+ if (toolScopeGuard)
1115
+ toolCallBoundSessions.add(session);
1087
1116
  const baseSessionName = agentConfig?.name ?? type;
1088
1117
  session.setSessionName(options.agentId ? `${baseSessionName}#${options.agentId.slice(0, 8)}` : baseSessionName);
1089
1118
  // Bind extensions so that session_start fires and extensions can initialize
@@ -0,0 +1,44 @@
1
+ import { type ToolResultEvent, type ToolResultEventResult } from "@earendil-works/pi-coding-agent";
2
+ export declare const CODEMODE_TOOL_NAME = "codemode";
3
+ export declare const CODEMODE_TIMEOUT_CEILING = 86400;
4
+ /** Deadline in seconds given to codemode scripts that set none (0 = leave them unbounded). */
5
+ export declare function getCodemodeTimeoutSeconds(): number;
6
+ export declare function setCodemodeTimeoutSeconds(n: number): void;
7
+ /**
8
+ * Give a codemode call a `timeout_ms` when its script sets none. Pi executes the same input
9
+ * object the `tool_call` handlers receive, so this changes the running script.
10
+ *
11
+ * - No options line: one is added. Line numbers in script errors then count from 2.
12
+ * - An options line without `timeout_ms`: the field is added to it.
13
+ * - An options line that sets `timeout_ms`, or that is not a JSON object: left as it is
14
+ * (codemode itself reports a malformed line to the model).
15
+ */
16
+ export declare function boundCodemodeTimeout(input: unknown, seconds: number): void;
17
+ /** Default cap on one script's audit log; set `PI_CODEMODE_AUDIT_MAX_BYTES` to change it. */
18
+ export declare const DEFAULT_AUDIT_MAX_BYTES: number;
19
+ /** Where raw outputs of codemode script calls are kept: `PI_CODEMODE_AUDIT_DIR`, else `<agent dir>/opensec/codemode`. */
20
+ export declare function codemodeAuditRoot(): string;
21
+ interface ToolCallLike {
22
+ toolName: string;
23
+ toolCallId: string;
24
+ }
25
+ interface HandlerCtx {
26
+ sessionManager?: {
27
+ getSessionId?: () => string;
28
+ };
29
+ }
30
+ export interface CodemodeAudit {
31
+ /** Call from the session's (single) `tool_call` handler for every call that is allowed to run. */
32
+ recordCall(event: ToolCallLike, ctx?: HandlerCtx): void;
33
+ /** Register as a `tool_result` handler. */
34
+ onToolResult(event: ToolResultEvent): ToolResultEventResult | undefined;
35
+ }
36
+ /**
37
+ * The audit trail for one session. Every call a codemode script makes (a `tool_result` with a
38
+ * `parentToolCallId`) is appended to `<root>/<session>/<script call id>.log`, and the script's own
39
+ * result gets a line naming that file. Each script's log is capped (`PI_CODEMODE_AUDIT_MAX_BYTES`,
40
+ * default 16 MiB); past the cap only call headers are written. A write failure never fails a
41
+ * tool call; the note then says the raw outputs could not be saved.
42
+ */
43
+ export declare function createCodemodeAudit(): CodemodeAudit;
44
+ export {};
@@ -0,0 +1,189 @@
1
+ // Guards for pi's built-in `codemode` tool (a model-written script that calls other tools).
2
+ //
3
+ // - A deadline: codemode scripts run with no time limit unless the script's first line sets
4
+ // `timeout_ms` (`// @options: {"timeout_ms": …}`), so one stuck call held the agent forever.
5
+ // Scripts that set none get `codemodeTimeoutSeconds` (default 30 minutes).
6
+ // - An audit trail: only the script's own output reaches the model, so a wrong filter can make
7
+ // a real hit look like "0 matches" with no trace. The raw output of every call a script makes
8
+ // is written to disk, and the script's result says where, and that it is not proof of absence.
9
+ import { appendFileSync, mkdirSync } from "node:fs";
10
+ import { join, resolve } from "node:path";
11
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
12
+ export const CODEMODE_TOOL_NAME = "codemode";
13
+ const OPTIONS_PREFIX = "// @options:";
14
+ /** Largest `timeout_ms` codemode accepts (the `setTimeout` limit). */
15
+ const MAX_TIMEOUT_MS = 2_147_483_647;
16
+ export const CODEMODE_TIMEOUT_CEILING = 86_400;
17
+ let codemodeTimeoutSeconds = 1800;
18
+ /** Deadline in seconds given to codemode scripts that set none (0 = leave them unbounded). */
19
+ export function getCodemodeTimeoutSeconds() { return codemodeTimeoutSeconds; }
20
+ export function setCodemodeTimeoutSeconds(n) {
21
+ codemodeTimeoutSeconds = Math.min(CODEMODE_TIMEOUT_CEILING, Math.max(0, Math.floor(n)));
22
+ }
23
+ /**
24
+ * Give a codemode call a `timeout_ms` when its script sets none. Pi executes the same input
25
+ * object the `tool_call` handlers receive, so this changes the running script.
26
+ *
27
+ * - No options line: one is added. Line numbers in script errors then count from 2.
28
+ * - An options line without `timeout_ms`: the field is added to it.
29
+ * - An options line that sets `timeout_ms`, or that is not a JSON object: left as it is
30
+ * (codemode itself reports a malformed line to the model).
31
+ */
32
+ export function boundCodemodeTimeout(input, seconds) {
33
+ if (seconds <= 0 || !input || typeof input !== "object")
34
+ return;
35
+ const call = input;
36
+ if (typeof call.code !== "string" || call.code.trim() === "")
37
+ return;
38
+ const timeoutMs = Math.min(MAX_TIMEOUT_MS, seconds * 1000);
39
+ const code = call.code;
40
+ const newline = code.indexOf("\n");
41
+ const firstLine = (newline === -1 ? code : code.slice(0, newline)).replace(/\r$/, "");
42
+ const trimmed = firstLine.trimStart();
43
+ if (!trimmed.startsWith(OPTIONS_PREFIX)) {
44
+ call.code = `${OPTIONS_PREFIX} ${JSON.stringify({ timeout_ms: timeoutMs })}\n${code}`;
45
+ return;
46
+ }
47
+ let options;
48
+ try {
49
+ options = JSON.parse(trimmed.slice(OPTIONS_PREFIX.length).trim());
50
+ }
51
+ catch {
52
+ return;
53
+ }
54
+ if (!options || typeof options !== "object" || Array.isArray(options))
55
+ return;
56
+ if (options.timeout_ms !== undefined)
57
+ return;
58
+ const rest = newline === -1 ? "" : code.slice(newline);
59
+ call.code = `${OPTIONS_PREFIX} ${JSON.stringify({ ...options, timeout_ms: timeoutMs })}${rest}`;
60
+ }
61
+ /** Default cap on one script's audit log; set `PI_CODEMODE_AUDIT_MAX_BYTES` to change it. */
62
+ export const DEFAULT_AUDIT_MAX_BYTES = 16 * 1024 * 1024;
63
+ function auditMaxBytes() {
64
+ const n = Number(process.env.PI_CODEMODE_AUDIT_MAX_BYTES);
65
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_AUDIT_MAX_BYTES;
66
+ }
67
+ /** Where raw outputs of codemode script calls are kept: `PI_CODEMODE_AUDIT_DIR`, else `<agent dir>/opensec/codemode`. */
68
+ export function codemodeAuditRoot() {
69
+ const configured = process.env.PI_CODEMODE_AUDIT_DIR?.trim();
70
+ return configured ? resolve(configured) : join(getAgentDir(), "opensec", "codemode");
71
+ }
72
+ const safe = (s) => s.replace(/[^A-Za-z0-9._-]/g, "-");
73
+ /**
74
+ * What a script received from one call. A script gets a tool's structured result when it has one:
75
+ * for `bash`, `structuredContent.output` holds up to 1 MiB, where the text the model would see from
76
+ * a direct call stops at 50 KB / 2,000 lines. So log the structured output when there is one, and
77
+ * any path pi wrote the complete output to.
78
+ */
79
+ function rawOutput(event) {
80
+ const meta = [];
81
+ const structured = event.structuredContent;
82
+ let body;
83
+ if (structured && typeof structured === "object" && !Array.isArray(structured)) {
84
+ const record = structured;
85
+ if (typeof record.output === "string") {
86
+ body = record.output;
87
+ if (record.exit_code !== undefined)
88
+ meta.push(`exit_code: ${String(record.exit_code)}`);
89
+ if (record.truncated === true)
90
+ meta.push("truncated: true (the script got the first and last 512 KiB)");
91
+ if (typeof record.full_output_path === "string")
92
+ meta.push(`full output: ${record.full_output_path}`);
93
+ }
94
+ else {
95
+ meta.push(`structured: ${JSON.stringify(structured)}`);
96
+ }
97
+ }
98
+ else if (structured !== undefined) {
99
+ meta.push(`structured: ${JSON.stringify(structured)}`);
100
+ }
101
+ const details = event.details;
102
+ if (details && typeof details === "object") {
103
+ const fullOutputPath = details.fullOutputPath;
104
+ if (typeof fullOutputPath === "string" && !meta.some((m) => m.endsWith(fullOutputPath))) {
105
+ meta.push(`full output: ${fullOutputPath}`);
106
+ }
107
+ }
108
+ if (body === undefined) {
109
+ body = (event.content ?? []).map((c) => (c.type === "text" ? c.text : `[${c.type}]`)).join("\n");
110
+ }
111
+ return { body, meta };
112
+ }
113
+ /**
114
+ * The audit trail for one session. Every call a codemode script makes (a `tool_result` with a
115
+ * `parentToolCallId`) is appended to `<root>/<session>/<script call id>.log`, and the script's own
116
+ * result gets a line naming that file. Each script's log is capped (`PI_CODEMODE_AUDIT_MAX_BYTES`,
117
+ * default 16 MiB); past the cap only call headers are written. A write failure never fails a
118
+ * tool call; the note then says the raw outputs could not be saved.
119
+ */
120
+ export function createCodemodeAudit() {
121
+ const scripts = new Map();
122
+ const recordCall = (event, ctx) => {
123
+ if (event.toolName !== CODEMODE_TOOL_NAME)
124
+ return;
125
+ const session = safe(ctx?.sessionManager?.getSessionId?.() ?? "session");
126
+ scripts.set(event.toolCallId, {
127
+ file: join(codemodeAuditRoot(), session, `${safe(event.toolCallId)}.log`),
128
+ calls: 0,
129
+ bytes: 0,
130
+ capped: false,
131
+ });
132
+ };
133
+ const onToolResult = (event) => {
134
+ if (event.parentToolCallId) {
135
+ const script = scripts.get(event.parentToolCallId);
136
+ if (!script)
137
+ return undefined;
138
+ script.calls += 1;
139
+ const { body, meta } = rawOutput(event);
140
+ const head = [
141
+ `===== call ${script.calls}: ${event.toolName} (${event.toolCallId})${event.isError ? " ERROR" : ""}`,
142
+ `input: ${JSON.stringify(event.input ?? {})}`,
143
+ ...meta,
144
+ ].join("\n");
145
+ const max = auditMaxBytes();
146
+ let entry;
147
+ if (script.capped) {
148
+ entry = `${head}\n[output not saved: log cap of ${max} bytes reached]\n\n`;
149
+ }
150
+ else {
151
+ entry = `${head}\n${body}\n\n`;
152
+ const size = Buffer.byteLength(entry);
153
+ if (script.bytes + size > max) {
154
+ const room = Math.max(0, max - script.bytes - Buffer.byteLength(head) - 200);
155
+ const kept = Buffer.from(body).subarray(0, room).toString();
156
+ entry = `${head}\n${kept}\n[log cap of ${max} bytes reached: the rest of this output and later outputs are not saved]\n\n`;
157
+ script.capped = true;
158
+ }
159
+ }
160
+ script.bytes += Buffer.byteLength(entry);
161
+ try {
162
+ mkdirSync(join(script.file, ".."), { recursive: true, mode: 0o700 });
163
+ appendFileSync(script.file, entry, { mode: 0o600 });
164
+ }
165
+ catch (error) {
166
+ script.failed ??= String(error?.code ?? error);
167
+ }
168
+ return undefined;
169
+ }
170
+ if (event.toolName !== CODEMODE_TOOL_NAME)
171
+ return undefined;
172
+ const script = scripts.get(event.toolCallId);
173
+ scripts.delete(event.toolCallId);
174
+ if (!script || script.calls === 0)
175
+ return undefined;
176
+ const where = script.failed
177
+ ? `could not be saved (${script.failed})`
178
+ : `are in ${script.file}${script.capped ? " (cut off at the log size cap)" : ""}`;
179
+ const note = `[Raw outputs of this script's ${script.calls} tool call${script.calls === 1 ? "" : "s"} ${where}. `
180
+ + "The script's printed output is filtered: it is not proof that something is absent. "
181
+ + "Before concluding absence, check the raw outputs or print the pattern, scope and count.]";
182
+ return {
183
+ content: [...(event.content ?? []), { type: "text", text: note }],
184
+ // Replacing content alone would drop the script's structured result.
185
+ ...(event.structuredContent === undefined ? {} : { structuredContent: event.structuredContent }),
186
+ };
187
+ };
188
+ return { recordCall, onToolResult };
189
+ }
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ import { AgentManager, isTopLevelAgent, managerServices, RECORD_ENTRY_TYPE, RESU
21
21
  import { boundBashTimeout, getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, getToolTimeoutSeconds, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, setToolTimeoutSeconds, steerAgent } from "./agent-runner.js";
22
22
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
23
23
  import { inChildSessionContext } from "./child-context.js";
24
+ import { boundCodemodeTimeout, CODEMODE_TOOL_NAME, createCodemodeAudit, getCodemodeTimeoutSeconds, setCodemodeTimeoutSeconds } from "./codemode-guard.js";
24
25
  import { CompletionOutbox, NOTIFICATION_HOLD_MS } from "./completion-outbox.js";
25
26
  import { registerRpcHandlers } from "./cross-extension-rpc.js";
26
27
  import { loadCustomAgents } from "./custom-agents.js";
@@ -1998,11 +1999,19 @@ export default function (pi) {
1998
1999
  // so an unbounded `grep -r … /` or a server started in the foreground would
1999
2000
  // otherwise hold the turn until someone notices. (Subagents are also capped —
2000
2001
  // see installToolTimeouts in agent-runner.ts.)
2001
- pi.on("tool_call", (event) => {
2002
+ const codemodeAudit = createCodemodeAudit();
2003
+ pi.on("tool_call", (event, ctx) => {
2002
2004
  if (event.toolName === "bash")
2003
2005
  boundBashTimeout(event.input, getToolTimeoutSeconds(), false);
2006
+ // Pi's codemode scripts have no deadline unless they set one; give those that don't
2007
+ // `codemodeTimeoutSeconds` (default 30 minutes). A deadline the script sets is kept.
2008
+ if (event.toolName === CODEMODE_TOOL_NAME)
2009
+ boundCodemodeTimeout(event.input, getCodemodeTimeoutSeconds());
2010
+ codemodeAudit.recordCall(event, ctx);
2004
2011
  return undefined;
2005
2012
  });
2013
+ // Keeps the raw output of every call a codemode script makes; see codemode-guard.ts.
2014
+ pi.on("tool_result", (event) => codemodeAudit.onToolResult(event));
2006
2015
  // The parent's turn is over: spend that arrived after its last subagent tool
2007
2016
  // result (background agents, workflows) reaches the session stats now.
2008
2017
  pi.on("agent_end", (_event, ctx) => {
@@ -2061,6 +2070,7 @@ export default function (pi) {
2061
2070
  setDefaultMaxTurns,
2062
2071
  setGraceTurns,
2063
2072
  setToolTimeoutSeconds,
2073
+ setCodemodeTimeoutSeconds,
2064
2074
  setDefaultJoinMode,
2065
2075
  setBackgroundByDefault,
2066
2076
  setSchedulingEnabled,
@@ -4237,6 +4247,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
4237
4247
  defaultMaxTurns: getDefaultMaxTurns() ?? 0,
4238
4248
  graceTurns: getGraceTurns(),
4239
4249
  toolTimeoutSeconds: getToolTimeoutSeconds(),
4250
+ codemodeTimeoutSeconds: getCodemodeTimeoutSeconds(),
4240
4251
  defaultJoinMode: getDefaultJoinMode(),
4241
4252
  backgroundByDefault: getBackgroundByDefault(),
4242
4253
  schedulingEnabled: isSchedulingEnabled(),
@@ -35,7 +35,7 @@
35
35
  */
36
36
  import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
37
37
  /** The package version this code ships in. `test/session-handoff.test.ts` keeps it equal to package.json. */
38
- export declare const PACKAGE_VERSION = "0.20.5";
38
+ export declare const PACKAGE_VERSION = "0.20.6";
39
39
  /** Bumped when the shape of a parked entry changes within one version (never in a release). */
40
40
  export declare const HANDOFF_PROTOCOL = 1;
41
41
  /** Writes an entry into the session `owner` if the caller can (it is the active one there); false otherwise. */
@@ -36,7 +36,7 @@
36
36
  import { existsSync } from "node:fs";
37
37
  import { SessionManager } from "@earendil-works/pi-coding-agent";
38
38
  /** The package version this code ships in. `test/session-handoff.test.ts` keeps it equal to package.json. */
39
- export const PACKAGE_VERSION = "0.20.5";
39
+ export const PACKAGE_VERSION = "0.20.6";
40
40
  /** Bumped when the shape of a parked entry changes within one version (never in a release). */
41
41
  export const HANDOFF_PROTOCOL = 1;
42
42
  const HANDOFF_KEY = Symbol.for("pi-subagents:handoff:@kushalkhemka/opensec-pi-subagents");
@@ -36,6 +36,11 @@ export interface SubagentsSettings {
36
36
  * default 1200). See `toolTimeoutSeconds` in agent-runner.ts.
37
37
  */
38
38
  toolTimeoutSeconds?: number;
39
+ /**
40
+ * Deadline in seconds for pi's `codemode` scripts that set no `timeout_ms`
41
+ * (0 = leave them unbounded, default 1800). See codemode-guard.ts.
42
+ */
43
+ codemodeTimeoutSeconds?: number;
39
44
  defaultJoinMode?: JoinMode;
40
45
  /**
41
46
  * Whether a top-level `Agent` spawn that doesn't say runs detached.
@@ -330,6 +335,7 @@ export interface SettingsAppliers {
330
335
  setDefaultMaxTurns: (n: number) => void;
331
336
  setGraceTurns: (n: number) => void;
332
337
  setToolTimeoutSeconds: (n: number) => void;
338
+ setCodemodeTimeoutSeconds: (n: number) => void;
333
339
  setDefaultJoinMode: (mode: JoinMode) => void;
334
340
  setBackgroundByDefault: (b: boolean) => void;
335
341
  setSchedulingEnabled: (b: boolean) => void;
package/dist/settings.js CHANGED
@@ -17,6 +17,7 @@ const MAX_CONCURRENT_CEILING = 1024;
17
17
  const MAX_TURNS_CEILING = 10_000;
18
18
  const GRACE_TURNS_CEILING = 1_000;
19
19
  const TOOL_TIMEOUT_CEILING = 86_400;
20
+ const CODEMODE_TIMEOUT_CEILING = 86_400;
20
21
  const SUBAGENT_DEPTH_CEILING = 16;
21
22
  /** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
22
23
  function sanitize(raw) {
@@ -51,6 +52,11 @@ function sanitize(raw) {
51
52
  r.toolTimeoutSeconds <= TOOL_TIMEOUT_CEILING) {
52
53
  out.toolTimeoutSeconds = r.toolTimeoutSeconds;
53
54
  }
55
+ if (Number.isInteger(r.codemodeTimeoutSeconds) &&
56
+ r.codemodeTimeoutSeconds >= 0 &&
57
+ r.codemodeTimeoutSeconds <= CODEMODE_TIMEOUT_CEILING) {
58
+ out.codemodeTimeoutSeconds = r.codemodeTimeoutSeconds;
59
+ }
54
60
  if (Number.isInteger(r.maxSubagentDepth) &&
55
61
  r.maxSubagentDepth >= 0 &&
56
62
  r.maxSubagentDepth <= SUBAGENT_DEPTH_CEILING) {
@@ -200,6 +206,8 @@ export function applySettings(s, appliers) {
200
206
  appliers.setGraceTurns(s.graceTurns);
201
207
  if (typeof s.toolTimeoutSeconds === "number")
202
208
  appliers.setToolTimeoutSeconds(s.toolTimeoutSeconds);
209
+ if (typeof s.codemodeTimeoutSeconds === "number")
210
+ appliers.setCodemodeTimeoutSeconds(s.codemodeTimeoutSeconds);
203
211
  if (typeof s.maxSubagentDepth === "number")
204
212
  appliers.setMaxSubagentDepth(s.maxSubagentDepth);
205
213
  if (typeof s.fallbackSubagent === "string")
@@ -51,9 +51,14 @@
51
51
  /**
52
52
  * Runs inside the realm, ahead of the script body, on a single line.
53
53
  *
54
- * One line matters: the body is compiled at `\n` + line 1, and the host passes
55
- * `lineOffset: -1` so reported line numbers match the file the author wrote. Any
56
- * newline in here shifts every stack frame in every workflow script.
54
+ * One line matters: the body starts on the same line as the wrapper and this
55
+ * prelude, so the script's line N is the compiled source's line N and reported
56
+ * line numbers match the file the author wrote. Any newline in here shifts every
57
+ * stack frame in every workflow script.
58
+ *
59
+ * Same line rather than `\n` + `lineOffset: -1`: Bun's `vm.Script` honours a
60
+ * positive `lineOffset` but ignores a negative one, which put every frame one
61
+ * line below the line the author wrote.
57
62
  */
58
63
  const DETERMINISM_PRELUDE = "const Date = (function () {" +
59
64
  " const RealDate = globalThis.Date;" +
@@ -729,8 +734,8 @@ async function workflowIn(scope, nameOrRef, args) {
729
734
  // \`meta\` is deliberately not a parameter: the body still opens with its
730
735
  // own \`const meta = { ... }\` (extractMeta strips only the \`export\`), so a
731
736
  // parameter of that name would collide with it.
732
- "(async (agent, phase, log, workflow, console, args) => {" + PRELUDE + "\\n" + loaded.body + "\\n})",
733
- { filename: "workflow:" + loaded.name + ".js", lineOffset: -1 }
737
+ "(async (agent, phase, log, workflow, console, args) => {" + PRELUDE + loaded.body + "\\n})",
738
+ { filename: "workflow:" + loaded.name + ".js" }
734
739
  );
735
740
  run = compiled.runInContext(realmContext);
736
741
  } catch (error) {
@@ -802,11 +807,10 @@ async function main() {
802
807
  sandbox.meta = realmParse(workerData.metaJson);
803
808
  sandbox.args = workerData.argsJson === undefined ? undefined : realmParse(workerData.argsJson);
804
809
 
805
- const script = new vm.Script("(async () => {" + PRELUDE + "\\n" + workerData.body + "\\n})()", {
810
+ // The wrapper and prelude share the body's first line (see PRELUDE), so a
811
+ // thrown error points at the line the author wrote with no lineOffset.
812
+ const script = new vm.Script("(async () => {" + PRELUDE + workerData.body + "\\n})()", {
806
813
  filename: "workflow.js",
807
- // The wrapper adds exactly one line above the body; undo it so a thrown
808
- // error points at the line the author wrote.
809
- lineOffset: -1,
810
814
  });
811
815
 
812
816
  const value = await script.runInContext(context);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kushalkhemka/opensec-pi-subagents",
3
- "version": "0.20.5",
3
+ "version": "0.20.6",
4
4
  "description": "OpenSec Subagents for Pi: a fork of @tintinweb/pi-subagents with parent model and thinking-level inheritance and cached agent discovery.",
5
5
  "author": "OpenSec",
6
6
  "contributors": [
@@ -23,6 +23,7 @@ import {
23
23
  } from "@earendil-works/pi-coding-agent";
24
24
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
25
25
  import { runInChildSessionContext } from "./child-context.js";
26
+ import { boundCodemodeTimeout, CODEMODE_TOOL_NAME, createCodemodeAudit, getCodemodeTimeoutSeconds } from "./codemode-guard.js";
26
27
  import { buildParentContext, extractText } from "./context.js";
27
28
  import { DEFAULT_AGENTS } from "./default-agents.js";
28
29
  import { detectEnv } from "./env.js";
@@ -263,7 +264,8 @@ export interface ToolScopeGuard {
263
264
  }
264
265
 
265
266
  /**
266
- * A `tool_call` handler that blocks out-of-scope tools for every call path.
267
+ * A `tool_call` handler that blocks out-of-scope tools for every call path, and
268
+ * gives in-scope `bash` calls the tool timeout (see `toolTimeoutSeconds`).
267
269
  *
268
270
  * Replacing `session.agent.beforeToolCall` only covers calls the model makes
269
271
  * directly. Pi 1.0 also lets tools call tools (`ctx.executeTool()`, used by
@@ -279,10 +281,21 @@ export function createToolScopeGuard(): ToolScopeGuard {
279
281
  name: TOOL_SCOPE_GUARD_NAME,
280
282
  hidden: true,
281
283
  factory: (pi) => {
282
- pi.on("tool_call", (event) => {
283
- if (!inScope || inScope().has(event.toolName)) return undefined;
284
- return { block: true, reason: `Tool "${event.toolName}" is not available to this subagent.` };
284
+ const audit = createCodemodeAudit();
285
+ pi.on("tool_call", (event, ctx) => {
286
+ if (inScope && !inScope().has(event.toolName)) {
287
+ return { block: true, reason: `Tool "${event.toolName}" is not available to this subagent.` };
288
+ }
289
+ // Pi executes the same input object, so this bounds the running call,
290
+ // including calls another tool makes (codemode, ctx.executeTool), which
291
+ // never pass through session.agent.beforeToolCall.
292
+ if (event.toolName === "bash") boundBashTimeout(event.input, toolTimeoutSeconds, true);
293
+ if (event.toolName === CODEMODE_TOOL_NAME) boundCodemodeTimeout(event.input, getCodemodeTimeoutSeconds());
294
+ audit.recordCall(event, ctx);
295
+ return undefined;
285
296
  });
297
+ // Keeps the raw output of every call a codemode script makes; see codemode-guard.ts.
298
+ pi.on("tool_result", (event) => audit.onToolResult(event));
286
299
  },
287
300
  },
288
301
  setScope(fn) {
@@ -540,10 +553,20 @@ export function boundBashTimeout(args: unknown, limitSeconds: number, cap: boole
540
553
  }
541
554
  }
542
555
 
556
+ /**
557
+ * Sessions whose `bash` calls are already bounded by the inline `tool_call`
558
+ * guard. Those must not also get the `beforeToolCall` patch: the guard covers
559
+ * nested calls too, and pi's own `beforeToolCall` is what runs the guard.
560
+ */
561
+ const toolCallBoundSessions = new WeakSet<object>();
562
+
543
563
  /**
544
564
  * Bound a subagent session's tool calls (see `toolTimeoutSeconds`).
545
565
  *
546
- * `bash` calls without a timeout, or with a longer one, get the limit. The
566
+ * `bash` calls without a timeout, or with a longer one, get the limit: through
567
+ * the inline `tool_call` guard when the session loaded extensions (every call
568
+ * path), otherwise by wrapping `session.agent.beforeToolCall` (a session without
569
+ * extensions loads no built-ins either, so no tool calls tools). The
547
570
  * watchdog covers every tool: past the limit (plus slack) it aborts the run,
548
571
  * which kills a running command's process tree, and `takeHung()` then reports
549
572
  * the call so the caller can resume the agent with `hungToolPrompt()`. When the
@@ -560,11 +583,14 @@ export function installToolTimeouts(session: AgentSession, limitSeconds = toolTi
560
583
  let resolveStuck!: () => void;
561
584
  const stuck = new Promise<void>(resolve => { resolveStuck = resolve; });
562
585
 
586
+ const patchBeforeToolCall = !toolCallBoundSessions.has(session);
563
587
  const priorBeforeToolCall = session.agent.beforeToolCall;
564
- session.agent.beforeToolCall = async (context, signal) => {
565
- if (context.toolCall.name === "bash") boundBashTimeout(context.args, limitSeconds, true);
566
- return priorBeforeToolCall?.(context, signal);
567
- };
588
+ if (patchBeforeToolCall) {
589
+ session.agent.beforeToolCall = async (context, signal) => {
590
+ if (context.toolCall.name === "bash") boundBashTimeout(context.args, limitSeconds, true);
591
+ return priorBeforeToolCall?.(context, signal);
592
+ };
593
+ }
568
594
 
569
595
  const running = new Map<string, { toolName: string; started: number }>();
570
596
  let hung: HungTool | undefined;
@@ -608,7 +634,7 @@ export function installToolTimeouts(session: AgentSession, limitSeconds = toolTi
608
634
  dispose() {
609
635
  clearInterval(timer);
610
636
  unsubscribe();
611
- session.agent.beforeToolCall = priorBeforeToolCall;
637
+ if (patchBeforeToolCall) session.agent.beforeToolCall = priorBeforeToolCall;
612
638
  },
613
639
  };
614
640
  }
@@ -1342,6 +1368,7 @@ export async function runAgent(
1342
1368
  }
1343
1369
 
1344
1370
  const { session } = await runInChildSessionContext(() => createAgentSession(sessionOpts));
1371
+ if (toolScopeGuard) toolCallBoundSessions.add(session);
1345
1372
 
1346
1373
  const baseSessionName = agentConfig?.name ?? type;
1347
1374
  session.setSessionName(
@@ -0,0 +1,200 @@
1
+ // Guards for pi's built-in `codemode` tool (a model-written script that calls other tools).
2
+ //
3
+ // - A deadline: codemode scripts run with no time limit unless the script's first line sets
4
+ // `timeout_ms` (`// @options: {"timeout_ms": …}`), so one stuck call held the agent forever.
5
+ // Scripts that set none get `codemodeTimeoutSeconds` (default 30 minutes).
6
+ // - An audit trail: only the script's own output reaches the model, so a wrong filter can make
7
+ // a real hit look like "0 matches" with no trace. The raw output of every call a script makes
8
+ // is written to disk, and the script's result says where, and that it is not proof of absence.
9
+
10
+ import { appendFileSync, mkdirSync } from "node:fs";
11
+ import { join, resolve } from "node:path";
12
+ import { getAgentDir, type ToolResultEvent, type ToolResultEventResult } from "@earendil-works/pi-coding-agent";
13
+
14
+ export const CODEMODE_TOOL_NAME = "codemode";
15
+ const OPTIONS_PREFIX = "// @options:";
16
+ /** Largest `timeout_ms` codemode accepts (the `setTimeout` limit). */
17
+ const MAX_TIMEOUT_MS = 2_147_483_647;
18
+ export const CODEMODE_TIMEOUT_CEILING = 86_400;
19
+
20
+ let codemodeTimeoutSeconds = 1800;
21
+
22
+ /** Deadline in seconds given to codemode scripts that set none (0 = leave them unbounded). */
23
+ export function getCodemodeTimeoutSeconds(): number { return codemodeTimeoutSeconds; }
24
+ export function setCodemodeTimeoutSeconds(n: number): void {
25
+ codemodeTimeoutSeconds = Math.min(CODEMODE_TIMEOUT_CEILING, Math.max(0, Math.floor(n)));
26
+ }
27
+
28
+ /**
29
+ * Give a codemode call a `timeout_ms` when its script sets none. Pi executes the same input
30
+ * object the `tool_call` handlers receive, so this changes the running script.
31
+ *
32
+ * - No options line: one is added. Line numbers in script errors then count from 2.
33
+ * - An options line without `timeout_ms`: the field is added to it.
34
+ * - An options line that sets `timeout_ms`, or that is not a JSON object: left as it is
35
+ * (codemode itself reports a malformed line to the model).
36
+ */
37
+ export function boundCodemodeTimeout(input: unknown, seconds: number): void {
38
+ if (seconds <= 0 || !input || typeof input !== "object") return;
39
+ const call = input as { code?: unknown };
40
+ if (typeof call.code !== "string" || call.code.trim() === "") return;
41
+ const timeoutMs = Math.min(MAX_TIMEOUT_MS, seconds * 1000);
42
+ const code = call.code;
43
+ const newline = code.indexOf("\n");
44
+ const firstLine = (newline === -1 ? code : code.slice(0, newline)).replace(/\r$/, "");
45
+ const trimmed = firstLine.trimStart();
46
+ if (!trimmed.startsWith(OPTIONS_PREFIX)) {
47
+ call.code = `${OPTIONS_PREFIX} ${JSON.stringify({ timeout_ms: timeoutMs })}\n${code}`;
48
+ return;
49
+ }
50
+ let options: unknown;
51
+ try {
52
+ options = JSON.parse(trimmed.slice(OPTIONS_PREFIX.length).trim());
53
+ } catch {
54
+ return;
55
+ }
56
+ if (!options || typeof options !== "object" || Array.isArray(options)) return;
57
+ if ((options as Record<string, unknown>).timeout_ms !== undefined) return;
58
+ const rest = newline === -1 ? "" : code.slice(newline);
59
+ call.code = `${OPTIONS_PREFIX} ${JSON.stringify({ ...(options as object), timeout_ms: timeoutMs })}${rest}`;
60
+ }
61
+
62
+ /** Default cap on one script's audit log; set `PI_CODEMODE_AUDIT_MAX_BYTES` to change it. */
63
+ export const DEFAULT_AUDIT_MAX_BYTES = 16 * 1024 * 1024;
64
+
65
+ function auditMaxBytes(): number {
66
+ const n = Number(process.env.PI_CODEMODE_AUDIT_MAX_BYTES);
67
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_AUDIT_MAX_BYTES;
68
+ }
69
+
70
+ /** Where raw outputs of codemode script calls are kept: `PI_CODEMODE_AUDIT_DIR`, else `<agent dir>/opensec/codemode`. */
71
+ export function codemodeAuditRoot(): string {
72
+ const configured = process.env.PI_CODEMODE_AUDIT_DIR?.trim();
73
+ return configured ? resolve(configured) : join(getAgentDir(), "opensec", "codemode");
74
+ }
75
+
76
+ const safe = (s: string) => s.replace(/[^A-Za-z0-9._-]/g, "-");
77
+
78
+ interface ToolCallLike {
79
+ toolName: string;
80
+ toolCallId: string;
81
+ }
82
+ interface HandlerCtx { sessionManager?: { getSessionId?: () => string } }
83
+
84
+ export interface CodemodeAudit {
85
+ /** Call from the session's (single) `tool_call` handler for every call that is allowed to run. */
86
+ recordCall(event: ToolCallLike, ctx?: HandlerCtx): void;
87
+ /** Register as a `tool_result` handler. */
88
+ onToolResult(event: ToolResultEvent): ToolResultEventResult | undefined;
89
+ }
90
+
91
+ /**
92
+ * What a script received from one call. A script gets a tool's structured result when it has one:
93
+ * for `bash`, `structuredContent.output` holds up to 1 MiB, where the text the model would see from
94
+ * a direct call stops at 50 KB / 2,000 lines. So log the structured output when there is one, and
95
+ * any path pi wrote the complete output to.
96
+ */
97
+ function rawOutput(event: ToolResultEvent): { body: string; meta: string[] } {
98
+ const meta: string[] = [];
99
+ const structured = event.structuredContent;
100
+ let body: string | undefined;
101
+ if (structured && typeof structured === "object" && !Array.isArray(structured)) {
102
+ const record = structured as Record<string, unknown>;
103
+ if (typeof record.output === "string") {
104
+ body = record.output;
105
+ if (record.exit_code !== undefined) meta.push(`exit_code: ${String(record.exit_code)}`);
106
+ if (record.truncated === true) meta.push("truncated: true (the script got the first and last 512 KiB)");
107
+ if (typeof record.full_output_path === "string") meta.push(`full output: ${record.full_output_path}`);
108
+ } else {
109
+ meta.push(`structured: ${JSON.stringify(structured)}`);
110
+ }
111
+ } else if (structured !== undefined) {
112
+ meta.push(`structured: ${JSON.stringify(structured)}`);
113
+ }
114
+ const details = (event as { details?: unknown }).details;
115
+ if (details && typeof details === "object") {
116
+ const fullOutputPath = (details as { fullOutputPath?: unknown }).fullOutputPath;
117
+ if (typeof fullOutputPath === "string" && !meta.some((m) => m.endsWith(fullOutputPath))) {
118
+ meta.push(`full output: ${fullOutputPath}`);
119
+ }
120
+ }
121
+ if (body === undefined) {
122
+ body = (event.content ?? []).map((c) => (c.type === "text" ? c.text : `[${c.type}]`)).join("\n");
123
+ }
124
+ return { body, meta };
125
+ }
126
+
127
+ /**
128
+ * The audit trail for one session. Every call a codemode script makes (a `tool_result` with a
129
+ * `parentToolCallId`) is appended to `<root>/<session>/<script call id>.log`, and the script's own
130
+ * result gets a line naming that file. Each script's log is capped (`PI_CODEMODE_AUDIT_MAX_BYTES`,
131
+ * default 16 MiB); past the cap only call headers are written. A write failure never fails a
132
+ * tool call; the note then says the raw outputs could not be saved.
133
+ */
134
+ export function createCodemodeAudit(): CodemodeAudit {
135
+ const scripts = new Map<string, { file: string; calls: number; bytes: number; capped: boolean; failed?: string }>();
136
+
137
+ const recordCall = (event: ToolCallLike, ctx?: HandlerCtx): void => {
138
+ if (event.toolName !== CODEMODE_TOOL_NAME) return;
139
+ const session = safe(ctx?.sessionManager?.getSessionId?.() ?? "session");
140
+ scripts.set(event.toolCallId, {
141
+ file: join(codemodeAuditRoot(), session, `${safe(event.toolCallId)}.log`),
142
+ calls: 0,
143
+ bytes: 0,
144
+ capped: false,
145
+ });
146
+ };
147
+
148
+ const onToolResult = (event: ToolResultEvent): ToolResultEventResult | undefined => {
149
+ if (event.parentToolCallId) {
150
+ const script = scripts.get(event.parentToolCallId);
151
+ if (!script) return undefined;
152
+ script.calls += 1;
153
+ const { body, meta } = rawOutput(event);
154
+ const head = [
155
+ `===== call ${script.calls}: ${event.toolName} (${event.toolCallId})${event.isError ? " ERROR" : ""}`,
156
+ `input: ${JSON.stringify(event.input ?? {})}`,
157
+ ...meta,
158
+ ].join("\n");
159
+ const max = auditMaxBytes();
160
+ let entry: string;
161
+ if (script.capped) {
162
+ entry = `${head}\n[output not saved: log cap of ${max} bytes reached]\n\n`;
163
+ } else {
164
+ entry = `${head}\n${body}\n\n`;
165
+ const size = Buffer.byteLength(entry);
166
+ if (script.bytes + size > max) {
167
+ const room = Math.max(0, max - script.bytes - Buffer.byteLength(head) - 200);
168
+ const kept = Buffer.from(body).subarray(0, room).toString();
169
+ entry = `${head}\n${kept}\n[log cap of ${max} bytes reached: the rest of this output and later outputs are not saved]\n\n`;
170
+ script.capped = true;
171
+ }
172
+ }
173
+ script.bytes += Buffer.byteLength(entry);
174
+ try {
175
+ mkdirSync(join(script.file, ".."), { recursive: true, mode: 0o700 });
176
+ appendFileSync(script.file, entry, { mode: 0o600 });
177
+ } catch (error) {
178
+ script.failed ??= String((error as { code?: string })?.code ?? error);
179
+ }
180
+ return undefined;
181
+ }
182
+ if (event.toolName !== CODEMODE_TOOL_NAME) return undefined;
183
+ const script = scripts.get(event.toolCallId);
184
+ scripts.delete(event.toolCallId);
185
+ if (!script || script.calls === 0) return undefined;
186
+ const where = script.failed
187
+ ? `could not be saved (${script.failed})`
188
+ : `are in ${script.file}${script.capped ? " (cut off at the log size cap)" : ""}`;
189
+ const note = `[Raw outputs of this script's ${script.calls} tool call${script.calls === 1 ? "" : "s"} ${where}. `
190
+ + "The script's printed output is filtered: it is not proof that something is absent. "
191
+ + "Before concluding absence, check the raw outputs or print the pattern, scope and count.]";
192
+ return {
193
+ content: [...(event.content ?? []), { type: "text", text: note }],
194
+ // Replacing content alone would drop the script's structured result.
195
+ ...(event.structuredContent === undefined ? {} : { structuredContent: event.structuredContent }),
196
+ };
197
+ };
198
+
199
+ return { recordCall, onToolResult };
200
+ }
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ import { AgentManager, type CompactionInfo, isTopLevelAgent, type ManagerObserve
22
22
  import { boundBashTimeout, getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, getToolTimeoutSeconds, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, setToolTimeoutSeconds, steerAgent } from "./agent-runner.js";
23
23
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
24
24
  import { inChildSessionContext } from "./child-context.js";
25
+ import { boundCodemodeTimeout, CODEMODE_TOOL_NAME, createCodemodeAudit, getCodemodeTimeoutSeconds, setCodemodeTimeoutSeconds } from "./codemode-guard.js";
25
26
  import { CompletionOutbox, NOTIFICATION_HOLD_MS } from "./completion-outbox.js";
26
27
  import { type RpcHandle, registerRpcHandlers } from "./cross-extension-rpc.js";
27
28
  import { loadCustomAgents } from "./custom-agents.js";
@@ -2098,10 +2099,17 @@ export default function (pi: ExtensionAPI) {
2098
2099
  // so an unbounded `grep -r … /` or a server started in the foreground would
2099
2100
  // otherwise hold the turn until someone notices. (Subagents are also capped —
2100
2101
  // see installToolTimeouts in agent-runner.ts.)
2101
- pi.on("tool_call", (event) => {
2102
+ const codemodeAudit = createCodemodeAudit();
2103
+ pi.on("tool_call", (event, ctx) => {
2102
2104
  if (event.toolName === "bash") boundBashTimeout(event.input, getToolTimeoutSeconds(), false);
2105
+ // Pi's codemode scripts have no deadline unless they set one; give those that don't
2106
+ // `codemodeTimeoutSeconds` (default 30 minutes). A deadline the script sets is kept.
2107
+ if (event.toolName === CODEMODE_TOOL_NAME) boundCodemodeTimeout(event.input, getCodemodeTimeoutSeconds());
2108
+ codemodeAudit.recordCall(event, ctx);
2103
2109
  return undefined;
2104
2110
  });
2111
+ // Keeps the raw output of every call a codemode script makes; see codemode-guard.ts.
2112
+ pi.on("tool_result", (event) => codemodeAudit.onToolResult(event));
2105
2113
 
2106
2114
  // The parent's turn is over: spend that arrived after its last subagent tool
2107
2115
  // result (background agents, workflows) reaches the session stats now.
@@ -2172,6 +2180,7 @@ export default function (pi: ExtensionAPI) {
2172
2180
  setDefaultMaxTurns,
2173
2181
  setGraceTurns,
2174
2182
  setToolTimeoutSeconds,
2183
+ setCodemodeTimeoutSeconds,
2175
2184
  setDefaultJoinMode,
2176
2185
  setBackgroundByDefault,
2177
2186
  setSchedulingEnabled,
@@ -4500,6 +4509,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
4500
4509
  defaultMaxTurns: getDefaultMaxTurns() ?? 0,
4501
4510
  graceTurns: getGraceTurns(),
4502
4511
  toolTimeoutSeconds: getToolTimeoutSeconds(),
4512
+ codemodeTimeoutSeconds: getCodemodeTimeoutSeconds(),
4503
4513
  defaultJoinMode: getDefaultJoinMode(),
4504
4514
  backgroundByDefault: getBackgroundByDefault(),
4505
4515
  schedulingEnabled: isSchedulingEnabled(),
@@ -38,7 +38,7 @@ import { existsSync } from "node:fs";
38
38
  import { type ExtensionContext, SessionManager } from "@earendil-works/pi-coding-agent";
39
39
 
40
40
  /** The package version this code ships in. `test/session-handoff.test.ts` keeps it equal to package.json. */
41
- export const PACKAGE_VERSION = "0.20.5";
41
+ export const PACKAGE_VERSION = "0.20.6";
42
42
 
43
43
  /** Bumped when the shape of a parked entry changes within one version (never in a release). */
44
44
  export const HANDOFF_PROTOCOL = 1;
package/src/settings.ts CHANGED
@@ -47,6 +47,11 @@ export interface SubagentsSettings {
47
47
  * default 1200). See `toolTimeoutSeconds` in agent-runner.ts.
48
48
  */
49
49
  toolTimeoutSeconds?: number;
50
+ /**
51
+ * Deadline in seconds for pi's `codemode` scripts that set no `timeout_ms`
52
+ * (0 = leave them unbounded, default 1800). See codemode-guard.ts.
53
+ */
54
+ codemodeTimeoutSeconds?: number;
50
55
  defaultJoinMode?: JoinMode;
51
56
  /**
52
57
  * Whether a top-level `Agent` spawn that doesn't say runs detached.
@@ -344,6 +349,7 @@ export interface SettingsAppliers {
344
349
  setDefaultMaxTurns: (n: number) => void;
345
350
  setGraceTurns: (n: number) => void;
346
351
  setToolTimeoutSeconds: (n: number) => void;
352
+ setCodemodeTimeoutSeconds: (n: number) => void;
347
353
  setDefaultJoinMode: (mode: JoinMode) => void;
348
354
  setBackgroundByDefault: (b: boolean) => void;
349
355
  setSchedulingEnabled: (b: boolean) => void;
@@ -382,6 +388,7 @@ const MAX_CONCURRENT_CEILING = 1024;
382
388
  const MAX_TURNS_CEILING = 10_000;
383
389
  const GRACE_TURNS_CEILING = 1_000;
384
390
  const TOOL_TIMEOUT_CEILING = 86_400;
391
+ const CODEMODE_TIMEOUT_CEILING = 86_400;
385
392
  const SUBAGENT_DEPTH_CEILING = 16;
386
393
 
387
394
  /** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
@@ -426,6 +433,13 @@ function sanitize(raw: unknown): SubagentsSettings {
426
433
  ) {
427
434
  out.toolTimeoutSeconds = r.toolTimeoutSeconds as number;
428
435
  }
436
+ if (
437
+ Number.isInteger(r.codemodeTimeoutSeconds) &&
438
+ (r.codemodeTimeoutSeconds as number) >= 0 &&
439
+ (r.codemodeTimeoutSeconds as number) <= CODEMODE_TIMEOUT_CEILING
440
+ ) {
441
+ out.codemodeTimeoutSeconds = r.codemodeTimeoutSeconds as number;
442
+ }
429
443
  if (
430
444
  Number.isInteger(r.maxSubagentDepth) &&
431
445
  (r.maxSubagentDepth as number) >= 0 &&
@@ -579,6 +593,7 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
579
593
  if (typeof s.defaultMaxTurns === "number") appliers.setDefaultMaxTurns(s.defaultMaxTurns);
580
594
  if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns);
581
595
  if (typeof s.toolTimeoutSeconds === "number") appliers.setToolTimeoutSeconds(s.toolTimeoutSeconds);
596
+ if (typeof s.codemodeTimeoutSeconds === "number") appliers.setCodemodeTimeoutSeconds(s.codemodeTimeoutSeconds);
582
597
  if (typeof s.maxSubagentDepth === "number") appliers.setMaxSubagentDepth(s.maxSubagentDepth);
583
598
  if (typeof s.fallbackSubagent === "string") appliers.setFallbackSubagent(s.fallbackSubagent);
584
599
  if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
@@ -52,9 +52,14 @@
52
52
  /**
53
53
  * Runs inside the realm, ahead of the script body, on a single line.
54
54
  *
55
- * One line matters: the body is compiled at `\n` + line 1, and the host passes
56
- * `lineOffset: -1` so reported line numbers match the file the author wrote. Any
57
- * newline in here shifts every stack frame in every workflow script.
55
+ * One line matters: the body starts on the same line as the wrapper and this
56
+ * prelude, so the script's line N is the compiled source's line N and reported
57
+ * line numbers match the file the author wrote. Any newline in here shifts every
58
+ * stack frame in every workflow script.
59
+ *
60
+ * Same line rather than `\n` + `lineOffset: -1`: Bun's `vm.Script` honours a
61
+ * positive `lineOffset` but ignores a negative one, which put every frame one
62
+ * line below the line the author wrote.
58
63
  */
59
64
  const DETERMINISM_PRELUDE =
60
65
  "const Date = (function () {" +
@@ -732,8 +737,8 @@ async function workflowIn(scope, nameOrRef, args) {
732
737
  // \`meta\` is deliberately not a parameter: the body still opens with its
733
738
  // own \`const meta = { ... }\` (extractMeta strips only the \`export\`), so a
734
739
  // parameter of that name would collide with it.
735
- "(async (agent, phase, log, workflow, console, args) => {" + PRELUDE + "\\n" + loaded.body + "\\n})",
736
- { filename: "workflow:" + loaded.name + ".js", lineOffset: -1 }
740
+ "(async (agent, phase, log, workflow, console, args) => {" + PRELUDE + loaded.body + "\\n})",
741
+ { filename: "workflow:" + loaded.name + ".js" }
737
742
  );
738
743
  run = compiled.runInContext(realmContext);
739
744
  } catch (error) {
@@ -805,11 +810,10 @@ async function main() {
805
810
  sandbox.meta = realmParse(workerData.metaJson);
806
811
  sandbox.args = workerData.argsJson === undefined ? undefined : realmParse(workerData.argsJson);
807
812
 
808
- const script = new vm.Script("(async () => {" + PRELUDE + "\\n" + workerData.body + "\\n})()", {
813
+ // The wrapper and prelude share the body's first line (see PRELUDE), so a
814
+ // thrown error points at the line the author wrote with no lineOffset.
815
+ const script = new vm.Script("(async () => {" + PRELUDE + workerData.body + "\\n})()", {
809
816
  filename: "workflow.js",
810
- // The wrapper adds exactly one line above the body; undo it so a thrown
811
- // error points at the line the author wrote.
812
- lineOffset: -1,
813
817
  });
814
818
 
815
819
  const value = await script.runInContext(context);