pi-daddy 0.14.0 → 0.16.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/CHANGELOG.md +123 -0
- package/README.md +37 -14
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +10 -4
- package/dist/cli.js.map +1 -1
- package/dist/executor.d.ts +38 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/executor.js +93 -0
- package/dist/executor.js.map +1 -0
- package/dist/grant-store.d.ts +68 -0
- package/dist/grant-store.d.ts.map +1 -0
- package/dist/grant-store.js +142 -0
- package/dist/grant-store.js.map +1 -0
- package/dist/herdr-cli.d.ts +78 -0
- package/dist/herdr-cli.d.ts.map +1 -0
- package/dist/herdr-cli.js +113 -0
- package/dist/herdr-cli.js.map +1 -0
- package/dist/herdr-name.d.ts +37 -0
- package/dist/herdr-name.d.ts.map +1 -0
- package/dist/herdr-name.js +59 -0
- package/dist/herdr-name.js.map +1 -0
- package/dist/herdr-poll.d.ts +104 -0
- package/dist/herdr-poll.d.ts.map +1 -0
- package/dist/herdr-poll.js +150 -0
- package/dist/herdr-poll.js.map +1 -0
- package/dist/herdr-stage.d.ts +40 -0
- package/dist/herdr-stage.d.ts.map +1 -0
- package/dist/herdr-stage.js +54 -0
- package/dist/herdr-stage.js.map +1 -0
- package/dist/ledger-report.d.ts +18 -0
- package/dist/ledger-report.d.ts.map +1 -1
- package/dist/ledger-report.js +10 -0
- package/dist/ledger-report.js.map +1 -1
- package/dist/ledger.d.ts +17 -0
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +1 -0
- package/dist/ledger.js.map +1 -1
- package/dist/pane-reaper.d.ts +66 -4
- package/dist/pane-reaper.d.ts.map +1 -1
- package/dist/pane-reaper.js +131 -9
- package/dist/pane-reaper.js.map +1 -1
- package/dist/progress.d.ts +96 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +167 -0
- package/dist/progress.js.map +1 -0
- package/dist/run-child.d.ts +27 -0
- package/dist/run-child.d.ts.map +1 -1
- package/dist/run-child.js +84 -7
- package/dist/run-child.js.map +1 -1
- package/dist/run-herdr.d.ts +41 -28
- package/dist/run-herdr.d.ts.map +1 -1
- package/dist/run-herdr.js +150 -167
- package/dist/run-herdr.js.map +1 -1
- package/dist/skill-packages.d.ts +16 -0
- package/dist/skill-packages.d.ts.map +1 -1
- package/dist/skill-packages.js +39 -10
- package/dist/skill-packages.js.map +1 -1
- package/extensions/delegation.ts +94 -2
- package/extensions/grants-command.ts +57 -1
- package/extensions/grants.ts +109 -165
- package/extensions/init-command.ts +132 -0
- package/extensions/run-delegation.ts +70 -8
- package/extensions/session-report.ts +231 -0
- package/extensions/session.ts +138 -17
- package/extensions/tripwire.ts +44 -0
- package/package.json +17 -1
- package/src/cli.ts +10 -4
- package/src/executor.ts +122 -0
- package/src/grant-store.ts +151 -0
- package/src/herdr-cli.ts +125 -0
- package/src/herdr-name.ts +61 -0
- package/src/herdr-poll.ts +185 -0
- package/src/herdr-stage.ts +55 -0
- package/src/ledger-report.ts +21 -0
- package/src/ledger.ts +18 -0
- package/src/pane-reaper.ts +147 -9
- package/src/progress.ts +206 -0
- package/src/run-child.ts +96 -7
- package/src/run-herdr.ts +170 -174
- package/src/skill-packages.ts +41 -10
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staging a definition's instructions to a file, because herdr cannot carry them in argv.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `src/run-herdr.ts` at the 400-line ceiling. A real seam rather than an arbitrary cut: this is
|
|
5
|
+
* the one place that works around a **herdr encoding limit**, and it is the only part of the herdr path that
|
|
6
|
+
* touches the filesystem. `run-herdr.ts` starts and reaps agents; this prepares one argument.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Move a multi-line `--append-system-prompt` out of argv, because herdr cannot encode it.
|
|
15
|
+
*
|
|
16
|
+
* **Measured.** `herdr agent start` types the argv into the pane's shell, so a value containing newlines
|
|
17
|
+
* is rejected outright: `invalid_agent_argument — agent arguments cannot be encoded safely for the target
|
|
18
|
+
* shell`. A definition's `SKILL.md` body is always multi-line, so every `delegate({agent})` spawn would
|
|
19
|
+
* fail on this path.
|
|
20
|
+
*
|
|
21
|
+
* pi accepts a **file path** there as readily as literal text (`resolvePromptInput` + `existsSync` in
|
|
22
|
+
* `dist/core/resource-loader.js`), so the fix is to write the body to a temp file and pass its path — one
|
|
23
|
+
* short, shell-safe argument.
|
|
24
|
+
*
|
|
25
|
+
* The split lives here rather than in `planSpawn` because the constraint is **herdr's**, not pi's: the
|
|
26
|
+
* direct executor passes the same text inline with no trouble, and a plan builder that pre-emptively wrote
|
|
27
|
+
* temp files for everybody would be paying one executor's tax on both paths.
|
|
28
|
+
*/
|
|
29
|
+
export function splitSystemPrompt(args: string[]): { args: string[]; systemPrompt?: string } {
|
|
30
|
+
const at = args.indexOf("--append-system-prompt");
|
|
31
|
+
if (at === -1 || at + 1 >= args.length) return { args };
|
|
32
|
+
return { args: [...args.slice(0, at), ...args.slice(at + 2)], systemPrompt: args[at + 1] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Move a multi-line system prompt into a temp file and return argv pointing at it.
|
|
37
|
+
*
|
|
38
|
+
* Returns the directory so the caller can remove it — and **every** early return on the herdr path must, because
|
|
39
|
+
* nothing else can: a reviewer measured a permanent `/tmp/grants-herdr-*` per failed `tab create`, unreachable by
|
|
40
|
+
* either pane sweep because the pane it belonged to never existed.
|
|
41
|
+
*/
|
|
42
|
+
export async function stageSystemPrompt(
|
|
43
|
+
args: string[],
|
|
44
|
+
): Promise<{ args: string[]; promptDir?: string; error?: string }> {
|
|
45
|
+
const split = splitSystemPrompt(args);
|
|
46
|
+
if (split.systemPrompt === undefined) return { args: split.args };
|
|
47
|
+
try {
|
|
48
|
+
const promptDir = await mkdtemp(join(tmpdir(), "grants-herdr-"));
|
|
49
|
+
const file = join(promptDir, "system-prompt.md");
|
|
50
|
+
await writeFile(file, split.systemPrompt, "utf8");
|
|
51
|
+
return { args: [...split.args, "--append-system-prompt", file], promptDir };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return { args: split.args, error: `could not stage the system prompt for herdr: ${String(error)}` };
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/ledger-report.ts
CHANGED
|
@@ -27,6 +27,20 @@ export interface LedgerReport {
|
|
|
27
27
|
corrupt: Array<{ line: number; text: string }>;
|
|
28
28
|
/** Records where an agent asked for more than it held — ADR-0008's designated signal. */
|
|
29
29
|
escalationAttempts: number;
|
|
30
|
+
/**
|
|
31
|
+
* How many records ran under each executor, plus how many name none — ADR-0031.
|
|
32
|
+
*
|
|
33
|
+
* **Added because the field was written and never read, which is R-51's shape exactly.** R-51 was
|
|
34
|
+
* `definitionDigest`: recorded from the start, absent from every report, so the questions ADR-0018 advertised
|
|
35
|
+
* needed hand-written `jq`. `executor` arrived the same way — `src/ledger.ts` justifies making it *required*
|
|
36
|
+
* with "reading it back is the only reason it exists", and nothing read it back. `docs/SPEC.md` claims the
|
|
37
|
+
* executor is "announced three times… per child in the ledger"; without this the third announcement was to
|
|
38
|
+
* `jq` only.
|
|
39
|
+
*
|
|
40
|
+
* `unknown` counts pre-0.16 lines, which have no such field. Reported rather than folded into `process`,
|
|
41
|
+
* because "written before the executor was recorded" and "ran as a subprocess" are different facts.
|
|
42
|
+
*/
|
|
43
|
+
executors: { herdr: number; process: number; unknown: number };
|
|
30
44
|
/**
|
|
31
45
|
* Every distinct set of instructions this ledger saw run, with how many spawns used it (R-51).
|
|
32
46
|
*
|
|
@@ -113,6 +127,7 @@ export async function verifyLedger(path: string): Promise<LedgerReport> {
|
|
|
113
127
|
records: 0,
|
|
114
128
|
corrupt: [],
|
|
115
129
|
escalationAttempts: 0,
|
|
130
|
+
executors: { herdr: 0, process: 0, unknown: 0 },
|
|
116
131
|
definitions: [],
|
|
117
132
|
approvals: {
|
|
118
133
|
bySource: { prompt: 0, session: 0, persisted: 0, inherited: 0 },
|
|
@@ -132,6 +147,7 @@ export async function verifyLedger(path: string): Promise<LedgerReport> {
|
|
|
132
147
|
const digests = new Map<string, { name: string; source: string; sha256: string; spawns: number }>();
|
|
133
148
|
let records = 0;
|
|
134
149
|
let escalationAttempts = 0;
|
|
150
|
+
const executors = { herdr: 0, process: 0, unknown: 0 };
|
|
135
151
|
const bySource: Record<ApprovalSource, number> = { prompt: 0, session: 0, persisted: 0, inherited: 0 };
|
|
136
152
|
// `capability@subject` seen per source, so the report can state a bound as well as a raw count.
|
|
137
153
|
const distinct: Record<ApprovalSource, Set<string>> = {
|
|
@@ -153,6 +169,10 @@ export async function verifyLedger(path: string): Promise<LedgerReport> {
|
|
|
153
169
|
if (!Array.isArray(parsed.denied)) throw new Error("not a grant record");
|
|
154
170
|
records += 1;
|
|
155
171
|
if (isEscalationAttempt(parsed)) escalationAttempts += 1;
|
|
172
|
+
const executor = (parsed as { executor?: unknown }).executor;
|
|
173
|
+
if (executor === "herdr") executors.herdr += 1;
|
|
174
|
+
else if (executor === "process") executors.process += 1;
|
|
175
|
+
else executors.unknown += 1;
|
|
156
176
|
if (parsed.humanDenied) {
|
|
157
177
|
humanDenied += 1;
|
|
158
178
|
const subject = parsed.agentType === undefined || parsed.agentType === "delegate" ? DELEGATE_SUBJECT : parsed.agentType;
|
|
@@ -209,6 +229,7 @@ export async function verifyLedger(path: string): Promise<LedgerReport> {
|
|
|
209
229
|
records,
|
|
210
230
|
corrupt,
|
|
211
231
|
escalationAttempts,
|
|
232
|
+
executors,
|
|
212
233
|
definitions: [...digests.values()].sort((a, b) => a.name.localeCompare(b.name) || a.sha256.localeCompare(b.sha256)),
|
|
213
234
|
approvals: {
|
|
214
235
|
bySource,
|
package/src/ledger.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
|
23
23
|
import { withFileLock } from "./file-lock.ts";
|
|
24
24
|
import { dirname } from "node:path";
|
|
25
25
|
import type { Capability, ResolveResult } from "./resolve.ts";
|
|
26
|
+
import type { ExecutorKind } from "./executor.ts";
|
|
26
27
|
import type { DefinitionDigest } from "./definitions.ts";
|
|
27
28
|
import { DELEGATE_SUBJECT } from "./approval.ts";
|
|
28
29
|
import type { ApprovalScope, ApprovalSource } from "./approval.ts";
|
|
@@ -107,6 +108,20 @@ export interface GrantRecord {
|
|
|
107
108
|
* it. Absent for a `tools:`-style delegation, which has no definition.
|
|
108
109
|
*/
|
|
109
110
|
definitionDigest?: DefinitionDigest;
|
|
111
|
+
/**
|
|
112
|
+
* WHERE this child ran — ADR-0031.
|
|
113
|
+
*
|
|
114
|
+
* **Required rather than optional**, which is unusual in this record and deliberate. Before ADR-0031 the
|
|
115
|
+
* executor was a variable an operator set, so "which one ran?" was answerable from configuration after the
|
|
116
|
+
* fact. It is now decided by a **runtime probe** at session start, so nothing outside the record preserves
|
|
117
|
+
* the answer — and the two paths do not produce the same argv, because the herdr plan withholds `--print`
|
|
118
|
+
* (`delegationContext.interactive`). A trail that cannot say where a child ran cannot be read back
|
|
119
|
+
* reliably, and reading it back is the only reason it exists.
|
|
120
|
+
*
|
|
121
|
+
* Written on refusals too, including the tripwire's: the honest value there is the executor the session
|
|
122
|
+
* *would* have used, because a refused spawn has no executor of its own.
|
|
123
|
+
*/
|
|
124
|
+
executor: ExecutorKind;
|
|
110
125
|
}
|
|
111
126
|
|
|
112
127
|
export interface LedgerOptions {
|
|
@@ -137,6 +152,8 @@ export function buildRecord(args: {
|
|
|
137
152
|
humanDenied?: boolean;
|
|
138
153
|
gateOutcome?: PromptOutcomeKind;
|
|
139
154
|
definitionDigest?: DefinitionDigest;
|
|
155
|
+
/** Where the child ran (ADR-0031). Required: the probe's answer survives nowhere else. */
|
|
156
|
+
executor: ExecutorKind;
|
|
140
157
|
now: Date;
|
|
141
158
|
}): GrantRecord {
|
|
142
159
|
// R-46: the scalar is a SUMMARY, emitted only when it cannot mislead. `buildRecord` derives it rather
|
|
@@ -151,6 +168,7 @@ export function buildRecord(args: {
|
|
|
151
168
|
childId: args.childId,
|
|
152
169
|
depth: args.depth,
|
|
153
170
|
agentType: args.agentType,
|
|
171
|
+
executor: args.executor,
|
|
154
172
|
requested: args.requested,
|
|
155
173
|
parentGrant: args.parentGrant,
|
|
156
174
|
effective: args.result.effective,
|
package/src/pane-reaper.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Close herdr panes this process opened but never got to close.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* **As of ADR-0032 a pane belongs to the AGENT RUN, not to the tool call.** `runHerdrPane` closes a tab only for
|
|
5
|
+
* a child that did *not* settle — closing the tab is the only kill herdr offers, so a child still working must
|
|
6
|
+
* lose its pane. A child that answered keeps its pane so a human can read it, and this module reaps those: at
|
|
7
|
+
* `agent_settled` (async) and at process `exit` (sync backstop).
|
|
8
|
+
*
|
|
9
|
+
* The gap that remains is the one this module was created for. A pi session **killed outright** runs no `exit`
|
|
10
|
+
* handler, so it leaves one pane per tracked child, and `docs/probes/g16-herdr` records that an orphaned pane is
|
|
11
|
+
* not trivially closable afterwards. R-62 was re-rated L×L → M×L when ADR-0031 made panes the default path.
|
|
7
12
|
*
|
|
8
13
|
* **Registered on `exit` only, deliberately — not on SIGINT or SIGTERM.** That is the part worth reading,
|
|
9
14
|
* because the obvious fix is the dangerous one. Adding a signal listener *suppresses Node's default
|
|
@@ -24,14 +29,52 @@
|
|
|
24
29
|
|
|
25
30
|
import { execFileSync } from "node:child_process";
|
|
26
31
|
import { rmSync } from "node:fs";
|
|
32
|
+
import { rm } from "node:fs/promises";
|
|
33
|
+
import { MAX_CHILDREN_PER_CALL } from "./fanout.ts";
|
|
34
|
+
import { defaultExec, parseReply, type HerdrExec } from "./herdr-cli.ts";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How many panes may be open at once — ADR-0032.
|
|
38
|
+
*
|
|
39
|
+
* **Derived from `MAX_CHILDREN_PER_CALL` rather than declared, so the two cannot drift.** The bound is not
|
|
40
|
+
* decoration: `delegate_all` is capped per call and the fan-out budget bounds a subtree, but a plain blocking
|
|
41
|
+
* `delegate` spends **nothing** from that budget by design — so thirty sequential `delegate` calls in one agent
|
|
42
|
+
* run would otherwise hold thirty panes open until it settled.
|
|
43
|
+
*/
|
|
44
|
+
export const MAX_OPEN_PANES = MAX_CHILDREN_PER_CALL;
|
|
27
45
|
|
|
28
46
|
export interface OpenPane {
|
|
29
47
|
/** herdr tab id — what `tab close` takes. */
|
|
30
48
|
tab: string;
|
|
31
|
-
/**
|
|
49
|
+
/** herdr agent name, for diagnostics. There is no `agent stop`, so nothing acts on it — see `closePane`. */
|
|
32
50
|
name: string;
|
|
33
51
|
/** Staged system-prompt directory, removed with the pane it belonged to. */
|
|
34
52
|
promptDir?: string;
|
|
53
|
+
/**
|
|
54
|
+
* True once the child has answered and stopped working.
|
|
55
|
+
*
|
|
56
|
+
* **The trim may only close a pane with this set**, and that is a correctness rule rather than politeness. pi
|
|
57
|
+
* executes tool calls in **parallel** by default, and a plain `delegate` spends nothing from the fan-out
|
|
58
|
+
* budget — so one assistant message can hold `delegate_all(8)` *and* a `delegate`, and the ninth pane's trim
|
|
59
|
+
* used to close the oldest **live sibling**. Measured: two `delegate_all(8)` in one message killed **8 of 16
|
|
60
|
+
* children mid-work**, each reported as "could not be started" with its partial output discarded, while the
|
|
61
|
+
* ledger recorded all sixteen as provisioned. ADR-0032 argued the cap from *sequential* delegates only.
|
|
62
|
+
*/
|
|
63
|
+
settled?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* The operator asked to keep this tab (`PI_GRANTS_HERDR_KEEP_PANE=1`), so no sweep may close it.
|
|
66
|
+
*
|
|
67
|
+
* It is registered anyway, and only for its `promptDir`: that temp dir was otherwise unreachable by either
|
|
68
|
+
* sweep and leaked one directory per kept pane, forever. So `exit` removes the staged prompt and leaves the
|
|
69
|
+
* tab, which is exactly what the flag promises.
|
|
70
|
+
*/
|
|
71
|
+
keepTab?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A run has finished with its pane: the trim may now reclaim it. */
|
|
75
|
+
export function markPaneSettled(tab: string): void {
|
|
76
|
+
const pane = open.get(tab);
|
|
77
|
+
if (pane) pane.settled = true;
|
|
35
78
|
}
|
|
36
79
|
|
|
37
80
|
/**
|
|
@@ -108,25 +151,120 @@ export function reapOpenPanes(syncExec: (args: string[]) => void = defaultSyncEx
|
|
|
108
151
|
// Panes left behind stay in the map; there is no later sweep, and saying so is the honest position —
|
|
109
152
|
// `openPaneCount()` is non-zero afterwards precisely so a caller could report it if it ever wanted to.
|
|
110
153
|
if (now() >= deadline) break;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
154
|
+
// No `agent stop`: it is not a herdr command (see `closePane`). Closing the tab is the kill.
|
|
155
|
+
//
|
|
156
|
+
// A `keepTab` pane is registered only for its staged prompt: remove that and leave the tab alone, which is
|
|
157
|
+
// the whole of what `PI_GRANTS_HERDR_KEEP_PANE=1` promises.
|
|
158
|
+
let closedThisPane = pane.keepTab === true;
|
|
159
|
+
if (pane.keepTab) {
|
|
160
|
+
if (pane.promptDir) {
|
|
161
|
+
try {
|
|
162
|
+
rmSync(pane.promptDir, { recursive: true, force: true });
|
|
163
|
+
} catch {
|
|
164
|
+
/* /tmp litter, not correctness */
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
open.delete(pane.tab);
|
|
168
|
+
continue;
|
|
115
169
|
}
|
|
116
170
|
try {
|
|
117
171
|
syncExec(["tab", "close", pane.tab]);
|
|
118
172
|
closed.push(pane.tab);
|
|
173
|
+
closedThisPane = true;
|
|
119
174
|
} catch {
|
|
120
175
|
/* an orphan we could not close: `herdr tab close` is the manual remedy, as documented above */
|
|
121
176
|
}
|
|
122
|
-
|
|
177
|
+
// **The staged prompt is deleted only when the tab is provably gone.** It used to be removed
|
|
178
|
+
// unconditionally, so a pane herdr refused to close lost its `--append-system-prompt` file — and pi treats a
|
|
179
|
+
// missing path there as **literal text, with no warning** (`resource-loader.js`: `existsSync` fails, the
|
|
180
|
+
// input is returned). Re-running that pane's echoed argv would hand the child a pathname as its
|
|
181
|
+
// instructions. Litter is cheaper than silently swapping a definition's body for its filename.
|
|
182
|
+
if (closedThisPane && pane.promptDir) {
|
|
123
183
|
try {
|
|
124
184
|
rmSync(pane.promptDir, { recursive: true, force: true });
|
|
125
185
|
} catch {
|
|
126
186
|
/* /tmp litter, not correctness */
|
|
127
187
|
}
|
|
128
188
|
}
|
|
189
|
+
// Untracked either way, and only here: at `exit` there is no later sweep, so keeping an unclosable pane
|
|
190
|
+
// registered buys nothing. `closePane`'s async path deliberately differs — another sweep may still run.
|
|
129
191
|
open.delete(pane.tab);
|
|
130
192
|
}
|
|
131
193
|
return closed;
|
|
132
194
|
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Close one tracked pane. Shared by the async sweep and the trim so they cannot disagree about what "closed"
|
|
198
|
+
* means — which is the whole of R-62's lesson, one layer up.
|
|
199
|
+
*
|
|
200
|
+
* Returns whether the tab is **provably** gone. A close herdr refused leaves the pane tracked, so the `exit`
|
|
201
|
+
* handler retries it; untracking on a failed close is what disabled the one control built for that failure.
|
|
202
|
+
*/
|
|
203
|
+
async function closePane(exec: HerdrExec, pane: OpenPane): Promise<boolean> {
|
|
204
|
+
// **No `agent stop`.** It is not a herdr command — measured against 0.7.5, where it prints the usage banner
|
|
205
|
+
// and exits 0, which `defaultExec` reports as success. Two call sites here and one in `run-herdr.ts` issued it
|
|
206
|
+
// for nothing, and `docs/probes/g16-herdr` asserted it worked from a block that was never run. Closing the tab
|
|
207
|
+
// is the only kill herdr offers, and it does kill the child.
|
|
208
|
+
const reply = await exec(["tab", "close", pane.tab]).catch(() => undefined);
|
|
209
|
+
const closed = reply !== undefined && !parseReply(reply).error;
|
|
210
|
+
if (!closed) return false;
|
|
211
|
+
if (pane.promptDir) await rm(pane.promptDir, { recursive: true, force: true }).catch(() => undefined);
|
|
212
|
+
open.delete(pane.tab);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Close every outstanding pane — the `agent_settled` path (ADR-0032).
|
|
218
|
+
*
|
|
219
|
+
* **A separate function from `reapOpenPanes` rather than a shared implementation**, and the reason is not
|
|
220
|
+
* style. The sync one is `execFileSync` with a six-second total budget *by necessity*: an `exit` handler cannot
|
|
221
|
+
* await. Running that at `agent_settled` would freeze pi for up to six seconds **every time the operator gets
|
|
222
|
+
* their prompt back** — turning a feature that exists to make work visible into a stall.
|
|
223
|
+
*
|
|
224
|
+
* Both drain the same `Map`, keyed by tab id, so a double close is impossible and a pane herdr refused stays
|
|
225
|
+
* registered for whichever sweep runs next.
|
|
226
|
+
*
|
|
227
|
+
* Sequential rather than concurrent: a fan-out's panes are at most `MAX_OPEN_PANES`, and eight `tab close`
|
|
228
|
+
* calls in parallel against one herdr server buys nothing worth the burst.
|
|
229
|
+
*/
|
|
230
|
+
export async function reapOpenPanesAsync(exec: HerdrExec = defaultExec): Promise<string[]> {
|
|
231
|
+
const closed: string[] = [];
|
|
232
|
+
for (const pane of [...open.values()]) {
|
|
233
|
+
// `keepTab` panes are registered only so their staged prompt can be reaped at `exit`; the tab itself is the
|
|
234
|
+
// operator's to close.
|
|
235
|
+
if (pane.keepTab) continue;
|
|
236
|
+
if (await closePane(exec, pane)) closed.push(pane.tab);
|
|
237
|
+
}
|
|
238
|
+
return closed;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Keep at most `MAX_OPEN_PANES` open, closing the oldest first.
|
|
243
|
+
*
|
|
244
|
+
* The `Map`'s insertion order **is** the age order, so no timestamp is needed — and that is why `trackPane`
|
|
245
|
+
* must never re-`set` an existing tab, which would move it to the back of the queue and make an old pane look
|
|
246
|
+
* new.
|
|
247
|
+
*
|
|
248
|
+
* Whatever it closes is returned so the caller can **say so** rather than silently dropping a pane the operator
|
|
249
|
+
* was reading (R-48's rule, applied to a display).
|
|
250
|
+
*/
|
|
251
|
+
export async function trimOpenPanes(exec: HerdrExec = defaultExec): Promise<string[]> {
|
|
252
|
+
if (open.size <= MAX_OPEN_PANES) return [];
|
|
253
|
+
const closed: string[] = [];
|
|
254
|
+
|
|
255
|
+
// **Only SETTLED panes are candidates, oldest first.** A live sibling's pane is its child's only terminal, and
|
|
256
|
+
// closing a tab kills the child — see `OpenPane.settled`. If every open pane is live the cap is exceeded and
|
|
257
|
+
// nothing is closed, which is the right failure: a pane too many costs an operator a keystroke, and a killed
|
|
258
|
+
// child costs them the work.
|
|
259
|
+
//
|
|
260
|
+
// **Walks past what it cannot close**, rather than attempting `excess` panes from the front and calling it
|
|
261
|
+
// done. A pane herdr refuses to close used to sit at the head forever, so the cap silently stopped holding
|
|
262
|
+
// (measured: 30 panes for 30 delegates when every close was refused) and the same corpse was re-attacked on
|
|
263
|
+
// every spawn — O(n²) round-trips.
|
|
264
|
+
for (const pane of [...open.values()]) {
|
|
265
|
+
if (open.size - closed.length <= MAX_OPEN_PANES) break;
|
|
266
|
+
if (!pane.settled || pane.keepTab) continue;
|
|
267
|
+
if (await closePane(exec, pane)) closed.push(pane.tab);
|
|
268
|
+
}
|
|
269
|
+
return closed;
|
|
270
|
+
}
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The status block a running delegation renders — ADR-0032, in the shape the operator chose on 2026-08-17.
|
|
3
|
+
*
|
|
4
|
+
* Three properties are load-bearing rather than cosmetic, and each has a test:
|
|
5
|
+
*
|
|
6
|
+
* - **Bounded height AND bounded width.** Five lines per child — header, up to `TAIL_LINES` of tail, and a
|
|
7
|
+
* blank separator — so eight children is at most 42 lines, plus `MAX_LINE_CHARS` per line. The width half was
|
|
8
|
+
* missing and the omission mattered: a line cap is what actually bounds the block, because eight children
|
|
9
|
+
* each printing one megabyte-long line rendered 25 lines and 8 MiB. "Four lines per child" was also simply
|
|
10
|
+
* wrong arithmetic — the separator was never counted.
|
|
11
|
+
* - **No braiding.** Each child's text stays under its own header, which is what a chronological stream of two
|
|
12
|
+
* concurrent children cannot offer.
|
|
13
|
+
* - **The pane id is on screen while the child is alive.** That is the entire difference between a pane you can
|
|
14
|
+
* switch to and one you learn about after it closed.
|
|
15
|
+
*
|
|
16
|
+
* What it gives up is stated rather than implied (R-48): output older than the last `TAIL_LINES` lines is not
|
|
17
|
+
* here. It is in the pane while the pane lives, and in the returned result afterwards. **The block is a display
|
|
18
|
+
* and never the result** — conflating the two is R-03 with a new cause.
|
|
19
|
+
*
|
|
20
|
+
* Pure: no pi, no herdr, and no clock. `now` is a parameter so elapsed time is testable.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Lines of context kept per child. Three, because four children of four lines each is already a screenful. */
|
|
24
|
+
export const TAIL_LINES = 3;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Longest a single displayed line may be.
|
|
28
|
+
*
|
|
29
|
+
* **The line COUNT cap was never a height cap, and this is what makes the module header's claim true.** A child
|
|
30
|
+
* printing a minified bundle, a base64 blob or single-line JSON produces one line of megabytes, so
|
|
31
|
+
* `TAIL_LINES` bounded nothing that matters: eight such children rendered **25 lines and 8 MiB — about 84,000
|
|
32
|
+
* wrapped rows** — and were repainted every `PAINT_INTERVAL_MS`. Measured. The test asserting "fixed height"
|
|
33
|
+
* passed throughout, because 25 ≤ 34.
|
|
34
|
+
*
|
|
35
|
+
* 200 is a little over two rows on a wide terminal: enough to read a long path or a stack frame, short enough
|
|
36
|
+
* that eight children cannot flood the screen.
|
|
37
|
+
*/
|
|
38
|
+
export const MAX_LINE_CHARS = 200;
|
|
39
|
+
|
|
40
|
+
/** Trim one display line to `MAX_LINE_CHARS`, saying so rather than cutting silently (R-48). */
|
|
41
|
+
function clampLine(line: string): string {
|
|
42
|
+
if (line.length <= MAX_LINE_CHARS) return line;
|
|
43
|
+
return `${line.slice(0, MAX_LINE_CHARS)}… (+${line.length - MAX_LINE_CHARS} chars)`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ChildState = "starting" | "running" | "completed" | "failed";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The last few lines a child printed, plus whether the final one is still being written.
|
|
50
|
+
*
|
|
51
|
+
* **`open` is why this is not a bare `string[]`.** A pipe delivers bytes, not lines, so `Read` and
|
|
52
|
+
* `ing file.ts` arrive as two chunks and must render as one line — and knowing whether to join or to start a new
|
|
53
|
+
* line is state that `(lines, chunk)` alone cannot carry. The first draft encoded it as a trailing-space
|
|
54
|
+
* sentinel inside the array, which worked and was unreadable; a named boolean is the same information without
|
|
55
|
+
* the puzzle.
|
|
56
|
+
*/
|
|
57
|
+
export interface Tail {
|
|
58
|
+
lines: string[];
|
|
59
|
+
/** True when the last element is a partial line awaiting more bytes. */
|
|
60
|
+
open: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const emptyTail: Tail = { lines: [], open: false };
|
|
64
|
+
|
|
65
|
+
export interface ChildProgress {
|
|
66
|
+
/** Definition name, or `delegate` for a `tools:` spawn. */
|
|
67
|
+
label: string;
|
|
68
|
+
/** herdr agent name, when this child runs in a pane. */
|
|
69
|
+
agentName?: string;
|
|
70
|
+
paneId?: string;
|
|
71
|
+
state: ChildState;
|
|
72
|
+
startedAt: number;
|
|
73
|
+
/** Set once terminal, so elapsed time freezes instead of counting forever. */
|
|
74
|
+
settledAt?: number;
|
|
75
|
+
tail: Tail;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Collapse a terminal's carriage returns the way a terminal would: what follows the last `\r` wins.
|
|
80
|
+
*
|
|
81
|
+
* A spinner writes `working \r working \r done`. Splitting on `\r` as if it were a newline would fill the whole
|
|
82
|
+
* three-line tail with one frame per tick and push the child's real output out of the block.
|
|
83
|
+
*/
|
|
84
|
+
function lastAfterCarriageReturn(line: string): string {
|
|
85
|
+
const at = line.lastIndexOf("\r");
|
|
86
|
+
return at === -1 ? line : line.slice(at + 1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Fold a raw chunk into a tail.
|
|
91
|
+
*
|
|
92
|
+
* Blank lines are dropped so a child printing newlines cannot blank the block — at three lines of context, four
|
|
93
|
+
* newlines would erase everything the operator was reading.
|
|
94
|
+
*/
|
|
95
|
+
export function appendTail(tail: Tail, chunk: string): Tail {
|
|
96
|
+
if (chunk.length === 0) return tail;
|
|
97
|
+
|
|
98
|
+
const parts = chunk.split("\n");
|
|
99
|
+
const lines = [...tail.lines];
|
|
100
|
+
|
|
101
|
+
// The first part continues the previous line when that line was left open.
|
|
102
|
+
//
|
|
103
|
+
// **Clamped on every write, not only at render.** A child that never emits a newline keeps extending one
|
|
104
|
+
// line, and an unclamped join grew it without bound — measured at 12,692 characters from a pane that never
|
|
105
|
+
// held more than 16. Clamping here also bounds what is retained, not merely what is shown.
|
|
106
|
+
if (tail.open && lines.length > 0) {
|
|
107
|
+
lines[lines.length - 1] = clampLine(lastAfterCarriageReturn(lines[lines.length - 1] + parts[0]));
|
|
108
|
+
} else if (parts[0].trim().length > 0) {
|
|
109
|
+
lines.push(clampLine(lastAfterCarriageReturn(parts[0])));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const part of parts.slice(1)) {
|
|
113
|
+
if (part.trim().length > 0) lines.push(clampLine(lastAfterCarriageReturn(part)));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { lines: lines.slice(-TAIL_LINES), open: !chunk.endsWith("\n") };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Replace a tail wholesale from a pane SNAPSHOT — the herdr path's counterpart to `appendTail`.
|
|
121
|
+
*
|
|
122
|
+
* `agent read` returns a snapshot of a bounded terminal, so the right operation is *replace*, not *append*.
|
|
123
|
+
* Appending snapshots is what fabricated text the child never printed: a pane whose bottom line was
|
|
124
|
+
* `working on step 29` followed by a re-report beginning `line30` rendered `working on step 29line30`.
|
|
125
|
+
* Measured. `open` is always false here, because a snapshot is never mid-line as far as the display is
|
|
126
|
+
* concerned — there is nothing further to join onto.
|
|
127
|
+
*/
|
|
128
|
+
export function replaceTail(lines: string[]): Tail {
|
|
129
|
+
return { lines: lines.slice(-TAIL_LINES).map(clampLine), open: false };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A child label is a DIRECTORY name, so it is third-party text on a line this package composes.
|
|
134
|
+
*
|
|
135
|
+
* R-77 and R-78 were both "a name from somewhere else reached a generated artefact"; here a newline forges an
|
|
136
|
+
* entire extra child row in the operator's status block, complete with a plausible agent and pane. Same class,
|
|
137
|
+
* same treatment as `spawn-summary.ts`'s `safeName`: rendered inert rather than trusted.
|
|
138
|
+
*/
|
|
139
|
+
function safeLabel(label: string): string {
|
|
140
|
+
return /[\n\r\t]/.test(label) ? JSON.stringify(label) : label;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** `m:ss`. A delegation is minutes-scale, and an hour-long one has problems this line will not explain. */
|
|
144
|
+
function elapsed(child: ChildProgress, now: number): string {
|
|
145
|
+
const total = Math.floor(Math.max(0, (child.settledAt ?? now) - child.startedAt) / 1000);
|
|
146
|
+
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function renderProgress(children: ChildProgress[], executorKind: "herdr" | "process", now: number): string {
|
|
150
|
+
const where = executorKind === "herdr" ? "herdr panes" : "captured subprocesses";
|
|
151
|
+
const lines = [`${children.length} ${children.length === 1 ? "child" : "children"} · ${where}`, ""];
|
|
152
|
+
|
|
153
|
+
for (const child of children) {
|
|
154
|
+
const header = [safeLabel(child.label).padEnd(10)];
|
|
155
|
+
if (child.agentName) header.push(`agent ${child.agentName}`);
|
|
156
|
+
if (child.paneId) header.push(`pane ${child.paneId}`);
|
|
157
|
+
header.push(child.state, elapsed(child, now));
|
|
158
|
+
lines.push(header.join(" "));
|
|
159
|
+
// Sliced again here as well as in `appendTail`, so a caller that assembled a `Tail` by hand cannot make the
|
|
160
|
+
// block unbounded. The height guarantee belongs to the renderer, not to its inputs.
|
|
161
|
+
// Clamped again here as well as on write, so a caller that assembled a `Tail` by hand cannot make the block
|
|
162
|
+
// unbounded either. The height guarantee belongs to the renderer, not to its inputs.
|
|
163
|
+
for (const line of child.tail.lines.slice(-TAIL_LINES)) lines.push(` ${clampLine(line)}`);
|
|
164
|
+
lines.push("");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return lines.join("\n").trimEnd();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** How often the block is repainted. A child printing fast must not re-render it hundreds of times a second. */
|
|
171
|
+
export const PAINT_INTERVAL_MS = 250;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Call `fn` at most once per `intervalMs`, and always once more after the last call.
|
|
175
|
+
*
|
|
176
|
+
* The trailing call is the point: without it the final frame — the one showing every child settled — is the one
|
|
177
|
+
* most likely to be dropped, so the block would freeze mid-run and never show completion. `flush` exists so a
|
|
178
|
+
* caller that knows it is finished can paint immediately rather than waiting out the interval.
|
|
179
|
+
*/
|
|
180
|
+
export function throttle(fn: () => void, intervalMs: number): { call: () => void; flush: () => void } {
|
|
181
|
+
let last = 0;
|
|
182
|
+
let timer: NodeJS.Timeout | undefined;
|
|
183
|
+
|
|
184
|
+
const run = (at: number) => {
|
|
185
|
+
last = at;
|
|
186
|
+
timer = undefined;
|
|
187
|
+
fn();
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
call: () => {
|
|
192
|
+
const now = Date.now();
|
|
193
|
+
if (now - last >= intervalMs) return run(now);
|
|
194
|
+
// Already scheduled: the pending call will render whatever state exists when it fires, which is newer
|
|
195
|
+
// than this one. Queueing another would only repaint the same thing twice.
|
|
196
|
+
if (timer) return;
|
|
197
|
+
timer = setTimeout(() => run(Date.now()), intervalMs - (now - last)).unref?.() as never;
|
|
198
|
+
},
|
|
199
|
+
flush: () => {
|
|
200
|
+
if (timer) clearTimeout(timer);
|
|
201
|
+
timer = undefined;
|
|
202
|
+
last = Date.now();
|
|
203
|
+
fn();
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|