@te-river/opencode-team-mode 1.8.5 → 1.8.7

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 (50) hide show
  1. package/README.md +10 -9
  2. package/README.zh-CN.md +11 -10
  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 +20 -9
  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 +101 -11
  35. package/dist/tm/dispatch.d.ts.map +1 -1
  36. package/dist/tm/dispatch.js +572 -30
  37. package/dist/tm/dispatch.js.map +1 -1
  38. package/dist/tm/ledger.d.ts +50 -7
  39. package/dist/tm/ledger.d.ts.map +1 -1
  40. package/dist/tm/ledger.js +161 -18
  41. package/dist/tm/ledger.js.map +1 -1
  42. package/dist/tm/pipelines.d.ts.map +1 -1
  43. package/dist/tm/pipelines.js +6 -3
  44. package/dist/tm/pipelines.js.map +1 -1
  45. package/dist/tm/result.d.ts +13 -1
  46. package/dist/tm/result.d.ts.map +1 -1
  47. package/dist/tm/result.js +14 -2
  48. package/dist/tm/result.js.map +1 -1
  49. package/docs/installation-v2.md +28 -24
  50. package/package.json +1 -1
@@ -112,6 +112,34 @@ export function sessionApiOf(client) {
112
112
  return null;
113
113
  return api;
114
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
+ }
115
143
  /** The host's OWN visible-and-non-blocking sub-agent path: its built-in `task`
116
144
  * takes `background: true`, and because that card is rendered by the host's
117
145
  * own `task` renderer it links to the live child session — which a plugin tool
@@ -314,6 +342,177 @@ export function hostEnvelopeBody(messages, sessionID) {
314
342
  }
315
343
  return null;
316
344
  }
345
+ const HISTORY_ACK_RE = /The subagent is working in the background \(sessionID: (ses_[A-Za-z0-9]+)\)/gi;
346
+ const SESSION_ID_RE = /^ses_[A-Za-z0-9]+$/;
347
+ const SHELL_ID_RE = /^sh_[A-Za-z0-9]+$/;
348
+ /**
349
+ * The text bodies a transcript carries, for the HISTORY scanners only.
350
+ *
351
+ * Deliberately wider than `transcriptTexts` above, which keeps the body-collection policy
352
+ * (`type:"text"`): a background dispatch's ACK reaches the parent as a TOOL RESULT part, and
353
+ * the v2 seam hands tool parts through untouched (`normaliseContextMessages` moves
354
+ * `content[]` to `parts[]` and keeps their kind). Reading only `type:"text"` would make
355
+ * this whole recovery path structurally dead — the exact shape of the task-offload bug,
356
+ * where a matcher that never fired was mistaken for "nothing to match". `textsOf` in
357
+ * `src/host/v2-subagent.ts` reads `{text}` off every part for the same reason.
358
+ */
359
+ function historyTexts(messages) {
360
+ const list = Array.isArray(messages) ? messages : [];
361
+ const out = [];
362
+ for (const entry of list) {
363
+ const e = entry;
364
+ if (typeof e?.text === "string" && e.text.trim())
365
+ out.push(e.text);
366
+ if (!Array.isArray(e?.parts))
367
+ continue;
368
+ const joined = e.parts
369
+ .map((p) => {
370
+ const t = p?.text;
371
+ return typeof t === "string" ? t : "";
372
+ })
373
+ .filter((s) => s.trim())
374
+ .join("\n");
375
+ if (joined.trim())
376
+ out.push(joined);
377
+ }
378
+ return out;
379
+ }
380
+ /** End index of the tag that starts at `from`, honouring quotes: a shell `command` may
381
+ * contain `>` (a redirect), and cutting the tag there would lose the `state` after it. */
382
+ function historyTagEnd(text, from) {
383
+ let quote = "";
384
+ for (let i = from; i < text.length; i++) {
385
+ const ch = text[i];
386
+ if (quote) {
387
+ if (ch === "\\")
388
+ i++;
389
+ else if (ch === quote)
390
+ quote = "";
391
+ continue;
392
+ }
393
+ if (ch === '"' || ch === "'")
394
+ quote = ch;
395
+ else if (ch === ">")
396
+ return i;
397
+ }
398
+ return -1;
399
+ }
400
+ function historyTagAttr(tag, name) {
401
+ const m = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(tag);
402
+ return (m?.[1] ?? m?.[2] ?? "").trim();
403
+ }
404
+ /** A label for a candidate whose evidence carried none: the nearest line above the ACK (the
405
+ * dispatch description usually rides there), else the line below it, else `"host child"`. */
406
+ function historyLabelNear(text, index) {
407
+ const before = text
408
+ .slice(0, index)
409
+ .split(/\r?\n/)
410
+ .map((s) => s.trim())
411
+ .filter(Boolean);
412
+ const after = text
413
+ .slice(index)
414
+ .split(/\r?\n/)
415
+ .map((s) => s.trim())
416
+ .filter(Boolean);
417
+ const cand = before[before.length - 1] ?? after[1] ?? "";
418
+ return cand.replace(/^[-*·\s]+/, "").slice(0, 40) || "host child";
419
+ }
420
+ function historyStateOf(raw) {
421
+ const s = raw.toLowerCase();
422
+ if (s === "running" || s === "started" || s === "busy")
423
+ return "running";
424
+ if (s === "completed" || s === "succeeded" || s === "done")
425
+ return "completed";
426
+ return "error";
427
+ }
428
+ /** Sub-agent candidates named by a parent transcript, in transcript order (a later
429
+ * envelope overwrites the earlier ACK for the same id — that is what "last one wins"
430
+ * means for a child that was woken and re-ran). */
431
+ export function childrenFromParentHistory(messages) {
432
+ const found = new Map();
433
+ for (const text of historyTexts(messages)) {
434
+ for (const m of text.matchAll(HISTORY_ACK_RE)) {
435
+ const sid = m[1];
436
+ const prior = found.get(sid);
437
+ // An envelope already read for this id carries a stronger state; an ACK after it is a
438
+ // re-dispatch, so it wins — both cases reduce to "the later evidence wins".
439
+ found.set(sid, { sessionID: sid, state: "running", label: prior?.label ?? historyLabelNear(text, m.index ?? 0) });
440
+ }
441
+ let from = 0;
442
+ for (;;) {
443
+ const open = text.indexOf("<subagent", from);
444
+ if (open < 0)
445
+ break;
446
+ if (!/\s/.test(text[open + "<subagent".length] ?? "")) {
447
+ from = open + 9;
448
+ continue;
449
+ }
450
+ const gt = historyTagEnd(text, open);
451
+ if (gt < 0)
452
+ break;
453
+ const tag = text.slice(open, gt + 1);
454
+ const sid = historyTagAttr(tag, "sessionID");
455
+ const stateRaw = historyTagAttr(tag, "state");
456
+ const close = text.indexOf("</subagent>", gt + 1);
457
+ if (SESSION_ID_RE.test(sid) && stateRaw) {
458
+ const state = historyStateOf(stateRaw);
459
+ const body = close >= 0 && state === "completed" ? text.slice(gt + 1, close).trim() : "";
460
+ const described = historyTagAttr(tag, "description").slice(0, 40);
461
+ found.set(sid, {
462
+ sessionID: sid,
463
+ state,
464
+ ...(body ? { body } : {}),
465
+ label: described || found.get(sid)?.label || "host child",
466
+ });
467
+ }
468
+ from = close >= 0 ? close + "</subagent>".length : gt + 1;
469
+ }
470
+ }
471
+ return [...found.values()];
472
+ }
473
+ /** Background shells the host announced in a parent transcript, last state per id. */
474
+ export function backgroundShellsFromParentHistory(messages) {
475
+ const found = new Map();
476
+ for (const text of historyTexts(messages)) {
477
+ let from = 0;
478
+ for (;;) {
479
+ const open = text.indexOf("<shell", from);
480
+ if (open < 0)
481
+ break;
482
+ if (!/\s/.test(text[open + "<shell".length] ?? "")) {
483
+ from = open + 6;
484
+ continue;
485
+ }
486
+ const gt = historyTagEnd(text, open);
487
+ if (gt < 0)
488
+ break;
489
+ const tag = text.slice(open, gt + 1);
490
+ const id = historyTagAttr(tag, "id");
491
+ if (SHELL_ID_RE.test(id)) {
492
+ const state = (historyTagAttr(tag, "state") || "running").toLowerCase();
493
+ const command = historyTagAttr(tag, "command");
494
+ found.set(id, { id, state, ...(command ? { command: command.slice(0, 80) } : {}) });
495
+ }
496
+ from = gt + 1;
497
+ }
498
+ }
499
+ return [...found.values()];
500
+ }
501
+ /** The `后台 shell` section tm_join appends. A running shell is named as RUNNING and told
502
+ * not to be re-dispatched: the user can see those processes, and a lead that cannot see
503
+ * them dispatches the same work twice. Nothing is killed here — the host owns the shell. */
504
+ export function renderBackgroundShells(shells) {
505
+ if (!shells.length)
506
+ return null;
507
+ const verdict = (s) => s.state === "running" ? "运行中" : s.state === "completed" ? "已完成" : `失败(${s.state})`;
508
+ const parts = shells.map((s) => `${s.id} ${verdict(s)}${s.command ? ` · ${shorten(s.command, 60)}` : ""}`);
509
+ const running = shells.filter((s) => s.state === "running");
510
+ const lines = [`后台 shell:${shells.length} 个(${parts.join(" / ")})`];
511
+ if (running.length) {
512
+ lines.push(`⏳ ${running.map((s) => s.id).join("、")} 还在跑 —— 别重复派同类活(宿主的 shell 自己收输出,这里只报告,不 kill)。`);
513
+ }
514
+ return lines.join("\n");
515
+ }
317
516
  /**
318
517
  * Turn whatever the host puts in an error slot into ONE readable line.
319
518
  *
@@ -365,10 +564,27 @@ export function describeHostError(err, max = 220) {
365
564
  * that had already landed — the fix is to say it, not to withhold the text.
366
565
  */
367
566
  export const INCOMPLETE_PRESUMED_BODY_WARNING = "⚠ 推定结算且正文未带完成时间:这可能是最后一次工具调用前的半截话,不要据此判定交付;稍后再次 tm_join 可复核(或让子代理续跑)。";
368
- /** One child's status line — the lead reads these, so keep them terse. */
567
+ /** One child's status line — the lead reads these, so keep them terse. The tag is chosen by
568
+ * the settle SOURCE, because the same word would otherwise cover a host-confirmed finish, an
569
+ * unreachable seam and a guess: only `session-get` may print the host's outcome, and nothing
570
+ * but a real settlement may print 完成. */
369
571
  export function renderChildLine(c, elapsedMs) {
370
572
  const secs = Math.round(elapsedMs / 1000);
371
- const tag = c.state === "running" ? `运行中 ${secs}s` : c.state === "error" ? `失败:${shorten(c.error ?? "", 80)}` : `已完成 ${secs}s`;
573
+ const hostRunSecs = c.hostIdleAt !== undefined && c.hostCreatedAt !== undefined && c.hostIdleAt > c.hostCreatedAt
574
+ ? Math.round((c.hostIdleAt - c.hostCreatedAt) / 1000)
575
+ : secs;
576
+ const tag = c.state === "running"
577
+ ? `运行中 ${secs}s`
578
+ : c.state === "error"
579
+ ? `失败:${shorten(c.error ?? "", 80)}`
580
+ : c.settleSource === "session-get"
581
+ ? // The host's own record answered with a finished run: the only form allowed to
582
+ // print its outcome word, and it never says 推定.
583
+ `已结算(${c.outcome ?? "宿主未给 outcome"})· 运行 ${hostRunSecs}s`
584
+ : c.settleSource === "parent-idle"
585
+ ? // An inference. 完成 is exactly the word this may not borrow.
586
+ `推定已结算(未确认完成)· 派发后 ${secs}s`
587
+ : `已完成 ${secs}s`;
372
588
  // #33: a row the host reported IDLE for a stop attempt is not a delivery — the lead
373
589
  // needs to see that the child was never running, not a bare 已完成.
374
590
  const stop = c.state === "idle" && c.cancelNote ? ` ·${c.cancelNote}` : "";
@@ -378,6 +594,38 @@ export function renderChildLine(c, elapsedMs) {
378
594
  ? " ·(宿主 subagent 派发)"
379
595
  : ""}`;
380
596
  }
597
+ /**
598
+ * One line saying HOW the round's rows reached their state: `宿主确认` (the host's own record,
599
+ * event or envelope), `推定` (our inference) and `未结算` (still running). A lead skimming two
600
+ * rows that both read 已结算 otherwise cannot tell which one the host actually confirmed — and
601
+ * that difference is the whole point of asking `session.get` first.
602
+ */
603
+ export function settleTallyLine(children) {
604
+ if (!children.length)
605
+ return "";
606
+ let confirmed = 0;
607
+ let presumed = 0;
608
+ let open = 0;
609
+ const outcomes = new Map();
610
+ for (const c of children) {
611
+ if (c.state === "running") {
612
+ open++;
613
+ continue;
614
+ }
615
+ if (c.settleSource === "parent-idle") {
616
+ presumed++;
617
+ continue;
618
+ }
619
+ confirmed++;
620
+ if (c.outcome)
621
+ outcomes.set(c.outcome, (outcomes.get(c.outcome) ?? 0) + 1);
622
+ }
623
+ const named = [...outcomes.entries()].map(([o, n]) => `${o} ${n}`);
624
+ const withoutOutcome = confirmed - [...outcomes.values()].reduce((a, b) => a + b, 0);
625
+ if (withoutOutcome > 0)
626
+ named.push(`宿主未给 outcome ${withoutOutcome}`);
627
+ return `结算:宿主确认 ${confirmed}${named.length ? `(${named.join(" · ")})` : ""} / 推定 ${presumed} / 未结算 ${open}`;
628
+ }
381
629
  /** Verdicts tm_join can actually observe (issue #7's "leader keeps control"). */
382
630
  export function summarizeStates(children) {
383
631
  let running = 0;
@@ -528,6 +776,51 @@ export function steerVerdictLine(outcome, opts) {
528
776
  return `未插话${seam}:${shorten(opts?.note ?? "宿主没有回 inbox id", 120)}`;
529
777
  }
530
778
  }
779
+ /** What the lead reads after the delivery check. Every branch says out loud that delivery is
780
+ * not obedience — the composition must never promise what the child will do. */
781
+ export function steerDeliveryLine(v, inboxID, note) {
782
+ const inbox = inboxID ? `(inbox=${inboxID})` : "";
783
+ switch (v) {
784
+ case "delivered":
785
+ return `已送达${inbox}:该 inbox 项已经不在待投递清单里,宿主把它交给了子会话。投递不等于子代理照做 —— 它是否按这条改,仍要看下一次结算与正文。`;
786
+ case "queued":
787
+ return `已受理(未送达,宿主在步边界投递)${inbox}:该项仍在待投递清单里。投递≠执行,别把这一句当成子代理会照做。`;
788
+ case "unverified":
789
+ return `已受理${inbox}(未验证送达${note ? `:${shorten(note, 80)}` : ":宿主没有给出可核对的待投递清单"})。投递≠执行。`;
790
+ }
791
+ }
792
+ /** Re-read the child's inbox once after a successful steer and answer the only question that
793
+ * can be answered: is the item still pending? `unverified` covers every shape we cannot
794
+ * read — no seam, a throwing call, a payload with items we could not identify — because
795
+ * "absent" must not be inferred from "unreadable". */
796
+ export async function verifySteerDelivery(api, sessionID, inboxID) {
797
+ if (!api || api.v2SteerSeam !== true || typeof api.inboxList !== "function") {
798
+ return { verdict: "unverified", note: "这个宿主没有 session.inbox.list 的缝" };
799
+ }
800
+ try {
801
+ const un = unwrapClientResult(await api.inboxList({ sessionID }));
802
+ if (!un.ok)
803
+ return { verdict: "unverified", note: un.message };
804
+ const data = un.data;
805
+ const seamOut = String(data?.outcome ?? "");
806
+ if (seamOut === "threw" || seamOut === "no-seam") {
807
+ return {
808
+ verdict: "unverified",
809
+ note: seamOut === "no-seam" ? "宿主没给 session.inbox.list" : String(data?.message ?? "查询被宿主拒绝"),
810
+ };
811
+ }
812
+ const listed = inboxItemLines(data?.items);
813
+ // An item we could not parse is not an item that is gone: `skipped` covers unknown id
814
+ // shapes AND the overflow past the list cap, and both make "absent" unprovable.
815
+ if (listed.skipped > 0)
816
+ return { verdict: "unverified", note: `清单里有 ${listed.skipped} 项没被列出(id 形状或条数上限)` };
817
+ const stillQueued = listed.lines.some((l) => l.split(" · ")[0] === `id=${inboxID}`);
818
+ return stillQueued ? { verdict: "queued" } : { verdict: "delivered" };
819
+ }
820
+ catch (err) {
821
+ return { verdict: "unverified", note: describeHostError(err, 120) };
822
+ }
823
+ }
531
824
  export function steerOutcomeLabel(o) {
532
825
  return o === "steered" ? "已受理" : o === "not-steered" ? "未插话" : "无插话缝";
533
826
  }
@@ -688,6 +981,15 @@ export function buildDispatchTools(deps) {
688
981
  // comparable with this one. Renaming would split the history in two.
689
982
  const log = (e) => store.appendTrajectory({ tool: "tm_dispatch", ...e });
690
983
  const children = new Map();
984
+ /** Settle checks still in flight. `observeEvent` is a synchronous hook — the host does not
985
+ * await it — while asking `session.get` is async, so the read is tracked here and DRAINED by
986
+ * tm_join before it reports. Without that, a join issued right after a parent-idle event
987
+ * could answer from a row we were in the middle of updating. */
988
+ const settling = new Set();
989
+ const trackSettle = (p) => {
990
+ settling.add(p);
991
+ void p.finally(() => settling.delete(p));
992
+ };
691
993
  /** Per-parent memory of the LAST join's outcome. A wait is not parallelism
692
994
  * — while tm_join is in flight the lead's turn is blocked exactly like a
693
995
  * synchronous `task` call — so a SECOND wait that inherited a first wait
@@ -820,7 +1122,7 @@ export function buildDispatchTools(deps) {
820
1122
  // occurs on a v1-shaped client that lacks `session.messages`, and a sentence
821
1123
  // blaming "v2" there would be false in the opposite direction.
822
1124
  if (typeof api?.children !== "function")
823
- return { adopted: 0, looked: false, why: "这个宿主客户端没有给出 session.children(可辨认的 session API 缺失),我没有看过会话树" };
1125
+ return { adopted: 0, looked: false, why: "这个宿主客户端没有给出 session.children(缺的只是「列举会话树」这一条缝,不是我整个 session API 不可用),我没有看过会话树" };
824
1126
  if (!parent)
825
1127
  return { adopted: 0, looked: false, why: "调用方会话 id 缺失,无从查询子会话" };
826
1128
  let un;
@@ -860,6 +1162,118 @@ export function buildDispatchTools(deps) {
860
1162
  }
861
1163
  return { adopted, looked: true, why: adopted ? "" : "会话树里没有被认领标题的子会话" };
862
1164
  }
1165
+ /** Read the CALLING session's own transcript. On a host with no `session.children`
1166
+ * (2.1.4) this is the only recovery source left, and it is also where the host announces
1167
+ * its background shells. Never throws: a failure is a reported `why`, because
1168
+ * "we could not look" and "there was nothing" must not print the same. */
1169
+ async function readParentMessages(parent, directory) {
1170
+ if (!parent)
1171
+ return { ok: false, why: "调用方会话 id 缺失,读不到父会话历史" };
1172
+ if (typeof api?.messages !== "function") {
1173
+ return { ok: false, why: "这个宿主客户端没有给出读取会话正文的缝(v2 上是 ctx.session.context),父会话历史读不到" };
1174
+ }
1175
+ try {
1176
+ const un = unwrapClientResult(await api.messages({ path: { id: parent }, ...(directory ? { query: { directory } } : {}) }));
1177
+ if (!un.ok)
1178
+ return { ok: false, why: `读取父会话历史失败:${shorten(un.message ?? "", 90)}` };
1179
+ return { ok: true, data: un.data, why: "" };
1180
+ }
1181
+ catch (err) {
1182
+ return {
1183
+ ok: false,
1184
+ why: `读取父会话历史失败:${describeHostError(err?.message ?? err, 90)}`,
1185
+ };
1186
+ }
1187
+ }
1188
+ /**
1189
+ * Adopt the children the PARENT TRANSCRIPT still names — the second recovery path, for a
1190
+ * host whose `session.children` does not exist. Measured live (2.1.4 + 1.8.6): a service
1191
+ * restart empties this in-process registry, `session.children` is gone, so tm_join
1192
+ * answered `没有待收集的派发` while the user could still see the sub-agents running — and
1193
+ * the lead re-dispatched work that was already in flight.
1194
+ *
1195
+ * The transcript is not trusted as ownership: every candidate is verified through the
1196
+ * host's own `session.get` (`parentID === the caller`) before a row is opened, because an
1197
+ * envelope names a session, it does not prove the session is OURS. A candidate the host
1198
+ * will not confirm is counted as skipped and never shown as a child.
1199
+ *
1200
+ * The row's state comes from the evidence: `state="completed"` envelope → settled
1201
+ * (`settleSource="injection"`, the host's own injection is the evidence);
1202
+ * `state="error"` → failed; an ACK or `state="running"` → running, and the host's own
1203
+ * `time.idle` is still asked for (it is the only source carrying the `outcome` word).
1204
+ */
1205
+ async function adoptFromParentHistory(parent, parentMessages, directory, only) {
1206
+ const candidates = childrenFromParentHistory(parentMessages);
1207
+ let adopted = 0;
1208
+ let skipped = 0;
1209
+ for (const cand of candidates) {
1210
+ const sid = cand.sessionID;
1211
+ if (!sid || sid === parent || children.has(sid) || (only && !only.has(sid))) {
1212
+ skipped++;
1213
+ continue;
1214
+ }
1215
+ const gate = await parentageOf(sid, parent, directory);
1216
+ if (!gate.ok) {
1217
+ skipped++;
1218
+ log({ step_id: "join", event: "adopt_history_refused", child: sid, reason: "parentage" });
1219
+ continue;
1220
+ }
1221
+ const hostAgent = gate.agent.trim().toLowerCase();
1222
+ // T3: this registry never carries a lead, whatever a stale transcript says.
1223
+ const agent = hostAgent && !sameAgent(hostAgent, leadAgent) ? hostAgent : "subagent";
1224
+ const startedAt = now();
1225
+ const rec = {
1226
+ sessionID: sid,
1227
+ agent,
1228
+ label: cand.label || "host child",
1229
+ parentSessionID: parent,
1230
+ startedAt,
1231
+ state: "running",
1232
+ via: "host-injection",
1233
+ };
1234
+ if (cand.state === "completed") {
1235
+ rec.state = "idle";
1236
+ rec.finishedAt = startedAt;
1237
+ rec.settleSource = "injection";
1238
+ }
1239
+ else if (cand.state === "error") {
1240
+ rec.state = "error";
1241
+ rec.finishedAt = startedAt;
1242
+ rec.error = "宿主注入信封报告 state=error(正文随注入消息送达)";
1243
+ }
1244
+ else {
1245
+ // Still running, as far as the transcript knows — but the host's own record is a
1246
+ // better witness and it also carries `outcome`, which no envelope does.
1247
+ const st = await readHostSessionState(api, sid);
1248
+ if (st.idleAt) {
1249
+ rec.state = "idle";
1250
+ rec.finishedAt = st.idleAt;
1251
+ rec.hostIdleAt = st.idleAt;
1252
+ if (st.createdAt !== undefined)
1253
+ rec.hostCreatedAt = st.createdAt;
1254
+ rec.settleSource = "session-get";
1255
+ if (st.outcome)
1256
+ rec.outcome = st.outcome;
1257
+ }
1258
+ else if (st.createdAt !== undefined) {
1259
+ rec.hostCreatedAt = st.createdAt;
1260
+ }
1261
+ }
1262
+ children.set(sid, rec);
1263
+ deps.onChildSession?.(sid, agent);
1264
+ adopted++;
1265
+ log({ step_id: "join", event: "adopt", child: sid, agent, state: cand.state, source: "parent-history" });
1266
+ }
1267
+ // One summary row per call: the counters are what makes "I looked and there was
1268
+ // nothing" distinguishable from "I never looked" in the trajectory.
1269
+ log({ step_id: "join", event: "adopt_history", candidates: candidates.length, adopted, skipped, source: "parent-history" });
1270
+ const why = !candidates.length
1271
+ ? "父会话历史里没有子代理派发记录(既没有后台 ack,也没有 <subagent> 信封;历史若被压缩过,这条也不算数)"
1272
+ : adopted
1273
+ ? ""
1274
+ : `父会话历史里 ${candidates.length} 个候选都没能认领(${skipped} 个已登记或宿主未确认归属)`;
1275
+ return { adopted, looked: true, candidates: candidates.length, skipped, why };
1276
+ }
863
1277
  /** Claim ONE explicitly named child of the calling session — the host's own
864
1278
  * `task` child included, which `adoptFromHost` must never pick up on its
865
1279
  * own. The lock is the host's parentage (`parentID === the caller`), read
@@ -953,8 +1367,9 @@ export function buildDispatchTools(deps) {
953
1367
  - 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.
954
1368
  - { 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 }).
955
1369
  - { 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.
956
- - 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 已送达. 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.
957
- - 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.
1370
+ - 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.
1371
+ - 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 on a host with no \`session.children\` the children your own transcript still names — the background ACK \`(sessionID: ses_…)\` and the \`<subagent sessionID="…" state="…">\` envelope — are claimed after the host confirms their parentage. One that answered while nobody was listening is reported 已完成, not lost.
1372
+ - Background SHELLS the host announced in this session are reported at the end of every reply (\`后台 shell:N 个\`), with the running ones named as still running. Nothing is killed by this tool: re-run a shell only if a completed one failed, and never dispatch the same work again while one is still running.
958
1373
  - 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).`,
959
1374
  args: {
960
1375
  ids: { descriptor: "ids: string[] (optional — only these child sessions)" },
@@ -999,7 +1414,20 @@ export function buildDispatchTools(deps) {
999
1414
  // wrote its governed result — so the token table read "0 调用 / 2 结果",
1000
1415
  // which looks like a broken counter rather than a tool that ran.
1001
1416
  store.appendTrajectory({ tool, step_id: "join", event: "call" });
1417
+ // A settle check may still be talking to the host (see `settling`). Drain it before
1418
+ // reading the registry so this reply never mixes a fresh table with a half-updated row.
1419
+ if (settling.size)
1420
+ await Promise.allSettled([...settling]);
1002
1421
  const directory = typeof c.directory === "string" && c.directory ? c.directory : undefined;
1422
+ // Read the CALLER's own transcript ONCE per call: it is the floor recovery source
1423
+ // when the host has no `session.children`, and the only place a background shell is
1424
+ // ever announced. In-process on v2 (`ctx.session.context`), one hop on v1.
1425
+ const parentHistory = await readParentMessages(parent, directory);
1426
+ // Shells are REPORTED, never claimed: they are the host's processes, and this tool
1427
+ // only exists to keep the lead from re-dispatching what is already running.
1428
+ const shellSection = parentHistory.ok
1429
+ ? renderBackgroundShells(backgroundShellsFromParentHistory(parentHistory.data))
1430
+ : null;
1003
1431
  const idList = parseIdList(args.ids);
1004
1432
  const idFilter = idList && idList.length ? new Set(idList) : null;
1005
1433
  const owned = () => [...children.values()].filter((r) => (!parent || r.parentSessionID === parent) && (!idFilter || idFilter.has(r.sessionID)));
@@ -1018,6 +1446,24 @@ export function buildDispatchTools(deps) {
1018
1446
  adoption = await adoptFromHost(parent, directory, idFilter);
1019
1447
  if (adoption.adopted)
1020
1448
  mine = owned();
1449
+ // Second recovery source, for the host that has no `session.children` at all
1450
+ // (measured 2.1.4): the parent transcript still names its children. Only asked
1451
+ // when the tree walk did not settle the question — never instead of it.
1452
+ if (mine.length < (idFilter ? idFilter.size : 1)) {
1453
+ const hist = parentHistory.ok
1454
+ ? await adoptFromParentHistory(parent, parentHistory.data, directory, idFilter)
1455
+ : { adopted: 0, looked: false, candidates: 0, skipped: 0, why: parentHistory.why };
1456
+ if (hist.adopted)
1457
+ mine = owned();
1458
+ // The `why` has to keep the three states apart: a children API that looked, no
1459
+ // children API but the history claimed something, and neither source yielding a
1460
+ // child. Fusing them is the overstatement this path was built to stop.
1461
+ adoption = {
1462
+ adopted: adoption.adopted + hist.adopted,
1463
+ looked: adoption.looked,
1464
+ why: [adoption.why, hist.why].filter(Boolean).join(";"),
1465
+ };
1466
+ }
1021
1467
  // Plan B: a NAMED id may be the host's own `task` child (no ·tm
1022
1468
  // marker, so the tree walk above never claims it). Collecting it is
1023
1469
  // how the lead reads back a result we replaced with a pointer — the
@@ -1140,6 +1586,20 @@ export function buildDispatchTools(deps) {
1140
1586
  // and a verdict read off whatever came back.
1141
1587
  note = "这个客户端不是 v2 的 ctx.session 桥(v1 的 session.prompt 走 {path,body},没有 delivery 字段),未调用";
1142
1588
  }
1589
+ // C — the write only proves ADMISSION. Ask the same inbox once more for the one
1590
+ // checkable fact: is the item still pending? Nothing is claimed when the seam
1591
+ // cannot answer (see steerDeliveryLine's unverified branch).
1592
+ const verified = outcome === "steered" && inboxID ? await verifySteerDelivery(api, target, inboxID) : null;
1593
+ if (verified) {
1594
+ log({
1595
+ step_id: "join",
1596
+ event: "steer_delivery",
1597
+ child: target,
1598
+ inboxID,
1599
+ verdict: verified.verdict,
1600
+ ...(verified.note ? { reason: shorten(verified.note, 120) } : {}),
1601
+ });
1602
+ }
1143
1603
  log({
1144
1604
  step_id: "join",
1145
1605
  event: "steer",
@@ -1150,6 +1610,7 @@ export function buildDispatchTools(deps) {
1150
1610
  ok: outcome === "steered",
1151
1611
  wake: wakeRequested,
1152
1612
  ...(inboxID ? { inboxID } : {}),
1613
+ ...(verified ? { delivered: verified.verdict } : {}),
1153
1614
  ...(seam ? { seam } : {}),
1154
1615
  // the LENGTH, never the text — R6 binds the audit trail as much as the reply
1155
1616
  chars: steerText.length,
@@ -1160,7 +1621,10 @@ export function buildDispatchTools(deps) {
1160
1621
  const wake = outcome === "steered" && wakeRequested && row && row.state !== "running"
1161
1622
  ? `\n已按 wake:true 唤醒:它当时不是运行中(${row.state === "error" ? "失败" : "已结算"}),宿主把这条插话当成新的执行输入——它会和新派发的 worker 并发,留意这一点。`
1162
1623
  : "";
1163
- return toToolResult(`插话目标:${rowLine}\n插话结果:${steerVerdictLine(outcome, { inboxID, note, seam })}\n插话计数:${steerOutcomeParts(tally).join(" · ")}${wake}`);
1624
+ const deliveryLine = verified
1625
+ ? `\n投递核对:${steerDeliveryLine(verified.verdict, inboxID, verified.note)}`
1626
+ : "";
1627
+ return toToolResult(`插话目标:${rowLine}\n插话结果:${steerVerdictLine(outcome, { inboxID, note, seam })}\n插话计数:${steerOutcomeParts(tally).join(" · ")}${deliveryLine}${wake}`);
1164
1628
  }
1165
1629
  if (wantsUnread) {
1166
1630
  if (!api || api.v2SteerSeam !== true || typeof api.inboxList !== "function") {
@@ -1223,18 +1687,29 @@ export function buildDispatchTools(deps) {
1223
1687
  const saw = adoption.looked;
1224
1688
  const inner = idFilter
1225
1689
  ? (() => {
1226
- // Naming the shapes the v2 session bridge tried is the whole point:
1227
- // without it, a claim that failed because `ctx.session.get` answered
1228
- // in a shape we do not know prints the same sentence as a tree we
1229
- // did query, and the lead goes back to waiting for a report that
1230
- // exists.
1690
+ // TWO independent facts live here, and they used to be fused into one
1691
+ // sentence: what the session READER can do, and whether the CHILDREN
1692
+ // endpoint exists. On 2.1.4 `ctx.session.get({sessionID})` answers while
1693
+ // `session.children` does not, so the old text ("可辨认的 session API 缺失"
1694
+ // plus the last error of a shape already abandoned) told the lead the host
1695
+ // had no session API at all — and it went back to assuming its id was
1696
+ // fine and the host broken. State them separately, and name the shapes
1697
+ // only when EVERY shape failed: a shape that worked makes the abandoned
1698
+ // ones irrelevant, not evidence.
1231
1699
  const sr = deps.sessionReaderReport?.();
1232
1700
  const bridge = sr && sr.attempted > 0
1233
- ? `;我试过的 ctx.session 形状:${(sr.failedShapes.length ? sr.failedShapes.join("→") : sr.usedShape ?? "?")}${sr.lastError ? `(宿主最后一次的错误:${shorten(sr.lastError, 90)})` : "(宿主没有报错,只是没给出 parentID)"}`
1701
+ ? sr.usedShape
1702
+ ? `;ctx.session.get 可用(形状 ${sr.usedShape}):宿主没有 session.children,按 id 读单条会话可以,列举会话树不行`
1703
+ : `;按 id 读子会话也失败:试过的 ctx.session 形状 ${sr.failedShapes.length ? sr.failedShapes.join("→") : "?"}${sr.lastError ? `(宿主最后一次的错误:${shorten(sr.lastError, 90)})` : "(宿主没有报错,只是没给出 parentID)"}`
1234
1704
  : "";
1705
+ // A named id that neither our registry nor the host's by-id read could
1706
+ // resolve is a miss about THAT id — saying it means the host has no
1707
+ // session API is the claim this branch exists to stop making.
1708
+ const named = [...idFilter].slice(0, 3).map((s) => shorten(s, 40)).join("、");
1709
+ const miss = `ids 未匹配到本会话的子代理:${named} 在本机不存在或无权限`;
1235
1710
  return saw
1236
- ? `(ids 未匹配到本会话的子代理,宿主会话树里也没有可认领的子会话)${bridge}`
1237
- : `(ids 未匹配到;而且我没能查看宿主会话树:${adoption.why})${bridge}`;
1711
+ ? `(${miss};宿主会话树里也没有可认领的子会话${bridge})`
1712
+ : `(${miss};而且我没能查看宿主会话树:${adoption.why}${bridge})`;
1238
1713
  })()
1239
1714
  : saw
1240
1715
  ? ""
@@ -1245,7 +1720,11 @@ export function buildDispatchTools(deps) {
1245
1720
  // the transcript, and the honest reason is that a synchronous host
1246
1721
  // task never enters this registry at all.
1247
1722
  `如果你用的是同步 \`task\`:宿主自己就把结果收回来了,它从不登记在这里,这不是派发失败。` +
1248
- `只有后台 \`task {background:true}\`(或显式带 ids)需要 tm_join 来取。`);
1723
+ `只有后台 \`task {background:true}\`(或显式带 ids)需要 tm_join 来取。` +
1724
+ // A round with no collectable CHILD is still not an empty round when the host
1725
+ // has shell commands in flight: the user can see them and the lead cannot, so
1726
+ // silence here is what produced a duplicate dispatch.
1727
+ (shellSection ? `\n\n${shellSection}` : ""));
1249
1728
  }
1250
1729
  await settleAdopted(mine, directory);
1251
1730
  const waitMs = Math.max(0, Math.min(maxWaitMs, Number(args.waitMs) || 0));
@@ -1362,8 +1841,10 @@ export function buildDispatchTools(deps) {
1362
1841
  });
1363
1842
  }
1364
1843
  const sums = summarizeStates(mine);
1844
+ const settleLine = settleTallyLine(mine);
1365
1845
  const header = [
1366
1846
  `派发汇总:${sums.idle} 完成 / ${sums.running} 运行中 / ${sums.error} 失败${settled ? "(全部已结算)" : `(未等到全部结算 · 本次已等 ${Math.round((now() - waitStart) / 1000)}s)`}`,
1847
+ ...(settleLine ? [settleLine] : []),
1367
1848
  ...mine.map((r) => renderChildLine(r, (r.finishedAt ?? now()) - r.startedAt)),
1368
1849
  ];
1369
1850
  // The `正文:` line belongs directly under the child rows it describes, but the
@@ -1525,7 +2006,7 @@ export function buildDispatchTools(deps) {
1525
2006
  // when bodies were actually read (`includeText:false` has no body to judge).
1526
2007
  if (bodyStatus.length)
1527
2008
  header.splice(childRowsEnd, 0, ...bodyStatus);
1528
- const body = [...header, ...(blocks.length ? ["", blocks.join("\n")] : [])].join("\n");
2009
+ const body = [...header, ...(blocks.length ? ["", blocks.join("\n")] : []), ...(shellSection ? ["", shellSection] : [])].join("\n");
1529
2010
  const stepId = deps.pipelines.nextStepId();
1530
2011
  return toToolResult(await deps.pipelines.govern(stepId, tool, body, {
1531
2012
  contentType: detectContentType(body),
@@ -1538,6 +2019,66 @@ export function buildDispatchTools(deps) {
1538
2019
  }
1539
2020
  },
1540
2021
  };
2022
+ /** Settle the still-running HOST children of a session that just went idle — by ASKING the
2023
+ * host, not by assuming. One `session.get` per child, three outcomes kept apart:
2024
+ * - `time.idle` present → settled, `settleSource="session-get"`, host `outcome` kept;
2025
+ * - answered WITHOUT idle → the child is still running: the row is left alone and the
2026
+ * attempt is counted (`settle_unchecked`), because a live child reported as settled is
2027
+ * the exact overstatement this tool was already fixed for;
2028
+ * - no seam / a throwing call → the old `parent-idle` presumption, printed as one.
2029
+ * `idle_settled` and `idle_presumed` are separate trajectory rows on purpose: tm_stats can
2030
+ * then show how often the host actually confirmed a finish. */
2031
+ async function reconcileHostChildren(rows) {
2032
+ for (const child of rows) {
2033
+ if (child.state !== "running")
2034
+ continue;
2035
+ const st = await readHostSessionState(api, child.sessionID);
2036
+ if (st.idleAt) {
2037
+ child.state = "idle";
2038
+ child.finishedAt = st.idleAt;
2039
+ child.hostIdleAt = st.idleAt;
2040
+ if (st.createdAt !== undefined)
2041
+ child.hostCreatedAt = st.createdAt;
2042
+ child.settleSource = "session-get";
2043
+ if (st.outcome)
2044
+ child.outcome = st.outcome;
2045
+ log({
2046
+ step_id: "events",
2047
+ event: "idle_settled",
2048
+ source: "session-get",
2049
+ child: child.sessionID,
2050
+ agent: child.agent,
2051
+ ...(st.outcome ? { outcome: st.outcome } : {}),
2052
+ ms: st.idleAt - child.startedAt,
2053
+ });
2054
+ continue;
2055
+ }
2056
+ if (st.answered) {
2057
+ log({
2058
+ step_id: "events",
2059
+ event: "settle_unchecked",
2060
+ source: "session-get",
2061
+ reason: "still-running",
2062
+ child: child.sessionID,
2063
+ agent: child.agent,
2064
+ });
2065
+ continue;
2066
+ }
2067
+ presumeParentIdle([child]);
2068
+ }
2069
+ }
2070
+ /** The FALLBACK: a still-running host child of a session that just went idle, on a host we
2071
+ * could not ask. Recorded as `parent-idle` and printed as a presumption — never 完成. */
2072
+ function presumeParentIdle(rows) {
2073
+ for (const child of rows) {
2074
+ if (child.state !== "running")
2075
+ continue;
2076
+ child.state = "idle";
2077
+ child.finishedAt = now();
2078
+ child.settleSource = "parent-idle";
2079
+ log({ step_id: "events", event: "idle_presumed", child: child.sessionID, agent: child.agent, ms: child.finishedAt - child.startedAt });
2080
+ }
2081
+ }
1541
2082
  const observeEvent = (event) => {
1542
2083
  const rawType = String(event?.type ?? "");
1543
2084
  if (!rawType.startsWith("session."))
@@ -1560,21 +2101,22 @@ export function buildDispatchTools(deps) {
1560
2101
  return;
1561
2102
  const rec = children.get(sid);
1562
2103
  if (!rec) {
1563
- // The parent's own idle is the only completion signal v2 gives us for a host
1564
- // child: the background reply is injected into the parent before the parent's turn
1565
- // ends (measured: child injected …824730, parent idle …829038), and the plugin has
1566
- // no endpoint to read a child session's state. So settle the still-running host
1567
- // children of THIS session — and say plainly that it is a presumption.
2104
+ // The parent's own idle is the only completion signal the host gives us for a HOST
2105
+ // child — and it is not the strongest one available: `session.get({sessionID})` answers
2106
+ // the child's own `time.idle` (verified on 2.1.4), so we ASK first and only fall back to
2107
+ // the inference when the host cannot be asked. A row that stayed 运行中 forever would be
2108
+ // an overstatement in reverse; a row presumed settled when the host says it is still
2109
+ // executing is the overstatement this tool exists to refuse.
1568
2110
  if (type === "session.idle") {
1569
- for (const child of children.values()) {
1570
- if (child.via !== "host-injection" || child.state !== "running")
1571
- continue;
1572
- if (child.parentSessionID !== sid)
1573
- continue;
1574
- child.state = "idle";
1575
- child.finishedAt = now();
1576
- child.settleSource = "parent-idle";
1577
- log({ step_id: "events", event: "idle_presumed", child: child.sessionID, agent: child.agent, ms: child.finishedAt - child.startedAt });
2111
+ const pending = [...children.values()].filter((child) => child.via === "host-injection" && child.state === "running" && child.parentSessionID === sid);
2112
+ if (pending.length) {
2113
+ // No seam at all → the fallback needs no round-trip, so it lands HERE and the
2114
+ // synchronous hook stays synchronously readable. A seam that EXISTS is asked, and
2115
+ // that read is tracked so tm_join drains it before reporting.
2116
+ if (!api || typeof api.get !== "function")
2117
+ presumeParentIdle(pending);
2118
+ else
2119
+ trackSettle(reconcileHostChildren(pending));
1578
2120
  }
1579
2121
  }
1580
2122
  return;