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

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.
@@ -342,6 +342,177 @@ export function hostEnvelopeBody(messages, sessionID) {
342
342
  }
343
343
  return null;
344
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
+ }
345
516
  /**
346
517
  * Turn whatever the host puts in an error slot into ONE readable line.
347
518
  *
@@ -393,31 +564,49 @@ export function describeHostError(err, max = 220) {
393
564
  * that had already landed — the fix is to say it, not to withhold the text.
394
565
  */
395
566
  export const INCOMPLETE_PRESUMED_BODY_WARNING = "⚠ 推定结算且正文未带完成时间:这可能是最后一次工具调用前的半截话,不要据此判定交付;稍后再次 tm_join 可复核(或让子代理续跑)。";
567
+ /** Wall-clock elapsed for a row, or `null` when NOBODY measured a start. `startedAt` is only
568
+ * set when it is a real measurement (the host's own `time.created` for an adopted history row,
569
+ * our dispatch moment otherwise); the host's run window (`hostCreatedAt` → `hostIdleAt`) is the
570
+ * fallback. A history row is discovered long after its run began, so our discovery moment is
571
+ * not a start — printing `0s` from it is the fake duration this split exists to remove. */
572
+ export function elapsedOf(c, at = Date.now()) {
573
+ if (!c.startedAtUnknown)
574
+ return (c.finishedAt ?? at) - c.startedAt;
575
+ if (typeof c.hostCreatedAt === "number")
576
+ return (c.hostIdleAt ?? c.finishedAt ?? at) - c.hostCreatedAt;
577
+ return null;
578
+ }
396
579
  /** One child's status line — the lead reads these, so keep them terse. The tag is chosen by
397
580
  * the settle SOURCE, because the same word would otherwise cover a host-confirmed finish, an
398
581
  * unreachable seam and a guess: only `session-get` may print the host's outcome, and nothing
399
582
  * but a real settlement may print 完成. */
400
583
  export function renderChildLine(c, elapsedMs) {
401
- const secs = Math.round(elapsedMs / 1000);
584
+ const secs = elapsedMs === null ? null : Math.round(elapsedMs / 1000);
585
+ /** Seconds, or the truth when nobody measured a start — never `0s`. */
586
+ const dur = (n) => (n === null ? "时长未知" : `${n}s`);
402
587
  const hostRunSecs = c.hostIdleAt !== undefined && c.hostCreatedAt !== undefined && c.hostIdleAt > c.hostCreatedAt
403
588
  ? Math.round((c.hostIdleAt - c.hostCreatedAt) / 1000)
404
589
  : secs;
405
590
  const tag = c.state === "running"
406
- ? `运行中 ${secs}s`
591
+ ? secs === null
592
+ ? "运行中(时长未知)"
593
+ : `运行中 ${secs}s`
407
594
  : c.state === "error"
408
595
  ? `失败:${shorten(c.error ?? "", 80)}`
409
596
  : c.settleSource === "session-get"
410
597
  ? // The host's own record answered with a finished run: the only form allowed to
411
598
  // print its outcome word, and it never says 推定.
412
- `已结算(${c.outcome ?? "宿主未给 outcome"})· 运行 ${hostRunSecs}s`
599
+ `已结算(${c.outcome ?? "宿主未给 outcome"})· 运行 ${dur(hostRunSecs)}`
413
600
  : c.settleSource === "parent-idle"
414
601
  ? // An inference. 完成 is exactly the word this may not borrow.
415
- `推定已结算(未确认完成)· 派发后 ${secs}s`
416
- : `已完成 ${secs}s`;
602
+ `推定已结算(未确认完成)· 派发后 ${dur(secs)}`
603
+ : `已完成 ${dur(secs)}`;
417
604
  // #33: a row the host reported IDLE for a stop attempt is not a delivery — the lead
418
605
  // needs to see that the child was never running, not a bare 已完成.
419
606
  const stop = c.state === "idle" && c.cancelNote ? ` ·${c.cancelNote}` : "";
420
- return `${c.sessionID} · ${c.agent} · "${c.label}" · ${tag}${stop}${c.adopted ? " ·(本进程重启后由宿主会话树接管)" : ""}${c.via === "host-injection" && c.settleSource === "parent-idle"
607
+ // A state read off the transcript envelope is not the host's verdict, and the row says so.
608
+ const inferred = c.historyInferred ? " ·(按历史信封推断)" : "";
609
+ return `${c.sessionID} · ${c.agent} · "${c.label}" · ${tag}${inferred}${stop}${c.adopted ? " ·(本进程重启后由宿主会话树接管)" : ""}${c.via === "host-injection" && c.settleSource === "parent-idle"
421
610
  ? " ·(宿主子代理:随父会话空闲推定已结算;宿主未给该子会话 idle 事件)"
422
611
  : c.via === "host-injection"
423
612
  ? " ·(宿主 subagent 派发)"
@@ -991,6 +1180,171 @@ export function buildDispatchTools(deps) {
991
1180
  }
992
1181
  return { adopted, looked: true, why: adopted ? "" : "会话树里没有被认领标题的子会话" };
993
1182
  }
1183
+ /** Read the CALLING session's own transcript. On a host with no `session.children`
1184
+ * (2.1.4) this is the only recovery source left, and it is also where the host announces
1185
+ * its background shells. Never throws: a failure is a reported `why`, because
1186
+ * "we could not look" and "there was nothing" must not print the same. */
1187
+ async function readParentMessages(parent, directory) {
1188
+ if (!parent)
1189
+ return { ok: false, why: "调用方会话 id 缺失,读不到父会话历史" };
1190
+ if (typeof api?.messages !== "function") {
1191
+ return { ok: false, why: "这个宿主客户端没有给出读取会话正文的缝(v2 上是 ctx.session.context),父会话历史读不到" };
1192
+ }
1193
+ try {
1194
+ const un = unwrapClientResult(await api.messages({ path: { id: parent }, ...(directory ? { query: { directory } } : {}) }));
1195
+ if (!un.ok)
1196
+ return { ok: false, why: `读取父会话历史失败:${shorten(un.message ?? "", 90)}` };
1197
+ return { ok: true, data: un.data, why: "" };
1198
+ }
1199
+ catch (err) {
1200
+ return {
1201
+ ok: false,
1202
+ why: `读取父会话历史失败:${describeHostError(err?.message ?? err, 90)}`,
1203
+ };
1204
+ }
1205
+ }
1206
+ /**
1207
+ * Adopt the children the PARENT TRANSCRIPT still names — the second recovery path, for a
1208
+ * host whose `session.children` does not exist. Measured live (2.1.4 + 1.8.6): a service
1209
+ * restart empties this in-process registry, `session.children` is gone, so tm_join
1210
+ * answered `没有待收集的派发` while the user could still see the sub-agents running — and
1211
+ * the lead re-dispatched work that was already in flight.
1212
+ *
1213
+ * The transcript is not trusted as ownership: every candidate is verified through the
1214
+ * host's own `session.get` (`parentID === the caller`) before a row is opened, because an
1215
+ * envelope names a session, it does not prove the session is OURS. A candidate the host
1216
+ * will not confirm is counted as skipped and never shown as a child.
1217
+ *
1218
+ * The row's state comes from the HOST, for every candidate: `time.idle` → settled with the
1219
+ * host's own `outcome` word; answered WITHOUT `time.idle` → RUNNING, whatever the transcript's
1220
+ * envelope says (1.8.7 took three `state="error"` envelopes as conclusions and answered
1221
+ * `0 运行中 / 3 失败` while the host's own UI still showed two of those children running).
1222
+ * The envelope's state is the fallback ONLY when the per-session read cannot answer, and the
1223
+ * row then prints `按历史信封推断` instead of borrowing the host's verdict.
1224
+ *
1225
+ * A candidate that is already FINISHED does not enter the live table: it becomes one count
1226
+ * (`另有 N 条已结算的历史子代理`) and is returned, body and all, when named with `ids`.
1227
+ */
1228
+ async function adoptFromParentHistory(parent, parentMessages, directory, only) {
1229
+ const candidates = childrenFromParentHistory(parentMessages);
1230
+ let adopted = 0;
1231
+ let skipped = 0;
1232
+ let settled = 0;
1233
+ let running = 0;
1234
+ // The split is counted per candidate, so the trajectory says how many of the leftovers were
1235
+ // still work and how many were already history. An already-registered row is tallied from
1236
+ // its own record — it is still part of what this transcript names.
1237
+ const tally = (r) => {
1238
+ if (r.state === "running")
1239
+ running++;
1240
+ else
1241
+ settled++;
1242
+ };
1243
+ for (const cand of candidates) {
1244
+ const sid = cand.sessionID;
1245
+ if (!sid || sid === parent) {
1246
+ skipped++;
1247
+ continue;
1248
+ }
1249
+ const known = children.get(sid);
1250
+ if (known) {
1251
+ skipped++;
1252
+ if (!only || only.has(sid))
1253
+ tally(known);
1254
+ continue;
1255
+ }
1256
+ if (only && !only.has(sid)) {
1257
+ skipped++;
1258
+ continue;
1259
+ }
1260
+ const gate = await parentageOf(sid, parent, directory);
1261
+ if (!gate.ok) {
1262
+ skipped++;
1263
+ log({ step_id: "join", event: "adopt_history_refused", child: sid, reason: "parentage" });
1264
+ continue;
1265
+ }
1266
+ const hostAgent = gate.agent.trim().toLowerCase();
1267
+ // T3: this registry never carries a lead, whatever a stale transcript says.
1268
+ const agent = hostAgent && !sameAgent(hostAgent, leadAgent) ? hostAgent : "subagent";
1269
+ const rec = {
1270
+ sessionID: sid,
1271
+ agent,
1272
+ label: cand.label || "host child",
1273
+ parentSessionID: parent,
1274
+ // Our discovery moment — never the run's start. Replaced below by the host's own
1275
+ // `time.created` when it answers with one; until then the row reports 时长未知.
1276
+ startedAt: now(),
1277
+ startedAtUnknown: true,
1278
+ state: "running",
1279
+ via: "host-injection",
1280
+ };
1281
+ // ASK THE HOST FIRST, for every candidate. The transcript proves a dispatch existed; it
1282
+ // does not decide whether the run is over — the host's `time.idle` does. A 1.8.7 live
1283
+ // session read three `state="error"` envelopes as verdicts and reported 0 运行中 / 3 失败
1284
+ // while the host's UI still showed two children running.
1285
+ const st = await readHostSessionState(api, sid);
1286
+ if (st.createdAt !== undefined) {
1287
+ // The host's own start: the only clock a duration may be derived from.
1288
+ rec.hostCreatedAt = st.createdAt;
1289
+ rec.startedAt = st.createdAt;
1290
+ rec.startedAtUnknown = false;
1291
+ }
1292
+ if (st.idleAt) {
1293
+ rec.state = "idle";
1294
+ rec.finishedAt = st.idleAt;
1295
+ rec.hostIdleAt = st.idleAt;
1296
+ rec.settleSource = "session-get";
1297
+ if (st.outcome)
1298
+ rec.outcome = st.outcome;
1299
+ }
1300
+ else if (st.answered) {
1301
+ // Answered, and no finished run on record → still running. No `time.created` means no
1302
+ // duration; the row says so rather than counting from our discovery moment.
1303
+ }
1304
+ else if (cand.state === "completed" || cand.state === "error") {
1305
+ // The per-session read could not answer: the envelope is the only evidence left, and
1306
+ // the row is marked so the lead can see whose word this is.
1307
+ rec.historyInferred = true;
1308
+ rec.settleSource = "injection";
1309
+ if (cand.state === "completed") {
1310
+ rec.state = "idle";
1311
+ }
1312
+ else {
1313
+ rec.state = "error";
1314
+ rec.error = "宿主注入信封报告 state=error(正文随注入消息送达)";
1315
+ }
1316
+ }
1317
+ // A candidate that is already finished leaves the LIVE table (it cannot be running) and
1318
+ // becomes a count. `owned` still returns it when the lead names it with `ids`.
1319
+ if (rec.state !== "running")
1320
+ rec.historySettled = true;
1321
+ tally(rec);
1322
+ children.set(sid, rec);
1323
+ deps.onChildSession?.(sid, agent);
1324
+ adopted++;
1325
+ log({ step_id: "join", event: "adopt", child: sid, agent, state: rec.state, source: "parent-history" });
1326
+ }
1327
+ // One summary row per call: the counters are what makes "I looked and there was
1328
+ // nothing" distinguishable from "I never looked" in the trajectory — and `settled` /
1329
+ // `running` are what makes "the transcript named them" distinguishable from "they are
1330
+ // live work".
1331
+ log({
1332
+ step_id: "join",
1333
+ event: "adopt_history",
1334
+ candidates: candidates.length,
1335
+ adopted,
1336
+ skipped,
1337
+ settled,
1338
+ running,
1339
+ source: "parent-history",
1340
+ });
1341
+ const why = !candidates.length
1342
+ ? "父会话历史里没有子代理派发记录(既没有后台 ack,也没有 <subagent> 信封;历史若被压缩过,这条也不算数)"
1343
+ : adopted
1344
+ ? ""
1345
+ : `父会话历史里 ${candidates.length} 个候选都没能认领(${skipped} 个已登记或宿主未确认归属)`;
1346
+ return { adopted, looked: true, candidates: candidates.length, skipped, settled, running, why };
1347
+ }
994
1348
  /** Claim ONE explicitly named child of the calling session — the host's own
995
1349
  * `task` child included, which `adoptFromHost` must never pick up on its
996
1350
  * own. The lock is the host's parentage (`parentID === the caller`), read
@@ -1085,7 +1439,8 @@ export function buildDispatchTools(deps) {
1085
1439
  - { 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 }).
1086
1440
  - { 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
1441
  - 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.
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.
1442
+ - 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. Their STATE is the host's \`session.get\`: no \`time.idle\` means still RUNNING, whatever an old envelope in the transcript says. One that answered while nobody was listening is not lost — it stops being a table row (only children that can still be running are listed) and is counted as \`另有 N 条已结算的历史子代理\`; name it with \`ids\` to get that row and its body back.
1443
+ - 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.
1089
1444
  - 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).`,
1090
1445
  args: {
1091
1446
  ids: { descriptor: "ids: string[] (optional — only these child sessions)" },
@@ -1135,9 +1490,43 @@ export function buildDispatchTools(deps) {
1135
1490
  if (settling.size)
1136
1491
  await Promise.allSettled([...settling]);
1137
1492
  const directory = typeof c.directory === "string" && c.directory ? c.directory : undefined;
1493
+ // Read the CALLER's own transcript ONCE per call: it is the floor recovery source
1494
+ // when the host has no `session.children`, and the only place a background shell is
1495
+ // ever announced. In-process on v2 (`ctx.session.context`), one hop on v1.
1496
+ const parentHistory = await readParentMessages(parent, directory);
1497
+ // Shells are REPORTED, never claimed: they are the host's processes, and this tool
1498
+ // only exists to keep the lead from re-dispatching what is already running.
1499
+ const shellSection = parentHistory.ok
1500
+ ? renderBackgroundShells(backgroundShellsFromParentHistory(parentHistory.data))
1501
+ : null;
1138
1502
  const idList = parseIdList(args.ids);
1139
1503
  const idFilter = idList && idList.length ? new Set(idList) : null;
1140
- const owned = () => [...children.values()].filter((r) => (!parent || r.parentSessionID === parent) && (!idFilter || idFilter.has(r.sessionID)));
1504
+ const owned = () => [...children.values()].filter((r) => (!parent || r.parentSessionID === parent) &&
1505
+ (!idFilter || idFilter.has(r.sessionID)) &&
1506
+ // A settled HISTORY row is a count, not a table row — the table answers "what can
1507
+ // still be running". Naming it with `ids` is the documented way to re-read it,
1508
+ // so an explicit filter always includes it.
1509
+ (!r.historySettled || Boolean(idFilter)));
1510
+ /** Settled history rows this session knows about, minus the ones the lead NAMED (those
1511
+ * are rendered as rows instead). Reported as ONE line, never as N rows: 26 finished
1512
+ * history children used to bury the single live one. */
1513
+ const historySettledCount = () => {
1514
+ let n = 0;
1515
+ for (const r of children.values()) {
1516
+ if (!r.historySettled)
1517
+ continue;
1518
+ if (parent && r.parentSessionID !== parent)
1519
+ continue;
1520
+ if (idFilter?.has(r.sessionID))
1521
+ continue;
1522
+ n++;
1523
+ }
1524
+ return n;
1525
+ };
1526
+ const historyLine = () => {
1527
+ const n = historySettledCount();
1528
+ return n ? `另有 ${n} 条已结算的历史子代理(正文可用 ids 取回)。` : "";
1529
+ };
1141
1530
  let mine = owned();
1142
1531
  // Whether we got to LOOK at the host's tree, and what it said — the empty
1143
1532
  // answer must not claim "there is nothing to adopt" on a host we cannot
@@ -1153,6 +1542,24 @@ export function buildDispatchTools(deps) {
1153
1542
  adoption = await adoptFromHost(parent, directory, idFilter);
1154
1543
  if (adoption.adopted)
1155
1544
  mine = owned();
1545
+ // Second recovery source, for the host that has no `session.children` at all
1546
+ // (measured 2.1.4): the parent transcript still names its children. Only asked
1547
+ // when the tree walk did not settle the question — never instead of it.
1548
+ if (mine.length < (idFilter ? idFilter.size : 1)) {
1549
+ const hist = parentHistory.ok
1550
+ ? await adoptFromParentHistory(parent, parentHistory.data, directory, idFilter)
1551
+ : { adopted: 0, looked: false, candidates: 0, skipped: 0, settled: 0, running: 0, why: parentHistory.why };
1552
+ if (hist.adopted)
1553
+ mine = owned();
1554
+ // The `why` has to keep the three states apart: a children API that looked, no
1555
+ // children API but the history claimed something, and neither source yielding a
1556
+ // child. Fusing them is the overstatement this path was built to stop.
1557
+ adoption = {
1558
+ adopted: adoption.adopted + hist.adopted,
1559
+ looked: adoption.looked,
1560
+ why: [adoption.why, hist.why].filter(Boolean).join(";"),
1561
+ };
1562
+ }
1156
1563
  // Plan B: a NAMED id may be the host's own `task` child (no ·tm
1157
1564
  // marker, so the tree walk above never claims it). Collecting it is
1158
1565
  // how the lead reads back a result we replaced with a pointer — the
@@ -1216,7 +1623,7 @@ export function buildDispatchTools(deps) {
1216
1623
  }
1217
1624
  const row = children.get(target);
1218
1625
  const rowLine = row
1219
- ? renderChildLine(row, (row.finishedAt ?? now()) - row.startedAt)
1626
+ ? renderChildLine(row, elapsedOf(row, now()))
1220
1627
  : `${target} · ${gate.agent} ·(本进程没有它的登记行,归属由宿主确认)`;
1221
1628
  if (steerText) {
1222
1629
  // A steer into a session that is not running is a WAKE, not an interjection:
@@ -1409,7 +1816,15 @@ export function buildDispatchTools(deps) {
1409
1816
  // the transcript, and the honest reason is that a synchronous host
1410
1817
  // task never enters this registry at all.
1411
1818
  `如果你用的是同步 \`task\`:宿主自己就把结果收回来了,它从不登记在这里,这不是派发失败。` +
1412
- `只有后台 \`task {background:true}\`(或显式带 ids)需要 tm_join 来取。`);
1819
+ `只有后台 \`task {background:true}\`(或显式带 ids)需要 tm_join 来取。` +
1820
+ // A round whose leftovers are ALL settled is still not empty: they are history,
1821
+ // and the lead must know they exist (and how to read them) without the whole
1822
+ // table being dumped again.
1823
+ (historyLine() ? `\n\n${historyLine()}` : "") +
1824
+ // A round with no collectable CHILD is still not an empty round when the host
1825
+ // has shell commands in flight: the user can see them and the lead cannot, so
1826
+ // silence here is what produced a duplicate dispatch.
1827
+ (shellSection ? `\n\n${shellSection}` : ""));
1413
1828
  }
1414
1829
  await settleAdopted(mine, directory);
1415
1830
  const waitMs = Math.max(0, Math.min(maxWaitMs, Number(args.waitMs) || 0));
@@ -1530,7 +1945,10 @@ export function buildDispatchTools(deps) {
1530
1945
  const header = [
1531
1946
  `派发汇总:${sums.idle} 完成 / ${sums.running} 运行中 / ${sums.error} 失败${settled ? "(全部已结算)" : `(未等到全部结算 · 本次已等 ${Math.round((now() - waitStart) / 1000)}s)`}`,
1532
1947
  ...(settleLine ? [settleLine] : []),
1533
- ...mine.map((r) => renderChildLine(r, (r.finishedAt ?? now()) - r.startedAt)),
1948
+ // Just under the summary: the settled HISTORY rows, as one count. Their bodies are
1949
+ // still reachable with `ids`, which is exactly what the line promises.
1950
+ ...(historyLine() ? [historyLine()] : []),
1951
+ ...mine.map((r) => renderChildLine(r, elapsedOf(r, now()))),
1534
1952
  ];
1535
1953
  // The `正文:` line belongs directly under the child rows it describes, but the
1536
1954
  // body's completeness is only knowable once the bodies have been read — remember
@@ -1691,7 +2109,7 @@ export function buildDispatchTools(deps) {
1691
2109
  // when bodies were actually read (`includeText:false` has no body to judge).
1692
2110
  if (bodyStatus.length)
1693
2111
  header.splice(childRowsEnd, 0, ...bodyStatus);
1694
- const body = [...header, ...(blocks.length ? ["", blocks.join("\n")] : [])].join("\n");
2112
+ const body = [...header, ...(blocks.length ? ["", blocks.join("\n")] : []), ...(shellSection ? ["", shellSection] : [])].join("\n");
1695
2113
  const stepId = deps.pipelines.nextStepId();
1696
2114
  return toToolResult(await deps.pipelines.govern(stepId, tool, body, {
1697
2115
  contentType: detectContentType(body),
@@ -1734,7 +2152,10 @@ export function buildDispatchTools(deps) {
1734
2152
  child: child.sessionID,
1735
2153
  agent: child.agent,
1736
2154
  ...(st.outcome ? { outcome: st.outcome } : {}),
1737
- ms: st.idleAt - child.startedAt,
2155
+ ...(() => {
2156
+ const ms = elapsedOf(child, st.idleAt);
2157
+ return ms === null ? {} : { ms };
2158
+ })(),
1738
2159
  });
1739
2160
  continue;
1740
2161
  }
@@ -1761,7 +2182,8 @@ export function buildDispatchTools(deps) {
1761
2182
  child.state = "idle";
1762
2183
  child.finishedAt = now();
1763
2184
  child.settleSource = "parent-idle";
1764
- log({ step_id: "events", event: "idle_presumed", child: child.sessionID, agent: child.agent, ms: child.finishedAt - child.startedAt });
2185
+ const ms = elapsedOf(child);
2186
+ log({ step_id: "events", event: "idle_presumed", child: child.sessionID, agent: child.agent, ...(ms === null ? {} : { ms }) });
1765
2187
  }
1766
2188
  }
1767
2189
  const observeEvent = (event) => {
@@ -1811,7 +2233,8 @@ export function buildDispatchTools(deps) {
1811
2233
  rec.state = "idle";
1812
2234
  rec.finishedAt = now();
1813
2235
  rec.settleSource = "event";
1814
- log({ step_id: "events", event: "idle", child: sid, agent: rec.agent, ms: (rec.finishedAt ?? 0) - rec.startedAt });
2236
+ const ms = elapsedOf(rec);
2237
+ log({ step_id: "events", event: "idle", child: sid, agent: rec.agent, ...(ms === null ? {} : { ms }) });
1815
2238
  }
1816
2239
  return;
1817
2240
  }
@@ -1870,7 +2293,8 @@ export function buildDispatchTools(deps) {
1870
2293
  rec.state = "idle";
1871
2294
  }
1872
2295
  rec.settleSource = "injection";
1873
- log({ step_id: "events", event: "idle_injection", child: rec.sessionID, agent: rec.agent, ms: rec.finishedAt - rec.startedAt });
2296
+ const ms = elapsedOf(rec);
2297
+ log({ step_id: "events", event: "idle_injection", child: rec.sessionID, agent: rec.agent, ...(ms === null ? {} : { ms }) });
1874
2298
  return true;
1875
2299
  }
1876
2300
  return {