pantheon-opencode 1.5.0-beta.2 → 1.5.0-beta.20

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 (107) hide show
  1. package/.pantheon/code-mode/manifest.json +21 -0
  2. package/AGENTS.md +14 -63
  3. package/CHANGELOG.md +1260 -0
  4. package/README.md +162 -23
  5. package/README.pt-BR.md +104 -19
  6. package/bin/pantheon-init.mjs +178 -21
  7. package/commands/pantheon.md +1 -35
  8. package/package.json +23 -15
  9. package/plugin.json +1 -1
  10. package/pyproject.toml +62 -0
  11. package/scripts/code_mode_server.py +431 -55
  12. package/scripts/doctor.mjs +207 -9
  13. package/scripts/install/cli-ui.mjs +17 -11
  14. package/scripts/install/config-migration.mjs +4 -6
  15. package/scripts/install/opencode.mjs +137 -35
  16. package/scripts/install/plugin.mjs +12 -45
  17. package/scripts/install/shared.mjs +43 -2
  18. package/scripts/install/strings.mjs +129 -0
  19. package/scripts/install-mcp.mjs +1 -0
  20. package/scripts/manifest-inventory.mjs +410 -0
  21. package/scripts/mcp_persistence_server.py +536 -164
  22. package/scripts/package-evidence.mjs +242 -0
  23. package/scripts/probe-pantheon-cost.mjs +10 -1
  24. package/scripts/release-beta-version.mjs +56 -60
  25. package/scripts/sync-artifacts.mjs +169 -0
  26. package/scripts/sync-tui.mjs +129 -64
  27. package/scripts/test-opencode-v1-v2-sandbox.sh +79 -161
  28. package/scripts/validation-policy.mjs +7 -7
  29. package/scripts/version-check.mjs +113 -108
  30. package/scripts/versioning.mjs +230 -86
  31. package/src/agents/apollo.md +1 -0
  32. package/src/agents/gaia.md +1 -0
  33. package/src/agents/mnemosyne.md +1 -1
  34. package/src/agents/zeus.md +11 -54
  35. package/src/instructions/memory-protocol.instructions.md +6 -3
  36. package/src/instructions/zeus-anti-stall.instructions.md +2 -4
  37. package/src/instructions/zeus-council-synthesis.instructions.md +3 -28
  38. package/src/instructions/zeus-timeout-retry.instructions.md +3 -28
  39. package/src/mcp/code_mode_server.py +431 -55
  40. package/src/mcp/mcp_persistence_server.py +536 -164
  41. package/src/mcp/toon_codec.py +2 -4
  42. package/src/pantheon/background-job-board.ts +47 -5
  43. package/src/pantheon/{delegation-compaction.ts → compaction-context.ts} +10 -100
  44. package/src/pantheon/cost-command.ts +309 -44
  45. package/src/pantheon/delegation-enforce.ts +59 -14
  46. package/src/pantheon/file-persistence-salvage.ts +64 -0
  47. package/src/pantheon/file-persistence.ts +194 -45
  48. package/src/pantheon/goal-loop.ts +1 -1
  49. package/src/pantheon/goal-store.ts +1 -1
  50. package/src/pantheon/idle-continuation.ts +4 -6
  51. package/src/pantheon/model-command.ts +1 -2
  52. package/src/pantheon/native-task-status.ts +139 -0
  53. package/src/pantheon/presets.d.mts +0 -20
  54. package/src/pantheon/presets.mjs +0 -64
  55. package/src/pantheon/session-guard.ts +2 -2
  56. package/src/pantheon/session-id.ts +30 -0
  57. package/src/pantheon/shared-board.ts +37 -0
  58. package/src/pantheon/task-result-guard.ts +106 -0
  59. package/src/pantheon/todo-enforcer.ts +3 -3
  60. package/src/pantheon/tool-ceiling.ts +159 -0
  61. package/src/pantheon/tool-context.ts +23 -0
  62. package/src/pantheon/v2-bridge.ts +1 -4
  63. package/src/plugin-v2.ts +8 -21
  64. package/src/plugin.ts +83 -251
  65. package/src/plugins/pantheon-hooks.ts +51 -0
  66. package/src/plugins/tui/dist/server.d.ts +1 -1
  67. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  68. package/src/plugins/tui/dist/server.js +1 -1
  69. package/src/plugins/tui/dist/server.js.map +1 -1
  70. package/src/plugins/tui/dist/tui.d.ts +261 -77
  71. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  72. package/src/plugins/tui/dist/tui.js +640 -234
  73. package/src/plugins/tui/dist/tui.js.map +1 -1
  74. package/src/plugins/tui/package-lock.json +2 -2
  75. package/src/plugins/tui/package.json +2 -2
  76. package/src/plugins/tui/src/index.tsx +872 -226
  77. package/src/routing.yml +0 -16
  78. package/src/skills/context-compression/SKILL.md +1 -1
  79. package/scripts/generate-preset-docs.mjs +0 -300
  80. package/scripts/generate-prompts.sh +0 -222
  81. package/scripts/hash_verify.py +0 -192
  82. package/scripts/install/component.mjs +0 -24
  83. package/scripts/install/migrate-config.mjs +0 -302
  84. package/scripts/test-context-compression.sh +0 -169
  85. package/src/pantheon/auto-wake.ts +0 -89
  86. package/src/pantheon/chat-reminders.ts +0 -9
  87. package/src/pantheon/comment-checker.ts +0 -48
  88. package/src/pantheon/compaction-assert.ts +0 -160
  89. package/src/pantheon/cost-tracker.ts +0 -108
  90. package/src/pantheon/delegate-manager.ts +0 -669
  91. package/src/pantheon/delegation-classifier.ts +0 -307
  92. package/src/pantheon/delegation-finalize.ts +0 -374
  93. package/src/pantheon/delegation-notify.ts +0 -212
  94. package/src/pantheon/delegation-toast.ts +0 -73
  95. package/src/pantheon/delegation.ts +0 -1120
  96. package/src/pantheon/dispatch-guard.ts +0 -109
  97. package/src/pantheon/permission-eval.ts +0 -284
  98. package/src/pantheon/preemptive-compact.ts +0 -76
  99. package/src/pantheon/retry-policy.ts +0 -212
  100. package/src/pantheon/step-cap.ts +0 -110
  101. package/src/pantheon/token-opt.ts +0 -781
  102. package/src/pantheon/toon-codec.ts +0 -481
  103. package/src/pantheon/v2-events.ts +0 -131
  104. package/src/pantheon/v2-hooks.ts +0 -287
  105. package/src/pantheon/v2-tool-definitions.ts +0 -331
  106. package/src/pantheon/zeus-delegate-with-retry.ts +0 -406
  107. package/src/plugins/tui/dist/tui.tsx +0 -2605
@@ -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
  }
@@ -17,11 +17,11 @@
17
17
  import { z } from 'zod'
18
18
 
19
19
  import type { BackgroundJobBoard } from './background-job-board.ts'
20
- import type { ToolContextLike } from './delegation.ts'
21
20
  import { GoalStore } from './goal-store.ts'
22
21
  import { createPantheonLogger } from './logger.ts'
23
22
  import { safeSessionPath } from './session-guard.ts'
24
23
  import type { TodoEnforcerMessage } from './todo-enforcer.ts'
24
+ import type { ToolContextLike } from './tool-context.ts'
25
25
 
26
26
  // Silence-by-default TUI policy (pantheon-hooks L42-58): warn → hooks.log,
27
27
  // console echo opt-in via PANTHEON_HOOKS_LOG=1. `deps.logger` stays for tests.
@@ -15,7 +15,7 @@
15
15
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
16
16
  import { join } from 'node:path'
17
17
 
18
- import { assertSafeParentSessionID } from './delegation-finalize.ts'
18
+ import { assertSafeParentSessionID } from './session-id.ts'
19
19
 
20
20
  export type GoalStatus = 'pending' | 'in_progress' | 'done'
21
21
 
@@ -5,9 +5,8 @@
5
5
  * Priority: an ACTIVE GOAL owns the idle — `GoalLoop.onIdle` continues the
6
6
  * goal. Without an active goal, the TODO enforcer gets the idle (it
7
7
  * self-guards on its own enabled flag + guards). Board-child sessions never
8
- * reach the dispatcher — the plugin's event hook routes those to the
9
- * delegation finalize path first (`handleDelegationEvent` returns true) and
10
- * only calls the dispatcher on the `!delegated` branch.
8
+ * reach the dispatcher — the plugin's event hook owns board transitions and
9
+ * only calls the dispatcher for non-board sessions.
11
10
  *
12
11
  * Independently testable — the loop and enforcer are structural interfaces;
13
12
  * the real GoalLoop/TodoEnforcer satisfy them.
@@ -34,9 +33,8 @@ export interface IdleDispatcher {
34
33
  }
35
34
 
36
35
  /**
37
- * Build the idle router. The plugin event hook calls this only on the
38
- * `!delegated` branch of `handleDelegationEvent`, so board children never
39
- * arrive here.
36
+ * Build the idle router. The plugin event hook calls this only for non-board
37
+ * sessions, so board children never arrive here.
40
38
  */
41
39
  export function createIdleDispatcher(deps: {
42
40
  goalLoop: GoalLoopLike
@@ -23,8 +23,6 @@ import { lstat, mkdir, open, rename, unlink } from 'node:fs/promises'
23
23
  import { homedir } from 'node:os'
24
24
  import { basename, dirname, join, resolve } from 'node:path'
25
25
  import { z } from 'zod'
26
-
27
- import type { ToolContextLike } from './delegation.ts'
28
26
  import {
29
27
  capabilityEntry,
30
28
  hasVision,
@@ -32,6 +30,7 @@ import {
32
30
  normalizeCapability,
33
31
  resolveActivePreset,
34
32
  } from './presets.mjs'
33
+ import type { ToolContextLike } from './tool-context.ts'
35
34
 
36
35
  export type ModelScope = 'project' | 'global'
37
36
  export type ModelAction = 'status' | 'show' | 'set' | 'reset'
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Native Task Status — classifies BackgroundJobRecord results from native
3
+ * task() delegation into actionable status codes.
4
+ *
5
+ * Pure module — zero external dependencies. Consumed by the tool-ceiling and
6
+ * cost layers to decide retry/skip/escalate strategy.
7
+ *
8
+ * @module native-task-status
9
+ */
10
+
11
+ import type { BackgroundJobRecord } from './background-job-board.ts'
12
+
13
+ // ─── Types ─────────────────────────────────────────────────────────────
14
+
15
+ /** Actionable status for a native task() delegation result. */
16
+ export type NativeTaskStatus =
17
+ | 'OK'
18
+ | 'UNSUPPORTED'
19
+ | 'UNAVAILABLE'
20
+ | 'INVALID_INPUT'
21
+ | 'INVALID_STATE'
22
+ | 'CONFLICT'
23
+ | 'CORRUPT_DATA'
24
+ | 'TIMEOUT'
25
+ | 'ESCALATE'
26
+
27
+ // ─── Helpers ───────────────────────────────────────────────────────────
28
+
29
+ /** Check if a string contains any of the given substrings (case-insensitive). */
30
+ function containsAny(text: string | undefined, patterns: readonly string[]): boolean {
31
+ if (text == null) return false
32
+ const lower = text.toLowerCase()
33
+ return patterns.some((p) => lower.includes(p.toLowerCase()))
34
+ }
35
+
36
+ /** Check if a job's output is empty or whitespace-only. */
37
+ function isOutputEmpty(job: BackgroundJobRecord): boolean {
38
+ const summary = job.resultSummary
39
+ if (summary == null) return true
40
+ if (typeof summary !== 'string') return true
41
+ return summary.trim() === ''
42
+ }
43
+
44
+ // ─── Implementation ────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Classify a BackgroundJobRecord result into a NativeTaskStatus.
48
+ *
49
+ * Priority order (first match wins):
50
+ * 1. verifyError and lastStatusError patterns → UNSUPPORTED, CONFLICT,
51
+ * INVALID_INPUT, INVALID_STATE, CORRUPT_DATA, ESCALATE
52
+ * 2. state === startup_failed or timedOut → TIMEOUT
53
+ * 3. state === completed + non-empty output → OK
54
+ * 4. state === completed + empty output → UNAVAILABLE (silent failure / admission rejection)
55
+ * 5. state === error → UNAVAILABLE (safe default for provider/infra errors)
56
+ * 6. Fallback → UNAVAILABLE
57
+ */
58
+ export function classifyNativeResult(
59
+ job: BackgroundJobRecord,
60
+ verifyError?: string,
61
+ ): NativeTaskStatus {
62
+ const statusError = [verifyError, job.lastStatusError]
63
+ .filter((value): value is string => value != null && value.length > 0)
64
+ .join(' ')
65
+
66
+ // A missing native task API is terminal and must not be retried or sent
67
+ // through a fallback model. Provider/transport failures remain unavailable.
68
+ if (
69
+ containsAny(statusError, [
70
+ 'api unavailable',
71
+ 'api not available',
72
+ 'api missing',
73
+ 'method not found',
74
+ 'unknown method',
75
+ 'not supported',
76
+ 'unsupported',
77
+ 'does not support',
78
+ 'task() is not available',
79
+ ])
80
+ ) {
81
+ return 'UNSUPPORTED'
82
+ }
83
+
84
+ // ── Phase 1: verifyError explicit signals (highest priority) ───────
85
+
86
+ if (verifyError != null && verifyError.length > 0) {
87
+ // CONFLICT: canDispatch=false or concurrency exceeded
88
+ if (containsAny(verifyError, ['canDispatch', 'concurrency', 'conflict'])) {
89
+ return 'CONFLICT'
90
+ }
91
+
92
+ // INVALID_INPUT: agent not recognized / invalid
93
+ if (containsAny(verifyError, ['invalid agent', 'agent not found', 'unknown agent'])) {
94
+ return 'INVALID_INPUT'
95
+ }
96
+
97
+ // INVALID_STATE: child already in terminal state
98
+ if (containsAny(verifyError, ['already terminal', 'already in terminal', 'invalid state'])) {
99
+ return 'INVALID_STATE'
100
+ }
101
+
102
+ // CORRUPT_DATA: output can't be parsed / corrupted
103
+ if (containsAny(verifyError, ['corrupt', 'parse failed', 'malformed', 'invalid format'])) {
104
+ return 'CORRUPT_DATA'
105
+ }
106
+
107
+ // ESCALATE: unrecoverable failure
108
+ if (containsAny(verifyError, ['unrecoverable', 'fatal', 'max retries exceeded'])) {
109
+ return 'ESCALATE'
110
+ }
111
+ }
112
+
113
+ // ── Phase 2: state-based classification ────────────────────────────
114
+
115
+ // TIMEOUT: startup failure or timed out
116
+ if (job.state === 'startup_failed' || job.state === 'startup_unknown' || job.timedOut) {
117
+ return 'TIMEOUT'
118
+ }
119
+
120
+ // ── Phase 3: output-based classification ───────────────────────────
121
+
122
+ if (job.state === 'completed') {
123
+ if (!isOutputEmpty(job)) {
124
+ return 'OK'
125
+ }
126
+ // completed but empty → silent failure (BackendAdmissionRejected pattern)
127
+ return 'UNAVAILABLE'
128
+ }
129
+
130
+ // ── Phase 4: error state fallback ──────────────────────────────────
131
+
132
+ if (job.state === 'error') {
133
+ return 'UNAVAILABLE'
134
+ }
135
+
136
+ // ── Fallback ───────────────────────────────────────────────────────
137
+
138
+ return 'UNAVAILABLE'
139
+ }
@@ -104,26 +104,6 @@ export interface RoutingRetryPolicyOptions {
104
104
  logger?: { warn?: (msg: string) => void }
105
105
  }
106
106
 
107
- /**
108
- * R1: per-error-type retry policy from routing.yml `retry_policy`
109
- * (auth/rate_limit/timeout/other → max retries). Fail-open → null.
110
- */
111
- export declare function loadRoutingRetryPolicy(
112
- options?: RoutingRetryPolicyOptions,
113
- ): Record<string, number> | null
114
-
115
- export interface RoutingCooldownResult {
116
- allowed_fails: number
117
- cooldown_time_seconds: number
118
- }
119
-
120
- /**
121
- * R1: provider cooldown config from routing.yml `cooldown`. Fail-open → null.
122
- */
123
- export declare function loadRoutingCooldown(
124
- options?: RoutingRetryPolicyOptions,
125
- ): RoutingCooldownResult | null
126
-
127
107
  /**
128
108
  * R4: per-agent step caps from routing.yml `agents.<name>.max_steps`.
129
109
  * Fail-open → {} (no agent capped).
@@ -213,70 +213,6 @@ function loadRoutingYaml(routingPath) {
213
213
  return routing && typeof routing === 'object' ? routing : null
214
214
  }
215
215
 
216
- const RETRY_ERROR_TYPES = new Set(['auth', 'rate_limit', 'timeout', 'other'])
217
-
218
- /**
219
- * Load the R1 per-error-type retry policy from routing.yml (`retry_policy:`).
220
- * Fail-open: missing/unparseable config yields null (the caller falls back
221
- * to DEFAULT_RETRY_POLICY) and warns via the optional logger.
222
- *
223
- * @param {object} [opts]
224
- * @param {string} [opts.routingPath]
225
- * @param {{warn?: Function}} [opts.logger]
226
- * @returns {Record<string, number>|null} error-type → max retries
227
- */
228
- export function loadRoutingRetryPolicy({ routingPath, logger = console } = {}) {
229
- try {
230
- const routing = loadRoutingYaml(routingPath)
231
- const policy = routing?.retry_policy
232
- if (!policy || typeof policy !== 'object' || Array.isArray(policy)) return null
233
- const out = {}
234
- for (const [type, max] of Object.entries(policy)) {
235
- if (RETRY_ERROR_TYPES.has(type) && Number.isSafeInteger(max) && max >= 0) {
236
- out[type] = max
237
- }
238
- }
239
- return Object.keys(out).length > 0 ? out : null
240
- } catch (err) {
241
- const reason = err instanceof Error ? err.message : String(err)
242
- logger.warn?.(`presets: routing.yml retry_policy unavailable (${reason})`)
243
- return null
244
- }
245
- }
246
-
247
- /**
248
- * Load the R1 provider cooldown config from routing.yml (`cooldown:`).
249
- * Fail-open: missing/unparseable config yields null (caller falls back to
250
- * DEFAULT_COOLDOWN).
251
- *
252
- * @param {object} [opts]
253
- * @param {string} [opts.routingPath]
254
- * @param {{warn?: Function}} [opts.logger]
255
- * @returns {{allowed_fails: number, cooldown_time_seconds: number}|null}
256
- */
257
- export function loadRoutingCooldown({ routingPath, logger = console } = {}) {
258
- try {
259
- const routing = loadRoutingYaml(routingPath)
260
- const cooldown = routing?.cooldown
261
- if (!cooldown || typeof cooldown !== 'object' || Array.isArray(cooldown)) return null
262
- const allowedFails = cooldown.allowed_fails
263
- const cooldownTimeSeconds = cooldown.cooldown_time_seconds
264
- if (
265
- !Number.isSafeInteger(allowedFails) ||
266
- allowedFails < 1 ||
267
- !Number.isSafeInteger(cooldownTimeSeconds) ||
268
- cooldownTimeSeconds < 1
269
- ) {
270
- return null
271
- }
272
- return { allowed_fails: allowedFails, cooldown_time_seconds: cooldownTimeSeconds }
273
- } catch (err) {
274
- const reason = err instanceof Error ? err.message : String(err)
275
- logger.warn?.(`presets: routing.yml cooldown unavailable (${reason})`)
276
- return null
277
- }
278
- }
279
-
280
216
  /**
281
217
  * Load the R4 per-agent step caps from routing.yml
282
218
  * (`agents.<name>.max_steps`). Fail-open: missing config yields {}.
@@ -10,8 +10,8 @@
10
10
  * never throw, never send the placeholder).
11
11
  *
12
12
  * Mirrors the TUI's `src/plugins/tui/src/index.tsx` contract verbatim so both
13
- * sides agree on what "valid" means. The TUI file remains self-contained
14
- * (dist/tui.tsx is a raw copy, no relative imports), so its local copy is kept
13
+ * sides agree on what "valid" means. The TUI plugin ships as a bundle
14
+ * (dist/tui.js) without the src/pantheon tree, so its local copy is kept
15
15
  * in sync — this module is the shared source for `src/pantheon/*` and
16
16
  * `src/plugin.ts`.
17
17
  *
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Session ID safety — reject session IDs that could escape a per-session
3
+ * output directory.
4
+ *
5
+ * Shared helper for the file-backed stores that remain after the delegate
6
+ * removal (goal-store and friends) — it no longer lives alongside the
7
+ * deleted delegation module.
8
+ *
9
+ * `sessionID` is embedded in a path
10
+ * `<dir>/<sessionID>/file.json` — `path.join` does NOT strip `..`, so a
11
+ * traversing ID would write outside the sandbox. Call this BEFORE any path
12
+ * construction.
13
+ *
14
+ * @module session-id
15
+ */
16
+
17
+ /**
18
+ * Reject session IDs that could escape the target directory.
19
+ *
20
+ * @throws {Error} When the ID contains `..`, `/`, or `\`.
21
+ */
22
+ export function assertSafeParentSessionID(parentSessionID: string): void {
23
+ if (
24
+ parentSessionID.includes('..') ||
25
+ parentSessionID.includes('/') ||
26
+ parentSessionID.includes('\\')
27
+ ) {
28
+ throw new Error(`Invalid parentSessionID: ${parentSessionID}`)
29
+ }
30
+ }