opencode-ultracode 0.1.0

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.
@@ -0,0 +1,265 @@
1
+ // Pending permission / question requests, tracked in the TUI process.
2
+ //
3
+ // opencode's own permission dialog lives in the `session` route; while one of
4
+ // our workflow routes is on screen nothing surfaces a sub-agent that is blocked
5
+ // on "may I read this folder?" — the agent just sits there as "running". This
6
+ // module follows the server's pending requests (initial list + live events,
7
+ // re-listed periodically as a safety net) so the views can flag the agent and
8
+ // let the user answer from inside /workflows.
9
+
10
+ import { batch, createMemo, createSignal } from "solid-js"
11
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
12
+ import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
13
+ import type { AgentState, RunState } from "../shared/state.ts"
14
+
15
+ export type PendingRequest =
16
+ | { kind: "permission"; id: string; sessionID: string; at: number; req: PermissionRequest }
17
+ | { kind: "question"; id: string; sessionID: string; at: number; req: QuestionRequest }
18
+
19
+ export type PermissionReply = "once" | "always" | "reject"
20
+
21
+ export interface RequestStore {
22
+ /** every pending request the server knows about, oldest first */
23
+ all: () => PendingRequest[]
24
+ forSession: (sessionID: string | undefined) => PendingRequest[]
25
+ forAgent: (agent: AgentState | undefined) => PendingRequest[]
26
+ /** agents of this run that are blocked on a request, in spawn order */
27
+ waitingAgents: (run: RunState) => AgentState[]
28
+ /** requests that belong to no workflow agent (e.g. the main session) */
29
+ unattributed: (runs: RunState[]) => PendingRequest[]
30
+
31
+ replyPermission: (id: string, reply: PermissionReply) => Promise<boolean>
32
+ replyQuestion: (id: string, answers: string[][]) => Promise<boolean>
33
+ rejectQuestion: (id: string) => Promise<boolean>
34
+
35
+ /** register a callback for requests that appear after startup */
36
+ onNew: (fn: (p: PendingRequest) => void) => void
37
+ }
38
+
39
+ const RELIST_MS = 5000
40
+
41
+ export function createRequestStore(api: TuiPluginApi, onDispose: (fn: () => void) => void): RequestStore {
42
+ const [pending, setPending] = createSignal<Record<string, PendingRequest>>({})
43
+ const listeners: Array<(p: PendingRequest) => void> = []
44
+ // ids we answered but the server has not yet confirmed gone — hidden from
45
+ // the views so a double keypress cannot answer twice
46
+ const answered = new Set<string>()
47
+ let seeded = false
48
+
49
+ function requestRender(): void {
50
+ try {
51
+ api.renderer?.requestRender?.()
52
+ } catch {}
53
+ }
54
+
55
+ const add = (p: PendingRequest, announce: boolean) => {
56
+ if (answered.has(p.id)) return
57
+ let isNew = false
58
+ setPending((cur) => {
59
+ if (cur[p.id]) return cur
60
+ isNew = true
61
+ return { ...cur, [p.id]: p }
62
+ })
63
+ if (isNew) {
64
+ requestRender()
65
+ if (announce) for (const fn of listeners) fn(p)
66
+ }
67
+ }
68
+ const remove = (id: string) => {
69
+ answered.delete(id)
70
+ setPending((cur) => {
71
+ if (!cur[id]) return cur
72
+ const next = { ...cur }
73
+ delete next[id]
74
+ return next
75
+ })
76
+ requestRender()
77
+ }
78
+
79
+ const wrapPermission = (req: PermissionRequest, at: number): PendingRequest => ({ kind: "permission", id: req.id, sessionID: req.sessionID, at, req })
80
+ const wrapQuestion = (req: QuestionRequest, at: number): PendingRequest => ({ kind: "question", id: req.id, sessionID: req.sessionID, at, req })
81
+
82
+ // --- full re-list: startup seed + periodic safety net -------------------------
83
+
84
+ async function relist(): Promise<void> {
85
+ let perms: PermissionRequest[] = []
86
+ let questions: QuestionRequest[] = []
87
+ let ok = false
88
+ try {
89
+ const [p, q] = await Promise.all([api.client.permission.list(), api.client.question.list()])
90
+ perms = ((p as any)?.data ?? []) as PermissionRequest[]
91
+ questions = ((q as any)?.data ?? []) as QuestionRequest[]
92
+ ok = !(p as any)?.error && !(q as any)?.error
93
+ } catch {
94
+ return
95
+ }
96
+ if (!ok) return
97
+ const now = Date.now()
98
+ const live = new Set<string>()
99
+ batch(() => {
100
+ for (const r of perms) {
101
+ live.add(r.id)
102
+ add(wrapPermission(r, pending()[r.id]?.at ?? now), seeded)
103
+ }
104
+ for (const r of questions) {
105
+ live.add(r.id)
106
+ add(wrapQuestion(r, pending()[r.id]?.at ?? now), seeded)
107
+ }
108
+ // anything the server no longer lists was answered elsewhere (main session dialog, another client)
109
+ for (const id of Object.keys(pending())) if (!live.has(id)) remove(id)
110
+ })
111
+ seeded = true
112
+ }
113
+
114
+ relist().catch(() => {})
115
+ const timer = setInterval(() => {
116
+ relist().catch(() => {})
117
+ }, RELIST_MS)
118
+ onDispose(() => clearInterval(timer))
119
+
120
+ // --- live events ------------------------------------------------------------------
121
+
122
+ const subs: Array<() => void> = []
123
+ try {
124
+ subs.push(api.event.on("permission.asked", (e) => add(wrapPermission(e.properties, Date.now()), true)))
125
+ subs.push(api.event.on("permission.replied", (e) => remove(e.properties.requestID)))
126
+ subs.push(api.event.on("question.asked", (e) => add(wrapQuestion(e.properties, Date.now()), true)))
127
+ subs.push(api.event.on("question.replied", (e) => remove(e.properties.requestID)))
128
+ subs.push(api.event.on("question.rejected", (e) => remove(e.properties.requestID)))
129
+ } catch {}
130
+ onDispose(() => {
131
+ for (const off of subs) {
132
+ try {
133
+ off()
134
+ } catch {}
135
+ }
136
+ })
137
+
138
+ // --- derived --------------------------------------------------------------------------
139
+
140
+ const all = createMemo(() => Object.values(pending()).sort((a, b) => a.at - b.at))
141
+ const bySession = createMemo(() => {
142
+ const m = new Map<string, PendingRequest[]>()
143
+ for (const p of all()) {
144
+ const list = m.get(p.sessionID)
145
+ if (list) list.push(p)
146
+ else m.set(p.sessionID, [p])
147
+ }
148
+ return m
149
+ })
150
+
151
+ const forSession = (sessionID: string | undefined): PendingRequest[] => (sessionID ? bySession().get(sessionID) ?? [] : [])
152
+ const forAgent = (agent: AgentState | undefined): PendingRequest[] => {
153
+ if (!agent || agent.status !== "running") return []
154
+ return forSession(agent.sessionId)
155
+ }
156
+
157
+ // --- replies ---------------------------------------------------------------------------
158
+
159
+ const markAnswered = (id: string) => {
160
+ answered.add(id)
161
+ setPending((cur) => {
162
+ if (!cur[id]) return cur
163
+ const next = { ...cur }
164
+ delete next[id]
165
+ return next
166
+ })
167
+ requestRender()
168
+ }
169
+ const unmark = (id: string, p: PendingRequest | undefined) => {
170
+ answered.delete(id)
171
+ if (p) add(p, false)
172
+ }
173
+
174
+ return {
175
+ all,
176
+ forSession,
177
+ forAgent,
178
+ waitingAgents: (run) => {
179
+ const sessions = bySession()
180
+ if (!sessions.size) return []
181
+ const out: AgentState[] = []
182
+ for (const id of run.agentOrder) {
183
+ const a = run.agents[id]
184
+ if (a && a.status === "running" && a.sessionId && sessions.has(a.sessionId)) out.push(a)
185
+ }
186
+ return out
187
+ },
188
+ unattributed: (runs) => {
189
+ const owned = new Set<string>()
190
+ for (const r of runs) for (const id of r.agentOrder) {
191
+ const s = r.agents[id]?.sessionId
192
+ if (s) owned.add(s)
193
+ }
194
+ return all().filter((p) => !owned.has(p.sessionID))
195
+ },
196
+
197
+ replyPermission: async (id, reply) => {
198
+ const prev = pending()[id]
199
+ markAnswered(id)
200
+ try {
201
+ const res: any = await api.client.permission.reply({ requestID: id, reply })
202
+ if (res?.error) throw new Error(errText(res.error))
203
+ return true
204
+ } catch (e) {
205
+ unmark(id, prev)
206
+ api.ui.toast({ variant: "error", message: `Could not send reply: ${errText(e)}` })
207
+ return false
208
+ }
209
+ },
210
+ replyQuestion: async (id, answers) => {
211
+ const prev = pending()[id]
212
+ markAnswered(id)
213
+ try {
214
+ const res: any = await api.client.question.reply({ requestID: id, answers })
215
+ if (res?.error) throw new Error(errText(res.error))
216
+ return true
217
+ } catch (e) {
218
+ unmark(id, prev)
219
+ api.ui.toast({ variant: "error", message: `Could not send answer: ${errText(e)}` })
220
+ return false
221
+ }
222
+ },
223
+ rejectQuestion: async (id) => {
224
+ const prev = pending()[id]
225
+ markAnswered(id)
226
+ try {
227
+ const res: any = await api.client.question.reject({ requestID: id })
228
+ if (res?.error) throw new Error(errText(res.error))
229
+ return true
230
+ } catch (e) {
231
+ unmark(id, prev)
232
+ api.ui.toast({ variant: "error", message: `Could not reject: ${errText(e)}` })
233
+ return false
234
+ }
235
+ },
236
+
237
+ onNew: (fn) => {
238
+ listeners.push(fn)
239
+ },
240
+ }
241
+ }
242
+
243
+ /** one-line description of what the request is asking for */
244
+ export function describeRequest(p: PendingRequest): string {
245
+ if (p.kind === "permission") {
246
+ const pats = p.req.patterns?.filter(Boolean) ?? []
247
+ return pats.length ? `${p.req.permission}: ${pats.join(", ")}` : p.req.permission
248
+ }
249
+ const q = p.req.questions?.[0]
250
+ const more = (p.req.questions?.length ?? 0) - 1
251
+ return q ? `${q.header || "question"}: ${q.question}${more > 0 ? ` (+${more} more)` : ""}` : "question"
252
+ }
253
+
254
+ function errText(e: unknown): string {
255
+ if (e instanceof Error) return e.message
256
+ if (typeof e === "string") return e
257
+ const any = e as any
258
+ if (any?.data?.message) return String(any.data.message)
259
+ if (any?.message) return String(any.message)
260
+ try {
261
+ return JSON.stringify(e)
262
+ } catch {
263
+ return String(e)
264
+ }
265
+ }
@@ -0,0 +1,266 @@
1
+ // TUI store: polls /tmp/opencode-workflows/<project>/<id>/state.json and exposes the
2
+ // run list as a fine-grained Solid store. Changed files are merged with
3
+ // `reconcile`, so only the cells whose values actually changed re-render —
4
+ // rows are never torn down and rebuilt on a tick, which keeps the view calm.
5
+
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
7
+ import { join } from "node:path"
8
+ import { batch, createMemo, createSignal } from "solid-js"
9
+ import { createStore as createSolidStore, reconcile } from "solid-js/store"
10
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
11
+ import { controlPath, runsRoot, workflowRoot, type RunState } from "../shared/state.ts"
12
+
13
+ export interface WorkflowStore {
14
+ /** all runs, newest first (fine-grained proxies — read fields inside JSX) */
15
+ runs: () => RunState[]
16
+ runById: (runId: string) => RunState | undefined
17
+ activeRun: () => RunState | undefined
18
+ activeRunId: () => string | undefined
19
+ openRun: (runId: string) => void
20
+ closeRun: () => void
21
+
22
+ selPhase: () => number
23
+ setSelPhase: (i: number) => void
24
+ selAgent: () => string | undefined
25
+ setSelAgent: (id: string | undefined) => void
26
+ expandActivity: () => boolean
27
+ toggleExpand: () => void
28
+ fullPrompt: () => boolean
29
+ toggleFullPrompt: () => void
30
+
31
+ /** wall clock, refreshed every second while something is running */
32
+ now: () => number
33
+ /** animated spinner frame for running items */
34
+ spinner: () => string
35
+ /** terminal size, refreshed on resize */
36
+ size: () => { width: number; height: number }
37
+ /** true when a run claims to be live but its state file stopped updating */
38
+ isStale: (run: RunState) => boolean
39
+
40
+ runsDir: () => string
41
+ control: (runId: string, action: "pause" | "resume" | "stop") => void
42
+ /** a control.json the engine has not consumed yet (e.g. resume waiting for the server plugin) */
43
+ pendingControl: (runId: string) => "pause" | "resume" | "stop" | undefined
44
+ deleteRun: (runId: string) => void
45
+ saveScript: (run: RunState) => string | undefined
46
+ markNotified: (runId: string) => void
47
+ wasNotified: (runId: string) => boolean
48
+ }
49
+
50
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
51
+ const POLL_MS = 250
52
+
53
+ export function isLive(status: string | undefined): boolean {
54
+ return status === "running" || status === "paused" || status === "pending"
55
+ }
56
+
57
+ /** the engine heartbeats state.json every ~2s; a live run whose file is
58
+ * this old has lost its engine (opencode crashed or was closed) */
59
+ export const STALE_AFTER_MS = 20_000
60
+
61
+ export function createStore(api: TuiPluginApi, onDispose?: (fn: () => void) => void): WorkflowStore {
62
+ const root = () => api.state.path.worktree || api.state.path.directory
63
+ const runsDir = () => runsRoot(root())
64
+ const dispose = onDispose ?? (() => {})
65
+
66
+ const [state, setState] = createSolidStore<{ runs: Record<string, RunState> }>({ runs: {} })
67
+ const [order, setOrder] = createSignal<string[]>([], { equals: (a, b) => a.length === b.length && a.every((v, i) => v === b[i]) })
68
+ const mtimes = new Map<string, number>()
69
+ const [written, setWritten] = createSignal<Record<string, number>>({})
70
+
71
+ const [activeId, setActiveId] = createSignal<string | undefined>(undefined)
72
+ const [selPhase, setSelPhase] = createSignal(0)
73
+ const [selAgent, setSelAgent] = createSignal("")
74
+ const [expand, setExpand] = createSignal(false)
75
+ const [fullPrompt, setFullPrompt] = createSignal(false)
76
+ const [now, setNow] = createSignal(Date.now())
77
+ const [frame, setFrame] = createSignal(0)
78
+ const [size, setSize] = createSignal({ width: api.renderer?.terminalWidth ?? 120, height: api.renderer?.terminalHeight ?? 40 })
79
+ const notified = new Set<string>()
80
+
81
+ function requestRender(): void {
82
+ try {
83
+ api.renderer?.requestRender?.()
84
+ } catch {}
85
+ }
86
+
87
+ // --- polling -------------------------------------------------------------
88
+
89
+ function refresh(): void {
90
+ let names: string[] = []
91
+ try {
92
+ names = readdirSync(runsDir()).filter((n) => n.startsWith("run_"))
93
+ } catch {
94
+ names = []
95
+ }
96
+ const seen = new Set(names)
97
+ let changed = false
98
+ const parsed: Array<[string, RunState]> = []
99
+ for (const n of names) {
100
+ const p = join(runsDir(), n, "state.json")
101
+ try {
102
+ const st = statSync(p)
103
+ if (mtimes.get(n) === st.mtimeMs) continue
104
+ // the engine writes with writeFileSync (not atomic) — a half-written
105
+ // file fails to parse; keep the previous state and retry next tick
106
+ const json = JSON.parse(readFileSync(p, "utf8")) as RunState
107
+ if (!json || typeof json !== "object" || !json.runId) continue
108
+ mtimes.set(n, st.mtimeMs)
109
+ parsed.push([n, json])
110
+ } catch {}
111
+ }
112
+ const gone = [...mtimes.keys()].filter((k) => !seen.has(k))
113
+ if (!parsed.length && !gone.length) return
114
+
115
+ batch(() => {
116
+ for (const [n, json] of parsed) {
117
+ // merge: true → keyless arrays (phases, activity, liveFeed) merge by
118
+ // index instead of being replaced, so their rows keep identity
119
+ setState("runs", n, reconcile(json, { key: "id", merge: true }))
120
+ changed = true
121
+ }
122
+ for (const k of gone) {
123
+ mtimes.delete(k)
124
+ setState("runs", k, undefined as any)
125
+ changed = true
126
+ }
127
+ const ids = Object.keys(state.runs).filter((k) => state.runs[k])
128
+ ids.sort((a, b) => (state.runs[b]?.startedAt ?? 0) - (state.runs[a]?.startedAt ?? 0))
129
+ setOrder(ids)
130
+ setWritten(Object.fromEntries(mtimes))
131
+ })
132
+ if (changed) requestRender()
133
+ }
134
+
135
+ const pollTimer = setInterval(() => {
136
+ try {
137
+ refresh()
138
+ } catch {}
139
+ }, POLL_MS)
140
+ dispose(() => clearInterval(pollTimer))
141
+ refresh()
142
+
143
+ // --- clock + spinner ------------------------------------------------------
144
+ // The spinner only animates while a run is live; otherwise the screen is
145
+ // fully static and no frames are drawn.
146
+
147
+ const anyLive = createMemo(() => order().some((id) => isLive(state.runs[id]?.status)))
148
+ const clock = setInterval(() => {
149
+ setNow(Date.now())
150
+ if (anyLive()) requestRender()
151
+ }, 1000)
152
+ dispose(() => clearInterval(clock))
153
+
154
+ let spinTimer: ReturnType<typeof setInterval> | null = null
155
+ const spinTick = () => {
156
+ if (!anyLive()) return
157
+ setFrame((f) => (f + 1) % SPINNER.length)
158
+ requestRender()
159
+ }
160
+ spinTimer = setInterval(spinTick, 120)
161
+ dispose(() => spinTimer && clearInterval(spinTimer))
162
+
163
+ // --- resize ---------------------------------------------------------------
164
+
165
+ const onResize = () => {
166
+ try {
167
+ setSize({ width: api.renderer.terminalWidth, height: api.renderer.terminalHeight })
168
+ } catch {}
169
+ }
170
+ try {
171
+ api.renderer?.on?.("resize", onResize)
172
+ dispose(() => {
173
+ try {
174
+ api.renderer?.off?.("resize", onResize)
175
+ } catch {}
176
+ })
177
+ } catch {}
178
+
179
+ // --- derived --------------------------------------------------------------
180
+
181
+ const runs = createMemo(() => order().map((id) => state.runs[id]).filter(Boolean) as RunState[])
182
+ const activeRun = createMemo(() => {
183
+ const id = activeId()
184
+ return id ? state.runs[id] : undefined
185
+ })
186
+
187
+ return {
188
+ runs,
189
+ runById: (id) => state.runs[id],
190
+ activeRun,
191
+ activeRunId: activeId,
192
+ openRun: (runId) => {
193
+ batch(() => {
194
+ setActiveId(runId)
195
+ setSelPhase(0)
196
+ setSelAgent("")
197
+ setExpand(false)
198
+ setFullPrompt(false)
199
+ })
200
+ },
201
+ closeRun: () => setActiveId(undefined),
202
+
203
+ selPhase,
204
+ setSelPhase: (i) => setSelPhase(i),
205
+ selAgent: () => selAgent() || undefined,
206
+ setSelAgent: (id) => setSelAgent(id ?? ""),
207
+ expandActivity: expand,
208
+ toggleExpand: () => setExpand((v) => !v),
209
+ fullPrompt,
210
+ toggleFullPrompt: () => setFullPrompt((v) => !v),
211
+
212
+ now,
213
+ spinner: () => SPINNER[frame()] ?? SPINNER[0],
214
+ size,
215
+ isStale: (run) => {
216
+ if (!isLive(run.status)) return false
217
+ const at = written()[run.runId]
218
+ return at != null && now() - at > STALE_AFTER_MS
219
+ },
220
+
221
+ runsDir,
222
+ control: (runId, action) => {
223
+ try {
224
+ mkdirSync(join(runsDir(), runId), { recursive: true })
225
+ writeFileSync(controlPath(runsDir(), runId), JSON.stringify({ action, at: Date.now() }))
226
+ } catch {}
227
+ },
228
+ pendingControl: (runId) => {
229
+ now() // re-evaluate each clock tick
230
+ try {
231
+ const raw = readFileSync(controlPath(runsDir(), runId), "utf8")
232
+ const a = JSON.parse(raw)?.action
233
+ return a === "pause" || a === "resume" || a === "stop" ? a : undefined
234
+ } catch {
235
+ return undefined
236
+ }
237
+ },
238
+ deleteRun: (runId) => {
239
+ try {
240
+ rmSync(join(runsDir(), runId), { recursive: true, force: true })
241
+ refresh()
242
+ } catch {}
243
+ },
244
+ saveScript: (run) => {
245
+ try {
246
+ const src = join(runsDir(), run.runId, "script.js")
247
+ if (!existsSync(src)) return undefined
248
+ const destDir = workflowRoot(root())
249
+ mkdirSync(destDir, { recursive: true })
250
+ const dest = join(destDir, `${safe(run.name)}.js`)
251
+ writeFileSync(dest, readFileSync(src, "utf8"))
252
+ return dest
253
+ } catch {
254
+ return undefined
255
+ }
256
+ },
257
+ markNotified: (id) => {
258
+ notified.add(id)
259
+ },
260
+ wasNotified: (id) => notified.has(id),
261
+ }
262
+ }
263
+
264
+ function safe(n: string): string {
265
+ return n.replace(/[^a-zA-Z0-9_-]/g, "-")
266
+ }