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,1020 @@
1
+ // Workflow engine: runs a parsed workflow script against the opencode SDK.
2
+ // Deterministic-ish sandbox (see script.ts), concurrency pool, live state
3
+ // writing to /tmp/opencode-workflows/<project>/<runId>/state.json, pause/stop control.
4
+
5
+ import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync, existsSync, rmSync } from "node:fs"
6
+ import { createHash } from "node:crypto"
7
+ import { cpus } from "node:os"
8
+ import { join } from "node:path"
9
+ import {
10
+ type AgentState,
11
+ type AgentStatus,
12
+ type RunState,
13
+ type RunStatus,
14
+ controlPath,
15
+ journalPath,
16
+ runDir,
17
+ statePath,
18
+ } from "../shared/state.ts"
19
+ import { extractJson, validateSchema } from "./schema.ts"
20
+ import { buildScriptFunction, parseScript, type AgentOpts, type Primitives } from "./script.ts"
21
+
22
+ export interface EngineClient {
23
+ session: {
24
+ create(args: { body: { parentID?: string; title?: string } }): Promise<any>
25
+ prompt(args: { path: { id: string }; body: any }): Promise<any>
26
+ abort(args: { path: { id: string } }): Promise<any>
27
+ }
28
+ }
29
+
30
+ export interface EngineDeps {
31
+ client: EngineClient
32
+ /** absolute path to the project's .opencode dir (saved workflows live in <dir>/workflows) */
33
+ opencodeDir: string
34
+ /** absolute directory that holds one folder per run (see runsRoot() in shared/state.ts) */
35
+ runsRoot: string
36
+ mainSessionID: string
37
+ defaultModel?: string
38
+ availableModels: Set<string>
39
+ runArgs?: any
40
+ /** called when a child session is created for an agent (agentId, sessionId) */
41
+ onChildSession?: (agentId: string, sessionId: string) => void
42
+ log?: (level: "debug" | "info" | "warn" | "error", message: string, meta?: Record<string, unknown>) => void
43
+ }
44
+
45
+ export interface RunOptions {
46
+ script?: string
47
+ scriptPath?: string
48
+ name?: string
49
+ args?: any
50
+ budgetTotal?: number | null
51
+ /**
52
+ * Resume a run whose engine died (opencode exited/crashed, or it was stopped).
53
+ * The engine must be constructed with the SAME runId. The prior script is
54
+ * re-executed; every agent() whose (phase, label, prompt) matches a completed
55
+ * agent of the prior run returns that agent's journaled result instantly and
56
+ * is marked `replayed`. Everything else runs for real.
57
+ */
58
+ resume?: PriorRun
59
+ }
60
+
61
+ /** what loadPriorRun() recovers from a run folder for a resume */
62
+ export interface PriorRun {
63
+ state: RunState
64
+ script: string
65
+ /** replay key (see replayKey) -> journaled results + agent snapshots, in spawn order */
66
+ replay: Map<string, Array<{ result: any; agent: AgentState }>>
67
+ /** completed agents that can be replayed */
68
+ replayable: number
69
+ }
70
+
71
+ /** agents are matched across runs by where they ran and what they were asked */
72
+ function replayKey(phase: string, label: string, prompt: string): string {
73
+ return `${phase}\u0000${label}\u0000${createHash("sha1").update(prompt).digest("hex")}`
74
+ }
75
+
76
+ /**
77
+ * Read a finished/stopped run's folder so it can be resumed in place. Returns
78
+ * undefined when the folder has no usable state or script. Results come from
79
+ * journal.jsonl (`agent-done` entries carry the full value); the state file
80
+ * supplies prompts and token/cost snapshots. A result that was truncated in
81
+ * the journal is not replayable, so that agent simply runs again.
82
+ */
83
+ export function loadPriorRun(runsRootDir: string, runId: string): PriorRun | undefined {
84
+ let state: RunState
85
+ let script: string
86
+ try {
87
+ state = JSON.parse(readFileSync(statePath(runsRootDir, runId), "utf8"))
88
+ script = readFileSync(join(runDir(runsRootDir, runId), "script.js"), "utf8")
89
+ } catch {
90
+ return undefined
91
+ }
92
+ if (!state || typeof state !== "object" || !state.agents || !script) return undefined
93
+ const results = new Map<string, any>()
94
+ try {
95
+ for (const line of readFileSync(journalPath(runsRootDir, runId), "utf8").split("\n")) {
96
+ if (!line) continue
97
+ let e: any
98
+ try {
99
+ e = JSON.parse(line)
100
+ } catch {
101
+ continue
102
+ }
103
+ if (e?.type !== "agent-done" || e.status !== "completed" || typeof e.result !== "string") continue
104
+ try {
105
+ results.set(e.id, JSON.parse(e.result))
106
+ } catch {
107
+ // truncated or otherwise unparsable → not replayable
108
+ }
109
+ }
110
+ } catch {}
111
+ const replay = new Map<string, Array<{ result: any; agent: AgentState }>>()
112
+ let replayable = 0
113
+ for (const id of state.agentOrder ?? Object.keys(state.agents)) {
114
+ const a = state.agents[id]
115
+ if (!a || a.status !== "completed") continue
116
+ let result: any
117
+ if (results.has(id)) result = results.get(id)
118
+ else if (a.outcome !== undefined) result = a.outcome
119
+ else continue
120
+ const key = replayKey(a.phase, a.label, a.prompt ?? "")
121
+ const list = replay.get(key) ?? []
122
+ list.push({ result, agent: a })
123
+ replay.set(key, list)
124
+ replayable++
125
+ }
126
+ return { state, script, replay, replayable }
127
+ }
128
+
129
+ export class RunAbortedError extends Error {
130
+ constructor() {
131
+ super("workflow run was stopped")
132
+ }
133
+ }
134
+
135
+ interface ModelRef {
136
+ providerID: string
137
+ modelID: string
138
+ label: string
139
+ }
140
+
141
+ export function generateRunId(): string {
142
+ const ts = Date.now().toString(36)
143
+ const rand = Math.random().toString(36).slice(2, 8)
144
+ return `run_${ts}${rand}`
145
+ }
146
+
147
+ export class RunEngine {
148
+ state!: RunState
149
+ private stopRequested = false
150
+ private paused = false
151
+ private flushTimer: any = null
152
+ private flushScheduled = false
153
+ private heartbeat: any = null
154
+ private controlTimer: any = null
155
+ private agentSeq = 0
156
+ private sessionToAgent = new Map<string, string>()
157
+ private sessionMsgTotals = new Map<string, Map<string, { t: number; o: number; c: number; ctx: number }>>()
158
+ private lastTextPart = new Map<string, string>()
159
+ private sem = 0
160
+ private semMax: number
161
+ private waiters: Array<() => void> = []
162
+ private stoppedAgents = new Set<string>()
163
+ private replay: Map<string, Array<{ result: any; agent: AgentState }>> | undefined
164
+ private startedAt: number
165
+ private resolveModelLabel: (m: ModelRef) => string
166
+
167
+ private deps: EngineDeps
168
+ private runId: string
169
+ constructor(deps: EngineDeps, runId: string) {
170
+ this.deps = deps
171
+ this.runId = runId
172
+ this.semMax = Math.max(1, Math.min(16, (cpus()?.length ?? 4) - 2))
173
+ this.startedAt = Date.now()
174
+ this.resolveModelLabel = (m) => `${m.providerID}/${m.modelID}`
175
+ }
176
+
177
+ get runDir() {
178
+ return runDir(this.deps.runsRoot, this.runId)
179
+ }
180
+
181
+ registerSession(agentId: string, sessionId: string) {
182
+ this.sessionToAgent.set(sessionId, agentId)
183
+ }
184
+
185
+ // --- public API used by the server plugin -------------------------------
186
+ // Token/cost accounting is per assistant message (deduped by message id):
187
+ // bus `message.updated` events stream it live, and the final prompt response
188
+ // records the last message (same id → overwrite, no double count). Bus
189
+ // `message.part.updated` events drive LIVE tool activity (onPartUpdated).
190
+ onMessageUpdated(msg: any): void {
191
+ this.recordMessage(msg)
192
+ }
193
+
194
+ recordMessage(msg: any): void {
195
+ if (!msg || typeof msg.id !== "string" || !msg.sessionID) return
196
+ if (msg.role && msg.role !== "assistant") return
197
+ const agentId = this.sessionToAgent.get(msg.sessionID)
198
+ if (!agentId) return
199
+ const a = this.state.agents[agentId]
200
+ if (!a) return
201
+ const tk = tokenTotal(msg.tokens)
202
+ const entry = {
203
+ t: Math.max(0, tk | 0),
204
+ o: Math.max(0, Number(msg.tokens?.output ?? 0) | 0),
205
+ c: typeof msg.cost === "number" ? msg.cost : 0,
206
+ ctx: Math.max(0, contextTokens(msg.tokens) | 0),
207
+ }
208
+ const per = this.sessionMsgTotals.get(msg.sessionID) ?? new Map<string, { t: number; o: number; c: number; ctx: number }>()
209
+ const prev = per.get(msg.id)
210
+ if (prev && prev.t === entry.t && prev.o === entry.o && prev.c === entry.c && prev.ctx === entry.ctx) return
211
+ per.set(msg.id, entry)
212
+ this.sessionMsgTotals.set(msg.sessionID, per)
213
+ // billed = sum over all calls; context = prompt size of the latest call
214
+ // (message ids are time-ordered, so the greatest id is the newest call;
215
+ // a still-streaming message may report 0 until the provider fills it in,
216
+ // so keep the previous non-zero context in that case)
217
+ let T = 0
218
+ let O = 0
219
+ let C = 0
220
+ let newestId = ""
221
+ let ctx = 0
222
+ for (const [id, e] of per) {
223
+ T += e.t
224
+ O += e.o
225
+ C += e.c
226
+ if (id > newestId && e.ctx > 0) {
227
+ newestId = id
228
+ ctx = e.ctx
229
+ }
230
+ }
231
+ a.tokens = T
232
+ a.outputTokens = O
233
+ a.contextTokens = ctx
234
+ a.cost = C
235
+ this.recount()
236
+ this.markDirty()
237
+ }
238
+
239
+ onPartUpdated(part: any): void {
240
+ const agentId = this.sessionToAgent.get(part?.sessionID)
241
+ if (!agentId) return
242
+ const a = this.state.agents[agentId]
243
+ if (!a) return
244
+ if (part?.type === "reasoning") {
245
+ this.onReasoningPart(a, part)
246
+ return
247
+ }
248
+ if (part?.type === "text") {
249
+ const txt = typeof part?.text === "string" ? part.text : ""
250
+ if (!txt.trim()) return
251
+ a.liveText = txt.slice(-800)
252
+ const pid = typeof part?.id === "string" ? part.id : ""
253
+ if (pid !== this.lastTextPart.get(agentId)) {
254
+ this.lastTextPart.set(agentId, pid)
255
+ this.pushLiveFeed(a, "text", txt)
256
+ } else {
257
+ // same part still streaming: refresh the newest feed line in place
258
+ const f = a.liveFeed?.[a.liveFeed.length - 1]
259
+ if (f && f.kind === "text") f.text = txt.trim().replace(/\s+/g, " ").slice(0, 160)
260
+ }
261
+ this.markDirty()
262
+ return
263
+ }
264
+ if (part?.type !== "tool") return
265
+ const st = part.state
266
+ const callId = typeof part?.callID === "string" ? part.callID : typeof part?.id === "string" ? part.id : undefined
267
+ // match the open activity entry by call id first; fall back to "latest
268
+ // open entry of the same tool" for hosts that do not send ids
269
+ const existing =
270
+ (callId && a.activity.find((x) => x.callId === callId)) ||
271
+ a.activity.find((x) => !x.callId && x.tool === part.tool && !x.endedAt)
272
+ const title = toolTitle(part.tool, st)
273
+ if (!existing) {
274
+ a.activity.push({
275
+ callId,
276
+ tool: part.tool,
277
+ title,
278
+ preview: undefined,
279
+ startedAt: Date.now(),
280
+ })
281
+ a.toolCalls = a.activity.filter((x) => x.kind !== "think").length
282
+ this.pushLiveFeed(a, "tool", `${part.tool} ${title !== part.tool ? title : ""}`)
283
+ this.recount()
284
+ this.markDirty()
285
+ return
286
+ }
287
+ if (existing.title === existing.tool && title !== part.tool) existing.title = title
288
+ if (!existing.endedAt && (st?.status === "completed" || st?.status === "error")) {
289
+ existing.endedAt = Date.now()
290
+ existing.preview = truncate(String(st?.output ?? st?.error ?? ""), 300)
291
+ const tail = st?.status === "error" ? `failed · ${oneLine(String(st?.error ?? ""))}` : "done"
292
+ this.pushLiveFeed(a, "tool", `${existing.tool} ${tail} · ${existing.title !== existing.tool ? existing.title : ""}`)
293
+ this.markDirty()
294
+ } else {
295
+ this.markDirty()
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Thinking blocks become "think" activity rows: one per reasoning part,
301
+ * matched by part id. The row's title is the first line of the thought
302
+ * (empty when the provider hides the text), its duration comes from the
303
+ * part's own time.start/time.end so it is exact even when events lag.
304
+ */
305
+ private onReasoningPart(a: AgentState, part: any): void {
306
+ const pid = typeof part?.id === "string" ? part.id : undefined
307
+ if (!pid) return
308
+ const txt = typeof part?.text === "string" ? part.text : ""
309
+ const start = typeof part?.time?.start === "number" ? part.time.start : Date.now()
310
+ const end = typeof part?.time?.end === "number" ? part.time.end : undefined
311
+ const title = oneLine(txt).slice(0, 120)
312
+ const feedText = title ? `think · ${title}` : "think"
313
+ let act = a.activity.find((x) => x.kind === "think" && x.callId === pid)
314
+ if (!act) {
315
+ act = { kind: "think", callId: pid, tool: "think", title, startedAt: start }
316
+ a.activity.push(act)
317
+ this.pushLiveFeed(a, "think", feedText)
318
+ } else {
319
+ if (title) act.title = title
320
+ // keep the feed line for this thought fresh while the text streams
321
+ const feed = a.liveFeed ?? []
322
+ const f = [...feed].reverse().find((x) => x.kind === "think")
323
+ if (f && !act.endedAt) f.text = feedLine(feedText)
324
+ }
325
+ if (txt.trim()) act.preview = truncate(txt, 300)
326
+ if (end && !act.endedAt) {
327
+ act.endedAt = end
328
+ const feed = a.liveFeed ?? []
329
+ const f = [...feed].reverse().find((x) => x.kind === "think")
330
+ const dur = fmtSecs(end - act.startedAt)
331
+ if (f) f.text = feedLine(act.title ? `think ${dur} · ${act.title}` : `think ${dur}`)
332
+ }
333
+ this.markDirty()
334
+ }
335
+
336
+ private pushLiveFeed(a: AgentState, kind: "text" | "tool" | "think", text: string): void {
337
+ const line = feedLine(text)
338
+ if (!line) return
339
+ const feed = a.liveFeed ?? (a.liveFeed = [])
340
+ feed.push({ at: Date.now(), kind, text: line })
341
+ while (feed.length > 10) feed.shift()
342
+ }
343
+
344
+ // --- run lifecycle -------------------------------------------------------
345
+
346
+ async run(opts: RunOptions): Promise<{ runId: string; status: RunStatus; name: string; error?: string; result?: string }> {
347
+ mkdirSync(this.runDir, { recursive: true })
348
+ const prior = opts.resume
349
+ if (prior) {
350
+ if (prior.state.runId !== this.runId) throw new Error(`resume: engine runId ${this.runId} does not match prior run ${prior.state.runId}`)
351
+ opts = { ...opts, script: prior.script, scriptPath: undefined, name: undefined, args: opts.args ?? prior.state.args }
352
+ this.replay = prior.replay
353
+ this.startedAt = prior.state.startedAt || this.startedAt
354
+ }
355
+ const parsed = this.resolveScript(opts)
356
+ const meta = parsed.meta
357
+ const total = countAgents(meta)
358
+
359
+ this.state = {
360
+ runId: this.runId,
361
+ status: "running",
362
+ name: meta.name,
363
+ description: meta.description,
364
+ whenToUse: meta.whenToUse,
365
+ phases: (meta.phases ?? []).map((p: any, i: number) => ({ ...p, index: i + 1, agentIds: [], done: 0 })),
366
+ agents: {},
367
+ agentOrder: [],
368
+ logs: [],
369
+ agentCount: 0,
370
+ agentDone: 0,
371
+ startedAt: this.startedAt,
372
+ totalTokens: 0,
373
+ totalContextTokens: 0,
374
+ totalCost: 0,
375
+ scriptPath: opts.scriptPath ?? (opts.name ? this.findSavedScript(opts.name) : undefined),
376
+ directory: join(this.deps.opencodeDir, ".."),
377
+ mainSessionID: this.deps.mainSessionID || undefined,
378
+ defaultModel: this.deps.defaultModel,
379
+ args: opts.args,
380
+ }
381
+ if (prior) {
382
+ // keep the story of the run: earlier logs, then a marker for this resume
383
+ this.state.logs = (prior.state.logs ?? []).slice(-150)
384
+ this.state.scriptPath = prior.state.scriptPath
385
+ this.state.resumedAt = Date.now()
386
+ this.state.resumeCount = (prior.state.resumeCount ?? 0) + 1
387
+ this.logLine("log", `resumed (${prior.replayable} completed agent${prior.replayable === 1 ? "" : "s"} replay from the journal, the rest run again)`)
388
+ this.writeJournal({ type: "run-start", runId: this.runId, name: meta.name, agentEstimate: total, at: Date.now(), resumed: true, replayable: prior.replayable })
389
+ } else {
390
+ this.writeJournal({ type: "run-start", runId: this.runId, name: meta.name, agentEstimate: total, at: this.startedAt })
391
+ }
392
+ this.flushNow()
393
+
394
+ this.heartbeat = setInterval(() => this.markDirty(), 2000)
395
+ this.controlTimer = setInterval(() => this.pollControl(), 300)
396
+
397
+ try {
398
+ this.deps.runArgs = opts.args
399
+ const fn = buildScriptFunction(this.primitives(opts.budgetTotal ?? null), parsed.body)
400
+ const result = await fn()
401
+ this.finish(meta, this.stopRequested ? "stopped" : "completed", undefined, result)
402
+ } catch (e: any) {
403
+ const stopped = this.stopRequested
404
+ this.finish(meta, stopped ? "stopped" : "failed", stopped ? undefined : (e?.message ?? String(e)), undefined, stopped ? undefined : e)
405
+ } finally {
406
+ this.cleanupTimers()
407
+ this.flushNow()
408
+ }
409
+ return {
410
+ runId: this.runId,
411
+ status: this.state.status,
412
+ name: meta.name,
413
+ error: this.state.error,
414
+ result: this.state.result,
415
+ }
416
+ }
417
+
418
+ private finish(meta: { name: string; description: string }, status: RunStatus, error?: string, result?: any, rawError?: unknown): void {
419
+ this.state.status = status
420
+ this.state.endedAt = Date.now()
421
+ this.state.error = this.state.error ?? error
422
+ if (result !== undefined) {
423
+ this.state.result = truncate(typeof result === "string" ? result : JSON.stringify(result, null, 2), 200_000)
424
+ }
425
+ this.logLine(status, error ? `failed: ${error}` : "completed")
426
+ this.writeJournal({ type: "run-end", runId: this.runId, status, at: Date.now() })
427
+ this.deps.log?.(status === "failed" ? "error" : "info", `workflow ${meta.name}: ${status}`, {
428
+ runId: this.runId,
429
+ agents: this.state.agentCount,
430
+ tokens: this.state.totalTokens,
431
+ contextTokens: this.state.totalContextTokens,
432
+ error: rawError ? String(rawError) : undefined,
433
+ })
434
+ }
435
+
436
+ private budgetTotal: number | null = null
437
+ private primitives(budgetTotal: number | null): Primitives {
438
+ this.budgetTotal = budgetTotal
439
+ return {
440
+ agent: (prompt, opts) => this.spawnAgent(prompt, opts ?? {}),
441
+ parallel: (thunks) => this.parallel(thunks),
442
+ pipeline: (items, ...stages) => this.pipeline(items, stages),
443
+ phase: (title) => this.setPhase(title),
444
+ log: (message) => this.logLine("log", String(message)),
445
+ args: this.deps.runArgs,
446
+ budget: {
447
+ total: budgetTotal,
448
+ spent: () => this.outputTokensSpent(),
449
+ remaining: () => (budgetTotal == null ? Infinity : Math.max(0, budgetTotal - this.outputTokensSpent())),
450
+ },
451
+ }
452
+ }
453
+
454
+ // --- primitives ----------------------------------------------------------
455
+
456
+ private currentPhase = ""
457
+
458
+ private setPhase(title: string) {
459
+ this.currentPhase = title
460
+ if (!this.state.phases.find((p) => p.title === title)) {
461
+ this.state.phases.push({ title, index: this.state.phases.length + 1, agentIds: [], done: 0 })
462
+ this.recount()
463
+ }
464
+ this.markDirty()
465
+ }
466
+
467
+ private ensurePhase(title: string): number {
468
+ let p = this.state.phases.find((x) => x.title === title)
469
+ if (!p) {
470
+ p = { title, index: this.state.phases.length + 1, agentIds: [], done: 0 }
471
+ this.state.phases.push(p)
472
+ }
473
+ return p.index
474
+ }
475
+
476
+ private async parallel(thunks: Array<() => Promise<any>>): Promise<any[]> {
477
+ if (!Array.isArray(thunks)) throw new Error("parallel() expects an array of thunks")
478
+ if (thunks.length > 4096) throw new Error("parallel() accepts at most 4096 items")
479
+ const out: any[] = new Array(thunks.length)
480
+ await Promise.all(
481
+ thunks.map(async (t, i) => {
482
+ try {
483
+ if (typeof t !== "function") throw new Error(`parallel(${i}) is not a thunk`)
484
+ out[i] = await t()
485
+ } catch (e) {
486
+ this.logLine("log", `parallel item ${i} failed: ${errText(e)}`)
487
+ out[i] = null
488
+ }
489
+ }),
490
+ )
491
+ return out
492
+ }
493
+
494
+ private async pipeline(items: any[], stages: Array<(prev: any, item: any, index: number) => Promise<any> | any>): Promise<any[]> {
495
+ if (!Array.isArray(items)) throw new Error("pipeline() expects an items array")
496
+ if (items.length > 4096) throw new Error("pipeline() accepts at most 4096 items")
497
+ if (!stages.length) throw new Error("pipeline() needs at least one stage")
498
+ const results = await Promise.all(
499
+ items.map(async (item, index) => {
500
+ let prev: any = null
501
+ try {
502
+ for (const stage of stages) prev = await stage(prev, item, index)
503
+ } catch (e) {
504
+ this.logLine("log", `pipeline item ${index} dropped: ${errText(e)}`)
505
+ return null
506
+ }
507
+ return prev
508
+ }),
509
+ )
510
+ return results
511
+ }
512
+
513
+ private async spawnAgent(prompt: string, opts: AgentOpts): Promise<any> {
514
+ if (this.state.agentCount >= 1000) throw new Error("workflow agent cap (1000) reached")
515
+ const total = this.budgetTotal
516
+ if (total != null && this.outputTokensSpent() >= total)
517
+ throw new Error("token budget exhausted; further agent() calls are blocked")
518
+
519
+ this.agentSeq++
520
+ const id = `ag-${String(this.agentSeq).padStart(3, "0")}`
521
+ const label = opts.label ?? `agent-${this.agentSeq}`
522
+ const phaseTitle = opts.phase ?? this.currentPhase ?? "main"
523
+ const phaseIndex = this.ensurePhase(phaseTitle)
524
+ if (this.currentPhase === "" && !opts.phase) this.currentPhase = phaseTitle
525
+
526
+ const model = this.resolveModel(opts.model)
527
+ const agent: AgentState = {
528
+ id,
529
+ label,
530
+ phase: phaseTitle,
531
+ phaseIndex,
532
+ status: "queued",
533
+ model: model ? this.resolveModelLabel(model) : this.deps.defaultModel ?? "default",
534
+ tokens: 0,
535
+ contextTokens: 0,
536
+ outputTokens: 0,
537
+ cost: 0,
538
+ toolCalls: 0,
539
+ activity: [],
540
+ prompt: String(prompt),
541
+ sessionId: undefined,
542
+ }
543
+ this.state.agents[id] = agent
544
+ this.state.agentOrder.push(id)
545
+ this.state.phases.find((p) => p.title === phaseTitle)?.agentIds.push(id)
546
+ this.state.agentCount++
547
+ this.recount()
548
+ this.markDirty()
549
+ this.writeJournal({ type: "agent-start", id, label, phase: phaseTitle, at: Date.now() })
550
+
551
+ const hit = this.takeReplay(phaseTitle, label, agent.prompt)
552
+ if (hit) {
553
+ const p = hit.agent
554
+ agent.status = "completed"
555
+ agent.replayed = true
556
+ agent.model = p.model || agent.model
557
+ agent.tokens = p.tokens || 0
558
+ agent.contextTokens = p.contextTokens || 0
559
+ agent.outputTokens = p.outputTokens || 0
560
+ agent.cost = p.cost || 0
561
+ agent.toolCalls = p.toolCalls || 0
562
+ agent.activity = Array.isArray(p.activity) ? p.activity : []
563
+ agent.outcome = p.outcome
564
+ agent.outcomeText = p.outcomeText ?? truncate(typeof hit.result === "string" ? hit.result : JSON.stringify(hit.result, null, 2), 4000)
565
+ agent.sessionId = p.sessionId
566
+ agent.startedAt = p.startedAt
567
+ agent.endedAt = p.endedAt ?? Date.now()
568
+ this.onAgentTerminal(agent)
569
+ this.writeJournal({ type: "agent-done", id, status: "completed", replayed: true, result: truncate(JSON.stringify(hit.result ?? null), 100_000), at: Date.now() })
570
+ return hit.result
571
+ }
572
+
573
+ await this.acquireSem()
574
+ try {
575
+ if (this.stopRequested) {
576
+ agent.status = "cancelled"
577
+ agent.error = "run stopped"
578
+ this.onAgentTerminal(agent)
579
+ return null
580
+ }
581
+ await this.waitIfPaused()
582
+ if (this.stopRequested) {
583
+ agent.status = "cancelled"
584
+ agent.error = "run stopped"
585
+ this.onAgentTerminal(agent)
586
+ return null
587
+ }
588
+ agent.status = "running"
589
+ agent.startedAt = Date.now()
590
+ this.markDirty()
591
+
592
+ const session = unwrap(await this.deps.client.session.create({
593
+ body: { parentID: this.deps.mainSessionID, title: `wf/${this.state.name}/${label}` },
594
+ }))
595
+ agent.sessionId = session.id
596
+ this.sessionToAgent.set(session.id, id)
597
+ this.deps.onChildSession?.(id, session.id)
598
+
599
+ let attempts = opts.schema ? 2 : 1
600
+ let lastFailure = ""
601
+ for (let attempt = 0; attempt < attempts; attempt++) {
602
+ const body: any = {
603
+ parts: [{ type: "text", text: this.subagentPrompt(prompt, phaseTitle, opts, attempt, lastFailure) }],
604
+ }
605
+ if (model) body.model = { providerID: model.providerID, modelID: model.modelID }
606
+ const res = unwrap(await this.deps.client.session.prompt({ path: { id: session.id }, body }))
607
+ const info = res?.info
608
+ if (!info) throw new Error("empty session response")
609
+ this.recordMessage(info)
610
+ if (info.time?.completed) agent.endedAt = info.time.completed
611
+ this.recount()
612
+ this.markDirty()
613
+ if (info.error) {
614
+ throw new Error(typeof info.error === "string" ? info.error : info.error?.message ?? JSON.stringify(info.error))
615
+ }
616
+ const text = finalText(res?.parts)
617
+ if (!opts.schema) {
618
+ agent.outcomeText = truncate(text, 4000)
619
+ return this.terminate(agent, "completed", text)
620
+ }
621
+ const ex = extractJson(text)
622
+ if (!ex.ok) {
623
+ lastFailure = `previous response was not valid JSON: ${ex.error}`
624
+ agent.activity.push({ tool: "StructuredOutput", title: "invalid output", preview: ex.error, startedAt: Date.now(), endedAt: Date.now() })
625
+ this.markDirty()
626
+ continue
627
+ }
628
+ try {
629
+ validateSchema(ex.value, opts.schema)
630
+ agent.outcome = ex.value
631
+ agent.outcomeText = truncate(JSON.stringify(ex.value, null, 2), 4000)
632
+ return this.terminate(agent, "completed", ex.value)
633
+ } catch (e: any) {
634
+ lastFailure = `previous JSON failed schema validation: ${e?.message ?? e}`
635
+ continue
636
+ }
637
+ }
638
+ agent.status = "failed"
639
+ agent.error = `structured output failed after ${attempts} attempts (${lastFailure})`
640
+ this.onAgentTerminal(agent)
641
+ this.writeJournal({ type: "agent-done", id, status: "failed", error: agent.error, at: Date.now() })
642
+ return null
643
+ } catch (e: any) {
644
+ if (this.stopRequested || (e instanceof RunAbortedError)) {
645
+ agent.status = "cancelled"
646
+ agent.error = "run stopped"
647
+ } else {
648
+ agent.status = "failed"
649
+ agent.error = errText(e)
650
+ }
651
+ this.onAgentTerminal(agent)
652
+ this.writeJournal({ type: "agent-done", id, status: agent.status, error: agent.error, at: Date.now() })
653
+ return null
654
+ } finally {
655
+ this.releaseSem()
656
+ }
657
+ }
658
+
659
+ private terminate(agent: AgentState, status: AgentStatus, value: any): any {
660
+ agent.status = status
661
+ agent.endedAt = Date.now()
662
+ this.onAgentTerminal(agent)
663
+ this.writeJournal({
664
+ type: "agent-done",
665
+ id: agent.id,
666
+ status,
667
+ result: truncate(JSON.stringify(value ?? null), 100_000),
668
+ at: Date.now(),
669
+ })
670
+ return value
671
+ }
672
+
673
+ /** pop the next journaled result for this (phase, label, prompt), if resuming */
674
+ private takeReplay(phase: string, label: string, prompt: string): { result: any; agent: AgentState } | undefined {
675
+ if (!this.replay) return undefined
676
+ const list = this.replay.get(replayKey(phase, label, prompt))
677
+ if (!list?.length) return undefined
678
+ return list.shift()
679
+ }
680
+
681
+ private onAgentTerminal(_agent: AgentState): void {
682
+ this.recount()
683
+ this.markDirty()
684
+ }
685
+
686
+ // --- model resolution -----------------------------------------------------
687
+
688
+ private resolveModel(requested?: string): ModelRef | undefined {
689
+ if (!requested) return this.parseModel(this.deps.defaultModel)
690
+ const direct = this.parseModel(requested)
691
+ if (direct) {
692
+ if (this.deps.availableModels.has(`${direct.providerID}/${direct.modelID}`)) return direct
693
+ // not in the known set: fall back to session model and note it
694
+ this.logLine("log", `model "${requested}" not found in available models; using session model for agent`)
695
+ return this.parseModel(this.deps.defaultModel)
696
+ }
697
+ // bare name: match any provider/modelID
698
+ const hits: string[] = []
699
+ for (const m of this.deps.availableModels) {
700
+ const i = m.indexOf("/")
701
+ if (m.slice(i + 1) === requested || m === requested) hits.push(m)
702
+ }
703
+ if (hits.length >= 1) return this.parseModel(hits[0])!
704
+ this.logLine("log", `model "${requested}" not found; using session model for agent`)
705
+ return this.parseModel(this.deps.defaultModel)
706
+ }
707
+
708
+ private parseModel(s?: string): ModelRef | undefined {
709
+ if (!s) return undefined
710
+ const i = s.indexOf("/")
711
+ if (i <= 0 || i === s.length - 1) return undefined
712
+ return { providerID: s.slice(0, i), modelID: s.slice(i + 1), label: s }
713
+ }
714
+
715
+ // --- prompting -------------------------------------------------------------
716
+
717
+ private subagentPrompt(prompt: string, phase: string, opts: AgentOpts, attempt: number, lastFailure: string): string {
718
+ const parts: string[] = []
719
+ parts.push(`You are a sub-agent of a workflow run (workflow "${this.state.name}", phase "${phase}").`)
720
+ parts.push("Your final message text is the machine return value of this task — treat it as data for the orchestrator, not a message to a human.")
721
+ parts.push("- Be concise and factual. No conversational preamble, no summaries of what you did.")
722
+ if (attempt > 0 && lastFailure) {
723
+ parts.push("")
724
+ parts.push(`IMPORTANT: your previous attempt was rejected — ${lastFailure}. This time respond correctly.`)
725
+ }
726
+ if (opts.schema) {
727
+ parts.push("")
728
+ parts.push("Respond with ONLY a single JSON object that matches this JSON Schema exactly. No markdown fences, no text before or after:")
729
+ parts.push(JSON.stringify(opts.schema))
730
+ } else {
731
+ parts.push("")
732
+ parts.push("Return the raw result text. If the result is code or JSON, a fenced block or bare value is fine.")
733
+ }
734
+ parts.push("")
735
+ parts.push("--- TASK ---")
736
+ parts.push(prompt)
737
+ return parts.join("\n")
738
+ }
739
+
740
+ // --- control (pause / stop) -------------------------------------------------
741
+
742
+ private pollControl(): void {
743
+ const p = controlPath(this.deps.runsRoot, this.runId)
744
+ let raw: string | undefined
745
+ try {
746
+ raw = readFileSync(p, "utf8")
747
+ } catch {
748
+ return
749
+ }
750
+ let ctl: any
751
+ try {
752
+ ctl = JSON.parse(raw)
753
+ } catch {
754
+ return
755
+ }
756
+ if (!ctl || typeof ctl !== "object") return
757
+ // accept both shapes: {"stop":true} and {"action":"stop"}
758
+ const wantStop = ctl.stop === true || ctl.action === "stop"
759
+ const wantPause = ctl.pause === true || ctl.action === "pause"
760
+ const wantResume = ctl.resume === true || ctl.action === "resume"
761
+ try {
762
+ rmSync(p, { force: true })
763
+ } catch {}
764
+ if (wantPause && !this.paused && !this.stopRequested) {
765
+ this.paused = true
766
+ this.state.status = "paused"
767
+ this.logLine("log", "paused by user")
768
+ this.markDirty()
769
+ }
770
+ if (wantResume && this.paused) {
771
+ this.paused = false
772
+ if (this.state.status === "paused") this.state.status = "running"
773
+ this.logLine("log", "resumed by user")
774
+ this.markDirty()
775
+ }
776
+ if (wantStop && !this.stopRequested) {
777
+ this.stopRequested = true
778
+ this.state.status = "running"
779
+ this.logLine("log", "stop requested by user")
780
+ this.markDirty()
781
+ this.abortAll()
782
+ }
783
+ }
784
+
785
+ private abortAll(): void {
786
+ for (const a of Object.values(this.state.agents)) {
787
+ if (a.status === "running" && a.sessionId) {
788
+ this.stoppedAgents.add(a.id)
789
+ this.deps.client.session
790
+ .abort({ path: { id: a.sessionId } })
791
+ .catch(() => {})
792
+ }
793
+ }
794
+ }
795
+
796
+ private async waitIfPaused(): Promise<void> {
797
+ while (this.paused && !this.stopRequested) {
798
+ await sleep(300)
799
+ }
800
+ if (this.stopRequested) throw new RunAbortedError()
801
+ }
802
+
803
+ // --- concurrency pool --------------------------------------------------------
804
+
805
+ private acquireSem(): Promise<void> {
806
+ if (this.sem < this.semMax) {
807
+ this.sem++
808
+ return Promise.resolve()
809
+ }
810
+ return new Promise((res) => this.waiters.push(res))
811
+ }
812
+ private releaseSem(): void {
813
+ this.sem--
814
+ const next = this.waiters.shift()
815
+ if (next) {
816
+ this.sem++
817
+ next()
818
+ }
819
+ }
820
+
821
+ private outputTokensSpent(): number {
822
+ return Object.values(this.state.agents).reduce((s, a) => s + (a.outputTokens || 0), 0)
823
+ }
824
+
825
+ // --- state persistence ---------------------------------------------------------
826
+
827
+ private recount(): void {
828
+ for (const p of this.state.phases) {
829
+ p.done = p.agentIds.filter((id) => {
830
+ const a = this.state.agents[id]
831
+ return a && a.status !== "queued" && a.status !== "running"
832
+ }).length
833
+ }
834
+ const agents = Object.values(this.state.agents)
835
+ this.state.agentCount = agents.length
836
+ this.state.agentDone = agents.filter((a) => a.status !== "queued" && a.status !== "running").length
837
+ this.state.totalTokens = agents.reduce((s, a) => s + (a.tokens || 0), 0)
838
+ this.state.totalContextTokens = agents.reduce((s, a) => s + (a.contextTokens || 0), 0)
839
+ this.state.totalCost = agents.reduce((s, a) => s + (a.cost || 0), 0)
840
+ }
841
+
842
+ private markDirty(): void {
843
+ if (this.flushScheduled || !this.state) return
844
+ this.flushScheduled = true
845
+ this.flushTimer = setTimeout(() => {
846
+ this.flushScheduled = false
847
+ this.flushTimer = null
848
+ this.flushNow()
849
+ }, 400)
850
+ }
851
+
852
+ flushNow(): void {
853
+ if (!this.state) return
854
+ try {
855
+ mkdirSync(this.runDir, { recursive: true })
856
+ writeFileSync(statePath(this.deps.runsRoot, this.runId), JSON.stringify(this.state, null, 2))
857
+ } catch (e: any) {
858
+ this.deps.log?.("error", `workflow state write failed: ${errText(e)}`)
859
+ }
860
+ }
861
+
862
+ private writeJournal(entry: any): void {
863
+ try {
864
+ mkdirSync(this.runDir, { recursive: true })
865
+ appendFileSync(journalPath(this.deps.runsRoot, this.runId), JSON.stringify(entry) + "\n")
866
+ } catch {}
867
+ }
868
+
869
+ private logLine(kind: string, message: string): void {
870
+ this.state.logs.push({ at: Date.now(), message })
871
+ if (this.state.logs.length > 200) this.state.logs.splice(0, this.state.logs.length - 200)
872
+ this.deps.log?.("info", `[${this.state.name}] ${message}`)
873
+ this.markDirty()
874
+ }
875
+
876
+ private resolveScript(opts: RunOptions): { meta: any; body: string; raw: string } {
877
+ let script: string | undefined = opts.script
878
+ if (!script && opts.scriptPath) script = readFileSync(opts.scriptPath, "utf8")
879
+ if (!script && opts.name) {
880
+ const p = this.findSavedScript(opts.name)
881
+ if (p) script = readFileSync(p, "utf8")
882
+ else throw new Error(`saved workflow "${opts.name}" not found (looked in .opencode/workflows and ~/.config/opencode/workflows)`)
883
+ }
884
+ if (!script) throw new Error("Workflow requires one of: script (inline), scriptPath, or name")
885
+ const parsed = parseScript(script)
886
+ try {
887
+ writeFileSync(join(this.runDir, "script.js"), script)
888
+ } catch {}
889
+ return { ...parsed, raw: script }
890
+ }
891
+
892
+ findSavedScript(name: string): string | undefined {
893
+ const candidates = [
894
+ join(this.deps.opencodeDir, "workflows", `${safeName(name)}.js`),
895
+ join(this.deps.opencodeDir, "workflows", `${safeName(name)}`),
896
+ ]
897
+ for (const c of candidates) if (existsSync(c) && statSync(c).isFile()) return c
898
+ return undefined
899
+ }
900
+
901
+ /** host is shutting down: mark the run stopped so the TUI never shows a zombie "running" */
902
+ shutdown(reason: string): void {
903
+ if (!this.state) return
904
+ if (this.state.status === "completed" || this.state.status === "failed" || this.state.status === "stopped") return
905
+ this.stopRequested = true
906
+ this.abortAll()
907
+ for (const a of Object.values(this.state.agents)) {
908
+ if (a.status === "running" || a.status === "queued") {
909
+ a.status = "cancelled"
910
+ a.error = reason
911
+ a.endedAt = Date.now()
912
+ }
913
+ }
914
+ this.recount()
915
+ this.state.status = "stopped"
916
+ this.state.endedAt = Date.now()
917
+ this.state.error = this.state.error ?? reason
918
+ this.logLine("stopped", reason)
919
+ this.writeJournal({ type: "run-end", runId: this.runId, status: "stopped", at: Date.now(), reason })
920
+ this.cleanupTimers()
921
+ this.flushNow()
922
+ }
923
+
924
+ private cleanupTimers(): void {
925
+ if (this.heartbeat) clearInterval(this.heartbeat)
926
+ if (this.controlTimer) clearInterval(this.controlTimer)
927
+ if (this.flushTimer) clearTimeout(this.flushTimer)
928
+ }
929
+ }
930
+
931
+ // --- helpers -----------------------------------------------------------------
932
+
933
+ function tokenTotal(t: any): number {
934
+ if (!t) return 0
935
+ return (t.input ?? 0) + (t.output ?? 0) + (t.reasoning ?? 0) + (t.cache?.read ?? 0) + (t.cache?.write ?? 0)
936
+ }
937
+
938
+ /** prompt size of one API call: everything the model read, minus what it wrote */
939
+ function contextTokens(t: any): number {
940
+ if (!t) return 0
941
+ return (t.input ?? 0) + (t.cache?.read ?? 0) + (t.cache?.write ?? 0)
942
+ }
943
+
944
+ function finalText(parts: any[]): string {
945
+ if (!parts) return ""
946
+ const texts: string[] = []
947
+ for (let i = parts.length - 1; i >= 0; i--) {
948
+ const p = parts[i]
949
+ if (p?.type === "text" && typeof p.text === "string") {
950
+ texts.unshift(p.text)
951
+ }
952
+ }
953
+ // last text part wins (final answer), fall back to all
954
+ const last = texts.length ? texts[texts.length - 1] : ""
955
+ return last.trim() || texts.join("\n")
956
+ }
957
+
958
+ function unwrap(res: any): any {
959
+ if (res && typeof res === "object" && "data" in res) {
960
+ const r = res as any
961
+ if (r.error) {
962
+ const msg = r.error?.data?.message ?? r.error?.message ?? "opencode API error"
963
+ throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg))
964
+ }
965
+ return r.data
966
+ }
967
+ return res
968
+ }
969
+
970
+ function truncate(s: string, n: number): string {
971
+ if (s.length <= n) return s
972
+ return s.slice(0, n) + `… [truncated ${s.length - n} chars]`
973
+ }
974
+
975
+ function errText(e: any): string {
976
+ return e?.message ?? String(e)
977
+ }
978
+
979
+ function sleep(ms: number): Promise<void> {
980
+ return new Promise((r) => setTimeout(r, ms))
981
+ }
982
+
983
+ function countAgents(meta: { phases?: any[] }): number {
984
+ const fromPhases = (meta.phases ?? []).reduce((n, p: any) => n + (Array.isArray(p?.agentIds) ? p.agentIds.length : 0), 0)
985
+ return fromPhases || (meta.phases?.length ?? 0)
986
+ }
987
+
988
+ function safeName(n: string): string {
989
+ return n.replace(/[^a-zA-Z0-9_-]/g, "-")
990
+ }
991
+
992
+ export type { Primitives }
993
+
994
+ /** human title for a tool call: host title → recognisable input field → tool name */
995
+ function toolTitle(tool: string, st: any): string {
996
+ const t = typeof st?.title === "string" ? st.title.trim() : ""
997
+ if (t) return oneLine(t).slice(0, 120)
998
+ const input = st?.input
999
+ if (input && typeof input === "object") {
1000
+ for (const k of ["command", "filePath", "path", "pattern", "query", "url", "description", "prompt", "title"]) {
1001
+ const v = (input as any)[k]
1002
+ if (typeof v === "string" && v.trim()) return oneLine(v).slice(0, 120)
1003
+ }
1004
+ for (const v of Object.values(input)) if (typeof v === "string" && v.trim()) return oneLine(v).slice(0, 120)
1005
+ }
1006
+ return String(tool)
1007
+ }
1008
+
1009
+ function oneLine(s: string): string {
1010
+ return s.replace(/\s+/g, " ").trim()
1011
+ }
1012
+
1013
+ function feedLine(text: string): string {
1014
+ return String(text ?? "").trim().replace(/\s+/g, " ").slice(0, 160)
1015
+ }
1016
+
1017
+ function fmtSecs(ms: number): string {
1018
+ const s = Math.max(0, Math.round(ms / 1000))
1019
+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${s % 60 ? ` ${s % 60}s` : ""}`
1020
+ }