pantheon-opencode 1.5.0-beta.9 → 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 +304 -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 +212 -81
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +475 -254
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +2 -2
- package/src/plugins/tui/src/index.tsx +667 -269
- 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 -2853
|
@@ -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,36 +886,38 @@ 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
|
|
@@ -925,12 +928,12 @@ async function setupUsageBar(api: TuiPluginApi) {
|
|
|
925
928
|
* "panel: children=N(pantheon=M native=K) md=N events=N"
|
|
926
929
|
* to `.pantheon/logs/hooks.log` (silence-by-default, createPantheonLogger
|
|
927
930
|
* policy) so the (0) symptom is diagnosable from disk: children>0 means
|
|
928
|
-
* the source works, pantheon/native splits
|
|
931
|
+
* the source works, pantheon/native splits report vs task() children.
|
|
929
932
|
* A children fetch
|
|
930
933
|
* failure logs "panel: error <msg>" (previously silenced), and a missing
|
|
931
934
|
* current session (null/placeholder sessionID) logs "panel: children=0
|
|
932
|
-
* md=N ... (no sessionID —
|
|
933
|
-
* instead of erroring.
|
|
935
|
+
* md=N ... (no sessionID — inactive panel)" — the panel stays EMPTY
|
|
936
|
+
* instead of erroring (no cross-session history).
|
|
934
937
|
*
|
|
935
938
|
* 5. SESSIONID GUARD (placeholder regression) — the runtime may hand the
|
|
936
939
|
* sidebar the UNSUBSTITUTED template literal "{sessionID}" as its
|
|
@@ -940,8 +943,8 @@ async function setupUsageBar(api: TuiPluginApi) {
|
|
|
940
943
|
* panel into "(0)". resolveCurrentSessionID (slot prop → api.state →
|
|
941
944
|
* route.current) never yields a placeholder (isValidSessionId rejects
|
|
942
945
|
* "{sessionID}" and "%7BsessionID%7D" via the startsWith("ses")
|
|
943
|
-
* contract); a null resolution SKIPS the children fetch entirely and
|
|
944
|
-
* renders
|
|
946
|
+
* contract); a null resolution SKIPS the children fetch entirely and the
|
|
947
|
+
* panel renders EMPTY — zero rows, zero errors.
|
|
945
948
|
*
|
|
946
949
|
* The live tool-part lifecycle helpers below remain (tested, exported) but
|
|
947
950
|
* are no longer the source of truth: they feed a version signal that merely
|
|
@@ -954,7 +957,7 @@ export type DelegationEntry = {
|
|
|
954
957
|
alias: string
|
|
955
958
|
/** Parent session the job was launched from (dir name under .pantheon/delegations). */
|
|
956
959
|
sessionID: string
|
|
957
|
-
/** Child session id (=
|
|
960
|
+
/** Child session id (= task id, from the `Task ID` header). The
|
|
958
961
|
* children channel always sets it from the child session itself; the md
|
|
959
962
|
* channel parses it so child↔md matching works by taskID. */
|
|
960
963
|
taskID?: string
|
|
@@ -977,9 +980,15 @@ export type DelegationEntry = {
|
|
|
977
980
|
description: string
|
|
978
981
|
/** True while the panel is waiting for pantheon_delegation_read. */
|
|
979
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.
|
|
980
987
|
/** Internal provenance used to keep a finalized md report authoritative.
|
|
981
|
-
* 'children-only' = a native task() child session with NO
|
|
982
|
-
* (
|
|
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. */
|
|
983
992
|
source?: 'child' | 'live' | 'md' | 'children-only'
|
|
984
993
|
}
|
|
985
994
|
|
|
@@ -1155,29 +1164,39 @@ export async function readDelegationEntries(dir: string): Promise<DelegationEntr
|
|
|
1155
1164
|
|
|
1156
1165
|
/** Read delegation reports from EVERY session under
|
|
1157
1166
|
* `<root>/.pantheon/delegations/<sessionID>/<alias>.md` — the panel's
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
1160
|
-
*
|
|
1161
|
-
*
|
|
1162
|
-
*
|
|
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 []. */
|
|
1163
1173
|
export async function readAllDelegationEntries(root: string): Promise<DelegationEntry[]> {
|
|
1164
1174
|
return readDelegationEntries(join(root, '.pantheon', 'delegations'))
|
|
1165
1175
|
}
|
|
1166
1176
|
|
|
1167
|
-
/** Resolve the
|
|
1168
|
-
*
|
|
1169
|
-
*
|
|
1170
|
-
*
|
|
1171
|
-
*
|
|
1172
|
-
*
|
|
1173
|
-
|
|
1174
|
-
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(
|
|
1175
1184
|
state: { directory?: string; worktree?: string } | undefined,
|
|
1176
1185
|
cwd = process.cwd(),
|
|
1177
1186
|
): string {
|
|
1178
1187
|
const root = state?.directory ?? state?.worktree ?? ''
|
|
1179
|
-
if (root === '' || root === '/') return
|
|
1180
|
-
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')
|
|
1181
1200
|
}
|
|
1182
1201
|
|
|
1183
1202
|
/** Project root derived from the delegations dir: `<root>/.pantheon/delegations`
|
|
@@ -1208,29 +1227,49 @@ export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry)
|
|
|
1208
1227
|
}
|
|
1209
1228
|
|
|
1210
1229
|
/** Split the panel list: active jobs (running/retry, stale-marked) first,
|
|
1211
|
-
* then the most recent terminal reports
|
|
1212
|
-
*
|
|
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
|
|
1213
1232
|
* without the TUI runtime. */
|
|
1214
1233
|
export function splitDelegationList(
|
|
1215
1234
|
all: readonly DelegationEntry[],
|
|
1216
1235
|
maxRecent = 8,
|
|
1217
1236
|
now = Date.now(),
|
|
1218
1237
|
staleThresholdMs = 30 * 60 * 1000, // 30 minutes
|
|
1219
|
-
): { 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'
|
|
1220
1245
|
const active = all
|
|
1221
|
-
.filter((d) => d.state
|
|
1246
|
+
.filter((d) => isActiveState(d.state))
|
|
1222
1247
|
.map((d) => markStaleIfRunning(d, now, staleThresholdMs))
|
|
1223
|
-
|
|
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)
|
|
1224
1264
|
return {
|
|
1225
1265
|
active,
|
|
1226
1266
|
recent: terminal.slice(0, maxRecent),
|
|
1227
|
-
archived: terminal.slice(maxRecent),
|
|
1228
1267
|
}
|
|
1229
1268
|
}
|
|
1230
1269
|
|
|
1231
|
-
/**
|
|
1232
|
-
* existing tests): active jobs first, then the most
|
|
1233
|
-
* 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. */
|
|
1234
1273
|
export function visibleDelegationList(
|
|
1235
1274
|
all: readonly DelegationEntry[],
|
|
1236
1275
|
maxTerminal = 8,
|
|
@@ -1249,10 +1288,41 @@ export const STALE_RUNNING_THRESHOLD_MS = 30 * 60 * 1000
|
|
|
1249
1288
|
* display-only `stale-running` state. */
|
|
1250
1289
|
export const IDLE_SILENCE_MS = 60 * 1000
|
|
1251
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
|
+
|
|
1252
1322
|
/**
|
|
1253
1323
|
* Mark a running entry as `stale-running` if it has been running longer than
|
|
1254
1324
|
* the threshold AND has no recent activity (no `updatedAt` change in the last
|
|
1255
|
-
* `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the
|
|
1325
|
+
* `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the persisted state is unchanged.
|
|
1256
1326
|
*
|
|
1257
1327
|
* A `stale-running` entry renders with a warning indicator but the underlying
|
|
1258
1328
|
* delegation is still treated as running by the backend.
|
|
@@ -1270,7 +1340,7 @@ export function markStaleIfRunning(
|
|
|
1270
1340
|
return { ...entry, state: 'stale-running' as const }
|
|
1271
1341
|
}
|
|
1272
1342
|
|
|
1273
|
-
/** Compact elapsed-time label: "
|
|
1343
|
+
/** Compact elapsed-time label, single unit only: "12s"/"3m"/"1h"/"2d" — ticks every
|
|
1274
1344
|
* second for running jobs. */
|
|
1275
1345
|
export function fmtElapsed(ms: number): string {
|
|
1276
1346
|
const total = Math.max(0, Math.floor(ms / 1000))
|
|
@@ -1278,16 +1348,17 @@ export function fmtElapsed(ms: number): string {
|
|
|
1278
1348
|
const hours = Math.floor((total % 86_400) / 3_600)
|
|
1279
1349
|
const minutes = Math.floor((total % 3_600) / 60)
|
|
1280
1350
|
const seconds = total % 60
|
|
1281
|
-
if (days > 0) return `${days}d
|
|
1282
|
-
if (hours > 0) return `${hours}h
|
|
1283
|
-
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`
|
|
1284
1354
|
return `${seconds}s`
|
|
1285
1355
|
}
|
|
1286
1356
|
|
|
1287
|
-
/** Elapsed label for one entry:
|
|
1288
|
-
*
|
|
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). */
|
|
1289
1360
|
export function delegationElapsed(entry: DelegationEntry, now: number): string {
|
|
1290
|
-
if (entry.state === 'running' || entry.state === 'stale-running')
|
|
1361
|
+
if (entry.state === 'running' || entry.state === 'retry' || entry.state === 'stale-running')
|
|
1291
1362
|
return fmtElapsed(now - entry.startedAt)
|
|
1292
1363
|
return entry.updatedAt !== null ? fmtElapsed(entry.updatedAt - entry.startedAt) : '\u2014'
|
|
1293
1364
|
}
|
|
@@ -1336,12 +1407,47 @@ export function delegationActivityLabel(entry: DelegationEntry): string {
|
|
|
1336
1407
|
|
|
1337
1408
|
const DELEGATION_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
1338
1409
|
|
|
1339
|
-
/** Return
|
|
1340
|
-
|
|
1341
|
-
|
|
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
|
|
1342
1415
|
return DELEGATION_SPINNER_FRAMES[index] ?? DELEGATION_SPINNER_FRAMES[0]
|
|
1343
1416
|
}
|
|
1344
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
|
+
|
|
1345
1451
|
/* ─── Live tool activity per child session (beta.6) ────────────────────────
|
|
1346
1452
|
* The delegation panel's activity line ("↳ bash npm test") — what the agent
|
|
1347
1453
|
* is doing RIGHT NOW. Sourced from message.part.updated tool parts: every
|
|
@@ -1360,10 +1466,6 @@ const latestToolActivity = new Map<string, ToolActivity>()
|
|
|
1360
1466
|
const MAX_TOOL_ACTIVITY_ENTRIES = 200
|
|
1361
1467
|
const TOOL_ACTIVITY_TTL_MS = 5 * 60 * 1000
|
|
1362
1468
|
|
|
1363
|
-
/** Archived-section pagination state — module-level so it survives sidebar
|
|
1364
|
-
* remounts (route changes) without leaking into the component tree. */
|
|
1365
|
-
const archivedUiState = { page: 0 }
|
|
1366
|
-
|
|
1367
1469
|
/** First usable summary string from common tool input fields. */
|
|
1368
1470
|
function summarizeToolInput(input: Record<string, unknown> | undefined): string {
|
|
1369
1471
|
if (!input) return ''
|
|
@@ -1446,13 +1548,29 @@ export function mergeChildDelegationSources(
|
|
|
1446
1548
|
})
|
|
1447
1549
|
|
|
1448
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
|
|
1449
1554
|
if (
|
|
1450
1555
|
liveEntry.alias === null &&
|
|
1451
1556
|
liveEntry.taskID === null &&
|
|
1452
|
-
Date.now() - liveEntry.startedAt >
|
|
1557
|
+
Date.now() - liveEntry.startedAt > aliaslessTTL
|
|
1453
1558
|
)
|
|
1454
1559
|
continue
|
|
1455
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
|
+
}
|
|
1456
1574
|
const index =
|
|
1457
1575
|
(incoming.taskID !== undefined ? byTask.get(incoming.taskID) : undefined) ??
|
|
1458
1576
|
(incoming.alias !== ''
|
|
@@ -1500,7 +1618,10 @@ export function mergeChildDelegationSources(
|
|
|
1500
1618
|
startedAt: Math.min(existing.startedAt, incoming.startedAt),
|
|
1501
1619
|
updatedAt: incoming.updatedAt,
|
|
1502
1620
|
read: incoming.read,
|
|
1503
|
-
|
|
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',
|
|
1504
1625
|
}
|
|
1505
1626
|
}
|
|
1506
1627
|
|
|
@@ -1513,7 +1634,7 @@ export function mergeChildDelegationSources(
|
|
|
1513
1634
|
* (api.event.on) delivers `message.part.updated` for every part change;
|
|
1514
1635
|
* we only care about tool parts whose `tool` is pantheon_delegate (job
|
|
1515
1636
|
* launch), the native `task` subagent tool (same parentID === caller
|
|
1516
|
-
* mechanism — its rows render `
|
|
1637
|
+
* mechanism — its rows render `nat:`), or pantheon_delegation_read
|
|
1517
1638
|
* (blocking until terminal → closes the entry). Shape-adapted from the
|
|
1518
1639
|
* SDK v2 ToolPart but duck-typed so the pure helpers are testable without
|
|
1519
1640
|
* the SDK. */
|
|
@@ -1583,7 +1704,7 @@ const READ_ALIAS_PATTERN = /^[a-z]{2,8}-\d+$/i
|
|
|
1583
1704
|
/** Extract the tool name + args from a `message.part.updated` part and
|
|
1584
1705
|
* reduce it to what the panel needs. Returns null for anything that is
|
|
1585
1706
|
* not a pantheon delegation tool part, the native `task` subagent tool
|
|
1586
|
-
* (same parentID === caller mechanism — its children render `
|
|
1707
|
+
* (same parentID === caller mechanism — its children render `nat:`),
|
|
1587
1708
|
* or is missing its callID. */
|
|
1588
1709
|
export function parseDelegationToolPart(
|
|
1589
1710
|
part: DelegationToolPart,
|
|
@@ -1644,8 +1765,8 @@ export function parseDelegationToolPart(
|
|
|
1644
1765
|
: ''
|
|
1645
1766
|
// The alias/taskID only exist once the delegate tool COMPLETES (they are
|
|
1646
1767
|
// returned in its output); a running/pending part has neither. Native
|
|
1647
|
-
// `task` parts never carry a
|
|
1648
|
-
// the child session id
|
|
1768
|
+
// `task` parts never carry a report alias — the children channel supplies
|
|
1769
|
+
// the child session id and the row's identity.
|
|
1649
1770
|
let alias: string | null = null
|
|
1650
1771
|
let taskID: string | null = null
|
|
1651
1772
|
if (status === 'completed') {
|
|
@@ -1718,8 +1839,8 @@ export function reduceDelegationToolPart(
|
|
|
1718
1839
|
// Delegate tool (pantheon_delegate AND native task): pending/running →
|
|
1719
1840
|
// job launched (running). A COMPLETED delegate tool only means the job was
|
|
1720
1841
|
// registered — it stays running and we pick up alias + taskID from the
|
|
1721
|
-
// output. Native `task` parts never yield a
|
|
1722
|
-
// stay
|
|
1842
|
+
// output. Native `task` parts never yield a report alias, so their rows
|
|
1843
|
+
// stay identity-less until the children channel resolves them.
|
|
1723
1844
|
const existing = map.get(parsed.callID)
|
|
1724
1845
|
if (parsed.status === 'error') {
|
|
1725
1846
|
if (existing !== undefined && existing.state === 'error' && existing.updatedAt === parsed.endAt)
|
|
@@ -1804,7 +1925,7 @@ export function removeDelegationEntry(
|
|
|
1804
1925
|
* SDK exposes `api.state.part(messageID)`). The native `task` tool spawns a
|
|
1805
1926
|
* child session with parentID = caller — the same mechanism as
|
|
1806
1927
|
* pantheon_delegate — so its parts feed the live-map as the native signal
|
|
1807
|
-
* (rows
|
|
1928
|
+
* (rows come from the children channel). Pure w.r.t. I/O — used
|
|
1808
1929
|
* by the mount re-scan to re-seed the live map after compaction/attach. */
|
|
1809
1930
|
export function collectDelegationToolParts(
|
|
1810
1931
|
messages: readonly { id?: string; parts?: unknown[] }[] | undefined,
|
|
@@ -1902,6 +2023,30 @@ export function mergeDelegationSources(
|
|
|
1902
2023
|
return all
|
|
1903
2024
|
}
|
|
1904
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
|
+
|
|
1905
2050
|
/* ─── Children channel (primary source, delegations-sidebar pattern) ────
|
|
1906
2051
|
* `api.client.session.children` returns the child sessions of the current
|
|
1907
2052
|
* session — every pantheon_delegate spawns one (parentID = caller). The
|
|
@@ -1910,7 +2055,7 @@ export function mergeDelegationSources(
|
|
|
1910
2055
|
|
|
1911
2056
|
/* ─── Current-session resolution + path guard (placeholder regression) ──
|
|
1912
2057
|
* The sidebar forwarded `props.session_id` verbatim into
|
|
1913
|
-
* `session.children({
|
|
2058
|
+
* `session.children({ sessionID })`. When the TUI runtime renders the
|
|
1914
2059
|
* sidebar without a focused session it leaves the route template
|
|
1915
2060
|
* UNSUBSTITUTED — the literal "{sessionID}" string — which opencode's server
|
|
1916
2061
|
* rejected every poll (SchemaError: Expected a string starting with "ses",
|
|
@@ -1974,33 +2119,38 @@ export function resolveCurrentSessionID(sources: TuiSessionSources): string | nu
|
|
|
1974
2119
|
}
|
|
1975
2120
|
|
|
1976
2121
|
/** THE single choke point for every `session.children` / session-API path.
|
|
1977
|
-
* Returns
|
|
1978
|
-
* null for anything else (placeholder,
|
|
1979
|
-
* skips the call entirely instead of
|
|
1980
|
-
* (the "%7BsessionID%7D" regression).
|
|
1981
|
-
*
|
|
1982
|
-
*
|
|
1983
|
-
|
|
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 {
|
|
1984
2133
|
if (!isValidSessionId(id)) return null
|
|
1985
|
-
return {
|
|
2134
|
+
return { sessionID: id }
|
|
1986
2135
|
}
|
|
1987
2136
|
|
|
1988
|
-
/** Build the `session.children`
|
|
2137
|
+
/** Build the `session.children` parameters ONLY from a validated session id.
|
|
1989
2138
|
* Delegates to {@link safeSessionPath} — the single choke point. Returns
|
|
1990
|
-
* null for null/invalid ids so the caller
|
|
1991
|
-
* sending an unsubstituted placeholder (the
|
|
1992
|
-
|
|
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 {
|
|
1993
2143
|
return safeSessionPath(id)
|
|
1994
2144
|
}
|
|
1995
2145
|
|
|
1996
2146
|
/** Duck-typed subset of a child Session (+ its live status type). */
|
|
1997
2147
|
export type ChildDelegationLike = {
|
|
1998
|
-
/** Child session id (=
|
|
2148
|
+
/** Child session id (= task id). */
|
|
1999
2149
|
id: string
|
|
2000
2150
|
/** Session title — the delegate's description or prompt prefix. */
|
|
2001
2151
|
title?: string
|
|
2002
2152
|
/** Agent name when the child session carries one (duck-typed; native
|
|
2003
|
-
* task() children may expose it,
|
|
2153
|
+
* task() children may expose it, md reports always do). */
|
|
2004
2154
|
agent?: string
|
|
2005
2155
|
/** Status type from api.state.session.status: 'busy' | 'retry' | 'idle',
|
|
2006
2156
|
* or undefined when the status API is unavailable. */
|
|
@@ -2011,29 +2161,232 @@ export type ChildDelegationLike = {
|
|
|
2011
2161
|
}
|
|
2012
2162
|
}
|
|
2013
2163
|
|
|
2014
|
-
/** Map a child status type to a display state.
|
|
2015
|
-
*
|
|
2016
|
-
*
|
|
2017
|
-
*
|
|
2018
|
-
|
|
2019
|
-
|
|
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' {
|
|
2020
2187
|
if (status === 'retry') return 'retry'
|
|
2021
|
-
|
|
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
|
|
2268
|
+
}
|
|
2269
|
+
|
|
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})`
|
|
2022
2366
|
}
|
|
2023
2367
|
|
|
2024
|
-
/**
|
|
2025
|
-
*
|
|
2026
|
-
*
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
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 }
|
|
2032
2385
|
}
|
|
2033
2386
|
|
|
2034
2387
|
/** Split a display list into native task() rows vs pantheon_delegate rows.
|
|
2035
|
-
* Native = source 'children-only' (no
|
|
2036
|
-
* as pantheon. Pure — powers the
|
|
2388
|
+
* Native = source 'children-only' (no delegate report); everything else counts
|
|
2389
|
+
* as pantheon. Pure — powers the hooks.log line. */
|
|
2037
2390
|
export function countDelegationSources(entries: readonly DelegationEntry[]): {
|
|
2038
2391
|
native: number
|
|
2039
2392
|
pantheon: number
|
|
@@ -2045,7 +2398,7 @@ export function countDelegationSources(entries: readonly DelegationEntry[]): {
|
|
|
2045
2398
|
}
|
|
2046
2399
|
|
|
2047
2400
|
/** Diagnostic hooks.log line for a panel re-fetch, with the children
|
|
2048
|
-
* breakdown (pantheon = children WITH a
|
|
2401
|
+
* breakdown (pantheon = children WITH a delegate report, native = children
|
|
2049
2402
|
* WITHOUT one). Pure — the View logs the returned string verbatim. */
|
|
2050
2403
|
export function formatPanelLogLine(
|
|
2051
2404
|
children: number,
|
|
@@ -2065,19 +2418,44 @@ export function formatPanelLogLine(
|
|
|
2065
2418
|
* itself (fallback 'agent'), state derived from its status, startedAt from
|
|
2066
2419
|
* time.created. A report-less child is a NATIVE task() child (every
|
|
2067
2420
|
* child of the current session — pantheon_delegate OR the native `task()`
|
|
2068
|
-
* tool — carries parentID = caller), so it gets source 'children-only',
|
|
2069
|
-
*
|
|
2070
|
-
*
|
|
2071
|
-
* non-empty when the child carries no title.
|
|
2072
|
-
*
|
|
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
|
|
2073
2428
|
* the child's live status. A running child is NEVER archived — it always
|
|
2074
2429
|
* lands in the active split (splitDelegationList active = running/retry).
|
|
2075
|
-
*
|
|
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.
|
|
2076
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
|
+
|
|
2077
2454
|
export function childrenToDelegationEntries(
|
|
2078
2455
|
children: readonly ChildDelegationLike[] | undefined,
|
|
2079
2456
|
md: readonly DelegationEntry[],
|
|
2080
2457
|
now = Date.now(),
|
|
2458
|
+
parentSessionID = '',
|
|
2081
2459
|
): DelegationEntry[] {
|
|
2082
2460
|
const byTaskID = new Map<string, DelegationEntry>()
|
|
2083
2461
|
for (const m of md) {
|
|
@@ -2089,13 +2467,29 @@ export function childrenToDelegationEntries(
|
|
|
2089
2467
|
if (child.id === '' || seen.has(child.id)) continue
|
|
2090
2468
|
seen.add(child.id)
|
|
2091
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
|
|
2092
2482
|
const state =
|
|
2093
|
-
mdEntry !== undefined && mdEntry.state !== 'running'
|
|
2483
|
+
mdEntry !== undefined && mdEntry.state !== 'running' && mdTerminalCoversChild(mdEntry, child)
|
|
2094
2484
|
? mdEntry.state
|
|
2095
|
-
: childStatusToState(child.status)
|
|
2485
|
+
: childStatusToState(child.status, child.time, now)
|
|
2096
2486
|
out.push({
|
|
2097
|
-
alias: mdEntry?.alias ??
|
|
2098
|
-
|
|
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,
|
|
2099
2493
|
taskID: child.id,
|
|
2100
2494
|
agent: mdEntry?.agent ?? child.agent ?? 'agent',
|
|
2101
2495
|
state,
|
|
@@ -2123,17 +2517,46 @@ export function childrenToDelegationEntries(
|
|
|
2123
2517
|
* server-valid session id ("ses...") ever reaches the router, so an
|
|
2124
2518
|
* unsubstituted "{sessionID}" placeholder can never be routed. */
|
|
2125
2519
|
export function navigateToDelegationSession(
|
|
2126
|
-
route:
|
|
2520
|
+
route:
|
|
2521
|
+
| {
|
|
2522
|
+
navigate?: (name: string, params?: Record<string, unknown>) => void | PromiseLike<void>
|
|
2523
|
+
}
|
|
2524
|
+
| undefined,
|
|
2127
2525
|
taskID: string | undefined,
|
|
2128
2526
|
): boolean {
|
|
2129
2527
|
if (typeof route?.navigate !== 'function' || !isValidSessionId(taskID)) return false
|
|
2130
|
-
|
|
2131
|
-
|
|
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
|
+
}
|
|
2132
2555
|
}
|
|
2133
2556
|
|
|
2134
2557
|
/* ─── Silence-by-default panel logger ──────────────────────
|
|
2135
|
-
* Mirrors src/pantheon/logger.ts createPantheonLogger (the
|
|
2136
|
-
*
|
|
2558
|
+
* Mirrors src/pantheon/logger.ts createPantheonLogger (the copied plugin
|
|
2559
|
+
* ships only dist/ + package.json, so it cannot import from src/pantheon):
|
|
2137
2560
|
* every line is ALWAYS appended to `<logDir>/.pantheon/logs/hooks.log` and
|
|
2138
2561
|
* echoed to the console ONLY when PANTHEON_HOOKS_LOG is truthy (opencode
|
|
2139
2562
|
* renders plugin console output directly into the TUI — the pollution bug).
|
|
@@ -2175,8 +2598,19 @@ function createTuiLogger(projectRoot: string | undefined, module = 'pantheon-tui
|
|
|
2175
2598
|
`${lines.map((l) => `[${stamp}] [${module}] ${l}`).join('\n')}\n`,
|
|
2176
2599
|
'utf8',
|
|
2177
2600
|
)
|
|
2178
|
-
} catch {
|
|
2179
|
-
/* 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
|
+
}
|
|
2180
2614
|
}
|
|
2181
2615
|
})()
|
|
2182
2616
|
if (echo) {
|
|
@@ -2229,53 +2663,32 @@ function DelegationRow(props: {
|
|
|
2229
2663
|
api: TuiPluginApi
|
|
2230
2664
|
job: DelegationEntry
|
|
2231
2665
|
now: number
|
|
2232
|
-
|
|
2666
|
+
frame: number
|
|
2233
2667
|
}) {
|
|
2234
2668
|
const theme = () => props.api.theme.current
|
|
2235
2669
|
|
|
2236
|
-
|
|
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(() => {
|
|
2237
2675
|
const t = theme()
|
|
2238
|
-
switch (props.job.state) {
|
|
2239
|
-
case '
|
|
2676
|
+
switch (delegationStateTone(props.job.state)) {
|
|
2677
|
+
case 'warning':
|
|
2240
2678
|
return t.warning
|
|
2241
|
-
case 'retry':
|
|
2242
|
-
return t.warning
|
|
2243
|
-
case 'stale-running':
|
|
2244
|
-
return t.error // warning color for stale delegations
|
|
2245
|
-
case 'completed':
|
|
2246
|
-
return t.success
|
|
2247
2679
|
case 'error':
|
|
2248
2680
|
return t.error
|
|
2681
|
+
case 'success':
|
|
2682
|
+
return t.success
|
|
2249
2683
|
default:
|
|
2250
|
-
return t.textMuted //
|
|
2251
|
-
}
|
|
2252
|
-
})
|
|
2253
|
-
|
|
2254
|
-
const marker = createMemo(() => {
|
|
2255
|
-
if (props.job.state === 'running') return `${delegationSpinnerFrame(props.animationNow)} `
|
|
2256
|
-
if (props.job.state === 'retry') return '\u27f3 ' // ⟳ retrying
|
|
2257
|
-
if (props.job.state === 'stale-running') return '\u26a0 ' // warning triangle
|
|
2258
|
-
switch (props.job.state) {
|
|
2259
|
-
case 'completed':
|
|
2260
|
-
return '\u2713 '
|
|
2261
|
-
case 'error':
|
|
2262
|
-
return '\u2715 '
|
|
2263
|
-
default:
|
|
2264
|
-
return '\u25cb '
|
|
2684
|
+
return t.textMuted // defensive: unknown runtime state
|
|
2265
2685
|
}
|
|
2266
2686
|
})
|
|
2267
2687
|
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
const
|
|
2272
|
-
|
|
2273
|
-
const detail = createMemo(() => {
|
|
2274
|
-
const elapsed = delegationElapsed(props.job, props.now)
|
|
2275
|
-
const description = props.job.description !== '' ? ` \u2014 ${props.job.description}` : ''
|
|
2276
|
-
const line = `${elapsed}${description}`
|
|
2277
|
-
return line.length > 180 ? `${line.slice(0, 177)}\u2026` : line
|
|
2278
|
-
})
|
|
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))
|
|
2279
2692
|
|
|
2280
2693
|
// beta.6: live tool call the child is running RIGHT NOW ("↳ bash npm test"),
|
|
2281
2694
|
// fed by message.part.updated activity tracking. Null while idle/done.
|
|
@@ -2286,25 +2699,26 @@ function DelegationRow(props: {
|
|
|
2286
2699
|
// Click a row → navigate to the child session (route API guarded in the
|
|
2287
2700
|
// helper). Enter-key binding is left out (per-row keymap wiring is not
|
|
2288
2701
|
// worth the complexity in this sidebar); mouse is enabled in tui.json.
|
|
2289
|
-
const open = ()
|
|
2290
|
-
navigateToDelegationSession(props.api.route, props.job.taskID)
|
|
2291
|
-
}
|
|
2702
|
+
const open = createDelegationRowOpenHandler(props.api.route, props.job.taskID)
|
|
2292
2703
|
|
|
2293
2704
|
return (
|
|
2294
2705
|
<box onMouseDown={open}>
|
|
2706
|
+
{/* Line 1: toned lead (glyph + identity) + toned elapsed tail. */}
|
|
2295
2707
|
<box flexDirection="row">
|
|
2296
|
-
<text
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
>{` ${props.job.agent} \u2014 ${delegationActivityLabel(props.job)}`}</text>
|
|
2708
|
+
<text>
|
|
2709
|
+
<span fg={tone()}>{lead()}</span>
|
|
2710
|
+
<span fg={tone()}>{elapsed()}</span>
|
|
2711
|
+
</text>
|
|
2301
2712
|
</box>
|
|
2302
|
-
|
|
2713
|
+
{/* Line 2: the description, indented under the identity and toned. */}
|
|
2714
|
+
<Show when={description() !== ''}>
|
|
2715
|
+
<text fg={tone()}>{` ${description()}`}</text>
|
|
2716
|
+
</Show>
|
|
2303
2717
|
<Show when={activity()}>
|
|
2304
2718
|
{(a) => (
|
|
2305
2719
|
<text
|
|
2306
|
-
fg={
|
|
2307
|
-
>{`
|
|
2720
|
+
fg={tone()}
|
|
2721
|
+
>{` ↳ ${truncateDelegationDescription(`${a().tool}${a().summary !== '' ? ` ${a().summary}` : ''}`)}`}</text>
|
|
2308
2722
|
)}
|
|
2309
2723
|
</Show>
|
|
2310
2724
|
</box>
|
|
@@ -2413,14 +2827,16 @@ function View(props: {
|
|
|
2413
2827
|
// (.pantheon/delegations) enriches each child with alias/agent/description
|
|
2414
2828
|
// and terminal duration via the `Task ID` match. Live tool-call events are
|
|
2415
2829
|
// merged optimistically so the row appears before those channels catch up.
|
|
2416
|
-
|
|
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))
|
|
2417
2834
|
// Silence-by-default panel logger → the REAL <root>/.pantheon/logs/hooks.log.
|
|
2418
|
-
|
|
2419
|
-
//
|
|
2420
|
-
//
|
|
2421
|
-
//
|
|
2422
|
-
//
|
|
2423
|
-
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".
|
|
2424
2840
|
// The rendered list is childDelegations (children enriched with md);
|
|
2425
2841
|
// there is no separate md-only signal — the md channel only feeds
|
|
2426
2842
|
// childrenToDelegationEntries below.
|
|
@@ -2429,9 +2845,9 @@ function View(props: {
|
|
|
2429
2845
|
// (oh-my-opencode-slim pattern): re-fetch children + re-read the md every
|
|
2430
2846
|
// second so the panel updates even when NO event ever fires.
|
|
2431
2847
|
const [now, setNow] = createSignal(Date.now())
|
|
2432
|
-
//
|
|
2433
|
-
//
|
|
2434
|
-
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)
|
|
2435
2851
|
// Cumulative count of event-triggered refreshes — logged with every fetch
|
|
2436
2852
|
// ("panel: children=N(pantheon=M native=K) md=N events=N") so the (0)
|
|
2437
2853
|
// symptom is diagnosable: children>0 means the source works; events=0
|
|
@@ -2445,15 +2861,17 @@ function View(props: {
|
|
|
2445
2861
|
if (delegationsInflight !== null) return delegationsInflight
|
|
2446
2862
|
delegationsInflight = (async () => {
|
|
2447
2863
|
try {
|
|
2448
|
-
|
|
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
|
|
2449
2867
|
// 0. md reports from EVERY session under .pantheon/delegations — the
|
|
2450
|
-
//
|
|
2451
|
-
//
|
|
2452
|
-
//
|
|
2453
|
-
//
|
|
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 [].
|
|
2454
2872
|
let md: DelegationEntry[] = []
|
|
2455
2873
|
try {
|
|
2456
|
-
md = await readAllDelegationEntries(
|
|
2874
|
+
md = await readAllDelegationEntries(projectRoot())
|
|
2457
2875
|
} catch {
|
|
2458
2876
|
md = [] // fail-open: never crash the sidebar on a read error
|
|
2459
2877
|
}
|
|
@@ -2462,24 +2880,32 @@ function View(props: {
|
|
|
2462
2880
|
// slot prop; forwarding it to session.children made the server reject
|
|
2463
2881
|
// every poll (~1 err/s: "Expected a string starting with \"ses\", got
|
|
2464
2882
|
// \"%7BsessionID%7D\"") and fail-open the panel into "(0)". A null
|
|
2465
|
-
// resolution SKIPS the fetch entirely: the
|
|
2466
|
-
// (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).
|
|
2467
2885
|
const sessionID = resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api })
|
|
2468
2886
|
if (sessionID === null) {
|
|
2469
|
-
|
|
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([])
|
|
2470
2891
|
panelLog.info(
|
|
2471
|
-
`${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID —
|
|
2892
|
+
`${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — inactive panel)`,
|
|
2472
2893
|
)
|
|
2473
2894
|
return
|
|
2474
2895
|
}
|
|
2475
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).
|
|
2476
2901
|
let children: ChildDelegationLike[] = []
|
|
2477
2902
|
try {
|
|
2478
2903
|
// buildChildrenPath re-validates: with sessionID !== null it is
|
|
2479
2904
|
// never null, so no placeholder can reach the wire.
|
|
2480
|
-
const
|
|
2481
|
-
|
|
2482
|
-
|
|
2905
|
+
const childrenPath = buildChildrenPath(sessionID)
|
|
2906
|
+
const result = childrenPath
|
|
2907
|
+
? await props.api.client.session.children(childrenPath)
|
|
2908
|
+
: undefined
|
|
2483
2909
|
const data = (result?.data ?? result) as unknown
|
|
2484
2910
|
children = Array.isArray(data) ? (data as ChildDelegationLike[]) : []
|
|
2485
2911
|
} catch (err) {
|
|
@@ -2493,13 +2919,16 @@ function View(props: {
|
|
|
2493
2919
|
// path — "%7BsessionID%7D").
|
|
2494
2920
|
if (!isValidSessionId(childID)) return undefined
|
|
2495
2921
|
try {
|
|
2496
|
-
return state
|
|
2922
|
+
return state.session.status(childID)?.type
|
|
2497
2923
|
} catch {
|
|
2498
2924
|
return undefined // status API unavailable — child state fallback
|
|
2499
2925
|
}
|
|
2500
2926
|
}
|
|
2501
2927
|
const withStatus = children.map((c) => ({ ...c, status: resolveStatus(c.id) }))
|
|
2502
|
-
|
|
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)
|
|
2503
2932
|
for (const [k, v] of props.liveStore.map)
|
|
2504
2933
|
if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 30_000)
|
|
2505
2934
|
props.liveStore.map.delete(k)
|
|
@@ -2509,7 +2938,16 @@ function View(props: {
|
|
|
2509
2938
|
const liveEntries = [...props.liveStore.map.values()].filter(
|
|
2510
2939
|
(entry) => entry.sessionID === sessionID,
|
|
2511
2940
|
)
|
|
2512
|
-
|
|
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
|
+
)
|
|
2513
2951
|
// 4. diagnostic line — every re-fetch (silence-by-default, hooks.log).
|
|
2514
2952
|
const counts = countDelegationSources(childEntries)
|
|
2515
2953
|
panelLog.info(
|
|
@@ -2527,23 +2965,16 @@ function View(props: {
|
|
|
2527
2965
|
})()
|
|
2528
2966
|
return delegationsInflight
|
|
2529
2967
|
}
|
|
2530
|
-
//
|
|
2531
|
-
//
|
|
2532
|
-
//
|
|
2533
|
-
|
|
2534
|
-
const
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
//
|
|
2539
|
-
|
|
2540
|
-
const delegationCounts = createMemo(() => countDelegationSources(visibleDelegations()))
|
|
2541
|
-
const [archivedPage, setArchivedPage] = createSignal(archivedUiState.page)
|
|
2542
|
-
const clampArchivedPage = (page: number, pages: number) => {
|
|
2543
|
-
const clamped = Math.max(0, Math.min(page, Math.max(0, pages - 1)))
|
|
2544
|
-
archivedUiState.page = clamped
|
|
2545
|
-
setArchivedPage(clamped)
|
|
2546
|
-
}
|
|
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))
|
|
2547
2978
|
|
|
2548
2979
|
// ── Event subscriptions for live session/delegation updates ──
|
|
2549
2980
|
onMount(() => {
|
|
@@ -2591,7 +3022,9 @@ function View(props: {
|
|
|
2591
3022
|
void refreshDelegations()
|
|
2592
3023
|
}, 1_000)
|
|
2593
3024
|
cleanup.push(() => clearInterval(poll))
|
|
2594
|
-
|
|
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)
|
|
2595
3028
|
cleanup.push(() => clearInterval(animation))
|
|
2596
3029
|
|
|
2597
3030
|
// Compaction recovery: `message.part.removed` clears live entries while
|
|
@@ -2688,13 +3121,13 @@ function View(props: {
|
|
|
2688
3121
|
<text fg={theme().text} attributes={1}>
|
|
2689
3122
|
{`${showDelegations() ? '▼' : '▶'} Delegations`}
|
|
2690
3123
|
</text>
|
|
2691
|
-
<text
|
|
2692
|
-
|
|
2693
|
-
|
|
3124
|
+
<text fg={theme().textMuted}>
|
|
3125
|
+
{delegationCeiling().visible.length > 0 ? ` ${delegationHeader()}` : ' — idle'}
|
|
3126
|
+
</text>
|
|
2694
3127
|
</box>
|
|
2695
3128
|
<Show when={showDelegations()}>
|
|
2696
3129
|
<Show
|
|
2697
|
-
when={
|
|
3130
|
+
when={delegationCeiling().visible.length > 0}
|
|
2698
3131
|
fallback={
|
|
2699
3132
|
<box marginLeft={1}>
|
|
2700
3133
|
<text fg={theme().textMuted}>No delegations</text>
|
|
@@ -2702,50 +3135,15 @@ function View(props: {
|
|
|
2702
3135
|
}
|
|
2703
3136
|
>
|
|
2704
3137
|
<box marginLeft={1} flexDirection="column">
|
|
2705
|
-
<For each={
|
|
2706
|
-
{(job) => (
|
|
2707
|
-
<DelegationRow
|
|
2708
|
-
api={props.api}
|
|
2709
|
-
job={job}
|
|
2710
|
-
now={now()}
|
|
2711
|
-
animationNow={animationNow()}
|
|
2712
|
-
/>
|
|
2713
|
-
)}
|
|
3138
|
+
<For each={delegationCeiling().visible}>
|
|
3139
|
+
{(job) => <DelegationRow api={props.api} job={job} now={now()} frame={frame()} />}
|
|
2714
3140
|
</For>
|
|
2715
|
-
<Show when={
|
|
2716
|
-
{(
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
const slice = archived.slice(page * pageSize, page * pageSize + pageSize)
|
|
2722
|
-
return (
|
|
2723
|
-
<box flexDirection="column">
|
|
2724
|
-
<box flexDirection="row" marginLeft={0}>
|
|
2725
|
-
<text
|
|
2726
|
-
fg={theme().textMuted}
|
|
2727
|
-
onMouseDown={() => clampArchivedPage(page - 1, pages)}
|
|
2728
|
-
>{`\u2039 `}</text>
|
|
2729
|
-
<text fg={theme().textMuted}>{`Archived (${archived.length}) `}</text>
|
|
2730
|
-
<text fg={theme().textMuted}>{`${page + 1}/${pages}`}</text>
|
|
2731
|
-
<text
|
|
2732
|
-
fg={theme().textMuted}
|
|
2733
|
-
onMouseDown={() => clampArchivedPage(page + 1, pages)}
|
|
2734
|
-
>{` \u203a`}</text>
|
|
2735
|
-
</box>
|
|
2736
|
-
<For each={slice}>
|
|
2737
|
-
{(job) => (
|
|
2738
|
-
<DelegationRow
|
|
2739
|
-
api={props.api}
|
|
2740
|
-
job={job}
|
|
2741
|
-
now={now()}
|
|
2742
|
-
animationNow={animationNow()}
|
|
2743
|
-
/>
|
|
2744
|
-
)}
|
|
2745
|
-
</For>
|
|
2746
|
-
</box>
|
|
2747
|
-
)
|
|
2748
|
-
})()}
|
|
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>
|
|
2749
3147
|
</Show>
|
|
2750
3148
|
</box>
|
|
2751
3149
|
</Show>
|