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.
- package/.pantheon/code-mode/manifest.json +21 -0
- package/AGENTS.md +14 -63
- package/CHANGELOG.md +1260 -0
- package/README.md +162 -23
- package/README.pt-BR.md +104 -19
- package/bin/pantheon-init.mjs +178 -21
- package/commands/pantheon.md +1 -35
- package/package.json +23 -15
- package/plugin.json +1 -1
- package/pyproject.toml +62 -0
- package/scripts/code_mode_server.py +431 -55
- package/scripts/doctor.mjs +207 -9
- package/scripts/install/cli-ui.mjs +17 -11
- package/scripts/install/config-migration.mjs +4 -6
- package/scripts/install/opencode.mjs +137 -35
- package/scripts/install/plugin.mjs +12 -45
- package/scripts/install/shared.mjs +43 -2
- package/scripts/install/strings.mjs +129 -0
- package/scripts/install-mcp.mjs +1 -0
- package/scripts/manifest-inventory.mjs +410 -0
- package/scripts/mcp_persistence_server.py +536 -164
- package/scripts/package-evidence.mjs +242 -0
- package/scripts/probe-pantheon-cost.mjs +10 -1
- package/scripts/release-beta-version.mjs +56 -60
- package/scripts/sync-artifacts.mjs +169 -0
- package/scripts/sync-tui.mjs +129 -64
- package/scripts/test-opencode-v1-v2-sandbox.sh +79 -161
- package/scripts/validation-policy.mjs +7 -7
- package/scripts/version-check.mjs +113 -108
- package/scripts/versioning.mjs +230 -86
- package/src/agents/apollo.md +1 -0
- package/src/agents/gaia.md +1 -0
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +11 -54
- package/src/instructions/memory-protocol.instructions.md +6 -3
- package/src/instructions/zeus-anti-stall.instructions.md +2 -4
- package/src/instructions/zeus-council-synthesis.instructions.md +3 -28
- package/src/instructions/zeus-timeout-retry.instructions.md +3 -28
- package/src/mcp/code_mode_server.py +431 -55
- package/src/mcp/mcp_persistence_server.py +536 -164
- package/src/mcp/toon_codec.py +2 -4
- package/src/pantheon/background-job-board.ts +47 -5
- package/src/pantheon/{delegation-compaction.ts → compaction-context.ts} +10 -100
- package/src/pantheon/cost-command.ts +309 -44
- package/src/pantheon/delegation-enforce.ts +59 -14
- package/src/pantheon/file-persistence-salvage.ts +64 -0
- package/src/pantheon/file-persistence.ts +194 -45
- package/src/pantheon/goal-loop.ts +1 -1
- package/src/pantheon/goal-store.ts +1 -1
- package/src/pantheon/idle-continuation.ts +4 -6
- package/src/pantheon/model-command.ts +1 -2
- package/src/pantheon/native-task-status.ts +139 -0
- package/src/pantheon/presets.d.mts +0 -20
- package/src/pantheon/presets.mjs +0 -64
- package/src/pantheon/session-guard.ts +2 -2
- package/src/pantheon/session-id.ts +30 -0
- package/src/pantheon/shared-board.ts +37 -0
- package/src/pantheon/task-result-guard.ts +106 -0
- package/src/pantheon/todo-enforcer.ts +3 -3
- package/src/pantheon/tool-ceiling.ts +159 -0
- package/src/pantheon/tool-context.ts +23 -0
- package/src/pantheon/v2-bridge.ts +1 -4
- package/src/plugin-v2.ts +8 -21
- package/src/plugin.ts +83 -251
- package/src/plugins/pantheon-hooks.ts +51 -0
- package/src/plugins/tui/dist/server.d.ts +1 -1
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/server.js +1 -1
- package/src/plugins/tui/dist/server.js.map +1 -1
- package/src/plugins/tui/dist/tui.d.ts +261 -77
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +640 -234
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +2 -2
- package/src/plugins/tui/src/index.tsx +872 -226
- package/src/routing.yml +0 -16
- package/src/skills/context-compression/SKILL.md +1 -1
- package/scripts/generate-preset-docs.mjs +0 -300
- package/scripts/generate-prompts.sh +0 -222
- package/scripts/hash_verify.py +0 -192
- package/scripts/install/component.mjs +0 -24
- package/scripts/install/migrate-config.mjs +0 -302
- package/scripts/test-context-compression.sh +0 -169
- package/src/pantheon/auto-wake.ts +0 -89
- package/src/pantheon/chat-reminders.ts +0 -9
- package/src/pantheon/comment-checker.ts +0 -48
- package/src/pantheon/compaction-assert.ts +0 -160
- package/src/pantheon/cost-tracker.ts +0 -108
- package/src/pantheon/delegate-manager.ts +0 -669
- package/src/pantheon/delegation-classifier.ts +0 -307
- package/src/pantheon/delegation-finalize.ts +0 -374
- package/src/pantheon/delegation-notify.ts +0 -212
- package/src/pantheon/delegation-toast.ts +0 -73
- package/src/pantheon/delegation.ts +0 -1120
- package/src/pantheon/dispatch-guard.ts +0 -109
- package/src/pantheon/permission-eval.ts +0 -284
- package/src/pantheon/preemptive-compact.ts +0 -76
- package/src/pantheon/retry-policy.ts +0 -212
- package/src/pantheon/step-cap.ts +0 -110
- package/src/pantheon/token-opt.ts +0 -781
- package/src/pantheon/toon-codec.ts +0 -481
- package/src/pantheon/v2-events.ts +0 -131
- package/src/pantheon/v2-hooks.ts +0 -287
- package/src/pantheon/v2-tool-definitions.ts +0 -331
- package/src/pantheon/zeus-delegate-with-retry.ts +0 -406
- 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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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,
|
|
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
|
-
*
|
|
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
|
|
116
|
+
await this.ops.mkdir(this.dir)
|
|
45
117
|
}
|
|
46
118
|
|
|
47
|
-
/**
|
|
48
|
-
private
|
|
49
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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 './
|
|
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
|
|
9
|
-
*
|
|
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
|
|
38
|
-
*
|
|
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).
|
package/src/pantheon/presets.mjs
CHANGED
|
@@ -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
|
|
14
|
-
* (dist/tui.
|
|
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
|
+
}
|