@te-river/opencode-team-mode 1.6.2 → 1.7.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 (198) hide show
  1. package/README.md +117 -118
  2. package/README.zh-CN.md +98 -100
  3. package/dist/agents.d.ts.map +1 -1
  4. package/dist/agents.js +89 -79
  5. package/dist/agents.js.map +1 -1
  6. package/dist/approval-gate.d.ts +8 -9
  7. package/dist/approval-gate.d.ts.map +1 -1
  8. package/dist/approval-gate.js +13 -14
  9. package/dist/approval-gate.js.map +1 -1
  10. package/dist/capabilities.d.ts +11 -37
  11. package/dist/capabilities.d.ts.map +1 -1
  12. package/dist/capabilities.js +11 -141
  13. package/dist/capabilities.js.map +1 -1
  14. package/dist/commands.js +1 -1
  15. package/dist/envprotect/hook.d.ts +12 -31
  16. package/dist/envprotect/hook.d.ts.map +1 -1
  17. package/dist/envprotect/hook.js +13 -91
  18. package/dist/envprotect/hook.js.map +1 -1
  19. package/dist/envprotect/patterns.d.ts +6 -4
  20. package/dist/envprotect/patterns.d.ts.map +1 -1
  21. package/dist/envprotect/patterns.js +6 -4
  22. package/dist/envprotect/patterns.js.map +1 -1
  23. package/dist/envprotect.d.ts +1 -1
  24. package/dist/envprotect.d.ts.map +1 -1
  25. package/dist/envprotect.js +1 -1
  26. package/dist/envprotect.js.map +1 -1
  27. package/dist/host/v2-browser-gate.d.ts +19 -1
  28. package/dist/host/v2-browser-gate.d.ts.map +1 -1
  29. package/dist/host/v2-browser-gate.js +47 -6
  30. package/dist/host/v2-browser-gate.js.map +1 -1
  31. package/dist/host/v2-capabilities.d.ts +33 -0
  32. package/dist/host/v2-capabilities.d.ts.map +1 -1
  33. package/dist/host/v2-capabilities.js +50 -1
  34. package/dist/host/v2-capabilities.js.map +1 -1
  35. package/dist/host/v2-compaction.d.ts +129 -0
  36. package/dist/host/v2-compaction.d.ts.map +1 -0
  37. package/dist/host/v2-compaction.js +346 -0
  38. package/dist/host/v2-compaction.js.map +1 -0
  39. package/dist/host/v2-default-agent.d.ts +37 -0
  40. package/dist/host/v2-default-agent.d.ts.map +1 -0
  41. package/dist/host/v2-default-agent.js +128 -0
  42. package/dist/host/v2-default-agent.js.map +1 -0
  43. package/dist/host/v2-events.d.ts +2 -0
  44. package/dist/host/v2-events.d.ts.map +1 -1
  45. package/dist/host/v2-events.js +27 -0
  46. package/dist/host/v2-events.js.map +1 -1
  47. package/dist/host/v2-guard.d.ts +178 -0
  48. package/dist/host/v2-guard.d.ts.map +1 -1
  49. package/dist/host/v2-guard.js +324 -8
  50. package/dist/host/v2-guard.js.map +1 -1
  51. package/dist/host/v2-offload.d.ts +1 -1
  52. package/dist/host/v2-offload.js +2 -2
  53. package/dist/host/v2-permissions.d.ts +45 -44
  54. package/dist/host/v2-permissions.d.ts.map +1 -1
  55. package/dist/host/v2-permissions.js +48 -49
  56. package/dist/host/v2-permissions.js.map +1 -1
  57. package/dist/host/v2-probe-chain.d.ts +93 -0
  58. package/dist/host/v2-probe-chain.d.ts.map +1 -0
  59. package/dist/host/v2-probe-chain.js +146 -0
  60. package/dist/host/v2-probe-chain.js.map +1 -0
  61. package/dist/host/v2-prune.d.ts +157 -0
  62. package/dist/host/v2-prune.d.ts.map +1 -0
  63. package/dist/host/v2-prune.js +507 -0
  64. package/dist/host/v2-prune.js.map +1 -0
  65. package/dist/host/v2-retry.d.ts +143 -0
  66. package/dist/host/v2-retry.d.ts.map +1 -0
  67. package/dist/host/v2-retry.js +315 -0
  68. package/dist/host/v2-retry.js.map +1 -0
  69. package/dist/host/v2-scope.d.ts.map +1 -1
  70. package/dist/host/v2-scope.js +14 -7
  71. package/dist/host/v2-scope.js.map +1 -1
  72. package/dist/host/v2-session-client.d.ts +24 -0
  73. package/dist/host/v2-session-client.d.ts.map +1 -1
  74. package/dist/host/v2-session-client.js +243 -3
  75. package/dist/host/v2-session-client.js.map +1 -1
  76. package/dist/host/v2-session.d.ts +72 -1
  77. package/dist/host/v2-session.d.ts.map +1 -1
  78. package/dist/host/v2-session.js +154 -10
  79. package/dist/host/v2-session.js.map +1 -1
  80. package/dist/host/v2-tool.d.ts +2 -3
  81. package/dist/host/v2-tool.d.ts.map +1 -1
  82. package/dist/host/v2-tool.js +2 -3
  83. package/dist/host/v2-tool.js.map +1 -1
  84. package/dist/host/v2-types.d.ts +25 -0
  85. package/dist/host/v2-types.d.ts.map +1 -1
  86. package/dist/host/v2.d.ts +14 -12
  87. package/dist/host/v2.d.ts.map +1 -1
  88. package/dist/host/v2.js +500 -73
  89. package/dist/host/v2.js.map +1 -1
  90. package/dist/identity.d.ts +27 -0
  91. package/dist/identity.d.ts.map +1 -0
  92. package/dist/identity.js +34 -0
  93. package/dist/identity.js.map +1 -0
  94. package/dist/index.d.ts +9 -24
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +17 -27
  97. package/dist/index.js.map +1 -1
  98. package/dist/prompts/lead.d.ts +1 -1
  99. package/dist/prompts/lead.d.ts.map +1 -1
  100. package/dist/prompts/lead.js +38 -25
  101. package/dist/prompts/lead.js.map +1 -1
  102. package/dist/prompts/shared.d.ts +2 -2
  103. package/dist/prompts/shared.d.ts.map +1 -1
  104. package/dist/prompts/shared.js +66 -56
  105. package/dist/prompts/shared.js.map +1 -1
  106. package/dist/prompts/specialists.d.ts +2 -2
  107. package/dist/prompts/specialists.d.ts.map +1 -1
  108. package/dist/prompts/specialists.js +53 -83
  109. package/dist/prompts/specialists.js.map +1 -1
  110. package/dist/tm/args-schema.d.ts +7 -32
  111. package/dist/tm/args-schema.d.ts.map +1 -1
  112. package/dist/tm/args-schema.js +12 -153
  113. package/dist/tm/args-schema.js.map +1 -1
  114. package/dist/tm/board.d.ts +10 -9
  115. package/dist/tm/board.d.ts.map +1 -1
  116. package/dist/tm/board.js +11 -10
  117. package/dist/tm/board.js.map +1 -1
  118. package/dist/tm/config-files.d.ts +81 -0
  119. package/dist/tm/config-files.d.ts.map +1 -0
  120. package/dist/tm/config-files.js +209 -0
  121. package/dist/tm/config-files.js.map +1 -0
  122. package/dist/tm/config-layers.d.ts +117 -0
  123. package/dist/tm/config-layers.d.ts.map +1 -0
  124. package/dist/tm/config-layers.js +320 -0
  125. package/dist/tm/config-layers.js.map +1 -0
  126. package/dist/tm/config.d.ts +10 -101
  127. package/dist/tm/config.d.ts.map +1 -1
  128. package/dist/tm/config.js +17 -71
  129. package/dist/tm/config.js.map +1 -1
  130. package/dist/tm/dispatch.d.ts +115 -21
  131. package/dist/tm/dispatch.d.ts.map +1 -1
  132. package/dist/tm/dispatch.js +424 -67
  133. package/dist/tm/dispatch.js.map +1 -1
  134. package/dist/tm/egress.d.ts +19 -7
  135. package/dist/tm/egress.d.ts.map +1 -1
  136. package/dist/tm/egress.js +21 -9
  137. package/dist/tm/egress.js.map +1 -1
  138. package/dist/tm/index.d.ts +23 -20
  139. package/dist/tm/index.d.ts.map +1 -1
  140. package/dist/tm/index.js +117 -116
  141. package/dist/tm/index.js.map +1 -1
  142. package/dist/tm/ledger.d.ts +9 -2
  143. package/dist/tm/ledger.d.ts.map +1 -1
  144. package/dist/tm/ledger.js +4 -1
  145. package/dist/tm/ledger.js.map +1 -1
  146. package/dist/tm/perm-ask.d.ts +3 -3
  147. package/dist/tm/perm-ask.d.ts.map +1 -1
  148. package/dist/tm/perm-ask.js +3 -3
  149. package/dist/tm/perm-ask.js.map +1 -1
  150. package/dist/tm/pipelines.d.ts +12 -11
  151. package/dist/tm/pipelines.d.ts.map +1 -1
  152. package/dist/tm/pipelines.js +40 -207
  153. package/dist/tm/pipelines.js.map +1 -1
  154. package/dist/tm/preview.d.ts +1 -1
  155. package/dist/tm/preview.js +3 -3
  156. package/dist/tm/preview.js.map +1 -1
  157. package/dist/tm/refs.d.ts +31 -4
  158. package/dist/tm/refs.d.ts.map +1 -1
  159. package/dist/tm/refs.js +48 -4
  160. package/dist/tm/refs.js.map +1 -1
  161. package/dist/tm/result.d.ts +1 -1
  162. package/dist/tm/result.d.ts.map +1 -1
  163. package/dist/tm/result.js +4 -3
  164. package/dist/tm/result.js.map +1 -1
  165. package/dist/tm/search.d.ts +2 -2
  166. package/dist/tm/search.js +7 -7
  167. package/dist/tm/search.js.map +1 -1
  168. package/dist/tm/serp-loop.d.ts +19 -7
  169. package/dist/tm/serp-loop.d.ts.map +1 -1
  170. package/dist/tm/serp-loop.js +24 -9
  171. package/dist/tm/serp-loop.js.map +1 -1
  172. package/dist/tm/stats.d.ts +12 -27
  173. package/dist/tm/stats.d.ts.map +1 -1
  174. package/dist/tm/stats.js +43 -45
  175. package/dist/tm/stats.js.map +1 -1
  176. package/dist/tm/store.d.ts +18 -5
  177. package/dist/tm/store.d.ts.map +1 -1
  178. package/dist/tm/store.js +39 -9
  179. package/dist/tm/store.js.map +1 -1
  180. package/dist/tm/tools.d.ts +7 -5
  181. package/dist/tm/tools.d.ts.map +1 -1
  182. package/dist/tm/tools.js +10 -41
  183. package/dist/tm/tools.js.map +1 -1
  184. package/dist/tm/webfetch.d.ts +1 -1
  185. package/dist/tm/webfetch.d.ts.map +1 -1
  186. package/dist/tm/webfetch.js +38 -23
  187. package/dist/tm/webfetch.js.map +1 -1
  188. package/dist/types.d.ts +40 -123
  189. package/dist/types.d.ts.map +1 -1
  190. package/dist/types.js +7 -21
  191. package/dist/types.js.map +1 -1
  192. package/docs/installation-v2.md +109 -33
  193. package/docs/installation.md +9 -0
  194. package/docs/research/browser-pane.md +5 -0
  195. package/docs/v2-port-notes.md +87 -0
  196. package/package.json +1 -1
  197. package/scripts/gen-v2-config.mjs +246 -94
  198. package/scripts/lib/fake-ctx.mjs +75 -0
@@ -28,7 +28,20 @@
28
28
  * through the SAME governance pipeline as every other tm_* tool, so a long
29
29
  * sub-agent report arrives as a handle + an 80-token preview the lead can page
30
30
  * through with tm_fetch — not kilotokens dumped inline.
31
+ *
32
+ * Steering (2026-10-04): the same tool is also the lead's INTERJECTION channel —
33
+ * `steer` puts a line into a running child's inbox (`Session.Inbox.Delivery =
34
+ * "steer" | "queue"`, "Steering wakes session execution."), `unread` lists that
35
+ * child's undelivered items by id and shape, `unsend` withdraws one that has not
36
+ * been delivered yet. No new tool was registered for it: a new tool name would
37
+ * have to move the whitelist in agents.ts, the permission triples in
38
+ * v2-permissions.ts and the request-layer deletion logic at once, and three
39
+ * places that can drift are worse than one argument set on a tool the lead
40
+ * already calls. What the host does NOT give is tool-level cancellation — the
41
+ * only thing stoppable is the whole active execution (`interrupt`) — so nothing
42
+ * here pretends that granularity exists.
31
43
  */
44
+ import { sameAgent } from "../identity.js";
32
45
  import { tmError, toToolResult } from "./result.js";
33
46
  import { unwrapClientResult } from "./client-unwrap.js";
34
47
  import { detectContentType } from "./preview.js";
@@ -70,35 +83,6 @@ export function openHostTodos(todos) {
70
83
  }
71
84
  return open;
72
85
  }
73
- /** #80: the line tm_join adds when a SETTLED child still owns a browser window.
74
- * Takes only the leases already filtered to settled children, so the ownership
75
- * rule stays where the child registry is; this function's whole job is to say
76
- * it in the one shape that survives the lead's skimming — and to refuse to say
77
- * it for a window that was never ours. */
78
- export function leaseTripwire(held, callerSessionID) {
79
- if (!held.length)
80
- return null;
81
- const isMine = (l) => !!callerSessionID && l.owner === callerSessionID;
82
- const mine = held.filter(isMine);
83
- const theirs = held.filter((l) => !isMine(l));
84
- const at = (l) => `空闲 ${Math.round(l.idleMs / 1000)}s`;
85
- const parts = [];
86
- if (theirs.length) {
87
- parts.push(`⚠ ${theirs.length} 个浏览器还开着(子代理已结算,但它没 close):` +
88
- theirs.map((l) => `${l.id}(${l.agent || "未知角色"} · ${at(l)})`).join("、") +
89
- `\n要么让它 close 并引用工具自己的三种裁决之一(已确认关闭 / 进程未核验 / 警告:关闭未完全成功),` +
90
- `要么由你向用户写明为什么留着。不要替它写"已关闭"——那是把没人核实过的结论交付出去。`);
91
- }
92
- // The caller's own window is the one the user is looking at, and the live
93
- // recheck proved this branch was the missing one: a lead that keeps a browser
94
- // across rounds is legitimate, so this is a reminder, not an accusation —
95
- // but silence is the defect.
96
- for (const l of mine) {
97
- parts.push(`(你自己还占着 ${l.id},${at(l)}。收尾前 action:"close" 并引用工具的裁决句;` +
98
- `如果确实要跨轮留着,就在回复里向用户说明为什么。)`);
99
- }
100
- return parts.join("\n");
101
- }
102
86
  /** Resolve the host session API defensively — `messages` is what tm_join
103
87
  * cannot work without (the collected reply); a host without it means the
104
88
  * caller must degrade, and `create`/`promptAsync` are deliberately NOT part
@@ -311,6 +295,20 @@ export function summarizeStates(children) {
311
295
  }
312
296
  return { running, idle, error };
313
297
  }
298
+ /**
299
+ * The children of ONE caller that are still running — the concurrency cap's
300
+ * data source (#49 feature 2). Pure, and reads the SAME registry `tm_join`
301
+ * uses rather than a second bookkeeping table: the rows already carry
302
+ * `parentSessionID`, so "how many of THIS session's children are live" is a
303
+ * filter, not a new store. An empty caller id matches nothing (never "all"),
304
+ * because a missing session id must not turn one session's cap into a global one.
305
+ */
306
+ export function runningChildrenOf(children, callerSessionID) {
307
+ const caller = String(callerSessionID ?? "").trim();
308
+ if (!caller)
309
+ return [];
310
+ return children.filter((c) => c.parentSessionID === caller && c.state === "running");
311
+ }
314
312
  /**
315
313
  * Sleep used only by tm_join's bounded wait. Deliberately a plain timer:
316
314
  * it never holds the event loop (the wait is awaited, not spun) and it is
@@ -401,8 +399,202 @@ export function cancelOutcomeParts(tally) {
401
399
  }
402
400
  return out;
403
401
  }
402
+ /** Read the acceptance verdict out of the v2 bridge's `{data:{outcome,inboxID,seam,message}}`.
403
+ * Anything unrecognisable is `not-steered`, never `steered` — a shape we cannot read is not
404
+ * evidence a child was told anything. The id check is applied HERE as well as in the seam:
405
+ * a `steered` that carries no `^msg_` id contradicts itself, and the conservative reading
406
+ * wins. */
407
+ export function steerOutcomeOf(res) {
408
+ const un = unwrapClientResult(res);
409
+ if (!un.ok)
410
+ return { outcome: "not-steered", note: un.message };
411
+ const data = un.data;
412
+ const o = String(data?.outcome ?? "");
413
+ const seam = typeof data?.seam === "string" && data.seam ? data.seam : undefined;
414
+ const note = typeof data?.message === "string" && data.message ? data.message : undefined;
415
+ const known = ["steered", "not-steered", "no-seam"];
416
+ if (!known.includes(o)) {
417
+ return {
418
+ outcome: "not-steered",
419
+ note: o ? `宿主返回了没认出的结果(${shorten(o, 24)})` : (note ?? "宿主没有回 inbox id"),
420
+ ...(seam ? { seam } : {}),
421
+ };
422
+ }
423
+ if (o === "no-seam")
424
+ return { outcome: "no-seam", ...(note ? { note } : {}) };
425
+ const raw = typeof data?.inboxID === "string" ? data.inboxID.trim() : "";
426
+ const inboxID = /^msg_/.test(raw) ? raw : "";
427
+ if (o === "steered") {
428
+ if (!inboxID)
429
+ return { outcome: "not-steered", note: "宿主说受理但没有给出 ^msg_ 形状的 inbox id,无法确认入队", ...(seam ? { seam } : {}) };
430
+ return { outcome: "steered", inboxID, ...(seam ? { seam } : {}) };
431
+ }
432
+ return { outcome: "not-steered", ...(note ? { note } : {}), ...(seam ? { seam } : {}) };
433
+ }
434
+ /** What the lead reads, per outcome. Only the first may carry a success word, and none of
435
+ * them may claim the child has seen the text. */
436
+ export function steerVerdictLine(outcome, opts) {
437
+ const seam = opts?.seam ? ` · seam=${opts.seam}` : "";
438
+ switch (outcome) {
439
+ case "steered":
440
+ return `已受理${seam} · inbox=${opts?.inboxID ?? "?"} —— 宿主把它放进该子会话的 inbox,并在步边界唤醒执行(文档原句 "Steering wakes session execution.")。这不等于子代理已经读到这条插话。`;
441
+ case "no-seam":
442
+ return `这个宿主没给插话的缝(ctx.session 既没有 prompt 也没有 synthetic),未发送${opts?.note ? `:${shorten(opts.note, 80)}` : ""}`;
443
+ case "not-steered":
444
+ return `未插话${seam}:${shorten(opts?.note ?? "宿主没有回 inbox id", 120)}`;
445
+ }
446
+ }
447
+ export function steerOutcomeLabel(o) {
448
+ return o === "steered" ? "已受理" : o === "not-steered" ? "未插话" : "无插话缝";
449
+ }
450
+ /** Fixed order so a rerun prints the same line; an absent outcome prints nothing, not "0". */
451
+ export function steerOutcomeParts(tally) {
452
+ const order = ["steered", "not-steered", "no-seam"];
453
+ const out = [];
454
+ for (const o of order) {
455
+ const n = tally[o];
456
+ if (n)
457
+ out.push(`${n} ${steerOutcomeLabel(o)}`);
458
+ }
459
+ return out;
460
+ }
461
+ export function unsendOutcomeOf(res) {
462
+ const un = unwrapClientResult(res);
463
+ if (!un.ok)
464
+ return { outcome: "threw", note: un.message };
465
+ const data = un.data;
466
+ const o = String(data?.outcome ?? "");
467
+ const known = ["cancelled", "noop", "threw", "no-seam"];
468
+ const note = typeof data?.message === "string" && data.message ? data.message : undefined;
469
+ if (!known.includes(o)) {
470
+ // The host answered with something we cannot read. It is NOT a cancel, and calling a
471
+ // parse miss a refusal would be inventing a host verdict — so it keeps the "answered,
472
+ // named nothing" reading with what it saw attached.
473
+ return { outcome: "noop", note: o ? `宿主返回了没认出的结果(${shorten(o, 24)})` : (note ?? "宿主应答但没有指认被撤回的项") };
474
+ }
475
+ if (o === "cancelled") {
476
+ const raw = typeof data?.inboxID === "string" ? data.inboxID.trim() : "";
477
+ if (!/^msg_/.test(raw))
478
+ return { outcome: "noop", note: "宿主回说撤回但没有给出 ^msg_ 形状的 id" };
479
+ return { outcome: "cancelled", inboxID: raw };
480
+ }
481
+ if (o === "threw")
482
+ return { outcome: "threw", ...(note ? { note } : {}) };
483
+ if (o === "no-seam")
484
+ return { outcome: "no-seam", ...(note ? { note } : {}) };
485
+ return { outcome: "noop", ...(note ? { note } : {}) };
486
+ }
487
+ export function unsendVerdictLine(outcome, opts) {
488
+ switch (outcome) {
489
+ case "cancelled":
490
+ return `已撤回(宿主回指 inbox=${opts?.inboxID ?? "?"})—— 它此前还没被投递`;
491
+ case "noop":
492
+ return `宿主应答了但没有指认被撤回的项${opts?.note ? `(${shorten(opts.note, 80)})` : ""} —— 按文档 "Unavailable items are a no-op",已投递或不存在的项就是这个结果,所以我没有撤回任何东西可报告`;
493
+ case "threw":
494
+ return `撤回被宿主拒绝:${shorten(opts?.note ?? "", 120)}`;
495
+ case "no-seam":
496
+ return "这个宿主没给 session.inbox.cancel 的缝,未发送撤回";
497
+ }
498
+ }
499
+ export function unsendOutcomeLabel(o) {
500
+ return o === "cancelled" ? "已撤回" : o === "noop" ? "应答未指认" : o === "threw" ? "被宿主拒绝" : "无撤回缝";
501
+ }
502
+ export function unsendOutcomeParts(tally) {
503
+ const order = ["cancelled", "noop", "threw", "no-seam"];
504
+ const out = [];
505
+ for (const o of order) {
506
+ const n = tally[o];
507
+ if (n)
508
+ out.push(`${n} ${unsendOutcomeLabel(o)}`);
509
+ }
510
+ return out;
511
+ }
512
+ /** `unsend` arrives in three shapes and NONE of them is guessed at: a real object, a JSON
513
+ * string (models send objects as text — the exact `ids` failure this file already records),
514
+ * or the compact `"ses_…|msg_…"`. A missing field is an error, never a default, because
515
+ * withdrawing the wrong inbox item is a write to someone else's queue. */
516
+ export function parseUnsendArg(raw) {
517
+ let o = null;
518
+ if (raw && typeof raw === "object")
519
+ o = raw;
520
+ else if (typeof raw === "string") {
521
+ const s = raw.trim();
522
+ if (s.startsWith("{")) {
523
+ try {
524
+ const parsed = JSON.parse(s);
525
+ if (parsed && typeof parsed === "object")
526
+ o = parsed;
527
+ else
528
+ return { ok: false, error: `unsend 的 JSON 文本不是一个对象(收到 ${shorten(s, 60)})` };
529
+ }
530
+ catch {
531
+ return { ok: false, error: `unsend 的 JSON 文本解析失败(收到 ${shorten(s, 60)})` };
532
+ }
533
+ }
534
+ else {
535
+ const m = /^(ses[\w-]*)\s*[|/]\s*(msg_[\w-]+)$/.exec(s);
536
+ if (!m)
537
+ return { ok: false, error: `unsend 需要 {"sessionID":"ses_…","inboxID":"msg_…"}(或 "ses_…|msg_…"),收到 ${shorten(s, 60)}` };
538
+ o = { sessionID: m[1], inboxID: m[2] };
539
+ }
540
+ }
541
+ else {
542
+ return { ok: false, error: `unsend 需要对象 {sessionID, inboxID},收到 ${raw === undefined ? "undefined" : typeof raw}` };
543
+ }
544
+ const sessionID = String(o?.sessionID ?? o?.id ?? "").trim();
545
+ const inboxID = String(o?.inboxID ?? o?.inbox_id ?? o?.messageID ?? "").trim();
546
+ if (!sessionID.startsWith("ses"))
547
+ return { ok: false, error: `unsend.sessionID 不是一个会话 id(收到 ${shorten(sessionID || "(空)", 40)})` };
548
+ if (!/^msg_/.test(inboxID))
549
+ return { ok: false, error: `unsend.inboxID 必须匹配宿主文档的 ^msg_ 模式(收到 ${shorten(inboxID || "(空)", 40)})` };
550
+ return { ok: true, sessionID, inboxID };
551
+ }
552
+ /**
553
+ * Undelivered inbox items, reduced to a NAME and a SHAPE — never the body. An item's text
554
+ * is the lead's own interjection or a child's brief, and the R6 口径 binds a read-back as
555
+ * much as a diagnostic, so the answer can say "there are 3, here are their ids" and the
556
+ * caller can `unsend` one without any of their content entering the parent's context.
557
+ * `skipped` is counted rather than silent: an item whose id does not match the documented
558
+ * `^msg_` pattern is a host shape we did not recognise, not an item that does not exist.
559
+ */
560
+ export function inboxItemLines(list, max = 20) {
561
+ if (!Array.isArray(list))
562
+ return { lines: [], total: 0, skipped: 0 };
563
+ const lines = [];
564
+ let skipped = 0;
565
+ for (const item of list.slice(0, max)) {
566
+ if (!item || typeof item !== "object") {
567
+ skipped++;
568
+ continue;
569
+ }
570
+ const rec = item;
571
+ const info = rec.info && typeof rec.info === "object" ? rec.info : undefined;
572
+ const id = String(rec.inboxID ?? rec.id ?? info?.id ?? "").trim();
573
+ if (!/^msg_/.test(id)) {
574
+ skipped++;
575
+ continue;
576
+ }
577
+ const kind = String(rec.kind ?? rec.role ?? info?.role ?? rec.type ?? "")
578
+ .trim()
579
+ .toLowerCase()
580
+ .slice(0, 16);
581
+ const delivery = String(rec.delivery ?? info?.delivery ?? "").trim().toLowerCase().slice(0, 8);
582
+ const parts = [`id=${id}`];
583
+ if (kind)
584
+ parts.push(`kind=${kind}`);
585
+ if (delivery)
586
+ parts.push(`delivery=${delivery}`);
587
+ // a LENGTH only — the text itself is never copied into the parent's reply
588
+ if (typeof rec.text === "string")
589
+ parts.push(`textlen=${rec.text.length}`);
590
+ lines.push(parts.join(" · "));
591
+ }
592
+ if (list.length > max)
593
+ skipped += list.length - max;
594
+ return { lines, total: list.length, skipped };
595
+ }
404
596
  export function buildDispatchTools(deps) {
405
- const leadAgent = deps.leadAgent ?? "team";
597
+ const leadAgent = deps.leadAgent ?? "Team";
406
598
  const targets = deps.targets ?? DISPATCH_TARGETS;
407
599
  const maxWaitMs = deps.maxWaitMs ?? 60_000;
408
600
  const now = deps.now ?? (() => Date.now());
@@ -428,8 +620,8 @@ export function buildDispatchTools(deps) {
428
620
  return;
429
621
  let un;
430
622
  try {
431
- // PROPERTY-ACCESS CALL — the SDK's endpoints need their receiver (see
432
- // approval-gate.ts's replyCapableFn note); `const f = api.status` then
623
+ // PROPERTY-ACCESS CALL — the SDK's endpoints need their receiver;
624
+ // `const f = api.status` then
433
625
  // `f(...)` threw "Cannot read properties of undefined (reading
434
626
  // 'client')" on the live host and killed every dispatch.
435
627
  un = unwrapClientResult(await api.status(directory ? { query: { directory } } : {}));
@@ -597,11 +789,50 @@ export function buildDispatchTools(deps) {
597
789
  }
598
790
  }
599
791
  }
792
+ /** #steer — the PARENTAGE GATE, and a hard one: an interjection is a WRITE into
793
+ * someone else's conversation, so it takes the lock `claimNamedChild` takes and takes
794
+ * it BEFORE anything is sent — the host itself must say `parentID === the caller`.
795
+ * Three shapes are all refusals and all fail closed: no seam to ask, a call that
796
+ * threw, and a session whose parent the host did not name (an `unknown` ownership is
797
+ * never treated as ours — the same rule `v2-scope` applies to sessions). A foreign
798
+ * parent is NAMED, because "whose child is it" is the question the lead has next. */
799
+ async function parentageOf(sid, parent, directory) {
800
+ if (!sid)
801
+ return { ok: false, why: "没有点名子会话 id" };
802
+ if (!parent)
803
+ return { ok: false, why: "调用方会话 id 缺失,无法核对归属" };
804
+ if (sid === parent)
805
+ return { ok: false, why: "那是你自己的会话,不是子代理" };
806
+ if (typeof api?.get !== "function") {
807
+ return { ok: false, why: "这个宿主客户端没有给出读取会话的缝(session.get),我无法确认它是你的子会话,因此没有发送" };
808
+ }
809
+ let un;
810
+ try {
811
+ un = unwrapClientResult(await api.get({ path: { id: sid }, ...(directory ? { query: { directory } } : {}) }));
812
+ }
813
+ catch (err) {
814
+ return { ok: false, why: `向宿主核对归属失败:${describeHostError(err?.message ?? err)}` };
815
+ }
816
+ if (!un.ok)
817
+ return { ok: false, why: `宿主没有给出这个会话的信息:${shorten(un.message ?? "", 90)}` };
818
+ const info = un.data;
819
+ const pid = String(info?.parentID ?? "").trim();
820
+ if (!pid)
821
+ return { ok: false, why: "宿主没有回 parentID —— 归属未知,未知不当作自己的,未发送" };
822
+ if (pid !== parent) {
823
+ // Both ids printed in full (bounded, not to 24): "whose child is it" is the question
824
+ // the lead has next, and a truncated id is one it cannot act on.
825
+ return { ok: false, why: `它是会话 ${shorten(pid, 40)} 的子代理,不是本会话(${shorten(parent, 40)})的 —— 不是我的,我不碰` };
826
+ }
827
+ const rawAgent = String(info?.agent ?? "").trim().toLowerCase();
828
+ return { ok: true, agent: rawAgent || children.get(sid)?.agent || "subagent" };
829
+ }
600
830
  const join = {
601
831
  description: `Collect the sub-agent work attached to THIS session — the read-back side of delegation. You reach for it in two situations: the host's background \`task\` finished and TeamMode replaced its injected reply with a preview (pull the whole thing back with \`{ ids: ["<child session>"] }\`), or a dispatched child from before this build is still open and needs collecting or cancelling.
602
832
  - No args: status snapshot of every open child of THIS session — running / idle(done) / error, with elapsed seconds. Cheap and non-blocking: use it to decide whether to keep working or start merging.
603
833
  - { waitMs: 30000 }: bounded wait (capped at ${waitLabel(maxWaitMs)}) until every child settles. A wait BLOCKS YOU — your turn is parked in this tool call, so it is the synchronous \`task\` experience with none of its visibility. Wait once, briefly, and only when the very next step needs the answer; a second consecutive wait after nothing settled is cut to 10s and answered with what to do instead. Never wait for a child whose result you do not need — abort it instead ({ cancel: true }).
604
834
  - { 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.
835
+ - Steer a child while it runs — the same call, no separate tool: { steer: "先停手,验收改成 X", ids: ["<child>"] } admits the line into that child's inbox with delivery "steer" (or "queue") and the host wakes execution at the step boundary. 已受理 is the whole claim: it is NOT proof the child read it, and the reply never says 已送达. { unread: true, ids: ["<child>"] } lists that child's undelivered inbox items (ids + shape only, never the body); { unsend: {"sessionID":"ses_…","inboxID":"msg_…"} } withdraws one that has not been delivered — an already-delivered item is the documented no-op and is reported as one. All three ask the host for parentage FIRST and refuse a session that is not yours; a host without the seam answers no-seam instead of pretending.
605
836
  - 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.
606
837
  - 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.`,
607
838
  args: {
@@ -609,6 +840,26 @@ export function buildDispatchTools(deps) {
609
840
  waitMs: { descriptor: `waitMs: number (optional 0..${maxWaitMs} — bounded wait; a SECOND consecutive wait is cut to 10s, because waiting is not parallel work)` },
610
841
  cancel: { descriptor: "cancel: true (abort the still-running children in this set)" },
611
842
  includeText: { descriptor: "includeText: false to get only the status table (default true)" },
843
+ // #steer — the four interjection args. They ride the DESCRIPTOR channel (tm_join
844
+ // builds its args without zod), and on v2 that channel is what the host serialises
845
+ // into the parameter spec, so the head token decides the type: `string` → string,
846
+ // `steer|queue` → an enum, `true` → boolean. `unsend` is described as a STRING on
847
+ // purpose — the descriptor parser has no object rule, and a parameter the schema
848
+ // calls an object while the host validates strictly is a parameter the model cannot
849
+ // send — so the object shape arrives as its JSON text, and execute() also accepts a
850
+ // real object and the compact "ses_…|msg_…" form (parseUnsendArg).
851
+ steer: {
852
+ descriptor: 'steer: string (optional — one line to interject into the child named by ids; the host admits it as inbox input and wakes execution at the step boundary, so 已受理 is not the same claim as 已送达. Needs exactly one id, and parentage is verified with the host first.)',
853
+ },
854
+ delivery: {
855
+ descriptor: "delivery: steer|queue (optional, default steer — applies to steer only. Any other value is refused rather than silently defaulted.)",
856
+ },
857
+ unread: {
858
+ descriptor: "unread: true (optional — list the undelivered inbox items of the child named by ids: ids and shape only, never the body)",
859
+ },
860
+ unsend: {
861
+ descriptor: 'unsend: string (optional — the JSON object as text {"sessionID":"ses_…","inboxID":"msg_…"}, or "ses_…|msg_…"; withdraws an item that has not been delivered yet. An unavailable item is the documented no-op and is reported as one, not as a success.)',
862
+ },
612
863
  },
613
864
  execute: async (rawArgs, ctx) => {
614
865
  const tool = "tm_join";
@@ -616,7 +867,7 @@ export function buildDispatchTools(deps) {
616
867
  const args = (rawArgs ?? {});
617
868
  const c = (ctx ?? {});
618
869
  const caller = String(c.agent ?? "").trim();
619
- if (caller && caller !== leadAgent) {
870
+ if (caller && !sameAgent(caller, leadAgent)) {
620
871
  return toToolResult(tmError(tool, "governance", `只有 ${leadAgent} 能收集派发结果。`));
621
872
  }
622
873
  const parent = String(c.sessionID ?? "").trim();
@@ -657,37 +908,152 @@ export function buildDispatchTools(deps) {
657
908
  }
658
909
  }
659
910
  }
660
- // #87: computed BEFORE any early return. The live regression of #86 was
661
- // a lead holding a browser while tm_join answered "nothing to collect" —
662
- // a SYNCHRONOUS host task never registers in this table (the host
663
- // collects it inline), so the header assembly below never ran, and the
664
- // window the user was looking at went unreported again. A forgotten
665
- // browser has to surface on EVERY answer this tool gives.
666
- const leaseLine = () => {
667
- if (typeof deps.browserLeases !== "function")
668
- return "";
669
- let held = [];
670
- try {
671
- const done = new Set(mine.filter((r) => r.state !== "running").map((r) => r.sessionID));
672
- held = deps.browserLeases().filter((l) => done.has(l.owner) || l.owner === parent);
911
+ // ── #steer: 插话 / 未读 / 撤回 ──────────────────────────────────────────────
912
+ // Three new actions, each answered and returned HERE. A call that names none of
913
+ // them falls through to the collect/wait flow below untouched, which is what keeps
914
+ // the shipped `ids` / `waitMs` / `cancel` / `includeText` semantics byte-exact.
915
+ const steerText = typeof args.steer === "string" ? args.steer.trim() : "";
916
+ const wantsUnread = args.unread === true || args.unread === "true";
917
+ const unsendRequested = args.unsend !== undefined && args.unsend !== null && args.unsend !== "";
918
+ const steerActions = (steerText ? 1 : 0) + (wantsUnread ? 1 : 0) + (unsendRequested ? 1 : 0);
919
+ if (!steerActions && args.steer !== undefined) {
920
+ return toToolResult(tmError(tool, "args", "steer 需要非空文本(只要状态就去掉它,或改用 unread / cancel)。"));
921
+ }
922
+ if (steerActions > 1) {
923
+ return toToolResult(tmError(tool, "args", "一次只能做一件插话动作:steer / unread / unsend 三选一 —— 合在一起,三种结论就没法分开计数了。"));
924
+ }
925
+ if (steerActions) {
926
+ const deliveryRaw = typeof args.delivery === "string" ? args.delivery.trim().toLowerCase() : "";
927
+ if (args.delivery !== undefined && deliveryRaw !== "steer" && deliveryRaw !== "queue") {
928
+ return toToolResult(tmError(tool, "args", `delivery 只能是 "steer" 或 "queue"(收到 ${shorten(String(args.delivery ?? ""), 24)}),未发送。`));
673
929
  }
674
- catch {
675
- /* the lease table is an extra, never a reason to fail a join */
676
- return "";
930
+ // The documented default is not something we let the host decide: an omitted
931
+ // `delivery` is spelled `steer` here, so the value in the reply is the value that
932
+ // was sent rather than a guess about this build's inbox scheduler.
933
+ const delivery = deliveryRaw === "queue" ? "queue" : "steer";
934
+ const unsendParsed = unsendRequested ? parseUnsendArg(args.unsend) : null;
935
+ if (unsendParsed && !unsendParsed.ok)
936
+ return toToolResult(tmError(tool, "args", `${unsendParsed.error}。`));
937
+ const unsendID = unsendParsed && unsendParsed.ok ? unsendParsed.inboxID : "";
938
+ let target = unsendID ? (unsendParsed && unsendParsed.ok ? unsendParsed.sessionID : "") : "";
939
+ if (!target) {
940
+ if (!idFilter) {
941
+ return toToolResult(tmError(tool, "args", `${steerText ? "steer" : "unread"} 要用 ids 点名一个子会话:{ ids: ["ses_…"], ${steerText ? "steer" : "unread"}: … }。`));
942
+ }
943
+ if (idFilter.size !== 1) {
944
+ return toToolResult(tmError(tool, "args", `插话只能针对一个子会话(ids 里有 ${idFilter.size} 个)。`));
945
+ }
946
+ target = [...idFilter][0];
677
947
  }
678
- const line = leaseTripwire(held, parent) ?? "";
679
- if (line) {
948
+ const gate = await parentageOf(target, parent, directory);
949
+ if (!gate.ok) {
680
950
  log({
681
951
  step_id: "join",
682
- event: "lease_held",
683
- count: held.length,
684
- ids: held.map((l) => l.id).join(","),
952
+ event: unsendID ? "unsend_refused" : steerText ? "steer_refused" : "unread_refused",
953
+ child: target,
954
+ reason: "parentage",
685
955
  });
956
+ return toToolResult(`没有发送:${gate.why}。`);
686
957
  }
687
- return line;
688
- };
958
+ const row = children.get(target);
959
+ const rowLine = row
960
+ ? renderChildLine(row, (row.finishedAt ?? now()) - row.startedAt)
961
+ : `${target} · ${gate.agent} ·(本进程没有它的登记行,归属由宿主确认)`;
962
+ if (steerText) {
963
+ let outcome = "no-seam";
964
+ let inboxID;
965
+ let note;
966
+ let seam;
967
+ if (api && api.v2SteerSeam === true && typeof api.prompt === "function") {
968
+ const got = steerOutcomeOf(await api.prompt({ sessionID: target, text: steerText, delivery }));
969
+ outcome = got.outcome;
970
+ inboxID = got.inboxID;
971
+ note = got.note;
972
+ seam = got.seam;
973
+ }
974
+ else {
975
+ // The v1 client is NOT called: its `session.prompt` takes `{path,body}` with
976
+ // no `delivery` field, so firing it would be a write with the wrong contract
977
+ // and a verdict read off whatever came back.
978
+ note = "这个客户端不是 v2 的 ctx.session 桥(v1 的 session.prompt 走 {path,body},没有 delivery 字段),未调用";
979
+ }
980
+ log({
981
+ step_id: "join",
982
+ event: "steer",
983
+ child: target,
984
+ agent: gate.agent,
985
+ delivery,
986
+ outcome,
987
+ ok: outcome === "steered",
988
+ ...(inboxID ? { inboxID } : {}),
989
+ ...(seam ? { seam } : {}),
990
+ // the LENGTH, never the text — R6 binds the audit trail as much as the reply
991
+ chars: steerText.length,
992
+ });
993
+ const tally = {};
994
+ tally[outcome] = 1;
995
+ log({ step_id: "join", event: "steer_summary", count: 1, outcomes: steerOutcomeParts(tally).join(" / ") });
996
+ const wake = row && row.state !== "running"
997
+ ? `\n它现在不是运行中(${row.state === "error" ? "失败" : "已结算"});按文档 steer 会唤醒它的执行,但唤醒之后它做什么,要等它结算才看得到。`
998
+ : "";
999
+ return toToolResult(`插话目标:${rowLine}\n插话结果:${steerVerdictLine(outcome, { inboxID, note, seam })}\n插话计数:${steerOutcomeParts(tally).join(" · ")}${wake}`);
1000
+ }
1001
+ if (wantsUnread) {
1002
+ if (!api || api.v2SteerSeam !== true || typeof api.inboxList !== "function") {
1003
+ log({ step_id: "join", event: "unread", child: target, outcome: "no-seam" });
1004
+ return toToolResult(`未读列表没有查询:这个宿主没给 session.inbox.list 的缝(v1 客户端没有这条契约),未发送。\n目标:${rowLine}`);
1005
+ }
1006
+ const un = unwrapClientResult(await api.inboxList({ sessionID: target }));
1007
+ const data = un.ok
1008
+ ? un.data
1009
+ : null;
1010
+ const seamOut = String(data?.outcome ?? "");
1011
+ const spelling = typeof data?.spelling === "string" ? data.spelling : "";
1012
+ if (!un.ok || seamOut === "threw" || seamOut === "no-seam") {
1013
+ const why = seamOut === "no-seam"
1014
+ ? "这个宿主没给 session.inbox.list 的缝(两种拼写都试了)"
1015
+ : `查询被宿主拒绝:${shorten(String(data?.message ?? (un.ok ? "" : un.message) ?? ""), 120)}`;
1016
+ log({ step_id: "join", event: "unread", child: target, outcome: seamOut === "no-seam" ? "no-seam" : "threw" });
1017
+ return toToolResult(`未读列表没有查到:${why}。\n目标:${rowLine}`);
1018
+ }
1019
+ const listed = inboxItemLines(data?.items);
1020
+ log({
1021
+ step_id: "join",
1022
+ event: "unread",
1023
+ child: target,
1024
+ outcome: "listed",
1025
+ count: listed.lines.length,
1026
+ total: listed.total,
1027
+ skipped: listed.skipped,
1028
+ ...(spelling ? { spelling } : {}),
1029
+ });
1030
+ const trimmed = listed.total > listed.lines.length
1031
+ ? `(宿主返回 ${listed.total} 项,${listed.skipped} 项的 id 不匹配 ^msg_ 或超出 20 条上限,没有列出)`
1032
+ : "";
1033
+ return toToolResult(`未投递的插话项(${target}):${listed.lines.length} 条${trimmed}\n` +
1034
+ `${listed.lines.join("\n") || "(没有未投递项)"}\n` +
1035
+ `(只给 id 与形状,正文不进这里 —— 要撤回某条就 unsend 它的 id)\n目标:${rowLine}`);
1036
+ }
1037
+ // unsend — the only way to take back a line that has not been delivered yet.
1038
+ if (!api || api.v2SteerSeam !== true || typeof api.inboxCancel !== "function") {
1039
+ log({ step_id: "join", event: "unsend", child: target, inboxID: unsendID, outcome: "no-seam" });
1040
+ return toToolResult(`撤回没有发送:这个宿主没给 session.inbox.cancel 的缝(v1 客户端没有这条契约)。\n目标:${rowLine}`);
1041
+ }
1042
+ const got = unsendOutcomeOf(await api.inboxCancel({ sessionID: target, inboxID: unsendID }));
1043
+ const tallyU = {};
1044
+ tallyU[got.outcome] = 1;
1045
+ log({
1046
+ step_id: "join",
1047
+ event: "unsend",
1048
+ child: target,
1049
+ inboxID: unsendID,
1050
+ outcome: got.outcome,
1051
+ ok: got.outcome === "cancelled",
1052
+ });
1053
+ log({ step_id: "join", event: "unsend_summary", count: 1, outcomes: unsendOutcomeParts(tallyU).join(" / ") });
1054
+ return toToolResult(`撤回目标:${target} · inbox=${unsendID}\n结果:${unsendVerdictLine(got.outcome, { inboxID: got.inboxID, note: got.note })}\n撤回计数:${unsendOutcomeParts(tallyU).join(" · ")}\n${rowLine}`);
1055
+ }
689
1056
  if (!mine.length) {
690
- const lease = leaseLine();
691
1057
  // The parenthetical is now chosen by whether we actually looked, so a
692
1058
  // host we cannot query no longer reports as "confirmed: nothing there".
693
1059
  const saw = adoption.looked;
@@ -715,8 +1081,7 @@ export function buildDispatchTools(deps) {
715
1081
  // the transcript, and the honest reason is that a synchronous host
716
1082
  // task never enters this registry at all.
717
1083
  `如果你用的是同步 \`task\`:宿主自己就把结果收回来了,它从不登记在这里,这不是派发失败。` +
718
- `只有后台 \`task {background:true}\`(或显式带 ids)需要 tm_join 来取。` +
719
- (lease ? `\n${lease}` : ""));
1084
+ `只有后台 \`task {background:true}\`(或显式带 ids)需要 tm_join 来取。`);
720
1085
  }
721
1086
  await settleAdopted(mine, directory);
722
1087
  const waitMs = Math.max(0, Math.min(maxWaitMs, Number(args.waitMs) || 0));
@@ -887,14 +1252,6 @@ export function buildDispatchTools(deps) {
887
1252
  });
888
1253
  }
889
1254
  }
890
- // #80/#86/#87: THE LEASE TRIPWIRE — one computation (leaseLine above),
891
- // attached to every answer this tool gives, so a settled round and an
892
- // empty one cannot disagree about whether a window is still open.
893
- if (settled) {
894
- const lease = leaseLine();
895
- if (lease)
896
- header.push(lease);
897
- }
898
1255
  const wantText = !(args.includeText === false || args.includeText === "false");
899
1256
  const collectible = mine.filter((r) => r.state !== "running");
900
1257
  const blocks = [];