pantheon-opencode 1.2.0 → 1.2.1

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 (43) hide show
  1. package/README.md +101 -3
  2. package/bin/pantheon-init.mjs +79 -0
  3. package/commands/pantheon-vision.md +67 -0
  4. package/opencode.json +13 -9
  5. package/package.json +12 -5
  6. package/scripts/_pantheon_paths.py +0 -2
  7. package/scripts/doctor.mjs +4 -2
  8. package/scripts/install/health-check.mjs +101 -1
  9. package/scripts/install/model-picker.mjs +118 -0
  10. package/scripts/install/opencode.mjs +102 -19
  11. package/scripts/install/venv.mjs +25 -2
  12. package/scripts/install-mcp.mjs +98 -13
  13. package/scripts/mcp_persistence_server.py +1 -1
  14. package/scripts/secret-scan.mjs +88 -0
  15. package/scripts/uninstall.mjs +3 -3
  16. package/scripts/validate-routing.mjs +15 -0
  17. package/src/agents/hermes.md +2 -2
  18. package/src/agents/mnemosyne.md +1 -1
  19. package/src/agents/zeus.md +1 -1
  20. package/src/mcp/_pantheon_paths.py +0 -2
  21. package/src/mcp/install-mcp.mjs +6 -913
  22. package/src/mcp/mcp_persistence_server.py +2 -1
  23. package/src/mcp/pantheon_vision_server.py +495 -0
  24. package/src/mcp/requirements-vision.txt +3 -0
  25. package/src/pantheon/auto-wake.ts +91 -0
  26. package/src/pantheon/background-job-board.ts +568 -0
  27. package/src/pantheon/file-persistence.ts +95 -0
  28. package/src/pantheon/presets.d.mts +108 -0
  29. package/src/pantheon/presets.mjs +554 -0
  30. package/src/pantheon/vision.ts +1437 -0
  31. package/src/plugin.ts +69 -25
  32. package/src/plugins/tui/package.json +3 -3
  33. package/src/plugins/tui/src/index.tsx +866 -121
  34. package/src/routing.yml +148 -0
  35. package/src/skills/context-compression/SKILL.md +1 -1
  36. package/src/skills/loop-engineering/SKILL.md +1 -1
  37. package/src/skills/orchestration-workflow/SKILL.md +11 -11
  38. package/scripts/scrub_secrets.py +0 -281
  39. package/src/plugins/tui/dist/tui.d.ts +0 -8
  40. package/src/plugins/tui/dist/tui.d.ts.map +0 -1
  41. package/src/plugins/tui/dist/tui.js +0 -609
  42. package/src/plugins/tui/dist/tui.js.map +0 -1
  43. package/src/plugins/tui/dist/tui.tsx +0 -444
@@ -0,0 +1,568 @@
1
+ /**
2
+ * Background Job Board — state machine for tracking background agent jobs.
3
+ *
4
+ * Tracks jobs through a state machine (running → completed/error/cancelled → reconciled),
5
+ * supports alias generation per session, persistence via adapter, auto-wake signal files,
6
+ * and terminal state listeners (persist-before-notify write-ahead-log pattern).
7
+ *
8
+ * Pure TypeScript — zero external dependencies beyond Node.js builtins (fs/path).
9
+ *
10
+ * @module background-job-board
11
+ */
12
+
13
+ import { mkdir, rename, writeFile } from 'node:fs/promises'
14
+ import { join, dirname } from 'node:path'
15
+
16
+ // ─── Types ─────────────────────────────────────────────────────────────
17
+
18
+ /** Valid states for a background job. */
19
+ export type BackgroundJobState =
20
+ | 'running'
21
+ | 'completed'
22
+ | 'error'
23
+ | 'cancelled'
24
+ | 'reconciled'
25
+
26
+ /** Terminal states that can transition to reconciled. */
27
+ const TERMINAL_STATES: ReadonlySet<BackgroundJobState> = new Set([
28
+ 'completed',
29
+ 'error',
30
+ 'cancelled',
31
+ ])
32
+
33
+ /**
34
+ * Transitions allowed via `updateStatus()`.
35
+ * Each entry defines which target states are reachable from the current state.
36
+ * Only running → terminal, or same-terminal → same-terminal (idempotent re-notify).
37
+ */
38
+ const UPDATE_TRANSITIONS: Record<
39
+ BackgroundJobState,
40
+ ReadonlySet<BackgroundJobState>
41
+ > = {
42
+ running: new Set(['completed', 'error', 'cancelled']),
43
+ completed: new Set(['completed']),
44
+ error: new Set(['error']),
45
+ cancelled: new Set(['cancelled']),
46
+ reconciled: new Set([]),
47
+ }
48
+
49
+ /** A file context reference read during a job's execution. */
50
+ export interface ContextFile {
51
+ path: string
52
+ lineCount: number
53
+ lastReadAt: number
54
+ }
55
+
56
+ /** Full record of a background agent job. */
57
+ export interface BackgroundJobRecord {
58
+ taskID: string
59
+ parentSessionID: string
60
+ /** Agent name, e.g. "apollo", "hermes". */
61
+ agent: string
62
+ /** Human-readable description of the job. */
63
+ description: string
64
+ /** Optional objective for the job. */
65
+ objective?: string
66
+ /** Current state in the state machine. */
67
+ state: BackgroundJobState
68
+ /** Whether the job timed out (distinct from error state). */
69
+ timedOut: boolean
70
+ /** Short unique alias within the parent session, e.g. "apo-1". */
71
+ alias: string
72
+ /** Epoch ms when the job was launched. */
73
+ launchedAt: number
74
+ /** Epoch ms when the job reached a terminal state. */
75
+ completedAt?: number
76
+ /** Epoch ms of the last state change. */
77
+ updatedAt: number
78
+ /** Optional summary of the result. */
79
+ resultSummary?: string
80
+ /** Most recent error message. */
81
+ lastStatusError?: string
82
+ /** Cumulative error count across retries. */
83
+ totalErrors: number
84
+ /** Cumulative timeout count. */
85
+ timeoutCount: number
86
+ /** True if the job is in a terminal state that hasn't been reconciled yet. */
87
+ terminalUnreconciled: boolean
88
+ /** Files read during execution. */
89
+ contextFiles: ContextFile[]
90
+ }
91
+
92
+ /** Input for launching a new job. */
93
+ export interface LaunchInput {
94
+ taskID: string
95
+ parentSessionID: string
96
+ agent: string
97
+ description: string
98
+ objective?: string
99
+ contextFiles?: ContextFile[]
100
+ }
101
+
102
+ /** Input for updating a job's status to a terminal state. */
103
+ export interface StatusInput {
104
+ taskID: string
105
+ state: 'completed' | 'error' | 'cancelled'
106
+ resultSummary?: string
107
+ error?: string
108
+ timedOut?: boolean
109
+ }
110
+
111
+ /** Configuration options for the BackgroundJobBoard. */
112
+ export interface BoardOptions {
113
+ /** Maximum concurrent jobs per agent (default: 3). */
114
+ maxConcurrentPerAgent?: number
115
+ /** Maximum completed jobs kept per agent for reuse (default: 3). */
116
+ maxReusablePerAgent?: number
117
+ /**
118
+ * Directory for auto-wake signal files.
119
+ * When set, a `.signal.json` file is atomically written on every terminal transition.
120
+ * When null or undefined, no signal files are written.
121
+ */
122
+ signalDir?: string | null
123
+ }
124
+
125
+ /** Persistence adapter interface — injected by the plugin layer. */
126
+ export interface PersistenceAdapter {
127
+ saveJob(record: BackgroundJobRecord): Promise<void>
128
+ loadAllJobs(): Promise<BackgroundJobRecord[]>
129
+ deleteJob(taskID: string): Promise<void>
130
+ }
131
+
132
+ // ─── Agent Prefix Mapping ──────────────────────────────────────────────
133
+
134
+ const AGENT_PREFIXES: Record<string, string> = {
135
+ apollo: 'apo',
136
+ hermes: 'her',
137
+ aphrodite: 'aph',
138
+ demeter: 'dem',
139
+ themis: 'the',
140
+ prometheus: 'pro',
141
+ hephaestus: 'hep',
142
+ nyx: 'nyx',
143
+ athena: 'ath',
144
+ gaia: 'gai',
145
+ iris: 'iri',
146
+ mnemosyne: 'mne',
147
+ talos: 'tal',
148
+ }
149
+
150
+ function getAgentPrefix(agent: string): string {
151
+ return AGENT_PREFIXES[agent.toLowerCase()] ?? 'job'
152
+ }
153
+
154
+ // ─── BackgroundJobBoard ────────────────────────────────────────────────
155
+
156
+ /**
157
+ * In-memory job board that tracks background agent jobs through a state machine.
158
+ *
159
+ * Features:
160
+ * - Strict state machine with validated transitions
161
+ * - Per-session, per-agent alias generation (apo-1, her-2, …)
162
+ * - Optional persistence via injected PersistenceAdapter
163
+ * - Optional auto-wake signal files on terminal transitions
164
+ * - Terminal state listeners with persist-before-notify guarantee
165
+ * - expire/prune helpers for session lifecycle management
166
+ */
167
+ export class BackgroundJobBoard {
168
+ private readonly jobs: Map<string, BackgroundJobRecord> = new Map()
169
+ /**
170
+ * aliasCounters[parentSessionID][agentPrefix] → next counter value
171
+ */
172
+ private readonly aliasCounters: Map<string, Map<string, number>> = new Map()
173
+ private readonly terminalListeners: Set<(taskID: string) => void> = new Set()
174
+ private readonly options: Required<BoardOptions>
175
+ private persistence: PersistenceAdapter | null = null
176
+
177
+ constructor(options?: BoardOptions) {
178
+ this.options = {
179
+ maxConcurrentPerAgent: options?.maxConcurrentPerAgent ?? 3,
180
+ maxReusablePerAgent: options?.maxReusablePerAgent ?? 3,
181
+ signalDir: options?.signalDir ?? null,
182
+ }
183
+ }
184
+
185
+ // ─── Persistence ────────────────────────────────────────────────────
186
+
187
+ /** Inject a persistence adapter. Must be called before `recoverRunningJobs()`. */
188
+ setPersistence(adapter: PersistenceAdapter): void {
189
+ this.persistence = adapter
190
+ }
191
+
192
+ /**
193
+ * Recover previously persisted jobs on startup.
194
+ * Any jobs left in `running` state are marked as `error` (orphaned by crash).
195
+ */
196
+ async recoverRunningJobs(): Promise<void> {
197
+ if (!this.persistence) return
198
+ const records = await this.persistence.loadAllJobs()
199
+ for (const record of records) {
200
+ if (record.state === 'running') {
201
+ record.state = 'error'
202
+ record.lastStatusError = 'Process restarted — job marked as errored'
203
+ record.updatedAt = Date.now()
204
+ record.totalErrors++
205
+ }
206
+ this.jobs.set(record.taskID, record)
207
+ }
208
+ }
209
+
210
+ // ─── Core Operations ────────────────────────────────────────────────
211
+
212
+ /**
213
+ * Register a new job launch.
214
+ * The job starts in the `running` state with a generated alias.
215
+ */
216
+ async registerLaunch(input: LaunchInput): Promise<BackgroundJobRecord> {
217
+ const prefix = getAgentPrefix(input.agent)
218
+ let sessionCounters = this.aliasCounters.get(input.parentSessionID)
219
+ if (!sessionCounters) {
220
+ sessionCounters = new Map()
221
+ this.aliasCounters.set(input.parentSessionID, sessionCounters)
222
+ }
223
+ const counter = (sessionCounters.get(prefix) ?? 0) + 1
224
+ sessionCounters.set(prefix, counter)
225
+
226
+ const now = Date.now()
227
+ const record: BackgroundJobRecord = {
228
+ taskID: input.taskID,
229
+ parentSessionID: input.parentSessionID,
230
+ agent: input.agent,
231
+ description: input.description,
232
+ ...(input.objective !== undefined ? { objective: input.objective } : {}),
233
+ state: 'running',
234
+ timedOut: false,
235
+ alias: `${prefix}-${counter}`,
236
+ launchedAt: now,
237
+ updatedAt: now,
238
+ totalErrors: 0,
239
+ timeoutCount: 0,
240
+ terminalUnreconciled: false,
241
+ contextFiles: input.contextFiles ?? [],
242
+ }
243
+
244
+ this.jobs.set(input.taskID, record)
245
+ await this.persistRecord(record)
246
+ return record
247
+ }
248
+
249
+ /**
250
+ * Update a job's state to a terminal state.
251
+ *
252
+ * Valid transitions: `running` → `completed`|`error`|`cancelled`
253
+ * Idempotent: terminal → same terminal (re-notifies listeners, re-persists).
254
+ * Rejected: `running` → `running`, terminal → different terminal, `reconciled` → anything.
255
+ *
256
+ * Follows write-ahead-log: persist → signal → notify listeners.
257
+ */
258
+ async updateStatus(input: StatusInput): Promise<BackgroundJobRecord | undefined> {
259
+ const job = this.jobs.get(input.taskID)
260
+ if (!job) return undefined
261
+
262
+ const allowed = UPDATE_TRANSITIONS[job.state]
263
+ if (!allowed.has(input.state)) {
264
+ throw new Error(
265
+ `Invalid state transition: ${job.state} → ${input.state} for job ${input.taskID}`,
266
+ )
267
+ }
268
+
269
+ const wasTerminal = TERMINAL_STATES.has(job.state)
270
+ const willBeTerminal = TERMINAL_STATES.has(input.state)
271
+
272
+ // --- mutate in-memory state ---
273
+ job.state = input.state
274
+ job.updatedAt = Date.now()
275
+
276
+ if (input.resultSummary !== undefined) {
277
+ job.resultSummary = input.resultSummary
278
+ }
279
+ if (input.error !== undefined) {
280
+ job.lastStatusError = input.error
281
+ }
282
+ if (input.timedOut !== undefined) {
283
+ job.timedOut = input.timedOut
284
+ }
285
+ if (input.state === 'error') {
286
+ job.totalErrors++
287
+ }
288
+ if (input.timedOut) {
289
+ job.timeoutCount++
290
+ }
291
+ if (willBeTerminal) {
292
+ if (job.completedAt === undefined) {
293
+ job.completedAt = Date.now()
294
+ }
295
+ job.terminalUnreconciled = true
296
+ }
297
+
298
+ // --- write-ahead log: persist before notify ---
299
+ await this.persistRecord(job)
300
+
301
+ // --- signal on terminal states ---
302
+ if (willBeTerminal) {
303
+ await this.writeSignal(job)
304
+ }
305
+
306
+ // --- notify listeners on first terminal transition ---
307
+ if (willBeTerminal && !wasTerminal) {
308
+ this.notifyTerminal(job.taskID)
309
+ }
310
+
311
+ return job
312
+ }
313
+
314
+ /**
315
+ * Cancel a running job.
316
+ *
317
+ * More permissive than `updateStatus({ state: 'cancelled' })` — it allows
318
+ * cancelling from any non-terminal state. Idempotent on already-cancelled jobs.
319
+ */
320
+ async markCancelled(taskID: string, reason?: string): Promise<BackgroundJobRecord | undefined> {
321
+ const job = this.jobs.get(taskID)
322
+ if (!job) return undefined
323
+
324
+ if (job.state === 'reconciled') {
325
+ throw new Error(`Cannot cancel reconciled job ${taskID}`)
326
+ }
327
+
328
+ if (job.state === 'cancelled') {
329
+ // Idempotent — re-persist and re-signal
330
+ await this.persistRecord(job)
331
+ await this.writeSignal(job)
332
+ return job
333
+ }
334
+
335
+ if (job.state !== 'running') {
336
+ throw new Error(
337
+ `Cannot cancel job ${taskID} in state ${job.state} — only running jobs can be cancelled`,
338
+ )
339
+ }
340
+
341
+ const wasTerminal = TERMINAL_STATES.has(job.state)
342
+ job.state = 'cancelled'
343
+ job.updatedAt = Date.now()
344
+ if (reason !== undefined) {
345
+ job.lastStatusError = reason
346
+ }
347
+ job.completedAt ??= Date.now()
348
+ job.terminalUnreconciled = true
349
+
350
+ await this.persistRecord(job)
351
+ await this.writeSignal(job)
352
+
353
+ if (!wasTerminal) {
354
+ this.notifyTerminal(job.taskID)
355
+ }
356
+
357
+ return job
358
+ }
359
+
360
+ /**
361
+ * Mark a terminal job as reconciled (acknowledged/consumed by the orchestrator).
362
+ *
363
+ * Valid: only `completed`, `error`, or `cancelled` → `reconciled`.
364
+ * Idempotent: already-reconciled jobs are returned as-is.
365
+ * Rejected: `running` → `reconciled`.
366
+ */
367
+ async markReconciled(taskID: string): Promise<BackgroundJobRecord | undefined> {
368
+ const job = this.jobs.get(taskID)
369
+ if (!job) return undefined
370
+
371
+ if (job.state === 'reconciled') {
372
+ return job
373
+ }
374
+
375
+ if (!TERMINAL_STATES.has(job.state)) {
376
+ throw new Error(
377
+ `Cannot reconcile job ${taskID} in state ${job.state} — only terminal states can be reconciled`,
378
+ )
379
+ }
380
+
381
+ const wasTerminalUnreconciled = job.terminalUnreconciled
382
+ job.state = 'reconciled'
383
+ job.updatedAt = Date.now()
384
+ job.terminalUnreconciled = false
385
+
386
+ await this.persistRecord(job)
387
+ await this.writeSignal(job)
388
+
389
+ if (wasTerminalUnreconciled) {
390
+ this.notifyTerminal(job.taskID)
391
+ }
392
+
393
+ return job
394
+ }
395
+
396
+ // ─── Queries ─────────────────────────────────────────────────────────
397
+
398
+ /** Look up a job by its taskID. */
399
+ get(taskID: string): BackgroundJobRecord | undefined {
400
+ return this.jobs.get(taskID)
401
+ }
402
+
403
+ /**
404
+ * List jobs, optionally filtered by parent session ID.
405
+ * Returns a snapshot (shallow copy) of the matching records.
406
+ */
407
+ list(parentSessionID?: string): BackgroundJobRecord[] {
408
+ const all = Array.from(this.jobs.values())
409
+ if (!parentSessionID) return [...all]
410
+ return all.filter(j => j.parentSessionID === parentSessionID)
411
+ }
412
+
413
+ /**
414
+ * Resolve a job by either taskID or alias within a parent session.
415
+ * Checks exact taskID first, then falls back to alias matching.
416
+ */
417
+ resolve(
418
+ parentSessionID: string,
419
+ taskIDOrAlias: string,
420
+ ): BackgroundJobRecord | undefined {
421
+ // Exact taskID match (session-scoped)
422
+ const byID = this.jobs.get(taskIDOrAlias)
423
+ if (byID?.parentSessionID === parentSessionID) return byID
424
+
425
+ // Alias match within session
426
+ for (const job of this.jobs.values()) {
427
+ if (job.parentSessionID === parentSessionID && job.alias === taskIDOrAlias) {
428
+ return job
429
+ }
430
+ }
431
+
432
+ return undefined
433
+ }
434
+
435
+ /** Check whether a new job can be dispatched for the given agent (concurrency limit). */
436
+ canDispatch(agent: string): boolean {
437
+ return this.getRunningCount(agent) < this.options.maxConcurrentPerAgent
438
+ }
439
+
440
+ /** Count currently running jobs, optionally filtered by agent. */
441
+ getRunningCount(agent?: string): number {
442
+ let count = 0
443
+ for (const job of this.jobs.values()) {
444
+ if (job.state === 'running' && (agent === undefined || job.agent === agent)) {
445
+ count++
446
+ }
447
+ }
448
+ return count
449
+ }
450
+
451
+ // ─── Terminal State Listeners ────────────────────────────────────────
452
+
453
+ /** Register a listener that fires when a job reaches a terminal state. */
454
+ onTerminal(listener: (taskID: string) => void): void {
455
+ this.terminalListeners.add(listener)
456
+ }
457
+
458
+ /** Remove a previously registered terminal state listener. */
459
+ removeTerminalListener(listener: (taskID: string) => void): void {
460
+ this.terminalListeners.delete(listener)
461
+ }
462
+
463
+ // ─── Prompt Integration ──────────────────────────────────────────────
464
+
465
+ /**
466
+ * Generate a formatted prompt fragment for the agent system prompt,
467
+ * showing all background jobs for the given parent session.
468
+ * Returns `undefined` when there are no jobs (caller can skip the section).
469
+ */
470
+ formatForPrompt(parentSessionID: string): string | undefined {
471
+ const jobs = this.list(parentSessionID)
472
+ if (jobs.length === 0) return undefined
473
+
474
+ const lines = jobs.map(j => {
475
+ const status =
476
+ j.state === 'completed'
477
+ ? 'OK'
478
+ : j.state === 'error'
479
+ ? 'ERR'
480
+ : j.state === 'cancelled'
481
+ ? 'CAN'
482
+ : j.state === 'running'
483
+ ? 'RUN'
484
+ : 'REC'
485
+ return ` [${j.alias}] ${j.description} — ${status}`
486
+ })
487
+
488
+ return `Background Jobs:\n${lines.join('\n')}`
489
+ }
490
+
491
+ // ─── Cleanup ─────────────────────────────────────────────────────────
492
+
493
+ /**
494
+ * Remove jobs whose `updatedAt` is older than `ttlMs` milliseconds
495
+ * from the current time.
496
+ */
497
+ pruneExpired(ttlMs: number): void {
498
+ const now = Date.now()
499
+ for (const [taskID, job] of this.jobs) {
500
+ if (now - job.updatedAt > ttlMs) {
501
+ this.jobs.delete(taskID)
502
+ }
503
+ }
504
+ }
505
+
506
+ /** Remove all jobs belonging to a parent session. */
507
+ clearParent(parentSessionID: string): void {
508
+ for (const [taskID, job] of this.jobs) {
509
+ if (job.parentSessionID === parentSessionID) {
510
+ this.jobs.delete(taskID)
511
+ }
512
+ }
513
+ }
514
+
515
+ // ─── Internal Helpers ────────────────────────────────────────────────
516
+
517
+ private async persistRecord(record: BackgroundJobRecord): Promise<void> {
518
+ if (!this.persistence) return
519
+ try {
520
+ await this.persistence.saveJob(record)
521
+ } catch (err) {
522
+ console.error(
523
+ `[BackgroundJobBoard] Failed to persist job ${record.taskID}:`,
524
+ err,
525
+ )
526
+ }
527
+ }
528
+
529
+ private notifyTerminal(taskID: string): void {
530
+ for (const listener of this.terminalListeners) {
531
+ try {
532
+ listener(taskID)
533
+ } catch (err) {
534
+ console.error(
535
+ `[BackgroundJobBoard] Terminal listener error for ${taskID}:`,
536
+ err,
537
+ )
538
+ }
539
+ }
540
+ }
541
+
542
+ private async writeSignal(record: BackgroundJobRecord): Promise<void> {
543
+ const signalDir = this.options.signalDir
544
+ if (!signalDir) return
545
+
546
+ const signalPath = join(signalDir, `${record.alias}.signal.json`)
547
+ const tmpPath = join(signalDir, `${record.alias}.signal.tmp`)
548
+ const content = JSON.stringify({
549
+ taskID: record.taskID,
550
+ alias: record.alias,
551
+ agent: record.agent,
552
+ state: record.state,
553
+ summary: record.resultSummary ?? null,
554
+ timestamp: Date.now(),
555
+ })
556
+
557
+ try {
558
+ await mkdir(dirname(signalPath), { recursive: true })
559
+ await writeFile(tmpPath, content, 'utf-8')
560
+ await rename(tmpPath, signalPath)
561
+ } catch (err) {
562
+ console.error(
563
+ `[BackgroundJobBoard] Failed to write signal for ${record.alias}:`,
564
+ err,
565
+ )
566
+ }
567
+ }
568
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * File-based persistence adapter for the BackgroundJobBoard.
3
+ *
4
+ * Stores all jobs in a single `state.json` file using atomic writes
5
+ * (write to `state.json.tmp` then rename) to prevent partial writes
6
+ * from crashes.
7
+ *
8
+ * @module file-persistence
9
+ */
10
+
11
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
12
+ import { dirname } from 'node:path'
13
+
14
+ import type { BackgroundJobRecord, PersistenceAdapter } from './background-job-board.ts'
15
+
16
+ /** Default path for the state file. */
17
+ const DEFAULT_STATE_PATH = '.pantheon/board/state.json'
18
+
19
+ /**
20
+ * File-based persistence adapter.
21
+ *
22
+ * All jobs are stored in a single JSON array in `statePath`.
23
+ * Writes are atomic: content is written to a `.tmp` file, then renamed.
24
+ *
25
+ * The board's in-memory `this.jobs` Map is the source of truth —
26
+ * this file is a crash-recovery snapshot only. `deleteJob()` is a no-op
27
+ * because stale entries are harmless and overwritten on the next `saveJob()`.
28
+ */
29
+ export class FilePersistenceAdapter implements PersistenceAdapter {
30
+ private readonly statePath: string
31
+ private readonly dir: string
32
+
33
+ /**
34
+ * @param statePath Path to the state JSON file (default: `.pantheon/board/state.json`)
35
+ */
36
+ constructor(statePath?: string) {
37
+ this.statePath = statePath ?? DEFAULT_STATE_PATH
38
+ this.dir = dirname(this.statePath)
39
+ }
40
+
41
+ /** Ensure the parent directory exists. */
42
+ private async ensureDir(): Promise<void> {
43
+ await mkdir(this.dir, { recursive: true })
44
+ }
45
+
46
+ /** Path to the atomic-write temp file. */
47
+ private get tmpPath(): string {
48
+ return this.statePath + '.tmp'
49
+ }
50
+
51
+ /**
52
+ * Read all jobs from the state file.
53
+ * Returns `null` if the file doesn't exist (first run / no jobs yet).
54
+ */
55
+ private async readState(): Promise<BackgroundJobRecord[] | null> {
56
+ try {
57
+ const content = await readFile(this.statePath, 'utf-8')
58
+ return JSON.parse(content) as BackgroundJobRecord[]
59
+ } catch (err: unknown) {
60
+ if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
61
+ return null
62
+ }
63
+ throw err
64
+ }
65
+ }
66
+
67
+ /** Write the full state array atomically (tmp + rename). */
68
+ private async writeState(records: BackgroundJobRecord[]): Promise<void> {
69
+ await this.ensureDir()
70
+ const content = JSON.stringify(records, null, 2)
71
+ await writeFile(this.tmpPath, content, 'utf-8')
72
+ await rename(this.tmpPath, this.statePath)
73
+ }
74
+
75
+ async saveJob(record: BackgroundJobRecord): Promise<void> {
76
+ const records = (await this.readState()) ?? []
77
+ const idx = records.findIndex(r => r.taskID === record.taskID)
78
+ if (idx >= 0) {
79
+ records[idx] = record
80
+ } else {
81
+ records.push(record)
82
+ }
83
+ await this.writeState(records)
84
+ }
85
+
86
+ async loadAllJobs(): Promise<BackgroundJobRecord[]> {
87
+ return (await this.readState()) ?? []
88
+ }
89
+
90
+ async deleteJob(_taskID: string): Promise<void> {
91
+ // No-op: the board's in-memory Map is the source of truth.
92
+ // The file is a crash-recovery snapshot; stale entries are harmless
93
+ // and will be overwritten on the next saveJob().
94
+ }
95
+ }