sakura-agent 0.1.0 → 0.1.1

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 (91) hide show
  1. package/README.md +144 -78
  2. package/README.zh-CN.md +145 -79
  3. package/THIRD_PARTY_NOTICES.md +8 -58
  4. package/dist/packages/agent/src/index.d.ts +49 -11
  5. package/dist/packages/agent/src/index.js +50 -55
  6. package/dist/packages/agent/src/restrict-tools.d.ts +6 -0
  7. package/dist/packages/agent/src/restrict-tools.js +11 -0
  8. package/dist/packages/agent/src/system-prompt.d.ts +5 -0
  9. package/dist/packages/agent/src/system-prompt.js +1 -1
  10. package/dist/packages/agent/src/wake.d.ts +41 -0
  11. package/dist/packages/agent/src/wake.js +71 -0
  12. package/dist/packages/env/src/confine.d.ts +13 -0
  13. package/dist/packages/env/src/confine.js +40 -0
  14. package/dist/packages/env/src/connect-ssh.d.ts +33 -0
  15. package/dist/packages/env/src/connect-ssh.js +132 -0
  16. package/dist/packages/env/src/index.d.ts +5 -1
  17. package/dist/packages/env/src/index.js +3 -1
  18. package/dist/packages/env/src/local.d.ts +11 -2
  19. package/dist/packages/env/src/local.js +42 -10
  20. package/dist/packages/env/src/remote.d.ts +17 -1
  21. package/dist/packages/env/src/remote.js +54 -4
  22. package/dist/packages/evals/src/bfcl.d.ts +24 -0
  23. package/dist/packages/evals/src/bfcl.js +71 -0
  24. package/dist/packages/evals/src/index.d.ts +3 -0
  25. package/dist/packages/evals/src/index.js +2 -0
  26. package/dist/packages/evals/src/png.d.ts +2 -0
  27. package/dist/packages/evals/src/png.js +30 -0
  28. package/dist/packages/evals/src/pool.d.ts +3 -0
  29. package/dist/packages/evals/src/pool.js +22 -0
  30. package/dist/packages/evals/src/providers.d.ts +13 -0
  31. package/dist/packages/evals/src/providers.js +23 -0
  32. package/dist/packages/kernel/src/compaction.d.ts +19 -0
  33. package/dist/packages/kernel/src/compaction.js +12 -2
  34. package/dist/packages/kernel/src/goal.d.ts +10 -1
  35. package/dist/packages/kernel/src/goal.js +28 -0
  36. package/dist/packages/kernel/src/hooks.d.ts +5 -0
  37. package/dist/packages/kernel/src/hooks.js +21 -1
  38. package/dist/packages/kernel/src/index.d.ts +3 -2
  39. package/dist/packages/kernel/src/index.js +1 -0
  40. package/dist/packages/kernel/src/loop.d.ts +28 -4
  41. package/dist/packages/kernel/src/loop.js +69 -47
  42. package/dist/packages/kernel/src/types.d.ts +25 -4
  43. package/dist/packages/kernel/src/usage.d.ts +3 -0
  44. package/dist/packages/kernel/src/usage.js +16 -0
  45. package/dist/packages/llm/src/chat/request.js +22 -4
  46. package/dist/packages/llm/src/chat/retry.d.ts +1 -1
  47. package/dist/packages/llm/src/chat/retry.js +1 -1
  48. package/dist/packages/llm/src/index.js +7 -2
  49. package/dist/packages/plugins/context/src/index.d.ts +2 -1
  50. package/dist/packages/plugins/context/src/index.js +2 -1
  51. package/dist/packages/plugins/limits/src/index.d.ts +4 -1
  52. package/dist/packages/plugins/redaction/src/index.d.ts +3 -0
  53. package/dist/packages/plugins/redaction/src/index.js +3 -0
  54. package/dist/packages/plugins/redaction/src/rules.gen.js +1 -1
  55. package/dist/packages/plugins/review/src/chain.d.ts +2 -0
  56. package/dist/packages/plugins/review/src/chain.js +2 -1
  57. package/dist/packages/plugins/review/src/hard-rules.d.ts +6 -0
  58. package/dist/packages/plugins/review/src/hard-rules.js +58 -25
  59. package/dist/packages/plugins/review/src/index.d.ts +13 -0
  60. package/dist/packages/plugins/review/src/index.js +9 -5
  61. package/dist/packages/plugins/tools/src/builtin/bash.js +24 -10
  62. package/dist/packages/plugins/toolset/src/codemode.d.ts +17 -6
  63. package/dist/packages/plugins/toolset/src/codemode.js +84 -26
  64. package/dist/packages/plugins/toolset/src/index.d.ts +1 -1
  65. package/dist/packages/plugins/toolset/src/index.js +1 -1
  66. package/dist/packages/plugins/trace/src/index.d.ts +27 -0
  67. package/dist/packages/plugins/trace/src/index.js +29 -0
  68. package/dist/packages/plugins/trace/src/modules.d.ts +6 -0
  69. package/dist/packages/plugins/trace/src/modules.js +28 -0
  70. package/dist/packages/session/src/file-log.d.ts +6 -1
  71. package/dist/packages/session/src/file-log.js +45 -25
  72. package/dist/packages/session/src/fork.d.ts +16 -0
  73. package/dist/packages/session/src/fork.js +70 -0
  74. package/dist/packages/session/src/index.d.ts +2 -0
  75. package/dist/packages/session/src/index.js +1 -0
  76. package/dist/packages/session/src/log-core.d.ts +37 -0
  77. package/dist/packages/session/src/log-core.js +47 -0
  78. package/dist/packages/session/src/memory-log.d.ts +3 -12
  79. package/dist/packages/session/src/memory-log.js +3 -23
  80. package/dist/packages/session/src/projection.js +2 -0
  81. package/dist/packages/testing/src/faux.d.ts +36 -0
  82. package/dist/packages/testing/src/faux.js +69 -0
  83. package/dist/packages/testing/src/index.d.ts +4 -0
  84. package/dist/packages/testing/src/index.js +2 -0
  85. package/dist/packages/testing/src/rng.d.ts +7 -0
  86. package/dist/packages/testing/src/rng.js +17 -0
  87. package/dist/sdk/index.d.ts +11 -3
  88. package/dist/sdk/index.js +5 -1
  89. package/dist/sdk/testing.d.ts +2 -0
  90. package/dist/sdk/testing.js +2 -0
  91. package/package.json +5 -1
@@ -5,7 +5,7 @@ export const defaultRetry = {
5
5
  baseDelayMs: 500,
6
6
  maxDelayMs: 32_000,
7
7
  maxServerDelayMs: 60_000,
8
- maxStallRetries: 1,
8
+ maxStallRetries: 4,
9
9
  sleep: (ms, signal) => new Promise((resolve, reject) => {
10
10
  if (signal.aborted)
11
11
  return reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
@@ -16,8 +16,13 @@ async function* watchIdle(source, ms, cancel) {
16
16
  const stalled = new Promise((resolve) => {
17
17
  timer = setTimeout(() => resolve("stalled"), ms);
18
18
  });
19
- const result = await Promise.race([next, stalled]);
20
- clearTimeout(timer);
19
+ let result;
20
+ try {
21
+ result = await Promise.race([next, stalled]);
22
+ }
23
+ finally {
24
+ clearTimeout(timer); // also when the read fails (a cancel, a broken connection): a timer left running keeps the process alive
25
+ }
21
26
  if (result === "stalled") {
22
27
  cancel();
23
28
  throw new StalledStreamError(Math.round(ms / 1000));
@@ -21,6 +21,7 @@ export interface ContextPlugin extends Plugin {
21
21
  /**
22
22
  * Puts project instructions (AGENTS.md) at the start of the conversation as a pinned message, once.
23
23
  * They survive compaction and are not summarised. Only the AGENTS.md name is understood; there is no
24
- * parent-directory search, no per-directory loading and no @path includes.
24
+ * parent-directory search, no per-directory loading and no @path includes. A changed file replaces the instructions after the next compaction;
25
+ * a file that is removed or emptied does not take them back.
25
26
  */
26
27
  export declare function contextPlugin(options?: ContextOptions): ContextPlugin;
@@ -13,7 +13,8 @@ async function readSource(env, source, maxBytes) {
13
13
  /**
14
14
  * Puts project instructions (AGENTS.md) at the start of the conversation as a pinned message, once.
15
15
  * They survive compaction and are not summarised. Only the AGENTS.md name is understood; there is no
16
- * parent-directory search, no per-directory loading and no @path includes.
16
+ * parent-directory search, no per-directory loading and no @path includes. A changed file replaces the instructions after the next compaction;
17
+ * a file that is removed or emptied does not take them back.
17
18
  */
18
19
  export function contextPlugin(options = {}) {
19
20
  const fileName = options.fileName ?? "AGENTS.md";
@@ -8,7 +8,10 @@ export interface Budget {
8
8
  maxTokens?: number;
9
9
  /** Stop after this much spend in the session; needs `prices`. */
10
10
  maxCostUsd?: number;
11
- /** Stop a single run that has been going this long. */
11
+ /**
12
+ * Stop a single run that has been going this long. This is checked at the end of each turn, not during one: a turn that is under way (a model
13
+ * request, a command) finishes first, and what stops those is their own limits. So it is a budget, not a hard deadline.
14
+ */
12
15
  maxRunSeconds?: number;
13
16
  }
14
17
  export interface LimitsOptions extends Budget {
@@ -20,6 +20,9 @@ export interface RedactionPlugin extends Plugin {
20
20
  * Keeps secrets away from the model. Every request is scanned; secrets become stable placeholders, and the model's
21
21
  * reply (text and tool arguments) gets the real values back before anything runs or is saved. If scanning fails the
22
22
  * request is not sent. Off unless you add it.
23
+ *
24
+ * It protects what is sent to the model provider, not what is stored here: the log (the session file), the events and any trace keep the real values,
25
+ * because the tools need them and a resumed session is read from the log. Use `scrub` on anything you export or share.
23
26
  */
24
27
  export declare function redactionPlugin(options?: RedactionOptions): RedactionPlugin;
25
28
  export type { SecretRule } from "./engine.js";
@@ -9,6 +9,9 @@ const MAX_REMEMBERED = 5000;
9
9
  * Keeps secrets away from the model. Every request is scanned; secrets become stable placeholders, and the model's
10
10
  * reply (text and tool arguments) gets the real values back before anything runs or is saved. If scanning fails the
11
11
  * request is not sent. Off unless you add it.
12
+ *
13
+ * It protects what is sent to the model provider, not what is stored here: the log (the session file), the events and any trace keep the real values,
14
+ * because the tools need them and a resumed session is read from the log. Use `scrub` on anything you export or share.
12
15
  */
13
16
  export function redactionPlugin(options = {}) {
14
17
  const engine = createEngine({ ...(options.key ? { key: options.key } : {}), ...(options.rules ? { rules: options.rules } : {}) });
@@ -1,4 +1,4 @@
1
- // Generated by scripts/gen-secret-rules.py from the gitleaks rule table.
1
+ // Generated from the gitleaks rule table.
2
2
  // gitleaks: https://github.com/gitleaks/gitleaks, MIT License, Copyright (c) 2019 Zachary Rice.
3
3
  // The licence text is in licenses/gitleaks-MIT.txt. Do not edit by hand.
4
4
  export const gitleaksRules = [
@@ -13,6 +13,8 @@ export interface ReviewContext {
13
13
  log: Log;
14
14
  signal: AbortSignal;
15
15
  tool(name: string): Tool | undefined;
16
+ /** The working directory the call runs in. Paths that leave it are not waved through as read-only. */
17
+ cwd?: string;
16
18
  }
17
19
  /** One link of the chain. `pass` hands the call to the next link. */
18
20
  export interface Authorizer {
@@ -1,3 +1,4 @@
1
+ import { describeFailure } from "../../../kernel/src/index.js";
1
2
  /**
2
3
  * Asks each authorizer in turn. The first answer that is not `pass` wins. The chain always ends in an answer:
3
4
  * if every authorizer passes (or one fails), the call is denied. An empty chain is the caller's choice to skip review.
@@ -11,7 +12,7 @@ export async function runChain(authorizers, call, context) {
11
12
  catch (error) {
12
13
  return {
13
14
  kind: "deny",
14
- reason: `The safety check '${authorizer.name}' failed (${error instanceof Error ? error.message : String(error)}), so the action was not allowed.`,
15
+ reason: `The safety check '${authorizer.name}' failed (${describeFailure(error)}), so the action was not allowed.`,
15
16
  decidedBy: authorizer.name,
16
17
  };
17
18
  }
@@ -1,7 +1,13 @@
1
1
  import type { Authorizer } from "./chain.js";
2
+ /** Whether a word contains a variable or command substitution, so what it stands for cannot be told from the text. */
3
+ export declare function mayExpand(word: string): boolean;
4
+ export declare function mayLeaveDirectory(word: string, cwd: string | undefined): boolean;
2
5
  export declare function mentionsSensitivePath(text: string): boolean;
3
6
  /**
4
7
  * Deterministic rules, no model: read-only calls are allowed, catastrophic ones are refused, everything else
5
8
  * is passed on. Reading credentials counts as sensitive and is passed on instead of allowed.
6
9
  */
10
+ export declare function createHardRules(options?: {
11
+ directoryRules?: boolean;
12
+ }): Authorizer;
7
13
  export declare const hardRules: Authorizer;
@@ -1,3 +1,4 @@
1
+ import { posix } from "node:path";
1
2
  import { parseShell } from "./shell.js";
2
3
  const READ_ONLY = new Set([
3
4
  "ls",
@@ -88,6 +89,31 @@ const CATASTROPHIC = [
88
89
  [new RegExp(`${RUNS}(shutdown|reboot|halt|poweroff)(\\s|[);&|]|$)`), "shutting the machine down"],
89
90
  [/(curl|wget)\b[^|;&]*\|\s*(sudo\s+)?(sh|bash|zsh|dash|ksh|python3?|perl|ruby|node)\b/, "running downloaded code without looking at it"],
90
91
  ];
92
+ /**
93
+ * Whether a word of a command may point outside the working directory, or cannot be told. Such a word sends a read-only call on to the
94
+ * next link (the reviewer) instead of waving it through. Only the text is read: a symbolic link inside the directory is not followed.
95
+ */
96
+ const optionValue = (word) => (word.startsWith("-") ? (word.includes("=") ? word.slice(word.indexOf("=") + 1) : "") : word);
97
+ /** Whether a word contains a variable or command substitution, so what it stands for cannot be told from the text. */
98
+ export function mayExpand(word) {
99
+ return /[$`]/.test(optionValue(word));
100
+ }
101
+ export function mayLeaveDirectory(word, cwd) {
102
+ const value = optionValue(word);
103
+ if (value === "")
104
+ return false;
105
+ if (/[$`]/.test(value) || value.startsWith("~"))
106
+ return true;
107
+ if (value.startsWith("/")) {
108
+ if (cwd === undefined)
109
+ return true;
110
+ const root = posix.normalize(cwd).replace(/\/+$/, "");
111
+ const target = posix.normalize(value);
112
+ return !(target === root || target.startsWith(`${root}/`));
113
+ }
114
+ const target = posix.normalize(value);
115
+ return target === ".." || target.startsWith("../");
116
+ }
91
117
  export function mentionsSensitivePath(text) {
92
118
  return SENSITIVE.test(text);
93
119
  }
@@ -95,29 +121,36 @@ export function mentionsSensitivePath(text) {
95
121
  * Deterministic rules, no model: read-only calls are allowed, catastrophic ones are refused, everything else
96
122
  * is passed on. Reading credentials counts as sensitive and is passed on instead of allowed.
97
123
  */
98
- export const hardRules = {
99
- name: "hard-rules",
100
- async review(call, { tool }) {
101
- const args = (typeof call.args === "object" && call.args !== null ? call.args : {});
102
- if (call.name === "bash" || tool(call.name)?.effect === "exec") {
103
- const command = typeof args.command === "string" ? args.command : "";
104
- for (const [pattern, what] of CATASTROPHIC) {
105
- if (pattern.test(command))
106
- return { kind: "deny", reason: `This command looks like ${what}. It is never allowed.` };
124
+ export function createHardRules(options = {}) {
125
+ const directoryRules = options.directoryRules !== false;
126
+ return {
127
+ name: "hard-rules",
128
+ async review(call, { tool, cwd }) {
129
+ const outside = (word) => (directoryRules ? mayLeaveDirectory(word, cwd) : mayExpand(word));
130
+ const args = (typeof call.args === "object" && call.args !== null ? call.args : {});
131
+ if (call.name === "bash" || tool(call.name)?.effect === "exec") {
132
+ const command = typeof args.command === "string" ? args.command : "";
133
+ for (const [pattern, what] of CATASTROPHIC) {
134
+ if (pattern.test(command))
135
+ return { kind: "deny", reason: `This command looks like ${what}. It is never allowed.` };
136
+ }
137
+ const parsed = parseShell(command);
138
+ if (parsed.complex || parsed.writesFile || parsed.commands.length === 0)
139
+ return { kind: "pass" };
140
+ if (!parsed.commands.every(commandIsReadOnly))
141
+ return { kind: "pass" };
142
+ if (mentionsSensitivePath(command))
143
+ return { kind: "pass" };
144
+ if (parsed.commands.some((words) => words.slice(1).some(outside)))
145
+ return { kind: "pass" };
146
+ return { kind: "allow", reason: "read-only command" };
107
147
  }
108
- const parsed = parseShell(command);
109
- if (parsed.complex || parsed.writesFile || parsed.commands.length === 0)
110
- return { kind: "pass" };
111
- if (!parsed.commands.every(commandIsReadOnly))
112
- return { kind: "pass" };
113
- if (mentionsSensitivePath(command))
114
- return { kind: "pass" };
115
- return { kind: "allow", reason: "read-only command" };
116
- }
117
- if (tool(call.name)?.effect === "read") {
118
- const path = typeof args.path === "string" ? args.path : "";
119
- return mentionsSensitivePath(path) ? { kind: "pass" } : { kind: "allow", reason: "read-only tool" };
120
- }
121
- return { kind: "pass" };
122
- },
123
- };
148
+ if (tool(call.name)?.effect === "read") {
149
+ const path = typeof args.path === "string" ? args.path : "";
150
+ return mentionsSensitivePath(path) || outside(path) ? { kind: "pass" } : { kind: "allow", reason: "read-only tool" };
151
+ }
152
+ return { kind: "pass" };
153
+ },
154
+ };
155
+ }
156
+ export const hardRules = createHardRules();
@@ -16,6 +16,12 @@ export interface ReviewOptions {
16
16
  /** The model that reviews. Usually the same one as the agent's. */
17
17
  llm: LlmClient;
18
18
  policy?: PolicyInput;
19
+ /**
20
+ * By default a read-only command or tool that points outside the working directory is not allowed by the hard rules but sent to the reviewer.
21
+ * `false` removes that rule, for an application where the agent works on the whole machine and has no working directory in that sense. The other rules
22
+ * stay: commands that write, expansions the text cannot show, credentials and the catastrophic patterns.
23
+ */
24
+ directoryRules?: boolean;
19
25
  /** Extra authorizers, asked after the hard rules and before the reviewer. */
20
26
  authorizers?: Authorizer[];
21
27
  timeoutMs?: number;
@@ -27,6 +33,8 @@ export interface AuthorizeContext {
27
33
  log: Log;
28
34
  signal: AbortSignal;
29
35
  tool(name: string): Tool | undefined;
36
+ /** The working directory the call runs in (optional; without it any absolute path counts as outside). */
37
+ cwd?: string;
30
38
  }
31
39
  export interface ReviewPlugin extends Plugin {
32
40
  /**
@@ -36,6 +44,11 @@ export interface ReviewPlugin extends Plugin {
36
44
  authorize(call: ToolCall, context: AuthorizeContext): Promise<Extract<Decision, {
37
45
  kind: "allow" | "deny";
38
46
  }>>;
47
+ /**
48
+ * Stops the loop from reviewing calls to this tool itself. For a tool that only runs other tools and has each of them reviewed through `authorize`
49
+ * (code mode's `run_code`), so one script is not reviewed twice. `createAgent` does this for code mode; a plugin wired by hand is reviewed as a whole unless told.
50
+ */
51
+ exempt(toolName: string): void;
39
52
  }
40
53
  /**
41
54
  * Automatic review of tool calls, with nobody to ask: hard rules, then any extra authorizers, then a reviewer
@@ -1,5 +1,5 @@
1
1
  import { runChain } from "./chain.js";
2
- import { hardRules } from "./hard-rules.js";
2
+ import { createHardRules } from "./hard-rules.js";
3
3
  import { resolvePolicy } from "./policy.js";
4
4
  import { createReviewer } from "./reviewer.js";
5
5
  const stable = (value) => JSON.stringify(value, (_key, inner) => (inner && typeof inner === "object" && !Array.isArray(inner) ? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => a.localeCompare(b))) : inner));
@@ -13,7 +13,7 @@ export function reviewPlugin(options) {
13
13
  const states = new WeakMap();
14
14
  const fresh = () => ({ verdicts: new Map(), recent: [], consecutive: 0, tripped: false });
15
15
  const reviewer = createReviewer({ llm: options.llm, policy, ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}) });
16
- const chain = [hardRules, ...(options.authorizers ?? []), reviewer];
16
+ const chain = [createHardRules({ directoryRules: options.directoryRules !== false }), ...(options.authorizers ?? []), reviewer];
17
17
  function record(state, refused) {
18
18
  state.recent.push(refused);
19
19
  if (state.recent.length > (options.breaker?.window?.size ?? 50))
@@ -24,6 +24,7 @@ export function reviewPlugin(options) {
24
24
  state.tripped = true;
25
25
  }
26
26
  let emit = () => undefined;
27
+ const exempted = new Set();
27
28
  async function authorize(call, context) {
28
29
  const state = states.get(context.log) ?? fresh();
29
30
  states.set(context.log, state);
@@ -31,13 +32,15 @@ export function reviewPlugin(options) {
31
32
  return { kind: "deny", reason: "Too many actions were refused in this run, so further actions are blocked until the owner looks at it." };
32
33
  const key = `${call.name}\u0000${stable(call.args)}`;
33
34
  const cached = state.verdicts.get(key);
34
- const result = cached ? { ...cached, decidedBy: "cache" } : await runChain(chain, call, { log: context.log, signal: context.signal, tool: context.tool });
35
+ const result = cached
36
+ ? { ...cached, decidedBy: "cache" }
37
+ : await runChain(chain, call, { log: context.log, signal: context.signal, tool: context.tool, ...(context.cwd !== undefined ? { cwd: context.cwd } : {}) });
35
38
  if (!cached) {
36
39
  if (result.decidedBy === "reviewer")
37
40
  state.verdicts.set(key, { kind: result.kind, reason: result.reason });
38
41
  record(state, result.kind === "deny");
39
42
  }
40
- emit({ kind: "review", tool: call.name, outcome: result.kind, decidedBy: result.decidedBy, reason: result.reason });
43
+ emit({ kind: "review", tool: call.name, callId: call.id, outcome: result.kind, decidedBy: result.decidedBy, reason: result.reason });
41
44
  if (result.kind === "allow")
42
45
  return { kind: "allow" };
43
46
  return { kind: "deny", reason: `${result.reason} Do not try the same action again; choose another approach, or tell the user what you need.` };
@@ -46,12 +49,13 @@ export function reviewPlugin(options) {
46
49
  name: "review",
47
50
  compensates: "models act without anyone checking the risky ones",
48
51
  authorize,
52
+ exempt: (toolName) => void exempted.add(toolName),
49
53
  async prepare({ log }) {
50
54
  states.set(log, fresh()); // decisions are remembered within one run only
51
55
  },
52
56
  setup(api) {
53
57
  emit = api.emit;
54
- api.on("tool.before", (call, context) => authorize(call, context));
58
+ api.on("tool.before", (call, context) => exempted.has(call.name) ? undefined : authorize(call, { log: context.log, signal: context.signal, tool: context.tool, cwd: context.env.cwd }));
55
59
  api.on("turn.end", ({ log }) => (states.get(log)?.tripped ? { pause: "approval" } : undefined));
56
60
  },
57
61
  };
@@ -20,13 +20,15 @@ export function createBashTool(options = {}) {
20
20
  "A command that is still running after 120 seconds is moved to the background. You then receive its pid, the path of its output file and how to stop it. " +
21
21
  "Set `background: true` to start a server or a long job and return at once; you are told when it ends. " +
22
22
  "To wait for something, run `sleep N`; a command that starts with `sleep N` gets N extra seconds before it is moved to the background. " +
23
- "Optional `timeout` (seconds) kills the command when it is reached; there is no default timeout.",
23
+ "Optional `timeout` (seconds) kills the command when it is reached; there is no default timeout. " +
24
+ "A command that runs in the background tells you when it ends, in your next turn. Set `wake: true` to be woken up for it even if you have nothing else to do by then (to act on the result when it ends), or `wake: false` if you do not need to be. If you leave it out, the application's own setting applies.",
24
25
  parameters: {
25
26
  type: "object",
26
27
  properties: {
27
28
  command: { type: "string", description: "The shell command to run" },
28
29
  timeout: { type: "number", description: "Kill the command after this many seconds" },
29
30
  background: { type: "boolean", description: "Start the command and return at once, without waiting for it to end" },
31
+ wake: { type: "boolean", description: "For a command that runs in the background: wake you up when it ends (true), or not (false)" },
30
32
  },
31
33
  required: ["command"],
32
34
  },
@@ -37,6 +39,7 @@ export function createBashTool(options = {}) {
37
39
  const command = requireString(args, "command");
38
40
  const timeout = optionalNumber(args, "timeout", 0);
39
41
  const background = optionalBoolean(args, "background") === true;
42
+ const wake = optionalBoolean(args, "wake");
40
43
  const handle = env.spawn(command, { signal });
41
44
  let timedOut = false;
42
45
  const hardTimer = timeout !== undefined
@@ -47,21 +50,32 @@ export function createBashTool(options = {}) {
47
50
  : undefined;
48
51
  const soft = background ? graceMs : softLimitFor(command, softBase); // in the background it still gets a moment, so a command that fails at once is reported as a failure
49
52
  let softTimer;
50
- const raced = await Promise.race([
51
- handle.wait().then((result) => ({ background: false, result })),
52
- new Promise((resolve) => {
53
- softTimer = setTimeout(() => resolve({ background: true }), soft);
54
- }),
55
- ]);
53
+ let raced;
54
+ try {
55
+ raced = await Promise.race([
56
+ handle.wait().then((result) => ({ background: false, result })),
57
+ new Promise((resolve) => {
58
+ softTimer = setTimeout(() => resolve({ background: true }), soft);
59
+ }),
60
+ ]);
61
+ }
62
+ catch (error) {
63
+ // the environment lost contact with the command: no timer may be left running, and the model is told what happened
64
+ clearTimeout(softTimer);
65
+ if (hardTimer)
66
+ clearTimeout(hardTimer);
67
+ throw error;
68
+ }
56
69
  clearTimeout(softTimer);
57
70
  if (raced.background) {
58
71
  // the hard timeout, if one was given, keeps running and still stops the command
59
72
  // tell the agent when it ends, so it does not have to keep looking at the output file
60
- const ended = watch(`background command (pid ${handle.pid})`);
73
+ const ended = watch(`background command (pid ${handle.pid})`, wake === undefined ? undefined : { wake });
61
74
  void handle.wait().then((finished) => {
62
75
  const how = finished.timedOut || timedOut ? "was stopped by its timeout" : finished.exitCode === null ? "was stopped" : `finished with exit code ${finished.exitCode}`;
63
- ended(`The background command (pid ${handle.pid}) ${how}. Its output is in ${handle.outputFile}.`);
64
- });
76
+ // a command that was stopped because the run was cancelled must not start the agent again: whoever cancelled wanted it to stop
77
+ ended(`The background command (pid ${handle.pid}) ${how}. Its output is in ${handle.outputFile}.`, signal.aborted ? { wake: false } : undefined);
78
+ }, (error) => ended(`The background command (pid ${handle.pid}) could not be followed to its end: ${error instanceof Error ? error.message : String(error)}`));
65
79
  const seen = await env.readFile(handle.outputFile).then((bytes) => new TextDecoder().decode(bytes), () => "");
66
80
  const tail = foldTail(seen, { maxLines: 20, maxBytes: 4000 }).text;
67
81
  return text((background
@@ -7,6 +7,7 @@ export type Authorize = (call: ToolCall, context: {
7
7
  log: Log;
8
8
  signal: AbortSignal;
9
9
  tool(name: string): Tool | undefined;
10
+ cwd?: string;
10
11
  }) => Promise<{
11
12
  kind: "allow";
12
13
  } | {
@@ -16,21 +17,31 @@ export type Authorize = (call: ToolCall, context: {
16
17
  export interface CodemodeOptions {
17
18
  /** The tools the script can call as `tools.<name>(args)`. */
18
19
  catalog: Tool[];
19
- /** Tools with these effects can be called from a script (default: only "read"). Others raise an error inside the script. */
20
+ /** Tools with these effects can be called from a script (default: all of them). Others raise an error inside the script. */
20
21
  allowEffects?: ToolEffect[];
21
- /** Asked before every tool call a script makes, as the loop's hooks cannot see them. A refusal raises an error inside the script. Without it, only the effect rule above protects. */
22
+ /** Asked before every tool call a script makes, as the loop's hooks cannot see them. A refusal raises an error inside the script. Without it, every allowed call simply runs. */
22
23
  authorize?: Authorize;
23
- /** The script is stopped after this long (default 10 seconds). */
24
+ /**
25
+ * The script is stopped after running this long (default 10 seconds). This is the script's own time: time spent running JavaScript, and time spent
26
+ * waiting while no tool call is under way. Time spent waiting for a tool call does not count, because tools have limits of their own.
27
+ */
24
28
  timeoutMs?: number;
25
- /** Memory limit of the sandbox in bytes (default 64 MB). */
29
+ /** A limit on the whole run of the script, tool calls included. Not set by default. */
30
+ totalTimeoutMs?: number;
31
+ /** Memory limit of the script runtime in bytes (default 64 MB). */
26
32
  memoryBytes?: number;
27
33
  /** Longest text returned to the model, in characters; the middle is cut (default 20000). */
28
34
  maxOutputChars?: number;
29
35
  }
36
+ /** The name of the tool that runs a script. Whoever needs to refer to it takes it from here. */
37
+ export declare const CODEMODE_TOOL = "run_code";
30
38
  /**
31
39
  * Lets the model write one JavaScript program that calls tools, instead of one call per request. The program runs in QuickJS
32
40
  * (WebAssembly), which has no access to the machine: only the tools in the catalog, and only those whose effect is allowed.
33
- * Calls made from the script do not pass through the hooks of the loop. Two things stand in for them: only read-only tools are allowed by default,
34
- * and `authorize` (for example the review plugin's) is asked before each call.
41
+ * Calls made from the script do not pass through the hooks of the loop, so `authorize` (for example the review plugin's) is asked
42
+ * before each call. With no `authorize`, every call runs, as a direct call would with no review installed.
43
+ *
44
+ * The runtime only keeps the script itself away from the machine. The tools it calls (a shell command, a file write, an MCP tool) run outside the
45
+ * runtime with their full power, so a call from a script is exactly as risky as the same call made directly, and needs the same review.
35
46
  */
36
47
  export declare function codemodePlugin(options: CodemodeOptions): Plugin;