@pikiloom/kernel 0.2.4 → 0.2.6

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.
@@ -31,13 +31,16 @@ export declare function claudeContextWindowFromModel(model: unknown): number | n
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
33
  export declare function claudeBgHoldCapMs(): number;
34
+ export declare function claudeBgSettleQuietMs(): number;
34
35
  export declare function isTerminalTaskStatus(status: unknown): boolean;
35
36
  export declare function trackClaudeBackgroundTask(ev: any, s: any): void;
37
+ export declare function markClaudeTaskNotificationTerminal(content: any, s: any): void;
36
38
  export declare function pendingClaudeBackgroundTasks(s: any): number;
37
- export type ClaudeResultSettleDecision = 'settle' | 'hold';
39
+ export type ClaudeResultSettleDecision = 'settle' | 'hold' | 'quiet-settle';
38
40
  export declare function decideClaudeResultSettle(input: {
39
41
  hasError: boolean;
40
42
  pendingBackground: number;
43
+ sawBackground: boolean;
41
44
  }): ClaudeResultSettleDecision;
42
45
  export declare function claudeUserMessage(text: string, attachments?: string[]): string;
43
46
  export declare function emitClaudeImages(blocks: any[], s: any, emit: (e: DriverEvent) => void): void;
@@ -72,12 +72,22 @@ export class ClaudeDriver {
72
72
  return new Promise((resolve) => {
73
73
  let child;
74
74
  let settled = false;
75
+ // holdCap: hard backstop while a background task is still running (never-completing daemon).
76
+ // quiet: fires once all known background tasks finished AND Claude has gone quiet, so trailing
77
+ // wake-up turns can still land before we close (see claudeBgSettleQuietMs).
75
78
  let holdCapTimer = null;
79
+ let quietTimer = null;
76
80
  const usageOf = () => this.usage(state);
81
+ const unref = (tm) => { if (tm && typeof tm.unref === 'function')
82
+ tm.unref(); };
77
83
  const clearHoldCap = () => { if (holdCapTimer) {
78
84
  clearTimeout(holdCapTimer);
79
85
  holdCapTimer = null;
80
86
  } };
87
+ const clearQuiet = () => { if (quietTimer) {
88
+ clearTimeout(quietTimer);
89
+ quietTimer = null;
90
+ } };
81
91
  // kill=true SIGTERMs immediately — fast exit, used once nothing is left running in the
82
92
  // background (a normal turn, or a wake-up turn after every background task finished).
83
93
  // kill=false only ends stdin and lets Claude shut down on its own, so any still-running
@@ -88,6 +98,7 @@ export class ClaudeDriver {
88
98
  return;
89
99
  settled = true;
90
100
  clearHoldCap();
101
+ clearQuiet();
91
102
  try {
92
103
  child?.stdin?.end();
93
104
  }
@@ -104,8 +115,7 @@ export class ClaudeDriver {
104
115
  child?.kill('SIGTERM');
105
116
  }
106
117
  catch { /* ignore */ } }, CLAUDE_EXIT_LEAK_GUARD_MS);
107
- if (typeof guard.unref === 'function')
108
- guard.unref();
118
+ unref(guard);
109
119
  }
110
120
  }
111
121
  resolve(r);
@@ -114,6 +124,23 @@ export class ClaudeDriver {
114
124
  ok: !state.error, text: state.text, reasoning: state.reasoning || undefined,
115
125
  error: state.error, stopReason: opts.stopReason ?? state.stopReason, sessionId: state.sessionId, usage: usageOf(),
116
126
  }, opts.kill ?? true);
127
+ // Absolute cap while holding for a still-running background task (stopReason marks it as
128
+ // "still running in the background" so the empty-text fallback reads right). Idempotent.
129
+ const armHoldCap = () => {
130
+ if (holdCapTimer)
131
+ return;
132
+ holdCapTimer = setTimeout(() => { if (!settled)
133
+ settleResult({ stopReason: 'background', kill: false }); }, claudeBgHoldCapMs());
134
+ unref(holdCapTimer);
135
+ };
136
+ // Grace close once all background work is done: settle gracefully (no kill) if Claude stays
137
+ // quiet for the window. Re-armed on every event, so a still-streaming wake-up keeps it open.
138
+ const armQuiet = () => {
139
+ clearQuiet();
140
+ quietTimer = setTimeout(() => { if (!settled)
141
+ settleResult({ kill: false }); }, claudeBgSettleQuietMs());
142
+ unref(quietTimer);
143
+ };
117
144
  try {
118
145
  child = spawn(this.bin, args, { cwd: input.workdir, env: { ...process.env, ...(input.env || {}) }, stdio: ['pipe', 'pipe', 'pipe'] });
119
146
  }
@@ -162,24 +189,38 @@ export class ClaudeDriver {
162
189
  continue;
163
190
  }
164
191
  handleClaudeEvent(ev, state, ctx.emit);
165
- if (ev.type !== 'result')
166
- continue;
167
- const hasError = !!ev.is_error || (Array.isArray(ev.errors) && ev.errors.length > 0) || !!state.error;
168
192
  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();
193
+ if (ev.type === 'result') {
194
+ const hasError = !!ev.is_error || (Array.isArray(ev.errors) && ev.errors.length > 0) || !!state.error;
195
+ const decision = decideClaudeResultSettle({ hasError, pendingBackground: pending, sawBackground: state.bgStarted.size > 0 });
196
+ if (decision === 'settle') {
197
+ settleResult();
198
+ return;
179
199
  }
200
+ if (decision === 'hold') {
201
+ clearQuiet();
202
+ armHoldCap();
203
+ continue;
204
+ }
205
+ // 'quiet-settle': every known background task finished, but Claude may still be delivering
206
+ // wake-up turns whose delivery trails the completion status (with parallel agents the last
207
+ // one's completion can land before an earlier one's wake-up result). Don't exit here — wait
208
+ // for Claude to go quiet, then close gracefully so no in-flight wake-up is torn down. Keep
209
+ // the hold cap armed as an absolute backstop.
210
+ armHoldCap();
211
+ armQuiet();
180
212
  continue;
181
213
  }
182
- settleResult();
214
+ // Non-result activity: a still-streaming wake-up refreshes the quiet window; a newly-started
215
+ // background task pulls us back into the hold.
216
+ if (quietTimer) {
217
+ if (pending > 0) {
218
+ clearQuiet();
219
+ armHoldCap();
220
+ }
221
+ else
222
+ armQuiet();
223
+ }
183
224
  }
184
225
  });
185
226
  child.stderr.on('data', (c) => { stderr += c.toString('utf8'); });
@@ -424,10 +465,13 @@ export function handleClaudeEvent(ev, s, emit) {
424
465
  return;
425
466
  }
426
467
  if (t === 'user') {
468
+ // Background wake-up delivery: a `<task-notification>` tag (as a string or a text block) marks
469
+ // its background task terminal — an extra completion signal alongside the system task events.
470
+ markClaudeTaskNotificationTerminal(ev.message?.content, s);
427
471
  // Tool results: surface generated images as artifacts AND close out the tool call
428
472
  // (status done/failed + a result detail) so toolCalls is a faithful structured SSOT
429
473
  // and the runtime's activity projection can render the execution trail.
430
- const contents = ev.message?.content || [];
474
+ const contents = Array.isArray(ev.message?.content) ? ev.message.content : [];
431
475
  for (const b of contents) {
432
476
  if (b?.type !== 'tool_result')
433
477
  continue;
@@ -503,6 +547,18 @@ export function claudeBgHoldCapMs() {
503
547
  const raw = Number(process.env.PIKILOOM_CLAUDE_BG_HOLD_MS);
504
548
  return Number.isFinite(raw) && raw > 0 ? raw : CLAUDE_BG_HOLD_CAP_DEFAULT_MS;
505
549
  }
550
+ // Once every KNOWN background task has finished, how long Claude must stay quiet (no further
551
+ // output at all) before we close a background turn. A completed task's status races AHEAD of the
552
+ // wake-up turn that reports it — with N parallel agents finishing together, the last agent's
553
+ // completion can land before an earlier agent's wake-up result, so exiting at the first
554
+ // pending==0 result would kill the still-undelivered wake-ups (the "background was running when
555
+ // the process exited" failure). Refreshed on every event, so a still-streaming wake-up keeps it
556
+ // alive. Override with PIKILOOM_CLAUDE_BG_SETTLE_QUIET_MS.
557
+ const CLAUDE_BG_SETTLE_QUIET_DEFAULT_MS = 15_000;
558
+ export function claudeBgSettleQuietMs() {
559
+ const raw = Number(process.env.PIKILOOM_CLAUDE_BG_SETTLE_QUIET_MS);
560
+ return Number.isFinite(raw) && raw > 0 ? raw : CLAUDE_BG_SETTLE_QUIET_DEFAULT_MS;
561
+ }
506
562
  // After we settle a held turn gracefully (stdin closed, no kill), force-kill the lingering
507
563
  // process only if it hasn't exited on its own within this window. Backstop against leaks.
508
564
  const CLAUDE_EXIT_LEAK_GUARD_MS = 15_000;
@@ -524,6 +580,32 @@ export function trackClaudeBackgroundTask(ev, s) {
524
580
  if (isTerminalTaskStatus(ev?.patch?.status ?? ev?.status))
525
581
  (s.bgTerminal ||= new Set()).add(id);
526
582
  }
583
+ function claudeContentText(content) {
584
+ if (typeof content === 'string')
585
+ return content;
586
+ if (Array.isArray(content))
587
+ return content.filter((b) => b?.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n');
588
+ return '';
589
+ }
590
+ // Extra completion signal (mirrors the legacy driver): Claude delivers a background wake-up as a
591
+ // `type:'user'` message carrying a `<task-notification>` tag (<task-id>/<tool-use-id>/<status>).
592
+ // Mark that task terminal too, so a missed/absent system task_notification still lets pending
593
+ // reach 0 (instead of the turn hanging to the hold cap).
594
+ export function markClaudeTaskNotificationTerminal(content, s) {
595
+ const text = claudeContentText(content);
596
+ if (!text || !text.includes('<task-notification>'))
597
+ return;
598
+ const tag = (name) => {
599
+ const m = text.match(new RegExp(`<${name}>\\s*([^<]*?)\\s*</${name}>`));
600
+ return m ? m[1].trim() : '';
601
+ };
602
+ const status = tag('status');
603
+ if (status && !isTerminalTaskStatus(status))
604
+ return;
605
+ for (const id of [tag('task-id'), tag('tool-use-id')])
606
+ if (id)
607
+ (s.bgTerminal ||= new Set()).add(id);
608
+ }
527
609
  export function pendingClaudeBackgroundTasks(s) {
528
610
  const started = s?.bgStarted;
529
611
  if (!started?.size)
@@ -535,12 +617,19 @@ export function pendingClaudeBackgroundTasks(s) {
535
617
  n++;
536
618
  return n;
537
619
  }
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.
620
+ // At a `result` event, decide how to end the turn:
621
+ // - error → 'settle' now (hard exit).
622
+ // - a background task is still running → 'hold' the process open for its wake-up.
623
+ // - background done BUT this turn launched background work → 'quiet-settle': do NOT exit at this
624
+ // result. Claude may still be DELIVERING wake-up turns whose delivery trails their task's
625
+ // completion status (see claudeBgSettleQuietMs); wait for it to go quiet, then close gracefully.
626
+ // - a plain turn (no background) → 'settle' now.
540
627
  export function decideClaudeResultSettle(input) {
541
628
  if (input.hasError)
542
629
  return 'settle';
543
- return input.pendingBackground > 0 ? 'hold' : 'settle';
630
+ if (input.pendingBackground > 0)
631
+ return 'hold';
632
+ return input.sawBackground ? 'quiet-settle' : 'settle';
544
633
  }
545
634
  // Claude vision input formats (matches the legacy driver / Anthropic API: png, jpeg, gif, webp).
546
635
  const CLAUDE_IMAGE_MIME = {
package/dist/index.d.ts CHANGED
@@ -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, isTerminalTaskStatus, trackClaudeBackgroundTask, pendingClaudeBackgroundTasks, decideClaudeResultSettle, claudeBgHoldCapMs, type ClaudeResultSettleDecision, } from './drivers/claude.js';
12
+ export { ClaudeDriver, isTerminalTaskStatus, trackClaudeBackgroundTask, pendingClaudeBackgroundTasks, markClaudeTaskNotificationTerminal, decideClaudeResultSettle, claudeBgHoldCapMs, claudeBgSettleQuietMs, 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';
package/dist/index.js CHANGED
@@ -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, isTerminalTaskStatus, trackClaudeBackgroundTask, pendingClaudeBackgroundTasks, decideClaudeResultSettle, claudeBgHoldCapMs, } from './drivers/claude.js';
22
+ export { ClaudeDriver, isTerminalTaskStatus, trackClaudeBackgroundTask, pendingClaudeBackgroundTasks, markClaudeTaskNotificationTerminal, decideClaudeResultSettle, claudeBgHoldCapMs, claudeBgSettleQuietMs, } 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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikiloom/kernel",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Heterogeneous coding agents (Claude / Codex / Gemini / ACP) -> an interaction-friendly, accumulating session snapshot + control handle, over pluggable surfaces (IM, Web, tunnel, TUI). The reusable core pikiloom itself is built on.",
5
5
  "type": "module",
6
6
  "license": "MIT",