pantheon-opencode 1.5.0-beta.11 → 1.5.0-beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/dev-tui.sh +1 -1
- package/scripts/install/plugin.mjs +9 -12
- package/scripts/sync-tui.mjs +8 -11
- package/src/pantheon/session-guard.ts +2 -2
- package/src/plugins/tui/dist/tui.d.ts +111 -59
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +353 -239
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +2 -2
- package/src/plugins/tui/src/index.tsx +415 -201
- package/src/plugins/tui/dist/tui.tsx +0 -3008
|
@@ -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')
|
|
@@ -899,9 +900,9 @@ async function setupUsageBar(api: TuiPluginApi) {
|
|
|
899
900
|
* and the native `task()` tool ALSO spawns a child session with
|
|
900
901
|
* parentID = caller. A child WITHOUT a board report is a native
|
|
901
902
|
* task() child: childrenToDelegationEntries tags it source
|
|
902
|
-
* 'children-only'
|
|
903
|
+
* 'children-only' (distinct info color),
|
|
903
904
|
* so native task() work is visible in the panel alongside board
|
|
904
|
-
* delegates
|
|
905
|
+
* delegates — never filtered out, never duplicated
|
|
905
906
|
* with a board row for the same child (a child WITH a report keeps the
|
|
906
907
|
* md entry, source 'md').
|
|
907
908
|
*
|
|
@@ -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
|
-
* (
|
|
987
|
-
|
|
987
|
+
* (a native child row); '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)" 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`
|
|
@@ -1212,18 +1302,18 @@ export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry)
|
|
|
1212
1302
|
}
|
|
1213
1303
|
|
|
1214
1304
|
/** Split the panel list: active jobs (running/retry, stale-marked) first,
|
|
1215
|
-
* then the most recent terminal reports
|
|
1216
|
-
*
|
|
1305
|
+
* then the most recent terminal reports; the remaining tail is collapsed
|
|
1306
|
+
* by the View into a single "… +N more" line. Pure — so the history-only panel (no sessionID) is testable
|
|
1217
1307
|
* without the TUI runtime. */
|
|
1218
1308
|
export function splitDelegationList(
|
|
1219
1309
|
all: readonly DelegationEntry[],
|
|
1220
1310
|
maxRecent = 8,
|
|
1221
1311
|
now = Date.now(),
|
|
1222
1312
|
staleThresholdMs = 30 * 60 * 1000, // 30 minutes
|
|
1223
|
-
): { active: DelegationEntry[]; recent: DelegationEntry[]
|
|
1313
|
+
): { active: DelegationEntry[]; recent: DelegationEntry[] } {
|
|
1224
1314
|
// Active = running OR retry OR the display-only stale-running. A stale row
|
|
1225
1315
|
// is still a live job (backend state unchanged), so it must never fall into
|
|
1226
|
-
// the terminal
|
|
1316
|
+
// the terminal tail — pushing a running delegation out hid the row
|
|
1227
1317
|
// the user launched.
|
|
1228
1318
|
const isActiveState = (st: DelegationEntry['state']): boolean =>
|
|
1229
1319
|
st === 'running' || st === 'retry' || st === 'stale-running'
|
|
@@ -1234,13 +1324,12 @@ export function splitDelegationList(
|
|
|
1234
1324
|
return {
|
|
1235
1325
|
active,
|
|
1236
1326
|
recent: terminal.slice(0, maxRecent),
|
|
1237
|
-
archived: terminal.slice(maxRecent),
|
|
1238
1327
|
}
|
|
1239
1328
|
}
|
|
1240
1329
|
|
|
1241
|
-
/**
|
|
1242
|
-
* existing tests): active jobs first, then the most
|
|
1243
|
-
* reports (capped)
|
|
1330
|
+
/** Live-first window used by {@link ceilingDelegationList} (kept for the
|
|
1331
|
+
* ceiling helper and existing tests): active jobs first, then the most
|
|
1332
|
+
* recent terminal reports (capped). Pure. */
|
|
1244
1333
|
export function visibleDelegationList(
|
|
1245
1334
|
all: readonly DelegationEntry[],
|
|
1246
1335
|
maxTerminal = 8,
|
|
@@ -1280,7 +1369,7 @@ export function markStaleIfRunning(
|
|
|
1280
1369
|
return { ...entry, state: 'stale-running' as const }
|
|
1281
1370
|
}
|
|
1282
1371
|
|
|
1283
|
-
/** Compact elapsed-time label: "
|
|
1372
|
+
/** Compact elapsed-time label, single unit only: "12s"/"3m"/"1h"/"2d" — ticks every
|
|
1284
1373
|
* second for running jobs. */
|
|
1285
1374
|
export function fmtElapsed(ms: number): string {
|
|
1286
1375
|
const total = Math.max(0, Math.floor(ms / 1000))
|
|
@@ -1288,9 +1377,9 @@ export function fmtElapsed(ms: number): string {
|
|
|
1288
1377
|
const hours = Math.floor((total % 86_400) / 3_600)
|
|
1289
1378
|
const minutes = Math.floor((total % 3_600) / 60)
|
|
1290
1379
|
const seconds = total % 60
|
|
1291
|
-
if (days > 0) return `${days}d
|
|
1292
|
-
if (hours > 0) return `${hours}h
|
|
1293
|
-
if (minutes > 0) return `${minutes}m
|
|
1380
|
+
if (days > 0) return `${days}d`
|
|
1381
|
+
if (hours > 0) return `${hours}h`
|
|
1382
|
+
if (minutes > 0) return `${minutes}m`
|
|
1294
1383
|
return `${seconds}s`
|
|
1295
1384
|
}
|
|
1296
1385
|
|
|
@@ -1361,38 +1450,14 @@ export function delegationSpinnerFrame(now: number): string {
|
|
|
1361
1450
|
* pre-dispatch tool part maps to `running` in {@link reduceDelegationToolPart}. */
|
|
1362
1451
|
export type DelegationDisplayState = DelegationEntry['state']
|
|
1363
1452
|
|
|
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
1453
|
/** Semantic tone mapped to the TUI theme at the row ({@link DelegationRow}).
|
|
1392
1454
|
* Kept separate + pure so the color channel is testable without booting the
|
|
1393
1455
|
* renderer. */
|
|
1394
1456
|
export type DelegationStateTone = 'warning' | 'error' | 'success' | 'muted'
|
|
1395
1457
|
|
|
1458
|
+
/** Color is the ONLY channel separating `stale-running` from a live `running`
|
|
1459
|
+
* row — the 4-glyph row design has no dedicated stale shape. Display-only;
|
|
1460
|
+
* no behavior change. */
|
|
1396
1461
|
export function delegationStateTone(state: DelegationDisplayState): DelegationStateTone {
|
|
1397
1462
|
switch (state) {
|
|
1398
1463
|
case 'running':
|
|
@@ -1428,10 +1493,6 @@ const latestToolActivity = new Map<string, ToolActivity>()
|
|
|
1428
1493
|
const MAX_TOOL_ACTIVITY_ENTRIES = 200
|
|
1429
1494
|
const TOOL_ACTIVITY_TTL_MS = 5 * 60 * 1000
|
|
1430
1495
|
|
|
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
1496
|
/** First usable summary string from common tool input fields. */
|
|
1436
1497
|
function summarizeToolInput(input: Record<string, unknown> | undefined): string {
|
|
1437
1498
|
if (!input) return ''
|
|
@@ -1521,6 +1582,17 @@ export function mergeChildDelegationSources(
|
|
|
1521
1582
|
)
|
|
1522
1583
|
continue
|
|
1523
1584
|
const incoming = toDelegationEntry(liveEntry)
|
|
1585
|
+
// A native task() live part never carries a board alias — it is a
|
|
1586
|
+
// children-only row by definition whether it lands on a new row or
|
|
1587
|
+
// upgrades an existing
|
|
1588
|
+
// children-only one. pantheon_delegate pending parts keep source 'live'.
|
|
1589
|
+
const isNativeLive = liveEntry.tool === 'task' && liveEntry.alias === null
|
|
1590
|
+
if (isNativeLive) {
|
|
1591
|
+
// Native task() rows carry no board alias — reuse the synthetic
|
|
1592
|
+
// 'native-task' marker so the single identity falls back to the agent.
|
|
1593
|
+
incoming.source = 'children-only'
|
|
1594
|
+
incoming.alias = 'native-task'
|
|
1595
|
+
}
|
|
1524
1596
|
const index =
|
|
1525
1597
|
(incoming.taskID !== undefined ? byTask.get(incoming.taskID) : undefined) ??
|
|
1526
1598
|
(incoming.alias !== ''
|
|
@@ -1568,7 +1640,10 @@ export function mergeChildDelegationSources(
|
|
|
1568
1640
|
startedAt: Math.min(existing.startedAt, incoming.startedAt),
|
|
1569
1641
|
updatedAt: incoming.updatedAt,
|
|
1570
1642
|
read: incoming.read,
|
|
1571
|
-
|
|
1643
|
+
// A native task() live upgrade must not flip a children-only row to
|
|
1644
|
+
// 'live' (that would drop the children-only provenance); pantheon_delegate
|
|
1645
|
+
// upgrades still take source 'live'.
|
|
1646
|
+
source: isNativeLive ? existing.source : 'live',
|
|
1572
1647
|
}
|
|
1573
1648
|
}
|
|
1574
1649
|
|
|
@@ -1713,7 +1788,7 @@ export function parseDelegationToolPart(
|
|
|
1713
1788
|
// The alias/taskID only exist once the delegate tool COMPLETES (they are
|
|
1714
1789
|
// returned in its output); a running/pending part has neither. Native
|
|
1715
1790
|
// `task` parts never carry a board alias — the children channel supplies
|
|
1716
|
-
// the child session id
|
|
1791
|
+
// the child session id and the row's identity.
|
|
1717
1792
|
let alias: string | null = null
|
|
1718
1793
|
let taskID: string | null = null
|
|
1719
1794
|
if (status === 'completed') {
|
|
@@ -1787,7 +1862,7 @@ export function reduceDelegationToolPart(
|
|
|
1787
1862
|
// job launched (running). A COMPLETED delegate tool only means the job was
|
|
1788
1863
|
// registered — it stays running and we pick up alias + taskID from the
|
|
1789
1864
|
// output. Native `task` parts never yield a board alias, so their rows
|
|
1790
|
-
// stay
|
|
1865
|
+
// stay identity-less until the children channel resolves them.
|
|
1791
1866
|
const existing = map.get(parsed.callID)
|
|
1792
1867
|
if (parsed.status === 'error') {
|
|
1793
1868
|
if (existing !== undefined && existing.state === 'error' && existing.updatedAt === parsed.endAt)
|
|
@@ -1872,7 +1947,7 @@ export function removeDelegationEntry(
|
|
|
1872
1947
|
* SDK exposes `api.state.part(messageID)`). The native `task` tool spawns a
|
|
1873
1948
|
* child session with parentID = caller — the same mechanism as
|
|
1874
1949
|
* pantheon_delegate — so its parts feed the live-map as the native signal
|
|
1875
|
-
* (rows
|
|
1950
|
+
* (rows come from the children channel). Pure w.r.t. I/O — used
|
|
1876
1951
|
* by the mount re-scan to re-seed the live map after compaction/attach. */
|
|
1877
1952
|
export function collectDelegationToolParts(
|
|
1878
1953
|
messages: readonly { id?: string; parts?: unknown[] }[] | undefined,
|
|
@@ -1970,6 +2045,95 @@ export function mergeDelegationSources(
|
|
|
1970
2045
|
return all
|
|
1971
2046
|
}
|
|
1972
2047
|
|
|
2048
|
+
/* ─── Board channel: record → display + authoritative merge ───────────── */
|
|
2049
|
+
|
|
2050
|
+
/** Job-board FSM → panel display state. `reconciled` is intentionally absent:
|
|
2051
|
+
* the panel parser rejects it (a reconciled job is done and folded away). */
|
|
2052
|
+
const BOARD_STATE_TO_DISPLAY: Record<string, DelegationEntry['state']> = {
|
|
2053
|
+
running: 'running',
|
|
2054
|
+
completed: 'completed',
|
|
2055
|
+
error: 'error',
|
|
2056
|
+
startup_failed: 'startup_failed',
|
|
2057
|
+
startup_unknown: 'startup_unknown',
|
|
2058
|
+
cancelled: 'cancelled',
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
/** Map one persisted board record into the display shape. Returns null for
|
|
2062
|
+
* states the panel does not render (reconciled / unknown). Pure. */
|
|
2063
|
+
export function boardRecordToDelegationEntry(record: BoardJobRecord): DelegationEntry | null {
|
|
2064
|
+
const state = BOARD_STATE_TO_DISPLAY[record.state]
|
|
2065
|
+
if (state === undefined) return null
|
|
2066
|
+
const running = state === 'running'
|
|
2067
|
+
return {
|
|
2068
|
+
alias: record.alias !== '' ? record.alias : `job-${record.taskID.slice(0, 8)}`,
|
|
2069
|
+
sessionID: record.parentSessionID,
|
|
2070
|
+
taskID: record.taskID,
|
|
2071
|
+
agent: record.agent,
|
|
2072
|
+
state,
|
|
2073
|
+
startedAt: record.launchedAt ?? record.updatedAt ?? 0,
|
|
2074
|
+
updatedAt: running ? null : (record.completedAt ?? record.updatedAt ?? null),
|
|
2075
|
+
timedOut: record.timedOut === true,
|
|
2076
|
+
description: record.description ?? '',
|
|
2077
|
+
source: 'board',
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
/** Map a whole board snapshot, dropping unrenderable states. Sorted like every
|
|
2082
|
+
* other channel (running first, then most recent). Pure. */
|
|
2083
|
+
export function boardRecordsToDelegationEntries(
|
|
2084
|
+
records: readonly BoardJobRecord[],
|
|
2085
|
+
): DelegationEntry[] {
|
|
2086
|
+
const out: DelegationEntry[] = []
|
|
2087
|
+
for (const record of records) {
|
|
2088
|
+
const entry = boardRecordToDelegationEntry(record)
|
|
2089
|
+
if (entry !== null) out.push(entry)
|
|
2090
|
+
}
|
|
2091
|
+
out.sort(compareDelegationEntries)
|
|
2092
|
+
return out
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
/** Merge the board over any other channel. The board is AUTHORITATIVE for
|
|
2096
|
+
* state/alias/agent (it is the persisted FSM) and it is the only channel
|
|
2097
|
+
* that carries jobs from OTHER sessions. Dedup by taskID first, then by
|
|
2098
|
+
* (sessionID, alias) — aliases are per parent session. Pure. */
|
|
2099
|
+
export function mergeBoardDelegationSources(
|
|
2100
|
+
base: readonly DelegationEntry[],
|
|
2101
|
+
board: readonly DelegationEntry[],
|
|
2102
|
+
): DelegationEntry[] {
|
|
2103
|
+
const key = (sessionID: string, alias: string): string => `${sessionID}\u0000${alias}`
|
|
2104
|
+
const byTaskID = new Map<string, number>()
|
|
2105
|
+
const bySessionAlias = new Map<string, number>()
|
|
2106
|
+
const out = [...base]
|
|
2107
|
+
out.forEach((entry, index) => {
|
|
2108
|
+
if (entry.taskID !== undefined) byTaskID.set(entry.taskID, index)
|
|
2109
|
+
bySessionAlias.set(key(entry.sessionID, entry.alias), index)
|
|
2110
|
+
})
|
|
2111
|
+
|
|
2112
|
+
for (const incoming of board) {
|
|
2113
|
+
const index =
|
|
2114
|
+
(incoming.taskID !== undefined ? byTaskID.get(incoming.taskID) : undefined) ??
|
|
2115
|
+
bySessionAlias.get(key(incoming.sessionID, incoming.alias))
|
|
2116
|
+
if (index === undefined) {
|
|
2117
|
+
out.push(incoming)
|
|
2118
|
+
const next = out.length - 1
|
|
2119
|
+
if (incoming.taskID !== undefined) byTaskID.set(incoming.taskID, next)
|
|
2120
|
+
bySessionAlias.set(key(incoming.sessionID, incoming.alias), next)
|
|
2121
|
+
continue
|
|
2122
|
+
}
|
|
2123
|
+
out[index] = {
|
|
2124
|
+
...incoming,
|
|
2125
|
+
// Session preservation: the board is authoritative for state/alias/agent,
|
|
2126
|
+
// but an EMPTY board sessionID (record without parentSessionID) must
|
|
2127
|
+
// never clobber a known session — otherwise a focused-session row would
|
|
2128
|
+
// lose its attribution. Keep the known session.
|
|
2129
|
+
sessionID: incoming.sessionID !== '' ? incoming.sessionID : (out[index]?.sessionID ?? ''),
|
|
2130
|
+
} // board wins — it is the persisted source of truth
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
out.sort(compareDelegationEntries)
|
|
2134
|
+
return out
|
|
2135
|
+
}
|
|
2136
|
+
|
|
1973
2137
|
/* ─── Children channel (primary source, delegations-sidebar pattern) ────
|
|
1974
2138
|
* `api.client.session.children` returns the child sessions of the current
|
|
1975
2139
|
* session — every pantheon_delegate spawns one (parentID = caller). The
|
|
@@ -2089,37 +2253,72 @@ export function childStatusToState(status: string | undefined): 'running' | 'com
|
|
|
2089
2253
|
return 'running' // busy or unknown
|
|
2090
2254
|
}
|
|
2091
2255
|
|
|
2092
|
-
/**
|
|
2093
|
-
* that split native task() work from pantheon_delegate jobs — the other is
|
|
2094
|
-
* {@link delegationIcon}):
|
|
2256
|
+
/** Status-only row model: ONE glyph + ONE identity per row.
|
|
2095
2257
|
*
|
|
2096
|
-
*
|
|
2097
|
-
*
|
|
2098
|
-
*
|
|
2099
|
-
*
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
*
|
|
2104
|
-
|
|
2105
|
-
|
|
2258
|
+
* A row is
|
|
2259
|
+
* `{glyph} {alias:7} {elapsed:>5}` plus a muted description line. The short
|
|
2260
|
+
* alias (`apo-1`) is the single identity; rows without a board alias show
|
|
2261
|
+
* the agent instead (never both). */
|
|
2262
|
+
export type DelegationRowStatus = 'active' | 'done' | 'failed' | 'retry'
|
|
2263
|
+
|
|
2264
|
+
/** Map every FSM/display state to one of the 4 row kinds. reconciled never
|
|
2265
|
+
* reaches the panel (the board parser drops it) but reads as done;
|
|
2266
|
+
* cancelled reads as done; startup_failed reads as failed while
|
|
2267
|
+
* startup_unknown (no error known) reads as retry. Pure. */
|
|
2268
|
+
export function delegationRowStatus(state: DelegationDisplayState): DelegationRowStatus {
|
|
2269
|
+
switch (state) {
|
|
2270
|
+
case 'running':
|
|
2271
|
+
case 'stale-running':
|
|
2272
|
+
return 'active'
|
|
2273
|
+
case 'retry':
|
|
2274
|
+
case 'startup_unknown':
|
|
2275
|
+
return 'retry'
|
|
2276
|
+
case 'completed':
|
|
2277
|
+
case 'cancelled':
|
|
2278
|
+
return 'done'
|
|
2279
|
+
case 'error':
|
|
2280
|
+
case 'startup_failed':
|
|
2281
|
+
return 'failed'
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
/** The only 4 glyphs a row may show: animated active, done, failed, retry.
|
|
2286
|
+
* Shape is redundant with color (never the only signal). Pure. */
|
|
2287
|
+
export const DELEGATION_ROW_GLYPHS: Readonly<Record<DelegationRowStatus, string>> = {
|
|
2288
|
+
active: '⠋', // base frame of the animated spinner
|
|
2289
|
+
done: '✓',
|
|
2290
|
+
failed: '✕',
|
|
2291
|
+
retry: '⟳',
|
|
2106
2292
|
}
|
|
2107
2293
|
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2294
|
+
export function delegationRowGlyph(status: DelegationRowStatus): string {
|
|
2295
|
+
return DELEGATION_ROW_GLYPHS[status]
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
/** Row marker (`<glyph> `) — the single state channel. `active` animates
|
|
2299
|
+
* through the 1s spinner for the given tick; every other kind is static.
|
|
2300
|
+
* Pure. */
|
|
2301
|
+
export function delegationRowMarker(state: DelegationDisplayState, now = Date.now()): string {
|
|
2302
|
+
const status = delegationRowStatus(state)
|
|
2303
|
+
const glyph = status === 'active' ? delegationSpinnerFrame(now) : delegationRowGlyph(status)
|
|
2304
|
+
return `${glyph} `
|
|
2114
2305
|
}
|
|
2115
2306
|
|
|
2116
|
-
/**
|
|
2117
|
-
*
|
|
2118
|
-
*
|
|
2119
|
-
export function
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2307
|
+
/** ONE identity per row: the short board alias (`apo-1`); a row without a
|
|
2308
|
+
* board alias (native task() child, alias `native-task`) shows the agent
|
|
2309
|
+
* instead — never alias + agent stacked. Pure. */
|
|
2310
|
+
export function delegationRowIdentity(entry: DelegationEntry): string {
|
|
2311
|
+
if (entry.alias === 'native-task' && entry.agent !== '') return entry.agent
|
|
2312
|
+
return entry.alias
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
/** Fixed alias cell, pad-right + hard-truncate to `width` (default 7) so
|
|
2316
|
+
* identities line up across rows. Pure. */
|
|
2317
|
+
export const DELEGATION_ALIAS_WIDTH = 7
|
|
2318
|
+
|
|
2319
|
+
export function formatDelegationAlias(identity: string, width = DELEGATION_ALIAS_WIDTH): string {
|
|
2320
|
+
if (identity.length >= width) return identity.slice(0, width)
|
|
2321
|
+
return `${identity}${' '.repeat(width - identity.length)}`
|
|
2123
2322
|
}
|
|
2124
2323
|
|
|
2125
2324
|
/** Max description width on the row detail line — the old 180-char slice
|
|
@@ -2153,10 +2352,10 @@ export function truncateDelegationDescription(
|
|
|
2153
2352
|
return `${graphemes.slice(0, Math.max(0, max - 1)).join('')}\u2026`
|
|
2154
2353
|
}
|
|
2155
2354
|
|
|
2156
|
-
/** Fixed elapsed-time box: right-aligned to `width` (default
|
|
2157
|
-
* values line up across rows, e.g. `
|
|
2355
|
+
/** Fixed elapsed-time box: right-aligned to `width` (default 5) so elapsed
|
|
2356
|
+
* values line up across rows, e.g. ` 12s`. A label that already fills
|
|
2158
2357
|
* the box is returned untouched. Pure. */
|
|
2159
|
-
export const DELEGATION_ELAPSED_WIDTH =
|
|
2358
|
+
export const DELEGATION_ELAPSED_WIDTH = 5
|
|
2160
2359
|
|
|
2161
2360
|
export function formatDelegationElapsed(
|
|
2162
2361
|
entry: DelegationEntry,
|
|
@@ -2168,10 +2367,11 @@ export function formatDelegationElapsed(
|
|
|
2168
2367
|
return `${' '.repeat(width - label.length)}${label}`
|
|
2169
2368
|
}
|
|
2170
2369
|
|
|
2171
|
-
/**
|
|
2172
|
-
*
|
|
2173
|
-
|
|
2174
|
-
|
|
2370
|
+
/** Colored left half of a row line: `<marker><alias:7> ` — the state glyph
|
|
2371
|
+
* plus the single identity, padded so the muted elapsed column aligns. The
|
|
2372
|
+
* row renders this colored lead separately from the muted elapsed tail. Pure. */
|
|
2373
|
+
export function formatDelegationRowLead(entry: DelegationEntry, marker: string): string {
|
|
2374
|
+
return `${marker}${formatDelegationAlias(delegationRowIdentity(entry))} `
|
|
2175
2375
|
}
|
|
2176
2376
|
|
|
2177
2377
|
// Tree connectors (`isNestedDelegation` / `delegationTreePrefix`) were removed
|
|
@@ -2179,22 +2379,52 @@ export function formatDelegationRow(entry: DelegationEntry, marker: string): str
|
|
|
2179
2379
|
// ever actually nested and the connectors were never populated in production.
|
|
2180
2380
|
// BACKLOG: produce real grandchild rows first, then reintroduce them.
|
|
2181
2381
|
|
|
2182
|
-
/**
|
|
2183
|
-
*
|
|
2184
|
-
*
|
|
2382
|
+
/** Visible-row ceiling for the panel: the remainder collapses into a single
|
|
2383
|
+
* "… +N more" line. Live rows render first, so a running job is never hidden
|
|
2384
|
+
* by the cap. */
|
|
2385
|
+
export const DELEGATION_VISIBLE_CEILING = 8
|
|
2386
|
+
|
|
2387
|
+
/** Header summary: `(N active · M done)` plus `· K failed` only when K > 0.
|
|
2388
|
+
* Active counts running/retry AND display-only stale-running (a stale row is
|
|
2389
|
+
* still a live job — it must never read as done); failed counts
|
|
2390
|
+
* error/startup_failed; cancelled reads as done. Pure. */
|
|
2185
2391
|
export function formatDelegationHeader(entries: readonly DelegationEntry[]): string {
|
|
2186
2392
|
let active = 0
|
|
2393
|
+
let done = 0
|
|
2394
|
+
let failed = 0
|
|
2187
2395
|
for (const e of entries) {
|
|
2188
|
-
|
|
2396
|
+
switch (delegationRowStatus(e.state)) {
|
|
2397
|
+
case 'active':
|
|
2398
|
+
case 'retry':
|
|
2399
|
+
active++
|
|
2400
|
+
break
|
|
2401
|
+
case 'failed':
|
|
2402
|
+
failed++
|
|
2403
|
+
break
|
|
2404
|
+
case 'done':
|
|
2405
|
+
done++
|
|
2406
|
+
break
|
|
2407
|
+
}
|
|
2189
2408
|
}
|
|
2190
|
-
const
|
|
2191
|
-
|
|
2192
|
-
|
|
2409
|
+
const tail = failed > 0 ? ` · ${failed} failed` : ''
|
|
2410
|
+
return `(${active} active · ${done} done${tail})`
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
/** Cap the panel: live rows first, then most-recent terminal rows, at most
|
|
2414
|
+
* `maxVisible` total. `hidden` is how many were dropped (rendered as
|
|
2415
|
+
* "… +N more"). Pure. */
|
|
2416
|
+
export function ceilingDelegationList(
|
|
2417
|
+
all: readonly DelegationEntry[],
|
|
2418
|
+
maxVisible = DELEGATION_VISIBLE_CEILING,
|
|
2419
|
+
now = Date.now(),
|
|
2420
|
+
): { visible: DelegationEntry[]; hidden: number } {
|
|
2421
|
+
const visible = visibleDelegationList(all, maxVisible, now).slice(0, maxVisible)
|
|
2422
|
+
return { visible, hidden: Math.max(0, all.length - visible.length) }
|
|
2193
2423
|
}
|
|
2194
2424
|
|
|
2195
2425
|
/** Split a display list into native task() rows vs pantheon_delegate rows.
|
|
2196
2426
|
* Native = source 'children-only' (no board report); everything else counts
|
|
2197
|
-
* as pantheon. Pure — powers the
|
|
2427
|
+
* as pantheon. Pure — powers the hooks.log line. */
|
|
2198
2428
|
export function countDelegationSources(entries: readonly DelegationEntry[]): {
|
|
2199
2429
|
native: number
|
|
2200
2430
|
pantheon: number
|
|
@@ -2227,18 +2457,22 @@ export function formatPanelLogLine(
|
|
|
2227
2457
|
* time.created. A report-less child is a NATIVE task() child (every
|
|
2228
2458
|
* child of the current session — pantheon_delegate OR the native `task()`
|
|
2229
2459
|
* tool — carries parentID = caller), so it gets source 'children-only',
|
|
2230
|
-
* the internal alias 'native-task'
|
|
2460
|
+
* the internal alias 'native-task' instead of a
|
|
2231
2461
|
* board alias. The 'task nativa' description fallback keeps the row
|
|
2232
2462
|
* non-empty when the child carries no title.
|
|
2233
2463
|
* Terminal md state wins over the derived state; a running md defers to
|
|
2234
2464
|
* the child's live status. A running child is NEVER archived — it always
|
|
2235
2465
|
* lands in the active split (splitDelegationList active = running/retry).
|
|
2466
|
+
* `parentSessionID` (the focused session id) is stamped on report-less
|
|
2467
|
+
* children so the row carries its origin session; omit it and they stay
|
|
2468
|
+
* unscoped ('').
|
|
2236
2469
|
* Sorted running-first (compareDelegationEntries).
|
|
2237
2470
|
* Pure — no I/O. */
|
|
2238
2471
|
export function childrenToDelegationEntries(
|
|
2239
2472
|
children: readonly ChildDelegationLike[] | undefined,
|
|
2240
2473
|
md: readonly DelegationEntry[],
|
|
2241
2474
|
now = Date.now(),
|
|
2475
|
+
parentSessionID = '',
|
|
2242
2476
|
): DelegationEntry[] {
|
|
2243
2477
|
const byTaskID = new Map<string, DelegationEntry>()
|
|
2244
2478
|
for (const m of md) {
|
|
@@ -2256,7 +2490,11 @@ export function childrenToDelegationEntries(
|
|
|
2256
2490
|
: childStatusToState(child.status)
|
|
2257
2491
|
out.push({
|
|
2258
2492
|
alias: mdEntry?.alias ?? 'native-task',
|
|
2259
|
-
|
|
2493
|
+
// A report-less child IS a child of the focused session
|
|
2494
|
+
// (session.children is session-scoped), so the caller-passed
|
|
2495
|
+
// parentSessionID is stamped on it as its origin session; an md
|
|
2496
|
+
// match always carries its own session (the report dir name).
|
|
2497
|
+
sessionID: mdEntry?.sessionID ?? parentSessionID,
|
|
2260
2498
|
taskID: child.id,
|
|
2261
2499
|
agent: mdEntry?.agent ?? child.agent ?? 'agent',
|
|
2262
2500
|
state,
|
|
@@ -2293,8 +2531,8 @@ export function navigateToDelegationSession(
|
|
|
2293
2531
|
}
|
|
2294
2532
|
|
|
2295
2533
|
/* ─── Silence-by-default panel logger ──────────────────────
|
|
2296
|
-
* Mirrors src/pantheon/logger.ts createPantheonLogger (the
|
|
2297
|
-
*
|
|
2534
|
+
* Mirrors src/pantheon/logger.ts createPantheonLogger (the copied plugin
|
|
2535
|
+
* ships only dist/ + package.json, so it cannot import from src/pantheon):
|
|
2298
2536
|
* every line is ALWAYS appended to `<logDir>/.pantheon/logs/hooks.log` and
|
|
2299
2537
|
* echoed to the console ONLY when PANTHEON_HOOKS_LOG is truthy (opencode
|
|
2300
2538
|
* renders plugin console output directly into the TUI — the pollution bug).
|
|
@@ -2395,7 +2633,7 @@ function SessionRow(props: { api: TuiPluginApi; session: any }) {
|
|
|
2395
2633
|
)
|
|
2396
2634
|
}
|
|
2397
2635
|
|
|
2398
|
-
/* ─── Delegation Row (single job from the children/md channels) ─ */
|
|
2636
|
+
/* ─── Delegation Row (single job from the children/md/board channels) ─ */
|
|
2399
2637
|
|
|
2400
2638
|
function DelegationRow(props: {
|
|
2401
2639
|
api: TuiPluginApi
|
|
@@ -2405,7 +2643,10 @@ function DelegationRow(props: {
|
|
|
2405
2643
|
}) {
|
|
2406
2644
|
const theme = () => props.api.theme.current
|
|
2407
2645
|
|
|
2408
|
-
|
|
2646
|
+
// Color is a SEPARATE channel from the glyph: the lead (glyph + identity)
|
|
2647
|
+
// carries the state tone, while elapsed + description stay muted so the
|
|
2648
|
+
// state reads at a glance without shouting.
|
|
2649
|
+
const tone = createMemo(() => {
|
|
2409
2650
|
const t = theme()
|
|
2410
2651
|
switch (delegationStateTone(props.job.state)) {
|
|
2411
2652
|
case 'warning':
|
|
@@ -2419,16 +2660,10 @@ function DelegationRow(props: {
|
|
|
2419
2660
|
}
|
|
2420
2661
|
})
|
|
2421
2662
|
|
|
2422
|
-
const marker = createMemo(() =>
|
|
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))
|
|
2663
|
+
const marker = createMemo(() => delegationRowMarker(props.job.state, props.animationNow))
|
|
2664
|
+
const lead = createMemo(() => formatDelegationRowLead(props.job, marker()))
|
|
2431
2665
|
const elapsed = createMemo(() => formatDelegationElapsed(props.job, props.now))
|
|
2666
|
+
const description = createMemo(() => truncateDelegationDescription(props.job.description))
|
|
2432
2667
|
|
|
2433
2668
|
// beta.6: live tool call the child is running RIGHT NOW ("↳ bash npm test"),
|
|
2434
2669
|
// fed by message.part.updated activity tracking. Null while idle/done.
|
|
@@ -2445,22 +2680,22 @@ function DelegationRow(props: {
|
|
|
2445
2680
|
|
|
2446
2681
|
return (
|
|
2447
2682
|
<box onMouseDown={open}>
|
|
2683
|
+
{/* Line 1: colored lead (glyph + identity) + muted elapsed tail. */}
|
|
2448
2684
|
<box flexDirection="row">
|
|
2449
|
-
<text
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
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>
|
|
2685
|
+
<text>
|
|
2686
|
+
<span fg={tone()}>{lead()}</span>
|
|
2687
|
+
<span fg={theme().textMuted}>{elapsed()}</span>
|
|
2688
|
+
</text>
|
|
2458
2689
|
</box>
|
|
2690
|
+
{/* Line 2: the description, indented under the identity and muted. */}
|
|
2691
|
+
<Show when={description() !== ''}>
|
|
2692
|
+
<text fg={theme().textMuted}>{` ${description()}`}</text>
|
|
2693
|
+
</Show>
|
|
2459
2694
|
<Show when={activity()}>
|
|
2460
2695
|
{(a) => (
|
|
2461
2696
|
<text
|
|
2462
2697
|
fg={theme().textMuted}
|
|
2463
|
-
>{`
|
|
2698
|
+
>{` ↳ ${truncateDelegationDescription(`${a().tool}${a().summary !== '' ? ` ${a().summary}` : ''}`)}`}</text>
|
|
2464
2699
|
)}
|
|
2465
2700
|
</Show>
|
|
2466
2701
|
</box>
|
|
@@ -2569,14 +2804,15 @@ function View(props: {
|
|
|
2569
2804
|
// (.pantheon/delegations) enriches each child with alias/agent/description
|
|
2570
2805
|
// and terminal duration via the `Task ID` match. Live tool-call events are
|
|
2571
2806
|
// merged optimistically so the row appears before those channels catch up.
|
|
2572
|
-
|
|
2807
|
+
// Consistent project root (directory ?? worktree, cwd fallback) — the md
|
|
2808
|
+
// history, the board state file and the panel logger all read the SAME
|
|
2809
|
+
// root (audit finding: the channels resolved it independently).
|
|
2810
|
+
const projectRoot = createMemo(() => resolvePantheonRoot((props.api.state as any).path))
|
|
2573
2811
|
// Silence-by-default panel logger → the REAL <root>/.pantheon/logs/hooks.log.
|
|
2574
|
-
|
|
2575
|
-
//
|
|
2576
|
-
//
|
|
2577
|
-
//
|
|
2578
|
-
// hooks.log (the empty nested dirs observed in production).
|
|
2579
|
-
const panelLog = createTuiLogger(panelLogDir(delegationsDir()))
|
|
2812
|
+
const panelLog = createTuiLogger(projectRoot())
|
|
2813
|
+
// Every job the board knows about is shown — no scope filter ("showing is
|
|
2814
|
+
// what matters"). Live-first ordering keeps running rows on top; the
|
|
2815
|
+
// ceiling collapses the rest into "… +N more".
|
|
2580
2816
|
// The rendered list is childDelegations (children enriched with md);
|
|
2581
2817
|
// there is no separate md-only signal — the md channel only feeds
|
|
2582
2818
|
// childrenToDelegationEntries below.
|
|
@@ -2609,10 +2845,19 @@ function View(props: {
|
|
|
2609
2845
|
// the full historical list. Fail-open: a missing dir yields [].
|
|
2610
2846
|
let md: DelegationEntry[] = []
|
|
2611
2847
|
try {
|
|
2612
|
-
md = await readAllDelegationEntries(
|
|
2848
|
+
md = await readAllDelegationEntries(projectRoot())
|
|
2613
2849
|
} catch {
|
|
2614
2850
|
md = [] // fail-open: never crash the sidebar on a read error
|
|
2615
2851
|
}
|
|
2852
|
+
// 0b. board snapshot — the 4th channel. Cross-session and AUTHORITATIVE
|
|
2853
|
+
// for state/alias/agent (the persisted FSM). This is what makes a job
|
|
2854
|
+
// running in ANOTHER session visible. Fail-open: absent/corrupt → [].
|
|
2855
|
+
let board: DelegationEntry[] = []
|
|
2856
|
+
try {
|
|
2857
|
+
board = boardRecordsToDelegationEntries(await readBoardState(projectRoot()))
|
|
2858
|
+
} catch {
|
|
2859
|
+
board = [] // fail-open: never crash the sidebar on a board read error
|
|
2860
|
+
}
|
|
2616
2861
|
// 1. current session — resolve + GUARD (placeholder regression). The
|
|
2617
2862
|
// runtime may render the sidebar with an unsubstituted "{sessionID}"
|
|
2618
2863
|
// slot prop; forwarding it to session.children made the server reject
|
|
@@ -2622,9 +2867,9 @@ function View(props: {
|
|
|
2622
2867
|
// (zero children, zero errors).
|
|
2623
2868
|
const sessionID = resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api })
|
|
2624
2869
|
if (sessionID === null) {
|
|
2625
|
-
setChildDelegations(md)
|
|
2870
|
+
setChildDelegations(mergeBoardDelegationSources(md, board))
|
|
2626
2871
|
panelLog.info(
|
|
2627
|
-
`${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — history shown)`,
|
|
2872
|
+
`${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — history+board shown)`,
|
|
2628
2873
|
)
|
|
2629
2874
|
return
|
|
2630
2875
|
}
|
|
@@ -2655,7 +2900,10 @@ function View(props: {
|
|
|
2655
2900
|
}
|
|
2656
2901
|
}
|
|
2657
2902
|
const withStatus = children.map((c) => ({ ...c, status: resolveStatus(c.id) }))
|
|
2658
|
-
|
|
2903
|
+
// Report-less rows ARE children of the focused session
|
|
2904
|
+
// (session.children is session-scoped), so the resolved id is stamped on
|
|
2905
|
+
// the row as its origin session.
|
|
2906
|
+
const childEntries = childrenToDelegationEntries(withStatus, md, now(), sessionID)
|
|
2659
2907
|
for (const [k, v] of props.liveStore.map)
|
|
2660
2908
|
if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 30_000)
|
|
2661
2909
|
props.liveStore.map.delete(k)
|
|
@@ -2665,7 +2913,14 @@ function View(props: {
|
|
|
2665
2913
|
const liveEntries = [...props.liveStore.map.values()].filter(
|
|
2666
2914
|
(entry) => entry.sessionID === sessionID,
|
|
2667
2915
|
)
|
|
2668
|
-
|
|
2916
|
+
// Board LAST so it is authoritative over children/md/live for the same
|
|
2917
|
+
// job, and adds the rows from other sessions the children API cannot see.
|
|
2918
|
+
setChildDelegations(
|
|
2919
|
+
mergeBoardDelegationSources(
|
|
2920
|
+
mergeChildDelegationSources(childEntries, liveEntries),
|
|
2921
|
+
board,
|
|
2922
|
+
),
|
|
2923
|
+
)
|
|
2669
2924
|
// 4. diagnostic line — every re-fetch (silence-by-default, hooks.log).
|
|
2670
2925
|
const counts = countDelegationSources(childEntries)
|
|
2671
2926
|
panelLog.info(
|
|
@@ -2683,24 +2938,13 @@ function View(props: {
|
|
|
2683
2938
|
})()
|
|
2684
2939
|
return delegationsInflight
|
|
2685
2940
|
}
|
|
2686
|
-
//
|
|
2687
|
-
//
|
|
2688
|
-
//
|
|
2689
|
-
const
|
|
2690
|
-
|
|
2691
|
-
|
|
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)".
|
|
2941
|
+
// Live-first ceiling: at most DELEGATION_VISIBLE_CEILING rows render; the
|
|
2942
|
+
// remainder collapses into a single "… +N more" line. The header counts the
|
|
2943
|
+
// FULL board list — every session, no scope filter.
|
|
2944
|
+
const delegationCeiling = createMemo(() =>
|
|
2945
|
+
ceilingDelegationList(childDelegations(), DELEGATION_VISIBLE_CEILING, now()),
|
|
2946
|
+
)
|
|
2697
2947
|
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
2948
|
|
|
2705
2949
|
// ── Event subscriptions for live session/delegation updates ──
|
|
2706
2950
|
onMount(() => {
|
|
@@ -2840,16 +3084,18 @@ function View(props: {
|
|
|
2840
3084
|
</Show>
|
|
2841
3085
|
</Show>
|
|
2842
3086
|
|
|
2843
|
-
{/* ── Delegations (session.children primary + .pantheon/delegations md enrichment) ── */}
|
|
3087
|
+
{/* ── Delegations (session.children primary + .pantheon/delegations md enrichment + board snapshot) ── */}
|
|
2844
3088
|
<box onMouseDown={() => setShowDelegations((x) => !x)}>
|
|
2845
3089
|
<text fg={theme().text} attributes={1}>
|
|
2846
3090
|
{`${showDelegations() ? '▼' : '▶'} Delegations`}
|
|
2847
3091
|
</text>
|
|
2848
|
-
<text fg={theme().textMuted}>
|
|
3092
|
+
<text fg={theme().textMuted}>
|
|
3093
|
+
{childDelegations().length > 0 ? ` ${delegationHeader()}` : ' — idle'}
|
|
3094
|
+
</text>
|
|
2849
3095
|
</box>
|
|
2850
3096
|
<Show when={showDelegations()}>
|
|
2851
3097
|
<Show
|
|
2852
|
-
when={
|
|
3098
|
+
when={delegationCeiling().visible.length > 0}
|
|
2853
3099
|
fallback={
|
|
2854
3100
|
<box marginLeft={1}>
|
|
2855
3101
|
<text fg={theme().textMuted}>No delegations</text>
|
|
@@ -2857,7 +3103,7 @@ function View(props: {
|
|
|
2857
3103
|
}
|
|
2858
3104
|
>
|
|
2859
3105
|
<box marginLeft={1} flexDirection="column">
|
|
2860
|
-
<For each={
|
|
3106
|
+
<For each={delegationCeiling().visible}>
|
|
2861
3107
|
{(job) => (
|
|
2862
3108
|
<DelegationRow
|
|
2863
3109
|
api={props.api}
|
|
@@ -2867,40 +3113,8 @@ function View(props: {
|
|
|
2867
3113
|
/>
|
|
2868
3114
|
)}
|
|
2869
3115
|
</For>
|
|
2870
|
-
<Show when={
|
|
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
|
-
})()}
|
|
3116
|
+
<Show when={delegationCeiling().hidden > 0}>
|
|
3117
|
+
<text fg={theme().textMuted}>{`… +${delegationCeiling().hidden} more`}</text>
|
|
2904
3118
|
</Show>
|
|
2905
3119
|
</box>
|
|
2906
3120
|
</Show>
|