pantheon-opencode 1.3.2 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/opencode.json +4 -1
- package/package.json +4 -2
- package/scripts/cost.mjs +69 -0
- package/scripts/install/opencode.mjs +27 -9
- package/src/agents/zeus.md +76 -0
- package/src/pantheon/background-job-board.ts +95 -6
- package/src/pantheon/cost-command.ts +208 -0
- package/src/pantheon/cost-tracker.ts +108 -0
- package/src/pantheon/delegation-compaction.ts +110 -0
- package/src/pantheon/delegation-enforce.ts +144 -0
- package/src/pantheon/delegation-finalize.ts +270 -0
- package/src/pantheon/delegation-notify.ts +238 -0
- package/src/pantheon/delegation.ts +388 -0
- package/src/pantheon/dispatch-guard.ts +102 -0
- package/src/pantheon/file-persistence.ts +10 -6
- package/src/pantheon/goal-loop.ts +297 -0
- package/src/pantheon/goal-store.ts +97 -0
- package/src/pantheon/hashline/core.ts +91 -0
- package/src/pantheon/hashline/read-enhancer.ts +66 -0
- package/src/pantheon/hashline/tool.ts +259 -0
- package/src/pantheon/hashline/xxhash.ts +53 -0
- package/src/pantheon/idle-continuation.ts +54 -0
- package/src/pantheon/todo-enforcer.ts +369 -0
- package/src/plugin.ts +272 -6
- package/src/plugins/pantheon-hooks.ts +17 -8
- package/src/plugins/tui/package.json +1 -1
- package/src/routing.yml +33 -2
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cost Tracker (Wave 4, PR #46) — JSONL cost ledger for background
|
|
3
|
+
* delegations. Every delegation that finishes (or times out) records one
|
|
4
|
+
* line; the ledger lives at `.pantheon/costs/delegations.jsonl` and gives
|
|
5
|
+
* Nyx / the /cost command a durable, human-readable cost history.
|
|
6
|
+
*
|
|
7
|
+
* record() APPENDS (appendFile + parent dir auto-create), never rewrites.
|
|
8
|
+
* A missing ledger is an EMPTY ledger — list() → [], summary() → zeros,
|
|
9
|
+
* no crash. Injectable clock not needed: timestamp is caller-provided
|
|
10
|
+
* (ISO 8601), matching the delegation toolset's timestamping style.
|
|
11
|
+
*
|
|
12
|
+
* @module cost-tracker
|
|
13
|
+
*/
|
|
14
|
+
import { appendFile, mkdir, readFile } from 'node:fs/promises'
|
|
15
|
+
import { dirname } from 'node:path'
|
|
16
|
+
|
|
17
|
+
/** One delegation cost event. */
|
|
18
|
+
export interface CostRecord {
|
|
19
|
+
/** Board task ID (e.g. "ses_child_1" or the job alias "apo-1"). */
|
|
20
|
+
taskId: string
|
|
21
|
+
/** Session that owns the delegation — list(sessionID?) filters on this. */
|
|
22
|
+
sessionID?: string
|
|
23
|
+
/** Delegated agent name (e.g. "hermes"). */
|
|
24
|
+
agent: string
|
|
25
|
+
/** Model ID actually used (e.g. "opencode/deepseek-v4-flash"). */
|
|
26
|
+
model: string
|
|
27
|
+
tokensInput: number
|
|
28
|
+
tokensOutput: number
|
|
29
|
+
/** Cost in USD (already computed by the caller). */
|
|
30
|
+
costUsd: number
|
|
31
|
+
/** ISO 8601 timestamp of the record. */
|
|
32
|
+
timestamp: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Aggregate across the whole ledger. */
|
|
36
|
+
export interface CostSummary {
|
|
37
|
+
count: number
|
|
38
|
+
totalCostUsd: number
|
|
39
|
+
totalTokensInput: number
|
|
40
|
+
totalTokensOutput: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CostTrackerOptions {
|
|
44
|
+
/** Ledger path. Default: ".pantheon/costs/delegations.jsonl". */
|
|
45
|
+
filePath?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CostTracker {
|
|
49
|
+
/** Append one record to the ledger (creates the parent dir on first write). */
|
|
50
|
+
record(entry: CostRecord): Promise<void>
|
|
51
|
+
/** All records, optionally filtered by sessionID. Missing file → []. */
|
|
52
|
+
list(sessionID?: string): Promise<CostRecord[]>
|
|
53
|
+
/** Aggregate totals across the whole ledger. Missing file → zeros. */
|
|
54
|
+
summary(): Promise<CostSummary>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Parse the ledger file; missing file → empty array (never throws). */
|
|
58
|
+
async function readLedger(filePath: string): Promise<CostRecord[]> {
|
|
59
|
+
let raw: string
|
|
60
|
+
try {
|
|
61
|
+
raw = await readFile(filePath, 'utf8')
|
|
62
|
+
} catch {
|
|
63
|
+
return []
|
|
64
|
+
}
|
|
65
|
+
const records: CostRecord[] = []
|
|
66
|
+
for (const line of raw.split('\n')) {
|
|
67
|
+
if (line.trim() === '') continue
|
|
68
|
+
try {
|
|
69
|
+
records.push(JSON.parse(line) as CostRecord)
|
|
70
|
+
} catch {
|
|
71
|
+
// Skip malformed lines — a partial ledger never breaks the reader.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return records
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createCostTracker(options?: CostTrackerOptions): CostTracker {
|
|
78
|
+
const filePath = options?.filePath ?? '.pantheon/costs/delegations.jsonl'
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
async record(entry: CostRecord): Promise<void> {
|
|
82
|
+
await mkdir(dirname(filePath), { recursive: true })
|
|
83
|
+
await appendFile(filePath, `${JSON.stringify(entry)}\n`, 'utf8')
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async list(sessionID?: string): Promise<CostRecord[]> {
|
|
87
|
+
const records = await readLedger(filePath)
|
|
88
|
+
if (sessionID === undefined) return records
|
|
89
|
+
return records.filter((r) => r.sessionID === sessionID)
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
async summary(): Promise<CostSummary> {
|
|
93
|
+
const records = await readLedger(filePath)
|
|
94
|
+
const summary: CostSummary = {
|
|
95
|
+
count: records.length,
|
|
96
|
+
totalCostUsd: 0,
|
|
97
|
+
totalTokensInput: 0,
|
|
98
|
+
totalTokensOutput: 0,
|
|
99
|
+
}
|
|
100
|
+
for (const r of records) {
|
|
101
|
+
summary.totalCostUsd += r.costUsd
|
|
102
|
+
summary.totalTokensInput += r.tokensInput
|
|
103
|
+
summary.totalTokensOutput += r.tokensOutput
|
|
104
|
+
}
|
|
105
|
+
return summary
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delegation Compaction Context (Phase 4) — build the context blocks injected
|
|
3
|
+
* by the `experimental.session.compacting` hook so a compacted session does
|
|
4
|
+
* not lose track of its in-flight background delegations.
|
|
5
|
+
*
|
|
6
|
+
* Returns a string[] (empty when there is nothing to preserve):
|
|
7
|
+
* - "Background Delegations (running):" — every running job launched by the
|
|
8
|
+
* session (active work is never capped): alias, agent, started ISO,
|
|
9
|
+
* truncated prompt.
|
|
10
|
+
* - "Background Delegations (finished, unread):" — terminal-unreconciled
|
|
11
|
+
* jobs, newest first, capped at `maxItems` (default 10, matching
|
|
12
|
+
* routing.yml background_delegation.max_compaction_items).
|
|
13
|
+
*
|
|
14
|
+
* Reconciled jobs are excluded — they have been read already.
|
|
15
|
+
*
|
|
16
|
+
* The block style mirrors board.formatForPrompt (" [alias] description —
|
|
17
|
+
* STATUS") so compaction context reads consistently with the system prompt,
|
|
18
|
+
* but with the extra id/agent/started detail the compaction hook needs.
|
|
19
|
+
*
|
|
20
|
+
* @module delegation-compaction
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
|
|
24
|
+
|
|
25
|
+
// ─── Types ─────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface CompactionContextOptions {
|
|
28
|
+
/** Scope to jobs launched by this session; omit for all jobs. */
|
|
29
|
+
sessionID?: string
|
|
30
|
+
/** Cap on unread terminal jobs kept (default 10). */
|
|
31
|
+
maxItems?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Prompt truncation length for a context line. */
|
|
35
|
+
const PROMPT_MAX_LEN = 100
|
|
36
|
+
|
|
37
|
+
// ─── Formatting ────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/** Collapse whitespace and truncate a prompt to PROMPT_MAX_LEN chars. */
|
|
40
|
+
function truncatePrompt(text: string): string {
|
|
41
|
+
const flat = text.trim().replace(/\s+/g, ' ')
|
|
42
|
+
if (flat.length <= PROMPT_MAX_LEN) return flat
|
|
43
|
+
return `${flat.slice(0, PROMPT_MAX_LEN)}…`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One-line terminal status label, mirroring formatForPrompt. */
|
|
47
|
+
function terminalLabel(state: BackgroundJobRecord['state']): string {
|
|
48
|
+
switch (state) {
|
|
49
|
+
case 'completed':
|
|
50
|
+
return 'OK'
|
|
51
|
+
case 'error':
|
|
52
|
+
return 'ERR'
|
|
53
|
+
case 'cancelled':
|
|
54
|
+
return 'CAN'
|
|
55
|
+
default:
|
|
56
|
+
return state.toUpperCase()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function runningLine(job: BackgroundJobRecord): string {
|
|
61
|
+
return (
|
|
62
|
+
` [${job.alias}] ${job.agent} — ${truncatePrompt(job.description)} — ` +
|
|
63
|
+
`RUNNING since ${new Date(job.launchedAt).toISOString()}`
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function unreadTerminalLine(job: BackgroundJobRecord): string {
|
|
68
|
+
return (
|
|
69
|
+
` [${job.alias}] ${job.agent} — ${truncatePrompt(job.description)} — ` +
|
|
70
|
+
`${terminalLabel(job.state)} ${new Date(job.updatedAt).toISOString()} [unread]`
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── Build ─────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build the compaction context blocks for a session's background delegations.
|
|
78
|
+
* Returns an empty array when there is nothing to preserve (caller skips the
|
|
79
|
+
* hook injection entirely).
|
|
80
|
+
*/
|
|
81
|
+
export function buildCompactionContext(
|
|
82
|
+
board: BackgroundJobBoard,
|
|
83
|
+
opts: CompactionContextOptions = {},
|
|
84
|
+
): string[] {
|
|
85
|
+
const maxItems = opts.maxItems ?? 10
|
|
86
|
+
const jobs = opts.sessionID !== undefined ? board.list(opts.sessionID) : board.list()
|
|
87
|
+
|
|
88
|
+
const running = jobs
|
|
89
|
+
.filter((j) => j.state === 'running')
|
|
90
|
+
.sort((a, b) => a.launchedAt - b.launchedAt)
|
|
91
|
+
const unreadTerminal = jobs
|
|
92
|
+
.filter((j) => j.terminalUnreconciled)
|
|
93
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
94
|
+
.slice(0, maxItems)
|
|
95
|
+
|
|
96
|
+
if (running.length === 0 && unreadTerminal.length === 0) return []
|
|
97
|
+
|
|
98
|
+
const blocks: string[] = []
|
|
99
|
+
if (running.length > 0) {
|
|
100
|
+
blocks.push(`Background Delegations (running):\n${running.map(runningLine).join('\n')}`)
|
|
101
|
+
}
|
|
102
|
+
if (unreadTerminal.length > 0) {
|
|
103
|
+
blocks.push(
|
|
104
|
+
`Background Delegations (finished, unread):\n${unreadTerminal
|
|
105
|
+
.map(unreadTerminalLine)
|
|
106
|
+
.join('\n')}`,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
return blocks
|
|
110
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-Only Enforcement (Phase 4) — deny mutating tools inside sessions that
|
|
3
|
+
* were delegated as read-only (advisory `read_only: true` on
|
|
4
|
+
* pantheon_delegate, or the agent ∈ `readOnlyAgents` from routing.yml).
|
|
5
|
+
*
|
|
6
|
+
* OpenCode hook: `tool.execute.before`. The hook shape is
|
|
7
|
+
* `(input, output) => Promise<void>`; THROWING from the hook denies the tool
|
|
8
|
+
* call and the caller (the subagent session) sees the thrown message. This
|
|
9
|
+
* module never needs the opencode SDK — the handler is a structural closure
|
|
10
|
+
* wired from plugin.ts.
|
|
11
|
+
*
|
|
12
|
+
* Blocked tools (edit, write, bash, task, hashline_edit):
|
|
13
|
+
* - edit/write/bash are the mutating surface — read-only agents (apollo,
|
|
14
|
+
* gaia) must stay investigation-only.
|
|
15
|
+
* - `hashline_edit` is the Wave 2 (PR #46) tag-anchored edit tool — it is a
|
|
16
|
+
* write surface too, so read-only agents must not bypass enforcement via
|
|
17
|
+
* it.
|
|
18
|
+
* - `task` is blocked too, which hard-enforces depth-2: a read-only agent
|
|
19
|
+
* cannot spawn its own subagents.
|
|
20
|
+
*
|
|
21
|
+
* Unknown sessions are ALLOWED by default — the guard only denies sessions
|
|
22
|
+
* that are explicitly registered, so normal (non-delegated) agent work is
|
|
23
|
+
* never blocked.
|
|
24
|
+
*
|
|
25
|
+
* The registry is populated by delegation.ts at delegate time: the CHILD
|
|
26
|
+
* session is registered when the delegate call is read-only.
|
|
27
|
+
*
|
|
28
|
+
* @module delegation-enforce
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// ─── Types ─────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/** Why a session is read-only. */
|
|
34
|
+
export interface ReadOnlyEntry {
|
|
35
|
+
/** Agent name the session was delegated to (e.g. "apollo"). */
|
|
36
|
+
agent: string
|
|
37
|
+
/** Whether read_only was set explicitly on the delegate call. */
|
|
38
|
+
readOnlyFlag?: boolean
|
|
39
|
+
/** Epoch ms when the session was registered. */
|
|
40
|
+
registeredAt: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Input the opencode `tool.execute.before` hook passes. */
|
|
44
|
+
export interface ToolExecuteBeforeInput {
|
|
45
|
+
tool: string
|
|
46
|
+
sessionID: string
|
|
47
|
+
callID: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The `tool.execute.before` handler shape (throw ⇒ deny). */
|
|
51
|
+
export type ToolExecuteBeforeHandler = (input: ToolExecuteBeforeInput) => Promise<void>
|
|
52
|
+
|
|
53
|
+
/** Options for createEnforcementGuard(). */
|
|
54
|
+
export interface EnforcementGuardOptions {
|
|
55
|
+
/** Tools denied in read-only sessions (default: DEFAULT_BLOCKED_TOOLS). */
|
|
56
|
+
blockedTools?: ReadonlySet<string>
|
|
57
|
+
/** Prefix for the denial message. */
|
|
58
|
+
messagePrefix?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Defaults ──────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/** Mutating tools denied in read-only sessions. `task` ⇒ depth-2 guard. */
|
|
64
|
+
export const DEFAULT_BLOCKED_TOOLS: ReadonlySet<string> = new Set([
|
|
65
|
+
'edit',
|
|
66
|
+
'write',
|
|
67
|
+
'bash',
|
|
68
|
+
'task',
|
|
69
|
+
'hashline_edit',
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
// ─── Registry ──────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read-only session registry. `createDelegationTools` registers child
|
|
76
|
+
* sessions here; the plugin's `tool.execute.before` guard consults it.
|
|
77
|
+
* Process-wide singleton for plugin wiring + factory for test isolation.
|
|
78
|
+
*/
|
|
79
|
+
export class ReadOnlySessionRegistry {
|
|
80
|
+
private readonly sessions = new Map<string, ReadOnlyEntry>()
|
|
81
|
+
|
|
82
|
+
/** Register a session as read-only. Idempotent (re-registration updates). */
|
|
83
|
+
register(sessionID: string, entry: { agent: string; readOnlyFlag?: boolean }): void {
|
|
84
|
+
const record: ReadOnlyEntry = { agent: entry.agent, registeredAt: Date.now() }
|
|
85
|
+
if (entry.readOnlyFlag !== undefined) record.readOnlyFlag = entry.readOnlyFlag
|
|
86
|
+
this.sessions.set(sessionID, record)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Remove a session from the read-only set (dynamic revocation). */
|
|
90
|
+
unregister(sessionID: string): void {
|
|
91
|
+
this.sessions.delete(sessionID)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Whether the session is registered as read-only. */
|
|
95
|
+
has(sessionID: string): boolean {
|
|
96
|
+
return this.sessions.has(sessionID)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Snapshot of the registered entry, or undefined. */
|
|
100
|
+
get(sessionID: string): ReadOnlyEntry | undefined {
|
|
101
|
+
return this.sessions.get(sessionID)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Snapshot of all read-only session IDs (guards read a stable copy). */
|
|
105
|
+
sessionIDs(): ReadonlySet<string> {
|
|
106
|
+
return new Set(this.sessions.keys())
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Remove every registration (test hygiene / plugin teardown). */
|
|
110
|
+
clear(): void {
|
|
111
|
+
this.sessions.clear()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Process-wide registry wired into the plugin. */
|
|
116
|
+
export const readOnlyRegistry = new ReadOnlySessionRegistry()
|
|
117
|
+
|
|
118
|
+
// ─── Guard ─────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Build the `tool.execute.before` handler. Throws with an actionable message
|
|
122
|
+
* when a blocked tool is invoked from a registered read-only session; any
|
|
123
|
+
* other session (or tool) passes through untouched.
|
|
124
|
+
*/
|
|
125
|
+
export function createEnforcementGuard(input: {
|
|
126
|
+
getReadOnlySessions: () => ReadonlySet<string>
|
|
127
|
+
options?: EnforcementGuardOptions
|
|
128
|
+
}): ToolExecuteBeforeHandler {
|
|
129
|
+
const blockedTools = input.options?.blockedTools ?? DEFAULT_BLOCKED_TOOLS
|
|
130
|
+
const prefix = input.options?.messagePrefix ?? 'pantheon_delegate guard'
|
|
131
|
+
|
|
132
|
+
return async (hook: ToolExecuteBeforeInput): Promise<void> => {
|
|
133
|
+
if (!blockedTools.has(hook.tool)) return
|
|
134
|
+
if (!input.getReadOnlySessions().has(hook.sessionID)) return
|
|
135
|
+
|
|
136
|
+
throw new Error(
|
|
137
|
+
`${prefix}: session ${hook.sessionID} is READ-ONLY — tool "${hook.tool}" is denied. ` +
|
|
138
|
+
`This session was delegated for investigation only (read-only agent); it cannot ` +
|
|
139
|
+
`modify files, run commands, or spawn subagents. ` +
|
|
140
|
+
`If a write result is needed, dispatch a write-capable agent ` +
|
|
141
|
+
`(e.g. hermes, aphrodite) with pantheon_delegate instead.`,
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delegation finalize — PULL output from a child session and persist the
|
|
3
|
+
* delegation report (markdown) + the terminal board transition.
|
|
4
|
+
*
|
|
5
|
+
* Completion is NOT pushed: the event hook observes `session.idle` on the
|
|
6
|
+
* child session, then calls `finalizeDelegation()` which pulls the child's
|
|
7
|
+
* messages via `client.session.messages()`, writes the report atomically
|
|
8
|
+
* (tmp + rename) to `.pantheon/delegations/<rootSessionID>/<alias>.md`, and
|
|
9
|
+
* moves the job to a terminal board state (persist-before-notify: the report
|
|
10
|
+
* file exists before `board.updateStatus` resolves waiters).
|
|
11
|
+
*
|
|
12
|
+
* Pure TypeScript — zero runtime dependencies beyond Node.js builtins and the
|
|
13
|
+
* BackgroundJobBoard. The client is a STRUCTURAL interface (fake-client
|
|
14
|
+
* friendly) so this module is testable standalone, without the opencode SDK.
|
|
15
|
+
*
|
|
16
|
+
* @module delegation-finalize
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
|
|
22
|
+
import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
|
|
23
|
+
|
|
24
|
+
// ─── Types ─────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/** A minimal structural view of one message bundle from session.messages(). */
|
|
27
|
+
export interface DelegationMessageBundle {
|
|
28
|
+
info?: { role?: string; finish?: string; error?: unknown }
|
|
29
|
+
parts?: Array<{ type?: string; text?: string }>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Minimal structural view of the opencode SDK session client we use. */
|
|
33
|
+
export interface DelegationClientSession {
|
|
34
|
+
create(input: {
|
|
35
|
+
body: { parentID: string; title?: string; model?: { id: string; providerID: string } }
|
|
36
|
+
}): Promise<{ id: string }>
|
|
37
|
+
promptAsync(input: {
|
|
38
|
+
path: { id: string }
|
|
39
|
+
body: { agent: string; parts: Array<{ type: 'text'; text: string }> }
|
|
40
|
+
}): Promise<unknown>
|
|
41
|
+
messages(input: { path: { id: string } }): Promise<Array<DelegationMessageBundle>>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The client surface used by the delegation core. */
|
|
45
|
+
export interface DelegationClient {
|
|
46
|
+
session: DelegationClientSession
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Tunables for the delegation core (all optional — see DELEGATION_DEFAULTS). */
|
|
50
|
+
export interface DelegationOptions {
|
|
51
|
+
/** Job timeout before the delegation is finalized as error/timedOut. */
|
|
52
|
+
timeoutMs?: number
|
|
53
|
+
/** Caller-side cap for pantheon_delegation_read's waitForTerminal. */
|
|
54
|
+
readTimeoutMs?: number
|
|
55
|
+
/** Base directory for delegation reports (default `.pantheon/delegations`). */
|
|
56
|
+
outputDir?: string
|
|
57
|
+
/** Explicit root-session allowlist for the depth guard. */
|
|
58
|
+
rootSessions?: ReadonlySet<string>
|
|
59
|
+
/** Custom depth-guard predicate — overrides rootSessions and the default. */
|
|
60
|
+
isRootSession?: (sessionID: string) => boolean
|
|
61
|
+
/**
|
|
62
|
+
* Agents whose delegated child sessions are registered as read-only
|
|
63
|
+
* (Phase 4 enforcement). Mirrors routing.yml
|
|
64
|
+
* background_delegation.read_only_agents. Matching is case-insensitive.
|
|
65
|
+
*/
|
|
66
|
+
readOnlyAgents?: ReadonlySet<string>
|
|
67
|
+
/**
|
|
68
|
+
* Per-agent model overrides for delegated child sessions, keyed by agent
|
|
69
|
+
* name (lowercase, e.g. `apollo`). Values are opencode model IDs in
|
|
70
|
+
* `provider/model` form (e.g. `opencode/deepseek-v4-flash-free`), sourced
|
|
71
|
+
* from routing.yml agent entries. Higher priority than the active preset.
|
|
72
|
+
*/
|
|
73
|
+
agentModels?: Readonly<Record<string, string>>
|
|
74
|
+
/**
|
|
75
|
+
* Env override for the active-preset resolution (see resolveActivePreset).
|
|
76
|
+
* Defaults to process.env. Primarily for tests.
|
|
77
|
+
*/
|
|
78
|
+
presetEnv?: Record<string, string | undefined>
|
|
79
|
+
/** Warning sink for the no-model fallback (defaults to console). */
|
|
80
|
+
logger?: { warn?: (msg: string) => void }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Dependencies threaded to the finalize path. */
|
|
84
|
+
export interface DelegationDeps {
|
|
85
|
+
board: BackgroundJobBoard
|
|
86
|
+
client: DelegationClient
|
|
87
|
+
options: DelegationOptions
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Terminal transition requested by the finalize path. */
|
|
91
|
+
export interface FinalizeInput {
|
|
92
|
+
state: 'completed' | 'error' | 'cancelled'
|
|
93
|
+
error?: string
|
|
94
|
+
timedOut?: boolean
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Defaults matching routing.yml background_delegation (timeout_ms 900000). */
|
|
98
|
+
export const DELEGATION_DEFAULTS = {
|
|
99
|
+
timeoutMs: 900_000,
|
|
100
|
+
readTimeoutMs: 900_000,
|
|
101
|
+
outputDir: '.pantheon/delegations',
|
|
102
|
+
} as const
|
|
103
|
+
|
|
104
|
+
// ─── Output pulling ────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/** Concatenate every non-empty text part across the child's messages. */
|
|
107
|
+
async function pullOutput(client: DelegationClient, childSessionID: string): Promise<string> {
|
|
108
|
+
try {
|
|
109
|
+
const bundles = await client.session.messages({ path: { id: childSessionID } })
|
|
110
|
+
const lines: string[] = []
|
|
111
|
+
for (const bundle of bundles) {
|
|
112
|
+
for (const part of bundle.parts ?? []) {
|
|
113
|
+
if (part.type === 'text' && typeof part.text === 'string' && part.text.trim() !== '') {
|
|
114
|
+
lines.push(part.text)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return lines.join('\n\n')
|
|
119
|
+
} catch (err: unknown) {
|
|
120
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
121
|
+
return `(failed to pull child session output: ${reason})`
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Markdown rendering ────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/** Render the delegation report body. Public for tests/Phase 3 reuse. */
|
|
128
|
+
export function renderDelegationMarkdown(
|
|
129
|
+
job: BackgroundJobRecord,
|
|
130
|
+
output: string,
|
|
131
|
+
opts: FinalizeInput & { at?: number },
|
|
132
|
+
): string {
|
|
133
|
+
const at = opts.at ?? Date.now()
|
|
134
|
+
const lines = [
|
|
135
|
+
`# Delegation Report — ${job.alias}`,
|
|
136
|
+
'',
|
|
137
|
+
`- **Task ID**: \`${job.taskID}\``,
|
|
138
|
+
`- **Agent**: ${job.agent}`,
|
|
139
|
+
`- **Description**: ${job.description}`,
|
|
140
|
+
`- **State**: ${opts.state}`,
|
|
141
|
+
`- **Timed out**: ${opts.timedOut === true ? 'true' : 'false'}`,
|
|
142
|
+
`- **Started**: ${new Date(job.launchedAt).toISOString()}`,
|
|
143
|
+
`- **Finalized**: ${new Date(at).toISOString()}`,
|
|
144
|
+
'',
|
|
145
|
+
'## Output',
|
|
146
|
+
'',
|
|
147
|
+
output.trim() !== '' ? output : '_No output captured._',
|
|
148
|
+
]
|
|
149
|
+
if (opts.error !== undefined) {
|
|
150
|
+
lines.push('', `**Error**: ${opts.error}`)
|
|
151
|
+
}
|
|
152
|
+
if (opts.timedOut === true) {
|
|
153
|
+
lines.push('', '[TIMEOUT REACHED]')
|
|
154
|
+
}
|
|
155
|
+
return `${lines.join('\n')}\n`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** One-line result summary from the pulled output (for the board record). */
|
|
159
|
+
function summarizeOutput(output: string): string {
|
|
160
|
+
const firstLine = output
|
|
161
|
+
.split('\n')
|
|
162
|
+
.map((l) => l.trim())
|
|
163
|
+
.find((l) => l !== '')
|
|
164
|
+
if (firstLine === undefined) return 'No output captured'
|
|
165
|
+
return firstLine.length > 120 ? `${firstLine.slice(0, 120)}…` : firstLine
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── Report persistence ────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Reject session IDs that could escape the delegation output directory.
|
|
172
|
+
* `parentSessionID` is embedded in the report path
|
|
173
|
+
* `<outputDir>/<parentSessionID>/<alias>.md` — `path.join` does NOT strip
|
|
174
|
+
* `..`, so a traversing ID would write outside the sandbox. Called BEFORE any
|
|
175
|
+
* path construction (F1 — path traversal hardening).
|
|
176
|
+
*
|
|
177
|
+
* @throws {Error} When the ID contains `..`, `/`, or `\`.
|
|
178
|
+
*/
|
|
179
|
+
export function assertSafeParentSessionID(parentSessionID: string): void {
|
|
180
|
+
if (
|
|
181
|
+
parentSessionID.includes('..') ||
|
|
182
|
+
parentSessionID.includes('/') ||
|
|
183
|
+
parentSessionID.includes('\\')
|
|
184
|
+
) {
|
|
185
|
+
throw new Error(`Invalid parentSessionID: ${parentSessionID}`)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Atomic write (tmp + rename) of the report under <outputDir>/<parent>/<alias>.md. */
|
|
190
|
+
async function writeDelegationReport(
|
|
191
|
+
outputDir: string,
|
|
192
|
+
job: BackgroundJobRecord,
|
|
193
|
+
md: string,
|
|
194
|
+
): Promise<string> {
|
|
195
|
+
assertSafeParentSessionID(job.parentSessionID)
|
|
196
|
+
const dir = join(outputDir, job.parentSessionID)
|
|
197
|
+
const filePath = join(dir, `${job.alias}.md`)
|
|
198
|
+
const tmpPath = join(dir, `${job.alias}.md.tmp`)
|
|
199
|
+
await mkdir(dir, { recursive: true })
|
|
200
|
+
await writeFile(tmpPath, md, 'utf-8')
|
|
201
|
+
await rename(tmpPath, filePath)
|
|
202
|
+
return filePath
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Read a previously written report; undefined when the file does not exist. */
|
|
206
|
+
export async function readDelegationReport(
|
|
207
|
+
outputDir: string,
|
|
208
|
+
job: BackgroundJobRecord,
|
|
209
|
+
): Promise<string | undefined> {
|
|
210
|
+
assertSafeParentSessionID(job.parentSessionID)
|
|
211
|
+
const filePath = join(outputDir, job.parentSessionID, `${job.alias}.md`)
|
|
212
|
+
try {
|
|
213
|
+
return await readFile(filePath, 'utf-8')
|
|
214
|
+
} catch (err: unknown) {
|
|
215
|
+
if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
216
|
+
return undefined
|
|
217
|
+
}
|
|
218
|
+
throw err
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ─── Finalize ──────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Finalize a delegation for a child session: pull output, write the report
|
|
226
|
+
* atomically, then move the board job to the requested terminal state.
|
|
227
|
+
*
|
|
228
|
+
* The board transition is guarded: only `running` → terminal, or an
|
|
229
|
+
* idempotent same-terminal re-notify, are applied. A finalize that races a
|
|
230
|
+
* timeout (already `error`) never overwrites the terminal state — the report
|
|
231
|
+
* file is still (re)written, so the marker reflects the latest observation.
|
|
232
|
+
*
|
|
233
|
+
* @returns The updated job record, or undefined when the session is unknown.
|
|
234
|
+
*/
|
|
235
|
+
export async function finalizeDelegation(
|
|
236
|
+
deps: DelegationDeps,
|
|
237
|
+
childSessionID: string,
|
|
238
|
+
opts: FinalizeInput,
|
|
239
|
+
): Promise<BackgroundJobRecord | undefined> {
|
|
240
|
+
const job = deps.board.get(childSessionID)
|
|
241
|
+
if (!job) return undefined
|
|
242
|
+
|
|
243
|
+
const output = await pullOutput(deps.client, childSessionID)
|
|
244
|
+
const outputDir = deps.options.outputDir ?? DELEGATION_DEFAULTS.outputDir
|
|
245
|
+
await writeDelegationReport(outputDir, job, renderDelegationMarkdown(job, output, opts))
|
|
246
|
+
|
|
247
|
+
const status: {
|
|
248
|
+
taskID: string
|
|
249
|
+
state: 'completed' | 'error' | 'cancelled'
|
|
250
|
+
error?: string
|
|
251
|
+
timedOut?: boolean
|
|
252
|
+
resultSummary?: string
|
|
253
|
+
} = {
|
|
254
|
+
taskID: childSessionID,
|
|
255
|
+
state: opts.state,
|
|
256
|
+
resultSummary: summarizeOutput(output),
|
|
257
|
+
}
|
|
258
|
+
if (opts.error !== undefined) status.error = opts.error
|
|
259
|
+
if (opts.timedOut !== undefined) status.timedOut = opts.timedOut
|
|
260
|
+
|
|
261
|
+
const current = deps.board.get(childSessionID)
|
|
262
|
+
if (current !== undefined) {
|
|
263
|
+
if (current.state === 'running' || current.state === opts.state) {
|
|
264
|
+
await deps.board.updateStatus(status)
|
|
265
|
+
}
|
|
266
|
+
// Terminal in a DIFFERENT state (e.g. timeout already recorded) — the
|
|
267
|
+
// report above still captures this finalize; the board state stands.
|
|
268
|
+
}
|
|
269
|
+
return deps.board.get(childSessionID) ?? job
|
|
270
|
+
}
|