pi-taskflow 0.0.19 → 0.0.21

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/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to pi-taskflow are documented here. This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
4
4
 
5
+ ## [0.0.21] — 2026-06-10
6
+
7
+ ### Added
8
+ - **Per-step context pre-read in shorthand modes.** Single, chain, and tasks shorthand steps now accept `context` (file paths) and `contextLimit`, desugared directly onto the generated phases. This eliminates `O(N²)` file exploration without writing the full DSL. In parallel `tasks` mode all branches share the deduped union of step contexts; chain steps each carry their own context. A top-level `context` in chain mode produces a warning (no unsupported flow-level default). Context-file changes automatically invalidate phase caches.
9
+
10
+ ### Fixed
11
+ - **Headless approval safety.** Approval phases now auto-reject (not auto-approve) when running in detached/background/CI mode, preventing silent bypass of human gates.
12
+ - **Step-reference validator accepts transitive ancestors.** The step-reference checker previously raised false positives on valid DAGs where dependencies span multiple levels of ancestry. Ancestor transitive closure is now fully resolved.
13
+
14
+ ## [0.0.20] — 2026-06-10
15
+
16
+ ### Added
17
+ - **Background (detached) execution — `detach: true`.** Run a taskflow in a detached child process without blocking the current session. Pass `detach: true` and get a `runId` back immediately; the flow executes in the background, persisting state to the store. Status polled via `/tf runs` and `resume` works as normal.
18
+ - `extensions/detached-runner.ts` (new): lightweight child-process entry script — reads serialized context, calls `executeTaskflow`, persists terminal state.
19
+ - `extensions/index.ts`: `detach: Boolean` parameter on the taskflow tool + child-process spawn logic (records PID in `RunState`).
20
+ - `extensions/store.ts`: `RunState` gains `pid?: number` + `detached?: boolean` fields; `isProcessAlive(pid)` stale-PID helper.
21
+ - Design: entry-point spawn wrapper — zero changes to the 1340-line `runtime.ts` core, no new phase type, no DSL version bump, fully backward-compatible.
22
+ - Approval phases auto-reject in background mode. Idle watchdog kills stalled children. Stale PID detection via signal-0 probe.
23
+ - 8 new tests (`test/detached.test.ts`): process-alive, PID persistence, end-to-end detached, crash→failed, resume after failure, stale PID, backward compat.
24
+
25
+ ### Fixed
26
+ - `approvalView` initialization robustness: throws a clear error when the approval view module is unavailable, preventing silent failures in detached/background mode.
27
+
5
28
  ## [0.0.19] — 2026-06-10
6
29
 
7
30
  ### Documentation
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-43D9AD?style=flat-square" alt="MIT license"></a>
9
9
  <a href="#whats-inside"><img src="https://img.shields.io/badge/runtime%20deps-0-43D9AD?style=flat-square" alt="zero runtime dependencies"></a>
10
10
  <a href="https://github.com/heggria/pi-taskflow/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/heggria/pi-taskflow/ci.yml?branch=main&style=flat-square&label=CI" alt="CI status"></a>
11
- <a href="#whats-inside"><img src="https://img.shields.io/badge/tests-535-6E8BFF?style=flat-square" alt="535 tests"></a>
11
+ <a href="#whats-inside"><img src="https://img.shields.io/badge/tests-601-6E8BFF?style=flat-square" alt="601 tests"></a>
12
12
  <a href="#whats-inside"><img src="https://img.shields.io/badge/dogfooded-%E2%9C%93-43D9AD?style=flat-square" alt="dogfooded"></a>
13
13
  <a href="https://pi.dev"><img src="https://img.shields.io/badge/for-Pi%20coding%20agent-B692FF?style=flat-square" alt="for the Pi coding agent"></a>
14
14
  </p>
@@ -131,7 +131,7 @@ The Pi ecosystem now has **20+ delegation, workflow, and orchestration extension
131
131
  - **`pi-subagents` / `@gotgenes/pi-subagents`** are the mature picks for ad-hoc "use reviewer on this diff" delegation and background jobs. `pi-taskflow` is for when those delegations need to become a *repeatable, resumable pipeline*.
132
132
  - **`pi-pipeline` / `pi-agent-flow`** ship *opinionated, fixed* flows. `pi-taskflow` ships an *empty canvas*: you (or the model) declare the graph that fits the job.
133
133
 
134
- > The honest one-liner: **`pi-taskflow` is the only Pi extension that gives you a *declarative, verifiable, resumable* DAG of task nodes — saved as a one-word command, with zero runtime dependencies and context isolation by design.** Where code-mode workflows let the model *script* the work, `pi-taskflow` lets it *declare a graph the runtime can prove correct before running.* The known gaps it's closing next: loop-until-done, worktree isolation, and non-blocking background runs (see [`STRATEGY.md`](./STRATEGY.md)).
134
+ > The honest one-liner: **`pi-taskflow` is the only Pi extension that gives you a *declarative, verifiable, resumable* DAG of task nodes — saved as a one-word command, with zero runtime dependencies and context isolation by design.** Where code-mode workflows let the model *script* the work, `pi-taskflow` lets it *declare a graph the runtime can prove correct before running.* The known gaps it's closing next: worktree isolation (see [`STRATEGY.md`](./STRATEGY.md)).
135
135
 
136
136
  ## 30-second start
137
137
 
@@ -304,7 +304,7 @@ Flow-level keys: `name`, `description`, `args`, `concurrency` (default 8), `agen
304
304
  - **`when`** — skip a phase unless an expression is truthy. Supports `{refs}`, `== != < > <= >=`, `&& || !`, parentheses, and quoted strings/numbers. Pair with `join: "any"` on the merge phase for real if/else routing. Parse errors **fail open**.
305
305
  - **`join: "any"`** — an OR-join: the phase runs as soon as *one* dependency completes (default `"all"` waits for all).
306
306
  - **`retry`** — `{ "max": 2, "backoffMs": 500, "factor": 2 }` retries a failing subagent with fixed or exponential backoff; usage is summed and the attempt count shows as `↻N` in the TUI. Transient provider errors (rate-limit / 5xx / timeout) **auto-retry even without an explicit policy**; hard errors don't.
307
- - **`approval`** — pause for a human (Approve / Reject / Edit). Reject halts the flow; Edit injects the typed note as the phase output for downstream steps. Non-interactive runs auto-approve.
307
+ - **`approval`** — pause for a human (Approve / Reject / Edit). Reject halts the flow; Edit injects the typed note as the phase output for downstream steps. Non-interactive runs auto-reject (safety: approval gates are never bypassed).
308
308
  - **`flow`** — `{ "type": "flow", "use": "deep-research", "with": { "topic": "{item}" } }` runs a **saved** flow as a phase (recursion is detected and rejected). Or **generate the sub-flow at runtime**: `{ "type": "flow", "def": "{steps.plan.json}" }` resolves an upstream phase's JSON output into a sub-flow, **validates it (cycles / dangling refs / duplicate ids), then runs it** — the number and shape of the generated phases is decided at runtime, not authored in advance. A malformed plan fails *open* (the phase is skipped with a `defError`, the run continues). This is how a planner decides *at runtime* what work to spawn — the declarative answer to a code-mode `for` loop, with each generated plan checked before it spends a token. Pair it with `loop` for **data-dependent iterative replanning** (round N's plan depends on round N-1's result). See [`examples/dynamic-plan-execute.json`](./examples/dynamic-plan-execute.json) and [`examples/iterative-replan.json`](./examples/iterative-replan.json).
309
309
 
310
310
  ### Loop-until-done (`loop`)
@@ -434,7 +434,7 @@ Resume is keyed on each phase's input hash — if an upstream output changed, de
434
434
  ```
435
435
  .pi/taskflows/<name>.json # project-scoped definitions (commit to share)
436
436
  ~/.pi/agent/taskflows/<name>.json # user-scoped definitions
437
- .pi/taskflows/runs/<runId>.json # run state for resume (gitignore this)
437
+ .pi/taskflows/runs/<flowName>/<runId>.json # run state for resume (gitignore this)
438
438
  ```
439
439
 
440
440
  > Commit `.pi/taskflows/` and your whole team shares the pipelines — no config sync, no onboarding doc. Run state is written atomically and guarded by a zero-dependency file lock, so concurrent runs never corrupt the index.
@@ -608,12 +608,12 @@ Copy one into `.pi/taskflows/<name>.json` (or `~/.pi/agent/taskflows/`) and it r
608
608
 
609
609
  <div align="center">
610
610
 
611
- **0 runtime dependencies** · **535 tests** · **9 phase types** · **cross-session resume** · **cross-run memoization** · **~5.4k LOC runtime**
611
+ **0 runtime dependencies** · **601 tests** · **9 phase types** · **cross-session resume** · **cross-run memoization** · **~7.7k LOC runtime**
612
612
 
613
613
  </div>
614
614
 
615
615
  - **Zero runtime dependencies.** No `dependencies` field — the runtime is built entirely on Node built-ins (`fs` / `path` / `os` / `child_process` / `crypto`). The file lock is `fs.openSync("wx")`, not a third-party library.
616
- - **535 tests across 21 test files** covering concurrency, atomic file locking (8-process race regressions), path-traversal hardening, cross-session resume, cross-run cache freshness (flow/thinking/tools key isolation, fingerprint invalidation, TTL/LRU eviction), gate verdicts, budget caps, retry/backoff, approval flows, loop termination, tournament judging, sub-flow composition, callback isolation, the idle watchdog, model-role init config, and parseModelFromLabel with parenthesized-model-name regression.
616
+ - **601 tests across 25 test files** covering concurrency, atomic file locking (8-process race regressions), path-traversal hardening, cross-session resume, cross-run cache freshness (flow/thinking/tools key isolation, fingerprint invalidation, TTL/LRU eviction), gate verdicts, budget caps, retry/backoff, approval flows, loop termination, tournament judging, sub-flow composition, callback isolation, the idle watchdog, model-role init config, and parseModelFromLabel with parenthesized-model-name regression.
617
617
  - **Hardened by design.** Path-traversal defense (lexical + `realpath`), runId validation, HTML/error sanitization, atomic writes, stale-lock stealing via `rename`, and an idle watchdog that kills wedged subagents.
618
618
  - **Dogfooded.** Every new feature has to survive the project's own `self-improve` taskflow before it ships.
619
619
 
@@ -637,11 +637,11 @@ Our `self-improve` flow is a 10-phase DAG — it audits the codebase, patches de
637
637
 
638
638
  ## Status & limits
639
639
 
640
- **v0.0.17** — loop-until-done (`loop` phase: iterate to a condition, convergence, or cap), tournament (best-of-N with a judge), cross-run memoization (content-addressed cache with git/file/glob/env fingerprints and TTL), interactive `/tf init` with role-aware model pickers + diff preview + atomic merge-write, configurable built-in agents, 18 built-in agents with 6 model roles. Full control-flow & reliability layer (`when` guards, `join: any`, `retry`/backoff, `approval`, `flow` composition, `budget` caps, idle watchdog) on top of the DSL + DAG runtime (`agent`/`parallel`/`map`/`gate`/`reduce`). Inline + saved flows, cross-session resume, live progress, and isolated context. A run executes as one streaming tool call.
640
+ **v0.0.20** — loop-until-done (`loop` phase: iterate to a condition, convergence, or cap), tournament (best-of-N with a judge), cross-run memoization (content-addressed cache with git/file/glob/env fingerprints and TTL), interactive `/tf init` with role-aware model pickers + diff preview + atomic merge-write, configurable built-in agents, 18 built-in agents with 6 model roles. Full control-flow & reliability layer (`when` guards, `join: any`, `retry`/backoff, `approval`, `flow` composition, `budget` caps, idle watchdog) on top of the DSL + DAG runtime (`agent`/`parallel`/`map`/`gate`/`reduce`). Inline + saved flows, cross-session resume, live progress, and isolated context. A run executes as one streaming tool call.
641
641
 
642
642
  Known boundaries (tracked, bounded — no surprises mid-flow):
643
643
 
644
- - **No detached background execution.** A run needs the Pi session open. True background execution (and event/cron triggers on top of it) is on the roadmap.
644
+ - **Detached background execution (new).** Add `detach: true` to `action: "run"` to spawn the flow in a detached child process. The tool returns immediately with the `runId`; the flow continues running even if the host session exits. Status is polled via the store (`/tf runs` or `action: "resume"`). Approval phases auto-reject in detached mode.
645
645
  - **No `output: "file"`.** Outputs are text/JSON only — write files via an agent's `write` tool call.
646
646
  - **`map` requires a JSON array.** The `over` field must resolve to a `{steps.ID.json}` array. Wrap a text list in a single-agent `output: "json"` phase first.
647
647
  - **The DAG must be acyclic.** Cycles are rejected at validation.
@@ -74,6 +74,7 @@ export interface AgentConfig {
74
74
  filePath: string;
75
75
  }
76
76
 
77
+ /** @internal */
77
78
  export interface AgentDiscoveryResult {
78
79
  agents: AgentConfig[];
79
80
  projectAgentsDir: string | null;
@@ -224,7 +225,13 @@ export interface SubagentSettings {
224
225
  * E.g. `{{fast}}` → `openrouter/deepseek/deepseek-v4-flash` if modelRoles.fast is set.
225
226
  * Returns undefined if the value is not a role reference or the role is unmapped.
226
227
  */
227
- export function resolveModelRole(model: string | undefined, roles?: Record<string, string>): string | undefined {
228
+ /**
229
+ * Resolve `{{roleName}}` model references against a role→model mapping.
230
+ * E.g. `{{fast}}` → `openrouter/deepseek/deepseek-v4-flash` if modelRoles.fast is set.
231
+ * Returns undefined if the value is not a role reference or the role is unmapped.
232
+ * @internal
233
+ */
234
+ function resolveModelRole(model: string | undefined, roles?: Record<string, string>): string | undefined {
228
235
  if (!model || !roles) return model;
229
236
  const match = model.match(/^\{\{(\w+)\}\}$/);
230
237
  if (!match) return model;
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Modal approval dialog for `approval` phases (ctx.ui.custom with overlay).
3
+ *
4
+ * Rendered as a centered bordered popup: the full upstream output (e.g. a
5
+ * plan) is shown in a scrollable viewport so long content can be reviewed
6
+ * before deciding. Every line is padded to the full dialog width so the
7
+ * overlay composites cleanly (no see-through, no ghosting in scrollback).
8
+ *
9
+ * While the dialog is open, SGR mouse reporting is enabled so the wheel
10
+ * scrolls the viewport instead of the terminal scrollback. It is restored
11
+ * on dispose.
12
+ *
13
+ * Keys: wheel/↑↓ scroll · PgUp/PgDn page · Home/End jump ·
14
+ * a/Enter approve · e edit (guidance) · r/Esc reject.
15
+ */
16
+
17
+ import type { Theme } from "@earendil-works/pi-coding-agent";
18
+ import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
19
+
20
+ export type ApprovalChoice = "approve" | "reject" | "edit";
21
+
22
+ export interface ApprovalViewOptions {
23
+ /** Header title, e.g. "Taskflow approval — flow/phase". */
24
+ title: string;
25
+ /** Interpolated approval prompt. */
26
+ message: string;
27
+ /** Full upstream phase output (the content being approved). */
28
+ upstream?: string;
29
+ }
30
+
31
+ /** Minimal writer used to toggle terminal mouse reporting. */
32
+ export interface TerminalWriter {
33
+ write(data: string): void;
34
+ }
35
+
36
+ const FALLBACK_ROWS = 24;
37
+ /** Wheel ticks scroll this many lines. */
38
+ const WHEEL_STEP = 3;
39
+ /** SGR mouse sequence: ESC [ < B ; X ; Y (M|m) */
40
+ const MOUSE_SGR = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
41
+ /** Enable basic mouse tracking + SGR encoding. */
42
+ const MOUSE_ON = "\x1b[?1000h\x1b[?1006h";
43
+ /** Restore: disable SGR encoding + mouse tracking. */
44
+ const MOUSE_OFF = "\x1b[?1006l\x1b[?1000l";
45
+
46
+ export class ApprovalViewComponent {
47
+ private theme: Theme;
48
+ private opts: ApprovalViewOptions;
49
+ private onDone: (choice: ApprovalChoice) => void;
50
+ private getRows: () => number;
51
+ private term?: TerminalWriter;
52
+ private scrollOffset = 0;
53
+ private cachedWidth?: number;
54
+ private cachedBody?: string[];
55
+ private mouseEnabled = false;
56
+ private decided = false;
57
+
58
+ constructor(
59
+ theme: Theme,
60
+ opts: ApprovalViewOptions,
61
+ onDone: (choice: ApprovalChoice) => void,
62
+ getRows?: () => number,
63
+ term?: TerminalWriter,
64
+ ) {
65
+ this.theme = theme;
66
+ this.opts = opts;
67
+ this.onDone = onDone;
68
+ this.getRows = getRows ?? (() => FALLBACK_ROWS);
69
+ this.term = term;
70
+ this.enableMouse();
71
+ }
72
+
73
+ private enableMouse(): void {
74
+ if (this.term && !this.mouseEnabled) {
75
+ try {
76
+ this.term.write(MOUSE_ON);
77
+ this.mouseEnabled = true;
78
+ } catch {
79
+ // non-tty / closed stream — wheel support is best-effort
80
+ }
81
+ }
82
+ }
83
+
84
+ /** Restore terminal mouse state. Idempotent; call from the overlay's dispose. */
85
+ dispose(): void {
86
+ if (this.term && this.mouseEnabled) {
87
+ this.mouseEnabled = false;
88
+ try {
89
+ this.term.write(MOUSE_OFF);
90
+ } catch {
91
+ // ignore
92
+ }
93
+ }
94
+ }
95
+
96
+ private decide(choice: ApprovalChoice): void {
97
+ if (this.decided) return;
98
+ this.decided = true;
99
+ this.dispose();
100
+ this.onDone(choice);
101
+ }
102
+
103
+ private rows(): number {
104
+ try {
105
+ return this.getRows() || FALLBACK_ROWS;
106
+ } catch {
107
+ return FALLBACK_ROWS;
108
+ }
109
+ }
110
+
111
+ /** Visible body height given the message height — dialog targets ~80% of the terminal. */
112
+ private maxVisible(msgRows: number): number {
113
+ const avail = Math.max(10, Math.floor(this.rows() * 0.8));
114
+ // Chrome: top border, message rows, separator, scroll info, separator, hints, bottom border.
115
+ const chrome = 1 + msgRows + 1 + 1 + 1 + 1 + 1;
116
+ return Math.max(3, Math.min(avail - chrome, 60));
117
+ }
118
+
119
+ /** Wrap the upstream text to the viewport width (cached per width). */
120
+ private bodyLines(innerW: number): string[] {
121
+ if (this.cachedBody && this.cachedWidth === innerW) return this.cachedBody;
122
+ const out: string[] = [];
123
+ const upstream = (this.opts.upstream ?? "").replace(/\r\n/g, "\n").trimEnd();
124
+ if (upstream) {
125
+ for (const raw of upstream.split("\n")) {
126
+ if (!raw.trim()) {
127
+ out.push("");
128
+ continue;
129
+ }
130
+ for (const l of wrapTextWithAnsi(raw, innerW)) out.push(l);
131
+ }
132
+ }
133
+ this.cachedWidth = innerW;
134
+ this.cachedBody = out;
135
+ return out;
136
+ }
137
+
138
+ private msgLines(innerW: number): string[] {
139
+ const out: string[] = [];
140
+ for (const raw of this.opts.message.split("\n")) {
141
+ for (const l of wrapTextWithAnsi(raw, innerW)) out.push(l);
142
+ }
143
+ return out.length ? out : [""];
144
+ }
145
+
146
+ private maxOffset(totalLines: number, visible: number): number {
147
+ return Math.max(0, totalLines - visible);
148
+ }
149
+
150
+ private clampScroll(delta: number): void {
151
+ const total = this.cachedBody?.length ?? 0;
152
+ const visible = this.maxVisible(1);
153
+ const cap = this.maxOffset(total, visible);
154
+ this.scrollOffset = Math.max(0, Math.min(cap, this.scrollOffset + delta));
155
+ }
156
+
157
+ handleInput(data: string): void {
158
+ // Mouse events (SGR) — wheel scrolls, everything else is swallowed.
159
+ const mouse = MOUSE_SGR.exec(data);
160
+ if (mouse) {
161
+ const b = Number(mouse[1]);
162
+ if (b & 64) {
163
+ // Wheel: low two bits 0 = up, 1 = down.
164
+ if ((b & 3) === 0) this.clampScroll(-WHEEL_STEP);
165
+ else if ((b & 3) === 1) this.clampScroll(WHEEL_STEP);
166
+ }
167
+ return;
168
+ }
169
+ // Decisions
170
+ if (matchesKey(data, "return") || data === "a" || data === "y") {
171
+ this.decide("approve");
172
+ return;
173
+ }
174
+ if (data === "e") {
175
+ this.decide("edit");
176
+ return;
177
+ }
178
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || data === "r" || data === "n") {
179
+ this.decide("reject");
180
+ return;
181
+ }
182
+ // Scrolling (only meaningful when a body exists)
183
+ const page = this.maxVisible(1);
184
+ if (matchesKey(data, "up") || data === "k") {
185
+ this.clampScroll(-1);
186
+ } else if (matchesKey(data, "down") || data === "j") {
187
+ this.clampScroll(1);
188
+ } else if (matchesKey(data, "pageUp") || matchesKey(data, "ctrl+u")) {
189
+ this.clampScroll(-page);
190
+ } else if (matchesKey(data, "pageDown") || matchesKey(data, "ctrl+d") || matchesKey(data, "space")) {
191
+ this.clampScroll(page);
192
+ } else if (matchesKey(data, "home") || data === "g") {
193
+ this.scrollOffset = 0;
194
+ } else if (matchesKey(data, "end") || data === "G") {
195
+ this.clampScroll(Number.MAX_SAFE_INTEGER);
196
+ }
197
+ }
198
+
199
+ /** Pad `content` with spaces to exactly `w` visible columns (ANSI-aware). */
200
+ private pad(content: string, w: number): string {
201
+ const t = truncateToWidth(content, w);
202
+ return t + " ".repeat(Math.max(0, w - visibleWidth(t)));
203
+ }
204
+
205
+ /** A full-width dialog row: │ <content padded> │ */
206
+ private row(content: string, width: number): string {
207
+ const th = this.theme;
208
+ const inner = this.pad(content, Math.max(1, width - 4));
209
+ return th.fg("border", "│") + " " + inner + " " + th.fg("border", "│");
210
+ }
211
+
212
+ private hrule(width: number, left: string, right: string): string {
213
+ const th = this.theme;
214
+ return th.fg("border", left + "─".repeat(Math.max(0, width - 2)) + right);
215
+ }
216
+
217
+ render(width: number): string[] {
218
+ const th = this.theme;
219
+ const innerW = Math.max(20, width - 4);
220
+ const lines: string[] = [];
221
+
222
+ // Top border with embedded title
223
+ const title = truncateToWidth(` ${this.opts.title} `, Math.max(0, width - 6));
224
+ const fill = Math.max(0, width - 4 - visibleWidth(title));
225
+ lines.push(
226
+ th.fg("border", "╭─") + th.fg("accent", title) + th.fg("border", "─".repeat(fill) + "─╮"),
227
+ );
228
+
229
+ // Approval prompt
230
+ const msg = this.msgLines(innerW);
231
+ for (const l of msg) lines.push(this.row(th.fg("text", l), width));
232
+
233
+ // Scrollable upstream body
234
+ const body = this.bodyLines(innerW);
235
+ const visible = this.maxVisible(msg.length);
236
+ const cap = this.maxOffset(body.length, visible);
237
+ this.scrollOffset = Math.min(this.scrollOffset, cap);
238
+ if (body.length > 0) {
239
+ lines.push(this.hrule(width, "├", "┤"));
240
+ const slice = body.slice(this.scrollOffset, this.scrollOffset + visible);
241
+ while (slice.length < Math.min(visible, body.length)) slice.push("");
242
+ for (const l of slice) lines.push(this.row(l, width));
243
+ if (cap > 0) {
244
+ const above = this.scrollOffset;
245
+ const below = Math.max(0, body.length - visible - this.scrollOffset);
246
+ lines.push(
247
+ this.row(th.fg("dim", `↑${above} more · ↓${below} more (${body.length} lines)`), width),
248
+ );
249
+ }
250
+ }
251
+
252
+ // Key hints
253
+ lines.push(this.hrule(width, "├", "┤"));
254
+ const scrollHint = cap > 0 ? "wheel/↑↓/PgUp/PgDn scroll · " : "";
255
+ lines.push(this.row(th.fg("dim", `${scrollHint}a/Enter approve · e edit · r/Esc reject`), width));
256
+ lines.push(this.hrule(width, "╰", "╯"));
257
+ return lines;
258
+ }
259
+
260
+ invalidate(): void {
261
+ this.cachedWidth = undefined;
262
+ this.cachedBody = undefined;
263
+ }
264
+ }
@@ -135,6 +135,7 @@ export function resolveFingerprint(entries: string[] | undefined, cwd: string):
135
135
  // Cross-run cache store
136
136
  // ---------------------------------------------------------------------------
137
137
 
138
+ /** @internal */
138
139
  export interface CacheEntry {
139
140
  /** The full cache key (== phase inputHash incl. fingerprint). */
140
141
  key: string;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Detached runner — spawned as a child process for background (detached) runs.
3
+ *
4
+ * Reads a context JSON file (path passed as argv[2]), calls executeTaskflow,
5
+ * and persists the terminal state. Top-level try/catch writes status "failed"
6
+ * on crash. Approval phases auto-reject in detached mode (no interactive
7
+ * approver available).
8
+ *
9
+ * This file is NOT imported by index.ts — it is spawned via `child_process.spawn`.
10
+ */
11
+
12
+ import { readFileSync } from "node:fs";
13
+ import { type AgentScope, discoverAgents, readSubagentSettings } from "./agents.ts";
14
+ import { executeTaskflow } from "./runtime.ts";
15
+ import { getFlow, loadRun, saveRun, DEFAULT_KEPT_RUNS, DEFAULT_RUN_AGE_DAYS } from "./store.ts";
16
+
17
+ interface DetachContext {
18
+ runId: string;
19
+ defName: string;
20
+ args: Record<string, unknown>;
21
+ cwd: string;
22
+ }
23
+
24
+ const contextPath = process.argv[2];
25
+ if (!contextPath) {
26
+ console.error("[detached-runner] Missing context file path argument");
27
+ process.exit(1);
28
+ }
29
+
30
+ let ctx: DetachContext;
31
+ try {
32
+ ctx = JSON.parse(readFileSync(contextPath, "utf-8")) as DetachContext;
33
+ } catch (e) {
34
+ console.error(`[detached-runner] Failed to read context: ${e instanceof Error ? e.message : String(e)}`);
35
+ process.exit(1);
36
+ }
37
+
38
+ const cleanupConfig = { maxKeep: DEFAULT_KEPT_RUNS, maxAgeDays: DEFAULT_RUN_AGE_DAYS };
39
+
40
+ try {
41
+ const state = loadRun(ctx.cwd, ctx.runId);
42
+ if (!state) {
43
+ console.error(`[detached-runner] Run not found: ${ctx.runId}`);
44
+ process.exit(1);
45
+ }
46
+
47
+ // Re-discover agents using the same settings as the host session.
48
+ const settings = readSubagentSettings();
49
+ cleanupConfig.maxKeep = settings.taskflow.maxKeptRuns;
50
+ cleanupConfig.maxAgeDays = settings.taskflow.maxRunAgeDays;
51
+ const scope: AgentScope = state.def.agentScope ?? "user";
52
+ const { agents } = discoverAgents(ctx.cwd, scope, settings.modelRoles, settings.taskflow);
53
+
54
+ const result = await executeTaskflow(state, {
55
+ cwd: ctx.cwd,
56
+ agents,
57
+ globalThinking: settings.globalThinking,
58
+ persist: (s) => saveRun(s, cleanupConfig),
59
+ // No requestApproval — approval phases auto-reject in detached/CI mode
60
+ // (safety: approval gates are never bypassed; the run records the rejection).
61
+ loadFlow: (name: string) => getFlow(ctx.cwd, name)?.def,
62
+ });
63
+
64
+ saveRun(result.state, cleanupConfig);
65
+ } catch (e) {
66
+ // Top-level catch: persist failure so the host can poll the terminal state.
67
+ const message = e instanceof Error ? e.message : String(e);
68
+ console.error(`[detached-runner] Fatal: ${message}`);
69
+ try {
70
+ const state = loadRun(ctx.cwd, ctx.runId);
71
+ if (state && state.status === "running") {
72
+ state.status = "failed";
73
+ saveRun(state, cleanupConfig);
74
+ }
75
+ } catch {
76
+ // Best-effort — if we can't even load the state, there's nothing to persist.
77
+ }
78
+ process.exit(1);
79
+ }
@@ -27,6 +27,7 @@ import { Type } from "typebox";
27
27
  import { type AgentScope, discoverAgents, readSubagentSettings, shouldSyncBuiltinAgentsToProject, syncBuiltinAgentsToProject } from "./agents.ts";
28
28
  import { renderRunResult, summarizeRun } from "./render.ts";
29
29
  import { RunHistoryComponent, type RunHistoryResult } from "./runs-view.ts";
30
+ import { ApprovalViewComponent, type ApprovalChoice } from "./approval-view.ts";
30
31
  import { executeTaskflow, type ApprovalDecision, type ApprovalRequest, type RuntimeResult } from "./runtime.ts";
31
32
  import { finalPhase, resolveArgs, type Taskflow, validateTaskflow, desugar, isShorthand } from "./schema.ts";
32
33
  import {
@@ -58,6 +59,15 @@ const ShorthandStep = Type.Object(
58
59
  {
59
60
  agent: Type.Optional(Type.String({ description: "Agent for this step (defaults to the first available agent)" })),
60
61
  task: Type.String({ description: "Task prompt for this step (supports {previous.output} in chains)" }),
62
+ context: Type.Optional(
63
+ Type.Array(Type.String(), {
64
+ description:
65
+ "File paths to pre-read and inject before this step's task (same as Phase.context). In parallel `tasks` mode all branches SHARE the union of step contexts.",
66
+ }),
67
+ ),
68
+ contextLimit: Type.Optional(
69
+ Type.Number({ description: "Max characters to read per context file (default 8000)." }),
70
+ ),
61
71
  },
62
72
  { additionalProperties: false },
63
73
  );
@@ -81,6 +91,15 @@ const TaskflowParams = Type.Object({
81
91
  task: Type.Optional(
82
92
  Type.String({ description: "Shorthand single mode: the task prompt (like subagent single mode)" }),
83
93
  ),
94
+ context: Type.Optional(
95
+ Type.Array(Type.String(), {
96
+ description:
97
+ "Shorthand single mode: file paths to pre-read and inject before the task (same as Phase.context).",
98
+ }),
99
+ ),
100
+ contextLimit: Type.Optional(
101
+ Type.Number({ description: "Shorthand single mode: max characters to read per context file (default 8000)." }),
102
+ ),
84
103
  tasks: Type.Optional(
85
104
  Type.Array(ShorthandStep, {
86
105
  description: "Shorthand parallel mode: run these tasks concurrently and merge results (like subagent parallel)",
@@ -110,6 +129,11 @@ const TaskflowParams = Type.Object({
110
129
  "Destructive: overwrites modelRoles in settings.json. Required for mode='apply-defaults'.",
111
130
  }),
112
131
  ),
132
+ detach: Type.Optional(
133
+ Type.Boolean({
134
+ description: "Run in background (detached child process); return runId immediately. Status polled via store.",
135
+ }),
136
+ ),
113
137
  });
114
138
 
115
139
  function makeRunState(def: Taskflow, args: Record<string, unknown>, cwd: string): RunState {
@@ -166,19 +190,51 @@ async function runFlow(
166
190
  }
167
191
 
168
192
  // Human-in-the-loop approver — only when an interactive UI is available.
193
+ // Renders a centered modal popup (TUI overlay) with a scrollable viewport
194
+ // so long upstream output (e.g. a plan) can be reviewed in full before
195
+ // deciding (mouse wheel / ↑↓ / PgUp / PgDn to scroll).
169
196
  const requestApproval = ctx.hasUI
170
197
  ? async (req: ApprovalRequest): Promise<ApprovalDecision> => {
171
- if (req.upstream?.trim()) {
172
- const snip = req.upstream.replace(/\s+/g, " ").trim();
173
- ctx.ui.notify(`[${def.name}/${req.phaseId}] ${snip.length > 280 ? `${snip.slice(0, 280)}…` : snip}`, "info");
174
- }
175
- const choice = await ctx.ui.select(
176
- `Taskflow approval — ${req.phaseId}: ${req.message}`,
177
- ["Approve", "Reject", "Edit / add guidance"],
178
- { signal },
198
+ const choice = await ctx.ui.custom<ApprovalChoice>(
199
+ (tui, theme, _kb, done) => {
200
+ const view = new ApprovalViewComponent(
201
+ theme,
202
+ {
203
+ title: `Taskflow approval — ${def.name}/${req.phaseId}`,
204
+ message: req.message,
205
+ upstream: req.upstream,
206
+ },
207
+ done,
208
+ () => tui.terminal.rows,
209
+ tui.terminal,
210
+ );
211
+ const onAbort = () => done("reject");
212
+ signal?.addEventListener("abort", onAbort, { once: true });
213
+ return {
214
+ render: (w: number) => view.render(w),
215
+ invalidate: () => view.invalidate(),
216
+ handleInput: (data: string) => {
217
+ view.handleInput(data);
218
+ tui.requestRender();
219
+ },
220
+ dispose: () => {
221
+ view.dispose();
222
+ signal?.removeEventListener("abort", onAbort);
223
+ },
224
+ };
225
+ },
226
+ {
227
+ overlay: true,
228
+ overlayOptions: {
229
+ width: "80%",
230
+ minWidth: 60,
231
+ maxHeight: "85%",
232
+ anchor: "center",
233
+ },
234
+ },
179
235
  );
180
- if (!choice || choice === "Reject") return { decision: "reject" };
181
- if (choice.startsWith("Edit")) {
236
+ if (choice === "reject") return { decision: "reject" };
237
+ if (choice === "edit") {
182
238
  const note = await ctx.ui.input("Guidance passed downstream as this phase's output", "type guidance…", {
183
239
  signal,
184
240
  });
@@ -535,7 +591,7 @@ export default function (pi: ExtensionAPI) {
535
591
  : params.tasks
536
592
  ? { tasks: params.tasks, name: params.name }
537
593
  : params.task
538
- ? { task: params.task, agent: params.agent, name: params.name }
594
+ ? { task: params.task, agent: params.agent, name: params.name, context: params.context, contextLimit: params.contextLimit }
539
595
  : undefined);
540
596
 
541
597
  if (shorthandSpec !== undefined) {
@@ -614,6 +670,41 @@ export default function (pi: ExtensionAPI) {
614
670
  for (const w of v.warnings) {
615
671
  console.warn(`[taskflow:${def.name}] ${w}`);
616
672
  }
673
+ // Detached (background) execution: spawn a child process and return immediately.
674
+ if (params.detach) {
675
+ const state = makeRunState(def, args, ctx.cwd);
676
+ state.detached = true;
677
+ saveRun(state);
678
+
679
+ // Serialize context for the detached runner script.
680
+ const { writeFileSync } = await import("node:fs");
681
+ const { spawn } = await import("node:child_process");
682
+ const os = await import("node:os");
683
+ const path = await import("node:path");
684
+ const tmpFile = path.join(os.tmpdir(), `taskflow-detach-${state.runId}.json`);
685
+ writeFileSync(tmpFile, JSON.stringify({
686
+ runId: state.runId,
687
+ defName: def.name,
688
+ args,
689
+ cwd: ctx.cwd,
690
+ }));
691
+
692
+ const runnerScript = path.join(path.dirname(new URL(import.meta.url).pathname), "detached-runner.ts");
693
+ const child = spawn(process.execPath, ["--experimental-strip-types", runnerScript, tmpFile], {
694
+ detached: true,
695
+ stdio: "ignore",
696
+ });
697
+ child.unref();
698
+
699
+ state.pid = child.pid ?? undefined;
700
+ saveRun(state);
701
+
702
+ return {
703
+ content: [{ type: "text", text: `Taskflow '${def.name}' started in background (pid: ${child.pid}). Run id: ${state.runId}` }],
704
+ details: { action, state, message: state.runId } satisfies TaskflowDetails,
705
+ };
706
+ }
707
+
617
708
  const result = await runFlow(def, args, ctx, signal, onUpdate as any);
618
709
  // Surface the validation warnings in the tool result so the model
619
710
  // can acknowledge or fix them, and the user sees them in the chat.
@@ -260,7 +260,7 @@ function tokenize(input: string): Tok[] {
260
260
  continue;
261
261
  }
262
262
  // number
263
- const numMatch = /^-?\d+(?:\.\d+)?/.exec(input.slice(i));
263
+ const numMatch = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(input.slice(i));
264
264
  if (numMatch) {
265
265
  toks.push({ t: "num", v: Number(numMatch[0]) });
266
266
  i += numMatch[0].length;