pantheon-opencode 1.5.0-beta.2 → 1.5.0-beta.20
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/.pantheon/code-mode/manifest.json +21 -0
- package/AGENTS.md +14 -63
- package/CHANGELOG.md +1260 -0
- package/README.md +162 -23
- package/README.pt-BR.md +104 -19
- package/bin/pantheon-init.mjs +178 -21
- package/commands/pantheon.md +1 -35
- package/package.json +23 -15
- package/plugin.json +1 -1
- package/pyproject.toml +62 -0
- package/scripts/code_mode_server.py +431 -55
- package/scripts/doctor.mjs +207 -9
- package/scripts/install/cli-ui.mjs +17 -11
- package/scripts/install/config-migration.mjs +4 -6
- package/scripts/install/opencode.mjs +137 -35
- package/scripts/install/plugin.mjs +12 -45
- package/scripts/install/shared.mjs +43 -2
- package/scripts/install/strings.mjs +129 -0
- package/scripts/install-mcp.mjs +1 -0
- package/scripts/manifest-inventory.mjs +410 -0
- package/scripts/mcp_persistence_server.py +536 -164
- package/scripts/package-evidence.mjs +242 -0
- package/scripts/probe-pantheon-cost.mjs +10 -1
- package/scripts/release-beta-version.mjs +56 -60
- package/scripts/sync-artifacts.mjs +169 -0
- package/scripts/sync-tui.mjs +129 -64
- package/scripts/test-opencode-v1-v2-sandbox.sh +79 -161
- package/scripts/validation-policy.mjs +7 -7
- package/scripts/version-check.mjs +113 -108
- package/scripts/versioning.mjs +230 -86
- package/src/agents/apollo.md +1 -0
- package/src/agents/gaia.md +1 -0
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +11 -54
- 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/code_mode_server.py +431 -55
- package/src/mcp/mcp_persistence_server.py +536 -164
- package/src/mcp/toon_codec.py +2 -4
- package/src/pantheon/background-job-board.ts +47 -5
- package/src/pantheon/{delegation-compaction.ts → compaction-context.ts} +10 -100
- package/src/pantheon/cost-command.ts +309 -44
- package/src/pantheon/delegation-enforce.ts +59 -14
- package/src/pantheon/file-persistence-salvage.ts +64 -0
- package/src/pantheon/file-persistence.ts +194 -45
- 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 +139 -0
- 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/shared-board.ts +37 -0
- package/src/pantheon/task-result-guard.ts +106 -0
- package/src/pantheon/todo-enforcer.ts +3 -3
- package/src/pantheon/tool-ceiling.ts +159 -0
- 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 +83 -251
- package/src/plugins/pantheon-hooks.ts +51 -0
- package/src/plugins/tui/dist/server.d.ts +1 -1
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/server.js +1 -1
- package/src/plugins/tui/dist/server.js.map +1 -1
- package/src/plugins/tui/dist/tui.d.ts +261 -77
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +640 -234
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +2 -2
- package/src/plugins/tui/src/index.tsx +872 -226
- 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 -89
- 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/cost-tracker.ts +0 -108
- package/src/pantheon/delegate-manager.ts +0 -669
- package/src/pantheon/delegation-classifier.ts +0 -307
- package/src/pantheon/delegation-finalize.ts +0 -374
- package/src/pantheon/delegation-notify.ts +0 -212
- package/src/pantheon/delegation-toast.ts +0 -73
- package/src/pantheon/delegation.ts +0 -1120
- package/src/pantheon/dispatch-guard.ts +0 -109
- package/src/pantheon/permission-eval.ts +0 -284
- package/src/pantheon/preemptive-compact.ts +0 -76
- package/src/pantheon/retry-policy.ts +0 -212
- package/src/pantheon/step-cap.ts +0 -110
- package/src/pantheon/token-opt.ts +0 -781
- package/src/pantheon/toon-codec.ts +0 -481
- package/src/pantheon/v2-events.ts +0 -131
- 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 -2605
|
@@ -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
|
|
@@ -959,6 +965,7 @@ export type DelegationEntry = {
|
|
|
959
965
|
agent: string
|
|
960
966
|
state:
|
|
961
967
|
| 'running'
|
|
968
|
+
| 'retry'
|
|
962
969
|
| 'completed'
|
|
963
970
|
| 'error'
|
|
964
971
|
| 'startup_failed'
|
|
@@ -973,9 +980,15 @@ export type DelegationEntry = {
|
|
|
973
980
|
description: string
|
|
974
981
|
/** True while the panel is waiting for pantheon_delegation_read. */
|
|
975
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.
|
|
976
987
|
/** Internal provenance used to keep a finalized md report authoritative.
|
|
977
|
-
* 'children-only' = a native task() child session with NO
|
|
978
|
-
* (
|
|
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. */
|
|
979
992
|
source?: 'child' | 'live' | 'md' | 'children-only'
|
|
980
993
|
}
|
|
981
994
|
|
|
@@ -1151,29 +1164,39 @@ export async function readDelegationEntries(dir: string): Promise<DelegationEntr
|
|
|
1151
1164
|
|
|
1152
1165
|
/** Read delegation reports from EVERY session under
|
|
1153
1166
|
* `<root>/.pantheon/delegations/<sessionID>/<alias>.md` — the panel's
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
1156
|
-
*
|
|
1157
|
-
*
|
|
1158
|
-
*
|
|
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 []. */
|
|
1159
1173
|
export async function readAllDelegationEntries(root: string): Promise<DelegationEntry[]> {
|
|
1160
1174
|
return readDelegationEntries(join(root, '.pantheon', 'delegations'))
|
|
1161
1175
|
}
|
|
1162
1176
|
|
|
1163
|
-
/** Resolve the
|
|
1164
|
-
*
|
|
1165
|
-
*
|
|
1166
|
-
*
|
|
1167
|
-
*
|
|
1168
|
-
*
|
|
1169
|
-
|
|
1170
|
-
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(
|
|
1171
1184
|
state: { directory?: string; worktree?: string } | undefined,
|
|
1172
1185
|
cwd = process.cwd(),
|
|
1173
1186
|
): string {
|
|
1174
1187
|
const root = state?.directory ?? state?.worktree ?? ''
|
|
1175
|
-
if (root === '' || root === '/') return
|
|
1176
|
-
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')
|
|
1177
1200
|
}
|
|
1178
1201
|
|
|
1179
1202
|
/** Project root derived from the delegations dir: `<root>/.pantheon/delegations`
|
|
@@ -1195,27 +1218,66 @@ export function tuiLogPath(projectRoot: string): string {
|
|
|
1195
1218
|
* falling back to startedAt, descending). Shared by the md reader and
|
|
1196
1219
|
* mergeDelegationSources. */
|
|
1197
1220
|
export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry): number {
|
|
1198
|
-
const
|
|
1199
|
-
|
|
1221
|
+
const isActive = (st: DelegationEntry['state']) =>
|
|
1222
|
+
st === 'running' || st === 'retry' || st === 'stale-running' ? 1 : 0
|
|
1223
|
+
const aRun = isActive(a.state)
|
|
1224
|
+
const bRun = isActive(b.state)
|
|
1200
1225
|
if (aRun !== bRun) return bRun - aRun
|
|
1201
1226
|
return (b.updatedAt ?? b.startedAt) - (a.updatedAt ?? a.startedAt)
|
|
1202
1227
|
}
|
|
1203
1228
|
|
|
1204
|
-
/**
|
|
1205
|
-
* recent terminal reports
|
|
1206
|
-
*
|
|
1207
|
-
*
|
|
1229
|
+
/** Split the panel list: active jobs (running/retry, stale-marked) first,
|
|
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
|
|
1232
|
+
* without the TUI runtime. */
|
|
1233
|
+
export function splitDelegationList(
|
|
1234
|
+
all: readonly DelegationEntry[],
|
|
1235
|
+
maxRecent = 8,
|
|
1236
|
+
now = Date.now(),
|
|
1237
|
+
staleThresholdMs = 30 * 60 * 1000, // 30 minutes
|
|
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'
|
|
1245
|
+
const active = all
|
|
1246
|
+
.filter((d) => isActiveState(d.state))
|
|
1247
|
+
.map((d) => markStaleIfRunning(d, now, staleThresholdMs))
|
|
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)
|
|
1264
|
+
return {
|
|
1265
|
+
active,
|
|
1266
|
+
recent: terminal.slice(0, maxRecent),
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
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. */
|
|
1208
1273
|
export function visibleDelegationList(
|
|
1209
1274
|
all: readonly DelegationEntry[],
|
|
1210
1275
|
maxTerminal = 8,
|
|
1211
1276
|
now = Date.now(),
|
|
1212
1277
|
staleThresholdMs = 30 * 60 * 1000, // 30 minutes
|
|
1213
1278
|
): DelegationEntry[] {
|
|
1214
|
-
const
|
|
1215
|
-
|
|
1216
|
-
.map((d) => markStaleIfRunning(d, now, staleThresholdMs))
|
|
1217
|
-
const terminal = all.filter((d) => d.state !== 'running').slice(0, maxTerminal)
|
|
1218
|
-
return [...running, ...terminal]
|
|
1279
|
+
const { active, recent } = splitDelegationList(all, maxTerminal, now, staleThresholdMs)
|
|
1280
|
+
return [...active, ...recent]
|
|
1219
1281
|
}
|
|
1220
1282
|
|
|
1221
1283
|
/** Default stale-running threshold: 30 minutes. */
|
|
@@ -1226,10 +1288,41 @@ export const STALE_RUNNING_THRESHOLD_MS = 30 * 60 * 1000
|
|
|
1226
1288
|
* display-only `stale-running` state. */
|
|
1227
1289
|
export const IDLE_SILENCE_MS = 60 * 1000
|
|
1228
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
|
+
|
|
1229
1322
|
/**
|
|
1230
1323
|
* Mark a running entry as `stale-running` if it has been running longer than
|
|
1231
1324
|
* the threshold AND has no recent activity (no `updatedAt` change in the last
|
|
1232
|
-
* `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the
|
|
1325
|
+
* `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the persisted state is unchanged.
|
|
1233
1326
|
*
|
|
1234
1327
|
* A `stale-running` entry renders with a warning indicator but the underlying
|
|
1235
1328
|
* delegation is still treated as running by the backend.
|
|
@@ -1247,7 +1340,7 @@ export function markStaleIfRunning(
|
|
|
1247
1340
|
return { ...entry, state: 'stale-running' as const }
|
|
1248
1341
|
}
|
|
1249
1342
|
|
|
1250
|
-
/** Compact elapsed-time label: "
|
|
1343
|
+
/** Compact elapsed-time label, single unit only: "12s"/"3m"/"1h"/"2d" — ticks every
|
|
1251
1344
|
* second for running jobs. */
|
|
1252
1345
|
export function fmtElapsed(ms: number): string {
|
|
1253
1346
|
const total = Math.max(0, Math.floor(ms / 1000))
|
|
@@ -1255,16 +1348,17 @@ export function fmtElapsed(ms: number): string {
|
|
|
1255
1348
|
const hours = Math.floor((total % 86_400) / 3_600)
|
|
1256
1349
|
const minutes = Math.floor((total % 3_600) / 60)
|
|
1257
1350
|
const seconds = total % 60
|
|
1258
|
-
if (days > 0) return `${days}d
|
|
1259
|
-
if (hours > 0) return `${hours}h
|
|
1260
|
-
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`
|
|
1261
1354
|
return `${seconds}s`
|
|
1262
1355
|
}
|
|
1263
1356
|
|
|
1264
|
-
/** Elapsed label for one entry:
|
|
1265
|
-
*
|
|
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). */
|
|
1266
1360
|
export function delegationElapsed(entry: DelegationEntry, now: number): string {
|
|
1267
|
-
if (entry.state === 'running' || entry.state === 'stale-running')
|
|
1361
|
+
if (entry.state === 'running' || entry.state === 'retry' || entry.state === 'stale-running')
|
|
1268
1362
|
return fmtElapsed(now - entry.startedAt)
|
|
1269
1363
|
return entry.updatedAt !== null ? fmtElapsed(entry.updatedAt - entry.startedAt) : '\u2014'
|
|
1270
1364
|
}
|
|
@@ -1294,6 +1388,7 @@ export function delegationActivity(entry: DelegationEntry): DelegationActivity {
|
|
|
1294
1388
|
}
|
|
1295
1389
|
|
|
1296
1390
|
export function delegationActivityLabel(entry: DelegationEntry): string {
|
|
1391
|
+
if (entry.state === 'retry') return 'RETRYING'
|
|
1297
1392
|
switch (delegationActivity(entry)) {
|
|
1298
1393
|
case 'delegating':
|
|
1299
1394
|
return 'DELEGATING'
|
|
@@ -1312,12 +1407,126 @@ export function delegationActivityLabel(entry: DelegationEntry): string {
|
|
|
1312
1407
|
|
|
1313
1408
|
const DELEGATION_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
1314
1409
|
|
|
1315
|
-
/** Return
|
|
1316
|
-
|
|
1317
|
-
|
|
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
|
|
1318
1415
|
return DELEGATION_SPINNER_FRAMES[index] ?? DELEGATION_SPINNER_FRAMES[0]
|
|
1319
1416
|
}
|
|
1320
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
|
+
|
|
1451
|
+
/* ─── Live tool activity per child session (beta.6) ────────────────────────
|
|
1452
|
+
* The delegation panel's activity line ("↳ bash npm test") — what the agent
|
|
1453
|
+
* is doing RIGHT NOW. Sourced from message.part.updated tool parts: every
|
|
1454
|
+
* pending/running tool part updates a per-child-session record (latest wins).
|
|
1455
|
+
* Module-level by design: the sidebar remounts on route changes and the
|
|
1456
|
+
* "what is each agent doing" state should survive that (OMO pattern). */
|
|
1457
|
+
|
|
1458
|
+
export type ToolActivity = {
|
|
1459
|
+
tool: string
|
|
1460
|
+
summary: string
|
|
1461
|
+
at: number
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/** Bounded tracker: child session id → latest running tool call. */
|
|
1465
|
+
const latestToolActivity = new Map<string, ToolActivity>()
|
|
1466
|
+
const MAX_TOOL_ACTIVITY_ENTRIES = 200
|
|
1467
|
+
const TOOL_ACTIVITY_TTL_MS = 5 * 60 * 1000
|
|
1468
|
+
|
|
1469
|
+
/** First usable summary string from common tool input fields. */
|
|
1470
|
+
function summarizeToolInput(input: Record<string, unknown> | undefined): string {
|
|
1471
|
+
if (!input) return ''
|
|
1472
|
+
const candidates = ['command', 'description', 'prompt', 'filePath', 'pattern', 'url', 'query']
|
|
1473
|
+
for (const key of candidates) {
|
|
1474
|
+
const value = input[key]
|
|
1475
|
+
if (typeof value === 'string' && value.trim() !== '') return value
|
|
1476
|
+
}
|
|
1477
|
+
return ''
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/** Reduce one message.part.updated tool part to displayable activity.
|
|
1481
|
+
* Returns null for non-tool parts, completed/error parts (no live activity)
|
|
1482
|
+
* or parts without a session id. Pure. */
|
|
1483
|
+
export function extractToolActivity(
|
|
1484
|
+
part: {
|
|
1485
|
+
type?: string
|
|
1486
|
+
tool?: string
|
|
1487
|
+
sessionID?: string
|
|
1488
|
+
state?: { status?: string; input?: Record<string, unknown> }
|
|
1489
|
+
},
|
|
1490
|
+
now = Date.now(),
|
|
1491
|
+
): { sessionID: string; activity: ToolActivity } | null {
|
|
1492
|
+
if (part?.type !== 'tool') return null
|
|
1493
|
+
const status = part.state?.status
|
|
1494
|
+
if (status !== 'pending' && status !== 'running') return null
|
|
1495
|
+
const sessionID = typeof part.sessionID === 'string' ? part.sessionID : ''
|
|
1496
|
+
if (sessionID === '') return null
|
|
1497
|
+
const tool = typeof part.tool === 'string' && part.tool !== '' ? part.tool : 'tool'
|
|
1498
|
+
const summary = summarizeToolInput(part.state?.input).replace(/\s+/g, ' ').trim().slice(0, 48)
|
|
1499
|
+
return { sessionID, activity: { tool, summary, at: now } }
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/** Record an activity sample (bounded map, oldest dropped). */
|
|
1503
|
+
export function trackToolActivity(
|
|
1504
|
+
map: Map<string, ToolActivity>,
|
|
1505
|
+
sessionID: string,
|
|
1506
|
+
activity: ToolActivity,
|
|
1507
|
+
): void {
|
|
1508
|
+
if (sessionID === '') return
|
|
1509
|
+
if (map.size >= MAX_TOOL_ACTIVITY_ENTRIES) {
|
|
1510
|
+
const oldest = map.keys().next().value
|
|
1511
|
+
if (oldest !== undefined) map.delete(oldest)
|
|
1512
|
+
}
|
|
1513
|
+
map.set(sessionID, activity)
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/** Latest live activity for a child session, or null when absent/stale. */
|
|
1517
|
+
export function latestToolActivityFor(
|
|
1518
|
+
map: Map<string, ToolActivity>,
|
|
1519
|
+
sessionID: string | undefined,
|
|
1520
|
+
now = Date.now(),
|
|
1521
|
+
ttlMs = TOOL_ACTIVITY_TTL_MS,
|
|
1522
|
+
): ToolActivity | null {
|
|
1523
|
+
if (!sessionID) return null
|
|
1524
|
+
const hit = map.get(sessionID)
|
|
1525
|
+
if (hit === undefined) return null
|
|
1526
|
+
if (now - hit.at > ttlMs) return null
|
|
1527
|
+
return hit
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1321
1530
|
/** Merge the immediate tool-event channel into the child-session channel.
|
|
1322
1531
|
*
|
|
1323
1532
|
* Children remain the durable source, while live entries make a delegation
|
|
@@ -1339,13 +1548,29 @@ export function mergeChildDelegationSources(
|
|
|
1339
1548
|
})
|
|
1340
1549
|
|
|
1341
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
|
|
1342
1554
|
if (
|
|
1343
1555
|
liveEntry.alias === null &&
|
|
1344
1556
|
liveEntry.taskID === null &&
|
|
1345
|
-
Date.now() - liveEntry.startedAt >
|
|
1557
|
+
Date.now() - liveEntry.startedAt > aliaslessTTL
|
|
1346
1558
|
)
|
|
1347
1559
|
continue
|
|
1348
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
|
+
}
|
|
1349
1574
|
const index =
|
|
1350
1575
|
(incoming.taskID !== undefined ? byTask.get(incoming.taskID) : undefined) ??
|
|
1351
1576
|
(incoming.alias !== ''
|
|
@@ -1393,7 +1618,10 @@ export function mergeChildDelegationSources(
|
|
|
1393
1618
|
startedAt: Math.min(existing.startedAt, incoming.startedAt),
|
|
1394
1619
|
updatedAt: incoming.updatedAt,
|
|
1395
1620
|
read: incoming.read,
|
|
1396
|
-
|
|
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',
|
|
1397
1625
|
}
|
|
1398
1626
|
}
|
|
1399
1627
|
|
|
@@ -1405,9 +1633,11 @@ export function mergeChildDelegationSources(
|
|
|
1405
1633
|
* Source of truth for jobs born while opencode is open. The TUI event bus
|
|
1406
1634
|
* (api.event.on) delivers `message.part.updated` for every part change;
|
|
1407
1635
|
* we only care about tool parts whose `tool` is pantheon_delegate (job
|
|
1408
|
-
* launch)
|
|
1409
|
-
*
|
|
1410
|
-
*
|
|
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. */
|
|
1411
1641
|
|
|
1412
1642
|
/** Duck-typed subset of a tool part (SDK v2 `ToolPart` / `ToolState`). */
|
|
1413
1643
|
export type DelegationToolPart = {
|
|
@@ -1433,7 +1663,7 @@ export type LiveDelegationEntry = {
|
|
|
1433
1663
|
partID: string
|
|
1434
1664
|
/** Parent session the delegation was launched from. */
|
|
1435
1665
|
sessionID: string
|
|
1436
|
-
tool: 'pantheon_delegate' | 'pantheon_delegation_read'
|
|
1666
|
+
tool: 'pantheon_delegate' | 'pantheon_delegation_read' | 'task'
|
|
1437
1667
|
/** Agent name (from the delegate input args). */
|
|
1438
1668
|
agent: string
|
|
1439
1669
|
description: string
|
|
@@ -1453,7 +1683,7 @@ export type ParsedDelegationToolPart = {
|
|
|
1453
1683
|
callID: string
|
|
1454
1684
|
partID: string
|
|
1455
1685
|
sessionID: string
|
|
1456
|
-
tool: 'pantheon_delegate' | 'pantheon_delegation_read'
|
|
1686
|
+
tool: 'pantheon_delegate' | 'pantheon_delegation_read' | 'task'
|
|
1457
1687
|
/** null for read parts (no agent arg — the id targets an existing job). */
|
|
1458
1688
|
agent: string | null
|
|
1459
1689
|
description: string
|
|
@@ -1473,13 +1703,20 @@ const READ_ALIAS_PATTERN = /^[a-z]{2,8}-\d+$/i
|
|
|
1473
1703
|
|
|
1474
1704
|
/** Extract the tool name + args from a `message.part.updated` part and
|
|
1475
1705
|
* reduce it to what the panel needs. Returns null for anything that is
|
|
1476
|
-
* 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. */
|
|
1477
1709
|
export function parseDelegationToolPart(
|
|
1478
1710
|
part: DelegationToolPart,
|
|
1479
1711
|
now = Date.now(),
|
|
1480
1712
|
): ParsedDelegationToolPart | null {
|
|
1481
1713
|
if (part.type !== 'tool') return null
|
|
1482
|
-
if (
|
|
1714
|
+
if (
|
|
1715
|
+
part.tool !== 'pantheon_delegate' &&
|
|
1716
|
+
part.tool !== 'pantheon_delegation_read' &&
|
|
1717
|
+
part.tool !== 'task'
|
|
1718
|
+
)
|
|
1719
|
+
return null
|
|
1483
1720
|
const callID = part.callID
|
|
1484
1721
|
if (callID === undefined || callID === '') return null
|
|
1485
1722
|
const sessionID = part.sessionID ?? ''
|
|
@@ -1514,10 +1751,22 @@ export function parseDelegationToolPart(
|
|
|
1514
1751
|
}
|
|
1515
1752
|
}
|
|
1516
1753
|
|
|
1517
|
-
const agent =
|
|
1518
|
-
|
|
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
|
+
: ''
|
|
1519
1766
|
// The alias/taskID only exist once the delegate tool COMPLETES (they are
|
|
1520
|
-
// 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.
|
|
1521
1770
|
let alias: string | null = null
|
|
1522
1771
|
let taskID: string | null = null
|
|
1523
1772
|
if (status === 'completed') {
|
|
@@ -1529,7 +1778,7 @@ export function parseDelegationToolPart(
|
|
|
1529
1778
|
callID,
|
|
1530
1779
|
partID: part.id ?? '',
|
|
1531
1780
|
sessionID,
|
|
1532
|
-
tool: 'pantheon_delegate',
|
|
1781
|
+
tool: part.tool === 'task' ? 'task' : 'pantheon_delegate',
|
|
1533
1782
|
agent,
|
|
1534
1783
|
description,
|
|
1535
1784
|
status,
|
|
@@ -1587,9 +1836,11 @@ export function reduceDelegationToolPart(
|
|
|
1587
1836
|
return changed
|
|
1588
1837
|
}
|
|
1589
1838
|
|
|
1590
|
-
// Delegate tool: pending/running →
|
|
1591
|
-
// delegate tool only means the job was
|
|
1592
|
-
// 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.
|
|
1593
1844
|
const existing = map.get(parsed.callID)
|
|
1594
1845
|
if (parsed.status === 'error') {
|
|
1595
1846
|
if (existing !== undefined && existing.state === 'error' && existing.updatedAt === parsed.endAt)
|
|
@@ -1598,7 +1849,7 @@ export function reduceDelegationToolPart(
|
|
|
1598
1849
|
callID: parsed.callID,
|
|
1599
1850
|
partID: parsed.partID,
|
|
1600
1851
|
sessionID: parsed.sessionID,
|
|
1601
|
-
tool:
|
|
1852
|
+
tool: parsed.tool,
|
|
1602
1853
|
agent: parsed.agent ?? 'agent',
|
|
1603
1854
|
description: parsed.description,
|
|
1604
1855
|
alias: existing?.alias ?? null,
|
|
@@ -1615,7 +1866,7 @@ export function reduceDelegationToolPart(
|
|
|
1615
1866
|
callID: parsed.callID,
|
|
1616
1867
|
partID: parsed.partID,
|
|
1617
1868
|
sessionID: parsed.sessionID,
|
|
1618
|
-
tool:
|
|
1869
|
+
tool: parsed.tool,
|
|
1619
1870
|
agent: parsed.agent ?? 'agent',
|
|
1620
1871
|
description: parsed.description,
|
|
1621
1872
|
alias: parsed.alias,
|
|
@@ -1668,11 +1919,14 @@ export function removeDelegationEntry(
|
|
|
1668
1919
|
return false
|
|
1669
1920
|
}
|
|
1670
1921
|
|
|
1671
|
-
/** Collect pantheon delegation tool parts from a session's messages.
|
|
1922
|
+
/** Collect pantheon delegation + native task tool parts from a session's messages.
|
|
1672
1923
|
* Messages may carry their parts inline (duck-typed `msg.parts`); when
|
|
1673
1924
|
* they don't, the optional `getParts(messageID)` callback is used (the TUI
|
|
1674
|
-
* SDK exposes `api.state.part(messageID)`).
|
|
1675
|
-
*
|
|
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. */
|
|
1676
1930
|
export function collectDelegationToolParts(
|
|
1677
1931
|
messages: readonly { id?: string; parts?: unknown[] }[] | undefined,
|
|
1678
1932
|
getParts?: (messageID: string) => readonly unknown[] | undefined,
|
|
@@ -1690,7 +1944,9 @@ export function collectDelegationToolParts(
|
|
|
1690
1944
|
const part = raw as DelegationToolPart
|
|
1691
1945
|
if (
|
|
1692
1946
|
part?.type === 'tool' &&
|
|
1693
|
-
(part.tool === 'pantheon_delegate' ||
|
|
1947
|
+
(part.tool === 'pantheon_delegate' ||
|
|
1948
|
+
part.tool === 'pantheon_delegation_read' ||
|
|
1949
|
+
part.tool === 'task')
|
|
1694
1950
|
) {
|
|
1695
1951
|
out.push(part)
|
|
1696
1952
|
}
|
|
@@ -1767,6 +2023,30 @@ export function mergeDelegationSources(
|
|
|
1767
2023
|
return all
|
|
1768
2024
|
}
|
|
1769
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
|
+
|
|
1770
2050
|
/* ─── Children channel (primary source, delegations-sidebar pattern) ────
|
|
1771
2051
|
* `api.client.session.children` returns the child sessions of the current
|
|
1772
2052
|
* session — every pantheon_delegate spawns one (parentID = caller). The
|
|
@@ -1775,7 +2055,7 @@ export function mergeDelegationSources(
|
|
|
1775
2055
|
|
|
1776
2056
|
/* ─── Current-session resolution + path guard (placeholder regression) ──
|
|
1777
2057
|
* The sidebar forwarded `props.session_id` verbatim into
|
|
1778
|
-
* `session.children({
|
|
2058
|
+
* `session.children({ sessionID })`. When the TUI runtime renders the
|
|
1779
2059
|
* sidebar without a focused session it leaves the route template
|
|
1780
2060
|
* UNSUBSTITUTED — the literal "{sessionID}" string — which opencode's server
|
|
1781
2061
|
* rejected every poll (SchemaError: Expected a string starting with "ses",
|
|
@@ -1839,33 +2119,38 @@ export function resolveCurrentSessionID(sources: TuiSessionSources): string | nu
|
|
|
1839
2119
|
}
|
|
1840
2120
|
|
|
1841
2121
|
/** THE single choke point for every `session.children` / session-API path.
|
|
1842
|
-
* Returns
|
|
1843
|
-
* null for anything else (placeholder,
|
|
1844
|
-
* skips the call entirely instead of
|
|
1845
|
-
* (the "%7BsessionID%7D" regression).
|
|
1846
|
-
*
|
|
1847
|
-
*
|
|
1848
|
-
|
|
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 {
|
|
1849
2133
|
if (!isValidSessionId(id)) return null
|
|
1850
|
-
return {
|
|
2134
|
+
return { sessionID: id }
|
|
1851
2135
|
}
|
|
1852
2136
|
|
|
1853
|
-
/** Build the `session.children`
|
|
2137
|
+
/** Build the `session.children` parameters ONLY from a validated session id.
|
|
1854
2138
|
* Delegates to {@link safeSessionPath} — the single choke point. Returns
|
|
1855
|
-
* null for null/invalid ids so the caller
|
|
1856
|
-
* sending an unsubstituted placeholder (the
|
|
1857
|
-
|
|
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 {
|
|
1858
2143
|
return safeSessionPath(id)
|
|
1859
2144
|
}
|
|
1860
2145
|
|
|
1861
2146
|
/** Duck-typed subset of a child Session (+ its live status type). */
|
|
1862
2147
|
export type ChildDelegationLike = {
|
|
1863
|
-
/** Child session id (=
|
|
2148
|
+
/** Child session id (= task id). */
|
|
1864
2149
|
id: string
|
|
1865
2150
|
/** Session title — the delegate's description or prompt prefix. */
|
|
1866
2151
|
title?: string
|
|
1867
2152
|
/** Agent name when the child session carries one (duck-typed; native
|
|
1868
|
-
* task() children may expose it,
|
|
2153
|
+
* task() children may expose it, md reports always do). */
|
|
1869
2154
|
agent?: string
|
|
1870
2155
|
/** Status type from api.state.session.status: 'busy' | 'retry' | 'idle',
|
|
1871
2156
|
* or undefined when the status API is unavailable. */
|
|
@@ -1876,23 +2161,253 @@ export type ChildDelegationLike = {
|
|
|
1876
2161
|
}
|
|
1877
2162
|
}
|
|
1878
2163
|
|
|
1879
|
-
/** Map a child status type to a display state.
|
|
1880
|
-
*
|
|
1881
|
-
*
|
|
1882
|
-
*
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
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' {
|
|
2187
|
+
if (status === 'retry') return 'retry'
|
|
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`
|
|
1886
2308
|
}
|
|
1887
2309
|
|
|
1888
|
-
/**
|
|
1889
|
-
*
|
|
1890
|
-
*
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
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}`
|
|
1896
2411
|
}
|
|
1897
2412
|
|
|
1898
2413
|
/** Turn child sessions (PRIMARY) enriched with md reports into the display
|
|
@@ -1903,15 +2418,44 @@ export function delegationTag(entry: DelegationEntry): string {
|
|
|
1903
2418
|
* itself (fallback 'agent'), state derived from its status, startedAt from
|
|
1904
2419
|
* time.created. A report-less child is a NATIVE task() child (every
|
|
1905
2420
|
* child of the current session — pantheon_delegate OR the native `task()`
|
|
1906
|
-
* tool — carries parentID = caller), so it gets source 'children-only'
|
|
1907
|
-
*
|
|
1908
|
-
*
|
|
1909
|
-
* 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.
|
|
1910
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
|
+
|
|
1911
2454
|
export function childrenToDelegationEntries(
|
|
1912
2455
|
children: readonly ChildDelegationLike[] | undefined,
|
|
1913
2456
|
md: readonly DelegationEntry[],
|
|
1914
2457
|
now = Date.now(),
|
|
2458
|
+
parentSessionID = '',
|
|
1915
2459
|
): DelegationEntry[] {
|
|
1916
2460
|
const byTaskID = new Map<string, DelegationEntry>()
|
|
1917
2461
|
for (const m of md) {
|
|
@@ -1923,13 +2467,29 @@ export function childrenToDelegationEntries(
|
|
|
1923
2467
|
if (child.id === '' || seen.has(child.id)) continue
|
|
1924
2468
|
seen.add(child.id)
|
|
1925
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
|
|
1926
2482
|
const state =
|
|
1927
|
-
mdEntry !== undefined && mdEntry.state !== 'running'
|
|
2483
|
+
mdEntry !== undefined && mdEntry.state !== 'running' && mdTerminalCoversChild(mdEntry, child)
|
|
1928
2484
|
? mdEntry.state
|
|
1929
|
-
: childStatusToState(child.status)
|
|
2485
|
+
: childStatusToState(child.status, child.time, now)
|
|
1930
2486
|
out.push({
|
|
1931
|
-
alias: mdEntry?.alias ??
|
|
1932
|
-
|
|
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,
|
|
1933
2493
|
taskID: child.id,
|
|
1934
2494
|
agent: mdEntry?.agent ?? child.agent ?? 'agent',
|
|
1935
2495
|
state,
|
|
@@ -1941,7 +2501,9 @@ export function childrenToDelegationEntries(
|
|
|
1941
2501
|
description:
|
|
1942
2502
|
mdEntry !== undefined && mdEntry.description !== ''
|
|
1943
2503
|
? mdEntry.description
|
|
1944
|
-
:
|
|
2504
|
+
: child.title !== undefined && child.title !== ''
|
|
2505
|
+
? child.title
|
|
2506
|
+
: 'task nativa',
|
|
1945
2507
|
source: mdEntry !== undefined ? 'md' : 'children-only',
|
|
1946
2508
|
})
|
|
1947
2509
|
}
|
|
@@ -1955,17 +2517,46 @@ export function childrenToDelegationEntries(
|
|
|
1955
2517
|
* server-valid session id ("ses...") ever reaches the router, so an
|
|
1956
2518
|
* unsubstituted "{sessionID}" placeholder can never be routed. */
|
|
1957
2519
|
export function navigateToDelegationSession(
|
|
1958
|
-
route:
|
|
2520
|
+
route:
|
|
2521
|
+
| {
|
|
2522
|
+
navigate?: (name: string, params?: Record<string, unknown>) => void | PromiseLike<void>
|
|
2523
|
+
}
|
|
2524
|
+
| undefined,
|
|
1959
2525
|
taskID: string | undefined,
|
|
1960
2526
|
): boolean {
|
|
1961
2527
|
if (typeof route?.navigate !== 'function' || !isValidSessionId(taskID)) return false
|
|
1962
|
-
|
|
1963
|
-
|
|
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
|
+
}
|
|
1964
2555
|
}
|
|
1965
2556
|
|
|
1966
2557
|
/* ─── Silence-by-default panel logger ──────────────────────
|
|
1967
|
-
* Mirrors src/pantheon/logger.ts createPantheonLogger (the
|
|
1968
|
-
*
|
|
2558
|
+
* Mirrors src/pantheon/logger.ts createPantheonLogger (the copied plugin
|
|
2559
|
+
* ships only dist/ + package.json, so it cannot import from src/pantheon):
|
|
1969
2560
|
* every line is ALWAYS appended to `<logDir>/.pantheon/logs/hooks.log` and
|
|
1970
2561
|
* echoed to the console ONLY when PANTHEON_HOOKS_LOG is truthy (opencode
|
|
1971
2562
|
* renders plugin console output directly into the TUI — the pollution bug).
|
|
@@ -2007,8 +2598,19 @@ function createTuiLogger(projectRoot: string | undefined, module = 'pantheon-tui
|
|
|
2007
2598
|
`${lines.map((l) => `[${stamp}] [${module}] ${l}`).join('\n')}\n`,
|
|
2008
2599
|
'utf8',
|
|
2009
2600
|
)
|
|
2010
|
-
} catch {
|
|
2011
|
-
/* 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
|
+
}
|
|
2012
2614
|
}
|
|
2013
2615
|
})()
|
|
2014
2616
|
if (echo) {
|
|
@@ -2061,68 +2663,64 @@ function DelegationRow(props: {
|
|
|
2061
2663
|
api: TuiPluginApi
|
|
2062
2664
|
job: DelegationEntry
|
|
2063
2665
|
now: number
|
|
2064
|
-
|
|
2666
|
+
frame: number
|
|
2065
2667
|
}) {
|
|
2066
2668
|
const theme = () => props.api.theme.current
|
|
2067
2669
|
|
|
2068
|
-
|
|
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(() => {
|
|
2069
2675
|
const t = theme()
|
|
2070
|
-
switch (props.job.state) {
|
|
2071
|
-
case '
|
|
2676
|
+
switch (delegationStateTone(props.job.state)) {
|
|
2677
|
+
case 'warning':
|
|
2072
2678
|
return t.warning
|
|
2073
|
-
case 'stale-running':
|
|
2074
|
-
return t.error // warning color for stale delegations
|
|
2075
|
-
case 'completed':
|
|
2076
|
-
return t.success
|
|
2077
2679
|
case 'error':
|
|
2078
2680
|
return t.error
|
|
2681
|
+
case 'success':
|
|
2682
|
+
return t.success
|
|
2079
2683
|
default:
|
|
2080
|
-
return t.textMuted //
|
|
2081
|
-
}
|
|
2082
|
-
})
|
|
2083
|
-
|
|
2084
|
-
const marker = createMemo(() => {
|
|
2085
|
-
if (props.job.state === 'running') return `${delegationSpinnerFrame(props.animationNow)} `
|
|
2086
|
-
if (props.job.state === 'stale-running') return '\u26a0 ' // warning triangle
|
|
2087
|
-
switch (props.job.state) {
|
|
2088
|
-
case 'completed':
|
|
2089
|
-
return '\u2713 '
|
|
2090
|
-
case 'error':
|
|
2091
|
-
return '\u2715 '
|
|
2092
|
-
default:
|
|
2093
|
-
return '\u25cb '
|
|
2684
|
+
return t.textMuted // defensive: unknown runtime state
|
|
2094
2685
|
}
|
|
2095
2686
|
})
|
|
2096
2687
|
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
const
|
|
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))
|
|
2101
2692
|
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
})
|
|
2693
|
+
// beta.6: live tool call the child is running RIGHT NOW ("↳ bash npm test"),
|
|
2694
|
+
// fed by message.part.updated activity tracking. Null while idle/done.
|
|
2695
|
+
const activity = createMemo(() =>
|
|
2696
|
+
latestToolActivityFor(latestToolActivity, props.job.taskID, props.now),
|
|
2697
|
+
)
|
|
2108
2698
|
|
|
2109
2699
|
// Click a row → navigate to the child session (route API guarded in the
|
|
2110
2700
|
// helper). Enter-key binding is left out (per-row keymap wiring is not
|
|
2111
2701
|
// worth the complexity in this sidebar); mouse is enabled in tui.json.
|
|
2112
|
-
const open = ()
|
|
2113
|
-
navigateToDelegationSession(props.api.route, props.job.taskID)
|
|
2114
|
-
}
|
|
2702
|
+
const open = createDelegationRowOpenHandler(props.api.route, props.job.taskID)
|
|
2115
2703
|
|
|
2116
2704
|
return (
|
|
2117
2705
|
<box onMouseDown={open}>
|
|
2706
|
+
{/* Line 1: toned lead (glyph + identity) + toned elapsed tail. */}
|
|
2118
2707
|
<box flexDirection="row">
|
|
2119
|
-
<text
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
>{` ${props.job.agent} \u2014 ${delegationActivityLabel(props.job)}`}</text>
|
|
2708
|
+
<text>
|
|
2709
|
+
<span fg={tone()}>{lead()}</span>
|
|
2710
|
+
<span fg={tone()}>{elapsed()}</span>
|
|
2711
|
+
</text>
|
|
2124
2712
|
</box>
|
|
2125
|
-
|
|
2713
|
+
{/* Line 2: the description, indented under the identity and toned. */}
|
|
2714
|
+
<Show when={description() !== ''}>
|
|
2715
|
+
<text fg={tone()}>{` ${description()}`}</text>
|
|
2716
|
+
</Show>
|
|
2717
|
+
<Show when={activity()}>
|
|
2718
|
+
{(a) => (
|
|
2719
|
+
<text
|
|
2720
|
+
fg={tone()}
|
|
2721
|
+
>{` ↳ ${truncateDelegationDescription(`${a().tool}${a().summary !== '' ? ` ${a().summary}` : ''}`)}`}</text>
|
|
2722
|
+
)}
|
|
2723
|
+
</Show>
|
|
2126
2724
|
</box>
|
|
2127
2725
|
)
|
|
2128
2726
|
}
|
|
@@ -2229,14 +2827,16 @@ function View(props: {
|
|
|
2229
2827
|
// (.pantheon/delegations) enriches each child with alias/agent/description
|
|
2230
2828
|
// and terminal duration via the `Task ID` match. Live tool-call events are
|
|
2231
2829
|
// merged optimistically so the row appears before those channels catch up.
|
|
2232
|
-
|
|
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))
|
|
2233
2834
|
// Silence-by-default panel logger → the REAL <root>/.pantheon/logs/hooks.log.
|
|
2234
|
-
|
|
2235
|
-
//
|
|
2236
|
-
//
|
|
2237
|
-
//
|
|
2238
|
-
//
|
|
2239
|
-
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".
|
|
2240
2840
|
// The rendered list is childDelegations (children enriched with md);
|
|
2241
2841
|
// there is no separate md-only signal — the md channel only feeds
|
|
2242
2842
|
// childrenToDelegationEntries below.
|
|
@@ -2245,13 +2845,14 @@ function View(props: {
|
|
|
2245
2845
|
// (oh-my-opencode-slim pattern): re-fetch children + re-read the md every
|
|
2246
2846
|
// second so the panel updates even when NO event ever fires.
|
|
2247
2847
|
const [now, setNow] = createSignal(Date.now())
|
|
2248
|
-
//
|
|
2249
|
-
//
|
|
2250
|
-
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)
|
|
2251
2851
|
// Cumulative count of event-triggered refreshes — logged with every fetch
|
|
2252
|
-
// ("panel: children=N md=N events=N") so the (0)
|
|
2253
|
-
// children>0 means the source works; events=0
|
|
2254
|
-
// 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.
|
|
2255
2856
|
let eventRefreshCount = 0
|
|
2256
2857
|
// In-flight guard: overlap between the poll, events and the version effect
|
|
2257
2858
|
// collapses into one fetch — no duplicate work, no duplicate entries.
|
|
@@ -2260,15 +2861,17 @@ function View(props: {
|
|
|
2260
2861
|
if (delegationsInflight !== null) return delegationsInflight
|
|
2261
2862
|
delegationsInflight = (async () => {
|
|
2262
2863
|
try {
|
|
2263
|
-
|
|
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
|
|
2264
2867
|
// 0. md reports from EVERY session under .pantheon/delegations — the
|
|
2265
|
-
//
|
|
2266
|
-
//
|
|
2267
|
-
//
|
|
2268
|
-
//
|
|
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 [].
|
|
2269
2872
|
let md: DelegationEntry[] = []
|
|
2270
2873
|
try {
|
|
2271
|
-
md = await readAllDelegationEntries(
|
|
2874
|
+
md = await readAllDelegationEntries(projectRoot())
|
|
2272
2875
|
} catch {
|
|
2273
2876
|
md = [] // fail-open: never crash the sidebar on a read error
|
|
2274
2877
|
}
|
|
@@ -2277,24 +2880,32 @@ function View(props: {
|
|
|
2277
2880
|
// slot prop; forwarding it to session.children made the server reject
|
|
2278
2881
|
// every poll (~1 err/s: "Expected a string starting with \"ses\", got
|
|
2279
2882
|
// \"%7BsessionID%7D\"") and fail-open the panel into "(0)". A null
|
|
2280
|
-
// resolution SKIPS the fetch entirely: the
|
|
2281
|
-
// (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).
|
|
2282
2885
|
const sessionID = resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api })
|
|
2283
2886
|
if (sessionID === null) {
|
|
2284
|
-
|
|
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([])
|
|
2285
2891
|
panelLog.info(
|
|
2286
|
-
|
|
2892
|
+
`${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — inactive panel)`,
|
|
2287
2893
|
)
|
|
2288
2894
|
return
|
|
2289
2895
|
}
|
|
2290
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).
|
|
2291
2901
|
let children: ChildDelegationLike[] = []
|
|
2292
2902
|
try {
|
|
2293
2903
|
// buildChildrenPath re-validates: with sessionID !== null it is
|
|
2294
2904
|
// never null, so no placeholder can reach the wire.
|
|
2295
|
-
const
|
|
2296
|
-
|
|
2297
|
-
|
|
2905
|
+
const childrenPath = buildChildrenPath(sessionID)
|
|
2906
|
+
const result = childrenPath
|
|
2907
|
+
? await props.api.client.session.children(childrenPath)
|
|
2908
|
+
: undefined
|
|
2298
2909
|
const data = (result?.data ?? result) as unknown
|
|
2299
2910
|
children = Array.isArray(data) ? (data as ChildDelegationLike[]) : []
|
|
2300
2911
|
} catch (err) {
|
|
@@ -2308,13 +2919,16 @@ function View(props: {
|
|
|
2308
2919
|
// path — "%7BsessionID%7D").
|
|
2309
2920
|
if (!isValidSessionId(childID)) return undefined
|
|
2310
2921
|
try {
|
|
2311
|
-
return state
|
|
2922
|
+
return state.session.status(childID)?.type
|
|
2312
2923
|
} catch {
|
|
2313
2924
|
return undefined // status API unavailable — child state fallback
|
|
2314
2925
|
}
|
|
2315
2926
|
}
|
|
2316
2927
|
const withStatus = children.map((c) => ({ ...c, status: resolveStatus(c.id) }))
|
|
2317
|
-
|
|
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)
|
|
2318
2932
|
for (const [k, v] of props.liveStore.map)
|
|
2319
2933
|
if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 30_000)
|
|
2320
2934
|
props.liveStore.map.delete(k)
|
|
@@ -2324,10 +2938,26 @@ function View(props: {
|
|
|
2324
2938
|
const liveEntries = [...props.liveStore.map.values()].filter(
|
|
2325
2939
|
(entry) => entry.sessionID === sessionID,
|
|
2326
2940
|
)
|
|
2327
|
-
|
|
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
|
+
)
|
|
2328
2951
|
// 4. diagnostic line — every re-fetch (silence-by-default, hooks.log).
|
|
2952
|
+
const counts = countDelegationSources(childEntries)
|
|
2329
2953
|
panelLog.info(
|
|
2330
|
-
|
|
2954
|
+
formatPanelLogLine(
|
|
2955
|
+
children.length,
|
|
2956
|
+
counts.pantheon,
|
|
2957
|
+
counts.native,
|
|
2958
|
+
md.length,
|
|
2959
|
+
eventRefreshCount,
|
|
2960
|
+
),
|
|
2331
2961
|
)
|
|
2332
2962
|
} finally {
|
|
2333
2963
|
delegationsInflight = null
|
|
@@ -2335,9 +2965,16 @@ function View(props: {
|
|
|
2335
2965
|
})()
|
|
2336
2966
|
return delegationsInflight
|
|
2337
2967
|
}
|
|
2338
|
-
//
|
|
2339
|
-
//
|
|
2340
|
-
|
|
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))
|
|
2341
2978
|
|
|
2342
2979
|
// ── Event subscriptions for live session/delegation updates ──
|
|
2343
2980
|
onMount(() => {
|
|
@@ -2385,7 +3022,9 @@ function View(props: {
|
|
|
2385
3022
|
void refreshDelegations()
|
|
2386
3023
|
}, 1_000)
|
|
2387
3024
|
cleanup.push(() => clearInterval(poll))
|
|
2388
|
-
|
|
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)
|
|
2389
3028
|
cleanup.push(() => clearInterval(animation))
|
|
2390
3029
|
|
|
2391
3030
|
// Compaction recovery: `message.part.removed` clears live entries while
|
|
@@ -2480,13 +3119,15 @@ function View(props: {
|
|
|
2480
3119
|
{/* ── Delegations (session.children primary + .pantheon/delegations md enrichment) ── */}
|
|
2481
3120
|
<box onMouseDown={() => setShowDelegations((x) => !x)}>
|
|
2482
3121
|
<text fg={theme().text} attributes={1}>
|
|
2483
|
-
{`${showDelegations() ? '
|
|
3122
|
+
{`${showDelegations() ? '▼' : '▶'} Delegations`}
|
|
3123
|
+
</text>
|
|
3124
|
+
<text fg={theme().textMuted}>
|
|
3125
|
+
{delegationCeiling().visible.length > 0 ? ` ${delegationHeader()}` : ' — idle'}
|
|
2484
3126
|
</text>
|
|
2485
|
-
<text fg={theme().textMuted}>{` (${String(visibleDelegations().length)})`}</text>
|
|
2486
3127
|
</box>
|
|
2487
3128
|
<Show when={showDelegations()}>
|
|
2488
3129
|
<Show
|
|
2489
|
-
when={
|
|
3130
|
+
when={delegationCeiling().visible.length > 0}
|
|
2490
3131
|
fallback={
|
|
2491
3132
|
<box marginLeft={1}>
|
|
2492
3133
|
<text fg={theme().textMuted}>No delegations</text>
|
|
@@ -2494,16 +3135,16 @@ function View(props: {
|
|
|
2494
3135
|
}
|
|
2495
3136
|
>
|
|
2496
3137
|
<box marginLeft={1} flexDirection="column">
|
|
2497
|
-
<For each={
|
|
2498
|
-
{(job) => (
|
|
2499
|
-
<DelegationRow
|
|
2500
|
-
api={props.api}
|
|
2501
|
-
job={job}
|
|
2502
|
-
now={now()}
|
|
2503
|
-
animationNow={animationNow()}
|
|
2504
|
-
/>
|
|
2505
|
-
)}
|
|
3138
|
+
<For each={delegationCeiling().visible}>
|
|
3139
|
+
{(job) => <DelegationRow api={props.api} job={job} now={now()} frame={frame()} />}
|
|
2506
3140
|
</For>
|
|
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>
|
|
3147
|
+
</Show>
|
|
2507
3148
|
</box>
|
|
2508
3149
|
</Show>
|
|
2509
3150
|
</Show>
|
|
@@ -2552,6 +3193,11 @@ const tui: TuiPlugin = (api, _options, _meta) => {
|
|
|
2552
3193
|
const props = (event?.properties ?? {}) as { part?: unknown; info?: { part?: unknown } }
|
|
2553
3194
|
const part = (props.part ?? props.info?.part) as DelegationToolPart | undefined
|
|
2554
3195
|
if (part === undefined) return
|
|
3196
|
+
// beta.6: track the live tool call per child session (panel's
|
|
3197
|
+
// activity line). Fire-and-forget — display-only state.
|
|
3198
|
+
const sample = extractToolActivity(part as any)
|
|
3199
|
+
if (sample !== null)
|
|
3200
|
+
trackToolActivity(latestToolActivity, sample.sessionID, sample.activity)
|
|
2555
3201
|
if (reduceDelegationToolPart(liveStore.map, part)) liveStore.bump()
|
|
2556
3202
|
}),
|
|
2557
3203
|
)
|