@cjhyy/code-shell-capability-coding 0.9.43 → 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.
- package/dist/capability-runtime.js +4 -1
- package/dist/cc-orchestrator/codex-session-history.d.ts +3 -1
- package/dist/cc-orchestrator/codex-session-history.js +11 -4
- package/dist/cc-orchestrator/external-agent-changes.js +7 -65
- package/dist/cc-orchestrator/external-agent-driver.js +61 -3
- package/dist/cc-orchestrator/session-discovery.d.ts +4 -2
- package/dist/cc-orchestrator/session-discovery.js +63 -6
- package/dist/cc-orchestrator/session-history.js +5 -0
- package/dist/external-runtimes/claude-code/event-translator.d.ts +7 -0
- package/dist/external-runtimes/claude-code/event-translator.js +58 -9
- package/dist/external-runtimes/claude-code/mcp-config.d.ts +8 -0
- package/dist/external-runtimes/claude-code/mcp-config.js +16 -3
- package/dist/external-runtimes/claude-code/permissions.d.ts +69 -0
- package/dist/external-runtimes/claude-code/permissions.js +120 -0
- package/dist/external-runtimes/claude-code/runtime.d.ts +40 -4
- package/dist/external-runtimes/claude-code/runtime.js +130 -39
- package/dist/external-runtimes/codex/app-server-client.d.ts +12 -0
- package/dist/external-runtimes/codex/app-server-client.js +16 -0
- package/dist/external-runtimes/codex/runtime.d.ts +11 -0
- package/dist/external-runtimes/codex/runtime.js +143 -16
- package/dist/external-runtimes/index.d.ts +2 -0
- package/dist/external-runtimes/index.js +1 -0
- package/dist/external-runtimes/session-factory.d.ts +14 -0
- package/dist/external-runtimes/session-factory.js +40 -1
- package/dist/external-runtimes/shared/mcp-bridge.d.ts +36 -1
- package/dist/external-runtimes/shared/mcp-bridge.js +93 -13
- package/dist/git/parse-log.d.ts +6 -2
- package/dist/git/parse-log.js +7 -3
- package/dist/git/utils.d.ts +4 -0
- package/dist/git/utils.js +29 -10
- package/dist/git/worktree/crud.js +28 -0
- package/dist/git/worktree/diff.js +57 -29
- package/dist/index.capability.js +14 -1
- package/dist/index.git.d.ts +1 -0
- package/dist/index.git.js +1 -0
- package/dist/quota/index.d.ts +2 -1
- package/dist/quota/index.js +27 -7
- package/dist/quota/types.d.ts +5 -2
- package/dist/review/review-prompt.d.ts +2 -0
- package/dist/review/review-prompt.js +70 -29
- package/dist/review/review-target.d.ts +29 -0
- package/dist/review/review-target.js +353 -0
- package/dist/tools/apply-patch/applier.js +64 -39
- package/dist/tools/apply-patch/backup-targets.d.ts +8 -0
- package/dist/tools/apply-patch/backup-targets.js +24 -0
- package/dist/tools/apply-patch/parser.js +4 -2
- package/dist/tools/check-quota.js +3 -1
- package/dist/tools/lsp.js +36 -5
- 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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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 (
|
|
153
|
-
this.
|
|
193
|
+
if (turnId)
|
|
194
|
+
this.bindTurnId(turn, turnId);
|
|
154
195
|
return {
|
|
155
196
|
get turnId() {
|
|
156
|
-
return
|
|
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
|
|
171
|
-
if (!this.threadId || !
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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({});
|
package/dist/git/parse-log.d.ts
CHANGED
|
@@ -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
|
|
10
|
-
* malformed/short lines (missing
|
|
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
|
*/
|
package/dist/git/parse-log.js
CHANGED
|
@@ -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
|
|
3
|
-
* malformed/short lines (missing
|
|
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),
|
package/dist/git/utils.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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",
|
|
114
|
+
const raw = git(cwd, ["log", `--format=${GIT_LOG_FORMAT}`, `-${count}`], 10000);
|
|
96
115
|
if (!raw)
|
|
97
116
|
return [];
|
|
98
117
|
return parseGitLog(raw);
|