pi-agent-flow 1.2.6 → 1.3.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/README.md CHANGED
@@ -79,7 +79,7 @@ The result is faster, cheaper, and cleaner delegation: the main agent remains un
79
79
 
80
80
  | Flow | Purpose | Tools | Tier |
81
81
  |------|---------|-------|------|
82
- | `[scout]` | Discover files, trace code paths, map architecture | `batch_read`, `bash`, `find`, `grep`, `ls` | `lite` |
82
+ | `[scout]` | Discover files, trace code paths, map architecture | `batch`, `bash`, `find`, `grep`, `ls` | `lite` |
83
83
  | `[debug]` | Investigate logs, errors, stack traces, root causes | `batch`, `bash`, `find`, `grep`, `ls` | `lite` |
84
84
  | `[build]` | Implement features, fix bugs, write tests, ship | `batch`, `bash`, `find`, `grep`, `ls` | `flash` |
85
85
  | `[craft]` | Plan structure, break down requirements, design solutions | `batch`, `bash`, `find`, `grep`, `ls` | `full` |
package/agents/scout.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: scout
3
3
  description: Discover files, trace code paths, map architecture
4
- tools: batch_read, bash, find, grep, ls
4
+ tools: batch, bash, find, grep, ls
5
5
  maxDepth: 0
6
6
  ---
7
7
 
@@ -12,8 +12,8 @@ During this scout flow — your mission is to discover relevant context. Move fa
12
12
  ## Workflow
13
13
 
14
14
  1. Survey — use `ls`, `find`, and `grep` to locate relevant files and symbols before reading whole files.
15
- 2. Inspect — use `batch_read` with `o: "read"`, `s: <offset>`, and `l: <limit>` for targeted file reading instead of bash `sed`/`head`/`tail`.
16
- 3. If `batch_read` returns a context map for a large code/infra file, do not retry the full-file read; use the reported line ranges for targeted follow-up reads.
15
+ 2. Inspect — use `batch` with `o: "read"`, `s: <offset>`, and `l: <limit>` for targeted file reading instead of bash `sed`/`head`/`tail`.
16
+ 3. If `batch` returns a context map for a large code/infra file, do not retry the full-file read; use the reported line ranges for targeted follow-up reads.
17
17
  4. Trace — follow code paths, dependencies, configuration, and tests that explain the requested area.
18
18
  5. Report — cite concrete evidence and stop when the requested context is mapped.
19
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-flow",
3
- "version": "1.2.6",
3
+ "version": "1.3.0",
4
4
  "description": "Flow-state delegation extension for Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/agents.ts CHANGED
@@ -136,7 +136,22 @@ function parseFlowFile(filePath: string, source: "user" | "project" | "bundled")
136
136
 
137
137
  const name = typeof frontmatter.name === "string" ? frontmatter.name.trim().toLowerCase() : "";
138
138
  const description = typeof frontmatter.description === "string" ? frontmatter.description.trim() : "";
139
- if (!name || !description) return null;
139
+ if (!name || !description) {
140
+ if (!name) console.warn(`[pi-agent-flow] Skipping flow file "${filePath}": missing or empty 'name' field.`);
141
+ if (!description) console.warn(`[pi-agent-flow] Skipping flow file "${filePath}": missing or empty 'description' field.`);
142
+ return null;
143
+ }
144
+
145
+ // Warn about unknown frontmatter keys
146
+ const knownKeys = new Set([
147
+ "name", "description", "tools", "model", "thinking",
148
+ "maxDepth", "inheritContext", "tier",
149
+ ]);
150
+ for (const key of Object.keys(frontmatter)) {
151
+ if (!knownKeys.has(key)) {
152
+ console.warn(`[pi-agent-flow] Unknown frontmatter key "${key}" in "${filePath}". This field will be ignored.`);
153
+ }
154
+ }
140
155
 
141
156
  let tools: string[] | undefined;
142
157
  if (typeof frontmatter.tools === "string") {
@@ -11,6 +11,7 @@ export const MAX_BYTES = 50 * 1024; // 50KB
11
11
  export const SAFE_FULL_READ_LIMIT = 300;
12
12
  export const TARGETED_READ_LINE_LIMIT = 1000;
13
13
  export const MAX_CONTEXT_MAP_ENTRIES = 100;
14
+ export const MAX_TOTAL_RESULT_LINES = 1500;
14
15
 
15
16
  // ---------------------------------------------------------------------------
16
17
  // Types
@@ -16,6 +16,7 @@ import {
16
16
  MAX_BYTES,
17
17
  SAFE_FULL_READ_LIMIT,
18
18
  TARGETED_READ_LINE_LIMIT,
19
+ MAX_TOTAL_RESULT_LINES,
19
20
  } from "./constants.js";
20
21
  import {
21
22
  normalizeToLF,
@@ -212,6 +213,8 @@ export async function executeOperations(
212
213
  const counts = { read: 0, write: 0, edit: 0, delete: 0, error: 0, skipped: 0 };
213
214
  const errors: { path: string; op: string; message: string; hint?: string }[] = [];
214
215
  const truncatedFiles: { path: string; shown: number; total: number; nextOffset?: number }[] = [];
216
+ const aggregateLimitSkipped: { path: string }[] = [];
217
+ let aggregateLinesRead = 0;
215
218
  const includeLimitWarnings = options.includeLimitWarnings ?? true;
216
219
 
217
220
  for (const op of operations) {
@@ -232,6 +235,18 @@ export async function executeOperations(
232
235
 
233
236
  switch (op.o) {
234
237
  case "read": {
238
+ if (aggregateLinesRead >= MAX_TOTAL_RESULT_LINES) {
239
+ results.push({
240
+ op: "read",
241
+ path: op.p,
242
+ status: "skipped",
243
+ error: `Skipped: aggregate line limit of ${MAX_TOTAL_RESULT_LINES} already reached. Use separate batch/batch_read calls.`,
244
+ });
245
+ counts.skipped++;
246
+ aggregateLimitSkipped.push({ path: op.p });
247
+ break;
248
+ }
249
+
235
250
  // Access check before reading
236
251
  try {
237
252
  await fs.access(resolvedPath);
@@ -293,6 +308,8 @@ export async function executeOperations(
293
308
  });
294
309
  }
295
310
 
311
+ aggregateLinesRead += linesRead;
312
+
296
313
  results.push({
297
314
  op: "read",
298
315
  path: op.p,
@@ -404,7 +421,7 @@ export async function executeOperations(
404
421
  }
405
422
  }
406
423
  // Build the enhanced summary and content text
407
- const summary = buildSummary(counts, errors, truncatedFiles);
424
+ const summary = buildSummary(counts, errors, truncatedFiles, aggregateLimitSkipped);
408
425
  const contentText = buildContentText(summary, results);
409
426
 
410
427
  return { summary, contentText, results };
@@ -418,6 +435,7 @@ function buildSummary(
418
435
  counts: { read: number; write: number; edit: number; delete: number; error: number; skipped: number },
419
436
  errors: { path: string; op: string; message: string; hint?: string }[],
420
437
  truncatedFiles: { path: string; shown: number; total: number; nextOffset?: number }[],
438
+ aggregateLimitSkipped: { path: string }[] = [],
421
439
  ): string {
422
440
  const totalSuccess =
423
441
  counts.read + counts.write + counts.edit + counts.delete;
@@ -471,6 +489,13 @@ function buildSummary(
471
489
  }
472
490
  }
473
491
 
492
+ // Aggregate line limit warnings
493
+ if (aggregateLimitSkipped.length > 0) {
494
+ parts.push(
495
+ ` ⚠ Aggregate line limit (${MAX_TOTAL_RESULT_LINES}) reached — skipped ${aggregateLimitSkipped.length} read${aggregateLimitSkipped.length > 1 ? "s" : ""}: ${aggregateLimitSkipped.map((s) => s.path).join(", ")}`,
496
+ );
497
+ }
498
+
474
499
  return parts.join("\n");
475
500
  }
476
501
 
package/src/executor.ts CHANGED
@@ -139,6 +139,24 @@ async function confirmProjectFlowsIfNeeded(
139
139
  };
140
140
  }
141
141
 
142
+ // ---------------------------------------------------------------------------
143
+ // Cache limits
144
+ // ---------------------------------------------------------------------------
145
+
146
+ const FLOW_RESULT_CACHE_MAX_ENTRIES = 50;
147
+
148
+ /** Evict oldest entries from the cache when it exceeds the cap. */
149
+ function evictCacheOverflow(cache: Map<string, unknown>): void {
150
+ if (cache.size <= FLOW_RESULT_CACHE_MAX_ENTRIES) return;
151
+ const excess = cache.size - FLOW_RESULT_CACHE_MAX_ENTRIES;
152
+ const keys = cache.keys();
153
+ for (let i = 0; i < excess; i++) {
154
+ const next = keys.next();
155
+ if (next.done) break;
156
+ cache.delete(next.value);
157
+ }
158
+ }
159
+
142
160
  function shouldFailover(result: SingleResult): boolean {
143
161
  if (result.stopReason === "aborted") return false;
144
162
  const text = `${result.errorMessage ?? ""}\n${result.stderr ?? ""}`.toLowerCase();
@@ -354,6 +372,7 @@ export async function executeFlows(
354
372
  existing.push(compressed);
355
373
  flowResultCache.set(toolCallId, existing);
356
374
  }
375
+ evictCacheOverflow(flowResultCache);
357
376
 
358
377
  // Build tool result
359
378
  const successCount = results.filter((r) => isFlowSuccess(r)).length;
@@ -369,21 +388,21 @@ export async function executeFlows(
369
388
  ? "\n\n---\n\n💡 " + hookResult.advisors.join("\n💡 ")
370
389
  : "";
371
390
 
372
- // Auto-transition: collect qualifying transitions
391
+ // Auto-transition: collect qualifying transitions.
392
+ // No confidence threshold gating — non-deterministic agents should not
393
+ // have unstable numeric params controlling execution flow.
373
394
  const queuedTransitions: Array<{ type: string; intent: string }> = [];
374
395
  if (autoTransition && hookResult.autoTransitions.length > 0) {
375
396
  for (const transition of hookResult.autoTransitions) {
376
- if (transition.confidence >= 0.7) {
377
- const normalizedType = transition.type.toLowerCase();
378
- const flowExists = flows.some((f) => f.name === normalizedType);
379
- const notAlreadyRequested = !requested.has(normalizedType);
380
- const noCycles = !preventCycles || !ancestorFlowStack.includes(normalizedType);
381
- if (flowExists && notAlreadyRequested && noCycles) {
382
- queuedTransitions.push({
383
- type: transition.type,
384
- intent: transition.intent,
385
- });
386
- }
397
+ const normalizedType = transition.type.toLowerCase();
398
+ const flowExists = flows.some((f) => f.name === normalizedType);
399
+ const notAlreadyRequested = !requested.has(normalizedType);
400
+ const noCycles = !preventCycles || !ancestorFlowStack.includes(normalizedType);
401
+ if (flowExists && notAlreadyRequested && noCycles) {
402
+ queuedTransitions.push({
403
+ type: transition.type,
404
+ intent: transition.intent,
405
+ });
387
406
  }
388
407
  }
389
408
  }
package/src/flow.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  getFlowOutput,
21
21
  normalizeFlowResult,
22
22
  } from "./types.js";
23
- import { extractStructuredOutput } from "./structured-output.js";
23
+ import { extractStructuredOutput, enrichStructuredOutputCommands } from "./structured-output.js";
24
24
 
25
25
  const isWindows = process.platform === "win32";
26
26
  const SIGKILL_TIMEOUT_MS = 5000;
@@ -67,6 +67,12 @@ function mergeStreamingUsage(
67
67
  * work on Unix and Windows without going through a shell wrapper.
68
68
  */
69
69
  function resolveFlowSpawn(): { command: string; prefixArgs: string[] } {
70
+ // Support PI_FLOW_SPAWN_COMMAND env var override for exotic runtime
71
+ // environments (e.g. bundled with pkg/nexe where process.argv[1] is unreliable).
72
+ const envOverride = process.env["PI_FLOW_SPAWN_COMMAND"];
73
+ if (envOverride && envOverride.trim()) {
74
+ return { command: envOverride.trim(), prefixArgs: [] };
75
+ }
70
76
  const isNode = /[\\/]node(?:\.exe)?$/i.test(process.execPath);
71
77
  if (isNode && process.argv[1]) {
72
78
  return { command: process.execPath, prefixArgs: [process.argv[1]] };
@@ -221,6 +227,8 @@ function buildFlowArgs(
221
227
  `\n\n## Structured Output\n\n` +
222
228
  `End your response with a JSON code block containing:\n` +
223
229
  `\n` +
230
+ `For every command you ran, include the exact verbatim command string in the \\"command\\" field (not a paraphrase or summary).\n` +
231
+ `\n` +
224
232
  `\`\`\`json\n` +
225
233
  `{\n` +
226
234
  ` "version": "1.0",\n` +
@@ -233,7 +241,7 @@ function buildFlowArgs(
233
241
  ` { "type": "read", "description": "what was done", "target": "file.ts", "result": "success", "evidence": "output or proof" }\n` +
234
242
  ` ],\n` +
235
243
  ` "commands": [\n` +
236
- ` { "command": "npm test", "tool": "bash", "target": ".", "result": "success", "output": "12 passing, 2 failing", "purpose": "Run test suite to verify fix" }\n` +
244
+ ` { "command": "curl -s -X POST https://api.example.com/v1/data -H 'Authorization: Bearer token'", "tool": "bash" }\n` +
237
245
  ` ],\n` +
238
246
  ` "notDone": [\n` +
239
247
  ` { "item": "unfinished work", "reason": "why it was not completed", "blocker": "blocking issue if any", "nextStep": "specific follow-up" }\n` +
@@ -580,7 +588,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
580
588
  const flowText = getFlowOutput(normalized.messages);
581
589
  const extracted = extractStructuredOutput(flowText);
582
590
  if (extracted) {
583
- normalized.structuredOutput = extracted;
591
+ normalized.structuredOutput = enrichStructuredOutputCommands(extracted, normalized.messages);
584
592
  }
585
593
  }
586
594
 
package/src/hooks.ts CHANGED
@@ -28,10 +28,21 @@ export function registerHook(hook: PostFlowHook): void {
28
28
  else hooks.push(hook);
29
29
  }
30
30
 
31
+ /**
32
+ * Unregister a hook by name.
33
+ * Returns true if a hook was removed, false if no hook with that name existed.
34
+ */
35
+ export function unregisterHook(name: string): boolean {
36
+ const idx = hooks.findIndex((h) => h.name === name);
37
+ if (idx < 0) return false;
38
+ hooks.splice(idx, 1);
39
+ return true;
40
+ }
41
+
31
42
  export interface RunHooksResult {
32
43
  /** Advisory messages sorted by priority. */
33
44
  advisors: string[];
34
- /** Auto-transitions collected from hooks, sorted by confidence descending. */
45
+ /** Auto-transitions collected from hooks. */
35
46
  autoTransitions: AutoTransition[];
36
47
  }
37
48
 
@@ -49,6 +60,9 @@ export function runHooks(
49
60
 
50
61
  /**
51
62
  * Run all hooks and return both advisors and auto-transitions.
63
+ *
64
+ * Each hook action is wrapped in try/catch so one bad hook cannot crash
65
+ * the pipeline. Failures are reported as advisory warnings.
52
66
  */
53
67
  export function runHooksDetailed(
54
68
  params: Array<{ type: string; intent: string }>,
@@ -67,17 +81,24 @@ export function runHooksDetailed(
67
81
  if (matching.length === 0) continue;
68
82
  if (onlyOnSuccess && !matching.every((r) => isFlowSuccess(r))) continue;
69
83
 
70
- const result = hook.action({ results: matching, params });
71
- if (result) {
72
- messages.push({ priority: result.priority ?? 0, content: result.content });
73
- if (result.autoTransition) {
74
- transitions.push(result.autoTransition);
84
+ try {
85
+ const result = hook.action({ results: matching, params });
86
+ if (result) {
87
+ messages.push({ priority: result.priority ?? 0, content: result.content });
88
+ if (result.autoTransition) {
89
+ transitions.push(result.autoTransition);
90
+ }
75
91
  }
92
+ } catch (err) {
93
+ const errorMessage = err instanceof Error ? err.message : String(err);
94
+ messages.push({
95
+ priority: 999,
96
+ content: `Hook "${hook.name}" failed: ${errorMessage}`,
97
+ });
76
98
  }
77
99
  }
78
100
 
79
101
  messages.sort((a, b) => a.priority - b.priority);
80
- transitions.sort((a, b) => b.confidence - a.confidence);
81
102
  return {
82
103
  advisors: messages.map((m) => m.content),
83
104
  autoTransitions: transitions,
@@ -99,7 +120,7 @@ export function clearHooks(): void {
99
120
  }
100
121
 
101
122
  // ---------------------------------------------------------------------------
102
- // Built-in hooks
123
+ // Built-in hooks — success transitions
103
124
  // ---------------------------------------------------------------------------
104
125
 
105
126
  /** Suggest audit flow after a successful build flow. */
@@ -156,10 +177,6 @@ registerHook({
156
177
  },
157
178
  });
158
179
 
159
- // ---------------------------------------------------------------------------
160
- // Extended transition hooks
161
- // ---------------------------------------------------------------------------
162
-
163
180
  /** Suggest build or debug flow after a successful scout flow. */
164
181
  registerHook({
165
182
  name: "pi-agent-flow/scout-to-build",
@@ -231,3 +248,47 @@ registerHook({
231
248
  };
232
249
  },
233
250
  });
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Built-in hooks — failure transitions
254
+ // ---------------------------------------------------------------------------
255
+
256
+ /** Suggest debug flow after a failed build flow. */
257
+ registerHook({
258
+ name: "pi-agent-flow/build-to-debug-on-failure",
259
+ trigger: { flowTypes: ["build"], onlyOnSuccess: false },
260
+ action: (ctx) => {
261
+ const allSucceeded = ctx.results.every((r) => isFlowSuccess(r));
262
+ if (allSucceeded) return null;
263
+ const debugWasRequested = ctx.params.some(
264
+ (p) => p.type.toLowerCase() === "debug",
265
+ );
266
+ if (debugWasRequested) return null;
267
+
268
+ return {
269
+ content:
270
+ "Build failed. Consider running a [debug] flow to investigate the root cause.",
271
+ priority: 10,
272
+ };
273
+ },
274
+ });
275
+
276
+ /** Suggest build flow after a failed audit flow (found issues). */
277
+ registerHook({
278
+ name: "pi-agent-flow/audit-to-build-on-failure",
279
+ trigger: { flowTypes: ["audit"], onlyOnSuccess: false },
280
+ action: (ctx) => {
281
+ const allSucceeded = ctx.results.every((r) => isFlowSuccess(r));
282
+ if (allSucceeded) return null;
283
+ const buildWasRequested = ctx.params.some(
284
+ (p) => p.type.toLowerCase() === "build",
285
+ );
286
+ if (buildWasRequested) return null;
287
+
288
+ return {
289
+ content:
290
+ "Audit found issues. Consider running a [build] flow to fix them.",
291
+ priority: 10,
292
+ };
293
+ },
294
+ });
package/src/index.ts CHANGED
@@ -5,7 +5,6 @@
5
5
  * Each flow receives a forked snapshot of the current session context.
6
6
  */
7
7
 
8
- import { randomUUID } from "node:crypto";
9
8
  import * as os from "node:os";
10
9
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
11
10
  import { Type } from "@sinclair/typebox";
@@ -20,7 +19,7 @@ import {
20
19
  import { getInheritedCliArgs } from "./cli-args.js";
21
20
  import { renderFlowCall, renderFlowResult } from "./render.js";
22
21
  import { getFlowSummaryText } from "./runner-events.js";
23
- import { runHooks, runHooksDetailed, getRegisteredHooks, registerHook } from "./hooks.js";
22
+ import { runHooks, runHooksDetailed, getRegisteredHooks, registerHook, unregisterHook } from "./hooks.js";
24
23
  import { mapFlowConcurrent, runFlow } from "./flow.js";
25
24
  import { executeFlows } from "./executor.js";
26
25
  import {
@@ -31,6 +30,7 @@ import {
31
30
  type FileEntry,
32
31
  type CommandEntry,
33
32
  type AutoTransition,
33
+ type PiAgentFlowAPI,
34
34
  emptyFlowUsage,
35
35
  isFlowError,
36
36
  isFlowSuccess,
@@ -43,6 +43,17 @@ import {
43
43
  looksLikeUrlPrompt,
44
44
  looksLikeWebSearchPrompt,
45
45
  } from "./web-tool.js";
46
+ import {
47
+ SLIDING_PROMPT,
48
+ SLIDING_PROMPT_OPEN_TAG,
49
+ stripSlidingPromptText,
50
+ stripSlidingPromptFromContent,
51
+ contentContainsSlidingTag,
52
+ isJsonEqual,
53
+ stripSlidingPromptsFromMessages,
54
+ makeSlidingPromptMessage,
55
+ } from "./sliding-prompt.js";
56
+ import { DEFAULT_TRANSITIONS, buildTransitionHooks } from "./transitions.js";
46
57
  import { createTimedBashToolDefinition } from "./timed-bash.js";
47
58
 
48
59
  // ---------------------------------------------------------------------------
@@ -346,110 +357,7 @@ async function confirmProjectFlowsIfNeeded(
346
357
  // before the latest user message by the context handler.
347
358
  // ---------------------------------------------------------------------------
348
359
 
349
- const SLIDING_PROMPT_UUID = randomUUID();
350
- const SLIDING_PROMPT_OPEN_TAG = `<pi-flow-sliding-system id="${SLIDING_PROMPT_UUID}">`;
351
- const SLIDING_PROMPT_CLOSE_TAG = `</pi-flow-sliding-system id="${SLIDING_PROMPT_UUID}">`;
352
-
353
- const SLIDING_PROMPT =
354
- `${SLIDING_PROMPT_OPEN_TAG}\n` +
355
- `You are operating with pi-agent-flow routing.\n` +
356
- `If the answer is already in context, answer directly; otherwise delegate to the appropriate flow.\n` +
357
- `For git, bash, CLI, or terminal tasks, delegate to [build].\n` +
358
- `${SLIDING_PROMPT_CLOSE_TAG}`;
359
-
360
- const SLIDING_PROMPT_RE = new RegExp(
361
- SLIDING_PROMPT_OPEN_TAG.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +
362
- "[\\s\\S]*?" +
363
- SLIDING_PROMPT_CLOSE_TAG.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
364
- "g",
365
- );
366
-
367
- /** Legacy regex to strip old bare sliding system prompt tags (no id attribute). */
368
- const LEGACY_SLIDING_PROMPT_RE = /<pi-flow-sliding-system(?:\s[^>]*)?>[\s\S]*?<\/pi-flow-sliding-system(?:\s[^>]*)?>/g;
369
-
370
- /** Strip any old sliding system prompt tags from a string. */
371
- function stripSlidingPromptText(text: string): string {
372
- return text.replace(SLIDING_PROMPT_RE, "").replace(LEGACY_SLIDING_PROMPT_RE, "");
373
- }
374
-
375
- /** Strip sliding prompt tags from content (string or text-part array). */
376
- function stripSlidingPromptFromContent(
377
- content: string | { type: string; text?: string }[],
378
- ): string | { type: string; text?: string }[] {
379
- if (typeof content === "string") {
380
- return stripSlidingPromptText(content);
381
- }
382
- return content.map((c) => {
383
- if (c.type === "text" && typeof c.text === "string") {
384
- return { ...c, text: stripSlidingPromptText(c.text) };
385
- }
386
- return c;
387
- });
388
- }
389
-
390
- /** Check whether content (string or text-part array) contains the sliding tag. */
391
- /** Input-message content types (string or multipart text-part array). */
392
- type MessageContent = string | Array<{ type: string; text?: string }>;
393
-
394
- /** Type guard: is this a text-part with a string .text? */
395
- function isTextPart(part: unknown): part is { type: "text"; text: string } {
396
- return (
397
- part != null &&
398
- typeof part === "object" &&
399
- "type" in part &&
400
- part.type === "text" &&
401
- "text" in part &&
402
- typeof (part as { text?: unknown }).text === "string"
403
- );
404
- }
405
-
406
- /** Check whether content (string or text-part array) contains the sliding tag. */
407
- function contentContainsSlidingTag(content: MessageContent): boolean {
408
- const hasCurrent = (text: string) => text.includes(SLIDING_PROMPT_OPEN_TAG);
409
- const hasLegacy = (text: string) => text.includes("<pi-flow-sliding-system>");
410
- const check = (text: string) => hasCurrent(text) || hasLegacy(text);
411
- if (typeof content === "string") {
412
- return check(content);
413
- }
414
- if (Array.isArray(content)) {
415
- return content.some((part) => isTextPart(part) && check(part.text));
416
- }
417
- return false;
418
- }
419
-
420
- /** Remove any existing sliding-system-prompt system messages and strip tags from user messages.
421
- * Returns the sanitized messages and a flag indicating whether anything changed.
422
- */
423
- function stripSlidingPromptsFromMessages(messages: any[]): { messages: any[]; changed: boolean } {
424
- let changed = false;
425
- const result = messages
426
- .filter((msg) => {
427
- // Remove dedicated sliding system prompt messages
428
- if (msg.role === "system" && contentContainsSlidingTag(msg.content)) {
429
- changed = true;
430
- return false;
431
- }
432
- return true;
433
- })
434
- .map((msg) => {
435
- // Also strip stray tags embedded in user/assistant messages
436
- if (!("content" in msg)) return msg;
437
- const stripped = stripSlidingPromptFromContent(msg.content);
438
- if (isJsonEqual(stripped, msg.content)) return msg;
439
- changed = true;
440
- return { ...msg, content: stripped };
441
- });
442
- return { messages: result, changed };
443
- }
444
-
445
- /** Build a system message containing the sliding prompt. */
446
- function makeSlidingPromptMessage(referenceMessage?: any): any {
447
- return {
448
- role: "system",
449
- content: SLIDING_PROMPT,
450
- timestamp: referenceMessage?.timestamp,
451
- };
452
- }
360
+ // SLIDING_PROMPT, SLIDING_PROMPT_OPEN_TAG imported from ./sliding-prompt.js
453
361
 
454
362
  const REASONING_PART_TYPES = new Set([
455
363
  "thinking",
@@ -498,22 +406,6 @@ function stripReasoningFromAssistantMessage(message: any): {
498
406
  return { message: next, changed };
499
407
  }
500
408
 
501
- /** Deep-equality check that handles unordered object keys (unlike JSON.stringify). */
502
- function isJsonEqual(a: unknown, b: unknown): boolean {
503
- if (Object.is(a, b)) return true;
504
- if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
505
- if (Array.isArray(a) !== Array.isArray(b)) return false;
506
-
507
- const keysA = Object.keys(a as Record<string, unknown>);
508
- const keysB = Object.keys(b as Record<string, unknown>);
509
- if (keysA.length !== keysB.length) return false;
510
-
511
- for (const key of keysA) {
512
- if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
513
- if (!isJsonEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) return false;
514
- }
515
- return true;
516
- }
517
409
  // ---------------------------------------------------------------------------
518
410
  // Flow result compression
519
411
  // ---------------------------------------------------------------------------
@@ -556,11 +448,7 @@ function renderCompressedFlowResult(r: CompressedFlowResult): string {
556
448
  parts.push(`Files:\n${fileLines.join("\n")}`);
557
449
  }
558
450
  if (r.commands?.length) {
559
- const cmdLines = r.commands.map((c) => {
560
- const result = c.result ? ` (${c.result})` : "";
561
- const purpose = c.purpose ? ` — ${c.purpose}` : "";
562
- return ` ${c.tool ?? "cmd"}: ${c.command}${result}${purpose}`;
563
- });
451
+ const cmdLines = r.commands.map((c) => ` ${c.tool ?? "cmd"}: ${c.command}`);
564
452
  parts.push(`Commands:\n${cmdLines.join("\n")}`);
565
453
  }
566
454
  if (r.error) parts.push(`Error: ${r.error}`);
@@ -800,6 +688,11 @@ export default function (pi: ExtensionAPI) {
800
688
  discoveredFlows = discovery.flows;
801
689
  loadedFlowModelConfigs = loadFlowModelConfigs(ctx.cwd);
802
690
 
691
+ // Register declarative transition hooks from the transition matrix
692
+ for (const hook of buildTransitionHooks(DEFAULT_TRANSITIONS)) {
693
+ registerHook(hook);
694
+ }
695
+
803
696
  // Resolve toolOptimize: CLI flag > env var > settings.json > default
804
697
  const cliFlag = pi.getFlag("tool-optimize");
805
698
  if (typeof cliFlag === "boolean") {
@@ -1097,14 +990,31 @@ flow [type] accomplished
1097
990
  // -------------------------------------------------------------------------
1098
991
 
1099
992
  // Emit a ready event with the API surface so external plugins can extend.
993
+ // Emit a typed plugin API surface
994
+ const pluginApi: PiAgentFlowAPI = {
995
+ registerHook,
996
+ unregisterHook,
997
+ getRegisteredHooks,
998
+ discoverFlows: (cwd: string) => discoverFlows(cwd, "all"),
999
+ getFlowTier: (name: string) => getFlowTier(name),
1000
+ getSettings: () => ({ toolOptimize, structuredOutput, maxConcurrency, autoTransition }),
1001
+ };
1002
+
1100
1003
  if (typeof pi.emit === "function") {
1101
- pi.emit("pi-agent-flow:ready", {
1102
- registerHook,
1103
- getRegisteredHooks,
1104
- discoverFlows: (cwd: string) => discoverFlows(cwd, "all"),
1105
- getFlowTier: (name: string) => getFlowTier(name),
1106
- getSettings: () => ({ toolOptimize, structuredOutput, maxConcurrency, autoTransition }),
1107
- });
1004
+ pi.emit("pi-agent-flow:ready", pluginApi);
1005
+ }
1006
+
1007
+ // Register cleanup on process exit (once)
1008
+ if (!(globalThis as any).__pi_agent_flow_shutdown_registered) {
1009
+ (globalThis as any).__pi_agent_flow_shutdown_registered = true;
1010
+ const emitShutdown = () => {
1011
+ if (typeof pi.emit === "function") {
1012
+ pi.emit("pi-agent-flow:shutdown", { reason: "process-exit" });
1013
+ }
1014
+ };
1015
+ process.on("exit", emitShutdown);
1016
+ process.on("SIGINT", () => { emitShutdown(); process.exit(130); });
1017
+ process.on("SIGTERM", () => { emitShutdown(); process.exit(143); });
1108
1018
  }
1109
1019
 
1110
1020
  }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Sliding system prompt utilities.
3
+ *
4
+ * Manages the sliding prompt tag that is injected before the latest user
5
+ * message each turn. Provides helpers for stripping, detecting, and building
6
+ * the prompt so they can be tested independently.
7
+ */
8
+
9
+ import { randomUUID } from "node:crypto";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Tag constants
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const SLIDING_PROMPT_UUID = randomUUID();
16
+
17
+ export const SLIDING_PROMPT_OPEN_TAG = `<pi-flow-sliding-system id="${SLIDING_PROMPT_UUID}">`;
18
+ export const SLIDING_PROMPT_CLOSE_TAG = `</pi-flow-sliding-system id="${SLIDING_PROMPT_UUID}">`;
19
+
20
+ export const SLIDING_PROMPT =
21
+ `${SLIDING_PROMPT_OPEN_TAG}\n` +
22
+ `You are operating with pi-agent-flow routing.\n` +
23
+ `If the answer is already in context, answer directly; otherwise delegate to the appropriate flow.\n` +
24
+ `For git, bash, CLI, or terminal tasks, delegate to [build].\n` +
25
+ `${SLIDING_PROMPT_CLOSE_TAG}`;
26
+
27
+ const SLIDING_PROMPT_RE = new RegExp(
28
+ SLIDING_PROMPT_OPEN_TAG.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +
29
+ "[\\s\\S]*?" +
30
+ SLIDING_PROMPT_CLOSE_TAG.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
31
+ "g",
32
+ );
33
+
34
+ /** Legacy regex to strip old bare sliding system prompt tags (no id attribute). */
35
+ const LEGACY_SLIDING_PROMPT_RE = /<pi-flow-sliding-system(?:\s[^>]*)?>[\s\S]*?<\/pi-flow-sliding-system(?:\s[^>]*)?>/g;
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Content types
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /** Input-message content types (string or multipart text-part array). */
42
+ type MessageContent = string | Array<{ type: string; text?: string }>;
43
+
44
+ /** Type guard: is this a text-part with a string .text? */
45
+ function isTextPart(part: unknown): part is { type: "text"; text: string } {
46
+ return (
47
+ part != null &&
48
+ typeof part === "object" &&
49
+ "type" in part &&
50
+ part.type === "text" &&
51
+ "text" in part &&
52
+ typeof (part as { text?: unknown }).text === "string"
53
+ );
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Public helpers
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /** Strip any old sliding system prompt tags from a string. */
61
+ export function stripSlidingPromptText(text: string): string {
62
+ return text.replace(SLIDING_PROMPT_RE, "").replace(LEGACY_SLIDING_PROMPT_RE, "");
63
+ }
64
+
65
+ /** Strip sliding prompt tags from content (string or text-part array). */
66
+ export function stripSlidingPromptFromContent(
67
+ content: string | { type: string; text?: string }[],
68
+ ): string | { type: string; text?: string }[] {
69
+ if (typeof content === "string") {
70
+ return stripSlidingPromptText(content);
71
+ }
72
+ return content.map((c) => {
73
+ if (c.type === "text" && typeof c.text === "string") {
74
+ return { ...c, text: stripSlidingPromptText(c.text) };
75
+ }
76
+ return c;
77
+ });
78
+ }
79
+
80
+ /** Check whether content (string or text-part array) contains the sliding tag. */
81
+ export function contentContainsSlidingTag(content: MessageContent): boolean {
82
+ const hasCurrent = (text: string) => text.includes(SLIDING_PROMPT_OPEN_TAG);
83
+ const hasLegacy = (text: string) => text.includes("<pi-flow-sliding-system>");
84
+ const check = (text: string) => hasCurrent(text) || hasLegacy(text);
85
+ if (typeof content === "string") {
86
+ return check(content);
87
+ }
88
+ if (Array.isArray(content)) {
89
+ return content.some((part) => isTextPart(part) && check(part.text));
90
+ }
91
+ return false;
92
+ }
93
+
94
+ /** Deep-equality check that handles unordered object keys (unlike JSON.stringify). */
95
+ export function isJsonEqual(a: unknown, b: unknown): boolean {
96
+ if (Object.is(a, b)) return true;
97
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
98
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
99
+
100
+ const keysA = Object.keys(a as Record<string, unknown>);
101
+ const keysB = Object.keys(b as Record<string, unknown>);
102
+ if (keysA.length !== keysB.length) return false;
103
+
104
+ for (const key of keysA) {
105
+ if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
106
+ if (!isJsonEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) return false;
107
+ }
108
+ return true;
109
+ }
110
+
111
+ /** Remove any existing sliding-system-prompt system messages and strip tags from user messages.
112
+ * Returns the sanitized messages and a flag indicating whether anything changed.
113
+ */
114
+ export function stripSlidingPromptsFromMessages(messages: any[]): { messages: any[]; changed: boolean } {
115
+ let changed = false;
116
+ const result = messages
117
+ .filter((msg) => {
118
+ // Remove dedicated sliding system prompt messages
119
+ if (msg.role === "system" && contentContainsSlidingTag(msg.content)) {
120
+ changed = true;
121
+ return false;
122
+ }
123
+ return true;
124
+ })
125
+ .map((msg) => {
126
+ // Also strip stray tags embedded in user/assistant messages
127
+ if (!("content" in msg)) return msg;
128
+ const stripped = stripSlidingPromptFromContent(msg.content);
129
+ if (isJsonEqual(stripped, msg.content)) return msg;
130
+ changed = true;
131
+ return { ...msg, content: stripped };
132
+ });
133
+ return { messages: result, changed };
134
+ }
135
+
136
+ /** Build a system message containing the sliding prompt. */
137
+ export function makeSlidingPromptMessage(referenceMessage?: any): any {
138
+ return {
139
+ role: "system",
140
+ content: SLIDING_PROMPT,
141
+ timestamp: referenceMessage?.timestamp,
142
+ };
143
+ }
@@ -5,6 +5,7 @@
5
5
  * and validates it against the FlowStructuredOutput schema.
6
6
  */
7
7
 
8
+ import type { Message } from "@mariozechner/pi-ai";
8
9
  import type { Action, CommandEntry, FileEntry, FlowStructuredOutput, NotDoneItem } from "./types.js";
9
10
 
10
11
  type FlowStatus = FlowStructuredOutput["status"];
@@ -95,3 +96,64 @@ export function extractStructuredOutput(text: string): FlowStructuredOutput | un
95
96
  ...(parsed.extensions !== undefined ? { extensions: parsed.extensions } : {}),
96
97
  };
97
98
  }
99
+
100
+ /**
101
+ * Walk the message history and mechanically replace paraphrased bash commands
102
+ * in a structured-output `commands` array with the exact verbatim strings from
103
+ * the actual tool calls. This fixes the common LLM behaviour of summarising
104
+ * `curl -s -X POST …` as `"curl GAWA baseline"`.
105
+ *
106
+ * Batch operations are flattened so that bash commands nested inside a batch
107
+ * call are included in the same chronological order they were executed.
108
+ */
109
+ export function enrichStructuredOutputCommands(
110
+ structuredOutput: FlowStructuredOutput,
111
+ messages: Message[],
112
+ ): FlowStructuredOutput {
113
+ // Collect actual verbatim bash commands (including those inside batch ops)
114
+ const actualBashCommands: string[] = [];
115
+ for (const msg of messages) {
116
+ if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
117
+ for (const part of msg.content) {
118
+ if (part.type !== "toolCall") continue;
119
+ const name =
120
+ part.name ||
121
+ (part as unknown as { toolName?: string }).toolName ||
122
+ "";
123
+ const args = part.arguments || part.input || {};
124
+
125
+ if (name === "bash") {
126
+ const cmd = (args.command as string) || "";
127
+ if (cmd) actualBashCommands.push(cmd);
128
+ } else if (name === "batch") {
129
+ const ops = Array.isArray(args.o)
130
+ ? args.o
131
+ : Array.isArray(args.op)
132
+ ? args.op
133
+ : Array.isArray(args.operations)
134
+ ? args.operations
135
+ : [];
136
+ for (const op of ops) {
137
+ if (!op) continue;
138
+ const opType = (op.o ?? op.op) as string;
139
+ if (opType === "bash" && op.command) {
140
+ actualBashCommands.push(op.command as string);
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ if (actualBashCommands.length === 0) return structuredOutput;
148
+
149
+ // Replace paraphrased bash commands with actual verbatim ones, in order.
150
+ let bashIdx = 0;
151
+ const enrichedCommands = structuredOutput.commands.map((cmd) => {
152
+ if (cmd.tool === "bash" && bashIdx < actualBashCommands.length) {
153
+ return { command: actualBashCommands[bashIdx++], tool: "bash" };
154
+ }
155
+ return { command: cmd.command, tool: cmd.tool };
156
+ });
157
+
158
+ return { ...structuredOutput, commands: enrichedCommands };
159
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Declarative transition matrix for post-flow routing.
3
+ *
4
+ * Instead of imperative hooks for common flow paths, this module defines a
5
+ * data-driven transition map. Each entry describes a recommended follow-up
6
+ * flow given a source flow's outcome. The matrix is user-overridable via
7
+ * settings.json.
8
+ *
9
+ * NOTE: There is no confidence threshold gating. Non-deterministic agents
10
+ * should not have unstable numeric params controlling execution flow.
11
+ * All matching transitions are recommended; the caller decides whether to
12
+ * auto-queue them.
13
+ */
14
+
15
+ import { type PostFlowHook, type AutoTransition, isFlowSuccess } from "./types.js";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Transition descriptor
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export interface FlowTransition {
22
+ /** Source flow name (case-insensitive). */
23
+ from: string;
24
+ /** Target flow name (case-insensitive). */
25
+ to: string;
26
+ /** Condition for this transition. */
27
+ on: "success" | "failure" | "always";
28
+ /** Advisory message shown to the user. */
29
+ advice: string;
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Default transition matrix
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export const DEFAULT_TRANSITIONS: FlowTransition[] = [
37
+ { from: "scout", to: "build", on: "success", advice: "Context mapped. Consider running a [build] flow to implement changes, or [debug] if investigating an issue." },
38
+ { from: "scout", to: "debug", on: "success", advice: "Context mapped. Consider running a [debug] flow if investigating an issue." },
39
+ { from: "debug", to: "build", on: "success", advice: "The root cause has been identified. Consider running a [build] flow to implement the fix." },
40
+ { from: "debug", to: "audit", on: "success", advice: "Root cause identified. Consider running an [audit] flow to verify the fix area for related issues." },
41
+ { from: "build", to: "audit", on: "success", advice: "Consider running a [audit] flow to audit the changes for security, correctness, and code quality." },
42
+ { from: "build", to: "debug", on: "failure", advice: "Build failed. Consider running a [debug] flow to investigate the root cause." },
43
+ { from: "audit", to: "scout", on: "success", advice: "Audit complete. Consider running a [scout] flow to trace the audit findings across the codebase." },
44
+ { from: "audit", to: "build", on: "failure", advice: "Audit found issues. Consider running a [build] flow to fix them." },
45
+ { from: "craft", to: "build", on: "success", advice: "Plan ready. Consider running a [build] flow to implement the design." },
46
+ { from: "ideas", to: "craft", on: "success", advice: "Ideas explored. Consider running a [craft] flow to design the approach, or [build] to implement directly." },
47
+ ];
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Hook generation
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Convert the transition matrix into PostFlowHook instances.
55
+ *
56
+ * Each transition becomes a hook that checks:
57
+ * 1. The source flow type matches
58
+ * 2. The target flow was not already requested
59
+ * 3. The outcome matches the `on` condition
60
+ */
61
+ export function buildTransitionHooks(transitions: FlowTransition[]): PostFlowHook[] {
62
+ return transitions.map((t) => ({
63
+ name: `pi-agent-flow/${t.from}-to-${t.to}-${t.on}`,
64
+ trigger: {
65
+ flowTypes: [t.from],
66
+ onlyOnSuccess: t.on === "success",
67
+ },
68
+ action: (ctx) => {
69
+ // Respect the on condition: success hooks only fire when all results
70
+ // succeeded; failure hooks only fire when at least one result failed.
71
+ const allSucceeded = ctx.results.every((r) => isFlowSuccess(r));
72
+ if (t.on === "success" && !allSucceeded) return null;
73
+ if (t.on === "failure" && allSucceeded) return null;
74
+
75
+ const alreadyRequested = ctx.params.some(
76
+ (p) => p.type.toLowerCase() === t.to,
77
+ );
78
+ if (alreadyRequested) return null;
79
+
80
+ return {
81
+ content: t.advice,
82
+ priority: 10,
83
+ };
84
+ },
85
+ }));
86
+ }
package/src/types.ts CHANGED
@@ -39,18 +39,10 @@ export interface FileEntry {
39
39
 
40
40
  /** Structured command/tool invocation entry in a flow's output. */
41
41
  export interface CommandEntry {
42
- /** The command string or tool call representation. */
42
+ /** The exact verbatim command string or tool call that was executed. */
43
43
  command: string;
44
44
  /** Tool used: bash, grep, find, ls, batch, read, write, edit, flow, web. */
45
45
  tool?: string;
46
- /** Working directory or target scope (file path, URL, etc.). */
47
- target?: string;
48
- /** Whether it succeeded, failed, or was aborted. */
49
- result?: "success" | "failure" | "partial" | "aborted";
50
- /** High-level output or error excerpt (not full stdout). */
51
- output?: string;
52
- /** Why this command was run (1 sentence). */
53
- purpose?: string;
54
46
  }
55
47
 
56
48
  /** Compressed representation of a flow result for child context inheritance. */
@@ -365,6 +357,24 @@ export interface AutoTransition {
365
357
  type: string;
366
358
  /** Intent for the follow-up flow. */
367
359
  intent: string;
368
- /** Confidence score (0-1). Higher means more certain. */
369
- confidence: number;
370
360
  }
361
+ // ---------------------------------------------------------------------------
362
+ // Plugin API types
363
+ // ---------------------------------------------------------------------------
364
+
365
+ /** Public API surface exposed via the pi-agent-flow:ready event. */
366
+ export interface PiAgentFlowAPI {
367
+ /** Register or replace a post-flow hook. */
368
+ registerHook: (hook: PostFlowHook) => void;
369
+ /** Unregister a hook by name. Returns true if removed. */
370
+ unregisterHook: (name: string) => boolean;
371
+ /** Get a snapshot of all registered hooks. */
372
+ getRegisteredHooks: () => PostFlowHook[];
373
+ /** Discover all available flows for a given working directory. */
374
+ discoverFlows: (cwd: string) => { flows: Array<{ name: string; description: string; source: string }>; projectFlowsDir: string | null };
375
+ /** Determine the model tier for a given flow name. */
376
+ getFlowTier: (name: string) => string;
377
+ /** Get current flow settings. */
378
+ getSettings: () => { toolOptimize: boolean; structuredOutput: boolean; maxConcurrency: number; autoTransition: boolean };
379
+ }
380
+