pantheon-opencode 1.3.3 → 1.3.5-beta.52.c75b04d
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 +919 -0
- package/README.md +157 -15
- package/bin/pantheon-init.mjs +177 -120
- package/opencode.json +3 -6
- package/package.json +8 -4
- package/scripts/build-agents-md.mjs +195 -0
- package/scripts/cost.mjs +17 -0
- package/scripts/doctor.mjs +56 -22
- package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
- package/scripts/install/opencode.mjs +189 -38
- package/scripts/install/plugin.mjs +112 -28
- package/scripts/install-mcp.mjs +1 -1
- package/scripts/redaction-gate.mjs +69 -0
- package/scripts/release-beta-version.mjs +80 -0
- package/scripts/release-notes.mjs +317 -0
- package/scripts/test-context-compression.sh +5 -2
- package/scripts/validate-package.mjs +56 -0
- package/scripts/versioning.mjs +117 -18
- package/src/agents/zeus.md +6 -2
- package/src/pantheon/background-job-board.ts +24 -38
- package/src/pantheon/chat-reminders.ts +79 -0
- package/src/pantheon/compaction-assert.ts +160 -0
- package/src/pantheon/cost-command.ts +57 -18
- package/src/pantheon/delegation-compaction.ts +143 -16
- package/src/pantheon/delegation-finalize.ts +8 -1
- package/src/pantheon/delegation-notify.ts +19 -157
- package/src/pantheon/delegation.ts +330 -28
- package/src/pantheon/dispatch-guard.ts +9 -4
- package/src/pantheon/goal-loop.ts +7 -4
- package/src/pantheon/hashline/tool.ts +2 -1
- package/src/pantheon/logger.ts +117 -0
- package/src/pantheon/plugin-once.ts +76 -0
- package/src/pantheon/preemptive-compact.ts +76 -0
- package/src/pantheon/presets.d.mts +31 -0
- package/src/pantheon/presets.mjs +105 -3
- package/src/pantheon/todo-enforcer.ts +79 -27
- package/src/pantheon/todo-preserve.ts +295 -0
- package/src/plugin.ts +177 -50
- package/src/plugins/pantheon-hooks.ts +35 -22
- package/src/plugins/tui/dist/server.d.ts +17 -0
- package/src/plugins/tui/dist/server.d.ts.map +1 -0
- package/src/plugins/tui/dist/server.js +20 -0
- package/src/plugins/tui/dist/server.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +303 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +977 -350
- package/src/plugins/tui/dist/tui.tsx +1427 -150
- package/src/plugins/tui/package.json +4 -3
- package/src/plugins/tui/src/index.tsx +1427 -150
- package/src/plugins/tui/src/server.ts +15 -0
- package/src/plugins/tui/tsdown.config.ts +1 -1
- package/src/routing.yml +3 -1
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Slots:
|
|
13
13
|
* - sidebar_content (order 900) — Pantheon sidebar (header/version/
|
|
14
|
-
* branch, Sessions,
|
|
14
|
+
* branch, Sessions, real-time Delegations panel).
|
|
15
15
|
* - app_bottom (order 60) — AI subscription usage gauges
|
|
16
16
|
* (Anthropic/OpenAI quotas, OpenCode Go/Zen dollar limits + provider
|
|
17
17
|
* status incidents).
|
|
@@ -46,56 +46,31 @@
|
|
|
46
46
|
*/
|
|
47
47
|
|
|
48
48
|
import { Buffer } from 'node:buffer'
|
|
49
|
-
import {
|
|
49
|
+
import type { Dirent } from 'node:fs'
|
|
50
|
+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
50
51
|
import { homedir } from 'node:os'
|
|
51
52
|
import { dirname, join } from 'node:path'
|
|
52
53
|
import { fileURLToPath } from 'node:url'
|
|
53
54
|
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui'
|
|
54
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
createEffect,
|
|
57
|
+
createMemo,
|
|
58
|
+
createResource,
|
|
59
|
+
createSignal,
|
|
60
|
+
For,
|
|
61
|
+
onCleanup,
|
|
62
|
+
onMount,
|
|
63
|
+
Show,
|
|
64
|
+
} from 'solid-js'
|
|
55
65
|
|
|
56
66
|
/* ─── Constants ─────────────────────────────────────────── */
|
|
57
67
|
|
|
58
|
-
const COMMANDS = [
|
|
59
|
-
{ name: '/pantheon', desc: 'Council synthesis' },
|
|
60
|
-
{ name: '/pantheon-status', desc: 'System status' },
|
|
61
|
-
{ name: '/pantheon-audit', desc: 'Full audit' },
|
|
62
|
-
{ name: '/pantheon-bg', desc: 'List background tasks' },
|
|
63
|
-
{ name: '/pantheon-cancel', desc: 'Cancel task' },
|
|
64
|
-
{ name: '/pantheon-deepwork', desc: 'Deep work mode' },
|
|
65
|
-
{ name: '/pantheon-focus', desc: 'Focus on scope' },
|
|
66
|
-
{ name: '/pantheon-remember', desc: 'Memory store/recall' },
|
|
67
|
-
{ name: '/pantheon-search', desc: 'Memory search' },
|
|
68
|
-
{ name: '/pantheon-consolidate', desc: 'Merge memories' },
|
|
69
|
-
{ name: '/pantheon-forget', desc: 'Compress memories' },
|
|
70
|
-
] as const
|
|
71
|
-
|
|
72
68
|
/** How often the sidebar re-reads .pantheon/active-preset.json so `set-tier`
|
|
73
69
|
* changes made while opencode is open show up within ~30s. */
|
|
74
70
|
const PRESET_REFRESH_MS = 30_000
|
|
75
71
|
|
|
76
|
-
const AGENTS = [
|
|
77
|
-
{ name: 'zeus', tier: 'default' as const, role: 'Orchestrator' },
|
|
78
|
-
{ name: 'athena', tier: 'premium' as const, role: 'Strategic planner' },
|
|
79
|
-
{ name: 'apollo', tier: 'fast' as const, role: 'Codebase discovery' },
|
|
80
|
-
{ name: 'hermes', tier: 'default' as const, role: 'Backend' },
|
|
81
|
-
{ name: 'aphrodite', tier: 'default' as const, role: 'Frontend' },
|
|
82
|
-
{ name: 'demeter', tier: 'default' as const, role: 'Database' },
|
|
83
|
-
{ name: 'themis', tier: 'premium' as const, role: 'Quality & security' },
|
|
84
|
-
{ name: 'prometheus', tier: 'default' as const, role: 'Infrastructure' },
|
|
85
|
-
{ name: 'hephaestus', tier: 'default' as const, role: 'AI pipelines' },
|
|
86
|
-
{ name: 'nyx', tier: 'fast' as const, role: 'Observability' },
|
|
87
|
-
{ name: 'gaia', tier: 'fast' as const, role: 'Remote sensing' },
|
|
88
|
-
{ name: 'iris', tier: 'fast' as const, role: 'GitHub operations' },
|
|
89
|
-
{ name: 'mnemosyne', tier: 'fast' as const, role: 'Memory bank' },
|
|
90
|
-
{ name: 'talos', tier: 'fast' as const, role: 'Hotfixes' },
|
|
91
|
-
] as const
|
|
92
|
-
|
|
93
72
|
/* ─── Helpers ───────────────────────────────────────────── */
|
|
94
73
|
|
|
95
|
-
function fmtInt(n: number): string {
|
|
96
|
-
return Intl.NumberFormat('en-US').format(Math.max(0, Math.round(n)))
|
|
97
|
-
}
|
|
98
|
-
|
|
99
74
|
/* ─── Active Preset Detection ──────────────────────────────
|
|
100
75
|
* Inline replica of src/pantheon/presets.mjs resolveActivePreset() — the TUI
|
|
101
76
|
* plugin is self-contained (dist/tui.tsx is a raw copy of this file with no
|
|
@@ -873,12 +848,1075 @@ async function setupUsageBar(api: TuiPluginApi) {
|
|
|
873
848
|
})
|
|
874
849
|
}
|
|
875
850
|
|
|
851
|
+
/* ─── Delegations (real-time panel) ────────────────────────
|
|
852
|
+
* CHILDREN + MD (delegations-sidebar pattern, feat/compaction-134):
|
|
853
|
+
*
|
|
854
|
+
* 1. PRIMARY — SDK child sessions via `api.client.session.children`:
|
|
855
|
+
* every pantheon_delegate creates a child session with
|
|
856
|
+
* parentID = caller (src/pantheon/delegation.ts session.create), so
|
|
857
|
+
* the children of the current session ARE the delegation list — the
|
|
858
|
+
* same mechanism the opencode-delegations-sidebar reference uses
|
|
859
|
+
* (`client.session.children` + `session.parentID === current`).
|
|
860
|
+
* Live status comes from `api.state.session.status(childID)`
|
|
861
|
+
* (busy/retry → running; idle → terminal). This does NOT depend on
|
|
862
|
+
* tool-part events, which runtime 1.18.13 may not deliver for
|
|
863
|
+
* pantheon_delegate (the "(0) even while delegating" symptom).
|
|
864
|
+
*
|
|
865
|
+
* 1b. NATIVE task() CHILDREN — `session.children` returns ALL children,
|
|
866
|
+
* and the native `task()` tool ALSO spawns a child session with
|
|
867
|
+
* parentID = caller. A child WITHOUT a board report is a native
|
|
868
|
+
* task() child: childrenToDelegationEntries tags it source
|
|
869
|
+
* 'children-only' with the `[task]` row tag (distinct info color),
|
|
870
|
+
* so native task() work is visible in the panel alongside board
|
|
871
|
+
* delegates ([apo-1]) — never filtered out, never duplicated with a
|
|
872
|
+
* board row for the same child (a child WITH a report keeps the md
|
|
873
|
+
* entry, source 'md').
|
|
874
|
+
*
|
|
875
|
+
* 2. ENRICHMENT + HISTORY — markdown reports the job board persists under
|
|
876
|
+
* `.pantheon/delegations/<sessionID>/<alias>.md` (written by
|
|
877
|
+
* src/pantheon/delegation-finalize.ts at terminal state). Each child
|
|
878
|
+
* is matched to its report by the `Task ID` header (== child session
|
|
879
|
+
* id == board task id), which supplies the alias (apo-N), agent,
|
|
880
|
+
* description and terminal duration/timedOut. No report → the child
|
|
881
|
+
* itself still renders (title as description, derived state). The md
|
|
882
|
+
* channel is ALSO read from EVERY session (readAllDelegationEntries) —
|
|
883
|
+
* the panel shows the full delegation history even without a focused
|
|
884
|
+
* session.
|
|
885
|
+
*
|
|
886
|
+
* 3. REFRESH — session.created/updated/deleted/status events and
|
|
887
|
+
* message.part.updated/removed (via liveStore.version bumps) re-fetch
|
|
888
|
+
* children; a 1s safety poll (oh-my-opencode-slim pattern) re-fetches
|
|
889
|
+
* children + re-reads the md so the panel updates EVEN without events.
|
|
890
|
+
*
|
|
891
|
+
* 4. DIAGNOSTICS — every re-fetch logs "panel: children=N md=N events=N"
|
|
892
|
+
* to `.pantheon/logs/hooks.log` (silence-by-default, createPantheonLogger
|
|
893
|
+
* policy) so the (0) symptom is diagnosable from disk. A children fetch
|
|
894
|
+
* failure logs "panel: error <msg>" (previously silenced), and a missing
|
|
895
|
+
* current session (null/placeholder sessionID) logs "panel: children=0
|
|
896
|
+
* md=N ... (no sessionID — history shown)" — the md HISTORY renders
|
|
897
|
+
* instead of erroring.
|
|
898
|
+
*
|
|
899
|
+
* 5. SESSIONID GUARD (placeholder regression) — the runtime may hand the
|
|
900
|
+
* sidebar the UNSUBSTITUTED template literal "{sessionID}" as its
|
|
901
|
+
* session_id (no focused session). Forwarding it to session.children
|
|
902
|
+
* made opencode's server reject every poll (~1 err/s: "Expected a string
|
|
903
|
+
* starting with \"ses\", got \"%7BsessionID%7D\"") and fail-open the
|
|
904
|
+
* panel into "(0)". resolveCurrentSessionID (slot prop → api.state →
|
|
905
|
+
* route.current) never yields a placeholder (isValidSessionId rejects
|
|
906
|
+
* "{sessionID}" and "%7BsessionID%7D" via the startsWith("ses")
|
|
907
|
+
* contract); a null resolution SKIPS the children fetch entirely and
|
|
908
|
+
* renders the md history — zero errors.
|
|
909
|
+
*
|
|
910
|
+
* The live tool-part lifecycle helpers below remain (tested, exported) but
|
|
911
|
+
* are no longer the source of truth: they feed a version signal that merely
|
|
912
|
+
* re-triggers the children fetch. Everything is fail-open: a missing
|
|
913
|
+
* children API or directory yields [], and any unreadable input is skipped —
|
|
914
|
+
* never crash. */
|
|
915
|
+
|
|
916
|
+
export type DelegationEntry = {
|
|
917
|
+
/** Job alias, e.g. "apo-1" (from the H1 title, falling back to filename). */
|
|
918
|
+
alias: string
|
|
919
|
+
/** Parent session the job was launched from (dir name under .pantheon/delegations). */
|
|
920
|
+
sessionID: string
|
|
921
|
+
/** Child session id (= board task id, from the `Task ID` header). The
|
|
922
|
+
* children channel always sets it from the child session itself; the md
|
|
923
|
+
* channel parses it so child↔md matching works by taskID. */
|
|
924
|
+
taskID?: string
|
|
925
|
+
/** Agent name, e.g. "apollo". */
|
|
926
|
+
agent: string
|
|
927
|
+
state: 'running' | 'completed' | 'error' | 'cancelled'
|
|
928
|
+
/** Epoch ms of the `Started` header. */
|
|
929
|
+
startedAt: number
|
|
930
|
+
/** Epoch ms of the `Finalized` header — null while still running. */
|
|
931
|
+
updatedAt: number | null
|
|
932
|
+
timedOut: boolean
|
|
933
|
+
description: string
|
|
934
|
+
/** True while the panel is waiting for pantheon_delegation_read. */
|
|
935
|
+
read?: boolean
|
|
936
|
+
/** Internal provenance used to keep a finalized md report authoritative.
|
|
937
|
+
* 'children-only' = a native task() child session with NO board report
|
|
938
|
+
* (rendered with the distinct `[task]` tag); 'md' = board report wins. */
|
|
939
|
+
source?: 'child' | 'live' | 'md' | 'children-only'
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** True for `\s` characters (space, tab, newline, CR) — plain char checks so
|
|
943
|
+
* the parser stays regex-free (CodeQL flagged the old `\s*`/`\s+` + `(.+)`
|
|
944
|
+
* patterns as polynomial ReDoS: 12x HIGH). */
|
|
945
|
+
function isWs(ch: string | undefined): boolean {
|
|
946
|
+
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** Em/en dash + hyphen — the separators accepted after the H1 title. */
|
|
950
|
+
const TITLE_SEPARATORS = '—–-'
|
|
951
|
+
|
|
952
|
+
/** Parse one delegation report md header into a structured entry.
|
|
953
|
+
* Returns null (skip) when the file is not a recognizable report:
|
|
954
|
+
* missing agent/state/startedAt, an unknown state, or an unparsable
|
|
955
|
+
* Started timestamp. The alias falls back to the file name when the H1
|
|
956
|
+
* title is missing. Pure — no I/O.
|
|
957
|
+
*
|
|
958
|
+
* Linear, single-pass over `raw.split('\n')` with plain string operations
|
|
959
|
+
* (startsWith/indexOf/slice) — zero regex, so worst case is O(bytes) even
|
|
960
|
+
* on adversarial whitespace-heavy input (ReDoS regression, CodeQL 12x HIGH). */
|
|
961
|
+
export function parseDelegationMarkdown(
|
|
962
|
+
raw: string,
|
|
963
|
+
fileAlias?: string,
|
|
964
|
+
sessionID = '',
|
|
965
|
+
): DelegationEntry | null {
|
|
966
|
+
let title: string | undefined
|
|
967
|
+
let agent: string | undefined
|
|
968
|
+
let description = ''
|
|
969
|
+
let state: string | undefined
|
|
970
|
+
let timedOut = false
|
|
971
|
+
let started: string | undefined
|
|
972
|
+
let finalized: string | undefined
|
|
973
|
+
let taskID: string | undefined
|
|
974
|
+
|
|
975
|
+
for (const rawLine of raw.split('\n')) {
|
|
976
|
+
// H1 title: `# Delegation Report — <alias>` (empty alias → fallback later).
|
|
977
|
+
if (rawLine[0] === '#' && isWs(rawLine[1])) {
|
|
978
|
+
let i = 2
|
|
979
|
+
while (i < rawLine.length && isWs(rawLine[i])) i++
|
|
980
|
+
if (rawLine.startsWith('Delegation Report', i)) {
|
|
981
|
+
i += 'Delegation Report'.length
|
|
982
|
+
while (i < rawLine.length && isWs(rawLine[i])) i++
|
|
983
|
+
if (i < rawLine.length && TITLE_SEPARATORS.includes(rawLine[i])) {
|
|
984
|
+
const rest = rawLine.slice(i + 1).trim()
|
|
985
|
+
if (rest !== '' && title === undefined) title = rest
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
continue
|
|
989
|
+
}
|
|
990
|
+
// Field bullets: `- **Name**: value` — unknown names are ignored.
|
|
991
|
+
if (rawLine[0] !== '-') continue
|
|
992
|
+
let i = 1
|
|
993
|
+
while (i < rawLine.length && isWs(rawLine[i])) i++
|
|
994
|
+
if (!rawLine.startsWith('**', i)) continue
|
|
995
|
+
const nameStart = i + 2
|
|
996
|
+
const valueEnd = rawLine.indexOf('**:', nameStart)
|
|
997
|
+
if (valueEnd < 0) continue
|
|
998
|
+
const name = rawLine.slice(nameStart, valueEnd)
|
|
999
|
+
const value = rawLine.slice(valueEnd + 3).trim()
|
|
1000
|
+
switch (name) {
|
|
1001
|
+
case 'Task ID':
|
|
1002
|
+
// `- **Task ID**: \`ses_x\`` — strip the surrounding backticks.
|
|
1003
|
+
if (value !== '' && taskID === undefined) taskID = stripTaskIdTicks(value)
|
|
1004
|
+
break
|
|
1005
|
+
case 'Agent':
|
|
1006
|
+
// Empty value counts as missing (old regex `(.+)` required 1+ chars).
|
|
1007
|
+
if (value !== '' && agent === undefined) agent = value
|
|
1008
|
+
break
|
|
1009
|
+
case 'Description':
|
|
1010
|
+
description = value
|
|
1011
|
+
break
|
|
1012
|
+
case 'State':
|
|
1013
|
+
if (value !== '' && state === undefined) state = value
|
|
1014
|
+
break
|
|
1015
|
+
case 'Timed out':
|
|
1016
|
+
// Old `(true|false)` prefix match, case-sensitive — kept verbatim.
|
|
1017
|
+
timedOut = value.startsWith('true')
|
|
1018
|
+
break
|
|
1019
|
+
case 'Started':
|
|
1020
|
+
if (value !== '' && started === undefined) started = value
|
|
1021
|
+
break
|
|
1022
|
+
case 'Finalized':
|
|
1023
|
+
finalized = value
|
|
1024
|
+
break
|
|
1025
|
+
default:
|
|
1026
|
+
break // unknown field (e.g. Task ID) → ignore the line
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const startedAt = started !== undefined ? Date.parse(started) : NaN
|
|
1031
|
+
if (agent === undefined || state === undefined || Number.isNaN(startedAt)) return null
|
|
1032
|
+
const normalized = state.toLowerCase()
|
|
1033
|
+
if (
|
|
1034
|
+
normalized !== 'running' &&
|
|
1035
|
+
normalized !== 'completed' &&
|
|
1036
|
+
normalized !== 'error' &&
|
|
1037
|
+
normalized !== 'cancelled'
|
|
1038
|
+
) {
|
|
1039
|
+
return null
|
|
1040
|
+
}
|
|
1041
|
+
const finalizedAt = finalized !== undefined ? Date.parse(finalized) : NaN
|
|
1042
|
+
|
|
1043
|
+
return {
|
|
1044
|
+
alias: title ?? (fileAlias !== undefined ? stripMdSuffix(fileAlias) : 'unknown'),
|
|
1045
|
+
sessionID,
|
|
1046
|
+
...(taskID !== undefined ? { taskID } : {}),
|
|
1047
|
+
agent,
|
|
1048
|
+
state: normalized,
|
|
1049
|
+
startedAt,
|
|
1050
|
+
updatedAt: Number.isNaN(finalizedAt) ? null : finalizedAt,
|
|
1051
|
+
timedOut,
|
|
1052
|
+
description,
|
|
1053
|
+
source: 'md',
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/** Strip a trailing `.md` (any case) — linear replacement for /\.md$/i. */
|
|
1058
|
+
function stripMdSuffix(name: string): string {
|
|
1059
|
+
return name.toLowerCase().endsWith('.md') ? name.slice(0, name.length - 3) : name
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/** Strip surrounding backticks from a `Task ID` value: "`ses_x`" → "ses_x". */
|
|
1063
|
+
function stripTaskIdTicks(value: string): string {
|
|
1064
|
+
const start = value[0] === '`' ? 1 : 0
|
|
1065
|
+
const end =
|
|
1066
|
+
value.length > start && value[value.length - 1] === '`' ? value.length - 1 : value.length
|
|
1067
|
+
return value.slice(start, end)
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/** Read every delegation report under `<dir>/<sessionID>/<alias>.md`.
|
|
1071
|
+
* Fail-open: a missing/unreadable directory yields [], and each unreadable
|
|
1072
|
+
* or malformed file is skipped individually. Entries are sorted running
|
|
1073
|
+
* first, then terminal by `updatedAt` (most recent first) so the panel can
|
|
1074
|
+
* render them in order directly. */
|
|
1075
|
+
export async function readDelegationEntries(dir: string): Promise<DelegationEntry[]> {
|
|
1076
|
+
let sessionDirs: Dirent[]
|
|
1077
|
+
try {
|
|
1078
|
+
sessionDirs = await readdir(dir, { withFileTypes: true })
|
|
1079
|
+
} catch {
|
|
1080
|
+
return [] // directory absent/unreadable — nothing to show
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const entries: DelegationEntry[] = []
|
|
1084
|
+
for (const session of sessionDirs) {
|
|
1085
|
+
if (!session.isDirectory()) continue
|
|
1086
|
+
let files: Dirent[]
|
|
1087
|
+
try {
|
|
1088
|
+
files = await readdir(join(dir, session.name), { withFileTypes: true })
|
|
1089
|
+
} catch {
|
|
1090
|
+
continue // unreadable session dir — skip
|
|
1091
|
+
}
|
|
1092
|
+
for (const file of files) {
|
|
1093
|
+
if (!file.isFile() || !file.name.endsWith('.md')) continue
|
|
1094
|
+
try {
|
|
1095
|
+
const raw = await readFile(join(dir, session.name, file.name), 'utf8')
|
|
1096
|
+
const entry = parseDelegationMarkdown(raw, file.name, session.name)
|
|
1097
|
+
if (entry !== null) entries.push(entry)
|
|
1098
|
+
} catch {
|
|
1099
|
+
// unreadable/malformed file — skip, never crash
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
entries.sort(compareDelegationEntries)
|
|
1105
|
+
return entries
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/** Read delegation reports from EVERY session under
|
|
1109
|
+
* `<root>/.pantheon/delegations/<sessionID>/<alias>.md` — the panel's
|
|
1110
|
+
* HISTORY channel. Unlike the children/live channels it does NOT depend on a
|
|
1111
|
+
* resolved sessionID: with no focused session (null/placeholder), the panel
|
|
1112
|
+
* still shows the reports from all past sessions (running first, Finalized
|
|
1113
|
+
* desc — the sort applied by readDelegationEntries). Fail-open: a
|
|
1114
|
+
* missing/unreadable directory yields []. */
|
|
1115
|
+
export async function readAllDelegationEntries(root: string): Promise<DelegationEntry[]> {
|
|
1116
|
+
return readDelegationEntries(join(root, '.pantheon', 'delegations'))
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/** Resolve the directory where the job board writes delegation md reports.
|
|
1120
|
+
* The board writes `.pantheon/delegations` RELATIVE to the server cwd,
|
|
1121
|
+
* which the TUI exposes as `TuiState.path.directory`. `project` does NOT
|
|
1122
|
+
* exist on `TuiState.path` (the old `state?.project ?? state?.worktree`
|
|
1123
|
+
* resolution was always undefined for the first term) and `worktree` is
|
|
1124
|
+
* `/` when there is no git (e.g. the sandbox test project) — a root of
|
|
1125
|
+
* `''` or `'/'` must fall back to `process.cwd()`. */
|
|
1126
|
+
export function resolveDelegationsDir(
|
|
1127
|
+
state: { directory?: string; worktree?: string } | undefined,
|
|
1128
|
+
cwd = process.cwd(),
|
|
1129
|
+
): string {
|
|
1130
|
+
const root = state?.directory ?? state?.worktree ?? ''
|
|
1131
|
+
if (root === '' || root === '/') return join(cwd, '.pantheon', 'delegations')
|
|
1132
|
+
return join(root, '.pantheon', 'delegations')
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** Project root derived from the delegations dir: `<root>/.pantheon/delegations`
|
|
1136
|
+
* → `<root>`. Used to point the panel logger at the REAL hooks.log — passing
|
|
1137
|
+
* the delegations dir (or its dirname) directly made createTuiLogger append
|
|
1138
|
+
* to `<root>/.pantheon/.pantheon/logs/hooks.log`, a nested empty dir the real
|
|
1139
|
+
* log never saw. Pure — no I/O. */
|
|
1140
|
+
export function panelLogDir(delegationsDir: string): string {
|
|
1141
|
+
return dirname(dirname(delegationsDir))
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/** Where the panel logger appends lines: `<projectRoot>/.pantheon/logs/hooks.log`.
|
|
1145
|
+
* Pure — testable without the runtime. */
|
|
1146
|
+
export function tuiLogPath(projectRoot: string): string {
|
|
1147
|
+
return join(projectRoot, '.pantheon', 'logs', 'hooks.log')
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/** Sort delegations: running first, then terminal by recency (updatedAt,
|
|
1151
|
+
* falling back to startedAt, descending). Shared by the md reader and
|
|
1152
|
+
* mergeDelegationSources. */
|
|
1153
|
+
export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry): number {
|
|
1154
|
+
const aRun = a.state === 'running' ? 1 : 0
|
|
1155
|
+
const bRun = b.state === 'running' ? 1 : 0
|
|
1156
|
+
if (aRun !== bRun) return bRun - aRun
|
|
1157
|
+
return (b.updatedAt ?? b.startedAt) - (a.updatedAt ?? a.startedAt)
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/** The list the panel actually renders: running jobs first, then the most
|
|
1161
|
+
* recent terminal reports (capped). Pure — so the history-only panel (no
|
|
1162
|
+
* sessionID) is testable without the TUI runtime. The header count uses the
|
|
1163
|
+
* same "running + recentes" list. */
|
|
1164
|
+
export function visibleDelegationList(
|
|
1165
|
+
all: readonly DelegationEntry[],
|
|
1166
|
+
maxTerminal = 8,
|
|
1167
|
+
): DelegationEntry[] {
|
|
1168
|
+
const running = all.filter((d) => d.state === 'running')
|
|
1169
|
+
const terminal = all.filter((d) => d.state !== 'running').slice(0, maxTerminal)
|
|
1170
|
+
return [...running, ...terminal]
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** Compact elapsed-time label: "5m 12s", "1h 30m", "2d 4h" — ticks every
|
|
1174
|
+
* second for running jobs. */
|
|
1175
|
+
export function fmtElapsed(ms: number): string {
|
|
1176
|
+
const total = Math.max(0, Math.floor(ms / 1000))
|
|
1177
|
+
const days = Math.floor(total / 86_400)
|
|
1178
|
+
const hours = Math.floor((total % 86_400) / 3_600)
|
|
1179
|
+
const minutes = Math.floor((total % 3_600) / 60)
|
|
1180
|
+
const seconds = total % 60
|
|
1181
|
+
if (days > 0) return `${days}d ${hours}h`
|
|
1182
|
+
if (hours > 0) return `${hours}h ${minutes}m`
|
|
1183
|
+
if (minutes > 0) return `${minutes}m ${seconds}s`
|
|
1184
|
+
return `${seconds}s`
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/** Elapsed label for one entry: running → ticks `now - startedAt`, terminal
|
|
1188
|
+
* → fixed `updatedAt - startedAt` (em dash when no finalized timestamp). */
|
|
1189
|
+
export function delegationElapsed(entry: DelegationEntry, now: number): string {
|
|
1190
|
+
if (entry.state === 'running') return fmtElapsed(now - entry.startedAt)
|
|
1191
|
+
return entry.updatedAt !== null ? fmtElapsed(entry.updatedAt - entry.startedAt) : '\u2014'
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/** The activity labels shown by the animated row. Keeping this pure makes the
|
|
1195
|
+
* state machine testable without booting OpenCode's renderer. */
|
|
1196
|
+
export type DelegationActivity =
|
|
1197
|
+
| 'delegating'
|
|
1198
|
+
| 'working'
|
|
1199
|
+
| 'reading'
|
|
1200
|
+
| 'completed'
|
|
1201
|
+
| 'error'
|
|
1202
|
+
| 'cancelled'
|
|
1203
|
+
|
|
1204
|
+
export function delegationActivity(entry: DelegationEntry): DelegationActivity {
|
|
1205
|
+
if (entry.state === 'completed') return 'completed'
|
|
1206
|
+
if (entry.state === 'error') return 'error'
|
|
1207
|
+
if (entry.state === 'cancelled') return 'cancelled'
|
|
1208
|
+
if (entry.read) return 'reading'
|
|
1209
|
+
if (entry.alias.startsWith('live-') && entry.taskID === undefined) return 'delegating'
|
|
1210
|
+
return 'working'
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
export function delegationActivityLabel(entry: DelegationEntry): string {
|
|
1214
|
+
switch (delegationActivity(entry)) {
|
|
1215
|
+
case 'delegating':
|
|
1216
|
+
return 'DELEGATING'
|
|
1217
|
+
case 'working':
|
|
1218
|
+
return 'WORKING'
|
|
1219
|
+
case 'reading':
|
|
1220
|
+
return 'READING RESULT'
|
|
1221
|
+
case 'completed':
|
|
1222
|
+
return entry.timedOut ? 'DONE (TIMED OUT)' : 'DONE'
|
|
1223
|
+
case 'error':
|
|
1224
|
+
return 'ERROR'
|
|
1225
|
+
default:
|
|
1226
|
+
return 'CANCELLED'
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const DELEGATION_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
1231
|
+
|
|
1232
|
+
/** Return a deterministic spinner frame. The View ticks this every 140ms. */
|
|
1233
|
+
export function delegationSpinnerFrame(now: number): string {
|
|
1234
|
+
const index = Math.floor(Math.max(0, now) / 140) % DELEGATION_SPINNER_FRAMES.length
|
|
1235
|
+
return DELEGATION_SPINNER_FRAMES[index] ?? DELEGATION_SPINNER_FRAMES[0]
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/** Merge the immediate tool-event channel into the child-session channel.
|
|
1239
|
+
*
|
|
1240
|
+
* Children remain the durable source, while live entries make a delegation
|
|
1241
|
+
* visible before the child API/report catches up. A finalized md entry wins
|
|
1242
|
+
* over a stale live entry; a child-only row is upgraded with live agent,
|
|
1243
|
+
* alias, phase and timestamps. */
|
|
1244
|
+
export function mergeChildDelegationSources(
|
|
1245
|
+
children: readonly DelegationEntry[],
|
|
1246
|
+
live: readonly LiveDelegationEntry[],
|
|
1247
|
+
): DelegationEntry[] {
|
|
1248
|
+
const result = [...children]
|
|
1249
|
+
const byTask = new Map<string, number>()
|
|
1250
|
+
const bySessionAlias = new Map<string, number>()
|
|
1251
|
+
const key = (sessionID: string, alias: string): string => `${sessionID}\u0000${alias}`
|
|
1252
|
+
|
|
1253
|
+
result.forEach((entry, index) => {
|
|
1254
|
+
if (entry.taskID !== undefined) byTask.set(entry.taskID, index)
|
|
1255
|
+
bySessionAlias.set(key(entry.sessionID, entry.alias), index)
|
|
1256
|
+
})
|
|
1257
|
+
|
|
1258
|
+
for (const liveEntry of live) {
|
|
1259
|
+
const incoming = toDelegationEntry(liveEntry)
|
|
1260
|
+
const index =
|
|
1261
|
+
(incoming.taskID !== undefined ? byTask.get(incoming.taskID) : undefined) ??
|
|
1262
|
+
(incoming.alias !== ''
|
|
1263
|
+
? bySessionAlias.get(key(incoming.sessionID, incoming.alias))
|
|
1264
|
+
: undefined)
|
|
1265
|
+
|
|
1266
|
+
if (index === undefined) {
|
|
1267
|
+
result.push(incoming)
|
|
1268
|
+
const newIndex = result.length - 1
|
|
1269
|
+
if (incoming.taskID !== undefined) byTask.set(incoming.taskID, newIndex)
|
|
1270
|
+
bySessionAlias.set(key(incoming.sessionID, incoming.alias), newIndex)
|
|
1271
|
+
continue
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
const existing = result[index]
|
|
1275
|
+
if (existing === undefined) continue
|
|
1276
|
+
const finalized = existing.source === 'md' && existing.state !== 'running'
|
|
1277
|
+
if (finalized) continue
|
|
1278
|
+
result[index] = {
|
|
1279
|
+
...existing,
|
|
1280
|
+
alias: liveEntry.alias !== null ? incoming.alias : existing.alias,
|
|
1281
|
+
sessionID: existing.sessionID || incoming.sessionID,
|
|
1282
|
+
taskID: existing.taskID ?? incoming.taskID,
|
|
1283
|
+
agent: incoming.agent !== 'agent' ? incoming.agent : existing.agent,
|
|
1284
|
+
description: incoming.description !== '' ? incoming.description : existing.description,
|
|
1285
|
+
state: incoming.state,
|
|
1286
|
+
startedAt: Math.min(existing.startedAt, incoming.startedAt),
|
|
1287
|
+
updatedAt: incoming.updatedAt,
|
|
1288
|
+
read: incoming.read,
|
|
1289
|
+
source: 'live',
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
result.sort(compareDelegationEntries)
|
|
1294
|
+
return result
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/* ─── Live tool-call lifecycle (agent-sidebar pattern) ─────
|
|
1298
|
+
* Source of truth for jobs born while opencode is open. The TUI event bus
|
|
1299
|
+
* (api.event.on) delivers `message.part.updated` for every part change;
|
|
1300
|
+
* we only care about tool parts whose `tool` is pantheon_delegate (job
|
|
1301
|
+
* launch) or pantheon_delegation_read (blocking until terminal → closes
|
|
1302
|
+
* the entry). Shape-adapted from the SDK v2 ToolPart but duck-typed so the
|
|
1303
|
+
* pure helpers are testable without the SDK. */
|
|
1304
|
+
|
|
1305
|
+
/** Duck-typed subset of a tool part (SDK v2 `ToolPart` / `ToolState`). */
|
|
1306
|
+
export type DelegationToolPart = {
|
|
1307
|
+
id?: string
|
|
1308
|
+
callID?: string
|
|
1309
|
+
sessionID?: string
|
|
1310
|
+
type?: string
|
|
1311
|
+
tool?: string
|
|
1312
|
+
state?: {
|
|
1313
|
+
status?: string
|
|
1314
|
+
input?: Record<string, unknown>
|
|
1315
|
+
output?: string
|
|
1316
|
+
error?: string
|
|
1317
|
+
time?: { start?: number; end?: number }
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/** One live delegation tracked in-memory, keyed by the delegate callID. */
|
|
1322
|
+
export type LiveDelegationEntry = {
|
|
1323
|
+
/** Tool call id of the pantheon_delegate part (stable across events). */
|
|
1324
|
+
callID: string
|
|
1325
|
+
/** Part id (for message.part.removed cleanup). */
|
|
1326
|
+
partID: string
|
|
1327
|
+
/** Parent session the delegation was launched from. */
|
|
1328
|
+
sessionID: string
|
|
1329
|
+
tool: 'pantheon_delegate' | 'pantheon_delegation_read'
|
|
1330
|
+
/** Agent name (from the delegate input args). */
|
|
1331
|
+
agent: string
|
|
1332
|
+
description: string
|
|
1333
|
+
/** Known after the delegate tool completes (parsed from its output). */
|
|
1334
|
+
alias: string | null
|
|
1335
|
+
/** Child session id (parsed from the delegate output). */
|
|
1336
|
+
taskID: string | null
|
|
1337
|
+
state: 'running' | 'completed' | 'error' | 'cancelled'
|
|
1338
|
+
startedAt: number
|
|
1339
|
+
updatedAt: number | null
|
|
1340
|
+
/** True once a pantheon_delegation_read for this job has been observed. */
|
|
1341
|
+
read: boolean
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/** Result of parsing one tool part into lifecycle-relevant fields. */
|
|
1345
|
+
export type ParsedDelegationToolPart = {
|
|
1346
|
+
callID: string
|
|
1347
|
+
partID: string
|
|
1348
|
+
sessionID: string
|
|
1349
|
+
tool: 'pantheon_delegate' | 'pantheon_delegation_read'
|
|
1350
|
+
/** null for read parts (no agent arg — the id targets an existing job). */
|
|
1351
|
+
agent: string | null
|
|
1352
|
+
description: string
|
|
1353
|
+
status: 'pending' | 'running' | 'completed' | 'error'
|
|
1354
|
+
alias: string | null
|
|
1355
|
+
taskID: string | null
|
|
1356
|
+
startedAt: number
|
|
1357
|
+
endAt: number | null
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/** Alias in the delegate output: "Delegated to apollo: [apo-1] (task …)". */
|
|
1361
|
+
const DELEGATE_ALIAS_PATTERN = /\[([a-z]{2,8}-\d+)\]/i
|
|
1362
|
+
/** Child task id in the delegate output: "(task ses_child_9)". */
|
|
1363
|
+
const DELEGATE_TASKID_PATTERN = /\(task\s+([a-z0-9_]+)\)/i
|
|
1364
|
+
/** Plain alias, as passed to pantheon_delegation_read input.id: "apo-1". */
|
|
1365
|
+
const READ_ALIAS_PATTERN = /^[a-z]{2,8}-\d+$/i
|
|
1366
|
+
|
|
1367
|
+
/** Extract the tool name + args from a `message.part.updated` part and
|
|
1368
|
+
* reduce it to what the panel needs. Returns null for anything that is
|
|
1369
|
+
* not a pantheon delegation tool part (or is missing its callID). */
|
|
1370
|
+
export function parseDelegationToolPart(
|
|
1371
|
+
part: DelegationToolPart,
|
|
1372
|
+
now = Date.now(),
|
|
1373
|
+
): ParsedDelegationToolPart | null {
|
|
1374
|
+
if (part.type !== 'tool') return null
|
|
1375
|
+
if (part.tool !== 'pantheon_delegate' && part.tool !== 'pantheon_delegation_read') return null
|
|
1376
|
+
const callID = part.callID
|
|
1377
|
+
if (callID === undefined || callID === '') return null
|
|
1378
|
+
const sessionID = part.sessionID ?? ''
|
|
1379
|
+
const state = part.state ?? {}
|
|
1380
|
+
const status = state.status ?? 'pending'
|
|
1381
|
+
if (status !== 'pending' && status !== 'running' && status !== 'completed' && status !== 'error')
|
|
1382
|
+
return null
|
|
1383
|
+
const input = state.input ?? {}
|
|
1384
|
+
const startedAt = state.time?.start ?? now
|
|
1385
|
+
const endAt = state.time?.end ?? null
|
|
1386
|
+
|
|
1387
|
+
if (part.tool === 'pantheon_delegation_read') {
|
|
1388
|
+
const target = typeof input.id === 'string' ? input.id : null
|
|
1389
|
+
let alias: string | null = null
|
|
1390
|
+
let taskID: string | null = null
|
|
1391
|
+
if (target !== null) {
|
|
1392
|
+
if (READ_ALIAS_PATTERN.test(target)) alias = target
|
|
1393
|
+
else if (target.startsWith('ses_')) taskID = target // raw child session id
|
|
1394
|
+
}
|
|
1395
|
+
return {
|
|
1396
|
+
callID,
|
|
1397
|
+
partID: part.id ?? '',
|
|
1398
|
+
sessionID,
|
|
1399
|
+
tool: 'pantheon_delegation_read',
|
|
1400
|
+
agent: null,
|
|
1401
|
+
description: '',
|
|
1402
|
+
status,
|
|
1403
|
+
alias,
|
|
1404
|
+
taskID,
|
|
1405
|
+
startedAt,
|
|
1406
|
+
endAt,
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
const agent = typeof input.agent === 'string' ? input.agent : 'agent'
|
|
1411
|
+
const description = typeof input.description === 'string' ? input.description : ''
|
|
1412
|
+
// The alias/taskID only exist once the delegate tool COMPLETES (they are
|
|
1413
|
+
// returned in its output); a running/pending part has neither.
|
|
1414
|
+
let alias: string | null = null
|
|
1415
|
+
let taskID: string | null = null
|
|
1416
|
+
if (status === 'completed') {
|
|
1417
|
+
const output = state.output ?? ''
|
|
1418
|
+
alias = DELEGATE_ALIAS_PATTERN.exec(output)?.[1] ?? null
|
|
1419
|
+
taskID = DELEGATE_TASKID_PATTERN.exec(output)?.[1] ?? null
|
|
1420
|
+
}
|
|
1421
|
+
return {
|
|
1422
|
+
callID,
|
|
1423
|
+
partID: part.id ?? '',
|
|
1424
|
+
sessionID,
|
|
1425
|
+
tool: 'pantheon_delegate',
|
|
1426
|
+
agent,
|
|
1427
|
+
description,
|
|
1428
|
+
status,
|
|
1429
|
+
alias,
|
|
1430
|
+
taskID,
|
|
1431
|
+
startedAt,
|
|
1432
|
+
endAt,
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/** Find a live entry by alias or taskID (read parts resolve by id). */
|
|
1437
|
+
function findLiveByTarget(
|
|
1438
|
+
map: Map<string, LiveDelegationEntry>,
|
|
1439
|
+
alias: string | null,
|
|
1440
|
+
taskID: string | null,
|
|
1441
|
+
): LiveDelegationEntry | undefined {
|
|
1442
|
+
if (alias === null && taskID === null) return undefined
|
|
1443
|
+
for (const entry of map.values()) {
|
|
1444
|
+
if (alias !== null && entry.alias === alias) return entry
|
|
1445
|
+
if (taskID !== null && entry.taskID === taskID) return entry
|
|
1446
|
+
}
|
|
1447
|
+
return undefined
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/** Apply one tool part to the live map. Returns true when the map changed.
|
|
1451
|
+
* Pure w.r.t. I/O — only mutates `map`. */
|
|
1452
|
+
export function reduceDelegationToolPart(
|
|
1453
|
+
map: Map<string, LiveDelegationEntry>,
|
|
1454
|
+
part: DelegationToolPart,
|
|
1455
|
+
now = Date.now(),
|
|
1456
|
+
): boolean {
|
|
1457
|
+
const parsed = parseDelegationToolPart(part, now)
|
|
1458
|
+
if (parsed === null) return false
|
|
1459
|
+
|
|
1460
|
+
// Read tool: never creates a row — it resolves a delegation by id. It
|
|
1461
|
+
// marks the target `read` and (because it blocks until terminal) closes
|
|
1462
|
+
// the entry on completed/error with its end timestamp.
|
|
1463
|
+
if (parsed.tool === 'pantheon_delegation_read') {
|
|
1464
|
+
const target = findLiveByTarget(map, parsed.alias, parsed.taskID)
|
|
1465
|
+
if (target === undefined) return false
|
|
1466
|
+
let changed = false
|
|
1467
|
+
if (!target.read) {
|
|
1468
|
+
target.read = true
|
|
1469
|
+
changed = true
|
|
1470
|
+
}
|
|
1471
|
+
if (parsed.status === 'completed' && target.state === 'running') {
|
|
1472
|
+
target.state = 'completed'
|
|
1473
|
+
target.updatedAt = parsed.endAt ?? now
|
|
1474
|
+
changed = true
|
|
1475
|
+
} else if (parsed.status === 'error' && target.state === 'running') {
|
|
1476
|
+
target.state = 'error'
|
|
1477
|
+
target.updatedAt = parsed.endAt ?? now
|
|
1478
|
+
changed = true
|
|
1479
|
+
}
|
|
1480
|
+
return changed
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// Delegate tool: pending/running → job launched (running). A COMPLETED
|
|
1484
|
+
// delegate tool only means the job was registered — it stays running and
|
|
1485
|
+
// we pick up alias + taskID from the output.
|
|
1486
|
+
const existing = map.get(parsed.callID)
|
|
1487
|
+
if (parsed.status === 'error') {
|
|
1488
|
+
if (existing !== undefined && existing.state === 'error' && existing.updatedAt === parsed.endAt)
|
|
1489
|
+
return false
|
|
1490
|
+
map.set(parsed.callID, {
|
|
1491
|
+
callID: parsed.callID,
|
|
1492
|
+
partID: parsed.partID,
|
|
1493
|
+
sessionID: parsed.sessionID,
|
|
1494
|
+
tool: 'pantheon_delegate',
|
|
1495
|
+
agent: parsed.agent ?? 'agent',
|
|
1496
|
+
description: parsed.description,
|
|
1497
|
+
alias: existing?.alias ?? null,
|
|
1498
|
+
taskID: existing?.taskID ?? null,
|
|
1499
|
+
state: 'error',
|
|
1500
|
+
startedAt: existing?.startedAt ?? parsed.startedAt,
|
|
1501
|
+
updatedAt: parsed.endAt ?? now,
|
|
1502
|
+
read: existing?.read ?? false,
|
|
1503
|
+
})
|
|
1504
|
+
return true
|
|
1505
|
+
}
|
|
1506
|
+
if (existing === undefined) {
|
|
1507
|
+
map.set(parsed.callID, {
|
|
1508
|
+
callID: parsed.callID,
|
|
1509
|
+
partID: parsed.partID,
|
|
1510
|
+
sessionID: parsed.sessionID,
|
|
1511
|
+
tool: 'pantheon_delegate',
|
|
1512
|
+
agent: parsed.agent ?? 'agent',
|
|
1513
|
+
description: parsed.description,
|
|
1514
|
+
alias: parsed.alias,
|
|
1515
|
+
taskID: parsed.taskID,
|
|
1516
|
+
state: 'running',
|
|
1517
|
+
startedAt: parsed.startedAt,
|
|
1518
|
+
updatedAt: null,
|
|
1519
|
+
read: false,
|
|
1520
|
+
})
|
|
1521
|
+
return true
|
|
1522
|
+
}
|
|
1523
|
+
// Existing entry: absorb newly-discovered alias/taskID/agent/description.
|
|
1524
|
+
let changed = false
|
|
1525
|
+
if (parsed.alias !== null && existing.alias !== parsed.alias) {
|
|
1526
|
+
existing.alias = parsed.alias
|
|
1527
|
+
changed = true
|
|
1528
|
+
}
|
|
1529
|
+
if (parsed.taskID !== null && existing.taskID !== parsed.taskID) {
|
|
1530
|
+
existing.taskID = parsed.taskID
|
|
1531
|
+
changed = true
|
|
1532
|
+
}
|
|
1533
|
+
if (parsed.agent !== null && existing.agent !== parsed.agent) {
|
|
1534
|
+
existing.agent = parsed.agent
|
|
1535
|
+
changed = true
|
|
1536
|
+
}
|
|
1537
|
+
if (parsed.description !== '' && existing.description !== parsed.description) {
|
|
1538
|
+
existing.description = parsed.description
|
|
1539
|
+
changed = true
|
|
1540
|
+
}
|
|
1541
|
+
if (existing.partID === '' && parsed.partID !== '') {
|
|
1542
|
+
existing.partID = parsed.partID
|
|
1543
|
+
changed = true
|
|
1544
|
+
}
|
|
1545
|
+
return changed
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
/** Remove a live entry by part id (message.part.removed) or call id.
|
|
1549
|
+
* Returns true when something was removed. */
|
|
1550
|
+
export function removeDelegationEntry(
|
|
1551
|
+
map: Map<string, LiveDelegationEntry>,
|
|
1552
|
+
partIDOrCallID: string,
|
|
1553
|
+
): boolean {
|
|
1554
|
+
if (map.delete(partIDOrCallID)) return true
|
|
1555
|
+
for (const [key, entry] of map) {
|
|
1556
|
+
if (entry.partID === partIDOrCallID) {
|
|
1557
|
+
map.delete(key)
|
|
1558
|
+
return true
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
return false
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/** Collect pantheon delegation tool parts from a session's messages.
|
|
1565
|
+
* Messages may carry their parts inline (duck-typed `msg.parts`); when
|
|
1566
|
+
* they don't, the optional `getParts(messageID)` callback is used (the TUI
|
|
1567
|
+
* SDK exposes `api.state.part(messageID)`). Pure w.r.t. I/O — used by the
|
|
1568
|
+
* mount re-scan to re-seed the live map after compaction/attach. */
|
|
1569
|
+
export function collectDelegationToolParts(
|
|
1570
|
+
messages: readonly { id?: string; parts?: unknown[] }[] | undefined,
|
|
1571
|
+
getParts?: (messageID: string) => readonly unknown[] | undefined,
|
|
1572
|
+
): DelegationToolPart[] {
|
|
1573
|
+
const out: DelegationToolPart[] = []
|
|
1574
|
+
for (const msg of messages ?? []) {
|
|
1575
|
+
let parts: readonly unknown[] | undefined
|
|
1576
|
+
if (Array.isArray(msg?.parts)) {
|
|
1577
|
+
parts = msg.parts
|
|
1578
|
+
} else if (msg?.id !== undefined && typeof getParts === 'function') {
|
|
1579
|
+
parts = getParts(msg.id)
|
|
1580
|
+
}
|
|
1581
|
+
if (!parts) continue
|
|
1582
|
+
for (const raw of parts) {
|
|
1583
|
+
const part = raw as DelegationToolPart
|
|
1584
|
+
if (
|
|
1585
|
+
part?.type === 'tool' &&
|
|
1586
|
+
(part.tool === 'pantheon_delegate' || part.tool === 'pantheon_delegation_read')
|
|
1587
|
+
) {
|
|
1588
|
+
out.push(part)
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
return out
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/** Apply a batch of tool parts (in message order) to the live map. Used on
|
|
1596
|
+
* mount to re-seed entries that `message.part.removed` (compaction) wiped,
|
|
1597
|
+
* from the session's existing tool parts. Returns how many parts changed
|
|
1598
|
+
* the map (0 on the second identical seed — idempotent, no extra bumps). */
|
|
1599
|
+
export function seedLiveDelegationMap(
|
|
1600
|
+
map: Map<string, LiveDelegationEntry>,
|
|
1601
|
+
parts: readonly DelegationToolPart[],
|
|
1602
|
+
now = Date.now(),
|
|
1603
|
+
): number {
|
|
1604
|
+
let changed = 0
|
|
1605
|
+
for (const part of parts) {
|
|
1606
|
+
if (reduceDelegationToolPart(map, part, now)) changed++
|
|
1607
|
+
}
|
|
1608
|
+
return changed
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
/** Convert a live entry into the shared display shape. Alias falls back to
|
|
1612
|
+
* a `live-<callID>` prefix while the delegate tool has not completed yet. */
|
|
1613
|
+
export function toDelegationEntry(live: LiveDelegationEntry): DelegationEntry {
|
|
1614
|
+
return {
|
|
1615
|
+
alias: live.alias ?? `live-${live.callID.slice(0, 8)}`,
|
|
1616
|
+
sessionID: live.sessionID,
|
|
1617
|
+
...(live.taskID !== null ? { taskID: live.taskID } : {}),
|
|
1618
|
+
agent: live.agent,
|
|
1619
|
+
state: live.state,
|
|
1620
|
+
startedAt: live.startedAt,
|
|
1621
|
+
updatedAt: live.updatedAt,
|
|
1622
|
+
timedOut: false,
|
|
1623
|
+
description: live.description,
|
|
1624
|
+
read: live.read,
|
|
1625
|
+
source: 'live',
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/** Combine the live channel with the md (historical) channel into one
|
|
1630
|
+
* display list. Dedupes by (sessionID, alias) — aliases are per-parent-
|
|
1631
|
+
* session, so the same alias in different sessions stays separate. A
|
|
1632
|
+
* terminal md entry is authoritative over a live running entry for the
|
|
1633
|
+
* same job (it carries Finalized/timedOut/cancelled from finalize). */
|
|
1634
|
+
export function mergeDelegationSources(
|
|
1635
|
+
live: readonly LiveDelegationEntry[],
|
|
1636
|
+
md: readonly DelegationEntry[],
|
|
1637
|
+
): DelegationEntry[] {
|
|
1638
|
+
const keyOf = (sessionID: string, alias: string): string => `${sessionID}\u0000${alias}`
|
|
1639
|
+
const byKey = new Map<string, DelegationEntry>()
|
|
1640
|
+
for (const m of md) {
|
|
1641
|
+
const key = keyOf(m.sessionID, m.alias)
|
|
1642
|
+
const existing = byKey.get(key)
|
|
1643
|
+
if (existing === undefined || (existing.state === 'running' && m.state !== 'running'))
|
|
1644
|
+
byKey.set(key, m)
|
|
1645
|
+
}
|
|
1646
|
+
const aliasless: DelegationEntry[] = []
|
|
1647
|
+
for (const l of live) {
|
|
1648
|
+
const e = toDelegationEntry(l)
|
|
1649
|
+
if (l.alias === null) {
|
|
1650
|
+
aliasless.push(e)
|
|
1651
|
+
continue
|
|
1652
|
+
}
|
|
1653
|
+
const key = keyOf(l.sessionID, l.alias)
|
|
1654
|
+
const mdEntry = byKey.get(key)
|
|
1655
|
+
if (mdEntry !== undefined && mdEntry.state !== 'running') continue // md terminal wins
|
|
1656
|
+
byKey.set(key, e)
|
|
1657
|
+
}
|
|
1658
|
+
const all = [...aliasless, ...byKey.values()]
|
|
1659
|
+
all.sort(compareDelegationEntries)
|
|
1660
|
+
return all
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
/* ─── Children channel (primary source, delegations-sidebar pattern) ────
|
|
1664
|
+
* `api.client.session.children` returns the child sessions of the current
|
|
1665
|
+
* session — every pantheon_delegate spawns one (parentID = caller). The
|
|
1666
|
+
* pure helpers here turn those children + the md reports into the display
|
|
1667
|
+
* list, and navigate to a child session on click. */
|
|
1668
|
+
|
|
1669
|
+
/* ─── Current-session resolution + path guard (placeholder regression) ──
|
|
1670
|
+
* The sidebar forwarded `props.session_id` verbatim into
|
|
1671
|
+
* `session.children({ path: { id } })`. When the TUI runtime renders the
|
|
1672
|
+
* sidebar without a focused session it leaves the route template
|
|
1673
|
+
* UNSUBSTITUTED — the literal "{sessionID}" string — which opencode's server
|
|
1674
|
+
* rejected every poll (SchemaError: Expected a string starting with "ses",
|
|
1675
|
+
* got "%7BsessionID%7D"), ~1 error/s from the 1s safety poll, and the failed
|
|
1676
|
+
* call fail-opened into "Delegations (0)".
|
|
1677
|
+
*
|
|
1678
|
+
* Contract: resolution NEVER yields a placeholder — an invalid/absent id is
|
|
1679
|
+
* null, and a null resolution means the children fetch is SKIPPED (empty
|
|
1680
|
+
* panel, ZERO errors). Sources, in order:
|
|
1681
|
+
* 1. the sidebar_content slot prop (`session_id`);
|
|
1682
|
+
* 2. `api.state.sessionID` (duck-typed: the typed TuiState has no such
|
|
1683
|
+
* field, but the runtime state is a superset — the delegations-sidebar
|
|
1684
|
+
* reference reads the current session from api.state);
|
|
1685
|
+
* 3. `api.route.current.params.sessionID` (typed: TuiRouteCurrent's
|
|
1686
|
+
* `{ name: 'session', params: { sessionID } }` variant).
|
|
1687
|
+
* Each source passes the same server-aligned validity check (non-empty
|
|
1688
|
+
* string starting with "ses"), so a placeholder or garbage at any position
|
|
1689
|
+
* falls through to the next source instead of hitting the wire. */
|
|
1690
|
+
|
|
1691
|
+
/** Server-aligned session id validity: opencode rejects anything not starting
|
|
1692
|
+
* with "ses" (SchemaError). This deliberately mirrors that exact contract —
|
|
1693
|
+
* nothing stricter, nothing looser — so a template placeholder ("{sessionID}"),
|
|
1694
|
+
* its URL-encoded form ("%7BsessionID%7D", what the server reported in the
|
|
1695
|
+
* schema error), an empty/undefined value, or a foreign id (e.g. "wrk_") can
|
|
1696
|
+
* never reach a path and error-spam the log. Confirmed: "{sessionID}" starts
|
|
1697
|
+
* with "{" and "%7BsessionID%7D" with "%" — both fail startsWith("ses"), so
|
|
1698
|
+
* the placeholder is rejected WITHOUT an explicit denylist (covered by tests). */
|
|
1699
|
+
export function isValidSessionId(id: unknown): id is string {
|
|
1700
|
+
return typeof id === 'string' && id.startsWith('ses')
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/** Sources the sidebar can resolve the CURRENT session id from. Duck-typed
|
|
1704
|
+
* subsets of TuiPluginApi / TuiState / TuiRouteCurrent so the helper stays
|
|
1705
|
+
* pure and testable without the TUI runtime. */
|
|
1706
|
+
export type TuiSessionSources = {
|
|
1707
|
+
/** sidebar_content slot prop (`session_id`). */
|
|
1708
|
+
sessionID?: string | null
|
|
1709
|
+
api?: {
|
|
1710
|
+
/** Runtime state superset — may expose the current session id. */
|
|
1711
|
+
state?: { sessionID?: unknown }
|
|
1712
|
+
/** Typed route: { name: 'session', params: { sessionID } } when in one. */
|
|
1713
|
+
route?: { current?: { name?: string; params?: Record<string, unknown> } }
|
|
1714
|
+
} | null
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
/** Resolve the current session id for the sidebar. Order: slot prop →
|
|
1718
|
+
* api.state.sessionID (runtime superset) → api.route.current.params.sessionID
|
|
1719
|
+
* (typed route). Every source is validated; invalid/absent → next source.
|
|
1720
|
+
* NEVER returns a placeholder or non-ses id. Null → callers MUST skip the
|
|
1721
|
+
* fetch (empty panel, zero errors). Pure — no I/O, no runtime required. */
|
|
1722
|
+
export function resolveCurrentSessionID(sources: TuiSessionSources): string | null {
|
|
1723
|
+
const candidates: unknown[] = [
|
|
1724
|
+
sources?.sessionID,
|
|
1725
|
+
sources?.api?.state?.sessionID,
|
|
1726
|
+
sources?.api?.route?.current?.params?.sessionID,
|
|
1727
|
+
]
|
|
1728
|
+
for (const candidate of candidates) {
|
|
1729
|
+
if (isValidSessionId(candidate)) return candidate
|
|
1730
|
+
}
|
|
1731
|
+
return null
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
/** THE single choke point for every `session.children` / session-API path.
|
|
1735
|
+
* Returns `{ path: { id } }` ONLY for a server-valid session id; returns
|
|
1736
|
+
* null for anything else (placeholder, empty, foreign id) so the caller
|
|
1737
|
+
* skips the call entirely instead of sending an unsubstituted placeholder
|
|
1738
|
+
* (the "%7BsessionID%7D" regression). Every session-API call site MUST go
|
|
1739
|
+
* through this function (enforced by the source-scan test in
|
|
1740
|
+
* tests/pantheon/tui-delegations.test.ts). */
|
|
1741
|
+
export function safeSessionPath(id: unknown): { path: { id: string } } | null {
|
|
1742
|
+
if (!isValidSessionId(id)) return null
|
|
1743
|
+
return { path: { id } }
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
/** Build the `session.children` path ONLY from a validated session id.
|
|
1747
|
+
* Delegates to {@link safeSessionPath} — the single choke point. Returns
|
|
1748
|
+
* null for null/invalid ids so the caller skips the fetch instead of
|
|
1749
|
+
* sending an unsubstituted placeholder (the "%7BsessionID%7D" regression). */
|
|
1750
|
+
export function buildChildrenPath(id: string | null | undefined): { path: { id: string } } | null {
|
|
1751
|
+
return safeSessionPath(id)
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
/** Duck-typed subset of a child Session (+ its live status type). */
|
|
1755
|
+
export type ChildDelegationLike = {
|
|
1756
|
+
/** Child session id (= board task id). */
|
|
1757
|
+
id: string
|
|
1758
|
+
/** Session title — the delegate's description or prompt prefix. */
|
|
1759
|
+
title?: string
|
|
1760
|
+
/** Agent name when the child session carries one (duck-typed; native
|
|
1761
|
+
* task() children may expose it, board reports always do). */
|
|
1762
|
+
agent?: string
|
|
1763
|
+
/** Status type from api.state.session.status: 'busy' | 'retry' | 'idle',
|
|
1764
|
+
* or undefined when the status API is unavailable. */
|
|
1765
|
+
status?: string
|
|
1766
|
+
time?: {
|
|
1767
|
+
created?: number
|
|
1768
|
+
updated?: number
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
/** Map a child status type to a display state. busy/retry → running
|
|
1773
|
+
* (the child is actively working), idle → completed, unknown → running
|
|
1774
|
+
* (fail-open: a freshly-seen child is assumed active; the 1s poll + md
|
|
1775
|
+
* correct it as soon as terminal data exists). */
|
|
1776
|
+
export function childStatusToState(status: string | undefined): 'running' | 'completed' {
|
|
1777
|
+
if (status === 'idle') return 'completed'
|
|
1778
|
+
return 'running' // busy, retry, or unknown
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
/** Row tag for a delegation entry: `[task]` for native task() children
|
|
1782
|
+
* (source 'children-only' — no board report), `[<alias>]` for board rows
|
|
1783
|
+
* ([apo-1]). The panel renders the tag with a distinct style so native
|
|
1784
|
+
* task() children are visually separable from pantheon_delegate jobs. */
|
|
1785
|
+
export function delegationTag(entry: DelegationEntry): string {
|
|
1786
|
+
return entry.source === 'children-only' ? '[task]' : `[${entry.alias}]`
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/** Turn child sessions (PRIMARY) enriched with md reports into the display
|
|
1790
|
+
* list. One entry per child id (duplicates across re-fetches collapse).
|
|
1791
|
+
* The md report is matched by `Task ID` (== child.id) and supplies alias,
|
|
1792
|
+
* agent, description, terminal state and duration. A child without a
|
|
1793
|
+
* report still renders: description from its title, agent from the child
|
|
1794
|
+
* itself (fallback 'agent'), state derived from its status, startedAt from
|
|
1795
|
+
* time.created. A report-less child is a NATIVE task() child (every
|
|
1796
|
+
* child of the current session — pantheon_delegate OR the native `task()`
|
|
1797
|
+
* tool — carries parentID = caller), so it gets source 'children-only'
|
|
1798
|
+
* and the `[task]` tag instead of a board alias.
|
|
1799
|
+
* Terminal md state wins over the derived state; a running md defers to
|
|
1800
|
+
* the child's live status. Sorted running-first (compareDelegationEntries).
|
|
1801
|
+
* Pure — no I/O. */
|
|
1802
|
+
export function childrenToDelegationEntries(
|
|
1803
|
+
children: readonly ChildDelegationLike[] | undefined,
|
|
1804
|
+
md: readonly DelegationEntry[],
|
|
1805
|
+
now = Date.now(),
|
|
1806
|
+
): DelegationEntry[] {
|
|
1807
|
+
const byTaskID = new Map<string, DelegationEntry>()
|
|
1808
|
+
for (const m of md) {
|
|
1809
|
+
if (m.taskID !== undefined && !byTaskID.has(m.taskID)) byTaskID.set(m.taskID, m)
|
|
1810
|
+
}
|
|
1811
|
+
const out: DelegationEntry[] = []
|
|
1812
|
+
const seen = new Set<string>()
|
|
1813
|
+
for (const child of children ?? []) {
|
|
1814
|
+
if (child.id === '' || seen.has(child.id)) continue
|
|
1815
|
+
seen.add(child.id)
|
|
1816
|
+
const mdEntry = byTaskID.get(child.id)
|
|
1817
|
+
const state =
|
|
1818
|
+
mdEntry !== undefined && mdEntry.state !== 'running'
|
|
1819
|
+
? mdEntry.state
|
|
1820
|
+
: childStatusToState(child.status)
|
|
1821
|
+
out.push({
|
|
1822
|
+
alias: mdEntry?.alias ?? 'task',
|
|
1823
|
+
sessionID: mdEntry?.sessionID ?? '',
|
|
1824
|
+
taskID: child.id,
|
|
1825
|
+
agent: mdEntry?.agent ?? child.agent ?? 'agent',
|
|
1826
|
+
state,
|
|
1827
|
+
startedAt: mdEntry?.startedAt ?? child.time?.created ?? now,
|
|
1828
|
+
updatedAt: mdEntry?.updatedAt ?? (state === 'running' ? null : (child.time?.updated ?? null)),
|
|
1829
|
+
timedOut: mdEntry?.timedOut ?? false,
|
|
1830
|
+
description:
|
|
1831
|
+
mdEntry !== undefined && mdEntry.description !== ''
|
|
1832
|
+
? mdEntry.description
|
|
1833
|
+
: (child.title ?? ''),
|
|
1834
|
+
source: mdEntry !== undefined ? 'md' : 'children-only',
|
|
1835
|
+
})
|
|
1836
|
+
}
|
|
1837
|
+
out.sort(compareDelegationEntries)
|
|
1838
|
+
return out
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
/** Navigate the TUI to a child session (click/Enter on a delegation row).
|
|
1842
|
+
* Returns false when the route API is unavailable or the target id is
|
|
1843
|
+
* missing/placeholder — the row stays inert instead of crashing. Only a
|
|
1844
|
+
* server-valid session id ("ses...") ever reaches the router, so an
|
|
1845
|
+
* unsubstituted "{sessionID}" placeholder can never be routed. */
|
|
1846
|
+
export function navigateToDelegationSession(
|
|
1847
|
+
route: { navigate?: (name: string, params?: Record<string, unknown>) => void } | undefined,
|
|
1848
|
+
taskID: string | undefined,
|
|
1849
|
+
): boolean {
|
|
1850
|
+
if (typeof route?.navigate !== 'function' || !isValidSessionId(taskID)) return false
|
|
1851
|
+
route.navigate('session', { sessionID: taskID })
|
|
1852
|
+
return true
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
/* ─── Silence-by-default panel logger ──────────────────────
|
|
1856
|
+
* Mirrors src/pantheon/logger.ts createPantheonLogger (the TUI plugin is
|
|
1857
|
+
* self-contained — dist/tui.tsx is a raw copy with no relative imports):
|
|
1858
|
+
* every line is ALWAYS appended to `<logDir>/.pantheon/logs/hooks.log` and
|
|
1859
|
+
* echoed to the console ONLY when PANTHEON_HOOKS_LOG is truthy (opencode
|
|
1860
|
+
* renders plugin console output directly into the TUI — the pollution bug).
|
|
1861
|
+
* Best-effort and fire-and-forget: a logging failure must never break the
|
|
1862
|
+
* panel. */
|
|
1863
|
+
type TuiLogger = { info: (message: string, ...args: unknown[]) => void }
|
|
1864
|
+
|
|
1865
|
+
/** Silence-by-default panel logger.
|
|
1866
|
+
* @param projectRoot the PROJECT ROOT — the logger appends to
|
|
1867
|
+
* `<projectRoot>/.pantheon/logs/hooks.log` (tuiLogPath). Passing anything
|
|
1868
|
+
* deeper (e.g. the delegations dir) used to double-nest the path into
|
|
1869
|
+
* `<root>/.pantheon/.pantheon/logs/hooks.log` and never reach the real log;
|
|
1870
|
+
* derive the root with {@link panelLogDir}. */
|
|
1871
|
+
function createTuiLogger(projectRoot: string | undefined, module = 'pantheon-tui'): TuiLogger {
|
|
1872
|
+
const echo = (process.env.PANTHEON_HOOKS_LOG ?? '').trim() !== ''
|
|
1873
|
+
const logPath = tuiLogPath(projectRoot ?? process.cwd())
|
|
1874
|
+
const formatArg = (arg: unknown): string => {
|
|
1875
|
+
if (arg instanceof Error) return arg.stack ?? arg.message
|
|
1876
|
+
if (typeof arg === 'string') return arg
|
|
1877
|
+
try {
|
|
1878
|
+
return JSON.stringify(arg)
|
|
1879
|
+
} catch {
|
|
1880
|
+
return String(arg)
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
const write = (message: string, args: unknown[]) => {
|
|
1884
|
+
const lines = [message, ...args.map(formatArg)]
|
|
1885
|
+
.join(' ')
|
|
1886
|
+
.split('\n')
|
|
1887
|
+
.map((p) => p.trim())
|
|
1888
|
+
.filter((p) => p !== '')
|
|
1889
|
+
if (lines.length === 0) return
|
|
1890
|
+
void (async () => {
|
|
1891
|
+
try {
|
|
1892
|
+
await mkdir(dirname(logPath), { recursive: true })
|
|
1893
|
+
const stamp = new Date().toISOString()
|
|
1894
|
+
await appendFile(
|
|
1895
|
+
logPath,
|
|
1896
|
+
`${lines.map((l) => `[${stamp}] [${module}] ${l}`).join('\n')}\n`,
|
|
1897
|
+
'utf8',
|
|
1898
|
+
)
|
|
1899
|
+
} catch {
|
|
1900
|
+
/* best-effort file log — never break the panel over logging */
|
|
1901
|
+
}
|
|
1902
|
+
})()
|
|
1903
|
+
if (echo) {
|
|
1904
|
+
for (const line of lines) console.log(`[${module}] ${line}`)
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
return { info: (message, ...args) => write(message, args) }
|
|
1908
|
+
}
|
|
1909
|
+
|
|
876
1910
|
/* ─── Session Row (single session with live status indicator) ─ */
|
|
877
1911
|
|
|
878
1912
|
function SessionRow(props: { api: TuiPluginApi; session: any }) {
|
|
879
1913
|
const theme = () => props.api.theme.current
|
|
880
1914
|
|
|
881
1915
|
const status = createMemo(() => {
|
|
1916
|
+
// isValidSessionId guard: a placeholder / foreign session id must never
|
|
1917
|
+
// reach the status API (same "%7BsessionID%7D" regression class as the
|
|
1918
|
+
// children path). Invalid id → no status call, no icon.
|
|
1919
|
+
if (!isValidSessionId(props.session?.id)) return null
|
|
882
1920
|
try {
|
|
883
1921
|
const s = (props.api.state as any).session?.status?.(props.session.id)
|
|
884
1922
|
if (s?.type) return s.type as string
|
|
@@ -906,13 +1944,98 @@ function SessionRow(props: { api: TuiPluginApi; session: any }) {
|
|
|
906
1944
|
)
|
|
907
1945
|
}
|
|
908
1946
|
|
|
1947
|
+
/* ─── Delegation Row (single job from the children/md channels) ─ */
|
|
1948
|
+
|
|
1949
|
+
function DelegationRow(props: {
|
|
1950
|
+
api: TuiPluginApi
|
|
1951
|
+
job: DelegationEntry
|
|
1952
|
+
now: number
|
|
1953
|
+
animationNow: number
|
|
1954
|
+
}) {
|
|
1955
|
+
const theme = () => props.api.theme.current
|
|
1956
|
+
|
|
1957
|
+
const color = createMemo(() => {
|
|
1958
|
+
const t = theme()
|
|
1959
|
+
switch (props.job.state) {
|
|
1960
|
+
case 'running':
|
|
1961
|
+
return t.warning
|
|
1962
|
+
case 'completed':
|
|
1963
|
+
return t.success
|
|
1964
|
+
case 'error':
|
|
1965
|
+
return t.error
|
|
1966
|
+
default:
|
|
1967
|
+
return t.textMuted // cancelled
|
|
1968
|
+
}
|
|
1969
|
+
})
|
|
1970
|
+
|
|
1971
|
+
const marker = createMemo(() => {
|
|
1972
|
+
if (props.job.state === 'running') return `${delegationSpinnerFrame(props.animationNow)} `
|
|
1973
|
+
switch (props.job.state) {
|
|
1974
|
+
case 'completed':
|
|
1975
|
+
return '\u2713 '
|
|
1976
|
+
case 'error':
|
|
1977
|
+
return '\u2715 '
|
|
1978
|
+
default:
|
|
1979
|
+
return '\u25cb '
|
|
1980
|
+
}
|
|
1981
|
+
})
|
|
1982
|
+
|
|
1983
|
+
// The row tag: `[task]` for native task() children (no board report),
|
|
1984
|
+
// `[apo-1]`-style alias for pantheon_delegate board rows. Rendered with
|
|
1985
|
+
// the info color so task() children are visually distinct from delegates.
|
|
1986
|
+
const tagColor = createMemo(() => (props.job.source === 'children-only' ? theme().info : color()))
|
|
1987
|
+
|
|
1988
|
+
const detail = createMemo(() => {
|
|
1989
|
+
const elapsed = delegationElapsed(props.job, props.now)
|
|
1990
|
+
const description = props.job.description !== '' ? ` \u2014 ${props.job.description}` : ''
|
|
1991
|
+
const line = `${elapsed}${description}`
|
|
1992
|
+
return line.length > 180 ? `${line.slice(0, 177)}\u2026` : line
|
|
1993
|
+
})
|
|
1994
|
+
|
|
1995
|
+
// Click a row → navigate to the child session (route API guarded in the
|
|
1996
|
+
// helper). Enter-key binding is left out (per-row keymap wiring is not
|
|
1997
|
+
// worth the complexity in this sidebar); mouse is enabled in tui.json.
|
|
1998
|
+
const open = () => {
|
|
1999
|
+
navigateToDelegationSession(props.api.route, props.job.taskID)
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
return (
|
|
2003
|
+
<box onMouseDown={open}>
|
|
2004
|
+
<box flexDirection="row">
|
|
2005
|
+
<text fg={color()}>{marker()}</text>
|
|
2006
|
+
<text fg={tagColor()}>{delegationTag(props.job)}</text>
|
|
2007
|
+
<text
|
|
2008
|
+
fg={color()}
|
|
2009
|
+
>{` ${props.job.agent} \u2014 ${delegationActivityLabel(props.job)}`}</text>
|
|
2010
|
+
</box>
|
|
2011
|
+
<text fg={theme().textMuted}>{detail()}</text>
|
|
2012
|
+
</box>
|
|
2013
|
+
)
|
|
2014
|
+
}
|
|
2015
|
+
|
|
909
2016
|
/* ─── Main Sidebar View ──────────────────────────────────── */
|
|
910
2017
|
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
2018
|
+
/** Plugin-level live delegation store shared with the event subscriptions
|
|
2019
|
+
* in `tui()`: the map of live entries + a version signal bumped on every
|
|
2020
|
+
* mutation. The View subscribes to the version (in an effect) to refresh the
|
|
2021
|
+
* durable child list and also reads the map as an optimistic live source. */
|
|
2022
|
+
export type LiveDelegationStore = {
|
|
2023
|
+
map: Map<string, LiveDelegationEntry>
|
|
2024
|
+
/** Reactive version getter — View reads it inside an effect to re-fetch. */
|
|
2025
|
+
version: () => number
|
|
2026
|
+
/** Bump the version after a live mutation. */
|
|
2027
|
+
bump: () => void
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
function View(props: {
|
|
2031
|
+
api: TuiPluginApi
|
|
2032
|
+
sessionID: string | undefined
|
|
2033
|
+
version: string | null
|
|
2034
|
+
liveStore: LiveDelegationStore
|
|
2035
|
+
}) {
|
|
915
2036
|
const [showSessions, setShowSessions] = createSignal(false)
|
|
2037
|
+
// Show live work immediately; the header remains clickable to collapse it.
|
|
2038
|
+
const [showDelegations, setShowDelegations] = createSignal(true)
|
|
916
2039
|
|
|
917
2040
|
const theme = () => props.api.theme.current
|
|
918
2041
|
|
|
@@ -971,7 +2094,11 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
|
|
|
971
2094
|
const data: any[] = (result as any).data ?? result
|
|
972
2095
|
if (!Array.isArray(data)) return []
|
|
973
2096
|
return data
|
|
974
|
-
.filter(
|
|
2097
|
+
.filter(
|
|
2098
|
+
(s: any) =>
|
|
2099
|
+
!s.parentID &&
|
|
2100
|
+
s.id !== resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api }),
|
|
2101
|
+
)
|
|
975
2102
|
.sort((a: any, b: any) => {
|
|
976
2103
|
const ta = a.time?.updated ?? a.updated ?? 0
|
|
977
2104
|
const tb = b.time?.updated ?? b.updated ?? 0
|
|
@@ -980,14 +2107,126 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
|
|
|
980
2107
|
.slice(0, 8)
|
|
981
2108
|
})
|
|
982
2109
|
|
|
983
|
-
// ──
|
|
984
|
-
|
|
2110
|
+
// ── Delegations: session.children (PRIMARY) + md reports (enrichment) ──
|
|
2111
|
+
// The panel's source of truth is the SDK child-session API
|
|
2112
|
+
// (`api.client.session.children`) — every pantheon_delegate spawns a child
|
|
2113
|
+
// session with parentID = this session, so children ARE the delegation
|
|
2114
|
+
// list (delegations-sidebar pattern). The md channel
|
|
2115
|
+
// (.pantheon/delegations) enriches each child with alias/agent/description
|
|
2116
|
+
// and terminal duration via the `Task ID` match. Live tool-call events are
|
|
2117
|
+
// merged optimistically so the row appears before those channels catch up.
|
|
2118
|
+
const delegationsDir = createMemo(() => resolveDelegationsDir((props.api.state as any).path))
|
|
2119
|
+
// Silence-by-default panel logger → the REAL <root>/.pantheon/logs/hooks.log.
|
|
2120
|
+
// panelLogDir(<root>/.pantheon/delegations) = <root>: the old code passed
|
|
2121
|
+
// dirname(delegationsDir()) (= <root>/.pantheon), which createTuiLogger
|
|
2122
|
+
// re-joined with `.pantheon/logs/hooks.log` → a nested
|
|
2123
|
+
// <root>/.pantheon/.pantheon/logs/hooks.log that never reached the real
|
|
2124
|
+
// hooks.log (the empty nested dirs observed in production).
|
|
2125
|
+
const panelLog = createTuiLogger(panelLogDir(delegationsDir()))
|
|
2126
|
+
// The rendered list is childDelegations (children enriched with md);
|
|
2127
|
+
// there is no separate md-only signal — the md channel only feeds
|
|
2128
|
+
// childrenToDelegationEntries below.
|
|
2129
|
+
const [childDelegations, setChildDelegations] = createSignal<DelegationEntry[]>([])
|
|
2130
|
+
// 1s ticker drives the running-jobs elapsed counter AND the safety poll
|
|
2131
|
+
// (oh-my-opencode-slim pattern): re-fetch children + re-read the md every
|
|
2132
|
+
// second so the panel updates even when NO event ever fires.
|
|
2133
|
+
const [now, setNow] = createSignal(Date.now())
|
|
2134
|
+
// Separate fast ticker for the spinner. The elapsed label intentionally
|
|
2135
|
+
// remains on the 1s ticker to avoid unnecessary work.
|
|
2136
|
+
const [animationNow, setAnimationNow] = createSignal(Date.now())
|
|
2137
|
+
// Cumulative count of event-triggered refreshes — logged with every fetch
|
|
2138
|
+
// ("panel: children=N md=N events=N") so the (0) symptom is diagnosable:
|
|
2139
|
+
// children>0 means the source works; events=0 means the event bus isn't
|
|
2140
|
+
// delivering, and the 1s poll is what keeps the panel honest.
|
|
2141
|
+
let eventRefreshCount = 0
|
|
2142
|
+
// In-flight guard: overlap between the poll, events and the version effect
|
|
2143
|
+
// collapses into one fetch — no duplicate work, no duplicate entries.
|
|
2144
|
+
let delegationsInflight: Promise<void> | null = null
|
|
2145
|
+
const refreshDelegations = () => {
|
|
2146
|
+
if (delegationsInflight !== null) return delegationsInflight
|
|
2147
|
+
delegationsInflight = (async () => {
|
|
2148
|
+
try {
|
|
2149
|
+
const state = props.api.state as any
|
|
2150
|
+
// 0. md reports from EVERY session under .pantheon/delegations — the
|
|
2151
|
+
// HISTORY channel. Read BEFORE the sessionID guard so the panel shows
|
|
2152
|
+
// past reports (running first, Finalized desc) even when no session
|
|
2153
|
+
// is focused (null/placeholder sessionID): the "(0)" symptom becomes
|
|
2154
|
+
// the full historical list. Fail-open: a missing dir yields [].
|
|
2155
|
+
let md: DelegationEntry[] = []
|
|
2156
|
+
try {
|
|
2157
|
+
md = await readAllDelegationEntries(panelLogDir(delegationsDir()))
|
|
2158
|
+
} catch {
|
|
2159
|
+
md = [] // fail-open: never crash the sidebar on a read error
|
|
2160
|
+
}
|
|
2161
|
+
// 1. current session — resolve + GUARD (placeholder regression). The
|
|
2162
|
+
// runtime may render the sidebar with an unsubstituted "{sessionID}"
|
|
2163
|
+
// slot prop; forwarding it to session.children made the server reject
|
|
2164
|
+
// every poll (~1 err/s: "Expected a string starting with \"ses\", got
|
|
2165
|
+
// \"%7BsessionID%7D\"") and fail-open the panel into "(0)". A null
|
|
2166
|
+
// resolution SKIPS the fetch entirely: the md history is shown alone
|
|
2167
|
+
// (zero children, zero errors).
|
|
2168
|
+
const sessionID = resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api })
|
|
2169
|
+
if (sessionID === null) {
|
|
2170
|
+
setChildDelegations(md)
|
|
2171
|
+
panelLog.info(
|
|
2172
|
+
`panel: children=0 md=${md.length} events=${eventRefreshCount} (no sessionID — history shown)`,
|
|
2173
|
+
)
|
|
2174
|
+
return
|
|
2175
|
+
}
|
|
2176
|
+
// 2. children — PRIMARY source (SDK: api.client.session.children).
|
|
2177
|
+
let children: ChildDelegationLike[] = []
|
|
2178
|
+
try {
|
|
2179
|
+
// buildChildrenPath re-validates: with sessionID !== null it is
|
|
2180
|
+
// never null, so no placeholder can reach the wire.
|
|
2181
|
+
const result = await (props.api.client as any)?.session?.children?.(
|
|
2182
|
+
buildChildrenPath(sessionID),
|
|
2183
|
+
)
|
|
2184
|
+
const data = (result?.data ?? result) as unknown
|
|
2185
|
+
children = Array.isArray(data) ? (data as ChildDelegationLike[]) : []
|
|
2186
|
+
} catch (err) {
|
|
2187
|
+
children = [] // children API unavailable — the panel shows md only
|
|
2188
|
+
panelLog.info('panel: error children fetch', err)
|
|
2189
|
+
}
|
|
2190
|
+
// 3. live status per child (sync API) + merge children with md.
|
|
2191
|
+
const resolveStatus = (childID: unknown): string | undefined => {
|
|
2192
|
+
// isValidSessionId guard: a placeholder / foreign id must never
|
|
2193
|
+
// reach the status API (same regression class as the children
|
|
2194
|
+
// path — "%7BsessionID%7D").
|
|
2195
|
+
if (!isValidSessionId(childID)) return undefined
|
|
2196
|
+
try {
|
|
2197
|
+
return state?.session?.status?.(childID)?.type as string | undefined
|
|
2198
|
+
} catch {
|
|
2199
|
+
return undefined // status API unavailable — child state fallback
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
const withStatus = children.map((c) => ({ ...c, status: resolveStatus(c.id) }))
|
|
2203
|
+
const childEntries = childrenToDelegationEntries(withStatus, md, now())
|
|
2204
|
+
// The event channel is optimistic: it renders a job even while the
|
|
2205
|
+
// child session/report is still being created. Filter by parent so a
|
|
2206
|
+
// different focused session never leaks rows into this sidebar.
|
|
2207
|
+
const liveEntries = [...props.liveStore.map.values()].filter(
|
|
2208
|
+
(entry) => entry.sessionID === sessionID,
|
|
2209
|
+
)
|
|
2210
|
+
setChildDelegations(mergeChildDelegationSources(childEntries, liveEntries))
|
|
2211
|
+
// 4. diagnostic line — every re-fetch (silence-by-default, hooks.log).
|
|
2212
|
+
panelLog.info(
|
|
2213
|
+
`panel: children=${children.length} md=${md.length} events=${eventRefreshCount}`,
|
|
2214
|
+
)
|
|
2215
|
+
} finally {
|
|
2216
|
+
delegationsInflight = null
|
|
2217
|
+
}
|
|
2218
|
+
})()
|
|
2219
|
+
return delegationsInflight
|
|
2220
|
+
}
|
|
2221
|
+
// Running jobs first, then the 8 most recent terminal reports. The header
|
|
2222
|
+
// count uses this same "running + recentes" list.
|
|
2223
|
+
const visibleDelegations = createMemo(() => visibleDelegationList(childDelegations()))
|
|
985
2224
|
|
|
986
|
-
// ── Event subscriptions for live session/
|
|
2225
|
+
// ── Event subscriptions for live session/delegation updates ──
|
|
987
2226
|
onMount(() => {
|
|
988
2227
|
const cleanup: (() => void)[] = []
|
|
989
2228
|
|
|
990
|
-
// Session events — triggers resource refetch
|
|
2229
|
+
// Session events — triggers the sessions resource refetch.
|
|
991
2230
|
try {
|
|
992
2231
|
cleanup.push(props.api.event.on('session.status', refetchSessions))
|
|
993
2232
|
cleanup.push(props.api.event.on('session.created', refetchSessions))
|
|
@@ -997,19 +2236,67 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
|
|
|
997
2236
|
/* events API not available in this runtime */
|
|
998
2237
|
}
|
|
999
2238
|
|
|
1000
|
-
//
|
|
2239
|
+
// Delegation panel: children are born/die with session lifecycle events
|
|
2240
|
+
// (a delegate spawns a child → session.created fires) — re-fetch on all
|
|
2241
|
+
// of them. `session.status` re-syncs busy→idle state transitions.
|
|
1001
2242
|
try {
|
|
1002
|
-
const
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
cleanup.push(
|
|
1006
|
-
ev.on('memory.deleted', () =>
|
|
1007
|
-
setMemoryCount((c) => (c !== null ? Math.max(0, c - 1) : 0)),
|
|
1008
|
-
),
|
|
1009
|
-
)
|
|
2243
|
+
const eventRefresh = () => {
|
|
2244
|
+
eventRefreshCount += 1
|
|
2245
|
+
void refreshDelegations()
|
|
1010
2246
|
}
|
|
2247
|
+
cleanup.push(props.api.event.on('session.created', eventRefresh))
|
|
2248
|
+
cleanup.push(props.api.event.on('session.updated', eventRefresh))
|
|
2249
|
+
cleanup.push(props.api.event.on('session.deleted', eventRefresh))
|
|
2250
|
+
cleanup.push(props.api.event.on('session.status', eventRefresh))
|
|
1011
2251
|
} catch {
|
|
1012
|
-
/*
|
|
2252
|
+
/* events API not available — the 1s poll still covers it */
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
// message.part.updated/removed → liveStore.version() bumps (tui()):
|
|
2256
|
+
// subscribe here so the panel ALSO re-fetches children when tool-part
|
|
2257
|
+
// events DO arrive and the updated live row is rendered immediately.
|
|
2258
|
+
createEffect(() => {
|
|
2259
|
+
props.liveStore.version() // subscribe to live mutations
|
|
2260
|
+
eventRefreshCount += 1
|
|
2261
|
+
void refreshDelegations()
|
|
2262
|
+
})
|
|
2263
|
+
|
|
2264
|
+
// Safety poll (OMO-slim pattern): 1s re-fetch children + md + tick the
|
|
2265
|
+
// elapsed counter. Guarantees the panel updates even with zero events.
|
|
2266
|
+
const poll = setInterval(() => {
|
|
2267
|
+
setNow(Date.now())
|
|
2268
|
+
void refreshDelegations()
|
|
2269
|
+
}, 1_000)
|
|
2270
|
+
cleanup.push(() => clearInterval(poll))
|
|
2271
|
+
const animation = setInterval(() => setAnimationNow(Date.now()), 140)
|
|
2272
|
+
cleanup.push(() => clearInterval(animation))
|
|
2273
|
+
|
|
2274
|
+
// Compaction recovery: `message.part.removed` clears live entries while
|
|
2275
|
+
// compacting, so after a compaction/attach the live map would start
|
|
2276
|
+
// empty even though the session still holds the delegation tool parts.
|
|
2277
|
+
// Re-seed the live map from the session's existing parts (in message
|
|
2278
|
+
// order) right after the subscriptions are registered — the version
|
|
2279
|
+
// bump re-triggers the children fetch. Fail-open: if the SDK access is
|
|
2280
|
+
// unavailable we skip — the children API + the 1s poll keep the panel
|
|
2281
|
+
// working.
|
|
2282
|
+
try {
|
|
2283
|
+
const sdk = (props.api.state as any)?.session
|
|
2284
|
+
const mountSessionID = resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api })
|
|
2285
|
+
if (mountSessionID !== null && typeof sdk?.messages === 'function') {
|
|
2286
|
+
const messages: readonly { id?: string; parts?: unknown[] }[] =
|
|
2287
|
+
sdk.messages(mountSessionID) ?? []
|
|
2288
|
+
const state = props.api.state as any
|
|
2289
|
+
const getParts =
|
|
2290
|
+
typeof state?.part === 'function'
|
|
2291
|
+
? (messageID: string) => state.part(messageID)
|
|
2292
|
+
: undefined
|
|
2293
|
+
const parts = collectDelegationToolParts(messages, getParts)
|
|
2294
|
+
if (parts.length > 0 && seedLiveDelegationMap(props.liveStore.map, parts) > 0) {
|
|
2295
|
+
props.liveStore.bump()
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
} catch {
|
|
2299
|
+
/* re-scan unavailable — children API + 1s poll still cover it */
|
|
1013
2300
|
}
|
|
1014
2301
|
|
|
1015
2302
|
onCleanup(() =>
|
|
@@ -1019,17 +2306,6 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
|
|
|
1019
2306
|
)
|
|
1020
2307
|
})
|
|
1021
2308
|
|
|
1022
|
-
// ── Config summary ──
|
|
1023
|
-
const configSummary = createMemo(() => {
|
|
1024
|
-
const cfg = (props.api.state as any).config
|
|
1025
|
-
if (!cfg) return null
|
|
1026
|
-
return {
|
|
1027
|
-
mcpCount: cfg.mcp ? Object.keys(cfg.mcp).length : 0,
|
|
1028
|
-
pluginCount: Array.isArray(cfg.plugin) ? cfg.plugin.length : 0,
|
|
1029
|
-
autoCompaction: cfg.compaction?.auto === true,
|
|
1030
|
-
}
|
|
1031
|
-
})
|
|
1032
|
-
|
|
1033
2309
|
const HR = () => <text fg={theme().textMuted}>{'\u2500'.repeat(28)}</text>
|
|
1034
2310
|
|
|
1035
2311
|
return (
|
|
@@ -1084,113 +2360,114 @@ function View(props: { api: TuiPluginApi; sessionID: string; version: string | n
|
|
|
1084
2360
|
</Show>
|
|
1085
2361
|
</Show>
|
|
1086
2362
|
|
|
1087
|
-
{/* ──
|
|
1088
|
-
<box onMouseDown={() =>
|
|
2363
|
+
{/* ── Delegations (session.children primary + .pantheon/delegations md enrichment) ── */}
|
|
2364
|
+
<box onMouseDown={() => setShowDelegations((x) => !x)}>
|
|
1089
2365
|
<text fg={theme().text} attributes={1}>
|
|
1090
|
-
{`${
|
|
2366
|
+
{`${showDelegations() ? '\u25bc' : '\u25b6'} Delegations`}
|
|
1091
2367
|
</text>
|
|
1092
|
-
<text fg={theme().textMuted}>{` (${String(
|
|
2368
|
+
<text fg={theme().textMuted}>{` (${String(visibleDelegations().length)})`}</text>
|
|
1093
2369
|
</box>
|
|
1094
|
-
<Show when={
|
|
1095
|
-
<box marginLeft={1} flexDirection="column">
|
|
1096
|
-
<For each={COMMANDS}>
|
|
1097
|
-
{(cmd) => (
|
|
1098
|
-
<box
|
|
1099
|
-
onMouseDown={(e) => {
|
|
1100
|
-
e.stopPropagation()
|
|
1101
|
-
try {
|
|
1102
|
-
const cmdApi = (props.api as any).command
|
|
1103
|
-
const cmdName = cmd.name.replace('/', '')
|
|
1104
|
-
if (cmdApi?.trigger?.(cmdName)) return
|
|
1105
|
-
} catch {
|
|
1106
|
-
/* fall through to toast */
|
|
1107
|
-
}
|
|
1108
|
-
props.api.ui?.toast?.({ title: 'Command', message: `Type ${cmd.name} in chat` })
|
|
1109
|
-
}}
|
|
1110
|
-
>
|
|
1111
|
-
<text fg={cmd.name === '/pantheon' ? theme().accent : theme().textMuted}>
|
|
1112
|
-
{cmd.name}
|
|
1113
|
-
</text>
|
|
1114
|
-
<text fg={theme().textMuted}>{` \u2014 ${cmd.desc}`}</text>
|
|
1115
|
-
</box>
|
|
1116
|
-
)}
|
|
1117
|
-
</For>
|
|
1118
|
-
</box>
|
|
1119
|
-
</Show>
|
|
1120
|
-
|
|
1121
|
-
{/* ── Agents ── */}
|
|
1122
|
-
<box onMouseDown={() => setShowAgents((x) => !x)}>
|
|
1123
|
-
<text fg={theme().text} attributes={1}>
|
|
1124
|
-
{`${showAgents() ? '\u25bc' : '\u25b6'} Agents`}
|
|
1125
|
-
</text>
|
|
1126
|
-
<text fg={theme().textMuted}>{` (${String(AGENTS.length)})`}</text>
|
|
1127
|
-
</box>
|
|
1128
|
-
<Show when={showAgents()}>
|
|
1129
|
-
<box marginLeft={1} flexDirection="column">
|
|
1130
|
-
<For each={AGENTS}>
|
|
1131
|
-
{(agent) => (
|
|
1132
|
-
<box>
|
|
1133
|
-
<text fg={agent.tier === 'premium' ? theme().accent : theme().textMuted}>
|
|
1134
|
-
{`${agent.tier === 'premium' ? '\u2726 ' : '\u00b7 '}${agent.name}`}
|
|
1135
|
-
</text>
|
|
1136
|
-
<text fg={theme().textMuted}>{` \u2014 ${agent.role}`}</text>
|
|
1137
|
-
</box>
|
|
1138
|
-
)}
|
|
1139
|
-
</For>
|
|
1140
|
-
</box>
|
|
1141
|
-
</Show>
|
|
1142
|
-
|
|
1143
|
-
{/* ── Config ── */}
|
|
1144
|
-
<box onMouseDown={() => setShowConfig((x) => !x)}>
|
|
1145
|
-
<text fg={theme().text} attributes={1}>
|
|
1146
|
-
{`${showConfig() ? '\u25bc' : '\u25b6'} Config`}
|
|
1147
|
-
</text>
|
|
1148
|
-
</box>
|
|
1149
|
-
<Show when={showConfig()}>
|
|
2370
|
+
<Show when={showDelegations()}>
|
|
1150
2371
|
<Show
|
|
1151
|
-
when={
|
|
2372
|
+
when={visibleDelegations().length > 0}
|
|
1152
2373
|
fallback={
|
|
1153
2374
|
<box marginLeft={1}>
|
|
1154
|
-
<text fg={theme().textMuted}>No
|
|
2375
|
+
<text fg={theme().textMuted}>No delegations</text>
|
|
1155
2376
|
</box>
|
|
1156
2377
|
}
|
|
1157
2378
|
>
|
|
1158
|
-
{
|
|
1159
|
-
<
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
2379
|
+
<box marginLeft={1} flexDirection="column">
|
|
2380
|
+
<For each={visibleDelegations()}>
|
|
2381
|
+
{(job) => (
|
|
2382
|
+
<DelegationRow
|
|
2383
|
+
api={props.api}
|
|
2384
|
+
job={job}
|
|
2385
|
+
now={now()}
|
|
2386
|
+
animationNow={animationNow()}
|
|
2387
|
+
/>
|
|
2388
|
+
)}
|
|
2389
|
+
</For>
|
|
2390
|
+
</box>
|
|
1168
2391
|
</Show>
|
|
1169
2392
|
</Show>
|
|
1170
|
-
|
|
1171
|
-
{/* ── Memory (always visible summary) ── */}
|
|
1172
|
-
<box marginTop={1}>
|
|
1173
|
-
<text fg={theme().textMuted}>
|
|
1174
|
-
{memoryCount() !== null ? `Memory: ${fmtInt(memoryCount()!)} entries` : 'Memory: N/A'}
|
|
1175
|
-
</text>
|
|
1176
|
-
</box>
|
|
1177
2393
|
</box>
|
|
1178
2394
|
)
|
|
1179
2395
|
}
|
|
1180
2396
|
|
|
1181
2397
|
/* ─── Plugin Registration ────────────────────────────────── */
|
|
1182
2398
|
|
|
1183
|
-
const tui: TuiPlugin =
|
|
1184
|
-
|
|
2399
|
+
const tui: TuiPlugin = (api, _options, _meta) => {
|
|
2400
|
+
// Do not await project file/process probes here. OpenCode waits for the TUI
|
|
2401
|
+
// factory before it finishes startup, and a Windows host opening a WSL
|
|
2402
|
+
// project can leave those probes pending while the project bridge comes up.
|
|
2403
|
+
// Register the sidebar immediately and fill in the optional version later.
|
|
2404
|
+
const [version, setVersion] = createSignal<string | null>(null)
|
|
2405
|
+
void detectVersion(api)
|
|
2406
|
+
.then((detected) => setVersion(detected))
|
|
2407
|
+
.catch(() => setVersion(null))
|
|
1185
2408
|
|
|
1186
2409
|
// Vendored feature (MIT): AI subscription usage gauges.
|
|
1187
2410
|
void setupUsageBar(api) // async init — never blocks sidebar registration
|
|
1188
2411
|
|
|
2412
|
+
// ── Live delegation store (legacy tool-part tracker, kept as a REFRESH
|
|
2413
|
+
// TRIGGER) ──
|
|
2414
|
+
// The Delegations panel's PRIMARY source is `api.client.session.children`
|
|
2415
|
+
// (delegations-sidebar pattern) — `message.part.updated` may not fire for
|
|
2416
|
+
// pantheon_delegate on runtime 1.18.13 (the "(0)" symptom). The tool-part
|
|
2417
|
+
// lifecycle below still tracks pantheon_delegate / pantheon_delegation_read
|
|
2418
|
+
// parts and bumps a version signal the View subscribes to: when part
|
|
2419
|
+
// events DO arrive, the panel re-fetches children. Fail-open: if the
|
|
2420
|
+
// events API is unavailable nothing subscribes and the 1s safety poll
|
|
2421
|
+
// keeps the panel working (never crash).
|
|
2422
|
+
const [liveVersion, setLiveVersion] = createSignal(0)
|
|
2423
|
+
const liveStore: LiveDelegationStore = {
|
|
2424
|
+
map: new Map<string, LiveDelegationEntry>(),
|
|
2425
|
+
version: liveVersion,
|
|
2426
|
+
bump: () => setLiveVersion((v) => v + 1),
|
|
2427
|
+
}
|
|
2428
|
+
const unsubLive: (() => void)[] = []
|
|
2429
|
+
|
|
2430
|
+
try {
|
|
2431
|
+
unsubLive.push(
|
|
2432
|
+
api.event.on('message.part.updated', (event: any) => {
|
|
2433
|
+
// SDK v2 shape: properties.part (v1 drift: properties.info.part).
|
|
2434
|
+
const props = (event?.properties ?? {}) as { part?: unknown; info?: { part?: unknown } }
|
|
2435
|
+
const part = (props.part ?? props.info?.part) as DelegationToolPart | undefined
|
|
2436
|
+
if (part === undefined) return
|
|
2437
|
+
if (reduceDelegationToolPart(liveStore.map, part)) liveStore.bump()
|
|
2438
|
+
}),
|
|
2439
|
+
)
|
|
2440
|
+
} catch {
|
|
2441
|
+
/* events API unavailable — children API + 1s poll cover the panel */
|
|
2442
|
+
}
|
|
2443
|
+
try {
|
|
2444
|
+
unsubLive.push(
|
|
2445
|
+
api.event.on('message.part.removed', (event: any) => {
|
|
2446
|
+
// SDK v2 shape: properties.partID (v1 drift: properties.part.id).
|
|
2447
|
+
const props = (event?.properties ?? {}) as { partID?: string; part?: { id?: string } }
|
|
2448
|
+
const partID = props.partID ?? props.part?.id
|
|
2449
|
+
if (partID !== undefined && removeDelegationEntry(liveStore.map, partID)) liveStore.bump()
|
|
2450
|
+
}),
|
|
2451
|
+
)
|
|
2452
|
+
} catch {
|
|
2453
|
+
/* events API unavailable — children API + 1s poll cover the panel */
|
|
2454
|
+
}
|
|
2455
|
+
api.lifecycle.onDispose(() => {
|
|
2456
|
+
for (const unsub of unsubLive) {
|
|
2457
|
+
try {
|
|
2458
|
+
unsub()
|
|
2459
|
+
} catch {
|
|
2460
|
+
/* ignore */
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
liveStore.map.clear()
|
|
2464
|
+
})
|
|
2465
|
+
|
|
1189
2466
|
api.slots.register({
|
|
1190
2467
|
order: 900,
|
|
1191
2468
|
slots: {
|
|
1192
2469
|
sidebar_content(_ctx, props) {
|
|
1193
|
-
return <View api={api} sessionID={props.session_id} version={version} />
|
|
2470
|
+
return <View api={api} sessionID={props.session_id} version={version()} liveStore={liveStore} />
|
|
1194
2471
|
},
|
|
1195
2472
|
},
|
|
1196
2473
|
})
|