pantheon-opencode 1.3.2 → 1.3.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/README.md +154 -1
- package/bin/pantheon-init.mjs +160 -120
- package/opencode.json +0 -1
- package/package.json +10 -5
- package/scripts/cost.mjs +69 -0
- package/scripts/doctor.mjs +56 -22
- package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
- package/scripts/install/opencode.mjs +132 -32
- package/scripts/install/plugin.mjs +112 -28
- package/scripts/install-mcp.mjs +1 -1
- package/scripts/redaction-gate.mjs +69 -0
- package/scripts/release-notes.mjs +317 -0
- package/scripts/test-context-compression.sh +5 -2
- package/scripts/validate-package.mjs +56 -0
- package/scripts/versioning.mjs +117 -18
- package/src/agents/zeus.md +80 -0
- package/src/pantheon/background-job-board.ts +115 -40
- package/src/pantheon/chat-reminders.ts +79 -0
- package/src/pantheon/compaction-assert.ts +160 -0
- package/src/pantheon/cost-command.ts +208 -0
- package/src/pantheon/cost-tracker.ts +108 -0
- package/src/pantheon/delegation-compaction.ts +237 -0
- package/src/pantheon/delegation-enforce.ts +144 -0
- package/src/pantheon/delegation-finalize.ts +277 -0
- package/src/pantheon/delegation-notify.ts +100 -0
- package/src/pantheon/delegation.ts +690 -0
- package/src/pantheon/dispatch-guard.ts +107 -0
- package/src/pantheon/file-persistence.ts +10 -6
- package/src/pantheon/goal-loop.ts +300 -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 +260 -0
- package/src/pantheon/hashline/xxhash.ts +53 -0
- package/src/pantheon/idle-continuation.ts +54 -0
- package/src/pantheon/logger.ts +117 -0
- package/src/pantheon/plugin-once.ts +76 -0
- package/src/pantheon/preemptive-compact.ts +76 -0
- package/src/pantheon/presets.d.mts +31 -0
- package/src/pantheon/presets.mjs +105 -3
- package/src/pantheon/todo-enforcer.ts +421 -0
- package/src/pantheon/todo-preserve.ts +295 -0
- package/src/plugin.ts +406 -13
- package/src/plugins/pantheon-hooks.ts +52 -30
- package/src/plugins/tui/dist/server.d.ts +17 -0
- package/src/plugins/tui/dist/server.d.ts.map +1 -0
- package/src/plugins/tui/dist/server.js +20 -0
- package/src/plugins/tui/dist/server.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +303 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +977 -350
- package/src/plugins/tui/dist/tui.tsx +1427 -150
- package/src/plugins/tui/package.json +4 -3
- package/src/plugins/tui/src/index.tsx +1427 -150
- package/src/plugins/tui/src/server.ts +15 -0
- package/src/plugins/tui/tsdown.config.ts +1 -1
- package/src/routing.yml +35 -2
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cost Command (Wave 4, PR #46) — `pantheon_cost` structural tool: cost +
|
|
3
|
+
* token visibility for delegation traffic, straight from opencode.db.
|
|
4
|
+
*
|
|
5
|
+
* Reads the opencode session database READ-ONLY (sum cost + tokens by
|
|
6
|
+
* agent over the last N days → markdown table). Zero new dependencies:
|
|
7
|
+
* - primary path: node:sqlite (DatabaseSync, readOnly) — node ≥ 22.5;
|
|
8
|
+
* - fallback: spawn scripts/cost.mjs (node:sqlite, prints JSON) when the
|
|
9
|
+
* direct import is unavailable;
|
|
10
|
+
* - missing/unreadable db → FRIENDLY error string, never a crash (tools
|
|
11
|
+
* return errors as TEXT).
|
|
12
|
+
*
|
|
13
|
+
* dbPath resolution: explicit option > env PANTHEON_COST_DB > XDG data
|
|
14
|
+
* home > ~/.local/share/opencode/opencode.db. The tool is wired in
|
|
15
|
+
* plugin.ts alongside the delegation toolset (usable by zeus and any agent
|
|
16
|
+
* with tool access — no routing change needed).
|
|
17
|
+
*
|
|
18
|
+
* @module cost-command
|
|
19
|
+
*/
|
|
20
|
+
import { execFile } from 'node:child_process'
|
|
21
|
+
import { existsSync } from 'node:fs'
|
|
22
|
+
import { homedir } from 'node:os'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
import { promisify } from 'node:util'
|
|
25
|
+
import { z } from 'zod'
|
|
26
|
+
|
|
27
|
+
import type { ToolContextLike } from './delegation.ts'
|
|
28
|
+
|
|
29
|
+
const execFileAsync = promisify(execFile)
|
|
30
|
+
|
|
31
|
+
/** One aggregated cost row (per agent). */
|
|
32
|
+
export interface CostRow {
|
|
33
|
+
agent: string
|
|
34
|
+
costUsd: number
|
|
35
|
+
tokensInput: number
|
|
36
|
+
tokensOutput: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CostCommandOptions {
|
|
40
|
+
/** Explicit db path (testability / unusual installs). Default: resolved candidates. */
|
|
41
|
+
dbPath?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One cost tool: description + zod args shape + execute (delegation.ts shape). */
|
|
45
|
+
export interface CostTool<Args extends z.ZodRawShape = typeof costArgs> {
|
|
46
|
+
description: string
|
|
47
|
+
args: Args
|
|
48
|
+
execute(args: z.infer<z.ZodObject<Args>>, ctx: ToolContextLike): Promise<string>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface CostCommand {
|
|
52
|
+
pantheon_cost: CostTool
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const costArgs = {
|
|
56
|
+
days: z
|
|
57
|
+
.number()
|
|
58
|
+
.int()
|
|
59
|
+
.min(1)
|
|
60
|
+
.max(365)
|
|
61
|
+
.optional()
|
|
62
|
+
.describe('Number of days of delegation history to include (default 7).'),
|
|
63
|
+
} satisfies z.ZodRawShape
|
|
64
|
+
|
|
65
|
+
/** Resolve candidate db paths in priority order (env override first). */
|
|
66
|
+
function dbCandidates(): string[] {
|
|
67
|
+
const candidates: string[] = []
|
|
68
|
+
const env = process.env.PANTHEON_COST_DB
|
|
69
|
+
if (env !== undefined && env !== '') candidates.push(env)
|
|
70
|
+
const xdg = process.env.XDG_DATA_HOME
|
|
71
|
+
const dataHome = xdg !== undefined && xdg !== '' ? xdg : join(homedir(), '.local', 'share')
|
|
72
|
+
candidates.push(join(dataHome, 'opencode', 'opencode.db'))
|
|
73
|
+
return candidates
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* First existing db path, or undefined. An EXPLICIT override shadows the
|
|
78
|
+
* candidate list entirely (testability: a fake path must not fall through
|
|
79
|
+
* to the real opencode.db).
|
|
80
|
+
*/
|
|
81
|
+
function findExistingDb(override?: string): string | undefined {
|
|
82
|
+
const candidates = override !== undefined && override !== '' ? [override] : dbCandidates()
|
|
83
|
+
for (const candidate of candidates) {
|
|
84
|
+
if (existsSync(candidate)) return candidate
|
|
85
|
+
}
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Aggregate cost + tokens by agent via node:sqlite (read-only). */
|
|
90
|
+
async function queryWithNodeSqlite(dbPath: string, days: number): Promise<CostRow[]> {
|
|
91
|
+
const sqlite = await import('node:sqlite')
|
|
92
|
+
const db = new sqlite.DatabaseSync(dbPath, { readOnly: true })
|
|
93
|
+
try {
|
|
94
|
+
const since = Date.now() - days * 86_400_000
|
|
95
|
+
const rows = db.prepare('SELECT data FROM message WHERE time_created >= ?').all(since)
|
|
96
|
+
return aggregateMessages(rows.map((row) => String(row.data)))
|
|
97
|
+
} finally {
|
|
98
|
+
db.close()
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Fallback: delegate the same aggregation to scripts/cost.mjs (node:sqlite). */
|
|
103
|
+
async function queryWithScript(
|
|
104
|
+
scriptPath: string,
|
|
105
|
+
dbPath: string,
|
|
106
|
+
days: number,
|
|
107
|
+
): Promise<CostRow[]> {
|
|
108
|
+
const { stdout } = await execFileAsync(process.execPath, [scriptPath, dbPath, String(days)], {
|
|
109
|
+
timeout: 15_000,
|
|
110
|
+
encoding: 'utf8',
|
|
111
|
+
})
|
|
112
|
+
const parsed = JSON.parse(stdout) as { ok: boolean; rows?: CostRow[]; error?: string }
|
|
113
|
+
if (!parsed.ok) throw new Error(parsed.error ?? 'cost.mjs failed')
|
|
114
|
+
return parsed.rows ?? []
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Parse message.data JSON rows and aggregate cost + tokens by assistant agent. */
|
|
118
|
+
function aggregateMessages(dataValues: readonly string[]): CostRow[] {
|
|
119
|
+
const byAgent = new Map<string, CostRow>()
|
|
120
|
+
for (const value of dataValues) {
|
|
121
|
+
try {
|
|
122
|
+
const outer = JSON.parse(value) as Record<string, unknown>
|
|
123
|
+
// The message row wraps the payload in a nested JSON string; when it
|
|
124
|
+
// is not a string, the row IS the payload (either way: flat info).
|
|
125
|
+
const info =
|
|
126
|
+
typeof outer.data === 'string' ? (JSON.parse(outer.data) as Record<string, unknown>) : outer
|
|
127
|
+
if (info.role !== 'assistant') continue
|
|
128
|
+
const agent = typeof info.agent === 'string' && info.agent !== '' ? info.agent : null
|
|
129
|
+
if (agent === null) continue
|
|
130
|
+
const costUsd = Number(info.cost) || 0
|
|
131
|
+
const tokens = (info.tokens ?? {}) as Record<string, unknown>
|
|
132
|
+
const tokensInput = Number(tokens.input) || 0
|
|
133
|
+
const tokensOutput = Number(tokens.output) || 0
|
|
134
|
+
const acc = byAgent.get(agent) ?? { agent, costUsd: 0, tokensInput: 0, tokensOutput: 0 }
|
|
135
|
+
acc.costUsd += costUsd
|
|
136
|
+
acc.tokensInput += tokensInput
|
|
137
|
+
acc.tokensOutput += tokensOutput
|
|
138
|
+
byAgent.set(agent, acc)
|
|
139
|
+
} catch {
|
|
140
|
+
// Skip malformed rows — a partial ledger never breaks the report.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return [...byAgent.values()].sort((a, b) => b.costUsd - a.costUsd)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Render the aggregate as a markdown table with a total row. */
|
|
147
|
+
function renderMarkdown(rows: readonly CostRow[], days: number): string {
|
|
148
|
+
if (rows.length === 0) {
|
|
149
|
+
return `## Cost by agent (last ${days} days)\n\nNo cost records in the last ${days} days.`
|
|
150
|
+
}
|
|
151
|
+
const total = rows.reduce(
|
|
152
|
+
(acc, r) => {
|
|
153
|
+
acc.costUsd += r.costUsd
|
|
154
|
+
acc.tokensInput += r.tokensInput
|
|
155
|
+
acc.tokensOutput += r.tokensOutput
|
|
156
|
+
return acc
|
|
157
|
+
},
|
|
158
|
+
{ costUsd: 0, tokensInput: 0, tokensOutput: 0 },
|
|
159
|
+
)
|
|
160
|
+
const lines = [
|
|
161
|
+
`## Cost by agent (last ${days} days)`,
|
|
162
|
+
'',
|
|
163
|
+
'| Agent | Cost (USD) | Tokens In | Tokens Out |',
|
|
164
|
+
'|-------|-----------:|----------:|-----------:|',
|
|
165
|
+
...rows.map(
|
|
166
|
+
(r) => `| ${r.agent} | $${r.costUsd.toFixed(6)} | ${r.tokensInput} | ${r.tokensOutput} |`,
|
|
167
|
+
),
|
|
168
|
+
`| **Total** | **$${total.costUsd.toFixed(6)}** | **${total.tokensInput}** | **${total.tokensOutput}** |`,
|
|
169
|
+
'',
|
|
170
|
+
`Source: opencode.db (read-only)`,
|
|
171
|
+
]
|
|
172
|
+
return lines.join('\n')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function createCostCommand(options?: CostCommandOptions): CostCommand {
|
|
176
|
+
const scriptPath = new URL('../../scripts/cost.mjs', import.meta.url).pathname
|
|
177
|
+
|
|
178
|
+
const execute = async (args: { days?: number }, _ctx: ToolContextLike): Promise<string> => {
|
|
179
|
+
const days = args.days ?? 7
|
|
180
|
+
try {
|
|
181
|
+
const dbPath = findExistingDb(options?.dbPath)
|
|
182
|
+
if (dbPath === undefined) {
|
|
183
|
+
const expected =
|
|
184
|
+
options?.dbPath ?? process.env.PANTHEON_COST_DB ?? '~/.local/share/opencode/opencode.db'
|
|
185
|
+
return (
|
|
186
|
+
`pantheon_cost failed: opencode.db not found (expected at ${expected}). ` +
|
|
187
|
+
`No cost history is available until opencode has stored session data.`
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
const rows = await queryWithNodeSqlite(dbPath, days).catch(async () =>
|
|
191
|
+
queryWithScript(scriptPath, dbPath, days),
|
|
192
|
+
)
|
|
193
|
+
return renderMarkdown(rows, days)
|
|
194
|
+
} catch (err: unknown) {
|
|
195
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
196
|
+
return `pantheon_cost failed: ${reason}`
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
pantheon_cost: {
|
|
202
|
+
description:
|
|
203
|
+
'Report delegation cost + token usage by agent over the last N days, read from opencode.db (read-only, no writes).',
|
|
204
|
+
args: costArgs,
|
|
205
|
+
execute,
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -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,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compaction Context (Phase 4 + release-134 Phase 2) — build the context
|
|
3
|
+
* blocks injected by the `experimental.session.compacting` hook so a
|
|
4
|
+
* compacted session does not lose its working state: in-flight background
|
|
5
|
+
* delegations, active goals and pending todos.
|
|
6
|
+
*
|
|
7
|
+
* Returns a Promise<string[]> (empty when there is nothing to preserve).
|
|
8
|
+
* Section order is stable:
|
|
9
|
+
* - "<pantheon-context directive>" — preservation directive (prefix,
|
|
10
|
+
* PANTHEON_COMPACTION_DIRECTIVE). Emitted whenever at least one other
|
|
11
|
+
* section follows; a totally-empty state returns [] (an orphan
|
|
12
|
+
* directive is noise).
|
|
13
|
+
* - "<mission_context>" — active (non-done) goals from `opts.goals`:
|
|
14
|
+
* " [id] objective — status". Omitted when the goal source is absent,
|
|
15
|
+
* disabled, unscoped (no sessionID), fails, or has no active goals.
|
|
16
|
+
* - "<todo_context>" — pending (not completed/cancelled) todos from
|
|
17
|
+
* `opts.todos`: " [id] description — status". Omitted when the todo
|
|
18
|
+
* source is absent, disabled, unscoped (no sessionID), fails, or has
|
|
19
|
+
* no pending todos.
|
|
20
|
+
* - "Background Delegations (running):" — every running job launched by
|
|
21
|
+
* the session (active work is never capped): alias, agent, started ISO,
|
|
22
|
+
* truncated prompt.
|
|
23
|
+
* - "Background Delegations (finished, unread):" — terminal-unreconciled
|
|
24
|
+
* jobs, newest first, capped at `maxItems` (default 10, matching
|
|
25
|
+
* routing.yml background_delegation.max_compaction_items).
|
|
26
|
+
*
|
|
27
|
+
* Reconciled jobs are excluded — they have been read already.
|
|
28
|
+
*
|
|
29
|
+
* The block style mirrors board.formatForPrompt (" [alias] description —
|
|
30
|
+
* STATUS") so compaction context reads consistently with the system prompt,
|
|
31
|
+
* but with the extra id/agent/started detail the compaction hook needs.
|
|
32
|
+
*
|
|
33
|
+
* @module delegation-compaction
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
|
|
37
|
+
import type { Goal } from './goal-store.ts'
|
|
38
|
+
import { createPantheonLogger } from './logger.ts'
|
|
39
|
+
import type { TodoLike } from './todo-enforcer.ts'
|
|
40
|
+
|
|
41
|
+
// Silence-by-default TUI policy (pantheon-hooks L42-58): the default warn
|
|
42
|
+
// fallback logs to .pantheon/logs/hooks.log; console echo is opt-in via
|
|
43
|
+
// PANTHEON_HOOKS_LOG=1. `deps.logger` injection stays for tests.
|
|
44
|
+
const log = createPantheonLogger({ module: 'pantheon-compaction' })
|
|
45
|
+
|
|
46
|
+
// ─── Constants ─────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Preservation directive (release-134 Phase 2): the native compaction
|
|
50
|
+
* prompt keeps every pushed section, so this prefix asks the model to keep
|
|
51
|
+
* the pantheon sections verbatim — not paraphrased, merged, or dropped.
|
|
52
|
+
*/
|
|
53
|
+
export const PANTHEON_COMPACTION_DIRECTIVE =
|
|
54
|
+
'preserve these sections verbatim in the summarized context'
|
|
55
|
+
|
|
56
|
+
// ─── Types ─────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/** Source for the <mission_context> section (active goals). */
|
|
59
|
+
export interface CompactionGoalSource {
|
|
60
|
+
/** True when the goal loop is enabled; false/undefined → section omitted. */
|
|
61
|
+
enabled: boolean
|
|
62
|
+
/** List the session's goals (any status; the builder keeps non-done). */
|
|
63
|
+
list(sessionID: string): Promise<Goal[]>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Source for the <todo_context> section (pending todos). */
|
|
67
|
+
export interface CompactionTodoSource {
|
|
68
|
+
/** True when the todo enforcer is enabled; false/undefined → section omitted. */
|
|
69
|
+
enabled: boolean
|
|
70
|
+
/** List the session's todos (any status; the builder keeps pending). */
|
|
71
|
+
list(sessionID: string): Promise<TodoLike[]>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface CompactionContextOptions {
|
|
75
|
+
/** Scope to jobs launched by this session; omit for all jobs. */
|
|
76
|
+
sessionID?: string
|
|
77
|
+
/** Cap on unread terminal jobs kept (default 10). */
|
|
78
|
+
maxItems?: number
|
|
79
|
+
/** Goal source for <mission_context> (optional — omitted when absent). */
|
|
80
|
+
goals?: CompactionGoalSource
|
|
81
|
+
/** Todo source for <todo_context> (optional — omitted when absent). */
|
|
82
|
+
todos?: CompactionTodoSource
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Prompt truncation length for a context line. */
|
|
86
|
+
const PROMPT_MAX_LEN = 100
|
|
87
|
+
|
|
88
|
+
// ─── Formatting ────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/** Collapse whitespace and truncate a prompt to PROMPT_MAX_LEN chars. */
|
|
91
|
+
function truncatePrompt(text: string): string {
|
|
92
|
+
const flat = text.trim().replace(/\s+/g, ' ')
|
|
93
|
+
if (flat.length <= PROMPT_MAX_LEN) return flat
|
|
94
|
+
return `${flat.slice(0, PROMPT_MAX_LEN)}…`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One-line terminal status label, mirroring formatForPrompt. */
|
|
98
|
+
function terminalLabel(state: BackgroundJobRecord['state']): string {
|
|
99
|
+
switch (state) {
|
|
100
|
+
case 'completed':
|
|
101
|
+
return 'OK'
|
|
102
|
+
case 'error':
|
|
103
|
+
return 'ERR'
|
|
104
|
+
case 'cancelled':
|
|
105
|
+
return 'CAN'
|
|
106
|
+
default:
|
|
107
|
+
return state.toUpperCase()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function runningLine(job: BackgroundJobRecord): string {
|
|
112
|
+
return (
|
|
113
|
+
` [${job.alias}] ${job.agent} — ${truncatePrompt(job.description)} — ` +
|
|
114
|
+
`RUNNING since ${new Date(job.launchedAt).toISOString()}`
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function unreadTerminalLine(job: BackgroundJobRecord): string {
|
|
119
|
+
return (
|
|
120
|
+
` [${job.alias}] ${job.agent} — ${truncatePrompt(job.description)} — ` +
|
|
121
|
+
`${terminalLabel(job.state)} ${new Date(job.updatedAt).toISOString()} [unread]`
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Sections ──────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* `<mission_context>` — active (non-done) goals for the session.
|
|
129
|
+
* Returns undefined when the section must be omitted: no goal source, the
|
|
130
|
+
* goal loop is disabled, no sessionID (per-session data cannot be scoped),
|
|
131
|
+
* the source throws (logged, fail-open), or no active goals remain.
|
|
132
|
+
*/
|
|
133
|
+
async function missionContextBlock(opts: CompactionContextOptions): Promise<string | undefined> {
|
|
134
|
+
const source = opts.goals
|
|
135
|
+
if (opts.sessionID === undefined || source === undefined || !source.enabled) return undefined
|
|
136
|
+
|
|
137
|
+
let goals: Goal[]
|
|
138
|
+
try {
|
|
139
|
+
goals = await source.list(opts.sessionID)
|
|
140
|
+
} catch (err: unknown) {
|
|
141
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
142
|
+
log.warn(`compaction goal context failed for session ${opts.sessionID}: ${reason}`)
|
|
143
|
+
return undefined
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const active = goals.filter((goal) => goal.status !== 'done')
|
|
147
|
+
if (active.length === 0) return undefined
|
|
148
|
+
|
|
149
|
+
const lines = active.map((goal) => ` [${goal.id}] ${goal.objective} — ${goal.status}`)
|
|
150
|
+
return `<mission_context>\n${lines.join('\n')}`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* `<todo_context>` — pending (not completed/cancelled) todos for the
|
|
155
|
+
* session. Returns undefined when the section must be omitted: no todo
|
|
156
|
+
* source, the enforcer is disabled, no sessionID, the source throws
|
|
157
|
+
* (logged, fail-open), or no pending todos remain.
|
|
158
|
+
*/
|
|
159
|
+
async function todoContextBlock(opts: CompactionContextOptions): Promise<string | undefined> {
|
|
160
|
+
const source = opts.todos
|
|
161
|
+
if (opts.sessionID === undefined || source === undefined || !source.enabled) return undefined
|
|
162
|
+
|
|
163
|
+
let todos: TodoLike[]
|
|
164
|
+
try {
|
|
165
|
+
todos = await source.list(opts.sessionID)
|
|
166
|
+
} catch (err: unknown) {
|
|
167
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
168
|
+
log.warn(`compaction todo context failed for session ${opts.sessionID}: ${reason}`)
|
|
169
|
+
return undefined
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const pending = todos.filter((todo) => todo.status !== 'completed' && todo.status !== 'cancelled')
|
|
173
|
+
if (pending.length === 0) return undefined
|
|
174
|
+
|
|
175
|
+
const lines = pending.map((todo) => {
|
|
176
|
+
const label = todo.content?.trim() || todo.id || '(no description)'
|
|
177
|
+
const id = todo.id !== undefined && todo.id !== '' ? `[${todo.id}] ` : ''
|
|
178
|
+
return ` ${id}${label} — ${todo.status}`
|
|
179
|
+
})
|
|
180
|
+
return `<todo_context>\n${lines.join('\n')}`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `<delegation_context>` — the existing delegation blocks, byte-for-byte
|
|
185
|
+
* unchanged from Phase 4 (running, then unread terminal ≤ maxItems).
|
|
186
|
+
*/
|
|
187
|
+
function delegationBlocks(board: BackgroundJobBoard, opts: CompactionContextOptions): string[] {
|
|
188
|
+
const maxItems = opts.maxItems ?? 10
|
|
189
|
+
const jobs = opts.sessionID !== undefined ? board.list(opts.sessionID) : board.list()
|
|
190
|
+
|
|
191
|
+
const running = jobs
|
|
192
|
+
.filter((j) => j.state === 'running')
|
|
193
|
+
.sort((a, b) => a.launchedAt - b.launchedAt)
|
|
194
|
+
const unreadTerminal = jobs
|
|
195
|
+
.filter((j) => j.terminalUnreconciled)
|
|
196
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
197
|
+
.slice(0, maxItems)
|
|
198
|
+
|
|
199
|
+
const blocks: string[] = []
|
|
200
|
+
if (running.length > 0) {
|
|
201
|
+
blocks.push(`Background Delegations (running):\n${running.map(runningLine).join('\n')}`)
|
|
202
|
+
}
|
|
203
|
+
if (unreadTerminal.length > 0) {
|
|
204
|
+
blocks.push(
|
|
205
|
+
`Background Delegations (finished, unread):\n${unreadTerminal
|
|
206
|
+
.map(unreadTerminalLine)
|
|
207
|
+
.join('\n')}`,
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
return blocks
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── Build ─────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Build the compaction context blocks for a session: preservation
|
|
217
|
+
* directive, active goals, pending todos, and in-flight background
|
|
218
|
+
* delegations. Returns an empty array when there is nothing to preserve
|
|
219
|
+
* (caller skips the hook injection entirely).
|
|
220
|
+
*/
|
|
221
|
+
export async function buildCompactionContext(
|
|
222
|
+
board: BackgroundJobBoard,
|
|
223
|
+
opts: CompactionContextOptions = {},
|
|
224
|
+
): Promise<string[]> {
|
|
225
|
+
const [mission, todo] = await Promise.all([missionContextBlock(opts), todoContextBlock(opts)])
|
|
226
|
+
const delegation = delegationBlocks(board, opts)
|
|
227
|
+
|
|
228
|
+
// Totally-empty state → nothing to preserve; a lone directive is noise.
|
|
229
|
+
if (mission === undefined && todo === undefined && delegation.length === 0) return []
|
|
230
|
+
|
|
231
|
+
const blocks: string[] = []
|
|
232
|
+
blocks.push(`<pantheon-context directive>\n${PANTHEON_COMPACTION_DIRECTIVE}`)
|
|
233
|
+
if (mission !== undefined) blocks.push(mission)
|
|
234
|
+
if (todo !== undefined) blocks.push(todo)
|
|
235
|
+
blocks.push(...delegation)
|
|
236
|
+
return blocks
|
|
237
|
+
}
|
|
@@ -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
|
+
}
|