pantheon-opencode 1.5.0-beta.8 → 1.5.0
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/AGENTS.md +14 -63
- package/CHANGELOG.md +320 -0
- package/README.md +14 -27
- package/README.pt-BR.md +5 -6
- package/commands/pantheon.md +1 -35
- package/package.json +1 -2
- package/plugin.json +1 -1
- package/pyproject.toml +2 -5
- package/scripts/doctor.mjs +32 -3
- package/scripts/install/plugin.mjs +9 -12
- package/scripts/mcp_persistence_server.py +1 -1
- package/scripts/sync-tui.mjs +8 -11
- package/src/agents/apollo.md +1 -0
- package/src/agents/gaia.md +1 -0
- package/src/agents/zeus.md +9 -52
- package/src/instructions/memory-protocol.instructions.md +6 -3
- package/src/instructions/zeus-anti-stall.instructions.md +2 -4
- package/src/instructions/zeus-council-synthesis.instructions.md +3 -28
- package/src/instructions/zeus-timeout-retry.instructions.md +3 -28
- package/src/mcp/mcp_persistence_server.py +1 -1
- package/src/mcp/toon_codec.py +2 -4
- package/src/pantheon/background-job-board.ts +4 -4
- package/src/pantheon/{delegation-compaction.ts → compaction-context.ts} +10 -100
- package/src/pantheon/cost-command.ts +1 -2
- package/src/pantheon/delegation-enforce.ts +59 -14
- package/src/pantheon/goal-loop.ts +1 -1
- package/src/pantheon/goal-store.ts +1 -1
- package/src/pantheon/idle-continuation.ts +4 -6
- package/src/pantheon/model-command.ts +1 -2
- package/src/pantheon/native-task-status.ts +2 -2
- package/src/pantheon/presets.d.mts +0 -20
- package/src/pantheon/presets.mjs +0 -64
- package/src/pantheon/session-guard.ts +2 -2
- package/src/pantheon/session-id.ts +30 -0
- package/src/pantheon/task-result-guard.ts +2 -3
- package/src/pantheon/todo-enforcer.ts +3 -3
- package/src/pantheon/tool-context.ts +23 -0
- package/src/pantheon/v2-bridge.ts +1 -4
- package/src/plugin-v2.ts +8 -21
- package/src/plugin.ts +61 -245
- package/src/plugins/pantheon-hooks.ts +1 -1
- package/src/plugins/tui/dist/tui.d.ts +229 -77
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +509 -259
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +2 -2
- package/src/plugins/tui/src/index.tsx +753 -279
- package/src/routing.yml +0 -16
- package/src/skills/context-compression/SKILL.md +1 -1
- package/scripts/generate-preset-docs.mjs +0 -300
- package/scripts/generate-prompts.sh +0 -222
- package/scripts/hash_verify.py +0 -192
- package/scripts/install/component.mjs +0 -24
- package/scripts/install/migrate-config.mjs +0 -302
- package/scripts/test-context-compression.sh +0 -169
- package/src/pantheon/auto-wake.ts +0 -85
- package/src/pantheon/chat-reminders.ts +0 -9
- package/src/pantheon/comment-checker.ts +0 -48
- package/src/pantheon/compaction-assert.ts +0 -160
- package/src/pantheon/compaction-handoff.ts +0 -304
- package/src/pantheon/cost-tracker.ts +0 -108
- package/src/pantheon/delegate-manager.ts +0 -766
- package/src/pantheon/delegation-classifier.ts +0 -307
- package/src/pantheon/delegation-finalize.ts +0 -406
- package/src/pantheon/delegation-notify.ts +0 -212
- package/src/pantheon/delegation-toast.ts +0 -73
- package/src/pantheon/delegation.ts +0 -1337
- package/src/pantheon/dispatch-guard.ts +0 -109
- package/src/pantheon/native-probe.ts +0 -234
- package/src/pantheon/permission-eval.ts +0 -284
- package/src/pantheon/preemptive-compact.ts +0 -76
- package/src/pantheon/provider-capabilities.ts +0 -221
- package/src/pantheon/provider-stream-tracker.ts +0 -182
- package/src/pantheon/retry-policy.ts +0 -212
- package/src/pantheon/step-cap.ts +0 -110
- package/src/pantheon/token-opt.ts +0 -689
- package/src/pantheon/toon-codec.ts +0 -481
- package/src/pantheon/v2-events.ts +0 -148
- package/src/pantheon/v2-hooks.ts +0 -287
- package/src/pantheon/v2-tool-definitions.ts +0 -331
- package/src/pantheon/zeus-delegate-with-retry.ts +0 -406
- package/src/plugins/tui/dist/tui.tsx +0 -2777
|
@@ -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/
|
|
@@ -47,7 +48,7 @@
|
|
|
47
48
|
|
|
48
49
|
import { Buffer } from 'node:buffer'
|
|
49
50
|
import type { Dirent } from 'node:fs'
|
|
50
|
-
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
51
|
+
import { appendFile, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
51
52
|
import { homedir } from 'node:os'
|
|
52
53
|
import { dirname, join } from 'node:path'
|
|
53
54
|
import { fileURLToPath } from 'node:url'
|
|
@@ -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')
|
|
@@ -885,49 +886,54 @@ async function setupUsageBar(api: TuiPluginApi) {
|
|
|
885
886
|
* CHILDREN + MD (delegations-sidebar pattern, feat/compaction-134):
|
|
886
887
|
*
|
|
887
888
|
* 1. PRIMARY — SDK child sessions via `api.client.session.children`:
|
|
888
|
-
* every
|
|
889
|
-
* parentID = caller (
|
|
889
|
+
* every delegated child session is created with
|
|
890
|
+
* parentID = caller (native `task()` child session), so
|
|
890
891
|
* the children of the current session ARE the delegation list — the
|
|
891
892
|
* same mechanism the opencode-delegations-sidebar reference uses
|
|
892
893
|
* (`client.session.children` + `session.parentID === current`).
|
|
893
894
|
* Live status comes from `api.state.session.status(childID)`
|
|
894
895
|
* (busy/retry → running; idle → terminal). This does NOT depend on
|
|
895
896
|
* tool-part events, which runtime 1.18.13 may not deliver for
|
|
896
|
-
*
|
|
897
|
+
* native `task()` (the "(0) even while delegating" symptom).
|
|
897
898
|
*
|
|
898
899
|
* 1b. NATIVE task() CHILDREN — `session.children` returns ALL children,
|
|
899
900
|
* and the native `task()` tool ALSO spawns a child session with
|
|
900
|
-
* parentID = caller. A child WITHOUT a
|
|
901
|
+
* parentID = caller. A child WITHOUT a delegate report is a native
|
|
901
902
|
* task() child: childrenToDelegationEntries tags it source
|
|
902
|
-
* 'children-only'
|
|
903
|
-
* so native task() work is visible in the panel alongside
|
|
904
|
-
*
|
|
905
|
-
* with a
|
|
903
|
+
* 'children-only' (distinct info color),
|
|
904
|
+
* so native task() work is visible in the panel alongside delegate
|
|
905
|
+
* children — never filtered out, never duplicated
|
|
906
|
+
* with a report row for the same child (a child WITH a report keeps the
|
|
906
907
|
* md entry, source 'md').
|
|
907
908
|
*
|
|
908
|
-
* 2. ENRICHMENT
|
|
909
|
-
* `.pantheon/delegations/<sessionID>/<alias>.md` (
|
|
910
|
-
*
|
|
909
|
+
* 2. ENRICHMENT — markdown reports written under
|
|
910
|
+
* `.pantheon/delegations/<sessionID>/<alias>.md` (legacy delegate output;
|
|
911
|
+
* the delegate layer was removed, existing reports stay readable). Each
|
|
912
|
+
* child
|
|
911
913
|
* is matched to its report by the `Task ID` header (== child session
|
|
912
|
-
* id ==
|
|
914
|
+
* id == task id), which supplies the alias (apo-N), agent,
|
|
913
915
|
* description and terminal duration/timedOut. No report → the child
|
|
914
916
|
* itself still renders (title as description, derived state). The md
|
|
915
|
-
* channel is
|
|
916
|
-
*
|
|
917
|
-
*
|
|
917
|
+
* channel is read from EVERY session (readAllDelegationEntries) as an
|
|
918
|
+
* enrichment source, but the ACTIVE-SESSION FILTER
|
|
919
|
+
* (filterDelegationsToSession) drops every non-focused report — the panel
|
|
920
|
+
* lists only the focused session.
|
|
918
921
|
*
|
|
919
922
|
* 3. REFRESH — session.created/updated/deleted/status events and
|
|
920
923
|
* message.part.updated/removed (via liveStore.version bumps) re-fetch
|
|
921
924
|
* children; a 1s safety poll (oh-my-opencode-slim pattern) re-fetches
|
|
922
925
|
* children + re-reads the md so the panel updates EVEN without events.
|
|
923
926
|
*
|
|
924
|
-
* 4. DIAGNOSTICS — every re-fetch logs
|
|
927
|
+
* 4. DIAGNOSTICS — every re-fetch logs
|
|
928
|
+
* "panel: children=N(pantheon=M native=K) md=N events=N"
|
|
925
929
|
* to `.pantheon/logs/hooks.log` (silence-by-default, createPantheonLogger
|
|
926
|
-
* policy) so the (0) symptom is diagnosable from disk
|
|
930
|
+
* policy) so the (0) symptom is diagnosable from disk: children>0 means
|
|
931
|
+
* the source works, pantheon/native splits report vs task() children.
|
|
932
|
+
* A children fetch
|
|
927
933
|
* failure logs "panel: error <msg>" (previously silenced), and a missing
|
|
928
934
|
* current session (null/placeholder sessionID) logs "panel: children=0
|
|
929
|
-
* md=N ... (no sessionID —
|
|
930
|
-
* instead of erroring.
|
|
935
|
+
* md=N ... (no sessionID — inactive panel)" — the panel stays EMPTY
|
|
936
|
+
* instead of erroring (no cross-session history).
|
|
931
937
|
*
|
|
932
938
|
* 5. SESSIONID GUARD (placeholder regression) — the runtime may hand the
|
|
933
939
|
* sidebar the UNSUBSTITUTED template literal "{sessionID}" as its
|
|
@@ -937,8 +943,8 @@ async function setupUsageBar(api: TuiPluginApi) {
|
|
|
937
943
|
* panel into "(0)". resolveCurrentSessionID (slot prop → api.state →
|
|
938
944
|
* route.current) never yields a placeholder (isValidSessionId rejects
|
|
939
945
|
* "{sessionID}" and "%7BsessionID%7D" via the startsWith("ses")
|
|
940
|
-
* contract); a null resolution SKIPS the children fetch entirely and
|
|
941
|
-
* renders
|
|
946
|
+
* contract); a null resolution SKIPS the children fetch entirely and the
|
|
947
|
+
* panel renders EMPTY — zero rows, zero errors.
|
|
942
948
|
*
|
|
943
949
|
* The live tool-part lifecycle helpers below remain (tested, exported) but
|
|
944
950
|
* are no longer the source of truth: they feed a version signal that merely
|
|
@@ -951,7 +957,7 @@ export type DelegationEntry = {
|
|
|
951
957
|
alias: string
|
|
952
958
|
/** Parent session the job was launched from (dir name under .pantheon/delegations). */
|
|
953
959
|
sessionID: string
|
|
954
|
-
/** Child session id (=
|
|
960
|
+
/** Child session id (= task id, from the `Task ID` header). The
|
|
955
961
|
* children channel always sets it from the child session itself; the md
|
|
956
962
|
* channel parses it so child↔md matching works by taskID. */
|
|
957
963
|
taskID?: string
|
|
@@ -974,9 +980,15 @@ export type DelegationEntry = {
|
|
|
974
980
|
description: string
|
|
975
981
|
/** True while the panel is waiting for pantheon_delegation_read. */
|
|
976
982
|
read?: boolean
|
|
983
|
+
// NOTE: the nested/tree view is BACKLOG. `session.children` returns direct
|
|
984
|
+
// children only, so no real grandchild rows exist yet; the parentTaskID /
|
|
985
|
+
// childCount fields + tree-connector helpers were removed as dead code.
|
|
986
|
+
// Reintroduce them once nested rows are actually produced.
|
|
977
987
|
/** Internal provenance used to keep a finalized md report authoritative.
|
|
978
|
-
* 'children-only' = a native task() child session with NO
|
|
979
|
-
* (
|
|
988
|
+
* 'children-only' = a native task() child session with NO delegate report
|
|
989
|
+
* (a native child row); 'md' = the child has a matching report;
|
|
990
|
+
* 'child' = the plain children channel; 'live' = the optimistic tool-event
|
|
991
|
+
* channel. */
|
|
980
992
|
source?: 'child' | 'live' | 'md' | 'children-only'
|
|
981
993
|
}
|
|
982
994
|
|
|
@@ -1152,29 +1164,39 @@ export async function readDelegationEntries(dir: string): Promise<DelegationEntr
|
|
|
1152
1164
|
|
|
1153
1165
|
/** Read delegation reports from EVERY session under
|
|
1154
1166
|
* `<root>/.pantheon/delegations/<sessionID>/<alias>.md` — the panel's
|
|
1155
|
-
*
|
|
1156
|
-
*
|
|
1157
|
-
*
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
1167
|
+
* ENRICHMENT source. The read is deliberately unfiltered (each entry carries
|
|
1168
|
+
* its directory's sessionID, so a taskID match can enrich any channel);
|
|
1169
|
+
* callers MUST scope the merged result with {@link filterDelegationsToSession}
|
|
1170
|
+
* before rendering — only the active session's reports are shown. Entries are
|
|
1171
|
+
* running first, Finalized desc (the sort applied by readDelegationEntries).
|
|
1172
|
+
* Fail-open: a missing/unreadable directory yields []. */
|
|
1160
1173
|
export async function readAllDelegationEntries(root: string): Promise<DelegationEntry[]> {
|
|
1161
1174
|
return readDelegationEntries(join(root, '.pantheon', 'delegations'))
|
|
1162
1175
|
}
|
|
1163
1176
|
|
|
1164
|
-
/** Resolve the
|
|
1165
|
-
*
|
|
1166
|
-
*
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
1169
|
-
*
|
|
1170
|
-
|
|
1171
|
-
export function resolveDelegationsDir(
|
|
1177
|
+
/** Resolve the PROJECT ROOT used by every pantheon file channel. `directory`
|
|
1178
|
+
* wins over `worktree` (the old `resolveDelegationsDir` already did this);
|
|
1179
|
+
* an absent/empty root or `/` (no git — e.g. the sandbox test project) falls
|
|
1180
|
+
* back to cwd. Standardised here so the delegations md and the panel logger
|
|
1181
|
+
* all read the SAME root (audit finding: the channels
|
|
1182
|
+
* resolved the root independently). Pure — no I/O. */
|
|
1183
|
+
export function resolvePantheonRoot(
|
|
1172
1184
|
state: { directory?: string; worktree?: string } | undefined,
|
|
1173
1185
|
cwd = process.cwd(),
|
|
1174
1186
|
): string {
|
|
1175
1187
|
const root = state?.directory ?? state?.worktree ?? ''
|
|
1176
|
-
if (root === '' || root === '/') return
|
|
1177
|
-
return
|
|
1188
|
+
if (root === '' || root === '/') return cwd
|
|
1189
|
+
return root
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** Resolve the directory where delegation md reports are written.
|
|
1193
|
+
* The finalizer writes `.pantheon/delegations` RELATIVE to the server cwd,
|
|
1194
|
+
* which the TUI exposes as `TuiState.path.directory`. */
|
|
1195
|
+
export function resolveDelegationsDir(
|
|
1196
|
+
state: { directory?: string; worktree?: string } | undefined,
|
|
1197
|
+
cwd = process.cwd(),
|
|
1198
|
+
): string {
|
|
1199
|
+
return join(resolvePantheonRoot(state, cwd), '.pantheon', 'delegations')
|
|
1178
1200
|
}
|
|
1179
1201
|
|
|
1180
1202
|
/** Project root derived from the delegations dir: `<root>/.pantheon/delegations`
|
|
@@ -1205,29 +1227,49 @@ export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry)
|
|
|
1205
1227
|
}
|
|
1206
1228
|
|
|
1207
1229
|
/** Split the panel list: active jobs (running/retry, stale-marked) first,
|
|
1208
|
-
* then the most recent terminal reports
|
|
1209
|
-
*
|
|
1230
|
+
* then the most recent terminal reports; the remaining tail is collapsed
|
|
1231
|
+
* by the View into a single "… +N more" line. Pure — so the history-only panel (no sessionID) is testable
|
|
1210
1232
|
* without the TUI runtime. */
|
|
1211
1233
|
export function splitDelegationList(
|
|
1212
1234
|
all: readonly DelegationEntry[],
|
|
1213
1235
|
maxRecent = 8,
|
|
1214
1236
|
now = Date.now(),
|
|
1215
1237
|
staleThresholdMs = 30 * 60 * 1000, // 30 minutes
|
|
1216
|
-
): { active: DelegationEntry[]; recent: DelegationEntry[]
|
|
1238
|
+
): { active: DelegationEntry[]; recent: DelegationEntry[] } {
|
|
1239
|
+
// Active = running OR retry OR the display-only stale-running. A stale row
|
|
1240
|
+
// is still a live job (backend state unchanged), so it must never fall into
|
|
1241
|
+
// the terminal tail — pushing a running delegation out hid the row
|
|
1242
|
+
// the user launched.
|
|
1243
|
+
const isActiveState = (st: DelegationEntry['state']): boolean =>
|
|
1244
|
+
st === 'running' || st === 'retry' || st === 'stale-running'
|
|
1217
1245
|
const active = all
|
|
1218
|
-
.filter((d) => d.state
|
|
1246
|
+
.filter((d) => isActiveState(d.state))
|
|
1219
1247
|
.map((d) => markStaleIfRunning(d, now, staleThresholdMs))
|
|
1220
|
-
|
|
1248
|
+
.sort(compareDelegationEntries)
|
|
1249
|
+
const terminal = all
|
|
1250
|
+
.filter((d) => !isActiveState(d.state))
|
|
1251
|
+
.filter((d) => {
|
|
1252
|
+
// A missing/invalid finalized timestamp is not evidence that a report is
|
|
1253
|
+
// old. Keep it visible rather than accidentally hiding a terminal job.
|
|
1254
|
+
if (d.updatedAt === null || !Number.isFinite(d.updatedAt)) return true
|
|
1255
|
+
const age = now - d.updatedAt
|
|
1256
|
+
if (age < 0) return true
|
|
1257
|
+
const retention =
|
|
1258
|
+
delegationRowStatus(d.state) === 'failed'
|
|
1259
|
+
? DELEGATION_FAILED_RETENTION_MS
|
|
1260
|
+
: DELEGATION_DONE_RETENTION_MS
|
|
1261
|
+
return age < retention
|
|
1262
|
+
})
|
|
1263
|
+
.sort(compareDelegationEntries)
|
|
1221
1264
|
return {
|
|
1222
1265
|
active,
|
|
1223
1266
|
recent: terminal.slice(0, maxRecent),
|
|
1224
|
-
archived: terminal.slice(maxRecent),
|
|
1225
1267
|
}
|
|
1226
1268
|
}
|
|
1227
1269
|
|
|
1228
|
-
/**
|
|
1229
|
-
* existing tests): active jobs first, then the most
|
|
1230
|
-
* reports (capped)
|
|
1270
|
+
/** Live-first window used by {@link ceilingDelegationList} (kept for the
|
|
1271
|
+
* ceiling helper and existing tests): active jobs first, then the most
|
|
1272
|
+
* recent terminal reports (capped). Pure. */
|
|
1231
1273
|
export function visibleDelegationList(
|
|
1232
1274
|
all: readonly DelegationEntry[],
|
|
1233
1275
|
maxTerminal = 8,
|
|
@@ -1246,10 +1288,41 @@ export const STALE_RUNNING_THRESHOLD_MS = 30 * 60 * 1000
|
|
|
1246
1288
|
* display-only `stale-running` state. */
|
|
1247
1289
|
export const IDLE_SILENCE_MS = 60 * 1000
|
|
1248
1290
|
|
|
1291
|
+
/** Visual-only terminal retention windows. Reports remain on disk; these
|
|
1292
|
+
* constants only control which rows enter the TUI window. */
|
|
1293
|
+
export const DELEGATION_DONE_RETENTION_MS = 2 * 60 * 1000
|
|
1294
|
+
export const DELEGATION_FAILED_RETENTION_MS = 10 * 60 * 1000
|
|
1295
|
+
|
|
1296
|
+
/** Grace window for an ABSENT child status. `api.state.session.status()` only
|
|
1297
|
+
* carries the sessions that are currently ALIVE; every finished child of a
|
|
1298
|
+
* long-lived session is missing from that map. A child with no status is
|
|
1299
|
+
* therefore treated as terminal (done) — treating it as running made every
|
|
1300
|
+
* historical child of the session show up as "active" (the "197 children,
|
|
1301
|
+
* all active" regression). The one exception is a freshly-spawned child that
|
|
1302
|
+
* has not been registered by the status API yet: within this window of its
|
|
1303
|
+
* last activity (`time.updated`, falling back to `time.created`) it still
|
|
1304
|
+
* reads as running. */
|
|
1305
|
+
export const DELEGATION_CHILD_STATUS_GRACE_MS = 60 * 1000
|
|
1306
|
+
|
|
1307
|
+
/** Recency window for the children channel. `session.children` returns EVERY
|
|
1308
|
+
* child the focused session ever spawned, so a long session accumulates
|
|
1309
|
+
* hundreds of historical rows that would inflate the panel. A child whose
|
|
1310
|
+
* last activity (its own time, or its matched report's Finalized timestamp)
|
|
1311
|
+
* is older than this window is dropped BEFORE it can enter the list. 24h
|
|
1312
|
+
* keeps the sidebar scoped to current work while still covering long
|
|
1313
|
+
* delegations. A child with no timestamp at all is kept (fail-open). */
|
|
1314
|
+
export const DELEGATION_CHILDREN_RECENCY_MS = 24 * 60 * 60 * 1000
|
|
1315
|
+
|
|
1316
|
+
/** Alias-less NATIVE task() live entries never receive a report alias (the
|
|
1317
|
+
* task tool output carries none), so the 30s alias-less prune in
|
|
1318
|
+
* mergeChildDelegationSources must not apply to them — 5 minutes covers a
|
|
1319
|
+
* slow child listing while still bounding the live map. */
|
|
1320
|
+
export const NATIVE_LIVE_ALIASLESS_TTL_MS = 5 * 60 * 1000
|
|
1321
|
+
|
|
1249
1322
|
/**
|
|
1250
1323
|
* Mark a running entry as `stale-running` if it has been running longer than
|
|
1251
1324
|
* the threshold AND has no recent activity (no `updatedAt` change in the last
|
|
1252
|
-
* `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the
|
|
1325
|
+
* `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the persisted state is unchanged.
|
|
1253
1326
|
*
|
|
1254
1327
|
* A `stale-running` entry renders with a warning indicator but the underlying
|
|
1255
1328
|
* delegation is still treated as running by the backend.
|
|
@@ -1267,7 +1340,7 @@ export function markStaleIfRunning(
|
|
|
1267
1340
|
return { ...entry, state: 'stale-running' as const }
|
|
1268
1341
|
}
|
|
1269
1342
|
|
|
1270
|
-
/** Compact elapsed-time label: "
|
|
1343
|
+
/** Compact elapsed-time label, single unit only: "12s"/"3m"/"1h"/"2d" — ticks every
|
|
1271
1344
|
* second for running jobs. */
|
|
1272
1345
|
export function fmtElapsed(ms: number): string {
|
|
1273
1346
|
const total = Math.max(0, Math.floor(ms / 1000))
|
|
@@ -1275,16 +1348,17 @@ export function fmtElapsed(ms: number): string {
|
|
|
1275
1348
|
const hours = Math.floor((total % 86_400) / 3_600)
|
|
1276
1349
|
const minutes = Math.floor((total % 3_600) / 60)
|
|
1277
1350
|
const seconds = total % 60
|
|
1278
|
-
if (days > 0) return `${days}d
|
|
1279
|
-
if (hours > 0) return `${hours}h
|
|
1280
|
-
if (minutes > 0) return `${minutes}m
|
|
1351
|
+
if (days > 0) return `${days}d`
|
|
1352
|
+
if (hours > 0) return `${hours}h`
|
|
1353
|
+
if (minutes > 0) return `${minutes}m`
|
|
1281
1354
|
return `${seconds}s`
|
|
1282
1355
|
}
|
|
1283
1356
|
|
|
1284
|
-
/** Elapsed label for one entry:
|
|
1285
|
-
*
|
|
1357
|
+
/** Elapsed label for one entry: an ACTIVE entry (running/retry/stale-running)
|
|
1358
|
+
* ticks `now - startedAt`; a terminal one is fixed at
|
|
1359
|
+
* `updatedAt - startedAt` (em dash when no finalized timestamp). */
|
|
1286
1360
|
export function delegationElapsed(entry: DelegationEntry, now: number): string {
|
|
1287
|
-
if (entry.state === 'running' || entry.state === 'stale-running')
|
|
1361
|
+
if (entry.state === 'running' || entry.state === 'retry' || entry.state === 'stale-running')
|
|
1288
1362
|
return fmtElapsed(now - entry.startedAt)
|
|
1289
1363
|
return entry.updatedAt !== null ? fmtElapsed(entry.updatedAt - entry.startedAt) : '\u2014'
|
|
1290
1364
|
}
|
|
@@ -1333,12 +1407,47 @@ export function delegationActivityLabel(entry: DelegationEntry): string {
|
|
|
1333
1407
|
|
|
1334
1408
|
const DELEGATION_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
1335
1409
|
|
|
1336
|
-
/** Return
|
|
1337
|
-
|
|
1338
|
-
|
|
1410
|
+
/** Return the spinner glyph for the given frame counter. The frame counter
|
|
1411
|
+
* is incremented every ~80ms by a dedicated animation timer, decoupled from
|
|
1412
|
+
* the data poll cycle. Pure — no side effects. */
|
|
1413
|
+
export function delegationSpinnerFrame(frame: number): string {
|
|
1414
|
+
const index = Math.abs(Math.floor(frame)) % DELEGATION_SPINNER_FRAMES.length
|
|
1339
1415
|
return DELEGATION_SPINNER_FRAMES[index] ?? DELEGATION_SPINNER_FRAMES[0]
|
|
1340
1416
|
}
|
|
1341
1417
|
|
|
1418
|
+
/** Every state the row knows how to draw: the delegation lifecycle states
|
|
1419
|
+
* derived from the children status + md reports (`completed`, `error`,
|
|
1420
|
+
* `cancelled`, `startup_failed`, `startup_unknown`) plus the TUI-only
|
|
1421
|
+
* states (`retry`, `stale-running`). Fase 1 deliberately omits speculative
|
|
1422
|
+
* blocked/paused/scheduled/skipped. There is no `pending` display state: a
|
|
1423
|
+
* pre-dispatch tool part maps to `running` in {@link reduceDelegationToolPart}. */
|
|
1424
|
+
export type DelegationDisplayState = DelegationEntry['state']
|
|
1425
|
+
|
|
1426
|
+
/** Semantic tone mapped to the TUI theme at the row ({@link DelegationRow}).
|
|
1427
|
+
* Kept separate + pure so the color channel is testable without booting the
|
|
1428
|
+
* renderer. Every display state resolves to one of the three status colors;
|
|
1429
|
+
* the row paints its whole content with it (see {@link DelegationRow}). */
|
|
1430
|
+
export type DelegationStateTone = 'warning' | 'error' | 'success'
|
|
1431
|
+
|
|
1432
|
+
/** Status → color mapping for the whole-row tone. Red = failure, green =
|
|
1433
|
+
* terminal, yellow = in flight. The glyph remains a redundant channel so the
|
|
1434
|
+
* state stays legible in monochrome. Display-only; no behavior change. */
|
|
1435
|
+
export function delegationStateTone(state: DelegationDisplayState): DelegationStateTone {
|
|
1436
|
+
switch (state) {
|
|
1437
|
+
case 'running':
|
|
1438
|
+
case 'retry':
|
|
1439
|
+
case 'startup_unknown':
|
|
1440
|
+
case 'stale-running':
|
|
1441
|
+
return 'warning'
|
|
1442
|
+
case 'error':
|
|
1443
|
+
case 'startup_failed':
|
|
1444
|
+
return 'error'
|
|
1445
|
+
case 'completed':
|
|
1446
|
+
case 'cancelled':
|
|
1447
|
+
return 'success'
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1342
1451
|
/* ─── Live tool activity per child session (beta.6) ────────────────────────
|
|
1343
1452
|
* The delegation panel's activity line ("↳ bash npm test") — what the agent
|
|
1344
1453
|
* is doing RIGHT NOW. Sourced from message.part.updated tool parts: every
|
|
@@ -1357,10 +1466,6 @@ const latestToolActivity = new Map<string, ToolActivity>()
|
|
|
1357
1466
|
const MAX_TOOL_ACTIVITY_ENTRIES = 200
|
|
1358
1467
|
const TOOL_ACTIVITY_TTL_MS = 5 * 60 * 1000
|
|
1359
1468
|
|
|
1360
|
-
/** Archived-section pagination state — module-level so it survives sidebar
|
|
1361
|
-
* remounts (route changes) without leaking into the component tree. */
|
|
1362
|
-
const archivedUiState = { page: 0 }
|
|
1363
|
-
|
|
1364
1469
|
/** First usable summary string from common tool input fields. */
|
|
1365
1470
|
function summarizeToolInput(input: Record<string, unknown> | undefined): string {
|
|
1366
1471
|
if (!input) return ''
|
|
@@ -1443,13 +1548,29 @@ export function mergeChildDelegationSources(
|
|
|
1443
1548
|
})
|
|
1444
1549
|
|
|
1445
1550
|
for (const liveEntry of live) {
|
|
1551
|
+
// A native task() live part never carries a report alias or taskID, so it
|
|
1552
|
+
// is exempt from the 30s alias-less prune and lives on its own longer TTL.
|
|
1553
|
+
const aliaslessTTL = liveEntry.tool === 'task' ? NATIVE_LIVE_ALIASLESS_TTL_MS : 30_000
|
|
1446
1554
|
if (
|
|
1447
1555
|
liveEntry.alias === null &&
|
|
1448
1556
|
liveEntry.taskID === null &&
|
|
1449
|
-
Date.now() - liveEntry.startedAt >
|
|
1557
|
+
Date.now() - liveEntry.startedAt > aliaslessTTL
|
|
1450
1558
|
)
|
|
1451
1559
|
continue
|
|
1452
1560
|
const incoming = toDelegationEntry(liveEntry)
|
|
1561
|
+
// A native task() live part never carries a report alias — it is a
|
|
1562
|
+
// children-only row by definition whether it lands on a new row or
|
|
1563
|
+
// upgrades an existing
|
|
1564
|
+
// children-only one. pantheon_delegate pending parts keep source 'live'.
|
|
1565
|
+
const isNativeLive = liveEntry.tool === 'task' && liveEntry.alias === null
|
|
1566
|
+
if (isNativeLive) {
|
|
1567
|
+
// Native task() rows carry no report alias — keep the per-call
|
|
1568
|
+
// `live-<callID>` identity from toDelegationEntry (one row per native
|
|
1569
|
+
// call) with the children-only provenance; the row identity still falls
|
|
1570
|
+
// back to the agent via delegationRowIdentity. A taskID-known native
|
|
1571
|
+
// still merges into its child row through the byTask lookup below.
|
|
1572
|
+
incoming.source = 'children-only'
|
|
1573
|
+
}
|
|
1453
1574
|
const index =
|
|
1454
1575
|
(incoming.taskID !== undefined ? byTask.get(incoming.taskID) : undefined) ??
|
|
1455
1576
|
(incoming.alias !== ''
|
|
@@ -1497,7 +1618,10 @@ export function mergeChildDelegationSources(
|
|
|
1497
1618
|
startedAt: Math.min(existing.startedAt, incoming.startedAt),
|
|
1498
1619
|
updatedAt: incoming.updatedAt,
|
|
1499
1620
|
read: incoming.read,
|
|
1500
|
-
|
|
1621
|
+
// A native task() live upgrade must not flip a children-only row to
|
|
1622
|
+
// 'live' (that would drop the children-only provenance); pantheon_delegate
|
|
1623
|
+
// upgrades still take source 'live'.
|
|
1624
|
+
source: isNativeLive ? existing.source : 'live',
|
|
1501
1625
|
}
|
|
1502
1626
|
}
|
|
1503
1627
|
|
|
@@ -1509,9 +1633,11 @@ export function mergeChildDelegationSources(
|
|
|
1509
1633
|
* Source of truth for jobs born while opencode is open. The TUI event bus
|
|
1510
1634
|
* (api.event.on) delivers `message.part.updated` for every part change;
|
|
1511
1635
|
* we only care about tool parts whose `tool` is pantheon_delegate (job
|
|
1512
|
-
* launch)
|
|
1513
|
-
*
|
|
1514
|
-
*
|
|
1636
|
+
* launch), the native `task` subagent tool (same parentID === caller
|
|
1637
|
+
* mechanism — its rows render `nat:`), or pantheon_delegation_read
|
|
1638
|
+
* (blocking until terminal → closes the entry). Shape-adapted from the
|
|
1639
|
+
* SDK v2 ToolPart but duck-typed so the pure helpers are testable without
|
|
1640
|
+
* the SDK. */
|
|
1515
1641
|
|
|
1516
1642
|
/** Duck-typed subset of a tool part (SDK v2 `ToolPart` / `ToolState`). */
|
|
1517
1643
|
export type DelegationToolPart = {
|
|
@@ -1537,7 +1663,7 @@ export type LiveDelegationEntry = {
|
|
|
1537
1663
|
partID: string
|
|
1538
1664
|
/** Parent session the delegation was launched from. */
|
|
1539
1665
|
sessionID: string
|
|
1540
|
-
tool: 'pantheon_delegate' | 'pantheon_delegation_read'
|
|
1666
|
+
tool: 'pantheon_delegate' | 'pantheon_delegation_read' | 'task'
|
|
1541
1667
|
/** Agent name (from the delegate input args). */
|
|
1542
1668
|
agent: string
|
|
1543
1669
|
description: string
|
|
@@ -1557,7 +1683,7 @@ export type ParsedDelegationToolPart = {
|
|
|
1557
1683
|
callID: string
|
|
1558
1684
|
partID: string
|
|
1559
1685
|
sessionID: string
|
|
1560
|
-
tool: 'pantheon_delegate' | 'pantheon_delegation_read'
|
|
1686
|
+
tool: 'pantheon_delegate' | 'pantheon_delegation_read' | 'task'
|
|
1561
1687
|
/** null for read parts (no agent arg — the id targets an existing job). */
|
|
1562
1688
|
agent: string | null
|
|
1563
1689
|
description: string
|
|
@@ -1577,13 +1703,20 @@ const READ_ALIAS_PATTERN = /^[a-z]{2,8}-\d+$/i
|
|
|
1577
1703
|
|
|
1578
1704
|
/** Extract the tool name + args from a `message.part.updated` part and
|
|
1579
1705
|
* reduce it to what the panel needs. Returns null for anything that is
|
|
1580
|
-
* not a pantheon delegation tool part
|
|
1706
|
+
* not a pantheon delegation tool part, the native `task` subagent tool
|
|
1707
|
+
* (same parentID === caller mechanism — its children render `nat:`),
|
|
1708
|
+
* or is missing its callID. */
|
|
1581
1709
|
export function parseDelegationToolPart(
|
|
1582
1710
|
part: DelegationToolPart,
|
|
1583
1711
|
now = Date.now(),
|
|
1584
1712
|
): ParsedDelegationToolPart | null {
|
|
1585
1713
|
if (part.type !== 'tool') return null
|
|
1586
|
-
if (
|
|
1714
|
+
if (
|
|
1715
|
+
part.tool !== 'pantheon_delegate' &&
|
|
1716
|
+
part.tool !== 'pantheon_delegation_read' &&
|
|
1717
|
+
part.tool !== 'task'
|
|
1718
|
+
)
|
|
1719
|
+
return null
|
|
1587
1720
|
const callID = part.callID
|
|
1588
1721
|
if (callID === undefined || callID === '') return null
|
|
1589
1722
|
const sessionID = part.sessionID ?? ''
|
|
@@ -1618,10 +1751,22 @@ export function parseDelegationToolPart(
|
|
|
1618
1751
|
}
|
|
1619
1752
|
}
|
|
1620
1753
|
|
|
1621
|
-
const agent =
|
|
1622
|
-
|
|
1754
|
+
const agent =
|
|
1755
|
+
typeof input.agent === 'string'
|
|
1756
|
+
? input.agent
|
|
1757
|
+
: typeof input.subagent_type === 'string'
|
|
1758
|
+
? (input.subagent_type as string)
|
|
1759
|
+
: 'agent'
|
|
1760
|
+
const description =
|
|
1761
|
+
typeof input.description === 'string'
|
|
1762
|
+
? input.description
|
|
1763
|
+
: typeof input.prompt === 'string'
|
|
1764
|
+
? (input.prompt as string).slice(0, 120)
|
|
1765
|
+
: ''
|
|
1623
1766
|
// The alias/taskID only exist once the delegate tool COMPLETES (they are
|
|
1624
|
-
// returned in its output); a running/pending part has neither.
|
|
1767
|
+
// returned in its output); a running/pending part has neither. Native
|
|
1768
|
+
// `task` parts never carry a report alias — the children channel supplies
|
|
1769
|
+
// the child session id and the row's identity.
|
|
1625
1770
|
let alias: string | null = null
|
|
1626
1771
|
let taskID: string | null = null
|
|
1627
1772
|
if (status === 'completed') {
|
|
@@ -1633,7 +1778,7 @@ export function parseDelegationToolPart(
|
|
|
1633
1778
|
callID,
|
|
1634
1779
|
partID: part.id ?? '',
|
|
1635
1780
|
sessionID,
|
|
1636
|
-
tool: 'pantheon_delegate',
|
|
1781
|
+
tool: part.tool === 'task' ? 'task' : 'pantheon_delegate',
|
|
1637
1782
|
agent,
|
|
1638
1783
|
description,
|
|
1639
1784
|
status,
|
|
@@ -1691,9 +1836,11 @@ export function reduceDelegationToolPart(
|
|
|
1691
1836
|
return changed
|
|
1692
1837
|
}
|
|
1693
1838
|
|
|
1694
|
-
// Delegate tool: pending/running →
|
|
1695
|
-
// delegate tool only means the job was
|
|
1696
|
-
// we pick up alias + taskID from the
|
|
1839
|
+
// Delegate tool (pantheon_delegate AND native task): pending/running →
|
|
1840
|
+
// job launched (running). A COMPLETED delegate tool only means the job was
|
|
1841
|
+
// registered — it stays running and we pick up alias + taskID from the
|
|
1842
|
+
// output. Native `task` parts never yield a report alias, so their rows
|
|
1843
|
+
// stay identity-less until the children channel resolves them.
|
|
1697
1844
|
const existing = map.get(parsed.callID)
|
|
1698
1845
|
if (parsed.status === 'error') {
|
|
1699
1846
|
if (existing !== undefined && existing.state === 'error' && existing.updatedAt === parsed.endAt)
|
|
@@ -1702,7 +1849,7 @@ export function reduceDelegationToolPart(
|
|
|
1702
1849
|
callID: parsed.callID,
|
|
1703
1850
|
partID: parsed.partID,
|
|
1704
1851
|
sessionID: parsed.sessionID,
|
|
1705
|
-
tool:
|
|
1852
|
+
tool: parsed.tool,
|
|
1706
1853
|
agent: parsed.agent ?? 'agent',
|
|
1707
1854
|
description: parsed.description,
|
|
1708
1855
|
alias: existing?.alias ?? null,
|
|
@@ -1719,7 +1866,7 @@ export function reduceDelegationToolPart(
|
|
|
1719
1866
|
callID: parsed.callID,
|
|
1720
1867
|
partID: parsed.partID,
|
|
1721
1868
|
sessionID: parsed.sessionID,
|
|
1722
|
-
tool:
|
|
1869
|
+
tool: parsed.tool,
|
|
1723
1870
|
agent: parsed.agent ?? 'agent',
|
|
1724
1871
|
description: parsed.description,
|
|
1725
1872
|
alias: parsed.alias,
|
|
@@ -1772,11 +1919,14 @@ export function removeDelegationEntry(
|
|
|
1772
1919
|
return false
|
|
1773
1920
|
}
|
|
1774
1921
|
|
|
1775
|
-
/** Collect pantheon delegation tool parts from a session's messages.
|
|
1922
|
+
/** Collect pantheon delegation + native task tool parts from a session's messages.
|
|
1776
1923
|
* Messages may carry their parts inline (duck-typed `msg.parts`); when
|
|
1777
1924
|
* they don't, the optional `getParts(messageID)` callback is used (the TUI
|
|
1778
|
-
* SDK exposes `api.state.part(messageID)`).
|
|
1779
|
-
*
|
|
1925
|
+
* SDK exposes `api.state.part(messageID)`). The native `task` tool spawns a
|
|
1926
|
+
* child session with parentID = caller — the same mechanism as
|
|
1927
|
+
* pantheon_delegate — so its parts feed the live-map as the native signal
|
|
1928
|
+
* (rows come from the children channel). Pure w.r.t. I/O — used
|
|
1929
|
+
* by the mount re-scan to re-seed the live map after compaction/attach. */
|
|
1780
1930
|
export function collectDelegationToolParts(
|
|
1781
1931
|
messages: readonly { id?: string; parts?: unknown[] }[] | undefined,
|
|
1782
1932
|
getParts?: (messageID: string) => readonly unknown[] | undefined,
|
|
@@ -1794,7 +1944,9 @@ export function collectDelegationToolParts(
|
|
|
1794
1944
|
const part = raw as DelegationToolPart
|
|
1795
1945
|
if (
|
|
1796
1946
|
part?.type === 'tool' &&
|
|
1797
|
-
(part.tool === 'pantheon_delegate' ||
|
|
1947
|
+
(part.tool === 'pantheon_delegate' ||
|
|
1948
|
+
part.tool === 'pantheon_delegation_read' ||
|
|
1949
|
+
part.tool === 'task')
|
|
1798
1950
|
) {
|
|
1799
1951
|
out.push(part)
|
|
1800
1952
|
}
|
|
@@ -1871,6 +2023,30 @@ export function mergeDelegationSources(
|
|
|
1871
2023
|
return all
|
|
1872
2024
|
}
|
|
1873
2025
|
|
|
2026
|
+
/** Scope a fully-merged display list to the ACTIVE session only.
|
|
2027
|
+
*
|
|
2028
|
+
* The panel is session-scoped: rows from other sessions (md history under
|
|
2029
|
+
* `.pantheon/delegations/<other-session>/`) and rows with no
|
|
2030
|
+
* attributable session (empty sessionID) are DROPPED. The previous
|
|
2031
|
+
* cross-session behavior rendered those rows and clicking them led to
|
|
2032
|
+
* "Session not found" — there is no cross-session channel anymore.
|
|
2033
|
+
*
|
|
2034
|
+
* The md channel still feeds the merge UNFILTERED so it can ENRICH an
|
|
2035
|
+
* active-session row with state/alias/agent (dedup by taskID), but it can
|
|
2036
|
+
* never introduce a row for another session: this filter is applied
|
|
2037
|
+
* ONCE, after every merge (children + live + md).
|
|
2038
|
+
*
|
|
2039
|
+
* Returns [] when no active session resolves (null/placeholder) — there is no
|
|
2040
|
+
* scope to show. Native children always carry the active session id (stamped
|
|
2041
|
+
* by `childrenToDelegationEntries`), so they survive the filter. Pure. */
|
|
2042
|
+
export function filterDelegationsToSession(
|
|
2043
|
+
entries: readonly DelegationEntry[],
|
|
2044
|
+
activeSessionID: string | null | undefined,
|
|
2045
|
+
): DelegationEntry[] {
|
|
2046
|
+
if (!isValidSessionId(activeSessionID)) return []
|
|
2047
|
+
return entries.filter((entry) => entry.sessionID === activeSessionID)
|
|
2048
|
+
}
|
|
2049
|
+
|
|
1874
2050
|
/* ─── Children channel (primary source, delegations-sidebar pattern) ────
|
|
1875
2051
|
* `api.client.session.children` returns the child sessions of the current
|
|
1876
2052
|
* session — every pantheon_delegate spawns one (parentID = caller). The
|
|
@@ -1879,7 +2055,7 @@ export function mergeDelegationSources(
|
|
|
1879
2055
|
|
|
1880
2056
|
/* ─── Current-session resolution + path guard (placeholder regression) ──
|
|
1881
2057
|
* The sidebar forwarded `props.session_id` verbatim into
|
|
1882
|
-
* `session.children({
|
|
2058
|
+
* `session.children({ sessionID })`. When the TUI runtime renders the
|
|
1883
2059
|
* sidebar without a focused session it leaves the route template
|
|
1884
2060
|
* UNSUBSTITUTED — the literal "{sessionID}" string — which opencode's server
|
|
1885
2061
|
* rejected every poll (SchemaError: Expected a string starting with "ses",
|
|
@@ -1943,33 +2119,38 @@ export function resolveCurrentSessionID(sources: TuiSessionSources): string | nu
|
|
|
1943
2119
|
}
|
|
1944
2120
|
|
|
1945
2121
|
/** THE single choke point for every `session.children` / session-API path.
|
|
1946
|
-
* Returns
|
|
1947
|
-
* null for anything else (placeholder,
|
|
1948
|
-
* skips the call entirely instead of
|
|
1949
|
-
* (the "%7BsessionID%7D" regression).
|
|
1950
|
-
*
|
|
1951
|
-
*
|
|
1952
|
-
|
|
2122
|
+
* Returns the v2 SDK parameter shape `{ sessionID }` ONLY for a
|
|
2123
|
+
* server-valid session id; returns null for anything else (placeholder,
|
|
2124
|
+
* empty, foreign id) so the caller skips the call entirely instead of
|
|
2125
|
+
* sending an unsubstituted placeholder (the "%7BsessionID%7D" regression).
|
|
2126
|
+
* The TUI client is `@opencode-ai/sdk/v2`, whose session methods take a
|
|
2127
|
+
* FLAT parameter object (`{ sessionID }`), NOT the v1 `{ path: { id } }`
|
|
2128
|
+
* envelope — passing the v1 shape left the v2 `{sessionID}` URL template
|
|
2129
|
+
* unsubstituted (`/session/%7BsessionID%7D/children`). Every session-API
|
|
2130
|
+
* call site MUST go through this function (enforced by the source-scan
|
|
2131
|
+
* test in tests/pantheon/tui-delegations.test.ts). */
|
|
2132
|
+
export function safeSessionPath(id: unknown): { sessionID: string } | null {
|
|
1953
2133
|
if (!isValidSessionId(id)) return null
|
|
1954
|
-
return {
|
|
2134
|
+
return { sessionID: id }
|
|
1955
2135
|
}
|
|
1956
2136
|
|
|
1957
|
-
/** Build the `session.children`
|
|
2137
|
+
/** Build the `session.children` parameters ONLY from a validated session id.
|
|
1958
2138
|
* Delegates to {@link safeSessionPath} — the single choke point. Returns
|
|
1959
|
-
* null for null/invalid ids so the caller
|
|
1960
|
-
* sending an unsubstituted placeholder (the
|
|
1961
|
-
|
|
2139
|
+
* the v2 SDK shape `{ sessionID }`; null for null/invalid ids so the caller
|
|
2140
|
+
* skips the fetch instead of sending an unsubstituted placeholder (the
|
|
2141
|
+
* "%7BsessionID%7D" regression). */
|
|
2142
|
+
export function buildChildrenPath(id: string | null | undefined): { sessionID: string } | null {
|
|
1962
2143
|
return safeSessionPath(id)
|
|
1963
2144
|
}
|
|
1964
2145
|
|
|
1965
2146
|
/** Duck-typed subset of a child Session (+ its live status type). */
|
|
1966
2147
|
export type ChildDelegationLike = {
|
|
1967
|
-
/** Child session id (=
|
|
2148
|
+
/** Child session id (= task id). */
|
|
1968
2149
|
id: string
|
|
1969
2150
|
/** Session title — the delegate's description or prompt prefix. */
|
|
1970
2151
|
title?: string
|
|
1971
2152
|
/** Agent name when the child session carries one (duck-typed; native
|
|
1972
|
-
* task() children may expose it,
|
|
2153
|
+
* task() children may expose it, md reports always do). */
|
|
1973
2154
|
agent?: string
|
|
1974
2155
|
/** Status type from api.state.session.status: 'busy' | 'retry' | 'idle',
|
|
1975
2156
|
* or undefined when the status API is unavailable. */
|
|
@@ -1980,24 +2161,253 @@ export type ChildDelegationLike = {
|
|
|
1980
2161
|
}
|
|
1981
2162
|
}
|
|
1982
2163
|
|
|
1983
|
-
/** Map a child status type to a display state.
|
|
1984
|
-
*
|
|
1985
|
-
*
|
|
1986
|
-
*
|
|
1987
|
-
|
|
1988
|
-
|
|
2164
|
+
/** Map a child status type to a display state.
|
|
2165
|
+
*
|
|
2166
|
+
* Only `busy`/`retry` are EXPLICIT live states. `idle` is terminal and so is
|
|
2167
|
+
* any other/unknown status — a status the panel does not recognise is not
|
|
2168
|
+
* evidence of activity. An ABSENT status is the historical case: the status
|
|
2169
|
+
* map (`api.state.session.status`) contains only currently-alive sessions, so
|
|
2170
|
+
* every finished child of a long session is missing from it. Defaulting that
|
|
2171
|
+
* to running made the whole session history render as "active" (the
|
|
2172
|
+
* "197 children, all active" regression), so an absent status is terminal
|
|
2173
|
+
* (done) UNLESS the child was active within `graceMs` — a just-spawned child
|
|
2174
|
+
* that the status API has not registered yet must still render as running.
|
|
2175
|
+
*
|
|
2176
|
+
* @param status the `api.state.session.status(id)?.type`, or undefined
|
|
2177
|
+
* @param time the child session time fields (`created`/`updated`)
|
|
2178
|
+
* @param now current wall-clock ms (injectable for tests)
|
|
2179
|
+
* @param graceMs recency window for the absent-status running assumption
|
|
2180
|
+
* Pure — no I/O. */
|
|
2181
|
+
export function childStatusToState(
|
|
2182
|
+
status: string | undefined,
|
|
2183
|
+
time?: { created?: number; updated?: number },
|
|
2184
|
+
now = Date.now(),
|
|
2185
|
+
graceMs = DELEGATION_CHILD_STATUS_GRACE_MS,
|
|
2186
|
+
): 'running' | 'completed' | 'retry' {
|
|
1989
2187
|
if (status === 'retry') return 'retry'
|
|
1990
|
-
|
|
2188
|
+
if (status === 'busy') {
|
|
2189
|
+
// A stale "busy" in the status map means the child is likely dead, not
|
|
2190
|
+
// running. The opencode status map may retain entries for sessions that
|
|
2191
|
+
// have already terminated. Cross-check against the child's own activity
|
|
2192
|
+
// timestamps: if the last activity is older than the stale-running
|
|
2193
|
+
// threshold, treat the child as completed rather than propagating the
|
|
2194
|
+
// stale busy status as "running" (the "213 children, all active" bug).
|
|
2195
|
+
if (time) {
|
|
2196
|
+
const lastActivity = Math.max(time.updated ?? 0, time.created ?? 0)
|
|
2197
|
+
if (lastActivity > 0 && now - lastActivity > STALE_RUNNING_THRESHOLD_MS) return 'completed'
|
|
2198
|
+
}
|
|
2199
|
+
return 'running'
|
|
2200
|
+
}
|
|
2201
|
+
if (status !== undefined) return 'completed' // idle + any other explicit terminal
|
|
2202
|
+
// Absent status: running only while the child's last activity is fresh.
|
|
2203
|
+
const lastActivity = Math.max(time?.updated ?? 0, time?.created ?? 0)
|
|
2204
|
+
if (lastActivity > 0 && now - lastActivity < graceMs) return 'running'
|
|
2205
|
+
return 'completed'
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
/** Status-only row model: ONE glyph + ONE identity per row.
|
|
2209
|
+
*
|
|
2210
|
+
* A row is
|
|
2211
|
+
* `{glyph} {alias:7} {elapsed:>5}` plus a muted description line. The short
|
|
2212
|
+
* alias (`apo-1`) is the single identity; rows without a report alias show
|
|
2213
|
+
* the agent instead (never both). */
|
|
2214
|
+
export type DelegationRowStatus = 'active' | 'done' | 'failed' | 'retry'
|
|
2215
|
+
|
|
2216
|
+
/** Map every FSM/display state to one of the 4 row kinds. reconciled never
|
|
2217
|
+
* reaches the panel (the md parser drops it) but reads as done;
|
|
2218
|
+
* cancelled reads as done; startup_failed reads as failed while
|
|
2219
|
+
* startup_unknown (no error known) reads as retry. Pure. */
|
|
2220
|
+
export function delegationRowStatus(state: DelegationDisplayState): DelegationRowStatus {
|
|
2221
|
+
switch (state) {
|
|
2222
|
+
case 'running':
|
|
2223
|
+
case 'stale-running':
|
|
2224
|
+
return 'active'
|
|
2225
|
+
case 'retry':
|
|
2226
|
+
case 'startup_unknown':
|
|
2227
|
+
return 'retry'
|
|
2228
|
+
case 'completed':
|
|
2229
|
+
case 'cancelled':
|
|
2230
|
+
return 'done'
|
|
2231
|
+
case 'error':
|
|
2232
|
+
case 'startup_failed':
|
|
2233
|
+
return 'failed'
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
/** The only 4 glyphs a row may show: animated active, done, failed, retry.
|
|
2238
|
+
* Shape is redundant with color (never the only signal). Pure. */
|
|
2239
|
+
export const DELEGATION_ROW_GLYPHS: Readonly<Record<DelegationRowStatus, string>> = {
|
|
2240
|
+
active: '⠋', // base frame of the animated spinner
|
|
2241
|
+
done: '✓',
|
|
2242
|
+
failed: '✕',
|
|
2243
|
+
retry: '⟳',
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
export function delegationRowGlyph(status: DelegationRowStatus): string {
|
|
2247
|
+
return DELEGATION_ROW_GLYPHS[status]
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
/** Row marker (`<glyph> `) — the single state channel. `active` animates
|
|
2251
|
+
* through the spinner for the given frame counter (incremented every ~80ms);
|
|
2252
|
+
* every other kind is static. Pure. */
|
|
2253
|
+
export function delegationRowMarker(state: DelegationDisplayState, frame = 0): string {
|
|
2254
|
+
const status = delegationRowStatus(state)
|
|
2255
|
+
const glyph = status === 'active' ? delegationSpinnerFrame(frame) : delegationRowGlyph(status)
|
|
2256
|
+
return `${glyph} `
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
/** ONE identity per row: the short report alias (`apo-1`); a row without a
|
|
2260
|
+
* report alias (native task() child `native-task` / `native-<id>`, or an
|
|
2261
|
+
* alias-less native live row `live-<callID>` kept as children-only) shows
|
|
2262
|
+
* the agent instead — never alias + agent stacked. Pure. */
|
|
2263
|
+
export function delegationRowIdentity(entry: DelegationEntry): string {
|
|
2264
|
+
if (entry.agent === '') return entry.alias
|
|
2265
|
+
if (entry.alias === 'native-task' || entry.alias.startsWith('native-')) return entry.agent
|
|
2266
|
+
if (entry.alias.startsWith('live-') && entry.source === 'children-only') return entry.agent
|
|
2267
|
+
return entry.alias
|
|
1991
2268
|
}
|
|
1992
2269
|
|
|
1993
|
-
/**
|
|
1994
|
-
*
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2270
|
+
/** Fixed alias cell, pad-right + hard-truncate to `width` (default 7) so
|
|
2271
|
+
* identities line up across rows. Pure. */
|
|
2272
|
+
export const DELEGATION_ALIAS_WIDTH = 7
|
|
2273
|
+
|
|
2274
|
+
export function formatDelegationAlias(identity: string, width = DELEGATION_ALIAS_WIDTH): string {
|
|
2275
|
+
if (identity.length >= width) return identity.slice(0, width)
|
|
2276
|
+
return `${identity}${' '.repeat(width - identity.length)}`
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
/** Max description width on the row detail line — the old 180-char slice
|
|
2280
|
+
* wrapped the sidebar; 44 keeps one readable line. */
|
|
2281
|
+
export const DELEGATION_DESCRIPTION_MAX = 44
|
|
2282
|
+
|
|
2283
|
+
/** Lazily-built grapheme segmenter. `Intl.Segmenter` keeps ZWJ emoji families
|
|
2284
|
+
* (and skin-tone/variation sequences) intact; `Array.from` (code points) is
|
|
2285
|
+
* the fallback for runtimes without it. */
|
|
2286
|
+
let graphemeSegmenter: Intl.Segmenter | undefined
|
|
2287
|
+
|
|
2288
|
+
/** Split `text` into display graphemes so truncation never cuts a multi-unit
|
|
2289
|
+
* emoji in half (surrogate pair → mojibake). */
|
|
2290
|
+
function splitGraphemes(text: string): string[] {
|
|
2291
|
+
if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
|
|
2292
|
+
graphemeSegmenter ??= new Intl.Segmenter('pt', { granularity: 'grapheme' })
|
|
2293
|
+
return Array.from(graphemeSegmenter.segment(text), (s) => s.segment)
|
|
2294
|
+
}
|
|
2295
|
+
return Array.from(text)
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
/** Truncate a description to `max` graphemes appending `…` when cut. Exact
|
|
2299
|
+
* `max`-length text is left untouched. Grapheme-granular, so an emoji made of
|
|
2300
|
+
* several code points is never split into mojibake at the boundary. Pure. */
|
|
2301
|
+
export function truncateDelegationDescription(
|
|
2302
|
+
text: string,
|
|
2303
|
+
max = DELEGATION_DESCRIPTION_MAX,
|
|
2304
|
+
): string {
|
|
2305
|
+
const graphemes = splitGraphemes(text)
|
|
2306
|
+
if (graphemes.length <= max) return text
|
|
2307
|
+
return `${graphemes.slice(0, Math.max(0, max - 1)).join('')}\u2026`
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
/** Fixed elapsed-time box: right-aligned to `width` (default 5) so elapsed
|
|
2311
|
+
* values line up across rows, e.g. ` 12s`. A label that already fills
|
|
2312
|
+
* the box is returned untouched. Pure. */
|
|
2313
|
+
export const DELEGATION_ELAPSED_WIDTH = 5
|
|
2314
|
+
|
|
2315
|
+
export function formatDelegationElapsed(
|
|
2316
|
+
entry: DelegationEntry,
|
|
2317
|
+
now: number,
|
|
2318
|
+
width = DELEGATION_ELAPSED_WIDTH,
|
|
2319
|
+
): string {
|
|
2320
|
+
const label = delegationElapsed(entry, now)
|
|
2321
|
+
if (label.length >= width) return label
|
|
2322
|
+
return `${' '.repeat(width - label.length)}${label}`
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
/** Colored left half of a row line: `<marker><alias:7> ` — the state glyph
|
|
2326
|
+
* plus the single identity, padded so the muted elapsed column aligns. The
|
|
2327
|
+
* row renders this colored lead separately from the muted elapsed tail. Pure. */
|
|
2328
|
+
export function formatDelegationRowLead(entry: DelegationEntry, marker: string): string {
|
|
2329
|
+
return `${marker}${formatDelegationAlias(delegationRowIdentity(entry))} `
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
// Tree connectors (`isNestedDelegation` / `delegationTreePrefix`) were removed
|
|
2333
|
+
// as dead code: `session.children` returns direct children only, so no row is
|
|
2334
|
+
// ever actually nested and the connectors were never populated in production.
|
|
2335
|
+
// BACKLOG: produce real grandchild rows first, then reintroduce them.
|
|
2336
|
+
|
|
2337
|
+
/** Visible-row ceiling for the panel: the remainder collapses into a single
|
|
2338
|
+
* "… +N more" line. Live rows render first, so a running job is never hidden
|
|
2339
|
+
* by the cap. */
|
|
2340
|
+
export const DELEGATION_VISIBLE_CEILING = 8
|
|
2341
|
+
|
|
2342
|
+
/** Header summary: `(N active · M done)` plus `· K failed` only when K > 0.
|
|
2343
|
+
* Active counts running/retry AND display-only stale-running (a stale row is
|
|
2344
|
+
* still a live job — it must never read as done); failed counts
|
|
2345
|
+
* error/startup_failed; cancelled reads as done. Pure. */
|
|
2346
|
+
export function formatDelegationHeader(entries: readonly DelegationEntry[]): string {
|
|
2347
|
+
let active = 0
|
|
2348
|
+
let done = 0
|
|
2349
|
+
let failed = 0
|
|
2350
|
+
for (const e of entries) {
|
|
2351
|
+
switch (delegationRowStatus(e.state)) {
|
|
2352
|
+
case 'active':
|
|
2353
|
+
case 'retry':
|
|
2354
|
+
active++
|
|
2355
|
+
break
|
|
2356
|
+
case 'failed':
|
|
2357
|
+
failed++
|
|
2358
|
+
break
|
|
2359
|
+
case 'done':
|
|
2360
|
+
done++
|
|
2361
|
+
break
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
const tail = failed > 0 ? ` · ${failed} failed` : ''
|
|
2365
|
+
return `(${active} active · ${done} done${tail})`
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
/** Cap the panel: live rows first, then most-recent retained terminal rows, at
|
|
2369
|
+
* most `maxVisible` total. Hidden counts describe the retained render list,
|
|
2370
|
+
* not expired history. */
|
|
2371
|
+
export function ceilingDelegationList(
|
|
2372
|
+
all: readonly DelegationEntry[],
|
|
2373
|
+
maxVisible = DELEGATION_VISIBLE_CEILING,
|
|
2374
|
+
now = Date.now(),
|
|
2375
|
+
): { visible: DelegationEntry[]; hidden: number; hiddenActive: number; hiddenTerminal: number } {
|
|
2376
|
+
const { active, recent } = splitDelegationList(all, all.length, now)
|
|
2377
|
+
const visibleActive = active.slice(0, maxVisible)
|
|
2378
|
+
const visible = [
|
|
2379
|
+
...visibleActive,
|
|
2380
|
+
...recent.slice(0, Math.max(0, maxVisible - visibleActive.length)),
|
|
2381
|
+
]
|
|
2382
|
+
const hiddenActive = Math.max(0, active.length - visibleActive.length)
|
|
2383
|
+
const hiddenTerminal = Math.max(0, recent.length - (visible.length - visibleActive.length))
|
|
2384
|
+
return { visible, hidden: hiddenActive + hiddenTerminal, hiddenActive, hiddenTerminal }
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
/** Split a display list into native task() rows vs pantheon_delegate rows.
|
|
2388
|
+
* Native = source 'children-only' (no delegate report); everything else counts
|
|
2389
|
+
* as pantheon. Pure — powers the hooks.log line. */
|
|
2390
|
+
export function countDelegationSources(entries: readonly DelegationEntry[]): {
|
|
2391
|
+
native: number
|
|
2392
|
+
pantheon: number
|
|
2393
|
+
total: number
|
|
2394
|
+
} {
|
|
2395
|
+
let native = 0
|
|
2396
|
+
for (const e of entries) if (e.source === 'children-only') native++
|
|
2397
|
+
return { native, pantheon: entries.length - native, total: entries.length }
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
/** Diagnostic hooks.log line for a panel re-fetch, with the children
|
|
2401
|
+
* breakdown (pantheon = children WITH a delegate report, native = children
|
|
2402
|
+
* WITHOUT one). Pure — the View logs the returned string verbatim. */
|
|
2403
|
+
export function formatPanelLogLine(
|
|
2404
|
+
children: number,
|
|
2405
|
+
pantheon: number,
|
|
2406
|
+
native: number,
|
|
2407
|
+
md: number,
|
|
2408
|
+
events: number,
|
|
2409
|
+
): string {
|
|
2410
|
+
return `panel: children=${children}(pantheon=${pantheon} native=${native}) md=${md} events=${events}`
|
|
2001
2411
|
}
|
|
2002
2412
|
|
|
2003
2413
|
/** Turn child sessions (PRIMARY) enriched with md reports into the display
|
|
@@ -2008,15 +2418,44 @@ export function delegationTag(entry: DelegationEntry): string {
|
|
|
2008
2418
|
* itself (fallback 'agent'), state derived from its status, startedAt from
|
|
2009
2419
|
* time.created. A report-less child is a NATIVE task() child (every
|
|
2010
2420
|
* child of the current session — pantheon_delegate OR the native `task()`
|
|
2011
|
-
* tool — carries parentID = caller), so it gets source 'children-only'
|
|
2012
|
-
*
|
|
2013
|
-
*
|
|
2014
|
-
* the
|
|
2421
|
+
* tool — carries parentID = caller), so it gets source 'children-only', a
|
|
2422
|
+
* per-child alias 'native-<last4 of the child id>' (one identity per native
|
|
2423
|
+
* row) instead of a report alias. The 'task nativa' description fallback
|
|
2424
|
+
* keeps the row non-empty when the child carries no title.
|
|
2425
|
+
* A FRESH terminal md state wins over the derived state (a stale md report
|
|
2426
|
+
* from a previous incarnation never flips a live child — see
|
|
2427
|
+
* mdTerminalCoversChild); a running md defers to
|
|
2428
|
+
* the child's live status. A running child is NEVER archived — it always
|
|
2429
|
+
* lands in the active split (splitDelegationList active = running/retry).
|
|
2430
|
+
* `parentSessionID` (the focused session id) is stamped on report-less
|
|
2431
|
+
* children so the row carries its origin session; omit it and they stay
|
|
2432
|
+
* unscoped ('').
|
|
2433
|
+
* Children outside {@link DELEGATION_CHILDREN_RECENCY_MS} are dropped before
|
|
2434
|
+
* they can enter the list (the per-session child list grows for the whole
|
|
2435
|
+
* session lifetime); the survivors are sorted running-first, most recent
|
|
2436
|
+
* first (compareDelegationEntries), so a ceiling hides the OLDEST rows.
|
|
2015
2437
|
* Pure — no I/O. */
|
|
2438
|
+
/** Temporal guard for the children channel (same recycled-alias class as the
|
|
2439
|
+
* md merge): an md terminal report matched by taskID may still belong to
|
|
2440
|
+
* a PREVIOUS job incarnation. Accept it only when its Finalized timestamp
|
|
2441
|
+
* covers the child's latest activity, when either side has no timestamp to
|
|
2442
|
+
* compare, or when both startedAt agree on a single job incarnation.
|
|
2443
|
+
* Pure — no I/O. */
|
|
2444
|
+
function mdTerminalCoversChild(md: DelegationEntry, child: ChildDelegationLike): boolean {
|
|
2445
|
+
const childUpdated = child.time?.updated
|
|
2446
|
+
if (md.updatedAt !== null && childUpdated !== undefined) {
|
|
2447
|
+
if (md.updatedAt >= childUpdated) return true
|
|
2448
|
+
// Stale Finalized — same incarnation only if both startedAt agree.
|
|
2449
|
+
return md.startedAt === child.time?.created
|
|
2450
|
+
}
|
|
2451
|
+
return true
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2016
2454
|
export function childrenToDelegationEntries(
|
|
2017
2455
|
children: readonly ChildDelegationLike[] | undefined,
|
|
2018
2456
|
md: readonly DelegationEntry[],
|
|
2019
2457
|
now = Date.now(),
|
|
2458
|
+
parentSessionID = '',
|
|
2020
2459
|
): DelegationEntry[] {
|
|
2021
2460
|
const byTaskID = new Map<string, DelegationEntry>()
|
|
2022
2461
|
for (const m of md) {
|
|
@@ -2028,13 +2467,29 @@ export function childrenToDelegationEntries(
|
|
|
2028
2467
|
if (child.id === '' || seen.has(child.id)) continue
|
|
2029
2468
|
seen.add(child.id)
|
|
2030
2469
|
const mdEntry = byTaskID.get(child.id)
|
|
2470
|
+
// Recency window (source-level): the children API returns EVERY child the
|
|
2471
|
+
// session ever spawned, so without this the whole session history enters
|
|
2472
|
+
// the list (the "197 children" panel). Effective recency = the latest of
|
|
2473
|
+
// the child's own activity and its matched report's Finalized timestamp;
|
|
2474
|
+
// a child with NO timestamp at all is kept (fail-open — it cannot be
|
|
2475
|
+
// judged). Oldest rows are the ones dropped.
|
|
2476
|
+
const lastActivity = Math.max(
|
|
2477
|
+
child.time?.updated ?? 0,
|
|
2478
|
+
child.time?.created ?? 0,
|
|
2479
|
+
mdEntry?.updatedAt ?? 0,
|
|
2480
|
+
)
|
|
2481
|
+
if (lastActivity > 0 && now - lastActivity > DELEGATION_CHILDREN_RECENCY_MS) continue
|
|
2031
2482
|
const state =
|
|
2032
|
-
mdEntry !== undefined && mdEntry.state !== 'running'
|
|
2483
|
+
mdEntry !== undefined && mdEntry.state !== 'running' && mdTerminalCoversChild(mdEntry, child)
|
|
2033
2484
|
? mdEntry.state
|
|
2034
|
-
: childStatusToState(child.status)
|
|
2485
|
+
: childStatusToState(child.status, child.time, now)
|
|
2035
2486
|
out.push({
|
|
2036
|
-
alias: mdEntry?.alias ??
|
|
2037
|
-
|
|
2487
|
+
alias: mdEntry?.alias ?? `native-${child.id.slice(-4)}`,
|
|
2488
|
+
// A report-less child IS a child of the focused session
|
|
2489
|
+
// (session.children is session-scoped), so the caller-passed
|
|
2490
|
+
// parentSessionID is stamped on it as its origin session; an md
|
|
2491
|
+
// match always carries its own session (the report dir name).
|
|
2492
|
+
sessionID: mdEntry?.sessionID ?? parentSessionID,
|
|
2038
2493
|
taskID: child.id,
|
|
2039
2494
|
agent: mdEntry?.agent ?? child.agent ?? 'agent',
|
|
2040
2495
|
state,
|
|
@@ -2046,7 +2501,9 @@ export function childrenToDelegationEntries(
|
|
|
2046
2501
|
description:
|
|
2047
2502
|
mdEntry !== undefined && mdEntry.description !== ''
|
|
2048
2503
|
? mdEntry.description
|
|
2049
|
-
:
|
|
2504
|
+
: child.title !== undefined && child.title !== ''
|
|
2505
|
+
? child.title
|
|
2506
|
+
: 'task nativa',
|
|
2050
2507
|
source: mdEntry !== undefined ? 'md' : 'children-only',
|
|
2051
2508
|
})
|
|
2052
2509
|
}
|
|
@@ -2060,17 +2517,46 @@ export function childrenToDelegationEntries(
|
|
|
2060
2517
|
* server-valid session id ("ses...") ever reaches the router, so an
|
|
2061
2518
|
* unsubstituted "{sessionID}" placeholder can never be routed. */
|
|
2062
2519
|
export function navigateToDelegationSession(
|
|
2063
|
-
route:
|
|
2520
|
+
route:
|
|
2521
|
+
| {
|
|
2522
|
+
navigate?: (name: string, params?: Record<string, unknown>) => void | PromiseLike<void>
|
|
2523
|
+
}
|
|
2524
|
+
| undefined,
|
|
2064
2525
|
taskID: string | undefined,
|
|
2065
2526
|
): boolean {
|
|
2066
2527
|
if (typeof route?.navigate !== 'function' || !isValidSessionId(taskID)) return false
|
|
2067
|
-
|
|
2068
|
-
|
|
2528
|
+
try {
|
|
2529
|
+
const navigation = route.navigate('session', { sessionID: taskID })
|
|
2530
|
+
if (navigation !== undefined) {
|
|
2531
|
+
// A removed session can reject route.navigate for historical rows. That
|
|
2532
|
+
// rejection is expected and intentionally absorbed so it cannot break
|
|
2533
|
+
// the TUI; the no-op avoids logging sensitive session details.
|
|
2534
|
+
void Promise.resolve(navigation).catch(() => undefined)
|
|
2535
|
+
}
|
|
2536
|
+
return true
|
|
2537
|
+
} catch {
|
|
2538
|
+
// Synchronous router failures are also fail-open for an inert historical row.
|
|
2539
|
+
return false
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
/** Build the mouse handler used by each delegation row. */
|
|
2544
|
+
export function createDelegationRowOpenHandler(
|
|
2545
|
+
route:
|
|
2546
|
+
| {
|
|
2547
|
+
navigate?: (name: string, params?: Record<string, unknown>) => void | PromiseLike<void>
|
|
2548
|
+
}
|
|
2549
|
+
| undefined,
|
|
2550
|
+
taskID: string | undefined,
|
|
2551
|
+
): () => void {
|
|
2552
|
+
return () => {
|
|
2553
|
+
navigateToDelegationSession(route, taskID)
|
|
2554
|
+
}
|
|
2069
2555
|
}
|
|
2070
2556
|
|
|
2071
2557
|
/* ─── Silence-by-default panel logger ──────────────────────
|
|
2072
|
-
* Mirrors src/pantheon/logger.ts createPantheonLogger (the
|
|
2073
|
-
*
|
|
2558
|
+
* Mirrors src/pantheon/logger.ts createPantheonLogger (the copied plugin
|
|
2559
|
+
* ships only dist/ + package.json, so it cannot import from src/pantheon):
|
|
2074
2560
|
* every line is ALWAYS appended to `<logDir>/.pantheon/logs/hooks.log` and
|
|
2075
2561
|
* echoed to the console ONLY when PANTHEON_HOOKS_LOG is truthy (opencode
|
|
2076
2562
|
* renders plugin console output directly into the TUI — the pollution bug).
|
|
@@ -2112,8 +2598,19 @@ function createTuiLogger(projectRoot: string | undefined, module = 'pantheon-tui
|
|
|
2112
2598
|
`${lines.map((l) => `[${stamp}] [${module}] ${l}`).join('\n')}\n`,
|
|
2113
2599
|
'utf8',
|
|
2114
2600
|
)
|
|
2115
|
-
} catch {
|
|
2116
|
-
/* best-effort file log — never break the panel over logging
|
|
2601
|
+
} catch (err) {
|
|
2602
|
+
/* best-effort file log — never break the panel over logging, but do
|
|
2603
|
+
* NOT swallow the failure silently: a missing symbol here (e.g.
|
|
2604
|
+
* `appendFile` used without an import) previously threw an invisible
|
|
2605
|
+
* ReferenceError and the log silently stopped writing. Gated by the
|
|
2606
|
+
* echo flag to avoid the TUI-pollution bug described above. */
|
|
2607
|
+
if (echo) {
|
|
2608
|
+
try {
|
|
2609
|
+
process.stderr.write(`[${module}] file-log failed: ${formatArg(err)}\n`)
|
|
2610
|
+
} catch {
|
|
2611
|
+
/* stderr unavailable — never break the panel */
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2117
2614
|
}
|
|
2118
2615
|
})()
|
|
2119
2616
|
if (echo) {
|
|
@@ -2166,53 +2663,32 @@ function DelegationRow(props: {
|
|
|
2166
2663
|
api: TuiPluginApi
|
|
2167
2664
|
job: DelegationEntry
|
|
2168
2665
|
now: number
|
|
2169
|
-
|
|
2666
|
+
frame: number
|
|
2170
2667
|
}) {
|
|
2171
2668
|
const theme = () => props.api.theme.current
|
|
2172
2669
|
|
|
2173
|
-
|
|
2670
|
+
// Color is a status-wide channel: the WHOLE row (lead, elapsed, description,
|
|
2671
|
+
// live activity) carries the state tone, so red=failed / green=done /
|
|
2672
|
+
// yellow=in-flight reads at a glance. The glyph stays as a redundant,
|
|
2673
|
+
// monochrome-legible signal — never the only one.
|
|
2674
|
+
const tone = createMemo(() => {
|
|
2174
2675
|
const t = theme()
|
|
2175
|
-
switch (props.job.state) {
|
|
2176
|
-
case '
|
|
2177
|
-
return t.warning
|
|
2178
|
-
case 'retry':
|
|
2676
|
+
switch (delegationStateTone(props.job.state)) {
|
|
2677
|
+
case 'warning':
|
|
2179
2678
|
return t.warning
|
|
2180
|
-
case 'stale-running':
|
|
2181
|
-
return t.error // warning color for stale delegations
|
|
2182
|
-
case 'completed':
|
|
2183
|
-
return t.success
|
|
2184
2679
|
case 'error':
|
|
2185
2680
|
return t.error
|
|
2681
|
+
case 'success':
|
|
2682
|
+
return t.success
|
|
2186
2683
|
default:
|
|
2187
|
-
return t.textMuted //
|
|
2188
|
-
}
|
|
2189
|
-
})
|
|
2190
|
-
|
|
2191
|
-
const marker = createMemo(() => {
|
|
2192
|
-
if (props.job.state === 'running') return `${delegationSpinnerFrame(props.animationNow)} `
|
|
2193
|
-
if (props.job.state === 'retry') return '\u27f3 ' // ⟳ retrying
|
|
2194
|
-
if (props.job.state === 'stale-running') return '\u26a0 ' // warning triangle
|
|
2195
|
-
switch (props.job.state) {
|
|
2196
|
-
case 'completed':
|
|
2197
|
-
return '\u2713 '
|
|
2198
|
-
case 'error':
|
|
2199
|
-
return '\u2715 '
|
|
2200
|
-
default:
|
|
2201
|
-
return '\u25cb '
|
|
2684
|
+
return t.textMuted // defensive: unknown runtime state
|
|
2202
2685
|
}
|
|
2203
2686
|
})
|
|
2204
2687
|
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
const
|
|
2209
|
-
|
|
2210
|
-
const detail = createMemo(() => {
|
|
2211
|
-
const elapsed = delegationElapsed(props.job, props.now)
|
|
2212
|
-
const description = props.job.description !== '' ? ` \u2014 ${props.job.description}` : ''
|
|
2213
|
-
const line = `${elapsed}${description}`
|
|
2214
|
-
return line.length > 180 ? `${line.slice(0, 177)}\u2026` : line
|
|
2215
|
-
})
|
|
2688
|
+
const marker = createMemo(() => delegationRowMarker(props.job.state, props.frame))
|
|
2689
|
+
const lead = createMemo(() => formatDelegationRowLead(props.job, marker()))
|
|
2690
|
+
const elapsed = createMemo(() => formatDelegationElapsed(props.job, props.now))
|
|
2691
|
+
const description = createMemo(() => truncateDelegationDescription(props.job.description))
|
|
2216
2692
|
|
|
2217
2693
|
// beta.6: live tool call the child is running RIGHT NOW ("↳ bash npm test"),
|
|
2218
2694
|
// fed by message.part.updated activity tracking. Null while idle/done.
|
|
@@ -2223,25 +2699,26 @@ function DelegationRow(props: {
|
|
|
2223
2699
|
// Click a row → navigate to the child session (route API guarded in the
|
|
2224
2700
|
// helper). Enter-key binding is left out (per-row keymap wiring is not
|
|
2225
2701
|
// worth the complexity in this sidebar); mouse is enabled in tui.json.
|
|
2226
|
-
const open = ()
|
|
2227
|
-
navigateToDelegationSession(props.api.route, props.job.taskID)
|
|
2228
|
-
}
|
|
2702
|
+
const open = createDelegationRowOpenHandler(props.api.route, props.job.taskID)
|
|
2229
2703
|
|
|
2230
2704
|
return (
|
|
2231
2705
|
<box onMouseDown={open}>
|
|
2706
|
+
{/* Line 1: toned lead (glyph + identity) + toned elapsed tail. */}
|
|
2232
2707
|
<box flexDirection="row">
|
|
2233
|
-
<text
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
>{` ${props.job.agent} \u2014 ${delegationActivityLabel(props.job)}`}</text>
|
|
2708
|
+
<text>
|
|
2709
|
+
<span fg={tone()}>{lead()}</span>
|
|
2710
|
+
<span fg={tone()}>{elapsed()}</span>
|
|
2711
|
+
</text>
|
|
2238
2712
|
</box>
|
|
2239
|
-
|
|
2713
|
+
{/* Line 2: the description, indented under the identity and toned. */}
|
|
2714
|
+
<Show when={description() !== ''}>
|
|
2715
|
+
<text fg={tone()}>{` ${description()}`}</text>
|
|
2716
|
+
</Show>
|
|
2240
2717
|
<Show when={activity()}>
|
|
2241
2718
|
{(a) => (
|
|
2242
2719
|
<text
|
|
2243
|
-
fg={
|
|
2244
|
-
>{`
|
|
2720
|
+
fg={tone()}
|
|
2721
|
+
>{` ↳ ${truncateDelegationDescription(`${a().tool}${a().summary !== '' ? ` ${a().summary}` : ''}`)}`}</text>
|
|
2245
2722
|
)}
|
|
2246
2723
|
</Show>
|
|
2247
2724
|
</box>
|
|
@@ -2350,14 +2827,16 @@ function View(props: {
|
|
|
2350
2827
|
// (.pantheon/delegations) enriches each child with alias/agent/description
|
|
2351
2828
|
// and terminal duration via the `Task ID` match. Live tool-call events are
|
|
2352
2829
|
// merged optimistically so the row appears before those channels catch up.
|
|
2353
|
-
|
|
2830
|
+
// Consistent project root (directory ?? worktree, cwd fallback) — the md
|
|
2831
|
+
// history and the panel logger both read the SAME root (audit finding: the
|
|
2832
|
+
// channels resolved it independently).
|
|
2833
|
+
const projectRoot = createMemo(() => resolvePantheonRoot((props.api.state as any).path))
|
|
2354
2834
|
// Silence-by-default panel logger → the REAL <root>/.pantheon/logs/hooks.log.
|
|
2355
|
-
|
|
2356
|
-
//
|
|
2357
|
-
//
|
|
2358
|
-
//
|
|
2359
|
-
//
|
|
2360
|
-
const panelLog = createTuiLogger(panelLogDir(delegationsDir()))
|
|
2835
|
+
const panelLog = createTuiLogger(projectRoot())
|
|
2836
|
+
// ONLY the active session renders: the final list is filtered by
|
|
2837
|
+
// filterDelegationsToSession after every merge (children + live + md).
|
|
2838
|
+
// Live-first ordering keeps running rows on top; the ceiling
|
|
2839
|
+
// collapses the rest into "… +N more".
|
|
2361
2840
|
// The rendered list is childDelegations (children enriched with md);
|
|
2362
2841
|
// there is no separate md-only signal — the md channel only feeds
|
|
2363
2842
|
// childrenToDelegationEntries below.
|
|
@@ -2366,13 +2845,14 @@ function View(props: {
|
|
|
2366
2845
|
// (oh-my-opencode-slim pattern): re-fetch children + re-read the md every
|
|
2367
2846
|
// second so the panel updates even when NO event ever fires.
|
|
2368
2847
|
const [now, setNow] = createSignal(Date.now())
|
|
2369
|
-
//
|
|
2370
|
-
//
|
|
2371
|
-
const [
|
|
2848
|
+
// Frame counter for the spinner animation — incremented every ~80ms by a
|
|
2849
|
+
// dedicated fast timer, completely decoupled from the data poll cycle.
|
|
2850
|
+
const [frame, setFrame] = createSignal(0)
|
|
2372
2851
|
// Cumulative count of event-triggered refreshes — logged with every fetch
|
|
2373
|
-
// ("panel: children=N md=N events=N") so the (0)
|
|
2374
|
-
// children>0 means the source works; events=0
|
|
2375
|
-
// delivering, and the 1s poll is what keeps
|
|
2852
|
+
// ("panel: children=N(pantheon=M native=K) md=N events=N") so the (0)
|
|
2853
|
+
// symptom is diagnosable: children>0 means the source works; events=0
|
|
2854
|
+
// means the event bus isn't delivering, and the 1s poll is what keeps
|
|
2855
|
+
// the panel honest.
|
|
2376
2856
|
let eventRefreshCount = 0
|
|
2377
2857
|
// In-flight guard: overlap between the poll, events and the version effect
|
|
2378
2858
|
// collapses into one fetch — no duplicate work, no duplicate entries.
|
|
@@ -2381,15 +2861,17 @@ function View(props: {
|
|
|
2381
2861
|
if (delegationsInflight !== null) return delegationsInflight
|
|
2382
2862
|
delegationsInflight = (async () => {
|
|
2383
2863
|
try {
|
|
2384
|
-
|
|
2864
|
+
// Typed (no `as any`): TuiState.session.status is a v2 sync accessor
|
|
2865
|
+
// taking the session id directly — same shape as the client call.
|
|
2866
|
+
const state = props.api.state
|
|
2385
2867
|
// 0. md reports from EVERY session under .pantheon/delegations — the
|
|
2386
|
-
//
|
|
2387
|
-
//
|
|
2388
|
-
//
|
|
2389
|
-
//
|
|
2868
|
+
// ENRICHMENT channel. Read unfiltered (taskID match supplies alias/
|
|
2869
|
+
// agent/description/terminal state); the active-session filter after
|
|
2870
|
+
// the merge keeps only the focused session's reports. Fail-open: a
|
|
2871
|
+
// missing dir yields [].
|
|
2390
2872
|
let md: DelegationEntry[] = []
|
|
2391
2873
|
try {
|
|
2392
|
-
md = await readAllDelegationEntries(
|
|
2874
|
+
md = await readAllDelegationEntries(projectRoot())
|
|
2393
2875
|
} catch {
|
|
2394
2876
|
md = [] // fail-open: never crash the sidebar on a read error
|
|
2395
2877
|
}
|
|
@@ -2398,24 +2880,32 @@ function View(props: {
|
|
|
2398
2880
|
// slot prop; forwarding it to session.children made the server reject
|
|
2399
2881
|
// every poll (~1 err/s: "Expected a string starting with \"ses\", got
|
|
2400
2882
|
// \"%7BsessionID%7D\"") and fail-open the panel into "(0)". A null
|
|
2401
|
-
// resolution SKIPS the fetch entirely: the
|
|
2402
|
-
// (zero children, zero errors).
|
|
2883
|
+
// resolution SKIPS the fetch entirely: the active-session filter yields
|
|
2884
|
+
// an EMPTY panel (zero children, zero rows, zero errors).
|
|
2403
2885
|
const sessionID = resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api })
|
|
2404
2886
|
if (sessionID === null) {
|
|
2405
|
-
|
|
2887
|
+
// No focused session → no scope → empty list. The md read still ran
|
|
2888
|
+
// (enrichment source) but with no session there is nothing to scope
|
|
2889
|
+
// to: there is no cross-session panel.
|
|
2890
|
+
setChildDelegations([])
|
|
2406
2891
|
panelLog.info(
|
|
2407
|
-
|
|
2892
|
+
`${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — inactive panel)`,
|
|
2408
2893
|
)
|
|
2409
2894
|
return
|
|
2410
2895
|
}
|
|
2411
2896
|
// 2. children — PRIMARY source (SDK: api.client.session.children).
|
|
2897
|
+
// v2 SDK: `children` takes a FLAT `{ sessionID }` parameter object.
|
|
2898
|
+
// The v1 `{ path: { id } }` envelope is NOT understood by the v2
|
|
2899
|
+
// client — it left the URL template unsubstituted and the server
|
|
2900
|
+
// rejected every poll ("%7BsessionID%7D" regression).
|
|
2412
2901
|
let children: ChildDelegationLike[] = []
|
|
2413
2902
|
try {
|
|
2414
2903
|
// buildChildrenPath re-validates: with sessionID !== null it is
|
|
2415
2904
|
// never null, so no placeholder can reach the wire.
|
|
2416
|
-
const
|
|
2417
|
-
|
|
2418
|
-
|
|
2905
|
+
const childrenPath = buildChildrenPath(sessionID)
|
|
2906
|
+
const result = childrenPath
|
|
2907
|
+
? await props.api.client.session.children(childrenPath)
|
|
2908
|
+
: undefined
|
|
2419
2909
|
const data = (result?.data ?? result) as unknown
|
|
2420
2910
|
children = Array.isArray(data) ? (data as ChildDelegationLike[]) : []
|
|
2421
2911
|
} catch (err) {
|
|
@@ -2429,13 +2919,16 @@ function View(props: {
|
|
|
2429
2919
|
// path — "%7BsessionID%7D").
|
|
2430
2920
|
if (!isValidSessionId(childID)) return undefined
|
|
2431
2921
|
try {
|
|
2432
|
-
return state
|
|
2922
|
+
return state.session.status(childID)?.type
|
|
2433
2923
|
} catch {
|
|
2434
2924
|
return undefined // status API unavailable — child state fallback
|
|
2435
2925
|
}
|
|
2436
2926
|
}
|
|
2437
2927
|
const withStatus = children.map((c) => ({ ...c, status: resolveStatus(c.id) }))
|
|
2438
|
-
|
|
2928
|
+
// Report-less rows ARE children of the focused session
|
|
2929
|
+
// (session.children is session-scoped), so the resolved id is stamped on
|
|
2930
|
+
// the row as its origin session.
|
|
2931
|
+
const childEntries = childrenToDelegationEntries(withStatus, md, now(), sessionID)
|
|
2439
2932
|
for (const [k, v] of props.liveStore.map)
|
|
2440
2933
|
if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 30_000)
|
|
2441
2934
|
props.liveStore.map.delete(k)
|
|
@@ -2445,10 +2938,26 @@ function View(props: {
|
|
|
2445
2938
|
const liveEntries = [...props.liveStore.map.values()].filter(
|
|
2446
2939
|
(entry) => entry.sessionID === sessionID,
|
|
2447
2940
|
)
|
|
2448
|
-
|
|
2941
|
+
// The event channel is optimistic; merge it over the children/md rows.
|
|
2942
|
+
// Then the active-session filter — applied ONCE after the merge — keeps
|
|
2943
|
+
// ONLY rows whose sessionID === the focused session, so md entries from
|
|
2944
|
+
// other sessions (and orphans with no sessionID) can never render.
|
|
2945
|
+
setChildDelegations(
|
|
2946
|
+
filterDelegationsToSession(
|
|
2947
|
+
mergeChildDelegationSources(childEntries, liveEntries),
|
|
2948
|
+
sessionID,
|
|
2949
|
+
),
|
|
2950
|
+
)
|
|
2449
2951
|
// 4. diagnostic line — every re-fetch (silence-by-default, hooks.log).
|
|
2952
|
+
const counts = countDelegationSources(childEntries)
|
|
2450
2953
|
panelLog.info(
|
|
2451
|
-
|
|
2954
|
+
formatPanelLogLine(
|
|
2955
|
+
children.length,
|
|
2956
|
+
counts.pantheon,
|
|
2957
|
+
counts.native,
|
|
2958
|
+
md.length,
|
|
2959
|
+
eventRefreshCount,
|
|
2960
|
+
),
|
|
2452
2961
|
)
|
|
2453
2962
|
} finally {
|
|
2454
2963
|
delegationsInflight = null
|
|
@@ -2456,20 +2965,16 @@ function View(props: {
|
|
|
2456
2965
|
})()
|
|
2457
2966
|
return delegationsInflight
|
|
2458
2967
|
}
|
|
2459
|
-
//
|
|
2460
|
-
//
|
|
2461
|
-
//
|
|
2462
|
-
|
|
2463
|
-
const
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
const
|
|
2469
|
-
const clamped = Math.max(0, Math.min(page, Math.max(0, pages - 1)))
|
|
2470
|
-
archivedUiState.page = clamped
|
|
2471
|
-
setArchivedPage(clamped)
|
|
2472
|
-
}
|
|
2968
|
+
// Live-first ceiling: at most DELEGATION_VISIBLE_CEILING rows render; the
|
|
2969
|
+
// remainder collapses into a single "… +N more" line. Both the ceiling and
|
|
2970
|
+
// the header operate on the ALREADY-FILTERED active-session list
|
|
2971
|
+
// (childDelegations), so other-session / hidden rows never inflate counts.
|
|
2972
|
+
const delegationCeiling = createMemo(() =>
|
|
2973
|
+
ceilingDelegationList(childDelegations(), DELEGATION_VISIBLE_CEILING, now()),
|
|
2974
|
+
)
|
|
2975
|
+
// Header counts the same retained rows shown below, so hidden/expired md
|
|
2976
|
+
// history cannot inflate the summary.
|
|
2977
|
+
const delegationHeader = createMemo(() => formatDelegationHeader(delegationCeiling().visible))
|
|
2473
2978
|
|
|
2474
2979
|
// ── Event subscriptions for live session/delegation updates ──
|
|
2475
2980
|
onMount(() => {
|
|
@@ -2517,7 +3022,9 @@ function View(props: {
|
|
|
2517
3022
|
void refreshDelegations()
|
|
2518
3023
|
}, 1_000)
|
|
2519
3024
|
cleanup.push(() => clearInterval(poll))
|
|
2520
|
-
|
|
3025
|
+
// Fast spinner animation: 80ms frame counter, decoupled from data poll.
|
|
3026
|
+
// Only the spinner glyph depends on this; elapsed + data use the 1s timer.
|
|
3027
|
+
const animation = setInterval(() => setFrame((f) => f + 1), 80)
|
|
2521
3028
|
cleanup.push(() => clearInterval(animation))
|
|
2522
3029
|
|
|
2523
3030
|
// Compaction recovery: `message.part.removed` clears live entries while
|
|
@@ -2612,13 +3119,15 @@ function View(props: {
|
|
|
2612
3119
|
{/* ── Delegations (session.children primary + .pantheon/delegations md enrichment) ── */}
|
|
2613
3120
|
<box onMouseDown={() => setShowDelegations((x) => !x)}>
|
|
2614
3121
|
<text fg={theme().text} attributes={1}>
|
|
2615
|
-
{`${showDelegations() ? '
|
|
3122
|
+
{`${showDelegations() ? '▼' : '▶'} Delegations`}
|
|
3123
|
+
</text>
|
|
3124
|
+
<text fg={theme().textMuted}>
|
|
3125
|
+
{delegationCeiling().visible.length > 0 ? ` ${delegationHeader()}` : ' — idle'}
|
|
2616
3126
|
</text>
|
|
2617
|
-
<text fg={theme().textMuted}>{` (${String(visibleDelegations().length)})`}</text>
|
|
2618
3127
|
</box>
|
|
2619
3128
|
<Show when={showDelegations()}>
|
|
2620
3129
|
<Show
|
|
2621
|
-
when={
|
|
3130
|
+
when={delegationCeiling().visible.length > 0}
|
|
2622
3131
|
fallback={
|
|
2623
3132
|
<box marginLeft={1}>
|
|
2624
3133
|
<text fg={theme().textMuted}>No delegations</text>
|
|
@@ -2626,50 +3135,15 @@ function View(props: {
|
|
|
2626
3135
|
}
|
|
2627
3136
|
>
|
|
2628
3137
|
<box marginLeft={1} flexDirection="column">
|
|
2629
|
-
<For each={
|
|
2630
|
-
{(job) => (
|
|
2631
|
-
<DelegationRow
|
|
2632
|
-
api={props.api}
|
|
2633
|
-
job={job}
|
|
2634
|
-
now={now()}
|
|
2635
|
-
animationNow={animationNow()}
|
|
2636
|
-
/>
|
|
2637
|
-
)}
|
|
3138
|
+
<For each={delegationCeiling().visible}>
|
|
3139
|
+
{(job) => <DelegationRow api={props.api} job={job} now={now()} frame={frame()} />}
|
|
2638
3140
|
</For>
|
|
2639
|
-
<Show when={
|
|
2640
|
-
{(
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
const slice = archived.slice(page * pageSize, page * pageSize + pageSize)
|
|
2646
|
-
return (
|
|
2647
|
-
<box flexDirection="column">
|
|
2648
|
-
<box flexDirection="row" marginLeft={0}>
|
|
2649
|
-
<text
|
|
2650
|
-
fg={theme().textMuted}
|
|
2651
|
-
onMouseDown={() => clampArchivedPage(page - 1, pages)}
|
|
2652
|
-
>{`\u2039 `}</text>
|
|
2653
|
-
<text fg={theme().textMuted}>{`Archived (${archived.length}) `}</text>
|
|
2654
|
-
<text fg={theme().textMuted}>{`${page + 1}/${pages}`}</text>
|
|
2655
|
-
<text
|
|
2656
|
-
fg={theme().textMuted}
|
|
2657
|
-
onMouseDown={() => clampArchivedPage(page + 1, pages)}
|
|
2658
|
-
>{` \u203a`}</text>
|
|
2659
|
-
</box>
|
|
2660
|
-
<For each={slice}>
|
|
2661
|
-
{(job) => (
|
|
2662
|
-
<DelegationRow
|
|
2663
|
-
api={props.api}
|
|
2664
|
-
job={job}
|
|
2665
|
-
now={now()}
|
|
2666
|
-
animationNow={animationNow()}
|
|
2667
|
-
/>
|
|
2668
|
-
)}
|
|
2669
|
-
</For>
|
|
2670
|
-
</box>
|
|
2671
|
-
)
|
|
2672
|
-
})()}
|
|
3141
|
+
<Show when={delegationCeiling().hidden > 0}>
|
|
3142
|
+
<text fg={theme().textMuted}>
|
|
3143
|
+
{delegationCeiling().hiddenActive > 0
|
|
3144
|
+
? `… +${delegationCeiling().hiddenActive} active`
|
|
3145
|
+
: `… +${delegationCeiling().hiddenTerminal} more`}
|
|
3146
|
+
</text>
|
|
2673
3147
|
</Show>
|
|
2674
3148
|
</box>
|
|
2675
3149
|
</Show>
|