@cjhyy/code-shell-core 0.6.0-rc.2 → 0.6.0-rc.4

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.
@@ -1323,6 +1323,12 @@ export class Engine {
1323
1323
  disabledPlugins,
1324
1324
  skillAllowlist: this.config.skillAllowlist,
1325
1325
  memoriesMaxAgeDays: this.readMemoriesConfig()?.maxAge,
1326
+ goalToolState: {
1327
+ hasGoal: this.config.isSubAgent !== true &&
1328
+ (normalizeGoal(options?.goal) !== undefined ||
1329
+ session.state.activeGoal !== undefined ||
1330
+ normalizeGoal(this.config.goal) !== undefined),
1331
+ },
1326
1332
  });
1327
1333
  // Connect MCP servers (if configured and not already connected).
1328
1334
  // B1: prefer the Runtime-owned MCPManager so all sessions in a
@@ -1358,6 +1364,14 @@ export class Engine {
1358
1364
  // cwd. Recomputed every message, so configuring a key takes effect on the
1359
1365
  // NEXT message without a restart. Tools with no guard entry are always kept.
1360
1366
  const guardCwd = toolCtx.cwd;
1367
+ const toolVisibility = {
1368
+ cwd: guardCwd,
1369
+ hasGoal: this.config.isSubAgent !== true &&
1370
+ (normalizeGoal(options?.goal) !== undefined ||
1371
+ session.state.activeGoal !== undefined ||
1372
+ normalizeGoal(this.config.goal) !== undefined),
1373
+ };
1374
+ toolCtx.toolVisibility = toolVisibility;
1361
1375
  // #7: per-turn project builtin override. The toolRegistry's builtin tool
1362
1376
  // SET is ctor-frozen (and may be shared via runtime), so a mid-session
1363
1377
  // project override of a builtin can't rebuild the registry. But the tool
@@ -1407,7 +1421,7 @@ export class Engine {
1407
1421
  .filter((t) => mcpVisible(t.name))
1408
1422
  .filter((t) => {
1409
1423
  const guard = BUILTIN_TOOL_GUARDS.get(t.name);
1410
- return guard ? guard(guardCwd) : true;
1424
+ return guard ? guard(toolVisibility) : true;
1411
1425
  })
1412
1426
  .filter((t) => {
1413
1427
  const flag = TOOL_FEATURE_FLAGS.get(t.name);
@@ -23,6 +23,8 @@ export declare class EngineRuntime {
23
23
  readonly costTracker: CostTracker;
24
24
  private sandboxCache;
25
25
  constructor(opts: EngineRuntimeOptions);
26
+ clearModels(): void;
27
+ reloadModelsFromSettings(): void;
26
28
  /**
27
29
  * Lazily resolve and cache a sandbox backend for `(mode, cwd)`.
28
30
  *
@@ -1,3 +1,6 @@
1
+ import { getMergedCatalog } from "../model-catalog/index.js";
2
+ import { modelEntriesFromConnections } from "./model-connections-pool.js";
3
+ import { defaultCacheDir } from "../llm/model-cache.js";
1
4
  import { resolveSandboxBackend, } from "../tool-system/sandbox/index.js";
2
5
  import { sandboxCacheKey } from "./sandbox-cache-key.js";
3
6
  /**
@@ -22,6 +25,28 @@ export class EngineRuntime {
22
25
  this.mcpPool = opts.mcpPool;
23
26
  this.costTracker = opts.costTracker;
24
27
  }
28
+ clearModels() {
29
+ this.modelPool.clear();
30
+ }
31
+ reloadModelsFromSettings() {
32
+ const settings = this.settings.load();
33
+ const connections = settings.modelConnections ?? [];
34
+ const credentials = settings.credentials ?? [];
35
+ const catalog = getMergedCatalog();
36
+ const entries = modelEntriesFromConnections(connections, credentials, catalog);
37
+ this.modelPool.clear();
38
+ if (entries.length === 0)
39
+ return;
40
+ for (const entry of entries)
41
+ this.modelPool.register(entry);
42
+ if (entries.length > 0) {
43
+ const defaultText = settings.defaults?.text;
44
+ const key = defaultText && entries.some((e) => e.key === defaultText) ? defaultText : entries[0].key;
45
+ this.modelPool.switch(key);
46
+ this.modelPool.setCacheDir(defaultCacheDir());
47
+ this.modelPool.reloadCachedContextWindows();
48
+ }
49
+ }
25
50
  /**
26
51
  * Lazily resolve and cache a sandbox backend for `(mode, cwd)`.
27
52
  *
@@ -14,6 +14,18 @@ export interface GitStatusEntry {
14
14
  }
15
15
  export type { GitLogEntry };
16
16
  export declare function isGitRepo(cwd: string): boolean;
17
+ /**
18
+ * Resolve a directory to its PROJECT ROOT: the enclosing git repository's
19
+ * top-level dir if `cwd` is inside a git repo, otherwise `cwd` unchanged.
20
+ *
21
+ * This is the project-boundary rule the desktop uses when adding/identifying a
22
+ * project: picking a SUBDIRECTORY of a git repo should belong to that one repo
23
+ * (its root), not spawn a separate project per subdir — mirrors how editors
24
+ * (and Claude Code) treat a repo as one workspace. A non-git folder is its own
25
+ * project (returned as-is). Never throws; on any git failure falls back to cwd.
26
+ * Returns the git-reported toplevel (already absolute, forward-slashed on win).
27
+ */
28
+ export declare function resolveProjectRoot(cwd: string): string;
17
29
  export declare function getCurrentBranch(cwd: string): string;
18
30
  export declare function getGitStatus(cwd: string): GitStatusEntry[];
19
31
  export declare function getGitDiff(cwd: string, opts?: {
package/dist/git/utils.js CHANGED
@@ -8,15 +8,15 @@
8
8
  * into the string form.
9
9
  */
10
10
  import { execFileSync } from "node:child_process";
11
+ import { realpathSync } from "node:fs";
11
12
  import { parseGitLog } from "./parse-log.js";
12
- import { resolveExecutable } from "../utils/exec.js";
13
+ import { resolveExecutable, resolveGit } from "../utils/exec.js";
13
14
  // Resolve git/gh through PATH×PATHEXT on Windows so a .cmd/.exe shim is found
14
15
  // (bare execFile doesn't walk PATHEXT). No-op on POSIX. See utils/exec.ts.
15
- const GIT_BIN = resolveExecutable("git");
16
16
  const GH_BIN = resolveExecutable("gh");
17
17
  /** Run git with an argv array and return its trimmed stdout. */
18
18
  function git(cwd, args, timeoutMs = 10000) {
19
- return execFileSync(GIT_BIN, args, { cwd, encoding: "utf-8", timeout: timeoutMs }).trim();
19
+ return execFileSync(resolveGit(), args, { cwd, encoding: "utf-8", timeout: timeoutMs }).trim();
20
20
  }
21
21
  /** Run gh with an argv array and return its trimmed stdout. */
22
22
  function gh(cwd, args, timeoutMs = 10000) {
@@ -31,6 +31,33 @@ export function isGitRepo(cwd) {
31
31
  return false;
32
32
  }
33
33
  }
34
+ /**
35
+ * Resolve a directory to its PROJECT ROOT: the enclosing git repository's
36
+ * top-level dir if `cwd` is inside a git repo, otherwise `cwd` unchanged.
37
+ *
38
+ * This is the project-boundary rule the desktop uses when adding/identifying a
39
+ * project: picking a SUBDIRECTORY of a git repo should belong to that one repo
40
+ * (its root), not spawn a separate project per subdir — mirrors how editors
41
+ * (and Claude Code) treat a repo as one workspace. A non-git folder is its own
42
+ * project (returned as-is). Never throws; on any git failure falls back to cwd.
43
+ * Returns the git-reported toplevel (already absolute, forward-slashed on win).
44
+ */
45
+ export function resolveProjectRoot(cwd) {
46
+ let realCwd = cwd;
47
+ try {
48
+ realCwd = realpathSync(cwd);
49
+ }
50
+ catch {
51
+ // Non-existent paths are allowed to fall back unchanged below.
52
+ }
53
+ try {
54
+ const top = git(realCwd, ["rev-parse", "--show-toplevel"], 5000);
55
+ return top ? realpathSync(top) : realCwd;
56
+ }
57
+ catch {
58
+ return realCwd;
59
+ }
60
+ }
34
61
  export function getCurrentBranch(cwd) {
35
62
  return git(cwd, ["branch", "--show-current"], 5000);
36
63
  }
@@ -82,13 +109,13 @@ export function gitAdd(cwd, files = ["."]) {
82
109
  // `--` ensures a path starting with `-` cannot be parsed as a flag.
83
110
  // Each file is its own argv token, so spaces / quotes / non-ASCII pass
84
111
  // through verbatim with no shell parsing.
85
- execFileSync(GIT_BIN, ["add", "--", ...files], { cwd, timeout: 10000 });
112
+ execFileSync(resolveGit(), ["add", "--", ...files], { cwd, timeout: 10000 });
86
113
  }
87
114
  export function gitCommit(cwd, message) {
88
115
  // Pre-fix this used `JSON.stringify(message)` which only happened to be
89
116
  // safe because JSON.stringify covers most shell metacharacters — but it's
90
117
  // not real escaping. The argv form is.
91
- return execFileSync(GIT_BIN, ["commit", "-m", message], {
118
+ return execFileSync(resolveGit(), ["commit", "-m", message], {
92
119
  cwd,
93
120
  encoding: "utf-8",
94
121
  timeout: 30000,
@@ -115,7 +142,7 @@ export function gitCheckout(cwd, branch, create = false) {
115
142
  throw new Error(`refusing branch name that starts with '-': ${branch}`);
116
143
  }
117
144
  const args = create ? ["checkout", "-b", branch] : ["checkout", branch];
118
- execFileSync(GIT_BIN, args, { cwd, timeout: 10000 });
145
+ execFileSync(resolveGit(), args, { cwd, timeout: 10000 });
119
146
  }
120
147
  export function ghAvailable() {
121
148
  try {
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export declare const VERSION = "0.5.0-rc.2";
6
+ export declare const VERSION = "0.6.0-rc.4";
7
7
  export type { Message, ContentBlock, ToolDefinition, ToolCall, ToolResult, RegisteredTool, TranscriptEvent, TranscriptEventType, SessionState, SessionStatus, TokenUsage, CompiledInput, PermissionDecision, PermissionMode, PermissionRule, TurnPhase, TurnResult, TerminalReason, StreamEvent, StreamCallback, LLMConfig, ClientDefaults, LLMResponse, Settings, MCPServerConfig, } from "./types.js";
8
8
  export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, SandboxUnavailableError, } from "./exceptions.js";
9
9
  export { Engine, loadAgentDefinitionsForCwd } from "./engine/engine.js";
@@ -116,7 +116,7 @@ export { getGraphemeSegmenter, firstGrapheme, lastGrapheme, getWordSegmenter, ge
116
116
  export { env } from "./utils/env.js";
117
117
  export { default as sliceAnsi } from "./utils/sliceAnsi.js";
118
118
  export { execFileNoThrow } from "./utils/execFileNoThrow.js";
119
- export { findExecutable, resolveExecutable, setGitPathOverride, resolveGit, isGitAvailable, } from "./utils/exec.js";
119
+ export { findExecutable, resolveExecutable, setGitPathOverride, resolveGit, isGitAvailable, resolveGitPath, } from "./utils/exec.js";
120
120
  export { gte } from "./utils/semver.js";
121
121
  export { lock, lockSync, unlock, check } from "./utils/lockfile.js";
122
122
  export { logForDebugging } from "./utils/debug.js";
@@ -134,7 +134,7 @@ export { CostTracker, costTracker, installCostTracking } from "./cost-tracker.js
134
134
  export { NOOP_COLORIZER, type Colorizer } from "./colorizer.js";
135
135
  export { hasApiKey, resolveApiKey, appendOnboardingResult, detectEnvKeys, saveArenaSettingsByKeys, type OnboardingResult, } from "./onboarding.js";
136
136
  export { getCurrentVersion, checkForUpdate, scheduleAutoInstallOnExit, getUpdateAvailable, getAutoUpdateDisabledReason, type UpdateInfo, } from "./updater.js";
137
- export { isGitRepo, getCurrentBranch, getGitStatus, getGitDiff, getGitDiffStat, getGitLog, gitAdd, gitCommit, gitListBranches, gitCheckout, ghAvailable, ghPrComments, } from "./git/utils.js";
137
+ export { isGitRepo, resolveProjectRoot, getCurrentBranch, getGitStatus, getGitDiff, getGitDiffStat, getGitLog, gitAdd, gitCommit, gitListBranches, gitCheckout, ghAvailable, ghPrComments, } from "./git/utils.js";
138
138
  export { buildReviewPrompt, parseDimensions, ALL_DIMENSIONS, type ReviewDimension, type ReviewPromptOptions, } from "./review/review-prompt.js";
139
139
  /**
140
140
  * @internal Exports below are consumed by the in-repo TUI/desktop hosts, not
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export const VERSION = "0.5.0-rc.2";
6
+ export const VERSION = "0.6.0-rc.4";
7
7
  // ─── Exceptions ──────────────────────────────────────────────────
8
8
  export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, SandboxUnavailableError, } from "./exceptions.js";
9
9
  // ─── Engine (primary API) ────────────────────────────────────────
@@ -136,7 +136,7 @@ export { getGraphemeSegmenter, firstGrapheme, lastGrapheme, getWordSegmenter, ge
136
136
  export { env } from "./utils/env.js";
137
137
  export { default as sliceAnsi } from "./utils/sliceAnsi.js";
138
138
  export { execFileNoThrow } from "./utils/execFileNoThrow.js";
139
- export { findExecutable, resolveExecutable, setGitPathOverride, resolveGit, isGitAvailable, } from "./utils/exec.js";
139
+ export { findExecutable, resolveExecutable, setGitPathOverride, resolveGit, isGitAvailable, resolveGitPath, } from "./utils/exec.js";
140
140
  export { gte } from "./utils/semver.js";
141
141
  // Cross-process file lock (proper-lockfile via createRequire, ESM-safe). Used by
142
142
  // RunLock / CronStore internally; exported so other writers of shared files
@@ -163,7 +163,7 @@ export { hasApiKey, resolveApiKey, appendOnboardingResult, detectEnvKeys, saveAr
163
163
  // ─── Updater ─────────────────────────────────────────────────────
164
164
  export { getCurrentVersion, checkForUpdate, scheduleAutoInstallOnExit, getUpdateAvailable, getAutoUpdateDisabledReason, } from "./updater.js";
165
165
  // ─── Git Utilities ───────────────────────────────────────────────
166
- export { isGitRepo, getCurrentBranch, getGitStatus, getGitDiff, getGitDiffStat, getGitLog, gitAdd, gitCommit, gitListBranches, gitCheckout, ghAvailable, ghPrComments, } from "./git/utils.js";
166
+ export { isGitRepo, resolveProjectRoot, getCurrentBranch, getGitStatus, getGitDiff, getGitDiffStat, getGitLog, gitAdd, gitCommit, gitListBranches, gitCheckout, ghAvailable, ghPrComments, } from "./git/utils.js";
167
167
  // ─── Code review (TODO 7.3) ─────────────────────────────────────
168
168
  export { buildReviewPrompt, parseDimensions, ALL_DIMENSIONS, } from "./review/review-prompt.js";
169
169
  // ─── Tool-system (extended for TUI) ─────────────────────────────
@@ -79,6 +79,8 @@ export declare class ModelPool {
79
79
  * The first entry (or the one matching `defaultKey`) becomes active.
80
80
  */
81
81
  constructor(entries?: ModelEntry[], defaultKey?: string);
82
+ /** Clear all registered models and active selection. */
83
+ clear(): void;
82
84
  /** Register a model at runtime. */
83
85
  register(entry: ModelEntry): void;
84
86
  /** Fill in known defaults (e.g. context window for DeepSeek V4) when the entry doesn't specify them. */
@@ -142,6 +142,11 @@ export class ModelPool {
142
142
  this.activeKey = entries[0].key;
143
143
  }
144
144
  }
145
+ /** Clear all registered models and active selection. */
146
+ clear() {
147
+ this.models.clear();
148
+ this.activeKey = undefined;
149
+ }
145
150
  /** Register a model at runtime. */
146
151
  register(entry) {
147
152
  this.models.set(entry.key, this.withBuiltinDefaults(entry));
@@ -228,6 +233,7 @@ export class ModelPool {
228
233
  // instead of fabricating 8192, which silently truncates long outputs and
229
234
  // masks the real per-model cap.
230
235
  maxTokens: entry.maxOutputTokens,
236
+ ...(entry.maxContextTokens !== undefined ? { maxContextTokens: entry.maxContextTokens } : {}),
231
237
  // reasoning: entry overrides catalog. No base fallback — see class doc.
232
238
  ...(entry.reasoning ?? fromCat?.reasoning
233
239
  ? { reasoning: entry.reasoning ?? fromCat?.reasoning }
@@ -41,6 +41,10 @@ export interface ComposerOptions {
41
41
  * dispatch gate.
42
42
  */
43
43
  skillAllowlist?: string[];
44
+ /** Volatile goal state used only in the trailing dynamic-context message. */
45
+ goalToolState?: {
46
+ hasGoal: boolean;
47
+ };
44
48
  /**
45
49
  * settings.memories.maxAge (days). When > 0, memories whose file mtime is
46
50
  * older than this are dropped from the injected memory context (TODO 8.1).
@@ -79,6 +83,7 @@ export declare class PromptComposer {
79
83
  * breakpoint — a change here never re-bills the history prefix.
80
84
  */
81
85
  buildDynamicContextMessage(): Promise<Message | null>;
86
+ private buildGoalToolContext;
82
87
  invalidateCache(sectionName?: string): void;
83
88
  private getSections;
84
89
  private getInstructions;
@@ -113,7 +113,8 @@ export class PromptComposer {
113
113
  // prefix — so a memory change (extraction / recall usage++ / approve) never
114
114
  // re-bills the cached prefix. See buildUserContextMessage for the rationale.
115
115
  const memoryContext = this.getMemoryContext();
116
- const parts = [skillsListing, gitStatus, memoryContext].filter(Boolean);
116
+ const goalToolContext = this.buildGoalToolContext();
117
+ const parts = [skillsListing, gitStatus, memoryContext, goalToolContext].filter(Boolean);
117
118
  if (parts.length === 0)
118
119
  return null;
119
120
  return {
@@ -121,6 +122,13 @@ export class PromptComposer {
121
122
  content: `<system-reminder>\n${parts.join("\n\n")}\n</system-reminder>`,
122
123
  };
123
124
  }
125
+ buildGoalToolContext() {
126
+ if (!this.options.goalToolState)
127
+ return "";
128
+ return this.options.goalToolState.hasGoal
129
+ ? "当前存在 active goal。只有在目标完全完成时才可调用 complete_goal;只有用户明确要求取消/停止/放弃该目标时才可调用 cancel_goal。"
130
+ : "当前没有 active goal。不要调用 complete_goal/cancel_goal;如果误调用,系统会拒绝。";
131
+ }
124
132
  invalidateCache(sectionName) {
125
133
  this.sectionCache.invalidate(sectionName);
126
134
  if (!sectionName) {
@@ -17,6 +17,7 @@ export interface ChatSessionManagerOptions {
17
17
  }
18
18
  export declare class ChatSessionManager {
19
19
  private readonly sessions;
20
+ readonly runtime: EngineRuntime;
20
21
  private readonly factory;
21
22
  private readonly maxSessions;
22
23
  private readonly idleTtlMs;
@@ -3,11 +3,13 @@ import { backgroundShellManager } from "../runtime/background-shell.js";
3
3
  import { clearAgentOutputFiles } from "../tool-system/builtin/agent-output-file.js";
4
4
  export class ChatSessionManager {
5
5
  sessions = new Map();
6
+ runtime;
6
7
  factory;
7
8
  maxSessions;
8
9
  idleTtlMs;
9
10
  sweeper = null;
10
11
  constructor(opts) {
12
+ this.runtime = opts.runtime;
11
13
  this.factory = opts.engineFactory;
12
14
  this.maxSessions = opts.maxSessions ?? 16;
13
15
  this.idleTtlMs = opts.idleTtlMs ?? 30 * 60 * 1000;
@@ -1,4 +1,5 @@
1
1
  import type { Engine, EngineResult } from "../engine/engine.js";
2
+ import type { ModelEntry } from "../llm/model-pool.js";
2
3
  import type { StreamEvent } from "../types.js";
3
4
  export interface ChatSessionOptions {
4
5
  id: string;
@@ -116,7 +117,7 @@ export declare class ChatSession {
116
117
  * turn is in flight so a hot switch never mutates the model under a
117
118
  * running LLM client.
118
119
  */
119
- requestModelSwitch(key: string): void;
120
+ requestModelSwitch(key: string): ModelEntry;
120
121
  /**
121
122
  * Perform the actual model switch for this session: rotate the engine's model
122
123
  * and reset the session's cumulative usage on disk — a different model has a
@@ -126,10 +126,14 @@ export class ChatSession {
126
126
  */
127
127
  requestModelSwitch(key) {
128
128
  if (this.isBusy()) {
129
+ const pool = this.engine.getModelPool();
130
+ const entry = pool.get(key);
131
+ if (!entry)
132
+ throw new Error(`Model not found: ${key}`);
129
133
  this.pendingModel = key;
130
- return;
134
+ return entry;
131
135
  }
132
- this.applyModelSwitch(key);
136
+ return this.applyModelSwitch(key);
133
137
  }
134
138
  /**
135
139
  * Perform the actual model switch for this session: rotate the engine's model
@@ -140,8 +144,9 @@ export class ChatSession {
140
144
  * path and the deferred run-boundary path.
141
145
  */
142
146
  applyModelSwitch(key) {
143
- this.engine.switchModel(key);
147
+ const entry = this.engine.switchModel(key);
144
148
  this.engine.resetSessionUsage(this.id);
149
+ return entry;
145
150
  }
146
151
  queueDepth() {
147
152
  return this.queue.length;
@@ -461,7 +461,18 @@ export class AgentServer {
461
461
  }
462
462
  const resolve = s.pendingApprovals.get(params.requestId);
463
463
  if (!resolve) {
464
- this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, `No pending approval: ${params.requestId}`));
464
+ // Tool approvals still resolve through the interactive backend's
465
+ // legacy pending map; the sessionId on their envelope is UI routing
466
+ // metadata. Accept a session-tagged response for those requests too.
467
+ const legacyResolve = this.pendingApprovals.get(params.requestId);
468
+ if (!legacyResolve) {
469
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, `No pending approval: ${params.requestId}`));
470
+ return;
471
+ }
472
+ this.pendingApprovals.delete(params.requestId);
473
+ this.clearApprovalTimer(params.requestId);
474
+ legacyResolve(params.decision);
475
+ this.transport.send(createResponse(req.id, { ok: true }));
465
476
  return;
466
477
  }
467
478
  s.pendingApprovals.delete(params.requestId);
@@ -709,7 +720,11 @@ export class AgentServer {
709
720
  const sid = params.sessionId;
710
721
  const s = this.chatManager.get(sid);
711
722
  if (!s) {
712
- this.transport.send(createErrorResponse(req.id, ErrorCodes.SessionClosed, `No such session: ${sid}`));
723
+ // Session not found (already cleaned by idle sweeper, or never created).
724
+ // Don't create one just for configure — let the subsequent run() do it
725
+ // with proper per-session config. Return OK since there's nothing to
726
+ // configure on a non-existent session.
727
+ this.transport.send(createResponse(req.id, { ok: true }));
713
728
  return;
714
729
  }
715
730
  if (typeof params.planMode === "boolean")
@@ -717,13 +732,32 @@ export class AgentServer {
717
732
  if (typeof params.permissionMode === "string") {
718
733
  s.engine.setPermissionMode(params.permissionMode);
719
734
  }
735
+ if (params.clearModels) {
736
+ this.chatManager.runtime.clearModels();
737
+ }
738
+ if (params.reloadModels) {
739
+ try {
740
+ this.chatManager.runtime.reloadModelsFromSettings();
741
+ }
742
+ catch (err) {
743
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, err.message));
744
+ return;
745
+ }
746
+ }
720
747
  // Per-session model switch — the missing piece that made model changes
721
748
  // worker-global (session-isolation research §3). requestModelSwitch
722
749
  // applies immediately when idle, defers to the run boundary when busy
723
750
  // so it never swaps the model under a running LLM client.
724
751
  if (typeof params.model === "string") {
725
752
  try {
726
- s.requestModelSwitch(params.model);
753
+ const entry = s.requestModelSwitch(params.model);
754
+ this.transport.send(createResponse(req.id, {
755
+ ok: true,
756
+ model: entry.model,
757
+ key: entry.key,
758
+ maxContextTokens: entry.maxContextTokens,
759
+ }));
760
+ return;
727
761
  }
728
762
  catch (err) {
729
763
  this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, err.message));
@@ -746,22 +780,33 @@ export class AgentServer {
746
780
  // Global configure — delegate to legacyEngine if available,
747
781
  // or to any session's engine from chatManager for settings ops
748
782
  const engine = this.legacyEngine ?? this.anyEngine();
783
+ if (params.clearModels) {
784
+ if (this.chatManager) {
785
+ this.chatManager.runtime.clearModels();
786
+ }
787
+ else {
788
+ this.legacyEngine?.getModelPool().clear();
789
+ this.globalQueryEngine?.getModelPool().clear();
790
+ }
791
+ }
749
792
  if (params.reloadModels) {
750
793
  try {
751
- const seen = new Set();
752
- const reload = (target) => {
753
- if (!target || seen.has(target))
754
- return;
755
- seen.add(target);
756
- target.reloadModelPool();
757
- };
758
- reload(this.legacyEngine);
759
- reload(this.globalQueryEngine);
760
794
  if (this.chatManager) {
761
- this.chatManager.forEachSession((s) => reload(s.engine));
795
+ this.chatManager.runtime.reloadModelsFromSettings();
796
+ }
797
+ else {
798
+ const seen = new Set();
799
+ const reload = (target) => {
800
+ if (!target || seen.has(target))
801
+ return;
802
+ seen.add(target);
803
+ target.reloadModelPool();
804
+ };
805
+ reload(this.legacyEngine);
806
+ reload(this.globalQueryEngine);
807
+ if (seen.size === 0)
808
+ reload(engine);
762
809
  }
763
- if (seen.size === 0)
764
- reload(engine);
765
810
  }
766
811
  catch (err) {
767
812
  this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, err.message));
@@ -771,7 +816,12 @@ export class AgentServer {
771
816
  if (params.model !== undefined && engine) {
772
817
  try {
773
818
  const entry = engine.switchModel(params.model);
774
- this.transport.send(createResponse(req.id, { ok: true, model: entry.model, key: entry.key }));
819
+ this.transport.send(createResponse(req.id, {
820
+ ok: true,
821
+ model: entry.model,
822
+ key: entry.key,
823
+ maxContextTokens: entry.maxContextTokens,
824
+ }));
775
825
  return;
776
826
  }
777
827
  catch (err) {
@@ -1291,6 +1341,7 @@ export class AgentServer {
1291
1341
  requestApprovalFromClient(request) {
1292
1342
  return new Promise((resolve) => {
1293
1343
  const requestId = nanoid(12);
1344
+ const sessionId = typeof request.sessionId === "string" ? request.sessionId : undefined;
1294
1345
  this.pendingApprovals.set(requestId, resolve);
1295
1346
  const timer = setTimeout(() => {
1296
1347
  if (this.pendingApprovals.has(requestId)) {
@@ -1300,7 +1351,11 @@ export class AgentServer {
1300
1351
  }
1301
1352
  }, AgentServer.APPROVAL_TIMEOUT_MS);
1302
1353
  this.approvalTimers.set(requestId, timer);
1303
- this.notify(Methods.ApprovalRequest, { requestId, request });
1354
+ this.notify(Methods.ApprovalRequest, {
1355
+ ...(sessionId ? { sessionId } : {}),
1356
+ requestId,
1357
+ request,
1358
+ });
1304
1359
  });
1305
1360
  }
1306
1361
  /**
@@ -149,6 +149,8 @@ export interface ConfigureParams {
149
149
  * running engine picks them up without a process restart.
150
150
  */
151
151
  reloadModels?: boolean;
152
+ /** Clear the live model pool, used after logout removes saved credentials. */
153
+ clearModels?: boolean;
152
154
  /**
153
155
  * Re-read disk settings and hot-push the disk-default config fields (preset /
154
156
  * customSystemPrompt / appendSystemPrompt / personalization / mcpServers) +
@@ -243,6 +245,8 @@ export interface AgentStreamEventNotification {
243
245
  }
244
246
  /** Server requests approval from the client (UI). */
245
247
  export interface ApprovalRequestNotification {
248
+ /** Originating engine session when known. */
249
+ sessionId?: string;
246
250
  requestId: string;
247
251
  request: ApprovalRequest;
248
252
  }
@@ -85,8 +85,13 @@ export declare function resolveShellInvocation(command: string, shell?: string):
85
85
  file: string;
86
86
  args: string[];
87
87
  };
88
+ export declare function resolveGitBash(): string | undefined;
89
+ /** Reset the Git Bash probe cache. Test-only (platform is stubbed per test). */
90
+ export declare function _resetGitBashCache(): void;
88
91
  /** 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. */
92
+ * (no `-c`/`/c` command). Windows → Git Bash if present, else ComSpec/cmd.exe;
93
+ * POSIX → $SHELL/bin/bash. Windows prefers Git Bash so the model's bash-syntax
94
+ * commands actually run (cmd.exe can't). */
90
95
  export declare function defaultShellBinary(shell?: string): string;
91
96
  /**
92
97
  * 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
@@ -115,20 +117,81 @@ export function mergeShellEnv(base, projectEnv) {
115
117
  export function resolveShellInvocation(command, shell) {
116
118
  if (process.platform === "win32") {
117
119
  const file = shell ?? process.env.ComSpec ?? "cmd.exe";
118
- // PowerShell variants take -Command; cmd.exe (and cmd-like) take /c.
120
+ // Flag form depends on the shell: PowerShell -Command; a POSIX shell such
121
+ // as Git Bash's bash.exe / sh → -c (it does NOT understand cmd's /c); cmd.exe
122
+ // (and cmd-like) → /c. Detecting bash/sh matters now that defaultShellBinary
123
+ // prefers Git Bash on Windows — feeding it /c would break every command.
119
124
  const isPwsh = /(^|[\\/])(pwsh|powershell)(\.exe)?$/i.test(file);
120
- return isPwsh ? { file, args: ["-Command", command] } : { file, args: ["/c", command] };
125
+ if (isPwsh)
126
+ return { file, args: ["-Command", command] };
127
+ const isPosixShell = /(^|[\\/])(bash|sh|zsh|dash)(\.exe)?$/i.test(file);
128
+ return { file, args: [isPosixShell ? "-c" : "/c", command] };
121
129
  }
122
130
  const file = shell ?? process.env.SHELL ?? "/bin/bash";
123
131
  return { file, args: ["-c", command] };
124
132
  }
133
+ /**
134
+ * Best-effort locate Git Bash's `bash.exe` on Windows. Returns undefined on
135
+ * non-Windows, when git isn't installed, or when no bash.exe is found.
136
+ *
137
+ * WHY: The Bash tool and background shells feed the model's *bash* commands
138
+ * (`ls`, `&&`, `$(…)`, pipes, quoting) to the shell. On Windows the historical
139
+ * default is `cmd.exe`, which can't run any of that — so "Bash" was effectively
140
+ * broken on Windows. Git for Windows (which most devs already have — we detect
141
+ * it for repo ops anyway) ships a full bash at `<git>\bin\bash.exe`, so prefer
142
+ * it. Only falls back to cmd.exe when Git Bash truly isn't present.
143
+ *
144
+ * Resolution order:
145
+ * 1. CODE_SHELL_GIT_BASH_PATH env override (explicit user config wins).
146
+ * 2. Reverse-engineer from the git binary's location: `where git` gives e.g.
147
+ * `C:\Program Files\Git\cmd\git.exe` → `…\Git\bin\bash.exe`.
148
+ * 3. The two default install locations (Program Files / Program Files (x86)).
149
+ * Cached after first probe (a spawn per command would be wasteful).
150
+ */
151
+ let gitBashCache;
152
+ export function resolveGitBash() {
153
+ if (process.platform !== "win32")
154
+ return undefined;
155
+ if (gitBashCache !== undefined)
156
+ return gitBashCache ?? undefined;
157
+ const override = process.env.CODE_SHELL_GIT_BASH_PATH;
158
+ if (override && existsSync(override))
159
+ return (gitBashCache = override);
160
+ const candidates = [];
161
+ // (2) derive from `where git`. Git installs git.exe under either \cmd\ or
162
+ // \bin\; bash.exe lives under the sibling \bin\. Walk up to the Git root.
163
+ try {
164
+ const out = execFileSync("where", ["git"], { encoding: "utf-8", timeout: 3000 });
165
+ const gitExe = out.split(/\r?\n/).find((l) => l.trim().toLowerCase().endsWith("git.exe"));
166
+ if (gitExe) {
167
+ const gitRoot = dirname(dirname(gitExe.trim())); // …\Git\cmd\git.exe → …\Git
168
+ candidates.push(join(gitRoot, "bin", "bash.exe"));
169
+ }
170
+ }
171
+ catch {
172
+ // git not on PATH — fall through to the well-known locations.
173
+ }
174
+ // (3) default install locations.
175
+ const pf = process.env["ProgramFiles"] ?? "C:\\Program Files";
176
+ const pf86 = process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
177
+ candidates.push(join(pf, "Git", "bin", "bash.exe"));
178
+ candidates.push(join(pf86, "Git", "bin", "bash.exe"));
179
+ const found = candidates.find((p) => existsSync(p));
180
+ return (gitBashCache = found ?? null) ?? undefined;
181
+ }
182
+ /** Reset the Git Bash probe cache. Test-only (platform is stubbed per test). */
183
+ export function _resetGitBashCache() {
184
+ gitBashCache = undefined;
185
+ }
125
186
  /** 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. */
187
+ * (no `-c`/`/c` command). Windows → Git Bash if present, else ComSpec/cmd.exe;
188
+ * POSIX → $SHELL/bin/bash. Windows prefers Git Bash so the model's bash-syntax
189
+ * commands actually run (cmd.exe can't). */
127
190
  export function defaultShellBinary(shell) {
128
191
  if (shell)
129
192
  return shell;
130
193
  if (process.platform === "win32")
131
- return process.env.ComSpec ?? "cmd.exe";
194
+ return resolveGitBash() ?? process.env.ComSpec ?? "cmd.exe";
132
195
  return process.env.SHELL ?? "/bin/bash";
133
196
  }
134
197
  /**
@@ -143,8 +206,10 @@ export function resolveSpawnTarget(command, opts) {
143
206
  const wrapped = opts.sandbox.wrap(command, { cwd: opts.cwd, shell: opts.shell });
144
207
  return { file: wrapped.file, args: wrapped.args, cleanup: wrapped.cleanup };
145
208
  }
146
- // No sandbox (the `off` backend / Windows): pick the shell + command-flag
147
- // form for the platform instead of assuming POSIX `-c`.
209
+ // No sandbox configured: pick the shell + command-flag form for the
210
+ // platform instead of assuming POSIX `-c`. Note Bash always passes a
211
+ // backend (off at minimum), so it never reaches this line — the off
212
+ // backend's wrap() delegates to resolveShellInvocation itself.
148
213
  return resolveShellInvocation(command, opts.shell);
149
214
  }
150
215
  /**
@@ -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
  },
@@ -2,6 +2,7 @@
2
2
  * Built-in tool registration.
3
3
  */
4
4
  import type { RegisteredTool } from "../../types.js";
5
+ import type { ToolVisibilityContext } from "../context.js";
5
6
  /**
6
7
  * Tool executor signature.
7
8
  *
@@ -27,6 +28,7 @@ export type BuiltinToolResult = string | {
27
28
  sandbox: import("../../types.js").ToolResult["sandbox"];
28
29
  };
29
30
  export type BuiltinToolFn = (args: Record<string, unknown>, ctx?: import("../context.js").ToolContext) => Promise<BuiltinToolResult>;
31
+ export type BuiltinToolGuard = (ctx: ToolVisibilityContext) => boolean;
30
32
  export interface BuiltinTool {
31
33
  definition: RegisteredTool;
32
34
  execute: BuiltinToolFn;
@@ -38,6 +40,6 @@ export declare const BUILTIN_TOOLS: BuiltinTool[];
38
40
  * engine.ts toolDefs assembly). Tools NOT listed here are always visible.
39
41
  * Keyed by the tool's `name` (must match the toolDef name).
40
42
  */
41
- export declare const BUILTIN_TOOL_GUARDS: Map<string, (cwd: string) => boolean>;
43
+ export declare const BUILTIN_TOOL_GUARDS: Map<string, BuiltinToolGuard>;
42
44
  /** UseCredential is available when the cwd's CredentialStore has ≥1 credential. */
43
45
  export declare function isUseCredentialAvailable(cwd: string): boolean;
@@ -711,17 +711,17 @@ export const BUILTIN_TOOLS = [
711
711
  * Keyed by the tool's `name` (must match the toolDef name).
712
712
  */
713
713
  export const BUILTIN_TOOL_GUARDS = new Map([
714
- [webSearchToolDef.name, isWebSearchAvailable],
715
- [generateImageToolDef.name, isGenerateImageAvailable],
716
- [generateVideoToolDef.name, isGenerateVideoAvailable],
714
+ [webSearchToolDef.name, (ctx) => isWebSearchAvailable(ctx.cwd)],
715
+ [generateImageToolDef.name, (ctx) => isGenerateImageAvailable(ctx.cwd)],
716
+ [generateVideoToolDef.name, (ctx) => isGenerateVideoAvailable(ctx.cwd)],
717
717
  // UseCredential is hidden until at least one credential exists — keeps it out
718
718
  // of the tool list (and the context) for the common no-credentials case,
719
719
  // matching the spec's "quiet when empty" intent (true ToolSearch-deferral for
720
720
  // builtins isn't wired in the engine).
721
- [useCredentialToolDef.name, isUseCredentialAvailable],
721
+ [useCredentialToolDef.name, (ctx) => isUseCredentialAvailable(ctx.cwd)],
722
722
  // InjectCredential hidden until ≥1 cookie credential exists (browser injection
723
723
  // is cookie-only). Also degrades at call time if no browser bridge is wired.
724
- [injectCredentialToolDef.name, isInjectCredentialAvailable],
724
+ [injectCredentialToolDef.name, (ctx) => isInjectCredentialAvailable(ctx.cwd)],
725
725
  ]);
726
726
  /** UseCredential is available when the cwd's CredentialStore has ≥1 credential. */
727
727
  export function isUseCredentialAvailable(cwd) {
@@ -6,6 +6,11 @@ export const sleepToolDef = {
6
6
  description: "Pause execution for a brief, deterministic wait (e.g. letting a just-started service settle for a few seconds). " +
7
7
  "Do NOT use Sleep to poll for or wait on background work (background shells, async sub-agents, video generation): " +
8
8
  "the system wakes you automatically when that work completes — just end your turn instead of looping Sleep. " +
9
+ "If you want a safety net in case a background task hangs and never signals completion, do NOT loop Sleep either — " +
10
+ "instead end your turn and schedule a one-shot self-wakeup with CronCreate " +
11
+ "({ schedule: '5m', once: true, continueInSession: true, permissionLevel: 'read-only', " +
12
+ "prompt: 'check whether <that task> finished; if still running, wait again' }). " +
13
+ "That returns control to you at the interval without burning a turn spinning. " +
9
14
  "Maximum duration is 300 seconds (5 minutes).",
10
15
  inputSchema: {
11
16
  type: "object",
@@ -169,6 +169,10 @@ export interface SubAgentSpawner {
169
169
  * Optional fields are filled in by Engine.run(); some headless paths may
170
170
  * leave them undefined (e.g. running without UI → no askUser).
171
171
  */
172
+ export interface ToolVisibilityContext {
173
+ cwd: string;
174
+ hasGoal: boolean;
175
+ }
172
176
  export interface ToolContext {
173
177
  /** Active working directory for this Engine. */
174
178
  cwd: string;
@@ -290,6 +294,12 @@ export interface ToolContext {
290
294
  * sub-agents and no-cwd contexts (same as readBuiltinOverride).
291
295
  */
292
296
  disabledBuiltins?: Set<string>;
297
+ /**
298
+ * Per-turn context used by builtin availability guards. Engine.run() uses the
299
+ * same object to hide tools from the model; ToolExecutor reuses it to reject
300
+ * direct calls to tools that are not available in the current runtime state.
301
+ */
302
+ toolVisibility?: ToolVisibilityContext;
293
303
  /**
294
304
  * MCP servers THIS session's merged config enables (keys of
295
305
  * config.mcpServers, enabled!==false). The pool + registry are
@@ -11,6 +11,9 @@ import { validateToolArgs } from "./validation.js";
11
11
  import { PLAN_MODE_ALLOWED_TOOLS } from "./plan-mode-allowlist.js";
12
12
  import { enforcePathPolicyWithApproval } from "./path-policy.js";
13
13
  import { parsePatch } from "./builtin/apply-patch/parser.js";
14
+ import { BUILTIN_TOOL_GUARDS } from "./builtin/index.js";
15
+ import { COMPLETE_GOAL_TOOL_NAME } from "./builtin/complete-goal.js";
16
+ import { CANCEL_GOAL_TOOL_NAME } from "./builtin/cancel-goal.js";
14
17
  // A1 hardening: hooks must never promote a non-`allow` classifier
15
18
  // decision to `allow`. They may otherwise adjust the decision freely
16
19
  // (e.g. tighten `allow` to `deny`/`ask`, or relax `deny` to `ask` to
@@ -119,6 +122,24 @@ export class ToolExecutor {
119
122
  isError: true,
120
123
  };
121
124
  }
125
+ if ((call.toolName === COMPLETE_GOAL_TOOL_NAME || call.toolName === CANCEL_GOAL_TOOL_NAME) &&
126
+ this.toolCtx?.toolVisibility?.hasGoal !== true) {
127
+ return {
128
+ id: call.id,
129
+ toolName: call.toolName,
130
+ error: `Tool ${call.toolName} is only available while an active goal is present. Do NOT retry this tool call unless a goal is active.`,
131
+ isError: true,
132
+ };
133
+ }
134
+ const visibilityGuard = BUILTIN_TOOL_GUARDS.get(call.toolName);
135
+ if (visibilityGuard && this.toolCtx?.toolVisibility && !visibilityGuard(this.toolCtx.toolVisibility)) {
136
+ return {
137
+ id: call.id,
138
+ toolName: call.toolName,
139
+ error: `Tool ${call.toolName} is not available in the current session context. Do NOT retry this tool call unless the relevant context changes.`,
140
+ isError: true,
141
+ };
142
+ }
122
143
  // Same gate for MCP tools: the registry is worker-shared, so it can hold
123
144
  // tools from servers OTHER sessions enabled. Visibility filtering hides
124
145
  // them from this session's tool list; this rejects a direct call anyway.
@@ -274,7 +295,7 @@ export class ToolExecutor {
274
295
  // the hook and the user together have decided.
275
296
  if (hookResult.decision === "ask") {
276
297
  const reason = hookResult.messages?.join("\n") ?? undefined;
277
- const approved = await this.permission.handleAsk(call.toolName, call.args, reason);
298
+ const approved = await this.permission.handleAsk(call.toolName, call.args, reason, { sessionId: this.toolCtx?.sessionId });
278
299
  if (!approved) {
279
300
  return {
280
301
  id: call.id,
@@ -347,7 +368,9 @@ export class ToolExecutor {
347
368
  }
348
369
  if (decision === "ask") {
349
370
  const reason = permHook.messages?.join("\n");
350
- const approved = await this.permission.handleAsk(call.toolName, call.args, reason);
371
+ const approved = await this.permission.handleAsk(call.toolName, call.args, reason, {
372
+ sessionId: this.toolCtx?.sessionId,
373
+ });
351
374
  if (!approved) {
352
375
  return {
353
376
  id: call.id,
@@ -130,7 +130,9 @@ export declare class PermissionClassifier {
130
130
  reconfigure(mode: PermissionMode, approvalBackend: ApprovalBackend, rules?: PermissionRule[]): void;
131
131
  getMode(): PermissionMode;
132
132
  classify(toolName: string, args: Record<string, unknown>): PermissionDecision;
133
- handleAsk(toolName: string, args: Record<string, unknown>, reason?: string): Promise<boolean>;
133
+ handleAsk(toolName: string, args: Record<string, unknown>, reason?: string, opts?: {
134
+ sessionId?: string;
135
+ }): Promise<boolean>;
134
136
  /** Get denial warning message if the model keeps getting denied. */
135
137
  getDenialWarning(toolName: string): string | undefined;
136
138
  private matchesRule;
@@ -861,7 +861,7 @@ export class PermissionClassifier {
861
861
  return "ask";
862
862
  }
863
863
  }
864
- async handleAsk(toolName, args, reason) {
864
+ async handleAsk(toolName, args, reason, opts) {
865
865
  if (this.defaultMode === "dontAsk") {
866
866
  this.log.info("permission.auto_deny", {
867
867
  cat: "permission",
@@ -900,6 +900,7 @@ export class PermissionClassifier {
900
900
  ? `${baseDescription}\n\nReason (from pre_tool_use hook): ${reason}`
901
901
  : baseDescription;
902
902
  result = await this.approvalBackend.requestApproval({
903
+ ...(opts?.sessionId ? { sessionId: opts.sessionId } : {}),
903
904
  toolName,
904
905
  args,
905
906
  description,
@@ -1,8 +1,14 @@
1
+ import { resolveShellInvocation } from "../../runtime/spawn-common.js";
1
2
  export function createOffBackend() {
2
3
  return {
3
4
  name: "off",
4
5
  wrap(command, opts) {
5
- return { file: opts.shell, args: ["-c", command] };
6
+ // "off" = no sandboxing; just run the command through the shell. Delegate
7
+ // to resolveShellInvocation for the PLATFORM-CORRECT flag instead of a
8
+ // hardcoded POSIX `-c`: on Windows cmd.exe needs `/c` (a bare `-c` is taken
9
+ // as a filename and cmd hangs in interactive mode until timeout — the
10
+ // "Bash never runs on Windows" beta regression). POSIX still gets `-c`.
11
+ return resolveShellInvocation(command, opts.shell);
6
12
  },
7
13
  };
8
14
  }
package/dist/types.d.ts CHANGED
@@ -229,6 +229,8 @@ export interface PermissionRule {
229
229
  reason?: string;
230
230
  }
231
231
  export interface ApprovalRequest {
232
+ /** Originating engine session. Hosts use this only to route the prompt UI. */
233
+ sessionId?: string;
232
234
  toolName: string;
233
235
  args: Record<string, unknown>;
234
236
  description: string;
@@ -464,6 +466,8 @@ export interface LLMConfig {
464
466
  apiKey?: string;
465
467
  baseUrl?: string;
466
468
  maxTokens?: number;
469
+ /** Per-model context window size used by hosts to seed Engine.maxContextTokens. */
470
+ maxContextTokens?: number;
467
471
  /**
468
472
  * Shell command whose stdout is the auth token (TODO 7.2). Resolved at
469
473
  * client-build time when `apiKey` is absent. The trimmed first line of
@@ -38,3 +38,11 @@ export declare function setGitPathOverride(path: string | null | undefined): voi
38
38
  export declare function resolveGit(env?: NodeJS.ProcessEnv): string;
39
39
  /** Is a usable git binary available (override path, or git on PATH)? */
40
40
  export declare function isGitAvailable(env?: NodeJS.ProcessEnv): boolean;
41
+ /**
42
+ * The RESOLVED absolute path of the usable git binary (override path, or git
43
+ * found on PATH), or null if none. Lets the settings UI auto-fill the git.path
44
+ * field after a successful detection instead of only reporting available:true
45
+ * with no path (the "检测到了但没回填 path" complaint). Returns the real path so
46
+ * the user can see/keep exactly what was found.
47
+ */
48
+ export declare function resolveGitPath(env?: NodeJS.ProcessEnv): string | null;
@@ -120,6 +120,16 @@ export function resolveGit(env = process.env) {
120
120
  export function isGitAvailable(env = process.env) {
121
121
  return findExecutable(gitPathOverride ?? "git", env) !== null;
122
122
  }
123
+ /**
124
+ * The RESOLVED absolute path of the usable git binary (override path, or git
125
+ * found on PATH), or null if none. Lets the settings UI auto-fill the git.path
126
+ * field after a successful detection instead of only reporting available:true
127
+ * with no path (the "检测到了但没回填 path" complaint). Returns the real path so
128
+ * the user can see/keep exactly what was found.
129
+ */
130
+ export function resolveGitPath(env = process.env) {
131
+ return findExecutable(gitPathOverride ?? "git", env);
132
+ }
123
133
  function resolveExecutableUncached(trimmed, env) {
124
134
  const command = trimmed;
125
135
  // Already a path (absolute or relative with a separator): resolve extension.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-core",
3
- "version": "0.6.0-rc.2",
3
+ "version": "0.6.0-rc.4",
4
4
  "description": "Core engine for code-shell — agent orchestration, tool execution, hooks, protocol. UI-agnostic.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",