sakuracode 0.0.0-stage → 0.1.0

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 (117) hide show
  1. package/README.md +256 -2
  2. package/README.zh-CN.md +257 -0
  3. package/dist/LICENSE +21 -0
  4. package/dist/THIRD_PARTY_NOTICES.md +13 -0
  5. package/dist/code/bin/sakuracode.mjs +5 -0
  6. package/dist/code/src/agent-session.js +41 -0
  7. package/dist/code/src/ansi.js +220 -0
  8. package/dist/code/src/cli.js +211 -0
  9. package/dist/code/src/config.js +183 -0
  10. package/dist/code/src/exec-mode.js +84 -0
  11. package/dist/code/src/framework-testing.js +2 -0
  12. package/dist/code/src/framework.js +3 -0
  13. package/dist/code/src/idle-compaction.js +38 -0
  14. package/dist/code/src/images.js +18 -0
  15. package/dist/code/src/invocation.js +39 -0
  16. package/dist/code/src/markdown.js +383 -0
  17. package/dist/code/src/mentions.js +22 -0
  18. package/dist/code/src/permissions.js +31 -0
  19. package/dist/code/src/providers.js +56 -0
  20. package/dist/code/src/reports.js +81 -0
  21. package/dist/code/src/run-driver.js +76 -0
  22. package/dist/code/src/session-controller.js +405 -0
  23. package/dist/code/src/session-store.js +92 -0
  24. package/dist/code/src/shell.js +36 -0
  25. package/dist/code/src/slash-commands.js +83 -0
  26. package/dist/code/src/status-line.js +26 -0
  27. package/dist/code/src/toml.js +256 -0
  28. package/dist/code/src/transcript.js +288 -0
  29. package/dist/code/src/tui/app.js +267 -0
  30. package/dist/code/src/tui/keys.js +151 -0
  31. package/dist/code/src/tui/line-editor.js +324 -0
  32. package/dist/code/src/tui/live-region.js +71 -0
  33. package/dist/code/src/tui/screen.js +69 -0
  34. package/dist/code/src/tui/transcript-view.js +102 -0
  35. package/dist/licenses/codex-Apache-2.0.txt +201 -0
  36. package/dist/licenses/gitleaks-MIT.txt +21 -0
  37. package/dist/licenses/pi-mono-MIT.txt +21 -0
  38. package/dist/packages/agent/src/compose.js +91 -0
  39. package/dist/packages/agent/src/index.js +161 -0
  40. package/dist/packages/agent/src/restrict-tools.js +11 -0
  41. package/dist/packages/agent/src/system-prompt.js +49 -0
  42. package/dist/packages/agent/src/wake.js +71 -0
  43. package/dist/packages/env/src/confine.js +40 -0
  44. package/dist/packages/env/src/connect-ssh.js +242 -0
  45. package/dist/packages/env/src/index.js +4 -0
  46. package/dist/packages/env/src/local.js +206 -0
  47. package/dist/packages/env/src/remote-stream.js +143 -0
  48. package/dist/packages/env/src/remote-types.js +10 -0
  49. package/dist/packages/env/src/remote.js +183 -0
  50. package/dist/packages/evals/src/bfcl.js +71 -0
  51. package/dist/packages/evals/src/index.js +2 -0
  52. package/dist/packages/evals/src/png.js +30 -0
  53. package/dist/packages/evals/src/pool.js +22 -0
  54. package/dist/packages/evals/src/providers.js +23 -0
  55. package/dist/packages/kernel/src/channel.js +34 -0
  56. package/dist/packages/kernel/src/compaction.js +86 -0
  57. package/dist/packages/kernel/src/constants.js +5 -0
  58. package/dist/packages/kernel/src/goal.js +65 -0
  59. package/dist/packages/kernel/src/hooks.js +153 -0
  60. package/dist/packages/kernel/src/index.js +4 -0
  61. package/dist/packages/kernel/src/loop.js +556 -0
  62. package/dist/packages/kernel/src/prompts.js +99 -0
  63. package/dist/packages/kernel/src/system.js +49 -0
  64. package/dist/packages/kernel/src/types.js +23 -0
  65. package/dist/packages/kernel/src/usage.js +16 -0
  66. package/dist/packages/llm/src/chat/compat.js +16 -0
  67. package/dist/packages/llm/src/chat/errors.js +61 -0
  68. package/dist/packages/llm/src/chat/request.js +109 -0
  69. package/dist/packages/llm/src/chat/retry.js +57 -0
  70. package/dist/packages/llm/src/chat/stream.js +122 -0
  71. package/dist/packages/llm/src/index.js +120 -0
  72. package/dist/packages/plugins/agents/src/index.js +206 -0
  73. package/dist/packages/plugins/agents/src/view.js +60 -0
  74. package/dist/packages/plugins/context/src/index.js +67 -0
  75. package/dist/packages/plugins/limits/src/cost.js +3 -0
  76. package/dist/packages/plugins/limits/src/index.js +31 -0
  77. package/dist/packages/plugins/redaction/src/engine.js +110 -0
  78. package/dist/packages/plugins/redaction/src/index.js +69 -0
  79. package/dist/packages/plugins/redaction/src/restore.js +44 -0
  80. package/dist/packages/plugins/redaction/src/rules.gen.js +2059 -0
  81. package/dist/packages/plugins/resilience/src/index.js +86 -0
  82. package/dist/packages/plugins/review/src/chain.js +27 -0
  83. package/dist/packages/plugins/review/src/hard-rules.js +156 -0
  84. package/dist/packages/plugins/review/src/index.js +93 -0
  85. package/dist/packages/plugins/review/src/policy.js +65 -0
  86. package/dist/packages/plugins/review/src/reviewer.js +207 -0
  87. package/dist/packages/plugins/review/src/shell.js +124 -0
  88. package/dist/packages/plugins/tools/src/artifacts.js +61 -0
  89. package/dist/packages/plugins/tools/src/builtin/args.js +46 -0
  90. package/dist/packages/plugins/tools/src/builtin/bash.js +110 -0
  91. package/dist/packages/plugins/tools/src/builtin/edit.js +71 -0
  92. package/dist/packages/plugins/tools/src/builtin/image.js +84 -0
  93. package/dist/packages/plugins/tools/src/builtin/read.js +89 -0
  94. package/dist/packages/plugins/tools/src/builtin/write.js +35 -0
  95. package/dist/packages/plugins/tools/src/edit-match.js +159 -0
  96. package/dist/packages/plugins/tools/src/fold.js +65 -0
  97. package/dist/packages/plugins/tools/src/index.js +10 -0
  98. package/dist/packages/plugins/toolset/src/bm25.js +35 -0
  99. package/dist/packages/plugins/toolset/src/codemode.js +226 -0
  100. package/dist/packages/plugins/toolset/src/index.js +3 -0
  101. package/dist/packages/plugins/toolset/src/mcp.js +173 -0
  102. package/dist/packages/plugins/toolset/src/search.js +57 -0
  103. package/dist/packages/plugins/trace/src/index.js +29 -0
  104. package/dist/packages/plugins/trace/src/modules.js +28 -0
  105. package/dist/packages/session/src/file-log.js +144 -0
  106. package/dist/packages/session/src/fork.js +70 -0
  107. package/dist/packages/session/src/index.js +3 -0
  108. package/dist/packages/session/src/log-core.js +47 -0
  109. package/dist/packages/session/src/memory-log.js +7 -0
  110. package/dist/packages/session/src/projection.js +107 -0
  111. package/dist/packages/session/src/tree.js +35 -0
  112. package/dist/packages/testing/src/faux.js +69 -0
  113. package/dist/packages/testing/src/index.js +2 -0
  114. package/dist/packages/testing/src/rng.js +17 -0
  115. package/dist/sdk/index.js +10 -0
  116. package/dist/sdk/testing.js +2 -0
  117. package/package.json +54 -4
@@ -0,0 +1,56 @@
1
+ // Model providers: the built-in ones, the ones from the config file, and turning a choice into the framework's ModelConfig.
2
+ import { compatPresets } from "./framework.js";
3
+ export class ProviderSetupError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = "ProviderSetupError";
7
+ }
8
+ }
9
+ /** Chat Completions providers that are known to work. The order is the order tried when the user chose none. */
10
+ export const BUILTIN_PROVIDERS = {
11
+ deepseek: { baseUrl: "https://api.deepseek.com", model: "deepseek-flash", keyEnv: "DEEPSEEK_API_KEY", contextWindow: 128_000, compat: "deepseek", images: true },
12
+ // OpenCode Go refuses a request without a session id (error MissingSessionID); the id is the terminal session's id.
13
+ opencode: { baseUrl: "https://opencode.ai/zen/go/v1", model: "deepseek-v4.1-flash", keyEnv: "OPENCODE_API_KEY", contextWindow: 128_000, compat: "deepseek", images: true, sessionHeader: "x-opencode-session" },
14
+ };
15
+ export function allProviders(settings) {
16
+ return { ...BUILTIN_PROVIDERS, ...settings.providers };
17
+ }
18
+ export function listProviders(settings, env) {
19
+ return Object.entries(allProviders(settings)).map(([name, definition]) => ({ name, definition, keyPresent: Boolean(env[definition.keyEnv]), builtin: name in BUILTIN_PROVIDERS && !(name in settings.providers) }));
20
+ }
21
+ /** The provider to use: the one the user chose, else the first one whose key is set. */
22
+ export function chooseProvider(settings, env) {
23
+ const all = allProviders(settings);
24
+ if (settings.provider !== undefined) {
25
+ if (!(settings.provider in all))
26
+ throw new ProviderSetupError(`unknown provider "${settings.provider}" (known: ${Object.keys(all).join(", ")})`);
27
+ return settings.provider;
28
+ }
29
+ const withKey = listProviders(settings, env).find((entry) => entry.keyPresent);
30
+ if (!withKey) {
31
+ const needed = Object.values(all).map((definition) => definition.keyEnv);
32
+ throw new ProviderSetupError(`no API key found; set one of: ${needed.join(", ")} (\`sakuracode providers\` shows what is set)`);
33
+ }
34
+ return withKey.name;
35
+ }
36
+ export function buildModelConfig(request) {
37
+ const definition = allProviders(request.settings)[request.provider];
38
+ if (!definition)
39
+ throw new ProviderSetupError(`unknown provider "${request.provider}"`);
40
+ const apiKey = request.env[definition.keyEnv];
41
+ if (!apiKey)
42
+ throw new ProviderSetupError(`provider "${request.provider}" needs the environment variable ${definition.keyEnv}`);
43
+ const compat = definition.compat === undefined ? undefined : compatPresets[definition.compat];
44
+ if (definition.compat !== undefined && !compat)
45
+ throw new ProviderSetupError(`provider "${request.provider}": unknown compat preset "${definition.compat}" (known: ${Object.keys(compatPresets).join(", ")})`);
46
+ const headers = { ...definition.headers, ...(definition.sessionHeader ? { [definition.sessionHeader]: request.sessionId } : {}) };
47
+ const withImages = compat || definition.images ? { ...compat, ...(definition.images ? { images: true } : {}) } : undefined;
48
+ return {
49
+ baseUrl: definition.baseUrl,
50
+ apiKey,
51
+ model: request.model ?? definition.model,
52
+ contextWindow: definition.contextWindow,
53
+ ...(withImages ? { compat: withImages } : {}),
54
+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
55
+ };
56
+ }
@@ -0,0 +1,81 @@
1
+ import { PERMISSION_NAMES, PERMISSION_PRESETS } from "./permissions.js";
2
+ const number = (value) => value.toLocaleString("en-US");
3
+ /** Token counts the short way (12.3k, 6.40M), as the mature tools print them. */
4
+ export function tokens(value) {
5
+ if (value < 1000)
6
+ return String(value);
7
+ if (value < 999_500)
8
+ return `${(value / 1000).toFixed(value < 100_000 ? 1 : 0)}k`;
9
+ return `${(value / 1_000_000).toFixed(2)}M`;
10
+ }
11
+ /** The share of one reply's input that came from the cache: cache read / (cache read + new input + cache write). */
12
+ function replyHitRate(usage) {
13
+ const total = usage.input + usage.cacheRead + usage.cacheWrite;
14
+ return total > 0 ? usage.cacheRead / total : undefined;
15
+ }
16
+ /** The middle one of the per-reply cache hit rates of the model replies in a log (compaction and goal-check requests are not replies). */
17
+ export function medianReplyHitRate(entries) {
18
+ const rates = [];
19
+ for (const entry of entries) {
20
+ if (entry.type !== "message" || entry.message.role !== "assistant" || !entry.message.usage)
21
+ continue;
22
+ const rate = replyHitRate(entry.message.usage);
23
+ if (rate !== undefined)
24
+ rates.push(rate);
25
+ }
26
+ if (rates.length === 0)
27
+ return undefined;
28
+ rates.sort((a, b) => a - b);
29
+ const middle = Math.floor(rates.length / 2);
30
+ return rates.length % 2 === 1 ? rates[middle] : (rates[middle - 1] + rates[middle]) / 2;
31
+ }
32
+ const percent = (share) => `${(share * 100).toFixed(1)}%`;
33
+ export function statusReport(input) {
34
+ const free = input.context.window > 0 ? Math.round((input.context.free / input.context.window) * 100) : 0;
35
+ const rows = [
36
+ ["Session", `\`${input.sessionId}\``],
37
+ ["Session file", `\`${input.sessionFile}\``],
38
+ ["Directory", `\`${input.cwd}\` (not restricted: the agent may go anywhere)`],
39
+ ["Model", `${input.model} via ${input.provider}${input.thinking ? `, thinking ${input.thinking}` : ""}`],
40
+ ["Permissions", `${input.permissions}: ${PERMISSION_PRESETS[input.permissions].summary}`],
41
+ ["Redaction", input.redaction ? "on" : "off"],
42
+ ["Context", `${number(input.context.tokens)} of ${number(input.context.window)} tokens used, ${free}% left${input.context.compactAt ? `, compacts at ${number(input.context.compactAt)}` : ""}`],
43
+ ...usageRows(input),
44
+ ["Goal", input.goal ? `${input.goal.status}: ${input.goal.objective}` : "none"],
45
+ ["Idle compaction", input.idleCompaction],
46
+ ["State", input.working ? "working" : "idle"],
47
+ ];
48
+ return ["| Item | Value |", "|---|---|", ...rows.map(([key, value]) => `| **${key}** | ${value} |`)].join("\n");
49
+ }
50
+ export function modelsReport(listings, current) {
51
+ const rows = listings.map((entry) => {
52
+ const here = entry.name === current.provider;
53
+ return `| ${here ? "▶ " : ""}\`${entry.name}\` | ${here ? current.model : entry.definition.model} | \`${entry.definition.keyEnv}\` ${entry.keyPresent ? "set" : "**not set**"} |`;
54
+ });
55
+ return ["| Provider | Model | Key |", "|---|---|---|", ...rows, "", "Switch with `/model <provider>` or `/model <provider> <model>`."].join("\n");
56
+ }
57
+ export function permissionsReport(current, redaction) {
58
+ const rows = PERMISSION_NAMES.map((name) => `| ${name === current ? "▶ " : ""}\`${name}\` | ${PERMISSION_PRESETS[name].summary} |`);
59
+ return ["| Preset | Meaning |", "|---|---|", ...rows, "", `Redaction is **${redaction ? "on" : "off"}** (\`/redaction on\`). The working directory is never restricted.`].join("\n");
60
+ }
61
+ export function mcpReport(status) {
62
+ const names = Object.keys(status);
63
+ if (names.length === 0)
64
+ return "No MCP servers are configured. Add them under `[mcp.servers.NAME]` in the config file.";
65
+ return ["| Server | State | Tools |", "|---|---|---|", ...names.map((name) => `| \`${name}\` | ${status[name].ok ? "connected" : `failed: ${status[name].error ?? "unknown"}`} | ${status[name].toolCount} |`)].join("\n");
66
+ }
67
+ /**
68
+ * Token use in the shape of the mature tools: the whole input with the cached part named (as Claude Code's /usage does),
69
+ * the cache hit rate (as its "Prompt cache" line does), the output, and the replies they belong to.
70
+ */
71
+ function usageRows(input) {
72
+ const { usage } = input;
73
+ const totalInput = usage.input + usage.cacheRead + usage.cacheWrite;
74
+ const written = usage.cacheWrite > 0 ? `, ${tokens(usage.cacheWrite)} written to the cache` : "";
75
+ const hit = totalInput > 0 ? `${percent(usage.cacheRead / totalInput)} of input tokens from the cache${input.medianHitRate === undefined ? "" : `; median reply ${percent(input.medianHitRate)}`}` : "no requests yet";
76
+ return [
77
+ ["Input", totalInput > 0 ? `${tokens(totalInput)} tokens: ${tokens(usage.cacheRead)} cached, ${tokens(usage.input)} new${written}` : "none yet"],
78
+ ["Cache hit rate", `${hit} (${number(usage.turns)} replies; compaction and goal checks included in the totals, review requests not counted)`],
79
+ ["Output", `${tokens(usage.output)} tokens`],
80
+ ];
81
+ }
@@ -0,0 +1,76 @@
1
+ import { textOf } from "./transcript.js";
2
+ export function createRunDriver(transcript) {
3
+ let working = false;
4
+ let current;
5
+ let pending = [];
6
+ const eventListeners = new Set();
7
+ const changeListeners = new Set();
8
+ const changed = () => {
9
+ for (const listener of changeListeners)
10
+ listener();
11
+ };
12
+ /** The kernel announces a steered or queued message with a user `message_end` when it takes it. */
13
+ function delivered(event) {
14
+ if (event.type !== "message_end" || event.message.role !== "user")
15
+ return;
16
+ const text = textOf(event.message.content);
17
+ const index = pending.findIndex((item) => item.text === text);
18
+ if (index !== -1) {
19
+ pending.splice(index, 1);
20
+ changed();
21
+ }
22
+ }
23
+ return {
24
+ get working() {
25
+ return working;
26
+ },
27
+ pending: () => pending,
28
+ async run(agent, input, options) {
29
+ working = true;
30
+ pending = [];
31
+ current = { agent };
32
+ changed();
33
+ const handle = agent.run(input, options);
34
+ try {
35
+ for await (const event of handle.events) {
36
+ transcript.apply(event);
37
+ for (const listener of eventListeners)
38
+ listener(event);
39
+ delivered(event);
40
+ }
41
+ const result = await handle.result;
42
+ const leftover = pending;
43
+ pending = [];
44
+ return { result, leftover };
45
+ }
46
+ finally {
47
+ working = false;
48
+ current = undefined;
49
+ changed();
50
+ }
51
+ },
52
+ steer(text) {
53
+ if (!working || !current)
54
+ return;
55
+ pending.push({ kind: "steer", text });
56
+ current.agent.steer(text);
57
+ changed();
58
+ },
59
+ queue(text) {
60
+ if (!working || !current)
61
+ return;
62
+ pending.push({ kind: "queue", text });
63
+ current.agent.queue(text);
64
+ changed();
65
+ },
66
+ cancel: () => current?.agent.cancel(),
67
+ onEvent(listener) {
68
+ eventListeners.add(listener);
69
+ return () => eventListeners.delete(listener);
70
+ },
71
+ onChange(listener) {
72
+ changeListeners.add(listener);
73
+ return () => changeListeners.delete(listener);
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,405 @@
1
+ // The application core. It owns the current agent session and decides what a submitted line means: a prompt, a slash command,
2
+ // a shell command, or a steering/queued message while the agent works. It has no terminal I/O; it talks to the outside only
3
+ // through the Transcript (what to show) and the Surface (what only a screen can do).
4
+ import { openAgentSession } from "./agent-session.js";
5
+ import { expandMentions } from "./mentions.js";
6
+ import { isPermissionName } from "./permissions.js";
7
+ import { allProviders, chooseProvider, listProviders } from "./providers.js";
8
+ import { mcpReport, medianReplyHitRate, modelsReport, permissionsReport, statusReport } from "./reports.js";
9
+ import { createRunDriver } from "./run-driver.js";
10
+ import { sessionLabel, titleFromPrompt } from "./session-store.js";
11
+ import { runShell } from "./shell.js";
12
+ import { findCommand, helpMarkdown, parseSlash } from "./slash-commands.js";
13
+ import { contextLeftPercent } from "./status-line.js";
14
+ import { excerpt, replayLog } from "./transcript.js";
15
+ import { imageMediaType } from "./images.js";
16
+ /** Below this a context is never compacted on its own, whatever the setting says. */
17
+ const MIN_COMPACT_TOKENS = 1000;
18
+ const thousands = (tokens) => (tokens < 1000 ? String(tokens) : `${Math.round(tokens / 1000)}k`);
19
+ // tracked changes against HEAD (or the index in a repository without commits), then every untracked file as an addition
20
+ const DIFF_COMMAND = 'git --no-pager diff HEAD 2>/dev/null || git --no-pager diff; git ls-files --others --exclude-standard | while read -r f; do git --no-pager diff --no-index -- /dev/null "$f"; done';
21
+ export function createController(deps) {
22
+ const { transcript, surface, store } = deps;
23
+ const openSession = deps.openSession ?? openAgentSession;
24
+ const shell = deps.runShell ?? runShell;
25
+ const driver = createRunDriver(transcript);
26
+ let session;
27
+ let meta;
28
+ let contextPercent;
29
+ const firstImages = [...(deps.images ?? [])];
30
+ let choice = {
31
+ provider: deps.clients ? "scripted" : chooseProvider(deps.settings, deps.env),
32
+ model: deps.settings.model,
33
+ permissions: deps.settings.permissions,
34
+ redaction: deps.settings.redaction,
35
+ thinking: deps.settings.thinking,
36
+ };
37
+ driver.onChange(() => surface.changed());
38
+ const notice = (level, text) => transcript.add({ kind: "notice", level, text });
39
+ const requireSession = () => {
40
+ if (!session)
41
+ throw new Error("no session is open");
42
+ return session;
43
+ };
44
+ // ---------- opening and closing ----------
45
+ async function open(target, next) {
46
+ const opened = await openSession({
47
+ settings: deps.settings,
48
+ provider: next.provider,
49
+ model: next.model,
50
+ sessionId: target.id,
51
+ sessionFile: store.sessionFile(target.id),
52
+ cwd: deps.cwd,
53
+ permissions: next.permissions,
54
+ redaction: next.redaction,
55
+ thinking: next.thinking,
56
+ maxTokens: deps.settings.maxTokens,
57
+ env: deps.env,
58
+ onRetry: (message) => notice("warn", message),
59
+ ...(deps.clients ? { clients: deps.clients } : {}),
60
+ });
61
+ session = opened;
62
+ choice = { ...next, model: opened.model };
63
+ meta = await store.update(target.id, { provider: next.provider, model: opened.model });
64
+ await refreshContext();
65
+ surface.changed();
66
+ }
67
+ async function closeCurrent(options) {
68
+ if (!session || !meta)
69
+ return;
70
+ const closing = meta;
71
+ await session.close();
72
+ session = undefined;
73
+ if (options.discardIfEmpty && closing.title === undefined)
74
+ await store.remove(closing.id);
75
+ }
76
+ /** Reopens the current session with other settings; if that fails the old settings are restored. */
77
+ async function reopen(patch) {
78
+ if (!meta)
79
+ return false;
80
+ const previous = choice;
81
+ const target = meta;
82
+ await closeCurrent({ discardIfEmpty: false });
83
+ try {
84
+ await open(target, { ...previous, ...patch });
85
+ return true;
86
+ }
87
+ catch (error) {
88
+ notice("error", `Could not switch: ${error.message}`);
89
+ await open(target, previous);
90
+ return false;
91
+ }
92
+ }
93
+ async function startFresh() {
94
+ const created = await store.create({ cwd: deps.cwd, provider: choice.provider, model: choice.model ?? "" });
95
+ await open(created, choice);
96
+ }
97
+ async function switchTo(target) {
98
+ await closeCurrent({ discardIfEmpty: true });
99
+ const known = target.provider in allProviders(deps.settings) || deps.clients !== undefined;
100
+ const keyed = deps.clients !== undefined || Boolean(deps.env[allProviders(deps.settings)[target.provider]?.keyEnv ?? ""]);
101
+ const next = known && keyed && deps.settings.provider === undefined ? { ...choice, provider: target.provider, model: target.model } : choice;
102
+ transcript.reset();
103
+ await open(target, next);
104
+ const entries = await requireSession().agent.log.branch();
105
+ replayLog(transcript, entries);
106
+ notice("info", `Resumed ${sessionLabel(meta ?? target)} (${target.id.slice(0, 8)}).`);
107
+ }
108
+ async function refreshContext() {
109
+ if (!session)
110
+ return;
111
+ const status = await session.agent.context();
112
+ contextPercent = contextLeftPercent(status.free, status.window);
113
+ }
114
+ // ---------- running ----------
115
+ async function runInput(input, options) {
116
+ const active = requireSession();
117
+ let next = input;
118
+ let outcome;
119
+ for (;;) {
120
+ outcome = await driver.run(active.agent, next, options?.compact ? { compact: true } : undefined);
121
+ reportEnd(outcome.result);
122
+ await refreshContext();
123
+ if (outcome.leftover.length === 0 || outcome.result.reason !== "done")
124
+ break;
125
+ next = outcome.leftover.map((item) => item.text).join("\n\n");
126
+ options = undefined;
127
+ }
128
+ if (meta)
129
+ meta = await store.update(meta.id, {});
130
+ return outcome.result;
131
+ }
132
+ function reportEnd(result) {
133
+ if (result.reason === "error")
134
+ notice("error", `The run failed: ${result.error ?? "unknown error"}`);
135
+ else if (result.reason === "paused" && result.pause === "approval")
136
+ notice("warn", "The run was paused because too many actions were refused. Send a message to continue.");
137
+ }
138
+ // Between "a prompt was submitted" and "the driver is working" there are a few awaits; a second line typed in that
139
+ // moment must wait for the run to exist, or it would start a second run.
140
+ let launching;
141
+ async function startPrompt(text) {
142
+ let ready;
143
+ launching = new Promise((resolve) => (ready = resolve));
144
+ try {
145
+ if (meta && meta.title === undefined)
146
+ meta = await store.update(meta.id, { title: titleFromPrompt(text) });
147
+ const mentions = await expandMentions(text, deps.cwd, deps.home);
148
+ const images = [...firstImages.splice(0), ...mentions.images];
149
+ const body = mentions.text;
150
+ const running = runInput(images.length === 0 ? body : [{ type: "text", text: body }, ...images.map((path) => ({ type: "image", mediaType: imageMediaType(path) ?? "image/png", ref: path }))]);
151
+ // driver.run marks the driver as working before its first await
152
+ launching = undefined;
153
+ ready();
154
+ return await running;
155
+ }
156
+ finally {
157
+ launching = undefined;
158
+ ready();
159
+ }
160
+ }
161
+ async function runShellForUser(command) {
162
+ const result = await shell(command, deps.cwd);
163
+ const { lines, hidden } = excerpt(result.output, 40);
164
+ transcript.add({ kind: "shell", command, output: lines, hidden, exitCode: result.exitCode });
165
+ }
166
+ // ---------- the commands ----------
167
+ const host = {
168
+ print: (markdown) => transcript.add({ kind: "info", markdown }),
169
+ fail: (text) => notice("error", text),
170
+ help: helpMarkdown,
171
+ async status() {
172
+ const active = requireSession();
173
+ return statusReport({
174
+ sessionId: meta?.id ?? "?",
175
+ sessionFile: store.sessionFile(meta?.id ?? "?"),
176
+ cwd: deps.cwd,
177
+ provider: active.provider,
178
+ model: active.model,
179
+ thinking: active.thinking,
180
+ permissions: active.permissions,
181
+ redaction: active.redaction,
182
+ context: await active.agent.context(),
183
+ usage: await active.agent.usage(),
184
+ medianHitRate: medianReplyHitRate(await active.agent.log.branch()),
185
+ goal: await active.agent.goal(),
186
+ working: driver.working,
187
+ idleCompaction: deps.settings.autoCompactIdle ? `after ${deps.settings.cacheTtlMinutes} min idle, from ${deps.settings.autoCompactMinTokens.toLocaleString("en-US")} tokens` : "off",
188
+ });
189
+ },
190
+ models: () => modelsReport(listProviders(deps.settings, deps.env), { provider: choice.provider, model: choice.model ?? "" }),
191
+ async switchModel(provider, model) {
192
+ if (provider === undefined)
193
+ return;
194
+ if (!(provider in allProviders(deps.settings)))
195
+ return notice("error", `Unknown provider "${provider}". Known: ${Object.keys(allProviders(deps.settings)).join(", ")}.`);
196
+ if (await reopen({ provider, model }))
197
+ notice("info", `Now using ${choice.model} via ${choice.provider}. The conversation continues.`);
198
+ },
199
+ async permissions(name) {
200
+ if (name === undefined)
201
+ return host.print(permissionsReport(choice.permissions, choice.redaction));
202
+ if (!isPermissionName(name))
203
+ return notice("error", `Unknown preset "${name}". Use auto, read-only, strict or full-access.`);
204
+ if (await reopen({ permissions: name }))
205
+ notice("info", `Permissions: ${name}.`);
206
+ },
207
+ async redaction(value) {
208
+ if (value === undefined)
209
+ return host.print(`Redaction is **${choice.redaction ? "on" : "off"}**. Use \`/redaction on\` or \`/redaction off\`.`);
210
+ if (await reopen({ redaction: value === "on" }))
211
+ notice("info", `Redaction is ${value}.`);
212
+ },
213
+ async compact() {
214
+ let compacted = false;
215
+ const stop = driver.onEvent((event) => {
216
+ if (event.type === "compaction")
217
+ compacted = true;
218
+ });
219
+ const result = await runInput(undefined, { compact: true });
220
+ stop();
221
+ if (result.reason === "done" && !compacted)
222
+ notice("info", "Nothing worth summarising yet.");
223
+ },
224
+ async newSession() {
225
+ await closeCurrent({ discardIfEmpty: true });
226
+ transcript.reset();
227
+ await startFresh();
228
+ notice("info", `New session ${meta?.id.slice(0, 8)}.`);
229
+ },
230
+ async clearScreen() {
231
+ surface.clearScreen();
232
+ await host.newSession();
233
+ },
234
+ async resume(reference) {
235
+ let target;
236
+ if (reference === undefined) {
237
+ const all = (await store.list()).filter((item) => item.title !== undefined && item.id !== meta?.id);
238
+ if (all.length === 0)
239
+ return notice("info", "There are no earlier sessions to resume.");
240
+ target = await surface.pickSession(all);
241
+ if (!target)
242
+ return;
243
+ }
244
+ else {
245
+ target = await store.find(reference, reference === "last" ? { cwd: deps.cwd } : {});
246
+ if (!target && reference === "last")
247
+ target = (await store.list()).find((item) => item.title !== undefined && item.id !== meta?.id);
248
+ if (!target)
249
+ return notice("error", `No session matches "${reference}".`);
250
+ }
251
+ await switchTo(target);
252
+ },
253
+ async fork() {
254
+ const active = requireSession();
255
+ if (!meta || meta.title === undefined)
256
+ return notice("info", "There is nothing to fork yet.");
257
+ const child = await store.create({ cwd: deps.cwd, provider: choice.provider, model: choice.model ?? "", parentId: meta.id });
258
+ await active.agent.fork(store.sessionFile(child.id));
259
+ await store.update(child.id, { title: `fork: ${meta.title}` });
260
+ const forked = (await store.get(child.id));
261
+ await closeCurrent({ discardIfEmpty: false });
262
+ await open(forked, choice);
263
+ notice("info", `Forked into session ${forked.id.slice(0, 8)}. The original session is unchanged.`);
264
+ },
265
+ async undo() {
266
+ const active = requireSession();
267
+ const entries = await active.agent.log.branch();
268
+ const index = entries.findLastIndex((entry) => entry.type === "message" && entry.message.role === "user");
269
+ const entry = entries[index];
270
+ if (!entry || entry.type !== "message")
271
+ return notice("info", "There is no message to undo.");
272
+ active.agent.rewindTo(entry.parentId);
273
+ const text = entry.message.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
274
+ notice("info", "Went back to before your last message. It is in the prompt; edit it and send it again.");
275
+ surface.prefill(text);
276
+ await refreshContext();
277
+ },
278
+ async rename(title) {
279
+ if (!meta)
280
+ return;
281
+ meta = await store.update(meta.id, { title });
282
+ notice("info", `Session renamed to "${title}".`);
283
+ },
284
+ runPrompt(prompt) {
285
+ void startPrompt(prompt).catch((error) => notice("error", error.message));
286
+ },
287
+ async diff() {
288
+ const result = await shell(DIFF_COMMAND, deps.cwd);
289
+ const body = result.output.trim();
290
+ host.print(body === "" ? "No uncommitted changes." : ["````diff", body.length > 20_000 ? `${body.slice(0, 20_000)}\n… (cut)` : body, "````"].join("\n"));
291
+ },
292
+ async goal(argument) {
293
+ const active = requireSession();
294
+ if (argument === undefined) {
295
+ const goal = await active.agent.goal();
296
+ return host.print(goal ? `Goal (${goal.status}): ${goal.objective}` : "No goal is set. Use `/goal <objective>`.");
297
+ }
298
+ if (argument === "clear") {
299
+ await active.agent.clearGoal();
300
+ return notice("info", "Goal cleared.");
301
+ }
302
+ await active.agent.setGoal({ objective: argument });
303
+ notice("info", "Goal set. Working on it now; it is checked before the agent may stop.");
304
+ await startPrompt(argument);
305
+ },
306
+ mcp: () => mcpReport(requireSession().agent.mcp),
307
+ exit: () => surface.exit(),
308
+ };
309
+ // ---------- the public side ----------
310
+ async function dispatchSlash(name, args) {
311
+ const command = findCommand(name);
312
+ if (!command)
313
+ return notice("error", `Unknown command /${name}. Type /help for the list.`);
314
+ if (driver.working && !command.duringRun)
315
+ return notice("warn", `/${command.name} cannot be used while the agent is working. Press Esc to cancel the run first.`);
316
+ try {
317
+ await command.run(args, host);
318
+ }
319
+ catch (error) {
320
+ notice("error", `/${command.name} failed: ${error.message}`);
321
+ }
322
+ surface.changed();
323
+ }
324
+ return {
325
+ driver,
326
+ async start(options = {}) {
327
+ if (options.resume !== undefined) {
328
+ const target = await store.find(options.resume, options.resume === "last" ? { cwd: deps.cwd } : {});
329
+ const found = target ?? (options.resume === "last" ? (await store.list())[0] : undefined);
330
+ if (!found)
331
+ throw new Error(`No session matches "${options.resume}".`);
332
+ await switchTo(found);
333
+ }
334
+ else
335
+ await startFresh();
336
+ },
337
+ async submit(raw, mode = "steer") {
338
+ const text = raw.trim();
339
+ if (text === "")
340
+ return;
341
+ const slash = parseSlash(text);
342
+ if (slash)
343
+ return dispatchSlash(slash.name, slash.args);
344
+ if (text.startsWith("!") && text.length > 1 && !text.startsWith("!!"))
345
+ return runShellForUser(text.slice(1).trim());
346
+ if (launching)
347
+ await launching;
348
+ if (driver.working) {
349
+ const mentioned = await expandMentions(text, deps.cwd, deps.home);
350
+ const expanded = mentioned.images.length === 0 ? mentioned.text : `${mentioned.text}\nImage files (open them with the read tool):\n${mentioned.images.map((path) => `- ${path}`).join("\n")}`;
351
+ if (mode === "queue")
352
+ driver.queue(expanded);
353
+ else
354
+ driver.steer(expanded);
355
+ return;
356
+ }
357
+ await startPrompt(text);
358
+ },
359
+ runPrompt: (text) => startPrompt(text),
360
+ cancel: () => driver.cancel(),
361
+ async compactIdle(minTokens, idleMinutes) {
362
+ if (!session || driver.working)
363
+ return false;
364
+ const before = (await session.agent.context()).tokens;
365
+ if (before < Math.max(MIN_COMPACT_TOKENS, minTokens))
366
+ return false;
367
+ let compacted = false;
368
+ const stop = driver.onEvent((event) => {
369
+ if (event.type === "compaction")
370
+ compacted = true;
371
+ });
372
+ transcript.muteCompaction(true);
373
+ try {
374
+ const result = await runInput(undefined, { compact: true });
375
+ if (result.reason !== "done" || !compacted)
376
+ return false;
377
+ }
378
+ finally {
379
+ transcript.muteCompaction(false);
380
+ stop();
381
+ }
382
+ const after = (await requireSession().agent.context()).tokens;
383
+ notice("info", `compacted after ${idleMinutes} min idle, ${thousands(before)} to ${thousands(after)} tokens`);
384
+ return true;
385
+ },
386
+ statusInfo: () => ({
387
+ provider: choice.provider,
388
+ model: choice.model ?? "",
389
+ thinking: session?.thinking,
390
+ cwd: deps.cwd,
391
+ home: deps.home,
392
+ contextLeftPercent: contextPercent,
393
+ permissions: choice.permissions,
394
+ redaction: choice.redaction,
395
+ working: driver.working,
396
+ queued: driver.pending().length,
397
+ }),
398
+ pending: () => driver.pending(),
399
+ current: () => session,
400
+ currentMeta: () => meta,
401
+ async close() {
402
+ await closeCurrent({ discardIfEmpty: true });
403
+ },
404
+ };
405
+ }