@love-moon/ai-sdk 0.16.0 → 0.17.1

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,45 @@
1
1
  # @love-moon/ai-sdk
2
2
 
3
+ ## 0.17.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 7e92c0f: When a task's model quota runs out ("You've hit your session limit · resets
8
+ 1:20am"), fire no longer stops there. It reads the reset time, schedules a
9
+ one-off Conductor scheduled message to continue a minute or few after the
10
+ reset, and posts a single notice. Messages that arrive while waiting are queued
11
+ and handed to the resume turn together instead of each failing with another
12
+ "处理失败". The resume shows up in the task's scheduled-message list, where it
13
+ can be canceled. This also covers a quota hit while Claude works on its own
14
+ between user turns: ai-sdk now reports such a background turn that ends in an
15
+ error result (or a process exit after it) as a failed turn.
16
+
17
+ ## 0.17.0
18
+
19
+ ### Patch Changes
20
+
21
+ - d088b31: Claude tasks keep one Claude process per session, with its input left open
22
+ between turns, instead of starting a new one-shot process for every turn.
23
+ Background subagents now run to completion: before, claude killed them 10
24
+ minutes after the main agent's turn ended, so long multi-agent tasks went quiet
25
+ until the user sent another message. When they finish, Claude's follow-up reply
26
+ is posted to the chat without waiting for a user message. Subagent narration is
27
+ no longer posted as a task reply.
28
+ - 79ca552: Claude no longer switches to `acceptEdits` mode when the daemon runs as root.
29
+ That fallback looked like it worked, but in a headless task it silently refused
30
+ every shell command (ssh, rsync, ...) and every read outside the project. Claude
31
+ now keeps `bypassPermissions` everywhere. As root without `IS_SANDBOX=1`, the
32
+ chat shows a warning right after "session started" telling you to add
33
+ `envs: { IS_SANDBOX: "1" }` to the conductor config (or run the daemon as a
34
+ regular user). Terminal tasks print the same hint. An `IS_SANDBOX` set in config
35
+ `envs` now also reaches terminal (PTY) claude tasks. `conductor config` always
36
+ writes `--dangerously-skip-permissions` for claude.
37
+ - ccbfeeb: Kimi Code tasks no longer kill background subagents after 12 minutes. `kimi -p`
38
+ stays silent while it waits for a task it launched with `run_in_background`,
39
+ and the session's idle deadline used to treat that silence as a stuck turn and
40
+ terminate kimi together with the task. Once a turn launches a background task,
41
+ the deadline now waits for kimi to exit on its own.
42
+
3
43
  ## 0.16.0
4
44
 
5
45
  ### Patch Changes
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)) {
@@ -52,11 +118,27 @@ function sumStreamedUsage(usageByMessageId, base = null) {
52
118
  return total;
53
119
  }
54
120
  /**
55
- * A finished turn's usage. A turn that waits on background tasks emits one result per
56
- * segment, and `usage` covers only the last one; `modelUsage` accumulates every API call
57
- * the query's process made (all segments and subagents), so prefer its sum.
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.
58
125
  */
59
- function resultUsage(resultMessage) {
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) {
60
142
  const total = {};
61
143
  for (const entry of Object.values(resultMessage.modelUsage || {})) {
62
144
  for (const [from, to] of [
@@ -71,10 +153,7 @@ function resultUsage(resultMessage) {
71
153
  }
72
154
  }
73
155
  }
74
- if (Object.keys(total).length) {
75
- return total;
76
- }
77
- return resultMessage.usage ? { ...resultMessage.usage } : null;
156
+ return Object.keys(total).length ? total : null;
78
157
  }
79
158
  function normalizeClaudeBackend(backend) {
80
159
  const normalized = String(backend || "").trim().toLowerCase();
@@ -110,71 +189,76 @@ function normalizePermissionMode(value) {
110
189
  // Two different env idioms in that one line, and both are load-bearing:
111
190
  // IS_SANDBOX is a *strict* `=== "1"` compare, while CLAUDE_CODE_BUBBLEWRAP goes
112
191
  // through claude's loose truthy parser (`1|true|yes|on`, trimmed+lowercased).
113
- // Do not "helpfully" accept IS_SANDBOX=true here — being more permissive than
114
- // the gate keeps bypassPermissions and just moves the failure into claude,
115
- // 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.
116
198
  export function isClaudeRootPermissionRestricted(env = process.env) {
117
199
  if (typeof process.getuid !== "function" || process.getuid() !== 0) {
118
200
  return false;
119
201
  }
120
- if (env?.IS_SANDBOX === "1") {
202
+ if (String(env?.IS_SANDBOX ?? "") === "1") {
121
203
  return false;
122
204
  }
123
205
  const bubblewrap = String(env?.CLAUDE_CODE_BUBBLEWRAP ?? "").trim().toLowerCase();
124
206
  return !["1", "true", "yes", "on"].includes(bubblewrap);
125
207
  }
126
- // Root installs (docker, CI, bare VPS) would otherwise fail every turn, so fall
127
- // 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).
128
214
  export function resolveClaudePermissionPolicy(options = {}, env = process.env) {
129
- const requested = normalizePermissionMode(options.permissionMode);
215
+ const permissionMode = normalizePermissionMode(options.permissionMode);
130
216
  // A configured value we don't recognize still falls back to the default (a
131
217
  // typo must never take the session down), but the caller reports it so the
132
218
  // user finds out their config line is doing nothing.
133
219
  const rawMode = typeof options.permissionMode === "string" ? options.permissionMode.trim() : "";
134
220
  const invalidMode = rawMode && !PERMISSION_MODES.has(rawMode) ? rawMode : "";
135
- if (!isClaudeRootPermissionRestricted(env)) {
136
- return {
137
- permissionMode: requested,
138
- allowDangerouslySkipPermissions: requested === DEFAULT_PERMISSION_MODE || options.allowDangerouslySkipPermissions === true,
139
- downgradedFrom: "",
140
- invalidMode,
141
- };
142
- }
143
- const permissionMode = requested === DEFAULT_PERMISSION_MODE ? ROOT_PERMISSION_MODE : requested;
221
+ const rootRestricted = isClaudeRootPermissionRestricted(env);
222
+ const bypass = permissionMode === DEFAULT_PERMISSION_MODE;
144
223
  return {
145
224
  permissionMode,
146
- allowDangerouslySkipPermissions: false,
147
- 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,
148
229
  invalidMode,
149
230
  };
150
231
  }
151
- // The PTY / tool-preset path in the daemon hands a configured allow_cli_list
152
- // command straight to a shell, so it never reaches the session class above and
153
- // never sees resolveClaudePermissionPolicy. Rewrite the command string there
154
- // too, or a root box keeps failing in terminal tasks after the SDK path is fixed.
155
- 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) {
156
236
  const command = typeof commandLine === "string" ? commandLine : "";
157
- if (!command.trim() || !isClaudeRootPermissionRestricted(env)) {
158
- return command;
159
- }
160
- // Also drop a valueless `--permission-mode` (end of string, or immediately
161
- // followed by another flag). Without this the append branch below would emit
162
- // `--permission-mode --permission-mode acceptEdits`, and claude reads the
163
- // flag name as the mode and `acceptEdits` as a positional prompt.
164
- const stripped = command
165
- .replace(/\s*--dangerously-skip-permissions(?=\s|$)/g, "")
166
- .replace(/\s*--permission-mode=(?=\s|$)/g, "")
167
- .replace(/\s*--permission-mode(?=\s+-|\s*$)/g, "")
168
- .trim();
169
- const configuredMode = extractLongFlagFromCommandLine(stripped, "permission-mode");
170
- if (!configuredMode) {
171
- return `${stripped} --permission-mode ${ROOT_PERMISSION_MODE}`;
172
- }
173
- if (configuredMode === DEFAULT_PERMISSION_MODE) {
174
- return stripped.replace(/(--permission-mode[=\s]+)bypassPermissions(?=\s|$)/, `$1${ROOT_PERMISSION_MODE}`);
175
- }
176
- // Any other configured mode is already root-safe; leave it verbatim.
177
- 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");
178
262
  }
179
263
  function normalizeText(value) {
180
264
  return typeof value === "string" ? value : "";
@@ -315,6 +399,7 @@ export class ClaudeAgentSdkSession extends EventEmitter {
315
399
  this.lastReplyTarget = "";
316
400
  this.manualResumeReady = Boolean(this.sessionId);
317
401
  this.currentTurn = null;
402
+ this.liveQuery = null;
318
403
  this.lastResult = null;
319
404
  this.rateLimitInfo = null;
320
405
  this.currentTurnStatus = null;
@@ -340,9 +425,15 @@ export class ClaudeAgentSdkSession extends EventEmitter {
340
425
  + `using ${this.permissionPolicy.permissionMode} instead `
341
426
  + `(valid: ${[...PERMISSION_MODES].join(", ")})`);
342
427
  }
343
- if (this.permissionPolicy.downgradedFrom) {
344
- this.trace(`permission mode ${this.permissionPolicy.downgradedFrom} -> ${this.permissionPolicy.permissionMode} `
345
- + `(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
+ }));
346
437
  }
347
438
  }
348
439
  writeLog(message) {
@@ -381,6 +472,7 @@ export class ClaudeAgentSdkSession extends EventEmitter {
381
472
  : null,
382
473
  currentTurnStatus: this.getCurrentTurnStatus(),
383
474
  capabilities: this.getCapabilities(),
475
+ notices: [...this.notices],
384
476
  };
385
477
  }
386
478
  getSessionInfo() {
@@ -461,6 +553,8 @@ export class ClaudeAgentSdkSession extends EventEmitter {
461
553
  status_done_line: payload?.status_done_line,
462
554
  reply_preview: payload?.reply_preview,
463
555
  thread_id: this.sessionId || undefined,
556
+ // A turn claude ran on its own, between user turns (see dispatchLiveMessage).
557
+ ...(payload?.background === true ? { background: true } : {}),
464
558
  };
465
559
  this.updateCurrentTurnStatus(normalized);
466
560
  const snapshot = this.getCurrentTurnStatus();
@@ -839,7 +933,8 @@ export class ClaudeAgentSdkSession extends EventEmitter {
839
933
  noteToolStarted(currentTurn, block.id, block.name, block.input);
840
934
  }
841
935
  }
842
- const text = extractAssistantText(message.message);
936
+ // Subagent narration (parent_tool_use_id set) is not a reply to the user.
937
+ const text = message.parent_tool_use_id ? "" : extractAssistantText(message.message);
843
938
  if (!text) {
844
939
  return;
845
940
  }
@@ -893,6 +988,26 @@ export class ClaudeAgentSdkSession extends EventEmitter {
893
988
  if (!currentTurn) {
894
989
  return false;
895
990
  }
991
+ const { liveQuery } = currentTurn;
992
+ if (liveQuery) {
993
+ // The interrupted turn reports its own result; tear the process down only
994
+ // if it does not, so a stuck CLI cannot hold the turn open.
995
+ try {
996
+ await liveQuery.query.interrupt?.();
997
+ }
998
+ catch {
999
+ // best effort
1000
+ }
1001
+ if (this.currentTurn === currentTurn) {
1002
+ const timer = setTimeout(() => {
1003
+ if (this.currentTurn === currentTurn) {
1004
+ this.closeLiveQuery(liveQuery);
1005
+ }
1006
+ }, INTERRUPT_GRACE_MS);
1007
+ timer.unref?.();
1008
+ }
1009
+ return true;
1010
+ }
896
1011
  try {
897
1012
  await currentTurn.query?.interrupt?.();
898
1013
  }
@@ -913,6 +1028,168 @@ export class ClaudeAgentSdkSession extends EventEmitter {
913
1028
  }
914
1029
  return true;
915
1030
  }
1031
+ createTurnState({ suppressReply = false, onProgress = null } = {}) {
1032
+ return {
1033
+ abortController: null,
1034
+ liveQuery: null,
1035
+ onProgress,
1036
+ settle: null,
1037
+ suppressReply,
1038
+ compactMetadata: null,
1039
+ compactError: "",
1040
+ conversationReset: "",
1041
+ emittedAssistantMessage: false,
1042
+ fullText: "",
1043
+ items: [],
1044
+ query: null,
1045
+ resultMessage: null,
1046
+ terminalWorkingStatusEmitted: false,
1047
+ usageBase: null,
1048
+ usageByMessageId: new Map(),
1049
+ uuid: "",
1050
+ };
1051
+ }
1052
+ /**
1053
+ * One claude process per session, fed through a prompt stream that stays
1054
+ * open between turns. A one-shot string prompt closes claude's input at once,
1055
+ * and claude then kills background subagents 10 minutes after the main
1056
+ * agent's turn ends (CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS). Kept open, the
1057
+ * subagents run to completion and claude starts its own follow-up turn on
1058
+ * their notifications.
1059
+ */
1060
+ ensureLiveQuery(sdkModule) {
1061
+ if (this.liveQuery && !this.liveQuery.ended) {
1062
+ return this.liveQuery;
1063
+ }
1064
+ const abortController = new AbortController();
1065
+ const liveQuery = {
1066
+ abortController,
1067
+ backgroundTurn: null,
1068
+ ended: false,
1069
+ input: createInputQueue(),
1070
+ query: null,
1071
+ usageBase: null,
1072
+ };
1073
+ liveQuery.query = sdkModule.query({
1074
+ prompt: liveQuery.input,
1075
+ options: this.buildSdkOptions(abortController),
1076
+ });
1077
+ this.liveQuery = liveQuery;
1078
+ void this.pumpLiveQuery(liveQuery);
1079
+ return liveQuery;
1080
+ }
1081
+ closeLiveQuery(liveQuery) {
1082
+ liveQuery.ended = true;
1083
+ if (this.liveQuery === liveQuery) {
1084
+ this.liveQuery = null;
1085
+ }
1086
+ liveQuery.input.close();
1087
+ try {
1088
+ liveQuery.abortController.abort();
1089
+ }
1090
+ catch {
1091
+ // best effort
1092
+ }
1093
+ try {
1094
+ liveQuery.query?.close?.();
1095
+ }
1096
+ catch {
1097
+ // best effort
1098
+ }
1099
+ }
1100
+ settleTurn(turn, error, resultMessage = null) {
1101
+ const settle = turn.settle;
1102
+ turn.settle = null;
1103
+ settle?.(error, resultMessage);
1104
+ }
1105
+ async pumpLiveQuery(liveQuery) {
1106
+ let failure = null;
1107
+ try {
1108
+ for await (const message of liveQuery.query) {
1109
+ await this.dispatchLiveMessage(liveQuery, message);
1110
+ }
1111
+ }
1112
+ catch (error) {
1113
+ failure = error;
1114
+ }
1115
+ this.closeLiveQuery(liveQuery);
1116
+ const turn = this.currentTurn;
1117
+ if (turn?.liveQuery === liveQuery && turn.settle) {
1118
+ this.settleTurn(turn, failure, turn.resultMessage);
1119
+ }
1120
+ else if (failure && !this.closeRequested) {
1121
+ this.trace(`claude process ended between turns: ${failure?.message || failure}`);
1122
+ }
1123
+ const backgroundTurn = liveQuery.backgroundTurn;
1124
+ liveQuery.backgroundTurn = null;
1125
+ if (backgroundTurn) {
1126
+ await this.finishBackgroundTurn(backgroundTurn, null, failure);
1127
+ }
1128
+ else if (failure && !this.closeRequested && !this.currentTurn) {
1129
+ // The process died after a background turn's result, e.g. an error
1130
+ // result (quota hit) makes claude exit right after reporting it. No turn
1131
+ // is left to own the failure, so report it as a background one.
1132
+ await this.emitWorkingStatus({
1133
+ phase: "turn_failed",
1134
+ status_done_line: failure?.message || String(failure),
1135
+ reply_in_progress: false,
1136
+ background: true,
1137
+ });
1138
+ }
1139
+ }
1140
+ /**
1141
+ * Routes the long-lived query's output. A result ends the pending turn when it
1142
+ * echoes that turn's user message uuid (or echoes none while no follow-up turn
1143
+ * of claude's own is running); everything else is a background turn, e.g.
1144
+ * claude answering a finished subagent's notification between user turns.
1145
+ */
1146
+ async dispatchLiveMessage(liveQuery, message) {
1147
+ const turn = this.currentTurn?.liveQuery === liveQuery && this.currentTurn.settle ? this.currentTurn : null;
1148
+ const backgroundTurn = liveQuery.backgroundTurn;
1149
+ if (message?.type !== "result") {
1150
+ const target = backgroundTurn || turn || (liveQuery.backgroundTurn = this.createTurnState());
1151
+ await this.handleSdkMessage(message, target, { onProgress: target.onProgress });
1152
+ return;
1153
+ }
1154
+ liveQuery.backgroundTurn = null;
1155
+ const uuids = resultUserMessageUuids(message);
1156
+ if (turn && (uuids.length ? uuids.includes(turn.uuid) : !backgroundTurn)) {
1157
+ // A background turn that folded the user's message in ends as this turn.
1158
+ if (backgroundTurn?.emittedAssistantMessage) {
1159
+ turn.emittedAssistantMessage = true;
1160
+ }
1161
+ await this.handleSdkMessage(message, turn, { onProgress: turn.onProgress });
1162
+ liveQuery.usageBase = modelUsageTotal(message) || liveQuery.usageBase;
1163
+ this.settleTurn(turn, null, message);
1164
+ return;
1165
+ }
1166
+ const target = backgroundTurn || this.createTurnState();
1167
+ await this.handleSdkMessage(message, target, {});
1168
+ await this.finishBackgroundTurn(target, message);
1169
+ }
1170
+ async finishBackgroundTurn(backgroundTurn, resultMessage, failure = null) {
1171
+ // An error result (e.g. "You've hit your session limit") can carry subtype
1172
+ // "success"; `is_error` is what marks it.
1173
+ const errorResult = Boolean(resultMessage && (resultMessage.subtype !== "success" || resultMessage.is_error === true));
1174
+ const failed = Boolean(failure) || errorResult;
1175
+ const text = normalizeText(resultMessage?.result);
1176
+ // An error result's text still reaches the chat, as it always has.
1177
+ if (!failure && resultMessage?.subtype === "success" && text && !backgroundTurn.emittedAssistantMessage) {
1178
+ await this.emitAssistantMessage(text);
1179
+ }
1180
+ // A pending user turn owns the working status.
1181
+ if (this.currentTurn) {
1182
+ return;
1183
+ }
1184
+ await this.emitTerminalWorkingStatus(backgroundTurn, failed
1185
+ ? {
1186
+ phase: "turn_failed",
1187
+ status_done_line: failure?.message ||
1188
+ (resultMessage?.is_error === true && text ? text : extractResultErrorMessage(resultMessage)),
1189
+ background: true,
1190
+ }
1191
+ : { phase: "turn_completed", status_done_line: "claude finished", background: true });
1192
+ }
916
1193
  async runTurn(promptText, { useInitialImages = false, media: mediaInput, contextFiles, onProgress = null, jsonSchema = null, suppressReply = false } = {}) {
917
1194
  if (this.closeRequested) {
918
1195
  throw this.createSessionClosedError();
@@ -942,31 +1219,24 @@ export class ClaudeAgentSdkSession extends EventEmitter {
942
1219
  if (!suppressReply) {
943
1220
  this.history.push({ role: "user", content: promptText });
944
1221
  }
945
- const abortController = new AbortController();
946
- const currentTurn = {
947
- abortController,
948
- suppressReply,
949
- compactMetadata: null,
950
- compactError: "",
951
- conversationReset: "",
952
- emittedAssistantMessage: false,
953
- fullText: "",
954
- items: [],
955
- query: null,
956
- resultMessage: null,
957
- terminalWorkingStatusEmitted: false,
958
- usageByMessageId: new Map(),
959
- };
1222
+ // Structured output is fixed when the process starts, so a jsonSchema turn
1223
+ // runs on a one-shot query of its own.
1224
+ const oneShot = Boolean(jsonSchema && typeof jsonSchema === "object");
1225
+ const abortController = oneShot ? new AbortController() : null;
1226
+ const currentTurn = this.createTurnState({ suppressReply, onProgress });
1227
+ currentTurn.abortController = abortController;
960
1228
  this.currentTurn = currentTurn;
961
1229
  this.markTurnStartedStatus();
962
- const closeGuard = this.createCloseGuard(() => {
963
- abortController.abort();
964
- currentTurn.query?.close?.();
965
- });
966
- const turnTimeoutGuard = this.createTurnTimeoutGuard(() => {
967
- abortController.abort();
1230
+ const stopTurn = () => {
1231
+ if (currentTurn.liveQuery) {
1232
+ this.closeLiveQuery(currentTurn.liveQuery);
1233
+ return;
1234
+ }
1235
+ abortController?.abort();
968
1236
  currentTurn.query?.close?.();
969
- });
1237
+ };
1238
+ const closeGuard = this.createCloseGuard(stopTurn);
1239
+ const turnTimeoutGuard = this.createTurnTimeoutGuard(stopTurn);
970
1240
  const previousOutputFormat = this.options.outputFormat;
971
1241
  if (jsonSchema && typeof jsonSchema === "object") {
972
1242
  this.options.outputFormat = { type: "json_schema", schema: jsonSchema };
@@ -977,28 +1247,45 @@ export class ClaudeAgentSdkSession extends EventEmitter {
977
1247
  reply_in_progress: true,
978
1248
  status_line: "claude is working",
979
1249
  }, onProgress);
980
- const query = sdkModule.query({
981
- prompt: media.length ? buildClaudeInput(effectivePrompt, media) : effectivePrompt,
982
- options: this.buildSdkOptions(abortController),
983
- });
984
- currentTurn.query = query;
985
- const resultMessage = await Promise.race([
986
- (async () => {
1250
+ let turnResult;
1251
+ if (oneShot) {
1252
+ const query = sdkModule.query({
1253
+ prompt: media.length ? buildClaudeInput(effectivePrompt, media) : effectivePrompt,
1254
+ options: this.buildSdkOptions(abortController),
1255
+ });
1256
+ currentTurn.query = query;
1257
+ turnResult = (async () => {
987
1258
  for await (const message of query) {
988
1259
  await this.handleSdkMessage(message, currentTurn, { onProgress });
989
1260
  }
990
1261
  return currentTurn.resultMessage;
991
- })(),
992
- closeGuard.promise,
993
- turnTimeoutGuard.promise,
994
- ]);
1262
+ })();
1263
+ }
1264
+ else {
1265
+ turnResult = new Promise((resolve, reject) => {
1266
+ currentTurn.settle = (error, message) => (error ? reject(error) : resolve(message));
1267
+ });
1268
+ const liveQuery = this.ensureLiveQuery(sdkModule);
1269
+ currentTurn.liveQuery = liveQuery;
1270
+ currentTurn.query = liveQuery.query;
1271
+ currentTurn.usageBase = liveQuery.usageBase;
1272
+ currentTurn.uuid = randomUUID();
1273
+ if (!liveQuery.input.push(buildClaudeUserMessage(effectivePrompt, media, currentTurn.uuid))) {
1274
+ this.settleTurn(currentTurn, createTurnError("Claude process input is closed", { reason: "process_exited" }));
1275
+ }
1276
+ }
1277
+ turnResult.catch(() => { });
1278
+ const resultMessage = await Promise.race([turnResult, closeGuard.promise, turnTimeoutGuard.promise]);
995
1279
  if (!resultMessage) {
996
1280
  throw createTurnError("Claude query ended without a result message", {
997
1281
  reason: "missing_result",
998
1282
  });
999
1283
  }
1000
- if (resultMessage.subtype !== "success") {
1001
- const errorMessage = extractResultErrorMessage(resultMessage);
1284
+ // An error result (e.g. "You've hit your session limit") can carry subtype
1285
+ // "success"; `is_error` is what marks it.
1286
+ if (resultMessage.subtype !== "success" || resultMessage.is_error === true) {
1287
+ const errorText = resultMessage.is_error === true ? normalizeText(resultMessage.result) : "";
1288
+ const errorMessage = errorText || extractResultErrorMessage(resultMessage);
1002
1289
  this.maybeEmitAuthRequired(errorMessage, errorMessage);
1003
1290
  await this.emitTerminalWorkingStatus(currentTurn, {
1004
1291
  phase: "turn_failed",
@@ -1012,7 +1299,7 @@ export class ClaudeAgentSdkSession extends EventEmitter {
1012
1299
  ? [...resultMessage.permission_denials]
1013
1300
  : [],
1014
1301
  // A failed turn still spent tokens.
1015
- usage: resultUsage(resultMessage),
1302
+ usage: resultUsage(resultMessage, currentTurn.usageBase),
1016
1303
  });
1017
1304
  }
1018
1305
  const structuredOutput = jsonSchema && resultMessage.structured_output && typeof resultMessage.structured_output === "object"
@@ -1037,7 +1324,7 @@ export class ClaudeAgentSdkSession extends EventEmitter {
1037
1324
  }, onProgress);
1038
1325
  return {
1039
1326
  text: responseText,
1040
- usage: resultUsage(resultMessage),
1327
+ usage: resultUsage(resultMessage, currentTurn.usageBase),
1041
1328
  items: currentTurn.items,
1042
1329
  events: [],
1043
1330
  provider: this.backend,
@@ -1059,7 +1346,7 @@ export class ClaudeAgentSdkSession extends EventEmitter {
1059
1346
  // A failed turn still spent tokens; an interrupted query ends without a
1060
1347
  // (final) result, so fall back to the last result plus what streamed since.
1061
1348
  const usage = error?.usage ??
1062
- sumStreamedUsage(currentTurn.usageByMessageId, currentTurn.resultMessage ? resultUsage(currentTurn.resultMessage) : null);
1349
+ sumStreamedUsage(currentTurn.usageByMessageId, currentTurn.resultMessage ? resultUsage(currentTurn.resultMessage, currentTurn.usageBase) : null);
1063
1350
  if (error?.reason === "turn_timeout") {
1064
1351
  await this.interruptCurrentTurn();
1065
1352
  }
@@ -1094,11 +1381,14 @@ export class ClaudeAgentSdkSession extends EventEmitter {
1094
1381
  }
1095
1382
  closeGuard.cleanup();
1096
1383
  turnTimeoutGuard.cleanup();
1097
- try {
1098
- currentTurn.query?.close?.();
1099
- }
1100
- catch {
1101
- // best effort
1384
+ currentTurn.settle = null;
1385
+ if (oneShot) {
1386
+ try {
1387
+ currentTurn.query?.close?.();
1388
+ }
1389
+ catch {
1390
+ // best effort
1391
+ }
1102
1392
  }
1103
1393
  }
1104
1394
  }
@@ -1272,7 +1562,12 @@ export class ClaudeAgentSdkSession extends EventEmitter {
1272
1562
  }
1273
1563
  this.closeRequested = true;
1274
1564
  this.flushCloseWaiters();
1275
- await this.interruptCurrentTurn();
1565
+ if (!this.currentTurn?.liveQuery) {
1566
+ await this.interruptCurrentTurn();
1567
+ }
1568
+ if (this.liveQuery) {
1569
+ this.closeLiveQuery(this.liveQuery);
1570
+ }
1276
1571
  this.closed = true;
1277
1572
  }
1278
1573
  }
@@ -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.16.0",
3
+ "version": "0.17.1",
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.16.0"
56
+ "@love-moon/chat-web": "^0.17.1"
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": "77239b9"
63
+ "gitCommitId": "e3dbbad"
64
64
  }