@cjhyy/code-shell-capability-coding 0.9.44 → 0.9.46

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 (49) hide show
  1. package/dist/capability-runtime.js +4 -1
  2. package/dist/cc-orchestrator/codex-session-history.d.ts +3 -1
  3. package/dist/cc-orchestrator/codex-session-history.js +11 -4
  4. package/dist/cc-orchestrator/external-agent-changes.js +7 -65
  5. package/dist/cc-orchestrator/external-agent-driver.js +61 -3
  6. package/dist/cc-orchestrator/session-discovery.d.ts +4 -2
  7. package/dist/cc-orchestrator/session-discovery.js +63 -6
  8. package/dist/cc-orchestrator/session-history.js +5 -0
  9. package/dist/external-runtimes/claude-code/event-translator.d.ts +7 -0
  10. package/dist/external-runtimes/claude-code/event-translator.js +58 -9
  11. package/dist/external-runtimes/claude-code/mcp-config.d.ts +8 -0
  12. package/dist/external-runtimes/claude-code/mcp-config.js +16 -3
  13. package/dist/external-runtimes/claude-code/permissions.d.ts +69 -0
  14. package/dist/external-runtimes/claude-code/permissions.js +120 -0
  15. package/dist/external-runtimes/claude-code/runtime.d.ts +40 -4
  16. package/dist/external-runtimes/claude-code/runtime.js +130 -39
  17. package/dist/external-runtimes/codex/app-server-client.d.ts +12 -0
  18. package/dist/external-runtimes/codex/app-server-client.js +16 -0
  19. package/dist/external-runtimes/codex/runtime.d.ts +11 -0
  20. package/dist/external-runtimes/codex/runtime.js +143 -16
  21. package/dist/external-runtimes/index.d.ts +2 -0
  22. package/dist/external-runtimes/index.js +1 -0
  23. package/dist/external-runtimes/session-factory.d.ts +14 -0
  24. package/dist/external-runtimes/session-factory.js +40 -1
  25. package/dist/external-runtimes/shared/mcp-bridge.d.ts +36 -1
  26. package/dist/external-runtimes/shared/mcp-bridge.js +93 -13
  27. package/dist/git/parse-log.d.ts +6 -2
  28. package/dist/git/parse-log.js +7 -3
  29. package/dist/git/utils.d.ts +4 -0
  30. package/dist/git/utils.js +29 -10
  31. package/dist/git/worktree/crud.js +28 -0
  32. package/dist/git/worktree/diff.js +57 -29
  33. package/dist/index.capability.js +14 -1
  34. package/dist/index.git.d.ts +1 -0
  35. package/dist/index.git.js +1 -0
  36. package/dist/quota/index.d.ts +2 -1
  37. package/dist/quota/index.js +27 -7
  38. package/dist/quota/types.d.ts +5 -2
  39. package/dist/review/review-prompt.d.ts +2 -0
  40. package/dist/review/review-prompt.js +70 -29
  41. package/dist/review/review-target.d.ts +29 -0
  42. package/dist/review/review-target.js +353 -0
  43. package/dist/tools/apply-patch/applier.js +64 -39
  44. package/dist/tools/apply-patch/backup-targets.d.ts +8 -0
  45. package/dist/tools/apply-patch/backup-targets.js +24 -0
  46. package/dist/tools/apply-patch/parser.js +4 -2
  47. package/dist/tools/check-quota.js +3 -1
  48. package/dist/tools/lsp.js +36 -5
  49. package/package.json +2 -2
@@ -7,6 +7,16 @@ import { textWithAttachmentReferences } from "../turn-input.js";
7
7
  const CRITICAL_RPC_TIMEOUT_MS = 60_000;
8
8
  /** Interrupt is a fail-safe; an unbounded wait silently defeats it. */
9
9
  const INTERRUPT_TIMEOUT_MS = 10_000;
10
+ /** Read a turn id from a `turn/started` / `turn/completed` notification. */
11
+ function notificationTurnId(params) {
12
+ if (!params || typeof params !== "object")
13
+ return undefined;
14
+ const record = params;
15
+ const turn = record.turn && typeof record.turn === "object" ? record.turn : {};
16
+ if (typeof turn.id === "string" && turn.id)
17
+ return turn.id;
18
+ return typeof record.turnId === "string" && record.turnId ? record.turnId : undefined;
19
+ }
10
20
  export class CodexRuntime {
11
21
  options;
12
22
  hooks;
@@ -55,6 +65,7 @@ export class CodexRuntime {
55
65
  this.started = true;
56
66
  this.client.onNotification((method, params) => this.onNotification(method, params));
57
67
  this.client.onServerRequest((method, params) => this.onServerRequest(method, params));
68
+ this.client.onClose((reason) => this.onTransportClosed(reason));
58
69
  this.client.start();
59
70
  await this.client.request("initialize", {
60
71
  clientInfo: { name: "codeshell", title: "CodeShell", version: "1" },
@@ -125,9 +136,21 @@ export class CodexRuntime {
125
136
  async send(input, onEvent) {
126
137
  if (!this.threadId)
127
138
  throw new Error("CodexRuntime.send() before start()");
139
+ // A dead app-server can never run another turn. Fail now, before any turn
140
+ // state exists, so the host rebuilds the runtime instead of waiting.
141
+ if (this.client.isClosed) {
142
+ throw new Error(`Codex app-server is not running (${this.client.closedReason ?? "closed"}); ` +
143
+ `the runtime must be restarted`);
144
+ }
128
145
  let resolveDone;
129
146
  const done = new Promise((resolve) => (resolveDone = resolve));
130
- this.activeTurn = { resolve: resolveDone, onEvent };
147
+ const turn = {
148
+ resolve: resolveDone,
149
+ onEvent,
150
+ starting: true,
151
+ interruptSent: false,
152
+ };
153
+ this.activeTurn = turn;
131
154
  let text = textWithAttachmentReferences(input);
132
155
  if (this.firstTurn && !this.resumed && this.options.initialContext) {
133
156
  text = `${this.options.initialContext}\n\n<current_user_request>\n${text}\n</current_user_request>`;
@@ -142,18 +165,36 @@ export class CodexRuntime {
142
165
  ? { detail: attachment.detail }
143
166
  : {}),
144
167
  }));
145
- const response = (await this.client.request("turn/start", {
146
- threadId: this.threadId,
147
- ...(input.clientMessageId ? { clientUserMessageId: input.clientMessageId } : {}),
148
- // `text_elements` is required by the protocol even when empty.
149
- input: [{ type: "text", text, text_elements: [] }, ...imageInputs],
150
- }, CRITICAL_RPC_TIMEOUT_MS));
168
+ let response;
169
+ try {
170
+ response = (await this.client.request("turn/start", {
171
+ threadId: this.threadId,
172
+ ...(input.clientMessageId ? { clientUserMessageId: input.clientMessageId } : {}),
173
+ // `text_elements` is required by the protocol even when empty.
174
+ input: [{ type: "text", text, text_elements: [] }, ...imageInputs],
175
+ }, CRITICAL_RPC_TIMEOUT_MS));
176
+ }
177
+ catch (error) {
178
+ // The rejection is the caller's terminal signal for this turn. Release the
179
+ // turn state so a Stop waiting on its id, or a later close, does not hang
180
+ // on a turn the caller never received.
181
+ turn.starting = false;
182
+ if (this.activeTurn === turn)
183
+ this.activeTurn = undefined;
184
+ turn.pendingInterrupt?.resolve();
185
+ turn.pendingInterrupt = undefined;
186
+ turn.resolve();
187
+ throw error;
188
+ }
189
+ turn.starting = false;
190
+ // The response is authoritative for the id, but `turn/started` may already
191
+ // have supplied it (and already delivered a deferred Stop).
151
192
  const turnId = response?.turn?.id;
152
- if (this.activeTurn)
153
- this.activeTurn.id = turnId;
193
+ if (turnId)
194
+ this.bindTurnId(turn, turnId);
154
195
  return {
155
196
  get turnId() {
156
- return turnId;
197
+ return turn.id;
157
198
  },
158
199
  done,
159
200
  };
@@ -167,8 +208,45 @@ export class CodexRuntime {
167
208
  * that no further step starts (§13.3).
168
209
  */
169
210
  async interrupt() {
170
- const turnId = this.activeTurn?.id;
171
- if (!this.threadId || !turnId)
211
+ const turn = this.activeTurn;
212
+ if (!this.threadId || !turn)
213
+ return;
214
+ if (turn.id)
215
+ return this.requestInterrupt(turn.id);
216
+ // Stop between sending turn/start and learning the turn id: Codex may
217
+ // already be running the turn, so returning here would silently drop the
218
+ // Stop. Remember it, and send turn/interrupt the moment the id is known.
219
+ if (!turn.starting)
220
+ return;
221
+ if (!turn.pendingInterrupt) {
222
+ let resolve;
223
+ let reject;
224
+ const promise = new Promise((res, rej) => {
225
+ resolve = res;
226
+ reject = rej;
227
+ });
228
+ // Every caller awaits through the bounded race below; this only keeps a
229
+ // rejection that arrives after that race from becoming unhandled.
230
+ promise.catch(() => { });
231
+ turn.pendingInterrupt = { promise, resolve, reject };
232
+ }
233
+ // Bounded like the RPC itself. A timeout leaves the Stop recorded: it is
234
+ // still delivered if the id arrives later, but the caller learns now that
235
+ // the stop has not landed.
236
+ let timer;
237
+ const timeout = new Promise((_, reject) => {
238
+ timer = setTimeout(() => reject(new Error(`Codex did not report the turn id within ${INTERRUPT_TIMEOUT_MS}ms; ` +
239
+ `the interrupt has not been delivered yet`)), INTERRUPT_TIMEOUT_MS);
240
+ });
241
+ try {
242
+ await Promise.race([turn.pendingInterrupt.promise, timeout]);
243
+ }
244
+ finally {
245
+ clearTimeout(timer);
246
+ }
247
+ }
248
+ async requestInterrupt(turnId) {
249
+ if (!this.threadId)
172
250
  return;
173
251
  try {
174
252
  await this.client.request("turn/interrupt", { threadId: this.threadId, turnId }, INTERRUPT_TIMEOUT_MS);
@@ -185,23 +263,72 @@ export class CodexRuntime {
185
263
  async close() {
186
264
  // Settle any waiter first, so a close during a live turn cannot leave the
187
265
  // caller awaiting forever (§13.4).
188
- this.activeTurn?.resolve();
266
+ const turn = this.activeTurn;
189
267
  this.activeTurn = undefined;
268
+ turn?.pendingInterrupt?.resolve();
269
+ turn?.resolve();
190
270
  await this.client.close();
191
271
  }
272
+ /** Learn the active turn's id; deliver a Stop that was waiting for it, once. */
273
+ bindTurnId(turn, turnId) {
274
+ turn.id = turnId;
275
+ const waiter = turn.pendingInterrupt;
276
+ if (!waiter || turn.interruptSent)
277
+ return;
278
+ turn.interruptSent = true;
279
+ turn.pendingInterrupt = undefined;
280
+ this.requestInterrupt(turnId).then(waiter.resolve, waiter.reject);
281
+ }
282
+ /**
283
+ * The app-server is gone (exited, failed, broken stream). A turn Codex already
284
+ * accepted would otherwise wait forever for a `turn/completed` that can never
285
+ * arrive, so report it terminal now as a model error. A turn whose
286
+ * `turn/start` is still unanswered is NOT reported here: that RPC rejects, and
287
+ * the rejected `send()` is its caller's terminal signal.
288
+ */
289
+ onTransportClosed(reason) {
290
+ const turn = this.activeTurn;
291
+ if (!turn)
292
+ return;
293
+ this.activeTurn = undefined;
294
+ turn.pendingInterrupt?.resolve();
295
+ turn.pendingInterrupt = undefined;
296
+ if (!turn.starting) {
297
+ this.log("runtime.turn_lost", {
298
+ businessSessionId: this.options.businessSessionId,
299
+ reason: reason.slice(0, 200),
300
+ });
301
+ const sink = turn.onEvent ?? this.hooks.onEvent;
302
+ this.emitEvent({ type: "error", error: `Codex app-server stopped before the turn finished: ${reason}` }, sink);
303
+ this.emitEvent({ type: "turn_complete", reason: "model_error" }, sink);
304
+ }
305
+ turn.resolve();
306
+ }
192
307
  onNotification(method, params) {
193
308
  const events = this.translator?.translate({ method, params }) ?? [];
194
309
  for (const event of events) {
310
+ // A fresh turn announced while ours has no id yet IS ours: the translator
311
+ // already discarded orphans of finished turns. This is the earliest point
312
+ // the id is known, and a Stop pressed before the RPC answers depends on it.
313
+ if (event.type === "stream_request_start" && method === "turn/started") {
314
+ const turn = this.activeTurn;
315
+ const turnId = notificationTurnId(params);
316
+ if (turn && !turn.id && turnId)
317
+ this.bindTurnId(turn, turnId);
318
+ }
195
319
  this.emitEvent(event);
196
320
  if (event.type === "turn_complete") {
197
- this.activeTurn?.resolve();
321
+ const turn = this.activeTurn;
198
322
  this.activeTurn = undefined;
323
+ turn?.pendingInterrupt?.resolve();
324
+ turn?.resolve();
199
325
  }
200
326
  }
201
327
  }
202
- emitEvent(event) {
328
+ emitEvent(event, sink = this.activeTurn?.onEvent ??
329
+ this.hooks.onEvent) {
203
330
  try {
204
- (this.activeTurn?.onEvent ?? this.hooks.onEvent)?.(event);
331
+ sink?.(event);
205
332
  }
206
333
  catch (error) {
207
334
  this.log("runtime.event_handler_failed", {
@@ -35,6 +35,8 @@ export { writeClaudeMcpConfigFile } from "./claude-code/mcp-config.js";
35
35
  export { ClaudeEventTranslator } from "./claude-code/event-translator.js";
36
36
  export type { ClaudeEventTranslatorOptions } from "./claude-code/event-translator.js";
37
37
  export { ClaudeCodeRuntime } from "./claude-code/runtime.js";
38
+ export { CLAUDE_PERMISSION_MODES, CLAUDE_PERMISSION_PROMPT_TOOL, claudeModeUsesPermissionPrompt, claudePermissionModeFor, withClaudePermissionPrompt, } from "./claude-code/permissions.js";
39
+ export type { ClaudePermissionMode, ClaudePermissionPromptDecider, ClaudePermissionPromptRequest, } from "./claude-code/permissions.js";
38
40
  export type { ClaudeRuntimeOptions, ClaudeRuntimeHooks, ClaudeTurnHandle, } from "./claude-code/runtime.js";
39
41
  export type { CodexEventTranslatorOptions } from "./codex/event-translator.js";
40
42
  export { startExternalRuntimeSession } from "./session-factory.js";
@@ -26,4 +26,5 @@ export { buildClaudeMcpConfig, claudeAllowedToolNames, claudeBridgeArgs, CLAUDE_
26
26
  export { writeClaudeMcpConfigFile } from "./claude-code/mcp-config.js";
27
27
  export { ClaudeEventTranslator } from "./claude-code/event-translator.js";
28
28
  export { ClaudeCodeRuntime } from "./claude-code/runtime.js";
29
+ export { CLAUDE_PERMISSION_MODES, CLAUDE_PERMISSION_PROMPT_TOOL, claudeModeUsesPermissionPrompt, claudePermissionModeFor, withClaudePermissionPrompt, } from "./claude-code/permissions.js";
29
30
  export { startExternalRuntimeSession } from "./session-factory.js";
@@ -23,6 +23,7 @@ import type { PermissionRule, StreamEvent, ToolDefinition } from "@cjhyy/code-sh
23
23
  import { createSessionToolHost, type ExternalToolExposurePolicy, type ToolVisibilityInputs } from "@cjhyy/code-shell-core/extension";
24
24
  import { type CodexRuntimeHooks, type CodexRuntimeOptions } from "./codex/runtime.js";
25
25
  import { type ClaudeRuntimeHooks } from "./claude-code/runtime.js";
26
+ import { type ClaudePermissionMode } from "./claude-code/permissions.js";
26
27
  import type { ExternalRuntimeTurnInput } from "./turn-input.js";
27
28
  export type ExternalRuntimeKind = "codex" | "claude-code";
28
29
  /**
@@ -55,6 +56,11 @@ export interface ExternalRuntimeSessionOptions {
55
56
  contextOverrides?: Parameters<typeof createSessionToolHost>[0]["contextOverrides"];
56
57
  captureRunContext?: Parameters<typeof createSessionToolHost>[0]["captureRunContext"];
57
58
  settingsScope?: Parameters<typeof createSessionToolHost>[0]["settingsScope"];
59
+ /**
60
+ * Override the runtime's declared image capability (e.g. a host that knows
61
+ * the selected model is text-only). Defaults per runtime kind.
62
+ */
63
+ modelSupportsVision?: boolean;
58
64
  model?: string;
59
65
  /** Runtime thread/session id recovered from durable Desktop state. */
60
66
  resumeRuntimeSessionId?: string;
@@ -68,6 +74,14 @@ export interface ExternalRuntimeSessionOptions {
68
74
  approvalPolicy?: string;
69
75
  /** Claude Code only: extra CLI args. */
70
76
  claudeExtraArgs?: readonly string[];
77
+ /**
78
+ * Claude Code only: `--permission-mode` for its native tools. Defaults to
79
+ * `claudePermissionModeFor({ permissionMode, planMode })`. A host whose
80
+ * user-facing selection differs from the host-tool `permissionMode` it passes
81
+ * (Desktop maps full access to `acceptEdits` for host tools) passes it
82
+ * explicitly.
83
+ */
84
+ claudePermissionMode?: ClaudePermissionMode;
71
85
  /**
72
86
  * Override how the runtime process is launched (Codex: app-server client
73
87
  * options; Claude: the `claude` binary). Exists so the assembly can be tested
@@ -3,6 +3,20 @@ import { startLoopbackMcpBridge, } from "./shared/mcp-bridge.js";
3
3
  import { SessionContextStore } from "./shared/session-context-store.js";
4
4
  import { CodexRuntime } from "./codex/runtime.js";
5
5
  import { ClaudeCodeRuntime } from "./claude-code/runtime.js";
6
+ import { CLAUDE_MCP_SERVER_NAME } from "./claude-code/mcp-config.js";
7
+ import { CLAUDE_PERMISSION_PROMPT_TOOL, claudeModeUsesPermissionPrompt, claudePermissionModeFor, withClaudePermissionPrompt, } from "./claude-code/permissions.js";
8
+ /**
9
+ * Whether each runtime's model receives MCP image content from a tool result.
10
+ *
11
+ * Both runtimes drive vision-capable model families and forward MCP `image`
12
+ * content to the model. Without a declaration the host has no `llmConfig` to
13
+ * infer this from (the runtime picks and calls its own model), so image tools
14
+ * such as browser_observe(vision) failed closed and never captured pixels.
15
+ */
16
+ const RUNTIME_ACCEPTS_TOOL_IMAGES = Object.freeze({
17
+ codex: true,
18
+ "claude-code": true,
19
+ });
6
20
  /**
7
21
  * Assemble and start an external-runtime session.
8
22
  *
@@ -60,10 +74,30 @@ export async function startExternalRuntimeSession(options) {
60
74
  getStreamCallback: () => turnSink,
61
75
  ...(options.captureRunContext ? { captureRunContext: options.captureRunContext } : {}),
62
76
  ...(options.settingsScope ? { settingsScope: options.settingsScope } : {}),
77
+ modelSupportsVision: options.modelSupportsVision ?? RUNTIME_ACCEPTS_TOOL_IMAGES[options.kind],
78
+ // `hooks` is deliberately not passed: the host then loads the same
79
+ // settings/plugin tool hooks a Native Engine registers for this cwd,
80
+ // filtered by `projectTrusted` and `settingsScope`. The runtime's own
81
+ // native hooks (Codex/Claude config) are separate and unaffected.
63
82
  });
83
+ // Claude's NATIVE tools: an explicit mode always, and — only where the mode
84
+ // asks and the host can answer — a prompt tool on this session's bridge.
85
+ const claudePermissionMode = options.claudePermissionMode ??
86
+ claudePermissionModeFor({
87
+ permissionMode: options.permissionMode,
88
+ planMode: options.planMode,
89
+ });
90
+ const onClaudePermissionPrompt = options.hooks?.onClaudePermissionPrompt;
91
+ const claudePromptTool = options.kind === "claude-code" &&
92
+ onClaudePermissionPrompt &&
93
+ claudeModeUsesPermissionPrompt(claudePermissionMode)
94
+ ? CLAUDE_PERMISSION_PROMPT_TOOL
95
+ : undefined;
64
96
  // 3. Register before the runtime starts, so a tool call on the very first
65
97
  // turn is already routable (§13.1).
66
- store.register(pinnedThreadId, host);
98
+ store.register(pinnedThreadId, claudePromptTool && onClaudePermissionPrompt
99
+ ? withClaudePermissionPrompt(host, onClaudePermissionPrompt, CLAUDE_MCP_SERVER_NAME)
100
+ : host);
67
101
  // 4. Runtime.
68
102
  const exposedToolNames = host.listTools().map((definition) => definition.name);
69
103
  if (options.kind === "codex") {
@@ -100,6 +134,11 @@ export async function startExternalRuntimeSession(options) {
100
134
  ? { resumeRuntimeSessionId: options.resumeRuntimeSessionId }
101
135
  : {}),
102
136
  ...(options.initialContext ? { initialContext: options.initialContext } : {}),
137
+ ...(options.developerInstructions
138
+ ? { developerInstructions: options.developerInstructions }
139
+ : {}),
140
+ permissionMode: claudePermissionMode,
141
+ ...(claudePromptTool ? { permissionPromptTool: claudePromptTool } : {}),
103
142
  ...(options.claudeExtraArgs ? { extraArgs: options.claudeExtraArgs } : {}),
104
143
  ...(options.claudeCommand ? { command: options.claudeCommand } : {}),
105
144
  log,
@@ -12,12 +12,46 @@ export interface BridgeToolHost {
12
12
  id: string;
13
13
  name: string;
14
14
  input: unknown;
15
- }): Promise<{
15
+ }, signal?: AbortSignal): Promise<{
16
16
  result?: string;
17
17
  error?: string;
18
18
  isError?: boolean;
19
+ /** Structured blocks (images) a tool returned alongside its text. */
20
+ contentBlocks?: readonly BridgeContentBlock[];
19
21
  }>;
20
22
  }
23
+ /** The subset of core's `ContentBlock` the bridge reads. Structural, like
24
+ * {@link BridgeToolHost}, so this module stays independent of core. */
25
+ export interface BridgeContentBlock {
26
+ type: string;
27
+ text?: string;
28
+ source?: {
29
+ type?: string;
30
+ media_type?: string;
31
+ data?: string;
32
+ };
33
+ }
34
+ type McpContent = {
35
+ type: "text";
36
+ text: string;
37
+ } | {
38
+ type: "image";
39
+ data: string;
40
+ mimeType: string;
41
+ };
42
+ /**
43
+ * MCP `tools/call` content for a host result.
44
+ *
45
+ * The text always comes first and is always present: it is the tool's own
46
+ * summary of what it loaded, and a client that drops images still gets it.
47
+ * Image blocks then follow as MCP image content. Without them a browser
48
+ * screenshot reached the external model only as "[screenshot loaded]".
49
+ *
50
+ * Only well-formed base64 `image/*` blocks are forwarded; anything else a tool
51
+ * puts into `contentBlocks` is dropped rather than passed through in a shape
52
+ * the client never agreed to. An error result stays text-only.
53
+ */
54
+ export declare function mcpContentForResult(outcome: Awaited<ReturnType<BridgeToolHost["execute"]>>): McpContent[];
21
55
  export interface McpBridgeOptions {
22
56
  store: SessionContextStore<BridgeToolHost>;
23
57
  /** Logical MCP server name Codex is configured with. */
@@ -64,3 +98,4 @@ export declare function startLoopbackMcpBridge(options: McpBridgeOptions): Promi
64
98
  * by environment variable, never in argv (§12.2).
65
99
  */
66
100
  export declare function codexBridgeConfigArgs(handle: McpBridgeHandle, serverName?: string): string[];
101
+ export {};
@@ -20,6 +20,41 @@
20
20
  */
21
21
  import { createServer } from "node:http";
22
22
  import { randomBytes } from "node:crypto";
23
+ /**
24
+ * MCP `tools/call` content for a host result.
25
+ *
26
+ * The text always comes first and is always present: it is the tool's own
27
+ * summary of what it loaded, and a client that drops images still gets it.
28
+ * Image blocks then follow as MCP image content. Without them a browser
29
+ * screenshot reached the external model only as "[screenshot loaded]".
30
+ *
31
+ * Only well-formed base64 `image/*` blocks are forwarded; anything else a tool
32
+ * puts into `contentBlocks` is dropped rather than passed through in a shape
33
+ * the client never agreed to. An error result stays text-only.
34
+ */
35
+ export function mcpContentForResult(outcome) {
36
+ const content = [
37
+ { type: "text", text: String(outcome.result ?? outcome.error ?? "") },
38
+ ];
39
+ if (outcome.isError || outcome.error !== undefined)
40
+ return content;
41
+ for (const block of outcome.contentBlocks ?? []) {
42
+ if (block.type === "text" && typeof block.text === "string") {
43
+ content.push({ type: "text", text: block.text });
44
+ continue;
45
+ }
46
+ const source = block.source;
47
+ if (block.type === "image" &&
48
+ source?.type === "base64" &&
49
+ typeof source.data === "string" &&
50
+ source.data.length > 0 &&
51
+ typeof source.media_type === "string" &&
52
+ source.media_type.startsWith("image/")) {
53
+ content.push({ type: "image", data: source.data, mimeType: source.media_type });
54
+ }
55
+ }
56
+ return content;
57
+ }
23
58
  const MCP_PATH = "/mcp";
24
59
  const DEFAULT_MAX_BODY = 1024 * 1024;
25
60
  export const CODEX_MCP_TOKEN_ENV_VAR = "CODESHELL_CODEX_MCP_TOKEN";
@@ -82,6 +117,30 @@ export async function startLoopbackMcpBridge(options) {
82
117
  * to steer routing by supplying one either.
83
118
  */
84
119
  const threadIdFor = (params) => pinnedThreadId ?? threadIdFromMeta(params);
120
+ /**
121
+ * In-flight `tools/call` requests, so a client-side cancellation reaches the
122
+ * tool. Without this a Codex tool timeout abandoned the call while a
123
+ * foreground DriveAgent kept its child CLI running as an orphan.
124
+ *
125
+ * JSON-RPC ids are only unique per client, and one bridge can serve several
126
+ * Codex threads, so entries are keyed by thread as well as request id.
127
+ */
128
+ const inFlight = new Set();
129
+ const cancelInFlight = (params) => {
130
+ const raw = params?.requestId;
131
+ if (raw === undefined || raw === null)
132
+ return;
133
+ const requestId = JSON.stringify(raw);
134
+ const threadId = threadIdFor(params);
135
+ // An unattributed cancellation may only act when exactly one thread has
136
+ // that request id in flight; guessing between two would cancel the wrong
137
+ // session's tool.
138
+ const matches = [...inFlight].filter((entry) => entry.requestId === requestId && (threadId === undefined || entry.threadId === threadId));
139
+ if (matches.length !== 1)
140
+ return;
141
+ const reason = params.reason;
142
+ matches[0].controller.abort(typeof reason === "string" && reason ? reason : "MCP request cancelled");
143
+ };
85
144
  const server = createServer((req, res) => {
86
145
  void handle(req, res).catch((error) => {
87
146
  log("bridge.handler_failed", { error: error instanceof Error ? error.name : "unknown" });
@@ -199,6 +258,8 @@ export async function startLoopbackMcpBridge(options) {
199
258
  return;
200
259
  }
201
260
  if (typeof message.method === "string" && message.method.startsWith("notifications/")) {
261
+ if (message.method === "notifications/cancelled")
262
+ cancelInFlight(message.params);
202
263
  res.writeHead(202).end();
203
264
  return;
204
265
  }
@@ -255,15 +316,37 @@ export async function startLoopbackMcpBridge(options) {
255
316
  }
256
317
  const name = typeof params?.name === "string" ? params.name : "";
257
318
  const started = Date.now();
258
- // The host is the authorization boundary; the bridge only routes.
259
- const outcome = await resolved.host.execute({
260
- // One turn can contain many calls, including parallel ones. A turn id
261
- // aliases their transcript/UI entries and overwrites navigate with a
262
- // later observe error. Mint a host identity for each actual invocation.
263
- id: `mcp-${randomBytes(12).toString("hex")}`,
264
- name,
265
- input: params?.arguments ?? {},
266
- });
319
+ const pending = {
320
+ threadId,
321
+ requestId: JSON.stringify(message.id ?? null),
322
+ controller: new AbortController(),
323
+ };
324
+ inFlight.add(pending);
325
+ // A client that gives up drops the connection; treat that as cancellation.
326
+ // Node emits this unfinished `close` on disconnect (Bun's node:http does
327
+ // not, so the explicit notification above is the tested path). `req`'s
328
+ // own `close` fires once the body is read and cannot be used here.
329
+ const onClose = () => {
330
+ if (!res.writableFinished)
331
+ pending.controller.abort("MCP client disconnected");
332
+ };
333
+ res.once("close", onClose);
334
+ let outcome;
335
+ try {
336
+ // The host is the authorization boundary; the bridge only routes.
337
+ outcome = await resolved.host.execute({
338
+ // One turn can contain many calls, including parallel ones. A turn id
339
+ // aliases their transcript/UI entries and overwrites navigate with a
340
+ // later observe error. Mint a host identity for each actual invocation.
341
+ id: `mcp-${randomBytes(12).toString("hex")}`,
342
+ name,
343
+ input: params?.arguments ?? {},
344
+ }, pending.controller.signal);
345
+ }
346
+ finally {
347
+ inFlight.delete(pending);
348
+ res.off("close", onClose);
349
+ }
267
350
  log("bridge.call", {
268
351
  businessSessionId: resolved.host.businessSessionId,
269
352
  threadIdPrefix: threadId?.slice(0, 8),
@@ -271,10 +354,7 @@ export async function startLoopbackMcpBridge(options) {
271
354
  resultStatus: outcome.isError ? "error" : "ok",
272
355
  durationMs: Date.now() - started,
273
356
  });
274
- reply({
275
- content: [{ type: "text", text: String(outcome.result ?? outcome.error ?? "") }],
276
- isError: Boolean(outcome.isError),
277
- });
357
+ reply({ content: mcpContentForResult(outcome), isError: Boolean(outcome.isError) });
278
358
  return;
279
359
  }
280
360
  reply({});
@@ -5,9 +5,13 @@ export interface GitLogEntry {
5
5
  author: string;
6
6
  date: string;
7
7
  }
8
+ /** ASCII unit separator: unlike `|`, it does not occur in real commit subjects or author names. */
9
+ export declare const GIT_LOG_FIELD_SEPARATOR = "\u001F";
10
+ /** `git log --format` producing the fields parsed by {@link parseGitLog}. */
11
+ export declare const GIT_LOG_FORMAT = "%H%x1f%s%x1f%an%x1f%ci";
8
12
  /**
9
- * Parse `git log --format=%H|%s|%an|%ci` output into entries. Tolerates
10
- * malformed/short lines (missing `|` separators) by defaulting absent fields
13
+ * Parse `git log --format=GIT_LOG_FORMAT` output into entries. Tolerates
14
+ * malformed/short lines (missing separators) by defaulting absent fields
11
15
  * to "" rather than destructuring `undefined` and crashing on `.slice`
12
16
  * (review-2026-05-30). Blank lines are skipped.
13
17
  */
@@ -1,6 +1,10 @@
1
+ /** ASCII unit separator: unlike `|`, it does not occur in real commit subjects or author names. */
2
+ export const GIT_LOG_FIELD_SEPARATOR = "\x1f";
3
+ /** `git log --format` producing the fields parsed by {@link parseGitLog}. */
4
+ export const GIT_LOG_FORMAT = "%H%x1f%s%x1f%an%x1f%ci";
1
5
  /**
2
- * Parse `git log --format=%H|%s|%an|%ci` output into entries. Tolerates
3
- * malformed/short lines (missing `|` separators) by defaulting absent fields
6
+ * Parse `git log --format=GIT_LOG_FORMAT` output into entries. Tolerates
7
+ * malformed/short lines (missing separators) by defaulting absent fields
4
8
  * to "" rather than destructuring `undefined` and crashing on `.slice`
5
9
  * (review-2026-05-30). Blank lines are skipped.
6
10
  */
@@ -9,7 +13,7 @@ export function parseGitLog(raw) {
9
13
  .split("\n")
10
14
  .filter((line) => line.trim() !== "")
11
15
  .map((line) => {
12
- const parts = line.split("|");
16
+ const parts = line.split(GIT_LOG_FIELD_SEPARATOR);
13
17
  const hash = parts[0] ?? "";
14
18
  return {
15
19
  hash: hash.slice(0, 8),
@@ -11,6 +11,8 @@ import { type GitLogEntry } from "./parse-log.js";
11
11
  export interface GitStatusEntry {
12
12
  status: string;
13
13
  path: string;
14
+ /** Source path of a rename/copy entry. */
15
+ origPath?: string;
14
16
  }
15
17
  export type { GitLogEntry };
16
18
  export declare function isGitRepo(cwd: string): boolean;
@@ -36,6 +38,8 @@ export declare function getGitDiffStat(cwd: string, opts?: {
36
38
  staged?: boolean;
37
39
  file?: string;
38
40
  }): string;
41
+ /** Untracked, non-ignored files under `cwd`, relative to `cwd` (what `git add .` would add). */
42
+ export declare function getUntrackedFiles(cwd: string): string[];
39
43
  export declare function getGitLog(cwd: string, n?: number): GitLogEntry[];
40
44
  export declare function getRemoteUrl(cwd: string): string | undefined;
41
45
  export declare function gitAdd(cwd: string, files?: string[]): void;
package/dist/git/utils.js CHANGED
@@ -9,14 +9,18 @@
9
9
  */
10
10
  import { execFileSync } from "node:child_process";
11
11
  import { realpathSync } from "node:fs";
12
- import { parseGitLog } from "./parse-log.js";
12
+ import { GIT_LOG_FORMAT, parseGitLog } from "./parse-log.js";
13
13
  import { resolveExecutable, resolveGit } from "@cjhyy/code-shell-core/extension";
14
14
  // Resolve git/gh through PATH×PATHEXT on Windows so a .cmd/.exe shim is found
15
15
  // (bare execFile doesn't walk PATHEXT). No-op on POSIX. See utils/exec.ts.
16
16
  const GH_BIN = resolveExecutable("gh");
17
+ /** Run git with an argv array and return its untrimmed stdout. */
18
+ function gitRaw(cwd, args, timeoutMs = 10000) {
19
+ return execFileSync(resolveGit(), args, { cwd, encoding: "utf-8", timeout: timeoutMs });
20
+ }
17
21
  /** Run git with an argv array and return its trimmed stdout. */
18
22
  function git(cwd, args, timeoutMs = 10000) {
19
- return execFileSync(resolveGit(), args, { cwd, encoding: "utf-8", timeout: timeoutMs }).trim();
23
+ return gitRaw(cwd, args, timeoutMs).trim();
20
24
  }
21
25
  /** Run gh with an argv array and return its trimmed stdout. */
22
26
  function gh(cwd, args, timeoutMs = 10000) {
@@ -62,13 +66,22 @@ export function getCurrentBranch(cwd) {
62
66
  return git(cwd, ["branch", "--show-current"], 5000);
63
67
  }
64
68
  export function getGitStatus(cwd) {
65
- const raw = git(cwd, ["status", "--porcelain"], 10000);
66
- if (!raw)
67
- return [];
68
- return raw.split("\n").map((line) => ({
69
- status: line.slice(0, 2).trim(),
70
- path: line.slice(3),
71
- }));
69
+ // `-z` output is NUL-terminated and unquoted. It must not be trimmed: the
70
+ // fixed-width `XY ` prefix of an unstaged entry starts with a space.
71
+ const records = gitRaw(cwd, ["status", "--porcelain", "-z"], 10000).split("\0");
72
+ const entries = [];
73
+ for (let i = 0; i < records.length; i++) {
74
+ const record = records[i];
75
+ if (record.length < 4)
76
+ continue;
77
+ const xy = record.slice(0, 2);
78
+ const entry = { status: xy.trim(), path: record.slice(3) };
79
+ // Rename/copy entries are followed by a separate source-path record.
80
+ if (xy.includes("R") || xy.includes("C"))
81
+ entry.origPath = records[++i] ?? "";
82
+ entries.push(entry);
83
+ }
84
+ return entries;
72
85
  }
73
86
  export function getGitDiff(cwd, opts) {
74
87
  const args = ["diff", "--no-color"];
@@ -88,11 +101,17 @@ export function getGitDiffStat(cwd, opts) {
88
101
  args.push("--", opts.file);
89
102
  return git(cwd, args, 10000);
90
103
  }
104
+ /** Untracked, non-ignored files under `cwd`, relative to `cwd` (what `git add .` would add). */
105
+ export function getUntrackedFiles(cwd) {
106
+ return gitRaw(cwd, ["ls-files", "--others", "--exclude-standard", "-z"], 10000)
107
+ .split("\0")
108
+ .filter(Boolean);
109
+ }
91
110
  export function getGitLog(cwd, n = 10) {
92
111
  // `n` is numeric — coerce/validate to keep the argv clean even if a caller
93
112
  // hands us a string.
94
113
  const count = Number.isFinite(n) && n > 0 ? Math.floor(n) : 10;
95
- const raw = git(cwd, ["log", "--oneline", "--format=%H|%s|%an|%ci", `-${count}`], 10000);
114
+ const raw = git(cwd, ["log", `--format=${GIT_LOG_FORMAT}`, `-${count}`], 10000);
96
115
  if (!raw)
97
116
  return [];
98
117
  return parseGitLog(raw);