pantheon-opencode 1.6.0-beta.2 → 1.6.0-beta.4
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/CHANGELOG.md +112 -0
- package/README.md +47 -15
- package/README.pt-BR.md +43 -13
- package/package.json +13 -10
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/code_mode_server.py +1 -1
- package/scripts/cost.mjs +244 -41
- package/scripts/install/agents-md.mjs +2 -2
- package/scripts/install/health-check.mjs +9 -2
- package/scripts/install/shared.mjs +5 -5
- package/scripts/mcp_persistence_server.py +1 -1
- package/scripts/mcp_resources_server.py +1 -1
- package/scripts/memory_mcp_server.py +1 -1
- package/scripts/probe-context-rehydrate.mjs +6 -0
- package/scripts/probe-pantheon-cost.mjs +71 -15
- package/scripts/validate-routing.mjs +3 -3
- package/src/mcp/code_mode_server.py +1 -1
- package/src/mcp/mcp_persistence_server.py +1 -1
- package/src/mcp/mcp_resources_server.py +1 -1
- package/src/mcp/memory_mcp_server.py +1 -1
- package/src/mcp/pantheon_vision_server.py +1 -1
- package/src/mcp/requirements-mcp.txt +6 -4
- package/src/mcp/requirements-vision.txt +2 -2
- package/src/pantheon/cost-command.ts +399 -101
- package/src/pantheon/delegation-enforce.ts +10 -1
- package/src/pantheon/presets.mjs +3 -3
- package/src/pantheon/v2-tools.ts +426 -0
- package/src/plugin-v2.ts +473 -190
- package/src/plugins/tui/dist/server.d.ts +1 -2
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.d.ts +74 -74
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +17 -20
- package/src/plugins/tui/package-lock.json +364 -317
- package/src/plugins/tui/package.json +8 -5
- package/src/plugins/tui/tsconfig.build.json +5 -0
- package/src/plugins/tui/tsdown.config.ts +3 -0
package/src/plugin-v2.ts
CHANGED
|
@@ -2,26 +2,68 @@
|
|
|
2
2
|
* Pantheon V2 Plugin — full orchestration plugin for OpenCode V2.
|
|
3
3
|
*
|
|
4
4
|
* Registers:
|
|
5
|
-
* -
|
|
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)
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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.
|
|
28
|
+
*
|
|
29
|
+
* ## The four domains the SDK types do not carry
|
|
30
|
+
*
|
|
31
|
+
* `@opencode-ai/plugin` 1.18.33 types `PluginContext` as exactly:
|
|
32
|
+
* options · agent · aisdk · catalog · command · integration · plugin ·
|
|
33
|
+
* reference · skill
|
|
34
|
+
* — no `tool`, `event`, `permission` or `session`. 1.18.34 (`latest`) is the
|
|
35
|
+
* same, so following the stable SDK line does not resolve this: it is a
|
|
36
|
+
* STRUCTURAL DECLARATION task, not a pin reconciliation.
|
|
37
|
+
*
|
|
38
|
+
* A live opencode 2.0.22 host DOES have all four, with the shapes this module
|
|
39
|
+
* uses. Measured by `tests/canary/plugin-v2-tool-canary.test.mjs`, which loads
|
|
40
|
+
* this file through the real loader and records `Object.keys(ctx)` from inside
|
|
41
|
+
* the host:
|
|
42
|
+
*
|
|
43
|
+
* agent aisdk app command event experimental generate integration location
|
|
44
|
+
* mcp model options permission plugin provider reference rpc session shell
|
|
45
|
+
* skill storage tool vcs websearch worktree
|
|
46
|
+
*
|
|
47
|
+
* ctx.tool.transform function
|
|
48
|
+
* ctx.tool.hook function
|
|
49
|
+
* ctx.tool.list function
|
|
50
|
+
* ctx.event.subscribe function
|
|
51
|
+
* ctx.permission.hook function
|
|
52
|
+
* ctx.session.hook function
|
|
53
|
+
* ctx.catalog absent
|
|
54
|
+
*
|
|
55
|
+
* `HostPluginContext` below declares exactly the minimum of that, and
|
|
56
|
+
* `hostDomains` is the SINGLE documented cast that reaches them — replacing the
|
|
57
|
+
* six repeated `as unknown as Record<string, unknown>` casts this file carried
|
|
58
|
+
* (Phases 2, 3, 4, 5, 6 and 7 — every phase except Phase 1, which reads the
|
|
59
|
+
* SDK-typed domains directly), each of which type-checked nothing at all. The
|
|
60
|
+
* per-domain definition checks stay: `registerV2Tools` still returns `false`
|
|
61
|
+
* and marks `tool-transform` when `ctx.tool.transform` is missing, and likewise
|
|
62
|
+
* for the others. The cast is not a licence to assume: the canary above is what
|
|
63
|
+
* proves the domains exist on a real host, and it fails if they stop existing.
|
|
64
|
+
*
|
|
65
|
+
* If the SDK ever exposes these four, replacing `hostDomains` with the real
|
|
66
|
+
* type is a one-line change. Do NOT make that change while 1.18.x lacks them.
|
|
25
67
|
*
|
|
26
68
|
* V2_UNSUPPORTED_FEATURES distinguishes adapter limitations from observed
|
|
27
69
|
* host-absent APIs; setup handles optional registrations on a best-effort basis.
|
|
@@ -29,7 +71,8 @@
|
|
|
29
71
|
* @module plugin-v2
|
|
30
72
|
*/
|
|
31
73
|
|
|
32
|
-
import { appendFileSync } from 'node:fs'
|
|
74
|
+
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
75
|
+
import path from 'node:path'
|
|
33
76
|
import { fileURLToPath } from 'node:url'
|
|
34
77
|
import type {
|
|
35
78
|
AgentDraft,
|
|
@@ -38,11 +81,20 @@ import type {
|
|
|
38
81
|
ReferenceDraft,
|
|
39
82
|
} from '@opencode-ai/plugin/v2/promise'
|
|
40
83
|
import { define } from '@opencode-ai/plugin/v2/promise'
|
|
84
|
+
import {
|
|
85
|
+
createEnforcementGuard,
|
|
86
|
+
readOnlyRegistry,
|
|
87
|
+
syncReadOnlySession,
|
|
88
|
+
type ToolExecuteBeforeInput,
|
|
89
|
+
type ToolExecuteBeforeOutput,
|
|
90
|
+
} from './pantheon/delegation-enforce.ts'
|
|
41
91
|
import {
|
|
42
92
|
getV2BridgeFromContext,
|
|
43
93
|
type PantheonV2Bridge,
|
|
44
94
|
type V2ContextLike,
|
|
45
95
|
} from './pantheon/v2-bridge.ts'
|
|
96
|
+
import { createV2ToolDefinitions, type V2ToolResult } from './pantheon/v2-tools.ts'
|
|
97
|
+
import { type HookPayload, runHook } from './plugins/hook-runner.ts'
|
|
46
98
|
|
|
47
99
|
// ─── Unsupported Features Registry ───────────────────────────────────────
|
|
48
100
|
|
|
@@ -53,16 +105,34 @@ import {
|
|
|
53
105
|
*/
|
|
54
106
|
export const V2_UNSUPPORTED_FEATURES: string[] = [
|
|
55
107
|
'legacy-hooks',
|
|
56
|
-
// `catalog`
|
|
108
|
+
// `ctx.catalog` is the one domain a live 2.0.22 host does NOT have: it is
|
|
109
|
+
// absent from `Object.keys(ctx)`, measured in
|
|
110
|
+
// tests/canary/plugin-v2-tool-canary.test.mjs. (`catalog` IS in the 1.18.33
|
|
111
|
+
// SDK's PluginContext, so the SDK types it and the host does not provide it.)
|
|
57
112
|
'catalog-transform',
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
113
|
+
// Adapter support, NOT host availability: 2.0.22 exposes ctx.integration and
|
|
114
|
+
// ctx.skill with callable transforms, and Pantheon deliberately registers
|
|
115
|
+
// neither. These entries record what this plugin does not implement.
|
|
61
116
|
'integration-transform',
|
|
62
|
-
// The inspected SkillEditor shape has no `source()` helper
|
|
63
|
-
// directory source. This is narrower
|
|
64
|
-
// host availability or callable
|
|
117
|
+
// The inspected SkillEditor shape has no `source()` helper — i.e. no
|
|
118
|
+
// `SkillEditor.source()` — for adding a directory source. This is narrower
|
|
119
|
+
// than (and distinct from) ctx.skill's host availability or callable
|
|
120
|
+
// transform, which a live 2.0.22 host does provide.
|
|
65
121
|
'skill-transform',
|
|
122
|
+
// Adapter limitation, not a host gap: the goal loop needs a GoalStore, a
|
|
123
|
+
// GoalLoopClient and a BackgroundJobBoard, none of which the V2
|
|
124
|
+
// PluginContext exposes, and the V1 bridge resolves to null outside V1.
|
|
125
|
+
// pantheon_goal_create/get/update are therefore absent from the V2 surface
|
|
126
|
+
// rather than registered as non-functional placeholders.
|
|
127
|
+
'goal-tools',
|
|
128
|
+
// Adapter limitation, not a host gap: the caller/target delegation matrix
|
|
129
|
+
// needs a session hierarchy seeded from session metadata, and V2 exposes no
|
|
130
|
+
// seed path for it — SessionHierarchyRegistry.isRoot reports `true` for
|
|
131
|
+
// unknown sessions while unseeded, which would deny every `task()` call. The
|
|
132
|
+
// branch is therefore skipped rather than left to deny indiscriminately.
|
|
133
|
+
// Read-only depth-2 still holds via the blocked-tool list in the guard below,
|
|
134
|
+
// so this marker records an unenforced matrix, not an unenforced depth limit.
|
|
135
|
+
'delegation-matrix',
|
|
66
136
|
]
|
|
67
137
|
|
|
68
138
|
// ─── Constants ───────────────────────────────────────────────────────────
|
|
@@ -146,6 +216,68 @@ function markUnsupported(feature: string): void {
|
|
|
146
216
|
}
|
|
147
217
|
}
|
|
148
218
|
|
|
219
|
+
// ─── Host context domains the SDK types do not carry ────────────────────
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The minimal shape of `ctx.tool` this module uses, as a live opencode 2.0.22
|
|
223
|
+
* host provides it. `transform` registers the Pantheon tools, `hook` installs
|
|
224
|
+
* the `execute.before`/`execute.after` guards, `list` is the host-built
|
|
225
|
+
* descriptor dump the canary asserts on.
|
|
226
|
+
*/
|
|
227
|
+
interface V2ToolDomain {
|
|
228
|
+
transform?: (cb: (draft: V2ToolDraft) => void) => Promise<unknown>
|
|
229
|
+
hook?: (name: string, handler: (event: unknown) => void | Promise<void>) => Promise<unknown>
|
|
230
|
+
list?: () => Promise<unknown>
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The minimal shape of `ctx.event`: one async-iterable subscription. */
|
|
234
|
+
interface V2EventDomain {
|
|
235
|
+
subscribe?: (opts?: { signal?: AbortSignal }) => AsyncIterable<V2SessionEvent>
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The minimal shape of `ctx.permission`.
|
|
240
|
+
*
|
|
241
|
+
* Only a hook name and a handler: the plugin registers `evaluate` as a
|
|
242
|
+
* registration point and deliberately runs no V2-side permission logic, so no
|
|
243
|
+
* richer payload type is claimed here.
|
|
244
|
+
*/
|
|
245
|
+
interface V2PermissionDomain {
|
|
246
|
+
hook?: (name: string, handler: (event: unknown) => void | Promise<void>) => Promise<unknown>
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The minimal shape of `ctx.session`.
|
|
251
|
+
*
|
|
252
|
+
* Only `hook(name, handler)`. The host accepts any string and silently drops an
|
|
253
|
+
* unknown one — which is why the hook names below were a live defect once and
|
|
254
|
+
* why the host-backed hook canary exists.
|
|
255
|
+
*/
|
|
256
|
+
interface V2SessionDomain {
|
|
257
|
+
hook?: (name: string, handler: (event: unknown) => void | Promise<void>) => Promise<unknown>
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** `PluginContext` plus the four domains 1.18.x omits. See the module header. */
|
|
261
|
+
interface HostPluginContext extends PluginContext {
|
|
262
|
+
tool?: V2ToolDomain
|
|
263
|
+
event?: V2EventDomain
|
|
264
|
+
permission?: V2PermissionDomain
|
|
265
|
+
session?: V2SessionDomain
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* THE cast to the host's context. Every optional member stays optional and every
|
|
270
|
+
* caller re-checks before use, so this narrows nothing away: it replaces five
|
|
271
|
+
* anonymous `Record<string, unknown>` casts with one named, documented,
|
|
272
|
+
* type-checked boundary.
|
|
273
|
+
*
|
|
274
|
+
* When the SDK ships these four domains, delete this function and use
|
|
275
|
+
* `PluginContext` directly.
|
|
276
|
+
*/
|
|
277
|
+
function hostDomains(context: PluginContext): HostPluginContext {
|
|
278
|
+
return context as HostPluginContext
|
|
279
|
+
}
|
|
280
|
+
|
|
149
281
|
// ─── V1 Bridge Integration ──────────────────────────────────────────────
|
|
150
282
|
|
|
151
283
|
/**
|
|
@@ -283,32 +415,44 @@ function transformReferences(draft: ReferenceDraft): void {
|
|
|
283
415
|
*
|
|
284
416
|
* The V2 tool.transform API (when available) provides:
|
|
285
417
|
* draft.namespace({ name: "pantheon", description: "..." })
|
|
286
|
-
* draft.add({ name, description, input, execute })
|
|
418
|
+
* draft.add({ name, description, input, output, execute })
|
|
419
|
+
*
|
|
420
|
+
* `output` is part of the required shape, not an optional extra: see
|
|
421
|
+
* `V2ToolDraft` for the host contract that makes it mandatory.
|
|
287
422
|
*
|
|
288
423
|
* Returns true if tools were registered, false if the API is unavailable.
|
|
289
424
|
*/
|
|
290
425
|
async function registerV2Tools(context: PluginContext): Promise<boolean> {
|
|
291
|
-
const toolCtx = (context
|
|
292
|
-
| { transform?: (cb: (draft: V2ToolDraft) => void) => Promise<unknown> }
|
|
293
|
-
| undefined
|
|
426
|
+
const toolCtx = hostDomains(context).tool
|
|
294
427
|
|
|
295
428
|
if (!toolCtx?.transform) {
|
|
296
429
|
return false
|
|
297
430
|
}
|
|
298
431
|
|
|
299
432
|
try {
|
|
300
|
-
// Tool definitions
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
const toolDefs =
|
|
433
|
+
// Tool definitions carry their own `output` declaration; the draft requires
|
|
434
|
+
// it, and the host rejects a definition whose result shape cannot satisfy
|
|
435
|
+
// the declaration it makes.
|
|
436
|
+
const toolDefs = createV2ToolDefinitions()
|
|
304
437
|
|
|
305
438
|
await toolCtx.transform((draft: V2ToolDraft) => {
|
|
439
|
+
// Catalog grouping only. The host computes a tool's registration id as
|
|
440
|
+
// `options.namespace === undefined ? name : `${namespace}_${name}``, and
|
|
441
|
+
// `draft.namespace()` does NOT set `options.namespace` on later `add`
|
|
442
|
+
// calls — so the tools stay `hashline_edit` / `pantheon_cost` /
|
|
443
|
+
// `pantheon_model`, not `pantheon_*`. That bare name is what the
|
|
444
|
+
// enforcement guard's blocked-tool list matches on; see
|
|
445
|
+
// `adaptV2ExecuteBeforeEvent`.
|
|
306
446
|
draft.namespace({ name: 'pantheon', description: 'Pantheon orchestration tools' })
|
|
307
447
|
for (const def of toolDefs) {
|
|
308
448
|
draft.add({
|
|
309
449
|
name: def.name,
|
|
310
450
|
description: def.description,
|
|
311
451
|
input: def.input,
|
|
452
|
+
// Required by the host contract: without this the tool fails on
|
|
453
|
+
// every call with "Tool result declared output without an output
|
|
454
|
+
// schema". Must stay paired with def.execute resolving to `output`.
|
|
455
|
+
output: def.output,
|
|
312
456
|
execute: def.execute,
|
|
313
457
|
})
|
|
314
458
|
}
|
|
@@ -330,9 +474,7 @@ async function registerV2Tools(context: PluginContext): Promise<boolean> {
|
|
|
330
474
|
* Returns a cleanup function, or undefined if the API is unavailable.
|
|
331
475
|
*/
|
|
332
476
|
function subscribeV2Events(context: PluginContext): (() => void) | undefined {
|
|
333
|
-
const eventCtx = (context
|
|
334
|
-
| { subscribe?: (opts?: { signal?: AbortSignal }) => AsyncIterable<V2SessionEvent> }
|
|
335
|
-
| undefined
|
|
477
|
+
const eventCtx = hostDomains(context).event
|
|
336
478
|
|
|
337
479
|
if (!eventCtx?.subscribe) {
|
|
338
480
|
return undefined
|
|
@@ -377,20 +519,35 @@ async function handleV2SessionEvent(event: V2SessionEvent): Promise<void> {
|
|
|
377
519
|
}
|
|
378
520
|
}
|
|
379
521
|
|
|
522
|
+
/**
|
|
523
|
+
* Session-created handler: currently a no-op on the V2-only surface.
|
|
524
|
+
*
|
|
525
|
+
* V1 seeds session state here (`sessionHierarchy.register(info)` plus the
|
|
526
|
+
* root-session set) from the full session metadata carried by its own event.
|
|
527
|
+
* The V2 event does not carry that metadata, and — more to the point — V1 is a
|
|
528
|
+
* separate plugin instance that is not loaded when only `plugin-v2` is
|
|
529
|
+
* configured. So nothing runs here on a V2-only install. Do not read the bridge
|
|
530
|
+
* check below as coverage: a non-null bridge means a host process that ALSO
|
|
531
|
+
* loaded V1, and V1 does the seeding itself.
|
|
532
|
+
*
|
|
533
|
+
* The absent hierarchy seed is why the V2 enforcement guard is built without
|
|
534
|
+
* `isRootSession` / `isChildSession`; see `docs/UPGRADING.md`, "Known
|
|
535
|
+
* limitation — V2 enforcement covers the blocked-tool list, not the V1
|
|
536
|
+
* delegation matrix".
|
|
537
|
+
*/
|
|
380
538
|
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
539
|
const _bridge = resolveBridge()
|
|
385
540
|
if (_bridge?.board != null) {
|
|
386
|
-
// Bridge
|
|
387
|
-
//
|
|
541
|
+
// Bridge present ⇒ this process also loaded V1, whose own event hook owns
|
|
542
|
+
// root-session registration. Intentionally nothing to do on the V2 side.
|
|
388
543
|
}
|
|
389
544
|
}
|
|
390
545
|
|
|
391
546
|
async function onSessionIdle(_event: V2SessionEvent): Promise<void> {
|
|
392
|
-
// Idle continuation — goal loop / todo enforcer dispatch.
|
|
393
|
-
//
|
|
547
|
+
// Idle continuation — goal loop / todo enforcer dispatch. This one really
|
|
548
|
+
// does delegate: `bridge.todoEnforcer` is the V1 enforcer instance, reached
|
|
549
|
+
// only in a process that also loaded V1. On a V2-only install the bridge
|
|
550
|
+
// resolves to null and idle continuation does not run.
|
|
394
551
|
const sessionID = _event.properties?.sessionID as string | undefined
|
|
395
552
|
if (!sessionID) return
|
|
396
553
|
const bridge = resolveBridge()
|
|
@@ -403,23 +560,23 @@ async function onSessionIdle(_event: V2SessionEvent): Promise<void> {
|
|
|
403
560
|
}
|
|
404
561
|
}
|
|
405
562
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
}
|
|
563
|
+
/**
|
|
564
|
+
* Session-error handler: no V2-side implementation.
|
|
565
|
+
*
|
|
566
|
+
* The board error transition lives in V1's event hook, which is not loaded on
|
|
567
|
+
* a V2-only install. There is nothing to do here — deliberately, rather than
|
|
568
|
+
* describing behaviour this handler does not have.
|
|
569
|
+
*/
|
|
570
|
+
async function onSessionError(_event: V2SessionEvent): Promise<void> {}
|
|
414
571
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
}
|
|
572
|
+
/**
|
|
573
|
+
* Session-compacted handler: no V2-side implementation.
|
|
574
|
+
*
|
|
575
|
+
* Todo preservation and compaction context build live in the V1 path
|
|
576
|
+
* (`todo-preserve.ts`, driven by V1's compaction hook), which is not loaded on
|
|
577
|
+
* a V2-only install.
|
|
578
|
+
*/
|
|
579
|
+
async function onSessionCompacted(_event: V2SessionEvent): Promise<void> {}
|
|
423
580
|
|
|
424
581
|
// ─── V2 Session Hooks ───────────────────────────────────────────────────
|
|
425
582
|
|
|
@@ -433,11 +590,7 @@ async function onSessionCompacted(_event: V2SessionEvent): Promise<void> {
|
|
|
433
590
|
* Returns true if any hooks were registered.
|
|
434
591
|
*/
|
|
435
592
|
async function registerV2SessionHooks(context: PluginContext): Promise<boolean> {
|
|
436
|
-
const sessionCtx = (context
|
|
437
|
-
| {
|
|
438
|
-
hook?: (name: string, handler: (event: unknown) => void | Promise<void>) => Promise<unknown>
|
|
439
|
-
}
|
|
440
|
-
| undefined
|
|
593
|
+
const sessionCtx = hostDomains(context).session
|
|
441
594
|
|
|
442
595
|
if (!sessionCtx?.hook) {
|
|
443
596
|
return false
|
|
@@ -487,8 +640,10 @@ async function registerV2SessionHooks(context: PluginContext): Promise<boolean>
|
|
|
487
640
|
try {
|
|
488
641
|
// "prompt" hook — vision message interception
|
|
489
642
|
await sessionCtx.hook('prompt', (_event: unknown) => {
|
|
490
|
-
// Vision interception
|
|
491
|
-
//
|
|
643
|
+
// Vision interception. No V2-side implementation: the handler lives in
|
|
644
|
+
// the V1 path (`vision.chatMessage`), which is not loaded on a V2-only
|
|
645
|
+
// install. This is a permanent no-op there, not a graceful degradation —
|
|
646
|
+
// nothing is delegated, because there is nothing here to delegate to.
|
|
492
647
|
})
|
|
493
648
|
registered = true
|
|
494
649
|
} catch {
|
|
@@ -498,8 +653,169 @@ async function registerV2SessionHooks(context: PluginContext): Promise<boolean>
|
|
|
498
653
|
return registered
|
|
499
654
|
}
|
|
500
655
|
|
|
656
|
+
// ─── V2 Secret Scan ─────────────────────────────────────────────────────
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* High-confidence secret token formats — MIRRORS `SECRET_MASK_RE` in
|
|
660
|
+
* src/plugins/pantheon-hooks.ts (V1) and `HIGH_CONFIDENCE_PATTERNS` in
|
|
661
|
+
* scripts/hooks/scan-secrets.sh.
|
|
662
|
+
*
|
|
663
|
+
* Duplicated rather than imported because the V1 module is a separate plugin
|
|
664
|
+
* that is NOT loaded on a V2-only install (see the module header), and pulling
|
|
665
|
+
* it in for one regex would resurrect exactly the V1-hook coupling V2 exists to
|
|
666
|
+
* remove. The drift risk is real and is asserted by a test in
|
|
667
|
+
* tests/pantheon/plugin-v2-contract.test.ts.
|
|
668
|
+
*
|
|
669
|
+
* Applied to every log line this module writes, so a hook trail can never
|
|
670
|
+
* contain a raw credential. Header/KEY names (the Bifrost header alone,
|
|
671
|
+
* `api_key=...`) are deliberately NOT matched: they are not secret values.
|
|
672
|
+
*/
|
|
673
|
+
const SECRET_MASK_RE =
|
|
674
|
+
/(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
|
|
675
|
+
|
|
676
|
+
/** Replace every high-confidence secret match with '****'. */
|
|
677
|
+
function maskSecret(text: string): string {
|
|
678
|
+
return text.replace(SECRET_MASK_RE, '****')
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Message thrown when scan-secrets.sh exits 2 (a high-confidence token match).
|
|
683
|
+
*
|
|
684
|
+
* This is the only deliberate throw on the V2 surface. opencode's
|
|
685
|
+
* Plugin.trigger has no catch around hook invocations, so the rejection
|
|
686
|
+
* propagates and fails the tool's execute promise BEFORE the tool body runs —
|
|
687
|
+
* the same hard block V1 has (src/plugins/pantheon-hooks.ts
|
|
688
|
+
* `SECRET_BLOCK_MESSAGE`), which was inert on a V2-only install because
|
|
689
|
+
* pantheon-hooks.ts is never loaded there (opencode.json `plugins` declares only
|
|
690
|
+
* `src/plugin-v2`).
|
|
691
|
+
*
|
|
692
|
+
* English, like every other message this module emits: this string surfaces in
|
|
693
|
+
* the user's session as the tool error, so it is read by whoever hit the block,
|
|
694
|
+
* not by the code that raised it. A Portuguese release note elsewhere in the
|
|
695
|
+
* repo is not a reason for the runtime message to be Portuguese too.
|
|
696
|
+
*/
|
|
697
|
+
const SECRET_BLOCK_MESSAGE =
|
|
698
|
+
'[plugin-v2] Blocked: high-confidence secret detected in tool input — see .pantheon/logs/hooks.log'
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Append one masked, ISO-stamped line to .pantheon/logs/hooks.log under the
|
|
702
|
+
* working directory, and return whether it landed.
|
|
703
|
+
*
|
|
704
|
+
* V1's `reportFailure` also writes to `client.app.log` and shows a TUI toast.
|
|
705
|
+
* The V2 `PluginContext` exposes NEITHER (it is
|
|
706
|
+
* `{options, agent, aisdk, catalog, command, integration, plugin, reference,
|
|
707
|
+
* skill}` — no `client`, no `directory`), so this module has only the file
|
|
708
|
+
* channel. That is a real reduction in observability versus V1, stated here
|
|
709
|
+
* rather than implied. Best-effort by contract: a logging failure must never
|
|
710
|
+
* take down the session.
|
|
711
|
+
*/
|
|
712
|
+
function appendV2HookLog(line: string): boolean {
|
|
713
|
+
const masked = maskSecret(line)
|
|
714
|
+
if (masked === '') return false
|
|
715
|
+
const stamp = new Date().toISOString()
|
|
716
|
+
// Multi-line scanner stderr would otherwise produce continuation lines with
|
|
717
|
+
// no timestamp (V1's P0-5 fix) — one message becomes N timestamped lines.
|
|
718
|
+
const body = masked
|
|
719
|
+
.split('\n')
|
|
720
|
+
.map((part) => part.trim())
|
|
721
|
+
.filter((part) => part !== '')
|
|
722
|
+
.map((part) => `[${stamp}] ${part}`)
|
|
723
|
+
.join('\n')
|
|
724
|
+
if (body === '') return false
|
|
725
|
+
try {
|
|
726
|
+
const dir = path.join(process.cwd(), '.pantheon', 'logs')
|
|
727
|
+
mkdirSync(dir, { recursive: true })
|
|
728
|
+
appendFileSync(path.join(dir, 'hooks.log'), `${body}\n`, 'utf8')
|
|
729
|
+
return true
|
|
730
|
+
} catch {
|
|
731
|
+
return false
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
501
735
|
// ─── V2 Tool Hooks ──────────────────────────────────────────────────────
|
|
502
736
|
|
|
737
|
+
/**
|
|
738
|
+
* The `tool.execute.before` event as the host dispatches it (opencode 2.0.22).
|
|
739
|
+
*
|
|
740
|
+
* Transcribed from the host's tool service:
|
|
741
|
+
*
|
|
742
|
+
* trigger("tool", "execute.before", { tool, sessionID, agent, messageID, id, input })
|
|
743
|
+
*
|
|
744
|
+
* Two shape differences from V1's positional `(input, output)` pair, both
|
|
745
|
+
* handled by {@link adaptV2ExecuteBeforeEvent}: the host passes ONE payload
|
|
746
|
+
* object, and the tool arguments live on `input` where V1 called them
|
|
747
|
+
* `output.args`.
|
|
748
|
+
*
|
|
749
|
+
* `agent` is a required field of the host's tool context
|
|
750
|
+
* (`ToolInvocation.context = { sessionID, agent, messageID, id }`), so the
|
|
751
|
+
* active agent arrives on the very event the enforcement guard needs. V1 had to
|
|
752
|
+
* learn it from a separate `chat.params` hook and keep a session→agent map; V2
|
|
753
|
+
* does not have to, and neither does this module.
|
|
754
|
+
*/
|
|
755
|
+
interface V2ToolExecuteBeforeEvent {
|
|
756
|
+
tool?: unknown
|
|
757
|
+
sessionID?: unknown
|
|
758
|
+
agent?: unknown
|
|
759
|
+
id?: unknown
|
|
760
|
+
input?: unknown
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Read-only enforcement for the V2 tool surface.
|
|
765
|
+
*
|
|
766
|
+
* The same factory, the same `DEFAULT_BLOCKED_TOOLS` and the same read-only set
|
|
767
|
+
* that V1 uses, so the two surfaces cannot drift on what counts as a mutating
|
|
768
|
+
* tool — `hashline_edit` and `pantheon_model` are denied here for exactly the
|
|
769
|
+
* reason they are denied in V1.
|
|
770
|
+
*
|
|
771
|
+
* Deliberately NOT passed `getSessionAgent` / `isRootSession` / `isChildSession`:
|
|
772
|
+
* those drive V1's native-`task()` delegation matrix, which needs a session
|
|
773
|
+
* hierarchy seeded from session metadata. `SessionHierarchyRegistry.isRoot`
|
|
774
|
+
* reports `true` for unknown sessions while unseeded, so passing an unseeded
|
|
775
|
+
* predicate here would DENY every `task()` call in every session. Leaving them
|
|
776
|
+
* out skips that branch (the guard tests `options?.isRootSession !== undefined`).
|
|
777
|
+
* `task` is still denied inside a read-only session via the blocked-tool list,
|
|
778
|
+
* so depth-2 holds for apollo/gaia; the caller/target matrix itself is NOT
|
|
779
|
+
* enforced on V2. That is a known gap, not a covered case.
|
|
780
|
+
*/
|
|
781
|
+
const v2EnforcementGuard = createEnforcementGuard({
|
|
782
|
+
getReadOnlySessions: () => readOnlyRegistry.sessionIDs(),
|
|
783
|
+
})
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Project a V2 `execute.before` event onto the two-argument shape
|
|
787
|
+
* `createEnforcementGuard` was built for.
|
|
788
|
+
*
|
|
789
|
+
* `tool` is the host's registration id, computed as
|
|
790
|
+
* `options.namespace === undefined ? name : `${namespace}_${name}``. Pantheon
|
|
791
|
+
* declares a namespace for catalog grouping but sets `options.namespace` on no
|
|
792
|
+
* tool, so the id is the bare name (`hashline_edit`) and the blocked-tool match
|
|
793
|
+
* fires. A tool that opted into `options.namespace` would gain a prefix and stop
|
|
794
|
+
* matching — the integration test in tests/pantheon/plugin-v2-contract.test.ts
|
|
795
|
+
* drives the real registration path and asserts on the registered names, so
|
|
796
|
+
* that drift fails a test instead of silently disabling enforcement.
|
|
797
|
+
*
|
|
798
|
+
* A missing `sessionID` maps to the empty key: the registry lookup then matches
|
|
799
|
+
* the event's own (absent) identity, so a blocked tool in a read-only session is
|
|
800
|
+
* still denied. Fail-closed on the one axis that matters.
|
|
801
|
+
*/
|
|
802
|
+
function adaptV2ExecuteBeforeEvent(event: unknown): {
|
|
803
|
+
input: ToolExecuteBeforeInput
|
|
804
|
+
output: ToolExecuteBeforeOutput
|
|
805
|
+
agent: unknown
|
|
806
|
+
} {
|
|
807
|
+
const payload = (event ?? {}) as V2ToolExecuteBeforeEvent
|
|
808
|
+
return {
|
|
809
|
+
input: {
|
|
810
|
+
tool: typeof payload.tool === 'string' ? payload.tool : '',
|
|
811
|
+
sessionID: typeof payload.sessionID === 'string' ? payload.sessionID : '',
|
|
812
|
+
callID: typeof payload.id === 'string' ? payload.id : '',
|
|
813
|
+
},
|
|
814
|
+
output: { args: payload.input },
|
|
815
|
+
agent: payload.agent,
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
503
819
|
/**
|
|
504
820
|
* Register tool execution hooks via V2 ctx.tool.hook.
|
|
505
821
|
*
|
|
@@ -510,11 +826,7 @@ async function registerV2SessionHooks(context: PluginContext): Promise<boolean>
|
|
|
510
826
|
* Returns true if any hooks were registered.
|
|
511
827
|
*/
|
|
512
828
|
async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
|
|
513
|
-
const toolCtx = (context
|
|
514
|
-
| {
|
|
515
|
-
hook?: (name: string, handler: (event: unknown) => void | Promise<void>) => Promise<unknown>
|
|
516
|
-
}
|
|
517
|
-
| undefined
|
|
829
|
+
const toolCtx = hostDomains(context).tool
|
|
518
830
|
|
|
519
831
|
if (!toolCtx?.hook) {
|
|
520
832
|
return false
|
|
@@ -523,16 +835,60 @@ async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
|
|
|
523
835
|
let registered = false
|
|
524
836
|
|
|
525
837
|
try {
|
|
526
|
-
// "execute.before" — read-only enforcement
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
838
|
+
// "execute.before" — read-only enforcement. Throwing from this handler
|
|
839
|
+
// DENIES the tool call and the host surfaces the message to the session,
|
|
840
|
+
// which is how the guard blocks a mutating tool in a read-only session.
|
|
841
|
+
//
|
|
842
|
+
// This is the enforcement point for the V2 surface, not a delegation to
|
|
843
|
+
// V1: `src/plugin.ts` is not loaded when only `plugin-v2` is configured
|
|
844
|
+
// (opencode.json `plugins`), so the V1 `tool.execute.before` hook does not
|
|
845
|
+
// exist in that process. The guard below is instantiated here and consulted
|
|
846
|
+
// here; without it the tools registered by registerV2Tools would be a live
|
|
847
|
+
// write path in every session, including read-only ones.
|
|
848
|
+
await toolCtx.hook('execute.before', async (event: unknown) => {
|
|
849
|
+
const { input, output, agent } = adaptV2ExecuteBeforeEvent(event)
|
|
850
|
+
// V2's equivalent of V1's `chat.params` trigger: the host puts the active
|
|
851
|
+
// agent on the event itself, so registering here needs no separate hook
|
|
852
|
+
// and no session→agent map. A non-read-only (or absent) agent revokes the
|
|
853
|
+
// registration, which also covers an in-session agent switch.
|
|
854
|
+
syncReadOnlySession(readOnlyRegistry, input.sessionID, agent)
|
|
855
|
+
// Deliberately OUTSIDE the try/catch below: a read-only denial must keep
|
|
856
|
+
// propagating to the host, not be swallowed as a scanner failure.
|
|
857
|
+
await v2EnforcementGuard(input, output)
|
|
858
|
+
|
|
859
|
+
// High-confidence secret scan (scripts/hooks/scan-secrets.sh). This is the
|
|
860
|
+
// ONLY hard block on the V2 surface, and it exists here — rather than via
|
|
861
|
+
// `src/plugins/pantheon-hooks.ts` — because that module is a separate
|
|
862
|
+
// plugin that is never loaded on a V2-only install: opencode.json
|
|
863
|
+
// `plugins` declares only `src/plugin-v2`. V1's scan-secrets block was
|
|
864
|
+
// therefore inert by construction, not by misconfiguration.
|
|
865
|
+
//
|
|
866
|
+
// exit 2 → block. exit 1 (low-confidence header/KEY names) → advisory,
|
|
867
|
+
// logged only. A scanner failure (missing script, timeout) is fail-OPEN:
|
|
868
|
+
// runHook never rejects, and an infrastructure error must not deny
|
|
869
|
+
// unrelated tool calls.
|
|
870
|
+
let blockError: Error | null = null
|
|
871
|
+
try {
|
|
872
|
+
const payload: HookPayload = {
|
|
873
|
+
tool_name: input.tool,
|
|
874
|
+
tool_input: output.args ?? {},
|
|
875
|
+
agent_id: typeof agent === 'string' ? agent : '',
|
|
876
|
+
session_id: input.sessionID,
|
|
877
|
+
}
|
|
878
|
+
const scan = await runHook('scan-secrets.sh', payload)
|
|
879
|
+
if (scan.code === 2) {
|
|
880
|
+
appendV2HookLog(SECRET_BLOCK_MESSAGE)
|
|
881
|
+
appendV2HookLog(scan.stderr)
|
|
882
|
+
blockError = new Error(SECRET_BLOCK_MESSAGE)
|
|
883
|
+
} else if (scan.code === 1) {
|
|
884
|
+
// Advisory: log only, never block.
|
|
885
|
+
appendV2HookLog(scan.stderr)
|
|
886
|
+
}
|
|
887
|
+
} catch {
|
|
888
|
+
// Never let the scanner take down the session — runHook resolves a
|
|
889
|
+
// structured result instead of rejecting, so this is belt-and-braces.
|
|
535
890
|
}
|
|
891
|
+
if (blockError !== null) throw blockError
|
|
536
892
|
})
|
|
537
893
|
registered = true
|
|
538
894
|
} catch {
|
|
@@ -540,13 +896,14 @@ async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
|
|
|
540
896
|
}
|
|
541
897
|
|
|
542
898
|
try {
|
|
543
|
-
// "execute.after" — hashline read enhance + context sandbox
|
|
899
|
+
// "execute.after" — hashline read enhance + context sandbox.
|
|
900
|
+
//
|
|
901
|
+
// Registration point only: there is no V2-side behaviour here. The
|
|
902
|
+
// enhancer/sandbox live in the V1 path (`pantheon-hooks.ts` +
|
|
903
|
+
// `context-sandbox.ts`), a separate plugin instance that is not loaded when
|
|
904
|
+
// only `plugin-v2` is configured.
|
|
544
905
|
await toolCtx.hook('execute.after', (_event: unknown) => {
|
|
545
|
-
//
|
|
546
|
-
const bridge = resolveBridge()
|
|
547
|
-
if (bridge?.visionHandler != null) {
|
|
548
|
-
// Bridge available: V1 vision handler + read enhancer handle post-execution.
|
|
549
|
-
}
|
|
906
|
+
// Intentionally empty.
|
|
550
907
|
})
|
|
551
908
|
registered = true
|
|
552
909
|
} catch {
|
|
@@ -567,11 +924,7 @@ async function registerV2ToolHooks(context: PluginContext): Promise<boolean> {
|
|
|
567
924
|
* Returns true if the hook was registered.
|
|
568
925
|
*/
|
|
569
926
|
async function registerV2PermissionHook(context: PluginContext): Promise<boolean> {
|
|
570
|
-
const permCtx = (context
|
|
571
|
-
| {
|
|
572
|
-
hook?: (name: string, handler: (event: unknown) => void | Promise<void>) => Promise<unknown>
|
|
573
|
-
}
|
|
574
|
-
| undefined
|
|
927
|
+
const permCtx = hostDomains(context).permission
|
|
575
928
|
|
|
576
929
|
if (!permCtx?.hook) {
|
|
577
930
|
markUnsupported('permission-hook')
|
|
@@ -580,8 +933,11 @@ async function registerV2PermissionHook(context: PluginContext): Promise<boolean
|
|
|
580
933
|
|
|
581
934
|
try {
|
|
582
935
|
await permCtx.hook('evaluate', (_event: unknown) => {
|
|
583
|
-
//
|
|
584
|
-
//
|
|
936
|
+
// Registration point only — no V2-side permission logic. Read-only
|
|
937
|
+
// enforcement does NOT run here: it runs in the `execute.before` handler
|
|
938
|
+
// above, which is the hook the host consults before a tool executes. Do
|
|
939
|
+
// not read this as "the V1 guards cover it": V1 is a different plugin
|
|
940
|
+
// instance and is not loaded when only `plugin-v2` is configured.
|
|
585
941
|
})
|
|
586
942
|
return true
|
|
587
943
|
} catch {
|
|
@@ -593,7 +949,15 @@ async function registerV2PermissionHook(context: PluginContext): Promise<boolean
|
|
|
593
949
|
// ─── Tool Definition Factory ─────────────────────────────────────────────
|
|
594
950
|
|
|
595
951
|
/**
|
|
596
|
-
*
|
|
952
|
+
* Minimal structural view of the V2 tool draft.
|
|
953
|
+
*
|
|
954
|
+
* `output` is REQUIRED. The host (opencode 2.0.22) enforces a biconditional on
|
|
955
|
+
* a tool's declared `output` and the value its `execute` resolves to:
|
|
956
|
+
* `def.output === undefined` together with `'output' in result` throws
|
|
957
|
+
* "Tool result declared output without an output schema", and a declared
|
|
958
|
+
* `output` whose result omits the key throws "Tool did not return its
|
|
959
|
+
* declared output". A missing declaration therefore fails every call, so the
|
|
960
|
+
* field is mandatory here rather than optional.
|
|
597
961
|
*/
|
|
598
962
|
interface V2ToolDraft {
|
|
599
963
|
namespace(config: { name: string; description: string }): void
|
|
@@ -601,91 +965,12 @@ interface V2ToolDraft {
|
|
|
601
965
|
name: string
|
|
602
966
|
description: string
|
|
603
967
|
input: Record<string, unknown>
|
|
968
|
+
output: Record<string, unknown>
|
|
604
969
|
execute: (input: Record<string, unknown>, context: unknown) => Promise<V2ToolResult>
|
|
605
970
|
}): void
|
|
606
971
|
}
|
|
607
972
|
|
|
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
|
-
}
|
|
973
|
+
export type { V2ToolDef, V2ToolResult } from './pantheon/v2-tools.ts'
|
|
689
974
|
|
|
690
975
|
// ─── Plugin Definition ───────────────────────────────────────────────────
|
|
691
976
|
|
|
@@ -713,10 +998,12 @@ export const plugin = define({
|
|
|
713
998
|
//
|
|
714
999
|
// Pantheon's Phase 1/config transform registrations cover agent, command,
|
|
715
1000
|
// and reference; ctx.tool.transform is attempted separately in Phase 2.
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
//
|
|
1001
|
+
// ctx.integration and ctx.skill are present on a live 2.0.22 host and neither
|
|
1002
|
+
// is registered here — that is a Pantheon scope choice, not a host gap.
|
|
1003
|
+
// ctx.catalog is the one domain the host does not provide.
|
|
1004
|
+
// Phase 1 registers against the SDK-typed `context.agent` / `.command` /
|
|
1005
|
+
// `.reference` directly; the four domains the SDK omits are reached only
|
|
1006
|
+
// through `hostDomains` (see the module header).
|
|
720
1007
|
const phase1Transforms: Array<{ feature: string; register: () => Promise<unknown> }> = [
|
|
721
1008
|
{ feature: 'agent-transform', register: () => context.agent.transform(transformAgents) },
|
|
722
1009
|
{
|
|
@@ -769,14 +1056,7 @@ export const plugin = define({
|
|
|
769
1056
|
await registerV2PermissionHook(context).catch(() => false)
|
|
770
1057
|
|
|
771
1058
|
// ─── Phase 7: V2 Compaction Hook (best-effort) ───────────────────
|
|
772
|
-
const compactionCtx = (context
|
|
773
|
-
| {
|
|
774
|
-
hook?: (
|
|
775
|
-
name: string,
|
|
776
|
-
handler: (event: unknown) => void | Promise<void>,
|
|
777
|
-
) => Promise<unknown>
|
|
778
|
-
}
|
|
779
|
-
| undefined
|
|
1059
|
+
const compactionCtx = hostDomains(context).session
|
|
780
1060
|
if (compactionCtx?.hook) {
|
|
781
1061
|
try {
|
|
782
1062
|
// 2.0.16 `SessionHooks` key is `compaction` (the migration doc maps
|
|
@@ -786,7 +1066,10 @@ export const plugin = define({
|
|
|
786
1066
|
// Proven by the canary's negative control.
|
|
787
1067
|
await compactionCtx.hook('compaction', (_event: unknown) => {
|
|
788
1068
|
// Compaction context build — injects active goals, pending todos,
|
|
789
|
-
// and in-flight delegations.
|
|
1069
|
+
// and in-flight delegations. No V2-side implementation: that build
|
|
1070
|
+
// lives in the V1 path, which is not loaded on a V2-only install.
|
|
1071
|
+
// This registration is a real hook (the canary below proves it
|
|
1072
|
+
// fires) with no V2 behaviour behind it.
|
|
790
1073
|
//
|
|
791
1074
|
// Opt-in lifetime proof for the host-backed hook canary
|
|
792
1075
|
// (tests/pantheon/plugin-v2-hook-canary.test.mjs, MODE=real). It is
|