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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/README.md +114 -118
  2. package/README.zh-CN.md +95 -100
  3. package/dist/agents.d.ts.map +1 -1
  4. package/dist/agents.js +89 -79
  5. package/dist/agents.js.map +1 -1
  6. package/dist/approval-gate.d.ts +8 -9
  7. package/dist/approval-gate.d.ts.map +1 -1
  8. package/dist/approval-gate.js +13 -14
  9. package/dist/approval-gate.js.map +1 -1
  10. package/dist/capabilities.d.ts +11 -37
  11. package/dist/capabilities.d.ts.map +1 -1
  12. package/dist/capabilities.js +11 -141
  13. package/dist/capabilities.js.map +1 -1
  14. package/dist/commands.js +1 -1
  15. package/dist/envprotect/hook.d.ts +12 -31
  16. package/dist/envprotect/hook.d.ts.map +1 -1
  17. package/dist/envprotect/hook.js +13 -91
  18. package/dist/envprotect/hook.js.map +1 -1
  19. package/dist/envprotect/patterns.d.ts +6 -4
  20. package/dist/envprotect/patterns.d.ts.map +1 -1
  21. package/dist/envprotect/patterns.js +6 -4
  22. package/dist/envprotect/patterns.js.map +1 -1
  23. package/dist/envprotect.d.ts +1 -1
  24. package/dist/envprotect.d.ts.map +1 -1
  25. package/dist/envprotect.js +1 -1
  26. package/dist/envprotect.js.map +1 -1
  27. package/dist/host/v2-browser-gate.d.ts +19 -1
  28. package/dist/host/v2-browser-gate.d.ts.map +1 -1
  29. package/dist/host/v2-browser-gate.js +47 -6
  30. package/dist/host/v2-browser-gate.js.map +1 -1
  31. package/dist/host/v2-capabilities.d.ts +16 -0
  32. package/dist/host/v2-capabilities.d.ts.map +1 -1
  33. package/dist/host/v2-capabilities.js +22 -1
  34. package/dist/host/v2-capabilities.js.map +1 -1
  35. package/dist/host/v2-default-agent.d.ts +37 -0
  36. package/dist/host/v2-default-agent.d.ts.map +1 -0
  37. package/dist/host/v2-default-agent.js +128 -0
  38. package/dist/host/v2-default-agent.js.map +1 -0
  39. package/dist/host/v2-events.d.ts +1 -0
  40. package/dist/host/v2-events.d.ts.map +1 -1
  41. package/dist/host/v2-events.js +14 -0
  42. package/dist/host/v2-events.js.map +1 -1
  43. package/dist/host/v2-guard.d.ts +178 -0
  44. package/dist/host/v2-guard.d.ts.map +1 -1
  45. package/dist/host/v2-guard.js +324 -8
  46. package/dist/host/v2-guard.js.map +1 -1
  47. package/dist/host/v2-offload.d.ts +1 -1
  48. package/dist/host/v2-offload.js +2 -2
  49. package/dist/host/v2-permissions.d.ts +45 -44
  50. package/dist/host/v2-permissions.d.ts.map +1 -1
  51. package/dist/host/v2-permissions.js +48 -49
  52. package/dist/host/v2-permissions.js.map +1 -1
  53. package/dist/host/v2-probe-chain.d.ts +93 -0
  54. package/dist/host/v2-probe-chain.d.ts.map +1 -0
  55. package/dist/host/v2-probe-chain.js +146 -0
  56. package/dist/host/v2-probe-chain.js.map +1 -0
  57. package/dist/host/v2-prune.d.ts +157 -0
  58. package/dist/host/v2-prune.d.ts.map +1 -0
  59. package/dist/host/v2-prune.js +507 -0
  60. package/dist/host/v2-prune.js.map +1 -0
  61. package/dist/host/v2-retry.d.ts +143 -0
  62. package/dist/host/v2-retry.d.ts.map +1 -0
  63. package/dist/host/v2-retry.js +315 -0
  64. package/dist/host/v2-retry.js.map +1 -0
  65. package/dist/host/v2-scope.d.ts.map +1 -1
  66. package/dist/host/v2-scope.js +14 -7
  67. package/dist/host/v2-scope.js.map +1 -1
  68. package/dist/host/v2-session-client.d.ts +24 -0
  69. package/dist/host/v2-session-client.d.ts.map +1 -1
  70. package/dist/host/v2-session-client.js +243 -3
  71. package/dist/host/v2-session-client.js.map +1 -1
  72. package/dist/host/v2-session.d.ts +72 -1
  73. package/dist/host/v2-session.d.ts.map +1 -1
  74. package/dist/host/v2-session.js +154 -10
  75. package/dist/host/v2-session.js.map +1 -1
  76. package/dist/host/v2-tool.d.ts +2 -3
  77. package/dist/host/v2-tool.d.ts.map +1 -1
  78. package/dist/host/v2-tool.js +2 -3
  79. package/dist/host/v2-tool.js.map +1 -1
  80. package/dist/host/v2-types.d.ts +30 -0
  81. package/dist/host/v2-types.d.ts.map +1 -1
  82. package/dist/host/v2-usage.d.ts +50 -0
  83. package/dist/host/v2-usage.d.ts.map +1 -0
  84. package/dist/host/v2-usage.js +82 -0
  85. package/dist/host/v2-usage.js.map +1 -0
  86. package/dist/host/v2.d.ts +14 -12
  87. package/dist/host/v2.d.ts.map +1 -1
  88. package/dist/host/v2.js +430 -73
  89. package/dist/host/v2.js.map +1 -1
  90. package/dist/identity.d.ts +27 -0
  91. package/dist/identity.d.ts.map +1 -0
  92. package/dist/identity.js +34 -0
  93. package/dist/identity.js.map +1 -0
  94. package/dist/index.d.ts +9 -24
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +17 -27
  97. package/dist/index.js.map +1 -1
  98. package/dist/prompts/lead.d.ts +1 -1
  99. package/dist/prompts/lead.d.ts.map +1 -1
  100. package/dist/prompts/lead.js +38 -25
  101. package/dist/prompts/lead.js.map +1 -1
  102. package/dist/prompts/shared.d.ts +2 -2
  103. package/dist/prompts/shared.d.ts.map +1 -1
  104. package/dist/prompts/shared.js +66 -56
  105. package/dist/prompts/shared.js.map +1 -1
  106. package/dist/prompts/specialists.d.ts +2 -2
  107. package/dist/prompts/specialists.d.ts.map +1 -1
  108. package/dist/prompts/specialists.js +53 -83
  109. package/dist/prompts/specialists.js.map +1 -1
  110. package/dist/tm/args-schema.d.ts +7 -32
  111. package/dist/tm/args-schema.d.ts.map +1 -1
  112. package/dist/tm/args-schema.js +12 -153
  113. package/dist/tm/args-schema.js.map +1 -1
  114. package/dist/tm/board.d.ts +10 -9
  115. package/dist/tm/board.d.ts.map +1 -1
  116. package/dist/tm/board.js +11 -10
  117. package/dist/tm/board.js.map +1 -1
  118. package/dist/tm/config-files.d.ts +81 -0
  119. package/dist/tm/config-files.d.ts.map +1 -0
  120. package/dist/tm/config-files.js +209 -0
  121. package/dist/tm/config-files.js.map +1 -0
  122. package/dist/tm/config-layers.d.ts +117 -0
  123. package/dist/tm/config-layers.d.ts.map +1 -0
  124. package/dist/tm/config-layers.js +317 -0
  125. package/dist/tm/config-layers.js.map +1 -0
  126. package/dist/tm/config.d.ts +10 -101
  127. package/dist/tm/config.d.ts.map +1 -1
  128. package/dist/tm/config.js +17 -71
  129. package/dist/tm/config.js.map +1 -1
  130. package/dist/tm/dispatch.d.ts +115 -21
  131. package/dist/tm/dispatch.d.ts.map +1 -1
  132. package/dist/tm/dispatch.js +424 -67
  133. package/dist/tm/dispatch.js.map +1 -1
  134. package/dist/tm/egress.d.ts +19 -7
  135. package/dist/tm/egress.d.ts.map +1 -1
  136. package/dist/tm/egress.js +21 -9
  137. package/dist/tm/egress.js.map +1 -1
  138. package/dist/tm/index.d.ts +23 -20
  139. package/dist/tm/index.d.ts.map +1 -1
  140. package/dist/tm/index.js +117 -116
  141. package/dist/tm/index.js.map +1 -1
  142. package/dist/tm/ledger.d.ts +9 -2
  143. package/dist/tm/ledger.d.ts.map +1 -1
  144. package/dist/tm/ledger.js +4 -1
  145. package/dist/tm/ledger.js.map +1 -1
  146. package/dist/tm/perm-ask.d.ts +3 -3
  147. package/dist/tm/perm-ask.d.ts.map +1 -1
  148. package/dist/tm/perm-ask.js +3 -3
  149. package/dist/tm/perm-ask.js.map +1 -1
  150. package/dist/tm/pipelines.d.ts +12 -11
  151. package/dist/tm/pipelines.d.ts.map +1 -1
  152. package/dist/tm/pipelines.js +40 -207
  153. package/dist/tm/pipelines.js.map +1 -1
  154. package/dist/tm/preview.d.ts +1 -1
  155. package/dist/tm/preview.js +3 -3
  156. package/dist/tm/preview.js.map +1 -1
  157. package/dist/tm/refs.d.ts +31 -4
  158. package/dist/tm/refs.d.ts.map +1 -1
  159. package/dist/tm/refs.js +48 -4
  160. package/dist/tm/refs.js.map +1 -1
  161. package/dist/tm/result.d.ts +1 -1
  162. package/dist/tm/result.d.ts.map +1 -1
  163. package/dist/tm/result.js +4 -3
  164. package/dist/tm/result.js.map +1 -1
  165. package/dist/tm/search.d.ts +2 -2
  166. package/dist/tm/search.js +7 -7
  167. package/dist/tm/search.js.map +1 -1
  168. package/dist/tm/serp-loop.d.ts +19 -7
  169. package/dist/tm/serp-loop.d.ts.map +1 -1
  170. package/dist/tm/serp-loop.js +24 -9
  171. package/dist/tm/serp-loop.js.map +1 -1
  172. package/dist/tm/stats.d.ts +12 -27
  173. package/dist/tm/stats.d.ts.map +1 -1
  174. package/dist/tm/stats.js +43 -45
  175. package/dist/tm/stats.js.map +1 -1
  176. package/dist/tm/store.d.ts +18 -5
  177. package/dist/tm/store.d.ts.map +1 -1
  178. package/dist/tm/store.js +39 -9
  179. package/dist/tm/store.js.map +1 -1
  180. package/dist/tm/tools.d.ts +7 -5
  181. package/dist/tm/tools.d.ts.map +1 -1
  182. package/dist/tm/tools.js +10 -41
  183. package/dist/tm/tools.js.map +1 -1
  184. package/dist/tm/webfetch.d.ts +1 -1
  185. package/dist/tm/webfetch.d.ts.map +1 -1
  186. package/dist/tm/webfetch.js +38 -23
  187. package/dist/tm/webfetch.js.map +1 -1
  188. package/dist/types.d.ts +40 -123
  189. package/dist/types.d.ts.map +1 -1
  190. package/dist/types.js +7 -21
  191. package/dist/types.js.map +1 -1
  192. package/docs/installation-v2.md +133 -33
  193. package/docs/installation.md +9 -0
  194. package/docs/research/browser-pane.md +5 -0
  195. package/docs/v2-port-notes.md +133 -0
  196. package/package.json +1 -1
  197. package/scripts/gen-v2-config.mjs +246 -94
  198. package/scripts/lib/fake-ctx.mjs +67 -0
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,6 +28,7 @@
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";
@@ -38,16 +36,20 @@ import { agentVisibilityLine } from "../tm/stats.js";
38
36
  import { setAskUnavailableNote } from "../tm/perm-ask.js";
39
37
  import { setPrivateSpacePolicy } from "../tm/webfetch.js";
40
38
  import { blackboardNote } from "./note.js";
41
- import { applyV2BackgroundForce, applyV2PermissionGuards, needsCoarseShellAsk } from "./v2-guard.js";
39
+ import { applyV2BackgroundForce, applyV2EnvFileInputGuard, applyV2PermissionGuards, applyV2ShellTimeoutClamp, needsCoarseShellAsk } from "./v2-guard.js";
42
40
  import { applyV2EventFeed } from "./v2-events.js";
43
41
  import { createV2SessionReader } from "./v2-session-client.js";
44
42
  import { applyV2Probe, probeSummary } from "./v2-probe.js";
45
43
  import { applyV2NativeOffload } from "./v2-offload.js";
44
+ import { applyV2ProbeChain } from "./v2-probe-chain.js";
46
45
  import { applyV2SubagentRegistry, applyV2CompletionWatch } from "./v2-subagent.js";
47
46
  import { applyV2BrowserGate, browserGateSummary } from "./v2-browser-gate.js";
48
47
  import { seedWebfetchDomains } from "../tm/webfetch.js";
49
48
  import { v2CapabilityRows } from "./v2-capabilities.js";
50
- 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 { applyV2ContextPrune, resolvePruneConfig } from "./v2-prune.js";
52
+ import { checkDefaultAgentRole, globalConfigDir } from "./v2-default-agent.js";
51
53
  import { createStorageLedgerStore } from "../tm/ledger.js";
52
54
  import { createTeamScope } from "./v2-scope.js";
53
55
  import { bindV2Tool } from "./v2-tool.js";
@@ -69,8 +71,24 @@ function directoryOf(ctx) {
69
71
  return loc.project.directory;
70
72
  return process.cwd();
71
73
  }
74
+ /** #49 feature 2: the concurrency cap. Default 3 (matches "at most 3 parallel
75
+ * reviewers" and leaves a normal user headroom); `0` disables it. A missing or
76
+ * unparseable value falls back to the default rather than to 0, because a typo
77
+ * must not silently turn a safety cap off. */
78
+ export function resolveMaxConcurrentSubagents(env = process.env) {
79
+ const raw = String(env.TM_MAX_CONCURRENT_SUBAGENTS ?? "").trim();
80
+ if (!raw)
81
+ return 3;
82
+ const n = Number.parseInt(raw, 10);
83
+ if (!Number.isFinite(n) || n < 0)
84
+ return 3;
85
+ return n;
86
+ }
72
87
  export const v2Personality = {
73
- id: "team-mode",
88
+ // #45: the display id (host plugin list) — the npm package name. NOT the
89
+ // storage/audit names (`team-mode/selfcheck`, `team-mode/ledger/`,
90
+ // `team-mode-env-protect`), which stay put so existing data is not orphaned.
91
+ id: "@te-river/opencode-team-mode",
74
92
  async setup(ctx) {
75
93
  if (!ctx || typeof ctx.tool?.transform !== "function") {
76
94
  // Nothing can be registered without the tool domain. Say it in the
@@ -80,6 +98,23 @@ export const v2Personality = {
80
98
  return;
81
99
  }
82
100
  const directory = directoryOf(ctx);
101
+ // ---------- #38 runtime fallback: is the configured default role on disk? ----------
102
+ // The plugin cannot see config-directory roles through ctx.agent (probed: the
103
+ // editor snapshot holds only the 7 built-ins), so a `default_agent` naming a
104
+ // missing role would make the host fall back to `build` SILENTLY. Reading the
105
+ // global config from disk is the only way to say so. Never throws, never
106
+ // affects startup, and prints only the role name + the file path it looked for.
107
+ let defaultAgentCheck = { state: "no-config" };
108
+ try {
109
+ defaultAgentCheck = checkDefaultAgentRole({ configDir: globalConfigDir() });
110
+ }
111
+ catch {
112
+ defaultAgentCheck = { state: "unreadable" };
113
+ }
114
+ if (defaultAgentCheck.state === "missing-role") {
115
+ console.warn(`[team-mode] 全局配置的 default_agent="${defaultAgentCheck.defaultAgent}" 指向的角色文件不存在(${defaultAgentCheck.roleFile}):` +
116
+ "宿主会静默退回 build,会话不再是 Team。重跑 scripts/install.ps1(Windows)或 scripts/install.sh 修复。");
117
+ }
83
118
  // ---------- Team-scope isolation (#22, the user's requirement 2026-09-25) ----------
84
119
  // Every v2 hook fires for EVERY agent on this host. This one object is what
85
120
  // each layer asks before it mutates a request, a result, or a permission —
@@ -87,14 +122,24 @@ export const v2Personality = {
87
122
  const scope = createTeamScope(Object.keys(agents));
88
123
  setAskUnavailableNote(V2_NO_DIALOG_NOTE);
89
124
  const options = (ctx.options ?? {});
90
- const envProtectMode = options.envProtect ? resolveEnvProtectMode(process.env.TM_ENV_PROTECT) : "off";
125
+ // R6 is ARMED BY DEFAULT, matching v1 (which was always armed). The old form
126
+ // (`options.envProtect ? … : "off"`) made the env-file red line opt-IN, and
127
+ // `docs/installation-v2.md` never mentioned the option — so a default install
128
+ // read `.env` in plaintext while AGENTS.md called that rule "hard". Two
129
+ // explicit switches turn it off, and only those two: the plugin option
130
+ // `envProtect:false`, or `TM_ENV_PROTECT=off` (resolved below, which also
131
+ // fails CLOSED into "strict" for a typo). On v2 the shell face is `ask`
132
+ // (the host's own dialog) and the env-FILE face is `deny`; the resolved mode
133
+ // rides the boot row's `env_protect` field so the user can see which world
134
+ // they are in.
135
+ const envProtectMode = options.envProtect === false ? "off" : resolveEnvProtectMode(process.env.TM_ENV_PROTECT);
91
136
  const envProtectExtra = parseExtraDeny(process.env.TM_ENV_PROTECT_EXTRA_DENY);
92
137
  // ---------- 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
138
+ // The user's standing instruction (2026-09-25): do not block network
139
+ // access at all EXCEPT sensitive and internal addresses. A plugin cannot
140
+ // raise the host's official per-request dialog, so an allowlist would be a
141
+ // set of pages the agent can never see and no one can approve -- a gate with
142
+ // no door. `"*"` therefore
98
143
  // replaces the DEFAULT here, and what still holds absolutely is `checkWebUrl`'s
99
144
  // address policy underneath it: link-local / metadata / reserved ranges are a hard
100
145
  // deny no config can open (and were never consentable), and private space
@@ -102,9 +147,9 @@ export const v2Personality = {
102
147
  // tools, which have no dialog to ask with, and opened only by the host's own
103
148
  // `effect:"ask"` for the native tools. IPv4-mapped and DNS64 spellings are
104
149
  // 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.
150
+ // An explicit TM_WEBFETCH_ALLOWED_DOMAINS always wins; this only changes
151
+ // which default applies, and it is resolved from a COPY of the env so the
152
+ // process env itself is untouched.
108
153
  const v2Env = { ...process.env };
109
154
  if (!String(v2Env.TM_WEBFETCH_ALLOWED_DOMAINS ?? "").trim())
110
155
  v2Env.TM_WEBFETCH_ALLOWED_DOMAINS = "*";
@@ -129,9 +174,9 @@ export const v2Personality = {
129
174
  // other half of that sentence is that a direct tool definition rides EVERY
130
175
  // request (our own estimate for the full set is 9 528 tokens).
131
176
  // 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.
177
+ // description first, then go direct", and the descriptions are now slim enough
178
+ // that the measured per-role cost is 2 660 build-class / 4 726 tester /
179
+ // 6 391 researcher / 7 793 lead.
135
180
  // `TM_V2_CODEMODE=off` restores catalog-only for anyone who wants the tokens back
136
181
  // — and the boot note says which world this is, because in catalog mode most of
137
182
  // our governance text never reaches the model at all.
@@ -145,10 +190,10 @@ export const v2Personality = {
145
190
  // and the shutdown line reports both halves: `tools_codemode` (sent) and
146
191
  // `tools_in_request` (what the assembled request actually carried).
147
192
  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).
193
+ // No SDK client. The v2 plugin ctx carries no `file.read` / `find.text` /
194
+ // `session.*` / `pty.*` object, and the fs shim that used to stand in for the
195
+ // first two went away with `tm_read`/`tm_grep` (the native tools, governed at
196
+ // `execute.after`, are the file ladder now).
152
197
  // What IS bridged is the one thing the collect path cannot be honest without:
153
198
  // `tm_join` must be able to check whether a named session id is really the
154
199
  // caller's child, and a live 2.0.16 run proved that without it the tool reports
@@ -180,13 +225,12 @@ export const v2Personality = {
180
225
  const notes = [];
181
226
  // ---------- register the governed tools ----------
182
227
  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
228
+ // The retired tools are not registered. The host's own `execute` (Code
229
+ // Mode) already runs "one program, N governed calls, zero round-trips, only
230
+ // the aggregate entering the context", and a live session showed our
186
231
  // governed results still come back offloaded through it — so shipping a
187
232
  // 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.
233
+ // prompts the config files carry are forked by gen-v2-config.mjs.
190
234
  const V2_UNREGISTERED = V1_ONLY_TOOLS;
191
235
  const bindings = [];
192
236
  const derived = [];
@@ -212,10 +256,10 @@ export const v2Personality = {
212
256
  // shape, so name the tools and say how they were obtained.
213
257
  const retired = entries.map(([name]) => name).filter((n) => V2_UNREGISTERED.has(n));
214
258
  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 保留这些别名`);
259
+ // Deliberate absences are said, not silently missing: an agent that looks
260
+ // for `tm_read` needs to learn from the boot log that this is the design,
261
+ // not a failed install.
262
+ notes.push(`不注册 ${retired.join("/")} —— 同一件工作交给宿主自己的 read/grep/shell/execute(结果照样被 execute.after 治理)`);
219
263
  }
220
264
  if (derived.length) {
221
265
  notes.push(`${derived.join("/")} 没有 zod 形状,参数表是从描述符文本推出来的(类型靠 name: type 前缀判定,不是 zod 保证)`);
@@ -250,14 +294,72 @@ export const v2Personality = {
250
294
  // ---------- the permission guard (egress red line + R6 per command) ----------
251
295
  // Installed BEFORE the agent transform because whether the config still needs
252
296
  // the coarse `shell -> ask` escalation depends on this hook being there.
253
- const guards = await applyV2PermissionGuards(ctx, { envProtectMode, scope });
297
+ const guards = await applyV2PermissionGuards(ctx, {
298
+ envProtectMode,
299
+ scope,
300
+ // #49 feature 2: the concurrency cap reads the plugin's ONE children
301
+ // registry (the same rows tm_join collects), so a session cannot exceed the
302
+ // cap by dispatching faster than the lead collects. `0` disables it.
303
+ runningChildren: (caller) => tmRuntime.runningChildren(caller),
304
+ maxConcurrent: resolveMaxConcurrentSubagents(v2Env),
305
+ onConcurrencyDenied: (info) => {
306
+ try {
307
+ tmRuntime.pipelines.store.appendTrajectory({
308
+ tool: "host",
309
+ step_id: "v2-concurrency",
310
+ event: "denied",
311
+ caller: info.caller,
312
+ cap: info.cap,
313
+ running: info.running.length,
314
+ ids: info.running.map((c) => c.sessionID).join(","),
315
+ });
316
+ }
317
+ catch {
318
+ /* the trajectory is an extra, never a reason to fail the call */
319
+ }
320
+ },
321
+ });
254
322
  // Said out loud because it is a promise with a boundary: the user's rule is
255
323
  // that nothing outside Team may be touched, and the honest consequence is that
256
324
  // the red lines we inject are therefore NOT protecting a build session either.
257
325
  notes.push("已按 Team 作用域收口:工具面裁剪、温度、黑板提示、原生结果卸载、R6 与地址红线、subagent 强制后台,都只对我们六个角色的会话生效;build / plan 等其余模式保持宿主出厂行为(代价:那些会话也不由我们补红线,需要全局红线请让宿主自己的 permission 规则承担)");
258
326
  registrations.push(...guards.registrations);
327
+ // #58: the SAME R6 file-path face, on the seam that sees the whole input.
328
+ // `permission.evaluate`'s resource for `grep`/`glob` is the PATTERN, not the
329
+ // search path, so the red line had a hole the width of those two tools —
330
+ // `read .env` was denied while `grep SECRET in .env` succeeded (2.0.24).
331
+ const envFileInput = await applyV2EnvFileInputGuard(ctx, { envProtectMode, scope, report: guards.report });
332
+ registrations.push(...envFileInput.registrations);
259
333
  const bgForce = await applyV2BackgroundForce(ctx, { scope });
260
334
  registrations.push(...bgForce.registrations);
335
+ // The shell timeout clamp (issue #6), ported here when the v1 personality was
336
+ // cut: the host gives `execute.before` a mutable `input`, the same seam
337
+ // `applyV2BackgroundForce` writes, so the protection survives the deletion —
338
+ // but only if it is counted, because a clamp nobody can see is a clamp a user
339
+ // reads as "the tool killed my probe for no reason".
340
+ const shellTimeout = await applyV2ShellTimeoutClamp(ctx, {
341
+ probeMs: tmRuntime.config.bashTimeoutProbeMs,
342
+ maxMs: tmRuntime.config.bashTimeoutMaxMs,
343
+ readonlyAllowed: tmRuntime.config.bashReadonlyAllowed,
344
+ scope,
345
+ onClamp: (info) => {
346
+ try {
347
+ tmRuntime.pipelines.store.appendTrajectory({
348
+ tool: "shell",
349
+ step_id: "timeout-clamp",
350
+ event: "clamped",
351
+ via: info.via,
352
+ from_ms: info.from,
353
+ to_ms: info.to,
354
+ sessionID: info.sessionID ?? "",
355
+ });
356
+ }
357
+ catch {
358
+ /* the trajectory is an extra, never a reason to fail the call */
359
+ }
360
+ },
361
+ });
362
+ registrations.push(...shellTimeout.registrations);
261
363
  // JIT governance over the HOST's tools, so the offload promise does not depend
262
364
  // on which tool the model happened to pick (see v2-offload.ts for why a
263
365
  // per-tool promise is no promise). Registered BEFORE the probe so the probe's
@@ -265,6 +367,29 @@ export const v2Personality = {
265
367
  // teardown is a counter that does not exist.
266
368
  const offload = await applyV2NativeOffload(ctx, { pipelines: tmRuntime.pipelines, scope });
267
369
  registrations.push(...(await Promise.all(offload.registrations)));
370
+ // #35: the Code Mode adoption hint. Registered AFTER the offloader so its
371
+ // append lands on whatever text the offloader left (a rewritten body or the
372
+ // host's verbatim one) — the hint only ever ADDS a line, never rewrites.
373
+ const probeChain = applyV2ProbeChain(ctx, {
374
+ scope,
375
+ env: v2Env,
376
+ onAdvise: (info) => {
377
+ try {
378
+ tmRuntime.pipelines.store.appendTrajectory({
379
+ tool: "host",
380
+ step_id: "probe-chain",
381
+ event: "advised",
382
+ native_tool: info.tool,
383
+ count: info.count,
384
+ sessionID: info.sessionID,
385
+ });
386
+ }
387
+ catch {
388
+ /* the trajectory is an extra, never a reason to fail the call */
389
+ }
390
+ },
391
+ });
392
+ registrations.push(...(await Promise.all(probeChain.registrations)));
268
393
  // The host's own sub-agents, entered into tm_join's registry (decision 4,
269
394
  // 2026-09-26). On v2 every dispatch IS the host's `subagent` tool, so without this
270
395
  // the collect side had nothing to collect: tm_join answered 没有待收集的派发 about
@@ -302,6 +427,26 @@ export const v2Personality = {
302
427
  allowlist: seedWebfetchDomains(tmRuntime.config.webfetchAllowedDomains),
303
428
  scope,
304
429
  env: v2Env,
430
+ // #14: the SERP rate limit's trajectory line. The gate has no store, so the
431
+ // sink is wired here — same shape as the shell-timeout clamp's `onClamp`.
432
+ onSerp: (info) => {
433
+ try {
434
+ tmRuntime.pipelines.store.appendTrajectory({
435
+ tool: "browser",
436
+ step_id: "serp-loop",
437
+ event: info.event,
438
+ engine: info.engine,
439
+ query: info.query,
440
+ host: info.host,
441
+ count: info.count,
442
+ limit: info.limit,
443
+ sessionID: info.sessionID,
444
+ });
445
+ }
446
+ catch {
447
+ /* the trajectory is an extra, never a reason to fail the call */
448
+ }
449
+ },
305
450
  });
306
451
  if (!browserGate.registrations.length) {
307
452
  notes.push("原生 browser_* 的门禁没装上(ctx.tool.hook 不可用):宿主的 45 个浏览器工具在 Team 会话里也不受我们的域名 / 地址规则约束");
@@ -320,6 +465,12 @@ export const v2Personality = {
320
465
  /** The role ids the HOST resolved in a real request — the only observation that
321
466
  * separates "installed but invisible to the snapshot" from "not installed". */
322
467
  let resolvedAgentIds = () => [];
468
+ /** #58: `tools_in_request` used to live ONLY on `v2-shutdown`, which the CLI
469
+ * never writes — so on a CLI run the field was unobservable. Late-bound for
470
+ * the same reason as the counters: the request layer registers BELOW the
471
+ * probe, and a closure that touched a not-yet-initialized `const` would throw
472
+ * inside `onSummary`, where the try/catch would swallow the whole row. */
473
+ let tmInRequestSurface = () => "unobserved";
323
474
  /** A3: the in-process observation counters. Measured today: three runs wrote
324
475
  * ZERO `v2-shutdown` rows, because the CLI path never reaches dispose — so the
325
476
  * final totals have to survive on the throttled surface snapshot too, and a row
@@ -369,6 +520,9 @@ export const v2Personality = {
369
520
  // teardown — dispose is not guaranteed to run, and a run that is
370
521
  // interrupted is exactly the run whose counters someone needs.
371
522
  ...observationCounters("surface"),
523
+ // #58: the delivery fact the CLI could never see — it only rode
524
+ // `v2-shutdown`, which a CLI run never reaches.
525
+ tools_in_request: tmInRequestSurface(),
372
526
  ...summary,
373
527
  });
374
528
  }
@@ -404,7 +558,7 @@ export const v2Personality = {
404
558
  // `TM_R6_FINE_ASK=off`, or no hook installed to hand the decision to.
405
559
  const escalateShellAsk = envProtectMode !== "off" && needsCoarseShellAsk(process.env, guards.installed);
406
560
  // 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
561
+ // header for why there is no conservative form). `default()` on
408
562
  // an agent that does not exist would just make the host fall back to build
409
563
  // silently, so it is gated on the role actually being present, and the
410
564
  // refusal to promote is reported rather than hidden.
@@ -423,16 +577,16 @@ export const v2Personality = {
423
577
  const { triples, unmapped } = triplesFromAgentPermission(agents[id]?.permission, { escalateShellAsk, agentName: id });
424
578
  for (const u of unmapped)
425
579
  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.
580
+ // `V1_ONLY_TOOLS` is passed as the reclaim set: a triple naming a
581
+ // retired action can only have come from an earlier boot, and a
582
+ // permission rule for a tool the host has never heard of reads as a
583
+ // capability the user granted.
430
584
  const merged = mergeTriples(existing.permissions, triples, V1_ONLY_TOOLS);
431
585
  if (merged.changed)
432
586
  editor.update(id, (a) => { a.permissions = merged.triples; });
433
587
  }
434
588
  if (promoteTeamDefault) {
435
- // `editor.get("team")` returning nothing is NOT evidence the host does
589
+ // `editor.get("Team")` returning nothing is NOT evidence the host does
436
590
  // not know the role — the live run is literally executing as `team` while
437
591
  // this snapshot lists only the 7 built-ins, so the transform receives the
438
592
  // agent set from BEFORE the config directory merged. Gating the
@@ -441,13 +595,13 @@ export const v2Personality = {
441
595
  // the AFTER view is what gets recorded.
442
596
  let ok = true;
443
597
  try {
444
- editor.default("team");
598
+ editor.default("Team");
445
599
  }
446
600
  catch {
447
601
  ok = false;
448
602
  }
449
603
  defaultPromoted = ok;
450
- defaultPostCheck = editor.get?.("team") ? "present-after" : "absent-after";
604
+ defaultPostCheck = editor.get?.("Team") ? "present-after" : "absent-after";
451
605
  }
452
606
  // Recorded HERE, at the moment of observation. The host defers this
453
607
  // callback (a live boot reached the trajectory line with
@@ -486,11 +640,14 @@ export const v2Personality = {
486
640
  return -1;
487
641
  }
488
642
  })(),
489
- agents_editor_ids: (() => {
643
+ // The ids the transform callback can SEE — which is the set from BEFORE the config
644
+ // directory merges, so it never contains our six roles. Named for what it measures:
645
+ // the old name implied "the editor's ids" and the filter made it look truncated
646
+ // (2 of 7) when the truth is that the transform runs too early to see them.
647
+ agents_editor_ids_at_transform: (() => {
490
648
  try {
491
649
  return (editor.list?.() ?? [])
492
650
  .map((a) => String(a?.id ?? "?"))
493
- .filter((n) => wantedIds.includes(n) || n === "build" || n === "plan")
494
651
  .join(" ");
495
652
  }
496
653
  catch {
@@ -508,7 +665,7 @@ export const v2Personality = {
508
665
  notes.push("ctx.agent.transform 不存在,角色权限矩阵未规范化");
509
666
  }
510
667
  if (defaultPromoted === false) {
511
- notes.push("Team 没有成为默认:editor.default(\"team\") 抛错了");
668
+ notes.push("Team 没有成为默认:editor.default(\"Team\") 抛错了");
512
669
  }
513
670
  else if (defaultPromoted && defaultPostCheck !== "present-after") {
514
671
  // Measured on a live 2.0.16 standalone boot: the call is accepted, `team` is
@@ -531,14 +688,13 @@ export const v2Personality = {
531
688
  // ---------- the request layer ----------
532
689
  // The whitelist was only ever a DENY, which stops a call but leaves the
533
690
  // 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
691
+ // removal, and the resolved board root and the temperature ride the request
536
692
  // instead — a v2 agent is a config FILE and cannot carry a per-workspace
537
693
  // 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.
694
+ // sends. This also starts the blackboard TTL sweeper, which this
695
+ // personality otherwise silently lacked: the note promises a sweep, so
696
+ // shipping it without the sweeper would be the exact overstated claim this
697
+ // product is built to refuse.
542
698
  const ttlMs = resolveTtlMs(options);
543
699
  const ttlDays = Math.round(ttlMs / (24 * 60 * 60 * 1000)) || DEFAULT_TTL_DAYS;
544
700
  const note = blackboardNote(startBlackboardMaintenance(directory, ttlMs), ttlDays);
@@ -573,7 +729,70 @@ export const v2Personality = {
573
729
  }
574
730
  }
575
731
  }
576
- const session = await applyV2SessionLayer(ctx, { temperature, note, noteAgents: ["team"], plan, scope });
732
+ const session = await applyV2SessionLayer(ctx, {
733
+ temperature,
734
+ note,
735
+ noteAgents: ["Team"],
736
+ plan,
737
+ scope,
738
+ // #49 feature 4: 拆分任务. The layer owns no store, so the trajectory sink
739
+ // is wired here — same shape as the retry governor's `onEvent`.
740
+ split: resolveSplitConfig(v2Env),
741
+ onSplit: (row) => {
742
+ try {
743
+ tmRuntime.pipelines.store.appendTrajectory({ tool: "host", step_id: "v2-split", api: 2, ...row });
744
+ }
745
+ catch {
746
+ /* the trajectory is an extra, never a reason to fail the call */
747
+ }
748
+ },
749
+ });
750
+ // #58: point the late-bound reader at the real observation now that the
751
+ // request layer exists, so `v2-surface` carries `tools_in_request` too.
752
+ tmInRequestSurface = () => (session.report.tmInRequestSurface ? "tm-in-request" : "catalog-only");
753
+ // #49 feature 3: 错峰重试. We have no seam that intercepts the model's own
754
+ // retry, so this layer only (1) recognises a provider throttle from the event
755
+ // feed, (2) injects an exact wait into the next request, and (3) denies new
756
+ // dispatches during the breaker's cooldown. Registered AFTER the permission
757
+ // guards so its `evaluate` hook runs after the concurrency cap — both only
758
+ // ever deny, so whichever fires first keeps its message and the stricter
759
+ // effect (deny) stands either way.
760
+ const retryConfig = resolveRetryConfig(v2Env);
761
+ // The layer owns no store, so the trajectory sink is wired here — same shape
762
+ // as the shell-timeout clamp's `onClamp` and the SERP guard's `onSerp`.
763
+ const retrySink = (row) => {
764
+ try {
765
+ tmRuntime.pipelines.store.appendTrajectory({ tool: "host", step_id: "v2-retry", api: 2, ...row });
766
+ }
767
+ catch {
768
+ /* the trajectory is an extra, never a reason to fail the call */
769
+ }
770
+ };
771
+ const retryGovernor = createRetryGovernor({ ...retryConfig, onEvent: retrySink });
772
+ const retry = retryConfig.enabled
773
+ ? await applyV2RetryGovernor(ctx, { scope, governor: retryGovernor, onEvent: retrySink })
774
+ : { registrations: [], report: retryGovernor.report };
775
+ registrations.push(...retry.registrations);
776
+ // Context Pruning (plan 功能 1, #49): the request layer trims the tool
777
+ // surface, the HOST decides WHEN to summarize — the plugin's own 75% trigger was
778
+ // removed in 1.7.1 (see CHANGELOG), and this layer
779
+ // shrinks the HISTORY itself — settled messages become one-line pointers
780
+ // (handle / child id / re-run hint) once the session crosses a fraction of
781
+ // its window. Registered AFTER the session layer so its `context` hook runs
782
+ // on the same assembled request, and it never touches a message outside its
783
+ // plan (the newest message and every hard-protected one stay byte-exact).
784
+ const prune = await applyV2ContextPrune(ctx, {
785
+ config: resolvePruneConfig(process.env),
786
+ scope,
787
+ onEvent: (row) => {
788
+ try {
789
+ tmRuntime.pipelines.store.appendTrajectory({ tool: "host", step_id: "v2-prune", event: "prune", api: 2, ...row });
790
+ }
791
+ catch {
792
+ /* a diagnostic that cannot be written never breaks the request it describes */
793
+ }
794
+ },
795
+ });
577
796
  // Everything the matrix reports now exists, so the late-bound closure can be
578
797
  // pointed at the real observations.
579
798
  v2Matrix = () => v2CapabilityRows({
@@ -584,6 +803,23 @@ export const v2Personality = {
584
803
  offload,
585
804
  sessionHooks: 2,
586
805
  temperature,
806
+ // The prune layer's counters, handed to the matrix so its row can earn `ok` on a
807
+ // process that actually pruned something. Without this the row could only ever read
808
+ // `declared`, which is the "wired but never observed" shape the matrix exists to
809
+ // distinguish from "it ran".
810
+ prune: {
811
+ enabled: prune.report.enabled,
812
+ atPercent: prune.report.atPercent,
813
+ keepTailPercent: prune.report.keepTailPercent,
814
+ checked: prune.report.checked,
815
+ prunedMessages: prune.report.prunedMessages,
816
+ prunedTokens: prune.report.prunedTokens,
817
+ below: prune.report.below,
818
+ noLimit: prune.report.noLimit,
819
+ foreignSkipped: prune.report.foreignSkipped,
820
+ threw: prune.report.threw,
821
+ lastPercent: prune.report.lastPercent,
822
+ },
587
823
  // Both derived, not asserted: "we think v2 has no X" has to come from the
588
824
  // object we were handed, so a host that grows the seam shows up here
589
825
  // automatically instead of leaving a stale "missing" row forever.
@@ -606,16 +842,23 @@ export const v2Personality = {
606
842
  scope,
607
843
  });
608
844
  registrations.push(...session.registrations);
845
+ registrations.push(...prune.registrations);
609
846
  if (!session.registrations.length) {
610
847
  notes.push("ctx.session.hook 不存在:工具面裁剪、温度、黑板根目录三项请求层治理都没装上");
611
848
  }
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) });
849
+ // This personality had no event subscription at all (#8), so a child that
850
+ // settled two seconds after the dispatch stayed "running" for the whole wait
851
+ // budget and `tm_join` reported a state it had never observed — goal #6's
852
+ // failure shape, and a bug rather than a limitation now that
853
+ // `ctx.event.subscribe()` is measured to work.
854
+ const feed = await applyV2EventFeed(ctx, {
855
+ onEvent: (ev) => tmRuntime.observeDispatchEvent(ev),
856
+ // #49 feature 3: the retry governor's error tap. `session.error` is the only
857
+ // place a provider throttle is published; the governor classifies its text and
858
+ // arms the next-request directive / the cooldown. A tap that throws never
859
+ // closes the feed (the governor itself never throws).
860
+ onError: (data) => retryGovernor.observeEvent(data),
861
+ });
619
862
  if (!feed.report.active) {
620
863
  notes.push(`事件流没接通(${feed.report.stopped ?? "原因未知"}):tm_join 的结算检测只剩等待预算内的轮询,子代理结算了也要等到超时才报告`);
621
864
  }
@@ -627,7 +870,7 @@ export const v2Personality = {
627
870
  const ids = new Set(probe.report.agentsSeen);
628
871
  for (const id of Object.keys(session.report.removed))
629
872
  ids.add(id);
630
- return [...ids].filter((id) => wantedIds.includes(id)).sort();
873
+ return [...ids].filter((id) => wantedIds.some((w) => normalizeAgentName(w) === normalizeAgentName(id))).sort();
631
874
  };
632
875
  observationCounters = (source) => ({
633
876
  counters_at: source,
@@ -636,12 +879,66 @@ export const v2Personality = {
636
879
  guard_shell_matched: guards.report.shellMatched,
637
880
  guard_strictened: guards.report.strictened,
638
881
  guard_denied: guards.report.denied,
882
+ guard_envfile_denied: guards.report.envFileDenied,
883
+ guard_envfile_denied_by_input: guards.report.envFileDeniedByInput,
884
+ guard_envfile_input_seen: guards.report.envFileInputSeen,
885
+ guard_envfile_input_threw: guards.report.envFileInputThrew,
886
+ guard_envfile_input_foreign_skipped: guards.report.envFileInputForeignSkipped,
639
887
  guard_foreign_skipped: guards.report.foreignSkipped,
888
+ // #49 feature 2: the concurrency cap — how many `subagent` evaluations it
889
+ // looked at, how many it denied, and the peak running count it saw.
890
+ concurrency_seen: guards.report.concurrencySeen,
891
+ concurrency_denied: guards.report.concurrencyDenied,
892
+ concurrency_running_max: guards.report.concurrencyRunningMax,
893
+ concurrency_threw: guards.report.concurrencyThrew,
640
894
  subagent_seen: bgForce.report.seen,
641
895
  subagent_forced: bgForce.report.forced,
642
- tools_removed: Object.entries(session.report.removed).map(([k, v]) => `${k}=${v}`).join(" "),
896
+ shell_timeout_seen: shellTimeout.report.seen,
897
+ shell_timeout_clamped: shellTimeout.report.clamped,
898
+ shell_timeout_foreign_skipped: shellTimeout.report.foreignSkipped,
899
+ shell_timeout_threw: shellTimeout.report.threw,
900
+ // (a) The PLAN, not our hook's own cut. `session.report.removed` counts what
901
+ // THIS layer deleted from `event.tools`, and on a live host that is 0 because
902
+ // the permission layer already excluded the denied names before the request
903
+ // reached us — so the old field read as "nothing was trimmed" while the boot
904
+ // row's `request_removed_plan` said `team=7`. The whitelist's effect on the
905
+ // request IS the removal, so this reports the plan (aligned with
906
+ // `request_removed_plan`); the hook's own cut is an implementation detail that
907
+ // is 0 by design, not a fact the user needs a separate field for.
908
+ tools_removed: [...plan.entries()].map(([k, v]) => `${k}=${v.size}`).join(" "),
643
909
  note_pushed: session.report.notePushed,
644
910
  compaction_lines: session.report.compactionLines,
911
+ // #49 feature 4: 拆分任务 — briefs measured and advice injected.
912
+ split_seen: session.report.splitSeen,
913
+ split_advised: session.report.splitAdvised,
914
+ // #49 Context Pruning — the history-shrinking half of the budget work.
915
+ prune_enabled: prune.report.enabled,
916
+ prune_at_percent: prune.report.atPercent,
917
+ prune_keep_tail_percent: prune.report.keepTailPercent,
918
+ prune_checked: prune.report.checked,
919
+ prune_pruned_messages: prune.report.prunedMessages,
920
+ prune_pruned_tokens: prune.report.prunedTokens,
921
+ prune_unknown_parts: prune.report.unknownParts,
922
+ prune_skipped_protected: prune.report.skippedProtected,
923
+ prune_below: prune.report.below,
924
+ prune_no_limit: prune.report.noLimit,
925
+ prune_foreign_skipped: prune.report.foreignSkipped,
926
+ prune_threw: prune.report.threw,
927
+ prune_last_percent: prune.report.lastPercent,
928
+ // #49 feature 3: 错峰重试 — recognition counts, breaker trips, cooldown
929
+ // denials and the last computed wait, so "we backed off" is a number.
930
+ retry_enabled: retry.report.enabled,
931
+ retry_quota_seen: retry.report.quotaSeen,
932
+ retry_rate_seen: retry.report.rateSeen,
933
+ retry_transient_seen: retry.report.transientSeen,
934
+ retry_unknown_seen: retry.report.unknownSeen,
935
+ retry_break_trips: retry.report.breakTrips,
936
+ retry_cooldowns: retry.report.cooldowns,
937
+ retry_last_delay_ms: retry.report.lastDelayMs,
938
+ retry_directives_injected: retry.report.directivesInjected,
939
+ retry_cooldown_denied: retry.report.cooldownDenied,
940
+ retry_foreign_skipped: retry.report.foreignSkipped,
941
+ retry_threw: retry.report.threw,
645
942
  scope_ours: scope.report.ours,
646
943
  scope_foreign: scope.report.foreign,
647
944
  scope_unknown: scope.report.unknown,
@@ -675,6 +972,8 @@ export const v2Personality = {
675
972
  browser_gate_refused: browserGate.report.refused,
676
973
  browser_gate_leaked: browserGate.report.leaked,
677
974
  browser_gate_held: browserGate.report.held,
975
+ browser_gate_serp_nav: browserGate.report.serpNav,
976
+ browser_gate_serp_refused: browserGate.report.serpRefused,
678
977
  native_seen: offload.report.seen,
679
978
  native_ours: offload.report.ours,
680
979
  native_unmatched: offload.report.unmatched,
@@ -683,6 +982,13 @@ export const v2Personality = {
683
982
  native_tokens_saved: offload.report.tokensSaved,
684
983
  native_capped: offload.report.capped,
685
984
  native_report_capped: offload.report.reportCapped,
985
+ // #35: the Code Mode adoption hint — how many native file/command calls we
986
+ // saw, and how many results actually carried the appended line.
987
+ probe_chain_seen: probeChain.report.seen,
988
+ probe_chain_advised: probeChain.report.advised,
989
+ probe_chain_reset: probeChain.report.reset,
990
+ probe_chain_foreign_skipped: probeChain.report.foreignSkipped,
991
+ probe_chain_threw: probeChain.report.threw,
686
992
  });
687
993
  const removedPlanSizes = [...plan.entries()].map(([id, set]) => `${id}=${set.size}`).join(" ");
688
994
  try {
@@ -725,6 +1031,11 @@ export const v2Personality = {
725
1031
  request_temperature: temperature === false ? "off" : temperature,
726
1032
  request_removed_plan: removedPlanSizes,
727
1033
  board_ttl_days: ttlDays,
1034
+ // #38: the runtime fallback's verdict — "ok" / "missing-role" / "no-default"
1035
+ // / "no-config" / "unreadable". `default_agent_missing` names the role only
1036
+ // when one is actually missing, so a clean boot prints an empty string.
1037
+ default_agent_check: defaultAgentCheck.state,
1038
+ default_agent_missing: defaultAgentCheck.state === "missing-role" ? defaultAgentCheck.defaultAgent ?? "" : "",
728
1039
  env_protect: envProtectMode,
729
1040
  note: notes.join(" · "),
730
1041
  });
@@ -732,9 +1043,9 @@ export const v2Personality = {
732
1043
  catch {
733
1044
  /* the trajectory is an extra here, never a reason to fail the boot */
734
1045
  }
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.
1046
+ // A user reading their own logs would otherwise conclude the plugin was
1047
+ // silently broken: these facts are the visible difference between a working
1048
+ // install and a silent one.
738
1049
  // A2: three states, ONE definition. The sentence that used to live here —
739
1050
  // `配置里缺角色:…` — asserted a fact about files from a snapshot that is blind by
740
1051
  // construction (`ctx.agent.transform` receives the agent set from before the
@@ -773,11 +1084,55 @@ export const v2Personality = {
773
1084
  guard_shell_matched: guards.report.shellMatched,
774
1085
  guard_strictened: guards.report.strictened,
775
1086
  guard_denied: guards.report.denied,
1087
+ guard_envfile_denied: guards.report.envFileDenied,
1088
+ guard_envfile_denied_by_input: guards.report.envFileDeniedByInput,
1089
+ guard_envfile_input_seen: guards.report.envFileInputSeen,
1090
+ guard_envfile_input_threw: guards.report.envFileInputThrew,
1091
+ guard_envfile_input_foreign_skipped: guards.report.envFileInputForeignSkipped,
1092
+ concurrency_seen: guards.report.concurrencySeen,
1093
+ concurrency_denied: guards.report.concurrencyDenied,
1094
+ concurrency_running_max: guards.report.concurrencyRunningMax,
1095
+ concurrency_threw: guards.report.concurrencyThrew,
776
1096
  subagent_seen: bgForce.report.seen,
777
1097
  subagent_forced: bgForce.report.forced,
778
- tools_removed: Object.entries(session.report.removed).map(([k, v]) => `${k}=${v}`).join(" "),
1098
+ shell_timeout_seen: shellTimeout.report.seen,
1099
+ shell_timeout_clamped: shellTimeout.report.clamped,
1100
+ shell_timeout_foreign_skipped: shellTimeout.report.foreignSkipped,
1101
+ shell_timeout_threw: shellTimeout.report.threw,
779
1102
  note_pushed: session.report.notePushed,
780
1103
  compaction_lines: session.report.compactionLines,
1104
+ // #49 feature 4: 拆分任务 — the same counter set as the surface row.
1105
+ split_seen: session.report.splitSeen,
1106
+ split_advised: session.report.splitAdvised,
1107
+ // #49 Context Pruning — per-outcome, so "we pruned nothing" is
1108
+ // distinguishable from "we never found a denominator" (`no_limit`).
1109
+ prune_enabled: prune.report.enabled,
1110
+ prune_at_percent: prune.report.atPercent,
1111
+ prune_keep_tail_percent: prune.report.keepTailPercent,
1112
+ prune_checked: prune.report.checked,
1113
+ prune_pruned_messages: prune.report.prunedMessages,
1114
+ prune_pruned_tokens: prune.report.prunedTokens,
1115
+ prune_unknown_parts: prune.report.unknownParts,
1116
+ prune_skipped_protected: prune.report.skippedProtected,
1117
+ prune_below: prune.report.below,
1118
+ prune_no_limit: prune.report.noLimit,
1119
+ prune_no_messages: prune.report.noMessages,
1120
+ prune_foreign_skipped: prune.report.foreignSkipped,
1121
+ prune_threw: prune.report.threw,
1122
+ prune_last_percent: prune.report.lastPercent,
1123
+ // #49 feature 3: 错峰重试 — the same counter set as the surface row.
1124
+ retry_enabled: retry.report.enabled,
1125
+ retry_quota_seen: retry.report.quotaSeen,
1126
+ retry_rate_seen: retry.report.rateSeen,
1127
+ retry_transient_seen: retry.report.transientSeen,
1128
+ retry_unknown_seen: retry.report.unknownSeen,
1129
+ retry_break_trips: retry.report.breakTrips,
1130
+ retry_cooldowns: retry.report.cooldowns,
1131
+ retry_last_delay_ms: retry.report.lastDelayMs,
1132
+ retry_directives_injected: retry.report.directivesInjected,
1133
+ retry_cooldown_denied: retry.report.cooldownDenied,
1134
+ retry_foreign_skipped: retry.report.foreignSkipped,
1135
+ retry_threw: retry.report.threw,
781
1136
  // Accumulates for the whole process, so it can only be written at
782
1137
  // teardown: this is the number that answers "did the feed stay alive, and
783
1138
  // did the host rename the event types under us after the last upgrade?"
@@ -789,6 +1144,8 @@ export const v2Personality = {
789
1144
  browser_gate_refused: browserGate.report.refused,
790
1145
  browser_gate_leaked: browserGate.report.leaked,
791
1146
  browser_gate_held: browserGate.report.held,
1147
+ browser_gate_serp_nav: browserGate.report.serpNav,
1148
+ browser_gate_serp_refused: browserGate.report.serpRefused,
792
1149
  browser_gate_note: browserGateSummary(browserGate.report),
793
1150
  // What the model could actually call, read off the assembled requests —
794
1151
  // the half of the delivery story the sent flag cannot tell.
@@ -845,7 +1202,7 @@ export const v2Personality = {
845
1202
  /* a host that already tore down its own registry has nothing left to free */
846
1203
  }
847
1204
  }
848
- // tm_browser owns a child process — teardown waits on v1 and must wait here too.
1205
+ // A tool may own a child process — teardown must await it.
849
1206
  await tmRuntime.dispose();
850
1207
  };
851
1208
  },