pikiloom 0.4.43 → 0.4.44

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.
@@ -243,7 +243,15 @@ export async function kernelStream(opts) {
243
243
  const u = result.usage || snapshot.usage || {};
244
244
  return {
245
245
  ok: !!result.ok,
246
- message: (result.text || snapshot.text || '').trim() || (finalError ?? '(no output)'),
246
+ // Empty-text fallback (mirrors the legacy driver): a clean turn with no prose reads
247
+ // "(no textual response)", not "(no output)" (which the kernel path used to show for every
248
+ // textless turn — including a background-launch turn that intentionally ends without prose).
249
+ // A turn that settled while work keeps running in the background gets an explicit note.
250
+ message: (result.text || snapshot.text || '').trim()
251
+ || finalError
252
+ || (result.stopReason === 'background'
253
+ ? 'Work is now running in the background — I’ll report back here once it finishes. (Send any message to check on it.)'
254
+ : result.ok ? '(no textual response)' : '(no output)'),
247
255
  thinking: (result.reasoning || snapshot.reasoning || '').trim() || null,
248
256
  plan: toPikiloomPlan(snapshot.plan),
249
257
  sessionId: finalSessionId,
@@ -3,6 +3,8 @@ import fs from 'node:fs';
3
3
  import { fmtTokens, fmtUptime, fmtBytes } from './bot.js';
4
4
  import { getProjectSkillPaths, normalizeClaudeModelId, sessionListDisplayTitle, listAllMcpExtensions, listSkills as listAllSkills, } from '../agent/index.js';
5
5
  import { getDriver } from '../agent/driver.js';
6
+ import { accountAgentSupported, listAccounts, getActiveAccountId, probeAccountUsage, getCachedAccountUsage, } from '../agent/accounts.js';
7
+ import { withTimeoutFallback } from '../core/utils.js';
6
8
  import { effortOptionsFor } from '../core/config/runtime-config.js';
7
9
  import { getActiveProfile, getProvider } from '../model/index.js';
8
10
  import { buildWelcomeIntro, buildSkillCommandName, indexSkillsByCommand, SKILL_CMD_PREFIX } from './menu.js';
@@ -421,16 +423,53 @@ export async function getModelsListData(bot, chatId) {
421
423
  effort: buildEffortData(bot, cs.agent, currentModel),
422
424
  };
423
425
  }
426
+ // On-demand usage probe per agent. Bounded so `/status` stays snappy: each live probe falls
427
+ // back to the driver's cached value (and each account to its last-probed value) on timeout.
428
+ const USAGE_PROBE_TIMEOUT_MS = 2_500;
429
+ // Mirror the dashboard's top-right usage view for IM `/status`: every installed agent's usage,
430
+ // and for account-capable agents each account's own quota with the active one marked. The plain
431
+ // per-agent `getUsageLive` only ever reads the keychain default login, so without the per-account
432
+ // probes `/status` would always report the default account's numbers, never the selected one.
433
+ export async function getUsageOverview(bot, chatId) {
434
+ const currentAgent = bot.chat(chatId).agent;
435
+ const installed = bot.fetchAgents().agents.filter(a => a.installed);
436
+ const agents = await Promise.all(installed.map(async (info) => {
437
+ const agent = info.agent;
438
+ const model = bot.modelForAgent(agent);
439
+ const driver = getDriver(agent);
440
+ const cached = driver.getUsage({ agent, model });
441
+ const usage = driver.getUsageLive
442
+ ? await withTimeoutFallback(driver.getUsageLive({ agent, model }).catch(() => cached), USAGE_PROBE_TIMEOUT_MS, cached)
443
+ : cached;
444
+ const accounts = [];
445
+ if (accountAgentSupported(agent)) {
446
+ const recs = listAccounts(agent);
447
+ if (recs.length) {
448
+ const activeId = getActiveAccountId(agent);
449
+ const usages = await Promise.all(recs.map(rec => withTimeoutFallback(probeAccountUsage(agent, rec.id).catch(() => getCachedAccountUsage(agent, rec.id)), USAGE_PROBE_TIMEOUT_MS, getCachedAccountUsage(agent, rec.id))));
450
+ recs.forEach((rec, i) => accounts.push({
451
+ id: rec.id, label: rec.label, active: rec.id === activeId, usage: usages[i],
452
+ }));
453
+ accounts.push({ id: null, label: 'Default login', active: activeId === null, usage });
454
+ }
455
+ }
456
+ return { agent, label: agentDisplayLabel(agent), isCurrent: agent === currentAgent, usage, accounts };
457
+ }));
458
+ return { agents };
459
+ }
424
460
  export async function getStatusDataAsync(bot, chatId) {
425
461
  const d = bot.getStatusData(chatId);
426
- const driver = getDriver(d.agent);
427
- const usage = driver.getUsageLive
428
- ? await driver.getUsageLive({ agent: d.agent, model: d.model }).catch(() => d.usage)
429
- : d.usage;
462
+ const usageOverview = await getUsageOverview(bot, chatId);
463
+ // Surface the identity that is actually selected: the active account's quota when one is
464
+ // bound, else the agent's native/default-login usage (matches the dashboard ring).
465
+ const current = usageOverview.agents.find(a => a.isCurrent);
466
+ const activeAccount = current?.accounts.find(a => a.active);
467
+ const usage = (activeAccount?.usage?.ok ? activeAccount.usage : null) ?? current?.usage ?? d.usage;
430
468
  return {
431
469
  ...d,
432
470
  running: d.running ? { prompt: d.running.prompt, startedAt: d.running.startedAt } : null,
433
471
  usage,
472
+ usageOverview,
434
473
  git: readGitStatus(d.workdir),
435
474
  };
436
475
  }
@@ -93,35 +93,41 @@ export function trimActivityForPreview(text, maxChars = 900) {
93
93
  return tail.join('\n');
94
94
  return [ellipsis, ...tail].join('\n');
95
95
  }
96
- function rawUsageLine(parts) {
97
- return parts.filter(part => !!part && String(part).trim()).join(' ');
96
+ // Compact "5h 42% · 7d 18%" summary of a usage snapshot (matches the dashboard bars and the
97
+ // `/agents` account rows), or a short reason when no quota numbers are available.
98
+ export function formatUsageWindowsSummary(usage) {
99
+ if (!usage || !usage.ok)
100
+ return 'unavailable';
101
+ const parts = usage.windows
102
+ .filter(w => w.usedPercent != null)
103
+ .map(w => `${w.label} ${Math.round(w.usedPercent)}%`);
104
+ if (parts.length)
105
+ return parts.join(' · ');
106
+ return usage.status ? `status=${usage.status}` : 'no data';
98
107
  }
99
- export function buildProviderUsageLines(usage) {
108
+ // Multi-agent / multi-account usage block for `/status`, mirroring the dashboard's top-right
109
+ // view: each installed agent that has usage, and for account-capable agents every account's own
110
+ // quota with the active one marked (●). Returns [] when nothing has usage so callers can skip the
111
+ // section entirely. Leading blank + bold header follow the same shape callers already render.
112
+ export function buildUsageOverviewLines(overview) {
113
+ const shown = overview.agents.filter(a => (a.usage?.ok && a.usage.windows.length) || a.accounts.length);
114
+ if (!shown.length)
115
+ return [];
100
116
  const lines = [
101
117
  { text: '', bold: false },
102
118
  { text: 'Provider Usage', bold: true },
103
119
  ];
104
- if (!usage.ok) {
105
- lines.push({ text: ` Unavailable: ${usage.error || 'No recent usage data found.'}` });
106
- return lines;
107
- }
108
- if (usage.capturedAt) {
109
- const capturedAtMs = Date.parse(usage.capturedAt);
110
- if (Number.isFinite(capturedAtMs)) {
111
- lines.push({ text: ` Updated: ${fmtUptime(Math.max(0, Date.now() - capturedAtMs))} ago` });
120
+ for (const agent of shown) {
121
+ lines.push({ text: `${agent.label}${agent.isCurrent ? ' (current)' : ''}`, bold: true });
122
+ if (agent.accounts.length) {
123
+ for (const account of agent.accounts) {
124
+ const mark = account.active ? '●' : '○';
125
+ lines.push({ text: ` ${mark} ${account.label}: ${formatUsageWindowsSummary(account.usage)}` });
126
+ }
127
+ }
128
+ else {
129
+ lines.push({ text: ` ${formatUsageWindowsSummary(agent.usage)}` });
112
130
  }
113
- }
114
- if (!usage.windows.length) {
115
- lines.push({ text: ` ${usage.status ? `status=${usage.status}` : 'No window data'}` });
116
- return lines;
117
- }
118
- for (const window of usage.windows) {
119
- const details = rawUsageLine([
120
- window.usedPercent != null ? `${window.usedPercent}% used` : null,
121
- window.status ? `status=${window.status}` : null,
122
- window.resetAfterSeconds != null ? `resetAfterSeconds=${window.resetAfterSeconds}` : null,
123
- ]);
124
- lines.push({ text: ` ${window.label}: ${details || 'No details'}` });
125
131
  }
126
132
  return lines;
127
133
  }
@@ -7,6 +7,7 @@ import { shutdownAllDrivers } from '../../agent/driver.js';
7
7
  import { expandTilde } from '../../core/platform.js';
8
8
  import { registerProcessRuntime, requestProcessRestart, } from '../../core/process-control.js';
9
9
  import { getStatusDataAsync, getHostDataSync, getAgentsListData, getSkillsListData, getModelsListData, getSessionsPageData, getSessionsDigestData, formatSessionsDigestText, getStartData, getWorkspacesData, } from '../../bot/commands.js';
10
+ import { buildUsageOverviewLines } from '../../bot/render-shared.js';
10
11
  import { DingtalkChannel } from './channel.js';
11
12
  import { getActiveUserConfig } from '../../core/config/user-config.js';
12
13
  const SHUTDOWN_EXIT_CODE = {
@@ -209,6 +210,8 @@ export class DingtalkBot extends Bot {
209
210
  ];
210
211
  if (d.running)
211
212
  lines.push(`Running: ${fmtUptime(Date.now() - d.running.startedAt)}`);
213
+ for (const line of buildUsageOverviewLines(d.usageOverview))
214
+ lines.push(line.text);
212
215
  await ctx.reply(lines.join('\n'));
213
216
  }
214
217
  async cmdHost(ctx) {
@@ -7,6 +7,7 @@ import { shutdownAllDrivers } from '../../agent/driver.js';
7
7
  import { expandTilde } from '../../core/platform.js';
8
8
  import { registerProcessRuntime, requestProcessRestart, } from '../../core/process-control.js';
9
9
  import { getStatusDataAsync, getHostDataSync, getAgentsListData, getSkillsListData, getModelsListData, getSessionsPageData, getSessionsDigestData, formatSessionsDigestText, getStartData, getWorkspacesData, } from '../../bot/commands.js';
10
+ import { buildUsageOverviewLines } from '../../bot/render-shared.js';
10
11
  import { DiscordChannel } from './channel.js';
11
12
  import { getActiveUserConfig } from '../../core/config/user-config.js';
12
13
  const SHUTDOWN_EXIT_CODE = {
@@ -201,6 +202,8 @@ export class DiscordBot extends Bot {
201
202
  ];
202
203
  if (d.running)
203
204
  lines.push(`Running: ${fmtUptime(Date.now() - d.running.startedAt)}`);
205
+ for (const line of buildUsageOverviewLines(d.usageOverview))
206
+ lines.push(line.text);
204
207
  await ctx.reply(lines.join('\n'));
205
208
  }
206
209
  async cmdHost(ctx) {
@@ -1,7 +1,7 @@
1
1
  import { encodeCommandAction } from '../../bot/command-ui.js';
2
2
  import { fmtUptime, fmtTokens, fmtBytes, formatGitStatusLine } from '../../bot/bot.js';
3
3
  import { summarizePromptForStatus } from '../../bot/commands.js';
4
- import { footerStatusSymbol, formatFooterParts, trimActivityForPreview, buildProviderUsageLines, extractFinalReplyData, extractStreamPreviewData, } from '../../bot/render-shared.js';
4
+ import { footerStatusSymbol, formatFooterParts, trimActivityForPreview, buildUsageOverviewLines, extractFinalReplyData, extractStreamPreviewData, } from '../../bot/render-shared.js';
5
5
  export { dispatchImageBlocks } from '../../bot/render-shared.js';
6
6
  import { currentHumanLoopQuestion, humanLoopAnsweredCount, isHumanLoopAwaitingText, isHumanLoopQuestionAnswered, summarizeHumanLoopAnswer, } from '../../bot/human-loop.js';
7
7
  import path from 'node:path';
@@ -432,12 +432,8 @@ export function renderStatus(d) {
432
432
  if (d.running) {
433
433
  lines.push(`**Running:** ${fmtUptime(Date.now() - d.running.startedAt)} - ${summarizePromptForStatus(d.running.prompt)}`);
434
434
  }
435
- const usageLines = buildProviderUsageLines(d.usage);
436
- if (usageLines.length > 1) {
437
- lines.push('');
438
- for (const line of usageLines) {
439
- lines.push(line.text);
440
- }
435
+ for (const line of buildUsageOverviewLines(d.usageOverview)) {
436
+ lines.push(line.bold && line.text ? `**${line.text}**` : line.text);
441
437
  }
442
438
  lines.push('', '**Bot Usage**', ` Turns: ${d.stats.totalTurns}`);
443
439
  if (d.stats.totalInputTokens || d.stats.totalOutputTokens) {
@@ -7,6 +7,7 @@ import { shutdownAllDrivers } from '../../agent/driver.js';
7
7
  import { expandTilde } from '../../core/platform.js';
8
8
  import { registerProcessRuntime, requestProcessRestart, } from '../../core/process-control.js';
9
9
  import { getStatusDataAsync, getHostDataSync, getAgentsListData, getSkillsListData, getModelsListData, getSessionsPageData, getSessionsDigestData, formatSessionsDigestText, getStartData, getWorkspacesData, } from '../../bot/commands.js';
10
+ import { buildUsageOverviewLines } from '../../bot/render-shared.js';
10
11
  import { SlackChannel } from './channel.js';
11
12
  import { getActiveUserConfig } from '../../core/config/user-config.js';
12
13
  const SHUTDOWN_EXIT_CODE = {
@@ -210,6 +211,8 @@ export class SlackBot extends Bot {
210
211
  ];
211
212
  if (d.running)
212
213
  lines.push(`Running: ${fmtUptime(Date.now() - d.running.startedAt)}`);
214
+ for (const line of buildUsageOverviewLines(d.usageOverview))
215
+ lines.push(line.text);
213
216
  await ctx.reply(lines.join('\n'));
214
217
  }
215
218
  async cmdHost(ctx) {
@@ -12,7 +12,7 @@ import { buildAgentsCommandView, buildModelsCommandView, buildModeCommandView, b
12
12
  import { buildSwitchWorkdirView, buildWorkspacesView, resolveRegisteredPath } from './directory.js';
13
13
  import { LivePreview } from './live-preview.js';
14
14
  import { registerProcessRuntime, buildRestartCommand, requestProcessRestart, } from '../../core/process-control.js';
15
- import { buildInitialPreviewHtml, buildHumanLoopPromptHtml, buildAnsweredHumanLoopPromptHtml, buildStreamPreviewHtml, buildFinalReplyRender, dispatchImageBlocks, escapeHtml, formatMenuLines, formatProviderUsageLines, renderCommandNoticeHtml, renderCommandSelectionHtml, renderCommandSelectionKeyboard, renderSessionTurnHtml, truncateMiddle, unpackCallbackData, } from './render.js';
15
+ import { buildInitialPreviewHtml, buildHumanLoopPromptHtml, buildAnsweredHumanLoopPromptHtml, buildStreamPreviewHtml, buildFinalReplyRender, dispatchImageBlocks, escapeHtml, formatMenuLines, formatUsageOverviewLines, renderCommandNoticeHtml, renderCommandSelectionHtml, renderCommandSelectionKeyboard, renderSessionTurnHtml, truncateMiddle, unpackCallbackData, } from './render.js';
16
16
  import { currentHumanLoopQuestion, humanLoopOptionSelected } from '../../bot/human-loop.js';
17
17
  import { TelegramChannel } from './channel.js';
18
18
  import { splitText, supportsChannelCapability } from '../base.js';
@@ -344,7 +344,7 @@ export class TelegramBot extends Bot {
344
344
  if (d.running) {
345
345
  lines.push(`<b>Running:</b> ${fmtUptime(Date.now() - d.running.startedAt)} - ${escapeHtml(summarizePromptForStatus(d.running.prompt))}`);
346
346
  }
347
- lines.push(...formatProviderUsageLines(d.usage), '', '<b>Bot Usage</b>', ` Turns: ${d.stats.totalTurns}`);
347
+ lines.push(...formatUsageOverviewLines(d.usageOverview), '', '<b>Bot Usage</b>', ` Turns: ${d.stats.totalTurns}`);
348
348
  if (d.stats.totalInputTokens || d.stats.totalOutputTokens) {
349
349
  lines.push(` In: ${fmtTokens(d.stats.totalInputTokens)} Out: ${fmtTokens(d.stats.totalOutputTokens)}`);
350
350
  if (d.stats.totalCachedTokens)
@@ -1,6 +1,6 @@
1
1
  import { encodeCommandAction } from '../../bot/command-ui.js';
2
2
  import { currentHumanLoopQuestion, humanLoopAnsweredCount, isHumanLoopAwaitingText, isHumanLoopQuestionAnswered, summarizeHumanLoopAnswer, } from '../../bot/human-loop.js';
3
- import { footerStatusSymbol, formatFooterParts, trimActivityForPreview, buildProviderUsageLines, extractFinalReplyData, extractStreamPreviewData, parseGfmTable, } from '../../bot/render-shared.js';
3
+ import { footerStatusSymbol, formatFooterParts, trimActivityForPreview, buildUsageOverviewLines, extractFinalReplyData, extractStreamPreviewData, parseGfmTable, } from '../../bot/render-shared.js';
4
4
  export { dispatchImageBlocks } from '../../bot/render-shared.js';
5
5
  export function escapeHtml(t) {
6
6
  return t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -308,8 +308,8 @@ function formatFinalFooterHtml(status, agent, elapsedMs, contextPercent, decorat
308
308
  const primary = escapeHtml(`${footerStatusSymbol(status)} ${parts.identity}`);
309
309
  return `${primary}\n<i>${escapeHtml(parts.runtime)}</i>`;
310
310
  }
311
- export function formatProviderUsageLines(usage) {
312
- return buildProviderUsageLines(usage).map(line => line.bold ? `<b>${escapeHtml(line.text)}</b>` : escapeHtml(line.text));
311
+ export function formatUsageOverviewLines(overview) {
312
+ return buildUsageOverviewLines(overview).map(line => line.bold ? `<b>${escapeHtml(line.text)}</b>` : escapeHtml(line.text));
313
313
  }
314
314
  export function buildInitialPreviewHtml(agent, modelOrWaiting, effortOrQueuePosition, waitingArg = false, queuePositionArg = 0) {
315
315
  let model = null;
@@ -7,6 +7,7 @@ import { shutdownAllDrivers } from '../../agent/driver.js';
7
7
  import { expandTilde } from '../../core/platform.js';
8
8
  import { registerProcessRuntime, requestProcessRestart, } from '../../core/process-control.js';
9
9
  import { getStatusDataAsync, getHostDataSync, getAgentsListData, getSkillsListData, getModelsListData, getSessionsPageData, getSessionsDigestData, formatSessionsDigestText, getStartData, getWorkspacesData, } from '../../bot/commands.js';
10
+ import { buildUsageOverviewLines } from '../../bot/render-shared.js';
10
11
  import { WeComChannel } from './channel.js';
11
12
  import { getActiveUserConfig } from '../../core/config/user-config.js';
12
13
  const SHUTDOWN_EXIT_CODE = {
@@ -217,6 +218,8 @@ export class WeComBot extends Bot {
217
218
  ];
218
219
  if (d.running)
219
220
  lines.push(`Running: ${fmtUptime(Date.now() - d.running.startedAt)}`);
221
+ for (const line of buildUsageOverviewLines(d.usageOverview))
222
+ lines.push(line.text);
220
223
  await ctx.reply(lines.join('\n'));
221
224
  }
222
225
  async cmdHost(ctx) {
@@ -9,6 +9,7 @@ import { shutdownAllDrivers } from '../../agent/driver.js';
9
9
  import { expandTilde } from '../../core/platform.js';
10
10
  import { registerProcessRuntime, requestProcessRestart, } from '../../core/process-control.js';
11
11
  import { getStatusDataAsync, getHostDataSync, getModelsListData, getSessionsPageData, getSessionsDigestData, formatSessionsDigestText, getStartData, getWorkspacesData, handleGoalCommand, } from '../../bot/commands.js';
12
+ import { buildUsageOverviewLines } from '../../bot/render-shared.js';
12
13
  import { WeixinChannel } from './channel.js';
13
14
  import { getActiveUserConfig } from '../../core/config/user-config.js';
14
15
  const SHUTDOWN_EXIT_CODE = {
@@ -254,6 +255,8 @@ export class WeixinBot extends Bot {
254
255
  if (d.running) {
255
256
  lines.push(`Running: ${fmtUptime(Date.now() - d.running.startedAt)}`);
256
257
  }
258
+ for (const line of buildUsageOverviewLines(d.usageOverview))
259
+ lines.push(line.text);
257
260
  await ctx.reply(lines.join('\n'));
258
261
  }
259
262
  async cmdHost(ctx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiloom",
3
- "version": "0.4.43",
3
+ "version": "0.4.44",
4
4
  "description": "Put the world's smartest AI agents in your pocket. Command local Claude & Gemini via IM. | 让最好用的 IM 变成你电脑上的顶级 Agent 控制台",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,6 +30,15 @@ export declare function claudeUsageOf(s: ClaudeUsageState): UniversalUsage;
30
30
  export declare function claudeContextWindowFromModel(model: unknown): number | null;
31
31
  export declare function claudeEffectiveContextWindow(advertised: number | null): number | null;
32
32
  export declare function handleClaudeEvent(ev: any, s: any, emit: (e: DriverEvent) => void): void;
33
+ export declare function claudeBgHoldCapMs(): number;
34
+ export declare function isTerminalTaskStatus(status: unknown): boolean;
35
+ export declare function trackClaudeBackgroundTask(ev: any, s: any): void;
36
+ export declare function pendingClaudeBackgroundTasks(s: any): number;
37
+ export type ClaudeResultSettleDecision = 'settle' | 'hold';
38
+ export declare function decideClaudeResultSettle(input: {
39
+ hasError: boolean;
40
+ pendingBackground: number;
41
+ }): ClaudeResultSettleDecision;
33
42
  export declare function claudeUserMessage(text: string, attachments?: string[]): string;
34
43
  export declare function emitClaudeImages(blocks: any[], s: any, emit: (e: DriverEvent) => void): void;
35
44
  export declare function todoWriteToPlan(input: any): UniversalPlan | null;
@@ -30,10 +30,14 @@ export class ClaudeDriver {
30
30
  }
31
31
  run(input, ctx) {
32
32
  const steerable = !!input.steerable;
33
- // Image attachments must ride a stream-json user message (text-only stdin can't carry images),
34
- // so enable that input mode whenever there are attachments, not just for mid-turn steering.
35
- const useStreamJson = steerable || !!input.attachments?.length;
36
- const args = ['-p', '--verbose', '--output-format', 'stream-json', '--include-partial-messages'];
33
+ // Always drive Claude over a stream-json stdin and keep that stdin OPEN for the whole turn.
34
+ // That open stdin is what lets Claude's native "launch detached background work end the
35
+ // turn wake itself up and report when the work finishes" flow play out: it only happens
36
+ // while stdin stays open (with it closed, Claude exits at the first `result` and the wake-up
37
+ // — and any in-process background agent/workflow — is lost). Image attachments also need the
38
+ // stream-json user message (a plain text stdin can't carry images). `--replay-user-messages`
39
+ // + registerSteer remain the only mid-turn-steer-specific bits.
40
+ const args = ['-p', '--verbose', '--output-format', 'stream-json', '--include-partial-messages', '--input-format', 'stream-json'];
37
41
  if (input.model)
38
42
  args.push('--model', input.model);
39
43
  if (input.effort)
@@ -46,8 +50,6 @@ export class ClaudeDriver {
46
50
  args.push('--mcp-config', input.mcpConfigPath);
47
51
  if (input.permissionMode)
48
52
  args.push('--permission-mode', input.permissionMode); // parity: keep bypass/accept-edits on the kernel path
49
- if (useStreamJson)
50
- args.push('--input-format', 'stream-json');
51
53
  if (steerable)
52
54
  args.push('--replay-user-messages'); // parity: mid-turn steer
53
55
  if (input.extraArgs?.length)
@@ -64,26 +66,54 @@ export class ClaudeDriver {
64
66
  taskList: new Map(),
65
67
  taskOrder: [],
66
68
  pendingTaskCreates: new Map(),
69
+ // run_in_background lifecycle: task ids seen as started vs. reached a terminal status.
70
+ bgStarted: new Set(), bgTerminal: new Set(),
67
71
  };
68
72
  return new Promise((resolve) => {
69
73
  let child;
70
74
  let settled = false;
71
- const finish = (r) => {
75
+ let holdCapTimer = null;
76
+ const usageOf = () => this.usage(state);
77
+ const clearHoldCap = () => { if (holdCapTimer) {
78
+ clearTimeout(holdCapTimer);
79
+ holdCapTimer = null;
80
+ } };
81
+ // kill=true SIGTERMs immediately — fast exit, used once nothing is left running in the
82
+ // background (a normal turn, or a wake-up turn after every background task finished).
83
+ // kill=false only ends stdin and lets Claude shut down on its own, so any still-running
84
+ // detached background work survives a clean exit (a hard kill mid-flight is exactly what
85
+ // tore the background — and the wake-up — down before). A leak-guard SIGTERM is the backstop.
86
+ const finish = (r, kill = true) => {
72
87
  if (settled)
73
88
  return;
74
89
  settled = true;
90
+ clearHoldCap();
75
91
  try {
76
92
  child?.stdin?.end();
77
93
  }
78
94
  catch { /* ignore */ }
79
- if (steerable) {
80
- try {
81
- child?.kill('SIGTERM');
95
+ if (!child.killed && child.exitCode == null) {
96
+ if (kill) {
97
+ try {
98
+ child.kill('SIGTERM');
99
+ }
100
+ catch { /* ignore */ }
101
+ }
102
+ else {
103
+ const guard = setTimeout(() => { try {
104
+ child?.kill('SIGTERM');
105
+ }
106
+ catch { /* ignore */ } }, CLAUDE_EXIT_LEAK_GUARD_MS);
107
+ if (typeof guard.unref === 'function')
108
+ guard.unref();
82
109
  }
83
- catch { /* ignore */ }
84
- } // replay mode keeps the process alive past the turn
110
+ }
85
111
  resolve(r);
86
112
  };
113
+ const settleResult = (opts = {}) => finish({
114
+ ok: !state.error, text: state.text, reasoning: state.reasoning || undefined,
115
+ error: state.error, stopReason: opts.stopReason ?? state.stopReason, sessionId: state.sessionId, usage: usageOf(),
116
+ }, opts.kill ?? true);
87
117
  try {
88
118
  child = spawn(this.bin, args, { cwd: input.workdir, env: { ...process.env, ...(input.env || {}) }, stdio: ['pipe', 'pipe', 'pipe'] });
89
119
  }
@@ -110,14 +140,17 @@ export class ClaudeDriver {
110
140
  }
111
141
  });
112
142
  }
113
- const usageOf = () => this.usage(state);
114
143
  let buf = '';
115
144
  let stderr = '';
116
145
  child.stdout.on('data', (chunk) => {
146
+ if (settled)
147
+ return; // ignore the process's post-settle shutdown chatter
117
148
  buf += chunk.toString('utf8');
118
149
  const lines = buf.split('\n');
119
150
  buf = lines.pop() || '';
120
151
  for (const line of lines) {
152
+ if (settled)
153
+ return;
121
154
  const trimmed = line.trim();
122
155
  if (!trimmed)
123
156
  continue;
@@ -129,32 +162,42 @@ export class ClaudeDriver {
129
162
  continue;
130
163
  }
131
164
  handleClaudeEvent(ev, state, ctx.emit);
132
- if (steerable && ev.type === 'result') {
133
- // In replay mode the turn ends on `result` but the process lingers; settle now.
134
- finish({ ok: !state.error, text: state.text, reasoning: state.reasoning || undefined, error: state.error, stopReason: state.stopReason, sessionId: state.sessionId, usage: usageOf() });
165
+ if (ev.type !== 'result')
166
+ continue;
167
+ const hasError = !!ev.is_error || (Array.isArray(ev.errors) && ev.errors.length > 0) || !!state.error;
168
+ const pending = pendingClaudeBackgroundTasks(state);
169
+ if (decideClaudeResultSettle({ hasError, pendingBackground: pending }) === 'hold') {
170
+ // Background work is still running: do NOT end the turn here. Keep stdin open so Claude
171
+ // wakes itself up and streams a follow-up turn reporting the finished work — we settle
172
+ // on THAT result (pending back to 0). A cap bounds the wait so a never-completing daemon
173
+ // (e.g. a dev server) eventually settles (graceful, no kill) instead of holding forever.
174
+ if (!holdCapTimer) {
175
+ holdCapTimer = setTimeout(() => { if (!settled)
176
+ settleResult({ stopReason: 'background', kill: false }); }, claudeBgHoldCapMs());
177
+ if (typeof holdCapTimer.unref === 'function')
178
+ holdCapTimer.unref();
179
+ }
180
+ continue;
135
181
  }
182
+ settleResult();
136
183
  }
137
184
  });
138
185
  child.stderr.on('data', (c) => { stderr += c.toString('utf8'); });
139
186
  child.on('error', (err) => finish({ ok: false, text: state.text, error: `claude spawn error: ${err.message}`, stopReason: 'error' }));
140
187
  child.on('close', (code) => {
188
+ if (settled)
189
+ return;
141
190
  if (ctx.signal.aborted) {
142
- finish({ ok: false, text: state.text, reasoning: state.reasoning, error: 'Interrupted by user.', stopReason: 'interrupted', sessionId: state.sessionId, usage: usageOf() });
191
+ finish({ ok: false, text: state.text, reasoning: state.reasoning, error: 'Interrupted by user.', stopReason: 'interrupted', sessionId: state.sessionId, usage: usageOf() }, false);
143
192
  return;
144
193
  }
145
194
  const ok = !state.error && code === 0;
146
- finish({ ok, text: state.text, reasoning: state.reasoning || undefined, error: state.error || (ok ? null : `claude exited ${code}${stderr ? `: ${stderr.slice(0, 300)}` : ''}`), stopReason: state.stopReason, sessionId: state.sessionId, usage: usageOf() });
195
+ finish({ ok, text: state.text, reasoning: state.reasoning || undefined, error: state.error || (ok ? null : `claude exited ${code}${stderr ? `: ${stderr.slice(0, 300)}` : ''}`), stopReason: state.stopReason, sessionId: state.sessionId, usage: usageOf() }, false);
147
196
  });
148
197
  try {
149
- if (useStreamJson) {
150
- child.stdin.write(claudeUserMessage(input.prompt, input.attachments) + '\n');
151
- if (!steerable)
152
- child.stdin.end(); // single turn; steer mode keeps stdin open for replay
153
- }
154
- else {
155
- child.stdin.write(input.prompt);
156
- child.stdin.end();
157
- }
198
+ // Send the prompt as a stream-json user message and keep stdin OPEN (do not end it here):
199
+ // closing it makes Claude exit at the first `result`, before any background task finishes.
200
+ child.stdin.write(claudeUserMessage(input.prompt, input.attachments) + '\n');
158
201
  }
159
202
  catch { /* ignore */ }
160
203
  });
@@ -228,6 +271,7 @@ export function handleClaudeEvent(ev, s, emit) {
228
271
  return;
229
272
  }
230
273
  if (t === 'system') {
274
+ trackClaudeBackgroundTask(ev, s);
231
275
  if (ev.session_id && ev.session_id !== s.sessionId) {
232
276
  s.sessionId = ev.session_id;
233
277
  emit({ type: 'session', sessionId: ev.session_id });
@@ -442,6 +486,62 @@ export function handleClaudeEvent(ev, s, emit) {
442
486
  return;
443
487
  }
444
488
  }
489
+ // ── run_in_background lifecycle (background → wake-up) ───────────────────────────────────
490
+ // Claude streams a structured lifecycle for detached background work as `system` sub-events:
491
+ // { subtype:'task_started', task_id, tool_use_id, description } ← launched
492
+ // { subtype:'task_updated', task_id, patch:{ status } } ← progress / terminal
493
+ // { subtype:'task_notification', task_id, status } ← terminal (completed/killed/…)
494
+ // A task counts as pending from task_started until a terminal task_updated/task_notification.
495
+ // While any are pending the driver keeps the turn alive instead of ending it at `result`, so
496
+ // Claude's own background→wake-up turn (which reports the finished work) can stream in. Pure +
497
+ // exported for hermetic testing.
498
+ // How long, with no settle, to keep holding a turn open for a background task to finish and
499
+ // trigger Claude's wake-up turn, before giving up (so a never-completing daemon doesn't hang
500
+ // the turn forever). Override with PIKILOOM_CLAUDE_BG_HOLD_MS.
501
+ const CLAUDE_BG_HOLD_CAP_DEFAULT_MS = 10 * 60_000;
502
+ export function claudeBgHoldCapMs() {
503
+ const raw = Number(process.env.PIKILOOM_CLAUDE_BG_HOLD_MS);
504
+ return Number.isFinite(raw) && raw > 0 ? raw : CLAUDE_BG_HOLD_CAP_DEFAULT_MS;
505
+ }
506
+ // After we settle a held turn gracefully (stdin closed, no kill), force-kill the lingering
507
+ // process only if it hasn't exited on its own within this window. Backstop against leaks.
508
+ const CLAUDE_EXIT_LEAK_GUARD_MS = 15_000;
509
+ export function isTerminalTaskStatus(status) {
510
+ return /^(complete|done|success|succeed|finish|kill|fail|error|stop|cancel|abort|timed?_?out|timeout)/i
511
+ .test(String(status ?? '').trim());
512
+ }
513
+ export function trackClaudeBackgroundTask(ev, s) {
514
+ const subtype = ev?.subtype;
515
+ if (subtype !== 'task_started' && subtype !== 'task_updated' && subtype !== 'task_notification')
516
+ return;
517
+ const id = String(ev?.task_id ?? ev?.tool_use_id ?? '').trim();
518
+ if (!id)
519
+ return;
520
+ if (subtype === 'task_started') {
521
+ (s.bgStarted ||= new Set()).add(id);
522
+ return;
523
+ }
524
+ if (isTerminalTaskStatus(ev?.patch?.status ?? ev?.status))
525
+ (s.bgTerminal ||= new Set()).add(id);
526
+ }
527
+ export function pendingClaudeBackgroundTasks(s) {
528
+ const started = s?.bgStarted;
529
+ if (!started?.size)
530
+ return 0;
531
+ const terminal = s?.bgTerminal;
532
+ let n = 0;
533
+ for (const id of started)
534
+ if (!terminal?.has(id))
535
+ n++;
536
+ return n;
537
+ }
538
+ // At a `result` event: settle the turn normally, unless background work is still running
539
+ // (then hold the process open for the wake-up). Errors always settle.
540
+ export function decideClaudeResultSettle(input) {
541
+ if (input.hasError)
542
+ return 'settle';
543
+ return input.pendingBackground > 0 ? 'hold' : 'settle';
544
+ }
445
545
  // Claude vision input formats (matches the legacy driver / Anthropic API: png, jpeg, gif, webp).
446
546
  const CLAUDE_IMAGE_MIME = {
447
547
  '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
@@ -9,7 +9,7 @@ export type { SessionStore, CoreSessionRecord, ModelResolver, ModelInjection, To
9
9
  export type { LoomIO, PromptInput, Surface, SurfaceCapabilities, Plugin, SpawnContribution, } from './contracts/surface.js';
10
10
  export { FsSessionStore, NullModelResolver, NoopToolProvider, PassthroughSystemPromptBuilder, AutoCancelInteractionHandler, DeferToTerminalInteractionHandler, NoopCatalog, defaultBaseDir, } from './ports/defaults.js';
11
11
  export { EchoDriver } from './drivers/echo.js';
12
- export { ClaudeDriver } from './drivers/claude.js';
12
+ export { ClaudeDriver, isTerminalTaskStatus, trackClaudeBackgroundTask, pendingClaudeBackgroundTasks, decideClaudeResultSettle, claudeBgHoldCapMs, type ClaudeResultSettleDecision, } from './drivers/claude.js';
13
13
  export { CodexDriver } from './drivers/codex.js';
14
14
  export { GeminiDriver } from './drivers/gemini.js';
15
15
  export { AcpDriver, type AcpDriverConfig } from './drivers/acp.js';
@@ -19,7 +19,7 @@ export { attachTui } from './runtime/tui.js';
19
19
  export { FsSessionStore, NullModelResolver, NoopToolProvider, PassthroughSystemPromptBuilder, AutoCancelInteractionHandler, DeferToTerminalInteractionHandler, NoopCatalog, defaultBaseDir, } from './ports/defaults.js';
20
20
  // Drivers & surfaces (also available via subpath exports)
21
21
  export { EchoDriver } from './drivers/echo.js';
22
- export { ClaudeDriver } from './drivers/claude.js';
22
+ export { ClaudeDriver, isTerminalTaskStatus, trackClaudeBackgroundTask, pendingClaudeBackgroundTasks, decideClaudeResultSettle, claudeBgHoldCapMs, } from './drivers/claude.js';
23
23
  export { CodexDriver } from './drivers/codex.js';
24
24
  export { GeminiDriver } from './drivers/gemini.js';
25
25
  export { AcpDriver } from './drivers/acp.js';