@agimon-ai/po-workflow 0.0.0-alpha.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,4259 @@
1
+ import { n as resolveWorkflowEntry, r as validateWorkflowLaunch, t as parseWorkflowLaunchCommand } from "../workflowLaunchCommand-GGqimmTC.mjs";
2
+ import { composeServerProviderOverrides, defineServerPlugin } from "@agimon-ai/po-core/serverFacet";
3
+ import { composeRootHooks, defineChannel, defineRoot, defineRoutedContribution } from "@agimon-ai/po-core/extensionFile";
4
+ import { randomUUID } from "node:crypto";
5
+ import { readFile } from "node:fs/promises";
6
+ import path, { basename, isAbsolute, join, relative, resolve } from "node:path";
7
+ import { BACKGROUND_WORK_HOLDING_PROVIDERS, DOOM_BACKGROUND_WORK_SERVICE, holdsSettledSession, readDoomBackgroundWorkService } from "@agimon-ai/po-core/backgroundWork";
8
+ import { SUBAGENT_PARENT_SESSION_ENV, readChildProcessContext, resolveRootSessionId } from "@agimon-ai/po-core/childProcess";
9
+ import { DOOM_NARRATION_SERVICE, createNarrationRequest, requireDoomNarrationService } from "@agimon-ai/po-core/narration";
10
+ import { DOOM_NOTIFICATION_ENTRY_TYPE, createDoomNotificationEntryData } from "@agimon-ai/po-core/notification";
11
+ import { createDoomReadinessCoordinator, readDoomReadinessCoordinator } from "@agimon-ai/po-core/readiness";
12
+ import { defineMinorMode, serverMinorModes } from "@agimon-ai/po-minor-mode";
13
+ import { createDoomTelemetry } from "@agimon-ai/po-telemetry";
14
+ import { NativeTerminalService, WORKFLOW_DECISION_FILE_ENV, WORKFLOW_DECISION_STEERING_ENV, WORKFLOW_DECISION_STEERING_HOST, WorkflowRegistryService, WorkflowTerminalService, connectNativeTerminal, createEmbeddedWorkflowFeature, diagnoseWorkflow, gateStepDecision, readStepDecision, readWorkflowCatalog, readWorkflowProgress, serveNativeTerminal, summarizeWorkflow, summarizeWorkflowProgress, terminalSocketPath } from "@agimon-ai/workflow-mcp";
15
+ import { z } from "zod";
16
+ import fs, { existsSync, statSync } from "node:fs";
17
+ import { WORKFLOW_STEP_SESSION_PROVENANCE } from "@agimon-ai/po-core/hubChannel";
18
+ import { execFile } from "node:child_process";
19
+ import { Readable } from "node:stream";
20
+ import { doomApiCallerFrom } from "@agimon-ai/po-core/packageApi";
21
+ import { Hono } from "hono";
22
+ import { streamSSE } from "hono/streaming";
23
+ import { apiResponse, defineApiRoutes } from "@agimon-ai/po-core/web";
24
+ import { fileURLToPath } from "node:url";
25
+ import os from "node:os";
26
+ //#region src/constants/workflow.ts
27
+ const LAUNCH_WORKFLOW_TOOL_NAME = "launch_workflow";
28
+ const WORKFLOW_TOOLS_TOOL_NAME = "workflow_tools";
29
+ /**
30
+ * One launch's own id, carried in the run's environment.
31
+ *
32
+ * A launch answers once its run registers; matching on this id, rather than on
33
+ * the workflow and the time, keeps two launches of one workflow apart.
34
+ */
35
+ const WORKFLOW_LAUNCH_ID_ENV = "PO_WORKFLOW_LAUNCH_ID";
36
+ /**
37
+ * Provenance of the child session a launch creates to own and run one workflow.
38
+ * Not 'workflow', which marks a step's session and keeps it out of the rail.
39
+ */
40
+ const WORKFLOW_SESSION_PROVENANCE = "workflow-session";
41
+ /**
42
+ * Run env key naming the session that launched a run into its own workflow
43
+ * session. PI_SESSION_ID names the owner; this names the launcher, which lists
44
+ * the run, is told when it ends, and releases the workflow session after success.
45
+ */
46
+ const WORKFLOW_LAUNCHER_SESSION_ENV = "PO_WORKFLOW_LAUNCHER_SESSION_ID";
47
+ /** Message a workflow session sends its launcher to be released once its run succeeded. */
48
+ const WORKFLOW_SESSION_RELEASE_TYPE = "po-workflow.session-release";
49
+ //#endregion
50
+ //#region src/schemas/runConfig.ts
51
+ /** A YAML list, or one comma-separated string, as `--domains a,b` accepts. */
52
+ const nameList = z.union([z.string(), z.array(z.string())]).transform((value) => (Array.isArray(value) ? value : value.split(",")).map((name) => name.trim()).filter((name) => name.length > 0));
53
+ const singleName = z.string().trim().min(1);
54
+ /**
55
+ * The Po keys of a workflow step's `runConfig`.
56
+ *
57
+ * The engine treats `runConfig` as an opaque map, so this is where a typo such
58
+ * as `majormode` is caught instead of silently running on workspace defaults.
59
+ */
60
+ const poRunConfigSchema = z.object({
61
+ majorMode: singleName.optional().describe("Major mode the step session runs in."),
62
+ minorModes: nameList.optional().describe("Minor modes activated in the step session."),
63
+ profile: singleName.optional().describe("Profile the step session runs as."),
64
+ domains: nameList.optional().describe("Domains selected for the step session. An empty list selects none."),
65
+ model: singleName.optional().describe("Model selector, provider/id."),
66
+ thinking: singleName.optional().describe("Thinking level for the model."),
67
+ subagentModel: singleName.optional().describe("Preferred Team subagent model, independent of the step model."),
68
+ subagentThinking: singleName.optional().describe("Preferred Team subagent thinking level.")
69
+ }).strict();
70
+ /**
71
+ * The same keys for a templated step, whose `runConfig` is also the data its
72
+ * command template reads: keys Po does not use are left to the template.
73
+ */
74
+ const poTemplateRunConfigSchema = poRunConfigSchema.strip();
75
+ /** The runConfig keys Po reads itself, the host keys `workflow-mcp doctor` checks steps against. */
76
+ const PO_RUN_CONFIG_KEYS = poRunConfigSchema.keyof().options;
77
+ //#endregion
78
+ //#region src/schemas/workflowPi.ts
79
+ const RUN_SELECTOR_FIELDS = {
80
+ runKey: z.string().min(1).describe("Run key of the workflow run."),
81
+ workspace: z.string().optional().describe("Workspace the run belongs to.")
82
+ };
83
+ z.discriminatedUnion("action", [
84
+ z.object({
85
+ action: z.literal("status"),
86
+ ...RUN_SELECTOR_FIELDS
87
+ }).strict(),
88
+ z.object({
89
+ action: z.literal("tail"),
90
+ ...RUN_SELECTOR_FIELDS
91
+ }).strict(),
92
+ z.object({
93
+ action: z.literal("recovery-evidence"),
94
+ ...RUN_SELECTOR_FIELDS
95
+ }).strict(),
96
+ z.object({
97
+ action: z.literal("follow"),
98
+ ...RUN_SELECTOR_FIELDS
99
+ }).strict(),
100
+ z.object({
101
+ action: z.literal("open"),
102
+ ...RUN_SELECTOR_FIELDS
103
+ }).strict(),
104
+ z.object({
105
+ action: z.literal("pause"),
106
+ ...RUN_SELECTOR_FIELDS,
107
+ reason: z.string().optional()
108
+ }).strict(),
109
+ z.object({
110
+ action: z.literal("resume"),
111
+ ...RUN_SELECTOR_FIELDS,
112
+ reason: z.string().optional()
113
+ }).strict(),
114
+ z.object({
115
+ action: z.literal("stop"),
116
+ ...RUN_SELECTOR_FIELDS,
117
+ reason: z.string().optional()
118
+ }).strict(),
119
+ z.object({
120
+ action: z.literal("recover"),
121
+ ...RUN_SELECTOR_FIELDS,
122
+ dryRun: z.boolean().optional(),
123
+ job: z.string().optional(),
124
+ runner: z.string().optional()
125
+ }).strict()
126
+ ]);
127
+ const dispatchInputSchema = z.object({
128
+ description: z.string().optional().describe("Description shown for the workflow input."),
129
+ required: z.boolean().optional().describe("Whether the workflow input must be provided."),
130
+ default: z.string().optional().describe("Default value for the workflow input."),
131
+ type: z.string().optional().describe("Declared workflow input type."),
132
+ options: z.array(z.string()).optional().describe("Allowed values for the workflow input.")
133
+ }).passthrough();
134
+ z.object({ inputs: z.record(z.string(), dispatchInputSchema).optional().describe("Workflow dispatch input definitions.") }).passthrough();
135
+ z.object({
136
+ directory: z.string().min(1).describe("Directory searched for workflow definitions."),
137
+ hasNextPage: z.boolean().describe("Whether another catalog page exists."),
138
+ page: z.number().int().positive().describe("One-based catalog page number."),
139
+ pageSize: z.number().int().positive().describe("Catalog page size."),
140
+ workflows: z.array(z.object({
141
+ path: z.string().min(1).describe("Workflow path relative to the catalog directory."),
142
+ name: z.string().describe("Workflow display name."),
143
+ description: z.string().describe("Workflow description."),
144
+ tags: z.array(z.string()).describe("Workflow tags.")
145
+ }).passthrough()).describe("Workflow definitions on this page.")
146
+ }).passthrough();
147
+ const workflowToolsInputSchema = z.object({
148
+ action: z.enum(["recovery-evidence", "recover"]),
149
+ ...RUN_SELECTOR_FIELDS,
150
+ dryRun: z.boolean().optional().describe("recover only: print the replay without running it."),
151
+ runner: z.string().optional().describe("recover only: override the recorded runner.")
152
+ }).strict();
153
+ //#endregion
154
+ //#region src/services/backgroundWork/index.ts
155
+ /**
156
+ * Background-work registration for workflow runs.
157
+ *
158
+ * DESIGN PATTERNS:
159
+ * - Hard dependency binding through Cordis injection
160
+ * - Snapshot pattern: the extension pushes the current run set, the provider
161
+ * only reads it
162
+ *
163
+ * CODING STANDARDS:
164
+ * - Named exports only
165
+ * - Explicit return types on exported functions
166
+ *
167
+ * AVOID:
168
+ * - Process-global symbols or dynamic imports for extension communication
169
+ * - Reporting runs owned by another Pi session
170
+ */
171
+ const PROVIDER_NAME = "workflow-mcp";
172
+ /**
173
+ * Register a background-work provider for this session's workflow runs.
174
+ *
175
+ * The runtime pins all announcements to the active Pi session and validates
176
+ * each snapshot before it reaches another extension.
177
+ */
178
+ function registerRunProvider(ctx) {
179
+ let items = [];
180
+ let provider;
181
+ let disposed = false;
182
+ ctx.inject([DOOM_BACKGROUND_WORK_SERVICE], (serviceContext) => {
183
+ if (disposed) return void 0;
184
+ const service = readDoomBackgroundWorkService(serviceContext);
185
+ if (!service) return void 0;
186
+ const registration = service.register({
187
+ provider: PROVIDER_NAME,
188
+ listActiveWork: () => items
189
+ });
190
+ provider = registration;
191
+ return () => {
192
+ registration.dispose();
193
+ if (provider === registration) provider = void 0;
194
+ };
195
+ });
196
+ return {
197
+ update(next) {
198
+ items = next;
199
+ provider?.update();
200
+ },
201
+ dispose() {
202
+ if (disposed) return;
203
+ disposed = true;
204
+ items = [];
205
+ provider?.dispose();
206
+ provider = void 0;
207
+ }
208
+ };
209
+ }
210
+ //#endregion
211
+ //#region src/services/piToolBridge/index.ts
212
+ /**
213
+ * Environment variable stamped onto every run launched from a Pi session.
214
+ *
215
+ * `RunWorkflowService` forwards `env` to the inner CLI invocation across the
216
+ * `launch-command` delegation boundary and persists it on the run record, so
217
+ * this survives backgrounding and identifies the launching session afterwards.
218
+ */
219
+ const PI_SESSION_ENV$1 = "PI_SESSION_ID";
220
+ /** Operator override for the per-session launch ceiling. */
221
+ const MAX_CONCURRENT_ENV = "WORKFLOW_MCP_MAX_CONCURRENT";
222
+ const DEFAULT_MAX_CONCURRENT = 5;
223
+ /** Resolve the per-session launch ceiling from the environment. */
224
+ function resolveMaxConcurrent(env = process.env) {
225
+ const parsed = Number(env[MAX_CONCURRENT_ENV]);
226
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_CONCURRENT;
227
+ }
228
+ /**
229
+ * True when this session launched the run.
230
+ *
231
+ * Fails closed on a missing session id: an unstamped record (a CLI launch) and
232
+ * a call arriving before the session id is known both match nothing, so no run
233
+ * ever leaks across sessions through an undefined-equals-undefined comparison.
234
+ */
235
+ function isSessionRun(record, sessionId) {
236
+ return sessionId !== void 0 && record.env?.["PI_SESSION_ID"] === sessionId;
237
+ }
238
+ /** True when this session handed the run to a workflow session of its own: it is told, but does not own it. */
239
+ function isLaunchedRun(record, sessionId) {
240
+ return sessionId !== void 0 && record.env?.["PO_WORKFLOW_LAUNCHER_SESSION_ID"] === sessionId && !isSessionRun(record, sessionId);
241
+ }
242
+ /** The line a launcher's finish notice adds: where the run lived, and where to take it up. */
243
+ function launchedRunHint(record) {
244
+ return `It ran in workflow session ${record.env?.["PI_SESSION_ID"] ?? "unknown"}; troubleshoot and recover it there.`;
245
+ }
246
+ /**
247
+ * True when this session owns a run another session handed to it: it is the
248
+ * run's workflow session, so a finished run is posted without starting a turn.
249
+ */
250
+ function isOwnedDelegatedRun(record, sessionId) {
251
+ const launcher = record.env?.[WORKFLOW_LAUNCHER_SESSION_ENV];
252
+ return isSessionRun(record, sessionId) && typeof launcher === "string" && launcher !== "" && launcher !== sessionId;
253
+ }
254
+ /**
255
+ * What the caller is told when the registry, not the launcher, acknowledged a
256
+ * launch.
257
+ *
258
+ * Leads with the run key because it is the handle every other workflow action
259
+ * takes, and it is no longer available any other way: the launcher's own output
260
+ * is not waited for, so nothing else in this result carries it.
261
+ */
262
+ function launchedRunSummary(record) {
263
+ return [
264
+ `Started ${record.displayName} in workspace ${record.workspace}.`,
265
+ `Run key: ${record.runKey}`,
266
+ "The run is registered and going. This call returned at that point rather than waiting for the launcher process to exit."
267
+ ].join("\n");
268
+ }
269
+ /**
270
+ * What the caller is told when the launch was handed off but no run has
271
+ * registered inside the acknowledgement budget.
272
+ *
273
+ * Deliberately not an error: the launcher is slow rather than broken often
274
+ * enough that failing here would strand runs that go on to start normally.
275
+ * There is no run key to offer yet, so it points at the surfaces that do not
276
+ * need one.
277
+ */
278
+ function launchHandoffSummary(workflowPath) {
279
+ return [
280
+ `Launch handed off for ${workflowPath}, but no run has registered yet.`,
281
+ "Nothing was cancelled: the launcher is still starting, and a run that registers later still reports back here when it finishes.",
282
+ "Do not launch it again. Ask the user to check the active workflow list if they need to see it sooner."
283
+ ].join("\n");
284
+ }
285
+ new RegExp([
286
+ "\\u001B\\[[0-9;?]*[ -/]*[@-~]",
287
+ "\\u001B\\][^\\u0007\\u001B]*(?:\\u0007|\\u001B\\\\)",
288
+ "\\u001B[@-Z\\\\-_]"
289
+ ].join("|"), "g");
290
+ /** Flatten MCP tool content to text for Pi. */
291
+ function toolResultText(result) {
292
+ return result.content.map((part) => part.type === "text" ? part.text : "").filter(Boolean).join("\n").trim();
293
+ }
294
+ /**
295
+ * Attach a short menu of next steps to a problem.
296
+ *
297
+ * An error with no route forward is where an agent invents one: retrying a
298
+ * capacity limit, relaunching a run that should be recovered, or silently
299
+ * giving up on work the user is waiting for. Naming two or three real options
300
+ * turns an error into a decision, and the decision belongs to the user.
301
+ */
302
+ function withOptions(problem, options) {
303
+ if (options.length === 0) return problem;
304
+ return [
305
+ problem,
306
+ "",
307
+ "Options, put these to the user rather than picking one yourself:",
308
+ ...options.map(bullet)
309
+ ].join("\n");
310
+ }
311
+ function bullet(option) {
312
+ return `- ${option}`;
313
+ }
314
+ /** The job and step a run died on, preferring the progress log to the record. */
315
+ function failurePosition(record, jobs) {
316
+ const failedJob = jobs.find((job) => job.status === "failed");
317
+ const failedStep = failedJob?.steps.find((step) => step.status === "failed");
318
+ return {
319
+ job: failedJob?.name ?? record.failedJob,
320
+ step: failedStep?.name,
321
+ reason: failedStep?.reason ?? failedJob?.reason
322
+ };
323
+ }
324
+ /**
325
+ * Describe a finished run for the agent that launched it.
326
+ *
327
+ * Carries the job identifiers back so a dispatching agent can match the run to
328
+ * the work item it launched it for without another lookup. When the run failed,
329
+ * carries the failing step and a set of options too: the agent is being told
330
+ * about this out of band, with no user question in flight, so without them it
331
+ * has to guess whether to recover, relaunch, or report.
332
+ */
333
+ function finishedRunSummary(record, jobs = []) {
334
+ const outcome = record.stage === "completed" ? "completed" : `ended in ${record.stage}`;
335
+ const failure = failurePosition(record, jobs);
336
+ const lines = [
337
+ `Workflow run ${record.runKey} in workspace ${record.workspace} ${outcome}.`,
338
+ record.workflowId ? `Workflow: ${record.workflowId}` : void 0,
339
+ failure.job ? `Failed job: ${failure.job}${failure.step ? ` (step: ${failure.step})` : ""}` : void 0,
340
+ record.errorMessage ? `Error: ${record.errorMessage}` : void 0,
341
+ failure.reason && failure.reason !== record.errorMessage ? `Step reported: ${failure.reason}` : void 0
342
+ ];
343
+ const jobId = record.env?.AGIFLOW_JOB_ID;
344
+ if (jobId) lines.push(`Agiflow job: ${record.env?.AGIFLOW_JOB_KIND ?? "unknown"} ${jobId}`);
345
+ const summary = lines.filter((line) => Boolean(line)).join("\n");
346
+ if (record.stage === "completed") return summary;
347
+ if (record.outcome === "interrupted") return withOptions(summary, [`workflow_run {"action":"recover","runKey":${JSON.stringify(record.runKey)}}: pick it up where it stopped.`, "Leave it stopped and move on."]);
348
+ const diagnosticText = [record.errorMessage, failure.reason].filter(Boolean).join("\n");
349
+ if (diagnosticText.includes("JOB_ALREADY_CLAIMED")) return withOptions(summary, ["Report contention: another worker owns this job. Do not unlock, release, or retry the claim automatically."]);
350
+ if (diagnosticText.includes("WORKFLOW_NOT_OWNED") || /release failed/i.test(diagnosticText)) return withOptions(summary, ["Inspect the current workflow ownership and ask the user before any release or unlock. Never force another worker’s lock."]);
351
+ if (/running workflow not found/i.test(diagnosticText)) return withOptions(summary, [`workflow_run {"action":"status","runKey":${JSON.stringify(record.runKey)}}: re-check current state before retrying.`, "Report the recorded terminal state when no running owner remains."]);
352
+ return withOptions(summary, [
353
+ `workflow_run {"action":"tail","runKey":${JSON.stringify(record.runKey)}}: read the run's own output first, when the error above does not explain the failure.`,
354
+ `workflow_run {"action":"recover","runKey":${JSON.stringify(record.runKey)}}: resume from the failed job, keeping the work already done. Fits a transient or external cause.`,
355
+ "Report the failure and stop, when it needs a code change or a decision you cannot make."
356
+ ]);
357
+ }
358
+ //#endregion
359
+ //#region src/services/workflowExecution/index.ts
360
+ const AGIFLOW_JOB_ID_ENV = "AGIFLOW_JOB_ID";
361
+ const AGIFLOW_JOB_KIND_ENV = "AGIFLOW_JOB_KIND";
362
+ const AGIFLOW_PROJECT_ID_ENV = "AGIFLOW_PROJECT_ID";
363
+ /** How often the registry is asked whether the launch has produced a run. */
364
+ const LAUNCH_ACK_POLL_MS = 500;
365
+ /**
366
+ * How long to wait for that before answering without a run key.
367
+ *
368
+ * Long enough to cover a launcher that is merely slow, short enough that a
369
+ * caller is never left holding a turn open for a launcher that will not answer.
370
+ */
371
+ const LAUNCH_ACK_TIMEOUT_MS = 15e3;
372
+ function reportProgress(onUpdate, tool, message) {
373
+ onUpdate?.({
374
+ content: [{
375
+ type: "text",
376
+ text: message
377
+ }],
378
+ details: { tool }
379
+ });
380
+ }
381
+ const launchFailureOptions = [
382
+ "Report the failure to the user with the error above. A launch failure is usually a bad workflow path, a missing launcher, or a workflow whose own pre-conditions refused.",
383
+ "list_workflows: confirm the workflow path exists and its description matches the work, if the path may be wrong.",
384
+ "workflow_run with action status: check whether an earlier attempt is already running before launching again."
385
+ ];
386
+ /**
387
+ * An Error, whatever the launch rejected with.
388
+ *
389
+ * A rejection reaches here as `unknown` because it crossed a promise boundary,
390
+ * and rethrowing that as-is hands Pi's tool layer something it cannot read a
391
+ * message or a stack off. The original is kept as the cause rather than
392
+ * flattened into a string.
393
+ */
394
+ function asError$1(value) {
395
+ return value instanceof Error ? value : new Error(String(value), { cause: value });
396
+ }
397
+ /** Resolves to undefined, so a race against it reads as "nothing settled yet". */
398
+ function sleep(ms) {
399
+ return new Promise((settle) => {
400
+ setTimeout(() => settle(void 0), ms).unref?.();
401
+ });
402
+ }
403
+ /**
404
+ * Wait for whichever answer arrives first: the launch settling, the run
405
+ * appearing in the registry, or the budget running out.
406
+ *
407
+ * A launch that delegates to a terminal launcher settles when that launcher's
408
+ * whole process chain closes, which on a loaded machine has been measured
409
+ * minutes after the run itself was registered and working. The registry is the
410
+ * earlier and more truthful signal: a run recorded there is a run that started.
411
+ */
412
+ async function awaitLaunchAck(settled, dependencies, query) {
413
+ const findLaunchedRun = dependencies.findLaunchedRun;
414
+ if (!findLaunchedRun) return {
415
+ kind: "settled",
416
+ outcome: await settled
417
+ };
418
+ return awaitRunAck(settled, () => findLaunchedRun(query), dependencies.launchAckPollMs, dependencies.launchAckTimeoutMs);
419
+ }
420
+ async function awaitRunAck(settled, find, pollMs = LAUNCH_ACK_POLL_MS, timeoutMs = LAUNCH_ACK_TIMEOUT_MS) {
421
+ const deadline = Date.now() + timeoutMs;
422
+ for (;;) {
423
+ const outcome = await Promise.race([settled, sleep(pollMs)]);
424
+ if (outcome) return {
425
+ kind: "settled",
426
+ outcome
427
+ };
428
+ const record = await find().catch(() => void 0);
429
+ if (record) return {
430
+ kind: "record",
431
+ record
432
+ };
433
+ if (Date.now() >= deadline) return { kind: "handoff" };
434
+ }
435
+ }
436
+ /**
437
+ * Carry a failure that lands after the caller was told the run started.
438
+ *
439
+ * Answering early trades the launcher's exit code for timeliness. Dropping that
440
+ * code entirely is not part of the trade: a launch that fails a minute later
441
+ * would otherwise leave a user waiting on a run that never existed.
442
+ */
443
+ function reportLateFailure(settled, dependencies, ctx) {
444
+ settled.then((outcome) => {
445
+ if (outcome.kind === "error") {
446
+ dependencies.onLateFailure?.(asError$1(outcome.error), ctx);
447
+ return;
448
+ }
449
+ if (outcome.result.isError) dependencies.onLateFailure?.(new Error(toolResultText(outcome.result) || "Workflow launch failed."), ctx);
450
+ });
451
+ }
452
+ function createWorkflowLaunchExecutor(dependencies) {
453
+ return { async execute(input, ctx, onUpdate) {
454
+ const sessionId = resolveRootSessionId(ctx.sessionManager.getSessionId(), dependencies.environment);
455
+ dependencies.observeSession?.(sessionId);
456
+ const agiflowJobKind = input.env?.[AGIFLOW_JOB_KIND_ENV]?.trim();
457
+ const agiflowJobId = input.env?.[AGIFLOW_JOB_ID_ENV]?.trim();
458
+ const explicitProjectId = input.env?.[AGIFLOW_PROJECT_ID_ENV]?.trim();
459
+ if (Boolean(agiflowJobKind) !== Boolean(agiflowJobId)) throw new Error("Agiflow workflow launches require AGIFLOW_JOB_KIND and AGIFLOW_JOB_ID together.");
460
+ if (agiflowJobKind && agiflowJobKind !== "task" && agiflowJobKind !== "work-unit") throw new Error("Agiflow workflow launches require AGIFLOW_JOB_KIND to be task or work-unit.");
461
+ if (agiflowJobKind && !explicitProjectId) throw new Error("Agiflow workflow launches require AGIFLOW_PROJECT_ID with the job identity.");
462
+ if (agiflowJobKind && !input.prompt?.trim()) throw new Error("Agiflow workflow launches require a non-empty prompt so user_prompt runs do not wait for terminal input.");
463
+ reportProgress(onUpdate, LAUNCH_WORKFLOW_TOOL_NAME, "Checking workflow capacity...");
464
+ const maxConcurrent = resolveMaxConcurrent(dependencies.environment);
465
+ const active = await dependencies.activeRunCount?.() ?? 0;
466
+ if (active >= maxConcurrent) throw new Error(withOptions(`This session is at capacity: ${active}/${maxConcurrent} workflows running.`, [
467
+ "Wait for a running workflow to finish, then launch again. The extension reports each one as it ends.",
468
+ "workflow_run with action status: show what is running, so the user can judge whether something is stuck.",
469
+ "workflow_run with action stop: free a slot by stopping a run the user no longer wants."
470
+ ]));
471
+ if (input.runner) {
472
+ const rejection = dependencies.rejectRunner?.(input.workflowPath, input.runner);
473
+ if (rejection) throw new Error(rejection);
474
+ }
475
+ reportProgress(onUpdate, LAUNCH_WORKFLOW_TOOL_NAME, `Launching workflow ${input.workflowPath}...`);
476
+ const workflowEnv = { ...input.env };
477
+ const dispatcherContextFile = dependencies.environment.AGIFLOW_DISPATCH_CONTEXT_FILE;
478
+ const dispatcherProjectId = dependencies.environment[AGIFLOW_PROJECT_ID_ENV]?.trim();
479
+ if (dispatcherContextFile && agiflowJobKind && dispatcherProjectId && explicitProjectId && dispatcherProjectId !== explicitProjectId) throw new Error(`Agiflow workflow project identity conflicts with the dispatcher context (${explicitProjectId} versus ${dispatcherProjectId}).`);
480
+ if (dispatcherContextFile) for (const key of [
481
+ "AGIFLOW_ORGANIZATION_ID",
482
+ "AGIFLOW_PROJECT_ID",
483
+ "AGIFLOW_DEVICE_ID",
484
+ "BACKEND_AGIFLOW_API_ENDPOINT",
485
+ "AGIFLOW_DISPATCH_SECRET_FILE"
486
+ ]) {
487
+ const hostValue = dependencies.environment[key];
488
+ if (hostValue) workflowEnv[key] = hostValue;
489
+ }
490
+ const since = Date.now();
491
+ const launchId = randomUUID();
492
+ const settled = foldOutcome(dependencies.trackPendingRun(dependencies.runTool.execute({
493
+ ...input,
494
+ env: {
495
+ ...workflowEnv,
496
+ [PI_SESSION_ENV$1]: sessionId,
497
+ [WORKFLOW_LAUNCH_ID_ENV]: launchId
498
+ }
499
+ })));
500
+ const ack = await awaitLaunchAck(settled, dependencies, {
501
+ launchId,
502
+ sessionId,
503
+ since,
504
+ workflowPath: input.workflowPath
505
+ });
506
+ if (ack.kind === "settled") {
507
+ const outcome = ack.outcome;
508
+ if (outcome.kind === "error") throw asError$1(outcome.error);
509
+ const result = outcome.result;
510
+ if (result.isError) throw new Error(withOptions(toolResultText(result) || "Workflow launch failed.", launchFailureOptions));
511
+ await dependencies.onLaunch?.(ctx);
512
+ return result;
513
+ }
514
+ reportLateFailure(settled, dependencies, ctx);
515
+ await dependencies.onLaunch?.(ctx);
516
+ return { content: [{
517
+ type: "text",
518
+ text: ack.kind === "record" ? launchedRunSummary(ack.record) : launchHandoffSummary(input.workflowPath)
519
+ }] };
520
+ } };
521
+ }
522
+ function foldOutcome(promise) {
523
+ return promise.then((result) => ({
524
+ kind: "value",
525
+ result
526
+ }), (error) => ({
527
+ kind: "error",
528
+ error
529
+ }));
530
+ }
531
+ //#endregion
532
+ //#region src/services/workflowRuns/index.ts
533
+ /**
534
+ * MIRRORS @agimon-ai/workflow-mcp's on-disk registry
535
+ * (src/services/WorkflowRegistryService.ts and WorkflowProgressService.ts in
536
+ * that package). The engine keeps one directory per run under
537
+ * `<home>/workspaces/<workspace>/<stage>/<runKey>/` holding a `run.json`
538
+ * record and an append-only `progress.ndjson` job/step log; the hub only ever
539
+ * reads them. The derivation is duplicated rather than imported because
540
+ * po-web must not depend on the engine at runtime; the unit suite pins
541
+ * this mirror against the package's published types.
542
+ */
543
+ const DEFAULT_WORKFLOW_HOME_DIR_NAME = ".workflow-mcp";
544
+ const WORKSPACES_DIR_NAME = "workspaces";
545
+ const RUN_RECORD_FILE_NAME = "run.json";
546
+ const PROGRESS_FILE_NAME = "progress.ndjson";
547
+ const WORKFLOW_STAGES = [
548
+ "running",
549
+ "completed",
550
+ "error"
551
+ ];
552
+ /**
553
+ * Errored runs stay a day, which is how long recovery has to act on them.
554
+ *
555
+ * Finished runs have no retention of their own: a session keeps every run it
556
+ * launched, grouped by outcome, for as long as the session lives. A run leaves
557
+ * the view when the engine prunes its registry directory, not on a timer here,
558
+ * because "what did this session run today" is a question asked long after the
559
+ * run settled.
560
+ */
561
+ const WORKFLOW_ERROR_RETENTION_MS = 864e5;
562
+ /** The env key po-workflow stamps on runs it launches; ties a run to a Pi session. */
563
+ const PI_SESSION_ENV = "PI_SESSION_ID";
564
+ const PROGRESS_STATES = /* @__PURE__ */ new Set([
565
+ "running",
566
+ "completed",
567
+ "skipped",
568
+ "failed",
569
+ "pause_requested",
570
+ "paused",
571
+ "resumed"
572
+ ]);
573
+ const OUTCOMES = /* @__PURE__ */ new Set([
574
+ "success",
575
+ "skipped",
576
+ "failed",
577
+ "interrupted"
578
+ ]);
579
+ const EXECUTION_STATES = /* @__PURE__ */ new Set([
580
+ "running",
581
+ "pause_requested",
582
+ "paused",
583
+ "resume_requested"
584
+ ]);
585
+ const STEP_TERMINAL_STATES = /* @__PURE__ */ new Set([
586
+ "completed",
587
+ "skipped",
588
+ "failed"
589
+ ]);
590
+ /** The engine records its pre:/post: blocks under these reserved pseudo-job names. */
591
+ const PHASE_JOB_PRE = "pre";
592
+ const PHASE_JOB_POST = "post";
593
+ const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*[A-Za-z]/g;
594
+ /** Where workflow-mcp keeps its registry, mirroring the engine's own resolution. */
595
+ function resolveWorkflowHome(input) {
596
+ return resolve(input.envValue !== void 0 && input.envValue !== "" ? input.envValue : resolve(input.homeDir, DEFAULT_WORKFLOW_HOME_DIR_NAME));
597
+ }
598
+ function isRecord(value) {
599
+ return typeof value === "object" && value !== null && !Array.isArray(value);
600
+ }
601
+ function asOptionalString(value) {
602
+ return typeof value === "string" && value !== "" ? value : void 0;
603
+ }
604
+ function asOptionalNumber(value) {
605
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
606
+ }
607
+ /** Text without its terminal escape codes, as a log file or a reader wants it. */
608
+ function withoutAnsi(text) {
609
+ return text.replace(ANSI_ESCAPE_PATTERN, "");
610
+ }
611
+ function stripAnsi(text) {
612
+ return withoutAnsi(text).trim();
613
+ }
614
+ /**
615
+ * Validates one run.json into the wire shape plus scoping facts.
616
+ *
617
+ * Returns undefined for anything unreadable or a foreign format. The record
618
+ * is trusted for its own stage: the registry rewrites it when a run moves
619
+ * between stage directories.
620
+ */
621
+ function parseWorkflowRunRecord(raw) {
622
+ let parsed;
623
+ try {
624
+ parsed = JSON.parse(raw);
625
+ } catch {
626
+ return;
627
+ }
628
+ if (!isRecord(parsed)) return void 0;
629
+ const runKey = asOptionalString(parsed.runKey);
630
+ const workspace = asOptionalString(parsed.workspace);
631
+ const workflowPath = asOptionalString(parsed.workflowPath);
632
+ const startedAt = asOptionalString(parsed.startedAt);
633
+ const stage = asOptionalString(parsed.stage);
634
+ if (!runKey || !workspace || !workflowPath || !startedAt) return void 0;
635
+ if (stage === void 0 || !WORKFLOW_STAGES.includes(stage)) return void 0;
636
+ const outcome = asOptionalString(parsed.outcome);
637
+ const executionState = asOptionalString(parsed.executionState);
638
+ const rawError = asOptionalString(parsed.errorMessage);
639
+ const env = isRecord(parsed.env) ? parsed.env : void 0;
640
+ const view = {
641
+ ...asOptionalString(parsed.runId) === void 0 ? {} : { runId: asOptionalString(parsed.runId) },
642
+ runKey,
643
+ workspace,
644
+ displayName: asOptionalString(parsed.displayName) ?? runKey,
645
+ ...asOptionalString(parsed.workflowName) === void 0 ? {} : { workflowName: asOptionalString(parsed.workflowName) },
646
+ workflowPath,
647
+ stage,
648
+ ...outcome !== void 0 && OUTCOMES.has(outcome) ? { outcome } : {},
649
+ ...executionState !== void 0 && EXECUTION_STATES.has(executionState) ? { executionState } : {},
650
+ ...asOptionalString(parsed.prompt) === void 0 ? {} : { prompt: asOptionalString(parsed.prompt) },
651
+ startedAt,
652
+ ...asOptionalString(parsed.finishedAt) === void 0 ? {} : { finishedAt: asOptionalString(parsed.finishedAt) },
653
+ ...rawError === void 0 ? {} : { errorMessage: stripAnsi(rawError) },
654
+ ...asOptionalString(parsed.failedJob) === void 0 ? {} : { failedJob: asOptionalString(parsed.failedJob) },
655
+ ...parsed.stale === true ? { stale: true } : {},
656
+ ...asOptionalString(parsed.staleReason) === void 0 ? {} : { staleReason: asOptionalString(parsed.staleReason) },
657
+ ...asOptionalString(parsed.worktreeBranch) === void 0 ? {} : { worktreeBranch: asOptionalString(parsed.worktreeBranch) },
658
+ jobs: []
659
+ };
660
+ const piSessionId = env === void 0 ? void 0 : asOptionalString(env[PI_SESSION_ENV]);
661
+ const launcherSessionId = env === void 0 ? void 0 : asOptionalString(env[WORKFLOW_LAUNCHER_SESSION_ENV]);
662
+ if (piSessionId !== void 0) view.ownerSessionId = piSessionId;
663
+ if (launcherSessionId !== void 0) view.launcherSessionId = launcherSessionId;
664
+ return {
665
+ view,
666
+ ...piSessionId === void 0 ? {} : { piSessionId },
667
+ ...launcherSessionId === void 0 ? {} : { launcherSessionId }
668
+ };
669
+ }
670
+ function parseStepRef(value) {
671
+ if (!isRecord(value)) return void 0;
672
+ const kind = asOptionalString(value.kind);
673
+ const id = asOptionalString(value.id);
674
+ if (!kind || !id) return void 0;
675
+ const label = asOptionalString(value.label);
676
+ return {
677
+ kind,
678
+ id,
679
+ ...label === void 0 ? {} : { label }
680
+ };
681
+ }
682
+ /**
683
+ * Parses the append-only progress log. Malformed lines are skipped rather
684
+ * than failing the file: a reader routinely catches a torn final line
685
+ * mid-append.
686
+ */
687
+ function parseWorkflowProgress(raw) {
688
+ const events = [];
689
+ for (const line of raw.split("\n")) {
690
+ const trimmed = line.trim();
691
+ if (trimmed === "") continue;
692
+ let parsed;
693
+ try {
694
+ parsed = JSON.parse(trimmed);
695
+ } catch {
696
+ continue;
697
+ }
698
+ if (!isRecord(parsed)) continue;
699
+ const type = parsed.type;
700
+ const status = asOptionalString(parsed.status);
701
+ const job = asOptionalString(parsed.job);
702
+ const at = asOptionalString(parsed.at);
703
+ if (type !== "job" && type !== "step" || !job || !at) continue;
704
+ if (status === void 0 || !PROGRESS_STATES.has(status)) continue;
705
+ const step = asOptionalString(parsed.step);
706
+ const index = asOptionalNumber(parsed.index);
707
+ const total = asOptionalNumber(parsed.total);
708
+ const reason = asOptionalString(parsed.reason);
709
+ const ref = parseStepRef(parsed.ref);
710
+ const group = asOptionalString(parsed.group);
711
+ events.push({
712
+ type,
713
+ status,
714
+ job,
715
+ ...step === void 0 ? {} : { step },
716
+ ...index === void 0 ? {} : { index },
717
+ ...total === void 0 ? {} : { total },
718
+ ...reason === void 0 ? {} : { reason },
719
+ ...ref === void 0 ? {} : { ref },
720
+ ...group === void 0 ? {} : { group },
721
+ at
722
+ });
723
+ }
724
+ return events;
725
+ }
726
+ function jobPhase(name) {
727
+ if (name === PHASE_JOB_PRE) return PHASE_JOB_PRE;
728
+ if (name === PHASE_JOB_POST) return PHASE_JOB_POST;
729
+ return "job";
730
+ }
731
+ /** Names match the engine's progress log, including unnamed parallel children. */
732
+ function plannedSteps(steps = []) {
733
+ return steps.flatMap((step, index) => {
734
+ const name = step.name ?? `step ${index + 1}`;
735
+ return step.parallel === void 0 ? [{
736
+ name,
737
+ status: "pending"
738
+ }] : step.parallel.map((child, childIndex) => ({
739
+ name: child.name ?? `${name} ${childIndex + 1}`,
740
+ status: "pending",
741
+ group: name
742
+ }));
743
+ });
744
+ }
745
+ /** The whole declared route, before any job or step has emitted progress. */
746
+ function planWorkflowJobs(workflow, order) {
747
+ const jobs = order.map((name) => {
748
+ const job = workflow.jobs[name];
749
+ return {
750
+ name,
751
+ phase: "job",
752
+ status: "pending",
753
+ steps: [
754
+ ...plannedSteps(job.preJob?.steps),
755
+ ...plannedSteps(job.steps),
756
+ ...plannedSteps(job.postJob?.steps)
757
+ ]
758
+ };
759
+ });
760
+ if (workflow.pre !== void 0) jobs.unshift({
761
+ name: PHASE_JOB_PRE,
762
+ phase: "pre",
763
+ status: "pending",
764
+ steps: plannedSteps(workflow.pre.steps)
765
+ });
766
+ if (workflow.post !== void 0) jobs.push({
767
+ name: PHASE_JOB_POST,
768
+ phase: "post",
769
+ status: "pending",
770
+ steps: plannedSteps(workflow.post.steps)
771
+ });
772
+ return jobs;
773
+ }
774
+ /** Overlay progress on the plan, retaining recorded jobs and steps absent from the current definition. */
775
+ function foldWorkflowProgress(events, plan = []) {
776
+ const jobs = plan.map((job) => ({
777
+ ...job,
778
+ steps: job.steps.map((step) => ({ ...step }))
779
+ }));
780
+ const jobByName = new Map(jobs.map((job) => [job.name, job]));
781
+ for (const event of events) {
782
+ let job = jobByName.get(event.job);
783
+ if (job === void 0) {
784
+ job = {
785
+ name: event.job,
786
+ phase: jobPhase(event.job),
787
+ status: event.status,
788
+ steps: []
789
+ };
790
+ jobByName.set(event.job, job);
791
+ jobs.push(job);
792
+ }
793
+ if (event.type === "job") {
794
+ if (event.status === "running" && (event.index !== void 0 || job.status !== "running")) {
795
+ delete job.reason;
796
+ delete job.endedAt;
797
+ delete job.startedAt;
798
+ }
799
+ job.status = event.status;
800
+ if (event.reason !== void 0) job.reason = event.reason;
801
+ if (event.index !== void 0) job.index = event.index;
802
+ if (event.total !== void 0) job.total = event.total;
803
+ if (event.status === "running" && job.startedAt === void 0) job.startedAt = event.at;
804
+ if (STEP_TERMINAL_STATES.has(event.status)) job.endedAt = event.at;
805
+ continue;
806
+ }
807
+ if (event.step === void 0) continue;
808
+ if (job.status === "pending") job.status = event.status;
809
+ let step = job.steps.find((candidate) => candidate.name === event.step);
810
+ if (step === void 0) {
811
+ step = {
812
+ name: event.step,
813
+ status: event.status
814
+ };
815
+ job.steps.push(step);
816
+ } else if (event.status === "running" && (event.index !== void 0 || step.status !== "running")) {
817
+ delete step.ref;
818
+ delete step.reason;
819
+ delete step.endedAt;
820
+ delete step.startedAt;
821
+ }
822
+ step.status = event.status;
823
+ if (event.reason !== void 0) step.reason = event.reason;
824
+ if (event.ref !== void 0) step.ref = event.ref;
825
+ if (event.group !== void 0) step.group = event.group;
826
+ if (event.status === "running" && step.startedAt === void 0) step.startedAt = event.at;
827
+ if (STEP_TERMINAL_STATES.has(event.status)) step.endedAt = event.at;
828
+ }
829
+ for (const job of jobs) if (job.status === "skipped") {
830
+ for (const step of job.steps) if (step.status === "pending") step.status = "skipped";
831
+ }
832
+ return jobs;
833
+ }
834
+ const ACTIVE_PROGRESS_STATES = /* @__PURE__ */ new Set([
835
+ "running",
836
+ "pause_requested",
837
+ "paused",
838
+ "resumed"
839
+ ]);
840
+ /** The job and step a run is on right now, for the NOW breadcrumb. */
841
+ function workflowPosition(jobs) {
842
+ for (let jobIndex = jobs.length - 1; jobIndex >= 0; jobIndex -= 1) {
843
+ const job = jobs[jobIndex];
844
+ if (!ACTIVE_PROGRESS_STATES.has(job.status)) continue;
845
+ for (let stepIndex = job.steps.length - 1; stepIndex >= 0; stepIndex -= 1) {
846
+ const step = job.steps[stepIndex];
847
+ if (ACTIVE_PROGRESS_STATES.has(step.status)) return {
848
+ job: job.name,
849
+ step: step.name,
850
+ ...job.index === void 0 ? {} : { index: job.index },
851
+ ...job.total === void 0 ? {} : { total: job.total }
852
+ };
853
+ }
854
+ return {
855
+ job: job.name,
856
+ ...job.index === void 0 ? {} : { index: job.index },
857
+ ...job.total === void 0 ? {} : { total: job.total }
858
+ };
859
+ }
860
+ }
861
+ /** The finished view: record fields plus the folded job tree and position. */
862
+ function completeWorkflowRunView(view, jobs) {
863
+ const position = view.stage === "running" ? workflowPosition(jobs) : void 0;
864
+ return {
865
+ ...view,
866
+ jobs,
867
+ ...position === void 0 ? {} : { position }
868
+ };
869
+ }
870
+ /**
871
+ * Whether a run belongs on a session's workflow tab: launched by that Pi
872
+ * session, which is the same env test po-workflow's isSessionRun applies.
873
+ *
874
+ * The registry is one directory under $HOME shared by every repository and
875
+ * every Pi session on the machine, so repository proximity is not ownership.
876
+ * Scoping on it as well let two sessions open in one repo read each other's
877
+ * history, and kept a dead session's runs on the board of every session that
878
+ * replaced it. Fails closed on an unstamped record, which is what a launch
879
+ * from the CLI rather than from a session leaves behind.
880
+ */
881
+ function runBelongsToSession(run, sessionId) {
882
+ return run.piSessionId !== void 0 && run.piSessionId === sessionId;
883
+ }
884
+ /** Whether the session handed this run to a workflow session of its own: it lists the run but does not own it. */
885
+ function runLaunchedBySession(run, sessionId) {
886
+ return run.launcherSessionId !== void 0 && run.launcherSessionId === sessionId && !runBelongsToSession(run, sessionId);
887
+ }
888
+ function parseTime(value) {
889
+ if (value === void 0) return 0;
890
+ const time = Date.parse(value);
891
+ return Number.isFinite(time) ? time : 0;
892
+ }
893
+ const STAGE_ORDER = {
894
+ running: 0,
895
+ error: 1,
896
+ completed: 2
897
+ };
898
+ /**
899
+ * Whether a run still belongs in the view.
900
+ *
901
+ * Only errored runs age out here, and slowly: everything else a session
902
+ * started stays until the engine forgets the run itself.
903
+ */
904
+ function withinRetention(run, now) {
905
+ if (run.stage !== "error") return true;
906
+ return now - (parseTime(run.finishedAt) || parseTime(run.startedAt)) < WORKFLOW_ERROR_RETENTION_MS;
907
+ }
908
+ /**
909
+ * When a run last moved, which is what orders it within its group.
910
+ *
911
+ * A running run has only its start; a settled one is ordered by when it
912
+ * settled, because a list kept for the whole session is read as a history and
913
+ * two runs started together can finish an hour apart.
914
+ */
915
+ function lastMovedAt(run) {
916
+ return run.stage === "running" ? parseTime(run.startedAt) : parseTime(run.finishedAt) || parseTime(run.startedAt);
917
+ }
918
+ /**
919
+ * Running runs first, then errored (recovery acts on them), then finished;
920
+ * newest first within a group, and each group capped on its own.
921
+ */
922
+ function presentWorkflowRuns(runs, now) {
923
+ const groups = /* @__PURE__ */ new Map();
924
+ for (const run of runs) {
925
+ if (!withinRetention(run, now)) continue;
926
+ const group = groups.get(run.stage);
927
+ if (group) group.push(run);
928
+ else groups.set(run.stage, [run]);
929
+ }
930
+ return [...groups.entries()].sort(([left], [right]) => STAGE_ORDER[left] - STAGE_ORDER[right]).flatMap(([, group]) => group.sort((left, right) => lastMovedAt(right) - lastMovedAt(left)).slice(0, 24));
931
+ }
932
+ //#endregion
933
+ //#region src/services/serverLaunch/index.ts
934
+ /** Beside the run's own files, so the run's artifacts list and its deletion both include it. */
935
+ const RUN_LOG_FILENAME = "engine.log";
936
+ /** Exit codes a run ends with when nothing went wrong: finished, or skipped by its triggers. */
937
+ const SUCCESS_EXIT_CODES = /* @__PURE__ */ new Set([0, 2]);
938
+ /** Lines kept before a run has a directory to write them to. */
939
+ const PENDING_LINE_LIMIT = 500;
940
+ /** How long closing a session waits for its in-process runs to stop. */
941
+ const STOP_TIMEOUT_MS = 1e4;
942
+ const SESSION_CLOSED_REASON = "The session that runs this workflow closed.";
943
+ const RUNNING_STAGE$1 = "running";
944
+ const LAUNCH_REQUESTED_EVENT = "doom_workflow.launch_requested";
945
+ const RUN_STARTED_EVENT = "doom_workflow.run_started";
946
+ const RUN_FINISHED_EVENT = "doom_workflow.run_finished";
947
+ const RUN_FAILED_EVENT = "doom_workflow.run_failed";
948
+ const ENGINE_ERROR_EVENT = "doom_workflow.engine_error";
949
+ const ENGINE_WARNING_EVENT = "doom_workflow.engine_warning";
950
+ const RUN_LOG_FAILED_EVENT = "doom_workflow.run_log_failed";
951
+ const RUN_STOP_FAILED_EVENT = "doom_workflow.run_stop_failed";
952
+ function delay$1(ms) {
953
+ return new Promise((settle) => {
954
+ setTimeout(settle, ms).unref?.();
955
+ });
956
+ }
957
+ function textResult$1(text, isError = false) {
958
+ return {
959
+ content: [{
960
+ type: "text",
961
+ text
962
+ }],
963
+ ...isError ? { isError: true } : {}
964
+ };
965
+ }
966
+ /**
967
+ * The engine's log for one run, where a reader can find it.
968
+ *
969
+ * The engine writes why a step failed as a log line. Without a logger, those
970
+ * lines exist only in the output the run returns at its end, so a run that is
971
+ * still going, or one read later, has nothing to say about its failures. Here
972
+ * every line goes to the run directory's `engine.log`, and errors and warnings
973
+ * also go to telemetry, with the line as the exception so the text survives.
974
+ */
975
+ function createRunLog(telemetry) {
976
+ const pending = [];
977
+ let file;
978
+ let writing = Promise.resolve();
979
+ const reportLogFailure = (error) => telemetry?.recordWarning(RUN_LOG_FAILED_EVENT, error) ?? Promise.resolve();
980
+ const append = (line) => {
981
+ if (file === void 0) {
982
+ pending.push(line);
983
+ if (pending.length > PENDING_LINE_LIMIT) pending.shift();
984
+ return;
985
+ }
986
+ const target = file;
987
+ writing = writing.then(() => fs.promises.appendFile(target, `${line}\n`, "utf8")).catch(reportLogFailure);
988
+ };
989
+ return {
990
+ logger: {
991
+ info: (message) => append(withoutAnsi(message)),
992
+ warn: (message) => {
993
+ const line = withoutAnsi(message);
994
+ append(line);
995
+ telemetry?.recordWarning(ENGINE_WARNING_EVENT, new Error(line.trim()));
996
+ },
997
+ error: (message) => {
998
+ const line = withoutAnsi(message);
999
+ append(line);
1000
+ telemetry?.recordError(ENGINE_ERROR_EVENT, new Error(line.trim()), {}, { includeException: true });
1001
+ }
1002
+ },
1003
+ attach(target) {
1004
+ file = target;
1005
+ const held = pending.splice(0);
1006
+ if (held.length === 0) return;
1007
+ writing = writing.then(() => fs.promises.appendFile(target, held.map((line) => `${line}\n`).join(""), "utf8")).catch(reportLogFailure);
1008
+ },
1009
+ flushed: () => writing
1010
+ };
1011
+ }
1012
+ /**
1013
+ * Runs workflows inside the Po server for one session.
1014
+ *
1015
+ * It is the same launch the Pi extension runs, through the same executor:
1016
+ * the same Agiflow checks, capacity limit and runner check, and the same
1017
+ * answer once the run registers. What the server adds is where the run
1018
+ * happens, in this process with the host's step executor, and what it keeps:
1019
+ * each run's engine log, telemetry for its start and end, a notice when a run
1020
+ * fails after its launch already answered, and a stop for every run still
1021
+ * going when the session closes.
1022
+ */
1023
+ function createServerLauncher(dependencies) {
1024
+ const { feature, telemetry } = dependencies;
1025
+ const active = /* @__PURE__ */ new Map();
1026
+ let closing = false;
1027
+ const stopRun = async (run) => {
1028
+ if (run.stopRequested) return;
1029
+ try {
1030
+ const record = run.registration ?? (run.target ? await feature.registry.readRunByKey(run.target.workspace, RUNNING_STAGE$1, run.target.runKey) : void 0);
1031
+ if (!record || !record.runId || run.target && "env" in record && record.env?.["PI_SESSION_ID"] !== dependencies.sessionId) return;
1032
+ await feature.registry.requestStop(record.workspace, record.runKey, SESSION_CLOSED_REASON, record.runId);
1033
+ run.stopRequested = true;
1034
+ } catch (error) {
1035
+ if (error instanceof Error && ("code" in error && error.code === "ENOENT" || error.message.startsWith("Workflow run not found: "))) return;
1036
+ await telemetry?.recordWarning(RUN_STOP_FAILED_EVENT, error);
1037
+ }
1038
+ };
1039
+ const runInProcess = async (input) => {
1040
+ const launchId = input.env?.[WORKFLOW_LAUNCH_ID_ENV];
1041
+ if (launchId === void 0) throw new Error("An in-process launch needs its launch id.");
1042
+ const log = createRunLog(telemetry);
1043
+ const run = { settled: Promise.resolve() };
1044
+ active.set(launchId, run);
1045
+ const runner = input.runner ?? input.cliAgent;
1046
+ const execution = (async () => {
1047
+ try {
1048
+ const result = await feature.createRunService({ logger: log.logger }).run({
1049
+ ...input,
1050
+ ...runner === void 0 ? {} : { runner },
1051
+ skipLaunch: true,
1052
+ onRegistered: (registration) => {
1053
+ run.registration = registration;
1054
+ if (closing) stopRun(run);
1055
+ log.attach(path.join(registration.runDir, RUN_LOG_FILENAME));
1056
+ telemetry?.recordEvent(RUN_STARTED_EVENT, { outcome: "started" });
1057
+ dependencies.onRunsChanged?.();
1058
+ }
1059
+ });
1060
+ if (SUCCESS_EXIT_CODES.has(result.exitCode)) {
1061
+ telemetry?.recordEvent(RUN_FINISHED_EVENT, {
1062
+ outcome: "success",
1063
+ exit_code: result.exitCode
1064
+ });
1065
+ return textResult$1(withoutAnsi(result.output) || "Workflow completed.");
1066
+ }
1067
+ const tail = withoutAnsi(result.output).trim().split("\n").slice(-20).join("\n");
1068
+ telemetry?.recordError(RUN_FAILED_EVENT, new Error(tail || `The workflow exited with code ${String(result.exitCode)}.`), { exit_code: result.exitCode }, { includeException: true });
1069
+ return textResult$1(`Workflow failed (exit code ${String(result.exitCode)}):\n\n${withoutAnsi(result.output)}`, true);
1070
+ } catch (error) {
1071
+ telemetry?.recordError(RUN_FAILED_EVENT, error, { outcome: "error" }, { includeException: true });
1072
+ throw error;
1073
+ } finally {
1074
+ await log.flushed();
1075
+ active.delete(launchId);
1076
+ dependencies.onRunsChanged?.();
1077
+ }
1078
+ })();
1079
+ run.settled = execution.then(() => void 0, () => void 0);
1080
+ return execution;
1081
+ };
1082
+ const executor = createWorkflowLaunchExecutor({
1083
+ environment: dependencies.environment,
1084
+ activeRunCount: async () => active.size,
1085
+ ...dependencies.rejectRunner === void 0 ? {} : { rejectRunner: dependencies.rejectRunner },
1086
+ runTool: { execute: runInProcess },
1087
+ trackPendingRun: (pending) => pending,
1088
+ findLaunchedRun: async ({ launchId }) => {
1089
+ const registration = active.get(launchId)?.registration;
1090
+ if (registration === void 0) return void 0;
1091
+ return feature.registry.readRunByKey(registration.workspace, RUNNING_STAGE$1, registration.runKey);
1092
+ },
1093
+ onLateFailure: (error) => {
1094
+ const detail = error instanceof Error ? error.message : String(error);
1095
+ dependencies.notify(`Workflow launch failed after it was reported started.\n${detail}`, "warning");
1096
+ },
1097
+ onLaunch: () => {
1098
+ telemetry?.recordEvent(LAUNCH_REQUESTED_EVENT, { outcome: "requested" });
1099
+ dependencies.onRunsChanged?.();
1100
+ },
1101
+ ...dependencies.launchAckPollMs === void 0 ? {} : { launchAckPollMs: dependencies.launchAckPollMs },
1102
+ ...dependencies.launchAckTimeoutMs === void 0 ? {} : { launchAckTimeoutMs: dependencies.launchAckTimeoutMs }
1103
+ });
1104
+ const context = { sessionManager: { getSessionId: () => dependencies.sessionId } };
1105
+ return {
1106
+ launch: (input) => executor.execute(input, context),
1107
+ async recover(target, options) {
1108
+ if (closing) return textResult$1("The workflow session is closing.", true);
1109
+ const tool = dependencies.createRecoverTool({ ownerSessionId: () => dependencies.sessionId });
1110
+ if (options.dryRun) return tool.execute({
1111
+ ...target,
1112
+ ...options
1113
+ });
1114
+ const key = `recover:${randomUUID()}`;
1115
+ const run = {
1116
+ target,
1117
+ settled: Promise.resolve()
1118
+ };
1119
+ active.set(key, run);
1120
+ const execution = Promise.resolve().then(() => tool.execute({
1121
+ ...target,
1122
+ ...options
1123
+ })).finally(() => {
1124
+ active.delete(key);
1125
+ dependencies.onRunsChanged?.();
1126
+ });
1127
+ run.settled = execution.then(() => void 0, () => void 0);
1128
+ const find = async () => {
1129
+ const record = await feature.registry.readRunByKey(target.workspace, RUNNING_STAGE$1, target.runKey);
1130
+ return record?.env?.["PI_SESSION_ID"] === dependencies.sessionId ? record : void 0;
1131
+ };
1132
+ (async () => {
1133
+ while (active.has(key)) {
1134
+ if (closing) await stopRun(run);
1135
+ await Promise.race([run.settled, delay$1(dependencies.launchAckPollMs ?? 500)]);
1136
+ }
1137
+ })();
1138
+ const settled = foldOutcome(execution);
1139
+ const ack = await awaitRunAck(settled, find, dependencies.launchAckPollMs, dependencies.launchAckTimeoutMs);
1140
+ if (ack.kind === "settled") {
1141
+ if (ack.outcome.kind === "error") throw ack.outcome.error instanceof Error ? ack.outcome.error : new Error(String(ack.outcome.error));
1142
+ return ack.outcome.result;
1143
+ }
1144
+ if (ack.kind === "record") dependencies.onRunsChanged?.();
1145
+ settled.then((outcome) => {
1146
+ if (outcome.kind === "error" || outcome.result.isError) {
1147
+ const detail = outcome.kind === "error" ? String(outcome.error) : JSON.stringify(outcome.result.content);
1148
+ dependencies.notify(`Workflow recovery failed after acknowledgement.\n${detail}`, "warning");
1149
+ }
1150
+ });
1151
+ return textResult$1(ack.kind === "record" ? `Recovery started for ${ack.record.displayName} in workspace ${target.workspace}. Run key: ${target.runKey}. Started is not succeeded; check workflow_run status before reporting.` : "Recovery requested, not running yet; nothing was cancelled; do not recover it again.");
1152
+ },
1153
+ activeRunCount: () => active.size,
1154
+ async dispose() {
1155
+ closing = true;
1156
+ const runs = [...active.values()];
1157
+ if (runs.length === 0) return;
1158
+ await Promise.all(runs.map(stopRun));
1159
+ await Promise.race([Promise.all(runs.map((run) => run.settled)), delay$1(dependencies.stopTimeoutMs ?? STOP_TIMEOUT_MS)]);
1160
+ }
1161
+ };
1162
+ }
1163
+ //#endregion
1164
+ //#region src/types/webWorkflows.ts
1165
+ /** A customRun step ran as this child session. */
1166
+ const STEP_SESSION_REF_KIND = "session";
1167
+ /** A command step ran in this terminal pane. */
1168
+ const STEP_PANE_REF_KIND = "pane";
1169
+ const WORKFLOW_RUNS_TYPE = "workflow_runs";
1170
+ const WORKFLOW_CATALOG_TYPE = "workflow_catalog";
1171
+ //#endregion
1172
+ //#region src/services/stepExecutor/index.ts
1173
+ const STEP_DISPLAY_ENV = "WORKFLOW_STEP_DISPLAY";
1174
+ const WORKFLOW_NAME_ENV = "WORKFLOW_NAME";
1175
+ /** The engine's completion signal for terminal agents; a session this host steers never uses it. */
1176
+ const WORKFLOW_STATUS_FILE_ENV = "WORKFLOW_STATUS_FILE";
1177
+ /** How long a busy step session is left to finish a turn it was given elsewhere, such as web guidance. */
1178
+ const BUSY_RETRY_MS = 2e3;
1179
+ /** How long ending a step's session may take before the step stops waiting for it. */
1180
+ const RELEASE_TIMEOUT_MS = 1e4;
1181
+ /** What the runtime says when a turn is already running; the reminder then waits for it. */
1182
+ const BUSY_SESSION_PATTERN = /\bbusy\b|LaneBusy/i;
1183
+ const STEP_SESSION_STARTED_EVENT = "doom_workflow.step_session_started";
1184
+ const STEP_SESSION_FAILED_EVENT = "doom_workflow.step_session_failed";
1185
+ const STEP_REMINDER_EVENT = "doom_workflow.step_reminder";
1186
+ const STEP_WAITING_EVENT = "doom_workflow.step_waiting";
1187
+ const STEP_SESSION_RELEASE_FAILED_EVENT = "doom_workflow.step_session_release_failed";
1188
+ const STEP_PANE_FAILED_EVENT = "doom_workflow.step_pane_failed";
1189
+ const PO_KEYS = new Set(PO_RUN_CONFIG_KEYS);
1190
+ /**
1191
+ * A step's `runConfig`, validated against the keys Po understands.
1192
+ *
1193
+ * A templated step's may also carry keys its command template reads. A key
1194
+ * that is neither, such as `majormode`, fails the step rather than running it
1195
+ * on workspace defaults. An engine that does not say what its template reads
1196
+ * leaves such keys unchecked.
1197
+ */
1198
+ function readPoRunConfig(runConfig, template) {
1199
+ const parsed = (template === void 0 ? poRunConfigSchema : poTemplateRunConfigSchema).safeParse(runConfig ?? {});
1200
+ if (!parsed.success) {
1201
+ const problems = parsed.error.issues.map((issue) => `${issue.path.join(".") || "runConfig"}: ${issue.message}`);
1202
+ throw new Error(`Invalid runConfig: ${problems.join("; ")}`);
1203
+ }
1204
+ if (template?.reads !== void 0 && typeof runConfig === "object" && runConfig !== null) {
1205
+ const reads = new Set(template.reads);
1206
+ const unknown = Object.keys(runConfig).filter((key) => !PO_KEYS.has(key) && !reads.has(key));
1207
+ if (unknown.length > 0) throw new Error(`Invalid runConfig: ${unknown.join(", ")} is neither a Po setting nor read by the "${template.name}" command.`);
1208
+ }
1209
+ return parsed.data;
1210
+ }
1211
+ /** The selection a step session is pinned to; unset keys keep the workspace defaults. */
1212
+ function sessionSelection(config) {
1213
+ return {
1214
+ ...config.majorMode === void 0 ? {} : { majorMode: config.majorMode },
1215
+ ...config.minorModes === void 0 ? {} : { minorModes: config.minorModes },
1216
+ ...config.profile === void 0 ? {} : { profile: config.profile },
1217
+ ...config.domains === void 0 ? {} : { domains: config.domains }
1218
+ };
1219
+ }
1220
+ /**
1221
+ * What a step adds to the host environment.
1222
+ *
1223
+ * The step environment is the whole process environment plus the run's own
1224
+ * keys. Handing all of it to the session would pin a copy of the host's
1225
+ * variables into it; the difference is the part the agent actually needs, such
1226
+ * as WORKFLOW_RUN_DIR for the artifacts its prompt names.
1227
+ */
1228
+ function stepEnvironment(env, host) {
1229
+ return Object.fromEntries(Object.entries(env).filter(([key, value]) => host[key] !== value));
1230
+ }
1231
+ function stepSessionName(env, stepName) {
1232
+ const display = env[STEP_DISPLAY_ENV] ?? stepName;
1233
+ const workflow = env[WORKFLOW_NAME_ENV];
1234
+ return workflow ? `${workflow}: ${display}` : display;
1235
+ }
1236
+ function asError(error) {
1237
+ return error instanceof Error ? error : new Error(String(error));
1238
+ }
1239
+ function delay(ms) {
1240
+ return new Promise((settle) => {
1241
+ setTimeout(settle, ms).unref?.();
1242
+ });
1243
+ }
1244
+ /** A promise settled by `settle`, so a stop can end a wait that nothing else would. */
1245
+ function signal() {
1246
+ let settle = () => void 0;
1247
+ return {
1248
+ promise: new Promise((resolve) => {
1249
+ settle = resolve;
1250
+ }),
1251
+ settle
1252
+ };
1253
+ }
1254
+ /**
1255
+ * An execution that already failed, with why.
1256
+ *
1257
+ * Returned instead of throwing: the engine reports a thrown start as a spawn
1258
+ * error about shells and permissions, while a failed completion keeps this
1259
+ * message as the step's reason.
1260
+ */
1261
+ function failedExecution(error) {
1262
+ return {
1263
+ completion: Promise.resolve({ error: asError(error) }),
1264
+ stop: async () => void 0
1265
+ };
1266
+ }
1267
+ /**
1268
+ * Runs workflow steps inside Po instead of a shell.
1269
+ *
1270
+ * A customRun step becomes a child session of the launching session, with the
1271
+ * step's major mode, minor modes, profile, domains and model, so its messages
1272
+ * stream like any other conversation. Command steps go to their own terminal
1273
+ * pane when a launcher is available and fall back to the engine's spawn when
1274
+ * it is not.
1275
+ */
1276
+ function createStepExecutor(dependencies) {
1277
+ const { sessionService, launchPane, telemetry } = dependencies;
1278
+ const prompt = sessionService.prompt?.bind(sessionService);
1279
+ const readExecutionState = sessionService.readExecutionState?.bind(sessionService);
1280
+ /**
1281
+ * Run a step as a child session of the launching session, with the step's
1282
+ * major mode, minor modes, profile, domains and model.
1283
+ *
1284
+ * The session keeps going until the step has what the workflow needs: each
1285
+ * time the agent settles, the engine's decision gate is asked, and while
1286
+ * the step still lacks a decision (or its declared artifacts) the session is
1287
+ * prompted with the gate's reminder. The gate counts reminders against its
1288
+ * cap, so a stuck agent ends and the engine fails the step.
1289
+ */
1290
+ async function runSession(request) {
1291
+ if (prompt === void 0) return failedExecution("This host cannot prompt sessions, so this step cannot run here.");
1292
+ if (readExecutionState === void 0) return failedExecution("This host cannot read execution state, so this step cannot run here.");
1293
+ const attributes = {
1294
+ "workflow.step.name": request.stepName,
1295
+ templated: request.template !== void 0
1296
+ };
1297
+ let scope;
1298
+ let name;
1299
+ try {
1300
+ const config = readPoRunConfig(request.runConfig, request.template);
1301
+ if (config.majorMode === void 0) throw new Error("Agent steps require an effective runConfig.majorMode.");
1302
+ name = stepSessionName(request.env, request.stepName);
1303
+ const environment = stepEnvironment(request.env, dependencies.hostEnvironment);
1304
+ delete environment[WORKFLOW_STATUS_FILE_ENV];
1305
+ environment[WORKFLOW_DECISION_STEERING_ENV] = WORKFLOW_DECISION_STEERING_HOST;
1306
+ scope = await sessionService.create({
1307
+ cwd: request.cwd,
1308
+ name,
1309
+ parentSessionId: dependencies.parentSessionId,
1310
+ sessionProvenance: WORKFLOW_STEP_SESSION_PROVENANCE,
1311
+ selection: sessionSelection(config),
1312
+ ...config.model === void 0 ? {} : { model: config.model },
1313
+ ...config.thinking === void 0 ? {} : { thinking: config.thinking },
1314
+ ...request.systemPrompt === void 0 ? {} : { appendSystemPrompt: request.systemPrompt },
1315
+ environment
1316
+ });
1317
+ } catch (error) {
1318
+ telemetry?.recordError(STEP_SESSION_FAILED_EVENT, error, attributes, { includeException: true });
1319
+ return failedExecution(error);
1320
+ }
1321
+ telemetry?.recordEvent(STEP_SESSION_STARTED_EVENT, {
1322
+ ...attributes,
1323
+ outcome: "started"
1324
+ });
1325
+ const sessionId = scope.sessionId;
1326
+ const stopped = signal();
1327
+ let stopRequested = false;
1328
+ const decided = () => {
1329
+ const path = request.env[WORKFLOW_DECISION_FILE_ENV];
1330
+ return path !== void 0 && readStepDecision(path) !== void 0;
1331
+ };
1332
+ /**
1333
+ * Give the agent the gate's reminder. A session busy with a turn it was
1334
+ * given elsewhere, like web guidance, is left to finish it, then reminded
1335
+ * unless it decided meanwhile. Undefined means the step needs no more turns.
1336
+ */
1337
+ const remind = async (message) => {
1338
+ for (;;) try {
1339
+ return await prompt(sessionId, message);
1340
+ } catch (error) {
1341
+ if (!BUSY_SESSION_PATTERN.test(asError(error).message)) throw error;
1342
+ telemetry?.recordEvent(STEP_REMINDER_EVENT, {
1343
+ ...attributes,
1344
+ outcome: "busy"
1345
+ });
1346
+ await Promise.race([delay(dependencies.busyRetryMs ?? BUSY_RETRY_MS), stopped.promise]);
1347
+ if (stopRequested || decided()) return void 0;
1348
+ }
1349
+ };
1350
+ /**
1351
+ * End the session once the step is over. Released, it stays readable in the
1352
+ * workflow's view without holding a model, tools and extensions in the host.
1353
+ * A host that cannot release it is asked to abort a stopped turn instead.
1354
+ */
1355
+ const endSession = async () => {
1356
+ const ending = sessionService.release ? sessionService.release(sessionId) : stopRequested ? sessionService.abort?.(sessionId) ?? Promise.resolve() : Promise.resolve();
1357
+ const timeout = delay(dependencies.releaseTimeoutMs ?? RELEASE_TIMEOUT_MS).then(() => {
1358
+ throw new Error(`The step session did not end within ${String(dependencies.releaseTimeoutMs ?? RELEASE_TIMEOUT_MS)}ms.`);
1359
+ });
1360
+ try {
1361
+ await Promise.race([ending, timeout]);
1362
+ } catch (error) {
1363
+ telemetry?.recordWarning(STEP_SESSION_RELEASE_FAILED_EVENT, error, attributes);
1364
+ }
1365
+ };
1366
+ const completion = async () => {
1367
+ let receipt = await prompt(sessionId, request.prompt);
1368
+ while (receipt !== void 0) {
1369
+ await Promise.race([receipt.settled, stopped.promise]);
1370
+ if (stopRequested) return { exitCode: 0 };
1371
+ let waiting = false;
1372
+ for (;;) {
1373
+ const state = await Promise.race([readExecutionState(sessionId, { parentSessionId: dependencies.parentSessionId }), stopped.promise.then(() => void 0)]);
1374
+ if (stopRequested) return { exitCode: 0 };
1375
+ const snapshot = state?.backgroundWork;
1376
+ const items = snapshot?.items.filter(holdsSettledSession) ?? [];
1377
+ const errors = snapshot?.errors.filter((error) => BACKGROUND_WORK_HOLDING_PROVIDERS.includes(error.provider)) ?? [];
1378
+ if (state?.isIdle && !state.hasPendingMessages && snapshot !== void 0 && items.length === 0 && errors.length === 0) break;
1379
+ if (!waiting) {
1380
+ waiting = true;
1381
+ telemetry?.recordEvent(STEP_WAITING_EVENT, {
1382
+ ...attributes,
1383
+ items: items.map((item) => `${item.provider}:${item.id}`).join(", "),
1384
+ errors: errors.map((error) => `${error.provider}: ${error.message}`).join(", "),
1385
+ coordinator: snapshot === void 0 ? "missing" : "available"
1386
+ });
1387
+ }
1388
+ await Promise.race([delay(dependencies.busyRetryMs ?? BUSY_RETRY_MS), stopped.promise]);
1389
+ if (stopRequested) return { exitCode: 0 };
1390
+ }
1391
+ const gate = await gateStepDecision(request.env);
1392
+ if (gate.action === "allow") return { exitCode: 0 };
1393
+ telemetry?.recordEvent(STEP_REMINDER_EVENT, {
1394
+ ...attributes,
1395
+ outcome: "sent"
1396
+ });
1397
+ receipt = await remind(gate.message);
1398
+ }
1399
+ return { exitCode: 0 };
1400
+ };
1401
+ return {
1402
+ ref: {
1403
+ kind: STEP_SESSION_REF_KIND,
1404
+ id: sessionId,
1405
+ label: name
1406
+ },
1407
+ completion: completion().catch((error) => ({ error: asError(error) })).then(async (outcome) => {
1408
+ await endSession();
1409
+ return outcome;
1410
+ }),
1411
+ stop: async () => {
1412
+ stopRequested = true;
1413
+ stopped.settle();
1414
+ }
1415
+ };
1416
+ }
1417
+ return {
1418
+ async custom(request) {
1419
+ return runSession({
1420
+ cwd: request.cwd,
1421
+ env: request.env,
1422
+ stepName: request.stepName,
1423
+ runConfig: request.runConfig,
1424
+ prompt: request.customRun.prompt,
1425
+ ...request.customRun.systemPrompt === void 0 ? {} : { systemPrompt: request.customRun.systemPrompt }
1426
+ });
1427
+ },
1428
+ async command(request) {
1429
+ const template = request.template;
1430
+ if (template?.inProcess && prompt !== void 0) return runSession({
1431
+ cwd: request.cwd,
1432
+ env: request.env,
1433
+ stepName: request.stepName,
1434
+ runConfig: request.runConfig,
1435
+ template: {
1436
+ name: template.name,
1437
+ ...template.reads === void 0 ? {} : { reads: template.reads }
1438
+ },
1439
+ prompt: template.prompt,
1440
+ ...template.systemPrompt === void 0 ? {} : { systemPrompt: template.systemPrompt }
1441
+ });
1442
+ if (launchPane === void 0) return void 0;
1443
+ let pane;
1444
+ try {
1445
+ pane = await launchPane({
1446
+ id: dependencies.createId(),
1447
+ command: request.command,
1448
+ cwd: request.cwd,
1449
+ env: request.env
1450
+ });
1451
+ } catch (error) {
1452
+ telemetry?.recordError(STEP_PANE_FAILED_EVENT, error, { "workflow.step.name": request.stepName }, { includeException: true });
1453
+ return failedExecution(error);
1454
+ }
1455
+ if (pane === void 0) return void 0;
1456
+ return {
1457
+ ref: {
1458
+ kind: STEP_PANE_REF_KIND,
1459
+ id: pane.target,
1460
+ label: request.stepName
1461
+ },
1462
+ completion: pane.completion.then((result) => result, (error) => ({ error: asError(error) })),
1463
+ stop: async () => {
1464
+ await pane.stop();
1465
+ }
1466
+ };
1467
+ }
1468
+ };
1469
+ }
1470
+ /** The shell a command step runs in, the same one the engine spawns it with. */
1471
+ const STEP_SHELL = "/bin/zsh";
1472
+ /** Namespace for step pane sockets, apart from whole-run terminal sockets. */
1473
+ const STEP_PANE_SOCKET_SCOPE = "po-workflow-steps";
1474
+ const PANE_LOG_SUFFIX = ".log";
1475
+ const PANE_CAPTURE_COLUMNS = 120;
1476
+ const PANE_CAPTURE_ROWS = 40;
1477
+ /** Where a finished step pane's last screen is kept: beside the socket that served it. */
1478
+ function stepPaneLogPath(socketPath) {
1479
+ return `${socketPath}${PANE_LOG_SUFFIX}`;
1480
+ }
1481
+ /** The pane's current screen, or undefined once there is no pane left to read. */
1482
+ async function lastScreen(service, key) {
1483
+ try {
1484
+ const viewer = await service.attach(key, PANE_CAPTURE_COLUMNS, PANE_CAPTURE_ROWS);
1485
+ const lines = await viewer.snapshot();
1486
+ viewer.detach();
1487
+ return `${lines.join("\n")}\n`;
1488
+ } catch {
1489
+ return;
1490
+ }
1491
+ }
1492
+ /**
1493
+ * Opens each command step in its own RMUX pane, through workflow-mcp's terminal host.
1494
+ *
1495
+ * The pane gets the step's whole environment, which is what lets an agent's
1496
+ * Stop hook end an interactive step through WORKFLOW_STATUS_FILE. A unix
1497
+ * socket serves the pane to the run terminal in any scope; the socket path is
1498
+ * the pane's target. When the step ends or is stopped, its last screen is kept
1499
+ * beside the socket and the pane is released, whatever fails on the way.
1500
+ */
1501
+ function createNativeStepPaneLauncher(service = new NativeTerminalService(), telemetry) {
1502
+ const report = (stage) => (error) => telemetry?.recordWarning(STEP_PANE_FAILED_EVENT, error, { stage }) ?? Promise.resolve();
1503
+ return async (request) => {
1504
+ if (!await service.available()) return void 0;
1505
+ await service.spawn({
1506
+ command: STEP_SHELL,
1507
+ args: ["-c", request.command],
1508
+ cwd: request.cwd,
1509
+ env: { ...request.env },
1510
+ runKey: request.id
1511
+ });
1512
+ const target = terminalSocketPath(STEP_PANE_SOCKET_SCOPE, request.id);
1513
+ let server;
1514
+ try {
1515
+ server = await serveNativeTerminal({
1516
+ service,
1517
+ runKey: request.id,
1518
+ socketPath: target
1519
+ });
1520
+ } catch (error) {
1521
+ await service.kill(request.id).catch(report("kill"));
1522
+ throw error;
1523
+ }
1524
+ let finishing;
1525
+ const finish = () => finishing ??= (async () => {
1526
+ let screen;
1527
+ try {
1528
+ screen = await lastScreen(service, request.id);
1529
+ if (screen !== void 0) await fs.promises.writeFile(stepPaneLogPath(target), screen, "utf8");
1530
+ } catch (error) {
1531
+ await report("keep-screen")(error);
1532
+ } finally {
1533
+ await server.close().catch(report("close-socket"));
1534
+ await service.kill(request.id).catch(report("kill"));
1535
+ }
1536
+ return screen;
1537
+ })();
1538
+ return {
1539
+ target,
1540
+ completion: service.ended(request.id).then(async (exitCode) => {
1541
+ const screen = (await finish())?.trimEnd();
1542
+ return screen ? {
1543
+ exitCode,
1544
+ outputTail: screen
1545
+ } : { exitCode };
1546
+ }),
1547
+ stop: async () => {
1548
+ await finish();
1549
+ return true;
1550
+ }
1551
+ };
1552
+ };
1553
+ }
1554
+ //#endregion
1555
+ //#region src/services/webWorkflowCatalog/index.ts
1556
+ function unchanged(left, right) {
1557
+ return left !== void 0 && right !== void 0 && left.size === right.size && left.modifiedAt === right.modifiedAt;
1558
+ }
1559
+ function createWorkflowCatalogReader(deps) {
1560
+ const cache = /* @__PURE__ */ new Map();
1561
+ const detailFor = (path) => {
1562
+ const stamp = deps.stamp(path);
1563
+ const cached = cache.get(path);
1564
+ if (cached && unchanged(cached.stamp, stamp)) return cached.detail;
1565
+ const detail = deps.summarize(path);
1566
+ if (stamp !== void 0) cache.set(path, {
1567
+ stamp,
1568
+ detail
1569
+ });
1570
+ return detail;
1571
+ };
1572
+ return {
1573
+ async read(directory) {
1574
+ return (await deps.list(directory)).map((entry) => {
1575
+ const detail = detailFor(entry.path);
1576
+ return {
1577
+ path: entry.path,
1578
+ relativePath: entry.relativePath,
1579
+ name: entry.name === "" ? entry.relativePath : entry.name,
1580
+ description: entry.description,
1581
+ tags: entry.tags,
1582
+ triggers: detail.triggers,
1583
+ inputs: detail.inputs,
1584
+ jobs: detail.jobs,
1585
+ artifacts: detail.artifacts,
1586
+ ...detail.runners === void 0 ? {} : { runners: detail.runners },
1587
+ ...detail.commands === void 0 ? {} : { commands: detail.commands },
1588
+ ...detail.choices === void 0 ? {} : { choices: detail.choices },
1589
+ ...detail.error === void 0 ? {} : { error: detail.error }
1590
+ };
1591
+ });
1592
+ },
1593
+ forget(keep) {
1594
+ for (const path of cache.keys()) if (!keep.has(path)) cache.delete(path);
1595
+ }
1596
+ };
1597
+ }
1598
+ /** The launchable rows first, then the ones that would not parse, name ordered. */
1599
+ function presentWorkflowCatalog(entries) {
1600
+ return [...entries].sort((left, right) => {
1601
+ if (left.error === void 0 !== (right.error === void 0)) return left.error === void 0 ? -1 : 1;
1602
+ return left.name.localeCompare(right.name);
1603
+ });
1604
+ }
1605
+ //#endregion
1606
+ //#region src/services/workflowCatalogDeps/index.ts
1607
+ /**
1608
+ * Connect the pure catalog projection service to the workflow engine and filesystem.
1609
+ *
1610
+ * A workflow that parses can still be one Po would fail mid-run, such as
1611
+ * a step whose runConfig misspells `majorMode`. The engine's doctor, told the
1612
+ * keys Po reads, finds those; its errors mark the workflow as one that
1613
+ * cannot launch, in the catalog and at every launch, rather than at the step.
1614
+ */
1615
+ function defaultCatalogDeps() {
1616
+ return {
1617
+ list: (directory) => readWorkflowCatalog(directory),
1618
+ summarize: (workflowPath) => {
1619
+ const detail = summarizeWorkflow(workflowPath);
1620
+ if (detail.error !== void 0) return detail;
1621
+ const errors = diagnoseWorkflow(workflowPath, { runConfigKeys: PO_RUN_CONFIG_KEYS }).diagnostics.filter((diagnostic) => diagnostic.level === "error");
1622
+ if (errors.length === 0) return detail;
1623
+ return {
1624
+ ...detail,
1625
+ error: errors.map((diagnostic) => diagnostic.location === void 0 ? diagnostic.message : `${diagnostic.location}: ${diagnostic.message}`).join("\n")
1626
+ };
1627
+ },
1628
+ stamp: (workflowPath) => {
1629
+ try {
1630
+ const stats = statSync(workflowPath);
1631
+ return {
1632
+ size: stats.size,
1633
+ modifiedAt: stats.mtimeMs
1634
+ };
1635
+ } catch {
1636
+ return;
1637
+ }
1638
+ }
1639
+ };
1640
+ }
1641
+ //#endregion
1642
+ //#region src/services/workflowFence/index.ts
1643
+ const DISPATCHER_AGENT_NAME = "agiflow-dispatcher";
1644
+ function resolveDispatcherParentSession(environment) {
1645
+ return readChildProcessContext(environment)?.parentSessionId ?? (environment[SUBAGENT_PARENT_SESSION_ENV]?.trim() || void 0);
1646
+ }
1647
+ function isWorkflowDispatcherProcess(environment) {
1648
+ if (readChildProcessContext(environment)?.mode === DISPATCHER_AGENT_NAME) return true;
1649
+ return environment.PI_SUBAGENT_CHILD_AGENT === DISPATCHER_AGENT_NAME && Boolean(environment[SUBAGENT_PARENT_SESSION_ENV]?.trim());
1650
+ }
1651
+ //#endregion
1652
+ //#region src/services/stepPaneTerminal/index.ts
1653
+ const PROGRESS_FILENAME = "progress.ndjson";
1654
+ const NATIVE_LAUNCHER = "native";
1655
+ /** Runs whose folded refs are kept between reads; a screen stream reads its run twice a second. */
1656
+ const REFS_CACHE_LIMIT = 256;
1657
+ const PANE_CAPABILITIES = {
1658
+ readable: true,
1659
+ writable: true,
1660
+ resizable: false
1661
+ };
1662
+ const PANE_LOG_CAPABILITIES = {
1663
+ readable: true,
1664
+ writable: false,
1665
+ resizable: false,
1666
+ reason: "That step has finished; this is the output it left."
1667
+ };
1668
+ const SESSION_STEP_REASON = "This step runs as an agent session; its conversation appears here as soon as the session starts.";
1669
+ const PANE_GONE_LINE = "The step pane has closed.";
1670
+ /**
1671
+ * The steps a run is on and its latest pane, folded from its progress log.
1672
+ *
1673
+ * Later events win, as in the engine's own summary. Steps of a parallel group
1674
+ * run at once, so every running step is kept, and the current one is the
1675
+ * latest-started step still running. A step that starts again is a new
1676
+ * attempt: its previous session or pane is not this attempt's, so the ref is
1677
+ * dropped until the new one is recorded. The latest pane is kept after it
1678
+ * finishes so a run between steps, or one that has ended, can still show what
1679
+ * its last command printed.
1680
+ */
1681
+ function stepRefsFrom(events) {
1682
+ const steps = /* @__PURE__ */ new Map();
1683
+ const known = /* @__PURE__ */ new Map();
1684
+ let lastPane;
1685
+ for (const event of events) {
1686
+ if (event.type !== "step" || event.step === void 0) continue;
1687
+ const key = `${event.job}\u0000${event.step}`;
1688
+ const previous = steps.get(key);
1689
+ const running = event.status === "running";
1690
+ const restarted = running && (event.index !== void 0 || previous?.running !== true);
1691
+ if (restarted) steps.delete(key);
1692
+ const ref = event.ref ?? (restarted ? void 0 : previous?.ref);
1693
+ steps.set(key, {
1694
+ running,
1695
+ ...ref === void 0 ? {} : { ref }
1696
+ });
1697
+ if (event.ref !== void 0) known.set(event.ref.id, event.ref);
1698
+ if (event.ref?.kind === "pane") lastPane = event.ref;
1699
+ }
1700
+ const running = [...steps.values()].filter((step) => step.running && step.ref !== void 0).map((step) => step.ref);
1701
+ const current = running.at(-1);
1702
+ return {
1703
+ ...current === void 0 ? {} : { current },
1704
+ ...running.length === 0 ? {} : { running },
1705
+ ...lastPane === void 0 ? {} : { lastPane },
1706
+ ...known.size === 0 ? {} : { known: [...known.values()] }
1707
+ };
1708
+ }
1709
+ /**
1710
+ * Reads a run's refs, folding its progress log again only when the log changed.
1711
+ *
1712
+ * Every screen, keystroke and capability check asks for them, and the log grows
1713
+ * with every fix loop, so an unchanged file (same size and modification time)
1714
+ * answers from the last fold.
1715
+ */
1716
+ function createStepRefsReader() {
1717
+ const cache = /* @__PURE__ */ new Map();
1718
+ return (file) => {
1719
+ let stats;
1720
+ try {
1721
+ stats = fs.statSync(file);
1722
+ } catch {
1723
+ return {};
1724
+ }
1725
+ const cached = cache.get(file);
1726
+ if (cached !== void 0 && cached.size === stats.size && cached.mtimeMs === stats.mtimeMs) return cached.refs;
1727
+ let raw;
1728
+ try {
1729
+ raw = fs.readFileSync(file, "utf8");
1730
+ } catch {
1731
+ return {};
1732
+ }
1733
+ const refs = stepRefsFrom(parseWorkflowProgress(raw));
1734
+ cache.delete(file);
1735
+ cache.set(file, {
1736
+ size: stats.size,
1737
+ mtimeMs: stats.mtimeMs,
1738
+ refs
1739
+ });
1740
+ if (cache.size > REFS_CACHE_LIMIT) cache.delete(cache.keys().next().value);
1741
+ return refs;
1742
+ };
1743
+ }
1744
+ /**
1745
+ * Real dependencies: the registry's run directory, and the step executor's pane sockets.
1746
+ *
1747
+ * A pane is reached through the socket its step was served on, so any scope
1748
+ * of the server can read it, and a finished one through the screen kept
1749
+ * beside that socket.
1750
+ */
1751
+ function createStepPaneTerminalDependencies(registry) {
1752
+ const native = connectNativeTerminal();
1753
+ const readRefs = createStepRefsReader();
1754
+ return {
1755
+ stepRefs: (record) => readRefs(path.join(registry.runDirectoryFor(record), PROGRESS_FILENAME)),
1756
+ paneClient: () => ({
1757
+ capture: (target) => native.snapshot(target, {}).then((lines) => lines.join("\n"), () => void 0),
1758
+ input: (target, text) => native.write(target, text).then(() => true, () => false)
1759
+ }),
1760
+ paneLog: (_record, pane) => fs.promises.readFile(stepPaneLogPath(pane.id), "utf8").catch(() => void 0)
1761
+ };
1762
+ }
1763
+ /** Removes the screens a run's finished panes left behind, once the run itself is deleted. */
1764
+ async function removeStepPaneLogs(refs) {
1765
+ const panes = (refs.known ?? []).filter((ref) => ref.kind === STEP_PANE_REF_KIND);
1766
+ await Promise.all(panes.map((pane) => fs.promises.rm(stepPaneLogPath(pane.id), { force: true })));
1767
+ }
1768
+ function lastLines(text, count) {
1769
+ const lines = text.replace(/\n+$/, "").split("\n");
1770
+ return count === void 0 ? lines : lines.slice(-count);
1771
+ }
1772
+ /**
1773
+ * A run terminal that follows the step a host executor placed.
1774
+ *
1775
+ * A run executing in the server has no launcher pane of its own; each command
1776
+ * step gets one instead. A request naming a step reads that step's pane, which
1777
+ * is how each step of a parallel group gets its own screen and keyboard.
1778
+ * Without one it follows the current step. While a customRun step runs, there
1779
+ * is no terminal to show, so it says where the agent's messages are. Between
1780
+ * steps and after the run, an in-process run shows what its latest command
1781
+ * printed. A run delegated to a terminal launcher keeps the engine's facade.
1782
+ */
1783
+ function createStepPaneTerminal(facade, dependencies) {
1784
+ const inProcess = (record) => record.launcher?.type === NATIVE_LAUNCHER && record.terminalSocket === void 0;
1785
+ const resolveStep = (target) => {
1786
+ const refs = dependencies.stepRefs(target.record);
1787
+ const isRunning = (ref) => refs.running?.some((candidate) => candidate.id === ref.id) === true;
1788
+ if (target.step !== void 0) {
1789
+ const named = refs.known?.find((ref) => ref.id === target.step);
1790
+ if (named !== void 0) return {
1791
+ ref: named,
1792
+ running: isRunning(named)
1793
+ };
1794
+ }
1795
+ if (refs.current !== void 0) return {
1796
+ ref: refs.current,
1797
+ running: true
1798
+ };
1799
+ if (refs.lastPane !== void 0 && inProcess(target.record)) return {
1800
+ ref: refs.lastPane,
1801
+ running: false
1802
+ };
1803
+ };
1804
+ const readLog = async (record, pane, lines) => {
1805
+ const log = await dependencies.paneLog(record, pane);
1806
+ return log === void 0 ? [PANE_GONE_LINE] : lastLines(log, lines);
1807
+ };
1808
+ return {
1809
+ capabilities(target) {
1810
+ const step = resolveStep(target);
1811
+ if (step === void 0) return facade.capabilities(target.record);
1812
+ if (step.ref.kind === "session") return {
1813
+ readable: false,
1814
+ writable: false,
1815
+ resizable: false,
1816
+ reason: SESSION_STEP_REASON
1817
+ };
1818
+ return step.running ? PANE_CAPABILITIES : PANE_LOG_CAPABILITIES;
1819
+ },
1820
+ async screen(target, options) {
1821
+ const step = resolveStep(target);
1822
+ if (step === void 0) return facade.screen(target.record, options);
1823
+ if (step.ref.kind === "session") return [SESSION_STEP_REASON];
1824
+ if (step.running) {
1825
+ const screen = await dependencies.paneClient(target.record).capture(step.ref.id);
1826
+ if (screen !== void 0) return lastLines(screen, options.lines);
1827
+ }
1828
+ return readLog(target.record, step.ref, options.lines);
1829
+ },
1830
+ async write(target, data) {
1831
+ const step = resolveStep(target);
1832
+ if (step === void 0) return facade.write(target.record, data);
1833
+ if (step.ref.kind !== "pane" || !step.running) throw new Error(PANE_GONE_LINE);
1834
+ if (!await dependencies.paneClient(target.record).input(step.ref.id, data)) throw new Error(PANE_GONE_LINE);
1835
+ },
1836
+ async resize(target, columns, rows) {
1837
+ if (resolveStep(target) !== void 0) return false;
1838
+ return facade.resize(target.record, columns, rows);
1839
+ }
1840
+ };
1841
+ }
1842
+ //#endregion
1843
+ //#region src/services/workflowTerminal/index.ts
1844
+ const DEFAULT_REFRESH_MS = 750;
1845
+ const DEFAULT_LEASE_MS = 6e4;
1846
+ const NOT_HELD = "Another reader holds the keyboard for this run.";
1847
+ function createWorkflowTerminalService(deps) {
1848
+ const refreshMs = deps.refreshMs ?? DEFAULT_REFRESH_MS;
1849
+ const leaseMs = deps.leaseMs ?? DEFAULT_LEASE_MS;
1850
+ const screens = /* @__PURE__ */ new Map();
1851
+ const leases = /* @__PURE__ */ new Map();
1852
+ const heldBy = (identity) => {
1853
+ const lease = leases.get(identity);
1854
+ if (lease === void 0) return void 0;
1855
+ if (lease.expiresAt > deps.now()) return lease;
1856
+ leases.delete(identity);
1857
+ };
1858
+ return {
1859
+ async screen(identity, record, lines) {
1860
+ const entry = screens.get(identity) ?? { lines: [] };
1861
+ screens.set(identity, entry);
1862
+ if (entry.inFlight) return entry.lines;
1863
+ if (entry.at !== void 0 && deps.now() - entry.at < refreshMs) return entry.lines;
1864
+ entry.inFlight = deps.terminal.screen(record, { lines });
1865
+ try {
1866
+ entry.lines = await entry.inFlight;
1867
+ } finally {
1868
+ entry.at = deps.now();
1869
+ entry.inFlight = void 0;
1870
+ }
1871
+ return entry.lines;
1872
+ },
1873
+ capabilities(record) {
1874
+ return deps.terminal.capabilities(record);
1875
+ },
1876
+ takeControl(identity, token) {
1877
+ const holder = heldBy(identity);
1878
+ if (holder !== void 0 && holder.token !== token) return false;
1879
+ leases.set(identity, {
1880
+ token,
1881
+ expiresAt: deps.now() + leaseMs
1882
+ });
1883
+ return true;
1884
+ },
1885
+ releaseControl(identity, token) {
1886
+ if (heldBy(identity)?.token === token) leases.delete(identity);
1887
+ },
1888
+ async write(identity, record, token, data) {
1889
+ const holder = heldBy(identity);
1890
+ if (holder === void 0 || holder.token !== token) throw new Error(NOT_HELD);
1891
+ leases.set(identity, {
1892
+ token,
1893
+ expiresAt: deps.now() + leaseMs
1894
+ });
1895
+ await deps.terminal.write(record, data);
1896
+ },
1897
+ async resize(identity, record, token, columns, rows) {
1898
+ const holder = heldBy(identity);
1899
+ if (holder === void 0 || holder.token !== token) throw new Error(NOT_HELD);
1900
+ return deps.terminal.resize(record, columns, rows);
1901
+ },
1902
+ forget(live) {
1903
+ for (const identity of screens.keys()) if (!live.has(identity)) screens.delete(identity);
1904
+ for (const identity of leases.keys()) if (!live.has(identity)) leases.delete(identity);
1905
+ },
1906
+ forgetRun(identity) {
1907
+ screens.delete(identity);
1908
+ leases.delete(identity);
1909
+ }
1910
+ };
1911
+ }
1912
+ //#endregion
1913
+ //#region src/types/apiRoutes.ts
1914
+ /**
1915
+ * This package's routes, as data.
1916
+ *
1917
+ * No scope and no base path. The build reads both off
1918
+ * `src/extensions/(backend)/api/workflow/`, so the mount is stated once, by the
1919
+ * folder that creates it. The same API answers at all three scopes, because a
1920
+ * global contribution cascades into workspace and session; the folder under
1921
+ * `workspaces/sessions/` only re-mounts it for a session's own server.
1922
+ *
1923
+ * This is the one file the routes, the Hono app and the browser all read, so a
1924
+ * path cannot move on one side alone. The page used to build the hub's own
1925
+ * absolute URL and then rewrite its plugin prefix with String.replace to reach
1926
+ * a session, which is exactly the kind of copy that can move by one character
1927
+ * without anything noticing.
1928
+ *
1929
+ * `:workspace` and `:runKey` are Hono's own parameter syntax, because the app
1930
+ * registers `path` verbatim.
1931
+ */
1932
+ /** One run, which every route below addresses. */
1933
+ const RUN = "/runs/:workspace/:runKey";
1934
+ /**
1935
+ * The artifact route's parameter, as Hono spells a multi-segment match.
1936
+ *
1937
+ * An artifact is named by its path inside the run directory, so `reports/a.md`
1938
+ * is one parameter across two segments. The client's `params` substitution
1939
+ * fills a single segment and percent-encodes the slash with it, so the page
1940
+ * substitutes this one itself; see the page's `_lib/terminalApi.ts`.
1941
+ */
1942
+ const ARTIFACT_NAME_PARAM = ":name{.+}";
1943
+ /**
1944
+ * The step whose pane a terminal route reads: a step ref's id. Absent, the
1945
+ * route follows the run's current step, which is one of a parallel group's.
1946
+ */
1947
+ const STEP_TARGET_PARAM = "step";
1948
+ var apiRoutes_default = defineApiRoutes({
1949
+ /**
1950
+ * The terminal, as server-sent events.
1951
+ *
1952
+ * `stream: true` gets a URL and no call method: the consumer is the browser's
1953
+ * own `EventSource`, and a relayed call would buffer a stream that never ends.
1954
+ */
1955
+ screen: {
1956
+ method: "GET",
1957
+ path: `${RUN}/screen/stream`,
1958
+ query: [STEP_TARGET_PARAM],
1959
+ stream: true
1960
+ },
1961
+ control: {
1962
+ method: "POST",
1963
+ path: `${RUN}/control`,
1964
+ response: apiResponse()
1965
+ },
1966
+ keys: {
1967
+ method: "POST",
1968
+ path: `${RUN}/keys`
1969
+ },
1970
+ resize: {
1971
+ method: "POST",
1972
+ path: `${RUN}/resize`,
1973
+ response: apiResponse()
1974
+ },
1975
+ remove: {
1976
+ method: "DELETE",
1977
+ path: RUN,
1978
+ response: apiResponse()
1979
+ },
1980
+ stop: {
1981
+ method: "POST",
1982
+ path: `${RUN}/stop`,
1983
+ response: apiResponse()
1984
+ },
1985
+ /** Guidance for the agent session the running step is in, like steering a Team sub-agent. */
1986
+ steer: {
1987
+ method: "POST",
1988
+ path: `${RUN}/steer`,
1989
+ response: apiResponse()
1990
+ },
1991
+ /**
1992
+ * Launch a workflow into the mounting session's own runtime, which then owns
1993
+ * the run. A dispatcher session reaches its root's session mount through this.
1994
+ */
1995
+ launch: {
1996
+ method: "POST",
1997
+ path: "/launch",
1998
+ response: apiResponse()
1999
+ },
2000
+ openRunDirectory: {
2001
+ method: "POST",
2002
+ path: `${RUN}/open-directory`,
2003
+ response: apiResponse()
2004
+ },
2005
+ artifacts: {
2006
+ method: "GET",
2007
+ path: `${RUN}/artifacts`,
2008
+ query: ["directory"],
2009
+ response: apiResponse()
2010
+ },
2011
+ /** One artifact: its metadata and text, or its raw bytes under `raw=1`. */
2012
+ artifact: {
2013
+ method: "GET",
2014
+ path: `${RUN}/artifacts/${ARTIFACT_NAME_PARAM}`,
2015
+ query: ["raw", "download"],
2016
+ response: apiResponse()
2017
+ }
2018
+ });
2019
+ //#endregion
2020
+ //#region src/types/webWorkflowTerminal.ts
2021
+ /**
2022
+ * The wire contract between this package's hub API and its cockpit client:
2023
+ * one run's terminal, and the files its run directory holds.
2024
+ *
2025
+ * Both halves of the cockpit import these names, so the route strings and the
2026
+ * event names live here rather than being spelled twice.
2027
+ */
2028
+ /**
2029
+ * Segment this package's API is mounted under, below /api/plugins/.
2030
+ *
2031
+ * The same word names `src/extensions/(backend)/api/workflow/`, which is where
2032
+ * the build reads the mount for the browser's client, so the two cannot drift.
2033
+ * The routes themselves are declared in `apiRoutes.ts`.
2034
+ */
2035
+ const WORKFLOW_API_BASE_PATH = "workflow";
2036
+ /** The SSE event name the screen stream writes. */
2037
+ const WORKFLOW_SCREEN_EVENT = "screen";
2038
+ //#endregion
2039
+ //#region src/services/workflowHubApi/index.ts
2040
+ /** Stages a run can be recorded under, newest first: a live run is the common case. */
2041
+ const STAGES = [
2042
+ "running",
2043
+ "error",
2044
+ "completed"
2045
+ ];
2046
+ /** Lines of screen the stream sends, which is a terminal's visible height plus room. */
2047
+ const SCREEN_LINES = 48;
2048
+ /** How often the stream re-reads; the service coalesces, so this is an upper bound. */
2049
+ const STREAM_TICK_MS = 500;
2050
+ /** How often a settled run is re-checked before the stream closes itself. */
2051
+ const SETTLED_POLL_TICKS = 4;
2052
+ /** Bytes of one textual artifact returned as JSON; binary previews use a stream. */
2053
+ const MAX_ARTIFACT_BYTES = 524288;
2054
+ /** Recorded with a stop the web panel asks for, so the run log says where it came from. */
2055
+ const WEB_STOP_REASON = "Stopped from the web workflow panel.";
2056
+ /** Path segments a client supplies are names, never paths. */
2057
+ const SAFE_SEGMENT = /^[\w.@-]+$/;
2058
+ const ARTIFACT_MIME_TYPES = {
2059
+ ".aac": "audio/aac",
2060
+ ".bmp": "image/bmp",
2061
+ ".css": "text/css",
2062
+ ".csv": "text/csv",
2063
+ ".gif": "image/gif",
2064
+ ".htm": "text/html",
2065
+ ".html": "text/html",
2066
+ ".ico": "image/x-icon",
2067
+ ".jpeg": "image/jpeg",
2068
+ ".jpg": "image/jpeg",
2069
+ ".js": "text/javascript",
2070
+ ".json": "application/json",
2071
+ ".jsx": "text/jsx",
2072
+ ".log": "text/plain",
2073
+ ".m4a": "audio/mp4",
2074
+ ".markdown": "text/markdown",
2075
+ ".md": "text/markdown",
2076
+ ".mov": "video/quicktime",
2077
+ ".mp3": "audio/mpeg",
2078
+ ".mp4": "video/mp4",
2079
+ ".oga": "audio/ogg",
2080
+ ".ogg": "audio/ogg",
2081
+ ".ogv": "video/ogg",
2082
+ ".pdf": "application/pdf",
2083
+ ".png": "image/png",
2084
+ ".sh": "text/x-shellscript",
2085
+ ".svg": "image/svg+xml",
2086
+ ".toml": "text/toml",
2087
+ ".ts": "text/typescript",
2088
+ ".tsv": "text/tab-separated-values",
2089
+ ".tsx": "text/tsx",
2090
+ ".txt": "text/plain",
2091
+ ".wav": "audio/wav",
2092
+ ".webm": "video/webm",
2093
+ ".webp": "image/webp",
2094
+ ".xml": "application/xml",
2095
+ ".yaml": "text/yaml",
2096
+ ".yml": "text/yaml"
2097
+ };
2098
+ /**
2099
+ * One request's JSON body as a plain record.
2100
+ *
2101
+ * A body that is absent, malformed, or not an object reads as empty rather
2102
+ * than failing the route: every field is checked by the route anyway, and a
2103
+ * 400 naming the missing field beats a parse error naming nothing.
2104
+ */
2105
+ async function jsonBody(context) {
2106
+ const parsed = await context.req.json().catch(() => void 0);
2107
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
2108
+ }
2109
+ /** Rejects anything that could climb out of the registry before a path is built. */
2110
+ function isSafeSegment(value) {
2111
+ return value !== "" && value !== "../adapters" && value !== ".." && SAFE_SEGMENT.test(value);
2112
+ }
2113
+ /**
2114
+ * One terminal's identity for caches and leases, stable across stage moves: a
2115
+ * run's, or one step's pane in it, which keeps its own screen and keyboard.
2116
+ */
2117
+ function runIdentityOf(workspace, runKey, step) {
2118
+ return step === void 0 ? `${workspace}/${runKey}` : `${workspace}/${runKey}#${step}`;
2119
+ }
2120
+ /** The step a terminal request names, from its query or body; absent follows the current step. */
2121
+ function stepOf(value) {
2122
+ return typeof value === "string" && value !== "" ? value : void 0;
2123
+ }
2124
+ /**
2125
+ * This package's HTTP surface: one workflow run's terminal, and the files its
2126
+ * run directory holds.
2127
+ *
2128
+ * Mounted in the hub rather than in a session's server, because a run's
2129
+ * terminal belongs to its multiplexer rather than to the session that launched
2130
+ * it: any process can read a tmux pane, and the session's own server cannot
2131
+ * reach anything more. The routes therefore name a run, never a session.
2132
+ */
2133
+ function createWorkflowHubApi(options = {}) {
2134
+ const registry = options.registry ?? new WorkflowRegistryService();
2135
+ const facade = options.terminal ?? new WorkflowTerminalService();
2136
+ const stepPanes = createStepPaneTerminalDependencies(registry);
2137
+ const terminal = createWorkflowTerminalService({
2138
+ terminal: createStepPaneTerminal(facade, stepPanes),
2139
+ now: options.now ?? (() => Date.now())
2140
+ });
2141
+ const app = new Hono();
2142
+ /** The record behind a request, whichever stage it now sits in. */
2143
+ const runOf = async (workspace, runKey) => {
2144
+ if (!isSafeSegment(workspace) || !isSafeSegment(runKey)) return void 0;
2145
+ for (const stage of STAGES) try {
2146
+ return await registry.readRunByKey(workspace, stage, runKey);
2147
+ } catch {}
2148
+ };
2149
+ const missing = (workspace, runKey) => ({ error: `No workflow run '${runKey}' in workspace '${workspace}'.` });
2150
+ app.get(apiRoutes_default.screen.path, async (context) => {
2151
+ const workspace = context.req.param("workspace");
2152
+ const runKey = context.req.param("runKey");
2153
+ const record = await runOf(workspace, runKey);
2154
+ if (record === void 0) return context.json(missing(workspace, runKey), 404);
2155
+ const step = stepOf(context.req.query(STEP_TARGET_PARAM));
2156
+ const identity = runIdentityOf(workspace, runKey, step);
2157
+ return streamSSE(context, async (stream) => {
2158
+ let settledTicks = 0;
2159
+ let running = true;
2160
+ let latest = record;
2161
+ stream.onAbort(() => {
2162
+ running = false;
2163
+ });
2164
+ while (running) {
2165
+ const current = await runOf(workspace, runKey) ?? record;
2166
+ latest = current;
2167
+ const target = {
2168
+ record: current,
2169
+ ...step === void 0 ? {} : { step }
2170
+ };
2171
+ const capabilities = terminal.capabilities(target);
2172
+ const lines = capabilities.readable ? await terminal.screen(identity, target, SCREEN_LINES) : [];
2173
+ const settled = current.stage !== "running";
2174
+ if (settled) settledTicks += 1;
2175
+ const ended = settled && settledTicks >= SETTLED_POLL_TICKS;
2176
+ const event = {
2177
+ lines,
2178
+ capabilities,
2179
+ ...ended ? { ended: true } : {}
2180
+ };
2181
+ await stream.writeSSE({
2182
+ event: WORKFLOW_SCREEN_EVENT,
2183
+ data: JSON.stringify(event)
2184
+ });
2185
+ if (ended) break;
2186
+ await stream.sleep(STREAM_TICK_MS);
2187
+ }
2188
+ if (latest.stage !== "running") terminal.forgetRun(identity);
2189
+ });
2190
+ });
2191
+ app.post(apiRoutes_default.control.path, async (context) => {
2192
+ const workspace = context.req.param("workspace");
2193
+ const runKey = context.req.param("runKey");
2194
+ const record = await runOf(workspace, runKey);
2195
+ if (record === void 0) return context.json(missing(workspace, runKey), 404);
2196
+ const body = await jsonBody(context);
2197
+ const step = stepOf(body.step);
2198
+ const identity = runIdentityOf(workspace, runKey, step);
2199
+ const capabilities = terminal.capabilities({
2200
+ record,
2201
+ ...step === void 0 ? {} : { step }
2202
+ });
2203
+ if (body.release === true) {
2204
+ if (typeof body.token === "string") terminal.releaseControl(identity, body.token);
2205
+ return context.json({ held: false });
2206
+ }
2207
+ if (!capabilities.writable) {
2208
+ const refused = {
2209
+ held: false,
2210
+ reason: capabilities.reason
2211
+ };
2212
+ return context.json(refused, 409);
2213
+ }
2214
+ const token = typeof body.token === "string" && body.token !== "" ? body.token : randomUUID();
2215
+ if (!terminal.takeControl(identity, token)) return context.json({
2216
+ held: false,
2217
+ reason: "Another reader holds the keyboard."
2218
+ }, 409);
2219
+ const held = {
2220
+ held: true,
2221
+ token
2222
+ };
2223
+ return context.json(held);
2224
+ });
2225
+ app.post(apiRoutes_default.keys.path, async (context) => {
2226
+ const workspace = context.req.param("workspace");
2227
+ const runKey = context.req.param("runKey");
2228
+ const record = await runOf(workspace, runKey);
2229
+ if (record === void 0) return context.json(missing(workspace, runKey), 404);
2230
+ const body = await jsonBody(context);
2231
+ if (typeof body.token !== "string" || typeof body.data !== "string") return context.json({ error: "A keystroke needs a control token and its data." }, 400);
2232
+ const step = stepOf(body.step);
2233
+ try {
2234
+ await terminal.write(runIdentityOf(workspace, runKey, step), {
2235
+ record,
2236
+ ...step === void 0 ? {} : { step }
2237
+ }, body.token, body.data);
2238
+ } catch (error) {
2239
+ return context.json({ error: error instanceof Error ? error.message : String(error) }, 409);
2240
+ }
2241
+ return context.body(null, 204);
2242
+ });
2243
+ app.post(apiRoutes_default.resize.path, async (context) => {
2244
+ const workspace = context.req.param("workspace");
2245
+ const runKey = context.req.param("runKey");
2246
+ const record = await runOf(workspace, runKey);
2247
+ if (record === void 0) return context.json(missing(workspace, runKey), 404);
2248
+ const body = await jsonBody(context);
2249
+ const { token, columns, rows } = body;
2250
+ if (typeof token !== "string" || typeof columns !== "number" || typeof rows !== "number") return context.json({ error: "A resize needs a control token, columns and rows." }, 400);
2251
+ const step = stepOf(body.step);
2252
+ try {
2253
+ const resized = await terminal.resize(runIdentityOf(workspace, runKey, step), {
2254
+ record,
2255
+ ...step === void 0 ? {} : { step }
2256
+ }, token, columns, rows);
2257
+ return context.json({ resized });
2258
+ } catch (error) {
2259
+ return context.json({ error: error instanceof Error ? error.message : String(error) }, 409);
2260
+ }
2261
+ });
2262
+ app.post(apiRoutes_default.stop.path, async (context) => {
2263
+ const workspace = context.req.param("workspace");
2264
+ const runKey = context.req.param("runKey");
2265
+ const record = await runOf(workspace, runKey);
2266
+ if (record === void 0) return context.json(missing(workspace, runKey), 404);
2267
+ if (record.stage !== "running" || record.runId === void 0) return context.json({ error: "Only a running workflow can be stopped." }, 409);
2268
+ try {
2269
+ await registry.requestStop(workspace, runKey, WEB_STOP_REASON, record.runId);
2270
+ } catch (error) {
2271
+ return context.json({ error: error instanceof Error ? error.message : String(error) }, 409);
2272
+ }
2273
+ return context.json({ requested: true });
2274
+ });
2275
+ app.post(apiRoutes_default.steer.path, async (context) => {
2276
+ const workspace = context.req.param("workspace");
2277
+ const runKey = context.req.param("runKey");
2278
+ const record = await runOf(workspace, runKey);
2279
+ if (record === void 0) return context.json(missing(workspace, runKey), 404);
2280
+ const body = await jsonBody(context);
2281
+ const message = typeof body.message === "string" ? body.message.trim() : "";
2282
+ if (message === "") return context.json({ error: "Guidance needs a message." }, 400);
2283
+ if (record.stage !== "running") return context.json({ error: "Only a running workflow can be steered." }, 409);
2284
+ const refs = stepPanes.stepRefs(record);
2285
+ const step = typeof body.step === "string" ? body.step : void 0;
2286
+ const current = step === void 0 ? refs.current : (refs.running ?? []).find((candidate) => candidate.id === step);
2287
+ if (current?.kind !== "session") return context.json({ error: "The running step is not an agent session." }, 409);
2288
+ if (options.steer === void 0) return context.json({ error: "Steering is available from the session that launched this run." }, 409);
2289
+ try {
2290
+ await options.steer(current.id, message);
2291
+ } catch (error) {
2292
+ return context.json({ error: error instanceof Error ? error.message : String(error) }, 409);
2293
+ }
2294
+ return context.json({ delivered: true });
2295
+ });
2296
+ app.post(apiRoutes_default.launch.path, async (context) => {
2297
+ if (options.launch === void 0) return context.json({ error: "Launching needs the mount of a session with a workflow runtime." }, 409);
2298
+ const response = await options.launch(await jsonBody(context));
2299
+ return context.json(response);
2300
+ });
2301
+ app.delete(apiRoutes_default.remove.path, async (context) => {
2302
+ const workspace = context.req.param("workspace");
2303
+ const runKey = context.req.param("runKey");
2304
+ const record = await runOf(workspace, runKey);
2305
+ if (record === void 0) return context.json(missing(workspace, runKey), 404);
2306
+ if (record.stage === "running") return context.json({ error: "A running workflow must be stopped before it can be deleted." }, 409);
2307
+ const refs = stepPanes.stepRefs(record);
2308
+ try {
2309
+ fs.rmSync(registry.runDirectoryFor(record), {
2310
+ recursive: true,
2311
+ force: true
2312
+ });
2313
+ await removeStepPaneLogs(refs);
2314
+ } catch (error) {
2315
+ return context.json({ error: error instanceof Error ? error.message : String(error) }, 409);
2316
+ }
2317
+ return context.json({ deleted: true });
2318
+ });
2319
+ app.post(apiRoutes_default.openRunDirectory.path, async (context) => {
2320
+ if (doomApiCallerFrom(context.req.raw.headers)?.locality !== "local") return context.json({ error: "Opening Finder is only available on the host." }, 403);
2321
+ const workspace = context.req.param("workspace");
2322
+ const runKey = context.req.param("runKey");
2323
+ const record = await runOf(workspace, runKey);
2324
+ if (record === void 0) return context.json(missing(workspace, runKey), 404);
2325
+ if (process.platform !== "darwin") return context.json({ error: "Finder is only available on macOS." }, 409);
2326
+ try {
2327
+ const runDir = path.resolve(registry.runDirectoryFor(record));
2328
+ if (!fs.statSync(runDir).isDirectory()) return context.json({ error: "The run directory is not a folder." }, 409);
2329
+ await new Promise((resolve, reject) => {
2330
+ execFile("/usr/bin/open", [
2331
+ "-a",
2332
+ "Finder",
2333
+ runDir
2334
+ ], { timeout: 5e3 }, (error) => {
2335
+ if (error) reject(error);
2336
+ else resolve();
2337
+ });
2338
+ });
2339
+ return context.json({ opened: true });
2340
+ } catch (error) {
2341
+ return context.json({ error: error instanceof Error ? error.message : String(error) }, 409);
2342
+ }
2343
+ });
2344
+ app.get(apiRoutes_default.artifacts.path, async (context) => {
2345
+ const workspace = context.req.param("workspace");
2346
+ const runKey = context.req.param("runKey");
2347
+ const record = await runOf(workspace, runKey);
2348
+ if (record === void 0) return context.json(missing(workspace, runKey), 404);
2349
+ const runDir = registry.runDirectoryFor(record);
2350
+ const directory = context.req.query("directory") ?? "";
2351
+ if (resolveInside(runDir, directory) === void 0) return context.json({ error: "That folder is not inside this run." }, 400);
2352
+ try {
2353
+ const body = {
2354
+ runDir,
2355
+ description: record.runDirectory?.description ?? "",
2356
+ artifacts: readArtifacts(runDir, record, directory)
2357
+ };
2358
+ return context.json(body);
2359
+ } catch (error) {
2360
+ return context.json({ error: error instanceof Error ? error.message : String(error) }, 409);
2361
+ }
2362
+ });
2363
+ app.get(apiRoutes_default.artifact.path, async (context) => {
2364
+ const workspace = context.req.param("workspace");
2365
+ const runKey = context.req.param("runKey");
2366
+ const record = await runOf(workspace, runKey);
2367
+ if (record === void 0) return context.json(missing(workspace, runKey), 404);
2368
+ const runDir = registry.runDirectoryFor(record);
2369
+ const requested = context.req.param("name");
2370
+ const resolved = resolveInside(runDir, requested);
2371
+ if (resolved === void 0) return context.json({ error: `'${requested}' is not inside this run.` }, 400);
2372
+ if (context.req.query("raw") === "1") return streamArtifact(resolved, requested, context.req.header("range"), context.req.query("download") === "1") ?? context.json({ error: `'${requested}' has not been written.` }, 404);
2373
+ const body = readArtifact(resolved, requested);
2374
+ if (body === void 0) return context.json({ error: `'${requested}' has not been written.` }, 404);
2375
+ return context.json(body);
2376
+ });
2377
+ return app;
2378
+ }
2379
+ /**
2380
+ * A path inside the run directory, or undefined when it points anywhere else.
2381
+ *
2382
+ * The client supplies this one verbatim, unlike the run key, so it is resolved
2383
+ * and then checked rather than trusted: without the check the route would read
2384
+ * any file the hub can reach.
2385
+ */
2386
+ function resolveInside(runDir, requested) {
2387
+ const resolved = path.resolve(runDir, requested);
2388
+ const root = path.resolve(runDir);
2389
+ if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) return void 0;
2390
+ try {
2391
+ const realRoot = fs.realpathSync(root);
2392
+ let ancestor = resolved;
2393
+ while (!fs.existsSync(ancestor)) {
2394
+ const parent = path.dirname(ancestor);
2395
+ if (parent === ancestor) return void 0;
2396
+ ancestor = parent;
2397
+ }
2398
+ const real = fs.realpathSync(ancestor);
2399
+ if (real !== realRoot && !real.startsWith(`${realRoot}${path.sep}`)) return void 0;
2400
+ return resolved;
2401
+ } catch {
2402
+ return;
2403
+ }
2404
+ }
2405
+ /** Size and modification time of one path, or undefined when it is not there. */
2406
+ function statOf(target) {
2407
+ try {
2408
+ return fs.statSync(target);
2409
+ } catch {
2410
+ return;
2411
+ }
2412
+ }
2413
+ function stateOf(stats) {
2414
+ if (stats === void 0) return "pending";
2415
+ if (stats.isDirectory()) return "written";
2416
+ return stats.size === 0 ? "empty" : "written";
2417
+ }
2418
+ function viewOf(runDir, entry) {
2419
+ const target = resolveInside(runDir, entry.path);
2420
+ const stats = target === void 0 ? void 0 : statOf(target);
2421
+ return {
2422
+ ...entry,
2423
+ kind: stats?.isDirectory() ? "directory" : entry.kind,
2424
+ state: target === void 0 ? "unreadable" : stateOf(stats),
2425
+ ...stats === void 0 ? {} : {
2426
+ size: stats.size,
2427
+ modifiedAt: stats.mtime.toISOString()
2428
+ }
2429
+ };
2430
+ }
2431
+ /**
2432
+ * What the run directory holds: the workflow's own declaration first, then the
2433
+ * files nobody declared.
2434
+ *
2435
+ * The declaration is the point of the folder, so it leads and keeps its order
2436
+ * even for entries no job has written yet. The rest follows rather than being
2437
+ * hidden: the engine's own context.md and progress log are often exactly what a
2438
+ * reader is looking for.
2439
+ */
2440
+ function readArtifacts(runDir, record, directory = "") {
2441
+ const declared = (record.runDirectory?.entries ?? []).map((entry) => viewOf(runDir, {
2442
+ path: entry.path,
2443
+ kind: entry.kind,
2444
+ description: entry.description,
2445
+ producedBy: entry["produced-by"] ?? [],
2446
+ declared: true
2447
+ }));
2448
+ const children = declared.filter((entry) => path.posix.dirname(entry.path) === (directory || "."));
2449
+ const claimed = new Set(children.map((entry) => entry.path));
2450
+ const found = fs.readdirSync(path.join(runDir, directory), { withFileTypes: true }).filter((entry) => !claimed.has(path.posix.join(directory, entry.name))).map((entry) => viewOf(runDir, {
2451
+ path: path.posix.join(directory, entry.name),
2452
+ kind: entry.isDirectory() ? "directory" : "file",
2453
+ description: "",
2454
+ producedBy: [],
2455
+ declared: false
2456
+ })).sort((left, right) => left.path.localeCompare(right.path));
2457
+ return [...directory === "" ? declared : children, ...found];
2458
+ }
2459
+ /** Browser content type inferred from a filename without trusting client input. */
2460
+ function artifactMimeType(requested) {
2461
+ return ARTIFACT_MIME_TYPES[path.extname(requested).toLowerCase()] ?? "application/octet-stream";
2462
+ }
2463
+ function isTextArtifact(mimeType) {
2464
+ return mimeType.startsWith("text/") || mimeType === "application/json" || mimeType === "application/xml";
2465
+ }
2466
+ /** One artifact's metadata and bounded text, or undefined when it is not readable. */
2467
+ function readArtifact(resolved, requested) {
2468
+ const stats = statOf(resolved);
2469
+ if (stats === void 0 || stats.isDirectory()) return void 0;
2470
+ const mimeType = artifactMimeType(requested);
2471
+ let text;
2472
+ if (isTextArtifact(mimeType)) try {
2473
+ const handle = fs.openSync(resolved, "r");
2474
+ try {
2475
+ const buffer = Buffer.alloc(Math.min(stats.size, MAX_ARTIFACT_BYTES));
2476
+ fs.readSync(handle, buffer, 0, buffer.length, 0);
2477
+ text = buffer.toString("utf8");
2478
+ } finally {
2479
+ fs.closeSync(handle);
2480
+ }
2481
+ } catch {
2482
+ return;
2483
+ }
2484
+ return {
2485
+ path: requested,
2486
+ size: stats.size,
2487
+ modifiedAt: stats.mtime.toISOString(),
2488
+ mimeType,
2489
+ ...text === void 0 ? {} : { text },
2490
+ truncated: text !== void 0 && stats.size > MAX_ARTIFACT_BYTES
2491
+ };
2492
+ }
2493
+ /** Streams media with byte-range support so browser audio, video, and PDF controls can seek. */
2494
+ function streamArtifact(resolved, requested, rangeHeader, download) {
2495
+ const stats = statOf(resolved);
2496
+ if (stats === void 0 || stats.isDirectory()) return void 0;
2497
+ const mimeType = artifactMimeType(requested);
2498
+ let start = 0;
2499
+ let end = Math.max(0, stats.size - 1);
2500
+ let partial = false;
2501
+ const match = rangeHeader?.match(/^bytes=(\d*)-(\d*)$/);
2502
+ if (match !== null && match !== void 0 && stats.size > 0) {
2503
+ const requestedStart = match[1] === "" ? void 0 : Number(match[1]);
2504
+ const requestedEnd = match[2] === "" ? void 0 : Number(match[2]);
2505
+ if (requestedStart === void 0 && requestedEnd !== void 0) start = Math.max(0, stats.size - requestedEnd);
2506
+ else if (requestedStart !== void 0) {
2507
+ start = requestedStart;
2508
+ end = requestedEnd === void 0 ? end : Math.min(requestedEnd, end);
2509
+ }
2510
+ if (start >= stats.size || start > end) return new Response(null, {
2511
+ status: 416,
2512
+ headers: { "content-range": `bytes */${String(stats.size)}` }
2513
+ });
2514
+ partial = true;
2515
+ }
2516
+ const filename = path.basename(requested).replace(/["\\\r\n]/g, "_");
2517
+ const headers = new Headers({
2518
+ "accept-ranges": "bytes",
2519
+ "content-disposition": `${download || mimeType === "text/html" ? "attachment" : "inline"}; filename="${filename}"`,
2520
+ "content-length": String(stats.size === 0 ? 0 : end - start + 1),
2521
+ "content-type": mimeType,
2522
+ "x-content-type-options": "nosniff"
2523
+ });
2524
+ if (partial) headers.set("content-range", `bytes ${String(start)}-${String(end)}/${String(stats.size)}`);
2525
+ if (stats.size === 0) return new Response(null, {
2526
+ status: 200,
2527
+ headers
2528
+ });
2529
+ const body = Readable.toWeb(fs.createReadStream(resolved, {
2530
+ start,
2531
+ end
2532
+ }));
2533
+ return new Response(body, {
2534
+ status: partial ? 206 : 200,
2535
+ headers
2536
+ });
2537
+ }
2538
+ /**
2539
+ * This package's API, mounted by a session with its runtime's `launch` and by
2540
+ * the hub without one.
2541
+ */
2542
+ function createWorkflowApi(launch) {
2543
+ return {
2544
+ basePath: WORKFLOW_API_BASE_PATH,
2545
+ start(context) {
2546
+ const steer = context.sessionService?.steer?.bind(context.sessionService);
2547
+ const app = createWorkflowHubApi({
2548
+ ...steer === void 0 ? {} : { steer: (sessionId, message) => steer(sessionId, message) },
2549
+ ...launch === void 0 ? {} : { launch }
2550
+ });
2551
+ return {
2552
+ fetch: (request) => app.fetch(request),
2553
+ close: () => void 0
2554
+ };
2555
+ }
2556
+ };
2557
+ }
2558
+ /** The named export a host imports from this package's built hub entry. */
2559
+ const api = createWorkflowApi();
2560
+ //#endregion
2561
+ //#region src/services/workflowNarration/index.ts
2562
+ const RUNNING_STAGE = "running";
2563
+ const COMPLETED_STAGE = "completed";
2564
+ const ERROR_STAGE = "error";
2565
+ /**
2566
+ * Translate an observed workflow lifecycle transition into generic speech.
2567
+ * Voice owns delivery and ignores requests while its minor mode is inactive.
2568
+ */
2569
+ function narrateWorkflowTransition(requester, record, previousStage, startedDuringSession) {
2570
+ const newlyLaunched = previousStage === void 0 && startedDuringSession;
2571
+ if (newlyLaunched) requester.narrate(`Workflow launched: ${record.displayName}.`);
2572
+ if (previousStage !== RUNNING_STAGE && !newlyLaunched) return;
2573
+ if (record.stage === COMPLETED_STAGE) requester.narrate(`Workflow succeeded: ${record.displayName}.`);
2574
+ if (record.stage === ERROR_STAGE) requester.narrate(`Workflow failed: ${record.displayName}.`);
2575
+ }
2576
+ //#endregion
2577
+ //#region src/services/workflowResource/index.ts
2578
+ function packageRoot() {
2579
+ let directory = path.dirname(fileURLToPath(import.meta.url));
2580
+ while (!existsSync(path.join(directory, "package.json"))) {
2581
+ const parent = path.dirname(directory);
2582
+ if (parent === directory) throw new Error("Workflow package resources are unavailable.");
2583
+ directory = parent;
2584
+ }
2585
+ return directory;
2586
+ }
2587
+ async function readWorkflowSkill(name) {
2588
+ return readFile(path.join(packageRoot(), name === "workflow-recovery" ? "skills" : "src/prompts", name, "SKILL.md"), "utf8");
2589
+ }
2590
+ //#endregion
2591
+ //#region src/services/workflowSession/index.ts
2592
+ /**
2593
+ * The workflow session: the child session a launch creates to own and run one
2594
+ * workflow, nested under the session that launched it.
2595
+ *
2596
+ * DESIGN PATTERNS:
2597
+ * - Pure presenters for the rail line and the owner agent's brief
2598
+ * - A lifecycle driven by the run snapshots the runtime already publishes,
2599
+ * with every host effect injected
2600
+ *
2601
+ * AVOID:
2602
+ * - Posting or releasing on startup: a woken session reports nothing it did
2603
+ * not see change, so opening a stopped session never stops it again
2604
+ */
2605
+ const MINUTE_MS = 6e4;
2606
+ const HOUR_MS = 36e5;
2607
+ /** A failure cause is a line in a notice, not the log; a longer one is cut. */
2608
+ const MAX_CAUSE = 1e3;
2609
+ const WORKFLOW_FILE_SUFFIX = /\.workflow\.ya?ml$|\.ya?ml$/u;
2610
+ /** Whether this session was created to own a workflow run. */
2611
+ function isWorkflowSession(context) {
2612
+ return context.sessionContext?.provenance === WORKFLOW_SESSION_PROVENANCE;
2613
+ }
2614
+ /** The rail name of a workflow session: the catalog's name, else the file's stem. */
2615
+ function workflowSessionName(workflowPath, catalogName) {
2616
+ if (catalogName !== void 0 && catalogName.trim() !== "") return catalogName.trim();
2617
+ return basename(workflowPath).replace(WORKFLOW_FILE_SUFFIX, "") || "workflow";
2618
+ }
2619
+ function isPaused(run) {
2620
+ return run.stage === "running" && (run.executionState === "paused" || run.executionState === "pause_requested");
2621
+ }
2622
+ function needsAttention(run) {
2623
+ return run.stage === "error" || run.stale === true || isPaused(run);
2624
+ }
2625
+ function succeeded(run) {
2626
+ return run.stage === "completed" && (run.outcome === void 0 || run.outcome === "success" || run.outcome === "skipped");
2627
+ }
2628
+ function duration(run) {
2629
+ const start = Date.parse(run.startedAt);
2630
+ const end = run.finishedAt === void 0 ? NaN : Date.parse(run.finishedAt);
2631
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return void 0;
2632
+ const elapsed = Math.max(0, end - start);
2633
+ if (elapsed < MINUTE_MS) return "under a minute";
2634
+ if (elapsed < HOUR_MS) return `${Math.floor(elapsed / MINUTE_MS)}m`;
2635
+ return `${Math.floor(elapsed / HOUR_MS)}h ${String(Math.floor(elapsed % HOUR_MS / MINUTE_MS)).padStart(2, "0")}m`;
2636
+ }
2637
+ /**
2638
+ * The line the rail shows for a workflow session, in place of an idle agent's
2639
+ * copy. A failure or pause outranks a run in progress, because it is the one
2640
+ * that needs the reader.
2641
+ */
2642
+ function workflowSessionActivity(runs) {
2643
+ if (runs.length === 0) return void 0;
2644
+ const failed = runs.find((run) => run.stage === "error");
2645
+ if (failed !== void 0) return {
2646
+ label: `workflow failed · ${failed.failedJob ?? failed.displayName}`,
2647
+ attention: true
2648
+ };
2649
+ const paused = runs.find(isPaused);
2650
+ if (paused !== void 0) return {
2651
+ label: paused.position === void 0 ? "workflow paused" : `workflow paused · ${paused.position.job}`,
2652
+ attention: true
2653
+ };
2654
+ if (runs.find((run) => run.stage === "running" && run.stale === true) !== void 0) return {
2655
+ label: "workflow interrupted",
2656
+ attention: true
2657
+ };
2658
+ const running = runs.find((run) => run.stage === "running");
2659
+ if (running !== void 0) {
2660
+ const where = [running.position?.job, running.position?.step].filter(Boolean).join(" › ");
2661
+ return {
2662
+ label: where === "" ? "workflow · starting" : `workflow · ${where}`,
2663
+ since: running.startedAt
2664
+ };
2665
+ }
2666
+ if (runs.every(succeeded)) return { label: "workflow succeeded" };
2667
+ return {
2668
+ label: `workflow ${runs[0].outcome ?? "ended"}`,
2669
+ attention: true
2670
+ };
2671
+ }
2672
+ function runState(run) {
2673
+ if (run.stage === "error") return "failed";
2674
+ if (run.stale === true) return "interrupted (its process is gone)";
2675
+ if (isPaused(run)) return "paused";
2676
+ if (run.stage === "running") return "running";
2677
+ return run.outcome ?? "completed";
2678
+ }
2679
+ /**
2680
+ * The owner agent's live brief, added to its system prompt on every turn.
2681
+ *
2682
+ * Read from the registry each time rather than written once, so it survives a
2683
+ * wake and never describes a run that has since moved on.
2684
+ */
2685
+ function workflowSessionBrief(runs) {
2686
+ if (runs.length === 0) return "";
2687
+ return [
2688
+ "# Workflow session",
2689
+ "You are the owner agent of this workflow session. It runs the workflow below for the session that launched it, and you troubleshoot it.",
2690
+ ...runs.map((run) => {
2691
+ const where = [run.position?.job, run.position?.step].filter(Boolean).join(" › ");
2692
+ const failure = run.stage === "error" ? `; ${run.failedJob === void 0 ? "" : `job '${run.failedJob}' `}failed: ${run.errorMessage ?? "no cause recorded"}` : "";
2693
+ return `- ${run.displayName} (runKey ${run.runKey}, workspace ${run.workspace}): ${runState(run)}${where === "" ? "" : ` at ${where}`}${failure}`;
2694
+ }),
2695
+ "On a workflow error, diagnose it locally and summarize the cause here. Do not wake the launcher to diagnose it, automatically restart it, or diagnose a user-requested stop. When the user or launcher session asks: use workflow_run status with the runKey and workspace for progress, workflow_tools recovery-evidence to read a failure, and load the workflow-recovery skill before workflow_tools recover. Recover, pause, resume or stop a run only when the user asks or the launcher explicitly delegates it through intercom."
2696
+ ].join("\n");
2697
+ }
2698
+ function failureNotice(run) {
2699
+ const cause = (run.errorMessage ?? "the run ended in the error stage").slice(0, MAX_CAUSE);
2700
+ const job = run.failedJob === void 0 ? "" : ` at job "${run.failedJob}"`;
2701
+ return {
2702
+ title: "Workflow failed",
2703
+ body: `Workflow "${run.displayName}" failed${job}: ${cause}. Ask me to diagnose or recover it.`,
2704
+ level: "error"
2705
+ };
2706
+ }
2707
+ function finishNotice(run) {
2708
+ const took = duration(run);
2709
+ if (succeeded(run)) return {
2710
+ title: "Workflow succeeded",
2711
+ body: `Workflow "${run.displayName}" ${run.outcome === "skipped" ? "was skipped" : "succeeded"}${took === void 0 ? "" : ` in ${took}`}.`,
2712
+ level: "info"
2713
+ };
2714
+ return {
2715
+ title: "Workflow ended",
2716
+ body: `Workflow "${run.displayName}" ended: ${run.outcome ?? "no outcome recorded"}. Ask me to look into it.`,
2717
+ level: "error"
2718
+ };
2719
+ }
2720
+ const identity = (run) => `${run.workspace}/${run.runKey}`;
2721
+ /**
2722
+ * What a workflow session does as its runs move: keep the rail line current,
2723
+ * post a notice when a handed-off run fails or finishes, and ask its parent to
2724
+ * release it after an untouched success.
2725
+ *
2726
+ * "Untouched" means no run ever needed attention and nobody started a turn
2727
+ * here. A session you are reading or troubleshooting stays live until you
2728
+ * remove it.
2729
+ */
2730
+ function createWorkflowSessionLifecycle(deps) {
2731
+ const stages = /* @__PURE__ */ new Map();
2732
+ let seeded = false;
2733
+ let attention = false;
2734
+ let engaged = false;
2735
+ let requested = false;
2736
+ let disposed = false;
2737
+ let stopWaiting;
2738
+ const releasable = (runs) => deps.parentSessionId !== void 0 && !attention && !engaged && !disposed && runs.length > 0 && runs.every(succeeded) && runs.some((run) => run.launcherSessionId === deps.parentSessionId);
2739
+ const requestRelease = async (runs) => {
2740
+ const parent = deps.parentSessionId;
2741
+ if (parent === void 0 || requested || deps.requestRelease === void 0 || !releasable(runs)) return;
2742
+ if (!await deps.isIdle() || disposed) return;
2743
+ requested = true;
2744
+ const runKeys = runs.map((run) => run.runKey);
2745
+ if (deps.requestRelease(parent, runKeys)) return;
2746
+ stopWaiting = deps.onPeerReady?.((peer) => {
2747
+ if (peer !== parent) return;
2748
+ stopWaiting?.();
2749
+ stopWaiting = void 0;
2750
+ if (releasable(runs)) deps.requestRelease?.(parent, runKeys);
2751
+ });
2752
+ };
2753
+ return {
2754
+ async observe(runs) {
2755
+ if (disposed) return;
2756
+ deps.publishActivity?.(workflowSessionActivity(runs));
2757
+ if (runs.some(needsAttention)) attention = true;
2758
+ if (!seeded) {
2759
+ for (const run of runs) stages.set(identity(run), run.stage);
2760
+ seeded = true;
2761
+ return;
2762
+ }
2763
+ let finished = false;
2764
+ for (const run of runs) {
2765
+ const previous = stages.get(identity(run));
2766
+ stages.set(identity(run), run.stage);
2767
+ if (previous === run.stage || run.launcherSessionId === void 0) continue;
2768
+ if (run.stage === "error") await deps.postNotice(failureNotice(run));
2769
+ else if (run.stage === "completed") {
2770
+ await deps.postNotice(finishNotice(run));
2771
+ finished = true;
2772
+ }
2773
+ }
2774
+ if (finished) await requestRelease(runs);
2775
+ },
2776
+ agentStarted() {
2777
+ engaged = true;
2778
+ },
2779
+ dispose() {
2780
+ disposed = true;
2781
+ stopWaiting?.();
2782
+ stopWaiting = void 0;
2783
+ }
2784
+ };
2785
+ }
2786
+ //#endregion
2787
+ //#region src/services/workflowWatcher/index.ts
2788
+ const { parser } = createEmbeddedWorkflowFeature();
2789
+ /**
2790
+ * Read the durable workflow registry once.
2791
+ *
2792
+ * The registry is an initial snapshot only. Live delivery belongs to the
2793
+ * session lifecycle and is routed through the host-owned direct event bus.
2794
+ */
2795
+ function readWorkflowRuns(options = {}) {
2796
+ const environment = options.environment;
2797
+ const homeDir = options.homeDir ?? resolveWorkflowHome({
2798
+ envValue: environment?.["WORKFLOW_MCP_HOME"],
2799
+ homeDir: os.homedir()
2800
+ });
2801
+ const workspacesDir = path.join(homeDir, WORKSPACES_DIR_NAME);
2802
+ const recordCache = /* @__PURE__ */ new Map();
2803
+ const progressCache = /* @__PURE__ */ new Map();
2804
+ const cachedParse = (cache, filePath, parse, empty) => {
2805
+ let stat;
2806
+ try {
2807
+ stat = fs.statSync(filePath);
2808
+ } catch {
2809
+ cache.delete(filePath);
2810
+ return empty;
2811
+ }
2812
+ const cached = cache.get(filePath);
2813
+ if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.value;
2814
+ let raw;
2815
+ try {
2816
+ raw = fs.readFileSync(filePath, "utf8");
2817
+ } catch {
2818
+ cache.delete(filePath);
2819
+ return empty;
2820
+ }
2821
+ const value = parse(raw);
2822
+ cache.set(filePath, {
2823
+ size: stat.size,
2824
+ mtimeMs: stat.mtimeMs,
2825
+ value
2826
+ });
2827
+ return value;
2828
+ };
2829
+ const listDir = (directory) => {
2830
+ try {
2831
+ return fs.readdirSync(directory).sort();
2832
+ } catch {
2833
+ return [];
2834
+ }
2835
+ };
2836
+ const plans = /* @__PURE__ */ new Map();
2837
+ const planFor = (workflowPath) => {
2838
+ const cached = plans.get(workflowPath);
2839
+ if (cached !== void 0) return cached;
2840
+ let plan = [];
2841
+ try {
2842
+ const workflow = parser.parseWorkflowFile(workflowPath);
2843
+ plan = planWorkflowJobs(workflow, parser.resolveJobOrder(workflow.jobs, null));
2844
+ } catch {}
2845
+ plans.set(workflowPath, plan);
2846
+ return plan;
2847
+ };
2848
+ const runs = [];
2849
+ for (const workspace of listDir(workspacesDir)) for (const stage of WORKFLOW_STAGES) {
2850
+ const stageDir = path.join(workspacesDir, workspace, stage);
2851
+ for (const runKey of listDir(stageDir)) {
2852
+ const runDir = path.join(stageDir, runKey);
2853
+ const recordPath = path.join(runDir, RUN_RECORD_FILE_NAME);
2854
+ const progressPath = path.join(runDir, PROGRESS_FILE_NAME);
2855
+ const parsed = cachedParse(recordCache, recordPath, parseWorkflowRunRecord, void 0);
2856
+ if (parsed === void 0) continue;
2857
+ const jobs = foldWorkflowProgress(cachedParse(progressCache, progressPath, parseWorkflowProgress, []), planFor(parsed.view.workflowPath));
2858
+ runs.push({
2859
+ ...parsed,
2860
+ view: completeWorkflowRunView(parsed.view, jobs)
2861
+ });
2862
+ }
2863
+ }
2864
+ return runs;
2865
+ }
2866
+ //#endregion
2867
+ //#region src/services/serverRuntime/index.ts
2868
+ const SOURCE = "@agimon-ai/po-workflow";
2869
+ const WORKFLOW_MODE_ID = "workflow";
2870
+ const LIST_TOOL = "list_workflows";
2871
+ const LAUNCH_TOOL = "launch_workflow";
2872
+ const RUN_TOOL = "workflow_run";
2873
+ /** Origin of a request dispatched in process to another mount; nothing resolves it. */
2874
+ const IN_PROCESS_ORIGIN = "http://po.local";
2875
+ /** A burst of job and step events is published once, this long after the first. */
2876
+ const RUNS_PUBLISH_DEBOUNCE_MS = 100;
2877
+ /** A notification body's limit; a longer failure is cut to it. */
2878
+ const NOTICE_BODY_LIMIT = 4e3;
2879
+ /**
2880
+ * A launch refused because the workflow needs fixing. The doctor's findings
2881
+ * name only a job and a step, so the notice names the workflow and says
2882
+ * nothing ran; in a conversation it otherwise reads as part of whatever run
2883
+ * was reported just before it.
2884
+ */
2885
+ function needsFixingNotice(workflow, error) {
2886
+ return `Workflow "${workflow}" was not launched: it needs fixing.\n${error}`;
2887
+ }
2888
+ function textResult(text, isError = false) {
2889
+ return {
2890
+ content: [{
2891
+ type: "text",
2892
+ text
2893
+ }],
2894
+ ...isError ? { isError: true } : {}
2895
+ };
2896
+ }
2897
+ function isLaunchResponse(value) {
2898
+ return typeof value === "object" && value !== null && "text" in value && typeof value.text === "string";
2899
+ }
2900
+ function errorOf(value) {
2901
+ return typeof value === "object" && value !== null && "error" in value && typeof value.error === "string" ? value.error : void 0;
2902
+ }
2903
+ function errorMessage(error) {
2904
+ return error instanceof Error ? error.message : String(error);
2905
+ }
2906
+ /** The text of a launch's tool result, as the launch tool and the API report it. */
2907
+ function launchResultOf(result) {
2908
+ return textResult(result.content.map((item) => item.type === "text" ? item.text : "").filter((line) => line !== "").join("\n") || "Workflow launch requested.", result.isError === true);
2909
+ }
2910
+ function callResult(value) {
2911
+ const content = [];
2912
+ if (typeof value === "object" && value !== null && "content" in value && Array.isArray(value.content)) for (const item of value.content) {
2913
+ if (typeof item !== "object" || item === null || !("type" in item)) continue;
2914
+ const record = item;
2915
+ if (record.type === "text" && typeof record.text === "string") content.push({
2916
+ type: "text",
2917
+ text: record.text
2918
+ });
2919
+ else if (record.type === "image" && typeof record.data === "string" && typeof record.mimeType === "string") content.push({
2920
+ type: "image",
2921
+ data: record.data,
2922
+ mimeType: record.mimeType
2923
+ });
2924
+ }
2925
+ if (content.length === 0) content.push({
2926
+ type: "text",
2927
+ text: JSON.stringify(value, null, 2)
2928
+ });
2929
+ return {
2930
+ content,
2931
+ details: value,
2932
+ ...typeof value === "object" && value !== null && "isError" in value && value.isError === true ? { isError: true } : {}
2933
+ };
2934
+ }
2935
+ function untilAborted(work, signal) {
2936
+ if (signal.aborted) return Promise.reject(signal.reason);
2937
+ return new Promise((resolveWork, rejectWork) => {
2938
+ const onAbort = () => rejectWork(signal.reason);
2939
+ signal.addEventListener("abort", onAbort, { once: true });
2940
+ work.then(resolveWork, rejectWork).finally(() => signal.removeEventListener("abort", onAbort));
2941
+ });
2942
+ }
2943
+ function createWorkflowServerRuntime(host, serverHost) {
2944
+ if (serverHost.context.directEvents === void 0) throw new Error("Workflow headless facet requires the session direct event bus.");
2945
+ const directEvents = serverHost.context.directEvents;
2946
+ let disposed = false;
2947
+ let runProvider;
2948
+ let narration;
2949
+ let readiness;
2950
+ let ownedReadiness;
2951
+ let ready;
2952
+ const generation = randomUUID();
2953
+ const startupAbort = new AbortController();
2954
+ const sessionStartedAt = Date.now();
2955
+ let narrationReady = false;
2956
+ let previousStages = /* @__PURE__ */ new Map();
2957
+ let activeRunCount = 0;
2958
+ let stepGatePending = false;
2959
+ let serviceContext;
2960
+ const runIdentity = ({ view }) => view.runId ?? `${view.workspace}/${view.runKey}/${view.startedAt}`;
2961
+ const pendingCompletions = /* @__PURE__ */ new Map();
2962
+ const completionBatches = [];
2963
+ const deliveredCompletions = /* @__PURE__ */ new Set();
2964
+ let delivery;
2965
+ let deliveryRetry;
2966
+ const catalogDeps = defaultCatalogDeps();
2967
+ const catalogReader = createWorkflowCatalogReader(catalogDeps);
2968
+ const telemetry = createDoomTelemetry({
2969
+ serviceName: "doom-workflow",
2970
+ packageName: SOURCE,
2971
+ cwd: host.context.cwd,
2972
+ env: host.context.environment,
2973
+ enableLogs: true,
2974
+ enableTraces: true
2975
+ });
2976
+ /** True when this session was created to own one workflow run for the session that launched it. */
2977
+ const workflowSession = isWorkflowSession(host.context);
2978
+ const communication = serverHost.context.sessionCommunication;
2979
+ const lifecycle = workflowSession ? createWorkflowSessionLifecycle({
2980
+ parentSessionId: host.context.sessionContext?.parentSessionId,
2981
+ publishActivity: (activity) => serverHost.context.publishActivity?.(activity),
2982
+ postNotice: async (notice) => {
2983
+ const data = createDoomNotificationEntryData({
2984
+ title: notice.title,
2985
+ body: notice.body,
2986
+ level: notice.level
2987
+ });
2988
+ if (data !== void 0) await host.context.session.appendCustomEntry(DOOM_NOTIFICATION_ENTRY_TYPE, data);
2989
+ },
2990
+ isIdle: async () => {
2991
+ const activity = await host.context.session.activity();
2992
+ return activity.isIdle && !activity.hasPendingMessages;
2993
+ },
2994
+ ...communication === void 0 ? {} : {
2995
+ requestRelease: (parent, runKeys) => communication.publish(parent, WORKFLOW_SESSION_RELEASE_TYPE, { runKeys }),
2996
+ onPeerReady: (listener) => communication.onPeerReady(listener)
2997
+ }
2998
+ }) : void 0;
2999
+ const reportLifecycleFailure = (error) => {
3000
+ telemetry.recordWarning("doom_workflow.session_lifecycle_failed", error);
3001
+ };
3002
+ /** This session's runs, published to its panel and counted as its background work. */
3003
+ const publishRuns = (executionContext) => {
3004
+ const records = readWorkflowRuns({ environment: executionContext.environment }).filter((run) => runBelongsToSession(run, executionContext.sessionId) || runLaunchedBySession(run, executionContext.sessionId));
3005
+ const activeItems = records.filter((run) => run.view.stage === "running" && run.view.stale !== true).map((run) => ({
3006
+ id: `${run.view.workspace}/${run.view.runKey}`,
3007
+ sessionId: executionContext.sessionId
3008
+ }));
3009
+ activeRunCount = activeItems.length;
3010
+ const nextStages = /* @__PURE__ */ new Map();
3011
+ for (const record of records) {
3012
+ const { view } = record;
3013
+ const identity = runIdentity(record);
3014
+ const previous = previousStages.get(identity);
3015
+ nextStages.set(identity, view.stage);
3016
+ if (view.stage === "error" && previous !== "error" && runLaunchedBySession(record, executionContext.sessionId)) Promise.resolve(executionContext.client.notify({
3017
+ body: `Workflow "${view.displayName}" failed: ${view.errorMessage ?? "no cause recorded"}. Diagnose it in its workflow-owner session.`,
3018
+ level: "error"
3019
+ })).catch(reportLifecycleFailure);
3020
+ if (!dispatcher && view.stage !== "running" && !(view.stage === "error" && runLaunchedBySession(record, host.context.sessionId)) && !deliveredCompletions.has(identity) && (previous === "running" || previous === void 0 && Date.parse(view.startedAt) >= sessionStartedAt)) {
3021
+ if (!pendingCompletions.has(identity)) pendingCompletions.set(identity, {
3022
+ run: record,
3023
+ attempts: 0
3024
+ });
3025
+ }
3026
+ if (narrationReady && !disposed && narration !== void 0 && runBelongsToSession(record, executionContext.sessionId)) {
3027
+ const startedAt = Date.parse(view.startedAt);
3028
+ narrateWorkflowTransition({ narrate(text) {
3029
+ const request = createNarrationRequest(text);
3030
+ if (request === void 0 || narration === void 0) return;
3031
+ try {
3032
+ Promise.resolve(narration.request(request)).catch((error) => telemetry.recordWarning("doom_workflow.narration_failed", error));
3033
+ } catch (error) {
3034
+ telemetry.recordWarning("doom_workflow.narration_failed", error);
3035
+ }
3036
+ } }, view, previous, previous === void 0 && (!Number.isFinite(startedAt) || startedAt >= sessionStartedAt));
3037
+ }
3038
+ }
3039
+ previousStages = nextStages;
3040
+ const retained = new Map(activeItems.map((item) => [item.id, item]));
3041
+ for (const { run } of pendingCompletions.values()) {
3042
+ const id = `${run.view.workspace}/${run.view.runKey}`;
3043
+ retained.set(id, {
3044
+ id,
3045
+ sessionId: executionContext.sessionId
3046
+ });
3047
+ }
3048
+ runProvider?.update([...retained.values()]);
3049
+ const runs = presentWorkflowRuns(records.map((run) => run.view), Date.now());
3050
+ if (disposed) return false;
3051
+ directEvents.publish(WORKFLOW_RUNS_TYPE, executionContext.sessionId, { runs });
3052
+ lifecycle?.observe(runs).catch(reportLifecycleFailure);
3053
+ return retained.size > 0;
3054
+ };
3055
+ /** Retain terminal work until its batch is durably posted or, when loud, admitted. */
3056
+ const deliverCompletions = (automatic = false) => {
3057
+ if (disposed || delivery !== void 0) return delivery ?? Promise.resolve();
3058
+ delivery = (async () => {
3059
+ const prepared = [];
3060
+ for (const [identity, pending] of pendingCompletions) {
3061
+ if (disposed) return;
3062
+ if (pending.batched || automatic && pending.attempts >= 5) continue;
3063
+ try {
3064
+ const view = pending.run.view;
3065
+ const records = await feature.registry.listRuns(view.workspace);
3066
+ if (disposed) return;
3067
+ const record = records.find((candidate) => candidate.runKey === view.runKey && candidate.stage === view.stage && (view.runId !== void 0 ? candidate.runId === view.runId : candidate.startedAt === view.startedAt));
3068
+ if (record === void 0) throw new Error(`Completion record is unavailable: ${identity}`);
3069
+ const events = record.stage === "completed" ? [] : await readWorkflowProgress(feature.registry.runDirectoryFor(record));
3070
+ if (disposed) return;
3071
+ const startedAt = Date.parse(record.startedAt);
3072
+ const summary = finishedRunSummary(record, summarizeWorkflowProgress(events.filter((event) => !Number.isFinite(startedAt) || !Number.isFinite(Date.parse(event.at)) || Date.parse(event.at) >= startedAt)));
3073
+ const text = isLaunchedRun(record, host.context.sessionId) ? `${summary}\n${launchedRunHint(record)}` : summary;
3074
+ prepared.push({
3075
+ identity,
3076
+ record,
3077
+ text
3078
+ });
3079
+ } catch (error) {
3080
+ if (disposed) return;
3081
+ pending.attempts += 1;
3082
+ await telemetry.recordError("doom_workflow.terminal_delivery_failed", error, { "workflow.pending_terminal_count": pendingCompletions.size });
3083
+ }
3084
+ }
3085
+ for (const quiet of [true, false]) {
3086
+ const members = prepared.filter(({ record }) => (isOwnedDelegatedRun(record, host.context.sessionId) && record.stage !== "error") === quiet);
3087
+ if (members.length === 0) continue;
3088
+ for (const { identity } of members) pendingCompletions.get(identity).batched = true;
3089
+ completionBatches.push({
3090
+ identities: members.map(({ identity }) => identity),
3091
+ outcomes: members.map(({ record }) => record.stage),
3092
+ quiet,
3093
+ requestId: `workflow-finished:${generation}:${members.map(({ identity }) => identity).join(",")}`,
3094
+ message: {
3095
+ type: "workflow-run-finished",
3096
+ text: members.map(({ text }) => text).join("\n\n"),
3097
+ display: true,
3098
+ details: {
3099
+ runIds: members.map(({ identity, record }) => record.runId ?? identity),
3100
+ runs: members.map(({ record }) => ({
3101
+ runKey: record.runKey,
3102
+ workspace: record.workspace,
3103
+ stage: record.stage,
3104
+ ...record.workflowId ? { workflowId: record.workflowId } : {},
3105
+ ...record.failedJob ? { failedJob: record.failedJob } : {},
3106
+ ...record.errorMessage ? { error: record.errorMessage } : {}
3107
+ }))
3108
+ }
3109
+ },
3110
+ attempts: 0
3111
+ });
3112
+ }
3113
+ for (const batch of completionBatches.slice()) {
3114
+ if (disposed) return;
3115
+ if (automatic && batch.attempts >= 5) continue;
3116
+ try {
3117
+ if (batch.quiet) {
3118
+ if (host.context.session.appendMessage === void 0) throw new Error("Workflow completion requires informational message support.");
3119
+ await host.context.session.appendMessage(batch.message);
3120
+ } else {
3121
+ if (host.context.session.admitMessage === void 0) throw new Error("Workflow completion requires custom message admission support.");
3122
+ await host.context.session.admitMessage(batch.message, "steer", batch.requestId);
3123
+ }
3124
+ if (disposed) return;
3125
+ completionBatches.splice(completionBatches.indexOf(batch), 1);
3126
+ for (const identity of batch.identities) {
3127
+ deliveredCompletions.add(identity);
3128
+ pendingCompletions.delete(identity);
3129
+ }
3130
+ for (const outcome of batch.outcomes) telemetry.recordEvent("doom_workflow.run_transition", { outcome });
3131
+ publishRuns(host.context);
3132
+ } catch (error) {
3133
+ if (disposed) return;
3134
+ batch.attempts += 1;
3135
+ await telemetry.recordError("doom_workflow.terminal_delivery_failed", error, { "workflow.pending_terminal_count": pendingCompletions.size });
3136
+ }
3137
+ }
3138
+ })().finally(() => {
3139
+ delivery = void 0;
3140
+ if (!disposed && ([...pendingCompletions.values()].some((pending) => !pending.batched && pending.attempts < 5) || completionBatches.some((batch) => batch.attempts < 5)) && deliveryRetry === void 0) {
3141
+ deliveryRetry = setTimeout(() => {
3142
+ deliveryRetry = void 0;
3143
+ deliverCompletions(true);
3144
+ }, 1e3);
3145
+ deliveryRetry.unref?.();
3146
+ }
3147
+ });
3148
+ return delivery;
3149
+ };
3150
+ /** The workflows this session can launch. Runs moving on do not change them, so their events skip this. */
3151
+ const publishCatalog = async (executionContext) => {
3152
+ try {
3153
+ const workflows = presentWorkflowCatalog(await catalogReader.read(executionContext.cwd));
3154
+ if (disposed) return;
3155
+ directEvents.publish(WORKFLOW_CATALOG_TYPE, executionContext.sessionId, {
3156
+ cwd: executionContext.cwd,
3157
+ workflows
3158
+ });
3159
+ } catch (error) {
3160
+ if (disposed) return;
3161
+ const warning = error instanceof Error ? error.message : String(error);
3162
+ directEvents.publish(WORKFLOW_CATALOG_TYPE, executionContext.sessionId, {
3163
+ cwd: executionContext.cwd,
3164
+ workflows: [],
3165
+ warning
3166
+ });
3167
+ }
3168
+ };
3169
+ const publishLifecycle = async (executionContext) => {
3170
+ publishRuns(executionContext);
3171
+ await deliverCompletions();
3172
+ await publishCatalog(executionContext);
3173
+ return activeRunCount > 0 || pendingCompletions.size > 0;
3174
+ };
3175
+ let runsTimer;
3176
+ let runsAfter;
3177
+ /** Publishes the runs once for a burst of job and step events, which a fix loop can produce by the dozen. */
3178
+ const publishRunsSoon = (executionContext, after) => {
3179
+ if (disposed) return;
3180
+ if (after !== void 0) runsAfter = after;
3181
+ if (runsTimer !== void 0) return;
3182
+ runsTimer = setTimeout(() => {
3183
+ runsTimer = void 0;
3184
+ const done = runsAfter;
3185
+ runsAfter = void 0;
3186
+ try {
3187
+ publishRuns(executionContext);
3188
+ deliverCompletions().then(() => done?.(activeRunCount > 0 || pendingCompletions.size > 0));
3189
+ } catch (error) {
3190
+ telemetry.recordWarning("doom_workflow.publish_failed", error);
3191
+ }
3192
+ }, RUNS_PUBLISH_DEBOUNCE_MS);
3193
+ runsTimer.unref?.();
3194
+ };
3195
+ const sessionService = serverHost.context.sessionService;
3196
+ const featureOptions = sessionService === void 0 ? {} : { stepExecutor: createStepExecutor({
3197
+ sessionService,
3198
+ parentSessionId: host.context.sessionId,
3199
+ hostEnvironment: process.env,
3200
+ launchPane: createNativeStepPaneLauncher(void 0, telemetry),
3201
+ createId: () => `workflow-${randomUUID()}`,
3202
+ telemetry
3203
+ }) };
3204
+ const feature = createEmbeddedWorkflowFeature(featureOptions);
3205
+ const launcher = createServerLauncher({
3206
+ feature,
3207
+ createRecoverTool: (options) => createEmbeddedWorkflowFeature({
3208
+ ...featureOptions,
3209
+ registry: feature.registry
3210
+ }).createRecoverTool(options),
3211
+ sessionId: host.context.sessionId,
3212
+ environment: host.context.environment,
3213
+ telemetry,
3214
+ notify: (body, level) => host.context.client.notify({
3215
+ body: body.slice(0, NOTICE_BODY_LIMIT),
3216
+ level
3217
+ }),
3218
+ onRunsChanged: () => publishRunsSoon(host.context)
3219
+ });
3220
+ /**
3221
+ * Why this session cannot run a launch, or undefined when it can.
3222
+ *
3223
+ * The checks the slash command makes through the catalog, made for every
3224
+ * launch whatever asked for it: the launch tool, another session, or the
3225
+ * browser. The workflow must be a file in the repository the launch comes
3226
+ * from, a worktree's own when it runs in one, and its runner, command,
3227
+ * choice, inputs and prompt must be ones it declares.
3228
+ */
3229
+ const refuseLaunch = (input, where) => {
3230
+ if (typeof input.workflowPath !== "string" || input.workflowPath === "") return "A launch needs a workflowPath.";
3231
+ const workflowPath = resolve(where.cwd, input.workflowPath);
3232
+ const inside = relative(where.repoRoot, workflowPath);
3233
+ if (inside.startsWith("..") || isAbsolute(inside)) return `Workflow ${input.workflowPath} is outside this session's repository, so it cannot be launched here.`;
3234
+ const detail = catalogDeps.summarize(workflowPath);
3235
+ if (detail.error !== void 0) return needsFixingNotice(inside, detail.error);
3236
+ const problems = validateWorkflowLaunch(detail, {
3237
+ inputs: input.inputs ?? {},
3238
+ ...input.prompt === void 0 ? {} : { prompt: input.prompt },
3239
+ ...(input.runner ?? input.cliAgent) === void 0 ? {} : { runner: input.runner ?? input.cliAgent },
3240
+ ...input.command === void 0 ? {} : { command: input.command },
3241
+ ...input.choice === void 0 ? {} : { choice: input.choice }
3242
+ });
3243
+ return problems.length === 0 ? void 0 : problems.join("\n");
3244
+ };
3245
+ /**
3246
+ * Run a workflow in this process and answer once it has registered.
3247
+ *
3248
+ * The launch goes through the same executor the Pi extension uses; see
3249
+ * `createServerLauncher` for what the server adds around it.
3250
+ */
3251
+ const launchHere = async (parameters, where = host.context) => {
3252
+ let input;
3253
+ try {
3254
+ input = feature.runTool.getInputSchema().parse(parameters);
3255
+ } catch (error) {
3256
+ return textResult(`Error: ${errorMessage(error)}`, true);
3257
+ }
3258
+ const refusal = refuseLaunch(input, where);
3259
+ if (refusal !== void 0) return textResult(`Error: ${refusal}`, true);
3260
+ try {
3261
+ return launchResultOf(await launcher.launch({
3262
+ ...input,
3263
+ workflowPath: resolve(where.cwd, input.workflowPath)
3264
+ }));
3265
+ } catch (error) {
3266
+ return textResult(`Error: ${errorMessage(error)}`, true);
3267
+ }
3268
+ };
3269
+ /**
3270
+ * Hand a launch to the session that owns this session's runs, through that
3271
+ * session's own mount of this package's API. The owner runs it, so the run
3272
+ * and its step sessions outlive this session, as a dispatcher's launches must.
3273
+ */
3274
+ const launchIn = async (owner, parameters) => {
3275
+ if (serverHost.context.requestApi === void 0) return textResult(`Error: this host cannot reach session ${owner} to launch the workflow there.`, true);
3276
+ const response = await serverHost.context.requestApi({
3277
+ scope: "session",
3278
+ sessionId: owner
3279
+ }, WORKFLOW_API_BASE_PATH, new Request(`${IN_PROCESS_ORIGIN}${apiRoutes_default.launch.path}`, {
3280
+ method: "POST",
3281
+ headers: { "content-type": "application/json" },
3282
+ body: JSON.stringify(parameters)
3283
+ }));
3284
+ const body = await response.json().catch((error) => ({ error: String(error) }));
3285
+ if (response.ok && isLaunchResponse(body)) return textResult(body.text, body.isError === true);
3286
+ return textResult(`Error: session ${owner} did not launch the workflow (${errorOf(body) ?? `HTTP ${String(response.status)}`}).`, true);
3287
+ };
3288
+ /**
3289
+ * A workflow dispatcher launches for its root session, the way a CLI
3290
+ * dispatcher child does: the same markers name it, and the root runs what
3291
+ * it launches. Any other session runs its own launches.
3292
+ */
3293
+ const dispatcher = isWorkflowDispatcherProcess(host.context.environment);
3294
+ const launchOwner = () => {
3295
+ if (!dispatcher) return void 0;
3296
+ const parent = resolveDispatcherParentSession(host.context.environment);
3297
+ if (parent === void 0) return void 0;
3298
+ const owner = resolveRootSessionId(parent, host.context.environment);
3299
+ return owner === host.context.sessionId ? void 0 : owner;
3300
+ };
3301
+ /** Sessions this one is creating to hand launches to; they count toward its capacity before their runs register. */
3302
+ let creatingWorkflowSessions = 0;
3303
+ /** A host that can create a session and reach its API, for a session that is not itself a workflow session. */
3304
+ const canHandOff = () => !workflowSession && sessionService !== void 0 && serverHost.context.requestApi !== void 0;
3305
+ /**
3306
+ * Create a workflow session under this one and hand it the launch, so the
3307
+ * run, its step sessions and its owner agent live there, nested under this
3308
+ * session in the rail. The launch is checked here first: a refused launch
3309
+ * creates nothing.
3310
+ */
3311
+ const launchInWorkflowSession = async (parameters, where = host.context) => {
3312
+ let input;
3313
+ try {
3314
+ input = feature.runTool.getInputSchema().parse(parameters);
3315
+ } catch (error) {
3316
+ return textResult(`Error: ${errorMessage(error)}`, true);
3317
+ }
3318
+ const refusal = refuseLaunch(input, where);
3319
+ if (refusal !== void 0) return textResult(`Error: ${refusal}`, true);
3320
+ const self = host.context.sessionId;
3321
+ const running = readWorkflowRuns({ environment: host.context.environment }).filter((run) => run.view.stage === "running" && run.view.stale !== true && (runBelongsToSession(run, self) || runLaunchedBySession(run, self))).length;
3322
+ const ceiling = resolveMaxConcurrent(host.context.environment);
3323
+ if (running + creatingWorkflowSessions >= ceiling) return textResult(`Error: This session is at capacity: ${String(running + creatingWorkflowSessions)}/${String(ceiling)} workflows running.`, true);
3324
+ const workflowPath = resolve(where.cwd, input.workflowPath);
3325
+ creatingWorkflowSessions += 1;
3326
+ let child;
3327
+ try {
3328
+ const name = workflowSessionName(workflowPath, (await catalogReader.read(where.cwd)).find((candidate) => candidate.path === workflowPath)?.name);
3329
+ const config = poTemplateRunConfigSchema.parse(feature.parser.parseWorkflowFile(workflowPath).runConfig ?? {});
3330
+ if (config.majorMode === void 0) throw new Error("Delegated workflows require top-level runConfig.majorMode.");
3331
+ const selection = sessionSelection(config);
3332
+ child = (await sessionService.create({
3333
+ cwd: where.cwd,
3334
+ name,
3335
+ parentSessionId: self,
3336
+ sessionProvenance: WORKFLOW_SESSION_PROVENANCE,
3337
+ selection: {
3338
+ ...selection,
3339
+ minorModes: [...(selection.minorModes ?? []).filter((mode) => mode !== WORKFLOW_MODE_ID), WORKFLOW_MODE_ID]
3340
+ }
3341
+ })).sessionId;
3342
+ const result = await launchIn(child, {
3343
+ ...parameters,
3344
+ workflowPath,
3345
+ env: {
3346
+ ...input.env,
3347
+ [WORKFLOW_LAUNCHER_SESSION_ENV]: self
3348
+ }
3349
+ });
3350
+ if (result.isError === true) {
3351
+ await sessionService.close(child).catch(reportLifecycleFailure);
3352
+ return result;
3353
+ }
3354
+ return textResult(`${result.content.map((item) => item.text).join("\n")}\nRuns in workflow session "${name}" (${child}), nested under this session. Failures notify you here without waking this agent. Its owner diagnoses failures locally, but recovers only on request; delegate to its owner with intercom send to "${child}".`);
3355
+ } catch (error) {
3356
+ if (child !== void 0) await sessionService.close(child).catch(reportLifecycleFailure);
3357
+ return textResult(`Error: ${errorMessage(error)}`, true);
3358
+ } finally {
3359
+ creatingWorkflowSessions -= 1;
3360
+ }
3361
+ };
3362
+ const launch = async (parameters, where) => {
3363
+ const owner = launchOwner();
3364
+ if (owner !== void 0) return launchIn(owner, parameters);
3365
+ return canHandOff() ? launchInWorkflowSession(parameters, where) : launchHere(parameters, where);
3366
+ };
3367
+ /**
3368
+ * A launch another session handed this one. A hand-off from a launcher
3369
+ * carries its stamp and runs here; any other, such as a dispatcher's, goes
3370
+ * through the same routing as a launch made here.
3371
+ */
3372
+ const launchFromApi = async (parameters) => {
3373
+ const env = parameters.env;
3374
+ const result = typeof env === "object" && env !== null && typeof env["PO_WORKFLOW_LAUNCHER_SESSION_ID"] === "string" ? await launchHere(parameters) : await launch(parameters);
3375
+ publishRunsSoon(host.context);
3376
+ return {
3377
+ text: result.content.map((item) => item.text).join("\n"),
3378
+ ...result.isError === true ? { isError: true } : {}
3379
+ };
3380
+ };
3381
+ /**
3382
+ * A workflow session asks to be released after an untouched success. Only
3383
+ * the hub-attached source is trusted, and the registry, not the message,
3384
+ * decides: every run it owns succeeded and one was launched from here.
3385
+ */
3386
+ const releaseWorkflowSession = async (source) => {
3387
+ if (sessionService?.release === void 0) return;
3388
+ const owned = readWorkflowRuns({ environment: host.context.environment }).filter((run) => runBelongsToSession(run, source));
3389
+ const succeeded = (run) => run.view.stage === "completed" && (run.view.outcome === void 0 || run.view.outcome === "success" || run.view.outcome === "skipped");
3390
+ if (owned.length === 0 || !owned.every(succeeded)) return;
3391
+ if (!owned.some((run) => runLaunchedBySession(run, host.context.sessionId))) return;
3392
+ await sessionService.release(source);
3393
+ telemetry.recordEvent("doom_workflow.session_released", { outcome: "released" });
3394
+ };
3395
+ const stopReleaseRequests = communication?.subscribe(WORKFLOW_SESSION_RELEASE_TYPE, (source) => {
3396
+ releaseWorkflowSession(source).catch(reportLifecycleFailure);
3397
+ });
3398
+ const stopPeerReady = communication?.onPeerReady(() => void 0);
3399
+ let control;
3400
+ let controlDisposers = [];
3401
+ const disposeControl = () => {
3402
+ for (const dispose of controlDisposers.splice(0)) dispose();
3403
+ control?.dispose();
3404
+ control = void 0;
3405
+ };
3406
+ let modeOwner;
3407
+ const modeSelected = () => (host.context.selection.state?.["minor-mode"] ?? []).includes(WORKFLOW_MODE_ID);
3408
+ const modeState = () => {
3409
+ const active = modeSelected();
3410
+ return {
3411
+ activation: active ? "active" : "inactive",
3412
+ condition: ready?.snapshot().state === "failed" ? "failed" : "ready",
3413
+ ...active ? { detail: "workflow tools available" } : {},
3414
+ actions: [{
3415
+ id: "activate",
3416
+ enabled: !active,
3417
+ ...!active ? {} : { disabledReason: "Workflow mode is active." }
3418
+ }, {
3419
+ id: "deactivate",
3420
+ enabled: active,
3421
+ ...active ? {} : { disabledReason: "Workflow mode is inactive." }
3422
+ }]
3423
+ };
3424
+ };
3425
+ const selectMode = async (enabled) => {
3426
+ const modes = (host.context.selection.state?.["minor-mode"] ?? []).filter((mode) => mode !== WORKFLOW_MODE_ID);
3427
+ await host.changeSelection({
3428
+ axis: "state",
3429
+ key: "minor-mode",
3430
+ values: enabled ? [...modes, WORKFLOW_MODE_ID] : modes
3431
+ });
3432
+ modeOwner?.publish();
3433
+ };
3434
+ modeOwner = defineMinorMode({
3435
+ descriptor: {
3436
+ source: SOURCE,
3437
+ id: WORKFLOW_MODE_ID,
3438
+ label: "Workflow",
3439
+ description: "Workflow discovery, launch, inspection, control, and recovery tools.",
3440
+ order: 20,
3441
+ actions: [{
3442
+ id: "activate",
3443
+ label: "Activate",
3444
+ description: "Enable workflow tools for this session.",
3445
+ contexts: ["headless"],
3446
+ parameters: []
3447
+ }, {
3448
+ id: "deactivate",
3449
+ label: "Deactivate",
3450
+ description: "Disable workflow tools without stopping active runs.",
3451
+ contexts: ["headless"],
3452
+ parameters: []
3453
+ }]
3454
+ },
3455
+ state: modeState,
3456
+ async handleAction(_runtime, actionId, _argumentsValue, execution) {
3457
+ execution.signal.throwIfAborted();
3458
+ if (ready?.snapshot().state !== "ready") await waitForReadiness(execution.signal);
3459
+ if (actionId === "activate") {
3460
+ await selectMode(true);
3461
+ return { message: "Workflow mode activated." };
3462
+ }
3463
+ if (actionId === "deactivate") {
3464
+ await selectMode(false);
3465
+ return { message: "Workflow mode deactivated." };
3466
+ }
3467
+ throw new Error(`Unknown workflow mode action: ${actionId}`);
3468
+ }
3469
+ }).createOwner(void 0);
3470
+ let startingControl;
3471
+ const startControl = (executionContext) => {
3472
+ startingControl ??= (async () => {
3473
+ if (control !== void 0) {
3474
+ const ownedControl = control;
3475
+ await publishLifecycle(executionContext);
3476
+ return ownedControl;
3477
+ }
3478
+ if (disposed) throw new Error("Workflow runtime is disposed.");
3479
+ control = feature.createRunControl({ recordFilter: (record) => isSessionRun(record, host.context.sessionId) || isLaunchedRun(record, host.context.sessionId) });
3480
+ const ownedControl = control;
3481
+ const refresh = () => {
3482
+ publishRunsSoon(executionContext, (active) => {
3483
+ if (!active && !modeSelected() && control === ownedControl) disposeControl();
3484
+ });
3485
+ };
3486
+ controlDisposers = [
3487
+ ownedControl.on("runStarted", refresh),
3488
+ ownedControl.on("runUpdated", refresh),
3489
+ ownedControl.on("runFinished", refresh),
3490
+ ownedControl.on("job", refresh),
3491
+ ownedControl.on("step", refresh)
3492
+ ];
3493
+ try {
3494
+ await ownedControl.start();
3495
+ if (disposed || control !== ownedControl) throw new Error("Workflow observer was superseded.");
3496
+ await publishLifecycle(executionContext);
3497
+ return ownedControl;
3498
+ } catch (error) {
3499
+ if (control === ownedControl) disposeControl();
3500
+ throw error;
3501
+ }
3502
+ })().finally(() => {
3503
+ startingControl = void 0;
3504
+ });
3505
+ return startingControl;
3506
+ };
3507
+ const waitForReadiness = async (signal) => {
3508
+ signal?.throwIfAborted();
3509
+ if (disposed) throw new Error("Workflow runtime is disposed.");
3510
+ if (ready === void 0) throw new Error("Workflow session initialization has not started.");
3511
+ await ready.wait(signal === void 0 ? void 0 : { signal });
3512
+ if (disposed) throw new Error("Workflow runtime is disposed.");
3513
+ };
3514
+ return {
3515
+ api: [createWorkflowApi(async (parameters) => {
3516
+ await waitForReadiness();
3517
+ return launchFromApi(parameters);
3518
+ })],
3519
+ async onStart() {
3520
+ readiness ??= ownedReadiness = createDoomReadinessCoordinator({ notify: (notice) => {
3521
+ host.context.client.notify({
3522
+ body: `${SOURCE} initialization ${notice.state}: ${notice.error?.message ?? notice.diagnostics.join("; ")}`,
3523
+ level: "warning"
3524
+ });
3525
+ } });
3526
+ ready = readiness.start(SOURCE, generation, async (signal) => {
3527
+ signal.throwIfAborted();
3528
+ await untilAborted(startControl(host.context), AbortSignal.any([signal, startupAbort.signal]));
3529
+ signal.throwIfAborted();
3530
+ if (disposed) throw new Error("Workflow runtime is disposed.");
3531
+ narrationReady = true;
3532
+ return { value: void 0 };
3533
+ });
3534
+ await Promise.allSettled([ready.wait()]);
3535
+ modeOwner?.publish();
3536
+ },
3537
+ services: [
3538
+ (context) => {
3539
+ readiness = readDoomReadinessCoordinator(context);
3540
+ context.inject([DOOM_NARRATION_SERVICE], (serviceContext) => {
3541
+ const service = requireDoomNarrationService(serviceContext);
3542
+ narration = service;
3543
+ return () => {
3544
+ if (narration === service) narration = void 0;
3545
+ };
3546
+ });
3547
+ },
3548
+ serverMinorModes([modeOwner]),
3549
+ (context) => {
3550
+ serviceContext = context;
3551
+ const provider = registerRunProvider(context);
3552
+ runProvider = provider;
3553
+ return () => {
3554
+ provider.dispose();
3555
+ if (serviceContext === context) serviceContext = void 0;
3556
+ if (runProvider === provider) runProvider = void 0;
3557
+ };
3558
+ }
3559
+ ],
3560
+ hooks: [
3561
+ {
3562
+ event: "tool_execution_end",
3563
+ handle: async (_event, executionContext) => {
3564
+ await publishLifecycle(executionContext);
3565
+ }
3566
+ },
3567
+ {
3568
+ event: "session_shutdown",
3569
+ handle: disposeControl
3570
+ },
3571
+ {
3572
+ event: "session_start",
3573
+ handle: async () => {
3574
+ if ((workflowSession || dispatcher || host.context.environment.WORKFLOW_MCP_MODE === "on") && !modeSelected()) await selectMode(true);
3575
+ }
3576
+ },
3577
+ {
3578
+ event: "agent_start",
3579
+ handle: () => {
3580
+ lifecycle?.agentStarted();
3581
+ }
3582
+ },
3583
+ {
3584
+ event: "agent_settled",
3585
+ handle: async (_event, execution) => {
3586
+ const environment = execution.environment;
3587
+ if (disposed || stepGatePending || !environment[WORKFLOW_DECISION_FILE_ENV] || environment[WORKFLOW_DECISION_STEERING_ENV] === WORKFLOW_DECISION_STEERING_HOST || environment.PI_SUBAGENT_CHILD) return;
3588
+ stepGatePending = true;
3589
+ try {
3590
+ const activity = await execution.session.activity();
3591
+ if (disposed || !activity.isIdle || activity.hasPendingMessages) return;
3592
+ const snapshot = serviceContext === void 0 ? void 0 : readDoomBackgroundWorkService(serviceContext)?.snapshot(execution.sessionId);
3593
+ if (snapshot === void 0 || snapshot.items.some(holdsSettledSession) || snapshot.errors.some((error) => BACKGROUND_WORK_HOLDING_PROVIDERS.includes(error.provider))) return;
3594
+ const gate = await gateStepDecision(environment);
3595
+ if (disposed || gate.action !== "block") return;
3596
+ if (execution.session.admitPrompt === void 0) throw new Error("This host cannot admit workflow reminders.");
3597
+ await execution.session.admitPrompt(gate.message, "steer");
3598
+ } finally {
3599
+ stepGatePending = false;
3600
+ }
3601
+ }
3602
+ }
3603
+ ],
3604
+ resources: [
3605
+ {
3606
+ when: {
3607
+ state: { "minor-mode": WORKFLOW_MODE_ID },
3608
+ attribution: {
3609
+ kind: "minor",
3610
+ mode: WORKFLOW_MODE_ID
3611
+ }
3612
+ },
3613
+ name: "po-author-workflow",
3614
+ kind: "skill",
3615
+ read: () => readWorkflowSkill("po-author-workflow")
3616
+ },
3617
+ {
3618
+ when: {
3619
+ state: { "minor-mode": WORKFLOW_MODE_ID },
3620
+ attribution: {
3621
+ kind: "minor",
3622
+ mode: WORKFLOW_MODE_ID
3623
+ }
3624
+ },
3625
+ name: "po-use-workflow",
3626
+ kind: "skill",
3627
+ read: () => readWorkflowSkill("po-use-workflow")
3628
+ },
3629
+ {
3630
+ when: {
3631
+ state: { "minor-mode": WORKFLOW_MODE_ID },
3632
+ attribution: {
3633
+ kind: "minor",
3634
+ mode: WORKFLOW_MODE_ID
3635
+ }
3636
+ },
3637
+ name: "workflow-recovery",
3638
+ kind: "skill",
3639
+ read: () => readWorkflowSkill("workflow-recovery")
3640
+ },
3641
+ {
3642
+ when: {
3643
+ state: { "minor-mode": WORKFLOW_MODE_ID },
3644
+ attribution: {
3645
+ kind: "minor",
3646
+ mode: WORKFLOW_MODE_ID
3647
+ }
3648
+ },
3649
+ name: "workflow-session",
3650
+ kind: "context",
3651
+ read: (executionContext) => workflowSession ? workflowSessionBrief(presentWorkflowRuns(readWorkflowRuns({ environment: executionContext.environment }).filter((run) => runBelongsToSession(run, executionContext.sessionId)).map((run) => run.view), Date.now())) : ""
3652
+ }
3653
+ ],
3654
+ activities: [{
3655
+ when: {
3656
+ state: { "minor-mode": WORKFLOW_MODE_ID },
3657
+ attribution: {
3658
+ kind: "minor",
3659
+ mode: WORKFLOW_MODE_ID
3660
+ }
3661
+ },
3662
+ name: SOURCE,
3663
+ async start(executionContext) {
3664
+ const reportFailure = (error) => {
3665
+ process.emitWarning(`Could not publish headless workflow state: ${String(error)}`);
3666
+ };
3667
+ const stopFor = (ownedControl) => () => {
3668
+ publishLifecycle(executionContext).then((active) => {
3669
+ if (!active && !modeSelected() && control === ownedControl) disposeControl();
3670
+ }).catch(reportFailure);
3671
+ };
3672
+ return stopFor(await startControl(executionContext));
3673
+ }
3674
+ }],
3675
+ tools: [
3676
+ {
3677
+ when: {
3678
+ state: { "minor-mode": WORKFLOW_MODE_ID },
3679
+ attribution: {
3680
+ kind: "minor",
3681
+ mode: WORKFLOW_MODE_ID
3682
+ }
3683
+ },
3684
+ name: LIST_TOOL,
3685
+ label: "List Workflows",
3686
+ description: "List workflow definitions in this repository, not existing runs.",
3687
+ promptGuidelines: ["Use list_workflows to discover definitions, not to find an existing run."],
3688
+ parameters: z.toJSONSchema(feature.listWorkflowsTool.getInputSchema()),
3689
+ executionMode: "serial",
3690
+ async execute(_toolCallId, parameters, signal, _onUpdate, context) {
3691
+ signal?.throwIfAborted();
3692
+ await waitForReadiness(signal);
3693
+ const input = parameters;
3694
+ return callResult(await feature.listWorkflowsTool.execute({
3695
+ ...input,
3696
+ directory: input?.directory === void 0 ? context.cwd : typeof input.directory === "string" ? resolve(context.cwd, input.directory) : input.directory
3697
+ }));
3698
+ }
3699
+ },
3700
+ {
3701
+ when: {
3702
+ state: { "minor-mode": WORKFLOW_MODE_ID },
3703
+ attribution: {
3704
+ kind: "minor",
3705
+ mode: WORKFLOW_MODE_ID
3706
+ }
3707
+ },
3708
+ name: LAUNCH_TOOL,
3709
+ label: "Launch Workflow",
3710
+ description: "Start a workflow run. Prefer in-process Pi execution with command: \"pi\" when supported; runner: \"codex\" is not an alias for it. A successful launch is not a completed workflow.",
3711
+ promptGuidelines: [
3712
+ "Before launching, inspect the workflow and its imports for commands and interactiveRun choices. Prefer a supported Pi template with inProcess: true, selected explicitly with command: \"pi\" (or a supported in-process \"pi-claude\" command when requested). Only use an external CLI when the user requests it or no in-process option exists.",
3713
+ "command selects the exact template. Without it, runner only matches an exact command name; otherwise each step uses its first command. runner: \"codex\" does not imply command: \"pi\", so pass command explicitly to avoid switching to \"claude\" on later steps.",
3714
+ "Launch returns when a run starts, not when it finishes. Use the exact run key and separate workspace from its launch result for workflow_run status, not Agiflow project or job identifiers.",
3715
+ "Do not relaunch a healthy running workflow merely because it has not completed."
3716
+ ],
3717
+ parameters: z.toJSONSchema(feature.runTool.getInputSchema()),
3718
+ executionMode: "serial",
3719
+ async execute(_toolCallId, parameters, signal, _onUpdate, context) {
3720
+ signal?.throwIfAborted();
3721
+ await waitForReadiness(signal);
3722
+ const input = parameters;
3723
+ return callResult(await launch({
3724
+ ...input,
3725
+ ...typeof input.workflowPath === "string" ? { workflowPath: resolve(context.cwd, input.workflowPath) } : {}
3726
+ }, context));
3727
+ }
3728
+ },
3729
+ {
3730
+ when: {
3731
+ state: { "minor-mode": WORKFLOW_MODE_ID },
3732
+ attribution: {
3733
+ kind: "minor",
3734
+ mode: WORKFLOW_MODE_ID
3735
+ }
3736
+ },
3737
+ name: RUN_TOOL,
3738
+ label: "Workflow Run",
3739
+ description: "Inspect a session-owned workflow by exact run key and separate workspace, or request cooperative control.",
3740
+ promptGuidelines: [
3741
+ "Use workflow_run status for the recorded stage, outcome, position, jobs, and steps. A successful status lookup is not evidence that the workflow completed.",
3742
+ "For a healthy running workflow, summarize the recorded progress. Do not automatically search CLI commands or filesystem logs, relaunch, or diagnose failure merely because it remains running.",
3743
+ "Investigate further when the run reports failure or staleness, or when the user requests diagnostics. Only status, pause, resume, and stop are supported here; stop only on user request.",
3744
+ "Use exact registry runKey and separate workspace from the launch or status result. Agiflow project and job identifiers are not workflow run keys."
3745
+ ],
3746
+ parameters: {
3747
+ type: "object",
3748
+ properties: {
3749
+ action: {
3750
+ type: "string",
3751
+ enum: [
3752
+ "status",
3753
+ "pause",
3754
+ "resume",
3755
+ "stop"
3756
+ ]
3757
+ },
3758
+ runKey: {
3759
+ type: "string",
3760
+ minLength: 1,
3761
+ description: "Exact registry run key from the launch result, without the workspace prefix. Not an Agiflow job ID."
3762
+ },
3763
+ workspace: {
3764
+ type: "string",
3765
+ description: "Workspace from the launch result, passed separately from runKey."
3766
+ },
3767
+ reason: { type: "string" },
3768
+ expectedRunId: { type: "string" }
3769
+ },
3770
+ required: ["action", "runKey"],
3771
+ additionalProperties: false
3772
+ },
3773
+ executionMode: "serial",
3774
+ async execute(_toolCallId, parameters, signal) {
3775
+ signal?.throwIfAborted();
3776
+ await waitForReadiness(signal);
3777
+ const input = parameters;
3778
+ const runs = readWorkflowRuns({ environment: host.context.environment }).filter((run) => (runBelongsToSession(run, host.context.sessionId) || runLaunchedBySession(run, host.context.sessionId)) && run.view.runKey === input.runKey && (input.workspace === void 0 || run.view.workspace === input.workspace));
3779
+ if (runs.length !== 1) return {
3780
+ ...callResult({ error: runs.length === 0 ? "No matching workflow run was found in this session. This is a lookup failure, not evidence that the workflow failed. Use the exact runKey and separate workspace from its launch result, not Agiflow job identifiers." : "Specify the workspace for this run key." }),
3781
+ isError: true
3782
+ };
3783
+ const run = runs[0].view;
3784
+ if (input.action === "status") return callResult(run);
3785
+ if (!runBelongsToSession(runs[0], host.context.sessionId)) return callResult({ error: `This run belongs to workflow session ${run.ownerSessionId ?? "unknown"}; pause, resume or stop it there.` });
3786
+ if (!control) return callResult({ error: "Workflow activity is not active." });
3787
+ if (typeof input.expectedRunId !== "string" || input.expectedRunId.length === 0) return callResult({ error: "expectedRunId is required for workflow control." });
3788
+ return callResult(input.action === "pause" ? await control.pause(input.runKey, input.expectedRunId, input.workspace, input.reason) : input.action === "resume" ? await control.resume(input.runKey, input.expectedRunId, input.workspace) : await control.stop(input.runKey, input.expectedRunId, input.workspace, input.reason));
3789
+ }
3790
+ },
3791
+ {
3792
+ when: {
3793
+ state: { "minor-mode": WORKFLOW_MODE_ID },
3794
+ attribution: {
3795
+ kind: "minor",
3796
+ mode: WORKFLOW_MODE_ID
3797
+ }
3798
+ },
3799
+ name: WORKFLOW_TOOLS_TOOL_NAME,
3800
+ label: "Workflow Tools",
3801
+ description: "Workflow actions beyond workflow_run: recovery-evidence reads a failed run's durable evidence, including one an earlier session launched; recover resumes it from its active repair in this session.",
3802
+ promptGuidelines: [
3803
+ "Load workflow-recovery and read recovery-evidence before recover; recover is not a fresh launch.",
3804
+ "A recover answer means the replay started, not that it succeeded; verify with workflow_run status.",
3805
+ "Already claimed means another recovery won: do not retry or launch fresh. Never edit issue.md or repair.json."
3806
+ ],
3807
+ parameters: z.toJSONSchema(workflowToolsInputSchema),
3808
+ executionMode: "serial",
3809
+ async execute(_toolCallId, parameters, signal) {
3810
+ try {
3811
+ signal?.throwIfAborted();
3812
+ await waitForReadiness(signal);
3813
+ const input = workflowToolsInputSchema.parse(parameters);
3814
+ const matches = (await feature.registry.listRuns(input.workspace)).filter((record) => record.runKey === input.runKey && record.stage === "error");
3815
+ if (matches.length > 1) throw new Error("Run key exists in more than one workspace; retry with its workspace.");
3816
+ const record = matches[0];
3817
+ if (!record) throw new Error("No failed workflow run matches this key; do not recover a running or completed workflow.");
3818
+ const owner = record.env?.PI_SESSION_ID;
3819
+ if (input.action === "recover" && owner !== void 0 && owner !== host.context.sessionId && sessionService?.isLive(owner) === true) throw new Error(`Run ${record.runKey} belongs to live workflow session ${owner}; recover it there.`);
3820
+ if (input.action === "recover") return launchResultOf(await launcher.recover({
3821
+ runKey: record.runKey,
3822
+ workspace: record.workspace
3823
+ }, {
3824
+ dryRun: input.dryRun,
3825
+ runner: input.runner
3826
+ }));
3827
+ telemetry.recordEvent("doom_workflow.recovery_evidence_requested", { outcome: "requested" });
3828
+ const sections = [];
3829
+ for (const name of [
3830
+ "changelog.md",
3831
+ "context.md",
3832
+ "progress.ndjson"
3833
+ ]) try {
3834
+ const bytes = await readFile(join(feature.registry.runDirectoryFor(record), name));
3835
+ sections.push(`--- ${name} ---\n${bytes.subarray(-65536).toString("utf8")}`);
3836
+ } catch (error) {
3837
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
3838
+ }
3839
+ return { content: [{
3840
+ type: "text",
3841
+ text: `Workflow: ${record.displayName} (${record.runKey})\n${sections.length ? "" : "No durable evidence files found.\n"}--- run.json ---\n${JSON.stringify(record, null, 2)}${sections.length ? "\n" + sections.join("\n") : ""}`
3842
+ }] };
3843
+ } catch (error) {
3844
+ return {
3845
+ content: [{
3846
+ type: "text",
3847
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`
3848
+ }],
3849
+ isError: true
3850
+ };
3851
+ }
3852
+ }
3853
+ }
3854
+ ],
3855
+ commands: [{
3856
+ when: {
3857
+ state: { "minor-mode": WORKFLOW_MODE_ID },
3858
+ attribution: {
3859
+ kind: "minor",
3860
+ mode: WORKFLOW_MODE_ID
3861
+ }
3862
+ },
3863
+ name: "workflow-launch",
3864
+ description: "Launch a workflow from a slash command.",
3865
+ async execute(args, execution) {
3866
+ const parsed = parseWorkflowLaunchCommand(args);
3867
+ if ("error" in parsed) {
3868
+ await execution.client.notify({
3869
+ body: parsed.error,
3870
+ level: "error"
3871
+ });
3872
+ return;
3873
+ }
3874
+ try {
3875
+ const entry = resolveWorkflowEntry(await catalogReader.read(execution.cwd), parsed.workflow);
3876
+ if (!entry || entry.error) {
3877
+ await execution.client.notify({
3878
+ body: entry?.error ? needsFixingNotice(entry.name, entry.error) : `Workflow not found: ${parsed.workflow}`,
3879
+ level: "error"
3880
+ });
3881
+ return;
3882
+ }
3883
+ const problems = validateWorkflowLaunch(entry, parsed);
3884
+ if (problems.length > 0) {
3885
+ await execution.client.notify({
3886
+ body: problems.join("\n"),
3887
+ level: "error"
3888
+ });
3889
+ return;
3890
+ }
3891
+ const result = callResult(await launch({
3892
+ workflowPath: entry.path,
3893
+ ...parsed.runner === void 0 ? {} : { runner: parsed.runner },
3894
+ ...parsed.command === void 0 ? {} : { command: parsed.command },
3895
+ ...parsed.choice === void 0 ? {} : { choice: parsed.choice },
3896
+ ...Object.keys(parsed.inputs).length === 0 ? {} : { inputs: parsed.inputs },
3897
+ ...parsed.prompt === void 0 ? {} : { prompt: parsed.prompt }
3898
+ }, execution));
3899
+ const rendered = result.content.find((item) => item.type === "text");
3900
+ await execution.client.notify({
3901
+ body: rendered?.type === "text" ? rendered.text : "Workflow launch requested.",
3902
+ level: result.isError ? "error" : "info"
3903
+ });
3904
+ } catch (error) {
3905
+ await execution.client.notify({
3906
+ body: String(error),
3907
+ level: "error"
3908
+ });
3909
+ }
3910
+ }
3911
+ }],
3912
+ ...dispatcher ? { toolRestrictions: [{ excludedTools: [RUN_TOOL, WORKFLOW_TOOLS_TOOL_NAME] }] } : {},
3913
+ async onDispose() {
3914
+ disposed = true;
3915
+ startupAbort.abort();
3916
+ if (deliveryRetry !== void 0) clearTimeout(deliveryRetry);
3917
+ deliveryRetry = void 0;
3918
+ pendingCompletions.clear();
3919
+ narrationReady = false;
3920
+ narration = void 0;
3921
+ await ownedReadiness?.dispose();
3922
+ disposeControl();
3923
+ lifecycle?.dispose();
3924
+ stopReleaseRequests?.();
3925
+ stopPeerReady?.();
3926
+ if (runsTimer !== void 0) clearTimeout(runsTimer);
3927
+ runsTimer = void 0;
3928
+ runsAfter = void 0;
3929
+ await launcher.dispose();
3930
+ runProvider?.dispose();
3931
+ runProvider = void 0;
3932
+ await telemetry.shutdown();
3933
+ }
3934
+ };
3935
+ }
3936
+ //#endregion
3937
+ //#region src/extensions/workspaces/sessions/(backend)/_lib/index.server.ts
3938
+ var index_server_default$1 = (({ agent, host }) => agent ? createWorkflowServerRuntime(agent, host) : { api: [api] });
3939
+ //#endregion
3940
+ //#region src/extensions/workspaces/sessions/(backend)/root.server.ts
3941
+ var root_server_default = defineRoot((context) => {
3942
+ const value = index_server_default$1(context);
3943
+ return {
3944
+ value,
3945
+ services: value.services,
3946
+ activities: value.activities,
3947
+ onStart: value.onStart,
3948
+ onStop: value.onStop,
3949
+ onDispose: value.onDispose
3950
+ };
3951
+ });
3952
+ //#endregion
3953
+ //#region src/services/workflowCatalogChannel/index.ts
3954
+ /**
3955
+ * The workflow catalog channel. Durable state seeds a session once, then
3956
+ * session lifecycle events deliver updates over the direct event bus.
3957
+ */
3958
+ function createWorkflowCatalogChannel(options = {}) {
3959
+ const reader = createWorkflowCatalogReader(options.deps ?? defaultCatalogDeps());
3960
+ return {
3961
+ frameType: WORKFLOW_CATALOG_TYPE,
3962
+ start(host) {
3963
+ const scopes = /* @__PURE__ */ new Map();
3964
+ const latest = /* @__PURE__ */ new Map();
3965
+ const subscriptions = /* @__PURE__ */ new Map();
3966
+ const publish = (scope, payload) => {
3967
+ latest.set(scope.sessionId, payload);
3968
+ host.publish(scope.sessionId, payload);
3969
+ };
3970
+ const readInitial = async (scope) => {
3971
+ let payload;
3972
+ try {
3973
+ payload = {
3974
+ cwd: scope.cwd,
3975
+ workflows: presentWorkflowCatalog(await reader.read(scope.cwd))
3976
+ };
3977
+ } catch (error) {
3978
+ const warning = error instanceof Error ? error.message : String(error);
3979
+ payload = {
3980
+ cwd: scope.cwd,
3981
+ workflows: [],
3982
+ warning
3983
+ };
3984
+ host.onNotice(`workflow catalog for ${scope.cwd} is unavailable (${warning})`);
3985
+ }
3986
+ if (scopes.get(scope.sessionId) === scope) publish(scope, payload);
3987
+ };
3988
+ return {
3989
+ payloadFor(scope) {
3990
+ return scopes.has(scope.sessionId) ? latest.get(scope.sessionId) : void 0;
3991
+ },
3992
+ sessionAdded(scope) {
3993
+ scopes.set(scope.sessionId, scope);
3994
+ subscriptions.get(scope.sessionId)?.();
3995
+ const unsubscribe = host.directEvents.subscribe(WORKFLOW_CATALOG_TYPE, scope.sessionId, (value) => {
3996
+ if (isWorkflowCatalogPayload(value)) publish(scope, value);
3997
+ }, { replayLatest: true });
3998
+ subscriptions.set(scope.sessionId, unsubscribe);
3999
+ readInitial(scope);
4000
+ },
4001
+ sessionRemoved(sessionId) {
4002
+ subscriptions.get(sessionId)?.();
4003
+ subscriptions.delete(sessionId);
4004
+ scopes.delete(sessionId);
4005
+ latest.delete(sessionId);
4006
+ },
4007
+ close() {
4008
+ for (const unsubscribe of subscriptions.values()) unsubscribe();
4009
+ subscriptions.clear();
4010
+ scopes.clear();
4011
+ latest.clear();
4012
+ }
4013
+ };
4014
+ }
4015
+ };
4016
+ }
4017
+ function isWorkflowCatalogPayload(value) {
4018
+ return typeof value === "object" && value !== null && typeof value.cwd === "string" && Array.isArray(value.workflows);
4019
+ }
4020
+ //#endregion
4021
+ //#region src/services/workflowsHubChannel/index.ts
4022
+ function isWorkflowRunsPayload(value) {
4023
+ return typeof value === "object" && value !== null && Array.isArray(value.runs);
4024
+ }
4025
+ /**
4026
+ * The workflow runs data channel. Durable registry state seeds each session
4027
+ * once; subsequent updates arrive through the host-owned direct event bus.
4028
+ *
4029
+ * A session sees the runs it owns and, beside them, the runs it handed to its
4030
+ * own workflow sessions, so its Activity rows can open each one. Those come
4031
+ * from the owners' updates, routed here by the launcher stamp on each run, and
4032
+ * from the registry for workflow sessions that are no longer live.
4033
+ */
4034
+ function createWorkflowsChannel(options = {}) {
4035
+ const read = options.read ?? readWorkflowRuns;
4036
+ return {
4037
+ frameType: WORKFLOW_RUNS_TYPE,
4038
+ start(host) {
4039
+ const scopes = /* @__PURE__ */ new Map();
4040
+ const latest = /* @__PURE__ */ new Map();
4041
+ const subscriptions = /* @__PURE__ */ new Map();
4042
+ /** Each session's own runs, as it last reported them. */
4043
+ const own = /* @__PURE__ */ new Map();
4044
+ /** Launcher, then owning workflow session, then the runs one handed the other. */
4045
+ const delegated = /* @__PURE__ */ new Map();
4046
+ const publish = (sessionId) => {
4047
+ if (!scopes.has(sessionId)) return;
4048
+ const handed = [...delegated.get(sessionId)?.values() ?? []].flat();
4049
+ const payload = { runs: presentWorkflowRuns([...own.get(sessionId) ?? [], ...handed], Date.now()) };
4050
+ latest.set(sessionId, payload);
4051
+ host.publish(sessionId, payload);
4052
+ };
4053
+ /** Hands an owner's update to every session that launched one of its runs, or did until now. */
4054
+ const routeToLaunchers = (owner, runs) => {
4055
+ const launchers = /* @__PURE__ */ new Set();
4056
+ for (const run of runs) if (run.launcherSessionId !== void 0 && run.launcherSessionId !== owner) launchers.add(run.launcherSessionId);
4057
+ for (const [launcher, byOwner] of delegated) if (byOwner.has(owner)) launchers.add(launcher);
4058
+ for (const launcher of launchers) {
4059
+ if (!scopes.has(launcher)) continue;
4060
+ const mine = runs.filter((run) => run.launcherSessionId === launcher);
4061
+ const byOwner = delegated.get(launcher) ?? /* @__PURE__ */ new Map();
4062
+ delegated.set(launcher, byOwner);
4063
+ if (mine.length === 0) byOwner.delete(owner);
4064
+ else byOwner.set(owner, mine);
4065
+ publish(launcher);
4066
+ }
4067
+ };
4068
+ return {
4069
+ payloadFor(scope) {
4070
+ return scopes.has(scope.sessionId) ? latest.get(scope.sessionId) : void 0;
4071
+ },
4072
+ sessionAdded(scope) {
4073
+ const sessionId = scope.sessionId;
4074
+ scopes.set(sessionId, scope);
4075
+ const parsed = read({ environment: scope.environment });
4076
+ own.set(sessionId, parsed.filter((run) => runBelongsToSession(run, sessionId)).map((run) => run.view));
4077
+ const byOwner = /* @__PURE__ */ new Map();
4078
+ for (const run of parsed) {
4079
+ if (!runLaunchedBySession(run, sessionId) || run.piSessionId === void 0) continue;
4080
+ byOwner.set(run.piSessionId, [...byOwner.get(run.piSessionId) ?? [], run.view]);
4081
+ }
4082
+ delegated.set(sessionId, byOwner);
4083
+ subscriptions.get(sessionId)?.();
4084
+ const unsubscribe = host.directEvents.subscribe(WORKFLOW_RUNS_TYPE, sessionId, (value) => {
4085
+ if (!isWorkflowRunsPayload(value)) return;
4086
+ own.set(sessionId, value.runs);
4087
+ publish(sessionId);
4088
+ routeToLaunchers(sessionId, value.runs);
4089
+ }, { replayLatest: true });
4090
+ subscriptions.set(sessionId, unsubscribe);
4091
+ publish(sessionId);
4092
+ },
4093
+ sessionRemoved(sessionId) {
4094
+ subscriptions.get(sessionId)?.();
4095
+ subscriptions.delete(sessionId);
4096
+ scopes.delete(sessionId);
4097
+ latest.delete(sessionId);
4098
+ own.delete(sessionId);
4099
+ delegated.delete(sessionId);
4100
+ },
4101
+ close() {
4102
+ for (const unsubscribe of subscriptions.values()) unsubscribe();
4103
+ subscriptions.clear();
4104
+ scopes.clear();
4105
+ latest.clear();
4106
+ own.clear();
4107
+ delegated.clear();
4108
+ }
4109
+ };
4110
+ }
4111
+ };
4112
+ }
4113
+ //#endregion
4114
+ //#region src/extensions/(backend)/_lib/index.server.ts
4115
+ var index_server_default = (({ host }) => host.scope === "session" ? {} : {
4116
+ channels: [createWorkflowsChannel, createWorkflowCatalogChannel],
4117
+ api: [api]
4118
+ });
4119
+ //#endregion
4120
+ //#region src/extensions/(backend)/api/workflow/route.server.ts
4121
+ var route_server_default$1 = defineRoutedContribution((context) => index_server_default(context).api?.[0], { cardinality: "optional" });
4122
+ //#endregion
4123
+ //#region src/extensions/(backend)/channel/workflow-catalog.server.ts
4124
+ var workflow_catalog_server_default = defineChannel(createWorkflowCatalogChannel);
4125
+ //#endregion
4126
+ //#region src/extensions/(backend)/channel/workflows.server.ts
4127
+ var workflows_server_default = defineChannel(createWorkflowsChannel);
4128
+ //#endregion
4129
+ //#region src/extensions/workspaces/sessions/(backend)/api/workflow/route.server.ts
4130
+ var route_server_default = defineRoutedContribution((context) => context.root.api?.[0], { cardinality: "optional" });
4131
+ //#endregion
4132
+ //#region src/extensions/workspaces/sessions/(backend)/command/workflow-launch.server.ts
4133
+ var workflow_launch_server_default = defineRoutedContribution((context) => context.root.commands?.[0], { cardinality: "optional" });
4134
+ //#endregion
4135
+ //#region src/extensions/workspaces/sessions/(backend)/hook/agent-settled.server.ts
4136
+ var agent_settled_server_default = defineRoutedContribution((context) => context.root.hooks?.[4], { cardinality: "optional" });
4137
+ //#endregion
4138
+ //#region src/extensions/workspaces/sessions/(backend)/hook/agent-start.server.ts
4139
+ var agent_start_server_default = defineRoutedContribution((context) => context.root.hooks?.[3], { cardinality: "optional" });
4140
+ //#endregion
4141
+ //#region src/extensions/workspaces/sessions/(backend)/hook/session-shutdown.server.ts
4142
+ var session_shutdown_server_default = defineRoutedContribution((context) => context.root.hooks?.[1], { cardinality: "optional" });
4143
+ //#endregion
4144
+ //#region src/extensions/workspaces/sessions/(backend)/hook/session-start.server.ts
4145
+ var session_start_server_default = defineRoutedContribution((context) => context.root.hooks?.[2], { cardinality: "optional" });
4146
+ //#endregion
4147
+ //#region src/extensions/workspaces/sessions/(backend)/hook/tool-execution-end.server.ts
4148
+ var tool_execution_end_server_default = defineRoutedContribution((context) => context.root.hooks?.[0], { cardinality: "optional" });
4149
+ //#endregion
4150
+ //#region src/extensions/workspaces/sessions/(backend)/resource/po-author-workflow.server.ts
4151
+ var po_author_workflow_server_default = defineRoutedContribution((context) => context.root.resources?.[0], { cardinality: "optional" });
4152
+ //#endregion
4153
+ //#region src/extensions/workspaces/sessions/(backend)/resource/po-use-workflow.server.ts
4154
+ var po_use_workflow_server_default = defineRoutedContribution((context) => context.root.resources?.[1], { cardinality: "optional" });
4155
+ //#endregion
4156
+ //#region src/extensions/workspaces/sessions/(backend)/resource/workflow-recovery.server.ts
4157
+ var workflow_recovery_server_default = defineRoutedContribution((context) => context.root.resources?.[2], { cardinality: "optional" });
4158
+ //#endregion
4159
+ //#region src/extensions/workspaces/sessions/(backend)/resource/workflow-session.server.ts
4160
+ var workflow_session_server_default = defineRoutedContribution((context) => context.root.resources?.[3], { cardinality: "optional" });
4161
+ //#endregion
4162
+ //#region src/extensions/workspaces/sessions/(backend)/tool-restriction/dispatcher.server.ts
4163
+ var dispatcher_server_default = defineRoutedContribution((context) => context.root.toolRestrictions?.[0], { cardinality: "optional" });
4164
+ //#endregion
4165
+ //#region src/extensions/workspaces/sessions/(backend)/tool/launch-workflow.server.ts
4166
+ var launch_workflow_server_default = defineRoutedContribution((context) => context.root.tools?.[1], { cardinality: "optional" });
4167
+ //#endregion
4168
+ //#region src/extensions/workspaces/sessions/(backend)/tool/list-workflows.server.ts
4169
+ var list_workflows_server_default = defineRoutedContribution((context) => context.root.tools?.[0], { cardinality: "optional" });
4170
+ //#endregion
4171
+ //#region src/extensions/workspaces/sessions/(backend)/tool/workflow-run.server.ts
4172
+ var workflow_run_server_default = defineRoutedContribution((context) => context.root.tools?.[2], { cardinality: "optional" });
4173
+ //#endregion
4174
+ //#region src/extensions/workspaces/sessions/(backend)/tool/workflow-tools.server.ts
4175
+ var workflow_tools_server_default = defineRoutedContribution((context) => context.root.tools?.[3], { cardinality: "optional" });
4176
+ //#endregion
4177
+ //#region generated/server.ts
4178
+ const at = (value, context) => typeof value === "function" ? value(context) : value;
4179
+ /* v8 ignore next -- generated normalization branch */
4180
+ const defined = (values) => values.filter((value) => value !== void 0);
4181
+ const via = (identity, value) => value === void 0 ? void 0 : {
4182
+ ...identity,
4183
+ ...value
4184
+ };
4185
+ const facet = defineServerPlugin({
4186
+ name: "@agimon-ai/po-workflow",
4187
+ providerOverrides: composeServerProviderOverrides(root_server_default),
4188
+ global: (context) => ({
4189
+ get api() {
4190
+ return defined([at(route_server_default$1, context)]);
4191
+ },
4192
+ get channels() {
4193
+ return [() => via({ frameType: "workflow_catalog" }, workflow_catalog_server_default()), () => via({ frameType: "workflows" }, workflows_server_default())];
4194
+ }
4195
+ }),
4196
+ workspace: (context) => ({
4197
+ get api() {
4198
+ return defined([at(route_server_default$1, context)]);
4199
+ },
4200
+ get channels() {
4201
+ return [() => via({ frameType: "workflow_catalog" }, workflow_catalog_server_default()), () => via({ frameType: "workflows" }, workflows_server_default())];
4202
+ }
4203
+ }),
4204
+ session: async (context) => {
4205
+ const scopeSession = await root_server_default(context);
4206
+ const contextSession = {
4207
+ ...context,
4208
+ root: scopeSession.value
4209
+ };
4210
+ return {
4211
+ ...composeRootHooks(scopeSession),
4212
+ get api() {
4213
+ return defined([at(route_server_default$1, context), at(route_server_default, contextSession)]);
4214
+ },
4215
+ get channels() {
4216
+ return [() => via({ frameType: "workflow_catalog" }, workflow_catalog_server_default()), () => via({ frameType: "workflows" }, workflows_server_default())];
4217
+ },
4218
+ get commands() {
4219
+ return defined([via({ name: "workflow-launch" }, at(workflow_launch_server_default, contextSession))]);
4220
+ },
4221
+ get hooks() {
4222
+ return defined([
4223
+ via({ event: "agent_settled" }, at(agent_settled_server_default, contextSession)),
4224
+ via({ event: "agent_start" }, at(agent_start_server_default, contextSession)),
4225
+ via({ event: "session_shutdown" }, at(session_shutdown_server_default, contextSession)),
4226
+ via({ event: "session_start" }, at(session_start_server_default, contextSession)),
4227
+ via({ event: "tool_execution_end" }, at(tool_execution_end_server_default, contextSession))
4228
+ ]);
4229
+ },
4230
+ get resources() {
4231
+ return defined([
4232
+ at(po_author_workflow_server_default, contextSession),
4233
+ at(po_use_workflow_server_default, contextSession),
4234
+ at(workflow_recovery_server_default, contextSession),
4235
+ at(workflow_session_server_default, contextSession)
4236
+ ]);
4237
+ },
4238
+ get toolRestrictions() {
4239
+ return defined([at(dispatcher_server_default, contextSession)]);
4240
+ },
4241
+ get tools() {
4242
+ return defined([
4243
+ via({ name: "launch_workflow" }, at(launch_workflow_server_default, contextSession)),
4244
+ via({ name: "list_workflows" }, at(list_workflows_server_default, contextSession)),
4245
+ via({ name: "workflow_run" }, at(workflow_run_server_default, contextSession)),
4246
+ via({ name: "workflow_tools" }, at(workflow_tools_server_default, contextSession))
4247
+ ]);
4248
+ },
4249
+ services: [...scopeSession.services ?? []],
4250
+ get activities() {
4251
+ return [...scopeSession.activities ?? []];
4252
+ }
4253
+ };
4254
+ }
4255
+ });
4256
+ //#endregion
4257
+ export { facet as default, facet };
4258
+
4259
+ //# sourceMappingURL=server.mjs.map