pantheon-opencode 1.3.6 → 1.4.0
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/opencode.json +2 -1
- package/package.json +1 -1
- package/scripts/install/opencode.mjs +2 -0
- package/scripts/sync-tui.mjs +148 -0
- package/src/mcp/mcp_codemap_module.py +185 -0
- package/src/mcp/memory_mcp_server.py +91 -3
- package/src/pantheon/command-normalizer.ts +101 -0
- package/src/pantheon/context-sandbox.ts +273 -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 +76 -2
- package/src/plugins/tui/dist/tui.d.ts +18 -3
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +42 -10
- package/src/plugins/tui/dist/tui.tsx +69 -17
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +69 -17
- package/src/skills/codemap/SKILL.md +7 -0
- package/src/skills/context-compression/SKILL.md +39 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Output Sandboxing — Context Window Optimization (v2 P0)
|
|
3
|
+
*
|
|
4
|
+
* Truncates oversized tool outputs before they flood the context window.
|
|
5
|
+
* Pure truncation with metadata — no tiktoken, no LLM summarization (YAGNI).
|
|
6
|
+
*
|
|
7
|
+
* Hook: tool.execute.after — intercepts read/grep/glob/webfetch outputs.
|
|
8
|
+
*
|
|
9
|
+
* @module pantheon/context-sandbox
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ToolExecuteAfterInput {
|
|
13
|
+
tool: string
|
|
14
|
+
sessionID: string
|
|
15
|
+
callID: string
|
|
16
|
+
args?: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ToolExecuteAfterOutput {
|
|
20
|
+
title?: string
|
|
21
|
+
output: string
|
|
22
|
+
metadata?: Record<string, unknown>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ToolExecuteAfterHandler = (
|
|
26
|
+
input: ToolExecuteAfterInput,
|
|
27
|
+
output: ToolExecuteAfterOutput,
|
|
28
|
+
) => Promise<void>
|
|
29
|
+
|
|
30
|
+
export interface SandboxToolLimits {
|
|
31
|
+
read: { maxLines: number; keepHead: number; keepTail: number }
|
|
32
|
+
grep: { maxResults: number; keepTop: number }
|
|
33
|
+
glob: { maxFiles: number; keepTop: number }
|
|
34
|
+
webfetch: { maxChars: number; keepHead: number }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ContextSandboxConfig {
|
|
38
|
+
enabled: boolean
|
|
39
|
+
limits: SandboxToolLimits
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Default limits — sensatos, cobrem 80% do problema com ~30 LOC. */
|
|
43
|
+
export const DEFAULT_LIMITS: SandboxToolLimits = {
|
|
44
|
+
read: { maxLines: 200, keepHead: 50, keepTail: 10 },
|
|
45
|
+
grep: { maxResults: 20, keepTop: 10 },
|
|
46
|
+
glob: { maxFiles: 50, keepTop: 20 },
|
|
47
|
+
webfetch: { maxChars: 5000, keepHead: 2000 },
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const DEFAULT_CONFIG: ContextSandboxConfig = {
|
|
51
|
+
enabled: true,
|
|
52
|
+
limits: DEFAULT_LIMITS,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── Pure sandbox functions ──────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Sandbox truncation for `read` tool output.
|
|
59
|
+
* Input is expected to be line-numbered read output (e.g. "1: foo").
|
|
60
|
+
* If lines > maxLines, keep head + marker + tail.
|
|
61
|
+
*/
|
|
62
|
+
export function sandboxRead(output: string, limits = DEFAULT_LIMITS.read): string {
|
|
63
|
+
const lines = output.split('\n')
|
|
64
|
+
// trailing empty after final newline is not a real line — drop it for counting
|
|
65
|
+
const effectiveCount =
|
|
66
|
+
lines.length > 0 && lines[lines.length - 1] === '' ? lines.length - 1 : lines.length
|
|
67
|
+
if (effectiveCount <= limits.maxLines) return output
|
|
68
|
+
|
|
69
|
+
const head = lines.slice(0, limits.keepHead)
|
|
70
|
+
const tail =
|
|
71
|
+
limits.keepTail > 0
|
|
72
|
+
? lines.slice(Math.max(limits.keepHead, effectiveCount - limits.keepTail), effectiveCount)
|
|
73
|
+
: []
|
|
74
|
+
const hidden = effectiveCount - head.length - tail.length
|
|
75
|
+
const marker = `[TRUNCATED: ${hidden} lines hidden — showing ${head.length} head + ${tail.length} tail of ${effectiveCount} total lines]`
|
|
76
|
+
|
|
77
|
+
// Preserve trailing newline semantics of original if present
|
|
78
|
+
const hadTrailingNewline = output.endsWith('\n')
|
|
79
|
+
const parts = [...head, marker, ...tail]
|
|
80
|
+
let result = parts.join('\n')
|
|
81
|
+
if (hadTrailingNewline) result += '\n'
|
|
82
|
+
return result
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Sandbox truncation for `grep` tool output.
|
|
87
|
+
* Each non-empty line is considered a match. Keeps top N, drops tail.
|
|
88
|
+
*/
|
|
89
|
+
export function sandboxGrep(output: string, limits = DEFAULT_LIMITS.grep): string {
|
|
90
|
+
const lines = output.split('\n')
|
|
91
|
+
const effectiveCount =
|
|
92
|
+
lines.length > 0 && lines[lines.length - 1] === '' ? lines.length - 1 : lines.length
|
|
93
|
+
// Count non-empty? spec says 20 results — treat every line as result for simplicity,
|
|
94
|
+
// but filter obvious empties: if effective count includes empty trailing, already handled.
|
|
95
|
+
// Use effectiveCount as result count.
|
|
96
|
+
if (effectiveCount <= limits.maxResults) return output
|
|
97
|
+
|
|
98
|
+
const head = lines.slice(0, limits.keepTop)
|
|
99
|
+
const hidden = effectiveCount - head.length
|
|
100
|
+
const marker = `[TRUNCATED: Showing ${head.length} of ${effectiveCount} matches — ${hidden} more hidden. Use more specific pattern.]`
|
|
101
|
+
const hadTrailingNewline = output.endsWith('\n')
|
|
102
|
+
let result = [...head, marker].join('\n')
|
|
103
|
+
if (hadTrailingNewline) result += '\n'
|
|
104
|
+
return result
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Sandbox truncation for `glob` tool output.
|
|
109
|
+
* Each line is a file path. Keeps top N.
|
|
110
|
+
*/
|
|
111
|
+
export function sandboxGlob(output: string, limits = DEFAULT_LIMITS.glob): string {
|
|
112
|
+
const lines = output.split('\n')
|
|
113
|
+
const effectiveCount =
|
|
114
|
+
lines.length > 0 && lines[lines.length - 1] === '' ? lines.length - 1 : lines.length
|
|
115
|
+
if (effectiveCount <= limits.maxFiles) return output
|
|
116
|
+
|
|
117
|
+
const head = lines.slice(0, limits.keepTop)
|
|
118
|
+
const hidden = effectiveCount - head.length
|
|
119
|
+
const marker = `[TRUNCATED: showing ${head.length} of ${effectiveCount} files — ${hidden} more hidden]`
|
|
120
|
+
const hadTrailingNewline = output.endsWith('\n')
|
|
121
|
+
let result = [...head, marker].join('\n')
|
|
122
|
+
if (hadTrailingNewline) result += '\n'
|
|
123
|
+
return result
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Sandbox truncation for `webfetch` tool output.
|
|
128
|
+
* Character-based: keep head chars, drop rest.
|
|
129
|
+
*/
|
|
130
|
+
export function sandboxWebfetch(output: string, limits = DEFAULT_LIMITS.webfetch): string {
|
|
131
|
+
if (output.length <= limits.maxChars) return output
|
|
132
|
+
const head = output.slice(0, limits.keepHead)
|
|
133
|
+
const hidden = output.length - head.length
|
|
134
|
+
const marker = `\n[TRUNCATED: Content truncated — showing first ${head.length} of ${output.length} chars, ${hidden} chars hidden]\n`
|
|
135
|
+
return head + marker
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Dispatch sandbox truncation by tool name. Pure function — no I/O.
|
|
140
|
+
* Unknown tools pass through unchanged.
|
|
141
|
+
*/
|
|
142
|
+
export function sandboxOutput(
|
|
143
|
+
tool: string,
|
|
144
|
+
output: string,
|
|
145
|
+
limits: SandboxToolLimits = DEFAULT_LIMITS,
|
|
146
|
+
): string {
|
|
147
|
+
if (typeof output !== 'string' || output.length === 0) return output
|
|
148
|
+
const normalized = tool.trim().toLowerCase()
|
|
149
|
+
switch (normalized) {
|
|
150
|
+
case 'read':
|
|
151
|
+
return sandboxRead(output, limits.read)
|
|
152
|
+
case 'grep':
|
|
153
|
+
return sandboxGrep(output, limits.grep)
|
|
154
|
+
case 'glob':
|
|
155
|
+
return sandboxGlob(output, limits.glob)
|
|
156
|
+
case 'webfetch':
|
|
157
|
+
case 'fetch':
|
|
158
|
+
case 'web_fetch':
|
|
159
|
+
return sandboxWebfetch(output, limits.webfetch)
|
|
160
|
+
default:
|
|
161
|
+
return output
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── Plugin hook factory ───────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Resolve sandbox config from opencode.json `context_sandbox` block.
|
|
169
|
+
* Fail-open: any missing/invalid field falls back to defaults.
|
|
170
|
+
*/
|
|
171
|
+
export function resolveSandboxConfig(raw?: unknown): ContextSandboxConfig {
|
|
172
|
+
if (raw === null || raw === undefined || typeof raw !== 'object')
|
|
173
|
+
return { ...DEFAULT_CONFIG, limits: { ...DEFAULT_LIMITS } }
|
|
174
|
+
const obj = raw as Record<string, unknown>
|
|
175
|
+
const enabled = typeof obj.enabled === 'boolean' ? obj.enabled : true
|
|
176
|
+
const limitsRaw = obj.limits as Record<string, unknown> | undefined
|
|
177
|
+
|
|
178
|
+
const limits: SandboxToolLimits = {
|
|
179
|
+
read: {
|
|
180
|
+
maxLines:
|
|
181
|
+
typeof limitsRaw?.read === 'object' &&
|
|
182
|
+
limitsRaw.read !== null &&
|
|
183
|
+
typeof (limitsRaw.read as Record<string, unknown>).maxLines === 'number'
|
|
184
|
+
? ((limitsRaw.read as Record<string, unknown>).maxLines as number)
|
|
185
|
+
: DEFAULT_LIMITS.read.maxLines,
|
|
186
|
+
keepHead:
|
|
187
|
+
typeof limitsRaw?.read === 'object' &&
|
|
188
|
+
limitsRaw.read !== null &&
|
|
189
|
+
typeof (limitsRaw.read as Record<string, unknown>).keepHead === 'number'
|
|
190
|
+
? ((limitsRaw.read as Record<string, unknown>).keepHead as number)
|
|
191
|
+
: DEFAULT_LIMITS.read.keepHead,
|
|
192
|
+
keepTail:
|
|
193
|
+
typeof limitsRaw?.read === 'object' &&
|
|
194
|
+
limitsRaw.read !== null &&
|
|
195
|
+
typeof (limitsRaw.read as Record<string, unknown>).keepTail === 'number'
|
|
196
|
+
? ((limitsRaw.read as Record<string, unknown>).keepTail as number)
|
|
197
|
+
: DEFAULT_LIMITS.read.keepTail,
|
|
198
|
+
},
|
|
199
|
+
grep: {
|
|
200
|
+
maxResults:
|
|
201
|
+
typeof limitsRaw?.grep === 'object' &&
|
|
202
|
+
limitsRaw.grep !== null &&
|
|
203
|
+
typeof (limitsRaw.grep as Record<string, unknown>).maxResults === 'number'
|
|
204
|
+
? ((limitsRaw.grep as Record<string, unknown>).maxResults as number)
|
|
205
|
+
: DEFAULT_LIMITS.grep.maxResults,
|
|
206
|
+
keepTop:
|
|
207
|
+
typeof limitsRaw?.grep === 'object' &&
|
|
208
|
+
limitsRaw.grep !== null &&
|
|
209
|
+
typeof (limitsRaw.grep as Record<string, unknown>).keepTop === 'number'
|
|
210
|
+
? ((limitsRaw.grep as Record<string, unknown>).keepTop as number)
|
|
211
|
+
: DEFAULT_LIMITS.grep.keepTop,
|
|
212
|
+
},
|
|
213
|
+
glob: {
|
|
214
|
+
maxFiles:
|
|
215
|
+
typeof limitsRaw?.glob === 'object' &&
|
|
216
|
+
limitsRaw.glob !== null &&
|
|
217
|
+
typeof (limitsRaw.glob as Record<string, unknown>).maxFiles === 'number'
|
|
218
|
+
? ((limitsRaw.glob as Record<string, unknown>).maxFiles as number)
|
|
219
|
+
: DEFAULT_LIMITS.glob.maxFiles,
|
|
220
|
+
keepTop:
|
|
221
|
+
typeof limitsRaw?.glob === 'object' &&
|
|
222
|
+
limitsRaw.glob !== null &&
|
|
223
|
+
typeof (limitsRaw.glob as Record<string, unknown>).keepTop === 'number'
|
|
224
|
+
? ((limitsRaw.glob as Record<string, unknown>).keepTop as number)
|
|
225
|
+
: DEFAULT_LIMITS.glob.keepTop,
|
|
226
|
+
},
|
|
227
|
+
webfetch: {
|
|
228
|
+
maxChars:
|
|
229
|
+
typeof limitsRaw?.webfetch === 'object' &&
|
|
230
|
+
limitsRaw.webfetch !== null &&
|
|
231
|
+
typeof (limitsRaw.webfetch as Record<string, unknown>).maxChars === 'number'
|
|
232
|
+
? ((limitsRaw.webfetch as Record<string, unknown>).maxChars as number)
|
|
233
|
+
: DEFAULT_LIMITS.webfetch.maxChars,
|
|
234
|
+
keepHead:
|
|
235
|
+
typeof limitsRaw?.webfetch === 'object' &&
|
|
236
|
+
limitsRaw.webfetch !== null &&
|
|
237
|
+
typeof (limitsRaw.webfetch as Record<string, unknown>).keepHead === 'number'
|
|
238
|
+
? ((limitsRaw.webfetch as Record<string, unknown>).keepHead as number)
|
|
239
|
+
: DEFAULT_LIMITS.webfetch.keepHead,
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
return { enabled, limits }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Create the `tool.execute.after` handler that sandboxes oversized outputs.
|
|
247
|
+
* The handler mutates `output.output` in-place and tags metadata when truncated.
|
|
248
|
+
* Fail-open: never throws.
|
|
249
|
+
*/
|
|
250
|
+
export function createContextSandbox(
|
|
251
|
+
config: ContextSandboxConfig = DEFAULT_CONFIG,
|
|
252
|
+
): ToolExecuteAfterHandler {
|
|
253
|
+
// Capture mutable reference — caller (plugin config hook) may update limits live
|
|
254
|
+
const cfgRef = config
|
|
255
|
+
return async (input: ToolExecuteAfterInput, output: ToolExecuteAfterOutput): Promise<void> => {
|
|
256
|
+
try {
|
|
257
|
+
if (!cfgRef.enabled) return
|
|
258
|
+
if (typeof output.output !== 'string') return
|
|
259
|
+
const before = output.output
|
|
260
|
+
const after = sandboxOutput(input.tool, before, cfgRef.limits)
|
|
261
|
+
if (after !== before) {
|
|
262
|
+
output.output = after
|
|
263
|
+
output.metadata = {
|
|
264
|
+
...(output.metadata ?? {}),
|
|
265
|
+
truncated: true,
|
|
266
|
+
sandbox: input.tool.toLowerCase(),
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
// never break the tool result
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -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,7 +1,17 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
1
3
|
import type { Plugin, PluginInput } from '@opencode-ai/plugin'
|
|
2
4
|
import type { PluginConfig } from 'opencode'
|
|
3
5
|
import { BackgroundJobBoard } from './pantheon/background-job-board.ts'
|
|
6
|
+
import { createCommandNormalizer } from './pantheon/command-normalizer.ts'
|
|
4
7
|
import { reassertAfterCompaction } from './pantheon/compaction-assert.ts'
|
|
8
|
+
import {
|
|
9
|
+
type ContextSandboxConfig,
|
|
10
|
+
createContextSandbox,
|
|
11
|
+
resolveSandboxConfig,
|
|
12
|
+
DEFAULT_CONFIG as SANDBOX_DEFAULT_CONFIG,
|
|
13
|
+
DEFAULT_LIMITS as SANDBOX_DEFAULT_LIMITS,
|
|
14
|
+
} from './pantheon/context-sandbox.ts'
|
|
5
15
|
import { createCostCommand } from './pantheon/cost-command.ts'
|
|
6
16
|
import {
|
|
7
17
|
collectRootSessionIDs,
|
|
@@ -9,6 +19,7 @@ import {
|
|
|
9
19
|
type DelegationClient,
|
|
10
20
|
resolveDelegationBudgets,
|
|
11
21
|
resolveDelegationTimeoutMs,
|
|
22
|
+
startIdleChildScan,
|
|
12
23
|
} from './pantheon/delegation.ts'
|
|
13
24
|
import { buildCompactionContext } from './pantheon/delegation-compaction.ts'
|
|
14
25
|
import {
|
|
@@ -40,6 +51,7 @@ import {
|
|
|
40
51
|
todoEnforcerEnabledFromEnv,
|
|
41
52
|
} from './pantheon/todo-enforcer.ts'
|
|
42
53
|
import { TodoPreserver } from './pantheon/todo-preserve.ts'
|
|
54
|
+
import { checkTuiVersionStaleness } from './pantheon/tui-version-check.ts'
|
|
43
55
|
import { activePresetCandidates, createVisionHandler } from './pantheon/vision.ts'
|
|
44
56
|
|
|
45
57
|
// ─── Background Job Board Singleton ────────────────────────────────────
|
|
@@ -127,6 +139,13 @@ const enforcementGuard = createEnforcementGuard({
|
|
|
127
139
|
},
|
|
128
140
|
})
|
|
129
141
|
|
|
142
|
+
// Fase 1 (command-normalizer): deterministic `python` → `python3` rewrite for
|
|
143
|
+
// `bash` commands (PEP 394 — Ubuntu 20.04+ has no bare `python`). Unlike the
|
|
144
|
+
// enforcement guard above (which THROWS to deny), this guard REWRITES the
|
|
145
|
+
// command in place and lets it run. Logs every rewrite + `python -c` warning
|
|
146
|
+
// to the shared hooks.log for @nyx frequency measurement.
|
|
147
|
+
const commandNormalizer = createCommandNormalizer({ logger: log })
|
|
148
|
+
|
|
130
149
|
// Mirrors routing.yml background_delegation.max_compaction_items.
|
|
131
150
|
const COMPACTION_MAX_ITEMS = 10
|
|
132
151
|
|
|
@@ -334,6 +353,9 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
334
353
|
if (pantheonPluginOnce('pantheon:plugin')) {
|
|
335
354
|
return {}
|
|
336
355
|
}
|
|
356
|
+
// Runtime TUI version staleness check — warns if the copied TUI plugin
|
|
357
|
+
// is older than the installed package. Runs once, fail-open, no auto-sync.
|
|
358
|
+
checkTuiVersionStaleness((msg) => log.warn(msg))
|
|
337
359
|
const vision = createVisionHandler(input)
|
|
338
360
|
// Phase 2/3: background delegation toolset + the bound finalize lifecycle
|
|
339
361
|
// hook. Completion is observed through the event hook below
|
|
@@ -363,6 +385,28 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
363
385
|
},
|
|
364
386
|
})
|
|
365
387
|
|
|
388
|
+
// Proactive idle-child scan (Fix 2): finalize children that are idle on the
|
|
389
|
+
// board but have no MD report on disk — eliminates the phantom "running"
|
|
390
|
+
// window. Runs every 30s (configurable via PANTHEON_IDLE_SCAN_INTERVAL_MS),
|
|
391
|
+
// unref'd so it never holds the process open. Fail-open by design.
|
|
392
|
+
const idleScanIntervalMs = Number(process.env.PANTHEON_IDLE_SCAN_INTERVAL_MS) || undefined
|
|
393
|
+
startIdleChildScan(
|
|
394
|
+
{
|
|
395
|
+
board,
|
|
396
|
+
finalize: (childSessionID, opts) => delegation.finalizeDelegation(childSessionID, opts),
|
|
397
|
+
hasReport: (job) => {
|
|
398
|
+
try {
|
|
399
|
+
const filePath = join('.pantheon/delegations', job.parentSessionID, `${job.alias}.md`)
|
|
400
|
+
return existsSync(filePath)
|
|
401
|
+
} catch {
|
|
402
|
+
return false
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
logger: log,
|
|
406
|
+
},
|
|
407
|
+
idleScanIntervalMs,
|
|
408
|
+
)
|
|
409
|
+
|
|
366
410
|
// Wave 1 (PR #46): TODO continuation enforcer for root/non-board sessions.
|
|
367
411
|
// Mirrors routing.yml `todo_enforcer` (plugin scope has no routing.yml
|
|
368
412
|
// access — COMPACTION_MAX_ITEMS pattern). The event hook routes non-board
|
|
@@ -407,6 +451,24 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
407
451
|
const hashlineEdit = createHashlineEditTool()
|
|
408
452
|
const readEnhancer = createReadEnhancer()
|
|
409
453
|
|
|
454
|
+
// Context Window Optimization (v2 P0) — tool output sandboxing
|
|
455
|
+
// Pure truncation hook: read/grep/glob/webfetch → head + [TRUNCATED] + tail
|
|
456
|
+
// Fail-open: never throws, disabled via opencode.json context_sandbox.enabled
|
|
457
|
+
const sandboxConfig: ContextSandboxConfig = {
|
|
458
|
+
enabled: SANDBOX_DEFAULT_CONFIG.enabled,
|
|
459
|
+
limits: {
|
|
460
|
+
read: { ...SANDBOX_DEFAULT_LIMITS.read },
|
|
461
|
+
grep: { ...SANDBOX_DEFAULT_LIMITS.grep },
|
|
462
|
+
glob: { ...SANDBOX_DEFAULT_LIMITS.glob },
|
|
463
|
+
webfetch: { ...SANDBOX_DEFAULT_LIMITS.webfetch },
|
|
464
|
+
},
|
|
465
|
+
}
|
|
466
|
+
const sandboxHandler = createContextSandbox(sandboxConfig)
|
|
467
|
+
const combinedAfter: typeof readEnhancer = async (input, output) => {
|
|
468
|
+
await sandboxHandler(input, output)
|
|
469
|
+
await readEnhancer(input, output)
|
|
470
|
+
}
|
|
471
|
+
|
|
410
472
|
// Wave 4 (PR #46): /cost — delegation cost + token visibility. Reads
|
|
411
473
|
// opencode.db read-only (node:sqlite, falls back to scripts/cost.mjs).
|
|
412
474
|
// Fully wired (unlike dispatch-guard, which is manual-orchestration-only
|
|
@@ -423,6 +485,15 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
423
485
|
// WITHOUT a parentID is a root. Fail-open: a failed/throttled list
|
|
424
486
|
// leaves the set untouched (delegation.ts treats unknown sessions as
|
|
425
487
|
// roots), and config setup must never wait for the session API.
|
|
488
|
+
// Context sandbox config — opencode.json → context_sandbox: {enabled, limits}
|
|
489
|
+
try {
|
|
490
|
+
const raw = (config as unknown as Record<string, unknown>).context_sandbox
|
|
491
|
+
const resolved = resolveSandboxConfig(raw)
|
|
492
|
+
sandboxConfig.enabled = resolved.enabled
|
|
493
|
+
sandboxConfig.limits = resolved.limits
|
|
494
|
+
} catch {
|
|
495
|
+
// fail-open: keep defaults
|
|
496
|
+
}
|
|
426
497
|
seedRootSessionsInBackground(input)
|
|
427
498
|
config.agentsPath = config.agentsPath ?? []
|
|
428
499
|
config.agentsPath.push(new URL('./agents', import.meta.url).pathname)
|
|
@@ -549,13 +620,16 @@ const plugin: Plugin = async (input: PluginInput) => {
|
|
|
549
620
|
// no-ops for non-matching sessions/tools.
|
|
550
621
|
zeusReadGuard(input.tool, output?.args, sessionAgents.get(input.sessionID))
|
|
551
622
|
await enforcementGuard(input, output)
|
|
623
|
+
await commandNormalizer(input, output)
|
|
552
624
|
await todoPreserver.beforeTodoWrite(input, output)
|
|
553
625
|
},
|
|
554
|
-
// Wave 2 (PR #46)
|
|
626
|
+
// Wave 2 (PR #46) + Context Sandbox (v2 P0): sandbox truncates oversized
|
|
627
|
+
// outputs BEFORE hashline tags (so tags remain stable on kept lines).
|
|
628
|
+
// Additive —
|
|
555
629
|
// pantheon-hooks.ts (a separate plugin instance) owns its own
|
|
556
630
|
// tool.execute.after, so there is no key collision. Non-read tools pass
|
|
557
631
|
// through untouched.
|
|
558
|
-
'tool.execute.after':
|
|
632
|
+
'tool.execute.after': combinedAfter,
|
|
559
633
|
// Phase 4 + release-134 Phase 2: keep the session's working state across
|
|
560
634
|
// compaction — preservation directive, active goals (<mission_context>),
|
|
561
635
|
// pending todos (<todo_context>), and in-flight background delegations
|
|
@@ -11,7 +11,7 @@ type DelegationEntry = {
|
|
|
11
11
|
taskID?: string;
|
|
12
12
|
/** Agent name, e.g. "apollo". */
|
|
13
13
|
agent: string;
|
|
14
|
-
state: 'running' | 'completed' | 'error' | 'startup_failed' | 'startup_unknown' | 'cancelled';
|
|
14
|
+
state: 'running' | 'completed' | 'error' | 'startup_failed' | 'startup_unknown' | 'cancelled' | 'stale-running';
|
|
15
15
|
/** Epoch ms of the `Started` header. */
|
|
16
16
|
startedAt: number;
|
|
17
17
|
/** Epoch ms of the `Finalized` header — null while still running. */
|
|
@@ -77,7 +77,22 @@ declare function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry
|
|
|
77
77
|
* recent terminal reports (capped). Pure — so the history-only panel (no
|
|
78
78
|
* sessionID) is testable without the TUI runtime. The header count uses the
|
|
79
79
|
* same "running + recentes" list. */
|
|
80
|
-
declare function visibleDelegationList(all: readonly DelegationEntry[], maxTerminal?: number): DelegationEntry[];
|
|
80
|
+
declare function visibleDelegationList(all: readonly DelegationEntry[], maxTerminal?: number, now?: number, staleThresholdMs?: number): DelegationEntry[];
|
|
81
|
+
/** Default stale-running threshold: 30 minutes. */
|
|
82
|
+
declare const STALE_RUNNING_THRESHOLD_MS: number;
|
|
83
|
+
/** Idle silence window: if no updatedAt change in this window, the entry is
|
|
84
|
+
* considered stale. Combined with the stale-running threshold to produce the
|
|
85
|
+
* display-only `stale-running` state. */
|
|
86
|
+
declare const IDLE_SILENCE_MS: number;
|
|
87
|
+
/**
|
|
88
|
+
* Mark a running entry as `stale-running` if it has been running longer than
|
|
89
|
+
* the threshold AND has no recent activity (no `updatedAt` change in the last
|
|
90
|
+
* `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the board state is unchanged.
|
|
91
|
+
*
|
|
92
|
+
* A `stale-running` entry renders with a warning indicator but the underlying
|
|
93
|
+
* delegation is still treated as running by the backend.
|
|
94
|
+
*/
|
|
95
|
+
declare function markStaleIfRunning(entry: DelegationEntry, now: number, thresholdMs?: number): DelegationEntry;
|
|
81
96
|
/** Compact elapsed-time label: "5m 12s", "1h 30m", "2d 4h" — ticks every
|
|
82
97
|
* second for running jobs. */
|
|
83
98
|
declare function fmtElapsed(ms: number): string;
|
|
@@ -306,5 +321,5 @@ declare const plugin: TuiPluginModule & {
|
|
|
306
321
|
id: string;
|
|
307
322
|
};
|
|
308
323
|
//#endregion
|
|
309
|
-
export { ChildDelegationLike, DelegationActivity, DelegationEntry, DelegationToolPart, LiveDelegationEntry, LiveDelegationStore, ParsedDelegationToolPart, TuiSessionSources, buildChildrenPath, childStatusToState, childrenToDelegationEntries, collectDelegationToolParts, compareDelegationEntries, plugin as default, delegationActivity, delegationActivityLabel, delegationElapsed, delegationSpinnerFrame, delegationTag, fmtElapsed, isValidSessionId, mergeChildDelegationSources, mergeDelegationSources, navigateToDelegationSession, panelLogDir, parseDelegationMarkdown, parseDelegationToolPart, readAllDelegationEntries, readDelegationEntries, reduceDelegationToolPart, removeDelegationEntry, resolveCurrentSessionID, resolveDelegationsDir, safeSessionPath, seedLiveDelegationMap, toDelegationEntry, tuiLogPath, visibleDelegationList };
|
|
324
|
+
export { ChildDelegationLike, DelegationActivity, DelegationEntry, DelegationToolPart, IDLE_SILENCE_MS, LiveDelegationEntry, LiveDelegationStore, ParsedDelegationToolPart, STALE_RUNNING_THRESHOLD_MS, TuiSessionSources, buildChildrenPath, childStatusToState, childrenToDelegationEntries, collectDelegationToolParts, compareDelegationEntries, plugin as default, delegationActivity, delegationActivityLabel, delegationElapsed, delegationSpinnerFrame, delegationTag, fmtElapsed, isValidSessionId, markStaleIfRunning, mergeChildDelegationSources, mergeDelegationSources, navigateToDelegationSession, panelLogDir, parseDelegationMarkdown, parseDelegationToolPart, readAllDelegationEntries, readDelegationEntries, reduceDelegationToolPart, removeDelegationEntry, resolveCurrentSessionID, resolveDelegationsDir, safeSessionPath, seedLiveDelegationMap, toDelegationEntry, tuiLogPath, visibleDelegationList };
|
|
310
325
|
//# sourceMappingURL=tui.d.ts.map
|