pantheon-opencode 1.5.0-beta.4 → 1.5.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +44 -1
- package/README.pt-BR.md +42 -0
- package/bin/pantheon-init.mjs +178 -21
- package/package.json +13 -10
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/code_mode_server.py +35 -20
- package/scripts/doctor.mjs +178 -9
- package/scripts/install/cli-ui.mjs +17 -11
- package/scripts/install/config-migration.mjs +4 -6
- package/scripts/install/opencode.mjs +95 -35
- package/scripts/install/shared.mjs +43 -2
- package/scripts/install/strings.mjs +129 -0
- package/scripts/install-mcp.mjs +1 -0
- package/scripts/release-beta-version.mjs +2 -7
- package/scripts/sync-artifacts.mjs +169 -0
- package/scripts/sync-tui.mjs +50 -4
- package/src/mcp/code_mode_server.py +35 -20
- package/src/pantheon/auto-wake.ts +1 -5
- package/src/pantheon/shared-board.ts +37 -0
- package/src/plugin.ts +6 -8
- package/src/plugins/pantheon-hooks.ts +51 -0
- package/src/plugins/tui/dist/server.d.ts +1 -1
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/server.js +1 -1
- package/src/plugins/tui/dist/server.js.map +1 -1
- package/src/plugins/tui/dist/tui.d.ts +40 -8
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +253 -97
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/dist/tui.tsx +188 -16
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +188 -16
|
@@ -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
|
+
}
|
package/scripts/install-mcp.mjs
CHANGED
|
@@ -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
|
+
}
|
package/scripts/sync-tui.mjs
CHANGED
|
@@ -138,7 +138,7 @@ export function sanitizeNpmEnv(env = process.env) {
|
|
|
138
138
|
|
|
139
139
|
// ── Main ───────────────────────────────────────────────────────────────
|
|
140
140
|
|
|
141
|
-
function main() {
|
|
141
|
+
async function main() {
|
|
142
142
|
try {
|
|
143
143
|
const configDir = resolveConfigDir()
|
|
144
144
|
if (!configDir) {
|
|
@@ -148,8 +148,17 @@ function main() {
|
|
|
148
148
|
|
|
149
149
|
const tuiCopyDir = join(configDir, 'plugins', 'pantheon-tui')
|
|
150
150
|
if (!existsSync(tuiCopyDir)) {
|
|
151
|
-
// TUI
|
|
152
|
-
|
|
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
|
|
153
162
|
}
|
|
154
163
|
|
|
155
164
|
// Source: TUI plugin inside the installed package
|
|
@@ -181,6 +190,38 @@ function main() {
|
|
|
181
190
|
` TUI plugin updated to v${sourceVersion || 'latest'}${installedVersion ? ` (was v${installedVersion})` : ''}`,
|
|
182
191
|
)
|
|
183
192
|
}
|
|
193
|
+
|
|
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
|
+
}
|
|
184
225
|
} catch (err) {
|
|
185
226
|
console.error(`❌ TUI sync failed: ${err.message}`)
|
|
186
227
|
process.exitCode = 1
|
|
@@ -192,4 +233,9 @@ function main() {
|
|
|
192
233
|
const isDirectRun =
|
|
193
234
|
Boolean(process.argv[1]) && pathToFileURL(process.argv[1]).href === import.meta.url
|
|
194
235
|
|
|
195
|
-
if (isDirectRun)
|
|
236
|
+
if (isDirectRun) {
|
|
237
|
+
main().catch((err) => {
|
|
238
|
+
console.error(`❌ TUI sync failed: ${err.message}`)
|
|
239
|
+
process.exitCode = 1
|
|
240
|
+
})
|
|
241
|
+
}
|
|
@@ -157,12 +157,9 @@ def _detect_prlimit() -> str | None:
|
|
|
157
157
|
|
|
158
158
|
|
|
159
159
|
# ── Scripts Directory Resolution ─────────────────────────────────────────────
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
# 3. /.pantheon/code-mode/ (global fallback)
|
|
164
|
-
# 4. .pantheon/code-mode/ shipped inside the installed package (tarball
|
|
165
|
-
# fallback — package.json `files` includes .pantheon/code-mode/**)
|
|
160
|
+
# Project directories are selected before global directories. Once a usable
|
|
161
|
+
# project directory is selected, manifest errors are deliberately fail-closed;
|
|
162
|
+
# execution must never silently cross the project/global trust boundary.
|
|
166
163
|
|
|
167
164
|
|
|
168
165
|
def _is_within(path: Path, root: Path) -> bool:
|
|
@@ -229,25 +226,38 @@ def _packaged_scripts_dir() -> Path | None:
|
|
|
229
226
|
return None
|
|
230
227
|
|
|
231
228
|
|
|
232
|
-
def _resolve_scripts_dir(candidates: list[Path]) -> Path | None:
|
|
233
|
-
"""Select the first usable candidate,
|
|
229
|
+
def _resolve_scripts_dir(candidates: list[Path], *, allow_packaged: bool = True) -> Path | None:
|
|
230
|
+
"""Select the first usable candidate, optionally trying the package copy."""
|
|
234
231
|
for candidate in candidates:
|
|
235
232
|
if _has_usable_scripts(candidate):
|
|
236
233
|
return candidate
|
|
237
|
-
return _packaged_scripts_dir()
|
|
234
|
+
return _packaged_scripts_dir() if allow_packaged else None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _resolve_code_mode_dir() -> Path:
|
|
238
|
+
"""Resolve code-mode scripts project-first, with a global-only fallback.
|
|
239
|
+
|
|
240
|
+
A project directory is a trust boundary: if one is selected, callers use
|
|
241
|
+
its manifest even when that manifest is missing or corrupt. Global users
|
|
242
|
+
retain the packaged/global fallback when no usable project directory is
|
|
243
|
+
available.
|
|
244
|
+
"""
|
|
245
|
+
project = pantheon_project()
|
|
246
|
+
if project is not None:
|
|
247
|
+
project_candidates = [
|
|
248
|
+
project / ".opencode" / ".pantheon" / "code-mode",
|
|
249
|
+
project / ".pantheon" / "code-mode",
|
|
250
|
+
]
|
|
251
|
+
selected = _resolve_scripts_dir(project_candidates, allow_packaged=False)
|
|
252
|
+
if selected is not None:
|
|
253
|
+
return selected
|
|
254
|
+
|
|
255
|
+
global_dir = pantheon_home() / ".pantheon" / "code-mode"
|
|
256
|
+
return _resolve_scripts_dir([global_dir]) or global_dir
|
|
238
257
|
|
|
239
258
|
|
|
240
259
|
_PANTHEON_HOME: Path = pantheon_home()
|
|
241
|
-
|
|
242
|
-
_proj = pantheon_project()
|
|
243
|
-
if _proj is not None:
|
|
244
|
-
_SCRIPTS_DIR_CANDIDATES.append(_proj / ".opencode" / ".pantheon" / "code-mode")
|
|
245
|
-
_SCRIPTS_DIR_CANDIDATES.append(_proj / ".pantheon" / "code-mode")
|
|
246
|
-
_SCRIPTS_DIR_CANDIDATES.append(_PANTHEON_HOME / ".pantheon" / "code-mode")
|
|
247
|
-
|
|
248
|
-
SCRIPTS_DIR: Path = _resolve_scripts_dir(_SCRIPTS_DIR_CANDIDATES) or (
|
|
249
|
-
_PANTHEON_HOME / ".pantheon" / "code-mode"
|
|
250
|
-
)
|
|
260
|
+
SCRIPTS_DIR: Path = _resolve_code_mode_dir()
|
|
251
261
|
|
|
252
262
|
# ── FastMCP App ───────────────────────────────────────────────────────────────
|
|
253
263
|
mcp = FastMCP(
|
|
@@ -371,6 +381,11 @@ def _load_manifest(scripts_dir: Path | None = None) -> dict[str, str]:
|
|
|
371
381
|
"CORRUPT_DATA", "Code-mode manifest must be a JSON object."
|
|
372
382
|
)
|
|
373
383
|
|
|
384
|
+
if data.get("version") != 1:
|
|
385
|
+
raise ManifestError(
|
|
386
|
+
"CORRUPT_DATA", "Code-mode manifest malformed: version must be 1."
|
|
387
|
+
)
|
|
388
|
+
|
|
374
389
|
scripts = data.get("scripts")
|
|
375
390
|
if not isinstance(scripts, dict):
|
|
376
391
|
raise ManifestError(
|
|
@@ -384,6 +399,7 @@ def _load_manifest(scripts_dir: Path | None = None) -> dict[str, str]:
|
|
|
384
399
|
not isinstance(name, str)
|
|
385
400
|
or not isinstance(digest, str)
|
|
386
401
|
or len(digest) != _SHA256_HEX_LEN
|
|
402
|
+
or any(char not in "0123456789abcdefABCDEF" for char in digest)
|
|
387
403
|
):
|
|
388
404
|
raise ManifestError(
|
|
389
405
|
"CORRUPT_DATA",
|
|
@@ -877,4 +893,3 @@ def _cli() -> int:
|
|
|
877
893
|
|
|
878
894
|
if __name__ == "__main__":
|
|
879
895
|
raise SystemExit(_cli())
|
|
880
|
-
|
|
@@ -78,11 +78,7 @@ export async function consumeWakeSignals(signalDir?: string): Promise<WakeSignal
|
|
|
78
78
|
|
|
79
79
|
// Step 3: Only add to results after successful rename
|
|
80
80
|
signals.push(data)
|
|
81
|
-
} catch {
|
|
82
|
-
// Rename failed (another consumer got there first) or file is corrupted.
|
|
83
|
-
// Skip silently in either case.
|
|
84
|
-
continue
|
|
85
|
-
}
|
|
81
|
+
} catch {}
|
|
86
82
|
}
|
|
87
83
|
|
|
88
84
|
return signals
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-wide BackgroundJobBoard singleton shared by every Pantheon plugin
|
|
3
|
+
* surface (src/plugin.ts V1, src/plugins/pantheon-hooks.ts).
|
|
4
|
+
*
|
|
5
|
+
* WHY globalThis and not a module-level `const`: opencode loads each plugin
|
|
6
|
+
* TWICE from two different filesystem paths (the installed npm package AND
|
|
7
|
+
* the repo source — see src/pantheon/plugin-once.ts). Each load is a separate
|
|
8
|
+
* module instance, so a plain ESM singleton would yield TWO boards: the
|
|
9
|
+
* native-task mirror in pantheon-hooks would register jobs on one instance
|
|
10
|
+
* while the finalize path (session.idle + idle scan in plugin.ts) listens on
|
|
11
|
+
* the other, and the job would never finalize. A globalThis-anchored value is
|
|
12
|
+
* process-global by construction, deduping both module copies.
|
|
13
|
+
*
|
|
14
|
+
* The persistence file and signal dir are RELATIVE paths — the board operates
|
|
15
|
+
* against the opencode project root (the cwd of the plugin process), matching
|
|
16
|
+
* the pre-existing plugin.ts wiring exactly.
|
|
17
|
+
*/
|
|
18
|
+
import { BackgroundJobBoard } from './background-job-board.ts'
|
|
19
|
+
import { FilePersistenceAdapter } from './file-persistence.ts'
|
|
20
|
+
|
|
21
|
+
const BOARD_KEY = '__pantheonSharedBoard'
|
|
22
|
+
|
|
23
|
+
/** Construction inputs mirror the original plugin.ts module-scope board. */
|
|
24
|
+
const BOARD_OPTIONS = {
|
|
25
|
+
maxConcurrentPerAgent: 3,
|
|
26
|
+
signalDir: '.pantheon/deepwork/board-signals',
|
|
27
|
+
} as const
|
|
28
|
+
|
|
29
|
+
export function getSharedBoard(): BackgroundJobBoard {
|
|
30
|
+
const host = globalThis as { [BOARD_KEY]?: BackgroundJobBoard }
|
|
31
|
+
const existing = host[BOARD_KEY]
|
|
32
|
+
if (existing instanceof BackgroundJobBoard) return existing
|
|
33
|
+
const board = new BackgroundJobBoard(BOARD_OPTIONS)
|
|
34
|
+
board.setPersistence(new FilePersistenceAdapter('.pantheon/board/state.json'))
|
|
35
|
+
host[BOARD_KEY] = board
|
|
36
|
+
return board
|
|
37
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import type { Plugin, PluginInput } from '@opencode-ai/plugin'
|
|
4
4
|
import type { PluginConfig } from 'opencode'
|
|
5
|
-
import { BackgroundJobBoard } from './pantheon/background-job-board.ts'
|
|
6
5
|
import { createCommandNormalizer } from './pantheon/command-normalizer.ts'
|
|
7
6
|
import {
|
|
8
7
|
type ContextSandboxConfig,
|
|
@@ -30,7 +29,6 @@ import {
|
|
|
30
29
|
} from './pantheon/delegation-enforce.ts'
|
|
31
30
|
import { handleDelegationEvent } from './pantheon/delegation-notify.ts'
|
|
32
31
|
import { showDelegationTerminalToast, type ToastClient } from './pantheon/delegation-toast.ts'
|
|
33
|
-
import { FilePersistenceAdapter } from './pantheon/file-persistence.ts'
|
|
34
32
|
import { GOAL_LOOP_DEFAULTS, GoalLoop, GoalStore } from './pantheon/goal-loop.ts'
|
|
35
33
|
import { createReadEnhancer } from './pantheon/hashline/read-enhancer.ts'
|
|
36
34
|
import { createHashlineEditTool } from './pantheon/hashline/tool.ts'
|
|
@@ -45,6 +43,7 @@ import {
|
|
|
45
43
|
loadRoutingPermissionTask,
|
|
46
44
|
} from './pantheon/presets.mjs'
|
|
47
45
|
import { safeSessionPath } from './pantheon/session-guard.ts'
|
|
46
|
+
import { getSharedBoard } from './pantheon/shared-board.ts'
|
|
48
47
|
import { StepCapTracker } from './pantheon/step-cap.ts'
|
|
49
48
|
import { createTaskResultGuard } from './pantheon/task-result-guard.ts'
|
|
50
49
|
import {
|
|
@@ -77,12 +76,11 @@ const stepCapTracker = new StepCapTracker(loadRoutingMaxSteps({ logger: log }))
|
|
|
77
76
|
// (fail-open → undefined = existing runtime matrix only).
|
|
78
77
|
const routingPermissionTask = loadRoutingPermissionTask({ logger: log })
|
|
79
78
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
board.setPersistence(persistence)
|
|
79
|
+
// beta.5: the board is a PROCESS-wide singleton (getSharedBoard) shared with
|
|
80
|
+
// src/plugins/pantheon-hooks.ts, whose native-task mirror registers children
|
|
81
|
+
// here; the finalize path below (session.idle + idle scan) must observe the
|
|
82
|
+
// SAME instance. globalThis anchoring dedupes the npm+repo double load.
|
|
83
|
+
const board = getSharedBoard()
|
|
86
84
|
let terminalToastClient: ToastClient = {}
|
|
87
85
|
board
|
|
88
86
|
.recoverRunningJobs()
|
|
@@ -177,6 +177,7 @@ import { appendFile, mkdir } from 'node:fs/promises'
|
|
|
177
177
|
import { join } from 'node:path'
|
|
178
178
|
import type { Plugin, PluginInput } from '@opencode-ai/plugin'
|
|
179
179
|
import { pantheonPluginOnce } from '../pantheon/plugin-once.ts'
|
|
180
|
+
import { getSharedBoard } from '../pantheon/shared-board.ts'
|
|
180
181
|
import { type HookPayload, type HookResult, runHook } from './hook-runner.ts'
|
|
181
182
|
|
|
182
183
|
/** Tools that represent a subagent delegation (opencode `task` tool etc.). */
|
|
@@ -775,6 +776,51 @@ function delegationAgent(tool: string, args?: unknown): string {
|
|
|
775
776
|
return tool
|
|
776
777
|
}
|
|
777
778
|
|
|
779
|
+
/**
|
|
780
|
+
* beta.5: mirror a native background task dispatch onto the SHARED board
|
|
781
|
+
* (getSharedBoard) so the existing finalize path in plugin.ts (session.idle
|
|
782
|
+
* event + 30s idle scan) writes the terminal report and the TUI delegation
|
|
783
|
+
* panel tracks the child end-to-end. Fire-and-forget and silent: a mirror
|
|
784
|
+
* failure must never surface into the tool call or the transcript.
|
|
785
|
+
* registerLaunchIfAbsent keeps it idempotent against any other dispatcher
|
|
786
|
+
* (v2-events, plugin double-load, repeated after-hook firings).
|
|
787
|
+
*/
|
|
788
|
+
function mirrorNativeBackgroundDispatch(
|
|
789
|
+
input: { tool: string; sessionID: string },
|
|
790
|
+
args: unknown,
|
|
791
|
+
result: ToolExecuteAfterResult | null,
|
|
792
|
+
): void {
|
|
793
|
+
const taskID = extractTaskId(result)
|
|
794
|
+
if (taskID === '' || typeof input.sessionID !== 'string' || input.sessionID === '') return
|
|
795
|
+
const description = trim(delegationDescription(args) ?? `native ${input.tool} dispatch`, 200)
|
|
796
|
+
try {
|
|
797
|
+
getSharedBoard()
|
|
798
|
+
.registerLaunchIfAbsent({
|
|
799
|
+
taskID,
|
|
800
|
+
parentSessionID: input.sessionID,
|
|
801
|
+
agent: delegationAgent(input.tool, args),
|
|
802
|
+
description,
|
|
803
|
+
})
|
|
804
|
+
.catch(() => {
|
|
805
|
+
// Mirror is best-effort — never surface into the tool call.
|
|
806
|
+
})
|
|
807
|
+
} catch {
|
|
808
|
+
// Never let the mirror take down the tool call.
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** Task description from task-tool args (description ?? prompt); undefined when absent. */
|
|
813
|
+
function delegationDescription(args: unknown): string | undefined {
|
|
814
|
+
try {
|
|
815
|
+
const a = (args ?? {}) as Record<string, unknown>
|
|
816
|
+
const raw = a.description ?? a.prompt
|
|
817
|
+
if (typeof raw === 'string' && raw.trim() !== '') return raw
|
|
818
|
+
} catch {
|
|
819
|
+
// Malformed args — fall through to the generic description.
|
|
820
|
+
}
|
|
821
|
+
return undefined
|
|
822
|
+
}
|
|
823
|
+
|
|
778
824
|
/** 🚀 delegation-started toast (variant 'info', 2500ms), deduped per agent. */
|
|
779
825
|
async function delegationStartedToast(ctx: HookContext, agent: string): Promise<void> {
|
|
780
826
|
await throttledToast(ctx, {
|
|
@@ -1311,6 +1357,11 @@ const plugin: Plugin = async ({ client, directory }) => {
|
|
|
1311
1357
|
// NOT a completion: do not mark the group done and do not fire ✅
|
|
1312
1358
|
// — that would corrupt olympians aggregation. Real completion of
|
|
1313
1359
|
// background tasks is unobservable on 1.18.13 (see header).
|
|
1360
|
+
// beta.5: mirror the native background child onto the SHARED
|
|
1361
|
+
// board so the existing finalize path (session.idle + idle scan
|
|
1362
|
+
// in plugin.ts) writes the terminal report and the TUI
|
|
1363
|
+
// delegation panel tracks the child end-to-end. Fire-and-forget.
|
|
1364
|
+
mirrorNativeBackgroundDispatch(input, args, result)
|
|
1314
1365
|
} else {
|
|
1315
1366
|
const handledByOlympians = handleOlympiansCompletion(ctx, agent)
|
|
1316
1367
|
if (!handledByOlympians) void delegationDoneToast(ctx, agent)
|