@selesai/code 0.3.10 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/core/agent-session-auto-handoff.test.d.ts +2 -0
  2. package/dist/core/agent-session-auto-handoff.test.d.ts.map +1 -0
  3. package/dist/core/agent-session-auto-handoff.test.js +161 -0
  4. package/dist/core/agent-session-auto-handoff.test.js.map +1 -0
  5. package/dist/core/agent-session.d.ts +4 -0
  6. package/dist/core/agent-session.d.ts.map +1 -1
  7. package/dist/core/agent-session.js +43 -0
  8. package/dist/core/agent-session.js.map +1 -1
  9. package/dist/core/settings-manager-auto-handoff.test.d.ts +2 -0
  10. package/dist/core/settings-manager-auto-handoff.test.d.ts.map +1 -0
  11. package/dist/core/settings-manager-auto-handoff.test.js +29 -0
  12. package/dist/core/settings-manager-auto-handoff.test.js.map +1 -0
  13. package/dist/core/settings-manager.d.ts +9 -0
  14. package/dist/core/settings-manager.d.ts.map +1 -1
  15. package/dist/core/settings-manager.js +22 -0
  16. package/dist/core/settings-manager.js.map +1 -1
  17. package/dist/defaults/models.json +2 -3
  18. package/dist/extensions/caveman/index.js +16 -1
  19. package/dist/extensions/caveman/test/extension.test.js +7 -4
  20. package/dist/extensions/caveman/test/helpers.test.js +12 -1
  21. package/dist/extensions/context-compaction-reminder.test.ts +82 -0
  22. package/dist/extensions/context-compaction-reminder.ts +28 -0
  23. package/dist/extensions/handoff-new.test.ts +2 -13
  24. package/dist/extensions/handoff-new.ts +16 -25
  25. package/dist/extensions/package.json +1 -0
  26. package/dist/extensions/pi-powerline-footer/index.ts +43 -2
  27. package/dist/extensions/pi-powerline-footer/session-usage.ts +44 -0
  28. package/dist/extensions/pi-powerline-footer/tests/session-usage.test.ts +47 -0
  29. package/dist/extensions/pi-subagents/README.md +1 -1
  30. package/dist/extensions/pi-subagents/agents/architect.md +9 -29
  31. package/dist/extensions/pi-subagents/agents/builder.md +17 -106
  32. package/dist/extensions/pi-subagents/agents/commentator.md +20 -117
  33. package/dist/extensions/pi-subagents/agents/explorer.md +14 -35
  34. package/dist/extensions/pi-subagents/agents/recapper.md +20 -12
  35. package/dist/extensions/pi-subagents/agents/researcher.md +11 -34
  36. package/dist/extensions/pi-subagents/src/agents/agents.ts +46 -0
  37. package/dist/extensions/pi-subagents/src/extension/index.ts +2 -1
  38. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +2 -0
  39. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +24 -0
  40. package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +25 -1
  41. package/dist/extensions/question/constants.ts +0 -7
  42. package/dist/extensions/question/index.ts +11 -108
  43. package/dist/extensions/question/schemas.ts +1 -26
  44. package/dist/extensions/question/shortcuts.ts +5 -14
  45. package/dist/extensions/question/tests/question-list.test.ts +8 -0
  46. package/dist/extensions/question/tests/ui-protocol.test.ts +1 -7
  47. package/dist/extensions/question/tui-adapter.ts +3 -20
  48. package/dist/extensions/question/types.ts +0 -6
  49. package/dist/extensions/question/ui-protocol.ts +2 -17
  50. package/dist/extensions/workflow/adapter.ts +395 -275
  51. package/dist/extensions/workflow/extension.ts +2 -1
  52. package/dist/extensions/workflow/modes/prototype.ts +17 -19
  53. package/dist/extensions/workflow/modes/quick.ts +17 -19
  54. package/dist/extensions/workflow/modes/task.ts +82 -0
  55. package/dist/extensions/workflow/run-state.ts +124 -0
  56. package/dist/extensions/workflow/state-machine.ts +7 -8
  57. package/dist/extensions/workflow/task-validators.ts +69 -0
  58. package/dist/index.d.ts +1 -1
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js.map +1 -1
  61. package/dist/modes/interactive/components/settings-selector.d.ts +4 -0
  62. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  63. package/dist/modes/interactive/components/settings-selector.js +20 -0
  64. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  65. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  66. package/dist/modes/interactive/interactive-mode.js +8 -0
  67. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  68. package/dist/modes/rpc/rpc-client.d.ts +8 -0
  69. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  70. package/dist/modes/rpc/rpc-client.js +12 -0
  71. package/dist/modes/rpc/rpc-client.js.map +1 -1
  72. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  73. package/dist/modes/rpc/rpc-mode.js +14 -0
  74. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  75. package/dist/modes/rpc/rpc-types.d.ts +20 -0
  76. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  77. package/dist/modes/rpc/rpc-types.js.map +1 -1
  78. package/dist/skills/workflow-creation/SKILL.md +72 -0
  79. package/docs/workflows.md +46 -7
  80. package/package.json +1 -1
@@ -12,7 +12,8 @@ import type {
12
12
  import { Text } from "@earendil-works/pi-tui";
13
13
  import { Type } from "typebox";
14
14
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
15
- import { isAbsolute, resolve } from "node:path";
15
+ import { randomUUID } from "node:crypto";
16
+ import { basename, resolve } from "node:path";
16
17
 
17
18
  import {
18
19
  WorkflowStateMachine,
@@ -21,31 +22,25 @@ import {
21
22
  type WorkflowEffect,
22
23
  type WorkflowEntry,
23
24
  type FooterState,
24
- type Phase,
25
+ type WorkflowSnapshot,
25
26
  } from "./state-machine.ts";
27
+ import {
28
+ WORKFLOW_STATE_FILENAME,
29
+ type LoopState,
30
+ type PersistedWorkflowRun,
31
+ type WorkflowModes,
32
+ listResumableWorkflowRuns,
33
+ resolveWorkflowRun,
34
+ saveWorkflowRun,
35
+ } from "./run-state.ts";
26
36
  import { MARKERS } from "./validators.ts";
27
37
 
28
- function slugify(s: string): string {
29
- const slug = s
30
- .toLowerCase()
31
- // ponytail: u-flag so Unicode chars (Chinese, emoji, etc.) are also
32
- // treated as non-alphanumeric and replaced with dashes — without /u,
33
- // JS’s \W shorthand only matches ASCII, leaving Unicode in paths
34
- // which confuses agents and tools.
35
- .replace(/[^a-z0-9]+/ug, "-")
36
- .replace(/^-+|-+$/g, "")
37
- .slice(0, 40);
38
- return slug || "workflow";
39
- }
40
-
41
- function timestamp(): string {
42
- const d = new Date();
43
- const p = (n: number) => String(n).padStart(2, "0");
44
- return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
38
+ function defaultArtifactPathFor(_goal: string, base: string): string {
39
+ return resolve(base, randomUUID());
45
40
  }
46
41
 
47
- function defaultArtifactPathFor(goal: string, base: string): string {
48
- return `${base}/${timestamp()}-${slugify(goal)}`;
42
+ function artifactsBaseFor(config: WorkflowConfig): string {
43
+ return resolve(config.artifactsBase ?? "./.selesai/artifacts");
49
44
  }
50
45
 
51
46
  async function realFileExists(path: string): Promise<boolean> {
@@ -57,18 +52,6 @@ async function realFileExists(path: string): Promise<boolean> {
57
52
  }
58
53
  }
59
54
 
60
- // ponytail: phases where one spawned subagent owns the phase artifact. Force
61
- // the child `output` path; child processes do not share the parent's workflow
62
- // state, so the parent-scoped write_workflow_artifact tool cannot help there.
63
- // Plan 5: these are also the single-owner phases — parallel/chain calls are
64
- // blocked here because one agent must own plan.md / reuse.md / handoff.md /
65
- // review.md. Loop is engine-owned (Plan 3) and may fan out.
66
- const FORCE_OUTPUT_PHASES = new Set<Phase>(["plan", "reuse", "handoff", "audit"]);
67
-
68
- // ponytail: phases where the parent can save subagent text as the artifact
69
- // when the child did not write the file itself (dumb/local models).
70
- const SUBAGENT_FALLBACK_PHASES = new Set<Phase>(["plan", "reuse", "handoff", "audit"]);
71
-
72
55
  function textFromToolResultContent(content: unknown): string | undefined {
73
56
  if (!Array.isArray(content)) return undefined;
74
57
  const parts: string[] = [];
@@ -81,75 +64,23 @@ function textFromToolResultContent(content: unknown): string | undefined {
81
64
  return joined || undefined;
82
65
  }
83
66
 
84
- function validatorForPhaseArtifact(config: WorkflowConfig, phase: Phase) {
85
- const phaseValidator = config.artifactValidators?.[phase];
86
- if (phaseValidator) return phaseValidator;
87
- const file = config.phaseArtifacts[phase];
88
- if (!file) return undefined;
89
- const isTerminal = config.phases[config.phases.length - 1] === phase;
90
- if (!isTerminal || !config.closeArtifacts.includes(file)) return undefined;
91
- return config.closeValidators?.[file];
92
- }
93
-
94
- function shouldReplaceInvalidArtifact(
95
- config: WorkflowConfig,
96
- phase: Phase,
97
- currentContent: string,
98
- fallbackContent: string,
99
- ): boolean {
100
- const validator = validatorForPhaseArtifact(config, phase);
101
- if (!validator) return false;
102
- return !validator(currentContent).ok && validator(fallbackContent).ok;
103
- }
104
-
105
- // ponytail: rewrite the subagent tool input so the child writes its output
106
- // directly to ${artifactDir}/${file} as an absolute path. Absolute paths pass
107
- // through resolveSingleOutputPath verbatim, so injectOutputPathSystemPrompt
108
- // then forces the child to the correct location instead of repo root.
109
- // Mutates `input` in place (tool_call handlers can patch event.input).
110
- // No-op unless the workflow is active, in a force-output phase, and the call
111
- // targets the `subagent` tool. Respects an explicit absolute caller output.
112
- function forceSubagentOutputToArtifactDir(
113
- input: Record<string, unknown>,
114
- artifactDir: string,
115
- file: string,
116
- onlyAgents?: Set<string>,
117
- ): void {
118
- const dest = resolve(artifactDir, file);
119
- const shouldForce = (agent: string) => !onlyAgents || onlyAgents.has(agent);
120
- // Single-agent call: { agent, task, output?, ... }
121
- if (typeof input.agent === "string") {
122
- if (!shouldForce(input.agent)) return;
123
- const existing = input.output;
124
- if (typeof existing === "string" && isAbsolute(existing)) return; // caller pinned it
125
- input.output = dest;
126
- return;
127
- }
128
- // Top-level parallel: { tasks: [{ agent, output? }, ...] }
67
+ // Mutates a workflow subagent invocation to suppress both its configured
68
+ // default output and any caller-provided child output path. `output: false`
69
+ // preserves the normal inline result, which the parent must write explicitly.
70
+ function disableSubagentOutput(input: Record<string, unknown>): void {
71
+ if (typeof input.agent === "string") input.output = false;
129
72
  if (Array.isArray(input.tasks)) {
130
73
  for (const task of input.tasks) {
131
- if (task && typeof task === "object" && typeof task.agent === "string" && shouldForce(task.agent)) {
132
- const ex = task.output;
133
- if (typeof ex === "string" && isAbsolute(ex)) continue;
134
- task.output = dest;
135
- }
74
+ if (task && typeof task === "object") task.output = false;
136
75
  }
137
- return;
138
76
  }
139
- // Chain: { chain: [{ agent, output?, parallel: [{ agent, output? }] }] }
140
77
  if (Array.isArray(input.chain)) {
141
78
  for (const step of input.chain) {
142
79
  if (!step || typeof step !== "object") continue;
143
- if (typeof step.agent === "string" && shouldForce(step.agent)) {
144
- const ex = step.output;
145
- if (!(typeof ex === "string" && isAbsolute(ex))) step.output = dest;
146
- }
80
+ step.output = false;
147
81
  if (Array.isArray(step.parallel)) {
148
82
  for (const task of step.parallel) {
149
- if (task && typeof task === "object" && typeof task.agent === "string" && shouldForce(task.agent)) {
150
- const ex = task.output;
151
- if (!(typeof ex === "string" && isAbsolute(ex))) task.output = dest;
152
- }
83
+ if (task && typeof task === "object") task.output = false;
153
84
  }
154
85
  }
155
86
  }
@@ -157,8 +88,8 @@ function forceSubagentOutputToArtifactDir(
157
88
  }
158
89
 
159
90
  export interface WorkflowAdapterOptions {
160
- toolNames: { start: string; end: string };
161
- toolLabels: { start: string; end: string };
91
+ toolNames: { start: string; resume: string; end: string };
92
+ toolLabels: { start: string; resume: string; end: string };
162
93
  commandName: string;
163
94
  commandDescription: string;
164
95
  }
@@ -168,26 +99,14 @@ interface WorkflowController {
168
99
  config: WorkflowConfig;
169
100
  sm: WorkflowStateMachine;
170
101
  deps: WorkflowDeps;
171
- // ponytail: Plan 3 — engine-owned loop state. Lives on the controller, not
172
- // the state machine: the SM only cares "does loop-complete.md exist →
173
- // advance"; the adapter owns iteration counting + marker parsing + driving
174
- // the next subagent call. Cleared when the phase leaves "loop".
175
- // Ceiling: not persisted across session restart — a restart re-runs the
176
- // loop from round 0. Acceptable for a prototype workflow.
102
+ // The state machine stays pure; the adapter persists this orchestration
103
+ // detail alongside the state-machine snapshot.
177
104
  loopState?: LoopState;
105
+ run?: PersistedWorkflowRun;
178
106
  seenToolCallIds: Set<string>;
179
107
  lastBlockedKey?: string;
180
108
  }
181
109
 
182
- // ponytail: Plan 3 — loop orchestration state. The parent model does not
183
- // remember iterations; the adapter counts review rounds, parses the
184
- // commentator's WORKFLOW_REVIEW_STATUS marker, and drives the next step.
185
- interface LoopState {
186
- reviewRound: number;
187
- maxIterations: number;
188
- stage: "building" | "reviewing" | "clean" | "maxed";
189
- }
190
-
191
110
  const WORKFLOW_ARTIFACT_TOOL = "write_workflow_artifact";
192
111
  // ponytail: shared across all module copies so prototype + quick registration
193
112
  // can race. Module-level state alone is not enough when the loader runs each
@@ -210,6 +129,141 @@ function activeControllersFor(pi: ExtensionAPI): WorkflowController[] {
210
129
  return registry.controllers.filter((c) => c.pi === pi && c.sm.snapshot.active);
211
130
  }
212
131
 
132
+ function isRegisteredController(controller: WorkflowController): boolean {
133
+ return registry.controllers.includes(controller);
134
+ }
135
+
136
+ // Extension reload can reuse the same ExtensionAPI object while this module's
137
+ // global registry survives. Replace a mode's old controller so its active,
138
+ // pre-reload state cannot handle new tool results without a persisted run.
139
+ function replaceControllerFor(pi: ExtensionAPI, mode: string): void {
140
+ for (let i = registry.controllers.length - 1; i >= 0; i--) {
141
+ const controller = registry.controllers[i]!;
142
+ if (controller.pi === pi && controller.config.mode === mode) {
143
+ registry.controllers.splice(i, 1);
144
+ }
145
+ }
146
+ }
147
+
148
+ function modesFor(config: WorkflowConfig): WorkflowModes {
149
+ return { [config.mode]: config.phases };
150
+ }
151
+
152
+ function snapshotRun(controller: WorkflowController): PersistedWorkflowRun {
153
+ if (!controller.run) throw new Error("No workflow run is attached.");
154
+ const snapshot = controller.sm.snapshot;
155
+ return {
156
+ ...controller.run,
157
+ status: snapshot.active ? "active" : "completed",
158
+ goal: snapshot.userPrompt,
159
+ artifactDir: snapshot.artifactDir,
160
+ phase: snapshot.phase,
161
+ autoArmed: snapshot.autoArmed,
162
+ loopState: controller.loopState,
163
+ updatedAt: new Date().toISOString(),
164
+ };
165
+ }
166
+
167
+ async function persistRun(controller: WorkflowController): Promise<void> {
168
+ const run = snapshotRun(controller);
169
+ await saveWorkflowRun(run);
170
+ controller.run = run;
171
+ }
172
+
173
+ interface ControllerCheckpoint {
174
+ snapshot: WorkflowSnapshot;
175
+ loopState?: LoopState;
176
+ run?: PersistedWorkflowRun;
177
+ }
178
+
179
+ function checkpoint(controller: WorkflowController): ControllerCheckpoint {
180
+ return {
181
+ snapshot: controller.sm.snapshot,
182
+ loopState: controller.loopState && { ...controller.loopState },
183
+ run: controller.run && { ...controller.run, loopState: controller.run.loopState && { ...controller.run.loopState } },
184
+ };
185
+ }
186
+
187
+ function restoreCheckpoint(controller: WorkflowController, before: ControllerCheckpoint): void {
188
+ controller.sm.rehydrate(before.snapshot);
189
+ controller.loopState = before.loopState;
190
+ controller.run = before.run;
191
+ }
192
+
193
+ async function persistAfter(controller: WorkflowController, before: ControllerCheckpoint): Promise<void> {
194
+ try {
195
+ await persistRun(controller);
196
+ } catch (error) {
197
+ restoreCheckpoint(controller, before);
198
+ throw error;
199
+ }
200
+ }
201
+
202
+ async function resumeController(
203
+ controller: WorkflowController,
204
+ ctx: ExtensionContext,
205
+ selector: string,
206
+ endTool: string,
207
+ ): Promise<any> {
208
+ const { pi, config, sm } = controller;
209
+ if (activeControllersFor(pi).length) {
210
+ return { content: [{ type: "text", text: "A workflow is already active. End it before resuming another run." }], details: { alreadyActive: true } };
211
+ }
212
+ let selected;
213
+ try {
214
+ selected = await resolveWorkflowRun(selector, artifactsBaseFor(config), modesFor(config));
215
+ if (selected.run.status !== "active") throw new Error("Completed workflow runs cannot be resumed.");
216
+ if (selected.run.mode !== config.mode) throw new Error(`This is a ${selected.run.mode} run, not ${config.mode}.`);
217
+ } catch (error) {
218
+ return { content: [{ type: "text", text: `Cannot resume workflow: ${error instanceof Error ? error.message : String(error)}` }], details: { rejected: true } };
219
+ }
220
+ const before = checkpoint(controller);
221
+ sm.rehydrate({ active: true, phase: selected.run.phase, userPrompt: selected.run.goal, artifactDir: selected.run.artifactDir, autoArmed: selected.run.autoArmed });
222
+ controller.run = selected.run;
223
+ controller.loopState = selected.run.loopState;
224
+ controller.seenToolCallIds.clear();
225
+ controller.lastBlockedKey = undefined;
226
+ try {
227
+ await persistAfter(controller, before);
228
+ const reconcileBefore = checkpoint(controller);
229
+ const reconciled = await sm.onArtifactMaybe(controller.deps);
230
+ await persistAfter(controller, reconcileBefore);
231
+ const current = sm.continueCurrent();
232
+ const terminal = config.phases[config.phases.length - 1];
233
+ const terminalReady = reconciled.kind === "terminalReady" || (sm.snapshot.phase === terminal && !sm.snapshot.autoArmed);
234
+ if (terminalReady) {
235
+ if (current.kind === "advanced") {
236
+ applyFooter(ctx, current.footer);
237
+ applyEntry(pi, config, current.entry, controller.run);
238
+ }
239
+ continueAgent(pi, ctx, `Workflow is terminal-ready. Verify ${sm.snapshot.artifactDir} and call ${endTool} to complete it.`);
240
+ } else if (sm.snapshot.phase === "loop" && controller.loopState?.stage === "maxed") {
241
+ if (current.kind === "advanced") {
242
+ applyFooter(ctx, current.footer);
243
+ applyEntry(pi, config, current.entry, controller.run);
244
+ }
245
+ ctx.ui.notify(`Workflow loop is paused after ${controller.loopState.reviewRound}/${controller.loopState.maxIterations} blocking review rounds. Inspect ${sm.snapshot.artifactDir}/${controller.loopState.reviewPath ?? "loop-review-<round>.md"} before continuing.`, "warning");
246
+ } else if (sm.snapshot.phase === "loop" && controller.loopState) {
247
+ if (current.kind === "advanced") {
248
+ applyFooter(ctx, current.footer);
249
+ applyEntry(pi, config, current.entry, controller.run);
250
+ }
251
+ const ls = controller.loopState;
252
+ continueAgent(pi, ctx, ls.stage === "reviewing"
253
+ ? `Resume the loop at review round ${ls.reviewRound + 1}: call the subagent tool now with { agent: "commentator", task: "..." } to review the builder's uncommitted diff against ${sm.snapshot.artifactDir}/plan.md. End the review with WORKFLOW_REVIEW_STATUS: clean or WORKFLOW_REVIEW_STATUS: blocking.`
254
+ : `Resume the loop at review round ${ls.reviewRound}: call the subagent tool now with { agent: "builder", task: "..." } to address the feedback in ${sm.snapshot.artifactDir}/${ls.reviewPath ?? "loop-review-<round>.md"}.`);
255
+ } else {
256
+ applyControllerEffect(controller, ctx, current);
257
+ }
258
+ } catch (error) {
259
+ return { content: [{ type: "text", text: `Cannot resume workflow: state could not be persisted: ${error instanceof Error ? error.message : String(error)}` }], details: { persistenceError: true } };
260
+ }
261
+ return {
262
+ content: [{ type: "text", text: `Resumed ${config.mode} workflow ${controller.run!.id} at ${sm.snapshot.phase}. Artifacts: ${selected.statePath}` }],
263
+ details: { runId: controller.run!.id, statePath: selected.statePath, phase: sm.snapshot.phase },
264
+ };
265
+ }
266
+
213
267
  // ponytail: builds the deps bundle the adapter injects into the state machine.
214
268
  // The skip predicate wraps pi.exec("git"...) — the only true-external seam.
215
269
  function makeDeps(pi: ExtensionAPI, config: WorkflowConfig): WorkflowDeps {
@@ -276,8 +330,11 @@ function footerText(footer: FooterState, ctx: ExtensionContext): string | undefi
276
330
  return ctx.ui.theme.fg("warning", footer.text);
277
331
  }
278
332
 
279
- function applyEntry(pi: ExtensionAPI, config: WorkflowConfig, entry: WorkflowEntry): void {
280
- pi.appendEntry(config.entryType, entry);
333
+ function applyEntry(pi: ExtensionAPI, config: WorkflowConfig, entry: WorkflowEntry, run?: PersistedWorkflowRun): void {
334
+ pi.appendEntry(config.entryType, {
335
+ ...entry,
336
+ ...(run ? { workflowStatePath: resolve(run.artifactDir, WORKFLOW_STATE_FILENAME), runId: run.id } : {}),
337
+ });
281
338
  }
282
339
 
283
340
  function applyFooter(ctx: ExtensionContext, footer: FooterState): void {
@@ -304,16 +361,18 @@ function applyEffect(
304
361
  ctx: ExtensionContext,
305
362
  config: WorkflowConfig,
306
363
  eff: WorkflowEffect,
364
+ run?: PersistedWorkflowRun,
365
+ queuePrompt = true,
307
366
  ): void {
308
367
  switch (eff.kind) {
309
368
  case "started":
310
369
  case "advanced":
311
- if ("entry" in eff) applyEntry(pi, config, eff.entry);
370
+ if ("entry" in eff) applyEntry(pi, config, eff.entry, run);
312
371
  if ("footer" in eff) applyFooter(ctx, eff.footer);
313
- if (eff.prompt) continueAgent(pi, ctx, eff.prompt);
372
+ if (queuePrompt && eff.prompt) continueAgent(pi, ctx, eff.prompt);
314
373
  return;
315
374
  case "closed":
316
- if ("entry" in eff) applyEntry(pi, config, eff.entry);
375
+ if ("entry" in eff) applyEntry(pi, config, eff.entry, run);
317
376
  if ("footer" in eff) applyFooter(ctx, eff.footer);
318
377
  ctx.ui.notify(
319
378
  `${config.footerLabel} workflow complete. Artifacts: ${eff.artifactDir}`,
@@ -321,17 +380,16 @@ function applyEffect(
321
380
  );
322
381
  return;
323
382
  case "terminalNeedsArtifacts":
324
- if (eff.promptToQueue) continueAgent(pi, ctx, eff.promptToQueue);
383
+ if (queuePrompt && eff.promptToQueue) continueAgent(pi, ctx, eff.promptToQueue);
325
384
  return;
326
385
  case "blocked":
327
- // ponytail: Plan 4 — surface the semantic reason so a subagent-driven
328
- // phase (plan/handoff/audit) learns which marker is missing instead of
329
- // the workflow appearing to stall after the artifact was written.
330
- if (eff.reason) {
386
+ if (queuePrompt && eff.reason) {
331
387
  continueAgent(pi, ctx, `Phase ${eff.phase} artifact exists but is not approved: ${eff.reason}. Edit it via write_workflow_artifact to add the required marker, then the workflow will advance.`);
332
388
  }
333
389
  return;
334
390
  case "terminalReady":
391
+ if (queuePrompt) continueAgent(pi, ctx, `Terminal artifacts are ready. Call the explicit end workflow tool to complete this run.`);
392
+ return;
335
393
  case "endBlocked":
336
394
  case "idle":
337
395
  case "noOp":
@@ -349,6 +407,7 @@ function applyControllerEffect(
349
407
  controller: WorkflowController,
350
408
  ctx: ExtensionContext,
351
409
  eff: WorkflowEffect,
410
+ options: { queuePrompt?: boolean } = {},
352
411
  ): void {
353
412
  const blockedKey = blockedKeyFor(eff);
354
413
  if (blockedKey) {
@@ -357,13 +416,25 @@ function applyControllerEffect(
357
416
  } else if (eff.kind !== "noOp") {
358
417
  controller.lastBlockedKey = undefined;
359
418
  }
360
- applyEffect(controller.pi, ctx, controller.config, eff);
419
+ applyEffect(controller.pi, ctx, controller.config, eff, controller.run, options.queuePrompt);
361
420
  }
362
421
 
363
422
  function isSubagentManagementAction(input: Record<string, unknown> | undefined): boolean {
364
423
  return typeof input?.action === "string";
365
424
  }
366
425
 
426
+ function commandHelp(config: WorkflowConfig, options: WorkflowAdapterOptions): string {
427
+ const command = `/${options.commandName}`;
428
+ return [
429
+ `${config.footerLabel} workflow`,
430
+ `${command} <goal> — start a new durable run.`,
431
+ `${command} resume — list resumable runs.`,
432
+ `${command} resume <run-id|artifact-dir|workflow.json> — explicitly resume one.`,
433
+ `Phases advance when their artifacts are written. Runs never auto-resume after reload.`,
434
+ `Only one run may be attached; ${options.toolNames.end} explicitly completes a terminal-ready run.`,
435
+ ].join("\n");
436
+ }
437
+
367
438
  function registerSharedArtifactWriter(pi: ExtensionAPI): void {
368
439
  if (registry.writerRegisteredFor.has(pi)) return;
369
440
  registry.writerRegisteredFor.add(pi);
@@ -399,17 +470,38 @@ function registerSharedArtifactWriter(pi: ExtensionAPI): void {
399
470
  const path = resolve(snap.artifactDir, file);
400
471
  await mkdir(snap.artifactDir, { recursive: true });
401
472
  await writeFile(path, params.content, "utf8");
402
- const eff = await controller.sm.onArtifactMaybe(controller.deps);
403
- applyControllerEffect(controller, ctx, eff);
473
+ const before = checkpoint(controller);
474
+ let eff: WorkflowEffect;
475
+ try {
476
+ eff = await controller.sm.onArtifactMaybe(controller.deps);
477
+ await persistAfter(controller, before);
478
+ } catch (error) {
479
+ return {
480
+ content: [{ type: "text", text: `Wrote ${path}, but could not persist workflow state: ${error instanceof Error ? error.message : String(error)}` }],
481
+ details: { mode: controller.config.mode, phase: snap.phase, path, persistenceError: true },
482
+ };
483
+ }
484
+ // Most workflows pause at a user-controlled artifact boundary. Task's
485
+ // plan → loop transition queues the builder/review prompt immediately.
486
+ const queueNextPhase = controller.config.continueAfterArtifact === true && eff.kind === "advanced";
487
+ applyControllerEffect(controller, ctx, eff, { queuePrompt: queueNextPhase });
404
488
  if (eff.kind === "blocked" && eff.reason) {
405
489
  return {
406
490
  content: [{ type: "text", text: `Wrote ${path}, but it is not approved: ${eff.reason}. Re-write it via write_workflow_artifact to add the required marker.` }],
407
491
  details: { mode: controller.config.mode, phase: snap.phase, path, file, blocked: true, reason: eff.reason },
408
492
  };
409
493
  }
494
+ const advanced = eff.kind === "advanced";
410
495
  return {
411
- content: [{ type: "text", text: `Wrote ${path}.` }],
412
- details: { mode: controller.config.mode, phase: snap.phase, path, file },
496
+ content: [{ type: "text", text: advanced
497
+ ? queueNextPhase
498
+ ? `Wrote ${path}. Phase advanced to ${eff.phase}; the next workflow phase is queued.`
499
+ : `Wrote ${path}. Phase advanced to ${eff.phase}; wait for the user to continue the workflow.`
500
+ : `Wrote ${path}.` }],
501
+ details: { mode: controller.config.mode, phase: snap.phase, path, file, advanced },
502
+ // Stop only at user-controlled boundaries. Task deliberately queues
503
+ // its loop prompt, so terminating here would discard that continuation.
504
+ terminate: advanced && !queueNextPhase,
413
505
  };
414
506
  },
415
507
  renderResult(result, _options, theme) {
@@ -425,6 +517,7 @@ export function createWorkflowExtension(
425
517
  options: WorkflowAdapterOptions,
426
518
  ): (pi: ExtensionAPI) => void {
427
519
  return function workflowExtension(pi: ExtensionAPI): void {
520
+ replaceControllerFor(pi, config.mode);
428
521
  const deps = makeDeps(pi, config);
429
522
  // ponytail: default skip rule — reuse is skipped on empty projects (no git
430
523
  // commits). Shared by every mode today; a mode that wants a different rule
@@ -439,14 +532,14 @@ export function createWorkflowExtension(
439
532
  const controller: WorkflowController = { pi, config, sm, deps, seenToolCallIds: new Set() };
440
533
  registry.controllers.push(controller);
441
534
  registerSharedArtifactWriter(pi);
442
- const { start, end } = options.toolNames;
535
+ const { start, resume, end } = options.toolNames;
443
536
  const { mode, footerLabel } = config;
444
537
 
445
538
  // ── start tool ──
446
539
  pi.registerTool({
447
540
  name: start,
448
541
  label: options.toolLabels.start,
449
- description: `Start the ${mode} workflow for the given goal. Sets up grilling as the first phase and returns the grilling prompt. Do not call if a workflow is already active.`,
542
+ description: `Start the ${mode} workflow for the given goal. Sets up ${config.phases[0]} as the first phase and returns its prompt. Do not call if a workflow is already active.`,
450
543
  promptSnippet: `${start}(goal) - start the ${mode} workflow; goal is what the user wants to build.`,
451
544
  promptGuidelines: [
452
545
  `Call ${start} only when the user explicitly asks to start a ${mode} workflow and none is active.`,
@@ -465,12 +558,35 @@ export function createWorkflowExtension(
465
558
  details: { phase: other.sm.snapshot.phase, alreadyActive: true },
466
559
  };
467
560
  }
561
+ const before = checkpoint(controller);
468
562
  const eff = await sm.start(params.goal, deps);
469
563
  if (eff.kind === "started") {
564
+ controller.run = {
565
+ version: 1,
566
+ id: basename(sm.snapshot.artifactDir),
567
+ mode,
568
+ status: "active",
569
+ goal: sm.snapshot.userPrompt,
570
+ artifactDir: sm.snapshot.artifactDir,
571
+ phase: sm.snapshot.phase,
572
+ autoArmed: sm.snapshot.autoArmed,
573
+ createdAt: new Date().toISOString(),
574
+ updatedAt: new Date().toISOString(),
575
+ };
576
+ try {
577
+ await persistAfter(controller, before);
578
+ } catch (error) {
579
+ return {
580
+ content: [{ type: "text", text: `Could not start durable workflow: ${error instanceof Error ? error.message : String(error)}` }],
581
+ details: { persistenceError: true },
582
+ };
583
+ }
470
584
  controller.seenToolCallIds.clear();
471
585
  controller.lastBlockedKey = undefined;
472
586
  }
473
- applyControllerEffect(controller, ctx, eff);
587
+ // The start tool result already contains the grilling prompt. Queueing
588
+ // it again creates a duplicate autonomous turn.
589
+ applyControllerEffect(controller, ctx, eff, { queuePrompt: false });
474
590
  if (eff.kind === "alreadyActive") {
475
591
  return {
476
592
  content: [
@@ -498,6 +614,27 @@ export function createWorkflowExtension(
498
614
  },
499
615
  } satisfies ToolDefinition);
500
616
 
617
+ // ── explicit resume tool ──
618
+ pi.registerTool({
619
+ name: resume,
620
+ label: options.toolLabels.resume,
621
+ description: `Resume an explicitly selected ${mode} workflow run. Pass a run id, artifact directory, or workflow.json path.`,
622
+ promptSnippet: `${resume}(run) - resume an explicit ${mode} workflow run; run is its id, artifact directory, or workflow.json path.`,
623
+ promptGuidelines: [
624
+ `Call ${resume} only when the user explicitly selects a ${mode} workflow run.`,
625
+ "Workflow runs never resume automatically after reload.",
626
+ ],
627
+ parameters: Type.Object({ run: Type.String({ description: "Run id, artifact directory, or workflow.json path." }) }),
628
+ async execute(_id, params, _signal, _onUpdate, ctx) {
629
+ return resumeController(controller, ctx, params.run, end);
630
+ },
631
+ renderResult(result, _options, theme) {
632
+ const d = result.details as { rejected?: boolean; persistenceError?: boolean; phase?: string };
633
+ const failed = d.rejected || d.persistenceError;
634
+ return new Text(theme.fg(failed ? "warning" : "success", failed ? "○ workflow resume rejected" : `✓ workflow resumed · ${d.phase}`), 0, 0);
635
+ },
636
+ } satisfies ToolDefinition);
637
+
501
638
  // ── end tool ──
502
639
  pi.registerTool({
503
640
  name: end,
@@ -509,7 +646,18 @@ export function createWorkflowExtension(
509
646
  ],
510
647
  parameters: Type.Object({}),
511
648
  async execute(_id, _params, _signal, _onUpdate, ctx) {
649
+ const before = checkpoint(controller);
512
650
  const eff = await sm.end(deps);
651
+ if (eff.kind === "closed") {
652
+ try {
653
+ await persistAfter(controller, before);
654
+ } catch (error) {
655
+ return {
656
+ content: [{ type: "text", text: `Cannot end: workflow state could not be persisted: ${error instanceof Error ? error.message : String(error)}` }],
657
+ details: { persistenceError: true },
658
+ };
659
+ }
660
+ }
513
661
  applyControllerEffect(controller, ctx, eff);
514
662
  if (eff.kind === "closed") {
515
663
  controller.seenToolCallIds.clear();
@@ -563,162 +711,66 @@ export function createWorkflowExtension(
563
711
  },
564
712
  } satisfies ToolDefinition);
565
713
 
566
- // ── session_start: restore state from persisted entries ──
714
+ // ── session_start: disk state is never auto-attached. A session entry is
715
+ // merely a convenience pointer for rendering a stale-but-useful footer. ──
567
716
  pi.on("session_start", async (_event: any, ctx: ExtensionContext) => {
717
+ if (!isRegisteredController(controller)) return;
568
718
  const entries = ctx.sessionManager.getEntries();
569
719
  for (let i = entries.length - 1; i >= 0; i--) {
570
- const e = entries[i] as { type: string; customType?: string; data?: any };
571
- if (e.type === "custom" && e.customType === config.entryType && e.data) {
572
- if (e.data.done) {
573
- sm.rehydrate({
574
- active: false,
575
- phase: e.data.phase as Phase,
576
- userPrompt: e.data.userPrompt ?? "",
577
- artifactDir: e.data.artifactDir ?? "",
578
- autoArmed: false,
579
- });
580
- } else {
581
- sm.rehydrate({
582
- active: true,
583
- phase: e.data.phase as Phase,
584
- userPrompt: e.data.userPrompt ?? "",
585
- artifactDir: e.data.artifactDir ?? "",
586
- autoArmed: true,
587
- });
588
- }
589
- // ponytail: re-render footer from snapshot without advancing.
590
- if (sm.snapshot.active) {
591
- ctx.ui.setStatus(
592
- config.statusKey,
593
- ctx.ui.theme.fg(
594
- "warning",
595
- `● ${config.footerLabel} · ${config.phases.indexOf(sm.snapshot.phase) + 1}/${config.phases.length} ${sm.snapshot.phase}`,
596
- ),
597
- );
598
- } else {
599
- ctx.ui.setStatus(config.statusKey, undefined);
720
+ const entry = entries[i] as { type: string; customType?: string; data?: any };
721
+ const data = entry.data;
722
+ if (entry.type !== "custom" || entry.customType !== config.entryType || !data || data.mode !== mode || typeof data.workflowStatePath !== "string") continue;
723
+ try {
724
+ const { run } = await resolveWorkflowRun(data.workflowStatePath, artifactsBaseFor(config), modesFor(config));
725
+ if (run.status === "active") {
726
+ ctx.ui.setStatus(config.statusKey, ctx.ui.theme.fg(
727
+ "warning",
728
+ `● ${config.footerLabel} · ${config.phases.indexOf(run.phase) + 1}/${config.phases.length} ${run.phase} (resume required)`,
729
+ ));
600
730
  }
601
- break;
731
+ } catch {
732
+ // Old/custom entries are intentionally non-authoritative.
602
733
  }
734
+ break;
603
735
  }
604
736
  });
605
737
 
606
- // ── tool_call: enforce workflow boundaries. ──
738
+ // ── tool_call: enforce parent-owned artifact boundaries. ──
607
739
  pi.on("tool_call", (event: any, _ctx: ExtensionContext) => {
740
+ if (!isRegisteredController(controller)) return;
608
741
  const tool = event.toolName;
609
742
  if (tool !== "subagent" && tool !== "write" && tool !== "edit") return;
610
743
  const snap = sm.snapshot;
611
744
  if (!snap.active) return;
612
- if (tool === "subagent" && isSubagentManagementAction(event.input)) return;
613
- const file = config.phaseArtifacts[snap.phase];
614
745
  if (tool === "write" || tool === "edit") {
615
746
  return {
616
747
  block: true,
617
748
  reason: `Workflow is active (${mode}/${snap.phase}). Use ${WORKFLOW_ARTIFACT_TOOL} for workflow artifacts; workspace edits must be delegated to subagents.`,
618
749
  };
619
750
  }
620
- if (!file || !event.input || !FORCE_OUTPUT_PHASES.has(snap.phase)) return;
621
- // ponytail: Plan 5 — single-owner phases reject parallel/chain. One agent
622
- // must own the artifact; tasks/chain split ownership and the output
623
- // injector cannot pin one file per agent. Loop is exempt (engine-owned).
624
- if (Array.isArray(event.input.tasks) || Array.isArray(event.input.chain)) {
625
- return {
626
- block: true,
627
- reason: `Workflow phase ${snap.phase} expects one subagent owner for ${file}; use a single { agent, task } call, not tasks/chain.`,
628
- };
629
- }
630
- // ponytail: Plan 5 — workflow-spawned subagents run fresh by default so
631
- // no hidden parent context leaks into the phase owner. Only override
632
- // when the caller explicitly pins a context.
633
- if (typeof event.input.context !== "string") {
634
- event.input.context = "fresh";
635
- }
636
- // ponytail: Plan 5 — ban model override on workflow-owned phases. The
637
- // workflow, not the parent model, picks the agent; a model pin would
638
- // silently change cost/quality without the workflow knowing.
639
- if (typeof event.input.model === "string") {
640
- return {
641
- block: true,
642
- reason: `Workflow phase ${snap.phase} does not allow a model override on subagent calls; remove the model parameter.`,
643
- };
751
+ if (!isSubagentManagementAction(event.input) && event.input) {
752
+ // All child results return inline. The parent persists normal phase
753
+ // artifacts; the loop engine persists its own review files/marker.
754
+ disableSubagentOutput(event.input);
644
755
  }
645
- const onlyAgents = snap.phase === "audit" ? new Set(["commentator", "reviewer"]) : undefined;
646
- forceSubagentOutputToArtifactDir(event.input, snap.artifactDir, file, onlyAgents);
647
756
  });
648
757
 
649
- // ── tool_result: auto-advance after subagent/bash artifacts. ──
758
+ // ── tool_result: only the engine-owned loop persists subagent output. ──
650
759
  pi.on("tool_result", async (event: any, ctx: ExtensionContext) => {
651
- if (event.toolName !== "bash" && event.toolName !== "subagent") return;
652
- if (sm.snapshot.active && typeof event.toolCallId === "string") {
760
+ if (!isRegisteredController(controller)) return;
761
+ if (event.toolName !== "subagent" || event.isError) return;
762
+ // Parent-owned phases intentionally do nothing here: the main agent must
763
+ // inspect the inline child result and call write_workflow_artifact.
764
+ if (!sm.snapshot.active || sm.snapshot.phase !== "loop" || isSubagentManagementAction(event.input)) return;
765
+ const eventBefore = checkpoint(controller);
766
+ if (typeof event.toolCallId === "string") {
653
767
  if (controller.seenToolCallIds.has(event.toolCallId)) return;
654
768
  controller.seenToolCallIds.add(event.toolCallId);
655
769
  }
656
- // ponytail: a failed phase-owner tool used to leave the workflow quiet
657
- // until the user manually typed /prototype or /quick continue. Re-check
658
- // first (in case the tool wrote the artifact before failing), then if
659
- // nothing advanced, re-queue the current phase prompt automatically.
660
- if (event.isError && sm.snapshot.active) {
661
- if (event.toolName === "subagent" && isSubagentManagementAction(event.input)) return;
662
- const eff = await sm.onArtifactMaybe(deps);
663
- applyControllerEffect(controller, ctx, eff);
664
- if (eff.kind === "noOp") {
665
- const retry = sm.continueCurrent();
666
- continueAgent(
667
- pi,
668
- ctx,
669
- `The previous ${event.toolName} call failed during the ${retry.phase} phase. Stay in this phase and try again.\n\n${retry.prompt}`,
670
- );
671
- }
672
- if (sm.snapshot.phase !== "loop") {
673
- controller.loopState = undefined;
674
- }
675
- return;
676
- }
677
- // ponytail: if a subagent returned text but did not write the expected
678
- // artifact (common with dumb/local models), save the returned text as
679
- // the artifact so the workflow can still advance.
680
- if (event.toolName === "subagent" && sm.snapshot.active && SUBAGENT_FALLBACK_PHASES.has(sm.snapshot.phase)) {
681
- const phase = sm.snapshot.phase;
682
- const file = config.phaseArtifacts[phase];
683
- if (file) {
684
- // ponytail: management actions (list/get/models/doctor/status/...)
685
- // return text but are NOT the architect/recapper/... execution
686
- // result. Writing their text to plan.md would advance the workflow
687
- // on agent-listing output (the `subagent list` result is plain
688
- // text). Execution calls set `agent`/`chain`/`tasks`; management
689
- // calls set `action`. Only fall back for execution calls.
690
- if (!isSubagentManagementAction(event.input)) {
691
- const expectedPath = resolve(sm.snapshot.artifactDir, file);
692
- const text = textFromToolResultContent(event.content);
693
- if (text) {
694
- const validator = validatorForPhaseArtifact(config, phase);
695
- let shouldWrite = false;
696
- if (!(await realFileExists(expectedPath))) {
697
- shouldWrite = !validator || validator(text).ok;
698
- } else {
699
- try {
700
- const current = await readFile(expectedPath, "utf8");
701
- shouldWrite = shouldReplaceInvalidArtifact(config, phase, current, text);
702
- } catch {
703
- shouldWrite = false;
704
- }
705
- }
706
- if (shouldWrite) {
707
- await mkdir(sm.snapshot.artifactDir, { recursive: true });
708
- await writeFile(expectedPath, text, "utf8");
709
- }
710
- }
711
- }
712
- }
713
- }
714
- // ponytail: Plan 3 — engine-owned loop orchestration. The parent model
715
- // does NOT track iterations; the adapter counts review rounds, parses the
716
- // commentator's WORKFLOW_REVIEW_STATUS marker, and drives the next step
717
- // via followUp. Ceiling: there is no pi.callTool() API, so the engine
718
- // cannot invoke the subagent tool directly — it sends a followUp the
719
- // model is expected to act on. If the model deviates, the loop stalls
720
- // until the next tool_result nudges again. loopState is cleared when the
721
- // phase leaves "loop" (below).
770
+ // The adapter owns durable loop state; the normal subagent tool-result
771
+ // continuation lets the parent choose the next builder/commentator call.
772
+ // No synthetic follow-up chat is queued. loopState clears when the phase
773
+ // leaves "loop" (below).
722
774
  if (event.toolName === "subagent" && sm.snapshot.active && sm.snapshot.phase === "loop") {
723
775
  const dir = sm.snapshot.artifactDir;
724
776
  const maxIt = config.loopMaxIterations ?? 3;
@@ -729,15 +781,20 @@ export function createWorkflowExtension(
729
781
  const agent = event.input?.agent;
730
782
 
731
783
  if (agent === "builder") {
732
- // Build step done → drive the commentator review.
784
+ // The normal subagent result resumes the parent turn; record the
785
+ // next loop stage but do not inject a duplicate follow-up message.
733
786
  ls.stage = "reviewing";
734
- continueAgent(pi, ctx, `Builder finished. Call the subagent tool now with { agent: "commentator", task: "..." } to review the builder's uncommitted diff against ${dir}/plan.md. Craft the review task yourself based on what matters for this task. End the review with exactly one line on its own:
735
- WORKFLOW_REVIEW_STATUS: clean
736
- or
737
- WORKFLOW_REVIEW_STATUS: blocking`);
738
787
  } else if (agent === "commentator") {
739
- ls.reviewRound += 1;
740
- const status = parseLoopReviewStatus(textFromToolResultContent(event.content));
788
+ const reviewRound = ls.reviewRound + 1;
789
+ const reviewText = textFromToolResultContent(event.content);
790
+ if (reviewText) {
791
+ const reviewPath = `loop-review-${reviewRound}.md`;
792
+ await mkdir(dir, { recursive: true });
793
+ await writeFile(resolve(dir, reviewPath), reviewText, "utf8");
794
+ ls.reviewPath = reviewPath;
795
+ }
796
+ ls.reviewRound = reviewRound;
797
+ const status = parseLoopReviewStatus(reviewText);
741
798
  if (status === "clean") {
742
799
  ls.stage = "clean";
743
800
  await mkdir(dir, { recursive: true });
@@ -756,29 +813,40 @@ or
756
813
  // A genuine clean review on a later round still advances — the
757
814
  // clean branch above runs first.
758
815
  } else {
759
- // Blocking (or no marker) → drive a builder fix round.
816
+ // Blocking (or no marker) → the parent continues with a builder
817
+ // fix round from the normal subagent result, without a queued chat.
760
818
  ls.stage = "building";
761
- const reason = status === "blocking"
762
- ? "the blocking issues listed in the review"
763
- : "the review (no WORKFLOW_REVIEW_STATUS: clean|blocking marker was found)";
764
- continueAgent(pi, ctx, `Review round ${ls.reviewRound} was blocking. Call the subagent tool now with { agent: "builder", task: "..." } to fix ${reason}. Give the builder the review feedback and the relevant artifact paths. After the builder returns, the workflow will drive the next review automatically.`);
765
819
  }
766
820
  }
767
821
  // Non-builder/commentator subagent calls in loop fall through to
768
822
  // onArtifactMaybe (a no-op unless loop-complete.md exists).
769
823
  }
770
824
  const eff = await sm.onArtifactMaybe(deps);
771
- applyControllerEffect(controller, ctx, eff);
772
825
  // Clear loop state once we have left the loop phase (advanced to audit).
773
826
  if (sm.snapshot.phase !== "loop") {
774
827
  controller.loopState = undefined;
775
828
  }
829
+ try {
830
+ await persistAfter(controller, eventBefore);
831
+ } catch (error) {
832
+ ctx.ui.notify(`Workflow state was not saved: ${error instanceof Error ? error.message : String(error)}`, "warning");
833
+ return;
834
+ }
835
+ // The only artifact written in this handler is loop-complete.md, which
836
+ // is engine-owned. Parent-written phase artifacts advance inside
837
+ // write_workflow_artifact instead.
838
+ applyControllerEffect(controller, ctx, eff);
776
839
  });
777
840
 
778
841
  // ── /<command> ──
779
842
  pi.registerCommand(options.commandName, {
780
843
  description: options.commandDescription,
781
844
  handler: async (args: string, ctx: ExtensionCommandContext) => {
845
+ const trimmed = args.trim();
846
+ if (trimmed === "help") {
847
+ ctx.ui.notify(commandHelp(config, options), "info");
848
+ return;
849
+ }
782
850
  if (!ctx.isIdle()) {
783
851
  ctx.ui.notify(
784
852
  `Agent is busy. Wait for it to finish before calling /${options.commandName}.`,
@@ -787,6 +855,36 @@ or
787
855
  return;
788
856
  }
789
857
 
858
+ if (trimmed === "resume" || trimmed.startsWith("resume ")) {
859
+ if (sm.snapshot.active) {
860
+ ctx.ui.notify("A workflow is already active. End it before resuming another run.", "warning");
861
+ return;
862
+ }
863
+ const selector = trimmed.slice("resume".length).trim();
864
+ if (!selector) {
865
+ const runs = await listResumableWorkflowRuns(artifactsBaseFor(config), modesFor(config));
866
+ if (!runs.length) {
867
+ ctx.ui.notify(`No resumable ${mode} workflows found.`, "info");
868
+ return;
869
+ }
870
+ if (!ctx.hasUI) {
871
+ ctx.ui.notify(runs.map(({ run }) => `/${options.commandName} resume ${run.id} # ${run.phase}, ${run.goal}, ${run.updatedAt}`).join("\n"), "info");
872
+ return;
873
+ }
874
+ const labels = runs.map(({ run }) => `${run.id} · ${run.phase} · ${run.goal} · ${run.updatedAt}`);
875
+ const choice = await ctx.ui.select(`Resume ${mode} workflow`, labels);
876
+ if (choice === undefined) return;
877
+ const index = labels.indexOf(choice);
878
+ if (index < 0) return;
879
+ const result = await resumeController(controller, ctx, runs[index]!.run.id, end);
880
+ ctx.ui.notify(result.content[0].text, result.details.rejected || result.details.persistenceError ? "warning" : "info");
881
+ return;
882
+ }
883
+ const result = await resumeController(controller, ctx, selector, end);
884
+ ctx.ui.notify(result.content[0].text, result.details.rejected || result.details.persistenceError ? "warning" : "info");
885
+ return;
886
+ }
887
+
790
888
  if (sm.snapshot.active) {
791
889
  let goal = args.trim();
792
890
  const choice = ctx.hasUI
@@ -801,7 +899,8 @@ or
801
899
  if (choice === undefined) return;
802
900
  if (choice.startsWith("Continue")) {
803
901
  const eff = sm.continueCurrent();
804
- applyControllerEffect(controller, ctx, eff);
902
+ // The explicit Continue action below sends exactly one prompt.
903
+ applyControllerEffect(controller, ctx, eff, { queuePrompt: false });
805
904
  if (eff.kind === "advanced" && eff.prompt) {
806
905
  // continueCurrent already applied footer+entry; send the prompt.
807
906
  continueAgent(pi, ctx, eff.prompt);
@@ -820,12 +919,14 @@ or
820
919
  }
821
920
  const closed = sm.closeCurrent();
822
921
  if (closed) {
823
- applyEntry(pi, config, closed);
824
- ctx.ui.notify(`Closed ${mode} workflow at ${closed.artifactDir}.`, "info");
922
+ // Detach only: the on-disk active record remains resumable. Only
923
+ // end_*_workflow writes status: completed.
924
+ applyEntry(pi, config, { ...closed, done: false }, controller.run);
925
+ ctx.ui.notify(`Detached ${mode} workflow at ${closed.artifactDir}; it remains resumable.`, "info");
825
926
  }
826
927
  args = goal;
827
928
  } else if (!args.trim()) {
828
- ctx.ui.notify(`Usage: /${options.commandName} <what to build>`, "warning");
929
+ ctx.ui.notify(commandHelp(config, options), "info");
829
930
  return;
830
931
  }
831
932
 
@@ -837,8 +938,27 @@ or
837
938
  );
838
939
  return;
839
940
  }
941
+ const before = checkpoint(controller);
840
942
  const eff = await sm.start(args, deps);
841
943
  if (eff.kind === "started") {
944
+ controller.run = {
945
+ version: 1,
946
+ id: basename(sm.snapshot.artifactDir),
947
+ mode,
948
+ status: "active",
949
+ goal: sm.snapshot.userPrompt,
950
+ artifactDir: sm.snapshot.artifactDir,
951
+ phase: sm.snapshot.phase,
952
+ autoArmed: sm.snapshot.autoArmed,
953
+ createdAt: new Date().toISOString(),
954
+ updatedAt: new Date().toISOString(),
955
+ };
956
+ try {
957
+ await persistAfter(controller, before);
958
+ } catch (error) {
959
+ ctx.ui.notify(`Could not start durable workflow: ${error instanceof Error ? error.message : String(error)}`, "warning");
960
+ return;
961
+ }
842
962
  controller.seenToolCallIds.clear();
843
963
  controller.lastBlockedKey = undefined;
844
964
  }