pantheon-opencode 1.6.0-beta.1 → 1.6.0-beta.11
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/AGENTS.global.md +206 -0
- package/AGENTS.md +10 -891
- package/CHANGELOG.md +350 -0
- package/README.md +561 -52
- package/README.pt-BR.md +285 -20
- package/bin/pantheon-init.mjs +70 -4
- package/commands/pantheon-audit.md +7 -1
- package/commands/pantheon-consolidate.md +1 -1
- package/commands/pantheon-deepwork.md +14 -3
- package/commands/pantheon-focus.md +17 -0
- package/commands/pantheon-vision.md +61 -12
- package/package.json +24 -17
- package/plugin.json +2 -2
- package/pyproject.toml +8 -6
- package/scripts/build-agents-md.mjs +428 -56
- package/scripts/changelog-merge.mjs +161 -0
- package/scripts/{code_mode_server.py → code_mode.py} +3 -3
- package/scripts/cost.mjs +244 -41
- package/scripts/doctor.mjs +151 -1
- package/scripts/install/agents-md.mjs +4 -4
- package/scripts/install/backup.mjs +475 -0
- package/scripts/install/cli-ui.mjs +6 -1
- package/scripts/install/config-migration.mjs +317 -4
- package/scripts/install/exit-codes.mjs +26 -0
- package/scripts/install/health-check.mjs +21 -19
- package/scripts/install/interactive.mjs +16 -2
- package/scripts/install/migrate.mjs +41 -22
- package/scripts/install/opencode-version.mjs +246 -4
- package/scripts/install/opencode.mjs +1175 -215
- package/scripts/install/plan.mjs +287 -0
- package/scripts/install/plugin.mjs +62 -14
- package/scripts/install/shared.mjs +190 -48
- package/scripts/install/strings.mjs +20 -0
- package/scripts/install/venv.mjs +13 -3
- package/scripts/install/vision-prompt.mjs +171 -0
- package/scripts/install-mcp.mjs +28 -9
- package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
- package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
- package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
- package/scripts/probe-context-rehydrate.mjs +8 -2
- package/scripts/probe-pantheon-cost.mjs +71 -15
- package/scripts/prune.mjs +9 -3
- package/scripts/restore.mjs +222 -0
- package/scripts/run-node-tests.mjs +189 -0
- package/scripts/sync-artifacts.mjs +26 -13
- package/scripts/sync-tui.mjs +24 -3
- package/scripts/test-opencode-v2-sandbox.sh +180 -26
- package/scripts/validate-routing.mjs +3 -3
- package/scripts/validate-sandbox-install.mjs +138 -0
- package/scripts/versioning.mjs +13 -1
- package/scripts/vision-status.mjs +682 -0
- package/skills-lock.json +8 -8
- package/src/agents/aphrodite.md +20 -37
- package/src/agents/apollo.md +7 -3
- package/src/agents/athena.md +63 -35
- package/src/agents/demeter.md +8 -28
- package/src/agents/gaia.md +1 -1
- package/src/agents/hephaestus.md +10 -21
- package/src/agents/hermes.md +15 -38
- package/src/agents/iris.md +7 -1
- package/src/agents/mnemosyne.md +18 -54
- package/src/agents/nyx.md +1 -1
- package/src/agents/prometheus.md +16 -20
- package/src/agents/talos.md +10 -2
- package/src/agents/themis.md +5 -5
- package/src/agents/zeus.md +99 -172
- package/src/instructions/agent-return-format.instructions.md +34 -17
- package/src/instructions/backend-standards.instructions.md +2 -0
- package/src/instructions/frontend-standards.instructions.md +3 -1
- package/src/instructions/memory-protocol.instructions.md +12 -110
- package/src/instructions/test-economy.instructions.md +21 -0
- package/src/instructions/zeus-anti-stall.instructions.md +20 -67
- package/src/instructions/zeus-communication-rules.instructions.md +0 -1
- package/src/instructions/zeus-council-synthesis.instructions.md +31 -260
- package/src/instructions/zeus-memory-operations.instructions.md +30 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +61 -89
- package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
- package/src/mcp/eval_store.py +2 -2
- package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
- package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
- package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
- package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
- package/src/mcp/requirements-mcp.txt +6 -4
- package/src/mcp/requirements-vision.txt +2 -2
- package/src/pantheon/cost-command.ts +403 -102
- package/src/pantheon/delegation-enforce.ts +10 -1
- package/src/pantheon/model-agents.ts +19 -0
- package/src/pantheon/model-command.ts +218 -56
- package/src/pantheon/presets.d.mts +0 -8
- package/src/pantheon/presets.mjs +5 -32
- package/src/pantheon/v2-bounded-read.ts +640 -0
- package/src/pantheon/v2-delegation-enforce.ts +214 -0
- package/src/pantheon/v2-execute-after.ts +186 -0
- package/src/pantheon/v2-model-overrides.ts +275 -0
- package/src/pantheon/v2-tools.ts +418 -0
- package/src/pantheon/v2-unsupported.d.mts +11 -0
- package/src/pantheon/v2-unsupported.mjs +90 -0
- package/src/pantheon/v2-vision.ts +622 -0
- package/src/pantheon/vision-local.ts +531 -0
- package/src/pantheon/vision.ts +1160 -115
- package/src/plugin-v2.ts +1158 -219
- package/src/plugin.ts +12 -9
- package/src/plugins/tui/dist/server.d.ts +1 -2
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
- package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
- package/src/plugins/tui/dist/tui-v2.js +659 -0
- package/src/plugins/tui/dist/tui-v2.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +85 -82
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +72 -59
- package/src/plugins/tui/index-v2.ts +6 -0
- package/src/plugins/tui/package-lock.json +5516 -1353
- package/src/plugins/tui/package.json +22 -12
- package/src/plugins/tui/src/index-v2.tsx +734 -0
- package/src/plugins/tui/src/index.tsx +106 -85
- package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
- package/src/plugins/tui/tsconfig.build.json +5 -0
- package/src/plugins/tui/tsconfig.typecheck.json +5 -0
- package/src/plugins/tui/tsdown.config.ts +22 -2
- package/src/plugins/tui/tui-v2.ts +2 -0
- package/src/plugins/tui/tui.ts +3 -0
- package/src/routing.yml +11 -21
- package/src/skills/README.md +3 -3
- package/src/skills/agent-coordination/SKILL.md +7 -2
- package/src/skills/artifact-management/SKILL.md +12 -5
- package/src/skills/auto-continue/SKILL.md +52 -35
- package/src/skills/code-review-checklist/SKILL.md +5 -5
- package/src/skills/context-compression/SKILL.md +26 -879
- package/src/skills/session-goal/SKILL.md +14 -5
- package/src/skills/spec-driven-development/SKILL.md +3 -1
- package/src/skills/tdd-with-agents/SKILL.md +18 -10
- package/opencode.json +0 -58
package/scripts/install-mcp.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { homedir } from 'node:os'
|
|
|
24
24
|
import { basename, dirname, join, resolve } from 'node:path'
|
|
25
25
|
import { createInterface } from 'node:readline'
|
|
26
26
|
import { fileURLToPath } from 'node:url'
|
|
27
|
+
import { migrateMcpEntrypointPaths } from './install/config-migration.mjs'
|
|
27
28
|
|
|
28
29
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
29
30
|
const ROOT = join(__dirname, '..')
|
|
@@ -70,11 +71,11 @@ function resolveServerScript(relPath) {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
const VENV_PYTHON = resolveVenvPython()
|
|
73
|
-
const MCP_RESOURCES_SERVER = resolveServerScript('scripts/
|
|
74
|
-
const CODE_MODE_SERVER = resolveServerScript('scripts/
|
|
75
|
-
const MEMORY_MCP_SERVER = resolveServerScript('scripts/
|
|
76
|
-
const MCP_PERSISTENCE_SERVER = resolveServerScript('scripts/
|
|
77
|
-
const PANTHEON_VISION_SERVER = resolveServerScript('src/mcp/
|
|
74
|
+
const MCP_RESOURCES_SERVER = resolveServerScript('scripts/mcp_resources.py')
|
|
75
|
+
const CODE_MODE_SERVER = resolveServerScript('scripts/code_mode.py')
|
|
76
|
+
const MEMORY_MCP_SERVER = resolveServerScript('scripts/memory_mcp.py')
|
|
77
|
+
const MCP_PERSISTENCE_SERVER = resolveServerScript('scripts/mcp_persistence.py')
|
|
78
|
+
const PANTHEON_VISION_SERVER = resolveServerScript('src/mcp/pantheon_vision.py')
|
|
78
79
|
|
|
79
80
|
// ---------------------------------------------------------------------------
|
|
80
81
|
// MCP Definitions
|
|
@@ -209,7 +210,7 @@ export const MCPS = {
|
|
|
209
210
|
if (existsSync(scriptPath)) {
|
|
210
211
|
return { ok: true, message: 'server script present' }
|
|
211
212
|
}
|
|
212
|
-
return { ok: false, message: '
|
|
213
|
+
return { ok: false, message: 'mcp_resources.py not found at resolved path' }
|
|
213
214
|
},
|
|
214
215
|
},
|
|
215
216
|
'pantheon-code-mode': {
|
|
@@ -230,7 +231,7 @@ export const MCPS = {
|
|
|
230
231
|
if (existsSync(scriptPath)) {
|
|
231
232
|
return { ok: true, message: 'server script present' }
|
|
232
233
|
}
|
|
233
|
-
return { ok: false, message: '
|
|
234
|
+
return { ok: false, message: 'code_mode.py not found at resolved path' }
|
|
234
235
|
},
|
|
235
236
|
},
|
|
236
237
|
'pantheon-memory': {
|
|
@@ -251,7 +252,7 @@ export const MCPS = {
|
|
|
251
252
|
if (existsSync(scriptPath)) {
|
|
252
253
|
return { ok: true, message: 'server script present' }
|
|
253
254
|
}
|
|
254
|
-
return { ok: false, message: '
|
|
255
|
+
return { ok: false, message: 'memory_mcp.py not found at resolved path' }
|
|
255
256
|
},
|
|
256
257
|
},
|
|
257
258
|
'pantheon-persistence': {
|
|
@@ -272,7 +273,7 @@ export const MCPS = {
|
|
|
272
273
|
if (existsSync(scriptPath)) {
|
|
273
274
|
return { ok: true, message: 'server script present' }
|
|
274
275
|
}
|
|
275
|
-
return { ok: false, message: '
|
|
276
|
+
return { ok: false, message: 'mcp_persistence.py not found at resolved path' }
|
|
276
277
|
},
|
|
277
278
|
},
|
|
278
279
|
'pantheon-vision': {
|
|
@@ -471,8 +472,22 @@ function writeMcpConfig(platformName, mcpKey, dryRun, force) {
|
|
|
471
472
|
config[key] = {}
|
|
472
473
|
}
|
|
473
474
|
|
|
475
|
+
// Heal pre-rename MCP entrypoint paths before the skip check, so re-running
|
|
476
|
+
// the installer repairs an existing config whose `*_server.py` paths were
|
|
477
|
+
// orphaned by the entrypoint rename. Without this the rename is a silent
|
|
478
|
+
// break for every already-installed config (see config-migration.mjs).
|
|
479
|
+
const healed = migrateMcpEntrypointPaths(config[key])
|
|
480
|
+
|
|
474
481
|
// Check if the MCP already has an entry
|
|
475
482
|
if (config[key][mcpKey] && !force) {
|
|
483
|
+
if (healed > 0 && !dryRun) {
|
|
484
|
+
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
|
|
485
|
+
return {
|
|
486
|
+
status: 'healed',
|
|
487
|
+
path: configPath,
|
|
488
|
+
reason: `Updated ${healed} stale MCP entrypoint path(s) in ${platform.configFile}`,
|
|
489
|
+
}
|
|
490
|
+
}
|
|
476
491
|
return {
|
|
477
492
|
status: 'skipped',
|
|
478
493
|
reason: `Already exists in ${platform.configFile} (use --force to overwrite)`,
|
|
@@ -705,6 +720,10 @@ function logResult(platformLabel, mcpKey, result) {
|
|
|
705
720
|
console.log(` ⏭️ ${label} — ${result.reason}`)
|
|
706
721
|
progress.skipped.push({ platform: platformLabel, mcp: mcpKey, reason: result.reason })
|
|
707
722
|
break
|
|
723
|
+
case 'healed':
|
|
724
|
+
console.log(` 🩹 ${label} — ${result.reason}`)
|
|
725
|
+
progress.installed.push({ platform: platformLabel, mcp: mcpKey })
|
|
726
|
+
break
|
|
708
727
|
case 'dry-run': {
|
|
709
728
|
const desc =
|
|
710
729
|
result.entry?.type === 'remote' || result.entry?.type === 'http'
|
|
@@ -5,7 +5,7 @@ Key-Value store with FTS5 full-text search, TTL-based expiration,
|
|
|
5
5
|
and namespace isolation. Uses SQLite with zero external dependencies.
|
|
6
6
|
|
|
7
7
|
Usage:
|
|
8
|
-
python scripts/
|
|
8
|
+
python scripts/mcp_persistence.py
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
@@ -25,7 +25,7 @@ from pathlib import Path
|
|
|
25
25
|
from typing import Annotated
|
|
26
26
|
|
|
27
27
|
from _pantheon_paths import pantheon_home, pantheon_project
|
|
28
|
-
from
|
|
28
|
+
from fastmcp import FastMCP
|
|
29
29
|
from pydantic import Field
|
|
30
30
|
|
|
31
31
|
# ── Schema ──────────────────────────────────────────────────────────────────────
|
|
@@ -914,15 +914,35 @@ def _validate_delegations(delegations: object) -> None:
|
|
|
914
914
|
|
|
915
915
|
def _validate_tail(tail: object, payload_bytes: int | None = None) -> list[str]:
|
|
916
916
|
"""Validate bounded serialized tail lines."""
|
|
917
|
-
if not isinstance(tail, list)
|
|
918
|
-
raise ValueError("tail
|
|
917
|
+
if not isinstance(tail, list):
|
|
918
|
+
raise ValueError("tail must be an array")
|
|
919
|
+
if len(tail) > MAX_TAIL_ITEMS:
|
|
920
|
+
raise ValueError(
|
|
921
|
+
f"tail has {len(tail)} items; maximum is {MAX_TAIL_ITEMS}; "
|
|
922
|
+
"remove tail entries or omit tail"
|
|
923
|
+
)
|
|
919
924
|
for line in tail:
|
|
920
|
-
if not isinstance(line, str)
|
|
921
|
-
raise ValueError("tail entries
|
|
925
|
+
if not isinstance(line, str):
|
|
926
|
+
raise ValueError("tail entries must be strings")
|
|
927
|
+
line_bytes = len(line.encode())
|
|
928
|
+
if line_bytes > MAX_TAIL_LINE_LENGTH:
|
|
929
|
+
raise ValueError(
|
|
930
|
+
f"tail entries are {line_bytes:,} UTF-8 bytes; maximum is "
|
|
931
|
+
f"{MAX_TAIL_LINE_LENGTH:,}; remove or shorten tail entries, "
|
|
932
|
+
"or omit tail"
|
|
933
|
+
)
|
|
922
934
|
if payload_bytes is not None and payload_bytes > MAX_CONTEXT_CONTENT_BYTES:
|
|
923
|
-
raise ValueError(
|
|
924
|
-
|
|
925
|
-
|
|
935
|
+
raise ValueError(
|
|
936
|
+
f"tail payload is {payload_bytes:,} UTF-8 bytes; maximum is "
|
|
937
|
+
f"{MAX_CONTEXT_CONTENT_BYTES:,}; remove tail entries or omit tail"
|
|
938
|
+
)
|
|
939
|
+
tail_bytes = sum(len(line.encode()) for line in tail)
|
|
940
|
+
if tail_bytes > MAX_CONTEXT_CONTENT_BYTES:
|
|
941
|
+
raise ValueError(
|
|
942
|
+
f"tail contains {tail_bytes:,} UTF-8 bytes across strings; maximum "
|
|
943
|
+
f"is {MAX_CONTEXT_CONTENT_BYTES:,} bytes; remove tail entries or "
|
|
944
|
+
"omit tail"
|
|
945
|
+
)
|
|
926
946
|
return tail
|
|
927
947
|
|
|
928
948
|
|
|
@@ -1020,8 +1040,14 @@ def _collect_context_content_errors(
|
|
|
1020
1040
|
if not isinstance(content, str):
|
|
1021
1041
|
errors.shape(prefix, "a string", content)
|
|
1022
1042
|
return
|
|
1023
|
-
|
|
1024
|
-
|
|
1043
|
+
content_bytes = len(content.encode())
|
|
1044
|
+
if content_bytes > MAX_CONTEXT_CONTENT_BYTES:
|
|
1045
|
+
errors.add(
|
|
1046
|
+
prefix,
|
|
1047
|
+
f"is {content_bytes:,} UTF-8 bytes; maximum is "
|
|
1048
|
+
f"{MAX_CONTEXT_CONTENT_BYTES:,} bytes. Reduce content or omit "
|
|
1049
|
+
"optional tail if it contributes",
|
|
1050
|
+
)
|
|
1025
1051
|
return
|
|
1026
1052
|
try:
|
|
1027
1053
|
parsed = json.loads(content)
|
|
@@ -1030,11 +1056,10 @@ def _collect_context_content_errors(
|
|
|
1030
1056
|
# it as a checkpoint. It is still bounded above.
|
|
1031
1057
|
return
|
|
1032
1058
|
if isinstance(parsed, list) and key == "tail":
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
errors.add(f"{prefix}.tail", "exceeds its size limit")
|
|
1059
|
+
try:
|
|
1060
|
+
_validate_tail(parsed)
|
|
1061
|
+
except ValueError as exc:
|
|
1062
|
+
errors.reroot(prefix, str(exc))
|
|
1038
1063
|
return
|
|
1039
1064
|
if not isinstance(parsed, dict):
|
|
1040
1065
|
# The message already names 'content': do not prepend the path again.
|
|
@@ -1164,6 +1189,11 @@ def _upsert_context_entry(
|
|
|
1164
1189
|
name="context_save",
|
|
1165
1190
|
description="Save a context checkpoint for a session/phase. "
|
|
1166
1191
|
"Stores structured JSON in persistence KV with a key-specific TTL. "
|
|
1192
|
+
"The serialized content is limited to 65,536 UTF-8 bytes. An included "
|
|
1193
|
+
"tail may contain at most 100 strings, each at most 4,096 UTF-8 bytes, "
|
|
1194
|
+
"with at most 65,536 UTF-8 bytes across all tail strings. Oversized "
|
|
1195
|
+
"content or tail is rejected, never truncated; remove or omit optional "
|
|
1196
|
+
"tail data when applicable. "
|
|
1167
1197
|
"content is a JSON STRING; when it parses to an object, 'goal', 'phase', "
|
|
1168
1198
|
"'delegations' and 'heartbeat' must each be JSON OBJECTS and 'tail' must "
|
|
1169
1199
|
"be a JSON ARRAY. 'phase' is an object {current,total,name} — NOT a phase "
|
|
@@ -1180,7 +1210,12 @@ async def context_save(
|
|
|
1180
1210
|
str,
|
|
1181
1211
|
Field(
|
|
1182
1212
|
description=(
|
|
1183
|
-
"JSON checkpoint string
|
|
1213
|
+
"JSON checkpoint string, limited to 65,536 UTF-8 bytes. An "
|
|
1214
|
+
"included 'tail' may contain at most 100 strings, each at "
|
|
1215
|
+
"most 4,096 UTF-8 bytes, with at most 65,536 UTF-8 bytes "
|
|
1216
|
+
"across all tail strings. Oversized input is rejected, never "
|
|
1217
|
+
"truncated; remove or omit optional 'tail' when applicable. "
|
|
1218
|
+
"When it parses to an object, 'goal', "
|
|
1184
1219
|
"'phase', 'delegations' and 'heartbeat' must be objects and "
|
|
1185
1220
|
"'tail' must be an array; all are optional. 'phase' is an "
|
|
1186
1221
|
"object {current,total,name}, not a counter. Example: "
|
|
@@ -7,7 +7,7 @@ Provides MCP resources for the Pantheon agent framework:
|
|
|
7
7
|
plans/status, memory-bank files
|
|
8
8
|
|
|
9
9
|
Usage:
|
|
10
|
-
python scripts/
|
|
10
|
+
python scripts/mcp_resources.py
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
@@ -19,7 +19,7 @@ from typing import Any
|
|
|
19
19
|
import yaml
|
|
20
20
|
from _pantheon_paths import has_symlink_component, pantheon_home, pantheon_project
|
|
21
21
|
from eval_store import get_latest_eval, list_evals
|
|
22
|
-
from
|
|
22
|
+
from fastmcp import FastMCP
|
|
23
23
|
|
|
24
24
|
# ── Path Resolution ──────────────────────────────────────────────────────────
|
|
25
25
|
_PANTHEON_HOME: Path = pantheon_home()
|
|
@@ -19,7 +19,7 @@ Tools:
|
|
|
19
19
|
memory_stats — DB statistics
|
|
20
20
|
|
|
21
21
|
Usage:
|
|
22
|
-
python scripts/
|
|
22
|
+
python scripts/memory_mcp.py
|
|
23
23
|
"""
|
|
24
24
|
|
|
25
25
|
from __future__ import annotations
|
|
@@ -33,7 +33,7 @@ from pathlib import Path
|
|
|
33
33
|
from typing import Annotated, Any
|
|
34
34
|
|
|
35
35
|
from _pantheon_paths import pantheon_home
|
|
36
|
-
from
|
|
36
|
+
from fastmcp import FastMCP
|
|
37
37
|
from pydantic import BeforeValidator, Field
|
|
38
38
|
|
|
39
39
|
# ── Paths ─────────────────────────────────────────────────────────────────────
|
|
@@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
15
15
|
|
|
16
16
|
const requestedVersion = valueFor('--version')
|
|
17
17
|
const jsonOutput = process.argv.includes('--json')
|
|
18
|
-
const runtime = fileURLToPath(new URL('./
|
|
18
|
+
const runtime = fileURLToPath(new URL('./mcp_persistence.py', import.meta.url))
|
|
19
19
|
|
|
20
20
|
function valueFor(name) {
|
|
21
21
|
const index = process.argv.indexOf(name)
|
|
@@ -63,6 +63,12 @@ spec.loader.exec_module(module)
|
|
|
63
63
|
|
|
64
64
|
async def call(name, arguments):
|
|
65
65
|
result = await module.mcp.call_tool(name, arguments)
|
|
66
|
+
structured = getattr(result, "structured_content", None)
|
|
67
|
+
if structured is not None:
|
|
68
|
+
metadata = getattr(result, "meta", None) or {}
|
|
69
|
+
if metadata.get("fastmcp", {}).get("wrap_result"):
|
|
70
|
+
return structured.get("result") if isinstance(structured, dict) else structured
|
|
71
|
+
return structured
|
|
66
72
|
if isinstance(result, tuple):
|
|
67
73
|
structured = result[1]
|
|
68
74
|
if structured is not None:
|
|
@@ -186,7 +192,7 @@ async function main() {
|
|
|
186
192
|
return
|
|
187
193
|
}
|
|
188
194
|
if (!existsSync(runtime)) {
|
|
189
|
-
unavailable('installed package does not include
|
|
195
|
+
unavailable('installed package does not include mcp_persistence.py')
|
|
190
196
|
return
|
|
191
197
|
}
|
|
192
198
|
|
|
@@ -56,11 +56,48 @@ function setEnvironment(values) {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Write the rows in the family the requested host generation actually uses.
|
|
61
|
+
*
|
|
62
|
+
* Call sites describe an assistant turn the way they always have
|
|
63
|
+
* (`role: 'assistant'`, plus a `phase`). This function translates that
|
|
64
|
+
* descriptor into what is on disk: for V2, `role` becomes the `type` COLUMN and
|
|
65
|
+
* `phase` is dropped, because a real V2 payload carries neither — measured, the
|
|
66
|
+
* `role` key is absent from 40052 of 40052 rows on a host 2.0.22 database.
|
|
67
|
+
*
|
|
68
|
+
* Keeping that translation in ONE place is the point: a fixture that wrote
|
|
69
|
+
* `role` into a session_message row asserts the V1 format under a V2 name, which
|
|
70
|
+
* is exactly how the stale V1 predicate in the CLI mirror stayed green — the
|
|
71
|
+
* synthetic database agreed with the bug instead of contradicting it.
|
|
72
|
+
*/
|
|
59
73
|
function createSyntheticDb(DatabaseSync, path, rows) {
|
|
60
74
|
const db = new DatabaseSync(path)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
75
|
+
if (requestedVersion === 'v2') {
|
|
76
|
+
db.exec(
|
|
77
|
+
'CREATE TABLE session_message (id TEXT, session_id TEXT, type TEXT, seq INTEGER, time_created INTEGER, time_updated INTEGER, data TEXT)',
|
|
78
|
+
)
|
|
79
|
+
const insert = db.prepare(
|
|
80
|
+
'INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES (?,?,?,?,?,?,?)',
|
|
81
|
+
)
|
|
82
|
+
rows.forEach((row, index) => {
|
|
83
|
+
const payload = { ...row }
|
|
84
|
+
delete payload.role
|
|
85
|
+
delete payload.phase
|
|
86
|
+
insert.run(
|
|
87
|
+
`msg_${index + 1}`,
|
|
88
|
+
'ses_probe',
|
|
89
|
+
row.role,
|
|
90
|
+
index + 1,
|
|
91
|
+
Date.now(),
|
|
92
|
+
Date.now(),
|
|
93
|
+
JSON.stringify(payload),
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
} else {
|
|
97
|
+
db.exec('CREATE TABLE message (data TEXT NOT NULL, time_created INTEGER NOT NULL)')
|
|
98
|
+
const insert = db.prepare('INSERT INTO message (data, time_created) VALUES (?, ?)')
|
|
99
|
+
for (const data of rows) insert.run(JSON.stringify(data), Date.now())
|
|
100
|
+
}
|
|
64
101
|
db.close()
|
|
65
102
|
}
|
|
66
103
|
|
|
@@ -129,7 +166,10 @@ async function main() {
|
|
|
129
166
|
const root = mkdtempSync(join(tmpdir(), 'pantheon-cost-probe-'))
|
|
130
167
|
const dataHome = join(root, 'data')
|
|
131
168
|
const opencodeDir = join(dataHome, 'opencode')
|
|
132
|
-
|
|
169
|
+
// Every real installation writes `opencode.db`; the v1/v2 distinction is the
|
|
170
|
+
// SCHEMA of that file, never its name. Deriving `opencode-v2.db` here made the
|
|
171
|
+
// probe assert a path that exists on no real machine.
|
|
172
|
+
const selectedDb = join(opencodeDir, 'opencode.db')
|
|
133
173
|
const otherDb = join(root, 'other.db')
|
|
134
174
|
const envDb = join(root, 'env.db')
|
|
135
175
|
const explicitDb = join(root, 'explicit.db')
|
|
@@ -139,7 +179,9 @@ async function main() {
|
|
|
139
179
|
try {
|
|
140
180
|
mkdirSync(opencodeDir, { recursive: true })
|
|
141
181
|
// These rows intentionally include a monetary-looking field to prove it is
|
|
142
|
-
// ignored; only input/output/total tokens may appear in the report.
|
|
182
|
+
// ignored; only input/output/total tokens may appear in the report. Every
|
|
183
|
+
// V2 phase reads back as `unknown` because the V2 reader emits that literal:
|
|
184
|
+
// a V2 payload carries no phase field for a reader to recover.
|
|
143
185
|
const selectedRows =
|
|
144
186
|
requestedVersion === 'v2'
|
|
145
187
|
? [
|
|
@@ -224,8 +266,8 @@ async function main() {
|
|
|
224
266
|
selectedOutput,
|
|
225
267
|
requestedVersion === 'v2'
|
|
226
268
|
? [
|
|
227
|
-
{ agent: 'aphrodite', phase: '
|
|
228
|
-
{ agent: 'zeus', phase: '
|
|
269
|
+
{ agent: 'aphrodite', phase: 'unknown', input: 8, output: 10, total: 18 },
|
|
270
|
+
{ agent: 'zeus', phase: 'unknown', input: 4, output: 6, total: 10 },
|
|
229
271
|
]
|
|
230
272
|
: [
|
|
231
273
|
{ agent: 'hermes', phase: 'plan', input: 13, output: 24, total: 37 },
|
|
@@ -250,7 +292,12 @@ async function main() {
|
|
|
250
292
|
{},
|
|
251
293
|
{ sessionID: 'cost-probe' },
|
|
252
294
|
)
|
|
253
|
-
|
|
295
|
+
// V2 has no phase to report, so the same row reads back as 'unknown'.
|
|
296
|
+
requireOutput(
|
|
297
|
+
output,
|
|
298
|
+
`| env-agent | ${requestedVersion === 'v2' ? 'unknown' : 'override'} | 1 | 2 | 3 |`,
|
|
299
|
+
'PANTHEON_COST_DB',
|
|
300
|
+
)
|
|
254
301
|
if (output.includes('wrong-version') || output.includes('env-agent') === false) {
|
|
255
302
|
throw new Error('PANTHEON_COST_DB did not override the version-selected database')
|
|
256
303
|
}
|
|
@@ -269,7 +316,11 @@ async function main() {
|
|
|
269
316
|
{},
|
|
270
317
|
{ sessionID: 'cost-probe' },
|
|
271
318
|
)
|
|
272
|
-
requireOutput(
|
|
319
|
+
requireOutput(
|
|
320
|
+
output,
|
|
321
|
+
`| explicit-agent | ${requestedVersion === 'v2' ? 'unknown' : 'explicit'} | 9 | 1 | 10 |`,
|
|
322
|
+
'explicit dbPath',
|
|
323
|
+
)
|
|
273
324
|
if (output.includes('env-agent')) throw new Error('explicit dbPath did not take precedence')
|
|
274
325
|
checks.push('explicit dbPath precedence: PASS')
|
|
275
326
|
} finally {
|
|
@@ -287,11 +338,7 @@ async function main() {
|
|
|
287
338
|
{ sessionID: 'cost-probe' },
|
|
288
339
|
)
|
|
289
340
|
requireOutput(output, 'not found', 'missing DB')
|
|
290
|
-
requireOutput(
|
|
291
|
-
output,
|
|
292
|
-
requestedVersion === 'v2' ? 'opencode-v2.db' : 'opencode.db',
|
|
293
|
-
'selected missing DB path',
|
|
294
|
-
)
|
|
341
|
+
requireOutput(output, 'opencode.db', 'selected missing DB path')
|
|
295
342
|
checks.push('missing DB: PASS')
|
|
296
343
|
} finally {
|
|
297
344
|
missingRestore()
|
|
@@ -302,7 +349,16 @@ async function main() {
|
|
|
302
349
|
}).pantheon_cost.execute({}, { sessionID: 'cost-probe' })
|
|
303
350
|
requireOutput(incompatibleOutput, 'incompatible opencode.db schema', 'incompatible schema')
|
|
304
351
|
checks.push('incompatible schema: PASS')
|
|
305
|
-
|
|
352
|
+
// Names the synthetic fixture that was built, because the previous wording
|
|
353
|
+
// ("no real DB was read") was true of every run and therefore true of the run
|
|
354
|
+
// in which the V2 ledger was read as V1 and silently dropped. A verdict that
|
|
355
|
+
// cannot fail is not a verdict: it stayed green across the exact defect it
|
|
356
|
+
// was meant to cover.
|
|
357
|
+
emit(
|
|
358
|
+
'PASS',
|
|
359
|
+
`${checks.length} synthetic ${requestedVersion} checks passed against a temporary opencode.db (no user database was read)`,
|
|
360
|
+
checks,
|
|
361
|
+
)
|
|
306
362
|
} catch (error) {
|
|
307
363
|
emit('FAIL', diagnostic(error), checks)
|
|
308
364
|
} finally {
|
package/scripts/prune.mjs
CHANGED
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
* SAFETY: prune NEVER touches the active config, the active venv, the package
|
|
10
10
|
* repo, or the installed package. It only:
|
|
11
11
|
* - lists old global `pantheon` installs (suggests `npm uninstall -g pantheon`)
|
|
12
|
-
* - removes stale `opencode.json.bak*` backups (age > threshold) with
|
|
12
|
+
* - removes stale LEGACY `opencode.json.bak*` backups (age > threshold) with
|
|
13
|
+
* --apply. The modern installer no longer writes that sidecar — it creates an
|
|
14
|
+
* exact backup set under `.pantheon/backups/` (retained by the installer, not
|
|
15
|
+
* pruned here) — so this scan only cleans up pre-phase-2 installs.
|
|
13
16
|
* - lists legacy directories whose opencode.json references dead/relative
|
|
14
17
|
* paths (removal requires --apply --remove-dirs as explicit confirmation)
|
|
15
18
|
*
|
|
@@ -102,8 +105,11 @@ export function findLegacyGlobalInstalls() {
|
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
// ---------------------------------------------------------------------------
|
|
105
|
-
// (b) Stale opencode.json.bak* backups (age > threshold) in the config
|
|
106
|
-
// → removable with --apply. Fresh backups are listed as kept.
|
|
108
|
+
// (b) Stale LEGACY opencode.json.bak* backups (age > threshold) in the config
|
|
109
|
+
// dir → removable with --apply. Fresh backups are listed as kept. The
|
|
110
|
+
// current installer does not create this sidecar (it keeps an exact,
|
|
111
|
+
// self-retaining backup set under .pantheon/backups/), so this only cleans
|
|
112
|
+
// up installs from before phase 2.
|
|
107
113
|
// ---------------------------------------------------------------------------
|
|
108
114
|
|
|
109
115
|
export function findStaleBackups(configDir, maxAgeDays = DEFAULT_BACKUP_AGE_DAYS) {
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* restore.mjs — Pantheon exact-restore CLI (Beta 11 Setup, phase 2).
|
|
4
|
+
*
|
|
5
|
+
* Restores a backup set created by the installer back into the config dir,
|
|
6
|
+
* reproducing each file byte-for-byte and re-applying its recorded mode. The
|
|
7
|
+
* restore is itself reversible: the CURRENT state is backed up first.
|
|
8
|
+
*
|
|
9
|
+
* Safety contract:
|
|
10
|
+
* - the backup manifest is verified (hash of every backed-up file) before any
|
|
11
|
+
* restore; a tampered or incomplete set is refused outright;
|
|
12
|
+
* - the backup-vs-current diff is shown BY PATH ONLY (never file contents);
|
|
13
|
+
* - writing requires explicit authorization (`--yes`); a non-TTY run without
|
|
14
|
+
* `--yes` previews and exits without writing.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* pantheon-opencode restore [--project] [--from <dir>] [--dry-run] [--yes]
|
|
18
|
+
*
|
|
19
|
+
* Exit codes:
|
|
20
|
+
* 0 = restored (or preview shown with --dry-run)
|
|
21
|
+
* 1 = refused (missing/tampered backup, or no authorization)
|
|
22
|
+
* 2 = usage error
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { existsSync } from 'node:fs'
|
|
26
|
+
import { homedir } from 'node:os'
|
|
27
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
backupRoot,
|
|
31
|
+
createBackupSet,
|
|
32
|
+
isBackupDirName,
|
|
33
|
+
listBackupSets,
|
|
34
|
+
planRestore,
|
|
35
|
+
restoreBackupSet,
|
|
36
|
+
verifyBackupSet,
|
|
37
|
+
} from './install/backup.mjs'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the config dir whose `opencode.json` the installer manages.
|
|
41
|
+
* `--project` targets the CWD; otherwise XDG / PANTHEON_HOME.
|
|
42
|
+
* @param {{ project?: boolean }} opts
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
export function resolveConfigDir({ project = false } = {}) {
|
|
46
|
+
if (project) return resolve(process.cwd())
|
|
47
|
+
if (process.env.PANTHEON_HOME) return resolve(process.env.PANTHEON_HOME)
|
|
48
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
|
|
49
|
+
return join(base, 'opencode')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseArgs(argv) {
|
|
53
|
+
const args = { project: false, from: null, dryRun: false, yes: false, help: false }
|
|
54
|
+
for (let i = 0; i < argv.length; i++) {
|
|
55
|
+
const arg = argv[i]
|
|
56
|
+
switch (arg) {
|
|
57
|
+
case '--project':
|
|
58
|
+
args.project = true
|
|
59
|
+
break
|
|
60
|
+
case '--dry-run':
|
|
61
|
+
args.dryRun = true
|
|
62
|
+
break
|
|
63
|
+
case '--yes':
|
|
64
|
+
case '-y':
|
|
65
|
+
args.yes = true
|
|
66
|
+
break
|
|
67
|
+
case '--from':
|
|
68
|
+
args.from = argv[++i] ?? null
|
|
69
|
+
break
|
|
70
|
+
case '--help':
|
|
71
|
+
case '-h':
|
|
72
|
+
args.help = true
|
|
73
|
+
break
|
|
74
|
+
default:
|
|
75
|
+
break
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return args
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function showHelp() {
|
|
82
|
+
console.log(`
|
|
83
|
+
pantheon-opencode restore — restore an exact installer backup
|
|
84
|
+
|
|
85
|
+
Usage:
|
|
86
|
+
pantheon-opencode restore restore newest backup (global config)
|
|
87
|
+
pantheon-opencode restore --project restore newest backup (project)
|
|
88
|
+
pantheon-opencode restore --from <dir> restore a specific backup set
|
|
89
|
+
pantheon-opencode restore --dry-run preview only (writes nothing)
|
|
90
|
+
pantheon-opencode restore --yes authorize the write (non-interactive)
|
|
91
|
+
|
|
92
|
+
A backup set lives under <config-dir>/.pantheon/backups/<timestamp>/.
|
|
93
|
+
Restore reproduces each file byte-for-byte and re-applies its recorded mode.
|
|
94
|
+
File contents are never printed.
|
|
95
|
+
`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Choose the backup set to restore.
|
|
100
|
+
* @param {string} configDir
|
|
101
|
+
* @param {string|null} from
|
|
102
|
+
* @returns {{ dir: string, name: string } | null}
|
|
103
|
+
*/
|
|
104
|
+
export function pickBackupSet(configDir, from) {
|
|
105
|
+
if (from) {
|
|
106
|
+
// Accept either a bare timestamp set name (resolved under the backups root)
|
|
107
|
+
// or an explicit directory path.
|
|
108
|
+
if (isBackupDirName(from)) {
|
|
109
|
+
const dir = join(backupRoot(configDir), from)
|
|
110
|
+
return existsSync(dir) ? { dir, name: from } : null
|
|
111
|
+
}
|
|
112
|
+
const dir = isAbsolute(from) ? from : resolve(process.cwd(), from)
|
|
113
|
+
if (!existsSync(dir)) return null
|
|
114
|
+
return { dir, name: dir.split(/[\\/]/).pop() }
|
|
115
|
+
}
|
|
116
|
+
return listBackupSets(configDir)[0] ?? null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {object} opts
|
|
121
|
+
* @param {string} opts.configDir
|
|
122
|
+
* @param {string} opts.backupDir
|
|
123
|
+
* @param {boolean} [opts.dryRun]
|
|
124
|
+
* @param {boolean} [opts.yes]
|
|
125
|
+
* @returns {Promise<{ ok: boolean, code: number, entries?: object[] }>}
|
|
126
|
+
*/
|
|
127
|
+
export async function runRestore({ configDir, backupDir, dryRun = false, yes = false }) {
|
|
128
|
+
const verify = verifyBackupSet(backupDir)
|
|
129
|
+
if (!verify.ok) {
|
|
130
|
+
console.error(
|
|
131
|
+
`❌ Refusing to restore: backup failed integrity verification (${verify.reason ?? 'hash mismatch'}).`,
|
|
132
|
+
)
|
|
133
|
+
return { ok: false, code: 1 }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const plan = planRestore(backupDir, configDir)
|
|
137
|
+
if (plan.error) {
|
|
138
|
+
console.error(`❌ Refusing to restore: ${plan.error}.`)
|
|
139
|
+
return { ok: false, code: 1 }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const counts = { create: 0, update: 0, remove: 0, unchanged: 0 }
|
|
143
|
+
for (const entry of plan.entries) counts[entry.action] += 1
|
|
144
|
+
|
|
145
|
+
console.log('')
|
|
146
|
+
console.log(`Restore plan — ${configDir}`)
|
|
147
|
+
console.log(` from ${backupDir}`)
|
|
148
|
+
for (const entry of plan.entries) {
|
|
149
|
+
if (entry.action === 'unchanged') continue
|
|
150
|
+
console.log(` ${entry.action.padEnd(9)} ${entry.relativePath}`)
|
|
151
|
+
}
|
|
152
|
+
console.log(
|
|
153
|
+
` ${counts.create} create · ${counts.update} update · ${counts.remove} remove · ${counts.unchanged} unchanged`,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if (dryRun) {
|
|
157
|
+
console.log(' (dry-run) No files were written.')
|
|
158
|
+
return { ok: true, code: 0, entries: plan.entries }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!yes) {
|
|
162
|
+
// Reuse the shared confirm semantics (default NO). An interactive TTY may
|
|
163
|
+
// authorize without --yes; a non-TTY run must never proceed silently.
|
|
164
|
+
const tty = Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
|
165
|
+
if (!tty) {
|
|
166
|
+
console.error('❌ Refusing to restore without authorization. Re-run with --yes.')
|
|
167
|
+
return { ok: false, code: 1 }
|
|
168
|
+
}
|
|
169
|
+
const { promptReview } = await import('./install/interactive.mjs')
|
|
170
|
+
const accepted = await promptReview('Restore these changes?', { default: false })
|
|
171
|
+
if (!accepted) {
|
|
172
|
+
console.log(' Nothing restored.')
|
|
173
|
+
return { ok: true, code: 0, entries: plan.entries }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Reversible: snapshot the CURRENT state of exactly the affected paths first.
|
|
178
|
+
// Retention is DISABLED here (`keep: 0`): a retention pass at this point could
|
|
179
|
+
// delete the very set being restored once 5 older sets already exist (the new
|
|
180
|
+
// snapshot would be the 6th, and pruning would drop the oldest two — including
|
|
181
|
+
// the source). Apply still prunes; restore never deletes backups.
|
|
182
|
+
const { dir: preDir } = createBackupSet({
|
|
183
|
+
configDir,
|
|
184
|
+
entries: plan.manifest.entries.map((e) => e.relativePath),
|
|
185
|
+
packageVersion: plan.manifest.packageVersion,
|
|
186
|
+
keep: 0,
|
|
187
|
+
})
|
|
188
|
+
console.log(` pre-restore backup: ${preDir}`)
|
|
189
|
+
|
|
190
|
+
restoreBackupSet(backupDir, configDir, { dryRun: false })
|
|
191
|
+
console.log(` ✅ Restored ${plan.entries.length} path(s).`)
|
|
192
|
+
return { ok: true, code: 0, entries: plan.entries }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function main() {
|
|
196
|
+
const args = parseArgs(process.argv.slice(2))
|
|
197
|
+
if (args.help) {
|
|
198
|
+
showHelp()
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
const configDir = resolveConfigDir({ project: args.project })
|
|
202
|
+
const backup = pickBackupSet(configDir, args.from)
|
|
203
|
+
if (!backup) {
|
|
204
|
+
console.error(`❌ No backup set found under ${backupRoot(configDir)}`)
|
|
205
|
+
process.exitCode = 1
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
const result = await runRestore({
|
|
209
|
+
configDir,
|
|
210
|
+
backupDir: backup.dir,
|
|
211
|
+
dryRun: args.dryRun,
|
|
212
|
+
yes: args.yes,
|
|
213
|
+
})
|
|
214
|
+
if (!result.ok) process.exitCode = result.code
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
218
|
+
main().catch((err) => {
|
|
219
|
+
console.error(`❌ Restore failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
220
|
+
process.exitCode = 1
|
|
221
|
+
})
|
|
222
|
+
}
|