pantheon-opencode 1.5.0-beta.3 → 1.5.0-beta.5

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 (44) hide show
  1. package/.pantheon/code-mode/manifest.json +21 -0
  2. package/CHANGELOG.md +871 -0
  3. package/README.md +101 -1
  4. package/README.pt-BR.md +55 -1
  5. package/bin/pantheon-init.mjs +178 -21
  6. package/package.json +14 -10
  7. package/plugin.json +1 -1
  8. package/pyproject.toml +1 -1
  9. package/scripts/code_mode_server.py +339 -28
  10. package/scripts/doctor.mjs +178 -9
  11. package/scripts/install/cli-ui.mjs +17 -11
  12. package/scripts/install/config-migration.mjs +4 -6
  13. package/scripts/install/opencode.mjs +116 -35
  14. package/scripts/install/shared.mjs +43 -2
  15. package/scripts/install/strings.mjs +129 -0
  16. package/scripts/install-mcp.mjs +1 -0
  17. package/scripts/probe-pantheon-cost.mjs +10 -1
  18. package/scripts/release-beta-version.mjs +2 -7
  19. package/scripts/sync-artifacts.mjs +169 -0
  20. package/scripts/sync-tui.mjs +124 -34
  21. package/src/agents/zeus.md +2 -2
  22. package/src/mcp/code_mode_server.py +339 -28
  23. package/src/pantheon/auto-wake.ts +1 -5
  24. package/src/pantheon/background-job-board.ts +14 -0
  25. package/src/pantheon/cost-command.ts +62 -42
  26. package/src/pantheon/delegate-manager.ts +109 -12
  27. package/src/pantheon/native-probe.ts +234 -0
  28. package/src/pantheon/native-task-status.ts +139 -0
  29. package/src/pantheon/shared-board.ts +37 -0
  30. package/src/pantheon/task-result-guard.ts +107 -0
  31. package/src/pantheon/token-opt.ts +18 -110
  32. package/src/pantheon/tool-ceiling.ts +159 -0
  33. package/src/pantheon/v2-events.ts +10 -1
  34. package/src/plugin.ts +25 -9
  35. package/src/plugins/pantheon-hooks.ts +51 -0
  36. package/src/plugins/tui/dist/server.d.ts +1 -1
  37. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  38. package/src/plugins/tui/dist/server.js +1 -1
  39. package/src/plugins/tui/dist/server.js.map +1 -1
  40. package/src/plugins/tui/dist/tui.d.ts +1 -1
  41. package/src/plugins/tui/dist/tui.js +1 -1
  42. package/src/plugins/tui/dist/tui.js.map +1 -1
  43. package/src/plugins/tui/package-lock.json +2 -2
  44. package/src/plugins/tui/package.json +1 -1
@@ -0,0 +1,129 @@
1
+ /**
2
+ * strings.mjs — installer strings with locale auto-detection.
3
+ *
4
+ * LANG/LC_ALL/LC_MESSAGES starting with "pt" selects the PT-BR table;
5
+ * everything else (and the absence of a locale) selects EN. A plain string
6
+ * table — deliberately no i18n framework (YAGNI).
7
+ *
8
+ * Scope: the user journey (banner → install steps → health → summary →
9
+ * next steps). Deep diagnostics (per-file copy detail, doctor output) and
10
+ * the model-picker wizard remain single-language by design.
11
+ */
12
+
13
+ const TABLE = {
14
+ en: {
15
+ installing: 'Installing...',
16
+ dryRun: 'DRY RUN',
17
+ canceled: 'Installation canceled — nothing was broken; run init again anytime.',
18
+ canceledShort: 'Installation canceled.',
19
+ installFailed: (msg) => `❌ Installation failed: ${msg}`,
20
+ failHintNoMcp:
21
+ ' Run with --no-mcp to skip Python dependencies:\n npx pantheon-opencode init --no-mcp',
22
+ failHintForce:
23
+ ' Or retry with --force to recreate the venv:\n npx pantheon-opencode init --force',
24
+ runningHealthCheck: 'Running health check...',
25
+ installedTitle: (v) => `✅ Pantheon OpenCode v${v} installed!`,
26
+ nextSteps: 'Next steps:',
27
+ nextVerify: '1. Verify installation:\n npx pantheon-opencode doctor',
28
+ nextLaunch: '2. Launch OpenCode',
29
+ nextAgents: '3. Invoke agents with @agent-name in chat',
30
+ nextProject: '4. For project-local install:\n npx pantheon-opencode init --project',
31
+ // install steps
32
+ globalLayout:
33
+ 'Global config directory detected — using flat layout (agents/, skills/, commands/)',
34
+ installingAgents: 'Installing agents',
35
+ installingSkills: 'Installing skills',
36
+ installingInstructions: 'Installing instructions',
37
+ installingPrompts: 'Installing prompts',
38
+ installingCommands: 'Installing commands',
39
+ installingPlugins: 'Installing plugins',
40
+ settingUpRuntime: 'Setting up Python virtual environment',
41
+ healthSection: 'Health Check',
42
+ runningHealthChecks: 'Running health checks',
43
+ runtimeFailed: (msg) => `Python runtime setup failed: ${msg}`,
44
+ runtimeSkippedMcp:
45
+ 'MCP servers were NOT configured because the Python runtime is unavailable. ' +
46
+ 'Fix python3 (or free disk space) and re-run init to add them; everything else was installed.',
47
+ prereqMissing:
48
+ 'runtime prerequisites missing — install the tools above, or re-run with --no-mcp to install without Python MCP servers',
49
+ v2Migrating: 'Migrating config to V2 native format...',
50
+ // summary (printSummary)
51
+ summaryInstalled: (target) => `OpenCode installed in ${target}`,
52
+ summaryWithErrors: (target) => `Installation finished WITH ERRORS in ${target}`,
53
+ summaryComponents: (created, skipped) =>
54
+ ` Components:\t${created} installed, ${skipped} skipped`,
55
+ summaryErrors: (n) => ` ${n} error(s) found`,
56
+ summaryWarnings: (n) => ` ${n} warning(s) — run 'doctor' for details`,
57
+ summaryNext: ' Next steps:',
58
+ summaryNextAgents: ' • Configure your agents in opencode.json',
59
+ summaryNextMcp: ' • Add MCP servers in opencode.json (mcp section)',
60
+ summaryNextDoctor: " • Run 'opencode doctor' to verify the installation",
61
+ },
62
+ pt: {
63
+ installing: 'Instalando...',
64
+ dryRun: 'SIMULAÇÃO',
65
+ canceled: 'Instalação cancelada — nada foi quebrado; rode init de novo quando quiser.',
66
+ canceledShort: 'Instalação cancelada.',
67
+ installFailed: (msg) => `❌ Instalação falhou: ${msg}`,
68
+ failHintNoMcp:
69
+ ' Rode com --no-mcp para pular dependências Python:\n npx pantheon-opencode init --no-mcp',
70
+ failHintForce:
71
+ ' Ou tente de novo com --force para recriar a venv:\n npx pantheon-opencode init --force',
72
+ runningHealthCheck: 'Rodando verificação de saúde...',
73
+ installedTitle: (v) => `✅ Pantheon OpenCode v${v} instalado!`,
74
+ nextSteps: 'Próximos passos:',
75
+ nextVerify: '1. Verifique a instalação:\n npx pantheon-opencode doctor',
76
+ nextLaunch: '2. Abra o OpenCode',
77
+ nextAgents: '3. Invoc agentes com @nome-do-agente no chat',
78
+ nextProject: '4. Para instalar no projeto:\n npx pantheon-opencode init --project',
79
+ // install steps
80
+ globalLayout:
81
+ 'Diretório de config global detectado — layout plano (agents/, skills/, commands/)',
82
+ installingAgents: 'Instalando agentes',
83
+ installingSkills: 'Instalando skills',
84
+ installingInstructions: 'Instalando instruções',
85
+ installingPrompts: 'Instalando prompts',
86
+ installingCommands: 'Instalando comandos',
87
+ installingPlugins: 'Instalando plugins',
88
+ settingUpRuntime: 'Configurando ambiente virtual Python',
89
+ healthSection: 'Verificação de saúde',
90
+ runningHealthChecks: 'Rodando verificações de saúde',
91
+ runtimeFailed: (msg) => `Falha ao configurar o runtime Python: ${msg}`,
92
+ runtimeSkippedMcp:
93
+ 'Os servidores MCP NÃO foram configurados porque o runtime Python está indisponível. ' +
94
+ 'Corrija o python3 (ou libere espaço em disco) e rode init de novo para adicioná-los; todo o resto foi instalado.',
95
+ prereqMissing:
96
+ 'pré-requisitos do runtime ausentes — instale as ferramentas acima, ou rode de novo com --no-mcp para instalar sem os MCPs Python',
97
+ v2Migrating: 'Migrando config para o formato nativo V2...',
98
+ // summary (printSummary)
99
+ summaryInstalled: (target) => `OpenCode instalado em ${target}`,
100
+ summaryWithErrors: (target) => `Instalação concluída COM ERROS em ${target}`,
101
+ summaryComponents: (created, skipped) =>
102
+ ` Componentes:\t${created} instalados, ${skipped} pulados`,
103
+ summaryErrors: (n) => ` ${n} erro(s) encontrados`,
104
+ summaryWarnings: (n) => ` ${n} aviso(s) — rode 'doctor' para detalhes`,
105
+ summaryNext: ' Próximos passos:',
106
+ summaryNextAgents: ' • Configure seus agentes em opencode.json',
107
+ summaryNextMcp: ' • Adicione MCP servers em opencode.json (seção mcp)',
108
+ summaryNextDoctor: " • Rode 'opencode doctor' para verificar a instalação",
109
+ },
110
+ }
111
+
112
+ /** Pure locale resolution (exported for tests). */
113
+ export function detectLocale(env = process.env) {
114
+ const raw = env.LC_ALL ?? env.LC_MESSAGES ?? env.LANG ?? ''
115
+ return /^pt/i.test(raw) ? 'pt' : 'en'
116
+ }
117
+
118
+ /** Pure table access (exported for tests and explicit-locale callers). */
119
+ export function stringsFor(locale) {
120
+ return TABLE[locale] ?? TABLE.en
121
+ }
122
+
123
+ let cached = null
124
+
125
+ /** Locale-resolved string table for the current process. */
126
+ export function strings(env = process.env) {
127
+ if (cached === null) cached = stringsFor(detectLocale(env))
128
+ return cached
129
+ }
@@ -221,6 +221,7 @@ export const MCPS = {
221
221
  type: 'local',
222
222
  command: VENV_PYTHON,
223
223
  args: [CODE_MODE_SERVER],
224
+ cwd: '.',
224
225
  },
225
226
  },
226
227
  env: [],
@@ -28,6 +28,15 @@ function emit(status, detail, checks = []) {
28
28
  if (status === 'FAIL') process.exitCode = 1
29
29
  }
30
30
 
31
+ function diagnostic(error) {
32
+ const message = error instanceof Error ? error.message : String(error)
33
+ const stderr =
34
+ error && typeof error === 'object' && typeof error.stderr === 'string'
35
+ ? error.stderr.trim()
36
+ : ''
37
+ return stderr === '' ? message : `${message}; stderr: ${stderr}`
38
+ }
39
+
31
40
  function ambiental(detail) {
32
41
  emit('AMBIENTAL', detail)
33
42
  }
@@ -295,7 +304,7 @@ async function main() {
295
304
  checks.push('incompatible schema: PASS')
296
305
  emit('PASS', 'offline synthetic DB checks passed; no real DB was read', checks)
297
306
  } catch (error) {
298
- emit('FAIL', error instanceof Error ? error.message : String(error), checks)
307
+ emit('FAIL', diagnostic(error), checks)
299
308
  } finally {
300
309
  rmSync(root, { recursive: true, force: true })
301
310
  }
@@ -14,18 +14,13 @@
14
14
  * compatibility only; it is no longer generated.
15
15
  */
16
16
 
17
- import {
18
- SEMVER_PATTERN,
19
- validateInventory,
20
- writeInventoryVersion,
21
- } from './manifest-inventory.mjs'
17
+ import { SEMVER_PATTERN, validateInventory, writeInventoryVersion } from './manifest-inventory.mjs'
22
18
 
23
19
  const STABLE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/
24
20
  // Current committed sequential beta line.
25
21
  const BETA_LINE = /^(\d+\.\d+\.\d+)-beta\.(\d+)$/
26
22
  // Legacy PR+SHA beta scheme, accepted for recovery only.
27
- const LEGACY_BETA =
28
- /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-beta\.[1-9]\d*\.[0-9a-f]{7}$/
23
+ const LEGACY_BETA = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-beta\.[1-9]\d*\.[0-9a-f]{7}$/
29
24
  const BETA_RELEASE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-beta\.[1-9]\d*$/
30
25
 
31
26
  export function nextStableVersion(latest, intent = 'patch') {
@@ -0,0 +1,169 @@
1
+ /**
2
+ * sync-artifacts.mjs — postinstall freshness sync for copy-only artifacts.
3
+ *
4
+ * Guarantees that an `npm install -g pantheon-opencode` (or npx cache
5
+ * refresh) brings an EXISTING installation up to date for everything that is
6
+ * a pure byte-compare copy: agents + routing.yml, skills, AGENTS.md,
7
+ * commands, the runtime MCP scripts, the code-mode payload and tiers.json.
8
+ *
9
+ * Deliberately NOT done here (they belong to `pantheon-opencode init` /
10
+ * `update`, which the doctor points at when versions drift):
11
+ * - opencode.json merge (config keys, plugin registration, MCP entries)
12
+ * - venv/pip setup
13
+ * - tui.json registration changes
14
+ *
15
+ * The user's opencode.json already references the installed package root for
16
+ * plugins, so after a package update those references hit the NEW code
17
+ * automatically; this sync refreshes the COPIES that would otherwise stay
18
+ * stale until the next manual init.
19
+ */
20
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
21
+ import { join } from 'node:path'
22
+
23
+ import {
24
+ collectSkillNames,
25
+ copyFiles,
26
+ installSkills,
27
+ ROOT,
28
+ syncDir,
29
+ writeIfChanged,
30
+ } from './install/shared.mjs'
31
+
32
+ /** Canonical MCP script sources — mirrors installOpenCode phase 2.10. */
33
+ const MCP_SCRIPTS = {
34
+ 'mcp_resources_server.py': () => join(ROOT, 'src', 'mcp', 'mcp_resources_server.py'),
35
+ 'code_mode_server.py': () => join(ROOT, 'src', 'mcp', 'code_mode_server.py'),
36
+ 'memory_mcp_server.py': () => join(ROOT, 'src', 'mcp', 'memory_mcp_server.py'),
37
+ 'mcp_persistence_server.py': () => join(ROOT, 'src', 'mcp', 'mcp_persistence_server.py'),
38
+ '_pantheon_paths.py': () => join(ROOT, 'src', 'mcp', '_pantheon_paths.py'),
39
+ 'mcp_codemap_module.py': () => join(ROOT, 'src', 'mcp', 'mcp_codemap_module.py'),
40
+ 'pantheon_vision_server.py': () => join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py'),
41
+ 'eval_store.py': () => join(ROOT, 'src', 'mcp', 'eval_store.py'),
42
+ 'scrub-secrets.py': () => join(ROOT, 'scripts', 'scrub-secrets.py'),
43
+ }
44
+
45
+ /**
46
+ * Sync all copy-only artifacts from the installed package into configDir
47
+ * (the GLOBAL OpenCode config dir). Byte-compare idempotent; never throws.
48
+ * @returns {{ created: number, skipped: number, errors: string[] }}
49
+ */
50
+ export function syncCopyArtifacts(configDir) {
51
+ const result = { created: 0, skipped: 0, errors: [] }
52
+ const bump = (r) => {
53
+ result.created += r.created
54
+ result.skipped += r.skipped
55
+ }
56
+
57
+ // ── agents + routing.yml ──
58
+ try {
59
+ const dstAgents = join(configDir, 'agents')
60
+ if (existsSync(join(ROOT, 'src', 'agents'))) {
61
+ mkdirSync(dstAgents, { recursive: true })
62
+ bump(copyFiles(join(ROOT, 'src', 'agents'), dstAgents, false))
63
+ const routing = join(ROOT, 'src', 'routing.yml')
64
+ if (existsSync(routing)) {
65
+ if (
66
+ writeIfChanged(join(configDir, 'routing.yml'), readFileSync(routing, 'utf8'), false) ===
67
+ 'created'
68
+ ) {
69
+ result.created++
70
+ } else result.skipped++
71
+ }
72
+ }
73
+ } catch (err) {
74
+ result.errors.push(`agents: ${err.message}`)
75
+ }
76
+
77
+ // ── skills ──
78
+ try {
79
+ const names = collectSkillNames()
80
+ if (names.length > 0) bump(installSkills(names, configDir, false, ''))
81
+ } catch (err) {
82
+ result.errors.push(`skills: ${err.message}`)
83
+ }
84
+
85
+ // ── instructions (AGENTS.md) ──
86
+ try {
87
+ const src = join(ROOT, 'AGENTS.md')
88
+ if (existsSync(src)) {
89
+ if (
90
+ writeIfChanged(join(configDir, 'AGENTS.md'), readFileSync(src, 'utf8'), false) === 'created'
91
+ ) {
92
+ result.created++
93
+ } else result.skipped++
94
+ }
95
+ } catch (err) {
96
+ result.errors.push(`instructions: ${err.message}`)
97
+ }
98
+
99
+ // ── commands (*.md) ──
100
+ try {
101
+ const srcCmds = join(ROOT, 'commands')
102
+ if (existsSync(srcCmds)) {
103
+ bump(syncDir(srcCmds, join(configDir, 'commands'), false, false, (f) => f.endsWith('.md')))
104
+ }
105
+ } catch (err) {
106
+ result.errors.push(`commands: ${err.message}`)
107
+ }
108
+
109
+ // ── runtime payload (MCP scripts, code-mode, tiers) ──
110
+ try {
111
+ const dstScripts = join(configDir, 'scripts')
112
+ mkdirSync(dstScripts, { recursive: true })
113
+ for (const [name, srcFn] of Object.entries(MCP_SCRIPTS)) {
114
+ const src = srcFn()
115
+ if (!existsSync(src)) continue
116
+ const content = readFileSync(src, 'utf8')
117
+ const dst = join(dstScripts, name)
118
+ if (writeIfChanged(dst, content, false) === 'created') {
119
+ statSync(dst)
120
+ result.created++
121
+ } else result.skipped++
122
+ }
123
+
124
+ const req = join(ROOT, 'src', 'mcp', 'requirements-vision.txt')
125
+ if (existsSync(req)) {
126
+ if (
127
+ writeIfChanged(
128
+ join(configDir, 'requirements-vision.txt'),
129
+ readFileSync(req, 'utf8'),
130
+ false,
131
+ ) === 'created'
132
+ ) {
133
+ result.created++
134
+ } else result.skipped++
135
+ }
136
+
137
+ const srcCodeMode = join(ROOT, '.pantheon', 'code-mode')
138
+ const dstCodeMode = join(configDir, '.pantheon', 'code-mode')
139
+ if (existsSync(srcCodeMode)) {
140
+ bump(syncDir(srcCodeMode, dstCodeMode, false, false))
141
+ }
142
+
143
+ const srcTiers = join(ROOT, '.pantheon', 'tiers.json')
144
+ const dstTiers = join(configDir, '.pantheon', 'tiers.json')
145
+ if (existsSync(srcTiers)) {
146
+ if (writeIfChanged(dstTiers, readFileSync(srcTiers, 'utf8'), false) === 'created')
147
+ result.created++
148
+ else result.skipped++
149
+ }
150
+ } catch (err) {
151
+ result.errors.push(`runtime: ${err.message}`)
152
+ }
153
+
154
+ return result
155
+ }
156
+
157
+ /** Drop stale entries in dst that no longer exist in src (agents/skills dirs). */
158
+ export function pruneMissing(srcDir, dstDir) {
159
+ if (!existsSync(srcDir) || !existsSync(dstDir)) return 0
160
+ const src = new Set(readdirSync(srcDir))
161
+ let removed = 0
162
+ for (const entry of readdirSync(dstDir)) {
163
+ if (!src.has(entry)) {
164
+ rmSync(join(dstDir, entry), { recursive: true, force: true })
165
+ removed++
166
+ }
167
+ }
168
+ return removed
169
+ }
@@ -13,7 +13,7 @@ import { execSync } from 'node:child_process'
13
13
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
14
14
  import { homedir } from 'node:os'
15
15
  import { join } from 'node:path'
16
- import { fileURLToPath } from 'node:url'
16
+ import { fileURLToPath, pathToFileURL } from 'node:url'
17
17
 
18
18
  const __dirname = fileURLToPath(new URL('.', import.meta.url))
19
19
  const ROOT = join(__dirname, '..')
@@ -106,46 +106,136 @@ function copyPluginFiles(srcDir, dstDir) {
106
106
  return result
107
107
  }
108
108
 
109
- // ── Main ───────────────────────────────────────────────────────────────
109
+ // ── npm env hygiene ────────────────────────────────────────────────────
110
110
 
111
- try {
112
- const configDir = resolveConfigDir()
113
- if (!configDir) {
114
- // User hasn't run init yet — silent exit, no error
115
- process.exit(0)
116
- }
111
+ /**
112
+ * npm lifecycle env vars that put a nested `npm ci` into global context and
113
+ * make it abort with ECIGLOBAL ("`npm ci` does not work for global packages").
114
+ * When the package itself is installed with `npm install -g`, npm injects these
115
+ * into the postinstall environment; the TUI dependency sync is a local
116
+ * operation and must never inherit them.
117
+ */
118
+ const GLOBAL_NPM_ENV_KEYS = new Set([
119
+ 'npm_config_global',
120
+ 'npm_config_globalconfig',
121
+ 'npm_config_prefix',
122
+ 'npm_config_location',
123
+ ])
117
124
 
118
- const tuiCopyDir = join(configDir, 'plugins', 'pantheon-tui')
119
- if (!existsSync(tuiCopyDir)) {
120
- // TUI plugin not installed — silent exit
121
- process.exit(0)
125
+ /**
126
+ * Return a copy of `env` with global-context npm config vars removed.
127
+ * Matching is case-insensitive so Windows-style uppercase keys are handled.
128
+ * @param {NodeJS.ProcessEnv} [env]
129
+ * @returns {NodeJS.ProcessEnv}
130
+ */
131
+ export function sanitizeNpmEnv(env = process.env) {
132
+ const clean = { ...env }
133
+ for (const key of Object.keys(clean)) {
134
+ if (GLOBAL_NPM_ENV_KEYS.has(key.toLowerCase())) delete clean[key]
122
135
  }
136
+ return clean
137
+ }
123
138
 
124
- // Source: TUI plugin inside the installed package
125
- const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
126
- if (!existsSync(tuiSrcDir)) {
127
- throw new Error(`TUI plugin source not found: ${tuiSrcDir}`)
128
- }
139
+ // ── Main ───────────────────────────────────────────────────────────────
140
+
141
+ async function main() {
142
+ try {
143
+ const configDir = resolveConfigDir()
144
+ if (!configDir) {
145
+ // User hasn't run init yet — silent exit, no error
146
+ process.exit(0)
147
+ }
129
148
 
130
- // Compare versions before copy
131
- const installedVersion = readVersion(join(tuiCopyDir, 'package.json'))
132
- const sourceVersion = readVersion(join(tuiSrcDir, 'package.json'))
149
+ const tuiCopyDir = join(configDir, 'plugins', 'pantheon-tui')
150
+ if (!existsSync(tuiCopyDir)) {
151
+ // beta.5: no TUI install (plugins component omitted) — skip the TUI
152
+ // sync but STILL refresh the copy-only artifacts below.
153
+ const { syncCopyArtifacts } = await import('./sync-artifacts.mjs')
154
+ const synced = syncCopyArtifacts(configDir)
155
+ if (synced.errors.length > 0) {
156
+ for (const e of synced.errors) console.error(` ⚠️ artifact sync: ${e}`)
157
+ }
158
+ console.log(
159
+ ` Artifacts synced: ${synced.created} updated, ${synced.skipped} already current`,
160
+ )
161
+ return
162
+ }
133
163
 
134
- // Copy fresh files
135
- const { created } = copyPluginFiles(tuiSrcDir, tuiCopyDir)
164
+ // Source: TUI plugin inside the installed package
165
+ const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
166
+ if (!existsSync(tuiSrcDir)) {
167
+ throw new Error(`TUI plugin source not found: ${tuiSrcDir}`)
168
+ }
136
169
 
137
- // Lockfile installation is the only accepted dependency path.
138
- execSync('npm ci --omit=dev --no-audit --no-fund', { cwd: tuiCopyDir, stdio: 'pipe' })
170
+ // Compare versions before copy
171
+ const installedVersion = readVersion(join(tuiCopyDir, 'package.json'))
172
+ const sourceVersion = readVersion(join(tuiSrcDir, 'package.json'))
173
+
174
+ // Copy fresh files
175
+ const { created } = copyPluginFiles(tuiSrcDir, tuiCopyDir)
176
+
177
+ // Lockfile installation is the only accepted dependency path. Strip
178
+ // global-context npm vars so a global postinstall can't trigger ECIGLOBAL.
179
+ execSync('npm ci --omit=dev --no-audit --no-fund', {
180
+ cwd: tuiCopyDir,
181
+ stdio: 'pipe',
182
+ env: sanitizeNpmEnv(),
183
+ })
184
+
185
+ // Log result
186
+ if (installedVersion && sourceVersion && installedVersion === sourceVersion && created === 0) {
187
+ console.log(` TUI plugin already up to date (v${sourceVersion})`)
188
+ } else {
189
+ console.log(
190
+ ` TUI plugin updated to v${sourceVersion || 'latest'}${installedVersion ? ` (was v${installedVersion})` : ''}`,
191
+ )
192
+ }
139
193
 
140
- // Log result
141
- if (installedVersion && sourceVersion && installedVersion === sourceVersion && created === 0) {
142
- console.log(` TUI plugin already up to date (v${sourceVersion})`)
143
- } else {
144
- console.log(
145
- ` TUI plugin updated to v${sourceVersion || 'latest'}${installedVersion ? ` (was v${installedVersion})` : ''}`,
146
- )
194
+ // ── beta.5 freshness guarantee: refresh ALL copy-only artifacts ──
195
+ // Agents, skills, AGENTS.md, commands, MCP scripts, code-mode payload.
196
+ // The plugin references in opencode.json already point INTO this package,
197
+ // so only the copies would go stale after a package update.
198
+ const { syncCopyArtifacts } = await import('./sync-artifacts.mjs')
199
+ const { readState, writeState } = await import('./install/state.mjs')
200
+ const pkgVersion = readVersion(join(ROOT, 'package.json')) ?? 'unknown'
201
+ const synced = syncCopyArtifacts(configDir)
202
+ if (synced.errors.length > 0) {
203
+ for (const e of synced.errors) console.error(` ⚠️ artifact sync: ${e}`)
204
+ }
205
+ console.log(` Artifacts synced: ${synced.created} updated, ${synced.skipped} already current`)
206
+
207
+ // Version marker so doctor can detect a stale installation and point the
208
+ // user at `pantheon-opencode update` for the full refresh (config merge,
209
+ // venv, MCP entries).
210
+ try {
211
+ const previous = readState(configDir)
212
+ writeState(configDir, {
213
+ pantheon_version: pkgVersion,
214
+ previous_version: previous?.pantheon_version ?? null,
215
+ })
216
+ if (previous?.pantheon_version && previous.pantheon_version !== pkgVersion) {
217
+ console.log(
218
+ ` ⚠️ Installation moved ${previous.pantheon_version} → ${pkgVersion}. ` +
219
+ 'Run `npx pantheon-opencode init` to refresh config/venv/MCP entries.',
220
+ )
221
+ }
222
+ } catch {
223
+ // Marker is best-effort — never fail the postinstall over it.
224
+ }
225
+ } catch (err) {
226
+ console.error(`❌ TUI sync failed: ${err.message}`)
227
+ process.exitCode = 1
147
228
  }
148
- } catch (err) {
149
- console.error(`❌ TUI sync failed: ${err.message}`)
150
- process.exitCode = 1
229
+ }
230
+
231
+ // Only run when invoked directly (`node scripts/sync-tui.mjs`). Importing this
232
+ // module for unit tests must not trigger the side-effecting postinstall sync.
233
+ const isDirectRun =
234
+ Boolean(process.argv[1]) && pathToFileURL(process.argv[1]).href === import.meta.url
235
+
236
+ if (isDirectRun) {
237
+ main().catch((err) => {
238
+ console.error(`❌ TUI sync failed: ${err.message}`)
239
+ process.exitCode = 1
240
+ })
151
241
  }
@@ -329,8 +329,8 @@ nunca redispara. Apos 1 retry vazio → escalate (mesma regra do TODO Enforcer).
329
329
  > Uso baixo-nível: `createZeusRetryHelper().executeWithRetry(first, () => secondRead())` — classifica empty e decide retry/escalate (mesmo cap 1).
330
330
 
331
331
  ### /cost — pantheon_cost tool (WIRED no plugin)
332
- `pantheon_cost({ days?: number })` le o `opencode.db` READ-ONLY (node:sqlite;
333
- fallback `scripts/cost.mjs`) e devolve tabela markdown de custo + tokens por
332
+ `pantheon_cost({ days?: number })` le o `opencode.db` READ-ONLY usando o backend
333
+ unico `node:sqlite`; se indisponivel, retorna `UNSUPPORTED`, sem fallback. Devolve tabela markdown de custo + tokens por
334
334
  agente nos ultimos N dias (default 7). Uso: quando Nyx pedir visibilidade de
335
335
  custo, ou antes de escalar tier — decida se o batch valeu o preco.
336
336