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 +1 -1
- package/supervisor/channels/manager.ts +10 -0
- package/supervisor/harnesses/pi/index.ts +324 -15
- package/supervisor/harnesses/pi/session.ts +55 -8
- package/supervisor/harnesses/pi/tools/bash.ts +34 -11
- package/supervisor/harnesses/pi/tools/registry.ts +25 -9
- package/supervisor/harnesses/pi/tools/task.ts +108 -0
- package/supervisor/harnesses/pi/tools/types.ts +15 -0
- package/supervisor/index.ts +5 -1
- package/supervisor/public/morphy_sad.mov +0 -0
- package/supervisor/public/morphy_sad.webm +0 -0
- package/supervisor/shell.ts +1 -1
- package/supervisor/workspace-guard.js +1 -1
- package/workspace/client/public/morphy_bounce.mov +0 -0
- package/workspace/client/public/morphy_bounce.webm +0 -0
- package/workspace/client/public/morphy_hi.mov +0 -0
- package/workspace/client/public/morphy_hi.webm +0 -0
package/package.json
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
459
|
-
*
|
|
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())
|
|
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
|
-
/**
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
| {
|
|
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
|
-
|
|
222
|
-
|
|
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({
|
|
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({
|
|
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
|
-
|
|
82
|
+
killTree();
|
|
69
83
|
}, timeout);
|
|
70
84
|
|
|
71
85
|
const onAbort = () => {
|
|
72
|
-
|
|
86
|
+
killTree();
|
|
73
87
|
};
|
|
74
88
|
ctx.signal?.addEventListener('abort', onAbort);
|
|
75
89
|
|
|
76
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 {
|
package/supervisor/index.ts
CHANGED
|
@@ -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
|
package/supervisor/shell.ts
CHANGED
|
@@ -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.
|
|
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…</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.
|
|
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>' +
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|