pantheon-opencode 1.3.2 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/opencode.json +4 -1
- package/package.json +4 -2
- package/scripts/cost.mjs +69 -0
- package/scripts/install/opencode.mjs +27 -9
- package/src/agents/zeus.md +76 -0
- package/src/pantheon/background-job-board.ts +95 -6
- package/src/pantheon/cost-command.ts +208 -0
- package/src/pantheon/cost-tracker.ts +108 -0
- package/src/pantheon/delegation-compaction.ts +110 -0
- package/src/pantheon/delegation-enforce.ts +144 -0
- package/src/pantheon/delegation-finalize.ts +270 -0
- package/src/pantheon/delegation-notify.ts +238 -0
- package/src/pantheon/delegation.ts +388 -0
- package/src/pantheon/dispatch-guard.ts +102 -0
- package/src/pantheon/file-persistence.ts +10 -6
- package/src/pantheon/goal-loop.ts +297 -0
- package/src/pantheon/goal-store.ts +97 -0
- package/src/pantheon/hashline/core.ts +91 -0
- package/src/pantheon/hashline/read-enhancer.ts +66 -0
- package/src/pantheon/hashline/tool.ts +259 -0
- package/src/pantheon/hashline/xxhash.ts +53 -0
- package/src/pantheon/idle-continuation.ts +54 -0
- package/src/pantheon/todo-enforcer.ts +369 -0
- package/src/plugin.ts +272 -6
- package/src/plugins/pantheon-hooks.ts +17 -8
- package/src/plugins/tui/package.json +1 -1
- package/src/routing.yml +33 -2
|
@@ -0,0 +1,259 @@
|
|
|
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: `/tmp/x` must not match `/tmp2/x`.
|
|
109
|
+
if (resolved !== base && !resolved.startsWith(base + sep)) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
error: `hashline_edit: file "${file}" escapes the worktree (resolved to "${resolved}") — path containment denied`,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { ok: true, file: resolved }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Parse + validate a ref string against the ORIGINAL snapshot lines. */
|
|
119
|
+
function resolveRef(raw: string, file: string, lines: readonly string[]): ResolvedRef | string {
|
|
120
|
+
const m = HASHLINE_REF_RE.exec(raw)
|
|
121
|
+
if (!m) {
|
|
122
|
+
return `hashline_edit: invalid ref "${raw}" — expected "LINE#TAG" (e.g. "12#XJ")`
|
|
123
|
+
}
|
|
124
|
+
const line = Number(m[1])
|
|
125
|
+
const tag = m[2]
|
|
126
|
+
if (line < 1 || line > lines.length) {
|
|
127
|
+
return `hashline_edit: ref "${raw}" line ${line} out of range — file has ${lines.length} line(s)`
|
|
128
|
+
}
|
|
129
|
+
const actual = hashTag(lines[line - 1] ?? '', line)
|
|
130
|
+
if (actual !== tag) {
|
|
131
|
+
return buildMismatchError(file, raw, { line, tag: actual, content: lines[line - 1] }, lines)
|
|
132
|
+
}
|
|
133
|
+
return { line, tag, raw }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Validate ALL edits against the original snapshot; returns the resolved
|
|
138
|
+
* edit list or the error-as-text for the first failure.
|
|
139
|
+
*/
|
|
140
|
+
function validateEdits(
|
|
141
|
+
args: z.infer<z.ZodObject<typeof hashlineEditArgs>>,
|
|
142
|
+
file: string,
|
|
143
|
+
lines: readonly string[],
|
|
144
|
+
): ResolvedEdit[] | string {
|
|
145
|
+
const resolved: ResolvedEdit[] = []
|
|
146
|
+
for (const [i, e] of args.edits.entries()) {
|
|
147
|
+
const pos = resolveRef(e.ref, file, lines)
|
|
148
|
+
if (typeof pos === 'string') return pos
|
|
149
|
+
const edit: ResolvedEdit = { op: e.op, line: pos.line, endLine: pos.line, insert: [] }
|
|
150
|
+
|
|
151
|
+
if (e.op === 'delete' && e.lines) {
|
|
152
|
+
return `hashline_edit: delete edit (index ${i}) must not provide "lines"`
|
|
153
|
+
}
|
|
154
|
+
if (e.op === 'replace' && (!e.lines || e.lines.length === 0)) {
|
|
155
|
+
return `hashline_edit: replace edit (index ${i}) requires non-empty "lines"`
|
|
156
|
+
}
|
|
157
|
+
if ((e.op === 'append' || e.op === 'prepend') && typeof e.content !== 'string') {
|
|
158
|
+
return `hashline_edit: ${e.op} edit (index ${i}) requires "content"`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (e.endRef) {
|
|
162
|
+
const end = resolveRef(e.endRef, file, lines)
|
|
163
|
+
if (typeof end === 'string') return end
|
|
164
|
+
if (end.line < edit.line) {
|
|
165
|
+
return `hashline_edit: endRef "${e.endRef}" is before ref "${e.ref}"`
|
|
166
|
+
}
|
|
167
|
+
edit.endLine = end.line
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (e.op === 'replace') {
|
|
171
|
+
edit.insert = e.lines ?? []
|
|
172
|
+
} else if (e.op === 'append' || e.op === 'prepend') {
|
|
173
|
+
edit.insert = (e.content ?? '').split('\n')
|
|
174
|
+
}
|
|
175
|
+
resolved.push(edit)
|
|
176
|
+
}
|
|
177
|
+
return resolved
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── Tool factory ──────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Build the structural `hashline_edit` tool. execute() returns error-as-text
|
|
184
|
+
* on any validation failure (never throws for user errors, never writes
|
|
185
|
+
* partially); on success returns the summary with `{firstChangedLine,
|
|
186
|
+
* changedCount}` metadata.
|
|
187
|
+
*/
|
|
188
|
+
export function createHashlineEditTool() {
|
|
189
|
+
return {
|
|
190
|
+
description:
|
|
191
|
+
'Edit a file anchored by hashline refs (LINE#TAG) instead of raw line numbers. ' +
|
|
192
|
+
'Ops: replace (ref..endRef or single ref → lines), append/prepend (anchor ref → content), ' +
|
|
193
|
+
'delete (ref..endRef). ALL refs must be validated against the ORIGINAL file before ' +
|
|
194
|
+
'anything is written; on mismatch the tool returns an error with a re-tagged excerpt ' +
|
|
195
|
+
'and a Did-you-mean suggestion, and nothing is modified.',
|
|
196
|
+
args: hashlineEditArgs,
|
|
197
|
+
execute: async (
|
|
198
|
+
args: z.infer<z.ZodObject<typeof hashlineEditArgs>>,
|
|
199
|
+
ctx: HashlineEditContext,
|
|
200
|
+
): Promise<HashlineToolResult> => {
|
|
201
|
+
const resolved = resolveFile(args.file, ctx)
|
|
202
|
+
if (!resolved.ok) return resolved.error
|
|
203
|
+
const file = resolved.file
|
|
204
|
+
|
|
205
|
+
let original: string
|
|
206
|
+
try {
|
|
207
|
+
original = await readFile(file, 'utf8')
|
|
208
|
+
} catch {
|
|
209
|
+
return `hashline_edit: cannot read "${file}" — check the path and try again`
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const hadTrailingNewline = original.endsWith('\n')
|
|
213
|
+
const lines = original.split('\n')
|
|
214
|
+
|
|
215
|
+
// Phase 1: validate every ref against the ORIGINAL snapshot — no writes.
|
|
216
|
+
const edits = validateEdits(args, file, lines)
|
|
217
|
+
if (typeof edits === 'string') return edits
|
|
218
|
+
|
|
219
|
+
// Phase 2: apply bottom-up (highest line first; ties: later edit first).
|
|
220
|
+
edits.sort((a, b) => b.line - a.line || b.endLine - a.endLine)
|
|
221
|
+
const working = [...lines]
|
|
222
|
+
for (const e of edits) {
|
|
223
|
+
const deleteCount = e.endLine - e.line + 1
|
|
224
|
+
if (e.op === 'replace') {
|
|
225
|
+
working.splice(e.line - 1, deleteCount, ...e.insert)
|
|
226
|
+
} else if (e.op === 'delete') {
|
|
227
|
+
working.splice(e.line - 1, deleteCount)
|
|
228
|
+
} else if (e.op === 'append') {
|
|
229
|
+
working.splice(e.line, 0, ...e.insert)
|
|
230
|
+
} else {
|
|
231
|
+
// prepend: insert before the anchor line.
|
|
232
|
+
working.splice(e.line - 1, 0, ...e.insert)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let out = working.join('\n')
|
|
237
|
+
if (hadTrailingNewline && !out.endsWith('\n')) out += '\n'
|
|
238
|
+
// Atomic write (tmp + rename — same pattern as file-persistence.ts
|
|
239
|
+
// writeState / background-job-board.ts writeSignal): a mid-write crash
|
|
240
|
+
// leaves at most a `.tmp-<pid>` file, never partial target content.
|
|
241
|
+
// The tmp file is best-effort cleaned up if the write or rename fails.
|
|
242
|
+
const tmpPath = `${file}.tmp-${process.pid}`
|
|
243
|
+
try {
|
|
244
|
+
await writeFile(tmpPath, out, 'utf8')
|
|
245
|
+
await rename(tmpPath, file)
|
|
246
|
+
} catch {
|
|
247
|
+
await rm(tmpPath, { force: true }).catch(() => {})
|
|
248
|
+
return `hashline_edit: cannot write "${file}" — check permissions and try again`
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const firstChangedLine = Math.min(...edits.map((e) => e.line))
|
|
252
|
+
return {
|
|
253
|
+
title: 'hashline_edit',
|
|
254
|
+
output: `Updated ${file}: ${edits.length} edit(s) applied, first changed line ${firstChangedLine}.`,
|
|
255
|
+
metadata: { firstChangedLine, changedCount: edits.length },
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -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
|
+
}
|