@fyeeme/pi-review 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/index.ts +28 -0
- package/package.json +52 -0
- package/skills/code-review/SKILL.md +369 -0
- package/skills/simplify/SKILL.md +157 -0
- package/src/agent/dispatch.ts +353 -0
- package/src/commands/code-review.ts +100 -0
- package/src/commands/code-simplify.ts +66 -0
- package/src/skills.ts +22 -0
- package/src/tools/subagent.ts +324 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: simplify
|
|
3
|
+
description: "Review the changed code for reuse, simplification, efficiency, and altitude cleanups, then apply the fixes. Quality only — it does not hunt for bugs; use /code-review for that. v2 (from Claude Code CLI v2.1.223) — 4 cleanup agents fan out in parallel when context allows, else a single-pass inline cleanup; either way the fixes are applied to the working tree."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
<!--
|
|
7
|
+
Origin: Claude Code built-in skill `/simplify` (CLI v2.1.223), reverse-
|
|
8
|
+
engineered from bin/claude.exe strings. Pi registers it as /code-simplify.
|
|
9
|
+
|
|
10
|
+
Lineage:
|
|
11
|
+
v2.1.220 → the first reconstruction (v1)
|
|
12
|
+
v2.1.223 → verified 2026-08-06 against bin/claude.exe strings: skill body
|
|
13
|
+
(intro, Phase 0, the 4 cleanup angles, Phase 2 apply) and the
|
|
14
|
+
PARALLEL/SINGLE-PASS split are unchanged vs v2.1.220. The 4
|
|
15
|
+
angle bodies are shared verbatim with code-review's cleanup
|
|
16
|
+
angles (same source variables in the binary).
|
|
17
|
+
|
|
18
|
+
Bundled: ships inside the pi-review extension (skills/simplify/SKILL.md).
|
|
19
|
+
|
|
20
|
+
Invocation: /code-simplify [<target>]
|
|
21
|
+
target = file path | PR number | branch name
|
|
22
|
+
|
|
23
|
+
════════════════════════════════════════════════════════════════════════
|
|
24
|
+
Pi ADAPTATIONS (differ from the CC runtime)
|
|
25
|
+
════════════════════════════════════════════════════════════════════════
|
|
26
|
+
1. Fan-out tool — CC uses the Agent tool; Pi uses the `subagent` tool
|
|
27
|
+
(mode: parallel). Where CC says "the Agent tool", read `subagent`.
|
|
28
|
+
2. Mode guard — CC's _Yo has two clauses: (a) spawn-depth — single-pass when
|
|
29
|
+
agent depth >= CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH (default 3); (b) the
|
|
30
|
+
Agent tool must be in the allowlist. On Pi: (a) is N/A — the `subagent`
|
|
31
|
+
tool spawns a fresh subprocess (always depth 0), so depth never accumulates
|
|
32
|
+
— so decideSimplifyMode substitutes a context-fraction heuristic
|
|
33
|
+
(tokens/contextWindow >= 0.8 → single-pass), a Pi addition NOT a mirror of
|
|
34
|
+
_Yo; (b) is mirrored as "the `subagent` tool must be registered". The
|
|
35
|
+
decision is made DETERMINISTICALLY by the /code-simplify handler — it can
|
|
36
|
+
read ctx.getContextUsage(), which a pure-prompt skill cannot — and announced
|
|
37
|
+
in the trigger message; this skill just provides the two mode bodies.
|
|
38
|
+
3. Command — CC: /simplify; Pi: /code-simplify.
|
|
39
|
+
|
|
40
|
+
Prerequisite: the `subagent` tool (provided by the pi-review extension) for
|
|
41
|
+
PARALLEL MODE. SINGLE-PASS MODE runs standalone.
|
|
42
|
+
-->
|
|
43
|
+
|
|
44
|
+
You are improving the quality of the changed code, not hunting for bugs. Review
|
|
45
|
+
it for reuse, simplification, efficiency, and altitude issues, then fix what you
|
|
46
|
+
find. Do not look for correctness bugs — that is what `/code-review` is for.
|
|
47
|
+
|
|
48
|
+
The `/code-simplify` handler has already chosen the mode (PARALLEL or
|
|
49
|
+
SINGLE-PASS) from real context usage and announced it in the trigger message.
|
|
50
|
+
Follow the body that matches; do not fake the mode you weren't asked to run.
|
|
51
|
+
|
|
52
|
+
## Phase 0 — Gather the diff
|
|
53
|
+
|
|
54
|
+
Run `git diff @{upstream}...HEAD` (or `git diff main...HEAD` / `git diff HEAD~1`
|
|
55
|
+
if there's no upstream) to get the unified diff under review. If there are
|
|
56
|
+
uncommitted changes, or the range diff is empty, also run `git diff HEAD` and
|
|
57
|
+
include the working-tree changes in scope — the review often runs before the
|
|
58
|
+
commit. If a PR number, branch name, or file path was passed as an argument,
|
|
59
|
+
review that target instead. Treat this diff as the review scope.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
# PARALLEL MODE (subagent tool available AND context not near-full)
|
|
64
|
+
|
|
65
|
+
`/code-simplify → 4 cleanup agents in parallel → apply the fixes`
|
|
66
|
+
|
|
67
|
+
## Phase 1 — Review (4 cleanup agents in parallel)
|
|
68
|
+
|
|
69
|
+
Launch **4 independent review agents** via the `subagent` tool, all in a single
|
|
70
|
+
message so they run concurrently (mode: parallel). Pass each agent the diff and
|
|
71
|
+
one of the four angles below. Each returns its findings with `file`, `line`, a
|
|
72
|
+
one-line `summary`, and the concrete cost (what is duplicated, wasted, or harder
|
|
73
|
+
to maintain).
|
|
74
|
+
|
|
75
|
+
### Reuse
|
|
76
|
+
Flag new code that re-implements something the codebase already has — Grep
|
|
77
|
+
shared/utility modules and files adjacent to the change, and name the existing
|
|
78
|
+
helper to call instead.
|
|
79
|
+
|
|
80
|
+
### Simplification
|
|
81
|
+
Flag unnecessary complexity the diff adds: redundant or derivable state,
|
|
82
|
+
copy-paste with slight variation, deep nesting, dead code left behind. Name the
|
|
83
|
+
simpler form that does the same job.
|
|
84
|
+
|
|
85
|
+
### Efficiency
|
|
86
|
+
Flag wasted work the diff introduces: redundant computation or repeated I/O,
|
|
87
|
+
independent operations run sequentially, blocking work added to startup or hot
|
|
88
|
+
paths. Also flag long-lived objects built from closures or captured environments
|
|
89
|
+
— they keep the entire enclosing scope alive for the object's lifetime (a memory
|
|
90
|
+
leak when that scope holds large values); prefer a class/struct that copies only
|
|
91
|
+
the fields it needs. Name the cheaper alternative.
|
|
92
|
+
|
|
93
|
+
### Altitude
|
|
94
|
+
Check that each change is implemented at the right depth, not as a fragile
|
|
95
|
+
bandaid. Special cases layered on shared infrastructure are a sign the fix isn't
|
|
96
|
+
deep enough — prefer generalizing the underlying mechanism over adding special
|
|
97
|
+
cases.
|
|
98
|
+
|
|
99
|
+
## Phase 2 — Apply the fixes
|
|
100
|
+
|
|
101
|
+
Wait for all four agents to complete, dedup findings that point at the same line
|
|
102
|
+
or mechanism, and fix each remaining one directly. Skip any finding whose fix
|
|
103
|
+
would change intended behavior, require changes well outside the reviewed diff,
|
|
104
|
+
or that you judge to be a false positive — note the skip rather than arguing
|
|
105
|
+
with it. Finish with a brief summary of what was fixed and what was skipped (or
|
|
106
|
+
confirm the code was already clean).
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
# SINGLE-PASS MODE (subagent tool unavailable OR context near-full)
|
|
111
|
+
|
|
112
|
+
`/code-simplify → subagent tool unavailable → single-pass inline cleanup → apply the fixes`
|
|
113
|
+
|
|
114
|
+
The `subagent` tool isn't available in this context (or context is near-full), so
|
|
115
|
+
the usual 4-agent fan-out can't run. Work through all four angles below yourself,
|
|
116
|
+
in this same context, in one pass — do not skip an angle for lack of fan-out.
|
|
117
|
+
|
|
118
|
+
## Phase 1 — Review (4 cleanup angles, single pass)
|
|
119
|
+
|
|
120
|
+
Review the diff against each angle below in turn. For each, note findings with
|
|
121
|
+
`file`, `line`, a one-line `summary`, and the concrete cost (what is duplicated,
|
|
122
|
+
wasted, or harder to maintain).
|
|
123
|
+
|
|
124
|
+
### Reuse
|
|
125
|
+
Flag new code that re-implements something the codebase already has — Grep
|
|
126
|
+
shared/utility modules and files adjacent to the change, and name the existing
|
|
127
|
+
helper to call instead.
|
|
128
|
+
|
|
129
|
+
### Simplification
|
|
130
|
+
Flag unnecessary complexity the diff adds: redundant or derivable state,
|
|
131
|
+
copy-paste with slight variation, deep nesting, dead code left behind. Name the
|
|
132
|
+
simpler form that does the same job.
|
|
133
|
+
|
|
134
|
+
### Efficiency
|
|
135
|
+
Flag wasted work the diff introduces: redundant computation or repeated I/O,
|
|
136
|
+
independent operations run sequentially, blocking work added to startup or hot
|
|
137
|
+
paths. Also flag long-lived objects built from closures or captured environments
|
|
138
|
+
— they keep the entire enclosing scope alive for the object's lifetime (a memory
|
|
139
|
+
leak when that scope holds large values); prefer a class/struct that copies only
|
|
140
|
+
the fields it needs. Name the cheaper alternative.
|
|
141
|
+
|
|
142
|
+
### Altitude
|
|
143
|
+
Check that each change is implemented at the right depth, not as a fragile
|
|
144
|
+
bandaid. Special cases layered on shared infrastructure are a sign the fix isn't
|
|
145
|
+
deep enough — prefer generalizing the underlying mechanism over adding special
|
|
146
|
+
cases.
|
|
147
|
+
|
|
148
|
+
## Phase 2 — Apply the fixes
|
|
149
|
+
|
|
150
|
+
Dedup findings that point at the same line or mechanism, and fix each remaining
|
|
151
|
+
one directly. Skip any finding whose fix would change intended behavior, require
|
|
152
|
+
changes well outside the reviewed diff, or that you judge to be a false positive
|
|
153
|
+
— note the skip rather than arguing with it. Finish with a brief summary of what
|
|
154
|
+
was fixed and what was skipped (or confirm the code was already clean). State
|
|
155
|
+
clearly in your summary that this was a single-pass review done without the
|
|
156
|
+
`subagent` tool, not the full 4-agent fan-out, so whoever reads it isn't misled
|
|
157
|
+
about what actually ran.
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/agent/dispatch.ts — agent dispatch via pi subprocess.
|
|
3
|
+
*
|
|
4
|
+
* Reuses the spawn pattern from examples/extensions/subagent and
|
|
5
|
+
* pi-dynamic-workflows/src/agent/dispatch.ts: one `pi --mode json -p
|
|
6
|
+
* --no-session` subprocess per agent call, stdout parsed for
|
|
7
|
+
* {message_end, tool_result_end} events, AbortSignal → SIGTERM with a
|
|
8
|
+
* 5s SIGKILL escalation.
|
|
9
|
+
*
|
|
10
|
+
* This is a SELF-CONTAINED copy (no pi-dynamic-workflows dependency): the
|
|
11
|
+
* workflow-engine retry/skip/lifecycle machinery is stripped, leaving only
|
|
12
|
+
* spawnAgent + mapWithConcurrencyLimit + a per-call abort registry. The
|
|
13
|
+
* subagent tool builds its single/parallel/chain modes on top of this.
|
|
14
|
+
*
|
|
15
|
+
* When pi promotes spawnAgent to a public @earendil-works/pi-coding-agent
|
|
16
|
+
* export, this file should be deleted in favor of that import.
|
|
17
|
+
*/
|
|
18
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
19
|
+
import { StringDecoder } from "node:string_decoder";
|
|
20
|
+
import * as fs from "node:fs";
|
|
21
|
+
import * as os from "node:os";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
24
|
+
|
|
25
|
+
/** Stable id for one agent call; the registry key for per-call abort. */
|
|
26
|
+
export type AgentCallId = string;
|
|
27
|
+
|
|
28
|
+
/** callId → per-call AbortController. */
|
|
29
|
+
export type AgentAbortMap = Map<AgentCallId, AbortController>;
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Concurrency limiter (ported from examples/extensions/subagent)
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Run `fn` over `items` with at most `concurrency` in flight, preserving
|
|
37
|
+
* input order in the output array. parallel mode builds on this.
|
|
38
|
+
*/
|
|
39
|
+
export async function mapWithConcurrencyLimit<TIn, TOut>(
|
|
40
|
+
items: TIn[],
|
|
41
|
+
concurrency: number,
|
|
42
|
+
fn: (item: TIn, index: number) => Promise<TOut>,
|
|
43
|
+
): Promise<TOut[]> {
|
|
44
|
+
if (items.length === 0) return [];
|
|
45
|
+
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
46
|
+
const results: TOut[] = new Array(items.length);
|
|
47
|
+
let nextIndex = 0;
|
|
48
|
+
// Stop dispatching NEW items once any worker has errored, so a rejection
|
|
49
|
+
// doesn't leave sibling workers pulling more items and spawning unawaited
|
|
50
|
+
// subprocesses. In-flight calls finish; the failing worker rethrows.
|
|
51
|
+
let failed = false;
|
|
52
|
+
const workers = new Array(limit).fill(null).map(async () => {
|
|
53
|
+
while (!failed) {
|
|
54
|
+
const current = nextIndex++;
|
|
55
|
+
if (current >= items.length) return;
|
|
56
|
+
try {
|
|
57
|
+
results[current] = await fn(items[current], current);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
failed = true;
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
await Promise.all(workers);
|
|
65
|
+
return results;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// pi binary resolution (ported from examples/extensions/subagent)
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the `pi` invocation for the subprocess. Prefers re-entering the
|
|
74
|
+
* current script (node <script> / bun <script>); falls back to the `pi`
|
|
75
|
+
* binary on PATH when run under a generic runtime.
|
|
76
|
+
*/
|
|
77
|
+
export function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
78
|
+
const currentScript = process.argv[1];
|
|
79
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
80
|
+
if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
|
|
81
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const execName = path.basename(process.execPath).toLowerCase();
|
|
85
|
+
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
|
|
86
|
+
if (!isGenericRuntime) {
|
|
87
|
+
return { command: process.execPath, args };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { command: "pi", args };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Usage + result
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
export interface AgentUsage {
|
|
98
|
+
input: number;
|
|
99
|
+
output: number;
|
|
100
|
+
cacheRead: number;
|
|
101
|
+
cacheWrite: number;
|
|
102
|
+
cost: number;
|
|
103
|
+
contextTokens: number;
|
|
104
|
+
turns: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface AgentSpawnOptions {
|
|
108
|
+
/** Stable id for this call; the registry key for per-call abort. */
|
|
109
|
+
readonly callId: AgentCallId;
|
|
110
|
+
/** Prompt passed as the final positional arg to `pi -p`. */
|
|
111
|
+
readonly task: string;
|
|
112
|
+
/** Working directory for the spawned pi process. Defaults to process.cwd(). */
|
|
113
|
+
readonly cwd?: string;
|
|
114
|
+
/** `--model` override. */
|
|
115
|
+
readonly model?: string;
|
|
116
|
+
/** `--tools` whitelist (comma-joined). */
|
|
117
|
+
readonly tools?: string[];
|
|
118
|
+
/** System prompt appended via a temp file (`--append-system-prompt`). */
|
|
119
|
+
readonly systemPrompt?: string;
|
|
120
|
+
/** Caller-level abort signal; linked to this call's per-call controller. */
|
|
121
|
+
readonly signal?: AbortSignal;
|
|
122
|
+
/** Max assistant turns. When reached, the subprocess is aborted (SIGTERM). Omit for unlimited. */
|
|
123
|
+
readonly maxTurns?: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface AgentSpawnResult {
|
|
127
|
+
callId: AgentCallId;
|
|
128
|
+
exitCode: number;
|
|
129
|
+
messages: Message[];
|
|
130
|
+
stderr: string;
|
|
131
|
+
usage: AgentUsage;
|
|
132
|
+
model?: string;
|
|
133
|
+
stopReason?: string;
|
|
134
|
+
errorMessage?: string;
|
|
135
|
+
/** True if aborted. exitCode may be null/non-zero. */
|
|
136
|
+
aborted: boolean;
|
|
137
|
+
/** True if killed because the caller's maxTurns budget was reached. Distinct
|
|
138
|
+
* from `aborted` (external cancel): the agent did useful bounded work. */
|
|
139
|
+
maxTurnsReached: boolean;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Registry — Map<callId, ChildProcess> + per-call AbortController
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
export interface AgentSpawnRegistry {
|
|
147
|
+
/** callId → child process. The table that translates abort → SIGTERM on one process. */
|
|
148
|
+
readonly processes: Map<AgentCallId, ChildProcess>;
|
|
149
|
+
/** callId → per-call controller. */
|
|
150
|
+
readonly controllers: AgentAbortMap;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function createSpawnRegistry(): AgentSpawnRegistry {
|
|
154
|
+
return {
|
|
155
|
+
processes: new Map(),
|
|
156
|
+
controllers: new Map(),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Abort exactly one in-flight call by id. Aborts the call's per-call
|
|
162
|
+
* controller; spawnAgent's race-safe listener translates that into
|
|
163
|
+
* SIGTERM→SIGKILL on exactly the one subprocess. Returns false if the
|
|
164
|
+
* callId is not in flight.
|
|
165
|
+
*/
|
|
166
|
+
export function abortAgent(registry: AgentSpawnRegistry, callId: AgentCallId): boolean {
|
|
167
|
+
const controller = registry.controllers.get(callId);
|
|
168
|
+
if (!controller) return false;
|
|
169
|
+
controller.abort();
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Spawn — the dispatch primitive
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
export async function spawnAgent(
|
|
178
|
+
registry: AgentSpawnRegistry,
|
|
179
|
+
options: AgentSpawnOptions,
|
|
180
|
+
): Promise<AgentSpawnResult> {
|
|
181
|
+
const { callId, task, cwd, model, tools, systemPrompt, signal } = options;
|
|
182
|
+
|
|
183
|
+
// Per-call controller — the abort entry point.
|
|
184
|
+
const controller = new AbortController();
|
|
185
|
+
registry.controllers.set(callId, controller);
|
|
186
|
+
|
|
187
|
+
// Link the caller-level signal to this call's controller so a run-wide
|
|
188
|
+
// abort reaches every in-flight call. Named + removed in finally — otherwise
|
|
189
|
+
// a normally-completing call leaks a listener on the parent signal.
|
|
190
|
+
const onParentAbort = (): void => controller.abort();
|
|
191
|
+
if (signal) {
|
|
192
|
+
if (signal.aborted) controller.abort();
|
|
193
|
+
else signal.addEventListener("abort", onParentAbort);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const args: string[] = ["--mode", "json", "-p", "--no-session"];
|
|
197
|
+
if (model) args.push("--model", model);
|
|
198
|
+
if (tools && tools.length > 0) args.push("--tools", tools.join(","));
|
|
199
|
+
|
|
200
|
+
let tmpPromptDir: string | null = null;
|
|
201
|
+
let tmpPromptPath: string | null = null;
|
|
202
|
+
|
|
203
|
+
const result: AgentSpawnResult = {
|
|
204
|
+
callId,
|
|
205
|
+
exitCode: 0,
|
|
206
|
+
messages: [],
|
|
207
|
+
stderr: "",
|
|
208
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
209
|
+
aborted: false,
|
|
210
|
+
maxTurnsReached: false,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
if (systemPrompt && systemPrompt.trim()) {
|
|
215
|
+
const tmp = await writePromptToTempFile(callId, systemPrompt);
|
|
216
|
+
tmpPromptDir = tmp.dir;
|
|
217
|
+
tmpPromptPath = tmp.filePath;
|
|
218
|
+
args.push("--append-system-prompt", tmpPromptPath);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// The prompt is the final positional arg consumed by `-p`.
|
|
222
|
+
args.push(task);
|
|
223
|
+
|
|
224
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
225
|
+
const invocation = getPiInvocation(args);
|
|
226
|
+
const proc = spawn(invocation.command, invocation.args, {
|
|
227
|
+
cwd: cwd ?? process.cwd(),
|
|
228
|
+
shell: false,
|
|
229
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
230
|
+
});
|
|
231
|
+
registry.processes.set(callId, proc);
|
|
232
|
+
|
|
233
|
+
let buffer = "";
|
|
234
|
+
const decoder = new StringDecoder("utf8");
|
|
235
|
+
|
|
236
|
+
const processLine = (line: string) => {
|
|
237
|
+
if (!line.trim()) return;
|
|
238
|
+
let event: { type: string; message?: Message };
|
|
239
|
+
try {
|
|
240
|
+
event = JSON.parse(line) as { type: string; message?: Message };
|
|
241
|
+
} catch {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (event.type === "message_end" && event.message) {
|
|
246
|
+
const msg = event.message;
|
|
247
|
+
result.messages.push(msg);
|
|
248
|
+
if (msg.role === "assistant") {
|
|
249
|
+
result.usage.turns++;
|
|
250
|
+
// Enforce maxTurns: abort the subprocess when the limit is reached.
|
|
251
|
+
// killProc handles the SIGTERM→SIGKILL escalation. Use `!= null` so an
|
|
252
|
+
// explicit maxTurns: 0 is honored (and marked) rather than treated as "unset".
|
|
253
|
+
if (options.maxTurns != null && result.usage.turns >= options.maxTurns) {
|
|
254
|
+
result.maxTurnsReached = true;
|
|
255
|
+
controller.abort();
|
|
256
|
+
}
|
|
257
|
+
const usage = msg.usage;
|
|
258
|
+
if (usage) {
|
|
259
|
+
result.usage.input += usage.input || 0;
|
|
260
|
+
result.usage.output += usage.output || 0;
|
|
261
|
+
result.usage.cacheRead += usage.cacheRead || 0;
|
|
262
|
+
result.usage.cacheWrite += usage.cacheWrite || 0;
|
|
263
|
+
result.usage.cost += Number(usage.cost?.total) || 0;
|
|
264
|
+
result.usage.contextTokens = usage.totalTokens || 0;
|
|
265
|
+
}
|
|
266
|
+
if (!result.model && msg.model) result.model = msg.model;
|
|
267
|
+
if (msg.stopReason) result.stopReason = msg.stopReason;
|
|
268
|
+
if (msg.errorMessage) result.errorMessage = msg.errorMessage;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (event.type === "tool_result_end" && event.message) {
|
|
273
|
+
result.messages.push(event.message);
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
proc.stdout.on("data", (data) => {
|
|
278
|
+
// StringDecoder buffers incomplete multi-byte UTF-8 sequences across chunk
|
|
279
|
+
// boundaries so a CJK char split between two `data` events isn't replaced
|
|
280
|
+
// with U+FFFD (which would corrupt the line and silently drop the event).
|
|
281
|
+
buffer += decoder.write(data);
|
|
282
|
+
const lines = buffer.split("\n");
|
|
283
|
+
buffer = lines.pop() || "";
|
|
284
|
+
for (const line of lines) processLine(line);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
proc.stderr.on("data", (data) => {
|
|
288
|
+
result.stderr += data.toString();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
proc.on("close", (code) => {
|
|
292
|
+
const tail = decoder.end();
|
|
293
|
+
if (tail) buffer += tail;
|
|
294
|
+
if (buffer.trim()) processLine(buffer);
|
|
295
|
+
resolve(code ?? 1); // code===null → signal-killed (OOM/SIGKILL): treat as failure, not silent empty success
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
proc.on("error", (err) => {
|
|
299
|
+
// Surface the spawn error (e.g. ENOENT when `pi` is not on PATH) instead
|
|
300
|
+
// of swallowing it.
|
|
301
|
+
result.errorMessage = err.message;
|
|
302
|
+
result.stderr += err.message;
|
|
303
|
+
resolve(1);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Per-call abort → SIGTERM (SIGKILL after 5s). Race-safe: if the controller
|
|
307
|
+
// was already aborted before this listener registered, kill now.
|
|
308
|
+
const killProc = () => {
|
|
309
|
+
// Late-abort guard: if the proc already exited, don't flip a successful
|
|
310
|
+
// result's `aborted` flag.
|
|
311
|
+
if (proc.exitCode !== null || proc.signalCode !== null) return;
|
|
312
|
+
result.aborted = true;
|
|
313
|
+
proc.kill("SIGTERM");
|
|
314
|
+
const timer = setTimeout(() => {
|
|
315
|
+
// SIGTERM may be ignored — force SIGKILL after the grace period.
|
|
316
|
+
proc.kill("SIGKILL");
|
|
317
|
+
}, 5000);
|
|
318
|
+
// Clear the timer once the proc exits so we don't leak a libuv handle.
|
|
319
|
+
proc.once("close", () => clearTimeout(timer));
|
|
320
|
+
};
|
|
321
|
+
if (controller.signal.aborted) killProc();
|
|
322
|
+
else controller.signal.addEventListener("abort", killProc, { once: true });
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
result.exitCode = exitCode;
|
|
326
|
+
return result;
|
|
327
|
+
} finally {
|
|
328
|
+
// Always release registry slots, the parent-signal listener, and temp files.
|
|
329
|
+
registry.processes.delete(callId);
|
|
330
|
+
registry.controllers.delete(callId);
|
|
331
|
+
if (signal) signal.removeEventListener("abort", onParentAbort);
|
|
332
|
+
if (tmpPromptPath)
|
|
333
|
+
try {
|
|
334
|
+
fs.unlinkSync(tmpPromptPath);
|
|
335
|
+
} catch {
|
|
336
|
+
/* ignore */
|
|
337
|
+
}
|
|
338
|
+
if (tmpPromptDir)
|
|
339
|
+
try {
|
|
340
|
+
fs.rmdirSync(tmpPromptDir);
|
|
341
|
+
} catch {
|
|
342
|
+
/* ignore */
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function writePromptToTempFile(callId: string, prompt: string): Promise<{ dir: string; filePath: string }> {
|
|
348
|
+
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-cr-agent-"));
|
|
349
|
+
const safeName = callId.replace(/[^\w.-]+/g, "_");
|
|
350
|
+
const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
|
|
351
|
+
await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
|
|
352
|
+
return { dir: tmpDir, filePath };
|
|
353
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { bundledSkillPath } from "../skills.ts";
|
|
6
|
+
|
|
7
|
+
/** Effort levels the /code-review command accepts (mirrors CC's effort enum). */
|
|
8
|
+
export const REVIEW_LEVELS = ["low", "medium", "high", "xhigh", "max"] as const;
|
|
9
|
+
export type ReviewLevel = (typeof REVIEW_LEVELS)[number];
|
|
10
|
+
|
|
11
|
+
const DEFAULT_LEVEL: ReviewLevel = "low";
|
|
12
|
+
|
|
13
|
+
/** Where the last explicitly-typed effort is persisted (CC 2.1.223 codeReviewLastEffort). */
|
|
14
|
+
const STATE_FILE = path.join(os.homedir(), ".pi", ".pi-review-state.json");
|
|
15
|
+
|
|
16
|
+
export type EffortSource = "explicit" | "last-used" | "default";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse a leading effort level out of raw args; the remainder (flags + target)
|
|
20
|
+
* is returned verbatim. Pure — unit-testable. Mirrors CC's ecl()/Tjn(): the
|
|
21
|
+
* first token is the level only if it matches the enum; otherwise the whole
|
|
22
|
+
* string is the target/flags.
|
|
23
|
+
*/
|
|
24
|
+
export function parseReviewArgs(args: string): { level: ReviewLevel | undefined; rest: string } {
|
|
25
|
+
const tokens = (args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
26
|
+
if (tokens.length === 0) return { level: undefined, rest: "" };
|
|
27
|
+
const first = tokens[0]!.toLowerCase();
|
|
28
|
+
const isLevel = (REVIEW_LEVELS as readonly string[]).includes(first);
|
|
29
|
+
return {
|
|
30
|
+
level: isLevel ? (first as ReviewLevel) : undefined,
|
|
31
|
+
rest: isLevel ? tokens.slice(1).join(" ") : tokens.join(" "),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the effective effort + where it came from. Pure — unit-testable.
|
|
37
|
+
* Mirrors CC 2.1.223: explicit wins; otherwise reuse the last-typed level;
|
|
38
|
+
* otherwise the default. (220 defaulted straight to low with no memory.)
|
|
39
|
+
*/
|
|
40
|
+
export function resolveEffort(
|
|
41
|
+
explicit: ReviewLevel | undefined,
|
|
42
|
+
lastUsed: ReviewLevel | undefined,
|
|
43
|
+
): { level: ReviewLevel; source: EffortSource } {
|
|
44
|
+
if (explicit) return { level: explicit, source: "explicit" };
|
|
45
|
+
if (lastUsed) return { level: lastUsed, source: "last-used" };
|
|
46
|
+
return { level: DEFAULT_LEVEL, source: "default" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Best-effort persistence — sticky-effort is a convenience, not a correctness
|
|
50
|
+
// invariant; a read/write failure must not break the review.
|
|
51
|
+
function readLastEffort(): ReviewLevel | undefined {
|
|
52
|
+
try {
|
|
53
|
+
const raw = JSON.parse(fs.readFileSync(STATE_FILE, "utf8")) as { codeReviewLastEffort?: unknown };
|
|
54
|
+
const v = raw.codeReviewLastEffort;
|
|
55
|
+
return typeof v === "string" && (REVIEW_LEVELS as readonly string[]).includes(v)
|
|
56
|
+
? (v as ReviewLevel)
|
|
57
|
+
: undefined;
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function writeLastEffort(level: ReviewLevel): void {
|
|
63
|
+
try {
|
|
64
|
+
fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
|
|
65
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify({ codeReviewLastEffort: level }));
|
|
66
|
+
} catch {
|
|
67
|
+
/* ignore — non-critical */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Register the /code-review command — triggers the code-review skill.
|
|
73
|
+
*
|
|
74
|
+
* Handler decides the effective effort deterministically (CC 2.1.223): an
|
|
75
|
+
* explicit level is persisted and used; with no level, the last-typed level is
|
|
76
|
+
* reused; with no history, it falls back to low. The decision is announced in
|
|
77
|
+
* the trigger message so it is observable — same pattern as /code-simplify.
|
|
78
|
+
*/
|
|
79
|
+
export function registerCodeReview(pi: ExtensionAPI): void {
|
|
80
|
+
pi.registerCommand("code-review", {
|
|
81
|
+
description:
|
|
82
|
+
"Review the current diff using the code-review skill. Usage: /code-review [low|medium|high|xhigh|max] [--fix] [--comment] [--share] [<pr#>|<branch>|<path>]",
|
|
83
|
+
getArgumentCompletions(prefix) {
|
|
84
|
+
const tokens = ["low", "medium", "high", "xhigh", "max", "--fix", "--comment", "--share"];
|
|
85
|
+
return tokens.filter((t) => t.startsWith(prefix)).map((t) => ({ label: t, value: t }));
|
|
86
|
+
},
|
|
87
|
+
async handler(args) {
|
|
88
|
+
const { level: explicit, rest } = parseReviewArgs(args ?? "");
|
|
89
|
+
// Skip the read when we just wrote it — resolveEffort returns `explicit` unchanged.
|
|
90
|
+
const lastUsed = explicit ? undefined : readLastEffort();
|
|
91
|
+
if (explicit) writeLastEffort(explicit); // CC 2.1.223: remember the explicit level
|
|
92
|
+
const { level, source } = resolveEffort(explicit, lastUsed);
|
|
93
|
+
pi.sendUserMessage(
|
|
94
|
+
`Run a code review now. Effective effort: ${level} (${source})${rest ? `; extra args: ${rest}` : ""}.\n\n` +
|
|
95
|
+
`First load the review skill with the read tool: ${bundledSkillPath("code-review/SKILL.md")}. ` +
|
|
96
|
+
`Then follow it exactly — use the \`subagent\` tool for any fan-out / verify / gap-hunt the skill calls for.`,
|
|
97
|
+
);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { bundledSkillPath } from "../skills.ts";
|
|
3
|
+
|
|
4
|
+
/** Context fraction at which we fall back to single-pass — a Pi-specific heuristic (see decideSimplifyMode). */
|
|
5
|
+
const CONTEXT_NEAR_FULL_THRESHOLD = 0.8;
|
|
6
|
+
|
|
7
|
+
export type SimplifyMode = "parallel" | "single-pass";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Decide simplify mode deterministically from real context usage + tool availability.
|
|
11
|
+
* Pure function — unit-testable.
|
|
12
|
+
*
|
|
13
|
+
* CC parity note: CC's /simplify guard (_Yo) is a SPAWN-DEPTH recursion limit, NOT a
|
|
14
|
+
* context check — `RO(ctx) >= dne()` where RO returns the agent's depth and dne()
|
|
15
|
+
* returns CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH (default 3). That is N/A on Pi: the
|
|
16
|
+
* `subagent` tool spawns a fresh subprocess (depth 0), so depth never accumulates.
|
|
17
|
+
* The context-fraction heuristic below is a Pi-specific substitute (don't fan out
|
|
18
|
+
* when the parent's context is near-full), NOT a mirror of _Yo. The other _Yo clause
|
|
19
|
+
* — the Agent tool must be in the allowlist — IS mirrored here as `hasSubagent`.
|
|
20
|
+
*/
|
|
21
|
+
export function decideSimplifyMode(opts: {
|
|
22
|
+
tokens: number | null;
|
|
23
|
+
contextWindow: number;
|
|
24
|
+
hasSubagent: boolean;
|
|
25
|
+
}): SimplifyMode {
|
|
26
|
+
const { tokens, contextWindow, hasSubagent } = opts;
|
|
27
|
+
// Conservative: if we can't measure context (tokens unknown / window 0) or the
|
|
28
|
+
// subagent tool isn't registered, don't risk fan-out — go single-pass.
|
|
29
|
+
if (tokens == null || contextWindow <= 0 || !hasSubagent) return "single-pass";
|
|
30
|
+
const nearFull = tokens / contextWindow >= CONTEXT_NEAR_FULL_THRESHOLD;
|
|
31
|
+
return nearFull ? "single-pass" : "parallel";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Register the /code-simplify command. The handler decides parallel vs single-pass from
|
|
36
|
+
* ctx.getContextUsage() (real token count) + subagent tool availability — this is the
|
|
37
|
+
* deterministic Jvo guard that a pure-prompt skill cannot reproduce.
|
|
38
|
+
*/
|
|
39
|
+
export function registerSimplify(pi: ExtensionAPI): void {
|
|
40
|
+
pi.registerCommand("code-simplify", {
|
|
41
|
+
description:
|
|
42
|
+
"Clean up the changed code (reuse/simplification/efficiency/altitude) using the simplify skill. Mode (parallel 4-agent vs single-pass) is decided by the handler from real context usage. Usage: /code-simplify [<target>]",
|
|
43
|
+
async handler(args, ctx) {
|
|
44
|
+
const usage = ctx.getContextUsage();
|
|
45
|
+
const hasSubagent = pi.getAllTools().some((t) => t.name === "subagent");
|
|
46
|
+
const mode = decideSimplifyMode({
|
|
47
|
+
tokens: usage?.tokens ?? null,
|
|
48
|
+
contextWindow: usage?.contextWindow ?? 0,
|
|
49
|
+
hasSubagent,
|
|
50
|
+
});
|
|
51
|
+
const pct =
|
|
52
|
+
usage && usage.tokens != null && usage.contextWindow > 0
|
|
53
|
+
? Math.round((usage.tokens / usage.contextWindow) * 100) + "%"
|
|
54
|
+
: "?";
|
|
55
|
+
const bodyLabel = mode === "parallel" ? "PARALLEL MODE" : "SINGLE-PASS MODE";
|
|
56
|
+
pi.sendUserMessage(
|
|
57
|
+
`Clean up the changed code now. Target: ${args || "(whole diff)"}.\n\n` +
|
|
58
|
+
`Handler decided ${mode} mode (context ${pct} full, subagent ${hasSubagent ? "available" : "absent"}). ` +
|
|
59
|
+
`Load ${bundledSkillPath("simplify/SKILL.md")} via the read tool and follow the ${bodyLabel} body. ` +
|
|
60
|
+
(mode === "parallel"
|
|
61
|
+
? `Use the \`subagent\` tool (mode: parallel) for the 4-agent fan-out.`
|
|
62
|
+
: `Work the four angles inline — do not fake fan-out.`),
|
|
63
|
+
);
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|