@selesai/code 0.1.19 → 0.2.1
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/agents/architect.md +4 -3
- package/dist/agents/builder.md +5 -4
- package/dist/agents/commentator.md +3 -2
- package/dist/agents/explorer.md +4 -4
- package/dist/agents/recapper.md +4 -3
- package/dist/config.d.ts +6 -7
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +7 -9
- package/dist/config.js.map +1 -1
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +3 -1
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/compaction/compaction.d.ts.map +1 -1
- package/dist/core/compaction/compaction.js +18 -24
- package/dist/core/compaction/compaction.js.map +1 -1
- package/dist/core/extensions/loader.d.ts.map +1 -1
- package/dist/core/extensions/loader.js +2 -0
- package/dist/core/extensions/loader.js.map +1 -1
- package/dist/core/extensions/types.d.ts +8 -1
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/skills.d.ts.map +1 -1
- package/dist/core/skills.js +25 -11
- package/dist/core/skills.js.map +1 -1
- package/dist/defaults/models.json +143 -39
- package/dist/defaults/settings.json +2 -29
- package/dist/extensions/package.json +5 -4
- package/dist/extensions/pi-intercom/index.ts +12 -5
- package/dist/extensions/pi-powerline-footer/index.ts +4 -1
- package/dist/extensions/pi-powerline-footer/tps.ts +286 -0
- package/dist/extensions/pi-powerline-footer/welcome.ts +47 -46
- package/dist/extensions/pi-subagents/README.md +12 -5
- package/dist/extensions/pi-subagents/agents/context-builder.md +2 -2
- package/dist/extensions/pi-subagents/agents/researcher.md +4 -5
- package/dist/extensions/pi-subagents/package.json +8 -5
- package/dist/extensions/pi-subagents/prompts/parallel-context-build.md +1 -1
- package/dist/extensions/pi-subagents/prompts/parallel-handoff-plan.md +1 -1
- package/dist/extensions/pi-subagents/src/agents/agent-management.ts +1 -1
- package/dist/extensions/pi-subagents/src/agents/agents.ts +3 -3
- package/dist/extensions/pi-subagents/src/extension/control-notices.ts +1 -1
- package/dist/extensions/pi-subagents/src/extension/doctor.ts +3 -3
- package/dist/extensions/pi-subagents/src/extension/fanout-child.ts +1 -1
- package/dist/extensions/pi-subagents/src/extension/index.ts +29 -2
- package/dist/extensions/pi-subagents/src/profiles/profiles.ts +2 -2
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +72 -13
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +85 -41
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-clarify.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +1 -1
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +46 -3
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +2 -2
- package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +1 -3
- package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +2 -2
- package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +18 -18
- package/dist/extensions/pi-subagents/src/runs/shared/pi-spawn.ts +24 -24
- package/dist/extensions/pi-subagents/src/runs/shared/spawn-timing.ts +46 -0
- package/dist/extensions/pi-subagents/src/runs/shared/structured-output.ts +2 -2
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +4 -4
- package/dist/extensions/pi-subagents/src/shared/fork-context.ts +1 -1
- package/dist/extensions/pi-subagents/src/shared/types.ts +6 -6
- package/dist/extensions/pi-subagents/src/shared/utils.ts +3 -3
- package/dist/extensions/pi-subagents/src/slash/slash-bridge.ts +1 -1
- package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +1 -1
- package/dist/extensions/pi-subagents/src/tui/render-helpers.ts +1 -1
- package/dist/extensions/pi-subagents/src/tui/render.ts +1 -1
- package/dist/extensions/pi-subagents/test/unit/spawn-timing.test.ts +45 -0
- package/dist/extensions/pi-subagents/tsconfig.runner.json +17 -0
- package/dist/extensions/pi-web-agent/package.json +31 -0
- package/dist/extensions/pi-web-agent/src/backends/config.ts +205 -0
- package/dist/extensions/pi-web-agent/src/backends/doctor.ts +136 -0
- package/dist/extensions/pi-web-agent/src/backends/factory.ts +152 -0
- package/dist/extensions/pi-web-agent/src/backends/settings-reader.ts +25 -0
- package/dist/extensions/pi-web-agent/src/cache/ttl-cache.ts +28 -0
- package/dist/extensions/pi-web-agent/src/changelog-notice.ts +133 -0
- package/dist/extensions/pi-web-agent/src/commands/web-agent-config.ts +946 -0
- package/dist/extensions/pi-web-agent/src/extension.ts +126 -0
- package/dist/extensions/pi-web-agent/src/extract/readability.ts +118 -0
- package/dist/extensions/pi-web-agent/src/fetch/browser-resolution.ts +199 -0
- package/dist/extensions/pi-web-agent/src/fetch/firecrawl-fetch.ts +100 -0
- package/dist/extensions/pi-web-agent/src/fetch/headless-fetch.ts +117 -0
- package/dist/extensions/pi-web-agent/src/fetch/http-fetch.ts +67 -0
- package/dist/extensions/pi-web-agent/src/orchestration/answer-synthesizer.ts +60 -0
- package/dist/extensions/pi-web-agent/src/orchestration/candidate-selector.ts +50 -0
- package/dist/extensions/pi-web-agent/src/orchestration/direct-url.ts +52 -0
- package/dist/extensions/pi-web-agent/src/orchestration/evidence-quality.ts +105 -0
- package/dist/extensions/pi-web-agent/src/orchestration/evidence-ranker.ts +45 -0
- package/dist/extensions/pi-web-agent/src/orchestration/index.ts +28 -0
- package/dist/extensions/pi-web-agent/src/orchestration/query-planner.ts +47 -0
- package/dist/extensions/pi-web-agent/src/orchestration/research-orchestrator.ts +376 -0
- package/dist/extensions/pi-web-agent/src/orchestration/research-types.ts +64 -0
- package/dist/extensions/pi-web-agent/src/orchestration/research-worker.ts +181 -0
- package/dist/extensions/pi-web-agent/src/orchestration/source-profile.ts +101 -0
- package/dist/extensions/pi-web-agent/src/orchestration/stop-decider.ts +81 -0
- package/dist/extensions/pi-web-agent/src/presentation/config-store.ts +208 -0
- package/dist/extensions/pi-web-agent/src/presentation/config.ts +75 -0
- package/dist/extensions/pi-web-agent/src/presentation/explore-presentation.ts +61 -0
- package/dist/extensions/pi-web-agent/src/presentation/fetch-presentation.ts +54 -0
- package/dist/extensions/pi-web-agent/src/presentation/search-presentation.ts +41 -0
- package/dist/extensions/pi-web-agent/src/presentation/select-view.ts +20 -0
- package/dist/extensions/pi-web-agent/src/presentation/types.ts +63 -0
- package/dist/extensions/pi-web-agent/src/search/brave.ts +114 -0
- package/dist/extensions/pi-web-agent/src/search/duckduckgo.ts +72 -0
- package/dist/extensions/pi-web-agent/src/search/searxng.ts +96 -0
- package/dist/extensions/pi-web-agent/src/tools/web-explore.ts +71 -0
- package/dist/extensions/pi-web-agent/src/tools/web-fetch-headless.ts +31 -0
- package/dist/extensions/pi-web-agent/src/tools/web-fetch.ts +31 -0
- package/dist/extensions/pi-web-agent/src/tools/web-search.ts +157 -0
- package/dist/extensions/pi-web-agent/src/types.ts +88 -0
- package/dist/extensions/ponytail/index.js +189 -0
- package/dist/extensions/ponytail/package.json +8 -0
- package/dist/extensions/ponytail/ponytail-config.cjs +122 -0
- package/dist/extensions/ponytail/ponytail-instructions.cjs +94 -0
- package/dist/extensions/ponytail/test/extension.test.js +167 -0
- package/dist/extensions/ponytail/test/helpers.test.js +92 -0
- package/dist/extensions/question/constants.ts +38 -0
- package/dist/extensions/question/dialog-adapter.ts +74 -0
- package/dist/extensions/question/helpers.ts +102 -0
- package/dist/extensions/question/index.ts +671 -0
- package/dist/extensions/question/navigation.ts +14 -0
- package/dist/extensions/question/package.json +18 -0
- package/dist/extensions/question/question-list.ts +498 -0
- package/dist/extensions/question/row-layout.ts +162 -0
- package/dist/extensions/question/schemas.ts +69 -0
- package/dist/extensions/question/selection-mode.ts +49 -0
- package/dist/extensions/question/shortcuts.ts +53 -0
- package/dist/extensions/question/tests/helpers.test.ts +178 -0
- package/dist/extensions/question/tests/question-list.test.ts +230 -0
- package/dist/extensions/question/tests/row-layout.test.ts +136 -0
- package/dist/extensions/question/tests/shortcuts.test.ts +85 -0
- package/dist/extensions/question/tests/ui-protocol.test.ts +143 -0
- package/dist/extensions/question/tui-adapter.ts +33 -0
- package/dist/extensions/question/types.ts +102 -0
- package/dist/extensions/question/ui-protocol.ts +46 -0
- package/dist/extensions/tokenin-onboarding.ts +265 -2
- package/dist/extensions/web-agent-onboarding.ts +222 -0
- package/dist/extensions/workflow/adapter.ts +588 -0
- package/dist/extensions/workflow/extension.ts +24 -0
- package/dist/extensions/workflow/modes/prototype.ts +156 -0
- package/dist/extensions/workflow/modes/quick.ts +138 -0
- package/dist/extensions/workflow/package.json +17 -0
- package/dist/extensions/workflow/state-machine.ts +386 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +2 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +57 -13
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/utils/mime.d.ts.map +1 -1
- package/dist/utils/mime.js +24 -1
- package/dist/utils/mime.js.map +1 -1
- package/docs/docs.json +4 -0
- package/docs/workflows.md +206 -0
- package/package.json +5 -1
- package/README.md +0 -198
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
// ponytail: pi adapter over the pure WorkflowStateMachine. Owns all fs/pi
|
|
2
|
+
// wiring: tool/command/event registration, node:fs predicates, the git skip
|
|
3
|
+
// check, footer rendering, agent continuation, tool-result shaping. Every
|
|
4
|
+
// call site is one sm method + one applyEffect switch over domain effects.
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
ExtensionAPI,
|
|
8
|
+
ExtensionCommandContext,
|
|
9
|
+
ExtensionContext,
|
|
10
|
+
ToolDefinition,
|
|
11
|
+
} from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
13
|
+
import { Type } from "typebox";
|
|
14
|
+
import { access, mkdir, writeFile } from "node:fs/promises";
|
|
15
|
+
import { isAbsolute, resolve } from "node:path";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
WorkflowStateMachine,
|
|
19
|
+
type WorkflowConfig,
|
|
20
|
+
type WorkflowDeps,
|
|
21
|
+
type WorkflowEffect,
|
|
22
|
+
type WorkflowEntry,
|
|
23
|
+
type FooterState,
|
|
24
|
+
type Phase,
|
|
25
|
+
} from "./state-machine.ts";
|
|
26
|
+
|
|
27
|
+
function slugify(s: string): string {
|
|
28
|
+
const slug = s
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
// ponytail: u-flag so Unicode chars (Chinese, emoji, etc.) are also
|
|
31
|
+
// treated as non-alphanumeric and replaced with dashes — without /u,
|
|
32
|
+
// JS’s \W shorthand only matches ASCII, leaving Unicode in paths
|
|
33
|
+
// which confuses agents and tools.
|
|
34
|
+
.replace(/[^a-z0-9]+/ug, "-")
|
|
35
|
+
.replace(/^-+|-+$/g, "")
|
|
36
|
+
.slice(0, 40);
|
|
37
|
+
return slug || "workflow";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function timestamp(): string {
|
|
41
|
+
const d = new Date();
|
|
42
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
43
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function defaultArtifactPathFor(goal: string, base: string): string {
|
|
47
|
+
return `${base}/${timestamp()}-${slugify(goal)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function realFileExists(path: string): Promise<boolean> {
|
|
51
|
+
try {
|
|
52
|
+
await access(path);
|
|
53
|
+
return true;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ponytail: phases where one spawned subagent owns the phase artifact. Force
|
|
60
|
+
// the child `output` path; child processes do not share the parent's workflow
|
|
61
|
+
// state, so the parent-scoped write_workflow_artifact tool cannot help there.
|
|
62
|
+
const FORCE_OUTPUT_PHASES = new Set<Phase>(["plan", "reuse", "handoff", "audit"]);
|
|
63
|
+
|
|
64
|
+
// ponytail: phases where the parent can save subagent text as the artifact
|
|
65
|
+
// when the child did not write the file itself (dumb/local models).
|
|
66
|
+
const SUBAGENT_FALLBACK_PHASES = new Set<Phase>(["plan", "reuse", "handoff", "audit"]);
|
|
67
|
+
|
|
68
|
+
function textFromToolResultContent(content: unknown): string | undefined {
|
|
69
|
+
if (!Array.isArray(content)) return undefined;
|
|
70
|
+
const parts: string[] = [];
|
|
71
|
+
for (const part of content) {
|
|
72
|
+
if (part && typeof part === "object" && (part as any).type === "text" && typeof (part as any).text === "string") {
|
|
73
|
+
parts.push((part as any).text);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const joined = parts.join("\n\n").trim();
|
|
77
|
+
return joined || undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ponytail: rewrite the subagent tool input so the child writes its output
|
|
81
|
+
// directly to ${artifactDir}/${file} as an absolute path. Absolute paths pass
|
|
82
|
+
// through resolveSingleOutputPath verbatim, so injectOutputPathSystemPrompt
|
|
83
|
+
// then forces the child to the correct location instead of repo root.
|
|
84
|
+
// Mutates `input` in place (tool_call handlers can patch event.input).
|
|
85
|
+
// No-op unless the workflow is active, in a force-output phase, and the call
|
|
86
|
+
// targets the `subagent` tool. Respects an explicit absolute caller output.
|
|
87
|
+
function forceSubagentOutputToArtifactDir(
|
|
88
|
+
input: Record<string, unknown>,
|
|
89
|
+
artifactDir: string,
|
|
90
|
+
file: string,
|
|
91
|
+
onlyAgents?: Set<string>,
|
|
92
|
+
): void {
|
|
93
|
+
const dest = resolve(artifactDir, file);
|
|
94
|
+
const shouldForce = (agent: string) => !onlyAgents || onlyAgents.has(agent);
|
|
95
|
+
// Single-agent call: { agent, task, output?, ... }
|
|
96
|
+
if (typeof input.agent === "string") {
|
|
97
|
+
if (!shouldForce(input.agent)) return;
|
|
98
|
+
const existing = input.output;
|
|
99
|
+
if (typeof existing === "string" && isAbsolute(existing)) return; // caller pinned it
|
|
100
|
+
input.output = dest;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Top-level parallel: { tasks: [{ agent, output? }, ...] }
|
|
104
|
+
if (Array.isArray(input.tasks)) {
|
|
105
|
+
for (const task of input.tasks) {
|
|
106
|
+
if (task && typeof task === "object" && typeof task.agent === "string" && shouldForce(task.agent)) {
|
|
107
|
+
const ex = task.output;
|
|
108
|
+
if (typeof ex === "string" && isAbsolute(ex)) continue;
|
|
109
|
+
task.output = dest;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// Chain: { chain: [{ agent, output?, parallel: [{ agent, output? }] }] }
|
|
115
|
+
if (Array.isArray(input.chain)) {
|
|
116
|
+
for (const step of input.chain) {
|
|
117
|
+
if (!step || typeof step !== "object") continue;
|
|
118
|
+
if (typeof step.agent === "string" && shouldForce(step.agent)) {
|
|
119
|
+
const ex = step.output;
|
|
120
|
+
if (!(typeof ex === "string" && isAbsolute(ex))) step.output = dest;
|
|
121
|
+
}
|
|
122
|
+
if (Array.isArray(step.parallel)) {
|
|
123
|
+
for (const task of step.parallel) {
|
|
124
|
+
if (task && typeof task === "object" && typeof task.agent === "string" && shouldForce(task.agent)) {
|
|
125
|
+
const ex = task.output;
|
|
126
|
+
if (!(typeof ex === "string" && isAbsolute(ex))) task.output = dest;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface WorkflowAdapterOptions {
|
|
135
|
+
toolNames: { start: string; end: string };
|
|
136
|
+
toolLabels: { start: string; end: string };
|
|
137
|
+
commandName: string;
|
|
138
|
+
commandDescription: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface WorkflowController {
|
|
142
|
+
pi: ExtensionAPI;
|
|
143
|
+
config: WorkflowConfig;
|
|
144
|
+
sm: WorkflowStateMachine;
|
|
145
|
+
deps: WorkflowDeps;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const WORKFLOW_ARTIFACT_TOOL = "write_workflow_artifact";
|
|
149
|
+
// ponytail: shared across all module copies so prototype + quick registration
|
|
150
|
+
// can race. Module-level state alone is not enough when the loader runs each
|
|
151
|
+
// extension in its own module record.
|
|
152
|
+
const WORKFLOW_GLOBAL = Symbol.for("selesai.workflow.registry.v1");
|
|
153
|
+
type WorkflowRegistry = { controllers: WorkflowController[]; writerRegisteredFor: WeakSet<object> };
|
|
154
|
+
const registry: WorkflowRegistry = ((globalThis as any)[WORKFLOW_GLOBAL] ??= {
|
|
155
|
+
controllers: [],
|
|
156
|
+
writerRegisteredFor: new WeakSet(),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ponytail: tests call this between cases to drop pi references and reset
|
|
160
|
+
// writer-registration state. Production code never calls it.
|
|
161
|
+
export function __resetWorkflowRegistryForTests(): void {
|
|
162
|
+
registry.controllers.length = 0;
|
|
163
|
+
(registry.writerRegisteredFor as WeakSet<object>) = new WeakSet();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function activeControllersFor(pi: ExtensionAPI): WorkflowController[] {
|
|
167
|
+
return registry.controllers.filter((c) => c.pi === pi && c.sm.snapshot.active);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ponytail: builds the deps bundle the adapter injects into the state machine.
|
|
171
|
+
// The skip predicate wraps pi.exec("git"...) — the only true-external seam.
|
|
172
|
+
function makeDeps(pi: ExtensionAPI, config: WorkflowConfig): WorkflowDeps {
|
|
173
|
+
return {
|
|
174
|
+
async artifactExists(phase, dir) {
|
|
175
|
+
const file = config.phaseArtifacts[phase];
|
|
176
|
+
if (!file) return true;
|
|
177
|
+
return realFileExists(`${dir}/${file}`);
|
|
178
|
+
},
|
|
179
|
+
async fileExists(path) {
|
|
180
|
+
return realFileExists(path);
|
|
181
|
+
},
|
|
182
|
+
async mkdirArtifactDir(path) {
|
|
183
|
+
await mkdir(path, { recursive: true });
|
|
184
|
+
},
|
|
185
|
+
artifactPathFor: defaultArtifactPathFor,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ponytail: the git-based skip predicate shared by both modes today.
|
|
190
|
+
// A mode that wants a different skip rule supplies its own SkipRule.
|
|
191
|
+
async function isEmptyProject(pi: ExtensionAPI): Promise<boolean> {
|
|
192
|
+
try {
|
|
193
|
+
const result = await pi.exec("git", ["log", "--oneline", "-1"]);
|
|
194
|
+
return result.code !== 0 || !result.stdout.trim();
|
|
195
|
+
} catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function footerText(footer: FooterState, ctx: ExtensionContext): string | undefined {
|
|
201
|
+
if (!footer.visible) return undefined;
|
|
202
|
+
return ctx.ui.theme.fg("warning", footer.text);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function applyEntry(pi: ExtensionAPI, config: WorkflowConfig, entry: WorkflowEntry): void {
|
|
206
|
+
pi.appendEntry(config.entryType, entry);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function applyFooter(ctx: ExtensionContext, footer: FooterState): void {
|
|
210
|
+
ctx.ui.setStatus(footer.statusKey, footerText(footer, ctx));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function continueAgent(pi: ExtensionAPI, ctx: ExtensionContext, prompt: string): void {
|
|
214
|
+
// ponytail: agent is mid-turn (streaming) when tool_result fires, so a bare
|
|
215
|
+
// sendUserMessage() throws "Agent is already processing". deliverAs:"followUp"
|
|
216
|
+
// is the framework-native continuation. In idle state (e.g. session resume)
|
|
217
|
+
// it sends a direct turn.
|
|
218
|
+
if (ctx.isIdle()) {
|
|
219
|
+
pi.sendUserMessage(prompt);
|
|
220
|
+
} else {
|
|
221
|
+
pi.sendUserMessage(prompt, { deliverAs: "followUp" });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ponytail: apply side effects for effects that carry entry/footer/prompt.
|
|
226
|
+
// Terminal/blocked/idle/noOp/alreadyActive carry none — the adapter's call
|
|
227
|
+
// site formats the tool result from the effect's domain fields + config.
|
|
228
|
+
function applyEffect(
|
|
229
|
+
pi: ExtensionAPI,
|
|
230
|
+
ctx: ExtensionContext,
|
|
231
|
+
config: WorkflowConfig,
|
|
232
|
+
eff: WorkflowEffect,
|
|
233
|
+
): void {
|
|
234
|
+
switch (eff.kind) {
|
|
235
|
+
case "started":
|
|
236
|
+
case "advanced":
|
|
237
|
+
if ("entry" in eff) applyEntry(pi, config, eff.entry);
|
|
238
|
+
if ("footer" in eff) applyFooter(ctx, eff.footer);
|
|
239
|
+
if (eff.prompt) continueAgent(pi, ctx, eff.prompt);
|
|
240
|
+
return;
|
|
241
|
+
case "closed":
|
|
242
|
+
if ("entry" in eff) applyEntry(pi, config, eff.entry);
|
|
243
|
+
if ("footer" in eff) applyFooter(ctx, eff.footer);
|
|
244
|
+
ctx.ui.notify(
|
|
245
|
+
`${config.footerLabel} workflow complete. Artifacts: ${eff.artifactDir}`,
|
|
246
|
+
"info",
|
|
247
|
+
);
|
|
248
|
+
return;
|
|
249
|
+
case "terminalNeedsArtifacts":
|
|
250
|
+
if (eff.promptToQueue) continueAgent(pi, ctx, eff.promptToQueue);
|
|
251
|
+
return;
|
|
252
|
+
case "blocked":
|
|
253
|
+
case "terminalReady":
|
|
254
|
+
case "endBlocked":
|
|
255
|
+
case "idle":
|
|
256
|
+
case "noOp":
|
|
257
|
+
case "alreadyActive":
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function registerSharedArtifactWriter(pi: ExtensionAPI): void {
|
|
263
|
+
if (registry.writerRegisteredFor.has(pi)) return;
|
|
264
|
+
registry.writerRegisteredFor.add(pi);
|
|
265
|
+
pi.registerTool({
|
|
266
|
+
name: WORKFLOW_ARTIFACT_TOOL,
|
|
267
|
+
label: "Write Workflow Artifact",
|
|
268
|
+
description: "Write the current workflow phase artifact. The workflow enforces the destination path; provide content only.",
|
|
269
|
+
promptSnippet: `${WORKFLOW_ARTIFACT_TOOL}(content) - write the active workflow artifact; path is chosen by the workflow, not the agent.`,
|
|
270
|
+
promptGuidelines: [
|
|
271
|
+
`During any workflow, use ${WORKFLOW_ARTIFACT_TOOL} instead of write/edit for workflow artifacts.`,
|
|
272
|
+
"Provide artifact content only; do not provide a path.",
|
|
273
|
+
],
|
|
274
|
+
parameters: Type.Object({
|
|
275
|
+
content: Type.String({ description: "Artifact markdown/text content to save." }),
|
|
276
|
+
}),
|
|
277
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
278
|
+
const active = activeControllersFor(pi);
|
|
279
|
+
if (active.length !== 1) {
|
|
280
|
+
const msg = active.length === 0
|
|
281
|
+
? "No active workflow."
|
|
282
|
+
: `Multiple active workflows (${active.map((c) => c.config.mode).join(", ")}); close one first.`;
|
|
283
|
+
return { content: [{ type: "text", text: msg }], details: { active: active.length } };
|
|
284
|
+
}
|
|
285
|
+
const controller = active[0]!;
|
|
286
|
+
const snap = controller.sm.snapshot;
|
|
287
|
+
const file = controller.config.phaseArtifacts[snap.phase];
|
|
288
|
+
if (!file) {
|
|
289
|
+
return {
|
|
290
|
+
content: [{ type: "text", text: `Phase ${snap.phase} has no workflow artifact.` }],
|
|
291
|
+
details: { phase: snap.phase, blocked: true },
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const path = resolve(snap.artifactDir, file);
|
|
295
|
+
await mkdir(snap.artifactDir, { recursive: true });
|
|
296
|
+
await writeFile(path, params.content, "utf8");
|
|
297
|
+
const eff = await controller.sm.onArtifactMaybe(controller.deps);
|
|
298
|
+
applyEffect(pi, ctx, controller.config, eff);
|
|
299
|
+
return {
|
|
300
|
+
content: [{ type: "text", text: `Wrote ${path}.` }],
|
|
301
|
+
details: { mode: controller.config.mode, phase: snap.phase, path, file },
|
|
302
|
+
};
|
|
303
|
+
},
|
|
304
|
+
renderResult(result, _options, theme) {
|
|
305
|
+
const d = result.details as { path?: string; blocked?: boolean };
|
|
306
|
+
if (d.blocked) return new Text(theme.fg("warning", "○ workflow artifact not written"), 0, 0);
|
|
307
|
+
return new Text(theme.fg(d.path ? "success" : "warning", d.path ? `✓ wrote ${d.path}` : "○ no active workflow"), 0, 0);
|
|
308
|
+
},
|
|
309
|
+
} satisfies ToolDefinition);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function createWorkflowExtension(
|
|
313
|
+
config: WorkflowConfig,
|
|
314
|
+
options: WorkflowAdapterOptions,
|
|
315
|
+
): (pi: ExtensionAPI) => void {
|
|
316
|
+
return function workflowExtension(pi: ExtensionAPI): void {
|
|
317
|
+
const deps = makeDeps(pi, config);
|
|
318
|
+
// ponytail: default skip rule — reuse is skipped on empty projects (no git
|
|
319
|
+
// commits). Shared by every mode today; a mode that wants a different rule
|
|
320
|
+
// supplies its own skipRules in config and we respect them as-is.
|
|
321
|
+
const skipRules = config.skipRules ?? [
|
|
322
|
+
{
|
|
323
|
+
phase: "reuse",
|
|
324
|
+
shouldSkip: async () => isEmptyProject(pi),
|
|
325
|
+
},
|
|
326
|
+
];
|
|
327
|
+
const sm = new WorkflowStateMachine({ ...config, skipRules });
|
|
328
|
+
const controller: WorkflowController = { pi, config, sm, deps };
|
|
329
|
+
registry.controllers.push(controller);
|
|
330
|
+
registerSharedArtifactWriter(pi);
|
|
331
|
+
const { start, end } = options.toolNames;
|
|
332
|
+
const { mode, footerLabel } = config;
|
|
333
|
+
|
|
334
|
+
// ── start tool ──
|
|
335
|
+
pi.registerTool({
|
|
336
|
+
name: start,
|
|
337
|
+
label: options.toolLabels.start,
|
|
338
|
+
description: `Start the ${mode} workflow for the given goal. Sets up grilling as the first phase and returns the grilling prompt. Do not call if a workflow is already active.`,
|
|
339
|
+
promptSnippet: `${start}(goal) - start the ${mode} workflow; goal is what the user wants to build.`,
|
|
340
|
+
promptGuidelines: [
|
|
341
|
+
`Call ${start} only when the user explicitly asks to start a ${mode} workflow and none is active.`,
|
|
342
|
+
"Pass the user's full goal as the goal parameter.",
|
|
343
|
+
],
|
|
344
|
+
parameters: Type.Object({
|
|
345
|
+
goal: Type.String({
|
|
346
|
+
description: `What the user wants the ${mode} workflow to build or accomplish.`,
|
|
347
|
+
}),
|
|
348
|
+
}),
|
|
349
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
350
|
+
const other = activeControllersFor(pi).find((c) => c.sm !== sm);
|
|
351
|
+
if (other) {
|
|
352
|
+
return {
|
|
353
|
+
content: [{ type: "text", text: `A ${other.config.mode} workflow is already active (phase: ${other.sm.snapshot.phase}). Close it before starting ${mode}.` }],
|
|
354
|
+
details: { phase: other.sm.snapshot.phase, alreadyActive: true },
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const eff = await sm.start(params.goal, deps);
|
|
358
|
+
applyEffect(pi, ctx, config, eff);
|
|
359
|
+
if (eff.kind === "alreadyActive") {
|
|
360
|
+
return {
|
|
361
|
+
content: [
|
|
362
|
+
{
|
|
363
|
+
type: "text",
|
|
364
|
+
text: `A ${mode} workflow is already active (phase: ${eff.phase}). Do not call ${start} again. Phases auto-advance as artifacts land; call ${end} only from the final phase.`,
|
|
365
|
+
},
|
|
366
|
+
],
|
|
367
|
+
details: { phase: eff.phase, alreadyActive: true },
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
content: [
|
|
372
|
+
{ type: "text", text: `${footerLabel} workflow started. ${eff.prompt}` },
|
|
373
|
+
],
|
|
374
|
+
details: { phase: eff.phase },
|
|
375
|
+
};
|
|
376
|
+
},
|
|
377
|
+
renderResult(_result, _options, theme) {
|
|
378
|
+
return new Text(
|
|
379
|
+
theme.fg("warning", `● ${footerLabel} started · 1/${config.phases.length} ${config.phases[0]}`),
|
|
380
|
+
0,
|
|
381
|
+
0,
|
|
382
|
+
);
|
|
383
|
+
},
|
|
384
|
+
} satisfies ToolDefinition);
|
|
385
|
+
|
|
386
|
+
// ── end tool ──
|
|
387
|
+
pi.registerTool({
|
|
388
|
+
name: end,
|
|
389
|
+
label: options.toolLabels.end,
|
|
390
|
+
description: `Close the ${mode} workflow. Must be called when the final phase is complete. Marks the workflow finished and stops the agent loop.`,
|
|
391
|
+
promptSnippet: `${end}() - close the finished ${mode} workflow; no further phase transitions allowed.`,
|
|
392
|
+
promptGuidelines: [
|
|
393
|
+
`Call ${end} exactly once, from the terminal phase, when all close artifacts are complete.`,
|
|
394
|
+
],
|
|
395
|
+
parameters: Type.Object({}),
|
|
396
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
397
|
+
const eff = await sm.end(deps);
|
|
398
|
+
applyEffect(pi, ctx, config, eff);
|
|
399
|
+
switch (eff.kind) {
|
|
400
|
+
case "closed":
|
|
401
|
+
return {
|
|
402
|
+
content: [{ type: "text", text: `Workflow ended and closed. It can no longer be used. Artifacts saved at ${eff.artifactDir}.` }],
|
|
403
|
+
details: { closed: true, phase: eff.phase, artifactDir: eff.artifactDir },
|
|
404
|
+
terminate: true,
|
|
405
|
+
};
|
|
406
|
+
case "endBlocked": {
|
|
407
|
+
// ponytail: missing is either a filename (closeArtifacts loop)
|
|
408
|
+
// or a phase description like "not at terminal (audit)". Only
|
|
409
|
+
// suggest writing when it's an actual artifact file.
|
|
410
|
+
const isArtifactFile =
|
|
411
|
+
!eff.missing.includes(" ") && eff.missing.includes(".");
|
|
412
|
+
const hint = isArtifactFile
|
|
413
|
+
? ` Write ${sm.snapshot.artifactDir}/${eff.missing} first.`
|
|
414
|
+
: ` Continue through the phases to ${config.phases[config.phases.length - 1]}.`;
|
|
415
|
+
return {
|
|
416
|
+
content: [{ type: "text", text: `Cannot end: ${eff.missing}.${hint}` }],
|
|
417
|
+
details: { phase: eff.phase, blocked: eff.missing },
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
default:
|
|
421
|
+
return {
|
|
422
|
+
content: [{ type: "text", text: `No active ${mode} workflow to end.` }],
|
|
423
|
+
details: { active: false },
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
renderResult(result, _options, theme) {
|
|
428
|
+
const d = result.details as { closed?: boolean; artifactDir?: string };
|
|
429
|
+
if (!d.closed) {
|
|
430
|
+
return new Text(theme.fg("dim", "○ no workflow to end"), 0, 0);
|
|
431
|
+
}
|
|
432
|
+
return new Text(
|
|
433
|
+
theme.fg("success", `✓ ${footerLabel} closed · ${d.artifactDir}`),
|
|
434
|
+
0,
|
|
435
|
+
0,
|
|
436
|
+
);
|
|
437
|
+
},
|
|
438
|
+
} satisfies ToolDefinition);
|
|
439
|
+
|
|
440
|
+
// ── session_start: restore state from persisted entries ──
|
|
441
|
+
pi.on("session_start", async (_event: any, ctx: ExtensionContext) => {
|
|
442
|
+
const entries = ctx.sessionManager.getEntries();
|
|
443
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
444
|
+
const e = entries[i] as { type: string; customType?: string; data?: any };
|
|
445
|
+
if (e.type === "custom" && e.customType === config.entryType && e.data) {
|
|
446
|
+
if (e.data.done) {
|
|
447
|
+
sm.rehydrate({
|
|
448
|
+
active: false,
|
|
449
|
+
phase: e.data.phase as Phase,
|
|
450
|
+
userPrompt: e.data.userPrompt ?? "",
|
|
451
|
+
artifactDir: e.data.artifactDir ?? "",
|
|
452
|
+
autoArmed: false,
|
|
453
|
+
});
|
|
454
|
+
} else {
|
|
455
|
+
sm.rehydrate({
|
|
456
|
+
active: true,
|
|
457
|
+
phase: e.data.phase as Phase,
|
|
458
|
+
userPrompt: e.data.userPrompt ?? "",
|
|
459
|
+
artifactDir: e.data.artifactDir ?? "",
|
|
460
|
+
autoArmed: true,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
// ponytail: re-render footer from snapshot without advancing.
|
|
464
|
+
if (sm.snapshot.active) {
|
|
465
|
+
ctx.ui.setStatus(
|
|
466
|
+
config.statusKey,
|
|
467
|
+
ctx.ui.theme.fg(
|
|
468
|
+
"warning",
|
|
469
|
+
`● ${config.footerLabel} · ${config.phases.indexOf(sm.snapshot.phase) + 1}/${config.phases.length} ${sm.snapshot.phase}`,
|
|
470
|
+
),
|
|
471
|
+
);
|
|
472
|
+
} else {
|
|
473
|
+
ctx.ui.setStatus(config.statusKey, undefined);
|
|
474
|
+
}
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// ── tool_call: enforce workflow boundaries. ──
|
|
481
|
+
pi.on("tool_call", (event: any, _ctx: ExtensionContext) => {
|
|
482
|
+
const tool = event.toolName;
|
|
483
|
+
if (tool !== "subagent" && tool !== "write" && tool !== "edit") return;
|
|
484
|
+
const snap = sm.snapshot;
|
|
485
|
+
if (!snap.active) return;
|
|
486
|
+
const file = config.phaseArtifacts[snap.phase];
|
|
487
|
+
if (tool === "write" || tool === "edit") {
|
|
488
|
+
return {
|
|
489
|
+
block: true,
|
|
490
|
+
reason: `Workflow is active (${mode}/${snap.phase}). Use ${WORKFLOW_ARTIFACT_TOOL} for workflow artifacts; workspace edits must be delegated to subagents.`,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
if (!file || !event.input || !FORCE_OUTPUT_PHASES.has(snap.phase)) return;
|
|
494
|
+
const onlyAgents = snap.phase === "audit" ? new Set(["commentator", "reviewer"]) : undefined;
|
|
495
|
+
forceSubagentOutputToArtifactDir(event.input, snap.artifactDir, file, onlyAgents);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// ── tool_result: auto-advance after subagent/bash artifacts. ──
|
|
499
|
+
pi.on("tool_result", async (event: any, ctx: ExtensionContext) => {
|
|
500
|
+
if (event.toolName !== "bash" && event.toolName !== "subagent") return;
|
|
501
|
+
// ponytail: if a subagent returned text but did not write the expected
|
|
502
|
+
// artifact (common with dumb/local models), save the returned text as
|
|
503
|
+
// the artifact so the workflow can still advance.
|
|
504
|
+
if (event.toolName === "subagent" && sm.snapshot.active && SUBAGENT_FALLBACK_PHASES.has(sm.snapshot.phase)) {
|
|
505
|
+
const file = config.phaseArtifacts[sm.snapshot.phase];
|
|
506
|
+
if (file) {
|
|
507
|
+
const expectedPath = resolve(sm.snapshot.artifactDir, file);
|
|
508
|
+
if (!(await realFileExists(expectedPath))) {
|
|
509
|
+
const text = textFromToolResultContent(event.content);
|
|
510
|
+
if (text) {
|
|
511
|
+
await mkdir(sm.snapshot.artifactDir, { recursive: true });
|
|
512
|
+
await writeFile(expectedPath, text, "utf8");
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const eff = await sm.onArtifactMaybe(deps);
|
|
518
|
+
applyEffect(pi, ctx, config, eff);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// ── /<command> ──
|
|
522
|
+
pi.registerCommand(options.commandName, {
|
|
523
|
+
description: options.commandDescription,
|
|
524
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
525
|
+
if (!ctx.isIdle()) {
|
|
526
|
+
ctx.ui.notify(
|
|
527
|
+
`Agent is busy. Wait for it to finish before calling /${options.commandName}.`,
|
|
528
|
+
"warning",
|
|
529
|
+
);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (sm.snapshot.active) {
|
|
534
|
+
let goal = args.trim();
|
|
535
|
+
const choice = ctx.hasUI
|
|
536
|
+
? await ctx.ui.select(
|
|
537
|
+
`Workflow already active (phase: ${sm.snapshot.phase}). What do you want to do?`,
|
|
538
|
+
[
|
|
539
|
+
`Continue current workflow at ${sm.snapshot.phase}`,
|
|
540
|
+
"Close current and start a new one",
|
|
541
|
+
],
|
|
542
|
+
)
|
|
543
|
+
: undefined;
|
|
544
|
+
if (choice === undefined) return;
|
|
545
|
+
if (choice.startsWith("Continue")) {
|
|
546
|
+
const eff = sm.continueCurrent();
|
|
547
|
+
applyEffect(pi, ctx, config, eff);
|
|
548
|
+
if (eff.kind === "advanced" && eff.prompt) {
|
|
549
|
+
// continueCurrent already applied footer+entry; send the prompt.
|
|
550
|
+
continueAgent(pi, ctx, eff.prompt);
|
|
551
|
+
}
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (!goal && ctx.hasUI) {
|
|
555
|
+
goal = (await ctx.ui.input("Goal for the new workflow:")) ?? "";
|
|
556
|
+
}
|
|
557
|
+
if (!goal.trim()) {
|
|
558
|
+
ctx.ui.notify(
|
|
559
|
+
"No goal provided. Keeping current workflow active.",
|
|
560
|
+
"warning",
|
|
561
|
+
);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const closed = sm.closeCurrent();
|
|
565
|
+
if (closed) {
|
|
566
|
+
applyEntry(pi, config, closed);
|
|
567
|
+
ctx.ui.notify(`Closed ${mode} workflow at ${closed.artifactDir}.`, "info");
|
|
568
|
+
}
|
|
569
|
+
args = goal;
|
|
570
|
+
} else if (!args.trim()) {
|
|
571
|
+
ctx.ui.notify(`Usage: /${options.commandName} <what to build>`, "warning");
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const other = activeControllersFor(pi).find((c) => c.sm !== sm);
|
|
576
|
+
if (other) {
|
|
577
|
+
ctx.ui.notify(
|
|
578
|
+
`${other.config.footerLabel} workflow already active at ${other.sm.snapshot.phase}. Close it before starting ${mode}.`,
|
|
579
|
+
"warning",
|
|
580
|
+
);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const eff = await sm.start(args, deps);
|
|
584
|
+
applyEffect(pi, ctx, config, eff);
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
};
|
|
588
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// ponytail: single pi extension that mounts every workflow mode. One load
|
|
2
|
+
// resolves a single writer tool + N start/end tool pairs. Avoids the per-mode
|
|
3
|
+
// load conflict (multiple extension modules would each try to register the
|
|
4
|
+
// shared writer).
|
|
5
|
+
|
|
6
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { __resetWorkflowRegistryForTests, createWorkflowExtension } from "./adapter.ts";
|
|
8
|
+
import { prototypeMode } from "./modes/prototype.ts";
|
|
9
|
+
import { quickMode } from "./modes/quick.ts";
|
|
10
|
+
|
|
11
|
+
export { __resetWorkflowRegistryForTests };
|
|
12
|
+
|
|
13
|
+
const MODES = [prototypeMode, quickMode] as const;
|
|
14
|
+
|
|
15
|
+
export default function workflowModesExtension(pi: ExtensionAPI): void {
|
|
16
|
+
for (const mode of MODES) {
|
|
17
|
+
createWorkflowExtension(mode.config, {
|
|
18
|
+
toolNames: mode.toolNames,
|
|
19
|
+
toolLabels: mode.toolLabels,
|
|
20
|
+
commandName: mode.commandName,
|
|
21
|
+
commandDescription: mode.commandDescription,
|
|
22
|
+
})(pi);
|
|
23
|
+
}
|
|
24
|
+
}
|