@te-river/opencode-team-mode 1.6.0 → 1.6.2

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 (150) hide show
  1. package/README.md +80 -15
  2. package/README.zh-CN.md +67 -18
  3. package/dist/host/note.d.ts +7 -0
  4. package/dist/host/note.d.ts.map +1 -0
  5. package/dist/host/note.js +31 -0
  6. package/dist/host/note.js.map +1 -0
  7. package/dist/host/v1.d.ts +14 -0
  8. package/dist/host/v1.d.ts.map +1 -0
  9. package/dist/host/v1.js +340 -0
  10. package/dist/host/v1.js.map +1 -0
  11. package/dist/host/v2-browser-gate.d.ts +45 -0
  12. package/dist/host/v2-browser-gate.d.ts.map +1 -0
  13. package/dist/host/v2-browser-gate.js +319 -0
  14. package/dist/host/v2-browser-gate.js.map +1 -0
  15. package/dist/host/v2-capabilities.d.ts +78 -0
  16. package/dist/host/v2-capabilities.d.ts.map +1 -0
  17. package/dist/host/v2-capabilities.js +173 -0
  18. package/dist/host/v2-capabilities.js.map +1 -0
  19. package/dist/host/v2-client.d.ts +53 -0
  20. package/dist/host/v2-client.d.ts.map +1 -0
  21. package/dist/host/v2-client.js +193 -0
  22. package/dist/host/v2-client.js.map +1 -0
  23. package/dist/host/v2-events.d.ts +56 -0
  24. package/dist/host/v2-events.d.ts.map +1 -0
  25. package/dist/host/v2-events.js +158 -0
  26. package/dist/host/v2-events.js.map +1 -0
  27. package/dist/host/v2-guard.d.ts +101 -0
  28. package/dist/host/v2-guard.d.ts.map +1 -0
  29. package/dist/host/v2-guard.js +199 -0
  30. package/dist/host/v2-guard.js.map +1 -0
  31. package/dist/host/v2-offload.d.ts +148 -0
  32. package/dist/host/v2-offload.d.ts.map +1 -0
  33. package/dist/host/v2-offload.js +288 -0
  34. package/dist/host/v2-offload.js.map +1 -0
  35. package/dist/host/v2-permissions.d.ts +123 -0
  36. package/dist/host/v2-permissions.d.ts.map +1 -0
  37. package/dist/host/v2-permissions.js +190 -0
  38. package/dist/host/v2-permissions.js.map +1 -0
  39. package/dist/host/v2-probe.d.ts +85 -0
  40. package/dist/host/v2-probe.d.ts.map +1 -0
  41. package/dist/host/v2-probe.js +268 -0
  42. package/dist/host/v2-probe.js.map +1 -0
  43. package/dist/host/v2-scope.d.ts +51 -0
  44. package/dist/host/v2-scope.d.ts.map +1 -0
  45. package/dist/host/v2-scope.js +58 -0
  46. package/dist/host/v2-scope.js.map +1 -0
  47. package/dist/host/v2-session-client.d.ts +105 -0
  48. package/dist/host/v2-session-client.d.ts.map +1 -0
  49. package/dist/host/v2-session-client.js +390 -0
  50. package/dist/host/v2-session-client.js.map +1 -0
  51. package/dist/host/v2-session.d.ts +85 -0
  52. package/dist/host/v2-session.d.ts.map +1 -0
  53. package/dist/host/v2-session.js +171 -0
  54. package/dist/host/v2-session.js.map +1 -0
  55. package/dist/host/v2-subagent.d.ts +157 -0
  56. package/dist/host/v2-subagent.d.ts.map +1 -0
  57. package/dist/host/v2-subagent.js +286 -0
  58. package/dist/host/v2-subagent.js.map +1 -0
  59. package/dist/host/v2-tool.d.ts +67 -0
  60. package/dist/host/v2-tool.d.ts.map +1 -0
  61. package/dist/host/v2-tool.js +209 -0
  62. package/dist/host/v2-tool.js.map +1 -0
  63. package/dist/host/v2-transcript.d.ts +73 -0
  64. package/dist/host/v2-transcript.d.ts.map +1 -0
  65. package/dist/host/v2-transcript.js +151 -0
  66. package/dist/host/v2-transcript.js.map +1 -0
  67. package/dist/host/v2-types.d.ts +229 -0
  68. package/dist/host/v2-types.d.ts.map +1 -0
  69. package/dist/host/v2-types.js +17 -0
  70. package/dist/host/v2-types.js.map +1 -0
  71. package/dist/host/v2.d.ts +42 -0
  72. package/dist/host/v2.d.ts.map +1 -0
  73. package/dist/host/v2.js +853 -0
  74. package/dist/host/v2.js.map +1 -0
  75. package/dist/index.d.ts +23 -17
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +29 -366
  78. package/dist/index.js.map +1 -1
  79. package/dist/prompts/lead.d.ts +1 -1
  80. package/dist/prompts/lead.d.ts.map +1 -1
  81. package/dist/prompts/lead.js +456 -426
  82. package/dist/prompts/lead.js.map +1 -1
  83. package/dist/prompts/shared.d.ts +2 -2
  84. package/dist/prompts/shared.d.ts.map +1 -1
  85. package/dist/prompts/shared.js +38 -11
  86. package/dist/prompts/shared.js.map +1 -1
  87. package/dist/prompts/specialists.d.ts +2 -2
  88. package/dist/prompts/specialists.d.ts.map +1 -1
  89. package/dist/prompts/specialists.js +7 -6
  90. package/dist/prompts/specialists.js.map +1 -1
  91. package/dist/task-offload.d.ts +16 -0
  92. package/dist/task-offload.d.ts.map +1 -1
  93. package/dist/task-offload.js +51 -0
  94. package/dist/task-offload.js.map +1 -1
  95. package/dist/tm/browser.d.ts.map +1 -1
  96. package/dist/tm/browser.js +41 -10
  97. package/dist/tm/browser.js.map +1 -1
  98. package/dist/tm/dispatch.d.ts +86 -1
  99. package/dist/tm/dispatch.d.ts.map +1 -1
  100. package/dist/tm/dispatch.js +360 -39
  101. package/dist/tm/dispatch.js.map +1 -1
  102. package/dist/tm/dupe-guard.d.ts +16 -3
  103. package/dist/tm/dupe-guard.d.ts.map +1 -1
  104. package/dist/tm/dupe-guard.js +27 -7
  105. package/dist/tm/dupe-guard.js.map +1 -1
  106. package/dist/tm/index.d.ts +40 -1
  107. package/dist/tm/index.d.ts.map +1 -1
  108. package/dist/tm/index.js +40 -11
  109. package/dist/tm/index.js.map +1 -1
  110. package/dist/tm/ledger.d.ts +114 -0
  111. package/dist/tm/ledger.d.ts.map +1 -0
  112. package/dist/tm/ledger.js +308 -0
  113. package/dist/tm/ledger.js.map +1 -0
  114. package/dist/tm/perm-ask.d.ts +1 -3
  115. package/dist/tm/perm-ask.d.ts.map +1 -1
  116. package/dist/tm/perm-ask.js +10 -1
  117. package/dist/tm/perm-ask.js.map +1 -1
  118. package/dist/tm/preview.d.ts +55 -0
  119. package/dist/tm/preview.d.ts.map +1 -1
  120. package/dist/tm/preview.js +225 -0
  121. package/dist/tm/preview.js.map +1 -1
  122. package/dist/tm/search.d.ts.map +1 -1
  123. package/dist/tm/search.js +40 -10
  124. package/dist/tm/search.js.map +1 -1
  125. package/dist/tm/serp-loop.d.ts +45 -0
  126. package/dist/tm/serp-loop.d.ts.map +1 -0
  127. package/dist/tm/serp-loop.js +80 -0
  128. package/dist/tm/serp-loop.js.map +1 -0
  129. package/dist/tm/stats.d.ts +29 -0
  130. package/dist/tm/stats.d.ts.map +1 -1
  131. package/dist/tm/stats.js +278 -4
  132. package/dist/tm/stats.js.map +1 -1
  133. package/dist/tm/webfetch.d.ts +4 -0
  134. package/dist/tm/webfetch.d.ts.map +1 -1
  135. package/dist/tm/webfetch.js +75 -8
  136. package/dist/tm/webfetch.js.map +1 -1
  137. package/docs/installation-v2.md +445 -0
  138. package/docs/installation.md +12 -0
  139. package/docs/research/agent-data-exchange.md +117 -0
  140. package/docs/research/browser-pane.md +89 -0
  141. package/docs/research/host-http-api.md +132 -0
  142. package/docs/research/host-subagent-injection.md +187 -0
  143. package/docs/research/plugin-loader-contract.md +261 -0
  144. package/docs/research/v2-builtin-tools.md +447 -0
  145. package/docs/v2-port-notes.md +413 -0
  146. package/index.js +7 -0
  147. package/package.json +64 -61
  148. package/scripts/gen-v2-config.mjs +304 -0
  149. package/scripts/lib/config-surgery.cjs +239 -0
  150. package/scripts/lib/fake-ctx.mjs +169 -0
@@ -33,9 +33,29 @@ import { tmError, toToolResult } from "./result.js";
33
33
  import { unwrapClientResult } from "./client-unwrap.js";
34
34
  import { detectContentType } from "./preview.js";
35
35
  import { shorten } from "./config.js";
36
+ import { ledgerGoalLine, openItems } from "./ledger.js";
36
37
  /** The five dispatchable specialists — "team" is deliberately NOT in the
37
38
  * list: a lead spawning a lead is the nesting T3 closed. */
38
39
  export const DISPATCH_TARGETS = ["architect", "implementer", "reviewer", "tester", "researcher"];
40
+ /** A child seen being dispatched by the host's own tool, turned into a registry row.
41
+ * Null when the ids are unusable: a child whose id equals the caller's would make
42
+ * tm_join wait on the session it is running in, and an anonymous child cannot be
43
+ * settled by an event we match on session id. */
44
+ export function hostChildRecord(child, now = () => Date.now()) {
45
+ const sessionID = String(child?.sessionID ?? "").trim();
46
+ const parentSessionID = String(child?.parentSessionID ?? "").trim();
47
+ if (!sessionID || !parentSessionID || sessionID === parentSessionID)
48
+ return null;
49
+ return {
50
+ sessionID,
51
+ agent: String(child.agent ?? "").trim().toLowerCase() || "subagent",
52
+ label: String(child.label ?? "").trim().slice(0, 40) || "宿主子代理",
53
+ parentSessionID,
54
+ startedAt: now(),
55
+ state: "running",
56
+ via: "host-injection",
57
+ };
58
+ }
39
59
  export function openHostTodos(todos) {
40
60
  if (!Array.isArray(todos))
41
61
  return [];
@@ -169,9 +189,17 @@ export function parseDispatchTitle(title, targets) {
169
189
  * bare Part[] — the LAST assistant message is the answer, everything earlier
170
190
  * is that agent's own working transcript.
171
191
  *
192
+ * This function deliberately does NOT learn about the host's message shapes:
193
+ * `ctx.session.context` answers with `{id, role, metadata, content:[…]}` on
194
+ * 2.0.20 and with flat `{id, time, text, type}` on 2.0.18, and BOTH are
195
+ * normalised at that seam (`normaliseContextMessages`, src/host/v2-session-client.ts)
196
+ * before they reach here. Recognising `content` here as well would mean two
197
+ * places guess at a shape nobody measured, which is exactly how A1 shipped: the
198
+ * seam answered, the body was dropped, and the sentence blamed the host.
199
+ *
172
200
  * `completedAt` is what lets a rebuilt registry tell "still running" from
173
201
  * "finished while we were not listening": AssistantMessage.time.completed is
174
- * only set once the message is done.
202
+ * only set once the message is done, and it is copied, never invented.
175
203
  */
176
204
  export function lastAssistantMessage(messages) {
177
205
  const list = Array.isArray(messages) ? messages : [];
@@ -259,7 +287,14 @@ export function describeHostError(err, max = 220) {
259
287
  export function renderChildLine(c, elapsedMs) {
260
288
  const secs = Math.round(elapsedMs / 1000);
261
289
  const tag = c.state === "running" ? `运行中 ${secs}s` : c.state === "error" ? `失败:${shorten(c.error ?? "", 80)}` : `已完成 ${secs}s`;
262
- return `${c.sessionID} · ${c.agent} · "${c.label}" · ${tag}${c.adopted ? " ·(本进程重启后由宿主会话树接管)" : ""}`;
290
+ // #33: a row the host reported IDLE for a stop attempt is not a delivery — the lead
291
+ // needs to see that the child was never running, not a bare 已完成.
292
+ const stop = c.state === "idle" && c.cancelNote ? ` ·${c.cancelNote}` : "";
293
+ return `${c.sessionID} · ${c.agent} · "${c.label}" · ${tag}${stop}${c.adopted ? " ·(本进程重启后由宿主会话树接管)" : ""}${c.via === "host-injection" && c.settleSource === "parent-idle"
294
+ ? " ·(宿主子代理:随父会话空闲推定已结算,宿主没给过这个子会话的 idle 事件)"
295
+ : c.via === "host-injection"
296
+ ? " ·(宿主 subagent 派发)"
297
+ : ""}`;
263
298
  }
264
299
  /** Verdicts tm_join can actually observe (issue #7's "leader keeps control"). */
265
300
  export function summarizeStates(children) {
@@ -311,6 +346,61 @@ export function parseIdList(raw) {
311
346
  }
312
347
  return s.split(",").map((x) => x.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
313
348
  }
349
+ /** Read the stop verdict out of the v2 adapter's `{data:{outcome,message}}`. Anything
350
+ * unrecognisable is `unknown`, never `stopped` — a shape we cannot read is not evidence
351
+ * a child died. */
352
+ export function cancelOutcomeOf(res) {
353
+ const un = unwrapClientResult(res);
354
+ if (!un.ok)
355
+ return { outcome: "threw", note: un.message };
356
+ const data = un.data;
357
+ const o = String(data?.outcome ?? "");
358
+ const known = ["stopped", "idle", "unknown", "no-seam", "threw"];
359
+ if (known.includes(o))
360
+ return { outcome: o, note: typeof data?.message === "string" ? data.message : undefined };
361
+ return { outcome: "unknown", note: o ? `宿主返回了没认出的结果(${shorten(o, 24)})` : "宿主没有返回 interrupted 字段" };
362
+ }
363
+ /** The line the lead reads, per outcome. Terse and falsifiable: each names what was
364
+ * OBSERVED, and the escape hatch for the cases where nothing was observed. */
365
+ export function cancelVerdictLine(outcome, note) {
366
+ const why = note ? `:${shorten(note, 80)}` : "";
367
+ switch (outcome) {
368
+ case "stopped":
369
+ return "已由宿主中断(interrupted=true)";
370
+ case "idle":
371
+ return "宿主回 idle no-op——它当时没有活动执行,所以我没有停掉任何东西";
372
+ case "unknown":
373
+ return `中断调用成功,但宿主没给出 interrupted 布尔${why}——是否真的停了,我不知道`;
374
+ case "no-seam":
375
+ return "这个宿主没给中断子会话的缝(session.interrupt / abort 都没有),未取消";
376
+ case "threw":
377
+ return `中断被宿主拒绝${why}`;
378
+ }
379
+ }
380
+ /** Short name for one outcome, used in the count line. */
381
+ export function cancelOutcomeLabel(o) {
382
+ return o === "stopped"
383
+ ? "已由宿主中断"
384
+ : o === "idle"
385
+ ? "空闲未中断"
386
+ : o === "unknown"
387
+ ? "未确认"
388
+ : o === "no-seam"
389
+ ? "无中断缝"
390
+ : "被宿主拒绝";
391
+ }
392
+ /** The summary counts, spelled the way the lead has to quote them. Fixed order so a
393
+ * rerun prints the same line, and an absent outcome prints nothing rather than "0". */
394
+ export function cancelOutcomeParts(tally) {
395
+ const order = ["stopped", "idle", "unknown", "threw", "no-seam"];
396
+ const out = [];
397
+ for (const o of order) {
398
+ const n = tally[o];
399
+ if (n)
400
+ out.push(`${n} ${cancelOutcomeLabel(o)}`);
401
+ }
402
+ return out;
403
+ }
314
404
  export function buildDispatchTools(deps) {
315
405
  const leadAgent = deps.leadAgent ?? "team";
316
406
  const targets = deps.targets ?? DISPATCH_TARGETS;
@@ -366,14 +456,29 @@ export function buildDispatchTools(deps) {
366
456
  }
367
457
  function fetchReply(sid, directory) {
368
458
  return (async () => {
369
- if (typeof api?.messages !== "function")
370
- return { text: "" };
459
+ if (typeof api?.messages !== "function") {
460
+ return { text: "", error: "这个宿主客户端没有给出读取子会话正文的缝(v2 上它是 ctx.session.context 的兼容层)" };
461
+ }
371
462
  try {
372
- const un = unwrapClientResult(await api.messages({ path: { id: sid }, ...(directory ? { query: { directory } } : {}) }));
373
- return un.ok ? lastAssistantMessage(un.data) : { text: "" };
463
+ const raw = await api.messages({ path: { id: sid }, ...(directory ? { query: { directory } } : {}) });
464
+ const un = unwrapClientResult(raw);
465
+ const got = un.ok ? lastAssistantMessage(un.data) : { text: "", error: un.message };
466
+ // The seam that ANSWERED, named by the seam itself. On v1 that is the host's
467
+ // `session.messages`; on v2 the same call is served by `ctx.session.context`, and
468
+ // printing "session.messages" there would credit an endpoint this host does not
469
+ // have — the class of claim this product exists to refuse.
470
+ const via = String(raw?.via ?? "session.messages");
471
+ // And the SHAPE the seam saw, reported by the seam (names and counts only). This is
472
+ // what makes a `child_body_missing` row falsifiable instead of a shrug: "answered,
473
+ // and the last item carried no text part" is a different fact from "no seam".
474
+ const reported = raw?.shape;
475
+ const shape = typeof reported === "string" && reported ? reported : undefined;
476
+ return got.text
477
+ ? { ...got, via, ...(shape ? { shape } : {}) }
478
+ : { text: "", error: got.error, via, ...(shape ? { shape } : {}) };
374
479
  }
375
- catch {
376
- return { text: "" };
480
+ catch (err) {
481
+ return { text: "", error: describeHostError(err?.message ?? err, 140) };
377
482
  }
378
483
  })();
379
484
  }
@@ -392,17 +497,28 @@ export function buildDispatchTools(deps) {
392
497
  * path that may take a host child, and the parentage check it must pass.
393
498
  */
394
499
  async function adoptFromHost(parent, directory, only) {
395
- if (typeof api?.children !== "function" || !parent)
396
- return 0;
500
+ // Three outcomes used to collapse into `0`: the host surface is absent (v2's
501
+ // client shim has no `session` domain), the call failed, and "we asked and
502
+ // there was nothing". The empty-round answer reads "宿主会话树里也没有可认领的
503
+ // 子会话", which is a claim about case three — asserted in cases one and two,
504
+ // where nobody looked. A lead that believes it will stop waiting for a report
505
+ // that exists, which is the silent loss this function exists to prevent.
506
+ // The reason names the MISSING SEAM, not a host generation: the same absence
507
+ // occurs on a v1-shaped client that lacks `session.messages`, and a sentence
508
+ // blaming "v2" there would be false in the opposite direction.
509
+ if (typeof api?.children !== "function")
510
+ return { adopted: 0, looked: false, why: "这个宿主客户端没有给出 session.children(可辨认的 session API 缺失),我没有看过会话树" };
511
+ if (!parent)
512
+ return { adopted: 0, looked: false, why: "调用方会话 id 缺失,无从查询子会话" };
397
513
  let un;
398
514
  try {
399
515
  un = unwrapClientResult(await api.children({ path: { id: parent }, ...(directory ? { query: { directory } } : {}) }));
400
516
  }
401
- catch {
402
- return 0;
517
+ catch (err) {
518
+ return { adopted: 0, looked: false, why: `查询会话树失败:${describeHostError(err)}` };
403
519
  }
404
520
  if (!un.ok || !Array.isArray(un.data))
405
- return 0;
521
+ return { adopted: 0, looked: false, why: "宿主返回了无法解析的会话树结果" };
406
522
  let adopted = 0;
407
523
  for (const raw of un.data) {
408
524
  const sid = String(raw?.id ?? "").trim();
@@ -429,7 +545,7 @@ export function buildDispatchTools(deps) {
429
545
  adopted++;
430
546
  log({ step_id: "join", event: "adopt", child: sid, agent: parsed.agent, label: rec.label });
431
547
  }
432
- return adopted;
548
+ return { adopted, looked: true, why: adopted ? "" : "会话树里没有被认领标题的子会话" };
433
549
  }
434
550
  /** Claim ONE explicitly named child of the calling session — the host's own
435
551
  * `task` child included, which `adoptFromHost` must never pick up on its
@@ -482,11 +598,11 @@ export function buildDispatchTools(deps) {
482
598
  }
483
599
  }
484
600
  const join = {
485
- 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.
486
- - 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.
487
- - { 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 }).
488
- - { 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.
489
- - 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.
601
+ 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.
602
+ - 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.
603
+ - { 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 }).
604
+ - { 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.
605
+ - 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.
490
606
  - 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.`,
491
607
  args: {
492
608
  ids: { descriptor: "ids: string[] (optional — only these child sessions)" },
@@ -513,10 +629,19 @@ export function buildDispatchTools(deps) {
513
629
  const idFilter = idList && idList.length ? new Set(idList) : null;
514
630
  const owned = () => [...children.values()].filter((r) => (!parent || r.parentSessionID === parent) && (!idFilter || idFilter.has(r.sessionID)));
515
631
  let mine = owned();
632
+ // Whether we got to LOOK at the host's tree, and what it said — the empty
633
+ // answer must not claim "there is nothing to adopt" on a host we cannot
634
+ // query, so the fact travels with the attempt rather than being inferred.
635
+ let adoption = {
636
+ adopted: 0,
637
+ looked: false,
638
+ why: "本进程已经认全了要收的子代理,没有去查会话树",
639
+ };
516
640
  // This process has never seen some (or all) of what the lead is asking
517
641
  // for — the host's session tree, not our memory, is the authority.
518
642
  if (mine.length < (idFilter ? idFilter.size : 1)) {
519
- if (await adoptFromHost(parent, directory, idFilter))
643
+ adoption = await adoptFromHost(parent, directory, idFilter);
644
+ if (adoption.adopted)
520
645
  mine = owned();
521
646
  // Plan B: a NAMED id may be the host's own `task` child (no ·tm
522
647
  // marker, so the tree walk above never claims it). Collecting it is
@@ -563,7 +688,28 @@ export function buildDispatchTools(deps) {
563
688
  };
564
689
  if (!mine.length) {
565
690
  const lease = leaseLine();
566
- return toToolResult(`没有待收集的派发${idFilter ? "(ids 未匹配到本会话的子代理,宿主会话树里也没有可认领的子会话)" : ""}。` +
691
+ // The parenthetical is now chosen by whether we actually looked, so a
692
+ // host we cannot query no longer reports as "confirmed: nothing there".
693
+ const saw = adoption.looked;
694
+ const inner = idFilter
695
+ ? (() => {
696
+ // Naming the shapes the v2 session bridge tried is the whole point:
697
+ // without it, a claim that failed because `ctx.session.get` answered
698
+ // in a shape we do not know prints the same sentence as a tree we
699
+ // did query, and the lead goes back to waiting for a report that
700
+ // exists.
701
+ const sr = deps.sessionReaderReport?.();
702
+ const bridge = sr && sr.attempted > 0
703
+ ? `;我试过的 ctx.session 形状:${(sr.failedShapes.length ? sr.failedShapes.join("→") : sr.usedShape ?? "?")}${sr.lastError ? `(宿主最后一次的错误:${shorten(sr.lastError, 90)})` : "(宿主没有报错,只是没给出 parentID)"}`
704
+ : "";
705
+ return saw
706
+ ? `(ids 未匹配到本会话的子代理,宿主会话树里也没有可认领的子会话)${bridge}`
707
+ : `(ids 未匹配到;而且我没能查看宿主会话树:${adoption.why})${bridge}`;
708
+ })()
709
+ : saw
710
+ ? ""
711
+ : `(注意:${adoption.why},所以"确实没有子代理"这个结论我给不出)`;
712
+ return toToolResult(`没有待收集的派发${inner}。` +
567
713
  // The old line asserted the dispatch had failed. Measured live: it
568
714
  // had not — a completed host `task` with a full reply was sitting in
569
715
  // the transcript, and the honest reason is that a synchronous host
@@ -599,25 +745,62 @@ export function buildDispatchTools(deps) {
599
745
  still_running: stillRunning.length,
600
746
  repeat: repeatWait,
601
747
  });
748
+ // #33 — the STOP path. Runs BEFORE the header is built, because the per-child
749
+ // verdict belongs in the child's own row, and the summary line below must be able
750
+ // to say which of the outcomes it is counting.
751
+ let cancelTally = null;
602
752
  if (args.cancel === true || args.cancel === "true") {
753
+ cancelTally = {};
603
754
  for (const r of stillRunning) {
755
+ let outcome;
756
+ let note;
604
757
  if (typeof api?.abort === "function") {
758
+ // v1's path, byte-exact and FIRST: the personality is frozen and a shipped
759
+ // test pins "aborted on request".
605
760
  const un = unwrapClientResult(await api.abort({ path: { id: r.sessionID } }));
761
+ outcome = un.ok ? "stopped" : "threw";
606
762
  r.error = un.ok ? "aborted on request" : `abort failed: ${shorten(un.message ?? "", 60)}`;
763
+ r.state = "error";
764
+ }
765
+ else if (typeof api?.interrupt === "function") {
766
+ const got = cancelOutcomeOf(await api.interrupt({ path: { id: r.sessionID } }));
767
+ outcome = got.outcome;
768
+ note = got.note;
769
+ // `idle` is the host saying there was no active execution: the registry row
770
+ // was stale, not a failed stop. Anything else closes as undelivered.
771
+ r.state = outcome === "idle" ? "idle" : "error";
772
+ r.cancelNote = cancelVerdictLine(outcome, note);
773
+ if (r.state === "error")
774
+ r.error = r.cancelNote;
607
775
  }
608
776
  else {
777
+ outcome = "no-seam";
609
778
  r.error = "宿主无 abort 接口,未取消";
779
+ r.state = "error";
610
780
  }
611
- r.state = "error";
612
781
  r.finishedAt = now();
613
- log({ step_id: "join", event: "cancel", child: r.sessionID, agent: r.agent, ok: r.error.startsWith("aborted") });
782
+ cancelTally[outcome] = (cancelTally[outcome] ?? 0) + 1;
783
+ log({ step_id: "join", event: "cancel", child: r.sessionID, agent: r.agent, outcome, ok: outcome === "stopped" });
614
784
  }
785
+ log({
786
+ step_id: "join",
787
+ event: "cancel_summary",
788
+ count: stillRunning.length,
789
+ outcomes: cancelOutcomeParts(cancelTally).join(" / "),
790
+ });
615
791
  }
616
792
  const sums = summarizeStates(mine);
617
793
  const header = [
618
794
  `派发汇总:${sums.idle} 完成 / ${sums.running} 运行中 / ${sums.error} 失败${settled ? "(全部已结算)" : `(未等到全部结算 · 本次已等 ${Math.round((now() - waitStart) / 1000)}s)`}`,
619
795
  ...mine.map((r) => renderChildLine(r, (r.finishedAt ?? now()) - r.startedAt)),
620
796
  ];
797
+ if (cancelTally) {
798
+ const parts = cancelOutcomeParts(cancelTally);
799
+ header.push(`取消尝试:${parts.join(" · ") || "没有运行中的子代理可停"} —— ` +
800
+ `只有「已由宿主中断」是宿主确认过的停止;「空闲未中断」说明它当时没在活动执行里` +
801
+ `(登记行过时了,不是失败);「未确认」说明中断调用成功但宿主没回 interrupted 布尔。` +
802
+ `这三种结论不要混成一句"已取消"转述给用户。`);
803
+ }
621
804
  if (stillRunning.length && !settled) {
622
805
  header.push(`⏱ 还有 ${stillRunning.length} 个子代理在跑 —— 先告诉用户你在等谁、在等什么、这一轮不是交付,再决定是等还是去干活。` +
623
806
  `你的工具调用在界面上只是一行打不开的卡片,你不说,用户没法把"回合结束"和"任务完成"分开。`);
@@ -633,20 +816,75 @@ export function buildDispatchTools(deps) {
633
816
  // child settled says nothing about the USER'S goal, and the host's own
634
817
  // todo list does. Read-only (GET /session/{id}/todo has no write
635
818
  // body), so this can remind but never rewrite.
636
- if (settled && typeof api?.todo === "function" && parent) {
637
- try {
638
- const un = unwrapClientResult(await api.todo({ path: { id: parent }, ...(directory ? { query: { directory } } : {}) }));
639
- const open = un.ok ? openHostTodos(un.data) : [];
640
- if (open.length) {
641
- header.push(`⚠ 目标未达成:宿主 todolist 还有 ${open.length} 项未完成 —— ${open.slice(0, 6).map((t) => `「${t.content}」(${t.status})`).join("、")}` +
642
- (open.length > 6 ? ` …+${open.length - 6}` : "") +
643
- `\n按目标指令:要么继续做掉,要么向用户写明哪一条被什么卡住;不要把这轮当成收尾。` +
644
- `(若这些项其实已经做完——例如你刚把派发结果收齐——先用 todowrite 更新状态,再收尾。)`);
645
- log({ step_id: "join", event: "goal_open", count: open.length });
819
+ //
820
+ // The check has THREE outcomes and all three must be sayable. Silence when
821
+ // the seam is missing (v2's client shim has no `todo`) read as "the goal
822
+ // check ran and passed", which is the same overstatement this tool was
823
+ // already fixed for on the adoption path — a settled round is exactly when
824
+ // an unchecked box matters most.
825
+ if (settled) {
826
+ const canCheck = typeof api?.todo === "function" && Boolean(parent);
827
+ let checked = false;
828
+ let ledgerEmpty = false;
829
+ if (canCheck) {
830
+ try {
831
+ const un = unwrapClientResult(await api.todo({ path: { id: parent }, ...(directory ? { query: { directory } } : {}) }));
832
+ checked = un.ok;
833
+ const open = un.ok ? openHostTodos(un.data) : [];
834
+ if (open.length) {
835
+ header.push(`⚠ 目标未达成:宿主 todolist 还有 ${open.length} 项未完成 —— ${open.slice(0, 6).map((t) => `「${t.content}」(${t.status})`).join("、")}` +
836
+ (open.length > 6 ? ` …+${open.length - 6}` : "") +
837
+ `\n按目标指令:要么继续做掉,要么向用户写明哪一条被什么卡住;不要把这轮当成收尾。` +
838
+ // v1 has the built-in `todowrite`; a host without it must not be
839
+ // told to call a tool it cannot call (the tm_ptc_run / `task`
840
+ // lesson a third time). Without the write seam the only honest
841
+ // move is to say which items are done in the reply.
842
+ (typeof api?.todo === "function"
843
+ ? `(若这些项其实已经做完——例如你刚把派发结果收齐——先用 todowrite 更新状态,再收尾。)`
844
+ : `(这个宿主没给写清单的入口:若这些项其实已完成,就在回复里逐条写明哪条做完了、依据是什么。)`));
845
+ log({ step_id: "join", event: "goal_open", count: open.length });
846
+ }
847
+ }
848
+ catch {
849
+ checked = false;
850
+ /* the todo endpoint is an extra, never a reason to fail a join */
646
851
  }
647
852
  }
648
- catch {
649
- /* the todo endpoint is an extra, never a reason to fail a join */
853
+ if (!checked && deps.ledgerStore?.available?.() && parent) {
854
+ // v2 has no `session.todo` to read, so the list that CAN be checked is
855
+ // the one this plugin keeps (`tm_ledger`, in ctx.storage). A non-empty
856
+ // open set is a fact worth refusing a wrap-up over; an EMPTY one is not
857
+ // evidence the goal was met — it usually means nothing was ever
858
+ // recorded, which is its own honest "cannot check".
859
+ try {
860
+ const led = await deps.ledgerStore.load(parent);
861
+ const rest = led ? openItems(led) : [];
862
+ if (rest.length) {
863
+ const line = ledgerGoalLine(led);
864
+ if (line)
865
+ header.push(line);
866
+ log({ step_id: "join", event: "goal_open", count: rest.length, source: "ledger" });
867
+ checked = true;
868
+ }
869
+ else if (led) {
870
+ ledgerEmpty = true;
871
+ }
872
+ }
873
+ catch {
874
+ /* the ledger is an extra too — a store that will not read never fails a join */
875
+ }
876
+ }
877
+ if (!checked) {
878
+ header.push(canCheck
879
+ ? "(目标核对没做成:宿主 todo 端点返回异常,这一轮我没有看到你的清单状态。)"
880
+ : ledgerEmpty
881
+ ? "(目标核对没做成:这个宿主没给 session.todo 端点,而 tm_ledger 里一条都没有——没有可核对的目标状态。`所有子代理已结算` 不等于 `目标已达成`:要么先 tm_ledger { action:\"add\" } 把要求记下来,要么在回复里自己逐条核对再收尾。)"
882
+ : "(目标核对没做成:这个宿主没给 session.todo 端点,我也读不到本插件的 tm_ledger——无法读取清单状态。`所有子代理已结算` 不等于 `目标已达成`,这一轮请自己核对再收尾。)");
883
+ log({
884
+ step_id: "join",
885
+ event: "goal_unchecked",
886
+ reason: canCheck ? "endpoint_failed" : ledgerEmpty ? "ledger_empty" : "no_seam",
887
+ });
650
888
  }
651
889
  }
652
890
  // #80/#86/#87: THE LEASE TRIPWIRE — one computation (leaseLine above),
@@ -664,9 +902,28 @@ export function buildDispatchTools(deps) {
664
902
  for (const r of collectible) {
665
903
  const reply = await fetchReply(r.sessionID, directory);
666
904
  const text = reply.text;
667
- blocks.push(`--- ${r.agent} "${r.label}" (${r.sessionID}) ---\n${text.trim() ||
668
- `(该子会话没有可读的助手回复${r.error ? `;宿主错误:${r.error}` : reply.error ? `;宿主错误:${reply.error}` : ""})` +
669
- `——子会话不是文件,tm_read 读不到它:在宿主的会话面板里看这条子会话,或改用内置 task 工具重做这一步。`}`);
905
+ const src = text && reply.via ? ` ·正文来源=${reply.via}` : "";
906
+ // Which seam answered, per child — an id, a seam name and the SHAPE it saw, never
907
+ // a body (R6 binds a diagnostic too). This is the line that makes "正文来源=…"
908
+ // checkable later, and the only place the three outcomes stay distinguishable:
909
+ // a real body, a seam that answered with nothing readable (`shape=items=2 …
910
+ // content=1 kinds=[tool] text=0`), and a seam that never answered (`no-context-method`).
911
+ log({
912
+ step_id: "join",
913
+ event: text ? "child_body" : "child_body_missing",
914
+ child: r.sessionID,
915
+ via: reply.via ?? "none",
916
+ // Only when the SEAM reported one: v1's SDK client gives no shape, and the v1
917
+ // personality is frozen — its rows stay byte-exact rather than gaining a field
918
+ // nobody asked for. The v2 bridge reports a shape on every path.
919
+ ...(text || !reply.shape ? {} : { shape: reply.shape }),
920
+ });
921
+ blocks.push(`--- ${r.agent} "${r.label}" (${r.sessionID})${src} ---\n${text.trim() ||
922
+ (r.via === "host-injection"
923
+ ? `(这个子会话由宿主的 subagent 工具派发,而这一次我没有读到它的正文${reply.error ? `:${reply.error}` : ":这个宿主没给可读正文的缝"}。完成时宿主仍会把 \`<subagent sessionID=\"${r.sessionID}\" …>\` 注入本会话,我已经把它登记在案、状态如上。` +
924
+ `要全文就在那条注入到达后读它,或让子代理把交付写进黑板;不要为了拿正文反复 join。)`
925
+ : `(该子会话没有可读的助手回复${r.error ? `;宿主错误:${r.error}` : reply.error ? `;宿主错误:${reply.error}` : ""})` +
926
+ `——子会话不是文件,tm_read 读不到它:在宿主的会话面板里看这条子会话,或改用内置 task 工具重做这一步。`)}`);
670
927
  }
671
928
  }
672
929
  const body = [...header, ...(blocks.length ? ["", blocks.join("\n")] : [])].join("\n");
@@ -691,12 +948,31 @@ export function buildDispatchTools(deps) {
691
948
  if (!sid)
692
949
  return;
693
950
  const rec = children.get(sid);
694
- if (!rec)
951
+ if (!rec) {
952
+ // The parent's own idle is the only completion signal v2 gives us for a host
953
+ // child: the background reply is injected into the parent before the parent's turn
954
+ // ends (measured: child injected …824730, parent idle …829038), and the plugin has
955
+ // no endpoint to read a child session's state. So settle the still-running host
956
+ // children of THIS session — and say plainly that it is a presumption.
957
+ if (type === "session.idle") {
958
+ for (const child of children.values()) {
959
+ if (child.via !== "host-injection" || child.state !== "running")
960
+ continue;
961
+ if (child.parentSessionID !== sid)
962
+ continue;
963
+ child.state = "idle";
964
+ child.finishedAt = now();
965
+ child.settleSource = "parent-idle";
966
+ log({ step_id: "events", event: "idle_presumed", child: child.sessionID, agent: child.agent, ms: child.finishedAt - child.startedAt });
967
+ }
968
+ }
695
969
  return;
970
+ }
696
971
  if (type === "session.idle") {
697
972
  if (rec.state === "running") {
698
973
  rec.state = "idle";
699
974
  rec.finishedAt = now();
975
+ rec.settleSource = "event";
700
976
  log({ step_id: "events", event: "idle", child: sid, agent: rec.agent, ms: (rec.finishedAt ?? 0) - rec.startedAt });
701
977
  }
702
978
  return;
@@ -721,9 +997,54 @@ export function buildDispatchTools(deps) {
721
997
  }
722
998
  }
723
999
  };
1000
+ /** Open a row for a child the HOST dispatched (v2's `subagent` tool) that this
1001
+ * registry never created. Returns false when the ids are unusable or the child is
1002
+ * already known — a plugin reload replays hooks, and a second row for one session
1003
+ * would make tm_join count the same work twice. */
1004
+ function register(child) {
1005
+ const rec = hostChildRecord(child, now);
1006
+ if (!rec || children.has(rec.sessionID))
1007
+ return false;
1008
+ children.set(rec.sessionID, rec);
1009
+ deps.onChildSession?.(rec.sessionID, rec.agent);
1010
+ // The label is model-authored free text: it belongs in the lead's table, never in
1011
+ // the audit trail (R6 binds a diagnostic as much as a guard).
1012
+ log({ step_id: "join", event: "host_subagent", child: rec.sessionID, agent: rec.agent, parent: rec.parentSessionID });
1013
+ return true;
1014
+ }
1015
+ /** Settle a child from the host's OWN completion envelope (see
1016
+ * `applyV2CompletionWatch`). Returns false when this process does not know the id, so
1017
+ * an unrelated session's injection never touches our registry. This is the third
1018
+ * settle source and the only one that works mid-turn on v2: the child's own
1019
+ * `session.idle` is not forwarded to a plugin subscriber (measured 2026-09-26), and a
1020
+ * parent-idle presumption cannot fire while the parent is still working — which left a
1021
+ * child whose report had already been injected showing 运行中. */
1022
+ function settle(sessionID, state) {
1023
+ const rec = children.get(String(sessionID ?? "").trim());
1024
+ if (!rec || rec.state !== "running")
1025
+ return false;
1026
+ rec.finishedAt = now();
1027
+ if (state === "error" || state === "failed" || state === "aborted") {
1028
+ rec.state = "error";
1029
+ rec.error = `宿主报告子会话 ${state}(其正文随注入消息送达)`;
1030
+ }
1031
+ else {
1032
+ rec.state = "idle";
1033
+ }
1034
+ rec.settleSource = "injection";
1035
+ log({ step_id: "events", event: "idle_injection", child: rec.sessionID, agent: rec.agent, ms: rec.finishedAt - rec.startedAt });
1036
+ return true;
1037
+ }
724
1038
  return {
725
1039
  tm_join: join,
726
1040
  observeEvent,
1041
+ register,
1042
+ settle,
1043
+ /** Anything still running? The completion scan is O(messages) per request, so it
1044
+ * asks this first and skips the read when the answer is no. */
1045
+ hasOpen: () => { for (const c of children.values())
1046
+ if (c.state === "running")
1047
+ return true; return false; },
727
1048
  children: () => [...children.values()],
728
1049
  };
729
1050
  }