sakura-agent 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +207 -77
  2. package/README.zh-CN.md +208 -78
  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 +45 -0
  15. package/dist/packages/env/src/connect-ssh.js +242 -0
  16. package/dist/packages/env/src/index.d.ts +6 -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-stream.d.ts +48 -0
  21. package/dist/packages/env/src/remote-stream.js +143 -0
  22. package/dist/packages/env/src/remote-types.d.ts +17 -0
  23. package/dist/packages/env/src/remote-types.js +10 -0
  24. package/dist/packages/env/src/remote.d.ts +16 -7
  25. package/dist/packages/env/src/remote.js +73 -10
  26. package/dist/packages/evals/src/bfcl.d.ts +24 -0
  27. package/dist/packages/evals/src/bfcl.js +71 -0
  28. package/dist/packages/evals/src/index.d.ts +3 -0
  29. package/dist/packages/evals/src/index.js +2 -0
  30. package/dist/packages/evals/src/png.d.ts +2 -0
  31. package/dist/packages/evals/src/png.js +30 -0
  32. package/dist/packages/evals/src/pool.d.ts +3 -0
  33. package/dist/packages/evals/src/pool.js +22 -0
  34. package/dist/packages/evals/src/providers.d.ts +13 -0
  35. package/dist/packages/evals/src/providers.js +23 -0
  36. package/dist/packages/kernel/src/compaction.d.ts +24 -3
  37. package/dist/packages/kernel/src/compaction.js +23 -16
  38. package/dist/packages/kernel/src/goal.d.ts +10 -1
  39. package/dist/packages/kernel/src/goal.js +28 -0
  40. package/dist/packages/kernel/src/hooks.d.ts +5 -0
  41. package/dist/packages/kernel/src/hooks.js +21 -1
  42. package/dist/packages/kernel/src/index.d.ts +3 -2
  43. package/dist/packages/kernel/src/index.js +1 -0
  44. package/dist/packages/kernel/src/loop.d.ts +28 -4
  45. package/dist/packages/kernel/src/loop.js +69 -47
  46. package/dist/packages/kernel/src/types.d.ts +25 -4
  47. package/dist/packages/kernel/src/usage.d.ts +3 -0
  48. package/dist/packages/kernel/src/usage.js +16 -0
  49. package/dist/packages/llm/src/chat/errors.d.ts +11 -0
  50. package/dist/packages/llm/src/chat/errors.js +17 -0
  51. package/dist/packages/llm/src/chat/request.js +22 -4
  52. package/dist/packages/llm/src/chat/retry.d.ts +1 -1
  53. package/dist/packages/llm/src/chat/retry.js +1 -1
  54. package/dist/packages/llm/src/chat/stream.js +2 -1
  55. package/dist/packages/llm/src/index.js +7 -2
  56. package/dist/packages/plugins/context/src/index.d.ts +2 -1
  57. package/dist/packages/plugins/context/src/index.js +2 -1
  58. package/dist/packages/plugins/limits/src/index.d.ts +4 -1
  59. package/dist/packages/plugins/redaction/src/index.d.ts +3 -0
  60. package/dist/packages/plugins/redaction/src/index.js +3 -0
  61. package/dist/packages/plugins/redaction/src/rules.gen.js +1 -1
  62. package/dist/packages/plugins/review/src/chain.d.ts +10 -1
  63. package/dist/packages/plugins/review/src/chain.js +7 -4
  64. package/dist/packages/plugins/review/src/hard-rules.d.ts +6 -0
  65. package/dist/packages/plugins/review/src/hard-rules.js +58 -25
  66. package/dist/packages/plugins/review/src/index.d.ts +21 -0
  67. package/dist/packages/plugins/review/src/index.js +42 -7
  68. package/dist/packages/plugins/review/src/reviewer.d.ts +32 -3
  69. package/dist/packages/plugins/review/src/reviewer.js +115 -27
  70. package/dist/packages/plugins/tools/src/builtin/bash.js +24 -10
  71. package/dist/packages/plugins/tools/src/builtin/image.d.ts +11 -0
  72. package/dist/packages/plugins/tools/src/builtin/image.js +84 -0
  73. package/dist/packages/plugins/tools/src/builtin/read.js +26 -12
  74. package/dist/packages/plugins/toolset/src/codemode.d.ts +17 -6
  75. package/dist/packages/plugins/toolset/src/codemode.js +84 -26
  76. package/dist/packages/plugins/toolset/src/index.d.ts +1 -1
  77. package/dist/packages/plugins/toolset/src/index.js +1 -1
  78. package/dist/packages/plugins/trace/src/index.d.ts +27 -0
  79. package/dist/packages/plugins/trace/src/index.js +29 -0
  80. package/dist/packages/plugins/trace/src/modules.d.ts +6 -0
  81. package/dist/packages/plugins/trace/src/modules.js +28 -0
  82. package/dist/packages/session/src/file-log.d.ts +6 -1
  83. package/dist/packages/session/src/file-log.js +45 -25
  84. package/dist/packages/session/src/fork.d.ts +16 -0
  85. package/dist/packages/session/src/fork.js +70 -0
  86. package/dist/packages/session/src/index.d.ts +2 -0
  87. package/dist/packages/session/src/index.js +1 -0
  88. package/dist/packages/session/src/log-core.d.ts +37 -0
  89. package/dist/packages/session/src/log-core.js +47 -0
  90. package/dist/packages/session/src/memory-log.d.ts +3 -12
  91. package/dist/packages/session/src/memory-log.js +3 -23
  92. package/dist/packages/session/src/projection.js +22 -3
  93. package/dist/packages/testing/src/faux.d.ts +36 -0
  94. package/dist/packages/testing/src/faux.js +69 -0
  95. package/dist/packages/testing/src/index.d.ts +4 -0
  96. package/dist/packages/testing/src/index.js +2 -0
  97. package/dist/packages/testing/src/rng.d.ts +7 -0
  98. package/dist/packages/testing/src/rng.js +17 -0
  99. package/dist/sdk/index.d.ts +11 -3
  100. package/dist/sdk/index.js +5 -1
  101. package/dist/sdk/testing.d.ts +2 -0
  102. package/dist/sdk/testing.js +2 -0
  103. package/package.json +7 -2
@@ -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
@@ -0,0 +1,11 @@
1
+ /** The image formats that model APIs take. Anything else must be converted or refused before it is sent: a refused image is refused for good. */
2
+ export declare const SENDABLE_IMAGE_TYPES: readonly ["image/png", "image/jpeg", "image/gif", "image/webp"];
3
+ export type ImageType = (typeof SENDABLE_IMAGE_TYPES)[number] | "image/bmp";
4
+ /** The format a file really has, read from its first bytes (a file name proves nothing). Undefined when it is not an image we know. */
5
+ export declare function sniffImageType(bytes: Uint8Array): ImageType | undefined;
6
+ /**
7
+ * Turns an uncompressed 24-bit or 32-bit BMP (the kind programs write when they dump a frame) into a PNG. Undefined for anything else: a palette,
8
+ * run-length coding, 16 bits, unusual colour masks, a truncated file. The caller then tells the model the file cannot be shown. The alpha byte of a
9
+ * 32-bit BMP is dropped: in this form it is almost always unused (zero) and would make the picture invisible.
10
+ */
11
+ export declare function bmpToPng(bytes: Uint8Array): Uint8Array | undefined;
@@ -0,0 +1,84 @@
1
+ import { crc32, deflateSync } from "node:zlib";
2
+ /** The image formats that model APIs take. Anything else must be converted or refused before it is sent: a refused image is refused for good. */
3
+ export const SENDABLE_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
4
+ const startsWith = (bytes, signature, at = 0) => signature.every((value, index) => bytes[at + index] === value);
5
+ /** The format a file really has, read from its first bytes (a file name proves nothing). Undefined when it is not an image we know. */
6
+ export function sniffImageType(bytes) {
7
+ if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
8
+ return "image/png";
9
+ if (startsWith(bytes, [0xff, 0xd8, 0xff]))
10
+ return "image/jpeg";
11
+ if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38]))
12
+ return "image/gif"; // "GIF8"
13
+ if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes, [0x57, 0x45, 0x42, 0x50], 8))
14
+ return "image/webp"; // "RIFF....WEBP"
15
+ if (startsWith(bytes, [0x42, 0x4d]))
16
+ return "image/bmp"; // "BM"
17
+ return undefined;
18
+ }
19
+ /** A guard against a header that promises a picture too large to hold in memory. */
20
+ const MAX_PIXELS = 50_000_000;
21
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
22
+ function pngChunk(type, data) {
23
+ const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
24
+ const length = Buffer.alloc(4);
25
+ length.writeUInt32BE(data.length);
26
+ const checksum = Buffer.alloc(4);
27
+ checksum.writeUInt32BE(crc32(body));
28
+ return Buffer.concat([length, body, checksum]);
29
+ }
30
+ /**
31
+ * Turns an uncompressed 24-bit or 32-bit BMP (the kind programs write when they dump a frame) into a PNG. Undefined for anything else: a palette,
32
+ * run-length coding, 16 bits, unusual colour masks, a truncated file. The caller then tells the model the file cannot be shown. The alpha byte of a
33
+ * 32-bit BMP is dropped: in this form it is almost always unused (zero) and would make the picture invisible.
34
+ */
35
+ export function bmpToPng(bytes) {
36
+ if (sniffImageType(bytes) !== "image/bmp" || bytes.length < 54)
37
+ return undefined;
38
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
39
+ const dataOffset = view.getUint32(10, true);
40
+ const headerSize = view.getUint32(14, true);
41
+ if (headerSize < 40)
42
+ return undefined; // the old 12-byte header is not handled
43
+ const width = view.getInt32(18, true);
44
+ const rawHeight = view.getInt32(22, true);
45
+ const bitsPerPixel = view.getUint16(28, true);
46
+ const compression = view.getUint32(30, true);
47
+ const height = Math.abs(rawHeight);
48
+ const topDown = rawHeight < 0;
49
+ if (width <= 0 || height === 0 || width * height > MAX_PIXELS)
50
+ return undefined;
51
+ if (bitsPerPixel !== 24 && bitsPerPixel !== 32)
52
+ return undefined;
53
+ if (compression === 3) {
54
+ // BI_BITFIELDS: only the usual layout (red 0x00FF0000, green 0x0000FF00, blue 0x000000FF) is understood
55
+ if (bitsPerPixel !== 32 || bytes.length < 66 || view.getUint32(54, true) !== 0x00ff0000 || view.getUint32(58, true) !== 0x0000ff00 || view.getUint32(62, true) !== 0x000000ff)
56
+ return undefined;
57
+ }
58
+ else if (compression !== 0) {
59
+ return undefined;
60
+ }
61
+ const bytesPerPixel = bitsPerPixel / 8;
62
+ const rowSize = Math.ceil((width * bitsPerPixel) / 32) * 4;
63
+ if (dataOffset + rowSize * height > bytes.length)
64
+ return undefined;
65
+ // PNG rows: a filter byte (0 = none) and then red, green, blue for every pixel
66
+ const scanline = 1 + width * 3;
67
+ const raw = Buffer.alloc(scanline * height);
68
+ for (let row = 0; row < height; row++) {
69
+ const source = dataOffset + (topDown ? row : height - 1 - row) * rowSize;
70
+ const target = row * scanline + 1;
71
+ for (let column = 0; column < width; column++) {
72
+ const pixel = source + column * bytesPerPixel;
73
+ raw[target + column * 3] = bytes[pixel + 2] ?? 0;
74
+ raw[target + column * 3 + 1] = bytes[pixel + 1] ?? 0;
75
+ raw[target + column * 3 + 2] = bytes[pixel] ?? 0;
76
+ }
77
+ }
78
+ const header = Buffer.alloc(13);
79
+ header.writeUInt32BE(width, 0);
80
+ header.writeUInt32BE(height, 4);
81
+ header[8] = 8; // bit depth
82
+ header[9] = 2; // colour type: truecolour
83
+ return new Uint8Array(Buffer.concat([PNG_SIGNATURE, pngChunk("IHDR", header), pngChunk("IDAT", deflateSync(raw)), pngChunk("IEND", Buffer.alloc(0))]));
84
+ }
@@ -1,14 +1,23 @@
1
1
  import { DEFAULT_FOLD, foldHead } from "../fold.js";
2
2
  import { ArgumentError, optionalInteger, requireString } from "./args.js";
3
- const IMAGE_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp" };
3
+ import { bmpToPng, SENDABLE_IMAGE_TYPES, sniffImageType } from "./image.js";
4
+ /** File names that say "this is a picture". What the file really is, is decided by its first bytes (image.ts). */
5
+ const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"]);
6
+ const CONVERTED_DIRECTORY = ".sakura/artifacts";
4
7
  const fail = (text) => ({ content: [{ type: "text", text }], isError: true });
5
8
  const ok = (text) => ({ content: [{ type: "text", text }] });
9
+ const imageResult = (mediaType, ref, note = "") => ({
10
+ content: [
11
+ { type: "text", text: `Read image file [${mediaType}]${note ? ` (${note})` : ""}` },
12
+ { type: "image", mediaType, ref },
13
+ ],
14
+ });
6
15
  /*
7
16
  * Description adapted from Pi's read tool (MIT, Copyright (c) 2025 Mario Zechner; see THIRD_PARTY_NOTICES.md).
8
17
  */
9
18
  export const readTool = {
10
19
  name: "read",
11
- description: "Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp); images are sent as attachments. " +
20
+ description: "Read the contents of a file. Supports text files and images (jpg, png, gif, webp; a plain bmp is converted to png); images are sent as attachments. " +
12
21
  "For text files, output is truncated to the first 2000 lines or 50KB, whichever is hit first. " +
13
22
  "Use offset and limit for large files, and continue with offset until you have the whole file.",
14
23
  parameters: {
@@ -21,7 +30,7 @@ export const readTool = {
21
30
  required: ["path"],
22
31
  },
23
32
  effect: "read",
24
- async execute(args, { env }) {
33
+ async execute(args, { env, callId }) {
25
34
  try {
26
35
  const path = requireString(args, "path");
27
36
  const offset = optionalInteger(args, "offset", 1);
@@ -33,16 +42,21 @@ export const readTool = {
33
42
  if (info.isDirectory)
34
43
  return fail(`${path} is a directory, not a file.`);
35
44
  const extension = absolute.slice(absolute.lastIndexOf(".")).toLowerCase();
36
- const mediaType = IMAGE_TYPES[extension];
37
- if (mediaType) {
38
- return {
39
- content: [
40
- { type: "text", text: `Read image file [${mediaType}]` },
41
- { type: "image", mediaType, ref: absolute },
42
- ],
43
- };
44
- }
45
45
  const bytes = await env.readFile(absolute);
46
+ if (IMAGE_EXTENSIONS.has(extension)) {
47
+ const type = sniffImageType(bytes);
48
+ if (type && SENDABLE_IMAGE_TYPES.includes(type))
49
+ return imageResult(type, absolute);
50
+ if (type === "image/bmp") {
51
+ const png = bmpToPng(bytes);
52
+ if (!png)
53
+ return fail(`${path} is a BMP image in a form this tool cannot convert, and the model API does not accept BMP. Convert it to PNG with a command (for example ImageMagick: magick ${path} out.png) and read the PNG.`);
54
+ const converted = `${CONVERTED_DIRECTORY}/read-${callId.replace(/[^A-Za-z0-9_.-]/g, "_")}.png`;
55
+ await env.writeFile(converted, png);
56
+ return imageResult("image/png", env.resolve(converted), `converted from BMP to PNG, original ${path}`);
57
+ }
58
+ // a name that says picture, bytes that are not one: shown as the binary or text file it is
59
+ }
46
60
  if (bytes.subarray(0, 8000).includes(0))
47
61
  return fail(`${path} looks like a binary file and cannot be shown as text.`);
48
62
  const text = new TextDecoder().decode(bytes);
@@ -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;
@@ -1,5 +1,7 @@
1
1
  import { getQuickJS } from "quickjs-emscripten";
2
- const DESCRIPTION = "Runs a short JavaScript program in a sandbox (no files, no network, no timers) and returns what it prints and returns. " +
2
+ /** The name of the tool that runs a script. Whoever needs to refer to it takes it from here. */
3
+ export const CODEMODE_TOOL = "run_code";
4
+ const DESCRIPTION = "Runs a short JavaScript program in an isolated runtime (no files, no network, no timers) and returns what it prints and returns. " +
3
5
  "Inside the program, `tools.<name>(args)` calls a tool and returns a promise of its text; use `await`. `console.log` output is returned. " +
4
6
  "Use it to combine several tool calls or to process their results without sending everything through the conversation. Available tools: ";
5
7
  const PRELUDE = `
@@ -10,7 +12,7 @@ globalThis.tools = new Proxy({}, { get: (_t, name) => async (args) => {
10
12
  } });
11
13
  globalThis.console = { log: (...parts) => __print(parts.map((p) => (typeof p === "string" ? p : JSON.stringify(p))).join(" ")) };
12
14
  `;
13
- /** Deeper than this, the host's own stack overflows before the sandbox notices, which aborts the whole WebAssembly module; 256 KB still allows recursion about 1500 deep. */
15
+ /** Deeper than this, the host's own stack overflows before the runtime notices, which aborts the whole WebAssembly module; 256 KB still allows recursion about 1500 deep. */
14
16
  const STACK_BYTES = 256 * 1024;
15
17
  const text = (value, isError = false) => ({ content: [{ type: "text", text: value }], ...(isError ? { isError } : {}) });
16
18
  const textOfContent = (content) => content.map((part) => (part.type === "text" ? part.text : `[${part.type}]`)).join("\n");
@@ -23,11 +25,14 @@ function cut(value, limit) {
23
25
  /**
24
26
  * Lets the model write one JavaScript program that calls tools, instead of one call per request. The program runs in QuickJS
25
27
  * (WebAssembly), which has no access to the machine: only the tools in the catalog, and only those whose effect is allowed.
26
- * 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,
27
- * and `authorize` (for example the review plugin's) is asked before each call.
28
+ * Calls made from the script do not pass through the hooks of the loop, so `authorize` (for example the review plugin's) is asked
29
+ * before each call. With no `authorize`, every call runs, as a direct call would with no review installed.
30
+ *
31
+ * 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
32
+ * 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.
28
33
  */
29
34
  export function codemodePlugin(options) {
30
- const allowed = new Set(options.allowEffects ?? ["read"]);
35
+ const allowed = new Set(options.allowEffects ?? ["read", "write", "exec", "network", "irreversible"]);
31
36
  const byName = new Map(options.catalog.map((tool) => [tool.name, tool]));
32
37
  const timeoutMs = options.timeoutMs ?? 10_000;
33
38
  const memoryBytes = options.memoryBytes ?? 64 * 1024 * 1024;
@@ -42,7 +47,7 @@ export function codemodePlugin(options) {
42
47
  try {
43
48
  const args = JSON.parse(rawArgs);
44
49
  if (options.authorize) {
45
- const decision = await options.authorize({ id: `${context.callId}/${++calls}`, name, args }, { log: context.log, signal: context.signal, tool: (wanted) => byName.get(wanted) });
50
+ const decision = await options.authorize({ id: `${context.callId}/${++calls}`, name, args }, { log: context.log, signal: context.signal, tool: (wanted) => byName.get(wanted), ...(context.env?.cwd !== undefined ? { cwd: context.env.cwd } : {}) });
46
51
  if (decision.kind === "deny")
47
52
  return JSON.stringify({ error: decision.reason });
48
53
  }
@@ -55,7 +60,7 @@ export function codemodePlugin(options) {
55
60
  }
56
61
  }
57
62
  const runCode = {
58
- name: "run_code",
63
+ name: CODEMODE_TOOL,
59
64
  description: DESCRIPTION + options.catalog.map((tool) => tool.name).join(", "),
60
65
  parameters: { type: "object", properties: { code: { type: "string", description: "The body of an async function; use `return` for the result" } }, required: ["code"] },
61
66
  effect: "exec",
@@ -68,13 +73,45 @@ export function codemodePlugin(options) {
68
73
  const runtime = QuickJS.newRuntime();
69
74
  runtime.setMemoryLimit(memoryBytes);
70
75
  runtime.setMaxStackSize(STACK_BYTES);
71
- const deadline = Date.now() + timeoutMs;
72
- runtime.setInterruptHandler(() => Date.now() > deadline || context.signal.aborted);
76
+ // The script's own time is the time spent inside the runtime plus the time spent waiting while no tool call is under way.
77
+ // Waiting for a tool call is not counted, but a loop that runs while a call is under way is (it is counted as it runs).
78
+ const startedAt = Date.now();
79
+ let insideMs = 0;
80
+ let idleMs = 0;
81
+ let sliceStart = 0;
82
+ const used = () => insideMs + idleMs + (sliceStart > 0 ? Date.now() - sliceStart : 0);
83
+ const inRuntime = (work) => {
84
+ if (sliceStart > 0)
85
+ return work();
86
+ sliceStart = Date.now();
87
+ try {
88
+ return work();
89
+ }
90
+ finally {
91
+ insideMs += Date.now() - sliceStart;
92
+ sliceStart = 0;
93
+ }
94
+ };
95
+ const overTotal = () => options.totalTimeoutMs !== undefined && Date.now() - startedAt > options.totalTimeoutMs;
96
+ runtime.setInterruptHandler(() => used() > timeoutMs || overTotal() || context.signal.aborted);
73
97
  const vm = runtime.newContext();
74
98
  const printed = [];
75
- const pending = [];
99
+ const unsettled = new Set(); // the calls that have not come back yet; the wait below must not include the ones that have, or it would not wait
76
100
  const handles = [];
77
101
  const deferreds = [];
102
+ // The calls that have not returned. Each has its own signal, joined to the run's: cancelling the run stops them all, and when the script
103
+ // ends the ones still under way are stopped. A call that has returned (a background command, say) is left to the run.
104
+ const underway = new Set();
105
+ const failure = (message) => text(printed.length > 0 ? cut(`${printed.join("\n")}\n${message}`, maxOutput) : message, true);
106
+ const late = () => {
107
+ if (context.signal.aborted)
108
+ return "The script was cancelled."; // the calls it was waiting for were stopped with the run, so the script may report their errors instead
109
+ if (used() > timeoutMs)
110
+ return `The script took longer than ${seconds(timeoutMs)} seconds and was stopped.`;
111
+ if (overTotal())
112
+ return `The script took longer than ${seconds(options.totalTimeoutMs ?? 0)} seconds in total and was stopped.`;
113
+ return undefined;
114
+ };
78
115
  try {
79
116
  const print = vm.newFunction("__print", (line) => {
80
117
  printed.push(vm.getString(line));
@@ -86,54 +123,71 @@ export function codemodePlugin(options) {
86
123
  deferreds.push(promise);
87
124
  const name = vm.getString(nameHandle);
88
125
  const rawArgs = vm.getString(argsHandle);
89
- const work = callTool(name, rawArgs, context).then((reply) => {
126
+ const controller = new AbortController();
127
+ const stop = () => controller.abort();
128
+ if (context.signal.aborted)
129
+ stop();
130
+ else
131
+ context.signal.addEventListener("abort", stop, { once: true }); // stays joined after the call returns, so a background command it started is still stopped with the run
132
+ underway.add(controller);
133
+ const work = callTool(name, rawArgs, { ...context, signal: controller.signal })
134
+ .then((reply) => {
90
135
  if (!vm.alive)
91
136
  return;
92
137
  const value = vm.newString(reply);
93
138
  promise.resolve(value);
94
139
  value.dispose();
140
+ })
141
+ .catch(() => undefined)
142
+ .finally(() => {
143
+ underway.delete(controller);
144
+ unsettled.delete(work);
95
145
  });
96
- pending.push(work);
146
+ unsettled.add(work);
97
147
  void promise.settled
98
148
  .then(() => {
99
149
  if (runtime.alive)
100
- runtime.executePendingJobs(); // the script may be over and the sandbox gone by now
150
+ inRuntime(() => runtime.executePendingJobs()); // the script may be over and the runtime gone by now
101
151
  })
102
152
  .catch(() => undefined);
103
153
  return promise.handle;
104
154
  });
105
155
  vm.setProp(vm.global, "__callTool", call);
106
156
  call.dispose();
107
- const prelude = vm.evalCode(PRELUDE);
157
+ const prelude = inRuntime(() => vm.evalCode(PRELUDE));
108
158
  if (prelude.error) {
109
159
  prelude.error.dispose();
110
- return text("The sandbox could not start.", true);
160
+ return text("The script runtime could not start.", true);
111
161
  }
112
162
  prelude.value.dispose();
113
- const evaluated = vm.evalCode(`(async () => {\n${code}\n})()`);
163
+ const evaluated = inRuntime(() => vm.evalCode(`(async () => {\n${code}\n})()`));
114
164
  if (evaluated.error) {
115
165
  const message = vm.dump(evaluated.error);
116
166
  evaluated.error.dispose();
117
- return text(`Script error: ${formatError(message)}`, true);
167
+ return failure(late() ?? `Script error: ${formatError(message)}`);
118
168
  }
119
169
  handles.push(evaluated.value);
120
- runtime.executePendingJobs();
170
+ inRuntime(() => runtime.executePendingJobs());
121
171
  // the script is done when its promise settles; tool calls it started finish in between
122
172
  let state = vm.getPromiseState(evaluated.value);
173
+ let tick = Date.now();
123
174
  while (state.type === "pending") {
124
- if (Date.now() > deadline)
125
- return text(`The script took longer than ${Math.round(timeoutMs / 1000)} seconds and was stopped.`, true);
126
- if (context.signal.aborted)
127
- return text("The script was cancelled.", true);
128
- await Promise.race([Promise.allSettled(pending), new Promise((resolve) => setTimeout(resolve, 20))]);
129
- runtime.executePendingJobs();
175
+ const now = Date.now();
176
+ if (underway.size === 0)
177
+ idleMs += now - tick;
178
+ tick = now;
179
+ const over = late();
180
+ if (over)
181
+ return failure(over);
182
+ await Promise.race([...unsettled, new Promise((resolve) => setTimeout(resolve, 20))]);
183
+ inRuntime(() => runtime.executePendingJobs());
130
184
  state = vm.getPromiseState(evaluated.value);
131
185
  }
132
186
  const output = printed.length > 0 ? `${printed.join("\n")}\n` : "";
133
187
  if (state.type === "rejected") {
134
188
  const message = vm.dump(state.error);
135
189
  state.error.dispose();
136
- return text(cut(`${output}Script error: ${formatError(message)}`, maxOutput), true);
190
+ return failure(late() ?? `Script error: ${formatError(message)}`);
137
191
  }
138
192
  const value = vm.dump(state.value);
139
193
  state.value.dispose();
@@ -142,9 +196,11 @@ export function codemodePlugin(options) {
142
196
  return text(cut(combined === "" ? "(no output)" : combined, maxOutput));
143
197
  }
144
198
  catch (error) {
145
- return text(`Script failed: ${error instanceof Error ? error.message : String(error)}`, true);
199
+ return failure(`Script failed: ${error instanceof Error ? error.message : String(error)}`);
146
200
  }
147
201
  finally {
202
+ for (const controller of underway)
203
+ controller.abort(); // calls still under way when the script ends are stopped
148
204
  for (const handle of handles)
149
205
  if (handle.alive)
150
206
  handle.dispose();
@@ -158,6 +214,8 @@ export function codemodePlugin(options) {
158
214
  };
159
215
  return { name: "codemode", compensates: "one tool call per model request is slow and fills the context with intermediate results", setup: (api) => api.registerTool(runCode) };
160
216
  }
217
+ /** Seconds for an error message: a whole number when it is one, otherwise one decimal (0.3 and not 0). */
218
+ const seconds = (ms) => Number((ms / 1000).toFixed(1));
161
219
  function formatError(dumped) {
162
220
  if (typeof dumped === "object" && dumped !== null) {
163
221
  const { name, message } = dumped;
@@ -2,5 +2,5 @@ export { searchPlugin } from "./search.js";
2
2
  export type { SearchOptions } from "./search.js";
3
3
  export { connectMcp } from "./mcp.js";
4
4
  export type { McpConnection, McpServerConfig, McpServerStatus } from "./mcp.js";
5
- export { codemodePlugin } from "./codemode.js";
5
+ export { CODEMODE_TOOL, codemodePlugin } from "./codemode.js";
6
6
  export type { Authorize, CodemodeOptions } from "./codemode.js";
@@ -1,3 +1,3 @@
1
1
  export { searchPlugin } from "./search.js";
2
2
  export { connectMcp } from "./mcp.js";
3
- export { codemodePlugin } from "./codemode.js";
3
+ export { CODEMODE_TOOL, codemodePlugin } from "./codemode.js";
@@ -0,0 +1,27 @@
1
+ import type { Plugin } from "../../../kernel/src/index.js";
2
+ /** One step of a run, tagged with the module that did the work. Written one per line to a trace file. */
3
+ export interface TraceRecord {
4
+ /** Milliseconds since the trace began. */
5
+ t: number;
6
+ /** Module id, matching the node ids of the architecture diagram. */
7
+ module: string;
8
+ /** What happened (the event type). */
9
+ fn: string;
10
+ /** A short detail, never message text. */
11
+ detail?: string;
12
+ }
13
+ export interface TraceOptions {
14
+ /** Append records to this file as JSON lines. */
15
+ file?: string;
16
+ /** Receive each record. */
17
+ sink?(record: TraceRecord): void;
18
+ now?: () => number;
19
+ }
20
+ export interface TracePlugin extends Plugin {
21
+ records: TraceRecord[];
22
+ }
23
+ /**
24
+ * Records what runs, for the module map. It only watches events, never changes anything the model sees,
25
+ * and keeps no message text. A development aid, off unless added.
26
+ */
27
+ export declare function tracePlugin(options?: TraceOptions): TracePlugin;
@@ -0,0 +1,29 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { moduleOf } from "./modules.js";
4
+ /**
5
+ * Records what runs, for the module map. It only watches events, never changes anything the model sees,
6
+ * and keeps no message text. A development aid, off unless added.
7
+ */
8
+ export function tracePlugin(options = {}) {
9
+ const now = options.now ?? Date.now;
10
+ const started = now();
11
+ const records = [];
12
+ if (options.file)
13
+ mkdirSync(dirname(options.file), { recursive: true });
14
+ return {
15
+ name: "trace",
16
+ compensates: "nothing: it only watches",
17
+ records,
18
+ setup(api) {
19
+ api.on("event", (event) => {
20
+ const { module, detail } = moduleOf(event);
21
+ const record = { t: now() - started, module, fn: event.type, ...(detail !== undefined ? { detail } : {}) };
22
+ records.push(record);
23
+ options.sink?.(record);
24
+ if (options.file)
25
+ appendFileSync(options.file, `${JSON.stringify(record)}\n`);
26
+ });
27
+ },
28
+ };
29
+ }
@@ -0,0 +1,6 @@
1
+ import type { Event } from "../../../kernel/src/index.js";
2
+ /** Which module does the work behind each kind of event. */
3
+ export declare function moduleOf(event: Event): {
4
+ module: string;
5
+ detail?: string;
6
+ };
@@ -0,0 +1,28 @@
1
+ /** Which module does the work behind each kind of event. */
2
+ export function moduleOf(event) {
3
+ switch (event.type) {
4
+ case "run_start":
5
+ case "run_end":
6
+ case "turn_start":
7
+ case "turn_end":
8
+ case "paused":
9
+ return { module: "kernel", ...(event.type === "run_end" ? { detail: event.reason } : event.type === "paused" ? { detail: event.reason } : {}) };
10
+ case "message_start":
11
+ case "message_update":
12
+ case "reasoning_update":
13
+ case "message_reset":
14
+ case "message_end":
15
+ return { module: "llm" };
16
+ case "tool_start":
17
+ case "tool_end":
18
+ return { module: "tools", detail: event.call.name };
19
+ case "compaction":
20
+ return { module: "compaction" };
21
+ case "goal":
22
+ return { module: "goal", detail: event.status };
23
+ case "signal":
24
+ return { module: "kernel", detail: "signal" };
25
+ case "plugin":
26
+ return { module: `plugin-${event.name}` };
27
+ }
28
+ }
@@ -1,10 +1,15 @@
1
- import type { LogOptions, RewindableLog } from "./memory-log.js";
1
+ import type { LogOptions, RewindableLog } from "./log-core.js";
2
2
  export interface SessionHeader {
3
3
  type: "header";
4
4
  version: number;
5
5
  id: string;
6
6
  cwd: string;
7
7
  ts: number;
8
+ /** Set on a session that was made by forking another: the entry it was copied up to, and the session it came from when that is known. */
9
+ forkedFrom?: {
10
+ entry: string;
11
+ session?: string;
12
+ };
8
13
  }
9
14
  export interface FileLog extends RewindableLog {
10
15
  header: SessionHeader;