pi-harness-delegate 0.2.2 → 0.3.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/README.md +35 -12
- package/extensions/activity.ts +212 -16
- package/extensions/command.ts +83 -6
- package/extensions/harnesses/amp.ts +122 -99
- package/extensions/harnesses/claude.ts +8 -3
- package/extensions/harnesses/codex.ts +92 -45
- package/extensions/harnesses/opencode.ts +86 -74
- package/extensions/harnesses/types.ts +6 -4
- package/extensions/index.ts +617 -206
- package/extensions/notify.ts +50 -0
- package/extensions/progress.ts +1 -1
- package/extensions/run-registry.ts +90 -0
- package/extensions/templates.ts +3 -0
- package/extensions/usage.ts +8 -4
- package/package.json +1 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batches successful fan-out completion notifications so `/delegate all …` emits one
|
|
3
|
+
* notification instead of one per harness. Failures are never delayed or batched — they
|
|
4
|
+
* flush any pending batch and are emitted immediately. Confined to the notification path;
|
|
5
|
+
* not a general event system.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type NotifyLevel = 'info' | 'warning' | 'error';
|
|
9
|
+
export type NotifyFn = (text: string, level: NotifyLevel) => void;
|
|
10
|
+
|
|
11
|
+
/** Pure joiner for a batch of success lines — testable without timers. */
|
|
12
|
+
export function joinBatch(lines: string[]): string {
|
|
13
|
+
if (lines.length === 1) return lines[0];
|
|
14
|
+
return `${lines.length} runs completed:\n${lines.map(l => ` · ${l}`).join('\n')}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class NotifyBatcher {
|
|
18
|
+
private pending: string[] = [];
|
|
19
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly emit: NotifyFn,
|
|
23
|
+
private readonly debounceMs = 400,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
/** Queue a successful-completion line; flushes as one combined notification after a quiet period. */
|
|
27
|
+
success(line: string): void {
|
|
28
|
+
this.pending.push(line);
|
|
29
|
+
if (this.timer) clearTimeout(this.timer);
|
|
30
|
+
this.timer = setTimeout(() => this.flush(), this.debounceMs);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Flush any pending batch immediately, then emit this failure on its own — never delayed. */
|
|
34
|
+
failure(line: string): void {
|
|
35
|
+
this.flush();
|
|
36
|
+
this.emit(line, 'error');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Emit whatever is pending as one notification, then clear it. No-op when nothing is pending. */
|
|
40
|
+
flush(): void {
|
|
41
|
+
if (this.timer) {
|
|
42
|
+
clearTimeout(this.timer);
|
|
43
|
+
this.timer = null;
|
|
44
|
+
}
|
|
45
|
+
if (this.pending.length === 0) return;
|
|
46
|
+
const lines = this.pending;
|
|
47
|
+
this.pending = [];
|
|
48
|
+
this.emit(joinBatch(lines), 'info');
|
|
49
|
+
}
|
|
50
|
+
}
|
package/extensions/progress.ts
CHANGED
|
@@ -16,7 +16,7 @@ const SPIN_INTERVAL_MS = 100;
|
|
|
16
16
|
const MAX_VISIBLE_ENTRIES = 12;
|
|
17
17
|
|
|
18
18
|
export type FeedEntry =
|
|
19
|
-
| { kind: 'tool'; text: string; ok?: boolean }
|
|
19
|
+
| { kind: 'tool'; text: string; ok?: boolean; id?: string }
|
|
20
20
|
| { kind: 'thinking'; text: string }
|
|
21
21
|
| { kind: 'text'; text: string };
|
|
22
22
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-based active-run registry — makes the concurrency guard and `/delegate status`
|
|
3
|
+
* active count accurate across pi processes, not just the current one.
|
|
4
|
+
*
|
|
5
|
+
* One small JSON file per active run in `<agentDir>/delegate/runs/`. Best-effort throughout:
|
|
6
|
+
* registry I/O failures never break a delegation — callers should combine this with their own
|
|
7
|
+
* in-process counters as a fallback.
|
|
8
|
+
*
|
|
9
|
+
* Concurrency cap is best-effort, not a hard mutex: `countActiveRuns()` (read) and
|
|
10
|
+
* `acquireRun()` (write) are two separate steps with no lock between them, so two pi
|
|
11
|
+
* processes starting at the same instant can both observe a count under the limit and both
|
|
12
|
+
* proceed — `maxConcurrent` can be exceeded by a small margin under a tight race. This is a
|
|
13
|
+
* deliberate simplicity tradeoff (see AGENTS.md); do not rely on it for a hard cap.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { agentDir } from './config.ts';
|
|
19
|
+
|
|
20
|
+
function runsDir(): string {
|
|
21
|
+
return join(agentDir(), 'delegate', 'runs');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isAlive(pid: number): boolean {
|
|
25
|
+
try {
|
|
26
|
+
process.kill(pid, 0);
|
|
27
|
+
return true;
|
|
28
|
+
} catch (err) {
|
|
29
|
+
// ESRCH: no such process. EPERM: exists but we can't signal it — still alive.
|
|
30
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RunHandle {
|
|
35
|
+
file: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Register an active run. Returns null (never throws) if the registry can't be written. */
|
|
39
|
+
export function acquireRun(harness: string, mode: string): RunHandle | null {
|
|
40
|
+
try {
|
|
41
|
+
const dir = runsDir();
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
const file = join(dir, `${process.pid}-${harness}-${Math.random().toString(36).slice(2, 8)}.json`);
|
|
44
|
+
writeFileSync(file, JSON.stringify({ pid: process.pid, harness, mode, startedAt: Date.now() }));
|
|
45
|
+
return { file };
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Release a previously-acquired run. Best-effort — never throws. */
|
|
52
|
+
export function releaseRun(handle: RunHandle | null): void {
|
|
53
|
+
if (!handle) return;
|
|
54
|
+
try {
|
|
55
|
+
rmSync(handle.file, { force: true });
|
|
56
|
+
} catch {
|
|
57
|
+
// best-effort
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Count active runs across processes (optionally filtered to one harness), cleaning up
|
|
62
|
+
* entries left behind by dead processes. Returns 0 (never throws) if the registry is unreadable. */
|
|
63
|
+
export function countActiveRuns(harness?: string): number {
|
|
64
|
+
let files: string[];
|
|
65
|
+
try {
|
|
66
|
+
files = readdirSync(runsDir());
|
|
67
|
+
} catch {
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
let count = 0;
|
|
71
|
+
for (const f of files) {
|
|
72
|
+
if (!f.endsWith('.json')) continue;
|
|
73
|
+
const full = join(runsDir(), f);
|
|
74
|
+
try {
|
|
75
|
+
const data = JSON.parse(readFileSync(full, 'utf8')) as { pid: number; harness: string };
|
|
76
|
+
if (typeof data.pid === 'number' && isAlive(data.pid)) {
|
|
77
|
+
if (!harness || data.harness === harness) count++;
|
|
78
|
+
} else {
|
|
79
|
+
rmSync(full, { force: true }); // stale (dead pid) or corrupt (non-numeric pid)
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
try {
|
|
83
|
+
rmSync(full, { force: true }); // corrupt entry
|
|
84
|
+
} catch {
|
|
85
|
+
// best-effort
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return count;
|
|
90
|
+
}
|
package/extensions/templates.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface DelegateTemplate {
|
|
|
28
28
|
skill?: string;
|
|
29
29
|
defaultTask?: string;
|
|
30
30
|
defaultScope?: string;
|
|
31
|
+
/** Host-run shell command executed after the harness exits to check its claims (e.g. `bun test`). */
|
|
32
|
+
verify?: string;
|
|
31
33
|
prompt: string;
|
|
32
34
|
harness?: string;
|
|
33
35
|
}
|
|
@@ -95,6 +97,7 @@ export function parseTemplate(text: string): DelegateTemplate | null {
|
|
|
95
97
|
skill: meta.skill || undefined,
|
|
96
98
|
defaultTask: meta.defaultTask || undefined,
|
|
97
99
|
defaultScope: meta.defaultScope || undefined,
|
|
100
|
+
verify: meta.verify || undefined,
|
|
98
101
|
prompt: m[2].trim(),
|
|
99
102
|
harness: meta.harness || undefined,
|
|
100
103
|
};
|
package/extensions/usage.ts
CHANGED
|
@@ -5,16 +5,20 @@ export interface HarnessUsage {
|
|
|
5
5
|
outputTokens: number;
|
|
6
6
|
cacheCreationInputTokens: number;
|
|
7
7
|
cacheReadInputTokens: number;
|
|
8
|
-
|
|
8
|
+
/** null when the harness didn't report cost — distinct from a measured $0. */
|
|
9
|
+
totalCostUsd: number | null;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export type ClaudeUsage = HarnessUsage;
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Map harness usage/cost into pi's `Usage` shape so delegated runs appear
|
|
15
|
-
* in the pi footer token/cost stats and /session totals.
|
|
16
|
+
* in the pi footer token/cost stats and /session totals. Returns undefined when cost
|
|
17
|
+
* is unknown — `Usage.cost.total` is mandatory, so there's no honest number to put there,
|
|
18
|
+
* and reporting a fake $0 would silently under-report spend in pi's session totals.
|
|
16
19
|
*/
|
|
17
|
-
export function mapHarnessUsage(u: HarnessUsage): Usage {
|
|
20
|
+
export function mapHarnessUsage(u: HarnessUsage): Usage | undefined {
|
|
21
|
+
if (u.totalCostUsd === null) return undefined;
|
|
18
22
|
const input = u.inputTokens + u.cacheCreationInputTokens;
|
|
19
23
|
const cacheRead = u.cacheReadInputTokens;
|
|
20
24
|
const output = u.outputTokens;
|
|
@@ -35,6 +39,6 @@ export function mapHarnessUsage(u: HarnessUsage): Usage {
|
|
|
35
39
|
};
|
|
36
40
|
}
|
|
37
41
|
|
|
38
|
-
export function mapClaudeUsage(u: ClaudeUsage): Usage {
|
|
42
|
+
export function mapClaudeUsage(u: ClaudeUsage): Usage | undefined {
|
|
39
43
|
return mapHarnessUsage(u);
|
|
40
44
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-harness-delegate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Delegate work to any harness (Claude Code, Muse, OpenCode, Amp) from the pi coding agent \u2014 code reviews, plans, implementation, security audits, docs, or your own custom templates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "bun@1.3.14",
|