@trim21/personal-pi-extensions 0.1.20261012010044 → 0.1.20261012031237

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/README.md CHANGED
@@ -31,11 +31,13 @@
31
31
 
32
32
  `codemode` 让模型写一段 JavaScript(作为 async 函数体,`await` 与 `return` 都可用),在
33
33
  进程内的 worker 线程里用 QuickJS wasm VM 执行。VM 里没有 node、文件系统、网络、timer
34
- 或模块加载,脚本的出口只有 `call(name, args)` 与 `fs.read` / `fs.write` 两个文件原语。
34
+ 或模块加载,脚本的出口只有 `call(name, args)`、`fs.read` / `fs.write` 两个文件原语与内建的
35
+ `sleep`。
35
36
 
36
37
  - **嵌套调用经工具总线**:`call("Bash", { command })` 最终执行的是 `Bash` 工具自己的
37
- `execute`,所以工具的审批照常生效(Bash 沙箱外执行会弹自己的提权确认)。codemode
38
- 不额外加确认层:脚本里连发十次调用就是十次工具自己的审批(需要审批的那些)。
38
+ `execute`。**脚本里不弹审批**:需要人工授权的调用(写工作区外、Bash 申请沙箱外执行)
39
+ 在 codemode 内直接以 `CallFailedError` 失败,错误信息提示改用直调;模型直调同一个工具时
40
+ 审批照常出现。此外 codemode 不额外加确认层:不需要审批的调用不会多出任何确认框。
39
41
  - **可调用集合 = 声明了结构化输出的工具**(有 `structuredSchema` 的才进),执行时再与当前
40
42
  active 列表求交,所以 `personalExtensions.disabledTools`、pi 的 `defaultTools` /
41
43
  `--tools`、子代理的工具白名单都同样约束脚本。脚本拿到的返回值必须有确定的形状,因此没给
@@ -60,9 +62,15 @@
60
62
  (脚本可按 `instanceof CallFailedError` 区分调用失败与自身运行期错误)。没有声明输出
61
63
  结构的工具不进集合,`call()` 会报「not available in codemode」。
62
64
  - **脚本接口**:`call` / `CallFailedError` / `ALL_TOOLS` / `fs.read` / `fs.write` /
63
- `text` / `image` / `exit` / `console.*` / `store.set` / `store.get` / `store.list`
64
- (会话内持久的键值表);首行可选 `// @options: {"max_output_tokens": 10000}`。
65
- 脚本没有超时:死循环由调用方中止(Esc)结束,等嵌套调用返回(含用户审批弹窗)多久都不算超时。
65
+ `text` / `image` / `exit` / `sleep` / `console.*` / `store.set` / `store.get` / `store.list`
66
+ (会话内持久的键值表);首行可选
67
+ `// @options: {"max_output_tokens": 10000, "timeout_ms": 600000}`——选项留在脚本里而不是工具参数,
68
+ 因为 pi 的 grammar 约束(`constrainedSampling`)只支持单个自由文本参数,多出来的参数在支持
69
+ grammar 的模型上会被静默忽略。不声明 `timeout_ms` 时脚本没有时间上限:死循环由调用方中止(Esc)
70
+ 结束,等嵌套调用返回(含慢命令)多久都不算超时。声明了 `timeout_ms` 就有墙钟预算(含等待嵌套
71
+ 调用的时间),到点时正在跑的调用被中止、结果以超时结束,已产生的输出与调用记录仍然返回。
72
+ `await sleep(seconds)`(秒,可为小数)是脚本唯一的等待手段,由 worker 线程自己的定时器驱动
73
+ (不经主线程、不产生进度帧),它自己也没有上限——但会被 `timeout_ms` 结束。
66
74
  - **工具描述**里给出每个可调用工具的 `declare function call(name, args): Promise<T>` 重载,
67
75
  参数与返回类型都取自工具自己的 schema,所以模型在写脚本前就知道返回值形状。
68
76
  - **store** 记在每次成功调用工具结果的 `details.store` 上(与 `src/lib/file-reads.ts` 的
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.20261012010044",
3
+ "version": "0.1.20261012031237",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -86,6 +86,8 @@ export interface BwrapExecutionRequest {
86
86
  signal?: AbortSignal;
87
87
  onUpdate?: AgentToolUpdateCallback;
88
88
  ctx: ExtensionContext;
89
+ /** 本次调用不可请求用户审批(codemode 的嵌套调用):提权请求直接失败,不弹框。 */
90
+ unapprovable?: boolean;
89
91
  }
90
92
 
91
93
  /**
@@ -333,6 +335,13 @@ function describeLimits(resolved: ResolvedBwrap): string {
333
335
  */
334
336
  const UNSANDBOXED_DENIED = "User denied unsandboxed execution.";
335
337
 
338
+ /**
339
+ * codemode 内的提权请求:脚本里弹不了审批框(那次工具调用只经 codemode 暴露),
340
+ * 因此直接拒绝,并让模型知道改用直调就能走正常审批。
341
+ */
342
+ const UNSANDBOXED_DENIED_IN_CODEMODE =
343
+ "Unsandboxed execution needs user approval, which codemode cannot ask for. Call Bash directly instead of from a script.";
344
+
336
345
  /** 命令没经沙箱时沿用 prompt 里的说法,给出在沙盒外重跑的手段。 */
337
346
  const SANDBOX_ESCAPE_HATCH =
338
347
  "If the command needs more than that, use the `dangerouslyDisableSandbox` parameter to request unsandboxed execution; the user must approve this request.";
@@ -571,6 +580,7 @@ export class BwrapRuntime {
571
580
  request.description,
572
581
  execCwd,
573
582
  ruleSet,
583
+ request.unapprovable === true,
574
584
  )) === "sandbox";
575
585
  }
576
586
  }
@@ -706,11 +716,16 @@ export class BwrapRuntime {
706
716
  reason: string | undefined,
707
717
  execCwd: string,
708
718
  ruleSet: ApprovalRuleSet,
719
+ unapprovable: boolean,
709
720
  ): Promise<FullAccessGrant> {
710
721
  // hasUI 判定推迟到审批时刻:无 UI 会话弹不了审批框,按用户点 Deny 的标准文案拒绝
711
722
  if (!ctx.hasUI) {
712
723
  throw new Error(UNSANDBOXED_DENIED);
713
724
  }
725
+ // codemode 内一律不弹审批:提权请求直接失败,并提示改用直调
726
+ if (unapprovable) {
727
+ throw new Error(UNSANDBOXED_DENIED_IN_CODEMODE);
728
+ }
714
729
  const decision = await this.approveFullAccessUI(ctx, command, reason, execCwd, ruleSet);
715
730
  // 关闭对话框 = 中断并拒绝,不循环重问
716
731
  if (decision === undefined) {
@@ -127,7 +127,7 @@ export function registerShellTools(bus: ToolBus, pi: ExtensionAPI, runtime: Bwra
127
127
  },
128
128
  { additionalProperties: false },
129
129
  ),
130
- async execute(id, params, signal, onUpdate, ctx) {
130
+ async execute(id, params, signal, onUpdate, ctx, callOptions) {
131
131
  const timeout = params.timeout ?? BASH_DEFAULT_TIMEOUT_MS;
132
132
  if (!Number.isFinite(timeout) || timeout <= 0 || timeout > BASH_MAX_TIMEOUT_MS) {
133
133
  throw new Error(`timeout must be between 1 and ${BASH_MAX_TIMEOUT_MS} milliseconds`);
@@ -147,6 +147,7 @@ export function registerShellTools(bus: ToolBus, pi: ExtensionAPI, runtime: Bwra
147
147
  description: params.description,
148
148
  signal,
149
149
  onUpdate,
150
+ unapprovable: callOptions?.unapprovable,
150
151
  });
151
152
  } catch (error) {
152
153
  if (!(error instanceof Error)) {
@@ -152,6 +152,7 @@ export function renderDeclarations(tools: readonly ToolLike[]): string {
152
152
  "declare function text(value: unknown): void;",
153
153
  "declare function image(value: unknown): void;",
154
154
  "declare function exit(): void;",
155
+ "declare function sleep(seconds: number): Promise<void>;",
155
156
  "declare const store: { set(key: string, value: unknown): void; get(key: string): unknown; list(): string[] };",
156
157
  ].join("\n");
157
158
  }
@@ -36,6 +36,8 @@ const FS_WRITE = "fs.write";
36
36
  export interface FsCallContext {
37
37
  ctx: ExtensionContext;
38
38
  signal?: AbortSignal;
39
+ /** 本次调用不可请求用户审批(脚本里弹不了框):区外写入直接失败。 */
40
+ unapprovable?: boolean;
39
41
  }
40
42
 
41
43
  /** 一次 fs 原语的结果:`value` 回给脚本,`reads` 是本次新增的已读记账(进 details.reads)。 */
@@ -114,6 +116,7 @@ export function createCodemodeFs(options: CodemodeFsOptions): CodemodeFs {
114
116
  },
115
117
  policy,
116
118
  signal: call.signal,
119
+ unapprovable: call.unapprovable,
117
120
  });
118
121
  await mkdir(dirname(absolutePath), { recursive: true });
119
122
  await writeFile(absolutePath, content, { encoding: "utf8", signal: call.signal });
@@ -1,16 +1,17 @@
1
1
  /**
2
2
  * codemode 脚本侧的 prelude:在 QuickJS VM 里先于脚本求值,构建脚本能看到的全部
3
3
  * 能力(`call` / `CallFailedError` / `ALL_TOOLS` / `fs` / `text` / `image` / `exit` /
4
- * `console` / `store`),并把宿主桥接封在闭包里——脚本拿不到 `bridge` 本身。
4
+ * `sleep` / `console` / `store`),并把宿主桥接封在闭包里——脚本拿不到 `bridge` 本身。
5
5
  *
6
6
  * 值与参数过桥时都是 JSON 文本,本侧负责 parse/stringify;异步调用用一个 pending
7
7
  * 表把 id 映射到 promise,由宿主在结果到达时 settle。每次调用失败(工具或 fs 原语)
8
8
  * 都由宿主以 `ok: false` 回报,本侧统一 reject 成 `CallFailedError`(脚本可以按
9
9
  * instanceof 区分「调用失败」与自己的运行期错误)。
10
10
  *
11
- * 求值结果是一个函数 `(bridge, toolsJson, storeJson) => { settle, run, stalled }`。
11
+ * 求值结果是一个函数 `(bridge, toolsJson, storeJson) => { settle, timerDone, run, stalled }`。
12
12
  * `bridge(kind, a, b, c)`:
13
13
  * - `"call"`(id, name, argsJson):工具,以及名字为 `fs.read` / `fs.write` 的 fs 原语
14
+ * - `"sleep"`(id, ms):等待,由 worker 线程自己的定时器在 ms 后调 `timerDone(id)`
14
15
  * - `"output"`("text", text)/("image", data, mimeType)
15
16
  * - `"done"`(ok, valueJsonOrErrorJson, writesJson)
16
17
  */
@@ -259,6 +260,22 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
259
260
  throw EXIT;
260
261
  }
261
262
 
263
+ // 脚本唯一的等待手段:worker 线程在 ms 后调 timerDone(id) 解开它。沙箱里没有别的
264
+ // timer,也没有上限——睡多久由脚本决定,结束只能靠调用方中止。
265
+ function sleep(seconds) {
266
+ if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0) {
267
+ throw new TypeErrorCtor(
268
+ "sleep(seconds) expects a non-negative number of seconds, got " + format(seconds),
269
+ );
270
+ }
271
+ return new Promise((resolve) => {
272
+ const id = nextId++;
273
+ // sleep 只会被 timerDone 成功解开,reject 用不到
274
+ pending.set(id, { resolve, reject: () => {} });
275
+ bridge("sleep", id, Math.round(seconds * 1000));
276
+ });
277
+ }
278
+
262
279
  const console = {};
263
280
  for (const level of ["log", "info", "warn", "error", "debug"]) {
264
281
  console[level] = (...args) => {
@@ -275,6 +292,7 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
275
292
  Object.defineProperty(globalThis, "text", { value: text, enumerable: true });
276
293
  Object.defineProperty(globalThis, "image", { value: image, enumerable: true });
277
294
  Object.defineProperty(globalThis, "exit", { value: exit, enumerable: true });
295
+ Object.defineProperty(globalThis, "sleep", { value: sleep, enumerable: true });
278
296
 
279
297
  return {
280
298
  settle(id, ok, payload) {
@@ -294,6 +312,13 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
294
312
  }
295
313
  entry.resolve(value);
296
314
  },
315
+ // worker 线程的定时器到点后解开一次 sleep(成功路径,不带值)
316
+ timerDone(id) {
317
+ const entry = pending.get(id);
318
+ if (!entry) return;
319
+ pending.delete(id);
320
+ entry.resolve();
321
+ },
297
322
  run(fn) {
298
323
  let promise;
299
324
  try {
@@ -326,7 +351,7 @@ export const PRELUDE_SOURCE = String.raw`(function (bridge, toolsJson, storeJson
326
351
  stringify({
327
352
  name: "Error",
328
353
  message:
329
- "The script is waiting on a promise that can never settle: no tool call is pending, and timers do not exist here.",
354
+ "The script is waiting on a promise that can never settle: no tool call and no sleep is pending.",
330
355
  }),
331
356
  );
332
357
  return true;
@@ -6,8 +6,9 @@
6
6
  * 主线程执行工具后把结果发回去。脚本没有超时,只有调用方的中止:中止时主线程直接
7
7
  * `worker.terminate()`,因此不需要取消消息。
8
8
  *
9
- * worker 边界两侧都做 typebox 校验:worker 里跑的是模型写的代码,回传的消息同样
10
- * 当外部输入对待。
9
+ * 校验只做一个方向:worker 里跑的是模型写的代码,它回传的消息由主线程用 typebox
10
+ * 当外部输入复核;反向的消息是主线程自己构造的,形状由 `HostMessage` 类型约束,
11
+ * worker 只按 `t` 分派。
11
12
  */
12
13
 
13
14
  import { type TSchema, Type } from "typebox";
@@ -32,35 +33,6 @@ const outputItemSchema = Type.Union([
32
33
  Type.Object({ type: Type.Literal("image"), data: Type.String(), mimeType: Type.String() }),
33
34
  ]);
34
35
 
35
- const toolDeclSchema = Type.Object({
36
- name: Type.String(),
37
- description: Type.Optional(Type.String()),
38
- /** 工具的 structuredSchema(TypeBox schema):脚本侧据此渲染 call() 的返回类型。 */
39
- structuredSchema: Type.Optional(Type.Unknown()),
40
- });
41
-
42
- const startSchema = Type.Object({
43
- t: Type.Literal("start"),
44
- code: Type.String(),
45
- tools: Type.Array(toolDeclSchema),
46
- store: Type.Record(Type.String(), Type.Unknown()),
47
- });
48
-
49
- const resultSchema = Type.Union([
50
- Type.Object({
51
- t: Type.Literal("result"),
52
- id: Type.Number(),
53
- ok: Type.Literal(true),
54
- value: Type.Unknown(),
55
- }),
56
- Type.Object({
57
- t: Type.Literal("result"),
58
- id: Type.Number(),
59
- ok: Type.Literal(false),
60
- error: Type.String(),
61
- }),
62
- ]);
63
-
64
36
  const callSchema = Type.Object({
65
37
  t: Type.Literal("call"),
66
38
  id: Type.Number(),
@@ -100,7 +72,6 @@ const doneSchema = Type.Union([
100
72
  }),
101
73
  ]);
102
74
 
103
- const hostMessageSchema = Type.Union([startSchema, resultSchema]);
104
75
  const workerMessageSchema = Type.Union([callSchema, outputFrameSchema, doneSchema]);
105
76
 
106
77
  /** worker 启动数据:注册时编译好的 wasm 模块(结构化克隆可以带它跨线程)。 */
@@ -113,7 +84,7 @@ export interface WorkerBootstrap {
113
84
  export type CodemodeOutputItem =
114
85
  { type: "text"; text: string } | { type: "image"; data: string; mimeType: string };
115
86
 
116
- export type ScriptErrorKind = "script" | "aborted" | "sandbox";
87
+ export type ScriptErrorKind = "script" | "aborted" | "timeout" | "sandbox";
117
88
 
118
89
  export interface ScriptError {
119
90
  kind: ScriptErrorKind;
@@ -155,11 +126,6 @@ function decode<T>(schema: TSchema, value: unknown): DecodeResult<T> {
155
126
  return { ok: true, frame: value as T };
156
127
  }
157
128
 
158
- /** 校验主线程发给 worker 的消息。 */
159
- export function decodeHostMessage(value: unknown): DecodeResult<HostMessage> {
160
- return decode<HostMessage>(hostMessageSchema, value);
161
- }
162
-
163
129
  /** 校验 worker 发回主线程的消息。 */
164
130
  export function decodeWorkerMessage(value: unknown): DecodeResult<WorkerMessage> {
165
131
  return decode<WorkerMessage>(workerMessageSchema, value);
@@ -52,8 +52,18 @@ export interface SandboxRunOptions {
52
52
  toolCallId: string;
53
53
  /** 调用方中止本次执行;脚本没有自己的超时,死循环只能靠它结束。 */
54
54
  signal?: AbortSignal;
55
- /** 每次嵌套调用:由调用方执行工具并把结果回给脚本。 */
56
- onCall(request: { id: number; name: string; args: unknown }): Promise<SandboxCallOutcome>;
55
+ /**
56
+ * 本次执行的墙钟预算(毫秒),含等待嵌套调用的时间;缺省不设上限。
57
+ * 到点后执行以 `kind: "timeout"` 结束,正在跑的嵌套调用收到中止信号。
58
+ */
59
+ timeoutMs?: number;
60
+ /** 每次嵌套调用:由调用方执行工具并把结果回给脚本。`signal` 含调用方中止与本次预算。 */
61
+ onCall(request: {
62
+ id: number;
63
+ name: string;
64
+ args: unknown;
65
+ signal: AbortSignal;
66
+ }): Promise<SandboxCallOutcome>;
57
67
  /** 脚本流式产生的输出项,用于 toolcall 进度。 */
58
68
  onOutput?(items: readonly CodemodeOutputItem[]): void;
59
69
  /** 嵌套调用的开始与结束,用于 toolcall 进度;事件里带截至此刻的调用记录快照。 */
@@ -108,6 +118,15 @@ function runInWorker(wasm: object, options: SandboxRunOptions): Promise<SandboxO
108
118
  const started = new Map<number, { name: string; at: number }>();
109
119
  let callSequence = 0;
110
120
  let settled = false;
121
+ let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
122
+
123
+ // 超时与调用方中止合成一条信号,交给嵌套调用:预算到点时正在跑的工具也要停,
124
+ // 否则 terminate 只杀 worker,工具会在主线程继续跑完并留下副作用
125
+ const aborter = new AbortController();
126
+ const signal =
127
+ options.signal === undefined
128
+ ? aborter.signal
129
+ : AbortSignal.any([options.signal, aborter.signal]);
111
130
 
112
131
  // 进度帧发出去的是拷贝:记录随后会被改成终态,已经发出去的帧不能被它改掉
113
132
  const snapshotCalls = (): ScriptCall[] => calls.map((call) => ({ ...call }));
@@ -121,6 +140,7 @@ function runInWorker(wasm: object, options: SandboxRunOptions): Promise<SandboxO
121
140
  return;
122
141
  }
123
142
  settled = true;
143
+ clearTimeout(timeoutTimer);
124
144
  void worker.terminate();
125
145
  resolve({ ...body, output, calls });
126
146
  };
@@ -143,6 +163,15 @@ function runInWorker(wasm: object, options: SandboxRunOptions): Promise<SandboxO
143
163
  );
144
164
  }
145
165
 
166
+ if (options.timeoutMs !== undefined) {
167
+ const timeoutMs = options.timeoutMs;
168
+ timeoutTimer = setTimeout(() => {
169
+ // 先中止正在跑的嵌套调用,再定结果:terminate 不会自动停主线程里的工具
170
+ aborter.abort();
171
+ fail(`Execution timed out after ${timeoutMs} ms`, "timeout");
172
+ }, timeoutMs);
173
+ }
174
+
146
175
  const handleCall = async (id: number, name: string, args: unknown): Promise<void> => {
147
176
  callSequence += 1;
148
177
  const record: ScriptCall = {
@@ -157,7 +186,7 @@ function runInWorker(wasm: object, options: SandboxRunOptions): Promise<SandboxO
157
186
  options.onCallProgress?.({ phase: "start", name, args, calls: snapshotCalls() });
158
187
  let outcome: SandboxCallOutcome;
159
188
  try {
160
- outcome = await options.onCall({ id, name, args });
189
+ outcome = await options.onCall({ id, name, args, signal });
161
190
  } catch (error) {
162
191
  outcome = { ok: false, error: error instanceof Error ? error.message : String(error) };
163
192
  }
@@ -1,12 +1,15 @@
1
1
  /**
2
- * codemode 脚本源码的解析:首行可选的 `// @options: {"max_output_tokens":…}`。
2
+ * codemode 脚本源码的解析:首行可选的 `// @options: {"max_output_tokens":…, "timeout_ms":…}`。
3
3
  *
4
4
  * 选项行与脚本首行共用一行,解析后原样保留为空行,脚本里报错的行号因此与用户写的
5
5
  * 一致。解析失败(空输入、JSON 非法、未知字段、只有选项行没有代码)直接报错。
6
6
  */
7
7
 
8
8
  const OPTIONS_PREFIX = "// @options:";
9
- const SUPPORTED_FIELDS = ["max_output_tokens"] as const;
9
+ const SUPPORTED_FIELDS = ["max_output_tokens", "timeout_ms"] as const;
10
+
11
+ /** `timeout_ms` 的上限:`setTimeout` 能接受的最大延时(毫秒)。 */
12
+ const MAX_TIMEOUT_MS = 2_147_483_647;
10
13
 
11
14
  export const DEFAULT_OUTPUT_TOKENS = 10_000;
12
15
 
@@ -19,6 +22,8 @@ export class CodemodeSourceError extends Error {
19
22
 
20
23
  export interface CodemodeSourceOptions {
21
24
  maxOutputTokens: number;
25
+ /** 本次执行的墙钟预算(毫秒)。不声明时脚本没有时间上限。 */
26
+ timeoutMs: number;
22
27
  }
23
28
 
24
29
  export interface ParsedCodemodeSource {
@@ -45,6 +50,20 @@ function parseOptions(json: string): Partial<CodemodeSourceOptions> {
45
50
  `${OPTIONS_PREFIX} does not support ${JSON.stringify(key)}; supported fields are ${SUPPORTED_FIELDS.map((field) => `\`${field}\``).join(" and ")}`,
46
51
  );
47
52
  }
53
+ if (key === "timeout_ms") {
54
+ if (
55
+ typeof value !== "number" ||
56
+ !Number.isSafeInteger(value) ||
57
+ value <= 0 ||
58
+ value > MAX_TIMEOUT_MS
59
+ ) {
60
+ throw new CodemodeSourceError(
61
+ `${OPTIONS_PREFIX} \`timeout_ms\` must be a positive integer up to ${MAX_TIMEOUT_MS}`,
62
+ );
63
+ }
64
+ options.timeoutMs = value;
65
+ continue;
66
+ }
48
67
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
49
68
  throw new CodemodeSourceError(`${OPTIONS_PREFIX} ${key} must be a positive number`);
50
69
  }
@@ -113,6 +113,12 @@ function buildDescription(tools: readonly CallableTool[]): string {
113
113
  " fields you need into `text(value)` / `return` — e.g. `text(result.items.map((item) => item.title))`.",
114
114
  "- Do not stringify whole results (`JSON.stringify(result)`) or print every field the tool happened to",
115
115
  " return: urls, ids and metadata you will not use cost context and buy nothing.",
116
+ "- `await sleep(seconds)` waits that many seconds (fractions allowed). It is the only way to",
117
+ ' wait: the sandbox has no timers, and it beats `call("Bash", { command: "sleep 1" })`.',
118
+ "- Nested calls cannot ask for user approval: a call that would need it (writing outside the",
119
+ " workspace, unsandboxed Bash) fails. Make those calls directly instead of from a script.",
120
+ '- `// @options: {"timeout_ms": N}` bounds the whole run in milliseconds; without it the script',
121
+ " has no time limit beyond the user aborting it.",
116
122
  "- `store.set(key, value)`, `store.get(key)` and `store.list()` are a small key/value store that",
117
123
  " persists across codemode calls in this session; `store.set(key, undefined)` removes a key.",
118
124
  "- The sandbox has no file system, network, timers or Node APIs: calling tools is its only",
@@ -292,9 +298,12 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
292
298
  parameters: Type.Object({
293
299
  code: Type.String({
294
300
  description:
295
- 'Raw JavaScript source. Top-level await and return work. May start with a `// @options: {"max_output_tokens": 10000}` line.',
301
+ 'Raw JavaScript source. Top-level await and return work. May start with a `// @options: {"max_output_tokens": 10000, "timeout_ms": 600000}` line.',
296
302
  }),
297
303
  }),
304
+ // 采样约束与 pi 内置 codemode 同形:grammar 只能约束一个自由文本参数,所以
305
+ // options 只能待在脚本里(多参数会让这个工具在支持 grammar 的模型上退化成
306
+ // 单字段 custom tool,额外参数被静默忽略)
298
307
  constrainedSampling: {
299
308
  type: "grammar",
300
309
  variants: { openai_lark: CODEMODE_SOURCE_GRAMMAR },
@@ -304,10 +313,12 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
304
313
  async execute(toolCallId, params, signal, onUpdate, ctx) {
305
314
  let code: string;
306
315
  let maxOutputTokens = DEFAULT_OUTPUT_TOKENS;
316
+ let timeoutMs: number | undefined;
307
317
  try {
308
318
  const parsed = parseCodemodeSource(params.code);
309
319
  code = parsed.code;
310
320
  maxOutputTokens = parsed.options.maxOutputTokens ?? maxOutputTokens;
321
+ timeoutMs = parsed.options.timeoutMs;
311
322
  } catch (error) {
312
323
  const message = error instanceof Error ? error.message : String(error);
313
324
  return {
@@ -333,6 +344,7 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
333
344
  store: readStore(ctx),
334
345
  toolCallId,
335
346
  signal,
347
+ timeoutMs,
336
348
  onOutput: (items) => {
337
349
  const text = textOf(items);
338
350
  if (text === "") {
@@ -362,10 +374,15 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
362
374
  },
363
375
  });
364
376
  },
365
- onCall: async ({ name, args }) => {
377
+ onCall: async ({ name, args, signal: callSignal }) => {
366
378
  if (fs.handles(name)) {
367
379
  try {
368
- const result = await fs.execute(name, args, { ctx, signal });
380
+ // 脚本里弹不了审批:需要授权的操作(工作区外写入)直接失败
381
+ const result = await fs.execute(name, args, {
382
+ ctx,
383
+ signal: callSignal,
384
+ unapprovable: true,
385
+ });
369
386
  if (result.reads !== undefined) {
370
387
  Object.assign(recordedReads, result.reads);
371
388
  }
@@ -380,7 +397,7 @@ export function createCodemodeTools(pi: ExtensionAPI): CodemodeTools {
380
397
  if (!callable.has(name)) {
381
398
  return { ok: false, error: `Tool "${name}" is not available in codemode.` };
382
399
  }
383
- return bus.executeCodemodeTool(name, args, { ctx, signal });
400
+ return bus.executeCodemodeTool(name, args, { ctx, signal: callSignal });
384
401
  },
385
402
  });
386
403