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.
@@ -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