@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.
- package/dist/capability-control/service.d.ts +2 -0
- package/dist/capability-control/service.js +4 -2
- package/dist/cc-orchestrator/codex-session-history.js +1 -1
- package/dist/cc-orchestrator/external-agent-bindings.js +1 -1
- package/dist/cc-orchestrator/external-agent-session-store.js +3 -1
- package/dist/cc-orchestrator/session-history.js +2 -2
- package/dist/cli/agent-server-stdio.js +7 -2
- package/dist/context/compaction.d.ts +8 -1
- package/dist/context/compaction.js +49 -4
- package/dist/context/manager.d.ts +11 -2
- package/dist/context/manager.js +64 -3
- package/dist/credentials/inject-credential-tool.d.ts +3 -1
- package/dist/credentials/inject-credential-tool.js +29 -10
- package/dist/credentials/use-credential-tool.d.ts +1 -0
- package/dist/credentials/use-credential-tool.js +3 -0
- package/dist/engine/engine.d.ts +9 -0
- package/dist/engine/engine.js +96 -17
- package/dist/engine/turn-loop.d.ts +3 -1
- package/dist/engine/turn-loop.js +4 -2
- package/dist/engine/types.d.ts +8 -0
- package/dist/git/worktree/crud.d.ts +69 -0
- package/dist/git/worktree/crud.js +206 -0
- package/dist/git/worktree/diff.d.ts +14 -0
- package/dist/git/worktree/diff.js +82 -0
- package/dist/git/worktree/git-exec.d.ts +7 -0
- package/dist/git/worktree/git-exec.js +51 -0
- package/dist/git/worktree/index.d.ts +5 -0
- package/dist/git/worktree/index.js +5 -0
- package/dist/git/worktree/query.d.ts +42 -0
- package/dist/git/worktree/query.js +121 -0
- package/dist/git/worktree/slug.d.ts +11 -0
- package/dist/git/worktree/slug.js +58 -0
- package/dist/git/worktree.d.ts +1 -127
- package/dist/git/worktree.js +5 -464
- package/dist/index.d.ts +28 -27
- package/dist/index.js +24 -24
- package/dist/plugins/installer/checkUpdate.d.ts +4 -1
- package/dist/plugins/installer/checkUpdate.js +4 -2
- package/dist/plugins/installer/install.js +2 -0
- package/dist/plugins/installer/parseSource.d.ts +4 -1
- package/dist/plugins/installer/parseSource.js +28 -10
- package/dist/plugins/installer/update.d.ts +4 -1
- package/dist/plugins/installer/update.js +5 -3
- package/dist/plugins/parseMarketplaceInput.d.ts +4 -1
- package/dist/plugins/parseMarketplaceInput.js +4 -3
- package/dist/preset/index.d.ts +1 -0
- package/dist/preset/index.js +6 -0
- package/dist/prompt/sections/base.md +1 -1
- package/dist/protocol/chat-session-manager.js +7 -0
- package/dist/protocol/server.d.ts +10 -0
- package/dist/protocol/server.js +88 -4
- package/dist/protocol/types.d.ts +6 -0
- package/dist/protocol/types.js +2 -0
- package/dist/runtime/background-shell.js +2 -3
- package/dist/services/auto-dream.d.ts +15 -4
- package/dist/services/auto-dream.js +20 -20
- package/dist/services/dream-consolidation.d.ts +2 -3
- package/dist/services/dream-consolidation.js +65 -15
- package/dist/services/extract-memories.d.ts +14 -5
- package/dist/services/extract-memories.js +20 -3
- package/dist/services/global-dream-promotion.d.ts +23 -0
- package/dist/services/global-dream-promotion.js +112 -0
- package/dist/services/memory-orchestrator.js +347 -34
- package/dist/session/memory.d.ts +56 -17
- package/dist/session/memory.js +337 -78
- package/dist/session/session-manager.d.ts +1 -1
- package/dist/session/session-manager.js +6 -6
- package/dist/session/transcript.d.ts +1 -1
- package/dist/session/transcript.js +17 -5
- package/dist/settings/manager.js +43 -12
- package/dist/settings/schema.d.ts +21 -0
- package/dist/settings/schema.js +12 -0
- package/dist/tool-system/builtin/index.js +18 -8
- package/dist/tool-system/builtin/memory.js +40 -8
- package/dist/tool-system/builtin/view-image.d.ts +2 -2
- package/dist/tool-system/builtin/view-image.js +100 -19
- package/dist/tool-system/builtin/worktree.d.ts +2 -0
- package/dist/tool-system/builtin/worktree.js +59 -11
- package/dist/tool-system/context.d.ts +11 -1
- package/dist/tool-system/mcp-manager.d.ts +8 -5
- package/dist/tool-system/mcp-manager.js +85 -55
- package/dist/tool-system/path-policy.d.ts +2 -0
- package/dist/tool-system/path-policy.js +28 -12
- package/dist/tool-system/workspace-bridge.d.ts +11 -0
- package/dist/tool-system/workspace-bridge.js +1 -0
- package/dist/types.d.ts +14 -0
- 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<
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
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