@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 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, resolveClaudeCommandForRoot, resolveClaudePermissionPolicy } from "./providers/claude-agent-sdk-session.js";
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, resolveClaudeCommandForRoot, resolveClaudePermissionPolicy, } from "./providers/claude-agent-sdk-session.js";
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
- downgradedFrom: string;
5
+ rootSandboxRequired: boolean;
6
6
  invalidMode: any;
7
7
  };
8
- export function resolveClaudeCommandForRoot(commandLine: any, env?: NodeJS.ProcessEnv): string;
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: 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
- downgradedFrom: string;
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
- async function* buildClaudeInput(promptText, media) {
19
- yield {
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
- /** Per-turn usage summed from streamed assistant messages (one entry per API response). */
36
- function sumStreamedUsage(usageByMessageId) {
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 null;
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 — being more permissive than
86
- // the gate keeps bypassPermissions and just moves the failure into claude,
87
- // which is the exact bug we are fixing.
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
- // Root installs (docker, CI, bare VPS) would otherwise fail every turn, so fall
99
- // back to the closest usable mode instead of dying.
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 requested = normalizePermissionMode(options.permissionMode);
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
- if (!isClaudeRootPermissionRestricted(env)) {
108
- return {
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
- allowDangerouslySkipPermissions: false,
119
- downgradedFrom: permissionMode === requested ? "" : requested,
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
- // The PTY / tool-preset path in the daemon hands a configured allow_cli_list
124
- // command straight to a shell, so it never reaches the session class above and
125
- // never sees resolveClaudePermissionPolicy. Rewrite the command string there
126
- // too, or a root box keeps failing in terminal tasks after the SDK path is fixed.
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() || !isClaudeRootPermissionRestricted(env)) {
130
- return command;
131
- }
132
- // Also drop a valueless `--permission-mode` (end of string, or immediately
133
- // followed by another flag). Without this the append branch below would emit
134
- // `--permission-mode --permission-mode acceptEdits`, and claude reads the
135
- // flag name as the mode and `acceptEdits` as a positional prompt.
136
- const stripped = command
137
- .replace(/\s*--dangerously-skip-permissions(?=\s|$)/g, "")
138
- .replace(/\s*--permission-mode=(?=\s|$)/g, "")
139
- .replace(/\s*--permission-mode(?=\s+-|\s*$)/g, "")
140
- .trim();
141
- const configuredMode = extractLongFlagFromCommandLine(stripped, "permission-mode");
142
- if (!configuredMode) {
143
- return `${stripped} --permission-mode ${ROOT_PERMISSION_MODE}`;
144
- }
145
- if (configuredMode === DEFAULT_PERMISSION_MODE) {
146
- return stripped.replace(/(--permission-mode[=\s]+)bypassPermissions(?=\s|$)/, `$1${ROOT_PERMISSION_MODE}`);
147
- }
148
- // Any other configured mode is already root-safe; leave it verbatim.
149
- return stripped;
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
- if (this.permissionPolicy.downgradedFrom) {
316
- this.trace(`permission mode ${this.permissionPolicy.downgradedFrom} -> ${this.permissionPolicy.permissionMode} `
317
- + `(claude rejects ${this.permissionPolicy.downgradedFrom} as root; set IS_SANDBOX=1 to keep it)`);
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
- const text = extractAssistantText(message.message);
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
- const abortController = new AbortController();
916
- const currentTurn = {
917
- abortController,
918
- suppressReply,
919
- compactMetadata: null,
920
- compactError: "",
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 closeGuard = this.createCloseGuard(() => {
933
- abortController.abort();
934
- currentTurn.query?.close?.();
935
- });
936
- const turnTimeoutGuard = this.createTurnTimeoutGuard(() => {
937
- abortController.abort();
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
- const query = sdkModule.query({
951
- prompt: media.length ? buildClaudeInput(effectivePrompt, media) : effectivePrompt,
952
- options: this.buildSdkOptions(abortController),
953
- });
954
- currentTurn.query = query;
955
- const resultMessage = await Promise.race([
956
- (async () => {
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
- closeGuard.promise,
963
- turnTimeoutGuard.promise,
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.usage ? { ...resultMessage.usage } : null,
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.usage ? { ...resultMessage.usage } : null,
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 usage streamed so far.
1031
- const usage = error?.usage ?? sumStreamedUsage(currentTurn.usageByMessageId);
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
- try {
1067
- currentTurn.query?.close?.();
1068
- }
1069
- catch {
1070
- // best effort
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
- await this.interruptCurrentTurn();
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
- const turn = this.turnTokensSeen
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
- if (Date.now() - lastActivityAt < this.turnDeadlineMs) {
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.15.2",
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.15.2"
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": "f795a61"
63
+ "gitCommitId": "2799bc0"
64
64
  }