pantheon-opencode 1.3.2 → 1.3.4
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/README.md +154 -1
- package/bin/pantheon-init.mjs +160 -120
- package/opencode.json +0 -1
- package/package.json +10 -5
- package/scripts/cost.mjs +69 -0
- package/scripts/doctor.mjs +56 -22
- package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
- package/scripts/install/opencode.mjs +132 -32
- package/scripts/install/plugin.mjs +112 -28
- package/scripts/install-mcp.mjs +1 -1
- package/scripts/redaction-gate.mjs +69 -0
- package/scripts/release-notes.mjs +317 -0
- package/scripts/test-context-compression.sh +5 -2
- package/scripts/validate-package.mjs +56 -0
- package/scripts/versioning.mjs +117 -18
- package/src/agents/zeus.md +80 -0
- package/src/pantheon/background-job-board.ts +115 -40
- package/src/pantheon/chat-reminders.ts +79 -0
- package/src/pantheon/compaction-assert.ts +160 -0
- package/src/pantheon/cost-command.ts +208 -0
- package/src/pantheon/cost-tracker.ts +108 -0
- package/src/pantheon/delegation-compaction.ts +237 -0
- package/src/pantheon/delegation-enforce.ts +144 -0
- package/src/pantheon/delegation-finalize.ts +277 -0
- package/src/pantheon/delegation-notify.ts +100 -0
- package/src/pantheon/delegation.ts +690 -0
- package/src/pantheon/dispatch-guard.ts +107 -0
- package/src/pantheon/file-persistence.ts +10 -6
- package/src/pantheon/goal-loop.ts +300 -0
- package/src/pantheon/goal-store.ts +97 -0
- package/src/pantheon/hashline/core.ts +91 -0
- package/src/pantheon/hashline/read-enhancer.ts +66 -0
- package/src/pantheon/hashline/tool.ts +260 -0
- package/src/pantheon/hashline/xxhash.ts +53 -0
- package/src/pantheon/idle-continuation.ts +54 -0
- package/src/pantheon/logger.ts +117 -0
- package/src/pantheon/plugin-once.ts +76 -0
- package/src/pantheon/preemptive-compact.ts +76 -0
- package/src/pantheon/presets.d.mts +31 -0
- package/src/pantheon/presets.mjs +105 -3
- package/src/pantheon/todo-enforcer.ts +421 -0
- package/src/pantheon/todo-preserve.ts +295 -0
- package/src/plugin.ts +406 -13
- package/src/plugins/pantheon-hooks.ts +52 -30
- package/src/plugins/tui/dist/server.d.ts +17 -0
- package/src/plugins/tui/dist/server.d.ts.map +1 -0
- package/src/plugins/tui/dist/server.js +20 -0
- package/src/plugins/tui/dist/server.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +303 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +977 -350
- package/src/plugins/tui/dist/tui.tsx +1427 -150
- package/src/plugins/tui/package.json +4 -3
- package/src/plugins/tui/src/index.tsx +1427 -150
- package/src/plugins/tui/src/server.ts +15 -0
- package/src/plugins/tui/tsdown.config.ts +1 -1
- package/src/routing.yml +35 -2
package/scripts/doctor.mjs
CHANGED
|
@@ -34,7 +34,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
34
34
|
|
|
35
35
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
36
36
|
const ROOT = join(__dirname, '..')
|
|
37
|
-
const AGENTS_DIR = join(ROOT,
|
|
37
|
+
const AGENTS_DIR = join(ROOT, 'src', 'agents')
|
|
38
38
|
const PLATFORM_DIR = join(ROOT, 'platform')
|
|
39
39
|
|
|
40
40
|
// All known platforms (must match platform/ subdirectories with adapter.json)
|
|
@@ -246,7 +246,9 @@ function checkAgentFiles(args) {
|
|
|
246
246
|
|
|
247
247
|
// Determine which platforms to check
|
|
248
248
|
if (!existsSync(PLATFORM_DIR)) {
|
|
249
|
-
info(
|
|
249
|
+
info(
|
|
250
|
+
`Platform component not distributed in this package — platform check skipped (${args.profile} profile)`,
|
|
251
|
+
)
|
|
250
252
|
return
|
|
251
253
|
}
|
|
252
254
|
const platformDirs = readdirSync(PLATFORM_DIR, { withFileTypes: true })
|
|
@@ -255,7 +257,9 @@ function checkAgentFiles(args) {
|
|
|
255
257
|
.filter((name) => !args.platform || name === args.platform)
|
|
256
258
|
|
|
257
259
|
if (platformDirs.length === 0) {
|
|
258
|
-
info(
|
|
260
|
+
info(
|
|
261
|
+
`No platform directories found in platform/ — platform check skipped (${args.profile} profile)`,
|
|
262
|
+
)
|
|
259
263
|
return
|
|
260
264
|
}
|
|
261
265
|
|
|
@@ -377,7 +381,8 @@ function checkMcpConfig(args) {
|
|
|
377
381
|
|
|
378
382
|
const mcp = cfg.data.mcp
|
|
379
383
|
if (!mcp || typeof mcp !== 'object') {
|
|
380
|
-
if (requiredMcpNames.length > 0)
|
|
384
|
+
if (requiredMcpNames.length > 0)
|
|
385
|
+
error(`${cfg.label}: required MCPs missing — no "mcp" section`)
|
|
381
386
|
else info(`${cfg.label}: no "mcp" section (${args.profile} profile — optional)`)
|
|
382
387
|
continue
|
|
383
388
|
}
|
|
@@ -450,7 +455,9 @@ function checkMcpConfig(args) {
|
|
|
450
455
|
const exe = cmd[0]
|
|
451
456
|
if (!exe.includes('/')) {
|
|
452
457
|
// PATH-resolved executable — cannot verify statically; surface for manual check
|
|
453
|
-
info(
|
|
458
|
+
info(
|
|
459
|
+
`${cfg.label}: MCP "${name}" uses PATH executable "${exe}" (verify with \`which ${exe}\`)`,
|
|
460
|
+
)
|
|
454
461
|
continue
|
|
455
462
|
}
|
|
456
463
|
if (!existsSync(exe)) {
|
|
@@ -467,7 +474,8 @@ function checkMcpConfig(args) {
|
|
|
467
474
|
if (requiredMcpNames.length > 0) {
|
|
468
475
|
const configured = new Set(configs.flatMap((cfg) => Object.keys(cfg.data.mcp ?? {})))
|
|
469
476
|
for (const required of requiredMcpNames) {
|
|
470
|
-
if (configured.has(required))
|
|
477
|
+
if (configured.has(required))
|
|
478
|
+
pass(`Required MCP "${required}" configured in an available config`)
|
|
471
479
|
else error(`Required MCP "${required}" not configured in any available config`)
|
|
472
480
|
}
|
|
473
481
|
}
|
|
@@ -535,12 +543,17 @@ function mcpInitializeSmoke(command, cwd, timeoutMs) {
|
|
|
535
543
|
const msg = JSON.parse(line.trim())
|
|
536
544
|
if (msg.id === 1 && msg.result) {
|
|
537
545
|
const info = msg.result.serverInfo
|
|
538
|
-
const label = info?.name
|
|
546
|
+
const label = info?.name
|
|
547
|
+
? `${info.name}${info.version ? ` ${info.version}` : ''}`
|
|
548
|
+
: undefined
|
|
539
549
|
finish({ ok: true, serverName: label })
|
|
540
550
|
return
|
|
541
551
|
}
|
|
542
552
|
if (msg.error) {
|
|
543
|
-
finish({
|
|
553
|
+
finish({
|
|
554
|
+
ok: false,
|
|
555
|
+
reason: `initialize error: ${msg.error.code ?? ''} ${msg.error.message ?? ''}`,
|
|
556
|
+
})
|
|
544
557
|
}
|
|
545
558
|
} catch {
|
|
546
559
|
/* not JSON yet — keep buffering */
|
|
@@ -592,7 +605,9 @@ async function checkMcpRuntimeSmoke(args) {
|
|
|
592
605
|
for (const { name, command, cwd, source } of local) {
|
|
593
606
|
const result = await mcpInitializeSmoke(command, cwd, 10000)
|
|
594
607
|
if (result.ok) {
|
|
595
|
-
pass(
|
|
608
|
+
pass(
|
|
609
|
+
`MCP "${name}" (${source}): initialize handshake OK${result.serverName ? ` — ${result.serverName}` : ''}`,
|
|
610
|
+
)
|
|
596
611
|
} else {
|
|
597
612
|
error(`MCP "${name}" (${source}): ${result.reason}`)
|
|
598
613
|
}
|
|
@@ -645,7 +660,9 @@ function checkPermissionMismatches(args) {
|
|
|
645
660
|
// Locate the validation script
|
|
646
661
|
const validator = join(ROOT, 'scripts', 'validate-agent-frontmatter.py')
|
|
647
662
|
if (!existsSync(validator)) {
|
|
648
|
-
info(
|
|
663
|
+
info(
|
|
664
|
+
`validate-agent-frontmatter.py not found — optional helper skipped (${args.profile} profile)`,
|
|
665
|
+
)
|
|
649
666
|
return
|
|
650
667
|
}
|
|
651
668
|
|
|
@@ -876,12 +893,24 @@ function checkRequirementLine(line, installed) {
|
|
|
876
893
|
const cmp = compareVersions(have, want)
|
|
877
894
|
let ok = false
|
|
878
895
|
switch (op) {
|
|
879
|
-
case '==':
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
case '
|
|
883
|
-
|
|
884
|
-
|
|
896
|
+
case '==':
|
|
897
|
+
ok = cmp === 0
|
|
898
|
+
break
|
|
899
|
+
case '>=':
|
|
900
|
+
ok = cmp >= 0
|
|
901
|
+
break
|
|
902
|
+
case '<=':
|
|
903
|
+
ok = cmp <= 0
|
|
904
|
+
break
|
|
905
|
+
case '>':
|
|
906
|
+
ok = cmp > 0
|
|
907
|
+
break
|
|
908
|
+
case '<':
|
|
909
|
+
ok = cmp < 0
|
|
910
|
+
break
|
|
911
|
+
case '!=':
|
|
912
|
+
ok = cmp !== 0
|
|
913
|
+
break
|
|
885
914
|
case '~=': {
|
|
886
915
|
const spec = parseVersion(want)
|
|
887
916
|
const got = parseVersion(have)
|
|
@@ -961,12 +990,12 @@ function checkVenvLayer(args) {
|
|
|
961
990
|
*/
|
|
962
991
|
export function summaryMessage(summaryCounts, summaryExitCode) {
|
|
963
992
|
if (summaryCounts.error > 0 || summaryExitCode !== 0) {
|
|
964
|
-
const errorText =
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
: ''
|
|
993
|
+
const errorText =
|
|
994
|
+
summaryCounts.error > 0
|
|
995
|
+
? `${summaryCounts.error} blocking error(s) found`
|
|
996
|
+
: 'Blocking check failure'
|
|
997
|
+
const warningText =
|
|
998
|
+
summaryCounts.warn > 0 ? `; ${summaryCounts.warn} warning(s) remain advisory` : ''
|
|
970
999
|
return `❌ ${errorText}${warningText} — exit code ${summaryExitCode}`
|
|
971
1000
|
}
|
|
972
1001
|
|
|
@@ -1005,6 +1034,11 @@ async function main() {
|
|
|
1005
1034
|
process.exit(0)
|
|
1006
1035
|
}
|
|
1007
1036
|
|
|
1037
|
+
// Report the resolved validation profile up front (auto → global/sandbox)
|
|
1038
|
+
// so callers — including the `doctor` CLI wrapper — can see which policy
|
|
1039
|
+
// gate applied before any check output.
|
|
1040
|
+
console.log(`Validation profile: ${args.profile}`)
|
|
1041
|
+
|
|
1008
1042
|
if (!existsSync(args.target)) {
|
|
1009
1043
|
console.error(`❌ Target directory does not exist: ${args.target}`)
|
|
1010
1044
|
process.exit(2)
|
|
@@ -383,12 +383,27 @@ fi
|
|
|
383
383
|
# Emit a schema-compatible line: {event, timestamp, agent, status, reason,
|
|
384
384
|
# session_id, result, delegation_id, task_id, duration_ms} — P0-2: the
|
|
385
385
|
# delegation_id joins this record to the matching SubagentStart; task_id and
|
|
386
|
-
# duration_ms ride in their OWN fields.
|
|
386
|
+
# duration_ms ride in their OWN fields. 1.3.4: task_id is OMITTED when empty
|
|
387
|
+
# (never "" — the real child id is always present when a job exists) and
|
|
388
|
+
# duration_ms is emitted as a NUMBER (the plugin computes it; stringifying it
|
|
389
|
+
# broke downstream aggregation). Built with json.dumps so arbitrary
|
|
387
390
|
# reason/result text cannot break the JSON.
|
|
388
391
|
LOG_LINE=$(python3 -c '
|
|
389
392
|
import json
|
|
390
393
|
import sys
|
|
391
|
-
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _num(v):
|
|
397
|
+
"""Best-effort numeric parse — the plugin duration_ms is a number; a
|
|
398
|
+
non-numeric value (e.g. unset) becomes null, never a string."""
|
|
399
|
+
try:
|
|
400
|
+
return int(float(v))
|
|
401
|
+
except (TypeError, ValueError):
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
task_id = sys.argv[8] or ""
|
|
406
|
+
line = {
|
|
392
407
|
"event": "SubagentStop",
|
|
393
408
|
"timestamp": sys.argv[1],
|
|
394
409
|
"agent": sys.argv[2],
|
|
@@ -397,9 +412,11 @@ print(json.dumps({
|
|
|
397
412
|
"session_id": sys.argv[5],
|
|
398
413
|
"result": sys.argv[6],
|
|
399
414
|
"delegation_id": sys.argv[7],
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
415
|
+
}
|
|
416
|
+
if task_id:
|
|
417
|
+
line["task_id"] = task_id
|
|
418
|
+
line["duration_ms"] = _num(sys.argv[9])
|
|
419
|
+
print(json.dumps(line, ensure_ascii=False))
|
|
403
420
|
' "$TIMESTAMP" "$AGENT_NAME" "$STATUS" "$REASON" "$SESSION_ID" "$RESULT_SNIPPET" "$DELEGATION_ID" "$TASK_ID" "$DURATION_MS")
|
|
404
421
|
|
|
405
422
|
LOG_FILE="$LOG_DIR/delegations.log"
|
|
@@ -3,15 +3,7 @@
|
|
|
3
3
|
* opencode.mjs — OpenCode platform installer
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
chmodSync,
|
|
8
|
-
existsSync,
|
|
9
|
-
mkdirSync,
|
|
10
|
-
readdirSync,
|
|
11
|
-
readFileSync,
|
|
12
|
-
rmSync,
|
|
13
|
-
writeFileSync,
|
|
14
|
-
} from 'node:fs'
|
|
6
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
|
|
15
7
|
import { homedir } from 'node:os'
|
|
16
8
|
import { basename, join, resolve } from 'node:path'
|
|
17
9
|
import {
|
|
@@ -29,7 +21,7 @@ import {
|
|
|
29
21
|
} from './cli-ui.mjs'
|
|
30
22
|
import { healthCheck } from './health-check.mjs'
|
|
31
23
|
import { detectVersion, runMigrations } from './migrate.mjs'
|
|
32
|
-
import { installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
|
|
24
|
+
import { copyPluginFiles, installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
|
|
33
25
|
import {
|
|
34
26
|
collectSkillNames,
|
|
35
27
|
copyFiles,
|
|
@@ -84,6 +76,28 @@ function mergeMissing(target, source) {
|
|
|
84
76
|
return target
|
|
85
77
|
}
|
|
86
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Resolve a plugin ref from the packaged opencode.json to a path INSIDE the
|
|
81
|
+
* installed package.
|
|
82
|
+
*
|
|
83
|
+
* The packaged config may reference plugins via developer-machine absolute
|
|
84
|
+
* paths (e.g. <dev>/pantheon/.../src/plugin.ts). Copying those verbatim into
|
|
85
|
+
* the user's config would break every install on any other machine. This
|
|
86
|
+
* rewrites the entry to the plugin file inside the INSTALLED package (derived
|
|
87
|
+
* from ROOT — never hardcoded), so both dev and global installs resolve to a
|
|
88
|
+
* path that actually exists.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveInstalledPlugin(plugin) {
|
|
91
|
+
// Map any ref whose path tail starts with `src/` to the matching file INSIDE
|
|
92
|
+
// the installed package. This preserves the ref's relative location, so both
|
|
93
|
+
// src/plugins/* (hooks) and the root-level src/plugin.ts (delegation plugin,
|
|
94
|
+
// PR #45) resolve correctly — nothing is forced into src/plugins/.
|
|
95
|
+
const srcIndex = plugin.indexOf('src/')
|
|
96
|
+
if (srcIndex === -1) return plugin
|
|
97
|
+
const packaged = join(ROOT, plugin.slice(srcIndex))
|
|
98
|
+
return existsSync(packaged) ? packaged : plugin
|
|
99
|
+
}
|
|
100
|
+
|
|
87
101
|
/**
|
|
88
102
|
* Detect whether `target` is the user's global OpenCode installation directory
|
|
89
103
|
* (~/.opencode or $XDG_CONFIG_HOME/opencode).
|
|
@@ -101,6 +115,97 @@ export function isGlobalConfigDir(target) {
|
|
|
101
115
|
return resolve(target) === xdgDir
|
|
102
116
|
}
|
|
103
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Derive where the TUI plugin must be COPIED for a given config directory.
|
|
120
|
+
*
|
|
121
|
+
* The TUI plugin's single source of truth is src/plugins/tui inside the
|
|
122
|
+
* package; the loader must NEVER be pointed at that in-package dir (a package
|
|
123
|
+
* reinstall/upgrade replaces the dir under a live registration and leaves
|
|
124
|
+
* diverging copies loading). Instead the installer always copies it into the
|
|
125
|
+
* config dir's plugins/ dir and registers THAT copy:
|
|
126
|
+
*
|
|
127
|
+
* <configDir>/plugins/pantheon-tui (dist/ + package.json + index.tsx)
|
|
128
|
+
*
|
|
129
|
+
* The loader reads the copied package.json `exports` map:
|
|
130
|
+
* exports["./tui"] → dist/tui.js (COMPILED Solid output)
|
|
131
|
+
* exports["./server"] → dist/server.js (no-op server() stub)
|
|
132
|
+
*
|
|
133
|
+
* @param {string} configDir - directory holding tui.json (target for global
|
|
134
|
+
* installs, <target>/.opencode for project installs)
|
|
135
|
+
* @returns {string} absolute path of the copied plugin directory
|
|
136
|
+
*/
|
|
137
|
+
export function resolveTuiCopyTarget(configDir) {
|
|
138
|
+
return join(configDir, 'plugins', 'pantheon-tui')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Every tui.json location OpenCode reads: the two global config dirs
|
|
143
|
+
* (~/.opencode and $XDG_CONFIG_HOME/opencode) plus the target's own project
|
|
144
|
+
* config. Cleanup runs over ALL of them so a stale Pantheon TUI registration
|
|
145
|
+
* can never survive in a location the loader still reads.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} target - install target directory
|
|
148
|
+
* @returns {string[]} unique tui.json paths (deterministic order)
|
|
149
|
+
*/
|
|
150
|
+
export function tuiConfigLocations(target) {
|
|
151
|
+
const homeTui = join(homedir(), '.opencode', 'tui.json')
|
|
152
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
|
|
153
|
+
const xdgTui = join(xdgConfig, 'opencode', 'tui.json')
|
|
154
|
+
const projectTui = join(target, '.opencode', 'tui.json')
|
|
155
|
+
return [...new Set([homeTui, xdgTui, projectTui])]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Sync the Pantheon TUI plugin registration across EVERY tui.json location
|
|
160
|
+
* OpenCode reads.
|
|
161
|
+
*
|
|
162
|
+
* Contract (single source of truth):
|
|
163
|
+
* 1. ALWAYS copy src/plugins/tui → <configDir>/plugins/pantheon-tui
|
|
164
|
+
* (dist/* + package.json + index.tsx) so the registered reference is a
|
|
165
|
+
* directory that exists and carries the loader contract (exports →
|
|
166
|
+
* dist/tui.js, dist/server.js). The copy is idempotent: a second run is
|
|
167
|
+
* byte-identical.
|
|
168
|
+
* 2. Clean stale Pantheon TUI refs from ALL tui.json locations OpenCode
|
|
169
|
+
* loads — ~/.opencode, $XDG_CONFIG_HOME/opencode and
|
|
170
|
+
* <target>/.opencode — covering every old reference shape: dist file
|
|
171
|
+
* paths, in-package src/plugins/tui dirs, bare plugins/pantheon-tui
|
|
172
|
+
* copies, and npx paths. A stale ref left in the non-target location
|
|
173
|
+
* would still be loaded and break the TUI.
|
|
174
|
+
* 3. Register EXACTLY the copied directory in the selected target only —
|
|
175
|
+
* exactly ONE Pantheon TUI reference exists system-wide after install.
|
|
176
|
+
*
|
|
177
|
+
* @param {string} target - install target directory
|
|
178
|
+
* @param {object} [opts]
|
|
179
|
+
* @param {boolean} [opts.isGlobal=false] - flat global layout vs .opencode/
|
|
180
|
+
* @param {boolean} [opts.dryRun=false]
|
|
181
|
+
* @returns {'created'|'skipped'} status for the target registration
|
|
182
|
+
*/
|
|
183
|
+
export function syncTuiRegistration(target, { isGlobal = false, dryRun = false } = {}) {
|
|
184
|
+
const configDir = isGlobal ? target : join(target, '.opencode')
|
|
185
|
+
const targetTuiConfigPath = join(configDir, 'tui.json')
|
|
186
|
+
|
|
187
|
+
// 1. Always copy the single source of truth into the target config's
|
|
188
|
+
// plugins/pantheon-tui. Registering this copied directory (never the
|
|
189
|
+
// in-package src/plugins/tui) keeps installs hermetic and idempotent:
|
|
190
|
+
// the ref points at a dir that exists and has dist/tui.js.
|
|
191
|
+
const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
|
|
192
|
+
const tuiCopyDir = resolveTuiCopyTarget(configDir)
|
|
193
|
+
if (existsSync(tuiSrcDir)) {
|
|
194
|
+
copyPluginFiles(tuiSrcDir, tuiCopyDir, { dryRun })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 2. Clean stale Pantheon TUI refs from every location OpenCode reads.
|
|
198
|
+
// unregisterPlugin covers the bare relative id plus the extended stale
|
|
199
|
+
// patterns (dist files, in-package src/plugins/tui dir, bare
|
|
200
|
+
// plugins/pantheon-tui copies, npx paths).
|
|
201
|
+
for (const tuiConfigPath of tuiConfigLocations(target)) {
|
|
202
|
+
unregisterPlugin(tuiConfigPath, 'plugins/pantheon-tui', { dryRun })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 3. Register the copied directory in the selected target only.
|
|
206
|
+
return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun })
|
|
207
|
+
}
|
|
208
|
+
|
|
104
209
|
export async function installOpenCode(
|
|
105
210
|
target,
|
|
106
211
|
dryRun = false,
|
|
@@ -305,19 +410,7 @@ export async function installOpenCode(
|
|
|
305
410
|
// -----------------------------------------------------------------------
|
|
306
411
|
// 2.9 Create/update tui.json with plugin registration
|
|
307
412
|
// -----------------------------------------------------------------------
|
|
308
|
-
const
|
|
309
|
-
? join(target, 'tui.json')
|
|
310
|
-
: join(target, '.opencode', 'tui.json')
|
|
311
|
-
// Hermetic TUI plugin registration: opencode's tui loader treats relative
|
|
312
|
-
// entries ("plugins/pantheon-tui") as npm/github package specs and fails
|
|
313
|
-
// with NpmInstallFailedError ("Repository not found"). Register the plugin
|
|
314
|
-
// by the ABSOLUTE path of the built dist inside the installed package
|
|
315
|
-
// (derived from ROOT — never hardcoded), mirroring the loader's
|
|
316
|
-
// absolute-path support (same pattern as the hooks plugin fix).
|
|
317
|
-
const tuiPluginRef = join(ROOT, 'src', 'plugins', 'tui', 'dist', 'tui.tsx')
|
|
318
|
-
// Clean up the old broken relative ref (and stale dist refs) on upgrade.
|
|
319
|
-
unregisterPlugin(targetTuiConfigPath, 'plugins/pantheon-tui', { dryRun })
|
|
320
|
-
const tuiStatus = registerPlugin(targetTuiConfigPath, tuiPluginRef, { dryRun })
|
|
413
|
+
const tuiStatus = syncTuiRegistration(target, { isGlobal, dryRun })
|
|
321
414
|
if (tuiStatus === 'created') stats.created++
|
|
322
415
|
else stats.skipped++
|
|
323
416
|
|
|
@@ -578,19 +671,15 @@ export async function installOpenCode(
|
|
|
578
671
|
// --------------------------------------------------------------------
|
|
579
672
|
// B.5.1 Hermetic plugin resolution (packaging fix)
|
|
580
673
|
// --------------------------------------------------------------------
|
|
581
|
-
// The packaged opencode.json may reference
|
|
582
|
-
//
|
|
583
|
-
// Copying
|
|
584
|
-
//
|
|
674
|
+
// The packaged opencode.json may reference plugins via developer-machine
|
|
675
|
+
// absolute paths (e.g. <dev>/pantheon/.../src/plugin.ts or
|
|
676
|
+
// <dev>/pantheon/.../src/plugins/pantheon-hooks.ts). Copying those
|
|
677
|
+
// verbatim into the user's config would break every global install on any
|
|
678
|
+
// other machine. resolveInstalledPlugin() rewrites each entry to the plugin
|
|
585
679
|
// file inside the INSTALLED package (derived from ROOT — never hardcoded),
|
|
586
680
|
// so both dev and global installs resolve to a path that actually exists.
|
|
587
681
|
// The source opencode.json keeps its dev path for local development; the
|
|
588
682
|
// transform happens at install/sync time only.
|
|
589
|
-
function resolveInstalledPlugin(plugin) {
|
|
590
|
-
const file = basename(plugin)
|
|
591
|
-
const packaged = join(ROOT, 'src', 'plugins', file)
|
|
592
|
-
return existsSync(packaged) ? packaged : plugin
|
|
593
|
-
}
|
|
594
683
|
|
|
595
684
|
if (!Array.isArray(config.plugin)) {
|
|
596
685
|
config.plugin = []
|
|
@@ -606,6 +695,17 @@ export async function installOpenCode(
|
|
|
606
695
|
}
|
|
607
696
|
}
|
|
608
697
|
|
|
698
|
+
// The runtime hooks plugin MUST always be registered. The packaged
|
|
699
|
+
// opencode.json no longer carries dev-machine plugin paths (removed in
|
|
700
|
+
// 3e552cc), so config.plugin would otherwise stay empty and the
|
|
701
|
+
// pantheon-hooks runtime would never load. Register it unconditionally,
|
|
702
|
+
// replacing any stale entry for the same file (same basename-dedup as
|
|
703
|
+
// above) so installs and upgrades are idempotent and hermetic.
|
|
704
|
+
const hooksPlugin = resolveInstalledPlugin('src/plugins/pantheon-hooks.ts')
|
|
705
|
+
const hooksFile = basename(hooksPlugin)
|
|
706
|
+
config.plugin = config.plugin.filter((p) => basename(p) !== hooksFile)
|
|
707
|
+
config.plugin.push(hooksPlugin)
|
|
708
|
+
|
|
609
709
|
if (!config.provider && pantheonConfig.provider) {
|
|
610
710
|
config.provider = deepClone(pantheonConfig.provider)
|
|
611
711
|
} else if (config.provider && pantheonConfig.provider) {
|
|
@@ -3,34 +3,34 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { execSync } from 'node:child_process'
|
|
6
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
|
|
7
7
|
import { join } from 'node:path'
|
|
8
8
|
import { warning } from './cli-ui.mjs'
|
|
9
9
|
import { writeIfChanged } from './shared.mjs'
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
12
|
+
* Copy a plugin's runtime payload (src/index.tsx → index.tsx, dist/*,
|
|
13
|
+
* package.json) from source to destination. Pure file copy — no npm install.
|
|
13
14
|
* @param {string} srcDir - Source plugin directory (e.g. ROOT/src/plugins/tui)
|
|
14
15
|
* @param {string} dstDir - Destination plugin directory
|
|
15
16
|
* @param {object} [options]
|
|
16
17
|
* @param {boolean} [options.dryRun=false] - If true, don't write files
|
|
17
|
-
* @
|
|
18
|
-
* @returns {{ created: number, skipped: number, errors: number }}
|
|
18
|
+
* @returns {{ created: number, skipped: number }}
|
|
19
19
|
*/
|
|
20
|
-
export function
|
|
21
|
-
const result = { created: 0, skipped: 0
|
|
20
|
+
export function copyPluginFiles(srcDir, dstDir, { dryRun = false } = {}) {
|
|
21
|
+
const result = { created: 0, skipped: 0 }
|
|
22
22
|
|
|
23
23
|
if (!dryRun) mkdirSync(dstDir, { recursive: true })
|
|
24
|
-
if (clean && existsSync(dstDir) && !dryRun) {
|
|
25
|
-
rmSync(dstDir, { recursive: true, force: true })
|
|
26
|
-
mkdirSync(dstDir, { recursive: true })
|
|
27
|
-
}
|
|
28
24
|
if (!dryRun) mkdirSync(join(dstDir, 'dist'), { recursive: true })
|
|
29
25
|
|
|
30
26
|
// Copy src/index.tsx -> index.tsx
|
|
31
27
|
const srcIdx = join(srcDir, 'src', 'index.tsx')
|
|
32
28
|
if (existsSync(srcIdx)) {
|
|
33
|
-
const idxStatus = writeIfChanged(
|
|
29
|
+
const idxStatus = writeIfChanged(
|
|
30
|
+
join(dstDir, 'index.tsx'),
|
|
31
|
+
readFileSync(srcIdx, 'utf8'),
|
|
32
|
+
dryRun,
|
|
33
|
+
)
|
|
34
34
|
if (idxStatus === 'created') result.created++
|
|
35
35
|
else result.skipped++
|
|
36
36
|
}
|
|
@@ -40,25 +40,53 @@ export function installPlugin(srcDir, dstDir, { dryRun = false, clean = false }
|
|
|
40
40
|
if (existsSync(distSrc)) {
|
|
41
41
|
for (const f of readdirSync(distSrc)) {
|
|
42
42
|
const c = readFileSync(join(distSrc, f), 'utf8')
|
|
43
|
-
writeIfChanged(join(dstDir, 'dist', f), c, dryRun)
|
|
44
|
-
result.created++
|
|
43
|
+
const status = writeIfChanged(join(dstDir, 'dist', f), c, dryRun)
|
|
44
|
+
if (status === 'created') result.created++
|
|
45
|
+
else result.skipped++
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
// Copy package.json
|
|
49
50
|
const pkgSrc = join(srcDir, 'package.json')
|
|
50
51
|
if (existsSync(pkgSrc)) {
|
|
51
|
-
const pkgStatus = writeIfChanged(
|
|
52
|
+
const pkgStatus = writeIfChanged(
|
|
53
|
+
join(dstDir, 'package.json'),
|
|
54
|
+
readFileSync(pkgSrc, 'utf8'),
|
|
55
|
+
dryRun,
|
|
56
|
+
)
|
|
52
57
|
if (pkgStatus === 'created') result.created++
|
|
53
58
|
else result.skipped++
|
|
54
59
|
}
|
|
55
60
|
|
|
61
|
+
return result
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Install a plugin from source to destination.
|
|
66
|
+
* @param {string} srcDir - Source plugin directory (e.g. ROOT/src/plugins/tui)
|
|
67
|
+
* @param {string} dstDir - Destination plugin directory
|
|
68
|
+
* @param {object} [options]
|
|
69
|
+
* @param {boolean} [options.dryRun=false] - If true, don't write files
|
|
70
|
+
* @param {boolean} [options.clean=false] - If true, remove existing before install
|
|
71
|
+
* @returns {{ created: number, skipped: number, errors: number }}
|
|
72
|
+
*/
|
|
73
|
+
export function installPlugin(srcDir, dstDir, { dryRun = false, clean = false } = {}) {
|
|
74
|
+
const result = { created: 0, skipped: 0, errors: 0 }
|
|
75
|
+
|
|
76
|
+
if (clean && existsSync(dstDir) && !dryRun) {
|
|
77
|
+
rmSync(dstDir, { recursive: true, force: true })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const copyResult = copyPluginFiles(srcDir, dstDir, { dryRun })
|
|
81
|
+
result.created += copyResult.created
|
|
82
|
+
result.skipped += copyResult.skipped
|
|
83
|
+
|
|
56
84
|
// Install plugin dependencies (@opentui/core, @opentui/solid, solid-js)
|
|
57
85
|
if (!dryRun) {
|
|
58
86
|
try {
|
|
59
87
|
execSync('npm install --omit=dev --no-audit', { cwd: dstDir, stdio: 'pipe' })
|
|
60
88
|
} catch (e) {
|
|
61
|
-
warning(
|
|
89
|
+
warning(`Failed to install TUI plugin dependencies: ${e.message}`)
|
|
62
90
|
result.errors++
|
|
63
91
|
}
|
|
64
92
|
}
|
|
@@ -77,7 +105,7 @@ export function installPluginDependencies(pluginDir, { dryRun = false } = {}) {
|
|
|
77
105
|
try {
|
|
78
106
|
execSync('npm install --omit=dev --no-audit', { cwd: pluginDir, stdio: 'pipe' })
|
|
79
107
|
} catch (e) {
|
|
80
|
-
warning(
|
|
108
|
+
warning(`Failed to install plugin dependencies: ${e.message}`)
|
|
81
109
|
}
|
|
82
110
|
}
|
|
83
111
|
|
|
@@ -108,6 +136,55 @@ export function registerPlugin(tuiJsonPath, pluginId, { dryRun = false } = {}) {
|
|
|
108
136
|
return writeIfChanged(tuiJsonPath, tuiContent, dryRun)
|
|
109
137
|
}
|
|
110
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Suffix patterns that identify a Pantheon TUI registration as STALE. Every
|
|
141
|
+
* reference shape ever written by previous installers must be covered:
|
|
142
|
+
* - dist file refs (both the old in-package src/plugins/tui target and the
|
|
143
|
+
* copied plugins/pantheon-tui target, tsx and js eras)
|
|
144
|
+
* - the in-package source dir (src/plugins/tui) — a registration pointing
|
|
145
|
+
* into the installed package breaks on reinstall/upgrade (the dir is
|
|
146
|
+
* replaced under a live registration)
|
|
147
|
+
* - bare copied dirs (plugins/pantheon-tui) from any OLD install target —
|
|
148
|
+
* indistinguishable from the current copied dir without knowing the
|
|
149
|
+
* install target, so they are flagged too; syncTuiRegistration keeps the
|
|
150
|
+
* current ref via unregister-then-register
|
|
151
|
+
* - node_modules copies (npx cache installs of the vendored plugin)
|
|
152
|
+
*/
|
|
153
|
+
export const TUI_STALE_SUFFIXES = [
|
|
154
|
+
'/src/plugins/tui/dist/tui.tsx',
|
|
155
|
+
'/src/plugins/tui/dist/tui.js',
|
|
156
|
+
'/src/plugins/tui',
|
|
157
|
+
'/plugins/pantheon-tui/dist/tui.tsx',
|
|
158
|
+
'/plugins/pantheon-tui/dist/tui.js',
|
|
159
|
+
'/plugins/pantheon-tui',
|
|
160
|
+
'/node_modules/pantheon-tui',
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Whether a single tui.json plugin entry is a stale Pantheon TUI reference.
|
|
165
|
+
* @param {unknown} ref - tui.json plugin entry
|
|
166
|
+
* @returns {boolean}
|
|
167
|
+
*/
|
|
168
|
+
export function isStaleTuiRef(ref) {
|
|
169
|
+
if (typeof ref !== 'string') return false
|
|
170
|
+
if (ref === 'plugins/pantheon-tui') return true
|
|
171
|
+
const normalized = ref.replaceAll('\\', '/')
|
|
172
|
+
// npx spec strings ("npx pantheon-tui", "npx -y pantheon-tui",
|
|
173
|
+
// "npx:pantheon-tui") and npx cache paths end with /node_modules/pantheon-tui.
|
|
174
|
+
if (/^npx[\s:]/i.test(normalized) && normalized.includes('pantheon-tui')) return true
|
|
175
|
+
return TUI_STALE_SUFFIXES.some((suffix) => normalized.endsWith(suffix))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Pure filter: return the plugin entries of a tui.json plugin array that are
|
|
180
|
+
* stale Pantheon TUI references.
|
|
181
|
+
* @param {unknown[]} pluginArray - tui.json plugin array
|
|
182
|
+
* @returns {string[]}
|
|
183
|
+
*/
|
|
184
|
+
export function staleTuiRefs(pluginArray) {
|
|
185
|
+
return (Array.isArray(pluginArray) ? pluginArray : []).filter(isStaleTuiRef)
|
|
186
|
+
}
|
|
187
|
+
|
|
111
188
|
/**
|
|
112
189
|
* Unregister stale plugin references from tui.json.
|
|
113
190
|
* @param {string} tuiJsonPath - Path to tui.json
|
|
@@ -117,25 +194,32 @@ export function registerPlugin(tuiJsonPath, pluginId, { dryRun = false } = {}) {
|
|
|
117
194
|
* @returns {'created'|'skipped'}
|
|
118
195
|
*/
|
|
119
196
|
export function unregisterPlugin(tuiJsonPath, pluginId, { dryRun = false } = {}) {
|
|
197
|
+
if (!existsSync(tuiJsonPath)) {
|
|
198
|
+
// Nothing to clean — and never create an empty tui.json in a location
|
|
199
|
+
// OpenCode reads (the cross-location cleanup also touches global config
|
|
200
|
+
// dirs that may not exist yet; writing there would ENOENT).
|
|
201
|
+
return 'skipped'
|
|
202
|
+
}
|
|
120
203
|
let tuiConfig = { plugin: [] }
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
/* use default */
|
|
126
|
-
}
|
|
204
|
+
try {
|
|
205
|
+
tuiConfig = JSON.parse(readFileSync(tuiJsonPath, 'utf8'))
|
|
206
|
+
} catch {
|
|
207
|
+
/* use default */
|
|
127
208
|
}
|
|
128
209
|
if (!Array.isArray(tuiConfig.plugin)) {
|
|
129
210
|
tuiConfig.plugin = []
|
|
130
211
|
}
|
|
131
212
|
// Remove stale refs: the bare relative id (openCode < 1.19 wrote
|
|
132
213
|
// "plugins/pantheon-tui", which its tui loader misreads as an npm/github
|
|
133
|
-
// spec → NpmInstallFailedError)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
214
|
+
// spec → NpmInstallFailedError), old dist paths, in-package source dirs,
|
|
215
|
+
// bare copy dirs from previous install targets, and npx cache copies.
|
|
216
|
+
// Without removing the absolute paths, each init run accumulates another
|
|
217
|
+
// TUI plugin and OpenCode loads stale copies.
|
|
218
|
+
const exactStale = [pluginId, `${pluginId}/dist/tui.tsx`, `${pluginId}/dist/tui.js`]
|
|
219
|
+
tuiConfig.plugin = tuiConfig.plugin.filter((ref) => {
|
|
220
|
+
if (exactStale.includes(ref)) return false
|
|
221
|
+
return !isStaleTuiRef(ref)
|
|
222
|
+
})
|
|
139
223
|
const tuiContent = `${JSON.stringify(tuiConfig, null, 2)}\n`
|
|
140
224
|
return writeIfChanged(tuiJsonPath, tuiContent, dryRun)
|
|
141
225
|
}
|
package/scripts/install-mcp.mjs
CHANGED
|
@@ -37,7 +37,7 @@ const ROOT = join(__dirname, '..')
|
|
|
37
37
|
// venv python3 and the server script. ROOT alone is ambiguous: when this
|
|
38
38
|
// installer runs from a global npm install, `join(ROOT, '.venv/bin/python3')`
|
|
39
39
|
// points at a venv that does not exist there, and a stale config with a
|
|
40
|
-
// hardcoded path to a migrated home (e.g.
|
|
40
|
+
// hardcoded path to a migrated home (e.g. <dev>/admin) fails with
|
|
41
41
|
// `posix_spawn ENOENT`. Resolution order:
|
|
42
42
|
// 1. Canonical user install: ~/.config/opencode/.venv + ~/.config/opencode/scripts/
|
|
43
43
|
// 2. Local checkout: <ROOT>/.venv + <ROOT>/<relPath>
|