@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,210 @@
1
+ import { createHash } from "node:crypto";
2
+ import { realpath } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join, relative } from "node:path";
4
+ import type { Plugin } from "@opencode/plugin";
5
+ import { Session } from "@opencode/schema/session";
6
+ import type { threads } from "./threads";
7
+ import type { WorkflowAgentInput, WorkflowRun } from "./workflow-types";
8
+
9
+ type Threads = ReturnType<typeof threads>;
10
+ type Waiter = {
11
+ limit: number;
12
+ signal: AbortSignal;
13
+ resolve: (release: () => void) => void;
14
+ reject: (reason: unknown) => void;
15
+ abort: () => void;
16
+ };
17
+ type Semaphore = { active: number; queue: Waiter[] };
18
+
19
+ const globalState = globalThis as typeof globalThis & {
20
+ __opWorkflowOwnerSlots?: Map<string, Semaphore>;
21
+ __opWorkflowRunSlots?: Map<string, Semaphore>;
22
+ };
23
+ const ownerSlots = globalState.__opWorkflowOwnerSlots ??= new Map();
24
+ const runSlots = globalState.__opWorkflowRunSlots ??= new Map();
25
+
26
+ async function acquire(
27
+ states: Map<string, Semaphore>,
28
+ key: string,
29
+ limit: number,
30
+ signal: AbortSignal,
31
+ ) {
32
+ if (signal.aborted) throw signal.reason ?? new Error("Workflow cancelled");
33
+ const state = states.get(key) ?? { active: 0, queue: [] };
34
+ states.set(key, state);
35
+ let released = false;
36
+ const release = () => {
37
+ if (released) return;
38
+ released = true;
39
+ state.active--;
40
+ for (;;) {
41
+ const waiter = state.queue[0];
42
+ if (!waiter || state.active >= waiter.limit) break;
43
+ state.queue.shift();
44
+ waiter.signal.removeEventListener("abort", waiter.abort);
45
+ if (waiter.signal.aborted) {
46
+ waiter.reject(waiter.signal.reason ?? new Error("Workflow cancelled"));
47
+ continue;
48
+ }
49
+ state.active++;
50
+ waiter.resolve(releaseFor(states, key, state));
51
+ }
52
+ if (state.active === 0 && state.queue.length === 0) states.delete(key);
53
+ };
54
+ if (state.active < limit) {
55
+ state.active++;
56
+ return release;
57
+ }
58
+ return new Promise<() => void>((resolve, reject) => {
59
+ const waiter: Waiter = {
60
+ limit,
61
+ signal,
62
+ resolve,
63
+ reject,
64
+ abort: () => {
65
+ const index = state.queue.indexOf(waiter);
66
+ if (index >= 0) state.queue.splice(index, 1);
67
+ reject(signal.reason ?? new Error("Workflow cancelled"));
68
+ if (state.active === 0 && state.queue.length === 0) states.delete(key);
69
+ },
70
+ };
71
+ state.queue.push(waiter);
72
+ signal.addEventListener("abort", waiter.abort, { once: true });
73
+ });
74
+ }
75
+
76
+ function releaseFor(states: Map<string, Semaphore>, key: string, state: Semaphore) {
77
+ let released = false;
78
+ return () => {
79
+ if (released) return;
80
+ released = true;
81
+ state.active--;
82
+ for (;;) {
83
+ const waiter = state.queue[0];
84
+ if (!waiter || state.active >= waiter.limit) break;
85
+ state.queue.shift();
86
+ waiter.signal.removeEventListener("abort", waiter.abort);
87
+ if (waiter.signal.aborted) {
88
+ waiter.reject(waiter.signal.reason ?? new Error("Workflow cancelled"));
89
+ continue;
90
+ }
91
+ state.active++;
92
+ waiter.resolve(releaseFor(states, key, state));
93
+ }
94
+ if (state.active === 0 && state.queue.length === 0) states.delete(key);
95
+ };
96
+ }
97
+
98
+ export async function withWorkflowSlot<T>(
99
+ ownerID: string,
100
+ ownerLimit: number,
101
+ runID: string,
102
+ runLimit: number,
103
+ signal: AbortSignal,
104
+ run: () => Promise<T>,
105
+ ): Promise<T> {
106
+ const releaseRun = await acquire(runSlots, runID, runLimit, signal);
107
+ try {
108
+ const releaseOwner = await acquire(ownerSlots, ownerID, ownerLimit, signal);
109
+ try {
110
+ return await run();
111
+ } finally {
112
+ releaseOwner();
113
+ }
114
+ } finally {
115
+ releaseRun();
116
+ }
117
+ }
118
+
119
+ function safePrefix(value: string) {
120
+ return value.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 28);
121
+ }
122
+
123
+ export function workflowDirectoryPlan(
124
+ run: WorkflowRun,
125
+ input: WorkflowAgentInput,
126
+ namespacedKey: string,
127
+ ) {
128
+ const source = input.directory ?? run.directory;
129
+ if (input.isolation === "shared") return { source, directory: source };
130
+ if (input.access !== "write") {
131
+ throw new Error("Worktree isolation is only supported for explicit write steps");
132
+ }
133
+ const hash = createHash("sha256").update(namespacedKey).digest("hex").slice(0, 16);
134
+ const name = `workflow-${run.id.slice(-10)}-${safePrefix(namespacedKey)}-${hash}`;
135
+ const parent = join(dirname(source), ".opencode-workflows");
136
+ return { source, name, parent, directory: join(parent, name) };
137
+ }
138
+
139
+ function inside(path: string, root: string) {
140
+ const child = relative(root, path);
141
+ return child === "" || (!child.startsWith("..") && !isAbsolute(child));
142
+ }
143
+
144
+ export async function workflowSourceDirectory(
145
+ ctx: Pick<Plugin.Context, "worktree">,
146
+ run: WorkflowRun,
147
+ input: WorkflowAgentInput,
148
+ ) {
149
+ const source = await realpath(input.directory ?? run.directory);
150
+ const roots = [await realpath(run.directory)];
151
+ for (const entry of await ctx.worktree.list({ projectID: run.projectID })) {
152
+ try {
153
+ roots.push(await realpath(entry.directory));
154
+ } catch {
155
+ // Ignore stale inventory entries. They cannot authorize a real source directory.
156
+ }
157
+ }
158
+ if (!roots.some((root) => inside(source, root))) {
159
+ throw new Error("Workflow directory must be inside the owner project or one of its registered worktrees");
160
+ }
161
+ return source;
162
+ }
163
+
164
+ export async function workflowDirectory(
165
+ ctx: Pick<Plugin.Context, "worktree">,
166
+ run: WorkflowRun,
167
+ input: WorkflowAgentInput,
168
+ namespacedKey: string,
169
+ ): Promise<string> {
170
+ const plan = workflowDirectoryPlan(run, input, namespacedKey);
171
+ if (input.isolation === "shared") return plan.directory;
172
+ const inventory = await ctx.worktree.list({ projectID: run.projectID });
173
+ if (inventory.some((entry) => entry.directory === plan.directory)) return plan.directory;
174
+ try {
175
+ const created = await ctx.worktree.create({
176
+ projectID: run.projectID,
177
+ from: plan.source,
178
+ directory: plan.parent,
179
+ name: plan.name,
180
+ });
181
+ if (created.directory !== plan.directory) {
182
+ throw new Error(`Worktree strategy returned non-canonical directory ${created.directory}; expected ${plan.directory}`);
183
+ }
184
+ return created.directory;
185
+ } catch (error) {
186
+ const reconciled = (await ctx.worktree.list({ projectID: run.projectID }))
187
+ .find((entry) => entry.directory === plan.directory);
188
+ if (reconciled) return reconciled.directory;
189
+ throw error;
190
+ }
191
+ }
192
+
193
+ export function workflowTask(input: WorkflowAgentInput) {
194
+ const schema = input.schema === undefined
195
+ ? "No additional result schema was supplied; return a JSON value appropriate to the task."
196
+ : `The result field must validate against this JSON Schema:\n${JSON.stringify(input.schema)}`;
197
+ return `${input.prompt}\n\n${schema}`;
198
+ }
199
+
200
+ export async function interruptWorkflowWorkers(
201
+ workers: Threads,
202
+ ownerID: string,
203
+ run: WorkflowRun,
204
+ ) {
205
+ await Promise.allSettled(
206
+ run.steps
207
+ .filter((step) => step.status === "running" || step.status === "prepared")
208
+ .map((step) => workers.interrupt(ownerID, { workerID: Session.ID.make(step.workerID) })),
209
+ );
210
+ }
@@ -0,0 +1,180 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import { Skill } from "@opencode/plugin";
4
+ import type { Plugin } from "@opencode/plugin";
5
+ import type { SessionContext } from "@opencode/plugin/promise/session";
6
+ import { z } from "zod";
7
+ import { threads } from "./threads";
8
+ import { workflowEngine } from "./workflow-engine";
9
+ import { WorkflowControl, WorkflowsRpc } from "./workflow-rpc";
10
+ import { savedWorkflows, SavedWorkflow } from "./workflow-saved";
11
+ import { WorkflowLimits, WorkflowResult, WorkflowRun, WorkflowStart, WorkflowSummary, workflowSummary } from "./workflow-types";
12
+
13
+ export async function workflows(
14
+ ctx: Plugin.Context,
15
+ workers: ReturnType<typeof threads>,
16
+ models: Map<SessionContext["sessionID"], SessionContext["model"]>,
17
+ maxWorkers: number,
18
+ ) {
19
+ const concurrency = WorkflowLimits.shape.concurrency.parse(ctx.options.workflowConcurrency);
20
+ const maxAgents = WorkflowLimits.shape.maxAgents.parse(ctx.options.workflowMaxAgents);
21
+ const startInput = WorkflowStart.safeExtend({
22
+ concurrency: WorkflowLimits.shape.concurrency.default(concurrency),
23
+ maxAgents: WorkflowLimits.shape.maxAgents.default(maxAgents),
24
+ });
25
+ const saved = savedWorkflows(ctx.location.directory, ctx.location.project.canonical);
26
+ const engine = workflowEngine(ctx, workers, {
27
+ maxWorkers,
28
+ loadSaved: (name: string) => saved.load(name),
29
+ warmWorker: (sessionID: string) => ctx.session.command({ sessionID, name: "workflow-refresh", text: "" }),
30
+ });
31
+ const rpc = await ctx.rpc.register(WorkflowsRpc, {
32
+ snapshot: async ({ ownerID }) => ({ runs: (await engine.list(ownerID)).map(workflowSummary) }),
33
+ inspect: ({ ownerID, runID }) => engine.get(ownerID, runID),
34
+ control: async ({ ownerID, ...input }) => {
35
+ const run = await engine.control(ownerID, input);
36
+ await rpc.events.emit("updated", { ownerID, runID: run.id });
37
+ return run;
38
+ },
39
+ save: async ({ ownerID, runID, name, scope }) => {
40
+ const run = await engine.get(ownerID, runID);
41
+ const result = await saved.save(name, run.script, scope);
42
+ await refreshCommands();
43
+ return result;
44
+ },
45
+ });
46
+ await ctx.session.hook("prompt", (event) => engine.preparePrompt(event.sessionID, event.messageID));
47
+ await ctx.session.hook("context", (event) => engine.prepareContext(event.sessionID));
48
+ await ctx.tool.transform((editor) => {
49
+ editor.namespace({ name: "workflows", description: "Durable background JavaScript workflows using native OpenCode agents" });
50
+ editor.add({
51
+ name: "start",
52
+ description: "Start a dynamic JavaScript workflow in the background. Load workflow-authoring first. Supply script or a saved name, a stable retry key, and optional JSON args. The runner owns parallel agents, structured results, checkpoints, and resumable progress. Keep the same key and identical inputs for an exact retry. Configured role permissions apply to every agent. Returns immediately; inspect/control using the run ID.",
53
+ input: startInput,
54
+ output: WorkflowSummary,
55
+ options: { namespace: "workflows", codemode: false },
56
+ execute: async (input, tool) => {
57
+ const model = models.get(tool.sessionID);
58
+ if (!model) throw new Error("Workflow start requires a resolved session model");
59
+ const run = await engine.start(tool.sessionID, startInput.parse(input), { agent: tool.agent, model });
60
+ await rpc.events.emit("updated", { ownerID: tool.sessionID, runID: run.id });
61
+ const output = workflowSummary(run);
62
+ return { content: JSON.stringify(output), output };
63
+ },
64
+ });
65
+ editor.add({
66
+ name: "list",
67
+ description: "List your dynamic workflow runs and recorded progress. The final result and explicit worker evidence determine task success.",
68
+ input: z.object({}).strict(),
69
+ output: z.object({ runs: z.array(WorkflowSummary) }),
70
+ options: { namespace: "workflows", codemode: false },
71
+ execute: async (_input, tool) => {
72
+ const output = { runs: (await engine.list(tool.sessionID)).map(workflowSummary) };
73
+ return { content: JSON.stringify(output), output };
74
+ },
75
+ });
76
+ editor.add({
77
+ name: "inspect",
78
+ description: "Read a workflow's saved script, phases, step sessions, structured reports, usage, checkpoints, and final result.",
79
+ input: z.object({ runID: z.string().min(1) }).strict(),
80
+ output: WorkflowRun,
81
+ options: { namespace: "workflows", codemode: false },
82
+ execute: async (input, tool) => {
83
+ const output = await engine.get(tool.sessionID, input.runID);
84
+ return { content: JSON.stringify(output), output };
85
+ },
86
+ });
87
+ editor.add({
88
+ name: "control",
89
+ description: "Pause new workflow scheduling, stop active work, or explicitly resume the same recorded script. Resume a waiting checkpoint with checkpointKey and a JSON response. Only the owning session controls the run. Resume reconciles existing workers and recorded results.",
90
+ input: WorkflowControl,
91
+ output: WorkflowSummary,
92
+ options: { namespace: "workflows", codemode: false },
93
+ execute: async (input, tool) => {
94
+ const run = await engine.control(tool.sessionID, input);
95
+ await rpc.events.emit("updated", { ownerID: tool.sessionID, runID: run.id });
96
+ const output = workflowSummary(run);
97
+ return { content: JSON.stringify(output), output };
98
+ },
99
+ });
100
+ editor.add({
101
+ name: "result",
102
+ description: "Submit the assigned workflow step's final verdict, evidence, and JSON result. Only the original workflow worker can submit. The runner validates result against the step's schema before accepting it. Correct validation errors and retry. PASS requires completed verification, INCONCLUSIVE is not a pass.",
103
+ input: WorkflowResult,
104
+ output: z.object({ accepted: z.literal(true) }),
105
+ options: { namespace: "workflows", codemode: false },
106
+ execute: async (input, tool) => {
107
+ const output = await engine.result(tool.sessionID, input);
108
+ return { content: JSON.stringify(output), output };
109
+ },
110
+ });
111
+ editor.add({
112
+ name: "save",
113
+ description: "Save a run's JavaScript as a reusable workflow in the current project's .opencode/workflows or your OpenCode config's workflows directory. Existing files are never overwritten. Saves the script, not run arguments or worker transcripts.",
114
+ input: z.object({ runID: z.string(), name: z.string(), scope: z.enum(["project", "user"]).default("project") }).strict(),
115
+ output: z.object({ path: z.string() }),
116
+ options: { namespace: "workflows", codemode: false, permission: "edit" },
117
+ execute: async (input, tool) => {
118
+ const run = await engine.get(tool.sessionID, input.runID);
119
+ const output = await saved.save(input.name, run.script, input.scope);
120
+ await refreshCommands();
121
+ return { content: JSON.stringify(output), output };
122
+ },
123
+ });
124
+ editor.add({
125
+ name: "saved",
126
+ description: "List reusable workflow scripts available in this project and your OpenCode configuration. Project names take precedence over user names.",
127
+ input: z.object({}).strict(),
128
+ output: z.object({ workflows: z.array(SavedWorkflow) }),
129
+ options: { namespace: "workflows", codemode: false },
130
+ execute: async () => {
131
+ const output = { workflows: await saved.list() };
132
+ return { content: JSON.stringify(output), output };
133
+ },
134
+ });
135
+ });
136
+ const skillPath = fileURLToPath(new URL("../skills/workflow-authoring/SKILL.md", import.meta.url));
137
+ const skill = await readFile(skillPath, "utf8");
138
+ await ctx.skill.transform((editor) => editor.add({
139
+ id: Skill.ID.make("workflow-authoring"),
140
+ name: Skill.Name.make("Workflow authoring"),
141
+ description: "Author and run durable dynamic JavaScript workflows in OpenCode: parallel agents, structured handoffs, worktrees, recovery, and VERA verification.",
142
+ path: Skill.Info.fields.path.make(skillPath),
143
+ content: skill.replace(/^---\n[\s\S]*?\n---\n/, ""),
144
+ }));
145
+ let commands = await saved.list();
146
+ await ctx.command.transform((editor) => {
147
+ editor.add({
148
+ name: "workflow-refresh",
149
+ description: "Reload saved workflow commands for this location",
150
+ execute: refreshCommands,
151
+ });
152
+ editor.add({
153
+ name: "workflow-run",
154
+ description: "Ask the current agent to author and start a dynamic workflow",
155
+ execute: ({ sessionID, prompt, delivery }) => ctx.session.prompt({
156
+ ...prompt,
157
+ sessionID,
158
+ delivery,
159
+ text: `Load workflow-authoring and use a dynamic workflow for this task. Choose explicit roles and verification requirements, then start it with workflows_start.\n\n${prompt.text}`,
160
+ }).then(() => {}),
161
+ });
162
+ for (const command of commands) {
163
+ editor.add({
164
+ name: `workflow-${command.name}`,
165
+ description: command.description,
166
+ execute: ({ sessionID, prompt, delivery }) => ctx.session.prompt({
167
+ ...prompt,
168
+ sessionID,
169
+ delivery,
170
+ text: `Load workflow-authoring, then run saved workflow ${JSON.stringify(command.name)} through workflows_start with arguments from this request:\n\n${prompt.text}`,
171
+ }).then(() => {}),
172
+ });
173
+ }
174
+ });
175
+ async function refreshCommands() {
176
+ commands = await saved.list();
177
+ await ctx.command.reload();
178
+ }
179
+ return () => engine.dispose();
180
+ }
package/tui.ts CHANGED
@@ -4,6 +4,7 @@ import { createEffect, createSignal } from "solid-js";
4
4
  import { z } from "zod";
5
5
  import { ThreadsRpc } from "./src/rpc";
6
6
  import { activity } from "./src/activity";
7
+ import { workflowUI } from "./src/workflow-ui";
7
8
  import { cleanRoleTitle } from "./src/activity-model";
8
9
  import {
9
10
  BoxRenderable,
@@ -18,6 +19,7 @@ const CoordinatorRef = z.object({ coordinatorID: z.string() });
18
19
  export default Plugin.define({
19
20
  id: "op-threads",
20
21
  setup(ctx) {
22
+ const stopWorkflows = workflowUI(ctx);
21
23
  const rpc = ctx.client.rpc(ThreadsRpc);
22
24
  const sidebar = activity(
23
25
  ctx,
@@ -226,6 +228,7 @@ export default Plugin.define({
226
228
  });
227
229
  refresh();
228
230
  return () => {
231
+ stopWorkflows();
229
232
  stopped = true;
230
233
  abort.abort();
231
234
  sidebar.dispose();