pantheon-opencode 1.6.0-beta.1 → 1.6.0-beta.11

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.
Files changed (133) hide show
  1. package/AGENTS.global.md +206 -0
  2. package/AGENTS.md +10 -891
  3. package/CHANGELOG.md +350 -0
  4. package/README.md +561 -52
  5. package/README.pt-BR.md +285 -20
  6. package/bin/pantheon-init.mjs +70 -4
  7. package/commands/pantheon-audit.md +7 -1
  8. package/commands/pantheon-consolidate.md +1 -1
  9. package/commands/pantheon-deepwork.md +14 -3
  10. package/commands/pantheon-focus.md +17 -0
  11. package/commands/pantheon-vision.md +61 -12
  12. package/package.json +24 -17
  13. package/plugin.json +2 -2
  14. package/pyproject.toml +8 -6
  15. package/scripts/build-agents-md.mjs +428 -56
  16. package/scripts/changelog-merge.mjs +161 -0
  17. package/scripts/{code_mode_server.py → code_mode.py} +3 -3
  18. package/scripts/cost.mjs +244 -41
  19. package/scripts/doctor.mjs +151 -1
  20. package/scripts/install/agents-md.mjs +4 -4
  21. package/scripts/install/backup.mjs +475 -0
  22. package/scripts/install/cli-ui.mjs +6 -1
  23. package/scripts/install/config-migration.mjs +317 -4
  24. package/scripts/install/exit-codes.mjs +26 -0
  25. package/scripts/install/health-check.mjs +21 -19
  26. package/scripts/install/interactive.mjs +16 -2
  27. package/scripts/install/migrate.mjs +41 -22
  28. package/scripts/install/opencode-version.mjs +246 -4
  29. package/scripts/install/opencode.mjs +1175 -215
  30. package/scripts/install/plan.mjs +287 -0
  31. package/scripts/install/plugin.mjs +62 -14
  32. package/scripts/install/shared.mjs +190 -48
  33. package/scripts/install/strings.mjs +20 -0
  34. package/scripts/install/venv.mjs +13 -3
  35. package/scripts/install/vision-prompt.mjs +171 -0
  36. package/scripts/install-mcp.mjs +28 -9
  37. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
  38. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  39. package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
  40. package/scripts/probe-context-rehydrate.mjs +8 -2
  41. package/scripts/probe-pantheon-cost.mjs +71 -15
  42. package/scripts/prune.mjs +9 -3
  43. package/scripts/restore.mjs +222 -0
  44. package/scripts/run-node-tests.mjs +189 -0
  45. package/scripts/sync-artifacts.mjs +26 -13
  46. package/scripts/sync-tui.mjs +24 -3
  47. package/scripts/test-opencode-v2-sandbox.sh +180 -26
  48. package/scripts/validate-routing.mjs +3 -3
  49. package/scripts/validate-sandbox-install.mjs +138 -0
  50. package/scripts/versioning.mjs +13 -1
  51. package/scripts/vision-status.mjs +682 -0
  52. package/skills-lock.json +8 -8
  53. package/src/agents/aphrodite.md +20 -37
  54. package/src/agents/apollo.md +7 -3
  55. package/src/agents/athena.md +63 -35
  56. package/src/agents/demeter.md +8 -28
  57. package/src/agents/gaia.md +1 -1
  58. package/src/agents/hephaestus.md +10 -21
  59. package/src/agents/hermes.md +15 -38
  60. package/src/agents/iris.md +7 -1
  61. package/src/agents/mnemosyne.md +18 -54
  62. package/src/agents/nyx.md +1 -1
  63. package/src/agents/prometheus.md +16 -20
  64. package/src/agents/talos.md +10 -2
  65. package/src/agents/themis.md +5 -5
  66. package/src/agents/zeus.md +99 -172
  67. package/src/instructions/agent-return-format.instructions.md +34 -17
  68. package/src/instructions/backend-standards.instructions.md +2 -0
  69. package/src/instructions/frontend-standards.instructions.md +3 -1
  70. package/src/instructions/memory-protocol.instructions.md +12 -110
  71. package/src/instructions/test-economy.instructions.md +21 -0
  72. package/src/instructions/zeus-anti-stall.instructions.md +20 -67
  73. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  74. package/src/instructions/zeus-council-synthesis.instructions.md +31 -260
  75. package/src/instructions/zeus-memory-operations.instructions.md +30 -0
  76. package/src/instructions/zeus-timeout-retry.instructions.md +61 -89
  77. package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
  78. package/src/mcp/eval_store.py +2 -2
  79. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
  80. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  81. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
  82. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
  83. package/src/mcp/requirements-mcp.txt +6 -4
  84. package/src/mcp/requirements-vision.txt +2 -2
  85. package/src/pantheon/cost-command.ts +403 -102
  86. package/src/pantheon/delegation-enforce.ts +10 -1
  87. package/src/pantheon/model-agents.ts +19 -0
  88. package/src/pantheon/model-command.ts +218 -56
  89. package/src/pantheon/presets.d.mts +0 -8
  90. package/src/pantheon/presets.mjs +5 -32
  91. package/src/pantheon/v2-bounded-read.ts +640 -0
  92. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  93. package/src/pantheon/v2-execute-after.ts +186 -0
  94. package/src/pantheon/v2-model-overrides.ts +275 -0
  95. package/src/pantheon/v2-tools.ts +418 -0
  96. package/src/pantheon/v2-unsupported.d.mts +11 -0
  97. package/src/pantheon/v2-unsupported.mjs +90 -0
  98. package/src/pantheon/v2-vision.ts +622 -0
  99. package/src/pantheon/vision-local.ts +531 -0
  100. package/src/pantheon/vision.ts +1160 -115
  101. package/src/plugin-v2.ts +1158 -219
  102. package/src/plugin.ts +12 -9
  103. package/src/plugins/tui/dist/server.d.ts +1 -2
  104. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  105. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  106. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  107. package/src/plugins/tui/dist/tui-v2.js +659 -0
  108. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  109. package/src/plugins/tui/dist/tui.d.ts +85 -82
  110. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  111. package/src/plugins/tui/dist/tui.js +72 -59
  112. package/src/plugins/tui/index-v2.ts +6 -0
  113. package/src/plugins/tui/package-lock.json +5516 -1353
  114. package/src/plugins/tui/package.json +22 -12
  115. package/src/plugins/tui/src/index-v2.tsx +734 -0
  116. package/src/plugins/tui/src/index.tsx +106 -85
  117. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  118. package/src/plugins/tui/tsconfig.build.json +5 -0
  119. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  120. package/src/plugins/tui/tsdown.config.ts +22 -2
  121. package/src/plugins/tui/tui-v2.ts +2 -0
  122. package/src/plugins/tui/tui.ts +3 -0
  123. package/src/routing.yml +11 -21
  124. package/src/skills/README.md +3 -3
  125. package/src/skills/agent-coordination/SKILL.md +7 -2
  126. package/src/skills/artifact-management/SKILL.md +12 -5
  127. package/src/skills/auto-continue/SKILL.md +52 -35
  128. package/src/skills/code-review-checklist/SKILL.md +5 -5
  129. package/src/skills/context-compression/SKILL.md +26 -879
  130. package/src/skills/session-goal/SKILL.md +14 -5
  131. package/src/skills/spec-driven-development/SKILL.md +3 -1
  132. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  133. package/opencode.json +0 -58
@@ -0,0 +1,734 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: OpenCode V2 exposes its TUI API at runtime.
2
+ // biome-ignore-all lint/a11y/noStaticElementInteractions: TUI elements, not DOM
3
+ /** @jsxImportSource @opentui/solid */
4
+
5
+ import { readFile } from 'node:fs/promises'
6
+ import { homedir } from 'node:os'
7
+ import { join } from 'node:path'
8
+ import { Plugin } from '@opencode/plugin/tui'
9
+ import type { Context } from '@opencode/plugin/tui/context'
10
+ import type { Accessor } from 'solid-js'
11
+ import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js'
12
+
13
+ const SINGLETON = Symbol.for('pantheon.tui.v2.loaded')
14
+
15
+ /* ─── Constants ─────────────────────────────────────────── */
16
+
17
+ /** How often the sidebar re-reads .pantheon/active-preset.json (V1 parity) so
18
+ * `set-tier` changes made while opencode is open show up within ~30s. */
19
+ const PRESET_REFRESH_MS = 30_000
20
+ /** Running delegations rendered before the "… +N more" line (V1 parity). */
21
+ export const DELEGATIONS_VISIBLE_CEILING = 8
22
+ /** Running-row spinner cadence — matches V1's animation tick. */
23
+ const SPINNER_INTERVAL_MS = 80
24
+ /** Bounded warm-up: sync at most this many sessions at plugin load. */
25
+ const WARMUP_SESSION_LIMIT = 24
26
+ /** Sidebar body shown while the host has not published a session yet. */
27
+ export const SIDEBAR_WAITING_LABEL = 'Aguardando sessão…'
28
+ /** Delegate-completion toast title. */
29
+ const TOAST_TITLE = 'Pantheon'
30
+ /** One event name the host publishes on `data.on` — the OpenCode V2 union. */
31
+ export type SidebarDataEvent = Parameters<Context['data']['on']>[0]
32
+ /** V2 events that refresh the sidebar. V1 also listened to `session.updated`,
33
+ * but that name is absent from the OpenCode V2 event union (verified against
34
+ * `@opencode/client`'s `OpenCodeEvent`), so the valid events that carry the
35
+ * same state changes are used instead: `session.renamed` /
36
+ * `session.metadata.updated` / `session.moved` (the mutations `session.updated`
37
+ * used to stand for) and `session.idle` (a child finishing). `satisfies` keeps
38
+ * the literals while making a name outside the union a type error (surfaced by
39
+ * `tsc -p tsconfig.build.json`), and the sidebar test asserts the same names
40
+ * against the installed union at runtime. */
41
+ export const SIDEBAR_REFRESH_EVENTS = [
42
+ 'session.created',
43
+ 'session.renamed',
44
+ 'session.metadata.updated',
45
+ 'session.moved',
46
+ 'session.idle',
47
+ 'session.status',
48
+ 'session.deleted',
49
+ ] as const satisfies readonly SidebarDataEvent[]
50
+
51
+ /* ─── Active Preset Detection ──────────────────────────────
52
+ * Inline replica of the V1 sidebar resolver (src/plugins/tui/src/index.tsx,
53
+ * which itself mirrors src/pantheon/presets.mjs). The copied plugin ships only
54
+ * dist/ + package.json, so it can reach neither module at runtime — the
55
+ * resolution logic is mirrored here rather than imported.
56
+ *
57
+ * Priority: env PANTHEON_MODEL_PRESET > first existing candidate file > none.
58
+ * Custom/unknown preset names are shown as-is. "none" disables → default. */
59
+
60
+ export type PresetInfo = { name: string | null; source: 'env' | 'file' | null }
61
+
62
+ /** Read .pantheon/active-preset.json — first existing candidate wins; malformed
63
+ * JSON or a `preset` that is missing, empty or "none" → null (no fall-through
64
+ * to lower candidates). */
65
+ async function readActivePresetFile(cwd: string): Promise<{ name: string; source: 'file' } | null> {
66
+ const xdgConfig = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config')
67
+ const candidates = [
68
+ join(cwd, '.pantheon', 'active-preset.json'),
69
+ join(xdgConfig, 'opencode', '.pantheon', 'active-preset.json'),
70
+ join(homedir(), '.opencode', '.pantheon', 'active-preset.json'),
71
+ ]
72
+ for (const candidate of candidates) {
73
+ let raw: string
74
+ try {
75
+ raw = await readFile(candidate, 'utf8')
76
+ } catch {
77
+ continue // candidate does not exist — try the next one
78
+ }
79
+ try {
80
+ const parsed = JSON.parse(raw) as { preset?: unknown }
81
+ const name = parsed && typeof parsed === 'object' ? parsed.preset : undefined
82
+ if (typeof name !== 'string' || name.length === 0 || name === 'none') return null
83
+ return { name, source: 'file' }
84
+ } catch {
85
+ return null // malformed file — treat as default (mirrors presets.mjs)
86
+ }
87
+ }
88
+ return null
89
+ }
90
+
91
+ /** Env leg of resolution: PANTHEON_MODEL_PRESET set and !== 'none' wins. */
92
+ export function presetFromEnv(env: Record<string, string | undefined>): PresetInfo {
93
+ const name = env.PANTHEON_MODEL_PRESET
94
+ if (name !== undefined && name !== '' && name !== 'none') {
95
+ return { name, source: 'env' }
96
+ }
97
+ return { name: null, source: null }
98
+ }
99
+
100
+ /** Resolve the active preset for the sidebar: env > file > default. */
101
+ export async function resolvePresetForTui(
102
+ env: Record<string, string | undefined>,
103
+ cwd: string,
104
+ ): Promise<PresetInfo> {
105
+ const envPreset = presetFromEnv(env)
106
+ if (envPreset.source === 'env') return envPreset
107
+ return (await readActivePresetFile(cwd)) ?? { name: null, source: null }
108
+ }
109
+
110
+ /* ─── Session helpers ────────────────────────────────────── */
111
+
112
+ /** `SessionInfo.id` is always a `ses…` handle. Guarded so a placeholder or a
113
+ * foreign id never reaches the status/navigate APIs. */
114
+ export function isValidSessionID(id: unknown): id is string {
115
+ return typeof id === 'string' && id.startsWith('ses')
116
+ }
117
+
118
+ function numberOrNull(value: unknown): number | null {
119
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
120
+ }
121
+
122
+ /* ─── Delegation helpers ─────────────────────────────────── */
123
+
124
+ export type DelegationState = 'running' | 'completed' | 'failed' | 'interrupted'
125
+ export type DelegationTone = 'warning' | 'error' | 'success'
126
+
127
+ /** Grace window for a child that has not started yet. V2's `session.status()`
128
+ * returns `idle` both for "created but not started" and for "finished", so an
129
+ * idle/unknown/absent child whose own last activity (`time.updated`, falling
130
+ * back to `time.created`) is this recent still reads as running — otherwise a
131
+ * just-spawned child renders a premature ✓. Trade-off (V1 parity: it had the
132
+ * same window for its absent statuses): a child that finished within the window
133
+ * and reports no `outcome` reads as running until the window closes, at which
134
+ * point it turns terminal and its completion toast fires. A child that reports
135
+ * a terminal `outcome` is never held back by the window. */
136
+ export const DELEGATION_CHILD_STATUS_GRACE_MS = 60 * 1000
137
+
138
+ /** One delegated child job as the sidebar renders it. */
139
+ export type DelegationView = {
140
+ /** Child session id — also the click-through target. */
141
+ id: string
142
+ /** Alias shown on the row: the child title, else agent, else short id. */
143
+ label: string
144
+ state: DelegationState
145
+ /** Epoch ms of the child's `time.created`. */
146
+ startedAt: number | null
147
+ /** Epoch ms of the child's `time.updated`. */
148
+ updatedAt: number | null
149
+ }
150
+
151
+ /** Map the host's live session status to a display state. V2 exposes only
152
+ * `"idle" | "running"` through the TUI context (the client union adds `retry`
153
+ * and `busy`); `error`/`failed` are handled defensively for host drift.
154
+ * `idle`, `retry` and unknown/absent statuses are terminal only once the
155
+ * child's own activity is older than the grace window — within it the child
156
+ * reads as running (see `DELEGATION_CHILD_STATUS_GRACE_MS`). An interruption is
157
+ * not part of this union: it arrives as a terminal `outcome`. Pure. */
158
+ export function delegationStateFromStatus(
159
+ status: string | undefined,
160
+ time?: { created?: number; updated?: number },
161
+ now = Date.now(),
162
+ graceMs = DELEGATION_CHILD_STATUS_GRACE_MS,
163
+ ): DelegationState {
164
+ switch (status) {
165
+ case 'running':
166
+ case 'busy':
167
+ return 'running'
168
+ case 'error':
169
+ case 'failed':
170
+ return 'failed'
171
+ default: {
172
+ const lastActivity = Math.max(time?.updated ?? 0, time?.created ?? 0)
173
+ if (lastActivity > 0 && now - lastActivity < graceMs) return 'running'
174
+ return 'completed'
175
+ }
176
+ }
177
+ }
178
+
179
+ /** Map a V2 child `outcome` to a display state. `interrupted` (user abort,
180
+ * shutdown, superseded, inactivity) is distinct from both success and failure,
181
+ * so an aborted child never renders the ✓ of a completed one. Returns null for
182
+ * an absent/unrecognised outcome so the live status decides. Pure. */
183
+ export function delegationStateFromOutcome(outcome: unknown): DelegationState | null {
184
+ switch (outcome) {
185
+ case 'succeeded':
186
+ return 'completed'
187
+ case 'failed':
188
+ return 'failed'
189
+ case 'interrupted':
190
+ return 'interrupted'
191
+ default:
192
+ return null
193
+ }
194
+ }
195
+
196
+ /** Build a delegation row from a child session + its live status. A terminal
197
+ * `outcome` wins when present (the only V2 primitive that surfaces a
198
+ * delegation failure/interruption once the status is back to idle); otherwise
199
+ * the live status decides, with the absent/idle grace window applied. Pure. */
200
+ export function toDelegationView(
201
+ child: any,
202
+ status: string | undefined,
203
+ now = Date.now(),
204
+ ): DelegationView {
205
+ const id = typeof child?.id === 'string' ? child.id : ''
206
+ const title = typeof child?.title === 'string' && child.title !== '' ? child.title : ''
207
+ const agent = typeof child?.agent === 'string' && child.agent !== '' ? child.agent : ''
208
+ const label = title !== '' ? title : agent !== '' ? agent : id.slice(0, 8)
209
+ const state: DelegationState =
210
+ delegationStateFromOutcome(child?.outcome) ??
211
+ delegationStateFromStatus(status, child?.time, now)
212
+ return {
213
+ id,
214
+ label,
215
+ state,
216
+ startedAt: numberOrNull(child?.time?.created),
217
+ updatedAt: numberOrNull(child?.time?.updated),
218
+ }
219
+ }
220
+
221
+ const DELEGATION_TONES: Record<DelegationState, DelegationTone> = {
222
+ running: 'warning',
223
+ completed: 'success',
224
+ failed: 'error',
225
+ interrupted: 'warning',
226
+ }
227
+
228
+ /** Whole-row tone: red = failed, green = terminal, yellow = in flight. The
229
+ * glyph stays a redundant channel so the state reads in monochrome. Pure. */
230
+ export function delegationStateTone(state: DelegationState): DelegationTone {
231
+ return DELEGATION_TONES[state]
232
+ }
233
+
234
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
235
+
236
+ /** Spinner glyph for a frame counter (incremented every ~80ms while any job is
237
+ * running). Pure. */
238
+ export function spinnerFrame(frame: number): string {
239
+ const index = Math.abs(Math.floor(frame)) % SPINNER_FRAMES.length
240
+ return SPINNER_FRAMES[index] ?? SPINNER_FRAMES[0]
241
+ }
242
+
243
+ /** Row marker (`<glyph> `): the animated spinner while running, ✓ when done,
244
+ * ⊘ when interrupted, ✕ on failure. Pure. */
245
+ export function delegationRowMarker(state: DelegationState, frame = 0): string {
246
+ if (state === 'running') return `${spinnerFrame(frame)} `
247
+ if (state === 'failed') return '\u2715 '
248
+ if (state === 'interrupted') return '\u2298 '
249
+ return '\u2713 '
250
+ }
251
+
252
+ /** Compact single-unit elapsed label: "12s"/"3m"/"1h"/"2d". Pure. */
253
+ export function fmtElapsed(ms: number): string {
254
+ const total = Math.max(0, Math.floor(ms / 1000))
255
+ const days = Math.floor(total / 86_400)
256
+ const hours = Math.floor((total % 86_400) / 3_600)
257
+ const minutes = Math.floor((total % 3_600) / 60)
258
+ const seconds = total % 60
259
+ if (days > 0) return `${days}d`
260
+ if (hours > 0) return `${hours}h`
261
+ if (minutes > 0) return `${minutes}m`
262
+ return `${seconds}s`
263
+ }
264
+
265
+ /** Elapsed label for one row: a running job ticks `now - startedAt`; a terminal
266
+ * one is fixed at `updatedAt - startedAt` (em dash when a timestamp is
267
+ * missing). Pure. */
268
+ export function delegationElapsed(view: DelegationView, now: number): string {
269
+ if (view.state === 'running') {
270
+ return view.startedAt === null ? '\u2014' : fmtElapsed(now - view.startedAt)
271
+ }
272
+ if (view.startedAt !== null && view.updatedAt !== null)
273
+ return fmtElapsed(view.updatedAt - view.startedAt)
274
+ return '\u2014'
275
+ }
276
+
277
+ /** Live rows first (a running job never falls behind the cap), then by most
278
+ * recent activity. Pure. */
279
+ export function orderDelegations(views: readonly DelegationView[]): DelegationView[] {
280
+ return [...views].sort((a, b) => {
281
+ const activeA = a.state === 'running' ? 1 : 0
282
+ const activeB = b.state === 'running' ? 1 : 0
283
+ if (activeA !== activeB) return activeB - activeA
284
+ return (b.updatedAt ?? 0) - (a.updatedAt ?? 0)
285
+ })
286
+ }
287
+
288
+ /** Cap the panel: at most `maxVisible` rows, the remainder collapsing into the
289
+ * "… +N more" line. Pure. */
290
+ export function ceilingDelegations(
291
+ views: readonly DelegationView[],
292
+ maxVisible = DELEGATIONS_VISIBLE_CEILING,
293
+ ): { visible: DelegationView[]; hidden: number } {
294
+ const limit = Math.max(0, maxVisible)
295
+ const visible = views.slice(0, limit)
296
+ return { visible, hidden: Math.max(0, views.length - visible.length) }
297
+ }
298
+
299
+ /** Session ids to eagerly sync at load (deduped, bounded). Pure. */
300
+ export function collectWarmupTargets(
301
+ sessions: readonly any[],
302
+ limit = WARMUP_SESSION_LIMIT,
303
+ ): string[] {
304
+ const ids: string[] = []
305
+ for (const session of sessions) {
306
+ if (!isValidSessionID(session?.id) || ids.includes(session.id)) continue
307
+ ids.push(session.id)
308
+ if (ids.length >= limit) break
309
+ }
310
+ return ids
311
+ }
312
+
313
+ /* ─── Known Issue guard ──────────────────────────────────── */
314
+
315
+ /** Body a sidebar instance renders when the host has not published a real
316
+ * session id yet. OpenCode V2 scopes the `sidebar.content` slot to a session
317
+ * (`SlotMap["sidebar.content"] = { sessionID: string }`), so before the first
318
+ * prompt the slot may not mount at all — see the `home.footer.status`
319
+ * contribution for the pre-prompt degradation. Pure. */
320
+ export function sidebarPlaceholder(sessionID: string | undefined): string | null {
321
+ return isValidSessionID(sessionID) ? null : SIDEBAR_WAITING_LABEL
322
+ }
323
+
324
+ /* ─── Click-through ──────────────────────────────────────── */
325
+
326
+ /** Navigate to a session through the V2 router. Fail-open: an absent router or
327
+ * a removed session must never break the sidebar. Returns whether a valid
328
+ * navigation was dispatched. Pure. */
329
+ export function navigateToSession(
330
+ router: { navigate?: (destination: any) => void } | undefined,
331
+ sessionID: string | undefined,
332
+ ): boolean {
333
+ if (typeof router?.navigate !== 'function' || !isValidSessionID(sessionID)) return false
334
+ try {
335
+ router.navigate({ type: 'session', sessionID })
336
+ return true
337
+ } catch {
338
+ return false
339
+ }
340
+ }
341
+
342
+ /* ─── Delegate-completion detection (toasts) ─────────────── */
343
+
344
+ export type DelegationCompletion = { id: string; label: string; state: DelegationState }
345
+
346
+ /** Diff two delegation observations: entries that were `running` and are no
347
+ * longer running count as completions, fired once per transition. Returns the
348
+ * next snapshot so callers can keep a single tracker. First observation never
349
+ * reports a completion (no toast spam on mount). Pure. */
350
+ export function detectDelegationCompletions(
351
+ previous: ReadonlyMap<string, DelegationState>,
352
+ current: ReadonlyArray<DelegationCompletion>,
353
+ ): { completed: DelegationCompletion[]; snapshot: Map<string, DelegationState> } {
354
+ const snapshot = new Map<string, DelegationState>()
355
+ const completed: DelegationCompletion[] = []
356
+ for (const entry of current) {
357
+ snapshot.set(entry.id, entry.state)
358
+ if (previous.get(entry.id) === 'running' && entry.state !== 'running') completed.push(entry)
359
+ }
360
+ return { completed, snapshot }
361
+ }
362
+
363
+ /* ─── Runtime helpers ────────────────────────────────────── */
364
+
365
+ function resolveLocation(context: any): any {
366
+ try {
367
+ return context?.location ?? context?.data?.location?.default()
368
+ } catch {
369
+ return undefined
370
+ }
371
+ }
372
+
373
+ function resolveSidebarCwd(context: any): string {
374
+ const location = resolveLocation(context)
375
+ if (location && typeof location.directory === 'string' && location.directory !== '') {
376
+ return location.directory
377
+ }
378
+ return process.cwd()
379
+ }
380
+
381
+ function sessionStatusOf(context: any, sessionID: string): string | undefined {
382
+ try {
383
+ const status = context?.data?.session?.status?.(sessionID)
384
+ return typeof status === 'string' ? status : undefined
385
+ } catch {
386
+ return undefined
387
+ }
388
+ }
389
+
390
+ /** Populate the cached data at load so the sidebar has content before the first
391
+ * prompt (Known Issue guard). Fire-and-forget: a missing API or a failed sync
392
+ * degrades silently and the sidebar still renders its static header. */
393
+ function warmUpSidebarData(context: any, location: any): void {
394
+ try {
395
+ void Promise.resolve(context?.data?.location?.vcs?.sync?.(location)).catch(() => {})
396
+ } catch {
397
+ /* vcs API unavailable */
398
+ }
399
+ let listed: any[] = []
400
+ try {
401
+ const result = context?.data?.session?.list?.()
402
+ if (Array.isArray(result)) listed = result
403
+ } catch {
404
+ /* session API unavailable */
405
+ }
406
+ for (const id of collectWarmupTargets(listed)) {
407
+ try {
408
+ void Promise.resolve(context?.data?.session?.sync?.(id)).catch(() => {})
409
+ } catch {
410
+ /* per-session sync unavailable */
411
+ }
412
+ }
413
+ }
414
+
415
+ function showDelegationToast(context: any, done: DelegationCompletion): void {
416
+ const variant =
417
+ done.state === 'failed' ? 'error' : done.state === 'interrupted' ? 'warning' : 'success'
418
+ const verb =
419
+ done.state === 'failed' ? 'falhou' : done.state === 'interrupted' ? 'interrompida' : 'concluída'
420
+ try {
421
+ context?.ui?.toast?.show?.({
422
+ title: TOAST_TITLE,
423
+ message: `${done.label} ${verb}`,
424
+ variant,
425
+ sessionID: done.id,
426
+ })
427
+ } catch {
428
+ /* toast API unavailable — the row already carries the state tone */
429
+ }
430
+ }
431
+
432
+ /** Active preset signal with mount + 30s refresh. Shared by the sidebar and the
433
+ * home-screen status contribution. */
434
+ function useActivePreset(context: any): () => PresetInfo {
435
+ const [preset, setPreset] = createSignal<PresetInfo>(presetFromEnv(process.env))
436
+ onMount(() => {
437
+ const cwd = resolveSidebarCwd(context)
438
+ let cancelled = false
439
+ const refresh = async () => {
440
+ try {
441
+ const info = await resolvePresetForTui(process.env, cwd)
442
+ if (!cancelled) setPreset(info)
443
+ } catch {
444
+ /* transient read error — keep the last known value */
445
+ }
446
+ }
447
+ void refresh()
448
+ const timer = setInterval(() => void refresh(), PRESET_REFRESH_MS)
449
+ onCleanup(() => {
450
+ cancelled = true
451
+ clearInterval(timer)
452
+ })
453
+ })
454
+ return preset
455
+ }
456
+
457
+ /* ─── Reactive model ─────────────────────────────────────── */
458
+
459
+ /** Reactive state the sidebar view renders. Accessors keep the view reactive
460
+ * while the model itself stays free of JSX, so its effects are exercisable
461
+ * without a TUI renderer. */
462
+ export type SidebarModel = {
463
+ activeSessionID: Accessor<string>
464
+ session: Accessor<any>
465
+ branch: Accessor<string | null>
466
+ delegations: Accessor<DelegationView[]>
467
+ ceiling: Accessor<{ visible: DelegationView[]; hidden: number }>
468
+ hasRunning: Accessor<boolean>
469
+ frame: Accessor<number>
470
+ preset: Accessor<PresetInfo>
471
+ }
472
+
473
+ /** Reactive wiring behind the sidebar: refresh subscription, warm syncs, the
474
+ * running-only spinner and the completion-toast effect. `context` stays loosely
475
+ * typed (the host API only exists at runtime) but the refresh event names are
476
+ * narrowed to the V2 union at the subscription site. */
477
+ export function createSidebarModel(
478
+ context: any,
479
+ sessionID: () => string | undefined,
480
+ ): SidebarModel {
481
+ const [revision, setRevision] = createSignal(0)
482
+ const [frame, setFrame] = createSignal(0)
483
+ const location = resolveLocation(context)
484
+ const preset = useActivePreset(context)
485
+ let observedStates = new Map<string, DelegationState>()
486
+
487
+ const refresh = () => setRevision((value) => value + 1)
488
+ const activeSessionID = () => {
489
+ const id = sessionID()
490
+ return isValidSessionID(id) ? id : ''
491
+ }
492
+
493
+ const session = createMemo(() => {
494
+ revision()
495
+ const id = activeSessionID()
496
+ if (id === '') return undefined
497
+ try {
498
+ return context.data.session.get(id)
499
+ } catch {
500
+ /* session API unavailable */
501
+ return undefined
502
+ }
503
+ })
504
+
505
+ const branch = createMemo(() => {
506
+ revision()
507
+ try {
508
+ return context.data.location.vcs.info(location)?.branch?.current ?? null
509
+ } catch {
510
+ /* vcs API unavailable */
511
+ return null
512
+ }
513
+ })
514
+
515
+ // Delegations = the children of the active session (native task() children),
516
+ // enriched with each child's live status + outcome. The V2 family API returns
517
+ // the id set; `parentID` narrows it to direct children exactly like V1.
518
+ const allDelegations = createMemo(() => {
519
+ revision()
520
+ const id = activeSessionID()
521
+ if (id === '') return []
522
+ let family: string[] = []
523
+ try {
524
+ const result = context.data.session.family?.(id)
525
+ if (Array.isArray(result)) family = result
526
+ } catch {
527
+ return []
528
+ }
529
+ const now = Date.now()
530
+ const views: DelegationView[] = []
531
+ for (const childID of family) {
532
+ if (childID === id || !isValidSessionID(childID)) continue
533
+ const child = context.data.session.get(childID)
534
+ if (child?.parentID !== id) continue
535
+ views.push(toDelegationView(child, sessionStatusOf(context, childID), now))
536
+ }
537
+ return orderDelegations(views)
538
+ })
539
+
540
+ // The sidebar lists in-flight work only; terminal jobs are covered by the
541
+ // completion toast, so they are dropped from the displayed set. The full set
542
+ // is kept for the running→terminal detection that feeds those toasts.
543
+ const delegations = createMemo(() => allDelegations().filter((view) => view.state === 'running'))
544
+
545
+ const ceiling = createMemo(() => ceilingDelegations(delegations(), DELEGATIONS_VISIBLE_CEILING))
546
+ const hasRunning = createMemo(() => delegations().length > 0)
547
+
548
+ onMount(() => {
549
+ const stop: Array<() => void> = []
550
+ // The event names come from the V2 union; this cast is what turns an unknown
551
+ // name into a compile error instead of a silent no-op subscription.
552
+ const data = context?.data as Context['data'] | undefined
553
+ if (typeof data?.on === 'function') {
554
+ for (const type of SIDEBAR_REFRESH_EVENTS) {
555
+ try {
556
+ const unsubscribe = data.on(type, refresh)
557
+ if (typeof unsubscribe === 'function') stop.push(unsubscribe)
558
+ } catch {
559
+ // Sidebar stays useful with the initial cached state if an event is unavailable.
560
+ }
561
+ }
562
+ }
563
+ const id = activeSessionID()
564
+ if (id !== '') {
565
+ try {
566
+ void Promise.resolve(context.data.session.sync(id)).then(refresh)
567
+ } catch {
568
+ /* sync unavailable */
569
+ }
570
+ }
571
+ try {
572
+ void Promise.resolve(context.data.location.vcs.sync(location)).then(refresh)
573
+ } catch {
574
+ /* vcs sync unavailable */
575
+ }
576
+
577
+ // Spinner ticks only while a job is running; terminal rows stay static. The
578
+ // interval is armed on the running edge and cleared on the terminal one, so
579
+ // an idle sidebar schedules no work at all.
580
+ createEffect(() => {
581
+ if (!hasRunning()) return
582
+ const spinner = setInterval(() => setFrame((value) => value + 1), SPINNER_INTERVAL_MS)
583
+ onCleanup(() => clearInterval(spinner))
584
+ })
585
+
586
+ onCleanup(() => {
587
+ for (const unsubscribe of stop) {
588
+ if (typeof unsubscribe === 'function') unsubscribe()
589
+ }
590
+ })
591
+ })
592
+
593
+ // Discrete completion toast: exactly one per running→terminal transition.
594
+ createEffect(() => {
595
+ const views = allDelegations()
596
+ if (activeSessionID() === '') {
597
+ observedStates = new Map()
598
+ return
599
+ }
600
+ const { completed, snapshot } = detectDelegationCompletions(
601
+ observedStates,
602
+ views.map((view) => ({ id: view.id, label: view.label, state: view.state })),
603
+ )
604
+ observedStates = snapshot
605
+ for (const done of completed) showDelegationToast(context, done)
606
+ })
607
+
608
+ return {
609
+ activeSessionID,
610
+ session,
611
+ branch,
612
+ delegations,
613
+ ceiling,
614
+ hasRunning,
615
+ frame,
616
+ preset,
617
+ }
618
+ }
619
+
620
+ /* ─── Components ─────────────────────────────────────────── */
621
+
622
+ function Sidebar(props: { context: any; sessionID?: string }) {
623
+ const { context } = props
624
+ const model = createSidebarModel(context, () => props.sessionID)
625
+ const text = () => context.theme.text?.base
626
+ const muted = () => context.theme.text?.muted ?? text()
627
+ const tone = (value: DelegationTone) => context.theme?.text?.feedback?.[value]?.base ?? text()
628
+ const open = (sessionID: string) => () => navigateToSession(context.ui?.router, sessionID)
629
+
630
+ return (
631
+ <box flexDirection="column" width="100%" padding={1}>
632
+ <text fg={text()} attributes={1}>{`Pantheon v${__PANTHEON_VERSION__}`}</text>
633
+ <text fg={muted()}>OpenCode V2 · plugin TUI ativo</text>
634
+ <Show when={model.branch()}>
635
+ {(value) => <text fg={muted()}>{`Branch: ${value()}`}</text>}
636
+ </Show>
637
+ <Show
638
+ when={model.preset().name}
639
+ fallback={
640
+ <box flexDirection="row">
641
+ <text fg={muted()}>Preset: default</text>
642
+ </box>
643
+ }
644
+ >
645
+ {(name) => (
646
+ <box flexDirection="row">
647
+ <text fg={muted()}>{'⚡ Preset: '}</text>
648
+ <text fg={text()}>{name()}</text>
649
+ <text fg={muted()}>{` (${model.preset().source ?? ''})`}</text>
650
+ </box>
651
+ )}
652
+ </Show>
653
+
654
+ <Show
655
+ when={model.activeSessionID() !== '' && model.session()}
656
+ fallback={<text fg={muted()}>{sidebarPlaceholder(props.sessionID)}</text>}
657
+ >
658
+ {(current: any) => (
659
+ <>
660
+ <text fg={text()}>{`Sessão: ${current().title || current().id}`}</text>
661
+ <Show when={current().agent}>
662
+ <text fg={muted()}>{`Agente: ${current().agent}`}</text>
663
+ </Show>
664
+ </>
665
+ )}
666
+ </Show>
667
+
668
+ <Show when={model.delegations().length > 0}>
669
+ <text fg={text()} attributes={1}>
670
+ Delegações
671
+ </text>
672
+ <For each={model.ceiling().visible}>
673
+ {(view: DelegationView) => (
674
+ <box onMouseDown={open(view.id)}>
675
+ <text fg={tone(delegationStateTone(view.state))}>
676
+ {`${delegationRowMarker(view.state, model.frame())}${view.label} ${delegationElapsed(view, Date.now())}`}
677
+ </text>
678
+ </box>
679
+ )}
680
+ </For>
681
+ <Show when={model.ceiling().hidden > 0}>
682
+ <text fg={muted()}>{`… +${model.ceiling().hidden} more`}</text>
683
+ </Show>
684
+ </Show>
685
+ </box>
686
+ )
687
+ }
688
+
689
+ /** Home-screen status contribution. The sidebar slot is session-scoped, so
690
+ * before the first prompt the sidebar may not mount at all (Known Issue) —
691
+ * this keeps a compact Pantheon line visible on the home footer meanwhile. */
692
+ function HomeStatus(props: { context: any }) {
693
+ const preset = useActivePreset(props.context)
694
+ const muted = () => props.context.theme?.text?.muted ?? props.context.theme?.text?.base
695
+ return (
696
+ <text
697
+ fg={muted()}
698
+ >{`Pantheon v${__PANTHEON_VERSION__}${preset().name ? ` ⚡ ${preset().name}` : ''}`}</text>
699
+ )
700
+ }
701
+
702
+ /* ─── Plugin registration ────────────────────────────────── */
703
+
704
+ const plugin = Plugin.define({
705
+ id: 'pantheon.tui',
706
+ setup(context: Context) {
707
+ const globalState = globalThis as unknown as Record<PropertyKey, unknown>
708
+ if (globalState[SINGLETON]) return
709
+ globalState[SINGLETON] = true
710
+
711
+ const location = resolveLocation(context)
712
+ // Eager load/sync at setup so the cache is warm before the slot's first
713
+ // render (Known Issue: empty sidebar before the first prompt).
714
+ warmUpSidebarData(context, location)
715
+
716
+ const unregisterSidebar = context.ui.slot({
717
+ append: 'sidebar.content',
718
+ render: ({ sessionID }: { sessionID: string }) => (
719
+ <Sidebar context={context} sessionID={sessionID} />
720
+ ),
721
+ })
722
+ const unregisterHome = context.ui.slot({
723
+ append: 'home.footer.status',
724
+ render: () => <HomeStatus context={context} />,
725
+ })
726
+ return () => {
727
+ if (typeof unregisterSidebar === 'function') unregisterSidebar()
728
+ if (typeof unregisterHome === 'function') unregisterHome()
729
+ delete globalState[SINGLETON]
730
+ }
731
+ },
732
+ })
733
+
734
+ export default plugin