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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +80 -15
  2. package/README.zh-CN.md +67 -18
  3. package/dist/host/note.d.ts +7 -0
  4. package/dist/host/note.d.ts.map +1 -0
  5. package/dist/host/note.js +28 -0
  6. package/dist/host/note.js.map +1 -0
  7. package/dist/host/v1.d.ts +14 -0
  8. package/dist/host/v1.d.ts.map +1 -0
  9. package/dist/host/v1.js +340 -0
  10. package/dist/host/v1.js.map +1 -0
  11. package/dist/host/v2-browser-gate.d.ts +45 -0
  12. package/dist/host/v2-browser-gate.d.ts.map +1 -0
  13. package/dist/host/v2-browser-gate.js +319 -0
  14. package/dist/host/v2-browser-gate.js.map +1 -0
  15. package/dist/host/v2-capabilities.d.ts +68 -0
  16. package/dist/host/v2-capabilities.d.ts.map +1 -0
  17. package/dist/host/v2-capabilities.js +146 -0
  18. package/dist/host/v2-capabilities.js.map +1 -0
  19. package/dist/host/v2-client.d.ts +53 -0
  20. package/dist/host/v2-client.d.ts.map +1 -0
  21. package/dist/host/v2-client.js +193 -0
  22. package/dist/host/v2-client.js.map +1 -0
  23. package/dist/host/v2-events.d.ts +56 -0
  24. package/dist/host/v2-events.d.ts.map +1 -0
  25. package/dist/host/v2-events.js +158 -0
  26. package/dist/host/v2-events.js.map +1 -0
  27. package/dist/host/v2-guard.d.ts +101 -0
  28. package/dist/host/v2-guard.d.ts.map +1 -0
  29. package/dist/host/v2-guard.js +199 -0
  30. package/dist/host/v2-guard.js.map +1 -0
  31. package/dist/host/v2-offload.d.ts +148 -0
  32. package/dist/host/v2-offload.d.ts.map +1 -0
  33. package/dist/host/v2-offload.js +288 -0
  34. package/dist/host/v2-offload.js.map +1 -0
  35. package/dist/host/v2-permissions.d.ts +123 -0
  36. package/dist/host/v2-permissions.d.ts.map +1 -0
  37. package/dist/host/v2-permissions.js +190 -0
  38. package/dist/host/v2-permissions.js.map +1 -0
  39. package/dist/host/v2-probe.d.ts +85 -0
  40. package/dist/host/v2-probe.d.ts.map +1 -0
  41. package/dist/host/v2-probe.js +268 -0
  42. package/dist/host/v2-probe.js.map +1 -0
  43. package/dist/host/v2-scope.d.ts +51 -0
  44. package/dist/host/v2-scope.d.ts.map +1 -0
  45. package/dist/host/v2-scope.js +58 -0
  46. package/dist/host/v2-scope.js.map +1 -0
  47. package/dist/host/v2-session-client.d.ts +74 -0
  48. package/dist/host/v2-session-client.d.ts.map +1 -0
  49. package/dist/host/v2-session-client.js +171 -0
  50. package/dist/host/v2-session-client.js.map +1 -0
  51. package/dist/host/v2-session.d.ts +85 -0
  52. package/dist/host/v2-session.d.ts.map +1 -0
  53. package/dist/host/v2-session.js +171 -0
  54. package/dist/host/v2-session.js.map +1 -0
  55. package/dist/host/v2-subagent.d.ts +157 -0
  56. package/dist/host/v2-subagent.d.ts.map +1 -0
  57. package/dist/host/v2-subagent.js +286 -0
  58. package/dist/host/v2-subagent.js.map +1 -0
  59. package/dist/host/v2-tool.d.ts +67 -0
  60. package/dist/host/v2-tool.d.ts.map +1 -0
  61. package/dist/host/v2-tool.js +209 -0
  62. package/dist/host/v2-tool.js.map +1 -0
  63. package/dist/host/v2-transcript.d.ts +73 -0
  64. package/dist/host/v2-transcript.d.ts.map +1 -0
  65. package/dist/host/v2-transcript.js +151 -0
  66. package/dist/host/v2-transcript.js.map +1 -0
  67. package/dist/host/v2-types.d.ts +229 -0
  68. package/dist/host/v2-types.d.ts.map +1 -0
  69. package/dist/host/v2-types.js +17 -0
  70. package/dist/host/v2-types.js.map +1 -0
  71. package/dist/host/v2.d.ts +42 -0
  72. package/dist/host/v2.d.ts.map +1 -0
  73. package/dist/host/v2.js +706 -0
  74. package/dist/host/v2.js.map +1 -0
  75. package/dist/index.d.ts +23 -17
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +29 -366
  78. package/dist/index.js.map +1 -1
  79. package/dist/prompts/lead.d.ts +1 -1
  80. package/dist/prompts/lead.d.ts.map +1 -1
  81. package/dist/prompts/lead.js +452 -426
  82. package/dist/prompts/lead.js.map +1 -1
  83. package/dist/prompts/shared.d.ts +2 -2
  84. package/dist/prompts/shared.d.ts.map +1 -1
  85. package/dist/prompts/shared.js +30 -10
  86. package/dist/prompts/shared.js.map +1 -1
  87. package/dist/prompts/specialists.d.ts +2 -2
  88. package/dist/prompts/specialists.d.ts.map +1 -1
  89. package/dist/prompts/specialists.js +7 -6
  90. package/dist/prompts/specialists.js.map +1 -1
  91. package/dist/task-offload.d.ts +16 -0
  92. package/dist/task-offload.d.ts.map +1 -1
  93. package/dist/task-offload.js +51 -0
  94. package/dist/task-offload.js.map +1 -1
  95. package/dist/tm/browser.d.ts.map +1 -1
  96. package/dist/tm/browser.js +41 -10
  97. package/dist/tm/browser.js.map +1 -1
  98. package/dist/tm/dispatch.d.ts +47 -0
  99. package/dist/tm/dispatch.d.ts.map +1 -1
  100. package/dist/tm/dispatch.js +230 -31
  101. package/dist/tm/dispatch.js.map +1 -1
  102. package/dist/tm/dupe-guard.d.ts +16 -3
  103. package/dist/tm/dupe-guard.d.ts.map +1 -1
  104. package/dist/tm/dupe-guard.js +27 -7
  105. package/dist/tm/dupe-guard.js.map +1 -1
  106. package/dist/tm/index.d.ts +40 -1
  107. package/dist/tm/index.d.ts.map +1 -1
  108. package/dist/tm/index.js +40 -11
  109. package/dist/tm/index.js.map +1 -1
  110. package/dist/tm/ledger.d.ts +114 -0
  111. package/dist/tm/ledger.d.ts.map +1 -0
  112. package/dist/tm/ledger.js +308 -0
  113. package/dist/tm/ledger.js.map +1 -0
  114. package/dist/tm/perm-ask.d.ts +1 -3
  115. package/dist/tm/perm-ask.d.ts.map +1 -1
  116. package/dist/tm/perm-ask.js +10 -1
  117. package/dist/tm/perm-ask.js.map +1 -1
  118. package/dist/tm/preview.d.ts +55 -0
  119. package/dist/tm/preview.d.ts.map +1 -1
  120. package/dist/tm/preview.js +225 -0
  121. package/dist/tm/preview.js.map +1 -1
  122. package/dist/tm/search.d.ts.map +1 -1
  123. package/dist/tm/search.js +40 -10
  124. package/dist/tm/search.js.map +1 -1
  125. package/dist/tm/serp-loop.d.ts +45 -0
  126. package/dist/tm/serp-loop.d.ts.map +1 -0
  127. package/dist/tm/serp-loop.js +80 -0
  128. package/dist/tm/serp-loop.js.map +1 -0
  129. package/dist/tm/stats.d.ts +10 -0
  130. package/dist/tm/stats.d.ts.map +1 -1
  131. package/dist/tm/stats.js +160 -0
  132. package/dist/tm/stats.js.map +1 -1
  133. package/dist/tm/webfetch.d.ts +4 -0
  134. package/dist/tm/webfetch.d.ts.map +1 -1
  135. package/dist/tm/webfetch.js +75 -8
  136. package/dist/tm/webfetch.js.map +1 -1
  137. package/docs/installation-v2.md +436 -0
  138. package/docs/installation.md +12 -0
  139. package/docs/research/agent-data-exchange.md +117 -0
  140. package/docs/research/browser-pane.md +89 -0
  141. package/docs/research/host-http-api.md +132 -0
  142. package/docs/research/host-subagent-injection.md +187 -0
  143. package/docs/research/plugin-loader-contract.md +261 -0
  144. package/docs/research/v2-builtin-tools.md +447 -0
  145. package/docs/v2-port-notes.md +315 -0
  146. package/index.js +7 -0
  147. package/package.json +64 -61
  148. package/scripts/gen-v2-config.mjs +304 -0
  149. package/scripts/lib/config-surgery.cjs +239 -0
  150. package/scripts/lib/fake-ctx.mjs +146 -0
@@ -0,0 +1,706 @@
1
+ /**
2
+ * The v2 personality: `Plugin.define({id, setup(ctx)})`.
3
+ *
4
+ * `Plugin.define` in @opencode/plugin@2.0.16 is literally `plugin => plugin`,
5
+ * so this module exports the plain object and the v2 SDK never becomes a
6
+ * runtime dependency — which is what keeps the dual-personality package
7
+ * installable by a v1 user who cannot resolve that package at all.
8
+ *
9
+ * Scope of this file (milestone M2): build the governed runtime on a
10
+ * filesystem-backed client shim, register the tm_* tools through
11
+ * `ctx.tool.transform`, and normalize the permission matrix onto the agents the
12
+ * CONFIG declares. It deliberately does NOT touch: the approval gate (#94), the
13
+ * session hooks / compaction / description appends (#93), or the browser
14
+ * (#95). Those need this seam proven live first.
15
+ *
16
+ * Two v1 behaviors do not carry over, and both absences are stated rather than
17
+ * discovered later:
18
+ * - creating agents: `AgentEditor` has no `add`, so the six roles must come
19
+ * from the user's config (the installer writes `agents/*.md`).
20
+ * - the conservative default-agent promotion: v1 read `cfg.default_agent` and
21
+ * only filled it when empty or "build". v2 exposes `default(id)` with NO
22
+ * getter, so "only if the user did not choose" is not expressible — the
23
+ * choice is between never promoting and promoting every boot. The user's
24
+ * standing instruction (2026-09-25) is that Team is ALWAYS the default, so
25
+ * this promotes unconditionally; `defaultAgent: false` in the plugin options
26
+ * opts out, exactly as on v1.
27
+ *
28
+ * The official confirmation dialog is absent as well, and for a different
29
+ * reason: probed on the live host, a plugin has no way to raise one.
30
+ * `askFnOf` therefore finds no bridge and every governed call fails closed with
31
+ * the v2-worded refusal set below.
32
+ */
33
+ import { agents } from "../agents.js";
34
+ import { DEFAULT_TTL_DAYS, resolveTtlMs, startBlackboardMaintenance } from "../blackboard.js";
35
+ import { parseExtraDeny, resolveEnvProtectMode } from "../envprotect.js";
36
+ import { createTmTools } from "../tm/index.js";
37
+ import { setAskUnavailableNote } from "../tm/perm-ask.js";
38
+ import { setPrivateSpacePolicy } from "../tm/webfetch.js";
39
+ import { blackboardNote } from "./note.js";
40
+ import { applyV2BackgroundForce, applyV2PermissionGuards, needsCoarseShellAsk } from "./v2-guard.js";
41
+ import { applyV2EventFeed } from "./v2-events.js";
42
+ import { createV2SessionReader } from "./v2-session-client.js";
43
+ import { applyV2Probe, probeSummary } from "./v2-probe.js";
44
+ import { applyV2NativeOffload } from "./v2-offload.js";
45
+ import { applyV2SubagentRegistry, applyV2CompletionWatch } from "./v2-subagent.js";
46
+ import { applyV2BrowserGate, browserGateSummary } from "./v2-browser-gate.js";
47
+ import { seedWebfetchDomains } from "../tm/webfetch.js";
48
+ import { v2CapabilityRows } from "./v2-capabilities.js";
49
+ import { applyV2SessionLayer, removalPlan } from "./v2-session.js";
50
+ import { createStorageLedgerStore } from "../tm/ledger.js";
51
+ import { createTeamScope } from "./v2-scope.js";
52
+ import { bindV2Tool } from "./v2-tool.js";
53
+ import { mergeTriples, triplesFromAgentPermission, V1_ONLY_TOOLS } from "./v2-permissions.js";
54
+ /** The refusal has to name the real reason: on v2 the absence is the new
55
+ * protocol, and "旧版协议" would send the model looking for an upgrade the
56
+ * user already installed. It must ALSO not point at a domain gate this
57
+ * personality no longer runs (the default is `"*"`), so the knob it names is
58
+ * the host's own permission config, and the address red line is excluded from
59
+ * "ask the user" because that one has no consent path at all. */
60
+ export const V2_NO_DIALOG_NOTE = "这个宿主(OpenCode v2)不给插件弹出确认窗的入口,所以我没有问任何人就直接拒绝了——" +
61
+ "我不会在没有对话框的情况下假装用户同意。请换一个不需要这次访问的源," +
62
+ "或让用户在宿主的权限设置里放行(私网/元数据地址是红线,那条没有放行路径),不要重复调用。";
63
+ function directoryOf(ctx) {
64
+ const loc = ctx?.location;
65
+ if (typeof loc?.directory === "string" && loc.directory)
66
+ return loc.directory;
67
+ if (typeof loc?.project?.directory === "string" && loc.project.directory)
68
+ return loc.project.directory;
69
+ return process.cwd();
70
+ }
71
+ export const v2Personality = {
72
+ id: "team-mode",
73
+ async setup(ctx) {
74
+ if (!ctx || typeof ctx.tool?.transform !== "function") {
75
+ // Nothing can be registered without the tool domain. Say it in the
76
+ // server log instead of failing the load silently: a plugin that injects
77
+ // nothing looks identical to one that was never installed.
78
+ console.error("[team-mode] v2 setup aborted: ctx.tool.transform is missing on this host");
79
+ return;
80
+ }
81
+ const directory = directoryOf(ctx);
82
+ // ---------- Team-scope isolation (#22, the user's requirement 2026-09-25) ----------
83
+ // Every v2 hook fires for EVERY agent on this host. This one object is what
84
+ // each layer asks before it mutates a request, a result, or a permission —
85
+ // build/plan and any third-party agent must look exactly like a fresh install.
86
+ const scope = createTeamScope(Object.keys(agents));
87
+ setAskUnavailableNote(V2_NO_DIALOG_NOTE);
88
+ const options = (ctx.options ?? {});
89
+ const envProtectMode = options.envProtect ? resolveEnvProtectMode(process.env.TM_ENV_PROTECT) : "off";
90
+ const envProtectExtra = parseExtraDeny(process.env.TM_ENV_PROTECT_EXTRA_DENY);
91
+ // ---------- the v2 network policy: no domain gate, IP red line only ----------
92
+ // The user's standing instruction for v2 (2026-09-25): do not block network
93
+ // access at all EXCEPT sensitive and internal addresses. v1's 22-host seed list
94
+ // existed because v1 could open the host's official per-request dialog; on v2 a
95
+ // plugin cannot raise one, so an allowlist became a set of pages the agent can
96
+ // never see and no one can approve -- a gate with no door. `"*"` therefore
97
+ // replaces the DEFAULT here, and what still holds absolutely is `checkWebUrl`'s
98
+ // address policy underneath it: link-local / metadata / reserved ranges are a hard
99
+ // deny no config can open (and were never consentable), and private space
100
+ // (loopback / RFC1918 / CGNAT / .localhost) stays gated — refused through our
101
+ // tools, which have no dialog to ask with, and opened only by the host's own
102
+ // `effect:"ask"` for the native tools. IPv4-mapped and DNS64 spellings are
103
+ // unwrapped before that check, so the notation is not a way around it.
104
+ // An explicit TM_WEBFETCH_ALLOWED_DOMAINS always wins; this only changes which
105
+ // default applies, and it is resolved from a COPY of the env so the v1
106
+ // personality in the same process is untouched.
107
+ const v2Env = { ...process.env };
108
+ if (!String(v2Env.TM_WEBFETCH_ALLOWED_DOMAINS ?? "").trim())
109
+ v2Env.TM_WEBFETCH_ALLOWED_DOMAINS = "*";
110
+ // The DOMAIN gate is off on this personality (see the note above): 2.x gives a
111
+ // plugin no dialog, so "approve to proceed" is an instruction to wait for a
112
+ // window that never opens. Private space is NOT opened by the same logic,
113
+ // because that is not a whitelist question — a default that let a governed tool
114
+ // GET 192.168.1.1 would put the user's router in the trajectory for a mistake the
115
+ // model made. So the answer here is a refusal that names both exits
116
+ // (TM_PRIVATE_SPACE=allow, or the one host in TM_WEBFETCH_ALLOWED_DOMAINS) instead
117
+ // of promising a dialog, and `deny` is what a quiet v2 host gets.
118
+ const privateSpace = setPrivateSpacePolicy(String(v2Env.TM_PRIVATE_SPACE ?? "").trim() || "deny");
119
+ // ---------- how our tools reach the model at all (measured in the binary) ----------
120
+ // 2.0.16 decides tool visibility with `options.codemode`: a tool whose value is
121
+ // not `false` is offered ONLY inside the Code Mode catalog, where the host keeps
122
+ // the first line of its description (≤120 chars) under a ~2 000-token budget.
123
+ // `bindV2Tool` used to send no `options` at all, which is the real explanation
124
+ // for the live session that showed six tools and zero `tm_*` after a successful
125
+ // registration: the tools were there, and almost none of the governance text we
126
+ // wrote for them ever arrived. `TM_V2_CODEMODE=direct` sends the flag; the
127
+ // default keeps today's behaviour until the token cost is measured, because the
128
+ // other half of that sentence is that a direct tool definition rides EVERY
129
+ // request (our own estimate for the full set is 9 528 tokens).
130
+ // Delivered as REAL tools by default: the user's call (2026-09-25) was "slim the
131
+ // description first, then go direct", and the description is now 729 tokens for
132
+ // tm_browser (was 2 788 for the whole tool), which puts the measured per-role
133
+ // cost at 2 660 build-class / 4 726 tester / 6 391 researcher / 7 793 lead.
134
+ // `TM_V2_CODEMODE=off` restores catalog-only for anyone who wants the tokens back
135
+ // — and the boot note says which world this is, because in catalog mode most of
136
+ // our governance text never reaches the model at all.
137
+ // `TM_V2_CODEMODE` is now OPT-IN for direct delivery. Measured on a live 2.0.16
138
+ // desktop session (export read 2026-09-26): with `options.codemode:false` sent for
139
+ // every tool, the host still delivered all ten `tm_*` inside the Code Mode catalog
140
+ // ("They cannot be called directly…"), and the model's own callable list was the
141
+ // nine native tools. So the flag changes what we send, not what the model gets —
142
+ // and shipping it as the default made the README claim a delivery mode the host
143
+ // never gave. `direct` stays available as an experiment for a build that honours it,
144
+ // and the shutdown line reports both halves: `tools_codemode` (sent) and
145
+ // `tools_in_request` (what the assembled request actually carried).
146
+ const v2CodeModeDirect = /^(1|true|yes|on|direct)$/i.test(String(v2Env.TM_V2_CODEMODE ?? "").trim());
147
+ // No SDK client in the v1 sense. v1's `client` carried `file.read`, `find.text`,
148
+ // `session.*` and `pty.*`; the v2 plugin ctx has no such object, and the fs shim
149
+ // that used to stand in for the first two went away with `tm_read`/`tm_grep` (the
150
+ // native tools, governed at `execute.after`, are the file ladder now).
151
+ // What IS bridged is the one thing the collect path cannot be honest without:
152
+ // `tm_join` must be able to check whether a named session id is really the
153
+ // caller's child, and a live 2.0.16 run proved that without it the tool reports
154
+ // "no pending dispatch matched" about a child that already finished. v2 has
155
+ // `ctx.session.get`, so `v2-session-client.ts` wraps it in the shape the existing
156
+ // code reads. `session.children` has no counterpart, so adoption from the host's
157
+ // tree keeps reporting `no_seam` rather than implying we looked.
158
+ const sessionReader = createV2SessionReader(ctx);
159
+ const tmRuntime = await createTmTools({ directory, project: "", client: sessionReader.client, $: undefined }, {
160
+ mode: envProtectMode,
161
+ extra: envProtectExtra,
162
+ env: v2Env,
163
+ // The matrix is built from observations that do not exist yet at this line
164
+ // (the probe, the guards, the offloader all register below), so it is a
165
+ // LATE-BOUND closure rather than a snapshot taken too early — a row read
166
+ // before the observation would be exactly the "declared" lie this table
167
+ // exists to avoid.
168
+ capabilities: () => v2Matrix(),
169
+ // The LEDGER's home. v2 has no `todowrite` and no `session.todo` to
170
+ // read, so the lead's list lives in the host's own storage domain and
171
+ // `tm_join`'s goal tripwire checks THAT — see src/tm/ledger.ts. When the
172
+ // domain is missing the tool refuses with the reason rather than keeping
173
+ // the list in process memory and calling it recorded.
174
+ ledgerStore: createStorageLedgerStore(ctx.storage),
175
+ sessionReaderReport: () => sessionReader.report,
176
+ });
177
+ let v2Matrix = () => [];
178
+ const registrations = [];
179
+ const notes = [];
180
+ // ---------- register the governed tools ----------
181
+ const entries = Object.entries(tmRuntime.tools);
182
+ // v1 keeps tm_ptc_run; v2 does not register it. The host's own `execute`
183
+ // (Code Mode) already runs "one program, N governed calls, zero round-trips,
184
+ // only the aggregate entering the context", and a live session showed our
185
+ // governed results still come back offloaded through it — so shipping a
186
+ // second batch runner would hand the model two tools for one job. The
187
+ // module stays in the tree because the frozen v1 personality needs it, and
188
+ // the prompts the v2 config files carry are forked by gen-v2-config.mjs.
189
+ const V2_UNREGISTERED = V1_ONLY_TOOLS;
190
+ const bindings = [];
191
+ const derived = [];
192
+ for (const [name, def] of entries) {
193
+ if (V2_UNREGISTERED.has(name))
194
+ continue;
195
+ const bound = await bindV2Tool(name, def, directory, (agent, sessionID) => scope.learn(agent, sessionID), {
196
+ codemodeDirect: v2CodeModeDirect,
197
+ });
198
+ if (!bound) {
199
+ notes.push(`${name}: 没有 execute,未注册`);
200
+ continue;
201
+ }
202
+ if (!bound.inputExact)
203
+ notes.push(`${name}: 参数表不精确(${bound.note ?? "未知原因"})`);
204
+ else if (bound.inputSource === "descriptor")
205
+ derived.push(name);
206
+ bindings.push(bound);
207
+ }
208
+ // A descriptor-derived table is `exact` in the sense that the model sees the
209
+ // parameter names, but the TYPES were read off a `name: type` prefix by
210
+ // regex, not by zod. Saying nothing would let the log imply a zod-grade
211
+ // shape, so name the tools and say how they were obtained.
212
+ const retired = entries.map(([name]) => name).filter((n) => V2_UNREGISTERED.has(n));
213
+ if (retired.length) {
214
+ // Deliberate absences are said, not silently missing: an agent that reads a
215
+ // v1 document and finds no `tm_read` needs to learn from the boot log that
216
+ // this is the design, not a failed install.
217
+ notes.push(`v2 不注册 ${retired.join("/")} —— 同一件工作交给宿主自己的 read/grep/shell/execute(结果照样被 execute.after 治理),v1 保留这些别名`);
218
+ }
219
+ if (derived.length) {
220
+ notes.push(`${derived.join("/")} 没有 zod 形状,参数表是从描述符文本推出来的(类型靠 name: type 前缀判定,不是 zod 保证)`);
221
+ }
222
+ // How our tools reach the model at all — said every boot, because the two
223
+ // answers differ by thousands of tokens and by whether our governance text
224
+ // arrives at all. Both branches describe only what WE send: on 2.0.16 a live
225
+ // session showed `options.codemode:false` does not actually move the tools out of
226
+ // the Code Mode catalog, so the outcome is verified from the shutdown line's
227
+ // `tools_in_request` (or tm_stats), never from this flag.
228
+ notes.push(v2CodeModeDirect
229
+ ? "TM_V2_CODEMODE=direct:我们发了 options.codemode=false。宿主是否因此改成直接交付,要看 tm_stats 的 tools_in_request(2.0.16 实测:仍然只在 Code Mode 目录里,模型可直接调用的是那九个原生工具)"
230
+ : "tm_* 未带 options.codemode=false:宿主把它们放进 Code Mode 目录,模型看到的只有描述首行(≤120 字)——我们写在下面的治理文案大部分没送到。2.0.16 实测:TM_V2_CODEMODE=direct 也不改变这一点,所以默认留在此状态,真实交付情况以 tools_in_request 为准");
231
+ registrations.push(await ctx.tool.transform((editor) => {
232
+ for (const b of bindings)
233
+ editor.add(b.tool);
234
+ }));
235
+ // Did they actually land? Ask the host, do not assume — the whole reason
236
+ // this plugin has a capability probe is that "registered" and "live" are
237
+ // different facts on this platform.
238
+ let registeredNames = [];
239
+ try {
240
+ const live = (await ctx.tool.list());
241
+ const ids = new Set(live.map((t) => String(t.id ?? t.name ?? "")));
242
+ registeredNames = bindings.map((b) => b.tool.name).filter((n) => ids.has(n));
243
+ }
244
+ catch (err) {
245
+ notes.push(`tool.list 失败:${err?.message ?? String(err)}`);
246
+ }
247
+ const missing = bindings.map((b) => b.tool.name).filter((n) => !registeredNames.includes(n));
248
+ // ---------- normalize the agents the config declares ----------
249
+ // ---------- the permission guard (egress red line + R6 per command) ----------
250
+ // Installed BEFORE the agent transform because whether the config still needs
251
+ // the coarse `shell -> ask` escalation depends on this hook being there.
252
+ const guards = await applyV2PermissionGuards(ctx, { envProtectMode, scope });
253
+ // Said out loud because it is a promise with a boundary: the user's rule is
254
+ // that nothing outside Team may be touched, and the honest consequence is that
255
+ // the red lines we inject are therefore NOT protecting a build session either.
256
+ notes.push("已按 Team 作用域收口:工具面裁剪、温度、黑板提示、原生结果卸载、R6 与地址红线、subagent 强制后台,都只对我们六个角色的会话生效;build / plan 等其余模式保持宿主出厂行为(代价:那些会话也不由我们补红线,需要全局红线请让宿主自己的 permission 规则承担)");
257
+ registrations.push(...guards.registrations);
258
+ const bgForce = await applyV2BackgroundForce(ctx, { scope });
259
+ registrations.push(...bgForce.registrations);
260
+ // JIT governance over the HOST's tools, so the offload promise does not depend
261
+ // on which tool the model happened to pick (see v2-offload.ts for why a
262
+ // per-tool promise is no promise). Registered BEFORE the probe so the probe's
263
+ // throttled snapshot can carry its counters — a counter that only lands at
264
+ // teardown is a counter that does not exist.
265
+ const offload = await applyV2NativeOffload(ctx, { pipelines: tmRuntime.pipelines, scope });
266
+ registrations.push(...(await Promise.all(offload.registrations)));
267
+ // The host's own sub-agents, entered into tm_join's registry (decision 4,
268
+ // 2026-09-26). On v2 every dispatch IS the host's `subagent` tool, so without this
269
+ // the collect side had nothing to collect: tm_join answered 没有待收集的派发 about
270
+ // children the user could see on screen, and the settle events the feed forwards
271
+ // were dropped because the dispatcher ignores ids it has never heard of.
272
+ const hostChildren = applyV2SubagentRegistry(ctx, {
273
+ register: tmRuntime.registerHostChild,
274
+ scope,
275
+ });
276
+ registrations.push(...(await Promise.all(hostChildren.registrations)));
277
+ if (!hostChildren.active) {
278
+ notes.push("宿主 subagent 的认领没装上(ctx.tool.hook 不可用):后台子代理会回到 tm_join 看不见的状态");
279
+ }
280
+ // The settle path. Measured on 2.0.18: a child's own `session.idle` never reaches a
281
+ // plugin subscriber, and the parent-idle presumption cannot fire mid-turn, so a child
282
+ // whose report had already been injected stayed 运行中. The host's completion envelope
283
+ // IS the observation, so watch the two session seams that can carry it and count both.
284
+ const completion = applyV2CompletionWatch(ctx, {
285
+ settle: tmRuntime.settleHostChild,
286
+ hasOpen: tmRuntime.hasOpenHostChild,
287
+ scope,
288
+ });
289
+ registrations.push(...(await Promise.all(completion.registrations)));
290
+ if (!completion.active) {
291
+ notes.push("子代理完成注入的监听没装上(ctx.session.hook 不可用):tm_join 会把已完成的后台子代理报成运行中");
292
+ }
293
+ // BEFORE the session layer registers its own context hook, so the probe sees
294
+ // the host's full surface rather than the set we trimmed — that difference is
295
+ // exactly what it is there to record.
296
+ // The host's own browser catalog, behind OUR gate (#9): `permission.evaluate` was
297
+ // observed NOT firing for `browser_*`, so until now the domain list, the address
298
+ // red line and the R6 env-file rule had a bypass the length of a tool name — in
299
+ // the one surface that is also the only one with a native side panel.
300
+ const browserGate = applyV2BrowserGate(ctx, {
301
+ allowlist: seedWebfetchDomains(tmRuntime.config.webfetchAllowedDomains),
302
+ scope,
303
+ env: v2Env,
304
+ });
305
+ if (!browserGate.registrations.length) {
306
+ notes.push("原生 browser_* 的门禁没装上(ctx.tool.hook 不可用):宿主的 45 个浏览器工具在 Team 会话里也不受我们的域名 / 地址规则约束");
307
+ }
308
+ registrations.push(...browserGate.registrations);
309
+ const probe = await applyV2Probe(ctx, {
310
+ onSummary: (summary) => {
311
+ try {
312
+ tmRuntime.pipelines.store.appendTrajectory({
313
+ tool: "host",
314
+ step_id: "v2-surface",
315
+ event: "personality",
316
+ api: 2,
317
+ native_seen: offload.report.seen,
318
+ // The coverage denominator (#23): every call we resolved to a Team
319
+ // session, and the part of it whose tool we do not touch. Printed by
320
+ // tm_stats, so "most tool calls are JIT-governed" is checkable.
321
+ native_ours: offload.report.ours,
322
+ native_unmatched: offload.report.unmatched,
323
+ native_offloaded: offload.report.offloaded,
324
+ native_envelopes: offload.report.envelopes,
325
+ native_tokens_saved: offload.report.tokensSaved,
326
+ native_offload_active: offload.active && offload.registrations.length > 0,
327
+ host_subagent_seen: hostChildren.report.seen,
328
+ host_children_registered: hostChildren.report.registered,
329
+ host_children_unpaired: hostChildren.report.unpaired,
330
+ // The session seam rides the throttled snapshot too, not only teardown: a run
331
+ // that is interrupted never reaches dispose, and "did ctx.session.context
332
+ // answer" is exactly the fact an interrupted round is there to establish.
333
+ session_get_attempts: sessionReader.report.attempted,
334
+ session_get_shape: sessionReader.report.usedShape ?? "(none)",
335
+ session_get_resolved: sessionReader.report.resolved,
336
+ session_context_ok: sessionReader.report.contextOk,
337
+ session_context_keys: sessionReader.report.contextKeys.join(","),
338
+ completion_context_fired: completion.report.contextFired,
339
+ completion_skipped: completion.report.skipped,
340
+ completion_settled: completion.report.settled,
341
+ ...summary,
342
+ });
343
+ }
344
+ catch {
345
+ /* the probe is an extra */
346
+ }
347
+ },
348
+ });
349
+ registrations.push(...probe.registrations);
350
+ if (probe.report.hooksMissing.length) {
351
+ notes.push(`探针没挂上的钩子:${probe.report.hooksMissing.join(", ")}`);
352
+ }
353
+ // Three states, three sentences: "no seam", "you turned it off" and "it is
354
+ // running" are different facts for the user, and collapsing them is how a
355
+ // fallback gets read as a setting.
356
+ if (offload.registrations.length === 0) {
357
+ notes.push('这个宿主没有 tool.hook("execute.after"):原生 read/shell 的大输出不会被卸载,JIT 承诺只在 tm_* 上成立');
358
+ }
359
+ else if (!offload.active) {
360
+ notes.push("TM_NATIVE_OFFLOAD=off:原生工具的大输出按宿主原样进上下文(承诺退回 tm_* 范围)");
361
+ }
362
+ if (!guards.installed) {
363
+ notes.push("ctx.permission.hook 不存在:原生 webfetch 的元数据/私网红线和 R6 的按命令行判定都没地方落");
364
+ }
365
+ const missingAgents = [];
366
+ const unmappedActions = new Set();
367
+ const wantedIds = Object.keys(agents);
368
+ // R6 on v2: the plugin cannot raise a dialog (probed), but an `ask` EFFECT
369
+ // the host evaluates DOES open one. A live `--standalone` run recorded
370
+ // `{action:"shell", resourceCount:1}` reaching `permission.evaluate` for a real
371
+ // command, so the per-command classifier is now in charge and the config keeps
372
+ // the COARSE escalation (every command asks) only as the fallback:
373
+ // `TM_R6_FINE_ASK=off`, or no hook installed to hand the decision to.
374
+ const escalateShellAsk = envProtectMode !== "off" && needsCoarseShellAsk(process.env, guards.installed);
375
+ // Team is ALWAYS the default (the user's standing instruction — see the
376
+ // header for why this cannot be the conservative v1 form). `default()` on
377
+ // an agent that does not exist would just make the host fall back to build
378
+ // silently, so it is gated on the role actually being present, and the
379
+ // refusal to promote is reported rather than hidden.
380
+ const promoteTeamDefault = options.defaultAgent !== false;
381
+ let defaultPromoted = null;
382
+ let defaultPostCheck = "not-attempted";
383
+ if (typeof ctx.agent?.transform === "function") {
384
+ registrations.push(await ctx.agent.transform((editor) => {
385
+ for (const id of wantedIds) {
386
+ const existing = editor.get(id);
387
+ if (!existing) {
388
+ missingAgents.push(id);
389
+ continue;
390
+ }
391
+ const { triples, unmapped } = triplesFromAgentPermission(agents[id]?.permission, { escalateShellAsk, agentName: id });
392
+ for (const u of unmapped)
393
+ unmappedActions.add(u);
394
+ // `V1_ONLY_TOOLS` is passed as the reclaim set: a triple naming an
395
+ // action this personality never registers can only have come from an
396
+ // earlier boot, and a permission rule for a tool the host has never
397
+ // heard of reads as a capability the user granted.
398
+ const merged = mergeTriples(existing.permissions, triples, V1_ONLY_TOOLS);
399
+ if (merged.changed)
400
+ editor.update(id, (a) => { a.permissions = merged.triples; });
401
+ }
402
+ if (promoteTeamDefault) {
403
+ // `editor.get("team")` returning nothing is NOT evidence the host does
404
+ // not know the role — the live run is literally executing as `team` while
405
+ // this snapshot lists only the 7 built-ins, so the transform receives the
406
+ // agent set from BEFORE the config directory merged. Gating the
407
+ // promotion on that stale look-up is how "Team is always the default"
408
+ // silently stopped being true, so the call is attempted regardless and
409
+ // the AFTER view is what gets recorded.
410
+ let ok = true;
411
+ try {
412
+ editor.default("team");
413
+ }
414
+ catch {
415
+ ok = false;
416
+ }
417
+ defaultPromoted = ok;
418
+ defaultPostCheck = editor.get?.("team") ? "present-after" : "absent-after";
419
+ }
420
+ // Recorded HERE, at the moment of observation. The host defers this
421
+ // callback (a live boot reached the trajectory line with
422
+ // defaultPromoted still null, which printed as a clean-looking "n/a"), so
423
+ // the boot record cannot claim anything about what has not happened yet —
424
+ // least of all `agents_missing: ""`, which reads as "all six roles found"
425
+ // when the truth at that instant was "nobody has looked".
426
+ try {
427
+ tmRuntime.pipelines.store.appendTrajectory({
428
+ tool: "host",
429
+ step_id: "v2-agents",
430
+ event: "personality",
431
+ api: 2,
432
+ agents_missing: missingAgents.join(" "),
433
+ agents_default: defaultPromoted === null ? "opt-out" : defaultPostCheck === "present-after" ? "team" : "called-unverified",
434
+ agents_unmapped: [...unmappedActions].join(" "),
435
+ agents_normalized: true,
436
+ agents_default_post: defaultPostCheck,
437
+ // Is "all six missing" the host having no roles, or the callback
438
+ // firing before it loaded them? Those need different fixes and the
439
+ // difference is not guessable from the outside — the editor's own view
440
+ // is the only witness.
441
+ agents_in_editor: (() => {
442
+ try {
443
+ return (editor.list?.() ?? []).length;
444
+ }
445
+ catch {
446
+ return -1;
447
+ }
448
+ })(),
449
+ agents_editor_ids: (() => {
450
+ try {
451
+ return (editor.list?.() ?? [])
452
+ .map((a) => String(a?.id ?? "?"))
453
+ .filter((n) => wantedIds.includes(n) || n === "build" || n === "plan")
454
+ .join(" ");
455
+ }
456
+ catch {
457
+ return "list-threw";
458
+ }
459
+ })(),
460
+ });
461
+ }
462
+ catch {
463
+ /* the record is an extra, never a reason to fail the transform */
464
+ }
465
+ }));
466
+ }
467
+ else {
468
+ notes.push("ctx.agent.transform 不存在,角色权限矩阵未规范化");
469
+ }
470
+ if (defaultPromoted === false) {
471
+ notes.push("Team 没有成为默认:editor.default(\"team\") 抛错了");
472
+ }
473
+ else if (defaultPromoted && defaultPostCheck !== "present-after") {
474
+ // Measured on a live 2.0.16 standalone boot: the call is accepted, `team` is
475
+ // absent from the editor before AND after, and `default_agent` in
476
+ // opencode.jsonc keeps the user's value. The plugin therefore cannot promote
477
+ // the default on v2 — the transform receives the agent set from before the
478
+ // config directory merges. Saying so is the whole point: this file used to
479
+ // claim the promotion was live-verified, and the only thing that actually
480
+ // makes Team the default is the installer writing the key.
481
+ notes.push("Team 默认:editor.default(\"team\") 已被宿主接受但**无法核验生效**(transform 拿到的是配置合并前的角色集,实测调用后 editor 里仍无 team)。真正让这个要求成立的是安装器写 `default_agent: \"team\"`——没装过安装器就别假定 Team 是默认。");
482
+ }
483
+ if (unmappedActions.size) {
484
+ notes.push(`白名单里这些键在 v2 没有对应动作,已如实丢弃:${[...unmappedActions].join(",")}`);
485
+ }
486
+ if (escalateShellAsk) {
487
+ notes.push(guards.installed
488
+ ? "R6 已开,但 TM_R6_FINE_ASK=off 显式要回粗粒度:shell 在配置里升为 ask(每条命令都问),按命令行判定的 evaluate 钩子不再决定这件事。"
489
+ : "R6 已开,但这个宿主没给 permission.hook:按命令行的红线无处可挂,只能把 shell 整体升为 ask(每条命令都问)——这是退路,不是设计。");
490
+ }
491
+ // ---------- the request layer ----------
492
+ // The whitelist was only ever a DENY, which stops a call but leaves the
493
+ // tool's description in every request. Here it becomes a request-level
494
+ // removal, and the two things v1 baked into the team prompt (the resolved
495
+ // board root) or into the agent config (temperature) ride the request
496
+ // instead — a v2 agent is a config FILE and cannot carry a per-workspace
497
+ // path, and `temperature` is a legacy agent field the runner no longer
498
+ // sends. This also starts the blackboard TTL sweeper, which v1 owns and v2
499
+ // otherwise silently lacked: the note promises a sweep, so shipping it
500
+ // without the sweeper would be the exact overstated claim this product is
501
+ // built to refuse.
502
+ const ttlMs = resolveTtlMs(options);
503
+ const ttlDays = Math.round(ttlMs / (24 * 60 * 60 * 1000)) || DEFAULT_TTL_DAYS;
504
+ const note = blackboardNote(startBlackboardMaintenance(directory, ttlMs), ttlDays);
505
+ const temperature = options.temperature === false
506
+ ? false
507
+ : typeof options.temperature === "number"
508
+ ? options.temperature
509
+ : 0.2;
510
+ const plan = removalPlan(agents);
511
+ // ---------- does ctx.storage actually round-trip? ----------
512
+ // The LEDGER needs somewhere to live and `ctx.storage` is the candidate; the
513
+ // difference between "the domain exists" and "a value we wrote comes back"
514
+ // is not something a type file can settle, so boot asks it one question with a
515
+ // key that carries no user data and records the answer.
516
+ const storageProbe = { state: "absent" };
517
+ {
518
+ const st = ctx?.storage;
519
+ if (st && typeof st.set === "function" && typeof st.get === "function") {
520
+ const key = "team-mode/selfcheck";
521
+ const marker = `boot-${process.pid}`;
522
+ try {
523
+ await st.set(key, { marker, at: Date.now() });
524
+ const back = await st.get(key);
525
+ const got = back?.value?.marker ?? back?.marker;
526
+ storageProbe.state = got === marker ? "round-trip" : "read-back-mismatch";
527
+ if (got !== marker)
528
+ storageProbe.detail = `写回的值不是刚写进去的那个(拿到 ${JSON.stringify(got)?.slice(0, 40)})`;
529
+ }
530
+ catch (err) {
531
+ storageProbe.state = "threw";
532
+ storageProbe.detail = String(err?.message ?? err).slice(0, 80);
533
+ }
534
+ }
535
+ }
536
+ const session = await applyV2SessionLayer(ctx, { temperature, note, noteAgents: ["team"], plan, scope });
537
+ // Everything the matrix reports now exists, so the late-bound closure can be
538
+ // pointed at the real observations.
539
+ v2Matrix = () => v2CapabilityRows({
540
+ ctx,
541
+ probe,
542
+ guardsInstalled: guards.installed,
543
+ backgroundForced: true,
544
+ offload,
545
+ sessionHooks: 2,
546
+ temperature,
547
+ // Both derived, not asserted: "we think v2 has no X" has to come from the
548
+ // object we were handed, so a host that grows the seam shows up here
549
+ // automatically instead of leaving a stale "missing" row forever.
550
+ // tm_join reads the todo list through the SDK client, and this personality
551
+ // passes none — so the row is false until #8 bridges `ctx.session` into the
552
+ // path the tool actually consumes. Deriving it from ctx rather than
553
+ // hard-coding `false` is what keeps that honest either way.
554
+ eventFeed: feed.report,
555
+ hasTodoSeam: typeof ctx.session?.todo === "function",
556
+ hasAsk: typeof ctx.tool === "function",
557
+ storageState: storageProbe.state,
558
+ scope,
559
+ });
560
+ registrations.push(...session.registrations);
561
+ if (!session.registrations.length) {
562
+ notes.push("ctx.session.hook 不存在:工具面裁剪、温度、黑板根目录三项请求层治理都没装上");
563
+ }
564
+ // v1 handed the plugin a host `event` hook and pumped every session.idle /
565
+ // session.error / session.status into the child registry. This personality had
566
+ // no subscription at all (#8), so a child that settled two seconds after the
567
+ // dispatch stayed "running" for the whole wait budget and `tm_join` reported a
568
+ // state it had never observed — goal #6's failure shape, and a bug rather than
569
+ // a limitation now that `ctx.event.subscribe()` is measured to work.
570
+ const feed = await applyV2EventFeed(ctx, { onEvent: (ev) => tmRuntime.observeDispatchEvent(ev) });
571
+ if (!feed.report.active) {
572
+ notes.push(`事件流没接通(${feed.report.stopped ?? "原因未知"}):tm_join 的结算检测只剩等待预算内的轮询,子代理结算了也要等到超时才报告`);
573
+ }
574
+ const removedPlanSizes = [...plan.entries()].map(([id, set]) => `${id}=${set.size}`).join(" ");
575
+ try {
576
+ tmRuntime.pipelines.store.appendTrajectory({
577
+ tool: "host",
578
+ step_id: "v2-boot",
579
+ event: "personality",
580
+ api: 2,
581
+ directory,
582
+ tools_registered: registeredNames.length,
583
+ tools_v1_only: [...V2_UNREGISTERED].join(","),
584
+ tools_total: bindings.length,
585
+ tools_missing: missing.join(","),
586
+ agents_missing: missingAgents.length ? missingAgents.join(",") : defaultPromoted === null ? "待观察" : "",
587
+ agents_default: defaultPromoted === null ? "待观察(宿主异步调用 transform,见 v2-agents 行)" : defaultPromoted ? "team" : "not-promoted",
588
+ request_hooks: session.registrations.length,
589
+ guard_hooks: guards.registrations.length,
590
+ storage_probe: storageProbe.state,
591
+ storage_probe_detail: storageProbe.detail ?? "",
592
+ // Read at teardown, so these are counts of what actually happened in this
593
+ // process — the number that says whether the isolation was exercised at all.
594
+ scope_ours: scope.report.ours,
595
+ scope_foreign: scope.report.foreign,
596
+ scope_unknown: scope.report.unknown,
597
+ browser_gate: browserGate.registrations.length ? "armed" : "absent",
598
+ private_space: privateSpace,
599
+ tools_codemode: v2CodeModeDirect ? "direct" : "catalog",
600
+ guard_foreign_skipped: guards.report.foreignSkipped,
601
+ subagent_background: bgForce.registrations.length ? "forced-true" : "no-hook",
602
+ event_feed: feed.report.active ? "subscribed" : "absent",
603
+ event_feed_detail: feed.report.stopped ?? "",
604
+ guard_shell_coarse: escalateShellAsk,
605
+ request_temperature: temperature === false ? "off" : temperature,
606
+ request_removed_plan: removedPlanSizes,
607
+ board_ttl_days: ttlDays,
608
+ env_protect: envProtectMode,
609
+ note: notes.join(" · "),
610
+ });
611
+ }
612
+ catch {
613
+ /* the trajectory is an extra here, never a reason to fail the boot */
614
+ }
615
+ // A v1 user reading their own logs would otherwise conclude the plugin was
616
+ // silently broken: these two facts are the visible difference between the
617
+ // personalities.
618
+ if (missingAgents.length || missing.length || notes.length) {
619
+ const detail = [
620
+ missingAgents.length ? `配置里缺角色:${missingAgents.join(",")}(v2 插件不能新建角色,交给安装器)` : "",
621
+ missing.length ? `工具未出现在宿主表面:${missing.join(",")}` : "",
622
+ notes.length ? notes.join(" · ") : "",
623
+ ].filter(Boolean).join(";");
624
+ console.warn(`[team-mode] v2 人格已启动,但有缺口:${detail}`);
625
+ }
626
+ return async () => {
627
+ // The counters only exist in this process, so they have to be written down
628
+ // before teardown — otherwise "did permission.evaluate ever fire for
629
+ // shell?" (#12, the thing gating the fine-grained R6 ask) is unanswerable
630
+ // after the fact, and tm_stats would keep showing a boot line that says
631
+ // what was installed but never what it saw.
632
+ try {
633
+ tmRuntime.pipelines.store.appendTrajectory({
634
+ tool: "host",
635
+ step_id: "v2-shutdown",
636
+ event: "personality",
637
+ api: 2,
638
+ guard_seen: guards.report.seen,
639
+ guard_actions: Object.entries(guards.report.byAction).map(([k, v]) => `${k}=${v}`).join(" "),
640
+ guard_shell_matched: guards.report.shellMatched,
641
+ guard_strictened: guards.report.strictened,
642
+ guard_denied: guards.report.denied,
643
+ subagent_seen: bgForce.report.seen,
644
+ subagent_forced: bgForce.report.forced,
645
+ tools_removed: Object.entries(session.report.removed).map(([k, v]) => `${k}=${v}`).join(" "),
646
+ note_pushed: session.report.notePushed,
647
+ compaction_lines: session.report.compactionLines,
648
+ // Accumulates for the whole process, so it can only be written at
649
+ // teardown: this is the number that answers "did the feed stay alive, and
650
+ // did the host rename the event types under us after the last upgrade?"
651
+ event_received: feed.report.received,
652
+ event_forwarded: feed.report.forwarded,
653
+ event_unknown_types: Object.entries(feed.report.unknown).map(([k, v]) => `${k}=${v}`).join(" "),
654
+ event_stopped: feed.report.stopped ?? "",
655
+ browser_gate_seen: browserGate.report.seen,
656
+ browser_gate_refused: browserGate.report.refused,
657
+ browser_gate_leaked: browserGate.report.leaked,
658
+ browser_gate_held: browserGate.report.held,
659
+ browser_gate_note: browserGateSummary(browserGate.report),
660
+ // What the model could actually call, read off the assembled requests —
661
+ // the half of the delivery story the sent flag cannot tell.
662
+ tools_in_request: session.report.tmInRequestSurface ? "tm-in-request" : "catalog-only",
663
+ native_capped: offload.report.capped,
664
+ native_report_capped: offload.report.reportCapped,
665
+ // Did the collect side see the host's children at all? A zero here with a
666
+ // non-zero `seen` is the tm_join gap still open, and that is the difference
667
+ // between "nothing was dispatched" and "we were deaf to it".
668
+ host_subagent_seen: hostChildren.report.seen,
669
+ host_children_registered: hostChildren.report.registered,
670
+ host_children_unpaired: hostChildren.report.unpaired,
671
+ host_children_no_id: hostChildren.report.noChildId,
672
+ host_children_settled_at_once: hostChildren.report.settledAtOnce,
673
+ // The session-read seam, recorded rather than assumed: this bridge failed for a
674
+ // whole release cycle because the argument shape was guessed, and a guess that
675
+ // fails looks identical to a host that has no such method.
676
+ session_get_attempts: sessionReader.report.attempted,
677
+ session_get_shape: sessionReader.report.usedShape ?? "(none)",
678
+ session_get_resolved: sessionReader.report.resolved,
679
+ session_context_ok: sessionReader.report.contextOk,
680
+ session_context_keys: sessionReader.report.contextKeys.join(","),
681
+ session_get_error: sessionReader.report.lastError ?? sessionReader.report.contextError ?? "",
682
+ completion_context_fired: completion.report.contextFired,
683
+ completion_prompt_fired: completion.report.promptFired,
684
+ completion_skipped: completion.report.skipped,
685
+ completion_envelopes: completion.report.seen,
686
+ completion_settled: completion.report.settled,
687
+ ...probeSummary(probe.report),
688
+ });
689
+ }
690
+ catch {
691
+ /* the trajectory is an extra, never a reason to fail teardown */
692
+ }
693
+ for (const r of registrations) {
694
+ try {
695
+ await r.dispose();
696
+ }
697
+ catch {
698
+ /* a host that already tore down its own registry has nothing left to free */
699
+ }
700
+ }
701
+ // tm_browser owns a child process — teardown waits on v1 and must wait here too.
702
+ await tmRuntime.dispose();
703
+ };
704
+ },
705
+ };
706
+ //# sourceMappingURL=v2.js.map