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

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 (101) hide show
  1. package/README.md +788 -674
  2. package/README.zh-CN.md +712 -618
  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-hooks.d.ts +13 -2
  11. package/dist/host-hooks.d.ts.map +1 -1
  12. package/dist/host-hooks.js +19 -5
  13. package/dist/host-hooks.js.map +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +54 -14
  16. package/dist/index.js.map +1 -1
  17. package/dist/prompts/lead.d.ts +1 -1
  18. package/dist/prompts/lead.d.ts.map +1 -1
  19. package/dist/prompts/lead.js +135 -17
  20. package/dist/prompts/lead.js.map +1 -1
  21. package/dist/prompts/shared.d.ts +2 -2
  22. package/dist/prompts/shared.d.ts.map +1 -1
  23. package/dist/prompts/shared.js +94 -16
  24. package/dist/prompts/shared.js.map +1 -1
  25. package/dist/prompts/specialists.d.ts +2 -2
  26. package/dist/prompts/specialists.d.ts.map +1 -1
  27. package/dist/prompts/specialists.js +43 -10
  28. package/dist/prompts/specialists.js.map +1 -1
  29. package/dist/task-offload.d.ts +67 -0
  30. package/dist/task-offload.d.ts.map +1 -0
  31. package/dist/task-offload.js +123 -0
  32. package/dist/task-offload.js.map +1 -0
  33. package/dist/tm/args-schema.d.ts +4 -0
  34. package/dist/tm/args-schema.d.ts.map +1 -1
  35. package/dist/tm/args-schema.js +44 -3
  36. package/dist/tm/args-schema.js.map +1 -1
  37. package/dist/tm/board.d.ts +113 -0
  38. package/dist/tm/board.d.ts.map +1 -0
  39. package/dist/tm/board.js +275 -0
  40. package/dist/tm/board.js.map +1 -0
  41. package/dist/tm/browser.d.ts +325 -6
  42. package/dist/tm/browser.d.ts.map +1 -1
  43. package/dist/tm/browser.js +1329 -135
  44. package/dist/tm/browser.js.map +1 -1
  45. package/dist/tm/config.d.ts +36 -1
  46. package/dist/tm/config.d.ts.map +1 -1
  47. package/dist/tm/config.js +29 -1
  48. package/dist/tm/config.js.map +1 -1
  49. package/dist/tm/dispatch.d.ts +118 -31
  50. package/dist/tm/dispatch.d.ts.map +1 -1
  51. package/dist/tm/dispatch.js +385 -131
  52. package/dist/tm/dispatch.js.map +1 -1
  53. package/dist/tm/dupe-guard.js +1 -1
  54. package/dist/tm/dupe-guard.js.map +1 -1
  55. package/dist/tm/egress.d.ts +44 -0
  56. package/dist/tm/egress.d.ts.map +1 -0
  57. package/dist/tm/egress.js +191 -0
  58. package/dist/tm/egress.js.map +1 -0
  59. package/dist/tm/guard.js +1 -1
  60. package/dist/tm/guard.js.map +1 -1
  61. package/dist/tm/index.d.ts +28 -2
  62. package/dist/tm/index.d.ts.map +1 -1
  63. package/dist/tm/index.js +198 -10
  64. package/dist/tm/index.js.map +1 -1
  65. package/dist/tm/perm-ask.d.ts +47 -5
  66. package/dist/tm/perm-ask.d.ts.map +1 -1
  67. package/dist/tm/perm-ask.js +98 -7
  68. package/dist/tm/perm-ask.js.map +1 -1
  69. package/dist/tm/pipelines.d.ts.map +1 -1
  70. package/dist/tm/pipelines.js +24 -2
  71. package/dist/tm/pipelines.js.map +1 -1
  72. package/dist/tm/ptc/tool.d.ts.map +1 -1
  73. package/dist/tm/ptc/tool.js +2 -4
  74. package/dist/tm/ptc/tool.js.map +1 -1
  75. package/dist/tm/pty.d.ts.map +1 -1
  76. package/dist/tm/pty.js +6 -3
  77. package/dist/tm/pty.js.map +1 -1
  78. package/dist/tm/search.d.ts +5 -0
  79. package/dist/tm/search.d.ts.map +1 -1
  80. package/dist/tm/search.js +19 -17
  81. package/dist/tm/search.js.map +1 -1
  82. package/dist/tm/stats.d.ts +46 -0
  83. package/dist/tm/stats.d.ts.map +1 -1
  84. package/dist/tm/stats.js +124 -7
  85. package/dist/tm/stats.js.map +1 -1
  86. package/dist/tm/store.d.ts +3 -0
  87. package/dist/tm/store.d.ts.map +1 -1
  88. package/dist/tm/store.js +6 -1
  89. package/dist/tm/store.js.map +1 -1
  90. package/dist/tm/webfetch.d.ts +52 -0
  91. package/dist/tm/webfetch.d.ts.map +1 -1
  92. package/dist/tm/webfetch.js +128 -22
  93. package/dist/tm/webfetch.js.map +1 -1
  94. package/dist/tool-coerce.d.ts +24 -0
  95. package/dist/tool-coerce.d.ts.map +1 -0
  96. package/dist/tool-coerce.js +63 -0
  97. package/dist/tool-coerce.js.map +1 -0
  98. package/dist/types.d.ts +7 -1
  99. package/dist/types.d.ts.map +1 -1
  100. package/docs/installation.md +69 -2
  101. package/package.json +61 -59
@@ -1,29 +1,33 @@
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";
@@ -32,35 +36,131 @@ import { shorten } from "./config.js";
32
36
  /** The five dispatchable specialists — "team" is deliberately NOT in the
33
37
  * list: a lead spawning a lead is the nesting T3 closed. */
34
38
  export const DISPATCH_TARGETS = ["architect", "implementer", "reviewer", "tester", "researcher"];
35
- /** Resolve the host session API defensively — a missing namespace means the
36
- * host does not speak this protocol, and the caller must degrade. */
39
+ export function openHostTodos(todos) {
40
+ if (!Array.isArray(todos))
41
+ return [];
42
+ const open = [];
43
+ for (const t of todos) {
44
+ const status = String(t?.status ?? "").toLowerCase();
45
+ if (!status || status === "completed" || status === "cancelled" || status === "done")
46
+ continue;
47
+ const content = String(t?.content ?? "").trim();
48
+ if (content)
49
+ open.push({ content: content.slice(0, 120), status });
50
+ }
51
+ return open;
52
+ }
53
+ /** #80: the line tm_join adds when a SETTLED child still owns a browser window.
54
+ * Takes only the leases already filtered to settled children, so the ownership
55
+ * rule stays where the child registry is; this function's whole job is to say
56
+ * it in the one shape that survives the lead's skimming — and to refuse to say
57
+ * it for a window that was never ours. */
58
+ export function leaseTripwire(held, callerSessionID) {
59
+ if (!held.length)
60
+ return null;
61
+ const isMine = (l) => !!callerSessionID && l.owner === callerSessionID;
62
+ const mine = held.filter(isMine);
63
+ const theirs = held.filter((l) => !isMine(l));
64
+ const at = (l) => `空闲 ${Math.round(l.idleMs / 1000)}s`;
65
+ const parts = [];
66
+ if (theirs.length) {
67
+ parts.push(`⚠ ${theirs.length} 个浏览器还开着(子代理已结算,但它没 close):` +
68
+ theirs.map((l) => `${l.id}(${l.agent || "未知角色"} · ${at(l)})`).join("、") +
69
+ `\n要么让它 close 并引用工具自己的三种裁决之一(已确认关闭 / 进程未核验 / 警告:关闭未完全成功),` +
70
+ `要么由你向用户写明为什么留着。不要替它写"已关闭"——那是把没人核实过的结论交付出去。`);
71
+ }
72
+ // The caller's own window is the one the user is looking at, and the live
73
+ // recheck proved this branch was the missing one: a lead that keeps a browser
74
+ // across rounds is legitimate, so this is a reminder, not an accusation —
75
+ // but silence is the defect.
76
+ for (const l of mine) {
77
+ parts.push(`(你自己还占着 ${l.id},${at(l)}。收尾前 action:"close" 并引用工具的裁决句;` +
78
+ `如果确实要跨轮留着,就在回复里向用户说明为什么。)`);
79
+ }
80
+ return parts.join("\n");
81
+ }
82
+ /** Resolve the host session API defensively — `messages` is what tm_join
83
+ * cannot work without (the collected reply); a host without it means the
84
+ * caller must degrade, and `create`/`promptAsync` are deliberately NOT part
85
+ * of this contract any more: nothing here spawns sessions. */
37
86
  export function sessionApiOf(client) {
38
87
  const session = client?.session;
39
88
  if (!session || typeof session !== "object")
40
89
  return null;
41
90
  const api = session;
42
- if (typeof api.create !== "function" || typeof api.promptAsync !== "function")
91
+ if (typeof api.messages !== "function")
43
92
  return null;
44
93
  return api;
45
94
  }
46
- /** The title `tm_dispatch` gives every child session. `parseDispatchTitle`
47
- * is the discriminator on the way back: a child the host lists but this
48
- * process never created is OURS only if this shape (and a known agent)
49
- * matches — otherwise it belongs to the built-in `task` tool and we must
50
- * not claim its reply. */
51
- export function dispatchTitle(agent, label) {
52
- return `tm:${agent}:${label}`;
95
+ /** The host's OWN visible-and-non-blocking sub-agent path: its built-in `task`
96
+ * takes `background: true`, and because that card is rendered by the host's
97
+ * own `task` renderer it links to the live child session — which a plugin tool
98
+ * card structurally cannot do. Gated behind a runtime flag (verified in the
99
+ * desktop binary): `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS`, falling back
100
+ * to the umbrella `OPENCODE_EXPERIMENTAL`. We read the SAME env the host
101
+ * reads — our plugin runs inside that process — so this is a fact, not a
102
+ * guess. */
103
+ export function hostBackgroundSubagentsEnabled(env = process.env) {
104
+ const truthy = (v) => {
105
+ const s = typeof v === "string" ? v.trim().toLowerCase() : "";
106
+ if (!s)
107
+ return null;
108
+ return s === "1" || s === "true" || s === "yes" || s === "on";
109
+ };
110
+ return truthy(env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS) ?? truthy(env.OPENCODE_EXPERIMENTAL) ?? false;
111
+ }
112
+ /** A wait budget spelled the way a human reads it (never "0s" for 300 ms). */
113
+ function waitLabel(ms) {
114
+ return ms >= 1000 ? `${Math.round(ms / 1000)}s` : `${ms}ms`;
53
115
  }
116
+ /** What a tm_join wait may actually cost. The FIRST bounded wait gets the
117
+ * requested budget (clamped to TM_JOIN_MAX_WAIT_MS); a wait that follows a
118
+ * wait which settled nothing gets cut to REPEAT_WAIT_MS, because the second
119
+ * one is the shape of a lead parking instead of working — measured live at
120
+ * 2 × 300 s of nothing while six children ran. */
121
+ export const REPEAT_WAIT_MS = 10_000;
122
+ export function joinBudget(waitMs, maxWaitMs, prev) {
123
+ const clamped = Math.max(0, Math.min(maxWaitMs, waitMs));
124
+ const repeat = !!prev && prev.stillRunning > 0 && clamped > 0;
125
+ const streak = clamped === 0 ? (prev?.streak ?? 0) : repeat ? (prev?.streak ?? 1) + 1 : 1;
126
+ return { budget: repeat ? Math.min(clamped, REPEAT_WAIT_MS) : clamped, repeat, streak };
127
+ }
128
+ /** Recognise the title a `tm_dispatch` child used to carry — the tool is no
129
+ * longer registered (a plugin-spawned child is a session the user can neither
130
+ * open from a card nor stop from the UI), but children created before the
131
+ * change are still live in the host's session tree and tm_join must still
132
+ * collect them.
133
+ *
134
+ * The shape was the host's OWN subagent convention — the built-in task tool
135
+ * titles its children `${description} (@${agent} subagent)` (verified in the
136
+ * desktop binary), which is what the renderer's `taskSession()` heuristic
137
+ * reads to colour a child row. Ours added a ` ·tm` marker inside that shape,
138
+ * and the marker is LOAD-BEARING in both directions: it is what tells a
139
+ * leftover dispatch apart from a host `task` child, so an automatic tree walk
140
+ * never claims a reply that belongs to the host's dispatcher. The pre-1.5.15
141
+ * shape (`tm:<agent>:<label>`) still parses for the same reason — a session
142
+ * that exists must stay collectable. A child named explicitly by id takes a
143
+ * different path (`claimNamedChild`), where the lock is the host's own
144
+ * parentage rather than the title. */
54
145
  export function parseDispatchTitle(title, targets) {
55
146
  const t = typeof title === "string" ? title.trim() : "";
56
- if (!t.startsWith("tm:"))
147
+ if (t.startsWith("tm:")) {
148
+ const rest = t.slice(3);
149
+ const colon = rest.indexOf(":");
150
+ const legacyAgent = (colon < 0 ? rest : rest.slice(0, colon)).trim().toLowerCase();
151
+ if (!legacyAgent || !targets.includes(legacyAgent))
152
+ return null;
153
+ return { agent: legacyAgent, label: (colon < 0 ? "" : rest.slice(colon + 1)).trim().slice(0, 40) };
154
+ }
155
+ // The marker is required here — without it a built-in task child would read
156
+ // as ours, which is the one thing the restart path must never do.
157
+ const m = /^(.*?) \(@([^\s()]+) subagent ·tm\)$/.exec(t);
158
+ if (!m)
57
159
  return null;
58
- const rest = t.slice(3);
59
- const colon = rest.indexOf(":");
60
- const agent = (colon < 0 ? rest : rest.slice(0, colon)).trim().toLowerCase();
160
+ const agent = m[2].trim().toLowerCase();
61
161
  if (!agent || !targets.includes(agent))
62
162
  return null;
63
- return { agent, label: (colon < 0 ? "" : rest.slice(colon + 1)).trim().slice(0, 40) };
163
+ return { agent, label: m[1].trim().slice(0, 40) };
64
164
  }
65
165
  /**
66
166
  * Pull the assistant reply text (and, when the host records it, the moment
@@ -83,12 +183,15 @@ export function lastAssistantMessage(messages) {
83
183
  continue;
84
184
  const completed = Number(info?.time?.completed);
85
185
  const completedAt = Number.isFinite(completed) && completed > 0 ? completed : undefined;
186
+ // an assistant turn that errored carries the reason on info.error — the
187
+ // lead must see it, or "no reply" reads as "the agent had nothing to say"
188
+ const error = info?.error ? describeHostError(info.error, 200) : undefined;
86
189
  const parts = entry?.parts;
87
190
  if (!Array.isArray(parts)) {
88
191
  // a bare message list (no {info,parts} envelope) — take any text part
89
192
  const one = entry ?? {};
90
193
  if (one.type === "text" && typeof one.text === "string")
91
- return { text: one.text, completedAt };
194
+ return { text: one.text, ...(completedAt ? { completedAt } : {}), ...(error ? { error } : {}) };
92
195
  continue;
93
196
  }
94
197
  const texts = parts
@@ -97,8 +200,12 @@ export function lastAssistantMessage(messages) {
97
200
  return pp && pp.type === "text" && typeof pp.text === "string" && pp.text.trim() !== "";
98
201
  })
99
202
  .map((p) => String(p.text));
203
+ // keys are added only when present so a deep-equality assertion sees the
204
+ // shape the caller actually got (no `error: undefined` noise)
100
205
  if (texts.length)
101
- return { text: texts.join("\n"), completedAt };
206
+ return { text: texts.join("\n"), ...(completedAt ? { completedAt } : {}), ...(error ? { error } : {}) };
207
+ if (error)
208
+ return { text: "", ...(completedAt ? { completedAt } : {}), error };
102
209
  }
103
210
  return { text: "" };
104
211
  }
@@ -106,6 +213,48 @@ export function lastAssistantMessage(messages) {
106
213
  export function lastAssistantText(messages) {
107
214
  return lastAssistantMessage(messages).text;
108
215
  }
216
+ /**
217
+ * Turn whatever the host puts in an error slot into ONE readable line.
218
+ *
219
+ * The live host nests them (`{name, data:{message, ref}}`, `{error:{message}}`,
220
+ * a bare string — sometimes several at once), and `String(obj)` is
221
+ * "[object Object]". That is what a lead reported to a user for three child
222
+ * sessions that died at launch: the one diagnostic the whole flow had was
223
+ * destroyed by our own rendering. Never again: dig the known fields, then
224
+ * fall back to a compact key dump — never a bare object stringify.
225
+ */
226
+ export function describeHostError(err, max = 220) {
227
+ if (typeof err === "string")
228
+ return shorten(err.trim() || "session.error", max);
229
+ if (err === null || err === undefined)
230
+ return "session.error";
231
+ if (typeof err !== "object")
232
+ return shorten(String(err), max);
233
+ const o = err;
234
+ const box = (v) => v && typeof v === "object" ? v : undefined;
235
+ const data = box(o.data);
236
+ const inner = box(o.error);
237
+ const pick = (...cands) => {
238
+ for (const c of cands)
239
+ if (typeof c === "string" && c.trim())
240
+ return c.trim();
241
+ return "";
242
+ };
243
+ const message = pick(o.message, data?.message, inner?.message, inner?.name, data?.ref ? `ref=${String(data.ref)}` : "");
244
+ const name = pick(o.name, inner?.name, data?.name);
245
+ const ref = pick(data?.ref, o.ref);
246
+ if (message || name) {
247
+ const head = name && message && !message.startsWith(name) ? `${name}: ${message}` : message || name;
248
+ return shorten(ref && !head.includes(ref) ? `${head}(ref=${ref})` : head, max);
249
+ }
250
+ // nothing recognised — dump the SHAPE with short values, which is still
251
+ // infinitely more useful than "[object Object]"
252
+ const flat = Object.entries(o)
253
+ .slice(0, 6)
254
+ .map(([k, v]) => `${k}=${typeof v === "object" && v !== null ? Object.keys(v).slice(0, 4).join("+") : String(v).slice(0, 60)}`)
255
+ .join(" ");
256
+ return shorten(flat || "session.error", max);
257
+ }
109
258
  /** One child's status line — the lead reads these, so keep them terse. */
110
259
  export function renderChildLine(c, elapsedMs) {
111
260
  const secs = Math.round(elapsedMs / 1000);
@@ -138,14 +287,47 @@ function sleep(ms) {
138
287
  t.unref?.();
139
288
  });
140
289
  }
290
+ /** `ids` arrives in three shapes on the live host: a real array, a
291
+ * JSON-array STRING (`"[\"ses_x\"]"` — what a model actually sent on
292
+ * 2026-09-19), or a comma-separated list. Array.isArray alone silently
293
+ * ignored the string form and returned EVERY child when the lead asked for
294
+ * one, which is the same class of bug as the old `Boolean("false")` headless
295
+ * trap: the arg is trusted because it "looks" like the declared type. */
296
+ export function parseIdList(raw) {
297
+ if (Array.isArray(raw))
298
+ return raw.map((x) => String(x ?? "").trim()).filter(Boolean);
299
+ const s = typeof raw === "string" ? raw.trim() : "";
300
+ if (!s)
301
+ return null;
302
+ if (s.startsWith("[") && s.endsWith("]")) {
303
+ try {
304
+ const parsed = JSON.parse(s);
305
+ if (Array.isArray(parsed))
306
+ return parsed.map((x) => String(x ?? "").trim()).filter(Boolean);
307
+ }
308
+ catch {
309
+ /* fall through to the comma split */
310
+ }
311
+ }
312
+ return s.split(",").map((x) => x.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
313
+ }
141
314
  export function buildDispatchTools(deps) {
142
315
  const leadAgent = deps.leadAgent ?? "team";
143
316
  const targets = deps.targets ?? DISPATCH_TARGETS;
144
- const maxWaitMs = deps.maxWaitMs ?? 300_000;
317
+ const maxWaitMs = deps.maxWaitMs ?? 60_000;
145
318
  const now = deps.now ?? (() => Date.now());
146
319
  const store = deps.pipelines.store;
320
+ // The trajectory label stays `tm_dispatch` after the tool's death on purpose:
321
+ // tm_stats reads it, and adopt/cancel/wait events from a 1.5.15 run must stay
322
+ // comparable with this one. Renaming would split the history in two.
147
323
  const log = (e) => store.appendTrajectory({ tool: "tm_dispatch", ...e });
148
324
  const children = new Map();
325
+ /** Per-parent memory of the LAST join's outcome. A wait is not parallelism
326
+ * — while tm_join is in flight the lead's turn is blocked exactly like a
327
+ * synchronous `task` call — so a SECOND wait that inherited a first wait
328
+ * which settled nothing is the pattern a live session burned ten minutes
329
+ * on. It gets cut short and answered with what to do instead. */
330
+ const lastJoin = new Map();
149
331
  const api = sessionApiOf(deps.client);
150
332
  /** Refresh a child's state from the host's own status map (the event bus
151
333
  * is the fast path; this is the belt when an event was missed — a child
@@ -203,9 +385,11 @@ export function buildDispatchTools(deps) {
203
385
  * tm_join's honest answer would be "没有待收集的派发" while a finished report
204
386
  * sits in the host, which is exactly the silent loss issue #7 was about.
205
387
  *
206
- * The discriminator is the title tm_dispatch itself writes (`dispatchTitle`
207
- * + a known agent), so a child spawned by the built-in `task` tool is never
208
- * claimed as ours.
388
+ * The discriminator is the title shape our removed `tm_dispatch` used to
389
+ * write (a recognised agent + the ` ·tm` marker) or the pre-1.5.15
390
+ * `tm:<agent>:<label>`, so a child spawned by the built-in `task` tool is
391
+ * never claimed as ours on speculation — see `claimNamedChild` for the one
392
+ * path that may take a host child, and the parentage check it must pass.
209
393
  */
210
394
  async function adoptFromHost(parent, directory, only) {
211
395
  if (typeof api?.children !== "function" || !parent)
@@ -247,6 +431,42 @@ export function buildDispatchTools(deps) {
247
431
  }
248
432
  return adopted;
249
433
  }
434
+ /** Claim ONE explicitly named child of the calling session — the host's own
435
+ * `task` child included, which `adoptFromHost` must never pick up on its
436
+ * own. The lock is the host's parentage (`parentID === the caller`), read
437
+ * back rather than assumed: an id belonging to someone else stays a miss. */
438
+ async function claimNamedChild(sid, parent, directory) {
439
+ if (typeof api?.get !== "function" || !sid || !parent || children.has(sid))
440
+ return false;
441
+ let un;
442
+ try {
443
+ un = unwrapClientResult(await api.get({ path: { id: sid }, ...(directory ? { query: { directory } } : {}) }));
444
+ }
445
+ catch {
446
+ return false;
447
+ }
448
+ if (!un.ok)
449
+ return false;
450
+ const info = un.data;
451
+ if (String(info?.parentID ?? "").trim() !== parent)
452
+ return false;
453
+ const rawAgent = String(info?.agent ?? "").trim().toLowerCase();
454
+ const agent = targets.includes(rawAgent) ? rawAgent : "task";
455
+ const title = String(info?.title ?? "").trim();
456
+ const created = Number(info?.time?.created);
457
+ children.set(sid, {
458
+ sessionID: sid,
459
+ agent,
460
+ label: (title || "host task").slice(0, 40),
461
+ parentSessionID: parent,
462
+ startedAt: Number.isFinite(created) && created > 0 ? created : now(),
463
+ state: "running",
464
+ adopted: true,
465
+ });
466
+ deps.onChildSession?.(sid, agent);
467
+ log({ step_id: "join", event: "claim_host_task", child: sid, agent });
468
+ return true;
469
+ }
250
470
  /** Settle verdict for adopted rows: `AssistantMessage.time.completed` is
251
471
  * only set once the reply is done, so it answers "finished while nobody
252
472
  * was listening?" without waiting for an event that already fired. */
@@ -261,90 +481,16 @@ export function buildDispatchTools(deps) {
261
481
  }
262
482
  }
263
483
  }
264
- const dispatch = {
265
- 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).
266
- - { agent: ${targets.join("|")} , task: "<self-contained brief>", label: "<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.
267
- - 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.
268
- - 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.
269
- - 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.
270
- - 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.`,
271
- args: {
272
- agent: { descriptor: `agent: ${targets.join("|")} (required)` },
273
- task: { descriptor: "task: string (required — the full self-contained brief the child works from)" },
274
- label: { descriptor: "label: string (optional short slug for this dispatch, ≤40 chars)" },
275
- },
276
- execute: async (rawArgs, ctx) => {
277
- const tool = "tm_dispatch";
278
- try {
279
- const args = (rawArgs ?? {});
280
- const c = (ctx ?? {});
281
- const caller = String(c.agent ?? "").trim();
282
- if (caller && caller !== leadAgent) {
283
- return toToolResult(tmError(tool, "governance", `只有 ${leadAgent} 可以派发子代理(当前 agent="${shorten(caller, 24)}")。T3 规则:子代理不再派子代理——把需要并行的工作交给 lead 编排。`));
284
- }
285
- const agent = String(args.agent ?? "").trim().toLowerCase();
286
- if (!targets.includes(agent)) {
287
- return toToolResult(tmError(tool, "args", `未知派发目标 "${shorten(agent, 30)}"——可用: ${targets.join(", ")}`));
288
- }
289
- const task = typeof args.task === "string" ? args.task.trim() : "";
290
- if (task.length < 20) {
291
- return toToolResult(tmError(tool, "args", "task 太短:子代理看不到本会话,派发说明必须自包含(目标、涉及文件、完成判据、期望深度)。"));
292
- }
293
- const parent = String(c.sessionID ?? "").trim();
294
- if (!parent) {
295
- return toToolResult(tmError(tool, "client", "缺少 sessionID(宿主未把会话上下文传给插件),无法建立父子会话。"));
296
- }
297
- if (!api) {
298
- return toToolResult(tmError(tool, "client", `此宿主未暴露异步会话 API(client.session.create/promptAsync),tm_dispatch 不可用——改用内置 task 工具(会阻塞你,但功能等价)。`));
299
- }
300
- const label = (typeof args.label === "string" && args.label.trim() ? args.label.trim() : task.split(/\s+/).slice(0, 4).join(" ")).slice(0, 40);
301
- if (typeof api?.create !== "function" || typeof api?.promptAsync !== "function") {
302
- return toToolResult(tmError(tool, "client", "此宿主的 client.session 缺少 create/promptAsync——改用内置 task 工具。"));
303
- }
304
- // PROPERTY-ACCESS CALLS (api.create / api.promptAsync), never a
305
- // captured function reference — see refreshFromStatus above.
306
- const created = unwrapClientResult(await api.create({ body: { parentID: parent, title: dispatchTitle(agent, label) } }));
307
- if (!created.ok)
308
- return toToolResult(tmError(tool, "client", `创建子会话失败:${shorten(created.message ?? "", 120)}`));
309
- const sid = String(created.data?.id ?? "").trim();
310
- if (!sid)
311
- return toToolResult(tmError(tool, "client", "子会话创建后未返回 id"));
312
- const promptBody = {
313
- agent,
314
- noReply: false,
315
- parts: [{ type: "text", text: task }],
316
- };
317
- const directory = typeof c.directory === "string" && c.directory ? c.directory : undefined;
318
- const sent = unwrapClientResult(await api.promptAsync({ path: { id: sid }, ...(directory ? { query: { directory } } : {}), body: promptBody }));
319
- if (!sent.ok) {
320
- return toToolResult(tmError(tool, "client", `子会话 ${sid} 已创建但提示未能启动:${shorten(sent.message ?? "", 120)}——可对该 id 直接用内置 task,或本次改用 task 工具。`));
321
- }
322
- const rec = { sessionID: sid, agent, label, parentSessionID: parent, startedAt: now(), state: "running" };
323
- children.set(sid, rec);
324
- deps.onChildSession?.(sid, agent);
325
- log({ step_id: "dispatch", event: "start", child: sid, parent: parent, agent, label });
326
- return toToolResult([
327
- `已派发(非阻塞):${agent} · "${label}" · 子会话 ${sid}`,
328
- `你现在可以继续:编排下一步、做廉价检查、或继续派发其它独立任务。`,
329
- `收集结果:tm_join(立即看状态)或 tm_join { waitMs: 60000 }。所有派发完成前不要提交结论。`,
330
- ].join("\n"));
331
- }
332
- catch (err) {
333
- const e = err;
334
- return toToolResult(tmError(tool, "execute", `tm_dispatch 失败:${shorten(e?.message ?? err, 160)}`));
335
- }
336
- },
337
- };
338
484
  const join = {
339
- description: `Collect what the async dispatches (tm_dispatch) have produced.
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.
340
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.
341
- - { 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 }).
342
- - { ids: [...] }: restrict to those child sessions; { cancel: true }: abort still-running ones (a runaway child is yours to stop, not the user's problem).
343
- - 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.
344
- - 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.`,
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.
490
+ - 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.`,
345
491
  args: {
346
492
  ids: { descriptor: "ids: string[] (optional — only these child sessions)" },
347
- waitMs: { descriptor: "waitMs: number (optional 0..300000 — bounded wait before collecting)" },
493
+ waitMs: { descriptor: `waitMs: number (optional 0..${maxWaitMs} — bounded wait; a SECOND consecutive wait is cut to 10s, because waiting is not parallel work)` },
348
494
  cancel: { descriptor: "cancel: true (abort the still-running children in this set)" },
349
495
  includeText: { descriptor: "includeText: false to get only the status table (default true)" },
350
496
  },
@@ -358,10 +504,13 @@ export function buildDispatchTools(deps) {
358
504
  return toToolResult(tmError(tool, "governance", `只有 ${leadAgent} 能收集派发结果。`));
359
505
  }
360
506
  const parent = String(c.sessionID ?? "").trim();
507
+ // tm_stats counts calls from `event:"call"`, and tm_join only ever
508
+ // wrote its governed result — so the token table read "0 调用 / 2 结果",
509
+ // which looks like a broken counter rather than a tool that ran.
510
+ store.appendTrajectory({ tool, step_id: "join", event: "call" });
361
511
  const directory = typeof c.directory === "string" && c.directory ? c.directory : undefined;
362
- const idFilter = Array.isArray(args.ids)
363
- ? new Set(args.ids.map((x) => String(x ?? "").trim()).filter(Boolean))
364
- : null;
512
+ const idList = parseIdList(args.ids);
513
+ const idFilter = idList && idList.length ? new Set(idList) : null;
365
514
  const owned = () => [...children.values()].filter((r) => (!parent || r.parentSessionID === parent) && (!idFilter || idFilter.has(r.sessionID)));
366
515
  let mine = owned();
367
516
  // This process has never seen some (or all) of what the lead is asking
@@ -369,13 +518,66 @@ export function buildDispatchTools(deps) {
369
518
  if (mine.length < (idFilter ? idFilter.size : 1)) {
370
519
  if (await adoptFromHost(parent, directory, idFilter))
371
520
  mine = owned();
521
+ // Plan B: a NAMED id may be the host's own `task` child (no ·tm
522
+ // marker, so the tree walk above never claims it). Collecting it is
523
+ // how the lead reads back a result we replaced with a pointer — the
524
+ // host's parentage check below replaces the title discriminator, and
525
+ // only an explicit request reaches this path.
526
+ if (idFilter) {
527
+ for (const sid of idFilter) {
528
+ if (children.has(sid))
529
+ continue;
530
+ if (await claimNamedChild(sid, parent, directory))
531
+ mine = owned();
532
+ }
533
+ }
372
534
  }
535
+ // #87: computed BEFORE any early return. The live regression of #86 was
536
+ // a lead holding a browser while tm_join answered "nothing to collect" —
537
+ // a SYNCHRONOUS host task never registers in this table (the host
538
+ // collects it inline), so the header assembly below never ran, and the
539
+ // window the user was looking at went unreported again. A forgotten
540
+ // browser has to surface on EVERY answer this tool gives.
541
+ const leaseLine = () => {
542
+ if (typeof deps.browserLeases !== "function")
543
+ return "";
544
+ let held = [];
545
+ try {
546
+ const done = new Set(mine.filter((r) => r.state !== "running").map((r) => r.sessionID));
547
+ held = deps.browserLeases().filter((l) => done.has(l.owner) || l.owner === parent);
548
+ }
549
+ catch {
550
+ /* the lease table is an extra, never a reason to fail a join */
551
+ return "";
552
+ }
553
+ const line = leaseTripwire(held, parent) ?? "";
554
+ if (line) {
555
+ log({
556
+ step_id: "join",
557
+ event: "lease_held",
558
+ count: held.length,
559
+ ids: held.map((l) => l.id).join(","),
560
+ });
561
+ }
562
+ return line;
563
+ };
373
564
  if (!mine.length) {
374
- return toToolResult(`没有待收集的派发${idFilter ? "(ids 未匹配到本会话的子代理,宿主会话树里也没有 tm: 标题的子会话)" : ""}。刚派发过却看不到?说明那次派发没成功——回到 tm_dispatch 的返回值检查。`);
565
+ const lease = leaseLine();
566
+ return toToolResult(`没有待收集的派发${idFilter ? "(ids 未匹配到本会话的子代理,宿主会话树里也没有可认领的子会话)" : ""}。` +
567
+ // The old line asserted the dispatch had failed. Measured live: it
568
+ // had not — a completed host `task` with a full reply was sitting in
569
+ // the transcript, and the honest reason is that a synchronous host
570
+ // task never enters this registry at all.
571
+ `如果你用的是同步 \`task\`:宿主自己就把结果收回来了,它从不登记在这里,这不是派发失败。` +
572
+ `只有后台 \`task {background:true}\`(或显式带 ids)需要 tm_join 来取。` +
573
+ (lease ? `\n${lease}` : ""));
375
574
  }
376
575
  await settleAdopted(mine, directory);
377
576
  const waitMs = Math.max(0, Math.min(maxWaitMs, Number(args.waitMs) || 0));
378
- const deadline = now() + waitMs;
577
+ const prev = lastJoin.get(parent);
578
+ const { budget, repeat: repeatWait, streak } = joinBudget(waitMs, maxWaitMs, prev);
579
+ const waitStart = now();
580
+ const deadline = waitStart + budget;
379
581
  let settled = false;
380
582
  for (;;) {
381
583
  await refreshFromStatus(directory);
@@ -387,6 +589,16 @@ export function buildDispatchTools(deps) {
387
589
  await sleep(Math.min(1000, Math.max(100, deadline - now())));
388
590
  }
389
591
  const stillRunning = mine.filter((r) => r.state === "running");
592
+ lastJoin.set(parent, { at: now(), stillRunning: stillRunning.length, streak });
593
+ log({
594
+ step_id: "join",
595
+ event: "wait",
596
+ asked_ms: waitMs,
597
+ budget_ms: budget,
598
+ waited_ms: Math.max(0, now() - waitStart),
599
+ still_running: stillRunning.length,
600
+ repeat: repeatWait,
601
+ });
390
602
  if (args.cancel === true || args.cancel === "true") {
391
603
  for (const r of stillRunning) {
392
604
  if (typeof api?.abort === "function") {
@@ -403,9 +615,48 @@ export function buildDispatchTools(deps) {
403
615
  }
404
616
  const sums = summarizeStates(mine);
405
617
  const header = [
406
- `派发汇总:${sums.idle} 完成 / ${sums.running} 运行中 / ${sums.error} 失败${settled ? "(全部已结算)" : "(未等到全部结算,可再次 tm_join)"}`,
618
+ `派发汇总:${sums.idle} 完成 / ${sums.running} 运行中 / ${sums.error} 失败${settled ? "(全部已结算)" : `(未等到全部结算 · 本次已等 ${Math.round((now() - waitStart) / 1000)}s)`}`,
407
619
  ...mine.map((r) => renderChildLine(r, (r.finishedAt ?? now()) - r.startedAt)),
408
620
  ];
621
+ if (stillRunning.length && !settled) {
622
+ header.push(`⏱ 还有 ${stillRunning.length} 个子代理在跑 —— 先告诉用户你在等谁、在等什么、这一轮不是交付,再决定是等还是去干活。` +
623
+ `你的工具调用在界面上只是一行打不开的卡片,你不说,用户没法把"回合结束"和"任务完成"分开。`);
624
+ }
625
+ if (repeatWait && stillRunning.length) {
626
+ header.push(`⏳ 连续第 ${streak} 次等待,而且上一次也没等到结算 —— 这段等待里你什么都没做,它不是并行,是同步 task 的等价物。` +
627
+ `别再等:① 先做 lead 工作(合并骨架、路由下一个独立任务、你自己的验收项),做完再 tm_join;` +
628
+ `② 或就此收尾,把未收的派发逐条列成 open handoff 交给用户;` +
629
+ `③ 或 { cancel: true } 收掉已经不需要的那些。` +
630
+ `(宿主若开了 OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true,内置 task {background:true} 的卡片可以直接点开看实时进度。)`);
631
+ }
632
+ // Goal tripwire, at the exact moment a lead tends to wrap up: every
633
+ // child settled says nothing about the USER'S goal, and the host's own
634
+ // todo list does. Read-only (GET /session/{id}/todo has no write
635
+ // 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 });
646
+ }
647
+ }
648
+ catch {
649
+ /* the todo endpoint is an extra, never a reason to fail a join */
650
+ }
651
+ }
652
+ // #80/#86/#87: THE LEASE TRIPWIRE — one computation (leaseLine above),
653
+ // attached to every answer this tool gives, so a settled round and an
654
+ // empty one cannot disagree about whether a window is still open.
655
+ if (settled) {
656
+ const lease = leaseLine();
657
+ if (lease)
658
+ header.push(lease);
659
+ }
409
660
  const wantText = !(args.includeText === false || args.includeText === "false");
410
661
  const collectible = mine.filter((r) => r.state !== "running");
411
662
  const blocks = [];
@@ -413,7 +664,9 @@ export function buildDispatchTools(deps) {
413
664
  for (const r of collectible) {
414
665
  const reply = await fetchReply(r.sessionID, directory);
415
666
  const text = reply.text;
416
- blocks.push(`--- ${r.agent} "${r.label}" (${r.sessionID}) ---\n${text.trim() || "(该子会话没有可读的助手回复——用内置 read/tm_read 检查其会话,或重新派发)"}`);
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 工具重做这一步。`}`);
417
670
  }
418
671
  }
419
672
  const body = [...header, ...(blocks.length ? ["", blocks.join("\n")] : [])].join("\n");
@@ -425,7 +678,7 @@ export function buildDispatchTools(deps) {
425
678
  }
426
679
  catch (err) {
427
680
  const e = err;
428
- return toToolResult(tmError(tool, "execute", `tm_join 失败:${shorten(e?.message ?? err, 160)}`));
681
+ return toToolResult(tmError(tool, "execute", `tm_join 失败:${describeHostError(e?.message ?? err, 160)}`));
429
682
  }
430
683
  },
431
684
  };
@@ -451,7 +704,9 @@ export function buildDispatchTools(deps) {
451
704
  if (type === "session.error") {
452
705
  rec.state = "error";
453
706
  rec.finishedAt = now();
454
- rec.error = shorten(props?.error?.message ?? props?.error ?? "session.error", 120);
707
+ // describeHostError, NOT String(err): the host nests its errors and the
708
+ // bare stringify produced "[object Object]" for a real launch failure
709
+ rec.error = describeHostError(props?.error ?? "session.error", 200);
455
710
  log({ step_id: "events", event: "error", child: sid, agent: rec.agent, reason: rec.error });
456
711
  return;
457
712
  }
@@ -467,7 +722,6 @@ export function buildDispatchTools(deps) {
467
722
  }
468
723
  };
469
724
  return {
470
- tm_dispatch: dispatch,
471
725
  tm_join: join,
472
726
  observeEvent,
473
727
  children: () => [...children.values()],