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.
Files changed (57) hide show
  1. package/README.md +154 -1
  2. package/bin/pantheon-init.mjs +160 -120
  3. package/opencode.json +0 -1
  4. package/package.json +10 -5
  5. package/scripts/cost.mjs +69 -0
  6. package/scripts/doctor.mjs +56 -22
  7. package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
  8. package/scripts/install/opencode.mjs +132 -32
  9. package/scripts/install/plugin.mjs +112 -28
  10. package/scripts/install-mcp.mjs +1 -1
  11. package/scripts/redaction-gate.mjs +69 -0
  12. package/scripts/release-notes.mjs +317 -0
  13. package/scripts/test-context-compression.sh +5 -2
  14. package/scripts/validate-package.mjs +56 -0
  15. package/scripts/versioning.mjs +117 -18
  16. package/src/agents/zeus.md +80 -0
  17. package/src/pantheon/background-job-board.ts +115 -40
  18. package/src/pantheon/chat-reminders.ts +79 -0
  19. package/src/pantheon/compaction-assert.ts +160 -0
  20. package/src/pantheon/cost-command.ts +208 -0
  21. package/src/pantheon/cost-tracker.ts +108 -0
  22. package/src/pantheon/delegation-compaction.ts +237 -0
  23. package/src/pantheon/delegation-enforce.ts +144 -0
  24. package/src/pantheon/delegation-finalize.ts +277 -0
  25. package/src/pantheon/delegation-notify.ts +100 -0
  26. package/src/pantheon/delegation.ts +690 -0
  27. package/src/pantheon/dispatch-guard.ts +107 -0
  28. package/src/pantheon/file-persistence.ts +10 -6
  29. package/src/pantheon/goal-loop.ts +300 -0
  30. package/src/pantheon/goal-store.ts +97 -0
  31. package/src/pantheon/hashline/core.ts +91 -0
  32. package/src/pantheon/hashline/read-enhancer.ts +66 -0
  33. package/src/pantheon/hashline/tool.ts +260 -0
  34. package/src/pantheon/hashline/xxhash.ts +53 -0
  35. package/src/pantheon/idle-continuation.ts +54 -0
  36. package/src/pantheon/logger.ts +117 -0
  37. package/src/pantheon/plugin-once.ts +76 -0
  38. package/src/pantheon/preemptive-compact.ts +76 -0
  39. package/src/pantheon/presets.d.mts +31 -0
  40. package/src/pantheon/presets.mjs +105 -3
  41. package/src/pantheon/todo-enforcer.ts +421 -0
  42. package/src/pantheon/todo-preserve.ts +295 -0
  43. package/src/plugin.ts +406 -13
  44. package/src/plugins/pantheon-hooks.ts +52 -30
  45. package/src/plugins/tui/dist/server.d.ts +17 -0
  46. package/src/plugins/tui/dist/server.d.ts.map +1 -0
  47. package/src/plugins/tui/dist/server.js +20 -0
  48. package/src/plugins/tui/dist/server.js.map +1 -0
  49. package/src/plugins/tui/dist/tui.d.ts +303 -1
  50. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  51. package/src/plugins/tui/dist/tui.js +977 -350
  52. package/src/plugins/tui/dist/tui.tsx +1427 -150
  53. package/src/plugins/tui/package.json +4 -3
  54. package/src/plugins/tui/src/index.tsx +1427 -150
  55. package/src/plugins/tui/src/server.ts +15 -0
  56. package/src/plugins/tui/tsdown.config.ts +1 -1
  57. package/src/routing.yml +35 -2
@@ -0,0 +1,277 @@
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<{
30
+ type?: string
31
+ text?: string
32
+ /** Tool name on `tool` parts — used by the activity sampler. */
33
+ tool?: string
34
+ /** Tool input on `tool` parts (opencode SDK ToolPart.metadata). */
35
+ metadata?: { input?: unknown }
36
+ }>
37
+ }
38
+
39
+ /** Minimal structural view of the opencode SDK session client we use. */
40
+ export interface DelegationClientSession {
41
+ create(input: {
42
+ body: { parentID: string; title?: string; model?: { id: string; providerID: string } }
43
+ }): Promise<{ id: string }>
44
+ promptAsync(input: {
45
+ path: { id: string }
46
+ body: { agent: string; parts: Array<{ type: 'text'; text: string }> }
47
+ }): Promise<unknown>
48
+ messages(input: { path: { id: string } }): Promise<Array<DelegationMessageBundle>>
49
+ }
50
+
51
+ /** The client surface used by the delegation core. */
52
+ export interface DelegationClient {
53
+ session: DelegationClientSession
54
+ }
55
+
56
+ /** Tunables for the delegation core (all optional — see DELEGATION_DEFAULTS). */
57
+ export interface DelegationOptions {
58
+ /** Job timeout before the delegation is finalized as error/timedOut. */
59
+ timeoutMs?: number
60
+ /** Caller-side cap for pantheon_delegation_read's waitForTerminal. */
61
+ readTimeoutMs?: number
62
+ /** Base directory for delegation reports (default `.pantheon/delegations`). */
63
+ outputDir?: string
64
+ /** Explicit root-session allowlist for the depth guard. */
65
+ rootSessions?: ReadonlySet<string>
66
+ /** Custom depth-guard predicate — overrides rootSessions and the default. */
67
+ isRootSession?: (sessionID: string) => boolean
68
+ /**
69
+ * Agents whose delegated child sessions are registered as read-only
70
+ * (Phase 4 enforcement). Mirrors routing.yml
71
+ * background_delegation.read_only_agents. Matching is case-insensitive.
72
+ */
73
+ readOnlyAgents?: ReadonlySet<string>
74
+ /**
75
+ * Per-agent model overrides for delegated child sessions, keyed by agent
76
+ * name (lowercase, e.g. `apollo`). Values are opencode model IDs in
77
+ * `provider/model` form (e.g. `opencode/deepseek-v4-flash-free`), sourced
78
+ * from routing.yml agent entries. Higher priority than the active preset.
79
+ */
80
+ agentModels?: Readonly<Record<string, string>>
81
+ /**
82
+ * Env override for the active-preset resolution (see resolveActivePreset).
83
+ * Defaults to process.env. Primarily for tests.
84
+ */
85
+ presetEnv?: Record<string, string | undefined>
86
+ /** Warning sink for the no-model fallback (defaults to console). */
87
+ logger?: { warn?: (msg: string) => void }
88
+ }
89
+
90
+ /** Dependencies threaded to the finalize path. */
91
+ export interface DelegationDeps {
92
+ board: BackgroundJobBoard
93
+ client: DelegationClient
94
+ options: DelegationOptions
95
+ }
96
+
97
+ /** Terminal transition requested by the finalize path. */
98
+ export interface FinalizeInput {
99
+ state: 'completed' | 'error' | 'cancelled'
100
+ error?: string
101
+ timedOut?: boolean
102
+ }
103
+
104
+ /** Defaults matching routing.yml background_delegation (timeout_ms 900000). */
105
+ export const DELEGATION_DEFAULTS = {
106
+ timeoutMs: 900_000,
107
+ readTimeoutMs: 900_000,
108
+ outputDir: '.pantheon/delegations',
109
+ } as const
110
+
111
+ // ─── Output pulling ────────────────────────────────────────────────────
112
+
113
+ /** Concatenate every non-empty text part across the child's messages. */
114
+ async function pullOutput(client: DelegationClient, childSessionID: string): Promise<string> {
115
+ try {
116
+ const bundles = await client.session.messages({ path: { id: childSessionID } })
117
+ const lines: string[] = []
118
+ for (const bundle of bundles) {
119
+ for (const part of bundle.parts ?? []) {
120
+ if (part.type === 'text' && typeof part.text === 'string' && part.text.trim() !== '') {
121
+ lines.push(part.text)
122
+ }
123
+ }
124
+ }
125
+ return lines.join('\n\n')
126
+ } catch (err: unknown) {
127
+ const reason = err instanceof Error ? err.message : String(err)
128
+ return `(failed to pull child session output: ${reason})`
129
+ }
130
+ }
131
+
132
+ // ─── Markdown rendering ────────────────────────────────────────────────
133
+
134
+ /** Render the delegation report body. Public for tests/Phase 3 reuse. */
135
+ export function renderDelegationMarkdown(
136
+ job: BackgroundJobRecord,
137
+ output: string,
138
+ opts: FinalizeInput & { at?: number },
139
+ ): string {
140
+ const at = opts.at ?? Date.now()
141
+ const lines = [
142
+ `# Delegation Report — ${job.alias}`,
143
+ '',
144
+ `- **Task ID**: \`${job.taskID}\``,
145
+ `- **Agent**: ${job.agent}`,
146
+ `- **Description**: ${job.description}`,
147
+ `- **State**: ${opts.state}`,
148
+ `- **Timed out**: ${opts.timedOut === true ? 'true' : 'false'}`,
149
+ `- **Started**: ${new Date(job.launchedAt).toISOString()}`,
150
+ `- **Finalized**: ${new Date(at).toISOString()}`,
151
+ '',
152
+ '## Output',
153
+ '',
154
+ output.trim() !== '' ? output : '_No output captured._',
155
+ ]
156
+ if (opts.error !== undefined) {
157
+ lines.push('', `**Error**: ${opts.error}`)
158
+ }
159
+ if (opts.timedOut === true) {
160
+ lines.push('', '[TIMEOUT REACHED]')
161
+ }
162
+ return `${lines.join('\n')}\n`
163
+ }
164
+
165
+ /** One-line result summary from the pulled output (for the board record). */
166
+ function summarizeOutput(output: string): string {
167
+ const firstLine = output
168
+ .split('\n')
169
+ .map((l) => l.trim())
170
+ .find((l) => l !== '')
171
+ if (firstLine === undefined) return 'No output captured'
172
+ return firstLine.length > 120 ? `${firstLine.slice(0, 120)}…` : firstLine
173
+ }
174
+
175
+ // ─── Report persistence ────────────────────────────────────────────────
176
+
177
+ /**
178
+ * Reject session IDs that could escape the delegation output directory.
179
+ * `parentSessionID` is embedded in the report path
180
+ * `<outputDir>/<parentSessionID>/<alias>.md` — `path.join` does NOT strip
181
+ * `..`, so a traversing ID would write outside the sandbox. Called BEFORE any
182
+ * path construction (F1 — path traversal hardening).
183
+ *
184
+ * @throws {Error} When the ID contains `..`, `/`, or `\`.
185
+ */
186
+ export function assertSafeParentSessionID(parentSessionID: string): void {
187
+ if (
188
+ parentSessionID.includes('..') ||
189
+ parentSessionID.includes('/') ||
190
+ parentSessionID.includes('\\')
191
+ ) {
192
+ throw new Error(`Invalid parentSessionID: ${parentSessionID}`)
193
+ }
194
+ }
195
+
196
+ /** Atomic write (tmp + rename) of the report under <outputDir>/<parent>/<alias>.md. */
197
+ async function writeDelegationReport(
198
+ outputDir: string,
199
+ job: BackgroundJobRecord,
200
+ md: string,
201
+ ): Promise<string> {
202
+ assertSafeParentSessionID(job.parentSessionID)
203
+ const dir = join(outputDir, job.parentSessionID)
204
+ const filePath = join(dir, `${job.alias}.md`)
205
+ const tmpPath = join(dir, `${job.alias}.md.tmp`)
206
+ await mkdir(dir, { recursive: true })
207
+ await writeFile(tmpPath, md, 'utf-8')
208
+ await rename(tmpPath, filePath)
209
+ return filePath
210
+ }
211
+
212
+ /** Read a previously written report; undefined when the file does not exist. */
213
+ export async function readDelegationReport(
214
+ outputDir: string,
215
+ job: BackgroundJobRecord,
216
+ ): Promise<string | undefined> {
217
+ assertSafeParentSessionID(job.parentSessionID)
218
+ const filePath = join(outputDir, job.parentSessionID, `${job.alias}.md`)
219
+ try {
220
+ return await readFile(filePath, 'utf-8')
221
+ } catch (err: unknown) {
222
+ if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
223
+ return undefined
224
+ }
225
+ throw err
226
+ }
227
+ }
228
+
229
+ // ─── Finalize ──────────────────────────────────────────────────────────
230
+
231
+ /**
232
+ * Finalize a delegation for a child session: pull output, write the report
233
+ * atomically, then move the board job to the requested terminal state.
234
+ *
235
+ * The board transition is guarded: only `running` → terminal, or an
236
+ * idempotent same-terminal re-notify, are applied. A finalize that races a
237
+ * timeout (already `error`) never overwrites the terminal state — the report
238
+ * file is still (re)written, so the marker reflects the latest observation.
239
+ *
240
+ * @returns The updated job record, or undefined when the session is unknown.
241
+ */
242
+ export async function finalizeDelegation(
243
+ deps: DelegationDeps,
244
+ childSessionID: string,
245
+ opts: FinalizeInput,
246
+ ): Promise<BackgroundJobRecord | undefined> {
247
+ const job = deps.board.get(childSessionID)
248
+ if (!job) return undefined
249
+
250
+ const output = await pullOutput(deps.client, childSessionID)
251
+ const outputDir = deps.options.outputDir ?? DELEGATION_DEFAULTS.outputDir
252
+ await writeDelegationReport(outputDir, job, renderDelegationMarkdown(job, output, opts))
253
+
254
+ const status: {
255
+ taskID: string
256
+ state: 'completed' | 'error' | 'cancelled'
257
+ error?: string
258
+ timedOut?: boolean
259
+ resultSummary?: string
260
+ } = {
261
+ taskID: childSessionID,
262
+ state: opts.state,
263
+ resultSummary: summarizeOutput(output),
264
+ }
265
+ if (opts.error !== undefined) status.error = opts.error
266
+ if (opts.timedOut !== undefined) status.timedOut = opts.timedOut
267
+
268
+ const current = deps.board.get(childSessionID)
269
+ if (current !== undefined) {
270
+ if (current.state === 'running' || current.state === opts.state) {
271
+ await deps.board.updateStatus(status)
272
+ }
273
+ // Terminal in a DIFFERENT state (e.g. timeout already recorded) — the
274
+ // report above still captures this finalize; the board state stands.
275
+ }
276
+ return deps.board.get(childSessionID) ?? job
277
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Delegation event wiring — observe background-job lifecycle events and
3
+ * route them to finalize. This module is the `event`-hook complement of the
4
+ * board: `session.idle`/`session.error` on a child session that is a board
5
+ * job → `finalizeDelegation` (Phase 2), which transitions the board and
6
+ * fires `onTerminal`.
7
+ *
8
+ * NOT a notification channel. The user policy is ZERO delegation
9
+ * notifications in the chat transcript — no `<task-notification>` block is
10
+ * ever injected into a `chat.message` output, and no queued delivery path
11
+ * exists. Completion visibility lives in the legitimate channels only:
12
+ * the board `[unread]` marker (`pantheon_delegation_list`),
13
+ * `pantheon_delegation_read`, TUI toasts (pantheon-hooks, PANTHEON_TOASTS
14
+ * gate), and compaction carry-forward. The plugin's `onTerminal` listener
15
+ * writes a file-only log line for the on-disk audit trail.
16
+ *
17
+ * `handleDelegationEvent` wires the `event` hook: `session.idle`/`session.error`
18
+ * on a child session that is a board job → `finalizeDelegation` (Phase 2),
19
+ * which transitions the board and fires `onTerminal`. Finalize never notifies
20
+ * directly, and `markReconciled` does not re-fire onTerminal (reconcile is an
21
+ * acknowledgment, not a completion), so every job gets exactly ONE terminal
22
+ * transition — never a reconciled echo. Unknown sessions are ignored.
23
+ *
24
+ * Pure TypeScript — zero runtime dependencies beyond the board types.
25
+ *
26
+ * Follow-up (Themis review, Phase 5): `extractErrorMessage` handles the two
27
+ * observed `session.error` error shapes (string, { message }). The opencode SDK
28
+ * MAY expose other shapes (e.g. Error-like with non-string `message`) — if a
29
+ * real session.error ever carries one, the extraction degrades to the generic
30
+ * "session error" label. No code change: there is no evidence any other shape
31
+ * occurs in practice; revisit if a session.error shows an unexpected payload.
32
+ *
33
+ * @module delegation-notify
34
+ */
35
+
36
+ import type { BackgroundJobRecord } from './background-job-board.ts'
37
+ import type { FinalizeInput } from './delegation-finalize.ts'
38
+
39
+ export type { FinalizeInput } from './delegation-finalize.ts'
40
+
41
+ // ─── Types ─────────────────────────────────────────────────────────────
42
+
43
+ /** Structural view of the event payload the `event` hook receives. */
44
+ export interface DelegationEventLike {
45
+ type?: string
46
+ properties?: Record<string, unknown>
47
+ }
48
+
49
+ /** Dependencies threaded to handleDelegationEvent (board lookup + finalize). */
50
+ export interface DelegationEventDeps {
51
+ /** Look up a board job by session ID (child session IDs are task IDs). */
52
+ board: { get(taskID: string): BackgroundJobRecord | undefined }
53
+ /** Bound Phase 2 finalize — clears the timeout timer + writes the report. */
54
+ finalize: (childSessionID: string, opts: FinalizeInput) => Promise<unknown>
55
+ }
56
+
57
+ // ─── Event → finalize wiring ───────────────────────────────────────────
58
+
59
+ function extractErrorMessage(error: unknown): string {
60
+ if (typeof error === 'string' && error.trim() !== '') return error
61
+ if (error && typeof error === 'object' && 'message' in error) {
62
+ const message = (error as { message: unknown }).message
63
+ if (typeof message === 'string' && message.trim() !== '') return message
64
+ }
65
+ return 'session error'
66
+ }
67
+
68
+ /**
69
+ * Handle a lifecycle event for background delegations:
70
+ * - `session.idle` on a board job → finalize as completed;
71
+ * - `session.error` on a board job → finalize as error (message extracted);
72
+ * - any other event, a missing session ID, or an unknown session → no-op.
73
+ *
74
+ * Finalize transitions the board, which fires `onTerminal` (the plugin's
75
+ * file-only audit log — no chat delivery; see module header). The board's
76
+ * same-terminal idempotency makes repeated idles harmless (finalize rewrites
77
+ * the report but never transitions a terminal job to a different state).
78
+ *
79
+ * @returns True when the event named a board job (handled).
80
+ */
81
+ export async function handleDelegationEvent(
82
+ ev: DelegationEventLike,
83
+ deps: DelegationEventDeps,
84
+ ): Promise<boolean> {
85
+ const type = ev?.type
86
+ if (type !== 'session.idle' && type !== 'session.error') return false
87
+ const sessionID = ev.properties?.sessionID
88
+ if (typeof sessionID !== 'string' || sessionID === '') return false
89
+ if (deps.board.get(sessionID) === undefined) return false
90
+
91
+ if (type === 'session.idle') {
92
+ await deps.finalize(sessionID, { state: 'completed' })
93
+ } else {
94
+ await deps.finalize(sessionID, {
95
+ state: 'error',
96
+ error: extractErrorMessage(ev.properties?.error),
97
+ })
98
+ }
99
+ return true
100
+ }