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

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 (58) 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 +43 -4
  16. package/dist/external-runtimes/claude-code/runtime.js +155 -39
  17. package/dist/external-runtimes/claude-code/text-inference.d.ts +22 -0
  18. package/dist/external-runtimes/claude-code/text-inference.js +240 -0
  19. package/dist/external-runtimes/codex/app-server-client.d.ts +15 -0
  20. package/dist/external-runtimes/codex/app-server-client.js +69 -6
  21. package/dist/external-runtimes/codex/event-translator.js +16 -2
  22. package/dist/external-runtimes/codex/runtime.d.ts +14 -0
  23. package/dist/external-runtimes/codex/runtime.js +152 -16
  24. package/dist/external-runtimes/index.d.ts +4 -0
  25. package/dist/external-runtimes/index.js +2 -0
  26. package/dist/external-runtimes/session-factory.d.ts +16 -0
  27. package/dist/external-runtimes/session-factory.js +43 -1
  28. package/dist/external-runtimes/shared/mcp-bridge.d.ts +36 -1
  29. package/dist/external-runtimes/shared/mcp-bridge.js +93 -13
  30. package/dist/git/parse-log.d.ts +6 -2
  31. package/dist/git/parse-log.js +7 -3
  32. package/dist/git/utils.d.ts +4 -0
  33. package/dist/git/utils.js +29 -10
  34. package/dist/git/worktree/crud.d.ts +2 -0
  35. package/dist/git/worktree/crud.js +29 -1
  36. package/dist/git/worktree/diff.js +57 -29
  37. package/dist/index.capability.js +14 -1
  38. package/dist/index.git.d.ts +1 -0
  39. package/dist/index.git.js +1 -0
  40. package/dist/lsp/client.d.ts +3 -0
  41. package/dist/lsp/client.js +96 -25
  42. package/dist/lsp/manager.d.ts +2 -0
  43. package/dist/lsp/manager.js +56 -15
  44. package/dist/prompt/coding.md +1 -0
  45. package/dist/quota/index.d.ts +2 -1
  46. package/dist/quota/index.js +27 -7
  47. package/dist/quota/types.d.ts +5 -2
  48. package/dist/review/review-prompt.d.ts +2 -0
  49. package/dist/review/review-prompt.js +70 -29
  50. package/dist/review/review-target.d.ts +29 -0
  51. package/dist/review/review-target.js +353 -0
  52. package/dist/tools/apply-patch/applier.js +64 -39
  53. package/dist/tools/apply-patch/backup-targets.d.ts +8 -0
  54. package/dist/tools/apply-patch/backup-targets.js +24 -0
  55. package/dist/tools/apply-patch/parser.js +4 -2
  56. package/dist/tools/check-quota.js +3 -1
  57. package/dist/tools/lsp.js +36 -5
  58. 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;
@@ -15,6 +25,7 @@ export class CodexRuntime {
15
25
  log;
16
26
  translator;
17
27
  threadId;
28
+ reportedModel;
18
29
  started = false;
19
30
  resumed = false;
20
31
  firstTurn = true;
@@ -42,6 +53,10 @@ export class CodexRuntime {
42
53
  get runtimeSessionId() {
43
54
  return this.threadId;
44
55
  }
56
+ /** CLI-reported model, falling back to the requested model until reported. */
57
+ get resolvedModel() {
58
+ return this.reportedModel ?? this.options.model;
59
+ }
45
60
  /**
46
61
  * Spawn the app-server, handshake, and open a thread.
47
62
  *
@@ -55,6 +70,7 @@ export class CodexRuntime {
55
70
  this.started = true;
56
71
  this.client.onNotification((method, params) => this.onNotification(method, params));
57
72
  this.client.onServerRequest((method, params) => this.onServerRequest(method, params));
73
+ this.client.onClose((reason) => this.onTransportClosed(reason));
58
74
  this.client.start();
59
75
  await this.client.request("initialize", {
60
76
  clientInfo: { name: "codeshell", title: "CodeShell", version: "1" },
@@ -96,6 +112,10 @@ export class CodexRuntime {
96
112
  throw new Error(`${this.resumed ? "thread/resume" : "thread/start"} returned no thread id`);
97
113
  }
98
114
  this.threadId = threadId;
115
+ const reportedModel = thread?.model;
116
+ if (typeof reportedModel === "string" && reportedModel.trim()) {
117
+ this.reportedModel = reportedModel.trim();
118
+ }
99
119
  this.translator = new CodexEventTranslator({
100
120
  threadId,
101
121
  sessionId: this.options.businessSessionId,
@@ -125,9 +145,21 @@ export class CodexRuntime {
125
145
  async send(input, onEvent) {
126
146
  if (!this.threadId)
127
147
  throw new Error("CodexRuntime.send() before start()");
148
+ // A dead app-server can never run another turn. Fail now, before any turn
149
+ // state exists, so the host rebuilds the runtime instead of waiting.
150
+ if (this.client.isClosed) {
151
+ throw new Error(`Codex app-server is not running (${this.client.closedReason ?? "closed"}); ` +
152
+ `the runtime must be restarted`);
153
+ }
128
154
  let resolveDone;
129
155
  const done = new Promise((resolve) => (resolveDone = resolve));
130
- this.activeTurn = { resolve: resolveDone, onEvent };
156
+ const turn = {
157
+ resolve: resolveDone,
158
+ onEvent,
159
+ starting: true,
160
+ interruptSent: false,
161
+ };
162
+ this.activeTurn = turn;
131
163
  let text = textWithAttachmentReferences(input);
132
164
  if (this.firstTurn && !this.resumed && this.options.initialContext) {
133
165
  text = `${this.options.initialContext}\n\n<current_user_request>\n${text}\n</current_user_request>`;
@@ -142,18 +174,36 @@ export class CodexRuntime {
142
174
  ? { detail: attachment.detail }
143
175
  : {}),
144
176
  }));
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));
177
+ let response;
178
+ try {
179
+ response = (await this.client.request("turn/start", {
180
+ threadId: this.threadId,
181
+ ...(input.clientMessageId ? { clientUserMessageId: input.clientMessageId } : {}),
182
+ // `text_elements` is required by the protocol even when empty.
183
+ input: [{ type: "text", text, text_elements: [] }, ...imageInputs],
184
+ }, CRITICAL_RPC_TIMEOUT_MS));
185
+ }
186
+ catch (error) {
187
+ // The rejection is the caller's terminal signal for this turn. Release the
188
+ // turn state so a Stop waiting on its id, or a later close, does not hang
189
+ // on a turn the caller never received.
190
+ turn.starting = false;
191
+ if (this.activeTurn === turn)
192
+ this.activeTurn = undefined;
193
+ turn.pendingInterrupt?.resolve();
194
+ turn.pendingInterrupt = undefined;
195
+ turn.resolve();
196
+ throw error;
197
+ }
198
+ turn.starting = false;
199
+ // The response is authoritative for the id, but `turn/started` may already
200
+ // have supplied it (and already delivered a deferred Stop).
151
201
  const turnId = response?.turn?.id;
152
- if (this.activeTurn)
153
- this.activeTurn.id = turnId;
202
+ if (turnId)
203
+ this.bindTurnId(turn, turnId);
154
204
  return {
155
205
  get turnId() {
156
- return turnId;
206
+ return turn.id;
157
207
  },
158
208
  done,
159
209
  };
@@ -167,8 +217,45 @@ export class CodexRuntime {
167
217
  * that no further step starts (§13.3).
168
218
  */
169
219
  async interrupt() {
170
- const turnId = this.activeTurn?.id;
171
- if (!this.threadId || !turnId)
220
+ const turn = this.activeTurn;
221
+ if (!this.threadId || !turn)
222
+ return;
223
+ if (turn.id)
224
+ return this.requestInterrupt(turn.id);
225
+ // Stop between sending turn/start and learning the turn id: Codex may
226
+ // already be running the turn, so returning here would silently drop the
227
+ // Stop. Remember it, and send turn/interrupt the moment the id is known.
228
+ if (!turn.starting)
229
+ return;
230
+ if (!turn.pendingInterrupt) {
231
+ let resolve;
232
+ let reject;
233
+ const promise = new Promise((res, rej) => {
234
+ resolve = res;
235
+ reject = rej;
236
+ });
237
+ // Every caller awaits through the bounded race below; this only keeps a
238
+ // rejection that arrives after that race from becoming unhandled.
239
+ promise.catch(() => { });
240
+ turn.pendingInterrupt = { promise, resolve, reject };
241
+ }
242
+ // Bounded like the RPC itself. A timeout leaves the Stop recorded: it is
243
+ // still delivered if the id arrives later, but the caller learns now that
244
+ // the stop has not landed.
245
+ let timer;
246
+ const timeout = new Promise((_, reject) => {
247
+ timer = setTimeout(() => reject(new Error(`Codex did not report the turn id within ${INTERRUPT_TIMEOUT_MS}ms; ` +
248
+ `the interrupt has not been delivered yet`)), INTERRUPT_TIMEOUT_MS);
249
+ });
250
+ try {
251
+ await Promise.race([turn.pendingInterrupt.promise, timeout]);
252
+ }
253
+ finally {
254
+ clearTimeout(timer);
255
+ }
256
+ }
257
+ async requestInterrupt(turnId) {
258
+ if (!this.threadId)
172
259
  return;
173
260
  try {
174
261
  await this.client.request("turn/interrupt", { threadId: this.threadId, turnId }, INTERRUPT_TIMEOUT_MS);
@@ -185,23 +272,72 @@ export class CodexRuntime {
185
272
  async close() {
186
273
  // Settle any waiter first, so a close during a live turn cannot leave the
187
274
  // caller awaiting forever (§13.4).
188
- this.activeTurn?.resolve();
275
+ const turn = this.activeTurn;
189
276
  this.activeTurn = undefined;
277
+ turn?.pendingInterrupt?.resolve();
278
+ turn?.resolve();
190
279
  await this.client.close();
191
280
  }
281
+ /** Learn the active turn's id; deliver a Stop that was waiting for it, once. */
282
+ bindTurnId(turn, turnId) {
283
+ turn.id = turnId;
284
+ const waiter = turn.pendingInterrupt;
285
+ if (!waiter || turn.interruptSent)
286
+ return;
287
+ turn.interruptSent = true;
288
+ turn.pendingInterrupt = undefined;
289
+ this.requestInterrupt(turnId).then(waiter.resolve, waiter.reject);
290
+ }
291
+ /**
292
+ * The app-server is gone (exited, failed, broken stream). A turn Codex already
293
+ * accepted would otherwise wait forever for a `turn/completed` that can never
294
+ * arrive, so report it terminal now as a model error. A turn whose
295
+ * `turn/start` is still unanswered is NOT reported here: that RPC rejects, and
296
+ * the rejected `send()` is its caller's terminal signal.
297
+ */
298
+ onTransportClosed(reason) {
299
+ const turn = this.activeTurn;
300
+ if (!turn)
301
+ return;
302
+ this.activeTurn = undefined;
303
+ turn.pendingInterrupt?.resolve();
304
+ turn.pendingInterrupt = undefined;
305
+ if (!turn.starting) {
306
+ this.log("runtime.turn_lost", {
307
+ businessSessionId: this.options.businessSessionId,
308
+ reason: reason.slice(0, 200),
309
+ });
310
+ const sink = turn.onEvent ?? this.hooks.onEvent;
311
+ this.emitEvent({ type: "error", error: `Codex app-server stopped before the turn finished: ${reason}` }, sink);
312
+ this.emitEvent({ type: "turn_complete", reason: "model_error" }, sink);
313
+ }
314
+ turn.resolve();
315
+ }
192
316
  onNotification(method, params) {
193
317
  const events = this.translator?.translate({ method, params }) ?? [];
194
318
  for (const event of events) {
319
+ // A fresh turn announced while ours has no id yet IS ours: the translator
320
+ // already discarded orphans of finished turns. This is the earliest point
321
+ // the id is known, and a Stop pressed before the RPC answers depends on it.
322
+ if (event.type === "stream_request_start" && method === "turn/started") {
323
+ const turn = this.activeTurn;
324
+ const turnId = notificationTurnId(params);
325
+ if (turn && !turn.id && turnId)
326
+ this.bindTurnId(turn, turnId);
327
+ }
195
328
  this.emitEvent(event);
196
329
  if (event.type === "turn_complete") {
197
- this.activeTurn?.resolve();
330
+ const turn = this.activeTurn;
198
331
  this.activeTurn = undefined;
332
+ turn?.pendingInterrupt?.resolve();
333
+ turn?.resolve();
199
334
  }
200
335
  }
201
336
  }
202
- emitEvent(event) {
337
+ emitEvent(event, sink = this.activeTurn?.onEvent ??
338
+ this.hooks.onEvent) {
203
339
  try {
204
- (this.activeTurn?.onEvent ?? this.hooks.onEvent)?.(event);
340
+ sink?.(event);
205
341
  }
206
342
  catch (error) {
207
343
  this.log("runtime.event_handler_failed", {
@@ -35,6 +35,10 @@ 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 { runClaudeTextInference } from "./claude-code/text-inference.js";
39
+ export type { ClaudeTextInferenceOptions } from "./claude-code/text-inference.js";
40
+ export { CLAUDE_PERMISSION_MODES, CLAUDE_PERMISSION_PROMPT_TOOL, claudeModeUsesPermissionPrompt, claudePermissionModeFor, withClaudePermissionPrompt, } from "./claude-code/permissions.js";
41
+ export type { ClaudePermissionMode, ClaudePermissionPromptDecider, ClaudePermissionPromptRequest, } from "./claude-code/permissions.js";
38
42
  export type { ClaudeRuntimeOptions, ClaudeRuntimeHooks, ClaudeTurnHandle, } from "./claude-code/runtime.js";
39
43
  export type { CodexEventTranslatorOptions } from "./codex/event-translator.js";
40
44
  export { startExternalRuntimeSession } from "./session-factory.js";
@@ -26,4 +26,6 @@ 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 { runClaudeTextInference } from "./claude-code/text-inference.js";
30
+ export { CLAUDE_PERMISSION_MODES, CLAUDE_PERMISSION_PROMPT_TOOL, claudeModeUsesPermissionPrompt, claudePermissionModeFor, withClaudePermissionPrompt, } from "./claude-code/permissions.js";
29
31
  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
@@ -84,6 +98,8 @@ export interface ExternalRuntimeSession {
84
98
  readonly businessSessionId: string;
85
99
  /** Runtime-side id (Codex thread / Claude session). Resume + routing only. */
86
100
  readonly runtimeSessionId: string | undefined;
101
+ /** Latest CLI-reported model; requested model is the fallback when unavailable. */
102
+ readonly resolvedModel?: string;
87
103
  /** Tools actually exposed, after the allowlist and visibility guards. */
88
104
  listTools(): readonly ToolDefinition[];
89
105
  send(input: ExternalRuntimeTurnInput, onEvent?: (event: StreamEvent) => void): Promise<{
@@ -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,
@@ -121,6 +160,9 @@ export async function startExternalRuntimeSession(options) {
121
160
  get runtimeSessionId() {
122
161
  return activeRuntime.runtimeSessionId;
123
162
  },
163
+ get resolvedModel() {
164
+ return activeRuntime.resolvedModel;
165
+ },
124
166
  listTools: () => activeHost.listTools(),
125
167
  send: async (input, onEvent) => {
126
168
  if (turnSink)
@@ -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;