@henryqw/pi-subagent 27.4.4 → 27.4.5

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.
@@ -32,6 +32,8 @@ export declare function directSessionUsage(jsonl: string, prompt: string): {
32
32
  };
33
33
  /** The last `maxChars` of assistant text in the exact prompt's turn; ignores a partial trailing entry. */
34
34
  export declare function directPartialText(jsonl: string, prompt: string, maxChars: number): string | undefined;
35
+ /** One reader per handle; only complete, valid lines advance the byte offset. */
36
+ export declare function createDirectSessionReader(sessionFile: string, prompt: string): Pick<DirectHandle, "usageTokens" | "partialText" | "activity">;
35
37
  /** `turn` is the prompt's random turn identity; it proves which session turn a record owns.
36
38
  * `workspaceId` and `cwd` bind the record to its launching Herdr workspace and checkout. */
37
39
  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 { readFile, mkdtemp, stat, writeFile } from "node:fs/promises";
3
+ import { open, readFile, mkdtemp, stat, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { setTimeout as sleep } from "node:timers/promises";
@@ -122,99 +122,150 @@ export function exactDirectAnswer(jsonl, prompt, maxBytes = ANSWER_LIMIT, requir
122
122
  throw new Error(`Direct task reported ${completion.outcome}: ${completion.answer}`);
123
123
  return completion.answer;
124
124
  }
125
- // Ignore a partial trailing JSONL entry while the child is writing it.
126
- export function directSessionUsage(jsonl, prompt) {
127
- let active = false;
128
- let total = 0;
129
- let observed = false;
130
- const pending = new Map();
131
- let latest;
132
- for (const line of jsonl.split("\n")) {
125
+ class DirectSessionState {
126
+ active = false;
127
+ total = 0;
128
+ observed = false;
129
+ pending = new Map();
130
+ latest;
131
+ texts = [];
132
+ prompt;
133
+ constructor(prompt) { this.prompt = prompt; }
134
+ consume(line) {
133
135
  if (!line.trim())
134
- continue;
136
+ return true;
135
137
  let entry;
136
138
  try {
137
139
  entry = JSON.parse(line);
138
140
  }
139
141
  catch {
140
- break;
142
+ return false;
141
143
  }
142
144
  if (!entry || entry.type !== "message" || !entry.message || typeof entry.message !== "object")
143
- continue;
145
+ return true;
144
146
  const message = entry.message;
145
147
  if (message.role === "user") {
146
- active = Array.isArray(message.content) && message.content.length === 1
147
- && message.content[0]?.text === prompt;
148
- if (active) {
149
- total = 0;
150
- observed = false;
151
- pending.clear();
152
- latest = undefined;
148
+ this.active = Array.isArray(message.content) && message.content.length === 1
149
+ && message.content[0]?.text === this.prompt;
150
+ if (this.active) {
151
+ this.total = 0;
152
+ this.observed = false;
153
+ this.pending.clear();
154
+ this.latest = undefined;
155
+ this.texts = [];
153
156
  }
154
157
  }
155
- else if (active && message.role === "assistant") {
158
+ else if (this.active && message.role === "assistant") {
156
159
  if (message.usage && typeof message.usage === "object") {
157
160
  const usage = message.usage;
158
161
  const values = [usage.input, usage.output];
159
162
  if (values.every((value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0)) {
160
- total += values.reduce((sum, value) => sum + value, 0);
161
- observed = true;
163
+ this.total += values.reduce((sum, value) => sum + value, 0);
164
+ this.observed = true;
162
165
  }
163
166
  }
164
167
  if (Array.isArray(message.content))
165
168
  for (const part of message.content) {
166
169
  if (part?.type === "toolCall" && typeof part.id === "string" && typeof part.name === "string") {
167
- pending.set(part.id, part.name);
170
+ this.pending.set(part.id, part.name);
171
+ }
172
+ else if (part?.type === "text" && typeof part.text === "string" && part.text.trim()) {
173
+ this.texts.push(part.text);
168
174
  }
169
175
  }
170
176
  }
171
- else if (active && message.role === "toolResult" && typeof message.toolCallId === "string") {
172
- const name = pending.get(message.toolCallId);
173
- if (name === undefined)
174
- continue;
175
- pending.delete(message.toolCallId);
176
- latest = { name };
177
- if (typeof message.durationMs === "number" && Number.isFinite(message.durationMs) && message.durationMs >= 0) {
178
- latest.durationMs = message.durationMs;
177
+ else if (this.active && message.role === "toolResult" && typeof message.toolCallId === "string") {
178
+ const name = this.pending.get(message.toolCallId);
179
+ if (name !== undefined) {
180
+ this.pending.delete(message.toolCallId);
181
+ this.latest = { name };
182
+ if (typeof message.durationMs === "number" && Number.isFinite(message.durationMs) && message.durationMs >= 0) {
183
+ this.latest.durationMs = message.durationMs;
184
+ }
179
185
  }
180
186
  }
187
+ return true;
188
+ }
189
+ usage() {
190
+ return { tokens: this.observed ? this.total : undefined,
191
+ activity: this.pending.size || this.latest ? { pending: [...this.pending.values()], latest: this.latest } : undefined };
192
+ }
193
+ partialText(maxChars) {
194
+ const chars = Array.from(this.texts.join("\n").trim());
195
+ if (!chars.length)
196
+ return undefined;
197
+ return chars.length <= maxChars ? chars.join("") : `…${chars.slice(-maxChars).join("")}`;
181
198
  }
182
- return { tokens: observed ? total : undefined,
183
- activity: pending.size || latest ? { pending: [...pending.values()], latest } : undefined };
199
+ }
200
+ function parseDirectSession(jsonl, prompt) {
201
+ const state = new DirectSessionState(prompt);
202
+ for (const line of jsonl.split("\n"))
203
+ if (!state.consume(line))
204
+ break;
205
+ return state;
206
+ }
207
+ // Ignore a partial trailing JSONL entry while the child is writing it.
208
+ export function directSessionUsage(jsonl, prompt) {
209
+ return parseDirectSession(jsonl, prompt).usage();
184
210
  }
185
211
  /** The last `maxChars` of assistant text in the exact prompt's turn; ignores a partial trailing entry. */
186
212
  export function directPartialText(jsonl, prompt, maxChars) {
187
- let active = false;
188
- let texts = [];
189
- for (const line of jsonl.split("\n")) {
190
- if (!line.trim())
191
- continue;
192
- let entry;
213
+ return parseDirectSession(jsonl, prompt).partialText(maxChars);
214
+ }
215
+ /** One reader per handle; only complete, valid lines advance the byte offset. */
216
+ export function createDirectSessionReader(sessionFile, prompt) {
217
+ let offset = 0;
218
+ let previous;
219
+ let state = new DirectSessionState(prompt);
220
+ let lastUsage = {};
221
+ let reading;
222
+ const read = async () => {
223
+ const file = await open(sessionFile, "r");
193
224
  try {
194
- entry = JSON.parse(line);
195
- }
196
- catch {
197
- break;
198
- }
199
- if (!entry || entry.type !== "message" || !entry.message || typeof entry.message !== "object")
200
- continue;
201
- const message = entry.message;
202
- if (message.role === "user") {
203
- active = Array.isArray(message.content) && message.content.length === 1
204
- && message.content[0]?.text === prompt;
205
- if (active)
206
- texts = [];
225
+ const info = await file.stat();
226
+ if (info.size > SESSION_LIMIT) {
227
+ previous = info;
228
+ return false;
229
+ }
230
+ if (previous && (info.dev !== previous.dev || info.ino !== previous.ino || info.size < previous.size
231
+ || (info.size === previous.size && (info.mtimeMs !== previous.mtimeMs || info.ctimeMs !== previous.ctimeMs)))) {
232
+ offset = 0;
233
+ state = new DirectSessionState(prompt);
234
+ }
235
+ previous = info;
236
+ const buffer = Buffer.alloc(info.size - offset);
237
+ let length = 0;
238
+ while (length < buffer.length) {
239
+ const { bytesRead } = await file.read({ buffer, offset: length, length: buffer.length - length, position: offset + length });
240
+ if (!bytesRead)
241
+ break;
242
+ length += bytesRead;
243
+ }
244
+ let start = 0;
245
+ for (let end = buffer.indexOf(10); end >= 0 && end < length; end = buffer.indexOf(10, start)) {
246
+ if (!state.consume(buffer.toString("utf8", start, end)))
247
+ break;
248
+ offset += end + 1 - start;
249
+ start = end + 1;
250
+ }
251
+ return true;
207
252
  }
208
- else if (active && message.role === "assistant" && Array.isArray(message.content)) {
209
- for (const part of message.content)
210
- if (part?.type === "text" && typeof part.text === "string" && part.text.trim())
211
- texts.push(part.text);
253
+ finally {
254
+ await file.close();
212
255
  }
213
- }
214
- const chars = Array.from(texts.join("\n").trim());
215
- if (!chars.length)
216
- return undefined;
217
- return chars.length <= maxChars ? chars.join("") : `…${chars.slice(-maxChars).join("")}`;
256
+ };
257
+ // Timer samples and failure evidence can request the same file at the same time.
258
+ const sample = () => reading ??= read().finally(() => { reading = undefined; });
259
+ return {
260
+ get activity() { return lastUsage.activity; },
261
+ async usageTokens() {
262
+ lastUsage = await sample() ? state.usage() : {};
263
+ return lastUsage.tokens;
264
+ },
265
+ async partialText(maxChars) {
266
+ return await sample() ? state.partialText(maxChars) : undefined;
267
+ },
268
+ };
218
269
  }
219
270
  export class DirectAllocationError extends Error {
220
271
  allocation;
@@ -371,28 +422,12 @@ export function createDirectHerdr(pi, cwd, idleMs) {
371
422
  const state = inspect(accepted, "agent_prompted", name, paneId, tabId);
372
423
  if (state === "blocked" || state === "unknown")
373
424
  throw new Error(`Herdr agent became ${state}; inspect it before retrying.`);
374
- let lastUsageSize = -1;
375
- let lastUsage = {};
425
+ const session = createDirectSessionReader(sessionFile, prompt);
376
426
  return {
377
427
  name, tabId, paneId, sessionFile, prompt,
378
- get activity() { return lastUsage.activity; },
379
- async usageTokens() {
380
- const info = await stat(sessionFile);
381
- if (info.size > SESSION_LIMIT) {
382
- lastUsage = {};
383
- return undefined;
384
- }
385
- if (info.size !== lastUsageSize) {
386
- lastUsage = directSessionUsage(await readFile(sessionFile, "utf8"), prompt);
387
- lastUsageSize = info.size;
388
- }
389
- return lastUsage.tokens;
390
- },
391
- async partialText(maxChars) {
392
- if ((await stat(sessionFile)).size > SESSION_LIMIT)
393
- return undefined;
394
- return directPartialText(await readFile(sessionFile, "utf8"), prompt, maxChars);
395
- },
428
+ get activity() { return session.activity; },
429
+ usageTokens: session.usageTokens,
430
+ partialText: session.partialText,
396
431
  async answer(maxBytes) {
397
432
  const readAnswer = async () => {
398
433
  const info = await stat(sessionFile);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "27.4.4",
3
+ "version": "27.4.5",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",