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,394 @@
|
|
|
1
|
+
// opencode-ultracode — server plugin.
|
|
2
|
+
//
|
|
3
|
+
// Registers the `workflow` tool (model-facing orchestration), keyword triggers
|
|
4
|
+
// ("run a workflow", "ultracode", ...), a system-prompt nudge so the model
|
|
5
|
+
// recommends workflows for large tasks, and live usage tracking for sub-agent
|
|
6
|
+
// child sessions.
|
|
7
|
+
|
|
8
|
+
import { tool, type Hooks, type PluginInput } from "@opencode-ai/plugin"
|
|
9
|
+
import { join } from "node:path"
|
|
10
|
+
import { fileURLToPath } from "node:url"
|
|
11
|
+
import { existsSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"
|
|
12
|
+
import { generateRunId, loadPriorRun, RunEngine } from "../runtime/engine.ts"
|
|
13
|
+
import { parseScript } from "../runtime/script.ts"
|
|
14
|
+
import { type AgentState, type RunState, controlPath, runsRoot } from "../shared/state.ts"
|
|
15
|
+
|
|
16
|
+
const KEYWORD_RE =
|
|
17
|
+
/\b(ultracode|run\s+a\s+workflow|start\s+(?:a\s+)?workflow|use\s+(?:a\s+)?workflow)\b/iu
|
|
18
|
+
|
|
19
|
+
const KEYWORD_DIRECTIVE =
|
|
20
|
+
"[workflow requested] The user explicitly asked for workflow orchestration. Immediately call the `workflow` tool: author a script (must start with `export const meta = {...}`) that decomposes the request into phases and parallel agents, using agent()/parallel()/pipeline()/phase()/log(). Keep the plan proportional to the task."
|
|
21
|
+
|
|
22
|
+
const SYSTEM_GUIDANCE = `## Workflow orchestration
|
|
23
|
+
You have a \`workflow\` tool that fans a task out across many parallel sub-agents (50-100 in large runs) with phases, structured outputs, and a live progress view (/workflows).
|
|
24
|
+
Use it when:
|
|
25
|
+
- the user explicitly asks ("run a workflow", "ultracode", or the same in another language — the model decides), or
|
|
26
|
+
- the task is too large for one pass: it spans many files/modules, decomposes into parallel workstreams (audit, review, migration, research), or benefits from independent adversarial verification.
|
|
27
|
+
When the task seems large but the user did not ask: recommend a workflow in one or two sentences (scale + rough shape: phases and agent count) and wait for the go-ahead before calling the tool.
|
|
28
|
+
Do NOT use workflows for trivial or single-file tasks.
|
|
29
|
+
For script format (meta block, primitives, patterns), load the \`workflow-authoring\` skill (bundled with this plugin) before writing a script.`
|
|
30
|
+
|
|
31
|
+
const TOOL_DESCRIPTION = `Run a multi-agent workflow: parallel sub-agents over phases with structured outputs, for tasks too large for one pass.
|
|
32
|
+
Args: one of \`script\` (inline JS workflow, must start with \`export const meta = { name, description, phases? }\`), \`scriptPath\` (file), or \`name\` (saved workflow in .opencode/workflows/). Optional \`args\` passed to the script.
|
|
33
|
+
The run starts in the background after the user approves the plan; a result turn is delivered when it finishes. The user can watch progress in /workflows (phases, per-agent model/tokens/time, stop/pause).
|
|
34
|
+
\`resumeRunId\`: restart a run that was stopped or whose engine died (opencode exited while it ran). Completed agents replay from the journal; the rest run again. The user can also do this with \`p\` in /workflows.
|
|
35
|
+
Use for: audits, multi-file migrations, code review across many files, research sweeps, anything parallelizable or needing independent verification.
|
|
36
|
+
Authoring guide: the workflow-authoring skill (bundled with this plugin; load it first) documents agent()/parallel()/pipeline()/phase()/log(), schema-validated structured output, and quality patterns.`
|
|
37
|
+
|
|
38
|
+
// Skills shipped with the plugin (skills/<name>/SKILL.md next to src/). Registered
|
|
39
|
+
// through the `config` hook via `skills.paths`, so the model gets the
|
|
40
|
+
// workflow-authoring skill in every project without the user copying files
|
|
41
|
+
// into .opencode/skill or ~/.config/opencode/skill.
|
|
42
|
+
const BUNDLED_SKILLS_DIR = fileURLToPath(new URL("../../skills", import.meta.url))
|
|
43
|
+
|
|
44
|
+
type Client = NonNullable<PluginInput["client"]>
|
|
45
|
+
|
|
46
|
+
function modelLabel(providerID: string | undefined, id: string | undefined): string | undefined {
|
|
47
|
+
if (!providerID || !id) return undefined
|
|
48
|
+
return `${providerID}/${id}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default async (input: PluginInput): Promise<Hooks> => {
|
|
52
|
+
const active = new Map<string, RunEngine>()
|
|
53
|
+
const sessionModel = new Map<string, string>()
|
|
54
|
+
const childSessionRun = new Map<string, string>()
|
|
55
|
+
const childSessions = new Set<string>()
|
|
56
|
+
|
|
57
|
+
const log = (level: "debug" | "info" | "warn" | "error", message: string, meta?: Record<string, unknown>) => {
|
|
58
|
+
try {
|
|
59
|
+
input.client.app.log({ body: { level, service: "workflow", message, ...meta } }).catch(() => {})
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Fetched lazily (first workflow call), never during plugin init:
|
|
64
|
+
// awaiting a client call at init deadlocks against the server that is
|
|
65
|
+
// still bootstrapping (it waits for plugins before serving HTTP).
|
|
66
|
+
let modelsPromise: Promise<Set<string>> | undefined
|
|
67
|
+
const fetchModels = (): Promise<Set<string>> => {
|
|
68
|
+
modelsPromise ??= (async () => {
|
|
69
|
+
const out = new Set<string>()
|
|
70
|
+
try {
|
|
71
|
+
const res: any = await Promise.race([
|
|
72
|
+
input.client.config.providers(),
|
|
73
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error("providers timeout")), 5000)),
|
|
74
|
+
])
|
|
75
|
+
const data = res?.providers ?? res?.data?.providers ?? res ?? []
|
|
76
|
+
for (const p of data) for (const m of Object.keys(p?.models ?? {})) out.add(`${p.id}/${m}`)
|
|
77
|
+
} catch (e) {
|
|
78
|
+
log("warn", `could not list models: ${errMsg(e)}`)
|
|
79
|
+
}
|
|
80
|
+
return out
|
|
81
|
+
})()
|
|
82
|
+
return modelsPromise
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const projectRootOf = (worktree: string, directory: string): string =>
|
|
86
|
+
existsSync(worktree) ? worktree : directory
|
|
87
|
+
const opencodeDirOf = (worktree: string, directory: string): string =>
|
|
88
|
+
join(projectRootOf(worktree, directory), ".opencode")
|
|
89
|
+
|
|
90
|
+
const preview = (args: { script?: string; scriptPath?: string; name?: string }): {
|
|
91
|
+
name: string
|
|
92
|
+
description: string
|
|
93
|
+
phases: string[]
|
|
94
|
+
} => {
|
|
95
|
+
let raw = args.script
|
|
96
|
+
if (!raw && args.scriptPath) {
|
|
97
|
+
raw = readFileSync(args.scriptPath, "utf8")
|
|
98
|
+
}
|
|
99
|
+
if (!raw && args.name) {
|
|
100
|
+
const bases = [join(input.directory, ".opencode", "workflows"), join(input.worktree, ".opencode", "workflows")]
|
|
101
|
+
for (const base of bases) {
|
|
102
|
+
for (const ext of [".js", ""]) {
|
|
103
|
+
const p = join(base, `${safeName(args.name)}${ext}`)
|
|
104
|
+
if (existsSync(p)) raw = readFileSync(p, "utf8")
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!raw) throw new Error("no workflow script provided")
|
|
109
|
+
const parsed = parseScript(raw)
|
|
110
|
+
return {
|
|
111
|
+
name: parsed.meta.name,
|
|
112
|
+
description: parsed.meta.description,
|
|
113
|
+
phases: (parsed.meta.phases ?? []).map((p) => p.title),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const notifyMainSession = async (mainSessionID: string, res: { runId: string; status: string; name: string; error?: string; result?: string }): Promise<void> => {
|
|
118
|
+
const lines: string[] = []
|
|
119
|
+
lines.push(`Workflow run "${res.name}" finished with status: ${res.status}.`)
|
|
120
|
+
if (res.error) lines.push(`Error: ${res.error}`)
|
|
121
|
+
if (res.result) lines.push("", "Final result:", res.result)
|
|
122
|
+
lines.push("", "Summarize the outcome for the user concisely (a few sentences). Full run details are available in /workflows.")
|
|
123
|
+
try {
|
|
124
|
+
await input.client.session.prompt({
|
|
125
|
+
path: { id: mainSessionID },
|
|
126
|
+
body: { parts: [{ type: "text", text: lines.join("\n") }] },
|
|
127
|
+
})
|
|
128
|
+
} catch (e) {
|
|
129
|
+
log("warn", `could not deliver workflow result to session: ${errMsg(e)}`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const runsRootHere = () => runsRoot(projectRootOf(input.worktree, input.directory))
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Restart a run in place: same runId, same script, completed agents replayed
|
|
137
|
+
* from journal.jsonl. Used by the TUI (control.json {action:"resume"} on a run
|
|
138
|
+
* with no live engine) and by the tool's `resumeRunId`.
|
|
139
|
+
*/
|
|
140
|
+
const resumeRun = async (
|
|
141
|
+
runId: string,
|
|
142
|
+
opts: { runsRoot: string; notifySessionID?: string },
|
|
143
|
+
): Promise<{ ok: true; name: string; replayable: number } | { ok: false; reason: string }> => {
|
|
144
|
+
if (active.has(runId)) return { ok: false, reason: "run is already live in this opencode" }
|
|
145
|
+
const prior = loadPriorRun(opts.runsRoot, runId)
|
|
146
|
+
if (!prior) return { ok: false, reason: "no state.json/script.js for that run" }
|
|
147
|
+
if (prior.state.status === "completed") return { ok: false, reason: "run already completed" }
|
|
148
|
+
const mainSessionID = prior.state.mainSessionID ?? opts.notifySessionID ?? ""
|
|
149
|
+
const availableModels = await fetchModels()
|
|
150
|
+
const engine = new RunEngine(
|
|
151
|
+
{
|
|
152
|
+
client: input.client as any,
|
|
153
|
+
opencodeDir: join(prior.state.directory || projectRootOf(input.worktree, input.directory), ".opencode"),
|
|
154
|
+
runsRoot: opts.runsRoot,
|
|
155
|
+
mainSessionID,
|
|
156
|
+
defaultModel: prior.state.defaultModel ?? (mainSessionID ? sessionModel.get(mainSessionID) : undefined),
|
|
157
|
+
availableModels,
|
|
158
|
+
runArgs: prior.state.args,
|
|
159
|
+
onChildSession: (_agentId, sessionId) => {
|
|
160
|
+
childSessions.add(sessionId)
|
|
161
|
+
childSessionRun.set(sessionId, runId)
|
|
162
|
+
},
|
|
163
|
+
log,
|
|
164
|
+
},
|
|
165
|
+
runId,
|
|
166
|
+
)
|
|
167
|
+
active.set(runId, engine)
|
|
168
|
+
log("info", `resuming workflow run ${runId} (${prior.replayable} agents replay)`)
|
|
169
|
+
engine
|
|
170
|
+
.run({ resume: prior })
|
|
171
|
+
.then((res) => {
|
|
172
|
+
active.delete(runId)
|
|
173
|
+
for (const [sid, rid] of childSessionRun) if (rid === runId) childSessionRun.delete(sid)
|
|
174
|
+
const target = opts.notifySessionID ?? prior.state.mainSessionID
|
|
175
|
+
if (target) notifyMainSession(target, res)
|
|
176
|
+
})
|
|
177
|
+
.catch((e) => {
|
|
178
|
+
active.delete(runId)
|
|
179
|
+
log("error", `resumed workflow run ${runId} crashed: ${errMsg(e)}`)
|
|
180
|
+
})
|
|
181
|
+
return { ok: true, name: prior.state.name, replayable: prior.replayable }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The TUI can only write files. A live engine consumes its own control.json;
|
|
185
|
+
// a control file next to a run with NO engine in this process is a request
|
|
186
|
+
// aimed at us: "resume" restarts the run, anything else is stale and dropped.
|
|
187
|
+
const pollOrphanControls = () => {
|
|
188
|
+
let names: string[]
|
|
189
|
+
const root = runsRootHere()
|
|
190
|
+
try {
|
|
191
|
+
names = readdirSync(root).filter((n) => n.startsWith("run_"))
|
|
192
|
+
} catch {
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
for (const runId of names) {
|
|
196
|
+
if (active.has(runId)) continue
|
|
197
|
+
const cp = controlPath(root, runId)
|
|
198
|
+
let ctl: any
|
|
199
|
+
try {
|
|
200
|
+
if (!existsSync(cp)) continue
|
|
201
|
+
ctl = JSON.parse(readFileSync(cp, "utf8"))
|
|
202
|
+
} catch {
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
rmSync(cp, { force: true })
|
|
207
|
+
} catch {}
|
|
208
|
+
const wantResume = ctl?.action === "resume" || ctl?.resume === true
|
|
209
|
+
if (!wantResume) continue
|
|
210
|
+
resumeRun(runId, { runsRoot: root })
|
|
211
|
+
.then((r) => {
|
|
212
|
+
if (!r.ok) log("warn", `cannot resume ${runId}: ${r.reason}`)
|
|
213
|
+
})
|
|
214
|
+
.catch((e) => log("error", `resume ${runId} failed: ${errMsg(e)}`))
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const orphanTimer = setInterval(pollOrphanControls, 1000)
|
|
218
|
+
|
|
219
|
+
const workflowTool = tool({
|
|
220
|
+
description: TOOL_DESCRIPTION,
|
|
221
|
+
args: {
|
|
222
|
+
script: tool.schema.string().optional().describe("Inline workflow script (must start with `export const meta = { name, description, phases? }`)"),
|
|
223
|
+
scriptPath: tool.schema.string().optional().describe("Path to a workflow script file"),
|
|
224
|
+
name: tool.schema.string().optional().describe("Saved workflow name (.opencode/workflows/<name>.js)"),
|
|
225
|
+
args: tool.schema.any().optional().describe("Value passed to the script as global `args` (real JSON, not a stringified list)"),
|
|
226
|
+
resumeRunId: tool.schema.string().optional().describe("Resume a stopped run (its engine died) by runId: completed agents replay, the rest run again"),
|
|
227
|
+
},
|
|
228
|
+
execute: async (args, ctx) => {
|
|
229
|
+
if (args.resumeRunId) {
|
|
230
|
+
const r = await resumeRun(args.resumeRunId, {
|
|
231
|
+
runsRoot: runsRoot(projectRootOf(input.worktree, ctx.directory)),
|
|
232
|
+
notifySessionID: ctx.sessionID,
|
|
233
|
+
})
|
|
234
|
+
if (!r.ok) return { title: "workflow: cannot resume", output: `Run ${args.resumeRunId} cannot be resumed: ${r.reason}` }
|
|
235
|
+
return {
|
|
236
|
+
title: `workflow: ${r.name} resumed`,
|
|
237
|
+
output: `Workflow "${r.name}" resumed (runId ${args.resumeRunId}); ${r.replayable} completed agent(s) replay from the journal, the rest run again. A result turn will arrive automatically when it finishes — do not poll.`,
|
|
238
|
+
metadata: { runId: args.resumeRunId },
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (!args.script && !args.scriptPath && !args.name)
|
|
242
|
+
return { title: "workflow: missing input", output: "Provide one of: script (inline), scriptPath, name (saved workflow), or resumeRunId." }
|
|
243
|
+
|
|
244
|
+
let plan: { name: string; description: string; phases: string[] }
|
|
245
|
+
try {
|
|
246
|
+
plan = preview(args)
|
|
247
|
+
} catch (e: any) {
|
|
248
|
+
return { title: "workflow: invalid script", output: `Could not parse workflow: ${e?.message ?? e}` }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const runId = generateRunId()
|
|
252
|
+
const availableModels = await fetchModels()
|
|
253
|
+
const engine = new RunEngine(
|
|
254
|
+
{
|
|
255
|
+
client: input.client as any,
|
|
256
|
+
opencodeDir: opencodeDirOf(input.worktree, ctx.directory),
|
|
257
|
+
runsRoot: runsRoot(projectRootOf(input.worktree, ctx.directory)),
|
|
258
|
+
mainSessionID: ctx.sessionID,
|
|
259
|
+
defaultModel: sessionModel.get(ctx.sessionID),
|
|
260
|
+
availableModels,
|
|
261
|
+
runArgs: args.args,
|
|
262
|
+
onChildSession: (agentId, sessionId) => {
|
|
263
|
+
childSessions.add(sessionId)
|
|
264
|
+
childSessionRun.set(sessionId, runId)
|
|
265
|
+
},
|
|
266
|
+
log,
|
|
267
|
+
},
|
|
268
|
+
runId,
|
|
269
|
+
)
|
|
270
|
+
active.set(runId, engine)
|
|
271
|
+
|
|
272
|
+
// Plan approval through the standard permission flow.
|
|
273
|
+
await ctx.ask({
|
|
274
|
+
permission: "workflow",
|
|
275
|
+
patterns: [plan.name],
|
|
276
|
+
always: [plan.name],
|
|
277
|
+
metadata: {
|
|
278
|
+
description: plan.description,
|
|
279
|
+
phases: plan.phases,
|
|
280
|
+
runId,
|
|
281
|
+
hint: "workflow plan approval — phases shown; use /workflows to watch progress",
|
|
282
|
+
},
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
engine
|
|
286
|
+
.run({ script: args.script, scriptPath: args.scriptPath, name: args.name, args: args.args })
|
|
287
|
+
.then((res) => {
|
|
288
|
+
active.delete(runId)
|
|
289
|
+
for (const [sid, rid] of childSessionRun) if (rid === runId) childSessionRun.delete(sid)
|
|
290
|
+
notifyMainSession(ctx.sessionID, res)
|
|
291
|
+
})
|
|
292
|
+
.catch((e) => {
|
|
293
|
+
active.delete(runId)
|
|
294
|
+
log("error", `workflow run ${runId} crashed: ${errMsg(e)}`)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
title: `workflow: ${plan.name} started`,
|
|
299
|
+
output: `Workflow "${plan.name}" is now running (runId ${runId}). Phases: ${plan.phases.join(" → ") || "auto"}. The user can watch progress, pause, or stop it in /workflows. A result turn will arrive automatically when the run finishes — do not poll.`,
|
|
300
|
+
metadata: { runId },
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
tool: { workflow: workflowTool },
|
|
307
|
+
|
|
308
|
+
// Runs once at startup with the merged config object that opencode later
|
|
309
|
+
// hands to skill discovery, so pushing here is enough to make the bundled
|
|
310
|
+
// skills visible in this and every other project the plugin is loaded in.
|
|
311
|
+
config: async (cfg) => {
|
|
312
|
+
let isDir = false
|
|
313
|
+
try {
|
|
314
|
+
isDir = statSync(BUNDLED_SKILLS_DIR).isDirectory()
|
|
315
|
+
} catch {}
|
|
316
|
+
if (!isDir) {
|
|
317
|
+
log("warn", "bundled skills directory missing; workflow-authoring skill unavailable", { dir: BUNDLED_SKILLS_DIR })
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
const c = cfg as { skills?: { paths?: string[]; urls?: string[] } }
|
|
321
|
+
c.skills ??= {}
|
|
322
|
+
c.skills.paths ??= []
|
|
323
|
+
if (!c.skills.paths.includes(BUNDLED_SKILLS_DIR)) c.skills.paths.push(BUNDLED_SKILLS_DIR)
|
|
324
|
+
log("debug", "registered bundled skills", { dir: BUNDLED_SKILLS_DIR })
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
"chat.params": async (i) => {
|
|
328
|
+
const label = modelLabel(i.model?.providerID, i.model?.id)
|
|
329
|
+
if (label && !childSessions.has(i.sessionID)) sessionModel.set(i.sessionID, label)
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
"chat.message": async (i, o) => {
|
|
333
|
+
if (childSessions.has(i.sessionID)) return
|
|
334
|
+
const text = (o.parts ?? []).map((p) => (p as any).type === "text" ? (p as any).text ?? "" : "").join("\n")
|
|
335
|
+
if (KEYWORD_RE.test(text)) {
|
|
336
|
+
o.parts.push({
|
|
337
|
+
type: "text",
|
|
338
|
+
text: KEYWORD_DIRECTIVE,
|
|
339
|
+
id: `prt_workflow_request_${Math.random().toString(36).slice(2, 8)}`,
|
|
340
|
+
} as any)
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
"experimental.chat.system.transform": async (i, o) => {
|
|
345
|
+
if (i.sessionID && childSessions.has(i.sessionID)) return
|
|
346
|
+
o.system.push(SYSTEM_GUIDANCE)
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
event: async ({ event }) => {
|
|
350
|
+
const e = event as any
|
|
351
|
+
if (e?.type === "message.part.updated") {
|
|
352
|
+
const part = e.properties?.part
|
|
353
|
+
const runId = part?.sessionID ? childSessionRun.get(part.sessionID) : undefined
|
|
354
|
+
if (runId) active.get(runId)?.onPartUpdated(part)
|
|
355
|
+
}
|
|
356
|
+
if (e?.type === "message.updated") {
|
|
357
|
+
const m = e.properties?.info ?? e.properties?.message ?? (e.properties?.id ? e.properties : undefined)
|
|
358
|
+
const runId = m?.sessionID ? childSessionRun.get(m.sessionID) : undefined
|
|
359
|
+
if (runId) active.get(runId)?.onMessageUpdated(m)
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
dispose: async () => {
|
|
364
|
+
clearInterval(orphanTimer)
|
|
365
|
+
// opencode is going away: close out live runs so their state files do
|
|
366
|
+
// not claim "running" forever (the TUI would otherwise refuse to delete them).
|
|
367
|
+
// The run folder stays intact and can be resumed (p in /workflows).
|
|
368
|
+
for (const engine of active.values()) {
|
|
369
|
+
try {
|
|
370
|
+
engine.shutdown("opencode exited while the workflow was running — press p in /workflows to resume")
|
|
371
|
+
} catch (e) {
|
|
372
|
+
log("warn", `workflow shutdown failed: ${errMsg(e)}`)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
active.clear()
|
|
376
|
+
},
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// --- small helpers -----------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
function safeName(n: string): string {
|
|
383
|
+
return n.replace(/[^a-zA-Z0-9_-]/g, "-")
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function errMsg(e: unknown): string {
|
|
387
|
+
if (e instanceof Error) return e.message
|
|
388
|
+
if (typeof e === "string") return e
|
|
389
|
+
try {
|
|
390
|
+
return JSON.stringify(e)
|
|
391
|
+
} catch {
|
|
392
|
+
return String(e)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export function fmtTokens(n: number | undefined): string {
|
|
2
|
+
if (!n) return "0 tok"
|
|
3
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M tok`
|
|
4
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k tok`
|
|
5
|
+
return `${Math.round(n)} tok`
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** compact token count for table cells: "58.1k", "1.2M", "512" */
|
|
9
|
+
export function fmtTok(n: number | undefined): string {
|
|
10
|
+
if (!n) return "0"
|
|
11
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
|
12
|
+
if (n >= 10_000) return `${Math.round(n / 1000)}k`
|
|
13
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
|
|
14
|
+
return String(Math.round(n))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** "$0.0421", "$1.23", "$0" */
|
|
18
|
+
export function fmtCost(n: number | undefined): string {
|
|
19
|
+
if (!n) return "$0"
|
|
20
|
+
if (n < 0.01) return `$${n.toFixed(4)}`
|
|
21
|
+
if (n < 1) return `$${n.toFixed(3)}`
|
|
22
|
+
return `$${n.toFixed(2)}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function fmtDuration(ms: number | undefined, maxMs?: number): string {
|
|
26
|
+
if (ms == null) return "—"
|
|
27
|
+
const t = maxMs != null ? Math.min(ms, maxMs) : ms
|
|
28
|
+
if (t < 1000) return "0s"
|
|
29
|
+
const s = Math.floor(t / 1000)
|
|
30
|
+
if (s < 60) return `${s}s`
|
|
31
|
+
const m = Math.floor(s / 60)
|
|
32
|
+
const rs = s % 60
|
|
33
|
+
if (m < 60) return rs ? `${m}m ${rs}s` : `${m}m`
|
|
34
|
+
const h = Math.floor(m / 60)
|
|
35
|
+
const rm = m % 60
|
|
36
|
+
return rm ? `${h}h ${rm}m` : `${h}h`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** elapsed for a still-running agent */
|
|
40
|
+
export function fmtElapsed(startedAt: number | undefined, endedAt: number | undefined, now: number): string {
|
|
41
|
+
return fmtDuration(endedAt != null ? endedAt - (startedAt ?? endedAt) : startedAt ? now - startedAt : undefined)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** "just now", "3m ago", "2h ago", "yesterday", "3d ago" */
|
|
45
|
+
export function fmtAgo(ts: number | undefined, now: number): string {
|
|
46
|
+
if (!ts) return "—"
|
|
47
|
+
const s = Math.max(0, Math.floor((now - ts) / 1000))
|
|
48
|
+
if (s < 45) return "just now"
|
|
49
|
+
const m = Math.floor(s / 60)
|
|
50
|
+
if (m < 60) return `${m}m ago`
|
|
51
|
+
const h = Math.floor(m / 60)
|
|
52
|
+
if (h < 24) return `${h}h ago`
|
|
53
|
+
const d = Math.floor(h / 24)
|
|
54
|
+
if (d === 1) return "yesterday"
|
|
55
|
+
return `${d}d ago`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function fmtClock(ts: number | undefined): string {
|
|
59
|
+
if (!ts) return "—"
|
|
60
|
+
const d = new Date(ts)
|
|
61
|
+
const p = (n: number) => String(n).padStart(2, "0")
|
|
62
|
+
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** "anthropic/claude-sonnet-4-6" -> "claude-sonnet-4-6"; keeps provider for unknown tiers */
|
|
66
|
+
export function shortModel(model: string | undefined): string {
|
|
67
|
+
if (!model) return "default"
|
|
68
|
+
const i = model.indexOf("/")
|
|
69
|
+
return i >= 0 ? model.slice(i + 1) : model
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** clamp a string to n chars with an ellipsis */
|
|
73
|
+
export function clip(s: string, n: number): string {
|
|
74
|
+
if (n <= 0) return ""
|
|
75
|
+
if (s.length <= n) return s
|
|
76
|
+
if (n === 1) return "…"
|
|
77
|
+
return s.slice(0, n - 1) + "…"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** clip then pad to exactly n chars (left-aligned) */
|
|
81
|
+
export function cell(s: string, n: number): string {
|
|
82
|
+
return clip(s, n).padEnd(n)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** clip then pad to exactly n chars (right-aligned) */
|
|
86
|
+
export function cellR(s: string, n: number): string {
|
|
87
|
+
return clip(s, n).padStart(n)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** collapse whitespace/newlines into one line */
|
|
91
|
+
export function oneLine(s: string | undefined): string {
|
|
92
|
+
return String(s ?? "").replace(/\s+/g, " ").trim()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** word-wrap text to a column width, preserving explicit newlines */
|
|
96
|
+
export function wrapWords(text: string | undefined, width: number): string[] {
|
|
97
|
+
const w = Math.max(8, width | 0)
|
|
98
|
+
const out: string[] = []
|
|
99
|
+
for (const raw of String(text ?? "").replace(/\r/g, "").split("\n")) {
|
|
100
|
+
const line = raw.replace(/\t/g, " ")
|
|
101
|
+
if (line.length <= w) {
|
|
102
|
+
out.push(line)
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
let cur = ""
|
|
106
|
+
for (const word of line.split(" ")) {
|
|
107
|
+
if (word.length > w) {
|
|
108
|
+
if (cur) out.push(cur)
|
|
109
|
+
cur = ""
|
|
110
|
+
for (let i = 0; i < word.length; i += w) out.push(word.slice(i, i + w))
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
if (!cur) cur = word
|
|
114
|
+
else if (cur.length + 1 + word.length <= w) cur += " " + word
|
|
115
|
+
else {
|
|
116
|
+
out.push(cur)
|
|
117
|
+
cur = word
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (cur) out.push(cur)
|
|
121
|
+
}
|
|
122
|
+
return out
|
|
123
|
+
}
|