@cjhyy/code-shell-core 0.6.0-rc.1 → 0.6.0-rc.11

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 (69) hide show
  1. package/dist/context/compaction.d.ts +30 -0
  2. package/dist/context/compaction.js +93 -0
  3. package/dist/context/manager.d.ts +18 -0
  4. package/dist/context/manager.js +156 -44
  5. package/dist/context/token-counter.js +13 -0
  6. package/dist/engine/engine.d.ts +22 -12
  7. package/dist/engine/engine.js +263 -81
  8. package/dist/engine/model-connections-pool.js +1 -0
  9. package/dist/engine/model-facade.js +2 -12
  10. package/dist/engine/query.js +2 -0
  11. package/dist/engine/runtime.d.ts +2 -0
  12. package/dist/engine/runtime.js +25 -0
  13. package/dist/engine/session-usage.d.ts +12 -0
  14. package/dist/engine/session-usage.js +56 -0
  15. package/dist/engine/steer-queue.d.ts +2 -1
  16. package/dist/engine/steer-queue.js +2 -2
  17. package/dist/engine/turn-loop.d.ts +28 -2
  18. package/dist/engine/turn-loop.js +153 -26
  19. package/dist/git/utils.d.ts +12 -0
  20. package/dist/git/utils.js +33 -6
  21. package/dist/index.d.ts +4 -3
  22. package/dist/index.js +4 -3
  23. package/dist/llm/capabilities/rules.js +1 -1
  24. package/dist/llm/model-pool.d.ts +7 -0
  25. package/dist/llm/model-pool.js +8 -1
  26. package/dist/model-catalog/builtin.js +6 -1
  27. package/dist/preset/index.d.ts +5 -1
  28. package/dist/preset/index.js +21 -2
  29. package/dist/prompt/composer.d.ts +5 -0
  30. package/dist/prompt/composer.js +10 -2
  31. package/dist/prompt/sections/base.md +1 -0
  32. package/dist/protocol/chat-session-manager.d.ts +1 -0
  33. package/dist/protocol/chat-session-manager.js +2 -0
  34. package/dist/protocol/chat-session.d.ts +4 -1
  35. package/dist/protocol/chat-session.js +9 -3
  36. package/dist/protocol/client.d.ts +5 -1
  37. package/dist/protocol/client.js +8 -2
  38. package/dist/protocol/server.d.ts +13 -12
  39. package/dist/protocol/server.js +199 -67
  40. package/dist/protocol/types.d.ts +14 -0
  41. package/dist/runtime/background-shell.js +14 -0
  42. package/dist/runtime/safe-spawn.js +89 -11
  43. package/dist/runtime/spawn-common.d.ts +15 -4
  44. package/dist/runtime/spawn-common.js +113 -12
  45. package/dist/session/session-manager.js +7 -1
  46. package/dist/session/transcript.d.ts +4 -0
  47. package/dist/session/transcript.js +21 -0
  48. package/dist/tool-system/builtin/bash.js +3 -2
  49. package/dist/tool-system/builtin/cron.js +10 -2
  50. package/dist/tool-system/builtin/edit-model-catalog.js +15 -5
  51. package/dist/tool-system/builtin/generate-video.js +3 -0
  52. package/dist/tool-system/builtin/grep.d.ts +9 -0
  53. package/dist/tool-system/builtin/grep.js +100 -3
  54. package/dist/tool-system/builtin/index.d.ts +3 -1
  55. package/dist/tool-system/builtin/index.js +5 -5
  56. package/dist/tool-system/builtin/powershell.js +4 -1
  57. package/dist/tool-system/builtin/sleep.js +5 -0
  58. package/dist/tool-system/context.d.ts +10 -0
  59. package/dist/tool-system/executor.js +25 -2
  60. package/dist/tool-system/mcp-manager.js +17 -0
  61. package/dist/tool-system/mcp-stdio-diagnostics.d.ts +9 -0
  62. package/dist/tool-system/mcp-stdio-diagnostics.js +93 -0
  63. package/dist/tool-system/permission.d.ts +3 -1
  64. package/dist/tool-system/permission.js +2 -1
  65. package/dist/tool-system/sandbox/off.js +7 -1
  66. package/dist/types.d.ts +35 -1
  67. package/dist/utils/exec.d.ts +8 -0
  68. package/dist/utils/exec.js +10 -0
  69. package/package.json +1 -1
@@ -35,7 +35,7 @@
35
35
  */
36
36
  import { spawn } from "node:child_process";
37
37
  import { StringDecoder } from "node:string_decoder";
38
- import { resolveSpawnTarget, defaultShellBinary, killChildTree } from "./spawn-common.js";
38
+ import { resolveSpawnTarget, defaultShellBinary, killChildTree, killProcessGroup } from "./spawn-common.js";
39
39
  export const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
40
40
  export const DEFAULT_IO_DRAIN_GRACE_MS = 100;
41
41
  const TIMEOUT_SIGKILL_GRACE_MS = 2000;
@@ -50,6 +50,7 @@ export function safeSpawn(file, args, opts) {
50
50
  args,
51
51
  opts,
52
52
  cleanup: undefined,
53
+ resolveMs: undefined,
53
54
  });
54
55
  }
55
56
  /**
@@ -60,6 +61,7 @@ export function safeSpawn(file, args, opts) {
60
61
  * abort, timeout, pre-spawn-abort. Used by the Bash tool.
61
62
  */
62
63
  export function safeSpawnShell(command, opts) {
64
+ const resolveStartedAt = spawnProfileEnabled() ? performance.now() : 0;
63
65
  // Don't hardcode a POSIX default here — resolveSpawnTarget →
64
66
  // resolveShellInvocation picks the platform shell (cmd.exe on Windows,
65
67
  // $SHELL/bin/bash on POSIX) when opts.shell is omitted.
@@ -69,11 +71,24 @@ export function safeSpawnShell(command, opts) {
69
71
  shell,
70
72
  sandbox: opts.sandbox,
71
73
  });
72
- return runLifecycle({ file, args, opts, cleanup });
74
+ // Shell commands run free-form LLM strings that routinely background
75
+ // grandchildren (`pytest &`, `sh → npm → node`). Spawn as a process-group
76
+ // leader (detached) so a timeout/abort kill reaches the WHOLE subtree, not
77
+ // just the direct shell — otherwise an orphaned grandchild keeps the
78
+ // inherited stdout pipe open and Node's `close` never fires (the hang).
79
+ return runLifecycle({
80
+ file,
81
+ args,
82
+ opts,
83
+ cleanup,
84
+ resolveMs: elapsedMs(resolveStartedAt),
85
+ detached: true,
86
+ });
73
87
  }
74
- function runLifecycle({ file, args, opts, cleanup }) {
88
+ function runLifecycle({ file, args, opts, cleanup, resolveMs, detached }) {
75
89
  const maxBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
76
90
  const abortGrace = opts.ioDrainGraceMs ?? DEFAULT_IO_DRAIN_GRACE_MS;
91
+ const lifecycleStartedAt = spawnProfileEnabled() ? performance.now() : 0;
77
92
  // Pre-spawn abort: don't pay spawn cost.
78
93
  if (opts.signal?.aborted) {
79
94
  safeCleanup(cleanup);
@@ -81,10 +96,20 @@ function runLifecycle({ file, args, opts, cleanup }) {
81
96
  }
82
97
  return new Promise((resolve) => {
83
98
  let settled = false;
99
+ // Declared before finish() so the spawn-failed early-return (which calls
100
+ // finish before these are assigned) doesn't hit a TDZ reference.
101
+ let timer;
102
+ let settleTimer;
103
+ let onAbort;
84
104
  const finish = (result) => {
85
105
  if (settled)
86
106
  return;
87
107
  settled = true;
108
+ if (timer)
109
+ clearTimeout(timer);
110
+ if (settleTimer)
111
+ clearTimeout(settleTimer);
112
+ logSpawnProfile(file, args, resolveMs, elapsedMs(lifecycleStartedAt), result.reason);
88
113
  // Always release backend-allocated resources, regardless of exit path.
89
114
  // cleanup is best-effort — see seatbelt backend for rationale.
90
115
  safeCleanup(cleanup);
@@ -97,7 +122,7 @@ function runLifecycle({ file, args, opts, cleanup }) {
97
122
  };
98
123
  let child;
99
124
  try {
100
- child = spawn(file, args, { cwd: opts.cwd, env: opts.env });
125
+ child = spawn(file, args, { cwd: opts.cwd, env: opts.env, detached });
101
126
  }
102
127
  catch (err) {
103
128
  finish(emptyResult({ reason: "spawn_failed", spawnFailed: true, error: err.message }));
@@ -111,21 +136,63 @@ function runLifecycle({ file, args, opts, cleanup }) {
111
136
  let stderrTruncated = false;
112
137
  let timedOut = false;
113
138
  let aborted = false;
114
- const timer = setTimeout(() => {
139
+ let lastExitCode = null;
140
+ let lastExitSignal = null;
141
+ // Terminate the child on timeout/abort. When detached (shell mode) the
142
+ // child leads its own process group, so kill the WHOLE group — this reaps
143
+ // backgrounded grandchildren (`pytest &`, `sh → npm → node`) that a bare
144
+ // child.kill() would orphan. After the kill, arm a settle deadline:
145
+ // Node's `close` waits for every inherited stdio pipe to close, and an
146
+ // orphaned grandchild can hold stdout open forever, so `close` may never
147
+ // fire. Force a resolve via the last-seen `exit` code once the kill grace
148
+ // has elapsed — the promise must never hang past terminate().
149
+ const terminate = (graceMs) => {
150
+ if (detached && typeof child.pid === "number") {
151
+ void killProcessGroup(child.pid, { graceMs });
152
+ }
153
+ else {
154
+ killChildTree(child, graceMs);
155
+ }
156
+ if (settleTimer)
157
+ return;
158
+ settleTimer = setTimeout(() => {
159
+ finishFromExit();
160
+ }, graceMs + 500);
161
+ settleTimer.unref?.();
162
+ };
163
+ timer = setTimeout(() => {
115
164
  timedOut = true;
116
- // win32: taskkill /T reaps the whole tree (a foreground `npm test`
117
- // spawns node children that child.kill() alone would orphan). POSIX:
118
- // SIGTERM → grace → SIGKILL. See killChildTree.
119
- killChildTree(child, TIMEOUT_SIGKILL_GRACE_MS);
165
+ terminate(TIMEOUT_SIGKILL_GRACE_MS);
120
166
  }, opts.timeoutMs);
121
- let onAbort;
122
167
  if (opts.signal) {
123
168
  onAbort = () => {
124
169
  aborted = true;
125
- killChildTree(child, abortGrace);
170
+ terminate(abortGrace);
126
171
  };
127
172
  opts.signal.addEventListener("abort", onAbort, { once: true });
128
173
  }
174
+ // Fallback finish when `close` never arrives because a killed process's
175
+ // orphaned grandchild still holds a stdio pipe. Uses whatever `exit` code
176
+ // we saw (exit fires when the direct child dies, independent of stdio).
177
+ const finishFromExit = () => {
178
+ const reason = timedOut ? "timeout" : aborted ? "aborted" : "ok";
179
+ finish({
180
+ reason,
181
+ stdout,
182
+ stderr,
183
+ exitCode: lastExitCode,
184
+ signal: lastExitSignal,
185
+ stdoutTruncated,
186
+ stderrTruncated,
187
+ timedOut,
188
+ aborted,
189
+ spawnFailed: false,
190
+ });
191
+ };
192
+ child.on("exit", (code, sig) => {
193
+ lastExitCode = code;
194
+ lastExitSignal = sig;
195
+ });
129
196
  child.stdout?.on("data", (chunk) => {
130
197
  if (stdoutTruncated)
131
198
  return;
@@ -221,6 +288,17 @@ function runLifecycle({ file, args, opts, cleanup }) {
221
288
  });
222
289
  });
223
290
  }
291
+ function spawnProfileEnabled() {
292
+ return process.env.CODESHELL_SPAWN_PROFILE === "1";
293
+ }
294
+ function elapsedMs(startedAt) {
295
+ return startedAt > 0 ? Math.round(performance.now() - startedAt) : undefined;
296
+ }
297
+ function logSpawnProfile(file, args, resolveMs, lifecycleMs, reason) {
298
+ if (!spawnProfileEnabled())
299
+ return;
300
+ console.error(`[spawn-profile] shell=${JSON.stringify(file)} flag=${JSON.stringify(args[0] ?? "")} resolveMs=${resolveMs ?? "n/a"} lifecycleMs=${lifecycleMs ?? "n/a"} reason=${reason}`);
301
+ }
224
302
  function safeCleanup(cleanup) {
225
303
  if (!cleanup)
226
304
  return;
@@ -72,9 +72,11 @@ export interface SpawnTarget {
72
72
  * in five places (bash.ts, safe-spawn.ts, background-shell.ts, worktree.ts):
73
73
  *
74
74
  * - POSIX: `<explicit ?? $SHELL ?? /bin/bash> -c "<command>"`
75
- * - Windows: `cmd.exe /c "<command>"` (the `-c` flag does not exist on cmd;
76
- * a bare `-c` would be taken as a filename and fail). PowerShell uses
77
- * `-Command`, but cmd.exe is the safe default present on every Windows.
75
+ * - Windows: `<explicit shell ?? Git Bash ?? PowerShell ?? ComSpec ?? cmd.exe>` with the
76
+ * shell-appropriate flag: Git Bash/POSIX shells use `-c`, PowerShell uses
77
+ * `-Command`, and cmd.exe uses `/c` (`-c` would be taken as a filename and
78
+ * fail/hang). Git Bash is preferred because the Bash tool receives POSIX
79
+ * syntax from the model.
78
80
  *
79
81
  * `$SHELL` is ignored on Windows — it is virtually never set there, and when
80
82
  * it is (e.g. a stray value from a Unix-y env) it points at a POSIX path that
@@ -85,8 +87,17 @@ export declare function resolveShellInvocation(command: string, shell?: string):
85
87
  file: string;
86
88
  args: string[];
87
89
  };
90
+ export declare function resolveGitBash(): string | undefined;
91
+ /** Reset the Git Bash probe cache. Test-only (platform is stubbed per test). */
92
+ export declare function _resetGitBashCache(): void;
93
+ export declare function resolvePowerShell(): string | undefined;
94
+ /** Reset the PowerShell probe cache. Test-only (platform is stubbed per test). */
95
+ export declare function _resetPowerShellCache(): void;
88
96
  /** The platform's default interactive shell binary, for spawning a bare shell
89
- * (no `-c`/`/c` command). Windows → ComSpec/cmd.exe; POSIX $SHELL/bin/bash. */
97
+ * (no `-c`/`/c` command). Windows → Git Bash if present, else PowerShell,
98
+ * else ComSpec/cmd.exe;
99
+ * POSIX → $SHELL/bin/bash. Windows prefers Git Bash so the model's bash-syntax
100
+ * commands actually run (cmd.exe can't). */
90
101
  export declare function defaultShellBinary(shell?: string): string;
91
102
  /**
92
103
  * Resolve the actual (file, args) for a shell `command` under an optional
@@ -20,7 +20,9 @@
20
20
  * spawn detached, so there's no separate process group to reap); it shares
21
21
  * (1) and (2) here. The background manager uses all three.
22
22
  */
23
- import { spawn } from "node:child_process";
23
+ import { spawn, execFileSync } from "node:child_process";
24
+ import { existsSync } from "node:fs";
25
+ import { dirname, join } from "node:path";
24
26
  /**
25
27
  * Env vars that are always safe to forward into a sandboxed shell. Mirrors
26
28
  * the allowlist that previously lived in bash.ts — kept here so foreground
@@ -103,9 +105,11 @@ export function mergeShellEnv(base, projectEnv) {
103
105
  * in five places (bash.ts, safe-spawn.ts, background-shell.ts, worktree.ts):
104
106
  *
105
107
  * - POSIX: `<explicit ?? $SHELL ?? /bin/bash> -c "<command>"`
106
- * - Windows: `cmd.exe /c "<command>"` (the `-c` flag does not exist on cmd;
107
- * a bare `-c` would be taken as a filename and fail). PowerShell uses
108
- * `-Command`, but cmd.exe is the safe default present on every Windows.
108
+ * - Windows: `<explicit shell ?? Git Bash ?? PowerShell ?? ComSpec ?? cmd.exe>` with the
109
+ * shell-appropriate flag: Git Bash/POSIX shells use `-c`, PowerShell uses
110
+ * `-Command`, and cmd.exe uses `/c` (`-c` would be taken as a filename and
111
+ * fail/hang). Git Bash is preferred because the Bash tool receives POSIX
112
+ * syntax from the model.
109
113
  *
110
114
  * `$SHELL` is ignored on Windows — it is virtually never set there, and when
111
115
  * it is (e.g. a stray value from a Unix-y env) it points at a POSIX path that
@@ -114,21 +118,116 @@ export function mergeShellEnv(base, projectEnv) {
114
118
  */
115
119
  export function resolveShellInvocation(command, shell) {
116
120
  if (process.platform === "win32") {
117
- const file = shell ?? process.env.ComSpec ?? "cmd.exe";
118
- // PowerShell variants take -Command; cmd.exe (and cmd-like) take /c.
121
+ const file = shell ?? defaultShellBinary();
122
+ // Flag form depends on the shell: PowerShell -Command; a POSIX shell such
123
+ // as Git Bash's bash.exe / sh → -c (it does NOT understand cmd's /c); cmd.exe
124
+ // (and cmd-like) → /c. Detecting bash/sh matters now that defaultShellBinary
125
+ // prefers Git Bash on Windows — feeding it /c would break every command.
119
126
  const isPwsh = /(^|[\\/])(pwsh|powershell)(\.exe)?$/i.test(file);
120
- return isPwsh ? { file, args: ["-Command", command] } : { file, args: ["/c", command] };
127
+ if (isPwsh)
128
+ return { file, args: ["-Command", command] };
129
+ const isPosixShell = /(^|[\\/])(bash|sh|zsh|dash)(\.exe)?$/i.test(file);
130
+ return { file, args: [isPosixShell ? "-c" : "/c", command] };
121
131
  }
122
132
  const file = shell ?? process.env.SHELL ?? "/bin/bash";
123
133
  return { file, args: ["-c", command] };
124
134
  }
135
+ /**
136
+ * Best-effort locate Git Bash's `bash.exe` on Windows. Returns undefined on
137
+ * non-Windows, when git isn't installed, or when no bash.exe is found.
138
+ *
139
+ * WHY: The Bash tool and background shells feed the model's *bash* commands
140
+ * (`ls`, `&&`, `$(…)`, pipes, quoting) to the shell. On Windows the historical
141
+ * default is `cmd.exe`, which can't run any of that — so "Bash" was effectively
142
+ * broken on Windows. Git for Windows (which most devs already have — we detect
143
+ * it for repo ops anyway) ships a full bash at `<git>\bin\bash.exe`, so prefer
144
+ * it. Falls back to PowerShell before cmd.exe when Git Bash truly isn't present.
145
+ *
146
+ * Resolution order:
147
+ * 1. CODE_SHELL_GIT_BASH_PATH env override (explicit user config wins).
148
+ * 2. Reverse-engineer from the git binary's location: `where git` gives e.g.
149
+ * `C:\Program Files\Git\cmd\git.exe` → `…\Git\bin\bash.exe`.
150
+ * 3. The two default install locations (Program Files / Program Files (x86)).
151
+ * Cached after first probe (a spawn per command would be wasteful).
152
+ */
153
+ let gitBashCache;
154
+ export function resolveGitBash() {
155
+ if (process.platform !== "win32")
156
+ return undefined;
157
+ if (gitBashCache !== undefined)
158
+ return gitBashCache ?? undefined;
159
+ const override = process.env.CODE_SHELL_GIT_BASH_PATH;
160
+ if (override && existsSync(override))
161
+ return (gitBashCache = override);
162
+ const candidates = [];
163
+ // (2) derive from `where git`. Git installs git.exe under either \cmd\ or
164
+ // \bin\; bash.exe lives under the sibling \bin\. Walk up to the Git root.
165
+ try {
166
+ const out = execFileSync("where", ["git"], { encoding: "utf-8", timeout: 3000 });
167
+ const gitExe = out.split(/\r?\n/).find((l) => l.trim().toLowerCase().endsWith("git.exe"));
168
+ if (gitExe) {
169
+ const gitRoot = dirname(dirname(gitExe.trim())); // …\Git\cmd\git.exe → …\Git
170
+ candidates.push(join(gitRoot, "bin", "bash.exe"));
171
+ }
172
+ }
173
+ catch {
174
+ // git not on PATH — fall through to the well-known locations.
175
+ }
176
+ // (3) default install locations.
177
+ const pf = process.env["ProgramFiles"] ?? "C:\\Program Files";
178
+ const pf86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
179
+ candidates.push(join(pf, "Git", "bin", "bash.exe"));
180
+ candidates.push(join(pf86, "Git", "bin", "bash.exe"));
181
+ const found = candidates.find((p) => existsSync(p));
182
+ return (gitBashCache = found ?? null) ?? undefined;
183
+ }
184
+ /** Reset the Git Bash probe cache. Test-only (platform is stubbed per test). */
185
+ export function _resetGitBashCache() {
186
+ gitBashCache = undefined;
187
+ }
188
+ let powerShellCache;
189
+ export function resolvePowerShell() {
190
+ if (process.platform !== "win32")
191
+ return undefined;
192
+ if (powerShellCache !== undefined)
193
+ return powerShellCache ?? undefined;
194
+ const override = process.env.CODE_SHELL_POWERSHELL_PATH;
195
+ if (override && existsSync(override))
196
+ return (powerShellCache = override);
197
+ const candidates = [];
198
+ for (const exe of ["pwsh", "powershell"]) {
199
+ try {
200
+ const out = execFileSync("where", [exe], { encoding: "utf-8", timeout: 3000 });
201
+ const found = out.split(/\r?\n/).find((l) => l.trim().toLowerCase().endsWith(`${exe}.exe`));
202
+ if (found)
203
+ candidates.push(found.trim());
204
+ }
205
+ catch {
206
+ // Not on PATH; try the next shell / well-known locations.
207
+ }
208
+ }
209
+ const pf = process.env["ProgramFiles"] ?? "C:\\Program Files";
210
+ const systemRoot = process.env.SystemRoot ?? "C:\\Windows";
211
+ candidates.push(join(pf, "PowerShell", "7", "pwsh.exe"));
212
+ candidates.push(join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"));
213
+ const found = candidates.find((p) => existsSync(p));
214
+ return (powerShellCache = found ?? null) ?? undefined;
215
+ }
216
+ /** Reset the PowerShell probe cache. Test-only (platform is stubbed per test). */
217
+ export function _resetPowerShellCache() {
218
+ powerShellCache = undefined;
219
+ }
125
220
  /** The platform's default interactive shell binary, for spawning a bare shell
126
- * (no `-c`/`/c` command). Windows → ComSpec/cmd.exe; POSIX $SHELL/bin/bash. */
221
+ * (no `-c`/`/c` command). Windows → Git Bash if present, else PowerShell,
222
+ * else ComSpec/cmd.exe;
223
+ * POSIX → $SHELL/bin/bash. Windows prefers Git Bash so the model's bash-syntax
224
+ * commands actually run (cmd.exe can't). */
127
225
  export function defaultShellBinary(shell) {
128
226
  if (shell)
129
227
  return shell;
130
- if (process.platform === "win32")
131
- return process.env.ComSpec ?? "cmd.exe";
228
+ if (process.platform === "win32") {
229
+ return resolveGitBash() ?? resolvePowerShell() ?? process.env.ComSpec ?? "cmd.exe";
230
+ }
132
231
  return process.env.SHELL ?? "/bin/bash";
133
232
  }
134
233
  /**
@@ -143,8 +242,10 @@ export function resolveSpawnTarget(command, opts) {
143
242
  const wrapped = opts.sandbox.wrap(command, { cwd: opts.cwd, shell: opts.shell });
144
243
  return { file: wrapped.file, args: wrapped.args, cleanup: wrapped.cleanup };
145
244
  }
146
- // No sandbox (the `off` backend / Windows): pick the shell + command-flag
147
- // form for the platform instead of assuming POSIX `-c`.
245
+ // No sandbox configured: pick the shell + command-flag form for the
246
+ // platform instead of assuming POSIX `-c`. Note Bash always passes a
247
+ // backend (off at minimum), so it never reaches this line — the off
248
+ // backend's wrap() delegates to resolveShellInvocation itself.
148
249
  return resolveShellInvocation(command, opts.shell);
149
250
  }
150
251
  /**
@@ -7,6 +7,7 @@ import { homedir } from "node:os";
7
7
  import { nanoid } from "nanoid";
8
8
  import { Transcript } from "./transcript.js";
9
9
  import { SessionError } from "../exceptions.js";
10
+ import { normalizeCumulativeUsageCounters } from "../engine/session-usage.js";
10
11
  /**
11
12
  * Validate a session ID before it is joined into a filesystem path.
12
13
  *
@@ -83,6 +84,9 @@ export class SessionManager {
83
84
  model,
84
85
  provider,
85
86
  tokenUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
87
+ cumulativePromptTokens: 0,
88
+ cumulativeCacheReadTokens: 0,
89
+ cumulativeCacheCreationTokens: 0,
86
90
  turnCount: 0,
87
91
  invokedSkills: [],
88
92
  status: "active",
@@ -245,6 +249,7 @@ export class SessionManager {
245
249
  const transcriptFile = join(sessionDir, "transcript.jsonl");
246
250
  const transcript = Transcript.loadFromFile(transcriptFile);
247
251
  state.status = "active";
252
+ Object.assign(state, normalizeCumulativeUsageCounters(state, state.tokenUsage));
248
253
  return { state, transcript };
249
254
  }
250
255
  saveState(state) {
@@ -275,7 +280,8 @@ export class SessionManager {
275
280
  newBundle.state.parentSessionId = sourceSessionId;
276
281
  // Copy events up to the fork point
277
282
  for (const event of events) {
278
- if (event.type === "turn_boundary" && (event.data.turnNumber ?? -1) > forkTurn) {
283
+ if (event.type === "turn_boundary" &&
284
+ (event.data.turnNumber ?? -1) > forkTurn) {
279
285
  break;
280
286
  }
281
287
  newBundle.transcript.append(event.type, event.data);
@@ -22,7 +22,10 @@ export declare class Transcript {
22
22
  */
23
23
  appendMessage(role: string, content: string | ContentBlock[], opts?: {
24
24
  injected?: boolean;
25
+ steerId?: string;
26
+ clientMessageId?: string;
25
27
  }): TranscriptEvent;
28
+ hasClientMessageId(clientMessageId: string): boolean;
26
29
  appendToolUse(toolName: string, toolCallId: string, args: Record<string, unknown>): TranscriptEvent;
27
30
  appendToolResult(toolCallId: string, toolName: string, result?: string, error?: string): TranscriptEvent;
28
31
  /** Anchor for a spawned sub-agent (see TranscriptEventType "subagent").
@@ -52,6 +55,7 @@ export declare class Transcript {
52
55
  getEvents(type?: TranscriptEventType): TranscriptEvent[];
53
56
  get turnNumber(): number;
54
57
  get eventCount(): number;
58
+ private findMessageByClientId;
55
59
  private flush;
56
60
  /**
57
61
  * Repair tool_result pairing issues:
@@ -5,6 +5,7 @@
5
5
  import { appendFileSync, readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
6
6
  import { dirname } from "node:path";
7
7
  import { nanoid } from "nanoid";
8
+ import { logger } from "../logging/logger.js";
8
9
  export class Transcript {
9
10
  events = [];
10
11
  filePath;
@@ -40,12 +41,28 @@ export class Transcript {
40
41
  * step-gap steering messages are left unmarked so they render normally.
41
42
  */
42
43
  appendMessage(role, content, opts) {
44
+ if (opts?.clientMessageId) {
45
+ const existing = this.findMessageByClientId(opts.clientMessageId);
46
+ if (existing) {
47
+ logger.info("steer.submit.duplicate_ignored", {
48
+ clientMessageId: opts.clientMessageId,
49
+ role,
50
+ transcript: this.filePath,
51
+ });
52
+ return existing;
53
+ }
54
+ }
43
55
  return this.append("message", {
44
56
  role,
45
57
  content,
46
58
  ...(opts?.injected ? { injected: true } : {}),
59
+ ...(opts?.steerId ? { steerId: opts.steerId } : {}),
60
+ ...(opts?.clientMessageId ? { clientMessageId: opts.clientMessageId } : {}),
47
61
  });
48
62
  }
63
+ hasClientMessageId(clientMessageId) {
64
+ return this.findMessageByClientId(clientMessageId) !== undefined;
65
+ }
49
66
  appendToolUse(toolName, toolCallId, args) {
50
67
  return this.append("tool_use", { toolName, toolCallId, args });
51
68
  }
@@ -150,6 +167,10 @@ export class Transcript {
150
167
  get eventCount() {
151
168
  return this.events.length;
152
169
  }
170
+ findMessageByClientId(clientMessageId) {
171
+ return this.events.find((event) => event.type === "message" &&
172
+ event.data.clientMessageId === clientMessageId);
173
+ }
153
174
  flush(event) {
154
175
  try {
155
176
  appendFileSync(this.filePath, JSON.stringify(event) + "\n", "utf-8");
@@ -29,8 +29,9 @@ function sandboxMark(backend) {
29
29
  export const bashToolDef = {
30
30
  name: "Bash",
31
31
  description: "Execute a shell command and return its output. " +
32
- "Commands run in the user's default shell. Use for system operations, " +
33
- "git commands, running tests, installing packages, etc.",
32
+ "Use for ordinary shell commands, system operations, git commands, " +
33
+ "package-manager commands, running tests, installing packages, etc. Prefer Bash " +
34
+ "over PowerShell unless the task needs PowerShell-specific syntax or APIs.",
34
35
  inputSchema: {
35
36
  type: "object",
36
37
  properties: {
@@ -27,7 +27,14 @@ export const cronCreateToolDef = {
27
27
  "'30 8 * * 1' = 8:30am every Monday. Day-of-week: 0=Sunday..6=Saturday.\n\n" +
28
28
  "For calendar schedules, set `timezone` to the user's IANA zone (e.g. 'Asia/Shanghai', " +
29
29
  "'America/New_York'); ask the user if unknown. Set `cwd` to the project the job operates on. " +
30
- "Leave `permissionLevel` as 'read-only' unless the user explicitly wants the job to modify code.",
30
+ "Leave `permissionLevel` as 'read-only' unless the user explicitly wants the job to modify code.\n\n" +
31
+ "SELF-WAKEUP / background-task safety net: you can also schedule THIS job for yourself, with no user " +
32
+ "request, to re-check on a long-running background task (a download, a build, a background shell/agent) " +
33
+ "that might hang and never signal completion. Use a short interval + `once: true` + `continueInSession: true` " +
34
+ "and a `prompt` that reminds you what to check, e.g. schedule '5m', once true, continueInSession true, " +
35
+ "prompt 'check whether the yt-dlp download finished (BashOutput/ListShells); if still running, wait again'. " +
36
+ "You wake back in THIS conversation with full context, inspect the task, and either finish or reschedule. " +
37
+ "This is the right pattern for a simple poll-until-done loop or a hang safety net — prefer it over looping Sleep.",
31
38
  inputSchema: {
32
39
  type: "object",
33
40
  properties: {
@@ -52,7 +59,8 @@ export const cronCreateToolDef = {
52
59
  once: {
53
60
  type: "boolean",
54
61
  description: "true = one-shot: run once at the scheduled time, then auto-delete (for 'in N minutes / " +
55
- "at <time>, do X once' reminders or tasks). Default false = recurring per `schedule`. " +
62
+ "at <time>, do X once' reminders, tasks, or a self-wakeup to re-check a background task). " +
63
+ "Default false = recurring per `schedule`. " +
56
64
  "A one-shot still uses `schedule` for its time: interval '10m' = 10 minutes from now; " +
57
65
  "cron '0 7 25 6 *' = once at 07:00 on June 25.",
58
66
  },
@@ -10,11 +10,13 @@
10
10
  import { randomBytes } from "node:crypto";
11
11
  import { userCatalogPath } from "../../model-catalog/index.js";
12
12
  import { saveCatalogEntry } from "../../model-catalog/save-entry.js";
13
+ import { catalogEntrySchema } from "../../model-catalog/types.js";
13
14
  export const editModelCatalogToolDef = {
14
15
  name: "EditModelCatalog",
15
16
  description: "Add or update a provider/model template in the user model catalog so it " +
16
- "appears in the connection page (text/image/video). Keyed by `id` a new id " +
17
- "adds, an existing id updates (in place). Backs up the file before writing and " +
17
+ "appears in the connection page (text/image/video). Keyed by `id`: a new id " +
18
+ "adds a provider, an existing id replaces that user-catalog entry. Backs up " +
19
+ "the file before writing and " +
18
20
  "validates the entry. Use after researching a model's real facts (id, context " +
19
21
  "window, supported params, modalities) — do NOT guess; verify against the " +
20
22
  "provider's official docs first (see the model-fact-finder skill). Does NOT set " +
@@ -31,7 +33,8 @@ export const editModelCatalogToolDef = {
31
33
  "maxContextTokens?, maxOutputTokens?, supportsVision?, params?[]}). Each param: " +
32
34
  "{name, label?, control (enum|number|toggle|text), options?[], min?, max?, default?, " +
33
35
  "doc?, wire?{field}}. Fill maxContextTokens with the model's REAL window; declare " +
34
- "params per-model (only what that model supports).",
36
+ "params per-model (only what that model supports). To keep a custom provider " +
37
+ "beside a built-in one, use a distinct id.",
35
38
  },
36
39
  },
37
40
  required: ["entry"],
@@ -42,11 +45,18 @@ export async function editModelCatalogTool(args) {
42
45
  if (!entry || typeof entry !== "object") {
43
46
  return "Error: `entry` (a CatalogEntry object) is required.";
44
47
  }
48
+ const parsed = catalogEntrySchema.safeParse(entry);
49
+ if (!parsed.success) {
50
+ return `Error: invalid catalog entry: ${parsed.error.issues.map((i) => i.message).join("; ")}`;
51
+ }
45
52
  const stamp = `${Date.now()}-${randomBytes(3).toString("hex")}`;
46
- const r = saveCatalogEntry(entry, { path: userCatalogPath(), stamp });
53
+ const r = saveCatalogEntry(parsed.data, {
54
+ path: userCatalogPath(),
55
+ stamp,
56
+ });
47
57
  if (!r.ok)
48
58
  return `Error: ${r.error}`;
49
- return summarizeWrite(entry, r.action ?? "added", r.backup);
59
+ return summarizeWrite(parsed.data, r.action ?? "added", r.backup);
50
60
  }
51
61
  /**
52
62
  * Build a structured completion summary so the user can SEE exactly what was
@@ -86,6 +86,7 @@ export const generateVideoToolDef = {
86
86
  let injectedProvider = null;
87
87
  export function __setVideoProviderForTests(p) {
88
88
  injectedProvider = p;
89
+ availCache.clear();
89
90
  }
90
91
  /**
91
92
  * Tool-visibility guard: GenerateVideo is hidden until a video provider is
@@ -97,6 +98,8 @@ export function __setVideoProviderForTests(p) {
97
98
  const availCache = new Map();
98
99
  const AVAIL_TTL_MS = 1000;
99
100
  export function isGenerateVideoAvailable(cwd = process.cwd(), nowMs = Date.now()) {
101
+ if (injectedProvider)
102
+ return true;
100
103
  const hit = availCache.get(cwd);
101
104
  if (hit && nowMs - hit.at < AVAIL_TTL_MS)
102
105
  return hit.value;
@@ -3,5 +3,14 @@
3
3
  */
4
4
  import type { ToolDefinition } from "../../types.js";
5
5
  import type { ToolContext } from "../context.js";
6
+ type ExecFileForGrep = (file: string, args: readonly string[], options: {
7
+ maxBuffer: number;
8
+ timeout: number;
9
+ }) => Promise<{
10
+ stdout: string;
11
+ stderr: string;
12
+ }>;
13
+ export declare function _setGrepExecFileForTest(fn?: ExecFileForGrep): void;
6
14
  export declare const grepToolDef: ToolDefinition;
7
15
  export declare function grepTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<string>;
16
+ export {};