pantheon-opencode 1.5.0-beta.9 → 1.5.1
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 +326 -0
- package/README.md +77 -29
- package/README.pt-BR.md +5 -6
- package/commands/pantheon.md +1 -35
- package/package.json +2 -3
- package/plugin.json +1 -1
- package/pyproject.toml +3 -5
- package/scripts/doctor.mjs +260 -5
- 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/mnemosyne.md +11 -1
- package/src/agents/zeus.md +10 -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/memory_mcp_server.py +81 -27
- 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 +2 -2
- package/src/plugins/tui/dist/tui.d.ts +229 -103
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +495 -333
- package/src/plugins/tui/package-lock.json +5 -2
- package/src/plugins/tui/package.json +5 -2
- package/src/plugins/tui/src/index.tsx +710 -409
- 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/mcp/toon_codec.py +0 -491
- 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
|
|
@@ -975,11 +978,15 @@ export type DelegationEntry = {
|
|
|
975
978
|
updatedAt: number | null
|
|
976
979
|
timedOut: boolean
|
|
977
980
|
description: string
|
|
978
|
-
|
|
979
|
-
|
|
981
|
+
// NOTE: the nested/tree view is BACKLOG. `session.children` returns direct
|
|
982
|
+
// children only, so no real grandchild rows exist yet; the parentTaskID /
|
|
983
|
+
// childCount fields + tree-connector helpers were removed as dead code.
|
|
984
|
+
// Reintroduce them once nested rows are actually produced.
|
|
980
985
|
/** Internal provenance used to keep a finalized md report authoritative.
|
|
981
|
-
* 'children-only' = a native task() child session with NO
|
|
982
|
-
* (
|
|
986
|
+
* 'children-only' = a native task() child session with NO delegate report
|
|
987
|
+
* (a native child row); 'md' = the child has a matching report;
|
|
988
|
+
* 'child' = the plain children channel; 'live' = the optimistic tool-event
|
|
989
|
+
* channel. */
|
|
983
990
|
source?: 'child' | 'live' | 'md' | 'children-only'
|
|
984
991
|
}
|
|
985
992
|
|
|
@@ -1155,29 +1162,39 @@ export async function readDelegationEntries(dir: string): Promise<DelegationEntr
|
|
|
1155
1162
|
|
|
1156
1163
|
/** Read delegation reports from EVERY session under
|
|
1157
1164
|
* `<root>/.pantheon/delegations/<sessionID>/<alias>.md` — the panel's
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
1160
|
-
*
|
|
1161
|
-
*
|
|
1162
|
-
*
|
|
1165
|
+
* ENRICHMENT source. The read is deliberately unfiltered (each entry carries
|
|
1166
|
+
* its directory's sessionID, so a taskID match can enrich any channel);
|
|
1167
|
+
* callers MUST scope the merged result with {@link filterDelegationsToSession}
|
|
1168
|
+
* before rendering — only the active session's reports are shown. Entries are
|
|
1169
|
+
* running first, Finalized desc (the sort applied by readDelegationEntries).
|
|
1170
|
+
* Fail-open: a missing/unreadable directory yields []. */
|
|
1163
1171
|
export async function readAllDelegationEntries(root: string): Promise<DelegationEntry[]> {
|
|
1164
1172
|
return readDelegationEntries(join(root, '.pantheon', 'delegations'))
|
|
1165
1173
|
}
|
|
1166
1174
|
|
|
1167
|
-
/** Resolve the
|
|
1168
|
-
*
|
|
1169
|
-
*
|
|
1170
|
-
*
|
|
1171
|
-
*
|
|
1172
|
-
*
|
|
1173
|
-
|
|
1174
|
-
export function resolveDelegationsDir(
|
|
1175
|
+
/** Resolve the PROJECT ROOT used by every pantheon file channel. `directory`
|
|
1176
|
+
* wins over `worktree` (the old `resolveDelegationsDir` already did this);
|
|
1177
|
+
* an absent/empty root or `/` (no git — e.g. the sandbox test project) falls
|
|
1178
|
+
* back to cwd. Standardised here so the delegations md and the panel logger
|
|
1179
|
+
* all read the SAME root (audit finding: the channels
|
|
1180
|
+
* resolved the root independently). Pure — no I/O. */
|
|
1181
|
+
export function resolvePantheonRoot(
|
|
1175
1182
|
state: { directory?: string; worktree?: string } | undefined,
|
|
1176
1183
|
cwd = process.cwd(),
|
|
1177
1184
|
): string {
|
|
1178
1185
|
const root = state?.directory ?? state?.worktree ?? ''
|
|
1179
|
-
if (root === '' || root === '/') return
|
|
1180
|
-
return
|
|
1186
|
+
if (root === '' || root === '/') return cwd
|
|
1187
|
+
return root
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/** Resolve the directory where delegation md reports are written.
|
|
1191
|
+
* The finalizer writes `.pantheon/delegations` RELATIVE to the server cwd,
|
|
1192
|
+
* which the TUI exposes as `TuiState.path.directory`. */
|
|
1193
|
+
export function resolveDelegationsDir(
|
|
1194
|
+
state: { directory?: string; worktree?: string } | undefined,
|
|
1195
|
+
cwd = process.cwd(),
|
|
1196
|
+
): string {
|
|
1197
|
+
return join(resolvePantheonRoot(state, cwd), '.pantheon', 'delegations')
|
|
1181
1198
|
}
|
|
1182
1199
|
|
|
1183
1200
|
/** Project root derived from the delegations dir: `<root>/.pantheon/delegations`
|
|
@@ -1208,29 +1225,49 @@ export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry)
|
|
|
1208
1225
|
}
|
|
1209
1226
|
|
|
1210
1227
|
/** Split the panel list: active jobs (running/retry, stale-marked) first,
|
|
1211
|
-
* then the most recent terminal reports
|
|
1212
|
-
*
|
|
1228
|
+
* then the most recent terminal reports; the remaining tail is collapsed
|
|
1229
|
+
* by the View into a single "… +N more" line. Pure — so the history-only panel (no sessionID) is testable
|
|
1213
1230
|
* without the TUI runtime. */
|
|
1214
1231
|
export function splitDelegationList(
|
|
1215
1232
|
all: readonly DelegationEntry[],
|
|
1216
1233
|
maxRecent = 8,
|
|
1217
1234
|
now = Date.now(),
|
|
1218
1235
|
staleThresholdMs = 30 * 60 * 1000, // 30 minutes
|
|
1219
|
-
): { active: DelegationEntry[]; recent: DelegationEntry[]
|
|
1236
|
+
): { active: DelegationEntry[]; recent: DelegationEntry[] } {
|
|
1237
|
+
// Active = running OR retry OR the display-only stale-running. A stale row
|
|
1238
|
+
// is still a live job (backend state unchanged), so it must never fall into
|
|
1239
|
+
// the terminal tail — pushing a running delegation out hid the row
|
|
1240
|
+
// the user launched.
|
|
1241
|
+
const isActiveState = (st: DelegationEntry['state']): boolean =>
|
|
1242
|
+
st === 'running' || st === 'retry' || st === 'stale-running'
|
|
1220
1243
|
const active = all
|
|
1221
|
-
.filter((d) => d.state
|
|
1244
|
+
.filter((d) => isActiveState(d.state))
|
|
1222
1245
|
.map((d) => markStaleIfRunning(d, now, staleThresholdMs))
|
|
1223
|
-
|
|
1246
|
+
.sort(compareDelegationEntries)
|
|
1247
|
+
const terminal = all
|
|
1248
|
+
.filter((d) => !isActiveState(d.state))
|
|
1249
|
+
.filter((d) => {
|
|
1250
|
+
// A missing/invalid finalized timestamp is not evidence that a report is
|
|
1251
|
+
// old. Keep it visible rather than accidentally hiding a terminal job.
|
|
1252
|
+
if (d.updatedAt === null || !Number.isFinite(d.updatedAt)) return true
|
|
1253
|
+
const age = now - d.updatedAt
|
|
1254
|
+
if (age < 0) return true
|
|
1255
|
+
const retention =
|
|
1256
|
+
delegationRowStatus(d.state) === 'failed'
|
|
1257
|
+
? DELEGATION_FAILED_RETENTION_MS
|
|
1258
|
+
: DELEGATION_DONE_RETENTION_MS
|
|
1259
|
+
return age < retention
|
|
1260
|
+
})
|
|
1261
|
+
.sort(compareDelegationEntries)
|
|
1224
1262
|
return {
|
|
1225
1263
|
active,
|
|
1226
1264
|
recent: terminal.slice(0, maxRecent),
|
|
1227
|
-
archived: terminal.slice(maxRecent),
|
|
1228
1265
|
}
|
|
1229
1266
|
}
|
|
1230
1267
|
|
|
1231
|
-
/**
|
|
1232
|
-
* existing tests): active jobs first, then the most
|
|
1233
|
-
* reports (capped)
|
|
1268
|
+
/** Live-first window used by {@link ceilingDelegationList} (kept for the
|
|
1269
|
+
* ceiling helper and existing tests): active jobs first, then the most
|
|
1270
|
+
* recent terminal reports (capped). Pure. */
|
|
1234
1271
|
export function visibleDelegationList(
|
|
1235
1272
|
all: readonly DelegationEntry[],
|
|
1236
1273
|
maxTerminal = 8,
|
|
@@ -1249,10 +1286,41 @@ export const STALE_RUNNING_THRESHOLD_MS = 30 * 60 * 1000
|
|
|
1249
1286
|
* display-only `stale-running` state. */
|
|
1250
1287
|
export const IDLE_SILENCE_MS = 60 * 1000
|
|
1251
1288
|
|
|
1289
|
+
/** Visual-only terminal retention windows. Reports remain on disk; these
|
|
1290
|
+
* constants only control which rows enter the TUI window. */
|
|
1291
|
+
export const DELEGATION_DONE_RETENTION_MS = 2 * 60 * 1000
|
|
1292
|
+
export const DELEGATION_FAILED_RETENTION_MS = 10 * 60 * 1000
|
|
1293
|
+
|
|
1294
|
+
/** Grace window for an ABSENT child status. `api.state.session.status()` only
|
|
1295
|
+
* carries the sessions that are currently ALIVE; every finished child of a
|
|
1296
|
+
* long-lived session is missing from that map. A child with no status is
|
|
1297
|
+
* therefore treated as terminal (done) — treating it as running made every
|
|
1298
|
+
* historical child of the session show up as "active" (the "197 children,
|
|
1299
|
+
* all active" regression). The one exception is a freshly-spawned child that
|
|
1300
|
+
* has not been registered by the status API yet: within this window of its
|
|
1301
|
+
* last activity (`time.updated`, falling back to `time.created`) it still
|
|
1302
|
+
* reads as running. */
|
|
1303
|
+
export const DELEGATION_CHILD_STATUS_GRACE_MS = 60 * 1000
|
|
1304
|
+
|
|
1305
|
+
/** Recency window for the children channel. `session.children` returns EVERY
|
|
1306
|
+
* child the focused session ever spawned, so a long session accumulates
|
|
1307
|
+
* hundreds of historical rows that would inflate the panel. A child whose
|
|
1308
|
+
* last activity (its own time, or its matched report's Finalized timestamp)
|
|
1309
|
+
* is older than this window is dropped BEFORE it can enter the list. 24h
|
|
1310
|
+
* keeps the sidebar scoped to current work while still covering long
|
|
1311
|
+
* delegations. A child with no timestamp at all is kept (fail-open). */
|
|
1312
|
+
export const DELEGATION_CHILDREN_RECENCY_MS = 24 * 60 * 60 * 1000
|
|
1313
|
+
|
|
1314
|
+
/** Alias-less NATIVE task() live entries never receive a report alias (the
|
|
1315
|
+
* task tool output carries none), so the 30s alias-less prune in
|
|
1316
|
+
* mergeChildDelegationSources must not apply to them — 5 minutes covers a
|
|
1317
|
+
* slow child listing while still bounding the live map. */
|
|
1318
|
+
export const NATIVE_LIVE_ALIASLESS_TTL_MS = 5 * 60 * 1000
|
|
1319
|
+
|
|
1252
1320
|
/**
|
|
1253
1321
|
* Mark a running entry as `stale-running` if it has been running longer than
|
|
1254
1322
|
* the threshold AND has no recent activity (no `updatedAt` change in the last
|
|
1255
|
-
* `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the
|
|
1323
|
+
* `IDLE_SILENCE_MS`). This is DISPLAY-ONLY — the persisted state is unchanged.
|
|
1256
1324
|
*
|
|
1257
1325
|
* A `stale-running` entry renders with a warning indicator but the underlying
|
|
1258
1326
|
* delegation is still treated as running by the backend.
|
|
@@ -1270,7 +1338,7 @@ export function markStaleIfRunning(
|
|
|
1270
1338
|
return { ...entry, state: 'stale-running' as const }
|
|
1271
1339
|
}
|
|
1272
1340
|
|
|
1273
|
-
/** Compact elapsed-time label: "
|
|
1341
|
+
/** Compact elapsed-time label, single unit only: "12s"/"3m"/"1h"/"2d" — ticks every
|
|
1274
1342
|
* second for running jobs. */
|
|
1275
1343
|
export function fmtElapsed(ms: number): string {
|
|
1276
1344
|
const total = Math.max(0, Math.floor(ms / 1000))
|
|
@@ -1278,29 +1346,24 @@ export function fmtElapsed(ms: number): string {
|
|
|
1278
1346
|
const hours = Math.floor((total % 86_400) / 3_600)
|
|
1279
1347
|
const minutes = Math.floor((total % 3_600) / 60)
|
|
1280
1348
|
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
|
|
1349
|
+
if (days > 0) return `${days}d`
|
|
1350
|
+
if (hours > 0) return `${hours}h`
|
|
1351
|
+
if (minutes > 0) return `${minutes}m`
|
|
1284
1352
|
return `${seconds}s`
|
|
1285
1353
|
}
|
|
1286
1354
|
|
|
1287
|
-
/** Elapsed label for one entry:
|
|
1288
|
-
*
|
|
1355
|
+
/** Elapsed label for one entry: an ACTIVE entry (running/retry/stale-running)
|
|
1356
|
+
* ticks `now - startedAt`; a terminal one is fixed at
|
|
1357
|
+
* `updatedAt - startedAt` (em dash when no finalized timestamp). */
|
|
1289
1358
|
export function delegationElapsed(entry: DelegationEntry, now: number): string {
|
|
1290
|
-
if (entry.state === 'running' || entry.state === 'stale-running')
|
|
1359
|
+
if (entry.state === 'running' || entry.state === 'retry' || entry.state === 'stale-running')
|
|
1291
1360
|
return fmtElapsed(now - entry.startedAt)
|
|
1292
1361
|
return entry.updatedAt !== null ? fmtElapsed(entry.updatedAt - entry.startedAt) : '\u2014'
|
|
1293
1362
|
}
|
|
1294
1363
|
|
|
1295
1364
|
/** The activity labels shown by the animated row. Keeping this pure makes the
|
|
1296
1365
|
* state machine testable without booting OpenCode's renderer. */
|
|
1297
|
-
export type DelegationActivity =
|
|
1298
|
-
| 'delegating'
|
|
1299
|
-
| 'working'
|
|
1300
|
-
| 'reading'
|
|
1301
|
-
| 'completed'
|
|
1302
|
-
| 'error'
|
|
1303
|
-
| 'cancelled'
|
|
1366
|
+
export type DelegationActivity = 'delegating' | 'working' | 'completed' | 'error' | 'cancelled'
|
|
1304
1367
|
|
|
1305
1368
|
export function delegationActivity(entry: DelegationEntry): DelegationActivity {
|
|
1306
1369
|
if (entry.state === 'completed') return 'completed'
|
|
@@ -1311,7 +1374,6 @@ export function delegationActivity(entry: DelegationEntry): DelegationActivity {
|
|
|
1311
1374
|
)
|
|
1312
1375
|
return 'error'
|
|
1313
1376
|
if (entry.state === 'cancelled') return 'cancelled'
|
|
1314
|
-
if (entry.read) return 'reading'
|
|
1315
1377
|
if (entry.alias.startsWith('live-') && entry.taskID === undefined) return 'delegating'
|
|
1316
1378
|
return 'working'
|
|
1317
1379
|
}
|
|
@@ -1323,8 +1385,6 @@ export function delegationActivityLabel(entry: DelegationEntry): string {
|
|
|
1323
1385
|
return 'DELEGATING'
|
|
1324
1386
|
case 'working':
|
|
1325
1387
|
return 'WORKING'
|
|
1326
|
-
case 'reading':
|
|
1327
|
-
return 'READING RESULT'
|
|
1328
1388
|
case 'completed':
|
|
1329
1389
|
return entry.timedOut ? 'DONE (TIMED OUT)' : 'DONE'
|
|
1330
1390
|
case 'error':
|
|
@@ -1336,12 +1396,47 @@ export function delegationActivityLabel(entry: DelegationEntry): string {
|
|
|
1336
1396
|
|
|
1337
1397
|
const DELEGATION_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
1338
1398
|
|
|
1339
|
-
/** Return
|
|
1340
|
-
|
|
1341
|
-
|
|
1399
|
+
/** Return the spinner glyph for the given frame counter. The frame counter
|
|
1400
|
+
* is incremented every ~80ms by a dedicated animation timer, decoupled from
|
|
1401
|
+
* the data poll cycle. Pure — no side effects. */
|
|
1402
|
+
export function delegationSpinnerFrame(frame: number): string {
|
|
1403
|
+
const index = Math.abs(Math.floor(frame)) % DELEGATION_SPINNER_FRAMES.length
|
|
1342
1404
|
return DELEGATION_SPINNER_FRAMES[index] ?? DELEGATION_SPINNER_FRAMES[0]
|
|
1343
1405
|
}
|
|
1344
1406
|
|
|
1407
|
+
/** Every state the row knows how to draw: the delegation lifecycle states
|
|
1408
|
+
* derived from the children status + md reports (`completed`, `error`,
|
|
1409
|
+
* `cancelled`, `startup_failed`, `startup_unknown`) plus the TUI-only
|
|
1410
|
+
* states (`retry`, `stale-running`). Fase 1 deliberately omits speculative
|
|
1411
|
+
* blocked/paused/scheduled/skipped. There is no `pending` display state: a
|
|
1412
|
+
* pre-dispatch tool part maps to `running` in {@link reduceDelegationToolPart}. */
|
|
1413
|
+
export type DelegationDisplayState = DelegationEntry['state']
|
|
1414
|
+
|
|
1415
|
+
/** Semantic tone mapped to the TUI theme at the row ({@link DelegationRow}).
|
|
1416
|
+
* Kept separate + pure so the color channel is testable without booting the
|
|
1417
|
+
* renderer. Every display state resolves to one of the three status colors;
|
|
1418
|
+
* the row paints its whole content with it (see {@link DelegationRow}). */
|
|
1419
|
+
export type DelegationStateTone = 'warning' | 'error' | 'success'
|
|
1420
|
+
|
|
1421
|
+
/** Status → color mapping for the whole-row tone. Red = failure, green =
|
|
1422
|
+
* terminal, yellow = in flight. The glyph remains a redundant channel so the
|
|
1423
|
+
* state stays legible in monochrome. Display-only; no behavior change. */
|
|
1424
|
+
export function delegationStateTone(state: DelegationDisplayState): DelegationStateTone {
|
|
1425
|
+
switch (state) {
|
|
1426
|
+
case 'running':
|
|
1427
|
+
case 'retry':
|
|
1428
|
+
case 'startup_unknown':
|
|
1429
|
+
case 'stale-running':
|
|
1430
|
+
return 'warning'
|
|
1431
|
+
case 'error':
|
|
1432
|
+
case 'startup_failed':
|
|
1433
|
+
return 'error'
|
|
1434
|
+
case 'completed':
|
|
1435
|
+
case 'cancelled':
|
|
1436
|
+
return 'success'
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1345
1440
|
/* ─── Live tool activity per child session (beta.6) ────────────────────────
|
|
1346
1441
|
* The delegation panel's activity line ("↳ bash npm test") — what the agent
|
|
1347
1442
|
* is doing RIGHT NOW. Sourced from message.part.updated tool parts: every
|
|
@@ -1360,10 +1455,6 @@ const latestToolActivity = new Map<string, ToolActivity>()
|
|
|
1360
1455
|
const MAX_TOOL_ACTIVITY_ENTRIES = 200
|
|
1361
1456
|
const TOOL_ACTIVITY_TTL_MS = 5 * 60 * 1000
|
|
1362
1457
|
|
|
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
1458
|
/** First usable summary string from common tool input fields. */
|
|
1368
1459
|
function summarizeToolInput(input: Record<string, unknown> | undefined): string {
|
|
1369
1460
|
if (!input) return ''
|
|
@@ -1446,13 +1537,28 @@ export function mergeChildDelegationSources(
|
|
|
1446
1537
|
})
|
|
1447
1538
|
|
|
1448
1539
|
for (const liveEntry of live) {
|
|
1540
|
+
// A native task() live part never carries a report alias or taskID, so it
|
|
1541
|
+
// is exempt from the 30s alias-less prune and lives on its own longer TTL.
|
|
1542
|
+
const aliaslessTTL = liveEntry.tool === 'task' ? NATIVE_LIVE_ALIASLESS_TTL_MS : 30_000
|
|
1449
1543
|
if (
|
|
1450
1544
|
liveEntry.alias === null &&
|
|
1451
1545
|
liveEntry.taskID === null &&
|
|
1452
|
-
Date.now() - liveEntry.startedAt >
|
|
1546
|
+
Date.now() - liveEntry.startedAt > aliaslessTTL
|
|
1453
1547
|
)
|
|
1454
1548
|
continue
|
|
1455
1549
|
const incoming = toDelegationEntry(liveEntry)
|
|
1550
|
+
// A native task() live part never carries a report alias — it is a
|
|
1551
|
+
// children-only row by definition whether it lands on a new row or
|
|
1552
|
+
// upgrades an existing children-only one.
|
|
1553
|
+
const isNativeLive = liveEntry.tool === 'task' && liveEntry.alias === null
|
|
1554
|
+
if (isNativeLive) {
|
|
1555
|
+
// Native task() rows carry no report alias — keep the per-call
|
|
1556
|
+
// `live-<callID>` identity from toDelegationEntry (one row per native
|
|
1557
|
+
// call) with the children-only provenance; the row identity still falls
|
|
1558
|
+
// back to the agent via delegationRowIdentity. A taskID-known native
|
|
1559
|
+
// still merges into its child row through the byTask lookup below.
|
|
1560
|
+
incoming.source = 'children-only'
|
|
1561
|
+
}
|
|
1456
1562
|
const index =
|
|
1457
1563
|
(incoming.taskID !== undefined ? byTask.get(incoming.taskID) : undefined) ??
|
|
1458
1564
|
(incoming.alias !== ''
|
|
@@ -1471,12 +1577,12 @@ export function mergeChildDelegationSources(
|
|
|
1471
1577
|
if (existing === undefined) continue
|
|
1472
1578
|
// Any terminal state is authoritative — a child session that went idle
|
|
1473
1579
|
// correctly derives 'completed' via childStatusToState before the MD
|
|
1474
|
-
// report is written. The live tool-part channel stays 'running'
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1580
|
+
// report is written. The live tool-part channel stays 'running' for the
|
|
1581
|
+
// whole call, so allowing it to overwrite a terminal state would flip
|
|
1582
|
+
// completed → running (the bug this fixes).
|
|
1477
1583
|
//
|
|
1478
1584
|
// When the existing entry IS terminal, we still absorb live metadata
|
|
1479
|
-
// (alias, agent
|
|
1585
|
+
// (alias, agent) but preserve the terminal state.
|
|
1480
1586
|
if (existing.state !== 'running') {
|
|
1481
1587
|
if (liveEntry.alias !== null && existing.alias !== incoming.alias) {
|
|
1482
1588
|
existing.alias = incoming.alias
|
|
@@ -1484,9 +1590,6 @@ export function mergeChildDelegationSources(
|
|
|
1484
1590
|
if (incoming.agent !== 'agent' && existing.agent !== incoming.agent) {
|
|
1485
1591
|
existing.agent = incoming.agent
|
|
1486
1592
|
}
|
|
1487
|
-
if (incoming.read && !existing.read) {
|
|
1488
|
-
existing.read = true
|
|
1489
|
-
}
|
|
1490
1593
|
continue
|
|
1491
1594
|
}
|
|
1492
1595
|
result[index] = {
|
|
@@ -1499,8 +1602,9 @@ export function mergeChildDelegationSources(
|
|
|
1499
1602
|
state: incoming.state,
|
|
1500
1603
|
startedAt: Math.min(existing.startedAt, incoming.startedAt),
|
|
1501
1604
|
updatedAt: incoming.updatedAt,
|
|
1502
|
-
|
|
1503
|
-
|
|
1605
|
+
// A native task() live upgrade must not flip a children-only row to
|
|
1606
|
+
// 'live' (that would drop the children-only provenance).
|
|
1607
|
+
source: isNativeLive ? existing.source : 'live',
|
|
1504
1608
|
}
|
|
1505
1609
|
}
|
|
1506
1610
|
|
|
@@ -1509,12 +1613,11 @@ export function mergeChildDelegationSources(
|
|
|
1509
1613
|
}
|
|
1510
1614
|
|
|
1511
1615
|
/* ─── Live tool-call lifecycle (agent-sidebar pattern) ─────
|
|
1512
|
-
*
|
|
1616
|
+
* Refresh trigger for jobs born while opencode is open. The TUI event bus
|
|
1513
1617
|
* (api.event.on) delivers `message.part.updated` for every part change;
|
|
1514
|
-
* we only care about tool parts whose `tool` is
|
|
1515
|
-
*
|
|
1516
|
-
*
|
|
1517
|
-
* (blocking until terminal → closes the entry). Shape-adapted from the
|
|
1618
|
+
* we only care about tool parts whose `tool` is the native `task` subagent
|
|
1619
|
+
* tool (parentID === caller mechanism — its rows render `nat:`); the panel
|
|
1620
|
+
* rows themselves come from the children channel. Shape-adapted from the
|
|
1518
1621
|
* SDK v2 ToolPart but duck-typed so the pure helpers are testable without
|
|
1519
1622
|
* the SDK. */
|
|
1520
1623
|
|
|
@@ -1534,27 +1637,25 @@ export type DelegationToolPart = {
|
|
|
1534
1637
|
}
|
|
1535
1638
|
}
|
|
1536
1639
|
|
|
1537
|
-
/** One live delegation tracked in-memory, keyed by the
|
|
1640
|
+
/** One live delegation tracked in-memory, keyed by the task callID. */
|
|
1538
1641
|
export type LiveDelegationEntry = {
|
|
1539
|
-
/** Tool call id of the
|
|
1642
|
+
/** Tool call id of the task part (stable across events). */
|
|
1540
1643
|
callID: string
|
|
1541
1644
|
/** Part id (for message.part.removed cleanup). */
|
|
1542
1645
|
partID: string
|
|
1543
1646
|
/** Parent session the delegation was launched from. */
|
|
1544
1647
|
sessionID: string
|
|
1545
|
-
tool: '
|
|
1546
|
-
/** Agent name (from the
|
|
1648
|
+
tool: 'task'
|
|
1649
|
+
/** Agent name (from the task input args). */
|
|
1547
1650
|
agent: string
|
|
1548
1651
|
description: string
|
|
1549
|
-
/** Known
|
|
1652
|
+
/** Known once the task completes and its output carries a marker. */
|
|
1550
1653
|
alias: string | null
|
|
1551
|
-
/** Child session id (parsed from the
|
|
1654
|
+
/** Child session id (parsed from the task output, when present). */
|
|
1552
1655
|
taskID: string | null
|
|
1553
1656
|
state: 'running' | 'completed' | 'error' | 'cancelled'
|
|
1554
1657
|
startedAt: number
|
|
1555
1658
|
updatedAt: number | null
|
|
1556
|
-
/** True once a pantheon_delegation_read for this job has been observed. */
|
|
1557
|
-
read: boolean
|
|
1558
1659
|
}
|
|
1559
1660
|
|
|
1560
1661
|
/** Result of parsing one tool part into lifecycle-relevant fields. */
|
|
@@ -1562,9 +1663,9 @@ export type ParsedDelegationToolPart = {
|
|
|
1562
1663
|
callID: string
|
|
1563
1664
|
partID: string
|
|
1564
1665
|
sessionID: string
|
|
1565
|
-
tool: '
|
|
1566
|
-
/**
|
|
1567
|
-
agent: string
|
|
1666
|
+
tool: 'task'
|
|
1667
|
+
/** Agent name from the task input args ('agent' fallback when absent). */
|
|
1668
|
+
agent: string
|
|
1568
1669
|
description: string
|
|
1569
1670
|
status: 'pending' | 'running' | 'completed' | 'error'
|
|
1570
1671
|
alias: string | null
|
|
@@ -1573,29 +1674,21 @@ export type ParsedDelegationToolPart = {
|
|
|
1573
1674
|
endAt: number | null
|
|
1574
1675
|
}
|
|
1575
1676
|
|
|
1576
|
-
/** Alias in the
|
|
1677
|
+
/** Alias in the task output: "Delegated to apollo: [apo-1] (task …)". */
|
|
1577
1678
|
const DELEGATE_ALIAS_PATTERN = /\[([a-z]{2,8}-\d+)\]/i
|
|
1578
|
-
/** Child task id in the
|
|
1679
|
+
/** Child task id in the task output: "(task ses_child_9)". */
|
|
1579
1680
|
const DELEGATE_TASKID_PATTERN = /\(task\s+([a-z0-9_]+)\)/i
|
|
1580
|
-
/** Plain alias, as passed to pantheon_delegation_read input.id: "apo-1". */
|
|
1581
|
-
const READ_ALIAS_PATTERN = /^[a-z]{2,8}-\d+$/i
|
|
1582
1681
|
|
|
1583
1682
|
/** Extract the tool name + args from a `message.part.updated` part and
|
|
1584
1683
|
* reduce it to what the panel needs. Returns null for anything that is
|
|
1585
|
-
* not
|
|
1586
|
-
*
|
|
1587
|
-
* or is missing its callID. */
|
|
1684
|
+
* not the native `task` subagent tool (parentID === caller mechanism —
|
|
1685
|
+
* its children render `nat:`), or is missing its callID. */
|
|
1588
1686
|
export function parseDelegationToolPart(
|
|
1589
1687
|
part: DelegationToolPart,
|
|
1590
1688
|
now = Date.now(),
|
|
1591
1689
|
): ParsedDelegationToolPart | null {
|
|
1592
1690
|
if (part.type !== 'tool') return null
|
|
1593
|
-
if (
|
|
1594
|
-
part.tool !== 'pantheon_delegate' &&
|
|
1595
|
-
part.tool !== 'pantheon_delegation_read' &&
|
|
1596
|
-
part.tool !== 'task'
|
|
1597
|
-
)
|
|
1598
|
-
return null
|
|
1691
|
+
if (part.tool !== 'task') return null
|
|
1599
1692
|
const callID = part.callID
|
|
1600
1693
|
if (callID === undefined || callID === '') return null
|
|
1601
1694
|
const sessionID = part.sessionID ?? ''
|
|
@@ -1607,29 +1700,6 @@ export function parseDelegationToolPart(
|
|
|
1607
1700
|
const startedAt = state.time?.start ?? now
|
|
1608
1701
|
const endAt = state.time?.end ?? null
|
|
1609
1702
|
|
|
1610
|
-
if (part.tool === 'pantheon_delegation_read') {
|
|
1611
|
-
const target = typeof input.id === 'string' ? input.id : null
|
|
1612
|
-
let alias: string | null = null
|
|
1613
|
-
let taskID: string | null = null
|
|
1614
|
-
if (target !== null) {
|
|
1615
|
-
if (READ_ALIAS_PATTERN.test(target)) alias = target
|
|
1616
|
-
else if (target.startsWith('ses_')) taskID = target // raw child session id
|
|
1617
|
-
}
|
|
1618
|
-
return {
|
|
1619
|
-
callID,
|
|
1620
|
-
partID: part.id ?? '',
|
|
1621
|
-
sessionID,
|
|
1622
|
-
tool: 'pantheon_delegation_read',
|
|
1623
|
-
agent: null,
|
|
1624
|
-
description: '',
|
|
1625
|
-
status,
|
|
1626
|
-
alias,
|
|
1627
|
-
taskID,
|
|
1628
|
-
startedAt,
|
|
1629
|
-
endAt,
|
|
1630
|
-
}
|
|
1631
|
-
}
|
|
1632
|
-
|
|
1633
1703
|
const agent =
|
|
1634
1704
|
typeof input.agent === 'string'
|
|
1635
1705
|
? input.agent
|
|
@@ -1642,10 +1712,10 @@ export function parseDelegationToolPart(
|
|
|
1642
1712
|
: typeof input.prompt === 'string'
|
|
1643
1713
|
? (input.prompt as string).slice(0, 120)
|
|
1644
1714
|
: ''
|
|
1645
|
-
// The alias/taskID only exist once the
|
|
1646
|
-
// returned in its output); a running/pending part has neither.
|
|
1647
|
-
// `task`
|
|
1648
|
-
// the child session id
|
|
1715
|
+
// The alias/taskID only exist once the task COMPLETES (they are
|
|
1716
|
+
// returned in its output); a running/pending part has neither. A native
|
|
1717
|
+
// `task` part never carries a report alias — the children channel supplies
|
|
1718
|
+
// the child session id and the row's identity.
|
|
1649
1719
|
let alias: string | null = null
|
|
1650
1720
|
let taskID: string | null = null
|
|
1651
1721
|
if (status === 'completed') {
|
|
@@ -1657,7 +1727,7 @@ export function parseDelegationToolPart(
|
|
|
1657
1727
|
callID,
|
|
1658
1728
|
partID: part.id ?? '',
|
|
1659
1729
|
sessionID,
|
|
1660
|
-
tool:
|
|
1730
|
+
tool: 'task',
|
|
1661
1731
|
agent,
|
|
1662
1732
|
description,
|
|
1663
1733
|
status,
|
|
@@ -1668,20 +1738,6 @@ export function parseDelegationToolPart(
|
|
|
1668
1738
|
}
|
|
1669
1739
|
}
|
|
1670
1740
|
|
|
1671
|
-
/** Find a live entry by alias or taskID (read parts resolve by id). */
|
|
1672
|
-
function findLiveByTarget(
|
|
1673
|
-
map: Map<string, LiveDelegationEntry>,
|
|
1674
|
-
alias: string | null,
|
|
1675
|
-
taskID: string | null,
|
|
1676
|
-
): LiveDelegationEntry | undefined {
|
|
1677
|
-
if (alias === null && taskID === null) return undefined
|
|
1678
|
-
for (const entry of map.values()) {
|
|
1679
|
-
if (alias !== null && entry.alias === alias) return entry
|
|
1680
|
-
if (taskID !== null && entry.taskID === taskID) return entry
|
|
1681
|
-
}
|
|
1682
|
-
return undefined
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
1741
|
/** Apply one tool part to the live map. Returns true when the map changed.
|
|
1686
1742
|
* Pure w.r.t. I/O — only mutates `map`. */
|
|
1687
1743
|
export function reduceDelegationToolPart(
|
|
@@ -1692,34 +1748,11 @@ export function reduceDelegationToolPart(
|
|
|
1692
1748
|
const parsed = parseDelegationToolPart(part, now)
|
|
1693
1749
|
if (parsed === null) return false
|
|
1694
1750
|
|
|
1695
|
-
//
|
|
1696
|
-
//
|
|
1697
|
-
//
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
if (target === undefined) return false
|
|
1701
|
-
let changed = false
|
|
1702
|
-
if (!target.read) {
|
|
1703
|
-
target.read = true
|
|
1704
|
-
changed = true
|
|
1705
|
-
}
|
|
1706
|
-
if (parsed.status === 'completed' && target.state === 'running') {
|
|
1707
|
-
target.state = 'completed'
|
|
1708
|
-
target.updatedAt = parsed.endAt ?? now
|
|
1709
|
-
changed = true
|
|
1710
|
-
} else if (parsed.status === 'error' && target.state === 'running') {
|
|
1711
|
-
target.state = 'error'
|
|
1712
|
-
target.updatedAt = parsed.endAt ?? now
|
|
1713
|
-
changed = true
|
|
1714
|
-
}
|
|
1715
|
-
return changed
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
|
-
// Delegate tool (pantheon_delegate AND native task): pending/running →
|
|
1719
|
-
// job launched (running). A COMPLETED delegate tool only means the job was
|
|
1720
|
-
// registered — it stays running and we pick up alias + taskID from the
|
|
1721
|
-
// output. Native `task` parts never yield a board alias, so their rows
|
|
1722
|
-
// stay `[native]` until the children channel resolves them.
|
|
1751
|
+
// Task tool: pending/running → job launched (running). A COMPLETED task
|
|
1752
|
+
// only means the call returned — the child session keeps running, and we
|
|
1753
|
+
// pick up alias + taskID from the output when present. Native `task` parts
|
|
1754
|
+
// never yield a report alias, so their rows stay identity-less until the
|
|
1755
|
+
// children channel resolves them.
|
|
1723
1756
|
const existing = map.get(parsed.callID)
|
|
1724
1757
|
if (parsed.status === 'error') {
|
|
1725
1758
|
if (existing !== undefined && existing.state === 'error' && existing.updatedAt === parsed.endAt)
|
|
@@ -1729,14 +1762,13 @@ export function reduceDelegationToolPart(
|
|
|
1729
1762
|
partID: parsed.partID,
|
|
1730
1763
|
sessionID: parsed.sessionID,
|
|
1731
1764
|
tool: parsed.tool,
|
|
1732
|
-
agent: parsed.agent
|
|
1765
|
+
agent: parsed.agent,
|
|
1733
1766
|
description: parsed.description,
|
|
1734
1767
|
alias: existing?.alias ?? null,
|
|
1735
1768
|
taskID: existing?.taskID ?? null,
|
|
1736
1769
|
state: 'error',
|
|
1737
1770
|
startedAt: existing?.startedAt ?? parsed.startedAt,
|
|
1738
1771
|
updatedAt: parsed.endAt ?? now,
|
|
1739
|
-
read: existing?.read ?? false,
|
|
1740
1772
|
})
|
|
1741
1773
|
return true
|
|
1742
1774
|
}
|
|
@@ -1746,14 +1778,13 @@ export function reduceDelegationToolPart(
|
|
|
1746
1778
|
partID: parsed.partID,
|
|
1747
1779
|
sessionID: parsed.sessionID,
|
|
1748
1780
|
tool: parsed.tool,
|
|
1749
|
-
agent: parsed.agent
|
|
1781
|
+
agent: parsed.agent,
|
|
1750
1782
|
description: parsed.description,
|
|
1751
1783
|
alias: parsed.alias,
|
|
1752
1784
|
taskID: parsed.taskID,
|
|
1753
1785
|
state: 'running',
|
|
1754
1786
|
startedAt: parsed.startedAt,
|
|
1755
1787
|
updatedAt: null,
|
|
1756
|
-
read: false,
|
|
1757
1788
|
})
|
|
1758
1789
|
return true
|
|
1759
1790
|
}
|
|
@@ -1798,14 +1829,14 @@ export function removeDelegationEntry(
|
|
|
1798
1829
|
return false
|
|
1799
1830
|
}
|
|
1800
1831
|
|
|
1801
|
-
/** Collect
|
|
1832
|
+
/** Collect native task tool parts from a session's messages.
|
|
1802
1833
|
* Messages may carry their parts inline (duck-typed `msg.parts`); when
|
|
1803
1834
|
* they don't, the optional `getParts(messageID)` callback is used (the TUI
|
|
1804
1835
|
* SDK exposes `api.state.part(messageID)`). The native `task` tool spawns a
|
|
1805
|
-
* child session with parentID = caller
|
|
1806
|
-
*
|
|
1807
|
-
*
|
|
1808
|
-
*
|
|
1836
|
+
* child session with parentID = caller, so its parts feed the live-map as
|
|
1837
|
+
* the refresh signal (rows come from the children channel). Pure w.r.t.
|
|
1838
|
+
* I/O — used by the mount re-scan to re-seed the live map after
|
|
1839
|
+
* compaction/attach. */
|
|
1809
1840
|
export function collectDelegationToolParts(
|
|
1810
1841
|
messages: readonly { id?: string; parts?: unknown[] }[] | undefined,
|
|
1811
1842
|
getParts?: (messageID: string) => readonly unknown[] | undefined,
|
|
@@ -1821,12 +1852,7 @@ export function collectDelegationToolParts(
|
|
|
1821
1852
|
if (!parts) continue
|
|
1822
1853
|
for (const raw of parts) {
|
|
1823
1854
|
const part = raw as DelegationToolPart
|
|
1824
|
-
if (
|
|
1825
|
-
part?.type === 'tool' &&
|
|
1826
|
-
(part.tool === 'pantheon_delegate' ||
|
|
1827
|
-
part.tool === 'pantheon_delegation_read' ||
|
|
1828
|
-
part.tool === 'task')
|
|
1829
|
-
) {
|
|
1855
|
+
if (part?.type === 'tool' && part.tool === 'task') {
|
|
1830
1856
|
out.push(part)
|
|
1831
1857
|
}
|
|
1832
1858
|
}
|
|
@@ -1851,7 +1877,7 @@ export function seedLiveDelegationMap(
|
|
|
1851
1877
|
}
|
|
1852
1878
|
|
|
1853
1879
|
/** Convert a live entry into the shared display shape. Alias falls back to
|
|
1854
|
-
* a `live-<callID>` prefix while the
|
|
1880
|
+
* a `live-<callID>` prefix while the task call has not completed yet. */
|
|
1855
1881
|
export function toDelegationEntry(live: LiveDelegationEntry): DelegationEntry {
|
|
1856
1882
|
return {
|
|
1857
1883
|
alias: live.alias ?? `live-${live.callID.slice(0, 8)}`,
|
|
@@ -1863,7 +1889,6 @@ export function toDelegationEntry(live: LiveDelegationEntry): DelegationEntry {
|
|
|
1863
1889
|
updatedAt: live.updatedAt,
|
|
1864
1890
|
timedOut: false,
|
|
1865
1891
|
description: live.description,
|
|
1866
|
-
read: live.read,
|
|
1867
1892
|
source: 'live',
|
|
1868
1893
|
}
|
|
1869
1894
|
}
|
|
@@ -1902,15 +1927,39 @@ export function mergeDelegationSources(
|
|
|
1902
1927
|
return all
|
|
1903
1928
|
}
|
|
1904
1929
|
|
|
1930
|
+
/** Scope a fully-merged display list to the ACTIVE session only.
|
|
1931
|
+
*
|
|
1932
|
+
* The panel is session-scoped: rows from other sessions (md history under
|
|
1933
|
+
* `.pantheon/delegations/<other-session>/`) and rows with no
|
|
1934
|
+
* attributable session (empty sessionID) are DROPPED. The previous
|
|
1935
|
+
* cross-session behavior rendered those rows and clicking them led to
|
|
1936
|
+
* "Session not found" — there is no cross-session channel anymore.
|
|
1937
|
+
*
|
|
1938
|
+
* The md channel still feeds the merge UNFILTERED so it can ENRICH an
|
|
1939
|
+
* active-session row with state/alias/agent (dedup by taskID), but it can
|
|
1940
|
+
* never introduce a row for another session: this filter is applied
|
|
1941
|
+
* ONCE, after every merge (children + live + md).
|
|
1942
|
+
*
|
|
1943
|
+
* Returns [] when no active session resolves (null/placeholder) — there is no
|
|
1944
|
+
* scope to show. Native children always carry the active session id (stamped
|
|
1945
|
+
* by `childrenToDelegationEntries`), so they survive the filter. Pure. */
|
|
1946
|
+
export function filterDelegationsToSession(
|
|
1947
|
+
entries: readonly DelegationEntry[],
|
|
1948
|
+
activeSessionID: string | null | undefined,
|
|
1949
|
+
): DelegationEntry[] {
|
|
1950
|
+
if (!isValidSessionId(activeSessionID)) return []
|
|
1951
|
+
return entries.filter((entry) => entry.sessionID === activeSessionID)
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1905
1954
|
/* ─── Children channel (primary source, delegations-sidebar pattern) ────
|
|
1906
1955
|
* `api.client.session.children` returns the child sessions of the current
|
|
1907
|
-
* session — every
|
|
1956
|
+
* session — every native task() call spawns one (parentID = caller). The
|
|
1908
1957
|
* pure helpers here turn those children + the md reports into the display
|
|
1909
1958
|
* list, and navigate to a child session on click. */
|
|
1910
1959
|
|
|
1911
1960
|
/* ─── Current-session resolution + path guard (placeholder regression) ──
|
|
1912
1961
|
* The sidebar forwarded `props.session_id` verbatim into
|
|
1913
|
-
* `session.children({
|
|
1962
|
+
* `session.children({ sessionID })`. When the TUI runtime renders the
|
|
1914
1963
|
* sidebar without a focused session it leaves the route template
|
|
1915
1964
|
* UNSUBSTITUTED — the literal "{sessionID}" string — which opencode's server
|
|
1916
1965
|
* rejected every poll (SchemaError: Expected a string starting with "ses",
|
|
@@ -1974,33 +2023,38 @@ export function resolveCurrentSessionID(sources: TuiSessionSources): string | nu
|
|
|
1974
2023
|
}
|
|
1975
2024
|
|
|
1976
2025
|
/** 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
|
-
|
|
2026
|
+
* Returns the v2 SDK parameter shape `{ sessionID }` ONLY for a
|
|
2027
|
+
* server-valid session id; returns null for anything else (placeholder,
|
|
2028
|
+
* empty, foreign id) so the caller skips the call entirely instead of
|
|
2029
|
+
* sending an unsubstituted placeholder (the "%7BsessionID%7D" regression).
|
|
2030
|
+
* The TUI client is `@opencode-ai/sdk/v2`, whose session methods take a
|
|
2031
|
+
* FLAT parameter object (`{ sessionID }`), NOT the v1 `{ path: { id } }`
|
|
2032
|
+
* envelope — passing the v1 shape left the v2 `{sessionID}` URL template
|
|
2033
|
+
* unsubstituted (`/session/%7BsessionID%7D/children`). Every session-API
|
|
2034
|
+
* call site MUST go through this function (enforced by the source-scan
|
|
2035
|
+
* test in tests/pantheon/tui-delegations.test.ts). */
|
|
2036
|
+
export function safeSessionPath(id: unknown): { sessionID: string } | null {
|
|
1984
2037
|
if (!isValidSessionId(id)) return null
|
|
1985
|
-
return {
|
|
2038
|
+
return { sessionID: id }
|
|
1986
2039
|
}
|
|
1987
2040
|
|
|
1988
|
-
/** Build the `session.children`
|
|
2041
|
+
/** Build the `session.children` parameters ONLY from a validated session id.
|
|
1989
2042
|
* 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
|
-
|
|
2043
|
+
* the v2 SDK shape `{ sessionID }`; null for null/invalid ids so the caller
|
|
2044
|
+
* skips the fetch instead of sending an unsubstituted placeholder (the
|
|
2045
|
+
* "%7BsessionID%7D" regression). */
|
|
2046
|
+
export function buildChildrenPath(id: string | null | undefined): { sessionID: string } | null {
|
|
1993
2047
|
return safeSessionPath(id)
|
|
1994
2048
|
}
|
|
1995
2049
|
|
|
1996
2050
|
/** Duck-typed subset of a child Session (+ its live status type). */
|
|
1997
2051
|
export type ChildDelegationLike = {
|
|
1998
|
-
/** Child session id (=
|
|
2052
|
+
/** Child session id (= task id). */
|
|
1999
2053
|
id: string
|
|
2000
2054
|
/** Session title — the delegate's description or prompt prefix. */
|
|
2001
2055
|
title?: string
|
|
2002
2056
|
/** Agent name when the child session carries one (duck-typed; native
|
|
2003
|
-
* task() children may expose it,
|
|
2057
|
+
* task() children may expose it, md reports always do). */
|
|
2004
2058
|
agent?: string
|
|
2005
2059
|
/** Status type from api.state.session.status: 'busy' | 'retry' | 'idle',
|
|
2006
2060
|
* or undefined when the status API is unavailable. */
|
|
@@ -2011,29 +2065,232 @@ export type ChildDelegationLike = {
|
|
|
2011
2065
|
}
|
|
2012
2066
|
}
|
|
2013
2067
|
|
|
2014
|
-
/** Map a child status type to a display state.
|
|
2015
|
-
*
|
|
2016
|
-
*
|
|
2017
|
-
*
|
|
2018
|
-
|
|
2019
|
-
|
|
2068
|
+
/** Map a child status type to a display state.
|
|
2069
|
+
*
|
|
2070
|
+
* Only `busy`/`retry` are EXPLICIT live states. `idle` is terminal and so is
|
|
2071
|
+
* any other/unknown status — a status the panel does not recognise is not
|
|
2072
|
+
* evidence of activity. An ABSENT status is the historical case: the status
|
|
2073
|
+
* map (`api.state.session.status`) contains only currently-alive sessions, so
|
|
2074
|
+
* every finished child of a long session is missing from it. Defaulting that
|
|
2075
|
+
* to running made the whole session history render as "active" (the
|
|
2076
|
+
* "197 children, all active" regression), so an absent status is terminal
|
|
2077
|
+
* (done) UNLESS the child was active within `graceMs` — a just-spawned child
|
|
2078
|
+
* that the status API has not registered yet must still render as running.
|
|
2079
|
+
*
|
|
2080
|
+
* @param status the `api.state.session.status(id)?.type`, or undefined
|
|
2081
|
+
* @param time the child session time fields (`created`/`updated`)
|
|
2082
|
+
* @param now current wall-clock ms (injectable for tests)
|
|
2083
|
+
* @param graceMs recency window for the absent-status running assumption
|
|
2084
|
+
* Pure — no I/O. */
|
|
2085
|
+
export function childStatusToState(
|
|
2086
|
+
status: string | undefined,
|
|
2087
|
+
time?: { created?: number; updated?: number },
|
|
2088
|
+
now = Date.now(),
|
|
2089
|
+
graceMs = DELEGATION_CHILD_STATUS_GRACE_MS,
|
|
2090
|
+
): 'running' | 'completed' | 'retry' {
|
|
2020
2091
|
if (status === 'retry') return 'retry'
|
|
2021
|
-
|
|
2092
|
+
if (status === 'busy') {
|
|
2093
|
+
// A stale "busy" in the status map means the child is likely dead, not
|
|
2094
|
+
// running. The opencode status map may retain entries for sessions that
|
|
2095
|
+
// have already terminated. Cross-check against the child's own activity
|
|
2096
|
+
// timestamps: if the last activity is older than the stale-running
|
|
2097
|
+
// threshold, treat the child as completed rather than propagating the
|
|
2098
|
+
// stale busy status as "running" (the "213 children, all active" bug).
|
|
2099
|
+
if (time) {
|
|
2100
|
+
const lastActivity = Math.max(time.updated ?? 0, time.created ?? 0)
|
|
2101
|
+
if (lastActivity > 0 && now - lastActivity > STALE_RUNNING_THRESHOLD_MS) return 'completed'
|
|
2102
|
+
}
|
|
2103
|
+
return 'running'
|
|
2104
|
+
}
|
|
2105
|
+
if (status !== undefined) return 'completed' // idle + any other explicit terminal
|
|
2106
|
+
// Absent status: running only while the child's last activity is fresh.
|
|
2107
|
+
const lastActivity = Math.max(time?.updated ?? 0, time?.created ?? 0)
|
|
2108
|
+
if (lastActivity > 0 && now - lastActivity < graceMs) return 'running'
|
|
2109
|
+
return 'completed'
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
/** Status-only row model: ONE glyph + ONE identity per row.
|
|
2113
|
+
*
|
|
2114
|
+
* A row is
|
|
2115
|
+
* `{glyph} {alias:7} {elapsed:>5}` plus a muted description line. The short
|
|
2116
|
+
* alias (`apo-1`) is the single identity; rows without a report alias show
|
|
2117
|
+
* the agent instead (never both). */
|
|
2118
|
+
export type DelegationRowStatus = 'active' | 'done' | 'failed' | 'retry'
|
|
2119
|
+
|
|
2120
|
+
/** Map every FSM/display state to one of the 4 row kinds. reconciled never
|
|
2121
|
+
* reaches the panel (the md parser drops it) but reads as done;
|
|
2122
|
+
* cancelled reads as done; startup_failed reads as failed while
|
|
2123
|
+
* startup_unknown (no error known) reads as retry. Pure. */
|
|
2124
|
+
export function delegationRowStatus(state: DelegationDisplayState): DelegationRowStatus {
|
|
2125
|
+
switch (state) {
|
|
2126
|
+
case 'running':
|
|
2127
|
+
case 'stale-running':
|
|
2128
|
+
return 'active'
|
|
2129
|
+
case 'retry':
|
|
2130
|
+
case 'startup_unknown':
|
|
2131
|
+
return 'retry'
|
|
2132
|
+
case 'completed':
|
|
2133
|
+
case 'cancelled':
|
|
2134
|
+
return 'done'
|
|
2135
|
+
case 'error':
|
|
2136
|
+
case 'startup_failed':
|
|
2137
|
+
return 'failed'
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
/** The only 4 glyphs a row may show: animated active, done, failed, retry.
|
|
2142
|
+
* Shape is redundant with color (never the only signal). Pure. */
|
|
2143
|
+
export const DELEGATION_ROW_GLYPHS: Readonly<Record<DelegationRowStatus, string>> = {
|
|
2144
|
+
active: '⠋', // base frame of the animated spinner
|
|
2145
|
+
done: '✓',
|
|
2146
|
+
failed: '✕',
|
|
2147
|
+
retry: '⟳',
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
export function delegationRowGlyph(status: DelegationRowStatus): string {
|
|
2151
|
+
return DELEGATION_ROW_GLYPHS[status]
|
|
2022
2152
|
}
|
|
2023
2153
|
|
|
2024
|
-
/** Row
|
|
2025
|
-
*
|
|
2026
|
-
*
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
return entry.source === 'children-only' ? '[native]' : `[pantheon:${entry.alias}]`
|
|
2154
|
+
/** Row marker (`<glyph> `) — the single state channel. `active` animates
|
|
2155
|
+
* through the spinner for the given frame counter (incremented every ~80ms);
|
|
2156
|
+
* every other kind is static. Pure. */
|
|
2157
|
+
export function delegationRowMarker(state: DelegationDisplayState, frame = 0): string {
|
|
2158
|
+
const status = delegationRowStatus(state)
|
|
2159
|
+
const glyph = status === 'active' ? delegationSpinnerFrame(frame) : delegationRowGlyph(status)
|
|
2160
|
+
return `${glyph} `
|
|
2032
2161
|
}
|
|
2033
2162
|
|
|
2034
|
-
/**
|
|
2035
|
-
*
|
|
2036
|
-
*
|
|
2163
|
+
/** ONE identity per row: the short report alias (`apo-1`); a row without a
|
|
2164
|
+
* report alias (native task() child `native-task` / `native-<id>`, or an
|
|
2165
|
+
* alias-less native live row `live-<callID>` kept as children-only) shows
|
|
2166
|
+
* the agent instead — never alias + agent stacked. Pure. */
|
|
2167
|
+
export function delegationRowIdentity(entry: DelegationEntry): string {
|
|
2168
|
+
if (entry.agent === '') return entry.alias
|
|
2169
|
+
if (entry.alias === 'native-task' || entry.alias.startsWith('native-')) return entry.agent
|
|
2170
|
+
if (entry.alias.startsWith('live-') && entry.source === 'children-only') return entry.agent
|
|
2171
|
+
return entry.alias
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
/** Fixed alias cell, pad-right + hard-truncate to `width` (default 7) so
|
|
2175
|
+
* identities line up across rows. Pure. */
|
|
2176
|
+
export const DELEGATION_ALIAS_WIDTH = 7
|
|
2177
|
+
|
|
2178
|
+
export function formatDelegationAlias(identity: string, width = DELEGATION_ALIAS_WIDTH): string {
|
|
2179
|
+
if (identity.length >= width) return identity.slice(0, width)
|
|
2180
|
+
return `${identity}${' '.repeat(width - identity.length)}`
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
/** Max description width on the row detail line — the old 180-char slice
|
|
2184
|
+
* wrapped the sidebar; 44 keeps one readable line. */
|
|
2185
|
+
export const DELEGATION_DESCRIPTION_MAX = 44
|
|
2186
|
+
|
|
2187
|
+
/** Lazily-built grapheme segmenter. `Intl.Segmenter` keeps ZWJ emoji families
|
|
2188
|
+
* (and skin-tone/variation sequences) intact; `Array.from` (code points) is
|
|
2189
|
+
* the fallback for runtimes without it. */
|
|
2190
|
+
let graphemeSegmenter: Intl.Segmenter | undefined
|
|
2191
|
+
|
|
2192
|
+
/** Split `text` into display graphemes so truncation never cuts a multi-unit
|
|
2193
|
+
* emoji in half (surrogate pair → mojibake). */
|
|
2194
|
+
function splitGraphemes(text: string): string[] {
|
|
2195
|
+
if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
|
|
2196
|
+
graphemeSegmenter ??= new Intl.Segmenter('pt', { granularity: 'grapheme' })
|
|
2197
|
+
return Array.from(graphemeSegmenter.segment(text), (s) => s.segment)
|
|
2198
|
+
}
|
|
2199
|
+
return Array.from(text)
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
/** Truncate a description to `max` graphemes appending `…` when cut. Exact
|
|
2203
|
+
* `max`-length text is left untouched. Grapheme-granular, so an emoji made of
|
|
2204
|
+
* several code points is never split into mojibake at the boundary. Pure. */
|
|
2205
|
+
export function truncateDelegationDescription(
|
|
2206
|
+
text: string,
|
|
2207
|
+
max = DELEGATION_DESCRIPTION_MAX,
|
|
2208
|
+
): string {
|
|
2209
|
+
const graphemes = splitGraphemes(text)
|
|
2210
|
+
if (graphemes.length <= max) return text
|
|
2211
|
+
return `${graphemes.slice(0, Math.max(0, max - 1)).join('')}\u2026`
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
/** Fixed elapsed-time box: right-aligned to `width` (default 5) so elapsed
|
|
2215
|
+
* values line up across rows, e.g. ` 12s`. A label that already fills
|
|
2216
|
+
* the box is returned untouched. Pure. */
|
|
2217
|
+
export const DELEGATION_ELAPSED_WIDTH = 5
|
|
2218
|
+
|
|
2219
|
+
export function formatDelegationElapsed(
|
|
2220
|
+
entry: DelegationEntry,
|
|
2221
|
+
now: number,
|
|
2222
|
+
width = DELEGATION_ELAPSED_WIDTH,
|
|
2223
|
+
): string {
|
|
2224
|
+
const label = delegationElapsed(entry, now)
|
|
2225
|
+
if (label.length >= width) return label
|
|
2226
|
+
return `${' '.repeat(width - label.length)}${label}`
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
/** Colored left half of a row line: `<marker><alias:7> ` — the state glyph
|
|
2230
|
+
* plus the single identity, padded so the muted elapsed column aligns. The
|
|
2231
|
+
* row renders this colored lead separately from the muted elapsed tail. Pure. */
|
|
2232
|
+
export function formatDelegationRowLead(entry: DelegationEntry, marker: string): string {
|
|
2233
|
+
return `${marker}${formatDelegationAlias(delegationRowIdentity(entry))} `
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
// Tree connectors (`isNestedDelegation` / `delegationTreePrefix`) were removed
|
|
2237
|
+
// as dead code: `session.children` returns direct children only, so no row is
|
|
2238
|
+
// ever actually nested and the connectors were never populated in production.
|
|
2239
|
+
// BACKLOG: produce real grandchild rows first, then reintroduce them.
|
|
2240
|
+
|
|
2241
|
+
/** Visible-row ceiling for the panel: the remainder collapses into a single
|
|
2242
|
+
* "… +N more" line. Live rows render first, so a running job is never hidden
|
|
2243
|
+
* by the cap. */
|
|
2244
|
+
export const DELEGATION_VISIBLE_CEILING = 8
|
|
2245
|
+
|
|
2246
|
+
/** Header summary: `(N active · M done)` plus `· K failed` only when K > 0.
|
|
2247
|
+
* Active counts running/retry AND display-only stale-running (a stale row is
|
|
2248
|
+
* still a live job — it must never read as done); failed counts
|
|
2249
|
+
* error/startup_failed; cancelled reads as done. Pure. */
|
|
2250
|
+
export function formatDelegationHeader(entries: readonly DelegationEntry[]): string {
|
|
2251
|
+
let active = 0
|
|
2252
|
+
let done = 0
|
|
2253
|
+
let failed = 0
|
|
2254
|
+
for (const e of entries) {
|
|
2255
|
+
switch (delegationRowStatus(e.state)) {
|
|
2256
|
+
case 'active':
|
|
2257
|
+
case 'retry':
|
|
2258
|
+
active++
|
|
2259
|
+
break
|
|
2260
|
+
case 'failed':
|
|
2261
|
+
failed++
|
|
2262
|
+
break
|
|
2263
|
+
case 'done':
|
|
2264
|
+
done++
|
|
2265
|
+
break
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
const tail = failed > 0 ? ` · ${failed} failed` : ''
|
|
2269
|
+
return `(${active} active · ${done} done${tail})`
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
/** Cap the panel: live rows first, then most-recent retained terminal rows, at
|
|
2273
|
+
* most `maxVisible` total. Hidden counts describe the retained render list,
|
|
2274
|
+
* not expired history. */
|
|
2275
|
+
export function ceilingDelegationList(
|
|
2276
|
+
all: readonly DelegationEntry[],
|
|
2277
|
+
maxVisible = DELEGATION_VISIBLE_CEILING,
|
|
2278
|
+
now = Date.now(),
|
|
2279
|
+
): { visible: DelegationEntry[]; hidden: number; hiddenActive: number; hiddenTerminal: number } {
|
|
2280
|
+
const { active, recent } = splitDelegationList(all, all.length, now)
|
|
2281
|
+
const visibleActive = active.slice(0, maxVisible)
|
|
2282
|
+
const visible = [
|
|
2283
|
+
...visibleActive,
|
|
2284
|
+
...recent.slice(0, Math.max(0, maxVisible - visibleActive.length)),
|
|
2285
|
+
]
|
|
2286
|
+
const hiddenActive = Math.max(0, active.length - visibleActive.length)
|
|
2287
|
+
const hiddenTerminal = Math.max(0, recent.length - (visible.length - visibleActive.length))
|
|
2288
|
+
return { visible, hidden: hiddenActive + hiddenTerminal, hiddenActive, hiddenTerminal }
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
/** Split a display list into native task() rows vs pantheon report rows.
|
|
2292
|
+
* Native = source 'children-only' (no delegate report); everything else counts
|
|
2293
|
+
* as pantheon. Pure — powers the hooks.log line. */
|
|
2037
2294
|
export function countDelegationSources(entries: readonly DelegationEntry[]): {
|
|
2038
2295
|
native: number
|
|
2039
2296
|
pantheon: number
|
|
@@ -2045,7 +2302,7 @@ export function countDelegationSources(entries: readonly DelegationEntry[]): {
|
|
|
2045
2302
|
}
|
|
2046
2303
|
|
|
2047
2304
|
/** Diagnostic hooks.log line for a panel re-fetch, with the children
|
|
2048
|
-
* breakdown (pantheon = children WITH a
|
|
2305
|
+
* breakdown (pantheon = children WITH a delegate report, native = children
|
|
2049
2306
|
* WITHOUT one). Pure — the View logs the returned string verbatim. */
|
|
2050
2307
|
export function formatPanelLogLine(
|
|
2051
2308
|
children: number,
|
|
@@ -2064,20 +2321,45 @@ export function formatPanelLogLine(
|
|
|
2064
2321
|
* report still renders: description from its title, agent from the child
|
|
2065
2322
|
* itself (fallback 'agent'), state derived from its status, startedAt from
|
|
2066
2323
|
* time.created. A report-less child is a NATIVE task() child (every
|
|
2067
|
-
* child of the current session —
|
|
2068
|
-
*
|
|
2069
|
-
*
|
|
2070
|
-
*
|
|
2071
|
-
* non-empty when the child carries no title.
|
|
2072
|
-
*
|
|
2324
|
+
* child of the current session — the native `task()` tool — carries
|
|
2325
|
+
* parentID = caller), so it gets source 'children-only', a
|
|
2326
|
+
* per-child alias 'native-<last4 of the child id>' (one identity per native
|
|
2327
|
+
* row) instead of a report alias. The 'task nativa' description fallback
|
|
2328
|
+
* keeps the row non-empty when the child carries no title.
|
|
2329
|
+
* A FRESH terminal md state wins over the derived state (a stale md report
|
|
2330
|
+
* from a previous incarnation never flips a live child — see
|
|
2331
|
+
* mdTerminalCoversChild); a running md defers to
|
|
2073
2332
|
* the child's live status. A running child is NEVER archived — it always
|
|
2074
2333
|
* lands in the active split (splitDelegationList active = running/retry).
|
|
2075
|
-
*
|
|
2334
|
+
* `parentSessionID` (the focused session id) is stamped on report-less
|
|
2335
|
+
* children so the row carries its origin session; omit it and they stay
|
|
2336
|
+
* unscoped ('').
|
|
2337
|
+
* Children outside {@link DELEGATION_CHILDREN_RECENCY_MS} are dropped before
|
|
2338
|
+
* they can enter the list (the per-session child list grows for the whole
|
|
2339
|
+
* session lifetime); the survivors are sorted running-first, most recent
|
|
2340
|
+
* first (compareDelegationEntries), so a ceiling hides the OLDEST rows.
|
|
2341
|
+
* Pure — no I/O. */
|
|
2342
|
+
/** Temporal guard for the children channel (same recycled-alias class as the
|
|
2343
|
+
* md merge): an md terminal report matched by taskID may still belong to
|
|
2344
|
+
* a PREVIOUS job incarnation. Accept it only when its Finalized timestamp
|
|
2345
|
+
* covers the child's latest activity, when either side has no timestamp to
|
|
2346
|
+
* compare, or when both startedAt agree on a single job incarnation.
|
|
2076
2347
|
* Pure — no I/O. */
|
|
2348
|
+
function mdTerminalCoversChild(md: DelegationEntry, child: ChildDelegationLike): boolean {
|
|
2349
|
+
const childUpdated = child.time?.updated
|
|
2350
|
+
if (md.updatedAt !== null && childUpdated !== undefined) {
|
|
2351
|
+
if (md.updatedAt >= childUpdated) return true
|
|
2352
|
+
// Stale Finalized — same incarnation only if both startedAt agree.
|
|
2353
|
+
return md.startedAt === child.time?.created
|
|
2354
|
+
}
|
|
2355
|
+
return true
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2077
2358
|
export function childrenToDelegationEntries(
|
|
2078
2359
|
children: readonly ChildDelegationLike[] | undefined,
|
|
2079
2360
|
md: readonly DelegationEntry[],
|
|
2080
2361
|
now = Date.now(),
|
|
2362
|
+
parentSessionID = '',
|
|
2081
2363
|
): DelegationEntry[] {
|
|
2082
2364
|
const byTaskID = new Map<string, DelegationEntry>()
|
|
2083
2365
|
for (const m of md) {
|
|
@@ -2089,13 +2371,29 @@ export function childrenToDelegationEntries(
|
|
|
2089
2371
|
if (child.id === '' || seen.has(child.id)) continue
|
|
2090
2372
|
seen.add(child.id)
|
|
2091
2373
|
const mdEntry = byTaskID.get(child.id)
|
|
2374
|
+
// Recency window (source-level): the children API returns EVERY child the
|
|
2375
|
+
// session ever spawned, so without this the whole session history enters
|
|
2376
|
+
// the list (the "197 children" panel). Effective recency = the latest of
|
|
2377
|
+
// the child's own activity and its matched report's Finalized timestamp;
|
|
2378
|
+
// a child with NO timestamp at all is kept (fail-open — it cannot be
|
|
2379
|
+
// judged). Oldest rows are the ones dropped.
|
|
2380
|
+
const lastActivity = Math.max(
|
|
2381
|
+
child.time?.updated ?? 0,
|
|
2382
|
+
child.time?.created ?? 0,
|
|
2383
|
+
mdEntry?.updatedAt ?? 0,
|
|
2384
|
+
)
|
|
2385
|
+
if (lastActivity > 0 && now - lastActivity > DELEGATION_CHILDREN_RECENCY_MS) continue
|
|
2092
2386
|
const state =
|
|
2093
|
-
mdEntry !== undefined && mdEntry.state !== 'running'
|
|
2387
|
+
mdEntry !== undefined && mdEntry.state !== 'running' && mdTerminalCoversChild(mdEntry, child)
|
|
2094
2388
|
? mdEntry.state
|
|
2095
|
-
: childStatusToState(child.status)
|
|
2389
|
+
: childStatusToState(child.status, child.time, now)
|
|
2096
2390
|
out.push({
|
|
2097
|
-
alias: mdEntry?.alias ??
|
|
2098
|
-
|
|
2391
|
+
alias: mdEntry?.alias ?? `native-${child.id.slice(-4)}`,
|
|
2392
|
+
// A report-less child IS a child of the focused session
|
|
2393
|
+
// (session.children is session-scoped), so the caller-passed
|
|
2394
|
+
// parentSessionID is stamped on it as its origin session; an md
|
|
2395
|
+
// match always carries its own session (the report dir name).
|
|
2396
|
+
sessionID: mdEntry?.sessionID ?? parentSessionID,
|
|
2099
2397
|
taskID: child.id,
|
|
2100
2398
|
agent: mdEntry?.agent ?? child.agent ?? 'agent',
|
|
2101
2399
|
state,
|
|
@@ -2123,17 +2421,46 @@ export function childrenToDelegationEntries(
|
|
|
2123
2421
|
* server-valid session id ("ses...") ever reaches the router, so an
|
|
2124
2422
|
* unsubstituted "{sessionID}" placeholder can never be routed. */
|
|
2125
2423
|
export function navigateToDelegationSession(
|
|
2126
|
-
route:
|
|
2424
|
+
route:
|
|
2425
|
+
| {
|
|
2426
|
+
navigate?: (name: string, params?: Record<string, unknown>) => void | PromiseLike<void>
|
|
2427
|
+
}
|
|
2428
|
+
| undefined,
|
|
2127
2429
|
taskID: string | undefined,
|
|
2128
2430
|
): boolean {
|
|
2129
2431
|
if (typeof route?.navigate !== 'function' || !isValidSessionId(taskID)) return false
|
|
2130
|
-
|
|
2131
|
-
|
|
2432
|
+
try {
|
|
2433
|
+
const navigation = route.navigate('session', { sessionID: taskID })
|
|
2434
|
+
if (navigation !== undefined) {
|
|
2435
|
+
// A removed session can reject route.navigate for historical rows. That
|
|
2436
|
+
// rejection is expected and intentionally absorbed so it cannot break
|
|
2437
|
+
// the TUI; the no-op avoids logging sensitive session details.
|
|
2438
|
+
void Promise.resolve(navigation).catch(() => undefined)
|
|
2439
|
+
}
|
|
2440
|
+
return true
|
|
2441
|
+
} catch {
|
|
2442
|
+
// Synchronous router failures are also fail-open for an inert historical row.
|
|
2443
|
+
return false
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
/** Build the mouse handler used by each delegation row. */
|
|
2448
|
+
export function createDelegationRowOpenHandler(
|
|
2449
|
+
route:
|
|
2450
|
+
| {
|
|
2451
|
+
navigate?: (name: string, params?: Record<string, unknown>) => void | PromiseLike<void>
|
|
2452
|
+
}
|
|
2453
|
+
| undefined,
|
|
2454
|
+
taskID: string | undefined,
|
|
2455
|
+
): () => void {
|
|
2456
|
+
return () => {
|
|
2457
|
+
navigateToDelegationSession(route, taskID)
|
|
2458
|
+
}
|
|
2132
2459
|
}
|
|
2133
2460
|
|
|
2134
2461
|
/* ─── Silence-by-default panel logger ──────────────────────
|
|
2135
|
-
* Mirrors src/pantheon/logger.ts createPantheonLogger (the
|
|
2136
|
-
*
|
|
2462
|
+
* Mirrors src/pantheon/logger.ts createPantheonLogger (the copied plugin
|
|
2463
|
+
* ships only dist/ + package.json, so it cannot import from src/pantheon):
|
|
2137
2464
|
* every line is ALWAYS appended to `<logDir>/.pantheon/logs/hooks.log` and
|
|
2138
2465
|
* echoed to the console ONLY when PANTHEON_HOOKS_LOG is truthy (opencode
|
|
2139
2466
|
* renders plugin console output directly into the TUI — the pollution bug).
|
|
@@ -2175,8 +2502,19 @@ function createTuiLogger(projectRoot: string | undefined, module = 'pantheon-tui
|
|
|
2175
2502
|
`${lines.map((l) => `[${stamp}] [${module}] ${l}`).join('\n')}\n`,
|
|
2176
2503
|
'utf8',
|
|
2177
2504
|
)
|
|
2178
|
-
} catch {
|
|
2179
|
-
/* best-effort file log — never break the panel over logging
|
|
2505
|
+
} catch (err) {
|
|
2506
|
+
/* best-effort file log — never break the panel over logging, but do
|
|
2507
|
+
* NOT swallow the failure silently: a missing symbol here (e.g.
|
|
2508
|
+
* `appendFile` used without an import) previously threw an invisible
|
|
2509
|
+
* ReferenceError and the log silently stopped writing. Gated by the
|
|
2510
|
+
* echo flag to avoid the TUI-pollution bug described above. */
|
|
2511
|
+
if (echo) {
|
|
2512
|
+
try {
|
|
2513
|
+
process.stderr.write(`[${module}] file-log failed: ${formatArg(err)}\n`)
|
|
2514
|
+
} catch {
|
|
2515
|
+
/* stderr unavailable — never break the panel */
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2180
2518
|
}
|
|
2181
2519
|
})()
|
|
2182
2520
|
if (echo) {
|
|
@@ -2229,53 +2567,32 @@ function DelegationRow(props: {
|
|
|
2229
2567
|
api: TuiPluginApi
|
|
2230
2568
|
job: DelegationEntry
|
|
2231
2569
|
now: number
|
|
2232
|
-
|
|
2570
|
+
frame: number
|
|
2233
2571
|
}) {
|
|
2234
2572
|
const theme = () => props.api.theme.current
|
|
2235
2573
|
|
|
2236
|
-
|
|
2574
|
+
// Color is a status-wide channel: the WHOLE row (lead, elapsed, description,
|
|
2575
|
+
// live activity) carries the state tone, so red=failed / green=done /
|
|
2576
|
+
// yellow=in-flight reads at a glance. The glyph stays as a redundant,
|
|
2577
|
+
// monochrome-legible signal — never the only one.
|
|
2578
|
+
const tone = createMemo(() => {
|
|
2237
2579
|
const t = theme()
|
|
2238
|
-
switch (props.job.state) {
|
|
2239
|
-
case '
|
|
2580
|
+
switch (delegationStateTone(props.job.state)) {
|
|
2581
|
+
case 'warning':
|
|
2240
2582
|
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
2583
|
case 'error':
|
|
2248
2584
|
return t.error
|
|
2585
|
+
case 'success':
|
|
2586
|
+
return t.success
|
|
2249
2587
|
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 '
|
|
2588
|
+
return t.textMuted // defensive: unknown runtime state
|
|
2265
2589
|
}
|
|
2266
2590
|
})
|
|
2267
2591
|
|
|
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
|
-
})
|
|
2592
|
+
const marker = createMemo(() => delegationRowMarker(props.job.state, props.frame))
|
|
2593
|
+
const lead = createMemo(() => formatDelegationRowLead(props.job, marker()))
|
|
2594
|
+
const elapsed = createMemo(() => formatDelegationElapsed(props.job, props.now))
|
|
2595
|
+
const description = createMemo(() => truncateDelegationDescription(props.job.description))
|
|
2279
2596
|
|
|
2280
2597
|
// beta.6: live tool call the child is running RIGHT NOW ("↳ bash npm test"),
|
|
2281
2598
|
// fed by message.part.updated activity tracking. Null while idle/done.
|
|
@@ -2286,25 +2603,26 @@ function DelegationRow(props: {
|
|
|
2286
2603
|
// Click a row → navigate to the child session (route API guarded in the
|
|
2287
2604
|
// helper). Enter-key binding is left out (per-row keymap wiring is not
|
|
2288
2605
|
// worth the complexity in this sidebar); mouse is enabled in tui.json.
|
|
2289
|
-
const open = ()
|
|
2290
|
-
navigateToDelegationSession(props.api.route, props.job.taskID)
|
|
2291
|
-
}
|
|
2606
|
+
const open = createDelegationRowOpenHandler(props.api.route, props.job.taskID)
|
|
2292
2607
|
|
|
2293
2608
|
return (
|
|
2294
2609
|
<box onMouseDown={open}>
|
|
2610
|
+
{/* Line 1: toned lead (glyph + identity) + toned elapsed tail. */}
|
|
2295
2611
|
<box flexDirection="row">
|
|
2296
|
-
<text
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
>{` ${props.job.agent} \u2014 ${delegationActivityLabel(props.job)}`}</text>
|
|
2612
|
+
<text>
|
|
2613
|
+
<span fg={tone()}>{lead()}</span>
|
|
2614
|
+
<span fg={tone()}>{elapsed()}</span>
|
|
2615
|
+
</text>
|
|
2301
2616
|
</box>
|
|
2302
|
-
|
|
2617
|
+
{/* Line 2: the description, indented under the identity and toned. */}
|
|
2618
|
+
<Show when={description() !== ''}>
|
|
2619
|
+
<text fg={tone()}>{` ${description()}`}</text>
|
|
2620
|
+
</Show>
|
|
2303
2621
|
<Show when={activity()}>
|
|
2304
2622
|
{(a) => (
|
|
2305
2623
|
<text
|
|
2306
|
-
fg={
|
|
2307
|
-
>{`
|
|
2624
|
+
fg={tone()}
|
|
2625
|
+
>{` ↳ ${truncateDelegationDescription(`${a().tool}${a().summary !== '' ? ` ${a().summary}` : ''}`)}`}</text>
|
|
2308
2626
|
)}
|
|
2309
2627
|
</Show>
|
|
2310
2628
|
</box>
|
|
@@ -2407,20 +2725,22 @@ function View(props: {
|
|
|
2407
2725
|
|
|
2408
2726
|
// ── Delegations: session.children (PRIMARY) + md reports (enrichment) ──
|
|
2409
2727
|
// The panel's source of truth is the SDK child-session API
|
|
2410
|
-
// (`api.client.session.children`) — every
|
|
2728
|
+
// (`api.client.session.children`) — every native task() call spawns a child
|
|
2411
2729
|
// session with parentID = this session, so children ARE the delegation
|
|
2412
2730
|
// list (delegations-sidebar pattern). The md channel
|
|
2413
2731
|
// (.pantheon/delegations) enriches each child with alias/agent/description
|
|
2414
2732
|
// and terminal duration via the `Task ID` match. Live tool-call events are
|
|
2415
2733
|
// merged optimistically so the row appears before those channels catch up.
|
|
2416
|
-
|
|
2734
|
+
// Consistent project root (directory ?? worktree, cwd fallback) — the md
|
|
2735
|
+
// history and the panel logger both read the SAME root (audit finding: the
|
|
2736
|
+
// channels resolved it independently).
|
|
2737
|
+
const projectRoot = createMemo(() => resolvePantheonRoot((props.api.state as any).path))
|
|
2417
2738
|
// Silence-by-default panel logger → the REAL <root>/.pantheon/logs/hooks.log.
|
|
2418
|
-
|
|
2419
|
-
//
|
|
2420
|
-
//
|
|
2421
|
-
//
|
|
2422
|
-
//
|
|
2423
|
-
const panelLog = createTuiLogger(panelLogDir(delegationsDir()))
|
|
2739
|
+
const panelLog = createTuiLogger(projectRoot())
|
|
2740
|
+
// ONLY the active session renders: the final list is filtered by
|
|
2741
|
+
// filterDelegationsToSession after every merge (children + live + md).
|
|
2742
|
+
// Live-first ordering keeps running rows on top; the ceiling
|
|
2743
|
+
// collapses the rest into "… +N more".
|
|
2424
2744
|
// The rendered list is childDelegations (children enriched with md);
|
|
2425
2745
|
// there is no separate md-only signal — the md channel only feeds
|
|
2426
2746
|
// childrenToDelegationEntries below.
|
|
@@ -2429,9 +2749,9 @@ function View(props: {
|
|
|
2429
2749
|
// (oh-my-opencode-slim pattern): re-fetch children + re-read the md every
|
|
2430
2750
|
// second so the panel updates even when NO event ever fires.
|
|
2431
2751
|
const [now, setNow] = createSignal(Date.now())
|
|
2432
|
-
//
|
|
2433
|
-
//
|
|
2434
|
-
const [
|
|
2752
|
+
// Frame counter for the spinner animation — incremented every ~80ms by a
|
|
2753
|
+
// dedicated fast timer, completely decoupled from the data poll cycle.
|
|
2754
|
+
const [frame, setFrame] = createSignal(0)
|
|
2435
2755
|
// Cumulative count of event-triggered refreshes — logged with every fetch
|
|
2436
2756
|
// ("panel: children=N(pantheon=M native=K) md=N events=N") so the (0)
|
|
2437
2757
|
// symptom is diagnosable: children>0 means the source works; events=0
|
|
@@ -2445,15 +2765,17 @@ function View(props: {
|
|
|
2445
2765
|
if (delegationsInflight !== null) return delegationsInflight
|
|
2446
2766
|
delegationsInflight = (async () => {
|
|
2447
2767
|
try {
|
|
2448
|
-
|
|
2768
|
+
// Typed (no `as any`): TuiState.session.status is a v2 sync accessor
|
|
2769
|
+
// taking the session id directly — same shape as the client call.
|
|
2770
|
+
const state = props.api.state
|
|
2449
2771
|
// 0. md reports from EVERY session under .pantheon/delegations — the
|
|
2450
|
-
//
|
|
2451
|
-
//
|
|
2452
|
-
//
|
|
2453
|
-
//
|
|
2772
|
+
// ENRICHMENT channel. Read unfiltered (taskID match supplies alias/
|
|
2773
|
+
// agent/description/terminal state); the active-session filter after
|
|
2774
|
+
// the merge keeps only the focused session's reports. Fail-open: a
|
|
2775
|
+
// missing dir yields [].
|
|
2454
2776
|
let md: DelegationEntry[] = []
|
|
2455
2777
|
try {
|
|
2456
|
-
md = await readAllDelegationEntries(
|
|
2778
|
+
md = await readAllDelegationEntries(projectRoot())
|
|
2457
2779
|
} catch {
|
|
2458
2780
|
md = [] // fail-open: never crash the sidebar on a read error
|
|
2459
2781
|
}
|
|
@@ -2462,24 +2784,32 @@ function View(props: {
|
|
|
2462
2784
|
// slot prop; forwarding it to session.children made the server reject
|
|
2463
2785
|
// every poll (~1 err/s: "Expected a string starting with \"ses\", got
|
|
2464
2786
|
// \"%7BsessionID%7D\"") and fail-open the panel into "(0)". A null
|
|
2465
|
-
// resolution SKIPS the fetch entirely: the
|
|
2466
|
-
// (zero children, zero errors).
|
|
2787
|
+
// resolution SKIPS the fetch entirely: the active-session filter yields
|
|
2788
|
+
// an EMPTY panel (zero children, zero rows, zero errors).
|
|
2467
2789
|
const sessionID = resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api })
|
|
2468
2790
|
if (sessionID === null) {
|
|
2469
|
-
|
|
2791
|
+
// No focused session → no scope → empty list. The md read still ran
|
|
2792
|
+
// (enrichment source) but with no session there is nothing to scope
|
|
2793
|
+
// to: there is no cross-session panel.
|
|
2794
|
+
setChildDelegations([])
|
|
2470
2795
|
panelLog.info(
|
|
2471
|
-
`${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID —
|
|
2796
|
+
`${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — inactive panel)`,
|
|
2472
2797
|
)
|
|
2473
2798
|
return
|
|
2474
2799
|
}
|
|
2475
2800
|
// 2. children — PRIMARY source (SDK: api.client.session.children).
|
|
2801
|
+
// v2 SDK: `children` takes a FLAT `{ sessionID }` parameter object.
|
|
2802
|
+
// The v1 `{ path: { id } }` envelope is NOT understood by the v2
|
|
2803
|
+
// client — it left the URL template unsubstituted and the server
|
|
2804
|
+
// rejected every poll ("%7BsessionID%7D" regression).
|
|
2476
2805
|
let children: ChildDelegationLike[] = []
|
|
2477
2806
|
try {
|
|
2478
2807
|
// buildChildrenPath re-validates: with sessionID !== null it is
|
|
2479
2808
|
// never null, so no placeholder can reach the wire.
|
|
2480
|
-
const
|
|
2481
|
-
|
|
2482
|
-
|
|
2809
|
+
const childrenPath = buildChildrenPath(sessionID)
|
|
2810
|
+
const result = childrenPath
|
|
2811
|
+
? await props.api.client.session.children(childrenPath)
|
|
2812
|
+
: undefined
|
|
2483
2813
|
const data = (result?.data ?? result) as unknown
|
|
2484
2814
|
children = Array.isArray(data) ? (data as ChildDelegationLike[]) : []
|
|
2485
2815
|
} catch (err) {
|
|
@@ -2493,13 +2823,16 @@ function View(props: {
|
|
|
2493
2823
|
// path — "%7BsessionID%7D").
|
|
2494
2824
|
if (!isValidSessionId(childID)) return undefined
|
|
2495
2825
|
try {
|
|
2496
|
-
return state
|
|
2826
|
+
return state.session.status(childID)?.type
|
|
2497
2827
|
} catch {
|
|
2498
2828
|
return undefined // status API unavailable — child state fallback
|
|
2499
2829
|
}
|
|
2500
2830
|
}
|
|
2501
2831
|
const withStatus = children.map((c) => ({ ...c, status: resolveStatus(c.id) }))
|
|
2502
|
-
|
|
2832
|
+
// Report-less rows ARE children of the focused session
|
|
2833
|
+
// (session.children is session-scoped), so the resolved id is stamped on
|
|
2834
|
+
// the row as its origin session.
|
|
2835
|
+
const childEntries = childrenToDelegationEntries(withStatus, md, now(), sessionID)
|
|
2503
2836
|
for (const [k, v] of props.liveStore.map)
|
|
2504
2837
|
if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 30_000)
|
|
2505
2838
|
props.liveStore.map.delete(k)
|
|
@@ -2509,7 +2842,16 @@ function View(props: {
|
|
|
2509
2842
|
const liveEntries = [...props.liveStore.map.values()].filter(
|
|
2510
2843
|
(entry) => entry.sessionID === sessionID,
|
|
2511
2844
|
)
|
|
2512
|
-
|
|
2845
|
+
// The event channel is optimistic; merge it over the children/md rows.
|
|
2846
|
+
// Then the active-session filter — applied ONCE after the merge — keeps
|
|
2847
|
+
// ONLY rows whose sessionID === the focused session, so md entries from
|
|
2848
|
+
// other sessions (and orphans with no sessionID) can never render.
|
|
2849
|
+
setChildDelegations(
|
|
2850
|
+
filterDelegationsToSession(
|
|
2851
|
+
mergeChildDelegationSources(childEntries, liveEntries),
|
|
2852
|
+
sessionID,
|
|
2853
|
+
),
|
|
2854
|
+
)
|
|
2513
2855
|
// 4. diagnostic line — every re-fetch (silence-by-default, hooks.log).
|
|
2514
2856
|
const counts = countDelegationSources(childEntries)
|
|
2515
2857
|
panelLog.info(
|
|
@@ -2527,23 +2869,16 @@ function View(props: {
|
|
|
2527
2869
|
})()
|
|
2528
2870
|
return delegationsInflight
|
|
2529
2871
|
}
|
|
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
|
-
}
|
|
2872
|
+
// Live-first ceiling: at most DELEGATION_VISIBLE_CEILING rows render; the
|
|
2873
|
+
// remainder collapses into a single "… +N more" line. Both the ceiling and
|
|
2874
|
+
// the header operate on the ALREADY-FILTERED active-session list
|
|
2875
|
+
// (childDelegations), so other-session / hidden rows never inflate counts.
|
|
2876
|
+
const delegationCeiling = createMemo(() =>
|
|
2877
|
+
ceilingDelegationList(childDelegations(), DELEGATION_VISIBLE_CEILING, now()),
|
|
2878
|
+
)
|
|
2879
|
+
// Header counts the same retained rows shown below, so hidden/expired md
|
|
2880
|
+
// history cannot inflate the summary.
|
|
2881
|
+
const delegationHeader = createMemo(() => formatDelegationHeader(delegationCeiling().visible))
|
|
2547
2882
|
|
|
2548
2883
|
// ── Event subscriptions for live session/delegation updates ──
|
|
2549
2884
|
onMount(() => {
|
|
@@ -2591,7 +2926,9 @@ function View(props: {
|
|
|
2591
2926
|
void refreshDelegations()
|
|
2592
2927
|
}, 1_000)
|
|
2593
2928
|
cleanup.push(() => clearInterval(poll))
|
|
2594
|
-
|
|
2929
|
+
// Fast spinner animation: 80ms frame counter, decoupled from data poll.
|
|
2930
|
+
// Only the spinner glyph depends on this; elapsed + data use the 1s timer.
|
|
2931
|
+
const animation = setInterval(() => setFrame((f) => f + 1), 80)
|
|
2595
2932
|
cleanup.push(() => clearInterval(animation))
|
|
2596
2933
|
|
|
2597
2934
|
// Compaction recovery: `message.part.removed` clears live entries while
|
|
@@ -2688,13 +3025,13 @@ function View(props: {
|
|
|
2688
3025
|
<text fg={theme().text} attributes={1}>
|
|
2689
3026
|
{`${showDelegations() ? '▼' : '▶'} Delegations`}
|
|
2690
3027
|
</text>
|
|
2691
|
-
<text
|
|
2692
|
-
|
|
2693
|
-
|
|
3028
|
+
<text fg={theme().textMuted}>
|
|
3029
|
+
{delegationCeiling().visible.length > 0 ? ` ${delegationHeader()}` : ' — idle'}
|
|
3030
|
+
</text>
|
|
2694
3031
|
</box>
|
|
2695
3032
|
<Show when={showDelegations()}>
|
|
2696
3033
|
<Show
|
|
2697
|
-
when={
|
|
3034
|
+
when={delegationCeiling().visible.length > 0}
|
|
2698
3035
|
fallback={
|
|
2699
3036
|
<box marginLeft={1}>
|
|
2700
3037
|
<text fg={theme().textMuted}>No delegations</text>
|
|
@@ -2702,50 +3039,15 @@ function View(props: {
|
|
|
2702
3039
|
}
|
|
2703
3040
|
>
|
|
2704
3041
|
<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
|
-
)}
|
|
3042
|
+
<For each={delegationCeiling().visible}>
|
|
3043
|
+
{(job) => <DelegationRow api={props.api} job={job} now={now()} frame={frame()} />}
|
|
2714
3044
|
</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
|
-
})()}
|
|
3045
|
+
<Show when={delegationCeiling().hidden > 0}>
|
|
3046
|
+
<text fg={theme().textMuted}>
|
|
3047
|
+
{delegationCeiling().hiddenActive > 0
|
|
3048
|
+
? `… +${delegationCeiling().hiddenActive} active`
|
|
3049
|
+
: `… +${delegationCeiling().hiddenTerminal} more`}
|
|
3050
|
+
</text>
|
|
2749
3051
|
</Show>
|
|
2750
3052
|
</box>
|
|
2751
3053
|
</Show>
|
|
@@ -2770,16 +3072,15 @@ const tui: TuiPlugin = (api, _options, _meta) => {
|
|
|
2770
3072
|
// Vendored feature (MIT): AI subscription usage gauges.
|
|
2771
3073
|
void setupUsageBar(api) // async init — never blocks sidebar registration
|
|
2772
3074
|
|
|
2773
|
-
// ── Live delegation store (
|
|
3075
|
+
// ── Live delegation store (tool-part tracker, kept as a REFRESH
|
|
2774
3076
|
// TRIGGER) ──
|
|
2775
3077
|
// The Delegations panel's PRIMARY source is `api.client.session.children`
|
|
2776
3078
|
// (delegations-sidebar pattern) — `message.part.updated` may not fire for
|
|
2777
|
-
//
|
|
2778
|
-
// lifecycle below still tracks
|
|
2779
|
-
//
|
|
2780
|
-
//
|
|
2781
|
-
//
|
|
2782
|
-
// keeps the panel working (never crash).
|
|
3079
|
+
// the native task tool on runtime 1.18.13 (the "(0)" symptom). The
|
|
3080
|
+
// tool-part lifecycle below still tracks `task` parts and bumps a version
|
|
3081
|
+
// signal the View subscribes to: when part events DO arrive, the panel
|
|
3082
|
+
// re-fetches children. Fail-open: if the events API is unavailable nothing
|
|
3083
|
+
// subscribes and the 1s safety poll keeps the panel working (never crash).
|
|
2783
3084
|
const [liveVersion, setLiveVersion] = createSignal(0)
|
|
2784
3085
|
const liveStore: LiveDelegationStore = {
|
|
2785
3086
|
map: new Map<string, LiveDelegationEntry>(),
|