pantheon-opencode 1.5.0-beta.6 → 1.5.0-beta.7
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 +45 -0
- package/README.md +31 -10
- package/package.json +4 -3
- package/plugin.json +1 -1
- package/pyproject.toml +10 -2
- package/scripts/mcp_persistence_server.py +24 -5
- package/scripts/versioning.mjs +108 -7
- package/src/agents/mnemosyne.md +1 -1
- package/src/mcp/mcp_persistence_server.py +24 -5
- package/src/pantheon/background-job-board.ts +23 -0
- package/src/pantheon/cost-command.ts +249 -3
- package/src/pantheon/delegation-finalize.ts +32 -0
- package/src/pantheon/delegation.ts +95 -14
- package/src/pantheon/v2-events.ts +14 -6
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,51 @@ 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.7] - 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
|
+
## 🆕 What's New
|
|
27
|
+
|
|
28
|
+
- **Cost CLI fallback + `PANTHEON_NODE`:** `pantheon_cost` falls back to a
|
|
29
|
+
read-only `node scripts/cost.mjs` subprocess when `node:sqlite` is
|
|
30
|
+
unavailable in the host runtime (for example under Bun). The fallback
|
|
31
|
+
resolves a real Node.js binary — `PANTHEON_NODE` first, then `node` on
|
|
32
|
+
`PATH` — probes it for `node:sqlite` support (Node.js >= 22.5), and runs it
|
|
33
|
+
with an argument array, never a shell. Previously the tool returned
|
|
34
|
+
`status: UNSUPPORTED` with no fallback.
|
|
35
|
+
- **Delegation engine contract (ADR-0011):** legacy `pantheon_delegate` remains
|
|
36
|
+
the default engine through v1.5.x; native delegation is opt-in and
|
|
37
|
+
experimental via `PANTHEON_DELEGATE_MODE=native` until v1.6. See
|
|
38
|
+
[ADR-0011](https://github.com/ils15/pantheon-opencode/pull/126) (Proposed).
|
|
39
|
+
|
|
40
|
+
## 🐞 Fixed
|
|
41
|
+
|
|
42
|
+
- **Legacy read-only bypass on retry (P0):** a retried delegation now
|
|
43
|
+
re-applies read-only enforcement (the same rule as the initial child) and
|
|
44
|
+
registers the retry session, closing the bypass where a retried child could
|
|
45
|
+
write despite `read_only: true` or being a read-only agent.
|
|
46
|
+
- **Kill-switch scope (P0):** `PANTHEON_DELEGATION=off` now also disables the
|
|
47
|
+
legacy engine, not only the native manager — the legacy delegate/read/list
|
|
48
|
+
tools throw as documented.
|
|
49
|
+
- **Delegation signal leak (P1):** the legacy finalize path now deletes the
|
|
50
|
+
auto-wake signal file, so terminal delegations no longer leave stale
|
|
51
|
+
`.signal.json` artifacts behind.
|
|
52
|
+
- **Crash recovery is durable (P1):** `recoverRunningJobs` persists the
|
|
53
|
+
`error` state, marks the job `terminalUnreconciled`, and emits the terminal
|
|
54
|
+
signal plus notification for every reaped job instead of updating memory
|
|
55
|
+
only.
|
|
56
|
+
- **Atomic concurrency (P1):** `registerLaunch` validates
|
|
57
|
+
`maxConcurrentPerAgent` before any mutation, so concurrent dispatches cannot
|
|
58
|
+
both claim the last slot. A refused launch tears down the orphan child
|
|
59
|
+
session (read-only unregister plus `session.delete`) and the retry path
|
|
60
|
+
re-validates the same limit.
|
|
61
|
+
- **Legacy board hygiene (P1):** the legacy finalize path now runs
|
|
62
|
+
`pruneCompleted` and `enforceEntryCap`, matching the native manager and
|
|
63
|
+
preventing unbounded board growth.
|
|
64
|
+
- **Resilient V2 events:** a native child refused by the per-agent limit is
|
|
65
|
+
logged and skipped instead of aborting the rest of the event handler.
|
|
21
66
|
## [v1.5.0-beta.6] - 2026-09-12
|
|
22
67
|
|
|
23
68
|
<!-- Add new changes here. Running `node scripts/versioning.mjs apply` will
|
package/README.md
CHANGED
|
@@ -76,22 +76,43 @@ availability and configuration of OpenCode and any optional services you choose
|
|
|
76
76
|
to use. Check the [releases](https://github.com/ils15/pantheon-opencode/releases)
|
|
77
77
|
and [changelog](CHANGELOG.md) for the latest published changes.
|
|
78
78
|
|
|
79
|
-
##
|
|
79
|
+
## Delegation engines (legacy default, native opt-in)
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
Pantheon exposes two delegation engines behind one tool surface:
|
|
82
|
+
|
|
83
|
+
- **Legacy (default)** — `pantheon_delegate`, the original engine registered by
|
|
84
|
+
the V1 plugin. No configuration is required.
|
|
85
|
+
- **Native (opt-in, experimental)** — OpenCode's native `task()` child-session
|
|
86
|
+
engine, selected with `PANTHEON_DELEGATE_MODE=native`. Native is strict: it
|
|
87
|
+
does not use foreground fallback, model failover, or retry; it performs a
|
|
88
|
+
lazy capability probe on the first dispatch and includes the resulting
|
|
89
|
+
status in the delegation receipt.
|
|
90
|
+
|
|
91
|
+
Legacy stays the default for v1.5.x; native is not promoted to default until
|
|
92
|
+
v1.6. See [ADR-0011](.pantheon/memory-bank/adr/0011-delegation-engine-contract.md)
|
|
93
|
+
(Proposed) for the engine contract, rationale, and migration prerequisites.
|
|
94
|
+
|
|
95
|
+
**Kill-switch:** set `PANTHEON_DELEGATION=off` (case-insensitive) to disable
|
|
96
|
+
delegation entirely. The switch covers **both** engines — the legacy
|
|
97
|
+
`pantheon_delegate`/read/list tools throw, and the native manager throws too.
|
|
85
98
|
|
|
86
99
|
## Cost tool backend
|
|
87
100
|
|
|
88
|
-
`pantheon_cost`
|
|
89
|
-
`opencode.db`.
|
|
90
|
-
|
|
91
|
-
a
|
|
92
|
-
`
|
|
101
|
+
`pantheon_cost` prefers a read-only `node:sqlite` backend against the selected
|
|
102
|
+
`opencode.db`. When `node:sqlite` is unavailable in the host runtime (for
|
|
103
|
+
example under Bun), the tool falls back to spawning `node scripts/cost.mjs` as
|
|
104
|
+
a read-only subprocess. The fallback resolves a real Node.js binary —
|
|
105
|
+
`PANTHEON_NODE` first, then `node` on `PATH` — verifies it supports
|
|
106
|
+
`node:sqlite` (Node.js >= 22.5), and runs the script with an argument array,
|
|
107
|
+
never a shell. If neither source resolves, the tool returns `status:
|
|
108
|
+
UNSUPPORTED`. Database failures return a contract status such as `UNAVAILABLE`
|
|
109
|
+
or `CORRUPT_DATA` and include diagnostic detail, including captured stderr when
|
|
93
110
|
available.
|
|
94
111
|
|
|
112
|
+
| Variable | Default | Purpose |
|
|
113
|
+
|----------|---------|---------|
|
|
114
|
+
| `PANTHEON_NODE` | `node` from `PATH` | Path to a Node.js >= 22.5 binary used by the CLI fallback; an explicit path must exist and be executable, otherwise the tool fails fast instead of ignoring the override |
|
|
115
|
+
|
|
95
116
|
|
|
96
117
|
## Code-mode execution (explicit opt-in)
|
|
97
118
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pantheon-opencode",
|
|
3
|
-
"version": "1.5.0-beta.
|
|
3
|
+
"version": "1.5.0-beta.7",
|
|
4
4
|
"description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -41,8 +41,9 @@
|
|
|
41
41
|
"test:security": "npm run secret-scan",
|
|
42
42
|
"test:node": "node --test tests/*.mjs tests/pantheon/*.mjs",
|
|
43
43
|
"test:ts": "for f in tests/pantheon/*.test.ts; do if [ \"$f\" = tests/pantheon/plugin-v2-contract.test.ts ]; then NODE_OPTIONS=--conditions=import tsx \"$f\"; else tsx \"$f\"; fi || exit 1; done",
|
|
44
|
-
"test": "
|
|
45
|
-
"test
|
|
44
|
+
"test:all": "npm run test:ci && npm run test:node && npm run test:ts",
|
|
45
|
+
"test": "npm run test:all",
|
|
46
|
+
"test:ci": "python3 -m pytest -q --tb=short 2>&1"
|
|
46
47
|
},
|
|
47
48
|
"bin": {
|
|
48
49
|
"pantheon-opencode": "bin/pantheon-init.mjs"
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pantheon",
|
|
3
3
|
"publisher": "ils15",
|
|
4
|
-
"version": "1.5.0-beta.
|
|
4
|
+
"version": "1.5.0-beta.7",
|
|
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,13 +4,21 @@ build-backend = "setuptools.backends._legacy:_Backend"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pantheon"
|
|
7
|
-
version = "1.5.0-beta.
|
|
7
|
+
version = "1.5.0-beta.7"
|
|
8
8
|
description = "Multi-agent orchestration framework"
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
10
|
dependencies = []
|
|
11
11
|
|
|
12
12
|
[tool.pytest.ini_options]
|
|
13
|
-
|
|
13
|
+
# `tests/` holds the main suite; the two entries below are orphaned test
|
|
14
|
+
# modules that live next to the code they cover and are collected here so
|
|
15
|
+
# pytest (and therefore `npm test`) exercises them instead of letting them
|
|
16
|
+
# rot as dead code.
|
|
17
|
+
testpaths = [
|
|
18
|
+
"tests",
|
|
19
|
+
"scripts/vector_memory/test_memory.py",
|
|
20
|
+
"benchmarks/beta2/test_beta2.py",
|
|
21
|
+
]
|
|
14
22
|
asyncio_mode = "auto"
|
|
15
23
|
addopts = "-v --tb=short"
|
|
16
24
|
|
|
@@ -777,13 +777,32 @@ def _validate_heartbeat(heartbeat: object) -> None:
|
|
|
777
777
|
raise ValueError("heartbeat.turn_count must be a non-negative integer")
|
|
778
778
|
|
|
779
779
|
|
|
780
|
+
def _validate_version_metadata(version: object) -> None:
|
|
781
|
+
"""Validate optional checkpoint ``version`` metadata leniently.
|
|
782
|
+
|
|
783
|
+
``version`` is opaque application metadata (rehydrate does not consume it),
|
|
784
|
+
not a persistence requirement. It may be a positive integer, a JSON float
|
|
785
|
+
such as ``1.0``, or a version label like ``"1.5.0-beta.2"``. Absent/null
|
|
786
|
+
versions and any non-empty string remain valid, preserving existing integer
|
|
787
|
+
checkpoints while allowing forward-compatible labels. Only bools, empty
|
|
788
|
+
strings, non-finite/zero/negative numbers, and structured values are
|
|
789
|
+
rejected.
|
|
790
|
+
"""
|
|
791
|
+
if version is None:
|
|
792
|
+
return
|
|
793
|
+
if isinstance(version, bool):
|
|
794
|
+
raise ValueError("version must be a positive integer or version label")
|
|
795
|
+
if isinstance(version, (int, float)):
|
|
796
|
+
if not 1 <= version < float("inf"):
|
|
797
|
+
raise ValueError("version must be a positive integer or version label")
|
|
798
|
+
return
|
|
799
|
+
if not isinstance(version, str) or not version.strip():
|
|
800
|
+
raise ValueError("version must be a positive integer or version label")
|
|
801
|
+
|
|
802
|
+
|
|
780
803
|
def _validate_checkpoint_shape(checkpoint: dict) -> None:
|
|
781
804
|
"""Validate bounded structured checkpoint fields without sanitizing them."""
|
|
782
|
-
|
|
783
|
-
if version is not None and (
|
|
784
|
-
isinstance(version, bool) or not isinstance(version, int) or version < 1
|
|
785
|
-
):
|
|
786
|
-
raise ValueError("version must be a positive integer")
|
|
805
|
+
_validate_version_metadata(checkpoint.get("version"))
|
|
787
806
|
validators = (
|
|
788
807
|
("goal", _validate_goal),
|
|
789
808
|
("phase", _validate_phase),
|
package/scripts/versioning.mjs
CHANGED
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { execFileSync } from 'node:child_process'
|
|
24
|
-
import { readFileSync } from 'node:fs'
|
|
25
|
-
import { dirname, join } from 'node:path'
|
|
24
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
25
|
+
import { basename, dirname, join } from 'node:path'
|
|
26
26
|
import { fileURLToPath } from 'node:url'
|
|
27
27
|
import {
|
|
28
28
|
commitPreparedUpdates,
|
|
@@ -37,6 +37,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
|
37
37
|
const ROOT = join(__dirname, '..')
|
|
38
38
|
|
|
39
39
|
const CHANGELOG_PATH = join(ROOT, 'CHANGELOG.md')
|
|
40
|
+
const ZENODO_PATH = join(ROOT, '.zenodo.json')
|
|
41
|
+
const CITATION_PATH = join(ROOT, 'CITATION.cff')
|
|
40
42
|
|
|
41
43
|
// ---------------------------------------------------------------------------
|
|
42
44
|
// Git helpers
|
|
@@ -111,6 +113,90 @@ function prepareManifests(newVersion) {
|
|
|
111
113
|
return { inventory, updates: prepareInventoryVersion(inventory, newVersion) }
|
|
112
114
|
}
|
|
113
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Serialize `.zenodo.json` the way Biome 2.x formats it: objects stay expanded
|
|
118
|
+
* (matching `JSON.stringify(value, null, 2)`), while arrays containing only
|
|
119
|
+
* primitives are collapsed onto one line when they fit within the line width.
|
|
120
|
+
* A plain `JSON.stringify` emits the `keywords` array multiline, which Biome
|
|
121
|
+
* collapses — reintroducing a lint failure on the next release bump.
|
|
122
|
+
*
|
|
123
|
+
* @param {unknown} value parsed JSON document
|
|
124
|
+
* @param {number} [lineWidth] formatter line width (matches biome.json)
|
|
125
|
+
* @returns {string} Biome-compatible JSON with a trailing newline
|
|
126
|
+
*/
|
|
127
|
+
function formatZenodoJson(value, lineWidth = 100) {
|
|
128
|
+
const indent = (depth) => ' '.repeat(depth)
|
|
129
|
+
const render = (node, depth, column) => {
|
|
130
|
+
if (Array.isArray(node)) {
|
|
131
|
+
if (node.length === 0) return '[]'
|
|
132
|
+
const primitiveOnly = node.every((item) => item === null || typeof item !== 'object')
|
|
133
|
+
if (primitiveOnly) {
|
|
134
|
+
const inline = `[${node.map((item) => JSON.stringify(item)).join(', ')}]`
|
|
135
|
+
if (column + inline.length <= lineWidth) return inline
|
|
136
|
+
}
|
|
137
|
+
const body = node
|
|
138
|
+
.map((item) => `${indent(depth + 1)}${render(item, depth + 1, indent(depth + 1).length)}`)
|
|
139
|
+
.join(',\n')
|
|
140
|
+
return `[\n${body}\n${indent(depth)}]`
|
|
141
|
+
}
|
|
142
|
+
if (node !== null && typeof node === 'object') {
|
|
143
|
+
const entries = Object.entries(node)
|
|
144
|
+
if (entries.length === 0) return '{}'
|
|
145
|
+
const body = entries
|
|
146
|
+
.map(([key, item]) => {
|
|
147
|
+
const keyPrefix = `${indent(depth + 1)}${JSON.stringify(key)}: `
|
|
148
|
+
return `${keyPrefix}${render(item, depth + 1, keyPrefix.length)}`
|
|
149
|
+
})
|
|
150
|
+
.join(',\n')
|
|
151
|
+
return `{\n${body}\n${indent(depth)}}`
|
|
152
|
+
}
|
|
153
|
+
return JSON.stringify(node)
|
|
154
|
+
}
|
|
155
|
+
return `${render(value, 0, 0)}\n`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Keep the Zenodo deposition metadata (.zenodo.json) and the citation file
|
|
160
|
+
* (CITATION.cff) aligned with the version and date being released. Zenodo's
|
|
161
|
+
* release validator hard-fails when CITATION.cff version differs from the
|
|
162
|
+
* release tag, so these must be bumped together with the manifests.
|
|
163
|
+
*/
|
|
164
|
+
function prepareReleaseMetadata(newVersion, dateStr) {
|
|
165
|
+
if (!SEMVER_PATTERN.test(newVersion)) throw new Error(`invalid version: ${newVersion}`)
|
|
166
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) throw new Error(`invalid date: ${dateStr}`)
|
|
167
|
+
const tagUrl = `https://github.com/ils15/pantheon-opencode/releases/tag/v${newVersion}`
|
|
168
|
+
const updates = []
|
|
169
|
+
|
|
170
|
+
if (existsSync(ZENODO_PATH)) {
|
|
171
|
+
const zenodo = JSON.parse(readFileSync(ZENODO_PATH, 'utf8'))
|
|
172
|
+
const zenodoPrevious = typeof zenodo.version === 'string' ? zenodo.version : null
|
|
173
|
+
if (zenodoPrevious && zenodoPrevious !== newVersion && typeof zenodo.description === 'string') {
|
|
174
|
+
zenodo.description = zenodo.description.replaceAll(zenodoPrevious, newVersion)
|
|
175
|
+
}
|
|
176
|
+
zenodo.version = newVersion
|
|
177
|
+
zenodo.publication_date = dateStr
|
|
178
|
+
zenodo.url = tagUrl
|
|
179
|
+
updates.push({ path: ZENODO_PATH, content: formatZenodoJson(zenodo) })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (existsSync(CITATION_PATH)) {
|
|
183
|
+
const citationRaw = readFileSync(CITATION_PATH, 'utf8')
|
|
184
|
+
const citationMatch = /^version:\s*["']?([^"'\s]+)["']?\s*$/m.exec(citationRaw)
|
|
185
|
+
const citationPrevious = citationMatch ? citationMatch[1] : null
|
|
186
|
+
let citation = citationRaw
|
|
187
|
+
if (citationPrevious && citationPrevious !== newVersion) {
|
|
188
|
+
citation = citation.replaceAll(`Pantheon v${citationPrevious}`, `Pantheon v${newVersion}`)
|
|
189
|
+
}
|
|
190
|
+
citation = citation
|
|
191
|
+
.replace(/^version:.*$/m, `version: ${newVersion}`)
|
|
192
|
+
.replace(/^date-released:.*$/m, `date-released: ${dateStr}`)
|
|
193
|
+
.replace(/^url:.*$/m, `url: ${tagUrl}`)
|
|
194
|
+
updates.push({ path: CITATION_PATH, content: citation })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return updates
|
|
198
|
+
}
|
|
199
|
+
|
|
114
200
|
// ---------------------------------------------------------------------------
|
|
115
201
|
// CHANGELOG updater
|
|
116
202
|
//
|
|
@@ -243,8 +329,8 @@ function contentHasUnreleased() {
|
|
|
243
329
|
return readFileSync(CHANGELOG_PATH, 'utf8').includes('## [Unreleased]')
|
|
244
330
|
}
|
|
245
331
|
|
|
246
|
-
function commitPreparedRelease(manifestPlan, changelogPlan) {
|
|
247
|
-
const updates = [...manifestPlan.updates]
|
|
332
|
+
function commitPreparedRelease(manifestPlan, changelogPlan, metadataUpdates = []) {
|
|
333
|
+
const updates = [...manifestPlan.updates, ...metadataUpdates]
|
|
248
334
|
if (changelogPlan.changed) {
|
|
249
335
|
updates.push({ path: CHANGELOG_PATH, content: changelogPlan.content })
|
|
250
336
|
}
|
|
@@ -252,6 +338,9 @@ function commitPreparedRelease(manifestPlan, changelogPlan) {
|
|
|
252
338
|
for (const document of manifestPlan.inventory.documents) {
|
|
253
339
|
console.log(` ✓ ${document.entry.file} → ${manifestPlan.version}`)
|
|
254
340
|
}
|
|
341
|
+
for (const { path } of metadataUpdates) {
|
|
342
|
+
console.log(` ✓ ${basename(path)} → ${manifestPlan.version}`)
|
|
343
|
+
}
|
|
255
344
|
if (changelogPlan.changed) {
|
|
256
345
|
console.log(
|
|
257
346
|
changelogPlan.notesGenerated
|
|
@@ -314,7 +403,11 @@ switch (command) {
|
|
|
314
403
|
console.log(`Bumping ${current} → ${newVersion} (beta)`)
|
|
315
404
|
const changelogPlan = prepareUnreleased(newVersion, date)
|
|
316
405
|
const manifestPlan = prepareManifests(newVersion)
|
|
317
|
-
commitPreparedRelease(
|
|
406
|
+
commitPreparedRelease(
|
|
407
|
+
{ ...manifestPlan, version: newVersion },
|
|
408
|
+
changelogPlan,
|
|
409
|
+
prepareReleaseMetadata(newVersion, date),
|
|
410
|
+
)
|
|
318
411
|
console.log(`\nDone. Commit the version inventory and CHANGELOG as v${newVersion}.`)
|
|
319
412
|
console.log(`Tag v${newVersion} will be created by the release workflow after merge to main.`)
|
|
320
413
|
break
|
|
@@ -347,7 +440,11 @@ switch (command) {
|
|
|
347
440
|
const notesBody = generateNotes()
|
|
348
441
|
const changelogPlan = prepareUnreleased(current, date, notesBody)
|
|
349
442
|
const manifestPlan = prepareManifests(current)
|
|
350
|
-
commitPreparedRelease(
|
|
443
|
+
commitPreparedRelease(
|
|
444
|
+
{ ...manifestPlan, version: current },
|
|
445
|
+
changelogPlan,
|
|
446
|
+
prepareReleaseMetadata(current, date),
|
|
447
|
+
)
|
|
351
448
|
console.log(`Tag v${current} will be created by the release workflow after merge to main.`)
|
|
352
449
|
break
|
|
353
450
|
}
|
|
@@ -360,7 +457,11 @@ switch (command) {
|
|
|
360
457
|
const notesBody = generateNotes()
|
|
361
458
|
const changelogPlan = prepareUnreleased(newVersion, date, notesBody)
|
|
362
459
|
const manifestPlan = prepareManifests(newVersion)
|
|
363
|
-
commitPreparedRelease(
|
|
460
|
+
commitPreparedRelease(
|
|
461
|
+
{ ...manifestPlan, version: newVersion },
|
|
462
|
+
changelogPlan,
|
|
463
|
+
prepareReleaseMetadata(newVersion, date),
|
|
464
|
+
)
|
|
364
465
|
console.log(`\nDone. Commit the version inventory and CHANGELOG as v${newVersion}.`)
|
|
365
466
|
console.log(`Tag v${newVersion} will be created by the release workflow after merge to main.`)
|
|
366
467
|
break
|
package/src/agents/mnemosyne.md
CHANGED
|
@@ -777,13 +777,32 @@ def _validate_heartbeat(heartbeat: object) -> None:
|
|
|
777
777
|
raise ValueError("heartbeat.turn_count must be a non-negative integer")
|
|
778
778
|
|
|
779
779
|
|
|
780
|
+
def _validate_version_metadata(version: object) -> None:
|
|
781
|
+
"""Validate optional checkpoint ``version`` metadata leniently.
|
|
782
|
+
|
|
783
|
+
``version`` is opaque application metadata (rehydrate does not consume it),
|
|
784
|
+
not a persistence requirement. It may be a positive integer, a JSON float
|
|
785
|
+
such as ``1.0``, or a version label like ``"1.5.0-beta.2"``. Absent/null
|
|
786
|
+
versions and any non-empty string remain valid, preserving existing integer
|
|
787
|
+
checkpoints while allowing forward-compatible labels. Only bools, empty
|
|
788
|
+
strings, non-finite/zero/negative numbers, and structured values are
|
|
789
|
+
rejected.
|
|
790
|
+
"""
|
|
791
|
+
if version is None:
|
|
792
|
+
return
|
|
793
|
+
if isinstance(version, bool):
|
|
794
|
+
raise ValueError("version must be a positive integer or version label")
|
|
795
|
+
if isinstance(version, (int, float)):
|
|
796
|
+
if not 1 <= version < float("inf"):
|
|
797
|
+
raise ValueError("version must be a positive integer or version label")
|
|
798
|
+
return
|
|
799
|
+
if not isinstance(version, str) or not version.strip():
|
|
800
|
+
raise ValueError("version must be a positive integer or version label")
|
|
801
|
+
|
|
802
|
+
|
|
780
803
|
def _validate_checkpoint_shape(checkpoint: dict) -> None:
|
|
781
804
|
"""Validate bounded structured checkpoint fields without sanitizing them."""
|
|
782
|
-
|
|
783
|
-
if version is not None and (
|
|
784
|
-
isinstance(version, bool) or not isinstance(version, int) or version < 1
|
|
785
|
-
):
|
|
786
|
-
raise ValueError("version must be a positive integer")
|
|
805
|
+
_validate_version_metadata(checkpoint.get("version"))
|
|
787
806
|
validators = (
|
|
788
807
|
("goal", _validate_goal),
|
|
789
808
|
("phase", _validate_phase),
|
|
@@ -222,15 +222,28 @@ export class BackgroundJobBoard {
|
|
|
222
222
|
async recoverRunningJobs(): Promise<void> {
|
|
223
223
|
if (!this.persistence) return
|
|
224
224
|
const records = await this.persistence.loadAllJobs()
|
|
225
|
+
const recoveredIDs: string[] = []
|
|
225
226
|
for (const record of records) {
|
|
226
227
|
if (record.state === 'running') {
|
|
227
228
|
record.state = 'error'
|
|
228
229
|
record.lastStatusError = 'Process restarted — job marked as errored'
|
|
229
230
|
record.updatedAt = Date.now()
|
|
231
|
+
record.completedAt = Date.now()
|
|
230
232
|
record.totalErrors++
|
|
233
|
+
record.terminalUnreconciled = true
|
|
234
|
+
recoveredIDs.push(record.taskID)
|
|
231
235
|
}
|
|
232
236
|
this.jobs.set(record.taskID, record)
|
|
233
237
|
}
|
|
238
|
+
// Persist + signal + notify for each recovered job (write-ahead-log pattern)
|
|
239
|
+
for (const taskID of recoveredIDs) {
|
|
240
|
+
const job = this.jobs.get(taskID)
|
|
241
|
+
if (job) {
|
|
242
|
+
await this.persistRecord(job)
|
|
243
|
+
await this.writeSignal(job)
|
|
244
|
+
this.notifyTerminal(job.taskID)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
234
247
|
}
|
|
235
248
|
|
|
236
249
|
// ─── Core Operations ────────────────────────────────────────────────
|
|
@@ -240,6 +253,16 @@ export class BackgroundJobBoard {
|
|
|
240
253
|
* The job starts in the `running` state with a generated alias.
|
|
241
254
|
*/
|
|
242
255
|
async registerLaunch(input: LaunchInput): Promise<BackgroundJobRecord> {
|
|
256
|
+
// Atomic concurrency check: validate BEFORE any mutation or await.
|
|
257
|
+
// JavaScript's single-threaded event loop guarantees this check + set
|
|
258
|
+
// cannot be interleaved with another registerLaunch call.
|
|
259
|
+
if (!this.canDispatch(input.agent)) {
|
|
260
|
+
throw new Error(
|
|
261
|
+
`Concurrency limit reached for agent "${input.agent}" ` +
|
|
262
|
+
`(${this.getRunningCount(input.agent)} running, max ${this.options.maxConcurrentPerAgent})`,
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
243
266
|
const prefix = getAgentPrefix(input.agent)
|
|
244
267
|
let sessionCounters = this.aliasCounters.get(input.parentSessionID)
|
|
245
268
|
if (!sessionCounters) {
|
|
@@ -15,14 +15,19 @@
|
|
|
15
15
|
*
|
|
16
16
|
* @module cost-command
|
|
17
17
|
*/
|
|
18
|
-
import {
|
|
18
|
+
import { execFile } from 'node:child_process'
|
|
19
|
+
import { accessSync, constants, existsSync, statSync } from 'node:fs'
|
|
19
20
|
import { homedir } from 'node:os'
|
|
20
|
-
import { join } from 'node:path'
|
|
21
|
+
import { delimiter, dirname, join } from 'node:path'
|
|
22
|
+
import { fileURLToPath } from 'node:url'
|
|
21
23
|
import { z } from 'zod'
|
|
22
24
|
|
|
23
25
|
import type { ToolContextLike } from './delegation.ts'
|
|
24
26
|
import type { NativeTaskStatus } from './native-task-status.ts'
|
|
25
27
|
|
|
28
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
29
|
+
const __dirname = dirname(__filename)
|
|
30
|
+
|
|
26
31
|
/** One aggregated token row (per agent and phase). */
|
|
27
32
|
export interface CostRow {
|
|
28
33
|
agent: string
|
|
@@ -37,6 +42,8 @@ export interface CostCommandOptions {
|
|
|
37
42
|
dbPath?: string
|
|
38
43
|
/** Injectable loader for tests; production always loads node:sqlite. */
|
|
39
44
|
sqliteLoader?: () => Promise<SqliteModule>
|
|
45
|
+
/** Injectable CLI-fallback process boundary (tests / unusual runtimes). */
|
|
46
|
+
cliFallback?: CliFallbackDeps
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
export type OpenCodeVersion = 'v1' | 'v2'
|
|
@@ -220,6 +227,230 @@ function renderMarkdown(rows: readonly CostRow[], days: number): string {
|
|
|
220
227
|
return lines.join('\n')
|
|
221
228
|
}
|
|
222
229
|
|
|
230
|
+
// ─── CLI fallback: resolve a REAL Node, not the plugin runtime ──────────
|
|
231
|
+
//
|
|
232
|
+
// Under Bun (the runtime that motivates this fallback), process.execPath IS
|
|
233
|
+
// the Bun binary, which has no node:sqlite — so spawning it could never work.
|
|
234
|
+
// Resolve a genuine Node instead: PANTHEON_NODE → `node` on PATH → clear error.
|
|
235
|
+
|
|
236
|
+
/** First Node release exposing the built-in `node:sqlite` module. */
|
|
237
|
+
const NODE_SQLITE_MIN_VERSION = '22.5'
|
|
238
|
+
|
|
239
|
+
/** Minimal injectable child-process boundary (tests / unusual runtimes). */
|
|
240
|
+
export type ExecFileLike = (
|
|
241
|
+
file: string,
|
|
242
|
+
args: readonly string[],
|
|
243
|
+
) => Promise<{ stdout: string; stderr?: string }>
|
|
244
|
+
|
|
245
|
+
/** Injectables so the fallback never depends on the real machine in tests. */
|
|
246
|
+
export interface CliFallbackDeps {
|
|
247
|
+
/** Environment for PANTHEON_NODE / PATH lookup. Default: process.env. */
|
|
248
|
+
env?: NodeJS.ProcessEnv
|
|
249
|
+
/** Child-process boundary. Default: node:child_process.execFile. */
|
|
250
|
+
execFile?: ExecFileLike
|
|
251
|
+
/** Executable predicate used by the PANTHEON_NODE / PATH lookups. */
|
|
252
|
+
isExecutable?: (path: string) => boolean
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A real Node executable located for the CLI fallback. */
|
|
256
|
+
export interface ResolvedNode {
|
|
257
|
+
path: string
|
|
258
|
+
source: 'env' | 'path'
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
type ExecFailure = Error & { stdout?: string; stderr?: string }
|
|
262
|
+
|
|
263
|
+
function defaultIsExecutable(path: string): boolean {
|
|
264
|
+
try {
|
|
265
|
+
accessSync(path, constants.X_OK)
|
|
266
|
+
return statSync(path).isFile()
|
|
267
|
+
} catch {
|
|
268
|
+
return false
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function defaultExecFile(
|
|
273
|
+
file: string,
|
|
274
|
+
args: readonly string[],
|
|
275
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
276
|
+
return new Promise((resolve, reject) => {
|
|
277
|
+
execFile(file, [...args], { encoding: 'utf8' }, (error, stdout, stderr) => {
|
|
278
|
+
if (error) {
|
|
279
|
+
// execFile's callback error does not always carry the captured output;
|
|
280
|
+
// attach it so callers can surface cost.mjs's structured {ok:false}.
|
|
281
|
+
const failure = error as ExecFailure
|
|
282
|
+
if (typeof failure.stdout !== 'string') failure.stdout = stdout
|
|
283
|
+
if (typeof failure.stderr !== 'string') failure.stderr = stderr
|
|
284
|
+
reject(failure)
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
resolve({ stdout, stderr })
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Scan PATH (no shell) for a `node` executable. */
|
|
293
|
+
export function resolveNodeFromPath(
|
|
294
|
+
env: NodeJS.ProcessEnv,
|
|
295
|
+
isExecutable: (path: string) => boolean = defaultIsExecutable,
|
|
296
|
+
): string | undefined {
|
|
297
|
+
const pathValue = env.PATH ?? env.Path ?? ''
|
|
298
|
+
if (pathValue === '') return undefined
|
|
299
|
+
const isWindows = process.platform === 'win32'
|
|
300
|
+
const extensions = isWindows
|
|
301
|
+
? (env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';').filter((ext) => ext !== '')
|
|
302
|
+
: ['']
|
|
303
|
+
for (const dir of pathValue.split(delimiter)) {
|
|
304
|
+
if (dir === '') continue
|
|
305
|
+
for (const ext of extensions) {
|
|
306
|
+
const candidate = join(dir, `node${ext}`)
|
|
307
|
+
if (isExecutable(candidate)) return candidate
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return undefined
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Resolve a REAL Node.js binary for the CLI fallback.
|
|
315
|
+
*
|
|
316
|
+
* Order: PANTHEON_NODE (must exist and be executable — an explicit override
|
|
317
|
+
* that is unusable fails fast instead of being silently ignored) → `node`
|
|
318
|
+
* from PATH. Returns undefined only when neither source is available.
|
|
319
|
+
*/
|
|
320
|
+
export function resolveNodeExecutable(
|
|
321
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
322
|
+
isExecutable: (path: string) => boolean = defaultIsExecutable,
|
|
323
|
+
): ResolvedNode | undefined {
|
|
324
|
+
const explicit = env.PANTHEON_NODE
|
|
325
|
+
if (explicit !== undefined && explicit.trim() !== '') {
|
|
326
|
+
if (isExecutable(explicit)) return { path: explicit, source: 'env' }
|
|
327
|
+
throw new Error(
|
|
328
|
+
`PANTHEON_NODE is set to "${explicit}" but that is not an existing executable file`,
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
const fromPath = resolveNodeFromPath(env, isExecutable)
|
|
332
|
+
return fromPath === undefined ? undefined : { path: fromPath, source: 'path' }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function execErrorMessage(err: unknown): string {
|
|
336
|
+
if (err instanceof Error) {
|
|
337
|
+
const failure = err as ExecFailure
|
|
338
|
+
const stderr = typeof failure.stderr === 'string' ? failure.stderr.trim() : ''
|
|
339
|
+
if (stderr !== '') return stderr
|
|
340
|
+
return failure.message
|
|
341
|
+
}
|
|
342
|
+
return String(err)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function tryParseJson(text: string): unknown {
|
|
346
|
+
try {
|
|
347
|
+
return JSON.parse(text)
|
|
348
|
+
} catch {
|
|
349
|
+
return undefined
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function truncateForError(text: string): string {
|
|
354
|
+
const trimmed = text.trim()
|
|
355
|
+
return trimmed.length > 200 ? `${trimmed.slice(0, 200)}…` : trimmed
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Probe the resolved binary for built-in node:sqlite support. */
|
|
359
|
+
async function assertNodeSqliteSupport(
|
|
360
|
+
nodePath: string,
|
|
361
|
+
execFileAsync: ExecFileLike,
|
|
362
|
+
): Promise<void> {
|
|
363
|
+
try {
|
|
364
|
+
await execFileAsync(nodePath, ['-e', "require('node:sqlite')"])
|
|
365
|
+
} catch (err: unknown) {
|
|
366
|
+
throw new Error(
|
|
367
|
+
`resolved Node binary "${nodePath}" does not support node:sqlite (Node.js >= ${NODE_SQLITE_MIN_VERSION} required): ${execErrorMessage(err)}`,
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Pull the structured failure message cost.mjs prints to stdout (exit 1). */
|
|
373
|
+
function extractScriptFailure(err: unknown): string {
|
|
374
|
+
const failure = err as { stdout?: unknown; stderr?: unknown }
|
|
375
|
+
const stdout = typeof failure.stdout === 'string' ? failure.stdout : ''
|
|
376
|
+
const parsed = tryParseJson(stdout)
|
|
377
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
378
|
+
const record = parsed as { ok?: unknown; error?: unknown }
|
|
379
|
+
if (record.ok === false && typeof record.error === 'string' && record.error !== '') {
|
|
380
|
+
return record.error
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const stderr = typeof failure.stderr === 'string' ? failure.stderr.trim() : ''
|
|
384
|
+
if (stderr !== '') return stderr
|
|
385
|
+
return execErrorMessage(err)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Spawn `node scripts/cost.mjs` as a CLI fallback when node:sqlite is
|
|
390
|
+
* unavailable in the plugin process (e.g. Bun runtime). Returns CostRow[] by
|
|
391
|
+
* parsing the JSON the script writes to stdout.
|
|
392
|
+
*
|
|
393
|
+
* Resolves a REAL Node binary (PANTHEON_NODE → PATH): process.execPath under
|
|
394
|
+
* Bun is the Bun binary, which cannot run node:sqlite. The resolved binary is
|
|
395
|
+
* probed for node:sqlite support and the script is always run read-only with
|
|
396
|
+
* an argument array (never a shell).
|
|
397
|
+
*/
|
|
398
|
+
export async function queryWithCliFallback(
|
|
399
|
+
dbPath: string,
|
|
400
|
+
days: number,
|
|
401
|
+
deps?: CliFallbackDeps,
|
|
402
|
+
): Promise<CostRow[]> {
|
|
403
|
+
const env = deps?.env ?? process.env
|
|
404
|
+
const execFileAsync = deps?.execFile ?? defaultExecFile
|
|
405
|
+
const isExecutable = deps?.isExecutable ?? defaultIsExecutable
|
|
406
|
+
|
|
407
|
+
const costScript = join(__dirname, '..', '..', 'scripts', 'cost.mjs')
|
|
408
|
+
if (!existsSync(costScript)) {
|
|
409
|
+
throw new Error(`CLI fallback script not found: ${costScript}`)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const node = resolveNodeExecutable(env, isExecutable)
|
|
413
|
+
if (node === undefined) {
|
|
414
|
+
throw new Error(
|
|
415
|
+
`no Node.js executable found for the node:sqlite CLI fallback: set PANTHEON_NODE to a Node.js >= ${NODE_SQLITE_MIN_VERSION} binary or put "node" on PATH`,
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
await assertNodeSqliteSupport(node.path, execFileAsync)
|
|
420
|
+
|
|
421
|
+
let stdout: string
|
|
422
|
+
try {
|
|
423
|
+
;({ stdout } = await execFileAsync(node.path, [
|
|
424
|
+
costScript,
|
|
425
|
+
'--db-path',
|
|
426
|
+
dbPath,
|
|
427
|
+
'--days',
|
|
428
|
+
String(days),
|
|
429
|
+
]))
|
|
430
|
+
} catch (err: unknown) {
|
|
431
|
+
// cost.mjs sets exitCode=1 (execFile rejects) and writes {ok:false,error}
|
|
432
|
+
// to stdout before exiting — surface that structured failure.
|
|
433
|
+
throw new Error(`CLI fallback failed: ${extractScriptFailure(err)}`)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const parsed = tryParseJson(stdout)
|
|
437
|
+
if (parsed === null || typeof parsed !== 'object') {
|
|
438
|
+
throw new Error(`CLI fallback returned non-JSON output: ${truncateForError(stdout)}`)
|
|
439
|
+
}
|
|
440
|
+
const record = parsed as { ok?: unknown; rows?: unknown; error?: unknown }
|
|
441
|
+
if (record.ok !== true) {
|
|
442
|
+
const detail =
|
|
443
|
+
typeof record.error === 'string' && record.error !== ''
|
|
444
|
+
? record.error
|
|
445
|
+
: 'unexpected response shape from scripts/cost.mjs'
|
|
446
|
+
throw new Error(`CLI fallback failed: ${detail}`)
|
|
447
|
+
}
|
|
448
|
+
if (!Array.isArray(record.rows)) {
|
|
449
|
+
throw new Error('CLI fallback failed: scripts/cost.mjs response is missing the rows array')
|
|
450
|
+
}
|
|
451
|
+
return record.rows as CostRow[]
|
|
452
|
+
}
|
|
453
|
+
|
|
223
454
|
export function createCostCommand(options?: CostCommandOptions): CostCommand {
|
|
224
455
|
const loadSqlite =
|
|
225
456
|
options?.sqliteLoader ?? (async () => (await import('node:sqlite')) as SqliteModule)
|
|
@@ -269,7 +500,22 @@ export function createCostCommand(options?: CostCommandOptions): CostCommand {
|
|
|
269
500
|
`opencode.db not found (expected at ${expected}). No token history is available until opencode has stored session data.`,
|
|
270
501
|
)
|
|
271
502
|
}
|
|
272
|
-
|
|
503
|
+
let sqlite: SqliteModule | undefined
|
|
504
|
+
try {
|
|
505
|
+
sqlite = await loadSqlite()
|
|
506
|
+
} catch (loadErr: unknown) {
|
|
507
|
+
// node:sqlite unavailable (e.g. Bun runtime) — fall back to CLI.
|
|
508
|
+
const status = statusForError(loadErr)
|
|
509
|
+
if (status === 'UNSUPPORTED') {
|
|
510
|
+
try {
|
|
511
|
+
const rows = await queryWithCliFallback(dbPath, days, options?.cliFallback)
|
|
512
|
+
return `status: OK\n${renderMarkdown(rows, days)}`
|
|
513
|
+
} catch (cliErr: unknown) {
|
|
514
|
+
return failure(statusForError(cliErr), diagnostic(cliErr))
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
throw loadErr
|
|
518
|
+
}
|
|
273
519
|
const rows = await queryWithNodeSqlite(dbPath, days, sqlite)
|
|
274
520
|
return `status: OK\n${renderMarkdown(rows, days)}`
|
|
275
521
|
} catch (err: unknown) {
|
|
@@ -144,9 +144,25 @@ export interface DelegationOptions {
|
|
|
144
144
|
promptTimeoutMs?: number
|
|
145
145
|
/** Poll interval used by the bootstrap watchdog. */
|
|
146
146
|
bootstrapPollIntervalMs?: number
|
|
147
|
+
/**
|
|
148
|
+
* Max completed jobs to keep per agent after pruning (default: 10).
|
|
149
|
+
* Matches the native delegate-manager default.
|
|
150
|
+
*/
|
|
151
|
+
keepCompleted?: number
|
|
152
|
+
/**
|
|
153
|
+
* Hard cap on total board entries (default: 50).
|
|
154
|
+
* Matches the native delegate-manager default.
|
|
155
|
+
*/
|
|
156
|
+
maxEntries?: number
|
|
147
157
|
/** Injectable clock/sleeper for deterministic watchdog tests. */
|
|
148
158
|
now?: () => number
|
|
149
159
|
sleep?: (ms: number) => Promise<void>
|
|
160
|
+
/**
|
|
161
|
+
* Kill-switch: when false, all delegation tools (delegate/read/list) throw
|
|
162
|
+
* immediately. Mirrors `PANTHEON_DELEGATION=off` from the native manager.
|
|
163
|
+
* Defaults to true (delegation enabled) when not specified.
|
|
164
|
+
*/
|
|
165
|
+
delegationEnabled?: boolean
|
|
150
166
|
}
|
|
151
167
|
|
|
152
168
|
/** Dependencies threaded to the finalize path. */
|
|
@@ -370,5 +386,21 @@ export async function finalizeDelegation(
|
|
|
370
386
|
// Terminal in a DIFFERENT state (e.g. timeout already recorded) — the
|
|
371
387
|
// report above still captures this finalize; the board state stands.
|
|
372
388
|
}
|
|
389
|
+
|
|
390
|
+
// P1-1: Delete the auto-wake signal file to prevent leaks. The board
|
|
391
|
+
// writes .signal.json on every terminal transition, but the legacy path
|
|
392
|
+
// never called deleteSignal — only the native delegate-manager did.
|
|
393
|
+
// The consumer (auto-wake.ts consumeWakeSignals) is intentionally NOT
|
|
394
|
+
// wired in the event hook: finalizeDelegation already handles the
|
|
395
|
+
// terminal transition, so consuming signals would be redundant and risks
|
|
396
|
+
// double-processing. deleteSignal on finalize is sufficient.
|
|
397
|
+
await deps.board.deleteSignal(job.alias)
|
|
398
|
+
|
|
399
|
+
// P1-4: Prune old completed jobs and enforce the hard entry cap. The
|
|
400
|
+
// native delegate-manager called these in settle(), but the legacy
|
|
401
|
+
// finalize path omitted them, allowing unbounded board growth.
|
|
402
|
+
await deps.board.pruneCompleted(deps.options.keepCompleted ?? 10)
|
|
403
|
+
await deps.board.enforceEntryCap(deps.options.maxEntries ?? 50)
|
|
404
|
+
|
|
373
405
|
return deps.board.get(childSessionID) ?? job
|
|
374
406
|
}
|
|
@@ -740,10 +740,54 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
740
740
|
return finalizeDelegationReport(deps, childSessionID, opts)
|
|
741
741
|
}
|
|
742
742
|
|
|
743
|
+
// Kill-switch gate: when delegationEnabled is explicitly false (mirrors
|
|
744
|
+
// PANTHEON_DELEGATION=off from the native manager), all legacy tools throw.
|
|
745
|
+
function guardEnabled(): void {
|
|
746
|
+
if (options.delegationEnabled === false) {
|
|
747
|
+
throw new Error('pantheon delegation disabled (PANTHEON_DELEGATION=off)')
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Best-effort teardown of a child session whose `registerLaunch` was refused
|
|
753
|
+
* because the per-agent concurrency limit was hit between the pre-check and
|
|
754
|
+
* the registration (a race with another concurrent dispatch). Removes every
|
|
755
|
+
* side effect of the aborted dispatch — known-child marker, read-only
|
|
756
|
+
* registry entry and any reserved budget slot — then deletes the session when
|
|
757
|
+
* the host client exposes `session.delete`. Never throws: cleanup must not
|
|
758
|
+
* mask the original rejection.
|
|
759
|
+
*/
|
|
760
|
+
async function abortRejectedLaunch(
|
|
761
|
+
childSessionID: string,
|
|
762
|
+
wasReadOnly: boolean,
|
|
763
|
+
refund?: { key: string | undefined; reserved: boolean },
|
|
764
|
+
): Promise<void> {
|
|
765
|
+
knownChildren.delete(childSessionID)
|
|
766
|
+
if (wasReadOnly) readOnlyRegistry.unregister(childSessionID)
|
|
767
|
+
if (refund?.reserved && refund.key !== undefined) {
|
|
768
|
+
budgetUsage.set(refund.key, Math.max(0, (budgetUsage.get(refund.key) ?? 1) - 1))
|
|
769
|
+
}
|
|
770
|
+
// The plugin's adapted client (adaptDelegationClient) does not expose
|
|
771
|
+
// session.delete, and older hosts may not either. Delete best-effort so the
|
|
772
|
+
// abandoned session is not left behind; the registry cleanup above already
|
|
773
|
+
// removes the side effects that matter for read-only enforcement.
|
|
774
|
+
const session = client.session as {
|
|
775
|
+
delete?: (input: { path: { id: string } }) => Promise<unknown>
|
|
776
|
+
}
|
|
777
|
+
if (typeof session.delete !== 'function') return
|
|
778
|
+
try {
|
|
779
|
+
await session.delete({ path: { id: childSessionID } })
|
|
780
|
+
} catch (err: unknown) {
|
|
781
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
782
|
+
log.warn(`[pantheon-delegate] failed to delete orphan session ${childSessionID}: ${reason}`)
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
743
786
|
const pantheon_delegate: DelegationTool<typeof delegateArgs> = {
|
|
744
787
|
description: buildDelegateDescription(options),
|
|
745
788
|
args: delegateArgs,
|
|
746
789
|
execute: async (args, ctx) => {
|
|
790
|
+
guardEnabled()
|
|
747
791
|
if (!isRootSession(ctx.sessionID)) {
|
|
748
792
|
throw new Error(
|
|
749
793
|
`pantheon_delegate rejected: session ${ctx.sessionID} is a sub-session — only root sessions can delegate`,
|
|
@@ -871,13 +915,27 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
871
915
|
readOnlyRegistry.register(childSessionID, entry)
|
|
872
916
|
}
|
|
873
917
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
918
|
+
let job: BackgroundJobRecord
|
|
919
|
+
try {
|
|
920
|
+
job = await board.registerLaunch({
|
|
921
|
+
taskID: childSessionID,
|
|
922
|
+
parentSessionID: ctx.sessionID,
|
|
923
|
+
agent: args.agent,
|
|
924
|
+
description: args.description ?? effectivePrompt,
|
|
925
|
+
objective: effectivePrompt,
|
|
926
|
+
})
|
|
927
|
+
} catch (err: unknown) {
|
|
928
|
+
// Concurrency race: another dispatch claimed the last slot after the
|
|
929
|
+
// pre-check above but before this registration. Undo every side effect
|
|
930
|
+
// of the aborted dispatch so no orphan session/registry survives, then
|
|
931
|
+
// surface a clear rejection.
|
|
932
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
933
|
+
await abortRejectedLaunch(childSessionID, readOnly, {
|
|
934
|
+
key: budgetKey,
|
|
935
|
+
reserved: budgetReserved,
|
|
936
|
+
})
|
|
937
|
+
return `pantheon_delegate rejected: ${reason}`
|
|
938
|
+
}
|
|
881
939
|
|
|
882
940
|
// Timeout manager: cleared on finalize, unref'd so the timer never
|
|
883
941
|
// keeps the process alive on its own.
|
|
@@ -968,13 +1026,34 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
968
1026
|
const retryID = retry.id
|
|
969
1027
|
knownChildren.add(retryID)
|
|
970
1028
|
options.registerChildSession?.(retryID, ctx.sessionID)
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1029
|
+
|
|
1030
|
+
// Phase 4 read-only enforcement for the retry: recalculate readOnly
|
|
1031
|
+
// (same logic as the initial child at :866-872) and register the retry
|
|
1032
|
+
// session so the plugin's tool.execute.before guard enforces it.
|
|
1033
|
+
const retryReadOnly =
|
|
1034
|
+
args.read_only === true || (options.readOnlyAgents?.has(args.agent.toLowerCase()) ?? false)
|
|
1035
|
+
if (retryReadOnly) {
|
|
1036
|
+
const entry: { agent: string; readOnlyFlag?: boolean } = { agent: args.agent }
|
|
1037
|
+
if (args.read_only !== undefined) entry.readOnlyFlag = args.read_only
|
|
1038
|
+
readOnlyRegistry.register(retryID, entry)
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
let retryJob: BackgroundJobRecord
|
|
1042
|
+
try {
|
|
1043
|
+
retryJob = await board.registerLaunch({
|
|
1044
|
+
taskID: retryID,
|
|
1045
|
+
parentSessionID: ctx.sessionID,
|
|
1046
|
+
agent: args.agent,
|
|
1047
|
+
description: args.description ?? effectivePrompt,
|
|
1048
|
+
objective: effectivePrompt,
|
|
1049
|
+
})
|
|
1050
|
+
} catch (err: unknown) {
|
|
1051
|
+
// Same race as the initial child: the retry registration can be
|
|
1052
|
+
// refused. Clean up the retry session + registry entry before failing.
|
|
1053
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
1054
|
+
await abortRejectedLaunch(retryID, retryReadOnly)
|
|
1055
|
+
return `pantheon_delegate rejected: ${reason} (retry)`
|
|
1056
|
+
}
|
|
978
1057
|
const retryTimer = setTimeout(() => {
|
|
979
1058
|
timers.delete(retryID)
|
|
980
1059
|
void finalize(retryID, {
|
|
@@ -1035,6 +1114,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
1035
1114
|
'Resolves by alias or task ID.',
|
|
1036
1115
|
args: readArgs,
|
|
1037
1116
|
execute: async (args, ctx) => {
|
|
1117
|
+
guardEnabled()
|
|
1038
1118
|
const job = board.resolve(ctx.sessionID, args.id)
|
|
1039
1119
|
if (!job) {
|
|
1040
1120
|
return `Unknown delegation "${args.id}" for this session. Use pantheon_delegation_list to see active delegations.`
|
|
@@ -1093,6 +1173,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
1093
1173
|
'List background delegations for the current session, with [unread] for finished jobs.',
|
|
1094
1174
|
args: listArgs,
|
|
1095
1175
|
execute: async (_args, ctx) => {
|
|
1176
|
+
guardEnabled()
|
|
1096
1177
|
const jobs = board.list(ctx.sessionID)
|
|
1097
1178
|
if (jobs.length === 0) return 'No background delegations for this session.'
|
|
1098
1179
|
|
|
@@ -104,12 +104,20 @@ export function createV2EventDispatcher(deps: V2EventDeps): V2EventDispatcher {
|
|
|
104
104
|
} else {
|
|
105
105
|
// Native task(background=true) child: register on the board
|
|
106
106
|
// atomically (skip if V1 idle hook or re-dispatch already claimed it).
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
107
|
+
try {
|
|
108
|
+
await deps.board.registerLaunchIfAbsent({
|
|
109
|
+
taskID: info.id,
|
|
110
|
+
parentSessionID: info.parentID,
|
|
111
|
+
agent: 'native',
|
|
112
|
+
description: `native child ${info.id}`,
|
|
113
|
+
})
|
|
114
|
+
} catch (err) {
|
|
115
|
+
// The per-agent concurrency limit can refuse the launch. Do not
|
|
116
|
+
// abort the rest of the handler: this child is simply not
|
|
117
|
+
// mirrored on the board, while finalize/idle dispatch for every
|
|
118
|
+
// other event must still run.
|
|
119
|
+
log.warn(`[Pantheon V2] native child ${info.id} not mirrored on board:`, err)
|
|
120
|
+
}
|
|
113
121
|
}
|
|
114
122
|
}
|
|
115
123
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pantheon-tui",
|
|
3
|
-
"version": "1.5.0-beta.
|
|
3
|
+
"version": "1.5.0-beta.7",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pantheon-tui",
|
|
9
|
-
"version": "1.5.0-beta.
|
|
9
|
+
"version": "1.5.0-beta.7",
|
|
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.
|
|
4
|
+
"version": "1.5.0-beta.7",
|
|
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": {
|