bloby-bot 0.70.9 → 0.70.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.70.9",
3
+ "version": "0.70.11",
4
4
  "releaseNotes": [
5
5
  "1. Fix: agent self-update ",
6
6
  "1",
@@ -542,6 +542,16 @@ export class ChannelManager {
542
542
  eventData: any,
543
543
  botName: string,
544
544
  ): void {
545
+ // Synthetic turns (background-task continuation turns the harness injects
546
+ // with no matching user push — currently only the pi harness tags these)
547
+ // are INVISIBLE to channel routing: they enqueued no routing target, so
548
+ // consuming/flushing/draining here would steal a concurrently-queued
549
+ // channel message's route and cross-wire replies. They are dashboard-
550
+ // broadcast + DB-persist only. consumedThisTurn/chunkBuf are deliberately
551
+ // untouched — synthetic tokens never enter the chunk buffer, and the
552
+ // surrounding real turns' accounting stays intact.
553
+ if (eventData?.synthetic) return;
554
+
545
555
  const convId = eventData?.conversationId as string | undefined;
546
556
 
547
557
  if (type === 'bot:token' && eventData?.token) {
@@ -16,6 +16,8 @@ import { log } from '../../../shared/logger.js';
16
16
  import { WORKSPACE_DIR } from '../../../shared/paths.js';
17
17
  import type { SavedFile } from '../../file-saver.js';
18
18
  import { assembleSystemPrompt } from '../../../worker/prompts/prompt-assembler.js';
19
+ import { buildAgents } from '../../agents/index.js';
20
+ import crypto from 'crypto';
19
21
  import fs from 'fs';
20
22
  import path from 'path';
21
23
  import type {
@@ -34,6 +36,7 @@ import { readPiAuth } from './auth-storage.js';
34
36
  import { streamProvider } from './providers/stream.js';
35
37
  import type { PiMessage } from './providers/types.js';
36
38
  import { toolDefsForProvider } from './tools/registry.js';
39
+ import type { PiTaskHost } from './tools/types.js';
37
40
 
38
41
  // ── Live conversation state ────────────────────────────────────────────────
39
42
 
@@ -49,9 +52,47 @@ interface LiveConversation {
49
52
  pendingCount: number;
50
53
  /** 60ms micro-batcher for bot:token — collapses per-delta WS frame floods. */
51
54
  batcher: TokenBatcher;
55
+ /** Running background sub-agent tasks (Phase B). While non-empty, the
56
+ * conversation reports idle:false (recycling deferred) and counts as busy
57
+ * (backend restarts / self-updates deferred) so a task is never killed
58
+ * mid-flight by housekeeping. */
59
+ tasks: Map<string, RunningTask>;
60
+ /** Set when a completed background task used file tools — OR'd into the next
61
+ * bot:turn-complete (the continuation turn) so the backend restarts right
62
+ * after the user hears "Done!", mirroring claude's usedTools capture of
63
+ * sub-agent tool_use blocks. */
64
+ taskUsedFileTools: boolean;
65
+ /** Origin of each queued message, FIFO-aligned with inputQueue: 'user' for
66
+ * pushMessage, 'synthetic' for task-completion injections. Shifted on
67
+ * turn_started so the turn's events can be tagged synthetic — the channel
68
+ * routing FIFO must skip turns that never enqueued a routing target
69
+ * (Phase B review PI-B-1: an untagged continuation turn would steal a
70
+ * concurrently-queued channel message's route). */
71
+ turnOrigins: ('user' | 'synthetic')[];
72
+ /** True while the in-flight turn originated from pushSyntheticMessage. */
73
+ currentTurnSynthetic: boolean;
52
74
  loopDone: Promise<void> | null;
53
75
  }
54
76
 
77
+ interface RunningTask {
78
+ id: string;
79
+ description: string;
80
+ subagentType: string;
81
+ abortController: AbortController;
82
+ /** True when stopped via user:stop-task or conversation teardown. */
83
+ stopped: boolean;
84
+ /** True when the wall-clock task watchdog aborted it. */
85
+ timedOut: boolean;
86
+ startedAt: number;
87
+ }
88
+
89
+ /** Hard wall-clock cap per background task. Bounds the housekeeping wedge
90
+ * (idle:false defers recycling; anyConversationBusy defers restarts and
91
+ * self-updates) if a child ever hangs in a way the stream timeouts and the
92
+ * Bash exit-settle can't catch. Generous: a 50-round coder task fits well
93
+ * inside it. */
94
+ const TASK_WALL_CLOCK_MS = 30 * 60_000;
95
+
55
96
  const liveConversations = new Map<string, LiveConversation>();
56
97
 
57
98
  /**
@@ -227,6 +268,220 @@ function resolveAuth(): { ok: true; auth: PiSessionAuth } | { ok: false; error:
227
268
  };
228
269
  }
229
270
 
271
+ // ── Background sub-agents (Phase B — audit D4-1) ───────────────────────────
272
+
273
+ /** Inject a system-originated message into the parent's queue (task completion).
274
+ * Mirrors the Claude SDK's self-prompted continuation turn, with one
275
+ * improvement: the resulting turn's events are tagged `synthetic: true`
276
+ * (origin tracked via conv.turnOrigins) so the channel routing FIFO ignores
277
+ * them entirely — a continuation enqueues no routing target, and an untagged
278
+ * bot:response would steal a concurrently-queued channel message's route
279
+ * (review PI-B-1). Synthetic turns are dashboard-broadcast + DB-persist only.
280
+ * pendingCount/busy are maintained so idle stays accurate and the recycler
281
+ * can't fire mid-continuation. No bot:typing (claude parity). */
282
+ function pushSyntheticMessage(conv: LiveConversation, text: string): void {
283
+ conv.busy = true;
284
+ conv.pendingCount += 1;
285
+ conv.turnOrigins.push('synthetic');
286
+ conv.inputQueue.push({ role: 'user', content: [{ type: 'text', text }] });
287
+ }
288
+
289
+ /** coder.txt advertises the claude toolset ("Read, Write, Edit, Bash, Glob,
290
+ * Grep") — swap in the child's REAL pi toolset so the sub-agent never chases
291
+ * tools it doesn't have (audit D4-4). claude keeps its richer line. */
292
+ function rewriteToolAccessLine(prompt: string, toolNames: string[]): string {
293
+ return prompt.replace(/You have full tool access:[^\n]*/i, `You have full tool access: ${toolNames.join(', ')}.`);
294
+ }
295
+
296
+ /** Compact human-readable descriptor of a child tool call for bot:task-progress. */
297
+ function toolCallSummary(name: string, input: any): string {
298
+ const tail = (p: any) => (typeof p === 'string' ? p.split('/').slice(-2).join('/') : '');
299
+ switch (name.toLowerCase()) {
300
+ case 'bash': return `Bash: ${String(input?.description || input?.command || '').slice(0, 80)}`;
301
+ case 'read': return `Reading ${tail(input?.file_path)}`;
302
+ case 'write': return `Writing ${tail(input?.file_path)}`;
303
+ case 'edit': return `Editing ${tail(input?.file_path)}`;
304
+ default: return name;
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Per-conversation task host: spawns an in-process child `createPiSession`
310
+ * per Task call, translates child events into the `bot:task-*` vocabulary
311
+ * (payload fields exactly as claude.ts:443-484 emits them), and injects the
312
+ * completion back into the parent's queue for the "Done!" continuation turn.
313
+ */
314
+ function createTaskHost(conv: LiveConversation, getAuth: () => PiSessionAuth): PiTaskHost {
315
+ return {
316
+ spawn(req) {
317
+ const agents = buildAgents();
318
+ const cfg = agents[req.subagentType];
319
+ if (!cfg) {
320
+ return {
321
+ ok: false,
322
+ error: `Unknown subagent_type "${req.subagentType}". Available: ${Object.keys(agents).join(', ') || 'none'}.`,
323
+ };
324
+ }
325
+
326
+ const taskId = crypto.randomUUID().slice(0, 8);
327
+ const abortController = new AbortController();
328
+ const task: RunningTask = {
329
+ id: taskId,
330
+ description: req.description,
331
+ subagentType: req.subagentType,
332
+ abortController,
333
+ stopped: false,
334
+ timedOut: false,
335
+ startedAt: Date.now(),
336
+ };
337
+ conv.tasks.set(taskId, task);
338
+
339
+ // Wall-clock backstop: a hung child would otherwise pin idle:false and
340
+ // anyConversationBusy forever, deferring recycling/restarts/self-updates
341
+ // indefinitely (review PI-B lifecycle finding). Cleared in the finally.
342
+ const watchdog = setTimeout(() => {
343
+ if (!conv.tasks.has(taskId)) return;
344
+ log.warn(`[pi/task] Task ${taskId} hit the ${TASK_WALL_CLOCK_MS / 60_000}-minute wall clock — aborting`);
345
+ task.timedOut = true;
346
+ abortController.abort();
347
+ }, TASK_WALL_CLOCK_MS);
348
+
349
+ // Honor the agent config's tool restrictions (claude applies these via
350
+ // the SDK's tools/disallowedTools options — e.g. a future researcher
351
+ // agent with disallowedTools: ['Write','Edit']).
352
+ let childTools = toolDefsForProvider({ forSubagent: true });
353
+ if (Array.isArray(cfg.tools) && cfg.tools.length > 0) {
354
+ childTools = childTools.filter((t) => cfg.tools.includes(t.name));
355
+ }
356
+ if (Array.isArray(cfg.disallowedTools) && cfg.disallowedTools.length > 0) {
357
+ childTools = childTools.filter((t) => !cfg.disallowedTools.includes(t.name));
358
+ }
359
+ const systemPrompt = rewriteToolAccessLine(String(cfg.prompt || ''), childTools.map((t) => t.name));
360
+
361
+ let summaryText = '';
362
+ let errorText = '';
363
+ let usedFileTools = false;
364
+ let toolUses = 0;
365
+ // Outcome from the session's turn_complete — the error EVENT is
366
+ // suppressed when partial text streamed (D6-2 precedence), so these are
367
+ // how the host learns a child that streamed text still failed
368
+ // (review PI-B-CHILD-1: such tasks must not be reported 'completed').
369
+ let childErrored = false;
370
+ let childErrorMsg = '';
371
+ let childCapHit = false;
372
+ let lastUsage: { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheCreationTokens?: number } | undefined;
373
+
374
+ const session = createPiSession({
375
+ getAuth,
376
+ systemPrompt,
377
+ tools: childTools,
378
+ cwd: WORKSPACE_DIR,
379
+ abortController,
380
+ maxToolRounds: typeof cfg.maxTurns === 'number' ? cfg.maxTurns : 50,
381
+ onEvent: (evt: PiSessionEvent) => {
382
+ switch (evt.type) {
383
+ case 'tool_use':
384
+ toolUses += 1;
385
+ conv.batcher.flush();
386
+ conv.onMessage('bot:task-progress', {
387
+ conversationId: conv.id,
388
+ taskId,
389
+ summary: toolCallSummary(evt.name, evt.input),
390
+ lastTool: evt.name,
391
+ usage: { tool_uses: toolUses, duration_ms: Date.now() - task.startedAt },
392
+ });
393
+ break;
394
+ case 'text_end':
395
+ summaryText = evt.text;
396
+ break;
397
+ case 'error':
398
+ errorText = evt.error;
399
+ break;
400
+ case 'turn_complete':
401
+ usedFileTools = usedFileTools || evt.usedFileTools;
402
+ if (evt.usage) lastUsage = evt.usage;
403
+ if (evt.errored) {
404
+ childErrored = true;
405
+ childErrorMsg = evt.errorMsg || childErrorMsg;
406
+ }
407
+ if (evt.roundCapHit) childCapHit = true;
408
+ break;
409
+ }
410
+ },
411
+ });
412
+
413
+ const queue = createAsyncQueue<PiMessage>();
414
+ queue.push({ role: 'user', content: [{ type: 'text', text: req.prompt }] });
415
+ queue.end();
416
+
417
+ log.info(`[pi/task] ──── SUB-AGENT STARTED ──── id=${taskId} type=${req.subagentType} "${req.description}"`);
418
+ // Task events bypass translateAndEmit, so flush the token batcher first —
419
+ // bot:task-created COMMITS the dashboard stream buffer (useBlobyChat),
420
+ // and a batch flushed after it would mis-slice committedTextLength.
421
+ conv.batcher.flush();
422
+ conv.onMessage('bot:task-created', {
423
+ conversationId: conv.id,
424
+ taskId,
425
+ description: req.description,
426
+ type: req.subagentType,
427
+ });
428
+
429
+ void (async () => {
430
+ try {
431
+ await session.run(queue);
432
+ } catch (err: any) {
433
+ errorText = errorText || err?.message || String(err);
434
+ } finally {
435
+ clearTimeout(watchdog);
436
+ conv.tasks.delete(taskId);
437
+ // Honest status (review PI-B-CHILD-1): an error or round-cap exit is
438
+ // 'failed' even when partial text streamed — the parent must not
439
+ // relay half-done work as success.
440
+ const failed = task.timedOut || childErrored || childCapHit || !!errorText || !summaryText;
441
+ const status = task.stopped ? 'stopped' : failed ? 'failed' : 'completed';
442
+ let summary = summaryText || errorText || '(the agent produced no output)';
443
+ if (task.timedOut) {
444
+ summary += `\n\n[The task was aborted after ${TASK_WALL_CLOCK_MS / 60_000} minutes — work is incomplete.]`;
445
+ } else if (childCapHit) {
446
+ summary += '\n\n[The task hit its tool-round limit before finishing — work may be incomplete.]';
447
+ } else if (childErrored && summaryText) {
448
+ summary += `\n\n[The task hit an error before finishing: ${childErrorMsg || errorText || 'unknown error'}]`;
449
+ }
450
+ const u = lastUsage;
451
+ const totalTokens = u
452
+ ? (u.inputTokens || 0) + (u.outputTokens || 0) + (u.cacheReadTokens || 0) + (u.cacheCreationTokens || 0)
453
+ : 0;
454
+ log.info(
455
+ `[pi/task] ──── SUB-AGENT ${status.toUpperCase()} ──── id=${taskId} ` +
456
+ `tools=${toolUses} ${Math.round((Date.now() - task.startedAt) / 1000)}s summary=${summary.slice(0, 160)}`,
457
+ );
458
+ conv.batcher.flush();
459
+ conv.onMessage('bot:task-done', {
460
+ conversationId: conv.id,
461
+ taskId,
462
+ status,
463
+ summary,
464
+ usage: { tool_uses: toolUses, duration_ms: Date.now() - task.startedAt, total_tokens: totalTokens },
465
+ });
466
+ if (usedFileTools) conv.taskUsedFileTools = true;
467
+
468
+ // Drive the user-facing continuation turn — unless the conversation
469
+ // itself is gone (ended/recycled), in which case the report dies with
470
+ // it (claude parity: the SDK subprocess dies too).
471
+ if (liveConversations.get(conv.id) === conv && !conv.abortController.signal.aborted) {
472
+ const note = task.stopped
473
+ ? `[System: the background task "${req.description}" was stopped by the user. Acknowledge that briefly in your own voice — never mention agents, tasks, or system messages.]`
474
+ : `[System: background task "${req.description}" ${status}.]\n\nResult summary:\n${summary}\n\nRelay the outcome to the user concisely in your own voice (never mention agents, tasks, ids, or system messages). If it failed, say what went wrong and offer a next step.`;
475
+ pushSyntheticMessage(conv, note);
476
+ }
477
+ }
478
+ })();
479
+
480
+ return { ok: true, taskId };
481
+ },
482
+ };
483
+ }
484
+
230
485
  /** Convert a saved RecentMessage[] into the provider-neutral PiMessage[]. */
231
486
  function recentToPiMessages(messages: RecentMessage[] | undefined): PiMessage[] {
232
487
  if (!messages?.length) return [];
@@ -298,9 +553,20 @@ export async function startConversation(
298
553
  onMessage,
299
554
  busy: false,
300
555
  pendingCount: 0,
301
- batcher: createTokenBatcher((text) => onMessage('bot:token', { conversationId, token: text })),
556
+ batcher: null as unknown as TokenBatcher, // set right below (needs conv for the synthetic tag)
557
+ tasks: new Map(),
558
+ taskUsedFileTools: false,
559
+ turnOrigins: [],
560
+ currentTurnSynthetic: false,
302
561
  loopDone: null,
303
562
  };
563
+ conv.batcher = createTokenBatcher((text) =>
564
+ onMessage('bot:token', {
565
+ conversationId,
566
+ token: text,
567
+ ...(conv.currentTurnSynthetic ? { synthetic: true } : {}),
568
+ }),
569
+ );
304
570
  liveConversations.set(conversationId, conv);
305
571
 
306
572
  // Re-resolve auth on every provider round so a key/model fix in the wizard
@@ -319,6 +585,7 @@ export async function startConversation(
319
585
  tools: toolDefsForProvider(),
320
586
  cwd: WORKSPACE_DIR,
321
587
  abortController,
588
+ taskHost: createTaskHost(conv, getAuth),
322
589
  onEvent: (evt: PiSessionEvent) => {
323
590
  translateAndEmit(conv, evt);
324
591
  },
@@ -358,16 +625,28 @@ function translateAndEmit(conv: LiveConversation, evt: PiSessionEvent) {
358
625
  // invariant both depend on it.
359
626
  conv.batcher.flush();
360
627
 
628
+ // Synthetic tag for every event of a continuation turn (origin shifted at
629
+ // turn_started): the channel routing FIFO must treat these turns as
630
+ // invisible — they enqueued no routing target, so consuming one would steal
631
+ // a queued channel message's route (review PI-B-1).
632
+ const syn = conv.currentTurnSynthetic ? { synthetic: true } : {};
633
+
361
634
  switch (evt.type) {
362
635
  case 'turn_started':
636
+ conv.currentTurnSynthetic = conv.turnOrigins.shift() === 'synthetic';
363
637
  // No bloby event for this — `bot:typing` is already emitted by pushMessage().
364
638
  break;
365
639
  case 'text_end':
366
- conv.onMessage('bot:response', { conversationId: conv.id, content: evt.text });
640
+ conv.onMessage('bot:response', { conversationId: conv.id, content: evt.text, ...syn });
367
641
  break;
368
- case 'tool_use':
369
- conv.onMessage('bot:tool', { conversationId: conv.id, name: evt.name, input: evt.input });
642
+ case 'tool_use': {
643
+ // House vocabulary: claude's delegation tool is named Task; the pi
644
+ // prompt's 'Agent' alias resolves to the same tool — normalize the
645
+ // event so consumers see one name.
646
+ const toolName = evt.name === 'Agent' || evt.name === 'agent' ? 'Task' : evt.name;
647
+ conv.onMessage('bot:tool', { conversationId: conv.id, name: toolName, input: evt.input, ...syn });
370
648
  break;
649
+ }
371
650
  case 'tool_result':
372
651
  // Not surfaced yet (Phase D: translate to a bot:tool progress pulse).
373
652
  break;
@@ -375,9 +654,16 @@ function translateAndEmit(conv: LiveConversation, evt: PiSessionEvent) {
375
654
  conv.busy = false;
376
655
  // One turn-complete per pushed message (D1-1 restored that invariant);
377
656
  // idle gates the supervisor's proactive recycling so it never fires with
378
- // a message still queued claude.ts pendingCount semantics exactly.
657
+ // a message still queued OR a background task still running — recycling
658
+ // mid-task would kill the task (claude has the same teardown semantics,
659
+ // but its idle flag doesn't guard tasks; this is strictly safer).
379
660
  conv.pendingCount = Math.max(0, conv.pendingCount - 1);
380
- const idle = conv.pendingCount === 0;
661
+ const idle = conv.pendingCount === 0 && conv.tasks.size === 0;
662
+ // A finished background task's file edits restart the backend on the
663
+ // very next turn boundary (the continuation turn) — claude captures
664
+ // sub-agent tool_use blocks into the parent's usedTools the same way.
665
+ const usedFileTools = evt.usedFileTools || conv.taskUsedFileTools;
666
+ conv.taskUsedFileTools = false;
381
667
  // Prompt occupancy of the last provider round — input + cache reads +
382
668
  // cache writes, exactly claude.ts's contextTokens math. Output tokens
383
669
  // are NOT added (claude doesn't either; the recycler's 70% threshold
@@ -387,12 +673,13 @@ function translateAndEmit(conv: LiveConversation, evt: PiSessionEvent) {
387
673
  : 0;
388
674
  conv.onMessage('bot:turn-complete', {
389
675
  conversationId: conv.id,
390
- usedFileTools: evt.usedFileTools,
676
+ usedFileTools,
391
677
  contextTokens,
392
678
  contextWindow: evt.contextWindow || 0,
393
679
  idle,
680
+ ...syn,
394
681
  });
395
- log.info(`[pi/conversation] ──── TURN COMPLETE ──── busy=false ctx=${contextTokens}/${evt.contextWindow || 'n/a'} idle=${idle}`);
682
+ log.info(`[pi/conversation] ──── TURN COMPLETE ──── busy=false ctx=${contextTokens}/${evt.contextWindow || 'n/a'} idle=${idle} tasks=${conv.tasks.size}`);
396
683
  break;
397
684
  }
398
685
  case 'error': {
@@ -405,7 +692,7 @@ function translateAndEmit(conv: LiveConversation, evt: PiSessionEvent) {
405
692
  : evt.kind === 'auth'
406
693
  ? ' I\'ll reconnect with the new key as soon as it\'s saved.'
407
694
  : '';
408
- conv.onMessage('bot:error', { conversationId: conv.id, error: `${evt.error}${remedy}` });
695
+ conv.onMessage('bot:error', { conversationId: conv.id, error: `${evt.error}${remedy}`, ...syn });
409
696
  if (fatal) {
410
697
  // Unrecoverable for this session (audit D6-4): an over-window history
411
698
  // would re-fail on every future turn, and a dead key has no business
@@ -435,6 +722,7 @@ export function pushMessage(
435
722
  log.info(`[pi/conversation] ──── PUSH MESSAGE ──── busy=${conv.busy} pending=${conv.pendingCount + 1}`);
436
723
  conv.busy = true;
437
724
  conv.pendingCount += 1;
725
+ conv.turnOrigins.push('user');
438
726
  conv.inputQueue.push(buildUserMessage(content, attachments, savedFiles));
439
727
  conv.onMessage('bot:typing', { conversationId });
440
728
  return true;
@@ -445,6 +733,14 @@ export function endConversation(conversationId: string): void {
445
733
  if (!conv) return;
446
734
 
447
735
  log.info(`[pi/conversation] ──── ENDING CONVERSATION ${conversationId} ────`);
736
+ // Background tasks die with the conversation (claude parity — the SDK
737
+ // subprocess takes its tasks down too). Their finallys still emit
738
+ // bot:task-done {status:'stopped'} so dashboard task cards don't spin
739
+ // forever; the completion injection is skipped (conv gone).
740
+ for (const task of conv.tasks.values()) {
741
+ task.stopped = true;
742
+ task.abortController.abort();
743
+ }
448
744
  conv.batcher.discard();
449
745
  conv.inputQueue.end();
450
746
  conv.abortController.abort();
@@ -455,16 +751,29 @@ export function isConversationBusy(conversationId: string): boolean {
455
751
  return liveConversations.get(conversationId)?.busy || false;
456
752
  }
457
753
 
458
- /** True if ANY live conversation in this harness is mid-turn. Used by the supervisor to defer
459
- * backend restarts during channel/Alexa turns (which don't set the dashboard's agentQueryActive). */
754
+ /** True if ANY live conversation in this harness is mid-turn OR has a background
755
+ * sub-agent running. Used by the supervisor to defer backend restarts and
756
+ * self-updates — a restart mid-task would kill the task's work in flight. */
460
757
  export function anyConversationBusy(): boolean {
461
- for (const c of liveConversations.values()) if (c.busy) return true;
758
+ for (const c of liveConversations.values()) {
759
+ if (c.busy || c.tasks.size > 0) return true;
760
+ }
462
761
  return false;
463
762
  }
464
763
 
465
- /** Pi has no sub-agents yet; provided for interface compatibility. */
466
- export async function stopSubAgentTask(_conversationId: string, _taskId: string): Promise<void> {
467
- // no-op for Phase 1
764
+ /** Stop a specific background sub-agent task (dashboard user:stop-task). The
765
+ * child's teardown emits bot:task-done {status:'stopped'} and injects a brief
766
+ * acknowledgement turn into the parent. */
767
+ export async function stopSubAgentTask(conversationId: string, taskId: string): Promise<void> {
768
+ const conv = liveConversations.get(conversationId);
769
+ const task = conv?.tasks.get(taskId);
770
+ if (!task) {
771
+ log.warn(`[pi/task] Cannot stop task ${taskId} — not running in conversation ${conversationId}`);
772
+ return;
773
+ }
774
+ log.info(`[pi/task] Stopping sub-agent task ${taskId}`);
775
+ task.stopped = true;
776
+ task.abortController.abort();
468
777
  }
469
778
 
470
779
  /** Pi has no pre-warm step (no subprocess), but the interface requires this. */
@@ -38,7 +38,7 @@ import type { PiMessage, PiStreamEvent, PiToolDef, PiContentBlock, PiUsage, PiEr
38
38
  import { sleep } from './providers/retry.js';
39
39
  import type { AsyncQueue } from './async-queue.js';
40
40
  import { findTool } from './tools/registry.js';
41
- import type { PiTool } from './tools/types.js';
41
+ import type { PiTool, PiTaskHost } from './tools/types.js';
42
42
 
43
43
  export type PiSessionEvent =
44
44
  | { type: 'turn_started' }
@@ -46,7 +46,22 @@ export type PiSessionEvent =
46
46
  | { type: 'text_end'; text: string }
47
47
  | { type: 'tool_use'; id: string; name: string; input: any }
48
48
  | { type: 'tool_result'; toolUseId: string; name: string; isError?: boolean }
49
- | { type: 'turn_complete'; usedFileTools: boolean; usage?: PiUsage; contextWindow?: number }
49
+ | {
50
+ type: 'turn_complete';
51
+ usedFileTools: boolean;
52
+ usage?: PiUsage;
53
+ contextWindow?: number;
54
+ /** True when the turn ended on a provider error. NOTE: the `error` EVENT
55
+ * is suppressed when partial text streamed (D6-2 response-over-error
56
+ * precedence, designed for the watched parent stream) — these fields
57
+ * are how an UNwatched consumer (the task host) learns the turn failed,
58
+ * so a child that streamed text then died isn't reported 'completed'. */
59
+ errored?: boolean;
60
+ errorKind?: PiErrorKind;
61
+ errorMsg?: string;
62
+ /** True when the turn was cut off by the tool-round budget mid-task. */
63
+ roundCapHit?: boolean;
64
+ }
50
65
  | { type: 'error'; error: string; kind?: PiErrorKind };
51
66
 
52
67
  /** Everything the providers need that can change while a session is alive. */
@@ -78,6 +93,17 @@ export interface PiSessionInit {
78
93
  tools?: PiToolDef[];
79
94
  /** Resolved every time a tool fires (registry → run). */
80
95
  cwd: string;
96
+ /**
97
+ * Background sub-agent host (Phase B). Set only on PARENT live sessions —
98
+ * threaded into PiToolContext so the Task tool can spawn; child sessions
99
+ * leave it unset (no grandchildren, Claude SDK parity).
100
+ */
101
+ taskHost?: PiTaskHost;
102
+ /**
103
+ * Per-turn tool-round budget. Parents keep the default; sub-agent children
104
+ * get their agent config's maxTurns (e.g. coder: 50).
105
+ */
106
+ maxToolRounds?: number;
81
107
  /** Used to interrupt in-flight provider calls when the session ends. */
82
108
  abortController: AbortController;
83
109
  /** Caller's event sink — translated to bloby's `bot:*` events one layer up. */
@@ -199,7 +225,7 @@ export function createPiSession(init: PiSessionInit): PiSession {
199
225
  };
200
226
  }
201
227
  try {
202
- return await tool.run(call.input, { cwd: init.cwd, signal: init.abortController.signal });
228
+ return await tool.run(call.input, { cwd: init.cwd, signal: init.abortController.signal, tasks: init.taskHost });
203
229
  } catch (err: any) {
204
230
  return { output: `Tool ${call.name} threw: ${err?.message || err}`, isError: true };
205
231
  }
@@ -218,8 +244,12 @@ export function createPiSession(init: PiSessionInit): PiSession {
218
244
  let turnErrorMsg: string | undefined;
219
245
  let turnErrorKind: PiErrorKind | undefined;
220
246
 
221
- for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
222
- if (init.abortController.signal.aborted) break;
247
+ const maxRounds = Math.max(1, init.maxToolRounds ?? MAX_TOOL_ROUNDS);
248
+ // True only when the for-loop runs out of rounds with the model still
249
+ // mid-task — every intentional exit (done, errored, aborted) clears it.
250
+ let roundCapHit = true;
251
+ for (let round = 0; round < maxRounds; round++) {
252
+ if (init.abortController.signal.aborted) { roundCapHit = false; break; }
223
253
  // The separator condition is decided BEFORE the round so the round can
224
254
  // emit it ahead of its first token (claude.ts ordering — see runOneRound).
225
255
  const needsSeparator = accumulatedText.length > 0 && !accumulatedText.endsWith('\n');
@@ -277,6 +307,7 @@ export function createPiSession(init: PiSessionInit): PiSession {
277
307
  turnErrored = true;
278
308
  turnErrorMsg = res.errorMsg;
279
309
  turnErrorKind = res.errorKind;
310
+ roundCapHit = false;
280
311
  break;
281
312
  }
282
313
 
@@ -301,7 +332,7 @@ export function createPiSession(init: PiSessionInit): PiSession {
301
332
  }
302
333
 
303
334
  // No tool calls ⇒ the model is done with this turn.
304
- if (toolUses.length === 0) break;
335
+ if (toolUses.length === 0) { roundCapHit = false; break; }
305
336
  }
306
337
 
307
338
  // Turn-end emission order (audit D6-2, mirrors claude.ts:394-401):
@@ -325,7 +356,16 @@ export function createPiSession(init: PiSessionInit): PiSession {
325
356
  init.onEvent({ type: 'error', error: turnErrorMsg || 'Provider turn failed', kind: turnErrorKind });
326
357
  }
327
358
  const usedFileTools = Array.from(usedTools).some((t) => FILE_TOOL_NAMES.has(t));
328
- init.onEvent({ type: 'turn_complete', usedFileTools, usage: lastUsage, contextWindow: lastContextWindow });
359
+ init.onEvent({
360
+ type: 'turn_complete',
361
+ usedFileTools,
362
+ usage: lastUsage,
363
+ contextWindow: lastContextWindow,
364
+ errored: turnErrored || undefined,
365
+ errorKind: turnErrorKind,
366
+ errorMsg: turnErrorMsg,
367
+ roundCapHit: roundCapHit || undefined,
368
+ });
329
369
  }
330
370
  }
331
371
 
@@ -342,7 +382,14 @@ export function createPiSession(init: PiSessionInit): PiSession {
342
382
  // and chat aren't wedged. Skip when aborting (teardown emits conversation-ended).
343
383
  // usedFileTools=false is the safe default (it only governs whether to auto-restart now).
344
384
  if (!init.abortController.signal.aborted) {
345
- init.onEvent({ type: 'turn_complete', usedFileTools: false, usage: lastUsage, contextWindow: lastContextWindow });
385
+ init.onEvent({
386
+ type: 'turn_complete',
387
+ usedFileTools: false,
388
+ usage: lastUsage,
389
+ contextWindow: lastContextWindow,
390
+ errored: true,
391
+ errorMsg: err?.message || String(err),
392
+ });
346
393
  }
347
394
  }
348
395
  }
@@ -38,12 +38,26 @@ export const bashTool: PiTool = {
38
38
  let timedOut = false;
39
39
  let settled = false;
40
40
 
41
+ // detached:true gives the child its own process group so kills reach
42
+ // grandchildren too — a SIGKILLed direct child can leave orphans holding
43
+ // the stdio pipes, and 'close' (which waits for the pipes) then never
44
+ // fires, hanging the tool promise and wedging the whole turn.
41
45
  const child = spawn('bash', ['-lc', command], {
42
46
  cwd: ctx.cwd,
43
47
  env: process.env,
44
48
  stdio: ['ignore', 'pipe', 'pipe'],
49
+ detached: true,
45
50
  });
46
51
 
52
+ const killTree = () => {
53
+ try {
54
+ if (child.pid) process.kill(-child.pid, 'SIGKILL');
55
+ else child.kill('SIGKILL');
56
+ } catch {
57
+ try { child.kill('SIGKILL'); } catch {}
58
+ }
59
+ };
60
+
47
61
  const append = (chunk: Buffer) => {
48
62
  if (truncated) return;
49
63
  const remaining = OUTPUT_CAP_BYTES - Buffer.byteLength(out, 'utf-8');
@@ -65,23 +79,15 @@ export const bashTool: PiTool = {
65
79
 
66
80
  const timer = setTimeout(() => {
67
81
  timedOut = true;
68
- try { child.kill('SIGKILL'); } catch {}
82
+ killTree();
69
83
  }, timeout);
70
84
 
71
85
  const onAbort = () => {
72
- try { child.kill('SIGKILL'); } catch {}
86
+ killTree();
73
87
  };
74
88
  ctx.signal?.addEventListener('abort', onAbort);
75
89
 
76
- child.on('error', (err) => {
77
- if (settled) return;
78
- settled = true;
79
- clearTimeout(timer);
80
- ctx.signal?.removeEventListener('abort', onAbort);
81
- resolve({ output: `Failed to spawn command: ${err.message}`, isError: true });
82
- });
83
-
84
- child.on('close', (code, signal) => {
90
+ const finish = (code: number | null, signal: NodeJS.Signals | null) => {
85
91
  if (settled) return;
86
92
  settled = true;
87
93
  clearTimeout(timer);
@@ -103,6 +109,23 @@ export const bashTool: PiTool = {
103
109
  isError: true,
104
110
  });
105
111
  }
112
+ };
113
+
114
+ child.on('error', (err) => {
115
+ if (settled) return;
116
+ settled = true;
117
+ clearTimeout(timer);
118
+ ctx.signal?.removeEventListener('abort', onAbort);
119
+ resolve({ output: `Failed to spawn command: ${err.message}`, isError: true });
120
+ });
121
+
122
+ // 'close' is the normal settle point (all output drained). But orphaned
123
+ // grandchildren can inherit the stdio pipes and keep them open after the
124
+ // direct child died — settle from 'exit' after a short drain grace so the
125
+ // tool promise can never hang the turn on a pipe that won't close.
126
+ child.on('close', (code, signal) => finish(code, signal));
127
+ child.on('exit', (code, signal) => {
128
+ setTimeout(() => finish(code, signal), 1500);
106
129
  });
107
130
  });
108
131
  },
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Tool registry — the bag of tools the pi session passes to the model.
3
3
  *
4
- * Phase 2 ships the four core coding tools. Phase 3 or later will add Grep,
5
- * Glob, LS, NotebookEdit, etc. so the surface fully matches Claude SDK's.
4
+ * Read/Write/Edit/Bash mirror the Claude SDK tools; Task is the background
5
+ * sub-agent delegator (Phase B of the parity plan). Grep, Glob, LS,
6
+ * NotebookEdit etc. are still pending (Phase D) to fully match Claude SDK's
7
+ * surface.
6
8
  */
7
9
  import type { PiTool } from './types.js';
8
10
  import type { PiToolDef } from '../providers/types.js';
@@ -10,8 +12,9 @@ import { readTool } from './read.js';
10
12
  import { writeTool } from './write.js';
11
13
  import { editTool } from './edit.js';
12
14
  import { bashTool } from './bash.js';
15
+ import { taskTool, taskToolDef } from './task.js';
13
16
 
14
- export const PI_TOOLS: PiTool[] = [readTool, writeTool, editTool, bashTool];
17
+ export const PI_TOOLS: PiTool[] = [readTool, writeTool, editTool, bashTool, taskTool];
15
18
 
16
19
  const TOOL_BY_NAME = new Map<string, PiTool>();
17
20
  for (const t of PI_TOOLS) {
@@ -20,15 +23,28 @@ for (const t of PI_TOOLS) {
20
23
  // common aliases so we don't 404 a legitimate call over a casing nit.
21
24
  TOOL_BY_NAME.set(t.name.toLowerCase(), t);
22
25
  }
26
+ // The pi system prompt calls background delegation "the Agent tool" (claude
27
+ // heritage) — alias it so a model following the prompt verbatim still lands
28
+ // on the Task implementation.
29
+ TOOL_BY_NAME.set('Agent', taskTool);
30
+ TOOL_BY_NAME.set('agent', taskTool);
23
31
 
24
32
  export function findTool(name: string): PiTool | undefined {
25
33
  return TOOL_BY_NAME.get(name) || TOOL_BY_NAME.get(name.toLowerCase());
26
34
  }
27
35
 
28
- export function toolDefsForProvider(): PiToolDef[] {
29
- return PI_TOOLS.map((t) => ({
30
- name: t.name,
31
- description: t.description,
32
- inputSchema: t.inputSchema,
33
- }));
36
+ export function toolDefsForProvider(opts?: { forSubagent?: boolean }): PiToolDef[] {
37
+ const defs: PiToolDef[] = [];
38
+ for (const t of PI_TOOLS) {
39
+ if (t.name === 'Task') {
40
+ // Children cannot spawn grandchildren (Claude SDK parity) — a child that
41
+ // hallucinates a Task call still fails gracefully (ctx.tasks is unset).
42
+ if (opts?.forSubagent) continue;
43
+ // Rebuilt fresh so agent-roster/prompt edits apply per session start.
44
+ defs.push(taskToolDef());
45
+ continue;
46
+ }
47
+ defs.push({ name: t.name, description: t.description, inputSchema: t.inputSchema });
48
+ }
49
+ return defs;
34
50
  }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Task tool — background sub-agent delegation (audit D4-1, Phase B).
3
+ *
4
+ * Mirrors the Claude Agent SDK's Task tool contract and feel:
5
+ * - the model calls Task({description, prompt, subagent_type})
6
+ * - the tool returns IMMEDIATELY with an acknowledgement, so the parent turn
7
+ * ends in seconds and the chat stays fully conversational
8
+ * - the child runs as an in-process `createPiSession` (Bloby owns the agent
9
+ * loop, so no subprocess is needed — unlike upstream pi's subagent
10
+ * extension, which must spawn the pi CLI)
11
+ * - completion is injected back into the parent's input queue as a synthetic
12
+ * message that drives the user-facing "Done!" continuation turn
13
+ *
14
+ * Also registered under the alias 'Agent' (registry.ts): the pi system prompt
15
+ * sells "the Agent tool" — with this tool live, those sections are true as
16
+ * written, closing the audit's D4-2 finding without a prompt edit.
17
+ *
18
+ * Agent definitions come from `supervisor/agents/index.ts:buildAgents()` —
19
+ * the same roster the Claude harness uses, so both harnesses stay in sync.
20
+ */
21
+ import type { PiTool } from './types.js';
22
+ import type { PiToolDef } from '../providers/types.js';
23
+ import { buildAgents } from '../../../agents/index.js';
24
+
25
+ /**
26
+ * Tool definition with a FRESH subagent enum — built per session start (not at
27
+ * module load) so prompt-file/roster edits apply without a supervisor restart,
28
+ * and so the workspace is guaranteed to exist when prompts are read.
29
+ */
30
+ export function taskToolDef(): PiToolDef {
31
+ const agents = buildAgents();
32
+ const names = Object.keys(agents);
33
+ return {
34
+ name: 'Task',
35
+ description:
36
+ 'Delegate heavy work to a background sub-agent and keep chatting while it runs. ' +
37
+ 'Returns immediately — you will automatically receive the result when the agent finishes, ' +
38
+ 'so acknowledge the user briefly and end your turn. ' +
39
+ 'Available agents: ' +
40
+ (names.map((n) => `${n} (${agents[n].description})`).join('; ') || 'none configured'),
41
+ inputSchema: {
42
+ type: 'object',
43
+ properties: {
44
+ description: {
45
+ type: 'string',
46
+ description: 'Short (3-5 word) description of the task, shown to the user as a progress card.',
47
+ },
48
+ prompt: {
49
+ type: 'string',
50
+ description: 'Complete, self-contained instructions for the sub-agent. It cannot ask follow-up questions — include every detail it needs.',
51
+ },
52
+ subagent_type: {
53
+ type: 'string',
54
+ ...(names.length > 0 ? { enum: names } : {}),
55
+ description: 'Which sub-agent to delegate to.',
56
+ },
57
+ },
58
+ required: ['description', 'prompt', 'subagent_type'],
59
+ },
60
+ };
61
+ }
62
+
63
+ export const taskTool: PiTool = {
64
+ name: 'Task',
65
+ description: 'Delegate heavy work to a background sub-agent.',
66
+ // Static placeholder (no file I/O at module load) — providers receive the
67
+ // dynamic enum schema from taskToolDef() via registry.toolDefsForProvider.
68
+ inputSchema: {
69
+ type: 'object',
70
+ properties: {
71
+ description: { type: 'string' },
72
+ prompt: { type: 'string' },
73
+ subagent_type: { type: 'string' },
74
+ },
75
+ required: ['description', 'prompt', 'subagent_type'],
76
+ },
77
+
78
+ async run(input, ctx) {
79
+ if (!ctx.tasks) {
80
+ return {
81
+ output:
82
+ 'The Task tool is not available in this context — do the work yourself with your other tools.',
83
+ isError: true,
84
+ };
85
+ }
86
+ const description = typeof input?.description === 'string' ? input.description.trim() : '';
87
+ const prompt = typeof input?.prompt === 'string' ? input.prompt.trim() : '';
88
+ const subagentType = typeof input?.subagent_type === 'string' ? input.subagent_type.trim() : '';
89
+ if (!prompt) {
90
+ return { output: 'Task requires `prompt` — complete instructions for the sub-agent.', isError: true };
91
+ }
92
+
93
+ const res = ctx.tasks.spawn({
94
+ description: description || prompt.slice(0, 60),
95
+ prompt,
96
+ subagentType: subagentType || 'coder',
97
+ });
98
+ if (!res.ok) return { output: res.error, isError: true };
99
+
100
+ return {
101
+ output:
102
+ `Background task started (id: ${res.taskId}). It is now running while you keep chatting. ` +
103
+ `Tell the user in ONE short sentence that you're on it (your usual voice — never mention ` +
104
+ `agents, tasks, or ids), then end your turn. You will automatically receive the result ` +
105
+ `when it finishes.`,
106
+ };
107
+ },
108
+ };
@@ -14,11 +14,26 @@ export interface PiToolResult {
14
14
  isError?: boolean;
15
15
  }
16
16
 
17
+ /**
18
+ * Host interface a live conversation exposes to the Task tool so it can spawn
19
+ * background sub-agents (audit D4-1). Only PARENT sessions provide it — child
20
+ * sessions get `ctx.tasks` undefined, so a sub-agent cannot spawn
21
+ * grandchildren (Claude SDK parity).
22
+ */
23
+ export interface PiTaskHost {
24
+ /** Spawn a background sub-agent. Returns synchronously; the child runs detached. */
25
+ spawn(req: { description: string; prompt: string; subagentType: string }):
26
+ | { ok: true; taskId: string }
27
+ | { ok: false; error: string };
28
+ }
29
+
17
30
  export interface PiToolContext {
18
31
  /** Workspace root — every tool resolves paths against this. */
19
32
  cwd: string;
20
33
  /** Aborted when the session ends so long-running tools stop fast. */
21
34
  signal?: AbortSignal;
35
+ /** Present only in live parent sessions — lets the Task tool spawn sub-agents. */
36
+ tasks?: PiTaskHost;
22
37
  }
23
38
 
24
39
  export interface PiTool {
@@ -3236,8 +3236,12 @@ ${alreadyLinked ? '' : `
3236
3236
  return async (type: string, eventData: any) => {
3237
3237
  // Capture surface BEFORE routeWaStreamEvent consumes the routing target on bot:response.
3238
3238
  // Used below to suppress chat-bubble broadcasts for non-dashboard turns.
3239
+ // Synthetic turns (background-task continuations — see routeWaStreamEvent's
3240
+ // guard) never own a routing target; the FIFO head, if any, belongs to a
3241
+ // QUEUED channel message, so peeking it would misclassify the turn.
3242
+ // They are always dashboard turns.
3239
3243
  const triggerSurface = channelManager.peekCurrentSurface(convId);
3240
- const isDashboardTurn = !triggerSurface || triggerSurface === 'workspace' || triggerSurface === 'chat';
3244
+ const isDashboardTurn = eventData?.synthetic === true || !triggerSurface || triggerSurface === 'workspace' || triggerSurface === 'chat';
3241
3245
 
3242
3246
  if (type === 'bot:typing') {
3243
3247
  currentStreamConvId = convId;
Binary file
Binary file
@@ -170,7 +170,7 @@ export const SHELL_HTML = `<!DOCTYPE html>
170
170
  // preload=auto so the clip is fetched (and SW-cached) while the supervisor is
171
171
  // still up — by the time we show this, the origin is unreachable.
172
172
  '<video autoplay loop muted playsinline preload="auto" style="position:relative;width:100%;height:100%;object-fit:contain;border-radius:50%">' +
173
- '<source src="/morphy_sad.mov" type=\'video/mp4; codecs="hvc1"\'><source src="/morphy_sad.webm" type="video/webm">' +
173
+ '<source src="/morphy_sad.webm" type="video/webm"><source src="/morphy_sad.mov" type="video/mp4">' +
174
174
  '</video>' +
175
175
  '</div>' +
176
176
  '<h1 style="font-size:1.5rem;font-weight:700;margin:0 0 .6rem;background:linear-gradient(135deg,#0166FF,#009AFE,#4AEEFF);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">Workspace is restarting&hellip;</h1>' +
@@ -562,7 +562,7 @@
562
562
  '<div style="position:relative;width:160px;height:160px;margin:0 auto 1.2rem">' +
563
563
  '<div style="position:absolute;inset:-18px;background:radial-gradient(circle,rgba(1,102,255,.18) 0%,transparent 60%);filter:blur(18px)"></div>' +
564
564
  '<video autoplay loop muted playsinline style="position:relative;width:100%;height:100%;object-fit:contain;border-radius:50%">' +
565
- '<source src="/morphy_sad.mov" type=\'video/mp4; codecs="hvc1"\'><source src="/morphy_sad.webm" type="video/webm">' +
565
+ '<source src="/morphy_sad.webm" type="video/webm"><source src="/morphy_sad.mov" type="video/mp4">' +
566
566
  '</video>' +
567
567
  '</div>' +
568
568
  '<h1 style="font-size:1.5rem;font-weight:700;margin:0 0 .6rem;background:linear-gradient(135deg,#0166FF,#009AFE,#4AEEFF);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">Workspace error</h1>' +