pantheon-opencode 1.6.0-beta.7 → 1.6.0-beta.9

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 (66) hide show
  1. package/AGENTS.md +58 -15
  2. package/CHANGELOG.md +61 -94
  3. package/README.md +81 -103
  4. package/README.pt-BR.md +19 -37
  5. package/commands/pantheon-audit.md +7 -1
  6. package/commands/pantheon-consolidate.md +1 -1
  7. package/commands/pantheon-focus.md +17 -0
  8. package/package.json +1 -2
  9. package/plugin.json +2 -2
  10. package/pyproject.toml +1 -1
  11. package/scripts/build-agents-md.mjs +2 -3
  12. package/scripts/doctor.mjs +22 -104
  13. package/scripts/hooks/scan-secrets.sh +97 -0
  14. package/scripts/install/config-migration.mjs +9 -42
  15. package/scripts/install/interactive.mjs +1 -1
  16. package/scripts/install/opencode.mjs +174 -77
  17. package/scripts/install/plugin.mjs +43 -9
  18. package/scripts/install/strings.mjs +0 -8
  19. package/scripts/probe-pantheon-cost.mjs +3 -16
  20. package/scripts/sync-artifacts.mjs +1 -0
  21. package/scripts/sync-tui.mjs +24 -3
  22. package/scripts/test-opencode-v2-sandbox.sh +203 -786
  23. package/scripts/validate-sandbox-install.mjs +138 -0
  24. package/skills-lock.json +4 -5
  25. package/src/agents/aphrodite.md +2 -9
  26. package/src/agents/demeter.md +0 -9
  27. package/src/agents/gaia.md +1 -0
  28. package/src/agents/hephaestus.md +0 -9
  29. package/src/agents/hermes.md +2 -9
  30. package/src/agents/mnemosyne.md +18 -53
  31. package/src/agents/prometheus.md +0 -9
  32. package/src/agents/themis.md +0 -1
  33. package/src/agents/zeus.md +180 -19
  34. package/src/instructions/agent-return-format.instructions.md +48 -3
  35. package/src/instructions/memory-protocol.instructions.md +8 -9
  36. package/src/instructions/zeus-anti-stall.instructions.md +54 -6
  37. package/src/instructions/zeus-council-synthesis.instructions.md +71 -0
  38. package/src/instructions/zeus-memory-operations.instructions.md +4 -4
  39. package/src/instructions/zeus-timeout-retry.instructions.md +81 -7
  40. package/src/pantheon/v2-delegation-enforce.ts +2 -1
  41. package/src/plugin-v2.ts +251 -168
  42. package/src/plugins/pantheon-hooks.ts +35 -82
  43. package/src/plugins/tui/dist/tui-v2.d.ts +9 -0
  44. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  45. package/src/plugins/tui/dist/tui-v2.js +151 -0
  46. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  47. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  48. package/src/plugins/tui/dist/tui.js +29 -53
  49. package/src/plugins/tui/index-v2.ts +6 -0
  50. package/src/plugins/tui/package-lock.json +5411 -1292
  51. package/src/plugins/tui/package.json +10 -7
  52. package/src/plugins/tui/src/index-v2.tsx +108 -0
  53. package/src/plugins/tui/src/index.tsx +28 -63
  54. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  55. package/src/plugins/tui/tsdown.config.ts +2 -2
  56. package/src/plugins/tui/tui-v2.ts +2 -0
  57. package/src/plugins/tui/tui.ts +3 -0
  58. package/src/routing.yml +4 -2
  59. package/src/skills/README.md +7 -12
  60. package/src/skills/agent-coordination/SKILL.md +83 -13
  61. package/src/skills/auto-continue/SKILL.md +13 -14
  62. package/src/skills/context-compression/SKILL.md +26 -879
  63. package/src/skills/session-goal/SKILL.md +14 -5
  64. package/src/pantheon/secret-scanner.ts +0 -489
  65. package/src/plugins/tui/README.md +0 -14
  66. package/src/skills/council-synthesis/SKILL.md +0 -29
@@ -56,6 +56,7 @@ import {
56
56
  writeConfigWithBackup,
57
57
  writeIfChanged,
58
58
  } from './shared.mjs'
59
+ import { createInitialState, hasState, readState, setComponentState, writeState } from './state.mjs'
59
60
  import { strings } from './strings.mjs'
60
61
 
61
62
  import { setupVenv, venvPythonPath } from './venv.mjs'
@@ -135,6 +136,74 @@ export const MANAGED_FIELDS = Object.freeze([
135
136
  'disable_model_invocation',
136
137
  ])
137
138
 
139
+ const PANTHEON_MCP_SERVERS = Object.freeze([
140
+ 'pantheon-resources',
141
+ 'pantheon-memory',
142
+ 'pantheon-code-mode',
143
+ 'pantheon-persistence',
144
+ 'pantheon-vision',
145
+ ])
146
+
147
+ function normalizeMcpPermissionSegment(value) {
148
+ return value.replace(/[^a-zA-Z0-9_]/g, '_')
149
+ }
150
+
151
+ /**
152
+ * Translate an agent's `mcp_tools` frontmatter into per-tool native
153
+ * permissions. V1 matches the original server-tool names; V2 normalizes
154
+ * punctuation to underscores. Emitting both spellings keeps the same source
155
+ * metadata valid for either host. Unlisted bundled Pantheon servers default to
156
+ * deny; unrelated user MCP servers are not touched.
157
+ *
158
+ * @param {Record<string, 'all' | string[]>} mcpTools
159
+ * @returns {Record<string, 'allow' | 'deny'>}
160
+ */
161
+ export function deriveNativeMcpPermissions(mcpTools) {
162
+ if (mcpTools === null || typeof mcpTools !== 'object' || Array.isArray(mcpTools)) {
163
+ throw new TypeError('mcp_tools must be an object keyed by MCP server name')
164
+ }
165
+
166
+ const servers = new Set([...PANTHEON_MCP_SERVERS, ...Object.keys(mcpTools)])
167
+ const permissions = {}
168
+
169
+ for (const server of servers) {
170
+ if (!/^[a-zA-Z0-9._-]+$/.test(server)) {
171
+ throw new TypeError(`mcp_tools server name is invalid: ${server}`)
172
+ }
173
+ const tools = Object.hasOwn(mcpTools, server) ? mcpTools[server] : []
174
+ if (
175
+ tools !== 'all' &&
176
+ (!Array.isArray(tools) ||
177
+ tools.some((tool) => typeof tool !== 'string' || !/^[a-zA-Z0-9._-]+$/.test(tool)))
178
+ ) {
179
+ throw new TypeError(`mcp_tools.${server} must be "all" or an array of tool names`)
180
+ }
181
+
182
+ const serverAliases = new Set([server, normalizeMcpPermissionSegment(server)])
183
+ for (const alias of serverAliases) permissions[`${alias}_*`] = 'deny'
184
+ }
185
+
186
+ for (const server of servers) {
187
+ const tools = Object.hasOwn(mcpTools, server) ? mcpTools[server] : []
188
+ const serverAliases = new Set([server, normalizeMcpPermissionSegment(server)])
189
+ if (tools === 'all') {
190
+ for (const alias of serverAliases) permissions[`${alias}_*`] = 'allow'
191
+ continue
192
+ }
193
+
194
+ for (const tool of tools) {
195
+ const toolAliases = new Set([tool, normalizeMcpPermissionSegment(tool)])
196
+ for (const serverAlias of serverAliases) {
197
+ for (const toolAlias of toolAliases) {
198
+ permissions[`${serverAlias}_${toolAlias}`] = 'allow'
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ return permissions
205
+ }
206
+
138
207
  function deepClone(value) {
139
208
  return JSON.parse(JSON.stringify(value))
140
209
  }
@@ -226,26 +295,23 @@ function mergeMissing(target, source) {
226
295
  }
227
296
 
228
297
  /**
229
- * Resolve a plugin ref from the repository-root opencode.json to its stable
230
- * installed form.
298
+ * Resolve a plugin ref from the repository-root opencode.json to a path INSIDE
299
+ * the installed package.
231
300
  *
232
301
  * Only exact Pantheon plugin identities are resolved into the installed
233
302
  * package. In particular, a third-party absolute path ending in
234
303
  * `src/plugin.ts` or `src/plugins/pantheon-hooks.ts` is not evidence that the
235
- * plugin belongs to Pantheon and must be preserved verbatim. V1 remains a pair
236
- * of package-local paths; V2 uses an exact npm version so it is independent of
237
- * the installer's global prefix or transient npx cache.
304
+ * plugin belongs to Pantheon and must be preserved verbatim.
238
305
  */
239
306
  export function resolveInstalledPlugin(plugin) {
240
307
  if (typeof plugin !== 'string') return plugin
241
308
  const normalized = normalizePluginRef(plugin)
242
- if (normalized === PANTHEON_V2_PLUGIN || normalized === PANTHEON_V2_LEGACY_FILE) {
243
- return PANTHEON_V2_PACKAGE_SPEC
244
- }
245
309
  if (PANTHEON_PLUGIN_IDENTITIES.has(normalized)) return join(ROOT, normalized)
310
+ // Legacy V2 file ref (pre-directory-contract): migrate to the installed
311
+ // plugin directory so the beta loader accepts the entry.
312
+ if (normalized === PANTHEON_V2_LEGACY_FILE) return join(ROOT, PANTHEON_V2_PLUGIN)
246
313
  const identity = managedPluginIdentity(plugin)
247
314
  if (identity !== null && isAbsolute(normalized)) {
248
- if (identity === PANTHEON_V2_PLUGIN) return PANTHEON_V2_PACKAGE_SPEC
249
315
  return INSTALLED_PANTHEON_PLUGIN_PATHS.get(identity) ?? plugin
250
316
  }
251
317
  return plugin
@@ -267,14 +333,7 @@ const PANTHEON_V2_PLUGIN = 'src/plugin-v2'
267
333
  const PANTHEON_V2_LEGACY_FILE = 'src/plugin-v2.ts'
268
334
  const PANTHEON_V1_EXPORT = 'pantheon-opencode/plugin'
269
335
  const PANTHEON_V2_EXPORT = 'pantheon-opencode/plugin-v2'
270
- const PACKAGE_MANIFEST = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'))
271
- const PANTHEON_V2_PACKAGE_SPEC = `${PACKAGE_MANIFEST.name}@${PACKAGE_MANIFEST.version}`
272
- // Installation origin probe: npm copies (global prefix, npx cache) always live
273
- // under a `node_modules` directory; a repo checkout does not. From a checkout —
274
- // or between a version bump and its publish — the V2 pin above names a version
275
- // that may not exist on the npm registry yet, and the host cannot resolve it
276
- // until publish. Advisory only: the install itself is structurally correct.
277
- const RUNNING_FROM_NPM_INSTALL = import.meta.url.includes('/node_modules/')
336
+ const PANTHEON_PACKAGE_REF = /^pantheon-opencode(?:@[^/]+)?(?:\/(plugin|plugin-v2))?$/
278
337
  const PANTHEON_PLUGIN_IDENTITIES = new Set([
279
338
  PANTHEON_V1_PLUGIN,
280
339
  PANTHEON_V1_HOOKS,
@@ -303,14 +362,22 @@ function managedPluginIdentity(ref) {
303
362
  const normalized = normalizePluginRef(ref)
304
363
  if (typeof normalized !== 'string') return null
305
364
 
365
+ // Older installs registered the package root itself (often pinned to a
366
+ // beta version) in OpenCode's plural `plugins` list. Treat all exact npm
367
+ // specs for this package as managed so an update removes the old cached
368
+ // copy before registering the current package path. Do not match similarly
369
+ // named third-party packages such as `pantheon-opencode-fork`.
370
+ const packageRef = normalized.match(PANTHEON_PACKAGE_REF)
371
+ if (packageRef !== null) {
372
+ if (packageRef[1] === 'plugin') return PANTHEON_V1_PLUGIN
373
+ return PANTHEON_V2_PLUGIN
374
+ }
375
+
306
376
  if (PANTHEON_PLUGIN_IDENTITIES.has(normalized)) return normalized
307
377
  if (normalized === PANTHEON_V2_LEGACY_FILE) return PANTHEON_V2_PLUGIN
308
378
  if (normalized === PANTHEON_V1_EXPORT || normalized.startsWith(`${PANTHEON_V1_EXPORT}@`)) {
309
379
  return PANTHEON_V1_PLUGIN
310
380
  }
311
- if (normalized === PACKAGE_MANIFEST.name || normalized.startsWith(`${PACKAGE_MANIFEST.name}@`)) {
312
- return PANTHEON_V2_PLUGIN
313
- }
314
381
  if (normalized === PANTHEON_V2_EXPORT || normalized.startsWith(`${PANTHEON_V2_EXPORT}@`)) {
315
382
  return PANTHEON_V2_PLUGIN
316
383
  }
@@ -475,15 +542,14 @@ export function isGlobalConfigDir(target) {
475
542
  * package; the loader must NEVER be pointed at that in-package dir (a package
476
543
  * reinstall/upgrade replaces the dir under a live registration and leaves
477
544
  * diverging copies loading). Instead the installer always copies it into the
478
- * config dir's plugins/ dir and registers THAT copy:
545
+ * config dir's plugins/ dir and uses THAT copy:
479
546
  *
480
- * <configDir>/plugins/pantheon-tui (dist/ + package.json + index.tsx)
547
+ * <configDir>/plugins/pantheon-tui (tui.ts + dist/ + package manifests)
481
548
  *
482
- * The loader reads the copied package.json `exports` map:
483
- * exports["./tui"] → dist/tui.js (COMPILED Solid output)
484
- * exports["./server"] → dist/server.js (no-op server() stub)
549
+ * V1 registers that copy through tui.json. V2 discovers the local plugin's
550
+ * index.ts + tui.ts pair; the V2 shim re-exports dist/tui-v2.js.
485
551
  *
486
- * @param {string} configDir - directory holding tui.json (target for global
552
+ * @param {string} configDir - OpenCode config directory (target for global
487
553
  * installs, <target>/.opencode for project installs)
488
554
  * @returns {string} absolute path of the copied plugin directory
489
555
  */
@@ -509,23 +575,20 @@ export function tuiConfigLocations(target) {
509
575
  }
510
576
 
511
577
  /**
512
- * Sync the Pantheon TUI plugin registration across EVERY tui.json location
513
- * OpenCode reads.
578
+ * Sync the Pantheon TUI plugin payload and V1 registration. V2 discovers the
579
+ * copied local-plugin entrypoints and does not use a project tui.json.
514
580
  *
515
581
  * Contract (single source of truth):
516
- * 1. ALWAYS copy src/plugins/tui → <configDir>/plugins/pantheon-tui
517
- * (dist/* + package.json + index.tsx) so the registered reference is a
518
- * directory that exists and carries the loader contract (exports →
519
- * dist/tui.js, dist/server.js). The copy is idempotent: a second run is
520
- * byte-identical.
582
+ * 1. ALWAYS copy src/plugins/tui → <configDir>/plugins/pantheon-tui with the
583
+ * selected host entrypoints and compiled bundle. The copy is idempotent.
521
584
  * 2. Clean stale Pantheon TUI refs from ALL tui.json locations OpenCode
522
585
  * loads — ~/.opencode, $XDG_CONFIG_HOME/opencode and
523
586
  * <target>/.opencode — covering every old reference shape: dist file
524
587
  * paths, in-package src/plugins/tui dirs, bare plugins/pantheon-tui
525
588
  * copies, and npx paths. A stale ref left in the non-target location
526
589
  * would still be loaded and break the TUI.
527
- * 3. Register EXACTLY the copied directory in the selected target only —
528
- * exactly ONE Pantheon TUI reference exists system-wide after install.
590
+ * 3. V1 registers the copied directory in the selected target. V2 relies on
591
+ * local-plugin discovery and does not write a project tui.json entry.
529
592
  *
530
593
  * @param {string} target - install target directory
531
594
  * @param {object} [opts]
@@ -533,18 +596,19 @@ export function tuiConfigLocations(target) {
533
596
  * @param {boolean} [opts.dryRun=false]
534
597
  * @returns {'created'|'skipped'} status for the target registration
535
598
  */
536
- export function syncTuiRegistration(target, { isGlobal = false, dryRun = false } = {}) {
599
+ export function syncTuiRegistration(
600
+ target,
601
+ { isGlobal = false, dryRun = false, version = 'v1' } = {},
602
+ ) {
537
603
  const configDir = isGlobal ? target : join(target, '.opencode')
538
604
  const targetTuiConfigPath = join(configDir, 'tui.json')
539
605
 
540
- // 1. Always copy the single source of truth into the target config's
541
- // plugins/pantheon-tui. Registering this copied directory (never the
542
- // in-package src/plugins/tui) keeps installs hermetic and idempotent:
543
- // the ref points at a dir that exists and has dist/tui.js.
606
+ // 1. Copy the host-specific plugin into the target config's
607
+ // plugins/pantheon-tui. This keeps the installed entry hermetic.
544
608
  const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
545
609
  const tuiCopyDir = resolveTuiCopyTarget(configDir)
546
610
  if (existsSync(tuiSrcDir)) {
547
- copyPluginFiles(tuiSrcDir, tuiCopyDir, { dryRun })
611
+ copyPluginFiles(tuiSrcDir, tuiCopyDir, { dryRun, version })
548
612
  }
549
613
 
550
614
  // 2. Clean stale Pantheon TUI refs from every location OpenCode reads.
@@ -560,6 +624,11 @@ export function syncTuiRegistration(target, { isGlobal = false, dryRun = false }
560
624
  })
561
625
  }
562
626
 
627
+ // OpenCode V2 discovers local plugins from the config plugins directories;
628
+ // its TUI plugin API is exposed by the sibling tui.ts entrypoint. V1 still
629
+ // requires the explicit tui.json registration.
630
+ if (version === 'v2') return 'skipped'
631
+
563
632
  // 3. Register the copied directory in the selected target only.
564
633
  return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun })
565
634
  }
@@ -796,14 +865,13 @@ export async function installOpenCode(
796
865
  }
797
866
  }
798
867
 
799
- // Determine layout based on install scope.
800
- // Global config dir (~/.opencode or ~/.config/opencode) uses a flat layout:
801
- // agents/ skills/ commands/ plugins/
802
- // Project installs use the .opencode/ sub-directory layout:
803
- // .opencode/agents/ .opencode/skills/ .opencode/commands/
868
+ // V1 project installs use the legacy .opencode/agents layout. OpenCode V2's
869
+ // active config directory is the project root and its agent loader scans
870
+ // <config-dir>/{agent,agents}/**, so V2 project agents must be at ./agents.
871
+ // Global installs are flat for both versions.
804
872
  const isGlobal = isGlobalConfigDir(target)
805
873
  const _subDir = isGlobal ? '' : '.opencode'
806
- const agentPrefix = isGlobal ? 'agents' : '.opencode/agents'
874
+ const agentPrefix = isGlobal || version === 'v2' ? 'agents' : '.opencode/agents'
807
875
 
808
876
  configure({ dryRun })
809
877
 
@@ -822,7 +890,7 @@ export async function installOpenCode(
822
890
  stats.errors++
823
891
  } else {
824
892
  const agentStep = step(S.installingAgents)
825
- const dstDir = isGlobal ? join(target, 'agents') : join(target, '.opencode', 'agents')
893
+ const dstDir = join(target, agentPrefix)
826
894
  if (!dryRun) mkdirSync(dstDir, { recursive: true })
827
895
  if (clean && existsSync(dstDir) && !dryRun) {
828
896
  const existing = readdirSync(dstDir)
@@ -902,9 +970,7 @@ export async function installOpenCode(
902
970
  // already-installed Pantheon agents, but must not create agents the user
903
971
  // did not install. Full agent installs do this in phase 1 above.
904
972
  if (!componentSet.has('agents')) {
905
- const installedAgents = isGlobal
906
- ? join(target, 'agents')
907
- : join(target, '.opencode', 'agents')
973
+ const installedAgents = join(target, agentPrefix)
908
974
  const sourceAgents = join(ROOT, 'src', 'agents')
909
975
  if (existsSync(installedAgents) && existsSync(sourceAgents)) {
910
976
  const refreshed = copyAgentPrompts(sourceAgents, installedAgents, dryRun, true)
@@ -934,7 +1000,11 @@ export async function installOpenCode(
934
1000
  if (componentSet.has('commands')) {
935
1001
  section('\u26A1 Commands')
936
1002
  const srcCmds = join(ROOT, 'commands')
937
- const dstCmds = isGlobal ? join(target, 'commands') : join(target, '.opencode', 'commands')
1003
+ // V2 project sandboxes use OPENCODE_CONFIG_DIR=<project root>, so OpenCode
1004
+ // discovers custom commands from <target>/commands. V1 project installs
1005
+ // keep the standard .opencode/commands layout; global installs are flat.
1006
+ const commandDir = isGlobal || version === 'v2' ? 'commands' : '.opencode/commands'
1007
+ const dstCmds = join(target, commandDir)
938
1008
  if (existsSync(srcCmds)) {
939
1009
  const cmdResult = syncDir(srcCmds, dstCmds, dryRun, clean, (f) => f.endsWith('.md'))
940
1010
  stats.created += cmdResult.created
@@ -959,7 +1029,7 @@ export async function installOpenCode(
959
1029
  const dstPluginDir = isGlobal
960
1030
  ? join(target, 'plugins', 'pantheon-tui')
961
1031
  : join(target, '.opencode', 'plugins', 'pantheon-tui')
962
- const pluginStats = installPlugin(srcPluginDir, dstPluginDir, { dryRun, clean })
1032
+ const pluginStats = installPlugin(srcPluginDir, dstPluginDir, { dryRun, clean, version })
963
1033
  stats.created += pluginStats.created
964
1034
  stats.skipped += pluginStats.skipped
965
1035
  stats.errors += pluginStats.errors
@@ -971,7 +1041,7 @@ export async function installOpenCode(
971
1041
  if (componentSet.has('plugins')) {
972
1042
  let tuiStatus
973
1043
  try {
974
- tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun })
1044
+ tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun, version })
975
1045
  } catch (cause) {
976
1046
  error(`TUI plugin setup failed: ${cause.message}`)
977
1047
  stats.errors++
@@ -1000,6 +1070,7 @@ export async function installOpenCode(
1000
1070
  'mcp_persistence.py',
1001
1071
  'pantheon_vision.py',
1002
1072
  'eval_store.py',
1073
+ 'themis_heuristic_scan.py',
1003
1074
  ]
1004
1075
  const srcScriptsDir = join(ROOT, 'scripts')
1005
1076
  // Canonical MCP server sources live in src/mcp/. Map them explicitly so the
@@ -1154,6 +1225,12 @@ export async function installOpenCode(
1154
1225
  if (fm.permission) {
1155
1226
  agent.permission = JSON.parse(JSON.stringify(fm.permission))
1156
1227
  }
1228
+ if (fm.mcp_tools !== undefined) {
1229
+ agent.permission = {
1230
+ ...(agent.permission ?? {}),
1231
+ ...deriveNativeMcpPermissions(fm.mcp_tools),
1232
+ }
1233
+ }
1157
1234
 
1158
1235
  config[name] = agent
1159
1236
  }
@@ -1266,12 +1343,16 @@ export async function installOpenCode(
1266
1343
  // --------------------------------------------------------------------
1267
1344
  // B.5.1 Hermetic plugin resolution (packaging fix)
1268
1345
  // --------------------------------------------------------------------
1269
- // A developer running from a checkout may have a repository-root
1270
- // opencode.json with machine-specific plugin paths. V1 rewrites its two
1271
- // managed entries to paths under the installed package. V2 instead writes
1272
- // the exact package-root npm spec so it survives global-prefix and npx-cache
1273
- // changes; a source-checkout V2 install warns that the version may not yet
1274
- // exist on npm. Third-party paths remain untouched in either generation.
1346
+ // A developer running from a checkout has a repository-root opencode.json
1347
+ // that may reference plugins via developer-machine absolute paths (e.g.
1348
+ // <dev>/pantheon/.../src/plugin.ts or
1349
+ // <dev>/pantheon/.../src/plugins/pantheon-hooks.ts). Copying those
1350
+ // verbatim into the user's config would break every global install on any
1351
+ // other machine. resolveInstalledPlugin() rewrites each entry to the plugin
1352
+ // file inside the INSTALLED package (derived from ROOT — never hardcoded),
1353
+ // so both dev and global installs resolve to a path that actually exists.
1354
+ // The source opencode.json keeps its dev path for local development; the
1355
+ // transform happens at install/sync time only.
1275
1356
 
1276
1357
  if (version === 'v1') {
1277
1358
  // V1 loads the singular `plugin` list. Remove every Pantheon V2 entry from
@@ -1332,13 +1413,18 @@ export async function installOpenCode(
1332
1413
  }
1333
1414
  }
1334
1415
  }
1335
- // V2 plugin entries are resolved by OpenCode as npm packages. Pin the
1336
- // exact current version and use the package-root export, which is the V2
1337
- // default plugin. Unlike an absolute path into ROOT, this survives moving
1338
- // between a global install and an npx cache; unlike an unpinned package,
1339
- // it cannot silently change underneath an existing config. Legacy local
1340
- // directory/file refs are recognized via managedPluginIdentity and are
1341
- // replaced here.
1416
+ // The beta resolves every `plugins` entry through npm install, so an
1417
+ // `a/b` shorthand (e.g. the published `pantheon-opencode/plugin-v2`
1418
+ // export) is treated as a GitHub repo and dies with NpmInstallFailedError
1419
+ // (beta log evidence). The beta loader additionally requires each entry
1420
+ // to be a DIRECTORY with a real index.js/index.ts — a file path warns
1421
+ // "must be a directory" and a symlink is ignored (sandbox probe
1422
+ // evidence). Register the V2 plugin DIRECTORY inside the INSTALLED
1423
+ // package — exactly like V1's ensurePantheonPlugin, but pointing at
1424
+ // src/plugin-v2 whose index.ts shim re-exports the real entry.
1425
+ // Legacy refs (npm shorthand, the old src/plugin-v2.ts file path) are
1426
+ // recognized via managedPluginIdentity and already stripped above, so
1427
+ // they migrate here.
1342
1428
  const resolvedV2 = resolveInstalledPlugin(PANTHEON_V2_PLUGIN)
1343
1429
  config.plugins = config.plugins.filter(
1344
1430
  (plugin) => pluginReferenceIdentity(plugin) !== pluginReferenceIdentity(resolvedV2),
@@ -1522,16 +1608,6 @@ export async function installOpenCode(
1522
1608
  configToWrite.plugins,
1523
1609
  config.context_sandbox,
1524
1610
  )
1525
-
1526
- // Bootstrap chicken-and-egg: the pin names the CURRENT package version,
1527
- // which exists on the registry only after publish. From an npm install
1528
- // (global prefix / npx cache) that is the published version by
1529
- // construction; from a repo checkout the pin may point at an unpublished
1530
- // version the host cannot resolve. Warn instead of failing — the config is
1531
- // structurally correct and the pin realigns once the version is out.
1532
- if (!RUNNING_FROM_NPM_INSTALL) {
1533
- warning(S.v2UnpublishedPin(PANTHEON_V2_PACKAGE_SPEC))
1534
- }
1535
1611
  }
1536
1612
 
1537
1613
  const configContent = `${JSON.stringify(configToWrite, null, 2)}\n`
@@ -1635,4 +1711,25 @@ export async function installOpenCode(
1635
1711
  if (stats.errors > 0) {
1636
1712
  throw new Error(`installation completed with ${stats.errors} error(s); see output above`)
1637
1713
  }
1714
+
1715
+ // Record the component state only after the install has completed. The
1716
+ // postinstall marker can predate `init` and start with every component
1717
+ // pending; leaving it untouched made a healthy beta install look incomplete
1718
+ // to `doctor` forever. Unselected components retain their previous state.
1719
+ if (!dryRun) {
1720
+ const packageVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
1721
+ let state = readState(target)
1722
+ if (!state && hasState(target)) {
1723
+ throw new Error('Installation state exists but cannot be read; refusing to replace it')
1724
+ }
1725
+ state ??= createInitialState(packageVersion)
1726
+ state.pantheon_version = packageVersion
1727
+ for (const component of ['agents', 'skills', 'plugins', 'runtime']) {
1728
+ if (!componentSet.has(component)) continue
1729
+ const status = component === 'runtime' && !runtimeHealthy ? 'failed' : 'installed'
1730
+ const componentVersion = state.components?.[component]?.version ?? 1
1731
+ setComponentState(state, component, componentVersion, status)
1732
+ }
1733
+ writeState(target, state)
1734
+ }
1638
1735
  }
@@ -8,24 +8,24 @@ import { isAbsolute, join, resolve } from 'node:path'
8
8
  import { ROOT, writeIfChanged } from './shared.mjs'
9
9
 
10
10
  /**
11
- * Copy a plugin's runtime payload (the bundled dist/*, package.json and
12
- * package-lock.json) from source to destination. Only the bundle is copied —
13
- * the raw TSX source is never shipped to the destination, so the plugin can
14
- * be modularized without the loader needing relative imports.
11
+ * Copy a plugin's runtime payload (the root TUI loader, bundled dist/*,
12
+ * package.json and package-lock.json) from source to destination. The raw TSX
13
+ * implementation is never shipped to the destination; tui.ts only re-exports
14
+ * the host-specific bundle so the local plugin stays hermetic.
15
15
  * @param {string} srcDir - Source plugin directory (e.g. ROOT/src/plugins/tui)
16
16
  * @param {string} dstDir - Destination plugin directory
17
17
  * @param {object} [options]
18
18
  * @param {boolean} [options.dryRun=false] - If true, don't write files
19
+ * @param {'v1'|'v2'} [options.version='v1'] - Host plugin generation
19
20
  * @returns {{ created: number, skipped: number }}
20
21
  */
21
- export function copyPluginFiles(srcDir, dstDir, { dryRun = false } = {}) {
22
+ export function copyPluginFiles(srcDir, dstDir, { dryRun = false, version = 'v1' } = {}) {
22
23
  const result = { created: 0, skipped: 0 }
23
24
 
24
25
  if (!dryRun) mkdirSync(dstDir, { recursive: true })
25
26
  if (!dryRun) mkdirSync(join(dstDir, 'dist'), { recursive: true })
26
27
 
27
28
  // Drop legacy raw copies from previous installs (root index.tsx + dist/tui.tsx).
28
- // The loader consumes only the bundle, so stale raws must not linger.
29
29
  if (!dryRun) {
30
30
  rmSync(join(dstDir, 'index.tsx'), { force: true })
31
31
  rmSync(join(dstDir, 'dist', 'tui.tsx'), { force: true })
@@ -45,15 +45,45 @@ export function copyPluginFiles(srcDir, dstDir, { dryRun = false } = {}) {
45
45
  // Copy package.json
46
46
  const pkgSrc = join(srcDir, 'package.json')
47
47
  if (existsSync(pkgSrc)) {
48
+ const packageJson = JSON.parse(readFileSync(pkgSrc, 'utf8'))
49
+ if (version === 'v2' && packageJson.exports?.['./tui-v2']) {
50
+ packageJson.exports['./tui'] = packageJson.exports['./tui-v2']
51
+ }
48
52
  const pkgStatus = writeIfChanged(
49
53
  join(dstDir, 'package.json'),
50
- readFileSync(pkgSrc, 'utf8'),
54
+ `${JSON.stringify(packageJson, null, 2)}\n`,
51
55
  dryRun,
52
56
  )
53
57
  if (pkgStatus === 'created') result.created++
54
58
  else result.skipped++
55
59
  }
56
60
 
61
+ // Each host generation receives its own root loader and compiled bundle.
62
+ const tuiEntrySrc = join(srcDir, version === 'v2' ? 'tui-v2.ts' : 'tui.ts')
63
+ if (existsSync(tuiEntrySrc)) {
64
+ const entryStatus = writeIfChanged(
65
+ join(dstDir, 'tui.ts'),
66
+ readFileSync(tuiEntrySrc, 'utf8'),
67
+ dryRun,
68
+ )
69
+ if (entryStatus === 'created') result.created++
70
+ else result.skipped++
71
+ }
72
+
73
+ const v2ServerEntry = join(srcDir, 'index-v2.ts')
74
+ const v2ServerDestination = join(dstDir, 'index.ts')
75
+ if (version === 'v2' && existsSync(v2ServerEntry)) {
76
+ const entryStatus = writeIfChanged(
77
+ v2ServerDestination,
78
+ readFileSync(v2ServerEntry, 'utf8'),
79
+ dryRun,
80
+ )
81
+ if (entryStatus === 'created') result.created++
82
+ else result.skipped++
83
+ } else if (!dryRun) {
84
+ rmSync(v2ServerDestination, { force: true })
85
+ }
86
+
57
87
  // Copy the lockfile so dependency installation is reproducible. A missing
58
88
  // lockfile is intentionally not synthesized by the installer.
59
89
  const lockSrc = join(srcDir, 'package-lock.json')
@@ -79,14 +109,18 @@ export function copyPluginFiles(srcDir, dstDir, { dryRun = false } = {}) {
79
109
  * @param {boolean} [options.clean=false] - If true, remove existing before install
80
110
  * @returns {{ created: number, skipped: number, errors: number }}
81
111
  */
82
- export function installPlugin(srcDir, dstDir, { dryRun = false, clean = false } = {}) {
112
+ export function installPlugin(
113
+ srcDir,
114
+ dstDir,
115
+ { dryRun = false, clean = false, version = 'v1' } = {},
116
+ ) {
83
117
  const result = { created: 0, skipped: 0, errors: 0 }
84
118
 
85
119
  if (clean && existsSync(dstDir) && !dryRun) {
86
120
  rmSync(dstDir, { recursive: true, force: true })
87
121
  }
88
122
 
89
- const copyResult = copyPluginFiles(srcDir, dstDir, { dryRun })
123
+ const copyResult = copyPluginFiles(srcDir, dstDir, { dryRun, version })
90
124
  result.created += copyResult.created
91
125
  result.skipped += copyResult.skipped
92
126
 
@@ -47,10 +47,6 @@ const TABLE = {
47
47
  prereqMissing:
48
48
  'runtime prerequisites missing — install the tools above, or re-run with --no-mcp to install without Python MCP servers',
49
49
  v2Migrating: 'Migrating config to V2 native format...',
50
- v2UnpublishedPin: (spec) =>
51
- `V2 plugin is pinned to ${spec}, but this installer is running from a source checkout — ` +
52
- 'that version may not be published to npm yet, so OpenCode cannot resolve the pin until it is. ' +
53
- 'Run the installer from the published package (npx pantheon-opencode init) to avoid this.',
54
50
  // summary (printSummary)
55
51
  summaryInstalled: (target) => `OpenCode installed in ${target}`,
56
52
  summaryWithErrors: (target) => `Installation finished WITH ERRORS in ${target}`,
@@ -99,10 +95,6 @@ const TABLE = {
99
95
  prereqMissing:
100
96
  'pré-requisitos do runtime ausentes — instale as ferramentas acima, ou rode de novo com --no-mcp para instalar sem os MCPs Python',
101
97
  v2Migrating: 'Migrando config para o formato nativo V2...',
102
- v2UnpublishedPin: (spec) =>
103
- `Plugin V2 fixado em ${spec}, mas este installer está rodando de um checkout do código-fonte — ` +
104
- 'essa versão pode ainda não estar publicada no npm, então o OpenCode não resolve o pin até a publicação. ' +
105
- 'Rode o installer pelo pacote publicado (npx pantheon-opencode init) para evitar isso.',
106
98
  // summary (printSummary)
107
99
  summaryInstalled: (target) => `OpenCode instalado em ${target}`,
108
100
  summaryWithErrors: (target) => `Instalação concluída COM ERROS em ${target}`,
@@ -8,10 +8,9 @@
8
8
  * temporary synthetic opencode database. A missing fixture runtime is also
9
9
  * AMBIENTAL rather than a false PASS.
10
10
  */
11
- import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'
11
+ import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
12
12
  import { tmpdir } from 'node:os'
13
13
  import { join } from 'node:path'
14
- import { fileURLToPath, pathToFileURL } from 'node:url'
15
14
 
16
15
  const requestedVersion = valueFor('--version')
17
16
  const jsonOutput = process.argv.includes('--json')
@@ -152,27 +151,19 @@ async function main() {
152
151
  return
153
152
  }
154
153
 
155
- const root = mkdtempSync(join(tmpdir(), 'pantheon-cost-probe-'))
156
154
  let DatabaseSync
157
155
  let createCostCommand
158
156
  try {
159
157
  ;({ DatabaseSync } = await import('node:sqlite'))
160
- // Node's built-in TS stripping deliberately refuses files under
161
- // node_modules. Exercise the shipped command from an isolated temp copy
162
- // while resolving its runtime dependencies through the installed package.
163
- const runtimeSource = join(root, 'cost-command.ts')
164
- const packageNodeModules = fileURLToPath(new URL('../node_modules/', import.meta.url))
165
- copyFileSync(fileURLToPath(source), runtimeSource)
166
- symlinkSync(packageNodeModules, join(root, 'node_modules'), 'dir')
167
- ;({ createCostCommand } = await import(pathToFileURL(runtimeSource).href))
158
+ ;({ createCostCommand } = await import(source.href))
168
159
  } catch (error) {
169
- rmSync(root, { recursive: true, force: true })
170
160
  ambiental(
171
161
  `pantheon_cost fixture runtime unavailable: ${error instanceof Error ? error.message : String(error)}`,
172
162
  )
173
163
  return
174
164
  }
175
165
 
166
+ const root = mkdtempSync(join(tmpdir(), 'pantheon-cost-probe-'))
176
167
  const dataHome = join(root, 'data')
177
168
  const opencodeDir = join(dataHome, 'opencode')
178
169
  // Every real installation writes `opencode.db`; the v1/v2 distinction is the
@@ -263,7 +254,6 @@ async function main() {
263
254
 
264
255
  const restore = setEnvironment({
265
256
  XDG_DATA_HOME: dataHome,
266
- OPENCODE_DB: undefined,
267
257
  PANTHEON_OPENCODE_VERSION: requestedVersion,
268
258
  PANTHEON_COST_DB: undefined,
269
259
  })
@@ -294,7 +284,6 @@ async function main() {
294
284
 
295
285
  const envRestore = setEnvironment({
296
286
  XDG_DATA_HOME: dataHome,
297
- OPENCODE_DB: undefined,
298
287
  PANTHEON_OPENCODE_VERSION: requestedVersion,
299
288
  PANTHEON_COST_DB: envDb,
300
289
  })
@@ -319,7 +308,6 @@ async function main() {
319
308
 
320
309
  const explicitRestore = setEnvironment({
321
310
  XDG_DATA_HOME: dataHome,
322
- OPENCODE_DB: undefined,
323
311
  PANTHEON_OPENCODE_VERSION: requestedVersion,
324
312
  PANTHEON_COST_DB: envDb,
325
313
  })
@@ -341,7 +329,6 @@ async function main() {
341
329
 
342
330
  const missingRestore = setEnvironment({
343
331
  XDG_DATA_HOME: join(root, 'missing-data'),
344
- OPENCODE_DB: undefined,
345
332
  PANTHEON_OPENCODE_VERSION: requestedVersion,
346
333
  PANTHEON_COST_DB: undefined,
347
334
  })
@@ -40,6 +40,7 @@ const MCP_SCRIPTS = {
40
40
  'pantheon_vision.py': () => join(ROOT, 'src', 'mcp', 'pantheon_vision.py'),
41
41
  'eval_store.py': () => join(ROOT, 'src', 'mcp', 'eval_store.py'),
42
42
  'scrub-secrets.py': () => join(ROOT, 'scripts', 'scrub-secrets.py'),
43
+ 'themis_heuristic_scan.py': () => join(ROOT, 'scripts', 'themis_heuristic_scan.py'),
43
44
  }
44
45
 
45
46
  /**