@cleocode/goal 0.0.0-stage → 0.9.2

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 (68) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/LICENSE +21 -0
  3. package/README.md +141 -2
  4. package/cli/ocg.ts +149 -0
  5. package/dist/cli.js +8708 -0
  6. package/dist/tui.d.ts +2 -0
  7. package/dist/tui.js +1920 -0
  8. package/package.json +79 -4
  9. package/rpc.ts +2 -0
  10. package/server.ts +3 -0
  11. package/skills/write-goal/SKILL.md +211 -0
  12. package/skills/write-goal/assets/goal.template.yaml +38 -0
  13. package/skills/write-goal/references/examples.md +283 -0
  14. package/skills/write-goal/references/goal-schema.md +53 -0
  15. package/skills/write-goal/references/interview.md +36 -0
  16. package/src/cli/install.ts +125 -0
  17. package/src/cli/status.ts +116 -0
  18. package/src/contract/parse.ts +454 -0
  19. package/src/contract/render.ts +134 -0
  20. package/src/contract/types.ts +158 -0
  21. package/src/engine/amendment.ts +51 -0
  22. package/src/engine/amendments.ts +72 -0
  23. package/src/engine/cleo-bridge.ts +339 -0
  24. package/src/engine/cleo-link.ts +150 -0
  25. package/src/engine/continue-work.ts +131 -0
  26. package/src/engine/decisions.ts +44 -0
  27. package/src/engine/dogfood-record.ts +278 -0
  28. package/src/engine/handoff.ts +102 -0
  29. package/src/engine/interrupt-policy.ts +45 -0
  30. package/src/engine/inventory.ts +70 -0
  31. package/src/engine/launches.ts +82 -0
  32. package/src/engine/loop-stop.ts +50 -0
  33. package/src/engine/loop.ts +109 -0
  34. package/src/engine/next-work.ts +82 -0
  35. package/src/engine/owner-intent.ts +71 -0
  36. package/src/engine/owner-reviews.ts +178 -0
  37. package/src/engine/peer-announce.ts +67 -0
  38. package/src/engine/peer-transport.ts +78 -0
  39. package/src/engine/policy.ts +42 -0
  40. package/src/engine/registry.ts +119 -0
  41. package/src/engine/similarity.ts +65 -0
  42. package/src/engine/stale-decisions.ts +68 -0
  43. package/src/engine/state.ts +185 -0
  44. package/src/engine/store.ts +263 -0
  45. package/src/engine/tick-record.ts +129 -0
  46. package/src/engine/tick.ts +207 -0
  47. package/src/rpc.ts +182 -0
  48. package/src/server/app.ts +4320 -0
  49. package/src/server/help.ts +50 -0
  50. package/src/server/index.ts +23 -0
  51. package/src/server/intent.ts +22 -0
  52. package/src/server/messages.ts +228 -0
  53. package/src/summary/audit.ts +76 -0
  54. package/src/summary/build.ts +314 -0
  55. package/src/tui/dashboard.ts +322 -0
  56. package/src/tui/decision-form.ts +12 -0
  57. package/src/tui/format.ts +127 -0
  58. package/src/tui/index.tsx +718 -0
  59. package/src/tui/manager.ts +94 -0
  60. package/src/tui/navigation.ts +11 -0
  61. package/src/tui/pointer-select.ts +20 -0
  62. package/src/tui/tracer.ts +51 -0
  63. package/src/util/git.ts +102 -0
  64. package/src/util/ids.ts +20 -0
  65. package/src/util/shell.ts +42 -0
  66. package/src/verify/artifact.ts +95 -0
  67. package/src/verify/pipeline.ts +200 -0
  68. package/tui.tsx +2 -0
@@ -0,0 +1,718 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ // TUI plugin: a live goal card in the sidebar, a status pill under the prompt,
3
+ // a banner when the owner is needed, notices as toasts and desktop attention,
4
+ // and palette commands. Fed only by the server's GoalRpc (S6), never by files.
5
+ import type { Plugin } from "@opencode/plugin/tui"
6
+ import { createEffect, createSignal, For, onCleanup, Show, untrack } from "solid-js"
7
+ import { createStore, reconcile } from "solid-js/store"
8
+ import { GoalRpc, type GoalSummary, type GoalView, type LaunchView } from "../rpc"
9
+ import { bannerText, pillText, statusText, type Tone } from "./format"
10
+ import { tracerLines } from "./tracer"
11
+ import { cardAffordanceTab, dashboardLines, decisionRows, dialogDigest, DASHBOARD_TABS, quickActRows, type DashboardTab } from "./dashboard"
12
+ import { fitColumns, goalKey, launchStatusText, managerActions, orderedGoals, sidebarCards, targetOf, wrapColumns, type ManagerAction } from "./manager"
13
+ import { openGoalSession } from "./navigation"
14
+ import { pointerSelect } from "./pointer-select"
15
+
16
+ type Context = Plugin.Context
17
+
18
+ const SIDEBAR_WIDTH = 40
19
+ /** The name this plugin opens and renders in the session.panel slot. */
20
+ const PANEL_NAME = "goal-dashboard"
21
+
22
+ export default {
23
+ id: "opencode-goal",
24
+ async setup(context: Context) {
25
+ const rpc = context.client.rpc(GoalRpc as any) as any
26
+ const [views, setViews] = createStore<Record<string, GoalView | null>>({})
27
+ const [launches, setLaunches] = createStore<Record<string, LaunchView[]>>({})
28
+ const currentLaunch = (sessionID: string) => [...(launches[sessionID] ?? [])].sort((a, b) => Number(b.status === "executing") - Number(a.status === "executing") || (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0]
29
+ const [now, setNow] = createSignal(Date.now())
30
+ const timer = setInterval(() => setNow(Date.now()), 1000)
31
+ const requested = new Set<string>()
32
+ const [goalsList, setGoalsList] = createSignal<GoalSummary[]>([])
33
+ const [goalsState, setGoalsState] = createSignal<"loading" | "ready" | "error">("loading")
34
+ const [selectedGoalKey, setSelectedGoalKey] = createSignal<string>()
35
+ const [selectedDetails, setSelectedDetails] = createSignal<GoalSummary>()
36
+ const [managerNotice, setManagerNotice] = createSignal<{ message: string; tone: Tone }>()
37
+ let detailRequest = 0
38
+ let goalsFetchedAt = 0
39
+ let goalsRequested = false
40
+ let goalsInFlight = false
41
+ let goalsRefreshAgain = false
42
+ let goalsDirectory: string | undefined
43
+ let disposed = false
44
+ let focusManagerSection: (() => void) | undefined
45
+ // OpenCode 2.0.26 can render a fullscreen session.panel while
46
+ // ui.panel.current() is null. Track the real mounted slot, not a guessed
47
+ // global toggle flag, and use its own documented close capability.
48
+ const mountedPanels = new Map<string, { close: () => void }>()
49
+ const currentMountedPanel = () => {
50
+ const route = context.ui.router.current()
51
+ return route.type === "session" ? mountedPanels.get(route.sessionID) : undefined
52
+ }
53
+
54
+ const locationOf = (sessionID?: string) => (sessionID ? (context.data.session.get(sessionID) as any)?.location : undefined) ?? context.location ?? context.data.location?.default()
55
+ const ensureGoals = async (force = false) => {
56
+ goalsRequested = true
57
+ const route = context.ui.router.current()
58
+ const location = locationOf(route.type === "session" ? route.sessionID : undefined)
59
+ const directory = location?.directory
60
+ if (goalsDirectory !== directory) {
61
+ goalsDirectory = directory
62
+ goalsFetchedAt = 0
63
+ setGoalsList([])
64
+ setGoalsState("loading")
65
+ setSelectedGoalKey(undefined)
66
+ setSelectedDetails(undefined)
67
+ }
68
+ if (goalsInFlight) { if (force) goalsRefreshAgain = true; return }
69
+ if (!force && Date.now() - goalsFetchedAt < 4000) return
70
+ goalsInFlight = true
71
+ goalsFetchedAt = Date.now()
72
+ try {
73
+ const listed = await rpc.list({}, location ? { location } : undefined)
74
+ if (disposed || goalsDirectory !== directory) return
75
+ if (!Array.isArray(listed?.goals)) throw new Error("Goal inventory missing")
76
+ setGoalsList(listed.goals as GoalSummary[])
77
+ setGoalsState("ready")
78
+ } catch {
79
+ if (!disposed && goalsDirectory === directory) setGoalsState("error")
80
+ } finally {
81
+ goalsInFlight = false
82
+ if (!disposed && goalsRefreshAgain) { goalsRefreshAgain = false; void ensureGoals(true) }
83
+ }
84
+ }
85
+ // T058: events are the fast path, not the only path. A subscription that
86
+ // attaches before the plugin registers, a dropped stream, or a reconnect
87
+ // must never freeze the dashboard at its first snapshot (the live defect:
88
+ // stuck on S1, 0/N criteria for a whole run). Renders and a periodic
89
+ // ticker re-fetch snapshots that went stale, so the view converges.
90
+ const STALE_MS = 4000
91
+ const fetchedAt = new Map<string, number>()
92
+ const fetchView = async (sessionID: string) => {
93
+ fetchedAt.set(sessionID, Date.now())
94
+ try {
95
+ const location = locationOf(sessionID)
96
+ const result = await rpc.snapshot({ sessionID }, location ? { location } : undefined)
97
+ setViews(sessionID, reconcile((result?.view ?? null) as GoalView | null))
98
+ if (Array.isArray(result?.launches)) setLaunches(sessionID, reconcile(result.launches as LaunchView[]))
99
+ } catch {
100
+ // the server plugin may not be loaded for this location; show nothing
101
+ }
102
+ }
103
+ const ensure = (sessionID: string) => {
104
+ if (requested.has(sessionID)) return
105
+ requested.add(sessionID)
106
+ void fetchView(sessionID)
107
+ }
108
+
109
+ const offUpdated = rpc.events.on("updated", (event: any) => {
110
+ const data = event.data ?? {}, { sessionID, view, launch } = data
111
+ if (typeof sessionID === "string") {
112
+ // Launch-only notifications must not erase another live run's view.
113
+ if ("view" in data) setViews(sessionID, reconcile((view ?? null) as GoalView | null))
114
+ if (launch && typeof launch.decisionId === "string") setLaunches(sessionID, reconcile([...(launches[sessionID] ?? []).filter((p) => p.decisionId !== launch.decisionId), launch]))
115
+ }
116
+ if (goalsRequested) void ensureGoals(true)
117
+ })
118
+ const offNotice = rpc.events.on("notice", (event: any) => {
119
+ const data = event.data ?? {}
120
+ const current = context.ui.router.current()
121
+ const here = current.type === "session" && current.sessionID === data.sessionID
122
+ if (data.panel && here) {
123
+ const view = views[data.sessionID]
124
+ void context.ui.dialog.alert({ title: "Goal", message: view ? statusText(view, Date.now()) : String(data.text) })
125
+ } else {
126
+ context.ui.toast.show({ title: "Goal", message: String(data.text ?? ""), variant: (data.level ?? "info") as any, duration: data.level === "error" ? 8000 : 5000, ...(data.sessionID ? { sessionID: data.sessionID } : {}) })
127
+ }
128
+ if (data.attention)
129
+ void context.attention.notify({ title: "Goal", message: String(data.text ?? ""), notification: { when: "blurred" }, sound: { name: data.attention, when: "blurred" } })
130
+ })
131
+ // T045: the post-goal summary lands as a dialog for the owner (headline
132
+ // as the toast, full text — provenance caveats, scope audit, follow-ups —
133
+ // in the dialog), only for the session the goal belongs to.
134
+ // T065: dialogs are not scrollable — the full text once overflowed off
135
+ // the screen; dialogs carry a screen-fit digest, the panel keeps the rest.
136
+ // A summary must not displace an unanswered native Composer decision.
137
+ const openDecisions = new Map<string, number>()
138
+ const pendingSummaries = new Map<string, { headline: string; text: string; status: string; title?: string; digest?: string }>()
139
+ const summaryTitle = (data: any) => {
140
+ const label = String(data.status ?? "summary").replace(/_/g, " ")
141
+ return data.status === "complete" ? `✓ Goal complete — ${data.title ?? label}` : `Goal ${label}`
142
+ }
143
+ const summaryBody = (data: any) => String(data.digest ?? dialogDigest(String(data.headline ?? "summary"), String(data.text ?? "")))
144
+ const flushSummary = (sessionID: string) => {
145
+ const queued = pendingSummaries.get(sessionID)
146
+ pendingSummaries.delete(sessionID)
147
+ if (!queued) return
148
+ void context.ui.dialog.alert({
149
+ title: summaryTitle(queued),
150
+ message: summaryBody(queued),
151
+ })
152
+ }
153
+ const offSummary = rpc.events.on("summary", (event: any) => {
154
+ const data = event.data ?? {}
155
+ const current = context.ui.router.current()
156
+ if (current.type !== "session" || current.sessionID !== data.sessionID) {
157
+ context.ui.toast.show({ title: "Goal summary", message: String(data.headline ?? data.text ?? ""), variant: "info", ...(data.sessionID ? { sessionID: data.sessionID } : {}) })
158
+ return
159
+ }
160
+ // Queue the digest behind an actual pending native review.
161
+ if (openDecisions.has(data.sessionID)) {
162
+ pendingSummaries.set(data.sessionID, { headline: String(data.headline ?? "summary"), text: String(data.text ?? data.headline ?? ""), status: String(data.status ?? ""), title: data.title ? String(data.title) : undefined, digest: data.digest ? String(data.digest) : undefined })
163
+ context.ui.toast.show({ title: "Goal summary queued", message: String(data.headline ?? "the full summary opens after the decision"), variant: "info", ...(data.sessionID ? { sessionID: data.sessionID } : {}) })
164
+ return
165
+ }
166
+ void context.ui.dialog.alert({
167
+ title: summaryTitle(data),
168
+ message: summaryBody(data),
169
+ })
170
+ })
171
+ // Composer's core form is the sole decision surface. A notification
172
+ // never synthesizes an answer or remembers a second popup choice.
173
+ const offData = context.data.listen(({ details }: any) => {
174
+ if (details?.type === "server.connected") {
175
+ for (const id of requested) void fetchView(id)
176
+ if (goalsRequested) void ensureGoals(true)
177
+ }
178
+ })
179
+ const offHandoff = rpc.events.on("handoff", (event: any) => {
180
+ const data = event.data ?? {}
181
+ const route = context.ui.router.current()
182
+ if (typeof data.toSessionID !== "string" || typeof data.fromSessionID !== "string") return
183
+ if (route.type === "session" && route.sessionID === data.fromSessionID) openGoalSession(context.ui, data.toSessionID)
184
+ else context.ui.toast.show({ title: "Goal running elsewhere", message: `${data.slug} is running in ${data.toSessionID}; original history is preserved.`, variant: "info" })
185
+ void ensureGoals(true)
186
+ })
187
+ const refreshIfStale = (sessionID: string) => {
188
+ if (Date.now() - (fetchedAt.get(sessionID) ?? 0) <= STALE_MS) return
189
+ fetchedAt.set(sessionID, Date.now())
190
+ void fetchView(sessionID)
191
+ }
192
+ const ticker = setInterval(() => {
193
+ // A terminal (or empty) snapshot is not terminal for the SESSION:
194
+ // another goal can start here while its updated event is missed.
195
+ // Refresh every requested session, including old completed goals.
196
+ for (const id of requested) refreshIfStale(id)
197
+ if (goalsRequested) void ensureGoals()
198
+ }, STALE_MS)
199
+
200
+ // T056: the panel opens ON DEMAND (the v0.2 auto-open displaced the
201
+ // sidebar); the toggle command is keybindable beside OpenCode's
202
+ // session.sidebar toggle. T073: opening from the card's expand affordance
203
+ // lands on the tab whose content the sidebar truncated — the affordance
204
+ // is a click-through, not a dead hint.
205
+ let panelFocusPending = false
206
+ const openPanel = () => {
207
+ panelFocusPending = true // T074 sign-off fix: the panel TAKES focus on open so tab/↑↓/enter reach it (with the composer focused, tab cycled nothing)
208
+ const id = currentSession()
209
+ const v = id ? views[id] : undefined
210
+ const target = v ? cardAffordanceTab(v, now(), SIDEBAR_WIDTH) : undefined
211
+ if (target) setTab(target)
212
+ context.ui.panel.open(PANEL_NAME)
213
+ }
214
+ const togglePanel = () => {
215
+ // The host changes active panel ownership when tabs change. A local
216
+ // boolean becomes stale and turns the first F9 into an invisible close.
217
+ const mounted = currentMountedPanel()
218
+ if (mounted || context.ui.panel.current()?.name === PANEL_NAME) {
219
+ if (mounted) mounted.close(); else context.ui.panel.close()
220
+ } else openPanel()
221
+ }
222
+ const [tab, setTab] = createSignal<DashboardTab>("now")
223
+ const expandGoal = (goal: GoalSummary) => {
224
+ setManagerNotice(undefined)
225
+ openPanel()
226
+ setSelectedGoalKey(goalKey(goal))
227
+ setSelectedDetails(goal)
228
+ setTab("goals") // expand here; never jump or attach on mere selection
229
+ void ensureGoals(true)
230
+ void fetchDetails(goal)
231
+ }
232
+ const fetchDetails = async (goal: GoalSummary) => {
233
+ const ticket = ++detailRequest
234
+ const id = currentSession(), location = id ? locationOf(id) : undefined
235
+ try {
236
+ const result = await rpc.details({ key: goalKey(goal) }, location ? { location } : undefined)
237
+ if (!disposed && ticket === detailRequest && selectedGoalKey() === goalKey(goal) && result?.goal) setSelectedDetails(result.goal)
238
+ } catch { /* Summary stays visible, with unknown detail fields unclaimed. */ }
239
+ }
240
+ const selectedGoal = () => {
241
+ const row = goalsList().find((g) => goalKey(g) === selectedGoalKey())
242
+ const detail = selectedDetails()
243
+ return row && detail && goalKey(detail) === goalKey(row) && detail.lock === row.lock && detail.sessionID === row.sessionID ? { ...detail, ...row, details: detail.details } : row
244
+ }
245
+ const manageSelected = async (row: ManagerAction) => {
246
+ const goal = selectedGoal(), sessionID = currentSession()
247
+ if (row.action === "back") { setSelectedGoalKey(undefined); setSelectedDetails(undefined); setManagerNotice(undefined); return }
248
+ if (!goal || !sessionID) { setManagerNotice({ message: "The selected goal or active session is unavailable. Reopen the manager with F9.", tone: "warning" }); return }
249
+ if (row.action === "jump") {
250
+ if (!goal.sessionID || goal.sessionState !== "present") { setManagerNotice({ message: "This goal's session is not available for navigation.", tone: "warning" }); return }
251
+ try {
252
+ // Focusing the already-current tab leaves its fullscreen manager
253
+ // covering the conversation. Explicit activation must return to it.
254
+ if (goal.sessionID === sessionID) { const panel = currentMountedPanel(); if (panel) panel.close(); else context.ui.panel.close() }
255
+ else openGoalSession(context.ui, goal.sessionID)
256
+ } catch (error) { setManagerNotice({ message: `Navigation unavailable: ${String(error)}`, tone: "warning" }) }
257
+ return
258
+ }
259
+ const location = locationOf(sessionID)
260
+ try {
261
+ const result = await rpc.manage({ sessionID, action: row.action, target: targetOf(goal) }, location ? { location } : undefined)
262
+ // Native toast overlays can take the keyboard focus away from the
263
+ // selected action after a resize. Keep manager feedback in place;
264
+ // actual owner decisions still reveal their Composer form.
265
+ setManagerNotice({ message: String(result.message), tone: result.pending ? "muted" : result.ok ? "success" : "warning" })
266
+ void ensureGoals(true)
267
+ if (!result.pending) void fetchDetails(goal)
268
+ } catch (error) { setManagerNotice({ message: `Operation unavailable: ${String(error)}`, tone: "warning" }) }
269
+ }
270
+
271
+ const color = (tone: Tone) => {
272
+ const t = context.theme as any
273
+ if (tone === "base") return t.text.base
274
+ if (tone === "muted") return t.text.muted
275
+ return t.text.feedback?.[tone]?.base ?? t.text.base
276
+ }
277
+
278
+ const disposers: Array<() => void> = []
279
+
280
+ disposers.push(
281
+ context.ui.slot({
282
+ append: "sidebar.content",
283
+ render: (input: { sessionID: string }) => {
284
+ ensure(input.sessionID)
285
+ refreshIfStale(input.sessionID) // T058: focus renders refresh a stale card
286
+ void ensureGoals()
287
+ const width = typeof (input as any).width === "number" ? (input as any).width : SIDEBAR_WIDTH
288
+ const model = () => sidebarCards(goalsList(), input.sessionID, width)
289
+ return (
290
+ <box id="goal-sidebar" flexDirection="column" maxHeight={12}>
291
+ <Show when={model().total}>
292
+ <text height={1} flexShrink={0} fg={color("base")} wrapMode="none" truncate>Goals ({model().total})</text>
293
+ <For each={model().cards.map((c) => c.key)}>
294
+ {(key) => {
295
+ const card = () => model().cards.find((c) => c.key === key)
296
+ const expand = () => { const current = card(); if (current) expandGoal(current.goal) }
297
+ return (
298
+ <box flexDirection="column" marginTop={1} flexShrink={0}>
299
+ <select id={`goal-card:${key}`} height={2} flexShrink={0}
300
+ options={[{ name: card()?.title ?? "", description: card()?.description ?? "", value: key }]}
301
+ showDescription showSelectionIndicator={false}
302
+ textColor={color("base")} descriptionColor={color(card()?.tone ?? "muted")}
303
+ onSelect={expand} onMouseUp={expand} />
304
+ <text height={1} flexShrink={0} fg={color("muted")} wrapMode="none" truncate>{card()?.progress ?? ""}</text>
305
+ <Show when={card()?.handoffPointer}>{(pointer) => <text id={`goal-handoff-pointer:${key}`} height={1} flexShrink={0} fg={color("muted")} wrapMode="none" truncate>{pointer()}</text>}</Show>
306
+ </box>
307
+ )
308
+ }}
309
+ </For>
310
+ </Show>
311
+ <select id="goal-sidebar-manager" height={1} flexShrink={0} showDescription={false}
312
+ options={[{ name: goalsState() === "error" ? "Retry goals — F9 manager" : model().pointer, description: "Open the project Goals manager", value: "manager" }]}
313
+ onSelect={() => { openPanel(); setTab("goals"); void ensureGoals(true) }}
314
+ onMouseUp={() => { openPanel(); setTab("goals"); void ensureGoals(true) }} />
315
+ </box>
316
+ )
317
+ },
318
+ } as any),
319
+ )
320
+
321
+ disposers.push(
322
+ context.ui.slot({
323
+ append: "prompt.footer.status",
324
+ render: (input: { sessionID?: string }) => {
325
+ if (input.sessionID) ensure(input.sessionID)
326
+ return (
327
+ <Show when={input.sessionID ? views[input.sessionID] : undefined}>
328
+ {(view) => <text fg={color(view().status === "running" ? "success" : "muted")}>{pillText(view(), now())}</text>}
329
+ </Show>
330
+ )
331
+ },
332
+ } as any),
333
+ )
334
+
335
+ disposers.push(
336
+ context.ui.slot({
337
+ append: "session.composer.top",
338
+ render: (input: { sessionID: string }) => {
339
+ ensure(input.sessionID)
340
+ // Slot render runs once on mount. Derive inside JSX accessors so
341
+ // the Solid store remains tracked across progress/status changes
342
+ // and, critically, replacement by a NEW run in this session.
343
+ const tracer = () => {
344
+ const view = views[input.sessionID]
345
+ return view ? tracerLines(view, now(), 80) : []
346
+ }
347
+ const banner = () => {
348
+ const view = views[input.sessionID]
349
+ return view ? bannerText(view) : undefined
350
+ }
351
+ return (
352
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
353
+ <Show when={launchStatusText(currentLaunch(input.sessionID), views[input.sessionID])}>
354
+ {(line) => <text id="goal-launch-admission" fg={color(currentLaunch(input.sessionID)?.status === "executing" ? "muted" : "warning")} wrapMode="none" truncate>{line()}</text>}
355
+ </Show>
356
+ <For each={tracer()}>
357
+ {(line) => (
358
+ <text fg={color(line.tone)} wrapMode="none" truncate>
359
+ {line.text}
360
+ </text>
361
+ )}
362
+ </For>
363
+ <Show when={banner()}>
364
+ {(b) => (
365
+ <text fg={color(b().tone)} wrapMode="none" truncate>
366
+ {b().text}
367
+ </text>
368
+ )}
369
+ </Show>
370
+ </box>
371
+ )
372
+ },
373
+ } as any),
374
+ )
375
+
376
+ disposers.push(
377
+ context.ui.slot({
378
+ append: "session.panel",
379
+ render: (input: any) => {
380
+ if (input.name !== PANEL_NAME) return null
381
+ const mount = { close: () => { if (typeof input.close === "function") input.close(); else context.ui.panel.close() } }
382
+ mountedPanels.set(input.sessionID, mount)
383
+ onCleanup(() => { if (mountedPanels.get(input.sessionID) === mount) mountedPanels.delete(input.sessionID) })
384
+ ensure(input.sessionID)
385
+ void ensureGoals()
386
+ const view = () => views[input.sessionID]
387
+ if (!view() && !selectedGoalKey()) setTab("goals")
388
+ // PanelInput.width/focused are reactive host props; capturing a
389
+ // number once leaves every wrapped line stale after a resize.
390
+ const width = () => typeof input.width === "number" ? input.width : 46
391
+ // T074 sign-off fix: focus the panel once on open (the composer
392
+ // otherwise keeps swallowing tab/↑↓)
393
+ if (input.name === PANEL_NAME && panelFocusPending && typeof input.focus === "function") {
394
+ panelFocusPending = false
395
+ try {
396
+ input.focus()
397
+ } catch {
398
+ // focus is best-effort; the palette/palette-command still works
399
+ }
400
+ }
401
+ // T074 sign-off fix: the Decisions tab is ALWAYS interactive —
402
+ // open decision rows first, then the always-applicable quick acts
403
+ const rows = () => view() && tab() === "decisions" ? [...decisionRows(view()!), ...quickActRows(view()!)] : []
404
+ const managerRows = () => orderedGoals(goalsList(), input.sessionID)
405
+ const actions = () => { const g = selectedGoal(); return g ? managerActions(g, input.sessionID, !!view() && !["complete", "failed", "aborted", "superseded"].includes(view()!.status)) : [] }
406
+ // The host's panel transition restores input ownership AFTER the
407
+ // slot renders. `focused` on a newly mounted Select alone loses
408
+ // that race to the composer. Focus the scope and concrete control
409
+ // once after mount; never reclaim focus on every progress tick.
410
+ const focusControl = (control: any) => {
411
+ const scheduled = setTimeout(() => {
412
+ try { input.focus?.(); control.focus() } catch { /* unavailable panel scope */ }
413
+ }, 0)
414
+ onCleanup(() => clearTimeout(scheduled))
415
+ }
416
+ let managerControl: any, actionsControl: any, detailsControl: any, closeControl: any, lastFocusedControl: any
417
+ let resizeFocusTimer: ReturnType<typeof setTimeout> | undefined
418
+ onCleanup(() => clearTimeout(resizeFocusTimer))
419
+ const rememberFocus = (control: any) => { control.on?.("focused", () => { lastFocusedControl = control }) }
420
+ const restoreAfterResize = () => {
421
+ if (tab() !== "goals" || mountedPanels.get(input.sessionID) !== mount || !lastFocusedControl) return
422
+ const route = context.ui.router.current()
423
+ if (route.type !== "session" || route.sessionID !== input.sessionID) return
424
+ clearTimeout(resizeFocusTimer)
425
+ resizeFocusTimer = setTimeout(() => {
426
+ if (mountedPanels.get(input.sessionID) !== mount || (context.data.session.form?.list(input.sessionID, locationOf(input.sessionID))?.length ?? 0) > 0) return
427
+ input.focus?.()
428
+ lastFocusedControl?.focus()
429
+ }, 0)
430
+ }
431
+ context.renderer?.on?.("resize", restoreAfterResize)
432
+ onCleanup(() => context.renderer?.off?.("resize", restoreAfterResize))
433
+ createEffect(() => {
434
+ if (input.focused === true && tab() === "goals") untrack(() => focusControl(selectedGoal() ? actionsControl : managerControl))
435
+ })
436
+ const cycleSection = () => {
437
+ const controls = [managerControl, detailsControl, actionsControl, closeControl].filter((c) => c && c.visible !== false)
438
+ if (!controls.length) return
439
+ const at = controls.findIndex((c) => c.focused)
440
+ input.focus?.()
441
+ controls[(at + 1) % controls.length].focus()
442
+ }
443
+ focusManagerSection = cycleSection
444
+ onCleanup(() => { if (focusManagerSection === cycleSection) focusManagerSection = undefined })
445
+ return (
446
+ <Show when={tab() !== "goals"} fallback={
447
+ <box id="goal-manager" flexDirection="column" paddingLeft={1} paddingRight={1} height="100%" minHeight={0} overflow="hidden">
448
+ <text height={1} flexShrink={0} fg={color("base")} wrapMode="none" truncate><b>Goals manager · this project</b></text>
449
+ <select id="goal-manager-close" ref={(control: any) => { closeControl = control; rememberFocus(control) }} height={1} flexShrink={0}
450
+ options={[{ name: "Close manager · F9: return to sidebar", description: "Return to this session's basic sidebar without changing the goal", value: "close" }]}
451
+ showDescription={false} onSelect={() => { if (typeof input.close === "function") input.close(); else context.ui.panel.close() }}
452
+ onMouseUp={() => { if (typeof input.close === "function") input.close(); else context.ui.panel.close() }} />
453
+ <text height={1} flexShrink={0} fg={color("muted")} wrapMode="none" truncate>{fitColumns("Tab: focus · ↑↓: select · Enter: activate", width() - 2)}</text>
454
+ <Show when={goalsState() === "loading"}><text fg={color("muted")}>Loading project goals…</text></Show>
455
+ <Show when={goalsState() === "error"}><text fg={color("error")}>Inventory unavailable; F9 retries.</text></Show>
456
+ <Show when={managerRows().length} fallback={<Show when={goalsState() === "ready"}><text fg={color("muted")}>No goals — /goal starts the interview.</text></Show>}>
457
+ <select id="goal-manager-list" ref={(control: any) => { managerControl = control; rememberFocus(control); if (!selectedGoal()) focusControl(control) }} height={selectedGoal() ? 2 : Math.min(8, Math.max(2, managerRows().length * 2))} flexShrink={0}
458
+ options={managerRows().map((g) => ({ name: fitColumns(`${g.title}${g.archived ? " [archived]" : ""}`, width() - 4), description: fitColumns(`${g.status}${g.launch?.status === "executing" ? " · starting" : g.launch?.status === "expired" ? " · review expired" : ""} · ${g.proven}/${g.total} proven`, width() - 4), value: goalKey(g) }))}
459
+ showDescription focused={!selectedGoal()}
460
+ onMouseDown={(event: any) => { event.stopPropagation(); event.preventDefault() }}
461
+ onMouseUp={(event: any) => { input.focus?.(); pointerSelect(managerControl, event, 2) }}
462
+ onSelect={(_index: number, option: any) => { const goal = managerRows().find((g) => goalKey(g) === option?.value); if (goal) { setSelectedGoalKey(goalKey(goal)); setSelectedDetails(goal); void fetchDetails(goal) } }} />
463
+ </Show>
464
+ <Show when={selectedGoal()}>
465
+ {(goal) => (
466
+ <box id="goal-manager-details" flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0} marginTop={1}>
467
+ <text height={1} flexShrink={0} fg={color("base")} wrapMode="none" truncate>{fitColumns(`${goal().status} · ${goal().proven}/${goal().total} host-proven · ${goal().agent ?? "agent unknown"}`, width() - 2)}</text>
468
+ <text height={1} flexShrink={0} fg={color("muted")} wrapMode="none" truncate>{fitColumns(`Session: ${goal().sessionTitle ?? goal().sessionID ?? "unassigned"}`, width() - 2)}</text>
469
+ <Show when={managerNotice()}>{(notice) => <text id="goal-manager-feedback" height={1} flexShrink={0} fg={color(notice().tone)} wrapMode="none" truncate>{fitColumns(notice().message, width() - 2)}</text>}</Show>
470
+ <Show when={goal().steps?.length}><text height={1} flexShrink={0} fg={color("muted")} wrapMode="none" truncate>{fitColumns(`Plan: ${goal().steps?.filter((s) => s.status === "done").length}/${goal().steps?.length} done · ${goal().steps?.find((s) => s.status === "active")?.title ?? "no active step"}`, width() - 2)}</text></Show>
471
+ <text height={1} flexShrink={0} fg={color("muted")} wrapMode="none" truncate>Details · Tab to focus · wheel/PgUp/PgDn scroll</text>
472
+ <scrollbox id="goal-manager-detail-text" ref={(control: any) => { detailsControl = control; rememberFocus(control) }} flexGrow={1} flexShrink={1} minHeight={1}
473
+ scrollX={false} scrollY={true} viewportOptions={{ overflow: "hidden" }} contentOptions={{ flexDirection: "column" }} onMouseDown={() => { input.focus?.(); detailsControl?.focus() }}>
474
+ <text fg={color("base")}><b>Outcome</b></text>
475
+ <text fg={color("muted")} wrapMode="word">{`Goal: ${goal().slug}`}</text>
476
+ <text fg={color("base")} wrapMode="word">{goal().outcome ?? goal().title}</text>
477
+ <text fg={color("muted")} wrapMode="word">{`Session ID: ${goal().sessionID ?? "unassigned"}`}</text>
478
+ <Show when={launchStatusText(goal().launch, goal())}>{(line) => <text fg={color("warning")} wrapMode="word">{line()}</text>}</Show>
479
+ <Show when={goal().details?.criteria?.length}><text fg={color("base")}><b>Evidence</b></text></Show>
480
+ <For each={goal().details?.criteria ?? []}>{(criterion) => (
481
+ <text fg={color(criterion.status === "fail" ? "error" : "muted")} wrapMode="word">{`${criterion.id} [${criterion.status}] ${criterion.statement}${criterion.by ? ` (${criterion.by})` : ""}`}</text>
482
+ )}</For>
483
+ <For each={goal().issues ?? []}>{(issue) => <text fg={color("error")} wrapMode="word">{issue}</text>}</For>
484
+ </scrollbox>
485
+ <text height={1} flexShrink={0} fg={color("base")}><b>Actions · ↑↓ and Enter, or click</b></text>
486
+ <select id="goal-manager-actions" ref={(control: any) => { actionsControl = control; rememberFocus(control); focusControl(control) }} height={Math.min(8, actions().length)} flexShrink={0}
487
+ options={actions().map((r) => ({ name: r.label, description: r.description, value: r }))}
488
+ showDescription={false} focused
489
+ onMouseDown={(event: any) => { event.stopPropagation(); event.preventDefault() }}
490
+ onMouseUp={(event: any) => { input.focus?.(); pointerSelect(actionsControl, event, 1) }}
491
+ onSelect={(_index: number, option: any) => { if (option?.value) void manageSelected(option.value) }} />
492
+ </box>
493
+ )}
494
+ </Show>
495
+ </box>
496
+ }>
497
+ <Show when={view()}>
498
+ {(v) => (
499
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
500
+ <For each={dashboardLines(v(), tab(), now(), width(), goalsList() as any)}>
501
+ {(line) => (
502
+ <text fg={color(line.tone)} wrapMode="none" truncate>
503
+ {line.bold ? <b>{line.text}</b> : line.text}
504
+ </text>
505
+ )}
506
+ </For>
507
+ <Show when={rows().length}>
508
+ <select
509
+ options={rows().map((r) => ({ name: r.label, description: r.description, value: r }))}
510
+ showDescription
511
+ focused
512
+ onSelect={(_index: number, option: any) => {
513
+ const row = option?.value as { act?: string; arg?: string; label: string; description?: string } | undefined
514
+ if (row?.act) void act(row.act, row.arg)
515
+ else context.ui.toast.show({ title: "Goal", message: `Run: ${row?.description ?? row?.label ?? ""}`, variant: "info" })
516
+ }}
517
+ />
518
+ </Show>
519
+ </box>
520
+ )}
521
+ </Show>
522
+ </Show>
523
+ )
524
+ },
525
+ } as any),
526
+ )
527
+
528
+ // Palette commands live in a keymap layer owned by the app slot's render
529
+ // component (createComponent gives it a stable owner; T050). T064: the
530
+ // layer MUST be mode:"global" — a layer without a mode defaults to
531
+ // "base", and base-mode layers are unreachable whenever a modal is open
532
+ // (the command palette itself registers mode:"modal",
533
+ // packages/tui/src/ui/dialog-select.tsx), so the palette listed zero
534
+ // Goal commands live. Proven by pty A/B: mode:"global" lists all 8,
535
+ // mode-less lists none. Matches the host's own palette layer (app.tsx
536
+ // Keymap.createLayer(() => ({ mode: "global", ... }))).
537
+ const currentSession = () => {
538
+ const route = context.ui.router.current()
539
+ return route.type === "session" ? route.sessionID : undefined
540
+ }
541
+ const act = async (action: string, arg?: string, decision?: { sessionID: string; decisionId: string }) => {
542
+ const sessionID = decision?.sessionID ?? currentSession()
543
+ if (!sessionID) return
544
+ const location = locationOf(sessionID)
545
+ try {
546
+ const result = await rpc.act({ sessionID, action, ...(arg ? { arg } : {}), ...(decision ? { decisionId: decision.decisionId } : {}) }, location ? { location } : undefined)
547
+ context.ui.toast.show({ title: "Goal", message: result.message, variant: result.ok ? "success" : "warning" })
548
+ } catch (error) {
549
+ context.ui.toast.show({ title: "Goal", message: `Goal plugin unavailable: ${String(error)}`, variant: "error" })
550
+ }
551
+ }
552
+ const hasGoal = () => {
553
+ const id = currentSession()
554
+ return !!(id && views[id])
555
+ }
556
+ // Owner-approved T086: lifecycle choices belong only in native Composer ask forms.
557
+ // Preserve attention/result notifications, never open a duplicate dialog.
558
+ const openDecisionIdentity = new Map<string, string>()
559
+ const offDecision = rpc.events.on("decision", (event: any) => {
560
+ const data = event.data ?? {}
561
+ const choices = (data.choices ?? []) as Array<{ label: string; act?: string; arg?: string; run?: string }>
562
+ const route = context.ui.router.current()
563
+ // A full-screen Goals panel must not hide or retain Enter focus over
564
+ // the sole native Composer question. Close only OUR panel here; never
565
+ // dismiss an unrelated host dialog or jump to another session.
566
+ if (choices.some((c) => c.act) && route.type === "session" && route.sessionID === data.sessionID) {
567
+ const mounted = mountedPanels.get(data.sessionID)
568
+ if (mounted) mounted.close()
569
+ else if (context.ui.panel?.current?.()?.name === PANEL_NAME) context.ui.panel.close()
570
+ }
571
+ if (typeof data.sessionID === "string" && typeof data.decisionId === "string" && choices.some((c) => c.act)) {
572
+ openDecisions.set(data.sessionID, 1)
573
+ openDecisionIdentity.set(data.sessionID, data.decisionId)
574
+ }
575
+ context.ui.toast.show({ title: "Goal decision", message: choices.some((c) => c.act)
576
+ ? "Owner decision pending in this session's Composer. No operation executed."
577
+ : String(data.message ?? "Goal guidance is available in the manager."), variant: "info", ...(data.sessionID ? { sessionID: data.sessionID } : {}) })
578
+ })
579
+
580
+ // Only engine resolution closes pending review state. Never dismiss an
581
+ // unrelated host dialog or synthesize native answers from notifications.
582
+ const offResolved = rpc.events.on("decision.resolved", (event: any) => {
583
+ const data = event.data ?? {}
584
+ if (data.message) context.ui.toast.show({ title: "Goal decision", message: data.message, variant: "success" })
585
+ if (openDecisionIdentity.get(data.sessionID) !== data.decisionId) return
586
+ openDecisions.delete(data.sessionID)
587
+ openDecisionIdentity.delete(data.sessionID)
588
+ flushSummary(data.sessionID)
589
+ })
590
+ const offFailed = rpc.events.on("decision.failed", (event: any) => {
591
+ const data = event.data ?? {}
592
+ context.ui.toast.show({ title: "Goal decision", message: String(data.message ?? "Owner operation did not execute; inspect the goal and request a new review."), variant: "warning", duration: 8000 })
593
+ if (openDecisionIdentity.get(data.sessionID) === data.decisionId) {
594
+ openDecisions.delete(data.sessionID)
595
+ openDecisionIdentity.delete(data.sessionID)
596
+ flushSummary(data.sessionID)
597
+ }
598
+ })
599
+
600
+ // Navigation lives in the existing Goals manager: expand a row before
601
+ // deliberately attaching or jumping. No separate palette chooser.
602
+ disposers.push(
603
+ context.ui.slot({
604
+ append: "app",
605
+ render: () => {
606
+ context.keymap.layer(() => ({
607
+ mode: "global" as const,
608
+ commands: [
609
+ {
610
+ id: "goal.attach",
611
+ title: "Goal: Manage goals",
612
+ group: "Goal",
613
+ palette: true,
614
+ enabled: () => true,
615
+ run: () => { setTab("goals"); openPanel() },
616
+ },
617
+ {
618
+ id: "goal.panel",
619
+ title: "Goal: Toggle dashboard",
620
+ group: "Goal",
621
+ palette: true,
622
+ // T056/T074: the documented default was leader+g, but OpenCode
623
+ // 2.0.22 plugin keymap binds cannot join the leader chord
624
+ // (spike S28: leader+g / ctrl+x g / bare g never dispatch —
625
+ // the host's which-key table lists only host commands; a
626
+ // host tui.json bind for a plugin id did not dispatch
627
+ // either). F9 is the proven direct default (pty A/B); owners
628
+ // can rebind via their own tui.json on upgrades that fix the
629
+ // leader table.
630
+ bind: "f9",
631
+ enabled: () => true,
632
+ run: () => togglePanel(),
633
+ },
634
+ {
635
+ id: "goal.dashboard.tab",
636
+ title: "Goal: Next dashboard tab",
637
+ group: "Goal",
638
+ palette: true,
639
+ bind: "tab",
640
+ enabled: () => hasGoal() || !!currentMountedPanel() || context.ui.panel.current()?.name === PANEL_NAME,
641
+ run: () => {
642
+ if (tab() === "goals" && (currentMountedPanel() || context.ui.panel.current()?.name === PANEL_NAME) && focusManagerSection) { focusManagerSection(); return }
643
+ const order = DASHBOARD_TABS
644
+ setTab(order[(order.indexOf(tab()) + 1) % order.length]!)
645
+ },
646
+ },
647
+ {
648
+ id: "goal.status",
649
+ title: "Goal: Show status",
650
+ group: "Goal",
651
+ palette: true,
652
+ enabled: hasGoal,
653
+ run: () => {
654
+ const view = views[currentSession()!]
655
+ if (view) void context.ui.dialog.alert({ title: "Goal", message: statusText(view, Date.now()) })
656
+ },
657
+ },
658
+ { id: "goal.pause", title: "Goal: Pause", group: "Goal", palette: true, enabled: hasGoal, run: () => act("pause") },
659
+ { id: "goal.resume", title: "Goal: Resume", group: "Goal", palette: true, enabled: hasGoal, run: () => act("resume") },
660
+ { id: "goal.verify", title: "Goal: Verify now", group: "Goal", palette: true, enabled: hasGoal, run: () => act("verify") },
661
+ {
662
+ id: "goal.approve",
663
+ title: "Goal: Review in Composer",
664
+ group: "Goal",
665
+ palette: true,
666
+ enabled: () => !!currentSession(),
667
+ run: async () => {
668
+ const sessionID = currentSession()
669
+ if (!sessionID) return
670
+ const location = locationOf(sessionID)
671
+ try {
672
+ const result = await rpc.review({ sessionID }, location ? { location } : undefined)
673
+ context.ui.toast.show({ title: "Goal review", message: result.message, variant: result.ok ? "info" : "warning" })
674
+ } catch (error) { context.ui.toast.show({ title: "Goal review", message: String(error), variant: "warning" }) }
675
+ },
676
+ },
677
+ {
678
+ id: "goal.abort",
679
+ title: "Goal: Abort",
680
+ group: "Goal",
681
+ palette: true,
682
+ enabled: hasGoal,
683
+ run: async () => {
684
+ const sessionID = currentSession()
685
+ if (!sessionID) return
686
+ const location = locationOf(sessionID)
687
+ try {
688
+ const listed = await rpc.list({}, location ? { location } : undefined)
689
+ const goal = (listed?.goals as GoalSummary[] | undefined)?.find((g) => g.sessionID === sessionID && g.runId === views[sessionID]?.runId && !g.archived)
690
+ if (!goal) { context.ui.toast.show({ title: "Goal", message: "Current target changed; inspect its details before aborting.", variant: "warning" }); return }
691
+ const result = await rpc.manage({ sessionID, action: "abort", target: targetOf(goal) }, location ? { location } : undefined)
692
+ context.ui.toast.show({ title: "Goal", message: result.message, variant: result.ok ? "info" : "warning" })
693
+ } catch (error) { context.ui.toast.show({ title: "Goal", message: String(error), variant: "warning" }) }
694
+ },
695
+ },
696
+ ],
697
+ }))
698
+ return null
699
+ },
700
+ } as any),
701
+ )
702
+
703
+ return () => {
704
+ disposed = true
705
+ offHandoff()
706
+ clearInterval(timer)
707
+ clearInterval(ticker)
708
+ offUpdated?.()
709
+ offNotice?.()
710
+ offSummary?.()
711
+ offDecision?.()
712
+ offResolved?.()
713
+ offFailed?.()
714
+ offData?.()
715
+ for (const dispose of disposers.splice(0)) dispose()
716
+ }
717
+ },
718
+ }