pi-agent-flow 0.4.5 → 1.0.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
@@ -49,6 +49,8 @@ pi install .
49
49
  - **Cycle prevention** — blocks recursive delegation chains
50
50
  - **Flow discovery** — reads definitions from `~/.pi/agent/agents/` and `.pi/agents/`
51
51
  - **TUI rendering** — rich collapsed/expanded display in interactive mode
52
+ - **Post-flow hooks** — automatic advisory messages after successful flows (e.g., code → review)
53
+ - **Smooth streaming metrics** — context token counters increment tick-by-tick during active streaming instead of jumping at boundaries
52
54
 
53
55
  ---
54
56
 
@@ -113,6 +115,38 @@ flow [explore] accomplished
113
115
 
114
116
  ---
115
117
 
118
+ ## Post-Flow Hooks
119
+
120
+ When certain flows complete successfully, the system can inject advisory messages suggesting follow-up flows. This keeps the agent on the optimal path without requiring the user to manually chain flows.
121
+
122
+ ### Built-in Hooks
123
+
124
+ | Hook | Trigger | Advice |
125
+ |------|---------|--------|
126
+ | `code → review` | A `[code]` flow succeeds | *"Consider running a [review] flow to audit the changes…"* |
127
+ | `debug → code` | A `[debug]` flow succeeds | *"The root cause has been identified. Consider running a [code] flow to implement the fix."* |
128
+
129
+ Hooks are smart: if the agent already included the suggested flow in the same batch, the advisory is suppressed to avoid redundancy.
130
+
131
+ ### Extending
132
+
133
+ Hooks are registered via `registerHook()` in `hooks.ts`. Each hook defines a trigger (flow type + success requirement) and an action that returns advisory text. The hook system mirrors the flow discovery pattern, making it easy to add domain-specific hints.
134
+
135
+ Example — a custom `explore → architect` hook:
136
+
137
+ ```ts
138
+ registerHook({
139
+ name: "my/explore-to-architect",
140
+ trigger: { flowTypes: ["explore"], onlyOnSuccess: true },
141
+ action: (ctx) => ({
142
+ content: "Consider running an [architect] flow to design a solution.",
143
+ priority: 10,
144
+ }),
145
+ });
146
+ ```
147
+
148
+ ---
149
+
116
150
  ## Usage
117
151
 
118
152
  ### Single flow
@@ -143,6 +177,9 @@ flow [explore] accomplished
143
177
  | `--flow-max-depth [n]` | Maximum delegation depth | `3` |
144
178
  | `--flow-prevent-cycles` | Block cyclic delegation | `true` |
145
179
  | `--no-flow-prevent-cycles` | Disable cycle prevention | — |
180
+ | `--flow-lite-model [model]` | Model for lite-tier flows (`explore`, `debug`) | — |
181
+ | `--flow-flash-model [model]` | Model for flash-tier flows (`code`, `review`) | — |
182
+ | `--flow-full-model [model]` | Model for full-tier flows (`brainstorm`, `architect`) | — |
146
183
 
147
184
  ### Environment variables (propagated to child processes)
148
185
 
package/agents.ts CHANGED
@@ -17,6 +17,8 @@ import * as path from "node:path";
17
17
 
18
18
  export type FlowScope = "user" | "project" | "both" | "bundled" | "all";
19
19
 
20
+ export type FlowTier = "lite" | "flash" | "full";
21
+
20
22
  export interface FlowConfig {
21
23
  name: string;
22
24
  description: string;
@@ -25,6 +27,7 @@ export interface FlowConfig {
25
27
  thinking?: string;
26
28
  maxDepth?: number;
27
29
  inheritContext?: boolean;
30
+ tier?: FlowTier;
28
31
  systemPrompt: string;
29
32
  source: "user" | "project" | "bundled";
30
33
  filePath: string;
@@ -35,6 +38,24 @@ export interface FlowDiscoveryResult {
35
38
  projectFlowsDir: string | null;
36
39
  }
37
40
 
41
+ /** Determine the model tier for a given flow name. */
42
+ export function getFlowTier(flowName: string): FlowTier {
43
+ const normalized = flowName.toLowerCase().trim();
44
+ switch (normalized) {
45
+ case "explore":
46
+ case "debug":
47
+ return "lite";
48
+ case "code":
49
+ case "review":
50
+ return "flash";
51
+ case "brainstorm":
52
+ case "architect":
53
+ return "full";
54
+ default:
55
+ return "flash";
56
+ }
57
+ }
58
+
38
59
  // ---------------------------------------------------------------------------
39
60
  // Internal helpers
40
61
  // ---------------------------------------------------------------------------
@@ -169,6 +190,7 @@ function parseFlowFile(filePath: string, source: "user" | "project" | "bundled")
169
190
  thinking: typeof frontmatter.thinking === "string" ? frontmatter.thinking : undefined,
170
191
  maxDepth,
171
192
  inheritContext,
193
+ tier: getFlowTier(name),
172
194
  systemPrompt: body,
173
195
  source,
174
196
  filePath,
package/flow.ts CHANGED
@@ -10,9 +10,9 @@ import * as fs from "node:fs";
10
10
  import * as os from "node:os";
11
11
  import * as path from "node:path";
12
12
  import type { AgentToolResult } from "@mariozechner/pi-agent-core";
13
- import type { FlowConfig } from "./agents.js";
13
+ import { type FlowConfig, getFlowTier } from "./agents.js";
14
14
  import { parseFlowCliArgs } from "./runner-cli.js";
15
- import { processFlowJsonLine, drainStreamingText, drainStreamingEstimate } from "./runner-events.js";
15
+ import { processFlowJsonLine, drainStreamingText, drainStreamingEstimate, drainCtxEstimate } from "./runner-events.js";
16
16
  import {
17
17
  type SingleResult,
18
18
  type FlowDetails,
@@ -33,14 +33,21 @@ const PI_OFFLINE_ENV = "PI_OFFLINE";
33
33
  type FlowUpdateCallback = (partial: AgentToolResult<FlowDetails>) => void;
34
34
 
35
35
  /**
36
- * Merge actual usage with streaming estimate.
36
+ * Merge actual usage with streaming estimates.
37
37
  * Uses Math.max for output to avoid double-counting.
38
+ * Uses Math.max for contextTokens so the ctx estimate (baseline + streaming)
39
+ * smoothly increments during active streaming.
38
40
  */
39
- function mergeStreamingUsage(actual: SingleResult["usage"], estimatedTokens: number): SingleResult["usage"] {
40
- if (estimatedTokens <= 0) return actual;
41
+ function mergeStreamingUsage(
42
+ actual: SingleResult["usage"],
43
+ estimatedOutputTokens: number,
44
+ ctxEstimate: number,
45
+ ): SingleResult["usage"] {
46
+ if (estimatedOutputTokens <= 0 && ctxEstimate <= 0) return actual;
41
47
  return {
42
48
  ...actual,
43
- output: Math.max(actual.output, estimatedTokens),
49
+ ...(estimatedOutputTokens > 0 ? { output: Math.max(actual.output, estimatedOutputTokens) } : {}),
50
+ ...(ctxEstimate > 0 ? { contextTokens: Math.max(actual.contextTokens, ctxEstimate) } : {}),
44
51
  };
45
52
  }
46
53
 
@@ -94,6 +101,7 @@ function buildFlowArgs(
94
101
  flow: FlowConfig,
95
102
  intent: string,
96
103
  forkSessionPath: string | null,
104
+ tieredModels?: { lite?: string; flash?: string; full?: string },
97
105
  ): string[] {
98
106
  const args: string[] = [
99
107
  "--mode",
@@ -108,7 +116,9 @@ function buildFlowArgs(
108
116
  args.push("--session", forkSessionPath);
109
117
  }
110
118
 
111
- const model = flow.model ?? inheritedCliArgs.fallbackModel;
119
+ const tier = getFlowTier(flow.name);
120
+ const tierModel = tieredModels?.[tier] ?? inheritedCliArgs.tieredModels?.[tier];
121
+ const model = flow.model ?? tierModel ?? inheritedCliArgs.fallbackModel;
112
122
  if (model) args.push("--model", model);
113
123
 
114
124
  const thinking = flow.thinking ?? inheritedCliArgs.fallbackThinking;
@@ -165,6 +175,8 @@ export interface RunFlowOptions {
165
175
  maxDepth: number;
166
176
  /** Whether cycle prevention should be enforced in child processes. */
167
177
  preventCycles: boolean;
178
+ /** Tiered model overrides (lite/flash/full). */
179
+ tieredModels?: { lite?: string; flash?: string; full?: string };
168
180
  /** Abort signal for cancellation. */
169
181
  signal?: AbortSignal;
170
182
  /** Streaming update callback. */
@@ -210,6 +222,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
210
222
  };
211
223
  }
212
224
 
225
+ const resolvedModel = flow.model ?? inheritedCliArgs.fallbackModel;
213
226
  const result: SingleResult = {
214
227
  type: normalizedFlowName,
215
228
  agentSource: flow.source,
@@ -218,13 +231,14 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
218
231
  messages: [],
219
232
  stderr: "",
220
233
  usage: emptyFlowUsage(),
221
- model: flow.model,
234
+ model: resolvedModel,
222
235
  };
223
236
 
224
237
  const emitUpdate = () => {
225
238
  const streaming = drainStreamingText(result);
226
239
  const estimatedTokens = drainStreamingEstimate(result);
227
- const mergedUsage = mergeStreamingUsage(result.usage, estimatedTokens);
240
+ const ctxEst = drainCtxEstimate(result);
241
+ const mergedUsage = mergeStreamingUsage(result.usage, estimatedTokens, ctxEst);
228
242
  onUpdate?.({
229
243
  content: [
230
244
  {
@@ -250,6 +264,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
250
264
  flow,
251
265
  intent,
252
266
  forkSessionTmpPath,
267
+ opts.tieredModels,
253
268
  );
254
269
  let wasAborted = false;
255
270
 
package/hooks.ts ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Post-flow hook registry.
3
+ *
4
+ * Hooks inject advisory messages into flow tool results after execution.
5
+ * Built-in hooks fire automatically; user hooks can be registered via `registerHook()`.
6
+ */
7
+
8
+ import {
9
+ type PostFlowHook,
10
+ type SingleResult,
11
+ isFlowSuccess,
12
+ } from "./types.js";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Module-scoped hook registry
16
+ // ---------------------------------------------------------------------------
17
+
18
+ const hooks: PostFlowHook[] = [];
19
+
20
+ /**
21
+ * Register or replace a hook by name.
22
+ * If a hook with the same name already exists, it is replaced.
23
+ */
24
+ export function registerHook(hook: PostFlowHook): void {
25
+ const idx = hooks.findIndex((h) => h.name === hook.name);
26
+ if (idx >= 0) hooks[idx] = hook;
27
+ else hooks.push(hook);
28
+ }
29
+
30
+ /**
31
+ * Run all registered hooks against the given flow results.
32
+ * Returns an array of advisory strings sorted by priority.
33
+ */
34
+ export function runHooks(
35
+ params: Array<{ type: string; intent: string }>,
36
+ results: SingleResult[],
37
+ ): string[] {
38
+ const messages: Array<{ priority: number; content: string }> = [];
39
+
40
+ for (const hook of hooks) {
41
+ const triggerTypes = new Set(
42
+ hook.trigger.flowTypes.map((t) => t.toLowerCase()),
43
+ );
44
+ const onlyOnSuccess = hook.trigger.onlyOnSuccess !== false;
45
+
46
+ const matching = results.filter((r) => triggerTypes.has(r.type));
47
+ if (matching.length === 0) continue;
48
+ if (onlyOnSuccess && !matching.every((r) => isFlowSuccess(r))) continue;
49
+
50
+ const result = hook.action({ results: matching, params });
51
+ if (result) {
52
+ messages.push({ priority: result.priority ?? 0, content: result.content });
53
+ }
54
+ }
55
+
56
+ messages.sort((a, b) => a.priority - b.priority);
57
+ return messages.map((m) => m.content);
58
+ }
59
+
60
+ /**
61
+ * Clear all registered hooks. For testing only.
62
+ */
63
+ export function clearHooks(): void {
64
+ hooks.length = 0;
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Built-in hooks
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /** Suggest review flow after a successful code flow. */
72
+ registerHook({
73
+ name: "pi-agent-flow/code-to-review",
74
+ trigger: { flowTypes: ["code"], onlyOnSuccess: true },
75
+ action: (ctx) => {
76
+ const reviewWasRequested = ctx.params.some(
77
+ (p) => p.type.toLowerCase() === "review",
78
+ );
79
+ if (reviewWasRequested) return null;
80
+
81
+ return {
82
+ content:
83
+ "Consider running a [review] flow to audit the changes for security, correctness, and code quality.",
84
+ priority: 10,
85
+ };
86
+ },
87
+ });
88
+
89
+ /** Suggest code flow after a successful debug flow. */
90
+ registerHook({
91
+ name: "pi-agent-flow/debug-to-code",
92
+ trigger: { flowTypes: ["debug"], onlyOnSuccess: true },
93
+ action: (ctx) => {
94
+ const codeWasRequested = ctx.params.some(
95
+ (p) => p.type.toLowerCase() === "code",
96
+ );
97
+ if (codeWasRequested) return null;
98
+
99
+ return {
100
+ content:
101
+ "The root cause has been identified. Consider running a [code] flow to implement the fix.",
102
+ priority: 10,
103
+ };
104
+ },
105
+ });
package/index.ts CHANGED
@@ -8,8 +8,10 @@
8
8
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
9
  import { Type } from "@sinclair/typebox";
10
10
  import { type FlowConfig, discoverFlows } from "./agents.js";
11
+ import { loadFlowModels, type FlowModelConfig } from "./config.js";
11
12
  import { renderFlowCall, renderFlowResult } from "./render.js";
12
13
  import { getFlowSummaryText } from "./runner-events.js";
14
+ import { runHooks } from "./hooks.js";
13
15
  import { mapFlowConcurrent, runFlow } from "./flow.js";
14
16
  import {
15
17
  type SingleResult,
@@ -322,6 +324,44 @@ async function confirmProjectFlowsIfNeeded(
322
324
  );
323
325
  }
324
326
 
327
+ // ---------------------------------------------------------------------------
328
+ // Reminder flow injection (sliding — always on latest user message)
329
+ // ---------------------------------------------------------------------------
330
+
331
+ const REMINDER_TEXT =
332
+ "\n\n[reminder_flow: If the answer is in context, reply; otherwise, delegate to the appropriate flow.]";
333
+
334
+ function stripReminder(
335
+ content: string | { type: string; text?: string }[],
336
+ ): string | { type: string; text?: string }[] {
337
+ if (typeof content === "string") {
338
+ return content.replace(REMINDER_TEXT, "");
339
+ }
340
+ return content.map((c) => {
341
+ if (c.type === "text" && typeof c.text === "string") {
342
+ return { ...c, text: c.text.replace(REMINDER_TEXT, "") };
343
+ }
344
+ return c;
345
+ });
346
+ }
347
+
348
+ function appendReminder(
349
+ content: string | { type: string; text?: string }[],
350
+ ): string | { type: string; text?: string }[] {
351
+ if (typeof content === "string") {
352
+ return content + REMINDER_TEXT;
353
+ }
354
+ const copy = [...content];
355
+ const lastTextIdx = copy.map((c, i) => (c.type === "text" ? i : -1)).filter((i) => i !== -1).pop();
356
+ if (lastTextIdx === undefined) {
357
+ copy.push({ type: "text", text: REMINDER_TEXT });
358
+ } else {
359
+ const block = copy[lastTextIdx] as { type: "text"; text: string };
360
+ copy[lastTextIdx] = { ...block, text: block.text + REMINDER_TEXT };
361
+ }
362
+ return copy;
363
+ }
364
+
325
365
  // ---------------------------------------------------------------------------
326
366
  // Extension entry point
327
367
  // ---------------------------------------------------------------------------
@@ -335,12 +375,25 @@ export default function (pi: ExtensionAPI) {
335
375
  description: "Block delegating to flows already in the current delegation stack (default: true).",
336
376
  type: "boolean",
337
377
  });
378
+ pi.registerFlag("flow-lite-model", {
379
+ description: "Model for lite-tier flows (explore, debug).",
380
+ type: "string",
381
+ });
382
+ pi.registerFlag("flow-flash-model", {
383
+ description: "Model for flash-tier flows (code, review).",
384
+ type: "string",
385
+ });
386
+ pi.registerFlag("flow-full-model", {
387
+ description: "Model for full-tier flows (brainstorm, architect).",
388
+ type: "string",
389
+ });
338
390
 
339
391
  const depthConfig = resolveFlowDepthConfig(pi);
340
392
  const { currentDepth, maxDepth, canDelegate, ancestorFlowStack, preventCycles } =
341
393
  depthConfig;
342
394
 
343
395
  let discoveredFlows: FlowConfig[] = [];
396
+ let flowModelConfig: FlowModelConfig = {};
344
397
 
345
398
  // Auto-discover flows on session start
346
399
  pi.on("session_start", async (_event, ctx) => {
@@ -348,6 +401,7 @@ export default function (pi: ExtensionAPI) {
348
401
 
349
402
  const discovery = discoverFlows(ctx.cwd, "all");
350
403
  discoveredFlows = discovery.flows;
404
+ flowModelConfig = loadFlowModels(ctx.cwd);
351
405
  });
352
406
 
353
407
  // Inject available flows into the system prompt
@@ -394,6 +448,30 @@ flow [type] accomplished
394
448
  };
395
449
  });
396
450
 
451
+ // Sliding reminder: strip from all earlier user messages, append to latest
452
+ pi.on("context", async (event) => {
453
+ const messages = event.messages;
454
+ const userIndices = messages
455
+ .map((m, i) => (m.role === "user" ? i : -1))
456
+ .filter((i) => i !== -1);
457
+
458
+ if (userIndices.length === 0) return undefined;
459
+
460
+ const lastUserIndex = userIndices[userIndices.length - 1];
461
+
462
+ const modified = messages.map((msg, idx) => {
463
+ if (msg.role !== "user") return msg;
464
+
465
+ const content = stripReminder(msg.content);
466
+ if (idx === lastUserIndex) {
467
+ return { ...msg, content: appendReminder(content) };
468
+ }
469
+ return { ...msg, content };
470
+ });
471
+
472
+ return { messages: modified };
473
+ });
474
+
397
475
  // Register the flow tool
398
476
  if (canDelegate) {
399
477
  pi.registerTool({
@@ -415,6 +493,13 @@ flow [type] accomplished
415
493
  const { flows } = discovery;
416
494
  const makeDetails = makeFlowDetailsFactory(discovery.projectFlowsDir);
417
495
 
496
+ // Resolve tiered models: CLI flags override settings.json overrides parent --model
497
+ const tieredModels = {
498
+ lite: (pi.getFlag("flow-lite-model") as string | undefined) ?? flowModelConfig.lite,
499
+ flash: (pi.getFlag("flow-flash-model") as string | undefined) ?? flowModelConfig.flash,
500
+ full: (pi.getFlag("flow-full-model") as string | undefined) ?? flowModelConfig.full,
501
+ };
502
+
418
503
  // Build fork session snapshot (shared across all flows that inherit context)
419
504
  const forkSessionSnapshotJsonl = buildForkSessionSnapshotJsonl(
420
505
  ctx.sessionManager,
@@ -514,6 +599,7 @@ flow [type] accomplished
514
599
  parentFlowStack: ancestorFlowStack,
515
600
  maxDepth: effectiveMaxDepth,
516
601
  preventCycles,
602
+ tieredModels,
517
603
  signal,
518
604
  onUpdate: (partial) => {
519
605
  if (partial.details?.results[0]) {
@@ -536,18 +622,24 @@ flow [type] accomplished
536
622
  return `flow [${r.type}] ${status}\n\n${output}`;
537
623
  });
538
624
 
625
+ // Post-flow hooks — inject advisory messages
626
+ const advisors = runHooks(params.flow, results);
627
+ const advisorBlock = advisors.length > 0
628
+ ? "\n\n---\n\n💡 " + advisors.join("\n💡 ")
629
+ : "";
630
+
539
631
  return {
540
632
  content: [{
541
633
  type: "text" as const,
542
- text: `Flow: ${successCount}/${results.length} completed\n\n${flowReports.join("\n\n---\n\n")}`,
634
+ text: `Flow: ${successCount}/${results.length} completed\n\n${flowReports.join("\n\n---\n\n")}${advisorBlock}`,
543
635
  }],
544
636
  details: makeDetails(results),
545
637
  };
546
638
  },
547
639
 
548
640
  renderCall: (args, theme) => renderFlowCall(args, theme),
549
- renderResult: (result, { expanded }, theme) =>
550
- renderFlowResult(result, expanded, theme),
641
+ renderResult: (result, { expanded }, theme, args) =>
642
+ renderFlowResult(result, expanded, theme, args),
551
643
  });
552
644
  }
553
645
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-flow",
3
- "version": "0.4.5",
3
+ "version": "1.0.0",
4
4
  "description": "Flow-state delegation extension for Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -8,6 +8,7 @@
8
8
  "index.ts",
9
9
  "agents.ts",
10
10
  "flow.ts",
11
+ "hooks.ts",
11
12
  "runner-cli.js",
12
13
  "runner-events.js",
13
14
  "render.ts",
package/render-utils.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Pure utility functions for rendering extracted for testability.
2
+ * Pure utility functions for rendering - extracted for testability.
3
3
  */
4
4
 
5
5
  import type { UsageStats } from "./types.js";
@@ -27,39 +27,37 @@ export function formatFixedTokens(count: number): string {
27
27
  }
28
28
 
29
29
  /**
30
- * Format flow type name to fixed width (10 chars) in lowercase with dot padding.
31
- * Examples: "debug" → "debug.....", "architect" → "architect.", "brainstorm" → "brainstorm"
30
+ * Format flow type name to fixed width (10 chars) in lowercase with space padding.
31
+ * Examples: "debug" → "debug ", "architect" → "architect ", "brainstorm" → "brainstorm"
32
32
  */
33
33
  export function formatFlowTypeName(type: string): string {
34
34
  const lower = type.toLowerCase();
35
35
  const targetWidth = 10;
36
36
  if (lower.length >= targetWidth) return lower.slice(0, targetWidth);
37
- return lower + ".".repeat(targetWidth - lower.length);
37
+ return lower.padEnd(targetWidth, " ");
38
38
  }
39
39
 
40
40
  export function formatCompactStats(usage: Partial<UsageStats>, model?: string, maxWidth?: number): string {
41
41
  const parts: string[] = [];
42
- parts.push(`${formatFixedTokens(usage.input || 0)}↑`);
43
- parts.push(`${formatFixedTokens(usage.output || 0)}↓`);
44
- if (usage.cacheRead) parts.push(`cr:${formatFixedTokens(usage.cacheRead)}`);
45
- if (usage.contextTokens && usage.contextTokens > 0) parts.push(`ctx:${formatFixedTokens(usage.contextTokens)}`);
42
+ parts.push(`↑ ${formatFixedTokens(usage.input || 0)}`);
43
+ parts.push(`↓ ${formatFixedTokens(usage.output || 0)}`);
44
+ parts.push(`act: ${formatFixedTokens(usage.toolCalls || 0)}`);
45
+ parts.push(`ctx: ${formatFixedTokens(usage.contextTokens || 0)}`);
46
46
 
47
- let result = `[ ${parts.join(" ")} ]${model ? ` ${model}` : ""}`;
47
+ let result = parts.join(" · ") + (model ? ` · ${model}` : "");
48
48
 
49
49
  if (maxWidth && visibleLength(result) > maxWidth) {
50
50
  // Drop model first
51
- let narrow = `[ ${parts.join(" ")} ]`;
51
+ let narrow = parts.join(" · ");
52
52
  if (visibleLength(narrow) <= maxWidth) return narrow;
53
53
 
54
54
  // Drop context tokens
55
- const narrowParts = parts.slice();
56
- const ctxIndex = narrowParts.findIndex((p) => p.startsWith("ctx:"));
57
- if (ctxIndex !== -1) narrowParts.splice(ctxIndex, 1);
58
- narrow = `[ ${narrowParts.join(" ")} ]`;
55
+ const narrowParts = parts.slice(0, 3); // up to act
56
+ narrow = narrowParts.join(" · ");
59
57
  if (visibleLength(narrow) <= maxWidth) return narrow;
60
58
 
61
59
  // Bare minimum (just input/output)
62
- narrow = `[ ${parts[0]} ${parts[1]} ]`;
60
+ narrow = `${parts[0]} · ${parts[1]}`;
63
61
  if (visibleLength(narrow) <= maxWidth) return narrow;
64
62
 
65
63
  return truncateChars(result, maxWidth);
@@ -87,7 +85,7 @@ export function getTruncationBudget(prefixLength: number): number {
87
85
  return Math.max(width - prefixLength, 1);
88
86
  }
89
87
 
90
- /** Fixed content budget for collapsed-line text (dir/exe/log). */
88
+ /** Fixed content budget for collapsed-line text (dir/act/log). */
91
89
  export const CONTENT_MAX = 60;
92
90
 
93
91
  /**
@@ -102,19 +100,12 @@ export function contentBudget(prefixVisibleLen: number): number {
102
100
  * Truncate an ANSI-colored string to at most `max` visible characters,
103
101
  * preserving ANSI codes in the kept portions. Does not inject reset codes
104
102
  * — the caller is responsible for closing any open styles.
103
+ *
104
+ * Refactored to perform head-truncation (start + ...) instead of middle-truncation.
105
105
  */
106
106
  function truncateAnsi(text: string, max: number): string {
107
107
  if (visibleLength(text) <= max) return text;
108
108
 
109
- if (max < 3) {
110
- // Not enough room for '…' — just truncate without ellipsis
111
- const { raw } = takeVisible(text, max);
112
- return raw;
113
- }
114
-
115
- const head = Math.ceil(max * 0.6);
116
- const tail = max - head - 1; // 1 = '…'.length
117
-
118
109
  // Walk through the string, collecting raw chars until we've consumed
119
110
  // `count` visible characters. ANSI sequences are copied through without
120
111
  // counting toward the limit.
@@ -142,36 +133,19 @@ function truncateAnsi(text: string, max: number): string {
142
133
  return { raw, consumed: visible };
143
134
  }
144
135
 
145
- // Take head from the start
146
- const headResult = takeVisible(text, head);
147
-
148
- // Take tail from the end (walk backwards)
149
- function takeVisibleFromEnd(src: string, count: number): string {
150
- let visible = 0;
151
- let i = src.length - 1;
152
- while (i >= 0 && visible < count) {
153
- // Check if current char is end of an ANSI sequence
154
- if (src[i] === "m") {
155
- const escStart = src.lastIndexOf("\x1b[", i);
156
- if (escStart !== -1 && escStart < i) {
157
- const seq = src.slice(escStart, i + 1);
158
- if (/^\x1b\[[0-9;]*m$/.test(seq)) {
159
- // This is an ANSI sequence — don't count, skip past it
160
- i = escStart - 1;
161
- continue;
162
- }
163
- }
164
- }
165
- visible++;
166
- i--;
167
- }
168
- // Extract the raw substring from (i+1) to end, including any trailing ANSI
169
- return src.slice(i + 1);
136
+ if (max < 3) {
137
+ // Not enough room for '...' — just truncate without ellipsis
138
+ const { raw } = takeVisible(text, max);
139
+ return raw;
170
140
  }
171
141
 
172
- const tailRaw = takeVisibleFromEnd(text, tail);
142
+ const keep = max - 3; // 3 = '...'.length
143
+ const ellipsis = "...";
144
+
145
+ // Take head from the start
146
+ const headResult = takeVisible(text, keep);
173
147
 
174
- return headResult.raw + "\u2026" + tailRaw;
148
+ return headResult.raw + ellipsis;
175
149
  }
176
150
 
177
151
  export function truncateChars(text: string, max: number): string {
package/render.ts CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  isFlowError,
23
23
  isFlowSuccess,
24
24
  } from "./types.js";
25
- import { formatFixedTokens, formatCompactStats, formatFlowTypeName, truncateChars, contentBudget } from "./render-utils.js";
25
+ import { formatCompactStats, formatFlowTypeName, truncateChars, contentBudget } from "./render-utils.js";
26
26
 
27
27
  function shortenPath(p: string): string {
28
28
  const home = os.homedir();
@@ -126,11 +126,26 @@ export function renderFlowResult(
126
126
  result: { content: Array<{ type: string; text?: string }>; details?: unknown },
127
127
  expanded: boolean,
128
128
  theme: FlowTheme,
129
+ args?: Record<string, any>,
129
130
  ): Container | Text {
130
131
  const details = result.details as FlowDetails | undefined;
131
132
  const streamingText = result.content?.[0]?.type === "text" ? result.content[0].text : undefined;
132
133
 
133
134
  if (!details || details.results.length === 0) {
135
+ // Ghost Dashboard: render a placeholder status line during the zero state
136
+ const flowRequest = args?.flow?.[0];
137
+ if (flowRequest) {
138
+ const ghostResult: SingleResult = {
139
+ type: flowRequest.type || "unknown",
140
+ agentSource: "user",
141
+ intent: flowRequest.intent || "Processing...",
142
+ exitCode: -1, // In progress
143
+ messages: [],
144
+ stderr: "",
145
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0, toolCalls: 0 },
146
+ };
147
+ return renderFlowCollapsed(ghostResult, flowStatusIcon(ghostResult, theme), false, streamingText || "", theme);
148
+ }
134
149
  return new Text(streamingText || "", 0, 0);
135
150
  }
136
151
 
@@ -182,13 +197,8 @@ function renderFlowExpanded(
182
197
  container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
183
198
  }
184
199
 
185
- // Stats: all-in-one bracket format with context inline
186
- const statsParts: string[] = [];
187
- statsParts.push(`${formatFixedTokens(r.usage.input || 0)}↑`);
188
- statsParts.push(`${formatFixedTokens(r.usage.output || 0)}↓`);
189
- if (r.usage.cacheRead) statsParts.push(`cr:${formatFixedTokens(r.usage.cacheRead)}`);
190
- if (r.usage.contextTokens > 0) statsParts.push(`ctx:${formatFixedTokens(r.usage.contextTokens)}`);
191
- const inlineStats = `[ ${statsParts.join(" ")} ]${r.model ? ` ─ ${r.model}` : ""}`;
200
+ // Stats: dashboard format
201
+ const inlineStats = formatCompactStats(r.usage, r.model);
192
202
  container.addChild(new Text(theme.fg("dim", inlineStats), 0, 0));
193
203
 
194
204
  // Intent
@@ -239,12 +249,12 @@ function renderFlowCollapsed(
239
249
  container.addChild(new TruncatedText(`${theme.fg("dim", "├─ dir:")} ${theme.fg("dim", dirContent)}`, 0, 0));
240
250
  }
241
251
 
242
- // exe: line (last tool call)
252
+ // act: line (last tool call)
243
253
  const lastTool = getLastToolCall(r.messages);
244
254
  if (lastTool) {
245
- const exeStr = formatFlowToolCall(lastTool.name, lastTool.args, theme.fg.bind(theme));
246
- const exeContent = truncateChars(exeStr, contentBudget(10));
247
- container.addChild(new TruncatedText(`${theme.fg("dim", "├─ exe:")} ${exeContent}`, 0, 0));
255
+ const actStr = formatFlowToolCall(lastTool.name, lastTool.args, theme.fg.bind(theme));
256
+ const actContent = truncateChars(actStr, contentBudget(10));
257
+ container.addChild(new TruncatedText(`${theme.fg("dim", "├─ act:")} ${actContent}`, 0, 0));
248
258
  }
249
259
 
250
260
  // log: line (last assistant text or streaming)
@@ -308,13 +318,8 @@ function renderMultiFlowExpanded(
308
318
  // Per-flow header: ─── EXPLORER (no icon)
309
319
  container.addChild(new Text(`${theme.fg("muted", "─── ")}${theme.fg("accent", typeName)}`, 0, 0));
310
320
 
311
- // Stats: all-in-one bracket format with context inline
312
- const flowParts: string[] = [];
313
- flowParts.push(`${formatFixedTokens(r.usage.input || 0)}↑`);
314
- flowParts.push(`${formatFixedTokens(r.usage.output || 0)}↓`);
315
- if (r.usage.cacheRead) flowParts.push(`cr:${formatFixedTokens(r.usage.cacheRead)}`);
316
- if (r.usage.contextTokens > 0) flowParts.push(`ctx:${formatFixedTokens(r.usage.contextTokens)}`);
317
- const flowStats = `[ ${flowParts.join(" ")} ]${r.model ? ` ─ ${r.model}` : ""}`;
321
+ // Stats: dashboard format
322
+ const flowStats = formatCompactStats(r.usage, r.model);
318
323
  container.addChild(new Text(theme.fg("dim", flowStats), 0, 0));
319
324
 
320
325
  // Intent: just show text, no prefix
@@ -334,15 +339,10 @@ function renderMultiFlowExpanded(
334
339
  }
335
340
  }
336
341
 
337
- // Total stats: all-in-one bracket format with context inline
342
+ // Total stats: dashboard format
338
343
  const totalUsage = aggregateFlowUsage(results);
339
344
  const totalModel = results[0]?.model;
340
- const totalParts: string[] = [];
341
- totalParts.push(`${formatFixedTokens(totalUsage.input || 0)}↑`);
342
- totalParts.push(`${formatFixedTokens(totalUsage.output || 0)}↓`);
343
- if (totalUsage.cacheRead) totalParts.push(`cr:${formatFixedTokens(totalUsage.cacheRead)}`);
344
- if (totalUsage.contextTokens > 0) totalParts.push(`ctx:${formatFixedTokens(totalUsage.contextTokens)}`);
345
- const totalStats = `[ ${totalParts.join(" ")} ]${totalModel ? ` ─ ${totalModel}` : ""}`;
345
+ const totalStats = formatCompactStats(totalUsage, totalModel);
346
346
  container.addChild(new Spacer(1));
347
347
  container.addChild(new Text(theme.fg("dim", totalStats), 0, 0));
348
348
 
@@ -380,12 +380,12 @@ function renderActivityPanel(
380
380
  container.addChild(new TruncatedText(`${theme.fg("dim", indent + "├─ dir:")} ${theme.fg("dim", dirContent)}`, 0, 0));
381
381
  }
382
382
 
383
- // exe: line (last tool call)
383
+ // act: line (last tool call)
384
384
  const lastTool = getLastToolCall(r.messages);
385
385
  if (lastTool) {
386
- const exeStr = formatFlowToolCall(lastTool.name, lastTool.args, theme.fg.bind(theme));
387
- const exeContent = truncateChars(exeStr, contentBudget(10));
388
- container.addChild(new TruncatedText(`${theme.fg("dim", indent + "├─ exe:")} ${exeContent}`, 0, 0));
386
+ const actStr = formatFlowToolCall(lastTool.name, lastTool.args, theme.fg.bind(theme));
387
+ const actContent = truncateChars(actStr, contentBudget(10));
388
+ container.addChild(new TruncatedText(`${theme.fg("dim", indent + "├─ act:")} ${actContent}`, 0, 0));
389
389
  }
390
390
 
391
391
  // log: line (last assistant text)
package/runner-cli.js CHANGED
@@ -48,6 +48,9 @@ export function parseFlowCliArgs(argv) {
48
48
  let fallbackThinking;
49
49
  let fallbackTools;
50
50
  let fallbackNoTools = false;
51
+ let tieredLiteModel;
52
+ let tieredFlashModel;
53
+ let tieredFullModel;
51
54
 
52
55
  let i = 2; // skip executable + script name
53
56
  while (i < argv.length) {
@@ -198,6 +201,27 @@ export function parseFlowCliArgs(argv) {
198
201
  continue;
199
202
  }
200
203
 
204
+ if (flagName === "--flow-lite-model") {
205
+ const [value, skip] = getValue();
206
+ if (value !== undefined) tieredLiteModel = value;
207
+ i += skip;
208
+ continue;
209
+ }
210
+
211
+ if (flagName === "--flow-flash-model") {
212
+ const [value, skip] = getValue();
213
+ if (value !== undefined) tieredFlashModel = value;
214
+ i += skip;
215
+ continue;
216
+ }
217
+
218
+ if (flagName === "--flow-full-model") {
219
+ const [value, skip] = getValue();
220
+ if (value !== undefined) tieredFullModel = value;
221
+ i += skip;
222
+ continue;
223
+ }
224
+
201
225
  if (inlineValue !== undefined) {
202
226
  alwaysProxy.push(flagName, inlineValue);
203
227
  i++;
@@ -221,5 +245,10 @@ export function parseFlowCliArgs(argv) {
221
245
  fallbackThinking,
222
246
  fallbackTools,
223
247
  fallbackNoTools,
248
+ tieredModels: {
249
+ lite: tieredLiteModel,
250
+ flash: tieredFlashModel,
251
+ full: tieredFullModel,
252
+ },
224
253
  };
225
254
  }
package/runner-events.js CHANGED
@@ -63,6 +63,29 @@ function getStreamingEstimate(result) {
63
63
  return result.__streamingEstimate;
64
64
  }
65
65
 
66
+ /**
67
+ * Lazily initialize ctx baseline tracking properties on the result object.
68
+ * - __ctxBaseline: last known real totalTokens from message_end
69
+ * - __ctxStreamingChars: cumulative output chars since last baseline reset
70
+ */
71
+ function getCtxBaseline(result) {
72
+ if (!Object.prototype.hasOwnProperty.call(result, "__ctxBaseline")) {
73
+ Object.defineProperty(result, "__ctxBaseline", {
74
+ value: 0,
75
+ enumerable: false,
76
+ configurable: false,
77
+ writable: true,
78
+ });
79
+ Object.defineProperty(result, "__ctxStreamingChars", {
80
+ value: 0,
81
+ enumerable: false,
82
+ configurable: false,
83
+ writable: true,
84
+ });
85
+ }
86
+ return result.__ctxBaseline;
87
+ }
88
+
66
89
  /**
67
90
  * Track streaming characters and estimate output tokens.
68
91
  * Called on every streaming delta.
@@ -71,6 +94,9 @@ function updateStreamingEstimate(result, deltaLength) {
71
94
  if (deltaLength <= 0) return;
72
95
  const est = getStreamingEstimate(result);
73
96
  est.chars += deltaLength;
97
+ // Also accumulate chars for ctx estimation (not drained on emit)
98
+ getCtxBaseline(result);
99
+ result.__ctxStreamingChars += deltaLength;
74
100
  }
75
101
 
76
102
  /**
@@ -84,6 +110,18 @@ export function drainStreamingEstimate(result) {
84
110
  return tokens;
85
111
  }
86
112
 
113
+ /**
114
+ * Return the estimated context tokens: last known real totalTokens (baseline)
115
+ * plus any additional output tokens estimated since that baseline was set.
116
+ * Returns 0 before the first message_end when no baseline exists yet,
117
+ * in which case the caller should fall back to the streaming output estimate.
118
+ */
119
+ export function drainCtxEstimate(result) {
120
+ getCtxBaseline(result);
121
+ const streamingTokens = Math.floor(result.__ctxStreamingChars / CHARS_PER_TOKEN);
122
+ return result.__ctxBaseline + streamingTokens;
123
+ }
124
+
87
125
  /**
88
126
  * Accumulate a text or thinking delta into the streaming buffer.
89
127
  * Returns true if the caller should emit an update.
@@ -151,6 +189,10 @@ function addFlowAssistantMessage(result, message) {
151
189
  result.usage.cacheWrite += usage.cacheWrite || 0;
152
190
  result.usage.cost += usage.cost?.total || 0;
153
191
  result.usage.contextTokens = usage.totalTokens || 0;
192
+
193
+ // Snapshot ctx baseline for smooth streaming estimation
194
+ result.__ctxBaseline = usage.totalTokens || 0;
195
+ result.__ctxStreamingChars = 0;
154
196
  }
155
197
 
156
198
  // Count tool call parts in the message content
package/types.ts CHANGED
@@ -183,3 +183,41 @@ export function getLastAssistantText(messages: Message[]): string {
183
183
  }
184
184
  return "";
185
185
  }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Post-flow hook types
189
+ // ---------------------------------------------------------------------------
190
+
191
+ /** Condition that determines when a hook fires. */
192
+ export interface PostFlowHookTrigger {
193
+ /** Case-insensitive flow type names that trigger this hook. */
194
+ flowTypes: string[];
195
+ /** Only fire when all matching results succeeded. Default: true. */
196
+ onlyOnSuccess?: boolean;
197
+ }
198
+
199
+ /** Context passed to a hook's action function. */
200
+ export interface PostFlowHookContext {
201
+ /** Flow results that matched the trigger. */
202
+ results: SingleResult[];
203
+ /** Original flow request params. */
204
+ params: Array<{ type: string; intent: string }>;
205
+ }
206
+
207
+ /** Result returned by a hook's action function. */
208
+ export interface PostFlowHookResult {
209
+ /** Advisory text to inject. */
210
+ content: string;
211
+ /** Ordering key; lower runs first. Default: 0. */
212
+ priority?: number;
213
+ }
214
+
215
+ /** A post-flow hook that injects advisory messages into tool results. */
216
+ export interface PostFlowHook {
217
+ /** Unique name for dedup and debugging. */
218
+ name: string;
219
+ /** When to fire this hook. */
220
+ trigger: PostFlowHookTrigger;
221
+ /** Returns advisory text, or null to skip. */
222
+ action: (ctx: PostFlowHookContext) => PostFlowHookResult | null;
223
+ }