pantheon-opencode 1.6.0-beta.2 → 1.6.0-beta.3

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.
package/src/plugin-v2.ts CHANGED
@@ -2,10 +2,29 @@
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)
5
+ * - 3 self-sufficient tools via V2 tool.transform (hashline_edit,
6
+ * pantheon_cost, pantheon_model — see ./pantheon/v2-tools.ts; the goal tools
7
+ * need V1 infrastructure and are absent from this surface)
6
8
  * - 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
+ * - Session hooks (prompt, context, compaction)
10
+ * - A tool `execute.before` hook that ENFORCES read-only sessions: the agent
11
+ * arrives on the host's event, a read-only agent (apollo/gaia) registers its
12
+ * session, and the shared `createEnforcementGuard` throws to deny
13
+ * `hashline_edit` / `pantheon_model` / `edit` / `write` / `bash` / `task`.
14
+ * See `registerV2ToolHooks`.
15
+ * - The SAME `execute.before` hook runs scripts/hooks/scan-secrets.sh over the
16
+ * tool input and throws on exit 2 (high-confidence token match). This is the
17
+ * ONLY hard security block on the V2 surface. It is wired HERE, not delegated
18
+ * to `src/plugins/pantheon-hooks.ts`: opencode.json declares only
19
+ * `src/plugin-v2` in `plugins`, so the V1 plugin never loads on a V2-only
20
+ * install and V1's identical block was inert by construction.
21
+
22
+ * - A tool `execute.after` hook and a permission hook, registered as
23
+ * registration points with no V2-side behaviour
24
+ *
25
+ * Every tool registered through `draft.add` MUST declare `output`, and its
26
+ * `execute` MUST resolve to a value carrying `output`. The host enforces the
27
+ * two as a pair — see `V2ToolDraft` for the exact contract.
9
28
  *
10
29
  * The transitional SDK types used here expose these domains:
11
30
  * ctx.agent / ctx.command / ctx.model / ctx.reference / ctx.skill /
@@ -29,7 +48,8 @@
29
48
  * @module plugin-v2
30
49
  */
31
50
 
32
- import { appendFileSync } from 'node:fs'
51
+ import { appendFileSync, mkdirSync } from 'node:fs'
52
+ import path from 'node:path'
33
53
  import { fileURLToPath } from 'node:url'
34
54
  import type {
35
55
  AgentDraft,
@@ -38,11 +58,20 @@ import type {
38
58
  ReferenceDraft,
39
59
  } from '@opencode-ai/plugin/v2/promise'
40
60
  import { define } from '@opencode-ai/plugin/v2/promise'
61
+ import {
62
+ createEnforcementGuard,
63
+ readOnlyRegistry,
64
+ syncReadOnlySession,
65
+ type ToolExecuteBeforeInput,
66
+ type ToolExecuteBeforeOutput,
67
+ } from './pantheon/delegation-enforce.ts'
41
68
  import {
42
69
  getV2BridgeFromContext,
43
70
  type PantheonV2Bridge,
44
71
  type V2ContextLike,
45
72
  } from './pantheon/v2-bridge.ts'
73
+ import { createV2ToolDefinitions, type V2ToolResult } from './pantheon/v2-tools.ts'
74
+ import { type HookPayload, runHook } from './plugins/hook-runner.ts'
46
75
 
47
76
  // ─── Unsupported Features Registry ───────────────────────────────────────
48
77
 
@@ -63,6 +92,20 @@ export const V2_UNSUPPORTED_FEATURES: string[] = [
63
92
  // directory source. This is narrower than (and distinct from) ctx.skill's
64
93
  // host availability or callable transform.
65
94
  'skill-transform',
95
+ // Adapter limitation, not a host gap: the goal loop needs a GoalStore, a
96
+ // GoalLoopClient and a BackgroundJobBoard, none of which the V2
97
+ // PluginContext exposes, and the V1 bridge resolves to null outside V1.
98
+ // pantheon_goal_create/get/update are therefore absent from the V2 surface
99
+ // rather than registered as non-functional placeholders.
100
+ 'goal-tools',
101
+ // Adapter limitation, not a host gap: the caller/target delegation matrix
102
+ // needs a session hierarchy seeded from session metadata, and V2 exposes no
103
+ // seed path for it — SessionHierarchyRegistry.isRoot reports `true` for
104
+ // unknown sessions while unseeded, which would deny every `task()` call. The
105
+ // branch is therefore skipped rather than left to deny indiscriminately.
106
+ // Read-only depth-2 still holds via the blocked-tool list in the guard below,
107
+ // so this marker records an unenforced matrix, not an unenforced depth limit.
108
+ 'delegation-matrix',
66
109
  ]
67
110
 
68
111
  // ─── Constants ───────────────────────────────────────────────────────────
@@ -283,7 +326,10 @@ function transformReferences(draft: ReferenceDraft): void {
283
326
  *
284
327
  * The V2 tool.transform API (when available) provides:
285
328
  * draft.namespace({ name: "pantheon", description: "..." })
286
- * draft.add({ name, description, input, execute })
329
+ * draft.add({ name, description, input, output, execute })
330
+ *
331
+ * `output` is part of the required shape, not an optional extra: see
332
+ * `V2ToolDraft` for the host contract that makes it mandatory.
287
333
  *
288
334
  * Returns true if tools were registered, false if the API is unavailable.
289
335
  */
@@ -297,18 +343,29 @@ async function registerV2Tools(context: PluginContext): Promise<boolean> {
297
343
  }
298
344
 
299
345
  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)
346
+ // Tool definitions carry their own `output` declaration; the draft requires
347
+ // it, and the host rejects a definition whose result shape cannot satisfy
348
+ // the declaration it makes.
349
+ const toolDefs = createV2ToolDefinitions()
304
350
 
305
351
  await toolCtx.transform((draft: V2ToolDraft) => {
352
+ // Catalog grouping only. The host computes a tool's registration id as
353
+ // `options.namespace === undefined ? name : `${namespace}_${name}``, and
354
+ // `draft.namespace()` does NOT set `options.namespace` on later `add`
355
+ // calls — so the tools stay `hashline_edit` / `pantheon_cost` /
356
+ // `pantheon_model`, not `pantheon_*`. That bare name is what the
357
+ // enforcement guard's blocked-tool list matches on; see
358
+ // `adaptV2ExecuteBeforeEvent`.
306
359
  draft.namespace({ name: 'pantheon', description: 'Pantheon orchestration tools' })
307
360
  for (const def of toolDefs) {
308
361
  draft.add({
309
362
  name: def.name,
310
363
  description: def.description,
311
364
  input: def.input,
365
+ // Required by the host contract: without this the tool fails on
366
+ // every call with "Tool result declared output without an output
367
+ // schema". Must stay paired with def.execute resolving to `output`.
368
+ output: def.output,
312
369
  execute: def.execute,
313
370
  })
314
371
  }
@@ -377,20 +434,35 @@ async function handleV2SessionEvent(event: V2SessionEvent): Promise<void> {
377
434
  }
378
435
  }
379
436
 
437
+ /**
438
+ * Session-created handler: currently a no-op on the V2-only surface.
439
+ *
440
+ * V1 seeds session state here (`sessionHierarchy.register(info)` plus the
441
+ * root-session set) from the full session metadata carried by its own event.
442
+ * The V2 event does not carry that metadata, and — more to the point — V1 is a
443
+ * separate plugin instance that is not loaded when only `plugin-v2` is
444
+ * configured. So nothing runs here on a V2-only install. Do not read the bridge
445
+ * check below as coverage: a non-null bridge means a host process that ALSO
446
+ * loaded V1, and V1 does the seeding itself.
447
+ *
448
+ * The absent hierarchy seed is why the V2 enforcement guard is built without
449
+ * `isRootSession` / `isChildSession`; see `docs/UPGRADING.md`, "Known
450
+ * limitation — V2 enforcement covers the blocked-tool list, not the V1
451
+ * delegation matrix".
452
+ */
380
453
  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.
384
454
  const _bridge = resolveBridge()
385
455
  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.
456
+ // Bridge present ⇒ this process also loaded V1, whose own event hook owns
457
+ // root-session registration. Intentionally nothing to do on the V2 side.
388
458
  }
389
459
  }
390
460
 
391
461
  async function onSessionIdle(_event: V2SessionEvent): Promise<void> {
392
- // Idle continuation — goal loop / todo enforcer dispatch.
393
- // Bridge provides the full V1 infrastructure when available.
462
+ // Idle continuation — goal loop / todo enforcer dispatch. This one really
463
+ // does delegate: `bridge.todoEnforcer` is the V1 enforcer instance, reached
464
+ // only in a process that also loaded V1. On a V2-only install the bridge
465
+ // resolves to null and idle continuation does not run.
394
466
  const sessionID = _event.properties?.sessionID as string | undefined
395
467
  if (!sessionID) return
396
468
  const bridge = resolveBridge()
@@ -403,23 +475,23 @@ async function onSessionIdle(_event: V2SessionEvent): Promise<void> {
403
475
  }
404
476
  }
405
477
 
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.
412
- }
413
- }
478
+ /**
479
+ * Session-error handler: no V2-side implementation.
480
+ *
481
+ * The board error transition lives in V1's event hook, which is not loaded on
482
+ * a V2-only install. There is nothing to do here — deliberately, rather than
483
+ * describing behaviour this handler does not have.
484
+ */
485
+ async function onSessionError(_event: V2SessionEvent): Promise<void> {}
414
486
 
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
- }
487
+ /**
488
+ * Session-compacted handler: no V2-side implementation.
489
+ *
490
+ * Todo preservation and compaction context build live in the V1 path
491
+ * (`todo-preserve.ts`, driven by V1's compaction hook), which is not loaded on
492
+ * a V2-only install.
493
+ */
494
+ async function onSessionCompacted(_event: V2SessionEvent): Promise<void> {}
423
495
 
424
496
  // ─── V2 Session Hooks ───────────────────────────────────────────────────
425
497
 
@@ -487,8 +559,10 @@ async function registerV2SessionHooks(context: PluginContext): Promise<boolean>
487
559
  try {
488
560
  // "prompt" hook — vision message interception
489
561
  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.
562
+ // Vision interception. No V2-side implementation: the handler lives in
563
+ // the V1 path (`vision.chatMessage`), which is not loaded on a V2-only
564
+ // install. This is a permanent no-op there, not a graceful degradation —
565
+ // nothing is delegated, because there is nothing here to delegate to.
492
566
  })
493
567
  registered = true
494
568
  } catch {
@@ -498,8 +572,164 @@ async function registerV2SessionHooks(context: PluginContext): Promise<boolean>
498
572
  return registered
499
573
  }
500
574
 
575
+ // ─── V2 Secret Scan ─────────────────────────────────────────────────────
576
+
577
+ /**
578
+ * High-confidence secret token formats — MIRRORS `SECRET_MASK_RE` in
579
+ * src/plugins/pantheon-hooks.ts (V1) and `HIGH_CONFIDENCE_PATTERNS` in
580
+ * scripts/hooks/scan-secrets.sh.
581
+ *
582
+ * Duplicated rather than imported because the V1 module is a separate plugin
583
+ * that is NOT loaded on a V2-only install (see the module header), and pulling
584
+ * it in for one regex would resurrect exactly the V1-hook coupling V2 exists to
585
+ * remove. The drift risk is real and is asserted by a test in
586
+ * tests/pantheon/plugin-v2-contract.test.ts.
587
+ *
588
+ * Applied to every log line this module writes, so a hook trail can never
589
+ * contain a raw credential. Header/KEY names (the Bifrost header alone,
590
+ * `api_key=...`) are deliberately NOT matched: they are not secret values.
591
+ */
592
+ const SECRET_MASK_RE =
593
+ /(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
594
+
595
+ /** Replace every high-confidence secret match with '****'. */
596
+ function maskSecret(text: string): string {
597
+ return text.replace(SECRET_MASK_RE, '****')
598
+ }
599
+
600
+ /**
601
+ * Message thrown when scan-secrets.sh exits 2 (a high-confidence token match).
602
+ *
603
+ * This is the only deliberate throw on the V2 surface, and it mirrors the V1
604
+ * message (src/plugins/pantheon-hooks.ts `SECRET_BLOCK_MESSAGE`). opencode's
605
+ * Plugin.trigger has no catch around hook invocations, so the rejection
606
+ * propagates and fails the tool's execute promise BEFORE the tool body runs —
607
+ * the same hard block V1 had, which was inert on a V2-only install because
608
+ * pantheon-hooks.ts is never loaded there (opencode.json `plugins` declares only
609
+ * `src/plugin-v2`).
610
+ */
611
+ const SECRET_BLOCK_MESSAGE =
612
+ '[plugin-v2] Bloqueado: segredo de alta confiança detectado no tool input — consulte .pantheon/logs/hooks.log'
613
+
614
+ /**
615
+ * Append one masked, ISO-stamped line to .pantheon/logs/hooks.log under the
616
+ * working directory, and return whether it landed.
617
+ *
618
+ * V1's `reportFailure` also writes to `client.app.log` and shows a TUI toast.
619
+ * The V2 `PluginContext` exposes NEITHER (it is
620
+ * `{options, agent, aisdk, catalog, command, integration, plugin, reference,
621
+ * skill}` — no `client`, no `directory`), so this module has only the file
622
+ * channel. That is a real reduction in observability versus V1, stated here
623
+ * rather than implied. Best-effort by contract: a logging failure must never
624
+ * take down the session.
625
+ */
626
+ function appendV2HookLog(line: string): boolean {
627
+ const masked = maskSecret(line)
628
+ if (masked === '') return false
629
+ const stamp = new Date().toISOString()
630
+ // Multi-line scanner stderr would otherwise produce continuation lines with
631
+ // no timestamp (V1's P0-5 fix) — one message becomes N timestamped lines.
632
+ const body = masked
633
+ .split('\n')
634
+ .map((part) => part.trim())
635
+ .filter((part) => part !== '')
636
+ .map((part) => `[${stamp}] ${part}`)
637
+ .join('\n')
638
+ if (body === '') return false
639
+ try {
640
+ const dir = path.join(process.cwd(), '.pantheon', 'logs')
641
+ mkdirSync(dir, { recursive: true })
642
+ appendFileSync(path.join(dir, 'hooks.log'), `${body}\n`, 'utf8')
643
+ return true
644
+ } catch {
645
+ return false
646
+ }
647
+ }
648
+
501
649
  // ─── V2 Tool Hooks ──────────────────────────────────────────────────────
502
650
 
651
+ /**
652
+ * The `tool.execute.before` event as the host dispatches it (opencode 2.0.22).
653
+ *
654
+ * Transcribed from the host's tool service:
655
+ *
656
+ * trigger("tool", "execute.before", { tool, sessionID, agent, messageID, id, input })
657
+ *
658
+ * Two shape differences from V1's positional `(input, output)` pair, both
659
+ * handled by {@link adaptV2ExecuteBeforeEvent}: the host passes ONE payload
660
+ * object, and the tool arguments live on `input` where V1 called them
661
+ * `output.args`.
662
+ *
663
+ * `agent` is a required field of the host's tool context
664
+ * (`ToolInvocation.context = { sessionID, agent, messageID, id }`), so the
665
+ * active agent arrives on the very event the enforcement guard needs. V1 had to
666
+ * learn it from a separate `chat.params` hook and keep a session→agent map; V2
667
+ * does not have to, and neither does this module.
668
+ */
669
+ interface V2ToolExecuteBeforeEvent {
670
+ tool?: unknown
671
+ sessionID?: unknown
672
+ agent?: unknown
673
+ id?: unknown
674
+ input?: unknown
675
+ }
676
+
677
+ /**
678
+ * Read-only enforcement for the V2 tool surface.
679
+ *
680
+ * The same factory, the same `DEFAULT_BLOCKED_TOOLS` and the same read-only set
681
+ * that V1 uses, so the two surfaces cannot drift on what counts as a mutating
682
+ * tool — `hashline_edit` and `pantheon_model` are denied here for exactly the
683
+ * reason they are denied in V1.
684
+ *
685
+ * Deliberately NOT passed `getSessionAgent` / `isRootSession` / `isChildSession`:
686
+ * those drive V1's native-`task()` delegation matrix, which needs a session
687
+ * hierarchy seeded from session metadata. `SessionHierarchyRegistry.isRoot`
688
+ * reports `true` for unknown sessions while unseeded, so passing an unseeded
689
+ * predicate here would DENY every `task()` call in every session. Leaving them
690
+ * out skips that branch (the guard tests `options?.isRootSession !== undefined`).
691
+ * `task` is still denied inside a read-only session via the blocked-tool list,
692
+ * so depth-2 holds for apollo/gaia; the caller/target matrix itself is NOT
693
+ * enforced on V2. That is a known gap, not a covered case.
694
+ */
695
+ const v2EnforcementGuard = createEnforcementGuard({
696
+ getReadOnlySessions: () => readOnlyRegistry.sessionIDs(),
697
+ })
698
+
699
+ /**
700
+ * Project a V2 `execute.before` event onto the two-argument shape
701
+ * `createEnforcementGuard` was built for.
702
+ *
703
+ * `tool` is the host's registration id, computed as
704
+ * `options.namespace === undefined ? name : `${namespace}_${name}``. Pantheon
705
+ * declares a namespace for catalog grouping but sets `options.namespace` on no
706
+ * tool, so the id is the bare name (`hashline_edit`) and the blocked-tool match
707
+ * fires. A tool that opted into `options.namespace` would gain a prefix and stop
708
+ * matching — the integration test in tests/pantheon/plugin-v2-contract.test.ts
709
+ * drives the real registration path and asserts on the registered names, so
710
+ * that drift fails a test instead of silently disabling enforcement.
711
+ *
712
+ * A missing `sessionID` maps to the empty key: the registry lookup then matches
713
+ * the event's own (absent) identity, so a blocked tool in a read-only session is
714
+ * still denied. Fail-closed on the one axis that matters.
715
+ */
716
+ function adaptV2ExecuteBeforeEvent(event: unknown): {
717
+ input: ToolExecuteBeforeInput
718
+ output: ToolExecuteBeforeOutput
719
+ agent: unknown
720
+ } {
721
+ const payload = (event ?? {}) as V2ToolExecuteBeforeEvent
722
+ return {
723
+ input: {
724
+ tool: typeof payload.tool === 'string' ? payload.tool : '',
725
+ sessionID: typeof payload.sessionID === 'string' ? payload.sessionID : '',
726
+ callID: typeof payload.id === 'string' ? payload.id : '',
727
+ },
728
+ output: { args: payload.input },
729
+ agent: payload.agent,
730
+ }
731
+ }
732
+
503
733
  /**
504
734
  * Register tool execution hooks via V2 ctx.tool.hook.
505
735
  *
@@ -523,16 +753,60 @@ async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
523
753
  let registered = false
524
754
 
525
755
  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.
756
+ // "execute.before" — read-only enforcement. Throwing from this handler
757
+ // DENIES the tool call and the host surfaces the message to the session,
758
+ // which is how the guard blocks a mutating tool in a read-only session.
759
+ //
760
+ // This is the enforcement point for the V2 surface, not a delegation to
761
+ // V1: `src/plugin.ts` is not loaded when only `plugin-v2` is configured
762
+ // (opencode.json `plugins`), so the V1 `tool.execute.before` hook does not
763
+ // exist in that process. The guard below is instantiated here and consulted
764
+ // here; without it the tools registered by registerV2Tools would be a live
765
+ // write path in every session, including read-only ones.
766
+ await toolCtx.hook('execute.before', async (event: unknown) => {
767
+ const { input, output, agent } = adaptV2ExecuteBeforeEvent(event)
768
+ // V2's equivalent of V1's `chat.params` trigger: the host puts the active
769
+ // agent on the event itself, so registering here needs no separate hook
770
+ // and no session→agent map. A non-read-only (or absent) agent revokes the
771
+ // registration, which also covers an in-session agent switch.
772
+ syncReadOnlySession(readOnlyRegistry, input.sessionID, agent)
773
+ // Deliberately OUTSIDE the try/catch below: a read-only denial must keep
774
+ // propagating to the host, not be swallowed as a scanner failure.
775
+ await v2EnforcementGuard(input, output)
776
+
777
+ // High-confidence secret scan (scripts/hooks/scan-secrets.sh). This is the
778
+ // ONLY hard block on the V2 surface, and it exists here — rather than via
779
+ // `src/plugins/pantheon-hooks.ts` — because that module is a separate
780
+ // plugin that is never loaded on a V2-only install: opencode.json
781
+ // `plugins` declares only `src/plugin-v2`. V1's scan-secrets block was
782
+ // therefore inert by construction, not by misconfiguration.
783
+ //
784
+ // exit 2 → block. exit 1 (low-confidence header/KEY names) → advisory,
785
+ // logged only. A scanner failure (missing script, timeout) is fail-OPEN:
786
+ // runHook never rejects, and an infrastructure error must not deny
787
+ // unrelated tool calls.
788
+ let blockError: Error | null = null
789
+ try {
790
+ const payload: HookPayload = {
791
+ tool_name: input.tool,
792
+ tool_input: output.args ?? {},
793
+ agent_id: typeof agent === 'string' ? agent : '',
794
+ session_id: input.sessionID,
795
+ }
796
+ const scan = await runHook('scan-secrets.sh', payload)
797
+ if (scan.code === 2) {
798
+ appendV2HookLog(SECRET_BLOCK_MESSAGE)
799
+ appendV2HookLog(scan.stderr)
800
+ blockError = new Error(SECRET_BLOCK_MESSAGE)
801
+ } else if (scan.code === 1) {
802
+ // Advisory: log only, never block.
803
+ appendV2HookLog(scan.stderr)
804
+ }
805
+ } catch {
806
+ // Never let the scanner take down the session — runHook resolves a
807
+ // structured result instead of rejecting, so this is belt-and-braces.
535
808
  }
809
+ if (blockError !== null) throw blockError
536
810
  })
537
811
  registered = true
538
812
  } catch {
@@ -540,13 +814,14 @@ async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
540
814
  }
541
815
 
542
816
  try {
543
- // "execute.after" — hashline read enhance + context sandbox
817
+ // "execute.after" — hashline read enhance + context sandbox.
818
+ //
819
+ // Registration point only: there is no V2-side behaviour here. The
820
+ // enhancer/sandbox live in the V1 path (`pantheon-hooks.ts` +
821
+ // `context-sandbox.ts`), a separate plugin instance that is not loaded when
822
+ // only `plugin-v2` is configured.
544
823
  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.
549
- }
824
+ // Intentionally empty.
550
825
  })
551
826
  registered = true
552
827
  } catch {
@@ -580,8 +855,11 @@ async function registerV2PermissionHook(context: PluginContext): Promise<boolean
580
855
 
581
856
  try {
582
857
  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.
858
+ // Registration point only — no V2-side permission logic. Read-only
859
+ // enforcement does NOT run here: it runs in the `execute.before` handler
860
+ // above, which is the hook the host consults before a tool executes. Do
861
+ // not read this as "the V1 guards cover it": V1 is a different plugin
862
+ // instance and is not loaded when only `plugin-v2` is configured.
585
863
  })
586
864
  return true
587
865
  } catch {
@@ -593,7 +871,15 @@ async function registerV2PermissionHook(context: PluginContext): Promise<boolean
593
871
  // ─── Tool Definition Factory ─────────────────────────────────────────────
594
872
 
595
873
  /**
596
- * Internal types for V2 tool draft.
874
+ * Minimal structural view of the V2 tool draft.
875
+ *
876
+ * `output` is REQUIRED. The host (opencode 2.0.22) enforces a biconditional on
877
+ * a tool's declared `output` and the value its `execute` resolves to:
878
+ * `def.output === undefined` together with `'output' in result` throws
879
+ * "Tool result declared output without an output schema", and a declared
880
+ * `output` whose result omits the key throws "Tool did not return its
881
+ * declared output". A missing declaration therefore fails every call, so the
882
+ * field is mandatory here rather than optional.
597
883
  */
598
884
  interface V2ToolDraft {
599
885
  namespace(config: { name: string; description: string }): void
@@ -601,91 +887,12 @@ interface V2ToolDraft {
601
887
  name: string
602
888
  description: string
603
889
  input: Record<string, unknown>
890
+ output: Record<string, unknown>
604
891
  execute: (input: Record<string, unknown>, context: unknown) => Promise<V2ToolResult>
605
892
  }): void
606
893
  }
607
894
 
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
- }
895
+ export type { V2ToolDef, V2ToolResult } from './pantheon/v2-tools.ts'
689
896
 
690
897
  // ─── Plugin Definition ───────────────────────────────────────────────────
691
898
 
@@ -786,7 +993,10 @@ export const plugin = define({
786
993
  // Proven by the canary's negative control.
787
994
  await compactionCtx.hook('compaction', (_event: unknown) => {
788
995
  // Compaction context build — injects active goals, pending todos,
789
- // and in-flight delegations. Wired through V1 infrastructure.
996
+ // and in-flight delegations. No V2-side implementation: that build
997
+ // lives in the V1 path, which is not loaded on a V2-only install.
998
+ // This registration is a real hook (the canary below proves it
999
+ // fires) with no V2 behaviour behind it.
790
1000
  //
791
1001
  // Opt-in lifetime proof for the host-backed hook canary
792
1002
  // (tests/pantheon/plugin-v2-hook-canary.test.mjs, MODE=real). It is
@@ -165,7 +165,7 @@ async function detectVersion(api) {
165
165
  if (ver) return ver;
166
166
  }
167
167
  } catch {}
168
- return "1.6.0-beta.2";
168
+ return "1.6.0-beta.3";
169
169
  }
170
170
  /**
171
171
  * usage-bar — AI subscription usage gauge for the opencode TUI.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pantheon-tui",
3
- "version": "1.6.0-beta.2",
3
+ "version": "1.6.0-beta.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pantheon-tui",
9
- "version": "1.6.0-beta.2",
9
+ "version": "1.6.0-beta.3",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@opencode-ai/plugin": "1.18.30",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pantheon-tui",
3
3
  "type": "module",
4
- "version": "1.6.0-beta.2",
4
+ "version": "1.6.0-beta.3",
5
5
  "description": "Pantheon TUI plugin — sidebar (version, preset, sessions, real-time delegations) + AI subscription usage gauges incl. OpenCode Go/Zen dollar limits (vendored, MIT)",
6
6
  "license": "MIT",
7
7
  "engines": {