@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 +13 -18
- package/dist/direct-herdr.d.ts +2 -0
- package/dist/direct-herdr.js +116 -81
- package/docs/orchestration-architecture.html +125 -225
- package/docs/orchestration-architecture.svg +134 -188
- package/docs/worker-lifecycle.html +12 -7
- package/docs/worker-lifecycle.svg +24 -18
- package/package.json +1 -1
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
|
-
|
|
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`.
|
|
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
|
|
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
|
+

|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
|
package/dist/direct-herdr.d.ts
CHANGED
|
@@ -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"> & {
|
package/dist/direct-herdr.js
CHANGED
|
@@ -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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
136
|
+
return true;
|
|
135
137
|
let entry;
|
|
136
138
|
try {
|
|
137
139
|
entry = JSON.parse(line);
|
|
138
140
|
}
|
|
139
141
|
catch {
|
|
140
|
-
|
|
142
|
+
return false;
|
|
141
143
|
}
|
|
142
144
|
if (!entry || entry.type !== "message" || !entry.message || typeof entry.message !== "object")
|
|
143
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
375
|
-
let lastUsage = {};
|
|
425
|
+
const session = createDirectSessionReader(sessionFile, prompt);
|
|
376
426
|
return {
|
|
377
427
|
name, tabId, paneId, sessionFile, prompt,
|
|
378
|
-
get activity() { return
|
|
379
|
-
|
|
380
|
-
|
|
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);
|