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

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 (74) hide show
  1. package/README.md +24 -5
  2. package/README.zh-CN.md +20 -4
  3. package/dist/agents.d.ts.map +1 -1
  4. package/dist/agents.js +4 -2
  5. package/dist/agents.js.map +1 -1
  6. package/dist/capabilities.d.ts +66 -0
  7. package/dist/capabilities.d.ts.map +1 -0
  8. package/dist/capabilities.js +165 -0
  9. package/dist/capabilities.js.map +1 -0
  10. package/dist/host-hooks.d.ts.map +1 -1
  11. package/dist/host-hooks.js +1 -0
  12. package/dist/host-hooks.js.map +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +35 -0
  15. package/dist/index.js.map +1 -1
  16. package/dist/prompts/lead.d.ts +1 -1
  17. package/dist/prompts/lead.d.ts.map +1 -1
  18. package/dist/prompts/lead.js +51 -0
  19. package/dist/prompts/lead.js.map +1 -1
  20. package/dist/prompts/shared.d.ts +2 -2
  21. package/dist/prompts/shared.d.ts.map +1 -1
  22. package/dist/prompts/shared.js +32 -6
  23. package/dist/prompts/shared.js.map +1 -1
  24. package/dist/tm/args-schema.d.ts.map +1 -1
  25. package/dist/tm/args-schema.js +8 -0
  26. package/dist/tm/args-schema.js.map +1 -1
  27. package/dist/tm/browser.d.ts +25 -0
  28. package/dist/tm/browser.d.ts.map +1 -1
  29. package/dist/tm/browser.js +90 -2
  30. package/dist/tm/browser.js.map +1 -1
  31. package/dist/tm/cache.d.ts +68 -0
  32. package/dist/tm/cache.d.ts.map +1 -0
  33. package/dist/tm/cache.js +157 -0
  34. package/dist/tm/cache.js.map +1 -0
  35. package/dist/tm/config.d.ts +43 -0
  36. package/dist/tm/config.d.ts.map +1 -1
  37. package/dist/tm/config.js +10 -0
  38. package/dist/tm/config.js.map +1 -1
  39. package/dist/tm/dispatch.d.ts +84 -2
  40. package/dist/tm/dispatch.d.ts.map +1 -1
  41. package/dist/tm/dispatch.js +397 -39
  42. package/dist/tm/dispatch.js.map +1 -1
  43. package/dist/tm/dupe-guard.d.ts +58 -0
  44. package/dist/tm/dupe-guard.d.ts.map +1 -0
  45. package/dist/tm/dupe-guard.js +105 -0
  46. package/dist/tm/dupe-guard.js.map +1 -0
  47. package/dist/tm/index.d.ts +5 -0
  48. package/dist/tm/index.d.ts.map +1 -1
  49. package/dist/tm/index.js +29 -2
  50. package/dist/tm/index.js.map +1 -1
  51. package/dist/tm/perm-ask.d.ts +1 -0
  52. package/dist/tm/perm-ask.d.ts.map +1 -1
  53. package/dist/tm/perm-ask.js +15 -1
  54. package/dist/tm/perm-ask.js.map +1 -1
  55. package/dist/tm/pipelines.d.ts.map +1 -1
  56. package/dist/tm/pipelines.js +11 -0
  57. package/dist/tm/pipelines.js.map +1 -1
  58. package/dist/tm/search.d.ts +5 -0
  59. package/dist/tm/search.d.ts.map +1 -1
  60. package/dist/tm/search.js +44 -4
  61. package/dist/tm/search.js.map +1 -1
  62. package/dist/tm/stats.d.ts +147 -0
  63. package/dist/tm/stats.d.ts.map +1 -0
  64. package/dist/tm/stats.js +383 -0
  65. package/dist/tm/stats.js.map +1 -0
  66. package/dist/tm/store.d.ts +3 -0
  67. package/dist/tm/store.d.ts.map +1 -1
  68. package/dist/tm/store.js +6 -1
  69. package/dist/tm/store.js.map +1 -1
  70. package/dist/tm/webfetch.d.ts +19 -0
  71. package/dist/tm/webfetch.d.ts.map +1 -1
  72. package/dist/tm/webfetch.js +70 -4
  73. package/dist/tm/webfetch.js.map +1 -1
  74. package/package.json +4 -2
@@ -27,11 +27,26 @@
27
27
  */
28
28
  import { tmError, toToolResult } from "./result.js";
29
29
  import { unwrapClientResult } from "./client-unwrap.js";
30
+ import { askUserForTarget } from "./perm-ask.js";
30
31
  import { detectContentType } from "./preview.js";
31
32
  import { shorten } from "./config.js";
32
33
  /** The five dispatchable specialists — "team" is deliberately NOT in the
33
34
  * list: a lead spawning a lead is the nesting T3 closed. */
34
35
  export const DISPATCH_TARGETS = ["architect", "implementer", "reviewer", "tester", "researcher"];
36
+ export function openHostTodos(todos) {
37
+ if (!Array.isArray(todos))
38
+ return [];
39
+ const open = [];
40
+ for (const t of todos) {
41
+ const status = String(t?.status ?? "").toLowerCase();
42
+ if (!status || status === "completed" || status === "cancelled" || status === "done")
43
+ continue;
44
+ const content = String(t?.content ?? "").trim();
45
+ if (content)
46
+ open.push({ content: content.slice(0, 120), status });
47
+ }
48
+ return open;
49
+ }
35
50
  /** Resolve the host session API defensively — a missing namespace means the
36
51
  * host does not speak this protocol, and the caller must degrade. */
37
52
  export function sessionApiOf(client) {
@@ -43,13 +58,57 @@ export function sessionApiOf(client) {
43
58
  return null;
44
59
  return api;
45
60
  }
61
+ /** The title `tm_dispatch` gives every child session.
62
+ *
63
+ * The shape is the host's OWN subagent convention — the built-in task tool
64
+ * 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. */
80
+ export function parseDispatchTitle(title, targets) {
81
+ const t = typeof title === "string" ? title.trim() : "";
82
+ if (t.startsWith("tm:")) {
83
+ const rest = t.slice(3);
84
+ const colon = rest.indexOf(":");
85
+ const legacyAgent = (colon < 0 ? rest : rest.slice(0, colon)).trim().toLowerCase();
86
+ if (!legacyAgent || !targets.includes(legacyAgent))
87
+ return null;
88
+ return { agent: legacyAgent, label: (colon < 0 ? "" : rest.slice(colon + 1)).trim().slice(0, 40) };
89
+ }
90
+ // The marker is required here — without it a built-in task child would read
91
+ // as ours, which is the one thing the restart path must never do.
92
+ const m = /^(.*?) \(@([^\s()]+) subagent ·tm\)$/.exec(t);
93
+ if (!m)
94
+ return null;
95
+ const agent = m[2].trim().toLowerCase();
96
+ if (!agent || !targets.includes(agent))
97
+ return null;
98
+ return { agent, label: m[1].trim().slice(0, 40) };
99
+ }
46
100
  /**
47
- * Pull the assistant reply text out of a `session.messages` payload.
101
+ * Pull the assistant reply text (and, when the host records it, the moment
102
+ * that reply completed) out of a `session.messages` payload.
48
103
  * Shapes accepted (host has renamed things before): {info,parts} pairs or a
49
104
  * bare Part[] — the LAST assistant message is the answer, everything earlier
50
105
  * is that agent's own working transcript.
106
+ *
107
+ * `completedAt` is what lets a rebuilt registry tell "still running" from
108
+ * "finished while we were not listening": AssistantMessage.time.completed is
109
+ * only set once the message is done.
51
110
  */
52
- export function lastAssistantText(messages) {
111
+ export function lastAssistantMessage(messages) {
53
112
  const list = Array.isArray(messages) ? messages : [];
54
113
  for (let i = list.length - 1; i >= 0; i--) {
55
114
  const entry = list[i];
@@ -57,12 +116,17 @@ export function lastAssistantText(messages) {
57
116
  const role = typeof info?.role === "string" ? String(info.role).toLowerCase() : "";
58
117
  if (role && role !== "assistant")
59
118
  continue;
119
+ const completed = Number(info?.time?.completed);
120
+ const completedAt = Number.isFinite(completed) && completed > 0 ? completed : undefined;
121
+ // an assistant turn that errored carries the reason on info.error — the
122
+ // lead must see it, or "no reply" reads as "the agent had nothing to say"
123
+ const error = info?.error ? describeHostError(info.error, 200) : undefined;
60
124
  const parts = entry?.parts;
61
125
  if (!Array.isArray(parts)) {
62
126
  // a bare message list (no {info,parts} envelope) — take any text part
63
127
  const one = entry ?? {};
64
128
  if (one.type === "text" && typeof one.text === "string")
65
- return one.text;
129
+ return { text: one.text, ...(completedAt ? { completedAt } : {}), ...(error ? { error } : {}) };
66
130
  continue;
67
131
  }
68
132
  const texts = parts
@@ -71,16 +135,66 @@ export function lastAssistantText(messages) {
71
135
  return pp && pp.type === "text" && typeof pp.text === "string" && pp.text.trim() !== "";
72
136
  })
73
137
  .map((p) => String(p.text));
138
+ // keys are added only when present so a deep-equality assertion sees the
139
+ // shape the caller actually got (no `error: undefined` noise)
74
140
  if (texts.length)
75
- return texts.join("\n");
141
+ return { text: texts.join("\n"), ...(completedAt ? { completedAt } : {}), ...(error ? { error } : {}) };
142
+ if (error)
143
+ return { text: "", ...(completedAt ? { completedAt } : {}), error };
76
144
  }
77
- return "";
145
+ return { text: "" };
146
+ }
147
+ /** Back-compat seam: the reply text alone. */
148
+ export function lastAssistantText(messages) {
149
+ return lastAssistantMessage(messages).text;
150
+ }
151
+ /**
152
+ * Turn whatever the host puts in an error slot into ONE readable line.
153
+ *
154
+ * The live host nests them (`{name, data:{message, ref}}`, `{error:{message}}`,
155
+ * a bare string — sometimes several at once), and `String(obj)` is
156
+ * "[object Object]". That is what a lead reported to a user for three child
157
+ * sessions that died at launch: the one diagnostic the whole flow had was
158
+ * destroyed by our own rendering. Never again: dig the known fields, then
159
+ * fall back to a compact key dump — never a bare object stringify.
160
+ */
161
+ export function describeHostError(err, max = 220) {
162
+ if (typeof err === "string")
163
+ return shorten(err.trim() || "session.error", max);
164
+ if (err === null || err === undefined)
165
+ return "session.error";
166
+ if (typeof err !== "object")
167
+ return shorten(String(err), max);
168
+ const o = err;
169
+ const box = (v) => v && typeof v === "object" ? v : undefined;
170
+ const data = box(o.data);
171
+ const inner = box(o.error);
172
+ const pick = (...cands) => {
173
+ for (const c of cands)
174
+ if (typeof c === "string" && c.trim())
175
+ return c.trim();
176
+ return "";
177
+ };
178
+ const message = pick(o.message, data?.message, inner?.message, inner?.name, data?.ref ? `ref=${String(data.ref)}` : "");
179
+ const name = pick(o.name, inner?.name, data?.name);
180
+ const ref = pick(data?.ref, o.ref);
181
+ if (message || name) {
182
+ const head = name && message && !message.startsWith(name) ? `${name}: ${message}` : message || name;
183
+ return shorten(ref && !head.includes(ref) ? `${head}(ref=${ref})` : head, max);
184
+ }
185
+ // nothing recognised — dump the SHAPE with short values, which is still
186
+ // infinitely more useful than "[object Object]"
187
+ const flat = Object.entries(o)
188
+ .slice(0, 6)
189
+ .map(([k, v]) => `${k}=${typeof v === "object" && v !== null ? Object.keys(v).slice(0, 4).join("+") : String(v).slice(0, 60)}`)
190
+ .join(" ");
191
+ return shorten(flat || "session.error", max);
78
192
  }
79
193
  /** One child's status line — the lead reads these, so keep them terse. */
80
194
  export function renderChildLine(c, elapsedMs) {
81
195
  const secs = Math.round(elapsedMs / 1000);
82
196
  const tag = c.state === "running" ? `运行中 ${secs}s` : c.state === "error" ? `失败:${shorten(c.error ?? "", 80)}` : `已完成 ${secs}s`;
83
- return `${c.sessionID} · ${c.agent} · "${c.label}" · ${tag}`;
197
+ return `${c.sessionID} · ${c.agent} · "${c.label}" · ${tag}${c.adopted ? " ·(本进程重启后由宿主会话树接管)" : ""}`;
84
198
  }
85
199
  /** Verdicts tm_join can actually observe (issue #7's "leader keeps control"). */
86
200
  export function summarizeStates(children) {
@@ -108,10 +222,37 @@ function sleep(ms) {
108
222
  t.unref?.();
109
223
  });
110
224
  }
225
+ /** `ids` arrives in three shapes on the live host: a real array, a
226
+ * JSON-array STRING (`"[\"ses_x\"]"` — what a model actually sent on
227
+ * 2026-09-19), or a comma-separated list. Array.isArray alone silently
228
+ * ignored the string form and returned EVERY child when the lead asked for
229
+ * one, which is the same class of bug as the old `Boolean("false")` headless
230
+ * trap: the arg is trusted because it "looks" like the declared type. */
231
+ export function parseIdList(raw) {
232
+ if (Array.isArray(raw))
233
+ return raw.map((x) => String(x ?? "").trim()).filter(Boolean);
234
+ const s = typeof raw === "string" ? raw.trim() : "";
235
+ if (!s)
236
+ return null;
237
+ if (s.startsWith("[") && s.endsWith("]")) {
238
+ try {
239
+ const parsed = JSON.parse(s);
240
+ if (Array.isArray(parsed))
241
+ return parsed.map((x) => String(x ?? "").trim()).filter(Boolean);
242
+ }
243
+ catch {
244
+ /* fall through to the comma split */
245
+ }
246
+ }
247
+ return s.split(",").map((x) => x.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
248
+ }
111
249
  export function buildDispatchTools(deps) {
112
250
  const leadAgent = deps.leadAgent ?? "team";
113
251
  const targets = deps.targets ?? DISPATCH_TARGETS;
114
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";
115
256
  const now = deps.now ?? (() => Date.now());
116
257
  const store = deps.pipelines.store;
117
258
  const log = (e) => store.appendTrajectory({ tool: "tm_dispatch", ...e });
@@ -122,12 +263,15 @@ export function buildDispatchTools(deps) {
122
263
  * dispatched before a plugin restart, or a session.idle that never
123
264
  * arrived). */
124
265
  async function refreshFromStatus(directory) {
125
- const statusApi = api?.status;
126
- if (typeof statusApi !== "function")
266
+ if (typeof api?.status !== "function")
127
267
  return;
128
268
  let un;
129
269
  try {
130
- un = unwrapClientResult(await statusApi(directory ? { query: { directory } } : {}));
270
+ // PROPERTY-ACCESS CALL — the SDK's endpoints need their receiver (see
271
+ // approval-gate.ts's replyCapableFn note); `const f = api.status` then
272
+ // `f(...)` threw "Cannot read properties of undefined (reading
273
+ // 'client')" on the live host and killed every dispatch.
274
+ un = unwrapClientResult(await api.status(directory ? { query: { directory } } : {}));
131
275
  }
132
276
  catch {
133
277
  return;
@@ -151,29 +295,159 @@ export function buildDispatchTools(deps) {
151
295
  }
152
296
  function fetchReply(sid, directory) {
153
297
  return (async () => {
154
- const messages = api?.messages;
155
- if (typeof messages !== "function")
156
- return "";
298
+ if (typeof api?.messages !== "function")
299
+ return { text: "" };
157
300
  try {
158
- const un = unwrapClientResult(await messages({ path: { id: sid }, ...(directory ? { query: { directory } } : {}) }));
159
- return un.ok ? lastAssistantText(un.data) : "";
301
+ const un = unwrapClientResult(await api.messages({ path: { id: sid }, ...(directory ? { query: { directory } } : {}) }));
302
+ return un.ok ? lastAssistantMessage(un.data) : { text: "" };
160
303
  }
161
304
  catch {
162
- return "";
305
+ return { text: "" };
163
306
  }
164
307
  })();
165
308
  }
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
+ /**
341
+ * Adopt the children the HOST lists under this parent but this process never
342
+ * dispatched: the plugin instance restarted (or the lead resumed a session
343
+ * an earlier instance created) while a specialist was still working — or had
344
+ * already answered into a registry that no longer exists. Without this,
345
+ * tm_join's honest answer would be "没有待收集的派发" while a finished report
346
+ * sits in the host, which is exactly the silent loss issue #7 was about.
347
+ *
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.
351
+ */
352
+ async function adoptFromHost(parent, directory, only) {
353
+ if (typeof api?.children !== "function" || !parent)
354
+ return 0;
355
+ let un;
356
+ try {
357
+ un = unwrapClientResult(await api.children({ path: { id: parent }, ...(directory ? { query: { directory } } : {}) }));
358
+ }
359
+ catch {
360
+ return 0;
361
+ }
362
+ if (!un.ok || !Array.isArray(un.data))
363
+ return 0;
364
+ let adopted = 0;
365
+ for (const raw of un.data) {
366
+ const sid = String(raw?.id ?? "").trim();
367
+ if (!sid || children.has(sid))
368
+ continue;
369
+ if (only && !only.has(sid))
370
+ continue;
371
+ const parsed = parseDispatchTitle(raw?.title, targets);
372
+ if (!parsed)
373
+ continue;
374
+ const created = Number(raw?.time?.created);
375
+ const rec = {
376
+ sessionID: sid,
377
+ agent: parsed.agent,
378
+ label: parsed.label || parsed.agent,
379
+ parentSessionID: parent,
380
+ startedAt: Number.isFinite(created) && created > 0 ? created : now(),
381
+ state: "running",
382
+ adopted: true,
383
+ };
384
+ children.set(sid, rec);
385
+ // the child's own protected reads must still open the official dialog
386
+ deps.onChildSession?.(sid, parsed.agent);
387
+ adopted++;
388
+ log({ step_id: "join", event: "adopt", child: sid, agent: parsed.agent, label: rec.label });
389
+ }
390
+ return adopted;
391
+ }
392
+ /** Settle verdict for adopted rows: `AssistantMessage.time.completed` is
393
+ * only set once the reply is done, so it answers "finished while nobody
394
+ * was listening?" without waiting for an event that already fired. */
395
+ async function settleAdopted(records, directory) {
396
+ for (const rec of records) {
397
+ if (!rec.adopted || rec.state !== "running")
398
+ continue;
399
+ const reply = await fetchReply(rec.sessionID, directory);
400
+ if (typeof reply.completedAt === "number") {
401
+ rec.state = "idle";
402
+ rec.finishedAt = reply.completedAt;
403
+ }
404
+ }
405
+ }
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
+ }
166
434
  const dispatch = {
167
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).
168
- - { 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.
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.
169
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.
170
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.
171
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.
172
- - 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.`,
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.`}`,
173
445
  args: {
174
446
  agent: { descriptor: `agent: ${targets.join("|")} (required)` },
175
447
  task: { descriptor: "task: string (required — the full self-contained brief the child works from)" },
176
- label: { descriptor: "label: string (optional short slug for this dispatch, ≤40 chars)" },
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
+ },
177
451
  },
178
452
  execute: async (rawArgs, ctx) => {
179
453
  const tool = "tm_dispatch";
@@ -199,41 +473,94 @@ export function buildDispatchTools(deps) {
199
473
  if (!api) {
200
474
  return toToolResult(tmError(tool, "client", `此宿主未暴露异步会话 API(client.session.create/promptAsync),tm_dispatch 不可用——改用内置 task 工具(会阻塞你,但功能等价)。`));
201
475
  }
202
- const label = (typeof args.label === "string" && args.label.trim() ? args.label.trim() : task.split(/\s+/).slice(0, 4).join(" ")).slice(0, 40);
203
- const createApi = api?.create;
204
- const promptApi = api?.promptAsync;
205
- if (typeof createApi !== "function" || typeof promptApi !== "function") {
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") {
206
522
  return toToolResult(tmError(tool, "client", "此宿主的 client.session 缺少 create/promptAsync——改用内置 task 工具。"));
207
523
  }
208
- const created = unwrapClientResult(await createApi({ body: { parentID: parent, title: `tm:${agent}:${label}` } }));
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) } }));
209
527
  if (!created.ok)
210
528
  return toToolResult(tmError(tool, "client", `创建子会话失败:${shorten(created.message ?? "", 120)}`));
211
529
  const sid = String(created.data?.id ?? "").trim();
212
530
  if (!sid)
213
531
  return toToolResult(tmError(tool, "client", "子会话创建后未返回 id"));
532
+ const model = await parentModel(parent, directory);
214
533
  const promptBody = {
215
534
  agent,
216
535
  noReply: false,
536
+ ...(model ? { model } : {}),
217
537
  parts: [{ type: "text", text: task }],
218
538
  };
219
- const directory = typeof c.directory === "string" && c.directory ? c.directory : undefined;
220
- const sent = unwrapClientResult(await promptApi({ path: { id: sid }, ...(directory ? { query: { directory } } : {}), body: promptBody }));
539
+ const sent = unwrapClientResult(await api.promptAsync({ path: { id: sid }, ...(directory ? { query: { directory } } : {}), body: promptBody }));
221
540
  if (!sent.ok) {
222
541
  return toToolResult(tmError(tool, "client", `子会话 ${sid} 已创建但提示未能启动:${shorten(sent.message ?? "", 120)}——可对该 id 直接用内置 task,或本次改用 task 工具。`));
223
542
  }
224
543
  const rec = { sessionID: sid, agent, label, parentSessionID: parent, startedAt: now(), state: "running" };
225
544
  children.set(sid, rec);
226
545
  deps.onChildSession?.(sid, agent);
227
- log({ step_id: "dispatch", event: "start", child: sid, parent: parent, agent, label });
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})`);
228
552
  return toToolResult([
229
553
  `已派发(非阻塞):${agent} · "${label}" · 子会话 ${sid}`,
554
+ model
555
+ ? `模型沿用你当前的:${model.providerID}/${model.modelID}(子会话不自带模型)。`
556
+ : `⚠ 未能从本会话记录里解析出模型,子会话将由宿主决定——若它启动即失败,改用内置 task 工具。`,
230
557
  `你现在可以继续:编排下一步、做廉价检查、或继续派发其它独立任务。`,
231
558
  `收集结果:tm_join(立即看状态)或 tm_join { waitMs: 60000 }。所有派发完成前不要提交结论。`,
232
559
  ].join("\n"));
233
560
  }
234
561
  catch (err) {
235
562
  const e = err;
236
- return toToolResult(tmError(tool, "execute", `tm_dispatch 失败:${shorten(e?.message ?? err, 160)}`));
563
+ return toToolResult(tmError(tool, "execute", `tm_dispatch 失败:${describeHostError(e?.message ?? err, 160)}`));
237
564
  }
238
565
  },
239
566
  };
@@ -242,6 +569,7 @@ export function buildDispatchTools(deps) {
242
569
  - 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.
243
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 }).
244
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.
245
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.`,
246
574
  args: {
247
575
  ids: { descriptor: "ids: string[] (optional — only these child sessions)" },
@@ -260,13 +588,20 @@ export function buildDispatchTools(deps) {
260
588
  }
261
589
  const parent = String(c.sessionID ?? "").trim();
262
590
  const directory = typeof c.directory === "string" && c.directory ? c.directory : undefined;
263
- const idFilter = Array.isArray(args.ids)
264
- ? new Set(args.ids.map((x) => String(x ?? "").trim()).filter(Boolean))
265
- : null;
266
- const mine = [...children.values()].filter((r) => (!parent || r.parentSessionID === parent) && (!idFilter || idFilter.has(r.sessionID)));
591
+ const idList = parseIdList(args.ids);
592
+ const idFilter = idList && idList.length ? new Set(idList) : null;
593
+ const owned = () => [...children.values()].filter((r) => (!parent || r.parentSessionID === parent) && (!idFilter || idFilter.has(r.sessionID)));
594
+ let mine = owned();
595
+ // This process has never seen some (or all) of what the lead is asking
596
+ // for — the host's session tree, not our memory, is the authority.
597
+ if (mine.length < (idFilter ? idFilter.size : 1)) {
598
+ if (await adoptFromHost(parent, directory, idFilter))
599
+ mine = owned();
600
+ }
267
601
  if (!mine.length) {
268
- return toToolResult(`没有待收集的派发${idFilter ? "(ids 未匹配到本会话的子代理)" : ""}。刚派发过却看不到?说明那次派发没成功——回到 tm_dispatch 的返回值检查。`);
602
+ return toToolResult(`没有待收集的派发${idFilter ? "(ids 未匹配到本会话的子代理,宿主会话树里也没有 tm: 标题的子会话)" : ""}。刚派发过却看不到?说明那次派发没成功——回到 tm_dispatch 的返回值检查。`);
269
603
  }
604
+ await settleAdopted(mine, directory);
270
605
  const waitMs = Math.max(0, Math.min(maxWaitMs, Number(args.waitMs) || 0));
271
606
  const deadline = now() + waitMs;
272
607
  let settled = false;
@@ -280,11 +615,10 @@ export function buildDispatchTools(deps) {
280
615
  await sleep(Math.min(1000, Math.max(100, deadline - now())));
281
616
  }
282
617
  const stillRunning = mine.filter((r) => r.state === "running");
283
- const abortApi = api?.abort;
284
618
  if (args.cancel === true || args.cancel === "true") {
285
619
  for (const r of stillRunning) {
286
- if (typeof abortApi === "function") {
287
- const un = unwrapClientResult(await abortApi({ path: { id: r.sessionID } }));
620
+ if (typeof api?.abort === "function") {
621
+ const un = unwrapClientResult(await api.abort({ path: { id: r.sessionID } }));
288
622
  r.error = un.ok ? "aborted on request" : `abort failed: ${shorten(un.message ?? "", 60)}`;
289
623
  }
290
624
  else {
@@ -300,13 +634,35 @@ export function buildDispatchTools(deps) {
300
634
  `派发汇总:${sums.idle} 完成 / ${sums.running} 运行中 / ${sums.error} 失败${settled ? "(全部已结算)" : "(未等到全部结算,可再次 tm_join)"}`,
301
635
  ...mine.map((r) => renderChildLine(r, (r.finishedAt ?? now()) - r.startedAt)),
302
636
  ];
637
+ // Goal tripwire, at the exact moment a lead tends to wrap up: every
638
+ // child settled says nothing about the USER'S goal, and the host's own
639
+ // todo list does. Read-only (GET /session/{id}/todo has no write
640
+ // 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 });
650
+ }
651
+ }
652
+ catch {
653
+ /* the todo endpoint is an extra, never a reason to fail a join */
654
+ }
655
+ }
303
656
  const wantText = !(args.includeText === false || args.includeText === "false");
304
657
  const collectible = mine.filter((r) => r.state !== "running");
305
658
  const blocks = [];
306
659
  if (wantText) {
307
660
  for (const r of collectible) {
308
- const text = await fetchReply(r.sessionID, directory);
309
- blocks.push(`--- ${r.agent} "${r.label}" (${r.sessionID}) ---\n${text.trim() || "(该子会话没有可读的助手回复——用内置 read/tm_read 检查其会话,或重新派发)"}`);
661
+ const reply = await fetchReply(r.sessionID, directory);
662
+ 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 工具重做这一步。`}`);
310
666
  }
311
667
  }
312
668
  const body = [...header, ...(blocks.length ? ["", blocks.join("\n")] : [])].join("\n");
@@ -318,7 +674,7 @@ export function buildDispatchTools(deps) {
318
674
  }
319
675
  catch (err) {
320
676
  const e = err;
321
- return toToolResult(tmError(tool, "execute", `tm_join 失败:${shorten(e?.message ?? err, 160)}`));
677
+ return toToolResult(tmError(tool, "execute", `tm_join 失败:${describeHostError(e?.message ?? err, 160)}`));
322
678
  }
323
679
  },
324
680
  };
@@ -344,7 +700,9 @@ export function buildDispatchTools(deps) {
344
700
  if (type === "session.error") {
345
701
  rec.state = "error";
346
702
  rec.finishedAt = now();
347
- rec.error = shorten(props?.error?.message ?? props?.error ?? "session.error", 120);
703
+ // describeHostError, NOT String(err): the host nests its errors and the
704
+ // bare stringify produced "[object Object]" for a real launch failure
705
+ rec.error = describeHostError(props?.error ?? "session.error", 200);
348
706
  log({ step_id: "events", event: "error", child: sid, agent: rec.agent, reason: rec.error });
349
707
  return;
350
708
  }