@hcflabs/pi-subagent 28.2.1 → 28.2.3

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/CONTEXT.md CHANGED
@@ -28,6 +28,10 @@ A display and continuation reference for one recorded direct worker, such as `D7
28
28
  **Continued worker**:
29
29
  A new direct worker that resumes a finished worker's saved Pi conversation in a new tab with a new Direct reference.
30
30
 
31
+ **Final turn**:
32
+ The latest assistant turn on the exact branch of a worker prompt that Pi did not omit, with a `stop`, `length`, `error`, or `aborted` reason. Only a `stop` Final turn is a success. Pi omits a retried or recovered turn with a `context_edit` entry. The worker session decides completion, not the Herdr agent state.
33
+ _Avoid_: last message, idle state
34
+
31
35
  **Checkout admission**:
32
36
  The exclusive permission held by a direct write request that prevents Main and other direct writers from writing to the shared checkout. A request with Repository targets holds the checkout of each target. From a non-Git parent directory, a Main call that can write covers the checkout of every Repository target in it. It remains held until worker termination is proven; read classification is not a sandbox.
33
37
 
package/README.md CHANGED
@@ -238,7 +238,7 @@ Package-owned: `~/.pi/agent/config/pi-subagent/config.json`. Missing files silen
238
238
  | `maxTurns` | Provider turns per child | Safe integer ≥ 1 | `50` |
239
239
  | `maxTokens` | Optional tokens per child | Safe integer ≥ 1 | Unlimited |
240
240
  | `maxCorrections` | Same-worker corrections per isolated request, including Main's `revise` and `retry` corrections | Safe integer ≥ 0 | `1` |
241
- | `timeout.idleMinutes` | Direct worker and ephemeral child idle timeout | Positive; within Node's timer range | `10` |
241
+ | `timeout.idleMinutes` | Idle timeout for direct workers, settled isolated workers, and ephemeral children | Positive; within Node's timer range | `10` |
242
242
  | `timeout.maxMinutes` | Ephemeral child hard runtime | Greater than idle; within Node's timer range | `30` |
243
243
  | `roleDefaults` | Shared resources for built-in and user Roles | Object with `extensions`, `tools`, or `skills` arrays; same validation as Role fields | — |
244
244
  | `repoSuites` | Full suite for isolated changesets in one repository | Object keyed by the absolute path of the primary checkout. Each value is `{ "command": "<executable>", "args": [...] }` | — |
@@ -1,5 +1,6 @@
1
1
  import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { PreparedRoleLaunch, ResolvedRoleLaunch } from "./index.ts";
3
+ import { type SessionToolActivity, type SessionUsage } from "./session-turn.ts";
3
4
  /** 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. */
4
5
  export declare function directTaskReader(task: string, tools: readonly string[]): "read" | "bash" | undefined;
5
6
  /** A prompt is accepted only with the exact pane, agent, cwd and turn identity. */
@@ -24,25 +25,12 @@ export interface DirectHandle {
24
25
  focus(): Promise<void>;
25
26
  cancel(): Promise<void>;
26
27
  }
27
- export declare function exactDirectTerminalTurn(jsonl: string, prompt: string): boolean;
28
- /** Refuse continuation unless the saved session's exact recorded turn is terminal and no later user turn exists. */
29
- export declare function assertSettledDirectSession(sessionFile: string, turn: string): Promise<void>;
30
- export declare function exactDirectAnswer(jsonl: string, prompt: string, requireSuccess?: boolean): string;
31
- /** Measured from one session read; each field is absent when no turn record carried it. */
32
- export interface DirectUsage {
33
- tokens?: number;
34
- /** Sum of assistant `usage.cost.total`. */
35
- cost?: number;
36
- }
37
- export interface DirectToolActivity {
38
- pending: string[];
39
- latest?: {
40
- name: string;
41
- durationMs?: number;
42
- };
43
- }
44
- /** One reader per handle; only complete, valid lines advance the byte offset. */
45
- export declare function createDirectSessionReader(sessionFile: string, prompt: string): Pick<DirectHandle, "usage" | "partialText" | "activity">;
28
+ /** Selects the user prompt text that carries the exact turn identity. */
29
+ export declare const directTurnPrompt: (turn: string) => (text: string) => boolean;
30
+ /** Returns the answer of a successful Final turn. A write-access answer must be a succeeded JSON completion object. */
31
+ export declare function directCompletionAnswer(text: string, requireSuccess: boolean): string;
32
+ export type DirectUsage = SessionUsage;
33
+ export type DirectToolActivity = SessionToolActivity;
46
34
  /** `turn` is the prompt's random turn identity; it proves which session turn a record owns.
47
35
  * `workspaceId` and `cwd` bind the record to its launching Herdr workspace and checkout. */
48
36
  export type DirectTab = Pick<DirectHandle, "name" | "tabId" | "paneId" | "sessionFile"> & {
@@ -1,6 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { realpathSync } from "node:fs";
3
- import { open, readFile, mkdtemp, stat, writeFile } from "node:fs/promises";
3
+ import { open, mkdtemp, stat, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { setTimeout as sleep } from "node:timers/promises";
@@ -8,8 +8,11 @@ import { DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
8
8
  import { createHerdrClient, hasHerdrErrorCode, herdrCommandFailure, startPiAgent } from "@hcflabs/pi-herdr";
9
9
  import { utf8Prefix } from "./ephemeral.js";
10
10
  import { assertPrivateLease, PI_SUBAGENT_PROCESS_LEASE, scanProcessLease } from "./process-lease.js";
11
+ import { createSessionActivityReader, readSessionTurn, SESSION_LIMIT } from "./session-turn.js";
11
12
  const OPERATION_MS = 30_000;
12
- const SESSION_LIMIT = 16 * 1024 * 1024;
13
+ /** The longest Herdr wait while the agent is idle or done and the session has no Final turn. */
14
+ const SETTLED_POLL_MS = 1000;
15
+ const WAIT_STATES = ["working", "idle", "done", "blocked", "unknown"];
13
16
  // Leave room for boundary instructions below Linux's 128 KiB per-argument limit.
14
17
  const INLINE_TASK_BYTES = 64 * 1024;
15
18
  const DIRECT_OUTCOMES = ["succeeded", "failed", "blocked"];
@@ -42,80 +45,11 @@ const result = (value, type) => {
42
45
  throw new Error(`Herdr returned an unexpected ${type} response.`);
43
46
  return response;
44
47
  };
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
48
  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");
49
+ /** Selects the user prompt text that carries the exact turn identity. */
50
+ export const directTurnPrompt = (turn) => (text) => text.endsWith(turnSuffix(turn));
51
+ /** Returns the answer of a successful Final turn. A write-access answer must be a succeeded JSON completion object. */
52
+ export function directCompletionAnswer(text, requireSuccess) {
119
53
  if (!text.trim())
120
54
  throw new Error("Pi persisted an empty final answer.");
121
55
  if (!requireSuccess)
@@ -136,141 +70,6 @@ export function exactDirectAnswer(jsonl, prompt, requireSuccess = false) {
136
70
  throw new Error(`Direct task reported ${completion.outcome}: ${completion.answer}`);
137
71
  return completion.answer;
138
72
  }
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
73
  export class DirectAllocationError extends Error {
275
74
  allocation;
276
75
  constructor(allocation, cause) {
@@ -371,6 +170,8 @@ export function createDirectHerdr(pi, cwd, idleMs) {
371
170
  sessionFile = join(sessionDir, "session.jsonl");
372
171
  leasePath = join(sessionDir, "process.lease");
373
172
  await writeFile(leasePath, "", { flag: "wx", mode: 0o600 });
173
+ // Pi initializes an existing empty session in place and keeps its private mode.
174
+ await (await open(sessionFile, "wx", 0o600)).close();
374
175
  }
375
176
  const turn = randomBytes(16).toString("hex");
376
177
  if (reader) {
@@ -432,18 +233,21 @@ export function createDirectHerdr(pi, cwd, idleMs) {
432
233
  const state = inspect(accepted, "agent_prompted", name, paneId, tabId);
433
234
  if (state === "blocked" || state === "unknown")
434
235
  throw new Error(`Herdr agent became ${state}; inspect it before retrying.`);
435
- const session = createDirectSessionReader(sessionFile, prompt);
236
+ const session = createSessionActivityReader(sessionFile, prompt);
436
237
  return {
437
238
  name, tabId, paneId, sessionFile, prompt,
438
239
  get activity() { return session.activity; },
439
240
  usage: session.usage,
440
241
  partialText: session.partialText,
441
242
  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);
243
+ // The session decides completion. Herdr idle or done only starts a read.
244
+ const settledAnswer = async () => {
245
+ const final = await readSessionTurn(sessionFile, prompt, { signal });
246
+ if (final.state === "absent" || final.state === "running")
247
+ return undefined;
248
+ if (final.state === "failed")
249
+ throw new Error(`${final.failure}. Inspect tab ${tabId} and ${sessionFile} before retrying.`);
250
+ const text = directCompletionAnswer(final.answer, requireSuccess);
447
251
  const bytes = Buffer.byteLength(text, "utf8");
448
252
  if (bytes <= maxBytes)
449
253
  return { text, inline: text };
@@ -453,18 +257,6 @@ export function createDirectHerdr(pi, cwd, idleMs) {
453
257
  await writeFile(file, text, { flag: "wx", mode: 0o600 });
454
258
  return { text, answerFile: file, inline: `${utf8Prefix(text, maxBytes)}\n\n[Answer truncated to ${maxBytes} of ${bytes} bytes; read the full answer at ${file}]` };
455
259
  };
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
260
  let current = state;
469
261
  let deadline = Date.now() + idleMs;
470
262
  let sessionSize = 0;
@@ -481,10 +273,14 @@ export function createDirectHerdr(pi, cwd, idleMs) {
481
273
  deadline = Date.now() + idleMs;
482
274
  }
483
275
  };
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();
276
+ for (;;) {
277
+ if (current !== "working" && current !== "idle" && current !== "done") {
278
+ throw new Error(`Herdr agent in tab ${tabId} became ${current}; inspect it before retrying.`);
279
+ }
280
+ // A turn can finish before observation starts or between waits.
281
+ // Pi can also omit an error turn and retry it while Herdr reports done.
282
+ if (current !== "working") {
283
+ const answer = await settledAnswer();
488
284
  if (answer !== undefined)
489
285
  return answer;
490
286
  }
@@ -493,14 +289,12 @@ export function createDirectHerdr(pi, cwd, idleMs) {
493
289
  if (remaining <= 0)
494
290
  throw new Error(`Herdr agent in tab ${tabId} made no progress for ${idleMs}ms; inspect ${sessionFile} before retrying.`);
495
291
  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)))];
292
+ ...WAIT_STATES.filter((until) => until !== current).flatMap((until) => ["--until", until]),
293
+ "--timeout", String(Math.max(1, Math.min(current === "working" ? OPERATION_MS - 1000 : SETTLED_POLL_MS, remaining)))];
499
294
  const waited = await herdr.exec(args, options(signal));
500
295
  if (waited.code !== 0 || waited.killed) {
501
- if (!waited.killed && hasHerdrErrorCode(waited, "timeout")) {
296
+ if (!waited.killed && hasHerdrErrorCode(waited, "timeout"))
502
297
  continue;
503
- }
504
298
  throw new Error(herdrCommandFailure(args, waited));
505
299
  }
506
300
  const next = inspect(JSON.parse(waited.stdout), "agent_info", name, paneId, tabId);
@@ -508,9 +302,6 @@ export function createDirectHerdr(pi, cwd, idleMs) {
508
302
  deadline = Date.now() + idleMs;
509
303
  current = next;
510
304
  }
511
- if (current !== "done")
512
- throw new Error(`Herdr agent in tab ${tabId} became ${current}; inspect it before retrying.`);
513
- return readAnswer();
514
305
  },
515
306
  async focus() {
516
307
  // A reused pane ID must not receive focus: the agent identity must match the launch first.
@@ -23,6 +23,8 @@ export interface HerdrHostRuntimeOptions {
23
23
  leaseDirectory?: string;
24
24
  lsofCommand?: string;
25
25
  executionBudget?: () => Omit<EphemeralSubagentExecutionBudget, "startedAt">;
26
+ /** The time that a settled worker can show a running session without session growth. The default is 10 minutes. */
27
+ settledIdleMs?: () => number;
26
28
  }
27
29
  export declare function workspaceLabel(attempt: TaskAttempt): string;
28
30
  export declare class HerdrHostRuntime implements HostRuntime {
@@ -37,6 +39,7 @@ export declare class HerdrHostRuntime implements HostRuntime {
37
39
  private readonly leaseDirectory;
38
40
  private readonly lsofCommand;
39
41
  private readonly executionBudget?;
42
+ private readonly settledIdleMs;
40
43
  constructor(options: HerdrHostRuntimeOptions);
41
44
  /**
42
45
  * Fail-closed Herdr caller/capability gate. This method creates no state or host resource.
@@ -127,9 +130,7 @@ export declare class HerdrHostRuntime implements HostRuntime {
127
130
  private removeLeaseArtifacts;
128
131
  private assertPrivateLeaseDirectories;
129
132
  private workerSessionFile;
130
- private sessionFileExists;
131
- private privateSessionFile;
132
- private workerTurnFinished;
133
+ private workerSessionTurn;
133
134
  private removeWorkerSessionFile;
134
135
  private assertLeasePath;
135
136
  private scanLease;
@@ -10,7 +10,7 @@ import { buildChangesetTaskPrompt, withTransientLaunch, } from "./runner.js";
10
10
  import { runProcess as defaultRunProcess } from "./process.js";
11
11
  import { EXECUTION_BUDGET_ENV } from "./ephemeral.js";
12
12
  import { assertPrivateLease, PI_SUBAGENT_PROCESS_LEASE, scanProcessLease } from "./process-lease.js";
13
- import { exactDirectTerminalTurn } from "./direct-herdr.js";
13
+ import { readPrivateSession, readSessionTurn } from "./session-turn.js";
14
14
  import { READABLE_BRANCH_PATTERN } from "./worktree.js";
15
15
  const MIN_HERDR_VERSION = [0, 9, 3];
16
16
  const MIN_HERDR_PROTOCOL = 22;
@@ -19,10 +19,11 @@ const LSOF_OPERATION_CAP_MS = 3_000;
19
19
  const PROCESS_INSPECTION_CAP_MS = 3_000;
20
20
  const GIT_INSPECTION_CAP_MS = 30_000;
21
21
  const STALLED_PROMPT_POLL_MS = 250;
22
+ /** The default time that a settled worker can show a running session without session growth. */
23
+ const DEFAULT_SETTLED_IDLE_MS = 600_000;
22
24
  const HOST_LAYOUT_POLL_MS = 50;
23
25
  const HOST_LAYOUT_MAX_POLLS = 40;
24
26
  const DIAGNOSTIC_LIMIT = 8 * 1024;
25
- const SESSION_LIMIT = 16 * 1024 * 1024;
26
27
  const LEASE_MODE = 0o600;
27
28
  const DIRECTORY_MODE = 0o700;
28
29
  const CORRELATION_TOKEN_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
@@ -257,7 +258,9 @@ export class HerdrHostRuntime {
257
258
  leaseDirectory;
258
259
  lsofCommand;
259
260
  executionBudget;
261
+ settledIdleMs;
260
262
  constructor(options) {
263
+ this.settledIdleMs = options.settledIdleMs ?? (() => DEFAULT_SETTLED_IDLE_MS);
261
264
  this.inspectInFlightCandidate = options.inspectInFlightTaskCandidate;
262
265
  this.execute = options.runProcess ?? defaultRunProcess;
263
266
  this.herdr = createHerdrClient(this.execute);
@@ -888,6 +891,8 @@ export class HerdrHostRuntime {
888
891
  });
889
892
  }
890
893
  async reconcileDeliveredPrompt(input, allocation, context, prompt, promptMayBeInFlight = true) {
894
+ // A settled worker with a running session can wait for a Pi retry. Session growth resets this bound.
895
+ let settledRunning;
891
896
  for (;;) {
892
897
  let lifecycle;
893
898
  try {
@@ -906,6 +911,7 @@ export class HerdrHostRuntime {
906
911
  return { outcome: "unknown", diagnostic: "Delivered prompt lifecycle is unknown." };
907
912
  }
908
913
  if (lifecycle.status === "working") {
914
+ settledRunning = undefined;
909
915
  try {
910
916
  await this.delay(STALLED_PROMPT_POLL_MS, context.signal);
911
917
  }
@@ -938,22 +944,47 @@ export class HerdrHostRuntime {
938
944
  };
939
945
  }
940
946
  if (SETTLED_AGENT_STATES.has(lifecycle.status)) {
947
+ let final;
941
948
  try {
942
- if (await this.workerTurnFinished(allocation, prompt)) {
943
- return inspection.valid && !inspection.clean
944
- ? { outcome: "uncommitted", diagnostic: "Worker finished with uncommitted changes in its owned worktree." }
945
- : { outcome: "blocked", diagnostic: "Worker finished without an exact changed clean committed candidate." };
946
- }
949
+ final = await this.workerSessionTurn(allocation, prompt, context.signal);
947
950
  }
948
951
  catch (error) {
949
- return { outcome: "unknown", diagnostic: `Worker session evidence cannot be verified: ${error instanceof SyntaxError ? "malformed JSONL" : safeText(error)}` };
952
+ if (this.contextInterrupted(context))
953
+ return { outcome: "interrupted", diagnostic: "Delivered prompt reconciliation was interrupted." };
954
+ return { outcome: "unknown", diagnostic: `Worker session evidence cannot be verified: ${safeText(error)}` };
955
+ }
956
+ if (final.state === "succeeded" || final.state === "failed") {
957
+ return inspection.valid && !inspection.clean
958
+ ? { outcome: "uncommitted", diagnostic: "Worker finished with uncommitted changes in its owned worktree." }
959
+ : { outcome: "blocked", diagnostic: "Worker finished without an exact changed clean committed candidate." };
960
+ }
961
+ // A running session has no Final turn yet. Pi can omit an error turn and retry it while Herdr reports a settled state.
962
+ if (final.state === "running") {
963
+ let size;
964
+ try {
965
+ size = (await lstat(this.workerSessionFile(allocation.leasePath, allocation.token))).size;
966
+ }
967
+ catch (error) {
968
+ return { outcome: "unknown", diagnostic: `Worker session evidence cannot be verified: ${safeText(error)}` };
969
+ }
970
+ const now = this.now();
971
+ if (!settledRunning || size !== settledRunning.size)
972
+ settledRunning = { since: now, size };
973
+ else if (now - settledRunning.since >= this.settledIdleMs()) {
974
+ return {
975
+ outcome: "unknown",
976
+ diagnostic: await this.diagnostic(allocation, context, `Settled worker session made no progress for ${this.settledIdleMs()}ms and has no Final turn.`),
977
+ };
978
+ }
979
+ }
980
+ else
981
+ settledRunning = undefined;
982
+ if (final.state === "absent" && !promptMayBeInFlight) {
983
+ return {
984
+ outcome: "blocked",
985
+ diagnostic: await this.diagnostic(allocation, context, "Settled worker did not produce an exact changed clean committed candidate."),
986
+ };
950
987
  }
951
- }
952
- if (!promptMayBeInFlight && SETTLED_AGENT_STATES.has(lifecycle.status)) {
953
- return {
954
- outcome: "blocked",
955
- diagnostic: await this.diagnostic(allocation, context, "Settled worker did not produce an exact changed clean committed candidate."),
956
- };
957
988
  }
958
989
  try {
959
990
  await this.delay(STALLED_PROMPT_POLL_MS, context.signal);
@@ -1139,71 +1170,16 @@ export class HerdrHostRuntime {
1139
1170
  this.assertLeasePath(leasePath, token);
1140
1171
  return `${leasePath}.session.jsonl`;
1141
1172
  }
1142
- async sessionFileExists(path) {
1143
- try {
1144
- await lstat(path);
1145
- return true;
1146
- }
1147
- catch (error) {
1148
- if (error.code === "ENOENT")
1149
- return false;
1150
- throw error;
1151
- }
1152
- }
1153
- async privateSessionFile(path) {
1154
- let file;
1155
- try {
1156
- file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
1157
- }
1158
- catch (error) {
1159
- if (error.code === "ENOENT")
1160
- return undefined;
1161
- throw error;
1162
- }
1163
- try {
1164
- const info = await file.stat();
1165
- const uid = process.getuid?.();
1166
- if (!info.isFile() || (info.mode & 0o077) !== 0 || (uid !== undefined && info.uid !== uid)
1167
- || info.size > SESSION_LIMIT)
1168
- throw new Error("Worker session file is not private, regular, or bounded.");
1169
- const chunks = [];
1170
- let bytes = 0;
1171
- while (bytes <= SESSION_LIMIT) {
1172
- const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, SESSION_LIMIT + 1 - bytes));
1173
- const { bytesRead } = await file.read(chunk, 0, chunk.length, null);
1174
- if (!bytesRead)
1175
- break;
1176
- chunks.push(chunk.subarray(0, bytesRead));
1177
- bytes += bytesRead;
1178
- }
1179
- if (bytes > SESSION_LIMIT)
1180
- throw new Error("Worker session file exceeds the 16 MiB limit.");
1181
- return Buffer.concat(chunks, bytes).toString("utf8");
1182
- }
1183
- finally {
1184
- await file.close();
1185
- }
1186
- }
1187
- async workerTurnFinished(allocation, prompt) {
1188
- const path = this.workerSessionFile(allocation.leasePath, allocation.token);
1189
- if (!await this.sessionFileExists(path))
1190
- return false;
1191
- await this.assertPrivateLeaseDirectories(allocation.leasePath, false);
1192
- const session = await this.privateSessionFile(path);
1193
- if (session === undefined)
1194
- return false;
1195
- try {
1196
- return exactDirectTerminalTurn(session, prompt);
1197
- }
1198
- catch (error) {
1199
- if (error instanceof SyntaxError && !session.endsWith("\n"))
1200
- return false; // Pi may still be writing the last JSONL entry.
1201
- throw error;
1202
- }
1173
+ async workerSessionTurn(allocation, prompt, signal) {
1174
+ const final = await readSessionTurn(this.workerSessionFile(allocation.leasePath, allocation.token), prompt, { signal });
1175
+ // A symbolic link in the lease directories can redirect the session path, so evidence needs private directories.
1176
+ if (final.state !== "absent")
1177
+ await this.assertPrivateLeaseDirectories(allocation.leasePath, false);
1178
+ return final;
1203
1179
  }
1204
1180
  async removeWorkerSessionFile(leasePath, token) {
1205
1181
  const path = this.workerSessionFile(leasePath, token);
1206
- if (await this.privateSessionFile(path) !== undefined)
1182
+ if (await readPrivateSession(path) !== undefined)
1207
1183
  await unlink(path);
1208
1184
  }
1209
1185
  assertLeasePath(path, token) {
@@ -0,0 +1,68 @@
1
+ /** The largest worker session file that a reader accepts. */
2
+ export declare const SESSION_LIMIT: number;
3
+ declare const STOP_REASONS: readonly ["stop", "length", "toolUse", "error", "aborted"];
4
+ type StopReason = typeof STOP_REASONS[number];
5
+ export type FailedStopReason = Exclude<StopReason, "stop" | "toolUse">;
6
+ /** The prompt text, or a test that selects the prompt text of a user entry. */
7
+ export type PromptMatch = string | ((text: string) => boolean);
8
+ /**
9
+ * The evidence of one worker prompt in its Pi session.
10
+ * - `absent`: the file or the prompt entry does not exist.
11
+ * - `running`: the prompt exists, but its branch has no Final turn.
12
+ * - `succeeded`: the Final turn has the `stop` reason.
13
+ * - `failed`: the Final turn has a `length`, `error`, or `aborted` reason.
14
+ */
15
+ export type SessionTurn = {
16
+ state: "absent";
17
+ } | {
18
+ state: "running";
19
+ } | {
20
+ state: "succeeded";
21
+ stopReason: "stop";
22
+ answer: string;
23
+ } | {
24
+ state: "failed";
25
+ stopReason: FailedStopReason;
26
+ errorMessage?: string;
27
+ failure: string;
28
+ };
29
+ export interface SessionTurnOptions {
30
+ signal?: AbortSignal;
31
+ /** Test only. Replaces the delay before the confirm read of an `error` Final turn. */
32
+ settleMs?: number;
33
+ }
34
+ /** Each field is absent when no turn record carried it. */
35
+ export interface SessionUsage {
36
+ tokens?: number;
37
+ /** Sum of assistant `usage.cost.total`. */
38
+ cost?: number;
39
+ }
40
+ export interface SessionToolActivity {
41
+ pending: string[];
42
+ latest?: {
43
+ name: string;
44
+ durationMs?: number;
45
+ };
46
+ }
47
+ export interface SessionActivityReader {
48
+ usage(): Promise<SessionUsage>;
49
+ /** Bounded assistant text of this prompt so far. This text is failure evidence only, never completion. */
50
+ partialText(maxChars: number): Promise<string | undefined>;
51
+ /** Display-only activity from the last usage() sample. */
52
+ readonly activity?: SessionToolActivity;
53
+ }
54
+ /** Reads a private session file in bounded chunks. Returns undefined for a missing file. */
55
+ export declare function readPrivateSession(path: string): Promise<Buffer | undefined>;
56
+ /**
57
+ * Reads the Final turn of the exact worker prompt from its private Pi session file.
58
+ * An `error` Final turn is a failure only when a second read after about 500 ms shows the same unomitted entry.
59
+ * Throws for a file that is not private, larger than 16 MiB, or malformed, or that has a later user turn.
60
+ */
61
+ export declare function readSessionTurn(path: string, prompt: PromptMatch, options?: SessionTurnOptions): Promise<SessionTurn>;
62
+ /**
63
+ * Samples live usage, tool activity, and partial text of the exact prompt.
64
+ * Each sample parses only the complete lines after the last byte offset.
65
+ * A replaced, shrunk, or rewritten file starts a new parse from byte 0.
66
+ */
67
+ export declare function createSessionActivityReader(path: string, prompt: string): SessionActivityReader;
68
+ export {};
@@ -0,0 +1,339 @@
1
+ import { constants } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
+ import { setTimeout as sleep } from "node:timers/promises";
4
+ /** The largest worker session file that a reader accepts. */
5
+ export const SESSION_LIMIT = 16 * 1024 * 1024;
6
+ const CHUNK_BYTES = 64 * 1024;
7
+ /** Pi writes the omission of a retried error turn before its retry delay. A second read after this delay finds that omission. */
8
+ const ERROR_SETTLE_MS = 500;
9
+ const ERROR_MESSAGE_CHARS = 500;
10
+ const STOP_REASONS = ["stop", "length", "toolUse", "error", "aborted"];
11
+ const isObject = (value) => !!value && typeof value === "object" && !Array.isArray(value);
12
+ const notPrivate = (path) => `Pi session file ${path} is not private, regular, or bounded`;
13
+ const tooLarge = (path) => new Error(`Pi session file ${path} exceeds the 16 MiB limit.`);
14
+ /** Opens a session file only when it is a regular file of the current user with mode 0600 or stricter. Returns undefined for a missing file. */
15
+ async function openPrivateSession(path) {
16
+ let file;
17
+ try {
18
+ file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
19
+ }
20
+ catch (error) {
21
+ const code = error.code;
22
+ if (code === "ENOENT")
23
+ return undefined;
24
+ if (code === "ELOOP" || code === "EMLINK")
25
+ throw new Error(`${notPrivate(path)}: it is a symbolic link.`, { cause: error });
26
+ throw error;
27
+ }
28
+ try {
29
+ const info = await file.stat();
30
+ const uid = process.getuid?.();
31
+ if (!info.isFile())
32
+ throw new Error(`${notPrivate(path)}: it is not a regular file.`);
33
+ if ((info.mode & 0o077) !== 0 || (uid !== undefined && info.uid !== uid)) {
34
+ throw new Error(`${notPrivate(path)}: it must belong to the current user and have mode 0600, with no group or other access.`);
35
+ }
36
+ if (info.size > SESSION_LIMIT)
37
+ throw tooLarge(path);
38
+ return { file, info };
39
+ }
40
+ catch (error) {
41
+ await file.close();
42
+ throw error;
43
+ }
44
+ }
45
+ /** Reads a private session file in bounded chunks. Returns undefined for a missing file. */
46
+ export async function readPrivateSession(path) {
47
+ const opened = await openPrivateSession(path);
48
+ if (!opened)
49
+ return undefined;
50
+ try {
51
+ const chunks = [];
52
+ let bytes = 0;
53
+ for (;;) {
54
+ // The file can grow after the size check. Read one byte more than the limit to find that growth.
55
+ const chunk = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, SESSION_LIMIT + 1 - bytes));
56
+ const { bytesRead } = await opened.file.read(chunk, 0, chunk.length, null);
57
+ if (!bytesRead)
58
+ break;
59
+ chunks.push(chunk.subarray(0, bytesRead));
60
+ bytes += bytesRead;
61
+ if (bytes > SESSION_LIMIT)
62
+ throw tooLarge(path);
63
+ }
64
+ return Buffer.concat(chunks, bytes);
65
+ }
66
+ finally {
67
+ await opened.file.close();
68
+ }
69
+ }
70
+ function parseLine(line, path, lineNumber) {
71
+ let entry;
72
+ try {
73
+ entry = JSON.parse(line);
74
+ }
75
+ catch (error) {
76
+ throw new Error(`Pi session file ${path} has a malformed entry on line ${lineNumber}.`, { cause: error });
77
+ }
78
+ if (!isObject(entry) || typeof entry.type !== "string"
79
+ // A message entry must carry a message object with a role. A broken payload is not evidence.
80
+ || (entry.type === "message" && (!isObject(entry.message) || typeof entry.message.role !== "string"))) {
81
+ throw new Error(`Pi session file ${path} has a malformed entry on line ${lineNumber}.`);
82
+ }
83
+ return entry;
84
+ }
85
+ const messageOf = (entry) => entry.type === "message" && isObject(entry.message) ? entry.message : undefined;
86
+ function userText(entry) {
87
+ const message = messageOf(entry);
88
+ if (message?.role !== "user" || !Array.isArray(message.content) || message.content.length !== 1)
89
+ return undefined;
90
+ const part = message.content[0];
91
+ return isObject(part) && part.type === "text" && typeof part.text === "string" ? part.text : undefined;
92
+ }
93
+ function entryId(entry, path) {
94
+ if (typeof entry.id !== "string" || !entry.id)
95
+ throw new Error(`Pi session file ${path} has an entry without an ID.`);
96
+ return entry.id;
97
+ }
98
+ /** One read of the session text. `finalId` identifies the Final turn entry. */
99
+ function evaluateTurn(text, path, prompt) {
100
+ const matches = typeof prompt === "string" ? (value) => value === prompt : prompt;
101
+ // Pi can still write the last line. Only a line that ends with a newline is complete.
102
+ const lines = text.split("\n").slice(0, -1);
103
+ const entries = lines.map((line, index) => parseLine(line, path, index + 1)).filter((entry) => entry.type !== "session");
104
+ let promptIndex = -1;
105
+ entries.forEach((entry, index) => {
106
+ const value = userText(entry);
107
+ if (value !== undefined && matches(value))
108
+ promptIndex = index;
109
+ });
110
+ if (promptIndex < 0)
111
+ return { turn: { state: "absent" } };
112
+ if (entries.slice(promptIndex + 1).some((entry) => messageOf(entry)?.role === "user")) {
113
+ throw new Error(`Pi session file ${path} contains an unexpected user turn after the worker prompt.`);
114
+ }
115
+ const byId = new Map();
116
+ for (const entry of entries)
117
+ byId.set(entryId(entry, path), entry);
118
+ // All entry types link the branch. The latest entry is the leaf of the active branch.
119
+ const promptEntry = entries[promptIndex];
120
+ const branch = [];
121
+ const seen = new Set();
122
+ let current = entries.at(-1);
123
+ while (current && current !== promptEntry && !seen.has(current)) {
124
+ seen.add(current);
125
+ branch.push(current);
126
+ current = typeof current.parentId === "string" ? byId.get(current.parentId) : undefined;
127
+ }
128
+ if (current !== promptEntry)
129
+ return { turn: { state: "running" } };
130
+ branch.reverse();
131
+ // The latest edit for a target on the branch wins. A null replacement omits the target.
132
+ const edits = new Map();
133
+ for (const entry of branch) {
134
+ if (entry.type !== "context_edit")
135
+ continue;
136
+ if (typeof entry.targetId !== "string" || !("replacement" in entry)) {
137
+ throw new Error(`Pi session file ${path} has a malformed context_edit entry.`);
138
+ }
139
+ edits.set(entry.targetId, entry.replacement);
140
+ }
141
+ let final;
142
+ for (const entry of branch) {
143
+ if (messageOf(entry)?.role === "assistant" && edits.get(entryId(entry, path)) !== null)
144
+ final = entry;
145
+ }
146
+ if (!final)
147
+ return { turn: { state: "running" } };
148
+ const message = messageOf(final);
149
+ const reason = message.stopReason;
150
+ if (!STOP_REASONS.some((known) => known === reason)) {
151
+ throw new Error(`Pi session file ${path} has an assistant entry with an unknown stop reason.`);
152
+ }
153
+ const finalId = entryId(final, path);
154
+ if (reason === "toolUse")
155
+ return { turn: { state: "running" }, finalId };
156
+ if (reason === "stop") {
157
+ if (!Array.isArray(message.content))
158
+ throw new Error(`Pi session file ${path} has an assistant entry without content.`);
159
+ const answer = message.content.flatMap((part) => isObject(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : []).join("\n");
160
+ return { turn: { state: "succeeded", stopReason: "stop", answer }, finalId };
161
+ }
162
+ const stopReason = reason;
163
+ const detail = typeof message.errorMessage === "string" && message.errorMessage.trim()
164
+ ? Array.from(message.errorMessage.trim()).slice(0, ERROR_MESSAGE_CHARS).join("") : undefined;
165
+ return {
166
+ turn: { state: "failed", stopReason, ...(detail === undefined ? {} : { errorMessage: detail }),
167
+ failure: `Pi ended the worker turn with stop reason "${stopReason}"${detail === undefined ? "" : `: ${detail}`}` },
168
+ finalId,
169
+ };
170
+ }
171
+ async function readTurnOnce(path, prompt) {
172
+ const data = await readPrivateSession(path);
173
+ return data === undefined ? { turn: { state: "absent" } } : evaluateTurn(data.toString("utf8"), path, prompt);
174
+ }
175
+ /**
176
+ * Reads the Final turn of the exact worker prompt from its private Pi session file.
177
+ * An `error` Final turn is a failure only when a second read after about 500 ms shows the same unomitted entry.
178
+ * Throws for a file that is not private, larger than 16 MiB, or malformed, or that has a later user turn.
179
+ */
180
+ export async function readSessionTurn(path, prompt, options = {}) {
181
+ let read = await readTurnOnce(path, prompt);
182
+ for (;;) {
183
+ if (read.turn.state !== "failed" || read.turn.stopReason !== "error")
184
+ return read.turn;
185
+ // Pi can omit the error turn and retry it after Herdr reports the agent done.
186
+ await sleep(options.settleMs ?? ERROR_SETTLE_MS, undefined, options.signal ? { signal: options.signal } : undefined);
187
+ const next = await readTurnOnce(path, prompt);
188
+ if (next.turn.state === "failed" && next.turn.stopReason === "error" && next.finalId === read.finalId)
189
+ return next.turn;
190
+ read = next;
191
+ }
192
+ }
193
+ class ActivityState {
194
+ active = false;
195
+ total = 0;
196
+ observed = false;
197
+ cost;
198
+ pending = new Map();
199
+ latest;
200
+ texts = [];
201
+ prompt;
202
+ constructor(prompt) { this.prompt = prompt; }
203
+ consume(entry) {
204
+ const message = messageOf(entry);
205
+ if (!message)
206
+ return;
207
+ if (message.role === "user") {
208
+ this.active = userText(entry) === this.prompt;
209
+ if (this.active) {
210
+ this.total = 0;
211
+ this.observed = false;
212
+ this.cost = undefined;
213
+ this.pending.clear();
214
+ this.latest = undefined;
215
+ this.texts = [];
216
+ }
217
+ }
218
+ else if (this.active && message.role === "assistant") {
219
+ if (isObject(message.usage)) {
220
+ const usage = message.usage;
221
+ const values = [usage.input, usage.output];
222
+ if (values.every((value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0)) {
223
+ this.total += values.reduce((sum, value) => sum + value, 0);
224
+ this.observed = true;
225
+ }
226
+ const cost = isObject(usage.cost) ? usage.cost.total : undefined;
227
+ if (typeof cost === "number" && Number.isFinite(cost) && cost >= 0)
228
+ this.cost = (this.cost ?? 0) + cost;
229
+ }
230
+ if (Array.isArray(message.content))
231
+ for (const part of message.content) {
232
+ if (!isObject(part))
233
+ continue;
234
+ if (part.type === "toolCall" && typeof part.id === "string" && typeof part.name === "string") {
235
+ this.pending.set(part.id, part.name);
236
+ }
237
+ else if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
238
+ this.texts.push(part.text);
239
+ }
240
+ }
241
+ }
242
+ else if (this.active && message.role === "toolResult" && typeof message.toolCallId === "string") {
243
+ const name = this.pending.get(message.toolCallId);
244
+ if (name !== undefined) {
245
+ this.pending.delete(message.toolCallId);
246
+ this.latest = { name };
247
+ if (typeof message.durationMs === "number" && Number.isFinite(message.durationMs) && message.durationMs >= 0) {
248
+ this.latest.durationMs = message.durationMs;
249
+ }
250
+ }
251
+ }
252
+ }
253
+ usage() {
254
+ return { tokens: this.observed ? this.total : undefined, cost: this.cost,
255
+ activity: this.pending.size || this.latest ? { pending: [...this.pending.values()], latest: this.latest } : undefined };
256
+ }
257
+ partialText(maxChars) {
258
+ const chars = Array.from(this.texts.join("\n").trim());
259
+ if (!chars.length)
260
+ return undefined;
261
+ return chars.length <= maxChars ? chars.join("") : `…${chars.slice(-maxChars).join("")}`;
262
+ }
263
+ }
264
+ /**
265
+ * Samples live usage, tool activity, and partial text of the exact prompt.
266
+ * Each sample parses only the complete lines after the last byte offset.
267
+ * A replaced, shrunk, or rewritten file starts a new parse from byte 0.
268
+ */
269
+ export function createSessionActivityReader(path, prompt) {
270
+ let offset = 0;
271
+ let lineNumber = 0;
272
+ let previous;
273
+ let state = new ActivityState(prompt);
274
+ let lastUsage = {};
275
+ let reading;
276
+ const reset = () => { offset = 0; lineNumber = 0; previous = undefined; state = new ActivityState(prompt); };
277
+ const read = async () => {
278
+ let opened;
279
+ try {
280
+ opened = await openPrivateSession(path);
281
+ }
282
+ catch (error) {
283
+ reset();
284
+ throw error;
285
+ }
286
+ if (!opened) {
287
+ reset();
288
+ return;
289
+ }
290
+ const { file, info } = opened;
291
+ try {
292
+ if (previous && (info.dev !== previous.dev || info.ino !== previous.ino || info.size < previous.size
293
+ || (info.size === previous.size && (info.mtimeMs !== previous.mtimeMs || info.ctimeMs !== previous.ctimeMs))))
294
+ reset();
295
+ previous = info;
296
+ const buffer = Buffer.alloc(info.size - offset);
297
+ let length = 0;
298
+ while (length < buffer.length) {
299
+ const { bytesRead } = await file.read(buffer, length, Math.min(CHUNK_BYTES, buffer.length - length), offset + length);
300
+ if (!bytesRead)
301
+ break;
302
+ length += bytesRead;
303
+ }
304
+ // Only complete lines advance the offset. A malformed complete line stops the parse and throws.
305
+ let start = 0;
306
+ for (let end = buffer.indexOf(10); end >= 0 && end < length; end = buffer.indexOf(10, start)) {
307
+ state.consume(parseLine(buffer.toString("utf8", start, end), path, lineNumber + 1));
308
+ lineNumber += 1;
309
+ offset += end + 1 - start;
310
+ start = end + 1;
311
+ }
312
+ }
313
+ finally {
314
+ await file.close();
315
+ }
316
+ };
317
+ // Timer samples and failure evidence can request the same file at the same time.
318
+ const sample = async () => {
319
+ try {
320
+ await (reading ??= read().finally(() => { reading = undefined; }));
321
+ }
322
+ catch (error) {
323
+ lastUsage = {};
324
+ throw error;
325
+ }
326
+ };
327
+ return {
328
+ get activity() { return lastUsage.activity; },
329
+ async usage() {
330
+ await sample();
331
+ lastUsage = state.usage();
332
+ return { tokens: lastUsage.tokens, cost: lastUsage.cost };
333
+ },
334
+ async partialText(maxChars) {
335
+ await sample();
336
+ return state.partialText(maxChars);
337
+ },
338
+ };
339
+ }
@@ -127,7 +127,7 @@ The new tab gets a new agent name and reference. Its record points to the same s
127
127
 
128
128
  A request can continue each worker only one time, and only from the checkout and Herdr workspace where the worker ran. After a continuation, continue the newest reference. The extension refuses an older reference to the same session, because a later turn follows its own turn.
129
129
 
130
- Before launch, the request fails if the reference is unknown or matches more than one record on this session branch. It also fails if the Role is different or a locally active task still owns the session of the worker. A missing session file or a session file larger than 16 MiB also causes failure. The last session turn for the recorded prompt must be finished.
130
+ Before launch, the request fails if the reference is unknown or matches more than one record on this session branch. It also fails if the Role is different or a locally active task still owns the session of the worker. A missing session file, a session file larger than 16 MiB, or a session file that is not private also causes failure. The recorded prompt must have a Final turn.
131
131
 
132
132
  If Herdr still lists the old agent, the agent must match the recorded name, pane, tab, workspace, and checkout. It must also be idle or done. The extension never closes a working or blocked agent. The extension closes the old pane, then proves pane absence and two empty lease scans.
133
133
 
@@ -141,7 +141,13 @@ If the extension cannot prove termination of the stopped worker, the workflow ke
141
141
 
142
142
  ### Direct results and records
143
143
 
144
- The extension retrieves each final answer from its exact Pi session file, not its possibly truncated screen. Idle, blocked, unknown, or malformed answers fail with recoverable tab and session identities. No whole-run deadline applies.
144
+ The extension retrieves each final answer from its exact Pi session file, not its possibly truncated screen. The worker session decides completion, not the Herdr agent state. A Herdr `idle` or `done` state only starts a read of the session. A Herdr `blocked` or `unknown` state fails at once. A failed Final turn or a malformed session fails with recoverable tab and session identities. No whole-run deadline applies.
145
+
146
+ The Final turn is the latest assistant turn on the exact branch of the worker prompt that Pi did not omit. Only a `stop` Final turn is a success. A `length`, `error`, or `aborted` Final turn is a failure, and the failure names the stop reason. A `toolUse` turn, or no Final turn, means that the worker is still running. While the worker is running, the extension keeps waiting. The idle timeout applies, and growth of the session file starts the timeout again. Isolated workers use the same rule. When Herdr reports an isolated worker idle or done and its session stays without a Final turn and without growth for the idle timeout, the worker result is `unknown`.
147
+
148
+ Pi can retry a turn after a provider error, such as HTTP 429, or recover from a `length` turn. Before the retry, Pi writes a `context_edit` entry that omits the old turn, so the old turn is not the Final turn. Herdr can report the agent done before Pi writes this entry. Thus an `error` Final turn is a failure only when a second read after about 500 ms shows the same turn, still not omitted. A `length` Final turn is a failure unless Pi recovers it.
149
+
150
+ Each session file must be private: a regular file of the current user with mode `0600`, and 16 MiB or smaller. The extension creates each new session file with mode `0600` before it starts Pi. A session file with group or other access, or a symbolic link, causes failure. Continuation of a session file with a wider mode, for example from an older release, fails. The error gives the file path and the `0600` rule.
145
151
 
146
152
  The tasks in a request share a 40 KiB inline answer budget. Each worker prompt states its share. A longer exact answer is still a success. The result shows a UTF-8-safe prefix and a truncation marker. The marker gives the path of a private `answer-<turn>.md` file beside the session file, which holds the full answer. For a write task, the budget applies to the `answer` field of the completion object. A chain step receives the full answer of the previous step in `{previous}`; the budget applies only to the result for Main. The result details keep `answerFile` for each full-answer file. Recovery text includes these paths outside the output limit, even when checkout notes remove the inline link.
147
153
 
@@ -86,7 +86,8 @@ export const createIsolatedComponents: CreateIsolatedComponents = ({
86
86
  maxMs: null,
87
87
  ...(policy.maxTokens === undefined ? {} : { maxTokens: policy.maxTokens }),
88
88
  });
89
- const host = new HerdrHostRuntime({ inspectInFlightTaskCandidate: git.inspectInFlightTaskCandidate.bind(git), runProcess, executionBudget });
89
+ const host = new HerdrHostRuntime({ inspectInFlightTaskCandidate: git.inspectInFlightTaskCandidate.bind(git), runProcess, executionBudget,
90
+ settledIdleMs: () => currentPolicy().childIdleMs });
90
91
  const coordinator = new RoleLaunchRuntime({
91
92
  pi,
92
93
  context,
@@ -13,8 +13,18 @@ import { directRef, registerSubagentCommand, type DirectDisplay, type DirectTask
13
13
  import { MODEL_CLASS_GUIDANCE } from "./model-class-policy.ts";
14
14
  import { compactCost, compactTokens, ENTRY_STATUS_PRESENTATION, formatWorkflowResult, workerOutputForMain, type BackgroundWorkflowTransportDetails, type WorkflowTransportEntry } from "./result-transport.ts";
15
15
  import { checkoutNote, DelegateTaskParameters, identifyWorkflowEntries, parseDelegateTask, runForegroundWorkflow, snapshotCheckout, workflowRepos, writesCheckout, type CheckoutSnapshot, type ParsedWorkflow, type WorkflowEntry } from "./workflow.ts";
16
- import { assertSettledDirectSession, createDirectHerdr, DirectAllocationError, directTaskReader, type DirectAllocation, type DirectHandle, type DirectTab, type DirectToolActivity, type DirectUsage } from "../dist/direct-herdr.js";
16
+ import { createDirectHerdr, DirectAllocationError, directTaskReader, directTurnPrompt, type DirectAllocation, type DirectHandle, type DirectTab, type DirectToolActivity, type DirectUsage } from "../dist/direct-herdr.js";
17
17
  import { materializeTransientLaunch } from "../dist/launch-runtime.js";
18
+ import { readSessionTurn } from "../dist/session-turn.js";
19
+ /** Refuse continuation unless the saved session has a Final turn for the recorded turn and no later user turn. */
20
+ async function assertSettledDirectSession(sessionFile: string, turn: string): Promise<void> {
21
+ const final = await readSessionTurn(sessionFile, directTurnPrompt(turn));
22
+ if (final.state === "absent") throw new Error(`Saved Pi session ${sessionFile} is missing or unreadable, or has no recorded prompt`);
23
+ if (final.state === "running") {
24
+ throw new Error(`Saved Pi session ${sessionFile} has no terminal final turn for the recorded prompt; the worker may still be running`);
25
+ }
26
+ }
27
+
18
28
  const WIDGET_KEY = "subagent-status";
19
29
  const WIDGET_INTERVAL_MS = 1_000;
20
30
  const MAX_WIDGET_ITEMS = 8;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hcflabs/pi-subagent",
3
- "version": "28.2.1",
3
+ "version": "28.2.3",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",