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

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