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