@hcflabs/pi-subagent 0.0.0-stage → 27.10.8

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.
Files changed (66) hide show
  1. package/CONTEXT.md +77 -0
  2. package/LICENSE +22 -0
  3. package/README.md +282 -2
  4. package/dist/composition.d.ts +11 -0
  5. package/dist/composition.js +104 -0
  6. package/dist/direct-herdr.d.ts +71 -0
  7. package/dist/direct-herdr.js +527 -0
  8. package/dist/discard.d.ts +51 -0
  9. package/dist/discard.js +218 -0
  10. package/dist/display-text.d.ts +5 -0
  11. package/dist/display-text.js +9 -0
  12. package/dist/ephemeral.d.ts +79 -0
  13. package/dist/ephemeral.js +916 -0
  14. package/dist/git-process.d.ts +8 -0
  15. package/dist/git-process.js +25 -0
  16. package/dist/git-runtime.d.ts +115 -0
  17. package/dist/git-runtime.js +489 -0
  18. package/dist/herdr-runtime.d.ts +147 -0
  19. package/dist/herdr-runtime.js +1366 -0
  20. package/dist/index.d.ts +170 -0
  21. package/dist/index.js +589 -0
  22. package/dist/integration-git.d.ts +63 -0
  23. package/dist/integration-git.js +504 -0
  24. package/dist/launch-runtime.d.ts +54 -0
  25. package/dist/launch-runtime.js +178 -0
  26. package/dist/mcp-role.d.ts +12 -0
  27. package/dist/mcp-role.js +152 -0
  28. package/dist/process-lease.d.ts +5 -0
  29. package/dist/process-lease.js +43 -0
  30. package/dist/process.d.ts +9 -0
  31. package/dist/process.js +11 -0
  32. package/dist/prune.d.ts +78 -0
  33. package/dist/prune.js +205 -0
  34. package/dist/review-evidence.d.ts +17 -0
  35. package/dist/review-evidence.js +221 -0
  36. package/dist/runner.d.ts +431 -0
  37. package/dist/runner.js +3380 -0
  38. package/dist/schema.d.ts +2560 -0
  39. package/dist/schema.js +1365 -0
  40. package/dist/store.d.ts +66 -0
  41. package/dist/store.js +394 -0
  42. package/dist/worktree.d.ts +65 -0
  43. package/dist/worktree.js +288 -0
  44. package/docs/orchestration-architecture.html +138 -0
  45. package/docs/orchestration-architecture.svg +135 -0
  46. package/docs/orchestration.md +577 -0
  47. package/docs/worker-lifecycle.html +97 -0
  48. package/docs/worker-lifecycle.svg +86 -0
  49. package/example.png +0 -0
  50. package/examples/roles/implementer.md +25 -0
  51. package/examples/roles/reviewer.md +25 -0
  52. package/examples/roles/scout.md +25 -0
  53. package/extensions/admission.ts +82 -0
  54. package/extensions/config.ts +19 -0
  55. package/extensions/git-read.ts +126 -0
  56. package/extensions/isolated.ts +945 -0
  57. package/extensions/model-class-policy.ts +1 -0
  58. package/extensions/result-transport.ts +236 -0
  59. package/extensions/role-mcp.ts +14 -0
  60. package/extensions/role-tools.ts +178 -0
  61. package/extensions/subagent-command.ts +699 -0
  62. package/extensions/subagent.ts +958 -0
  63. package/extensions/workflow.ts +336 -0
  64. package/package.json +84 -4
  65. package/prompts/review.md +20 -0
  66. package/skills/pi-subagent/SKILL.md +41 -0
@@ -0,0 +1,527 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
3
+ import { open, readFile, mkdtemp, stat, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { setTimeout as sleep } from "node:timers/promises";
7
+ import { DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
8
+ import { createHerdrClient, hasHerdrErrorCode, herdrCommandFailure, startPiAgent } from "@hcflabs/pi-herdr";
9
+ import { utf8Prefix } from "./ephemeral.js";
10
+ import { assertPrivateLease, PI_SUBAGENT_PROCESS_LEASE, scanProcessLease } from "./process-lease.js";
11
+ const OPERATION_MS = 30_000;
12
+ const SESSION_LIMIT = 16 * 1024 * 1024;
13
+ // Leave room for boundary instructions below Linux's 128 KiB per-argument limit.
14
+ const INLINE_TASK_BYTES = 64 * 1024;
15
+ const DIRECT_OUTCOMES = ["succeeded", "failed", "blocked"];
16
+ /** The Role tool that reads a file-backed task packet, or undefined for an inline packet; throws when no tool can read it in full. */
17
+ export function directTaskReader(task, tools) {
18
+ if (Buffer.byteLength(task, "utf8") <= INLINE_TASK_BYTES)
19
+ return;
20
+ const longLine = task.split("\n").some((line) => Buffer.byteLength(line, "utf8") > DEFAULT_MAX_BYTES);
21
+ if (tools.includes("read") && !longLine)
22
+ return "read";
23
+ if (tools.includes("bash"))
24
+ return "bash";
25
+ throw new Error(longLine
26
+ ? `Large direct task packet has a line above Pi read's ${DEFAULT_MAX_BYTES}-byte limit. Add bash to the Role or use lines within that limit; no worker was launched.`
27
+ : "Large direct task packets require read or bash in the Role; no worker was launched.");
28
+ }
29
+ const object = (value, label) => {
30
+ if (!value || typeof value !== "object" || Array.isArray(value))
31
+ throw new Error(`Malformed Herdr ${label}.`);
32
+ return value;
33
+ };
34
+ const field = (value, label) => {
35
+ if (typeof value !== "string" || !value.trim())
36
+ throw new Error(`Malformed Herdr ${label}.`);
37
+ return value;
38
+ };
39
+ const result = (value, type) => {
40
+ const response = object(object(value, "response").result, "result");
41
+ if (response.type !== type)
42
+ throw new Error(`Herdr returned an unexpected ${type} response.`);
43
+ return response;
44
+ };
45
+ function exactFinalTurn(jsonl, prompt) {
46
+ const lines = jsonl.trimEnd().split("\n");
47
+ let userId;
48
+ let final;
49
+ let finalId;
50
+ const parents = new Map();
51
+ for (const line of lines) {
52
+ const entry = object(JSON.parse(line), "session entry");
53
+ if (entry.type === "session")
54
+ continue;
55
+ const id = field(entry.id, "session entry id");
56
+ // Non-message entries also link the exact session branch.
57
+ if (typeof entry.parentId === "string")
58
+ parents.set(id, entry.parentId);
59
+ if (entry.type !== "message")
60
+ continue;
61
+ const message = object(entry.message, "session message");
62
+ if (message.role === "user" && Array.isArray(message.content)
63
+ && message.content.length === 1 && object(message.content[0], "user content").text === prompt) {
64
+ userId = id;
65
+ final = undefined;
66
+ finalId = undefined;
67
+ }
68
+ else if (userId && message.role === "user") {
69
+ throw new Error("Pi session contains an unexpected user turn after direct delegation.");
70
+ }
71
+ else if (userId && message.role === "assistant") {
72
+ final = message;
73
+ finalId = id;
74
+ }
75
+ }
76
+ let ancestor = finalId;
77
+ const seen = new Set();
78
+ while (ancestor && ancestor !== userId && !seen.has(ancestor)) {
79
+ seen.add(ancestor);
80
+ ancestor = parents.get(ancestor);
81
+ }
82
+ return userId && ancestor === userId ? final : undefined;
83
+ }
84
+ export function exactDirectTerminalTurn(jsonl, prompt) {
85
+ const reason = exactFinalTurn(jsonl, prompt)?.stopReason;
86
+ return reason === "stop" || reason === "error" || reason === "aborted";
87
+ }
88
+ const turnSuffix = (turn) => `\n\nTurn identity: ${turn}`;
89
+ /** Refuse continuation unless the saved session's exact recorded turn is terminal and no later user turn exists. */
90
+ export async function assertSettledDirectSession(sessionFile, turn) {
91
+ const info = await stat(sessionFile).catch((error) => {
92
+ throw new Error(`Saved Pi session ${sessionFile} is missing or unreadable`, { cause: error });
93
+ });
94
+ if (info.size > SESSION_LIMIT)
95
+ throw new Error(`Saved Pi session ${sessionFile} exceeds 16 MiB`);
96
+ const jsonl = await readFile(sessionFile, "utf8");
97
+ let prompt;
98
+ for (const line of jsonl.trimEnd().split("\n")) {
99
+ const entry = JSON.parse(line);
100
+ const message = entry?.type === "message" ? entry.message : undefined;
101
+ const text = message?.role === "user" && Array.isArray(message.content) && message.content.length === 1
102
+ ? message.content[0]?.text : undefined;
103
+ if (typeof text === "string" && text.endsWith(turnSuffix(turn)))
104
+ prompt = text;
105
+ }
106
+ if (prompt === undefined || !exactDirectTerminalTurn(jsonl, prompt)) {
107
+ throw new Error(`Saved Pi session ${sessionFile} has no terminal final turn for the recorded prompt; the worker may still be running`);
108
+ }
109
+ }
110
+ export function exactDirectAnswer(jsonl, prompt, requireSuccess = false) {
111
+ const final = exactFinalTurn(jsonl, prompt);
112
+ if (!final || final.stopReason !== "stop" || !Array.isArray(final.content)) {
113
+ throw new Error("Pi did not persist an exact successful final answer for this prompt.");
114
+ }
115
+ const text = final.content.flatMap((part) => {
116
+ const content = object(part, "assistant content");
117
+ return content.type === "text" && typeof content.text === "string" ? [content.text] : [];
118
+ }).join("\n");
119
+ if (!text.trim())
120
+ throw new Error("Pi persisted an empty final answer.");
121
+ if (!requireSuccess)
122
+ return text;
123
+ let completion;
124
+ try {
125
+ completion = JSON.parse(text);
126
+ }
127
+ catch (error) {
128
+ throw new Error("Pi write-access final answer must be a JSON completion object.", { cause: error });
129
+ }
130
+ if (!completion || typeof completion !== "object" || Array.isArray(completion)
131
+ || !DIRECT_OUTCOMES.some((outcome) => outcome === completion.outcome)
132
+ || typeof completion.answer !== "string" || !completion.answer.trim()) {
133
+ throw new Error("Pi write-access completion must have a valid outcome and a non-empty answer.");
134
+ }
135
+ if (completion.outcome !== "succeeded")
136
+ throw new Error(`Direct task reported ${completion.outcome}: ${completion.answer}`);
137
+ return completion.answer;
138
+ }
139
+ class DirectSessionState {
140
+ active = false;
141
+ total = 0;
142
+ observed = false;
143
+ cost;
144
+ pending = new Map();
145
+ latest;
146
+ texts = [];
147
+ prompt;
148
+ constructor(prompt) { this.prompt = prompt; }
149
+ consume(line) {
150
+ if (!line.trim())
151
+ return true;
152
+ let entry;
153
+ try {
154
+ entry = JSON.parse(line);
155
+ }
156
+ catch {
157
+ return false;
158
+ }
159
+ if (!entry || entry.type !== "message" || !entry.message || typeof entry.message !== "object")
160
+ return true;
161
+ const message = entry.message;
162
+ if (message.role === "user") {
163
+ this.active = Array.isArray(message.content) && message.content.length === 1
164
+ && message.content[0]?.text === this.prompt;
165
+ if (this.active) {
166
+ this.total = 0;
167
+ this.observed = false;
168
+ this.cost = undefined;
169
+ this.pending.clear();
170
+ this.latest = undefined;
171
+ this.texts = [];
172
+ }
173
+ }
174
+ else if (this.active && message.role === "assistant") {
175
+ if (message.usage && typeof message.usage === "object") {
176
+ const usage = message.usage;
177
+ const values = [usage.input, usage.output];
178
+ if (values.every((value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0)) {
179
+ this.total += values.reduce((sum, value) => sum + value, 0);
180
+ this.observed = true;
181
+ }
182
+ const cost = usage.cost?.total;
183
+ if (typeof cost === "number" && Number.isFinite(cost) && cost >= 0)
184
+ this.cost = (this.cost ?? 0) + cost;
185
+ }
186
+ if (Array.isArray(message.content))
187
+ for (const part of message.content) {
188
+ if (part?.type === "toolCall" && typeof part.id === "string" && typeof part.name === "string") {
189
+ this.pending.set(part.id, part.name);
190
+ }
191
+ else if (part?.type === "text" && typeof part.text === "string" && part.text.trim()) {
192
+ this.texts.push(part.text);
193
+ }
194
+ }
195
+ }
196
+ else if (this.active && message.role === "toolResult" && typeof message.toolCallId === "string") {
197
+ const name = this.pending.get(message.toolCallId);
198
+ if (name !== undefined) {
199
+ this.pending.delete(message.toolCallId);
200
+ this.latest = { name };
201
+ if (typeof message.durationMs === "number" && Number.isFinite(message.durationMs) && message.durationMs >= 0) {
202
+ this.latest.durationMs = message.durationMs;
203
+ }
204
+ }
205
+ }
206
+ return true;
207
+ }
208
+ usage() {
209
+ return { tokens: this.observed ? this.total : undefined, cost: this.cost,
210
+ activity: this.pending.size || this.latest ? { pending: [...this.pending.values()], latest: this.latest } : undefined };
211
+ }
212
+ partialText(maxChars) {
213
+ const chars = Array.from(this.texts.join("\n").trim());
214
+ if (!chars.length)
215
+ return undefined;
216
+ return chars.length <= maxChars ? chars.join("") : `…${chars.slice(-maxChars).join("")}`;
217
+ }
218
+ }
219
+ /** One reader per handle; only complete, valid lines advance the byte offset. */
220
+ export function createDirectSessionReader(sessionFile, prompt) {
221
+ let offset = 0;
222
+ let previous;
223
+ let state = new DirectSessionState(prompt);
224
+ let lastUsage = {};
225
+ let reading;
226
+ const read = async () => {
227
+ const file = await open(sessionFile, "r");
228
+ try {
229
+ const info = await file.stat();
230
+ if (info.size > SESSION_LIMIT) {
231
+ previous = info;
232
+ return false;
233
+ }
234
+ if (previous && (info.dev !== previous.dev || info.ino !== previous.ino || info.size < previous.size
235
+ || (info.size === previous.size && (info.mtimeMs !== previous.mtimeMs || info.ctimeMs !== previous.ctimeMs)))) {
236
+ offset = 0;
237
+ state = new DirectSessionState(prompt);
238
+ }
239
+ previous = info;
240
+ const buffer = Buffer.alloc(info.size - offset);
241
+ let length = 0;
242
+ while (length < buffer.length) {
243
+ const { bytesRead } = await file.read({ buffer, offset: length, length: buffer.length - length, position: offset + length });
244
+ if (!bytesRead)
245
+ break;
246
+ length += bytesRead;
247
+ }
248
+ let start = 0;
249
+ for (let end = buffer.indexOf(10); end >= 0 && end < length; end = buffer.indexOf(10, start)) {
250
+ if (!state.consume(buffer.toString("utf8", start, end)))
251
+ break;
252
+ offset += end + 1 - start;
253
+ start = end + 1;
254
+ }
255
+ return true;
256
+ }
257
+ finally {
258
+ await file.close();
259
+ }
260
+ };
261
+ // Timer samples and failure evidence can request the same file at the same time.
262
+ const sample = () => reading ??= read().finally(() => { reading = undefined; });
263
+ return {
264
+ get activity() { return lastUsage.activity; },
265
+ async usage() {
266
+ lastUsage = await sample() ? state.usage() : {};
267
+ return { tokens: lastUsage.tokens, cost: lastUsage.cost };
268
+ },
269
+ async partialText(maxChars) {
270
+ return await sample() ? state.partialText(maxChars) : undefined;
271
+ },
272
+ };
273
+ }
274
+ export class DirectAllocationError extends Error {
275
+ allocation;
276
+ constructor(allocation, cause) {
277
+ super(`Direct tab allocation is unverified: ${cause instanceof Error ? cause.message : String(cause)}. No agent start was dispatched. Inspect /subagent allocation evidence: workspace ${allocation.workspaceId}, cwd ${allocation.cwd}, label ${JSON.stringify(allocation.label)}, tab ${allocation.tabId ?? "unknown"}, pane ${allocation.paneId ?? "unknown"}, agent ${allocation.name}, session ${allocation.sessionFile}, lease ${allocation.leasePath}. Manual allocation reconciliation is required; do not replay creation or unlock admission.`, { cause });
278
+ this.allocation = allocation;
279
+ }
280
+ }
281
+ export function createDirectHerdr(pi, cwd, idleMs) {
282
+ if (process.env.HERDR_ENV !== "1")
283
+ throw new Error("Direct delegation requires a Herdr-managed Pi pane (HERDR_ENV=1).");
284
+ const workspaceId = field(process.env.HERDR_WORKSPACE_ID, "HERDR_WORKSPACE_ID");
285
+ const callerPane = field(process.env.HERDR_PANE_ID, "HERDR_PANE_ID");
286
+ const workingDir = realpathSync(cwd);
287
+ const herdr = createHerdrClient(pi.exec.bind(pi));
288
+ const options = (signal) => ({ cwd: workingDir, timeout: OPERATION_MS, ...(signal ? { signal } : {}) });
289
+ const inspect = (value, type, name, paneId, tabId) => {
290
+ const agent = object(result(value, type).agent, "agent");
291
+ if (agent.name !== name || agent.pane_id !== paneId || agent.tab_id !== tabId || agent.workspace_id !== workspaceId
292
+ || agent.cwd !== workingDir || agent.interactive_ready !== true) {
293
+ throw new Error("Herdr agent identity or readiness did not match its verified launch.");
294
+ }
295
+ return field(agent.agent_status, "agent state");
296
+ };
297
+ return {
298
+ workspaceId,
299
+ cwd: workingDir,
300
+ /** Close only an exact owned pane, then prove it and its leased children stopped.
301
+ * A task result or Ctrl-C acknowledgement is not termination evidence.
302
+ * `settledOnly` refuses to close a listed agent that is not idle or done. */
303
+ async stop(tab, current = () => true, settledOnly = false) {
304
+ const requireCurrent = () => { if (!current())
305
+ throw new Error("Direct task scope changed; reopen /subagent."); };
306
+ requireCurrent();
307
+ await assertPrivateLease(tab.leasePath, false);
308
+ const listed = result(await herdr.json(["agent", "list"], options()), "agent_list");
309
+ if (!Array.isArray(listed.agents))
310
+ throw new Error("Malformed Herdr agent list.");
311
+ const related = listed.agents.map((value) => {
312
+ const agent = object(value, "listed agent");
313
+ for (const key of ["pane_id", "tab_id"])
314
+ field(agent[key], `listed agent ${key}`);
315
+ return agent;
316
+ }).filter((agent) => agent.name === tab.name || agent.pane_id === tab.paneId || agent.tab_id === tab.tabId);
317
+ const panes = async () => {
318
+ const list = result(await herdr.json(["pane", "list", "--workspace", workspaceId], options()), "pane_list");
319
+ if (!Array.isArray(list.panes))
320
+ throw new Error("Malformed Herdr pane list.");
321
+ return list.panes.map((value) => {
322
+ const pane = object(value, "listed pane");
323
+ for (const key of ["pane_id", "tab_id"])
324
+ field(pane[key], `listed pane ${key}`);
325
+ if (pane.workspace_id !== workspaceId)
326
+ throw new Error("Herdr pane list escaped the owned workspace.");
327
+ return pane;
328
+ });
329
+ };
330
+ if (related.length) {
331
+ const agent = related[0];
332
+ if (related.length !== 1 || agent.name !== tab.name || agent.pane_id !== tab.paneId
333
+ || agent.tab_id !== tab.tabId || agent.workspace_id !== workspaceId || agent.cwd !== workingDir
334
+ || tab.paneId === callerPane)
335
+ throw new Error("Worker termination requires one exact owned agent, workspace, checkout, tab and pane match.");
336
+ if (settledOnly && agent.agent_status !== "idle" && agent.agent_status !== "done") {
337
+ throw new Error(`Herdr agent ${tab.name} is ${String(agent.agent_status)}, not idle or done; it was not closed`);
338
+ }
339
+ requireCurrent();
340
+ result(await herdr.json(["pane", "close", tab.paneId], options()), "ok");
341
+ }
342
+ if ((await panes()).some((pane) => pane.pane_id === tab.paneId || pane.tab_id === tab.tabId)) {
343
+ throw new Error(`Owned pane ${tab.paneId} is still present; termination is unproved.`);
344
+ }
345
+ for (let scan = 0; scan < 2; scan++) {
346
+ const holders = await scanProcessLease(tab.leasePath, (args) => pi.exec("lsof", args, { cwd: workingDir, timeout: 3_000 }));
347
+ if (holders.length)
348
+ throw new Error(`Owned process lease ${tab.leasePath} still has holders: ${holders.join(", ")}. Stop those owned processes, then retry Close/cancel-and-release.`);
349
+ if (scan === 0)
350
+ await sleep(50);
351
+ }
352
+ requireCurrent();
353
+ },
354
+ /** `resume` reuses a stopped worker's saved session and private lease instead of a new temp directory. */
355
+ async start(launch, name, label, task, signal, onTab, requireSuccess = false, resume) {
356
+ const reader = directTaskReader(task, launch.tools);
357
+ const caller = result(await herdr.json(["pane", "current", "--current"], options(signal)), "pane_current");
358
+ const pane = object(caller.pane, "calling pane");
359
+ if (pane.pane_id !== callerPane || pane.workspace_id !== workspaceId)
360
+ throw new Error("Herdr caller pane no longer matches the launching workspace.");
361
+ if (Object.keys(launch.env).length)
362
+ throw new Error("Direct Herdr launch cannot transfer Role environment overrides.");
363
+ let sessionFile;
364
+ let leasePath;
365
+ if (resume) {
366
+ ({ sessionFile, leasePath } = resume);
367
+ await assertPrivateLease(leasePath, false);
368
+ }
369
+ else {
370
+ const sessionDir = await mkdtemp(join(tmpdir(), "pi-subagent-direct-"));
371
+ sessionFile = join(sessionDir, "session.jsonl");
372
+ leasePath = join(sessionDir, "process.lease");
373
+ await writeFile(leasePath, "", { flag: "wx", mode: 0o600 });
374
+ }
375
+ const turn = randomBytes(16).toString("hex");
376
+ if (reader) {
377
+ const taskFile = join(dirname(sessionFile), `task-${turn}.md`);
378
+ await writeFile(taskFile, task, { flag: "wx", mode: 0o600 });
379
+ task = `Use ${reader} to read the complete task packet at ${JSON.stringify(taskFile)}. Read all parts before you act; do not use a truncated preview. The file contains the full task and chain context. If you cannot read it in full, stop and report blocked work.`;
380
+ }
381
+ // Pi's native session is the exact answer channel. Herdr screen output is diagnostic only.
382
+ const sessionArgs = launch.args.filter((arg) => arg !== "--no-session");
383
+ if (sessionArgs.length !== launch.args.length - 1)
384
+ throw new Error("Role launch must contain exactly one --no-session option.");
385
+ signal.throwIfAborted();
386
+ // Tab creation is bounded but not session-cancellable: Herdr may create it
387
+ // before the CLI responds, and aborting the CLI loses the exact tab ID.
388
+ const allocation = { name, workspaceId, cwd: workingDir, label, sessionFile, leasePath, turn };
389
+ let tabId;
390
+ let paneId;
391
+ try {
392
+ const response = await herdr.json(["tab", "create", "--workspace", workspaceId, "--cwd", workingDir, "--label", label, "--env", `${PI_SUBAGENT_PROCESS_LEASE}=${leasePath}`, "--no-focus"], options());
393
+ const created = object(response.result, "tab allocation result");
394
+ // Capture every returned ID before validation can fail. These are evidence,
395
+ // not owned tabs; the caller retains this intent even without either ID.
396
+ for (const [source, key, target] of [[created.tab, "tab_id", "tabId"], [created.root_pane, "pane_id", "paneId"]]) {
397
+ if (source && typeof source === "object" && typeof source[key] === "string")
398
+ allocation[target] = source[key];
399
+ }
400
+ result(response, "tab_created");
401
+ const tab = object(created.tab, "created tab");
402
+ const workerPane = object(created.root_pane, "created pane");
403
+ tabId = field(tab.tab_id, "tab id");
404
+ paneId = field(workerPane.pane_id, "pane id");
405
+ if (tab.workspace_id !== workspaceId || workerPane.workspace_id !== workspaceId || workerPane.tab_id !== tabId
406
+ || workerPane.cwd !== workingDir || tab.focused !== false || workerPane.focused !== false || paneId === callerPane) {
407
+ throw new Error(`Herdr tab ${tabId} has unverified identity, cwd or focus; inspect it before retrying.`);
408
+ }
409
+ }
410
+ catch (error) {
411
+ throw new DirectAllocationError(allocation, error);
412
+ }
413
+ try {
414
+ // Record the tab before the agent start can outlive an aborted launch.
415
+ onTab({ name, tabId, paneId, sessionFile, leasePath, turn, workspaceId, cwd: workingDir });
416
+ signal.throwIfAborted();
417
+ const started = await startPiAgent(herdr, { name, pane: paneId,
418
+ args: [...sessionArgs, "--session", sessionFile], options: options(signal), shouldRetry: () => false });
419
+ if (started.code !== 0 || started.killed)
420
+ throw new Error(`Herdr agent start failed: ${started.stderr.slice(0, 1000)}`);
421
+ if (inspect(JSON.parse(started.stdout), "agent_started", name, paneId, tabId) !== "idle")
422
+ throw new Error("Herdr agent was not idle after start.");
423
+ // A canceled start may have succeeded server-side; retain its tab but
424
+ // never submit a new prompt after cancellation.
425
+ signal.throwIfAborted();
426
+ const completionInstruction = requireSuccess
427
+ ? `\n\nWrite-access completion: Return exactly one JSON object without Markdown, {"outcome":"succeeded","answer":"summary"}. outcome must be ${DIRECT_OUTCOMES.join(", ")}. Use succeeded only when the entire authorized task, including requested checks and commits, completed successfully; a failed mutation, unresolved partial work, or missing authorization must be failed or blocked, even if your Pi turn ends normally. Describe changes, checks, commit identity, remaining risks, and checkout status in answer.`
428
+ : "";
429
+ const prompt = `${task}\n\nDirect shared-checkout boundary: use only your Role's resources for the explicitly authorized task and scope. Writes and commits affect Main's existing checkout immediately; preserve unrelated files and staged changes. Do not create or manage worktrees, change branches, push, or perform external mutations without explicit task authorization. There is no isolated integration, automatic validation, or rollback. If instructions conflict or authorization is missing, stop and report it. Report changes, commit identity if any, checks, remaining risks, and checkout status; never claim isolated guarantees.${completionInstruction}${turnSuffix(turn)}`;
430
+ // Native prompt without --wait acknowledges submission, not completion of the turn.
431
+ const accepted = await herdr.json(["agent", "prompt", name, prompt], options(signal));
432
+ const state = inspect(accepted, "agent_prompted", name, paneId, tabId);
433
+ if (state === "blocked" || state === "unknown")
434
+ throw new Error(`Herdr agent became ${state}; inspect it before retrying.`);
435
+ const session = createDirectSessionReader(sessionFile, prompt);
436
+ return {
437
+ name, tabId, paneId, sessionFile, prompt,
438
+ get activity() { return session.activity; },
439
+ usage: session.usage,
440
+ partialText: session.partialText,
441
+ async answer(maxBytes) {
442
+ const readAnswer = async () => {
443
+ const info = await stat(sessionFile);
444
+ if (info.size > SESSION_LIMIT)
445
+ throw new Error(`Pi session in tab ${tabId} exceeds 16 MiB; inspect ${sessionFile} for recovery.`);
446
+ const text = exactDirectAnswer(await readFile(sessionFile, "utf8"), prompt, requireSuccess);
447
+ const bytes = Buffer.byteLength(text, "utf8");
448
+ if (bytes <= maxBytes)
449
+ return { text, inline: text };
450
+ // An oversized exact answer still succeeds: Main gets a bounded prefix and a private full copy.
451
+ // Exclusive creation refuses a worker-planted file, symlink, or FIFO at this predictable path.
452
+ const file = join(dirname(sessionFile), `answer-${turn}.md`);
453
+ await writeFile(file, text, { flag: "wx", mode: 0o600 });
454
+ return { text, answerFile: file, inline: `${utf8Prefix(text, maxBytes)}\n\n[Answer truncated to ${maxBytes} of ${bytes} bytes; read the full answer at ${file}]` };
455
+ };
456
+ const maybeAnswer = async () => {
457
+ try {
458
+ return await readAnswer();
459
+ }
460
+ catch (error) {
461
+ if (signal.aborted || !(error instanceof Error)
462
+ || (!('code' in error && error.code === "ENOENT")
463
+ && !error.message.includes("did not persist an exact successful final answer")))
464
+ throw error;
465
+ return undefined;
466
+ }
467
+ };
468
+ let current = state;
469
+ let deadline = Date.now() + idleMs;
470
+ let sessionSize = 0;
471
+ const observeProgress = async () => {
472
+ const info = await stat(sessionFile).catch((error) => {
473
+ if (error.code === "ENOENT")
474
+ return undefined;
475
+ throw error;
476
+ });
477
+ if (info && info.size > SESSION_LIMIT)
478
+ throw new Error(`Pi session in tab ${tabId} exceeds 16 MiB; inspect ${sessionFile} for recovery.`);
479
+ if (info && info.size > sessionSize) {
480
+ sessionSize = info.size;
481
+ deadline = Date.now() + idleMs;
482
+ }
483
+ };
484
+ while (current === "working" || current === "idle") {
485
+ // A turn may finish before observation begins (or between waits).
486
+ if (current === "idle") {
487
+ const answer = await maybeAnswer();
488
+ if (answer !== undefined)
489
+ return answer;
490
+ }
491
+ await observeProgress();
492
+ const remaining = deadline - Date.now();
493
+ if (remaining <= 0)
494
+ throw new Error(`Herdr agent in tab ${tabId} made no progress for ${idleMs}ms; inspect ${sessionFile} before retrying.`);
495
+ const args = ["agent", "wait", name,
496
+ ...(current === "idle" ? ["--until", "working"] : ["--until", "idle"]),
497
+ "--until", "done", "--until", "blocked", "--until", "unknown",
498
+ "--timeout", String(Math.max(1, Math.min(current === "idle" ? 1000 : OPERATION_MS - 1000, remaining)))];
499
+ const waited = await herdr.exec(args, options(signal));
500
+ if (waited.code !== 0 || waited.killed) {
501
+ if (!waited.killed && hasHerdrErrorCode(waited, "timeout")) {
502
+ continue;
503
+ }
504
+ throw new Error(herdrCommandFailure(args, waited));
505
+ }
506
+ const next = inspect(JSON.parse(waited.stdout), "agent_info", name, paneId, tabId);
507
+ if (next !== current)
508
+ deadline = Date.now() + idleMs;
509
+ current = next;
510
+ }
511
+ if (current !== "done")
512
+ throw new Error(`Herdr agent in tab ${tabId} became ${current}; inspect it before retrying.`);
513
+ return readAnswer();
514
+ },
515
+ async cancel() {
516
+ await herdr.json(["agent", "send-keys", name, "ctrl+c"], options());
517
+ },
518
+ };
519
+ }
520
+ catch (error) {
521
+ if (signal.aborted)
522
+ await herdr.exec(["agent", "send-keys", name, "ctrl+c"], options()).catch(() => undefined);
523
+ throw new Error(`Direct launch in Herdr tab ${tabId} is not verified; inspect the agent and ${sessionFile} before retrying: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
524
+ }
525
+ },
526
+ };
527
+ }
@@ -0,0 +1,51 @@
1
+ import { type GitRunner } from "./git-process.ts";
2
+ import type { ChangesetTaskAttempt, ChangesetTaskState, IntegrationGeneration, RunState, TextTaskAttempt } from "./schema.ts";
3
+ import type { RunStateHandle } from "./store.ts";
4
+ import { type WorktreeInfo } from "./worktree.ts";
5
+ import { type PruneGuards } from "./prune.ts";
6
+ export interface DiscardItem {
7
+ key: string;
8
+ identity: string;
9
+ path?: string;
10
+ branch?: string;
11
+ tip?: string;
12
+ checkout: boolean;
13
+ commits?: number;
14
+ dirty?: boolean;
15
+ ignored: string[];
16
+ hosts: string[];
17
+ blockers: string[];
18
+ }
19
+ export interface DiscardPlan {
20
+ root: string;
21
+ directory: string;
22
+ requestId: string;
23
+ items: DiscardItem[];
24
+ }
25
+ export interface DiscardGuards {
26
+ panes: PruneGuards["panes"];
27
+ blocked(): Promise<string | undefined>;
28
+ current(): boolean;
29
+ }
30
+ export interface DiscardResource {
31
+ key: string;
32
+ worktree?: Omit<WorktreeInfo, "baseCommit"> & {
33
+ baseCommit?: string;
34
+ };
35
+ textAttempt?: TextTaskAttempt;
36
+ task?: ChangesetTaskState;
37
+ attempt?: ChangesetTaskAttempt;
38
+ generation?: IntegrationGeneration;
39
+ }
40
+ export type DiscardRemoval = {
41
+ key: string;
42
+ outcome: "removed" | "skipped" | "failed";
43
+ reason: string;
44
+ };
45
+ export declare function discardResources(state: RunState): DiscardResource[];
46
+ export declare function primaryCheckout(root: string, run?: GitRunner): Promise<string>;
47
+ /** Read-only evidence. A resource is one worker attempt or integration generation, including its host resources. */
48
+ export declare function inspectDiscardResource(primary: string, mainCwd: string, resource: DiscardResource, guards: DiscardGuards, run?: GitRunner): Promise<DiscardItem>;
49
+ export declare function inspectDiscard(primary: string, mainCwd: string, state: RunState, guards: DiscardGuards): Promise<DiscardItem[]>;
50
+ /** Called only after the command's confirmation. Never exposed as an agent tool. */
51
+ export declare function removeDiscardResources(handle: RunStateHandle, primary: string, mainCwd: string, reviewed: readonly DiscardItem[], guards: DiscardGuards, close: (resource: DiscardResource, check: () => Promise<void>) => Promise<void>, run?: GitRunner): Promise<DiscardRemoval[]>;