@selesai/code 0.3.9 → 0.4.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 (222) hide show
  1. package/dist/cli/args.d.ts.map +1 -1
  2. package/dist/cli/args.js +4 -4
  3. package/dist/cli/args.js.map +1 -1
  4. package/dist/cli/config-selector.d.ts +4 -2
  5. package/dist/cli/config-selector.d.ts.map +1 -1
  6. package/dist/cli/config-selector.js +1 -1
  7. package/dist/cli/config-selector.js.map +1 -1
  8. package/dist/cli/file-processor.d.ts.map +1 -1
  9. package/dist/cli/file-processor.js +13 -25
  10. package/dist/cli/file-processor.js.map +1 -1
  11. package/dist/core/agent-session.d.ts +22 -5
  12. package/dist/core/agent-session.d.ts.map +1 -1
  13. package/dist/core/agent-session.js +130 -49
  14. package/dist/core/agent-session.js.map +1 -1
  15. package/dist/core/auth-storage.d.ts.map +1 -1
  16. package/dist/core/auth-storage.js +13 -5
  17. package/dist/core/auth-storage.js.map +1 -1
  18. package/dist/core/cache-stats.d.ts +49 -0
  19. package/dist/core/cache-stats.d.ts.map +1 -0
  20. package/dist/core/cache-stats.js +101 -0
  21. package/dist/core/cache-stats.js.map +1 -0
  22. package/dist/core/compaction/compaction.d.ts +1 -2
  23. package/dist/core/compaction/compaction.d.ts.map +1 -1
  24. package/dist/core/compaction/compaction.js +56 -81
  25. package/dist/core/compaction/compaction.js.map +1 -1
  26. package/dist/core/extensions/index.d.ts +1 -1
  27. package/dist/core/extensions/index.d.ts.map +1 -1
  28. package/dist/core/extensions/index.js.map +1 -1
  29. package/dist/core/extensions/loader.d.ts.map +1 -1
  30. package/dist/core/extensions/loader.js +10 -0
  31. package/dist/core/extensions/loader.js.map +1 -1
  32. package/dist/core/extensions/runner.d.ts +5 -3
  33. package/dist/core/extensions/runner.d.ts.map +1 -1
  34. package/dist/core/extensions/runner.js +38 -0
  35. package/dist/core/extensions/runner.js.map +1 -1
  36. package/dist/core/extensions/types.d.ts +41 -12
  37. package/dist/core/extensions/types.d.ts.map +1 -1
  38. package/dist/core/extensions/types.js.map +1 -1
  39. package/dist/core/http-dispatcher.d.ts.map +1 -1
  40. package/dist/core/http-dispatcher.js +28 -1
  41. package/dist/core/http-dispatcher.js.map +1 -1
  42. package/dist/core/index.d.ts +1 -1
  43. package/dist/core/index.d.ts.map +1 -1
  44. package/dist/core/index.js.map +1 -1
  45. package/dist/core/keybindings.d.ts.map +1 -1
  46. package/dist/core/keybindings.js +3 -6
  47. package/dist/core/keybindings.js.map +1 -1
  48. package/dist/core/model-registry.d.ts +4 -6
  49. package/dist/core/model-registry.d.ts.map +1 -1
  50. package/dist/core/model-registry.js +34 -8
  51. package/dist/core/model-registry.js.map +1 -1
  52. package/dist/core/model-resolver.d.ts +10 -0
  53. package/dist/core/model-resolver.d.ts.map +1 -1
  54. package/dist/core/model-resolver.js +15 -18
  55. package/dist/core/model-resolver.js.map +1 -1
  56. package/dist/core/package-manager.d.ts +4 -1
  57. package/dist/core/package-manager.d.ts.map +1 -1
  58. package/dist/core/package-manager.js +68 -26
  59. package/dist/core/package-manager.js.map +1 -1
  60. package/dist/core/provider-attribution.d.ts.map +1 -1
  61. package/dist/core/provider-attribution.js +0 -10
  62. package/dist/core/provider-attribution.js.map +1 -1
  63. package/dist/core/resource-loader.d.ts +2 -2
  64. package/dist/core/resource-loader.d.ts.map +1 -1
  65. package/dist/core/resource-loader.js +8 -7
  66. package/dist/core/resource-loader.js.map +1 -1
  67. package/dist/core/sdk.d.ts +1 -1
  68. package/dist/core/sdk.d.ts.map +1 -1
  69. package/dist/core/sdk.js +8 -1
  70. package/dist/core/sdk.js.map +1 -1
  71. package/dist/core/session-manager.d.ts +21 -2
  72. package/dist/core/session-manager.d.ts.map +1 -1
  73. package/dist/core/session-manager.js +104 -72
  74. package/dist/core/session-manager.js.map +1 -1
  75. package/dist/core/settings-manager.d.ts +14 -3
  76. package/dist/core/settings-manager.d.ts.map +1 -1
  77. package/dist/core/settings-manager.js +29 -1
  78. package/dist/core/settings-manager.js.map +1 -1
  79. package/dist/core/slash-commands.d.ts +1 -0
  80. package/dist/core/slash-commands.d.ts.map +1 -1
  81. package/dist/core/slash-commands.js +3 -3
  82. package/dist/core/slash-commands.js.map +1 -1
  83. package/dist/core/timings.d.ts +4 -2
  84. package/dist/core/timings.d.ts.map +1 -1
  85. package/dist/core/timings.js +24 -14
  86. package/dist/core/timings.js.map +1 -1
  87. package/dist/core/tools/bash.d.ts.map +1 -1
  88. package/dist/core/tools/bash.js +20 -5
  89. package/dist/core/tools/bash.js.map +1 -1
  90. package/dist/core/tools/edit.d.ts.map +1 -1
  91. package/dist/core/tools/edit.js +2 -2
  92. package/dist/core/tools/edit.js.map +1 -1
  93. package/dist/core/tools/read.d.ts.map +1 -1
  94. package/dist/core/tools/read.js +12 -25
  95. package/dist/core/tools/read.js.map +1 -1
  96. package/dist/defaults/models.json +2 -3
  97. package/dist/extensions/caveman/index.js +16 -1
  98. package/dist/extensions/caveman/test/extension.test.js +7 -4
  99. package/dist/extensions/caveman/test/helpers.test.js +12 -1
  100. package/dist/extensions/context-compaction-reminder.test.ts +82 -0
  101. package/dist/extensions/context-compaction-reminder.ts +28 -0
  102. package/dist/extensions/handoff-new.test.ts +2 -13
  103. package/dist/extensions/handoff-new.ts +16 -25
  104. package/dist/extensions/package.json +1 -0
  105. package/dist/extensions/pi-subagents/agents/architect.md +25 -173
  106. package/dist/extensions/pi-subagents/agents/builder.md +16 -105
  107. package/dist/extensions/pi-subagents/agents/commentator.md +19 -116
  108. package/dist/extensions/pi-subagents/agents/explorer.md +13 -33
  109. package/dist/extensions/pi-subagents/agents/recapper.md +19 -11
  110. package/dist/extensions/pi-subagents/agents/researcher.md +10 -33
  111. package/dist/extensions/question/constants.ts +0 -7
  112. package/dist/extensions/question/index.ts +11 -108
  113. package/dist/extensions/question/schemas.ts +1 -26
  114. package/dist/extensions/question/shortcuts.ts +5 -14
  115. package/dist/extensions/question/tests/question-list.test.ts +8 -0
  116. package/dist/extensions/question/tests/ui-protocol.test.ts +1 -7
  117. package/dist/extensions/question/tui-adapter.ts +3 -20
  118. package/dist/extensions/question/types.ts +0 -6
  119. package/dist/extensions/question/ui-protocol.ts +2 -17
  120. package/dist/extensions/workflow/adapter.ts +384 -124
  121. package/dist/extensions/workflow/extension.ts +2 -1
  122. package/dist/extensions/workflow/modes/prototype.ts +6 -6
  123. package/dist/extensions/workflow/modes/quick.ts +6 -6
  124. package/dist/extensions/workflow/modes/task.ts +79 -0
  125. package/dist/extensions/workflow/run-state.ts +124 -0
  126. package/dist/extensions/workflow/state-machine.ts +4 -8
  127. package/dist/index.d.ts +3 -2
  128. package/dist/index.d.ts.map +1 -1
  129. package/dist/index.js +2 -1
  130. package/dist/index.js.map +1 -1
  131. package/dist/main.d.ts +2 -2
  132. package/dist/main.d.ts.map +1 -1
  133. package/dist/main.js +17 -4
  134. package/dist/main.js.map +1 -1
  135. package/dist/modes/interactive/components/assistant-message.d.ts +3 -1
  136. package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  137. package/dist/modes/interactive/components/assistant-message.js +23 -15
  138. package/dist/modes/interactive/components/assistant-message.js.map +1 -1
  139. package/dist/modes/interactive/components/config-selector.d.ts +34 -3
  140. package/dist/modes/interactive/components/config-selector.d.ts.map +1 -1
  141. package/dist/modes/interactive/components/config-selector.js +280 -25
  142. package/dist/modes/interactive/components/config-selector.js.map +1 -1
  143. package/dist/modes/interactive/components/custom-entry.d.ts +19 -0
  144. package/dist/modes/interactive/components/custom-entry.d.ts.map +1 -0
  145. package/dist/modes/interactive/components/custom-entry.js +52 -0
  146. package/dist/modes/interactive/components/custom-entry.js.map +1 -0
  147. package/dist/modes/interactive/components/extension-editor.d.ts +3 -1
  148. package/dist/modes/interactive/components/extension-editor.d.ts.map +1 -1
  149. package/dist/modes/interactive/components/extension-editor.js +12 -3
  150. package/dist/modes/interactive/components/extension-editor.js.map +1 -1
  151. package/dist/modes/interactive/components/footer.d.ts +4 -0
  152. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  153. package/dist/modes/interactive/components/footer.js +1 -1
  154. package/dist/modes/interactive/components/footer.js.map +1 -1
  155. package/dist/modes/interactive/components/oauth-selector.d.ts +3 -1
  156. package/dist/modes/interactive/components/oauth-selector.d.ts.map +1 -1
  157. package/dist/modes/interactive/components/oauth-selector.js +17 -6
  158. package/dist/modes/interactive/components/oauth-selector.js.map +1 -1
  159. package/dist/modes/interactive/components/settings-selector.d.ts +4 -0
  160. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  161. package/dist/modes/interactive/components/settings-selector.js +25 -2
  162. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  163. package/dist/modes/interactive/components/status-indicator.d.ts +28 -0
  164. package/dist/modes/interactive/components/status-indicator.d.ts.map +1 -0
  165. package/dist/modes/interactive/components/status-indicator.js +60 -0
  166. package/dist/modes/interactive/components/status-indicator.js.map +1 -0
  167. package/dist/modes/interactive/components/thinking-selector.d.ts.map +1 -1
  168. package/dist/modes/interactive/components/thinking-selector.js +2 -1
  169. package/dist/modes/interactive/components/thinking-selector.js.map +1 -1
  170. package/dist/modes/interactive/components/user-message.d.ts +6 -2
  171. package/dist/modes/interactive/components/user-message.d.ts.map +1 -1
  172. package/dist/modes/interactive/components/user-message.js +19 -6
  173. package/dist/modes/interactive/components/user-message.js.map +1 -1
  174. package/dist/modes/interactive/interactive-mode.d.ts +21 -10
  175. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  176. package/dist/modes/interactive/interactive-mode.js +417 -196
  177. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  178. package/dist/modes/interactive/theme/dark.json +1 -0
  179. package/dist/modes/interactive/theme/light.json +1 -0
  180. package/dist/modes/interactive/theme/theme-schema.json +6 -2
  181. package/dist/modes/interactive/theme/theme.d.ts +3 -2
  182. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  183. package/dist/modes/interactive/theme/theme.js +10 -3
  184. package/dist/modes/interactive/theme/theme.js.map +1 -1
  185. package/dist/modes/print-mode.d.ts.map +1 -1
  186. package/dist/modes/print-mode.js +1 -1
  187. package/dist/modes/print-mode.js.map +1 -1
  188. package/dist/modes/rpc/rpc-client.d.ts +21 -6
  189. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  190. package/dist/modes/rpc/rpc-client.js +17 -3
  191. package/dist/modes/rpc/rpc-client.js.map +1 -1
  192. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  193. package/dist/modes/rpc/rpc-mode.js +20 -1
  194. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  195. package/dist/modes/rpc/rpc-types.d.ts +26 -0
  196. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  197. package/dist/modes/rpc/rpc-types.js.map +1 -1
  198. package/dist/package-manager-cli.d.ts +2 -2
  199. package/dist/package-manager-cli.d.ts.map +1 -1
  200. package/dist/package-manager-cli.js +71 -17
  201. package/dist/package-manager-cli.js.map +1 -1
  202. package/dist/rpc-entry.d.ts +3 -0
  203. package/dist/rpc-entry.d.ts.map +1 -0
  204. package/dist/rpc-entry.js +10 -0
  205. package/dist/rpc-entry.js.map +1 -0
  206. package/dist/skills/workflow-creation/SKILL.md +72 -0
  207. package/dist/utils/clipboard-image.d.ts.map +1 -1
  208. package/dist/utils/clipboard-image.js +1 -1
  209. package/dist/utils/clipboard-image.js.map +1 -1
  210. package/dist/utils/image-convert.d.ts +1 -0
  211. package/dist/utils/image-convert.d.ts.map +1 -1
  212. package/dist/utils/image-convert.js +21 -15
  213. package/dist/utils/image-convert.js.map +1 -1
  214. package/dist/utils/image-process.d.ts +18 -0
  215. package/dist/utils/image-process.d.ts.map +1 -0
  216. package/dist/utils/image-process.js +83 -0
  217. package/dist/utils/image-process.js.map +1 -0
  218. package/dist/utils/mime.d.ts.map +1 -1
  219. package/dist/utils/mime.js +41 -0
  220. package/dist/utils/mime.js.map +1 -1
  221. package/docs/workflows.md +40 -6
  222. package/package.json +8 -5
@@ -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, isAbsolute, resolve } from "node:path";
16
17
 
17
18
  import {
18
19
  WorkflowStateMachine,
@@ -22,30 +23,25 @@ import {
22
23
  type WorkflowEntry,
23
24
  type FooterState,
24
25
  type Phase,
26
+ type WorkflowSnapshot,
25
27
  } from "./state-machine.ts";
28
+ import {
29
+ WORKFLOW_STATE_FILENAME,
30
+ type LoopState,
31
+ type PersistedWorkflowRun,
32
+ type WorkflowModes,
33
+ listResumableWorkflowRuns,
34
+ resolveWorkflowRun,
35
+ saveWorkflowRun,
36
+ } from "./run-state.ts";
26
37
  import { MARKERS } from "./validators.ts";
27
38
 
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())}`;
39
+ function defaultArtifactPathFor(_goal: string, base: string): string {
40
+ return resolve(base, randomUUID());
45
41
  }
46
42
 
47
- function defaultArtifactPathFor(goal: string, base: string): string {
48
- return `${base}/${timestamp()}-${slugify(goal)}`;
43
+ function artifactsBaseFor(config: WorkflowConfig): string {
44
+ return resolve(config.artifactsBase ?? "./.selesai/artifacts");
49
45
  }
50
46
 
51
47
  async function realFileExists(path: string): Promise<boolean> {
@@ -157,8 +153,8 @@ function forceSubagentOutputToArtifactDir(
157
153
  }
158
154
 
159
155
  export interface WorkflowAdapterOptions {
160
- toolNames: { start: string; end: string };
161
- toolLabels: { start: string; end: string };
156
+ toolNames: { start: string; resume: string; end: string };
157
+ toolLabels: { start: string; resume: string; end: string };
162
158
  commandName: string;
163
159
  commandDescription: string;
164
160
  }
@@ -168,26 +164,14 @@ interface WorkflowController {
168
164
  config: WorkflowConfig;
169
165
  sm: WorkflowStateMachine;
170
166
  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.
167
+ // The state machine stays pure; the adapter persists this orchestration
168
+ // detail alongside the state-machine snapshot.
177
169
  loopState?: LoopState;
170
+ run?: PersistedWorkflowRun;
178
171
  seenToolCallIds: Set<string>;
179
172
  lastBlockedKey?: string;
180
173
  }
181
174
 
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
175
  const WORKFLOW_ARTIFACT_TOOL = "write_workflow_artifact";
192
176
  // ponytail: shared across all module copies so prototype + quick registration
193
177
  // can race. Module-level state alone is not enough when the loader runs each
@@ -210,6 +194,141 @@ function activeControllersFor(pi: ExtensionAPI): WorkflowController[] {
210
194
  return registry.controllers.filter((c) => c.pi === pi && c.sm.snapshot.active);
211
195
  }
212
196
 
197
+ function isRegisteredController(controller: WorkflowController): boolean {
198
+ return registry.controllers.includes(controller);
199
+ }
200
+
201
+ // Extension reload can reuse the same ExtensionAPI object while this module's
202
+ // global registry survives. Replace a mode's old controller so its active,
203
+ // pre-reload state cannot handle new tool results without a persisted run.
204
+ function replaceControllerFor(pi: ExtensionAPI, mode: string): void {
205
+ for (let i = registry.controllers.length - 1; i >= 0; i--) {
206
+ const controller = registry.controllers[i]!;
207
+ if (controller.pi === pi && controller.config.mode === mode) {
208
+ registry.controllers.splice(i, 1);
209
+ }
210
+ }
211
+ }
212
+
213
+ function modesFor(config: WorkflowConfig): WorkflowModes {
214
+ return { [config.mode]: config.phases };
215
+ }
216
+
217
+ function snapshotRun(controller: WorkflowController): PersistedWorkflowRun {
218
+ if (!controller.run) throw new Error("No workflow run is attached.");
219
+ const snapshot = controller.sm.snapshot;
220
+ return {
221
+ ...controller.run,
222
+ status: snapshot.active ? "active" : "completed",
223
+ goal: snapshot.userPrompt,
224
+ artifactDir: snapshot.artifactDir,
225
+ phase: snapshot.phase,
226
+ autoArmed: snapshot.autoArmed,
227
+ loopState: controller.loopState,
228
+ updatedAt: new Date().toISOString(),
229
+ };
230
+ }
231
+
232
+ async function persistRun(controller: WorkflowController): Promise<void> {
233
+ const run = snapshotRun(controller);
234
+ await saveWorkflowRun(run);
235
+ controller.run = run;
236
+ }
237
+
238
+ interface ControllerCheckpoint {
239
+ snapshot: WorkflowSnapshot;
240
+ loopState?: LoopState;
241
+ run?: PersistedWorkflowRun;
242
+ }
243
+
244
+ function checkpoint(controller: WorkflowController): ControllerCheckpoint {
245
+ return {
246
+ snapshot: controller.sm.snapshot,
247
+ loopState: controller.loopState && { ...controller.loopState },
248
+ run: controller.run && { ...controller.run, loopState: controller.run.loopState && { ...controller.run.loopState } },
249
+ };
250
+ }
251
+
252
+ function restoreCheckpoint(controller: WorkflowController, before: ControllerCheckpoint): void {
253
+ controller.sm.rehydrate(before.snapshot);
254
+ controller.loopState = before.loopState;
255
+ controller.run = before.run;
256
+ }
257
+
258
+ async function persistAfter(controller: WorkflowController, before: ControllerCheckpoint): Promise<void> {
259
+ try {
260
+ await persistRun(controller);
261
+ } catch (error) {
262
+ restoreCheckpoint(controller, before);
263
+ throw error;
264
+ }
265
+ }
266
+
267
+ async function resumeController(
268
+ controller: WorkflowController,
269
+ ctx: ExtensionContext,
270
+ selector: string,
271
+ endTool: string,
272
+ ): Promise<any> {
273
+ const { pi, config, sm } = controller;
274
+ if (activeControllersFor(pi).length) {
275
+ return { content: [{ type: "text", text: "A workflow is already active. End it before resuming another run." }], details: { alreadyActive: true } };
276
+ }
277
+ let selected;
278
+ try {
279
+ selected = await resolveWorkflowRun(selector, artifactsBaseFor(config), modesFor(config));
280
+ if (selected.run.status !== "active") throw new Error("Completed workflow runs cannot be resumed.");
281
+ if (selected.run.mode !== config.mode) throw new Error(`This is a ${selected.run.mode} run, not ${config.mode}.`);
282
+ } catch (error) {
283
+ return { content: [{ type: "text", text: `Cannot resume workflow: ${error instanceof Error ? error.message : String(error)}` }], details: { rejected: true } };
284
+ }
285
+ const before = checkpoint(controller);
286
+ sm.rehydrate({ active: true, phase: selected.run.phase, userPrompt: selected.run.goal, artifactDir: selected.run.artifactDir, autoArmed: selected.run.autoArmed });
287
+ controller.run = selected.run;
288
+ controller.loopState = selected.run.loopState;
289
+ controller.seenToolCallIds.clear();
290
+ controller.lastBlockedKey = undefined;
291
+ try {
292
+ await persistAfter(controller, before);
293
+ const reconcileBefore = checkpoint(controller);
294
+ const reconciled = await sm.onArtifactMaybe(controller.deps);
295
+ await persistAfter(controller, reconcileBefore);
296
+ const current = sm.continueCurrent();
297
+ const terminal = config.phases[config.phases.length - 1];
298
+ const terminalReady = reconciled.kind === "terminalReady" || (sm.snapshot.phase === terminal && !sm.snapshot.autoArmed);
299
+ if (terminalReady) {
300
+ if (current.kind === "advanced") {
301
+ applyFooter(ctx, current.footer);
302
+ applyEntry(pi, config, current.entry, controller.run);
303
+ }
304
+ continueAgent(pi, ctx, `Workflow is terminal-ready. Verify ${sm.snapshot.artifactDir} and call ${endTool} to complete it.`);
305
+ } else if (sm.snapshot.phase === "loop" && controller.loopState?.stage === "maxed") {
306
+ if (current.kind === "advanced") {
307
+ applyFooter(ctx, current.footer);
308
+ applyEntry(pi, config, current.entry, controller.run);
309
+ }
310
+ 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");
311
+ } else if (sm.snapshot.phase === "loop" && controller.loopState) {
312
+ if (current.kind === "advanced") {
313
+ applyFooter(ctx, current.footer);
314
+ applyEntry(pi, config, current.entry, controller.run);
315
+ }
316
+ const ls = controller.loopState;
317
+ continueAgent(pi, ctx, ls.stage === "reviewing"
318
+ ? `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.`
319
+ : `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"}.`);
320
+ } else {
321
+ applyControllerEffect(controller, ctx, current);
322
+ }
323
+ } catch (error) {
324
+ return { content: [{ type: "text", text: `Cannot resume workflow: state could not be persisted: ${error instanceof Error ? error.message : String(error)}` }], details: { persistenceError: true } };
325
+ }
326
+ return {
327
+ content: [{ type: "text", text: `Resumed ${config.mode} workflow ${controller.run!.id} at ${sm.snapshot.phase}. Artifacts: ${selected.statePath}` }],
328
+ details: { runId: controller.run!.id, statePath: selected.statePath, phase: sm.snapshot.phase },
329
+ };
330
+ }
331
+
213
332
  // ponytail: builds the deps bundle the adapter injects into the state machine.
214
333
  // The skip predicate wraps pi.exec("git"...) — the only true-external seam.
215
334
  function makeDeps(pi: ExtensionAPI, config: WorkflowConfig): WorkflowDeps {
@@ -276,8 +395,11 @@ function footerText(footer: FooterState, ctx: ExtensionContext): string | undefi
276
395
  return ctx.ui.theme.fg("warning", footer.text);
277
396
  }
278
397
 
279
- function applyEntry(pi: ExtensionAPI, config: WorkflowConfig, entry: WorkflowEntry): void {
280
- pi.appendEntry(config.entryType, entry);
398
+ function applyEntry(pi: ExtensionAPI, config: WorkflowConfig, entry: WorkflowEntry, run?: PersistedWorkflowRun): void {
399
+ pi.appendEntry(config.entryType, {
400
+ ...entry,
401
+ ...(run ? { workflowStatePath: resolve(run.artifactDir, WORKFLOW_STATE_FILENAME), runId: run.id } : {}),
402
+ });
281
403
  }
282
404
 
283
405
  function applyFooter(ctx: ExtensionContext, footer: FooterState): void {
@@ -304,16 +426,18 @@ function applyEffect(
304
426
  ctx: ExtensionContext,
305
427
  config: WorkflowConfig,
306
428
  eff: WorkflowEffect,
429
+ run?: PersistedWorkflowRun,
430
+ queuePrompt = true,
307
431
  ): void {
308
432
  switch (eff.kind) {
309
433
  case "started":
310
434
  case "advanced":
311
- if ("entry" in eff) applyEntry(pi, config, eff.entry);
435
+ if ("entry" in eff) applyEntry(pi, config, eff.entry, run);
312
436
  if ("footer" in eff) applyFooter(ctx, eff.footer);
313
- if (eff.prompt) continueAgent(pi, ctx, eff.prompt);
437
+ if (queuePrompt && eff.prompt) continueAgent(pi, ctx, eff.prompt);
314
438
  return;
315
439
  case "closed":
316
- if ("entry" in eff) applyEntry(pi, config, eff.entry);
440
+ if ("entry" in eff) applyEntry(pi, config, eff.entry, run);
317
441
  if ("footer" in eff) applyFooter(ctx, eff.footer);
318
442
  ctx.ui.notify(
319
443
  `${config.footerLabel} workflow complete. Artifacts: ${eff.artifactDir}`,
@@ -321,17 +445,16 @@ function applyEffect(
321
445
  );
322
446
  return;
323
447
  case "terminalNeedsArtifacts":
324
- if (eff.promptToQueue) continueAgent(pi, ctx, eff.promptToQueue);
448
+ if (queuePrompt && eff.promptToQueue) continueAgent(pi, ctx, eff.promptToQueue);
325
449
  return;
326
450
  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) {
451
+ if (queuePrompt && eff.reason) {
331
452
  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
453
  }
333
454
  return;
334
455
  case "terminalReady":
456
+ if (queuePrompt) continueAgent(pi, ctx, `Terminal artifacts are ready. Call the explicit end workflow tool to complete this run.`);
457
+ return;
335
458
  case "endBlocked":
336
459
  case "idle":
337
460
  case "noOp":
@@ -349,6 +472,7 @@ function applyControllerEffect(
349
472
  controller: WorkflowController,
350
473
  ctx: ExtensionContext,
351
474
  eff: WorkflowEffect,
475
+ options: { queuePrompt?: boolean } = {},
352
476
  ): void {
353
477
  const blockedKey = blockedKeyFor(eff);
354
478
  if (blockedKey) {
@@ -357,13 +481,25 @@ function applyControllerEffect(
357
481
  } else if (eff.kind !== "noOp") {
358
482
  controller.lastBlockedKey = undefined;
359
483
  }
360
- applyEffect(controller.pi, ctx, controller.config, eff);
484
+ applyEffect(controller.pi, ctx, controller.config, eff, controller.run, options.queuePrompt);
361
485
  }
362
486
 
363
487
  function isSubagentManagementAction(input: Record<string, unknown> | undefined): boolean {
364
488
  return typeof input?.action === "string";
365
489
  }
366
490
 
491
+ function commandHelp(config: WorkflowConfig, options: WorkflowAdapterOptions): string {
492
+ const command = `/${options.commandName}`;
493
+ return [
494
+ `${config.footerLabel} workflow`,
495
+ `${command} <goal> — start a new durable run.`,
496
+ `${command} resume — list resumable runs.`,
497
+ `${command} resume <run-id|artifact-dir|workflow.json> — explicitly resume one.`,
498
+ `Phases advance when their artifacts are written. Runs never auto-resume after reload.`,
499
+ `Only one run may be attached; ${options.toolNames.end} explicitly completes a terminal-ready run.`,
500
+ ].join("\n");
501
+ }
502
+
367
503
  function registerSharedArtifactWriter(pi: ExtensionAPI): void {
368
504
  if (registry.writerRegisteredFor.has(pi)) return;
369
505
  registry.writerRegisteredFor.add(pi);
@@ -399,17 +535,35 @@ function registerSharedArtifactWriter(pi: ExtensionAPI): void {
399
535
  const path = resolve(snap.artifactDir, file);
400
536
  await mkdir(snap.artifactDir, { recursive: true });
401
537
  await writeFile(path, params.content, "utf8");
402
- const eff = await controller.sm.onArtifactMaybe(controller.deps);
403
- applyControllerEffect(controller, ctx, eff);
538
+ const before = checkpoint(controller);
539
+ let eff: WorkflowEffect;
540
+ try {
541
+ eff = await controller.sm.onArtifactMaybe(controller.deps);
542
+ await persistAfter(controller, before);
543
+ } catch (error) {
544
+ return {
545
+ content: [{ type: "text", text: `Wrote ${path}, but could not persist workflow state: ${error instanceof Error ? error.message : String(error)}` }],
546
+ details: { mode: controller.config.mode, phase: snap.phase, path, persistenceError: true },
547
+ };
548
+ }
549
+ // A phase boundary is user-controlled: persist and show the new phase,
550
+ // but do not inject a follow-up turn that immediately starts it.
551
+ applyControllerEffect(controller, ctx, eff, { queuePrompt: false });
404
552
  if (eff.kind === "blocked" && eff.reason) {
405
553
  return {
406
554
  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
555
  details: { mode: controller.config.mode, phase: snap.phase, path, file, blocked: true, reason: eff.reason },
408
556
  };
409
557
  }
558
+ const advanced = eff.kind === "advanced";
410
559
  return {
411
- content: [{ type: "text", text: `Wrote ${path}.` }],
412
- details: { mode: controller.config.mode, phase: snap.phase, path, file },
560
+ content: [{ type: "text", text: advanced
561
+ ? `Wrote ${path}. Phase advanced to ${eff.phase}; wait for the user to continue the workflow.`
562
+ : `Wrote ${path}.` }],
563
+ details: { mode: controller.config.mode, phase: snap.phase, path, file, advanced },
564
+ // Stop the parent turn at a user-approved artifact boundary. Without
565
+ // this, the queued phase prompt can immediately launch the next agent.
566
+ terminate: advanced,
413
567
  };
414
568
  },
415
569
  renderResult(result, _options, theme) {
@@ -425,6 +579,7 @@ export function createWorkflowExtension(
425
579
  options: WorkflowAdapterOptions,
426
580
  ): (pi: ExtensionAPI) => void {
427
581
  return function workflowExtension(pi: ExtensionAPI): void {
582
+ replaceControllerFor(pi, config.mode);
428
583
  const deps = makeDeps(pi, config);
429
584
  // ponytail: default skip rule — reuse is skipped on empty projects (no git
430
585
  // commits). Shared by every mode today; a mode that wants a different rule
@@ -439,14 +594,14 @@ export function createWorkflowExtension(
439
594
  const controller: WorkflowController = { pi, config, sm, deps, seenToolCallIds: new Set() };
440
595
  registry.controllers.push(controller);
441
596
  registerSharedArtifactWriter(pi);
442
- const { start, end } = options.toolNames;
597
+ const { start, resume, end } = options.toolNames;
443
598
  const { mode, footerLabel } = config;
444
599
 
445
600
  // ── start tool ──
446
601
  pi.registerTool({
447
602
  name: start,
448
603
  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.`,
604
+ 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
605
  promptSnippet: `${start}(goal) - start the ${mode} workflow; goal is what the user wants to build.`,
451
606
  promptGuidelines: [
452
607
  `Call ${start} only when the user explicitly asks to start a ${mode} workflow and none is active.`,
@@ -465,12 +620,35 @@ export function createWorkflowExtension(
465
620
  details: { phase: other.sm.snapshot.phase, alreadyActive: true },
466
621
  };
467
622
  }
623
+ const before = checkpoint(controller);
468
624
  const eff = await sm.start(params.goal, deps);
469
625
  if (eff.kind === "started") {
626
+ controller.run = {
627
+ version: 1,
628
+ id: basename(sm.snapshot.artifactDir),
629
+ mode,
630
+ status: "active",
631
+ goal: sm.snapshot.userPrompt,
632
+ artifactDir: sm.snapshot.artifactDir,
633
+ phase: sm.snapshot.phase,
634
+ autoArmed: sm.snapshot.autoArmed,
635
+ createdAt: new Date().toISOString(),
636
+ updatedAt: new Date().toISOString(),
637
+ };
638
+ try {
639
+ await persistAfter(controller, before);
640
+ } catch (error) {
641
+ return {
642
+ content: [{ type: "text", text: `Could not start durable workflow: ${error instanceof Error ? error.message : String(error)}` }],
643
+ details: { persistenceError: true },
644
+ };
645
+ }
470
646
  controller.seenToolCallIds.clear();
471
647
  controller.lastBlockedKey = undefined;
472
648
  }
473
- applyControllerEffect(controller, ctx, eff);
649
+ // The start tool result already contains the grilling prompt. Queueing
650
+ // it again creates a duplicate autonomous turn.
651
+ applyControllerEffect(controller, ctx, eff, { queuePrompt: false });
474
652
  if (eff.kind === "alreadyActive") {
475
653
  return {
476
654
  content: [
@@ -498,6 +676,27 @@ export function createWorkflowExtension(
498
676
  },
499
677
  } satisfies ToolDefinition);
500
678
 
679
+ // ── explicit resume tool ──
680
+ pi.registerTool({
681
+ name: resume,
682
+ label: options.toolLabels.resume,
683
+ description: `Resume an explicitly selected ${mode} workflow run. Pass a run id, artifact directory, or workflow.json path.`,
684
+ promptSnippet: `${resume}(run) - resume an explicit ${mode} workflow run; run is its id, artifact directory, or workflow.json path.`,
685
+ promptGuidelines: [
686
+ `Call ${resume} only when the user explicitly selects a ${mode} workflow run.`,
687
+ "Workflow runs never resume automatically after reload.",
688
+ ],
689
+ parameters: Type.Object({ run: Type.String({ description: "Run id, artifact directory, or workflow.json path." }) }),
690
+ async execute(_id, params, _signal, _onUpdate, ctx) {
691
+ return resumeController(controller, ctx, params.run, end);
692
+ },
693
+ renderResult(result, _options, theme) {
694
+ const d = result.details as { rejected?: boolean; persistenceError?: boolean; phase?: string };
695
+ const failed = d.rejected || d.persistenceError;
696
+ return new Text(theme.fg(failed ? "warning" : "success", failed ? "○ workflow resume rejected" : `✓ workflow resumed · ${d.phase}`), 0, 0);
697
+ },
698
+ } satisfies ToolDefinition);
699
+
501
700
  // ── end tool ──
502
701
  pi.registerTool({
503
702
  name: end,
@@ -509,7 +708,18 @@ export function createWorkflowExtension(
509
708
  ],
510
709
  parameters: Type.Object({}),
511
710
  async execute(_id, _params, _signal, _onUpdate, ctx) {
711
+ const before = checkpoint(controller);
512
712
  const eff = await sm.end(deps);
713
+ if (eff.kind === "closed") {
714
+ try {
715
+ await persistAfter(controller, before);
716
+ } catch (error) {
717
+ return {
718
+ content: [{ type: "text", text: `Cannot end: workflow state could not be persisted: ${error instanceof Error ? error.message : String(error)}` }],
719
+ details: { persistenceError: true },
720
+ };
721
+ }
722
+ }
513
723
  applyControllerEffect(controller, ctx, eff);
514
724
  if (eff.kind === "closed") {
515
725
  controller.seenToolCallIds.clear();
@@ -563,48 +773,33 @@ export function createWorkflowExtension(
563
773
  },
564
774
  } satisfies ToolDefinition);
565
775
 
566
- // ── session_start: restore state from persisted entries ──
776
+ // ── session_start: disk state is never auto-attached. A session entry is
777
+ // merely a convenience pointer for rendering a stale-but-useful footer. ──
567
778
  pi.on("session_start", async (_event: any, ctx: ExtensionContext) => {
779
+ if (!isRegisteredController(controller)) return;
568
780
  const entries = ctx.sessionManager.getEntries();
569
781
  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);
782
+ const entry = entries[i] as { type: string; customType?: string; data?: any };
783
+ const data = entry.data;
784
+ if (entry.type !== "custom" || entry.customType !== config.entryType || !data || data.mode !== mode || typeof data.workflowStatePath !== "string") continue;
785
+ try {
786
+ const { run } = await resolveWorkflowRun(data.workflowStatePath, artifactsBaseFor(config), modesFor(config));
787
+ if (run.status === "active") {
788
+ ctx.ui.setStatus(config.statusKey, ctx.ui.theme.fg(
789
+ "warning",
790
+ `● ${config.footerLabel} · ${config.phases.indexOf(run.phase) + 1}/${config.phases.length} ${run.phase} (resume required)`,
791
+ ));
600
792
  }
601
- break;
793
+ } catch {
794
+ // Old/custom entries are intentionally non-authoritative.
602
795
  }
796
+ break;
603
797
  }
604
798
  });
605
799
 
606
800
  // ── tool_call: enforce workflow boundaries. ──
607
801
  pi.on("tool_call", (event: any, _ctx: ExtensionContext) => {
802
+ if (!isRegisteredController(controller)) return;
608
803
  const tool = event.toolName;
609
804
  if (tool !== "subagent" && tool !== "write" && tool !== "edit") return;
610
805
  const snap = sm.snapshot;
@@ -648,27 +843,29 @@ export function createWorkflowExtension(
648
843
 
649
844
  // ── tool_result: auto-advance after subagent/bash artifacts. ──
650
845
  pi.on("tool_result", async (event: any, ctx: ExtensionContext) => {
846
+ if (!isRegisteredController(controller)) return;
651
847
  if (event.toolName !== "bash" && event.toolName !== "subagent") return;
848
+ // Both modes receive every tool result. Inactive controllers have no
849
+ // attached run and must not try to persist ordinary agent activity.
850
+ if (!sm.snapshot.active) return;
851
+ const eventBefore = checkpoint(controller);
652
852
  if (sm.snapshot.active && typeof event.toolCallId === "string") {
653
853
  if (controller.seenToolCallIds.has(event.toolCallId)) return;
654
854
  controller.seenToolCallIds.add(event.toolCallId);
655
855
  }
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.
856
+ // Re-check after an error in case the tool wrote its artifact before
857
+ // failing. The normal tool-result continuation receives the error; do
858
+ // not inject another parent turn.
660
859
  if (event.isError && sm.snapshot.active) {
661
860
  if (event.toolName === "subagent" && isSubagentManagementAction(event.input)) return;
662
861
  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
- );
862
+ try {
863
+ await persistAfter(controller, eventBefore);
864
+ } catch (error) {
865
+ ctx.ui.notify(`Workflow state was not saved: ${error instanceof Error ? error.message : String(error)}`, "warning");
866
+ return;
671
867
  }
868
+ applyControllerEffect(controller, ctx, eff, { queuePrompt: false });
672
869
  if (sm.snapshot.phase !== "loop") {
673
870
  controller.loopState = undefined;
674
871
  }
@@ -711,14 +908,10 @@ export function createWorkflowExtension(
711
908
  }
712
909
  }
713
910
  }
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).
911
+ // The adapter owns durable loop state; the normal subagent tool-result
912
+ // continuation lets the parent choose the next builder/commentator call.
913
+ // No synthetic follow-up chat is queued. loopState clears when the phase
914
+ // leaves "loop" (below).
722
915
  if (event.toolName === "subagent" && sm.snapshot.active && sm.snapshot.phase === "loop") {
723
916
  const dir = sm.snapshot.artifactDir;
724
917
  const maxIt = config.loopMaxIterations ?? 3;
@@ -729,15 +922,20 @@ export function createWorkflowExtension(
729
922
  const agent = event.input?.agent;
730
923
 
731
924
  if (agent === "builder") {
732
- // Build step done → drive the commentator review.
925
+ // The normal subagent result resumes the parent turn; record the
926
+ // next loop stage but do not inject a duplicate follow-up message.
733
927
  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
928
  } else if (agent === "commentator") {
739
- ls.reviewRound += 1;
740
- const status = parseLoopReviewStatus(textFromToolResultContent(event.content));
929
+ const reviewRound = ls.reviewRound + 1;
930
+ const reviewText = textFromToolResultContent(event.content);
931
+ if (reviewText) {
932
+ const reviewPath = `loop-review-${reviewRound}.md`;
933
+ await mkdir(dir, { recursive: true });
934
+ await writeFile(resolve(dir, reviewPath), reviewText, "utf8");
935
+ ls.reviewPath = reviewPath;
936
+ }
937
+ ls.reviewRound = reviewRound;
938
+ const status = parseLoopReviewStatus(reviewText);
741
939
  if (status === "clean") {
742
940
  ls.stage = "clean";
743
941
  await mkdir(dir, { recursive: true });
@@ -756,29 +954,39 @@ or
756
954
  // A genuine clean review on a later round still advances — the
757
955
  // clean branch above runs first.
758
956
  } else {
759
- // Blocking (or no marker) → drive a builder fix round.
957
+ // Blocking (or no marker) → the parent continues with a builder
958
+ // fix round from the normal subagent result, without a queued chat.
760
959
  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
960
  }
766
961
  }
767
962
  // Non-builder/commentator subagent calls in loop fall through to
768
963
  // onArtifactMaybe (a no-op unless loop-complete.md exists).
769
964
  }
770
965
  const eff = await sm.onArtifactMaybe(deps);
771
- applyControllerEffect(controller, ctx, eff);
772
966
  // Clear loop state once we have left the loop phase (advanced to audit).
773
967
  if (sm.snapshot.phase !== "loop") {
774
968
  controller.loopState = undefined;
775
969
  }
970
+ try {
971
+ await persistAfter(controller, eventBefore);
972
+ } catch (error) {
973
+ ctx.ui.notify(`Workflow state was not saved: ${error instanceof Error ? error.message : String(error)}`, "warning");
974
+ return;
975
+ }
976
+ // Do not turn an artifact/subagent result into a new parent turn.
977
+ // The user resumes the next phase deliberately via the workflow command.
978
+ applyControllerEffect(controller, ctx, eff, { queuePrompt: false });
776
979
  });
777
980
 
778
981
  // ── /<command> ──
779
982
  pi.registerCommand(options.commandName, {
780
983
  description: options.commandDescription,
781
984
  handler: async (args: string, ctx: ExtensionCommandContext) => {
985
+ const trimmed = args.trim();
986
+ if (trimmed === "help") {
987
+ ctx.ui.notify(commandHelp(config, options), "info");
988
+ return;
989
+ }
782
990
  if (!ctx.isIdle()) {
783
991
  ctx.ui.notify(
784
992
  `Agent is busy. Wait for it to finish before calling /${options.commandName}.`,
@@ -787,6 +995,36 @@ or
787
995
  return;
788
996
  }
789
997
 
998
+ if (trimmed === "resume" || trimmed.startsWith("resume ")) {
999
+ if (sm.snapshot.active) {
1000
+ ctx.ui.notify("A workflow is already active. End it before resuming another run.", "warning");
1001
+ return;
1002
+ }
1003
+ const selector = trimmed.slice("resume".length).trim();
1004
+ if (!selector) {
1005
+ const runs = await listResumableWorkflowRuns(artifactsBaseFor(config), modesFor(config));
1006
+ if (!runs.length) {
1007
+ ctx.ui.notify(`No resumable ${mode} workflows found.`, "info");
1008
+ return;
1009
+ }
1010
+ if (!ctx.hasUI) {
1011
+ ctx.ui.notify(runs.map(({ run }) => `/${options.commandName} resume ${run.id} # ${run.phase}, ${run.goal}, ${run.updatedAt}`).join("\n"), "info");
1012
+ return;
1013
+ }
1014
+ const labels = runs.map(({ run }) => `${run.id} · ${run.phase} · ${run.goal} · ${run.updatedAt}`);
1015
+ const choice = await ctx.ui.select(`Resume ${mode} workflow`, labels);
1016
+ if (choice === undefined) return;
1017
+ const index = labels.indexOf(choice);
1018
+ if (index < 0) return;
1019
+ const result = await resumeController(controller, ctx, runs[index]!.run.id, end);
1020
+ ctx.ui.notify(result.content[0].text, result.details.rejected || result.details.persistenceError ? "warning" : "info");
1021
+ return;
1022
+ }
1023
+ const result = await resumeController(controller, ctx, selector, end);
1024
+ ctx.ui.notify(result.content[0].text, result.details.rejected || result.details.persistenceError ? "warning" : "info");
1025
+ return;
1026
+ }
1027
+
790
1028
  if (sm.snapshot.active) {
791
1029
  let goal = args.trim();
792
1030
  const choice = ctx.hasUI
@@ -801,7 +1039,8 @@ or
801
1039
  if (choice === undefined) return;
802
1040
  if (choice.startsWith("Continue")) {
803
1041
  const eff = sm.continueCurrent();
804
- applyControllerEffect(controller, ctx, eff);
1042
+ // The explicit Continue action below sends exactly one prompt.
1043
+ applyControllerEffect(controller, ctx, eff, { queuePrompt: false });
805
1044
  if (eff.kind === "advanced" && eff.prompt) {
806
1045
  // continueCurrent already applied footer+entry; send the prompt.
807
1046
  continueAgent(pi, ctx, eff.prompt);
@@ -820,12 +1059,14 @@ or
820
1059
  }
821
1060
  const closed = sm.closeCurrent();
822
1061
  if (closed) {
823
- applyEntry(pi, config, closed);
824
- ctx.ui.notify(`Closed ${mode} workflow at ${closed.artifactDir}.`, "info");
1062
+ // Detach only: the on-disk active record remains resumable. Only
1063
+ // end_*_workflow writes status: completed.
1064
+ applyEntry(pi, config, { ...closed, done: false }, controller.run);
1065
+ ctx.ui.notify(`Detached ${mode} workflow at ${closed.artifactDir}; it remains resumable.`, "info");
825
1066
  }
826
1067
  args = goal;
827
1068
  } else if (!args.trim()) {
828
- ctx.ui.notify(`Usage: /${options.commandName} <what to build>`, "warning");
1069
+ ctx.ui.notify(commandHelp(config, options), "info");
829
1070
  return;
830
1071
  }
831
1072
 
@@ -837,8 +1078,27 @@ or
837
1078
  );
838
1079
  return;
839
1080
  }
1081
+ const before = checkpoint(controller);
840
1082
  const eff = await sm.start(args, deps);
841
1083
  if (eff.kind === "started") {
1084
+ controller.run = {
1085
+ version: 1,
1086
+ id: basename(sm.snapshot.artifactDir),
1087
+ mode,
1088
+ status: "active",
1089
+ goal: sm.snapshot.userPrompt,
1090
+ artifactDir: sm.snapshot.artifactDir,
1091
+ phase: sm.snapshot.phase,
1092
+ autoArmed: sm.snapshot.autoArmed,
1093
+ createdAt: new Date().toISOString(),
1094
+ updatedAt: new Date().toISOString(),
1095
+ };
1096
+ try {
1097
+ await persistAfter(controller, before);
1098
+ } catch (error) {
1099
+ ctx.ui.notify(`Could not start durable workflow: ${error instanceof Error ? error.message : String(error)}`, "warning");
1100
+ return;
1101
+ }
842
1102
  controller.seenToolCallIds.clear();
843
1103
  controller.lastBlockedKey = undefined;
844
1104
  }