@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.
- 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
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** Values passed to `claude --permission-mode`. */
|
|
2
|
+
export const CLAUDE_PERMISSION_MODES = [
|
|
3
|
+
"default",
|
|
4
|
+
"acceptEdits",
|
|
5
|
+
"plan",
|
|
6
|
+
"dontAsk",
|
|
7
|
+
"bypassPermissions",
|
|
8
|
+
];
|
|
9
|
+
/**
|
|
10
|
+
* Map a CodeShell permission selection onto Claude's.
|
|
11
|
+
*
|
|
12
|
+
* Never escalates: Plan wins over everything; full access maps to
|
|
13
|
+
* `bypassPermissions` only when CodeShell itself is in `bypassPermissions`; and
|
|
14
|
+
* anything without an exact Claude equivalent — notably CodeShell's legacy
|
|
15
|
+
* `auto`, which in Claude would mean a model-classifier auto-approval — becomes
|
|
16
|
+
* `default`.
|
|
17
|
+
*/
|
|
18
|
+
export function claudePermissionModeFor(selection) {
|
|
19
|
+
if (selection.planMode || selection.permissionMode === "plan")
|
|
20
|
+
return "plan";
|
|
21
|
+
switch (selection.permissionMode) {
|
|
22
|
+
case "acceptEdits":
|
|
23
|
+
case "dontAsk":
|
|
24
|
+
case "bypassPermissions":
|
|
25
|
+
return selection.permissionMode;
|
|
26
|
+
default:
|
|
27
|
+
return "default";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Whether native asks should be routed to the host in this mode. Plan stays
|
|
32
|
+
* read-only (an approved ExitPlanMode would otherwise leave it); `dontAsk` means
|
|
33
|
+
* "do not ask"; `bypassPermissions` never asks.
|
|
34
|
+
*/
|
|
35
|
+
export function claudeModeUsesPermissionPrompt(mode) {
|
|
36
|
+
return mode === "default" || mode === "acceptEdits";
|
|
37
|
+
}
|
|
38
|
+
/** Plain CodeShell-side name; the bridge qualifies it as `mcp__<server>__…`. */
|
|
39
|
+
export const CLAUDE_PERMISSION_PROMPT_TOOL = "CodeShellPermissionPrompt";
|
|
40
|
+
function asRecord(value) {
|
|
41
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
42
|
+
? value
|
|
43
|
+
: undefined;
|
|
44
|
+
}
|
|
45
|
+
function deny(message) {
|
|
46
|
+
// The prompt tool's verdict is its TEXT result; an MCP error result would be
|
|
47
|
+
// treated by the CLI as a failed prompt rather than as a decision.
|
|
48
|
+
return { result: JSON.stringify({ behavior: "deny", message }) };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Add the permission-prompt tool to a session's bridge surface.
|
|
52
|
+
*
|
|
53
|
+
* Answers, in order:
|
|
54
|
+
* - the prompt tool itself → deny. It is visible to the model like any MCP
|
|
55
|
+
* tool; a direct call must not raise a host approval dialog.
|
|
56
|
+
* - this server's own tools → allow without asking. `ToolExecutor` authorizes
|
|
57
|
+
* them; asking here too would approve one operation twice (the same rule the
|
|
58
|
+
* Codex adapter applies to its MCP elicitations).
|
|
59
|
+
* - `ExitPlanMode` → deny. The mode is CodeShell's selection, not the model's.
|
|
60
|
+
* - everything else → `decide`, failing closed on any error or abort.
|
|
61
|
+
*/
|
|
62
|
+
export function withClaudePermissionPrompt(host, decide, serverName) {
|
|
63
|
+
const ownPrefix = `mcp__${serverName}__`;
|
|
64
|
+
const qualifiedSelf = `${ownPrefix}${CLAUDE_PERMISSION_PROMPT_TOOL}`;
|
|
65
|
+
return {
|
|
66
|
+
businessSessionId: host.businessSessionId,
|
|
67
|
+
listTools: () => [
|
|
68
|
+
...host.listTools(),
|
|
69
|
+
{
|
|
70
|
+
name: CLAUDE_PERMISSION_PROMPT_TOOL,
|
|
71
|
+
description: "Internal: Claude Code's permission prompt for its native tools, answered by " +
|
|
72
|
+
"the CodeShell user. Not for direct use.",
|
|
73
|
+
inputSchema: {
|
|
74
|
+
type: "object",
|
|
75
|
+
properties: {
|
|
76
|
+
tool_name: { type: "string" },
|
|
77
|
+
input: { type: "object" },
|
|
78
|
+
tool_use_id: { type: "string" },
|
|
79
|
+
},
|
|
80
|
+
required: ["tool_name", "input"],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
execute: async (call, signal) => {
|
|
85
|
+
if (call.name !== CLAUDE_PERMISSION_PROMPT_TOOL)
|
|
86
|
+
return host.execute(call, signal);
|
|
87
|
+
const params = asRecord(call.input);
|
|
88
|
+
const toolName = typeof params?.tool_name === "string" ? params.tool_name : "";
|
|
89
|
+
const input = asRecord(params?.input);
|
|
90
|
+
if (!toolName || !input)
|
|
91
|
+
return deny("Malformed permission request.");
|
|
92
|
+
if (toolName === qualifiedSelf || toolName === CLAUDE_PERMISSION_PROMPT_TOOL) {
|
|
93
|
+
return deny("The permission prompt tool cannot be called directly.");
|
|
94
|
+
}
|
|
95
|
+
if (toolName.startsWith(ownPrefix)) {
|
|
96
|
+
return { result: JSON.stringify({ behavior: "allow", updatedInput: input }) };
|
|
97
|
+
}
|
|
98
|
+
if (toolName === "ExitPlanMode") {
|
|
99
|
+
return deny("The permission mode is set by the CodeShell user and cannot be changed here.");
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
if (signal?.aborted)
|
|
103
|
+
return deny("The permission request was cancelled.");
|
|
104
|
+
const decision = await decide({
|
|
105
|
+
toolName,
|
|
106
|
+
input,
|
|
107
|
+
...(typeof params?.tool_use_id === "string" ? { toolUseId: params.tool_use_id } : {}),
|
|
108
|
+
}, signal);
|
|
109
|
+
if (signal?.aborted)
|
|
110
|
+
return deny("The permission request was cancelled.");
|
|
111
|
+
return decision.approved
|
|
112
|
+
? { result: JSON.stringify({ behavior: "allow", updatedInput: input }) }
|
|
113
|
+
: deny(decision.reason || "The CodeShell user denied this tool call.");
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return deny("The CodeShell approval request failed.");
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { StreamEvent } from "@cjhyy/code-shell-core/extension";
|
|
2
|
+
import { type ClaudePermissionMode, type ClaudePermissionPromptDecider } from "./permissions.js";
|
|
2
3
|
import type { McpBridgeHandle } from "../shared/mcp-bridge.js";
|
|
3
4
|
import { type ExternalRuntimeTurnInput } from "../turn-input.js";
|
|
4
5
|
export interface ClaudeRuntimeOptions {
|
|
@@ -15,11 +16,40 @@ export interface ClaudeRuntimeOptions {
|
|
|
15
16
|
model?: string;
|
|
16
17
|
resumeRuntimeSessionId?: string;
|
|
17
18
|
initialContext?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Stable host guidance, appended to Claude's system prompt on EVERY turn —
|
|
21
|
+
* new and resumed alike, because each turn is a new process. Contrast
|
|
22
|
+
* `initialContext`, a one-time transcript replay for a fresh conversation.
|
|
23
|
+
*/
|
|
24
|
+
developerInstructions?: string;
|
|
25
|
+
/**
|
|
26
|
+
* `--permission-mode` for Claude's NATIVE tools. Derive it with
|
|
27
|
+
* `claudePermissionModeFor`; see `permissions.ts` for why it is always sent.
|
|
28
|
+
*/
|
|
29
|
+
permissionMode?: ClaudePermissionMode;
|
|
30
|
+
/**
|
|
31
|
+
* Plain name of the bridge tool that answers Claude's permission prompts
|
|
32
|
+
* (`withClaudePermissionPrompt`). Qualified here as `mcp__<server>__<name>`.
|
|
33
|
+
*/
|
|
34
|
+
permissionPromptTool?: string;
|
|
18
35
|
serverName?: string;
|
|
36
|
+
/**
|
|
37
|
+
* How long `interrupt()`/`close()` wait after SIGTERM before SIGKILL.
|
|
38
|
+
* Defaults to {@link DEFAULT_INTERRUPT_GRACE_MS}.
|
|
39
|
+
*/
|
|
40
|
+
interruptGraceMs?: number;
|
|
19
41
|
log?: (event: string, data: Record<string, unknown>) => void;
|
|
20
42
|
}
|
|
43
|
+
/** SIGTERM grace before escalating to SIGKILL. */
|
|
44
|
+
export declare const DEFAULT_INTERRUPT_GRACE_MS = 3000;
|
|
21
45
|
export interface ClaudeRuntimeHooks {
|
|
22
46
|
onEvent?: (event: StreamEvent) => void;
|
|
47
|
+
/**
|
|
48
|
+
* Answer Claude's permission prompts for its NATIVE tools (Bash, Edit, …).
|
|
49
|
+
* Wired by the session factory through the bridge's permission-prompt tool;
|
|
50
|
+
* without it every native ask is denied. CodeShell Host Tools never reach it.
|
|
51
|
+
*/
|
|
52
|
+
onClaudePermissionPrompt?: ClaudePermissionPromptDecider;
|
|
23
53
|
}
|
|
24
54
|
export interface ClaudeTurnHandle {
|
|
25
55
|
readonly done: Promise<void>;
|
|
@@ -29,7 +59,7 @@ export declare class ClaudeCodeRuntime {
|
|
|
29
59
|
private readonly hooks;
|
|
30
60
|
readonly kind: "claude-code";
|
|
31
61
|
private readonly log;
|
|
32
|
-
private
|
|
62
|
+
private active?;
|
|
33
63
|
private claudeSessionId?;
|
|
34
64
|
private closed;
|
|
35
65
|
private firstTurn;
|
|
@@ -49,12 +79,18 @@ export declare class ClaudeCodeRuntime {
|
|
|
49
79
|
private onLine;
|
|
50
80
|
private emit;
|
|
51
81
|
/**
|
|
52
|
-
* Interrupt the active turn.
|
|
82
|
+
* Interrupt the active turn and wait until its process is gone.
|
|
83
|
+
*
|
|
84
|
+
* `claude -p` has no interrupt RPC, so this is a signal: SIGTERM, then SIGKILL
|
|
85
|
+
* after the grace period. Returning while the process still runs would let a
|
|
86
|
+
* CLI that ignores SIGTERM keep working — and keep the turn pending — after
|
|
87
|
+
* the host believes it stopped. The turn settles with `aborted_streaming`.
|
|
53
88
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
89
|
+
* As with Codex, it cannot retract side effects already dispatched — it only
|
|
90
|
+
* stops further steps.
|
|
56
91
|
*/
|
|
57
92
|
interrupt(): Promise<void>;
|
|
93
|
+
private terminate;
|
|
58
94
|
close(): Promise<void>;
|
|
59
95
|
/** Start a fresh conversation on the next `send()`, discarding resume state. */
|
|
60
96
|
resetConversation(): void;
|
|
@@ -17,15 +17,32 @@
|
|
|
17
17
|
import { spawn } from "node:child_process";
|
|
18
18
|
import { createInterface } from "node:readline";
|
|
19
19
|
import { ClaudeEventTranslator } from "./event-translator.js";
|
|
20
|
-
import { claudeBridgeArgs, CLAUDE_MCP_SERVER_NAME } from "./mcp-config.js";
|
|
20
|
+
import { claudeBridgeArgs, CLAUDE_MCP_SERVER_NAME, writeClaudeSystemPromptFile, } from "./mcp-config.js";
|
|
21
21
|
import { buildRuntimeSpawnEnv } from "../shared/spawn-env.js";
|
|
22
|
+
import { CLAUDE_PERMISSION_MODES, } from "./permissions.js";
|
|
22
23
|
import { textWithAttachmentReferences } from "../turn-input.js";
|
|
24
|
+
/** SIGTERM grace before escalating to SIGKILL. */
|
|
25
|
+
export const DEFAULT_INTERRUPT_GRACE_MS = 3_000;
|
|
26
|
+
/**
|
|
27
|
+
* Bound on waiting for `exit` after SIGKILL. SIGKILL cannot be ignored, so this
|
|
28
|
+
* only guards against a lost `exit` event; the turn is settled regardless.
|
|
29
|
+
*/
|
|
30
|
+
const KILL_SETTLE_MS = 2_000;
|
|
31
|
+
function settlesWithin(promise, ms) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
const timer = setTimeout(() => resolve(false), ms);
|
|
34
|
+
void promise.then(() => {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
resolve(true);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
23
40
|
export class ClaudeCodeRuntime {
|
|
24
41
|
options;
|
|
25
42
|
hooks;
|
|
26
43
|
kind = "claude-code";
|
|
27
44
|
log;
|
|
28
|
-
|
|
45
|
+
active;
|
|
29
46
|
claudeSessionId;
|
|
30
47
|
closed = false;
|
|
31
48
|
firstTurn = true;
|
|
@@ -33,6 +50,16 @@ export class ClaudeCodeRuntime {
|
|
|
33
50
|
constructor(options, hooks = {}) {
|
|
34
51
|
this.options = options;
|
|
35
52
|
this.hooks = hooks;
|
|
53
|
+
// Typed, but also reachable from untyped callers: an unknown mode must not
|
|
54
|
+
// reach the CLI, where e.g. `auto` would mean classifier auto-approval.
|
|
55
|
+
if (options.permissionMode !== undefined &&
|
|
56
|
+
!CLAUDE_PERMISSION_MODES.includes(options.permissionMode)) {
|
|
57
|
+
throw new Error(`Unsupported Claude permission mode: ${String(options.permissionMode)}`);
|
|
58
|
+
}
|
|
59
|
+
if (options.permissionPromptTool !== undefined &&
|
|
60
|
+
!/^[A-Za-z][A-Za-z0-9_]*$/.test(options.permissionPromptTool)) {
|
|
61
|
+
throw new Error("permissionPromptTool must be a plain bridge tool name");
|
|
62
|
+
}
|
|
36
63
|
this.log = options.log ?? (() => { });
|
|
37
64
|
this.claudeSessionId = options.resumeRuntimeSessionId;
|
|
38
65
|
}
|
|
@@ -51,7 +78,7 @@ export class ClaudeCodeRuntime {
|
|
|
51
78
|
async send(input, onEvent) {
|
|
52
79
|
if (this.closed)
|
|
53
80
|
throw new Error("ClaudeCodeRuntime is closed");
|
|
54
|
-
if (this.
|
|
81
|
+
if (this.active)
|
|
55
82
|
throw new Error("a turn is already running");
|
|
56
83
|
let terminalSeen = false;
|
|
57
84
|
const turnNumber = ++this.turnCount;
|
|
@@ -65,6 +92,15 @@ export class ClaudeCodeRuntime {
|
|
|
65
92
|
exposedToolNames: this.options.exposedToolNames,
|
|
66
93
|
...(this.options.serverName ? { serverName: this.options.serverName } : {}),
|
|
67
94
|
});
|
|
95
|
+
// Per turn: each `claude -p` is a new process, and `--resume` does not carry a
|
|
96
|
+
// previous invocation's appended system prompt forward.
|
|
97
|
+
const systemPrompt = this.options.developerInstructions?.trim()
|
|
98
|
+
? writeClaudeSystemPromptFile(this.options.developerInstructions)
|
|
99
|
+
: undefined;
|
|
100
|
+
const cleanupTurnFiles = () => {
|
|
101
|
+
wiring.cleanup();
|
|
102
|
+
systemPrompt?.cleanup();
|
|
103
|
+
};
|
|
68
104
|
const args = [
|
|
69
105
|
"-p",
|
|
70
106
|
"--output-format",
|
|
@@ -76,21 +112,36 @@ export class ClaudeCodeRuntime {
|
|
|
76
112
|
// Continuity across turns: a fresh process per turn means the session id is
|
|
77
113
|
// the only thread of memory.
|
|
78
114
|
...(this.claudeSessionId ? ["--resume", this.claudeSessionId] : []),
|
|
115
|
+
...(systemPrompt ? ["--append-system-prompt-file", systemPrompt.path] : []),
|
|
116
|
+
...(this.options.permissionMode ? ["--permission-mode", this.options.permissionMode] : []),
|
|
117
|
+
...(this.options.permissionPromptTool
|
|
118
|
+
? [
|
|
119
|
+
"--permission-prompt-tool",
|
|
120
|
+
`mcp__${this.options.serverName ?? CLAUDE_MCP_SERVER_NAME}__${this.options.permissionPromptTool}`,
|
|
121
|
+
]
|
|
122
|
+
: []),
|
|
79
123
|
...(this.options.extraArgs ?? []),
|
|
80
124
|
...wiring.args,
|
|
81
125
|
];
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
126
|
+
let child;
|
|
127
|
+
try {
|
|
128
|
+
child = spawn(this.options.command ?? "claude", args, {
|
|
129
|
+
cwd: this.options.cwd,
|
|
130
|
+
env: buildRuntimeSpawnEnv({
|
|
131
|
+
bridgeToken: {
|
|
132
|
+
name: this.options.bridge.tokenEnvVar,
|
|
133
|
+
value: this.options.bridge.token,
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
137
|
+
shell: false,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
// A synchronous spawn failure never reaches the exit/error handlers below.
|
|
142
|
+
cleanupTurnFiles();
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
94
145
|
child.stdout.setEncoding("utf8");
|
|
95
146
|
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
96
147
|
lines.on("line", (line) => {
|
|
@@ -112,31 +163,46 @@ export class ClaudeCodeRuntime {
|
|
|
112
163
|
}
|
|
113
164
|
this.firstTurn = false;
|
|
114
165
|
child.stdin.end(text);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
166
|
+
let resolveSettled;
|
|
167
|
+
const settled = new Promise((resolve) => (resolveSettled = resolve));
|
|
168
|
+
let finished = false;
|
|
169
|
+
const active = {
|
|
170
|
+
child,
|
|
171
|
+
settled,
|
|
172
|
+
interrupted: false,
|
|
173
|
+
finish: (code, error) => {
|
|
118
174
|
if (finished)
|
|
119
175
|
return;
|
|
120
176
|
finished = true;
|
|
121
177
|
lines.close();
|
|
122
|
-
// Clean up the config file that carried the bearer token
|
|
123
|
-
|
|
124
|
-
|
|
178
|
+
// Clean up the config file that carried the bearer token, and the
|
|
179
|
+
// system-prompt file.
|
|
180
|
+
cleanupTurnFiles();
|
|
181
|
+
if (this.active === active)
|
|
182
|
+
this.active = undefined;
|
|
125
183
|
if (!terminalSeen) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
184
|
+
if (active.interrupted) {
|
|
185
|
+
// A stop the user asked for is not a model failure. Same terminal
|
|
186
|
+
// reason Codex reports for an interrupted turn.
|
|
187
|
+
this.emit({ type: "turn_complete", reason: "aborted_streaming" }, onEvent);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
const detail = error?.message ??
|
|
191
|
+
`Claude Code exited without a terminal result (code ${code ?? "unknown"})`;
|
|
192
|
+
this.emit({ type: "error", error: detail }, onEvent);
|
|
193
|
+
this.emit({ type: "turn_complete", reason: "model_error" }, onEvent);
|
|
194
|
+
}
|
|
130
195
|
}
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
});
|
|
196
|
+
resolveSettled();
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
this.active = active;
|
|
200
|
+
child.once("exit", (code) => active.finish(code));
|
|
201
|
+
child.once("error", (error) => {
|
|
202
|
+
this.log("claude.spawn_failed", { error: error.message.slice(0, 200) });
|
|
203
|
+
active.finish(undefined, error);
|
|
138
204
|
});
|
|
139
|
-
return { done };
|
|
205
|
+
return { done: settled };
|
|
140
206
|
}
|
|
141
207
|
onLine(line, translator, emit) {
|
|
142
208
|
if (!line.trim())
|
|
@@ -171,16 +237,41 @@ export class ClaudeCodeRuntime {
|
|
|
171
237
|
}
|
|
172
238
|
}
|
|
173
239
|
/**
|
|
174
|
-
* Interrupt the active turn.
|
|
240
|
+
* Interrupt the active turn and wait until its process is gone.
|
|
175
241
|
*
|
|
176
|
-
* `claude -p` has no interrupt RPC, so this is a signal
|
|
177
|
-
*
|
|
242
|
+
* `claude -p` has no interrupt RPC, so this is a signal: SIGTERM, then SIGKILL
|
|
243
|
+
* after the grace period. Returning while the process still runs would let a
|
|
244
|
+
* CLI that ignores SIGTERM keep working — and keep the turn pending — after
|
|
245
|
+
* the host believes it stopped. The turn settles with `aborted_streaming`.
|
|
246
|
+
*
|
|
247
|
+
* As with Codex, it cannot retract side effects already dispatched — it only
|
|
248
|
+
* stops further steps.
|
|
178
249
|
*/
|
|
179
250
|
async interrupt() {
|
|
180
|
-
const
|
|
181
|
-
if (!
|
|
251
|
+
const active = this.active;
|
|
252
|
+
if (!active)
|
|
253
|
+
return;
|
|
254
|
+
active.interrupted = true;
|
|
255
|
+
active.stopping ??= this.terminate(active);
|
|
256
|
+
await active.stopping;
|
|
257
|
+
}
|
|
258
|
+
async terminate(active) {
|
|
259
|
+
const { child } = active;
|
|
260
|
+
const running = () => child.exitCode === null && child.signalCode === null;
|
|
261
|
+
if (running())
|
|
262
|
+
child.kill("SIGTERM");
|
|
263
|
+
const grace = this.options.interruptGraceMs ?? DEFAULT_INTERRUPT_GRACE_MS;
|
|
264
|
+
if (await settlesWithin(active.settled, grace))
|
|
265
|
+
return;
|
|
266
|
+
this.log("claude.sigkill", { graceMs: grace });
|
|
267
|
+
if (running())
|
|
268
|
+
child.kill("SIGKILL");
|
|
269
|
+
if (await settlesWithin(active.settled, KILL_SETTLE_MS))
|
|
182
270
|
return;
|
|
183
|
-
|
|
271
|
+
// No `exit` event even after SIGKILL (or the spawn never produced a process).
|
|
272
|
+
// Settle the turn anyway so neither the caller nor the session hangs.
|
|
273
|
+
this.log("claude.exit_not_observed", {});
|
|
274
|
+
active.finish(null);
|
|
184
275
|
}
|
|
185
276
|
async close() {
|
|
186
277
|
this.closed = true;
|
|
@@ -21,6 +21,14 @@ export type NotificationHandler = (method: string, params: unknown) => void;
|
|
|
21
21
|
* waiting either way.
|
|
22
22
|
*/
|
|
23
23
|
export type ServerRequestHandler = (method: string, params: unknown) => Promise<unknown> | unknown;
|
|
24
|
+
/**
|
|
25
|
+
* Told once when the transport is gone for good — the process exited or failed
|
|
26
|
+
* to spawn, the stream broke, or {@link CodexAppServerClient.close} ran. Pending
|
|
27
|
+
* requests are rejected by then; this is for state that is NOT a request, such
|
|
28
|
+
* as a turn whose RPC was already answered and now waits on notifications that
|
|
29
|
+
* can never arrive.
|
|
30
|
+
*/
|
|
31
|
+
export type CloseHandler = (reason: string) => void;
|
|
24
32
|
export declare class CodexAppServerClient {
|
|
25
33
|
private readonly options;
|
|
26
34
|
private child?;
|
|
@@ -29,6 +37,7 @@ export declare class CodexAppServerClient {
|
|
|
29
37
|
private readonly pending;
|
|
30
38
|
private notificationHandler?;
|
|
31
39
|
private requestHandler?;
|
|
40
|
+
private closeHandler?;
|
|
32
41
|
private closed;
|
|
33
42
|
private closeReason?;
|
|
34
43
|
/** Buffer lines that arrive before a handler exists, then drain in order. */
|
|
@@ -42,6 +51,7 @@ export declare class CodexAppServerClient {
|
|
|
42
51
|
*/
|
|
43
52
|
onNotification(handler: NotificationHandler): void;
|
|
44
53
|
onServerRequest(handler: ServerRequestHandler): void;
|
|
54
|
+
onClose(handler: CloseHandler): void;
|
|
45
55
|
/** Spawn the app-server. Handlers must already be registered. */
|
|
46
56
|
start(): void;
|
|
47
57
|
private acceptLine;
|
|
@@ -60,6 +70,8 @@ export declare class CodexAppServerClient {
|
|
|
60
70
|
notify(method: string, params?: unknown): void;
|
|
61
71
|
private write;
|
|
62
72
|
private failAll;
|
|
73
|
+
/** Why the transport closed, once it has. */
|
|
74
|
+
get closedReason(): string | undefined;
|
|
63
75
|
get isClosed(): boolean;
|
|
64
76
|
/** Stop via stdin EOF, then SIGTERM and SIGKILL if the server does not exit. */
|
|
65
77
|
close(): Promise<void>;
|
|
@@ -36,6 +36,7 @@ export class CodexAppServerClient {
|
|
|
36
36
|
pending = new Map();
|
|
37
37
|
notificationHandler;
|
|
38
38
|
requestHandler;
|
|
39
|
+
closeHandler;
|
|
39
40
|
closed = false;
|
|
40
41
|
closeReason;
|
|
41
42
|
/** Buffer lines that arrive before a handler exists, then drain in order. */
|
|
@@ -56,6 +57,9 @@ export class CodexAppServerClient {
|
|
|
56
57
|
onServerRequest(handler) {
|
|
57
58
|
this.requestHandler = handler;
|
|
58
59
|
}
|
|
60
|
+
onClose(handler) {
|
|
61
|
+
this.closeHandler = handler;
|
|
62
|
+
}
|
|
59
63
|
/** Spawn the app-server. Handlers must already be registered. */
|
|
60
64
|
start() {
|
|
61
65
|
if (this.child)
|
|
@@ -234,6 +238,18 @@ export class CodexAppServerClient {
|
|
|
234
238
|
entry.reject(new Error(reason));
|
|
235
239
|
}
|
|
236
240
|
this.pending.clear();
|
|
241
|
+
try {
|
|
242
|
+
this.closeHandler?.(reason);
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
this.log("appserver.close_handler_failed", {
|
|
246
|
+
error: error instanceof Error ? error.name : "unknown",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/** Why the transport closed, once it has. */
|
|
251
|
+
get closedReason() {
|
|
252
|
+
return this.closeReason;
|
|
237
253
|
}
|
|
238
254
|
get isClosed() {
|
|
239
255
|
return this.closed;
|
|
@@ -111,7 +111,18 @@ export declare class CodexRuntime {
|
|
|
111
111
|
* that no further step starts (§13.3).
|
|
112
112
|
*/
|
|
113
113
|
interrupt(): Promise<void>;
|
|
114
|
+
private requestInterrupt;
|
|
114
115
|
close(): Promise<void>;
|
|
116
|
+
/** Learn the active turn's id; deliver a Stop that was waiting for it, once. */
|
|
117
|
+
private bindTurnId;
|
|
118
|
+
/**
|
|
119
|
+
* The app-server is gone (exited, failed, broken stream). A turn Codex already
|
|
120
|
+
* accepted would otherwise wait forever for a `turn/completed` that can never
|
|
121
|
+
* arrive, so report it terminal now as a model error. A turn whose
|
|
122
|
+
* `turn/start` is still unanswered is NOT reported here: that RPC rejects, and
|
|
123
|
+
* the rejected `send()` is its caller's terminal signal.
|
|
124
|
+
*/
|
|
125
|
+
private onTransportClosed;
|
|
115
126
|
private onNotification;
|
|
116
127
|
private emitEvent;
|
|
117
128
|
/**
|