bloby-bot 0.70.10 → 0.70.12
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 +86 -12
- package/supervisor/harnesses/pi/session.ts +40 -5
- package/supervisor/harnesses/pi/tools/bash.ts +34 -11
- package/supervisor/index.ts +5 -1
- package/supervisor/public/morphy_sad.mov +0 -0
- package/supervisor/public/morphy_sad.webm +0 -0
- 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) {
|
|
@@ -62,6 +62,15 @@ interface LiveConversation {
|
|
|
62
62
|
* after the user hears "Done!", mirroring claude's usedTools capture of
|
|
63
63
|
* sub-agent tool_use blocks. */
|
|
64
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;
|
|
65
74
|
loopDone: Promise<void> | null;
|
|
66
75
|
}
|
|
67
76
|
|
|
@@ -72,9 +81,18 @@ interface RunningTask {
|
|
|
72
81
|
abortController: AbortController;
|
|
73
82
|
/** True when stopped via user:stop-task or conversation teardown. */
|
|
74
83
|
stopped: boolean;
|
|
84
|
+
/** True when the wall-clock task watchdog aborted it. */
|
|
85
|
+
timedOut: boolean;
|
|
75
86
|
startedAt: number;
|
|
76
87
|
}
|
|
77
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
|
+
|
|
78
96
|
const liveConversations = new Map<string, LiveConversation>();
|
|
79
97
|
|
|
80
98
|
/**
|
|
@@ -253,15 +271,18 @@ function resolveAuth(): { ok: true; auth: PiSessionAuth } | { ok: false; error:
|
|
|
253
271
|
// ── Background sub-agents (Phase B — audit D4-1) ───────────────────────────
|
|
254
272
|
|
|
255
273
|
/** Inject a system-originated message into the parent's queue (task completion).
|
|
256
|
-
* Mirrors the Claude SDK's self-prompted continuation turn
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
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). */
|
|
262
282
|
function pushSyntheticMessage(conv: LiveConversation, text: string): void {
|
|
263
283
|
conv.busy = true;
|
|
264
284
|
conv.pendingCount += 1;
|
|
285
|
+
conv.turnOrigins.push('synthetic');
|
|
265
286
|
conv.inputQueue.push({ role: 'user', content: [{ type: 'text', text }] });
|
|
266
287
|
}
|
|
267
288
|
|
|
@@ -310,10 +331,21 @@ function createTaskHost(conv: LiveConversation, getAuth: () => PiSessionAuth): P
|
|
|
310
331
|
subagentType: req.subagentType,
|
|
311
332
|
abortController,
|
|
312
333
|
stopped: false,
|
|
334
|
+
timedOut: false,
|
|
313
335
|
startedAt: Date.now(),
|
|
314
336
|
};
|
|
315
337
|
conv.tasks.set(taskId, task);
|
|
316
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
|
+
|
|
317
349
|
// Honor the agent config's tool restrictions (claude applies these via
|
|
318
350
|
// the SDK's tools/disallowedTools options — e.g. a future researcher
|
|
319
351
|
// agent with disallowedTools: ['Write','Edit']).
|
|
@@ -330,6 +362,13 @@ function createTaskHost(conv: LiveConversation, getAuth: () => PiSessionAuth): P
|
|
|
330
362
|
let errorText = '';
|
|
331
363
|
let usedFileTools = false;
|
|
332
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;
|
|
333
372
|
let lastUsage: { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheCreationTokens?: number } | undefined;
|
|
334
373
|
|
|
335
374
|
const session = createPiSession({
|
|
@@ -361,6 +400,11 @@ function createTaskHost(conv: LiveConversation, getAuth: () => PiSessionAuth): P
|
|
|
361
400
|
case 'turn_complete':
|
|
362
401
|
usedFileTools = usedFileTools || evt.usedFileTools;
|
|
363
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;
|
|
364
408
|
break;
|
|
365
409
|
}
|
|
366
410
|
},
|
|
@@ -388,9 +432,21 @@ function createTaskHost(conv: LiveConversation, getAuth: () => PiSessionAuth): P
|
|
|
388
432
|
} catch (err: any) {
|
|
389
433
|
errorText = errorText || err?.message || String(err);
|
|
390
434
|
} finally {
|
|
435
|
+
clearTimeout(watchdog);
|
|
391
436
|
conv.tasks.delete(taskId);
|
|
392
|
-
|
|
393
|
-
|
|
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
|
+
}
|
|
394
450
|
const u = lastUsage;
|
|
395
451
|
const totalTokens = u
|
|
396
452
|
? (u.inputTokens || 0) + (u.outputTokens || 0) + (u.cacheReadTokens || 0) + (u.cacheCreationTokens || 0)
|
|
@@ -497,11 +553,20 @@ export async function startConversation(
|
|
|
497
553
|
onMessage,
|
|
498
554
|
busy: false,
|
|
499
555
|
pendingCount: 0,
|
|
500
|
-
batcher:
|
|
556
|
+
batcher: null as unknown as TokenBatcher, // set right below (needs conv for the synthetic tag)
|
|
501
557
|
tasks: new Map(),
|
|
502
558
|
taskUsedFileTools: false,
|
|
559
|
+
turnOrigins: [],
|
|
560
|
+
currentTurnSynthetic: false,
|
|
503
561
|
loopDone: null,
|
|
504
562
|
};
|
|
563
|
+
conv.batcher = createTokenBatcher((text) =>
|
|
564
|
+
onMessage('bot:token', {
|
|
565
|
+
conversationId,
|
|
566
|
+
token: text,
|
|
567
|
+
...(conv.currentTurnSynthetic ? { synthetic: true } : {}),
|
|
568
|
+
}),
|
|
569
|
+
);
|
|
505
570
|
liveConversations.set(conversationId, conv);
|
|
506
571
|
|
|
507
572
|
// Re-resolve auth on every provider round so a key/model fix in the wizard
|
|
@@ -560,19 +625,26 @@ function translateAndEmit(conv: LiveConversation, evt: PiSessionEvent) {
|
|
|
560
625
|
// invariant both depend on it.
|
|
561
626
|
conv.batcher.flush();
|
|
562
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
|
+
|
|
563
634
|
switch (evt.type) {
|
|
564
635
|
case 'turn_started':
|
|
636
|
+
conv.currentTurnSynthetic = conv.turnOrigins.shift() === 'synthetic';
|
|
565
637
|
// No bloby event for this — `bot:typing` is already emitted by pushMessage().
|
|
566
638
|
break;
|
|
567
639
|
case 'text_end':
|
|
568
|
-
conv.onMessage('bot:response', { conversationId: conv.id, content: evt.text });
|
|
640
|
+
conv.onMessage('bot:response', { conversationId: conv.id, content: evt.text, ...syn });
|
|
569
641
|
break;
|
|
570
642
|
case 'tool_use': {
|
|
571
643
|
// House vocabulary: claude's delegation tool is named Task; the pi
|
|
572
644
|
// prompt's 'Agent' alias resolves to the same tool — normalize the
|
|
573
645
|
// event so consumers see one name.
|
|
574
646
|
const toolName = evt.name === 'Agent' || evt.name === 'agent' ? 'Task' : evt.name;
|
|
575
|
-
conv.onMessage('bot:tool', { conversationId: conv.id, name: toolName, input: evt.input });
|
|
647
|
+
conv.onMessage('bot:tool', { conversationId: conv.id, name: toolName, input: evt.input, ...syn });
|
|
576
648
|
break;
|
|
577
649
|
}
|
|
578
650
|
case 'tool_result':
|
|
@@ -605,6 +677,7 @@ function translateAndEmit(conv: LiveConversation, evt: PiSessionEvent) {
|
|
|
605
677
|
contextTokens,
|
|
606
678
|
contextWindow: evt.contextWindow || 0,
|
|
607
679
|
idle,
|
|
680
|
+
...syn,
|
|
608
681
|
});
|
|
609
682
|
log.info(`[pi/conversation] ──── TURN COMPLETE ──── busy=false ctx=${contextTokens}/${evt.contextWindow || 'n/a'} idle=${idle} tasks=${conv.tasks.size}`);
|
|
610
683
|
break;
|
|
@@ -619,7 +692,7 @@ function translateAndEmit(conv: LiveConversation, evt: PiSessionEvent) {
|
|
|
619
692
|
: evt.kind === 'auth'
|
|
620
693
|
? ' I\'ll reconnect with the new key as soon as it\'s saved.'
|
|
621
694
|
: '';
|
|
622
|
-
conv.onMessage('bot:error', { conversationId: conv.id, error: `${evt.error}${remedy}
|
|
695
|
+
conv.onMessage('bot:error', { conversationId: conv.id, error: `${evt.error}${remedy}`, ...syn });
|
|
623
696
|
if (fatal) {
|
|
624
697
|
// Unrecoverable for this session (audit D6-4): an over-window history
|
|
625
698
|
// would re-fail on every future turn, and a dead key has no business
|
|
@@ -649,6 +722,7 @@ export function pushMessage(
|
|
|
649
722
|
log.info(`[pi/conversation] ──── PUSH MESSAGE ──── busy=${conv.busy} pending=${conv.pendingCount + 1}`);
|
|
650
723
|
conv.busy = true;
|
|
651
724
|
conv.pendingCount += 1;
|
|
725
|
+
conv.turnOrigins.push('user');
|
|
652
726
|
conv.inputQueue.push(buildUserMessage(content, attachments, savedFiles));
|
|
653
727
|
conv.onMessage('bot:typing', { conversationId });
|
|
654
728
|
return true;
|
|
@@ -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. */
|
|
@@ -230,8 +245,11 @@ export function createPiSession(init: PiSessionInit): PiSession {
|
|
|
230
245
|
let turnErrorKind: PiErrorKind | undefined;
|
|
231
246
|
|
|
232
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;
|
|
233
251
|
for (let round = 0; round < maxRounds; round++) {
|
|
234
|
-
if (init.abortController.signal.aborted) break;
|
|
252
|
+
if (init.abortController.signal.aborted) { roundCapHit = false; break; }
|
|
235
253
|
// The separator condition is decided BEFORE the round so the round can
|
|
236
254
|
// emit it ahead of its first token (claude.ts ordering — see runOneRound).
|
|
237
255
|
const needsSeparator = accumulatedText.length > 0 && !accumulatedText.endsWith('\n');
|
|
@@ -289,6 +307,7 @@ export function createPiSession(init: PiSessionInit): PiSession {
|
|
|
289
307
|
turnErrored = true;
|
|
290
308
|
turnErrorMsg = res.errorMsg;
|
|
291
309
|
turnErrorKind = res.errorKind;
|
|
310
|
+
roundCapHit = false;
|
|
292
311
|
break;
|
|
293
312
|
}
|
|
294
313
|
|
|
@@ -313,7 +332,7 @@ export function createPiSession(init: PiSessionInit): PiSession {
|
|
|
313
332
|
}
|
|
314
333
|
|
|
315
334
|
// No tool calls ⇒ the model is done with this turn.
|
|
316
|
-
if (toolUses.length === 0) break;
|
|
335
|
+
if (toolUses.length === 0) { roundCapHit = false; break; }
|
|
317
336
|
}
|
|
318
337
|
|
|
319
338
|
// Turn-end emission order (audit D6-2, mirrors claude.ts:394-401):
|
|
@@ -337,7 +356,16 @@ export function createPiSession(init: PiSessionInit): PiSession {
|
|
|
337
356
|
init.onEvent({ type: 'error', error: turnErrorMsg || 'Provider turn failed', kind: turnErrorKind });
|
|
338
357
|
}
|
|
339
358
|
const usedFileTools = Array.from(usedTools).some((t) => FILE_TOOL_NAMES.has(t));
|
|
340
|
-
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
|
+
});
|
|
341
369
|
}
|
|
342
370
|
}
|
|
343
371
|
|
|
@@ -354,7 +382,14 @@ export function createPiSession(init: PiSessionInit): PiSession {
|
|
|
354
382
|
// and chat aren't wedged. Skip when aborting (teardown emits conversation-ended).
|
|
355
383
|
// usedFileTools=false is the safe default (it only governs whether to auto-restart now).
|
|
356
384
|
if (!init.abortController.signal.aborted) {
|
|
357
|
-
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
|
+
});
|
|
358
393
|
}
|
|
359
394
|
}
|
|
360
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
|
},
|
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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|