pantheon-opencode 1.3.3 → 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 (47) hide show
  1. package/README.md +115 -12
  2. package/bin/pantheon-init.mjs +160 -120
  3. package/opencode.json +0 -4
  4. package/package.json +7 -4
  5. package/scripts/doctor.mjs +56 -22
  6. package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
  7. package/scripts/install/opencode.mjs +108 -26
  8. package/scripts/install/plugin.mjs +112 -28
  9. package/scripts/install-mcp.mjs +1 -1
  10. package/scripts/redaction-gate.mjs +69 -0
  11. package/scripts/release-notes.mjs +317 -0
  12. package/scripts/test-context-compression.sh +5 -2
  13. package/scripts/validate-package.mjs +56 -0
  14. package/scripts/versioning.mjs +117 -18
  15. package/src/agents/zeus.md +6 -2
  16. package/src/pantheon/background-job-board.ts +24 -38
  17. package/src/pantheon/chat-reminders.ts +79 -0
  18. package/src/pantheon/compaction-assert.ts +160 -0
  19. package/src/pantheon/delegation-compaction.ts +143 -16
  20. package/src/pantheon/delegation-finalize.ts +8 -1
  21. package/src/pantheon/delegation-notify.ts +19 -157
  22. package/src/pantheon/delegation.ts +330 -28
  23. package/src/pantheon/dispatch-guard.ts +9 -4
  24. package/src/pantheon/goal-loop.ts +7 -4
  25. package/src/pantheon/hashline/tool.ts +2 -1
  26. package/src/pantheon/logger.ts +117 -0
  27. package/src/pantheon/plugin-once.ts +76 -0
  28. package/src/pantheon/preemptive-compact.ts +76 -0
  29. package/src/pantheon/presets.d.mts +31 -0
  30. package/src/pantheon/presets.mjs +105 -3
  31. package/src/pantheon/todo-enforcer.ts +79 -27
  32. package/src/pantheon/todo-preserve.ts +295 -0
  33. package/src/plugin.ts +177 -50
  34. package/src/plugins/pantheon-hooks.ts +35 -22
  35. package/src/plugins/tui/dist/server.d.ts +17 -0
  36. package/src/plugins/tui/dist/server.d.ts.map +1 -0
  37. package/src/plugins/tui/dist/server.js +20 -0
  38. package/src/plugins/tui/dist/server.js.map +1 -0
  39. package/src/plugins/tui/dist/tui.d.ts +303 -1
  40. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  41. package/src/plugins/tui/dist/tui.js +977 -350
  42. package/src/plugins/tui/dist/tui.tsx +1427 -150
  43. package/src/plugins/tui/package.json +4 -3
  44. package/src/plugins/tui/src/index.tsx +1427 -150
  45. package/src/plugins/tui/src/server.ts +15 -0
  46. package/src/plugins/tui/tsdown.config.ts +1 -1
  47. package/src/routing.yml +3 -1
@@ -153,8 +153,12 @@ pantheon_delegate({prompt, agent, description?, read_only?})
153
153
  -> cria sessao filha, registra no board, retorna alias: [apo-1]
154
154
  pantheon_delegation_read({id: "apo-1"})
155
155
  -> BLOQUEIA ate o job terminar, retorna o report markdown, marca reconciled
156
+ -> durante a espera, mostra o que o agente esta fazendo: seccao final "## Agent Activity"
157
+ (amostra as messages do child a cada ~2s; tool calls + args truncados / texto)
156
158
  pantheon_delegation_list()
157
159
  -> lista jobs da sessao, com [unread] para jobs terminados nao lidos
160
+ -> jobs RUNNING mostram "last activity:" (ultima acao visivel do child)
161
+ -> fail-open: sem suporte a messages, formato atual inalterado
158
162
  ```
159
163
 
160
164
  - **Read-only enforcement:** agentes read-only (apollo, gaia — `read_only_agents` no routing.yml) tem `edit`, `write`, `bash`, `task` NEGADOS dentro da sessao delegada (o guard de `tool.execute.before` THROW com mensagem acionavel). Use `read_only: true` no delegate para qualquer agente de investigacao.
@@ -164,7 +168,7 @@ pantheon_delegation_list()
164
168
  ### DELEGATION_RULES (plugin path)
165
169
 
166
170
  1. **NUNCA** polle `pantheon_delegation_list` para checar se um job terminou.
167
- 2. Voce SERA notificado via `task-notification` injetado no chat quando um job atinge estado terminal.
171
+ 2. Voce NAO recebe notificacao injetada no chat — politica do usuario: ZERO `task-notification` no transcript. Visibilidade de conclusao: marcador `[unread]` em `pantheon_delegation_list`, `pantheon_delegation_read({id})`, toasts TUI (pantheon-hooks) e carry-forward de compactacao.
168
172
  3. Use `pantheon_delegation_read({id})` APENAS para fan-in explicito / recuperacao de resultado sob demanda (bloqueia ate o fim).
169
173
  4. `pantheon_delegation_list` e para diagnostico, nao para polling.
170
174
 
@@ -282,7 +286,7 @@ Wave: dispare N, colete com tolerancia a falha
282
286
  ```
283
287
 
284
288
  ### Plugin Enforcer (auto — session.idle hook)
285
- O plugin re-injeta "Incomplete tasks remain..." em sessões root/não-board que
289
+ O plugin re-injeta "Continue: pending todos remain — review and proceed." em sessões root/não-board que
286
290
  vão a idle com todos incompletos. Guards (todos no `src/pantheon/todo-enforcer.ts`):
287
291
  - **User-activity (30s)** — após uma mensagem do usuário (`chat.message` hook),
288
292
  a injeção é suprimida por `user_activity_quiet_ms: 30000`.
@@ -11,17 +11,19 @@
11
11
  */
12
12
 
13
13
  import { mkdir, rename, writeFile } from 'node:fs/promises'
14
- import { join, dirname } from 'node:path'
14
+ import { dirname, join } from 'node:path'
15
+
16
+ import { createPantheonLogger } from './logger.ts'
17
+
18
+ // Silence-by-default TUI policy (pantheon-hooks L42-58): console output in a
19
+ // plugin renders into the opencode TUI — the "lixo". Board internals log to
20
+ // .pantheon/logs/hooks.log; console echo is opt-in via PANTHEON_HOOKS_LOG=1.
21
+ const log = createPantheonLogger({ module: 'BackgroundJobBoard' })
15
22
 
16
23
  // ─── Types ─────────────────────────────────────────────────────────────
17
24
 
18
25
  /** Valid states for a background job. */
19
- export type BackgroundJobState =
20
- | 'running'
21
- | 'completed'
22
- | 'error'
23
- | 'cancelled'
24
- | 'reconciled'
26
+ export type BackgroundJobState = 'running' | 'completed' | 'error' | 'cancelled' | 'reconciled'
25
27
 
26
28
  /** Terminal states that can transition to reconciled. */
27
29
  const TERMINAL_STATES: ReadonlySet<BackgroundJobState> = new Set([
@@ -35,10 +37,7 @@ const TERMINAL_STATES: ReadonlySet<BackgroundJobState> = new Set([
35
37
  * Each entry defines which target states are reachable from the current state.
36
38
  * Only running → terminal, or same-terminal → same-terminal (idempotent re-notify).
37
39
  */
38
- const UPDATE_TRANSITIONS: Record<
39
- BackgroundJobState,
40
- ReadonlySet<BackgroundJobState>
41
- > = {
40
+ const UPDATE_TRANSITIONS: Record<BackgroundJobState, ReadonlySet<BackgroundJobState>> = {
42
41
  running: new Set(['completed', 'error', 'cancelled']),
43
42
  completed: new Set(['completed']),
44
43
  error: new Set(['error']),
@@ -371,6 +370,13 @@ export class BackgroundJobBoard {
371
370
  * Valid: only `completed`, `error`, or `cancelled` → `reconciled`.
372
371
  * Idempotent: already-reconciled jobs are returned as-is.
373
372
  * Rejected: `running` → `reconciled`.
373
+ *
374
+ * Reconcile is an ACKNOWLEDGMENT, not a completion event: it does NOT fire
375
+ * the terminal listeners (onTerminal) and does NOT resolve waiters — the
376
+ * terminal transition already did both. Firing them here produced a second
377
+ * `<task-notification>` per job (completed + reconciled) in the parent
378
+ * transcript; waiters are covered by `waitForTerminal` resolving immediately
379
+ * for already-terminal/reconciled jobs.
374
380
  */
375
381
  async markReconciled(taskID: string): Promise<BackgroundJobRecord | undefined> {
376
382
  const job = this.jobs.get(taskID)
@@ -386,7 +392,6 @@ export class BackgroundJobBoard {
386
392
  )
387
393
  }
388
394
 
389
- const wasTerminalUnreconciled = job.terminalUnreconciled
390
395
  job.state = 'reconciled'
391
396
  job.updatedAt = Date.now()
392
397
  job.terminalUnreconciled = false
@@ -394,10 +399,6 @@ export class BackgroundJobBoard {
394
399
  await this.persistRecord(job)
395
400
  await this.writeSignal(job)
396
401
 
397
- if (wasTerminalUnreconciled) {
398
- this.notifyTerminal(job.taskID)
399
- }
400
-
401
402
  return job
402
403
  }
403
404
 
@@ -415,17 +416,14 @@ export class BackgroundJobBoard {
415
416
  list(parentSessionID?: string): BackgroundJobRecord[] {
416
417
  const all = Array.from(this.jobs.values())
417
418
  if (!parentSessionID) return [...all]
418
- return all.filter(j => j.parentSessionID === parentSessionID)
419
+ return all.filter((j) => j.parentSessionID === parentSessionID)
419
420
  }
420
421
 
421
422
  /**
422
423
  * Resolve a job by either taskID or alias within a parent session.
423
424
  * Checks exact taskID first, then falls back to alias matching.
424
425
  */
425
- resolve(
426
- parentSessionID: string,
427
- taskIDOrAlias: string,
428
- ): BackgroundJobRecord | undefined {
426
+ resolve(parentSessionID: string, taskIDOrAlias: string): BackgroundJobRecord | undefined {
429
427
  // Exact taskID match (session-scoped)
430
428
  const byID = this.jobs.get(taskIDOrAlias)
431
429
  if (byID?.parentSessionID === parentSessionID) return byID
@@ -538,7 +536,7 @@ export class BackgroundJobBoard {
538
536
  const jobs = this.list(parentSessionID)
539
537
  if (jobs.length === 0) return undefined
540
538
 
541
- const lines = jobs.map(j => {
539
+ const lines = jobs.map((j) => {
542
540
  const status =
543
541
  j.state === 'completed'
544
542
  ? 'OK'
@@ -595,10 +593,7 @@ export class BackgroundJobBoard {
595
593
  try {
596
594
  await this.persistence.saveJob(record)
597
595
  } catch (err) {
598
- console.error(
599
- `[BackgroundJobBoard] Failed to persist job ${record.taskID}:`,
600
- err,
601
- )
596
+ log.error(`[BackgroundJobBoard] Failed to persist job ${record.taskID}:`, err)
602
597
  }
603
598
  }
604
599
 
@@ -607,10 +602,7 @@ export class BackgroundJobBoard {
607
602
  try {
608
603
  await this.persistence.deleteJob(taskID)
609
604
  } catch (err) {
610
- console.error(
611
- `[BackgroundJobBoard] Failed to delete persisted job ${taskID}:`,
612
- err,
613
- )
605
+ log.error(`[BackgroundJobBoard] Failed to delete persisted job ${taskID}:`, err)
614
606
  }
615
607
  }
616
608
 
@@ -619,10 +611,7 @@ export class BackgroundJobBoard {
619
611
  try {
620
612
  listener(taskID)
621
613
  } catch (err) {
622
- console.error(
623
- `[BackgroundJobBoard] Terminal listener error for ${taskID}:`,
624
- err,
625
- )
614
+ log.error(`[BackgroundJobBoard] Terminal listener error for ${taskID}:`, err)
626
615
  }
627
616
  }
628
617
  this.resolveTerminalWaiters(taskID)
@@ -648,10 +637,7 @@ export class BackgroundJobBoard {
648
637
  await writeFile(tmpPath, content, 'utf-8')
649
638
  await rename(tmpPath, signalPath)
650
639
  } catch (err) {
651
- console.error(
652
- `[BackgroundJobBoard] Failed to write signal for ${record.alias}:`,
653
- err,
654
- )
640
+ log.error(`[BackgroundJobBoard] Failed to write signal for ${record.alias}:`, err)
655
641
  }
656
642
  }
657
643
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Chat Reminders (release-134 Phase 4) — the SHARED <system-reminder> buffer.
3
+ *
4
+ * WHY a shared module (not owned by pantheon-hooks.ts):
5
+ * 1. opencode's legacy plugin loader (verified in the 1.18.16 source,
6
+ * packages/opencode/src/plugin/index.ts getLegacyPlugins) does
7
+ * `Object.values(mod)` and treats EVERY export as a plugin factory —
8
+ * a non-function export throws `TypeError("Plugin export is not a
9
+ * function")` and a function export is invoked with a PluginInput.
10
+ * pantheon-hooks.ts therefore cannot EXPORT the enqueue API.
11
+ * 2. src/plugin.ts (a separate plugin file) must ENQUEUE into the SAME
12
+ * buffer the pantheon-hooks `chat.message` hook drains — a local copy
13
+ * would enqueue into a buffer that is never delivered.
14
+ * 3. opencode loads plugins via plain `await import(entry)` (PluginLoader),
15
+ * so both plugin files importing this module resolve to ONE module
16
+ * instance (Bun/Node module cache) → one shared buffer.
17
+ *
18
+ * release-134 Phase 4 uses this buffer for the post-compaction state
19
+ * re-assertion: `reassertAfterCompaction` (compaction-assert.ts) enqueues a
20
+ * fresh-state block here, and the existing chat.message delivery path
21
+ * (pantheon-hooks.ts) injects it as a <system-reminder> into the session's
22
+ * next message. Zero new delivery code — the P0 messageID guard already
23
+ * protects subagent promptAsync fires.
24
+ *
25
+ * Bounded: at most CHAT_REMINDER_MAX entries, each expiring
26
+ * CHAT_REMINDER_TTL_MS after enqueue. Full-buffer enqueues are SKIPPED,
27
+ * never unbounded. No background timers — expiry is only checked on enqueue
28
+ * and on delivery (drainFreshChatReminders).
29
+ *
30
+ * @module chat-reminders
31
+ */
32
+
33
+ /**
34
+ * Hard cap on queued reminder entries (anti-spam philosophy, matching the
35
+ * "throttled signals are skipped, never backlogged" rule).
36
+ */
37
+ export const CHAT_REMINDER_MAX = 10
38
+
39
+ /** How long a queued reminder stays deliverable (pruned after ~60s). */
40
+ export const CHAT_REMINDER_TTL_MS = 60_000
41
+
42
+ /** One queued reminder: the text plus its enqueue timestamp. */
43
+ export interface ChatReminderEntry {
44
+ text: string
45
+ at: number
46
+ }
47
+
48
+ /** The shared buffer — module-level state, consumed by the chat.message hook. */
49
+ export const pendingChatReminders: ChatReminderEntry[] = []
50
+
51
+ /**
52
+ * Queue one chat-reminder entry, pruning expired entries first. Never throws.
53
+ * A full buffer skips the push (bounded, never backlogged).
54
+ */
55
+ export function enqueueChatReminder(text: string): void {
56
+ const now = Date.now()
57
+ for (let i = pendingChatReminders.length - 1; i >= 0; i--) {
58
+ const r = pendingChatReminders[i]
59
+ if (r !== undefined && now - r.at > CHAT_REMINDER_TTL_MS) pendingChatReminders.splice(i, 1)
60
+ }
61
+ if (pendingChatReminders.length >= CHAT_REMINDER_MAX) return
62
+ pendingChatReminders.push({ text, at: now })
63
+ }
64
+
65
+ /**
66
+ * Deliver (drain) every FRESH reminder as one joined block: expired entries
67
+ * are dropped, the buffer is cleared in all cases (no leak). Returns
68
+ * undefined when nothing fresh remains — the caller skips injection.
69
+ *
70
+ * This is the delivery helper the pantheon-hooks `chat.message` hook calls
71
+ * AFTER its P0 messageID guard (empty messageID → early return, buffer kept).
72
+ */
73
+ export function drainFreshChatReminders(): string | undefined {
74
+ const now = Date.now()
75
+ const fresh = pendingChatReminders.filter((r) => now - r.at <= CHAT_REMINDER_TTL_MS)
76
+ pendingChatReminders.length = 0
77
+ if (fresh.length === 0) return undefined
78
+ return fresh.map((r) => r.text).join('\n')
79
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Compaction Re-Assert (release-134 Phase 4) — re-inject fresh state after
3
+ * compaction.
4
+ *
5
+ * WHY: after a session is compacted, the system state may have changed since
6
+ * the summary was built (new background delegations launched, results
7
+ * delivered, goals updated). The compacted session's context is frozen at
8
+ * summary time — the model would operate on stale state.
9
+ *
10
+ * HOW: the `session.compacted` event hook calls `reassertAfterCompaction`,
11
+ * which builds a COMPACT fresh-state block (running + unread delegations from
12
+ * the board, active goals from the GoalStore — same gating as Phase 2) and
13
+ * enqueues it into the SHARED chat-reminder buffer (chat-reminders.ts). The
14
+ * existing pantheon-hooks `chat.message` hook delivers it as a
15
+ * <system-reminder> into the session's next message — zero new delivery
16
+ * code, and the P0 messageID guard already protects subagent fires.
17
+ *
18
+ * Deliberately NOT `session.promptAsync({noReply:true})`: the sprint 4+7
19
+ * spike refuted noReply as a push mechanism on this opencode build (nothing
20
+ * is delivered to the parent).
21
+ *
22
+ * FAIL-OPEN: every source (board list, goals list, enqueue) is wrapped —
23
+ * a failure logs a warn via the silence-by-default logger and the hook never
24
+ * throws. A session with NO fresh state (nothing running/unread/goals) is a
25
+ * SILENT skip (no warn, no reminder). The block is capped at
26
+ * REASSERT_MAX_LINES item lines (1 header) so it stays compact.
27
+ *
28
+ * Pure TypeScript — zero runtime deps beyond the board/goal types and the
29
+ * shared reminder buffer.
30
+ *
31
+ * @module compaction-assert
32
+ */
33
+ import type { BackgroundJobBoard } from './background-job-board.ts'
34
+ import { enqueueChatReminder } from './chat-reminders.ts'
35
+ import type { CompactionGoalSource } from './delegation-compaction.ts'
36
+ import { createPantheonLogger } from './logger.ts'
37
+
38
+ // Silence-by-default TUI policy (pantheon-hooks L42-58): console echo is
39
+ // opt-in via PANTHEON_HOOKS_LOG=1. `deps.logger` injection stays for tests.
40
+ const log = createPantheonLogger({ module: 'pantheon-compaction' })
41
+
42
+ // ─── Constants ─────────────────────────────────────────────────────────
43
+
44
+ /** Max fresh-state item lines (running + unread + goals) in the block. */
45
+ export const REASSERT_MAX_LINES = 10
46
+
47
+ /** Header line — tells the model the summary may be stale. */
48
+ export const REASSERT_HEADER = 'State re-assertion after compaction — the summary may be stale:'
49
+
50
+ // ─── Types ─────────────────────────────────────────────────────────────
51
+
52
+ /** Dependencies threaded to reassertAfterCompaction (wired from plugin.ts). */
53
+ export interface CompactionAssertDeps {
54
+ /** The compacted session whose next message receives the reminder. */
55
+ sessionID: string
56
+ /** Board query surface — `list` is all the re-assertion needs. */
57
+ board: Pick<BackgroundJobBoard, 'list'>
58
+ /** Goal source for the goals block (optional — same gating as Phase 2). */
59
+ goals?: CompactionGoalSource
60
+ /** Injectable enqueue (testable). Defaults to the shared chat buffer. */
61
+ enqueue?: (text: string) => void
62
+ /** Injectable warn logger (testable). Defaults to silence-by-default. */
63
+ logger?: { warn: (message: string) => void }
64
+ }
65
+
66
+ // ─── Formatting ────────────────────────────────────────────────────────
67
+
68
+ /** One-line terminal status label, mirroring formatForPrompt. */
69
+ function terminalLabel(state: string): string {
70
+ switch (state) {
71
+ case 'completed':
72
+ return 'OK'
73
+ case 'error':
74
+ return 'ERR'
75
+ case 'cancelled':
76
+ return 'CAN'
77
+ default:
78
+ return state.toUpperCase()
79
+ }
80
+ }
81
+
82
+ /** Collapse whitespace and truncate a description/objective. */
83
+ function truncate(text: string, max = 80): string {
84
+ const flat = text.trim().replace(/\s+/g, ' ')
85
+ return flat.length <= max ? flat : `${flat.slice(0, max)}…`
86
+ }
87
+
88
+ // ─── Build ─────────────────────────────────────────────────────────────
89
+
90
+ /**
91
+ * Build the compact fresh-state lines: running delegations first (active work
92
+ * is the freshest signal), then unread terminal delegations, then active
93
+ * goals (only while the line budget allows). Returns undefined when there is
94
+ * nothing to assert (silent skip) or a source failed (warn + skip).
95
+ */
96
+ async function buildStateLines(
97
+ deps: CompactionAssertDeps,
98
+ warn: (message: string) => void,
99
+ ): Promise<string[] | undefined> {
100
+ const lines: string[] = []
101
+ try {
102
+ const jobs = deps.board.list(deps.sessionID)
103
+ const running = jobs
104
+ .filter((j) => j.state === 'running')
105
+ .sort((a, b) => a.launchedAt - b.launchedAt)
106
+ const unread = jobs
107
+ .filter((j) => j.terminalUnreconciled)
108
+ .sort((a, b) => b.updatedAt - a.updatedAt)
109
+
110
+ for (const job of running) {
111
+ if (lines.length >= REASSERT_MAX_LINES) break
112
+ lines.push(` running [${job.alias}] ${job.agent} — ${truncate(job.description)}`)
113
+ }
114
+ for (const job of unread) {
115
+ if (lines.length >= REASSERT_MAX_LINES) break
116
+ lines.push(
117
+ ` unread [${job.alias}] ${job.agent} — ${truncate(job.description)} — ${terminalLabel(job.state)}`,
118
+ )
119
+ }
120
+
121
+ const source = deps.goals
122
+ if (lines.length < REASSERT_MAX_LINES && source !== undefined && source.enabled) {
123
+ const goals = await source.list(deps.sessionID)
124
+ for (const goal of goals.filter((g) => g.status !== 'done')) {
125
+ if (lines.length >= REASSERT_MAX_LINES) break
126
+ lines.push(` goal [${goal.id}] ${truncate(goal.objective)} — ${goal.status}`)
127
+ }
128
+ }
129
+ } catch (err: unknown) {
130
+ const reason = err instanceof Error ? err.message : String(err)
131
+ warn(`compaction re-assert failed for session ${deps.sessionID}: ${reason}`)
132
+ return undefined
133
+ }
134
+ return lines.length === 0 ? undefined : lines
135
+ }
136
+
137
+ /**
138
+ * Re-assert fresh state for a compacted session: build the compact block and
139
+ * enqueue it as a chat reminder (delivered on the session's next message).
140
+ *
141
+ * Returns true when a reminder was enqueued; false when there was nothing to
142
+ * assert (silent skip) or a source failed (warned). NEVER throws — the event
143
+ * hook must never break the session.
144
+ */
145
+ export async function reassertAfterCompaction(deps: CompactionAssertDeps): Promise<boolean> {
146
+ // Silence-by-default logger: file-only warn, console echo opt-in via
147
+ // PANTHEON_HOOKS_LOG=1 (TUI pollution policy, pantheon-hooks L42-58).
148
+ const warn = deps.logger?.warn ?? ((message: string) => log.warn(message))
149
+ try {
150
+ const lines = await buildStateLines(deps, warn)
151
+ if (lines === undefined) return false
152
+ const enqueue = deps.enqueue ?? enqueueChatReminder
153
+ enqueue(`${REASSERT_HEADER}\n${lines.join('\n')}`)
154
+ return true
155
+ } catch (err: unknown) {
156
+ const reason = err instanceof Error ? err.message : String(err)
157
+ warn(`compaction re-assert failed for session ${deps.sessionID}: ${reason}`)
158
+ return false
159
+ }
160
+ }
@@ -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. */