pantheon-opencode 1.3.3 → 1.3.5-beta.52.c75b04d

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 (52) hide show
  1. package/AGENTS.md +919 -0
  2. package/README.md +157 -15
  3. package/bin/pantheon-init.mjs +177 -120
  4. package/opencode.json +3 -6
  5. package/package.json +8 -4
  6. package/scripts/build-agents-md.mjs +195 -0
  7. package/scripts/cost.mjs +17 -0
  8. package/scripts/doctor.mjs +56 -22
  9. package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
  10. package/scripts/install/opencode.mjs +189 -38
  11. package/scripts/install/plugin.mjs +112 -28
  12. package/scripts/install-mcp.mjs +1 -1
  13. package/scripts/redaction-gate.mjs +69 -0
  14. package/scripts/release-beta-version.mjs +80 -0
  15. package/scripts/release-notes.mjs +317 -0
  16. package/scripts/test-context-compression.sh +5 -2
  17. package/scripts/validate-package.mjs +56 -0
  18. package/scripts/versioning.mjs +117 -18
  19. package/src/agents/zeus.md +6 -2
  20. package/src/pantheon/background-job-board.ts +24 -38
  21. package/src/pantheon/chat-reminders.ts +79 -0
  22. package/src/pantheon/compaction-assert.ts +160 -0
  23. package/src/pantheon/cost-command.ts +57 -18
  24. package/src/pantheon/delegation-compaction.ts +143 -16
  25. package/src/pantheon/delegation-finalize.ts +8 -1
  26. package/src/pantheon/delegation-notify.ts +19 -157
  27. package/src/pantheon/delegation.ts +330 -28
  28. package/src/pantheon/dispatch-guard.ts +9 -4
  29. package/src/pantheon/goal-loop.ts +7 -4
  30. package/src/pantheon/hashline/tool.ts +2 -1
  31. package/src/pantheon/logger.ts +117 -0
  32. package/src/pantheon/plugin-once.ts +76 -0
  33. package/src/pantheon/preemptive-compact.ts +76 -0
  34. package/src/pantheon/presets.d.mts +31 -0
  35. package/src/pantheon/presets.mjs +105 -3
  36. package/src/pantheon/todo-enforcer.ts +79 -27
  37. package/src/pantheon/todo-preserve.ts +295 -0
  38. package/src/plugin.ts +177 -50
  39. package/src/plugins/pantheon-hooks.ts +35 -22
  40. package/src/plugins/tui/dist/server.d.ts +17 -0
  41. package/src/plugins/tui/dist/server.d.ts.map +1 -0
  42. package/src/plugins/tui/dist/server.js +20 -0
  43. package/src/plugins/tui/dist/server.js.map +1 -0
  44. package/src/plugins/tui/dist/tui.d.ts +303 -1
  45. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  46. package/src/plugins/tui/dist/tui.js +977 -350
  47. package/src/plugins/tui/dist/tui.tsx +1427 -150
  48. package/src/plugins/tui/package.json +4 -3
  49. package/src/plugins/tui/src/index.tsx +1427 -150
  50. package/src/plugins/tui/src/server.ts +15 -0
  51. package/src/plugins/tui/tsdown.config.ts +1 -1
  52. package/src/routing.yml +3 -1
@@ -1,11 +1,24 @@
1
1
  /**
2
- * Delegation Compaction Context (Phase 4) — build the context blocks injected
3
- * by the `experimental.session.compacting` hook so a compacted session does
4
- * not lose track of its in-flight background delegations.
2
+ * Compaction Context (Phase 4 + release-134 Phase 2) — build the context
3
+ * blocks injected by the `experimental.session.compacting` hook so a
4
+ * compacted session does not lose its working state: in-flight background
5
+ * delegations, active goals and pending todos.
5
6
  *
6
- * Returns a string[] (empty when there is nothing to preserve):
7
- * - "Background Delegations (running):" — every running job launched by the
8
- * session (active work is never capped): alias, agent, started ISO,
7
+ * Returns a Promise<string[]> (empty when there is nothing to preserve).
8
+ * Section order is stable:
9
+ * - "<pantheon-context directive>" — preservation directive (prefix,
10
+ * PANTHEON_COMPACTION_DIRECTIVE). Emitted whenever at least one other
11
+ * section follows; a totally-empty state returns [] (an orphan
12
+ * directive is noise).
13
+ * - "<mission_context>" — active (non-done) goals from `opts.goals`:
14
+ * " [id] objective — status". Omitted when the goal source is absent,
15
+ * disabled, unscoped (no sessionID), fails, or has no active goals.
16
+ * - "<todo_context>" — pending (not completed/cancelled) todos from
17
+ * `opts.todos`: " [id] description — status". Omitted when the todo
18
+ * source is absent, disabled, unscoped (no sessionID), fails, or has
19
+ * no pending todos.
20
+ * - "Background Delegations (running):" — every running job launched by
21
+ * the session (active work is never capped): alias, agent, started ISO,
9
22
  * truncated prompt.
10
23
  * - "Background Delegations (finished, unread):" — terminal-unreconciled
11
24
  * jobs, newest first, capped at `maxItems` (default 10, matching
@@ -21,14 +34,52 @@
21
34
  */
22
35
 
23
36
  import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
37
+ import type { Goal } from './goal-store.ts'
38
+ import { createPantheonLogger } from './logger.ts'
39
+ import type { TodoLike } from './todo-enforcer.ts'
40
+
41
+ // Silence-by-default TUI policy (pantheon-hooks L42-58): the default warn
42
+ // fallback logs to .pantheon/logs/hooks.log; console echo is opt-in via
43
+ // PANTHEON_HOOKS_LOG=1. `deps.logger` injection stays for tests.
44
+ const log = createPantheonLogger({ module: 'pantheon-compaction' })
45
+
46
+ // ─── Constants ─────────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Preservation directive (release-134 Phase 2): the native compaction
50
+ * prompt keeps every pushed section, so this prefix asks the model to keep
51
+ * the pantheon sections verbatim — not paraphrased, merged, or dropped.
52
+ */
53
+ export const PANTHEON_COMPACTION_DIRECTIVE =
54
+ 'preserve these sections verbatim in the summarized context'
24
55
 
25
56
  // ─── Types ─────────────────────────────────────────────────────────────
26
57
 
58
+ /** Source for the <mission_context> section (active goals). */
59
+ export interface CompactionGoalSource {
60
+ /** True when the goal loop is enabled; false/undefined → section omitted. */
61
+ enabled: boolean
62
+ /** List the session's goals (any status; the builder keeps non-done). */
63
+ list(sessionID: string): Promise<Goal[]>
64
+ }
65
+
66
+ /** Source for the <todo_context> section (pending todos). */
67
+ export interface CompactionTodoSource {
68
+ /** True when the todo enforcer is enabled; false/undefined → section omitted. */
69
+ enabled: boolean
70
+ /** List the session's todos (any status; the builder keeps pending). */
71
+ list(sessionID: string): Promise<TodoLike[]>
72
+ }
73
+
27
74
  export interface CompactionContextOptions {
28
75
  /** Scope to jobs launched by this session; omit for all jobs. */
29
76
  sessionID?: string
30
77
  /** Cap on unread terminal jobs kept (default 10). */
31
78
  maxItems?: number
79
+ /** Goal source for <mission_context> (optional — omitted when absent). */
80
+ goals?: CompactionGoalSource
81
+ /** Todo source for <todo_context> (optional — omitted when absent). */
82
+ todos?: CompactionTodoSource
32
83
  }
33
84
 
34
85
  /** Prompt truncation length for a context line. */
@@ -71,17 +122,69 @@ function unreadTerminalLine(job: BackgroundJobRecord): string {
71
122
  )
72
123
  }
73
124
 
74
- // ─── Build ─────────────────────────────────────────────────────────────
125
+ // ─── Sections ──────────────────────────────────────────────────────────
75
126
 
76
127
  /**
77
- * Build the compaction context blocks for a session's background delegations.
78
- * Returns an empty array when there is nothing to preserve (caller skips the
79
- * hook injection entirely).
128
+ * `<mission_context>` — active (non-done) goals for the session.
129
+ * Returns undefined when the section must be omitted: no goal source, the
130
+ * goal loop is disabled, no sessionID (per-session data cannot be scoped),
131
+ * the source throws (logged, fail-open), or no active goals remain.
80
132
  */
81
- export function buildCompactionContext(
82
- board: BackgroundJobBoard,
83
- opts: CompactionContextOptions = {},
84
- ): string[] {
133
+ async function missionContextBlock(opts: CompactionContextOptions): Promise<string | undefined> {
134
+ const source = opts.goals
135
+ if (opts.sessionID === undefined || source === undefined || !source.enabled) return undefined
136
+
137
+ let goals: Goal[]
138
+ try {
139
+ goals = await source.list(opts.sessionID)
140
+ } catch (err: unknown) {
141
+ const reason = err instanceof Error ? err.message : String(err)
142
+ log.warn(`compaction goal context failed for session ${opts.sessionID}: ${reason}`)
143
+ return undefined
144
+ }
145
+
146
+ const active = goals.filter((goal) => goal.status !== 'done')
147
+ if (active.length === 0) return undefined
148
+
149
+ const lines = active.map((goal) => ` [${goal.id}] ${goal.objective} — ${goal.status}`)
150
+ return `<mission_context>\n${lines.join('\n')}`
151
+ }
152
+
153
+ /**
154
+ * `<todo_context>` — pending (not completed/cancelled) todos for the
155
+ * session. Returns undefined when the section must be omitted: no todo
156
+ * source, the enforcer is disabled, no sessionID, the source throws
157
+ * (logged, fail-open), or no pending todos remain.
158
+ */
159
+ async function todoContextBlock(opts: CompactionContextOptions): Promise<string | undefined> {
160
+ const source = opts.todos
161
+ if (opts.sessionID === undefined || source === undefined || !source.enabled) return undefined
162
+
163
+ let todos: TodoLike[]
164
+ try {
165
+ todos = await source.list(opts.sessionID)
166
+ } catch (err: unknown) {
167
+ const reason = err instanceof Error ? err.message : String(err)
168
+ log.warn(`compaction todo context failed for session ${opts.sessionID}: ${reason}`)
169
+ return undefined
170
+ }
171
+
172
+ const pending = todos.filter((todo) => todo.status !== 'completed' && todo.status !== 'cancelled')
173
+ if (pending.length === 0) return undefined
174
+
175
+ const lines = pending.map((todo) => {
176
+ const label = todo.content?.trim() || todo.id || '(no description)'
177
+ const id = todo.id !== undefined && todo.id !== '' ? `[${todo.id}] ` : ''
178
+ return ` ${id}${label} — ${todo.status}`
179
+ })
180
+ return `<todo_context>\n${lines.join('\n')}`
181
+ }
182
+
183
+ /**
184
+ * `<delegation_context>` — the existing delegation blocks, byte-for-byte
185
+ * unchanged from Phase 4 (running, then unread terminal ≤ maxItems).
186
+ */
187
+ function delegationBlocks(board: BackgroundJobBoard, opts: CompactionContextOptions): string[] {
85
188
  const maxItems = opts.maxItems ?? 10
86
189
  const jobs = opts.sessionID !== undefined ? board.list(opts.sessionID) : board.list()
87
190
 
@@ -93,8 +196,6 @@ export function buildCompactionContext(
93
196
  .sort((a, b) => b.updatedAt - a.updatedAt)
94
197
  .slice(0, maxItems)
95
198
 
96
- if (running.length === 0 && unreadTerminal.length === 0) return []
97
-
98
199
  const blocks: string[] = []
99
200
  if (running.length > 0) {
100
201
  blocks.push(`Background Delegations (running):\n${running.map(runningLine).join('\n')}`)
@@ -108,3 +209,29 @@ export function buildCompactionContext(
108
209
  }
109
210
  return blocks
110
211
  }
212
+
213
+ // ─── Build ─────────────────────────────────────────────────────────────
214
+
215
+ /**
216
+ * Build the compaction context blocks for a session: preservation
217
+ * directive, active goals, pending todos, and in-flight background
218
+ * delegations. Returns an empty array when there is nothing to preserve
219
+ * (caller skips the hook injection entirely).
220
+ */
221
+ export async function buildCompactionContext(
222
+ board: BackgroundJobBoard,
223
+ opts: CompactionContextOptions = {},
224
+ ): Promise<string[]> {
225
+ const [mission, todo] = await Promise.all([missionContextBlock(opts), todoContextBlock(opts)])
226
+ const delegation = delegationBlocks(board, opts)
227
+
228
+ // Totally-empty state → nothing to preserve; a lone directive is noise.
229
+ if (mission === undefined && todo === undefined && delegation.length === 0) return []
230
+
231
+ const blocks: string[] = []
232
+ blocks.push(`<pantheon-context directive>\n${PANTHEON_COMPACTION_DIRECTIVE}`)
233
+ if (mission !== undefined) blocks.push(mission)
234
+ if (todo !== undefined) blocks.push(todo)
235
+ blocks.push(...delegation)
236
+ return blocks
237
+ }
@@ -26,7 +26,14 @@ import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-b
26
26
  /** A minimal structural view of one message bundle from session.messages(). */
27
27
  export interface DelegationMessageBundle {
28
28
  info?: { role?: string; finish?: string; error?: unknown }
29
- parts?: Array<{ type?: string; text?: string }>
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
+ }>
30
37
  }
31
38
 
32
39
  /** Minimal structural view of the opencode SDK session client we use. */
@@ -1,26 +1,25 @@
1
1
  /**
2
- * Delegation Notify (Phase 3) — completion notifications for background jobs.
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`.
3
7
  *
4
- * The spike refuted `promptAsync({noReply:true})` as a push mechanism — nothing
5
- * is delivered to the parent session. The working channel is a QUEUE + flush:
6
- *
7
- * 1. When a board job reaches a terminal state (`board.onTerminal`), the
8
- * plugin calls `notifyParent(job)` → a `<task-notification>` block is
9
- * queued in-memory for `job.parentSessionID` (FIFO, bounded per parent).
10
- * 2. The next `chat.message` hook fire for that parent session calls
11
- * `flushQueue(parentSessionID, output)`, which prepends the pending
12
- * notifications onto the first text part of the parent's user message —
13
- * the same graceful-degradation channel pantheon-hooks uses for its
14
- * <system-reminder> fallback buffer (TUI toasts are broken on 1.18.13).
15
- *
16
- * If the parent never sends another message the notification stays queued —
17
- * the graceful-degradation path. There is deliberately NO client push.
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.
18
16
  *
19
17
  * `handleDelegationEvent` wires the `event` hook: `session.idle`/`session.error`
20
18
  * on a child session that is a board job → `finalizeDelegation` (Phase 2),
21
- * which transitions the board and fires `onTerminal` → notify. That makes the
22
- * onTerminal listener the SINGLE notification point — finalize never notifies
23
- * directly, so there is no double-notify. Unknown sessions are ignored.
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.
24
23
  *
25
24
  * Pure TypeScript — zero runtime dependencies beyond the board types.
26
25
  *
@@ -41,12 +40,6 @@ export type { FinalizeInput } from './delegation-finalize.ts'
41
40
 
42
41
  // ─── Types ─────────────────────────────────────────────────────────────
43
42
 
44
- /** One queued notification: text + enqueue timestamp. */
45
- export interface QueuedNotification {
46
- text: string
47
- at: number
48
- }
49
-
50
43
  /** Structural view of the event payload the `event` hook receives. */
51
44
  export interface DelegationEventLike {
52
45
  type?: string
@@ -61,137 +54,6 @@ export interface DelegationEventDeps {
61
54
  finalize: (childSessionID: string, opts: FinalizeInput) => Promise<unknown>
62
55
  }
63
56
 
64
- // ─── Notification text ─────────────────────────────────────────────────
65
-
66
- /** Cap for description/summary text embedded in a notification. */
67
- const NOTIFICATION_TEXT_MAX = 120
68
-
69
- function truncate(text: string, max: number): string {
70
- const trimmed = text.trim()
71
- return trimmed.length > max ? `${trimmed.slice(0, max)}…` : trimmed
72
- }
73
-
74
- /**
75
- * Build the self-contained `<task-notification>` block for a terminal job.
76
- * Carries the task ID, alias, agent, state, timeout flag, description and
77
- * result summary so the parent model can act on the completion without extra
78
- * tool calls.
79
- */
80
- export function buildTaskNotification(job: BackgroundJobRecord): string {
81
- const stateLabel =
82
- job.state === 'completed' ? 'completed' : job.state === 'error' ? 'failed' : job.state
83
- const timeout = job.timedOut ? ' (timed out)' : ''
84
- // The `Result:` marker makes the child output origin explicit in the parent
85
- // context — the summary text came from the CHILD's output, not the parent's
86
- // own context (Themis mitigation, Phase 5).
87
- const summary = job.resultSummary
88
- ? ` — Result: ${truncate(job.resultSummary, NOTIFICATION_TEXT_MAX)}`
89
- : ''
90
- return [
91
- '<task-notification>',
92
- `<task id="${job.taskID}" alias="${job.alias}" agent="${job.agent}" state="${job.state}">`,
93
- `<summary>Delegation [${job.alias}] (${job.agent}) ${stateLabel}${timeout}: ${truncate(job.description, NOTIFICATION_TEXT_MAX)}${summary}</summary>`,
94
- `<read>Read the full report with pantheon_delegation_read({ id: "${job.alias}" }).</read>`,
95
- '</task>',
96
- '</task-notification>',
97
- ].join('\n')
98
- }
99
-
100
- // ─── Notifier (queue + flush) ──────────────────────────────────────────
101
-
102
- /** Per-parent queue bound — a full queue drops new entries, never grows unbounded. */
103
- const MAX_PER_PARENT = 10
104
-
105
- function isTextPartLike(value: unknown): value is { type: 'text'; text: string } {
106
- if (!value || typeof value !== 'object') return false
107
- const part = value as { type?: unknown; text?: unknown }
108
- return part.type === 'text' && typeof part.text === 'string'
109
- }
110
-
111
- /**
112
- * In-memory completion notification queue, keyed by parent session ID.
113
- * FIFO per parent, bounded (MAX_PER_PARENT — anti-spam philosophy, matching
114
- * pantheon-hooks' CHAT_REMINDER_MAX). Delivered by `flushQueue` into the
115
- * parent's next chat.message; delivered notifications are marked sent.
116
- */
117
- export class DelegationNotifier {
118
- private readonly queue = new Map<string, QueuedNotification[]>()
119
- private readonly sent = new Map<string, number>()
120
- private readonly warn: (message: string) => void
121
-
122
- /**
123
- * @param logger Optional injected logger (testable). Defaults to
124
- * `console.warn` with the `[pantheon-delegation]` prefix, matching the
125
- * plugin's structured-log style.
126
- */
127
- constructor(logger?: { warn: (message: string) => void }) {
128
- this.warn =
129
- logger?.warn ?? ((message: string) => console.warn(`[pantheon-delegation] ${message}`))
130
- }
131
-
132
- /** Notifications awaiting delivery for a parent session. */
133
- pendingCount(parentSessionID: string): number {
134
- return this.queue.get(parentSessionID)?.length ?? 0
135
- }
136
-
137
- /** Whether any notifications await delivery for a parent session. */
138
- hasPending(parentSessionID: string): boolean {
139
- return this.pendingCount(parentSessionID) > 0
140
- }
141
-
142
- /** Notifications already injected into a parent's chat.message output. */
143
- sentCount(parentSessionID: string): number {
144
- return this.sent.get(parentSessionID) ?? 0
145
- }
146
-
147
- /**
148
- * Queue a completion notification for a parent session. The channel: there
149
- * is NO client push — the notification is delivered by the next
150
- * `flushQueue` for that parent. Returns false when the queue is full.
151
- */
152
- queueNotification(parentSessionID: string, text: string): boolean {
153
- const entries = this.queue.get(parentSessionID) ?? []
154
- if (entries.length >= MAX_PER_PARENT) {
155
- this.warn(
156
- `notification dropped for parent session ${parentSessionID}: ` +
157
- `queue full (${MAX_PER_PARENT} pending)`,
158
- )
159
- return false
160
- }
161
- entries.push({ text, at: Date.now() })
162
- this.queue.set(parentSessionID, entries)
163
- return true
164
- }
165
-
166
- /** Build + queue the notification for a terminal job. False when full. */
167
- notifyParent(job: BackgroundJobRecord): boolean {
168
- return this.queueNotification(job.parentSessionID, buildTaskNotification(job))
169
- }
170
-
171
- /**
172
- * Inject all pending notifications for a parent into a `chat.message` output
173
- * (prepend onto the first text part — matching the pantheon-hooks reminder
174
- * injection pattern; with no text part the block is unshifted as a new one).
175
- * Delivered entries are marked sent. Returns how many were delivered (0 when
176
- * nothing was pending).
177
- */
178
- flushQueue(parentSessionID: string, output: { parts: unknown[] }): number {
179
- const entries = this.queue.get(parentSessionID)
180
- if (entries === undefined || entries.length === 0) return 0
181
- this.queue.delete(parentSessionID)
182
-
183
- const block = entries.map((entry) => entry.text).join('\n')
184
- const firstText = output.parts.find(isTextPartLike)
185
- if (firstText !== undefined) {
186
- firstText.text = `${block}\n\n${firstText.text}`
187
- } else {
188
- output.parts.unshift({ type: 'text', text: block })
189
- }
190
- this.sent.set(parentSessionID, (this.sent.get(parentSessionID) ?? 0) + entries.length)
191
- return entries.length
192
- }
193
- }
194
-
195
57
  // ─── Event → finalize wiring ───────────────────────────────────────────
196
58
 
197
59
  function extractErrorMessage(error: unknown): string {
@@ -209,8 +71,8 @@ function extractErrorMessage(error: unknown): string {
209
71
  * - `session.error` on a board job → finalize as error (message extracted);
210
72
  * - any other event, a missing session ID, or an unknown session → no-op.
211
73
  *
212
- * Finalize transitions the board, which fires `onTerminal` — the single point
213
- * that queues the completion notification (no double-notify). The board's
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
214
76
  * same-terminal idempotency makes repeated idles harmless (finalize rewrites
215
77
  * the report but never transitions a terminal job to a different state).
216
78
  *