one-code-extension 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.
Files changed (153) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +202 -0
  3. package/agents/explore.md +24 -0
  4. package/agents/general-purpose.md +21 -0
  5. package/agents/plan.md +22 -0
  6. package/docs/decisions/auto-mode.md +526 -0
  7. package/docs/decisions/branding.md +127 -0
  8. package/docs/decisions/code-review-remediation.md +106 -0
  9. package/docs/decisions/compaction.md +140 -0
  10. package/docs/decisions/distribution.md +133 -0
  11. package/docs/decisions/hooks.md +64 -0
  12. package/docs/decisions/lsp.md +58 -0
  13. package/docs/decisions/mcp.md +104 -0
  14. package/docs/decisions/memory-state.md +170 -0
  15. package/docs/decisions/model-policy.md +126 -0
  16. package/docs/decisions/modes.md +178 -0
  17. package/docs/decisions/skills-plugins.md +56 -0
  18. package/docs/decisions/subagents-workflows.md +225 -0
  19. package/docs/decisions/system-prompt.md +98 -0
  20. package/docs/decisions/tools.md +407 -0
  21. package/docs/decisions/tui.md +202 -0
  22. package/docs/decisions.md +148 -0
  23. package/extensions/ask-user/index.ts +124 -0
  24. package/extensions/ask-user/questions.ts +60 -0
  25. package/extensions/auto-mode/actions.ts +78 -0
  26. package/extensions/auto-mode/classifier.ts +388 -0
  27. package/extensions/auto-mode/config.ts +237 -0
  28. package/extensions/auto-mode/decision-log.ts +60 -0
  29. package/extensions/auto-mode/defaults.ts +65 -0
  30. package/extensions/auto-mode/instructions.ts +74 -0
  31. package/extensions/auto-mode/model-picker.ts +225 -0
  32. package/extensions/auto-mode/model-select.ts +178 -0
  33. package/extensions/auto-mode/paths.ts +109 -0
  34. package/extensions/auto-mode/pause.ts +96 -0
  35. package/extensions/auto-mode/prompt.ts +307 -0
  36. package/extensions/auto-mode/rules.ts +96 -0
  37. package/extensions/auto-mode/safety-floor.ts +100 -0
  38. package/extensions/auto-mode/sensitive.ts +137 -0
  39. package/extensions/auto-mode/shell-analysis.ts +702 -0
  40. package/extensions/background/index.ts +405 -0
  41. package/extensions/background/registry.ts +81 -0
  42. package/extensions/background/wakeup.ts +41 -0
  43. package/extensions/bash/background.ts +136 -0
  44. package/extensions/bash/index.ts +149 -0
  45. package/extensions/branding/assistant-marker.ts +53 -0
  46. package/extensions/branding/index.ts +366 -0
  47. package/extensions/branding/prompt-editor.ts +42 -0
  48. package/extensions/branding/prompt-marker.ts +29 -0
  49. package/extensions/branding/startup.ts +152 -0
  50. package/extensions/claude-compat/index.ts +50 -0
  51. package/extensions/claude-context/index.ts +79 -0
  52. package/extensions/compaction/index.ts +231 -0
  53. package/extensions/compaction/prompt.ts +191 -0
  54. package/extensions/context-management/index.ts +147 -0
  55. package/extensions/effort/index.ts +216 -0
  56. package/extensions/effort/slider.ts +253 -0
  57. package/extensions/exit/index.ts +28 -0
  58. package/extensions/file-tracker/index.ts +103 -0
  59. package/extensions/file-tracker/tracker.ts +145 -0
  60. package/extensions/hooks/debug.ts +53 -0
  61. package/extensions/hooks/executor.ts +132 -0
  62. package/extensions/hooks/index.ts +310 -0
  63. package/extensions/hooks/matcher.ts +72 -0
  64. package/extensions/hooks/plugin-hooks.ts +79 -0
  65. package/extensions/hooks/protocol.ts +151 -0
  66. package/extensions/hooks/settings.ts +163 -0
  67. package/extensions/hooks/trust.ts +151 -0
  68. package/extensions/lib/branch-restore.ts +35 -0
  69. package/extensions/lib/claude-context.ts +148 -0
  70. package/extensions/lib/deferred.ts +119 -0
  71. package/extensions/lib/git.ts +14 -0
  72. package/extensions/lib/memory.ts +219 -0
  73. package/extensions/lib/model-policy.ts +436 -0
  74. package/extensions/lib/model-tier.ts +99 -0
  75. package/extensions/lib/notifications.ts +18 -0
  76. package/extensions/lib/paths.ts +26 -0
  77. package/extensions/lib/per-cwd.ts +21 -0
  78. package/extensions/lib/persisted-output.ts +70 -0
  79. package/extensions/lib/pi-version.ts +54 -0
  80. package/extensions/lib/plugins.ts +227 -0
  81. package/extensions/lib/reminders.ts +212 -0
  82. package/extensions/lib/scratchpad.ts +68 -0
  83. package/extensions/lib/tui-render.ts +522 -0
  84. package/extensions/lsp/client.ts +278 -0
  85. package/extensions/lsp/format.ts +46 -0
  86. package/extensions/lsp/index.ts +193 -0
  87. package/extensions/lsp/protocol.ts +64 -0
  88. package/extensions/lsp/servers.ts +79 -0
  89. package/extensions/mcp/client.ts +205 -0
  90. package/extensions/mcp/config.ts +199 -0
  91. package/extensions/mcp/index.ts +373 -0
  92. package/extensions/mcp/schema.ts +161 -0
  93. package/extensions/memory/index.ts +90 -0
  94. package/extensions/notebook/index.ts +77 -0
  95. package/extensions/notebook/notebook.ts +108 -0
  96. package/extensions/permissions/index.ts +876 -0
  97. package/extensions/permissions/matcher.ts +455 -0
  98. package/extensions/permissions/modes.ts +107 -0
  99. package/extensions/permissions/protected-paths.ts +125 -0
  100. package/extensions/permissions/settings.ts +98 -0
  101. package/extensions/plan-mode/index.ts +209 -0
  102. package/extensions/plan-mode/reminder.ts +33 -0
  103. package/extensions/plan-mode/slug.ts +28 -0
  104. package/extensions/plan-mode/viewer.ts +124 -0
  105. package/extensions/plugins/index.ts +115 -0
  106. package/extensions/plugins/template.ts +42 -0
  107. package/extensions/search-tools/index.ts +0 -0
  108. package/extensions/search-tools/tools.ts +24 -0
  109. package/extensions/skill/index.ts +170 -0
  110. package/extensions/subagents/agents.ts +126 -0
  111. package/extensions/subagents/child.ts +414 -0
  112. package/extensions/subagents/default-model.ts +122 -0
  113. package/extensions/subagents/index.ts +1119 -0
  114. package/extensions/subagents/model-select.ts +346 -0
  115. package/extensions/subagents/rpc-turns.ts +123 -0
  116. package/extensions/subagents/runs.ts +93 -0
  117. package/extensions/subagents/usage.ts +53 -0
  118. package/extensions/subagents/worktree.ts +69 -0
  119. package/extensions/system-prompt/environment.ts +35 -0
  120. package/extensions/system-prompt/index.ts +54 -0
  121. package/extensions/system-prompt/template.ts +103 -0
  122. package/extensions/system-prompt/tiers/common.ts +63 -0
  123. package/extensions/system-prompt/tiers/frontier.ts +24 -0
  124. package/extensions/system-prompt/tiers/low.ts +71 -0
  125. package/extensions/system-prompt/tiers/mid.ts +56 -0
  126. package/extensions/system-reminder/index.ts +37 -0
  127. package/extensions/tasks/index.ts +140 -0
  128. package/extensions/tasks/store.ts +174 -0
  129. package/extensions/todo/index.ts +133 -0
  130. package/extensions/tool-search/index.ts +168 -0
  131. package/extensions/tool-style/index.ts +84 -0
  132. package/extensions/web/index.ts +43 -0
  133. package/extensions/web-fetch/extract.ts +139 -0
  134. package/extensions/web-fetch/index.ts +291 -0
  135. package/extensions/web-fetch/summarize.ts +78 -0
  136. package/extensions/workflow/agent-session.ts +368 -0
  137. package/extensions/workflow/globals.ts +251 -0
  138. package/extensions/workflow/index.ts +364 -0
  139. package/extensions/workflow/journal.ts +75 -0
  140. package/extensions/workflow/permission-gate.ts +71 -0
  141. package/extensions/workflow/run-manager.ts +306 -0
  142. package/extensions/workflow/saved-workflows.ts +45 -0
  143. package/extensions/workflow/script-source.ts +201 -0
  144. package/extensions/workflow/types.ts +99 -0
  145. package/extensions/workflow/vm-runtime.ts +31 -0
  146. package/extensions/workflow/widget.ts +56 -0
  147. package/extensions/worktree/index.ts +266 -0
  148. package/extensions/worktree/rewrite.ts +59 -0
  149. package/package.json +101 -0
  150. package/themes/one-code-light.json +76 -0
  151. package/themes/one-code.json +76 -0
  152. package/types/app.d.ts +5 -0
  153. package/types/community-packages.d.ts +12 -0
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Run lifecycle: registry of live/finished runs, background execution,
3
+ * abort layering, journaling glue, and resume seeding.
4
+ *
5
+ * Each run persists under `<sessionDir>/workflows/<runId>/` — `script.js`
6
+ * (the exact source executed) and `journal.jsonl` (completed agent calls).
7
+ * The directory is keyed by cwd via pi's session dir, so `resumeFromRunId`
8
+ * works across sessions and restarts. Background runs do NOT survive the
9
+ * process: session_shutdown aborts them (index.ts wires that); the journal
10
+ * makes an aborted run cheap to resume.
11
+ */
12
+
13
+ import { EventEmitter } from "node:events";
14
+ import { randomBytes } from "node:crypto";
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
+ import os from "node:os";
17
+ import { join } from "node:path";
18
+ import { AgentRunner } from "./agent-session.ts";
19
+ import { createScriptGlobals, MAX_CONCURRENCY, MAX_AGENTS_PER_RUN, type ScriptRunState } from "./globals.ts";
20
+ import { appendJournal, readJournal, ReplayCursor } from "./journal.ts";
21
+ import { findSavedWorkflow } from "./saved-workflows.ts";
22
+ import { parseWorkflowScript } from "./script-source.ts";
23
+ import { runWorkflowScript } from "./vm-runtime.ts";
24
+ import type { RunProgressEvent, RunStatus, WorkflowMeta } from "./types.ts";
25
+ import { WorkflowScriptError } from "./types.ts";
26
+
27
+ const WALL_CLOCK_CAP_MS = 30 * 60 * 1000;
28
+ const RECENT_EVENTS_CAP = 200;
29
+ const RESULT_CAP = 30_000;
30
+
31
+ export interface StartRunOptions {
32
+ script: string;
33
+ args: unknown;
34
+ tokenBudget: number | null;
35
+ resumeFromRunId?: string;
36
+ cwd: string;
37
+ sessionDir: string;
38
+ defaultModel: unknown;
39
+ configuredDefaultModel?: string;
40
+ defaultEffort?: string;
41
+ }
42
+
43
+ export class RunHandle extends EventEmitter {
44
+ status: RunStatus = "running";
45
+ result?: unknown;
46
+ errorMessage?: string;
47
+ readonly startedAt = Date.now();
48
+ finishedAt?: number;
49
+ readonly recentEvents: string[] = [];
50
+ state?: ScriptRunState;
51
+ /** Settles when the run reaches a terminal status (never rejects). */
52
+ finished!: Promise<RunHandle>;
53
+ private controller = new AbortController();
54
+ private resolveFinished!: (handle: RunHandle) => void;
55
+
56
+ readonly runId: string;
57
+ readonly meta: WorkflowMeta;
58
+ readonly runDir: string;
59
+ readonly scriptPath: string;
60
+ readonly resumed: boolean;
61
+
62
+ constructor(runId: string, meta: WorkflowMeta, runDir: string, scriptPath: string, resumed: boolean) {
63
+ super();
64
+ this.runId = runId;
65
+ this.meta = meta;
66
+ this.runDir = runDir;
67
+ this.scriptPath = scriptPath;
68
+ this.resumed = resumed;
69
+ this.finished = new Promise((resolve) => {
70
+ this.resolveFinished = resolve;
71
+ });
72
+ }
73
+
74
+ get signal(): AbortSignal {
75
+ return this.controller.signal;
76
+ }
77
+
78
+ record(event: RunProgressEvent): void {
79
+ const line = formatEvent(event);
80
+ if (line) {
81
+ this.recentEvents.push(line);
82
+ if (this.recentEvents.length > RECENT_EVENTS_CAP) this.recentEvents.shift();
83
+ }
84
+ this.emit("progress", event);
85
+ }
86
+
87
+ abort(reason: string): void {
88
+ if (this.status !== "running") return;
89
+ // First reason wins — a user stop shouldn't be masked by the script's
90
+ // consequent "aborted" error.
91
+ this.errorMessage ??= reason;
92
+ this.controller.abort();
93
+ }
94
+
95
+ finish(status: RunStatus, result?: unknown, errorMessage?: string): void {
96
+ if (this.status !== "running") return;
97
+ this.status = status;
98
+ this.result = result;
99
+ this.errorMessage ??= errorMessage;
100
+ this.finishedAt = Date.now();
101
+ this.emit("done", this);
102
+ this.resolveFinished(this);
103
+ }
104
+ }
105
+
106
+ function formatEvent(event: RunProgressEvent): string | undefined {
107
+ switch (event.type) {
108
+ case "log":
109
+ return `• ${event.text}`;
110
+ case "phase":
111
+ return `— ${event.phase} —`;
112
+ case "agentStart":
113
+ return `⏳ ${event.label}${event.phase ? ` [${event.phase}]` : ""}`;
114
+ case "agentEnd":
115
+ if (event.text) return `✗ ${event.label}: ${event.text}`;
116
+ return `✓ ${event.label}${event.replayed ? " (replayed)" : ""}${event.tokens ? ` · ${event.tokens.output} out` : ""}`;
117
+ default:
118
+ return undefined;
119
+ }
120
+ }
121
+
122
+ export class WorkflowRunManager {
123
+ private runs = new Map<string, RunHandle>();
124
+
125
+ list(): RunHandle[] {
126
+ return [...this.runs.values()];
127
+ }
128
+
129
+ get(runId: string): RunHandle | undefined {
130
+ return this.runs.get(runId);
131
+ }
132
+
133
+ abort(runId: string, reason: string): boolean {
134
+ const handle = this.runs.get(runId);
135
+ if (!handle || handle.status !== "running") return false;
136
+ handle.abort(reason);
137
+ return true;
138
+ }
139
+
140
+ abortAll(reason: string): void {
141
+ for (const handle of this.runs.values()) handle.abort(reason);
142
+ }
143
+
144
+ hasActiveRuns(): boolean {
145
+ return this.list().some((h) => h.status === "running");
146
+ }
147
+
148
+ /** Parse, persist, and launch a run. Throws synchronously on a bad script/resume id. */
149
+ start(options: StartRunOptions): RunHandle {
150
+ const { meta, body } = parseWorkflowScript(options.script);
151
+
152
+ const resumed = Boolean(options.resumeFromRunId);
153
+ const runId = options.resumeFromRunId ?? `wf_${randomBytes(5).toString("hex")}`;
154
+ if (!/^wf_[a-z0-9-]{6,}$/.test(runId)) {
155
+ throw new WorkflowScriptError(`Invalid runId "${runId}"`);
156
+ }
157
+ const runDir = join(options.sessionDir, "workflows", runId);
158
+ const journalPath = join(runDir, "journal.jsonl");
159
+
160
+ let replay: ReplayCursor | undefined;
161
+ if (resumed) {
162
+ if (this.runs.get(runId)?.status === "running") {
163
+ throw new WorkflowScriptError(`Run ${runId} is still running — stop it before resuming`);
164
+ }
165
+ if (!existsSync(runDir)) {
166
+ throw new WorkflowScriptError(`resumeFromRunId ${runId} not found under ${join(options.sessionDir, "workflows")}`);
167
+ }
168
+ replay = new ReplayCursor(readJournal(journalPath));
169
+ }
170
+ mkdirSync(runDir, { recursive: true });
171
+ const scriptPath = join(runDir, "script.js");
172
+ writeFileSync(scriptPath, options.script, "utf8");
173
+
174
+ const handle = new RunHandle(runId, meta, runDir, scriptPath, resumed);
175
+ this.runs.set(runId, handle);
176
+ void this.execute(handle, body, options, journalPath, replay);
177
+ return handle;
178
+ }
179
+
180
+ private async execute(
181
+ handle: RunHandle,
182
+ body: string,
183
+ options: StartRunOptions,
184
+ journalPath: string,
185
+ replay: ReplayCursor | undefined,
186
+ ): Promise<void> {
187
+ const wallClock = setTimeout(
188
+ () => handle.abort(`wall-clock cap reached (${WALL_CLOCK_CAP_MS / 60000} minutes)`),
189
+ WALL_CLOCK_CAP_MS,
190
+ );
191
+ wallClock.unref?.();
192
+
193
+ let runner: AgentRunner | undefined;
194
+ try {
195
+ runner = await AgentRunner.create({
196
+ cwd: options.cwd,
197
+ defaultModel: options.defaultModel,
198
+ configuredDefaultModel: options.configuredDefaultModel,
199
+ defaultEffort: options.defaultEffort as never,
200
+ onNotice: (message) => handle.record({ type: "log", text: `⚠ ${message}` }),
201
+ });
202
+
203
+ const { globals, state } = createScriptGlobals({
204
+ agentCall: (prompt, opts) => runner!.run(prompt, opts, handle.signal),
205
+ args: options.args,
206
+ budgetTotal: options.tokenBudget,
207
+ concurrency: defaultConcurrency(),
208
+ signal: handle.signal,
209
+ onEvent: (event) => handle.record(event),
210
+ onJournal: (entry) => appendJournal(journalPath, entry),
211
+ replay,
212
+ runWorkflow: (nameOrRef, childArgs) => this.runChild(handle, runner!, options, nameOrRef, childArgs, () => state),
213
+ });
214
+ handle.state = state;
215
+
216
+ const result = await runWorkflowScript(body, globals, `${handle.meta.name}.js`);
217
+ handle.finish(handle.signal.aborted ? "aborted" : "completed", result);
218
+ } catch (error) {
219
+ const aborted = handle.signal.aborted;
220
+ // Stop in-flight sibling agents instead of letting a doomed batch run on.
221
+ handle.abort((error as Error).message);
222
+ handle.finish(aborted ? "aborted" : "failed", undefined, (error as Error).message);
223
+ } finally {
224
+ clearTimeout(wallClock);
225
+ runner?.dispose();
226
+ }
227
+ }
228
+
229
+ /** One level of workflow() nesting: child shares the runner/signal, gets the remaining caps. */
230
+ private async runChild(
231
+ parent: RunHandle,
232
+ runner: AgentRunner,
233
+ options: StartRunOptions,
234
+ nameOrRef: unknown,
235
+ childArgs: unknown,
236
+ parentState: () => ScriptRunState,
237
+ ): Promise<unknown> {
238
+ let source: string;
239
+ let label: string;
240
+ if (typeof nameOrRef === "string") {
241
+ const saved = findSavedWorkflow(options.cwd, os.homedir(), nameOrRef);
242
+ if (!saved) throw new WorkflowScriptError(`workflow("${nameOrRef}"): no saved workflow with that name`);
243
+ source = readFileSync(saved.path, "utf8");
244
+ label = nameOrRef;
245
+ } else if (nameOrRef && typeof nameOrRef === "object" && typeof (nameOrRef as { scriptPath?: unknown }).scriptPath === "string") {
246
+ const path = (nameOrRef as { scriptPath: string }).scriptPath;
247
+ if (!existsSync(path)) throw new WorkflowScriptError(`workflow({scriptPath}): ${path} does not exist`);
248
+ source = readFileSync(path, "utf8");
249
+ label = path;
250
+ } else {
251
+ throw new WorkflowScriptError("workflow() takes a saved-workflow name or {scriptPath}");
252
+ }
253
+
254
+ const { meta, body } = parseWorkflowScript(source);
255
+ const state = parentState();
256
+ const remainingAgents = Math.max(0, MAX_AGENTS_PER_RUN - state.agentCount());
257
+ const remainingBudget =
258
+ options.tokenBudget === null ? null : Math.max(0, options.tokenBudget - state.outputTokens());
259
+
260
+ // Child calls are not journaled in v1: resume replays the parent's own
261
+ // agent() prefix only. Progress is forwarded under a "▸ name" phase.
262
+ const { globals } = createScriptGlobals({
263
+ agentCall: (prompt, opts) => runner.run(prompt, opts, parent.signal),
264
+ args: childArgs,
265
+ budgetTotal: remainingBudget,
266
+ concurrency: defaultConcurrency(),
267
+ maxAgents: remainingAgents,
268
+ signal: parent.signal,
269
+ onEvent: (event) => parent.record({ ...event, phase: `▸ ${meta.name}${event.phase ? ` · ${event.phase}` : ""}` }),
270
+ });
271
+ return runWorkflowScript(body, globals, `${label}.js`);
272
+ }
273
+ }
274
+
275
+ export function defaultConcurrency(): number {
276
+ return Math.min(MAX_CONCURRENCY, Math.max(1, os.cpus().length - 2));
277
+ }
278
+
279
+ /** Human/LLM-facing completion report, delivered as the followUp message. */
280
+ export function buildRunReport(handle: RunHandle): string {
281
+ const state = handle.state;
282
+ const duration = handle.finishedAt ? Math.round((handle.finishedAt - handle.startedAt) / 1000) : 0;
283
+ const statsLine = state
284
+ ? `${state.agentCount()} agent(s), ${state.outputTokens()} output tokens, $${state.cost().toFixed(4)}, ${duration}s`
285
+ : `${duration}s`;
286
+
287
+ if (handle.status === "completed") {
288
+ let resultText: string;
289
+ if (typeof handle.result === "string") {
290
+ resultText = handle.result;
291
+ } else if (handle.result === undefined) {
292
+ resultText = "(script returned no value)";
293
+ } else {
294
+ try {
295
+ resultText = JSON.stringify(handle.result, null, 2);
296
+ } catch {
297
+ resultText = String(handle.result);
298
+ }
299
+ }
300
+ if (resultText.length > RESULT_CAP) resultText = `${resultText.slice(0, RESULT_CAP)}\n… (truncated)`;
301
+ return `Workflow **${handle.meta.name}** (${handle.runId}) completed — ${statsLine}.\n\nResult:\n${resultText}`;
302
+ }
303
+
304
+ const tail = handle.recentEvents.slice(-5).join("\n");
305
+ return `Workflow **${handle.meta.name}** (${handle.runId}) ${handle.status} — ${statsLine}.\nReason: ${handle.errorMessage ?? "unknown"}${tail ? `\nRecent progress:\n${tail}` : ""}\nCompleted agent calls are journaled; re-invoke the workflow tool with resumeFromRunId: "${handle.runId}" to continue from where it stopped.`;
306
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Saved-workflow discovery (pure). Named workflow scripts live in
3
+ * `.claude/workflows/` (project) and `~/.claude/workflows/` (user) as `.js`
4
+ * files, matching Claude Code's layout; the file's basename is the invocation
5
+ * name and the leading `export const meta` supplies the description. Project
6
+ * definitions shadow user ones of the same name.
7
+ */
8
+
9
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
10
+ import { basename, join } from "node:path";
11
+ import { parseWorkflowScript } from "./script-source.ts";
12
+ import type { SavedWorkflow } from "./types.ts";
13
+
14
+ function scanDir(dir: string, source: SavedWorkflow["source"]): SavedWorkflow[] {
15
+ if (!existsSync(dir)) return [];
16
+ const found: SavedWorkflow[] = [];
17
+ for (const file of readdirSync(dir).sort()) {
18
+ if (!file.endsWith(".js") && !file.endsWith(".mjs")) continue;
19
+ const path = join(dir, file);
20
+ const workflow: SavedWorkflow = { name: basename(file).replace(/\.(js|mjs)$/, ""), path, source };
21
+ try {
22
+ workflow.meta = parseWorkflowScript(readFileSync(path, "utf8")).meta;
23
+ } catch {
24
+ // Listed anyway; the parse error surfaces if the user runs it.
25
+ }
26
+ found.push(workflow);
27
+ }
28
+ return found;
29
+ }
30
+
31
+ export function workflowDirs(cwd: string, home: string): { project: string; user: string } {
32
+ return { project: join(cwd, ".claude", "workflows"), user: join(home, ".claude", "workflows") };
33
+ }
34
+
35
+ export function discoverSavedWorkflows(cwd: string, home: string): SavedWorkflow[] {
36
+ const dirs = workflowDirs(cwd, home);
37
+ const project = scanDir(dirs.project, "project");
38
+ const names = new Set(project.map((w) => w.name));
39
+ const user = scanDir(dirs.user, "user").filter((w) => !names.has(w.name));
40
+ return [...project, ...user];
41
+ }
42
+
43
+ export function findSavedWorkflow(cwd: string, home: string, name: string): SavedWorkflow | undefined {
44
+ return discoverSavedWorkflows(cwd, home).find((w) => w.name === name);
45
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Workflow script source handling (pure).
3
+ *
4
+ * A workflow script is plain JavaScript that must begin with a pure-literal
5
+ * `export const meta = {...}`. acorn parses the whole script once; the meta
6
+ * literal is evaluated structurally (no code execution), the export statement
7
+ * is spliced out, and the remainder is wrapped in an async IIFE behind a
8
+ * prelude that makes wall-clock/randomness calls throw. The wrap is what makes
9
+ * top-level `await` and bare top-level `return` work: both become ordinary
10
+ * inside the IIFE.
11
+ *
12
+ * The vm context this produces code for is a determinism guard for resume
13
+ * replay, not a security sandbox — the injected `agent()` does real work on
14
+ * the host either way.
15
+ */
16
+
17
+ import { parse } from "acorn";
18
+ import { type WorkflowMeta, WorkflowScriptError } from "./types.ts";
19
+
20
+ /** Property keys that would let a "literal" reach into prototypes. */
21
+ const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
22
+
23
+ /** Names destructured off the injected `__workflow__` binding inside the vm. */
24
+ export const SCRIPT_GLOBALS = [
25
+ "agent",
26
+ "parallel",
27
+ "pipeline",
28
+ "workflow",
29
+ "phase",
30
+ "log",
31
+ "console",
32
+ "args",
33
+ "budget",
34
+ ] as const;
35
+
36
+ const DETERMINISM_PRELUDE = `
37
+ Math.random = () => { throw new Error("Math.random() is unavailable in a workflow (it breaks resume); vary agent prompts by index or pass randomness in via args"); };
38
+ globalThis.Date = new Proxy(Date, {
39
+ construct(target, ctorArgs, newTarget) {
40
+ if (ctorArgs.length === 0) throw new Error("new Date() with no arguments is unavailable in a workflow (it breaks resume); pass timestamps in via args");
41
+ return Reflect.construct(target, ctorArgs, newTarget);
42
+ },
43
+ apply() { throw new Error("Date() is unavailable in a workflow (it breaks resume); pass timestamps in via args"); },
44
+ get(target, prop, receiver) {
45
+ if (prop === "now") return () => { throw new Error("Date.now() is unavailable in a workflow (it breaks resume); pass timestamps in via args"); };
46
+ return Reflect.get(target, prop, receiver);
47
+ },
48
+ });
49
+ `;
50
+
51
+ export interface ParsedScript {
52
+ meta: WorkflowMeta;
53
+ /** Script source with the meta export spliced out. */
54
+ body: string;
55
+ }
56
+
57
+ interface Node {
58
+ type: string;
59
+ start: number;
60
+ end: number;
61
+ [key: string]: unknown;
62
+ }
63
+
64
+ /** Structurally evaluate an object/array/primitive literal AST — no code runs. */
65
+ function evaluateLiteral(node: Node, path: string): unknown {
66
+ switch (node.type) {
67
+ case "Literal":
68
+ return (node as { value?: unknown }).value;
69
+ case "TemplateLiteral": {
70
+ const expressions = node.expressions as Node[];
71
+ const quasis = node.quasis as Array<{ value: { cooked?: string } }>;
72
+ if (expressions.length > 0) {
73
+ throw new WorkflowScriptError(`\`${path}\` must be a pure literal: template interpolation is not allowed`);
74
+ }
75
+ return quasis.map((q) => q.value.cooked ?? "").join("");
76
+ }
77
+ case "UnaryExpression": {
78
+ const operator = node.operator as string;
79
+ const argument = node.argument as Node;
80
+ const literal = (argument as { value?: unknown }).value;
81
+ if (operator === "-" && argument.type === "Literal" && typeof literal === "number") {
82
+ return -literal;
83
+ }
84
+ throw new WorkflowScriptError(`\`${path}\` must be a pure literal: unsupported expression`);
85
+ }
86
+ case "ArrayExpression":
87
+ return (node.elements as (Node | null)[]).map((el, i) => {
88
+ if (!el) throw new WorkflowScriptError(`\`${path}[${i}]\` must not be a hole`);
89
+ if (el.type === "SpreadElement") {
90
+ throw new WorkflowScriptError(`\`${path}\` must be a pure literal: spread is not allowed`);
91
+ }
92
+ return evaluateLiteral(el, `${path}[${i}]`);
93
+ });
94
+ case "ObjectExpression": {
95
+ const out: Record<string, unknown> = {};
96
+ for (const prop of node.properties as Node[]) {
97
+ if (prop.type !== "Property") {
98
+ throw new WorkflowScriptError(`\`${path}\` must be a pure literal: spread/methods are not allowed`);
99
+ }
100
+ if (prop.computed) {
101
+ throw new WorkflowScriptError(`\`${path}\` must be a pure literal: computed keys are not allowed`);
102
+ }
103
+ const key = prop.key as Node;
104
+ const name =
105
+ key.type === "Identifier" ? (key.name as string) : key.type === "Literal" ? String((key as { value?: unknown }).value) : undefined;
106
+ if (name === undefined) {
107
+ throw new WorkflowScriptError(`\`${path}\` must be a pure literal: unsupported key`);
108
+ }
109
+ if (FORBIDDEN_KEYS.has(name)) {
110
+ throw new WorkflowScriptError(`\`${path}\` must not contain the key "${name}"`);
111
+ }
112
+ out[name] = evaluateLiteral(prop.value as Node, `${path}.${name}`);
113
+ }
114
+ return out;
115
+ }
116
+ default:
117
+ throw new WorkflowScriptError(`\`${path}\` must be a pure literal (objects, arrays, strings, numbers, booleans)`);
118
+ }
119
+ }
120
+
121
+ function validateMeta(value: unknown): WorkflowMeta {
122
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
123
+ throw new WorkflowScriptError("`meta` must be an object literal");
124
+ }
125
+ const meta = value as Record<string, unknown>;
126
+ if (typeof meta.name !== "string" || !meta.name.trim()) {
127
+ throw new WorkflowScriptError("`meta.name` must be a non-empty string");
128
+ }
129
+ if (typeof meta.description !== "string" || !meta.description.trim()) {
130
+ throw new WorkflowScriptError("`meta.description` must be a non-empty string");
131
+ }
132
+ if (meta.whenToUse !== undefined && typeof meta.whenToUse !== "string") {
133
+ throw new WorkflowScriptError("`meta.whenToUse` must be a string");
134
+ }
135
+ if (meta.phases !== undefined) {
136
+ if (!Array.isArray(meta.phases)) throw new WorkflowScriptError("`meta.phases` must be an array");
137
+ for (const phase of meta.phases) {
138
+ if (typeof phase !== "object" || phase === null || typeof (phase as { title?: unknown }).title !== "string") {
139
+ throw new WorkflowScriptError("each entry of `meta.phases` needs a `title` string");
140
+ }
141
+ }
142
+ }
143
+ return meta as unknown as WorkflowMeta;
144
+ }
145
+
146
+ /** Parse a workflow script: lift + validate `meta`, splice its export out. */
147
+ export function parseWorkflowScript(source: string): ParsedScript {
148
+ let ast: { body: Node[] };
149
+ try {
150
+ ast = parse(source, {
151
+ ecmaVersion: "latest",
152
+ sourceType: "module",
153
+ allowAwaitOutsideFunction: true,
154
+ allowReturnOutsideFunction: true,
155
+ }) as unknown as { body: Node[] };
156
+ } catch (error) {
157
+ throw new WorkflowScriptError(`Workflow script does not parse: ${(error as Error).message}`);
158
+ }
159
+
160
+ const first = ast.body[0];
161
+ if (!first || first.type !== "ExportNamedDeclaration") {
162
+ throw new WorkflowScriptError("Workflow scripts must begin with `export const meta = {...}`");
163
+ }
164
+ const declaration = first.declaration as Node | null;
165
+ if (!declaration || declaration.type !== "VariableDeclaration" || (declaration.kind as string) !== "const") {
166
+ throw new WorkflowScriptError("Workflow scripts must begin with `export const meta = {...}`");
167
+ }
168
+ const declarators = declaration.declarations as Node[];
169
+ const declarator = declarators[0];
170
+ if (
171
+ declarators.length !== 1 ||
172
+ (declarator.id as Node).type !== "Identifier" ||
173
+ ((declarator.id as Node).name as string) !== "meta"
174
+ ) {
175
+ throw new WorkflowScriptError("The first statement must export exactly one binding named `meta`");
176
+ }
177
+ const init = declarator.init as Node | null;
178
+ if (!init || init.type !== "ObjectExpression") {
179
+ throw new WorkflowScriptError("`meta` must be an object literal — no variables, calls, or spreads");
180
+ }
181
+
182
+ for (const node of ast.body.slice(1)) {
183
+ if (node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration" || node.type === "ImportDeclaration") {
184
+ throw new WorkflowScriptError("Workflow scripts cannot use import/export beyond the leading `export const meta`");
185
+ }
186
+ }
187
+
188
+ const meta = validateMeta(evaluateLiteral(init, "meta"));
189
+ const body = source.slice(0, first.start) + source.slice(first.end);
190
+ return { meta, body };
191
+ }
192
+
193
+ /**
194
+ * Wrap a spliced script body for `vm.Script`. The async IIFE is the final
195
+ * expression statement, so `runInContext()`'s completion value is the promise
196
+ * of the script's own (possibly top-level-returned) result.
197
+ */
198
+ export function wrapScriptBody(body: string): string {
199
+ const bindings = SCRIPT_GLOBALS.join(", ");
200
+ return `"use strict";\n${DETERMINISM_PRELUDE}\nconst { ${bindings} } = __workflow__;\n(async () => {\n${body}\n})();`;
201
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Shared shapes for the workflow extension. Pure types only — no runtime
3
+ * logic, no pi imports — so every module (pure and wiring alike) can depend
4
+ * on this file without dragging anything else in.
5
+ */
6
+
7
+ /** The `export const meta = {...}` literal every workflow script must start with. */
8
+ export interface WorkflowMeta {
9
+ name: string;
10
+ description: string;
11
+ whenToUse?: string;
12
+ phases?: WorkflowPhase[];
13
+ }
14
+
15
+ export interface WorkflowPhase {
16
+ title: string;
17
+ detail?: string;
18
+ model?: string;
19
+ }
20
+
21
+ /** Reasoning-effort subset shared with the subagent tool. */
22
+ export type AgentEffort = "off" | "minimal" | "low" | "medium" | "high";
23
+
24
+ /** Options accepted by the script-visible `agent(prompt, opts)` hook. */
25
+ export interface AgentCallOptions {
26
+ label?: string;
27
+ phase?: string;
28
+ /** JSON Schema (top-level object) forcing structured output. */
29
+ schema?: Record<string, unknown>;
30
+ model?: string;
31
+ /** Confirms a `model` that costs more per token than the session model. */
32
+ allowExpensive?: boolean;
33
+ effort?: AgentEffort;
34
+ isolation?: "worktree";
35
+ agentType?: string;
36
+ }
37
+
38
+ export interface AgentTokens {
39
+ input: number;
40
+ output: number;
41
+ total: number;
42
+ }
43
+
44
+ /** What a completed agent call produced, as stored in the journal. */
45
+ export interface AgentCallResult {
46
+ /** Final assistant text, or the validated structured object when `schema` was given. */
47
+ value: unknown;
48
+ tokens: AgentTokens;
49
+ cost: number;
50
+ /** Path of a kept worktree (uncommitted changes were left behind), if any. */
51
+ worktreePath?: string;
52
+ }
53
+
54
+ /** The one injection point globals.ts needs: something that runs a real agent. */
55
+ export type AgentCallFn = (prompt: string, opts: AgentCallOptions) => Promise<AgentCallResult>;
56
+
57
+ /** One line of journal.jsonl. */
58
+ export interface JournalEntry {
59
+ callIndex: number;
60
+ hash: string;
61
+ result: AgentCallResult;
62
+ timestamp: number;
63
+ }
64
+
65
+ /** The frozen `budget` global exposed to scripts. */
66
+ export interface BudgetSnapshot {
67
+ total: number | null;
68
+ spent(): number;
69
+ remaining(): number;
70
+ }
71
+
72
+ export type RunStatus = "running" | "completed" | "failed" | "aborted";
73
+
74
+ /** Progress events a run emits (consumed by the widget, /workflows, onUpdate). */
75
+ export interface RunProgressEvent {
76
+ type: "log" | "phase" | "agentStart" | "agentEnd";
77
+ text?: string;
78
+ phase?: string;
79
+ label?: string;
80
+ callIndex?: number;
81
+ tokens?: AgentTokens;
82
+ cost?: number;
83
+ replayed?: boolean;
84
+ }
85
+
86
+ /** A saved workflow discovered in .claude/workflows/ or ~/.claude/workflows/. */
87
+ export interface SavedWorkflow {
88
+ name: string;
89
+ path: string;
90
+ meta?: WorkflowMeta;
91
+ source: "project" | "user";
92
+ }
93
+
94
+ export class WorkflowScriptError extends Error {
95
+ constructor(message: string) {
96
+ super(message);
97
+ this.name = "WorkflowScriptError";
98
+ }
99
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Executes a parsed workflow script body inside a fresh `node:vm` context.
3
+ *
4
+ * The context gets exactly one host binding — `__workflow__`, the globals
5
+ * object — and none of the host's intrinsics (the vm realm supplies its own
6
+ * Math/Date/Promise/etc., which the prelude then neuters for determinism).
7
+ * The vm `timeout` only bounds the synchronous prefix of execution (up to the
8
+ * first await); async runaway is bounded by the agent/budget caps inside the
9
+ * injected agent(), not here.
10
+ */
11
+
12
+ import vm from "node:vm";
13
+ import type { ScriptGlobals } from "./globals.ts";
14
+ import { wrapScriptBody } from "./script-source.ts";
15
+ import { WorkflowScriptError } from "./types.ts";
16
+
17
+ const SYNC_TIMEOUT_MS = 5_000;
18
+
19
+ export async function runWorkflowScript(body: string, globals: ScriptGlobals, filename = "workflow.js"): Promise<unknown> {
20
+ const context = vm.createContext({ __workflow__: globals }, { codeGeneration: { strings: false, wasm: false } });
21
+ let completion: unknown;
22
+ try {
23
+ const script = new vm.Script(wrapScriptBody(body), { filename });
24
+ completion = script.runInContext(context, { timeout: SYNC_TIMEOUT_MS });
25
+ } catch (error) {
26
+ throw error instanceof WorkflowScriptError ? error : new WorkflowScriptError(`Workflow script failed: ${(error as Error).message}`);
27
+ }
28
+ // The wrap's final statement is the async IIFE, so the completion value is
29
+ // the promise of the script's own result (including a top-level `return`).
30
+ return await Promise.resolve(completion);
31
+ }