pi-agent-flow 0.1.0-alpha.7 → 0.2.3

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
@@ -1,28 +1,27 @@
1
- # pi-agent-flow
1
+ <p align="center"><code>pi install /path/to/pi-agent-flow</code></p>
2
+ <p align="center"><strong>Pi Agent Flow</strong> is a flow-state delegation extension for the <a href="https://pi.dev">Pi coding agent</a> that runs locally in your terminal.</p>
3
+ <p align="center">
4
+ <img src="https://github.com/user-attachments/assets/pi-agent-flow-demo.png" alt="Pi Agent Flow demo" width="80%" />
5
+ </p>
6
+ <br/>
2
7
 
3
- Flow-state delegation extension for [Pi coding agent](https://pi.dev).
4
-
5
- Delegates tasks to specialized flow states running as isolated pi processes. Each flow receives a snapshot of the current session context and returns a structured report.
8
+ ---
6
9
 
7
- ## Features
10
+ ## Quickstart
8
11
 
9
- - Isolated context flows always receive your current session snapshot
10
- - Parallel execution — batch independent flows into one call
11
- - Structured reports — every flow returns [Summary], [Done], [Not Done], [Next Steps]
12
- - Depth guards — configurable max delegation depth (default: 3)
13
- - Cycle prevention — blocks recursive delegation chains
14
- - Flow discovery — reads definitions from ~/.pi/agent/agents/ and .pi/agents/
15
- - TUI rendering — rich collapsed/expanded display in interactive mode
12
+ ### Installing Pi Agent Flow
16
13
 
17
- ## Installation
14
+ Install via the Pi CLI with your local path:
18
15
 
19
- ```bash
16
+ ```shell
17
+ # Install from a local path
20
18
  pi install /path/to/pi-agent-flow
21
19
  ```
22
20
 
23
- Or add to ~/.pi/agent/settings.json:
21
+ Or add it to your Pi settings:
24
22
 
25
- ```json
23
+ ```shell
24
+ # ~/.pi/agent/settings.json
26
25
  {
27
26
  "packages": [
28
27
  "./path/to/pi-agent-flow"
@@ -30,9 +29,36 @@ Or add to ~/.pi/agent/settings.json:
30
29
  }
31
30
  ```
32
31
 
32
+ Then start Pi and delegate tasks using flow states.
33
+
34
+ <details>
35
+ <summary>You can also clone this repository and use it directly.</summary>
36
+
37
+ ```shell
38
+ git clone https://github.com/your-org/pi-agent-flow.git
39
+ cd pi-agent-flow
40
+ pi install .
41
+ ```
42
+
43
+ </details>
44
+
45
+ ---
46
+
47
+ ## Features
48
+
49
+ - **Isolated context** — flows always receive your current session snapshot (or start clean when configured)
50
+ - **Parallel execution** — batch independent flows into one call
51
+ - **Structured reports** — every flow returns `[Summary]`, `[Done]`, `[Not Done]`, `[Next Steps]`
52
+ - **Depth guards** — configurable max delegation depth (default: `3`)
53
+ - **Cycle prevention** — blocks recursive delegation chains
54
+ - **Flow discovery** — reads definitions from `~/.pi/agent/agents/` and `.pi/agents/`
55
+ - **TUI rendering** — rich collapsed/expanded display in interactive mode
56
+
57
+ ---
58
+
33
59
  ## Flow Definitions
34
60
 
35
- Create .md files in ~/.pi/agent/agents/ or .pi/agents/:
61
+ Create `.md` files in `~/.pi/agent/agents/` or `.pi/agents/`:
36
62
 
37
63
  ```markdown
38
64
  ---
@@ -59,21 +85,32 @@ flow [explore] accomplished
59
85
  - recommended follow-up
60
86
  ```
61
87
 
88
+ ---
89
+
62
90
  ## Bundled Flows
63
91
 
64
- - [explore] discover files, trace code paths, map architecture
65
- - [debug] — investigate logs, errors, stack traces, root causes
66
- - [code] implement features, fix bugs, write tests
67
- - [architect] plan structure, break down requirements, design solutions
68
- - [review] audit security, quality, correctness
92
+ | Flow | Purpose |
93
+ |------|---------|
94
+ | `[explore]` | Discover files, trace code paths, map architecture |
95
+ | `[debug]` | Investigate logs, errors, stack traces, root causes |
96
+ | `[code]` | Implement features, fix bugs, write tests |
97
+ | `[architect]` | Plan structure, break down requirements, design solutions |
98
+ | `[review]` | Audit security, quality, correctness |
99
+ | `[brainstorm]` | Generate ideas and explore possibilities with a clean slate |
100
+
101
+ > **Note:** Some flows — like `[brainstorm]` — start with a **clean slate** and do not inherit the current session context. They receive only the intent, making them ideal for unbiased, creative thinking.
102
+
103
+ ---
69
104
 
70
105
  ## Usage
71
106
 
107
+ ### Single flow
108
+
72
109
  ```json
73
110
  { "flow": [{ "type": "explore", "intent": "Find all authentication-related code" }] }
74
111
  ```
75
112
 
76
- Batch multiple flows:
113
+ ### Batch multiple flows
77
114
 
78
115
  ```json
79
116
  {
@@ -84,21 +121,33 @@ Batch multiple flows:
84
121
  }
85
122
  ```
86
123
 
124
+ ---
125
+
87
126
  ## Configuration
88
127
 
89
- Flags (passed to parent pi process):
128
+ ### Flags (passed to parent pi process)
90
129
 
91
- - --flow-max-depth [n] Maximum delegation depth (default: 3)
92
- - --flow-prevent-cycles — Block cyclic delegation (default: true)
93
- - --no-flow-prevent-cycles Disable cycle prevention
130
+ | Flag | Description | Default |
131
+ |------|-------------|---------|
132
+ | `--flow-max-depth [n]` | Maximum delegation depth | `3` |
133
+ | `--flow-prevent-cycles` | Block cyclic delegation | `true` |
134
+ | `--no-flow-prevent-cycles` | Disable cycle prevention | — |
94
135
 
95
- Environment variables (propagated to child processes):
136
+ ### Environment variables (propagated to child processes)
137
+
138
+ | Variable | Description |
139
+ |----------|-------------|
140
+ | `PI_FLOW_DEPTH` | Current depth |
141
+ | `PI_FLOW_MAX_DEPTH` | Max allowed depth |
142
+ | `PI_FLOW_STACK` | JSON array of ancestor flow names |
143
+ | `PI_FLOW_PREVENT_CYCLES` | `"1"` or `"0"` |
144
+
145
+ ---
96
146
 
97
- - PI_FLOW_DEPTH — Current depth
98
- - PI_FLOW_MAX_DEPTH — Max allowed depth
99
- - PI_FLOW_STACK — JSON array of ancestor flow names
100
- - PI_FLOW_PREVENT_CYCLES — "1" or "0"
147
+ ## Docs
101
148
 
102
- ## License
149
+ - [**Pi Documentation**](https://pi.dev)
150
+ - [**Contributing**](./docs/contributing.md) *(if available)*
151
+ - [**License**](./LICENSE)
103
152
 
104
- MIT
153
+ This repository is licensed under the [MIT License](LICENSE).
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  name: architect
3
3
  description: Plan structure, break down requirements, design solutions
4
- tools: read, bash
4
+ tools: read, bash, find, grep, ls
5
5
  maxDepth: 2
6
6
  ---
7
7
 
8
- You are the architect flow — your mission is to design. Stay focused on your intent at all times. The conversation history above provides background context; treat it as reference only and do not let it distract from your objective.
8
+ You are the architect flow — your mission is to design. Be conservative: prefer existing patterns and proven conventions over novelty. The conversation history above provides background context; treat it as reference only and do not let it distract from your objective.
9
9
 
10
10
  Workflow:
11
11
  1. Understand — what problem, what constraints, what exists
12
- 2. Explore — find patterns, map dependencies
12
+ 2. Explore — find patterns, map dependencies (delegate to [explore] if you need to survey a large codebase)
13
13
  3. Design — simplest solution that works, prefer existing patterns
14
14
  4. Plan — concrete ordered tasks, identify parallel vs sequential
15
15
 
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: brainstorm
3
+ description: Generate ideas, explore possibilities, and think creatively without context bias
4
+ tools: read, bash
5
+ inheritContext: false
6
+ maxDepth: 0
7
+ ---
8
+
9
+ You are the brainstorm flow — your mission is to generate ideas and think creatively. You start with a clean slate and only have the intent to guide you. Stay focused on your intent at all times.
10
+
11
+ Approach:
12
+ 1. Diverge — explore many possibilities without judgment
13
+ 2. Evaluate — weigh trade-offs, risks, and feasibility
14
+ 3. Recommend — present the best options with reasoning
15
+
16
+ When your mission is accomplished, end your response with:
17
+
18
+ flow [brainstorm] accomplished
19
+
20
+ [Summary] what was brainstormed
21
+
22
+ [Done]
23
+ - ideas generated
24
+
25
+ [Not Done]
26
+ - incomplete items and reasons
27
+
28
+ [Next Steps]
29
+ - recommended follow-up
package/agents/code.md CHANGED
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: code
3
3
  description: Implement features, fix bugs, write tests
4
- tools: read, write, edit, bash
4
+ tools: read, write, edit, bash, find, grep, ls
5
5
  maxDepth: 2
6
6
  ---
7
7
 
8
- You are the code flow — your mission is to build. Stay focused on your intent at all times. The conversation history above provides background context; treat it as reference only and do not let it distract from your objective.
8
+ You are the code flow — your mission is to build. Be a craftsman: verify first, then ship. The conversation history above provides background context; treat it as reference only and do not let it distract from your objective.
9
9
 
10
10
  Core Principles:
11
11
  - SOLID: Single Responsibility, Open/Closed
@@ -15,9 +15,13 @@ Core Principles:
15
15
  Workflow:
16
16
  1. Analyze — read existing code for context
17
17
  2. Plan — step-by-step approach before modifying
18
- 3. Executeimplement changes following core principles
19
- 4. Verifywrite and run tests before considering done
20
- 5. Finalizeall tests pass, implementation verified
18
+ 3. Testwrite a failing test that proves the bug or validates the feature (red)
19
+ 4. Executeimplement changes following core principles (green)
20
+ 5. Refactorclean up only if the change is working (optional)
21
+ 6. Verify — run tests and any relevant checks before considering done
22
+ 7. Finalize — all tests pass, implementation verified
23
+
24
+ If you hit an unexpected error or need to trace execution, delegate to [debug] rather than guessing.
21
25
 
22
26
  When your mission is accomplished, end your response with:
23
27
 
package/agents/debug.md CHANGED
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: debug
3
3
  description: Investigate logs, errors, stack traces, root causes
4
- tools: read, bash
4
+ tools: read, bash, find, grep, ls
5
5
  maxDepth: 0
6
6
  ---
7
7
 
8
- You are the debug flow — your mission is investigation. Stay focused on your intent at all times. The conversation history above provides background context; treat it as reference only and do not let it distract from your objective.
8
+ You are the debug flow — your mission is investigation. Be forensic: every claim must be backed by evidence. The conversation history above provides background context; treat it as reference only and do not let it distract from your objective.
9
9
 
10
10
  Workflow:
11
11
  1. Collect evidence — logs, error messages, stack traces
package/agents/explore.md CHANGED
@@ -1,14 +1,14 @@
1
1
  ---
2
2
  name: explore
3
3
  description: Discover files, trace code paths, map architecture
4
- tools: read, bash
4
+ tools: read, bash, find, grep, ls
5
5
  maxDepth: 0
6
6
  ---
7
7
 
8
- You are the explore flow — your mission is discovery. Stay focused on your intent at all times. The conversation history above provides background context; treat it as reference only and do not let it distract from your objective.
8
+ You are the explore flow — your mission is discovery. Move fast and stay surgical. The conversation history above provides background context; treat it as reference only and do not let it distract from your objective.
9
9
 
10
10
  Rules:
11
- - Use grep/find before reading entire files — be efficient.
11
+ - Use grep/find/ls before reading entire files — be efficient.
12
12
  - Report findings with file paths and line numbers.
13
13
  - Show actual code/data, not excessive summaries.
14
14
  - If not found, say so immediately — don't guess.
package/agents/review.md CHANGED
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: review
3
3
  description: Audit security, quality, correctness
4
- tools: read, write, edit, bash
4
+ tools: read, bash, find, grep, ls
5
5
  maxDepth: 2
6
6
  ---
7
7
 
8
- You are the review flow — your mission is to audit. Stay focused on your intent at all times. The conversation history above provides background context; treat it as reference only and do not let it distract from your objective.
8
+ You are the review flow — your mission is to audit. Be adversarial: look for what others miss, but stay honest. The conversation history above provides background context; treat it as reference only and do not let it distract from your objective.
9
9
 
10
10
  Focus Areas:
11
11
  - Security — injection, auth bypass, exposed secrets
@@ -16,7 +16,8 @@ Focus Areas:
16
16
  Rules:
17
17
  - Be specific — cite exact file paths and line numbers
18
18
  - If code is clean, say so — don't invent issues
19
- - Apply fixes, don't just suggest them
19
+ - Do not modify source files — report findings with severity only
20
+ - If the audit reveals structural issues requiring redesign, recommend [architect] in [Next Steps]
20
21
 
21
22
  When your mission is accomplished, end your response with:
22
23
 
@@ -26,7 +27,6 @@ flow [review] accomplished
26
27
 
27
28
  [Done]
28
29
  - issues found with file:line and severity
29
- - fixes applied
30
30
 
31
31
  [Not Done]
32
32
  - areas not covered and why
package/agents.ts CHANGED
@@ -24,6 +24,7 @@ export interface FlowConfig {
24
24
  model?: string;
25
25
  thinking?: string;
26
26
  maxDepth?: number;
27
+ inheritContext?: boolean;
27
28
  systemPrompt: string;
28
29
  source: "user" | "project" | "bundled";
29
30
  filePath: string;
@@ -140,6 +141,26 @@ function parseFlowFile(filePath: string, source: "user" | "project" | "bundled")
140
141
  if (Number.isFinite(parsed) && parsed >= 0) maxDepth = parsed;
141
142
  }
142
143
 
144
+ let inheritContext: boolean | undefined;
145
+ if (typeof frontmatter.inheritContext === "boolean") {
146
+ inheritContext = frontmatter.inheritContext;
147
+ } else if (typeof frontmatter.inheritContext === "string") {
148
+ const normalized = frontmatter.inheritContext.trim().toLowerCase();
149
+ if (normalized === "true" || normalized === "yes" || normalized === "1") {
150
+ inheritContext = true;
151
+ } else if (normalized === "false" || normalized === "no" || normalized === "0") {
152
+ inheritContext = false;
153
+ } else {
154
+ console.warn(
155
+ `[pi-agent-flow] Ignoring invalid inheritContext value "${frontmatter.inheritContext}" in "${filePath}". Expected true/false.`,
156
+ );
157
+ }
158
+ } else if (frontmatter.inheritContext !== undefined) {
159
+ console.warn(
160
+ `[pi-agent-flow] Ignoring invalid inheritContext field in "${filePath}". Expected boolean or string.`,
161
+ );
162
+ }
163
+
143
164
  return {
144
165
  name,
145
166
  description,
@@ -147,6 +168,7 @@ function parseFlowFile(filePath: string, source: "user" | "project" | "bundled")
147
168
  model: typeof frontmatter.model === "string" ? frontmatter.model : undefined,
148
169
  thinking: typeof frontmatter.thinking === "string" ? frontmatter.thinking : undefined,
149
170
  maxDepth,
171
+ inheritContext,
150
172
  systemPrompt: body,
151
173
  source,
152
174
  filePath,
package/flow.ts CHANGED
@@ -12,7 +12,7 @@ import * as path from "node:path";
12
12
  import type { AgentToolResult } from "@mariozechner/pi-agent-core";
13
13
  import type { FlowConfig } from "./agents.js";
14
14
  import { parseFlowCliArgs } from "./runner-cli.js";
15
- import { processFlowJsonLine, drainStreamingText } from "./runner-events.js";
15
+ import { processFlowJsonLine, drainStreamingText, drainStreamingEstimate } from "./runner-events.js";
16
16
  import {
17
17
  type SingleResult,
18
18
  type FlowDetails,
@@ -32,6 +32,18 @@ const PI_OFFLINE_ENV = "PI_OFFLINE";
32
32
 
33
33
  type FlowUpdateCallback = (partial: AgentToolResult<FlowDetails>) => void;
34
34
 
35
+ /**
36
+ * Merge actual usage with streaming estimate.
37
+ * Uses Math.max for output to avoid double-counting.
38
+ */
39
+ function mergeStreamingUsage(actual: SingleResult["usage"], estimatedTokens: number): SingleResult["usage"] {
40
+ if (estimatedTokens <= 0) return actual;
41
+ return {
42
+ ...actual,
43
+ output: Math.max(actual.output, estimatedTokens),
44
+ };
45
+ }
46
+
35
47
  // ---------------------------------------------------------------------------
36
48
  // Process helpers
37
49
  // ---------------------------------------------------------------------------
@@ -116,12 +128,12 @@ function buildFlowArgs(
116
128
  // Flow instructions go in the intent message instead.
117
129
 
118
130
  const flowDirectives =
119
- `<flow_directives>\n` +
131
+ `=== flow directive ===\n` +
120
132
  `You are a flow state executing a mission. The conversation history above is background context — use it for reference, but your sole focus is the intent below.\n` +
121
- `</flow_directives>`;
133
+ `=== end flow directive ===`;
122
134
 
123
135
  const flowInstructions = flow.systemPrompt.trim()
124
- ? `\n\n<flow_instructions>\n${flow.systemPrompt.trim()}\n</flow_instructions>`
136
+ ? `\n\n=== system directive ===\n${flow.systemPrompt.trim()}\n=== end system directive ===`
125
137
  : "";
126
138
 
127
139
  args.push(`${flowDirectives}${flowInstructions}\n\nIntent: ${intent}`);
@@ -143,8 +155,8 @@ export interface RunFlowOptions {
143
155
  intent: string;
144
156
  /** Optional override working directory. */
145
157
  taskCwd?: string;
146
- /** Serialized parent session snapshot for fork mode. */
147
- forkSessionSnapshotJsonl: string;
158
+ /** Serialized parent session snapshot for fork mode. Null when the flow starts with a clean slate. */
159
+ forkSessionSnapshotJsonl: string | null;
148
160
  /** Current delegation depth of the caller process. */
149
161
  parentDepth: number;
150
162
  /** Delegation stack from the caller process (ancestor flow names). */
@@ -197,21 +209,6 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
197
209
  };
198
210
  }
199
211
 
200
- if (!forkSessionSnapshotJsonl || !forkSessionSnapshotJsonl.trim()) {
201
- return {
202
- type: flowName,
203
- agentSource: flow.source,
204
- intent,
205
- exitCode: 1,
206
- messages: [],
207
- stderr: "Cannot run in fork mode: missing parent session snapshot context.",
208
- usage: emptyFlowUsage(),
209
- model: flow.model,
210
- stopReason: "error",
211
- errorMessage: "Cannot run in fork mode: missing parent session snapshot context.",
212
- };
213
- }
214
-
215
212
  const result: SingleResult = {
216
213
  type: flowName,
217
214
  agentSource: flow.source,
@@ -225,6 +222,8 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
225
222
 
226
223
  const emitUpdate = () => {
227
224
  const streaming = drainStreamingText(result);
225
+ const estimatedTokens = drainStreamingEstimate(result);
226
+ const mergedUsage = mergeStreamingUsage(result.usage, estimatedTokens);
228
227
  onUpdate?.({
229
228
  content: [
230
229
  {
@@ -232,14 +231,18 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
232
231
  text: streaming || getFlowOutput(result.messages) || "(running...)",
233
232
  },
234
233
  ],
235
- details: makeDetails([result]),
234
+ details: makeDetails([{ ...result, usage: mergedUsage }]),
236
235
  });
237
236
  };
238
237
 
239
- // Write forked session snapshot to temp file
240
- const forkTmp = writeFlowSessionToTempFile(flow.name, forkSessionSnapshotJsonl);
241
- const forkSessionTmpDir = forkTmp.dir;
242
- const forkSessionTmpPath = forkTmp.filePath;
238
+ // Write forked session snapshot to temp file only when provided
239
+ let forkSessionTmpDir: string | null = null;
240
+ let forkSessionTmpPath: string | null = null;
241
+ if (forkSessionSnapshotJsonl) {
242
+ const forkTmp = writeFlowSessionToTempFile(flow.name, forkSessionSnapshotJsonl);
243
+ forkSessionTmpDir = forkTmp.dir;
244
+ forkSessionTmpPath = forkTmp.filePath;
245
+ }
243
246
 
244
247
  try {
245
248
  const piArgs = buildFlowArgs(
package/index.ts CHANGED
@@ -36,7 +36,7 @@ const FLOW_PREVENT_CYCLES_ENV = "PI_FLOW_PREVENT_CYCLES";
36
36
 
37
37
  const FlowItem = Type.Object({
38
38
  type: Type.String({
39
- description: "Flow type. Must match an available flow name exactly: explore, debug, code, architect, review.",
39
+ description: "Flow type. Must match an available flow name exactly: explore, debug, code, architect, review, brainstorm.",
40
40
  }),
41
41
  intent: Type.String({
42
42
  description: "Clear, specific mission for this flow.",
@@ -371,6 +371,7 @@ Before acting, reason about whether to dive into a flow:
371
371
  - [code] — when you are ready to build. Implement features, fix bugs, write tests.
372
372
  - [architect] — when you need a plan. Design structure, break down requirements before building.
373
373
  - [review] — when you need to verify. Audit security, quality, correctness.
374
+ - [brainstorm] — when you need fresh ideas. Start from a clean slate with only the intent.
374
375
 
375
376
  Multiple independent flows? Batch them into one call:
376
377
 
@@ -416,17 +417,10 @@ flow [type] accomplished
416
417
  const { flows } = discovery;
417
418
  const makeDetails = makeFlowDetailsFactory(discovery.projectFlowsDir);
418
419
 
419
- // Build fork session snapshot (shared across all flows)
420
+ // Build fork session snapshot (shared across all flows that inherit context)
420
421
  const forkSessionSnapshotJsonl = buildForkSessionSnapshotJsonl(
421
422
  ctx.sessionManager,
422
423
  );
423
- if (!forkSessionSnapshotJsonl) {
424
- return {
425
- content: [{ type: "text", text: "Cannot use fork mode: failed to snapshot current session context." }],
426
- details: makeDetails([]),
427
- isError: true,
428
- };
429
- }
430
424
 
431
425
  // Collect all requested flow names
432
426
  const requested = new Set(params.flow.map((f) => f.type));
@@ -513,13 +507,14 @@ flow [type] accomplished
513
507
  const effectiveMaxDepth =
514
508
  targetFlow?.maxDepth !== undefined ? targetFlow.maxDepth : maxDepth;
515
509
 
510
+ const shouldInheritContext = targetFlow?.inheritContext !== false;
516
511
  const result = await runFlow({
517
512
  cwd: ctx.cwd,
518
513
  flows,
519
514
  flowName: item.type,
520
515
  intent: item.intent,
521
516
  taskCwd: item.cwd,
522
- forkSessionSnapshotJsonl,
517
+ forkSessionSnapshotJsonl: shouldInheritContext ? forkSessionSnapshotJsonl : null,
523
518
  parentDepth: currentDepth,
524
519
  parentFlowStack: ancestorFlowStack,
525
520
  maxDepth: effectiveMaxDepth,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-flow",
3
- "version": "0.1.0-alpha.7",
3
+ "version": "0.2.3",
4
4
  "description": "Flow-state delegation extension for Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/render-utils.ts CHANGED
@@ -21,18 +21,67 @@ export function formatFlowUsage(usage: Partial<UsageStats>, model?: string): str
21
21
  if (usage.cacheWrite) parts.push(`CW:${formatTokens(usage.cacheWrite)}`);
22
22
  if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
23
23
  if (usage.contextTokens && usage.contextTokens > 0) parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
24
- if (model) parts.push(model);
24
+ if (model) parts.push(`model:${model}`);
25
25
  return parts.join(" ");
26
26
  }
27
27
 
28
+ /**
29
+ * Format a token count to exactly 5 characters with leading spaces.
30
+ * Shifts from k to M when value would exceed 5 chars.
31
+ * Examples: 500 → " 500", 1300 → " 1.3k", 32000 → "32.0k", 950500 → "0.95M"
32
+ */
33
+ export function formatFixedTokens(count: number): string {
34
+ if (count < 1000) {
35
+ return count.toString().padStart(5);
36
+ }
37
+
38
+ const k = count / 1000;
39
+ if (k < 100) {
40
+ return (k.toFixed(1) + "k").padStart(5);
41
+ } else if (k < 1000) {
42
+ const m = count / 1000000;
43
+ return (m.toFixed(2) + "M").padStart(5);
44
+ } else {
45
+ const m = count / 1000000;
46
+ return (m.toFixed(2) + "M").padStart(5);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Format flow type name to fixed width (10 chars) in uppercase with dot padding.
52
+ * Examples: "debug" → "DEBUG.....", "architect" → "ARCHITECT.", "brainstorm" → "BRAINSTORM"
53
+ */
54
+ export function formatFlowTypeName(type: string): string {
55
+ const upper = type.toUpperCase();
56
+ const targetWidth = 10;
57
+ if (upper.length >= targetWidth) return upper.slice(0, targetWidth);
58
+ return upper + ".".repeat(targetWidth - upper.length);
59
+ }
60
+
28
61
  export function formatCompactStats(usage: Partial<UsageStats>, model?: string): string {
29
62
  const parts: string[] = [];
30
- parts.push(`↑${formatTokens(usage.input || 0)}`);
31
- parts.push(`↓${formatTokens(usage.output || 0)}`);
32
- if (usage.cacheRead) parts.push(`cr:${formatTokens(usage.cacheRead)}`);
33
- if (usage.contextTokens && usage.contextTokens > 0) parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
34
- if (model) parts.push(model);
35
- return parts.join(" ");
63
+ parts.push(`${formatFixedTokens(usage.input || 0)}↑`);
64
+ parts.push(`${formatFixedTokens(usage.output || 0)}↓`);
65
+ if (usage.cacheRead) parts.push(`cr:${formatFixedTokens(usage.cacheRead)}`);
66
+ if (usage.contextTokens && usage.contextTokens > 0) parts.push(`ctx:${formatFixedTokens(usage.contextTokens)}`);
67
+ return `[ ${parts.join(" ")} ]${model ? ` ─ ${model}` : ""}`;
68
+ }
69
+
70
+ /**
71
+ * Format usage stats for expanded view with context tokens on separate line.
72
+ * Returns stats line and optional context tokens line.
73
+ * Example: { stats: "[12.4k↑ 2.1k↓ cr:85.0k] ─ mimo-v2.5-pro", contextTokens: "ctx: 32.0k" }
74
+ */
75
+ export function formatExpandedStats(usage: Partial<UsageStats>, model?: string): { stats: string; contextTokens: string | null } {
76
+ const parts: string[] = [];
77
+ parts.push(`${formatFixedTokens(usage.input || 0)}↑`);
78
+ parts.push(`${formatFixedTokens(usage.output || 0)}↓`);
79
+ if (usage.cacheRead) parts.push(`cr:${formatFixedTokens(usage.cacheRead)}`);
80
+
81
+ const stats = `[${parts.join(" ")}]${model ? ` ─ ${model}` : ""}`;
82
+ const contextTokens = usage.contextTokens && usage.contextTokens > 0 ? `ctx:${formatFixedTokens(usage.contextTokens)}` : null;
83
+
84
+ return { stats, contextTokens };
36
85
  }
37
86
 
38
87
  export function truncateChars(text: string, max: number): string {
package/render.ts CHANGED
@@ -17,10 +17,12 @@ import {
17
17
  aggregateFlowUsage,
18
18
  getFlowDisplayItems,
19
19
  getFlowOutput,
20
+ getLastToolCall,
21
+ getLastAssistantText,
20
22
  isFlowError,
21
23
  isFlowSuccess,
22
24
  } from "./types.js";
23
- import { formatTokens, formatFlowUsage, formatCompactStats, truncateChars, tailText } from "./render-utils.js";
25
+ import { formatTokens, formatFixedTokens, formatFlowUsage, formatCompactStats, formatExpandedStats, formatFlowTypeName, truncateChars, tailText } from "./render-utils.js";
24
26
 
25
27
  function shortenPath(p: string): string {
26
28
  const home = os.homedir();
@@ -109,19 +111,11 @@ function flowStatusIcon(r: SingleResult, theme: { fg: ThemeFg }): string {
109
111
  // renderFlowCall — shown while the flow is being invoked
110
112
  // ---------------------------------------------------------------------------
111
113
 
112
- function truncateText(text: string): string {
113
- const words = text.split(/\s+/);
114
- if (words.length <= 12) return text;
115
- return `${words.slice(0, 3).join(" ")} ... ${words.slice(-8).join(" ")}`;
116
- }
117
-
118
114
  export function renderFlowCall(args: Record<string, any>, theme: FlowTheme): Text {
119
115
  const flows = args.flow as Array<{ type: string; intent: string }> | undefined;
120
116
 
121
117
  // Minimal — renderFlowResult owns the full display
122
118
  return new Text("", 0, 0);
123
-
124
- return new Text(theme.fg("muted", "(empty flow call)"), 0, 0);
125
119
  }
126
120
 
127
121
  // ---------------------------------------------------------------------------
@@ -179,14 +173,24 @@ function renderFlowExpanded(
179
173
  const mdTheme = getMarkdownTheme();
180
174
  const container = new Container();
181
175
 
182
- // Header
183
- let header = `${icon} ${theme.fg("toolTitle", theme.bold(`[${r.type}]`))}${theme.fg("muted", ` (${r.agentSource})`)}`;
176
+ // Header: uppercase type name with dots, no icon, no source
177
+ const typeName = formatFlowTypeName(r.type);
178
+ let header = theme.fg("toolTitle", theme.bold(typeName));
184
179
  if (error && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
185
180
  container.addChild(new Text(header, 0, 0));
186
181
  if (error && r.errorMessage) {
187
182
  container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
188
183
  }
189
184
 
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}` : ""}`;
192
+ container.addChild(new Text(theme.fg("dim", inlineStats), 0, 0));
193
+
190
194
  // Intent
191
195
  container.addChild(new Spacer(1));
192
196
  container.addChild(new Text(theme.fg("muted", "─── Intent ───"), 0, 0));
@@ -210,13 +214,6 @@ function renderFlowExpanded(
210
214
  container.addChild(new Text(toolTraces, 0, 0));
211
215
  }
212
216
 
213
- // Usage
214
- const usageStr = formatFlowUsage(r.usage, r.model);
215
- if (usageStr) {
216
- container.addChild(new Spacer(1));
217
- container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
218
- }
219
-
220
217
  return container;
221
218
  }
222
219
 
@@ -231,23 +228,31 @@ function renderFlowCollapsed(
231
228
  streamingText?: string,
232
229
  ): Text {
233
230
  const stats = formatCompactStats(r.usage, r.model);
234
- let text = `${theme.bg("selectedBg", theme.fg("accent", theme.bold(r.type)))} ${theme.fg("dim", "─")} ${theme.fg("dim", stats)}`;
231
+ const typeName = formatFlowTypeName(r.type);
232
+ let text = `${theme.bg("selectedBg", theme.fg("accent", theme.bold(typeName)))} ${theme.fg("dim", "─")} ${theme.fg("dim", stats)}`;
235
233
  if (error && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
236
234
 
237
- // Intent line
235
+ // DIR: line (intent/objective)
238
236
  if (r.intent) {
239
- text += `\n${theme.fg("dim", `├─ int:`)} ${theme.fg("dim", truncateChars(r.intent, 40))}`;
237
+ text += `\n${theme.fg("dim", "├─ DIR:")} ${theme.fg("dim", truncateChars(r.intent, 50))}`;
238
+ }
239
+
240
+ // EXE: line (last tool call)
241
+ const lastTool = getLastToolCall(r.messages);
242
+ if (lastTool) {
243
+ const exeStr = formatFlowToolCall(lastTool.name, lastTool.args, theme.fg.bind(theme));
244
+ text += `\n${theme.fg("dim", "├─ EXE:")} ${exeStr}`;
240
245
  }
241
246
 
242
- // Output line
247
+ // LOG: line (last assistant text or streaming)
243
248
  if (flowOutput) {
244
- text += `\n${theme.fg("dim", "└─ msg:")} ${renderFlowReport(truncateChars(flowOutput, 25), theme)}`;
249
+ text += `\n${theme.fg("dim", "└─ LOG:")} ${theme.fg("dim", truncateChars(flowOutput, 50))}`;
245
250
  } else if (streamingText) {
246
- text += `\n${theme.fg("dim", "└─ msg:")} ${theme.fg("dim", tailText(streamingText, 40))}`;
251
+ text += `\n${theme.fg("dim", "└─ LOG:")} ${theme.fg("dim", tailText(streamingText, 50))}`;
247
252
  } else if (error && r.errorMessage) {
248
- text += `\n${theme.fg("dim", "└─ msg:")} ${theme.fg("error", truncateChars(r.errorMessage, 25))}`;
253
+ text += `\n${theme.fg("dim", "└─ LOG:")} ${theme.fg("error", truncateChars(r.errorMessage, 50))}`;
249
254
  } else {
250
- text += `\n${theme.fg("dim", "└─ msg:")} ${theme.fg("dim", "[n/a]")}`;
255
+ text += `\n${theme.fg("dim", "└─ LOG:")} ${theme.fg("dim", "[n/a]")}`;
251
256
  }
252
257
 
253
258
  return new Text(text, 0, 0);
@@ -282,19 +287,32 @@ function renderMultiFlowExpanded(
282
287
  const mdTheme = getMarkdownTheme();
283
288
  const container = new Container();
284
289
 
290
+ // Summary: just show count, no icon
285
291
  container.addChild(new Text(
286
- `${icon} ${theme.fg("toolTitle", theme.bold("flow "))}${theme.fg("accent", `${successCount}/${results.length} flows`)}`,
292
+ theme.fg("accent", `${results.length} flows`),
287
293
  0, 0,
288
294
  ));
289
295
 
290
296
  for (const r of results) {
291
- const rIcon = flowStatusIcon(r, theme);
292
297
  const displayItems = getFlowDisplayItems(r.messages);
293
298
  const flowOutput = getFlowOutput(r.messages);
299
+ const typeName = formatFlowTypeName(r.type);
294
300
 
295
301
  container.addChild(new Spacer(1));
296
- container.addChild(new Text(`${theme.fg("muted", "─── ")}${theme.fg("accent", `[${r.type}]`)} ${rIcon}`, 0, 0));
297
- container.addChild(new Text(theme.fg("muted", "Intent: ") + theme.fg("dim", r.intent), 0, 0));
302
+ // Per-flow header: ─── EXPLORER (no icon)
303
+ container.addChild(new Text(`${theme.fg("muted", "─── ")}${theme.fg("accent", typeName)}`, 0, 0));
304
+
305
+ // Stats: all-in-one bracket format with context inline
306
+ const flowParts: string[] = [];
307
+ flowParts.push(`${formatFixedTokens(r.usage.input || 0)}↑`);
308
+ flowParts.push(`${formatFixedTokens(r.usage.output || 0)}↓`);
309
+ if (r.usage.cacheRead) flowParts.push(`cr:${formatFixedTokens(r.usage.cacheRead)}`);
310
+ if (r.usage.contextTokens > 0) flowParts.push(`ctx:${formatFixedTokens(r.usage.contextTokens)}`);
311
+ const flowStats = `[ ${flowParts.join(" ")} ]${r.model ? ` ─ ${r.model}` : ""}`;
312
+ container.addChild(new Text(theme.fg("dim", flowStats), 0, 0));
313
+
314
+ // Intent: just show text, no prefix
315
+ container.addChild(new Text(theme.fg("dim", r.intent), 0, 0));
298
316
 
299
317
  if (flowOutput) {
300
318
  container.addChild(new Spacer(1));
@@ -308,65 +326,83 @@ function renderMultiFlowExpanded(
308
326
  container.addChild(new Text(theme.fg("muted", "─── Tool Calls ───"), 0, 0));
309
327
  container.addChild(new Text(toolTraces, 0, 0));
310
328
  }
311
-
312
- const taskUsage = formatFlowUsage(r.usage, r.model);
313
- if (taskUsage) container.addChild(new Text(theme.fg("dim", taskUsage), 0, 0));
314
329
  }
315
330
 
316
- const totalUsage = formatFlowUsage(aggregateFlowUsage(results));
317
- if (totalUsage) {
318
- container.addChild(new Spacer(1));
319
- container.addChild(new Text(theme.fg("dim", `Total: ${totalUsage}`), 0, 0));
320
- }
331
+ // Total stats: all-in-one bracket format with context inline
332
+ const totalUsage = aggregateFlowUsage(results);
333
+ const totalModel = results[0]?.model;
334
+ const totalParts: string[] = [];
335
+ totalParts.push(`${formatFixedTokens(totalUsage.input || 0)}↑`);
336
+ totalParts.push(`${formatFixedTokens(totalUsage.output || 0)}↓`);
337
+ if (totalUsage.cacheRead) totalParts.push(`cr:${formatFixedTokens(totalUsage.cacheRead)}`);
338
+ if (totalUsage.contextTokens > 0) totalParts.push(`ctx:${formatFixedTokens(totalUsage.contextTokens)}`);
339
+ const totalStats = `[ ${totalParts.join(" ")} ]${totalModel ? ` ─ ${totalModel}` : ""}`;
340
+ container.addChild(new Spacer(1));
341
+ container.addChild(new Text(theme.fg("dim", totalStats), 0, 0));
321
342
 
322
343
  return container;
323
344
  }
324
345
 
325
- function renderMultiFlowCollapsed(
346
+ function renderActivityPanel(
326
347
  results: SingleResult[],
327
348
  theme: FlowTheme,
328
- ): Text {
329
- let text = "";
349
+ ): Container {
350
+ const container = new Container();
330
351
 
331
352
  for (let i = 0; i < results.length; i++) {
332
353
  const r = results[i];
333
354
  const isLast = i === results.length - 1;
334
- const flowOutput = getFlowOutput(r.messages);
335
355
  const stats = formatCompactStats(r.usage, r.model);
336
356
  const error = isFlowError(r);
357
+ const typeName = formatFlowTypeName(r.type);
337
358
 
338
359
  // Header line
339
360
  const headerPrefix = isLast ? "└─" : "├─";
340
- let line = `${theme.fg("dim", headerPrefix)} ${theme.bg("selectedBg", theme.fg("accent", theme.bold(r.type)))}`;
341
- if (stats) {
342
- line += ` ${theme.fg("dim", "─")} ${theme.fg("dim", stats)}`;
343
- }
361
+ let headerLine = `${theme.fg("dim", headerPrefix)} ${theme.bg("selectedBg", theme.fg("accent", theme.bold(typeName)))} ${theme.fg("dim", "─")} ${theme.fg("dim", stats)}`;
344
362
  if (error && r.stopReason) {
345
- line += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
363
+ headerLine += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
346
364
  }
347
- if (i > 0) text += "\n";
348
- text += line;
365
+ container.addChild(new Text(headerLine, 0, 0));
349
366
 
350
367
  // Continuation indent for sub-lines
351
368
  const indent = isLast ? " " : "│ ";
352
369
 
353
- // Intent line
370
+ // DIR: line (intent/objective)
354
371
  if (r.intent) {
355
- const intentPrefix = hasMsg ? "├─" : "└─";
356
- text += `\n${theme.fg("dim", indent + intentPrefix + " int:")} ${theme.fg("dim", truncateChars(r.intent, 40))}`;
372
+ container.addChild(new Text(`${theme.fg("dim", indent + "├─ DIR:")} ${theme.fg("dim", truncateChars(r.intent, 50))}`, 0, 0));
357
373
  }
358
374
 
359
- // Output line
360
- if (flowOutput) {
361
- text += `\n${theme.fg("dim", indent + "└─ msg:")} ${renderFlowReport(truncateChars(flowOutput, 25), theme)}`;
375
+ // EXE: line (last tool call)
376
+ const lastTool = getLastToolCall(r.messages);
377
+ if (lastTool) {
378
+ const exeStr = formatFlowToolCall(lastTool.name, lastTool.args, theme.fg.bind(theme));
379
+ container.addChild(new Text(`${theme.fg("dim", indent + "├─ EXE:")} ${exeStr}`, 0, 0));
380
+ }
381
+
382
+ // LOG: line (last assistant text)
383
+ const lastText = getLastAssistantText(r.messages);
384
+ if (lastText) {
385
+ container.addChild(new Text(`${theme.fg("dim", indent + "└─ LOG:")} ${theme.fg("dim", truncateChars(lastText, 50))}`, 0, 0));
362
386
  } else if (error && r.errorMessage) {
363
- text += `\n${theme.fg("dim", indent + "└─ msg:")} ${theme.fg("error", truncateChars(r.errorMessage, 25))}`;
387
+ container.addChild(new Text(`${theme.fg("dim", indent + "└─ LOG:")} ${theme.fg("error", truncateChars(r.errorMessage, 50))}`, 0, 0));
364
388
  } else {
365
- text += `\n${theme.fg("dim", indent + "└─ msg:")} ${theme.fg("dim", "[n/a]")}`;
389
+ container.addChild(new Text(`${theme.fg("dim", indent + "└─ LOG:")} ${theme.fg("dim", "[n/a]")}`, 0, 0));
390
+ }
391
+
392
+ // Add blank line separator between flows (with continuation pipe)
393
+ if (!isLast) {
394
+ container.addChild(new Text(theme.fg("dim", "│"), 0, 0));
366
395
  }
367
396
  }
368
397
 
369
- text += `\n${theme.fg("muted", "(Ctrl+O to expand tool traces)")}`;
398
+ container.addChild(new Text(theme.fg("muted", "(Ctrl+O to expand tool traces)"), 0, 0));
370
399
 
371
- return new Text(text, 0, 0);
400
+ return container;
401
+ }
402
+
403
+ function renderMultiFlowCollapsed(
404
+ results: SingleResult[],
405
+ theme: FlowTheme,
406
+ ): Container {
407
+ return renderActivityPanel(results, theme);
372
408
  }
package/runner-events.js CHANGED
@@ -44,6 +44,46 @@ export function drainStreamingText(result) {
44
44
  return buf;
45
45
  }
46
46
 
47
+ // ---------------------------------------------------------------------------
48
+ // Streaming token estimate
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /** Chars per token heuristic for output estimation. */
52
+ const CHARS_PER_TOKEN = 4;
53
+
54
+ function getStreamingEstimate(result) {
55
+ if (!Object.prototype.hasOwnProperty.call(result, "__streamingEstimate")) {
56
+ Object.defineProperty(result, "__streamingEstimate", {
57
+ value: { chars: 0 },
58
+ enumerable: false,
59
+ configurable: false,
60
+ writable: true,
61
+ });
62
+ }
63
+ return result.__streamingEstimate;
64
+ }
65
+
66
+ /**
67
+ * Track streaming characters and estimate output tokens.
68
+ * Called on every streaming delta.
69
+ */
70
+ function updateStreamingEstimate(result, deltaLength) {
71
+ if (deltaLength <= 0) return;
72
+ const est = getStreamingEstimate(result);
73
+ est.chars += deltaLength;
74
+ }
75
+
76
+ /**
77
+ * Drain the current streaming estimate and return estimated output tokens.
78
+ * Returns 0 when no streaming has occurred.
79
+ */
80
+ export function drainStreamingEstimate(result) {
81
+ const est = getStreamingEstimate(result);
82
+ const tokens = Math.floor(est.chars / CHARS_PER_TOKEN);
83
+ est.chars = 0;
84
+ return tokens;
85
+ }
86
+
47
87
  /**
48
88
  * Accumulate a text or thinking delta into the streaming buffer.
49
89
  * Returns true if the caller should emit an update.
@@ -52,6 +92,7 @@ function accumulateStreamingDelta(result, delta) {
52
92
  if (!delta) return false;
53
93
  const buf = getStreamingTextBuffer(result);
54
94
  result.__streamingTextBuffer = buf + delta;
95
+ updateStreamingEstimate(result, delta.length);
55
96
  if (result.__streamingTextBuffer.length - result.__lastEmittedWordCount >= 40) {
56
97
  result.__lastEmittedWordCount = result.__streamingTextBuffer.length;
57
98
  return true;
@@ -97,6 +138,10 @@ function addFlowAssistantMessage(result, message) {
97
138
 
98
139
  result.messages.push(message);
99
140
 
141
+ // Reset streaming estimate when actual usage arrives
142
+ const est = getStreamingEstimate(result);
143
+ est.chars = 0;
144
+
100
145
  result.usage.turns++;
101
146
  const usage = message.usage;
102
147
  if (usage) {
package/types.ts CHANGED
@@ -140,10 +140,46 @@ export function getFlowDisplayItems(messages: Message[]): DisplayItem[] {
140
140
  if (part.type === "text") {
141
141
  items.push({ type: "text", text: part.text });
142
142
  } else if (part.type === "toolCall") {
143
- items.push({ type: "toolCall", name: part.name, args: part.arguments });
143
+ const name = part.name ?? part.toolName ?? "unknown";
144
+ const args = (part.arguments ?? part.input ?? {}) as Record<string, unknown>;
145
+ items.push({ type: "toolCall", name, args });
144
146
  }
145
147
  }
146
148
  }
147
149
  }
148
150
  return items;
149
151
  }
152
+
153
+ /** Extract the last tool call from message history. */
154
+ export function getLastToolCall(messages: Message[]): DisplayItem | undefined {
155
+ for (let i = messages.length - 1; i >= 0; i--) {
156
+ const msg = messages[i];
157
+ if (msg.role === "assistant") {
158
+ for (let j = msg.content.length - 1; j >= 0; j--) {
159
+ const part = msg.content[j];
160
+ if (part.type === "toolCall") {
161
+ const name = part.name ?? part.toolName ?? "unknown";
162
+ const args = (part.arguments ?? part.input ?? {}) as Record<string, unknown>;
163
+ return { type: "toolCall", name, args };
164
+ }
165
+ }
166
+ }
167
+ }
168
+ return undefined;
169
+ }
170
+
171
+ /** Extract the last assistant text from message history. */
172
+ export function getLastAssistantText(messages: Message[]): string {
173
+ for (let i = messages.length - 1; i >= 0; i--) {
174
+ const msg = messages[i];
175
+ if (msg.role === "assistant") {
176
+ for (let j = msg.content.length - 1; j >= 0; j--) {
177
+ const part = msg.content[j];
178
+ if (part.type === "text" && part.text.trim()) {
179
+ return part.text.trim();
180
+ }
181
+ }
182
+ }
183
+ }
184
+ return "";
185
+ }