pi-agent-flow 1.2.5 → 1.2.7

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.5",
3
+ "version": "1.2.7",
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") {
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
@@ -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]] };
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
  // ---------------------------------------------------------------------------
@@ -800,6 +692,11 @@ export default function (pi: ExtensionAPI) {
800
692
  discoveredFlows = discovery.flows;
801
693
  loadedFlowModelConfigs = loadFlowModelConfigs(ctx.cwd);
802
694
 
695
+ // Register declarative transition hooks from the transition matrix
696
+ for (const hook of buildTransitionHooks(DEFAULT_TRANSITIONS)) {
697
+ registerHook(hook);
698
+ }
699
+
803
700
  // Resolve toolOptimize: CLI flag > env var > settings.json > default
804
701
  const cliFlag = pi.getFlag("tool-optimize");
805
702
  if (typeof cliFlag === "boolean") {
@@ -1072,7 +969,7 @@ flow [type] accomplished
1072
969
  sessionManager: ctx.sessionManager,
1073
970
  hasUI: ctx.hasUI,
1074
971
  uiConfirm: (title, body) => ctx.ui.confirm(title, body),
1075
- onFlowMetrics: (metrics) => pi.emit("pi-agent-flow:complete", metrics),
972
+ onFlowMetrics: (metrics) => { if (typeof pi.emit === "function") pi.emit("pi-agent-flow:complete", metrics); },
1076
973
  confirmProjectFlows: params.confirmProjectFlows,
1077
974
  },
1078
975
  params.flow.map((f: any) => ({ type: f.type, intent: f.intent, aim: f.aim, cwd: f.cwd })),
@@ -1097,14 +994,31 @@ flow [type] accomplished
1097
994
  // -------------------------------------------------------------------------
1098
995
 
1099
996
  // Emit a ready event with the API surface so external plugins can extend.
997
+ // Emit a typed plugin API surface
998
+ const pluginApi: PiAgentFlowAPI = {
999
+ registerHook,
1000
+ unregisterHook,
1001
+ getRegisteredHooks,
1002
+ discoverFlows: (cwd: string) => discoverFlows(cwd, "all"),
1003
+ getFlowTier: (name: string) => getFlowTier(name),
1004
+ getSettings: () => ({ toolOptimize, structuredOutput, maxConcurrency, autoTransition }),
1005
+ };
1006
+
1100
1007
  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
- });
1008
+ pi.emit("pi-agent-flow:ready", pluginApi);
1009
+ }
1010
+
1011
+ // Register cleanup on process exit (once)
1012
+ if (!(globalThis as any).__pi_agent_flow_shutdown_registered) {
1013
+ (globalThis as any).__pi_agent_flow_shutdown_registered = true;
1014
+ const emitShutdown = () => {
1015
+ if (typeof pi.emit === "function") {
1016
+ pi.emit("pi-agent-flow:shutdown", { reason: "process-exit" });
1017
+ }
1018
+ };
1019
+ process.on("exit", emitShutdown);
1020
+ process.on("SIGINT", () => { emitShutdown(); process.exit(130); });
1021
+ process.on("SIGTERM", () => { emitShutdown(); process.exit(143); });
1108
1022
  }
1109
1023
 
1110
1024
  }
@@ -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
+ }
@@ -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
@@ -365,6 +365,24 @@ export interface AutoTransition {
365
365
  type: string;
366
366
  /** Intent for the follow-up flow. */
367
367
  intent: string;
368
- /** Confidence score (0-1). Higher means more certain. */
369
- confidence: number;
370
368
  }
369
+ // ---------------------------------------------------------------------------
370
+ // Plugin API types
371
+ // ---------------------------------------------------------------------------
372
+
373
+ /** Public API surface exposed via the pi-agent-flow:ready event. */
374
+ export interface PiAgentFlowAPI {
375
+ /** Register or replace a post-flow hook. */
376
+ registerHook: (hook: PostFlowHook) => void;
377
+ /** Unregister a hook by name. Returns true if removed. */
378
+ unregisterHook: (name: string) => boolean;
379
+ /** Get a snapshot of all registered hooks. */
380
+ getRegisteredHooks: () => PostFlowHook[];
381
+ /** Discover all available flows for a given working directory. */
382
+ discoverFlows: (cwd: string) => { flows: Array<{ name: string; description: string; source: string }>; projectFlowsDir: string | null };
383
+ /** Determine the model tier for a given flow name. */
384
+ getFlowTier: (name: string) => string;
385
+ /** Get current flow settings. */
386
+ getSettings: () => { toolOptimize: boolean; structuredOutput: boolean; maxConcurrency: number; autoTransition: boolean };
387
+ }
388
+