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

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.
@@ -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";
@@ -225,6 +241,79 @@ export function lastAssistantMessage(messages) {
225
241
  export function lastAssistantText(messages) {
226
242
  return lastAssistantMessage(messages).text;
227
243
  }
244
+ /** Every non-empty text body a transcript carries, in message order.
245
+ * Deliberately NOT filtered by role: the host's completion envelope arrives as an
246
+ * ordinary (user-role) message, and dropping it here would make the fallback below
247
+ * structurally dead. */
248
+ function transcriptTexts(messages) {
249
+ const list = Array.isArray(messages) ? messages : [];
250
+ const out = [];
251
+ for (const entry of list) {
252
+ const e = entry;
253
+ if (Array.isArray(e?.parts)) {
254
+ const t = e.parts
255
+ .filter((p) => {
256
+ const pp = p;
257
+ return pp && pp.type === "text" && typeof pp.text === "string";
258
+ })
259
+ .map((p) => String(p.text))
260
+ .join("\n");
261
+ if (t.trim())
262
+ out.push(t);
263
+ continue;
264
+ }
265
+ if (e?.type === "text" && typeof e.text === "string" && e.text.trim())
266
+ out.push(e.text);
267
+ }
268
+ return out;
269
+ }
270
+ /** The body of the LAST `<subagent sessionID="<sid>" …>…</subagent>` envelope in one
271
+ * text, or null. The id must match EXACTLY — an envelope naming another child is
272
+ * never adopted, because the lead naming a session must not receive someone else's
273
+ * report (the same rule `parentageOf` applies to steering). */
274
+ function lastEnvelopeFor(text, sessionID) {
275
+ let from = 0;
276
+ let found = null;
277
+ for (;;) {
278
+ const open = text.indexOf("<subagent", from);
279
+ if (open < 0)
280
+ break;
281
+ const gt = text.indexOf(">", open);
282
+ if (gt < 0)
283
+ break;
284
+ const tag = text.slice(open, gt + 1);
285
+ const close = text.indexOf("</subagent>", gt + 1);
286
+ if (close < 0)
287
+ break;
288
+ const id = /\bsessionID\s*=\s*(?:"([^"]*)"|'([^']*)')/.exec(tag);
289
+ if (id && (id[1] ?? id[2] ?? "").trim() === sessionID) {
290
+ const body = text.slice(gt + 1, close).trim();
291
+ if (body)
292
+ found = body;
293
+ }
294
+ from = close + "</subagent>".length;
295
+ }
296
+ return found;
297
+ }
298
+ /** The body the host injected into the PARENT transcript for this child, if any.
299
+ * The v2 plugin can read a child's own context, but a host sub-agent's report also
300
+ * arrives in the parent as a completion envelope — and on a host whose child context
301
+ * answers with no assistant text (the measured `child_body_missing`), that envelope
302
+ * is the only readable copy. Null when nothing matches, so the caller keeps its
303
+ * exact `child_body_missing` wording rather than inventing a third outcome. */
304
+ export function hostEnvelopeBody(messages, sessionID) {
305
+ const sid = String(sessionID ?? "").trim();
306
+ if (!sid)
307
+ return null;
308
+ const texts = transcriptTexts(messages);
309
+ // newest-first: a child re-run after a wake may have been injected more than once
310
+ for (let i = texts.length - 1; i >= 0; i--) {
311
+ const hit = lastEnvelopeFor(texts[i], sid);
312
+ if (hit)
313
+ return hit;
314
+ }
315
+ return null;
316
+ }
228
317
  /**
229
318
  * Turn whatever the host puts in an error slot into ONE readable line.
230
319
  *
@@ -267,6 +356,15 @@ export function describeHostError(err, max = 220) {
267
356
  .join(" ");
268
357
  return shorten(flat || "session.error", max);
269
358
  }
359
+ /**
360
+ * The line a PRESUMED settle must print when the body it collected carries no
361
+ * `time.completed`. Both halves of that condition matter: the settle was an inference
362
+ * (the parent went idle, no event named this child) AND the text has no completion marker,
363
+ * so it may be what the child had written before its last tool call. A live lead read
364
+ * exactly that fragment as the delivery, concluded "修复没落地", and re-dispatched work
365
+ * that had already landed — the fix is to say it, not to withhold the text.
366
+ */
367
+ export const INCOMPLETE_PRESUMED_BODY_WARNING = "⚠ 推定结算且正文未带完成时间:这可能是最后一次工具调用前的半截话,不要据此判定交付;稍后再次 tm_join 可复核(或让子代理续跑)。";
270
368
  /** One child's status line — the lead reads these, so keep them terse. */
271
369
  export function renderChildLine(c, elapsedMs) {
272
370
  const secs = Math.round(elapsedMs / 1000);
@@ -275,7 +373,7 @@ export function renderChildLine(c, elapsedMs) {
275
373
  // needs to see that the child was never running, not a bare 已完成.
276
374
  const stop = c.state === "idle" && c.cancelNote ? ` ·${c.cancelNote}` : "";
277
375
  return `${c.sessionID} · ${c.agent} · "${c.label}" · ${tag}${stop}${c.adopted ? " ·(本进程重启后由宿主会话树接管)" : ""}${c.via === "host-injection" && c.settleSource === "parent-idle"
278
- ? " ·(宿主子代理:随父会话空闲推定已结算,宿主没给过这个子会话的 idle 事件)"
376
+ ? " ·(宿主子代理:随父会话空闲推定已结算;宿主未给该子会话 idle 事件)"
279
377
  : c.via === "host-injection"
280
378
  ? " ·(宿主 subagent 派发)"
281
379
  : ""}`;
@@ -632,15 +730,36 @@ export function buildDispatchTools(deps) {
632
730
  }
633
731
  }
634
732
  }
635
- function fetchReply(sid, directory) {
733
+ /** Read the completion envelope the host injected into the PARENT transcript for
734
+ * this child. Absent seam / failed call / no matching envelope all return null —
735
+ * the fallback may not turn "I could not look" into a body. */
736
+ async function envelopeFromParent(parent, sid, directory) {
737
+ if (!parent || !sid || parent === sid)
738
+ return null;
739
+ if (typeof api?.messages !== "function")
740
+ return null;
741
+ try {
742
+ const raw = await api.messages({ path: { id: parent }, ...(directory ? { query: { directory } } : {}) });
743
+ const un = unwrapClientResult(raw);
744
+ if (!un.ok)
745
+ return null;
746
+ return hostEnvelopeBody(un.data, sid);
747
+ }
748
+ catch {
749
+ return null;
750
+ }
751
+ }
752
+ function fetchReply(sid, directory, parentSessionID) {
636
753
  return (async () => {
637
754
  if (typeof api?.messages !== "function") {
638
- return { text: "", error: "这个宿主客户端没有给出读取子会话正文的缝(v2 上它是 ctx.session.context 的兼容层)" };
755
+ return { text: "", error: "这个宿主客户端没有给出读取子会话正文的缝(v2 上它是 ctx.session.context 的兼容层)", bodyComplete: false };
639
756
  }
640
757
  try {
641
758
  const raw = await api.messages({ path: { id: sid }, ...(directory ? { query: { directory } } : {}) });
642
759
  const un = unwrapClientResult(raw);
643
- const got = un.ok ? lastAssistantMessage(un.data) : { text: "", error: un.message };
760
+ const got = un.ok
761
+ ? lastAssistantMessage(un.data)
762
+ : { text: "", error: un.message };
644
763
  // The seam that ANSWERED, named by the seam itself. On v1 that is the host's
645
764
  // `session.messages`; on v2 the same call is served by `ctx.session.context`, and
646
765
  // printing "session.messages" there would credit an endpoint this host does not
@@ -651,12 +770,28 @@ export function buildDispatchTools(deps) {
651
770
  // and the last item carried no text part" is a different fact from "no seam".
652
771
  const reported = raw?.shape;
653
772
  const shape = typeof reported === "string" && reported ? reported : undefined;
654
- return got.text
655
- ? { ...got, via, ...(shape ? { shape } : {}) }
656
- : { text: "", error: got.error, via, ...(shape ? { shape } : {}) };
773
+ // `bodyComplete` is derived from the SAME parse that produced the text, so a caller
774
+ // can never read a body and a completeness verdict from two different messages.
775
+ const completeness = {
776
+ bodyComplete: typeof got.completedAt === "number",
777
+ ...(typeof got.completedAt === "number" ? { bodyCompletedAt: got.completedAt } : {}),
778
+ };
779
+ return got.text.trim()
780
+ ? { ...got, via, ...(shape ? { shape } : {}), ...completeness }
781
+ : await (async () => {
782
+ // C: the child's own context answered with no assistant text. Before
783
+ // reporting `child_body_missing`, look for the host's completion envelope
784
+ // in the parent — for a host sub-agent that IS the delivered report, and a
785
+ // lead that cannot read it has to re-dispatch work that already landed.
786
+ const envelope = await envelopeFromParent(parentSessionID ?? "", sid, directory);
787
+ if (envelope) {
788
+ return { text: envelope, via: "host-envelope", shape: "parent-envelope", bodyComplete: true };
789
+ }
790
+ return { text: "", error: got.error, via, ...(shape ? { shape } : {}), ...completeness };
791
+ })();
657
792
  }
658
793
  catch (err) {
659
- return { text: "", error: describeHostError(err?.message ?? err, 140) };
794
+ return { text: "", error: describeHostError(err?.message ?? err, 140), bodyComplete: false };
660
795
  }
661
796
  })();
662
797
  }
@@ -817,10 +952,10 @@ export function buildDispatchTools(deps) {
817
952
  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.
818
953
  - 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.
819
954
  - { 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 }).
820
- - { 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.
821
- - 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.
955
+ - { 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.
822
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.
823
- - 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.`,
958
+ - 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).`,
824
959
  args: {
825
960
  ids: { descriptor: "ids: string[] (optional — only these child sessions)" },
826
961
  waitMs: { descriptor: `waitMs: number (optional 0..${maxWaitMs} — bounded wait; a SECOND consecutive wait is cut to 10s, because waiting is not parallel work)` },
@@ -840,6 +975,9 @@ export function buildDispatchTools(deps) {
840
975
  delivery: {
841
976
  descriptor: "delivery: steer|queue (optional, default steer — applies to steer only. Any other value is refused rather than silently defaulted.)",
842
977
  },
978
+ wake: {
979
+ 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\".)",
980
+ },
843
981
  unread: {
844
982
  descriptor: "unread: true (optional — list the undelivered inbox items of the child named by ids: ids and shape only, never the body)",
845
983
  },
@@ -946,6 +1084,45 @@ export function buildDispatchTools(deps) {
946
1084
  ? renderChildLine(row, (row.finishedAt ?? now()) - row.startedAt)
947
1085
  : `${target} · ${gate.agent} ·(本进程没有它的登记行,归属由宿主确认)`;
948
1086
  if (steerText) {
1087
+ // A steer into a session that is not running is a WAKE, not an interjection:
1088
+ // the host's own doc says "Steering wakes session execution", so it starts a
1089
+ // second run that overlaps whatever the lead dispatched meanwhile. Measured
1090
+ // cost: a settled child was woken this way and ran concurrently with a fresh
1091
+ // worker, two processes on one device. Default = refuse; `wake:true` is the
1092
+ // explicit opt-in, and the value is coerced the way the rest of this tool's
1093
+ // boolean args are (`cancel`/`unread` above): boolean or "true"/"false".
1094
+ const wakeRaw = args.wake;
1095
+ let wakeRequested = false;
1096
+ if (wakeRaw !== undefined && wakeRaw !== null) {
1097
+ if (wakeRaw === true || wakeRaw === false)
1098
+ wakeRequested = wakeRaw;
1099
+ else if (typeof wakeRaw === "string" && /^(true|false)$/i.test(wakeRaw.trim())) {
1100
+ wakeRequested = /^true$/i.test(wakeRaw.trim());
1101
+ }
1102
+ else {
1103
+ return toToolResult(tmError(tool, "args", `wake 只接受 true/false(布尔或 "true"/"false" 字符串),收到 ${shorten(String(wakeRaw), 24)},未发送。`));
1104
+ }
1105
+ }
1106
+ // The registry can be stale in the dangerous direction too (an event we
1107
+ // missed would leave a settled child looking running); ask the host before
1108
+ // trusting "running" when it offers a status map.
1109
+ if (row && row.state === "running" && typeof api?.status === "function") {
1110
+ await refreshFromStatus(directory);
1111
+ }
1112
+ const rowState = row?.state;
1113
+ if (rowState !== "running" && !wakeRequested) {
1114
+ log({
1115
+ step_id: "join",
1116
+ event: "steer_refused",
1117
+ child: target,
1118
+ agent: gate.agent,
1119
+ reason: "not-running",
1120
+ state: rowState ?? "unregistered",
1121
+ // the LENGTH, never the text — R6 binds the audit trail as much as the reply
1122
+ chars: steerText.length,
1123
+ });
1124
+ return toToolResult(`没有发送:该子代理已结算(或状态未知);插话会唤醒它并与新派发并发——要停止用 cancel:true,要复活请显式 wake:true。\n目标:${rowLine}`);
1125
+ }
949
1126
  let outcome = "no-seam";
950
1127
  let inboxID;
951
1128
  let note;
@@ -971,6 +1148,7 @@ export function buildDispatchTools(deps) {
971
1148
  delivery,
972
1149
  outcome,
973
1150
  ok: outcome === "steered",
1151
+ wake: wakeRequested,
974
1152
  ...(inboxID ? { inboxID } : {}),
975
1153
  ...(seam ? { seam } : {}),
976
1154
  // the LENGTH, never the text — R6 binds the audit trail as much as the reply
@@ -979,8 +1157,8 @@ export function buildDispatchTools(deps) {
979
1157
  const tally = {};
980
1158
  tally[outcome] = 1;
981
1159
  log({ step_id: "join", event: "steer_summary", count: 1, outcomes: steerOutcomeParts(tally).join(" / ") });
982
- const wake = row && row.state !== "running"
983
- ? `\n它现在不是运行中(${row.state === "error" ? "失败" : "已结算"});按文档 steer 会唤醒它的执行,但唤醒之后它做什么,要等它结算才看得到。`
1160
+ const wake = outcome === "steered" && wakeRequested && row && row.state !== "running"
1161
+ ? `\n已按 wake:true 唤醒:它当时不是运行中(${row.state === "error" ? "失败" : "已结算"}),宿主把这条插话当成新的执行输入——它会和新派发的 worker 并发,留意这一点。`
984
1162
  : "";
985
1163
  return toToolResult(`插话目标:${rowLine}\n插话结果:${steerVerdictLine(outcome, { inboxID, note, seam })}\n插话计数:${steerOutcomeParts(tally).join(" · ")}${wake}`);
986
1164
  }
@@ -1102,41 +1280,84 @@ export function buildDispatchTools(deps) {
1102
1280
  let cancelTally = null;
1103
1281
  if (args.cancel === true || args.cancel === "true") {
1104
1282
  cancelTally = {};
1105
- for (const r of stillRunning) {
1283
+ // B: an EXPLICITLY named id is always asked of the host. The registry is this
1284
+ // process's memory, not the host's truth — the live failure was a row settled
1285
+ // HERE while the host had just been made to run it by a steer, and the old loop
1286
+ // over `stillRunning` answered "没有运行中的子代理可停" without ever calling the
1287
+ // host. With no ids the shipped behaviour stays ("only those we still believe
1288
+ // are running"), because "cancel everything" must not interrogate every settled
1289
+ // child of the session.
1290
+ const cancelTargets = idFilter ? mine.filter((r) => idFilter.has(r.sessionID)) : stillRunning;
1291
+ for (const r of cancelTargets) {
1292
+ const wasRunning = r.state === "running";
1106
1293
  let outcome;
1107
1294
  let note;
1108
1295
  if (typeof api?.abort === "function") {
1109
- // v1's path, byte-exact and FIRST: the personality is frozen and a shipped
1110
- // test pins "aborted on request".
1296
+ // v1's path, byte-exact and FIRST for a RUNNING row: the personality is
1297
+ // frozen and a shipped test pins "aborted on request".
1111
1298
  const un = unwrapClientResult(await api.abort({ path: { id: r.sessionID } }));
1112
- outcome = un.ok ? "stopped" : "threw";
1113
- r.error = un.ok ? "aborted on request" : `abort failed: ${shorten(un.message ?? "", 60)}`;
1114
- r.state = "error";
1299
+ if (wasRunning) {
1300
+ outcome = un.ok ? "stopped" : "threw";
1301
+ r.error = un.ok ? "aborted on request" : `abort failed: ${shorten(un.message ?? "", 60)}`;
1302
+ r.state = "error";
1303
+ }
1304
+ else {
1305
+ // v1's abort returns no `interrupted` boolean, so an already-settled row
1306
+ // cannot honestly be called stopped OR failed from this answer.
1307
+ outcome = un.ok ? "unknown" : "threw";
1308
+ note = un.ok ? "v1 的 abort 不返回 interrupted 布尔" : un.message;
1309
+ r.cancelNote = cancelVerdictLine(outcome, note);
1310
+ }
1115
1311
  }
1116
1312
  else if (typeof api?.interrupt === "function") {
1117
1313
  const got = cancelOutcomeOf(await api.interrupt({ path: { id: r.sessionID } }));
1118
1314
  outcome = got.outcome;
1119
1315
  note = got.note;
1120
- // `idle` is the host saying there was no active execution: the registry row
1121
- // was stale, not a failed stop. Anything else closes as undelivered.
1122
- r.state = outcome === "idle" ? "idle" : "error";
1123
1316
  r.cancelNote = cancelVerdictLine(outcome, note);
1124
- if (r.state === "error")
1317
+ if (wasRunning) {
1318
+ // `idle` is the host saying there was no active execution: the registry row
1319
+ // was stale, not a failed stop. Anything else closes as undelivered.
1320
+ r.state = outcome === "idle" ? "idle" : "error";
1321
+ if (r.state === "error")
1322
+ r.error = r.cancelNote;
1323
+ }
1324
+ else if (outcome === "stopped") {
1325
+ // A row we believed settled was in fact executing (a woken session is the
1326
+ // measured case): the host just stopped it, and the row must say so.
1327
+ r.state = "error";
1125
1328
  r.error = r.cancelNote;
1329
+ }
1330
+ // idle / unknown / no-seam / threw on an already-settled row leave its settle
1331
+ // verdict alone — the stop attempt is extra evidence, not a re-settlement.
1126
1332
  }
1127
1333
  else {
1128
1334
  outcome = "no-seam";
1129
- r.error = "宿主无 abort 接口,未取消";
1130
- r.state = "error";
1335
+ if (wasRunning) {
1336
+ r.error = "宿主无 abort 接口,未取消";
1337
+ r.state = "error";
1338
+ }
1339
+ else {
1340
+ r.cancelNote = cancelVerdictLine("no-seam");
1341
+ }
1131
1342
  }
1132
- r.finishedAt = now();
1343
+ if (wasRunning || r.finishedAt === undefined)
1344
+ r.finishedAt = now();
1133
1345
  cancelTally[outcome] = (cancelTally[outcome] ?? 0) + 1;
1134
- log({ step_id: "join", event: "cancel", child: r.sessionID, agent: r.agent, outcome, ok: outcome === "stopped" });
1346
+ log({
1347
+ step_id: "join",
1348
+ event: "cancel",
1349
+ child: r.sessionID,
1350
+ agent: r.agent,
1351
+ outcome,
1352
+ explicit: Boolean(idFilter),
1353
+ was_running: wasRunning,
1354
+ ok: outcome === "stopped",
1355
+ });
1135
1356
  }
1136
1357
  log({
1137
1358
  step_id: "join",
1138
1359
  event: "cancel_summary",
1139
- count: stillRunning.length,
1360
+ count: cancelTargets.length,
1140
1361
  outcomes: cancelOutcomeParts(cancelTally).join(" / "),
1141
1362
  });
1142
1363
  }
@@ -1145,9 +1366,17 @@ export function buildDispatchTools(deps) {
1145
1366
  `派发汇总:${sums.idle} 完成 / ${sums.running} 运行中 / ${sums.error} 失败${settled ? "(全部已结算)" : `(未等到全部结算 · 本次已等 ${Math.round((now() - waitStart) / 1000)}s)`}`,
1146
1367
  ...mine.map((r) => renderChildLine(r, (r.finishedAt ?? now()) - r.startedAt)),
1147
1368
  ];
1369
+ // The `正文:` line belongs directly under the child rows it describes, but the
1370
+ // body's completeness is only knowable once the bodies have been read — remember
1371
+ // where the rows end and splice it back in there.
1372
+ const childRowsEnd = header.length;
1148
1373
  if (cancelTally) {
1149
1374
  const parts = cancelOutcomeParts(cancelTally);
1150
- header.push(`取消尝试:${parts.join(" · ") || "没有运行中的子代理可停"} —— ` +
1375
+ header.push(`取消尝试:${parts.join(" · ") ||
1376
+ // Never leave "没有运行中的子代理可停" as the ONLY answer: with no ids the
1377
+ // default restricted the attempt to rows we believed running, and the lead
1378
+ // must know that a settled-looking row was therefore not asked about.
1379
+ "没有运行中的子代理可停(未显式给 ids 时只对登记为运行中的子代理发起取消;若你怀疑某个已结算的子会话其实在跑,用 ids 点名——我会先去问宿主)"} —— ` +
1151
1380
  `只有「已由宿主中断」是宿主确认过的停止;「空闲未中断」说明它当时没在活动执行里` +
1152
1381
  `(登记行过时了,不是失败);「未确认」说明中断调用成功但宿主没回 interrupted 布尔。` +
1153
1382
  `这三种结论不要混成一句"已取消"转述给用户。`);
@@ -1241,11 +1470,27 @@ export function buildDispatchTools(deps) {
1241
1470
  const wantText = !(args.includeText === false || args.includeText === "false");
1242
1471
  const collectible = mine.filter((r) => r.state !== "running");
1243
1472
  const blocks = [];
1473
+ /** One token per collected child for the `正文:` line — each token is a MEASUREMENT
1474
+ * (`time.completed` was on the message, or it was not), never a guess. */
1475
+ const bodyStatus = [];
1244
1476
  if (wantText) {
1245
1477
  for (const r of collectible) {
1246
- const reply = await fetchReply(r.sessionID, directory);
1478
+ const reply = await fetchReply(r.sessionID, directory, parent);
1247
1479
  const text = reply.text;
1248
- const src = text && reply.via ? ` ·正文来源=${reply.via}` : "";
1480
+ r.bodyComplete = reply.bodyComplete === true;
1481
+ r.bodyCompletedAt = reply.bodyCompletedAt;
1482
+ const hasBody = Boolean(text.trim());
1483
+ // The combination that produced a wrong verdict live: a PRESUMED settle (no
1484
+ // event ever named this child) plus a body with no completion marker. Only
1485
+ // then is "半截" a fair reading — an event/injection-settled child on a seam
1486
+ // that simply does not carry `time` (2.0.20's ctx) must not be labelled a
1487
+ // fragment, which would be the same overstatement facing the other way.
1488
+ const half = hasBody && r.bodyComplete === false && r.settleSource === "parent-idle";
1489
+ bodyStatus.push(`正文:${!hasBody ? "缺失" : r.bodyComplete ? "完整" : half ? "半截(推定)" : "未带完成时间"} · ${r.agent} "${r.label}"`);
1490
+ // C labels the fallback in the lead's words: the body above came from the
1491
+ // host's own completion injection, not from the child's session context.
1492
+ const viaLabel = reply.via === "host-envelope" ? "宿主注入信封" : reply.via;
1493
+ const src = text && viaLabel ? ` ·正文来源=${viaLabel}` : "";
1249
1494
  // Which seam answered, per child — an id, a seam name and the SHAPE it saw, never
1250
1495
  // a body (R6 binds a diagnostic too). This is the line that makes "正文来源=…"
1251
1496
  // checkable later, and the only place the three outcomes stay distinguishable:
@@ -1256,12 +1501,19 @@ export function buildDispatchTools(deps) {
1256
1501
  event: text ? "child_body" : "child_body_missing",
1257
1502
  child: r.sessionID,
1258
1503
  via: reply.via ?? "none",
1504
+ // Completeness rides only the row that claims a BODY was read; a missing body
1505
+ // has no completion to report (`child_body_missing` stays byte-exact).
1506
+ ...(text ? { complete: r.bodyComplete === true } : {}),
1259
1507
  // Only when the SEAM reported one: v1's SDK client gives no shape, and the v1
1260
1508
  // personality is frozen — its rows stay byte-exact rather than gaining a field
1261
1509
  // nobody asked for. The v2 bridge reports a shape on every path.
1262
1510
  ...(text || !reply.shape ? {} : { shape: reply.shape }),
1263
1511
  });
1264
- blocks.push(`--- ${r.agent} "${r.label}" (${r.sessionID})${src} ---\n${text.trim() ||
1512
+ // Said BEFORE the body and INSIDE this child's own block: the body is what
1513
+ // gets misread as a delivery, and a warning floating between two blocks would
1514
+ // not name the child it is about.
1515
+ const warn = half ? `${INCOMPLETE_PRESUMED_BODY_WARNING}\n` : "";
1516
+ blocks.push(`--- ${r.agent} "${r.label}" (${r.sessionID})${src} ---\n${warn}${text.trim() ||
1265
1517
  (r.via === "host-injection"
1266
1518
  ? `(这个子会话由宿主的 subagent 工具派发,而这一次我没有读到它的正文${reply.error ? `:${reply.error}` : ":这个宿主没给可读正文的缝"}。完成时宿主仍会把 \`<subagent sessionID=\"${r.sessionID}\" …>\` 注入本会话,我已经把它登记在案、状态如上。` +
1267
1519
  `要全文就在那条注入到达后读它,或让子代理把交付写进黑板;不要为了拿正文反复 join。)`
@@ -1269,6 +1521,10 @@ export function buildDispatchTools(deps) {
1269
1521
  `——子会话不是文件,tm_read 读不到它:在宿主的会话面板里看这条子会话,或改用内置 task 工具重做这一步。`)}`);
1270
1522
  }
1271
1523
  }
1524
+ // One `正文:` line per child, directly under the child rows it describes, and only
1525
+ // when bodies were actually read (`includeText:false` has no body to judge).
1526
+ if (bodyStatus.length)
1527
+ header.splice(childRowsEnd, 0, ...bodyStatus);
1272
1528
  const body = [...header, ...(blocks.length ? ["", blocks.join("\n")] : [])].join("\n");
1273
1529
  const stepId = deps.pipelines.nextStepId();
1274
1530
  return toToolResult(await deps.pipelines.govern(stepId, tool, body, {