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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pantheon-opencode",
3
- "version": "1.3.6",
3
+ "version": "1.3.7",
4
4
  "description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -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.6",
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: 'running' | 'completed' | 'error' | 'startup_failed' | 'startup_unknown' | 'cancelled'
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.filter((d) => d.state === 'running')
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') return fmtElapsed(now - entry.startedAt)
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: mdEntry?.updatedAt ?? (state === 'running' ? null : (child.time?.updated ?? null)),
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 '