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 +23 -0
- package/README.md +8 -8
- package/extensions/agents.ts +8 -1
- package/extensions/approval-view.ts +264 -0
- package/extensions/cache.ts +1 -0
- package/extensions/detached-runner.ts +79 -0
- package/extensions/index.ts +102 -11
- package/extensions/interpolate.ts +1 -1
- package/extensions/runner.ts +19 -4
- package/extensions/runtime.ts +35 -17
- package/extensions/schema.ts +82 -17
- package/extensions/store.ts +40 -6
- package/package.json +2 -2
- package/skills/taskflow/SKILL.md +35 -9
- package/skills/taskflow/configuration.md +3 -3
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-
|
|
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:
|
|
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-
|
|
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
|
|
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** · **
|
|
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
|
-
- **
|
|
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.
|
|
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
|
-
- **
|
|
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.
|
package/extensions/agents.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/extensions/cache.ts
CHANGED
|
@@ -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
|
+
}
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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 (
|
|
181
|
-
if (choice
|
|
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;
|