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
@@ -1,63 +1,13 @@
1
1
  # Third-party notices
2
2
 
3
- This file registers every piece of third-party code that is copied, ported, or
4
- vendored into this repository. Entries under "Copied or ported" are the authoritative list; the
5
- "Planned sources" table lists upstream projects we may still borrow from.
3
+ Sakura borrows from a few other projects. Where code or text was copied or adapted, the source file says so in its header, and the licence text is kept in `licenses/`.
6
4
 
7
- Rule: borrowing a design idea does not need an entry. Copying or porting code
8
- (including translating it to another language) does. Each ported file must carry
9
- a header comment naming its source, and a check script must fail the build when a
10
- ported file has no header (planned for milestone M0).
5
+ | Project | Licence | What Sakura took |
6
+ |---|---|---|
7
+ | [Pi](https://github.com/badlogic/pi-mono) (Mario Zechner) | MIT | The edit-matching code in `packages/plugins/tools/src/edit-match.ts`, and the wording of the `read`, `write`, `edit` and `bash` tool descriptions, adapted. The structure and some rules of the system prompt follow Pi's. |
8
+ | [gitleaks](https://github.com/gitleaks/gitleaks) (Zachary Rice) | MIT | The secret-detection rules in `packages/plugins/redaction/src/rules.gen.ts`, converted to TypeScript. |
9
+ | [OpenAI Codex](https://github.com/openai/codex) | Apache-2.0 | Ideas only: some task-execution principles in the system prompt, reworded. No code or text is copied. |
11
10
 
12
- ## Copied or ported
11
+ Dependency: [`quickjs-emscripten`](https://github.com/justjake/quickjs-emscripten) (MIT) runs the code-mode scripts. It is installed from npm, not copied.
13
12
 
14
- ### packages/plugins/tools/src/edit-match.ts
15
- - Source: Pi (`pi-mono`), path `packages/coding-agent/src/core/tools/edit-diff.ts`, source snapshot dated 2026-10-04 (npm 1.5.0 was current at the time)
16
- - Licence: MIT
17
- - Copyright: Copyright (c) 2025 Mario Zechner
18
- - Changes: removed the diff-rendering helpers and the file-system and `diff` package imports; restructured into one `applyEdits` function; kept the fuzzy matching, line-ending handling, overlap detection, line-preserving replacement and the error messages
19
- - Licence text: `licenses/pi-mono-MIT.txt`
20
-
21
- ### packages/plugins/tools/src/builtin/{read,write,edit,bash}.ts (tool descriptions)
22
- - Source: Pi (`pi-mono`), `packages/coding-agent/src/core/tools/{read,write,edit,bash}.ts` (description texts); the implementations are written fresh
23
- - Licence: MIT
24
- - Copyright: Copyright (c) 2025 Mario Zechner
25
- - Changes: shortened and reworded for our tool contracts (timeouts, non-interactive commands)
26
- - Licence text: `licenses/pi-mono-MIT.txt`
27
-
28
- ### packages/agent/src/system-prompt.ts (rule texts)
29
- - Source: Pi (`pi-mono`) `packages/coding-agent/src/core/system-prompt.ts` and tool prompt snippets (structure and some rules); OpenAI Codex `codex-rs/protocol/src/prompts/base_instructions/default.md` (task-execution, validation and final-message principles)
30
- - Licence: Pi MIT; Codex Apache-2.0
31
- - Copyright: Copyright (c) 2025 Mario Zechner (Pi); OpenAI (Codex)
32
- - Changes: principles condensed and reworded into 11 general rules plus per-tool rules; no text copied verbatim from Codex; Pi's section layout (preamble, tools, rules, environment) followed
33
- - Licence text: `licenses/pi-mono-MIT.txt`, `licenses/codex-Apache-2.0.txt`
34
-
35
- ### packages/plugins/redaction/src/rules.gen.ts (generated)
36
- - Source: gitleaks (`gitleaks/gitleaks`), `config/gitleaks.toml`, source snapshot dated 2026-11-06. The public workspace includes the generated rule table.
37
- - Licence: MIT
38
- - Copyright: Copyright (c) 2019 Zachary Rice
39
- - Changes: converted by `scripts/gen-secret-rules.py` into TypeScript data (regular expressions translated to JavaScript, 22 of 221 rules skipped because they do not translate; allowlists not used)
40
- - Licence text: `licenses/gitleaks-MIT.txt`
41
-
42
- ## Planned sources (not yet copied)
43
-
44
- | Source | Licence | Upstream | Intended use | Status |
45
- |---|---|---|---|---|
46
- | Pi (`pi-mono`) | MIT, copyright Mario Zechner | `earendil-works/pi` | Edit-tool matching and the read/write/edit/bash tool descriptions are now ported (see below); system-prompt structure may follow; mcp client and codemode ideas | partly copied (see below) |
47
- | Magpie | MIT | `yetone/magpie` | Design reference for redaction (stable placeholders, streaming restore, fail closed). Not a code port any more (decision: only the essence) | not copied |
48
- | gitleaks | MIT (Zachary Rice) | `gitleaks/gitleaks` | Rule table converted for the redaction plugin (see above) | copied (see above) |
49
- | Codex | Apache-2.0 | `openai/codex` | Design reference (guardian reviewer, goal, compaction); base-instructions principles adapted into our system prompt (`protocol/src/prompts/base_instructions/default.md`), with changes noted. Other prompt texts are written fresh; if any text or code is copied, add an entry and keep the Apache-2.0 notice | not copied |
50
- | A2A | Apache-2.0 | `a2aproject/A2A` | Possible future protocol reference | not copied |
51
- | `quickjs-emscripten` | MIT (Fabrice Bellard's QuickJS, Jake Teton-Landis's bindings; checked on npm, all 7 installed packages report MIT) | npm | Runtime dependency of `packages/plugins/toolset` (codemode sandbox). Not copied, only installed. `quickjs-wasi` was the other candidate (also MIT), not used | dependency |
52
- | Mastra | not verified (GitHub reports NOASSERTION) | `mastra-ai/mastra` | Design reference only. **Do not copy code until the licence is read.** | not copied |
53
-
54
- ## Entry format (use when code is actually copied or ported)
55
-
56
- ```
57
- ### <file or component in this repo>
58
- - Source: <repository URL>, path <path>, commit <hash>
59
- - Licence: <SPDX id>
60
- - Copyright: <holder, year>
61
- - Changes: <what was changed or translated>
62
- - Licence text: <path to the full licence text kept in this repository>
63
- ```
13
+ Licence texts: `licenses/pi-mono-MIT.txt`, `licenses/gitleaks-MIT.txt`, `licenses/codex-Apache-2.0.txt`.
@@ -1,8 +1,10 @@
1
1
  import type { ChatCompat } from "../../llm/src/index.js";
2
- import type { CompactionConfig, Environment, LlmClient, Plugin, Prompts, RunHandle, ThinkingLevel, UserInput } from "../../kernel/src/index.js";
2
+ import type { CompactionConfig, ContextStatus, Environment, GoalRecord, LlmClient, Plugin, Prompts, RunHandle, RunOptions, ThinkingLevel, Tool, UsageTotals, UserInput } from "../../kernel/src/index.js";
3
3
  import type { AgentDefinition } from "../../plugins/agents/src/index.js";
4
4
  import type { McpServerConfig, McpServerStatus } from "../../plugins/toolset/src/index.js";
5
5
  import type { RewindableLog } from "../../session/src/index.js";
6
+ import type { WakeSetting } from "./wake.js";
7
+ type BuiltinToolName = "read" | "write" | "edit" | "bash";
6
8
  export interface ModelConfig {
7
9
  /** Base URL without /chat/completions, for example https://api.deepseek.com */
8
10
  baseUrl: string;
@@ -16,15 +18,25 @@ export interface AgentConfig {
16
18
  /** A provider to talk to, or a ready-made client (for tests). */
17
19
  model: ModelConfig | LlmClient;
18
20
  cwd?: string;
19
- tools?: Array<"read" | "write" | "edit" | "bash">;
21
+ /**
22
+ * Keep the file tools inside `cwd`: a read, write, edit or list of anything outside it is refused with an error the model sees, however the path is written
23
+ * (absolute, `..`, a link). For an agent with no shell tool this is the whole boundary, and no review is needed. A shell command can still reach anything,
24
+ * so with `bash` use it together with the review plugin. Only for the default local environment: build `createLocalEnvironment(cwd, { confine: true })` yourself
25
+ * for anything else (giving both is an error).
26
+ */
27
+ confine?: boolean;
28
+ /** The tools the model gets: names of the built-in ones (default all four) and tools you wrote. A kind of sub-agent that lists tool names gets only those, whoever registered them. */
29
+ tools?: Array<BuiltinToolName | Tool>;
20
30
  /** After this many milliseconds a running shell command is moved to the background (default 120000). */
21
31
  bashSoftTimeoutMs?: number;
22
32
  /** How long a command the model starts with `background: true` is watched before it is left to run (default 1000), so that one that fails at once is reported as failed. */
23
33
  bashBackgroundGraceMs?: number;
24
- /** Defaults to corePlugins(). Safety plugins (review, redaction, sandbox, sub-agents) are never in the default set. May contain bundles and plugins that say what they require; see compose(). */
34
+ /** Defaults to corePlugins(). Safety plugins (review, redaction, sub-agents) are never in the default set. May contain bundles and plugins that say what they require; see compose(). */
25
35
  plugins?: Plugin[];
26
36
  /** Replaces the generated system prompt. */
27
37
  system?: string;
38
+ /** Rules added to the generated system prompt, after the built-in ones. Sub-agents get them too. Not used when `system` replaces the prompt. */
39
+ rules?: string[];
28
40
  /** Write the conversation to this JSONL file; without it the conversation lives in memory only. */
29
41
  sessionFile?: string;
30
42
  thinking?: ThinkingLevel;
@@ -41,15 +53,14 @@ export interface AgentConfig {
41
53
  * Wakes the agent by itself. When background work ends (a command moved to the background, a background sub-agent) and no run is going,
42
54
  * a run is started so the agent hears about it; `onRun` receives each such run (to show its events). While work is still pending and the agent
43
55
  * is quiet, it is woken once after `cacheCheckMs` (default 4 minutes, a little under the prompt cache lifetime) to check, and then left alone until something ends.
56
+ * The agent can also ask for it: `bash` takes `wake: true` or `wake: false` for one command, which wins over this setting for that command. With no `wake` here,
57
+ * a command that asked to wake the agent still does, and its run is not shown to anyone (the log has all of it). The check before the cache expires needs this setting.
44
58
  */
45
- wake?: {
46
- onRun(handle: RunHandle): void;
47
- cacheCheckMs?: number;
48
- };
59
+ wake?: WakeSetting;
49
60
  /**
50
61
  * MCP servers, started once when the agent is created (a server that fails is left out and shows in `agent.mcp`). Their tools are named mcp__<server>__<tool>.
51
- * `expose`: "direct" declares every tool to the model; "search" lists only tool_search and tool_call; "codemode" lists only run_code, where read-only
52
- * tools can be called from a script. The default is "direct" up to 10 tools and "search" above.
62
+ * `expose`: "direct" declares every tool to the model; "search" lists only tool_search and tool_call; "codemode" lists only run_code, a script that can call the
63
+ * tools (each call goes through the review plugin when one is installed). The default is "direct" up to 10 tools and "search" above.
53
64
  */
54
65
  mcp?: {
55
66
  servers: Record<string, McpServerConfig>;
@@ -71,14 +82,41 @@ export interface AgentConfig {
71
82
  };
72
83
  }
73
84
  export interface Agent {
74
- /** Starts, resumes or follows up. See Kernel.run. */
75
- run(input?: UserInput): RunHandle;
85
+ /**
86
+ * Starts, resumes or follows up. See Kernel.run. `{ compact: true }` compacts the conversation first, whatever its size: `agent.run(undefined, { compact: true })`
87
+ * is "compact now", and the `compaction` event of the run says it was done.
88
+ */
89
+ run(input?: UserInput, options?: RunOptions): RunHandle;
76
90
  queue(text: string): void;
77
91
  steer(text: string): void;
78
92
  cancel(): void;
79
93
  log: RewindableLog;
80
94
  /** Goes back to an earlier point of the conversation (an entry id from `log.branch()`, or null for the very start); what is said next starts a new branch. */
81
95
  rewindTo(entryId: string | null): void;
96
+ /**
97
+ * Writes the conversation up to an earlier point (an entry id from `log.branch()`; left out, up to now) as a new session file, which is a session of
98
+ * its own: open it with `createAgent({ ...config, sessionFile: path })`. This session is not changed, and both can go on. A turn is not cut in two:
99
+ * the results of the tool calls of the last message copied come with it. Fails if the file exists.
100
+ */
101
+ fork(path: string, upTo?: string): Promise<void>;
102
+ /** What is waiting to be told to the agent and which background work it is waiting for. A host can use it to decide whether to start a run. */
103
+ pending(): {
104
+ signals: string[];
105
+ watching: string[];
106
+ };
107
+ /** The goal of the conversation, as the session file says it is (active, blocked or complete); undefined when there is none. */
108
+ goal(): Promise<GoalRecord | undefined>;
109
+ /** Sets the goal, replacing an earlier one; the next run is checked against it before it may stop. Part of the session, so it survives reopening and fork. Fails while a run is active. */
110
+ setGoal(goal: {
111
+ objective: string;
112
+ verify?: string;
113
+ }): Promise<void>;
114
+ /** Ends the goal (it counts as complete). Fails while a run is active. */
115
+ clearGoal(): Promise<void>;
116
+ /** How full the conversation is now: tokens used, the model's window, what is free, and where compaction starts (undefined when it is off). */
117
+ context(): Promise<ContextStatus>;
118
+ /** What the conversation has spent so far (tokens and replies), counted from the log, so it includes earlier runs of the same session file. */
119
+ usage(): Promise<UsageTotals>;
82
120
  /** How each MCP server did when the agent was created (for the person running it; the model is not told). Empty without `mcp`. */
83
121
  mcp: Record<string, McpServerStatus>;
84
122
  /** Releases the session file lock. */
@@ -3,24 +3,26 @@ import { createLocalEnvironment } from "../../env/src/index.js";
3
3
  import { createKernel } from "../../kernel/src/index.js";
4
4
  import { agentsPlugin } from "../../plugins/agents/src/index.js";
5
5
  import { contextPlugin } from "../../plugins/context/src/index.js";
6
- import { codemodePlugin, connectMcp, searchPlugin } from "../../plugins/toolset/src/index.js";
6
+ import { CODEMODE_TOOL, codemodePlugin, connectMcp, searchPlugin } from "../../plugins/toolset/src/index.js";
7
7
  import { limitsPlugin } from "../../plugins/limits/src/index.js";
8
8
  import { resiliencePlugin } from "../../plugins/resilience/src/index.js";
9
9
  import { builtinTools, toolsPlugin } from "../../plugins/tools/src/index.js";
10
- import { createMemoryLog, openFileLog } from "../../session/src/index.js";
10
+ import { createMemoryLog, forkLog, openFileLog } from "../../session/src/index.js";
11
11
  import { compose } from "./compose.js";
12
+ import { restrictTools } from "./restrict-tools.js";
12
13
  import { buildSystemPrompt } from "./system-prompt.js";
14
+ import { createWaker } from "./wake.js";
13
15
  /** The plugins every agent gets unless the caller passes its own list: project instructions (AGENTS.md), long tool output saved to files, resilience (replies that were cut off or came back empty are asked again), and limits (unlimited until configured). */
14
16
  export function corePlugins() {
15
17
  return [contextPlugin(), toolsPlugin(), resiliencePlugin(), limitsPlugin()];
16
18
  }
17
19
  const SUB_AGENT_NOTE = "You are a sub-agent working for another agent. Do the task you are given completely, then finish with a clear written report of what you did and found. That report is the only thing that goes back to the agent that asked.";
18
- const CACHE_CHECK_MS = 240_000;
19
- const checkInput = (labels) => `Background work you started is still running: ${labels.join(", ")}. Check on it once now (for example read its output), then stop and wait; you will be told when it ends. Do not poll.`;
20
20
  const isClient = (model) => "stream" in model;
21
21
  export async function createAgent(config) {
22
22
  const cwd = config.cwd ?? process.cwd();
23
- const env = config.environment ?? createLocalEnvironment(cwd);
23
+ if (config.confine && config.environment)
24
+ throw new Error("confine applies to the default local environment; for your own environment build it with createLocalEnvironment(cwd, { confine: true })");
25
+ const env = config.environment ?? createLocalEnvironment(cwd, config.confine ? { confine: true } : {});
24
26
  const connection = config.mcp ? await connectMcp(config.mcp.servers, config.mcp.timeoutMs !== undefined ? { timeoutMs: config.mcp.timeoutMs } : {}) : undefined;
25
27
  const mcpTools = connection?.tools ?? [];
26
28
  const expose = config.mcp?.expose ?? (mcpTools.length > 10 ? "search" : "direct");
@@ -28,7 +30,7 @@ export async function createAgent(config) {
28
30
  ...(config.bashSoftTimeoutMs !== undefined ? { softTimeoutMs: config.bashSoftTimeoutMs } : {}),
29
31
  ...(config.bashBackgroundGraceMs !== undefined ? { backgroundGraceMs: config.bashBackgroundGraceMs } : {}),
30
32
  };
31
- const tools = builtinTools(config.tools, { bash: bashOptions });
33
+ const tools = (config.tools ?? ["read", "write", "edit", "bash"]).map((entry) => (typeof entry === "string" ? builtinTools([entry], { bash: bashOptions })[0] : entry));
32
34
  const declared = expose === "direct" ? [...tools, ...mcpTools] : tools;
33
35
  const clientFor = (model) => isClient(model)
34
36
  ? model
@@ -39,11 +41,20 @@ export async function createAgent(config) {
39
41
  contextWindow: model.contextWindow,
40
42
  ...(model.compat ? { compat: model.compat } : {}),
41
43
  ...(model.headers ? { headers: model.headers } : {}),
44
+ // an image path that a tool returned is a path in the environment, which may be another machine
45
+ resolveImage: async (ref, mediaType) => `data:${mediaType};base64,${Buffer.from(await env.readFile(ref)).toString("base64")}`,
42
46
  });
43
47
  const llm = clientFor(config.model);
44
48
  const thinkingModel = !isClient(config.model) && config.model.compat?.thinkingFormat !== undefined && config.model.compat.thinkingFormat !== "none";
45
49
  const thinking = config.thinking ?? (thinkingModel ? "medium" : undefined);
46
- const system = config.system ?? buildSystemPrompt({ tools, cwd: env.cwd, platform: process.platform });
50
+ const system = config.system ??
51
+ buildSystemPrompt({
52
+ tools,
53
+ cwd: env.cwd,
54
+ platform: process.platform,
55
+ ...(env.description !== undefined ? { description: env.description } : {}),
56
+ ...(config.rules ? { rules: config.rules } : {}),
57
+ });
47
58
  // bundles are opened and a plugin that appears twice is installed once; a missing requirement, a cycle or two plugins with one name fail here, before anything runs
48
59
  const plugins = compose(config.plugins ?? corePlugins());
49
60
  const notInherited = new Set(config.subAgents?.notInherited ?? ["agents", "limits", "context", "tools"]);
@@ -51,9 +62,13 @@ export async function createAgent(config) {
51
62
  if (mcpTools.length > 0 && expose === "search")
52
63
  plugins.push(searchPlugin({ catalog: mcpTools }));
53
64
  // a plugin that offers `authorize` (the review plugin does) is asked before each call a script makes, because those calls do not pass the loop's hooks
54
- const authorize = plugins.map((plugin) => plugin.authorize).find((candidate) => typeof candidate === "function");
55
- if (mcpTools.length > 0 && expose === "codemode")
65
+ const reviewer = plugins.find((plugin) => typeof plugin.authorize === "function");
66
+ const authorize = reviewer?.authorize;
67
+ if (mcpTools.length > 0 && expose === "codemode") {
56
68
  plugins.push(codemodePlugin({ catalog: mcpTools, ...(authorize ? { authorize } : {}) }));
69
+ // every call a script makes is reviewed one by one, so the script as a whole is not reviewed again
70
+ reviewer?.exempt?.(CODEMODE_TOOL);
71
+ }
57
72
  const childLogs = [];
58
73
  const childModels = new Map(Object.entries(config.subAgents?.models ?? {}).map(([name, model]) => [name, clientFor(model)]));
59
74
  if (config.subAgents) {
@@ -65,7 +80,7 @@ export async function createAgent(config) {
65
80
  env,
66
81
  system: `${definition.systemPrompt ?? system}\n\n${SUB_AGENT_NOTE}`,
67
82
  tools: definition.tools ? tools.filter((tool) => definition.tools?.includes(tool.name)) : tools,
68
- plugins: maxTurns !== undefined ? [...inherited, limitsPlugin({ maxTurns })] : inherited,
83
+ plugins: [...(definition.tools ? restrictTools(inherited, definition.tools) : inherited), ...(maxTurns !== undefined ? [limitsPlugin({ maxTurns })] : [])],
69
84
  ...(config.maxTokens !== undefined ? { maxTokens: config.maxTokens } : {}),
70
85
  ...(thinking !== undefined ? { thinking } : {}),
71
86
  ...(config.compaction !== undefined ? { compaction: config.compaction } : {}),
@@ -90,7 +105,7 @@ export async function createAgent(config) {
90
105
  ...(config.maxTokens !== undefined ? { maxTokens: config.maxTokens } : {}),
91
106
  ...(thinking !== undefined ? { thinking } : {}),
92
107
  ...(config.now ? { now: config.now } : {}),
93
- ...(config.wake ? { onSignal: () => onBackgroundEnd() } : {}),
108
+ onSignal: (_log, _text, options) => waker.signalled(options),
94
109
  ...(config.compaction !== undefined ? { compaction: config.compaction } : {}),
95
110
  ...(config.goal ? { goal: config.goal } : {}),
96
111
  ...(config.prompts ? { prompts: config.prompts } : {}),
@@ -98,63 +113,43 @@ export async function createAgent(config) {
98
113
  const fileLog = config.sessionFile ? await openFileLog(config.sessionFile, { cwd: env.cwd }) : undefined;
99
114
  const log = fileLog ?? createMemoryLog();
100
115
  let active;
101
- let closed = false;
102
- let timer;
103
- let checkUsed = false; // the one proactive check per quiet period has been made
104
- function start(input) {
105
- const handle = kernel.run(log, input);
116
+ const waker = createWaker({
117
+ setting: config.wake,
118
+ pending: () => kernel.pending(log),
119
+ start: (input) => start(input),
120
+ busy: () => active !== undefined,
121
+ });
122
+ function start(input, options) {
123
+ waker.started();
124
+ const handle = kernel.run(log, input, options);
106
125
  active = handle;
107
- void handle.result.finally(() => {
126
+ const ended = (cancelled) => {
108
127
  if (active === handle)
109
128
  active = undefined;
110
- afterRun();
111
- });
129
+ waker.ended(cancelled);
130
+ };
131
+ void handle.result.then((result) => ended(result.reason === "cancelled"), () => ended(false));
112
132
  return handle;
113
133
  }
114
- function wakeWith(input) {
115
- if (closed || active || !config.wake)
116
- return;
117
- config.wake.onRun(start(input));
118
- }
119
- function onBackgroundEnd() {
120
- clearTimeout(timer);
121
- checkUsed = false;
122
- wakeWith(undefined); // while a run is going, it delivers the notice itself or afterRun does
123
- }
124
- /** Called when a run ends: wakes for anything the run did not get to tell the agent, or arms the one check before the cache expires. */
125
- function afterRun() {
126
- if (closed || !config.wake)
127
- return;
128
- const pending = kernel.pending(log);
129
- if (pending.signals.length > 0) {
130
- wakeWith(undefined);
131
- return;
132
- }
133
- if (pending.watching.length === 0 || checkUsed)
134
- return;
135
- clearTimeout(timer);
136
- timer = setTimeout(() => {
137
- const now = kernel.pending(log);
138
- if (now.signals.length > 0)
139
- return wakeWith(undefined);
140
- if (now.watching.length === 0)
141
- return;
142
- checkUsed = true;
143
- wakeWith(checkInput(now.watching));
144
- }, config.wake.cacheCheckMs ?? CACHE_CHECK_MS);
145
- timer.unref();
146
- }
147
134
  return {
148
135
  log,
149
136
  mcp: connection?.status ?? {},
150
137
  rewindTo: (entryId) => log.rewindTo(entryId),
151
- run: (input) => start(input),
138
+ fork: async (path, upTo) => {
139
+ await forkLog(log, path, { cwd: env.cwd, ...(upTo !== undefined ? { upTo } : {}), ...(fileLog ? { sessionId: fileLog.header.id } : {}) });
140
+ },
141
+ pending: () => kernel.pending(log),
142
+ goal: () => kernel.goal(log),
143
+ setGoal: (goal) => kernel.setGoal(log, goal),
144
+ clearGoal: () => kernel.clearGoal(log),
145
+ context: () => kernel.context(log),
146
+ usage: () => kernel.usage(log),
147
+ run: (input, options) => start(input, options),
152
148
  queue: (text) => active?.queue(text),
153
149
  steer: (text) => active?.steer(text),
154
150
  cancel: () => active?.cancel(),
155
151
  close: async () => {
156
- closed = true;
157
- clearTimeout(timer);
152
+ waker.close();
158
153
  await connection?.close();
159
154
  await env.dispose(); // stops commands that are still running in the background
160
155
  await Promise.all(childLogs.map((log) => log.close()));
@@ -0,0 +1,6 @@
1
+ import type { Plugin } from "../../kernel/src/index.js";
2
+ /**
3
+ * The plugins as a sub-agent with a limited tool list sees them: a tool a plugin registers is kept only if its name is on the list, the same rule
4
+ * that applies to the built-in tools. Everything else about the plugins stays as it is.
5
+ */
6
+ export declare function restrictTools(plugins: readonly Plugin[], allowed: readonly string[]): Plugin[];
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The plugins as a sub-agent with a limited tool list sees them: a tool a plugin registers is kept only if its name is on the list, the same rule
3
+ * that applies to the built-in tools. Everything else about the plugins stays as it is.
4
+ */
5
+ export function restrictTools(plugins, allowed) {
6
+ const names = new Set(allowed);
7
+ return plugins.map((plugin) => ({
8
+ ...plugin,
9
+ setup: (api) => plugin.setup({ ...api, registerTool: (tool) => (names.has(tool.name) ? api.registerTool(tool) : undefined) }),
10
+ }));
11
+ }
@@ -3,6 +3,11 @@ export interface SystemPromptOptions {
3
3
  tools: readonly Tool[];
4
4
  cwd: string;
5
5
  platform: string;
6
+ /**
7
+ * What the environment says about where the tools run (Environment.description). When there is one, it takes the place of the platform line,
8
+ * because the platform of this machine is then not where the commands run.
9
+ */
10
+ description?: string;
6
11
  /** Extra rules from plugins. */
7
12
  rules?: string[];
8
13
  }
@@ -42,7 +42,7 @@ export function buildSystemPrompt(options) {
42
42
  PREAMBLE,
43
43
  options.tools.length > 0 ? `<tools>\n${toolLines.join("\n")}\n</tools>` : "",
44
44
  `<rules>\n${rules.map((rule) => `- ${rule}`).join("\n")}\n</rules>`,
45
- `<environment>\nWorking directory: ${options.cwd}\nPlatform: ${options.platform}\n</environment>`,
45
+ `<environment>\nWorking directory: ${options.cwd}\n${options.description !== undefined ? `Where your tools run: ${options.description}` : `Platform: ${options.platform}`}\n</environment>`,
46
46
  ]
47
47
  .filter((section) => section !== "")
48
48
  .join("\n\n");
@@ -0,0 +1,41 @@
1
+ import type { RunHandle } from "../../kernel/src/index.js";
2
+ /** What the application can ask for. */
3
+ export interface WakeSetting {
4
+ /** Receives every run the agent starts by itself, to show its events. */
5
+ onRun(handle: RunHandle): void;
6
+ /** How long a quiet agent with work still under way is left before it is woken once to check on it (default 4 minutes, a little under the prompt cache lifetime). */
7
+ cacheCheckMs?: number;
8
+ }
9
+ export interface WakerOptions {
10
+ /** The application's setting; with none, the agent is woken only for work that asked for it. */
11
+ setting: WakeSetting | undefined;
12
+ /** What the kernel says is waiting: notices the agent has not been told yet, and work that is still under way. */
13
+ pending(): {
14
+ signals: string[];
15
+ watching: string[];
16
+ };
17
+ /** Starts a run on the agent's conversation. With no input it tells the agent what is waiting. */
18
+ start(input?: string): RunHandle;
19
+ /** Whether a run is going now. */
20
+ busy(): boolean;
21
+ }
22
+ /**
23
+ * When the agent starts a run by itself. It decides three things and keeps the state they need in one place:
24
+ * - when background work ends and the agent is quiet, a run starts so that it hears about it, if the application asked for that in general or the work asked
25
+ * for it in particular (the work's own wish wins);
26
+ * - a quiet agent with work still under way is woken once to check on it before the prompt cache expires, then left alone until something ends;
27
+ * - after a run that was cancelled nothing starts by itself: whoever cancelled wanted it to stop, and what is waiting is told in the next run.
28
+ */
29
+ export interface Waker {
30
+ /** Background work ended. `options.wake` is what that work asked for; left out, the application's setting decides. */
31
+ signalled(options?: {
32
+ wake?: boolean;
33
+ }): void;
34
+ /** A run is starting. It tells the agent everything that is waiting. */
35
+ started(): void;
36
+ /** A run ended. */
37
+ ended(cancelled: boolean): void;
38
+ /** No more runs by itself. */
39
+ close(): void;
40
+ }
41
+ export declare function createWaker(options: WakerOptions): Waker;
@@ -0,0 +1,71 @@
1
+ const CACHE_CHECK_MS = 240_000;
2
+ const checkInput = (labels) => `Background work you started is still running: ${labels.join(", ")}. Check on it once now (for example read its output), then stop and wait; you will be told when it ends. Do not poll.`;
3
+ /** The run of an agent that woke itself when the application gave no setting: nobody shows it, the log has all of it. */
4
+ async function drain(handle) {
5
+ for await (const _event of handle.events)
6
+ void _event;
7
+ }
8
+ export function createWaker(options) {
9
+ const { setting } = options;
10
+ let timer;
11
+ let closed = false;
12
+ let checkUsed = false; // the one check per quiet period has been made
13
+ let owed = false; // something that asked to wake the agent ended, and no run has told it yet
14
+ function wakeWith(input) {
15
+ if (closed || options.busy())
16
+ return;
17
+ const handle = options.start(input);
18
+ if (setting)
19
+ setting.onRun(handle);
20
+ else
21
+ void drain(handle);
22
+ }
23
+ return {
24
+ signalled(wish) {
25
+ if (!(wish?.wake ?? setting !== undefined))
26
+ return;
27
+ clearTimeout(timer);
28
+ checkUsed = false;
29
+ owed = true;
30
+ wakeWith(); // while a run is going, it delivers the notice itself or `ended` does
31
+ },
32
+ started() {
33
+ owed = false;
34
+ },
35
+ ended(cancelled) {
36
+ if (closed)
37
+ return;
38
+ if (cancelled) {
39
+ owed = false;
40
+ clearTimeout(timer);
41
+ return;
42
+ }
43
+ const pending = options.pending();
44
+ if (pending.signals.length > 0 && (setting || owed)) {
45
+ wakeWith();
46
+ return;
47
+ }
48
+ if (pending.signals.length === 0)
49
+ owed = false;
50
+ if (!setting)
51
+ return; // the check before the cache expires is the application's to ask for
52
+ if (pending.watching.length === 0 || checkUsed)
53
+ return;
54
+ clearTimeout(timer);
55
+ timer = setTimeout(() => {
56
+ const now = options.pending();
57
+ if (now.signals.length > 0)
58
+ return wakeWith();
59
+ if (now.watching.length === 0)
60
+ return;
61
+ checkUsed = true;
62
+ wakeWith(checkInput(now.watching));
63
+ }, setting.cacheCheckMs ?? CACHE_CHECK_MS);
64
+ timer.unref();
65
+ },
66
+ close() {
67
+ closed = true;
68
+ clearTimeout(timer);
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,13 @@
1
+ /** Thrown when a confined environment is asked for something outside its working directory. */
2
+ export declare class OutsideDirectoryError extends Error {
3
+ readonly path: string;
4
+ readonly root: string;
5
+ constructor(path: string, root: string);
6
+ }
7
+ /** Whether an absolute, normalised path is the root or below it, by text ("/work-evil" is not inside "/work"). */
8
+ export declare const isInside: (root: string, path: string) => boolean;
9
+ /**
10
+ * Where a path really is, following links, for a path that may not exist yet: the real location of its deepest existing ancestor, plus the
11
+ * names below it. A link that points nowhere is not followed blindly (writing through it would create its target): it counts as outside.
12
+ */
13
+ export declare function realLocation(path: string, root: string): Promise<string>;
@@ -0,0 +1,40 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import { basename, dirname, resolve, sep } from "node:path";
3
+ /** Thrown when a confined environment is asked for something outside its working directory. */
4
+ export class OutsideDirectoryError extends Error {
5
+ path;
6
+ root;
7
+ constructor(path, root) {
8
+ super(`${path} is outside the working directory ${root}`);
9
+ this.name = "OutsideDirectoryError";
10
+ this.path = path;
11
+ this.root = root;
12
+ }
13
+ }
14
+ /** Whether an absolute, normalised path is the root or below it, by text ("/work-evil" is not inside "/work"). */
15
+ export const isInside = (root, path) => path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
16
+ /**
17
+ * Where a path really is, following links, for a path that may not exist yet: the real location of its deepest existing ancestor, plus the
18
+ * names below it. A link that points nowhere is not followed blindly (writing through it would create its target): it counts as outside.
19
+ */
20
+ export async function realLocation(path, root) {
21
+ const below = [];
22
+ let current = path;
23
+ while (true) {
24
+ try {
25
+ return resolve(await realpath(current), ...below.reverse());
26
+ }
27
+ catch (error) {
28
+ const code = error.code;
29
+ if (code !== "ENOENT" && code !== "ENOTDIR")
30
+ throw error;
31
+ if (await lstat(current).then(() => true, () => false))
32
+ throw new OutsideDirectoryError(path, root); // a dangling link
33
+ const parent = dirname(current);
34
+ if (parent === current)
35
+ throw error;
36
+ below.push(basename(current));
37
+ current = parent;
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,33 @@
1
+ import type { RemoteRunner } from "./remote.js";
2
+ export interface SshOptions {
3
+ /** What to connect to: a name from ~/.ssh/config, or user@host. Authentication, keys and host keys are whatever ssh is set up to do. */
4
+ host: string;
5
+ port?: number;
6
+ identityFile?: string;
7
+ /** Extra ssh options, written as `-o Name=value` (ProxyJump, StrictHostKeyChecking, ...). */
8
+ options?: Record<string, string>;
9
+ /** Seconds to wait for a connection to be made (default 15). */
10
+ connectTimeoutSec?: number;
11
+ /** The ssh program to run (default "ssh"). */
12
+ ssh?: string;
13
+ }
14
+ export interface SshConnection {
15
+ /** Runs a command on the remote machine over the connection. Rejects with RemoteConnectionError when ssh could not reach it. */
16
+ run: RemoteRunner;
17
+ /** Hangs up and removes what was made on this machine. Safe to call more than once. */
18
+ close(): Promise<void>;
19
+ }
20
+ /**
21
+ * Opens one connection to the machine and runs every command over it. The first ssh logs in (once) and stays in the background; each later ssh finds it
22
+ * through a socket in a directory of ours and only starts a new process on the other side, so a command costs milliseconds and not a login. If the
23
+ * connection dies, the next command makes a new one by itself. Nothing is promised about the connection: when it cannot be reached, the command's
24
+ * result is a RemoteConnectionError and the caller says so.
25
+ */
26
+ export declare function connectSsh(options: SshOptions): Promise<SshConnection>;
27
+ /** What the model is told about where its tools run, over ssh. */
28
+ export declare function describeSsh(host: string, cwd: string): string;
29
+ /** An environment whose tools run on another machine over ssh. Close it (or dispose of the agent) to hang up. */
30
+ export declare function createSshEnvironment(options: SshOptions & {
31
+ cwd: string;
32
+ workDir?: string;
33
+ }): Promise<import("../../kernel/src/index.js").Environment>;