@op1/threads 0.1.8 → 0.2.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,88 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, mkdir, open, readdir, readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { z } from "zod";
6
+ import { parseWorkflow } from "./workflow-runtime";
7
+
8
+ const Name = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$/)
9
+ .refine((name) => name !== "run" && name !== "refresh", "This name is reserved for a workflow command");
10
+ export const SavedWorkflow = z.object({
11
+ name: Name,
12
+ description: z.string(),
13
+ path: z.string(),
14
+ scope: z.enum(["project", "user"]),
15
+ });
16
+
17
+ async function present(path: string) {
18
+ return lstat(path).catch((error: unknown) => {
19
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
20
+ throw error;
21
+ });
22
+ }
23
+
24
+ export function savedWorkflows(directory: string, canonical: string) {
25
+ const user = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "opencode", "workflows");
26
+ const project = join(directory, ".opencode", "workflows");
27
+ const roots = [
28
+ { directory: project, scope: "project" as const },
29
+ ...(resolve(canonical) === resolve(directory) ? [] : [{ directory: join(canonical, ".opencode", "workflows"), scope: "project" as const }]),
30
+ { directory: user, scope: "user" as const },
31
+ ];
32
+ return {
33
+ async list() {
34
+ const found = new Map<string, z.infer<typeof SavedWorkflow>>();
35
+ for (const root of roots) {
36
+ if (!await present(root.directory)) continue;
37
+ for (const file of await readdir(root.directory)) {
38
+ if (!file.endsWith(".js")) continue;
39
+ const name = file.slice(0, -3);
40
+ if (!Name.safeParse(name).success || found.has(name)) continue;
41
+ const path = join(root.directory, file);
42
+ const info = await present(path);
43
+ if (!info?.isFile() || info.isSymbolicLink()) continue;
44
+ const script = await readFile(path, "utf8");
45
+ let description: string;
46
+ try {
47
+ description = parseWorkflow(script).meta.description;
48
+ } catch (error) {
49
+ description = `Invalid workflow: ${String(error)}`;
50
+ }
51
+ found.set(name, { name, description, path, scope: root.scope });
52
+ }
53
+ }
54
+ return [...found.values()];
55
+ },
56
+ async load(name: string) {
57
+ Name.parse(name);
58
+ const item = (await this.list()).find((item) => item.name === name);
59
+ if (!item) throw new Error(`Saved workflow "${name}" not found`);
60
+ const file = await open(item.path, constants.O_RDONLY | constants.O_NOFOLLOW);
61
+ try {
62
+ return await file.readFile("utf8");
63
+ } finally {
64
+ await file.close();
65
+ }
66
+ },
67
+ async save(name: string, script: string, scope: "project" | "user") {
68
+ Name.parse(name);
69
+ parseWorkflow(script);
70
+ const root = scope === "project" ? project : user;
71
+ if (scope === "project") {
72
+ for (const path of [dirname(root), root]) {
73
+ if ((await present(path))?.isSymbolicLink()) throw new Error(`Cannot save workflows through symlink: ${path}`);
74
+ }
75
+ }
76
+ await mkdir(root, { recursive: true, mode: 0o700 });
77
+ const path = join(root, `${name}.js`);
78
+ const file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
79
+ try {
80
+ await file.writeFile(script, "utf8");
81
+ await file.sync();
82
+ } finally {
83
+ await file.close();
84
+ }
85
+ return { path };
86
+ },
87
+ };
88
+ }
@@ -0,0 +1,103 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { Plugin } from "@opencode/plugin";
3
+ import { serialized } from "./threads";
4
+ import { Json, WORKFLOW_CONTROL_HEADROOM, WORKFLOW_DIAGNOSTIC_JSON_BYTES, WORKFLOW_SETTLEMENT_DIAGNOSTIC_JSON_BYTES, WorkflowRun } from "./workflow-types";
5
+
6
+ export function workflowHash(value: unknown): string {
7
+ const json = Json.parse(value);
8
+ const canonical = (item: Json): string => {
9
+ if (Array.isArray(item)) return `[${item.map(canonical).join(",")}]`;
10
+ if (item !== null && typeof item === "object") {
11
+ return `{${Object.keys(item).sort().map((key) => `${JSON.stringify(key)}:${canonical(item[key])}`).join(",")}}`;
12
+ }
13
+ return JSON.stringify(item);
14
+ };
15
+ return createHash("sha256").update(canonical(json)).digest("hex");
16
+ }
17
+
18
+ export function workflowStore(storage: Plugin.Context["storage"], options: {
19
+ onDiagnostic?: (issue: { id: string; ownerID: string; message: string }) => Promise<void>;
20
+ } = {}) {
21
+ const journalLimit = 16 * 1024 * 1024;
22
+ const controlHeadroom = (run: WorkflowRun) => {
23
+ const settled = new Set(run.settlements?.filter((item) => item.kind === "agent").map((item) => item.key));
24
+ const pending = run.steps.reduce((bytes, step) => {
25
+ if (step.status === "completed") return bytes;
26
+ const settlementBytes = settled.has(step.key) ? 0
27
+ : Buffer.byteLength(JSON.stringify({ kind: "agent", key: step.key, outcome: "failure", error: "" }), "utf8")
28
+ + WORKFLOW_SETTLEMENT_DIAGNOSTIC_JSON_BYTES + 1;
29
+ return bytes + settlementBytes + (step.status === "failed" ? 0 : WORKFLOW_DIAGNOSTIC_JSON_BYTES + 256);
30
+ }, 0);
31
+ return Math.max(WORKFLOW_CONTROL_HEADROOM, WORKFLOW_DIAGNOSTIC_JSON_BYTES + 256 + pending);
32
+ };
33
+ const key = (id: string) => `workflows/runs/${id}`;
34
+ async function persist(run: WorkflowRun, kind: "payload" | "control") {
35
+ const value = structuredClone(Json.parse(run)) as Record<string, Json>;
36
+ const limits = value.limits as Record<string, Json>;
37
+ if (limits.concurrency === 3) delete limits.concurrency;
38
+ if (limits.maxAgents === 4) delete limits.maxAgents;
39
+ if (limits.agentTimeoutMs === 1_800_000) delete limits.agentTimeoutMs;
40
+ if (limits.timeoutMs === 86_400_000) delete limits.timeoutMs;
41
+ const limit = journalLimit - (kind === "payload" ? controlHeadroom(run) : 0);
42
+ if (Buffer.byteLength(JSON.stringify(value), "utf8") > limit) {
43
+ if (kind === "payload") {
44
+ throw new Error("Workflow payload exceeds 16 MiB journal capacity after required control headroom is reserved");
45
+ }
46
+ throw new Error("Workflow control update exceeds the 16 MiB journal hard limit and cannot be persisted without discarding durable data");
47
+ }
48
+ await storage.set(key(run.id), value);
49
+ }
50
+ async function get(id: string) {
51
+ const raw = await storage.get(key(id));
52
+ if (raw === undefined) throw new Error(`Workflow ${id} not found`);
53
+ const parsed = WorkflowRun.safeParse(raw);
54
+ if (!parsed.success) {
55
+ throw new Error(`Workflow ${id} has a corrupt or unsupported journal record; its raw data was preserved at ${key(id)}: ${parsed.error.issues[0]?.message ?? "validation failed"}. Inspect the retained record or start a new workflow run key.`);
56
+ }
57
+ return parsed.data;
58
+ }
59
+ return {
60
+ get,
61
+ async create(run: WorkflowRun) {
62
+ return serialized(`workflow-store:${run.id}`, async () => {
63
+ const existing = await storage.get(key(run.id));
64
+ if (existing !== undefined) {
65
+ const previous = await get(run.id);
66
+ if (previous.fingerprint !== run.fingerprint) throw new Error("This workflow key belongs to a different request");
67
+ return previous;
68
+ }
69
+ await persist(run, "payload");
70
+ return run;
71
+ });
72
+ },
73
+ async update(id: string, update: (run: WorkflowRun) => void, kind: "payload" | "control" = "payload") {
74
+ return serialized(`workflow-store:${id}`, async () => {
75
+ const run = await get(id);
76
+ update(run);
77
+ run.updated = Date.now();
78
+ const next = WorkflowRun.parse(run);
79
+ await persist(next, kind);
80
+ return next;
81
+ });
82
+ },
83
+ async list(ownerID: string) {
84
+ const runs: WorkflowRun[] = [];
85
+ let after: string | undefined;
86
+ do {
87
+ const page = await storage.scan({ prefix: "workflows/runs/", after, limit: 100 });
88
+ for (const entry of page.entries) {
89
+ const raw = entry.value;
90
+ if (typeof raw !== "object" || raw === null || !("ownerID" in raw) || raw.ownerID !== ownerID) continue;
91
+ const parsed = WorkflowRun.safeParse(raw);
92
+ if (parsed.success) runs.push(parsed.data);
93
+ else await options.onDiagnostic?.({
94
+ id: entry.key.slice("workflows/runs/".length), ownerID,
95
+ message: `Corrupt or unsupported journal record: ${parsed.error.issues[0]?.message ?? "validation failed"}`,
96
+ }).catch(() => undefined);
97
+ }
98
+ after = page.next;
99
+ } while (after);
100
+ return runs.sort((a, b) => b.created - a.created);
101
+ },
102
+ };
103
+ }
@@ -0,0 +1,144 @@
1
+ import { z } from "zod";
2
+ import { Report } from "./rpc";
3
+
4
+ export const WORKFLOW_DIAGNOSTIC_JSON_BYTES = 4_096;
5
+ export const WORKFLOW_SETTLEMENT_DIAGNOSTIC_JSON_BYTES = 512;
6
+ export const WORKFLOW_CONTROL_HEADROOM = 64 * 1024;
7
+
8
+ export const Json = z.json();
9
+ export type Json = z.infer<typeof Json>;
10
+ export const WorkflowModel = z.object({
11
+ providerID: z.string(),
12
+ id: z.string(),
13
+ variant: z.string().optional(),
14
+ });
15
+ export const WorkflowLimits = z.object({
16
+ concurrency: z.number().int().min(1).max(8).default(3),
17
+ maxAgents: z.number().int().min(1).max(1000).default(4),
18
+ agentTimeoutMs: z.number().int().min(1000).max(604800000).default(1800000),
19
+ timeoutMs: z.number().int().min(1000).max(604800000).default(86400000),
20
+ tokenBudget: z.number().int().positive().optional(),
21
+ });
22
+ export const WorkflowStart = WorkflowLimits.extend({
23
+ key: z.string().min(1).max(120),
24
+ script: z.string().min(1).max(200000).optional(),
25
+ name: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$/).optional(),
26
+ args: Json.default(null),
27
+ }).strict().refine((value) => Boolean(value.script) !== Boolean(value.name), {
28
+ message: "Supply exactly one of script or saved workflow name",
29
+ });
30
+ export type WorkflowStart = z.infer<typeof WorkflowStart>;
31
+
32
+ export const WorkflowAgentInput = z.object({
33
+ key: z.string().min(1).max(120),
34
+ prompt: z.string().min(1).max(100000),
35
+ agent: z.string().min(1),
36
+ label: z.string().min(1).max(160).optional(),
37
+ phase: z.string().min(1).max(160).optional(),
38
+ schema: z.record(z.string(), Json).optional(),
39
+ access: z.enum(["read", "write"]).default("read"),
40
+ isolation: z.enum(["shared", "worktree"]).default("shared"),
41
+ directory: z.string().min(1).optional(),
42
+ timeoutMs: z.number().int().min(1000).max(604800000).optional(),
43
+ }).strict();
44
+ export type WorkflowAgentInput = z.infer<typeof WorkflowAgentInput>;
45
+ export const WorkflowResult = Report.extend({ result: Json }).strict();
46
+ export type WorkflowResult = z.infer<typeof WorkflowResult>;
47
+ export const WorkflowUsage = z.object({
48
+ tokens: z.number().nonnegative(),
49
+ cost: z.number().nonnegative(),
50
+ measured: z.boolean(),
51
+ });
52
+
53
+ const StepBase = z.object({
54
+ key: z.string(),
55
+ fingerprint: z.string(),
56
+ index: z.number().int().nonnegative(),
57
+ input: WorkflowAgentInput,
58
+ workerID: z.string(),
59
+ spawnKey: z.string(),
60
+ created: z.number(),
61
+ phase: z.string(),
62
+ directory: z.string(),
63
+ model: WorkflowModel,
64
+ profileFingerprint: z.string(),
65
+ });
66
+ export const WorkflowStep = z.discriminatedUnion("status", [
67
+ StepBase.extend({ status: z.literal("prepared") }),
68
+ StepBase.extend({ status: z.literal("running"), usage: WorkflowUsage.optional() }),
69
+ StepBase.extend({
70
+ status: z.literal("completed"),
71
+ completed: z.number(),
72
+ report: WorkflowResult,
73
+ usage: WorkflowUsage,
74
+ }),
75
+ StepBase.extend({
76
+ status: z.literal("failed"),
77
+ error: z.string(),
78
+ retryable: z.boolean(),
79
+ usage: WorkflowUsage.optional(),
80
+ }),
81
+ ]);
82
+ export type WorkflowStep = z.infer<typeof WorkflowStep>;
83
+ export const WorkflowCheckpoint = z.object({
84
+ key: z.string(),
85
+ prompt: z.string(),
86
+ response: Json.optional(),
87
+ });
88
+ export const WorkflowSettlement = z.discriminatedUnion("kind", [
89
+ z.object({ kind: z.literal("agent"), key: z.string(), outcome: z.enum(["success", "failure"]), error: z.string().optional() }),
90
+ z.object({ kind: z.literal("checkpoint"), key: z.string(), response: Json }),
91
+ ]);
92
+ export type WorkflowSettlement = z.infer<typeof WorkflowSettlement>;
93
+ export const WorkflowRun = z.object({
94
+ version: z.literal(1),
95
+ id: z.string(),
96
+ key: z.string(),
97
+ ownerID: z.string(),
98
+ callerAgent: z.string(),
99
+ model: WorkflowModel,
100
+ projectID: z.string(),
101
+ directory: z.string(),
102
+ name: z.string(),
103
+ description: z.string(),
104
+ script: z.string(),
105
+ args: Json,
106
+ fingerprint: z.string(),
107
+ limits: WorkflowLimits,
108
+ status: z.enum(["running", "pausing", "paused", "stopping", "stopped", "waiting", "interrupted", "failed", "completed"]),
109
+ created: z.number(),
110
+ updated: z.number(),
111
+ phase: z.string(),
112
+ steps: z.array(WorkflowStep),
113
+ logs: z.array(z.object({ time: z.number(), text: z.string() })),
114
+ checkpoints: z.array(WorkflowCheckpoint),
115
+ settlements: z.array(WorkflowSettlement).optional(),
116
+ result: Json.optional(),
117
+ error: z.string().optional(),
118
+ deliveryID: z.string(),
119
+ delivered: z.boolean(),
120
+ });
121
+ export type WorkflowRun = z.infer<typeof WorkflowRun>;
122
+ export const WorkflowSummary = WorkflowRun.omit({ script: true, args: true, result: true, steps: true, logs: true, checkpoints: true, settlements: true, fingerprint: true, callerAgent: true, deliveryID: true, delivered: true }).extend({
123
+ counts: z.object({ completed: z.number(), running: z.number(), failed: z.number(), total: z.number() }),
124
+ usage: WorkflowUsage,
125
+ });
126
+ export type WorkflowSummary = z.infer<typeof WorkflowSummary>;
127
+ export function workflowSummary(run: WorkflowRun): WorkflowSummary {
128
+ const completed = run.steps.filter((step) => step.status === "completed");
129
+ const accounted = run.steps.flatMap((step) => "usage" in step && step.usage !== undefined ? [step.usage] : []);
130
+ return WorkflowSummary.parse({
131
+ ...run,
132
+ counts: {
133
+ completed: completed.length,
134
+ running: run.steps.filter((step) => step.status === "running" || step.status === "prepared").length,
135
+ failed: run.steps.filter((step) => step.status === "failed").length,
136
+ total: run.steps.length,
137
+ },
138
+ usage: {
139
+ tokens: accounted.reduce((total, item) => total + item.tokens, 0),
140
+ cost: accounted.reduce((total, item) => total + item.cost, 0),
141
+ measured: run.steps.length > 0 && run.steps.every((step) => step.status === "prepared" || ("usage" in step && step.usage?.measured === true)),
142
+ },
143
+ });
144
+ }
@@ -0,0 +1,251 @@
1
+ import type { Plugin } from "@opencode/plugin/tui";
2
+ import { createEffect, createSignal, For, Show } from "solid-js";
3
+ import type { Accessor } from "solid-js";
4
+ import { WorkflowsRpc } from "./workflow-rpc";
5
+ import { Json } from "./workflow-types";
6
+ import type { WorkflowRun, WorkflowSummary } from "./workflow-types";
7
+
8
+ export function workflowUI(ctx: Plugin.Context) {
9
+ const rpc = ctx.client.rpc(WorkflowsRpc);
10
+ const [runs, setRuns] = createSignal<WorkflowSummary[]>([]);
11
+ const [selected, setSelected] = createSignal<WorkflowRun>();
12
+ const [stepIndex, setStepIndex] = createSignal(0);
13
+ const [error, setError] = createSignal("");
14
+ const abort = new AbortController();
15
+ let owner = "";
16
+ let ownerGeneration = 0;
17
+ let refreshInFlight: Promise<RefreshResult | undefined> | undefined;
18
+ type OwnerContext = { ownerID: string; generation: number };
19
+ type RefreshResult = { context: OwnerContext; runs: WorkflowSummary[] };
20
+ const location = () => ctx.location ?? ctx.data.location.default();
21
+ function ownerID() {
22
+ const route = ctx.ui.router.current();
23
+ if (route.type !== "session") return undefined;
24
+ const session = ctx.data.session.get(route.sessionID);
25
+ const link = session?.metadata?.opThreads;
26
+ return link && typeof link === "object" && "coordinatorID" in link && typeof link.coordinatorID === "string"
27
+ ? link.coordinatorID
28
+ : route.sessionID;
29
+ }
30
+ function synchronizeOwner(): OwnerContext | undefined {
31
+ const current = ownerID() ?? "";
32
+ if (current !== owner) {
33
+ owner = current;
34
+ ownerGeneration += 1;
35
+ setRuns([]);
36
+ setSelected(undefined);
37
+ }
38
+ return current ? { ownerID: current, generation: ownerGeneration } : undefined;
39
+ }
40
+ function isCurrent(context: OwnerContext) {
41
+ const current = synchronizeOwner();
42
+ return !!current && current.ownerID === context.ownerID && current.generation === context.generation && !abort.signal.aborted;
43
+ }
44
+ function requestRefresh(context: OwnerContext) {
45
+ let request!: Promise<RefreshResult | undefined>;
46
+ request = (async () => {
47
+ try {
48
+ const snapshot = await rpc.snapshot({ ownerID: context.ownerID }, { location: location(), signal: abort.signal });
49
+ if (!isCurrent(context)) return;
50
+ setRuns(snapshot.runs);
51
+ const id = selected()?.id;
52
+ if (id) {
53
+ const run = await rpc.inspect({ ownerID: context.ownerID, runID: id }, { location: location(), signal: abort.signal });
54
+ if (!isCurrent(context)) return;
55
+ if (selected()?.id === id) setSelected(run);
56
+ }
57
+ setError("");
58
+ return { context, runs: snapshot.runs };
59
+ } catch (cause) {
60
+ if (isCurrent(context)) setError(String(cause));
61
+ } finally {
62
+ if (refreshInFlight === request) refreshInFlight = undefined;
63
+ }
64
+ })();
65
+ refreshInFlight = request;
66
+ return request;
67
+ }
68
+ function refreshInBackground() {
69
+ const context = synchronizeOwner();
70
+ if (!context || refreshInFlight || abort.signal.aborted) return;
71
+ void requestRefresh(context);
72
+ }
73
+ async function refreshFresh() {
74
+ synchronizeOwner();
75
+ while (refreshInFlight) await refreshInFlight;
76
+ const context = synchronizeOwner();
77
+ if (!context || abort.signal.aborted) return;
78
+ return requestRefresh(context);
79
+ }
80
+ async function openRun(id: string, expected = synchronizeOwner()) {
81
+ if (!expected || !isCurrent(expected)) return;
82
+ try {
83
+ const run = await rpc.inspect({ ownerID: expected.ownerID, runID: id }, { location: location(), signal: abort.signal });
84
+ if (!isCurrent(expected)) return;
85
+ setSelected(run);
86
+ setStepIndex(0);
87
+ ctx.ui.panel.open("threads.workflows");
88
+ } catch (cause) {
89
+ ctx.ui.toast.show({ message: String(cause), variant: "error" });
90
+ }
91
+ }
92
+ async function choose() {
93
+ const fresh = await refreshFresh();
94
+ if (!fresh || !isCurrent(fresh.context)) return;
95
+ if (!fresh.runs.length) {
96
+ ctx.ui.toast.show({ message: "No workflows yet. Use /workflow-run to describe a task.", variant: "info" });
97
+ return;
98
+ }
99
+ const id = await ctx.ui.dialog.select({
100
+ title: "Dynamic workflows",
101
+ options: fresh.runs.map((run) => ({
102
+ title: `${run.name} · ${run.status}`,
103
+ description: `${run.counts.completed}/${run.counts.total} recorded steps · ${run.phase || "starting"} · ${run.usage.measured ? "" : "≥"}${run.usage.tokens} tokens`,
104
+ value: run.id,
105
+ })),
106
+ });
107
+ if (id && fresh.runs.some((run) => run.id === id) && isCurrent(fresh.context)) await openRun(id, fresh.context);
108
+ }
109
+ function canControl(action: "pause" | "resume" | "stop") {
110
+ const run = selected();
111
+ const status = run?.status;
112
+ if (!status || run.ownerID !== ownerID()) return false;
113
+ if (action === "pause") return status === "running";
114
+ if (action === "resume") return status === "paused" || status === "interrupted" || status === "waiting";
115
+ return status === "running" || status === "pausing" || status === "paused" || status === "interrupted" || status === "waiting";
116
+ }
117
+ async function control(action: "pause" | "resume" | "stop") {
118
+ const run = selected();
119
+ const context = synchronizeOwner();
120
+ if (!run || !context || run.ownerID !== context.ownerID || !canControl(action)) return;
121
+ try {
122
+ const checkpoint = run.checkpoints.find((item) => item.response === undefined);
123
+ let answer: Json | undefined;
124
+ if (action === "resume" && checkpoint) {
125
+ const text = await ctx.ui.dialog.prompt({ title: checkpoint.prompt, placeholder: "JSON response, for example true or a quoted string" });
126
+ if (text === undefined) return;
127
+ if (!isCurrent(context) || selected()?.id !== run.id) return;
128
+ answer = Json.parse(JSON.parse(text));
129
+ }
130
+ const result = await rpc.control({
131
+ ownerID: run.ownerID,
132
+ runID: run.id,
133
+ action,
134
+ ...(checkpoint && action === "resume" ? { checkpointKey: checkpoint.key, response: answer } : {}),
135
+ }, { location: location(), signal: abort.signal });
136
+ if (!isCurrent(context) || selected()?.id !== run.id) return;
137
+ setSelected(result);
138
+ await refreshFresh();
139
+ } catch (cause) {
140
+ ctx.ui.toast.show({ message: String(cause), variant: "error" });
141
+ }
142
+ }
143
+ async function save() {
144
+ const run = selected();
145
+ if (!run) return;
146
+ const name = await ctx.ui.dialog.prompt({ title: "Save workflow", placeholder: run.name });
147
+ if (!name) return;
148
+ const scope = await ctx.ui.dialog.select({ title: "Save location", options: [
149
+ { title: "Project", value: "project" as const },
150
+ { title: "User", value: "user" as const },
151
+ ] });
152
+ if (!scope) return;
153
+ try {
154
+ const output = await rpc.save({ ownerID: run.ownerID, runID: run.id, name, scope }, { location: location(), signal: abort.signal });
155
+ ctx.ui.toast.show({ message: `Saved ${ctx.ui.format.path(output.path)}`, variant: "success" });
156
+ } catch (cause) {
157
+ ctx.ui.toast.show({ message: String(cause), variant: "error" });
158
+ }
159
+ }
160
+ const removePanel = ctx.ui.slot({
161
+ append: "session.panel",
162
+ render: (panel) => {
163
+ const moveStep = (delta: number) => {
164
+ const length = selected()?.steps.length ?? 0;
165
+ if (length) setStepIndex((index) => (index + delta + length) % length);
166
+ };
167
+ const openStep = () => {
168
+ const step = selected()?.steps[stepIndex()];
169
+ if (!step || step.status === "prepared") return;
170
+ panel.close();
171
+ ctx.ui.router.navigate({ type: "session", sessionID: step.workerID });
172
+ };
173
+ ctx.keymap.layer(() => ({ commands: panel.name === "threads.workflows" ? [
174
+ { id: "workflows.pause", bind: "p", title: "Pause workflow", enabled: () => canControl("pause"), run: () => control("pause") },
175
+ { id: "workflows.resume", bind: "r", title: "Resume workflow", enabled: () => canControl("resume"), run: () => control("resume") },
176
+ { id: "workflows.stop", bind: "x", title: "Stop workflow", enabled: () => canControl("stop"), run: () => control("stop") },
177
+ { id: "workflows.save", bind: "s", title: "Save workflow", run: save },
178
+ { id: "workflows.previous-step", bind: "up", title: "Previous workflow step", run: () => moveStep(-1) },
179
+ { id: "workflows.next-step", bind: "down", title: "Next workflow step", run: () => moveStep(1) },
180
+ { id: "workflows.open-step", bind: "return", title: "Open workflow worker", run: openStep },
181
+ { id: "workflows.fullscreen", bind: "f", title: "Toggle workflow fullscreen", run: panel.toggleFullscreen },
182
+ { id: "workflows.close", bind: "escape", run: panel.close },
183
+ ] : [] }));
184
+ return <Show when={panel.name === "threads.workflows"}>
185
+ <scrollbox flexGrow={1} padding={1}>
186
+ <text><b>Dynamic workflows</b></text>
187
+ <Show when={selected()}>{(run: Accessor<WorkflowRun>) => <box flexDirection="column" gap={1}>
188
+ <text><b>{run().name}</b>{` · ${run().status}`}</text>
189
+ <text>{run().description}</text>
190
+ <text>{`Run: ${run().id}`}</text>
191
+ <text>{`Phase: ${run().phase || "starting"}`}</text>
192
+ <text>{[
193
+ ...(canControl("pause") ? ["p pause"] : []),
194
+ ...(canControl("resume") ? ["r resume"] : []),
195
+ ...(canControl("stop") ? ["x stop"] : []),
196
+ "s save", "f fullscreen", "Esc close",
197
+ ].join(" · ")}</text>
198
+ <Show when={run().steps.length > 0}><text>↑/↓ select step · Enter open worker</text></Show>
199
+ <Show when={run().error}><text>{`Error: ${run().error}`}</text></Show>
200
+ <For each={run().steps}>{(step, index) => <box flexDirection="column" border={true} padding={1} onMouseUp={() => { setStepIndex(index()); openStep(); }}>
201
+ <text>{index() === stepIndex() ? "› " : " "}<b>{step.input.label ?? step.key}</b>{` · ${step.status}`}</text>
202
+ <text>{`${step.phase} · ${step.input.agent} · ${step.model.providerID}/${step.model.id}`}</text>
203
+ <text>{ctx.ui.format.path(step.directory)}</text>
204
+ <Show when={step.status === "completed" && step}>{(done: Accessor<Extract<WorkflowRun["steps"][number], { status: "completed" }>>) => <>
205
+ <text>{`${done().report.verdict}: ${done().report.summary}`}</text>
206
+ <text>{`${done().usage.measured ? "" : "unmeasured · "}${done().usage.tokens} tokens · $${done().usage.cost.toFixed(4)}`}</text>
207
+ <For each={done().report.evidence}>{(evidence) => <text>{evidence}</text>}</For>
208
+ </>}</Show>
209
+ <Show when={step.status === "failed" && step}>{(failed: Accessor<Extract<WorkflowRun["steps"][number], { status: "failed" }>>) => <text>{failed().error}</text>}</Show>
210
+ <text>{step.status === "prepared" ? "Waiting for worker admission" : "Click or select and press Enter to open worker"}</text>
211
+ </box>}</For>
212
+ <For each={run().checkpoints}>{(checkpoint) => <text>{`${checkpoint.response === undefined ? "Waiting" : "Answered"}: ${checkpoint.prompt}`}</text>}</For>
213
+ <For each={run().logs.slice(-30)}>{(entry) => <text>{entry.text}</text>}</For>
214
+ <Show when={run().result !== undefined}><text>{`Result:\n${JSON.stringify(run().result, null, 2)}`}</text></Show>
215
+ </box>}</Show>
216
+ <Show when={error()}><text>{error()}</text></Show>
217
+ </scrollbox>
218
+ </Show>;
219
+ },
220
+ });
221
+ const removeFooter = ctx.ui.slot({
222
+ append: "session.composer.top",
223
+ render: () => {
224
+ createEffect(() => { ownerID(); refreshInBackground(); });
225
+ return <Show when={runs().find((run) => ["running", "pausing", "waiting"].includes(run.status))}>{(run: Accessor<WorkflowSummary>) =>
226
+ <box onMouseUp={() => void openRun(run().id)}><text>{`Workflow ${run().name}: ${run().status} · ${run().counts.completed}/${run().counts.total} recorded steps · /workflows`}</text></box>
227
+ }</Show>;
228
+ },
229
+ });
230
+ const removeCommands = ctx.ui.slot({
231
+ append: "app",
232
+ render: () => {
233
+ ctx.keymap.layer(() => ({ mode: "global", commands: [{
234
+ id: "workflows.open", title: "Open dynamic workflows", palette: true, slash: { name: "workflows" }, run: choose,
235
+ }] }));
236
+ return null;
237
+ },
238
+ });
239
+ const stopEvents = ctx.data.listen(({ details }) => {
240
+ if (details.type.startsWith("session.") || details.type.startsWith("rpc.workflows.")) refreshInBackground();
241
+ });
242
+ const timer = setInterval(refreshInBackground, 3000);
243
+ return () => {
244
+ abort.abort();
245
+ clearInterval(timer);
246
+ stopEvents();
247
+ removePanel();
248
+ removeFooter();
249
+ removeCommands();
250
+ };
251
+ }