@te-river/opencode-team-mode 1.5.15 → 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 (97) hide show
  1. package/README.md +788 -690
  2. package/README.zh-CN.md +712 -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-hooks.d.ts +13 -2
  11. package/dist/host-hooks.d.ts.map +1 -1
  12. package/dist/host-hooks.js +18 -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 +92 -25
  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 +62 -10
  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 +36 -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 -25
  46. package/dist/tm/config.d.ts.map +1 -1
  47. package/dist/tm/config.js +29 -7
  48. package/dist/tm/config.js.map +1 -1
  49. package/dist/tm/dispatch.d.ts +86 -53
  50. package/dist/tm/dispatch.d.ts.map +1 -1
  51. package/dist/tm/dispatch.js +254 -251
  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 -19
  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 +16 -0
  83. package/dist/tm/stats.d.ts.map +1 -1
  84. package/dist/tm/stats.js +34 -4
  85. package/dist/tm/stats.js.map +1 -1
  86. package/dist/tm/webfetch.d.ts +52 -0
  87. package/dist/tm/webfetch.d.ts.map +1 -1
  88. package/dist/tm/webfetch.js +124 -23
  89. package/dist/tm/webfetch.js.map +1 -1
  90. package/dist/tool-coerce.d.ts +24 -0
  91. package/dist/tool-coerce.d.ts.map +1 -0
  92. package/dist/tool-coerce.js +63 -0
  93. package/dist/tool-coerce.js.map +1 -0
  94. package/dist/types.d.ts +7 -1
  95. package/dist/types.d.ts.map +1 -1
  96. package/docs/installation.md +69 -2
  97. package/package.json +61 -61
@@ -1,33 +1,36 @@
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";
33
36
  /** The five dispatchable specialists — "team" is deliberately NOT in the
@@ -47,36 +50,98 @@ export function openHostTodos(todos) {
47
50
  }
48
51
  return open;
49
52
  }
50
- /** Resolve the host session API defensively — a missing namespace means the
51
- * host does not speak this protocol, and the caller must degrade. */
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. */
52
86
  export function sessionApiOf(client) {
53
87
  const session = client?.session;
54
88
  if (!session || typeof session !== "object")
55
89
  return null;
56
90
  const api = session;
57
- if (typeof api.create !== "function" || typeof api.promptAsync !== "function")
91
+ if (typeof api.messages !== "function")
58
92
  return null;
59
93
  return api;
60
94
  }
61
- /** The title `tm_dispatch` gives every child session.
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`;
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.
62
133
  *
63
- * The shape is the host's OWN subagent convention — the built-in task tool
134
+ * The shape was the host's OWN subagent convention — the built-in task tool
64
135
  * 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. */
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. */
80
145
  export function parseDispatchTitle(title, targets) {
81
146
  const t = typeof title === "string" ? title.trim() : "";
82
147
  if (t.startsWith("tm:")) {
@@ -249,14 +314,20 @@ export function parseIdList(raw) {
249
314
  export function buildDispatchTools(deps) {
250
315
  const leadAgent = deps.leadAgent ?? "team";
251
316
  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";
317
+ const maxWaitMs = deps.maxWaitMs ?? 60_000;
256
318
  const now = deps.now ?? (() => Date.now());
257
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.
258
323
  const log = (e) => store.appendTrajectory({ tool: "tm_dispatch", ...e });
259
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();
260
331
  const api = sessionApiOf(deps.client);
261
332
  /** Refresh a child's state from the host's own status map (the event bus
262
333
  * is the fast path; this is the belt when an event was missed — a child
@@ -306,37 +377,6 @@ export function buildDispatchTools(deps) {
306
377
  }
307
378
  })();
308
379
  }
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
380
  /**
341
381
  * Adopt the children the HOST lists under this parent but this process never
342
382
  * dispatched: the plugin instance restarted (or the lead resumed a session
@@ -345,9 +385,11 @@ export function buildDispatchTools(deps) {
345
385
  * tm_join's honest answer would be "没有待收集的派发" while a finished report
346
386
  * sits in the host, which is exactly the silent loss issue #7 was about.
347
387
  *
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.
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.
351
393
  */
352
394
  async function adoptFromHost(parent, directory, only) {
353
395
  if (typeof api?.children !== "function" || !parent)
@@ -389,6 +431,42 @@ export function buildDispatchTools(deps) {
389
431
  }
390
432
  return adopted;
391
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
+ }
392
470
  /** Settle verdict for adopted rows: `AssistantMessage.time.completed` is
393
471
  * only set once the reply is done, so it answers "finished while nobody
394
472
  * was listening?" without waiting for an event that already fired. */
@@ -403,177 +481,16 @@ export function buildDispatchTools(deps) {
403
481
  }
404
482
  }
405
483
  }
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
484
  const join = {
568
- 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.
569
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.
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.`,
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.`,
574
491
  args: {
575
492
  ids: { descriptor: "ids: string[] (optional — only these child sessions)" },
576
- 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)` },
577
494
  cancel: { descriptor: "cancel: true (abort the still-running children in this set)" },
578
495
  includeText: { descriptor: "includeText: false to get only the status table (default true)" },
579
496
  },
@@ -587,6 +504,10 @@ export function buildDispatchTools(deps) {
587
504
  return toToolResult(tmError(tool, "governance", `只有 ${leadAgent} 能收集派发结果。`));
588
505
  }
589
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" });
590
511
  const directory = typeof c.directory === "string" && c.directory ? c.directory : undefined;
591
512
  const idList = parseIdList(args.ids);
592
513
  const idFilter = idList && idList.length ? new Set(idList) : null;
@@ -597,13 +518,66 @@ export function buildDispatchTools(deps) {
597
518
  if (mine.length < (idFilter ? idFilter.size : 1)) {
598
519
  if (await adoptFromHost(parent, directory, idFilter))
599
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
+ }
600
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
+ };
601
564
  if (!mine.length) {
602
- 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}` : ""));
603
574
  }
604
575
  await settleAdopted(mine, directory);
605
576
  const waitMs = Math.max(0, Math.min(maxWaitMs, Number(args.waitMs) || 0));
606
- 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;
607
581
  let settled = false;
608
582
  for (;;) {
609
583
  await refreshFromStatus(directory);
@@ -615,6 +589,16 @@ export function buildDispatchTools(deps) {
615
589
  await sleep(Math.min(1000, Math.max(100, deadline - now())));
616
590
  }
617
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
+ });
618
602
  if (args.cancel === true || args.cancel === "true") {
619
603
  for (const r of stillRunning) {
620
604
  if (typeof api?.abort === "function") {
@@ -631,9 +615,20 @@ export function buildDispatchTools(deps) {
631
615
  }
632
616
  const sums = summarizeStates(mine);
633
617
  const header = [
634
- `派发汇总:${sums.idle} 完成 / ${sums.running} 运行中 / ${sums.error} 失败${settled ? "(全部已结算)" : "(未等到全部结算,可再次 tm_join)"}`,
618
+ `派发汇总:${sums.idle} 完成 / ${sums.running} 运行中 / ${sums.error} 失败${settled ? "(全部已结算)" : `(未等到全部结算 · 本次已等 ${Math.round((now() - waitStart) / 1000)}s)`}`,
635
619
  ...mine.map((r) => renderChildLine(r, (r.finishedAt ?? now()) - r.startedAt)),
636
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
+ }
637
632
  // Goal tripwire, at the exact moment a lead tends to wrap up: every
638
633
  // child settled says nothing about the USER'S goal, and the host's own
639
634
  // todo list does. Read-only (GET /session/{id}/todo has no write
@@ -645,7 +640,8 @@ export function buildDispatchTools(deps) {
645
640
  if (open.length) {
646
641
  header.push(`⚠ 目标未达成:宿主 todolist 还有 ${open.length} 项未完成 —— ${open.slice(0, 6).map((t) => `「${t.content}」(${t.status})`).join("、")}` +
647
642
  (open.length > 6 ? ` …+${open.length - 6}` : "") +
648
- `\n按目标指令:要么继续做掉,要么向用户写明哪一条被什么卡住;不要把这轮当成收尾。`);
643
+ `\n按目标指令:要么继续做掉,要么向用户写明哪一条被什么卡住;不要把这轮当成收尾。` +
644
+ `(若这些项其实已经做完——例如你刚把派发结果收齐——先用 todowrite 更新状态,再收尾。)`);
649
645
  log({ step_id: "join", event: "goal_open", count: open.length });
650
646
  }
651
647
  }
@@ -653,6 +649,14 @@ export function buildDispatchTools(deps) {
653
649
  /* the todo endpoint is an extra, never a reason to fail a join */
654
650
  }
655
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
+ }
656
660
  const wantText = !(args.includeText === false || args.includeText === "false");
657
661
  const collectible = mine.filter((r) => r.state !== "running");
658
662
  const blocks = [];
@@ -718,7 +722,6 @@ export function buildDispatchTools(deps) {
718
722
  }
719
723
  };
720
724
  return {
721
- tm_dispatch: dispatch,
722
725
  tm_join: join,
723
726
  observeEvent,
724
727
  children: () => [...children.values()],