@te-river/opencode-team-mode 1.5.15 → 1.6.1

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 (196) hide show
  1. package/README.md +853 -690
  2. package/README.zh-CN.md +761 -631
  3. package/dist/agents.d.ts +5 -3
  4. package/dist/agents.d.ts.map +1 -1
  5. package/dist/agents.js +19 -8
  6. package/dist/agents.js.map +1 -1
  7. package/dist/capabilities.d.ts +1 -1
  8. package/dist/capabilities.js +2 -2
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/host/note.d.ts +7 -0
  11. package/dist/host/note.d.ts.map +1 -0
  12. package/dist/host/note.js +28 -0
  13. package/dist/host/note.js.map +1 -0
  14. package/dist/host/v1.d.ts +14 -0
  15. package/dist/host/v1.d.ts.map +1 -0
  16. package/dist/host/v1.js +340 -0
  17. package/dist/host/v1.js.map +1 -0
  18. package/dist/host/v2-browser-gate.d.ts +45 -0
  19. package/dist/host/v2-browser-gate.d.ts.map +1 -0
  20. package/dist/host/v2-browser-gate.js +319 -0
  21. package/dist/host/v2-browser-gate.js.map +1 -0
  22. package/dist/host/v2-capabilities.d.ts +68 -0
  23. package/dist/host/v2-capabilities.d.ts.map +1 -0
  24. package/dist/host/v2-capabilities.js +146 -0
  25. package/dist/host/v2-capabilities.js.map +1 -0
  26. package/dist/host/v2-client.d.ts +53 -0
  27. package/dist/host/v2-client.d.ts.map +1 -0
  28. package/dist/host/v2-client.js +193 -0
  29. package/dist/host/v2-client.js.map +1 -0
  30. package/dist/host/v2-events.d.ts +56 -0
  31. package/dist/host/v2-events.d.ts.map +1 -0
  32. package/dist/host/v2-events.js +158 -0
  33. package/dist/host/v2-events.js.map +1 -0
  34. package/dist/host/v2-guard.d.ts +101 -0
  35. package/dist/host/v2-guard.d.ts.map +1 -0
  36. package/dist/host/v2-guard.js +199 -0
  37. package/dist/host/v2-guard.js.map +1 -0
  38. package/dist/host/v2-offload.d.ts +148 -0
  39. package/dist/host/v2-offload.d.ts.map +1 -0
  40. package/dist/host/v2-offload.js +288 -0
  41. package/dist/host/v2-offload.js.map +1 -0
  42. package/dist/host/v2-permissions.d.ts +123 -0
  43. package/dist/host/v2-permissions.d.ts.map +1 -0
  44. package/dist/host/v2-permissions.js +190 -0
  45. package/dist/host/v2-permissions.js.map +1 -0
  46. package/dist/host/v2-probe.d.ts +85 -0
  47. package/dist/host/v2-probe.d.ts.map +1 -0
  48. package/dist/host/v2-probe.js +268 -0
  49. package/dist/host/v2-probe.js.map +1 -0
  50. package/dist/host/v2-scope.d.ts +51 -0
  51. package/dist/host/v2-scope.d.ts.map +1 -0
  52. package/dist/host/v2-scope.js +58 -0
  53. package/dist/host/v2-scope.js.map +1 -0
  54. package/dist/host/v2-session-client.d.ts +74 -0
  55. package/dist/host/v2-session-client.d.ts.map +1 -0
  56. package/dist/host/v2-session-client.js +171 -0
  57. package/dist/host/v2-session-client.js.map +1 -0
  58. package/dist/host/v2-session.d.ts +85 -0
  59. package/dist/host/v2-session.d.ts.map +1 -0
  60. package/dist/host/v2-session.js +171 -0
  61. package/dist/host/v2-session.js.map +1 -0
  62. package/dist/host/v2-subagent.d.ts +157 -0
  63. package/dist/host/v2-subagent.d.ts.map +1 -0
  64. package/dist/host/v2-subagent.js +286 -0
  65. package/dist/host/v2-subagent.js.map +1 -0
  66. package/dist/host/v2-tool.d.ts +67 -0
  67. package/dist/host/v2-tool.d.ts.map +1 -0
  68. package/dist/host/v2-tool.js +209 -0
  69. package/dist/host/v2-tool.js.map +1 -0
  70. package/dist/host/v2-transcript.d.ts +73 -0
  71. package/dist/host/v2-transcript.d.ts.map +1 -0
  72. package/dist/host/v2-transcript.js +151 -0
  73. package/dist/host/v2-transcript.js.map +1 -0
  74. package/dist/host/v2-types.d.ts +229 -0
  75. package/dist/host/v2-types.d.ts.map +1 -0
  76. package/dist/host/v2-types.js +17 -0
  77. package/dist/host/v2-types.js.map +1 -0
  78. package/dist/host/v2.d.ts +42 -0
  79. package/dist/host/v2.d.ts.map +1 -0
  80. package/dist/host/v2.js +706 -0
  81. package/dist/host/v2.js.map +1 -0
  82. package/dist/host-hooks.d.ts +13 -2
  83. package/dist/host-hooks.d.ts.map +1 -1
  84. package/dist/host-hooks.js +18 -5
  85. package/dist/host-hooks.js.map +1 -1
  86. package/dist/index.d.ts +23 -17
  87. package/dist/index.d.ts.map +1 -1
  88. package/dist/index.js +29 -326
  89. package/dist/index.js.map +1 -1
  90. package/dist/prompts/lead.d.ts +1 -1
  91. package/dist/prompts/lead.d.ts.map +1 -1
  92. package/dist/prompts/lead.js +452 -359
  93. package/dist/prompts/lead.js.map +1 -1
  94. package/dist/prompts/shared.d.ts +2 -2
  95. package/dist/prompts/shared.d.ts.map +1 -1
  96. package/dist/prompts/shared.js +89 -17
  97. package/dist/prompts/shared.js.map +1 -1
  98. package/dist/prompts/specialists.d.ts +2 -2
  99. package/dist/prompts/specialists.d.ts.map +1 -1
  100. package/dist/prompts/specialists.js +44 -10
  101. package/dist/prompts/specialists.js.map +1 -1
  102. package/dist/task-offload.d.ts +83 -0
  103. package/dist/task-offload.d.ts.map +1 -0
  104. package/dist/task-offload.js +174 -0
  105. package/dist/task-offload.js.map +1 -0
  106. package/dist/tm/args-schema.d.ts +4 -0
  107. package/dist/tm/args-schema.d.ts.map +1 -1
  108. package/dist/tm/args-schema.js +36 -3
  109. package/dist/tm/args-schema.js.map +1 -1
  110. package/dist/tm/board.d.ts +113 -0
  111. package/dist/tm/board.d.ts.map +1 -0
  112. package/dist/tm/board.js +275 -0
  113. package/dist/tm/board.js.map +1 -0
  114. package/dist/tm/browser.d.ts +325 -6
  115. package/dist/tm/browser.d.ts.map +1 -1
  116. package/dist/tm/browser.js +1360 -135
  117. package/dist/tm/browser.js.map +1 -1
  118. package/dist/tm/config.d.ts +36 -25
  119. package/dist/tm/config.d.ts.map +1 -1
  120. package/dist/tm/config.js +29 -7
  121. package/dist/tm/config.js.map +1 -1
  122. package/dist/tm/dispatch.d.ts +133 -53
  123. package/dist/tm/dispatch.d.ts.map +1 -1
  124. package/dist/tm/dispatch.js +481 -279
  125. package/dist/tm/dispatch.js.map +1 -1
  126. package/dist/tm/dupe-guard.d.ts +16 -3
  127. package/dist/tm/dupe-guard.d.ts.map +1 -1
  128. package/dist/tm/dupe-guard.js +27 -7
  129. package/dist/tm/dupe-guard.js.map +1 -1
  130. package/dist/tm/egress.d.ts +44 -0
  131. package/dist/tm/egress.d.ts.map +1 -0
  132. package/dist/tm/egress.js +191 -0
  133. package/dist/tm/egress.js.map +1 -0
  134. package/dist/tm/guard.js +1 -1
  135. package/dist/tm/guard.js.map +1 -1
  136. package/dist/tm/index.d.ts +67 -2
  137. package/dist/tm/index.d.ts.map +1 -1
  138. package/dist/tm/index.js +228 -20
  139. package/dist/tm/index.js.map +1 -1
  140. package/dist/tm/ledger.d.ts +114 -0
  141. package/dist/tm/ledger.d.ts.map +1 -0
  142. package/dist/tm/ledger.js +308 -0
  143. package/dist/tm/ledger.js.map +1 -0
  144. package/dist/tm/perm-ask.d.ts +45 -5
  145. package/dist/tm/perm-ask.d.ts.map +1 -1
  146. package/dist/tm/perm-ask.js +107 -7
  147. package/dist/tm/perm-ask.js.map +1 -1
  148. package/dist/tm/pipelines.d.ts.map +1 -1
  149. package/dist/tm/pipelines.js +24 -2
  150. package/dist/tm/pipelines.js.map +1 -1
  151. package/dist/tm/preview.d.ts +55 -0
  152. package/dist/tm/preview.d.ts.map +1 -1
  153. package/dist/tm/preview.js +225 -0
  154. package/dist/tm/preview.js.map +1 -1
  155. package/dist/tm/ptc/tool.d.ts.map +1 -1
  156. package/dist/tm/ptc/tool.js +2 -4
  157. package/dist/tm/ptc/tool.js.map +1 -1
  158. package/dist/tm/pty.d.ts.map +1 -1
  159. package/dist/tm/pty.js +6 -3
  160. package/dist/tm/pty.js.map +1 -1
  161. package/dist/tm/search.d.ts +5 -0
  162. package/dist/tm/search.d.ts.map +1 -1
  163. package/dist/tm/search.js +40 -8
  164. package/dist/tm/search.js.map +1 -1
  165. package/dist/tm/serp-loop.d.ts +45 -0
  166. package/dist/tm/serp-loop.d.ts.map +1 -0
  167. package/dist/tm/serp-loop.js +80 -0
  168. package/dist/tm/serp-loop.js.map +1 -0
  169. package/dist/tm/stats.d.ts +26 -0
  170. package/dist/tm/stats.d.ts.map +1 -1
  171. package/dist/tm/stats.js +194 -4
  172. package/dist/tm/stats.js.map +1 -1
  173. package/dist/tm/webfetch.d.ts +56 -0
  174. package/dist/tm/webfetch.d.ts.map +1 -1
  175. package/dist/tm/webfetch.js +187 -19
  176. package/dist/tm/webfetch.js.map +1 -1
  177. package/dist/tool-coerce.d.ts +24 -0
  178. package/dist/tool-coerce.d.ts.map +1 -0
  179. package/dist/tool-coerce.js +63 -0
  180. package/dist/tool-coerce.js.map +1 -0
  181. package/dist/types.d.ts +7 -1
  182. package/dist/types.d.ts.map +1 -1
  183. package/docs/installation-v2.md +436 -0
  184. package/docs/installation.md +81 -2
  185. package/docs/research/agent-data-exchange.md +117 -0
  186. package/docs/research/browser-pane.md +89 -0
  187. package/docs/research/host-http-api.md +132 -0
  188. package/docs/research/host-subagent-injection.md +187 -0
  189. package/docs/research/plugin-loader-contract.md +261 -0
  190. package/docs/research/v2-builtin-tools.md +447 -0
  191. package/docs/v2-port-notes.md +315 -0
  192. package/index.js +7 -0
  193. package/package.json +4 -1
  194. package/scripts/gen-v2-config.mjs +304 -0
  195. package/scripts/lib/config-surgery.cjs +239 -0
  196. package/scripts/lib/fake-ctx.mjs +146 -0
@@ -1,38 +1,61 @@
1
1
  /**
2
- * tm_dispatch / tm_join — ASYNC sub-agent dispatch (issue #7 of 2026-09-18).
2
+ * tm_join — the COLLECT side of sub-agent work (issue #7 of 2026-09-18; the
3
+ * dispatch side was removed in 1.5.16).
3
4
  *
4
- * Why this exists: the host's built-in `task` tool blocks the CALLING
5
- * session until the child agent returns, so "the team runs in parallel" was
6
- * only half true — the lead stood still for the sum of every dispatch, and
7
- * each child's full reply landed in the lead's context whether it needed it
8
- * there or not. OpenCode hands plugins its own client (`input.client`), and
9
- * the official session API has `create` + `promptAsync` — "start if needed
10
- * and return IMMEDIATELY" — so a plugin-owned dispatcher gets real overlap
11
- * without touching a line of host code. If a future host ever removes that
12
- * surface, both tools degrade with an explicit instruction to fall back to
13
- * `task`, rather than failing the round.
5
+ * What is gone and why: this module used to own a plugin-side dispatcher,
6
+ * `tm_dispatch`, which bought the lead real overlap by creating child sessions
7
+ * through `client.session.create` + `promptAsync`. It was removed on the
8
+ * user's instruction, because a child a plugin creates is a session the user
9
+ * can neither open from the tool card nor stop from the interface — the host
10
+ * renders only its own built-in tools, and only its own `task` card links to a
11
+ * live child session. "The lead can `cancel:true` it" is not the same thing as
12
+ * the user being able to. Delegation therefore goes back to the host's `task`
13
+ * (and `task { background: true }`, which the installers switch on), and what
14
+ * stays here is the half that is still ours to do: observing and collecting
15
+ * children, which is also how the lead reads back a reply that
16
+ * `src/task-offload.ts` replaced with a pointer.
14
17
  *
15
18
  * Contract kept from T3 (no sub-agent spawns a sub-agent):
16
- * - `tm_dispatch` executes ONLY for the team lead (ctx.agent check here,
17
- * plus an explicit deny for the five specialists in agents.ts — the
18
- * tm_* wildcard would otherwise hand it to everyone);
19
- * - children are the five named specialists, never "team";
20
- * - the child session is parented to the lead session (parentID), so the
21
- * host's own session tree, cancellation and UI still make sense.
19
+ * - `tm_join` executes ONLY for the team lead (ctx.agent check here, plus an
20
+ * explicit deny for the five specialists in agents.ts — the tm_* wildcard
21
+ * would otherwise hand it to everyone);
22
+ * - the children it knows about are the five named specialists, never "team";
23
+ * - everything it touches is parented to the calling session (parentID), and
24
+ * parentage is READ BACK from the host before an id is claimed — this module
25
+ * never assumes a session belongs to the caller.
22
26
  *
23
27
  * Context economy (design goal #4): tm_join renders each child's reply
24
- * through the SAME governance pipeline as every other tm_* tool, so five
25
- * long sub-agent reports become five handles + 80-token previews that the
26
- * lead can page through with tm_fetch — not 5×N kilotokens dumped inline.
28
+ * through the SAME governance pipeline as every other tm_* tool, so a long
29
+ * sub-agent report arrives as a handle + an 80-token preview the lead can page
30
+ * through with tm_fetch — not kilotokens dumped inline.
27
31
  */
28
32
  import { tmError, toToolResult } from "./result.js";
29
33
  import { unwrapClientResult } from "./client-unwrap.js";
30
- import { askUserForTarget } from "./perm-ask.js";
31
34
  import { detectContentType } from "./preview.js";
32
35
  import { shorten } from "./config.js";
36
+ import { ledgerGoalLine, openItems } from "./ledger.js";
33
37
  /** The five dispatchable specialists — "team" is deliberately NOT in the
34
38
  * list: a lead spawning a lead is the nesting T3 closed. */
35
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
+ }
36
59
  export function openHostTodos(todos) {
37
60
  if (!Array.isArray(todos))
38
61
  return [];
@@ -47,36 +70,98 @@ export function openHostTodos(todos) {
47
70
  }
48
71
  return open;
49
72
  }
50
- /** Resolve the host session API defensively — a missing namespace means the
51
- * host does not speak this protocol, and the caller must degrade. */
73
+ /** #80: the line tm_join adds when a SETTLED child still owns a browser window.
74
+ * Takes only the leases already filtered to settled children, so the ownership
75
+ * rule stays where the child registry is; this function's whole job is to say
76
+ * it in the one shape that survives the lead's skimming — and to refuse to say
77
+ * it for a window that was never ours. */
78
+ export function leaseTripwire(held, callerSessionID) {
79
+ if (!held.length)
80
+ return null;
81
+ const isMine = (l) => !!callerSessionID && l.owner === callerSessionID;
82
+ const mine = held.filter(isMine);
83
+ const theirs = held.filter((l) => !isMine(l));
84
+ const at = (l) => `空闲 ${Math.round(l.idleMs / 1000)}s`;
85
+ const parts = [];
86
+ if (theirs.length) {
87
+ parts.push(`⚠ ${theirs.length} 个浏览器还开着(子代理已结算,但它没 close):` +
88
+ theirs.map((l) => `${l.id}(${l.agent || "未知角色"} · ${at(l)})`).join("、") +
89
+ `\n要么让它 close 并引用工具自己的三种裁决之一(已确认关闭 / 进程未核验 / 警告:关闭未完全成功),` +
90
+ `要么由你向用户写明为什么留着。不要替它写"已关闭"——那是把没人核实过的结论交付出去。`);
91
+ }
92
+ // The caller's own window is the one the user is looking at, and the live
93
+ // recheck proved this branch was the missing one: a lead that keeps a browser
94
+ // across rounds is legitimate, so this is a reminder, not an accusation —
95
+ // but silence is the defect.
96
+ for (const l of mine) {
97
+ parts.push(`(你自己还占着 ${l.id},${at(l)}。收尾前 action:"close" 并引用工具的裁决句;` +
98
+ `如果确实要跨轮留着,就在回复里向用户说明为什么。)`);
99
+ }
100
+ return parts.join("\n");
101
+ }
102
+ /** Resolve the host session API defensively — `messages` is what tm_join
103
+ * cannot work without (the collected reply); a host without it means the
104
+ * caller must degrade, and `create`/`promptAsync` are deliberately NOT part
105
+ * of this contract any more: nothing here spawns sessions. */
52
106
  export function sessionApiOf(client) {
53
107
  const session = client?.session;
54
108
  if (!session || typeof session !== "object")
55
109
  return null;
56
110
  const api = session;
57
- if (typeof api.create !== "function" || typeof api.promptAsync !== "function")
111
+ if (typeof api.messages !== "function")
58
112
  return null;
59
113
  return api;
60
114
  }
61
- /** The title `tm_dispatch` gives every child session.
115
+ /** The host's OWN visible-and-non-blocking sub-agent path: its built-in `task`
116
+ * takes `background: true`, and because that card is rendered by the host's
117
+ * own `task` renderer it links to the live child session — which a plugin tool
118
+ * card structurally cannot do. Gated behind a runtime flag (verified in the
119
+ * desktop binary): `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS`, falling back
120
+ * to the umbrella `OPENCODE_EXPERIMENTAL`. We read the SAME env the host
121
+ * reads — our plugin runs inside that process — so this is a fact, not a
122
+ * guess. */
123
+ export function hostBackgroundSubagentsEnabled(env = process.env) {
124
+ const truthy = (v) => {
125
+ const s = typeof v === "string" ? v.trim().toLowerCase() : "";
126
+ if (!s)
127
+ return null;
128
+ return s === "1" || s === "true" || s === "yes" || s === "on";
129
+ };
130
+ return truthy(env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS) ?? truthy(env.OPENCODE_EXPERIMENTAL) ?? false;
131
+ }
132
+ /** A wait budget spelled the way a human reads it (never "0s" for 300 ms). */
133
+ function waitLabel(ms) {
134
+ return ms >= 1000 ? `${Math.round(ms / 1000)}s` : `${ms}ms`;
135
+ }
136
+ /** What a tm_join wait may actually cost. The FIRST bounded wait gets the
137
+ * requested budget (clamped to TM_JOIN_MAX_WAIT_MS); a wait that follows a
138
+ * wait which settled nothing gets cut to REPEAT_WAIT_MS, because the second
139
+ * one is the shape of a lead parking instead of working — measured live at
140
+ * 2 × 300 s of nothing while six children ran. */
141
+ export const REPEAT_WAIT_MS = 10_000;
142
+ export function joinBudget(waitMs, maxWaitMs, prev) {
143
+ const clamped = Math.max(0, Math.min(maxWaitMs, waitMs));
144
+ const repeat = !!prev && prev.stillRunning > 0 && clamped > 0;
145
+ const streak = clamped === 0 ? (prev?.streak ?? 0) : repeat ? (prev?.streak ?? 1) + 1 : 1;
146
+ return { budget: repeat ? Math.min(clamped, REPEAT_WAIT_MS) : clamped, repeat, streak };
147
+ }
148
+ /** Recognise the title a `tm_dispatch` child used to carry — the tool is no
149
+ * longer registered (a plugin-spawned child is a session the user can neither
150
+ * open from a card nor stop from the UI), but children created before the
151
+ * change are still live in the host's session tree and tm_join must still
152
+ * collect them.
62
153
  *
63
- * The shape is the host's OWN subagent convention — the built-in task tool
154
+ * The shape was the host's OWN subagent convention — the built-in task tool
64
155
  * titles its children `${description} (@${agent} subagent)` (verified in the
65
- * desktop binary), because that is what the renderer's `taskSession()`
66
- * heuristic reads to colour a child row and to resolve it from a tool card.
67
- * A plugin cannot register a tool renderer (the desktop's ToolRegistry holds
68
- * exactly the built-in names), so matching the convention is the only way our
69
- * children look like sub-agent sessions in the sidebar instead of a mystery
70
- * row. ` ·tm` is our own marker and it is LOAD-BEARING: it is what keeps a
71
- * `task`-spawned child out of `parseDispatchTitle`, so tm_join never claims a
72
- * reply that belongs to the host's dispatcher. */
73
- export function dispatchTitle(agent, label) {
74
- return `${label} (@${agent} subagent${DISPATCH_TITLE_MARKER})`;
75
- }
76
- /** Suffix tm_dispatch appends inside the host's `(@<agent> subagent)` shape. */
77
- export const DISPATCH_TITLE_MARKER = " ·tm";
78
- /** The pre-1.5.15 title (`tm:<agent>:<label>`). Still accepted on the way in
79
- * so a child created before the upgrade is not orphaned by restart recovery. */
156
+ * desktop binary), which is what the renderer's `taskSession()` heuristic
157
+ * reads to colour a child row. Ours added a ` ·tm` marker inside that shape,
158
+ * and the marker is LOAD-BEARING in both directions: it is what tells a
159
+ * leftover dispatch apart from a host `task` child, so an automatic tree walk
160
+ * never claims a reply that belongs to the host's dispatcher. The pre-1.5.15
161
+ * shape (`tm:<agent>:<label>`) still parses for the same reason — a session
162
+ * that exists must stay collectable. A child named explicitly by id takes a
163
+ * different path (`claimNamedChild`), where the lock is the host's own
164
+ * parentage rather than the title. */
80
165
  export function parseDispatchTitle(title, targets) {
81
166
  const t = typeof title === "string" ? title.trim() : "";
82
167
  if (t.startsWith("tm:")) {
@@ -194,7 +279,11 @@ export function describeHostError(err, max = 220) {
194
279
  export function renderChildLine(c, elapsedMs) {
195
280
  const secs = Math.round(elapsedMs / 1000);
196
281
  const tag = c.state === "running" ? `运行中 ${secs}s` : c.state === "error" ? `失败:${shorten(c.error ?? "", 80)}` : `已完成 ${secs}s`;
197
- return `${c.sessionID} · ${c.agent} · "${c.label}" · ${tag}${c.adopted ? " ·(本进程重启后由宿主会话树接管)" : ""}`;
282
+ return `${c.sessionID} · ${c.agent} · "${c.label}" · ${tag}${c.adopted ? " ·(本进程重启后由宿主会话树接管)" : ""}${c.via === "host-injection" && c.settleSource === "parent-idle"
283
+ ? " ·(宿主子代理:随父会话空闲推定已结算,宿主没给过这个子会话的 idle 事件)"
284
+ : c.via === "host-injection"
285
+ ? " ·(宿主 subagent 派发)"
286
+ : ""}`;
198
287
  }
199
288
  /** Verdicts tm_join can actually observe (issue #7's "leader keeps control"). */
200
289
  export function summarizeStates(children) {
@@ -249,14 +338,20 @@ export function parseIdList(raw) {
249
338
  export function buildDispatchTools(deps) {
250
339
  const leadAgent = deps.leadAgent ?? "team";
251
340
  const targets = deps.targets ?? DISPATCH_TARGETS;
252
- const maxWaitMs = deps.maxWaitMs ?? 300_000;
253
- const askBeforeSpawn = deps.askBeforeSpawn !== false;
254
- const maxDepth = Math.max(0, Number.isFinite(deps.maxDepth) ? deps.maxDepth : 1);
255
- const parallel = deps.parallelDispatch !== "off";
341
+ const maxWaitMs = deps.maxWaitMs ?? 60_000;
256
342
  const now = deps.now ?? (() => Date.now());
257
343
  const store = deps.pipelines.store;
344
+ // The trajectory label stays `tm_dispatch` after the tool's death on purpose:
345
+ // tm_stats reads it, and adopt/cancel/wait events from a 1.5.15 run must stay
346
+ // comparable with this one. Renaming would split the history in two.
258
347
  const log = (e) => store.appendTrajectory({ tool: "tm_dispatch", ...e });
259
348
  const children = new Map();
349
+ /** Per-parent memory of the LAST join's outcome. A wait is not parallelism
350
+ * — while tm_join is in flight the lead's turn is blocked exactly like a
351
+ * synchronous `task` call — so a SECOND wait that inherited a first wait
352
+ * which settled nothing is the pattern a live session burned ten minutes
353
+ * on. It gets cut short and answered with what to do instead. */
354
+ const lastJoin = new Map();
260
355
  const api = sessionApiOf(deps.client);
261
356
  /** Refresh a child's state from the host's own status map (the event bus
262
357
  * is the fast path; this is the belt when an event was missed — a child
@@ -295,48 +390,25 @@ export function buildDispatchTools(deps) {
295
390
  }
296
391
  function fetchReply(sid, directory) {
297
392
  return (async () => {
298
- if (typeof api?.messages !== "function")
299
- return { text: "" };
393
+ if (typeof api?.messages !== "function") {
394
+ return { text: "", error: "这个宿主客户端没有给出读取子会话正文的缝(v2 上它是 ctx.session.context 的兼容层)" };
395
+ }
300
396
  try {
301
- const un = unwrapClientResult(await api.messages({ path: { id: sid }, ...(directory ? { query: { directory } } : {}) }));
302
- return un.ok ? lastAssistantMessage(un.data) : { text: "" };
397
+ const raw = await api.messages({ path: { id: sid }, ...(directory ? { query: { directory } } : {}) });
398
+ const un = unwrapClientResult(raw);
399
+ const got = un.ok ? lastAssistantMessage(un.data) : { text: "", error: un.message };
400
+ // The seam that ANSWERED, named by the seam itself. On v1 that is the host's
401
+ // `session.messages`; on v2 the same call is served by `ctx.session.context`, and
402
+ // printing "session.messages" there would credit an endpoint this host does not
403
+ // have — the class of claim this product exists to refuse.
404
+ const via = String(raw?.via ?? "session.messages");
405
+ return got.text ? { ...got, via } : { text: "", error: got.error, via };
303
406
  }
304
- catch {
305
- return { text: "" };
407
+ catch (err) {
408
+ return { text: "", error: describeHostError(err?.message ?? err, 140) };
306
409
  }
307
410
  })();
308
411
  }
309
- /**
310
- * The model the CHILD should run. A child session carries no model of its
311
- * own, and `prompt_async` accepts `model:{providerID,modelID}` — the
312
- * built-in `task` tool supplies one, we did not, and three dispatched
313
- * children died at launch with no readable reply (2026-09-19). Inherit the
314
- * lead's own last-used model from the parent transcript; when nothing can
315
- * be resolved, dispatch anyway and say so in the reply, so a failure is
316
- * never mysterious.
317
- */
318
- async function parentModel(parent, directory) {
319
- if (typeof api?.messages !== "function" || !parent)
320
- return undefined;
321
- try {
322
- const un = unwrapClientResult(await api.messages({ path: { id: parent }, ...(directory ? { query: { directory } } : {}) }));
323
- if (!un.ok || !Array.isArray(un.data))
324
- return undefined;
325
- for (let i = un.data.length - 1; i >= 0; i--) {
326
- const info = un.data[i]?.info;
327
- if (!info || String(info.role ?? "").toLowerCase() !== "assistant")
328
- continue;
329
- const providerID = typeof info.providerID === "string" ? info.providerID.trim() : "";
330
- const modelID = typeof info.modelID === "string" ? info.modelID.trim() : "";
331
- if (providerID && modelID)
332
- return { providerID, modelID };
333
- }
334
- }
335
- catch {
336
- /* no transcript readable — dispatch without a model and report it */
337
- }
338
- return undefined;
339
- }
340
412
  /**
341
413
  * Adopt the children the HOST lists under this parent but this process never
342
414
  * dispatched: the plugin instance restarted (or the lead resumed a session
@@ -345,22 +417,35 @@ export function buildDispatchTools(deps) {
345
417
  * tm_join's honest answer would be "没有待收集的派发" while a finished report
346
418
  * sits in the host, which is exactly the silent loss issue #7 was about.
347
419
  *
348
- * The discriminator is the title tm_dispatch itself writes (`dispatchTitle`
349
- * + a known agent), so a child spawned by the built-in `task` tool is never
350
- * claimed as ours.
420
+ * The discriminator is the title shape our removed `tm_dispatch` used to
421
+ * write (a recognised agent + the ` ·tm` marker) or the pre-1.5.15
422
+ * `tm:<agent>:<label>`, so a child spawned by the built-in `task` tool is
423
+ * never claimed as ours on speculation — see `claimNamedChild` for the one
424
+ * path that may take a host child, and the parentage check it must pass.
351
425
  */
352
426
  async function adoptFromHost(parent, directory, only) {
353
- if (typeof api?.children !== "function" || !parent)
354
- return 0;
427
+ // Three outcomes used to collapse into `0`: the host surface is absent (v2's
428
+ // client shim has no `session` domain), the call failed, and "we asked and
429
+ // there was nothing". The empty-round answer reads "宿主会话树里也没有可认领的
430
+ // 子会话", which is a claim about case three — asserted in cases one and two,
431
+ // where nobody looked. A lead that believes it will stop waiting for a report
432
+ // that exists, which is the silent loss this function exists to prevent.
433
+ // The reason names the MISSING SEAM, not a host generation: the same absence
434
+ // occurs on a v1-shaped client that lacks `session.messages`, and a sentence
435
+ // blaming "v2" there would be false in the opposite direction.
436
+ if (typeof api?.children !== "function")
437
+ return { adopted: 0, looked: false, why: "这个宿主客户端没有给出 session.children(可辨认的 session API 缺失),我没有看过会话树" };
438
+ if (!parent)
439
+ return { adopted: 0, looked: false, why: "调用方会话 id 缺失,无从查询子会话" };
355
440
  let un;
356
441
  try {
357
442
  un = unwrapClientResult(await api.children({ path: { id: parent }, ...(directory ? { query: { directory } } : {}) }));
358
443
  }
359
- catch {
360
- return 0;
444
+ catch (err) {
445
+ return { adopted: 0, looked: false, why: `查询会话树失败:${describeHostError(err)}` };
361
446
  }
362
447
  if (!un.ok || !Array.isArray(un.data))
363
- return 0;
448
+ return { adopted: 0, looked: false, why: "宿主返回了无法解析的会话树结果" };
364
449
  let adopted = 0;
365
450
  for (const raw of un.data) {
366
451
  const sid = String(raw?.id ?? "").trim();
@@ -387,7 +472,43 @@ export function buildDispatchTools(deps) {
387
472
  adopted++;
388
473
  log({ step_id: "join", event: "adopt", child: sid, agent: parsed.agent, label: rec.label });
389
474
  }
390
- return adopted;
475
+ return { adopted, looked: true, why: adopted ? "" : "会话树里没有被认领标题的子会话" };
476
+ }
477
+ /** Claim ONE explicitly named child of the calling session — the host's own
478
+ * `task` child included, which `adoptFromHost` must never pick up on its
479
+ * own. The lock is the host's parentage (`parentID === the caller`), read
480
+ * back rather than assumed: an id belonging to someone else stays a miss. */
481
+ async function claimNamedChild(sid, parent, directory) {
482
+ if (typeof api?.get !== "function" || !sid || !parent || children.has(sid))
483
+ return false;
484
+ let un;
485
+ try {
486
+ un = unwrapClientResult(await api.get({ path: { id: sid }, ...(directory ? { query: { directory } } : {}) }));
487
+ }
488
+ catch {
489
+ return false;
490
+ }
491
+ if (!un.ok)
492
+ return false;
493
+ const info = un.data;
494
+ if (String(info?.parentID ?? "").trim() !== parent)
495
+ return false;
496
+ const rawAgent = String(info?.agent ?? "").trim().toLowerCase();
497
+ const agent = targets.includes(rawAgent) ? rawAgent : "task";
498
+ const title = String(info?.title ?? "").trim();
499
+ const created = Number(info?.time?.created);
500
+ children.set(sid, {
501
+ sessionID: sid,
502
+ agent,
503
+ label: (title || "host task").slice(0, 40),
504
+ parentSessionID: parent,
505
+ startedAt: Number.isFinite(created) && created > 0 ? created : now(),
506
+ state: "running",
507
+ adopted: true,
508
+ });
509
+ deps.onChildSession?.(sid, agent);
510
+ log({ step_id: "join", event: "claim_host_task", child: sid, agent });
511
+ return true;
391
512
  }
392
513
  /** Settle verdict for adopted rows: `AssistantMessage.time.completed` is
393
514
  * only set once the reply is done, so it answers "finished while nobody
@@ -403,177 +524,16 @@ export function buildDispatchTools(deps) {
403
524
  }
404
525
  }
405
526
  }
406
- /**
407
- * How deep the caller's session sits: the host's task tool counts the
408
- * parentID chain and refuses at `subagent_depth` (default 1 = no nested
409
- * subagents). That check lives in the TOOL, not in the session API, so a
410
- * plugin-side dispatcher that skipped it would quietly let a user's nesting
411
- * limit be escaped. Walk it the same way; an unreachable session (no `get`
412
- * endpoint, deleted) stops the walk at what we know rather than allowing.
413
- */
414
- async function sessionDepth(sessionID, directory) {
415
- if (typeof api?.get !== "function" || !sessionID)
416
- return 0;
417
- let depth = 0;
418
- let current = sessionID;
419
- for (let hop = 0; hop < 16; hop++) {
420
- try {
421
- const un = unwrapClientResult(await api.get({ path: { id: current }, ...(directory ? { query: { directory } } : {}) }));
422
- const parentID = un.ok ? String(un.data?.parentID ?? "").trim() : "";
423
- if (!parentID)
424
- break;
425
- depth++;
426
- current = parentID;
427
- }
428
- catch {
429
- break;
430
- }
431
- }
432
- return depth;
433
- }
434
- const dispatch = {
435
- description: `Fire-and-continue sub-agent dispatch — the lead's parallelism lever (the built-in task tool BLOCKS you until the child finishes; this one does not).
436
- - { agent: ${targets.join("|")} , task: "<self-contained brief>", description: "<short slug>" } → starts the specialist in its OWN child session (parented to yours) and returns the child session id IMMEDIATELY. You keep working the same round: plan the next step, do a cheap read of your own, or fire more dispatches — independent work runs concurrently instead of serially.
437
- - The child is a real session: it appears under yours in the host's session tree (titled "<description> (@<agent> subagent ·tm)"), so the user can open it and watch it work. Its tool card is NOT expandable — the host only renders bodies for its own built-in tools — so say out loud which child is running and what it is for.
438
- - The brief must stand alone: the child has not seen this conversation, does not know what you tried, and cannot ask you mid-run. Say what to do, WHY it matters, which files/paths are its territory, what "done" looks like, and how much thoroughness you expect — the child still answers the mandatory STATUS/CHANGES/FINDINGS/EVIDENCE/HANDOFF skeleton.
439
- - Collect with tm_join (no wait = status snapshot, waitMs = bounded wait). Collected replies ride the offload pipeline: a long report arrives as a handle + preview, not a context flood.
440
- - Division of labour: bulk reading / searching / aggregation burns the CHILD's context and comes back as a skeleton; your own context is the scarce resource — keep it for routing, decisions and the final merge.
441
- - Only the team lead may call this; a child never dispatches (no nested teams). If the host exposes no async session API the call fails with an explicit "use the built-in task tool" directive.${parallel
442
- ? ""
443
- : `
444
- - TM_PARALLEL_DISPATCH=off is set: at most ONE child may run at a time, so a second dispatch while one is live is refused. Dispatch, tm_join it, then dispatch the next — and tell the user why the team is serial.`}`,
445
- args: {
446
- agent: { descriptor: `agent: ${targets.join("|")} (required)` },
447
- task: { descriptor: "task: string (required — the full self-contained brief the child works from)" },
448
- description: {
449
- descriptor: "description: string (optional ≤40-char slug — the child session's title and the line the UI shows for this call; defaults to the task's first words)",
450
- },
451
- },
452
- execute: async (rawArgs, ctx) => {
453
- const tool = "tm_dispatch";
454
- try {
455
- const args = (rawArgs ?? {});
456
- const c = (ctx ?? {});
457
- const caller = String(c.agent ?? "").trim();
458
- if (caller && caller !== leadAgent) {
459
- return toToolResult(tmError(tool, "governance", `只有 ${leadAgent} 可以派发子代理(当前 agent="${shorten(caller, 24)}")。T3 规则:子代理不再派子代理——把需要并行的工作交给 lead 编排。`));
460
- }
461
- const agent = String(args.agent ?? "").trim().toLowerCase();
462
- if (!targets.includes(agent)) {
463
- return toToolResult(tmError(tool, "args", `未知派发目标 "${shorten(agent, 30)}"——可用: ${targets.join(", ")}`));
464
- }
465
- const task = typeof args.task === "string" ? args.task.trim() : "";
466
- if (task.length < 20) {
467
- return toToolResult(tmError(tool, "args", "task 太短:子代理看不到本会话,派发说明必须自包含(目标、涉及文件、完成判据、期望深度)。"));
468
- }
469
- const parent = String(c.sessionID ?? "").trim();
470
- if (!parent) {
471
- return toToolResult(tmError(tool, "client", "缺少 sessionID(宿主未把会话上下文传给插件),无法建立父子会话。"));
472
- }
473
- if (!api) {
474
- return toToolResult(tmError(tool, "client", `此宿主未暴露异步会话 API(client.session.create/promptAsync),tm_dispatch 不可用——改用内置 task 工具(会阻塞你,但功能等价)。`));
475
- }
476
- // `description` is the host's own name for this field (the task tool
477
- // takes prompt + description), and the desktop reads exactly that key
478
- // as a tool card's subtitle — so the dispatch shows as
479
- // "Called tm_dispatch · 修登录页" instead of a raw-JSON chip.
480
- const label = (typeof args.description === "string" && args.description.trim()
481
- ? args.description.trim()
482
- : task.split(/\s+/).slice(0, 4).join(" ")).slice(0, 40);
483
- const directory = typeof c.directory === "string" && c.directory ? c.directory : undefined;
484
- // --- governance the built-in task tool applies and we must not lose -
485
- // (1) nesting ceiling, same 口径 as the host's subagent_depth
486
- const depth = await sessionDepth(parent, directory);
487
- if (depth >= maxDepth) {
488
- return toToolResult(tmError(tool, "governance", `已达子代理嵌套上限(当前深度 ${depth},TM_SUBAGENT_DEPTH=${maxDepth},与宿主 subagent_depth 同口径)。` +
489
- `这与 T3「子代理不再派子代理」同源:把这项工作留在你自己这一层,或让宿主配置放宽。`));
490
- }
491
- // (1b) TM_PARALLEL_DISPATCH=off — one child at a time. Refused BEFORE
492
- // the dialog (same ordering as tm_pty's concurrency cap): never ask the
493
- // user to approve a spawn the policy has already declined.
494
- if (!parallel) {
495
- const live = [...children.values()].filter((r) => r.state === "running" && r.parentSessionID === parent);
496
- if (live.length) {
497
- const who = live.map((r) => `${r.agent}「${r.label}」`).slice(0, 4).join("、");
498
- log({ step_id: "dispatch", event: "serial_refused", agent, label, live: live.length });
499
- return toToolResult(tmError(tool, "governance", `TM_PARALLEL_DISPATCH=off:一次只跑一个子代理,而你这轮已有 ${live.length} 个在跑(${who})。` +
500
- `先 tm_join 收掉再派 ${agent}。注意:并行才是 Team 的意义——串行是你选的模式,` +
501
- `要恢复默认值就删掉 TM_PARALLEL_DISPATCH 或设为 on。`));
502
- }
503
- }
504
- // (2) the user decides which sub-agent gets spawned — the task tool
505
- // asks ctx.ask({permission:"task", patterns:[subagent_type]}) and we
506
- // bypass that tool, so we ask on our own permission name. No bridge ⇒
507
- // refuse (never a silent pass), same rule as tm_pty / evaluate_script.
508
- if (askBeforeSpawn) {
509
- const outcome = await askUserForTarget(ctx, {
510
- permission: tool,
511
- patterns: [agent],
512
- metadata: { tool, subagent_type: agent, description: label },
513
- });
514
- log({ step_id: "dispatch", event: "consent", agent, label, verdict: outcome });
515
- if (outcome !== "approved") {
516
- return toToolResult(tmError(tool, "permission", outcome === "unavailable"
517
- ? `tm_dispatch 需要官方确认窗批准派工,本宿主没有 ask 桥——已拒绝。改用内置 task 工具(会阻塞你,但由宿主治理),或 TM_DISPATCH_ASK=off。`
518
- : `派工未获批准(${agent}),已拒绝创建子会话。请改用内置 task 工具,或向用户确认后再派。`));
519
- }
520
- }
521
- if (typeof api?.create !== "function" || typeof api?.promptAsync !== "function") {
522
- return toToolResult(tmError(tool, "client", "此宿主的 client.session 缺少 create/promptAsync——改用内置 task 工具。"));
523
- }
524
- // PROPERTY-ACCESS CALLS (api.create / api.promptAsync), never a
525
- // captured function reference — see refreshFromStatus above.
526
- const created = unwrapClientResult(await api.create({ body: { parentID: parent, title: dispatchTitle(agent, label) } }));
527
- if (!created.ok)
528
- return toToolResult(tmError(tool, "client", `创建子会话失败:${shorten(created.message ?? "", 120)}`));
529
- const sid = String(created.data?.id ?? "").trim();
530
- if (!sid)
531
- return toToolResult(tmError(tool, "client", "子会话创建后未返回 id"));
532
- const model = await parentModel(parent, directory);
533
- const promptBody = {
534
- agent,
535
- noReply: false,
536
- ...(model ? { model } : {}),
537
- parts: [{ type: "text", text: task }],
538
- };
539
- const sent = unwrapClientResult(await api.promptAsync({ path: { id: sid }, ...(directory ? { query: { directory } } : {}), body: promptBody }));
540
- if (!sent.ok) {
541
- return toToolResult(tmError(tool, "client", `子会话 ${sid} 已创建但提示未能启动:${shorten(sent.message ?? "", 120)}——可对该 id 直接用内置 task,或本次改用 task 工具。`));
542
- }
543
- const rec = { sessionID: sid, agent, label, parentSessionID: parent, startedAt: now(), state: "running" };
544
- children.set(sid, rec);
545
- deps.onChildSession?.(sid, agent);
546
- log({ step_id: "dispatch", event: "start", child: sid, parent: parent, agent, label, model: model ? `${model.providerID}/${model.modelID}` : "host-default" });
547
- // The child's tool card is a one-liner the user cannot open (a plugin
548
- // cannot register a renderer), so the toast is the only thing that
549
- // points at the live session — without it a dispatch just looks like a
550
- // stalled tool call.
551
- deps.notify?.(`子代理已启动:${agent}「${label}」— 在左侧会话树里展开本会话即可实时查看(子会话 ${sid})`);
552
- return toToolResult([
553
- `已派发(非阻塞):${agent} · "${label}" · 子会话 ${sid}`,
554
- model
555
- ? `模型沿用你当前的:${model.providerID}/${model.modelID}(子会话不自带模型)。`
556
- : `⚠ 未能从本会话记录里解析出模型,子会话将由宿主决定——若它启动即失败,改用内置 task 工具。`,
557
- `你现在可以继续:编排下一步、做廉价检查、或继续派发其它独立任务。`,
558
- `收集结果:tm_join(立即看状态)或 tm_join { waitMs: 60000 }。所有派发完成前不要提交结论。`,
559
- ].join("\n"));
560
- }
561
- catch (err) {
562
- const e = err;
563
- return toToolResult(tmError(tool, "execute", `tm_dispatch 失败:${describeHostError(e?.message ?? err, 160)}`));
564
- }
565
- },
566
- };
567
527
  const join = {
568
- description: `Collect what the async dispatches (tm_dispatch) have produced.
528
+ 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.
569
529
  - 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.
570
- - { waitMs: 30000 }: bounded wait (capped at ${Math.round(maxWaitMs / 1000)}s) until every child settles, then returns each one's reply skeleton. Never wait for a child whose result you do not need — abort it instead ({ cancel: true }).
571
- - { ids: [...] }: restrict to those child sessions; { cancel: true }: abort still-running ones (a runaway child is yours to stop, not the user's problem).
572
- - A plugin/host restart does NOT orphan a dispatch: children 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.
573
- - 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 five parallel dispatches do not multiply your context. STATUS: blocked/failed children are surfaced first, always.`,
530
+ - { 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 }).
531
+ - { 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.
532
+ - 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.
533
+ - 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.`,
574
534
  args: {
575
535
  ids: { descriptor: "ids: string[] (optional — only these child sessions)" },
576
- waitMs: { descriptor: "waitMs: number (optional 0..300000 — bounded wait before collecting)" },
536
+ waitMs: { descriptor: `waitMs: number (optional 0..${maxWaitMs} — bounded wait; a SECOND consecutive wait is cut to 10s, because waiting is not parallel work)` },
577
537
  cancel: { descriptor: "cancel: true (abort the still-running children in this set)" },
578
538
  includeText: { descriptor: "includeText: false to get only the status table (default true)" },
579
539
  },
@@ -587,23 +547,110 @@ export function buildDispatchTools(deps) {
587
547
  return toToolResult(tmError(tool, "governance", `只有 ${leadAgent} 能收集派发结果。`));
588
548
  }
589
549
  const parent = String(c.sessionID ?? "").trim();
550
+ // tm_stats counts calls from `event:"call"`, and tm_join only ever
551
+ // wrote its governed result — so the token table read "0 调用 / 2 结果",
552
+ // which looks like a broken counter rather than a tool that ran.
553
+ store.appendTrajectory({ tool, step_id: "join", event: "call" });
590
554
  const directory = typeof c.directory === "string" && c.directory ? c.directory : undefined;
591
555
  const idList = parseIdList(args.ids);
592
556
  const idFilter = idList && idList.length ? new Set(idList) : null;
593
557
  const owned = () => [...children.values()].filter((r) => (!parent || r.parentSessionID === parent) && (!idFilter || idFilter.has(r.sessionID)));
594
558
  let mine = owned();
559
+ // Whether we got to LOOK at the host's tree, and what it said — the empty
560
+ // answer must not claim "there is nothing to adopt" on a host we cannot
561
+ // query, so the fact travels with the attempt rather than being inferred.
562
+ let adoption = {
563
+ adopted: 0,
564
+ looked: false,
565
+ why: "本进程已经认全了要收的子代理,没有去查会话树",
566
+ };
595
567
  // This process has never seen some (or all) of what the lead is asking
596
568
  // for — the host's session tree, not our memory, is the authority.
597
569
  if (mine.length < (idFilter ? idFilter.size : 1)) {
598
- if (await adoptFromHost(parent, directory, idFilter))
570
+ adoption = await adoptFromHost(parent, directory, idFilter);
571
+ if (adoption.adopted)
599
572
  mine = owned();
573
+ // Plan B: a NAMED id may be the host's own `task` child (no ·tm
574
+ // marker, so the tree walk above never claims it). Collecting it is
575
+ // how the lead reads back a result we replaced with a pointer — the
576
+ // host's parentage check below replaces the title discriminator, and
577
+ // only an explicit request reaches this path.
578
+ if (idFilter) {
579
+ for (const sid of idFilter) {
580
+ if (children.has(sid))
581
+ continue;
582
+ if (await claimNamedChild(sid, parent, directory))
583
+ mine = owned();
584
+ }
585
+ }
600
586
  }
587
+ // #87: computed BEFORE any early return. The live regression of #86 was
588
+ // a lead holding a browser while tm_join answered "nothing to collect" —
589
+ // a SYNCHRONOUS host task never registers in this table (the host
590
+ // collects it inline), so the header assembly below never ran, and the
591
+ // window the user was looking at went unreported again. A forgotten
592
+ // browser has to surface on EVERY answer this tool gives.
593
+ const leaseLine = () => {
594
+ if (typeof deps.browserLeases !== "function")
595
+ return "";
596
+ let held = [];
597
+ try {
598
+ const done = new Set(mine.filter((r) => r.state !== "running").map((r) => r.sessionID));
599
+ held = deps.browserLeases().filter((l) => done.has(l.owner) || l.owner === parent);
600
+ }
601
+ catch {
602
+ /* the lease table is an extra, never a reason to fail a join */
603
+ return "";
604
+ }
605
+ const line = leaseTripwire(held, parent) ?? "";
606
+ if (line) {
607
+ log({
608
+ step_id: "join",
609
+ event: "lease_held",
610
+ count: held.length,
611
+ ids: held.map((l) => l.id).join(","),
612
+ });
613
+ }
614
+ return line;
615
+ };
601
616
  if (!mine.length) {
602
- return toToolResult(`没有待收集的派发${idFilter ? "(ids 未匹配到本会话的子代理,宿主会话树里也没有 tm: 标题的子会话)" : ""}。刚派发过却看不到?说明那次派发没成功——回到 tm_dispatch 的返回值检查。`);
617
+ const lease = leaseLine();
618
+ // The parenthetical is now chosen by whether we actually looked, so a
619
+ // host we cannot query no longer reports as "confirmed: nothing there".
620
+ const saw = adoption.looked;
621
+ const inner = idFilter
622
+ ? (() => {
623
+ // Naming the shapes the v2 session bridge tried is the whole point:
624
+ // without it, a claim that failed because `ctx.session.get` answered
625
+ // in a shape we do not know prints the same sentence as a tree we
626
+ // did query, and the lead goes back to waiting for a report that
627
+ // exists.
628
+ const sr = deps.sessionReaderReport?.();
629
+ const bridge = sr && sr.attempted > 0
630
+ ? `;我试过的 ctx.session 形状:${(sr.failedShapes.length ? sr.failedShapes.join("→") : sr.usedShape ?? "?")}${sr.lastError ? `(宿主最后一次的错误:${shorten(sr.lastError, 90)})` : "(宿主没有报错,只是没给出 parentID)"}`
631
+ : "";
632
+ return saw
633
+ ? `(ids 未匹配到本会话的子代理,宿主会话树里也没有可认领的子会话)${bridge}`
634
+ : `(ids 未匹配到;而且我没能查看宿主会话树:${adoption.why})${bridge}`;
635
+ })()
636
+ : saw
637
+ ? ""
638
+ : `(注意:${adoption.why},所以"确实没有子代理"这个结论我给不出)`;
639
+ return toToolResult(`没有待收集的派发${inner}。` +
640
+ // The old line asserted the dispatch had failed. Measured live: it
641
+ // had not — a completed host `task` with a full reply was sitting in
642
+ // the transcript, and the honest reason is that a synchronous host
643
+ // task never enters this registry at all.
644
+ `如果你用的是同步 \`task\`:宿主自己就把结果收回来了,它从不登记在这里,这不是派发失败。` +
645
+ `只有后台 \`task {background:true}\`(或显式带 ids)需要 tm_join 来取。` +
646
+ (lease ? `\n${lease}` : ""));
603
647
  }
604
648
  await settleAdopted(mine, directory);
605
649
  const waitMs = Math.max(0, Math.min(maxWaitMs, Number(args.waitMs) || 0));
606
- const deadline = now() + waitMs;
650
+ const prev = lastJoin.get(parent);
651
+ const { budget, repeat: repeatWait, streak } = joinBudget(waitMs, maxWaitMs, prev);
652
+ const waitStart = now();
653
+ const deadline = waitStart + budget;
607
654
  let settled = false;
608
655
  for (;;) {
609
656
  await refreshFromStatus(directory);
@@ -615,6 +662,16 @@ export function buildDispatchTools(deps) {
615
662
  await sleep(Math.min(1000, Math.max(100, deadline - now())));
616
663
  }
617
664
  const stillRunning = mine.filter((r) => r.state === "running");
665
+ lastJoin.set(parent, { at: now(), stillRunning: stillRunning.length, streak });
666
+ log({
667
+ step_id: "join",
668
+ event: "wait",
669
+ asked_ms: waitMs,
670
+ budget_ms: budget,
671
+ waited_ms: Math.max(0, now() - waitStart),
672
+ still_running: stillRunning.length,
673
+ repeat: repeatWait,
674
+ });
618
675
  if (args.cancel === true || args.cancel === "true") {
619
676
  for (const r of stillRunning) {
620
677
  if (typeof api?.abort === "function") {
@@ -631,28 +688,103 @@ export function buildDispatchTools(deps) {
631
688
  }
632
689
  const sums = summarizeStates(mine);
633
690
  const header = [
634
- `派发汇总:${sums.idle} 完成 / ${sums.running} 运行中 / ${sums.error} 失败${settled ? "(全部已结算)" : "(未等到全部结算,可再次 tm_join)"}`,
691
+ `派发汇总:${sums.idle} 完成 / ${sums.running} 运行中 / ${sums.error} 失败${settled ? "(全部已结算)" : `(未等到全部结算 · 本次已等 ${Math.round((now() - waitStart) / 1000)}s)`}`,
635
692
  ...mine.map((r) => renderChildLine(r, (r.finishedAt ?? now()) - r.startedAt)),
636
693
  ];
694
+ if (stillRunning.length && !settled) {
695
+ header.push(`⏱ 还有 ${stillRunning.length} 个子代理在跑 —— 先告诉用户你在等谁、在等什么、这一轮不是交付,再决定是等还是去干活。` +
696
+ `你的工具调用在界面上只是一行打不开的卡片,你不说,用户没法把"回合结束"和"任务完成"分开。`);
697
+ }
698
+ if (repeatWait && stillRunning.length) {
699
+ header.push(`⏳ 连续第 ${streak} 次等待,而且上一次也没等到结算 —— 这段等待里你什么都没做,它不是并行,是同步 task 的等价物。` +
700
+ `别再等:① 先做 lead 工作(合并骨架、路由下一个独立任务、你自己的验收项),做完再 tm_join;` +
701
+ `② 或就此收尾,把未收的派发逐条列成 open handoff 交给用户;` +
702
+ `③ 或 { cancel: true } 收掉已经不需要的那些。` +
703
+ `(宿主若开了 OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true,内置 task {background:true} 的卡片可以直接点开看实时进度。)`);
704
+ }
637
705
  // Goal tripwire, at the exact moment a lead tends to wrap up: every
638
706
  // child settled says nothing about the USER'S goal, and the host's own
639
707
  // todo list does. Read-only (GET /session/{id}/todo has no write
640
708
  // body), so this can remind but never rewrite.
641
- if (settled && typeof api?.todo === "function" && parent) {
642
- try {
643
- const un = unwrapClientResult(await api.todo({ path: { id: parent }, ...(directory ? { query: { directory } } : {}) }));
644
- const open = un.ok ? openHostTodos(un.data) : [];
645
- if (open.length) {
646
- header.push(`⚠ 目标未达成:宿主 todolist 还有 ${open.length} 项未完成 —— ${open.slice(0, 6).map((t) => `「${t.content}」(${t.status})`).join("、")}` +
647
- (open.length > 6 ? ` …+${open.length - 6}` : "") +
648
- `\n按目标指令:要么继续做掉,要么向用户写明哪一条被什么卡住;不要把这轮当成收尾。`);
649
- log({ step_id: "join", event: "goal_open", count: open.length });
709
+ //
710
+ // The check has THREE outcomes and all three must be sayable. Silence when
711
+ // the seam is missing (v2's client shim has no `todo`) read as "the goal
712
+ // check ran and passed", which is the same overstatement this tool was
713
+ // already fixed for on the adoption path — a settled round is exactly when
714
+ // an unchecked box matters most.
715
+ if (settled) {
716
+ const canCheck = typeof api?.todo === "function" && Boolean(parent);
717
+ let checked = false;
718
+ let ledgerEmpty = false;
719
+ if (canCheck) {
720
+ try {
721
+ const un = unwrapClientResult(await api.todo({ path: { id: parent }, ...(directory ? { query: { directory } } : {}) }));
722
+ checked = un.ok;
723
+ const open = un.ok ? openHostTodos(un.data) : [];
724
+ if (open.length) {
725
+ header.push(`⚠ 目标未达成:宿主 todolist 还有 ${open.length} 项未完成 —— ${open.slice(0, 6).map((t) => `「${t.content}」(${t.status})`).join("、")}` +
726
+ (open.length > 6 ? ` …+${open.length - 6}` : "") +
727
+ `\n按目标指令:要么继续做掉,要么向用户写明哪一条被什么卡住;不要把这轮当成收尾。` +
728
+ // v1 has the built-in `todowrite`; a host without it must not be
729
+ // told to call a tool it cannot call (the tm_ptc_run / `task`
730
+ // lesson a third time). Without the write seam the only honest
731
+ // move is to say which items are done in the reply.
732
+ (typeof api?.todo === "function"
733
+ ? `(若这些项其实已经做完——例如你刚把派发结果收齐——先用 todowrite 更新状态,再收尾。)`
734
+ : `(这个宿主没给写清单的入口:若这些项其实已完成,就在回复里逐条写明哪条做完了、依据是什么。)`));
735
+ log({ step_id: "join", event: "goal_open", count: open.length });
736
+ }
737
+ }
738
+ catch {
739
+ checked = false;
740
+ /* the todo endpoint is an extra, never a reason to fail a join */
650
741
  }
651
742
  }
652
- catch {
653
- /* the todo endpoint is an extra, never a reason to fail a join */
743
+ if (!checked && deps.ledgerStore?.available?.() && parent) {
744
+ // v2 has no `session.todo` to read, so the list that CAN be checked is
745
+ // the one this plugin keeps (`tm_ledger`, in ctx.storage). A non-empty
746
+ // open set is a fact worth refusing a wrap-up over; an EMPTY one is not
747
+ // evidence the goal was met — it usually means nothing was ever
748
+ // recorded, which is its own honest "cannot check".
749
+ try {
750
+ const led = await deps.ledgerStore.load(parent);
751
+ const rest = led ? openItems(led) : [];
752
+ if (rest.length) {
753
+ const line = ledgerGoalLine(led);
754
+ if (line)
755
+ header.push(line);
756
+ log({ step_id: "join", event: "goal_open", count: rest.length, source: "ledger" });
757
+ checked = true;
758
+ }
759
+ else if (led) {
760
+ ledgerEmpty = true;
761
+ }
762
+ }
763
+ catch {
764
+ /* the ledger is an extra too — a store that will not read never fails a join */
765
+ }
766
+ }
767
+ if (!checked) {
768
+ header.push(canCheck
769
+ ? "(目标核对没做成:宿主 todo 端点返回异常,这一轮我没有看到你的清单状态。)"
770
+ : ledgerEmpty
771
+ ? "(目标核对没做成:这个宿主没给 session.todo 端点,而 tm_ledger 里一条都没有——没有可核对的目标状态。`所有子代理已结算` 不等于 `目标已达成`:要么先 tm_ledger { action:\"add\" } 把要求记下来,要么在回复里自己逐条核对再收尾。)"
772
+ : "(目标核对没做成:这个宿主没给 session.todo 端点,我也读不到本插件的 tm_ledger——无法读取清单状态。`所有子代理已结算` 不等于 `目标已达成`,这一轮请自己核对再收尾。)");
773
+ log({
774
+ step_id: "join",
775
+ event: "goal_unchecked",
776
+ reason: canCheck ? "endpoint_failed" : ledgerEmpty ? "ledger_empty" : "no_seam",
777
+ });
654
778
  }
655
779
  }
780
+ // #80/#86/#87: THE LEASE TRIPWIRE — one computation (leaseLine above),
781
+ // attached to every answer this tool gives, so a settled round and an
782
+ // empty one cannot disagree about whether a window is still open.
783
+ if (settled) {
784
+ const lease = leaseLine();
785
+ if (lease)
786
+ header.push(lease);
787
+ }
656
788
  const wantText = !(args.includeText === false || args.includeText === "false");
657
789
  const collectible = mine.filter((r) => r.state !== "running");
658
790
  const blocks = [];
@@ -660,9 +792,16 @@ export function buildDispatchTools(deps) {
660
792
  for (const r of collectible) {
661
793
  const reply = await fetchReply(r.sessionID, directory);
662
794
  const text = reply.text;
663
- blocks.push(`--- ${r.agent} "${r.label}" (${r.sessionID}) ---\n${text.trim() ||
664
- `(该子会话没有可读的助手回复${r.error ? `;宿主错误:${r.error}` : reply.error ? `;宿主错误:${reply.error}` : ""})` +
665
- `——子会话不是文件,tm_read 读不到它:在宿主的会话面板里看这条子会话,或改用内置 task 工具重做这一步。`}`);
795
+ const src = text && reply.via ? ` ·正文来源=${reply.via}` : "";
796
+ // Which seam answered, per child — an id and a seam name, never a body (R6 binds
797
+ // a diagnostic too). This is the line that makes "正文来源=…" checkable later.
798
+ log({ step_id: "join", event: text ? "child_body" : "child_body_missing", child: r.sessionID, via: reply.via ?? "none" });
799
+ blocks.push(`--- ${r.agent} "${r.label}" (${r.sessionID})${src} ---\n${text.trim() ||
800
+ (r.via === "host-injection"
801
+ ? `(这个子会话由宿主的 subagent 工具派发,而这一次我没有读到它的正文${reply.error ? `:${reply.error}` : ":这个宿主没给可读正文的缝"}。完成时宿主仍会把 \`<subagent sessionID=\"${r.sessionID}\" …>\` 注入本会话,我已经把它登记在案、状态如上。` +
802
+ `要全文就在那条注入到达后读它,或让子代理把交付写进黑板;不要为了拿正文反复 join。)`
803
+ : `(该子会话没有可读的助手回复${r.error ? `;宿主错误:${r.error}` : reply.error ? `;宿主错误:${reply.error}` : ""})` +
804
+ `——子会话不是文件,tm_read 读不到它:在宿主的会话面板里看这条子会话,或改用内置 task 工具重做这一步。`)}`);
666
805
  }
667
806
  }
668
807
  const body = [...header, ...(blocks.length ? ["", blocks.join("\n")] : [])].join("\n");
@@ -687,12 +826,31 @@ export function buildDispatchTools(deps) {
687
826
  if (!sid)
688
827
  return;
689
828
  const rec = children.get(sid);
690
- if (!rec)
829
+ if (!rec) {
830
+ // The parent's own idle is the only completion signal v2 gives us for a host
831
+ // child: the background reply is injected into the parent before the parent's turn
832
+ // ends (measured: child injected …824730, parent idle …829038), and the plugin has
833
+ // no endpoint to read a child session's state. So settle the still-running host
834
+ // children of THIS session — and say plainly that it is a presumption.
835
+ if (type === "session.idle") {
836
+ for (const child of children.values()) {
837
+ if (child.via !== "host-injection" || child.state !== "running")
838
+ continue;
839
+ if (child.parentSessionID !== sid)
840
+ continue;
841
+ child.state = "idle";
842
+ child.finishedAt = now();
843
+ child.settleSource = "parent-idle";
844
+ log({ step_id: "events", event: "idle_presumed", child: child.sessionID, agent: child.agent, ms: child.finishedAt - child.startedAt });
845
+ }
846
+ }
691
847
  return;
848
+ }
692
849
  if (type === "session.idle") {
693
850
  if (rec.state === "running") {
694
851
  rec.state = "idle";
695
852
  rec.finishedAt = now();
853
+ rec.settleSource = "event";
696
854
  log({ step_id: "events", event: "idle", child: sid, agent: rec.agent, ms: (rec.finishedAt ?? 0) - rec.startedAt });
697
855
  }
698
856
  return;
@@ -717,10 +875,54 @@ export function buildDispatchTools(deps) {
717
875
  }
718
876
  }
719
877
  };
878
+ /** Open a row for a child the HOST dispatched (v2's `subagent` tool) that this
879
+ * registry never created. Returns false when the ids are unusable or the child is
880
+ * already known — a plugin reload replays hooks, and a second row for one session
881
+ * would make tm_join count the same work twice. */
882
+ function register(child) {
883
+ const rec = hostChildRecord(child, now);
884
+ if (!rec || children.has(rec.sessionID))
885
+ return false;
886
+ children.set(rec.sessionID, rec);
887
+ deps.onChildSession?.(rec.sessionID, rec.agent);
888
+ // The label is model-authored free text: it belongs in the lead's table, never in
889
+ // the audit trail (R6 binds a diagnostic as much as a guard).
890
+ log({ step_id: "join", event: "host_subagent", child: rec.sessionID, agent: rec.agent, parent: rec.parentSessionID });
891
+ return true;
892
+ }
893
+ /** Settle a child from the host's OWN completion envelope (see
894
+ * `applyV2CompletionWatch`). Returns false when this process does not know the id, so
895
+ * an unrelated session's injection never touches our registry. This is the third
896
+ * settle source and the only one that works mid-turn on v2: the child's own
897
+ * `session.idle` is not forwarded to a plugin subscriber (measured 2026-09-26), and a
898
+ * parent-idle presumption cannot fire while the parent is still working — which left a
899
+ * child whose report had already been injected showing 运行中. */
900
+ function settle(sessionID, state) {
901
+ const rec = children.get(String(sessionID ?? "").trim());
902
+ if (!rec || rec.state !== "running")
903
+ return false;
904
+ rec.finishedAt = now();
905
+ if (state === "error" || state === "failed" || state === "aborted") {
906
+ rec.state = "error";
907
+ rec.error = `宿主报告子会话 ${state}(其正文随注入消息送达)`;
908
+ }
909
+ else {
910
+ rec.state = "idle";
911
+ }
912
+ rec.settleSource = "injection";
913
+ log({ step_id: "events", event: "idle_injection", child: rec.sessionID, agent: rec.agent, ms: rec.finishedAt - rec.startedAt });
914
+ return true;
915
+ }
720
916
  return {
721
- tm_dispatch: dispatch,
722
917
  tm_join: join,
723
918
  observeEvent,
919
+ register,
920
+ settle,
921
+ /** Anything still running? The completion scan is O(messages) per request, so it
922
+ * asks this first and skips the read when the answer is no. */
923
+ hasOpen: () => { for (const c of children.values())
924
+ if (c.state === "running")
925
+ return true; return false; },
724
926
  children: () => [...children.values()],
725
927
  };
726
928
  }