@cjhyy/code-shell-core 0.6.0-rc.13 → 0.6.0-rc.15

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 (87) hide show
  1. package/dist/capability-control/service.d.ts +2 -0
  2. package/dist/capability-control/service.js +4 -2
  3. package/dist/cc-orchestrator/codex-session-history.js +1 -1
  4. package/dist/cc-orchestrator/external-agent-bindings.js +1 -1
  5. package/dist/cc-orchestrator/external-agent-session-store.js +3 -1
  6. package/dist/cc-orchestrator/session-history.js +2 -2
  7. package/dist/cli/agent-server-stdio.js +7 -2
  8. package/dist/context/compaction.d.ts +8 -1
  9. package/dist/context/compaction.js +49 -4
  10. package/dist/context/manager.d.ts +11 -2
  11. package/dist/context/manager.js +64 -3
  12. package/dist/credentials/inject-credential-tool.d.ts +3 -1
  13. package/dist/credentials/inject-credential-tool.js +29 -10
  14. package/dist/credentials/use-credential-tool.d.ts +1 -0
  15. package/dist/credentials/use-credential-tool.js +3 -0
  16. package/dist/engine/engine.d.ts +9 -0
  17. package/dist/engine/engine.js +96 -17
  18. package/dist/engine/turn-loop.d.ts +3 -1
  19. package/dist/engine/turn-loop.js +4 -2
  20. package/dist/engine/types.d.ts +8 -0
  21. package/dist/git/worktree/crud.d.ts +69 -0
  22. package/dist/git/worktree/crud.js +206 -0
  23. package/dist/git/worktree/diff.d.ts +14 -0
  24. package/dist/git/worktree/diff.js +82 -0
  25. package/dist/git/worktree/git-exec.d.ts +7 -0
  26. package/dist/git/worktree/git-exec.js +51 -0
  27. package/dist/git/worktree/index.d.ts +5 -0
  28. package/dist/git/worktree/index.js +5 -0
  29. package/dist/git/worktree/query.d.ts +42 -0
  30. package/dist/git/worktree/query.js +121 -0
  31. package/dist/git/worktree/slug.d.ts +11 -0
  32. package/dist/git/worktree/slug.js +58 -0
  33. package/dist/git/worktree.d.ts +1 -127
  34. package/dist/git/worktree.js +5 -464
  35. package/dist/index.d.ts +28 -27
  36. package/dist/index.js +24 -24
  37. package/dist/plugins/installer/checkUpdate.d.ts +4 -1
  38. package/dist/plugins/installer/checkUpdate.js +4 -2
  39. package/dist/plugins/installer/install.js +2 -0
  40. package/dist/plugins/installer/parseSource.d.ts +4 -1
  41. package/dist/plugins/installer/parseSource.js +28 -10
  42. package/dist/plugins/installer/update.d.ts +4 -1
  43. package/dist/plugins/installer/update.js +5 -3
  44. package/dist/plugins/parseMarketplaceInput.d.ts +4 -1
  45. package/dist/plugins/parseMarketplaceInput.js +4 -3
  46. package/dist/preset/index.d.ts +1 -0
  47. package/dist/preset/index.js +6 -0
  48. package/dist/prompt/sections/base.md +1 -1
  49. package/dist/protocol/chat-session-manager.js +7 -0
  50. package/dist/protocol/server.d.ts +10 -0
  51. package/dist/protocol/server.js +88 -4
  52. package/dist/protocol/types.d.ts +6 -0
  53. package/dist/protocol/types.js +2 -0
  54. package/dist/runtime/background-shell.js +2 -3
  55. package/dist/services/auto-dream.d.ts +15 -4
  56. package/dist/services/auto-dream.js +20 -20
  57. package/dist/services/dream-consolidation.d.ts +2 -3
  58. package/dist/services/dream-consolidation.js +65 -15
  59. package/dist/services/extract-memories.d.ts +14 -5
  60. package/dist/services/extract-memories.js +20 -3
  61. package/dist/services/global-dream-promotion.d.ts +23 -0
  62. package/dist/services/global-dream-promotion.js +112 -0
  63. package/dist/services/memory-orchestrator.js +347 -34
  64. package/dist/session/memory.d.ts +56 -17
  65. package/dist/session/memory.js +337 -78
  66. package/dist/session/session-manager.d.ts +1 -1
  67. package/dist/session/session-manager.js +6 -6
  68. package/dist/session/transcript.d.ts +1 -1
  69. package/dist/session/transcript.js +17 -5
  70. package/dist/settings/manager.js +43 -12
  71. package/dist/settings/schema.d.ts +21 -0
  72. package/dist/settings/schema.js +12 -0
  73. package/dist/tool-system/builtin/index.js +18 -8
  74. package/dist/tool-system/builtin/memory.js +40 -8
  75. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  76. package/dist/tool-system/builtin/view-image.js +100 -19
  77. package/dist/tool-system/builtin/worktree.d.ts +2 -0
  78. package/dist/tool-system/builtin/worktree.js +59 -11
  79. package/dist/tool-system/context.d.ts +11 -1
  80. package/dist/tool-system/mcp-manager.d.ts +8 -5
  81. package/dist/tool-system/mcp-manager.js +85 -55
  82. package/dist/tool-system/path-policy.d.ts +2 -0
  83. package/dist/tool-system/path-policy.js +28 -12
  84. package/dist/tool-system/workspace-bridge.d.ts +11 -0
  85. package/dist/tool-system/workspace-bridge.js +1 -0
  86. package/dist/types.d.ts +14 -0
  87. package/package.json +1 -1
@@ -4,6 +4,49 @@
4
4
  import { existsSync } from "node:fs";
5
5
  import { isAbsolute, resolve } from "node:path";
6
6
  import { createWorktree, removeWorktree, listWorktrees, validateWorktreeSlug, selectPlatformScript, runWorktreeSetup, worktreeHasUncommittedOrAheadChanges, currentBranch, } from "../../git/worktree.js";
7
+ export const switchSessionWorkspaceToolDef = {
8
+ name: "SwitchSessionWorkspace",
9
+ description: "Switch this current conversation session into or out of a workspace through the host UI path. " +
10
+ "Use this when you need isolated or parallel work in a git worktree, when you need to move " +
11
+ "this conversation to an existing worktree path/branch, or when you are done and should return " +
12
+ "this current conversation to main. This is the correct way to move THIS conversation's session " +
13
+ "into or out of a worktree on desktop.",
14
+ inputSchema: {
15
+ type: "object",
16
+ properties: {
17
+ target: {
18
+ type: "string",
19
+ description: "Workspace target: 'main', a new slug, an existing worktree path, or an existing branch name.",
20
+ },
21
+ },
22
+ required: ["target"],
23
+ },
24
+ };
25
+ export async function switchSessionWorkspaceTool(args, ctx) {
26
+ const target = stringArg(args.target);
27
+ if (!target)
28
+ return "Error: target is required";
29
+ const bridge = ctx?.workspace;
30
+ if (!bridge) {
31
+ return ("SwitchSessionWorkspace is not available in this host. " +
32
+ "Use the host's supported workspace controls instead.");
33
+ }
34
+ try {
35
+ const workspace = await bridge.switch(target);
36
+ ctx?.setSessionWorkspace?.(workspace);
37
+ const details = workspace.kind === "worktree" && workspace.worktree
38
+ ? `\n Branch: ${workspace.worktree.branch}`
39
+ : "";
40
+ return (`Switched session workspace:\n` +
41
+ ` Path: ${workspace.root}` +
42
+ details +
43
+ `\n\n` +
44
+ nextTurnNotice(workspace.root, ctx?.cwd ?? workspace.root));
45
+ }
46
+ catch (err) {
47
+ return `Error switching workspace: ${err.message}`;
48
+ }
49
+ }
7
50
  export const enterWorktreeToolDef = {
8
51
  name: "EnterWorktree",
9
52
  description: "Switch the current session workspace. Target can be a new worktree slug, " +
@@ -40,6 +83,7 @@ export async function enterWorktreeTool(args, ctx) {
40
83
  };
41
84
  const fromRoot = fromWorkspace.root;
42
85
  const currentTurnRoot = ctx?.cwd ?? fromRoot;
86
+ const branchPrefix = ctx?.engine?.readWorktreeBranchPrefix?.(mainRoot);
43
87
  try {
44
88
  if (target === "main") {
45
89
  const workspace = { root: mainRoot, kind: "main" };
@@ -50,12 +94,13 @@ export async function enterWorktreeTool(args, ctx) {
50
94
  ` From: ${fromRoot}\n\n` +
51
95
  nextTurnNotice(mainRoot, currentTurnRoot));
52
96
  }
53
- const selected = resolveWorktreeTarget({
97
+ const selected = await resolveWorktreeTarget({
54
98
  target,
55
99
  cwd: ctx?.cwd ?? mainRoot,
56
100
  mainRoot,
57
101
  sessionId,
58
102
  currentWorkspace: fromWorkspace,
103
+ branchPrefix,
59
104
  });
60
105
  const workspace = toSessionWorkspace(selected, fromWorkspace);
61
106
  persistSessionWorkspace(sessionManager, sessionId, workspace, ctx);
@@ -125,7 +170,7 @@ export async function exitWorktreeTool(args, ctx) {
125
170
  return `Error: unknown action "${requested}" (expected keep, detach, or discard).`;
126
171
  }
127
172
  const hasPendingChanges = existsSync(workspace.worktree.path) &&
128
- worktreeHasUncommittedOrAheadChanges(workspace.worktree.path, workspace.worktree.baseRef);
173
+ (await worktreeHasUncommittedOrAheadChanges(workspace.worktree.path, workspace.worktree.baseRef));
129
174
  const action = requested ?? (hasPendingChanges ? undefined : "detach");
130
175
  if (!action) {
131
176
  return (`Error: worktree has uncommitted changes or new commits. Choose action "keep" to preserve ` +
@@ -137,10 +182,11 @@ export async function exitWorktreeTool(args, ctx) {
137
182
  }
138
183
  const mainRoot = sessionManager.readCwd(sessionId) ?? workspace.root;
139
184
  const currentTurnRoot = ctx?.cwd ?? workspace.root;
185
+ const branchPrefix = ctx?.engine?.readWorktreeBranchPrefix?.(mainRoot);
140
186
  try {
141
187
  let removal;
142
188
  if (action === "discard" || action === "detach") {
143
- const otherOwners = otherSessionOwnersForWorktree(sessionManager, sessionId, mainRoot, workspace.worktree.path);
189
+ const otherOwners = await otherSessionOwnersForWorktree(sessionManager, sessionId, mainRoot, workspace.worktree.path);
144
190
  if (otherOwners.length > 0) {
145
191
  const mainWorkspace = { root: mainRoot, kind: "main" };
146
192
  persistSessionWorkspace(sessionManager, sessionId, mainWorkspace, ctx);
@@ -149,7 +195,7 @@ export async function exitWorktreeTool(args, ctx) {
149
195
  }
150
196
  }
151
197
  if (action === "discard") {
152
- removal = removeWorktree(workspace.worktree.path, true);
198
+ removal = removeWorktree(workspace.worktree.path, true, { prefix: branchPrefix });
153
199
  }
154
200
  else if (action === "detach") {
155
201
  removal = removeWorktree(workspace.worktree.path, false);
@@ -196,15 +242,15 @@ function sessionServices(ctx) {
196
242
  }
197
243
  return { ok: true, sessionId, sessionManager };
198
244
  }
199
- function otherSessionOwnersForWorktree(sessionManager, sessionId, mainRoot, worktreePath) {
245
+ async function otherSessionOwnersForWorktree(sessionManager, sessionId, mainRoot, worktreePath) {
200
246
  const workspaceOwners = sessionManager
201
247
  .list(Number.MAX_SAFE_INTEGER)
202
248
  .map((session) => ({ sessionId: session.sessionId, workspace: session.workspace }))
203
249
  .filter((owner) => owner.workspace !== undefined);
204
- const entry = listWorktrees(mainRoot, {
250
+ const entry = (await listWorktrees(mainRoot, {
205
251
  currentSessionId: sessionId,
206
252
  workspaceOwners,
207
- }).find((worktree) => resolve(worktree.path) === resolve(worktreePath));
253
+ })).find((worktree) => resolve(worktree.path) === resolve(worktreePath));
208
254
  return (entry?.occupiedBySessionIds ?? []).filter((owner) => owner !== sessionId);
209
255
  }
210
256
  function sharedWorktreeRemovalSkippedMessage(otherOwners, mainRoot, currentTurnRoot) {
@@ -213,8 +259,8 @@ function sharedWorktreeRemovalSkippedMessage(otherOwners, mainRoot, currentTurnR
213
259
  `Back to ${mainRoot} starting next turn.\n` +
214
260
  nextTurnNotice(mainRoot, currentTurnRoot));
215
261
  }
216
- function resolveWorktreeTarget(opts) {
217
- const entries = listWorktrees(opts.mainRoot);
262
+ async function resolveWorktreeTarget(opts) {
263
+ const entries = await listWorktrees(opts.mainRoot);
218
264
  const pathTarget = pathLike(opts.target) ? resolvePathTarget(opts.target, opts.cwd) : undefined;
219
265
  const branchTarget = normalizeBranchName(opts.target);
220
266
  const match = entries.find((entry) => {
@@ -230,7 +276,7 @@ function resolveWorktreeTarget(opts) {
230
276
  worktreePath: match.path,
231
277
  worktreeName: match.path.split(/[\\/]/).pop() ?? match.branch,
232
278
  worktreeBranch: match.branch,
233
- originalBranch: currentBranch(opts.mainRoot),
279
+ originalBranch: await currentBranch(opts.mainRoot),
234
280
  sessionId: opts.sessionId,
235
281
  createdAt: Date.now(),
236
282
  },
@@ -241,7 +287,9 @@ function resolveWorktreeTarget(opts) {
241
287
  throw new Error(`no existing worktree found at ${opts.target}`);
242
288
  }
243
289
  validateWorktreeSlug(opts.target);
244
- const created = createWorktree(opts.mainRoot, opts.target, opts.sessionId);
290
+ const created = await createWorktree(opts.mainRoot, opts.target, opts.sessionId, {
291
+ prefix: opts.branchPrefix,
292
+ });
245
293
  return { created: true, session: created, from: created.originalBranch ?? "HEAD" };
246
294
  }
247
295
  function toSessionWorkspace(selected, currentWorkspace) {
@@ -40,6 +40,8 @@ export interface ToolRuntimeHost {
40
40
  linux?: string;
41
41
  windows?: string;
42
42
  } | undefined;
43
+ /** Branch prefix used for CodeShell-managed worktree branches. */
44
+ readWorktreeBranchPrefix?(cwd?: string): string | undefined;
43
45
  /** Resolve a setup-only sandbox for a newly-created worktree root. */
44
46
  resolveWorktreeSetupSandbox?(cwd: string): Promise<SandboxBackend | undefined>;
45
47
  /** Resolve setup-only shell env for a newly-created worktree root. */
@@ -180,6 +182,7 @@ export interface SubAgentSpawner {
180
182
  export interface ToolVisibilityContext {
181
183
  cwd: string;
182
184
  hasGoal: boolean;
185
+ settingsScope?: import("../settings/manager.js").SettingsScope;
183
186
  }
184
187
  export interface ToolContext {
185
188
  /** Active working directory for this Engine. */
@@ -348,6 +351,13 @@ export interface ToolContext {
348
351
  * docs/superpowers/specs/2026-06-16-browser-automation-mvp.md.
349
352
  */
350
353
  browser?: import("./browser-bridge.js").BrowserBridge;
354
+ /**
355
+ * Host-backed workspace switch bridge. Desktop wires this to Electron main so
356
+ * the model's SwitchSessionWorkspace tool goes through the same path as the UI
357
+ * switcher. Undefined outside hosts that can switch this conversation's
358
+ * session workspace.
359
+ */
360
+ workspace?: import("./workspace-bridge.js").WorkspaceBridge;
351
361
  /**
352
362
  * Inject a stored cookie credential into the built-in browser (restore its
353
363
  * login state so the AI can drive the page as that account). The host
@@ -359,7 +369,7 @@ export interface ToolContext {
359
369
  injectCredentialToBrowser?: InjectCredentialFn;
360
370
  }
361
371
  /** Inject a cookie credential into the built-in browser (host-implemented). */
362
- export type InjectCredentialFn = (credentialId: string) => Promise<{
372
+ export type InjectCredentialFn = (credentialId: string, credentialScope?: "full" | "project") => Promise<{
363
373
  ok: boolean;
364
374
  count?: number;
365
375
  error?: string;
@@ -6,6 +6,12 @@
6
6
  import type { Tool as McpTool } from "@modelcontextprotocol/sdk/types.js";
7
7
  import type { MCPServerConfig, RegisteredTool } from "../types.js";
8
8
  import { ToolRegistry } from "./registry.js";
9
+ interface MCPResourceInfo {
10
+ uri: string;
11
+ name: string;
12
+ description?: string;
13
+ serverName: string;
14
+ }
9
15
  /**
10
16
  * Read a required secret from `process.env` by NAME (Codex-style env-secret
11
17
  * handling — the value is never persisted in MCP config). A referenced env var
@@ -125,13 +131,10 @@ export declare class MCPManager {
125
131
  /**
126
132
  * List resources from MCP servers.
127
133
  */
128
- listResources(serverName?: string, signal?: AbortSignal): Promise<Array<{
129
- uri: string;
130
- name: string;
131
- description?: string;
132
- }>>;
134
+ listResources(serverName?: string, signal?: AbortSignal): Promise<MCPResourceInfo[]>;
133
135
  /**
134
136
  * Read a resource from an MCP server.
135
137
  */
136
138
  readResource(serverName: string, uri: string, signal?: AbortSignal): Promise<string>;
137
139
  }
140
+ export {};
@@ -9,9 +9,10 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
9
9
  import { logger } from "../logging/logger.js";
10
10
  import { CredentialStore } from "../credentials/index.js";
11
11
  import { ENV_ALLOWLIST } from "../runtime/spawn-common.js";
12
- import { writeFile, mkdir } from "node:fs/promises";
12
+ import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
13
13
  import { join } from "node:path";
14
14
  import { diagnoseMcpStdioMissingCommand, previewPath } from "./mcp-stdio-diagnostics.js";
15
+ import { codeShellHome } from "../session/session-manager.js";
15
16
  /**
16
17
  * Read a required secret from `process.env` by NAME (Codex-style env-secret
17
18
  * handling — the value is never persisted in MCP config). A referenced env var
@@ -118,7 +119,29 @@ export function inferTransportType(config) {
118
119
  * spill itself is bounded by the byte budget below; this cap is just
119
120
  * to keep ls(~/.code-shell/mcp_images) tractable.
120
121
  */
122
+ const MAX_MCP_IMAGE_SPILLS_PER_TOOL = 50;
121
123
  const MAX_MCP_IMAGE_BYTES = 8 * 1024 * 1024;
124
+ function spillTimestamp(filename, prefix) {
125
+ const suffix = filename.slice(prefix.length);
126
+ const dot = suffix.lastIndexOf(".");
127
+ const raw = dot === -1 ? suffix : suffix.slice(0, dot);
128
+ const timestamp = Number(raw);
129
+ return Number.isFinite(timestamp) ? timestamp : 0;
130
+ }
131
+ async function pruneMcpImageSpills(baseDir, prefix) {
132
+ const entries = await readdir(baseDir, { withFileTypes: true });
133
+ const spills = entries
134
+ .filter((entry) => entry.isFile() && entry.name.startsWith(prefix))
135
+ .map((entry) => ({
136
+ name: entry.name,
137
+ timestamp: spillTimestamp(entry.name, prefix),
138
+ }))
139
+ .sort((a, b) => a.timestamp - b.timestamp || a.name.localeCompare(b.name));
140
+ const excess = spills.length - MAX_MCP_IMAGE_SPILLS_PER_TOOL;
141
+ if (excess <= 0)
142
+ return;
143
+ await Promise.all(spills.slice(0, excess).map((entry) => unlink(join(baseDir, entry.name)).catch(() => { })));
144
+ }
122
145
  /**
123
146
  * Persist an MCP-returned image to disk and return the textual
124
147
  * reference the LLM should see. Images larger than
@@ -130,9 +153,7 @@ const MAX_MCP_IMAGE_BYTES = 8 * 1024 * 1024;
130
153
  * [mcp-image] server=playwright tool=screenshot saved=/abs/path.png (123 KB)
131
154
  */
132
155
  export async function spillMcpImage(serverName, toolName, base64, mimeType, opts) {
133
- const home = process.env.CODE_SHELL_HOME ?? process.env.HOME ?? "";
134
- const baseDir = opts?.baseDir ??
135
- (home ? join(home, ".code-shell", "mcp_images") : join("/tmp", "code-shell-mcp-images"));
156
+ const baseDir = opts?.baseDir ?? join(codeShellHome(), "mcp_images");
136
157
  const now = opts?.now ?? Date.now;
137
158
  const decodedBytes = Math.floor((base64.length * 3) / 4);
138
159
  if (decodedBytes > MAX_MCP_IMAGE_BYTES) {
@@ -147,11 +168,13 @@ export async function spillMcpImage(serverName, toolName, base64, mimeType, opts
147
168
  : "png";
148
169
  const safeServer = serverName.replace(/[^\w.-]+/g, "_");
149
170
  const safeTool = toolName.replace(/[^\w.-]+/g, "_");
150
- const filename = `${safeServer}-${safeTool}-${now()}.${ext}`;
171
+ const prefix = `${safeServer}-${safeTool}-`;
172
+ const filename = `${prefix}${now()}.${ext}`;
151
173
  const filePath = join(baseDir, filename);
152
174
  try {
153
175
  await mkdir(baseDir, { recursive: true });
154
176
  await writeFile(filePath, Buffer.from(base64, "base64"));
177
+ await pruneMcpImageSpills(baseDir, prefix);
155
178
  }
156
179
  catch (err) {
157
180
  logger.warn("mcp.image_spill_failed", {
@@ -448,59 +471,65 @@ export class MCPManager {
448
471
  * Discover tools from an MCP server and register them.
449
472
  */
450
473
  async discoverTools(serverName, client) {
451
- const result = await client.listTools();
452
- for (const tool of result.tools) {
453
- const registered = buildRegisteredTool(serverName, tool);
454
- // Register with an executor that calls the MCP server
455
- this.toolRegistry.registerTool(registered, async (args) => {
456
- const callResult = await client.callTool({
457
- name: tool.name,
458
- arguments: stripInternalToolArgs(args),
459
- });
460
- // Extract text + image content from the result. Image blobs
461
- // are spilled to ~/.code-shell/mcp_images/ so they don't bloat
462
- // the LLM message tree same pattern as the GenerateImage
463
- // tool and the model sees a textual reference it can Read
464
- // on a later turn if it needs the pixels. This sidesteps the
465
- // "MCP returned a 5MB screenshot token budget exploded"
466
- // failure mode that bit Codex (issue #11845); we never put
467
- // raw base64 image data in the result text. See
468
- // TODO-week.md #9c + docs/research-cc-vs-codex-image-handling.md §B.
469
- const parts = [];
470
- if (Array.isArray(callResult.content)) {
471
- for (const item of callResult.content) {
472
- if (typeof item === "string") {
473
- parts.push(item);
474
- continue;
475
- }
476
- if (typeof item !== "object" || item === null)
477
- continue;
478
- if ("text" in item) {
479
- parts.push(String(item.text));
480
- continue;
481
- }
482
- if (item.type === "image") {
483
- const block = item;
484
- if (typeof block.data === "string" && block.data.length > 0) {
485
- const note = await spillMcpImage(serverName, tool.name, block.data, block.mimeType ?? "image/png");
486
- parts.push(note);
474
+ try {
475
+ const result = await client.listTools();
476
+ for (const tool of result.tools) {
477
+ const registered = buildRegisteredTool(serverName, tool);
478
+ // Register with an executor that calls the MCP server
479
+ this.toolRegistry.registerTool(registered, async (args, ctx) => {
480
+ const callResult = await client.callTool({
481
+ name: tool.name,
482
+ arguments: stripInternalToolArgs(args),
483
+ }, undefined, ctx?.signal ? { signal: ctx.signal } : undefined);
484
+ // Extract text + image content from the result. Image blobs
485
+ // are spilled to ~/.code-shell/mcp_images/ so they don't bloat
486
+ // the LLM message tree same pattern as the GenerateImage
487
+ // tool and the model sees a textual reference it can Read
488
+ // on a later turn if it needs the pixels. This sidesteps the
489
+ // "MCP returned a 5MB screenshot token budget exploded"
490
+ // failure mode that bit Codex (issue #11845); we never put
491
+ // raw base64 image data in the result text. See
492
+ // TODO-week.md #9c + docs/research-cc-vs-codex-image-handling.md §B.
493
+ const parts = [];
494
+ if (Array.isArray(callResult.content)) {
495
+ for (const item of callResult.content) {
496
+ if (typeof item === "string") {
497
+ parts.push(item);
498
+ continue;
499
+ }
500
+ if (typeof item !== "object" || item === null)
501
+ continue;
502
+ if ("text" in item) {
503
+ parts.push(String(item.text));
504
+ continue;
505
+ }
506
+ if (item.type === "image") {
507
+ const block = item;
508
+ if (typeof block.data === "string" && block.data.length > 0) {
509
+ const note = await spillMcpImage(serverName, tool.name, block.data, block.mimeType ?? "image/png");
510
+ parts.push(note);
511
+ }
487
512
  }
488
513
  }
489
514
  }
490
- }
491
- const body = parts.join("\n") || "(no output)";
492
- // Trust boundary: MCP output is external content. Wrap it so the
493
- // model sees an explicit reminder that the body comes from an
494
- // untrusted server and any instructions inside are content, not
495
- // commands. The marker is intentionally short so it doesn't bloat
496
- // every tool result, but distinct enough that prompt-injected
497
- // strings can't fake their way out.
498
- return wrapMcpOutput(serverName, tool.name, body);
499
- });
500
- const set = this.registeredToolsByServer.get(serverName) ?? new Set();
501
- set.add(registered.name);
502
- this.registeredToolsByServer.set(serverName, set);
503
- logger.info("mcp.tool_registered", { server: serverName, tool: registered.name });
515
+ const body = parts.join("\n") || "(no output)";
516
+ // Trust boundary: MCP output is external content. Wrap it so the
517
+ // model sees an explicit reminder that the body comes from an
518
+ // untrusted server and any instructions inside are content, not
519
+ // commands. The marker is intentionally short so it doesn't bloat
520
+ // every tool result, but distinct enough that prompt-injected
521
+ // strings can't fake their way out.
522
+ return wrapMcpOutput(serverName, tool.name, body);
523
+ });
524
+ const set = this.registeredToolsByServer.get(serverName) ?? new Set();
525
+ set.add(registered.name);
526
+ this.registeredToolsByServer.set(serverName, set);
527
+ logger.info("mcp.tool_registered", { server: serverName, tool: registered.name });
528
+ }
529
+ }
530
+ catch (err) {
531
+ await this.disconnect(serverName);
532
+ throw err;
504
533
  }
505
534
  }
506
535
  /**
@@ -583,6 +612,7 @@ export class MCPManager {
583
612
  uri: r.uri,
584
613
  name: r.name ?? r.uri,
585
614
  description: r.description,
615
+ serverName: name,
586
616
  });
587
617
  }
588
618
  }
@@ -52,6 +52,8 @@ export interface ClassifyOptions {
52
52
  export type PathApprovalScope = "once" | "session" | "project";
53
53
  /** Test seam: clear the in-memory session grants. */
54
54
  export declare function _resetSessionPathGrants(): void;
55
+ export declare function openSessionPathApprovals(sessionId: string): void;
56
+ export declare function clearSessionPathApprovals(sessionId: string): void;
55
57
  /**
56
58
  * Classify a file path against the workspace + sensitive-path policy.
57
59
  *
@@ -105,6 +105,10 @@ function expandTilde(p) {
105
105
  }
106
106
  /** sessionId → set of approved directory grants (prefix + operation). */
107
107
  const sessionPathGrants = new Map();
108
+ /** Session ids explicitly closed by ChatSessionManager; late approvals must not recreate grants. */
109
+ const closedPathApprovalSessions = new Set();
110
+ /** Per-session prompt chains for enforcePathPolicyWithApproval (see comment there). */
111
+ const askChains = new Map();
108
112
  /** True if a grant for `grantOp` authorizes an operation of `wantOp`. */
109
113
  function grantCoversOp(grantOp, wantOp) {
110
114
  // write ⊇ {read, write}; read ⊇ {read}.
@@ -198,6 +202,8 @@ function storedOp(g) {
198
202
  function recordPathApproval(scope, resolved, operation, cwd, sessionId) {
199
203
  if (scope === "once")
200
204
  return;
205
+ if (sessionId && closedPathApprovalSessions.has(sessionId))
206
+ return;
201
207
  const prefix = dirPrefix(dirname(resolved));
202
208
  if (scope === "session") {
203
209
  if (!sessionId)
@@ -271,6 +277,16 @@ function recordPathApproval(scope, resolved, operation, cwd, sessionId) {
271
277
  /** Test seam: clear the in-memory session grants. */
272
278
  export function _resetSessionPathGrants() {
273
279
  sessionPathGrants.clear();
280
+ closedPathApprovalSessions.clear();
281
+ askChains.clear();
282
+ }
283
+ export function openSessionPathApprovals(sessionId) {
284
+ closedPathApprovalSessions.delete(sessionId);
285
+ }
286
+ export function clearSessionPathApprovals(sessionId) {
287
+ sessionPathGrants.delete(sessionId);
288
+ closedPathApprovalSessions.add(sessionId);
289
+ askChains.delete(sessionId);
274
290
  }
275
291
  /**
276
292
  * Best-effort resolution. realpath fails when the path doesn't exist yet —
@@ -461,8 +477,8 @@ export function enforcePathPolicy(filePath, operation, workspaceRoot) {
461
477
  }
462
478
  // ask — MVP refuses with explanatory message until askUser plumbing
463
479
  // lands. The conservative bias matches the plan's leaning answer for Q1.
464
- return `Error: path requires approval — ${c.reason}. Path: ${c.resolvedPath}. ` +
465
- `Set CODESHELL_PATH_POLICY=off to disable enforcement during a rollback.`;
480
+ return (`Error: path requires approval — ${c.reason}. Path: ${c.resolvedPath}. ` +
481
+ `Set CODESHELL_PATH_POLICY=off to disable enforcement during a rollback.`);
466
482
  }
467
483
  export async function enforcePathPolicyWithApproval(filePath, operation, ctx) {
468
484
  if (ctx?.cwd === undefined)
@@ -481,8 +497,8 @@ export async function enforcePathPolicyWithApproval(filePath, operation, ctx) {
481
497
  return `Error: blocked by path policy — ${c.reason}. Path: ${c.resolvedPath}`;
482
498
  }
483
499
  if (operation === "write" && ctx.planMode) {
484
- return `Error: blocked by path policy — ${c.reason}. Path: ${c.resolvedPath}. ` +
485
- `Plan mode does not allow file writes.`;
500
+ return (`Error: blocked by path policy — ${c.reason}. Path: ${c.resolvedPath}. ` +
501
+ `Plan mode does not allow file writes.`);
486
502
  }
487
503
  // Already approved this directory for this OPERATION (this session or
488
504
  // persisted for the project)? Proceed without re-prompting — this is the fix
@@ -491,8 +507,8 @@ export async function enforcePathPolicyWithApproval(filePath, operation, ctx) {
491
507
  if (isPathPreApproved(c.resolvedPath, operation, ctx.cwd, ctx.sessionId))
492
508
  return null;
493
509
  if (!ctx.askUser) {
494
- return `Error: path requires approval — ${c.reason}. Path: ${c.resolvedPath}. ` +
495
- `No interactive approval UI is available in this run.`;
510
+ return (`Error: path requires approval — ${c.reason}. Path: ${c.resolvedPath}. ` +
511
+ `No interactive approval UI is available in this run.`);
496
512
  }
497
513
  // Serialize concurrent asks (per session) and RE-CHECK grants when our turn
498
514
  // comes. Parallel tools hitting the same not-yet-approved directory all pass
@@ -502,7 +518,8 @@ export async function enforcePathPolicyWithApproval(filePath, operation, ctx) {
502
518
  const chainKey = ctx.sessionId ?? "__global__";
503
519
  const prevTurn = askChains.get(chainKey) ?? Promise.resolve();
504
520
  let release;
505
- askChains.set(chainKey, new Promise((r) => (release = r)));
521
+ const currentTurn = new Promise((r) => (release = r));
522
+ askChains.set(chainKey, currentTurn);
506
523
  try {
507
524
  await prevTurn;
508
525
  if (isPathPreApproved(c.resolvedPath, operation, ctx.cwd, ctx.sessionId))
@@ -511,6 +528,9 @@ export async function enforcePathPolicyWithApproval(filePath, operation, ctx) {
511
528
  }
512
529
  finally {
513
530
  release();
531
+ if (askChains.get(chainKey) === currentTurn) {
532
+ askChains.delete(chainKey);
533
+ }
514
534
  }
515
535
  }
516
536
  /** The actual interactive ask — split out so the serialized section reads flat.
@@ -521,9 +541,7 @@ async function promptForPathApproval(c, operation, ctx) {
521
541
  // "工作区外路径" header was misleading for sensitive-path asks.
522
542
  const isSensitive = c.reason.startsWith("sensitive");
523
543
  const what = operation === "read" ? "读取" : "写入";
524
- const title = isSensitive
525
- ? `工具想${what}敏感文件`
526
- : `工具想${what}工作区外路径`;
544
+ const title = isSensitive ? `工具想${what}敏感文件` : `工具想${what}工作区外路径`;
527
545
  const header = isSensitive ? "敏感文件权限" : "路径权限";
528
546
  // Scope options carry remembered grants for the directory of this path, so
529
547
  // the same folder isn't re-prompted. Labels are matched by exact string
@@ -566,8 +584,6 @@ async function promptForPathApproval(c, operation, ctx) {
566
584
  }
567
585
  return `Error: path approval denied by user — ${c.reason}. Path: ${c.resolvedPath}`;
568
586
  }
569
- /** Per-session prompt chains for enforcePathPolicyWithApproval (see comment there). */
570
- const askChains = new Map();
571
587
  /**
572
588
  * Internal: reset the "disabled warning" latch. Tests flip the env var
573
589
  * between cases and need each one to be able to re-trigger the warning.
@@ -0,0 +1,11 @@
1
+ import type { SessionWorkspace } from "../types.js";
2
+ /**
3
+ * WorkspaceBridge — host-backed session workspace switching.
4
+ *
5
+ * Desktop implements this through Electron main so model-initiated switches use
6
+ * the same service path as the UI workspace switcher. Undefined in headless or
7
+ * non-desktop contexts.
8
+ */
9
+ export interface WorkspaceBridge {
10
+ switch(target: string): Promise<SessionWorkspace>;
11
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/types.d.ts CHANGED
@@ -149,6 +149,14 @@ export interface SessionWorkspace {
149
149
  createdBy: "codeshell";
150
150
  };
151
151
  }
152
+ export interface ContextUsageAnchor {
153
+ promptTokens: number;
154
+ messageCount: number;
155
+ estimateAtAnchor?: number;
156
+ recordedAt: number;
157
+ provider?: string;
158
+ model?: string;
159
+ }
152
160
  export interface SessionState {
153
161
  sessionId: string;
154
162
  cwd: string;
@@ -162,6 +170,12 @@ export interface SessionState {
162
170
  * (for example model switches), so it must not drive whole-session metrics.
163
171
  */
164
172
  tokenUsage: TokenUsage;
173
+ /**
174
+ * Last provider-reported prompt-token count for the current context window.
175
+ * Separate from cumulative tokenUsage; used only to seed context-size estimates
176
+ * before the next provider response.
177
+ */
178
+ contextUsageAnchor?: ContextUsageAnchor;
165
179
  /**
166
180
  * Monotonic prompt-cache counters for the whole session. These only increase
167
181
  * from session start and are separate from the resettable tokenUsage window.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-core",
3
- "version": "0.6.0-rc.13",
3
+ "version": "0.6.0-rc.15",
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",