pantheon-opencode 1.3.4 → 1.3.5-beta.57.2e03676
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/AGENTS.md +919 -0
- package/README.md +64 -3
- package/bin/pantheon-init.mjs +17 -0
- package/opencode.json +3 -11
- package/package.json +2 -1
- package/scripts/build-agents-md.mjs +195 -0
- package/scripts/cost.mjs +17 -0
- package/scripts/doctor.mjs +203 -2
- package/scripts/install/opencode.mjs +91 -22
- package/scripts/release-beta-version.mjs +80 -0
- package/scripts/validate-routing.mjs +165 -1
- package/src/agents/aphrodite.md +3 -1
- package/src/agents/apollo.md +2 -0
- package/src/agents/athena.md +4 -1
- package/src/agents/demeter.md +3 -1
- package/src/agents/gaia.md +2 -0
- package/src/agents/hephaestus.md +3 -1
- package/src/agents/hermes.md +4 -1
- package/src/agents/iris.md +3 -1
- package/src/agents/mnemosyne.md +3 -1
- package/src/agents/nyx.md +3 -1
- package/src/agents/prometheus.md +3 -1
- package/src/agents/talos.md +3 -1
- package/src/agents/themis.md +3 -1
- package/src/pantheon/cost-command.ts +57 -18
- package/src/pantheon/delegation-enforce.ts +139 -5
- package/src/pantheon/delegation-finalize.ts +24 -0
- package/src/pantheon/delegation.ts +128 -15
- package/src/plugin.ts +43 -3
- package/src/plugins/tui/package.json +1 -1
- package/src/routing.yml +12 -9
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* - missing/unreadable db → FRIENDLY error string, never a crash (tools
|
|
11
11
|
* return errors as TEXT).
|
|
12
12
|
*
|
|
13
|
-
* dbPath resolution: explicit option > env PANTHEON_COST_DB >
|
|
14
|
-
*
|
|
13
|
+
* dbPath resolution: explicit option > env PANTHEON_COST_DB > the version-aware
|
|
14
|
+
* default selected by PANTHEON_OPENCODE_VERSION (v1 or v2). An unset version
|
|
15
|
+
* deliberately preserves V1 behavior. The tool is wired in
|
|
15
16
|
* plugin.ts alongside the delegation toolset (usable by zeus and any agent
|
|
16
17
|
* with tool access — no routing change needed).
|
|
17
18
|
*
|
|
@@ -41,6 +42,8 @@ export interface CostCommandOptions {
|
|
|
41
42
|
dbPath?: string
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
export type OpenCodeVersion = 'v1' | 'v2'
|
|
46
|
+
|
|
44
47
|
/** One cost tool: description + zod args shape + execute (delegation.ts shape). */
|
|
45
48
|
export interface CostTool<Args extends z.ZodRawShape = typeof costArgs> {
|
|
46
49
|
description: string
|
|
@@ -62,15 +65,32 @@ const costArgs = {
|
|
|
62
65
|
.describe('Number of days of delegation history to include (default 7).'),
|
|
63
66
|
} satisfies z.ZodRawShape
|
|
64
67
|
|
|
65
|
-
/**
|
|
66
|
-
function
|
|
67
|
-
const candidates: string[] = []
|
|
68
|
-
const env = process.env.PANTHEON_COST_DB
|
|
69
|
-
if (env !== undefined && env !== '') candidates.push(env)
|
|
68
|
+
/** Return the safe, isolated state DB name for an OpenCode version. */
|
|
69
|
+
function defaultDbPath(version: OpenCodeVersion | undefined): string {
|
|
70
70
|
const xdg = process.env.XDG_DATA_HOME
|
|
71
71
|
const dataHome = xdg !== undefined && xdg !== '' ? xdg : join(homedir(), '.local', 'share')
|
|
72
|
-
|
|
73
|
-
return
|
|
72
|
+
const filename = version === 'v2' ? 'opencode-v2.db' : 'opencode.db'
|
|
73
|
+
return join(dataHome, 'opencode', filename)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the database without probing another version's database.
|
|
78
|
+
*
|
|
79
|
+
* Explicit paths always win. A version selector changes exactly one default
|
|
80
|
+
* filename; it never falls back to V1, which prevents a V2 report from
|
|
81
|
+
* silently reading V1 history (or vice versa).
|
|
82
|
+
*/
|
|
83
|
+
export function resolveCostDbPath(options?: CostCommandOptions): string {
|
|
84
|
+
const explicit = options?.dbPath || process.env.PANTHEON_COST_DB
|
|
85
|
+
if (explicit) return explicit
|
|
86
|
+
|
|
87
|
+
const configuredVersion = process.env.PANTHEON_OPENCODE_VERSION
|
|
88
|
+
if (configuredVersion !== undefined && configuredVersion !== 'v1' && configuredVersion !== 'v2') {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`invalid PANTHEON_OPENCODE_VERSION "${configuredVersion}"; expected "v1" or "v2"`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
return defaultDbPath(configuredVersion as OpenCodeVersion | undefined)
|
|
74
94
|
}
|
|
75
95
|
|
|
76
96
|
/**
|
|
@@ -79,11 +99,28 @@ function dbCandidates(): string[] {
|
|
|
79
99
|
* to the real opencode.db).
|
|
80
100
|
*/
|
|
81
101
|
function findExistingDb(override?: string): string | undefined {
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
102
|
+
const candidate =
|
|
103
|
+
override === undefined ? resolveCostDbPath() : resolveCostDbPath({ dbPath: override })
|
|
104
|
+
return existsSync(candidate) ? candidate : undefined
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function assertCompatibleSchema(db: {
|
|
108
|
+
prepare(sql: string): { get(): unknown; all(...args: unknown[]): unknown[] }
|
|
109
|
+
}): void {
|
|
110
|
+
const row = db
|
|
111
|
+
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'message'")
|
|
112
|
+
.get()
|
|
113
|
+
if (row === undefined) {
|
|
114
|
+
throw new Error('incompatible opencode.db schema: required "message" table is missing')
|
|
115
|
+
}
|
|
116
|
+
const columns = db.prepare('PRAGMA table_info(message)').all() as Array<{ name?: unknown }>
|
|
117
|
+
const names = new Set(columns.map((column) => column.name))
|
|
118
|
+
const missing = ['data', 'time_created'].filter((name) => !names.has(name))
|
|
119
|
+
if (missing.length > 0) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`incompatible opencode.db schema: message table is missing ${missing.join(', ')}; select the matching V1/V2 database`,
|
|
122
|
+
)
|
|
85
123
|
}
|
|
86
|
-
return undefined
|
|
87
124
|
}
|
|
88
125
|
|
|
89
126
|
/** Aggregate cost + tokens by agent via node:sqlite (read-only). */
|
|
@@ -91,6 +128,7 @@ async function queryWithNodeSqlite(dbPath: string, days: number): Promise<CostRo
|
|
|
91
128
|
const sqlite = await import('node:sqlite')
|
|
92
129
|
const db = new sqlite.DatabaseSync(dbPath, { readOnly: true })
|
|
93
130
|
try {
|
|
131
|
+
assertCompatibleSchema(db)
|
|
94
132
|
const since = Date.now() - days * 86_400_000
|
|
95
133
|
const rows = db.prepare('SELECT data FROM message WHERE time_created >= ?').all(since)
|
|
96
134
|
return aggregateMessages(rows.map((row) => String(row.data)))
|
|
@@ -180,16 +218,17 @@ export function createCostCommand(options?: CostCommandOptions): CostCommand {
|
|
|
180
218
|
try {
|
|
181
219
|
const dbPath = findExistingDb(options?.dbPath)
|
|
182
220
|
if (dbPath === undefined) {
|
|
183
|
-
const expected =
|
|
184
|
-
options?.dbPath ?? process.env.PANTHEON_COST_DB ?? '~/.local/share/opencode/opencode.db'
|
|
221
|
+
const expected = resolveCostDbPath(options)
|
|
185
222
|
return (
|
|
186
223
|
`pantheon_cost failed: opencode.db not found (expected at ${expected}). ` +
|
|
187
224
|
`No cost history is available until opencode has stored session data.`
|
|
188
225
|
)
|
|
189
226
|
}
|
|
190
|
-
const rows = await queryWithNodeSqlite(dbPath, days).catch(async () =>
|
|
191
|
-
|
|
192
|
-
|
|
227
|
+
const rows = await queryWithNodeSqlite(dbPath, days).catch(async (err: unknown) => {
|
|
228
|
+
if (err instanceof Error && err.message.startsWith('incompatible opencode.db schema'))
|
|
229
|
+
throw err
|
|
230
|
+
return queryWithScript(scriptPath, dbPath, days)
|
|
231
|
+
})
|
|
193
232
|
return renderMarkdown(rows, days)
|
|
194
233
|
} catch (err: unknown) {
|
|
195
234
|
const reason = err instanceof Error ? err.message : String(err)
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
* - `task` is blocked too, which hard-enforces depth-2: a read-only agent
|
|
19
19
|
* cannot spawn its own subagents.
|
|
20
20
|
*
|
|
21
|
-
* Unknown sessions are
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* Unknown sessions are allowed for ordinary tools. Delegation tools are
|
|
22
|
+
* fail-closed when the runtime matrix is wired: missing agent identity or
|
|
23
|
+
* target metadata is denied rather than inferred.
|
|
24
24
|
*
|
|
25
25
|
* The registry is populated by delegation.ts at delegate time: the CHILD
|
|
26
26
|
* session is registered when the delegate call is read-only.
|
|
@@ -47,8 +47,100 @@ export interface ToolExecuteBeforeInput {
|
|
|
47
47
|
callID: string
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** The output object supplied alongside `tool.execute.before`. */
|
|
51
|
+
export interface ToolExecuteBeforeOutput {
|
|
52
|
+
args?: unknown
|
|
53
|
+
}
|
|
54
|
+
|
|
50
55
|
/** The `tool.execute.before` handler shape (throw ⇒ deny). */
|
|
51
|
-
export type ToolExecuteBeforeHandler = (
|
|
56
|
+
export type ToolExecuteBeforeHandler = (
|
|
57
|
+
input: ToolExecuteBeforeInput,
|
|
58
|
+
output?: ToolExecuteBeforeOutput,
|
|
59
|
+
) => Promise<void>
|
|
60
|
+
|
|
61
|
+
/** Runtime session hierarchy, populated only from SDK session metadata. */
|
|
62
|
+
export class SessionHierarchyRegistry {
|
|
63
|
+
private readonly roots = new Set<string>()
|
|
64
|
+
private readonly children = new Set<string>()
|
|
65
|
+
private seedState: 'unseeded' | 'pending' | 'seeded' | 'failed' = 'unseeded'
|
|
66
|
+
|
|
67
|
+
/** Mark the beginning of an authoritative session snapshot lookup. */
|
|
68
|
+
beginSeed(): void {
|
|
69
|
+
this.seedState = 'pending'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Complete the lookup and register the SDK's authoritative parentID data. */
|
|
73
|
+
completeSeed(sessions: ReadonlyArray<{ id: string; parentID?: string }>): void {
|
|
74
|
+
this.registerMany(sessions)
|
|
75
|
+
this.seedState = 'seeded'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Record an unavailable snapshot without classifying unknown sessions as children. */
|
|
79
|
+
failSeed(): void {
|
|
80
|
+
this.seedState = 'failed'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Register a session using its SDK parentID (or lack of one). */
|
|
84
|
+
register(session: { id: string; parentID?: string }): void {
|
|
85
|
+
if (session.parentID !== undefined) {
|
|
86
|
+
this.children.add(session.id)
|
|
87
|
+
this.roots.delete(session.id)
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
if (!this.children.has(session.id)) this.roots.add(session.id)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Register a snapshot returned by session.list(). */
|
|
94
|
+
registerMany(sessions: ReadonlyArray<{ id: string; parentID?: string }>): void {
|
|
95
|
+
for (const session of sessions) this.register(session)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether SDK metadata identified this session as a child. */
|
|
99
|
+
isChild(sessionID: string): boolean {
|
|
100
|
+
return this.children.has(sessionID)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Whether SDK metadata identified this session as a root. */
|
|
104
|
+
isRoot(sessionID: string): boolean {
|
|
105
|
+
if (this.children.has(sessionID)) return false
|
|
106
|
+
if (this.roots.has(sessionID)) return true
|
|
107
|
+
// Until the authoritative snapshot is available, an unknown session is
|
|
108
|
+
// deliberately unclassified. A valid root resumed after restart must not
|
|
109
|
+
// be denied merely because session.list() is slow or unavailable.
|
|
110
|
+
return this.seedState !== 'seeded'
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Snapshot of known root IDs. */
|
|
114
|
+
rootIDs(): ReadonlySet<string> {
|
|
115
|
+
return new Set(this.roots)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Normalize an agent identity without guessing from prompts or tool names. */
|
|
120
|
+
export function normalizeDelegationAgent(agent: unknown): string | undefined {
|
|
121
|
+
if (typeof agent !== 'string') return undefined
|
|
122
|
+
const normalized = agent.trim().toLowerCase()
|
|
123
|
+
return normalized === '' ? undefined : normalized
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Runtime delegation matrix from Fase B2. */
|
|
127
|
+
export function isDelegationAllowed(callerAgent: unknown, targetAgent: unknown): boolean {
|
|
128
|
+
const caller = normalizeDelegationAgent(callerAgent)
|
|
129
|
+
const target = normalizeDelegationAgent(targetAgent)
|
|
130
|
+
if (caller === undefined || target === undefined) return false
|
|
131
|
+
if (caller === 'zeus') return true
|
|
132
|
+
return (caller === 'athena' || caller === 'hermes') && target === 'apollo'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function targetFromTool(tool: string, args: unknown): string | undefined {
|
|
136
|
+
if (args === null || typeof args !== 'object') return undefined
|
|
137
|
+
const record = args as Record<string, unknown>
|
|
138
|
+
if (tool === 'pantheon_delegate') return normalizeDelegationAgent(record.agent)
|
|
139
|
+
if (tool === 'task') {
|
|
140
|
+
return normalizeDelegationAgent(record.subagent_type ?? record.subagentType)
|
|
141
|
+
}
|
|
142
|
+
return undefined
|
|
143
|
+
}
|
|
52
144
|
|
|
53
145
|
/** Options for createEnforcementGuard(). */
|
|
54
146
|
export interface EnforcementGuardOptions {
|
|
@@ -56,6 +148,14 @@ export interface EnforcementGuardOptions {
|
|
|
56
148
|
blockedTools?: ReadonlySet<string>
|
|
57
149
|
/** Prefix for the denial message. */
|
|
58
150
|
messagePrefix?: string
|
|
151
|
+
/** Current agent by session, populated by the SDK chat.params hook. */
|
|
152
|
+
getSessionAgent?: (sessionID: string) => string | undefined
|
|
153
|
+
/** SDK-backed root check used for the delegation matrix. */
|
|
154
|
+
isRootSession?: (sessionID: string) => boolean
|
|
155
|
+
/** SDK-backed child check used to prevent native task() depth escalation. */
|
|
156
|
+
isChildSession?: (sessionID: string) => boolean
|
|
157
|
+
/** Audit sink for denied runtime delegation attempts. */
|
|
158
|
+
logger?: { warn?: (message: string) => void }
|
|
59
159
|
}
|
|
60
160
|
|
|
61
161
|
// ─── Defaults ──────────────────────────────────────────────────────────
|
|
@@ -129,7 +229,41 @@ export function createEnforcementGuard(input: {
|
|
|
129
229
|
const blockedTools = input.options?.blockedTools ?? DEFAULT_BLOCKED_TOOLS
|
|
130
230
|
const prefix = input.options?.messagePrefix ?? 'pantheon_delegate guard'
|
|
131
231
|
|
|
132
|
-
return async (hook: ToolExecuteBeforeInput): Promise<void> => {
|
|
232
|
+
return async (hook: ToolExecuteBeforeInput, output?: ToolExecuteBeforeOutput): Promise<void> => {
|
|
233
|
+
const isDelegationTool = hook.tool === 'task' || hook.tool === 'pantheon_delegate'
|
|
234
|
+
if (isDelegationTool && input.options?.isChildSession?.(hook.sessionID) === true) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`${prefix}: session ${hook.sessionID} is a child session — delegation tools are denied ` +
|
|
237
|
+
'to prevent nested delegation',
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
// pantheon_delegate has the authoritative ToolContext.agent at execution
|
|
241
|
+
// time, so its matrix check lives in delegation.ts. The hook only has the
|
|
242
|
+
// session-scoped agent learned from chat.params for native task().
|
|
243
|
+
if (
|
|
244
|
+
hook.tool === 'task' &&
|
|
245
|
+
input.options?.isRootSession !== undefined &&
|
|
246
|
+
input.options?.isChildSession?.(hook.sessionID) !== true
|
|
247
|
+
) {
|
|
248
|
+
const caller = input.options.getSessionAgent?.(hook.sessionID)
|
|
249
|
+
const target = targetFromTool(hook.tool, output?.args)
|
|
250
|
+
if (!input.options.isRootSession(hook.sessionID)) {
|
|
251
|
+
input.options.logger?.warn?.(
|
|
252
|
+
`${prefix}: delegation denied for session ${hook.sessionID} — not an authorized root session`,
|
|
253
|
+
)
|
|
254
|
+
throw new Error(`${prefix}: session ${hook.sessionID} is not an authorized root session`)
|
|
255
|
+
}
|
|
256
|
+
if (!isDelegationAllowed(caller, target)) {
|
|
257
|
+
const reason =
|
|
258
|
+
caller === undefined ? 'caller agent is unavailable' : 'agent/target is not allowed'
|
|
259
|
+
input.options.logger?.warn?.(
|
|
260
|
+
`${prefix}: delegation denied for session ${hook.sessionID} — ${reason}`,
|
|
261
|
+
)
|
|
262
|
+
throw new Error(
|
|
263
|
+
`${prefix}: delegation denied — ${reason}; target must follow the runtime matrix`,
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
133
267
|
if (!blockedTools.has(hook.tool)) return
|
|
134
268
|
if (!input.getReadOnlySessions().has(hook.sessionID)) return
|
|
135
269
|
|
|
@@ -65,6 +65,16 @@ export interface DelegationOptions {
|
|
|
65
65
|
rootSessions?: ReadonlySet<string>
|
|
66
66
|
/** Custom depth-guard predicate — overrides rootSessions and the default. */
|
|
67
67
|
isRootSession?: (sessionID: string) => boolean
|
|
68
|
+
/** SDK-backed child predicate; authoritative when supplied by the plugin. */
|
|
69
|
+
isChildSession?: (sessionID: string) => boolean
|
|
70
|
+
/**
|
|
71
|
+
* Enforce the runtime B2 delegation matrix; createDelegationTools defaults
|
|
72
|
+
* this to true. Set false only for an explicitly legacy host that cannot
|
|
73
|
+
* provide ToolContext.agent.
|
|
74
|
+
*/
|
|
75
|
+
enforceRuntimeMatrix?: boolean
|
|
76
|
+
/** Record a child returned by the SDK session.create call. */
|
|
77
|
+
registerChildSession?: (sessionID: string, parentID: string) => void
|
|
68
78
|
/**
|
|
69
79
|
* Agents whose delegated child sessions are registered as read-only
|
|
70
80
|
* (Phase 4 enforcement). Mirrors routing.yml
|
|
@@ -78,6 +88,20 @@ export interface DelegationOptions {
|
|
|
78
88
|
* from routing.yml agent entries. Higher priority than the active preset.
|
|
79
89
|
*/
|
|
80
90
|
agentModels?: Readonly<Record<string, string>>
|
|
91
|
+
/**
|
|
92
|
+
* Fase B3: Per-exception delegation budgets. Keys use
|
|
93
|
+
* `<parent-agent>-><target-agent>` (for example `athena->apollo`). The
|
|
94
|
+
* count is tracked per parent session for this process. It is intentionally
|
|
95
|
+
* not persisted across restart because this layer has no reliable store.
|
|
96
|
+
* An absent key has no budget limit.
|
|
97
|
+
*/
|
|
98
|
+
delegationBudgets?: ReadonlyMap<string, number>
|
|
99
|
+
/**
|
|
100
|
+
* Fase B3: Wall-clock timeout (ms) for individual delegation sessions.
|
|
101
|
+
* The plugin resolves `PANTHEON_DELEGATION_TIMEOUT_MS` into this option;
|
|
102
|
+
* direct callers may provide it here instead.
|
|
103
|
+
*/
|
|
104
|
+
wallClockTimeoutMs?: number
|
|
81
105
|
/**
|
|
82
106
|
* Env override for the active-preset resolution (see resolveActivePreset).
|
|
83
107
|
* Defaults to process.env. Primarily for tests.
|
|
@@ -19,19 +19,13 @@
|
|
|
19
19
|
* Depth guard (root-session detection): the ToolContext carries NO parentID
|
|
20
20
|
* (spike), so root detection is configurable. Resolution order:
|
|
21
21
|
* 1. `options.isRootSession(sessionID)` custom predicate;
|
|
22
|
-
* 2. `options.rootSessions` explicit allowlist
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* Sessions in neither set are treated as root, so a resumed root can
|
|
26
|
-
* delegate after a restart even when the allowlist is incomplete
|
|
27
|
-
* (opencode does not replay `session.created` events for pre-existing
|
|
28
|
-
* sessions — the plugin seeds the registry from session.list() at
|
|
29
|
-
* startup, and this fallback covers a failed/incomplete seed);
|
|
22
|
+
* 2. `options.rootSessions` explicit allowlist, with `options.isChildSession`
|
|
23
|
+
* authoritative when supplied by the plugin. SDK session metadata is
|
|
24
|
+
* registered from parentID; no prompt/name inference is used.
|
|
30
25
|
* 3. default (no rootSessions): any session WE created via
|
|
31
26
|
* `session.create` is a sub-session; everything else is treated as
|
|
32
|
-
* root
|
|
33
|
-
*
|
|
34
|
-
* authoritative enforcement.
|
|
27
|
+
* root for standalone compatibility. The plugin supplies the SDK-backed
|
|
28
|
+
* hierarchy at runtime.
|
|
35
29
|
*
|
|
36
30
|
* Read-only enforcement is Phase 4 — `read_only` is exposed on the delegate
|
|
37
31
|
* args and the child session is registered in the read-only registry when the
|
|
@@ -44,7 +38,11 @@
|
|
|
44
38
|
import { z } from 'zod'
|
|
45
39
|
|
|
46
40
|
import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
|
|
47
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
isDelegationAllowed,
|
|
43
|
+
normalizeDelegationAgent,
|
|
44
|
+
readOnlyRegistry,
|
|
45
|
+
} from './delegation-enforce.ts'
|
|
48
46
|
import {
|
|
49
47
|
DELEGATION_DEFAULTS,
|
|
50
48
|
type DelegationClient,
|
|
@@ -67,6 +65,49 @@ export type {
|
|
|
67
65
|
} from './delegation-finalize.ts'
|
|
68
66
|
export { DELEGATION_DEFAULTS } from './delegation-finalize.ts'
|
|
69
67
|
|
|
68
|
+
// Fase B3: these are runtime controls, not routing/frontmatter fields. The
|
|
69
|
+
// plugin reads them from the environment, while tests and embedders can pass
|
|
70
|
+
// the same values through DelegationOptions.
|
|
71
|
+
export const B3_DEFAULT_EXCEPTION_BUDGET = 5
|
|
72
|
+
|
|
73
|
+
function readIntegerEnv(
|
|
74
|
+
env: Record<string, string | undefined>,
|
|
75
|
+
name: string,
|
|
76
|
+
fallback: number,
|
|
77
|
+
minimum: number,
|
|
78
|
+
): number {
|
|
79
|
+
const raw = env[name]?.trim()
|
|
80
|
+
if (raw === undefined || raw === '') return fallback
|
|
81
|
+
const value = Number(raw)
|
|
82
|
+
return Number.isSafeInteger(value) && value >= minimum ? value : fallback
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Resolve the two supported read-only delegation exception budgets. */
|
|
86
|
+
export function resolveDelegationBudgets(
|
|
87
|
+
env: Record<string, string | undefined> = process.env,
|
|
88
|
+
): ReadonlyMap<string, number> {
|
|
89
|
+
return new Map([
|
|
90
|
+
[
|
|
91
|
+
'athena->apollo',
|
|
92
|
+
readIntegerEnv(env, 'PANTHEON_ATHENA_APOLLO_BUDGET', B3_DEFAULT_EXCEPTION_BUDGET, 0),
|
|
93
|
+
],
|
|
94
|
+
[
|
|
95
|
+
'hermes->apollo',
|
|
96
|
+
readIntegerEnv(env, 'PANTHEON_HERMES_APOLLO_BUDGET', B3_DEFAULT_EXCEPTION_BUDGET, 0),
|
|
97
|
+
],
|
|
98
|
+
])
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Resolve an optional positive wall-clock timeout from the environment. */
|
|
102
|
+
export function resolveDelegationTimeoutMs(
|
|
103
|
+
env: Record<string, string | undefined> = process.env,
|
|
104
|
+
): number | undefined {
|
|
105
|
+
const raw = env.PANTHEON_DELEGATION_TIMEOUT_MS?.trim()
|
|
106
|
+
if (raw === undefined || raw === '') return undefined
|
|
107
|
+
const value = Number(raw)
|
|
108
|
+
return Number.isSafeInteger(value) && value > 0 ? value : undefined
|
|
109
|
+
}
|
|
110
|
+
|
|
70
111
|
// ─── Types ─────────────────────────────────────────────────────────────
|
|
71
112
|
|
|
72
113
|
// Silence-by-default TUI policy (pantheon-hooks L42-58): console output in a
|
|
@@ -79,6 +120,11 @@ export interface ToolContextLike {
|
|
|
79
120
|
sessionID: string
|
|
80
121
|
directory?: string
|
|
81
122
|
worktree?: string
|
|
123
|
+
/**
|
|
124
|
+
* The OpenCode SDK's ToolContext declares this as required. It remains
|
|
125
|
+
* optional here because the structural test/embedding surface can be
|
|
126
|
+
* supplied by older hosts; B3 must skip enforcement when it is absent.
|
|
127
|
+
*/
|
|
82
128
|
agent?: string
|
|
83
129
|
}
|
|
84
130
|
|
|
@@ -146,6 +192,9 @@ function stateLabel(state: BackgroundJobRecord['state']): string {
|
|
|
146
192
|
}
|
|
147
193
|
}
|
|
148
194
|
|
|
195
|
+
/** Normalize the SDK-provided caller agent without inventing an identity. */
|
|
196
|
+
const normalizeToolAgent = normalizeDelegationAgent
|
|
197
|
+
|
|
149
198
|
// ─── Agent activity sampling ────────────────────────────────────────────
|
|
150
199
|
//
|
|
151
200
|
// Visibility for `pantheon_delegation_read` / `pantheon_delegation_list`
|
|
@@ -478,16 +527,24 @@ export function collectRootSessionIDs(
|
|
|
478
527
|
*/
|
|
479
528
|
export function createDelegationTools(input: CreateDelegationToolsInput): DelegationToolset {
|
|
480
529
|
const { board, client } = input
|
|
481
|
-
|
|
530
|
+
// Runtime matrix enforcement is the safe default for every host. A legacy
|
|
531
|
+
// structural embedder must opt out explicitly with `false`; omission must
|
|
532
|
+
// never silently widen the delegation surface.
|
|
533
|
+
const options: DelegationOptions = { enforceRuntimeMatrix: true, ...(input.options ?? {}) }
|
|
482
534
|
const deps: DelegationDeps = { board, client, options }
|
|
483
535
|
const outputDir = options.outputDir ?? DELEGATION_DEFAULTS.outputDir
|
|
484
|
-
const timeoutMs = options.timeoutMs ?? DELEGATION_DEFAULTS.timeoutMs
|
|
536
|
+
const timeoutMs = options.wallClockTimeoutMs ?? options.timeoutMs ?? DELEGATION_DEFAULTS.timeoutMs
|
|
485
537
|
const readTimeoutMs = options.readTimeoutMs ?? DELEGATION_DEFAULTS.readTimeoutMs
|
|
486
538
|
|
|
487
539
|
const knownChildren = new Set<string>()
|
|
488
540
|
const timers = new Map<string, NodeJS.Timeout>()
|
|
541
|
+
// Fase B3: per-parent-session delegation budget tracking, scoped to this
|
|
542
|
+
// factory/process instance. Keyed by `${parentSessionID}:${parentAgent}->${targetAgent}`;
|
|
543
|
+
// it is not restart-persistent because the factory has no reliable store.
|
|
544
|
+
const budgetUsage = new Map<string, number>()
|
|
489
545
|
|
|
490
546
|
function isRootSession(sessionID: string): boolean {
|
|
547
|
+
if (options.isChildSession?.(sessionID) === true) return false
|
|
491
548
|
if (options.isRootSession !== undefined) return options.isRootSession(sessionID)
|
|
492
549
|
if (options.rootSessions !== undefined) {
|
|
493
550
|
// compaction-134 (fix layer 2): the allowlist alone is NOT
|
|
@@ -540,6 +597,54 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
540
597
|
)
|
|
541
598
|
}
|
|
542
599
|
|
|
600
|
+
// Fase B3: only the two read-only exceptions are budgeted. The official
|
|
601
|
+
// SDK provides the current agent on ToolContext. If a structural/older
|
|
602
|
+
// host omits it, do NOT infer identity from prompt, target, or process
|
|
603
|
+
// state: the budget is explicitly skipped and the delegation continues.
|
|
604
|
+
const parentAgent = normalizeToolAgent(ctx.agent)
|
|
605
|
+
const targetAgent = args.agent.toLowerCase()
|
|
606
|
+
if (options.enforceRuntimeMatrix === true && !isDelegationAllowed(parentAgent, targetAgent)) {
|
|
607
|
+
const reason =
|
|
608
|
+
parentAgent === undefined
|
|
609
|
+
? 'ToolContext.agent is unavailable'
|
|
610
|
+
: `${parentAgent}->${targetAgent} is not allowed`
|
|
611
|
+
options.logger?.warn?.(
|
|
612
|
+
`[pantheon-delegate] runtime delegation denied for session ${ctx.sessionID}: ${reason}`,
|
|
613
|
+
)
|
|
614
|
+
throw new Error(`pantheon_delegate rejected: runtime delegation matrix denied (${reason})`)
|
|
615
|
+
}
|
|
616
|
+
if (
|
|
617
|
+
parentAgent === undefined &&
|
|
618
|
+
!options.enforceRuntimeMatrix &&
|
|
619
|
+
(options.delegationBudgets?.size ?? 0) > 0
|
|
620
|
+
) {
|
|
621
|
+
const message =
|
|
622
|
+
`[pantheon-delegate] B3 budget skipped: current agent unavailable for session ${ctx.sessionID}; ` +
|
|
623
|
+
`no budget limit applied to ${targetAgent}. Runtime must provide ToolContext.agent ` +
|
|
624
|
+
'(the official SDK field) to enforce the configured exception budget.'
|
|
625
|
+
;(options.logger?.warn ?? ((msg: string) => log.warn(msg)))(message)
|
|
626
|
+
}
|
|
627
|
+
const budgetKey =
|
|
628
|
+
parentAgent === undefined ? undefined : `${ctx.sessionID}:${parentAgent}->${targetAgent}`
|
|
629
|
+
const budget =
|
|
630
|
+
parentAgent === undefined
|
|
631
|
+
? undefined
|
|
632
|
+
: options.delegationBudgets?.get(`${parentAgent}->${targetAgent}`)
|
|
633
|
+
let budgetReserved = false
|
|
634
|
+
if (budgetKey !== undefined && budget !== undefined) {
|
|
635
|
+
const used = budgetUsage.get(budgetKey) ?? 0
|
|
636
|
+
if (used >= budget) {
|
|
637
|
+
throw new Error(
|
|
638
|
+
`pantheon_delegate rejected: delegation budget exhausted for ${parentAgent}->${targetAgent} ` +
|
|
639
|
+
`(${used}/${budget} dispatches used in this session). ` +
|
|
640
|
+
'Configure the corresponding PANTHEON_*_APOLLO_BUDGET environment variable or DelegationOptions.',
|
|
641
|
+
)
|
|
642
|
+
}
|
|
643
|
+
// Reserve before the first await so concurrent calls cannot overshoot.
|
|
644
|
+
budgetUsage.set(budgetKey, used + 1)
|
|
645
|
+
budgetReserved = true
|
|
646
|
+
}
|
|
647
|
+
|
|
543
648
|
// session.create failure → return a clear error as TEXT (tools return
|
|
544
649
|
// errors as text, not thrown) and register NO job on the board — an
|
|
545
650
|
// unhandled rejection here would otherwise lose the failure entirely.
|
|
@@ -551,7 +656,11 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
551
656
|
// opencode/deepseek-v4-flash-free; if nothing usable remains the
|
|
552
657
|
// failure is returned as TEXT below — no session, no board job.
|
|
553
658
|
const resolved = resolveUsableChildModel(options, args.agent, args.model)
|
|
554
|
-
if (resolved.error !== undefined)
|
|
659
|
+
if (resolved.error !== undefined) {
|
|
660
|
+
if (budgetReserved && budgetKey !== undefined)
|
|
661
|
+
budgetUsage.set(budgetKey, (budgetUsage.get(budgetKey) ?? 1) - 1)
|
|
662
|
+
return resolved.error
|
|
663
|
+
}
|
|
555
664
|
const childModel = resolved.model
|
|
556
665
|
let created: { id: string }
|
|
557
666
|
try {
|
|
@@ -564,10 +673,14 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
564
673
|
})
|
|
565
674
|
} catch (err: unknown) {
|
|
566
675
|
const reason = err instanceof Error ? err.message : String(err)
|
|
676
|
+
if (budgetReserved && budgetKey !== undefined) {
|
|
677
|
+
budgetUsage.set(budgetKey, Math.max(0, (budgetUsage.get(budgetKey) ?? 1) - 1))
|
|
678
|
+
}
|
|
567
679
|
return `pantheon_delegate failed to create child session: ${reason}`
|
|
568
680
|
}
|
|
569
681
|
const childSessionID = created.id
|
|
570
682
|
knownChildren.add(childSessionID)
|
|
683
|
+
options.registerChildSession?.(childSessionID, ctx.sessionID)
|
|
571
684
|
|
|
572
685
|
// Phase 4 read-only enforcement: register the child session in the
|
|
573
686
|
// read-only registry when the delegate is read-only — explicit
|
package/src/plugin.ts
CHANGED
|
@@ -7,9 +7,15 @@ import {
|
|
|
7
7
|
collectRootSessionIDs,
|
|
8
8
|
createDelegationTools,
|
|
9
9
|
type DelegationClient,
|
|
10
|
+
resolveDelegationBudgets,
|
|
11
|
+
resolveDelegationTimeoutMs,
|
|
10
12
|
} from './pantheon/delegation.ts'
|
|
11
13
|
import { buildCompactionContext } from './pantheon/delegation-compaction.ts'
|
|
12
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
createEnforcementGuard,
|
|
16
|
+
readOnlyRegistry,
|
|
17
|
+
SessionHierarchyRegistry,
|
|
18
|
+
} from './pantheon/delegation-enforce.ts'
|
|
13
19
|
import { handleDelegationEvent } from './pantheon/delegation-notify.ts'
|
|
14
20
|
import { FilePersistenceAdapter } from './pantheon/file-persistence.ts'
|
|
15
21
|
import { GOAL_LOOP_DEFAULTS, GoalLoop, GoalStore } from './pantheon/goal-loop.ts'
|
|
@@ -94,6 +100,12 @@ setInterval(
|
|
|
94
100
|
// from tool.execute.before; unknown sessions are allowed (safe default).
|
|
95
101
|
const enforcementGuard = createEnforcementGuard({
|
|
96
102
|
getReadOnlySessions: () => readOnlyRegistry.sessionIDs(),
|
|
103
|
+
options: {
|
|
104
|
+
getSessionAgent: (sessionID) => sessionAgents.get(sessionID),
|
|
105
|
+
isRootSession: (sessionID) => rootSessions.has(sessionID) || sessionHierarchy.isRoot(sessionID),
|
|
106
|
+
isChildSession: (sessionID) => sessionHierarchy.isChild(sessionID),
|
|
107
|
+
logger: log,
|
|
108
|
+
},
|
|
97
109
|
})
|
|
98
110
|
|
|
99
111
|
// Mirrors routing.yml background_delegation.max_compaction_items.
|
|
@@ -111,6 +123,8 @@ const COMPACTION_MAX_ITEMS = 10
|
|
|
111
123
|
// created is a sub-session; unknown sessions are roots") so the depth guard
|
|
112
124
|
// works even when this set is incomplete.
|
|
113
125
|
const rootSessions = new Set<string>()
|
|
126
|
+
const sessionHierarchy = new SessionHierarchyRegistry()
|
|
127
|
+
const sessionAgents = new Map<string, string>()
|
|
114
128
|
|
|
115
129
|
/**
|
|
116
130
|
* Seed resumed root sessions without making OpenCode startup depend on the
|
|
@@ -127,18 +141,23 @@ function seedRootSessionsInBackground(input: PluginInput): void {
|
|
|
127
141
|
// the seed is always safe.
|
|
128
142
|
const session = input.client.session
|
|
129
143
|
if (typeof session?.list !== 'function') return
|
|
144
|
+
sessionHierarchy.beginSeed()
|
|
130
145
|
void session
|
|
131
146
|
.list()
|
|
132
147
|
.then((result) => {
|
|
133
148
|
if (!result.error && Array.isArray(result.data)) {
|
|
149
|
+
sessionHierarchy.completeSeed(result.data)
|
|
134
150
|
const seeded = collectRootSessionIDs(result.data)
|
|
135
151
|
if (seeded.size > 0) {
|
|
136
152
|
for (const id of seeded) rootSessions.add(id)
|
|
137
153
|
log.info(`[Pantheon Plugin] Seeded ${seeded.size} root session(s) from session.list`)
|
|
138
154
|
}
|
|
155
|
+
} else {
|
|
156
|
+
sessionHierarchy.failSeed()
|
|
139
157
|
}
|
|
140
158
|
})
|
|
141
159
|
.catch((err) => {
|
|
160
|
+
sessionHierarchy.failSeed()
|
|
142
161
|
log.warn('[Pantheon Plugin] Root session seed failed (fail-open):', err)
|
|
143
162
|
})
|
|
144
163
|
}
|
|
@@ -264,13 +283,26 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
264
283
|
// Phase 2/3: background delegation toolset + the bound finalize lifecycle
|
|
265
284
|
// hook. Completion is observed through the event hook below
|
|
266
285
|
// (session.idle / session.error on a child) → finalizeDelegation.
|
|
286
|
+
// Fase B3: runtime controls for the two read-only exceptions and the real
|
|
287
|
+
// per-child wall-clock timer. No task_budget field is injected into the
|
|
288
|
+
// plugin/frontmatter; these are enforced by delegation.ts itself.
|
|
289
|
+
const delegationBudgets = resolveDelegationBudgets()
|
|
290
|
+
const wallClockTimeoutMs = resolveDelegationTimeoutMs()
|
|
291
|
+
|
|
267
292
|
const delegation = createDelegationTools({
|
|
268
293
|
board,
|
|
269
294
|
client: adaptDelegationClient(input.client),
|
|
270
295
|
options: {
|
|
271
296
|
rootSessions,
|
|
297
|
+
isChildSession: (sessionID) => sessionHierarchy.isChild(sessionID),
|
|
298
|
+
registerChildSession: (sessionID, parentID) => {
|
|
299
|
+
sessionHierarchy.register({ id: sessionID, parentID })
|
|
300
|
+
},
|
|
301
|
+
enforceRuntimeMatrix: true,
|
|
272
302
|
readOnlyAgents: new Set(['apollo', 'gaia']),
|
|
273
303
|
agentModels: routingAgentModels,
|
|
304
|
+
delegationBudgets,
|
|
305
|
+
...(wallClockTimeoutMs !== undefined ? { wallClockTimeoutMs } : {}),
|
|
274
306
|
},
|
|
275
307
|
})
|
|
276
308
|
|
|
@@ -393,12 +425,20 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
393
425
|
// user-activity gate skips injection for userActivityQuietMs afterwards.
|
|
394
426
|
todoEnforcer.noteUserActivity(hookInput.sessionID)
|
|
395
427
|
},
|
|
428
|
+
'chat.params': async (hookInput) => {
|
|
429
|
+
// The SDK exposes the active agent here; tool.execute.before has no
|
|
430
|
+
// agent field. Never infer identity from a prompt or tool name.
|
|
431
|
+
sessionAgents.set(hookInput.sessionID, hookInput.agent)
|
|
432
|
+
},
|
|
396
433
|
'experimental.chat.messages.transform': vision.messagesTransform,
|
|
397
434
|
event: async ({ event: ev }) => {
|
|
398
435
|
// Phase 3: sessions created without a parent are roots for the depth guard.
|
|
399
436
|
if (ev.type === 'session.created') {
|
|
400
437
|
const info = ev.properties.info
|
|
401
|
-
if (info
|
|
438
|
+
if (info) {
|
|
439
|
+
sessionHierarchy.register(info)
|
|
440
|
+
if (info.parentID === undefined) rootSessions.add(info.id)
|
|
441
|
+
}
|
|
402
442
|
}
|
|
403
443
|
// release-134 Phase 3: activate the todo snapshot captured at
|
|
404
444
|
// compacting time — the first todowrite within the restore window is
|
|
@@ -449,7 +489,7 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
449
489
|
// first post-compaction `todowrite` with the captured todo snapshot.
|
|
450
490
|
// Both are no-ops for non-matching sessions/tools; the preserver is
|
|
451
491
|
// fail-open (only its intentional restore denial throws).
|
|
452
|
-
await enforcementGuard(input)
|
|
492
|
+
await enforcementGuard(input, output)
|
|
453
493
|
await todoPreserver.beforeTodoWrite(input, output)
|
|
454
494
|
},
|
|
455
495
|
// Wave 2 (PR #46): augment `read` output with hashline tags. Additive —
|