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.
Files changed (133) hide show
  1. package/AGENTS.global.md +206 -0
  2. package/AGENTS.md +10 -891
  3. package/CHANGELOG.md +350 -0
  4. package/README.md +561 -52
  5. package/README.pt-BR.md +285 -20
  6. package/bin/pantheon-init.mjs +70 -4
  7. package/commands/pantheon-audit.md +7 -1
  8. package/commands/pantheon-consolidate.md +1 -1
  9. package/commands/pantheon-deepwork.md +14 -3
  10. package/commands/pantheon-focus.md +17 -0
  11. package/commands/pantheon-vision.md +61 -12
  12. package/package.json +24 -17
  13. package/plugin.json +2 -2
  14. package/pyproject.toml +8 -6
  15. package/scripts/build-agents-md.mjs +428 -56
  16. package/scripts/changelog-merge.mjs +161 -0
  17. package/scripts/{code_mode_server.py → code_mode.py} +3 -3
  18. package/scripts/cost.mjs +244 -41
  19. package/scripts/doctor.mjs +151 -1
  20. package/scripts/install/agents-md.mjs +4 -4
  21. package/scripts/install/backup.mjs +475 -0
  22. package/scripts/install/cli-ui.mjs +6 -1
  23. package/scripts/install/config-migration.mjs +317 -4
  24. package/scripts/install/exit-codes.mjs +26 -0
  25. package/scripts/install/health-check.mjs +21 -19
  26. package/scripts/install/interactive.mjs +16 -2
  27. package/scripts/install/migrate.mjs +41 -22
  28. package/scripts/install/opencode-version.mjs +246 -4
  29. package/scripts/install/opencode.mjs +1175 -215
  30. package/scripts/install/plan.mjs +287 -0
  31. package/scripts/install/plugin.mjs +62 -14
  32. package/scripts/install/shared.mjs +190 -48
  33. package/scripts/install/strings.mjs +20 -0
  34. package/scripts/install/venv.mjs +13 -3
  35. package/scripts/install/vision-prompt.mjs +171 -0
  36. package/scripts/install-mcp.mjs +28 -9
  37. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
  38. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  39. package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
  40. package/scripts/probe-context-rehydrate.mjs +8 -2
  41. package/scripts/probe-pantheon-cost.mjs +71 -15
  42. package/scripts/prune.mjs +9 -3
  43. package/scripts/restore.mjs +222 -0
  44. package/scripts/run-node-tests.mjs +189 -0
  45. package/scripts/sync-artifacts.mjs +26 -13
  46. package/scripts/sync-tui.mjs +24 -3
  47. package/scripts/test-opencode-v2-sandbox.sh +180 -26
  48. package/scripts/validate-routing.mjs +3 -3
  49. package/scripts/validate-sandbox-install.mjs +138 -0
  50. package/scripts/versioning.mjs +13 -1
  51. package/scripts/vision-status.mjs +682 -0
  52. package/skills-lock.json +8 -8
  53. package/src/agents/aphrodite.md +20 -37
  54. package/src/agents/apollo.md +7 -3
  55. package/src/agents/athena.md +63 -35
  56. package/src/agents/demeter.md +8 -28
  57. package/src/agents/gaia.md +1 -1
  58. package/src/agents/hephaestus.md +10 -21
  59. package/src/agents/hermes.md +15 -38
  60. package/src/agents/iris.md +7 -1
  61. package/src/agents/mnemosyne.md +18 -54
  62. package/src/agents/nyx.md +1 -1
  63. package/src/agents/prometheus.md +16 -20
  64. package/src/agents/talos.md +10 -2
  65. package/src/agents/themis.md +5 -5
  66. package/src/agents/zeus.md +99 -172
  67. package/src/instructions/agent-return-format.instructions.md +34 -17
  68. package/src/instructions/backend-standards.instructions.md +2 -0
  69. package/src/instructions/frontend-standards.instructions.md +3 -1
  70. package/src/instructions/memory-protocol.instructions.md +12 -110
  71. package/src/instructions/test-economy.instructions.md +21 -0
  72. package/src/instructions/zeus-anti-stall.instructions.md +20 -67
  73. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  74. package/src/instructions/zeus-council-synthesis.instructions.md +31 -260
  75. package/src/instructions/zeus-memory-operations.instructions.md +30 -0
  76. package/src/instructions/zeus-timeout-retry.instructions.md +61 -89
  77. package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
  78. package/src/mcp/eval_store.py +2 -2
  79. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
  80. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  81. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
  82. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
  83. package/src/mcp/requirements-mcp.txt +6 -4
  84. package/src/mcp/requirements-vision.txt +2 -2
  85. package/src/pantheon/cost-command.ts +403 -102
  86. package/src/pantheon/delegation-enforce.ts +10 -1
  87. package/src/pantheon/model-agents.ts +19 -0
  88. package/src/pantheon/model-command.ts +218 -56
  89. package/src/pantheon/presets.d.mts +0 -8
  90. package/src/pantheon/presets.mjs +5 -32
  91. package/src/pantheon/v2-bounded-read.ts +640 -0
  92. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  93. package/src/pantheon/v2-execute-after.ts +186 -0
  94. package/src/pantheon/v2-model-overrides.ts +275 -0
  95. package/src/pantheon/v2-tools.ts +418 -0
  96. package/src/pantheon/v2-unsupported.d.mts +11 -0
  97. package/src/pantheon/v2-unsupported.mjs +90 -0
  98. package/src/pantheon/v2-vision.ts +622 -0
  99. package/src/pantheon/vision-local.ts +531 -0
  100. package/src/pantheon/vision.ts +1160 -115
  101. package/src/plugin-v2.ts +1158 -219
  102. package/src/plugin.ts +12 -9
  103. package/src/plugins/tui/dist/server.d.ts +1 -2
  104. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  105. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  106. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  107. package/src/plugins/tui/dist/tui-v2.js +659 -0
  108. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  109. package/src/plugins/tui/dist/tui.d.ts +85 -82
  110. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  111. package/src/plugins/tui/dist/tui.js +72 -59
  112. package/src/plugins/tui/index-v2.ts +6 -0
  113. package/src/plugins/tui/package-lock.json +5516 -1353
  114. package/src/plugins/tui/package.json +22 -12
  115. package/src/plugins/tui/src/index-v2.tsx +734 -0
  116. package/src/plugins/tui/src/index.tsx +106 -85
  117. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  118. package/src/plugins/tui/tsconfig.build.json +5 -0
  119. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  120. package/src/plugins/tui/tsdown.config.ts +22 -2
  121. package/src/plugins/tui/tui-v2.ts +2 -0
  122. package/src/plugins/tui/tui.ts +3 -0
  123. package/src/routing.yml +11 -21
  124. package/src/skills/README.md +3 -3
  125. package/src/skills/agent-coordination/SKILL.md +7 -2
  126. package/src/skills/artifact-management/SKILL.md +12 -5
  127. package/src/skills/auto-continue/SKILL.md +52 -35
  128. package/src/skills/code-review-checklist/SKILL.md +5 -5
  129. package/src/skills/context-compression/SKILL.md +26 -879
  130. package/src/skills/session-goal/SKILL.md +14 -5
  131. package/src/skills/spec-driven-development/SKILL.md +3 -1
  132. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  133. package/opencode.json +0 -58
@@ -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/mcp_resources_server.py')
74
- const CODE_MODE_SERVER = resolveServerScript('scripts/code_mode_server.py')
75
- const MEMORY_MCP_SERVER = resolveServerScript('scripts/memory_mcp_server.py')
76
- const MCP_PERSISTENCE_SERVER = resolveServerScript('scripts/mcp_persistence_server.py')
77
- const PANTHEON_VISION_SERVER = resolveServerScript('src/mcp/pantheon_vision_server.py')
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: 'mcp_resources_server.py not found at resolved path' }
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: 'code_mode_server.py not found at resolved path' }
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: 'memory_mcp_server.py not found at resolved path' }
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: 'mcp_persistence_server.py not found at resolved path' }
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/mcp_persistence_server.py
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 mcp.server.fastmcp import FastMCP
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) or len(tail) > MAX_TAIL_ITEMS:
918
- raise ValueError("tail exceeds its size limit")
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) or len(line.encode()) > MAX_TAIL_LINE_LENGTH:
921
- raise ValueError("tail entries exceed their size limit")
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("tail payload exceeds its size limit")
924
- if sum(len(line.encode()) for line in tail) > MAX_CONTEXT_CONTENT_BYTES:
925
- raise ValueError("tail payload exceeds its size limit")
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
- if len(content.encode()) > MAX_CONTEXT_CONTENT_BYTES:
1024
- errors.add(prefix, f"exceeds the {MAX_CONTEXT_CONTENT_BYTES}-byte limit")
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
- if len(parsed) > MAX_TAIL_ITEMS or any(
1034
- not isinstance(line, str) or len(line.encode()) > MAX_TAIL_LINE_LENGTH
1035
- for line in parsed
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. When it parses to an object, 'goal', "
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/mcp_resources_server.py
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 mcp.server.fastmcp import FastMCP
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/memory_mcp_server.py
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 mcp.server.fastmcp import FastMCP
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('./mcp_persistence_server.py', import.meta.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 mcp_persistence_server.py')
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
- db.exec('CREATE TABLE message (data TEXT NOT NULL, time_created INTEGER NOT NULL)')
62
- const insert = db.prepare('INSERT INTO message (data, time_created) VALUES (?, ?)')
63
- for (const data of rows) insert.run(JSON.stringify(data), Date.now())
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
- const selectedDb = join(opencodeDir, requestedVersion === 'v2' ? 'opencode-v2.db' : 'opencode.db')
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: 'plan', input: 8, output: 10, total: 18 },
228
- { agent: 'zeus', phase: 'audit', input: 4, output: 6, total: 10 },
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
- requireOutput(output, '| env-agent | override | 1 | 2 | 3 |', 'PANTHEON_COST_DB')
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(output, '| explicit-agent | explicit | 9 | 1 | 10 |', 'explicit dbPath')
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
- emit('PASS', 'offline synthetic DB checks passed; no real DB was read', checks)
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 --apply
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 dir
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
+ }