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
package/src/agents/zeus.md
CHANGED
|
@@ -144,6 +144,34 @@ task_status(task_id="ses_xxx", wait=true)
|
|
|
144
144
|
-> bloqueia ate completar: { state: "completed", task_result: "..." }
|
|
145
145
|
```
|
|
146
146
|
|
|
147
|
+
### Pantheon Delegation Tools (plugin) — 3-tool API
|
|
148
|
+
|
|
149
|
+
O plugin Pantheon adiciona 3 tools de delegacao gerenciada pelo BackgroundJobBoard:
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
pantheon_delegate({prompt, agent, description?, read_only?})
|
|
153
|
+
-> cria sessao filha, registra no board, retorna alias: [apo-1]
|
|
154
|
+
pantheon_delegation_read({id: "apo-1"})
|
|
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)
|
|
158
|
+
pantheon_delegation_list()
|
|
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
|
|
162
|
+
```
|
|
163
|
+
|
|
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.
|
|
165
|
+
- **Depth-2 hard-enforced:** sessao read-only NAO cria subagentes (`task` bloqueado) — investigacao nunca vira arvore.
|
|
166
|
+
- **Compactacao:** jobs em voo (running + unread terminal) sao injetados no contexto de compactacao via `experimental.session.compacting` — delegacao em andamento nao se perde ao compactar.
|
|
167
|
+
|
|
168
|
+
### DELEGATION_RULES (plugin path)
|
|
169
|
+
|
|
170
|
+
1. **NUNCA** polle `pantheon_delegation_list` para checar se um job terminou.
|
|
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.
|
|
172
|
+
3. Use `pantheon_delegation_read({id})` APENAS para fan-in explicito / recuperacao de resultado sob demanda (bloqueia ate o fim).
|
|
173
|
+
4. `pantheon_delegation_list` e para diagnostico, nao para polling.
|
|
174
|
+
|
|
147
175
|
### Background (sempre usar)
|
|
148
176
|
- **Apollo, Hermes, Aphrodite, Demeter, Hephaestus, Prometheus**
|
|
149
177
|
- Dispare em waves paralelas: ate 5 concorrentes
|
|
@@ -256,3 +284,55 @@ Wave: dispare N, colete com tolerancia a falha
|
|
|
256
284
|
catch:
|
|
257
285
|
r = retry(agente, prompt_alternativo)
|
|
258
286
|
```
|
|
287
|
+
|
|
288
|
+
### Plugin Enforcer (auto — session.idle hook)
|
|
289
|
+
O plugin re-injeta "Continue: pending todos remain — review and proceed." em sessões root/não-board que
|
|
290
|
+
vão a idle com todos incompletos. Guards (todos no `src/pantheon/todo-enforcer.ts`):
|
|
291
|
+
- **User-activity (30s)** — após uma mensagem do usuário (`chat.message` hook),
|
|
292
|
+
a injeção é suprimida por `user_activity_quiet_ms: 30000`.
|
|
293
|
+
- **Board-running** — sessão com job background do nosso board em running → skip.
|
|
294
|
+
- **Native-children (2 min)** — children de `task(background=true)` do opencode
|
|
295
|
+
NÃO estão no nosso board; o enforcer consulta `session.children()` e pula se
|
|
296
|
+
algum child tiver `time.updated` mais novo que `child_active_ms: 120000`
|
|
297
|
+
(background task nativo ainda rodando). API indisponível → fail-open (log + injeta).
|
|
298
|
+
- **Kill-switch**: `PANTHEON_TODO_ENFORCER=off` desativa o enforcer por completo
|
|
299
|
+
(lido na construção do plugin). routing.yml é espelho de documentação — o env
|
|
300
|
+
var é o switch real (precedente COMPACTION_MAX_ITEMS).
|
|
301
|
+
|
|
302
|
+
## Wave 4 (PR #46): Empty-Result Retry + /cost + Themis Tier
|
|
303
|
+
|
|
304
|
+
### Empty-Result Retry (dispatch-guard — MANUAL orchestration)
|
|
305
|
+
OpenCode 1.18.x NAO permite interceptar a conclusao de `task()` via hooks,
|
|
306
|
+
entao o `src/pantheon/dispatch-guard.ts` e uma lib pura usada POR VOCE na
|
|
307
|
+
orquestracao (NAO esta wired no plugin):
|
|
308
|
+
|
|
309
|
+
```
|
|
310
|
+
import { createDispatchGuard } from '.../src/pantheon/dispatch-guard.ts' # via code-mode ou subagente
|
|
311
|
+
|
|
312
|
+
guard = createDispatchGuard({ retryOnEmpty: true, logger: { warn: console.warn } })
|
|
313
|
+
|
|
314
|
+
# classificar resultado de task_status/wait:
|
|
315
|
+
# 'content' → tem texto, pronto
|
|
316
|
+
# 'empty-mode1' → SEM texto E SEM tokens (nada voltou) → RETRY 1x
|
|
317
|
+
# 'empty-mode2' → SEM texto, MAS tokens (raciocinou, perdeu a parte de texto;
|
|
318
|
+
# assinatura da falha themis Wave-2) → RETRY 1x
|
|
319
|
+
|
|
320
|
+
out = await guard.maybeRetry(result, async () => { ...task() de novo... })
|
|
321
|
+
# out.retried=true se redisparou; CAP DURO de 1 retry — nunca 2x.
|
|
322
|
+
# Se o retry voltar vazio, `out.retried=false` — NAO tente de novo: escale.
|
|
323
|
+
```
|
|
324
|
+
Regra: retry 1x APENAS em `empty-mode1`/`empty-mode2`. Resultado com conteudo
|
|
325
|
+
nunca redispara. Apos 1 retry vazio → escalate (mesma regra do TODO Enforcer).
|
|
326
|
+
|
|
327
|
+
### /cost — pantheon_cost tool (WIRED no plugin)
|
|
328
|
+
`pantheon_cost({ days?: number })` le o `opencode.db` READ-ONLY (node:sqlite;
|
|
329
|
+
fallback `scripts/cost.mjs`) e devolve tabela markdown de custo + tokens por
|
|
330
|
+
agente nos ultimos N dias (default 7). Uso: quando Nyx pedir visibilidade de
|
|
331
|
+
custo, ou antes de escalar tier — decida se o batch valeu o preco.
|
|
332
|
+
|
|
333
|
+
### Themis Tier Policy (routing.yml go-deepseek)
|
|
334
|
+
Revisoes de FASE do themis rodam em `opencode/deepseek-v4-flash`
|
|
335
|
+
(review rapido, barato — evita a assinatura empty-mode2 de retorno vazio).
|
|
336
|
+
O tier PRO (`deepseek-v4-pro`) fica RESERVADO para o FINAL GATE (auditoria
|
|
337
|
+
final apos tudo aprovado) — override manual na hora. Athena NAO muda:
|
|
338
|
+
planner continua em pro.
|
|
@@ -11,17 +11,19 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { mkdir, rename, writeFile } from 'node:fs/promises'
|
|
14
|
-
import {
|
|
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']),
|
|
@@ -129,6 +128,13 @@ export interface PersistenceAdapter {
|
|
|
129
128
|
deleteJob(taskID: string): Promise<void>
|
|
130
129
|
}
|
|
131
130
|
|
|
131
|
+
/** A pending `waitForTerminal()` registration for a taskID. */
|
|
132
|
+
interface TerminalWaiter {
|
|
133
|
+
resolve: (job: BackgroundJobRecord) => void
|
|
134
|
+
reject: (err: Error) => void
|
|
135
|
+
timer: NodeJS.Timeout
|
|
136
|
+
}
|
|
137
|
+
|
|
132
138
|
// ─── Agent Prefix Mapping ──────────────────────────────────────────────
|
|
133
139
|
|
|
134
140
|
const AGENT_PREFIXES: Record<string, string> = {
|
|
@@ -171,6 +177,7 @@ export class BackgroundJobBoard {
|
|
|
171
177
|
*/
|
|
172
178
|
private readonly aliasCounters: Map<string, Map<string, number>> = new Map()
|
|
173
179
|
private readonly terminalListeners: Set<(taskID: string) => void> = new Set()
|
|
180
|
+
private readonly terminalWaiters: Map<string, TerminalWaiter[]> = new Map()
|
|
174
181
|
private readonly options: Required<BoardOptions>
|
|
175
182
|
private persistence: PersistenceAdapter | null = null
|
|
176
183
|
|
|
@@ -363,6 +370,13 @@ export class BackgroundJobBoard {
|
|
|
363
370
|
* Valid: only `completed`, `error`, or `cancelled` → `reconciled`.
|
|
364
371
|
* Idempotent: already-reconciled jobs are returned as-is.
|
|
365
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.
|
|
366
380
|
*/
|
|
367
381
|
async markReconciled(taskID: string): Promise<BackgroundJobRecord | undefined> {
|
|
368
382
|
const job = this.jobs.get(taskID)
|
|
@@ -378,7 +392,6 @@ export class BackgroundJobBoard {
|
|
|
378
392
|
)
|
|
379
393
|
}
|
|
380
394
|
|
|
381
|
-
const wasTerminalUnreconciled = job.terminalUnreconciled
|
|
382
395
|
job.state = 'reconciled'
|
|
383
396
|
job.updatedAt = Date.now()
|
|
384
397
|
job.terminalUnreconciled = false
|
|
@@ -386,10 +399,6 @@ export class BackgroundJobBoard {
|
|
|
386
399
|
await this.persistRecord(job)
|
|
387
400
|
await this.writeSignal(job)
|
|
388
401
|
|
|
389
|
-
if (wasTerminalUnreconciled) {
|
|
390
|
-
this.notifyTerminal(job.taskID)
|
|
391
|
-
}
|
|
392
|
-
|
|
393
402
|
return job
|
|
394
403
|
}
|
|
395
404
|
|
|
@@ -407,17 +416,14 @@ export class BackgroundJobBoard {
|
|
|
407
416
|
list(parentSessionID?: string): BackgroundJobRecord[] {
|
|
408
417
|
const all = Array.from(this.jobs.values())
|
|
409
418
|
if (!parentSessionID) return [...all]
|
|
410
|
-
return all.filter(j => j.parentSessionID === parentSessionID)
|
|
419
|
+
return all.filter((j) => j.parentSessionID === parentSessionID)
|
|
411
420
|
}
|
|
412
421
|
|
|
413
422
|
/**
|
|
414
423
|
* Resolve a job by either taskID or alias within a parent session.
|
|
415
424
|
* Checks exact taskID first, then falls back to alias matching.
|
|
416
425
|
*/
|
|
417
|
-
resolve(
|
|
418
|
-
parentSessionID: string,
|
|
419
|
-
taskIDOrAlias: string,
|
|
420
|
-
): BackgroundJobRecord | undefined {
|
|
426
|
+
resolve(parentSessionID: string, taskIDOrAlias: string): BackgroundJobRecord | undefined {
|
|
421
427
|
// Exact taskID match (session-scoped)
|
|
422
428
|
const byID = this.jobs.get(taskIDOrAlias)
|
|
423
429
|
if (byID?.parentSessionID === parentSessionID) return byID
|
|
@@ -460,6 +466,65 @@ export class BackgroundJobBoard {
|
|
|
460
466
|
this.terminalListeners.delete(listener)
|
|
461
467
|
}
|
|
462
468
|
|
|
469
|
+
/**
|
|
470
|
+
* Resolve with the job record once the job reaches a terminal state
|
|
471
|
+
* (`completed`, `error`, `cancelled`, or `reconciled`), or reject with a
|
|
472
|
+
* timeout error if `timeoutMs` elapses first.
|
|
473
|
+
*
|
|
474
|
+
* Resolves immediately when the job is already terminal at call time.
|
|
475
|
+
* Multiple concurrent waiters for the same taskID all resolve.
|
|
476
|
+
*/
|
|
477
|
+
waitForTerminal(taskID: string, timeoutMs: number): Promise<BackgroundJobRecord> {
|
|
478
|
+
const existing = this.jobs.get(taskID)
|
|
479
|
+
if (existing && (TERMINAL_STATES.has(existing.state) || existing.state === 'reconciled')) {
|
|
480
|
+
return Promise.resolve(existing)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return new Promise<BackgroundJobRecord>((resolve, reject) => {
|
|
484
|
+
const waiter: TerminalWaiter = {
|
|
485
|
+
resolve,
|
|
486
|
+
reject,
|
|
487
|
+
timer: setTimeout(() => {
|
|
488
|
+
this.removeTerminalWaiter(taskID, waiter)
|
|
489
|
+
reject(
|
|
490
|
+
new Error(
|
|
491
|
+
`Timed out after ${timeoutMs}ms waiting for job ${taskID} to reach a terminal state`,
|
|
492
|
+
),
|
|
493
|
+
)
|
|
494
|
+
}, timeoutMs),
|
|
495
|
+
}
|
|
496
|
+
const waiters = this.terminalWaiters.get(taskID) ?? []
|
|
497
|
+
waiters.push(waiter)
|
|
498
|
+
this.terminalWaiters.set(taskID, waiters)
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private removeTerminalWaiter(taskID: string, waiter: TerminalWaiter): void {
|
|
503
|
+
const waiters = this.terminalWaiters.get(taskID)
|
|
504
|
+
if (!waiters) return
|
|
505
|
+
const idx = waiters.indexOf(waiter)
|
|
506
|
+
if (idx < 0) return
|
|
507
|
+
waiters.splice(idx, 1)
|
|
508
|
+
if (waiters.length === 0) {
|
|
509
|
+
this.terminalWaiters.delete(taskID)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private resolveTerminalWaiters(taskID: string): void {
|
|
514
|
+
const waiters = this.terminalWaiters.get(taskID)
|
|
515
|
+
if (!waiters) return
|
|
516
|
+
this.terminalWaiters.delete(taskID)
|
|
517
|
+
const job = this.jobs.get(taskID)
|
|
518
|
+
for (const waiter of waiters) {
|
|
519
|
+
clearTimeout(waiter.timer)
|
|
520
|
+
if (job) {
|
|
521
|
+
waiter.resolve(job)
|
|
522
|
+
} else {
|
|
523
|
+
waiter.reject(new Error(`Job ${taskID} was removed before reaching a terminal state`))
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
463
528
|
// ─── Prompt Integration ──────────────────────────────────────────────
|
|
464
529
|
|
|
465
530
|
/**
|
|
@@ -471,7 +536,7 @@ export class BackgroundJobBoard {
|
|
|
471
536
|
const jobs = this.list(parentSessionID)
|
|
472
537
|
if (jobs.length === 0) return undefined
|
|
473
538
|
|
|
474
|
-
const lines = jobs.map(j => {
|
|
539
|
+
const lines = jobs.map((j) => {
|
|
475
540
|
const status =
|
|
476
541
|
j.state === 'completed'
|
|
477
542
|
? 'OK'
|
|
@@ -491,16 +556,25 @@ export class BackgroundJobBoard {
|
|
|
491
556
|
// ─── Cleanup ─────────────────────────────────────────────────────────
|
|
492
557
|
|
|
493
558
|
/**
|
|
494
|
-
* Remove jobs whose `updatedAt` is older than `ttlMs` milliseconds
|
|
495
|
-
*
|
|
559
|
+
* Remove jobs whose `updatedAt` is older than `ttlMs` milliseconds.
|
|
560
|
+
*
|
|
561
|
+
* Only TERMINAL (completed/error/cancelled) and reconciled jobs are pruned —
|
|
562
|
+
* running jobs are never pruned. Each pruned job is also removed from the
|
|
563
|
+
* persistence adapter so it doesn't resurface after a restart.
|
|
496
564
|
*/
|
|
497
|
-
pruneExpired(ttlMs: number): void {
|
|
565
|
+
async pruneExpired(ttlMs: number): Promise<void> {
|
|
498
566
|
const now = Date.now()
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
567
|
+
const expired: BackgroundJobRecord[] = []
|
|
568
|
+
for (const job of this.jobs.values()) {
|
|
569
|
+
const isDone = TERMINAL_STATES.has(job.state) || job.state === 'reconciled'
|
|
570
|
+
if (isDone && now - job.updatedAt > ttlMs) {
|
|
571
|
+
expired.push(job)
|
|
502
572
|
}
|
|
503
573
|
}
|
|
574
|
+
for (const job of expired) {
|
|
575
|
+
this.jobs.delete(job.taskID)
|
|
576
|
+
await this.deletePersisted(job.taskID)
|
|
577
|
+
}
|
|
504
578
|
}
|
|
505
579
|
|
|
506
580
|
/** Remove all jobs belonging to a parent session. */
|
|
@@ -519,10 +593,16 @@ export class BackgroundJobBoard {
|
|
|
519
593
|
try {
|
|
520
594
|
await this.persistence.saveJob(record)
|
|
521
595
|
} catch (err) {
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
596
|
+
log.error(`[BackgroundJobBoard] Failed to persist job ${record.taskID}:`, err)
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private async deletePersisted(taskID: string): Promise<void> {
|
|
601
|
+
if (!this.persistence) return
|
|
602
|
+
try {
|
|
603
|
+
await this.persistence.deleteJob(taskID)
|
|
604
|
+
} catch (err) {
|
|
605
|
+
log.error(`[BackgroundJobBoard] Failed to delete persisted job ${taskID}:`, err)
|
|
526
606
|
}
|
|
527
607
|
}
|
|
528
608
|
|
|
@@ -531,12 +611,10 @@ export class BackgroundJobBoard {
|
|
|
531
611
|
try {
|
|
532
612
|
listener(taskID)
|
|
533
613
|
} catch (err) {
|
|
534
|
-
|
|
535
|
-
`[BackgroundJobBoard] Terminal listener error for ${taskID}:`,
|
|
536
|
-
err,
|
|
537
|
-
)
|
|
614
|
+
log.error(`[BackgroundJobBoard] Terminal listener error for ${taskID}:`, err)
|
|
538
615
|
}
|
|
539
616
|
}
|
|
617
|
+
this.resolveTerminalWaiters(taskID)
|
|
540
618
|
}
|
|
541
619
|
|
|
542
620
|
private async writeSignal(record: BackgroundJobRecord): Promise<void> {
|
|
@@ -559,10 +637,7 @@ export class BackgroundJobBoard {
|
|
|
559
637
|
await writeFile(tmpPath, content, 'utf-8')
|
|
560
638
|
await rename(tmpPath, signalPath)
|
|
561
639
|
} catch (err) {
|
|
562
|
-
|
|
563
|
-
`[BackgroundJobBoard] Failed to write signal for ${record.alias}:`,
|
|
564
|
-
err,
|
|
565
|
-
)
|
|
640
|
+
log.error(`[BackgroundJobBoard] Failed to write signal for ${record.alias}:`, err)
|
|
566
641
|
}
|
|
567
642
|
}
|
|
568
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
|
+
}
|