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,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
|