pantheon-opencode 1.5.0-beta.7 β†’ 1.5.0-beta.8

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/CHANGELOG.md CHANGED
@@ -18,6 +18,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
18
18
 
19
19
  ## βœ… Closed Issues
20
20
 
21
+ ## [v1.5.0-beta.8] - 2026-09-13
22
+
23
+ <!-- Add new changes here. Running `node scripts/versioning.mjs apply` will
24
+ move this section to a versioned entry and reset the template below. -->
25
+
26
+ ## 🐞 Fixed
27
+
28
+ - **Board persistence is corruption- and concurrency-safe:** state writes now go
29
+ through a UNIQUE temp file (per-process, per-write counter + random suffix), are
30
+ `fsync`ed, then renamed over the destination with bounded ENOENT retries, and
31
+ read-modify-write cycles on the same state path are serialized. This closes the
32
+ window where two writers could interleave and corrupt `state.json` or race the
33
+ rename.
34
+ - **Corrupt board state is quarantined and salvaged:** a malformed `state.json` is
35
+ moved aside to `<state>.corrupt-<ts>`, a clear error is logged, and every complete
36
+ record that can be parsed is salvaged and returned instead of throwing β€” so
37
+ delete/prune/recover callers cannot get stuck in a retry loop.
38
+ - **P1-1 β€” no orphan `.signal.json` on reconcile:** reconcile now deletes any
39
+ still-pending wake signal instead of re-writing it. Reconcile is an
40
+ acknowledgment, not a new terminal transition, so the finalize→reconcile cycle
41
+ no longer leaves a stale `<alias>.signal.json` with `state:"reconciled"` behind.
42
+ - **Unresolved `{sessionID}` template guard:** the TUI/runtime can hand the plugin
43
+ the unsubstituted literal `{sessionID}` (or `%7BsessionID%7D`) when no session is
44
+ focused; the plugin now refuses placeholder session ids at the delegation,
45
+ bootstrap-probe, and activity-sample boundaries instead of forwarding them to the
46
+ session API (which rejected every call).
47
+ - **Delegation lookup scoped to the calling session:** `pantheon_delegation_read`
48
+ resolves exact task IDs first (must belong to the caller's session), then alias
49
+ matches within that session (exactly one required). Cross-session and ambiguous
50
+ aliases now return explicit errors instead of the generic "Unknown delegation".
51
+
52
+
21
53
  ## [v1.5.0-beta.7] - 2026-09-13
22
54
 
23
55
  &lt;!-- Add new changes here. Running `node scripts/versioning.mjs apply` will
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pantheon-opencode",
3
- "version": "1.5.0-beta.7",
3
+ "version": "1.5.0-beta.8",
4
4
  "description": "Multi-agent orchestration for OpenCode β€” 14 specialized agents, TDD, Themis review gates.",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pantheon",
3
3
  "publisher": "ils15",
4
- "version": "1.5.0-beta.7",
4
+ "version": "1.5.0-beta.8",
5
5
  "displayName": "Pantheon",
6
6
  "description": "Conductor-Delegate orchestration platform with 14 specialized agents, 21 skills, and OpenCode support.",
7
7
  "agents": "./src/agents",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
4
4
 
5
5
  [project]
6
6
  name = "pantheon"
7
- version = "1.5.0-beta.7"
7
+ version = "1.5.0-beta.8"
8
8
  description = "Multi-agent orchestration framework"
9
9
  requires-python = ">=3.11"
10
10
  dependencies = []
@@ -457,7 +457,12 @@ export class BackgroundJobBoard {
457
457
  job.terminalUnreconciled = false
458
458
 
459
459
  await this.persistRecord(job)
460
- await this.writeSignal(job)
460
+ // P1-1: reconcile is an acknowledgment, NOT a new terminal transition
461
+ // (onTerminal does not re-fire). Re-writing a `.signal.json` here leaked
462
+ // orphan signals with `state:"reconciled"` after the finalize→reconcile
463
+ // cycle deleted them. Instead, remove any still-pending wake signal so
464
+ // reconcile leaves no `<alias>.signal.json` behind.
465
+ await this.deleteSignal(job.alias)
461
466
 
462
467
  return job
463
468
  }
@@ -61,6 +61,7 @@ import {
61
61
  import { finalizeIdleChildrenWithoutMd } from './delegation-notify.ts'
62
62
  import { createPantheonLogger } from './logger.ts'
63
63
  import { buildAgentListDescription } from './permission-globs.ts'
64
+ import { safeSessionPath } from './session-guard.ts'
64
65
  import { buildStopInstruction, cappedSummary, DEFAULT_MAX_STEPS } from './step-cap.ts'
65
66
 
66
67
  export type {
@@ -96,6 +97,40 @@ function readIntegerEnv(
96
97
  return Number.isSafeInteger(value) && value >= minimum ? value : fallback
97
98
  }
98
99
 
100
+ // ─── B1: unresolved session template guard ─────────────────────────────
101
+ //
102
+ // The TUI/runtime can hand a plugin surface the UNSUBSTITUTED template literal
103
+ // `{sessionID}` (URL-encoded `%7BsessionID%7D`) when no session is focused.
104
+ // Forwarding it to a session API makes the opencode server reject every call
105
+ // (`Expected a string starting with "ses", got "%7BsessionID%7D"`), which the
106
+ // beta.7 E2E recorded at ~1k errors/s. The delegation layer is a defensive
107
+ // choke point too: it is downstream of the plugin adapter in production, but
108
+ // tests, older hosts, and direct embedders can inject a raw client.
109
+ //
110
+ // This guard targets the placeholder ONLY. It is deliberately NOT the full
111
+ // `isValidSessionId` contract: structural test hosts use non-"ses" ids on
112
+ // purpose (delegation-bootstrap.test.ts), and rejecting those would be a
113
+ // behavior change unrelated to this bug. `session-guard.ts` remains the
114
+ // strict choke point for the plugin adapters.
115
+ const UNRESOLVED_SESSION_TEMPLATE = /(?:\{sessionID\}|%7BsessionID%7D)/i
116
+
117
+ /** True when a session id is an unresolved `{sessionID}` template placeholder. */
118
+ export function isUnresolvedSessionTemplate(id: unknown): boolean {
119
+ return typeof id === 'string' && UNRESOLVED_SESSION_TEMPLATE.test(id)
120
+ }
121
+
122
+ /**
123
+ * Refuse a placeholder session id before it reaches a session API. Returns
124
+ * true (and logs a clear warning) when the caller must skip the operation.
125
+ */
126
+ function refuseUnresolvedSessionTemplate(id: unknown, operation: string): boolean {
127
+ if (!isUnresolvedSessionTemplate(id)) return false
128
+ log.warn(
129
+ `[pantheon-delegate] ${operation} skipped: unresolved session template "${String(id)}" β€” the host did not substitute the session id`,
130
+ )
131
+ return true
132
+ }
133
+
99
134
  type BootstrapOutcome =
100
135
  | { status: 'started'; elapsedMs: number; reason: string }
101
136
  | { status: 'startup_failed' | 'bootstrap_unknown'; elapsedMs: number; reason: string }
@@ -115,6 +150,9 @@ async function promptWithTimeout(
115
150
  input: Parameters<DelegationClient['session']['promptAsync']>[0],
116
151
  timeoutMs: number,
117
152
  ): Promise<PromptOutcome> {
153
+ if (refuseUnresolvedSessionTemplate(input.path.id, 'promptAsync')) {
154
+ return { status: 'rejected', reason: `invalid sessionID: ${input.path.id}` }
155
+ }
118
156
  const controller = new AbortController()
119
157
  let timer: NodeJS.Timeout | undefined
120
158
  const operation = client.session.promptAsync({ ...input, signal: controller.signal }).then(
@@ -190,6 +228,14 @@ async function bootstrapChild(
190
228
  const hasStatusAPI = typeof client.session.get === 'function'
191
229
  const acceptedRunning = isRunningSession(accepted)
192
230
 
231
+ if (refuseUnresolvedSessionTemplate(childSessionID, 'bootstrap probe')) {
232
+ return {
233
+ status: 'bootstrap_unknown',
234
+ elapsedMs: 0,
235
+ reason: `invalid child sessionID "${childSessionID}"`,
236
+ }
237
+ }
238
+
193
239
  if (!hasMessagesAPI && !hasStatusAPI) {
194
240
  return acceptedRunning
195
241
  ? {
@@ -403,6 +449,72 @@ function stateLabel(state: BackgroundJobRecord['state']): string {
403
449
  }
404
450
  }
405
451
 
452
+ // ─── B2: session-scoped delegation target resolution ───────────────────
453
+ //
454
+ // Board aliases restart per parent session (apo-1 in every root), so an alias
455
+ // lookup MUST be scoped to the calling session. `pantheon_delegation_read`
456
+ // previously delegated to the board's first-match resolver and, when the id
457
+ // was not found in the current session, the generic "Unknown delegation"
458
+ // message hid the real cause (the alias belonged to another session). This
459
+ // helper makes the scoping explicit and returns a CLEAR error for each
460
+ // cross-session / ambiguous case.
461
+
462
+ /** Resolution outcome for a delegation read by task ID or alias. */
463
+ export type DelegationTargetResolution =
464
+ | { kind: 'found'; job: BackgroundJobRecord }
465
+ | { kind: 'error'; message: string }
466
+
467
+ /**
468
+ * Resolve a `pantheon_delegation_read` target with strict session scoping:
469
+ * - exact task-ID match first (must belong to `sessionID`);
470
+ * - then alias matches WITHIN `sessionID` (exactly one required);
471
+ * - an alias that exists only in another session β†’ clear cross-session
472
+ * error (never another session's job);
473
+ * - two matches in this session β†’ clear ambiguity error.
474
+ */
475
+ export function resolveDelegationTarget(
476
+ board: Pick<BackgroundJobBoard, 'list'>,
477
+ sessionID: string,
478
+ idOrAlias: string,
479
+ ): DelegationTargetResolution {
480
+ const jobs = board.list()
481
+
482
+ const byTaskID = jobs.find((job) => job.taskID === idOrAlias)
483
+ if (byTaskID !== undefined) {
484
+ if (byTaskID.parentSessionID === sessionID) return { kind: 'found', job: byTaskID }
485
+ return {
486
+ kind: 'error',
487
+ message:
488
+ `Delegation "${idOrAlias}" belongs to a different session ` +
489
+ `(${byTaskID.parentSessionID}) and cannot be read from ${sessionID}.`,
490
+ }
491
+ }
492
+
493
+ const aliasMatches = jobs.filter((job) => job.alias === idOrAlias)
494
+ const inSession = aliasMatches.filter((job) => job.parentSessionID === sessionID)
495
+ if (inSession.length === 1) return { kind: 'found', job: inSession[0]! }
496
+ if (inSession.length > 1) {
497
+ return {
498
+ kind: 'error',
499
+ message:
500
+ `Delegation alias "${idOrAlias}" is ambiguous in session ${sessionID} ` +
501
+ `(${inSession.length} matches); read it by task ID instead.`,
502
+ }
503
+ }
504
+ if (aliasMatches.length > 0) {
505
+ return {
506
+ kind: 'error',
507
+ message:
508
+ `Delegation alias "${idOrAlias}" belongs to a different session β€” ` +
509
+ 'aliases are session-scoped. Use pantheon_delegation_list in the session that created it.',
510
+ }
511
+ }
512
+ return {
513
+ kind: 'error',
514
+ message: `Unknown delegation "${idOrAlias}" for this session. Use pantheon_delegation_list to see active delegations.`,
515
+ }
516
+ }
517
+
406
518
  /** Normalize the SDK-provided caller agent without inventing an identity. */
407
519
  const normalizeToolAgent = normalizeDelegationAgent
408
520
 
@@ -509,6 +621,7 @@ async function sampleChildActivity(
509
621
  collector: ActivityCollector,
510
622
  ): Promise<void> {
511
623
  if (typeof client.session.messages !== 'function') return
624
+ if (refuseUnresolvedSessionTemplate(childSessionID, 'activity sample')) return
512
625
  try {
513
626
  const bundles = await client.session.messages({ path: { id: childSessionID } })
514
627
  if (!Array.isArray(bundles)) return
@@ -534,6 +647,7 @@ async function lastChildActivity(
534
647
  childSessionID: string,
535
648
  ): Promise<string | undefined> {
536
649
  if (typeof client.session.messages !== 'function') return undefined
650
+ if (refuseUnresolvedSessionTemplate(childSessionID, 'last activity fetch')) return undefined
537
651
  try {
538
652
  const bundles = await client.session.messages({ path: { id: childSessionID } })
539
653
  if (!Array.isArray(bundles)) return undefined
@@ -775,8 +889,13 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
775
889
  delete?: (input: { path: { id: string } }) => Promise<unknown>
776
890
  }
777
891
  if (typeof session.delete !== 'function') return
892
+ const sessionPath = safeSessionPath(childSessionID)
893
+ if (sessionPath === null) {
894
+ log.warn(`[pantheon-delegate] session.delete skipped: invalid sessionID "${childSessionID}"`)
895
+ return
896
+ }
778
897
  try {
779
- await session.delete({ path: { id: childSessionID } })
898
+ await session.delete(sessionPath)
780
899
  } catch (err: unknown) {
781
900
  const reason = err instanceof Error ? err.message : String(err)
782
901
  log.warn(`[pantheon-delegate] failed to delete orphan session ${childSessionID}: ${reason}`)
@@ -899,6 +1018,18 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
899
1018
  return `pantheon_delegate failed to create child session: ${reason}`
900
1019
  }
901
1020
  const childSessionID = created.id
1021
+ // B1: a host that passes through the unresolved template must never
1022
+ // produce a board job / bootstrap poll β€” reject it here with a clear
1023
+ // message and refund the reserved budget (no child was started).
1024
+ if (isUnresolvedSessionTemplate(childSessionID)) {
1025
+ if (budgetReserved && budgetKey !== undefined) {
1026
+ budgetUsage.set(budgetKey, Math.max(0, (budgetUsage.get(budgetKey) ?? 1) - 1))
1027
+ }
1028
+ log.warn(
1029
+ `[pantheon-delegate] session.create returned unresolved session template "${childSessionID}" β€” child ignored`,
1030
+ )
1031
+ return `pantheon_delegate failed: host returned an unresolved child session template ("${childSessionID}"); expected an id starting with "ses".`
1032
+ }
902
1033
  knownChildren.add(childSessionID)
903
1034
  options.registerChildSession?.(childSessionID, ctx.sessionID)
904
1035
 
@@ -1024,6 +1155,12 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
1024
1155
  return `[STARTUP FAILED] ${bootstrap.status}: ${bootstrap.reason}; retry creation failed: ${reason}`
1025
1156
  }
1026
1157
  const retryID = retry.id
1158
+ if (isUnresolvedSessionTemplate(retryID)) {
1159
+ log.warn(
1160
+ `[pantheon-delegate] retry session.create returned unresolved session template "${retryID}" β€” retry ignored`,
1161
+ )
1162
+ return `[STARTUP FAILED] ${bootstrap.status}: ${bootstrap.reason}; host returned an unresolved retry session template ("${retryID}")`
1163
+ }
1027
1164
  knownChildren.add(retryID)
1028
1165
  options.registerChildSession?.(retryID, ctx.sessionID)
1029
1166
 
@@ -1115,10 +1252,9 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
1115
1252
  args: readArgs,
1116
1253
  execute: async (args, ctx) => {
1117
1254
  guardEnabled()
1118
- const job = board.resolve(ctx.sessionID, args.id)
1119
- if (!job) {
1120
- return `Unknown delegation "${args.id}" for this session. Use pantheon_delegation_list to see active delegations.`
1121
- }
1255
+ const resolution = resolveDelegationTarget(board, ctx.sessionID, args.id)
1256
+ if (resolution.kind === 'error') return resolution.message
1257
+ const job = resolution.job
1122
1258
 
1123
1259
  // Sample the child's messages while waiting so the caller sees what the
1124
1260
  // agent is doing β€” fail-open: no messages support β†’ report as before.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Best-effort salvage of complete top-level JSON objects from a corrupt
3
+ * `state.json`. The file is normally a JSON array; a torn write can leave a
4
+ * truncated tail (or interleaved NUL bytes). We scan for balanced `{…}`
5
+ * objects, parsing each independently, so every intact record survives.
6
+ *
7
+ * Extracted from file-persistence.ts to keep that module within the 300-line
8
+ * ceiling. Exported for tests.
9
+ */
10
+ import type { BackgroundJobRecord } from './background-job-board.ts'
11
+
12
+ export function salvageRecords(content: string): BackgroundJobRecord[] {
13
+ const records: BackgroundJobRecord[] = []
14
+ let depth = 0
15
+ let start = -1
16
+ let inString = false
17
+ let escaped = false
18
+
19
+ for (let i = 0; i < content.length; i++) {
20
+ const ch = content[i]
21
+
22
+ if (inString) {
23
+ if (escaped) {
24
+ escaped = false
25
+ } else if (ch === '\\') {
26
+ escaped = true
27
+ } else if (ch === '"') {
28
+ inString = false
29
+ }
30
+ continue
31
+ }
32
+
33
+ if (ch === '"') {
34
+ inString = true
35
+ continue
36
+ }
37
+ if (ch === '{') {
38
+ if (depth === 0) start = i
39
+ depth++
40
+ continue
41
+ }
42
+ if (ch === '}') {
43
+ if (depth > 0) depth--
44
+ if (depth === 0 && start >= 0) {
45
+ const candidate = content.slice(start, i + 1)
46
+ try {
47
+ const parsed: unknown = JSON.parse(candidate)
48
+ if (
49
+ parsed !== null &&
50
+ typeof parsed === 'object' &&
51
+ typeof (parsed as BackgroundJobRecord).taskID === 'string'
52
+ ) {
53
+ records.push(parsed as BackgroundJobRecord)
54
+ }
55
+ } catch {
56
+ // Incomplete/partial object β€” skip it, keep scanning.
57
+ }
58
+ start = -1
59
+ }
60
+ }
61
+ }
62
+
63
+ return records
64
+ }
@@ -1,91 +1,238 @@
1
1
  /**
2
2
  * File-based persistence adapter for the BackgroundJobBoard.
3
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.
4
+ * Stores all jobs in a single `state.json` file. Writes are crash-safe:
5
+ * the payload is written to a UNIQUE temp file, fsynced, then renamed over
6
+ * the destination. Writes to the same path are serialized through an
7
+ * in-process queue so two logical writers (plugin Γ— native) cannot
8
+ * interleave and corrupt the JSON or race the rename.
9
+ *
10
+ * Reads are corruption-tolerant: a malformed `state.json` is moved aside to
11
+ * `<state>.corrupt-<ts>`, a clear log is emitted, and whatever complete
12
+ * records can be salvaged from the array are returned β€” never a throw, so
13
+ * callers (delete/prune/recover) cannot get stuck in a retry loop.
7
14
  *
8
15
  * @module file-persistence
9
16
  */
10
17
 
11
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
12
- import { dirname } from 'node:path'
18
+ import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises'
19
+ import { dirname, resolve } from 'node:path'
13
20
 
14
21
  import type { BackgroundJobRecord, PersistenceAdapter } from './background-job-board.ts'
22
+ import { salvageRecords } from './file-persistence-salvage.ts'
23
+ import { createPantheonLogger } from './logger.ts'
24
+
25
+ export { salvageRecords } from './file-persistence-salvage.ts'
26
+
27
+ const log = createPantheonLogger({ module: 'FilePersistence' })
15
28
 
16
29
  /** Default path for the state file. */
17
30
  const DEFAULT_STATE_PATH = '.pantheon/board/state.json'
18
31
 
32
+ /** Max rename attempts after an ENOENT before giving up (dir/tmp recreated each try). */
33
+ const RENAME_MAX_ATTEMPTS = 3
34
+
35
+ /** Minimal file handle surface used to fsync the temp file before rename. */
36
+ export interface PersistenceFileHandle {
37
+ writeFile(data: string, encoding: 'utf-8'): Promise<void>
38
+ sync(): Promise<void>
39
+ close(): Promise<void>
40
+ }
41
+
42
+ /**
43
+ * Filesystem operations used by the adapter. Injectable so tests can fault
44
+ * inject a failing `rename` (ENOENT) deterministically.
45
+ */
46
+ export interface PersistenceFsOps {
47
+ mkdir(dir: string): Promise<void>
48
+ readFile(path: string, encoding: 'utf-8'): Promise<string>
49
+ rename(from: string, to: string): Promise<void>
50
+ openForWrite(path: string): Promise<PersistenceFileHandle>
51
+ unlink(path: string): Promise<void>
52
+ }
53
+
54
+ /** Real Node.js filesystem implementation (the default). */
55
+ const defaultFsOps: PersistenceFsOps = {
56
+ mkdir: async (dir) => {
57
+ await mkdir(dir, { recursive: true })
58
+ },
59
+ readFile: (path, encoding) => readFile(path, encoding),
60
+ rename: (from, to) => rename(from, to),
61
+ openForWrite: (path) => open(path, 'w'),
62
+ unlink: (path) => unlink(path),
63
+ }
64
+
65
+ function isEnoent(err: unknown): boolean {
66
+ return err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT'
67
+ }
68
+
69
+ /**
70
+ * In-process write queues keyed by absolute state path. Shared across
71
+ * adapter INSTANCES so a plugin adapter and a native adapter pointing at the
72
+ * same file serialize their read-modify-write cycles.
73
+ */
74
+ const writeQueues = new Map<string, Promise<unknown>>()
75
+
76
+ function enqueueWrite<T>(key: string, task: () => Promise<T>): Promise<T> {
77
+ const previous = writeQueues.get(key) ?? Promise.resolve()
78
+ const run = previous.then(task, task)
79
+ writeQueues.set(
80
+ key,
81
+ run.then(
82
+ () => undefined,
83
+ () => undefined,
84
+ ),
85
+ )
86
+ return run
87
+ }
88
+
19
89
  /**
20
90
  * File-based persistence adapter.
21
91
  *
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. `deleteJob()` removes the
27
- * record from the snapshot so pruned/reconciled jobs don't resurface
28
- * after a restart.
92
+ * All jobs are stored in a single JSON array in `statePath`. Writes are
93
+ * atomic and serialized (unique tmp + fsync + rename, with ENOENT retry).
94
+ * Corrupt state is backed up and salvaged instead of throwing.
29
95
  */
30
96
  export class FilePersistenceAdapter implements PersistenceAdapter {
31
97
  private readonly statePath: string
32
98
  private readonly dir: string
99
+ private readonly ops: PersistenceFsOps
100
+ private readonly lockKey: string
101
+ private tmpCounter = 0
33
102
 
34
103
  /**
35
104
  * @param statePath Path to the state JSON file (default: `.pantheon/board/state.json`)
105
+ * @param ops Filesystem operations (defaults to real Node.js fs; injectable for tests)
36
106
  */
37
- constructor(statePath?: string) {
107
+ constructor(statePath?: string, ops: PersistenceFsOps = defaultFsOps) {
38
108
  this.statePath = statePath ?? DEFAULT_STATE_PATH
39
109
  this.dir = dirname(this.statePath)
110
+ this.ops = ops
111
+ this.lockKey = resolve(this.statePath)
40
112
  }
41
113
 
42
114
  /** Ensure the parent directory exists. */
43
115
  private async ensureDir(): Promise<void> {
44
- await mkdir(this.dir, { recursive: true })
116
+ await this.ops.mkdir(this.dir)
45
117
  }
46
118
 
47
- /** Path to the atomic-write temp file. */
48
- private get tmpPath(): string {
49
- return this.statePath + '.tmp'
119
+ /** Unique temp path per write β€” prevents two writers racing the same tmp. */
120
+ private makeTmpPath(): string {
121
+ this.tmpCounter += 1
122
+ const unique = `${process.pid}.${this.tmpCounter}.${Math.random().toString(36).slice(2, 8)}`
123
+ return `${this.statePath}.${unique}.tmp`
50
124
  }
51
125
 
52
126
  /**
53
127
  * Read all jobs from the state file.
54
- * Returns `null` if the file doesn't exist (first run / no jobs yet).
128
+ *
129
+ * Return contract distinguishes three failure modes:
130
+ * - absent file (ENOENT) β†’ `[]` (first run / no jobs yet);
131
+ * - corrupt content β†’ quarantine + `salvageRecords()` (best-effort recovery);
132
+ * - non-ENOENT IO error (e.g. EIO) β†’ `null`, signalling the read failed.
133
+ *
134
+ * Callers doing read-modify-write MUST abort on `null` so a failed read
135
+ * cannot overwrite the on-disk state with a partial snapshot.
55
136
  */
56
137
  private async readState(): Promise<BackgroundJobRecord[] | null> {
138
+ let content: string
57
139
  try {
58
- const content = await readFile(this.statePath, 'utf-8')
59
- return JSON.parse(content) as BackgroundJobRecord[]
140
+ content = await this.ops.readFile(this.statePath, 'utf-8')
60
141
  } catch (err: unknown) {
61
- if (
62
- err instanceof Error &&
63
- 'code' in err &&
64
- (err as NodeJS.ErrnoException).code === 'ENOENT'
65
- ) {
66
- return null
142
+ if (isEnoent(err)) return []
143
+ log.error(`[FilePersistence] Failed to read ${this.statePath}:`, err)
144
+ return null
145
+ }
146
+
147
+ try {
148
+ const parsed: unknown = JSON.parse(content)
149
+ if (!Array.isArray(parsed)) {
150
+ throw new Error('state.json is not a JSON array')
67
151
  }
68
- throw err
152
+ return parsed as BackgroundJobRecord[]
153
+ } catch (err: unknown) {
154
+ await this.quarantineCorrupt(err)
155
+ const salvaged = salvageRecords(content)
156
+ log.warn(
157
+ `[FilePersistence] Corrupt ${this.statePath} β€” recovered ${salvaged.length} record(s)`,
158
+ )
159
+ return salvaged
160
+ }
161
+ }
162
+
163
+ /** Move a corrupt state.json aside so the next write starts from a clean file. */
164
+ private async quarantineCorrupt(cause: unknown): Promise<void> {
165
+ const backupPath = `${this.statePath}.corrupt-${Date.now()}`
166
+ try {
167
+ await this.ops.rename(this.statePath, backupPath)
168
+ log.error(`[FilePersistence] Corrupt ${this.statePath} backed up to ${backupPath}:`, cause)
169
+ } catch (err: unknown) {
170
+ log.error(
171
+ `[FilePersistence] Could not back up corrupt ${this.statePath} β€” continuing with salvaged state:`,
172
+ err,
173
+ )
69
174
  }
70
175
  }
71
176
 
72
- /** Write the full state array atomically (tmp + rename). */
177
+ /** Write the full state array: unique tmp + fsync + rename (with ENOENT retry). */
73
178
  private async writeState(records: BackgroundJobRecord[]): Promise<void> {
74
- await this.ensureDir()
75
179
  const content = JSON.stringify(records, null, 2)
76
- await writeFile(this.tmpPath, content, 'utf-8')
77
- await rename(this.tmpPath, this.statePath)
180
+ const tmpPath = this.makeTmpPath()
181
+ let lastError: unknown
182
+
183
+ for (let attempt = 1; attempt <= RENAME_MAX_ATTEMPTS; attempt++) {
184
+ try {
185
+ await this.ensureDir()
186
+ const handle = await this.ops.openForWrite(tmpPath)
187
+ try {
188
+ await handle.writeFile(content, 'utf-8')
189
+ await handle.sync()
190
+ } finally {
191
+ await handle.close()
192
+ }
193
+ await this.ops.rename(tmpPath, this.statePath)
194
+ return
195
+ } catch (err: unknown) {
196
+ lastError = err
197
+ if (!isEnoent(err)) break
198
+ log.warn(
199
+ `[FilePersistence] rename ENOENT for ${this.statePath} ` +
200
+ `(attempt ${attempt}/${RENAME_MAX_ATTEMPTS}) β€” recreating dir and retrying`,
201
+ )
202
+ await this.safeUnlink(tmpPath)
203
+ }
204
+ }
205
+
206
+ await this.safeUnlink(tmpPath)
207
+ throw lastError instanceof Error ? lastError : new Error(String(lastError))
78
208
  }
79
209
 
80
- async saveJob(record: BackgroundJobRecord): Promise<void> {
81
- const records = (await this.readState()) ?? []
82
- const idx = records.findIndex((r) => r.taskID === record.taskID)
83
- if (idx >= 0) {
84
- records[idx] = record
85
- } else {
86
- records.push(record)
210
+ private async safeUnlink(path: string): Promise<void> {
211
+ try {
212
+ await this.ops.unlink(path)
213
+ } catch {
214
+ // Best-effort temp cleanup β€” absent file is fine.
87
215
  }
88
- await this.writeState(records)
216
+ }
217
+
218
+ async saveJob(record: BackgroundJobRecord): Promise<void> {
219
+ await enqueueWrite(this.lockKey, async () => {
220
+ const records = await this.readState()
221
+ if (records === null) {
222
+ log.error(
223
+ `[FilePersistence] Aborting saveJob(${record.taskID}): could not read ` +
224
+ `${this.statePath} β€” refusing to overwrite state with a partial snapshot`,
225
+ )
226
+ return
227
+ }
228
+ const idx = records.findIndex((r) => r.taskID === record.taskID)
229
+ if (idx >= 0) {
230
+ records[idx] = record
231
+ } else {
232
+ records.push(record)
233
+ }
234
+ await this.writeState(records)
235
+ })
89
236
  }
90
237
 
91
238
  async loadAllJobs(): Promise<BackgroundJobRecord[]> {
@@ -93,11 +240,13 @@ export class FilePersistenceAdapter implements PersistenceAdapter {
93
240
  }
94
241
 
95
242
  async deleteJob(taskID: string): Promise<void> {
96
- const records = await this.readState()
97
- if (!records) return
98
- const idx = records.findIndex((r) => r.taskID === taskID)
99
- if (idx < 0) return
100
- records.splice(idx, 1)
101
- await this.writeState(records)
243
+ await enqueueWrite(this.lockKey, async () => {
244
+ const records = await this.readState()
245
+ if (!records) return
246
+ const idx = records.findIndex((r) => r.taskID === taskID)
247
+ if (idx < 0) return
248
+ records.splice(idx, 1)
249
+ await this.writeState(records)
250
+ })
102
251
  }
103
252
  }
@@ -164,7 +164,7 @@ async function detectVersion(api) {
164
164
  if (ver) return ver;
165
165
  }
166
166
  } catch {}
167
- return "1.5.0-beta.7";
167
+ return "1.5.0-beta.8";
168
168
  }
169
169
  /**
170
170
  * usage-bar β€” AI subscription usage gauge for the opencode TUI.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pantheon-tui",
3
- "version": "1.5.0-beta.7",
3
+ "version": "1.5.0-beta.8",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pantheon-tui",
9
- "version": "1.5.0-beta.7",
9
+ "version": "1.5.0-beta.8",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@opencode-ai/plugin": ">=1.14.0",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pantheon-tui",
3
3
  "type": "module",
4
- "version": "1.5.0-beta.7",
4
+ "version": "1.5.0-beta.8",
5
5
  "description": "Pantheon TUI plugin β€” sidebar (version, preset, sessions, real-time delegations) + AI subscription usage gauges incl. OpenCode Go/Zen dollar limits (vendored, MIT)",
6
6
  "license": "MIT",
7
7
  "exports": {