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.
@@ -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): augment `read` output with hashline tags. Additive —
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': readEnhancer,
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