pantheon-opencode 1.4.2 → 1.5.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.pantheon/code-mode/.gitkeep +0 -0
  2. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +8 -0
  3. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +8 -0
  4. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +8 -0
  5. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +8 -0
  6. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +8 -0
  7. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +8 -0
  8. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +8 -0
  9. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +8 -0
  10. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +8 -0
  11. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +8 -0
  12. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +8 -0
  13. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +8 -0
  14. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +11 -0
  15. package/.pantheon/code-mode/README.md +93 -0
  16. package/.pantheon/code-mode/checkpoint-session.py +7 -0
  17. package/.pantheon/code-mode/checkpoint-session.sh +11 -0
  18. package/.pantheon/code-mode/checkpoint_session.py +481 -0
  19. package/.pantheon/code-mode/compress-inline.py +450 -0
  20. package/.pantheon/code-mode/eval-llm-judge.py +255 -0
  21. package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
  22. package/.pantheon/code-mode/eval-run.py +197 -0
  23. package/.pantheon/code-mode/eval-static.py +321 -0
  24. package/.pantheon/code-mode/example-sync.sh +5 -0
  25. package/.pantheon/code-mode/session-end-save.py +186 -0
  26. package/.pantheon/code-mode/session-end-save.sh +18 -0
  27. package/.pantheon/code-mode/test-allowed.sh +5 -0
  28. package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
  29. package/.pantheon/code-mode/test-env.sh +7 -0
  30. package/.pantheon/code-mode/test-prlimit.sh +9 -0
  31. package/.pantheon/code-mode/test-timeout.sh +6 -0
  32. package/README.md +158 -1189
  33. package/README.pt-BR.md +222 -0
  34. package/bin/pantheon-init.mjs +30 -11
  35. package/opencode.json +1 -0
  36. package/package.json +14 -3
  37. package/plugin.json +15 -0
  38. package/scripts/_pantheon_paths.py +48 -7
  39. package/scripts/ci-validate-yaml.py +7 -2
  40. package/scripts/code_mode_server.py +33 -2
  41. package/scripts/doctor.mjs +135 -127
  42. package/scripts/generate-preset-docs.mjs +127 -66
  43. package/scripts/generate-prompts.sh +2 -2
  44. package/scripts/install/cli-ui.mjs +8 -1
  45. package/scripts/install/config-migration.mjs +401 -0
  46. package/scripts/install/health-check.mjs +3 -3
  47. package/scripts/install/model-picker.mjs +72 -23
  48. package/scripts/install/opencode-version.mjs +47 -0
  49. package/scripts/install/opencode.mjs +261 -88
  50. package/scripts/install/plugin.mjs +112 -42
  51. package/scripts/install/shared.mjs +13 -158
  52. package/scripts/install-mcp.mjs +44 -232
  53. package/scripts/mcp_persistence_server.py +344 -15
  54. package/scripts/mcp_resources_server.py +144 -40
  55. package/scripts/memory_mcp_server.py +2 -2
  56. package/scripts/recover-zenodo-deposition.mjs +146 -0
  57. package/scripts/sync-tui.mjs +32 -7
  58. package/scripts/test-opencode-v1-v2-sandbox.sh +591 -0
  59. package/scripts/uninstall.mjs +298 -954
  60. package/scripts/validate-zenodo-release.mjs +154 -0
  61. package/scripts/zenodo-artifact.mjs +21 -0
  62. package/scripts/zenodo-release-state.mjs +53 -0
  63. package/src/mcp/_pantheon_paths.py +48 -7
  64. package/src/mcp/code_mode_server.py +33 -2
  65. package/src/mcp/eval_store.py +1 -1
  66. package/src/mcp/mcp_persistence_server.py +344 -15
  67. package/src/mcp/mcp_resources_server.py +144 -40
  68. package/src/mcp/pantheon_vision_server.py +1 -1
  69. package/src/mcp/toon_codec.py +491 -0
  70. package/src/pantheon/background-job-board.ts +67 -1
  71. package/src/pantheon/cost-command.ts +6 -2
  72. package/src/pantheon/delegate-manager.ts +384 -0
  73. package/src/pantheon/delegate-task-adapter.ts +444 -0
  74. package/src/pantheon/delegation-notify.ts +35 -20
  75. package/src/pantheon/model-command.ts +86 -32
  76. package/src/pantheon/token-opt.ts +555 -0
  77. package/src/pantheon/toon-codec.ts +481 -0
  78. package/src/pantheon/tui-version-check.ts +1 -3
  79. package/src/pantheon/v2-bridge.ts +129 -0
  80. package/src/pantheon/v2-events.ts +131 -0
  81. package/src/pantheon/v2-hooks.ts +287 -0
  82. package/src/pantheon/v2-tool-definitions.ts +331 -0
  83. package/src/plugin-v2/index.ts +21 -0
  84. package/src/plugin-v2.ts +836 -0
  85. package/src/plugin.ts +57 -10
  86. package/src/plugins/tui/dist/server.d.ts +1 -1
  87. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  88. package/src/plugins/tui/dist/server.js +1 -1
  89. package/src/plugins/tui/dist/server.js.map +1 -1
  90. package/src/plugins/tui/dist/tui.d.ts +10 -7
  91. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  92. package/src/plugins/tui/dist/tui.js +11 -8
  93. package/src/plugins/tui/dist/tui.js.map +1 -1
  94. package/src/plugins/tui/dist/tui.tsx +22 -16
  95. package/src/plugins/tui/package-lock.json +51 -51
  96. package/src/plugins/tui/package.json +7 -7
  97. package/src/plugins/tui/src/index.tsx +22 -16
  98. package/src/skills/README.md +0 -2
@@ -0,0 +1,836 @@
1
+ /**
2
+ * Pantheon V2 Plugin — full orchestration plugin for OpenCode V2.
3
+ *
4
+ * Registers:
5
+ * - 9 orchestration tools via V2 tool.transform (with V1 bridge fallback)
6
+ * - 4 event subscriptions (session.created, idle, error, compacted)
7
+ * - Session hooks (prompt, context) and tool hooks (execute.before/after)
8
+ * - Permission hooks for custom authorization
9
+ *
10
+ * V2 Plugin API surface (confirmed by investigation):
11
+ * ctx.tool.transform / ctx.event.subscribe / ctx.session.hook /
12
+ * ctx.tool.hook / ctx.permission.hook
13
+ *
14
+ * When a V2 API domain is unavailable, the feature is gracefully skipped
15
+ * and documented in V2_UNSUPPORTED_FEATURES. The plugin never breaks.
16
+ *
17
+ * @module plugin-v2
18
+ */
19
+
20
+ import { fileURLToPath } from 'node:url'
21
+ import type {
22
+ AgentDraft,
23
+ CatalogDraft,
24
+ CommandDraft,
25
+ PluginContext,
26
+ ReferenceDraft,
27
+ SkillDraft,
28
+ } from '@opencode-ai/plugin/v2/promise'
29
+ import { define } from '@opencode-ai/plugin/v2/promise'
30
+ import {
31
+ getV2BridgeFromContext,
32
+ type PantheonV2Bridge,
33
+ type V2ContextLike,
34
+ } from './pantheon/v2-bridge.ts'
35
+
36
+ // ─── Unsupported Features Registry ───────────────────────────────────────
37
+
38
+ /**
39
+ * Features not yet supported by the installed V2 plugin API.
40
+ * Updated dynamically at setup time based on actual API availability.
41
+ * Initialized with features known to be absent from the V2 promise API;
42
+ * additional features are appended during setup if their API is missing.
43
+ */
44
+ export const V2_UNSUPPORTED_FEATURES: string[] = ['legacy-hooks']
45
+
46
+ // ─── Constants ───────────────────────────────────────────────────────────
47
+
48
+ const POLICY_MARKER = '<!-- pantheon-v2-policy -->'
49
+ const POLICY = `${POLICY_MARKER}\nFollow Pantheon routing policy: delegate implementation work to the named specialist and do not claim work was performed without verification.`
50
+
51
+ /**
52
+ * SystemPart shape sniffing (beta 19192 `Schema validation failed` in
53
+ * system[N]): host sends SystemPart objects ({type,text}) but pinned SDK
54
+ * (@opencode-ai/plugin 1.18.18) only knows string/string[]. Detect at runtime.
55
+ * Never throws (fail-open).
56
+ */
57
+ function systemEntryText(entry: unknown): string | null {
58
+ try {
59
+ if (typeof entry === 'string') return entry
60
+ if (
61
+ typeof entry === 'object' &&
62
+ entry !== null &&
63
+ typeof (entry as { text?: unknown }).text === 'string'
64
+ ) {
65
+ return (entry as { text: string }).text
66
+ }
67
+ return null
68
+ } catch {
69
+ return null
70
+ }
71
+ }
72
+
73
+ function hasPolicyMarker(arr: unknown[]): boolean {
74
+ try {
75
+ return arr.some((s) => {
76
+ const t = systemEntryText(s)
77
+ return t?.includes(POLICY_MARKER) ?? false
78
+ })
79
+ } catch {
80
+ return false
81
+ }
82
+ }
83
+
84
+ function usesObjectShape(arr: unknown[]): boolean {
85
+ try {
86
+ return arr.some(
87
+ (s) =>
88
+ typeof s === 'object' && s !== null && typeof (s as { text?: unknown }).text === 'string',
89
+ )
90
+ } catch {
91
+ return false
92
+ }
93
+ }
94
+
95
+ function toSystemEntry(text: string, useObject: boolean): string | { type: 'text'; text: string } {
96
+ return useObject ? { type: 'text', text } : text
97
+ }
98
+
99
+ /**
100
+ * Add a feature to the unsupported list (idempotent).
101
+ */
102
+ function markUnsupported(feature: string): void {
103
+ if (!V2_UNSUPPORTED_FEATURES.includes(feature)) {
104
+ V2_UNSUPPORTED_FEATURES.push(feature)
105
+ }
106
+ }
107
+
108
+ // ─── V1 Bridge Integration ──────────────────────────────────────────────
109
+
110
+ /**
111
+ * Module-level bridge reference. Set via `setV2Bridge()` from V1 plugin
112
+ * after singletons are initialized. Tools check this first (fast path),
113
+ * then fall back to `ctx.options` retrieval.
114
+ */
115
+ let v1Bridge: PantheonV2Bridge | null = null
116
+
117
+ /**
118
+ * Set the V1 bridge from the V1 plugin's setup.
119
+ * Called once during V1 plugin initialization — the bridge is then
120
+ * available to all V2 tool handlers and event hooks.
121
+ */
122
+ export function setV2Bridge(bridge: PantheonV2Bridge): void {
123
+ v1Bridge = bridge
124
+ }
125
+
126
+ /**
127
+ * Get the V1 bridge, checking module-level first, then ctx.options.
128
+ * Returns null when no bridge is available (V2 standalone mode).
129
+ */
130
+ function resolveBridge(ctx?: V2ContextLike): PantheonV2Bridge | null {
131
+ if (v1Bridge != null) return v1Bridge
132
+ if (ctx != null) return getV2BridgeFromContext(ctx)
133
+ return null
134
+ }
135
+
136
+ // ─── V2 Transform Functions ─────────────────────────────────────────────
137
+
138
+ function transformAgents(draft: AgentDraft): void {
139
+ // Beta 19192 guard (issue #92): draft may not have `list`, or list()
140
+ // may return a non-iterable. Never throw — mark unsupported and return.
141
+ let agents: Iterable<{ id: string }>
142
+ try {
143
+ const maybeList = (draft as unknown as { list?: unknown }).list
144
+ if (typeof maybeList !== 'function') {
145
+ markUnsupported('agent-transform-list')
146
+ return
147
+ }
148
+ const result = (maybeList as (this: unknown) => unknown).call(draft)
149
+ if (
150
+ result == null ||
151
+ typeof (result as { [Symbol.iterator]?: unknown })[Symbol.iterator] !== 'function'
152
+ ) {
153
+ markUnsupported('agent-transform-list')
154
+ return
155
+ }
156
+ agents = result as Iterable<{ id: string }>
157
+ } catch {
158
+ markUnsupported('agent-transform-list')
159
+ return
160
+ }
161
+ try {
162
+ for (const agent of agents) {
163
+ draft.update(agent.id, (current) => {
164
+ // Beta 19192 hardening + SystemPart SKIP: `system` may be a non-string
165
+ // (host shape drift, e.g. object). `?.` covers only null/undefined, not
166
+ // wrong types — guard with typeof. String path preserved; empty/absent
167
+ // degrades to POLICY; truthy non-string OBJECT is SKIPPED (preserve host
168
+ // shape, avoid schema validation failure). Other primitives degrade to
169
+ // POLICY. Never throw.
170
+ try {
171
+ const sys: unknown = (current as { system?: unknown }).system
172
+ if (typeof sys === 'string' && sys.includes(POLICY_MARKER)) {
173
+ // Policy already present — nothing to do.
174
+ } else if (typeof sys === 'string' && sys) {
175
+ current.system = `${sys}\n\n${POLICY}`
176
+ } else if (sys == null || sys === '') {
177
+ current.system = POLICY
178
+ } else if (typeof sys === 'object') {
179
+ // Host object shape (e.g. SystemPart): SKIP instead of overwriting.
180
+ } else {
181
+ current.system = POLICY
182
+ }
183
+ } catch {
184
+ // Fail-open: leave agent untouched on unexpected shape.
185
+ }
186
+ if (agent.id === 'zeus') current.mode = 'primary'
187
+ })
188
+ }
189
+ } catch {
190
+ markUnsupported('agent-transform')
191
+ }
192
+ }
193
+
194
+ function transformCatalog(draft: CatalogDraft, options: PluginContext['options']): void {
195
+ const configured = options.default_model
196
+ if (typeof configured !== 'string') return
197
+ const separator = configured.indexOf('/')
198
+ if (separator <= 0 || separator === configured.length - 1) return
199
+ const providerID = configured.slice(0, separator)
200
+ const modelID = configured.slice(separator + 1)
201
+ if (draft.model.get(providerID, modelID)) draft.model.default.set(providerID, modelID)
202
+ }
203
+
204
+ function transformCommands(draft: CommandDraft): void {
205
+ // Beta 19192 guard (issue #92): draft may not have `list`, or list()
206
+ // may return a non-iterable. Never throw — mark unsupported and return.
207
+ let commands: Iterable<{ name: string }>
208
+ try {
209
+ const maybeList = (draft as unknown as { list?: unknown }).list
210
+ if (typeof maybeList !== 'function') {
211
+ markUnsupported('command-transform-list')
212
+ return
213
+ }
214
+ const result = (maybeList as (this: unknown) => unknown).call(draft)
215
+ if (
216
+ result == null ||
217
+ typeof (result as { [Symbol.iterator]?: unknown })[Symbol.iterator] !== 'function'
218
+ ) {
219
+ markUnsupported('command-transform-list')
220
+ return
221
+ }
222
+ commands = result as Iterable<{ name: string }>
223
+ } catch {
224
+ markUnsupported('command-transform-list')
225
+ return
226
+ }
227
+ try {
228
+ for (const command of commands) {
229
+ if (command.name.startsWith('pantheon-')) {
230
+ draft.update(command.name, (current) => {
231
+ current.description ??= 'Pantheon orchestration command'
232
+ })
233
+ }
234
+ }
235
+ } catch {
236
+ markUnsupported('command-transform')
237
+ }
238
+ }
239
+
240
+ function transformSkills(draft: SkillDraft): void {
241
+ // Beta 19192 guard (issue #92): draft may not have `list`, or list()
242
+ // may return a non-iterable. Never throw — mark unsupported and return.
243
+ let sources: Array<{ type: string; path: string }>
244
+ try {
245
+ const maybeList = (draft as unknown as { list?: unknown }).list
246
+ if (typeof maybeList !== 'function') {
247
+ markUnsupported('skill-transform-list')
248
+ return
249
+ }
250
+ const result = (maybeList as (this: unknown) => unknown).call(draft)
251
+ if (
252
+ result == null ||
253
+ typeof (result as { [Symbol.iterator]?: unknown })[Symbol.iterator] !== 'function'
254
+ ) {
255
+ markUnsupported('skill-transform-list')
256
+ return
257
+ }
258
+ sources = Array.isArray(result)
259
+ ? (result as Array<{ type: string; path: string }>)
260
+ : Array.from(result as Iterable<{ type: string; path: string }>)
261
+ } catch {
262
+ markUnsupported('skill-transform-list')
263
+ return
264
+ }
265
+ try {
266
+ const path = fileURLToPath(new URL('./skills', import.meta.url))
267
+ if (!sources.some((source) => source.type === 'directory' && source.path === path)) {
268
+ draft.source({ type: 'directory', path })
269
+ }
270
+ } catch {
271
+ markUnsupported('skill-transform')
272
+ }
273
+ }
274
+
275
+ function transformReferences(draft: ReferenceDraft): void {
276
+ draft.add('pantheon-agents', {
277
+ type: 'local',
278
+ path: fileURLToPath(new URL('../AGENTS.md', import.meta.url)),
279
+ description: 'Pantheon agent and execution policy',
280
+ })
281
+ }
282
+
283
+ // ─── V2 Tool Registration ───────────────────────────────────────────────
284
+
285
+ /**
286
+ * Attempt to register Pantheon tools via V2 ctx.tool.transform.
287
+ *
288
+ * The V2 tool.transform API (when available) provides:
289
+ * draft.namespace({ name: "pantheon", description: "..." })
290
+ * draft.add({ name, description, input, execute })
291
+ *
292
+ * Returns true if tools were registered, false if the API is unavailable.
293
+ */
294
+ async function registerV2Tools(context: PluginContext): Promise<boolean> {
295
+ const toolCtx = (context as unknown as Record<string, unknown>).tool as
296
+ | { transform?: (cb: (draft: V2ToolDraft) => void) => Promise<unknown> }
297
+ | undefined
298
+
299
+ if (!toolCtx?.transform) {
300
+ return false
301
+ }
302
+
303
+ try {
304
+ // Tool definitions require infrastructure instances — when available via
305
+ // V2 context, they are passed through; otherwise the factory returns
306
+ // definitions with lazy execute wrappers.
307
+ const toolDefs = createV2ToolDefinitionsFromContext(context)
308
+
309
+ await toolCtx.transform((draft: V2ToolDraft) => {
310
+ draft.namespace({ name: 'pantheon', description: 'Pantheon orchestration tools' })
311
+ for (const def of toolDefs) {
312
+ draft.add({
313
+ name: def.name,
314
+ description: def.description,
315
+ input: def.input,
316
+ execute: def.execute,
317
+ })
318
+ }
319
+ })
320
+ return true
321
+ } catch {
322
+ return false
323
+ }
324
+ }
325
+
326
+ // ─── V2 Event Subscription ──────────────────────────────────────────────
327
+
328
+ /**
329
+ * Attempt to subscribe to session events via V2 ctx.event.subscribe.
330
+ *
331
+ * The V2 event.subscribe API (when available) provides an async iterable:
332
+ * for await (const event of ctx.event.subscribe({ signal })) { ... }
333
+ *
334
+ * Returns a cleanup function, or undefined if the API is unavailable.
335
+ */
336
+ function subscribeV2Events(context: PluginContext): (() => void) | undefined {
337
+ const eventCtx = (context as unknown as Record<string, unknown>).event as
338
+ | { subscribe?: (opts?: { signal?: AbortSignal }) => AsyncIterable<V2SessionEvent> }
339
+ | undefined
340
+
341
+ if (!eventCtx?.subscribe) {
342
+ return undefined
343
+ }
344
+
345
+ const controller = new AbortController()
346
+ void (async () => {
347
+ try {
348
+ // biome-ignore lint/style/noNonNullAssertion: subscribe guaranteed by guard above
349
+ for await (const event of eventCtx.subscribe!({ signal: controller.signal })) {
350
+ await handleV2SessionEvent(event)
351
+ }
352
+ } catch (err: unknown) {
353
+ if (err instanceof Error && err.name === 'AbortError') return
354
+ // Log but don't crash
355
+ console.warn('[Pantheon V2] Event subscription error:', err)
356
+ }
357
+ })()
358
+
359
+ return () => controller.abort()
360
+ }
361
+
362
+ /**
363
+ * Route a V2 session event to the appropriate handler.
364
+ */
365
+ async function handleV2SessionEvent(event: V2SessionEvent): Promise<void> {
366
+ switch (event.type) {
367
+ case 'session.created':
368
+ await onSessionCreated(event)
369
+ break
370
+ case 'session.idle':
371
+ await onSessionIdle(event)
372
+ break
373
+ case 'session.error':
374
+ await onSessionError(event)
375
+ break
376
+ case 'session.compacted':
377
+ await onSessionCompacted(event)
378
+ break
379
+ default:
380
+ break
381
+ }
382
+ }
383
+
384
+ async function onSessionCreated(_event: V2SessionEvent): Promise<void> {
385
+ // Seed session state — handled by V1 bridge or direct registration.
386
+ // The V2 event doesn't carry enough context for full hierarchy setup;
387
+ // bridge.board is used when available for root-session registration.
388
+ const _bridge = resolveBridge()
389
+ if (_bridge?.board != null) {
390
+ // Bridge available: board will register root sessions via the V1 event hook.
391
+ // This is a no-op here — V1 handles hierarchy setup.
392
+ }
393
+ }
394
+
395
+ async function onSessionIdle(_event: V2SessionEvent): Promise<void> {
396
+ // Idle continuation — goal loop / todo enforcer dispatch.
397
+ // Bridge provides the full V1 infrastructure when available.
398
+ const sessionID = _event.properties?.sessionID as string | undefined
399
+ if (!sessionID) return
400
+ const bridge = resolveBridge()
401
+ if (bridge?.todoEnforcer != null) {
402
+ try {
403
+ await bridge.todoEnforcer.onIdle(sessionID)
404
+ } catch {
405
+ // Fail-open: idle continuation must never break the session.
406
+ }
407
+ }
408
+ }
409
+
410
+ async function onSessionError(_event: V2SessionEvent): Promise<void> {
411
+ // Error handling — delegation finalize as error.
412
+ const bridge = resolveBridge()
413
+ if (bridge?.board != null) {
414
+ // Bridge available: delegation error handling is done by V1 event hook.
415
+ // V2 only logs — V1 finalizeDelegation handles the board transition.
416
+ }
417
+ }
418
+
419
+ async function onSessionCompacted(_event: V2SessionEvent): Promise<void> {
420
+ // Compaction restore — todo preserve / context rebuild.
421
+ const bridge = resolveBridge()
422
+ if (bridge?.todoEnforcer != null) {
423
+ // Bridge available: todo preservation is done by V1 compaction hook.
424
+ // V2 only logs — V1 handles full compaction context build.
425
+ }
426
+ }
427
+
428
+ // ─── V2 Session Hooks ───────────────────────────────────────────────────
429
+
430
+ /**
431
+ * Register session hooks via V2 ctx.session.hook.
432
+ *
433
+ * The V2 session.hook API (when available) provides:
434
+ * ctx.session.hook("prompt", handler) — message admission
435
+ * ctx.session.hook("context", handler) — system context injection
436
+ *
437
+ * Returns true if any hooks were registered.
438
+ */
439
+ async function registerV2SessionHooks(context: PluginContext): Promise<boolean> {
440
+ const sessionCtx = (context as unknown as Record<string, unknown>).session as
441
+ | {
442
+ hook?: (name: string, handler: (event: unknown) => void | Promise<void>) => Promise<unknown>
443
+ }
444
+ | undefined
445
+
446
+ if (!sessionCtx?.hook) {
447
+ return false
448
+ }
449
+
450
+ let registered = false
451
+
452
+ try {
453
+ // "context" hook — inject routing policy + compaction state
454
+ await sessionCtx.hook('context', (event: unknown) => {
455
+ try {
456
+ // Beta 19192 hardening + SystemPart sniffing: host may send string[],
457
+ // SystemPart objects ({type,text}), mixed arrays, a single string, or
458
+ // a non-array system. Preserve string-path behavior; inject object
459
+ // shape when the array contains objects (else `Schema validation
460
+ // failed` in system[N]). Never throw to the host (fail-open).
461
+ const ctx = event as { system?: unknown } | null | undefined
462
+ const rawSystem: unknown = ctx?.system
463
+ if (typeof rawSystem === 'string' && ctx != null) {
464
+ ctx.system = rawSystem.includes(POLICY_MARKER) ? [rawSystem] : [rawSystem, POLICY]
465
+ } else if (Array.isArray(rawSystem)) {
466
+ if (!hasPolicyMarker(rawSystem)) {
467
+ rawSystem.push(toSystemEntry(POLICY, usesObjectShape(rawSystem)))
468
+ }
469
+ }
470
+ // Non-string, non-array system (or null event): ignore silently.
471
+ } catch {
472
+ // Fail-open: never throw to the host.
473
+ }
474
+ })
475
+ registered = true
476
+ } catch {
477
+ // Context hook not supported
478
+ }
479
+
480
+ try {
481
+ // "prompt" hook — vision message interception
482
+ await sessionCtx.hook('prompt', (_event: unknown) => {
483
+ // Vision interception delegates to the V1 vision handler.
484
+ // Without V1 client access, this is a graceful no-op.
485
+ })
486
+ registered = true
487
+ } catch {
488
+ // Prompt hook not supported
489
+ }
490
+
491
+ return registered
492
+ }
493
+
494
+ // ─── V2 Tool Hooks ──────────────────────────────────────────────────────
495
+
496
+ /**
497
+ * Register tool execution hooks via V2 ctx.tool.hook.
498
+ *
499
+ * The V2 tool.hook API (when available) provides:
500
+ * ctx.tool.hook("execute.before", handler) — pre-execution guard
501
+ * ctx.tool.hook("execute.after", handler) — post-execution augment
502
+ *
503
+ * Returns true if any hooks were registered.
504
+ */
505
+ async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
506
+ const toolCtx = (context as unknown as Record<string, unknown>).tool as
507
+ | {
508
+ hook?: (name: string, handler: (event: unknown) => void | Promise<void>) => Promise<unknown>
509
+ }
510
+ | undefined
511
+
512
+ if (!toolCtx?.hook) {
513
+ return false
514
+ }
515
+
516
+ let registered = false
517
+
518
+ try {
519
+ // "execute.before" — read-only enforcement + command normalization
520
+ await toolCtx.hook('execute.before', (_event: unknown) => {
521
+ // Enforcement guard runs via V1 bridge when infrastructure is available.
522
+ // The V2 hook is a registration point; actual enforcement is wired
523
+ // through the V1 tool.execute.before hook.
524
+ const bridge = resolveBridge()
525
+ if (bridge?.board != null) {
526
+ // Bridge available: V1 enforcement guard handles read-only + command normalization.
527
+ // No additional V2-side enforcement needed.
528
+ }
529
+ })
530
+ registered = true
531
+ } catch {
532
+ // Tool before-hook not supported
533
+ }
534
+
535
+ try {
536
+ // "execute.after" — hashline read enhance + context sandbox
537
+ await toolCtx.hook('execute.after', (_event: unknown) => {
538
+ // Read enhancer + sandbox run via V1 bridge.
539
+ const bridge = resolveBridge()
540
+ if (bridge?.visionHandler != null) {
541
+ // Bridge available: V1 vision handler + read enhancer handle post-execution.
542
+ }
543
+ })
544
+ registered = true
545
+ } catch {
546
+ // Tool after-hook not supported
547
+ }
548
+
549
+ return registered
550
+ }
551
+
552
+ // ─── V2 Permission Hook ─────────────────────────────────────────────────
553
+
554
+ /**
555
+ * Register permission hook via V2 ctx.permission.hook.
556
+ *
557
+ * The V2 permission.hook API (when available) provides:
558
+ * ctx.permission.hook("evaluate", handler) — custom permission logic
559
+ *
560
+ * Returns true if the hook was registered.
561
+ */
562
+ async function registerV2PermissionHook(context: PluginContext): Promise<boolean> {
563
+ const permCtx = (context as unknown as Record<string, unknown>).permission as
564
+ | {
565
+ hook?: (name: string, handler: (event: unknown) => void | Promise<void>) => Promise<unknown>
566
+ }
567
+ | undefined
568
+
569
+ if (!permCtx?.hook) {
570
+ markUnsupported('permission-hook')
571
+ return false
572
+ }
573
+
574
+ try {
575
+ await permCtx.hook('evaluate', (_event: unknown) => {
576
+ // Custom permission logic — Zeus read guard + read-only enforcement.
577
+ // Wired through V1 tool.execute.before when infrastructure is available.
578
+ })
579
+ return true
580
+ } catch {
581
+ markUnsupported('permission-hook')
582
+ return false
583
+ }
584
+ }
585
+
586
+ // ─── Tool Definition Factory ─────────────────────────────────────────────
587
+
588
+ /**
589
+ * Internal types for V2 tool draft.
590
+ */
591
+ interface V2ToolDraft {
592
+ namespace(config: { name: string; description: string }): void
593
+ add(tool: {
594
+ name: string
595
+ description: string
596
+ input: Record<string, unknown>
597
+ execute: (input: Record<string, unknown>, context: unknown) => Promise<V2ToolResult>
598
+ }): void
599
+ }
600
+
601
+ /**
602
+ * V2 tool result shape.
603
+ *
604
+ * The V1 SDK types `ToolResult` as `string | { output: string, ... }`, but
605
+ * the V2 beta host reads a field off the resolved value (minified `Ce`) and
606
+ * throws `Ce is not an Object` when a tool resolves to a bare string. Our
607
+ * handlers are fail-open, so that host-side TypeError escapes instead of
608
+ * being contained. Always resolve to the object shape — it satisfies both
609
+ * the SDK union and the beta host.
610
+ */
611
+ export interface V2ToolResult {
612
+ output: string
613
+ }
614
+
615
+ /**
616
+ * Wrap raw tool text into the object shape the V2 beta host requires.
617
+ * Never throws (fail-open): non-string input degrades to String(input).
618
+ */
619
+ export function toV2ToolResult(text: unknown): V2ToolResult {
620
+ try {
621
+ return { output: typeof text === 'string' ? text : String(text) }
622
+ } catch {
623
+ return { output: '[pantheon] tool output unavailable' }
624
+ }
625
+ }
626
+
627
+ /**
628
+ * A V2 tool definition ready for registration.
629
+ */
630
+ interface V2ToolDef {
631
+ name: string
632
+ description: string
633
+ input: Record<string, unknown>
634
+ execute: (input: Record<string, unknown>, context: unknown) => Promise<V2ToolResult>
635
+ }
636
+
637
+ /**
638
+ * Create V2 tool definitions from the plugin context.
639
+ *
640
+ * When V1 infrastructure (BackgroundJobBoard, delegation client, etc.) is
641
+ * available through the V2 context, tools are wired directly. Otherwise,
642
+ * each tool returns an error message explaining the limitation.
643
+ */
644
+ function createV2ToolDefinitionsFromContext(_context: PluginContext): V2ToolDef[] {
645
+ // Tool definitions are created lazily. When the full V1 infrastructure
646
+ // is bridged through the V2 context, these can be wired to real implementations.
647
+ // For now, they are structural definitions with placeholder execute functions
648
+ // that explain the V2 limitation.
649
+ const v2UnavailableMessage =
650
+ 'This tool requires V1 infrastructure (BackgroundJobBoard, SDK client). ' +
651
+ 'Use the V1 plugin (src/plugin.ts) for full tool support, or wire V1 ' +
652
+ 'infrastructure through the V2 bridge (src/pantheon/v2-bridge.ts).'
653
+
654
+ const toolNames = [
655
+ 'pantheon_delegate',
656
+ 'pantheon_delegation_read',
657
+ 'pantheon_delegation_list',
658
+ 'hashline_edit',
659
+ 'pantheon_goal_create',
660
+ 'pantheon_goal_get',
661
+ 'pantheon_goal_update',
662
+ 'pantheon_cost',
663
+ 'pantheon_model',
664
+ ]
665
+
666
+ const toolDescriptions: Record<string, string> = {
667
+ pantheon_delegate:
668
+ 'Dispatch a background agent as a child session and register it on the job board. ' +
669
+ 'Returns the readable alias (e.g. "apo-1"); read the result with pantheon_delegation_read.',
670
+ pantheon_delegation_read:
671
+ 'Block until a background delegation finishes (completed/error/cancelled), then return ' +
672
+ 'its report markdown (with a trailing agent-activity section) and mark the job reconciled.',
673
+ pantheon_delegation_list:
674
+ 'List background delegations for the current session, with [unread] for finished jobs.',
675
+ hashline_edit:
676
+ 'Edit a file anchored by hashline refs (LINE#TAG) instead of raw line numbers. ' +
677
+ 'Ops: replace, append, prepend, delete. ALL refs validated against ORIGINAL file first.',
678
+ pantheon_goal_create:
679
+ 'Create the single active goal for this session. The goal loop then continues the ' +
680
+ 'session automatically on idle until the goal is marked done.',
681
+ pantheon_goal_get:
682
+ 'Return the active goal for this session (status, objective, continuations).',
683
+ pantheon_goal_update:
684
+ 'Update the active goal: set status (pending/in_progress/done) and/or rewrite the objective. ' +
685
+ 'Setting status to done stops all further continuations.',
686
+ pantheon_cost:
687
+ 'Report delegation cost + token usage by agent over the last N days, read from opencode.db.',
688
+ pantheon_model:
689
+ 'Show, set, or reset per-agent model overrides in active-preset.json (project or global).',
690
+ }
691
+
692
+ return toolNames.map((name) => ({
693
+ name,
694
+ description: toolDescriptions[name] ?? `Pantheon tool: ${name}`,
695
+ input: { type: 'object' as const, properties: {} },
696
+ // FOLLOW-UP: connect the real wrappers from
697
+ // src/pantheon/v2-tool-definitions.ts (createV2ToolDefinitions with
698
+ // costCommand/goalTools) once V1 infrastructure is resolvable in the V2
699
+ // standalone context. The before/after factories in v2-hooks.ts:216-282
700
+ // are likewise unwired. Until then, placeholders resolve to the object
701
+ // shape via toV2ToolResult so the beta host never sees a bare string.
702
+ execute: async (_input: Record<string, unknown>, _context: unknown): Promise<V2ToolResult> => {
703
+ return toV2ToolResult(`${name}: ${v2UnavailableMessage}`)
704
+ },
705
+ }))
706
+ }
707
+
708
+ // ─── Plugin Definition ───────────────────────────────────────────────────
709
+
710
+ /** Event type from V2 event stream. */
711
+ interface V2SessionEvent {
712
+ type: string
713
+ properties?: Record<string, unknown>
714
+ }
715
+
716
+ /** Cleanup function type. */
717
+ type CleanupFn = () => void
718
+
719
+ /** Active cleanup handlers for the plugin lifecycle. */
720
+ const activeCleanups: CleanupFn[] = []
721
+
722
+ export const plugin = define({
723
+ id: 'pantheon-opencode-v2',
724
+
725
+ async setup(context: PluginContext): Promise<void> {
726
+ // ─── Phase 1: V2 Transforms (isolated per-domain, best-effort) ────
727
+ // Each domain registers independently: a failure in one transform
728
+ // (e.g. draft.list missing on beta 19192 → TypeError) must never
729
+ // reject setup() nor prevent the other domains from registering.
730
+ // See issue #92.
731
+ const phase1Transforms: Array<{ feature: string; register: () => Promise<unknown> }> = [
732
+ { feature: 'agent-transform', register: () => context.agent.transform(transformAgents) },
733
+ {
734
+ feature: 'catalog-transform',
735
+ register: () =>
736
+ context.catalog.transform((draft) => transformCatalog(draft, context.options)),
737
+ },
738
+ {
739
+ feature: 'command-transform',
740
+ register: () => context.command.transform(transformCommands),
741
+ },
742
+ {
743
+ feature: 'reference-transform',
744
+ register: () => context.reference.transform(transformReferences),
745
+ },
746
+ { feature: 'skill-transform', register: () => context.skill.transform(transformSkills) },
747
+ ]
748
+ const phase1Results = await Promise.allSettled(
749
+ phase1Transforms.map((t) => Promise.resolve().then(() => t.register())),
750
+ )
751
+ phase1Results.forEach((result, index) => {
752
+ // biome-ignore lint/style/noNonNullAssertion: index always in bounds — parallel arrays
753
+ const feature = phase1Transforms[index]!.feature
754
+ if (result.status === 'rejected') {
755
+ markUnsupported(feature)
756
+ }
757
+ })
758
+
759
+ // ─── Phase 2: V2 Tool Registration (best-effort) ─────────────────
760
+ const toolsRegistered = await registerV2Tools(context).catch(() => false)
761
+ if (!toolsRegistered) {
762
+ markUnsupported('tool-transform')
763
+ }
764
+
765
+ // ─── Phase 3: V2 Event Subscription (best-effort) ────────────────
766
+ const eventCleanup = subscribeV2Events(context)
767
+ if (eventCleanup) {
768
+ activeCleanups.push(eventCleanup)
769
+ } else {
770
+ markUnsupported('event-stream')
771
+ }
772
+
773
+ // ─── Phase 4: V2 Session Hooks (best-effort) ─────────────────────
774
+ const sessionHooksRegistered = await registerV2SessionHooks(context).catch(() => false)
775
+ if (!sessionHooksRegistered) {
776
+ markUnsupported('session-hooks')
777
+ }
778
+
779
+ // ─── Phase 5: V2 Tool Hooks (best-effort) ────────────────────────
780
+ const toolHooksRegistered = await registerV2ToolHooks(context).catch(() => false)
781
+ if (!toolHooksRegistered) {
782
+ markUnsupported('tool-execute-hooks')
783
+ }
784
+
785
+ // ─── Phase 6: V2 Permission Hook (best-effort) ───────────────────
786
+ await registerV2PermissionHook(context).catch(() => false)
787
+
788
+ // ─── Phase 7: V2 Compaction Hook (best-effort) ───────────────────
789
+ const compactionCtx = (context as unknown as Record<string, unknown>).session as
790
+ | {
791
+ hook?: (
792
+ name: string,
793
+ handler: (event: unknown) => void | Promise<void>,
794
+ ) => Promise<unknown>
795
+ }
796
+ | undefined
797
+ if (compactionCtx?.hook) {
798
+ try {
799
+ await compactionCtx.hook('compacting', (_event: unknown) => {
800
+ // Compaction context build — injects active goals, pending todos,
801
+ // and in-flight delegations. Wired through V1 infrastructure.
802
+ })
803
+ } catch {
804
+ markUnsupported('compaction-hook')
805
+ }
806
+ } else {
807
+ markUnsupported('compaction-hook')
808
+ }
809
+ },
810
+ })
811
+
812
+ // ─── V1 Compatibility Bridge ─────────────────────────────────────────────
813
+
814
+ /**
815
+ * V1-compatible cleanup — call from the V1 plugin's dispose hook or
816
+ * process exit handler to clean up V2 event subscriptions.
817
+ */
818
+ export function v2Dispose(): void {
819
+ for (const cleanup of activeCleanups) {
820
+ try {
821
+ cleanup()
822
+ } catch {
823
+ // Best-effort cleanup
824
+ }
825
+ }
826
+ activeCleanups.length = 0
827
+ }
828
+
829
+ /**
830
+ * Get the current list of unsupported V2 features (for diagnostics).
831
+ */
832
+ export function getUnsupportedFeatures(): readonly string[] {
833
+ return V2_UNSUPPORTED_FEATURES
834
+ }
835
+
836
+ export default plugin