pantheon-opencode 1.5.2 → 1.6.0-beta.10

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 (146) hide show
  1. package/.pantheon/code-mode/README.md +4 -2
  2. package/.pantheon/code-mode/checkpoint_session.py +138 -102
  3. package/.pantheon/code-mode/compress-inline.py +102 -57
  4. package/.pantheon/code-mode/manifest.json +2 -8
  5. package/AGENTS.global.md +182 -0
  6. package/AGENTS.md +10 -891
  7. package/CHANGELOG.md +343 -0
  8. package/README.md +577 -79
  9. package/README.pt-BR.md +345 -60
  10. package/bin/pantheon-init.mjs +26 -3
  11. package/commands/pantheon-audit.md +7 -1
  12. package/commands/pantheon-consolidate.md +1 -1
  13. package/commands/pantheon-deepwork.md +14 -3
  14. package/commands/pantheon-focus.md +17 -0
  15. package/commands/pantheon-optimize.md +2 -2
  16. package/commands/pantheon-vision.md +61 -12
  17. package/package.json +30 -11
  18. package/plugin.json +2 -2
  19. package/pyproject.toml +11 -16
  20. package/scripts/build-agents-md.mjs +376 -58
  21. package/scripts/changelog-merge.mjs +161 -0
  22. package/scripts/check-plugin-v2-coverage.mjs +213 -0
  23. package/scripts/ci-validate-yaml.py +7 -5
  24. package/scripts/{code_mode_server.py → code_mode.py} +119 -27
  25. package/scripts/cost.mjs +244 -41
  26. package/scripts/doctor.mjs +236 -29
  27. package/scripts/install/agents-md.mjs +4 -4
  28. package/scripts/install/cli-ui.mjs +1 -1
  29. package/scripts/install/config-migration.mjs +319 -6
  30. package/scripts/install/health-check.mjs +26 -37
  31. package/scripts/install/interactive.mjs +7 -7
  32. package/scripts/install/migrate.mjs +1 -1
  33. package/scripts/install/opencode-version.mjs +246 -4
  34. package/scripts/install/opencode.mjs +627 -179
  35. package/scripts/install/plugin.mjs +43 -9
  36. package/scripts/install/shared.mjs +120 -12
  37. package/scripts/install/vision-prompt.mjs +161 -0
  38. package/scripts/install-mcp.mjs +28 -9
  39. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
  40. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  41. package/scripts/{memory_mcp_server.py → memory_mcp.py} +372 -198
  42. package/scripts/postinstall.mjs +0 -7
  43. package/scripts/probe-context-rehydrate.mjs +36 -13
  44. package/scripts/probe-pantheon-cost.mjs +71 -15
  45. package/scripts/prune.mjs +2 -2
  46. package/scripts/run-node-tests.mjs +189 -0
  47. package/scripts/sync-artifacts.mjs +26 -13
  48. package/scripts/sync-tui.mjs +24 -3
  49. package/scripts/test-opencode-v2-sandbox.sh +1913 -0
  50. package/scripts/validate-routing.mjs +43 -3
  51. package/scripts/validate-sandbox-install.mjs +138 -0
  52. package/scripts/versioning.mjs +13 -1
  53. package/scripts/vision-status.mjs +682 -0
  54. package/skills-lock.json +15 -16
  55. package/src/agents/aphrodite.md +20 -37
  56. package/src/agents/apollo.md +7 -3
  57. package/src/agents/athena.md +63 -35
  58. package/src/agents/demeter.md +8 -28
  59. package/src/agents/gaia.md +1 -1
  60. package/src/agents/hephaestus.md +10 -21
  61. package/src/agents/hermes.md +15 -38
  62. package/src/agents/iris.md +1 -1
  63. package/src/agents/mnemosyne.md +23 -60
  64. package/src/agents/nyx.md +1 -1
  65. package/src/agents/prometheus.md +16 -20
  66. package/src/agents/talos.md +10 -2
  67. package/src/agents/themis.md +5 -5
  68. package/src/agents/zeus.md +99 -130
  69. package/src/instructions/agent-return-format.instructions.md +18 -3
  70. package/src/instructions/backend-standards.instructions.md +2 -0
  71. package/src/instructions/frontend-standards.instructions.md +3 -1
  72. package/src/instructions/memory-protocol.instructions.md +11 -110
  73. package/src/instructions/zeus-anti-stall.instructions.md +14 -61
  74. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  75. package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
  76. package/src/instructions/zeus-memory-operations.instructions.md +26 -0
  77. package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
  78. package/src/mcp/{code_mode_server.py → code_mode.py} +119 -27
  79. package/src/mcp/eval_store.py +2 -2
  80. package/src/mcp/mcp_codemap_module.py +559 -129
  81. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +392 -78
  82. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  83. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +373 -250
  84. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +396 -40
  85. package/src/mcp/requirements-mcp.txt +10 -6
  86. package/src/mcp/requirements-vision.txt +4 -2
  87. package/src/pantheon/cost-command.ts +399 -101
  88. package/src/pantheon/delegation-enforce.ts +10 -1
  89. package/src/pantheon/model-command.ts +18 -7
  90. package/src/pantheon/presets.d.mts +0 -8
  91. package/src/pantheon/presets.mjs +5 -32
  92. package/src/pantheon/todo-enforcer.ts +1 -1
  93. package/src/pantheon/v2-bounded-read.ts +640 -0
  94. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  95. package/src/pantheon/v2-execute-after.ts +186 -0
  96. package/src/pantheon/v2-tools.ts +429 -0
  97. package/src/pantheon/v2-unsupported.d.mts +11 -0
  98. package/src/pantheon/v2-unsupported.mjs +90 -0
  99. package/src/pantheon/v2-vision.ts +622 -0
  100. package/src/pantheon/vision-local.ts +531 -0
  101. package/src/pantheon/vision.ts +1161 -116
  102. package/src/plugin-v2.ts +1244 -288
  103. package/src/plugin.ts +12 -9
  104. package/src/plugins/tui/dist/server.d.ts +1 -2
  105. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  106. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  107. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  108. package/src/plugins/tui/dist/tui-v2.js +659 -0
  109. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  110. package/src/plugins/tui/dist/tui.d.ts +85 -82
  111. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  112. package/src/plugins/tui/dist/tui.js +72 -59
  113. package/src/plugins/tui/index-v2.ts +6 -0
  114. package/src/plugins/tui/package-lock.json +5504 -1341
  115. package/src/plugins/tui/package.json +25 -15
  116. package/src/plugins/tui/src/index-v2.tsx +734 -0
  117. package/src/plugins/tui/src/index.tsx +106 -85
  118. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  119. package/src/plugins/tui/tsconfig.build.json +5 -0
  120. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  121. package/src/plugins/tui/tsdown.config.ts +22 -2
  122. package/src/plugins/tui/tui-v2.ts +2 -0
  123. package/src/plugins/tui/tui.ts +3 -0
  124. package/src/routing.yml +11 -22
  125. package/src/skills/README.md +34 -39
  126. package/src/skills/agent-coordination/SKILL.md +7 -2
  127. package/src/skills/artifact-management/SKILL.md +12 -5
  128. package/src/skills/auto-continue/SKILL.md +54 -95
  129. package/src/skills/code-review-checklist/SKILL.md +5 -5
  130. package/src/skills/context-compression/SKILL.md +26 -879
  131. package/src/skills/session-goal/SKILL.md +14 -5
  132. package/src/skills/spec-driven-development/SKILL.md +3 -1
  133. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  134. package/.pantheon/code-mode/eval-llm-judge.py +0 -255
  135. package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
  136. package/.pantheon/code-mode/eval-run.py +0 -197
  137. package/.pantheon/code-mode/eval-static.py +0 -321
  138. package/.pantheon/code-mode/session-end-save.py +0 -186
  139. package/.pantheon/code-mode/session-end-save.sh +0 -18
  140. package/opencode.json +0 -58
  141. package/scripts/recover-zenodo-deposition.mjs +0 -146
  142. package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
  143. package/scripts/validate-zenodo-release.mjs +0 -154
  144. package/scripts/zenodo-artifact.mjs +0 -94
  145. package/scripts/zenodo-release-state.mjs +0 -53
  146. package/src/skills/orchestration-workflow/SKILL.md +0 -311
package/src/plugin-v2.ts CHANGED
@@ -2,68 +2,168 @@
2
2
  * Pantheon V2 Plugin — full orchestration plugin for OpenCode V2.
3
3
  *
4
4
  * Registers:
5
- * - 6 orchestration tools via V2 tool.transform (with V1 bridge fallback)
6
- * - 4 event subscriptions (session.created, idle, error, compacted)
7
- * - Session hooks (prompt, context) and tool hooks (execute.before/after)
8
- * - Permission hooks for custom authorization
5
+ * - 3 self-sufficient tools via V2 tool.transform (hashline_edit,
6
+ * pantheon_cost, pantheon_model) plus native `vision_describe` when the host
7
+ * exposes the V2 model catalog; Vision resolves OpenCode Zen auth and sends
8
+ * images directly to the selected multimodal model.
9
+ * The goal tools need V1 infrastructure and are absent from this surface.
10
+ * - 5 event subscriptions (session.created, idle, deleted, error, compacted)
11
+ * - Session hooks (prompt, context, compaction)
12
+ * - A tool `execute.before` hook that ENFORCES read-only sessions: the agent
13
+ * arrives on the host's event, a read-only agent (apollo/gaia) registers its
14
+ * session, and the shared `createEnforcementGuard` throws to deny
15
+ * `hashline_edit` / `pantheon_model` / `edit` / `write` / `bash` / `task`.
16
+ * See `registerV2ToolHooks`.
17
+ * - The SAME `execute.before` hook runs scripts/hooks/scan-secrets.sh over the
18
+ * tool input and throws on exit 2 (high-confidence token match). This is the
19
+ * ONLY hard security block on the V2 surface. It is wired HERE, not delegated
20
+ * to `src/plugins/pantheon-hooks.ts`: opencode.json declares only
21
+ * `src/plugin-v2` in `plugins`, so the V1 plugin never loads on a V2-only
22
+ * install and V1's identical block was inert by construction.
23
+
24
+ * - A tool `execute.after` hook and a permission hook, with permission
25
+ * evaluation enforcing the caller/target matrix for Pantheon agents
9
26
  *
10
- * V2 Plugin API surface (confirmed by investigation):
11
- * ctx.tool.transform / ctx.event.subscribe / ctx.session.hook /
12
- * ctx.tool.hook / ctx.permission.hook
27
+ * Every tool registered through `draft.add` MUST declare `output`, and its
28
+ * `execute` MUST resolve to a value carrying `output`. The host enforces the
29
+ * two as a pair — see `V2ToolDraft` for the exact contract.
13
30
  *
14
- * When a V2 API domain is unavailable, the feature is gracefully skipped
15
- * and documented in V2_UNSUPPORTED_FEATURES. The plugin never breaks.
31
+ * ## The host domains the SDK types do not carry
32
+ *
33
+ * `@opencode-ai/plugin` 1.18.33 types `PluginContext` as exactly:
34
+ * options · agent · aisdk · catalog · command · integration · plugin ·
35
+ * reference · skill
36
+ * — no `tool`, `event`, `permission` or `session`. 1.18.34 (`latest`) is the
37
+ * same, so following the stable SDK line does not resolve this: it is a
38
+ * STRUCTURAL DECLARATION task, not a pin reconciliation.
39
+ *
40
+ * A live opencode 2.0.22+ host DOES have these domains, with the shapes this module
41
+ * uses. Measured by `tests/canary/plugin-v2-tool-canary.test.mjs`, which loads
42
+ * this file through the real loader and records `Object.keys(ctx)` from inside
43
+ * the host:
44
+ *
45
+ * agent aisdk app command event experimental generate integration location
46
+ * mcp model options permission plugin provider reference rpc session shell
47
+ * skill storage tool vcs websearch worktree
48
+ *
49
+ * ctx.tool.transform function
50
+ * ctx.tool.hook function
51
+ * ctx.tool.list function
52
+ * ctx.event.subscribe function
53
+ * ctx.permission.hook function
54
+ * ctx.session.hook function
55
+ * ctx.catalog absent
56
+ *
57
+ * `HostPluginContext` below declares exactly the minimum of that, and
58
+ * `hostDomains` is the SINGLE documented cast that reaches them — replacing the
59
+ * six repeated `as unknown as Record<string, unknown>` casts this file carried
60
+ * (Phases 2, 3, 4, 5, 6 and 7 — every phase except Phase 1, which reads the
61
+ * SDK-typed domains directly), each of which type-checked nothing at all. The
62
+ * per-domain definition checks stay: `registerV2Tools` still returns `false`
63
+ * and marks `tool-transform` when `ctx.tool.transform` is missing, and likewise
64
+ * for the others. The cast is not a licence to assume: the canary above is what
65
+ * proves the domains exist on a real host, and it fails if they stop existing.
66
+ *
67
+ * If the SDK ever exposes these host domains, replacing `hostDomains` with the real
68
+ * type is a one-line change. Do NOT make that change while 1.18.x lacks them.
69
+ *
70
+ * V2_UNSUPPORTED_FEATURES distinguishes adapter limitations from observed
71
+ * host-absent APIs; setup handles optional registrations on a best-effort basis.
16
72
  *
17
73
  * @module plugin-v2
18
74
  */
19
75
 
76
+ import { appendFileSync, mkdirSync } from 'node:fs'
77
+ import { realpath as realpathAsync } from 'node:fs/promises'
78
+ import path from 'node:path'
20
79
  import { fileURLToPath } from 'node:url'
21
80
  import type {
22
81
  AgentDraft,
23
- CatalogDraft,
24
82
  CommandDraft,
25
83
  PluginContext,
26
84
  ReferenceDraft,
27
- SkillDraft,
28
85
  } from '@opencode-ai/plugin/v2/promise'
29
86
  import { define } from '@opencode-ai/plugin/v2/promise'
87
+ import {
88
+ createEnforcementGuard,
89
+ readOnlyRegistry,
90
+ syncReadOnlySession,
91
+ type ToolExecuteBeforeInput,
92
+ type ToolExecuteBeforeOutput,
93
+ } from './pantheon/delegation-enforce.ts'
94
+ import {
95
+ BOUNDED_READ_TOOL,
96
+ createV2BoundedReadTool,
97
+ type V2BoundedReadOriginRegistry,
98
+ } from './pantheon/v2-bounded-read.ts'
30
99
  import {
31
100
  getV2BridgeFromContext,
32
101
  type PantheonV2Bridge,
33
102
  type V2ContextLike,
34
103
  } from './pantheon/v2-bridge.ts'
104
+ import {
105
+ type AuthoritativeSession,
106
+ evaluateV2Delegation,
107
+ PANTHEON_AGENTS,
108
+ type V2PermissionEvent,
109
+ V2SubagentOriginRegistry,
110
+ v2ResourceTarget,
111
+ } from './pantheon/v2-delegation-enforce.ts'
112
+ import { createV2ExecuteAfter, resolveV2SandboxConfig } from './pantheon/v2-execute-after.ts'
113
+ import { createV2ToolDefinitions, type V2ToolResult } from './pantheon/v2-tools.ts'
114
+ import { V2_UNSUPPORTED_FEATURE_SEED } from './pantheon/v2-unsupported.mjs'
115
+ import { buildV2VisionPromptContext, type V2VisionRuntime } from './pantheon/v2-vision.ts'
116
+ import { type HookPayload, type HookResult, runHook } from './plugins/hook-runner.ts'
35
117
 
36
118
  // ─── Unsupported Features Registry ───────────────────────────────────────
37
119
 
38
120
  /**
39
- * Features not yet supported by the installed V2 plugin API.
40
- * Updated dynamically at setup time based on actual API availability.
41
- * Initialized with features known to be absent from the V2 promise API;
42
- * additional features are appended during setup if their API is missing.
121
+ * Features not implemented by the current Pantheon V2 adapter or absent from
122
+ * the observed host. A listed feature is not necessarily a host-absent API.
123
+ * Additional features are appended during setup when a required API is missing.
43
124
  */
44
- export const V2_UNSUPPORTED_FEATURES: string[] = ['legacy-hooks']
125
+ /**
126
+ * Live unsupported-feature list: the shared seed plus anything `markUnsupported`
127
+ * appends while this process talks to a real host.
128
+ *
129
+ * The seed lives in `src/pantheon/v2-unsupported.mjs` — a plain-`.mjs` module —
130
+ * so that the V2 installer and `scripts/doctor.mjs` report the SAME strings
131
+ * without either of them having to import TypeScript. This array stays a
132
+ * mutable copy rather than the shared seed itself: `markUnsupported` appends to
133
+ * it, and the seed must stay frozen. `getUnsupportedFeatures()` still returns
134
+ * this exact reference.
135
+ */
136
+ export const V2_UNSUPPORTED_FEATURES: string[] = [...V2_UNSUPPORTED_FEATURE_SEED]
45
137
 
46
138
  // ─── Constants ───────────────────────────────────────────────────────────
47
139
 
48
140
  const POLICY_MARKER = '<!-- pantheon-v2-policy -->'
49
141
  const POLICY = `${POLICY_MARKER}\nFollow Pantheon routing policy: delegate implementation work to the named specialist and do not claim work was performed without verification.`
50
142
 
143
+ /** 2.0.16 `system` on a session hook payload is `Array<SystemPart>`. */
144
+ interface SystemPart {
145
+ type: 'text'
146
+ text: string
147
+ }
148
+
149
+ function isTextPart(entry: unknown): entry is SystemPart {
150
+ return (
151
+ typeof entry === 'object' &&
152
+ entry !== null &&
153
+ typeof (entry as { text?: unknown }).text === 'string'
154
+ )
155
+ }
156
+
51
157
  /**
52
- * SystemPart shape sniffing (beta 19192 `Schema validation failed` in
53
- * system[N]): host sends SystemPart objects ({type,text}) but pinned SDK
54
- * (@opencode-ai/plugin 1.18.18) only knows string/string[]. Detect at runtime.
55
- * Never throws (fail-open).
158
+ * Extract text from a `system` entry. The host's own coercion accepts a bare
159
+ * string, but the canonical 2.0.16 shape is `{ type: 'text', text }`; the
160
+ * string branch is retained so the handler still works against a host that
161
+ * sends primitives. Never throws.
56
162
  */
57
163
  function systemEntryText(entry: unknown): string | null {
58
164
  try {
59
165
  if (typeof entry === 'string') return entry
60
- if (
61
- typeof entry === 'object' &&
62
- entry !== null &&
63
- typeof (entry as { text?: unknown }).text === 'string'
64
- ) {
65
- return (entry as { text: string }).text
66
- }
166
+ if (isTextPart(entry)) return entry.text
67
167
  return null
68
168
  } catch {
69
169
  return null
@@ -81,19 +181,30 @@ function hasPolicyMarker(arr: unknown[]): boolean {
81
181
  }
82
182
  }
83
183
 
84
- function usesObjectShape(arr: unknown[]): boolean {
85
- try {
86
- return arr.some(
87
- (s) =>
88
- typeof s === 'object' && s !== null && typeof (s as { text?: unknown }).text === 'string',
89
- )
90
- } catch {
91
- return false
184
+ /**
185
+ * Normalize a `system` value to the 2.0.16 `Array<SystemPart>` shape.
186
+ *
187
+ * `SessionContext.system` is `Array<SystemPart>` where a part is
188
+ * `{ type: 'text', text }`. A raw string (the whole value, or an element) is
189
+ * not that shape, so it is upgraded: element strings become parts, and a bare
190
+ * string value becomes a one-element part array. Non-array, non-string values
191
+ * are returned unchanged.
192
+ *
193
+ * Returns the normalized array, or `null` when `system` is neither a string
194
+ * nor an array (nothing to normalize).
195
+ */
196
+ function normalizeSystemToParts(system: unknown): unknown[] | null {
197
+ if (typeof system === 'string') {
198
+ return [{ type: 'text', text: system } satisfies SystemPart]
92
199
  }
93
- }
94
-
95
- function toSystemEntry(text: string, useObject: boolean): string | { type: 'text'; text: string } {
96
- return useObject ? { type: 'text', text } : text
200
+ if (!Array.isArray(system)) return null
201
+ for (let i = 0; i < system.length; i++) {
202
+ const entry = system[i]
203
+ if (typeof entry === 'string') {
204
+ system[i] = { type: 'text', text: entry } satisfies SystemPart
205
+ }
206
+ }
207
+ return system
97
208
  }
98
209
 
99
210
  /**
@@ -105,6 +216,88 @@ function markUnsupported(feature: string): void {
105
216
  }
106
217
  }
107
218
 
219
+ // ─── Host context domains the SDK types do not carry ────────────────────
220
+
221
+ /**
222
+ * The minimal shape of `ctx.tool` this module uses, as a live opencode 2.0.22
223
+ * host provides it. `transform` registers the Pantheon tools, `hook` installs
224
+ * the `execute.before`/`execute.after` guards, `list` is the host-built
225
+ * descriptor dump the canary asserts on.
226
+ */
227
+ interface V2ToolDomain {
228
+ transform?: (cb: (draft: V2ToolDraft) => void) => Promise<unknown>
229
+ hook?: (name: string, handler: (event: unknown) => void | Promise<void>) => Promise<unknown>
230
+ list?: () => Promise<unknown>
231
+ }
232
+
233
+ /** The minimal shape of `ctx.event`: one async-iterable subscription. */
234
+ interface V2EventDomain {
235
+ subscribe?: (opts?: { signal?: AbortSignal }) => AsyncIterable<V2SessionEvent>
236
+ }
237
+
238
+ /**
239
+ * The minimal shape of `ctx.permission`.
240
+ *
241
+ * The V2.0.25 live contract supplies an `evaluate` event with `resources`;
242
+ * runtime validation remains defensive because SDK declarations omit this
243
+ * domain and the host may evolve independently.
244
+ */
245
+ interface V2PermissionDomain {
246
+ assert?: (input: Record<string, unknown>) => Promise<unknown>
247
+ hook?: (
248
+ name: string,
249
+ handler: (event: unknown, output?: unknown) => void | Promise<void>,
250
+ ) => Promise<unknown>
251
+ }
252
+
253
+ /**
254
+ * The minimal shape of `ctx.session` used by this module. `get` follows the V2
255
+ * Promise API's object input (`{ sessionID }`); session hooks accept any string
256
+ * and silently drop an unknown one, which is why the host-backed hook canary
257
+ * exists.
258
+ */
259
+ interface V2SessionDomain {
260
+ hook?: (name: string, handler: (event: unknown) => void | Promise<void>) => Promise<unknown>
261
+ get?: (input: { sessionID: string }) => Promise<unknown>
262
+ }
263
+
264
+ interface V2LocationDomain {
265
+ directory?: string
266
+ project?: { id?: string }
267
+ }
268
+
269
+ interface V2ModelDomain {
270
+ list?: V2VisionRuntime['model']['list']
271
+ }
272
+
273
+ interface V2GenerateDomain {
274
+ text?: (input: { model: { providerID: string; id: string }; prompt: string }) => Promise<unknown>
275
+ }
276
+
277
+ /** `PluginContext` plus the host domains 1.18.x omits. See the module header. */
278
+ interface HostPluginContext extends PluginContext {
279
+ tool?: V2ToolDomain
280
+ event?: V2EventDomain
281
+ permission?: V2PermissionDomain
282
+ session?: V2SessionDomain
283
+ model?: V2ModelDomain
284
+ generate?: V2GenerateDomain
285
+ location?: V2LocationDomain
286
+ }
287
+
288
+ /**
289
+ * THE cast to the host's context. Every optional member stays optional and every
290
+ * caller re-checks before use, so this narrows nothing away: it replaces five
291
+ * anonymous `Record<string, unknown>` casts with one named, documented,
292
+ * type-checked boundary.
293
+ *
294
+ * When the SDK ships these four domains, delete this function and use
295
+ * `PluginContext` directly.
296
+ */
297
+ function hostDomains(context: PluginContext): HostPluginContext {
298
+ return context as HostPluginContext
299
+ }
300
+
108
301
  // ─── V1 Bridge Integration ──────────────────────────────────────────────
109
302
 
110
303
  /**
@@ -191,16 +384,6 @@ function transformAgents(draft: AgentDraft): void {
191
384
  }
192
385
  }
193
386
 
194
- function transformCatalog(draft: CatalogDraft, options: PluginContext['options']): void {
195
- const configured = options.default_model
196
- if (typeof configured !== 'string') return
197
- const separator = configured.indexOf('/')
198
- if (separator <= 0 || separator === configured.length - 1) return
199
- const providerID = configured.slice(0, separator)
200
- const modelID = configured.slice(separator + 1)
201
- if (draft.model.get(providerID, modelID)) draft.model.default.set(providerID, modelID)
202
- }
203
-
204
387
  function transformCommands(draft: CommandDraft): void {
205
388
  // Beta 19192 guard (issue #92): draft may not have `list`, or list()
206
389
  // may return a non-iterable. Never throw — mark unsupported and return.
@@ -237,41 +420,6 @@ function transformCommands(draft: CommandDraft): void {
237
420
  }
238
421
  }
239
422
 
240
- function transformSkills(draft: SkillDraft): void {
241
- // Beta 19192 guard (issue #92): draft may not have `list`, or list()
242
- // may return a non-iterable. Never throw — mark unsupported and return.
243
- let sources: Array<{ type: string; path: string }>
244
- try {
245
- const maybeList = (draft as unknown as { list?: unknown }).list
246
- if (typeof maybeList !== 'function') {
247
- markUnsupported('skill-transform-list')
248
- return
249
- }
250
- const result = (maybeList as (this: unknown) => unknown).call(draft)
251
- if (
252
- result == null ||
253
- typeof (result as { [Symbol.iterator]?: unknown })[Symbol.iterator] !== 'function'
254
- ) {
255
- markUnsupported('skill-transform-list')
256
- return
257
- }
258
- sources = Array.isArray(result)
259
- ? (result as Array<{ type: string; path: string }>)
260
- : Array.from(result as Iterable<{ type: string; path: string }>)
261
- } catch {
262
- markUnsupported('skill-transform-list')
263
- return
264
- }
265
- try {
266
- const path = fileURLToPath(new URL('./skills', import.meta.url))
267
- if (!sources.some((source) => source.type === 'directory' && source.path === path)) {
268
- draft.source({ type: 'directory', path })
269
- }
270
- } catch {
271
- markUnsupported('skill-transform')
272
- }
273
- }
274
-
275
423
  function transformReferences(draft: ReferenceDraft): void {
276
424
  draft.add('pantheon-agents', {
277
425
  type: 'local',
@@ -287,32 +435,53 @@ function transformReferences(draft: ReferenceDraft): void {
287
435
  *
288
436
  * The V2 tool.transform API (when available) provides:
289
437
  * draft.namespace({ name: "pantheon", description: "..." })
290
- * draft.add({ name, description, input, execute })
438
+ * draft.add({ name, description, input, output, execute })
439
+ *
440
+ * `output` is part of the required shape, not an optional extra: see
441
+ * `V2ToolDraft` for the host contract that makes it mandatory.
291
442
  *
292
443
  * Returns true if tools were registered, false if the API is unavailable.
293
444
  */
294
- async function registerV2Tools(context: PluginContext): Promise<boolean> {
295
- const toolCtx = (context as unknown as Record<string, unknown>).tool as
296
- | { transform?: (cb: (draft: V2ToolDraft) => void) => Promise<unknown> }
297
- | undefined
445
+ async function registerV2Tools(
446
+ context: PluginContext,
447
+ boundedRead?: ReturnType<typeof createV2BoundedReadTool>['definition'],
448
+ ): Promise<boolean> {
449
+ const host = hostDomains(context)
450
+ const toolCtx = host.tool
298
451
 
299
452
  if (!toolCtx?.transform) {
300
453
  return false
301
454
  }
302
455
 
303
456
  try {
304
- // Tool definitions require infrastructure instances — when available via
305
- // V2 context, they are passed through; otherwise the factory returns
306
- // definitions with lazy execute wrappers.
307
- const toolDefs = createV2ToolDefinitionsFromContext(context)
457
+ // Tool definitions carry their own `output` declaration; the draft requires
458
+ // it, and the host rejects a definition whose result shape cannot satisfy
459
+ // the declaration it makes.
460
+ const model = host.model
461
+ const visionReady = typeof model?.list === 'function'
462
+ if (!visionReady) markUnsupported('vision-tool')
463
+ const visionRuntime = visionReady ? ({ model } as V2VisionRuntime) : undefined
464
+ const toolDefs = createV2ToolDefinitions(visionRuntime)
465
+ if (boundedRead !== undefined) toolDefs.push(boundedRead)
308
466
 
309
467
  await toolCtx.transform((draft: V2ToolDraft) => {
468
+ // Catalog grouping only. The host computes a tool's registration id as
469
+ // `options.namespace === undefined ? name : `${namespace}_${name}``, and
470
+ // `draft.namespace()` does NOT set `options.namespace` on later `add`
471
+ // calls — so the tools stay `hashline_edit` / `pantheon_cost` /
472
+ // `pantheon_model`, not `pantheon_*`. That bare name is what the
473
+ // enforcement guard's blocked-tool list matches on; see
474
+ // `adaptV2ExecuteBeforeEvent`.
310
475
  draft.namespace({ name: 'pantheon', description: 'Pantheon orchestration tools' })
311
476
  for (const def of toolDefs) {
312
477
  draft.add({
313
478
  name: def.name,
314
479
  description: def.description,
315
480
  input: def.input,
481
+ // Required by the host contract: without this the tool fails on
482
+ // every call with "Tool result declared output without an output
483
+ // schema". Must stay paired with def.execute resolving to `output`.
484
+ output: def.output,
316
485
  execute: def.execute,
317
486
  })
318
487
  }
@@ -333,10 +502,11 @@ async function registerV2Tools(context: PluginContext): Promise<boolean> {
333
502
  *
334
503
  * Returns a cleanup function, or undefined if the API is unavailable.
335
504
  */
336
- function subscribeV2Events(context: PluginContext): (() => void) | undefined {
337
- const eventCtx = (context as unknown as Record<string, unknown>).event as
338
- | { subscribe?: (opts?: { signal?: AbortSignal }) => AsyncIterable<V2SessionEvent> }
339
- | undefined
505
+ function subscribeV2Events(
506
+ context: PluginContext,
507
+ onTurnEnd?: (sessionID: string) => void,
508
+ ): (() => void) | undefined {
509
+ const eventCtx = hostDomains(context).event
340
510
 
341
511
  if (!eventCtx?.subscribe) {
342
512
  return undefined
@@ -348,6 +518,19 @@ function subscribeV2Events(context: PluginContext): (() => void) | undefined {
348
518
  // biome-ignore lint/style/noNonNullAssertion: subscribe guaranteed by guard above
349
519
  for await (const event of eventCtx.subscribe!({ signal: controller.signal })) {
350
520
  await handleV2SessionEvent(event)
521
+ if (event.type === 'session.idle' || event.type === 'session.deleted') {
522
+ const properties = event.properties ?? {}
523
+ const info = properties.info
524
+ const infoID =
525
+ info !== null && typeof info === 'object' ? (info as { id?: unknown }).id : undefined
526
+ const sessionID =
527
+ typeof properties.sessionID === 'string'
528
+ ? properties.sessionID
529
+ : typeof infoID === 'string'
530
+ ? infoID
531
+ : undefined
532
+ if (sessionID) onTurnEnd?.(sessionID)
533
+ }
351
534
  }
352
535
  } catch (err: unknown) {
353
536
  if (err instanceof Error && err.name === 'AbortError') return
@@ -370,6 +553,9 @@ async function handleV2SessionEvent(event: V2SessionEvent): Promise<void> {
370
553
  case 'session.idle':
371
554
  await onSessionIdle(event)
372
555
  break
556
+ case 'session.deleted':
557
+ await onSessionDeleted(event)
558
+ break
373
559
  case 'session.error':
374
560
  await onSessionError(event)
375
561
  break
@@ -381,20 +567,68 @@ async function handleV2SessionEvent(event: V2SessionEvent): Promise<void> {
381
567
  }
382
568
  }
383
569
 
384
- async function onSessionCreated(_event: V2SessionEvent): Promise<void> {
385
- // Seed session state — handled by V1 bridge or direct registration.
386
- // The V2 event doesn't carry enough context for full hierarchy setup;
387
- // bridge.board is used when available for root-session registration.
570
+ /**
571
+ * Record the v2.0.29 lifecycle event in the opt-in F4A canary. Runtime events
572
+ * use a `{ id, created, type, data }` envelope; the child data carries
573
+ * `sessionID`, `parentID` and `agent`. Admission itself does not depend on this
574
+ * subscription: it checks the current permission source and `ctx.session.get`
575
+ * synchronously, so resumed roots and children use the same path.
576
+ */
577
+ async function onSessionCreated(event: V2SessionEvent): Promise<void> {
578
+ const payload = event as V2SessionEvent & Record<string, unknown>
579
+ const properties = event.properties ?? {}
580
+ const data =
581
+ event.data !== null && typeof event.data === 'object'
582
+ ? (event.data as Record<string, unknown>)
583
+ : {}
584
+ const info = properties.info ?? data.info
585
+ const sessionInfo =
586
+ info !== null && typeof info === 'object' ? (info as Record<string, unknown>) : {}
587
+ writeV2AdmissionProof('session.created', {
588
+ eventKeys: Object.keys(payload),
589
+ propertyKeys: Object.keys(properties),
590
+ dataKeys: Object.keys(data),
591
+ infoKeys: Object.keys(sessionInfo),
592
+ id:
593
+ sessionInfo.id ??
594
+ sessionInfo.sessionID ??
595
+ data.id ??
596
+ data.sessionID ??
597
+ properties.id ??
598
+ properties.sessionID ??
599
+ payload.sessionID ??
600
+ null,
601
+ parentID: sessionInfo.parentID ?? data.parentID ?? properties.parentID ?? null,
602
+ agent: sessionInfo.agent ?? data.agent ?? properties.agent ?? null,
603
+ })
388
604
  const _bridge = resolveBridge()
389
605
  if (_bridge?.board != null) {
390
- // Bridge available: board will register root sessions via the V1 event hook.
391
- // This is a no-op here — V1 handles hierarchy setup.
606
+ // Bridge present ⇒ this process also loaded V1, whose own event hook owns
607
+ // root-session registration. Intentionally nothing to do on the V2 side.
392
608
  }
393
609
  }
394
610
 
395
611
  async function onSessionIdle(_event: V2SessionEvent): Promise<void> {
396
- // Idle continuation — goal loop / todo enforcer dispatch.
397
- // Bridge provides the full V1 infrastructure when available.
612
+ // Temp-image sweep on the V2-only path: DELIBERATELY DEFERRED.
613
+ //
614
+ // V1 unlinks a session's temp images on `session.idle`/`session.deleted`
615
+ // (`cleanupSessionTempImages`, grace-guarded and refcounted). V2 cannot reuse
616
+ // it: `saveDataUrlImageSync` (the V2 materialization path) receives no session
617
+ // id, so there is no per-session `path → createdAt` map to sweep, and the
618
+ // content-hash temp dir is refcounted ONLY on the V1 path. The one reusable
619
+ // piece, `enforceTempFileCap`, is already invoked off every V2 save
620
+ // (`saveDataUrlImageSync`), so disk usage is bounded without an idle pass —
621
+ // re-running it here would be redundant, and a GLOBAL age-based unlink fired
622
+ // by one session's idle could delete a file another live session still
623
+ // references. Delivery is also best-effort: with no `ctx.event.subscribe`
624
+ // the stream is unsupported (`markUnsupported('event-stream')`) and no idle
625
+ // ever arrives. Implementing this safely needs V2-side session threading
626
+ // first; until then it stays deferred rather than approximated.
627
+ //
628
+ // Idle continuation — goal loop / todo enforcer dispatch. This one really
629
+ // does delegate: `bridge.todoEnforcer` is the V1 enforcer instance, reached
630
+ // only in a process that also loaded V1. On a V2-only install the bridge
631
+ // resolves to null and idle continuation does not run.
398
632
  const sessionID = _event.properties?.sessionID as string | undefined
399
633
  if (!sessionID) return
400
634
  const bridge = resolveBridge()
@@ -407,23 +641,75 @@ async function onSessionIdle(_event: V2SessionEvent): Promise<void> {
407
641
  }
408
642
  }
409
643
 
410
- async function onSessionError(_event: V2SessionEvent): Promise<void> {
411
- // Error handling — board transition to error.
412
- const bridge = resolveBridge()
413
- if (bridge?.board != null) {
414
- // Bridge available: error handling is done by the V1 event hook.
415
- // V2 only logs — V1 owns the board transition.
644
+ /**
645
+ * Session-deleted handler: drop the read-only registration.
646
+ *
647
+ * `readOnlyRegistry` is a process-wide singleton and the `execute.before` hook
648
+ * populates it on every delegated `apollo`/`gaia` call. V1 prunes it on
649
+ * `session.deleted` (`src/plugin.ts`); V2 did not, so on a long-lived host
650
+ * every finished investigation session stayed registered for the life of the
651
+ * process — a slow leak, and a stale entry that would keep denying tools if
652
+ * the id were ever reused.
653
+ *
654
+ * This mirrors V1: unregister the session, which drops its agent entry with it
655
+ * (the registry stores one `ReadOnlyEntry` per session id). V1 also clears a
656
+ * `sessionAgents` map here; V2 has no such map and needs no equivalent, since
657
+ * it reads the active agent off the event instead of caching it.
658
+ *
659
+ * The id is read from `properties.sessionID` — the field the sibling
660
+ * `session.idle` handler already uses — falling back to V1's
661
+ * `properties.info.id`. Both spellings are accepted because the V2 SDK types
662
+ * `session.deleted` as `{ sessionID, info }` and a miss here is a silent leak,
663
+ * which is precisely the bug this handler exists to close.
664
+ *
665
+ * A payload with NEITHER spelling is fail-open, and unavoidably so: unregistering
666
+ * is what needs the id, so an id that cannot be resolved cannot be cleaned up
667
+ * and the entry survives. The docstring used to leave that as a documented
668
+ * silent branch; it now warns on the way out, because the only other symptom of
669
+ * a renamed field is a registry that grows for the life of the process.
670
+ */
671
+ async function onSessionDeleted(event: V2SessionEvent): Promise<void> {
672
+ const properties = event.properties
673
+ if (properties === undefined) return
674
+ const info = properties.info
675
+ const sessionID =
676
+ typeof properties.sessionID === 'string'
677
+ ? properties.sessionID
678
+ : info !== null && typeof info === 'object'
679
+ ? (info as { id?: unknown }).id
680
+ : undefined
681
+ if (typeof sessionID !== 'string' || sessionID === '') {
682
+ // Fail-open by necessity, loud by choice: the entry for this session (if
683
+ // any) cannot be identified, so it stays registered. Same `console.warn`
684
+ // channel as the event-subscription error above — this module has no
685
+ // logger, and a diagnostic nobody reads on stderr is still a silent leak.
686
+ console.warn(
687
+ '[Pantheon V2] session.deleted carried no session id ' +
688
+ '(neither properties.sessionID nor properties.info.id); ' +
689
+ 'its read-only registration cannot be pruned and will persist.',
690
+ )
691
+ return
416
692
  }
693
+ readOnlyRegistry.unregister(sessionID)
417
694
  }
418
695
 
419
- async function onSessionCompacted(_event: V2SessionEvent): Promise<void> {
420
- // Compaction restore — todo preserve / context rebuild.
421
- const bridge = resolveBridge()
422
- if (bridge?.todoEnforcer != null) {
423
- // Bridge available: todo preservation is done by V1 compaction hook.
424
- // V2 only logs — V1 handles full compaction context build.
425
- }
426
- }
696
+ /**
697
+ * Session-error handler: no V2-side implementation.
698
+ *
699
+ * The board error transition lives in V1's event hook, which is not loaded on
700
+ * a V2-only install. There is nothing to do here — deliberately, rather than
701
+ * describing behaviour this handler does not have.
702
+ */
703
+ async function onSessionError(_event: V2SessionEvent): Promise<void> {}
704
+
705
+ /**
706
+ * Session-compacted handler: no V2-side implementation.
707
+ *
708
+ * Todo preservation and compaction context build live in the V1 path
709
+ * (`todo-preserve.ts`, driven by V1's compaction hook), which is not loaded on
710
+ * a V2-only install.
711
+ */
712
+ async function onSessionCompacted(_event: V2SessionEvent): Promise<void> {}
427
713
 
428
714
  // ─── V2 Session Hooks ───────────────────────────────────────────────────
429
715
 
@@ -437,11 +723,7 @@ async function onSessionCompacted(_event: V2SessionEvent): Promise<void> {
437
723
  * Returns true if any hooks were registered.
438
724
  */
439
725
  async function registerV2SessionHooks(context: PluginContext): Promise<boolean> {
440
- const sessionCtx = (context as unknown as Record<string, unknown>).session as
441
- | {
442
- hook?: (name: string, handler: (event: unknown) => void | Promise<void>) => Promise<unknown>
443
- }
444
- | undefined
726
+ const sessionCtx = hostDomains(context).session
445
727
 
446
728
  if (!sessionCtx?.hook) {
447
729
  return false
@@ -453,21 +735,32 @@ async function registerV2SessionHooks(context: PluginContext): Promise<boolean>
453
735
  // "context" hook — inject routing policy + compaction state
454
736
  await sessionCtx.hook('context', (event: unknown) => {
455
737
  try {
456
- // Beta 19192 hardening + SystemPart sniffing: host may send string[],
457
- // SystemPart objects ({type,text}), mixed arrays, a single string, or
458
- // a non-array system. Preserve string-path behavior; inject object
459
- // shape when the array contains objects (else `Schema validation
460
- // failed` in system[N]). Never throw to the host (fail-open).
461
- const ctx = event as { system?: unknown } | null | undefined
462
- const rawSystem: unknown = ctx?.system
463
- if (typeof rawSystem === 'string' && ctx != null) {
464
- ctx.system = rawSystem.includes(POLICY_MARKER) ? [rawSystem] : [rawSystem, POLICY]
465
- } else if (Array.isArray(rawSystem)) {
466
- if (!hasPolicyMarker(rawSystem)) {
467
- rawSystem.push(toSystemEntry(POLICY, usesObjectShape(rawSystem)))
738
+ // Opt-in lifetime proof for the host-backed hook canary (MODE=real).
739
+ // Inert unless PANTHEON_HOOK_CANARY_LIFETIME_PROOF is set.
740
+ if (process.env.PANTHEON_HOOK_CANARY_LIFETIME_PROOF) {
741
+ try {
742
+ appendFileSync(
743
+ process.env.PANTHEON_HOOK_CANARY_LIFETIME_PROOF,
744
+ 'plugin-v2:session.hook:context\n',
745
+ )
746
+ } catch {
747
+ // Best-effort instrumentation.
468
748
  }
469
749
  }
470
- // Non-string, non-array system (or null event): ignore silently.
750
+ // 2.0.16 payload is `{ system: Array<SystemPart> }`. Normalize any
751
+ // string entries to `{ type: 'text', text }` parts and append the
752
+ // policy as a part. Junk entries are ignored; a non-array,
753
+ // non-string system is left untouched. Never throws (fail-open).
754
+ const ctx = event as { system?: unknown } | null | undefined
755
+ if (ctx == null) return
756
+ const normalized = normalizeSystemToParts(ctx.system)
757
+ if (normalized == null) return
758
+ // A bare string is replaced by its part array; an array is mutated in
759
+ // place (the host may read the original reference).
760
+ if (typeof ctx.system === 'string') ctx.system = normalized
761
+ if (!hasPolicyMarker(normalized)) {
762
+ normalized.push({ type: 'text', text: POLICY } satisfies SystemPart)
763
+ }
471
764
  } catch {
472
765
  // Fail-open: never throw to the host.
473
766
  }
@@ -478,10 +771,31 @@ async function registerV2SessionHooks(context: PluginContext): Promise<boolean>
478
771
  }
479
772
 
480
773
  try {
481
- // "prompt" hook — vision message interception
482
- await sessionCtx.hook('prompt', (_event: unknown) => {
483
- // Vision interception delegates to the V1 vision handler.
484
- // Without V1 client access, this is a graceful no-op.
774
+ // "prompt" hook — preserve attached image references and add model-visible
775
+ // instructions so Zeus can inspect visual context and pass it to the
776
+ // implementation specialist before editing.
777
+ await sessionCtx.hook('prompt', (event: unknown) => {
778
+ try {
779
+ // 2.0.29 host payload: `{ sessionID, messageID, prompt: { text,
780
+ // files, agents, skills }, metadata, delivery }`. The session id is
781
+ // top-level (verified against the host's `SessionPrompt.prepare`), so a
782
+ // data: attachment's panic-free moment also relays it to the MCP.
783
+ const admission = event as {
784
+ sessionID?: unknown
785
+ prompt?: { text?: unknown; files?: unknown }
786
+ } | null
787
+ const prompt = admission?.prompt
788
+ if (!prompt || typeof prompt.text !== 'string') return
789
+ if (prompt.text.includes('<!-- pantheon-v2-visual-context -->')) return
790
+ const sessionID =
791
+ typeof admission?.sessionID === 'string' && admission.sessionID.trim() !== ''
792
+ ? admission.sessionID
793
+ : undefined
794
+ const visualContext = buildV2VisionPromptContext(prompt.files, prompt.text, sessionID)
795
+ if (visualContext) prompt.text = `${prompt.text}\n\n${visualContext}`
796
+ } catch {
797
+ // Fail-open: malformed/unsupported prompt shapes must not block input.
798
+ }
485
799
  })
486
800
  registered = true
487
801
  } catch {
@@ -491,8 +805,395 @@ async function registerV2SessionHooks(context: PluginContext): Promise<boolean>
491
805
  return registered
492
806
  }
493
807
 
808
+ // ─── V2 Secret Scan ─────────────────────────────────────────────────────
809
+
810
+ /**
811
+ * High-confidence secret token formats — MIRRORS `SECRET_MASK_RE` in
812
+ * src/plugins/pantheon-hooks.ts (V1) and `HIGH_CONFIDENCE_PATTERNS` in
813
+ * scripts/hooks/scan-secrets.sh.
814
+ *
815
+ * Duplicated rather than imported because the V1 module is a separate plugin
816
+ * that is NOT loaded on a V2-only install (see the module header), and pulling
817
+ * it in for one regex would resurrect exactly the V1-hook coupling V2 exists to
818
+ * remove. The drift risk is real and is asserted by a test in
819
+ * tests/pantheon/plugin-v2-contract.test.ts.
820
+ *
821
+ * Applied to every log line this module writes, so a hook trail can never
822
+ * contain a raw credential. Header/KEY names (the Bifrost header alone,
823
+ * `api_key=...`) are deliberately NOT matched: they are not secret values.
824
+ */
825
+ const SECRET_MASK_RE =
826
+ /(sk-bf-[A-Za-z0-9_-]{8,}|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9_]{36,}|glpat-[A-Za-z0-9_-]{20}|sk-[a-zA-Z0-9]{20,}|sk_live_[a-zA-Z0-9]{20,}|sk_test_[a-zA-Z0-9]{20,}|xox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}|bearer\s+[a-zA-Z0-9_.-]{20,}|eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*)/gi
827
+
828
+ /** Replace every high-confidence secret match with '****'. */
829
+ function maskSecret(text: string): string {
830
+ return text.replace(SECRET_MASK_RE, '****')
831
+ }
832
+
833
+ /**
834
+ * Message thrown when scan-secrets.sh exits 2 (a high-confidence token match).
835
+ *
836
+ * This is the only deliberate throw on the V2 surface. opencode's
837
+ * Plugin.trigger has no catch around hook invocations, so the rejection
838
+ * propagates and fails the tool's execute promise BEFORE the tool body runs —
839
+ * the same hard block V1 has (src/plugins/pantheon-hooks.ts
840
+ * `SECRET_BLOCK_MESSAGE`), which was inert on a V2-only install because
841
+ * pantheon-hooks.ts is never loaded there (opencode.json `plugins` declares only
842
+ * `src/plugin-v2`).
843
+ *
844
+ * English, like every other message this module emits: this string surfaces in
845
+ * the user's session as the tool error, so it is read by whoever hit the block,
846
+ * not by the code that raised it. A Portuguese release note elsewhere in the
847
+ * repo is not a reason for the runtime message to be Portuguese too.
848
+ */
849
+ const SECRET_BLOCK_MESSAGE =
850
+ '[plugin-v2] Blocked: high-confidence secret detected in tool input — see .pantheon/logs/hooks.log'
851
+
852
+ /**
853
+ * Message thrown when the secret scanner cannot produce a trustworthy verdict —
854
+ * the script is missing/unexecutable, the child timed out or was killed, the
855
+ * payload could not be serialized, the report was malformed, or the exit status
856
+ * was unexpected.
857
+ *
858
+ * This is a deliberate fail-CLOSED denial. The previous handler blocked only
859
+ * `code === 2` and treated every other outcome as advisory — but the runner
860
+ * reports a missing script, a spawn failure and a payload-serialization failure
861
+ * as `code: 1` too (src/plugins/hook-runner.ts), so a broken scanner read as a
862
+ * clean advisory and the tool ran, silently disabling the only hard secret block
863
+ * on the V2 surface.
864
+ *
865
+ * English and user-facing, like {@link SECRET_BLOCK_MESSAGE}. It carries no
866
+ * scanner output on purpose: a scan that failed mid-flight may still be holding
867
+ * the raw payload, and the denial must not become the leak it exists to prevent.
868
+ */
869
+ const SECRET_SCAN_UNAVAILABLE_MESSAGE =
870
+ '[plugin-v2] Blocked: the secret scanner could not complete, so this tool call was denied (fail-closed) — see .pantheon/logs/hooks.log'
871
+
872
+ /**
873
+ * Append one masked, ISO-stamped line to .pantheon/logs/hooks.log under the
874
+ * working directory, and return whether it landed.
875
+ *
876
+ * V1's `reportFailure` also writes to `client.app.log` and shows a TUI toast.
877
+ * The V2 `PluginContext` exposes NEITHER (it is
878
+ * `{options, agent, aisdk, catalog, command, integration, plugin, reference,
879
+ * skill}` — no `client`, no `directory`), so this module has only the file
880
+ * channel. That is a real reduction in observability versus V1, stated here
881
+ * rather than implied. Best-effort by contract: a logging failure must never
882
+ * take down the session.
883
+ */
884
+ function appendV2HookLog(line: string): boolean {
885
+ const masked = maskSecret(line)
886
+ if (masked === '') return false
887
+ const stamp = new Date().toISOString()
888
+ // Multi-line scanner stderr would otherwise produce continuation lines with
889
+ // no timestamp (V1's P0-5 fix) — one message becomes N timestamped lines.
890
+ const body = masked
891
+ .split('\n')
892
+ .map((part) => part.trim())
893
+ .filter((part) => part !== '')
894
+ .map((part) => `[${stamp}] ${part}`)
895
+ .join('\n')
896
+ if (body === '') return false
897
+ try {
898
+ const dir = path.join(process.cwd(), '.pantheon', 'logs')
899
+ mkdirSync(dir, { recursive: true })
900
+ appendFileSync(path.join(dir, 'hooks.log'), `${body}\n`, 'utf8')
901
+ return true
902
+ } catch {
903
+ return false
904
+ }
905
+ }
906
+
907
+ // ─── BENCH Hook Timing (beta-10 latency slice, Phase 0) ────────────────
908
+
909
+ /**
910
+ * BENCH INSTRUMENTATION — observation only, zero behavior change.
911
+ *
912
+ * The beta-10 latency slice measures the V2 tool hook chain. These helpers
913
+ * record the start/end of `execute.before` and `execute.after` per tool call
914
+ * and append one single-line `[bench]` entry per hook end to the EXISTING
915
+ * hooks.log channel (same appendV2HookLog path as every other line: masked,
916
+ * stamped, best-effort, never throwing). The lines are consumed by
917
+ * bench/lib/hooklog.mjs.
918
+ *
919
+ * Deliberate properties:
920
+ * - the log call happens in `finally`, so denied calls (read-only guard,
921
+ * secret block) are timed too, and the denial still propagates unchanged
922
+ * - the join key is the host callID (`event.id`); a TTL-bounded map prunes
923
+ * stale entries so a background/never-after call cannot leak memory
924
+ * - `since_before_ms=unavailable` when no before record exists — the bench
925
+ * contract never lets "unavailable" be encoded as 0
926
+ */
927
+
928
+ /** Entries older than this are pruned (a task tool result may never fire after). */
929
+ const BENCH_TIMING_TTL_MS = 5 * 60_000
930
+ /** Hard cap — mirrors the V1 delegationStarts bound. */
931
+ const BENCH_TIMING_MAX = 256
932
+
933
+ const benchTimings = new Map<string, number>()
934
+
935
+ function pruneBenchTimings(now: number): void {
936
+ for (const [callID, startedAt] of benchTimings) {
937
+ if (now - startedAt > BENCH_TIMING_TTL_MS) benchTimings.delete(callID)
938
+ }
939
+ while (benchTimings.size >= BENCH_TIMING_MAX) {
940
+ let oldestID: string | null = null
941
+ let oldestAt = Number.POSITIVE_INFINITY
942
+ for (const [id, at] of benchTimings) {
943
+ if (at < oldestAt) {
944
+ oldestAt = at
945
+ oldestID = id
946
+ }
947
+ }
948
+ if (oldestID === null) break
949
+ benchTimings.delete(oldestID)
950
+ }
951
+ }
952
+
953
+ /** Read `{tool, id}` off a V2 tool event without assuming its exact shape. */
954
+ function benchEventIdentity(event: unknown): { tool: string; callID: string } {
955
+ if (event === null || typeof event !== 'object') return { tool: '', callID: '' }
956
+ const payload = event as { tool?: unknown; id?: unknown }
957
+ return {
958
+ tool: typeof payload.tool === 'string' ? payload.tool : '',
959
+ callID: typeof payload.id === 'string' ? payload.id : '',
960
+ }
961
+ }
962
+
963
+ /**
964
+ * Append one `[bench]` timing line for a finished hook phase. Best-effort and
965
+ * exception-free by construction (appendV2HookLog never throws).
966
+ */
967
+ function logBenchHookTiming(phase: 'before' | 'after', event: unknown, startedAt: number): void {
968
+ try {
969
+ const { tool, callID } = benchEventIdentity(event)
970
+ const endedAt = Date.now()
971
+ if (phase === 'before') {
972
+ if (callID !== '') {
973
+ benchTimings.set(callID, startedAt)
974
+ pruneBenchTimings(startedAt)
975
+ }
976
+ appendV2HookLog(
977
+ `[bench] tool.execute.before end tool=${tool} callID=${callID} ` +
978
+ `started_at=${new Date(startedAt).toISOString()} duration_ms=${endedAt - startedAt}`,
979
+ )
980
+ return
981
+ }
982
+ const beforeAt = callID !== '' ? benchTimings.get(callID) : undefined
983
+ if (beforeAt !== undefined) benchTimings.delete(callID)
984
+ const sinceBefore = beforeAt === undefined ? 'unavailable' : String(endedAt - beforeAt)
985
+ appendV2HookLog(
986
+ `[bench] tool.execute.after end tool=${tool} callID=${callID} ` +
987
+ `duration_ms=${endedAt - startedAt} since_before_ms=${sinceBefore}`,
988
+ )
989
+ } catch {
990
+ // Instrumentation must never affect the hook chain.
991
+ }
992
+ }
993
+
994
+ // ─── V2 Secret-Scan Verdict ─────────────────────────────────────────────
995
+
996
+ /**
997
+ * The scanner's own report marker, written to stderr on every non-clean run
998
+ * (exit 1 advisory, exit 2 block). It is the discriminator between a genuine
999
+ * low-confidence advisory and a runner infrastructure failure, BOTH of which
1000
+ * surface as `code === 1`: the runner's own failures (missing/unexecutable
1001
+ * script, spawn error, payload-serialization error) resolve a structured
1002
+ * `HookResult` with `code: 1` and no scan report, while the real scanner's
1003
+ * advisory always carries this marker. Requiring it means a scanner that stops
1004
+ * reporting fails toward a denial, never toward a silent allow.
1005
+ */
1006
+ const SECRET_SCAN_MARKER = '[SECRET SCAN]'
1007
+
1008
+ /** Sanitized failure classes — logged in place of raw scanner output. */
1009
+ type SecretScanFailureReason =
1010
+ | 'scanner-timeout'
1011
+ | 'scanner-signal'
1012
+ | 'scanner-spawn-failure'
1013
+ | 'scanner-serialization-failure'
1014
+ | 'scanner-malformed-output'
1015
+ | 'scanner-unexpected-exit'
1016
+
1017
+ type SecretScanDecision =
1018
+ | { action: 'allow' }
1019
+ | { action: 'advisory' }
1020
+ | { action: 'block' }
1021
+ | { action: 'fail-closed'; reason: SecretScanFailureReason }
1022
+
1023
+ /**
1024
+ * The runner's own infrastructure-failure messages (see hook-runner.ts), matched
1025
+ * at the start of stderr where the runner is the only writer. Matching a prefix
1026
+ * rather than a substring keeps a scan report that merely happens to quote one
1027
+ * of these phrases from flipping a valid advisory into a denial.
1028
+ */
1029
+ const SCANNER_INFRA_FAILURES: ReadonlyArray<[string, SecretScanFailureReason]> = [
1030
+ ['payload serialization failed:', 'scanner-serialization-failure'],
1031
+ ['spawn failed:', 'scanner-spawn-failure'],
1032
+ ['hook script failed to spawn:', 'scanner-spawn-failure'],
1033
+ ]
1034
+
1035
+ /**
1036
+ * Classify one secret-scan {@link HookResult} into an enforcement decision.
1037
+ *
1038
+ * fail-closed by construction: `allow` and `advisory` are reachable only from a
1039
+ * result that is unambiguously a completed scanner run. Everything else —
1040
+ * timeout, signal, spawn/serialization failure, missing report, unknown or
1041
+ * unexpected exit status — denies. `advisory` additionally requires the
1042
+ * scanner's own report marker, which is what separates an exit-1 advisory from
1043
+ * the runner's exit-1 infrastructure failure.
1044
+ *
1045
+ * `block` covers the high-confidence match (exit 2); the caller turns it into
1046
+ * the user-facing block.
1047
+ */
1048
+ function evaluateSecretScanResult(scan: HookResult): SecretScanDecision {
1049
+ if (scan.timedOut) return { action: 'fail-closed', reason: 'scanner-timeout' }
1050
+ if (scan.signal !== null) return { action: 'fail-closed', reason: 'scanner-signal' }
1051
+ if (!Number.isInteger(scan.code)) {
1052
+ return { action: 'fail-closed', reason: 'scanner-malformed-output' }
1053
+ }
1054
+ for (const [signature, reason] of SCANNER_INFRA_FAILURES) {
1055
+ if (scan.stderr.startsWith(signature)) return { action: 'fail-closed', reason }
1056
+ }
1057
+ if (scan.code === 0) return { action: 'allow' }
1058
+ if (scan.code === 2) return { action: 'block' }
1059
+ if (scan.code === 1) {
1060
+ return scan.stderr.includes(SECRET_SCAN_MARKER)
1061
+ ? { action: 'advisory' }
1062
+ : { action: 'fail-closed', reason: 'scanner-malformed-output' }
1063
+ }
1064
+ return { action: 'fail-closed', reason: 'scanner-unexpected-exit' }
1065
+ }
1066
+
1067
+ /** Invocation of scripts/hooks/scan-secrets.sh used by the tool guard. */
1068
+ type SecretScanRunner = (payload: HookPayload) => Promise<HookResult>
1069
+
1070
+ const defaultSecretScanRunner: SecretScanRunner = (payload) => runHook('scan-secrets.sh', payload)
1071
+
1072
+ let secretScanRunner: SecretScanRunner = defaultSecretScanRunner
1073
+
1074
+ /**
1075
+ * Test seam — override (or, with `null`, restore) the V2 secret-scan runner.
1076
+ *
1077
+ * Exported only for tests/pantheon/plugin-v2-contract.test.ts, which injects
1078
+ * structured {@link HookResult}s (missing scanner, timeout, malformed output)
1079
+ * that the real script cannot be made to produce on demand. There is no
1080
+ * production caller; the shipped handler always uses `defaultSecretScanRunner`.
1081
+ */
1082
+ export function setV2SecretScanRunnerForTests(runner: SecretScanRunner | null): void {
1083
+ secretScanRunner = runner ?? defaultSecretScanRunner
1084
+ }
1085
+
494
1086
  // ─── V2 Tool Hooks ──────────────────────────────────────────────────────
495
1087
 
1088
+ /**
1089
+ * The `tool.execute.before` event as the host dispatches it (opencode 2.0.22).
1090
+ *
1091
+ * Transcribed from the host's tool service:
1092
+ *
1093
+ * trigger("tool", "execute.before", { tool, sessionID, agent, messageID, id, input })
1094
+ *
1095
+ * Two shape differences from V1's positional `(input, output)` pair, both
1096
+ * handled by {@link adaptV2ExecuteBeforeEvent}: the host passes ONE payload
1097
+ * object, and the tool arguments live on `input` where V1 called them
1098
+ * `output.args`.
1099
+ *
1100
+ * `agent` is a required field of the host's tool context
1101
+ * (`ToolInvocation.context = { sessionID, agent, messageID, id }`), so the
1102
+ * active agent arrives on the very event the enforcement guard needs. V1 had to
1103
+ * learn it from a separate `chat.params` hook and keep a session→agent map; V2
1104
+ * does not have to, and neither does this module.
1105
+ */
1106
+ interface V2ToolExecuteBeforeEvent {
1107
+ tool?: unknown
1108
+ sessionID?: unknown
1109
+ messageID?: unknown
1110
+ agent?: unknown
1111
+ id?: unknown
1112
+ input?: unknown
1113
+ }
1114
+
1115
+ /**
1116
+ * Read-only enforcement for the V2 tool surface.
1117
+ *
1118
+ * The same factory, the same `DEFAULT_BLOCKED_TOOLS` and the same read-only set
1119
+ * that V1 uses, so the two surfaces cannot drift on what counts as a mutating
1120
+ * tool — `hashline_edit` and `pantheon_model` are denied here for exactly the
1121
+ * reason they are denied in V1.
1122
+ *
1123
+ * Deliberately NOT passed `getSessionAgent` / `isRootSession` / `isChildSession`.
1124
+ * There are TWO distinct reasons, and conflating them is what produced a wrong
1125
+ * causal chain here once already — keep them apart:
1126
+ *
1127
+ * 1. MISSING `getSessionAgent` (wiring). This is what would deny every
1128
+ * `task()` call. V2 learns the active agent from the `execute.before` event
1129
+ * itself and keeps no session→agent map, so there is no lookup to hand the
1130
+ * guard. The guard would call `isDelegationAllowed(undefined, target)`,
1131
+ * which returns `false`, and throw "caller agent is unavailable" — for
1132
+ * every caller, including Zeus. Note what is NOT happening: an unseeded
1133
+ * `isRootSession` would *pass* the root gate (`isRoot` reports `true` for an
1134
+ * unknown session), so the hierarchy is not what denies here.
1135
+ *
1136
+ * 2. UNWIRED HIERARCHY (design). `SessionHierarchyRegistry` is what gates
1137
+ * exactly two things, and nothing else: the depth-2 child deny
1138
+ * (`isChildSession`) and the root-session gate (`isRootSession`). Left
1139
+ * unseeded, `isChild` is `false` for every session, so depth-2 would never
1140
+ * fire on a genuine child — a hierarchy that cannot tell a child from a root
1141
+ * is not a trustworthy input to either check.
1142
+ *
1143
+ * "Unwired", not "impossible": the V2 `session.created` event carries
1144
+ * `properties.info: Session` including `parentID?`, the same field V1 seeds
1145
+ * from on the same event, so one line in `onSessionCreated` would seed live
1146
+ * sessions (see its docstring). What has no V2 equivalent is V1's SECOND
1147
+ * source, the `client.session.list()` startup seed — V2's `PluginContext`
1148
+ * exposes no `client`. Whether a live 2.0.22 host populates `info.parentID`
1149
+ * is UNVERIFIED; no canary reads an event payload. So the correct summary is
1150
+ * "seeded for nothing", not "unseedable", and the guard's omission is right
1151
+ * today for the weaker reason that nothing seeds it at all.
1152
+ *
1153
+ * Omitting all three skips the branch wholesale: the guard only enters it when
1154
+ * `options?.isRootSession !== undefined`. `task` is still denied inside a
1155
+ * read-only session via the blocked-tool list, so depth-2 holds for
1156
+ * apollo/gaia; the caller/target matrix itself is NOT enforced on V2. That is
1157
+ * a known gap, not a covered case.
1158
+ */
1159
+ const v2EnforcementGuard = createEnforcementGuard({
1160
+ getReadOnlySessions: () => readOnlyRegistry.sessionIDs(),
1161
+ })
1162
+
1163
+ /**
1164
+ * Project a V2 `execute.before` event onto the two-argument shape
1165
+ * `createEnforcementGuard` was built for.
1166
+ *
1167
+ * `tool` is the host's registration id, computed as
1168
+ * `options.namespace === undefined ? name : `${namespace}_${name}``. Pantheon
1169
+ * declares a namespace for catalog grouping but sets `options.namespace` on no
1170
+ * tool, so the id is the bare name (`hashline_edit`) and the blocked-tool match
1171
+ * fires. A tool that opted into `options.namespace` would gain a prefix and stop
1172
+ * matching — the integration test in tests/pantheon/plugin-v2-contract.test.ts
1173
+ * drives the real registration path and asserts on the registered names, so
1174
+ * that drift fails a test instead of silently disabling enforcement.
1175
+ *
1176
+ * A missing `sessionID` maps to the empty key: the registry lookup then matches
1177
+ * the event's own (absent) identity, so a blocked tool in a read-only session is
1178
+ * still denied. Fail-closed on the one axis that matters.
1179
+ */
1180
+ function adaptV2ExecuteBeforeEvent(event: unknown): {
1181
+ input: ToolExecuteBeforeInput
1182
+ output: ToolExecuteBeforeOutput
1183
+ agent: unknown
1184
+ } {
1185
+ const payload = (event ?? {}) as V2ToolExecuteBeforeEvent
1186
+ return {
1187
+ input: {
1188
+ tool: typeof payload.tool === 'string' ? payload.tool : '',
1189
+ sessionID: typeof payload.sessionID === 'string' ? payload.sessionID : '',
1190
+ callID: typeof payload.id === 'string' ? payload.id : '',
1191
+ },
1192
+ output: { args: payload.input },
1193
+ agent: payload.agent,
1194
+ }
1195
+ }
1196
+
496
1197
  /**
497
1198
  * Register tool execution hooks via V2 ctx.tool.hook.
498
1199
  *
@@ -502,43 +1203,142 @@ async function registerV2SessionHooks(context: PluginContext): Promise<boolean>
502
1203
  *
503
1204
  * Returns true if any hooks were registered.
504
1205
  */
505
- async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
506
- const toolCtx = (context as unknown as Record<string, unknown>).tool as
507
- | {
508
- hook?: (name: string, handler: (event: unknown) => void | Promise<void>) => Promise<unknown>
509
- }
510
- | undefined
1206
+ async function registerV2ToolHooks(
1207
+ context: PluginContext,
1208
+ subagentOrigins: V2SubagentOriginRegistry,
1209
+ boundedReadOrigins?: V2BoundedReadOriginRegistry,
1210
+ ): Promise<{ registered: boolean; beforeRegistered: boolean }> {
1211
+ const toolCtx = hostDomains(context).tool
511
1212
 
512
1213
  if (!toolCtx?.hook) {
513
- return false
1214
+ return { registered: false, beforeRegistered: false }
514
1215
  }
515
1216
 
516
1217
  let registered = false
1218
+ let beforeRegistered = false
517
1219
 
518
1220
  try {
519
- // "execute.before" — read-only enforcement + command normalization
520
- await toolCtx.hook('execute.before', (_event: unknown) => {
521
- // Enforcement guard runs via V1 bridge when infrastructure is available.
522
- // The V2 hook is a registration point; actual enforcement is wired
523
- // through the V1 tool.execute.before hook.
524
- const bridge = resolveBridge()
525
- if (bridge?.board != null) {
526
- // Bridge available: V1 enforcement guard handles read-only + command normalization.
527
- // No additional V2-side enforcement needed.
1221
+ // "execute.before" — read-only enforcement. Throwing from this handler
1222
+ // DENIES the tool call and the host surfaces the message to the session,
1223
+ // which is how the guard blocks a mutating tool in a read-only session.
1224
+ //
1225
+ // This is the enforcement point for the V2 surface, not a delegation to
1226
+ // V1: `src/plugin.ts` is not loaded when only `plugin-v2` is configured
1227
+ // (opencode.json `plugins`), so the V1 `tool.execute.before` hook does not
1228
+ // exist in that process. The guard below is instantiated here and consulted
1229
+ // here; without it the tools registered by registerV2Tools would be a live
1230
+ // write path in every session, including read-only ones.
1231
+ await toolCtx.hook('execute.before', async (event: unknown) => {
1232
+ const benchStartedAt = Date.now()
1233
+ const { input, output, agent } = adaptV2ExecuteBeforeEvent(event)
1234
+ try {
1235
+ // V2's equivalent of V1's `chat.params` trigger: the host puts the active
1236
+ // agent on the event itself, so registering here needs no separate hook
1237
+ // and no session→agent map. A non-read-only (or absent) agent revokes the
1238
+ // registration, which also covers an in-session agent switch.
1239
+ syncReadOnlySession(readOnlyRegistry, input.sessionID, agent)
1240
+ // Deliberately OUTSIDE the try/catch below: a read-only denial must keep
1241
+ // propagating to the host, not be swallowed as a scanner failure.
1242
+ await v2EnforcementGuard(input, output)
1243
+
1244
+ // High-confidence secret scan (scripts/hooks/scan-secrets.sh). This is the
1245
+ // ONLY hard block on the V2 surface, and it exists here — rather than via
1246
+ // `src/plugins/pantheon-hooks.ts` — because that module is a separate
1247
+ // plugin that is never loaded on a V2-only install: opencode.json
1248
+ // `plugins` declares only `src/plugin-v2`. V1's scan-secrets block was
1249
+ // therefore inert by construction, not by misconfiguration.
1250
+ //
1251
+ // exit 0 → allow. exit 1 with a valid scanner report → advisory, logged
1252
+ // only. exit 2 (high-confidence match) → deny. A scanner that does NOT
1253
+ // produce a trustworthy verdict — missing/unexecutable script, timeout,
1254
+ // killed child, spawn/serialization failure, malformed or unexpected
1255
+ // output — is fail-CLOSED: this pending tool call is denied, the session
1256
+ // is not. (runHook resolves a structured result instead of rejecting, so
1257
+ // the exit-code-only reading below cannot lean on an exception.)
1258
+ let blockError: Error | null = null
1259
+ try {
1260
+ const payload: HookPayload = {
1261
+ tool_name: input.tool,
1262
+ tool_input: output.args ?? {},
1263
+ agent_id: typeof agent === 'string' ? agent : '',
1264
+ session_id: input.sessionID,
1265
+ }
1266
+ const scan = await secretScanRunner(payload)
1267
+ const decision = evaluateSecretScanResult(scan)
1268
+ if (decision.action === 'block') {
1269
+ appendV2HookLog(SECRET_BLOCK_MESSAGE)
1270
+ appendV2HookLog(scan.stderr)
1271
+ blockError = new Error(SECRET_BLOCK_MESSAGE)
1272
+ } else if (decision.action === 'advisory') {
1273
+ // Advisory: log only, never block.
1274
+ appendV2HookLog(scan.stderr)
1275
+ } else if (decision.action === 'fail-closed') {
1276
+ // Sanitized failure category only — never the scanner's raw stderr,
1277
+ // which on a failure path is unverified and may carry the very
1278
+ // payload the scan exists to keep out of logs.
1279
+ appendV2HookLog(`${SECRET_SCAN_UNAVAILABLE_MESSAGE} (${decision.reason})`)
1280
+ blockError = new Error(SECRET_SCAN_UNAVAILABLE_MESSAGE)
1281
+ }
1282
+ } catch {
1283
+ // The runner is contracted to resolve a structured result, so this only
1284
+ // guards an unexpected throw (including a future change to the runner).
1285
+ // A scanner that cannot run is fail-CLOSED: deny this call rather than
1286
+ // let it through with no scan at all.
1287
+ appendV2HookLog(SECRET_SCAN_UNAVAILABLE_MESSAGE)
1288
+ blockError = new Error(SECRET_SCAN_UNAVAILABLE_MESSAGE)
1289
+ }
1290
+ if (blockError !== null) throw blockError
1291
+ const boundedReadOrigin = boundedReadOrigins?.capture(event)
1292
+ const eventTool = (event as V2ToolExecuteBeforeEvent | null)?.tool
1293
+ if (eventTool === BOUNDED_READ_TOOL && boundedReadOrigin === undefined) {
1294
+ throw new Error('BOUND_READ_UNAUTHORIZED')
1295
+ }
1296
+ if (boundedReadOrigin !== undefined) {
1297
+ writeV2AdmissionProof('tool.execute.before bounded-read', {
1298
+ sessionID: boundedReadOrigin.sessionID,
1299
+ messageID: boundedReadOrigin.messageID,
1300
+ callID: boundedReadOrigin.callID,
1301
+ agent: boundedReadOrigin.agent,
1302
+ })
1303
+ }
1304
+ const origin = subagentOrigins.capture(event)
1305
+ if (origin !== undefined) {
1306
+ writeV2AdmissionProof('tool.execute.before', { ...origin })
1307
+ }
1308
+ } finally {
1309
+ // BENCH instrumentation — observation only; runs on the deny paths too.
1310
+ logBenchHookTiming('before', event, benchStartedAt)
528
1311
  }
529
1312
  })
530
1313
  registered = true
1314
+ beforeRegistered = true
531
1315
  } catch {
532
1316
  // Tool before-hook not supported
533
1317
  }
534
1318
 
535
1319
  try {
536
- // "execute.after" — hashline read enhance + context sandbox
537
- await toolCtx.hook('execute.after', (_event: unknown) => {
538
- // Read enhancer + sandbox run via V1 bridge.
539
- const bridge = resolveBridge()
540
- if (bridge?.visionHandler != null) {
541
- // Bridge available: V1 vision handler + read enhancer handle post-execution.
1320
+ // "execute.after" — the V1 parity chain (task-result-guard →
1321
+ // context-sandbox → read-enhancer) rebuilt on the V2 event shape.
1322
+ //
1323
+ // This IS behaviour, not a registration point: on a V2-only install the V1
1324
+ // `pantheon-hooks` plugin is not loaded, so this handler is the only thing
1325
+ // that truncates oversized output and tags `read` with hashline refs.
1326
+ // The sandbox config comes from `ctx.options.context_sandbox` (the V2 copy
1327
+ // the installer writes — V2.0.25 drops the V1 top-level key), resolved with
1328
+ // the same resolver V1 uses.
1329
+ const v2ExecuteAfter = createV2ExecuteAfter({
1330
+ sandbox: resolveV2SandboxConfig(context.options),
1331
+ })
1332
+ await toolCtx.hook('execute.after', async (event: unknown) => {
1333
+ const benchStartedAt = Date.now()
1334
+ try {
1335
+ subagentOrigins.forget(event)
1336
+ boundedReadOrigins?.forget(event)
1337
+ await v2ExecuteAfter(event)
1338
+ } finally {
1339
+ // BENCH instrumentation — observation only; the awaited call means the
1340
+ // duration covers the whole parity chain, not just its dispatch.
1341
+ logBenchHookTiming('after', event, benchStartedAt)
542
1342
  }
543
1343
  })
544
1344
  registered = true
@@ -546,7 +1346,7 @@ async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
546
1346
  // Tool after-hook not supported
547
1347
  }
548
1348
 
549
- return registered
1349
+ return { registered, beforeRegistered }
550
1350
  }
551
1351
 
552
1352
  // ─── V2 Permission Hook ─────────────────────────────────────────────────
@@ -555,16 +1355,18 @@ async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
555
1355
  * Register permission hook via V2 ctx.permission.hook.
556
1356
  *
557
1357
  * The V2 permission.hook API (when available) provides:
558
- * ctx.permission.hook("evaluate", handler) — custom permission logic
1358
+ * ctx.permission.hook("evaluate", handler) — host permission evaluation
1359
+ * The handler receives one mutable PermissionEvaluation event; decisions are
1360
+ * written to event.effect/event.message (there is no separate V1-style output).
559
1361
  *
560
1362
  * Returns true if the hook was registered.
561
1363
  */
562
- async function registerV2PermissionHook(context: PluginContext): Promise<boolean> {
563
- const permCtx = (context as unknown as Record<string, unknown>).permission as
564
- | {
565
- hook?: (name: string, handler: (event: unknown) => void | Promise<void>) => Promise<unknown>
566
- }
567
- | undefined
1364
+ async function registerV2PermissionHook(
1365
+ context: PluginContext,
1366
+ subagentOrigins: V2SubagentOriginRegistry,
1367
+ ): Promise<boolean> {
1368
+ const domains = hostDomains(context)
1369
+ const permCtx = domains.permission
568
1370
 
569
1371
  if (!permCtx?.hook) {
570
1372
  markUnsupported('permission-hook')
@@ -572,9 +1374,117 @@ async function registerV2PermissionHook(context: PluginContext): Promise<boolean
572
1374
  }
573
1375
 
574
1376
  try {
575
- await permCtx.hook('evaluate', (_event: unknown) => {
576
- // Custom permission logic — Zeus read guard + read-only enforcement.
577
- // Wired through V1 tool.execute.before when infrastructure is available.
1377
+ await permCtx.hook('evaluate', async (rawEvent: unknown) => {
1378
+ const event = rawEvent as V2PermissionEvent | null
1379
+ const effectBefore = event?.effect
1380
+ let origin: ReturnType<V2SubagentOriginRegistry['consume']>
1381
+ let decision: string | null = null
1382
+ let callerSessionParentID: unknown
1383
+ const writeProof = (): void => {
1384
+ const proofPath = process.env.PANTHEON_HOOK_CANARY_PERMISSION_PROOF
1385
+ if (!proofPath || !event || !Array.isArray(event.resources)) return
1386
+ try {
1387
+ appendFileSync(
1388
+ proofPath,
1389
+ `permission.evaluate ${JSON.stringify({
1390
+ action: event.action,
1391
+ resources: event.resources,
1392
+ agent: event.agent,
1393
+ sessionID: event.sessionID,
1394
+ callerSessionParentID: callerSessionParentID ?? null,
1395
+ source: event.source,
1396
+ matchedOrigin: origin ?? null,
1397
+ decision,
1398
+ effect: event.effect,
1399
+ message: event.message,
1400
+ eventFields: ['action', 'resources', 'agent', 'sessionID', 'source', 'effect'].filter(
1401
+ (key) => Object.hasOwn(event, key),
1402
+ ),
1403
+ effectBefore: effectBefore ?? null,
1404
+ effectAfter: event.effect ?? null,
1405
+ })}\n`,
1406
+ )
1407
+ } catch {
1408
+ // Instrumentation is best-effort and must not affect permission flow.
1409
+ }
1410
+ }
1411
+ const deny = (reason: string): void => {
1412
+ if (event === null || typeof event !== 'object') {
1413
+ throw new Error(
1414
+ `permission event is unavailable; denying V2 permission evaluation (${reason})`,
1415
+ )
1416
+ }
1417
+ event.effect = 'deny'
1418
+ event.message = `Pantheon delegation denied: ${reason}`
1419
+ if (event.effect !== 'deny') {
1420
+ throw new Error(
1421
+ `permission event is not mutable; denying V2 permission evaluation (${reason})`,
1422
+ )
1423
+ }
1424
+ writeProof()
1425
+ }
1426
+
1427
+ if (event?.effect === 'deny') {
1428
+ writeProof()
1429
+ return // explicit host deny is final
1430
+ }
1431
+ if (event == null) {
1432
+ deny('permission event is missing')
1433
+ return
1434
+ }
1435
+ if (!Array.isArray(event.resources) || event.resources.length === 0) {
1436
+ deny('permission resources are missing or malformed')
1437
+ return
1438
+ }
1439
+ const targets = event.resources.map(v2ResourceTarget)
1440
+ if (targets.some((target) => target === undefined)) {
1441
+ deny('permission resource target is malformed')
1442
+ return
1443
+ }
1444
+ const hasPantheonTarget = targets.some((target) => PANTHEON_AGENTS.has(target ?? ''))
1445
+ let session: AuthoritativeSession | undefined
1446
+ if (hasPantheonTarget) {
1447
+ origin = subagentOrigins.consume(event)
1448
+ if (origin === undefined) {
1449
+ deny('matching host subagent tool origin is missing or stale')
1450
+ return
1451
+ }
1452
+ const sessionID = typeof event.sessionID === 'string' ? event.sessionID.trim() : ''
1453
+ if (!sessionID) {
1454
+ deny('caller session ID is missing')
1455
+ return
1456
+ }
1457
+
1458
+ const sessionDomain = domains.session
1459
+ if (!sessionDomain || typeof sessionDomain.get !== 'function') {
1460
+ deny('session lookup API is unavailable')
1461
+ return
1462
+ }
1463
+
1464
+ let retrieved: unknown
1465
+ try {
1466
+ retrieved = await sessionDomain.get({ sessionID })
1467
+ } catch {
1468
+ deny('caller session lookup failed')
1469
+ return
1470
+ }
1471
+ if (retrieved === null || retrieved === undefined) {
1472
+ deny('caller session was not found')
1473
+ return
1474
+ }
1475
+ if (typeof retrieved !== 'object' || Array.isArray(retrieved)) {
1476
+ deny('caller session lookup returned invalid data')
1477
+ return
1478
+ }
1479
+ session = retrieved as AuthoritativeSession
1480
+ callerSessionParentID = session.parentID
1481
+ }
1482
+
1483
+ // Native-only resources remain under host policy. If a Pantheon target
1484
+ // appears anywhere, the shared policy evaluates the complete resource set.
1485
+ decision = evaluateV2Delegation(event, session, event.effect, origin)
1486
+ if (decision === 'deny') deny('caller or target is not allowed by delegation policy')
1487
+ else writeProof()
578
1488
  })
579
1489
  return true
580
1490
  } catch {
@@ -586,7 +1496,15 @@ async function registerV2PermissionHook(context: PluginContext): Promise<boolean
586
1496
  // ─── Tool Definition Factory ─────────────────────────────────────────────
587
1497
 
588
1498
  /**
589
- * Internal types for V2 tool draft.
1499
+ * Minimal structural view of the V2 tool draft.
1500
+ *
1501
+ * `output` is REQUIRED. The host (opencode 2.0.22) enforces a biconditional on
1502
+ * a tool's declared `output` and the value its `execute` resolves to:
1503
+ * `def.output === undefined` together with `'output' in result` throws
1504
+ * "Tool result declared output without an output schema", and a declared
1505
+ * `output` whose result omits the key throws "Tool did not return its
1506
+ * declared output". A missing declaration therefore fails every call, so the
1507
+ * field is mandatory here rather than optional.
590
1508
  */
591
1509
  interface V2ToolDraft {
592
1510
  namespace(config: { name: string; description: string }): void
@@ -594,91 +1512,12 @@ interface V2ToolDraft {
594
1512
  name: string
595
1513
  description: string
596
1514
  input: Record<string, unknown>
1515
+ output: Record<string, unknown>
597
1516
  execute: (input: Record<string, unknown>, context: unknown) => Promise<V2ToolResult>
598
1517
  }): void
599
1518
  }
600
1519
 
601
- /**
602
- * V2 tool result shape.
603
- *
604
- * The V1 SDK types `ToolResult` as `string | { output: string, ... }`, but
605
- * the V2 beta host reads a field off the resolved value (minified `Ce`) and
606
- * throws `Ce is not an Object` when a tool resolves to a bare string. Our
607
- * handlers are fail-open, so that host-side TypeError escapes instead of
608
- * being contained. Always resolve to the object shape — it satisfies both
609
- * the SDK union and the beta host.
610
- */
611
- export interface V2ToolResult {
612
- output: string
613
- }
614
-
615
- /**
616
- * A V2 tool definition ready for registration.
617
- */
618
- interface V2ToolDef {
619
- name: string
620
- description: string
621
- input: Record<string, unknown>
622
- execute: (input: Record<string, unknown>, context: unknown) => Promise<V2ToolResult>
623
- }
624
-
625
- /**
626
- * Create V2 tool definitions from the plugin context.
627
- *
628
- * When V1 infrastructure (BackgroundJobBoard, delegation client, etc.) is
629
- * available through the V2 context, tools are wired directly. Otherwise,
630
- * each tool returns an error message explaining the limitation.
631
- */
632
- function createV2ToolDefinitionsFromContext(_context: PluginContext): V2ToolDef[] {
633
- // Tool definitions are created lazily. When the full V1 infrastructure
634
- // is bridged through the V2 context, these can be wired to real implementations.
635
- // For now, they are structural definitions with placeholder execute functions
636
- // that explain the V2 limitation.
637
- const v2UnavailableMessage =
638
- 'This tool requires V1 infrastructure (BackgroundJobBoard, SDK client). ' +
639
- 'Use the V1 plugin (src/plugin.ts) for full tool support, or wire V1 ' +
640
- 'infrastructure through the V2 bridge (src/pantheon/v2-bridge.ts).'
641
-
642
- const toolNames = [
643
- 'hashline_edit',
644
- 'pantheon_goal_create',
645
- 'pantheon_goal_get',
646
- 'pantheon_goal_update',
647
- 'pantheon_cost',
648
- 'pantheon_model',
649
- ]
650
-
651
- const toolDescriptions: Record<string, string> = {
652
- hashline_edit:
653
- 'Edit a file anchored by hashline refs (LINE#TAG) instead of raw line numbers. ' +
654
- 'Ops: replace, append, prepend, delete. ALL refs validated against ORIGINAL file first.',
655
- pantheon_goal_create:
656
- 'Create the single active goal for this session. The goal loop then continues the ' +
657
- 'session automatically on idle until the goal is marked done.',
658
- pantheon_goal_get:
659
- 'Return the active goal for this session (status, objective, continuations).',
660
- pantheon_goal_update:
661
- 'Update the active goal: set status (pending/in_progress/done) and/or rewrite the objective. ' +
662
- 'Setting status to done stops all further continuations.',
663
- pantheon_cost:
664
- 'Report delegation cost + token usage by agent over the last N days, read from opencode.db.',
665
- pantheon_model:
666
- 'Show, set, or reset per-agent model overrides in active-preset.json (project or global).',
667
- }
668
-
669
- return toolNames.map((name) => ({
670
- name,
671
- description: toolDescriptions[name] ?? `Pantheon tool: ${name}`,
672
- input: { type: 'object' as const, properties: {} },
673
- // FOLLOW-UP: wire the real tool wrappers (costCommand/goalTools) once V1
674
- // infrastructure is resolvable in the V2 standalone context. Until then,
675
- // placeholders resolve to the object shape so the beta host never sees a
676
- // bare string.
677
- execute: async (_input: Record<string, unknown>, _context: unknown): Promise<V2ToolResult> => {
678
- return { output: `${name}: ${v2UnavailableMessage}` }
679
- },
680
- }))
681
- }
1520
+ export type { V2ToolDef, V2ToolResult } from './pantheon/v2-tools.ts'
682
1521
 
683
1522
  // ─── Plugin Definition ───────────────────────────────────────────────────
684
1523
 
@@ -686,6 +1525,83 @@ function createV2ToolDefinitionsFromContext(_context: PluginContext): V2ToolDef[
686
1525
  interface V2SessionEvent {
687
1526
  type: string
688
1527
  properties?: Record<string, unknown>
1528
+ data?: unknown
1529
+ }
1530
+
1531
+ /** Opt-in trace used by the isolated F4A canary; never logs prompts or inputs. */
1532
+ function writeV2AdmissionProof(label: string, detail: Record<string, unknown>): void {
1533
+ const proofPath = process.env.PANTHEON_HOOK_CANARY_PERMISSION_PROOF
1534
+ if (!proofPath) return
1535
+ try {
1536
+ appendFileSync(proofPath, `${label} ${JSON.stringify(detail)}\n`)
1537
+ } catch {
1538
+ // Proof is best-effort and must not affect admission behavior.
1539
+ }
1540
+ }
1541
+
1542
+ async function prepareV2BoundedRead(
1543
+ context: PluginContext,
1544
+ ): Promise<ReturnType<typeof createV2BoundedReadTool> | undefined> {
1545
+ const host = hostDomains(context)
1546
+ const directory = host.location?.directory
1547
+ const projectID = host.location?.project?.id
1548
+ const session = host.session
1549
+ const permission = host.permission
1550
+ if (
1551
+ typeof directory !== 'string' ||
1552
+ directory.trim() === '' ||
1553
+ typeof projectID !== 'string' ||
1554
+ projectID.trim() === '' ||
1555
+ typeof session?.get !== 'function' ||
1556
+ typeof permission?.assert !== 'function' ||
1557
+ typeof host.event?.subscribe !== 'function' ||
1558
+ typeof host.tool?.transform !== 'function' ||
1559
+ typeof host.tool?.hook !== 'function'
1560
+ ) {
1561
+ markUnsupported('bounded-read')
1562
+ return undefined
1563
+ }
1564
+
1565
+ const rootDirectory = await realpathAsync(directory).catch(() => undefined)
1566
+ if (rootDirectory === undefined) {
1567
+ markUnsupported('bounded-read')
1568
+ return undefined
1569
+ }
1570
+
1571
+ return createV2BoundedReadTool({
1572
+ rootDirectory,
1573
+ projectID,
1574
+ getSession: (sessionID) => {
1575
+ if (typeof session.get !== 'function') throw new Error('session lookup unavailable')
1576
+ return session.get.call(session, { sessionID })
1577
+ },
1578
+ assertPermission: (request) => {
1579
+ if (typeof permission.assert !== 'function') throw new Error('permission API unavailable')
1580
+ return permission.assert.call(permission, request)
1581
+ },
1582
+ scanContent: async ({ content, agent, sessionID, callID }) => {
1583
+ try {
1584
+ const scan = await secretScanRunner({
1585
+ tool_name: BOUNDED_READ_TOOL,
1586
+ tool_input: { content },
1587
+ agent_id: agent,
1588
+ session_id: sessionID,
1589
+ })
1590
+ const decision = evaluateSecretScanResult(scan)
1591
+ if (decision.action === 'allow') return 'allow'
1592
+ const reason = decision.action === 'fail-closed' ? decision.reason : 'content-match'
1593
+ appendV2HookLog(
1594
+ `[SECRET SCAN] bounded read withheld session=${sessionID} callID=${callID} reason=${reason}`,
1595
+ )
1596
+ return decision.action === 'fail-closed' ? 'unavailable' : 'deny'
1597
+ } catch {
1598
+ appendV2HookLog(
1599
+ `[SECRET SCAN] bounded read withheld session=${sessionID} callID=${callID} reason=scanner-failure`,
1600
+ )
1601
+ return 'unavailable'
1602
+ }
1603
+ },
1604
+ })
689
1605
  }
690
1606
 
691
1607
  /** Cleanup function type. */
@@ -699,17 +1615,21 @@ export const plugin = define({
699
1615
 
700
1616
  async setup(context: PluginContext): Promise<void> {
701
1617
  // ─── Phase 1: V2 Transforms (isolated per-domain, best-effort) ────
702
- // Each domain registers independently: a failure in one transform
703
- // (e.g. draft.list missing on beta 19192 → TypeError) must never
704
- // reject setup() nor prevent the other domains from registering.
1618
+ // Each transform registration is individually wrapped and settled via Promise.allSettled:
1619
+ // a rejection marks only that transform unsupported
1620
+ // and does not reject setup() or prevent later hook registrations.
705
1621
  // See issue #92.
1622
+ //
1623
+ // Pantheon's Phase 1/config transform registrations cover agent, command,
1624
+ // and reference; ctx.tool.transform is attempted separately in Phase 2.
1625
+ // ctx.integration and ctx.skill are present on a live 2.0.22 host and neither
1626
+ // is registered here — that is a Pantheon scope choice, not a host gap.
1627
+ // ctx.catalog is the one domain the host does not provide.
1628
+ // Phase 1 registers against the SDK-typed `context.agent` / `.command` /
1629
+ // `.reference` directly; the host domains the SDK omits are reached only
1630
+ // through `hostDomains` (see the module header).
706
1631
  const phase1Transforms: Array<{ feature: string; register: () => Promise<unknown> }> = [
707
1632
  { feature: 'agent-transform', register: () => context.agent.transform(transformAgents) },
708
- {
709
- feature: 'catalog-transform',
710
- register: () =>
711
- context.catalog.transform((draft) => transformCatalog(draft, context.options)),
712
- },
713
1633
  {
714
1634
  feature: 'command-transform',
715
1635
  register: () => context.command.transform(transformCommands),
@@ -718,7 +1638,6 @@ export const plugin = define({
718
1638
  feature: 'reference-transform',
719
1639
  register: () => context.reference.transform(transformReferences),
720
1640
  },
721
- { feature: 'skill-transform', register: () => context.skill.transform(transformSkills) },
722
1641
  ]
723
1642
  const phase1Results = await Promise.allSettled(
724
1643
  phase1Transforms.map((t) => Promise.resolve().then(() => t.register())),
@@ -731,49 +1650,79 @@ export const plugin = define({
731
1650
  }
732
1651
  })
733
1652
 
734
- // ─── Phase 2: V2 Tool Registration (best-effort) ─────────────────
735
- const toolsRegistered = await registerV2Tools(context).catch(() => false)
736
- if (!toolsRegistered) {
737
- markUnsupported('tool-transform')
738
- }
1653
+ const subagentOrigins = new V2SubagentOriginRegistry()
1654
+ const boundedRead = await prepareV2BoundedRead(context).catch(() => undefined)
1655
+ if (boundedRead === undefined) markUnsupported('bounded-read')
739
1656
 
740
- // ─── Phase 3: V2 Event Subscription (best-effort) ────────────────
741
- const eventCleanup = subscribeV2Events(context)
1657
+ // ─── Phase 2: V2 Event Subscription (best-effort) ────────────────
1658
+ const eventCleanup = subscribeV2Events(context, boundedRead?.clearSession)
742
1659
  if (eventCleanup) {
743
1660
  activeCleanups.push(eventCleanup)
744
1661
  } else {
745
1662
  markUnsupported('event-stream')
746
1663
  }
747
1664
 
748
- // ─── Phase 4: V2 Session Hooks (best-effort) ─────────────────────
1665
+ // ─── Phase 3: V2 Session Hooks (best-effort) ─────────────────────
749
1666
  const sessionHooksRegistered = await registerV2SessionHooks(context).catch(() => false)
750
1667
  if (!sessionHooksRegistered) {
751
1668
  markUnsupported('session-hooks')
752
1669
  }
753
1670
 
754
- // ─── Phase 5: V2 Tool Hooks (best-effort) ────────────────────────
755
- const toolHooksRegistered = await registerV2ToolHooks(context).catch(() => false)
756
- if (!toolHooksRegistered) {
1671
+ // ─── Phase 4: V2 Tool Hooks (best-effort) ────────────────────────
1672
+ const toolHookResult = await registerV2ToolHooks(
1673
+ context,
1674
+ subagentOrigins,
1675
+ boundedRead?.origins,
1676
+ ).catch(() => ({ registered: false, beforeRegistered: false }))
1677
+ if (!toolHookResult.registered) {
757
1678
  markUnsupported('tool-execute-hooks')
758
1679
  }
759
1680
 
1681
+ // ─── Phase 5: V2 Tool Registration (best-effort) ─────────────────
1682
+ const boundedReadDefinition = toolHookResult.beforeRegistered
1683
+ ? boundedRead?.definition
1684
+ : undefined
1685
+ if (boundedRead !== undefined && boundedReadDefinition === undefined) {
1686
+ markUnsupported('bounded-read')
1687
+ }
1688
+ const toolsRegistered = await registerV2Tools(context, boundedReadDefinition).catch(() => false)
1689
+ if (!toolsRegistered) markUnsupported('tool-transform')
1690
+
760
1691
  // ─── Phase 6: V2 Permission Hook (best-effort) ───────────────────
761
- await registerV2PermissionHook(context).catch(() => false)
1692
+ await registerV2PermissionHook(context, subagentOrigins).catch(() => false)
762
1693
 
763
1694
  // ─── Phase 7: V2 Compaction Hook (best-effort) ───────────────────
764
- const compactionCtx = (context as unknown as Record<string, unknown>).session as
765
- | {
766
- hook?: (
767
- name: string,
768
- handler: (event: unknown) => void | Promise<void>,
769
- ) => Promise<unknown>
770
- }
771
- | undefined
1695
+ const compactionCtx = hostDomains(context).session
772
1696
  if (compactionCtx?.hook) {
773
1697
  try {
774
- await compactionCtx.hook('compacting', (_event: unknown) => {
1698
+ // 2.0.16 `SessionHooks` key is `compaction` (the migration doc maps
1699
+ // `experimental.session.compacting` → `ctx.session.hook("compaction")`).
1700
+ // `compacting` is not a valid key: the registry accepts any string and
1701
+ // silently drops unknown names, so the old name was a permanent no-op.
1702
+ // Proven by the canary's negative control.
1703
+ await compactionCtx.hook('compaction', (_event: unknown) => {
775
1704
  // Compaction context build — injects active goals, pending todos,
776
- // and in-flight delegations. Wired through V1 infrastructure.
1705
+ // and in-flight delegations. No V2-side implementation: that build
1706
+ // lives in the V1 path, which is not loaded on a V2-only install.
1707
+ // This registration is a real hook (the canary below proves it
1708
+ // fires) with no V2 behaviour behind it.
1709
+ //
1710
+ // Opt-in lifetime proof for the host-backed hook canary
1711
+ // (tests/pantheon/plugin-v2-hook-canary.test.mjs, MODE=real). It is
1712
+ // inert unless PANTHEON_HOOK_CANARY_LIFETIME_PROOF is set, so
1713
+ // production behaviour is unchanged. This exists because a rename to
1714
+ // `compacting` was a silent no-op for an unknown length of time: the
1715
+ // canary can now observe that THIS registration fired.
1716
+ if (process.env.PANTHEON_HOOK_CANARY_LIFETIME_PROOF) {
1717
+ try {
1718
+ appendFileSync(
1719
+ process.env.PANTHEON_HOOK_CANARY_LIFETIME_PROOF,
1720
+ 'plugin-v2:session.hook:compaction\n',
1721
+ )
1722
+ } catch {
1723
+ // Proof is best-effort: never let instrumentation break the host.
1724
+ }
1725
+ }
777
1726
  })
778
1727
  } catch {
779
1728
  markUnsupported('compaction-hook')
@@ -802,7 +1751,14 @@ export function v2Dispose(): void {
802
1751
  }
803
1752
 
804
1753
  /**
805
- * Get the current list of unsupported V2 features (for diagnostics).
1754
+ * The current list of unsupported V2 features.
1755
+ *
1756
+ * Read by the installer and by `doctor` (both via the shared seed in
1757
+ * `src/pantheon/v2-unsupported.mjs`) so the markers a user is shown are the
1758
+ * same ones this returns, rather than a hand-copied duplicate.
1759
+ *
1760
+ * This returns the LIVE array: the shared seed plus whatever `markUnsupported`
1761
+ * appended while this process talked to a real host.
806
1762
  */
807
1763
  export function getUnsupportedFeatures(): readonly string[] {
808
1764
  return V2_UNSUPPORTED_FEATURES