pantheon-opencode 1.6.0-beta.1 → 1.6.0-beta.11

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