@love-moon/ai-sdk 0.15.2 → 0.17.0
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/CHANGELOG.md +43 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/providers/claude-agent-sdk-session.d.ts +89 -4
- package/dist/providers/claude-agent-sdk-session.js +404 -100
- package/dist/providers/codex-app-server-session.d.ts +10 -0
- package/dist/providers/codex-app-server-session.js +57 -1
- package/dist/providers/kimi-print-session.js +14 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# @love-moon/ai-sdk
|
|
2
2
|
|
|
3
|
+
## 0.17.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d088b31: Claude tasks keep one Claude process per session, with its input left open
|
|
8
|
+
between turns, instead of starting a new one-shot process for every turn.
|
|
9
|
+
Background subagents now run to completion: before, claude killed them 10
|
|
10
|
+
minutes after the main agent's turn ended, so long multi-agent tasks went quiet
|
|
11
|
+
until the user sent another message. When they finish, Claude's follow-up reply
|
|
12
|
+
is posted to the chat without waiting for a user message. Subagent narration is
|
|
13
|
+
no longer posted as a task reply.
|
|
14
|
+
- 79ca552: Claude no longer switches to `acceptEdits` mode when the daemon runs as root.
|
|
15
|
+
That fallback looked like it worked, but in a headless task it silently refused
|
|
16
|
+
every shell command (ssh, rsync, ...) and every read outside the project. Claude
|
|
17
|
+
now keeps `bypassPermissions` everywhere. As root without `IS_SANDBOX=1`, the
|
|
18
|
+
chat shows a warning right after "session started" telling you to add
|
|
19
|
+
`envs: { IS_SANDBOX: "1" }` to the conductor config (or run the daemon as a
|
|
20
|
+
regular user). Terminal tasks print the same hint. An `IS_SANDBOX` set in config
|
|
21
|
+
`envs` now also reaches terminal (PTY) claude tasks. `conductor config` always
|
|
22
|
+
writes `--dangerously-skip-permissions` for claude.
|
|
23
|
+
- ccbfeeb: Kimi Code tasks no longer kill background subagents after 12 minutes. `kimi -p`
|
|
24
|
+
stays silent while it waits for a task it launched with `run_in_background`,
|
|
25
|
+
and the session's idle deadline used to treat that silence as a stuck turn and
|
|
26
|
+
terminate kimi together with the task. Once a turn launches a background task,
|
|
27
|
+
the deadline now waits for kimi to exit on its own.
|
|
28
|
+
|
|
29
|
+
## 0.16.0
|
|
30
|
+
|
|
31
|
+
### Patch Changes
|
|
32
|
+
|
|
33
|
+
- 5c81d06: Claude turns that wait on background subagents now report their full token
|
|
34
|
+
usage. Such a turn emits one result per segment and the task card used to show
|
|
35
|
+
only the last segment; it now sums the result's `modelUsage`, which covers
|
|
36
|
+
every segment, every subagent and small side calls. `conductor serve-ai`'s
|
|
37
|
+
`prompt_tokens` / `completion_tokens` come from the same usage, so they now
|
|
38
|
+
include those too. A turn cut off after an earlier segment keeps that
|
|
39
|
+
segment's usage.
|
|
40
|
+
- b69d665: Codex turns that spawn sub-agents now include the sub-agents' tokens in the
|
|
41
|
+
turn usage. Their `thread/tokenUsage/updated` notifications carry the
|
|
42
|
+
sub-agent's own thread id and were dropped with its other events. A
|
|
43
|
+
sub-agent's replayed total is not counted, and whatever it spends after the
|
|
44
|
+
parent turn ends goes to the next turn.
|
|
45
|
+
|
|
3
46
|
## 0.15.2
|
|
4
47
|
|
|
5
48
|
## 0.15.1
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export { BUILT_IN_BACKENDS } from "./built-in-backends.js";
|
|
|
2
2
|
export { PROVIDER_MEDIA_CAPABILITIES } from "./media-adapters.js";
|
|
3
3
|
export { summarizeTurnUsage } from "./turn-usage.js";
|
|
4
4
|
export { createAiSession, RemoteAiSession } from "./client.js";
|
|
5
|
-
export { isClaudeRootPermissionRestricted,
|
|
5
|
+
export { isClaudeRootPermissionRestricted, buildClaudeRootSandboxNotice, claudeCommandNeedsRootSandbox, resolveClaudePermissionPolicy } from "./providers/claude-agent-sdk-session.js";
|
|
6
6
|
export { appendContextFilesToPrompt, normalizeContextFiles } from "./context-files.js";
|
|
7
7
|
export { assertMediaCapabilities, fileMediaToBase64, fileMediaToDataUri, normalizeMediaInputs, resolveTurnMedia } from "./media-input.js";
|
|
8
8
|
export { GOAL_STATUSES, TERMINAL_GOAL_STATUSES, isGoalStatus, isTerminalGoalStatus } from "./shared.js";
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { createAiSession, RemoteAiSession } from "./client.js";
|
|
2
2
|
export { BUILT_IN_BACKENDS } from "./built-in-backends.js";
|
|
3
3
|
export { PROVIDER_MEDIA_CAPABILITIES } from "./media-adapters.js";
|
|
4
|
-
export { isClaudeRootPermissionRestricted,
|
|
4
|
+
export { isClaudeRootPermissionRestricted, buildClaudeRootSandboxNotice, claudeCommandNeedsRootSandbox, resolveClaudePermissionPolicy, } from "./providers/claude-agent-sdk-session.js";
|
|
5
5
|
export { appendContextFilesToPrompt, normalizeContextFiles } from "./context-files.js";
|
|
6
6
|
export { summarizeTurnUsage } from "./turn-usage.js";
|
|
7
7
|
export { assertMediaCapabilities, fileMediaToBase64, fileMediaToDataUri, normalizeMediaInputs, resolveTurnMedia, } from "./media-input.js";
|
|
@@ -2,10 +2,11 @@ export function isClaudeRootPermissionRestricted(env?: NodeJS.ProcessEnv): boole
|
|
|
2
2
|
export function resolveClaudePermissionPolicy(options?: {}, env?: NodeJS.ProcessEnv): {
|
|
3
3
|
permissionMode: string;
|
|
4
4
|
allowDangerouslySkipPermissions: boolean;
|
|
5
|
-
|
|
5
|
+
rootSandboxRequired: boolean;
|
|
6
6
|
invalidMode: any;
|
|
7
7
|
};
|
|
8
|
-
export function
|
|
8
|
+
export function claudeCommandNeedsRootSandbox(commandLine: any, env?: NodeJS.ProcessEnv): boolean;
|
|
9
|
+
export function buildClaudeRootSandboxNotice({ configFile }?: {}): string;
|
|
9
10
|
export class ClaudeAgentSdkSession extends EventEmitter<[never]> {
|
|
10
11
|
static capabilities: Readonly<{
|
|
11
12
|
goal: true;
|
|
@@ -45,7 +46,10 @@ export class ClaudeAgentSdkSession extends EventEmitter<[never]> {
|
|
|
45
46
|
lastReplyTarget: string;
|
|
46
47
|
manualResumeReady: boolean;
|
|
47
48
|
currentTurn: {
|
|
48
|
-
abortController:
|
|
49
|
+
abortController: null;
|
|
50
|
+
liveQuery: null;
|
|
51
|
+
onProgress: null;
|
|
52
|
+
settle: null;
|
|
49
53
|
suppressReply: boolean;
|
|
50
54
|
compactMetadata: null;
|
|
51
55
|
compactError: string;
|
|
@@ -56,7 +60,27 @@ export class ClaudeAgentSdkSession extends EventEmitter<[never]> {
|
|
|
56
60
|
query: null;
|
|
57
61
|
resultMessage: null;
|
|
58
62
|
terminalWorkingStatusEmitted: boolean;
|
|
63
|
+
usageBase: null;
|
|
59
64
|
usageByMessageId: Map<any, any>;
|
|
65
|
+
uuid: string;
|
|
66
|
+
} | null;
|
|
67
|
+
liveQuery: {
|
|
68
|
+
abortController: AbortController;
|
|
69
|
+
backgroundTurn: null;
|
|
70
|
+
ended: boolean;
|
|
71
|
+
input: {
|
|
72
|
+
push(message: any): boolean;
|
|
73
|
+
close: () => void;
|
|
74
|
+
[Symbol.asyncIterator](): {
|
|
75
|
+
next: () => Promise<any>;
|
|
76
|
+
return: () => Promise<{
|
|
77
|
+
value: undefined;
|
|
78
|
+
done: boolean;
|
|
79
|
+
}>;
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
query: null;
|
|
83
|
+
usageBase: null;
|
|
60
84
|
} | null;
|
|
61
85
|
lastResult: any;
|
|
62
86
|
rateLimitInfo: any;
|
|
@@ -69,9 +93,10 @@ export class ClaudeAgentSdkSession extends EventEmitter<[never]> {
|
|
|
69
93
|
permissionPolicy: {
|
|
70
94
|
permissionMode: string;
|
|
71
95
|
allowDangerouslySkipPermissions: boolean;
|
|
72
|
-
|
|
96
|
+
rootSandboxRequired: boolean;
|
|
73
97
|
invalidMode: any;
|
|
74
98
|
};
|
|
99
|
+
notices: string[];
|
|
75
100
|
writeLog(message: any): void;
|
|
76
101
|
trace(message: any): void;
|
|
77
102
|
get threadId(): any;
|
|
@@ -103,6 +128,7 @@ export class ClaudeAgentSdkSession extends EventEmitter<[never]> {
|
|
|
103
128
|
image: "native";
|
|
104
129
|
}>;
|
|
105
130
|
};
|
|
131
|
+
notices: string[];
|
|
106
132
|
};
|
|
107
133
|
getSessionInfo(): {
|
|
108
134
|
backend: string;
|
|
@@ -165,6 +191,65 @@ export class ClaudeAgentSdkSession extends EventEmitter<[never]> {
|
|
|
165
191
|
onProgress: any;
|
|
166
192
|
}): Promise<void>;
|
|
167
193
|
interruptCurrentTurn(): Promise<boolean>;
|
|
194
|
+
createTurnState({ suppressReply, onProgress }?: {
|
|
195
|
+
suppressReply?: boolean | undefined;
|
|
196
|
+
onProgress?: null | undefined;
|
|
197
|
+
}): {
|
|
198
|
+
abortController: null;
|
|
199
|
+
liveQuery: null;
|
|
200
|
+
onProgress: null;
|
|
201
|
+
settle: null;
|
|
202
|
+
suppressReply: boolean;
|
|
203
|
+
compactMetadata: null;
|
|
204
|
+
compactError: string;
|
|
205
|
+
conversationReset: string;
|
|
206
|
+
emittedAssistantMessage: boolean;
|
|
207
|
+
fullText: string;
|
|
208
|
+
items: never[];
|
|
209
|
+
query: null;
|
|
210
|
+
resultMessage: null;
|
|
211
|
+
terminalWorkingStatusEmitted: boolean;
|
|
212
|
+
usageBase: null;
|
|
213
|
+
usageByMessageId: Map<any, any>;
|
|
214
|
+
uuid: string;
|
|
215
|
+
};
|
|
216
|
+
/**
|
|
217
|
+
* One claude process per session, fed through a prompt stream that stays
|
|
218
|
+
* open between turns. A one-shot string prompt closes claude's input at once,
|
|
219
|
+
* and claude then kills background subagents 10 minutes after the main
|
|
220
|
+
* agent's turn ends (CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS). Kept open, the
|
|
221
|
+
* subagents run to completion and claude starts its own follow-up turn on
|
|
222
|
+
* their notifications.
|
|
223
|
+
*/
|
|
224
|
+
ensureLiveQuery(sdkModule: any): {
|
|
225
|
+
abortController: AbortController;
|
|
226
|
+
backgroundTurn: null;
|
|
227
|
+
ended: boolean;
|
|
228
|
+
input: {
|
|
229
|
+
push(message: any): boolean;
|
|
230
|
+
close: () => void;
|
|
231
|
+
[Symbol.asyncIterator](): {
|
|
232
|
+
next: () => Promise<any>;
|
|
233
|
+
return: () => Promise<{
|
|
234
|
+
value: undefined;
|
|
235
|
+
done: boolean;
|
|
236
|
+
}>;
|
|
237
|
+
};
|
|
238
|
+
};
|
|
239
|
+
query: null;
|
|
240
|
+
usageBase: null;
|
|
241
|
+
};
|
|
242
|
+
closeLiveQuery(liveQuery: any): void;
|
|
243
|
+
settleTurn(turn: any, error: any, resultMessage?: null): void;
|
|
244
|
+
pumpLiveQuery(liveQuery: any): Promise<void>;
|
|
245
|
+
/**
|
|
246
|
+
* Routes the long-lived query's output. A result ends the pending turn when it
|
|
247
|
+
* echoes that turn's user message uuid (or echoes none while no follow-up turn
|
|
248
|
+
* of claude's own is running); everything else is a background turn, e.g.
|
|
249
|
+
* claude answering a finished subagent's notification between user turns.
|
|
250
|
+
*/
|
|
251
|
+
dispatchLiveMessage(liveQuery: any, message: any): Promise<void>;
|
|
252
|
+
finishBackgroundTurn(backgroundTurn: any, resultMessage: any, failure?: null): Promise<void>;
|
|
168
253
|
runTurn(promptText: any, { useInitialImages, media: mediaInput, contextFiles, onProgress, jsonSchema, suppressReply }?: {
|
|
169
254
|
useInitialImages?: boolean | undefined;
|
|
170
255
|
onProgress?: null | undefined;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { EventEmitter } from "node:events";
|
|
2
3
|
import { CLAUDE_AGENT_SDK_VARIANT as CLAUDE_PROVIDER_VARIANT } from "../built-in-backends.js";
|
|
3
4
|
import { appendContextFilesToPrompt } from "../context-files.js";
|
|
@@ -7,24 +8,89 @@ import { emitLog, extractLongFlagFromCommandLine, getBoundedEnvInt, isGoalStatus
|
|
|
7
8
|
const DEFAULT_TURN_DEADLINE_MS = 12 * 60 * 1000;
|
|
8
9
|
const MIN_TURN_DEADLINE_MS = 30 * 1000;
|
|
9
10
|
const MAX_TURN_DEADLINE_MS = 30 * 60 * 1000;
|
|
11
|
+
// How long an interrupted turn of the long-lived query may take to report its
|
|
12
|
+
// result before the process is torn down instead.
|
|
13
|
+
const INTERRUPT_GRACE_MS = 10 * 1000;
|
|
10
14
|
const DEFAULT_SETTING_SOURCES = ["user", "project", "local"];
|
|
11
15
|
const PERMISSION_MODES = new Set(["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk"]);
|
|
12
16
|
const DEFAULT_PERMISSION_MODE = "bypassPermissions";
|
|
13
|
-
// Auto ("accept edits") mode: the strongest mode claude allows as root.
|
|
14
|
-
const ROOT_PERMISSION_MODE = "acceptEdits";
|
|
15
17
|
function waitForever() {
|
|
16
18
|
return new Promise(() => { });
|
|
17
19
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
function buildClaudeUserMessage(promptText, media, uuid) {
|
|
21
|
+
return {
|
|
20
22
|
type: "user",
|
|
21
23
|
message: {
|
|
22
24
|
role: "user",
|
|
23
|
-
content: buildClaudeContent(promptText, media),
|
|
25
|
+
content: media.length ? buildClaudeContent(promptText, media) : promptText,
|
|
24
26
|
},
|
|
25
27
|
parent_tool_use_id: null,
|
|
28
|
+
...(uuid ? { uuid } : {}),
|
|
26
29
|
};
|
|
27
30
|
}
|
|
31
|
+
async function* buildClaudeInput(promptText, media) {
|
|
32
|
+
yield buildClaudeUserMessage(promptText, media);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The prompt stream of a long-lived query. It stays open between turns: claude
|
|
36
|
+
* only winds a print-mode process down (killing background subagents after its
|
|
37
|
+
* wait ceiling) once its input is closed.
|
|
38
|
+
*/
|
|
39
|
+
function createInputQueue() {
|
|
40
|
+
const buffer = [];
|
|
41
|
+
let waiter = null;
|
|
42
|
+
let closed = false;
|
|
43
|
+
const finish = () => {
|
|
44
|
+
closed = true;
|
|
45
|
+
if (waiter) {
|
|
46
|
+
const resolve = waiter;
|
|
47
|
+
waiter = null;
|
|
48
|
+
resolve({ value: undefined, done: true });
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
push(message) {
|
|
53
|
+
if (closed) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
if (waiter) {
|
|
57
|
+
const resolve = waiter;
|
|
58
|
+
waiter = null;
|
|
59
|
+
resolve({ value: message, done: false });
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
buffer.push(message);
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
},
|
|
66
|
+
close: finish,
|
|
67
|
+
[Symbol.asyncIterator]() {
|
|
68
|
+
return {
|
|
69
|
+
next: () => {
|
|
70
|
+
if (buffer.length) {
|
|
71
|
+
return Promise.resolve({ value: buffer.shift(), done: false });
|
|
72
|
+
}
|
|
73
|
+
if (closed) {
|
|
74
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
75
|
+
}
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
waiter = resolve;
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
return: () => {
|
|
81
|
+
finish();
|
|
82
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function resultUserMessageUuids(resultMessage) {
|
|
89
|
+
if (Array.isArray(resultMessage?.user_message_uuids)) {
|
|
90
|
+
return resultMessage.user_message_uuids;
|
|
91
|
+
}
|
|
92
|
+
return resultMessage?.user_message_uuid ? [resultMessage.user_message_uuid] : [];
|
|
93
|
+
}
|
|
28
94
|
function createTurnError(message, extras = {}) {
|
|
29
95
|
const error = new Error(message);
|
|
30
96
|
for (const [key, value] of Object.entries(extras)) {
|
|
@@ -32,12 +98,15 @@ function createTurnError(message, extras = {}) {
|
|
|
32
98
|
}
|
|
33
99
|
return error;
|
|
34
100
|
}
|
|
35
|
-
/**
|
|
36
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Per-turn usage summed from streamed assistant messages (one entry per API
|
|
103
|
+
* response), on top of `base` (what an earlier result of the turn covered).
|
|
104
|
+
*/
|
|
105
|
+
function sumStreamedUsage(usageByMessageId, base = null) {
|
|
37
106
|
if (!usageByMessageId.size) {
|
|
38
|
-
return
|
|
107
|
+
return base;
|
|
39
108
|
}
|
|
40
|
-
const total = {};
|
|
109
|
+
const total = { ...base };
|
|
41
110
|
for (const usage of usageByMessageId.values()) {
|
|
42
111
|
for (const key of ["input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens", "output_tokens"]) {
|
|
43
112
|
const value = Number(usage?.[key]);
|
|
@@ -48,6 +117,44 @@ function sumStreamedUsage(usageByMessageId) {
|
|
|
48
117
|
}
|
|
49
118
|
return total;
|
|
50
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* A finished turn's usage. `usage` covers only the main loop's last segment, while
|
|
122
|
+
* `modelUsage` accumulates every API call the query's process made (all segments,
|
|
123
|
+
* subagents and earlier turns of a long-lived query), so prefer its sum minus `base`,
|
|
124
|
+
* the total at the end of the previous turn.
|
|
125
|
+
*/
|
|
126
|
+
function resultUsage(resultMessage, base = null) {
|
|
127
|
+
const total = modelUsageTotal(resultMessage);
|
|
128
|
+
if (!total) {
|
|
129
|
+
return resultMessage.usage ? { ...resultMessage.usage } : null;
|
|
130
|
+
}
|
|
131
|
+
// /clear resets the running total; a total below its base starts over.
|
|
132
|
+
if (!base || Object.keys(base).some((key) => !(total[key] >= base[key]))) {
|
|
133
|
+
return total;
|
|
134
|
+
}
|
|
135
|
+
const delta = {};
|
|
136
|
+
for (const [key, value] of Object.entries(total)) {
|
|
137
|
+
delta[key] = value - base[key];
|
|
138
|
+
}
|
|
139
|
+
return delta;
|
|
140
|
+
}
|
|
141
|
+
function modelUsageTotal(resultMessage) {
|
|
142
|
+
const total = {};
|
|
143
|
+
for (const entry of Object.values(resultMessage.modelUsage || {})) {
|
|
144
|
+
for (const [from, to] of [
|
|
145
|
+
["inputTokens", "input_tokens"],
|
|
146
|
+
["cacheCreationInputTokens", "cache_creation_input_tokens"],
|
|
147
|
+
["cacheReadInputTokens", "cache_read_input_tokens"],
|
|
148
|
+
["outputTokens", "output_tokens"],
|
|
149
|
+
]) {
|
|
150
|
+
const value = Number(entry?.[from]);
|
|
151
|
+
if (Number.isFinite(value)) {
|
|
152
|
+
total[to] = (total[to] || 0) + value;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return Object.keys(total).length ? total : null;
|
|
157
|
+
}
|
|
51
158
|
function normalizeClaudeBackend(backend) {
|
|
52
159
|
const normalized = String(backend || "").trim().toLowerCase();
|
|
53
160
|
if (normalized === "claude-code") {
|
|
@@ -82,71 +189,76 @@ function normalizePermissionMode(value) {
|
|
|
82
189
|
// Two different env idioms in that one line, and both are load-bearing:
|
|
83
190
|
// IS_SANDBOX is a *strict* `=== "1"` compare, while CLAUDE_CODE_BUBBLEWRAP goes
|
|
84
191
|
// through claude's loose truthy parser (`1|true|yes|on`, trimmed+lowercased).
|
|
85
|
-
// Do not "helpfully" accept IS_SANDBOX=true here —
|
|
86
|
-
// the
|
|
87
|
-
//
|
|
192
|
+
// Do not "helpfully" accept IS_SANDBOX=true here — claude would still refuse,
|
|
193
|
+
// and the user would get no hint telling them why.
|
|
194
|
+
//
|
|
195
|
+
// Values are compared the way claude will see them: spawn stringifies env, so
|
|
196
|
+
// YAML `IS_SANDBOX: 1` (a number from config `envs:`) reaches claude as "1"
|
|
197
|
+
// and passes its gate. `true` becomes "true" and still does not.
|
|
88
198
|
export function isClaudeRootPermissionRestricted(env = process.env) {
|
|
89
199
|
if (typeof process.getuid !== "function" || process.getuid() !== 0) {
|
|
90
200
|
return false;
|
|
91
201
|
}
|
|
92
|
-
if (env?.IS_SANDBOX === "1") {
|
|
202
|
+
if (String(env?.IS_SANDBOX ?? "") === "1") {
|
|
93
203
|
return false;
|
|
94
204
|
}
|
|
95
205
|
const bubblewrap = String(env?.CLAUDE_CODE_BUBBLEWRAP ?? "").trim().toLowerCase();
|
|
96
206
|
return !["1", "true", "yes", "on"].includes(bubblewrap);
|
|
97
207
|
}
|
|
98
|
-
//
|
|
99
|
-
//
|
|
208
|
+
// The configured mode is used as-is, including on root. We used to downgrade
|
|
209
|
+
// bypassPermissions -> acceptEdits for root, but a headless session has no one
|
|
210
|
+
// to approve prompts, so acceptEdits silently denied every Bash command and
|
|
211
|
+
// every read outside cwd: the task "ran" and could do nothing. Instead we keep
|
|
212
|
+
// the mode and flag `rootSandboxRequired` so the caller can tell the user to
|
|
213
|
+
// opt in with IS_SANDBOX=1 (or run as a regular user).
|
|
100
214
|
export function resolveClaudePermissionPolicy(options = {}, env = process.env) {
|
|
101
|
-
const
|
|
215
|
+
const permissionMode = normalizePermissionMode(options.permissionMode);
|
|
102
216
|
// A configured value we don't recognize still falls back to the default (a
|
|
103
217
|
// typo must never take the session down), but the caller reports it so the
|
|
104
218
|
// user finds out their config line is doing nothing.
|
|
105
219
|
const rawMode = typeof options.permissionMode === "string" ? options.permissionMode.trim() : "";
|
|
106
220
|
const invalidMode = rawMode && !PERMISSION_MODES.has(rawMode) ? rawMode : "";
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
permissionMode: requested,
|
|
110
|
-
allowDangerouslySkipPermissions: requested === DEFAULT_PERMISSION_MODE || options.allowDangerouslySkipPermissions === true,
|
|
111
|
-
downgradedFrom: "",
|
|
112
|
-
invalidMode,
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
const permissionMode = requested === DEFAULT_PERMISSION_MODE ? ROOT_PERMISSION_MODE : requested;
|
|
221
|
+
const rootRestricted = isClaudeRootPermissionRestricted(env);
|
|
222
|
+
const bypass = permissionMode === DEFAULT_PERMISSION_MODE;
|
|
116
223
|
return {
|
|
117
224
|
permissionMode,
|
|
118
|
-
|
|
119
|
-
|
|
225
|
+
// A non-bypass mode the user picked on purpose never needs the escape
|
|
226
|
+
// flag, and as root it would only trip claude's gate.
|
|
227
|
+
allowDangerouslySkipPermissions: bypass || (options.allowDangerouslySkipPermissions === true && !rootRestricted),
|
|
228
|
+
rootSandboxRequired: bypass && rootRestricted,
|
|
120
229
|
invalidMode,
|
|
121
230
|
};
|
|
122
231
|
}
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
export function resolveClaudeCommandForRoot(commandLine, env = process.env) {
|
|
232
|
+
// True when a configured claude command line would hit claude's root gate:
|
|
233
|
+
// it asks for bypass (the flag, or `--permission-mode bypassPermissions`) and
|
|
234
|
+
// the process env doesn't opt out.
|
|
235
|
+
export function claudeCommandNeedsRootSandbox(commandLine, env = process.env) {
|
|
128
236
|
const command = typeof commandLine === "string" ? commandLine : "";
|
|
129
|
-
if (!command.trim()
|
|
130
|
-
return
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
.trim()
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
237
|
+
if (!command.trim()) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
const bypass = /(^|\s)--dangerously-skip-permissions(?=\s|$)/.test(command)
|
|
241
|
+
|| extractLongFlagFromCommandLine(command, "permission-mode") === DEFAULT_PERMISSION_MODE;
|
|
242
|
+
return bypass && isClaudeRootPermissionRestricted(env);
|
|
243
|
+
}
|
|
244
|
+
// User-facing hint shown in the task chat (and the PTY terminal) when claude
|
|
245
|
+
// is about to be started as root in bypass mode without IS_SANDBOX=1.
|
|
246
|
+
export function buildClaudeRootSandboxNotice({ configFile } = {}) {
|
|
247
|
+
const configPath = typeof configFile === "string" && configFile.trim()
|
|
248
|
+
? configFile.trim()
|
|
249
|
+
: "~/.conductor/config.yaml";
|
|
250
|
+
return [
|
|
251
|
+
"⚠️ 当前 conductor 以 root 用户运行 claude。Claude Code 禁止 root 使用 bypassPermissions(--dangerously-skip-permissions),本会话的请求会直接失败。",
|
|
252
|
+
"",
|
|
253
|
+
`如果这台机器是隔离环境(容器 / 虚机),请在 ${configPath} 中加入下面的配置,然后重启 daemon:`,
|
|
254
|
+
"",
|
|
255
|
+
"```yaml",
|
|
256
|
+
"envs:",
|
|
257
|
+
" IS_SANDBOX: \"1\"",
|
|
258
|
+
"```",
|
|
259
|
+
"",
|
|
260
|
+
"注意值必须是 1(写成 1 或 \"1\" 都可以),写成 true / yes 不生效。设置后 claude 会以 root 身份直接执行命令、不再请求确认;如果不是隔离环境,建议改用普通用户运行 daemon。",
|
|
261
|
+
].join("\n");
|
|
150
262
|
}
|
|
151
263
|
function normalizeText(value) {
|
|
152
264
|
return typeof value === "string" ? value : "";
|
|
@@ -287,6 +399,7 @@ export class ClaudeAgentSdkSession extends EventEmitter {
|
|
|
287
399
|
this.lastReplyTarget = "";
|
|
288
400
|
this.manualResumeReady = Boolean(this.sessionId);
|
|
289
401
|
this.currentTurn = null;
|
|
402
|
+
this.liveQuery = null;
|
|
290
403
|
this.lastResult = null;
|
|
291
404
|
this.rateLimitInfo = null;
|
|
292
405
|
this.currentTurnStatus = null;
|
|
@@ -312,9 +425,15 @@ export class ClaudeAgentSdkSession extends EventEmitter {
|
|
|
312
425
|
+ `using ${this.permissionPolicy.permissionMode} instead `
|
|
313
426
|
+ `(valid: ${[...PERMISSION_MODES].join(", ")})`);
|
|
314
427
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
428
|
+
// Shown to the user by fire as a chat message (see getSnapshot().notices):
|
|
429
|
+
// claude will refuse to start, and a daemon log line alone is invisible.
|
|
430
|
+
this.notices = [];
|
|
431
|
+
if (this.permissionPolicy.rootSandboxRequired) {
|
|
432
|
+
this.trace("WARN running as root without IS_SANDBOX=1; claude will refuse bypassPermissions "
|
|
433
|
+
+ "(set envs.IS_SANDBOX: \"1\" in the conductor config, or run as a regular user)");
|
|
434
|
+
this.notices.push(buildClaudeRootSandboxNotice({
|
|
435
|
+
configFile: options.configFile || this.env.CONDUCTOR_CONFIG || process.env.CONDUCTOR_CONFIG,
|
|
436
|
+
}));
|
|
318
437
|
}
|
|
319
438
|
}
|
|
320
439
|
writeLog(message) {
|
|
@@ -353,6 +472,7 @@ export class ClaudeAgentSdkSession extends EventEmitter {
|
|
|
353
472
|
: null,
|
|
354
473
|
currentTurnStatus: this.getCurrentTurnStatus(),
|
|
355
474
|
capabilities: this.getCapabilities(),
|
|
475
|
+
notices: [...this.notices],
|
|
356
476
|
};
|
|
357
477
|
}
|
|
358
478
|
getSessionInfo() {
|
|
@@ -811,7 +931,8 @@ export class ClaudeAgentSdkSession extends EventEmitter {
|
|
|
811
931
|
noteToolStarted(currentTurn, block.id, block.name, block.input);
|
|
812
932
|
}
|
|
813
933
|
}
|
|
814
|
-
|
|
934
|
+
// Subagent narration (parent_tool_use_id set) is not a reply to the user.
|
|
935
|
+
const text = message.parent_tool_use_id ? "" : extractAssistantText(message.message);
|
|
815
936
|
if (!text) {
|
|
816
937
|
return;
|
|
817
938
|
}
|
|
@@ -853,6 +974,8 @@ export class ClaudeAgentSdkSession extends EventEmitter {
|
|
|
853
974
|
this.updateSessionInfo(message.session_id || message.sessionId);
|
|
854
975
|
this.lastResult = message;
|
|
855
976
|
currentTurn.resultMessage = message;
|
|
977
|
+
// Its modelUsage covers everything streamed so far.
|
|
978
|
+
currentTurn.usageByMessageId.clear();
|
|
856
979
|
return;
|
|
857
980
|
default:
|
|
858
981
|
return;
|
|
@@ -863,6 +986,26 @@ export class ClaudeAgentSdkSession extends EventEmitter {
|
|
|
863
986
|
if (!currentTurn) {
|
|
864
987
|
return false;
|
|
865
988
|
}
|
|
989
|
+
const { liveQuery } = currentTurn;
|
|
990
|
+
if (liveQuery) {
|
|
991
|
+
// The interrupted turn reports its own result; tear the process down only
|
|
992
|
+
// if it does not, so a stuck CLI cannot hold the turn open.
|
|
993
|
+
try {
|
|
994
|
+
await liveQuery.query.interrupt?.();
|
|
995
|
+
}
|
|
996
|
+
catch {
|
|
997
|
+
// best effort
|
|
998
|
+
}
|
|
999
|
+
if (this.currentTurn === currentTurn) {
|
|
1000
|
+
const timer = setTimeout(() => {
|
|
1001
|
+
if (this.currentTurn === currentTurn) {
|
|
1002
|
+
this.closeLiveQuery(liveQuery);
|
|
1003
|
+
}
|
|
1004
|
+
}, INTERRUPT_GRACE_MS);
|
|
1005
|
+
timer.unref?.();
|
|
1006
|
+
}
|
|
1007
|
+
return true;
|
|
1008
|
+
}
|
|
866
1009
|
try {
|
|
867
1010
|
await currentTurn.query?.interrupt?.();
|
|
868
1011
|
}
|
|
@@ -883,6 +1026,151 @@ export class ClaudeAgentSdkSession extends EventEmitter {
|
|
|
883
1026
|
}
|
|
884
1027
|
return true;
|
|
885
1028
|
}
|
|
1029
|
+
createTurnState({ suppressReply = false, onProgress = null } = {}) {
|
|
1030
|
+
return {
|
|
1031
|
+
abortController: null,
|
|
1032
|
+
liveQuery: null,
|
|
1033
|
+
onProgress,
|
|
1034
|
+
settle: null,
|
|
1035
|
+
suppressReply,
|
|
1036
|
+
compactMetadata: null,
|
|
1037
|
+
compactError: "",
|
|
1038
|
+
conversationReset: "",
|
|
1039
|
+
emittedAssistantMessage: false,
|
|
1040
|
+
fullText: "",
|
|
1041
|
+
items: [],
|
|
1042
|
+
query: null,
|
|
1043
|
+
resultMessage: null,
|
|
1044
|
+
terminalWorkingStatusEmitted: false,
|
|
1045
|
+
usageBase: null,
|
|
1046
|
+
usageByMessageId: new Map(),
|
|
1047
|
+
uuid: "",
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* One claude process per session, fed through a prompt stream that stays
|
|
1052
|
+
* open between turns. A one-shot string prompt closes claude's input at once,
|
|
1053
|
+
* and claude then kills background subagents 10 minutes after the main
|
|
1054
|
+
* agent's turn ends (CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS). Kept open, the
|
|
1055
|
+
* subagents run to completion and claude starts its own follow-up turn on
|
|
1056
|
+
* their notifications.
|
|
1057
|
+
*/
|
|
1058
|
+
ensureLiveQuery(sdkModule) {
|
|
1059
|
+
if (this.liveQuery && !this.liveQuery.ended) {
|
|
1060
|
+
return this.liveQuery;
|
|
1061
|
+
}
|
|
1062
|
+
const abortController = new AbortController();
|
|
1063
|
+
const liveQuery = {
|
|
1064
|
+
abortController,
|
|
1065
|
+
backgroundTurn: null,
|
|
1066
|
+
ended: false,
|
|
1067
|
+
input: createInputQueue(),
|
|
1068
|
+
query: null,
|
|
1069
|
+
usageBase: null,
|
|
1070
|
+
};
|
|
1071
|
+
liveQuery.query = sdkModule.query({
|
|
1072
|
+
prompt: liveQuery.input,
|
|
1073
|
+
options: this.buildSdkOptions(abortController),
|
|
1074
|
+
});
|
|
1075
|
+
this.liveQuery = liveQuery;
|
|
1076
|
+
void this.pumpLiveQuery(liveQuery);
|
|
1077
|
+
return liveQuery;
|
|
1078
|
+
}
|
|
1079
|
+
closeLiveQuery(liveQuery) {
|
|
1080
|
+
liveQuery.ended = true;
|
|
1081
|
+
if (this.liveQuery === liveQuery) {
|
|
1082
|
+
this.liveQuery = null;
|
|
1083
|
+
}
|
|
1084
|
+
liveQuery.input.close();
|
|
1085
|
+
try {
|
|
1086
|
+
liveQuery.abortController.abort();
|
|
1087
|
+
}
|
|
1088
|
+
catch {
|
|
1089
|
+
// best effort
|
|
1090
|
+
}
|
|
1091
|
+
try {
|
|
1092
|
+
liveQuery.query?.close?.();
|
|
1093
|
+
}
|
|
1094
|
+
catch {
|
|
1095
|
+
// best effort
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
settleTurn(turn, error, resultMessage = null) {
|
|
1099
|
+
const settle = turn.settle;
|
|
1100
|
+
turn.settle = null;
|
|
1101
|
+
settle?.(error, resultMessage);
|
|
1102
|
+
}
|
|
1103
|
+
async pumpLiveQuery(liveQuery) {
|
|
1104
|
+
let failure = null;
|
|
1105
|
+
try {
|
|
1106
|
+
for await (const message of liveQuery.query) {
|
|
1107
|
+
await this.dispatchLiveMessage(liveQuery, message);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
catch (error) {
|
|
1111
|
+
failure = error;
|
|
1112
|
+
}
|
|
1113
|
+
this.closeLiveQuery(liveQuery);
|
|
1114
|
+
const turn = this.currentTurn;
|
|
1115
|
+
if (turn?.liveQuery === liveQuery && turn.settle) {
|
|
1116
|
+
this.settleTurn(turn, failure, turn.resultMessage);
|
|
1117
|
+
}
|
|
1118
|
+
else if (failure && !this.closeRequested) {
|
|
1119
|
+
this.trace(`claude process ended between turns: ${failure?.message || failure}`);
|
|
1120
|
+
}
|
|
1121
|
+
const backgroundTurn = liveQuery.backgroundTurn;
|
|
1122
|
+
liveQuery.backgroundTurn = null;
|
|
1123
|
+
if (backgroundTurn) {
|
|
1124
|
+
await this.finishBackgroundTurn(backgroundTurn, null, failure);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Routes the long-lived query's output. A result ends the pending turn when it
|
|
1129
|
+
* echoes that turn's user message uuid (or echoes none while no follow-up turn
|
|
1130
|
+
* of claude's own is running); everything else is a background turn, e.g.
|
|
1131
|
+
* claude answering a finished subagent's notification between user turns.
|
|
1132
|
+
*/
|
|
1133
|
+
async dispatchLiveMessage(liveQuery, message) {
|
|
1134
|
+
const turn = this.currentTurn?.liveQuery === liveQuery && this.currentTurn.settle ? this.currentTurn : null;
|
|
1135
|
+
const backgroundTurn = liveQuery.backgroundTurn;
|
|
1136
|
+
if (message?.type !== "result") {
|
|
1137
|
+
const target = backgroundTurn || turn || (liveQuery.backgroundTurn = this.createTurnState());
|
|
1138
|
+
await this.handleSdkMessage(message, target, { onProgress: target.onProgress });
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
liveQuery.backgroundTurn = null;
|
|
1142
|
+
const uuids = resultUserMessageUuids(message);
|
|
1143
|
+
if (turn && (uuids.length ? uuids.includes(turn.uuid) : !backgroundTurn)) {
|
|
1144
|
+
// A background turn that folded the user's message in ends as this turn.
|
|
1145
|
+
if (backgroundTurn?.emittedAssistantMessage) {
|
|
1146
|
+
turn.emittedAssistantMessage = true;
|
|
1147
|
+
}
|
|
1148
|
+
await this.handleSdkMessage(message, turn, { onProgress: turn.onProgress });
|
|
1149
|
+
liveQuery.usageBase = modelUsageTotal(message) || liveQuery.usageBase;
|
|
1150
|
+
this.settleTurn(turn, null, message);
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
const target = backgroundTurn || this.createTurnState();
|
|
1154
|
+
await this.handleSdkMessage(message, target, {});
|
|
1155
|
+
await this.finishBackgroundTurn(target, message);
|
|
1156
|
+
}
|
|
1157
|
+
async finishBackgroundTurn(backgroundTurn, resultMessage, failure = null) {
|
|
1158
|
+
const failed = Boolean(failure) || (resultMessage && resultMessage.subtype !== "success");
|
|
1159
|
+
const text = normalizeText(resultMessage?.result);
|
|
1160
|
+
if (!failed && text && !backgroundTurn.emittedAssistantMessage) {
|
|
1161
|
+
await this.emitAssistantMessage(text);
|
|
1162
|
+
}
|
|
1163
|
+
// A pending user turn owns the working status.
|
|
1164
|
+
if (this.currentTurn) {
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
await this.emitTerminalWorkingStatus(backgroundTurn, failed
|
|
1168
|
+
? {
|
|
1169
|
+
phase: "turn_failed",
|
|
1170
|
+
status_done_line: failure?.message || extractResultErrorMessage(resultMessage),
|
|
1171
|
+
}
|
|
1172
|
+
: { phase: "turn_completed", status_done_line: "claude finished" });
|
|
1173
|
+
}
|
|
886
1174
|
async runTurn(promptText, { useInitialImages = false, media: mediaInput, contextFiles, onProgress = null, jsonSchema = null, suppressReply = false } = {}) {
|
|
887
1175
|
if (this.closeRequested) {
|
|
888
1176
|
throw this.createSessionClosedError();
|
|
@@ -912,31 +1200,24 @@ export class ClaudeAgentSdkSession extends EventEmitter {
|
|
|
912
1200
|
if (!suppressReply) {
|
|
913
1201
|
this.history.push({ role: "user", content: promptText });
|
|
914
1202
|
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
conversationReset: "",
|
|
922
|
-
emittedAssistantMessage: false,
|
|
923
|
-
fullText: "",
|
|
924
|
-
items: [],
|
|
925
|
-
query: null,
|
|
926
|
-
resultMessage: null,
|
|
927
|
-
terminalWorkingStatusEmitted: false,
|
|
928
|
-
usageByMessageId: new Map(),
|
|
929
|
-
};
|
|
1203
|
+
// Structured output is fixed when the process starts, so a jsonSchema turn
|
|
1204
|
+
// runs on a one-shot query of its own.
|
|
1205
|
+
const oneShot = Boolean(jsonSchema && typeof jsonSchema === "object");
|
|
1206
|
+
const abortController = oneShot ? new AbortController() : null;
|
|
1207
|
+
const currentTurn = this.createTurnState({ suppressReply, onProgress });
|
|
1208
|
+
currentTurn.abortController = abortController;
|
|
930
1209
|
this.currentTurn = currentTurn;
|
|
931
1210
|
this.markTurnStartedStatus();
|
|
932
|
-
const
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
abortController
|
|
1211
|
+
const stopTurn = () => {
|
|
1212
|
+
if (currentTurn.liveQuery) {
|
|
1213
|
+
this.closeLiveQuery(currentTurn.liveQuery);
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
abortController?.abort();
|
|
938
1217
|
currentTurn.query?.close?.();
|
|
939
|
-
}
|
|
1218
|
+
};
|
|
1219
|
+
const closeGuard = this.createCloseGuard(stopTurn);
|
|
1220
|
+
const turnTimeoutGuard = this.createTurnTimeoutGuard(stopTurn);
|
|
940
1221
|
const previousOutputFormat = this.options.outputFormat;
|
|
941
1222
|
if (jsonSchema && typeof jsonSchema === "object") {
|
|
942
1223
|
this.options.outputFormat = { type: "json_schema", schema: jsonSchema };
|
|
@@ -947,21 +1228,35 @@ export class ClaudeAgentSdkSession extends EventEmitter {
|
|
|
947
1228
|
reply_in_progress: true,
|
|
948
1229
|
status_line: "claude is working",
|
|
949
1230
|
}, onProgress);
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1231
|
+
let turnResult;
|
|
1232
|
+
if (oneShot) {
|
|
1233
|
+
const query = sdkModule.query({
|
|
1234
|
+
prompt: media.length ? buildClaudeInput(effectivePrompt, media) : effectivePrompt,
|
|
1235
|
+
options: this.buildSdkOptions(abortController),
|
|
1236
|
+
});
|
|
1237
|
+
currentTurn.query = query;
|
|
1238
|
+
turnResult = (async () => {
|
|
957
1239
|
for await (const message of query) {
|
|
958
1240
|
await this.handleSdkMessage(message, currentTurn, { onProgress });
|
|
959
1241
|
}
|
|
960
1242
|
return currentTurn.resultMessage;
|
|
961
|
-
})()
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1243
|
+
})();
|
|
1244
|
+
}
|
|
1245
|
+
else {
|
|
1246
|
+
turnResult = new Promise((resolve, reject) => {
|
|
1247
|
+
currentTurn.settle = (error, message) => (error ? reject(error) : resolve(message));
|
|
1248
|
+
});
|
|
1249
|
+
const liveQuery = this.ensureLiveQuery(sdkModule);
|
|
1250
|
+
currentTurn.liveQuery = liveQuery;
|
|
1251
|
+
currentTurn.query = liveQuery.query;
|
|
1252
|
+
currentTurn.usageBase = liveQuery.usageBase;
|
|
1253
|
+
currentTurn.uuid = randomUUID();
|
|
1254
|
+
if (!liveQuery.input.push(buildClaudeUserMessage(effectivePrompt, media, currentTurn.uuid))) {
|
|
1255
|
+
this.settleTurn(currentTurn, createTurnError("Claude process input is closed", { reason: "process_exited" }));
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
turnResult.catch(() => { });
|
|
1259
|
+
const resultMessage = await Promise.race([turnResult, closeGuard.promise, turnTimeoutGuard.promise]);
|
|
965
1260
|
if (!resultMessage) {
|
|
966
1261
|
throw createTurnError("Claude query ended without a result message", {
|
|
967
1262
|
reason: "missing_result",
|
|
@@ -982,7 +1277,7 @@ export class ClaudeAgentSdkSession extends EventEmitter {
|
|
|
982
1277
|
? [...resultMessage.permission_denials]
|
|
983
1278
|
: [],
|
|
984
1279
|
// A failed turn still spent tokens.
|
|
985
|
-
usage: resultMessage
|
|
1280
|
+
usage: resultUsage(resultMessage, currentTurn.usageBase),
|
|
986
1281
|
});
|
|
987
1282
|
}
|
|
988
1283
|
const structuredOutput = jsonSchema && resultMessage.structured_output && typeof resultMessage.structured_output === "object"
|
|
@@ -1007,7 +1302,7 @@ export class ClaudeAgentSdkSession extends EventEmitter {
|
|
|
1007
1302
|
}, onProgress);
|
|
1008
1303
|
return {
|
|
1009
1304
|
text: responseText,
|
|
1010
|
-
usage: resultMessage
|
|
1305
|
+
usage: resultUsage(resultMessage, currentTurn.usageBase),
|
|
1011
1306
|
items: currentTurn.items,
|
|
1012
1307
|
events: [],
|
|
1013
1308
|
provider: this.backend,
|
|
@@ -1027,8 +1322,9 @@ export class ClaudeAgentSdkSession extends EventEmitter {
|
|
|
1027
1322
|
}
|
|
1028
1323
|
catch (error) {
|
|
1029
1324
|
// A failed turn still spent tokens; an interrupted query ends without a
|
|
1030
|
-
// result, so fall back to the
|
|
1031
|
-
const usage = error?.usage ??
|
|
1325
|
+
// (final) result, so fall back to the last result plus what streamed since.
|
|
1326
|
+
const usage = error?.usage ??
|
|
1327
|
+
sumStreamedUsage(currentTurn.usageByMessageId, currentTurn.resultMessage ? resultUsage(currentTurn.resultMessage, currentTurn.usageBase) : null);
|
|
1032
1328
|
if (error?.reason === "turn_timeout") {
|
|
1033
1329
|
await this.interruptCurrentTurn();
|
|
1034
1330
|
}
|
|
@@ -1063,11 +1359,14 @@ export class ClaudeAgentSdkSession extends EventEmitter {
|
|
|
1063
1359
|
}
|
|
1064
1360
|
closeGuard.cleanup();
|
|
1065
1361
|
turnTimeoutGuard.cleanup();
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1362
|
+
currentTurn.settle = null;
|
|
1363
|
+
if (oneShot) {
|
|
1364
|
+
try {
|
|
1365
|
+
currentTurn.query?.close?.();
|
|
1366
|
+
}
|
|
1367
|
+
catch {
|
|
1368
|
+
// best effort
|
|
1369
|
+
}
|
|
1071
1370
|
}
|
|
1072
1371
|
}
|
|
1073
1372
|
}
|
|
@@ -1241,7 +1540,12 @@ export class ClaudeAgentSdkSession extends EventEmitter {
|
|
|
1241
1540
|
}
|
|
1242
1541
|
this.closeRequested = true;
|
|
1243
1542
|
this.flushCloseWaiters();
|
|
1244
|
-
|
|
1543
|
+
if (!this.currentTurn?.liveQuery) {
|
|
1544
|
+
await this.interruptCurrentTurn();
|
|
1545
|
+
}
|
|
1546
|
+
if (this.liveQuery) {
|
|
1547
|
+
this.closeLiveQuery(this.liveQuery);
|
|
1548
|
+
}
|
|
1245
1549
|
this.closed = true;
|
|
1246
1550
|
}
|
|
1247
1551
|
}
|
|
@@ -46,6 +46,10 @@ export class CodexAppServerSession extends EventEmitter<[never]> {
|
|
|
46
46
|
totalTokens: number;
|
|
47
47
|
} | null;
|
|
48
48
|
turnTokensSeen: boolean;
|
|
49
|
+
subThreadTokenTotals: Map<any, any>;
|
|
50
|
+
subThreadTokenBaselines: Map<any, any>;
|
|
51
|
+
subThreadTokensReported: Map<any, any>;
|
|
52
|
+
subThreadTurnIds: Map<any, any>;
|
|
49
53
|
currentTurnStatus: any;
|
|
50
54
|
currentTurnActivityAt: number;
|
|
51
55
|
now: any;
|
|
@@ -160,6 +164,12 @@ export class CodexAppServerSession extends EventEmitter<[never]> {
|
|
|
160
164
|
applySessionConfigured(params: any): void;
|
|
161
165
|
/** Start counting a turn's tokens from the thread total known right now. */
|
|
162
166
|
beginTurnTokenAccounting(): void;
|
|
167
|
+
/**
|
|
168
|
+
* Track a spawned sub-agent thread's total. Its first update fixes its
|
|
169
|
+
* baseline: `total - last`, or the whole total when it is a replay (a turn id
|
|
170
|
+
* we never saw start), like the main thread's after thread/resume.
|
|
171
|
+
*/
|
|
172
|
+
noteSubThreadTokenUsage(threadId: any, { turnId, tokenUsage }?: {}): void;
|
|
163
173
|
/**
|
|
164
174
|
* Thread token usage plus `turnTotalTokens` (and its `turnInputTokens` /
|
|
165
175
|
* `turnCachedInputTokens` split): tokens spent since the current turn (or
|
|
@@ -88,6 +88,15 @@ function subtractTokenCounts(a, b) {
|
|
|
88
88
|
: {}),
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
|
+
function addTokenCounts(a, b) {
|
|
92
|
+
const split = a.inputTokens !== undefined && b.inputTokens !== undefined;
|
|
93
|
+
return {
|
|
94
|
+
totalTokens: a.totalTokens + b.totalTokens,
|
|
95
|
+
...(split
|
|
96
|
+
? { inputTokens: a.inputTokens + b.inputTokens, cachedInputTokens: a.cachedInputTokens + b.cachedInputTokens }
|
|
97
|
+
: {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
91
100
|
/**
|
|
92
101
|
* Adds tokens spent outside the reported attempt (an overflowed first try) to a
|
|
93
102
|
* turn result or error. Only the total grows: the input split, and so the cache
|
|
@@ -276,6 +285,13 @@ export class CodexAppServerSession extends EventEmitter {
|
|
|
276
285
|
// its own usage has arrived since; see snapshotTokenUsage().
|
|
277
286
|
this.turnTokenBaseline = null;
|
|
278
287
|
this.turnTokensSeen = false;
|
|
288
|
+
// Spawned sub-agent threads report their own thread totals: the latest one
|
|
289
|
+
// per thread, the part already counted (or not theirs to count), what the
|
|
290
|
+
// last snapshot reported, and each thread's running turn id.
|
|
291
|
+
this.subThreadTokenTotals = new Map();
|
|
292
|
+
this.subThreadTokenBaselines = new Map();
|
|
293
|
+
this.subThreadTokensReported = new Map();
|
|
294
|
+
this.subThreadTurnIds = new Map();
|
|
279
295
|
this.currentTurnStatus = null;
|
|
280
296
|
this.currentTurnActivityAt = 0;
|
|
281
297
|
this.now = typeof options.now === "function" ? options.now : () => Date.now();
|
|
@@ -533,6 +549,28 @@ export class CodexAppServerSession extends EventEmitter {
|
|
|
533
549
|
beginTurnTokenAccounting() {
|
|
534
550
|
this.turnTokenBaseline = readTokenCounts(this.tokenUsage?.total);
|
|
535
551
|
this.turnTokensSeen = false;
|
|
552
|
+
// What the previous turn reported is counted; sub-agent spend after that
|
|
553
|
+
// (including between turns) goes to this one.
|
|
554
|
+
for (const [threadId, reported] of this.subThreadTokensReported) {
|
|
555
|
+
this.subThreadTokenBaselines.set(threadId, reported);
|
|
556
|
+
}
|
|
557
|
+
this.subThreadTokensReported = new Map();
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Track a spawned sub-agent thread's total. Its first update fixes its
|
|
561
|
+
* baseline: `total - last`, or the whole total when it is a replay (a turn id
|
|
562
|
+
* we never saw start), like the main thread's after thread/resume.
|
|
563
|
+
*/
|
|
564
|
+
noteSubThreadTokenUsage(threadId, { turnId, tokenUsage } = {}) {
|
|
565
|
+
const total = readTokenCounts(tokenUsage?.total);
|
|
566
|
+
if (!total) {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (!this.subThreadTokenBaselines.has(threadId)) {
|
|
570
|
+
const replay = !turnId || turnId !== this.subThreadTurnIds.get(threadId);
|
|
571
|
+
this.subThreadTokenBaselines.set(threadId, replay ? total : subtractTokenCounts(total, readTokenCounts(tokenUsage.last)));
|
|
572
|
+
}
|
|
573
|
+
this.subThreadTokenTotals.set(threadId, total);
|
|
536
574
|
}
|
|
537
575
|
/**
|
|
538
576
|
* Thread token usage plus `turnTotalTokens` (and its `turnInputTokens` /
|
|
@@ -545,9 +583,17 @@ export class CodexAppServerSession extends EventEmitter {
|
|
|
545
583
|
if (!this.tokenUsage) {
|
|
546
584
|
return null;
|
|
547
585
|
}
|
|
548
|
-
|
|
586
|
+
let turn = this.turnTokensSeen
|
|
549
587
|
? subtractTokenCounts(readTokenCounts(this.tokenUsage.total), this.turnTokenBaseline)
|
|
550
588
|
: null;
|
|
589
|
+
for (const [threadId, baseline] of this.subThreadTokenBaselines) {
|
|
590
|
+
const latest = this.subThreadTokenTotals.get(threadId);
|
|
591
|
+
const spent = subtractTokenCounts(latest, baseline);
|
|
592
|
+
if (spent) {
|
|
593
|
+
turn = turn ? addTokenCounts(turn, spent) : spent;
|
|
594
|
+
this.subThreadTokensReported.set(threadId, latest);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
551
597
|
return {
|
|
552
598
|
...this.tokenUsage,
|
|
553
599
|
...(turn
|
|
@@ -625,6 +671,10 @@ export class CodexAppServerSession extends EventEmitter {
|
|
|
625
671
|
this.tokenUsage = null;
|
|
626
672
|
this.turnTokenBaseline = null;
|
|
627
673
|
this.turnTokensSeen = false;
|
|
674
|
+
this.subThreadTokenTotals = new Map();
|
|
675
|
+
this.subThreadTokenBaselines = new Map();
|
|
676
|
+
this.subThreadTokensReported = new Map();
|
|
677
|
+
this.subThreadTurnIds = new Map();
|
|
628
678
|
this.booted = false;
|
|
629
679
|
this.bootPromise = null;
|
|
630
680
|
}
|
|
@@ -914,6 +964,12 @@ export class CodexAppServerSession extends EventEmitter {
|
|
|
914
964
|
const threadId = typeof params?.threadId === "string" ? params.threadId.trim() : "";
|
|
915
965
|
if (threadId && this.sessionId && threadId !== this.sessionId) {
|
|
916
966
|
this.touchTurnActivity();
|
|
967
|
+
if (method === "turn/started" && typeof params?.turn?.id === "string") {
|
|
968
|
+
this.subThreadTurnIds.set(threadId, params.turn.id);
|
|
969
|
+
}
|
|
970
|
+
else if (method === "thread/tokenUsage/updated") {
|
|
971
|
+
this.noteSubThreadTokenUsage(threadId, params);
|
|
972
|
+
}
|
|
917
973
|
return;
|
|
918
974
|
}
|
|
919
975
|
switch (method) {
|
|
@@ -157,6 +157,13 @@ function quoteShellArg(value) {
|
|
|
157
157
|
}
|
|
158
158
|
return `'${normalized.replace(/'/g, `'\\''`)}'`;
|
|
159
159
|
}
|
|
160
|
+
// Kimi Code reports a detached launch (`Agent`/`Bash` with run_in_background)
|
|
161
|
+
// with these metadata lines; the task's completion later wakes the main agent
|
|
162
|
+
// inside the same `kimi -p` process.
|
|
163
|
+
function isBackgroundTaskLaunch(content) {
|
|
164
|
+
const text = normalizeTextContent(content);
|
|
165
|
+
return /^task_id: /m.test(text) && /^automatic_notification: true$/m.test(text);
|
|
166
|
+
}
|
|
160
167
|
function normalizeTextContent(content) {
|
|
161
168
|
if (typeof content === "string") {
|
|
162
169
|
return content;
|
|
@@ -633,7 +640,9 @@ export class KimiPrintSession extends EventEmitter {
|
|
|
633
640
|
if (currentTurn.settled) {
|
|
634
641
|
return;
|
|
635
642
|
}
|
|
636
|
-
|
|
643
|
+
// `kimi -p` stays silent while it waits for background tasks it
|
|
644
|
+
// launched; killing it on idle would kill those tasks too.
|
|
645
|
+
if (Date.now() - lastActivityAt < this.turnDeadlineMs || currentTurn.backgroundTaskLaunched) {
|
|
637
646
|
scheduleTurnDeadline();
|
|
638
647
|
return;
|
|
639
648
|
}
|
|
@@ -700,6 +709,10 @@ export class KimiPrintSession extends EventEmitter {
|
|
|
700
709
|
}
|
|
701
710
|
if (role === "tool") {
|
|
702
711
|
noteToolFinished(currentTurn, payload.tool_call_id);
|
|
712
|
+
if (!currentTurn.backgroundTaskLaunched && isBackgroundTaskLaunch(payload.content)) {
|
|
713
|
+
currentTurn.backgroundTaskLaunched = true;
|
|
714
|
+
this.trace("background task launched; turn idle deadline suspended until kimi exits");
|
|
715
|
+
}
|
|
703
716
|
void this.emitWorkingStatus({
|
|
704
717
|
phase: "command_execution",
|
|
705
718
|
reply_in_progress: true,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@love-moon/ai-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/lovemoon-ai/conductor.git"
|
|
@@ -53,12 +53,12 @@
|
|
|
53
53
|
"zod": "^4.1.5"
|
|
54
54
|
},
|
|
55
55
|
"optionalDependencies": {
|
|
56
|
-
"@love-moon/chat-web": "^0.
|
|
56
|
+
"@love-moon/chat-web": "^0.17.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@types/node": "^22.10.2",
|
|
60
60
|
"tsx": "^4.20.6",
|
|
61
61
|
"typescript": "^5.6.3"
|
|
62
62
|
},
|
|
63
|
-
"gitCommitId": "
|
|
63
|
+
"gitCommitId": "2799bc0"
|
|
64
64
|
}
|