pantheon-opencode 1.5.2 → 1.6.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pantheon/code-mode/README.md +4 -2
- package/.pantheon/code-mode/checkpoint_session.py +138 -102
- package/.pantheon/code-mode/compress-inline.py +102 -57
- package/.pantheon/code-mode/manifest.json +2 -8
- package/CHANGELOG.md +17 -0
- package/README.md +86 -52
- package/README.pt-BR.md +63 -43
- package/commands/pantheon-optimize.md +2 -2
- package/package.json +13 -1
- package/plugin.json +1 -1
- package/pyproject.toml +8 -15
- package/scripts/build-agents-md.mjs +2 -2
- package/scripts/check-plugin-v2-coverage.mjs +213 -0
- package/scripts/ci-validate-yaml.py +7 -5
- package/scripts/code_mode_server.py +116 -24
- package/scripts/doctor.mjs +85 -28
- package/scripts/install/cli-ui.mjs +1 -1
- package/scripts/install/config-migration.mjs +2 -2
- package/scripts/install/health-check.mjs +6 -19
- package/scripts/install/interactive.mjs +6 -6
- package/scripts/install/migrate.mjs +1 -1
- package/scripts/mcp_persistence_server.py +390 -76
- package/scripts/memory_mcp_server.py +370 -196
- package/scripts/postinstall.mjs +0 -7
- package/scripts/probe-context-rehydrate.mjs +28 -11
- package/scripts/prune.mjs +2 -2
- package/scripts/test-opencode-v2-sandbox.sh +1759 -0
- package/scripts/validate-routing.mjs +40 -0
- package/skills-lock.json +9 -10
- package/src/agents/mnemosyne.md +15 -16
- package/src/agents/zeus.md +1 -2
- package/src/mcp/code_mode_server.py +116 -24
- package/src/mcp/mcp_codemap_module.py +559 -129
- package/src/mcp/mcp_persistence_server.py +390 -76
- package/src/mcp/memory_mcp_server.py +371 -248
- package/src/mcp/pantheon_vision_server.py +136 -24
- package/src/mcp/requirements-mcp.txt +5 -3
- package/src/mcp/requirements-vision.txt +2 -0
- package/src/pantheon/model-command.ts +18 -7
- package/src/pantheon/todo-enforcer.ts +1 -1
- package/src/pantheon/vision.ts +1 -1
- package/src/plugin-v2.ts +134 -104
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/package-lock.json +20 -20
- package/src/plugins/tui/package.json +12 -12
- package/src/routing.yml +0 -1
- package/src/skills/README.md +31 -36
- package/src/skills/auto-continue/SKILL.md +25 -75
- package/.pantheon/code-mode/eval-llm-judge.py +0 -255
- package/.pantheon/code-mode/eval-monte-carlo.py +0 -368
- package/.pantheon/code-mode/eval-run.py +0 -197
- package/.pantheon/code-mode/eval-static.py +0 -321
- package/.pantheon/code-mode/session-end-save.py +0 -186
- package/.pantheon/code-mode/session-end-save.sh +0 -18
- package/scripts/recover-zenodo-deposition.mjs +0 -146
- package/scripts/test-opencode-v1-v2-sandbox.sh +0 -862
- package/scripts/validate-zenodo-release.mjs +0 -154
- package/scripts/zenodo-artifact.mjs +0 -94
- package/scripts/zenodo-release-state.mjs +0 -53
- package/src/skills/orchestration-workflow/SKILL.md +0 -311
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { dirname, join, resolve } from 'node:path'
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
6
|
+
|
|
7
|
+
export const REQUIRED_NODE_VERSION = 'v24.15.0'
|
|
8
|
+
export const LINE_THRESHOLD_NUMERATOR = 4
|
|
9
|
+
export const LINE_THRESHOLD_DENOMINATOR = 5
|
|
10
|
+
|
|
11
|
+
function integerField(value, name, minimum) {
|
|
12
|
+
const label = minimum === 0 ? 'non-negative' : 'positive'
|
|
13
|
+
if (!/^\d+$/.test(value)) {
|
|
14
|
+
throw new Error(`${name} must be a ${label} integer`)
|
|
15
|
+
}
|
|
16
|
+
const parsed = Number(value)
|
|
17
|
+
if (!Number.isSafeInteger(parsed) || parsed < minimum) {
|
|
18
|
+
throw new Error(`${name} must be a ${label} integer`)
|
|
19
|
+
}
|
|
20
|
+
return parsed
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeSourcePath(source, repoRoot) {
|
|
24
|
+
const path = source.startsWith('file:') ? fileURLToPath(source) : source
|
|
25
|
+
return resolve(repoRoot, path)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function exactlyOneField(lines, name) {
|
|
29
|
+
const values = lines.filter((line) => line.startsWith(`${name}:`))
|
|
30
|
+
if (values.length !== 1) {
|
|
31
|
+
throw new Error(`target LCOV record must contain exactly one ${name} field`)
|
|
32
|
+
}
|
|
33
|
+
return values[0].slice(name.length + 1)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function optionalPair(lines, foundName, hitName) {
|
|
37
|
+
const foundFields = lines.filter((line) => line.startsWith(`${foundName}:`))
|
|
38
|
+
const hitFields = lines.filter((line) => line.startsWith(`${hitName}:`))
|
|
39
|
+
if (foundFields.length === 0 && hitFields.length === 0) return null
|
|
40
|
+
if (foundFields.length !== 1 || hitFields.length !== 1) {
|
|
41
|
+
throw new Error(`target LCOV record must contain one ${foundName}/${hitName} pair`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const found = integerField(foundFields[0].slice(foundName.length + 1), foundName, 0)
|
|
45
|
+
const hit = integerField(hitFields[0].slice(hitName.length + 1), hitName, 0)
|
|
46
|
+
if (hit > found) throw new Error(`${hitName} ${hit} exceeds ${foundName} ${found}`)
|
|
47
|
+
return { hit, found }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Parse and validate the single LCOV record for the exact target source. */
|
|
51
|
+
export function parseTargetLcov(report, repoRoot, targetPath) {
|
|
52
|
+
if (typeof report !== 'string' || report.trim() === '') {
|
|
53
|
+
throw new Error('LCOV report is empty')
|
|
54
|
+
}
|
|
55
|
+
const normalizedRoot = resolve(repoRoot)
|
|
56
|
+
const normalizedTarget = resolve(targetPath)
|
|
57
|
+
const records = []
|
|
58
|
+
let current = []
|
|
59
|
+
for (const line of report.split(/\r?\n/)) {
|
|
60
|
+
if (line === 'end_of_record') {
|
|
61
|
+
if (current.length > 0) records.push(current)
|
|
62
|
+
current = []
|
|
63
|
+
} else if (line.trim() !== '') {
|
|
64
|
+
current.push(line)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (current.length > 0) records.push(current)
|
|
68
|
+
|
|
69
|
+
const targetRecords = []
|
|
70
|
+
for (const lines of records) {
|
|
71
|
+
const sourceFields = lines.filter((line) => line.startsWith('SF:'))
|
|
72
|
+
const matchingSources = sourceFields.filter(
|
|
73
|
+
(line) => normalizeSourcePath(line.slice(3), normalizedRoot) === normalizedTarget,
|
|
74
|
+
)
|
|
75
|
+
if (matchingSources.length === 0) continue
|
|
76
|
+
if (sourceFields.length !== 1 || matchingSources.length !== 1) {
|
|
77
|
+
throw new Error('target LCOV record must contain exactly one SF entry')
|
|
78
|
+
}
|
|
79
|
+
targetRecords.push(lines)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (targetRecords.length !== 1) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`expected exactly one LCOV record for ${normalizedTarget}; found ${targetRecords.length} LCOV records`,
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const lines = targetRecords[0]
|
|
89
|
+
const lineRecords = new Map()
|
|
90
|
+
for (const entry of lines.filter((line) => line.startsWith('DA:'))) {
|
|
91
|
+
const fields = entry.slice(3).split(',')
|
|
92
|
+
if (fields.length < 2 || fields.length > 3) {
|
|
93
|
+
throw new Error('DA entry must contain a line number and execution count')
|
|
94
|
+
}
|
|
95
|
+
const lineNumber = integerField(fields[0], 'DA line number', 1)
|
|
96
|
+
const count = integerField(fields[1], 'DA count', 0)
|
|
97
|
+
if (lineRecords.has(lineNumber)) {
|
|
98
|
+
throw new Error(`duplicate DA line number ${lineNumber}`)
|
|
99
|
+
}
|
|
100
|
+
lineRecords.set(lineNumber, count)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const found = integerField(exactlyOneField(lines, 'LF'), 'LF', 1)
|
|
104
|
+
const hit = integerField(exactlyOneField(lines, 'LH'), 'LH', 0)
|
|
105
|
+
if (hit > found) throw new Error(`LH ${hit} exceeds LF ${found}`)
|
|
106
|
+
if (found !== lineRecords.size) {
|
|
107
|
+
throw new Error(`LF ${found} does not equal ${lineRecords.size} unique DA entries`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const coveredRecords = [...lineRecords.values()].filter((count) => count > 0).length
|
|
111
|
+
if (hit !== coveredRecords) {
|
|
112
|
+
throw new Error(`LH ${hit} does not equal ${coveredRecords} covered DA entries`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
sourcePath: normalizedTarget,
|
|
117
|
+
lines: { hit, found },
|
|
118
|
+
branches: optionalPair(lines, 'BRF', 'BRH'),
|
|
119
|
+
functions: optionalPair(lines, 'FNF', 'FNH'),
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Compare using integer arithmetic so the 80% boundary cannot round down. */
|
|
124
|
+
export function passesLineThreshold(coverage) {
|
|
125
|
+
return (
|
|
126
|
+
BigInt(coverage.lines.hit) * BigInt(LINE_THRESHOLD_DENOMINATOR) >=
|
|
127
|
+
BigInt(coverage.lines.found) * BigInt(LINE_THRESHOLD_NUMERATOR)
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatMetric(metric) {
|
|
132
|
+
if (metric == null || metric.found === 0) return 'not reported'
|
|
133
|
+
return `${metric.hit}/${metric.found} (${((metric.hit / metric.found) * 100).toFixed(2)}%)`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function listTypeScriptTests(repoRoot) {
|
|
137
|
+
const directory = join(repoRoot, 'tests', 'pantheon')
|
|
138
|
+
return readdirSync(directory)
|
|
139
|
+
.filter((file) => file.endsWith('.test.ts'))
|
|
140
|
+
.sort()
|
|
141
|
+
.map((file) => join(directory, file))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function runCoverage() {
|
|
145
|
+
if (process.version !== REQUIRED_NODE_VERSION) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`plugin-v2 coverage requires Node ${REQUIRED_NODE_VERSION}; got ${process.version}`,
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
152
|
+
const targetPath = resolve(repoRoot, 'src', 'plugin-v2.ts')
|
|
153
|
+
const testFiles = listTypeScriptTests(repoRoot)
|
|
154
|
+
if (testFiles.length === 0) throw new Error('no tests/pantheon/*.test.ts files found')
|
|
155
|
+
|
|
156
|
+
const tempRoot = mkdtempSync(join(tmpdir(), 'pantheon-plugin-v2-coverage-'))
|
|
157
|
+
try {
|
|
158
|
+
const lcovPath = join(tempRoot, 'coverage.lcov')
|
|
159
|
+
const nodeOptions = [process.env.NODE_OPTIONS, '--conditions=import'].filter(Boolean).join(' ')
|
|
160
|
+
const args = [
|
|
161
|
+
'--experimental-test-coverage',
|
|
162
|
+
'--enable-source-maps',
|
|
163
|
+
'--import',
|
|
164
|
+
'tsx',
|
|
165
|
+
'--test',
|
|
166
|
+
'--test-concurrency=1',
|
|
167
|
+
'--test-reporter=spec',
|
|
168
|
+
'--test-reporter=lcov',
|
|
169
|
+
'--test-reporter-destination=stdout',
|
|
170
|
+
`--test-reporter-destination=${lcovPath}`,
|
|
171
|
+
...testFiles,
|
|
172
|
+
]
|
|
173
|
+
const result = spawnSync(process.execPath, args, {
|
|
174
|
+
cwd: repoRoot,
|
|
175
|
+
encoding: 'utf8',
|
|
176
|
+
env: { ...process.env, NODE_OPTIONS: nodeOptions },
|
|
177
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
178
|
+
})
|
|
179
|
+
if (result.stdout) process.stdout.write(result.stdout)
|
|
180
|
+
if (result.stderr) process.stderr.write(result.stderr)
|
|
181
|
+
if (result.error) throw new Error(`TypeScript test command failed: ${result.error.message}`)
|
|
182
|
+
if (result.status !== 0) {
|
|
183
|
+
throw new Error(`TypeScript test command exited ${result.status ?? 'without a status'}`)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let report
|
|
187
|
+
try {
|
|
188
|
+
report = readFileSync(lcovPath, 'utf8')
|
|
189
|
+
} catch (error) {
|
|
190
|
+
throw new Error(`LCOV report was not generated: ${error.message}`)
|
|
191
|
+
}
|
|
192
|
+
const coverage = parseTargetLcov(report, repoRoot, targetPath)
|
|
193
|
+
console.log(`Coverage source: ${coverage.sourcePath}`)
|
|
194
|
+
console.log(`Lines: ${formatMetric(coverage.lines)}`)
|
|
195
|
+
console.log(`Branches (informational): ${formatMetric(coverage.branches)}`)
|
|
196
|
+
console.log(`Functions (informational): ${formatMetric(coverage.functions)}`)
|
|
197
|
+
if (!passesLineThreshold(coverage)) {
|
|
198
|
+
throw new Error('src/plugin-v2.ts line coverage is below the required 80%')
|
|
199
|
+
}
|
|
200
|
+
console.log('src/plugin-v2.ts line coverage: PASS (>= 80%)')
|
|
201
|
+
} finally {
|
|
202
|
+
rmSync(tempRoot, { recursive: true, force: true })
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
|
|
207
|
+
try {
|
|
208
|
+
runCoverage()
|
|
209
|
+
} catch (error) {
|
|
210
|
+
console.error(`Plugin V2 coverage gate: ${error.message}`)
|
|
211
|
+
process.exitCode = 1
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Validate agent YAML frontmatter in CI."""
|
|
3
|
+
|
|
3
4
|
import os
|
|
4
5
|
import re
|
|
5
6
|
import sys
|
|
@@ -7,18 +8,19 @@ import sys
|
|
|
7
8
|
import yaml
|
|
8
9
|
|
|
9
10
|
errors = 0
|
|
10
|
-
for f in sorted(os.listdir(
|
|
11
|
-
if not f.endswith(
|
|
11
|
+
for f in sorted(os.listdir("src/agents")):
|
|
12
|
+
if not f.endswith(".md"):
|
|
12
13
|
continue
|
|
13
14
|
with open(f"src/agents/{f}", encoding="utf-8") as agent_file:
|
|
14
15
|
content = agent_file.read()
|
|
15
|
-
match = re.match(r
|
|
16
|
+
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
|
|
16
17
|
if match:
|
|
17
18
|
try:
|
|
18
19
|
yaml.safe_load(match.group(1))
|
|
19
20
|
except yaml.YAMLError as e:
|
|
20
|
-
print(f
|
|
21
|
+
print(f"FAIL: {f}: {e}")
|
|
21
22
|
errors += 1
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
agent_count = sum(1 for _ in os.listdir("src/agents") if _.endswith(".md"))
|
|
25
|
+
print(f"{agent_count - errors}/{agent_count} agents valid")
|
|
24
26
|
sys.exit(errors)
|
|
@@ -20,6 +20,11 @@ import hashlib
|
|
|
20
20
|
import json
|
|
21
21
|
import logging
|
|
22
22
|
import os
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import resource
|
|
26
|
+
except (ImportError, OSError, ValueError): # pragma: no cover - platform-specific
|
|
27
|
+
resource = None # type: ignore[assignment]
|
|
23
28
|
import shutil
|
|
24
29
|
import signal
|
|
25
30
|
import stat
|
|
@@ -125,6 +130,88 @@ def _build_script_env(
|
|
|
125
130
|
return env
|
|
126
131
|
|
|
127
132
|
|
|
133
|
+
# Additional tasks a sandboxed script may create beyond the UID's current
|
|
134
|
+
# count. RLIMIT_NPROC is a per-real-UID limit on the *total* number of
|
|
135
|
+
# processes AND threads, so the sandbox floor must clear everything the host
|
|
136
|
+
# already owns before a script can fork at all. This headroom is the actual
|
|
137
|
+
# anti-fork-bomb budget: the script still cannot exceed current + headroom.
|
|
138
|
+
NPROC_HEADROOM: int = 64
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _uid_task_count() -> int | None:
|
|
142
|
+
"""Count the tasks (processes + threads) this real UID already owns.
|
|
143
|
+
|
|
144
|
+
The kernel compares the post-fork total against RLIMIT_NPROC, so this is
|
|
145
|
+
the number the sandbox limit has to exceed. Returns *None* when the count
|
|
146
|
+
cannot be established (no readable ``/proc``), which makes the caller
|
|
147
|
+
fail open on ``--nproc`` rather than pin a value that may be too low and
|
|
148
|
+
break every script.
|
|
149
|
+
|
|
150
|
+
The tally is one too high per process: ``/proc/<pid>/task`` already lists
|
|
151
|
+
the thread-group leader, so the extra ``1`` counts it twice. That bias
|
|
152
|
+
only widens the derived limit (the fail-open direction), so it is left in
|
|
153
|
+
place deliberately.
|
|
154
|
+
"""
|
|
155
|
+
uid = os.getuid()
|
|
156
|
+
count = 0
|
|
157
|
+
try:
|
|
158
|
+
pids = os.listdir("/proc")
|
|
159
|
+
except OSError:
|
|
160
|
+
return None
|
|
161
|
+
for pid in pids:
|
|
162
|
+
if not pid.isdigit():
|
|
163
|
+
continue
|
|
164
|
+
proc_dir = f"/proc/{pid}"
|
|
165
|
+
try:
|
|
166
|
+
if os.stat(proc_dir).st_uid != uid:
|
|
167
|
+
continue
|
|
168
|
+
# ``/proc/<pid>/task`` already includes the thread-group leader,
|
|
169
|
+
# so the extra 1 counts it twice (over-count by one per process).
|
|
170
|
+
count += 1 + len(os.listdir(f"{proc_dir}/task"))
|
|
171
|
+
except OSError:
|
|
172
|
+
# Process exited between listdir and stat, or is not ours to read.
|
|
173
|
+
continue
|
|
174
|
+
return count
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _sandbox_nproc() -> str | None:
|
|
178
|
+
"""Return the ``--nproc`` value for a sandboxed script, or *None*.
|
|
179
|
+
|
|
180
|
+
Derived from the host rather than hardcoded: the value is the UID's
|
|
181
|
+
current task count plus ``NPROC_HEADROOM``, clamped to the hard limit
|
|
182
|
+
(``prlimit`` fails with EPERM above it, and an unprivileged process cannot
|
|
183
|
+
raise its soft limit past the hard one).
|
|
184
|
+
|
|
185
|
+
Returns *None* when the count or the limit cannot be read, in which case
|
|
186
|
+
the caller omits ``--nproc``. ``--as`` and ``--cpu`` still apply, so the
|
|
187
|
+
sandbox keeps its memory and CPU boundaries.
|
|
188
|
+
"""
|
|
189
|
+
current = _uid_task_count()
|
|
190
|
+
if current is None:
|
|
191
|
+
_log.warning(
|
|
192
|
+
"code-mode: cannot count this UID's tasks; omitting --nproc, so "
|
|
193
|
+
"the sandboxed script's fork budget is unbounded"
|
|
194
|
+
)
|
|
195
|
+
return None
|
|
196
|
+
if resource is None:
|
|
197
|
+
_log.warning(
|
|
198
|
+
"code-mode: cannot read RLIMIT_NPROC; omitting --nproc, so the "
|
|
199
|
+
"sandboxed script's fork budget is unbounded"
|
|
200
|
+
)
|
|
201
|
+
return None
|
|
202
|
+
try:
|
|
203
|
+
_soft, hard = resource.getrlimit(resource.RLIMIT_NPROC)
|
|
204
|
+
except (OSError, ValueError):
|
|
205
|
+
_log.warning(
|
|
206
|
+
"code-mode: cannot read RLIMIT_NPROC; omitting --nproc, so the "
|
|
207
|
+
"sandboxed script's fork budget is unbounded"
|
|
208
|
+
)
|
|
209
|
+
return None
|
|
210
|
+
if hard == getattr(resource, "RLIM_INFINITY", -1):
|
|
211
|
+
return str(current + NPROC_HEADROOM)
|
|
212
|
+
return str(max(1, min(current + NPROC_HEADROOM, hard)))
|
|
213
|
+
|
|
214
|
+
|
|
128
215
|
def _prlimit_prefix(
|
|
129
216
|
prlimit_path: str | None,
|
|
130
217
|
timeout_s: int = SCRIPT_TIMEOUT,
|
|
@@ -132,21 +219,34 @@ def _prlimit_prefix(
|
|
|
132
219
|
"""Return a ``prlimit`` command prefix to wrap a subprocess, or *None*.
|
|
133
220
|
|
|
134
221
|
On Linux with ``prlimit`` available the prefix adds:
|
|
135
|
-
--nproc
|
|
136
|
-
|
|
137
|
-
|
|
222
|
+
--nproc=<uid task count + NPROC_HEADROOM> --as=1073741824
|
|
223
|
+
--cpu=<timeout_s + 5>
|
|
224
|
+
|
|
225
|
+
``--nproc`` is derived per invocation by :func:`_sandbox_nproc` rather
|
|
226
|
+
than hardcoded. RLIMIT_NPROC is a per-real-UID total across processes AND
|
|
227
|
+
threads, so a fixed value below the host's current usage makes the very
|
|
228
|
+
first ``fork()`` inside the sandbox fail with EAGAIN — which is exactly
|
|
229
|
+
what a hardcoded ``--nproc=512`` did on a host already running more than
|
|
230
|
+
512 tasks. The isolation property is unchanged: a script still cannot
|
|
231
|
+
create more than ``NPROC_HEADROOM`` additional tasks.
|
|
232
|
+
|
|
233
|
+
On non-Linux, when *prlimit_path* is falsy, or when the nproc limit cannot
|
|
234
|
+
be derived, the flag is omitted (fail-open); ``--as``/``--cpu`` remain.
|
|
138
235
|
"""
|
|
139
236
|
if not prlimit_path:
|
|
140
237
|
return None
|
|
141
238
|
|
|
142
239
|
cpu_limit = timeout_s + 5
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
240
|
+
prefix = [prlimit_path]
|
|
241
|
+
nproc = _sandbox_nproc()
|
|
242
|
+
if nproc is not None:
|
|
243
|
+
prefix.append(f"--nproc={nproc}")
|
|
244
|
+
prefix += [
|
|
146
245
|
"--as=1073741824", # 1 GiB in bytes
|
|
147
246
|
f"--cpu={cpu_limit}",
|
|
148
247
|
"--",
|
|
149
248
|
]
|
|
249
|
+
return prefix
|
|
150
250
|
|
|
151
251
|
|
|
152
252
|
def _detect_prlimit() -> str | None:
|
|
@@ -226,7 +326,9 @@ def _packaged_scripts_dir() -> Path | None:
|
|
|
226
326
|
return None
|
|
227
327
|
|
|
228
328
|
|
|
229
|
-
def _resolve_scripts_dir(
|
|
329
|
+
def _resolve_scripts_dir(
|
|
330
|
+
candidates: list[Path], *, allow_packaged: bool = True
|
|
331
|
+
) -> Path | None:
|
|
230
332
|
"""Select the first usable candidate, optionally trying the package copy."""
|
|
231
333
|
for candidate in candidates:
|
|
232
334
|
if _has_usable_scripts(candidate):
|
|
@@ -377,9 +479,7 @@ def _load_manifest(scripts_dir: Path | None = None) -> dict[str, str]:
|
|
|
377
479
|
) from None
|
|
378
480
|
|
|
379
481
|
if not isinstance(data, dict):
|
|
380
|
-
raise ManifestError(
|
|
381
|
-
"CORRUPT_DATA", "Code-mode manifest must be a JSON object."
|
|
382
|
-
)
|
|
482
|
+
raise ManifestError("CORRUPT_DATA", "Code-mode manifest must be a JSON object.")
|
|
383
483
|
|
|
384
484
|
if data.get("version") != 1:
|
|
385
485
|
raise ManifestError(
|
|
@@ -527,7 +627,6 @@ def _contract_result(
|
|
|
527
627
|
return f"[{status}] {message}"
|
|
528
628
|
|
|
529
629
|
|
|
530
|
-
|
|
531
630
|
def _format_output(
|
|
532
631
|
stdout: str,
|
|
533
632
|
stderr: str,
|
|
@@ -818,22 +917,15 @@ async def execute_code_script(
|
|
|
818
917
|
timeout_s=timeout_s,
|
|
819
918
|
status="TIMEOUT",
|
|
820
919
|
)
|
|
821
|
-
except FileNotFoundError:
|
|
822
|
-
return _build_result(
|
|
823
|
-
"",
|
|
824
|
-
f"Script '{script_name}' not found or interpreter missing.",
|
|
825
|
-
-1,
|
|
826
|
-
timed_out=False,
|
|
827
|
-
duration_ms=0,
|
|
828
|
-
metadata=metadata,
|
|
829
|
-
json_output=json_output,
|
|
830
|
-
timeout_s=timeout_s,
|
|
831
|
-
status="UNAVAILABLE",
|
|
832
|
-
)
|
|
833
920
|
except OSError as e:
|
|
921
|
+
error = (
|
|
922
|
+
f"Script '{script_name}' not found or interpreter missing."
|
|
923
|
+
if isinstance(e, FileNotFoundError)
|
|
924
|
+
else f"Failed to execute script: {e}"
|
|
925
|
+
)
|
|
834
926
|
return _build_result(
|
|
835
927
|
"",
|
|
836
|
-
|
|
928
|
+
error,
|
|
837
929
|
-1,
|
|
838
930
|
timed_out=False,
|
|
839
931
|
duration_ms=0,
|
package/scripts/doctor.mjs
CHANGED
|
@@ -88,15 +88,25 @@ function parseArgs(argv) {
|
|
|
88
88
|
args.target = process.cwd()
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
if (args.profile === 'auto') {
|
|
92
|
-
args.profile = args.target.includes('pantheon-sandbox') ? 'sandbox' : 'global'
|
|
93
|
-
}
|
|
94
|
-
|
|
95
91
|
// Resolve to absolute
|
|
96
92
|
if (!args.target.startsWith('/')) {
|
|
97
93
|
args.target = join(process.cwd(), args.target)
|
|
98
94
|
}
|
|
99
95
|
|
|
96
|
+
if (args.profile === 'auto') {
|
|
97
|
+
const target = resolve(args.target)
|
|
98
|
+
// A checkout is not a managed OpenCode installation: its repository
|
|
99
|
+
// config intentionally contains only development MCPs. Use the lite
|
|
100
|
+
// policy for `npm run doctor` so a fresh checkout does not require a
|
|
101
|
+
// user's global MCP registry or pre-existing venv.
|
|
102
|
+
args.profile =
|
|
103
|
+
target === resolve(ROOT)
|
|
104
|
+
? 'lite'
|
|
105
|
+
: args.target.includes('pantheon-sandbox')
|
|
106
|
+
? 'sandbox'
|
|
107
|
+
: 'global'
|
|
108
|
+
}
|
|
109
|
+
|
|
100
110
|
return args
|
|
101
111
|
}
|
|
102
112
|
|
|
@@ -159,7 +169,7 @@ function showHelp() {
|
|
|
159
169
|
C. Permission Checks — frontmatter validation
|
|
160
170
|
D. OpenCode Status — OpenCode artifact layout
|
|
161
171
|
E. Git Status — uncommitted changes
|
|
162
|
-
|
|
172
|
+
F. Runtime Layer — runtime Python + pinned pip dependencies
|
|
163
173
|
H3. Plugin Drift — registered plugin version vs this package
|
|
164
174
|
K. Node Runtime — node:sqlite availability for pantheon_cost
|
|
165
175
|
|
|
@@ -818,12 +828,14 @@ function checkGitStatus(args) {
|
|
|
818
828
|
}
|
|
819
829
|
|
|
820
830
|
// ---------------------------------------------------------------------------
|
|
821
|
-
// Check F: Runtime Layer —
|
|
831
|
+
// Check F: Runtime Layer — Runtime Python & pinned dependencies
|
|
822
832
|
// ---------------------------------------------------------------------------
|
|
823
833
|
// The MCP servers run on the venv Python under ~/.config/opencode/.venv
|
|
824
|
-
// (same resolution as scripts/install-mcp.mjs)
|
|
825
|
-
//
|
|
826
|
-
//
|
|
834
|
+
// (same resolution as scripts/install-mcp.mjs) after installation. A
|
|
835
|
+
// repository checkout/CI job is not an installed target, however, so it may
|
|
836
|
+
// intentionally have no venv. In that case the doctor uses PANTHEON_PYTHON or
|
|
837
|
+
// python3 and still validates the exact pins. Missing or mismatched packages
|
|
838
|
+
// remain blocking errors; the fallback never makes the runtime check advisory
|
|
827
839
|
// (issue #18 / #21).
|
|
828
840
|
|
|
829
841
|
/** Parse `pip freeze` output into a normalized name → version map. */
|
|
@@ -906,31 +918,53 @@ function checkRequirementLine(line, installed) {
|
|
|
906
918
|
}
|
|
907
919
|
}
|
|
908
920
|
|
|
921
|
+
/** Classify whether a required runtime file is available to the doctor. */
|
|
922
|
+
export function classifyRequiredRuntimeFile(exists) {
|
|
923
|
+
return exists ? 'present' : 'error'
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** Validate a required runtime requirements file without downgrading absence. */
|
|
927
|
+
export function validateRequiredRuntimeFile(filePath) {
|
|
928
|
+
const present = classifyRequiredRuntimeFile(existsSync(filePath)) === 'present'
|
|
929
|
+
return {
|
|
930
|
+
ok: present,
|
|
931
|
+
message: present
|
|
932
|
+
? `requirements file present: ${filePath}`
|
|
933
|
+
: `requirements file not found: ${filePath}`,
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
909
937
|
function checkVenvLayer(args) {
|
|
910
|
-
section('F. Runtime Layer —
|
|
938
|
+
section('F. Runtime Layer — Runtime Python & Dependencies')
|
|
911
939
|
|
|
912
|
-
const
|
|
940
|
+
const runtimePython = resolveRuntimePython(args)
|
|
913
941
|
|
|
914
|
-
if (!existsSync(
|
|
915
|
-
if (
|
|
916
|
-
info(
|
|
942
|
+
if (!isAbsolute(runtimePython) || !existsSync(runtimePython)) {
|
|
943
|
+
if (runtimePython === 'python3') {
|
|
944
|
+
info('No dedicated .venv found — validating the Python interpreter on PATH')
|
|
917
945
|
} else {
|
|
918
|
-
error(
|
|
946
|
+
error(
|
|
947
|
+
`Runtime Python not found: ${runtimePython} (set PANTHEON_PYTHON or run \`pantheon-opencode init\` to create a .venv)`,
|
|
948
|
+
)
|
|
949
|
+
return
|
|
919
950
|
}
|
|
920
|
-
return
|
|
921
951
|
}
|
|
922
|
-
|
|
952
|
+
if (isAbsolute(runtimePython) && existsSync(runtimePython)) {
|
|
953
|
+
pass(`Runtime Python exists: ${runtimePython}`)
|
|
954
|
+
}
|
|
923
955
|
|
|
924
|
-
const probe = spawn(
|
|
956
|
+
const probe = spawn(runtimePython, ['-c', 'import sys; print(sys.version.split()[0])'])
|
|
925
957
|
if (probe.status !== 0) {
|
|
926
|
-
error(
|
|
958
|
+
error(
|
|
959
|
+
`Runtime Python does not respond (exit ${probe.status}): ${probe.stderr || 'interpreter is unavailable'}`,
|
|
960
|
+
)
|
|
927
961
|
return
|
|
928
962
|
}
|
|
929
|
-
pass(`
|
|
963
|
+
pass(`Runtime Python responds (Python ${probe.stdout})`)
|
|
930
964
|
|
|
931
|
-
const freeze = spawn(
|
|
965
|
+
const freeze = spawn(runtimePython, ['-m', 'pip', 'freeze'])
|
|
932
966
|
if (freeze.status !== 0) {
|
|
933
|
-
|
|
967
|
+
error(`pip freeze failed (exit ${freeze.status}) — cannot verify pinned versions`)
|
|
934
968
|
return
|
|
935
969
|
}
|
|
936
970
|
const installed = parsePipFreeze(freeze.stdout)
|
|
@@ -940,8 +974,9 @@ function checkVenvLayer(args) {
|
|
|
940
974
|
join(ROOT, 'src', 'mcp', 'requirements-vision.txt'),
|
|
941
975
|
]
|
|
942
976
|
for (const reqFile of reqFiles) {
|
|
943
|
-
|
|
944
|
-
|
|
977
|
+
const requirement = validateRequiredRuntimeFile(reqFile)
|
|
978
|
+
if (!requirement.ok) {
|
|
979
|
+
error(requirement.message)
|
|
945
980
|
continue
|
|
946
981
|
}
|
|
947
982
|
const base = basename(reqFile)
|
|
@@ -1073,7 +1108,9 @@ export function validateCodeModeManifest(codeModeDir) {
|
|
|
1073
1108
|
|
|
1074
1109
|
const actualScripts = readdirSync(codeModeDir).filter(
|
|
1075
1110
|
(name) =>
|
|
1076
|
-
|
|
1111
|
+
!name.startsWith('eval-') &&
|
|
1112
|
+
(name.endsWith('.py') || name.endsWith('.sh')) &&
|
|
1113
|
+
statSync(join(codeModeDir, name)).isFile(),
|
|
1077
1114
|
)
|
|
1078
1115
|
const missing = listed.filter((name) => !existsSync(join(codeModeDir, name)))
|
|
1079
1116
|
const extra = actualScripts.filter((name) => !Object.hasOwn(data.scripts, name))
|
|
@@ -1104,12 +1141,32 @@ export function checkCodeModeManifest(args) {
|
|
|
1104
1141
|
pass(`✅ Code-mode manifest valid: ${result.count}/${result.total} scripts verified`)
|
|
1105
1142
|
}
|
|
1106
1143
|
|
|
1107
|
-
/**
|
|
1144
|
+
/**
|
|
1145
|
+
* Resolve the Python used by the runtime checks.
|
|
1146
|
+
*
|
|
1147
|
+
* Installed targets use the venv created by init. A checkout in CI does not
|
|
1148
|
+
* have that installation state, however, so the explicit PANTHEON_PYTHON
|
|
1149
|
+
* override and the PATH fallback make the contract reproducible without
|
|
1150
|
+
* silently skipping dependency validation.
|
|
1151
|
+
*/
|
|
1108
1152
|
export function resolveRuntimePython(args) {
|
|
1153
|
+
const env = args.env ?? process.env
|
|
1154
|
+
if (typeof env.PANTHEON_PYTHON === 'string' && env.PANTHEON_PYTHON.trim() !== '') {
|
|
1155
|
+
return env.PANTHEON_PYTHON.trim()
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1109
1158
|
const target = resolve(args.target)
|
|
1110
1159
|
const isProject = existsSync(join(target, '.opencode'))
|
|
1111
|
-
const runtimeRoot = isProject ? target : resolveOpenCodeConfigDir(
|
|
1112
|
-
|
|
1160
|
+
const runtimeRoot = isProject ? target : resolveOpenCodeConfigDir(env)
|
|
1161
|
+
const venvCandidates =
|
|
1162
|
+
process.platform === 'win32'
|
|
1163
|
+
? [
|
|
1164
|
+
join(runtimeRoot, '.venv', 'Scripts', 'python.exe'),
|
|
1165
|
+
join(runtimeRoot, '.venv', 'Scripts', 'python'),
|
|
1166
|
+
]
|
|
1167
|
+
: [join(runtimeRoot, '.venv', 'bin', 'python3'), join(runtimeRoot, '.venv', 'bin', 'python')]
|
|
1168
|
+
const existing = venvCandidates.find((candidate) => existsSync(candidate))
|
|
1169
|
+
return existing ?? 'python3'
|
|
1113
1170
|
}
|
|
1114
1171
|
|
|
1115
1172
|
/** Return relative script arguments as absolute paths using an MCP entry cwd. */
|
|
@@ -116,7 +116,7 @@ const PROVIDER_RENAME_REVERSE = Object.fromEntries(
|
|
|
116
116
|
* V2 shape:
|
|
117
117
|
* { package: "aisdk:ai-sdk-openai", settings: { baseURL: "...", apiKey: "..." }, models: { ... } }
|
|
118
118
|
*/
|
|
119
|
-
function convertProviderV1toV2(
|
|
119
|
+
function convertProviderV1toV2(_name, config) {
|
|
120
120
|
const result = { ...config }
|
|
121
121
|
|
|
122
122
|
// npm → package (with aisdk: prefix)
|
|
@@ -171,7 +171,7 @@ function convertProviderV1toV2(name, config) {
|
|
|
171
171
|
/**
|
|
172
172
|
* Convert a single V2 provider config back to V1.
|
|
173
173
|
*/
|
|
174
|
-
function convertProviderV2toV1(
|
|
174
|
+
function convertProviderV2toV1(_name, config) {
|
|
175
175
|
const result = { ...config }
|
|
176
176
|
|
|
177
177
|
// package → npm (strip aisdk: prefix)
|