@henryqw/pi-subagent 27.4.3 → 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.
package/README.md CHANGED
@@ -13,11 +13,7 @@ pi install npm:@henryqw/pi-task-models
13
13
  pi install npm:@henryqw/pi-subagent
14
14
  ```
15
15
 
16
- Run `/task-models` and configure the `fast`, `balanced`, `frontier`, and `fav` routes you use. Direct writer termination proof requires native `lsof`.
17
-
18
- ### Upgrade from 26.x
19
-
20
- Version 27.0.0 requires direct tasks to declare `access` and rejects unknown Role frontmatter keys. The library replaces `loadRoles` with `loadRoleCatalog` and `findRole`. Read [Migrate from 26.x to 27.0.0](./docs/migration.md).
16
+ Configure the profiles that your Roles use in `/task-models`. Direct writer termination proof requires native `lsof`.
21
17
 
22
18
  ## Works with
23
19
 
@@ -48,7 +44,7 @@ Commands and UI are for you. Tools and the packaged skill are for Main, except `
48
44
  | Direct status widget (`D`) | ui | Show direct references, names, Role badges, status, routes, elapsed time, measured task tokens, and tool activity in the TUI. |
49
45
  | Isolated status widget (`I`) | ui | Show recorded task state, Role/model-class badges, and retained workspaces in the TUI. Readiness does not mean promotion. |
50
46
 
51
- `delegate_task`, `subagent_resume`, `subagent_stage`, `subagent_integrate`, and `subagent_abort` are model-only. Main must make these decisions visibly; `codemode` scripts can call only `subagent_status`. Delegation and continuation tools use plain-object parameter schemas, including for providers that omit root-union tools. Each mode and action still requires its exact arguments.
47
+ `delegate_task`, `subagent_resume`, `subagent_stage`, `subagent_integrate`, and `subagent_abort` are model-only. Main must make these decisions visibly; `codemode` scripts can call only `subagent_status`.
52
48
 
53
49
  ### Direct workflow
54
50
 
@@ -76,9 +72,7 @@ Every task requires `access: "read"` or `access: "write"`. Declare `write` for f
76
72
 
77
73
  Read requests run parallel tasks up to `maxSubagents`. Chains run in order, replace literal `{previous}` with the preceding successful answer, and stop on failure. Any `write` task makes the whole request serial and reserves checkout admission until all owned workers stop.
78
74
 
79
- Direct writes change Main's checkout immediately, including dirty checkouts. They have no automatic checks, rollback, or isolated integration guarantee. Specify permitted paths, actions, and exclusions. Preserve unrelated edits and staged changes; authorize commits and external actions explicitly.
80
-
81
- While admission is held, competing direct writers and Main writes fail. A direct writer also cannot start during a Main call that can write, including Bash or nested `codemode` writes. Main calls do not block each other; reads never wait. Use isolated mode to keep Main free to write.
75
+ Direct writes change Main's checkout immediately, including dirty checkouts, and have no automatic checks or rollback. Preserve unrelated edits. See [Direct contract](./docs/orchestration.md#direct-contract) for write scope and checkout-admission rules.
82
76
 
83
77
  Tasks in write requests must return one JSON completion object:
84
78
 
@@ -94,6 +88,8 @@ To continue a finished worker, add `"continueFrom": "D7.1"` with the same `role`
94
88
 
95
89
  ### Isolated workflow
96
90
 
91
+ ![Isolated workers commit checked candidates; Main stages ready work while siblings run, advances dependents, validates the combined tip, and explicitly promotes](./docs/orchestration-architecture.svg)
92
+
97
93
  Use isolated mode for checked implementation. An isolated request has one durable ID, one goal, and 1 to 8 text or changeset tasks:
98
94
 
99
95
  ```json
@@ -120,13 +116,14 @@ Use isolated mode for checked implementation. An isolated request has one durabl
120
116
 
121
117
  Workers use Main's pinned committed branch, HEAD, and tree, not staged, unstaged, or untracked edits. Branch or HEAD changes block new waves. Candidates must be committed with no tracked or untracked changes. Ignored generated files permit candidacy but prevent automatic cleanup.
122
118
 
123
- 1. Start the request and keep its durable ID. Main can work or end its turn without polling.
124
- 2. Inspect ready candidates with `subagent_status`. Main can stage exact candidates while siblings run and resolve conflicts there.
125
- 3. Use `subagent_integrate advance` for dependents on a staged snapshot.
126
- 4. After the wave settles, validate the combined integration tip with final checks and any final judgment.
127
- 5. Explicitly promote passing work into unchanged, clean Main.
119
+ Keep the durable ID; Main can work or end its turn without polling. Inspect ready candidates with `subagent_status`.
120
+
121
+ - `subagent_stage` stages exact candidates while siblings run; resolve conflicts in the integration checkout.
122
+ - `subagent_integrate advance` runs dependents on a staged snapshot.
123
+ - `subagent_integrate validate` checks the combined integration tip after the wave settles, with final checks and any final judgment.
124
+ - `subagent_integrate promote` explicitly promotes passing work.
128
125
 
129
- Checks and judgments bind to exact Git identities. Staging, combined validation, and promotion require clean Main at the pinned identity. Changed identities, conflicts, or uncertain outcomes block promotion and preserve evidence. A definitive combined check or review failure permits one committed integration correction and another validation.
126
+ Readiness requires preliminary checks and any task `judgment`; it does not permit promotion. Checks and judgments bind to exact Git identities. Staging, combined validation, and promotion require unchanged, clean Main at the pinned identity. Changed identities, conflicts, or uncertain outcomes block promotion and preserve evidence. A definitive combined check or review failure permits one committed integration correction, recorded with `subagent_integrate correct`, and another validation.
130
127
 
131
128
  The extension sends follow-ups when a candidate becomes ready, a worker needs attention, and the wave finishes. A missed follow-up does not authorize replay. Read [Isolated contract](./docs/orchestration.md#isolated-contract) and [Exact integration actions](./docs/orchestration.md#exact-integration-actions) for graph, judgment, revision, refresh, and release details.
132
129
 
@@ -134,9 +131,7 @@ The extension sends follow-ups when a candidate becomes ready, a worker needs at
134
131
 
135
132
  Open `/subagent` to browse direct work on this session branch and isolated requests in the current canonical Git checkout, including other sessions. **Refresh** reloads saved status without recovery or mutation. Invalid state IDs stay preserved with a warning. Direct recovery remains available outside Git or without isolated inventory.
136
133
 
137
- **Inspect status and recovery** opens a separate unfocused Herdr tab using the configured `scout` Role and `fast` route. It requires a Herdr-managed pane. The model receives bounded saved status, including retained paths, but not raw launch environment or session transcripts. Its analysis stays in that tab and does not start a Main turn. Missing Herdr, Role, or route fails without changing the request; Main can use `subagent_status` instead.
138
-
139
- For locally active unsealed changesets, **Send follow-up instructions** queues a revision. **Edit queued instructions** withdraws all pending instructions before opening the editor; cancel leaves them removed. Already-claimed instructions cannot be recalled. Submission queues one replacement, and late submissions can fail after sealing. Read [Inspect and revise active work](./docs/orchestration.md#inspect-and-revise-active-work) before editing queued instructions.
134
+ Inspection needs a Herdr pane, the configured `scout` Role, and the `fast` route. If it cannot start, the request stays unchanged; Main can use `subagent_status` to read state. See [Inspect and revise active work](./docs/orchestration.md#inspect-and-revise-active-work) for inspection and follow-up details.
140
135
 
141
136
  See [Status widgets](./docs/orchestration.md#status-widgets) for reference numbering, token counts, and activity metrics, and [Choose a delegation mode](./docs/orchestration.md#choose-a-delegation-mode) for routing guidance.
142
137
 
@@ -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);