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.
- package/LICENSE +21 -0
- package/README.md +243 -0
- package/package.json +60 -0
- package/skills/workflow-authoring/SKILL.md +89 -0
- package/src/runtime/engine.ts +1020 -0
- package/src/runtime/schema.ts +92 -0
- package/src/runtime/script.ts +220 -0
- package/src/runtime/selftest.ts +321 -0
- package/src/server/index.ts +394 -0
- package/src/shared/format.ts +123 -0
- package/src/shared/state.ts +189 -0
- package/src/tui/index.tsx +1517 -0
- package/src/tui/requests.ts +265 -0
- package/src/tui/store.ts +266 -0
|
@@ -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
|
+
}
|
package/src/tui/store.ts
ADDED
|
@@ -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
|
+
}
|