pantheon-opencode 1.3.6 → 1.3.7
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/package.json +1 -1
- package/scripts/sync-tui.mjs +148 -0
- package/src/pantheon/delegation-notify.ts +97 -0
- package/src/pantheon/delegation.ts +10 -0
- package/src/pantheon/tui-version-check.ts +72 -0
- package/src/plugin.ts +29 -0
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +55 -7
package/package.json
CHANGED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* sync-tui.mjs — Postinstall hook: sync TUI plugin to user's OpenCode config
|
|
4
|
+
*
|
|
5
|
+
* Runs automatically after `npm install pantheon-opencode@latest`. Detects the
|
|
6
|
+
* user's OpenCode config directory and, if the TUI plugin was previously
|
|
7
|
+
* installed (via `npx pantheon-opencode init`), copies fresh files from the
|
|
8
|
+
* installed package and refreshes dependencies.
|
|
9
|
+
*
|
|
10
|
+
* If the user hasn't initialized yet → do nothing (silent exit).
|
|
11
|
+
*/
|
|
12
|
+
import { execSync } from 'node:child_process'
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
|
|
18
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
19
|
+
const ROOT = join(__dirname, '..')
|
|
20
|
+
|
|
21
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Write content to filePath only if it differs from what's on disk.
|
|
25
|
+
* @returns {boolean} true if file was written (changed), false if skipped
|
|
26
|
+
*/
|
|
27
|
+
function writeIfChanged(filePath, content) {
|
|
28
|
+
const existing = existsSync(filePath) ? readFileSync(filePath, 'utf8') : null
|
|
29
|
+
if (existing === content) return false
|
|
30
|
+
writeFileSync(filePath, content, 'utf8')
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read the `version` field from a package.json, or null on failure.
|
|
36
|
+
*/
|
|
37
|
+
function readVersion(pkgPath) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).version || null
|
|
40
|
+
} catch {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the user's OpenCode config directory.
|
|
47
|
+
* Priority: $XDG_CONFIG_HOME/opencode → ~/.opencode → null (not initialized)
|
|
48
|
+
*/
|
|
49
|
+
function resolveConfigDir() {
|
|
50
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
|
|
51
|
+
const xdgDir = join(xdgConfig, 'opencode')
|
|
52
|
+
if (existsSync(xdgDir)) return xdgDir
|
|
53
|
+
|
|
54
|
+
const homeDir = join(homedir(), '.opencode')
|
|
55
|
+
if (existsSync(homeDir)) return homeDir
|
|
56
|
+
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Copy plugin runtime files (dist/*, package.json, src/index.tsx) from srcDir
|
|
62
|
+
* to dstDir. Skips files that are byte-identical.
|
|
63
|
+
* @returns {{ created: number, skipped: number }}
|
|
64
|
+
*/
|
|
65
|
+
function copyPluginFiles(srcDir, dstDir) {
|
|
66
|
+
const result = { created: 0, skipped: 0 }
|
|
67
|
+
|
|
68
|
+
mkdirSync(dstDir, { recursive: true })
|
|
69
|
+
mkdirSync(join(dstDir, 'dist'), { recursive: true })
|
|
70
|
+
|
|
71
|
+
// src/index.tsx → index.tsx
|
|
72
|
+
const srcIdx = join(srcDir, 'src', 'index.tsx')
|
|
73
|
+
if (existsSync(srcIdx)) {
|
|
74
|
+
const content = readFileSync(srcIdx, 'utf8')
|
|
75
|
+
if (writeIfChanged(join(dstDir, 'index.tsx'), content)) result.created++
|
|
76
|
+
else result.skipped++
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// dist/*
|
|
80
|
+
const distSrc = join(srcDir, 'dist')
|
|
81
|
+
if (existsSync(distSrc)) {
|
|
82
|
+
for (const f of readdirSync(distSrc)) {
|
|
83
|
+
const content = readFileSync(join(distSrc, f), 'utf8')
|
|
84
|
+
if (writeIfChanged(join(dstDir, 'dist', f), content)) result.created++
|
|
85
|
+
else result.skipped++
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// package.json
|
|
90
|
+
const pkgSrc = join(srcDir, 'package.json')
|
|
91
|
+
if (existsSync(pkgSrc)) {
|
|
92
|
+
const content = readFileSync(pkgSrc, 'utf8')
|
|
93
|
+
if (writeIfChanged(join(dstDir, 'package.json'), content)) result.created++
|
|
94
|
+
else result.skipped++
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return result
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Main ───────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const configDir = resolveConfigDir()
|
|
104
|
+
if (!configDir) {
|
|
105
|
+
// User hasn't run init yet — silent exit, no error
|
|
106
|
+
process.exit(0)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const tuiCopyDir = join(configDir, 'plugins', 'pantheon-tui')
|
|
110
|
+
if (!existsSync(tuiCopyDir)) {
|
|
111
|
+
// TUI plugin not installed — silent exit
|
|
112
|
+
process.exit(0)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Source: TUI plugin inside the installed package
|
|
116
|
+
const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
|
|
117
|
+
if (!existsSync(tuiSrcDir)) {
|
|
118
|
+
// Source not found (shouldn't happen in a proper install)
|
|
119
|
+
process.exit(0)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Compare versions before copy
|
|
123
|
+
const installedVersion = readVersion(join(tuiCopyDir, 'package.json'))
|
|
124
|
+
const sourceVersion = readVersion(join(tuiSrcDir, 'package.json'))
|
|
125
|
+
|
|
126
|
+
// Copy fresh files
|
|
127
|
+
const { created, skipped } = copyPluginFiles(tuiSrcDir, tuiCopyDir)
|
|
128
|
+
|
|
129
|
+
// Refresh dependencies in the copy
|
|
130
|
+
try {
|
|
131
|
+
execSync('npm install --omit=dev --no-audit', { cwd: tuiCopyDir, stdio: 'pipe' })
|
|
132
|
+
} catch {
|
|
133
|
+
// Non-fatal — deps may already be current
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Log result
|
|
137
|
+
if (installedVersion && sourceVersion && installedVersion === sourceVersion && created === 0) {
|
|
138
|
+
console.log(` TUI plugin already up to date (v${sourceVersion})`)
|
|
139
|
+
} else {
|
|
140
|
+
console.log(
|
|
141
|
+
` TUI plugin updated to v${sourceVersion || 'latest'}${installedVersion ? ` (was v${installedVersion})` : ''}`,
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
} catch (err) {
|
|
145
|
+
// Postinstall scripts must never break npm install
|
|
146
|
+
console.warn(`⚠️ TUI sync skipped: ${err.message}`)
|
|
147
|
+
process.exit(0)
|
|
148
|
+
}
|
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
* acknowledgment, not a completion), so every job gets exactly ONE terminal
|
|
22
22
|
* transition — never a reconciled echo. Unknown sessions are ignored.
|
|
23
23
|
*
|
|
24
|
+
* `finalizeIdleChildrenWithoutMd` proactively finalizes children that are
|
|
25
|
+
* idle on the board but have no MD report on disk — eliminates the phantom
|
|
26
|
+
* "running" window during `pullOutput` (Fix 2 for the TUI stale-running bug).
|
|
27
|
+
* Wired into a periodic scan via `startIdleChildScan` (default 30 s cadence).
|
|
28
|
+
*
|
|
24
29
|
* Pure TypeScript — zero runtime dependencies beyond the board types.
|
|
25
30
|
*
|
|
26
31
|
* Follow-up (Themis review, Phase 5): `extractErrorMessage` handles the two
|
|
@@ -38,6 +43,9 @@ import type { FinalizeInput } from './delegation-finalize.ts'
|
|
|
38
43
|
|
|
39
44
|
export type { FinalizeInput } from './delegation-finalize.ts'
|
|
40
45
|
|
|
46
|
+
/** Default cadence for the proactive idle-child scan (30 s). */
|
|
47
|
+
export const IDLE_SCAN_INTERVAL_MS = 30_000
|
|
48
|
+
|
|
41
49
|
// ─── Types ─────────────────────────────────────────────────────────────
|
|
42
50
|
|
|
43
51
|
/** Structural view of the event payload the `event` hook receives. */
|
|
@@ -98,3 +106,92 @@ export async function handleDelegationEvent(
|
|
|
98
106
|
}
|
|
99
107
|
return true
|
|
100
108
|
}
|
|
109
|
+
|
|
110
|
+
// ─── Proactive finalize (Fix 2) ────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/** Dependencies for idle-child scanning. */
|
|
113
|
+
export interface IdleChildScanDeps {
|
|
114
|
+
/** Look up a board job by session ID (child session IDs are task IDs). */
|
|
115
|
+
board: {
|
|
116
|
+
get(taskID: string): BackgroundJobRecord | undefined
|
|
117
|
+
list(parentSessionID?: string): BackgroundJobRecord[]
|
|
118
|
+
}
|
|
119
|
+
/** Bound Phase 2 finalize — clears the timeout timer + writes the report. */
|
|
120
|
+
finalize: (childSessionID: string, opts: FinalizeInput) => Promise<unknown>
|
|
121
|
+
/**
|
|
122
|
+
* Check whether an MD report exists for the given board job.
|
|
123
|
+
* Receives the full job record so the caller can construct the report
|
|
124
|
+
* path from `job.parentSessionID` + `job.alias`.
|
|
125
|
+
* If the function throws, the scan skips that child (fail-open).
|
|
126
|
+
*/
|
|
127
|
+
hasReport: (job: BackgroundJobRecord) => boolean
|
|
128
|
+
/** Optional logger for diagnostic messages. */
|
|
129
|
+
logger?: { info?: (msg: string) => void; warn?: (msg: string) => void }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Scan running board jobs for children whose sessions are idle but have no MD
|
|
134
|
+
* report on disk, and proactively finalize them. This eliminates the phantom
|
|
135
|
+
* "running" window that occurs between `session.idle` firing and the
|
|
136
|
+
* `pullOutput`→`writeDelegationReport` cycle completing.
|
|
137
|
+
*
|
|
138
|
+
* Called from the periodic idle-child scan (`startIdleChildScan`, default 30 s).
|
|
139
|
+
* The scan is O(N) where N = number of running jobs (typically < 10).
|
|
140
|
+
*
|
|
141
|
+
* Each child is finalized exactly once — the board's same-terminal idempotency
|
|
142
|
+
* ensures repeated scans are harmless. Errors are caught per-child so one
|
|
143
|
+
* failure never blocks the rest.
|
|
144
|
+
*
|
|
145
|
+
* @returns Number of children finalized (for diagnostics).
|
|
146
|
+
*/
|
|
147
|
+
export async function finalizeIdleChildrenWithoutMd(
|
|
148
|
+
deps: IdleChildScanDeps,
|
|
149
|
+
): Promise<number> {
|
|
150
|
+
const runningJobs = deps.board
|
|
151
|
+
.list()
|
|
152
|
+
.filter((j: BackgroundJobRecord) => j.state === 'running')
|
|
153
|
+
|
|
154
|
+
let finalized = 0
|
|
155
|
+
for (const job of runningJobs) {
|
|
156
|
+
try {
|
|
157
|
+
// If the child already has an MD report, finalize was already handled
|
|
158
|
+
// by handleDelegationEvent — skip.
|
|
159
|
+
if (deps.hasReport(job)) continue
|
|
160
|
+
// Child is idle on the board but has no report — proactively finalize.
|
|
161
|
+
await deps.finalize(job.taskID, { state: 'completed' })
|
|
162
|
+
finalized++
|
|
163
|
+
} catch (err) {
|
|
164
|
+
deps.logger?.warn?.(
|
|
165
|
+
`[delegation-notify] proactive finalize failed for ${job.taskID}: ${err}`,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return finalized
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── Periodic scan launcher ─────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Start a periodic scan that proactively finalizes idle board children
|
|
176
|
+
* without MD reports. The scan runs every `intervalMs` (default 30 s) and
|
|
177
|
+
* is **unref'd** so it never holds the process open on its own.
|
|
178
|
+
*
|
|
179
|
+
* The timer is idempotent — calling this function multiple times is safe
|
|
180
|
+
* (each call creates an independent timer, but the underlying finalize
|
|
181
|
+
* operation is itself idempotent due to the board's same-terminal guard).
|
|
182
|
+
*
|
|
183
|
+
* @returns A `NodeJS.Timeout` handle that can be cleared with
|
|
184
|
+
* `clearInterval(handle)` to stop the scan.
|
|
185
|
+
*/
|
|
186
|
+
export function startIdleChildScan(
|
|
187
|
+
deps: IdleChildScanDeps,
|
|
188
|
+
intervalMs: number = IDLE_SCAN_INTERVAL_MS,
|
|
189
|
+
): NodeJS.Timeout {
|
|
190
|
+
const timer = setInterval(() => {
|
|
191
|
+
void finalizeIdleChildrenWithoutMd(deps).catch((err) => {
|
|
192
|
+
deps.logger?.warn?.(`[delegation-notify] periodic idle scan failed: ${err}`)
|
|
193
|
+
})
|
|
194
|
+
}, intervalMs)
|
|
195
|
+
timer.unref()
|
|
196
|
+
return timer
|
|
197
|
+
}
|
|
@@ -58,6 +58,11 @@ import {
|
|
|
58
58
|
finalizeDelegation as finalizeDelegationReport,
|
|
59
59
|
readDelegationReport,
|
|
60
60
|
} from './delegation-finalize.ts'
|
|
61
|
+
import {
|
|
62
|
+
type IdleChildScanDeps,
|
|
63
|
+
finalizeIdleChildrenWithoutMd,
|
|
64
|
+
startIdleChildScan,
|
|
65
|
+
} from './delegation-notify.ts'
|
|
61
66
|
import { createPantheonLogger } from './logger.ts'
|
|
62
67
|
import { buildAgentListDescription } from './permission-globs.ts'
|
|
63
68
|
import { missingProviderKeyEnv, resolveActivePreset } from './presets.mjs'
|
|
@@ -71,6 +76,8 @@ export type {
|
|
|
71
76
|
FinalizeInput,
|
|
72
77
|
} from './delegation-finalize.ts'
|
|
73
78
|
export { DELEGATION_DEFAULTS } from './delegation-finalize.ts'
|
|
79
|
+
export type { IdleChildScanDeps } from './delegation-notify.ts'
|
|
80
|
+
export { finalizeIdleChildrenWithoutMd, startIdleChildScan } from './delegation-notify.ts'
|
|
74
81
|
|
|
75
82
|
// Fase B3: these are runtime controls, not routing/frontmatter fields. The
|
|
76
83
|
// plugin reads them from the environment, while tests and embedders can pass
|
|
@@ -326,6 +333,8 @@ export interface DelegationToolset {
|
|
|
326
333
|
childSessionID: string,
|
|
327
334
|
opts: FinalizeInput,
|
|
328
335
|
) => Promise<BackgroundJobRecord | undefined>
|
|
336
|
+
/** Proactive finalize for idle children without MD reports (Fix 2). */
|
|
337
|
+
finalizeIdleChildrenWithoutMd: typeof finalizeIdleChildrenWithoutMd
|
|
329
338
|
}
|
|
330
339
|
|
|
331
340
|
/** Input for createDelegationTools(). */
|
|
@@ -1182,5 +1191,6 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
1182
1191
|
pantheon_delegation_read,
|
|
1183
1192
|
pantheon_delegation_list,
|
|
1184
1193
|
finalizeDelegation: finalize,
|
|
1194
|
+
finalizeIdleChildrenWithoutMd,
|
|
1185
1195
|
}
|
|
1186
1196
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tui-version-check.ts — Runtime staleness check for the TUI plugin copy
|
|
3
|
+
*
|
|
4
|
+
* Compares the version in the user's copied TUI plugin package.json against
|
|
5
|
+
* the version of the installed pantheon-opencode package. If mismatched,
|
|
6
|
+
* logs a one-time warning. Does NOT auto-sync (safety first).
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
|
|
13
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
14
|
+
const ROOT = join(__dirname, '..', '..')
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Detect the user's OpenCode config directory (same logic as sync-tui.mjs).
|
|
18
|
+
* Priority: $XDG_CONFIG_HOME/opencode → ~/.opencode → null
|
|
19
|
+
*/
|
|
20
|
+
function resolveConfigDir(): string | null {
|
|
21
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
|
|
22
|
+
const xdgDir = join(xdgConfig, 'opencode')
|
|
23
|
+
if (existsSync(xdgDir)) return xdgDir
|
|
24
|
+
|
|
25
|
+
const homeDir = join(homedir(), '.opencode')
|
|
26
|
+
if (existsSync(homeDir)) return homeDir
|
|
27
|
+
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read the `version` field from a package.json, or null on failure.
|
|
33
|
+
*/
|
|
34
|
+
function readVersion(pkgPath: string): string | null {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).version || null
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Run the TUI version staleness check. Call once at plugin init.
|
|
44
|
+
*
|
|
45
|
+
* @param logger - Logging function (defaults to console.warn)
|
|
46
|
+
*/
|
|
47
|
+
export function checkTuiVersionStaleness(
|
|
48
|
+
logger: (msg: string) => void = console.warn,
|
|
49
|
+
): void {
|
|
50
|
+
try {
|
|
51
|
+
const configDir = resolveConfigDir()
|
|
52
|
+
if (!configDir) return // Not initialized — nothing to check
|
|
53
|
+
|
|
54
|
+
const tuiCopyDir = join(configDir, 'plugins', 'pantheon-tui')
|
|
55
|
+
if (!existsSync(tuiCopyDir)) return // TUI not installed
|
|
56
|
+
|
|
57
|
+
const copyVersion = readVersion(join(tuiCopyDir, 'package.json'))
|
|
58
|
+
if (!copyVersion) return // Can't read version — skip
|
|
59
|
+
|
|
60
|
+
// Read the installed package version from the repo root's package.json
|
|
61
|
+
const currentVersion = readVersion(join(ROOT, 'package.json'))
|
|
62
|
+
if (!currentVersion) return // Can't determine current version — skip
|
|
63
|
+
|
|
64
|
+
if (copyVersion !== currentVersion) {
|
|
65
|
+
logger(
|
|
66
|
+
`⚠️ TUI plugin v${copyVersion} is outdated. Current: v${currentVersion}. Run: npx pantheon-opencode init`,
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// Fail-open — version check must never block plugin initialization
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Plugin, PluginInput } from '@opencode-ai/plugin'
|
|
2
2
|
import type { PluginConfig } from 'opencode'
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
3
5
|
import { BackgroundJobBoard } from './pantheon/background-job-board.ts'
|
|
4
6
|
import { reassertAfterCompaction } from './pantheon/compaction-assert.ts'
|
|
5
7
|
import { createCostCommand } from './pantheon/cost-command.ts'
|
|
@@ -9,6 +11,7 @@ import {
|
|
|
9
11
|
type DelegationClient,
|
|
10
12
|
resolveDelegationBudgets,
|
|
11
13
|
resolveDelegationTimeoutMs,
|
|
14
|
+
startIdleChildScan,
|
|
12
15
|
} from './pantheon/delegation.ts'
|
|
13
16
|
import { buildCompactionContext } from './pantheon/delegation-compaction.ts'
|
|
14
17
|
import {
|
|
@@ -40,6 +43,7 @@ import {
|
|
|
40
43
|
todoEnforcerEnabledFromEnv,
|
|
41
44
|
} from './pantheon/todo-enforcer.ts'
|
|
42
45
|
import { TodoPreserver } from './pantheon/todo-preserve.ts'
|
|
46
|
+
import { checkTuiVersionStaleness } from './pantheon/tui-version-check.ts'
|
|
43
47
|
import { activePresetCandidates, createVisionHandler } from './pantheon/vision.ts'
|
|
44
48
|
|
|
45
49
|
// ─── Background Job Board Singleton ────────────────────────────────────
|
|
@@ -334,6 +338,9 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
334
338
|
if (pantheonPluginOnce('pantheon:plugin')) {
|
|
335
339
|
return {}
|
|
336
340
|
}
|
|
341
|
+
// Runtime TUI version staleness check — warns if the copied TUI plugin
|
|
342
|
+
// is older than the installed package. Runs once, fail-open, no auto-sync.
|
|
343
|
+
checkTuiVersionStaleness((msg) => log.warn(msg))
|
|
337
344
|
const vision = createVisionHandler(input)
|
|
338
345
|
// Phase 2/3: background delegation toolset + the bound finalize lifecycle
|
|
339
346
|
// hook. Completion is observed through the event hook below
|
|
@@ -363,6 +370,28 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
363
370
|
},
|
|
364
371
|
})
|
|
365
372
|
|
|
373
|
+
// Proactive idle-child scan (Fix 2): finalize children that are idle on the
|
|
374
|
+
// board but have no MD report on disk — eliminates the phantom "running"
|
|
375
|
+
// window. Runs every 30s (configurable via PANTHEON_IDLE_SCAN_INTERVAL_MS),
|
|
376
|
+
// unref'd so it never holds the process open. Fail-open by design.
|
|
377
|
+
const idleScanIntervalMs = Number(process.env.PANTHEON_IDLE_SCAN_INTERVAL_MS) || undefined
|
|
378
|
+
startIdleChildScan(
|
|
379
|
+
{
|
|
380
|
+
board,
|
|
381
|
+
finalize: (childSessionID, opts) => delegation.finalizeDelegation(childSessionID, opts),
|
|
382
|
+
hasReport: (job) => {
|
|
383
|
+
try {
|
|
384
|
+
const filePath = join('.pantheon/delegations', job.parentSessionID, `${job.alias}.md`)
|
|
385
|
+
return existsSync(filePath)
|
|
386
|
+
} catch {
|
|
387
|
+
return false
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
logger: log,
|
|
391
|
+
},
|
|
392
|
+
idleScanIntervalMs,
|
|
393
|
+
)
|
|
394
|
+
|
|
366
395
|
// Wave 1 (PR #46): TODO continuation enforcer for root/non-board sessions.
|
|
367
396
|
// Mirrors routing.yml `todo_enforcer` (plugin scope has no routing.yml
|
|
368
397
|
// access — COMPACTION_MAX_ITEMS pattern). The event hook routes non-board
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pantheon-tui",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.3.
|
|
4
|
+
"version": "1.3.7",
|
|
5
5
|
"description": "Pantheon TUI plugin — sidebar (version, preset, sessions, real-time delegations) + AI subscription usage gauges incl. OpenCode Go/Zen dollar limits (vendored, MIT)",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"exports": {
|
|
@@ -942,7 +942,14 @@ export type DelegationEntry = {
|
|
|
942
942
|
taskID?: string
|
|
943
943
|
/** Agent name, e.g. "apollo". */
|
|
944
944
|
agent: string
|
|
945
|
-
state:
|
|
945
|
+
state:
|
|
946
|
+
| 'running'
|
|
947
|
+
| 'completed'
|
|
948
|
+
| 'error'
|
|
949
|
+
| 'startup_failed'
|
|
950
|
+
| 'startup_unknown'
|
|
951
|
+
| 'cancelled'
|
|
952
|
+
| 'stale-running'
|
|
946
953
|
/** Epoch ms of the `Started` header. */
|
|
947
954
|
startedAt: number
|
|
948
955
|
/** Epoch ms of the `Finalized` header — null while still running. */
|
|
@@ -1048,8 +1055,10 @@ export function parseDelegationMarkdown(
|
|
|
1048
1055
|
const startedAt = started !== undefined ? Date.parse(started) : NaN
|
|
1049
1056
|
if (agent === undefined || state === undefined || Number.isNaN(startedAt)) return null
|
|
1050
1057
|
const normalized = state.toLowerCase()
|
|
1058
|
+
// Running state should only come from live channels, never from persisted
|
|
1059
|
+
// reports. A stale "running" MD artifact contaminates the panel — skip it.
|
|
1060
|
+
if (normalized === 'running') return null
|
|
1051
1061
|
if (
|
|
1052
|
-
normalized !== 'running' &&
|
|
1053
1062
|
normalized !== 'completed' &&
|
|
1054
1063
|
normalized !== 'error' &&
|
|
1055
1064
|
normalized !== 'startup_failed' &&
|
|
@@ -1171,8 +1180,8 @@ export function tuiLogPath(projectRoot: string): string {
|
|
|
1171
1180
|
* falling back to startedAt, descending). Shared by the md reader and
|
|
1172
1181
|
* mergeDelegationSources. */
|
|
1173
1182
|
export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry): number {
|
|
1174
|
-
const aRun = a.state === 'running' ? 1 : 0
|
|
1175
|
-
const bRun = b.state === 'running' ? 1 : 0
|
|
1183
|
+
const aRun = a.state === 'running' || a.state === 'stale-running' ? 1 : 0
|
|
1184
|
+
const bRun = b.state === 'running' || b.state === 'stale-running' ? 1 : 0
|
|
1176
1185
|
if (aRun !== bRun) return bRun - aRun
|
|
1177
1186
|
return (b.updatedAt ?? b.startedAt) - (a.updatedAt ?? a.startedAt)
|
|
1178
1187
|
}
|
|
@@ -1184,12 +1193,45 @@ export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry)
|
|
|
1184
1193
|
export function visibleDelegationList(
|
|
1185
1194
|
all: readonly DelegationEntry[],
|
|
1186
1195
|
maxTerminal = 8,
|
|
1196
|
+
now = Date.now(),
|
|
1197
|
+
staleThresholdMs = 30 * 60 * 1000, // 30 minutes
|
|
1187
1198
|
): DelegationEntry[] {
|
|
1188
|
-
const running = all
|
|
1199
|
+
const running = all
|
|
1200
|
+
.filter((d) => d.state === 'running')
|
|
1201
|
+
.map((d) => markStaleIfRunning(d, now, staleThresholdMs))
|
|
1189
1202
|
const terminal = all.filter((d) => d.state !== 'running').slice(0, maxTerminal)
|
|
1190
1203
|
return [...running, ...terminal]
|
|
1191
1204
|
}
|
|
1192
1205
|
|
|
1206
|
+
/** Default stale-running threshold: 30 minutes. */
|
|
1207
|
+
export const STALE_RUNNING_THRESHOLD_MS = 30 * 60 * 1000
|
|
1208
|
+
|
|
1209
|
+
/** Idle silence window: if no updatedAt change in this window, the entry is
|
|
1210
|
+
* considered stale. Combined with the stale-running threshold to produce the
|
|
1211
|
+
* display-only `stale-running` state. */
|
|
1212
|
+
export const IDLE_SILENCE_MS = 60 * 1000
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* Mark a running entry as `stale-running` if it has been running longer than
|
|
1216
|
+
* the threshold AND has no recent activity (no `updatedAt` change in the last
|
|
1217
|
+
* `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the board state is unchanged.
|
|
1218
|
+
*
|
|
1219
|
+
* A `stale-running` entry renders with a warning indicator but the underlying
|
|
1220
|
+
* delegation is still treated as running by the backend.
|
|
1221
|
+
*/
|
|
1222
|
+
export function markStaleIfRunning(
|
|
1223
|
+
entry: DelegationEntry,
|
|
1224
|
+
now: number,
|
|
1225
|
+
thresholdMs = STALE_RUNNING_THRESHOLD_MS,
|
|
1226
|
+
): DelegationEntry {
|
|
1227
|
+
if (entry.state !== 'running') return entry
|
|
1228
|
+
const elapsed = now - entry.startedAt
|
|
1229
|
+
if (elapsed < thresholdMs) return entry
|
|
1230
|
+
// If updatedAt exists and is recent, entry is still active — not stale.
|
|
1231
|
+
if (entry.updatedAt !== null && now - entry.updatedAt < IDLE_SILENCE_MS) return entry
|
|
1232
|
+
return { ...entry, state: 'stale-running' as const }
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1193
1235
|
/** Compact elapsed-time label: "5m 12s", "1h 30m", "2d 4h" — ticks every
|
|
1194
1236
|
* second for running jobs. */
|
|
1195
1237
|
export function fmtElapsed(ms: number): string {
|
|
@@ -1207,7 +1249,8 @@ export function fmtElapsed(ms: number): string {
|
|
|
1207
1249
|
/** Elapsed label for one entry: running → ticks `now - startedAt`, terminal
|
|
1208
1250
|
* → fixed `updatedAt - startedAt` (em dash when no finalized timestamp). */
|
|
1209
1251
|
export function delegationElapsed(entry: DelegationEntry, now: number): string {
|
|
1210
|
-
if (entry.state === 'running'
|
|
1252
|
+
if (entry.state === 'running' || entry.state === 'stale-running')
|
|
1253
|
+
return fmtElapsed(now - entry.startedAt)
|
|
1211
1254
|
return entry.updatedAt !== null ? fmtElapsed(entry.updatedAt - entry.startedAt) : '\u2014'
|
|
1212
1255
|
}
|
|
1213
1256
|
|
|
@@ -1874,7 +1917,9 @@ export function childrenToDelegationEntries(
|
|
|
1874
1917
|
agent: mdEntry?.agent ?? child.agent ?? 'agent',
|
|
1875
1918
|
state,
|
|
1876
1919
|
startedAt: mdEntry?.startedAt ?? child.time?.created ?? now,
|
|
1877
|
-
updatedAt:
|
|
1920
|
+
updatedAt:
|
|
1921
|
+
mdEntry?.updatedAt ??
|
|
1922
|
+
(state === 'running' || state === 'stale-running' ? null : (child.time?.updated ?? null)),
|
|
1878
1923
|
timedOut: mdEntry?.timedOut ?? false,
|
|
1879
1924
|
description:
|
|
1880
1925
|
mdEntry !== undefined && mdEntry.description !== ''
|
|
@@ -2008,6 +2053,8 @@ function DelegationRow(props: {
|
|
|
2008
2053
|
switch (props.job.state) {
|
|
2009
2054
|
case 'running':
|
|
2010
2055
|
return t.warning
|
|
2056
|
+
case 'stale-running':
|
|
2057
|
+
return t.error // warning color for stale delegations
|
|
2011
2058
|
case 'completed':
|
|
2012
2059
|
return t.success
|
|
2013
2060
|
case 'error':
|
|
@@ -2019,6 +2066,7 @@ function DelegationRow(props: {
|
|
|
2019
2066
|
|
|
2020
2067
|
const marker = createMemo(() => {
|
|
2021
2068
|
if (props.job.state === 'running') return `${delegationSpinnerFrame(props.animationNow)} `
|
|
2069
|
+
if (props.job.state === 'stale-running') return '\u26a0 ' // warning triangle
|
|
2022
2070
|
switch (props.job.state) {
|
|
2023
2071
|
case 'completed':
|
|
2024
2072
|
return '\u2713 '
|