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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/README.md +117 -118
  2. package/README.zh-CN.md +98 -100
  3. package/dist/agents.d.ts.map +1 -1
  4. package/dist/agents.js +89 -79
  5. package/dist/agents.js.map +1 -1
  6. package/dist/approval-gate.d.ts +8 -9
  7. package/dist/approval-gate.d.ts.map +1 -1
  8. package/dist/approval-gate.js +13 -14
  9. package/dist/approval-gate.js.map +1 -1
  10. package/dist/capabilities.d.ts +11 -37
  11. package/dist/capabilities.d.ts.map +1 -1
  12. package/dist/capabilities.js +11 -141
  13. package/dist/capabilities.js.map +1 -1
  14. package/dist/commands.js +1 -1
  15. package/dist/envprotect/hook.d.ts +12 -31
  16. package/dist/envprotect/hook.d.ts.map +1 -1
  17. package/dist/envprotect/hook.js +13 -91
  18. package/dist/envprotect/hook.js.map +1 -1
  19. package/dist/envprotect/patterns.d.ts +6 -4
  20. package/dist/envprotect/patterns.d.ts.map +1 -1
  21. package/dist/envprotect/patterns.js +6 -4
  22. package/dist/envprotect/patterns.js.map +1 -1
  23. package/dist/envprotect.d.ts +1 -1
  24. package/dist/envprotect.d.ts.map +1 -1
  25. package/dist/envprotect.js +1 -1
  26. package/dist/envprotect.js.map +1 -1
  27. package/dist/host/note.d.ts.map +1 -1
  28. package/dist/host/note.js +8 -5
  29. package/dist/host/note.js.map +1 -1
  30. package/dist/host/v2-browser-gate.d.ts +19 -1
  31. package/dist/host/v2-browser-gate.d.ts.map +1 -1
  32. package/dist/host/v2-browser-gate.js +47 -6
  33. package/dist/host/v2-browser-gate.js.map +1 -1
  34. package/dist/host/v2-capabilities.d.ts +43 -0
  35. package/dist/host/v2-capabilities.d.ts.map +1 -1
  36. package/dist/host/v2-capabilities.js +77 -1
  37. package/dist/host/v2-capabilities.js.map +1 -1
  38. package/dist/host/v2-compaction.d.ts +129 -0
  39. package/dist/host/v2-compaction.d.ts.map +1 -0
  40. package/dist/host/v2-compaction.js +346 -0
  41. package/dist/host/v2-compaction.js.map +1 -0
  42. package/dist/host/v2-default-agent.d.ts +37 -0
  43. package/dist/host/v2-default-agent.d.ts.map +1 -0
  44. package/dist/host/v2-default-agent.js +128 -0
  45. package/dist/host/v2-default-agent.js.map +1 -0
  46. package/dist/host/v2-events.d.ts +2 -0
  47. package/dist/host/v2-events.d.ts.map +1 -1
  48. package/dist/host/v2-events.js +27 -0
  49. package/dist/host/v2-events.js.map +1 -1
  50. package/dist/host/v2-guard.d.ts +178 -0
  51. package/dist/host/v2-guard.d.ts.map +1 -1
  52. package/dist/host/v2-guard.js +324 -8
  53. package/dist/host/v2-guard.js.map +1 -1
  54. package/dist/host/v2-offload.d.ts +1 -1
  55. package/dist/host/v2-offload.js +2 -2
  56. package/dist/host/v2-permissions.d.ts +45 -44
  57. package/dist/host/v2-permissions.d.ts.map +1 -1
  58. package/dist/host/v2-permissions.js +48 -49
  59. package/dist/host/v2-permissions.js.map +1 -1
  60. package/dist/host/v2-probe-chain.d.ts +93 -0
  61. package/dist/host/v2-probe-chain.d.ts.map +1 -0
  62. package/dist/host/v2-probe-chain.js +146 -0
  63. package/dist/host/v2-probe-chain.js.map +1 -0
  64. package/dist/host/v2-prune.d.ts +157 -0
  65. package/dist/host/v2-prune.d.ts.map +1 -0
  66. package/dist/host/v2-prune.js +507 -0
  67. package/dist/host/v2-prune.js.map +1 -0
  68. package/dist/host/v2-retry.d.ts +143 -0
  69. package/dist/host/v2-retry.d.ts.map +1 -0
  70. package/dist/host/v2-retry.js +315 -0
  71. package/dist/host/v2-retry.js.map +1 -0
  72. package/dist/host/v2-scope.d.ts.map +1 -1
  73. package/dist/host/v2-scope.js +14 -7
  74. package/dist/host/v2-scope.js.map +1 -1
  75. package/dist/host/v2-session-client.d.ts +62 -7
  76. package/dist/host/v2-session-client.d.ts.map +1 -1
  77. package/dist/host/v2-session-client.js +485 -26
  78. package/dist/host/v2-session-client.js.map +1 -1
  79. package/dist/host/v2-session.d.ts +72 -1
  80. package/dist/host/v2-session.d.ts.map +1 -1
  81. package/dist/host/v2-session.js +154 -10
  82. package/dist/host/v2-session.js.map +1 -1
  83. package/dist/host/v2-tool.d.ts +2 -3
  84. package/dist/host/v2-tool.d.ts.map +1 -1
  85. package/dist/host/v2-tool.js +2 -3
  86. package/dist/host/v2-tool.js.map +1 -1
  87. package/dist/host/v2-types.d.ts +25 -0
  88. package/dist/host/v2-types.d.ts.map +1 -1
  89. package/dist/host/v2.d.ts +14 -12
  90. package/dist/host/v2.d.ts.map +1 -1
  91. package/dist/host/v2.js +648 -74
  92. package/dist/host/v2.js.map +1 -1
  93. package/dist/identity.d.ts +27 -0
  94. package/dist/identity.d.ts.map +1 -0
  95. package/dist/identity.js +34 -0
  96. package/dist/identity.js.map +1 -0
  97. package/dist/index.d.ts +9 -24
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +17 -27
  100. package/dist/index.js.map +1 -1
  101. package/dist/prompts/lead.d.ts +1 -1
  102. package/dist/prompts/lead.d.ts.map +1 -1
  103. package/dist/prompts/lead.js +44 -27
  104. package/dist/prompts/lead.js.map +1 -1
  105. package/dist/prompts/shared.d.ts +2 -2
  106. package/dist/prompts/shared.d.ts.map +1 -1
  107. package/dist/prompts/shared.js +74 -57
  108. package/dist/prompts/shared.js.map +1 -1
  109. package/dist/prompts/specialists.d.ts +2 -2
  110. package/dist/prompts/specialists.d.ts.map +1 -1
  111. package/dist/prompts/specialists.js +53 -83
  112. package/dist/prompts/specialists.js.map +1 -1
  113. package/dist/tm/args-schema.d.ts +7 -32
  114. package/dist/tm/args-schema.d.ts.map +1 -1
  115. package/dist/tm/args-schema.js +12 -153
  116. package/dist/tm/args-schema.js.map +1 -1
  117. package/dist/tm/board.d.ts +10 -9
  118. package/dist/tm/board.d.ts.map +1 -1
  119. package/dist/tm/board.js +11 -10
  120. package/dist/tm/board.js.map +1 -1
  121. package/dist/tm/config-files.d.ts +81 -0
  122. package/dist/tm/config-files.d.ts.map +1 -0
  123. package/dist/tm/config-files.js +209 -0
  124. package/dist/tm/config-files.js.map +1 -0
  125. package/dist/tm/config-layers.d.ts +117 -0
  126. package/dist/tm/config-layers.d.ts.map +1 -0
  127. package/dist/tm/config-layers.js +320 -0
  128. package/dist/tm/config-layers.js.map +1 -0
  129. package/dist/tm/config.d.ts +10 -101
  130. package/dist/tm/config.d.ts.map +1 -1
  131. package/dist/tm/config.js +17 -71
  132. package/dist/tm/config.js.map +1 -1
  133. package/dist/tm/dispatch.d.ts +154 -22
  134. package/dist/tm/dispatch.d.ts.map +1 -1
  135. package/dist/tm/dispatch.js +559 -80
  136. package/dist/tm/dispatch.js.map +1 -1
  137. package/dist/tm/egress.d.ts +19 -7
  138. package/dist/tm/egress.d.ts.map +1 -1
  139. package/dist/tm/egress.js +21 -9
  140. package/dist/tm/egress.js.map +1 -1
  141. package/dist/tm/index.d.ts +23 -20
  142. package/dist/tm/index.d.ts.map +1 -1
  143. package/dist/tm/index.js +117 -116
  144. package/dist/tm/index.js.map +1 -1
  145. package/dist/tm/ledger.d.ts +9 -2
  146. package/dist/tm/ledger.d.ts.map +1 -1
  147. package/dist/tm/ledger.js +4 -1
  148. package/dist/tm/ledger.js.map +1 -1
  149. package/dist/tm/perm-ask.d.ts +3 -3
  150. package/dist/tm/perm-ask.d.ts.map +1 -1
  151. package/dist/tm/perm-ask.js +3 -3
  152. package/dist/tm/perm-ask.js.map +1 -1
  153. package/dist/tm/pipelines.d.ts +12 -11
  154. package/dist/tm/pipelines.d.ts.map +1 -1
  155. package/dist/tm/pipelines.js +40 -207
  156. package/dist/tm/pipelines.js.map +1 -1
  157. package/dist/tm/preview.d.ts +1 -1
  158. package/dist/tm/preview.js +3 -3
  159. package/dist/tm/preview.js.map +1 -1
  160. package/dist/tm/refs.d.ts +31 -4
  161. package/dist/tm/refs.d.ts.map +1 -1
  162. package/dist/tm/refs.js +48 -4
  163. package/dist/tm/refs.js.map +1 -1
  164. package/dist/tm/result.d.ts +1 -1
  165. package/dist/tm/result.d.ts.map +1 -1
  166. package/dist/tm/result.js +4 -3
  167. package/dist/tm/result.js.map +1 -1
  168. package/dist/tm/search.d.ts +2 -2
  169. package/dist/tm/search.js +7 -7
  170. package/dist/tm/search.js.map +1 -1
  171. package/dist/tm/serp-loop.d.ts +19 -7
  172. package/dist/tm/serp-loop.d.ts.map +1 -1
  173. package/dist/tm/serp-loop.js +24 -9
  174. package/dist/tm/serp-loop.js.map +1 -1
  175. package/dist/tm/stats.d.ts +31 -27
  176. package/dist/tm/stats.d.ts.map +1 -1
  177. package/dist/tm/stats.js +170 -58
  178. package/dist/tm/stats.js.map +1 -1
  179. package/dist/tm/store.d.ts +18 -5
  180. package/dist/tm/store.d.ts.map +1 -1
  181. package/dist/tm/store.js +39 -9
  182. package/dist/tm/store.js.map +1 -1
  183. package/dist/tm/tools.d.ts +7 -5
  184. package/dist/tm/tools.d.ts.map +1 -1
  185. package/dist/tm/tools.js +10 -41
  186. package/dist/tm/tools.js.map +1 -1
  187. package/dist/tm/webfetch.d.ts +1 -1
  188. package/dist/tm/webfetch.d.ts.map +1 -1
  189. package/dist/tm/webfetch.js +38 -23
  190. package/dist/tm/webfetch.js.map +1 -1
  191. package/dist/types.d.ts +40 -123
  192. package/dist/types.d.ts.map +1 -1
  193. package/dist/types.js +7 -21
  194. package/dist/types.js.map +1 -1
  195. package/docs/installation-v2.md +124 -39
  196. package/docs/installation.md +9 -0
  197. package/docs/research/browser-pane.md +5 -0
  198. package/docs/v2-port-notes.md +500 -315
  199. package/package.json +1 -1
  200. package/scripts/gen-v2-config.mjs +246 -94
  201. package/scripts/lib/fake-ctx.mjs +244 -146
package/dist/host/v2.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
- * The v2 personality: `Plugin.define({id, setup(ctx)})`.
2
+ * The v2 personality: `Plugin.define({id, setup(ctx)})` — the only personality
3
+ * since 1.7.0 (v1 support was removed).
3
4
  *
4
5
  * `Plugin.define` in @opencode/plugin@2.0.16 is literally `plugin => plugin`,
5
6
  * 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.
7
+ * runtime dependency.
8
8
  *
9
9
  * Scope of this file (milestone M2): build the governed runtime on a
10
10
  * filesystem-backed client shim, register the tm_* tools through
@@ -13,17 +13,14 @@
13
13
  * session hooks / compaction / description appends (#93), or the browser
14
14
  * (#95). Those need this seam proven live first.
15
15
  *
16
- * Two v1 behaviors do not carry over, and both absences are stated rather than
17
- * discovered later:
16
+ * Two host limits are stated rather than discovered later:
18
17
  * - creating agents: `AgentEditor` has no `add`, so the six roles must come
19
18
  * 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.
19
+ * - the default-agent promotion: `default(id)` has NO getter, so "only if the
20
+ * user did not choose" is not expressible — the choice is between never
21
+ * promoting and promoting every boot. The user's standing instruction
22
+ * (2026-09-25) is that Team is ALWAYS the default, so this promotes
23
+ * unconditionally; `defaultAgent: false` in the plugin options opts out.
27
24
  *
28
25
  * The official confirmation dialog is absent as well, and for a different
29
26
  * reason: probed on the live host, a plugin has no way to raise one.
@@ -31,22 +28,29 @@
31
28
  * the v2-worded refusal set below.
32
29
  */
33
30
  import { agents } from "../agents.js";
31
+ import { normalizeAgentName } from "../identity.js";
34
32
  import { DEFAULT_TTL_DAYS, resolveTtlMs, startBlackboardMaintenance } from "../blackboard.js";
35
33
  import { parseExtraDeny, resolveEnvProtectMode } from "../envprotect.js";
36
34
  import { createTmTools } from "../tm/index.js";
35
+ import { agentVisibilityLine } from "../tm/stats.js";
37
36
  import { setAskUnavailableNote } from "../tm/perm-ask.js";
38
37
  import { setPrivateSpacePolicy } from "../tm/webfetch.js";
39
38
  import { blackboardNote } from "./note.js";
40
- import { applyV2BackgroundForce, applyV2PermissionGuards, needsCoarseShellAsk } from "./v2-guard.js";
39
+ import { applyV2BackgroundForce, applyV2EnvFileInputGuard, applyV2PermissionGuards, applyV2ShellTimeoutClamp, needsCoarseShellAsk } from "./v2-guard.js";
41
40
  import { applyV2EventFeed } from "./v2-events.js";
42
41
  import { createV2SessionReader } from "./v2-session-client.js";
43
42
  import { applyV2Probe, probeSummary } from "./v2-probe.js";
44
43
  import { applyV2NativeOffload } from "./v2-offload.js";
44
+ import { applyV2ProbeChain } from "./v2-probe-chain.js";
45
45
  import { applyV2SubagentRegistry, applyV2CompletionWatch } from "./v2-subagent.js";
46
46
  import { applyV2BrowserGate, browserGateSummary } from "./v2-browser-gate.js";
47
47
  import { seedWebfetchDomains } from "../tm/webfetch.js";
48
48
  import { v2CapabilityRows } from "./v2-capabilities.js";
49
- import { applyV2SessionLayer, removalPlan } from "./v2-session.js";
49
+ import { applyV2SessionLayer, removalPlan, resolveSplitConfig } from "./v2-session.js";
50
+ import { applyV2RetryGovernor, createRetryGovernor, resolveRetryConfig } from "./v2-retry.js";
51
+ import { applyV2EarlyCompaction, resolveCompactConfig } from "./v2-compaction.js";
52
+ import { applyV2ContextPrune, resolvePruneConfig } from "./v2-prune.js";
53
+ import { checkDefaultAgentRole, globalConfigDir } from "./v2-default-agent.js";
50
54
  import { createStorageLedgerStore } from "../tm/ledger.js";
51
55
  import { createTeamScope } from "./v2-scope.js";
52
56
  import { bindV2Tool } from "./v2-tool.js";
@@ -68,8 +72,24 @@ function directoryOf(ctx) {
68
72
  return loc.project.directory;
69
73
  return process.cwd();
70
74
  }
75
+ /** #49 feature 2: the concurrency cap. Default 3 (matches "at most 3 parallel
76
+ * reviewers" and leaves a normal user headroom); `0` disables it. A missing or
77
+ * unparseable value falls back to the default rather than to 0, because a typo
78
+ * must not silently turn a safety cap off. */
79
+ export function resolveMaxConcurrentSubagents(env = process.env) {
80
+ const raw = String(env.TM_MAX_CONCURRENT_SUBAGENTS ?? "").trim();
81
+ if (!raw)
82
+ return 3;
83
+ const n = Number.parseInt(raw, 10);
84
+ if (!Number.isFinite(n) || n < 0)
85
+ return 3;
86
+ return n;
87
+ }
71
88
  export const v2Personality = {
72
- id: "team-mode",
89
+ // #45: the display id (host plugin list) — the npm package name. NOT the
90
+ // storage/audit names (`team-mode/selfcheck`, `team-mode/ledger/`,
91
+ // `team-mode-env-protect`), which stay put so existing data is not orphaned.
92
+ id: "@te-river/opencode-team-mode",
73
93
  async setup(ctx) {
74
94
  if (!ctx || typeof ctx.tool?.transform !== "function") {
75
95
  // Nothing can be registered without the tool domain. Say it in the
@@ -79,6 +99,23 @@ export const v2Personality = {
79
99
  return;
80
100
  }
81
101
  const directory = directoryOf(ctx);
102
+ // ---------- #38 runtime fallback: is the configured default role on disk? ----------
103
+ // The plugin cannot see config-directory roles through ctx.agent (probed: the
104
+ // editor snapshot holds only the 7 built-ins), so a `default_agent` naming a
105
+ // missing role would make the host fall back to `build` SILENTLY. Reading the
106
+ // global config from disk is the only way to say so. Never throws, never
107
+ // affects startup, and prints only the role name + the file path it looked for.
108
+ let defaultAgentCheck = { state: "no-config" };
109
+ try {
110
+ defaultAgentCheck = checkDefaultAgentRole({ configDir: globalConfigDir() });
111
+ }
112
+ catch {
113
+ defaultAgentCheck = { state: "unreadable" };
114
+ }
115
+ if (defaultAgentCheck.state === "missing-role") {
116
+ console.warn(`[team-mode] 全局配置的 default_agent="${defaultAgentCheck.defaultAgent}" 指向的角色文件不存在(${defaultAgentCheck.roleFile}):` +
117
+ "宿主会静默退回 build,会话不再是 Team。重跑 scripts/install.ps1(Windows)或 scripts/install.sh 修复。");
118
+ }
82
119
  // ---------- Team-scope isolation (#22, the user's requirement 2026-09-25) ----------
83
120
  // Every v2 hook fires for EVERY agent on this host. This one object is what
84
121
  // each layer asks before it mutates a request, a result, or a permission —
@@ -86,14 +123,24 @@ export const v2Personality = {
86
123
  const scope = createTeamScope(Object.keys(agents));
87
124
  setAskUnavailableNote(V2_NO_DIALOG_NOTE);
88
125
  const options = (ctx.options ?? {});
89
- const envProtectMode = options.envProtect ? resolveEnvProtectMode(process.env.TM_ENV_PROTECT) : "off";
126
+ // R6 is ARMED BY DEFAULT, matching v1 (which was always armed). The old form
127
+ // (`options.envProtect ? … : "off"`) made the env-file red line opt-IN, and
128
+ // `docs/installation-v2.md` never mentioned the option — so a default install
129
+ // read `.env` in plaintext while AGENTS.md called that rule "hard". Two
130
+ // explicit switches turn it off, and only those two: the plugin option
131
+ // `envProtect:false`, or `TM_ENV_PROTECT=off` (resolved below, which also
132
+ // fails CLOSED into "strict" for a typo). On v2 the shell face is `ask`
133
+ // (the host's own dialog) and the env-FILE face is `deny`; the resolved mode
134
+ // rides the boot row's `env_protect` field so the user can see which world
135
+ // they are in.
136
+ const envProtectMode = options.envProtect === false ? "off" : resolveEnvProtectMode(process.env.TM_ENV_PROTECT);
90
137
  const envProtectExtra = parseExtraDeny(process.env.TM_ENV_PROTECT_EXTRA_DENY);
91
138
  // ---------- 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
139
+ // The user's standing instruction (2026-09-25): do not block network
140
+ // access at all EXCEPT sensitive and internal addresses. A plugin cannot
141
+ // raise the host's official per-request dialog, so an allowlist would be a
142
+ // set of pages the agent can never see and no one can approve -- a gate with
143
+ // no door. `"*"` therefore
97
144
  // replaces the DEFAULT here, and what still holds absolutely is `checkWebUrl`'s
98
145
  // address policy underneath it: link-local / metadata / reserved ranges are a hard
99
146
  // deny no config can open (and were never consentable), and private space
@@ -101,9 +148,9 @@ export const v2Personality = {
101
148
  // tools, which have no dialog to ask with, and opened only by the host's own
102
149
  // `effect:"ask"` for the native tools. IPv4-mapped and DNS64 spellings are
103
150
  // 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.
151
+ // An explicit TM_WEBFETCH_ALLOWED_DOMAINS always wins; this only changes
152
+ // which default applies, and it is resolved from a COPY of the env so the
153
+ // process env itself is untouched.
107
154
  const v2Env = { ...process.env };
108
155
  if (!String(v2Env.TM_WEBFETCH_ALLOWED_DOMAINS ?? "").trim())
109
156
  v2Env.TM_WEBFETCH_ALLOWED_DOMAINS = "*";
@@ -128,9 +175,9 @@ export const v2Personality = {
128
175
  // other half of that sentence is that a direct tool definition rides EVERY
129
176
  // request (our own estimate for the full set is 9 528 tokens).
130
177
  // 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.
178
+ // description first, then go direct", and the descriptions are now slim enough
179
+ // that the measured per-role cost is 2 660 build-class / 4 726 tester /
180
+ // 6 391 researcher / 7 793 lead.
134
181
  // `TM_V2_CODEMODE=off` restores catalog-only for anyone who wants the tokens back
135
182
  // — and the boot note says which world this is, because in catalog mode most of
136
183
  // our governance text never reaches the model at all.
@@ -144,10 +191,10 @@ export const v2Personality = {
144
191
  // and the shutdown line reports both halves: `tools_codemode` (sent) and
145
192
  // `tools_in_request` (what the assembled request actually carried).
146
193
  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).
194
+ // No SDK client. The v2 plugin ctx carries no `file.read` / `find.text` /
195
+ // `session.*` / `pty.*` object, and the fs shim that used to stand in for the
196
+ // first two went away with `tm_read`/`tm_grep` (the native tools, governed at
197
+ // `execute.after`, are the file ladder now).
151
198
  // What IS bridged is the one thing the collect path cannot be honest without:
152
199
  // `tm_join` must be able to check whether a named session id is really the
153
200
  // caller's child, and a live 2.0.16 run proved that without it the tool reports
@@ -179,13 +226,12 @@ export const v2Personality = {
179
226
  const notes = [];
180
227
  // ---------- register the governed tools ----------
181
228
  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
229
+ // The retired tools are not registered. The host's own `execute` (Code
230
+ // Mode) already runs "one program, N governed calls, zero round-trips, only
231
+ // the aggregate entering the context", and a live session showed our
185
232
  // governed results still come back offloaded through it — so shipping a
186
233
  // 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.
234
+ // prompts the config files carry are forked by gen-v2-config.mjs.
189
235
  const V2_UNREGISTERED = V1_ONLY_TOOLS;
190
236
  const bindings = [];
191
237
  const derived = [];
@@ -211,10 +257,10 @@ export const v2Personality = {
211
257
  // shape, so name the tools and say how they were obtained.
212
258
  const retired = entries.map(([name]) => name).filter((n) => V2_UNREGISTERED.has(n));
213
259
  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 保留这些别名`);
260
+ // Deliberate absences are said, not silently missing: an agent that looks
261
+ // for `tm_read` needs to learn from the boot log that this is the design,
262
+ // not a failed install.
263
+ notes.push(`不注册 ${retired.join("/")} —— 同一件工作交给宿主自己的 read/grep/shell/execute(结果照样被 execute.after 治理)`);
218
264
  }
219
265
  if (derived.length) {
220
266
  notes.push(`${derived.join("/")} 没有 zod 形状,参数表是从描述符文本推出来的(类型靠 name: type 前缀判定,不是 zod 保证)`);
@@ -249,14 +295,72 @@ export const v2Personality = {
249
295
  // ---------- the permission guard (egress red line + R6 per command) ----------
250
296
  // Installed BEFORE the agent transform because whether the config still needs
251
297
  // the coarse `shell -> ask` escalation depends on this hook being there.
252
- const guards = await applyV2PermissionGuards(ctx, { envProtectMode, scope });
298
+ const guards = await applyV2PermissionGuards(ctx, {
299
+ envProtectMode,
300
+ scope,
301
+ // #49 feature 2: the concurrency cap reads the plugin's ONE children
302
+ // registry (the same rows tm_join collects), so a session cannot exceed the
303
+ // cap by dispatching faster than the lead collects. `0` disables it.
304
+ runningChildren: (caller) => tmRuntime.runningChildren(caller),
305
+ maxConcurrent: resolveMaxConcurrentSubagents(v2Env),
306
+ onConcurrencyDenied: (info) => {
307
+ try {
308
+ tmRuntime.pipelines.store.appendTrajectory({
309
+ tool: "host",
310
+ step_id: "v2-concurrency",
311
+ event: "denied",
312
+ caller: info.caller,
313
+ cap: info.cap,
314
+ running: info.running.length,
315
+ ids: info.running.map((c) => c.sessionID).join(","),
316
+ });
317
+ }
318
+ catch {
319
+ /* the trajectory is an extra, never a reason to fail the call */
320
+ }
321
+ },
322
+ });
253
323
  // Said out loud because it is a promise with a boundary: the user's rule is
254
324
  // that nothing outside Team may be touched, and the honest consequence is that
255
325
  // the red lines we inject are therefore NOT protecting a build session either.
256
326
  notes.push("已按 Team 作用域收口:工具面裁剪、温度、黑板提示、原生结果卸载、R6 与地址红线、subagent 强制后台,都只对我们六个角色的会话生效;build / plan 等其余模式保持宿主出厂行为(代价:那些会话也不由我们补红线,需要全局红线请让宿主自己的 permission 规则承担)");
257
327
  registrations.push(...guards.registrations);
328
+ // #58: the SAME R6 file-path face, on the seam that sees the whole input.
329
+ // `permission.evaluate`'s resource for `grep`/`glob` is the PATTERN, not the
330
+ // search path, so the red line had a hole the width of those two tools —
331
+ // `read .env` was denied while `grep SECRET in .env` succeeded (2.0.24).
332
+ const envFileInput = await applyV2EnvFileInputGuard(ctx, { envProtectMode, scope, report: guards.report });
333
+ registrations.push(...envFileInput.registrations);
258
334
  const bgForce = await applyV2BackgroundForce(ctx, { scope });
259
335
  registrations.push(...bgForce.registrations);
336
+ // The shell timeout clamp (issue #6), ported here when the v1 personality was
337
+ // cut: the host gives `execute.before` a mutable `input`, the same seam
338
+ // `applyV2BackgroundForce` writes, so the protection survives the deletion —
339
+ // but only if it is counted, because a clamp nobody can see is a clamp a user
340
+ // reads as "the tool killed my probe for no reason".
341
+ const shellTimeout = await applyV2ShellTimeoutClamp(ctx, {
342
+ probeMs: tmRuntime.config.bashTimeoutProbeMs,
343
+ maxMs: tmRuntime.config.bashTimeoutMaxMs,
344
+ readonlyAllowed: tmRuntime.config.bashReadonlyAllowed,
345
+ scope,
346
+ onClamp: (info) => {
347
+ try {
348
+ tmRuntime.pipelines.store.appendTrajectory({
349
+ tool: "shell",
350
+ step_id: "timeout-clamp",
351
+ event: "clamped",
352
+ via: info.via,
353
+ from_ms: info.from,
354
+ to_ms: info.to,
355
+ sessionID: info.sessionID ?? "",
356
+ });
357
+ }
358
+ catch {
359
+ /* the trajectory is an extra, never a reason to fail the call */
360
+ }
361
+ },
362
+ });
363
+ registrations.push(...shellTimeout.registrations);
260
364
  // JIT governance over the HOST's tools, so the offload promise does not depend
261
365
  // on which tool the model happened to pick (see v2-offload.ts for why a
262
366
  // per-tool promise is no promise). Registered BEFORE the probe so the probe's
@@ -264,6 +368,29 @@ export const v2Personality = {
264
368
  // teardown is a counter that does not exist.
265
369
  const offload = await applyV2NativeOffload(ctx, { pipelines: tmRuntime.pipelines, scope });
266
370
  registrations.push(...(await Promise.all(offload.registrations)));
371
+ // #35: the Code Mode adoption hint. Registered AFTER the offloader so its
372
+ // append lands on whatever text the offloader left (a rewritten body or the
373
+ // host's verbatim one) — the hint only ever ADDS a line, never rewrites.
374
+ const probeChain = applyV2ProbeChain(ctx, {
375
+ scope,
376
+ env: v2Env,
377
+ onAdvise: (info) => {
378
+ try {
379
+ tmRuntime.pipelines.store.appendTrajectory({
380
+ tool: "host",
381
+ step_id: "probe-chain",
382
+ event: "advised",
383
+ native_tool: info.tool,
384
+ count: info.count,
385
+ sessionID: info.sessionID,
386
+ });
387
+ }
388
+ catch {
389
+ /* the trajectory is an extra, never a reason to fail the call */
390
+ }
391
+ },
392
+ });
393
+ registrations.push(...(await Promise.all(probeChain.registrations)));
267
394
  // The host's own sub-agents, entered into tm_join's registry (decision 4,
268
395
  // 2026-09-26). On v2 every dispatch IS the host's `subagent` tool, so without this
269
396
  // the collect side had nothing to collect: tm_join answered 没有待收集的派发 about
@@ -301,11 +428,59 @@ export const v2Personality = {
301
428
  allowlist: seedWebfetchDomains(tmRuntime.config.webfetchAllowedDomains),
302
429
  scope,
303
430
  env: v2Env,
431
+ // #14: the SERP rate limit's trajectory line. The gate has no store, so the
432
+ // sink is wired here — same shape as the shell-timeout clamp's `onClamp`.
433
+ onSerp: (info) => {
434
+ try {
435
+ tmRuntime.pipelines.store.appendTrajectory({
436
+ tool: "browser",
437
+ step_id: "serp-loop",
438
+ event: info.event,
439
+ engine: info.engine,
440
+ query: info.query,
441
+ host: info.host,
442
+ count: info.count,
443
+ limit: info.limit,
444
+ sessionID: info.sessionID,
445
+ });
446
+ }
447
+ catch {
448
+ /* the trajectory is an extra, never a reason to fail the call */
449
+ }
450
+ },
304
451
  });
305
452
  if (!browserGate.registrations.length) {
306
453
  notes.push("原生 browser_* 的门禁没装上(ctx.tool.hook 不可用):宿主的 45 个浏览器工具在 Team 会话里也不受我们的域名 / 地址规则约束");
307
454
  }
308
455
  registrations.push(...browserGate.registrations);
456
+ // ---- late-bound observation readers (A2 / A3) ----
457
+ // Both of these are `let` for one reason each: the layers they read register
458
+ // BELOW this point, and the probe takes its first snapshot at attach — a closure
459
+ // that touched a not-yet-initialized `const` would throw inside `onSummary`, the
460
+ // try/catch there would swallow it, and the row would silently disappear. So
461
+ // until the real reader is installed, each one says "nothing observed yet"
462
+ // instead of inventing a zero.
463
+ /** How many times `ctx.agent.transform` actually handed us an editor. Zero is
464
+ * "nobody has looked", which is NOT the same fact as "all six roles found". */
465
+ let agentsEditorRounds = 0;
466
+ /** The role ids the HOST resolved in a real request — the only observation that
467
+ * separates "installed but invisible to the snapshot" from "not installed". */
468
+ let resolvedAgentIds = () => [];
469
+ /** #58: `tools_in_request` used to live ONLY on `v2-shutdown`, which the CLI
470
+ * never writes — so on a CLI run the field was unobservable. Late-bound for
471
+ * the same reason as the counters: the request layer registers BELOW the
472
+ * probe, and a closure that touched a not-yet-initialized `const` would throw
473
+ * inside `onSummary`, where the try/catch would swallow the whole row. */
474
+ let tmInRequestSurface = () => "unobserved";
475
+ /** A3: the in-process observation counters. Measured today: three runs wrote
476
+ * ZERO `v2-shutdown` rows, because the CLI path never reaches dispose — so the
477
+ * final totals have to survive on the throttled surface snapshot too, and a row
478
+ * has to say WHERE its counters were read (`counters_at`) or "absent" gets read
479
+ * as "never fired". */
480
+ let observationCounters = () => ({
481
+ counters_at: "attach",
482
+ counters_note: "观察计数器还没接上(请求层与事件流当时尚未注册)——这一行里缺失的字段是「没有地方去读」,不是 0 次,别把它读成「宿主从没调用过」",
483
+ });
309
484
  const probe = await applyV2Probe(ctx, {
310
485
  onSummary: (summary) => {
311
486
  try {
@@ -335,9 +510,20 @@ export const v2Personality = {
335
510
  session_get_resolved: sessionReader.report.resolved,
336
511
  session_context_ok: sessionReader.report.contextOk,
337
512
  session_context_keys: sessionReader.report.contextKeys.join(","),
513
+ // #33: the stop seam rides the throttled snapshot too, so an interrupted round
514
+ // still records whether cancel:true reached the host at all.
515
+ stop_tried: sessionReader.report.interruptTried,
516
+ stop_confirmed: sessionReader.report.interruptConfirmed,
338
517
  completion_context_fired: completion.report.contextFired,
339
518
  completion_skipped: completion.report.skipped,
340
519
  completion_settled: completion.report.settled,
520
+ // A3: the whole counter set rides the throttled snapshot, not only
521
+ // teardown — dispose is not guaranteed to run, and a run that is
522
+ // interrupted is exactly the run whose counters someone needs.
523
+ ...observationCounters("surface"),
524
+ // #58: the delivery fact the CLI could never see — it only rode
525
+ // `v2-shutdown`, which a CLI run never reaches.
526
+ tools_in_request: tmInRequestSurface(),
341
527
  ...summary,
342
528
  });
343
529
  }
@@ -373,7 +559,7 @@ export const v2Personality = {
373
559
  // `TM_R6_FINE_ASK=off`, or no hook installed to hand the decision to.
374
560
  const escalateShellAsk = envProtectMode !== "off" && needsCoarseShellAsk(process.env, guards.installed);
375
561
  // 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
562
+ // header for why there is no conservative form). `default()` on
377
563
  // an agent that does not exist would just make the host fall back to build
378
564
  // silently, so it is gated on the role actually being present, and the
379
565
  // refusal to promote is reported rather than hidden.
@@ -382,6 +568,7 @@ export const v2Personality = {
382
568
  let defaultPostCheck = "not-attempted";
383
569
  if (typeof ctx.agent?.transform === "function") {
384
570
  registrations.push(await ctx.agent.transform((editor) => {
571
+ agentsEditorRounds++;
385
572
  for (const id of wantedIds) {
386
573
  const existing = editor.get(id);
387
574
  if (!existing) {
@@ -391,16 +578,16 @@ export const v2Personality = {
391
578
  const { triples, unmapped } = triplesFromAgentPermission(agents[id]?.permission, { escalateShellAsk, agentName: id });
392
579
  for (const u of unmapped)
393
580
  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.
581
+ // `V1_ONLY_TOOLS` is passed as the reclaim set: a triple naming a
582
+ // retired action can only have come from an earlier boot, and a
583
+ // permission rule for a tool the host has never heard of reads as a
584
+ // capability the user granted.
398
585
  const merged = mergeTriples(existing.permissions, triples, V1_ONLY_TOOLS);
399
586
  if (merged.changed)
400
587
  editor.update(id, (a) => { a.permissions = merged.triples; });
401
588
  }
402
589
  if (promoteTeamDefault) {
403
- // `editor.get("team")` returning nothing is NOT evidence the host does
590
+ // `editor.get("Team")` returning nothing is NOT evidence the host does
404
591
  // not know the role — the live run is literally executing as `team` while
405
592
  // this snapshot lists only the 7 built-ins, so the transform receives the
406
593
  // agent set from BEFORE the config directory merged. Gating the
@@ -409,13 +596,13 @@ export const v2Personality = {
409
596
  // the AFTER view is what gets recorded.
410
597
  let ok = true;
411
598
  try {
412
- editor.default("team");
599
+ editor.default("Team");
413
600
  }
414
601
  catch {
415
602
  ok = false;
416
603
  }
417
604
  defaultPromoted = ok;
418
- defaultPostCheck = editor.get?.("team") ? "present-after" : "absent-after";
605
+ defaultPostCheck = editor.get?.("Team") ? "present-after" : "absent-after";
419
606
  }
420
607
  // Recorded HERE, at the moment of observation. The host defers this
421
608
  // callback (a live boot reached the trajectory line with
@@ -430,6 +617,14 @@ export const v2Personality = {
430
617
  event: "personality",
431
618
  api: 2,
432
619
  agents_missing: missingAgents.join(" "),
620
+ // A2: the three fields tm_stats' `agentVisibilityLine` needs to keep
621
+ // the three states apart. The editor snapshot is blind by construction
622
+ // (it gets the agent set from BEFORE the config directory merges), so
623
+ // "not in the snapshot" is never evidence of a missing file — the
624
+ // decisive observation is whether the host resolved the id in a request.
625
+ agents_editor_rounds: agentsEditorRounds,
626
+ agents_editor_unseen: missingAgents.join(","),
627
+ agents_resolved_in_request: resolvedAgentIds().join(","),
433
628
  agents_default: defaultPromoted === null ? "opt-out" : defaultPostCheck === "present-after" ? "team" : "called-unverified",
434
629
  agents_unmapped: [...unmappedActions].join(" "),
435
630
  agents_normalized: true,
@@ -446,11 +641,14 @@ export const v2Personality = {
446
641
  return -1;
447
642
  }
448
643
  })(),
449
- agents_editor_ids: (() => {
644
+ // The ids the transform callback can SEE — which is the set from BEFORE the config
645
+ // directory merges, so it never contains our six roles. Named for what it measures:
646
+ // the old name implied "the editor's ids" and the filter made it look truncated
647
+ // (2 of 7) when the truth is that the transform runs too early to see them.
648
+ agents_editor_ids_at_transform: (() => {
450
649
  try {
451
650
  return (editor.list?.() ?? [])
452
651
  .map((a) => String(a?.id ?? "?"))
453
- .filter((n) => wantedIds.includes(n) || n === "build" || n === "plan")
454
652
  .join(" ");
455
653
  }
456
654
  catch {
@@ -468,7 +666,7 @@ export const v2Personality = {
468
666
  notes.push("ctx.agent.transform 不存在,角色权限矩阵未规范化");
469
667
  }
470
668
  if (defaultPromoted === false) {
471
- notes.push("Team 没有成为默认:editor.default(\"team\") 抛错了");
669
+ notes.push("Team 没有成为默认:editor.default(\"Team\") 抛错了");
472
670
  }
473
671
  else if (defaultPromoted && defaultPostCheck !== "present-after") {
474
672
  // Measured on a live 2.0.16 standalone boot: the call is accepted, `team` is
@@ -491,14 +689,13 @@ export const v2Personality = {
491
689
  // ---------- the request layer ----------
492
690
  // The whitelist was only ever a DENY, which stops a call but leaves the
493
691
  // 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
692
+ // removal, and the resolved board root and the temperature ride the request
496
693
  // instead — a v2 agent is a config FILE and cannot carry a per-workspace
497
694
  // 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.
695
+ // sends. This also starts the blackboard TTL sweeper, which this
696
+ // personality otherwise silently lacked: the note promises a sweep, so
697
+ // shipping it without the sweeper would be the exact overstated claim this
698
+ // product is built to refuse.
502
699
  const ttlMs = resolveTtlMs(options);
503
700
  const ttlDays = Math.round(ttlMs / (24 * 60 * 60 * 1000)) || DEFAULT_TTL_DAYS;
504
701
  const note = blackboardNote(startBlackboardMaintenance(directory, ttlMs), ttlDays);
@@ -533,7 +730,89 @@ export const v2Personality = {
533
730
  }
534
731
  }
535
732
  }
536
- const session = await applyV2SessionLayer(ctx, { temperature, note, noteAgents: ["team"], plan, scope });
733
+ const session = await applyV2SessionLayer(ctx, {
734
+ temperature,
735
+ note,
736
+ noteAgents: ["Team"],
737
+ plan,
738
+ scope,
739
+ // #49 feature 4: 拆分任务. The layer owns no store, so the trajectory sink
740
+ // is wired here — same shape as the retry governor's `onEvent`.
741
+ split: resolveSplitConfig(v2Env),
742
+ onSplit: (row) => {
743
+ try {
744
+ tmRuntime.pipelines.store.appendTrajectory({ tool: "host", step_id: "v2-split", api: 2, ...row });
745
+ }
746
+ catch {
747
+ /* the trajectory is an extra, never a reason to fail the call */
748
+ }
749
+ },
750
+ });
751
+ // #58: point the late-bound reader at the real observation now that the
752
+ // request layer exists, so `v2-surface` carries `tools_in_request` too.
753
+ tmInRequestSurface = () => (session.report.tmInRequestSurface ? "tm-in-request" : "catalog-only");
754
+ // #49 feature 3: 错峰重试. We have no seam that intercepts the model's own
755
+ // retry, so this layer only (1) recognises a provider throttle from the event
756
+ // feed, (2) injects an exact wait into the next request, and (3) denies new
757
+ // dispatches during the breaker's cooldown. Registered AFTER the permission
758
+ // guards so its `evaluate` hook runs after the concurrency cap — both only
759
+ // ever deny, so whichever fires first keeps its message and the stricter
760
+ // effect (deny) stands either way.
761
+ const retryConfig = resolveRetryConfig(v2Env);
762
+ // The layer owns no store, so the trajectory sink is wired here — same shape
763
+ // as the shell-timeout clamp's `onClamp` and the SERP guard's `onSerp`.
764
+ const retrySink = (row) => {
765
+ try {
766
+ tmRuntime.pipelines.store.appendTrajectory({ tool: "host", step_id: "v2-retry", api: 2, ...row });
767
+ }
768
+ catch {
769
+ /* the trajectory is an extra, never a reason to fail the call */
770
+ }
771
+ };
772
+ const retryGovernor = createRetryGovernor({ ...retryConfig, onEvent: retrySink });
773
+ const retry = retryConfig.enabled
774
+ ? await applyV2RetryGovernor(ctx, { scope, governor: retryGovernor, onEvent: retrySink })
775
+ : { registrations: [], report: retryGovernor.report };
776
+ registrations.push(...retry.registrations);
777
+ // Team's own early-compaction trigger. The host exposes no percentage knob (its
778
+ // `compaction` block is auto/prune/tail_turns/preserve_recent_tokens/reserved) and a
779
+ // 2.x plugin has no config domain at all, so "compact at 75% of the window" can only
780
+ // live here, in the package, enforced through `ctx.session.compact`.
781
+ const compactConfig = resolveCompactConfig(process.env);
782
+ const compaction = await applyV2EarlyCompaction(ctx, {
783
+ config: compactConfig,
784
+ scope,
785
+ // Every outcome gets its OWN trajectory line: the counters ride rows a CLI run
786
+ // never produces (`v2-shutdown` needs dispose, `v2-surface` is throttled), so
787
+ // without this the feature would be invisible in exactly the runs used to check it.
788
+ onEvent: (row) => {
789
+ try {
790
+ tmRuntime.pipelines.store.appendTrajectory({ tool: "host", step_id: "v2-compact", event: "compaction", api: 2, ...row });
791
+ }
792
+ catch {
793
+ /* a diagnostic that cannot be written never breaks the request it describes */
794
+ }
795
+ },
796
+ });
797
+ // Context Pruning (plan 功能 1, #49): the request layer trims the tool
798
+ // surface, the compaction layer decides WHEN to summarize, and this layer
799
+ // shrinks the HISTORY itself — settled messages become one-line pointers
800
+ // (handle / child id / re-run hint) once the session crosses a fraction of
801
+ // its window. Registered AFTER the session layer so its `context` hook runs
802
+ // on the same assembled request, and it never touches a message outside its
803
+ // plan (the newest message and every hard-protected one stay byte-exact).
804
+ const prune = await applyV2ContextPrune(ctx, {
805
+ config: resolvePruneConfig(process.env),
806
+ scope,
807
+ onEvent: (row) => {
808
+ try {
809
+ tmRuntime.pipelines.store.appendTrajectory({ tool: "host", step_id: "v2-prune", event: "prune", api: 2, ...row });
810
+ }
811
+ catch {
812
+ /* a diagnostic that cannot be written never breaks the request it describes */
813
+ }
814
+ },
815
+ });
537
816
  // Everything the matrix reports now exists, so the late-bound closure can be
538
817
  // pointed at the real observations.
539
818
  v2Matrix = () => v2CapabilityRows({
@@ -544,6 +823,23 @@ export const v2Personality = {
544
823
  offload,
545
824
  sessionHooks: 2,
546
825
  temperature,
826
+ // The prune layer's counters, handed to the matrix so its row can earn `ok` on a
827
+ // process that actually pruned something. Without this the row could only ever read
828
+ // `declared`, which is the "wired but never observed" shape the matrix exists to
829
+ // distinguish from "it ran".
830
+ prune: {
831
+ enabled: prune.report.enabled,
832
+ atPercent: prune.report.atPercent,
833
+ keepTailPercent: prune.report.keepTailPercent,
834
+ checked: prune.report.checked,
835
+ prunedMessages: prune.report.prunedMessages,
836
+ prunedTokens: prune.report.prunedTokens,
837
+ below: prune.report.below,
838
+ noLimit: prune.report.noLimit,
839
+ foreignSkipped: prune.report.foreignSkipped,
840
+ threw: prune.report.threw,
841
+ lastPercent: prune.report.lastPercent,
842
+ },
547
843
  // Both derived, not asserted: "we think v2 has no X" has to come from the
548
844
  // object we were handed, so a host that grows the seam shows up here
549
845
  // automatically instead of leaving a stale "missing" row forever.
@@ -552,25 +848,197 @@ export const v2Personality = {
552
848
  // path the tool actually consumes. Deriving it from ctx rather than
553
849
  // hard-coding `false` is what keeps that honest either way.
554
850
  eventFeed: feed.report,
851
+ // #33: the stop seam's counters, read off the bridge the tool actually calls.
852
+ stop: {
853
+ tried: sessionReader.report.interruptTried,
854
+ confirmed: sessionReader.report.interruptConfirmed,
855
+ refused: sessionReader.report.interruptRefused,
856
+ unknown: sessionReader.report.interruptUnknown,
857
+ error: sessionReader.report.interruptError,
858
+ },
859
+ // #39: the early-compaction seam's counters, read off the layer that calls it.
860
+ compact: {
861
+ enabled: compaction.report.enabled,
862
+ percent: compaction.report.percent,
863
+ checked: compaction.report.checked,
864
+ fired: compaction.report.fired,
865
+ confirmed: compaction.report.confirmed,
866
+ conflicts: compaction.report.conflicts,
867
+ threw: compaction.report.threw,
868
+ noLimit: compaction.report.noLimit,
869
+ source: compaction.report.lastSource,
870
+ lastPercent: compaction.report.lastPercent,
871
+ error: compaction.report.lastError,
872
+ wired: compaction.registrations.length > 0,
873
+ },
555
874
  hasTodoSeam: typeof ctx.session?.todo === "function",
556
875
  hasAsk: typeof ctx.tool === "function",
557
876
  storageState: storageProbe.state,
558
877
  scope,
559
878
  });
560
879
  registrations.push(...session.registrations);
880
+ registrations.push(...prune.registrations);
561
881
  if (!session.registrations.length) {
562
882
  notes.push("ctx.session.hook 不存在:工具面裁剪、温度、黑板根目录三项请求层治理都没装上");
563
883
  }
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) });
884
+ // This personality had no event subscription at all (#8), so a child that
885
+ // settled two seconds after the dispatch stayed "running" for the whole wait
886
+ // budget and `tm_join` reported a state it had never observed — goal #6's
887
+ // failure shape, and a bug rather than a limitation now that
888
+ // `ctx.event.subscribe()` is measured to work.
889
+ const feed = await applyV2EventFeed(ctx, {
890
+ onEvent: (ev) => tmRuntime.observeDispatchEvent(ev),
891
+ // #39: the host publishes usage ONLY on the event feed (measured: the context hook's
892
+ // messages carry no tokens, while session.usage.updated fires hundreds of times), so
893
+ // the early-compaction layer is fed from here rather than from a second subscription.
894
+ onUsage: (data) => compaction.observeUsage(data),
895
+ // #49 feature 3: the retry governor's error tap. `session.error` is the only
896
+ // place a provider throttle is published; the governor classifies its text and
897
+ // arms the next-request directive / the cooldown. A tap that throws never
898
+ // closes the feed (the governor itself never throws).
899
+ onError: (data) => retryGovernor.observeEvent(data),
900
+ });
571
901
  if (!feed.report.active) {
572
902
  notes.push(`事件流没接通(${feed.report.stopped ?? "原因未知"}):tm_join 的结算检测只剩等待预算内的轮询,子代理结算了也要等到超时才报告`);
573
903
  }
904
+ // Every layer the counters live in is registered now, so point the late-bound
905
+ // readers at the real observations. From here on, both the throttled `v2-surface`
906
+ // row and `v2-shutdown` carry the same set — labelled by `counters_at`, because
907
+ // "no dispatch happened" and "we were deaf to it" must never print one sentence.
908
+ resolvedAgentIds = () => {
909
+ const ids = new Set(probe.report.agentsSeen);
910
+ for (const id of Object.keys(session.report.removed))
911
+ ids.add(id);
912
+ return [...ids].filter((id) => wantedIds.some((w) => normalizeAgentName(w) === normalizeAgentName(id))).sort();
913
+ };
914
+ observationCounters = (source) => ({
915
+ counters_at: source,
916
+ guard_seen: guards.report.seen,
917
+ guard_actions: Object.entries(guards.report.byAction).map(([k, v]) => `${k}=${v}`).join(" "),
918
+ guard_shell_matched: guards.report.shellMatched,
919
+ guard_strictened: guards.report.strictened,
920
+ guard_denied: guards.report.denied,
921
+ guard_envfile_denied: guards.report.envFileDenied,
922
+ guard_envfile_denied_by_input: guards.report.envFileDeniedByInput,
923
+ guard_envfile_input_seen: guards.report.envFileInputSeen,
924
+ guard_envfile_input_threw: guards.report.envFileInputThrew,
925
+ guard_envfile_input_foreign_skipped: guards.report.envFileInputForeignSkipped,
926
+ guard_foreign_skipped: guards.report.foreignSkipped,
927
+ // #49 feature 2: the concurrency cap — how many `subagent` evaluations it
928
+ // looked at, how many it denied, and the peak running count it saw.
929
+ concurrency_seen: guards.report.concurrencySeen,
930
+ concurrency_denied: guards.report.concurrencyDenied,
931
+ concurrency_running_max: guards.report.concurrencyRunningMax,
932
+ concurrency_threw: guards.report.concurrencyThrew,
933
+ subagent_seen: bgForce.report.seen,
934
+ subagent_forced: bgForce.report.forced,
935
+ shell_timeout_seen: shellTimeout.report.seen,
936
+ shell_timeout_clamped: shellTimeout.report.clamped,
937
+ shell_timeout_foreign_skipped: shellTimeout.report.foreignSkipped,
938
+ shell_timeout_threw: shellTimeout.report.threw,
939
+ // (a) The PLAN, not our hook's own cut. `session.report.removed` counts what
940
+ // THIS layer deleted from `event.tools`, and on a live host that is 0 because
941
+ // the permission layer already excluded the denied names before the request
942
+ // reached us — so the old field read as "nothing was trimmed" while the boot
943
+ // row's `request_removed_plan` said `team=7`. The whitelist's effect on the
944
+ // request IS the removal, so this reports the plan (aligned with
945
+ // `request_removed_plan`); the hook's own cut is an implementation detail that
946
+ // is 0 by design, not a fact the user needs a separate field for.
947
+ tools_removed: [...plan.entries()].map(([k, v]) => `${k}=${v.size}`).join(" "),
948
+ note_pushed: session.report.notePushed,
949
+ compaction_lines: session.report.compactionLines,
950
+ // #49 feature 4: 拆分任务 — briefs measured and advice injected.
951
+ split_seen: session.report.splitSeen,
952
+ split_advised: session.report.splitAdvised,
953
+ compact_enabled: compaction.report.enabled,
954
+ compact_at_percent: compaction.report.percent,
955
+ compact_checked: compaction.report.checked,
956
+ compact_usage_seen: compaction.report.usageSeen,
957
+ compact_usage_events: compaction.report.usageEvents,
958
+ compact_event_decided: compaction.report.eventDecided,
959
+ compact_fired: compaction.report.fired,
960
+ compact_confirmed: compaction.report.confirmed,
961
+ compact_source: compaction.report.lastSource,
962
+ compact_last_percent: compaction.report.lastPercent,
963
+ // #49 Context Pruning — the history-shrinking half of the budget work.
964
+ prune_enabled: prune.report.enabled,
965
+ prune_at_percent: prune.report.atPercent,
966
+ prune_keep_tail_percent: prune.report.keepTailPercent,
967
+ prune_checked: prune.report.checked,
968
+ prune_pruned_messages: prune.report.prunedMessages,
969
+ prune_pruned_tokens: prune.report.prunedTokens,
970
+ prune_unknown_parts: prune.report.unknownParts,
971
+ prune_skipped_protected: prune.report.skippedProtected,
972
+ prune_below: prune.report.below,
973
+ prune_no_limit: prune.report.noLimit,
974
+ prune_foreign_skipped: prune.report.foreignSkipped,
975
+ prune_threw: prune.report.threw,
976
+ prune_last_percent: prune.report.lastPercent,
977
+ // #49 feature 3: 错峰重试 — recognition counts, breaker trips, cooldown
978
+ // denials and the last computed wait, so "we backed off" is a number.
979
+ retry_enabled: retry.report.enabled,
980
+ retry_quota_seen: retry.report.quotaSeen,
981
+ retry_rate_seen: retry.report.rateSeen,
982
+ retry_transient_seen: retry.report.transientSeen,
983
+ retry_unknown_seen: retry.report.unknownSeen,
984
+ retry_break_trips: retry.report.breakTrips,
985
+ retry_cooldowns: retry.report.cooldowns,
986
+ retry_last_delay_ms: retry.report.lastDelayMs,
987
+ retry_directives_injected: retry.report.directivesInjected,
988
+ retry_cooldown_denied: retry.report.cooldownDenied,
989
+ retry_foreign_skipped: retry.report.foreignSkipped,
990
+ retry_threw: retry.report.threw,
991
+ scope_ours: scope.report.ours,
992
+ scope_foreign: scope.report.foreign,
993
+ scope_unknown: scope.report.unknown,
994
+ host_subagent_seen: hostChildren.report.seen,
995
+ host_children_registered: hostChildren.report.registered,
996
+ host_children_unpaired: hostChildren.report.unpaired,
997
+ host_children_no_id: hostChildren.report.noChildId,
998
+ host_children_settled_at_once: hostChildren.report.settledAtOnce,
999
+ session_get_attempts: sessionReader.report.attempted,
1000
+ session_get_shape: sessionReader.report.usedShape ?? "(none)",
1001
+ session_get_resolved: sessionReader.report.resolved,
1002
+ session_context_ok: sessionReader.report.contextOk,
1003
+ // AGENTS.md says a `child_body_missing` row can be checked against the shape the
1004
+ // seam saw — until now `report.contextShape` was write-only (the trajectory rows
1005
+ // printed the return value, `session_get_shape` printed `usedShape`), so the claim
1006
+ // had no readback. Names and counts only, never a value (R6 binds a diagnostic).
1007
+ session_context_shape: sessionReader.report.contextShape ?? "(none)",
1008
+ session_context_keys: sessionReader.report.contextKeys.join(","),
1009
+ session_get_error: sessionReader.report.lastError ?? sessionReader.report.contextError ?? "",
1010
+ completion_context_fired: completion.report.contextFired,
1011
+ completion_prompt_fired: completion.report.promptFired,
1012
+ completion_skipped: completion.report.skipped,
1013
+ completion_envelopes: completion.report.seen,
1014
+ completion_settled: completion.report.settled,
1015
+ event_feed: feed.report.active ? "subscribed" : "absent",
1016
+ event_stopped: feed.report.stopped ?? "",
1017
+ event_received: feed.report.received,
1018
+ event_forwarded: feed.report.forwarded,
1019
+ event_unknown_types: Object.entries(feed.report.unknown).map(([k, v]) => `${k}=${v}`).join(" "),
1020
+ browser_gate_seen: browserGate.report.seen,
1021
+ browser_gate_refused: browserGate.report.refused,
1022
+ browser_gate_leaked: browserGate.report.leaked,
1023
+ browser_gate_held: browserGate.report.held,
1024
+ browser_gate_serp_nav: browserGate.report.serpNav,
1025
+ browser_gate_serp_refused: browserGate.report.serpRefused,
1026
+ native_seen: offload.report.seen,
1027
+ native_ours: offload.report.ours,
1028
+ native_unmatched: offload.report.unmatched,
1029
+ native_offloaded: offload.report.offloaded,
1030
+ native_envelopes: offload.report.envelopes,
1031
+ native_tokens_saved: offload.report.tokensSaved,
1032
+ native_capped: offload.report.capped,
1033
+ native_report_capped: offload.report.reportCapped,
1034
+ // #35: the Code Mode adoption hint — how many native file/command calls we
1035
+ // saw, and how many results actually carried the appended line.
1036
+ probe_chain_seen: probeChain.report.seen,
1037
+ probe_chain_advised: probeChain.report.advised,
1038
+ probe_chain_reset: probeChain.report.reset,
1039
+ probe_chain_foreign_skipped: probeChain.report.foreignSkipped,
1040
+ probe_chain_threw: probeChain.report.threw,
1041
+ });
574
1042
  const removedPlanSizes = [...plan.entries()].map(([id, set]) => `${id}=${set.size}`).join(" ");
575
1043
  try {
576
1044
  tmRuntime.pipelines.store.appendTrajectory({
@@ -583,7 +1051,14 @@ export const v2Personality = {
583
1051
  tools_v1_only: [...V2_UNREGISTERED].join(","),
584
1052
  tools_total: bindings.length,
585
1053
  tools_missing: missing.join(","),
586
- agents_missing: missingAgents.length ? missingAgents.join(",") : defaultPromoted === null ? "待观察" : "",
1054
+ // A2: the boot row no longer carries `agents_missing`. At this instant the
1055
+ // host may not have called the transform at all, so the old placeholder
1056
+ // ("待观察") was pasted by tm_stats as 配置里缺角色 — an assertion about files
1057
+ // nobody had measured. The three fields below are what the three-state
1058
+ // renderer reads; the observation itself lands in the `v2-agents` row.
1059
+ agents_editor_rounds: agentsEditorRounds,
1060
+ agents_editor_unseen: agentsEditorRounds ? missingAgents.join(",") : "",
1061
+ agents_resolved_in_request: resolvedAgentIds().join(","),
587
1062
  agents_default: defaultPromoted === null ? "待观察(宿主异步调用 transform,见 v2-agents 行)" : defaultPromoted ? "team" : "not-promoted",
588
1063
  request_hooks: session.registrations.length,
589
1064
  guard_hooks: guards.registrations.length,
@@ -605,6 +1080,11 @@ export const v2Personality = {
605
1080
  request_temperature: temperature === false ? "off" : temperature,
606
1081
  request_removed_plan: removedPlanSizes,
607
1082
  board_ttl_days: ttlDays,
1083
+ // #38: the runtime fallback's verdict — "ok" / "missing-role" / "no-default"
1084
+ // / "no-config" / "unreadable". `default_agent_missing` names the role only
1085
+ // when one is actually missing, so a clean boot prints an empty string.
1086
+ default_agent_check: defaultAgentCheck.state,
1087
+ default_agent_missing: defaultAgentCheck.state === "missing-role" ? defaultAgentCheck.defaultAgent ?? "" : "",
608
1088
  env_protect: envProtectMode,
609
1089
  note: notes.join(" · "),
610
1090
  });
@@ -612,12 +1092,25 @@ export const v2Personality = {
612
1092
  catch {
613
1093
  /* the trajectory is an extra here, never a reason to fail the boot */
614
1094
  }
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) {
1095
+ // A user reading their own logs would otherwise conclude the plugin was
1096
+ // silently broken: these facts are the visible difference between a working
1097
+ // install and a silent one.
1098
+ // A2: three states, ONE definition. The sentence that used to live here —
1099
+ // `配置里缺角色:…` — asserted a fact about files from a snapshot that is blind by
1100
+ // construction (`ctx.agent.transform` receives the agent set from before the
1101
+ // config directory merges), and a real gap is never laundered into "看不见" either:
1102
+ // `agentVisibilityLine` only says that when the host never resolved the id.
1103
+ const agentGap = agentVisibilityLine({
1104
+ agents_editor_rounds: agentsEditorRounds,
1105
+ agents_editor_unseen: missingAgents.join(","),
1106
+ agents_resolved_in_request: resolvedAgentIds().join(","),
1107
+ }) ||
1108
+ (agentsEditorRounds
1109
+ ? ""
1110
+ : "角色集还没观察:宿主尚未调用 ctx.agent.transform(它是异步的)——此刻既不能说六个角色都在,也不能说缺哪个,判据是 v2-agents 行的 agents_editor_rounds 与 agents_resolved_in_request");
1111
+ if (agentGap || missing.length || notes.length) {
619
1112
  const detail = [
620
- missingAgents.length ? `配置里缺角色:${missingAgents.join(",")}(v2 插件不能新建角色,交给安装器)` : "",
1113
+ agentGap,
621
1114
  missing.length ? `工具未出现在宿主表面:${missing.join(",")}` : "",
622
1115
  notes.length ? notes.join(" · ") : "",
623
1116
  ].filter(Boolean).join(";");
@@ -640,11 +1133,76 @@ export const v2Personality = {
640
1133
  guard_shell_matched: guards.report.shellMatched,
641
1134
  guard_strictened: guards.report.strictened,
642
1135
  guard_denied: guards.report.denied,
1136
+ guard_envfile_denied: guards.report.envFileDenied,
1137
+ guard_envfile_denied_by_input: guards.report.envFileDeniedByInput,
1138
+ guard_envfile_input_seen: guards.report.envFileInputSeen,
1139
+ guard_envfile_input_threw: guards.report.envFileInputThrew,
1140
+ guard_envfile_input_foreign_skipped: guards.report.envFileInputForeignSkipped,
1141
+ concurrency_seen: guards.report.concurrencySeen,
1142
+ concurrency_denied: guards.report.concurrencyDenied,
1143
+ concurrency_running_max: guards.report.concurrencyRunningMax,
1144
+ concurrency_threw: guards.report.concurrencyThrew,
643
1145
  subagent_seen: bgForce.report.seen,
644
1146
  subagent_forced: bgForce.report.forced,
645
- tools_removed: Object.entries(session.report.removed).map(([k, v]) => `${k}=${v}`).join(" "),
1147
+ shell_timeout_seen: shellTimeout.report.seen,
1148
+ shell_timeout_clamped: shellTimeout.report.clamped,
1149
+ shell_timeout_foreign_skipped: shellTimeout.report.foreignSkipped,
1150
+ shell_timeout_threw: shellTimeout.report.threw,
646
1151
  note_pushed: session.report.notePushed,
647
1152
  compaction_lines: session.report.compactionLines,
1153
+ // #49 feature 4: 拆分任务 — the same counter set as the surface row.
1154
+ split_seen: session.report.splitSeen,
1155
+ split_advised: session.report.splitAdvised,
1156
+ // Early compaction, counted per outcome so the claim is checkable: `fired` is
1157
+ // what we admitted, `confirmed` what the host accepted, and a `no_limit` with no
1158
+ // `fired` means we never found a denominator — not that the window was empty.
1159
+ compact_enabled: compaction.report.enabled,
1160
+ compact_at_percent: compaction.report.percent,
1161
+ compact_checked: compaction.report.checked,
1162
+ compact_usage_seen: compaction.report.usageSeen,
1163
+ compact_usage_events: compaction.report.usageEvents,
1164
+ compact_event_decided: compaction.report.eventDecided,
1165
+ compact_fired: compaction.report.fired,
1166
+ compact_confirmed: compaction.report.confirmed,
1167
+ compact_below: compaction.report.below,
1168
+ compact_deduped: compaction.report.deduped,
1169
+ compact_no_usage: compaction.report.noUsage,
1170
+ compact_no_limit: compaction.report.noLimit,
1171
+ compact_conflicts: compaction.report.conflicts,
1172
+ compact_threw: compaction.report.threw,
1173
+ compact_foreign_skipped: compaction.report.foreignSkipped,
1174
+ compact_source: compaction.report.lastSource,
1175
+ compact_last_percent: compaction.report.lastPercent,
1176
+ compact_error: compaction.report.lastError,
1177
+ // #49 Context Pruning — per-outcome, so "we pruned nothing" is
1178
+ // distinguishable from "we never found a denominator" (`no_limit`).
1179
+ prune_enabled: prune.report.enabled,
1180
+ prune_at_percent: prune.report.atPercent,
1181
+ prune_keep_tail_percent: prune.report.keepTailPercent,
1182
+ prune_checked: prune.report.checked,
1183
+ prune_pruned_messages: prune.report.prunedMessages,
1184
+ prune_pruned_tokens: prune.report.prunedTokens,
1185
+ prune_unknown_parts: prune.report.unknownParts,
1186
+ prune_skipped_protected: prune.report.skippedProtected,
1187
+ prune_below: prune.report.below,
1188
+ prune_no_limit: prune.report.noLimit,
1189
+ prune_no_messages: prune.report.noMessages,
1190
+ prune_foreign_skipped: prune.report.foreignSkipped,
1191
+ prune_threw: prune.report.threw,
1192
+ prune_last_percent: prune.report.lastPercent,
1193
+ // #49 feature 3: 错峰重试 — the same counter set as the surface row.
1194
+ retry_enabled: retry.report.enabled,
1195
+ retry_quota_seen: retry.report.quotaSeen,
1196
+ retry_rate_seen: retry.report.rateSeen,
1197
+ retry_transient_seen: retry.report.transientSeen,
1198
+ retry_unknown_seen: retry.report.unknownSeen,
1199
+ retry_break_trips: retry.report.breakTrips,
1200
+ retry_cooldowns: retry.report.cooldowns,
1201
+ retry_last_delay_ms: retry.report.lastDelayMs,
1202
+ retry_directives_injected: retry.report.directivesInjected,
1203
+ retry_cooldown_denied: retry.report.cooldownDenied,
1204
+ retry_foreign_skipped: retry.report.foreignSkipped,
1205
+ retry_threw: retry.report.threw,
648
1206
  // Accumulates for the whole process, so it can only be written at
649
1207
  // teardown: this is the number that answers "did the feed stay alive, and
650
1208
  // did the host rename the event types under us after the last upgrade?"
@@ -656,6 +1214,8 @@ export const v2Personality = {
656
1214
  browser_gate_refused: browserGate.report.refused,
657
1215
  browser_gate_leaked: browserGate.report.leaked,
658
1216
  browser_gate_held: browserGate.report.held,
1217
+ browser_gate_serp_nav: browserGate.report.serpNav,
1218
+ browser_gate_serp_refused: browserGate.report.serpRefused,
659
1219
  browser_gate_note: browserGateSummary(browserGate.report),
660
1220
  // What the model could actually call, read off the assembled requests —
661
1221
  // the half of the delivery story the sent flag cannot tell.
@@ -679,11 +1239,25 @@ export const v2Personality = {
679
1239
  session_context_ok: sessionReader.report.contextOk,
680
1240
  session_context_keys: sessionReader.report.contextKeys.join(","),
681
1241
  session_get_error: sessionReader.report.lastError ?? sessionReader.report.contextError ?? "",
1242
+ // #33 — the stop path, counted rather than assumed. `stop_refused` is the host's
1243
+ // documented idle no-op (interrupted=false), which is a REAL answer and not a
1244
+ // failure; `stop_unknown` is the case where the call worked and gave no boolean,
1245
+ // and only a human or the next host build can tell which of the two we are in.
1246
+ stop_tried: sessionReader.report.interruptTried,
1247
+ stop_confirmed: sessionReader.report.interruptConfirmed,
1248
+ stop_refused: sessionReader.report.interruptRefused,
1249
+ stop_unknown: sessionReader.report.interruptUnknown,
1250
+ stop_keys: sessionReader.report.interruptKeys.join(","),
1251
+ stop_error: sessionReader.report.interruptError ?? "",
682
1252
  completion_context_fired: completion.report.contextFired,
683
1253
  completion_prompt_fired: completion.report.promptFired,
684
1254
  completion_skipped: completion.report.skipped,
685
1255
  completion_envelopes: completion.report.seen,
686
1256
  completion_settled: completion.report.settled,
1257
+ // A3: same set as the throttled snapshot, labelled as the final total.
1258
+ // The explicit fields above stay (they are this row's own record); the
1259
+ // spread is what stamps `counters_at:"shutdown"` and adds the rest.
1260
+ ...observationCounters("shutdown"),
687
1261
  ...probeSummary(probe.report),
688
1262
  });
689
1263
  }
@@ -698,7 +1272,7 @@ export const v2Personality = {
698
1272
  /* a host that already tore down its own registry has nothing left to free */
699
1273
  }
700
1274
  }
701
- // tm_browser owns a child process — teardown waits on v1 and must wait here too.
1275
+ // A tool may own a child process — teardown must await it.
702
1276
  await tmRuntime.dispose();
703
1277
  };
704
1278
  },