@yeaft/webchat-agent 0.1.1083 → 0.1.1085

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": "@yeaft/webchat-agent",
3
- "version": "0.1.1083",
3
+ "version": "0.1.1085",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/config.js CHANGED
@@ -73,7 +73,7 @@ const DEFAULTS = {
73
73
  baseDelayMs: 1_000,
74
74
  maxDelayMs: 30_000,
75
75
  jitterRatio: 0.25,
76
- streamIdleTimeoutMs: 110_000,
76
+ streamIdleTimeoutMs: 20_000,
77
77
  },
78
78
  };
79
79
 
package/yeaft/engine.js CHANGED
@@ -466,6 +466,50 @@ export class Engine {
466
466
  /** @type {Array<{content:string|Array, preview:string}>} */
467
467
  #pendingUserMessages = [];
468
468
 
469
+ /**
470
+ * Tasks (background bash, sub-agent spawns) that were launched DURING
471
+ * the currently-running query() and have NOT terminated yet. The query
472
+ * loop refuses to finalize end_turn while this set is non-empty — instead
473
+ * it parks on `#asyncTaskWaiters` until a terminal event or a new user
474
+ * append wakes it up. Cleared at the top of each query() and again in
475
+ * the finally block so a stale set never leaks across turns.
476
+ * @type {Set<string>}
477
+ */
478
+ #pendingAsyncTaskIds = new Set();
479
+
480
+ /**
481
+ * Terminal task events that arrived after their producing tool already
482
+ * returned. Each entry becomes a synthetic user message at the next
483
+ * adapter boundary. Format mirrors `#pendingUserMessages` so the same
484
+ * drain path can splice both into `conversationMessages`.
485
+ * @type {Array<{content:string|Array, preview:string, internal:boolean, taskId?:string}>}
486
+ */
487
+ #pendingTaskResultMessages = [];
488
+
489
+ /**
490
+ * Resolvers parked by the main loop while it waits for an async task to
491
+ * terminate (or a fresh user append to arrive). Wake order is FIFO; every
492
+ * resolver is invoked exactly once and the queue cleared, so a single
493
+ * task completion releases every waiter in the same engine and the loop
494
+ * decides on the next iteration whether to keep waiting.
495
+ * @type {Array<() => void>}
496
+ */
497
+ #asyncTaskWaiters = [];
498
+
499
+ /**
500
+ * External coordinator hooks. `getOrCreateVpEngine` (web-bridge.js)
501
+ * installs these so the bridge can route a `taskManager` `completed`
502
+ * event back to THIS engine while it's still running its query() — same
503
+ * turn, next adapter loop. Unset (null) in non-bridge contexts (tests,
504
+ * sub-agents without a bridge) — the engine then degrades to "no
505
+ * coordinator", which still works because tools call back through
506
+ * `toolCtx.registerAsyncTask` and the engine waits locally; web-bridge
507
+ * fallback (legacy `scheduleTaskResultReentry` → new turn) handles the
508
+ * post-run case.
509
+ * @type {{ onRegister?: (taskId:string, engine:Engine) => void, onUnregister?: (taskId:string) => void } | null}
510
+ */
511
+ #asyncTaskCoordinator = null;
512
+
469
513
  /**
470
514
  * Per-group "adjust has run at least once this engine lifetime" flag.
471
515
  * Keyed by sessionId (or 'default'). The first turn always runs adjust;
@@ -1042,6 +1086,13 @@ export class Engine {
1042
1086
  // can mark "after this batch, end the turn — do NOT call adapter
1043
1087
  // again". Honored at the top of the tool-loop continuation.
1044
1088
  requestEndTurn: vpCtx?.requestEndTurn,
1089
+ // Background-task ownership hook. Tools that produce a TaskManager
1090
+ // task (bash background, agent spawn) call this with the new
1091
+ // `task.id` so the engine keeps the current query parked at end_turn
1092
+ // until the task terminates — its result is then spliced into the
1093
+ // next adapter loop in the SAME turn. Tools that don't produce
1094
+ // async tasks ignore it.
1095
+ registerAsyncTask: (taskId) => this.#registerAsyncTask(taskId),
1045
1096
  // Sub-agent plumbing — Agent tool needs these to spawn a child
1046
1097
  // Engine that inherits the parent's adapter / stores / toolset.
1047
1098
  parentEngineDeps: {
@@ -1065,6 +1116,12 @@ export class Engine {
1065
1116
  // to. Null when the parent has no stats wired (e.g. tests).
1066
1117
  toolStats: this.#toolStats || null,
1067
1118
  taskManager: this.#taskManager || null,
1119
+ // Propagate the async-task coordinator so sub-agents launched
1120
+ // from this engine register their background tasks against the
1121
+ // SAME owner map the bridge uses. Without this, a sub-agent's
1122
+ // background bash terminal event would not find its engine and
1123
+ // would fall through to the legacy rescue path.
1124
+ asyncTaskCoordinator: this.#asyncTaskCoordinator || null,
1068
1125
  },
1069
1126
  };
1070
1127
  }
@@ -1394,16 +1451,28 @@ export class Engine {
1394
1451
  if (this.#pendingUserMessages.length > 0) {
1395
1452
  pending.push(...this.#pendingUserMessages.splice(0));
1396
1453
  }
1454
+ // Task-result re-entries flow through the same drain so the main loop
1455
+ // sees ONE append queue. Each entry carries `internal: true` so the
1456
+ // `user_append` event downstream is tagged correctly (UI hides the
1457
+ // bubble; persistence stamps role=assistant).
1458
+ if (this.#pendingTaskResultMessages.length > 0) {
1459
+ pending.push(...this.#pendingTaskResultMessages.splice(0));
1460
+ }
1397
1461
  return pending
1398
1462
  .map((item) => {
1399
- if (typeof item === 'string') return { content: item, preview: item };
1463
+ if (typeof item === 'string') return { content: item, preview: item, internal: false };
1400
1464
  if (!item || typeof item !== 'object') return null;
1401
1465
  const content = item.content ?? item.text;
1402
1466
  if (typeof content !== 'string' && !Array.isArray(content)) return null;
1403
1467
  const preview = typeof item.preview === 'string'
1404
1468
  ? item.preview
1405
1469
  : (typeof content === 'string' ? content : '[content blocks]');
1406
- return { content, preview };
1470
+ return {
1471
+ content,
1472
+ preview,
1473
+ internal: Boolean(item.internal),
1474
+ taskId: typeof item.taskId === 'string' ? item.taskId : undefined,
1475
+ };
1407
1476
  })
1408
1477
  .filter(Boolean);
1409
1478
  }
@@ -1512,6 +1581,27 @@ export class Engine {
1512
1581
  this.#abortReason = null;
1513
1582
  this.#currentThreadId = MAIN_THREAD_ID;
1514
1583
  this.#pendingUserMessages.length = 0;
1584
+ // Hand back any async tasks still on the books to the coordinator so
1585
+ // a late terminal event falls through to the legacy rescue path
1586
+ // (new turn) instead of being silently swallowed. The coordinator
1587
+ // is responsible for keeping its owner map in sync.
1588
+ if (this.#pendingAsyncTaskIds.size > 0) {
1589
+ const leftover = Array.from(this.#pendingAsyncTaskIds);
1590
+ this.#pendingAsyncTaskIds.clear();
1591
+ for (const tid of leftover) {
1592
+ try { this.#asyncTaskCoordinator?.onUnregister?.(tid); } catch { /* ignore */ }
1593
+ }
1594
+ }
1595
+ this.#pendingTaskResultMessages.length = 0;
1596
+ // Release any parked waiters so they don't pin a microtask after
1597
+ // query() returns. The loop has already exited so they're harmless,
1598
+ // but cleanup keeps the promise graph tight.
1599
+ if (this.#asyncTaskWaiters.length > 0) {
1600
+ const waiters = this.#asyncTaskWaiters.splice(0);
1601
+ for (const r of waiters) {
1602
+ try { r(); } catch { /* ignore */ }
1603
+ }
1604
+ }
1515
1605
  }
1516
1606
  }
1517
1607
 
@@ -2460,6 +2550,80 @@ export class Engine {
2460
2550
  continue;
2461
2551
  }
2462
2552
 
2553
+ // If no tool calls, we're done — UNLESS we still own a pending
2554
+ // async task. The user-facing semantic: a turn that launched a
2555
+ // background bash / sub-agent stays "live" until those tasks
2556
+ // terminate (or the user appends, or abort). The model already
2557
+ // said end_turn; we just defer finalization by parking on the
2558
+ // wait queue, then splice the synthetic task-result message in
2559
+ // and run one more adapter loop. This matches the contract the
2560
+ // bridge documents for `formatTaskResultForVp`: "Consume it now:
2561
+ // tell the user the outcome or continue the work. Do not wait
2562
+ // for another user turn."
2563
+ if ((stopReason !== 'tool_use' || toolCalls.length === 0)
2564
+ && this.#pendingAsyncTaskIds.size > 0
2565
+ && !signal?.aborted) {
2566
+ // Drop into a wait loop. The loop wakes on (a) any task
2567
+ // terminal event delivered via `notifyAsyncTaskCompleted`, (b)
2568
+ // a fresh user append (which is honored as a higher priority
2569
+ // user input), or (c) abort. On wake we re-check: if either
2570
+ // queue has content, drain + splice + continue the outer loop.
2571
+ // If both queues are empty AND we still have pending tasks AND
2572
+ // we're not aborted, we just keep waiting. This is the only
2573
+ // place query() can block on something other than the LLM stream.
2574
+ yield {
2575
+ type: 'async_task_wait_start',
2576
+ turnId: queryTurnId,
2577
+ loopNumber: turnNumber,
2578
+ threadId,
2579
+ pendingTaskIds: Array.from(this.#pendingAsyncTaskIds),
2580
+ };
2581
+ // Race-safe wait: a `notifyAsyncTaskCompleted` callback that
2582
+ // arrives synchronously between `yield` and the `await` below
2583
+ // (e.g. the bridge handles a task event in the same microtask
2584
+ // as the wait_start event) will already have populated the
2585
+ // queues, so #waitForAsyncWake resolves immediately on its
2586
+ // fast-path check. Drain BEFORE deciding to continue so we
2587
+ // never "fall through" with content sitting in either queue.
2588
+ while (!signal?.aborted
2589
+ && this.#pendingTaskResultMessages.length === 0
2590
+ && this.#pendingUserMessages.length === 0) {
2591
+ if (this.#pendingAsyncTaskIds.size === 0) break;
2592
+ await this.#waitForAsyncWake(signal);
2593
+ }
2594
+ yield {
2595
+ type: 'async_task_wait_end',
2596
+ turnId: queryTurnId,
2597
+ loopNumber: turnNumber,
2598
+ threadId,
2599
+ aborted: Boolean(signal?.aborted),
2600
+ remainingTaskIds: Array.from(this.#pendingAsyncTaskIds),
2601
+ };
2602
+ if (!signal?.aborted) {
2603
+ const appendedAfterAsyncWait = this.#drainPendingUserMessages(drainPendingUserMessages);
2604
+ if (appendedAfterAsyncWait.length > 0) {
2605
+ for (const item of appendedAfterAsyncWait) {
2606
+ conversationMessages.push({ role: 'user', content: item.content });
2607
+ yield {
2608
+ type: 'user_append',
2609
+ turnId: queryTurnId,
2610
+ loopNumber: turnNumber,
2611
+ threadId,
2612
+ preview: String(item.preview || '').slice(0, 200),
2613
+ internal: Boolean(item.internal),
2614
+ taskId: typeof item.taskId === 'string' ? item.taskId : undefined,
2615
+ };
2616
+ }
2617
+ yield { type: 'turn_end', turnNumber, stopReason: 'async_task_continue', threadId };
2618
+ continue;
2619
+ }
2620
+ }
2621
+ // If we fall through here, either abort fired or the wait
2622
+ // exited with no payload (all tasks released themselves via
2623
+ // engine teardown / unregister with no notification). Drop into
2624
+ // the regular end_turn path.
2625
+ }
2626
+
2463
2627
  // If no tool calls, we're done
2464
2628
  if (stopReason !== 'tool_use' || toolCalls.length === 0) {
2465
2629
  if (pendingSubAgentNotifs.length > 0) {
@@ -3109,9 +3273,134 @@ export class Engine {
3109
3273
  if (typeof content === 'string' && !content.trim()) return false;
3110
3274
  const preview = typeof content === 'string' ? content : '[content blocks]';
3111
3275
  this.#pendingUserMessages.push({ content, preview });
3276
+ // Wake the async-task wait loop too. A user typing while the engine
3277
+ // is parked on a background task should release the loop immediately
3278
+ // so the user's words get spliced into the next iteration.
3279
+ this.#wakeAsyncTaskWaiters();
3280
+ return true;
3281
+ }
3282
+
3283
+ /**
3284
+ * Install the coordinator that lets an external dispatcher (web-bridge)
3285
+ * route a background task's terminal event back to THIS engine while
3286
+ * its query() is still running. Pass `null` to detach.
3287
+ * @param {{ onRegister?: (taskId:string, engine:Engine) => void, onUnregister?: (taskId:string) => void } | null} coord
3288
+ */
3289
+ setAsyncTaskCoordinator(coord) {
3290
+ this.#asyncTaskCoordinator = (coord && typeof coord === 'object') ? coord : null;
3291
+ }
3292
+
3293
+ /**
3294
+ * True iff the currently-running query is holding pending async tasks.
3295
+ * Used by external probes (web-bridge fallback dispatcher) to decide
3296
+ * whether a task terminal event should be injected into the same turn
3297
+ * or fall back to the legacy "open a new turn" rescue path.
3298
+ * @returns {boolean}
3299
+ */
3300
+ hasPendingAsyncTasks() {
3301
+ return this.#pendingAsyncTaskIds.size > 0;
3302
+ }
3303
+
3304
+ /**
3305
+ * True iff THIS engine has registered the given taskId as belonging to
3306
+ * the currently-running query. Web-bridge calls this before
3307
+ * `notifyAsyncTaskCompleted` so a terminal event for a task that wasn't
3308
+ * launched from this turn (or whose engine already finished) falls
3309
+ * through to the legacy rescue path.
3310
+ * @param {string} taskId
3311
+ * @returns {boolean}
3312
+ */
3313
+ ownsPendingAsyncTask(taskId) {
3314
+ return typeof taskId === 'string' && this.#pendingAsyncTaskIds.has(taskId);
3315
+ }
3316
+
3317
+ /**
3318
+ * Deliver a terminal task event into the currently-running query. Drops
3319
+ * the task from the pending set, queues a synthetic user message with
3320
+ * the rendered task result, and wakes the wait loop. Returns true iff
3321
+ * the engine accepted ownership (the bridge should NOT fall back to the
3322
+ * legacy rescue path). Returns false when the engine was never holding
3323
+ * this taskId — caller should fall through to its rescue path.
3324
+ *
3325
+ * @param {string} taskId
3326
+ * @param {string|Array} content — pre-formatted task result body
3327
+ * @param {{ preview?: string }} [opts]
3328
+ * @returns {boolean}
3329
+ */
3330
+ notifyAsyncTaskCompleted(taskId, content, opts = {}) {
3331
+ if (!this.ownsPendingAsyncTask(taskId)) return false;
3332
+ if (typeof content !== 'string' && !Array.isArray(content)) return false;
3333
+ if (typeof content === 'string' && !content.trim()) return false;
3334
+ // Defensive: an empty content-block array would splice as a wire-valid
3335
+ // but semantically empty user message, which the adapter would happily
3336
+ // forward and bill for. Production callers (formatTaskResultForVp)
3337
+ // always emit a non-empty string today; this guards future refactors.
3338
+ if (Array.isArray(content) && content.length === 0) return false;
3339
+ this.#pendingAsyncTaskIds.delete(taskId);
3340
+ try { this.#asyncTaskCoordinator?.onUnregister?.(taskId); } catch { /* coord must not throw into engine */ }
3341
+ const preview = typeof opts.preview === 'string'
3342
+ ? opts.preview
3343
+ : (typeof content === 'string' ? content.slice(0, 200) : '[task result]');
3344
+ this.#pendingTaskResultMessages.push({
3345
+ content,
3346
+ preview,
3347
+ internal: true,
3348
+ taskId,
3349
+ });
3350
+ this.#wakeAsyncTaskWaiters();
3112
3351
  return true;
3113
3352
  }
3114
3353
 
3354
+ /**
3355
+ * Register a background task as belonging to the current query. Called
3356
+ * from tools (bash background, agent spawn) via `toolCtx.registerAsyncTask`.
3357
+ * @param {string} taskId
3358
+ * @returns {void}
3359
+ */
3360
+ #registerAsyncTask(taskId) {
3361
+ if (typeof taskId !== 'string' || !taskId) return;
3362
+ this.#pendingAsyncTaskIds.add(taskId);
3363
+ try { this.#asyncTaskCoordinator?.onRegister?.(taskId, this); } catch { /* coord must not throw into tools */ }
3364
+ }
3365
+
3366
+ #wakeAsyncTaskWaiters() {
3367
+ if (this.#asyncTaskWaiters.length === 0) return;
3368
+ const waiters = this.#asyncTaskWaiters.splice(0);
3369
+ for (const resolve of waiters) {
3370
+ try { resolve(); } catch { /* never break the loop on a stray callback */ }
3371
+ }
3372
+ }
3373
+
3374
+ /**
3375
+ * Wait for *any* of: an async task terminal event, a fresh user append,
3376
+ * or signal abort. Resolves immediately if any of those is already
3377
+ * pending. The loop re-evaluates conditions on wake — multiple
3378
+ * concurrent tasks all wake the same waiter once, then the loop drains
3379
+ * everything in one iteration and decides whether to keep waiting.
3380
+ * @param {AbortSignal|null|undefined} signal
3381
+ * @returns {Promise<void>}
3382
+ */
3383
+ #waitForAsyncWake(signal) {
3384
+ return new Promise((resolve) => {
3385
+ // Fast paths — anything already pending releases instantly. This is
3386
+ // the common case when a task finished between adapter loops.
3387
+ if (this.#pendingTaskResultMessages.length > 0) return resolve();
3388
+ if (this.#pendingUserMessages.length > 0) return resolve();
3389
+ if (signal?.aborted) return resolve();
3390
+ this.#asyncTaskWaiters.push(resolve);
3391
+ if (signal && typeof signal.addEventListener === 'function') {
3392
+ // Best-effort abort wakeup; the loop re-checks signal.aborted.
3393
+ const onAbort = () => {
3394
+ // Splice the resolver out of the wait queue so it can't double-fire.
3395
+ const idx = this.#asyncTaskWaiters.indexOf(resolve);
3396
+ if (idx >= 0) this.#asyncTaskWaiters.splice(idx, 1);
3397
+ try { resolve(); } catch { /* ignore */ }
3398
+ };
3399
+ try { signal.addEventListener('abort', onAbort, { once: true }); } catch { /* old runtimes */ }
3400
+ }
3401
+ });
3402
+ }
3403
+
3115
3404
  /** @returns {string|null} */
3116
3405
  get yeaftDir() { return this.#yeaftDir; }
3117
3406
 
@@ -156,6 +156,12 @@ export function startSubAgent(agent, deps = {}) {
156
156
  toolStats: deps.toolStats || null,
157
157
  taskManager: deps.taskManager || null,
158
158
  });
159
+ // Same-turn async-task plumbing: inherit the parent's coordinator so a
160
+ // background bash launched FROM this sub-agent registers itself against
161
+ // the shared owner map and its terminal event reaches the sub-engine.
162
+ if (deps.asyncTaskCoordinator && typeof subEngine.setAsyncTaskCoordinator === 'function') {
163
+ subEngine.setAsyncTaskCoordinator(deps.asyncTaskCoordinator);
164
+ }
159
165
 
160
166
  agent.subEngine = subEngine;
161
167
  agent.engineMessages = agent.engineMessages || [];
@@ -427,7 +427,14 @@ use it as the default workflow or call it repeatedly in a loop.`,
427
427
  runtime: { subAgentId: agentId, name, cwd: agent.cwd },
428
428
  source: { threadId: callerScope.parentThreadId || ctx?.threadId || 'main' },
429
429
  });
430
- if (task?.id) agent.taskId = task.id;
430
+ if (task?.id) {
431
+ agent.taskId = task.id;
432
+ // Same-turn parking — see tools/bash.js for the contract. The
433
+ // sub-agent runs in its own engine but reports completion
434
+ // through the same TaskManager event, so the spawning turn
435
+ // stays parked until the sub-agent finishes.
436
+ try { ctx.registerAsyncTask?.(task.id); } catch { /* coord errors must not block spawn */ }
437
+ }
431
438
  startSubAgent(agent, deps);
432
439
  } catch (err) {
433
440
  agent.status = STATUS.FAILED;
@@ -246,6 +246,12 @@ Guidelines:
246
246
  threadId: ctx.threadId || 'main',
247
247
  },
248
248
  });
249
+ // Same-turn parking: tell the engine "this turn has an async
250
+ // task in flight". The engine refuses to finalize end_turn
251
+ // while the set is non-empty and will splice the task result
252
+ // into the next adapter loop when it terminates. No-op when
253
+ // the engine didn't wire the hook (legacy callers / tests).
254
+ try { ctx.registerAsyncTask?.(task.id); } catch { /* never block tool return on coord errors */ }
249
255
  return `Started background task ${task.id}.\nStatus: ${task.status}\nLog: ${task.log?.path || ''}\nUse ListTasks, ReadTaskLog, or CancelTask to inspect or control it.`;
250
256
  } catch (err) {
251
257
  return JSON.stringify({ error: err?.message || String(err) });
@@ -258,6 +258,42 @@ const vpDrivers = new Map();
258
258
  const vpEngines = new Map();
259
259
  /** @type {Map<string, AbortController>} */
260
260
  const vpAborts = new Map();
261
+
262
+ /**
263
+ * Owner index for background tasks currently parked on a running engine.
264
+ * Populated by the per-engine async-task coordinator at register time;
265
+ * cleared at notify time or when the engine teardown unregisters whatever
266
+ * it didn't get to deliver. Used by `scheduleTaskResultReentry` to pick
267
+ * "same-turn injection" over the legacy "new turn" rescue path when the
268
+ * engine is still live.
269
+ * @type {Map<string, import('./engine.js').Engine>}
270
+ */
271
+ const asyncTaskOwners = new Map();
272
+
273
+ /**
274
+ * Build a coordinator for a freshly-constructed engine. The coordinator
275
+ * keeps `asyncTaskOwners` in sync so a `taskManager` `completed` event
276
+ * can find the engine that launched it in O(1).
277
+ *
278
+ * Defined as a factory (not a single shared object) so each engine's
279
+ * `onRegister` callback closes over its own engine reference — sub-agents
280
+ * inherit a coordinator that still associates their tasks with the
281
+ * sub-engine, not the parent.
282
+ *
283
+ * @returns {{ onRegister: (taskId: string, engine: import('./engine.js').Engine) => void, onUnregister: (taskId: string) => void }}
284
+ */
285
+ function buildAsyncTaskCoordinator() {
286
+ return {
287
+ onRegister(taskId, engine) {
288
+ if (typeof taskId !== 'string' || !taskId) return;
289
+ asyncTaskOwners.set(taskId, engine);
290
+ },
291
+ onUnregister(taskId) {
292
+ if (typeof taskId !== 'string' || !taskId) return;
293
+ asyncTaskOwners.delete(taskId);
294
+ },
295
+ };
296
+ }
261
297
  /**
262
298
  * Per-(sessionId, vpId) current TodoWrite list. Each VP in a group keeps
263
299
  * its own todo state so two VPs in the same group can independently
@@ -956,6 +992,15 @@ function getOrCreateVpEngine(sessionId, vpId, threadId = 'main') {
956
992
  sessionId,
957
993
  vpId,
958
994
  });
995
+ // Install the async-task coordinator so background tasks launched
996
+ // from this engine register against the shared owner map and
997
+ // `scheduleTaskResultReentry` can deliver terminal events back into
998
+ // the same query() instead of opening a fresh turn.
999
+ try {
1000
+ if (typeof eng.setAsyncTaskCoordinator === 'function') {
1001
+ eng.setAsyncTaskCoordinator(buildAsyncTaskCoordinator());
1002
+ }
1003
+ } catch { /* coordinator is best-effort plumbing, never block engine creation */ }
959
1004
  vpEngines.set(key, eng);
960
1005
  return eng;
961
1006
  }
@@ -1053,6 +1098,36 @@ function scheduleTaskResultReentry(event) {
1053
1098
  const vpId = task.ownerVpId || null;
1054
1099
  if (!sessionId || !vpId) return;
1055
1100
  const threadId = task.source?.threadId || task.runtime?.threadId || 'main';
1101
+ const formatted = formatTaskResultForVp(task);
1102
+
1103
+ // Same-turn fast path: when the engine that launched this task is
1104
+ // still running its query() AND has parked on the wait queue, hand
1105
+ // the result straight in — it splices into the very next adapter
1106
+ // loop with no new turn / new VP envelope. Falls through to the
1107
+ // legacy "open a new turn" rescue path when:
1108
+ // - the engine already finished (typical orphan / late completion),
1109
+ // - the engine was torn down between register and complete, or
1110
+ // - the task wasn't registered with the engine in the first place
1111
+ // (e.g. legacy `taskManager.startTask` callers that bypass tools).
1112
+ const ownerEngine = asyncTaskOwners.get(task.id);
1113
+ if (ownerEngine
1114
+ && typeof ownerEngine.ownsPendingAsyncTask === 'function'
1115
+ && ownerEngine.ownsPendingAsyncTask(task.id)
1116
+ && typeof ownerEngine.notifyAsyncTaskCompleted === 'function') {
1117
+ try {
1118
+ const accepted = ownerEngine.notifyAsyncTaskCompleted(task.id, formatted, {
1119
+ preview: `task ${task.kind || 'tool'} ${task.status}`,
1120
+ });
1121
+ if (accepted) {
1122
+ asyncTaskOwners.delete(task.id);
1123
+ return;
1124
+ }
1125
+ } catch {
1126
+ // Same-turn delivery is best-effort. Fall through to the legacy
1127
+ // rescue path so we never drop a terminal event on the floor.
1128
+ }
1129
+ }
1130
+
1056
1131
  const msgId = `task_result_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
1057
1132
  queueMicrotask(() => {
1058
1133
  enqueueForVp(sessionId, vpId, {
@@ -1064,7 +1139,7 @@ function scheduleTaskResultReentry(event) {
1064
1139
  id: msgId,
1065
1140
  from: 'tool',
1066
1141
  role: 'assistant',
1067
- text: formatTaskResultForVp(task),
1142
+ text: formatted,
1068
1143
  meta: {
1069
1144
  injectedBy: 'task_result',
1070
1145
  taskId: task.id,
@@ -1399,6 +1474,7 @@ export async function __testResetVpState() {
1399
1474
  vpInboxes.clear();
1400
1475
  vpDrivers.clear();
1401
1476
  vpEngines.clear();
1477
+ asyncTaskOwners.clear();
1402
1478
  vpAborts.clear();
1403
1479
  sessionContexts.clear();
1404
1480
  vpCurrentTodos.clear();
@@ -2456,6 +2532,37 @@ function handleEngineEvent(event, hctx) {
2456
2532
  }, envelope);
2457
2533
  break;
2458
2534
 
2535
+ // Same-turn async-task wait. Engine parks at end_turn while a
2536
+ // background bash / sub-agent is still running and re-enters the
2537
+ // same turn when the terminal event arrives (see engine.js
2538
+ // `#runQuery` wait block). Bridge forwards both edges so the debug
2539
+ // panel (and any other in-process subscriber) can render the park
2540
+ // window with the live list of pending taskIds. Wire types stay
2541
+ // namespaced under `vp_async_task_*` to match the existing
2542
+ // `vp_thread_*` / `vp_typing_*` event family.
2543
+ case 'async_task_wait_start':
2544
+ sendSessionEvent({
2545
+ type: 'vp_async_task_wait_start',
2546
+ turnId: event.turnId,
2547
+ threadId: event.threadId,
2548
+ loopNumber: event.loopNumber,
2549
+ pendingTaskIds: Array.isArray(event.pendingTaskIds) ? event.pendingTaskIds : [],
2550
+ ts: Date.now(),
2551
+ }, envelope);
2552
+ break;
2553
+
2554
+ case 'async_task_wait_end':
2555
+ sendSessionEvent({
2556
+ type: 'vp_async_task_wait_end',
2557
+ turnId: event.turnId,
2558
+ threadId: event.threadId,
2559
+ loopNumber: event.loopNumber,
2560
+ aborted: Boolean(event.aborted),
2561
+ remainingTaskIds: Array.isArray(event.remainingTaskIds) ? event.remainingTaskIds : [],
2562
+ ts: Date.now(),
2563
+ }, envelope);
2564
+ break;
2565
+
2459
2566
  case 'loop':
2460
2567
  // feat-6af5f9f1 PR B: replaces the old `debug_turn` event. Same
2461
2568
  // payload shape plus turnId + loopNumber + usage.totalTokens.
@@ -4160,6 +4267,7 @@ export function handleYeaftModelSwitch(msg) {
4160
4267
  // Engines would otherwise keep the old effective config and drop newly
4161
4268
  // selected effort values until process restart.
4162
4269
  vpEngines.clear();
4270
+ asyncTaskOwners.clear();
4163
4271
 
4164
4272
  sendSessionEvent({
4165
4273
  type: 'model_switched',
@@ -4501,6 +4609,7 @@ export async function resetYeaftSession() {
4501
4609
  vpInboxes.clear();
4502
4610
  vpDrivers.clear();
4503
4611
  vpEngines.clear();
4612
+ asyncTaskOwners.clear();
4504
4613
  sessionContexts.clear();
4505
4614
  vpCurrentTodos.clear();
4506
4615
  threadClassifier = defaultClassifyThread;