@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.
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +4 -4
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/config-selector.d.ts +4 -2
- package/dist/cli/config-selector.d.ts.map +1 -1
- package/dist/cli/config-selector.js +1 -1
- package/dist/cli/config-selector.js.map +1 -1
- package/dist/cli/file-processor.d.ts.map +1 -1
- package/dist/cli/file-processor.js +13 -25
- package/dist/cli/file-processor.js.map +1 -1
- package/dist/core/agent-session.d.ts +22 -5
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +130 -49
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/auth-storage.d.ts.map +1 -1
- package/dist/core/auth-storage.js +13 -5
- package/dist/core/auth-storage.js.map +1 -1
- package/dist/core/cache-stats.d.ts +49 -0
- package/dist/core/cache-stats.d.ts.map +1 -0
- package/dist/core/cache-stats.js +101 -0
- package/dist/core/cache-stats.js.map +1 -0
- package/dist/core/compaction/compaction.d.ts +1 -2
- package/dist/core/compaction/compaction.d.ts.map +1 -1
- package/dist/core/compaction/compaction.js +56 -81
- package/dist/core/compaction/compaction.js.map +1 -1
- package/dist/core/extensions/index.d.ts +1 -1
- package/dist/core/extensions/index.d.ts.map +1 -1
- package/dist/core/extensions/index.js.map +1 -1
- package/dist/core/extensions/loader.d.ts.map +1 -1
- package/dist/core/extensions/loader.js +10 -0
- package/dist/core/extensions/loader.js.map +1 -1
- package/dist/core/extensions/runner.d.ts +5 -3
- package/dist/core/extensions/runner.d.ts.map +1 -1
- package/dist/core/extensions/runner.js +38 -0
- package/dist/core/extensions/runner.js.map +1 -1
- package/dist/core/extensions/types.d.ts +41 -12
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/http-dispatcher.d.ts.map +1 -1
- package/dist/core/http-dispatcher.js +28 -1
- package/dist/core/http-dispatcher.js.map +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/keybindings.d.ts.map +1 -1
- package/dist/core/keybindings.js +3 -6
- package/dist/core/keybindings.js.map +1 -1
- package/dist/core/model-registry.d.ts +4 -6
- package/dist/core/model-registry.d.ts.map +1 -1
- package/dist/core/model-registry.js +34 -8
- package/dist/core/model-registry.js.map +1 -1
- package/dist/core/model-resolver.d.ts +10 -0
- package/dist/core/model-resolver.d.ts.map +1 -1
- package/dist/core/model-resolver.js +15 -18
- package/dist/core/model-resolver.js.map +1 -1
- package/dist/core/package-manager.d.ts +4 -1
- package/dist/core/package-manager.d.ts.map +1 -1
- package/dist/core/package-manager.js +68 -26
- package/dist/core/package-manager.js.map +1 -1
- package/dist/core/provider-attribution.d.ts.map +1 -1
- package/dist/core/provider-attribution.js +0 -10
- package/dist/core/provider-attribution.js.map +1 -1
- package/dist/core/resource-loader.d.ts +2 -2
- package/dist/core/resource-loader.d.ts.map +1 -1
- package/dist/core/resource-loader.js +8 -7
- package/dist/core/resource-loader.js.map +1 -1
- package/dist/core/sdk.d.ts +1 -1
- package/dist/core/sdk.d.ts.map +1 -1
- package/dist/core/sdk.js +8 -1
- package/dist/core/sdk.js.map +1 -1
- package/dist/core/session-manager.d.ts +21 -2
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +104 -72
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/settings-manager.d.ts +14 -3
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +29 -1
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/slash-commands.d.ts +1 -0
- package/dist/core/slash-commands.d.ts.map +1 -1
- package/dist/core/slash-commands.js +3 -3
- package/dist/core/slash-commands.js.map +1 -1
- package/dist/core/timings.d.ts +4 -2
- package/dist/core/timings.d.ts.map +1 -1
- package/dist/core/timings.js +24 -14
- package/dist/core/timings.js.map +1 -1
- package/dist/core/tools/bash.d.ts.map +1 -1
- package/dist/core/tools/bash.js +20 -5
- package/dist/core/tools/bash.js.map +1 -1
- package/dist/core/tools/edit.d.ts.map +1 -1
- package/dist/core/tools/edit.js +2 -2
- package/dist/core/tools/edit.js.map +1 -1
- package/dist/core/tools/read.d.ts.map +1 -1
- package/dist/core/tools/read.js +12 -25
- package/dist/core/tools/read.js.map +1 -1
- package/dist/defaults/models.json +2 -3
- package/dist/extensions/caveman/index.js +16 -1
- package/dist/extensions/caveman/test/extension.test.js +7 -4
- package/dist/extensions/caveman/test/helpers.test.js +12 -1
- package/dist/extensions/context-compaction-reminder.test.ts +82 -0
- package/dist/extensions/context-compaction-reminder.ts +28 -0
- package/dist/extensions/handoff-new.test.ts +2 -13
- package/dist/extensions/handoff-new.ts +16 -25
- package/dist/extensions/package.json +1 -0
- package/dist/extensions/pi-subagents/agents/architect.md +25 -173
- package/dist/extensions/pi-subagents/agents/builder.md +16 -105
- package/dist/extensions/pi-subagents/agents/commentator.md +19 -116
- package/dist/extensions/pi-subagents/agents/explorer.md +13 -33
- package/dist/extensions/pi-subagents/agents/recapper.md +19 -11
- package/dist/extensions/pi-subagents/agents/researcher.md +10 -33
- package/dist/extensions/question/constants.ts +0 -7
- package/dist/extensions/question/index.ts +11 -108
- package/dist/extensions/question/schemas.ts +1 -26
- package/dist/extensions/question/shortcuts.ts +5 -14
- package/dist/extensions/question/tests/question-list.test.ts +8 -0
- package/dist/extensions/question/tests/ui-protocol.test.ts +1 -7
- package/dist/extensions/question/tui-adapter.ts +3 -20
- package/dist/extensions/question/types.ts +0 -6
- package/dist/extensions/question/ui-protocol.ts +2 -17
- package/dist/extensions/workflow/adapter.ts +384 -124
- package/dist/extensions/workflow/extension.ts +2 -1
- package/dist/extensions/workflow/modes/prototype.ts +6 -6
- package/dist/extensions/workflow/modes/quick.ts +6 -6
- package/dist/extensions/workflow/modes/task.ts +79 -0
- package/dist/extensions/workflow/run-state.ts +124 -0
- package/dist/extensions/workflow/state-machine.ts +4 -8
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/main.d.ts +2 -2
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +17 -4
- package/dist/main.js.map +1 -1
- package/dist/modes/interactive/components/assistant-message.d.ts +3 -1
- package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/assistant-message.js +23 -15
- package/dist/modes/interactive/components/assistant-message.js.map +1 -1
- package/dist/modes/interactive/components/config-selector.d.ts +34 -3
- package/dist/modes/interactive/components/config-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/config-selector.js +280 -25
- package/dist/modes/interactive/components/config-selector.js.map +1 -1
- package/dist/modes/interactive/components/custom-entry.d.ts +19 -0
- package/dist/modes/interactive/components/custom-entry.d.ts.map +1 -0
- package/dist/modes/interactive/components/custom-entry.js +52 -0
- package/dist/modes/interactive/components/custom-entry.js.map +1 -0
- package/dist/modes/interactive/components/extension-editor.d.ts +3 -1
- package/dist/modes/interactive/components/extension-editor.d.ts.map +1 -1
- package/dist/modes/interactive/components/extension-editor.js +12 -3
- package/dist/modes/interactive/components/extension-editor.js.map +1 -1
- package/dist/modes/interactive/components/footer.d.ts +4 -0
- package/dist/modes/interactive/components/footer.d.ts.map +1 -1
- package/dist/modes/interactive/components/footer.js +1 -1
- package/dist/modes/interactive/components/footer.js.map +1 -1
- package/dist/modes/interactive/components/oauth-selector.d.ts +3 -1
- package/dist/modes/interactive/components/oauth-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/oauth-selector.js +17 -6
- package/dist/modes/interactive/components/oauth-selector.js.map +1 -1
- package/dist/modes/interactive/components/settings-selector.d.ts +4 -0
- package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/settings-selector.js +25 -2
- package/dist/modes/interactive/components/settings-selector.js.map +1 -1
- package/dist/modes/interactive/components/status-indicator.d.ts +28 -0
- package/dist/modes/interactive/components/status-indicator.d.ts.map +1 -0
- package/dist/modes/interactive/components/status-indicator.js +60 -0
- package/dist/modes/interactive/components/status-indicator.js.map +1 -0
- package/dist/modes/interactive/components/thinking-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/thinking-selector.js +2 -1
- package/dist/modes/interactive/components/thinking-selector.js.map +1 -1
- package/dist/modes/interactive/components/user-message.d.ts +6 -2
- package/dist/modes/interactive/components/user-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/user-message.js +19 -6
- package/dist/modes/interactive/components/user-message.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +21 -10
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +417 -196
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/interactive/theme/dark.json +1 -0
- package/dist/modes/interactive/theme/light.json +1 -0
- package/dist/modes/interactive/theme/theme-schema.json +6 -2
- package/dist/modes/interactive/theme/theme.d.ts +3 -2
- package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
- package/dist/modes/interactive/theme/theme.js +10 -3
- package/dist/modes/interactive/theme/theme.js.map +1 -1
- package/dist/modes/print-mode.d.ts.map +1 -1
- package/dist/modes/print-mode.js +1 -1
- package/dist/modes/print-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-client.d.ts +21 -6
- package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-client.js +17 -3
- package/dist/modes/rpc/rpc-client.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +20 -1
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-types.d.ts +26 -0
- package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-types.js.map +1 -1
- package/dist/package-manager-cli.d.ts +2 -2
- package/dist/package-manager-cli.d.ts.map +1 -1
- package/dist/package-manager-cli.js +71 -17
- package/dist/package-manager-cli.js.map +1 -1
- package/dist/rpc-entry.d.ts +3 -0
- package/dist/rpc-entry.d.ts.map +1 -0
- package/dist/rpc-entry.js +10 -0
- package/dist/rpc-entry.js.map +1 -0
- package/dist/skills/workflow-creation/SKILL.md +72 -0
- package/dist/utils/clipboard-image.d.ts.map +1 -1
- package/dist/utils/clipboard-image.js +1 -1
- package/dist/utils/clipboard-image.js.map +1 -1
- package/dist/utils/image-convert.d.ts +1 -0
- package/dist/utils/image-convert.d.ts.map +1 -1
- package/dist/utils/image-convert.js +21 -15
- package/dist/utils/image-convert.js.map +1 -1
- package/dist/utils/image-process.d.ts +18 -0
- package/dist/utils/image-process.d.ts.map +1 -0
- package/dist/utils/image-process.js +83 -0
- package/dist/utils/image-process.js.map +1 -0
- package/dist/utils/mime.d.ts.map +1 -1
- package/dist/utils/mime.js +41 -0
- package/dist/utils/mime.js.map +1 -1
- package/docs/workflows.md +40 -6
- 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 {
|
|
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
|
|
29
|
-
|
|
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
|
|
48
|
-
return
|
|
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
|
-
//
|
|
172
|
-
// the state
|
|
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,
|
|
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
|
-
|
|
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
|
|
403
|
-
|
|
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:
|
|
412
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
657
|
-
//
|
|
658
|
-
//
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
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
|
-
//
|
|
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
|
|
740
|
-
const
|
|
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) →
|
|
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
|
-
|
|
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
|
-
|
|
824
|
-
|
|
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(
|
|
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
|
}
|