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,260 @@
1
+ /**
2
+ * Hashline edit tool (Wave 2, PR #46) — structural `hashline_edit` tool that
3
+ * anchors edits to stable sha256 content tags instead of fragile line numbers.
4
+ *
5
+ * Tool shape matches the delegation tools (`{description, args, execute}`,
6
+ * zod-validated — same import pattern as delegation.ts).
7
+ *
8
+ * Safety model (council-approved):
9
+ * 1. ALL refs are validated against the ORIGINAL file snapshot BEFORE any
10
+ * write — a mismatch returns an error-as-text with a re-tagged excerpt,
11
+ * `>>>` marker, and a Did-you-mean suggestion. NO partial write ever.
12
+ * 2. Edits are applied BOTTOM-UP (highest line first) so every ref resolves
13
+ * against the original numbering, even for adjacent edits.
14
+ *
15
+ * Ops:
16
+ * - replace: `ref` (or `ref`..`endRef` range) → replace with `lines`
17
+ * - append: insert `content` AFTER the anchor `ref` line
18
+ * - prepend: insert `content` BEFORE the anchor `ref` line
19
+ * - delete: remove the `ref` line (or `ref`..`endRef` range); no `lines`
20
+ *
21
+ * @module hashline/tool
22
+ */
23
+
24
+ import { readFile, rename, rm, writeFile } from 'node:fs/promises'
25
+ import { isAbsolute, resolve, sep } from 'node:path'
26
+ import { z } from 'zod'
27
+
28
+ import { buildMismatchError, HASHLINE_REF_RE } from './core.ts'
29
+ import { hashTag } from './xxhash.ts'
30
+
31
+ // ─── Types ─────────────────────────────────────────────────────────────
32
+
33
+ /** Structural tool context (subset of the opencode ToolContext). */
34
+ export interface HashlineEditContext {
35
+ sessionID: string
36
+ directory?: string
37
+ worktree?: string
38
+ agent?: string
39
+ }
40
+
41
+ /** One hashline edit operation. */
42
+ export interface HashlineEditOp {
43
+ op: 'replace' | 'append' | 'prepend' | 'delete'
44
+ /** Anchor ref "LINE#TAG" against the ORIGINAL snapshot. */
45
+ ref: string
46
+ /** Optional range end for replace/delete ("LINE#TAG"). */
47
+ endRef?: string
48
+ /** Replacement lines (replace). */
49
+ lines?: string[]
50
+ /** Text to insert (append/prepend; split on newlines). */
51
+ content?: string
52
+ }
53
+
54
+ /** Tool result — string (error-as-text) or success with metadata. */
55
+ export type HashlineToolResult =
56
+ | string
57
+ | { title?: string; output: string; metadata: { firstChangedLine: number; changedCount: number } }
58
+
59
+ // ─── Args schema ───────────────────────────────────────────────────────
60
+
61
+ const hashlineEditOpArgs = {
62
+ op: z.enum(['replace', 'append', 'prepend', 'delete']),
63
+ ref: z.string().min(1),
64
+ endRef: z.string().optional(),
65
+ lines: z.array(z.string()).optional(),
66
+ content: z.string().optional(),
67
+ } satisfies z.ZodRawShape
68
+
69
+ const hashlineEditArgs = {
70
+ file: z.string().min(1),
71
+ edits: z.array(z.object(hashlineEditOpArgs)).min(1),
72
+ } satisfies z.ZodRawShape
73
+
74
+ /** A ref validated against the original snapshot. */
75
+ interface ResolvedRef {
76
+ line: number
77
+ tag: string
78
+ /** Ref string as given (for error messages). */
79
+ raw: string
80
+ }
81
+
82
+ /** A fully validated edit, ready to apply bottom-up. */
83
+ interface ResolvedEdit {
84
+ op: HashlineEditOp['op']
85
+ line: number
86
+ endLine: number
87
+ insert: string[]
88
+ }
89
+
90
+ // ─── Helpers ───────────────────────────────────────────────────────────
91
+
92
+ /**
93
+ * Resolve the edit file to an absolute path, with a containment guard.
94
+ *
95
+ * Relative paths must stay inside `base` (worktree → directory → cwd):
96
+ * `../` traversal (`../../etc/passwd`) is rejected as error-as-text, so no
97
+ * read/write ever happens outside the worktree. Absolute paths are trusted
98
+ * as-is — hashline_edit carries the same privilege as edit/write/bash, so
99
+ * this is cheap defense-in-depth, not a security boundary.
100
+ */
101
+ function resolveFile(
102
+ file: string,
103
+ ctx: HashlineEditContext,
104
+ ): { ok: true; file: string } | { ok: false; error: string } {
105
+ if (isAbsolute(file)) return { ok: true, file: resolve(file) }
106
+ const base = resolve(ctx.worktree ?? ctx.directory ?? process.cwd())
107
+ const resolved = resolve(base, file)
108
+ // Prefix check with a path.sep boundary: base + sep + name must not
109
+ // match a sibling prefix (e.g. `/data/x` vs `/data2/x`).
110
+ if (resolved !== base && !resolved.startsWith(base + sep)) {
111
+ return {
112
+ ok: false,
113
+ error: `hashline_edit: file "${file}" escapes the worktree (resolved to "${resolved}") — path containment denied`,
114
+ }
115
+ }
116
+ return { ok: true, file: resolved }
117
+ }
118
+
119
+ /** Parse + validate a ref string against the ORIGINAL snapshot lines. */
120
+ function resolveRef(raw: string, file: string, lines: readonly string[]): ResolvedRef | string {
121
+ const m = HASHLINE_REF_RE.exec(raw)
122
+ if (!m) {
123
+ return `hashline_edit: invalid ref "${raw}" — expected "LINE#TAG" (e.g. "12#XJ")`
124
+ }
125
+ const line = Number(m[1])
126
+ const tag = m[2]
127
+ if (line < 1 || line > lines.length) {
128
+ return `hashline_edit: ref "${raw}" line ${line} out of range — file has ${lines.length} line(s)`
129
+ }
130
+ const actual = hashTag(lines[line - 1] ?? '', line)
131
+ if (actual !== tag) {
132
+ return buildMismatchError(file, raw, { line, tag: actual, content: lines[line - 1] }, lines)
133
+ }
134
+ return { line, tag, raw }
135
+ }
136
+
137
+ /**
138
+ * Validate ALL edits against the original snapshot; returns the resolved
139
+ * edit list or the error-as-text for the first failure.
140
+ */
141
+ function validateEdits(
142
+ args: z.infer<z.ZodObject<typeof hashlineEditArgs>>,
143
+ file: string,
144
+ lines: readonly string[],
145
+ ): ResolvedEdit[] | string {
146
+ const resolved: ResolvedEdit[] = []
147
+ for (const [i, e] of args.edits.entries()) {
148
+ const pos = resolveRef(e.ref, file, lines)
149
+ if (typeof pos === 'string') return pos
150
+ const edit: ResolvedEdit = { op: e.op, line: pos.line, endLine: pos.line, insert: [] }
151
+
152
+ if (e.op === 'delete' && e.lines) {
153
+ return `hashline_edit: delete edit (index ${i}) must not provide "lines"`
154
+ }
155
+ if (e.op === 'replace' && (!e.lines || e.lines.length === 0)) {
156
+ return `hashline_edit: replace edit (index ${i}) requires non-empty "lines"`
157
+ }
158
+ if ((e.op === 'append' || e.op === 'prepend') && typeof e.content !== 'string') {
159
+ return `hashline_edit: ${e.op} edit (index ${i}) requires "content"`
160
+ }
161
+
162
+ if (e.endRef) {
163
+ const end = resolveRef(e.endRef, file, lines)
164
+ if (typeof end === 'string') return end
165
+ if (end.line < edit.line) {
166
+ return `hashline_edit: endRef "${e.endRef}" is before ref "${e.ref}"`
167
+ }
168
+ edit.endLine = end.line
169
+ }
170
+
171
+ if (e.op === 'replace') {
172
+ edit.insert = e.lines ?? []
173
+ } else if (e.op === 'append' || e.op === 'prepend') {
174
+ edit.insert = (e.content ?? '').split('\n')
175
+ }
176
+ resolved.push(edit)
177
+ }
178
+ return resolved
179
+ }
180
+
181
+ // ─── Tool factory ──────────────────────────────────────────────────────
182
+
183
+ /**
184
+ * Build the structural `hashline_edit` tool. execute() returns error-as-text
185
+ * on any validation failure (never throws for user errors, never writes
186
+ * partially); on success returns the summary with `{firstChangedLine,
187
+ * changedCount}` metadata.
188
+ */
189
+ export function createHashlineEditTool() {
190
+ return {
191
+ description:
192
+ 'Edit a file anchored by hashline refs (LINE#TAG) instead of raw line numbers. ' +
193
+ 'Ops: replace (ref..endRef or single ref → lines), append/prepend (anchor ref → content), ' +
194
+ 'delete (ref..endRef). ALL refs must be validated against the ORIGINAL file before ' +
195
+ 'anything is written; on mismatch the tool returns an error with a re-tagged excerpt ' +
196
+ 'and a Did-you-mean suggestion, and nothing is modified.',
197
+ args: hashlineEditArgs,
198
+ execute: async (
199
+ args: z.infer<z.ZodObject<typeof hashlineEditArgs>>,
200
+ ctx: HashlineEditContext,
201
+ ): Promise<HashlineToolResult> => {
202
+ const resolved = resolveFile(args.file, ctx)
203
+ if (!resolved.ok) return resolved.error
204
+ const file = resolved.file
205
+
206
+ let original: string
207
+ try {
208
+ original = await readFile(file, 'utf8')
209
+ } catch {
210
+ return `hashline_edit: cannot read "${file}" — check the path and try again`
211
+ }
212
+
213
+ const hadTrailingNewline = original.endsWith('\n')
214
+ const lines = original.split('\n')
215
+
216
+ // Phase 1: validate every ref against the ORIGINAL snapshot — no writes.
217
+ const edits = validateEdits(args, file, lines)
218
+ if (typeof edits === 'string') return edits
219
+
220
+ // Phase 2: apply bottom-up (highest line first; ties: later edit first).
221
+ edits.sort((a, b) => b.line - a.line || b.endLine - a.endLine)
222
+ const working = [...lines]
223
+ for (const e of edits) {
224
+ const deleteCount = e.endLine - e.line + 1
225
+ if (e.op === 'replace') {
226
+ working.splice(e.line - 1, deleteCount, ...e.insert)
227
+ } else if (e.op === 'delete') {
228
+ working.splice(e.line - 1, deleteCount)
229
+ } else if (e.op === 'append') {
230
+ working.splice(e.line, 0, ...e.insert)
231
+ } else {
232
+ // prepend: insert before the anchor line.
233
+ working.splice(e.line - 1, 0, ...e.insert)
234
+ }
235
+ }
236
+
237
+ let out = working.join('\n')
238
+ if (hadTrailingNewline && !out.endsWith('\n')) out += '\n'
239
+ // Atomic write (tmp + rename — same pattern as file-persistence.ts
240
+ // writeState / background-job-board.ts writeSignal): a mid-write crash
241
+ // leaves at most a `.tmp-<pid>` file, never partial target content.
242
+ // The tmp file is best-effort cleaned up if the write or rename fails.
243
+ const tmpPath = `${file}.tmp-${process.pid}`
244
+ try {
245
+ await writeFile(tmpPath, out, 'utf8')
246
+ await rename(tmpPath, file)
247
+ } catch {
248
+ await rm(tmpPath, { force: true }).catch(() => {})
249
+ return `hashline_edit: cannot write "${file}" — check permissions and try again`
250
+ }
251
+
252
+ const firstChangedLine = Math.min(...edits.map((e) => e.line))
253
+ return {
254
+ title: 'hashline_edit',
255
+ output: `Updated ${file}: ${edits.length} edit(s) applied, first changed line ${firstChangedLine}.`,
256
+ metadata: { firstChangedLine, changedCount: edits.length },
257
+ }
258
+ },
259
+ }
260
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Hashline tag computation (Wave 2, PR #46) — sha256-truncated per-line tags.
3
+ *
4
+ * Pure `node:crypto` — zero vendored xxhash, zero deps. A 2-char tag is
5
+ * derived from the FIRST byte of the sha256 digest, mapped through a 16-char
6
+ * alphabet (16² = 256 possible tags — the same alphabet as the reference
7
+ * implementation for ecosystem familiarity).
8
+ *
9
+ * Seeding rule:
10
+ * - Lines containing at least one letter/digit (`/[\p{L}\p{N}]/u`) use
11
+ * seed 0 → the tag depends ONLY on the content, so identical content
12
+ * yields the same tag anywhere in the file (stable anchors for moving
13
+ * lines).
14
+ * - Blank / symbol-only lines use the LINE NUMBER as seed → otherwise all
15
+ * blank lines would share one tag and could never be anchored uniquely.
16
+ *
17
+ * @module hashline/xxhash
18
+ */
19
+
20
+ import { createHash } from 'node:crypto'
21
+
22
+ /** 16-char tag alphabet — same as the reference hashline (ecosystem familiarity). */
23
+ export const HASHLINE_DICT = 'ZPMQVRWSNKTXJBYH'
24
+
25
+ /** Lines that carry at least one letter or digit hash by content alone. */
26
+ const ALNUM_RE = /[\p{L}\p{N}]/u
27
+
28
+ /**
29
+ * Normalize a line before hashing: strip the trailing `\r` (CRLF inputs) and
30
+ * trim trailing whitespace so cosmetic edits do not change the tag.
31
+ */
32
+ export function normalizeLine(line: string): string {
33
+ return line.replace(/\r$/, '').trimEnd()
34
+ }
35
+
36
+ /**
37
+ * Compute the 2-char hashline tag for `line` at 1-based `lineNumber`.
38
+ *
39
+ * @param line Raw line content (may include trailing newline artifacts).
40
+ * @param lineNumber 1-based line number (seeds blank/symbol lines only).
41
+ * @returns Two uppercase chars from HASHLINE_DICT.
42
+ */
43
+ export function hashTag(line: string, lineNumber: number): string {
44
+ const normalized = normalizeLine(line)
45
+ const seed = ALNUM_RE.test(normalized) ? 0 : lineNumber
46
+ // The `\u0000` NUL between seed and content disambiguates the
47
+ // seed/content concatenation: without it, seed 1 + content "2" and
48
+ // seed 12 + content "" would collide into the same digest input. NUL
49
+ // never occurs in real source lines, so it is a safe separator.
50
+ const digest = createHash('sha256').update(`${seed}\u0000${normalized}`).digest()
51
+ const first = digest[0] ?? 0
52
+ return HASHLINE_DICT.charAt(first >> 4) + HASHLINE_DICT.charAt(first & 0x0f)
53
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Idle Continuation Dispatcher (Wave 3, PR #46) — routes `session.idle`
3
+ * events to the subsystem that owns the idle.
4
+ *
5
+ * Priority: an ACTIVE GOAL owns the idle — `GoalLoop.onIdle` continues the
6
+ * goal. Without an active goal, the TODO enforcer gets the idle (it
7
+ * self-guards on its own enabled flag + guards). Board-child sessions never
8
+ * reach the dispatcher — the plugin's event hook routes those to the
9
+ * delegation finalize path first (`handleDelegationEvent` returns true) and
10
+ * only calls the dispatcher on the `!delegated` branch.
11
+ *
12
+ * Independently testable — the loop and enforcer are structural interfaces;
13
+ * the real GoalLoop/TodoEnforcer satisfy them.
14
+ *
15
+ * Pure TypeScript — zero runtime dependencies.
16
+ *
17
+ * @module idle-continuation
18
+ */
19
+
20
+ /** Structural goal-loop surface the dispatcher needs. */
21
+ export interface GoalLoopLike {
22
+ hasActiveGoal(sessionID: string): Promise<boolean>
23
+ onIdle(sessionID: string): Promise<void>
24
+ }
25
+
26
+ /** Structural todo-enforcer surface the dispatcher needs. */
27
+ export interface TodoEnforcerLike {
28
+ onIdle(sessionID: string): Promise<void>
29
+ }
30
+
31
+ /** The dispatcher's onIdle surface (used by the plugin event hook). */
32
+ export interface IdleDispatcher {
33
+ onIdle(sessionID: string): Promise<void>
34
+ }
35
+
36
+ /**
37
+ * Build the idle router. The plugin event hook calls this only on the
38
+ * `!delegated` branch of `handleDelegationEvent`, so board children never
39
+ * arrive here.
40
+ */
41
+ export function createIdleDispatcher(deps: {
42
+ goalLoop: GoalLoopLike
43
+ todoEnforcer: TodoEnforcerLike
44
+ }): IdleDispatcher {
45
+ return {
46
+ onIdle: async (sessionID: string): Promise<void> => {
47
+ if (await deps.goalLoop.hasActiveGoal(sessionID)) {
48
+ await deps.goalLoop.onIdle(sessionID)
49
+ return
50
+ }
51
+ await deps.todoEnforcer.onIdle(sessionID)
52
+ },
53
+ }
54
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Pantheon plugin logger — silence-by-default TUI policy (shared with
3
+ * pantheon-hooks, see src/plugins/pantheon-hooks.ts L42-58).
4
+ *
5
+ * WHY: `console.log`/`console.error` in an opencode plugin writes to the
6
+ * process stdout/stderr, which the TUI renders DIRECTLY into the terminal —
7
+ * the user-visible "lixo" (e.g. `[Pantheon Plugin] Board terminal: ...` in
8
+ * the TUI footer). Every Pantheon module therefore routes its logs through
9
+ * this helper:
10
+ *
11
+ * 1. ALWAYS append one ISO-stamped, module-prefixed line to the
12
+ * project-local `.pantheon/logs/hooks.log` (same file pantheon-hooks
13
+ * uses, so the plugin trail stays auditable on disk);
14
+ * 2. echo to the console ONLY when `PANTHEON_HOOKS_LOG=1` (or any truthy
15
+ * value — same env var pantheon-hooks uses; read at creation time).
16
+ *
17
+ * All channels are best-effort and fire-and-forget: a logging failure must
18
+ * never break the plugin, and an unhandled rejection could itself pollute
19
+ * the TUI, so writes are swallowed.
20
+ *
21
+ * Pure TypeScript — zero external deps beyond Node.js builtins (fs/path).
22
+ *
23
+ * @module logger
24
+ */
25
+
26
+ import { appendFile, mkdir } from 'node:fs/promises'
27
+ import { dirname, join } from 'node:path'
28
+
29
+ export interface PantheonLoggerOptions {
30
+ /** Module tag prefixed to every line (e.g. 'pantheon-plugin'). */
31
+ module: string
32
+ /**
33
+ * Env gate for the console echo — truthy means echo in addition to the
34
+ * log file. Defaults to `process.env.PANTHEON_HOOKS_LOG` (read at
35
+ * creation), matching the pantheon-hooks policy. Injectable for tests.
36
+ */
37
+ env?: string | undefined
38
+ /**
39
+ * Project directory — the log lands at `<logDir>/.pantheon/logs/hooks.log`.
40
+ * Defaults to `process.cwd()` (opencode runs plugins with cwd = project,
41
+ * consistent with the plugin's other relative `.pantheon/...` paths).
42
+ */
43
+ logDir?: string | undefined
44
+ }
45
+
46
+ export interface PantheonLogger {
47
+ info(message: string, ...args: unknown[]): void
48
+ warn(message: string, ...args: unknown[]): void
49
+ error(message: string, ...args: unknown[]): void
50
+ }
51
+
52
+ const LOG_LEVELS = ['info', 'warn', 'error'] as const
53
+ type LogLevel = (typeof LOG_LEVELS)[number]
54
+
55
+ /** Console method per level — info maps to console.log (the only console touch). */
56
+ const CONSOLE_METHODS: Record<LogLevel, 'log' | 'warn' | 'error'> = {
57
+ info: 'log',
58
+ warn: 'warn',
59
+ error: 'error',
60
+ }
61
+
62
+ /** Serialize a log arg — Errors to stack/message, never "[object Object]". */
63
+ function formatArg(arg: unknown): string {
64
+ if (arg instanceof Error) return arg.stack ?? arg.message
65
+ if (typeof arg === 'string') return arg
66
+ try {
67
+ return JSON.stringify(arg)
68
+ } catch {
69
+ return String(arg)
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Append one ISO-stamped line to the hooks.log. Multi-line messages are
75
+ * split so EVERY line carries the stamp (pantheon-hooks appendHookLog
76
+ * pattern). Throws only on IO failure — callers swallow.
77
+ */
78
+ async function appendToLog(logPath: string, prefix: string, args: unknown[]): Promise<void> {
79
+ const body = [prefix, ...args.map(formatArg)].join(' ')
80
+ const stamp = new Date().toISOString()
81
+ const lines = body
82
+ .split('\n')
83
+ .map((p) => p.trim())
84
+ .filter((p) => p !== '')
85
+ .map((p) => `[${stamp}] ${p}`)
86
+ if (lines.length === 0) return
87
+ await mkdir(dirname(logPath), { recursive: true })
88
+ await appendFile(logPath, `${lines.join('\n')}\n`, 'utf8')
89
+ }
90
+
91
+ /**
92
+ * Create a module logger. Every level writes to the log file ALWAYS and
93
+ * echoes to the console only when the env gate is truthy. Fire-and-forget:
94
+ * returns void, never throws.
95
+ */
96
+ export function createPantheonLogger(options: PantheonLoggerOptions): PantheonLogger {
97
+ const echo = (options.env ?? process.env.PANTHEON_HOOKS_LOG ?? '').trim() !== ''
98
+ const logPath = join(options.logDir ?? process.cwd(), '.pantheon', 'logs', 'hooks.log')
99
+
100
+ const write = (level: LogLevel, message: string, args: unknown[]): void => {
101
+ void appendToLog(logPath, `[${options.module}] ${message}`, args).catch(() => {
102
+ // Best-effort file log — never break the plugin over logging.
103
+ })
104
+ if (echo) {
105
+ // Opt-in debug echo (PANTHEON_HOOKS_LOG=1). Off by default: console
106
+ // output in a plugin renders into the opencode TUI (the pollution bug).
107
+ const consoleMethod = CONSOLE_METHODS[level]
108
+ console[consoleMethod](`[${options.module}] ${message}`, ...args)
109
+ }
110
+ }
111
+
112
+ return {
113
+ info: (message, ...args) => write('info', message, args),
114
+ warn: (message, ...args) => write('warn', message, args),
115
+ error: (message, ...args) => write('error', message, args),
116
+ }
117
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Runtime double-registration guard for the Pantheon opencode plugins.
3
+ *
4
+ * PROBLEM (2026-08-12, live logs — duplicate toasts + duplicate task_ids in
5
+ * ONE process): opencode MERGES the global `~/.config/opencode/opencode.json`
6
+ * (which points at the INSTALLED npm package's plugin paths) with the project
7
+ * `opencode.json` (which points at the REPO source paths). Both list the SAME
8
+ * two plugins — `src/plugin.ts` and `src/plugins/pantheon-hooks.ts` — so each
9
+ * module is LOADED TWICE from two different filesystem paths in the same
10
+ * process. Each load is a separate module instance with its own module scope;
11
+ * the plugin factory then runs twice, registering every hook twice (duplicate
12
+ * toasts, duplicate delegation correlation, duplicated tool registrations).
13
+ *
14
+ * FIX: claim a stable per-plugin key on a process-global Set
15
+ * (`globalThis.__pantheonPluginsLoaded`). The second factory invocation sees
16
+ * its key already claimed and returns an empty hooks object — a no-op — so
17
+ * hooks register exactly once. The guard is PROCESS-global BY DESIGN: only a
18
+ * shared global can dedupe two separate module instances.
19
+ *
20
+ * Fail-safe: access to globalThis is wrapped — if the runtime ever lacks it
21
+ * (never in bun/node), the helper returns false and the plugin always runs
22
+ * (double registration returns, but the plugin never breaks startup).
23
+ *
24
+ * TEST ESCAPE HATCH: set PANTHEON_PLUGIN_ONCE=off to disable the guard. The
25
+ * multi-phase regression suites (pantheon-hooks-chat.test.mjs,
26
+ * plugin-log-policy.test.mjs) deliberately invoke the hooks factory once per
27
+ * phase in the SAME process via cache-busted imports, and the process-global
28
+ * guard would turn every phase after the first into a no-op. Production
29
+ * never sets this — the guard stays active and dedupes the npm-package +
30
+ * repo double load.
31
+ *
32
+ * USAGE — at the TOP of the plugin factory:
33
+ *
34
+ * const plugin: Plugin = async (input: PluginInput) => {
35
+ * if (pantheonPluginOnce('pantheon:plugin')) return {}
36
+ * ... // full factory body — runs exactly once per process
37
+ * }
38
+ *
39
+ * NOTE: this module is imported (never re-exported) by the plugin modules —
40
+ * the opencode legacy loader invokes every FUNCTION-valued export of a plugin
41
+ * module as a plugin factory, so helpers must live in a separate module.
42
+ */
43
+ const GLOBAL_KEY = '__pantheonPluginsLoaded'
44
+
45
+ /** Read (or lazily create) the shared guard Set. Null on any failure. */
46
+ function guardSet(): Set<string> | null {
47
+ try {
48
+ const g = globalThis as Record<string, unknown>
49
+ if (g[GLOBAL_KEY] === undefined) {
50
+ g[GLOBAL_KEY] = new Set<string>()
51
+ }
52
+ const set = g[GLOBAL_KEY]
53
+ return set instanceof Set ? (set as Set<string>) : null
54
+ } catch {
55
+ return null // fail-open: no globalThis support — see header
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Claim `key` for this process. Returns TRUE when the key was ALREADY claimed
61
+ * by another module instance — the caller MUST become a no-op (return an
62
+ * empty hooks object). Returns FALSE on the FIRST claim — the caller runs its
63
+ * full factory body. Fail-open: without globalThis support every call returns
64
+ * false, so the plugin always runs (never breaks startup).
65
+ */
66
+ export function pantheonPluginOnce(key: string): boolean {
67
+ // Test escape hatch (see header): the multi-phase regression suites invoke
68
+ // the factory once per phase in one process — the dedup must not apply.
69
+ // Production never sets it, so the npm+repo double load stays deduped.
70
+ if (process.env.PANTHEON_PLUGIN_ONCE === 'off') return false
71
+ const set = guardSet()
72
+ if (set === null) return false
73
+ if (set.has(key)) return true
74
+ set.add(key)
75
+ return false
76
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Preemptive Compaction Check (release-134 Phase 5) — dormant, ready-to-wire.
3
+ *
4
+ * WHY: the SDK 1.18.x exposes NO runtime source of context-usage percentage,
5
+ * so there is nothing to observe at runtime yet. This module is the pure
6
+ * decision core that warns the model BEFORE the context fills up — ready to
7
+ * be wired to a future usage source, but deliberately NOT wired today.
8
+ *
9
+ * HOW: `preemptiveCompactCheck` is a pure function: given the current usage
10
+ * fraction and the last fraction at which a warning was emitted, it decides
11
+ * whether to warn again (threshold + re-warn step) and returns the effective
12
+ * `lastWarnedPct`. The `enqueue` callback is INJECTED by the caller — this
13
+ * module stays standalone and does NOT import the shared chat-reminders
14
+ * buffer.
15
+ *
16
+ * YAGNI: no background timers, no polling, no plugin wiring. When (if) the
17
+ * SDK exposes a usage source, the caller wires it into this check.
18
+ *
19
+ * FAIL-OPEN: the whole check is wrapped — an enqueue failure returns the
20
+ * previous state and never throws.
21
+ *
22
+ * @module preemptive-compact
23
+ */
24
+
25
+ // ─── Constants ─────────────────────────────────────────────────────────
26
+
27
+ /** Warn when context usage reaches this fraction (78%). */
28
+ export const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
29
+
30
+ /** Re-warn only when usage rose at least this much since the last warning (5pp). */
31
+ export const PREEMPTIVE_REWARN_STEP = 0.05
32
+
33
+ // ─── Types ─────────────────────────────────────────────────────────────
34
+
35
+ /** Dependencies threaded to preemptiveCompactCheck (wired from plugin.ts). */
36
+ export interface PreemptiveCompactDeps {
37
+ /** The session whose context usage is being checked. */
38
+ sessionID: string
39
+ /** Current context usage as a fraction (0..1). */
40
+ usagePct: number
41
+ /** Last fraction at which a warning was emitted (undefined = never). */
42
+ lastWarnedPct: number | undefined
43
+ /** Injectable enqueue (testable). The module stays standalone. */
44
+ enqueue: (text: string) => void
45
+ }
46
+
47
+ // ─── Decision ──────────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Pure decision: should a warning be emitted at the given usage?
51
+ * True when usage is at/above the threshold AND either no warning was
52
+ * emitted yet or usage rose by at least the re-warn step since the last one.
53
+ */
54
+ export function shouldWarn(usagePct: number, lastWarnedPct: number | undefined): boolean {
55
+ if (usagePct < PREEMPTIVE_COMPACTION_THRESHOLD) return false
56
+ if (lastWarnedPct === undefined) return true
57
+ return usagePct >= lastWarnedPct + PREEMPTIVE_REWARN_STEP
58
+ }
59
+
60
+ /**
61
+ * Preemptive compaction check: when a warning should fire, enqueue the
62
+ * reminder and return the usage fraction as the new `lastWarnedPct`.
63
+ * Otherwise (or on any failure) return the previous `lastWarnedPct`.
64
+ * NEVER throws — the hook must never break the session.
65
+ */
66
+ export function preemptiveCompactCheck(deps: PreemptiveCompactDeps): number | undefined {
67
+ try {
68
+ if (!shouldWarn(deps.usagePct, deps.lastWarnedPct)) return deps.lastWarnedPct
69
+ deps.enqueue(
70
+ `context usage at ${Math.round(deps.usagePct * 100)}% — consider compacting before continuing`,
71
+ )
72
+ return deps.usagePct
73
+ } catch {
74
+ return deps.lastWarnedPct
75
+ }
76
+ }
@@ -84,6 +84,20 @@ export declare function visionBrokenOnGateway(model: string, providerID: string)
84
84
 
85
85
  export declare function loadPresetDefs(routingPath?: string): Record<string, PresetDef>
86
86
 
87
+ export interface RoutingAgentModelsOptions {
88
+ routingPath?: string
89
+ logger?: { warn?: (msg: string) => void }
90
+ }
91
+
92
+ /**
93
+ * Lowercase agent → "provider/model" mapping from routing.yml's FIRST
94
+ * (default) preset — the static source for the delegation toolset's
95
+ * options.agentModels. Fail-open: missing/corrupt routing.yml → {}.
96
+ */
97
+ export declare function loadRoutingAgentModels(
98
+ options?: RoutingAgentModelsOptions,
99
+ ): Record<string, string>
100
+
87
101
  export declare function resolveActivePreset(options?: ResolveOptions): ResolvedPreset | null
88
102
 
89
103
  export declare function applyActivePresetToConfig<C extends object>(
@@ -91,6 +105,23 @@ export declare function applyActivePresetToConfig<C extends object>(
91
105
  options?: ResolveOptions & { env?: Record<string, string | undefined> },
92
106
  ): ResolvedPreset | null
93
107
 
108
+ export interface ProviderKeyOptions {
109
+ env?: Record<string, string | undefined>
110
+ routingPath?: string
111
+ }
112
+
113
+ /** Name of the env var whose value is missing for a provider, or undefined when usable. */
114
+ export declare function missingProviderKeyEnv(
115
+ providerID: string,
116
+ options?: ProviderKeyOptions,
117
+ ): string | undefined
118
+
119
+ /** Whether the provider's API key is configured (apiKeyEnv set + non-empty, or no gate). */
120
+ export declare function providerKeyConfigured(
121
+ providerID: string,
122
+ options?: ProviderKeyOptions,
123
+ ): boolean
124
+
94
125
  export interface MissingApiKeyError extends Error {
95
126
  code: 'PANTHEON_MISSING_API_KEY'
96
127
  envVar: string