@te-river/opencode-team-mode 1.8.4 → 1.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +9 -8
  2. package/README.zh-CN.md +10 -9
  3. package/dist/agents.d.ts.map +1 -1
  4. package/dist/agents.js +14 -5
  5. package/dist/agents.js.map +1 -1
  6. package/dist/host/v2-types.d.ts +0 -1
  7. package/dist/host/v2-types.d.ts.map +1 -1
  8. package/dist/host/v2.d.ts +9 -5
  9. package/dist/host/v2.d.ts.map +1 -1
  10. package/dist/host/v2.js +84 -72
  11. package/dist/host/v2.js.map +1 -1
  12. package/dist/index.d.ts +10 -6
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +10 -6
  15. package/dist/index.js.map +1 -1
  16. package/dist/prompts/lead.d.ts +1 -1
  17. package/dist/prompts/lead.d.ts.map +1 -1
  18. package/dist/prompts/lead.js +7 -0
  19. package/dist/prompts/lead.js.map +1 -1
  20. package/dist/prompts/shared.d.ts +1 -1
  21. package/dist/prompts/shared.d.ts.map +1 -1
  22. package/dist/prompts/shared.js +10 -0
  23. package/dist/prompts/shared.js.map +1 -1
  24. package/dist/tm/config-files.d.ts +30 -6
  25. package/dist/tm/config-files.d.ts.map +1 -1
  26. package/dist/tm/config-files.js +47 -10
  27. package/dist/tm/config-files.js.map +1 -1
  28. package/dist/tm/config-layers.d.ts +20 -9
  29. package/dist/tm/config-layers.d.ts.map +1 -1
  30. package/dist/tm/config-layers.js +19 -8
  31. package/dist/tm/config-layers.js.map +1 -1
  32. package/dist/tm/config-template.js +2 -2
  33. package/dist/tm/config-template.js.map +1 -1
  34. package/dist/tm/dispatch.d.ts +90 -11
  35. package/dist/tm/dispatch.d.ts.map +1 -1
  36. package/dist/tm/dispatch.js +490 -52
  37. package/dist/tm/dispatch.js.map +1 -1
  38. package/dist/tm/pipelines.d.ts.map +1 -1
  39. package/dist/tm/pipelines.js +6 -3
  40. package/dist/tm/pipelines.js.map +1 -1
  41. package/dist/tm/result.d.ts +13 -1
  42. package/dist/tm/result.d.ts.map +1 -1
  43. package/dist/tm/result.js +14 -2
  44. package/dist/tm/result.js.map +1 -1
  45. package/docs/installation-v2.md +28 -24
  46. package/package.json +1 -1
@@ -40,6 +40,22 @@
40
40
  * already calls. What the host does NOT give is tool-level cancellation — the
41
41
  * only thing stoppable is the whole active execution (`interrupt`) — so nothing
42
42
  * here pretends that granularity exists.
43
+ *
44
+ * Waking (2026-10-23, measured in another project): steering a session that has
45
+ * already settled is not an interjection, it is a RESURRECTION — the host's own
46
+ * sentence is "Steering wakes session execution", so the line starts a second run
47
+ * that overlaps whatever the lead dispatched meanwhile (two processes on one
48
+ * device, in the incident that produced this). A steer therefore refuses unless
49
+ * the registry says `running`, and the explicit `wake:true` opt-in is the only way
50
+ * to deliver into a settled/unknown child. The same incident showed the mirror
51
+ * bug on the stop path: a settled row made `cancel:true` answer "没有运行中的
52
+ * 子代理可停" WITHOUT asking the host, while the woken child was in fact running.
53
+ * An explicitly named id is always asked of the host now.
54
+ *
55
+ * The body fallback has one more source: a settled host sub-agent's report also
56
+ * lives in the PARENT transcript as `<subagent sessionID="…">…</subagent>`; when
57
+ * the child session's context yields no assistant text, that envelope is read
58
+ * (exact id match, last one wins) instead of reporting `child_body_missing`.
43
59
  */
44
60
  import { sameAgent } from "../identity.js";
45
61
  import { tmError, toToolResult } from "./result.js";
@@ -96,6 +112,34 @@ export function sessionApiOf(client) {
96
112
  return null;
97
113
  return api;
98
114
  }
115
+ export async function readHostSessionState(api, sessionID) {
116
+ const id = String(sessionID ?? "").trim();
117
+ if (!api || typeof api.get !== "function" || !id)
118
+ return { answered: false };
119
+ try {
120
+ // The flat `{sessionID}` spelling is the measured one; the v2 bridge also retries the
121
+ // older `{path:{id}}` shape, so either call convention still lands.
122
+ const un = unwrapClientResult(await api.get({ sessionID: id }));
123
+ if (!un.ok)
124
+ return { answered: false };
125
+ const d = un.data;
126
+ if (!d || typeof d !== "object")
127
+ return { answered: false };
128
+ const createdAt = typeof d.time?.created === "number" && d.time.created > 0 ? d.time.created : undefined;
129
+ const idleAt = typeof d.time?.idle === "number" && d.time.idle > 0 ? d.time.idle : undefined;
130
+ const outcome = typeof d.outcome === "string" && d.outcome.trim() ? d.outcome.trim() : undefined;
131
+ return {
132
+ answered: true,
133
+ ...(createdAt ? { createdAt } : {}),
134
+ ...(idleAt ? { idleAt } : {}),
135
+ ...(outcome ? { outcome } : {}),
136
+ };
137
+ }
138
+ catch {
139
+ // A throwing seam is "we could not ask" — never "it settled".
140
+ return { answered: false };
141
+ }
142
+ }
99
143
  /** The host's OWN visible-and-non-blocking sub-agent path: its built-in `task`
100
144
  * takes `background: true`, and because that card is rendered by the host's
101
145
  * own `task` renderer it links to the live child session — which a plugin tool
@@ -225,6 +269,79 @@ export function lastAssistantMessage(messages) {
225
269
  export function lastAssistantText(messages) {
226
270
  return lastAssistantMessage(messages).text;
227
271
  }
272
+ /** Every non-empty text body a transcript carries, in message order.
273
+ * Deliberately NOT filtered by role: the host's completion envelope arrives as an
274
+ * ordinary (user-role) message, and dropping it here would make the fallback below
275
+ * structurally dead. */
276
+ function transcriptTexts(messages) {
277
+ const list = Array.isArray(messages) ? messages : [];
278
+ const out = [];
279
+ for (const entry of list) {
280
+ const e = entry;
281
+ if (Array.isArray(e?.parts)) {
282
+ const t = e.parts
283
+ .filter((p) => {
284
+ const pp = p;
285
+ return pp && pp.type === "text" && typeof pp.text === "string";
286
+ })
287
+ .map((p) => String(p.text))
288
+ .join("\n");
289
+ if (t.trim())
290
+ out.push(t);
291
+ continue;
292
+ }
293
+ if (e?.type === "text" && typeof e.text === "string" && e.text.trim())
294
+ out.push(e.text);
295
+ }
296
+ return out;
297
+ }
298
+ /** The body of the LAST `<subagent sessionID="<sid>" …>…</subagent>` envelope in one
299
+ * text, or null. The id must match EXACTLY — an envelope naming another child is
300
+ * never adopted, because the lead naming a session must not receive someone else's
301
+ * report (the same rule `parentageOf` applies to steering). */
302
+ function lastEnvelopeFor(text, sessionID) {
303
+ let from = 0;
304
+ let found = null;
305
+ for (;;) {
306
+ const open = text.indexOf("<subagent", from);
307
+ if (open < 0)
308
+ break;
309
+ const gt = text.indexOf(">", open);
310
+ if (gt < 0)
311
+ break;
312
+ const tag = text.slice(open, gt + 1);
313
+ const close = text.indexOf("</subagent>", gt + 1);
314
+ if (close < 0)
315
+ break;
316
+ const id = /\bsessionID\s*=\s*(?:"([^"]*)"|'([^']*)')/.exec(tag);
317
+ if (id && (id[1] ?? id[2] ?? "").trim() === sessionID) {
318
+ const body = text.slice(gt + 1, close).trim();
319
+ if (body)
320
+ found = body;
321
+ }
322
+ from = close + "</subagent>".length;
323
+ }
324
+ return found;
325
+ }
326
+ /** The body the host injected into the PARENT transcript for this child, if any.
327
+ * The v2 plugin can read a child's own context, but a host sub-agent's report also
328
+ * arrives in the parent as a completion envelope — and on a host whose child context
329
+ * answers with no assistant text (the measured `child_body_missing`), that envelope
330
+ * is the only readable copy. Null when nothing matches, so the caller keeps its
331
+ * exact `child_body_missing` wording rather than inventing a third outcome. */
332
+ export function hostEnvelopeBody(messages, sessionID) {
333
+ const sid = String(sessionID ?? "").trim();
334
+ if (!sid)
335
+ return null;
336
+ const texts = transcriptTexts(messages);
337
+ // newest-first: a child re-run after a wake may have been injected more than once
338
+ for (let i = texts.length - 1; i >= 0; i--) {
339
+ const hit = lastEnvelopeFor(texts[i], sid);
340
+ if (hit)
341
+ return hit;
342
+ }
343
+ return null;
344
+ }
228
345
  /**
229
346
  * Turn whatever the host puts in an error slot into ONE readable line.
230
347
  *
@@ -276,10 +393,27 @@ export function describeHostError(err, max = 220) {
276
393
  * that had already landed — the fix is to say it, not to withhold the text.
277
394
  */
278
395
  export const INCOMPLETE_PRESUMED_BODY_WARNING = "⚠ 推定结算且正文未带完成时间:这可能是最后一次工具调用前的半截话,不要据此判定交付;稍后再次 tm_join 可复核(或让子代理续跑)。";
279
- /** One child's status line — the lead reads these, so keep them terse. */
396
+ /** One child's status line — the lead reads these, so keep them terse. The tag is chosen by
397
+ * the settle SOURCE, because the same word would otherwise cover a host-confirmed finish, an
398
+ * unreachable seam and a guess: only `session-get` may print the host's outcome, and nothing
399
+ * but a real settlement may print 完成. */
280
400
  export function renderChildLine(c, elapsedMs) {
281
401
  const secs = Math.round(elapsedMs / 1000);
282
- const tag = c.state === "running" ? `运行中 ${secs}s` : c.state === "error" ? `失败:${shorten(c.error ?? "", 80)}` : `已完成 ${secs}s`;
402
+ const hostRunSecs = c.hostIdleAt !== undefined && c.hostCreatedAt !== undefined && c.hostIdleAt > c.hostCreatedAt
403
+ ? Math.round((c.hostIdleAt - c.hostCreatedAt) / 1000)
404
+ : secs;
405
+ const tag = c.state === "running"
406
+ ? `运行中 ${secs}s`
407
+ : c.state === "error"
408
+ ? `失败:${shorten(c.error ?? "", 80)}`
409
+ : c.settleSource === "session-get"
410
+ ? // The host's own record answered with a finished run: the only form allowed to
411
+ // print its outcome word, and it never says 推定.
412
+ `已结算(${c.outcome ?? "宿主未给 outcome"})· 运行 ${hostRunSecs}s`
413
+ : c.settleSource === "parent-idle"
414
+ ? // An inference. 完成 is exactly the word this may not borrow.
415
+ `推定已结算(未确认完成)· 派发后 ${secs}s`
416
+ : `已完成 ${secs}s`;
283
417
  // #33: a row the host reported IDLE for a stop attempt is not a delivery — the lead
284
418
  // needs to see that the child was never running, not a bare 已完成.
285
419
  const stop = c.state === "idle" && c.cancelNote ? ` ·${c.cancelNote}` : "";
@@ -289,6 +423,38 @@ export function renderChildLine(c, elapsedMs) {
289
423
  ? " ·(宿主 subagent 派发)"
290
424
  : ""}`;
291
425
  }
426
+ /**
427
+ * One line saying HOW the round's rows reached their state: `宿主确认` (the host's own record,
428
+ * event or envelope), `推定` (our inference) and `未结算` (still running). A lead skimming two
429
+ * rows that both read 已结算 otherwise cannot tell which one the host actually confirmed — and
430
+ * that difference is the whole point of asking `session.get` first.
431
+ */
432
+ export function settleTallyLine(children) {
433
+ if (!children.length)
434
+ return "";
435
+ let confirmed = 0;
436
+ let presumed = 0;
437
+ let open = 0;
438
+ const outcomes = new Map();
439
+ for (const c of children) {
440
+ if (c.state === "running") {
441
+ open++;
442
+ continue;
443
+ }
444
+ if (c.settleSource === "parent-idle") {
445
+ presumed++;
446
+ continue;
447
+ }
448
+ confirmed++;
449
+ if (c.outcome)
450
+ outcomes.set(c.outcome, (outcomes.get(c.outcome) ?? 0) + 1);
451
+ }
452
+ const named = [...outcomes.entries()].map(([o, n]) => `${o} ${n}`);
453
+ const withoutOutcome = confirmed - [...outcomes.values()].reduce((a, b) => a + b, 0);
454
+ if (withoutOutcome > 0)
455
+ named.push(`宿主未给 outcome ${withoutOutcome}`);
456
+ return `结算:宿主确认 ${confirmed}${named.length ? `(${named.join(" · ")})` : ""} / 推定 ${presumed} / 未结算 ${open}`;
457
+ }
292
458
  /** Verdicts tm_join can actually observe (issue #7's "leader keeps control"). */
293
459
  export function summarizeStates(children) {
294
460
  let running = 0;
@@ -439,6 +605,51 @@ export function steerVerdictLine(outcome, opts) {
439
605
  return `未插话${seam}:${shorten(opts?.note ?? "宿主没有回 inbox id", 120)}`;
440
606
  }
441
607
  }
608
+ /** What the lead reads after the delivery check. Every branch says out loud that delivery is
609
+ * not obedience — the composition must never promise what the child will do. */
610
+ export function steerDeliveryLine(v, inboxID, note) {
611
+ const inbox = inboxID ? `(inbox=${inboxID})` : "";
612
+ switch (v) {
613
+ case "delivered":
614
+ return `已送达${inbox}:该 inbox 项已经不在待投递清单里,宿主把它交给了子会话。投递不等于子代理照做 —— 它是否按这条改,仍要看下一次结算与正文。`;
615
+ case "queued":
616
+ return `已受理(未送达,宿主在步边界投递)${inbox}:该项仍在待投递清单里。投递≠执行,别把这一句当成子代理会照做。`;
617
+ case "unverified":
618
+ return `已受理${inbox}(未验证送达${note ? `:${shorten(note, 80)}` : ":宿主没有给出可核对的待投递清单"})。投递≠执行。`;
619
+ }
620
+ }
621
+ /** Re-read the child's inbox once after a successful steer and answer the only question that
622
+ * can be answered: is the item still pending? `unverified` covers every shape we cannot
623
+ * read — no seam, a throwing call, a payload with items we could not identify — because
624
+ * "absent" must not be inferred from "unreadable". */
625
+ export async function verifySteerDelivery(api, sessionID, inboxID) {
626
+ if (!api || api.v2SteerSeam !== true || typeof api.inboxList !== "function") {
627
+ return { verdict: "unverified", note: "这个宿主没有 session.inbox.list 的缝" };
628
+ }
629
+ try {
630
+ const un = unwrapClientResult(await api.inboxList({ sessionID }));
631
+ if (!un.ok)
632
+ return { verdict: "unverified", note: un.message };
633
+ const data = un.data;
634
+ const seamOut = String(data?.outcome ?? "");
635
+ if (seamOut === "threw" || seamOut === "no-seam") {
636
+ return {
637
+ verdict: "unverified",
638
+ note: seamOut === "no-seam" ? "宿主没给 session.inbox.list" : String(data?.message ?? "查询被宿主拒绝"),
639
+ };
640
+ }
641
+ const listed = inboxItemLines(data?.items);
642
+ // An item we could not parse is not an item that is gone: `skipped` covers unknown id
643
+ // shapes AND the overflow past the list cap, and both make "absent" unprovable.
644
+ if (listed.skipped > 0)
645
+ return { verdict: "unverified", note: `清单里有 ${listed.skipped} 项没被列出(id 形状或条数上限)` };
646
+ const stillQueued = listed.lines.some((l) => l.split(" · ")[0] === `id=${inboxID}`);
647
+ return stillQueued ? { verdict: "queued" } : { verdict: "delivered" };
648
+ }
649
+ catch (err) {
650
+ return { verdict: "unverified", note: describeHostError(err, 120) };
651
+ }
652
+ }
442
653
  export function steerOutcomeLabel(o) {
443
654
  return o === "steered" ? "已受理" : o === "not-steered" ? "未插话" : "无插话缝";
444
655
  }
@@ -599,6 +810,15 @@ export function buildDispatchTools(deps) {
599
810
  // comparable with this one. Renaming would split the history in two.
600
811
  const log = (e) => store.appendTrajectory({ tool: "tm_dispatch", ...e });
601
812
  const children = new Map();
813
+ /** Settle checks still in flight. `observeEvent` is a synchronous hook — the host does not
814
+ * await it — while asking `session.get` is async, so the read is tracked here and DRAINED by
815
+ * tm_join before it reports. Without that, a join issued right after a parent-idle event
816
+ * could answer from a row we were in the middle of updating. */
817
+ const settling = new Set();
818
+ const trackSettle = (p) => {
819
+ settling.add(p);
820
+ void p.finally(() => settling.delete(p));
821
+ };
602
822
  /** Per-parent memory of the LAST join's outcome. A wait is not parallelism
603
823
  * — while tm_join is in flight the lead's turn is blocked exactly like a
604
824
  * synchronous `task` call — so a SECOND wait that inherited a first wait
@@ -641,7 +861,26 @@ export function buildDispatchTools(deps) {
641
861
  }
642
862
  }
643
863
  }
644
- function fetchReply(sid, directory) {
864
+ /** Read the completion envelope the host injected into the PARENT transcript for
865
+ * this child. Absent seam / failed call / no matching envelope all return null —
866
+ * the fallback may not turn "I could not look" into a body. */
867
+ async function envelopeFromParent(parent, sid, directory) {
868
+ if (!parent || !sid || parent === sid)
869
+ return null;
870
+ if (typeof api?.messages !== "function")
871
+ return null;
872
+ try {
873
+ const raw = await api.messages({ path: { id: parent }, ...(directory ? { query: { directory } } : {}) });
874
+ const un = unwrapClientResult(raw);
875
+ if (!un.ok)
876
+ return null;
877
+ return hostEnvelopeBody(un.data, sid);
878
+ }
879
+ catch {
880
+ return null;
881
+ }
882
+ }
883
+ function fetchReply(sid, directory, parentSessionID) {
645
884
  return (async () => {
646
885
  if (typeof api?.messages !== "function") {
647
886
  return { text: "", error: "这个宿主客户端没有给出读取子会话正文的缝(v2 上它是 ctx.session.context 的兼容层)", bodyComplete: false };
@@ -668,9 +907,19 @@ export function buildDispatchTools(deps) {
668
907
  bodyComplete: typeof got.completedAt === "number",
669
908
  ...(typeof got.completedAt === "number" ? { bodyCompletedAt: got.completedAt } : {}),
670
909
  };
671
- return got.text
910
+ return got.text.trim()
672
911
  ? { ...got, via, ...(shape ? { shape } : {}), ...completeness }
673
- : { text: "", error: got.error, via, ...(shape ? { shape } : {}), ...completeness };
912
+ : await (async () => {
913
+ // C: the child's own context answered with no assistant text. Before
914
+ // reporting `child_body_missing`, look for the host's completion envelope
915
+ // in the parent — for a host sub-agent that IS the delivered report, and a
916
+ // lead that cannot read it has to re-dispatch work that already landed.
917
+ const envelope = await envelopeFromParent(parentSessionID ?? "", sid, directory);
918
+ if (envelope) {
919
+ return { text: envelope, via: "host-envelope", shape: "parent-envelope", bodyComplete: true };
920
+ }
921
+ return { text: "", error: got.error, via, ...(shape ? { shape } : {}), ...completeness };
922
+ })();
674
923
  }
675
924
  catch (err) {
676
925
  return { text: "", error: describeHostError(err?.message ?? err, 140), bodyComplete: false };
@@ -702,7 +951,7 @@ export function buildDispatchTools(deps) {
702
951
  // occurs on a v1-shaped client that lacks `session.messages`, and a sentence
703
952
  // blaming "v2" there would be false in the opposite direction.
704
953
  if (typeof api?.children !== "function")
705
- return { adopted: 0, looked: false, why: "这个宿主客户端没有给出 session.children(可辨认的 session API 缺失),我没有看过会话树" };
954
+ return { adopted: 0, looked: false, why: "这个宿主客户端没有给出 session.children(缺的只是「列举会话树」这一条缝,不是我整个 session API 不可用),我没有看过会话树" };
706
955
  if (!parent)
707
956
  return { adopted: 0, looked: false, why: "调用方会话 id 缺失,无从查询子会话" };
708
957
  let un;
@@ -834,10 +1083,10 @@ export function buildDispatchTools(deps) {
834
1083
  description: `Collect the sub-agent work attached to THIS session — the read-back side of delegation. You reach for it in two situations: the host's background \`task\` finished and TeamMode replaced its injected reply with a preview (pull the whole thing back with \`{ ids: ["<child session>"] }\`), or a dispatched child from before this build is still open and needs collecting or cancelling.
835
1084
  - No args: status snapshot of every open child of THIS session — running / idle(done) / error, with elapsed seconds. Cheap and non-blocking: use it to decide whether to keep working or start merging.
836
1085
  - { waitMs: 30000 }: bounded wait (capped at ${waitLabel(maxWaitMs)}) until every child settles. A wait BLOCKS YOU — your turn is parked in this tool call, so it is the synchronous \`task\` experience with none of its visibility. Wait once, briefly, and only when the very next step needs the answer; a second consecutive wait after nothing settled is cut to 10s and answered with what to do instead. Never wait for a child whose result you do not need — abort it instead ({ cancel: true }).
837
- - { ids: [...] }: restrict to those child sessions — a host \`task\` child is collectable when you NAME it (its parentage is verified against the host's own session tree, never assumed); { cancel: true }: abort still-running ones.
838
- - Steer a child while it runs — the same call, no separate tool: { steer: "先停手,验收改成 X", ids: ["<child>"] } admits the line into that child's inbox with delivery "steer" (or "queue") and the host wakes execution at the step boundary. 已受理 is the whole claim: it is NOT proof the child read it, and the reply never says 已送达. { unread: true, ids: ["<child>"] } lists that child's undelivered inbox items (ids + shape only, never the body); { unsend: {"sessionID":"ses_…","inboxID":"msg_…"} } withdraws one that has not been delivered — an already-delivered item is the documented no-op and is reported as one. All three ask the host for parentage FIRST and refuse a session that is not yours; a host without the seam answers no-seam instead of pretending.
1086
+ - { ids: [...] }: restrict to those child sessions — a host \`task\` child is collectable when you NAME it (its parentage is verified against the host's own session tree, never assumed); { cancel: true }: abort still-running ones. With explicit ids the host is ALWAYS asked, even for a row this process believes settled — a settled-looking row can have been woken and be running for real.
1087
+ - Steer a child while it runs — the same call, no separate tool: { steer: "先停手,验收改成 X", ids: ["<child>"] } admits the line into that child's inbox with delivery "steer" (or "queue") and the host wakes execution at the step boundary. 已受理 means the host took the line into that inbox — NOT that the child read or obeyed it. After a successful steer the tool re-reads that inbox ONCE: an item gone from the pending list is reported 已送达, still pending is 已受理(未送达,宿主在步边界投递), and an inbox it cannot read is 已受理(未验证送达)— delivery is never inferred from the write alone, and none of the three means the child followed it. Steer REFUSES a child whose registered state is not running (idle / error / unknown), because the host's doc sentence "Steering wakes session execution" means the line starts a second run that overlaps new dispatches — pass { wake: true } to do that deliberately. { unread: true, ids: ["<child>"] } lists that child's undelivered inbox items (ids + shape only, never the body); { unsend: {"sessionID":"ses_…","inboxID":"msg_…"} } withdraws one that has not been delivered — an already-delivered item is the documented no-op and is reported as one. All three ask the host for parentage FIRST and refuse a session that is not yours; a host without the seam answers no-seam instead of pretending.
839
1088
  - A plugin/host restart does NOT orphan a child: those the host still lists under your session are re-adopted automatically (their rows are marked 接管), and one that answered while nobody was listening is reported 已完成, not lost.
840
- - Replies come back through the offload pipeline: a long sub-agent report arrives as a handle + ≤80-token preview (page it with tm_fetch), so a batch of parallel work does not multiply your context. STATUS: blocked/failed children are surfaced first, always.`,
1089
+ - Replies come back through the offload pipeline: a long sub-agent report arrives as a handle + ≤80-token preview (page it with tm_fetch), so a batch of parallel work does not multiply your context. STATUS: blocked/failed children are surfaced first, always. When a child's own session yields no assistant text, the host's injected \`<subagent sessionID="…">…</subagent>\` envelope in THIS session is read as the body and labelled 正文来源=宿主注入信封 (an id must match exactly).`,
841
1090
  args: {
842
1091
  ids: { descriptor: "ids: string[] (optional — only these child sessions)" },
843
1092
  waitMs: { descriptor: `waitMs: number (optional 0..${maxWaitMs} — bounded wait; a SECOND consecutive wait is cut to 10s, because waiting is not parallel work)` },
@@ -857,6 +1106,9 @@ export function buildDispatchTools(deps) {
857
1106
  delivery: {
858
1107
  descriptor: "delivery: steer|queue (optional, default steer — applies to steer only. Any other value is refused rather than silently defaulted.)",
859
1108
  },
1109
+ wake: {
1110
+ descriptor: "wake: boolean (optional, default false — steer only. A steer into a settled/idle child is REFUSED unless this is true, because the host wakes execution and the child then overlaps new dispatches. Accepts true/false or the strings \"true\"/\"false\".)",
1111
+ },
860
1112
  unread: {
861
1113
  descriptor: "unread: true (optional — list the undelivered inbox items of the child named by ids: ids and shape only, never the body)",
862
1114
  },
@@ -878,6 +1130,10 @@ export function buildDispatchTools(deps) {
878
1130
  // wrote its governed result — so the token table read "0 调用 / 2 结果",
879
1131
  // which looks like a broken counter rather than a tool that ran.
880
1132
  store.appendTrajectory({ tool, step_id: "join", event: "call" });
1133
+ // A settle check may still be talking to the host (see `settling`). Drain it before
1134
+ // reading the registry so this reply never mixes a fresh table with a half-updated row.
1135
+ if (settling.size)
1136
+ await Promise.allSettled([...settling]);
881
1137
  const directory = typeof c.directory === "string" && c.directory ? c.directory : undefined;
882
1138
  const idList = parseIdList(args.ids);
883
1139
  const idFilter = idList && idList.length ? new Set(idList) : null;
@@ -963,6 +1219,45 @@ export function buildDispatchTools(deps) {
963
1219
  ? renderChildLine(row, (row.finishedAt ?? now()) - row.startedAt)
964
1220
  : `${target} · ${gate.agent} ·(本进程没有它的登记行,归属由宿主确认)`;
965
1221
  if (steerText) {
1222
+ // A steer into a session that is not running is a WAKE, not an interjection:
1223
+ // the host's own doc says "Steering wakes session execution", so it starts a
1224
+ // second run that overlaps whatever the lead dispatched meanwhile. Measured
1225
+ // cost: a settled child was woken this way and ran concurrently with a fresh
1226
+ // worker, two processes on one device. Default = refuse; `wake:true` is the
1227
+ // explicit opt-in, and the value is coerced the way the rest of this tool's
1228
+ // boolean args are (`cancel`/`unread` above): boolean or "true"/"false".
1229
+ const wakeRaw = args.wake;
1230
+ let wakeRequested = false;
1231
+ if (wakeRaw !== undefined && wakeRaw !== null) {
1232
+ if (wakeRaw === true || wakeRaw === false)
1233
+ wakeRequested = wakeRaw;
1234
+ else if (typeof wakeRaw === "string" && /^(true|false)$/i.test(wakeRaw.trim())) {
1235
+ wakeRequested = /^true$/i.test(wakeRaw.trim());
1236
+ }
1237
+ else {
1238
+ return toToolResult(tmError(tool, "args", `wake 只接受 true/false(布尔或 "true"/"false" 字符串),收到 ${shorten(String(wakeRaw), 24)},未发送。`));
1239
+ }
1240
+ }
1241
+ // The registry can be stale in the dangerous direction too (an event we
1242
+ // missed would leave a settled child looking running); ask the host before
1243
+ // trusting "running" when it offers a status map.
1244
+ if (row && row.state === "running" && typeof api?.status === "function") {
1245
+ await refreshFromStatus(directory);
1246
+ }
1247
+ const rowState = row?.state;
1248
+ if (rowState !== "running" && !wakeRequested) {
1249
+ log({
1250
+ step_id: "join",
1251
+ event: "steer_refused",
1252
+ child: target,
1253
+ agent: gate.agent,
1254
+ reason: "not-running",
1255
+ state: rowState ?? "unregistered",
1256
+ // the LENGTH, never the text — R6 binds the audit trail as much as the reply
1257
+ chars: steerText.length,
1258
+ });
1259
+ return toToolResult(`没有发送:该子代理已结算(或状态未知);插话会唤醒它并与新派发并发——要停止用 cancel:true,要复活请显式 wake:true。\n目标:${rowLine}`);
1260
+ }
966
1261
  let outcome = "no-seam";
967
1262
  let inboxID;
968
1263
  let note;
@@ -980,6 +1275,20 @@ export function buildDispatchTools(deps) {
980
1275
  // and a verdict read off whatever came back.
981
1276
  note = "这个客户端不是 v2 的 ctx.session 桥(v1 的 session.prompt 走 {path,body},没有 delivery 字段),未调用";
982
1277
  }
1278
+ // C — the write only proves ADMISSION. Ask the same inbox once more for the one
1279
+ // checkable fact: is the item still pending? Nothing is claimed when the seam
1280
+ // cannot answer (see steerDeliveryLine's unverified branch).
1281
+ const verified = outcome === "steered" && inboxID ? await verifySteerDelivery(api, target, inboxID) : null;
1282
+ if (verified) {
1283
+ log({
1284
+ step_id: "join",
1285
+ event: "steer_delivery",
1286
+ child: target,
1287
+ inboxID,
1288
+ verdict: verified.verdict,
1289
+ ...(verified.note ? { reason: shorten(verified.note, 120) } : {}),
1290
+ });
1291
+ }
983
1292
  log({
984
1293
  step_id: "join",
985
1294
  event: "steer",
@@ -988,7 +1297,9 @@ export function buildDispatchTools(deps) {
988
1297
  delivery,
989
1298
  outcome,
990
1299
  ok: outcome === "steered",
1300
+ wake: wakeRequested,
991
1301
  ...(inboxID ? { inboxID } : {}),
1302
+ ...(verified ? { delivered: verified.verdict } : {}),
992
1303
  ...(seam ? { seam } : {}),
993
1304
  // the LENGTH, never the text — R6 binds the audit trail as much as the reply
994
1305
  chars: steerText.length,
@@ -996,10 +1307,13 @@ export function buildDispatchTools(deps) {
996
1307
  const tally = {};
997
1308
  tally[outcome] = 1;
998
1309
  log({ step_id: "join", event: "steer_summary", count: 1, outcomes: steerOutcomeParts(tally).join(" / ") });
999
- const wake = row && row.state !== "running"
1000
- ? `\n它现在不是运行中(${row.state === "error" ? "失败" : "已结算"});按文档 steer 会唤醒它的执行,但唤醒之后它做什么,要等它结算才看得到。`
1310
+ const wake = outcome === "steered" && wakeRequested && row && row.state !== "running"
1311
+ ? `\n已按 wake:true 唤醒:它当时不是运行中(${row.state === "error" ? "失败" : "已结算"}),宿主把这条插话当成新的执行输入——它会和新派发的 worker 并发,留意这一点。`
1312
+ : "";
1313
+ const deliveryLine = verified
1314
+ ? `\n投递核对:${steerDeliveryLine(verified.verdict, inboxID, verified.note)}`
1001
1315
  : "";
1002
- return toToolResult(`插话目标:${rowLine}\n插话结果:${steerVerdictLine(outcome, { inboxID, note, seam })}\n插话计数:${steerOutcomeParts(tally).join(" · ")}${wake}`);
1316
+ return toToolResult(`插话目标:${rowLine}\n插话结果:${steerVerdictLine(outcome, { inboxID, note, seam })}\n插话计数:${steerOutcomeParts(tally).join(" · ")}${deliveryLine}${wake}`);
1003
1317
  }
1004
1318
  if (wantsUnread) {
1005
1319
  if (!api || api.v2SteerSeam !== true || typeof api.inboxList !== "function") {
@@ -1062,18 +1376,29 @@ export function buildDispatchTools(deps) {
1062
1376
  const saw = adoption.looked;
1063
1377
  const inner = idFilter
1064
1378
  ? (() => {
1065
- // Naming the shapes the v2 session bridge tried is the whole point:
1066
- // without it, a claim that failed because `ctx.session.get` answered
1067
- // in a shape we do not know prints the same sentence as a tree we
1068
- // did query, and the lead goes back to waiting for a report that
1069
- // exists.
1379
+ // TWO independent facts live here, and they used to be fused into one
1380
+ // sentence: what the session READER can do, and whether the CHILDREN
1381
+ // endpoint exists. On 2.1.4 `ctx.session.get({sessionID})` answers while
1382
+ // `session.children` does not, so the old text ("可辨认的 session API 缺失"
1383
+ // plus the last error of a shape already abandoned) told the lead the host
1384
+ // had no session API at all — and it went back to assuming its id was
1385
+ // fine and the host broken. State them separately, and name the shapes
1386
+ // only when EVERY shape failed: a shape that worked makes the abandoned
1387
+ // ones irrelevant, not evidence.
1070
1388
  const sr = deps.sessionReaderReport?.();
1071
1389
  const bridge = sr && sr.attempted > 0
1072
- ? `;我试过的 ctx.session 形状:${(sr.failedShapes.length ? sr.failedShapes.join("→") : sr.usedShape ?? "?")}${sr.lastError ? `(宿主最后一次的错误:${shorten(sr.lastError, 90)})` : "(宿主没有报错,只是没给出 parentID)"}`
1390
+ ? sr.usedShape
1391
+ ? `;ctx.session.get 可用(形状 ${sr.usedShape}):宿主没有 session.children,按 id 读单条会话可以,列举会话树不行`
1392
+ : `;按 id 读子会话也失败:试过的 ctx.session 形状 ${sr.failedShapes.length ? sr.failedShapes.join("→") : "?"}${sr.lastError ? `(宿主最后一次的错误:${shorten(sr.lastError, 90)})` : "(宿主没有报错,只是没给出 parentID)"}`
1073
1393
  : "";
1394
+ // A named id that neither our registry nor the host's by-id read could
1395
+ // resolve is a miss about THAT id — saying it means the host has no
1396
+ // session API is the claim this branch exists to stop making.
1397
+ const named = [...idFilter].slice(0, 3).map((s) => shorten(s, 40)).join("、");
1398
+ const miss = `ids 未匹配到本会话的子代理:${named} 在本机不存在或无权限`;
1074
1399
  return saw
1075
- ? `(ids 未匹配到本会话的子代理,宿主会话树里也没有可认领的子会话)${bridge}`
1076
- : `(ids 未匹配到;而且我没能查看宿主会话树:${adoption.why})${bridge}`;
1400
+ ? `(${miss};宿主会话树里也没有可认领的子会话${bridge})`
1401
+ : `(${miss};而且我没能查看宿主会话树:${adoption.why}${bridge})`;
1077
1402
  })()
1078
1403
  : saw
1079
1404
  ? ""
@@ -1119,47 +1444,92 @@ export function buildDispatchTools(deps) {
1119
1444
  let cancelTally = null;
1120
1445
  if (args.cancel === true || args.cancel === "true") {
1121
1446
  cancelTally = {};
1122
- for (const r of stillRunning) {
1447
+ // B: an EXPLICITLY named id is always asked of the host. The registry is this
1448
+ // process's memory, not the host's truth — the live failure was a row settled
1449
+ // HERE while the host had just been made to run it by a steer, and the old loop
1450
+ // over `stillRunning` answered "没有运行中的子代理可停" without ever calling the
1451
+ // host. With no ids the shipped behaviour stays ("only those we still believe
1452
+ // are running"), because "cancel everything" must not interrogate every settled
1453
+ // child of the session.
1454
+ const cancelTargets = idFilter ? mine.filter((r) => idFilter.has(r.sessionID)) : stillRunning;
1455
+ for (const r of cancelTargets) {
1456
+ const wasRunning = r.state === "running";
1123
1457
  let outcome;
1124
1458
  let note;
1125
1459
  if (typeof api?.abort === "function") {
1126
- // v1's path, byte-exact and FIRST: the personality is frozen and a shipped
1127
- // test pins "aborted on request".
1460
+ // v1's path, byte-exact and FIRST for a RUNNING row: the personality is
1461
+ // frozen and a shipped test pins "aborted on request".
1128
1462
  const un = unwrapClientResult(await api.abort({ path: { id: r.sessionID } }));
1129
- outcome = un.ok ? "stopped" : "threw";
1130
- r.error = un.ok ? "aborted on request" : `abort failed: ${shorten(un.message ?? "", 60)}`;
1131
- r.state = "error";
1463
+ if (wasRunning) {
1464
+ outcome = un.ok ? "stopped" : "threw";
1465
+ r.error = un.ok ? "aborted on request" : `abort failed: ${shorten(un.message ?? "", 60)}`;
1466
+ r.state = "error";
1467
+ }
1468
+ else {
1469
+ // v1's abort returns no `interrupted` boolean, so an already-settled row
1470
+ // cannot honestly be called stopped OR failed from this answer.
1471
+ outcome = un.ok ? "unknown" : "threw";
1472
+ note = un.ok ? "v1 的 abort 不返回 interrupted 布尔" : un.message;
1473
+ r.cancelNote = cancelVerdictLine(outcome, note);
1474
+ }
1132
1475
  }
1133
1476
  else if (typeof api?.interrupt === "function") {
1134
1477
  const got = cancelOutcomeOf(await api.interrupt({ path: { id: r.sessionID } }));
1135
1478
  outcome = got.outcome;
1136
1479
  note = got.note;
1137
- // `idle` is the host saying there was no active execution: the registry row
1138
- // was stale, not a failed stop. Anything else closes as undelivered.
1139
- r.state = outcome === "idle" ? "idle" : "error";
1140
1480
  r.cancelNote = cancelVerdictLine(outcome, note);
1141
- if (r.state === "error")
1481
+ if (wasRunning) {
1482
+ // `idle` is the host saying there was no active execution: the registry row
1483
+ // was stale, not a failed stop. Anything else closes as undelivered.
1484
+ r.state = outcome === "idle" ? "idle" : "error";
1485
+ if (r.state === "error")
1486
+ r.error = r.cancelNote;
1487
+ }
1488
+ else if (outcome === "stopped") {
1489
+ // A row we believed settled was in fact executing (a woken session is the
1490
+ // measured case): the host just stopped it, and the row must say so.
1491
+ r.state = "error";
1142
1492
  r.error = r.cancelNote;
1493
+ }
1494
+ // idle / unknown / no-seam / threw on an already-settled row leave its settle
1495
+ // verdict alone — the stop attempt is extra evidence, not a re-settlement.
1143
1496
  }
1144
1497
  else {
1145
1498
  outcome = "no-seam";
1146
- r.error = "宿主无 abort 接口,未取消";
1147
- r.state = "error";
1499
+ if (wasRunning) {
1500
+ r.error = "宿主无 abort 接口,未取消";
1501
+ r.state = "error";
1502
+ }
1503
+ else {
1504
+ r.cancelNote = cancelVerdictLine("no-seam");
1505
+ }
1148
1506
  }
1149
- r.finishedAt = now();
1507
+ if (wasRunning || r.finishedAt === undefined)
1508
+ r.finishedAt = now();
1150
1509
  cancelTally[outcome] = (cancelTally[outcome] ?? 0) + 1;
1151
- log({ step_id: "join", event: "cancel", child: r.sessionID, agent: r.agent, outcome, ok: outcome === "stopped" });
1510
+ log({
1511
+ step_id: "join",
1512
+ event: "cancel",
1513
+ child: r.sessionID,
1514
+ agent: r.agent,
1515
+ outcome,
1516
+ explicit: Boolean(idFilter),
1517
+ was_running: wasRunning,
1518
+ ok: outcome === "stopped",
1519
+ });
1152
1520
  }
1153
1521
  log({
1154
1522
  step_id: "join",
1155
1523
  event: "cancel_summary",
1156
- count: stillRunning.length,
1524
+ count: cancelTargets.length,
1157
1525
  outcomes: cancelOutcomeParts(cancelTally).join(" / "),
1158
1526
  });
1159
1527
  }
1160
1528
  const sums = summarizeStates(mine);
1529
+ const settleLine = settleTallyLine(mine);
1161
1530
  const header = [
1162
1531
  `派发汇总:${sums.idle} 完成 / ${sums.running} 运行中 / ${sums.error} 失败${settled ? "(全部已结算)" : `(未等到全部结算 · 本次已等 ${Math.round((now() - waitStart) / 1000)}s)`}`,
1532
+ ...(settleLine ? [settleLine] : []),
1163
1533
  ...mine.map((r) => renderChildLine(r, (r.finishedAt ?? now()) - r.startedAt)),
1164
1534
  ];
1165
1535
  // The `正文:` line belongs directly under the child rows it describes, but the
@@ -1168,7 +1538,11 @@ export function buildDispatchTools(deps) {
1168
1538
  const childRowsEnd = header.length;
1169
1539
  if (cancelTally) {
1170
1540
  const parts = cancelOutcomeParts(cancelTally);
1171
- header.push(`取消尝试:${parts.join(" · ") || "没有运行中的子代理可停"} —— ` +
1541
+ header.push(`取消尝试:${parts.join(" · ") ||
1542
+ // Never leave "没有运行中的子代理可停" as the ONLY answer: with no ids the
1543
+ // default restricted the attempt to rows we believed running, and the lead
1544
+ // must know that a settled-looking row was therefore not asked about.
1545
+ "没有运行中的子代理可停(未显式给 ids 时只对登记为运行中的子代理发起取消;若你怀疑某个已结算的子会话其实在跑,用 ids 点名——我会先去问宿主)"} —— ` +
1172
1546
  `只有「已由宿主中断」是宿主确认过的停止;「空闲未中断」说明它当时没在活动执行里` +
1173
1547
  `(登记行过时了,不是失败);「未确认」说明中断调用成功但宿主没回 interrupted 布尔。` +
1174
1548
  `这三种结论不要混成一句"已取消"转述给用户。`);
@@ -1267,7 +1641,7 @@ export function buildDispatchTools(deps) {
1267
1641
  const bodyStatus = [];
1268
1642
  if (wantText) {
1269
1643
  for (const r of collectible) {
1270
- const reply = await fetchReply(r.sessionID, directory);
1644
+ const reply = await fetchReply(r.sessionID, directory, parent);
1271
1645
  const text = reply.text;
1272
1646
  r.bodyComplete = reply.bodyComplete === true;
1273
1647
  r.bodyCompletedAt = reply.bodyCompletedAt;
@@ -1279,7 +1653,10 @@ export function buildDispatchTools(deps) {
1279
1653
  // fragment, which would be the same overstatement facing the other way.
1280
1654
  const half = hasBody && r.bodyComplete === false && r.settleSource === "parent-idle";
1281
1655
  bodyStatus.push(`正文:${!hasBody ? "缺失" : r.bodyComplete ? "完整" : half ? "半截(推定)" : "未带完成时间"} · ${r.agent} "${r.label}"`);
1282
- const src = text && reply.via ? ` ·正文来源=${reply.via}` : "";
1656
+ // C labels the fallback in the lead's words: the body above came from the
1657
+ // host's own completion injection, not from the child's session context.
1658
+ const viaLabel = reply.via === "host-envelope" ? "宿主注入信封" : reply.via;
1659
+ const src = text && viaLabel ? ` ·正文来源=${viaLabel}` : "";
1283
1660
  // Which seam answered, per child — an id, a seam name and the SHAPE it saw, never
1284
1661
  // a body (R6 binds a diagnostic too). This is the line that makes "正文来源=…"
1285
1662
  // checkable later, and the only place the three outcomes stay distinguishable:
@@ -1327,6 +1704,66 @@ export function buildDispatchTools(deps) {
1327
1704
  }
1328
1705
  },
1329
1706
  };
1707
+ /** Settle the still-running HOST children of a session that just went idle — by ASKING the
1708
+ * host, not by assuming. One `session.get` per child, three outcomes kept apart:
1709
+ * - `time.idle` present → settled, `settleSource="session-get"`, host `outcome` kept;
1710
+ * - answered WITHOUT idle → the child is still running: the row is left alone and the
1711
+ * attempt is counted (`settle_unchecked`), because a live child reported as settled is
1712
+ * the exact overstatement this tool was already fixed for;
1713
+ * - no seam / a throwing call → the old `parent-idle` presumption, printed as one.
1714
+ * `idle_settled` and `idle_presumed` are separate trajectory rows on purpose: tm_stats can
1715
+ * then show how often the host actually confirmed a finish. */
1716
+ async function reconcileHostChildren(rows) {
1717
+ for (const child of rows) {
1718
+ if (child.state !== "running")
1719
+ continue;
1720
+ const st = await readHostSessionState(api, child.sessionID);
1721
+ if (st.idleAt) {
1722
+ child.state = "idle";
1723
+ child.finishedAt = st.idleAt;
1724
+ child.hostIdleAt = st.idleAt;
1725
+ if (st.createdAt !== undefined)
1726
+ child.hostCreatedAt = st.createdAt;
1727
+ child.settleSource = "session-get";
1728
+ if (st.outcome)
1729
+ child.outcome = st.outcome;
1730
+ log({
1731
+ step_id: "events",
1732
+ event: "idle_settled",
1733
+ source: "session-get",
1734
+ child: child.sessionID,
1735
+ agent: child.agent,
1736
+ ...(st.outcome ? { outcome: st.outcome } : {}),
1737
+ ms: st.idleAt - child.startedAt,
1738
+ });
1739
+ continue;
1740
+ }
1741
+ if (st.answered) {
1742
+ log({
1743
+ step_id: "events",
1744
+ event: "settle_unchecked",
1745
+ source: "session-get",
1746
+ reason: "still-running",
1747
+ child: child.sessionID,
1748
+ agent: child.agent,
1749
+ });
1750
+ continue;
1751
+ }
1752
+ presumeParentIdle([child]);
1753
+ }
1754
+ }
1755
+ /** The FALLBACK: a still-running host child of a session that just went idle, on a host we
1756
+ * could not ask. Recorded as `parent-idle` and printed as a presumption — never 完成. */
1757
+ function presumeParentIdle(rows) {
1758
+ for (const child of rows) {
1759
+ if (child.state !== "running")
1760
+ continue;
1761
+ child.state = "idle";
1762
+ child.finishedAt = now();
1763
+ child.settleSource = "parent-idle";
1764
+ log({ step_id: "events", event: "idle_presumed", child: child.sessionID, agent: child.agent, ms: child.finishedAt - child.startedAt });
1765
+ }
1766
+ }
1330
1767
  const observeEvent = (event) => {
1331
1768
  const rawType = String(event?.type ?? "");
1332
1769
  if (!rawType.startsWith("session."))
@@ -1349,21 +1786,22 @@ export function buildDispatchTools(deps) {
1349
1786
  return;
1350
1787
  const rec = children.get(sid);
1351
1788
  if (!rec) {
1352
- // The parent's own idle is the only completion signal v2 gives us for a host
1353
- // child: the background reply is injected into the parent before the parent's turn
1354
- // ends (measured: child injected …824730, parent idle …829038), and the plugin has
1355
- // no endpoint to read a child session's state. So settle the still-running host
1356
- // children of THIS session — and say plainly that it is a presumption.
1789
+ // The parent's own idle is the only completion signal the host gives us for a HOST
1790
+ // child — and it is not the strongest one available: `session.get({sessionID})` answers
1791
+ // the child's own `time.idle` (verified on 2.1.4), so we ASK first and only fall back to
1792
+ // the inference when the host cannot be asked. A row that stayed 运行中 forever would be
1793
+ // an overstatement in reverse; a row presumed settled when the host says it is still
1794
+ // executing is the overstatement this tool exists to refuse.
1357
1795
  if (type === "session.idle") {
1358
- for (const child of children.values()) {
1359
- if (child.via !== "host-injection" || child.state !== "running")
1360
- continue;
1361
- if (child.parentSessionID !== sid)
1362
- continue;
1363
- child.state = "idle";
1364
- child.finishedAt = now();
1365
- child.settleSource = "parent-idle";
1366
- log({ step_id: "events", event: "idle_presumed", child: child.sessionID, agent: child.agent, ms: child.finishedAt - child.startedAt });
1796
+ const pending = [...children.values()].filter((child) => child.via === "host-injection" && child.state === "running" && child.parentSessionID === sid);
1797
+ if (pending.length) {
1798
+ // No seam at all → the fallback needs no round-trip, so it lands HERE and the
1799
+ // synchronous hook stays synchronously readable. A seam that EXISTS is asked, and
1800
+ // that read is tracked so tm_join drains it before reporting.
1801
+ if (!api || typeof api.get !== "function")
1802
+ presumeParentIdle(pending);
1803
+ else
1804
+ trackSettle(reconcileHostChildren(pending));
1367
1805
  }
1368
1806
  }
1369
1807
  return;