pantheon-opencode 1.5.0-beta.11 → 1.5.0-beta.13
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/CHANGELOG.md +47 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/dev-tui.sh +1 -1
- package/scripts/install/plugin.mjs +9 -12
- package/scripts/sync-tui.mjs +8 -11
- package/src/pantheon/session-guard.ts +2 -2
- package/src/plugins/tui/dist/tui.d.ts +111 -59
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +353 -239
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +2 -2
- package/src/plugins/tui/src/index.tsx +415 -201
- package/src/plugins/tui/dist/tui.tsx +0 -3008
|
@@ -1,3008 +0,0 @@
|
|
|
1
|
-
// biome-ignore-all lint/suspicious/noExplicitAny: necessary for TuiPluginApi dynamic state access
|
|
2
|
-
// biome-ignore-all lint/a11y/noStaticElementInteractions: TUI elements, not DOM
|
|
3
|
-
/** @jsxImportSource @opentui/solid */
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* pantheon-tui — Pantheon TUI plugin for opencode.
|
|
7
|
-
*
|
|
8
|
-
* Single self-contained entry file: `tsdown && cp src/index.tsx dist/tui.tsx`
|
|
9
|
-
* keeps the no-build load path working (dist/tui.tsx is raw source, transpiled
|
|
10
|
-
* on the fly by opencode). All feature code lives in this one file.
|
|
11
|
-
*
|
|
12
|
-
* Slots:
|
|
13
|
-
* - sidebar_content (order 900) — Pantheon sidebar (header/version/
|
|
14
|
-
* branch, Sessions, real-time Delegations panel).
|
|
15
|
-
* - app_bottom (order 60) — AI subscription usage gauges
|
|
16
|
-
* (Anthropic/OpenAI quotas, OpenCode Go/Zen dollar limits + provider
|
|
17
|
-
* status incidents).
|
|
18
|
-
*
|
|
19
|
-
* ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
-
* VENDORED FEATURES (MIT) — incorporated with their license headers preserved:
|
|
21
|
-
*
|
|
22
|
-
* • satas20/opencode-usage-bar (MIT)
|
|
23
|
-
* https://github.com/satas20/opencode-usage-bar
|
|
24
|
-
*
|
|
25
|
-
* v1.2.0 added an OpenCode Go/Zen usage provider (`opencodego`) on top of
|
|
26
|
-
* the vendored base — dollar-denominated rolling limits ($12/5h, $30/7d,
|
|
27
|
-
* $60/month) via the same key used for inference. Still MIT-credited to
|
|
28
|
-
* satas20 for the original bar/config/poll code.
|
|
29
|
-
*
|
|
30
|
-
* satas20/opencode-todo-progress was vendored in v1.1.0 alongside the
|
|
31
|
-
* usage-bar but has since been REMOVED as redundant — the native session
|
|
32
|
-
* footer already surfaces todo/context state, so its todo bar slot was
|
|
33
|
-
* dropped entirely. Only usage-bar code remains.
|
|
34
|
-
* ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
-
*
|
|
36
|
-
* LIMITATION — native context/tokens statusline:
|
|
37
|
-
* The opencode session footer natively shows a context/tokens statusline
|
|
38
|
-
* next to the prompt. There is NO config option to hide it: the tui.json
|
|
39
|
-
* schema (opencode.ai/tui.json) only exposes `theme`, `keybinds`,
|
|
40
|
-
* `scroll_speed`, `scroll_acceleration`, `diff_style`, `mouse` and
|
|
41
|
-
* `attention`, and the SDK `Config` type has no statusline/tokens display
|
|
42
|
-
* flag (checked against @opencode-ai/sdk `Config` and the published
|
|
43
|
-
* schema). The Pantheon sidebar therefore no longer duplicates that footer
|
|
44
|
-
* (the old ContextBar was removed) and we deliberately do NOT hack the
|
|
45
|
-
* native footer. Track upstream: opencode statusline config.
|
|
46
|
-
*/
|
|
47
|
-
|
|
48
|
-
import { Buffer } from 'node:buffer'
|
|
49
|
-
import type { Dirent } from 'node:fs'
|
|
50
|
-
import { appendFile, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
51
|
-
import { homedir } from 'node:os'
|
|
52
|
-
import { dirname, join } from 'node:path'
|
|
53
|
-
import { fileURLToPath } from 'node:url'
|
|
54
|
-
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui'
|
|
55
|
-
import {
|
|
56
|
-
createEffect,
|
|
57
|
-
createMemo,
|
|
58
|
-
createResource,
|
|
59
|
-
createSignal,
|
|
60
|
-
For,
|
|
61
|
-
onCleanup,
|
|
62
|
-
onMount,
|
|
63
|
-
Show,
|
|
64
|
-
} from 'solid-js'
|
|
65
|
-
|
|
66
|
-
// ─── Double-load guard (mirrors src/pantheon/plugin-once.ts) ───────────────
|
|
67
|
-
// Opencode merges global + project plugin paths, so this module can load twice
|
|
68
|
-
// in one process. The second factory invocation becomes a no-op.
|
|
69
|
-
const __tuiPluginOnceKey = '__pantheonPluginsLoaded'
|
|
70
|
-
function pantheonPluginOnce(key: string): boolean {
|
|
71
|
-
try {
|
|
72
|
-
const g = globalThis as unknown as Record<string, unknown>
|
|
73
|
-
if (g[__tuiPluginOnceKey] === undefined) g[__tuiPluginOnceKey] = new Set<string>()
|
|
74
|
-
const set = g[__tuiPluginOnceKey]
|
|
75
|
-
if (!(set instanceof Set)) return false
|
|
76
|
-
if ((set as Set<string>).has(key)) return true
|
|
77
|
-
;(set as Set<string>).add(key)
|
|
78
|
-
return false
|
|
79
|
-
} catch {
|
|
80
|
-
return false
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/* ─── Constants ─────────────────────────────────────────── */
|
|
85
|
-
|
|
86
|
-
/** How often the sidebar re-reads .pantheon/active-preset.json so `set-tier`
|
|
87
|
-
* changes made while opencode is open show up within ~30s. */
|
|
88
|
-
const PRESET_REFRESH_MS = 30_000
|
|
89
|
-
|
|
90
|
-
/* ─── Helpers ───────────────────────────────────────────── */
|
|
91
|
-
|
|
92
|
-
/* ─── Active Preset Detection ──────────────────────────────
|
|
93
|
-
* Inline replica of src/pantheon/presets.mjs resolveActivePreset() — the TUI
|
|
94
|
-
* plugin is self-contained (dist/tui.tsx is a raw copy of this file with no
|
|
95
|
-
* relative imports), so the resolution logic is mirrored here rather than
|
|
96
|
-
* importing presets.mjs.
|
|
97
|
-
*
|
|
98
|
-
* Priority: env PANTHEON_MODEL_PRESET > first existing candidate file > none.
|
|
99
|
-
* Custom/unknown preset names are shown as-is (a preset may be defined that
|
|
100
|
-
* is not in the built-in list). "none" disables → default. */
|
|
101
|
-
|
|
102
|
-
type PresetInfo = { name: string | null; source: 'env' | 'file' | null }
|
|
103
|
-
|
|
104
|
-
/** Read .pantheon/active-preset.json — mirrors the presets.mjs file leg:
|
|
105
|
-
* first existing candidate wins; malformed JSON or a `preset` that is
|
|
106
|
-
* missing, empty or "none" → null (no fall-through to lower candidates). */
|
|
107
|
-
async function readActivePresetFile(cwd: string): Promise<{ name: string; source: 'file' } | null> {
|
|
108
|
-
const xdgConfig = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config')
|
|
109
|
-
const candidates = [
|
|
110
|
-
join(cwd, '.pantheon', 'active-preset.json'),
|
|
111
|
-
join(xdgConfig, 'opencode', '.pantheon', 'active-preset.json'),
|
|
112
|
-
join(homedir(), '.opencode', '.pantheon', 'active-preset.json'),
|
|
113
|
-
]
|
|
114
|
-
for (const candidate of candidates) {
|
|
115
|
-
let raw: string
|
|
116
|
-
try {
|
|
117
|
-
raw = await readFile(candidate, 'utf8')
|
|
118
|
-
} catch {
|
|
119
|
-
continue // candidate does not exist — try the next one
|
|
120
|
-
}
|
|
121
|
-
try {
|
|
122
|
-
const parsed = JSON.parse(raw) as { preset?: unknown }
|
|
123
|
-
const name = parsed && typeof parsed === 'object' ? parsed.preset : undefined
|
|
124
|
-
if (typeof name !== 'string' || name.length === 0 || name === 'none') return null
|
|
125
|
-
return { name, source: 'file' }
|
|
126
|
-
} catch {
|
|
127
|
-
return null // malformed file — treat as default (mirrors presets.mjs)
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
return null
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Env leg of resolution: PANTHEON_MODEL_PRESET set and !== 'none' wins. */
|
|
134
|
-
function presetFromEnv(env: Record<string, string | undefined>): PresetInfo {
|
|
135
|
-
const name = env.PANTHEON_MODEL_PRESET
|
|
136
|
-
if (name !== undefined && name !== '' && name !== 'none') {
|
|
137
|
-
return { name, source: 'env' }
|
|
138
|
-
}
|
|
139
|
-
return { name: null, source: null }
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** Resolve the active preset for the sidebar: env > file > default. */
|
|
143
|
-
async function resolvePresetForTui(
|
|
144
|
-
env: Record<string, string | undefined>,
|
|
145
|
-
cwd: string,
|
|
146
|
-
): Promise<PresetInfo> {
|
|
147
|
-
const envPreset = presetFromEnv(env)
|
|
148
|
-
if (envPreset.source === 'env') return envPreset
|
|
149
|
-
return (await readActivePresetFile(cwd)) ?? { name: null, source: null }
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/* ─── Version Detection ────────────────────────────────────
|
|
153
|
-
* 100% dinâmico — fallback embutido em build (__PANTHEON_VERSION__).
|
|
154
|
-
* Se falhar, retorna null e a View omite a versão.
|
|
155
|
-
*
|
|
156
|
-
* Try order:
|
|
157
|
-
* 0a. pantheon-tui/package.json (installed: dist/tui.js → 2 níveis)
|
|
158
|
-
* 0b. pantheon/package.json (dev: src/plugins/tui/dist/tui.tsx → 4 níveis)
|
|
159
|
-
* 1. api.client.file.read package.json
|
|
160
|
-
* 2. git describe --tags
|
|
161
|
-
* 3. opencode --version
|
|
162
|
-
* 4. __PANTHEON_VERSION__ (tsdown define — build-time embed)
|
|
163
|
-
* 5. null */
|
|
164
|
-
|
|
165
|
-
// Build-time embedded version (tsdown `define` in tsdown.config.ts). Injected
|
|
166
|
-
// only into the bundled dist/tui.js; the raw dist/tui.tsx copy falls through.
|
|
167
|
-
declare const __PANTHEON_VERSION__: string | undefined
|
|
168
|
-
|
|
169
|
-
async function detectVersion(api: TuiPluginApi): Promise<string | null> {
|
|
170
|
-
// Try 0a: installed location (2 levels: dist/tui.js → pantheon-tui/package.json)
|
|
171
|
-
try {
|
|
172
|
-
const pkgUrl = new URL('../../package.json', import.meta.url)
|
|
173
|
-
const pkgContent = await readFile(fileURLToPath(pkgUrl), 'utf8')
|
|
174
|
-
const pkg = JSON.parse(pkgContent)
|
|
175
|
-
if (pkg.version) return pkg.version
|
|
176
|
-
} catch {}
|
|
177
|
-
|
|
178
|
-
// Try 0b: dev location (4 levels: src/plugins/tui/dist/tui.tsx → package.json)
|
|
179
|
-
try {
|
|
180
|
-
const pkgUrl = new URL('../../../../package.json', import.meta.url)
|
|
181
|
-
const pkgContent = await readFile(fileURLToPath(pkgUrl), 'utf8')
|
|
182
|
-
const pkg = JSON.parse(pkgContent)
|
|
183
|
-
if (pkg.version) return pkg.version
|
|
184
|
-
} catch {}
|
|
185
|
-
|
|
186
|
-
// Try 1: package.json do projeto (via worktree path)
|
|
187
|
-
try {
|
|
188
|
-
const wt = ((api.state as any).path?.worktree ?? '') as string
|
|
189
|
-
const fp = wt ? `${wt}/package.json` : 'package.json'
|
|
190
|
-
const result = await api.client.file.read({ path: fp })
|
|
191
|
-
const content = String(result?.data?.content ?? '')
|
|
192
|
-
const match = content.match(/"version":\s*"([^"]+)"/)
|
|
193
|
-
if (match?.[1]) return match[1]
|
|
194
|
-
} catch {
|
|
195
|
-
/* fall through */
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Try 2: git describe --tags
|
|
199
|
-
try {
|
|
200
|
-
const proc = (api as any).client?.process
|
|
201
|
-
if (typeof proc?.exec === 'function') {
|
|
202
|
-
const r = await proc.exec({
|
|
203
|
-
command: 'git',
|
|
204
|
-
args: ['describe', '--tags', '--always'],
|
|
205
|
-
timeoutMs: 3000,
|
|
206
|
-
})
|
|
207
|
-
const stdout = (r.stdout ?? r.output ?? '') as string
|
|
208
|
-
const tag = stdout
|
|
209
|
-
.trim()
|
|
210
|
-
.replace(/^v/, '')
|
|
211
|
-
.replace(/-\d+-g[0-9a-f]+$/, '')
|
|
212
|
-
if (tag) return tag
|
|
213
|
-
}
|
|
214
|
-
} catch {
|
|
215
|
-
/* fall through */
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Try 3: opencode --version
|
|
219
|
-
try {
|
|
220
|
-
const proc = (api as any).client?.process
|
|
221
|
-
if (typeof proc?.exec === 'function') {
|
|
222
|
-
const r = await proc.exec({ command: 'opencode', args: ['--version'], timeoutMs: 3000 })
|
|
223
|
-
const stdout = (r.stdout ?? r.output ?? '') as string
|
|
224
|
-
const ver = stdout.trim().replace(/^v/, '')
|
|
225
|
-
if (ver) return ver
|
|
226
|
-
}
|
|
227
|
-
} catch {
|
|
228
|
-
/* fall through */
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Try 4: build-time embedded version (tsdown define) — last-resort fallback
|
|
232
|
-
// so the sidebar ALWAYS shows a version even when every probe above fails.
|
|
233
|
-
if (typeof __PANTHEON_VERSION__ !== 'undefined' && __PANTHEON_VERSION__) {
|
|
234
|
-
return __PANTHEON_VERSION__
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
return null // sem fallback — versão não aparece
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/* ──────────────────────────────────────────────────────────
|
|
241
|
-
* VENDORED: opencode-usage-bar (MIT) — satas20
|
|
242
|
-
* https://github.com/satas20/opencode-usage-bar
|
|
243
|
-
* ────────────────────────────────────────────────────────── */
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* usage-bar — AI subscription usage gauge for the opencode TUI.
|
|
247
|
-
*
|
|
248
|
-
* Renders a compact usage strip in the `app_bottom` slot — a full-width row
|
|
249
|
-
* just below the session footer:
|
|
250
|
-
*
|
|
251
|
-
* ▓▓▓▓░░ 65% · 0h 11m (one window)
|
|
252
|
-
* cld ▓▓▓▓░ 65% · 0h 11m 7d ▓░░░░ 19% · 1d 11h oai ▓░░░░ 12% · 3h 4m
|
|
253
|
-
* ! cld ▓▓▓▓░ 65% · 0h 11m (anthropic incident)
|
|
254
|
-
*
|
|
255
|
-
* Providers:
|
|
256
|
-
* anthropic — Claude Pro/Max via the OAuth token in ~/.claude/.credentials.json
|
|
257
|
-
* openai — ChatGPT Plus/Pro via the Codex CLI login in ~/.codex/auth.json
|
|
258
|
-
* opencodego— OpenCode Go/Zen dollar limits ($12/5h, $30/7d, $60/mo) via
|
|
259
|
-
* PANTHEON_OPENCODE_API_KEY (fallback OPENCODE_API_KEY). No
|
|
260
|
-
* status page — the provider hides silently when unconfigured
|
|
261
|
-
* or when the (not yet public) usage endpoint is unavailable.
|
|
262
|
-
*
|
|
263
|
-
* When a provider's public status page reports an incident, a colored `!`
|
|
264
|
-
* marker appears next to its prefix (red = major/critical, amber = minor,
|
|
265
|
-
* cyan = maintenance). The usage bar itself is unaffected. Disable with
|
|
266
|
-
* `show_status = false` under `[ui]`.
|
|
267
|
-
*
|
|
268
|
-
* Configured via ~/.config/opencode/usage-bar.toml (auto-created with
|
|
269
|
-
* commented defaults on first run; read once at startup). Tokens/keys are
|
|
270
|
-
* only ever sent to their own provider's API host.
|
|
271
|
-
*
|
|
272
|
-
* Loaded via tui.json, e.g.:
|
|
273
|
-
* { "plugin": ["@satas/opencode-usage-bar"] } // published npm package
|
|
274
|
-
* { "plugin": ["/abs/path/to/src/index.tsx"] } // local file (no build)
|
|
275
|
-
*/
|
|
276
|
-
|
|
277
|
-
const POLL_MS = 120_000
|
|
278
|
-
const FETCH_TIMEOUT_MS = 10_000
|
|
279
|
-
const CONFIG_FILE = 'usage-bar.toml'
|
|
280
|
-
|
|
281
|
-
// ---------------------------------------------------------------------------
|
|
282
|
-
// Types
|
|
283
|
-
// ---------------------------------------------------------------------------
|
|
284
|
-
|
|
285
|
-
/** Which quota window a value belongs to; toggled per provider in config. */
|
|
286
|
-
type WindowCategory = '5h' | '7d' | '1m' | 'model'
|
|
287
|
-
|
|
288
|
-
/** Provider health from the vendor's public status page (Statuspage schema). */
|
|
289
|
-
type StatusIndicator = 'none' | 'minor' | 'major' | 'critical' | 'maintenance'
|
|
290
|
-
|
|
291
|
-
type UsageWindow = {
|
|
292
|
-
category: WindowCategory
|
|
293
|
-
/** short display label, e.g. "5h", "7d", "Fable" */
|
|
294
|
-
label: string
|
|
295
|
-
/** 0–100 percent of the window used */
|
|
296
|
-
percent: number
|
|
297
|
-
/** epoch ms when the window resets */
|
|
298
|
-
resetsAt: number
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
type ProviderId = 'anthropic' | 'openai' | 'opencodego'
|
|
302
|
-
|
|
303
|
-
type ProviderConfig = {
|
|
304
|
-
enabled: boolean
|
|
305
|
-
show: Record<WindowCategory, boolean>
|
|
306
|
-
/** anthropic: path to .credentials.json */
|
|
307
|
-
credentialsPath?: string
|
|
308
|
-
/** openai: path to Codex auth.json */
|
|
309
|
-
codexAuthPath?: string
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
type UsageBarConfig = {
|
|
313
|
-
showBars: boolean
|
|
314
|
-
showStatus: boolean
|
|
315
|
-
barWidth?: number
|
|
316
|
-
providers: Record<ProviderId, ProviderConfig>
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
type Provider = {
|
|
320
|
-
id: ProviderId
|
|
321
|
-
/** short prefix shown when multiple providers are visible */
|
|
322
|
-
short: string
|
|
323
|
-
/** vendor status page JSON endpoint (Statuspage `status.json`); optional */
|
|
324
|
-
statusUrl?: string
|
|
325
|
-
fetchUsage(cfg: ProviderConfig): Promise<UsageWindow[] | null>
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// ---------------------------------------------------------------------------
|
|
329
|
-
// Helpers
|
|
330
|
-
// ---------------------------------------------------------------------------
|
|
331
|
-
|
|
332
|
-
function expandTilde(p: string) {
|
|
333
|
-
return p === '~' ? homedir() : p.startsWith('~/') ? join(homedir(), p.slice(2)) : p
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function fmtDuration(ms: number) {
|
|
337
|
-
const totalMinutes = Math.max(0, Math.ceil(ms / 60_000))
|
|
338
|
-
const days = Math.floor(totalMinutes / 1440)
|
|
339
|
-
const hours = Math.floor((totalMinutes % 1440) / 60)
|
|
340
|
-
const minutes = totalMinutes % 60
|
|
341
|
-
if (days > 0) return `${days}d ${hours}h`
|
|
342
|
-
return `${hours}h ${minutes}m`
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/** Decode a JWT's `exp` claim (unix seconds) without verifying. 0 on failure. */
|
|
346
|
-
function jwtExp(token: string): number {
|
|
347
|
-
try {
|
|
348
|
-
const payload = token.split('.')[1]
|
|
349
|
-
if (!payload) return 0
|
|
350
|
-
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as {
|
|
351
|
-
exp?: number
|
|
352
|
-
}
|
|
353
|
-
return typeof claims.exp === 'number' ? claims.exp : 0
|
|
354
|
-
} catch {
|
|
355
|
-
return 0
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
async function fetchJson(url: string, headers: Record<string, string>): Promise<unknown | null> {
|
|
360
|
-
try {
|
|
361
|
-
const res = await fetch(url, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
|
|
362
|
-
if (!res.ok) return null
|
|
363
|
-
return await res.json()
|
|
364
|
-
} catch {
|
|
365
|
-
return null
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/** Fetch a vendor's overall status from its public Statuspage JSON endpoint.
|
|
370
|
-
* Returns the `indicator` ("none" when healthy), or `null` when the fetch
|
|
371
|
-
* itself failed — so a network blip never clears a known incident. */
|
|
372
|
-
async function fetchStatus(url: string): Promise<StatusIndicator | null> {
|
|
373
|
-
const data = (await fetchJson(url, {})) as {
|
|
374
|
-
status?: { indicator?: string }
|
|
375
|
-
} | null
|
|
376
|
-
if (!data?.status) return null
|
|
377
|
-
const indicator = data.status.indicator
|
|
378
|
-
return indicator === 'minor' ||
|
|
379
|
-
indicator === 'major' ||
|
|
380
|
-
indicator === 'critical' ||
|
|
381
|
-
indicator === 'maintenance'
|
|
382
|
-
? indicator
|
|
383
|
-
: 'none'
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// ---------------------------------------------------------------------------
|
|
387
|
-
// opencode auth store (fallback credential source)
|
|
388
|
-
// ---------------------------------------------------------------------------
|
|
389
|
-
|
|
390
|
-
type OpencodeAuthEntry = {
|
|
391
|
-
type?: string
|
|
392
|
-
access?: string
|
|
393
|
-
expires?: number // epoch ms; 0 = no expiry
|
|
394
|
-
accountId?: string
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/** Set from `api.state.path.state` at startup; default matches opencode's
|
|
398
|
-
* XDG data dir. */
|
|
399
|
-
let opencodeAuthFile = join(homedir(), '.local', 'share', 'opencode', 'auth.json')
|
|
400
|
-
|
|
401
|
-
/** Read a provider's entry from opencode's own auth store
|
|
402
|
-
* (`opencode auth login`). Returns null when missing/unreadable. */
|
|
403
|
-
async function opencodeAuth(...ids: string[]): Promise<OpencodeAuthEntry | null> {
|
|
404
|
-
try {
|
|
405
|
-
const auth = JSON.parse(await readFile(opencodeAuthFile, 'utf8')) as Record<
|
|
406
|
-
string,
|
|
407
|
-
OpencodeAuthEntry
|
|
408
|
-
>
|
|
409
|
-
for (const id of ids) {
|
|
410
|
-
const entry = auth[id]
|
|
411
|
-
if (entry) return entry
|
|
412
|
-
}
|
|
413
|
-
return null
|
|
414
|
-
} catch {
|
|
415
|
-
return null
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// ---------------------------------------------------------------------------
|
|
420
|
-
// Providers
|
|
421
|
-
// ---------------------------------------------------------------------------
|
|
422
|
-
|
|
423
|
-
/** Claude Pro/Max — Anthropic's OAuth usage endpoint (same one Claude Code's
|
|
424
|
-
* `/usage` uses). Token from ~/.claude/.credentials.json, falling back to
|
|
425
|
-
* opencode's own auth store; sent only to api.anthropic.com. */
|
|
426
|
-
const anthropicProvider: Provider = {
|
|
427
|
-
id: 'anthropic',
|
|
428
|
-
short: 'cld',
|
|
429
|
-
statusUrl: 'https://status.anthropic.com/api/v2/status.json',
|
|
430
|
-
async fetchUsage(cfg) {
|
|
431
|
-
let token: string | undefined
|
|
432
|
-
try {
|
|
433
|
-
const path = expandTilde(
|
|
434
|
-
cfg.credentialsPath ?? join(homedir(), '.claude', '.credentials.json'),
|
|
435
|
-
)
|
|
436
|
-
const creds = JSON.parse(await readFile(path, 'utf8')) as {
|
|
437
|
-
claudeAiOauth?: { accessToken?: string; expiresAt?: number }
|
|
438
|
-
}
|
|
439
|
-
const oauth = creds.claudeAiOauth
|
|
440
|
-
if (oauth?.accessToken && !(oauth.expiresAt && Date.now() >= oauth.expiresAt))
|
|
441
|
-
token = oauth.accessToken
|
|
442
|
-
} catch {
|
|
443
|
-
// fall through to opencode auth
|
|
444
|
-
}
|
|
445
|
-
if (!token) {
|
|
446
|
-
const entry = await opencodeAuth('anthropic')
|
|
447
|
-
if (entry?.access && !(entry.expires && Date.now() >= entry.expires)) token = entry.access
|
|
448
|
-
}
|
|
449
|
-
if (!token) return null
|
|
450
|
-
|
|
451
|
-
const data = (await fetchJson('https://api.anthropic.com/api/oauth/usage', {
|
|
452
|
-
authorization: `Bearer ${token}`,
|
|
453
|
-
'anthropic-beta': 'oauth-2025-04-20',
|
|
454
|
-
})) as {
|
|
455
|
-
limits?: Array<{
|
|
456
|
-
kind?: string
|
|
457
|
-
percent?: number
|
|
458
|
-
resets_at?: string
|
|
459
|
-
scope?: { model?: { display_name?: string | null } | null } | null
|
|
460
|
-
}>
|
|
461
|
-
} | null
|
|
462
|
-
if (!data || !Array.isArray(data.limits)) return null
|
|
463
|
-
|
|
464
|
-
const windows: UsageWindow[] = []
|
|
465
|
-
for (const limit of data.limits) {
|
|
466
|
-
if (!limit || typeof limit.percent !== 'number' || !Number.isFinite(limit.percent)) continue
|
|
467
|
-
if (!limit.kind || !limit.resets_at) continue
|
|
468
|
-
const resetsAt = Date.parse(limit.resets_at)
|
|
469
|
-
if (Number.isNaN(resetsAt)) continue
|
|
470
|
-
const category: WindowCategory =
|
|
471
|
-
limit.kind === 'session' ? '5h' : limit.kind === 'weekly_all' ? '7d' : 'model'
|
|
472
|
-
const label = category === 'model' ? (limit.scope?.model?.display_name ?? 'model') : category
|
|
473
|
-
windows.push({ category, label, percent: limit.percent, resetsAt })
|
|
474
|
-
}
|
|
475
|
-
// Session window first, then the rest in API order.
|
|
476
|
-
windows.sort((a, b) => Number(b.category === '5h') - Number(a.category === '5h'))
|
|
477
|
-
return windows.length > 0 ? windows : null
|
|
478
|
-
},
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
/** ChatGPT Plus/Pro (Codex) — reads the Codex CLI login and asks the wham
|
|
482
|
-
* usage endpoint. Read-only: never refreshes/rewrites auth.json; when the
|
|
483
|
-
* token is expired we simply hide (Codex CLI refreshes the file itself). */
|
|
484
|
-
const openaiProvider: Provider = {
|
|
485
|
-
id: 'openai',
|
|
486
|
-
short: 'oai',
|
|
487
|
-
statusUrl: 'https://status.openai.com/api/v2/status.json',
|
|
488
|
-
async fetchUsage(cfg) {
|
|
489
|
-
let accessToken: string | undefined
|
|
490
|
-
let accountId: string | undefined
|
|
491
|
-
try {
|
|
492
|
-
const path = expandTilde(cfg.codexAuthPath ?? join(homedir(), '.codex', 'auth.json'))
|
|
493
|
-
const auth = JSON.parse(await readFile(path, 'utf8')) as {
|
|
494
|
-
tokens?: { access_token?: string; id_token?: string; account_id?: string }
|
|
495
|
-
}
|
|
496
|
-
const tokens = auth.tokens
|
|
497
|
-
// Check the expiry of the token we actually send (the access token);
|
|
498
|
-
// the id_token expires much earlier and would cause false negatives.
|
|
499
|
-
const exp = tokens?.access_token ? jwtExp(tokens.access_token) : 0
|
|
500
|
-
if (tokens?.access_token && !(exp > 0 && exp * 1000 <= Date.now() + 60_000)) {
|
|
501
|
-
accessToken = tokens.access_token
|
|
502
|
-
accountId = tokens.account_id
|
|
503
|
-
}
|
|
504
|
-
} catch {
|
|
505
|
-
// fall through to opencode auth
|
|
506
|
-
}
|
|
507
|
-
if (!accessToken) {
|
|
508
|
-
const entry = await opencodeAuth('openai')
|
|
509
|
-
if (entry?.access && !(entry.expires && Date.now() >= entry.expires)) {
|
|
510
|
-
accessToken = entry.access
|
|
511
|
-
accountId = entry.accountId
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
if (!accessToken) return null
|
|
515
|
-
|
|
516
|
-
const headers: Record<string, string> = {
|
|
517
|
-
authorization: `Bearer ${accessToken}`,
|
|
518
|
-
'user-agent': 'codex-cli',
|
|
519
|
-
}
|
|
520
|
-
if (accountId) headers['chatgpt-account-id'] = accountId
|
|
521
|
-
|
|
522
|
-
const data = (await fetchJson('https://chatgpt.com/backend-api/wham/usage', headers)) as {
|
|
523
|
-
rate_limit?: {
|
|
524
|
-
primary_window?: WhamWindow | null
|
|
525
|
-
secondary_window?: WhamWindow | null
|
|
526
|
-
}
|
|
527
|
-
} | null
|
|
528
|
-
if (!data?.rate_limit) return null
|
|
529
|
-
|
|
530
|
-
// Classify by window duration when present (some accounts return the
|
|
531
|
-
// weekly window as primary_window), falling back to position.
|
|
532
|
-
const windows: UsageWindow[] = []
|
|
533
|
-
const primary = parseWhamWindow(data.rate_limit.primary_window, '5h')
|
|
534
|
-
if (primary) windows.push(primary)
|
|
535
|
-
const secondary = parseWhamWindow(data.rate_limit.secondary_window, '7d')
|
|
536
|
-
if (secondary) windows.push(secondary)
|
|
537
|
-
windows.sort((a, b) => Number(b.category === '5h') - Number(a.category === '5h'))
|
|
538
|
-
return windows.length > 0 ? windows : null
|
|
539
|
-
},
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
type WhamWindow = {
|
|
543
|
-
used_percent?: number
|
|
544
|
-
limit_window_seconds?: number
|
|
545
|
-
reset_at?: number
|
|
546
|
-
reset_after_seconds?: number
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
function parseWhamWindow(
|
|
550
|
-
w: WhamWindow | null | undefined,
|
|
551
|
-
fallback: WindowCategory,
|
|
552
|
-
): UsageWindow | null {
|
|
553
|
-
if (!w || typeof w.used_percent !== 'number' || !Number.isFinite(w.used_percent)) return null
|
|
554
|
-
let resetsAt: number | undefined
|
|
555
|
-
if (typeof w.reset_at === 'number') resetsAt = w.reset_at * 1000
|
|
556
|
-
else if (typeof w.reset_after_seconds === 'number')
|
|
557
|
-
resetsAt = Date.now() + w.reset_after_seconds * 1000
|
|
558
|
-
if (!resetsAt || !Number.isFinite(resetsAt)) return null
|
|
559
|
-
const category: WindowCategory =
|
|
560
|
-
typeof w.limit_window_seconds === 'number' && w.limit_window_seconds > 0
|
|
561
|
-
? w.limit_window_seconds <= 21_600 // ≤ 6h → session window
|
|
562
|
-
? '5h'
|
|
563
|
-
: '7d'
|
|
564
|
-
: fallback
|
|
565
|
-
return { category, label: category, percent: w.used_percent, resetsAt }
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
/** OpenCode Go/Zen — dollar-denominated rolling usage limits ($12 / 5h,
|
|
569
|
-
* $30 / 7d, $60 / subscription month). Token is the same key used for
|
|
570
|
-
* inference: PANTHEON_OPENCODE_API_KEY, falling back to OPENCODE_API_KEY.
|
|
571
|
-
* It is only ever sent in the Authorization header to opencode.ai — never
|
|
572
|
-
* logged or written to disk. The usage endpoint (opencode.ai/zen/go/v1/
|
|
573
|
-
* usage) is not yet part of the public docs, so any non-ok response
|
|
574
|
-
* (404/401/403/500) hides the provider silently instead of erroring out. */
|
|
575
|
-
const opencodeGoProvider: Provider = {
|
|
576
|
-
id: 'opencodego',
|
|
577
|
-
short: 'go',
|
|
578
|
-
// No statusUrl — opencode.ai has no verified public status page to poll.
|
|
579
|
-
async fetchUsage(_cfg) {
|
|
580
|
-
const token = process.env.PANTHEON_OPENCODE_API_KEY ?? process.env.OPENCODE_API_KEY
|
|
581
|
-
if (!token) return null
|
|
582
|
-
|
|
583
|
-
const data = (await fetchJson('https://opencode.ai/zen/go/v1/usage', {
|
|
584
|
-
authorization: `Bearer ${token}`,
|
|
585
|
-
})) as {
|
|
586
|
-
rolling5h?: GoUsageWindow
|
|
587
|
-
weekly?: GoUsageWindow
|
|
588
|
-
monthly?: GoUsageWindow
|
|
589
|
-
} | null
|
|
590
|
-
if (!data) return null
|
|
591
|
-
|
|
592
|
-
const windows: UsageWindow[] = []
|
|
593
|
-
// Mirror the defensive parsing used by the other providers: a window
|
|
594
|
-
// missing its percent or reset time is skipped rather than rendered.
|
|
595
|
-
const push = (w: GoUsageWindow | undefined, category: '5h' | '7d' | '1m') => {
|
|
596
|
-
if (!w) return
|
|
597
|
-
if (typeof w.usagePercent !== 'number' || !Number.isFinite(w.usagePercent)) return
|
|
598
|
-
if (typeof w.resetInSec !== 'number' || !Number.isFinite(w.resetInSec)) return
|
|
599
|
-
windows.push({
|
|
600
|
-
category,
|
|
601
|
-
label: category,
|
|
602
|
-
percent: Math.round(w.usagePercent),
|
|
603
|
-
resetsAt: Date.now() + w.resetInSec * 1000,
|
|
604
|
-
})
|
|
605
|
-
}
|
|
606
|
-
push(data.rolling5h, '5h')
|
|
607
|
-
push(data.weekly, '7d')
|
|
608
|
-
push(data.monthly, '1m')
|
|
609
|
-
return windows.length > 0 ? windows : null
|
|
610
|
-
},
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
type GoUsageWindow = {
|
|
614
|
-
usageDollars?: number
|
|
615
|
-
limitDollars?: number
|
|
616
|
-
usagePercent?: number
|
|
617
|
-
resetInSec?: number
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
const providers: Provider[] = [anthropicProvider, openaiProvider, opencodeGoProvider]
|
|
621
|
-
|
|
622
|
-
// ---------------------------------------------------------------------------
|
|
623
|
-
// Config
|
|
624
|
-
// ---------------------------------------------------------------------------
|
|
625
|
-
|
|
626
|
-
const DEFAULT_TOML = `# opencode-usage-bar configuration
|
|
627
|
-
# Read once at startup — restart opencode after editing.
|
|
628
|
-
|
|
629
|
-
[ui]
|
|
630
|
-
show_bars = true # render ▓▓░░ mini-bars (false = text only)
|
|
631
|
-
show_status = true # show a ! marker next to a provider during incidents
|
|
632
|
-
# bar_width = 6 # override bar width (default: 6 for a single window, 5 otherwise)
|
|
633
|
-
|
|
634
|
-
[anthropic]
|
|
635
|
-
enabled = true # Claude Pro/Max via ~/.claude/.credentials.json
|
|
636
|
-
show_5h = true # rolling 5-hour session window
|
|
637
|
-
show_7d = false # weekly cap across all models
|
|
638
|
-
show_model = false # per-model weekly windows (e.g. Fable)
|
|
639
|
-
# credentials_path = "~/.claude/.credentials.json"
|
|
640
|
-
|
|
641
|
-
[openai]
|
|
642
|
-
enabled = false # ChatGPT Plus/Pro via the Codex CLI login
|
|
643
|
-
show_5h = true
|
|
644
|
-
show_7d = false
|
|
645
|
-
# codex_auth_path = "~/.codex/auth.json"
|
|
646
|
-
|
|
647
|
-
[opencodego]
|
|
648
|
-
enabled = true # OpenCode Go/Zen dollar usage via PANTHEON_OPENCODE_API_KEY
|
|
649
|
-
show_5h = true # rolling 5-hour window ($12)
|
|
650
|
-
show_7d = true # rolling 7-day window ($30)
|
|
651
|
-
show_1m = false # subscription-month window ($60) — opt-in
|
|
652
|
-
# Falls back to OPENCODE_API_KEY when PANTHEON_OPENCODE_API_KEY is unset.
|
|
653
|
-
`
|
|
654
|
-
|
|
655
|
-
function defaultConfig(): UsageBarConfig {
|
|
656
|
-
const show = (over: Partial<Record<WindowCategory, boolean>> = {}) => ({
|
|
657
|
-
'5h': true,
|
|
658
|
-
'7d': false,
|
|
659
|
-
'1m': false,
|
|
660
|
-
model: false,
|
|
661
|
-
...over,
|
|
662
|
-
})
|
|
663
|
-
return {
|
|
664
|
-
showBars: true,
|
|
665
|
-
showStatus: true,
|
|
666
|
-
providers: {
|
|
667
|
-
anthropic: { enabled: true, show: show() },
|
|
668
|
-
openai: { enabled: false, show: show() },
|
|
669
|
-
// 5h+7d on by default; monthly window opt-in; model never for Go.
|
|
670
|
-
opencodego: { enabled: true, show: show({ '7d': true }) },
|
|
671
|
-
},
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
type TomlTable = Record<string, unknown>
|
|
676
|
-
|
|
677
|
-
function asTable(v: unknown): TomlTable {
|
|
678
|
-
return v && typeof v === 'object' && !Array.isArray(v) ? (v as TomlTable) : {}
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
function bool(v: unknown, fallback: boolean) {
|
|
682
|
-
return typeof v === 'boolean' ? v : fallback
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
function str(v: unknown): string | undefined {
|
|
686
|
-
return typeof v === 'string' && v.length > 0 ? v : undefined
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
function parseConfig(raw: string): UsageBarConfig {
|
|
690
|
-
const toml = (globalThis as Record<string, any>).Bun?.TOML
|
|
691
|
-
if (!toml?.parse) return defaultConfig()
|
|
692
|
-
const root = asTable(toml.parse(raw))
|
|
693
|
-
const cfg = defaultConfig()
|
|
694
|
-
|
|
695
|
-
const ui = asTable(root.ui)
|
|
696
|
-
cfg.showBars = bool(ui.show_bars, cfg.showBars)
|
|
697
|
-
cfg.showStatus = bool(ui.show_status, cfg.showStatus)
|
|
698
|
-
const rawWidth = ui.bar_width
|
|
699
|
-
if (typeof rawWidth === 'number' && Number.isFinite(rawWidth) && rawWidth >= 1)
|
|
700
|
-
cfg.barWidth = Math.min(40, Math.floor(rawWidth))
|
|
701
|
-
|
|
702
|
-
for (const id of ['anthropic', 'openai', 'opencodego'] as ProviderId[]) {
|
|
703
|
-
const t = asTable(root[id])
|
|
704
|
-
const p = cfg.providers[id]
|
|
705
|
-
p.enabled = bool(t.enabled, p.enabled)
|
|
706
|
-
p.show['5h'] = bool(t.show_5h, p.show['5h'])
|
|
707
|
-
p.show['7d'] = bool(t.show_7d, p.show['7d'])
|
|
708
|
-
p.show['1m'] = bool(t.show_1m, p.show['1m'])
|
|
709
|
-
p.show.model = bool(t.show_model, p.show.model)
|
|
710
|
-
// Assign only when present — keeps `exactOptionalPropertyTypes` happy.
|
|
711
|
-
const credentialsPath = str(t.credentials_path)
|
|
712
|
-
if (credentialsPath !== undefined) p.credentialsPath = credentialsPath
|
|
713
|
-
const codexAuthPath = str(t.codex_auth_path)
|
|
714
|
-
if (codexAuthPath !== undefined) p.codexAuthPath = codexAuthPath
|
|
715
|
-
}
|
|
716
|
-
return cfg
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
/** Load `<config dir>/usage-bar.toml`, creating it with commented defaults on
|
|
720
|
-
* first run. Any failure falls back to defaults without touching an existing
|
|
721
|
-
* file. */
|
|
722
|
-
async function loadConfig(configPath: string | undefined): Promise<UsageBarConfig> {
|
|
723
|
-
const dir =
|
|
724
|
-
configPath && !configPath.endsWith('.json')
|
|
725
|
-
? configPath
|
|
726
|
-
: configPath
|
|
727
|
-
? dirname(configPath)
|
|
728
|
-
: join(homedir(), '.config', 'opencode')
|
|
729
|
-
const file = join(dir, CONFIG_FILE)
|
|
730
|
-
|
|
731
|
-
try {
|
|
732
|
-
const raw = await readFile(file, 'utf8')
|
|
733
|
-
try {
|
|
734
|
-
return parseConfig(raw)
|
|
735
|
-
} catch {
|
|
736
|
-
return defaultConfig() // malformed TOML — keep the file, use defaults
|
|
737
|
-
}
|
|
738
|
-
} catch (err) {
|
|
739
|
-
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
|
740
|
-
try {
|
|
741
|
-
await mkdir(dir, { recursive: true })
|
|
742
|
-
await writeFile(file, DEFAULT_TOML, { flag: 'wx' })
|
|
743
|
-
} catch {
|
|
744
|
-
// ignore — config stays default in memory
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
return defaultConfig()
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
/** Vendored usage-bar plugin init: reads config, seeds the kv cache, starts
|
|
752
|
-
* the poll loop and registers the `app_bottom` slot. Skips everything (and
|
|
753
|
-
* registers nothing) when no provider is enabled — mirrors the standalone
|
|
754
|
-
* plugin's early return. */
|
|
755
|
-
async function setupUsageBar(api: TuiPluginApi) {
|
|
756
|
-
const config = await loadConfig(api.state.path?.config)
|
|
757
|
-
if (api.state.path?.state) opencodeAuthFile = join(api.state.path.state, 'auth.json')
|
|
758
|
-
|
|
759
|
-
const enabled = providers.filter((p) => config.providers[p.id].enabled)
|
|
760
|
-
if (enabled.length === 0) return // nothing to poll or render
|
|
761
|
-
|
|
762
|
-
const seed: Record<string, UsageWindow[]> = {}
|
|
763
|
-
for (const p of enabled) {
|
|
764
|
-
const cached = api.kv.get<UsageWindow[] | undefined>(`usage-bar.${p.id}.windows`, undefined)
|
|
765
|
-
if (cached) seed[p.id] = cached
|
|
766
|
-
}
|
|
767
|
-
const [byProvider, setByProvider] = createSignal<Record<string, UsageWindow[]>>(seed)
|
|
768
|
-
// Status is deliberately not cached across restarts: incidents are
|
|
769
|
-
// short-lived, and the first poll lands seconds after startup anyway.
|
|
770
|
-
const [byStatus, setByStatus] = createSignal<Record<string, StatusIndicator>>({})
|
|
771
|
-
const [now, setNow] = createSignal(Date.now())
|
|
772
|
-
|
|
773
|
-
setInterval(() => setNow(Date.now()), 1_000)
|
|
774
|
-
|
|
775
|
-
for (const p of enabled) {
|
|
776
|
-
const cfg = config.providers[p.id]
|
|
777
|
-
const poll = async () => {
|
|
778
|
-
// Fetch usage and status concurrently; status pages are CDN-backed and
|
|
779
|
-
// never throttle, so we poll them on the same cadence as usage.
|
|
780
|
-
const statusP = config.showStatus && p.statusUrl ? fetchStatus(p.statusUrl) : null
|
|
781
|
-
const [all, status] = await Promise.all([p.fetchUsage(cfg), statusP])
|
|
782
|
-
if (all) {
|
|
783
|
-
const windows = all.filter((w) => cfg.show[w.category] && w.resetsAt > Date.now())
|
|
784
|
-
setByProvider((prev) => ({ ...prev, [p.id]: windows }))
|
|
785
|
-
api.kv.set(`usage-bar.${p.id}.windows`, windows)
|
|
786
|
-
}
|
|
787
|
-
// `null` means the status fetch failed — keep the last known indicator.
|
|
788
|
-
if (status !== null) setByStatus((prev) => ({ ...prev, [p.id]: status }))
|
|
789
|
-
// Back off when the usage fetch failed (e.g. 429 — these endpoints throttle).
|
|
790
|
-
setTimeout(poll, all ? POLL_MS : POLL_MS * 3)
|
|
791
|
-
}
|
|
792
|
-
void poll()
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
api.slots.register({
|
|
796
|
-
order: 60,
|
|
797
|
-
slots: {
|
|
798
|
-
// `app_bottom` renders as a full-width row below the session footer.
|
|
799
|
-
app_bottom() {
|
|
800
|
-
const theme = () => api.theme.current
|
|
801
|
-
|
|
802
|
-
type Group = { short: string; status: StatusIndicator; windows: UsageWindow[] }
|
|
803
|
-
const groups = createMemo<Group[]>(() => {
|
|
804
|
-
const map = byProvider()
|
|
805
|
-
const statusMap = byStatus()
|
|
806
|
-
const out: Group[] = []
|
|
807
|
-
for (const p of enabled) {
|
|
808
|
-
// Drop windows that have reset since the last poll.
|
|
809
|
-
const windows = (map[p.id] ?? []).filter((w) => w.resetsAt > now())
|
|
810
|
-
if (windows.length > 0)
|
|
811
|
-
out.push({ short: p.short, status: statusMap[p.id] ?? 'none', windows })
|
|
812
|
-
}
|
|
813
|
-
return out
|
|
814
|
-
})
|
|
815
|
-
const totalWindows = createMemo(() =>
|
|
816
|
-
groups().reduce((sum, g) => sum + g.windows.length, 0),
|
|
817
|
-
)
|
|
818
|
-
const barWidth = createMemo(() => config.barWidth ?? (totalWindows() === 1 ? 6 : 5))
|
|
819
|
-
const multiProvider = createMemo(() => groups().length >= 2)
|
|
820
|
-
|
|
821
|
-
const pctOf = (w: UsageWindow) => Math.min(100, Math.max(0, Math.round(w.percent)))
|
|
822
|
-
const filledOf = (w: UsageWindow) =>
|
|
823
|
-
Math.min(barWidth(), Math.max(0, Math.round((pctOf(w) / 100) * barWidth())))
|
|
824
|
-
const colorOf = (w: UsageWindow) => {
|
|
825
|
-
const t = theme()
|
|
826
|
-
const pct = pctOf(w)
|
|
827
|
-
if (pct > 85) return t.error
|
|
828
|
-
if (pct >= 50) return t.warning
|
|
829
|
-
return t.success
|
|
830
|
-
}
|
|
831
|
-
// Status marker color: red for severe incidents, amber for minor,
|
|
832
|
-
// cyan for scheduled maintenance.
|
|
833
|
-
const statusColor = (s: StatusIndicator) => {
|
|
834
|
-
const t = theme()
|
|
835
|
-
if (s === 'critical' || s === 'major') return t.error
|
|
836
|
-
if (s === 'minor') return t.warning
|
|
837
|
-
return t.info
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
return (
|
|
841
|
-
<Show when={groups().length > 0}>
|
|
842
|
-
<box flexDirection="row" gap={3} alignItems="center" width="100%" paddingLeft={1}>
|
|
843
|
-
<For each={groups()}>
|
|
844
|
-
{(g) => (
|
|
845
|
-
<box flexDirection="row" gap={2} alignItems="center" flexShrink={0}>
|
|
846
|
-
<Show when={config.showStatus && g.status !== 'none'}>
|
|
847
|
-
<text fg={statusColor(g.status)}>!</text>
|
|
848
|
-
</Show>
|
|
849
|
-
<Show when={multiProvider()}>
|
|
850
|
-
<text fg={theme().textMuted}>{g.short}</text>
|
|
851
|
-
</Show>
|
|
852
|
-
<For each={g.windows}>
|
|
853
|
-
{(w) => (
|
|
854
|
-
<box flexDirection="row" gap={1} alignItems="center" flexShrink={0}>
|
|
855
|
-
<Show when={g.windows.length >= 2}>
|
|
856
|
-
<text fg={theme().textMuted}>{w.label}</text>
|
|
857
|
-
</Show>
|
|
858
|
-
<Show when={config.showBars}>
|
|
859
|
-
<box flexDirection="row">
|
|
860
|
-
<text fg={colorOf(w)}>{'▓'.repeat(filledOf(w))}</text>
|
|
861
|
-
<text fg={theme().textMuted}>
|
|
862
|
-
{'░'.repeat(barWidth() - filledOf(w))}
|
|
863
|
-
</text>
|
|
864
|
-
</box>
|
|
865
|
-
</Show>
|
|
866
|
-
<text fg={theme().text}>{pctOf(w)}%</text>
|
|
867
|
-
<text
|
|
868
|
-
fg={theme().textMuted}
|
|
869
|
-
>{`· ${fmtDuration(w.resetsAt - now())}`}</text>
|
|
870
|
-
</box>
|
|
871
|
-
)}
|
|
872
|
-
</For>
|
|
873
|
-
</box>
|
|
874
|
-
)}
|
|
875
|
-
</For>
|
|
876
|
-
</box>
|
|
877
|
-
</Show>
|
|
878
|
-
)
|
|
879
|
-
},
|
|
880
|
-
},
|
|
881
|
-
})
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
/* ─── Delegations (real-time panel) ────────────────────────
|
|
885
|
-
* CHILDREN + MD (delegations-sidebar pattern, feat/compaction-134):
|
|
886
|
-
*
|
|
887
|
-
* 1. PRIMARY — SDK child sessions via `api.client.session.children`:
|
|
888
|
-
* every pantheon_delegate creates a child session with
|
|
889
|
-
* parentID = caller (src/pantheon/delegation.ts session.create), so
|
|
890
|
-
* the children of the current session ARE the delegation list — the
|
|
891
|
-
* same mechanism the opencode-delegations-sidebar reference uses
|
|
892
|
-
* (`client.session.children` + `session.parentID === current`).
|
|
893
|
-
* Live status comes from `api.state.session.status(childID)`
|
|
894
|
-
* (busy/retry → running; idle → terminal). This does NOT depend on
|
|
895
|
-
* tool-part events, which runtime 1.18.13 may not deliver for
|
|
896
|
-
* pantheon_delegate (the "(0) even while delegating" symptom).
|
|
897
|
-
*
|
|
898
|
-
* 1b. NATIVE task() CHILDREN — `session.children` returns ALL children,
|
|
899
|
-
* and the native `task()` tool ALSO spawns a child session with
|
|
900
|
-
* parentID = caller. A child WITHOUT a board report is a native
|
|
901
|
-
* task() child: childrenToDelegationEntries tags it source
|
|
902
|
-
* 'children-only' with the `nat:` row tag (distinct info color),
|
|
903
|
-
* so native task() work is visible in the panel alongside board
|
|
904
|
-
* delegates (`pan:apo-1`) — never filtered out, never duplicated
|
|
905
|
-
* with a board row for the same child (a child WITH a report keeps the
|
|
906
|
-
* md entry, source 'md').
|
|
907
|
-
*
|
|
908
|
-
* 2. ENRICHMENT + HISTORY — markdown reports the job board persists under
|
|
909
|
-
* `.pantheon/delegations/<sessionID>/<alias>.md` (written by
|
|
910
|
-
* src/pantheon/delegation-finalize.ts at terminal state). Each child
|
|
911
|
-
* is matched to its report by the `Task ID` header (== child session
|
|
912
|
-
* id == board task id), which supplies the alias (apo-N), agent,
|
|
913
|
-
* description and terminal duration/timedOut. No report → the child
|
|
914
|
-
* itself still renders (title as description, derived state). The md
|
|
915
|
-
* channel is ALSO read from EVERY session (readAllDelegationEntries) —
|
|
916
|
-
* the panel shows the full delegation history even without a focused
|
|
917
|
-
* session.
|
|
918
|
-
*
|
|
919
|
-
* 3. REFRESH — session.created/updated/deleted/status events and
|
|
920
|
-
* message.part.updated/removed (via liveStore.version bumps) re-fetch
|
|
921
|
-
* children; a 1s safety poll (oh-my-opencode-slim pattern) re-fetches
|
|
922
|
-
* children + re-reads the md so the panel updates EVEN without events.
|
|
923
|
-
*
|
|
924
|
-
* 4. DIAGNOSTICS — every re-fetch logs
|
|
925
|
-
* "panel: children=N(pantheon=M native=K) md=N events=N"
|
|
926
|
-
* to `.pantheon/logs/hooks.log` (silence-by-default, createPantheonLogger
|
|
927
|
-
* policy) so the (0) symptom is diagnosable from disk: children>0 means
|
|
928
|
-
* the source works, pantheon/native splits board vs task() children.
|
|
929
|
-
* A children fetch
|
|
930
|
-
* failure logs "panel: error <msg>" (previously silenced), and a missing
|
|
931
|
-
* current session (null/placeholder sessionID) logs "panel: children=0
|
|
932
|
-
* md=N ... (no sessionID — history shown)" — the md HISTORY renders
|
|
933
|
-
* instead of erroring.
|
|
934
|
-
*
|
|
935
|
-
* 5. SESSIONID GUARD (placeholder regression) — the runtime may hand the
|
|
936
|
-
* sidebar the UNSUBSTITUTED template literal "{sessionID}" as its
|
|
937
|
-
* session_id (no focused session). Forwarding it to session.children
|
|
938
|
-
* made opencode's server reject every poll (~1 err/s: "Expected a string
|
|
939
|
-
* starting with \"ses\", got \"%7BsessionID%7D\"") and fail-open the
|
|
940
|
-
* panel into "(0)". resolveCurrentSessionID (slot prop → api.state →
|
|
941
|
-
* route.current) never yields a placeholder (isValidSessionId rejects
|
|
942
|
-
* "{sessionID}" and "%7BsessionID%7D" via the startsWith("ses")
|
|
943
|
-
* contract); a null resolution SKIPS the children fetch entirely and
|
|
944
|
-
* renders the md history — zero errors.
|
|
945
|
-
*
|
|
946
|
-
* The live tool-part lifecycle helpers below remain (tested, exported) but
|
|
947
|
-
* are no longer the source of truth: they feed a version signal that merely
|
|
948
|
-
* re-triggers the children fetch. Everything is fail-open: a missing
|
|
949
|
-
* children API or directory yields [], and any unreadable input is skipped —
|
|
950
|
-
* never crash. */
|
|
951
|
-
|
|
952
|
-
export type DelegationEntry = {
|
|
953
|
-
/** Job alias, e.g. "apo-1" (from the H1 title, falling back to filename). */
|
|
954
|
-
alias: string
|
|
955
|
-
/** Parent session the job was launched from (dir name under .pantheon/delegations). */
|
|
956
|
-
sessionID: string
|
|
957
|
-
/** Child session id (= board task id, from the `Task ID` header). The
|
|
958
|
-
* children channel always sets it from the child session itself; the md
|
|
959
|
-
* channel parses it so child↔md matching works by taskID. */
|
|
960
|
-
taskID?: string
|
|
961
|
-
/** Agent name, e.g. "apollo". */
|
|
962
|
-
agent: string
|
|
963
|
-
state:
|
|
964
|
-
| 'running'
|
|
965
|
-
| 'retry'
|
|
966
|
-
| 'completed'
|
|
967
|
-
| 'error'
|
|
968
|
-
| 'startup_failed'
|
|
969
|
-
| 'startup_unknown'
|
|
970
|
-
| 'cancelled'
|
|
971
|
-
| 'stale-running'
|
|
972
|
-
/** Epoch ms of the `Started` header. */
|
|
973
|
-
startedAt: number
|
|
974
|
-
/** Epoch ms of the `Finalized` header — null while still running. */
|
|
975
|
-
updatedAt: number | null
|
|
976
|
-
timedOut: boolean
|
|
977
|
-
description: string
|
|
978
|
-
/** True while the panel is waiting for pantheon_delegation_read. */
|
|
979
|
-
read?: boolean
|
|
980
|
-
// NOTE: the nested/tree view is BACKLOG. `session.children` returns direct
|
|
981
|
-
// children only, so no real grandchild rows exist yet; the parentTaskID /
|
|
982
|
-
// childCount fields + tree-connector helpers were removed as dead code.
|
|
983
|
-
// Reintroduce them once nested rows are actually produced.
|
|
984
|
-
/** Internal provenance used to keep a finalized md report authoritative.
|
|
985
|
-
* 'children-only' = a native task() child session with NO board report
|
|
986
|
-
* (rendered with the distinct `nat:` prefix); 'md' = board report wins. */
|
|
987
|
-
source?: 'child' | 'live' | 'md' | 'children-only'
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
/** True for `\s` characters (space, tab, newline, CR) — plain char checks so
|
|
991
|
-
* the parser stays regex-free (CodeQL flagged the old `\s*`/`\s+` + `(.+)`
|
|
992
|
-
* patterns as polynomial ReDoS: 12x HIGH). */
|
|
993
|
-
function isWs(ch: string | undefined): boolean {
|
|
994
|
-
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
/** Em/en dash + hyphen — the separators accepted after the H1 title. */
|
|
998
|
-
const TITLE_SEPARATORS = '—–-'
|
|
999
|
-
|
|
1000
|
-
/** Parse one delegation report md header into a structured entry.
|
|
1001
|
-
* Returns null (skip) when the file is not a recognizable report:
|
|
1002
|
-
* missing agent/state/startedAt, an unknown state, or an unparsable
|
|
1003
|
-
* Started timestamp. The alias falls back to the file name when the H1
|
|
1004
|
-
* title is missing. Pure — no I/O.
|
|
1005
|
-
*
|
|
1006
|
-
* Linear, single-pass over `raw.split('\n')` with plain string operations
|
|
1007
|
-
* (startsWith/indexOf/slice) — zero regex, so worst case is O(bytes) even
|
|
1008
|
-
* on adversarial whitespace-heavy input (ReDoS regression, CodeQL 12x HIGH). */
|
|
1009
|
-
export function parseDelegationMarkdown(
|
|
1010
|
-
raw: string,
|
|
1011
|
-
fileAlias?: string,
|
|
1012
|
-
sessionID = '',
|
|
1013
|
-
): DelegationEntry | null {
|
|
1014
|
-
let title: string | undefined
|
|
1015
|
-
let agent: string | undefined
|
|
1016
|
-
let description = ''
|
|
1017
|
-
let state: string | undefined
|
|
1018
|
-
let timedOut = false
|
|
1019
|
-
let started: string | undefined
|
|
1020
|
-
let finalized: string | undefined
|
|
1021
|
-
let taskID: string | undefined
|
|
1022
|
-
|
|
1023
|
-
for (const rawLine of raw.split('\n')) {
|
|
1024
|
-
// H1 title: `# Delegation Report — <alias>` (empty alias → fallback later).
|
|
1025
|
-
if (rawLine[0] === '#' && isWs(rawLine[1])) {
|
|
1026
|
-
let i = 2
|
|
1027
|
-
while (i < rawLine.length && isWs(rawLine[i])) i++
|
|
1028
|
-
if (rawLine.startsWith('Delegation Report', i)) {
|
|
1029
|
-
i += 'Delegation Report'.length
|
|
1030
|
-
while (i < rawLine.length && isWs(rawLine[i])) i++
|
|
1031
|
-
if (i < rawLine.length && TITLE_SEPARATORS.includes(rawLine[i])) {
|
|
1032
|
-
const rest = rawLine.slice(i + 1).trim()
|
|
1033
|
-
if (rest !== '' && title === undefined) title = rest
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
continue
|
|
1037
|
-
}
|
|
1038
|
-
// Field bullets: `- **Name**: value` — unknown names are ignored.
|
|
1039
|
-
if (rawLine[0] !== '-') continue
|
|
1040
|
-
let i = 1
|
|
1041
|
-
while (i < rawLine.length && isWs(rawLine[i])) i++
|
|
1042
|
-
if (!rawLine.startsWith('**', i)) continue
|
|
1043
|
-
const nameStart = i + 2
|
|
1044
|
-
const valueEnd = rawLine.indexOf('**:', nameStart)
|
|
1045
|
-
if (valueEnd < 0) continue
|
|
1046
|
-
const name = rawLine.slice(nameStart, valueEnd)
|
|
1047
|
-
const value = rawLine.slice(valueEnd + 3).trim()
|
|
1048
|
-
switch (name) {
|
|
1049
|
-
case 'Task ID':
|
|
1050
|
-
// `- **Task ID**: \`ses_x\`` — strip the surrounding backticks.
|
|
1051
|
-
if (value !== '' && taskID === undefined) taskID = stripTaskIdTicks(value)
|
|
1052
|
-
break
|
|
1053
|
-
case 'Agent':
|
|
1054
|
-
// Empty value counts as missing (old regex `(.+)` required 1+ chars).
|
|
1055
|
-
if (value !== '' && agent === undefined) agent = value
|
|
1056
|
-
break
|
|
1057
|
-
case 'Description':
|
|
1058
|
-
description = value
|
|
1059
|
-
break
|
|
1060
|
-
case 'State':
|
|
1061
|
-
if (value !== '' && state === undefined) state = value
|
|
1062
|
-
break
|
|
1063
|
-
case 'Timed out':
|
|
1064
|
-
// Old `(true|false)` prefix match, case-sensitive — kept verbatim.
|
|
1065
|
-
timedOut = value.startsWith('true')
|
|
1066
|
-
break
|
|
1067
|
-
case 'Started':
|
|
1068
|
-
if (value !== '' && started === undefined) started = value
|
|
1069
|
-
break
|
|
1070
|
-
case 'Finalized':
|
|
1071
|
-
finalized = value
|
|
1072
|
-
break
|
|
1073
|
-
default:
|
|
1074
|
-
break // unknown field (e.g. Task ID) → ignore the line
|
|
1075
|
-
}
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
const startedAt = started !== undefined ? Date.parse(started) : NaN
|
|
1079
|
-
if (agent === undefined || state === undefined || Number.isNaN(startedAt)) return null
|
|
1080
|
-
const normalized = state.toLowerCase()
|
|
1081
|
-
// Running state should only come from live channels, never from persisted
|
|
1082
|
-
// reports. A stale "running" MD artifact contaminates the panel — skip it.
|
|
1083
|
-
if (normalized === 'running') return null
|
|
1084
|
-
if (
|
|
1085
|
-
normalized !== 'completed' &&
|
|
1086
|
-
normalized !== 'error' &&
|
|
1087
|
-
normalized !== 'startup_failed' &&
|
|
1088
|
-
normalized !== 'startup_unknown' &&
|
|
1089
|
-
normalized !== 'cancelled'
|
|
1090
|
-
) {
|
|
1091
|
-
return null
|
|
1092
|
-
}
|
|
1093
|
-
const finalizedAt = finalized !== undefined ? Date.parse(finalized) : NaN
|
|
1094
|
-
|
|
1095
|
-
return {
|
|
1096
|
-
alias: title ?? (fileAlias !== undefined ? stripMdSuffix(fileAlias) : 'unknown'),
|
|
1097
|
-
sessionID,
|
|
1098
|
-
...(taskID !== undefined ? { taskID } : {}),
|
|
1099
|
-
agent,
|
|
1100
|
-
state: normalized,
|
|
1101
|
-
startedAt,
|
|
1102
|
-
updatedAt: Number.isNaN(finalizedAt) ? null : finalizedAt,
|
|
1103
|
-
timedOut,
|
|
1104
|
-
description,
|
|
1105
|
-
source: 'md',
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
/** Strip a trailing `.md` (any case) — linear replacement for /\.md$/i. */
|
|
1110
|
-
function stripMdSuffix(name: string): string {
|
|
1111
|
-
return name.toLowerCase().endsWith('.md') ? name.slice(0, name.length - 3) : name
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
/** Strip surrounding backticks from a `Task ID` value: "`ses_x`" → "ses_x". */
|
|
1115
|
-
function stripTaskIdTicks(value: string): string {
|
|
1116
|
-
const start = value[0] === '`' ? 1 : 0
|
|
1117
|
-
const end =
|
|
1118
|
-
value.length > start && value[value.length - 1] === '`' ? value.length - 1 : value.length
|
|
1119
|
-
return value.slice(start, end)
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
/** Read every delegation report under `<dir>/<sessionID>/<alias>.md`.
|
|
1123
|
-
* Fail-open: a missing/unreadable directory yields [], and each unreadable
|
|
1124
|
-
* or malformed file is skipped individually. Entries are sorted running
|
|
1125
|
-
* first, then terminal by `updatedAt` (most recent first) so the panel can
|
|
1126
|
-
* render them in order directly. */
|
|
1127
|
-
export async function readDelegationEntries(dir: string): Promise<DelegationEntry[]> {
|
|
1128
|
-
let sessionDirs: Dirent[]
|
|
1129
|
-
try {
|
|
1130
|
-
sessionDirs = await readdir(dir, { withFileTypes: true })
|
|
1131
|
-
} catch {
|
|
1132
|
-
return [] // directory absent/unreadable — nothing to show
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
const entries: DelegationEntry[] = []
|
|
1136
|
-
for (const session of sessionDirs) {
|
|
1137
|
-
if (!session.isDirectory()) continue
|
|
1138
|
-
let files: Dirent[]
|
|
1139
|
-
try {
|
|
1140
|
-
files = await readdir(join(dir, session.name), { withFileTypes: true })
|
|
1141
|
-
} catch {
|
|
1142
|
-
continue // unreadable session dir — skip
|
|
1143
|
-
}
|
|
1144
|
-
for (const file of files) {
|
|
1145
|
-
if (!file.isFile() || !file.name.endsWith('.md')) continue
|
|
1146
|
-
try {
|
|
1147
|
-
const raw = await readFile(join(dir, session.name, file.name), 'utf8')
|
|
1148
|
-
const entry = parseDelegationMarkdown(raw, file.name, session.name)
|
|
1149
|
-
if (entry !== null) entries.push(entry)
|
|
1150
|
-
} catch {
|
|
1151
|
-
// unreadable/malformed file — skip, never crash
|
|
1152
|
-
}
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
entries.sort(compareDelegationEntries)
|
|
1157
|
-
return entries
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
/** Read delegation reports from EVERY session under
|
|
1161
|
-
* `<root>/.pantheon/delegations/<sessionID>/<alias>.md` — the panel's
|
|
1162
|
-
* HISTORY channel. Unlike the children/live channels it does NOT depend on a
|
|
1163
|
-
* resolved sessionID: with no focused session (null/placeholder), the panel
|
|
1164
|
-
* still shows the reports from all past sessions (running first, Finalized
|
|
1165
|
-
* desc — the sort applied by readDelegationEntries). Fail-open: a
|
|
1166
|
-
* missing/unreadable directory yields []. */
|
|
1167
|
-
export async function readAllDelegationEntries(root: string): Promise<DelegationEntry[]> {
|
|
1168
|
-
return readDelegationEntries(join(root, '.pantheon', 'delegations'))
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
/** Resolve the directory where the job board writes delegation md reports.
|
|
1172
|
-
* The board writes `.pantheon/delegations` RELATIVE to the server cwd,
|
|
1173
|
-
* which the TUI exposes as `TuiState.path.directory`. `project` does NOT
|
|
1174
|
-
* exist on `TuiState.path` (the old `state?.project ?? state?.worktree`
|
|
1175
|
-
* resolution was always undefined for the first term) and `worktree` is
|
|
1176
|
-
* `/` when there is no git (e.g. the sandbox test project) — a root of
|
|
1177
|
-
* `''` or `'/'` must fall back to `process.cwd()`. */
|
|
1178
|
-
export function resolveDelegationsDir(
|
|
1179
|
-
state: { directory?: string; worktree?: string } | undefined,
|
|
1180
|
-
cwd = process.cwd(),
|
|
1181
|
-
): string {
|
|
1182
|
-
const root = state?.directory ?? state?.worktree ?? ''
|
|
1183
|
-
if (root === '' || root === '/') return join(cwd, '.pantheon', 'delegations')
|
|
1184
|
-
return join(root, '.pantheon', 'delegations')
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
/** Project root derived from the delegations dir: `<root>/.pantheon/delegations`
|
|
1188
|
-
* → `<root>`. Used to point the panel logger at the REAL hooks.log — passing
|
|
1189
|
-
* the delegations dir (or its dirname) directly made createTuiLogger append
|
|
1190
|
-
* to `<root>/.pantheon/.pantheon/logs/hooks.log`, a nested empty dir the real
|
|
1191
|
-
* log never saw. Pure — no I/O. */
|
|
1192
|
-
export function panelLogDir(delegationsDir: string): string {
|
|
1193
|
-
return dirname(dirname(delegationsDir))
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
/** Where the panel logger appends lines: `<projectRoot>/.pantheon/logs/hooks.log`.
|
|
1197
|
-
* Pure — testable without the runtime. */
|
|
1198
|
-
export function tuiLogPath(projectRoot: string): string {
|
|
1199
|
-
return join(projectRoot, '.pantheon', 'logs', 'hooks.log')
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
/** Sort delegations: running first, then terminal by recency (updatedAt,
|
|
1203
|
-
* falling back to startedAt, descending). Shared by the md reader and
|
|
1204
|
-
* mergeDelegationSources. */
|
|
1205
|
-
export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry): number {
|
|
1206
|
-
const isActive = (st: DelegationEntry['state']) =>
|
|
1207
|
-
st === 'running' || st === 'retry' || st === 'stale-running' ? 1 : 0
|
|
1208
|
-
const aRun = isActive(a.state)
|
|
1209
|
-
const bRun = isActive(b.state)
|
|
1210
|
-
if (aRun !== bRun) return bRun - aRun
|
|
1211
|
-
return (b.updatedAt ?? b.startedAt) - (a.updatedAt ?? a.startedAt)
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
/** Split the panel list: active jobs (running/retry, stale-marked) first,
|
|
1215
|
-
* then the most recent terminal reports, then the archived tail (paginated
|
|
1216
|
-
* in the View). Pure — so the history-only panel (no sessionID) is testable
|
|
1217
|
-
* without the TUI runtime. */
|
|
1218
|
-
export function splitDelegationList(
|
|
1219
|
-
all: readonly DelegationEntry[],
|
|
1220
|
-
maxRecent = 8,
|
|
1221
|
-
now = Date.now(),
|
|
1222
|
-
staleThresholdMs = 30 * 60 * 1000, // 30 minutes
|
|
1223
|
-
): { active: DelegationEntry[]; recent: DelegationEntry[]; archived: DelegationEntry[] } {
|
|
1224
|
-
// Active = running OR retry OR the display-only stale-running. A stale row
|
|
1225
|
-
// is still a live job (backend state unchanged), so it must never fall into
|
|
1226
|
-
// the terminal/archived tail — archiving a running delegation hid the row
|
|
1227
|
-
// the user launched.
|
|
1228
|
-
const isActiveState = (st: DelegationEntry['state']): boolean =>
|
|
1229
|
-
st === 'running' || st === 'retry' || st === 'stale-running'
|
|
1230
|
-
const active = all
|
|
1231
|
-
.filter((d) => isActiveState(d.state))
|
|
1232
|
-
.map((d) => markStaleIfRunning(d, now, staleThresholdMs))
|
|
1233
|
-
const terminal = all.filter((d) => !isActiveState(d.state))
|
|
1234
|
-
return {
|
|
1235
|
-
active,
|
|
1236
|
-
recent: terminal.slice(0, maxRecent),
|
|
1237
|
-
archived: terminal.slice(maxRecent),
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
/** The list the panel actually renders (kept for the header count and
|
|
1242
|
-
* existing tests): active jobs first, then the most recent terminal
|
|
1243
|
-
* reports (capped) — the archived tail is rendered separately. Pure. */
|
|
1244
|
-
export function visibleDelegationList(
|
|
1245
|
-
all: readonly DelegationEntry[],
|
|
1246
|
-
maxTerminal = 8,
|
|
1247
|
-
now = Date.now(),
|
|
1248
|
-
staleThresholdMs = 30 * 60 * 1000, // 30 minutes
|
|
1249
|
-
): DelegationEntry[] {
|
|
1250
|
-
const { active, recent } = splitDelegationList(all, maxTerminal, now, staleThresholdMs)
|
|
1251
|
-
return [...active, ...recent]
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
/** Default stale-running threshold: 30 minutes. */
|
|
1255
|
-
export const STALE_RUNNING_THRESHOLD_MS = 30 * 60 * 1000
|
|
1256
|
-
|
|
1257
|
-
/** Idle silence window: if no updatedAt change in this window, the entry is
|
|
1258
|
-
* considered stale. Combined with the stale-running threshold to produce the
|
|
1259
|
-
* display-only `stale-running` state. */
|
|
1260
|
-
export const IDLE_SILENCE_MS = 60 * 1000
|
|
1261
|
-
|
|
1262
|
-
/**
|
|
1263
|
-
* Mark a running entry as `stale-running` if it has been running longer than
|
|
1264
|
-
* the threshold AND has no recent activity (no `updatedAt` change in the last
|
|
1265
|
-
* `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the board state is unchanged.
|
|
1266
|
-
*
|
|
1267
|
-
* A `stale-running` entry renders with a warning indicator but the underlying
|
|
1268
|
-
* delegation is still treated as running by the backend.
|
|
1269
|
-
*/
|
|
1270
|
-
export function markStaleIfRunning(
|
|
1271
|
-
entry: DelegationEntry,
|
|
1272
|
-
now: number,
|
|
1273
|
-
thresholdMs = STALE_RUNNING_THRESHOLD_MS,
|
|
1274
|
-
): DelegationEntry {
|
|
1275
|
-
if (entry.state !== 'running') return entry
|
|
1276
|
-
const elapsed = now - entry.startedAt
|
|
1277
|
-
if (elapsed < thresholdMs) return entry
|
|
1278
|
-
// If updatedAt exists and is recent, entry is still active — not stale.
|
|
1279
|
-
if (entry.updatedAt !== null && now - entry.updatedAt < IDLE_SILENCE_MS) return entry
|
|
1280
|
-
return { ...entry, state: 'stale-running' as const }
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
/** Compact elapsed-time label: "5m 12s", "1h 30m", "2d 4h" — ticks every
|
|
1284
|
-
* second for running jobs. */
|
|
1285
|
-
export function fmtElapsed(ms: number): string {
|
|
1286
|
-
const total = Math.max(0, Math.floor(ms / 1000))
|
|
1287
|
-
const days = Math.floor(total / 86_400)
|
|
1288
|
-
const hours = Math.floor((total % 86_400) / 3_600)
|
|
1289
|
-
const minutes = Math.floor((total % 3_600) / 60)
|
|
1290
|
-
const seconds = total % 60
|
|
1291
|
-
if (days > 0) return `${days}d ${hours}h`
|
|
1292
|
-
if (hours > 0) return `${hours}h ${minutes}m`
|
|
1293
|
-
if (minutes > 0) return `${minutes}m ${seconds}s`
|
|
1294
|
-
return `${seconds}s`
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
/** Elapsed label for one entry: an ACTIVE entry (running/retry/stale-running)
|
|
1298
|
-
* ticks `now - startedAt`; a terminal one is fixed at
|
|
1299
|
-
* `updatedAt - startedAt` (em dash when no finalized timestamp). */
|
|
1300
|
-
export function delegationElapsed(entry: DelegationEntry, now: number): string {
|
|
1301
|
-
if (entry.state === 'running' || entry.state === 'retry' || entry.state === 'stale-running')
|
|
1302
|
-
return fmtElapsed(now - entry.startedAt)
|
|
1303
|
-
return entry.updatedAt !== null ? fmtElapsed(entry.updatedAt - entry.startedAt) : '\u2014'
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
/** The activity labels shown by the animated row. Keeping this pure makes the
|
|
1307
|
-
* state machine testable without booting OpenCode's renderer. */
|
|
1308
|
-
export type DelegationActivity =
|
|
1309
|
-
| 'delegating'
|
|
1310
|
-
| 'working'
|
|
1311
|
-
| 'reading'
|
|
1312
|
-
| 'completed'
|
|
1313
|
-
| 'error'
|
|
1314
|
-
| 'cancelled'
|
|
1315
|
-
|
|
1316
|
-
export function delegationActivity(entry: DelegationEntry): DelegationActivity {
|
|
1317
|
-
if (entry.state === 'completed') return 'completed'
|
|
1318
|
-
if (
|
|
1319
|
-
entry.state === 'error' ||
|
|
1320
|
-
entry.state === 'startup_failed' ||
|
|
1321
|
-
entry.state === 'startup_unknown'
|
|
1322
|
-
)
|
|
1323
|
-
return 'error'
|
|
1324
|
-
if (entry.state === 'cancelled') return 'cancelled'
|
|
1325
|
-
if (entry.read) return 'reading'
|
|
1326
|
-
if (entry.alias.startsWith('live-') && entry.taskID === undefined) return 'delegating'
|
|
1327
|
-
return 'working'
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
export function delegationActivityLabel(entry: DelegationEntry): string {
|
|
1331
|
-
if (entry.state === 'retry') return 'RETRYING'
|
|
1332
|
-
switch (delegationActivity(entry)) {
|
|
1333
|
-
case 'delegating':
|
|
1334
|
-
return 'DELEGATING'
|
|
1335
|
-
case 'working':
|
|
1336
|
-
return 'WORKING'
|
|
1337
|
-
case 'reading':
|
|
1338
|
-
return 'READING RESULT'
|
|
1339
|
-
case 'completed':
|
|
1340
|
-
return entry.timedOut ? 'DONE (TIMED OUT)' : 'DONE'
|
|
1341
|
-
case 'error':
|
|
1342
|
-
return 'ERROR'
|
|
1343
|
-
default:
|
|
1344
|
-
return 'CANCELLED'
|
|
1345
|
-
}
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
const DELEGATION_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
1349
|
-
|
|
1350
|
-
/** Return a deterministic spinner frame. The View ticks this every 1000ms
|
|
1351
|
-
* (not 140ms — the fast tick flickered without adding information). */
|
|
1352
|
-
export function delegationSpinnerFrame(now: number): string {
|
|
1353
|
-
const index = Math.floor(Math.max(0, now) / 1_000) % DELEGATION_SPINNER_FRAMES.length
|
|
1354
|
-
return DELEGATION_SPINNER_FRAMES[index] ?? DELEGATION_SPINNER_FRAMES[0]
|
|
1355
|
-
}
|
|
1356
|
-
|
|
1357
|
-
/** Every state the row knows how to draw: the real BackgroundJobBoard FSM
|
|
1358
|
-
* (src/pantheon/background-job-board.ts) plus the TUI display-only states
|
|
1359
|
-
* (`retry`, `stale-running`). Fase 1 deliberately omits speculative
|
|
1360
|
-
* blocked/paused/scheduled/skipped. There is no `pending` display state: a
|
|
1361
|
-
* pre-dispatch tool part maps to `running` in {@link reduceDelegationToolPart}. */
|
|
1362
|
-
export type DelegationDisplayState = DelegationEntry['state']
|
|
1363
|
-
|
|
1364
|
-
/** Static glyph per display state. `running` shows its base spinner frame;
|
|
1365
|
-
* callers that animate must prefer {@link delegationStateMarker}. Unicode
|
|
1366
|
-
* geometric shapes only (no Nerd Font) — shape is an independent channel
|
|
1367
|
-
* from color, so rows stay legible without color. */
|
|
1368
|
-
export const DELEGATION_STATE_GLYPHS: Readonly<Record<DelegationDisplayState, string>> = {
|
|
1369
|
-
running: '\u280b', // ⠋ base frame of the animated spinner
|
|
1370
|
-
retry: '\u27f3', // ⟳ retrying
|
|
1371
|
-
'stale-running': '\u26a0', // ⚠ stale (display-only alert)
|
|
1372
|
-
completed: '\u2713', // ✓
|
|
1373
|
-
error: '\u2715', // ✕
|
|
1374
|
-
cancelled: '\u2212', // − minus: stopped, distinct from error's ✕
|
|
1375
|
-
startup_failed: '\u26a0', // ⚠ boot failed
|
|
1376
|
-
startup_unknown: '\u26a0', // ⚠ boot outcome unknown
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
export function delegationStateGlyph(state: DelegationDisplayState): string {
|
|
1380
|
-
return DELEGATION_STATE_GLYPHS[state]
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
/** Row marker (`<glyph> `) — the state channel. `running` animates through
|
|
1384
|
-
* {@link delegationSpinnerFrame} for the given tick; every other state is
|
|
1385
|
-
* static. Pure. */
|
|
1386
|
-
export function delegationStateMarker(state: DelegationDisplayState, now = Date.now()): string {
|
|
1387
|
-
const glyph = state === 'running' ? delegationSpinnerFrame(now) : delegationStateGlyph(state)
|
|
1388
|
-
return `${glyph} `
|
|
1389
|
-
}
|
|
1390
|
-
|
|
1391
|
-
/** Semantic tone mapped to the TUI theme at the row ({@link DelegationRow}).
|
|
1392
|
-
* Kept separate + pure so the color channel is testable without booting the
|
|
1393
|
-
* renderer. */
|
|
1394
|
-
export type DelegationStateTone = 'warning' | 'error' | 'success' | 'muted'
|
|
1395
|
-
|
|
1396
|
-
export function delegationStateTone(state: DelegationDisplayState): DelegationStateTone {
|
|
1397
|
-
switch (state) {
|
|
1398
|
-
case 'running':
|
|
1399
|
-
case 'retry':
|
|
1400
|
-
case 'startup_unknown':
|
|
1401
|
-
return 'warning'
|
|
1402
|
-
case 'stale-running':
|
|
1403
|
-
case 'error':
|
|
1404
|
-
case 'startup_failed':
|
|
1405
|
-
return 'error'
|
|
1406
|
-
case 'completed':
|
|
1407
|
-
return 'success'
|
|
1408
|
-
case 'cancelled':
|
|
1409
|
-
return 'muted'
|
|
1410
|
-
}
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
|
-
/* ─── Live tool activity per child session (beta.6) ────────────────────────
|
|
1414
|
-
* The delegation panel's activity line ("↳ bash npm test") — what the agent
|
|
1415
|
-
* is doing RIGHT NOW. Sourced from message.part.updated tool parts: every
|
|
1416
|
-
* pending/running tool part updates a per-child-session record (latest wins).
|
|
1417
|
-
* Module-level by design: the sidebar remounts on route changes and the
|
|
1418
|
-
* "what is each agent doing" state should survive that (OMO pattern). */
|
|
1419
|
-
|
|
1420
|
-
export type ToolActivity = {
|
|
1421
|
-
tool: string
|
|
1422
|
-
summary: string
|
|
1423
|
-
at: number
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
/** Bounded tracker: child session id → latest running tool call. */
|
|
1427
|
-
const latestToolActivity = new Map<string, ToolActivity>()
|
|
1428
|
-
const MAX_TOOL_ACTIVITY_ENTRIES = 200
|
|
1429
|
-
const TOOL_ACTIVITY_TTL_MS = 5 * 60 * 1000
|
|
1430
|
-
|
|
1431
|
-
/** Archived-section pagination state — module-level so it survives sidebar
|
|
1432
|
-
* remounts (route changes) without leaking into the component tree. */
|
|
1433
|
-
const archivedUiState = { page: 0 }
|
|
1434
|
-
|
|
1435
|
-
/** First usable summary string from common tool input fields. */
|
|
1436
|
-
function summarizeToolInput(input: Record<string, unknown> | undefined): string {
|
|
1437
|
-
if (!input) return ''
|
|
1438
|
-
const candidates = ['command', 'description', 'prompt', 'filePath', 'pattern', 'url', 'query']
|
|
1439
|
-
for (const key of candidates) {
|
|
1440
|
-
const value = input[key]
|
|
1441
|
-
if (typeof value === 'string' && value.trim() !== '') return value
|
|
1442
|
-
}
|
|
1443
|
-
return ''
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
|
-
/** Reduce one message.part.updated tool part to displayable activity.
|
|
1447
|
-
* Returns null for non-tool parts, completed/error parts (no live activity)
|
|
1448
|
-
* or parts without a session id. Pure. */
|
|
1449
|
-
export function extractToolActivity(
|
|
1450
|
-
part: {
|
|
1451
|
-
type?: string
|
|
1452
|
-
tool?: string
|
|
1453
|
-
sessionID?: string
|
|
1454
|
-
state?: { status?: string; input?: Record<string, unknown> }
|
|
1455
|
-
},
|
|
1456
|
-
now = Date.now(),
|
|
1457
|
-
): { sessionID: string; activity: ToolActivity } | null {
|
|
1458
|
-
if (part?.type !== 'tool') return null
|
|
1459
|
-
const status = part.state?.status
|
|
1460
|
-
if (status !== 'pending' && status !== 'running') return null
|
|
1461
|
-
const sessionID = typeof part.sessionID === 'string' ? part.sessionID : ''
|
|
1462
|
-
if (sessionID === '') return null
|
|
1463
|
-
const tool = typeof part.tool === 'string' && part.tool !== '' ? part.tool : 'tool'
|
|
1464
|
-
const summary = summarizeToolInput(part.state?.input).replace(/\s+/g, ' ').trim().slice(0, 48)
|
|
1465
|
-
return { sessionID, activity: { tool, summary, at: now } }
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
/** Record an activity sample (bounded map, oldest dropped). */
|
|
1469
|
-
export function trackToolActivity(
|
|
1470
|
-
map: Map<string, ToolActivity>,
|
|
1471
|
-
sessionID: string,
|
|
1472
|
-
activity: ToolActivity,
|
|
1473
|
-
): void {
|
|
1474
|
-
if (sessionID === '') return
|
|
1475
|
-
if (map.size >= MAX_TOOL_ACTIVITY_ENTRIES) {
|
|
1476
|
-
const oldest = map.keys().next().value
|
|
1477
|
-
if (oldest !== undefined) map.delete(oldest)
|
|
1478
|
-
}
|
|
1479
|
-
map.set(sessionID, activity)
|
|
1480
|
-
}
|
|
1481
|
-
|
|
1482
|
-
/** Latest live activity for a child session, or null when absent/stale. */
|
|
1483
|
-
export function latestToolActivityFor(
|
|
1484
|
-
map: Map<string, ToolActivity>,
|
|
1485
|
-
sessionID: string | undefined,
|
|
1486
|
-
now = Date.now(),
|
|
1487
|
-
ttlMs = TOOL_ACTIVITY_TTL_MS,
|
|
1488
|
-
): ToolActivity | null {
|
|
1489
|
-
if (!sessionID) return null
|
|
1490
|
-
const hit = map.get(sessionID)
|
|
1491
|
-
if (hit === undefined) return null
|
|
1492
|
-
if (now - hit.at > ttlMs) return null
|
|
1493
|
-
return hit
|
|
1494
|
-
}
|
|
1495
|
-
|
|
1496
|
-
/** Merge the immediate tool-event channel into the child-session channel.
|
|
1497
|
-
*
|
|
1498
|
-
* Children remain the durable source, while live entries make a delegation
|
|
1499
|
-
* visible before the child API/report catches up. A finalized md entry wins
|
|
1500
|
-
* over a stale live entry; a child-only row is upgraded with live agent,
|
|
1501
|
-
* alias, phase and timestamps. */
|
|
1502
|
-
export function mergeChildDelegationSources(
|
|
1503
|
-
children: readonly DelegationEntry[],
|
|
1504
|
-
live: readonly LiveDelegationEntry[],
|
|
1505
|
-
): DelegationEntry[] {
|
|
1506
|
-
const result = [...children]
|
|
1507
|
-
const byTask = new Map<string, number>()
|
|
1508
|
-
const bySessionAlias = new Map<string, number>()
|
|
1509
|
-
const key = (sessionID: string, alias: string): string => `${sessionID}\u0000${alias}`
|
|
1510
|
-
|
|
1511
|
-
result.forEach((entry, index) => {
|
|
1512
|
-
if (entry.taskID !== undefined) byTask.set(entry.taskID, index)
|
|
1513
|
-
bySessionAlias.set(key(entry.sessionID, entry.alias), index)
|
|
1514
|
-
})
|
|
1515
|
-
|
|
1516
|
-
for (const liveEntry of live) {
|
|
1517
|
-
if (
|
|
1518
|
-
liveEntry.alias === null &&
|
|
1519
|
-
liveEntry.taskID === null &&
|
|
1520
|
-
Date.now() - liveEntry.startedAt > 30_000
|
|
1521
|
-
)
|
|
1522
|
-
continue
|
|
1523
|
-
const incoming = toDelegationEntry(liveEntry)
|
|
1524
|
-
const index =
|
|
1525
|
-
(incoming.taskID !== undefined ? byTask.get(incoming.taskID) : undefined) ??
|
|
1526
|
-
(incoming.alias !== ''
|
|
1527
|
-
? bySessionAlias.get(key(incoming.sessionID, incoming.alias))
|
|
1528
|
-
: undefined)
|
|
1529
|
-
|
|
1530
|
-
if (index === undefined) {
|
|
1531
|
-
result.push(incoming)
|
|
1532
|
-
const newIndex = result.length - 1
|
|
1533
|
-
if (incoming.taskID !== undefined) byTask.set(incoming.taskID, newIndex)
|
|
1534
|
-
bySessionAlias.set(key(incoming.sessionID, incoming.alias), newIndex)
|
|
1535
|
-
continue
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
const existing = result[index]
|
|
1539
|
-
if (existing === undefined) continue
|
|
1540
|
-
// Any terminal state is authoritative — a child session that went idle
|
|
1541
|
-
// correctly derives 'completed' via childStatusToState before the MD
|
|
1542
|
-
// report is written. The live tool-part channel stays 'running' until
|
|
1543
|
-
// pantheon_delegation_read fires, so allowing it to overwrite a
|
|
1544
|
-
// terminal state would flip completed → running (the bug this fixes).
|
|
1545
|
-
//
|
|
1546
|
-
// When the existing entry IS terminal, we still absorb live metadata
|
|
1547
|
-
// (alias, agent, read) but preserve the terminal state.
|
|
1548
|
-
if (existing.state !== 'running') {
|
|
1549
|
-
if (liveEntry.alias !== null && existing.alias !== incoming.alias) {
|
|
1550
|
-
existing.alias = incoming.alias
|
|
1551
|
-
}
|
|
1552
|
-
if (incoming.agent !== 'agent' && existing.agent !== incoming.agent) {
|
|
1553
|
-
existing.agent = incoming.agent
|
|
1554
|
-
}
|
|
1555
|
-
if (incoming.read && !existing.read) {
|
|
1556
|
-
existing.read = true
|
|
1557
|
-
}
|
|
1558
|
-
continue
|
|
1559
|
-
}
|
|
1560
|
-
result[index] = {
|
|
1561
|
-
...existing,
|
|
1562
|
-
alias: liveEntry.alias !== null ? incoming.alias : existing.alias,
|
|
1563
|
-
sessionID: existing.sessionID || incoming.sessionID,
|
|
1564
|
-
taskID: existing.taskID ?? incoming.taskID,
|
|
1565
|
-
agent: incoming.agent !== 'agent' ? incoming.agent : existing.agent,
|
|
1566
|
-
description: incoming.description !== '' ? incoming.description : existing.description,
|
|
1567
|
-
state: incoming.state,
|
|
1568
|
-
startedAt: Math.min(existing.startedAt, incoming.startedAt),
|
|
1569
|
-
updatedAt: incoming.updatedAt,
|
|
1570
|
-
read: incoming.read,
|
|
1571
|
-
source: 'live',
|
|
1572
|
-
}
|
|
1573
|
-
}
|
|
1574
|
-
|
|
1575
|
-
result.sort(compareDelegationEntries)
|
|
1576
|
-
return result
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
/* ─── Live tool-call lifecycle (agent-sidebar pattern) ─────
|
|
1580
|
-
* Source of truth for jobs born while opencode is open. The TUI event bus
|
|
1581
|
-
* (api.event.on) delivers `message.part.updated` for every part change;
|
|
1582
|
-
* we only care about tool parts whose `tool` is pantheon_delegate (job
|
|
1583
|
-
* launch), the native `task` subagent tool (same parentID === caller
|
|
1584
|
-
* mechanism — its rows render `nat:`), or pantheon_delegation_read
|
|
1585
|
-
* (blocking until terminal → closes the entry). Shape-adapted from the
|
|
1586
|
-
* SDK v2 ToolPart but duck-typed so the pure helpers are testable without
|
|
1587
|
-
* the SDK. */
|
|
1588
|
-
|
|
1589
|
-
/** Duck-typed subset of a tool part (SDK v2 `ToolPart` / `ToolState`). */
|
|
1590
|
-
export type DelegationToolPart = {
|
|
1591
|
-
id?: string
|
|
1592
|
-
callID?: string
|
|
1593
|
-
sessionID?: string
|
|
1594
|
-
type?: string
|
|
1595
|
-
tool?: string
|
|
1596
|
-
state?: {
|
|
1597
|
-
status?: string
|
|
1598
|
-
input?: Record<string, unknown>
|
|
1599
|
-
output?: string
|
|
1600
|
-
error?: string
|
|
1601
|
-
time?: { start?: number; end?: number }
|
|
1602
|
-
}
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
|
-
/** One live delegation tracked in-memory, keyed by the delegate callID. */
|
|
1606
|
-
export type LiveDelegationEntry = {
|
|
1607
|
-
/** Tool call id of the pantheon_delegate part (stable across events). */
|
|
1608
|
-
callID: string
|
|
1609
|
-
/** Part id (for message.part.removed cleanup). */
|
|
1610
|
-
partID: string
|
|
1611
|
-
/** Parent session the delegation was launched from. */
|
|
1612
|
-
sessionID: string
|
|
1613
|
-
tool: 'pantheon_delegate' | 'pantheon_delegation_read' | 'task'
|
|
1614
|
-
/** Agent name (from the delegate input args). */
|
|
1615
|
-
agent: string
|
|
1616
|
-
description: string
|
|
1617
|
-
/** Known after the delegate tool completes (parsed from its output). */
|
|
1618
|
-
alias: string | null
|
|
1619
|
-
/** Child session id (parsed from the delegate output). */
|
|
1620
|
-
taskID: string | null
|
|
1621
|
-
state: 'running' | 'completed' | 'error' | 'cancelled'
|
|
1622
|
-
startedAt: number
|
|
1623
|
-
updatedAt: number | null
|
|
1624
|
-
/** True once a pantheon_delegation_read for this job has been observed. */
|
|
1625
|
-
read: boolean
|
|
1626
|
-
}
|
|
1627
|
-
|
|
1628
|
-
/** Result of parsing one tool part into lifecycle-relevant fields. */
|
|
1629
|
-
export type ParsedDelegationToolPart = {
|
|
1630
|
-
callID: string
|
|
1631
|
-
partID: string
|
|
1632
|
-
sessionID: string
|
|
1633
|
-
tool: 'pantheon_delegate' | 'pantheon_delegation_read' | 'task'
|
|
1634
|
-
/** null for read parts (no agent arg — the id targets an existing job). */
|
|
1635
|
-
agent: string | null
|
|
1636
|
-
description: string
|
|
1637
|
-
status: 'pending' | 'running' | 'completed' | 'error'
|
|
1638
|
-
alias: string | null
|
|
1639
|
-
taskID: string | null
|
|
1640
|
-
startedAt: number
|
|
1641
|
-
endAt: number | null
|
|
1642
|
-
}
|
|
1643
|
-
|
|
1644
|
-
/** Alias in the delegate output: "Delegated to apollo: [apo-1] (task …)". */
|
|
1645
|
-
const DELEGATE_ALIAS_PATTERN = /\[([a-z]{2,8}-\d+)\]/i
|
|
1646
|
-
/** Child task id in the delegate output: "(task ses_child_9)". */
|
|
1647
|
-
const DELEGATE_TASKID_PATTERN = /\(task\s+([a-z0-9_]+)\)/i
|
|
1648
|
-
/** Plain alias, as passed to pantheon_delegation_read input.id: "apo-1". */
|
|
1649
|
-
const READ_ALIAS_PATTERN = /^[a-z]{2,8}-\d+$/i
|
|
1650
|
-
|
|
1651
|
-
/** Extract the tool name + args from a `message.part.updated` part and
|
|
1652
|
-
* reduce it to what the panel needs. Returns null for anything that is
|
|
1653
|
-
* not a pantheon delegation tool part, the native `task` subagent tool
|
|
1654
|
-
* (same parentID === caller mechanism — its children render `nat:`),
|
|
1655
|
-
* or is missing its callID. */
|
|
1656
|
-
export function parseDelegationToolPart(
|
|
1657
|
-
part: DelegationToolPart,
|
|
1658
|
-
now = Date.now(),
|
|
1659
|
-
): ParsedDelegationToolPart | null {
|
|
1660
|
-
if (part.type !== 'tool') return null
|
|
1661
|
-
if (
|
|
1662
|
-
part.tool !== 'pantheon_delegate' &&
|
|
1663
|
-
part.tool !== 'pantheon_delegation_read' &&
|
|
1664
|
-
part.tool !== 'task'
|
|
1665
|
-
)
|
|
1666
|
-
return null
|
|
1667
|
-
const callID = part.callID
|
|
1668
|
-
if (callID === undefined || callID === '') return null
|
|
1669
|
-
const sessionID = part.sessionID ?? ''
|
|
1670
|
-
const state = part.state ?? {}
|
|
1671
|
-
const status = state.status ?? 'pending'
|
|
1672
|
-
if (status !== 'pending' && status !== 'running' && status !== 'completed' && status !== 'error')
|
|
1673
|
-
return null
|
|
1674
|
-
const input = state.input ?? {}
|
|
1675
|
-
const startedAt = state.time?.start ?? now
|
|
1676
|
-
const endAt = state.time?.end ?? null
|
|
1677
|
-
|
|
1678
|
-
if (part.tool === 'pantheon_delegation_read') {
|
|
1679
|
-
const target = typeof input.id === 'string' ? input.id : null
|
|
1680
|
-
let alias: string | null = null
|
|
1681
|
-
let taskID: string | null = null
|
|
1682
|
-
if (target !== null) {
|
|
1683
|
-
if (READ_ALIAS_PATTERN.test(target)) alias = target
|
|
1684
|
-
else if (target.startsWith('ses_')) taskID = target // raw child session id
|
|
1685
|
-
}
|
|
1686
|
-
return {
|
|
1687
|
-
callID,
|
|
1688
|
-
partID: part.id ?? '',
|
|
1689
|
-
sessionID,
|
|
1690
|
-
tool: 'pantheon_delegation_read',
|
|
1691
|
-
agent: null,
|
|
1692
|
-
description: '',
|
|
1693
|
-
status,
|
|
1694
|
-
alias,
|
|
1695
|
-
taskID,
|
|
1696
|
-
startedAt,
|
|
1697
|
-
endAt,
|
|
1698
|
-
}
|
|
1699
|
-
}
|
|
1700
|
-
|
|
1701
|
-
const agent =
|
|
1702
|
-
typeof input.agent === 'string'
|
|
1703
|
-
? input.agent
|
|
1704
|
-
: typeof input.subagent_type === 'string'
|
|
1705
|
-
? (input.subagent_type as string)
|
|
1706
|
-
: 'agent'
|
|
1707
|
-
const description =
|
|
1708
|
-
typeof input.description === 'string'
|
|
1709
|
-
? input.description
|
|
1710
|
-
: typeof input.prompt === 'string'
|
|
1711
|
-
? (input.prompt as string).slice(0, 120)
|
|
1712
|
-
: ''
|
|
1713
|
-
// The alias/taskID only exist once the delegate tool COMPLETES (they are
|
|
1714
|
-
// returned in its output); a running/pending part has neither. Native
|
|
1715
|
-
// `task` parts never carry a board alias — the children channel supplies
|
|
1716
|
-
// the child session id, so the row renders `nat:`.
|
|
1717
|
-
let alias: string | null = null
|
|
1718
|
-
let taskID: string | null = null
|
|
1719
|
-
if (status === 'completed') {
|
|
1720
|
-
const output = state.output ?? ''
|
|
1721
|
-
alias = DELEGATE_ALIAS_PATTERN.exec(output)?.[1] ?? null
|
|
1722
|
-
taskID = DELEGATE_TASKID_PATTERN.exec(output)?.[1] ?? null
|
|
1723
|
-
}
|
|
1724
|
-
return {
|
|
1725
|
-
callID,
|
|
1726
|
-
partID: part.id ?? '',
|
|
1727
|
-
sessionID,
|
|
1728
|
-
tool: part.tool === 'task' ? 'task' : 'pantheon_delegate',
|
|
1729
|
-
agent,
|
|
1730
|
-
description,
|
|
1731
|
-
status,
|
|
1732
|
-
alias,
|
|
1733
|
-
taskID,
|
|
1734
|
-
startedAt,
|
|
1735
|
-
endAt,
|
|
1736
|
-
}
|
|
1737
|
-
}
|
|
1738
|
-
|
|
1739
|
-
/** Find a live entry by alias or taskID (read parts resolve by id). */
|
|
1740
|
-
function findLiveByTarget(
|
|
1741
|
-
map: Map<string, LiveDelegationEntry>,
|
|
1742
|
-
alias: string | null,
|
|
1743
|
-
taskID: string | null,
|
|
1744
|
-
): LiveDelegationEntry | undefined {
|
|
1745
|
-
if (alias === null && taskID === null) return undefined
|
|
1746
|
-
for (const entry of map.values()) {
|
|
1747
|
-
if (alias !== null && entry.alias === alias) return entry
|
|
1748
|
-
if (taskID !== null && entry.taskID === taskID) return entry
|
|
1749
|
-
}
|
|
1750
|
-
return undefined
|
|
1751
|
-
}
|
|
1752
|
-
|
|
1753
|
-
/** Apply one tool part to the live map. Returns true when the map changed.
|
|
1754
|
-
* Pure w.r.t. I/O — only mutates `map`. */
|
|
1755
|
-
export function reduceDelegationToolPart(
|
|
1756
|
-
map: Map<string, LiveDelegationEntry>,
|
|
1757
|
-
part: DelegationToolPart,
|
|
1758
|
-
now = Date.now(),
|
|
1759
|
-
): boolean {
|
|
1760
|
-
const parsed = parseDelegationToolPart(part, now)
|
|
1761
|
-
if (parsed === null) return false
|
|
1762
|
-
|
|
1763
|
-
// Read tool: never creates a row — it resolves a delegation by id. It
|
|
1764
|
-
// marks the target `read` and (because it blocks until terminal) closes
|
|
1765
|
-
// the entry on completed/error with its end timestamp.
|
|
1766
|
-
if (parsed.tool === 'pantheon_delegation_read') {
|
|
1767
|
-
const target = findLiveByTarget(map, parsed.alias, parsed.taskID)
|
|
1768
|
-
if (target === undefined) return false
|
|
1769
|
-
let changed = false
|
|
1770
|
-
if (!target.read) {
|
|
1771
|
-
target.read = true
|
|
1772
|
-
changed = true
|
|
1773
|
-
}
|
|
1774
|
-
if (parsed.status === 'completed' && target.state === 'running') {
|
|
1775
|
-
target.state = 'completed'
|
|
1776
|
-
target.updatedAt = parsed.endAt ?? now
|
|
1777
|
-
changed = true
|
|
1778
|
-
} else if (parsed.status === 'error' && target.state === 'running') {
|
|
1779
|
-
target.state = 'error'
|
|
1780
|
-
target.updatedAt = parsed.endAt ?? now
|
|
1781
|
-
changed = true
|
|
1782
|
-
}
|
|
1783
|
-
return changed
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
// Delegate tool (pantheon_delegate AND native task): pending/running →
|
|
1787
|
-
// job launched (running). A COMPLETED delegate tool only means the job was
|
|
1788
|
-
// registered — it stays running and we pick up alias + taskID from the
|
|
1789
|
-
// output. Native `task` parts never yield a board alias, so their rows
|
|
1790
|
-
// stay `nat:` until the children channel resolves them.
|
|
1791
|
-
const existing = map.get(parsed.callID)
|
|
1792
|
-
if (parsed.status === 'error') {
|
|
1793
|
-
if (existing !== undefined && existing.state === 'error' && existing.updatedAt === parsed.endAt)
|
|
1794
|
-
return false
|
|
1795
|
-
map.set(parsed.callID, {
|
|
1796
|
-
callID: parsed.callID,
|
|
1797
|
-
partID: parsed.partID,
|
|
1798
|
-
sessionID: parsed.sessionID,
|
|
1799
|
-
tool: parsed.tool,
|
|
1800
|
-
agent: parsed.agent ?? 'agent',
|
|
1801
|
-
description: parsed.description,
|
|
1802
|
-
alias: existing?.alias ?? null,
|
|
1803
|
-
taskID: existing?.taskID ?? null,
|
|
1804
|
-
state: 'error',
|
|
1805
|
-
startedAt: existing?.startedAt ?? parsed.startedAt,
|
|
1806
|
-
updatedAt: parsed.endAt ?? now,
|
|
1807
|
-
read: existing?.read ?? false,
|
|
1808
|
-
})
|
|
1809
|
-
return true
|
|
1810
|
-
}
|
|
1811
|
-
if (existing === undefined) {
|
|
1812
|
-
map.set(parsed.callID, {
|
|
1813
|
-
callID: parsed.callID,
|
|
1814
|
-
partID: parsed.partID,
|
|
1815
|
-
sessionID: parsed.sessionID,
|
|
1816
|
-
tool: parsed.tool,
|
|
1817
|
-
agent: parsed.agent ?? 'agent',
|
|
1818
|
-
description: parsed.description,
|
|
1819
|
-
alias: parsed.alias,
|
|
1820
|
-
taskID: parsed.taskID,
|
|
1821
|
-
state: 'running',
|
|
1822
|
-
startedAt: parsed.startedAt,
|
|
1823
|
-
updatedAt: null,
|
|
1824
|
-
read: false,
|
|
1825
|
-
})
|
|
1826
|
-
return true
|
|
1827
|
-
}
|
|
1828
|
-
// Existing entry: absorb newly-discovered alias/taskID/agent/description.
|
|
1829
|
-
let changed = false
|
|
1830
|
-
if (parsed.alias !== null && existing.alias !== parsed.alias) {
|
|
1831
|
-
existing.alias = parsed.alias
|
|
1832
|
-
changed = true
|
|
1833
|
-
}
|
|
1834
|
-
if (parsed.taskID !== null && existing.taskID !== parsed.taskID) {
|
|
1835
|
-
existing.taskID = parsed.taskID
|
|
1836
|
-
changed = true
|
|
1837
|
-
}
|
|
1838
|
-
if (parsed.agent !== null && existing.agent !== parsed.agent) {
|
|
1839
|
-
existing.agent = parsed.agent
|
|
1840
|
-
changed = true
|
|
1841
|
-
}
|
|
1842
|
-
if (parsed.description !== '' && existing.description !== parsed.description) {
|
|
1843
|
-
existing.description = parsed.description
|
|
1844
|
-
changed = true
|
|
1845
|
-
}
|
|
1846
|
-
if (existing.partID === '' && parsed.partID !== '') {
|
|
1847
|
-
existing.partID = parsed.partID
|
|
1848
|
-
changed = true
|
|
1849
|
-
}
|
|
1850
|
-
return changed
|
|
1851
|
-
}
|
|
1852
|
-
|
|
1853
|
-
/** Remove a live entry by part id (message.part.removed) or call id.
|
|
1854
|
-
* Returns true when something was removed. */
|
|
1855
|
-
export function removeDelegationEntry(
|
|
1856
|
-
map: Map<string, LiveDelegationEntry>,
|
|
1857
|
-
partIDOrCallID: string,
|
|
1858
|
-
): boolean {
|
|
1859
|
-
if (map.delete(partIDOrCallID)) return true
|
|
1860
|
-
for (const [key, entry] of map) {
|
|
1861
|
-
if (entry.partID === partIDOrCallID) {
|
|
1862
|
-
map.delete(key)
|
|
1863
|
-
return true
|
|
1864
|
-
}
|
|
1865
|
-
}
|
|
1866
|
-
return false
|
|
1867
|
-
}
|
|
1868
|
-
|
|
1869
|
-
/** Collect pantheon delegation + native task tool parts from a session's messages.
|
|
1870
|
-
* Messages may carry their parts inline (duck-typed `msg.parts`); when
|
|
1871
|
-
* they don't, the optional `getParts(messageID)` callback is used (the TUI
|
|
1872
|
-
* SDK exposes `api.state.part(messageID)`). The native `task` tool spawns a
|
|
1873
|
-
* child session with parentID = caller — the same mechanism as
|
|
1874
|
-
* pantheon_delegate — so its parts feed the live-map as the native signal
|
|
1875
|
-
* (rows render `nat:` via the children channel). Pure w.r.t. I/O — used
|
|
1876
|
-
* by the mount re-scan to re-seed the live map after compaction/attach. */
|
|
1877
|
-
export function collectDelegationToolParts(
|
|
1878
|
-
messages: readonly { id?: string; parts?: unknown[] }[] | undefined,
|
|
1879
|
-
getParts?: (messageID: string) => readonly unknown[] | undefined,
|
|
1880
|
-
): DelegationToolPart[] {
|
|
1881
|
-
const out: DelegationToolPart[] = []
|
|
1882
|
-
for (const msg of messages ?? []) {
|
|
1883
|
-
let parts: readonly unknown[] | undefined
|
|
1884
|
-
if (Array.isArray(msg?.parts)) {
|
|
1885
|
-
parts = msg.parts
|
|
1886
|
-
} else if (msg?.id !== undefined && typeof getParts === 'function') {
|
|
1887
|
-
parts = getParts(msg.id)
|
|
1888
|
-
}
|
|
1889
|
-
if (!parts) continue
|
|
1890
|
-
for (const raw of parts) {
|
|
1891
|
-
const part = raw as DelegationToolPart
|
|
1892
|
-
if (
|
|
1893
|
-
part?.type === 'tool' &&
|
|
1894
|
-
(part.tool === 'pantheon_delegate' ||
|
|
1895
|
-
part.tool === 'pantheon_delegation_read' ||
|
|
1896
|
-
part.tool === 'task')
|
|
1897
|
-
) {
|
|
1898
|
-
out.push(part)
|
|
1899
|
-
}
|
|
1900
|
-
}
|
|
1901
|
-
}
|
|
1902
|
-
return out
|
|
1903
|
-
}
|
|
1904
|
-
|
|
1905
|
-
/** Apply a batch of tool parts (in message order) to the live map. Used on
|
|
1906
|
-
* mount to re-seed entries that `message.part.removed` (compaction) wiped,
|
|
1907
|
-
* from the session's existing tool parts. Returns how many parts changed
|
|
1908
|
-
* the map (0 on the second identical seed — idempotent, no extra bumps). */
|
|
1909
|
-
export function seedLiveDelegationMap(
|
|
1910
|
-
map: Map<string, LiveDelegationEntry>,
|
|
1911
|
-
parts: readonly DelegationToolPart[],
|
|
1912
|
-
now = Date.now(),
|
|
1913
|
-
): number {
|
|
1914
|
-
let changed = 0
|
|
1915
|
-
for (const part of parts) {
|
|
1916
|
-
if (reduceDelegationToolPart(map, part, now)) changed++
|
|
1917
|
-
}
|
|
1918
|
-
return changed
|
|
1919
|
-
}
|
|
1920
|
-
|
|
1921
|
-
/** Convert a live entry into the shared display shape. Alias falls back to
|
|
1922
|
-
* a `live-<callID>` prefix while the delegate tool has not completed yet. */
|
|
1923
|
-
export function toDelegationEntry(live: LiveDelegationEntry): DelegationEntry {
|
|
1924
|
-
return {
|
|
1925
|
-
alias: live.alias ?? `live-${live.callID.slice(0, 8)}`,
|
|
1926
|
-
sessionID: live.sessionID,
|
|
1927
|
-
...(live.taskID !== null ? { taskID: live.taskID } : {}),
|
|
1928
|
-
agent: live.agent,
|
|
1929
|
-
state: live.state,
|
|
1930
|
-
startedAt: live.startedAt,
|
|
1931
|
-
updatedAt: live.updatedAt,
|
|
1932
|
-
timedOut: false,
|
|
1933
|
-
description: live.description,
|
|
1934
|
-
read: live.read,
|
|
1935
|
-
source: 'live',
|
|
1936
|
-
}
|
|
1937
|
-
}
|
|
1938
|
-
|
|
1939
|
-
/** Combine the live channel with the md (historical) channel into one
|
|
1940
|
-
* display list. Dedupes by (sessionID, alias) — aliases are per-parent-
|
|
1941
|
-
* session, so the same alias in different sessions stays separate. A
|
|
1942
|
-
* terminal md entry is authoritative over a live running entry for the
|
|
1943
|
-
* same job (it carries Finalized/timedOut/cancelled from finalize). */
|
|
1944
|
-
export function mergeDelegationSources(
|
|
1945
|
-
live: readonly LiveDelegationEntry[],
|
|
1946
|
-
md: readonly DelegationEntry[],
|
|
1947
|
-
): DelegationEntry[] {
|
|
1948
|
-
const keyOf = (sessionID: string, alias: string): string => `${sessionID}\u0000${alias}`
|
|
1949
|
-
const byKey = new Map<string, DelegationEntry>()
|
|
1950
|
-
for (const m of md) {
|
|
1951
|
-
const key = keyOf(m.sessionID, m.alias)
|
|
1952
|
-
const existing = byKey.get(key)
|
|
1953
|
-
if (existing === undefined || (existing.state === 'running' && m.state !== 'running'))
|
|
1954
|
-
byKey.set(key, m)
|
|
1955
|
-
}
|
|
1956
|
-
const aliasless: DelegationEntry[] = []
|
|
1957
|
-
for (const l of live) {
|
|
1958
|
-
const e = toDelegationEntry(l)
|
|
1959
|
-
if (l.alias === null) {
|
|
1960
|
-
aliasless.push(e)
|
|
1961
|
-
continue
|
|
1962
|
-
}
|
|
1963
|
-
const key = keyOf(l.sessionID, l.alias)
|
|
1964
|
-
const mdEntry = byKey.get(key)
|
|
1965
|
-
if (mdEntry !== undefined && mdEntry.state !== 'running') continue // md terminal wins
|
|
1966
|
-
byKey.set(key, e)
|
|
1967
|
-
}
|
|
1968
|
-
const all = [...aliasless, ...byKey.values()]
|
|
1969
|
-
all.sort(compareDelegationEntries)
|
|
1970
|
-
return all
|
|
1971
|
-
}
|
|
1972
|
-
|
|
1973
|
-
/* ─── Children channel (primary source, delegations-sidebar pattern) ────
|
|
1974
|
-
* `api.client.session.children` returns the child sessions of the current
|
|
1975
|
-
* session — every pantheon_delegate spawns one (parentID = caller). The
|
|
1976
|
-
* pure helpers here turn those children + the md reports into the display
|
|
1977
|
-
* list, and navigate to a child session on click. */
|
|
1978
|
-
|
|
1979
|
-
/* ─── Current-session resolution + path guard (placeholder regression) ──
|
|
1980
|
-
* The sidebar forwarded `props.session_id` verbatim into
|
|
1981
|
-
* `session.children({ path: { id } })`. When the TUI runtime renders the
|
|
1982
|
-
* sidebar without a focused session it leaves the route template
|
|
1983
|
-
* UNSUBSTITUTED — the literal "{sessionID}" string — which opencode's server
|
|
1984
|
-
* rejected every poll (SchemaError: Expected a string starting with "ses",
|
|
1985
|
-
* got "%7BsessionID%7D"), ~1 error/s from the 1s safety poll, and the failed
|
|
1986
|
-
* call fail-opened into "Delegations (0)".
|
|
1987
|
-
*
|
|
1988
|
-
* Contract: resolution NEVER yields a placeholder — an invalid/absent id is
|
|
1989
|
-
* null, and a null resolution means the children fetch is SKIPPED (empty
|
|
1990
|
-
* panel, ZERO errors). Sources, in order:
|
|
1991
|
-
* 1. the sidebar_content slot prop (`session_id`);
|
|
1992
|
-
* 2. `api.state.sessionID` (duck-typed: the typed TuiState has no such
|
|
1993
|
-
* field, but the runtime state is a superset — the delegations-sidebar
|
|
1994
|
-
* reference reads the current session from api.state);
|
|
1995
|
-
* 3. `api.route.current.params.sessionID` (typed: TuiRouteCurrent's
|
|
1996
|
-
* `{ name: 'session', params: { sessionID } }` variant).
|
|
1997
|
-
* Each source passes the same server-aligned validity check (non-empty
|
|
1998
|
-
* string starting with "ses"), so a placeholder or garbage at any position
|
|
1999
|
-
* falls through to the next source instead of hitting the wire. */
|
|
2000
|
-
|
|
2001
|
-
/** Server-aligned session id validity: opencode rejects anything not starting
|
|
2002
|
-
* with "ses" (SchemaError). This deliberately mirrors that exact contract —
|
|
2003
|
-
* nothing stricter, nothing looser — so a template placeholder ("{sessionID}"),
|
|
2004
|
-
* its URL-encoded form ("%7BsessionID%7D", what the server reported in the
|
|
2005
|
-
* schema error), an empty/undefined value, or a foreign id (e.g. "wrk_") can
|
|
2006
|
-
* never reach a path and error-spam the log. Confirmed: "{sessionID}" starts
|
|
2007
|
-
* with "{" and "%7BsessionID%7D" with "%" — both fail startsWith("ses"), so
|
|
2008
|
-
* the placeholder is rejected WITHOUT an explicit denylist (covered by tests). */
|
|
2009
|
-
export function isValidSessionId(id: unknown): id is string {
|
|
2010
|
-
return typeof id === 'string' && id.startsWith('ses')
|
|
2011
|
-
}
|
|
2012
|
-
|
|
2013
|
-
/** Sources the sidebar can resolve the CURRENT session id from. Duck-typed
|
|
2014
|
-
* subsets of TuiPluginApi / TuiState / TuiRouteCurrent so the helper stays
|
|
2015
|
-
* pure and testable without the TUI runtime. */
|
|
2016
|
-
export type TuiSessionSources = {
|
|
2017
|
-
/** sidebar_content slot prop (`session_id`). */
|
|
2018
|
-
sessionID?: string | null
|
|
2019
|
-
api?: {
|
|
2020
|
-
/** Runtime state superset — may expose the current session id. */
|
|
2021
|
-
state?: { sessionID?: unknown }
|
|
2022
|
-
/** Typed route: { name: 'session', params: { sessionID } } when in one. */
|
|
2023
|
-
route?: { current?: { name?: string; params?: Record<string, unknown> } }
|
|
2024
|
-
} | null
|
|
2025
|
-
}
|
|
2026
|
-
|
|
2027
|
-
/** Resolve the current session id for the sidebar. Order: slot prop →
|
|
2028
|
-
* api.state.sessionID (runtime superset) → api.route.current.params.sessionID
|
|
2029
|
-
* (typed route). Every source is validated; invalid/absent → next source.
|
|
2030
|
-
* NEVER returns a placeholder or non-ses id. Null → callers MUST skip the
|
|
2031
|
-
* fetch (empty panel, zero errors). Pure — no I/O, no runtime required. */
|
|
2032
|
-
export function resolveCurrentSessionID(sources: TuiSessionSources): string | null {
|
|
2033
|
-
const candidates: unknown[] = [
|
|
2034
|
-
sources?.sessionID,
|
|
2035
|
-
sources?.api?.state?.sessionID,
|
|
2036
|
-
sources?.api?.route?.current?.params?.sessionID,
|
|
2037
|
-
]
|
|
2038
|
-
for (const candidate of candidates) {
|
|
2039
|
-
if (isValidSessionId(candidate)) return candidate
|
|
2040
|
-
}
|
|
2041
|
-
return null
|
|
2042
|
-
}
|
|
2043
|
-
|
|
2044
|
-
/** THE single choke point for every `session.children` / session-API path.
|
|
2045
|
-
* Returns `{ path: { id } }` ONLY for a server-valid session id; returns
|
|
2046
|
-
* null for anything else (placeholder, empty, foreign id) so the caller
|
|
2047
|
-
* skips the call entirely instead of sending an unsubstituted placeholder
|
|
2048
|
-
* (the "%7BsessionID%7D" regression). Every session-API call site MUST go
|
|
2049
|
-
* through this function (enforced by the source-scan test in
|
|
2050
|
-
* tests/pantheon/tui-delegations.test.ts). */
|
|
2051
|
-
export function safeSessionPath(id: unknown): { path: { id: string } } | null {
|
|
2052
|
-
if (!isValidSessionId(id)) return null
|
|
2053
|
-
return { path: { id } }
|
|
2054
|
-
}
|
|
2055
|
-
|
|
2056
|
-
/** Build the `session.children` path ONLY from a validated session id.
|
|
2057
|
-
* Delegates to {@link safeSessionPath} — the single choke point. Returns
|
|
2058
|
-
* null for null/invalid ids so the caller skips the fetch instead of
|
|
2059
|
-
* sending an unsubstituted placeholder (the "%7BsessionID%7D" regression). */
|
|
2060
|
-
export function buildChildrenPath(id: string | null | undefined): { path: { id: string } } | null {
|
|
2061
|
-
return safeSessionPath(id)
|
|
2062
|
-
}
|
|
2063
|
-
|
|
2064
|
-
/** Duck-typed subset of a child Session (+ its live status type). */
|
|
2065
|
-
export type ChildDelegationLike = {
|
|
2066
|
-
/** Child session id (= board task id). */
|
|
2067
|
-
id: string
|
|
2068
|
-
/** Session title — the delegate's description or prompt prefix. */
|
|
2069
|
-
title?: string
|
|
2070
|
-
/** Agent name when the child session carries one (duck-typed; native
|
|
2071
|
-
* task() children may expose it, board reports always do). */
|
|
2072
|
-
agent?: string
|
|
2073
|
-
/** Status type from api.state.session.status: 'busy' | 'retry' | 'idle',
|
|
2074
|
-
* or undefined when the status API is unavailable. */
|
|
2075
|
-
status?: string
|
|
2076
|
-
time?: {
|
|
2077
|
-
created?: number
|
|
2078
|
-
updated?: number
|
|
2079
|
-
}
|
|
2080
|
-
}
|
|
2081
|
-
|
|
2082
|
-
/** Map a child status type to a display state. busy/retry → running
|
|
2083
|
-
* (the child is actively working), idle → completed, unknown → running
|
|
2084
|
-
* (fail-open: a freshly-seen child is assumed active; the 1s poll + md
|
|
2085
|
-
* correct it as soon as terminal data exists). */
|
|
2086
|
-
export function childStatusToState(status: string | undefined): 'running' | 'completed' | 'retry' {
|
|
2087
|
-
if (status === 'idle') return 'completed'
|
|
2088
|
-
if (status === 'retry') return 'retry'
|
|
2089
|
-
return 'running' // busy or unknown
|
|
2090
|
-
}
|
|
2091
|
-
|
|
2092
|
-
/** Short row prefix for a delegation entry (one of the two visual channels
|
|
2093
|
-
* that split native task() work from pantheon_delegate jobs — the other is
|
|
2094
|
-
* {@link delegationIcon}):
|
|
2095
|
-
*
|
|
2096
|
-
* - `nat:<agent>` — a native task() child (source 'children-only', no board
|
|
2097
|
-
* report). The child session carries no board alias, so the agent IS the
|
|
2098
|
-
* identity.
|
|
2099
|
-
* - `pan:<alias>` — a pantheon_delegate board row (`pan:apo-1`), the same
|
|
2100
|
-
* alias the manager prints in pantheon_delegation_list.
|
|
2101
|
-
*
|
|
2102
|
-
* Short prefixes keep the narrow sidebar readable (the old
|
|
2103
|
-
* `pan:apo-1`/`nat:` tags ate the row width). */
|
|
2104
|
-
export function delegationTag(entry: DelegationEntry): string {
|
|
2105
|
-
return entry.source === 'children-only' ? `nat:${entry.agent}` : `pan:${entry.alias}`
|
|
2106
|
-
}
|
|
2107
|
-
|
|
2108
|
-
/** Row glyph: hollow diamond `◇` for native task() children (info color,
|
|
2109
|
-
* outline) vs filled diamond `◆` for pantheon_delegate rows (state color).
|
|
2110
|
-
* Shape + color are independent channels, so the split stays legible even
|
|
2111
|
-
* without color. */
|
|
2112
|
-
export function delegationIcon(entry: DelegationEntry): string {
|
|
2113
|
-
return entry.source === 'children-only' ? '\u25c7' : '\u25c6'
|
|
2114
|
-
}
|
|
2115
|
-
|
|
2116
|
-
/** Row identity after the status marker + glyph. Pantheon rows append the
|
|
2117
|
-
* agent (`pan:apo-1 apollo`); native rows already carry it inside the tag
|
|
2118
|
-
* (`nat:apollo`) and must not duplicate it. */
|
|
2119
|
-
export function formatDelegationIdentity(entry: DelegationEntry): string {
|
|
2120
|
-
return entry.source === 'children-only'
|
|
2121
|
-
? delegationTag(entry)
|
|
2122
|
-
: `${delegationTag(entry)} ${entry.agent}`
|
|
2123
|
-
}
|
|
2124
|
-
|
|
2125
|
-
/** Max description width on the row detail line — the old 180-char slice
|
|
2126
|
-
* wrapped the sidebar; 44 keeps one readable line. */
|
|
2127
|
-
export const DELEGATION_DESCRIPTION_MAX = 44
|
|
2128
|
-
|
|
2129
|
-
/** Lazily-built grapheme segmenter. `Intl.Segmenter` keeps ZWJ emoji families
|
|
2130
|
-
* (and skin-tone/variation sequences) intact; `Array.from` (code points) is
|
|
2131
|
-
* the fallback for runtimes without it. */
|
|
2132
|
-
let graphemeSegmenter: Intl.Segmenter | undefined
|
|
2133
|
-
|
|
2134
|
-
/** Split `text` into display graphemes so truncation never cuts a multi-unit
|
|
2135
|
-
* emoji in half (surrogate pair → mojibake). */
|
|
2136
|
-
function splitGraphemes(text: string): string[] {
|
|
2137
|
-
if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
|
|
2138
|
-
graphemeSegmenter ??= new Intl.Segmenter('pt', { granularity: 'grapheme' })
|
|
2139
|
-
return Array.from(graphemeSegmenter.segment(text), (s) => s.segment)
|
|
2140
|
-
}
|
|
2141
|
-
return Array.from(text)
|
|
2142
|
-
}
|
|
2143
|
-
|
|
2144
|
-
/** Truncate a description to `max` graphemes appending `…` when cut. Exact
|
|
2145
|
-
* `max`-length text is left untouched. Grapheme-granular, so an emoji made of
|
|
2146
|
-
* several code points is never split into mojibake at the boundary. Pure. */
|
|
2147
|
-
export function truncateDelegationDescription(
|
|
2148
|
-
text: string,
|
|
2149
|
-
max = DELEGATION_DESCRIPTION_MAX,
|
|
2150
|
-
): string {
|
|
2151
|
-
const graphemes = splitGraphemes(text)
|
|
2152
|
-
if (graphemes.length <= max) return text
|
|
2153
|
-
return `${graphemes.slice(0, Math.max(0, max - 1)).join('')}\u2026`
|
|
2154
|
-
}
|
|
2155
|
-
|
|
2156
|
-
/** Fixed elapsed-time box: right-aligned to `width` (default 8) so elapsed
|
|
2157
|
-
* values line up across rows, e.g. ` 12s`. A label that already fills
|
|
2158
|
-
* the box is returned untouched. Pure. */
|
|
2159
|
-
export const DELEGATION_ELAPSED_WIDTH = 8
|
|
2160
|
-
|
|
2161
|
-
export function formatDelegationElapsed(
|
|
2162
|
-
entry: DelegationEntry,
|
|
2163
|
-
now: number,
|
|
2164
|
-
width = DELEGATION_ELAPSED_WIDTH,
|
|
2165
|
-
): string {
|
|
2166
|
-
const label = delegationElapsed(entry, now)
|
|
2167
|
-
if (label.length >= width) return label
|
|
2168
|
-
return `${' '.repeat(width - label.length)}${label}`
|
|
2169
|
-
}
|
|
2170
|
-
|
|
2171
|
-
/** First row line: `<marker><glyph> <identity>`. The marker already carries
|
|
2172
|
-
* its trailing space. Pure — the row renders the returned string verbatim. */
|
|
2173
|
-
export function formatDelegationRow(entry: DelegationEntry, marker: string): string {
|
|
2174
|
-
return `${marker}${delegationIcon(entry)} ${formatDelegationIdentity(entry)}`
|
|
2175
|
-
}
|
|
2176
|
-
|
|
2177
|
-
// Tree connectors (`isNestedDelegation` / `delegationTreePrefix`) were removed
|
|
2178
|
-
// as dead code: `session.children` returns direct children only, so no row is
|
|
2179
|
-
// ever actually nested and the connectors were never populated in production.
|
|
2180
|
-
// BACKLOG: produce real grandchild rows first, then reintroduce them.
|
|
2181
|
-
|
|
2182
|
-
/** Header summary: `(N active · M done · nat:K pan:M)`. Active counts
|
|
2183
|
-
* running/retry AND display-only stale-running (a stale row is still a live
|
|
2184
|
-
* job — it must never read as done). Pure. */
|
|
2185
|
-
export function formatDelegationHeader(entries: readonly DelegationEntry[]): string {
|
|
2186
|
-
let active = 0
|
|
2187
|
-
for (const e of entries) {
|
|
2188
|
-
if (e.state === 'running' || e.state === 'retry' || e.state === 'stale-running') active++
|
|
2189
|
-
}
|
|
2190
|
-
const done = entries.length - active
|
|
2191
|
-
const counts = countDelegationSources(entries)
|
|
2192
|
-
return `(${active} active \u00b7 ${done} done \u00b7 nat:${counts.native} pan:${counts.pantheon})`
|
|
2193
|
-
}
|
|
2194
|
-
|
|
2195
|
-
/** Split a display list into native task() rows vs pantheon_delegate rows.
|
|
2196
|
-
* Native = source 'children-only' (no board report); everything else counts
|
|
2197
|
-
* as pantheon. Pure — powers the header breakdown + the hooks.log line. */
|
|
2198
|
-
export function countDelegationSources(entries: readonly DelegationEntry[]): {
|
|
2199
|
-
native: number
|
|
2200
|
-
pantheon: number
|
|
2201
|
-
total: number
|
|
2202
|
-
} {
|
|
2203
|
-
let native = 0
|
|
2204
|
-
for (const e of entries) if (e.source === 'children-only') native++
|
|
2205
|
-
return { native, pantheon: entries.length - native, total: entries.length }
|
|
2206
|
-
}
|
|
2207
|
-
|
|
2208
|
-
/** Diagnostic hooks.log line for a panel re-fetch, with the children
|
|
2209
|
-
* breakdown (pantheon = children WITH a board report, native = children
|
|
2210
|
-
* WITHOUT one). Pure — the View logs the returned string verbatim. */
|
|
2211
|
-
export function formatPanelLogLine(
|
|
2212
|
-
children: number,
|
|
2213
|
-
pantheon: number,
|
|
2214
|
-
native: number,
|
|
2215
|
-
md: number,
|
|
2216
|
-
events: number,
|
|
2217
|
-
): string {
|
|
2218
|
-
return `panel: children=${children}(pantheon=${pantheon} native=${native}) md=${md} events=${events}`
|
|
2219
|
-
}
|
|
2220
|
-
|
|
2221
|
-
/** Turn child sessions (PRIMARY) enriched with md reports into the display
|
|
2222
|
-
* list. One entry per child id (duplicates across re-fetches collapse).
|
|
2223
|
-
* The md report is matched by `Task ID` (== child.id) and supplies alias,
|
|
2224
|
-
* agent, description, terminal state and duration. A child without a
|
|
2225
|
-
* report still renders: description from its title, agent from the child
|
|
2226
|
-
* itself (fallback 'agent'), state derived from its status, startedAt from
|
|
2227
|
-
* time.created. A report-less child is a NATIVE task() child (every
|
|
2228
|
-
* child of the current session — pantheon_delegate OR the native `task()`
|
|
2229
|
-
* tool — carries parentID = caller), so it gets source 'children-only',
|
|
2230
|
-
* the internal alias 'native-task' and the `nat:` tag instead of a
|
|
2231
|
-
* board alias. The 'task nativa' description fallback keeps the row
|
|
2232
|
-
* non-empty when the child carries no title.
|
|
2233
|
-
* Terminal md state wins over the derived state; a running md defers to
|
|
2234
|
-
* the child's live status. A running child is NEVER archived — it always
|
|
2235
|
-
* lands in the active split (splitDelegationList active = running/retry).
|
|
2236
|
-
* Sorted running-first (compareDelegationEntries).
|
|
2237
|
-
* Pure — no I/O. */
|
|
2238
|
-
export function childrenToDelegationEntries(
|
|
2239
|
-
children: readonly ChildDelegationLike[] | undefined,
|
|
2240
|
-
md: readonly DelegationEntry[],
|
|
2241
|
-
now = Date.now(),
|
|
2242
|
-
): DelegationEntry[] {
|
|
2243
|
-
const byTaskID = new Map<string, DelegationEntry>()
|
|
2244
|
-
for (const m of md) {
|
|
2245
|
-
if (m.taskID !== undefined && !byTaskID.has(m.taskID)) byTaskID.set(m.taskID, m)
|
|
2246
|
-
}
|
|
2247
|
-
const out: DelegationEntry[] = []
|
|
2248
|
-
const seen = new Set<string>()
|
|
2249
|
-
for (const child of children ?? []) {
|
|
2250
|
-
if (child.id === '' || seen.has(child.id)) continue
|
|
2251
|
-
seen.add(child.id)
|
|
2252
|
-
const mdEntry = byTaskID.get(child.id)
|
|
2253
|
-
const state =
|
|
2254
|
-
mdEntry !== undefined && mdEntry.state !== 'running'
|
|
2255
|
-
? mdEntry.state
|
|
2256
|
-
: childStatusToState(child.status)
|
|
2257
|
-
out.push({
|
|
2258
|
-
alias: mdEntry?.alias ?? 'native-task',
|
|
2259
|
-
sessionID: mdEntry?.sessionID ?? '',
|
|
2260
|
-
taskID: child.id,
|
|
2261
|
-
agent: mdEntry?.agent ?? child.agent ?? 'agent',
|
|
2262
|
-
state,
|
|
2263
|
-
startedAt: mdEntry?.startedAt ?? child.time?.created ?? now,
|
|
2264
|
-
updatedAt:
|
|
2265
|
-
mdEntry?.updatedAt ??
|
|
2266
|
-
(state === 'running' || state === 'stale-running' ? null : (child.time?.updated ?? null)),
|
|
2267
|
-
timedOut: mdEntry?.timedOut ?? false,
|
|
2268
|
-
description:
|
|
2269
|
-
mdEntry !== undefined && mdEntry.description !== ''
|
|
2270
|
-
? mdEntry.description
|
|
2271
|
-
: child.title !== undefined && child.title !== ''
|
|
2272
|
-
? child.title
|
|
2273
|
-
: 'task nativa',
|
|
2274
|
-
source: mdEntry !== undefined ? 'md' : 'children-only',
|
|
2275
|
-
})
|
|
2276
|
-
}
|
|
2277
|
-
out.sort(compareDelegationEntries)
|
|
2278
|
-
return out
|
|
2279
|
-
}
|
|
2280
|
-
|
|
2281
|
-
/** Navigate the TUI to a child session (click/Enter on a delegation row).
|
|
2282
|
-
* Returns false when the route API is unavailable or the target id is
|
|
2283
|
-
* missing/placeholder — the row stays inert instead of crashing. Only a
|
|
2284
|
-
* server-valid session id ("ses...") ever reaches the router, so an
|
|
2285
|
-
* unsubstituted "{sessionID}" placeholder can never be routed. */
|
|
2286
|
-
export function navigateToDelegationSession(
|
|
2287
|
-
route: { navigate?: (name: string, params?: Record<string, unknown>) => void } | undefined,
|
|
2288
|
-
taskID: string | undefined,
|
|
2289
|
-
): boolean {
|
|
2290
|
-
if (typeof route?.navigate !== 'function' || !isValidSessionId(taskID)) return false
|
|
2291
|
-
route.navigate('session', { sessionID: taskID })
|
|
2292
|
-
return true
|
|
2293
|
-
}
|
|
2294
|
-
|
|
2295
|
-
/* ─── Silence-by-default panel logger ──────────────────────
|
|
2296
|
-
* Mirrors src/pantheon/logger.ts createPantheonLogger (the TUI plugin is
|
|
2297
|
-
* self-contained — dist/tui.tsx is a raw copy with no relative imports):
|
|
2298
|
-
* every line is ALWAYS appended to `<logDir>/.pantheon/logs/hooks.log` and
|
|
2299
|
-
* echoed to the console ONLY when PANTHEON_HOOKS_LOG is truthy (opencode
|
|
2300
|
-
* renders plugin console output directly into the TUI — the pollution bug).
|
|
2301
|
-
* Best-effort and fire-and-forget: a logging failure must never break the
|
|
2302
|
-
* panel. */
|
|
2303
|
-
type TuiLogger = { info: (message: string, ...args: unknown[]) => void }
|
|
2304
|
-
|
|
2305
|
-
/** Silence-by-default panel logger.
|
|
2306
|
-
* @param projectRoot the PROJECT ROOT — the logger appends to
|
|
2307
|
-
* `<projectRoot>/.pantheon/logs/hooks.log` (tuiLogPath). Passing anything
|
|
2308
|
-
* deeper (e.g. the delegations dir) used to double-nest the path into
|
|
2309
|
-
* `<root>/.pantheon/.pantheon/logs/hooks.log` and never reach the real log;
|
|
2310
|
-
* derive the root with {@link panelLogDir}. */
|
|
2311
|
-
function createTuiLogger(projectRoot: string | undefined, module = 'pantheon-tui'): TuiLogger {
|
|
2312
|
-
const echo = (process.env.PANTHEON_HOOKS_LOG ?? '').trim() !== ''
|
|
2313
|
-
const logPath = tuiLogPath(projectRoot ?? process.cwd())
|
|
2314
|
-
const formatArg = (arg: unknown): string => {
|
|
2315
|
-
if (arg instanceof Error) return arg.stack ?? arg.message
|
|
2316
|
-
if (typeof arg === 'string') return arg
|
|
2317
|
-
try {
|
|
2318
|
-
return JSON.stringify(arg)
|
|
2319
|
-
} catch {
|
|
2320
|
-
return String(arg)
|
|
2321
|
-
}
|
|
2322
|
-
}
|
|
2323
|
-
const write = (message: string, args: unknown[]) => {
|
|
2324
|
-
const lines = [message, ...args.map(formatArg)]
|
|
2325
|
-
.join(' ')
|
|
2326
|
-
.split('\n')
|
|
2327
|
-
.map((p) => p.trim())
|
|
2328
|
-
.filter((p) => p !== '')
|
|
2329
|
-
if (lines.length === 0) return
|
|
2330
|
-
void (async () => {
|
|
2331
|
-
try {
|
|
2332
|
-
await mkdir(dirname(logPath), { recursive: true })
|
|
2333
|
-
const stamp = new Date().toISOString()
|
|
2334
|
-
await appendFile(
|
|
2335
|
-
logPath,
|
|
2336
|
-
`${lines.map((l) => `[${stamp}] [${module}] ${l}`).join('\n')}\n`,
|
|
2337
|
-
'utf8',
|
|
2338
|
-
)
|
|
2339
|
-
} catch (err) {
|
|
2340
|
-
/* best-effort file log — never break the panel over logging, but do
|
|
2341
|
-
* NOT swallow the failure silently: a missing symbol here (e.g.
|
|
2342
|
-
* `appendFile` used without an import) previously threw an invisible
|
|
2343
|
-
* ReferenceError and the log silently stopped writing. Gated by the
|
|
2344
|
-
* echo flag to avoid the TUI-pollution bug described above. */
|
|
2345
|
-
if (echo) {
|
|
2346
|
-
try {
|
|
2347
|
-
process.stderr.write(`[${module}] file-log failed: ${formatArg(err)}\n`)
|
|
2348
|
-
} catch {
|
|
2349
|
-
/* stderr unavailable — never break the panel */
|
|
2350
|
-
}
|
|
2351
|
-
}
|
|
2352
|
-
}
|
|
2353
|
-
})()
|
|
2354
|
-
if (echo) {
|
|
2355
|
-
for (const line of lines) console.log(`[${module}] ${line}`)
|
|
2356
|
-
}
|
|
2357
|
-
}
|
|
2358
|
-
return { info: (message, ...args) => write(message, args) }
|
|
2359
|
-
}
|
|
2360
|
-
|
|
2361
|
-
/* ─── Session Row (single session with live status indicator) ─ */
|
|
2362
|
-
|
|
2363
|
-
function SessionRow(props: { api: TuiPluginApi; session: any }) {
|
|
2364
|
-
const theme = () => props.api.theme.current
|
|
2365
|
-
|
|
2366
|
-
const status = createMemo(() => {
|
|
2367
|
-
// isValidSessionId guard: a placeholder / foreign session id must never
|
|
2368
|
-
// reach the status API (same "%7BsessionID%7D" regression class as the
|
|
2369
|
-
// children path). Invalid id → no status call, no icon.
|
|
2370
|
-
if (!isValidSessionId(props.session?.id)) return null
|
|
2371
|
-
try {
|
|
2372
|
-
const s = (props.api.state as any).session?.status?.(props.session.id)
|
|
2373
|
-
if (s?.type) return s.type as string
|
|
2374
|
-
} catch {
|
|
2375
|
-
/* ignore */
|
|
2376
|
-
}
|
|
2377
|
-
return null
|
|
2378
|
-
})
|
|
2379
|
-
|
|
2380
|
-
const statusIcon = createMemo(() => {
|
|
2381
|
-
switch (status()) {
|
|
2382
|
-
case 'busy':
|
|
2383
|
-
return '\u25cf ' // ● — running
|
|
2384
|
-
case 'retry':
|
|
2385
|
-
return '\u26a0 ' // ⚠ — needs attention
|
|
2386
|
-
default:
|
|
2387
|
-
return '' // idle/unknown — no icon
|
|
2388
|
-
}
|
|
2389
|
-
})
|
|
2390
|
-
|
|
2391
|
-
return (
|
|
2392
|
-
<text fg={theme().textMuted}>
|
|
2393
|
-
{`${statusIcon()}${props.session?.id?.slice(0, 8) ?? '????'}... ${props.session?.title?.slice(0, 26) ?? '(untitled)'}`}
|
|
2394
|
-
</text>
|
|
2395
|
-
)
|
|
2396
|
-
}
|
|
2397
|
-
|
|
2398
|
-
/* ─── Delegation Row (single job from the children/md channels) ─ */
|
|
2399
|
-
|
|
2400
|
-
function DelegationRow(props: {
|
|
2401
|
-
api: TuiPluginApi
|
|
2402
|
-
job: DelegationEntry
|
|
2403
|
-
now: number
|
|
2404
|
-
animationNow: number
|
|
2405
|
-
}) {
|
|
2406
|
-
const theme = () => props.api.theme.current
|
|
2407
|
-
|
|
2408
|
-
const color = createMemo(() => {
|
|
2409
|
-
const t = theme()
|
|
2410
|
-
switch (delegationStateTone(props.job.state)) {
|
|
2411
|
-
case 'warning':
|
|
2412
|
-
return t.warning
|
|
2413
|
-
case 'error':
|
|
2414
|
-
return t.error
|
|
2415
|
-
case 'success':
|
|
2416
|
-
return t.success
|
|
2417
|
-
default:
|
|
2418
|
-
return t.textMuted // muted (cancelled)
|
|
2419
|
-
}
|
|
2420
|
-
})
|
|
2421
|
-
|
|
2422
|
-
const marker = createMemo(() => delegationStateMarker(props.job.state, props.animationNow))
|
|
2423
|
-
|
|
2424
|
-
// Two visual channels split native task() from pantheon_delegate: the tag
|
|
2425
|
-
// prefix (`nat:`/`pan:`, via delegationTag) and the glyph color — info
|
|
2426
|
-
// (cyan) for native, state color for board rows. The glyph shape (◇/◆) is
|
|
2427
|
-
// the third, color-independent channel.
|
|
2428
|
-
const tagColor = createMemo(() => (props.job.source === 'children-only' ? theme().info : color()))
|
|
2429
|
-
|
|
2430
|
-
const description = createMemo(() => truncateDelegationDescription(props.job.description))
|
|
2431
|
-
const elapsed = createMemo(() => formatDelegationElapsed(props.job, props.now))
|
|
2432
|
-
|
|
2433
|
-
// beta.6: live tool call the child is running RIGHT NOW ("↳ bash npm test"),
|
|
2434
|
-
// fed by message.part.updated activity tracking. Null while idle/done.
|
|
2435
|
-
const activity = createMemo(() =>
|
|
2436
|
-
latestToolActivityFor(latestToolActivity, props.job.taskID, props.now),
|
|
2437
|
-
)
|
|
2438
|
-
|
|
2439
|
-
// Click a row → navigate to the child session (route API guarded in the
|
|
2440
|
-
// helper). Enter-key binding is left out (per-row keymap wiring is not
|
|
2441
|
-
// worth the complexity in this sidebar); mouse is enabled in tui.json.
|
|
2442
|
-
const open = () => {
|
|
2443
|
-
navigateToDelegationSession(props.api.route, props.job.taskID)
|
|
2444
|
-
}
|
|
2445
|
-
|
|
2446
|
-
return (
|
|
2447
|
-
<box onMouseDown={open}>
|
|
2448
|
-
<box flexDirection="row">
|
|
2449
|
-
<text fg={tagColor()}>{formatDelegationRow(props.job, marker())}</text>
|
|
2450
|
-
</box>
|
|
2451
|
-
{/* Elapsed is ALWAYS rendered (outside the optional-description Show) so
|
|
2452
|
-
* an active job with no description never loses its timer. */}
|
|
2453
|
-
<box flexDirection="row" justifyContent={description() !== '' ? 'space-between' : 'flex-end'}>
|
|
2454
|
-
<Show when={description() !== ''}>
|
|
2455
|
-
<text fg={theme().textMuted}>{description()}</text>
|
|
2456
|
-
</Show>
|
|
2457
|
-
<text fg={theme().textMuted}>{elapsed()}</text>
|
|
2458
|
-
</box>
|
|
2459
|
-
<Show when={activity()}>
|
|
2460
|
-
{(a) => (
|
|
2461
|
-
<text
|
|
2462
|
-
fg={theme().textMuted}
|
|
2463
|
-
>{` \u21b3 ${truncateDelegationDescription(`${a().tool}${a().summary !== '' ? ` ${a().summary}` : ''}`)}`}</text>
|
|
2464
|
-
)}
|
|
2465
|
-
</Show>
|
|
2466
|
-
</box>
|
|
2467
|
-
)
|
|
2468
|
-
}
|
|
2469
|
-
|
|
2470
|
-
/* ─── Main Sidebar View ──────────────────────────────────── */
|
|
2471
|
-
|
|
2472
|
-
/** Plugin-level live delegation store shared with the event subscriptions
|
|
2473
|
-
* in `tui()`: the map of live entries + a version signal bumped on every
|
|
2474
|
-
* mutation. The View subscribes to the version (in an effect) to refresh the
|
|
2475
|
-
* durable child list and also reads the map as an optimistic live source. */
|
|
2476
|
-
export type LiveDelegationStore = {
|
|
2477
|
-
map: Map<string, LiveDelegationEntry>
|
|
2478
|
-
/** Reactive version getter — View reads it inside an effect to re-fetch. */
|
|
2479
|
-
version: () => number
|
|
2480
|
-
/** Bump the version after a live mutation. */
|
|
2481
|
-
bump: () => void
|
|
2482
|
-
}
|
|
2483
|
-
|
|
2484
|
-
function View(props: {
|
|
2485
|
-
api: TuiPluginApi
|
|
2486
|
-
sessionID: string | undefined
|
|
2487
|
-
version: string | null
|
|
2488
|
-
liveStore: LiveDelegationStore
|
|
2489
|
-
}) {
|
|
2490
|
-
const [showSessions, setShowSessions] = createSignal(false)
|
|
2491
|
-
// Show live work immediately; the header remains clickable to collapse it.
|
|
2492
|
-
const [showDelegations, setShowDelegations] = createSignal(true)
|
|
2493
|
-
|
|
2494
|
-
const theme = () => props.api.theme.current
|
|
2495
|
-
|
|
2496
|
-
const branch = createMemo(() =>
|
|
2497
|
-
props.api.state.vcs?.branch ? `\u2387 ${props.api.state.vcs.branch}` : null,
|
|
2498
|
-
)
|
|
2499
|
-
|
|
2500
|
-
// ── Active preset indicator ──
|
|
2501
|
-
// Seeded synchronously from env (never blocks first paint), then re-checked
|
|
2502
|
-
// against .pantheon/active-preset.json on mount and every 30s so `set-tier`
|
|
2503
|
-
// changes made while opencode is open become visible.
|
|
2504
|
-
const [preset, setPreset] = createSignal<PresetInfo>(presetFromEnv(process.env))
|
|
2505
|
-
|
|
2506
|
-
onMount(() => {
|
|
2507
|
-
const cwd = ((props.api.state as any).path?.worktree ?? '') || process.cwd()
|
|
2508
|
-
let cancelled = false
|
|
2509
|
-
const refresh = async () => {
|
|
2510
|
-
try {
|
|
2511
|
-
const info = await resolvePresetForTui(process.env, cwd)
|
|
2512
|
-
if (!cancelled) setPreset(info)
|
|
2513
|
-
} catch {
|
|
2514
|
-
// transient read error — keep the last known value
|
|
2515
|
-
}
|
|
2516
|
-
}
|
|
2517
|
-
void refresh()
|
|
2518
|
-
const timer = setInterval(() => void refresh(), PRESET_REFRESH_MS)
|
|
2519
|
-
onCleanup(() => {
|
|
2520
|
-
cancelled = true
|
|
2521
|
-
clearInterval(timer)
|
|
2522
|
-
})
|
|
2523
|
-
})
|
|
2524
|
-
|
|
2525
|
-
// ── Sessions: fetch via api.client.session.list (not proc.exec) ──
|
|
2526
|
-
const [sessionList, { refetch: refetchSessions }] = createResource(async () => {
|
|
2527
|
-
try {
|
|
2528
|
-
const result = await (props.api.client as any)?.session?.list?.({ limit: 100 })
|
|
2529
|
-
return result ?? { data: [] }
|
|
2530
|
-
} catch {
|
|
2531
|
-
return { data: [] }
|
|
2532
|
-
}
|
|
2533
|
-
})
|
|
2534
|
-
|
|
2535
|
-
// Total top-level sessions (excludes subagent sessions)
|
|
2536
|
-
const totalSessions = createMemo(() => {
|
|
2537
|
-
const result = sessionList()
|
|
2538
|
-
if (!result) return 0
|
|
2539
|
-
const data: any[] = (result as any).data ?? result
|
|
2540
|
-
if (!Array.isArray(data)) return 0
|
|
2541
|
-
return data.filter((s: any) => !s.parentID).length
|
|
2542
|
-
})
|
|
2543
|
-
|
|
2544
|
-
// Recent top-level sessions, sorted by time.updated descending
|
|
2545
|
-
const recentSessions = createMemo(() => {
|
|
2546
|
-
const result = sessionList()
|
|
2547
|
-
if (!result) return []
|
|
2548
|
-
const data: any[] = (result as any).data ?? result
|
|
2549
|
-
if (!Array.isArray(data)) return []
|
|
2550
|
-
return data
|
|
2551
|
-
.filter(
|
|
2552
|
-
(s: any) =>
|
|
2553
|
-
!s.parentID &&
|
|
2554
|
-
s.id !== resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api }),
|
|
2555
|
-
)
|
|
2556
|
-
.sort((a: any, b: any) => {
|
|
2557
|
-
const ta = a.time?.updated ?? a.updated ?? 0
|
|
2558
|
-
const tb = b.time?.updated ?? b.updated ?? 0
|
|
2559
|
-
return tb - ta
|
|
2560
|
-
})
|
|
2561
|
-
.slice(0, 8)
|
|
2562
|
-
})
|
|
2563
|
-
|
|
2564
|
-
// ── Delegations: session.children (PRIMARY) + md reports (enrichment) ──
|
|
2565
|
-
// The panel's source of truth is the SDK child-session API
|
|
2566
|
-
// (`api.client.session.children`) — every pantheon_delegate spawns a child
|
|
2567
|
-
// session with parentID = this session, so children ARE the delegation
|
|
2568
|
-
// list (delegations-sidebar pattern). The md channel
|
|
2569
|
-
// (.pantheon/delegations) enriches each child with alias/agent/description
|
|
2570
|
-
// and terminal duration via the `Task ID` match. Live tool-call events are
|
|
2571
|
-
// merged optimistically so the row appears before those channels catch up.
|
|
2572
|
-
const delegationsDir = createMemo(() => resolveDelegationsDir((props.api.state as any).path))
|
|
2573
|
-
// Silence-by-default panel logger → the REAL <root>/.pantheon/logs/hooks.log.
|
|
2574
|
-
// panelLogDir(<root>/.pantheon/delegations) = <root>: the old code passed
|
|
2575
|
-
// dirname(delegationsDir()) (= <root>/.pantheon), which createTuiLogger
|
|
2576
|
-
// re-joined with `.pantheon/logs/hooks.log` → a nested
|
|
2577
|
-
// <root>/.pantheon/.pantheon/logs/hooks.log that never reached the real
|
|
2578
|
-
// hooks.log (the empty nested dirs observed in production).
|
|
2579
|
-
const panelLog = createTuiLogger(panelLogDir(delegationsDir()))
|
|
2580
|
-
// The rendered list is childDelegations (children enriched with md);
|
|
2581
|
-
// there is no separate md-only signal — the md channel only feeds
|
|
2582
|
-
// childrenToDelegationEntries below.
|
|
2583
|
-
const [childDelegations, setChildDelegations] = createSignal<DelegationEntry[]>([])
|
|
2584
|
-
// 1s ticker drives the running-jobs elapsed counter AND the safety poll
|
|
2585
|
-
// (oh-my-opencode-slim pattern): re-fetch children + re-read the md every
|
|
2586
|
-
// second so the panel updates even when NO event ever fires.
|
|
2587
|
-
const [now, setNow] = createSignal(Date.now())
|
|
2588
|
-
// Separate fast ticker for the spinner. The elapsed label intentionally
|
|
2589
|
-
// remains on the 1s ticker to avoid unnecessary work.
|
|
2590
|
-
const [animationNow, setAnimationNow] = createSignal(Date.now())
|
|
2591
|
-
// Cumulative count of event-triggered refreshes — logged with every fetch
|
|
2592
|
-
// ("panel: children=N(pantheon=M native=K) md=N events=N") so the (0)
|
|
2593
|
-
// symptom is diagnosable: children>0 means the source works; events=0
|
|
2594
|
-
// means the event bus isn't delivering, and the 1s poll is what keeps
|
|
2595
|
-
// the panel honest.
|
|
2596
|
-
let eventRefreshCount = 0
|
|
2597
|
-
// In-flight guard: overlap between the poll, events and the version effect
|
|
2598
|
-
// collapses into one fetch — no duplicate work, no duplicate entries.
|
|
2599
|
-
let delegationsInflight: Promise<void> | null = null
|
|
2600
|
-
const refreshDelegations = () => {
|
|
2601
|
-
if (delegationsInflight !== null) return delegationsInflight
|
|
2602
|
-
delegationsInflight = (async () => {
|
|
2603
|
-
try {
|
|
2604
|
-
const state = props.api.state as any
|
|
2605
|
-
// 0. md reports from EVERY session under .pantheon/delegations — the
|
|
2606
|
-
// HISTORY channel. Read BEFORE the sessionID guard so the panel shows
|
|
2607
|
-
// past reports (running first, Finalized desc) even when no session
|
|
2608
|
-
// is focused (null/placeholder sessionID): the "(0)" symptom becomes
|
|
2609
|
-
// the full historical list. Fail-open: a missing dir yields [].
|
|
2610
|
-
let md: DelegationEntry[] = []
|
|
2611
|
-
try {
|
|
2612
|
-
md = await readAllDelegationEntries(panelLogDir(delegationsDir()))
|
|
2613
|
-
} catch {
|
|
2614
|
-
md = [] // fail-open: never crash the sidebar on a read error
|
|
2615
|
-
}
|
|
2616
|
-
// 1. current session — resolve + GUARD (placeholder regression). The
|
|
2617
|
-
// runtime may render the sidebar with an unsubstituted "{sessionID}"
|
|
2618
|
-
// slot prop; forwarding it to session.children made the server reject
|
|
2619
|
-
// every poll (~1 err/s: "Expected a string starting with \"ses\", got
|
|
2620
|
-
// \"%7BsessionID%7D\"") and fail-open the panel into "(0)". A null
|
|
2621
|
-
// resolution SKIPS the fetch entirely: the md history is shown alone
|
|
2622
|
-
// (zero children, zero errors).
|
|
2623
|
-
const sessionID = resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api })
|
|
2624
|
-
if (sessionID === null) {
|
|
2625
|
-
setChildDelegations(md)
|
|
2626
|
-
panelLog.info(
|
|
2627
|
-
`${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — history shown)`,
|
|
2628
|
-
)
|
|
2629
|
-
return
|
|
2630
|
-
}
|
|
2631
|
-
// 2. children — PRIMARY source (SDK: api.client.session.children).
|
|
2632
|
-
let children: ChildDelegationLike[] = []
|
|
2633
|
-
try {
|
|
2634
|
-
// buildChildrenPath re-validates: with sessionID !== null it is
|
|
2635
|
-
// never null, so no placeholder can reach the wire.
|
|
2636
|
-
const result = await (props.api.client as any)?.session?.children?.(
|
|
2637
|
-
buildChildrenPath(sessionID),
|
|
2638
|
-
)
|
|
2639
|
-
const data = (result?.data ?? result) as unknown
|
|
2640
|
-
children = Array.isArray(data) ? (data as ChildDelegationLike[]) : []
|
|
2641
|
-
} catch (err) {
|
|
2642
|
-
children = [] // children API unavailable — the panel shows md only
|
|
2643
|
-
panelLog.info('panel: error children fetch', err)
|
|
2644
|
-
}
|
|
2645
|
-
// 3. live status per child (sync API) + merge children with md.
|
|
2646
|
-
const resolveStatus = (childID: unknown): string | undefined => {
|
|
2647
|
-
// isValidSessionId guard: a placeholder / foreign id must never
|
|
2648
|
-
// reach the status API (same regression class as the children
|
|
2649
|
-
// path — "%7BsessionID%7D").
|
|
2650
|
-
if (!isValidSessionId(childID)) return undefined
|
|
2651
|
-
try {
|
|
2652
|
-
return state?.session?.status?.(childID)?.type as string | undefined
|
|
2653
|
-
} catch {
|
|
2654
|
-
return undefined // status API unavailable — child state fallback
|
|
2655
|
-
}
|
|
2656
|
-
}
|
|
2657
|
-
const withStatus = children.map((c) => ({ ...c, status: resolveStatus(c.id) }))
|
|
2658
|
-
const childEntries = childrenToDelegationEntries(withStatus, md, now())
|
|
2659
|
-
for (const [k, v] of props.liveStore.map)
|
|
2660
|
-
if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 30_000)
|
|
2661
|
-
props.liveStore.map.delete(k)
|
|
2662
|
-
// The event channel is optimistic: it renders a job even while the
|
|
2663
|
-
// child session/report is still being created. Filter by parent so a
|
|
2664
|
-
// different focused session never leaks rows into this sidebar.
|
|
2665
|
-
const liveEntries = [...props.liveStore.map.values()].filter(
|
|
2666
|
-
(entry) => entry.sessionID === sessionID,
|
|
2667
|
-
)
|
|
2668
|
-
setChildDelegations(mergeChildDelegationSources(childEntries, liveEntries))
|
|
2669
|
-
// 4. diagnostic line — every re-fetch (silence-by-default, hooks.log).
|
|
2670
|
-
const counts = countDelegationSources(childEntries)
|
|
2671
|
-
panelLog.info(
|
|
2672
|
-
formatPanelLogLine(
|
|
2673
|
-
children.length,
|
|
2674
|
-
counts.pantheon,
|
|
2675
|
-
counts.native,
|
|
2676
|
-
md.length,
|
|
2677
|
-
eventRefreshCount,
|
|
2678
|
-
),
|
|
2679
|
-
)
|
|
2680
|
-
} finally {
|
|
2681
|
-
delegationsInflight = null
|
|
2682
|
-
}
|
|
2683
|
-
})()
|
|
2684
|
-
return delegationsInflight
|
|
2685
|
-
}
|
|
2686
|
-
// beta.6: active (running/retry) first, then the 8 most recent terminal
|
|
2687
|
-
// reports, then the archived tail (paginated). Pagination state lives at
|
|
2688
|
-
// module level so sidebar remounts keep the user's page.
|
|
2689
|
-
const delegationSplit = createMemo(() => splitDelegationList(childDelegations()))
|
|
2690
|
-
const visibleDelegations = createMemo(() => [
|
|
2691
|
-
...delegationSplit().active,
|
|
2692
|
-
...delegationSplit().recent,
|
|
2693
|
-
])
|
|
2694
|
-
// Header summary over the FULL list (active + recent + archived): active
|
|
2695
|
-
// vs done plus the native/pantheon breakdown, so a busy session reads
|
|
2696
|
-
// "Delegations (2 active · 9 done · nat:3 pan:8)".
|
|
2697
|
-
const delegationHeader = createMemo(() => formatDelegationHeader(childDelegations()))
|
|
2698
|
-
const [archivedPage, setArchivedPage] = createSignal(archivedUiState.page)
|
|
2699
|
-
const clampArchivedPage = (page: number, pages: number) => {
|
|
2700
|
-
const clamped = Math.max(0, Math.min(page, Math.max(0, pages - 1)))
|
|
2701
|
-
archivedUiState.page = clamped
|
|
2702
|
-
setArchivedPage(clamped)
|
|
2703
|
-
}
|
|
2704
|
-
|
|
2705
|
-
// ── Event subscriptions for live session/delegation updates ──
|
|
2706
|
-
onMount(() => {
|
|
2707
|
-
const cleanup: (() => void)[] = []
|
|
2708
|
-
|
|
2709
|
-
// Session events — triggers the sessions resource refetch.
|
|
2710
|
-
try {
|
|
2711
|
-
cleanup.push(props.api.event.on('session.status', refetchSessions))
|
|
2712
|
-
cleanup.push(props.api.event.on('session.created', refetchSessions))
|
|
2713
|
-
cleanup.push(props.api.event.on('session.updated', refetchSessions))
|
|
2714
|
-
cleanup.push(props.api.event.on('session.deleted', refetchSessions))
|
|
2715
|
-
} catch {
|
|
2716
|
-
/* events API not available in this runtime */
|
|
2717
|
-
}
|
|
2718
|
-
|
|
2719
|
-
// Delegation panel: children are born/die with session lifecycle events
|
|
2720
|
-
// (a delegate spawns a child → session.created fires) — re-fetch on all
|
|
2721
|
-
// of them. `session.status` re-syncs busy→idle state transitions.
|
|
2722
|
-
try {
|
|
2723
|
-
const eventRefresh = () => {
|
|
2724
|
-
eventRefreshCount += 1
|
|
2725
|
-
void refreshDelegations()
|
|
2726
|
-
}
|
|
2727
|
-
cleanup.push(props.api.event.on('session.created', eventRefresh))
|
|
2728
|
-
cleanup.push(props.api.event.on('session.updated', eventRefresh))
|
|
2729
|
-
cleanup.push(props.api.event.on('session.deleted', eventRefresh))
|
|
2730
|
-
cleanup.push(props.api.event.on('session.status', eventRefresh))
|
|
2731
|
-
} catch {
|
|
2732
|
-
/* events API not available — the 1s poll still covers it */
|
|
2733
|
-
}
|
|
2734
|
-
|
|
2735
|
-
// message.part.updated/removed → liveStore.version() bumps (tui()):
|
|
2736
|
-
// subscribe here so the panel ALSO re-fetches children when tool-part
|
|
2737
|
-
// events DO arrive and the updated live row is rendered immediately.
|
|
2738
|
-
createEffect(() => {
|
|
2739
|
-
props.liveStore.version() // subscribe to live mutations
|
|
2740
|
-
eventRefreshCount += 1
|
|
2741
|
-
void refreshDelegations()
|
|
2742
|
-
})
|
|
2743
|
-
|
|
2744
|
-
// Safety poll (OMO-slim pattern): 1s re-fetch children + md + tick the
|
|
2745
|
-
// elapsed counter. Guarantees the panel updates even with zero events.
|
|
2746
|
-
const poll = setInterval(() => {
|
|
2747
|
-
setNow(Date.now())
|
|
2748
|
-
void refreshDelegations()
|
|
2749
|
-
}, 1_000)
|
|
2750
|
-
cleanup.push(() => clearInterval(poll))
|
|
2751
|
-
const animation = setInterval(() => setAnimationNow(Date.now()), 1_000)
|
|
2752
|
-
cleanup.push(() => clearInterval(animation))
|
|
2753
|
-
|
|
2754
|
-
// Compaction recovery: `message.part.removed` clears live entries while
|
|
2755
|
-
// compacting, so after a compaction/attach the live map would start
|
|
2756
|
-
// empty even though the session still holds the delegation tool parts.
|
|
2757
|
-
// Re-seed the live map from the session's existing parts (in message
|
|
2758
|
-
// order) right after the subscriptions are registered — the version
|
|
2759
|
-
// bump re-triggers the children fetch. Fail-open: if the SDK access is
|
|
2760
|
-
// unavailable we skip — the children API + the 1s poll keep the panel
|
|
2761
|
-
// working.
|
|
2762
|
-
try {
|
|
2763
|
-
const sdk = (props.api.state as any)?.session
|
|
2764
|
-
const mountSessionID = resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api })
|
|
2765
|
-
if (mountSessionID !== null && typeof sdk?.messages === 'function') {
|
|
2766
|
-
const messages: readonly { id?: string; parts?: unknown[] }[] =
|
|
2767
|
-
sdk.messages(mountSessionID) ?? []
|
|
2768
|
-
const state = props.api.state as any
|
|
2769
|
-
const getParts =
|
|
2770
|
-
typeof state?.part === 'function'
|
|
2771
|
-
? (messageID: string) => state.part(messageID)
|
|
2772
|
-
: undefined
|
|
2773
|
-
const parts = collectDelegationToolParts(messages, getParts)
|
|
2774
|
-
if (parts.length > 0 && seedLiveDelegationMap(props.liveStore.map, parts) > 0) {
|
|
2775
|
-
props.liveStore.bump()
|
|
2776
|
-
}
|
|
2777
|
-
}
|
|
2778
|
-
} catch {
|
|
2779
|
-
/* re-scan unavailable — children API + 1s poll still cover it */
|
|
2780
|
-
}
|
|
2781
|
-
|
|
2782
|
-
onCleanup(() =>
|
|
2783
|
-
cleanup.forEach((fn) => {
|
|
2784
|
-
fn()
|
|
2785
|
-
}),
|
|
2786
|
-
)
|
|
2787
|
-
})
|
|
2788
|
-
|
|
2789
|
-
const HR = () => <text fg={theme().textMuted}>{'\u2500'.repeat(28)}</text>
|
|
2790
|
-
|
|
2791
|
-
return (
|
|
2792
|
-
<box flexDirection="column" width="100%">
|
|
2793
|
-
{/* ── Header ── */}
|
|
2794
|
-
<text fg={theme().accent} attributes={1}>
|
|
2795
|
-
{`Pantheon${props.version ? ` v${props.version}` : ''}`}
|
|
2796
|
-
</text>
|
|
2797
|
-
<Show when={branch()}>{(b) => <text fg={theme().textMuted}>{b()}</text>}</Show>
|
|
2798
|
-
|
|
2799
|
-
{/* ── Active preset indicator ── */}
|
|
2800
|
-
<Show
|
|
2801
|
-
when={preset().name}
|
|
2802
|
-
fallback={
|
|
2803
|
-
<box flexDirection="row" gap={1}>
|
|
2804
|
-
<text fg={theme().textMuted}>Preset: default</text>
|
|
2805
|
-
</box>
|
|
2806
|
-
}
|
|
2807
|
-
>
|
|
2808
|
-
{(name) => (
|
|
2809
|
-
<box flexDirection="row" gap={1}>
|
|
2810
|
-
<text fg={theme().textMuted}>{'\u26a1 Preset:'}</text>
|
|
2811
|
-
<text fg={theme().accent}>{name()}</text>
|
|
2812
|
-
<text fg={theme().textMuted}>{`(${preset().source ?? ''})`}</text>
|
|
2813
|
-
</box>
|
|
2814
|
-
)}
|
|
2815
|
-
</Show>
|
|
2816
|
-
|
|
2817
|
-
<HR />
|
|
2818
|
-
|
|
2819
|
-
{/* ── Sessions ── */}
|
|
2820
|
-
<box onMouseDown={() => setShowSessions((x) => !x)}>
|
|
2821
|
-
<text fg={theme().text} attributes={1}>
|
|
2822
|
-
{`${showSessions() ? '\u25bc' : '\u25b6'} Sessions`}
|
|
2823
|
-
</text>
|
|
2824
|
-
<text fg={theme().textMuted}>{` (${String(totalSessions())})`}</text>
|
|
2825
|
-
</box>
|
|
2826
|
-
<Show when={showSessions()}>
|
|
2827
|
-
<Show
|
|
2828
|
-
when={recentSessions().length > 0}
|
|
2829
|
-
fallback={
|
|
2830
|
-
<box marginLeft={1}>
|
|
2831
|
-
<text fg={theme().textMuted}>No recent sessions</text>
|
|
2832
|
-
</box>
|
|
2833
|
-
}
|
|
2834
|
-
>
|
|
2835
|
-
<box marginLeft={1} flexDirection="column">
|
|
2836
|
-
<For each={recentSessions()}>
|
|
2837
|
-
{(ses: any) => <SessionRow api={props.api} session={ses} />}
|
|
2838
|
-
</For>
|
|
2839
|
-
</box>
|
|
2840
|
-
</Show>
|
|
2841
|
-
</Show>
|
|
2842
|
-
|
|
2843
|
-
{/* ── Delegations (session.children primary + .pantheon/delegations md enrichment) ── */}
|
|
2844
|
-
<box onMouseDown={() => setShowDelegations((x) => !x)}>
|
|
2845
|
-
<text fg={theme().text} attributes={1}>
|
|
2846
|
-
{`${showDelegations() ? '▼' : '▶'} Delegations`}
|
|
2847
|
-
</text>
|
|
2848
|
-
<text fg={theme().textMuted}>{` ${delegationHeader()}`}</text>
|
|
2849
|
-
</box>
|
|
2850
|
-
<Show when={showDelegations()}>
|
|
2851
|
-
<Show
|
|
2852
|
-
when={visibleDelegations().length > 0}
|
|
2853
|
-
fallback={
|
|
2854
|
-
<box marginLeft={1}>
|
|
2855
|
-
<text fg={theme().textMuted}>No delegations</text>
|
|
2856
|
-
</box>
|
|
2857
|
-
}
|
|
2858
|
-
>
|
|
2859
|
-
<box marginLeft={1} flexDirection="column">
|
|
2860
|
-
<For each={visibleDelegations()}>
|
|
2861
|
-
{(job) => (
|
|
2862
|
-
<DelegationRow
|
|
2863
|
-
api={props.api}
|
|
2864
|
-
job={job}
|
|
2865
|
-
now={now()}
|
|
2866
|
-
animationNow={animationNow()}
|
|
2867
|
-
/>
|
|
2868
|
-
)}
|
|
2869
|
-
</For>
|
|
2870
|
-
<Show when={delegationSplit().archived.length > 0}>
|
|
2871
|
-
{(() => {
|
|
2872
|
-
const archived = delegationSplit().archived
|
|
2873
|
-
const pageSize = 8
|
|
2874
|
-
const pages = Math.ceil(archived.length / pageSize)
|
|
2875
|
-
const page = Math.min(archivedPage(), pages - 1)
|
|
2876
|
-
const slice = archived.slice(page * pageSize, page * pageSize + pageSize)
|
|
2877
|
-
return (
|
|
2878
|
-
<box flexDirection="column">
|
|
2879
|
-
<box flexDirection="row" marginLeft={0}>
|
|
2880
|
-
<text
|
|
2881
|
-
fg={theme().textMuted}
|
|
2882
|
-
onMouseDown={() => clampArchivedPage(page - 1, pages)}
|
|
2883
|
-
>{`\u2039 `}</text>
|
|
2884
|
-
<text fg={theme().textMuted}>{`Archived (${archived.length}) `}</text>
|
|
2885
|
-
<text fg={theme().textMuted}>{`${page + 1}/${pages}`}</text>
|
|
2886
|
-
<text
|
|
2887
|
-
fg={theme().textMuted}
|
|
2888
|
-
onMouseDown={() => clampArchivedPage(page + 1, pages)}
|
|
2889
|
-
>{` \u203a`}</text>
|
|
2890
|
-
</box>
|
|
2891
|
-
<For each={slice}>
|
|
2892
|
-
{(job) => (
|
|
2893
|
-
<DelegationRow
|
|
2894
|
-
api={props.api}
|
|
2895
|
-
job={job}
|
|
2896
|
-
now={now()}
|
|
2897
|
-
animationNow={animationNow()}
|
|
2898
|
-
/>
|
|
2899
|
-
)}
|
|
2900
|
-
</For>
|
|
2901
|
-
</box>
|
|
2902
|
-
)
|
|
2903
|
-
})()}
|
|
2904
|
-
</Show>
|
|
2905
|
-
</box>
|
|
2906
|
-
</Show>
|
|
2907
|
-
</Show>
|
|
2908
|
-
</box>
|
|
2909
|
-
)
|
|
2910
|
-
}
|
|
2911
|
-
|
|
2912
|
-
/* ─── Plugin Registration ────────────────────────────────── */
|
|
2913
|
-
|
|
2914
|
-
const tui: TuiPlugin = (api, _options, _meta) => {
|
|
2915
|
-
if (pantheonPluginOnce('pantheon:tui')) return
|
|
2916
|
-
// Do not await project file/process probes here. OpenCode waits for the TUI
|
|
2917
|
-
// factory before it finishes startup, and a Windows host opening a WSL
|
|
2918
|
-
// project can leave those probes pending while the project bridge comes up.
|
|
2919
|
-
// Register the sidebar immediately and fill in the optional version later.
|
|
2920
|
-
const [version, setVersion] = createSignal<string | null>(null)
|
|
2921
|
-
void detectVersion(api)
|
|
2922
|
-
.then((detected) => setVersion(detected))
|
|
2923
|
-
.catch(() => setVersion(null))
|
|
2924
|
-
|
|
2925
|
-
// Vendored feature (MIT): AI subscription usage gauges.
|
|
2926
|
-
void setupUsageBar(api) // async init — never blocks sidebar registration
|
|
2927
|
-
|
|
2928
|
-
// ── Live delegation store (legacy tool-part tracker, kept as a REFRESH
|
|
2929
|
-
// TRIGGER) ──
|
|
2930
|
-
// The Delegations panel's PRIMARY source is `api.client.session.children`
|
|
2931
|
-
// (delegations-sidebar pattern) — `message.part.updated` may not fire for
|
|
2932
|
-
// pantheon_delegate on runtime 1.18.13 (the "(0)" symptom). The tool-part
|
|
2933
|
-
// lifecycle below still tracks pantheon_delegate / pantheon_delegation_read
|
|
2934
|
-
// parts and bumps a version signal the View subscribes to: when part
|
|
2935
|
-
// events DO arrive, the panel re-fetches children. Fail-open: if the
|
|
2936
|
-
// events API is unavailable nothing subscribes and the 1s safety poll
|
|
2937
|
-
// keeps the panel working (never crash).
|
|
2938
|
-
const [liveVersion, setLiveVersion] = createSignal(0)
|
|
2939
|
-
const liveStore: LiveDelegationStore = {
|
|
2940
|
-
map: new Map<string, LiveDelegationEntry>(),
|
|
2941
|
-
version: liveVersion,
|
|
2942
|
-
bump: () => setLiveVersion((v) => v + 1),
|
|
2943
|
-
}
|
|
2944
|
-
const unsubLive: (() => void)[] = []
|
|
2945
|
-
|
|
2946
|
-
try {
|
|
2947
|
-
unsubLive.push(
|
|
2948
|
-
api.event.on('message.part.updated', (event: any) => {
|
|
2949
|
-
// SDK v2 shape: properties.part (v1 drift: properties.info.part).
|
|
2950
|
-
const props = (event?.properties ?? {}) as { part?: unknown; info?: { part?: unknown } }
|
|
2951
|
-
const part = (props.part ?? props.info?.part) as DelegationToolPart | undefined
|
|
2952
|
-
if (part === undefined) return
|
|
2953
|
-
// beta.6: track the live tool call per child session (panel's
|
|
2954
|
-
// activity line). Fire-and-forget — display-only state.
|
|
2955
|
-
const sample = extractToolActivity(part as any)
|
|
2956
|
-
if (sample !== null)
|
|
2957
|
-
trackToolActivity(latestToolActivity, sample.sessionID, sample.activity)
|
|
2958
|
-
if (reduceDelegationToolPart(liveStore.map, part)) liveStore.bump()
|
|
2959
|
-
}),
|
|
2960
|
-
)
|
|
2961
|
-
} catch {
|
|
2962
|
-
/* events API unavailable — children API + 1s poll cover the panel */
|
|
2963
|
-
}
|
|
2964
|
-
try {
|
|
2965
|
-
unsubLive.push(
|
|
2966
|
-
api.event.on('message.part.removed', (event: any) => {
|
|
2967
|
-
// SDK v2 shape: properties.partID (v1 drift: properties.part.id).
|
|
2968
|
-
const props = (event?.properties ?? {}) as { partID?: string; part?: { id?: string } }
|
|
2969
|
-
const partID = props.partID ?? props.part?.id
|
|
2970
|
-
if (partID !== undefined && removeDelegationEntry(liveStore.map, partID)) liveStore.bump()
|
|
2971
|
-
}),
|
|
2972
|
-
)
|
|
2973
|
-
} catch {
|
|
2974
|
-
/* events API unavailable — children API + 1s poll cover the panel */
|
|
2975
|
-
}
|
|
2976
|
-
api.lifecycle.onDispose(() => {
|
|
2977
|
-
for (const unsub of unsubLive) {
|
|
2978
|
-
try {
|
|
2979
|
-
unsub()
|
|
2980
|
-
} catch {
|
|
2981
|
-
/* ignore */
|
|
2982
|
-
}
|
|
2983
|
-
}
|
|
2984
|
-
liveStore.map.clear()
|
|
2985
|
-
})
|
|
2986
|
-
|
|
2987
|
-
api.slots.register({
|
|
2988
|
-
order: 900,
|
|
2989
|
-
slots: {
|
|
2990
|
-
sidebar_content(_ctx, props) {
|
|
2991
|
-
return (
|
|
2992
|
-
<View api={api} sessionID={props.session_id} version={version()} liveStore={liveStore} />
|
|
2993
|
-
)
|
|
2994
|
-
},
|
|
2995
|
-
},
|
|
2996
|
-
})
|
|
2997
|
-
}
|
|
2998
|
-
|
|
2999
|
-
const plugin: TuiPluginModule & { id: string; setup: () => Promise<void> } = {
|
|
3000
|
-
id: 'pantheon.tui',
|
|
3001
|
-
tui,
|
|
3002
|
-
// Beta loader contract: every `plugins` directory entry must expose a
|
|
3003
|
-
// no-op `setup` so the entry loads as `pantheon.tui` with zero errors
|
|
3004
|
-
// (sandbox probe evidence). The TUI itself boots via `tui()`.
|
|
3005
|
-
setup: async () => {},
|
|
3006
|
-
}
|
|
3007
|
-
|
|
3008
|
-
export default plugin
|