@mjasnikovs/pi-task 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/shared/child-output.d.ts +21 -0
- package/dist/shared/child-output.js +40 -0
- package/dist/shared/child-process.d.ts +71 -0
- package/dist/shared/child-process.js +190 -0
- package/dist/shared/pi-invocation.d.ts +7 -0
- package/dist/shared/pi-invocation.js +24 -0
- package/dist/task/child-runner.d.ts +66 -0
- package/dist/task/child-runner.js +157 -0
- package/dist/task/enrichment.d.ts +12 -0
- package/dist/task/enrichment.js +82 -0
- package/dist/task/failure-classifier.d.ts +15 -0
- package/dist/task/failure-classifier.js +63 -0
- package/dist/task/file-inventory.d.ts +9 -0
- package/dist/task/file-inventory.js +44 -0
- package/dist/task/loop-detector.d.ts +32 -0
- package/dist/task/loop-detector.js +46 -0
- package/dist/task/orchestrator.d.ts +54 -0
- package/dist/task/orchestrator.js +387 -0
- package/dist/task/parsers.d.ts +32 -0
- package/dist/task/parsers.js +172 -0
- package/dist/task/phases.d.ts +56 -0
- package/dist/task/phases.js +477 -0
- package/dist/task/prompts.d.ts +21 -0
- package/dist/task/prompts.js +346 -0
- package/dist/task/service-blocks.d.ts +3 -0
- package/dist/task/service-blocks.js +10 -0
- package/dist/task/task-file.d.ts +14 -0
- package/dist/task/task-file.js +15 -0
- package/dist/task/task-io.d.ts +19 -0
- package/dist/task/task-io.js +78 -0
- package/dist/task/task-parsers.d.ts +12 -0
- package/dist/task/task-parsers.js +75 -0
- package/dist/task/task-types.d.ts +21 -0
- package/dist/task/task-types.js +18 -0
- package/dist/task/timings.d.ts +18 -0
- package/dist/task/timings.js +36 -0
- package/dist/task/widget.d.ts +39 -0
- package/dist/task/widget.js +122 -0
- package/dist/workers/brave-search.d.ts +17 -0
- package/dist/workers/brave-search.js +77 -0
- package/dist/workers/docs-cache.d.ts +16 -0
- package/dist/workers/docs-cache.js +66 -0
- package/dist/workers/docs-core.d.ts +86 -0
- package/dist/workers/docs-core.js +329 -0
- package/dist/workers/docs-index.d.ts +9 -0
- package/dist/workers/docs-index.js +200 -0
- package/dist/workers/docs-resolve.d.ts +12 -0
- package/dist/workers/docs-resolve.js +126 -0
- package/dist/workers/docs-retrieve.d.ts +15 -0
- package/dist/workers/docs-retrieve.js +91 -0
- package/dist/workers/fetch-core.d.ts +35 -0
- package/dist/workers/fetch-core.js +91 -0
- package/dist/workers/html-clean.d.ts +17 -0
- package/dist/workers/html-clean.js +142 -0
- package/dist/workers/index.d.ts +2 -0
- package/dist/workers/index.js +10 -0
- package/dist/workers/npm-version.d.ts +32 -0
- package/dist/workers/npm-version.js +102 -0
- package/dist/workers/pi-worker-core.d.ts +28 -0
- package/dist/workers/pi-worker-core.js +29 -0
- package/dist/workers/pi-worker-docs.d.ts +16 -0
- package/dist/workers/pi-worker-docs.js +143 -0
- package/dist/workers/pi-worker-fetch.d.ts +20 -0
- package/dist/workers/pi-worker-fetch.js +72 -0
- package/dist/workers/pi-worker-search.d.ts +7 -0
- package/dist/workers/pi-worker-search.js +55 -0
- package/dist/workers/pi-worker.d.ts +10 -0
- package/dist/workers/pi-worker.js +61 -0
- package/dist/workers/search-core.d.ts +19 -0
- package/dist/workers/search-core.js +35 -0
- package/dist/workers/shared.d.ts +3 -0
- package/dist/workers/shared.js +4 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Edgars Mjasnikovs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# 🧩 pi-task
|
|
4
|
+
|
|
5
|
+
**Deterministic spec-orchestration for local models — with bundled web, docs, fetch, and worker sub-agent tools.**
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/@mjasnikovs/pi-task)
|
|
8
|
+
[](./LICENSE)
|
|
9
|
+
[](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)
|
|
10
|
+
[](#development)
|
|
11
|
+
[](./tsconfig.json)
|
|
12
|
+
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## What it does
|
|
18
|
+
|
|
19
|
+
Local models drift. Ask one to plan a non-trivial change and it skips context, hallucinates APIs, and forgets what you actually asked. `pi-task` fixes this by **not trusting a single prompt** — it drives your request through a fixed, persisted pipeline of small, verifiable steps, then hands the main session a clean spec to execute.
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
/task add rate-limiting to the public API
|
|
23
|
+
│
|
|
24
|
+
▼
|
|
25
|
+
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
|
26
|
+
│ refine │──▶│ research │──▶│ grill │──▶│ compose │──▶│ critique │
|
|
27
|
+
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
|
|
28
|
+
sharpen the parallel clarifying assemble triage +
|
|
29
|
+
raw prompt sub-agents: questions the spec rewrite if
|
|
30
|
+
files · APIs · (auto- or the draft
|
|
31
|
+
context · you answer) isn't clean
|
|
32
|
+
tooling
|
|
33
|
+
│
|
|
34
|
+
▼
|
|
35
|
+
final spec ──▶ main pi session (you keep working in the same chat)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Every phase boundary is written to `.pi-tasks/TASK_NNNN.md`, so a task survives a crash, a restart, or a `/task-cancel` — pick it back up with `/task-resume`.
|
|
39
|
+
|
|
40
|
+
## Why it's different
|
|
41
|
+
|
|
42
|
+
- **Deterministic by construction.** The phase order is fixed code, not a model's free choice. The orchestrator loops over a config table; each phase has one job and one output section.
|
|
43
|
+
- **Parallel research, focused output.** The research phase fans out to isolated child agents — one indexing project files, others digging into APIs, context, and tooling — and **verifies tooling claims** before they reach the spec.
|
|
44
|
+
- **Context stays clean.** Noisy file/code spelunking, page fetches, and docs lookups run in throwaway child sessions. The parent only ever sees the distilled answer, never the raw page or the 4k-line file.
|
|
45
|
+
- **Built for local LLMs.** A loop detector and failure classifier catch the stalls, repetitions, and malformed output that smaller models produce, and retry with sharper emphasis instead of giving up.
|
|
46
|
+
- **Crash-safe.** State is a plain Markdown file you can read, diff, and edit by hand.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
pi install npm:@mjasnikovs/pi-task
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
> Requires [`pi`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) (the Earendil coding agent) ≥ 0.75.
|
|
55
|
+
|
|
56
|
+
## Slash commands
|
|
57
|
+
|
|
58
|
+
| Command | What it does |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| `/task <prompt>` | Start a new task and run it through the full pipeline. |
|
|
61
|
+
| `/task-list` | Open the task list in an editor dialog. |
|
|
62
|
+
| `/task-resume [id]` | Resume the most recent (or named) unfinished task. |
|
|
63
|
+
| `/task-cancel` | Cancel the running task (soft-terminal — still resumable). |
|
|
64
|
+
|
|
65
|
+
## The pipeline
|
|
66
|
+
|
|
67
|
+
| Phase | Output section | What happens |
|
|
68
|
+
| --- | --- | --- |
|
|
69
|
+
| **refine** | `refined prompt` | Sharpens your raw ask into an unambiguous, self-contained statement. |
|
|
70
|
+
| **research** | `research` | Fans out to parallel sub-agents (project files · APIs · domain context · tooling), enriches any referenced packages/URLs/external services with fresh docs, then **verifies tooling claims**. |
|
|
71
|
+
| **grill** | `grill Q&A` | Generates the clarifying questions the spec can't be written without — auto-answered from context where possible, surfaced to you where not. |
|
|
72
|
+
| **compose** | `spec` | Assembles refined prompt + research + Q&A into a single implementation spec. |
|
|
73
|
+
| **critique** | `spec` | Triages the draft; if it isn't already clean, rewrites it. The triage pass skips the expensive rewrite when the draft already holds up. |
|
|
74
|
+
|
|
75
|
+
The finished spec is delivered to your main `pi` conversation via `sendUserMessage`, so you keep working in the same chat — no context handoff, no copy-paste.
|
|
76
|
+
|
|
77
|
+
## Bundled tools
|
|
78
|
+
|
|
79
|
+
`pi-task` also registers four MCP-style worker tools (formerly `@mjasnikovs/pi-worker`). All are parallel-execution-capable, so the parent session can issue several calls in one turn.
|
|
80
|
+
|
|
81
|
+
### `pi-worker`
|
|
82
|
+
Spawns an isolated child `pi --print` session with read + bash tools. Use it for noisy file/code work that would otherwise flood the main context.
|
|
83
|
+
|
|
84
|
+
### `pi-worker-search`
|
|
85
|
+
Runs a Brave Search query and returns a compact markdown list (title · URL · snippet). Use it to discover candidate URLs before fetching.
|
|
86
|
+
|
|
87
|
+
> **Requires** `BRAVE_SEARCH_API_KEY` (also accepted as `BRAVE_API_KEY`). Grab a free key at [api.search.brave.com/app/keys](https://api.search.brave.com/app/keys).
|
|
88
|
+
|
|
89
|
+
### `pi-worker-fetch`
|
|
90
|
+
Fetches a URL, cleans the HTML to markdown ([Readability](https://github.com/mozilla/readability) + [Turndown](https://github.com/mixmark-io/turndown)), then hands it to an isolated child that extracts **only** the content answering your `query`. The parent never sees the raw page.
|
|
91
|
+
|
|
92
|
+
- Only `text/html` responses — PDFs, JSON, etc. return a clear error.
|
|
93
|
+
- Bodies over 2 MB are rejected.
|
|
94
|
+
- The extraction child runs with `--no-tools` to mitigate visible-text prompt injection.
|
|
95
|
+
|
|
96
|
+
### `pi-worker-docs`
|
|
97
|
+
Resolves an installed npm package, indexes its `.d.ts` files and README into a local SQLite cache, retrieves the most relevant chunks for your `query`, and passes them to an isolated child that extracts the focused answer. Version-pinned to whatever is in your `node_modules`.
|
|
98
|
+
|
|
99
|
+
- The package must be installed in the project's `node_modules`; otherwise a one-time auto-install into a dedicated cache dir is attempted.
|
|
100
|
+
- The first call for a `(package, version)` pair pays a one-time ingestion cost; later calls are FTS-only.
|
|
101
|
+
- Cache lives at `${XDG_CACHE_HOME:-~/.cache}/pi-worker/docs.sqlite` — delete it to reset.
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
| Variable | Used by | Notes |
|
|
106
|
+
| --- | --- | --- |
|
|
107
|
+
| `BRAVE_SEARCH_API_KEY` / `BRAVE_API_KEY` | `pi-worker-search`, research enrichment | Required for web search. |
|
|
108
|
+
| `XDG_CACHE_HOME` | `pi-worker-docs` | Overrides the docs cache location (defaults to `~/.cache`). |
|
|
109
|
+
|
|
110
|
+
Tasks are persisted to `<cwd>/.pi-tasks/TASK_NNNN.md`. Add `.pi-tasks/` to your `.gitignore` if you don't want them checked in.
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
bun install
|
|
116
|
+
bun test src/ # 326 tests across 28 files
|
|
117
|
+
bun run lint # prettier + eslint + tsc --noEmit
|
|
118
|
+
bun run build # tsc → dist/
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Built with [Bun](https://bun.sh), TypeScript (strict), and [TypeBox](https://github.com/sinclairzx81/typebox) for tool schemas. Design docs and plans live in [`docs/`](./docs).
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
[MIT](./LICENSE) © Edgars Mjasnikovs
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for parsing and formatting child pi output.
|
|
3
|
+
*
|
|
4
|
+
* Used by both fetch-core (web page extraction) and docs-core (npm package
|
|
5
|
+
* docs extraction). The child pi outputs <answer> and <excerpt> XML tags;
|
|
6
|
+
* these functions parse, verify, and format the result.
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseChildOutput(stdout: string): {
|
|
9
|
+
answer: string;
|
|
10
|
+
excerpt?: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function normaliseWhitespace(s: string): string;
|
|
13
|
+
/** Check whether an excerpt appears verbatim in the source content
|
|
14
|
+
* (whitespace-normalised). Returns false for empty excerpts. */
|
|
15
|
+
export declare function isExcerptInContent(excerpt: string, content: string): boolean;
|
|
16
|
+
/** Format the child's parsed output with a header and optional excerpt block.
|
|
17
|
+
* When `verified === false` a warning is prepended. */
|
|
18
|
+
export declare function formatResultText(header: string, parsed: {
|
|
19
|
+
answer: string;
|
|
20
|
+
excerpt?: string;
|
|
21
|
+
}, verified: boolean | undefined): string;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for parsing and formatting child pi output.
|
|
3
|
+
*
|
|
4
|
+
* Used by both fetch-core (web page extraction) and docs-core (npm package
|
|
5
|
+
* docs extraction). The child pi outputs <answer> and <excerpt> XML tags;
|
|
6
|
+
* these functions parse, verify, and format the result.
|
|
7
|
+
*/
|
|
8
|
+
export function parseChildOutput(stdout) {
|
|
9
|
+
const trimmed = stdout.trim();
|
|
10
|
+
const answerMatch = /<answer>([\s\S]*?)<\/answer>/i.exec(trimmed);
|
|
11
|
+
const excerptMatch = /<excerpt>([\s\S]*?)<\/excerpt>/i.exec(trimmed);
|
|
12
|
+
if (!answerMatch)
|
|
13
|
+
return { answer: trimmed };
|
|
14
|
+
return {
|
|
15
|
+
answer: answerMatch[1].trim(),
|
|
16
|
+
excerpt: excerptMatch?.[1].trim() || undefined
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function normaliseWhitespace(s) {
|
|
20
|
+
return s.replace(/\s+/g, ' ').trim();
|
|
21
|
+
}
|
|
22
|
+
/** Check whether an excerpt appears verbatim in the source content
|
|
23
|
+
* (whitespace-normalised). Returns false for empty excerpts. */
|
|
24
|
+
export function isExcerptInContent(excerpt, content) {
|
|
25
|
+
if (!excerpt)
|
|
26
|
+
return false;
|
|
27
|
+
return normaliseWhitespace(content).includes(normaliseWhitespace(excerpt));
|
|
28
|
+
}
|
|
29
|
+
/** Format the child's parsed output with a header and optional excerpt block.
|
|
30
|
+
* When `verified === false` a warning is prepended. */
|
|
31
|
+
export function formatResultText(header, parsed, verified) {
|
|
32
|
+
if (!parsed.excerpt) {
|
|
33
|
+
return header ? `${header}\n\n${parsed.answer}` : parsed.answer;
|
|
34
|
+
}
|
|
35
|
+
const quote = parsed.excerpt.replace(/\n/g, '\n> ');
|
|
36
|
+
const warning = verified === false ?
|
|
37
|
+
'WARNING: cited excerpt not found verbatim in source content — the child pi may have paraphrased or hallucinated.\n\n'
|
|
38
|
+
: '';
|
|
39
|
+
return `${warning}${header}\n\n${parsed.answer}\n\nSource excerpt:\n> ${quote}`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { EventEmitter } from 'node:events';
|
|
2
|
+
/** Grace period between SIGTERM and SIGKILL (ms). */
|
|
3
|
+
export declare const KILL_GRACE_MS = 5000;
|
|
4
|
+
/** Base flags shared by all child pi invocations. */
|
|
5
|
+
export declare const CHILD_BASE_ARGS: readonly ["--print", "--no-skills", "--no-extensions", "--no-prompt-templates", "--no-context-files", "--no-session"];
|
|
6
|
+
export interface ProcLike extends EventEmitter {
|
|
7
|
+
stdout: EventEmitter | null;
|
|
8
|
+
stderr: EventEmitter | null;
|
|
9
|
+
killed: boolean;
|
|
10
|
+
kill(signal: string): boolean | void;
|
|
11
|
+
}
|
|
12
|
+
export type SpawnFn = (command: string, args: ReadonlyArray<string>, options: {
|
|
13
|
+
cwd: string;
|
|
14
|
+
shell: boolean;
|
|
15
|
+
stdio: ['ignore', 'pipe', 'pipe'];
|
|
16
|
+
}) => ProcLike;
|
|
17
|
+
export interface ChildResult {
|
|
18
|
+
stdout: string;
|
|
19
|
+
stderr: string;
|
|
20
|
+
exitCode: number;
|
|
21
|
+
aborted: boolean;
|
|
22
|
+
/** Extracted assistant text (only populated in json-events mode). */
|
|
23
|
+
text?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface ToolCall {
|
|
26
|
+
name: string;
|
|
27
|
+
args: unknown;
|
|
28
|
+
}
|
|
29
|
+
export interface LoopHit {
|
|
30
|
+
call: ToolCall;
|
|
31
|
+
count: number;
|
|
32
|
+
windowSize: number;
|
|
33
|
+
}
|
|
34
|
+
export interface ContextSnapshot {
|
|
35
|
+
tokens: number;
|
|
36
|
+
contextWindow: number;
|
|
37
|
+
percent: number;
|
|
38
|
+
}
|
|
39
|
+
export interface RunChildTextOptions {
|
|
40
|
+
mode: 'text';
|
|
41
|
+
/**
|
|
42
|
+
* Drop stdout chunks instead of buffering them into the result. Use for
|
|
43
|
+
* pipe-through tools (npm install, build commands) where we only need the
|
|
44
|
+
* exit code and stderr — verbose stdout can exceed V8's max string length.
|
|
45
|
+
*/
|
|
46
|
+
discardStdout?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Fires exactly once on the first stdout data chunk. Lets callers split
|
|
49
|
+
* total elapsed into wait-for-first-byte vs generation time — useful when
|
|
50
|
+
* concurrent children may queue on an upstream slot (e.g. model API
|
|
51
|
+
* concurrency caps) before producing output.
|
|
52
|
+
*/
|
|
53
|
+
onFirstByte?: () => void;
|
|
54
|
+
}
|
|
55
|
+
export interface RunChildJsonEventsOptions {
|
|
56
|
+
mode: 'json-events';
|
|
57
|
+
onLine?: (line: string) => void;
|
|
58
|
+
onContextUsage?: (snapshot: ContextSnapshot) => void;
|
|
59
|
+
onToolCall?: (call: ToolCall) => LoopHit | null;
|
|
60
|
+
onFirstByte?: () => void;
|
|
61
|
+
}
|
|
62
|
+
export type RunChildOptions = RunChildTextOptions | RunChildJsonEventsOptions;
|
|
63
|
+
export declare function runChild(spawn: SpawnFn, invocation: {
|
|
64
|
+
command: string;
|
|
65
|
+
args: ReadonlyArray<string>;
|
|
66
|
+
}, cwd: string, signal: AbortSignal | undefined, opts?: RunChildOptions): Promise<ChildResult>;
|
|
67
|
+
export declare function runChildDefault(invocation: {
|
|
68
|
+
command: string;
|
|
69
|
+
args: ReadonlyArray<string>;
|
|
70
|
+
}, cwd: string, signal: AbortSignal | undefined, opts?: RunChildOptions, spawnFn?: SpawnFn): Promise<ChildResult>;
|
|
71
|
+
export declare function summarizeToolArgs(toolName: string, args: unknown): string;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { spawn as defaultSpawn } from 'node:child_process';
|
|
2
|
+
/** Grace period between SIGTERM and SIGKILL (ms). */
|
|
3
|
+
export const KILL_GRACE_MS = 5000;
|
|
4
|
+
/** Base flags shared by all child pi invocations. */
|
|
5
|
+
export const CHILD_BASE_ARGS = [
|
|
6
|
+
'--print',
|
|
7
|
+
'--no-skills',
|
|
8
|
+
'--no-extensions',
|
|
9
|
+
'--no-prompt-templates',
|
|
10
|
+
'--no-context-files',
|
|
11
|
+
'--no-session'
|
|
12
|
+
];
|
|
13
|
+
// ─── Unified runChild ────────────────────────────────────────────────────────
|
|
14
|
+
export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
15
|
+
return new Promise(resolve => {
|
|
16
|
+
let stdout = '';
|
|
17
|
+
let stderr = '';
|
|
18
|
+
let aborted = false;
|
|
19
|
+
const isJsonEvents = opts?.mode === 'json-events';
|
|
20
|
+
const discardStdout = opts?.mode === 'text' && opts.discardStdout === true;
|
|
21
|
+
// State for json-events mode
|
|
22
|
+
let finalText = '';
|
|
23
|
+
let textDeltaAccum = '';
|
|
24
|
+
const proc = spawn(invocation.command, invocation.args, {
|
|
25
|
+
cwd,
|
|
26
|
+
shell: false,
|
|
27
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
28
|
+
});
|
|
29
|
+
let firstByteFired = false;
|
|
30
|
+
proc.stdout?.on('data', (d) => {
|
|
31
|
+
if (!firstByteFired) {
|
|
32
|
+
firstByteFired = true;
|
|
33
|
+
opts?.onFirstByte?.();
|
|
34
|
+
}
|
|
35
|
+
if (discardStdout)
|
|
36
|
+
return;
|
|
37
|
+
const chunk = d.toString();
|
|
38
|
+
stdout += chunk;
|
|
39
|
+
if (isJsonEvents) {
|
|
40
|
+
drainJsonEvents(chunk, opts);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
proc.stderr?.on('data', (d) => {
|
|
44
|
+
stderr += d.toString();
|
|
45
|
+
});
|
|
46
|
+
proc.on('close', (code) => {
|
|
47
|
+
const text = isJsonEvents ? (finalText || textDeltaAccum).trim() : undefined;
|
|
48
|
+
resolve({ stdout, stderr, exitCode: code ?? 0, aborted, text });
|
|
49
|
+
});
|
|
50
|
+
proc.on('error', () => {
|
|
51
|
+
resolve({ stdout, stderr, exitCode: 1, aborted });
|
|
52
|
+
});
|
|
53
|
+
if (signal) {
|
|
54
|
+
const kill = () => {
|
|
55
|
+
aborted = true;
|
|
56
|
+
proc.kill('SIGTERM');
|
|
57
|
+
setTimeout(() => {
|
|
58
|
+
if (!proc.killed)
|
|
59
|
+
proc.kill('SIGKILL');
|
|
60
|
+
}, KILL_GRACE_MS);
|
|
61
|
+
};
|
|
62
|
+
if (signal.aborted)
|
|
63
|
+
kill();
|
|
64
|
+
else
|
|
65
|
+
signal.addEventListener('abort', kill, { once: true });
|
|
66
|
+
}
|
|
67
|
+
// ─── JSON event-stream processing ──────────────────────────────────
|
|
68
|
+
function drainJsonEvents(chunk, jsonOpts) {
|
|
69
|
+
let buf = chunk;
|
|
70
|
+
let nl;
|
|
71
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
72
|
+
const line = buf.slice(0, nl).trim();
|
|
73
|
+
buf = buf.slice(nl + 1);
|
|
74
|
+
if (line.length === 0)
|
|
75
|
+
continue;
|
|
76
|
+
try {
|
|
77
|
+
const evt = JSON.parse(line);
|
|
78
|
+
if (evt && typeof evt === 'object') {
|
|
79
|
+
handleEvent(evt, jsonOpts);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Non-JSON line (startup banner, etc.) — ignore.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function handleEvent(evt, jsonOpts) {
|
|
88
|
+
const t = typeof evt.type === 'string' ? evt.type : '';
|
|
89
|
+
if (t === 'context_usage' && jsonOpts.onContextUsage) {
|
|
90
|
+
const tokens = Number(evt.tokens ?? 0);
|
|
91
|
+
const contextWindow = Number(evt.contextWindow ?? 0);
|
|
92
|
+
const percent = Number(evt.percent ?? 0);
|
|
93
|
+
if (tokens > 0 || contextWindow > 0) {
|
|
94
|
+
jsonOpts.onContextUsage({ tokens, contextWindow, percent });
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (t === 'message_end' && jsonOpts.onContextUsage) {
|
|
99
|
+
const msg = evt.message;
|
|
100
|
+
if (msg?.role === 'assistant') {
|
|
101
|
+
const usage = msg.usage;
|
|
102
|
+
if (usage) {
|
|
103
|
+
const tokens = Number(usage.input ?? 0)
|
|
104
|
+
+ Number(usage.cacheRead ?? 0)
|
|
105
|
+
+ Number(usage.cacheWrite ?? 0)
|
|
106
|
+
+ Number(usage.output ?? 0);
|
|
107
|
+
if (tokens > 0) {
|
|
108
|
+
jsonOpts.onContextUsage({ tokens, contextWindow: 0, percent: 0 });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (t === 'agent_end' && Array.isArray(evt.messages)) {
|
|
115
|
+
for (let i = evt.messages.length - 1; i >= 0; i--) {
|
|
116
|
+
const m = evt.messages[i];
|
|
117
|
+
if (m && m.role === 'assistant' && Array.isArray(m.content)) {
|
|
118
|
+
const texts = [];
|
|
119
|
+
for (const c of m.content) {
|
|
120
|
+
if (c?.type === 'text' && typeof c.text === 'string') {
|
|
121
|
+
texts.push(c.text);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (texts.length > 0) {
|
|
125
|
+
finalText = texts.join('');
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (t === 'message_update') {
|
|
133
|
+
const ame = evt.assistantMessageEvent;
|
|
134
|
+
const ameType = ame && typeof ame.type === 'string' ? ame.type : '';
|
|
135
|
+
if (ameType === 'text_start') {
|
|
136
|
+
textDeltaAccum = '';
|
|
137
|
+
if (jsonOpts.onLine)
|
|
138
|
+
jsonOpts.onLine('writing answer…');
|
|
139
|
+
}
|
|
140
|
+
else if (ameType === 'text_delta' && typeof ame.delta === 'string') {
|
|
141
|
+
textDeltaAccum += ame.delta;
|
|
142
|
+
}
|
|
143
|
+
else if (ameType === 'thinking_start' && jsonOpts.onLine) {
|
|
144
|
+
jsonOpts.onLine('thinking…');
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (t === 'tool_execution_start') {
|
|
149
|
+
const tn = typeof evt.toolName === 'string' ? evt.toolName : 'tool';
|
|
150
|
+
if (jsonOpts.onLine) {
|
|
151
|
+
const detail = summarizeToolArgs(tn, evt.args);
|
|
152
|
+
jsonOpts.onLine(detail ? `${tn}: ${detail}` : tn);
|
|
153
|
+
}
|
|
154
|
+
if (jsonOpts.onToolCall) {
|
|
155
|
+
const hit = jsonOpts.onToolCall({ name: tn, args: evt.args });
|
|
156
|
+
if (hit) {
|
|
157
|
+
aborted = true;
|
|
158
|
+
proc.kill('SIGTERM');
|
|
159
|
+
setTimeout(() => {
|
|
160
|
+
if (!proc.killed)
|
|
161
|
+
proc.kill('SIGKILL');
|
|
162
|
+
}, KILL_GRACE_MS);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
// ─── Convenience: spawn with default node child_process ──────────────────────
|
|
170
|
+
export function runChildDefault(invocation, cwd, signal, opts, spawnFn) {
|
|
171
|
+
return runChild(spawnFn ?? defaultSpawn, invocation, cwd, signal, opts);
|
|
172
|
+
}
|
|
173
|
+
// ─── Shared helpers ──────────────────────────────────────────────────────────
|
|
174
|
+
export function summarizeToolArgs(toolName, args) {
|
|
175
|
+
if (!args || typeof args !== 'object')
|
|
176
|
+
return '';
|
|
177
|
+
const a = args;
|
|
178
|
+
if (toolName === 'bash' && typeof a.command === 'string') {
|
|
179
|
+
return a.command.replace(/\s+/g, ' ').trim();
|
|
180
|
+
}
|
|
181
|
+
if (typeof a.file_path === 'string')
|
|
182
|
+
return a.file_path;
|
|
183
|
+
if (typeof a.path === 'string')
|
|
184
|
+
return a.path;
|
|
185
|
+
if (typeof a.filePath === 'string')
|
|
186
|
+
return a.filePath;
|
|
187
|
+
if (typeof a.pattern === 'string')
|
|
188
|
+
return a.pattern;
|
|
189
|
+
return '';
|
|
190
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Pick the right way to re-invoke pi: prefer the current pi script under the
|
|
2
|
+
* current node/bun runtime; fall back to the `pi` shim on PATH. Mirrors the
|
|
3
|
+
* pattern from pi-coding-agent's official subagent example. */
|
|
4
|
+
export declare function getPiInvocation(args: string[]): {
|
|
5
|
+
command: string;
|
|
6
|
+
args: string[];
|
|
7
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
/** Pick the right way to re-invoke pi: prefer the current pi script under the
|
|
4
|
+
* current node/bun runtime; fall back to the `pi` shim on PATH. Mirrors the
|
|
5
|
+
* pattern from pi-coding-agent's official subagent example. */
|
|
6
|
+
export function getPiInvocation(args) {
|
|
7
|
+
// Test/dev override: point at a specific pi binary directly. Bypasses the
|
|
8
|
+
// re-invoke-current-script heuristic, which goes wrong under `bun test`
|
|
9
|
+
// (currentScript is the .test.ts file, not a pi entrypoint).
|
|
10
|
+
if (process.env.PI_BIN) {
|
|
11
|
+
return { command: process.env.PI_BIN, args };
|
|
12
|
+
}
|
|
13
|
+
const currentScript = process.argv[1];
|
|
14
|
+
const isBunVirtualScript = currentScript?.startsWith('/$bunfs/root/');
|
|
15
|
+
if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
|
|
16
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
17
|
+
}
|
|
18
|
+
const execName = path.basename(process.execPath).toLowerCase();
|
|
19
|
+
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
|
|
20
|
+
if (!isGenericRuntime) {
|
|
21
|
+
return { command: process.execPath, args };
|
|
22
|
+
}
|
|
23
|
+
return { command: 'pi', args };
|
|
24
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child process runner for the pi-task orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper layer over the unified `runChild` in `shared/child-process.ts`.
|
|
5
|
+
* Provides JSON event-stream parsing, loop detection, and context-usage tracking
|
|
6
|
+
* for phase-level child pi invocations.
|
|
7
|
+
*/
|
|
8
|
+
import { type SpawnFn, type ContextSnapshot, type ToolCall, type LoopHit } from '../shared/child-process.js';
|
|
9
|
+
export declare const LOOP_WINDOW = 20;
|
|
10
|
+
export declare const LOOP_THRESHOLD = 5;
|
|
11
|
+
export declare const MAX_LOOP_RESTARTS = 2;
|
|
12
|
+
export interface PhaseRunResult {
|
|
13
|
+
text: string;
|
|
14
|
+
exitCode: number;
|
|
15
|
+
stderr: string;
|
|
16
|
+
loopHit?: LoopHit;
|
|
17
|
+
}
|
|
18
|
+
export declare function childArgs(tools: string, prompt: string): string[];
|
|
19
|
+
export declare const USER_CANCELLED = "__user_cancelled__";
|
|
20
|
+
/**
|
|
21
|
+
* Run a child pi process with JSON event-stream output, loop detection, and
|
|
22
|
+
* context-usage tracking. This is the typed convenience wrapper used by
|
|
23
|
+
* phase-level code.
|
|
24
|
+
*/
|
|
25
|
+
export declare function runChild(cwd: string, tools: string, prompt: string, signal: AbortSignal, onLine?: (line: string) => void, onContextUsage?: (snapshot: ContextSnapshot) => void, onToolCall?: (call: ToolCall) => LoopHit | null, spawnFn?: SpawnFn): Promise<PhaseRunResult>;
|
|
26
|
+
interface PhaseDeps {
|
|
27
|
+
cwd: string;
|
|
28
|
+
taskId: string;
|
|
29
|
+
signal: AbortSignal;
|
|
30
|
+
onChildOutput?: (line: string) => void;
|
|
31
|
+
onContextUsage?: (snapshot: ContextSnapshot) => void;
|
|
32
|
+
/**
|
|
33
|
+
* Record a sub-step duration under the currently running top-level phase.
|
|
34
|
+
* The orchestrator rebinds this between phases so each call lands in the
|
|
35
|
+
* right phase's children array. Phases that don't care can ignore it.
|
|
36
|
+
*/
|
|
37
|
+
recordSubStep?: (label: string, ms: number) => void;
|
|
38
|
+
spawn?: SpawnFn;
|
|
39
|
+
}
|
|
40
|
+
export type { PhaseDeps };
|
|
41
|
+
/** Run a child pi and return its assistant text. Throws if exit code != 0. */
|
|
42
|
+
export declare function runPhaseChild(deps: PhaseDeps, name: string, tools: string, prompt: string): Promise<string>;
|
|
43
|
+
export declare function prependHint(hint: string | null, prompt: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* Run a phase child with loop detection. On a detected loop, kill and re-spawn
|
|
46
|
+
* with a hint that names the offending call. Cap at MAX_LOOP_RESTARTS restarts;
|
|
47
|
+
* the (MAX_LOOP_RESTARTS+1)th loop throws LoopExhaustedError.
|
|
48
|
+
*/
|
|
49
|
+
export declare function runPhaseWithLoopGuard(deps: PhaseDeps, name: string, tools: string, buildPrompt: (loopHint: string | null) => string): Promise<string>;
|
|
50
|
+
/**
|
|
51
|
+
* Run a child up to twice; the second attempt gets `emphasized=true` to escalate
|
|
52
|
+
* the prompt. On success, return the validator's value; on two failures, throw
|
|
53
|
+
* the caller-supplied error built from the last problem string.
|
|
54
|
+
*/
|
|
55
|
+
export declare function runWithEmphasisRetry<T>(deps: PhaseDeps, name: string, tools: string, build: (retryProblem: string | null) => string, validate: (text: string) => {
|
|
56
|
+
ok: true;
|
|
57
|
+
value: T;
|
|
58
|
+
} | {
|
|
59
|
+
ok: false;
|
|
60
|
+
problem: string;
|
|
61
|
+
}, onFail: (problem: string) => Error): Promise<T>;
|
|
62
|
+
export declare class LoopExhaustedError extends Error {
|
|
63
|
+
readonly phase: string;
|
|
64
|
+
readonly history: LoopHit[];
|
|
65
|
+
constructor(phase: string, history: LoopHit[]);
|
|
66
|
+
}
|