pantheon-opencode 1.5.0-beta.10 → 1.5.0-beta.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/package.json +1 -2
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/dev-tui.sh +48 -0
- package/scripts/doctor.mjs +32 -3
- package/scripts/install/plugin.mjs +9 -12
- package/scripts/sync-tui.mjs +8 -11
- package/src/pantheon/session-guard.ts +2 -2
- package/src/plugins/tui/dist/tui.d.ts +90 -8
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +355 -104
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +2 -2
- package/src/plugins/tui/src/index.tsx +410 -59
- package/src/plugins/tui/dist/tui.tsx +0 -2968
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pantheon-tui",
|
|
3
|
-
"version": "1.5.0-beta.
|
|
3
|
+
"version": "1.5.0-beta.12",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pantheon-tui",
|
|
9
|
-
"version": "1.5.0-beta.
|
|
9
|
+
"version": "1.5.0-beta.12",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@opencode-ai/plugin": ">=1.14.0",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pantheon-tui",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.5.0-beta.
|
|
4
|
+
"version": "1.5.0-beta.12",
|
|
5
5
|
"description": "Pantheon TUI plugin — sidebar (version, preset, sessions, real-time delegations) + AI subscription usage gauges incl. OpenCode Go/Zen dollar limits (vendored, MIT)",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"exports": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"src"
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
|
-
"build": "tsdown
|
|
16
|
+
"build": "tsdown",
|
|
17
17
|
"typecheck": "tsc --noEmit"
|
|
18
18
|
},
|
|
19
19
|
"peerDependencies": {
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* pantheon-tui — Pantheon TUI plugin for opencode.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Bundled entry: `tsdown` emits `dist/tui.js`, and the package `exports`
|
|
9
|
+
* (`./tui` → ./dist/tui.js, `./server` → ./dist/server.js) is the ONLY load
|
|
10
|
+
* path — no raw TSX copy is shipped. Because the bundle inlines relative
|
|
11
|
+
* imports, this entry may be split into modules without breaking the loader.
|
|
11
12
|
*
|
|
12
13
|
* Slots:
|
|
13
14
|
* - sidebar_content (order 900) — Pantheon sidebar (header/version/
|
|
@@ -90,9 +91,9 @@ const PRESET_REFRESH_MS = 30_000
|
|
|
90
91
|
/* ─── Helpers ───────────────────────────────────────────── */
|
|
91
92
|
|
|
92
93
|
/* ─── Active Preset Detection ──────────────────────────────
|
|
93
|
-
* Inline replica of src/pantheon/presets.mjs resolveActivePreset() — the
|
|
94
|
-
* plugin
|
|
95
|
-
*
|
|
94
|
+
* Inline replica of src/pantheon/presets.mjs resolveActivePreset() — the
|
|
95
|
+
* copied plugin ships only dist/ + package.json, so it cannot reach
|
|
96
|
+
* src/pantheon at runtime; the resolution logic is mirrored here rather than
|
|
96
97
|
* importing presets.mjs.
|
|
97
98
|
*
|
|
98
99
|
* Priority: env PANTHEON_MODEL_PRESET > first existing candidate file > none.
|
|
@@ -155,7 +156,7 @@ async function resolvePresetForTui(
|
|
|
155
156
|
*
|
|
156
157
|
* Try order:
|
|
157
158
|
* 0a. pantheon-tui/package.json (installed: dist/tui.js → 2 níveis)
|
|
158
|
-
* 0b. pantheon/package.json (dev: src/plugins/tui/dist/tui.
|
|
159
|
+
* 0b. pantheon/package.json (dev: src/plugins/tui/dist/tui.js → 4 níveis)
|
|
159
160
|
* 1. api.client.file.read package.json
|
|
160
161
|
* 2. git describe --tags
|
|
161
162
|
* 3. opencode --version
|
|
@@ -163,7 +164,7 @@ async function resolvePresetForTui(
|
|
|
163
164
|
* 5. null */
|
|
164
165
|
|
|
165
166
|
// Build-time embedded version (tsdown `define` in tsdown.config.ts). Injected
|
|
166
|
-
//
|
|
167
|
+
// into the bundled dist/tui.js — the only artifact the loader consumes.
|
|
167
168
|
declare const __PANTHEON_VERSION__: string | undefined
|
|
168
169
|
|
|
169
170
|
async function detectVersion(api: TuiPluginApi): Promise<string | null> {
|
|
@@ -175,7 +176,7 @@ async function detectVersion(api: TuiPluginApi): Promise<string | null> {
|
|
|
175
176
|
if (pkg.version) return pkg.version
|
|
176
177
|
} catch {}
|
|
177
178
|
|
|
178
|
-
// Try 0b: dev location (4 levels: src/plugins/tui/dist/tui.
|
|
179
|
+
// Try 0b: dev location (4 levels: src/plugins/tui/dist/tui.js → package.json)
|
|
179
180
|
try {
|
|
180
181
|
const pkgUrl = new URL('../../../../package.json', import.meta.url)
|
|
181
182
|
const pkgContent = await readFile(fileURLToPath(pkgUrl), 'utf8')
|
|
@@ -983,8 +984,9 @@ export type DelegationEntry = {
|
|
|
983
984
|
// Reintroduce them once nested rows are actually produced.
|
|
984
985
|
/** Internal provenance used to keep a finalized md report authoritative.
|
|
985
986
|
* 'children-only' = a native task() child session with NO board report
|
|
986
|
-
* (rendered with the distinct `nat:` prefix); 'md' = board report wins
|
|
987
|
-
|
|
987
|
+
* (rendered with the distinct `nat:` prefix); 'md' = board report wins;
|
|
988
|
+
* 'board' = read from .pantheon/board/state.json (cross-session FSM). */
|
|
989
|
+
source?: 'child' | 'live' | 'md' | 'children-only' | 'board'
|
|
988
990
|
}
|
|
989
991
|
|
|
990
992
|
/** True for `\s` characters (space, tab, newline, CR) — plain char checks so
|
|
@@ -1168,20 +1170,108 @@ export async function readAllDelegationEntries(root: string): Promise<Delegation
|
|
|
1168
1170
|
return readDelegationEntries(join(root, '.pantheon', 'delegations'))
|
|
1169
1171
|
}
|
|
1170
1172
|
|
|
1173
|
+
/* ─── Job board channel (4th source: .pantheon/board/state.json) ────────
|
|
1174
|
+
* `session.children` is scoped to the focused session, so a job running in
|
|
1175
|
+
* ANOTHER session is invisible to it (the "Delegations (0)" / nat:0 symptom).
|
|
1176
|
+
* The board state file is the only durable, cross-session source:
|
|
1177
|
+
* FilePersistence (src/pantheon/file-persistence.ts) stores every
|
|
1178
|
+
* BackgroundJobRecord there as a JSON array. Reads are fail-open — absent,
|
|
1179
|
+
* corrupt or unreadable → [] — so a broken board never crashes the panel. */
|
|
1180
|
+
|
|
1181
|
+
/** The persisted subset of BackgroundJobRecord the panel consumes. Duck-typed
|
|
1182
|
+
* so a corrupt/older record never breaks parsing. */
|
|
1183
|
+
export type BoardJobRecord = {
|
|
1184
|
+
taskID: string
|
|
1185
|
+
parentSessionID: string
|
|
1186
|
+
agent: string
|
|
1187
|
+
description?: string
|
|
1188
|
+
state: string
|
|
1189
|
+
alias: string
|
|
1190
|
+
launchedAt?: number
|
|
1191
|
+
completedAt?: number
|
|
1192
|
+
updatedAt?: number
|
|
1193
|
+
timedOut?: boolean
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/** The FilePersistence state path: `<root>/.pantheon/board/state.json`. */
|
|
1197
|
+
export function boardStatePath(root: string): string {
|
|
1198
|
+
return join(root, '.pantheon', 'board', 'state.json')
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/** Validate one parsed record, keeping only the fields the panel uses.
|
|
1202
|
+
* Returns null for anything without a taskID/state (a corrupt entry is
|
|
1203
|
+
* skipped individually — the rest of the snapshot still loads). */
|
|
1204
|
+
function normalizeBoardRecord(item: unknown): BoardJobRecord | null {
|
|
1205
|
+
if (typeof item !== 'object' || item === null) return null
|
|
1206
|
+
const rec = item as Record<string, unknown>
|
|
1207
|
+
if (typeof rec.taskID !== 'string' || rec.taskID === '') return null
|
|
1208
|
+
if (typeof rec.state !== 'string' || rec.state === '') return null
|
|
1209
|
+
const record: BoardJobRecord = {
|
|
1210
|
+
taskID: rec.taskID,
|
|
1211
|
+
parentSessionID: typeof rec.parentSessionID === 'string' ? rec.parentSessionID : '',
|
|
1212
|
+
agent: typeof rec.agent === 'string' && rec.agent !== '' ? rec.agent : 'agent',
|
|
1213
|
+
state: rec.state,
|
|
1214
|
+
alias: typeof rec.alias === 'string' ? rec.alias : '',
|
|
1215
|
+
}
|
|
1216
|
+
if (typeof rec.description === 'string') record.description = rec.description
|
|
1217
|
+
if (typeof rec.launchedAt === 'number' && Number.isFinite(rec.launchedAt))
|
|
1218
|
+
record.launchedAt = rec.launchedAt
|
|
1219
|
+
if (typeof rec.completedAt === 'number' && Number.isFinite(rec.completedAt))
|
|
1220
|
+
record.completedAt = rec.completedAt
|
|
1221
|
+
if (typeof rec.updatedAt === 'number' && Number.isFinite(rec.updatedAt))
|
|
1222
|
+
record.updatedAt = rec.updatedAt
|
|
1223
|
+
if (typeof rec.timedOut === 'boolean') record.timedOut = rec.timedOut
|
|
1224
|
+
return record
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/** Read `.pantheon/board/state.json` (the cross-session job board snapshot).
|
|
1228
|
+
* Fail-open: missing file, corrupt JSON, non-array payload or an unreadable
|
|
1229
|
+
* path all yield [] — the panel keeps rendering from the other channels. */
|
|
1230
|
+
export async function readBoardState(root: string): Promise<BoardJobRecord[]> {
|
|
1231
|
+
let raw: string
|
|
1232
|
+
try {
|
|
1233
|
+
raw = await readFile(boardStatePath(root), 'utf8')
|
|
1234
|
+
} catch {
|
|
1235
|
+
return [] // absent/unreadable — no board yet
|
|
1236
|
+
}
|
|
1237
|
+
let parsed: unknown
|
|
1238
|
+
try {
|
|
1239
|
+
parsed = JSON.parse(raw)
|
|
1240
|
+
} catch {
|
|
1241
|
+
return [] // corrupt JSON — never crash the sidebar
|
|
1242
|
+
}
|
|
1243
|
+
if (!Array.isArray(parsed)) return []
|
|
1244
|
+
const records: BoardJobRecord[] = []
|
|
1245
|
+
for (const item of parsed) {
|
|
1246
|
+
const record = normalizeBoardRecord(item)
|
|
1247
|
+
if (record !== null) records.push(record)
|
|
1248
|
+
}
|
|
1249
|
+
return records
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/** Resolve the PROJECT ROOT used by every pantheon file channel. `directory`
|
|
1253
|
+
* wins over `worktree` (the old `resolveDelegationsDir` already did this);
|
|
1254
|
+
* an absent/empty root or `/` (no git — e.g. the sandbox test project) falls
|
|
1255
|
+
* back to cwd. Standardised here so the delegations md, the board state file
|
|
1256
|
+
* and the panel logger all read the SAME root (audit finding: the channels
|
|
1257
|
+
* resolved the root independently). Pure — no I/O. */
|
|
1258
|
+
export function resolvePantheonRoot(
|
|
1259
|
+
state: { directory?: string; worktree?: string } | undefined,
|
|
1260
|
+
cwd = process.cwd(),
|
|
1261
|
+
): string {
|
|
1262
|
+
const root = state?.directory ?? state?.worktree ?? ''
|
|
1263
|
+
if (root === '' || root === '/') return cwd
|
|
1264
|
+
return root
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1171
1267
|
/** Resolve the directory where the job board writes delegation md reports.
|
|
1172
1268
|
* The board writes `.pantheon/delegations` RELATIVE to the server cwd,
|
|
1173
|
-
* which the TUI exposes as `TuiState.path.directory`.
|
|
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()`. */
|
|
1269
|
+
* which the TUI exposes as `TuiState.path.directory`. */
|
|
1178
1270
|
export function resolveDelegationsDir(
|
|
1179
1271
|
state: { directory?: string; worktree?: string } | undefined,
|
|
1180
1272
|
cwd = process.cwd(),
|
|
1181
1273
|
): string {
|
|
1182
|
-
|
|
1183
|
-
if (root === '' || root === '/') return join(cwd, '.pantheon', 'delegations')
|
|
1184
|
-
return join(root, '.pantheon', 'delegations')
|
|
1274
|
+
return join(resolvePantheonRoot(state, cwd), '.pantheon', 'delegations')
|
|
1185
1275
|
}
|
|
1186
1276
|
|
|
1187
1277
|
/** Project root derived from the delegations dir: `<root>/.pantheon/delegations`
|
|
@@ -1354,6 +1444,62 @@ export function delegationSpinnerFrame(now: number): string {
|
|
|
1354
1444
|
return DELEGATION_SPINNER_FRAMES[index] ?? DELEGATION_SPINNER_FRAMES[0]
|
|
1355
1445
|
}
|
|
1356
1446
|
|
|
1447
|
+
/** Every state the row knows how to draw: the real BackgroundJobBoard FSM
|
|
1448
|
+
* (src/pantheon/background-job-board.ts) plus the TUI display-only states
|
|
1449
|
+
* (`retry`, `stale-running`). Fase 1 deliberately omits speculative
|
|
1450
|
+
* blocked/paused/scheduled/skipped. There is no `pending` display state: a
|
|
1451
|
+
* pre-dispatch tool part maps to `running` in {@link reduceDelegationToolPart}. */
|
|
1452
|
+
export type DelegationDisplayState = DelegationEntry['state']
|
|
1453
|
+
|
|
1454
|
+
/** Static glyph per display state. `running` shows its base spinner frame;
|
|
1455
|
+
* callers that animate must prefer {@link delegationStateMarker}. Unicode
|
|
1456
|
+
* geometric shapes only (no Nerd Font) — shape is an independent channel
|
|
1457
|
+
* from color, so rows stay legible without color. */
|
|
1458
|
+
export const DELEGATION_STATE_GLYPHS: Readonly<Record<DelegationDisplayState, string>> = {
|
|
1459
|
+
running: '\u280b', // ⠋ base frame of the animated spinner
|
|
1460
|
+
retry: '\u27f3', // ⟳ retrying
|
|
1461
|
+
'stale-running': '\u26a0', // ⚠ stale (display-only alert)
|
|
1462
|
+
completed: '\u2713', // ✓
|
|
1463
|
+
error: '\u2715', // ✕
|
|
1464
|
+
cancelled: '\u2212', // − minus: stopped, distinct from error's ✕
|
|
1465
|
+
startup_failed: '\u26a0', // ⚠ boot failed
|
|
1466
|
+
startup_unknown: '\u26a0', // ⚠ boot outcome unknown
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
export function delegationStateGlyph(state: DelegationDisplayState): string {
|
|
1470
|
+
return DELEGATION_STATE_GLYPHS[state]
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** Row marker (`<glyph> `) — the state channel. `running` animates through
|
|
1474
|
+
* {@link delegationSpinnerFrame} for the given tick; every other state is
|
|
1475
|
+
* static. Pure. */
|
|
1476
|
+
export function delegationStateMarker(state: DelegationDisplayState, now = Date.now()): string {
|
|
1477
|
+
const glyph = state === 'running' ? delegationSpinnerFrame(now) : delegationStateGlyph(state)
|
|
1478
|
+
return `${glyph} `
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/** Semantic tone mapped to the TUI theme at the row ({@link DelegationRow}).
|
|
1482
|
+
* Kept separate + pure so the color channel is testable without booting the
|
|
1483
|
+
* renderer. */
|
|
1484
|
+
export type DelegationStateTone = 'warning' | 'error' | 'success' | 'muted'
|
|
1485
|
+
|
|
1486
|
+
export function delegationStateTone(state: DelegationDisplayState): DelegationStateTone {
|
|
1487
|
+
switch (state) {
|
|
1488
|
+
case 'running':
|
|
1489
|
+
case 'retry':
|
|
1490
|
+
case 'startup_unknown':
|
|
1491
|
+
return 'warning'
|
|
1492
|
+
case 'stale-running':
|
|
1493
|
+
case 'error':
|
|
1494
|
+
case 'startup_failed':
|
|
1495
|
+
return 'error'
|
|
1496
|
+
case 'completed':
|
|
1497
|
+
return 'success'
|
|
1498
|
+
case 'cancelled':
|
|
1499
|
+
return 'muted'
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1357
1503
|
/* ─── Live tool activity per child session (beta.6) ────────────────────────
|
|
1358
1504
|
* The delegation panel's activity line ("↳ bash npm test") — what the agent
|
|
1359
1505
|
* is doing RIGHT NOW. Sourced from message.part.updated tool parts: every
|
|
@@ -1914,6 +2060,145 @@ export function mergeDelegationSources(
|
|
|
1914
2060
|
return all
|
|
1915
2061
|
}
|
|
1916
2062
|
|
|
2063
|
+
/* ─── Board channel: record → display + authoritative merge ───────────── */
|
|
2064
|
+
|
|
2065
|
+
/** Job-board FSM → panel display state. `reconciled` is intentionally absent:
|
|
2066
|
+
* the panel parser rejects it (a reconciled job is done and folded away). */
|
|
2067
|
+
const BOARD_STATE_TO_DISPLAY: Record<string, DelegationEntry['state']> = {
|
|
2068
|
+
running: 'running',
|
|
2069
|
+
completed: 'completed',
|
|
2070
|
+
error: 'error',
|
|
2071
|
+
startup_failed: 'startup_failed',
|
|
2072
|
+
startup_unknown: 'startup_unknown',
|
|
2073
|
+
cancelled: 'cancelled',
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
/** Map one persisted board record into the display shape. Returns null for
|
|
2077
|
+
* states the panel does not render (reconciled / unknown). Pure. */
|
|
2078
|
+
export function boardRecordToDelegationEntry(record: BoardJobRecord): DelegationEntry | null {
|
|
2079
|
+
const state = BOARD_STATE_TO_DISPLAY[record.state]
|
|
2080
|
+
if (state === undefined) return null
|
|
2081
|
+
const running = state === 'running'
|
|
2082
|
+
return {
|
|
2083
|
+
alias: record.alias !== '' ? record.alias : `job-${record.taskID.slice(0, 8)}`,
|
|
2084
|
+
sessionID: record.parentSessionID,
|
|
2085
|
+
taskID: record.taskID,
|
|
2086
|
+
agent: record.agent,
|
|
2087
|
+
state,
|
|
2088
|
+
startedAt: record.launchedAt ?? record.updatedAt ?? 0,
|
|
2089
|
+
updatedAt: running ? null : (record.completedAt ?? record.updatedAt ?? null),
|
|
2090
|
+
timedOut: record.timedOut === true,
|
|
2091
|
+
description: record.description ?? '',
|
|
2092
|
+
source: 'board',
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
/** Map a whole board snapshot, dropping unrenderable states. Sorted like every
|
|
2097
|
+
* other channel (running first, then most recent). Pure. */
|
|
2098
|
+
export function boardRecordsToDelegationEntries(
|
|
2099
|
+
records: readonly BoardJobRecord[],
|
|
2100
|
+
): DelegationEntry[] {
|
|
2101
|
+
const out: DelegationEntry[] = []
|
|
2102
|
+
for (const record of records) {
|
|
2103
|
+
const entry = boardRecordToDelegationEntry(record)
|
|
2104
|
+
if (entry !== null) out.push(entry)
|
|
2105
|
+
}
|
|
2106
|
+
out.sort(compareDelegationEntries)
|
|
2107
|
+
return out
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
/** Merge the board over any other channel. The board is AUTHORITATIVE for
|
|
2111
|
+
* state/alias/agent (it is the persisted FSM) and it is the only channel
|
|
2112
|
+
* that carries jobs from OTHER sessions. Dedup by taskID first, then by
|
|
2113
|
+
* (sessionID, alias) — aliases are per parent session. Pure. */
|
|
2114
|
+
export function mergeBoardDelegationSources(
|
|
2115
|
+
base: readonly DelegationEntry[],
|
|
2116
|
+
board: readonly DelegationEntry[],
|
|
2117
|
+
): DelegationEntry[] {
|
|
2118
|
+
const key = (sessionID: string, alias: string): string => `${sessionID}\u0000${alias}`
|
|
2119
|
+
const byTaskID = new Map<string, number>()
|
|
2120
|
+
const bySessionAlias = new Map<string, number>()
|
|
2121
|
+
const out = [...base]
|
|
2122
|
+
out.forEach((entry, index) => {
|
|
2123
|
+
if (entry.taskID !== undefined) byTaskID.set(entry.taskID, index)
|
|
2124
|
+
bySessionAlias.set(key(entry.sessionID, entry.alias), index)
|
|
2125
|
+
})
|
|
2126
|
+
|
|
2127
|
+
for (const incoming of board) {
|
|
2128
|
+
const index =
|
|
2129
|
+
(incoming.taskID !== undefined ? byTaskID.get(incoming.taskID) : undefined) ??
|
|
2130
|
+
bySessionAlias.get(key(incoming.sessionID, incoming.alias))
|
|
2131
|
+
if (index === undefined) {
|
|
2132
|
+
out.push(incoming)
|
|
2133
|
+
const next = out.length - 1
|
|
2134
|
+
if (incoming.taskID !== undefined) byTaskID.set(incoming.taskID, next)
|
|
2135
|
+
bySessionAlias.set(key(incoming.sessionID, incoming.alias), next)
|
|
2136
|
+
continue
|
|
2137
|
+
}
|
|
2138
|
+
out[index] = incoming // board wins — it is the persisted source of truth
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
out.sort(compareDelegationEntries)
|
|
2142
|
+
return out
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
/* ─── Delegation scope toggle ([Sessão]/[Tudo]) ───────────────────────── */
|
|
2146
|
+
|
|
2147
|
+
/** 'session' = focused session only (default); 'all' = every session the
|
|
2148
|
+
* board knows about. */
|
|
2149
|
+
export type DelegationScope = 'session' | 'all'
|
|
2150
|
+
|
|
2151
|
+
/** api.kv key holding the persisted scope (defaults to 'session'). */
|
|
2152
|
+
export const DELEGATION_SCOPE_KV_KEY = 'delegations.scope'
|
|
2153
|
+
|
|
2154
|
+
/** Structural subset of TuiKV — kept local so the pure helpers are testable
|
|
2155
|
+
* without the TUI runtime. */
|
|
2156
|
+
export type DelegationScopeKv = {
|
|
2157
|
+
get: (key: string, fallback?: unknown) => unknown
|
|
2158
|
+
set: (key: string, value: unknown) => void
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
/** Read the persisted scope; anything malformed/absent → 'session'. */
|
|
2162
|
+
export function readDelegationScope(kv: DelegationScopeKv | undefined): DelegationScope {
|
|
2163
|
+
try {
|
|
2164
|
+
const raw = kv?.get?.(DELEGATION_SCOPE_KV_KEY, 'session')
|
|
2165
|
+
return raw === 'all' ? 'all' : 'session'
|
|
2166
|
+
} catch {
|
|
2167
|
+
return 'session'
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
/** Persist the scope. Failure is non-fatal — the signal keeps it in memory. */
|
|
2172
|
+
export function writeDelegationScope(
|
|
2173
|
+
kv: DelegationScopeKv | undefined,
|
|
2174
|
+
scope: DelegationScope,
|
|
2175
|
+
): void {
|
|
2176
|
+
try {
|
|
2177
|
+
kv?.set?.(DELEGATION_SCOPE_KV_KEY, scope)
|
|
2178
|
+
} catch {
|
|
2179
|
+
/* in-memory only */
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
/** Keyboard/click toggle target. Pure. */
|
|
2184
|
+
export function nextDelegationScope(scope: DelegationScope): DelegationScope {
|
|
2185
|
+
return scope === 'session' ? 'all' : 'session'
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
/** Keep only the focused session's jobs ('session') or all of them ('all').
|
|
2189
|
+
* An empty sessionID marks a current-session child (the children channel is
|
|
2190
|
+
* session-scoped and only fills sessionID from a matching md report), so it
|
|
2191
|
+
* counts as the current session. With no resolved session there is nothing
|
|
2192
|
+
* to scope against — fail-open to the full list. Pure. */
|
|
2193
|
+
export function filterDelegationsByScope(
|
|
2194
|
+
entries: readonly DelegationEntry[],
|
|
2195
|
+
scope: DelegationScope,
|
|
2196
|
+
sessionID: string | null,
|
|
2197
|
+
): DelegationEntry[] {
|
|
2198
|
+
if (scope === 'all' || sessionID === null) return [...entries]
|
|
2199
|
+
return entries.filter((entry) => entry.sessionID === sessionID || entry.sessionID === '')
|
|
2200
|
+
}
|
|
2201
|
+
|
|
1917
2202
|
/* ─── Children channel (primary source, delegations-sidebar pattern) ────
|
|
1918
2203
|
* `api.client.session.children` returns the child sessions of the current
|
|
1919
2204
|
* session — every pantheon_delegate spawns one (parentID = caller). The
|
|
@@ -2237,8 +2522,8 @@ export function navigateToDelegationSession(
|
|
|
2237
2522
|
}
|
|
2238
2523
|
|
|
2239
2524
|
/* ─── Silence-by-default panel logger ──────────────────────
|
|
2240
|
-
* Mirrors src/pantheon/logger.ts createPantheonLogger (the
|
|
2241
|
-
*
|
|
2525
|
+
* Mirrors src/pantheon/logger.ts createPantheonLogger (the copied plugin
|
|
2526
|
+
* ships only dist/ + package.json, so it cannot import from src/pantheon):
|
|
2242
2527
|
* every line is ALWAYS appended to `<logDir>/.pantheon/logs/hooks.log` and
|
|
2243
2528
|
* echoed to the console ONLY when PANTHEON_HOOKS_LOG is truthy (opencode
|
|
2244
2529
|
* renders plugin console output directly into the TUI — the pollution bug).
|
|
@@ -2351,35 +2636,19 @@ function DelegationRow(props: {
|
|
|
2351
2636
|
|
|
2352
2637
|
const color = createMemo(() => {
|
|
2353
2638
|
const t = theme()
|
|
2354
|
-
switch (props.job.state) {
|
|
2355
|
-
case '
|
|
2356
|
-
return t.warning
|
|
2357
|
-
case 'retry':
|
|
2639
|
+
switch (delegationStateTone(props.job.state)) {
|
|
2640
|
+
case 'warning':
|
|
2358
2641
|
return t.warning
|
|
2359
|
-
case 'stale-running':
|
|
2360
|
-
return t.error // warning color for stale delegations
|
|
2361
|
-
case 'completed':
|
|
2362
|
-
return t.success
|
|
2363
2642
|
case 'error':
|
|
2364
2643
|
return t.error
|
|
2644
|
+
case 'success':
|
|
2645
|
+
return t.success
|
|
2365
2646
|
default:
|
|
2366
|
-
return t.textMuted // cancelled
|
|
2647
|
+
return t.textMuted // muted (cancelled)
|
|
2367
2648
|
}
|
|
2368
2649
|
})
|
|
2369
2650
|
|
|
2370
|
-
const marker = createMemo(() =>
|
|
2371
|
-
if (props.job.state === 'running') return `${delegationSpinnerFrame(props.animationNow)} `
|
|
2372
|
-
if (props.job.state === 'retry') return '\u27f3 ' // ⟳ retrying
|
|
2373
|
-
if (props.job.state === 'stale-running') return '\u26a0 ' // warning triangle
|
|
2374
|
-
switch (props.job.state) {
|
|
2375
|
-
case 'completed':
|
|
2376
|
-
return '\u2713 '
|
|
2377
|
-
case 'error':
|
|
2378
|
-
return '\u2715 '
|
|
2379
|
-
default:
|
|
2380
|
-
return '\u25cb '
|
|
2381
|
-
}
|
|
2382
|
-
})
|
|
2651
|
+
const marker = createMemo(() => delegationStateMarker(props.job.state, props.animationNow))
|
|
2383
2652
|
|
|
2384
2653
|
// Two visual channels split native task() from pantheon_delegate: the tag
|
|
2385
2654
|
// prefix (`nat:`/`pan:`, via delegationTag) and the glyph color — info
|
|
@@ -2529,14 +2798,22 @@ function View(props: {
|
|
|
2529
2798
|
// (.pantheon/delegations) enriches each child with alias/agent/description
|
|
2530
2799
|
// and terminal duration via the `Task ID` match. Live tool-call events are
|
|
2531
2800
|
// merged optimistically so the row appears before those channels catch up.
|
|
2532
|
-
|
|
2801
|
+
// Consistent project root (directory ?? worktree, cwd fallback) — the md
|
|
2802
|
+
// history, the board state file and the panel logger all read the SAME
|
|
2803
|
+
// root (audit finding: the channels resolved it independently).
|
|
2804
|
+
const projectRoot = createMemo(() => resolvePantheonRoot((props.api.state as any).path))
|
|
2533
2805
|
// Silence-by-default panel logger → the REAL <root>/.pantheon/logs/hooks.log.
|
|
2534
|
-
|
|
2535
|
-
//
|
|
2536
|
-
//
|
|
2537
|
-
//
|
|
2538
|
-
|
|
2539
|
-
|
|
2806
|
+
const panelLog = createTuiLogger(projectRoot())
|
|
2807
|
+
// Scope toggle: [Sessão] = focused session only (default), [Tudo] = every
|
|
2808
|
+
// session the board knows about. Persisted best-effort in api.kv; if the kv
|
|
2809
|
+
// is unavailable the signal keeps it for the process lifetime.
|
|
2810
|
+
const [delegationScope, setDelegationScope] = createSignal<DelegationScope>(
|
|
2811
|
+
readDelegationScope(props.api.kv as unknown as DelegationScopeKv),
|
|
2812
|
+
)
|
|
2813
|
+
const applyDelegationScope = (scope: DelegationScope) => {
|
|
2814
|
+
setDelegationScope(scope)
|
|
2815
|
+
writeDelegationScope(props.api.kv as unknown as DelegationScopeKv, scope)
|
|
2816
|
+
}
|
|
2540
2817
|
// The rendered list is childDelegations (children enriched with md);
|
|
2541
2818
|
// there is no separate md-only signal — the md channel only feeds
|
|
2542
2819
|
// childrenToDelegationEntries below.
|
|
@@ -2569,10 +2846,19 @@ function View(props: {
|
|
|
2569
2846
|
// the full historical list. Fail-open: a missing dir yields [].
|
|
2570
2847
|
let md: DelegationEntry[] = []
|
|
2571
2848
|
try {
|
|
2572
|
-
md = await readAllDelegationEntries(
|
|
2849
|
+
md = await readAllDelegationEntries(projectRoot())
|
|
2573
2850
|
} catch {
|
|
2574
2851
|
md = [] // fail-open: never crash the sidebar on a read error
|
|
2575
2852
|
}
|
|
2853
|
+
// 0b. board snapshot — the 4th channel. Cross-session and AUTHORITATIVE
|
|
2854
|
+
// for state/alias/agent (the persisted FSM). This is what makes a job
|
|
2855
|
+
// running in ANOTHER session visible. Fail-open: absent/corrupt → [].
|
|
2856
|
+
let board: DelegationEntry[] = []
|
|
2857
|
+
try {
|
|
2858
|
+
board = boardRecordsToDelegationEntries(await readBoardState(projectRoot()))
|
|
2859
|
+
} catch {
|
|
2860
|
+
board = [] // fail-open: never crash the sidebar on a board read error
|
|
2861
|
+
}
|
|
2576
2862
|
// 1. current session — resolve + GUARD (placeholder regression). The
|
|
2577
2863
|
// runtime may render the sidebar with an unsubstituted "{sessionID}"
|
|
2578
2864
|
// slot prop; forwarding it to session.children made the server reject
|
|
@@ -2582,9 +2868,9 @@ function View(props: {
|
|
|
2582
2868
|
// (zero children, zero errors).
|
|
2583
2869
|
const sessionID = resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api })
|
|
2584
2870
|
if (sessionID === null) {
|
|
2585
|
-
setChildDelegations(md)
|
|
2871
|
+
setChildDelegations(mergeBoardDelegationSources(md, board))
|
|
2586
2872
|
panelLog.info(
|
|
2587
|
-
`${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — history shown)`,
|
|
2873
|
+
`${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — history+board shown)`,
|
|
2588
2874
|
)
|
|
2589
2875
|
return
|
|
2590
2876
|
}
|
|
@@ -2616,6 +2902,12 @@ function View(props: {
|
|
|
2616
2902
|
}
|
|
2617
2903
|
const withStatus = children.map((c) => ({ ...c, status: resolveStatus(c.id) }))
|
|
2618
2904
|
const childEntries = childrenToDelegationEntries(withStatus, md, now())
|
|
2905
|
+
// childrenToDelegationEntries only fills sessionID from a matching md
|
|
2906
|
+
// report; a report-less row IS a child of the focused session, so tag
|
|
2907
|
+
// it with the resolved id — the scope filter can then keep it.
|
|
2908
|
+
for (const entry of childEntries) {
|
|
2909
|
+
if (entry.sessionID === '') entry.sessionID = sessionID
|
|
2910
|
+
}
|
|
2619
2911
|
for (const [k, v] of props.liveStore.map)
|
|
2620
2912
|
if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 30_000)
|
|
2621
2913
|
props.liveStore.map.delete(k)
|
|
@@ -2625,7 +2917,14 @@ function View(props: {
|
|
|
2625
2917
|
const liveEntries = [...props.liveStore.map.values()].filter(
|
|
2626
2918
|
(entry) => entry.sessionID === sessionID,
|
|
2627
2919
|
)
|
|
2628
|
-
|
|
2920
|
+
// Board LAST so it is authoritative over children/md/live for the same
|
|
2921
|
+
// job, and adds the rows from other sessions the children API cannot see.
|
|
2922
|
+
setChildDelegations(
|
|
2923
|
+
mergeBoardDelegationSources(
|
|
2924
|
+
mergeChildDelegationSources(childEntries, liveEntries),
|
|
2925
|
+
board,
|
|
2926
|
+
),
|
|
2927
|
+
)
|
|
2629
2928
|
// 4. diagnostic line — every re-fetch (silence-by-default, hooks.log).
|
|
2630
2929
|
const counts = countDelegationSources(childEntries)
|
|
2631
2930
|
panelLog.info(
|
|
@@ -2646,15 +2945,24 @@ function View(props: {
|
|
|
2646
2945
|
// beta.6: active (running/retry) first, then the 8 most recent terminal
|
|
2647
2946
|
// reports, then the archived tail (paginated). Pagination state lives at
|
|
2648
2947
|
// module level so sidebar remounts keep the user's page.
|
|
2649
|
-
|
|
2948
|
+
// The scope filters the rendered list (and the header counts): children/live
|
|
2949
|
+
// are already session-scoped, md and the board are not.
|
|
2950
|
+
const scopedDelegations = createMemo(() =>
|
|
2951
|
+
filterDelegationsByScope(
|
|
2952
|
+
childDelegations(),
|
|
2953
|
+
delegationScope(),
|
|
2954
|
+
resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api }),
|
|
2955
|
+
),
|
|
2956
|
+
)
|
|
2957
|
+
const delegationSplit = createMemo(() => splitDelegationList(scopedDelegations()))
|
|
2650
2958
|
const visibleDelegations = createMemo(() => [
|
|
2651
2959
|
...delegationSplit().active,
|
|
2652
2960
|
...delegationSplit().recent,
|
|
2653
2961
|
])
|
|
2654
|
-
// Header summary over the FULL list (active + recent + archived):
|
|
2655
|
-
// vs done plus the native/pantheon breakdown, so a busy session reads
|
|
2962
|
+
// Header summary over the FULL scoped list (active + recent + archived):
|
|
2963
|
+
// active vs done plus the native/pantheon breakdown, so a busy session reads
|
|
2656
2964
|
// "Delegations (2 active · 9 done · nat:3 pan:8)".
|
|
2657
|
-
const delegationHeader = createMemo(() => formatDelegationHeader(
|
|
2965
|
+
const delegationHeader = createMemo(() => formatDelegationHeader(scopedDelegations()))
|
|
2658
2966
|
const [archivedPage, setArchivedPage] = createSignal(archivedUiState.page)
|
|
2659
2967
|
const clampArchivedPage = (page: number, pages: number) => {
|
|
2660
2968
|
const clamped = Math.max(0, Math.min(page, Math.max(0, pages - 1)))
|
|
@@ -2739,6 +3047,29 @@ function View(props: {
|
|
|
2739
3047
|
/* re-scan unavailable — children API + 1s poll still cover it */
|
|
2740
3048
|
}
|
|
2741
3049
|
|
|
3050
|
+
// Keyboard shortcut for the scope toggle. `api.keymap` is an optional
|
|
3051
|
+
// peer (@opentui/keymap) — guard the duck-typed call so a degraded/missing
|
|
3052
|
+
// keymap never breaks the sidebar. The clickable [Sessão]/[Tudo] chips are
|
|
3053
|
+
// the always-available fallback.
|
|
3054
|
+
try {
|
|
3055
|
+
const keymap = (props.api as any)?.keymap
|
|
3056
|
+
if (typeof keymap?.registerLayer === 'function') {
|
|
3057
|
+
const off = keymap.registerLayer({
|
|
3058
|
+
priority: 1000,
|
|
3059
|
+
commands: [
|
|
3060
|
+
{
|
|
3061
|
+
name: 'pantheon.delegations.toggleScope',
|
|
3062
|
+
run: () => applyDelegationScope(nextDelegationScope(delegationScope())),
|
|
3063
|
+
},
|
|
3064
|
+
],
|
|
3065
|
+
bindings: [{ key: 'ctrl+shift+d', cmd: 'pantheon.delegations.toggleScope' }],
|
|
3066
|
+
})
|
|
3067
|
+
if (typeof off === 'function') cleanup.push(off)
|
|
3068
|
+
}
|
|
3069
|
+
} catch {
|
|
3070
|
+
/* keymap unavailable — the click toggle still works */
|
|
3071
|
+
}
|
|
3072
|
+
|
|
2742
3073
|
onCleanup(() =>
|
|
2743
3074
|
cleanup.forEach((fn) => {
|
|
2744
3075
|
fn()
|
|
@@ -2800,12 +3131,32 @@ function View(props: {
|
|
|
2800
3131
|
</Show>
|
|
2801
3132
|
</Show>
|
|
2802
3133
|
|
|
2803
|
-
{/* ── Delegations (session.children primary + .pantheon/delegations md enrichment) ── */}
|
|
3134
|
+
{/* ── Delegations (session.children primary + .pantheon/delegations md enrichment + board snapshot) ── */}
|
|
2804
3135
|
<box onMouseDown={() => setShowDelegations((x) => !x)}>
|
|
2805
3136
|
<text fg={theme().text} attributes={1}>
|
|
2806
3137
|
{`${showDelegations() ? '▼' : '▶'} Delegations`}
|
|
2807
3138
|
</text>
|
|
2808
3139
|
<text fg={theme().textMuted}>{` ${delegationHeader()}`}</text>
|
|
3140
|
+
<text fg={theme().textMuted}>{` `}</text>
|
|
3141
|
+
<text
|
|
3142
|
+
fg={delegationScope() === 'session' ? theme().accent : theme().textMuted}
|
|
3143
|
+
attributes={delegationScope() === 'session' ? 1 : undefined}
|
|
3144
|
+
onMouseDown={(e: unknown) => {
|
|
3145
|
+
if (typeof e === 'object' && e !== null && 'stopPropagation' in e)
|
|
3146
|
+
(e as { stopPropagation: () => void }).stopPropagation()
|
|
3147
|
+
applyDelegationScope('session')
|
|
3148
|
+
}}
|
|
3149
|
+
>{`[Sessão]`}</text>
|
|
3150
|
+
<text fg={theme().textMuted}>{`/`}</text>
|
|
3151
|
+
<text
|
|
3152
|
+
fg={delegationScope() === 'all' ? theme().accent : theme().textMuted}
|
|
3153
|
+
attributes={delegationScope() === 'all' ? 1 : undefined}
|
|
3154
|
+
onMouseDown={(e: unknown) => {
|
|
3155
|
+
if (typeof e === 'object' && e !== null && 'stopPropagation' in e)
|
|
3156
|
+
(e as { stopPropagation: () => void }).stopPropagation()
|
|
3157
|
+
applyDelegationScope('all')
|
|
3158
|
+
}}
|
|
3159
|
+
>{`[Tudo]`}</text>
|
|
2809
3160
|
</box>
|
|
2810
3161
|
<Show when={showDelegations()}>
|
|
2811
3162
|
<Show
|