pi-agent-replicate 0.0.0-stage → 1.4.1
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/AGENTS.md +27 -0
- package/CHANGELOG.md +152 -0
- package/CONTEXT.md +63 -0
- package/LICENSE +21 -0
- package/README.md +68 -2
- package/RELEASING.md +132 -0
- package/assets/banner.png +0 -0
- package/config.json.example +6 -0
- package/docs/README.md +23 -0
- package/docs/adr/0003-installable-role-packs.md +29 -0
- package/docs/adr/0008-adopt-pi-only-subagent-execution.md +22 -0
- package/docs/adr/0009-durable-recursive-subagent-ownership.md +23 -0
- package/docs/adr/0010-public-subagent-tree-registry.md +16 -0
- package/docs/configuration.md +14 -0
- package/docs/development.md +27 -0
- package/docs/public-subagent-tree-api.md +58 -0
- package/docs/retained-roles.md +71 -0
- package/docs/settled-lifecycle-contract.md +71 -0
- package/docs/worktree-subagents.md +58 -0
- package/examples/role-pack/extension.ts +18 -0
- package/examples/role-pack/package.json +16 -0
- package/examples/role-pack/roles/example-reviewer.md +12 -0
- package/index.d.ts +116 -0
- package/index.js +1064 -0
- package/index.ts +155 -0
- package/package.json +70 -4
- package/pi-extension/subagents/activity.js +575 -0
- package/pi-extension/subagents/activity.ts +980 -0
- package/pi-extension/subagents/completion.js +184 -0
- package/pi-extension/subagents/completion.ts +229 -0
- package/pi-extension/subagents/delivery-drain.js +15 -0
- package/pi-extension/subagents/delivery-drain.ts +20 -0
- package/pi-extension/subagents/delivery-files.js +86 -0
- package/pi-extension/subagents/delivery-files.ts +105 -0
- package/pi-extension/subagents/delivery-log.js +51 -0
- package/pi-extension/subagents/delivery-log.ts +51 -0
- package/pi-extension/subagents/delivery-payload.ts +55 -0
- package/pi-extension/subagents/delivery.ts +219 -0
- package/pi-extension/subagents/execution-owner.js +30 -0
- package/pi-extension/subagents/execution-owner.ts +38 -0
- package/pi-extension/subagents/herdr.js +645 -0
- package/pi-extension/subagents/herdr.ts +927 -0
- package/pi-extension/subagents/index.ts +3400 -0
- package/pi-extension/subagents/launch.js +774 -0
- package/pi-extension/subagents/launch.ts +1124 -0
- package/pi-extension/subagents/lifecycle.js +477 -0
- package/pi-extension/subagents/lifecycle.ts +582 -0
- package/pi-extension/subagents/lineage.js +806 -0
- package/pi-extension/subagents/lineage.ts +841 -0
- package/pi-extension/subagents/replica-background.js +32 -0
- package/pi-extension/subagents/replica-background.ts +38 -0
- package/pi-extension/subagents/replica-branch.ts +45 -0
- package/pi-extension/subagents/replica-directory-settings.ts +51 -0
- package/pi-extension/subagents/replica-history.js +22 -0
- package/pi-extension/subagents/replica-history.ts +21 -0
- package/pi-extension/subagents/replica-work.js +326 -0
- package/pi-extension/subagents/replica-work.ts +336 -0
- package/pi-extension/subagents/replica-worker.js +74 -0
- package/pi-extension/subagents/replica-worker.ts +71 -0
- package/pi-extension/subagents/replication.ts +99 -0
- package/pi-extension/subagents/result-delivery-queue.ts +33 -0
- package/pi-extension/subagents/role-catalog.js +123 -0
- package/pi-extension/subagents/role-catalog.ts +24 -0
- package/pi-extension/subagents/runtime-routing.js +154 -0
- package/pi-extension/subagents/runtime-routing.ts +239 -0
- package/pi-extension/subagents/session-binding.js +346 -0
- package/pi-extension/subagents/session-binding.ts +339 -0
- package/pi-extension/subagents/session-work.js +219 -0
- package/pi-extension/subagents/session-work.ts +167 -0
- package/pi-extension/subagents/session.js +318 -0
- package/pi-extension/subagents/session.ts +448 -0
- package/pi-extension/subagents/settled-contract.js +47 -0
- package/pi-extension/subagents/settled-contract.ts +175 -0
- package/pi-extension/subagents/source-state.js +180 -0
- package/pi-extension/subagents/source-state.ts +184 -0
- package/pi-extension/subagents/status.ts +493 -0
- package/pi-extension/subagents/subagent-done.js +805 -0
- package/pi-extension/subagents/subagent-done.ts +878 -0
- package/pi-extension/subagents/terminal.js +111 -0
- package/pi-extension/subagents/terminal.ts +208 -0
- package/pi-extension/subagents/watchdog.js +122 -0
- package/pi-extension/subagents/watchdog.ts +171 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
copyFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import type { SessionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
NewestAssistantEntry,
|
|
15
|
+
SessionBaselineCursor,
|
|
16
|
+
} from "./settled-contract.ts";
|
|
17
|
+
|
|
18
|
+
export interface SessionEntry {
|
|
19
|
+
type: string;
|
|
20
|
+
id: string;
|
|
21
|
+
parentId?: string | null;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MessageEntry extends SessionEntry {
|
|
26
|
+
type: "message";
|
|
27
|
+
message: {
|
|
28
|
+
role: "user" | "assistant" | "toolResult";
|
|
29
|
+
content: Array<{ type: string; text?: string; [key: string]: unknown }>;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type SeededSubagentSessionMode = "lineage-only" | "fork";
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
export function seedStandaloneSessionFile(params: { childSessionFile: string; childCwd: string }): void {
|
|
37
|
+
const header = {
|
|
38
|
+
type: "session",
|
|
39
|
+
version: 3,
|
|
40
|
+
id: randomUUID(),
|
|
41
|
+
timestamp: new Date().toISOString(),
|
|
42
|
+
cwd: params.childCwd,
|
|
43
|
+
};
|
|
44
|
+
mkdirSync(dirname(params.childSessionFile), { recursive: true });
|
|
45
|
+
writeFileSync(params.childSessionFile, `${JSON.stringify(header)}\n`, "utf8");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getForkContentLines(parentSessionFile: string): string[] {
|
|
49
|
+
const raw = readFileSync(parentSessionFile, "utf8");
|
|
50
|
+
const lines = raw.split("\n").filter((line) => line.trim());
|
|
51
|
+
|
|
52
|
+
let truncateAt = lines.length;
|
|
53
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
54
|
+
try {
|
|
55
|
+
const entry = JSON.parse(lines[i]);
|
|
56
|
+
if (entry.type === "message" && entry.message?.role === "user") {
|
|
57
|
+
truncateAt = i;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// ignore malformed lines
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return lines.slice(0, truncateAt).filter((line) => {
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(line).type !== "session";
|
|
68
|
+
} catch {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
export function seedSubagentSessionFile(params: {
|
|
76
|
+
mode: SeededSubagentSessionMode;
|
|
77
|
+
parentSessionFile: string;
|
|
78
|
+
childSessionFile: string;
|
|
79
|
+
childCwd: string;
|
|
80
|
+
}): void {
|
|
81
|
+
const header = {
|
|
82
|
+
type: "session",
|
|
83
|
+
version: 3,
|
|
84
|
+
id: randomUUID(),
|
|
85
|
+
timestamp: new Date().toISOString(),
|
|
86
|
+
cwd: params.childCwd,
|
|
87
|
+
parentSession: params.parentSessionFile,
|
|
88
|
+
};
|
|
89
|
+
const contentLines =
|
|
90
|
+
params.mode === "fork" ? getForkContentLines(params.parentSessionFile) : [];
|
|
91
|
+
const lines = [JSON.stringify(header), ...contentLines];
|
|
92
|
+
|
|
93
|
+
mkdirSync(dirname(params.childSessionFile), { recursive: true });
|
|
94
|
+
writeFileSync(params.childSessionFile, lines.join("\n") + "\n", "utf8");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Native projected messages may include modern Pi system checkpoints. */
|
|
98
|
+
export type ReplicationContextMessage = SessionContext["messages"][number] | { role: "system" };
|
|
99
|
+
/** Fork conversation and runtime, not extension control state; settle inherited calls without replay. */
|
|
100
|
+
export function seedReplicationSessionFile(params: {
|
|
101
|
+
parentSessionFile: string;
|
|
102
|
+
childSessionFile: string;
|
|
103
|
+
childCwd: string;
|
|
104
|
+
entries: readonly SessionEntry[];
|
|
105
|
+
contextMessages: readonly ReplicationContextMessage[];
|
|
106
|
+
resumeSessionId?: string;
|
|
107
|
+
/** Display name for this copy, separate from the inherited parent name. */
|
|
108
|
+
name?: string;
|
|
109
|
+
}): void {
|
|
110
|
+
const header = { type: "session", version: 3, id: params.resumeSessionId ?? randomUUID(), timestamp: new Date().toISOString(), cwd: params.childCwd, parentSession: params.parentSessionFile };
|
|
111
|
+
const rows: any[] = [header];
|
|
112
|
+
const pending = new Map<string, string>();
|
|
113
|
+
let parentId: string | null = null;
|
|
114
|
+
for (const source of params.entries) {
|
|
115
|
+
// Custom messages are conversation. Custom entries are extension-owned
|
|
116
|
+
// controls: restoring them can start a parent turn before our assignment.
|
|
117
|
+
if (source.type === "custom") continue;
|
|
118
|
+
const entry: SessionEntry = { ...source, parentId };
|
|
119
|
+
if (source.type === "compaction") {
|
|
120
|
+
const firstKeptIndex = params.entries.findIndex((row) => row.id === source.firstKeptEntryId);
|
|
121
|
+
if (params.entries[firstKeptIndex]?.type === "custom") {
|
|
122
|
+
entry.firstKeptEntryId = params.entries.slice(firstKeptIndex).find((row) => row.type !== "custom")?.id ?? source.id;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
rows.push(entry);
|
|
126
|
+
parentId = entry.id;
|
|
127
|
+
}
|
|
128
|
+
// Use the native projected context, not raw history before compaction or edits.
|
|
129
|
+
for (const message of params.contextMessages) {
|
|
130
|
+
if (message.role === "system") continue;
|
|
131
|
+
if (message.role === "toolResult") { pending.delete(message.toolCallId); continue; }
|
|
132
|
+
// A later turn already closes older orphan calls during provider replay.
|
|
133
|
+
pending.clear();
|
|
134
|
+
if (message.role !== "assistant" || message.stopReason === "error" || message.stopReason === "aborted") continue;
|
|
135
|
+
for (const part of message.content) if (part.type === "toolCall") pending.set(part.id, part.name);
|
|
136
|
+
}
|
|
137
|
+
for (const [toolCallId, toolName] of pending) {
|
|
138
|
+
const id = randomBytes(4).toString("hex");
|
|
139
|
+
rows.push({ type: "message", id, parentId, timestamp: new Date().toISOString(), message: {
|
|
140
|
+
role: "toolResult", toolCallId, toolName, isError: false, timestamp: Date.now(),
|
|
141
|
+
content: [{ type: "text", text: "This call belongs to the original session. This copy continues with its own assignment." }],
|
|
142
|
+
} });
|
|
143
|
+
parentId = id;
|
|
144
|
+
}
|
|
145
|
+
mkdirSync(dirname(params.childSessionFile), { recursive: true });
|
|
146
|
+
if (params.name) rows.push({ type: "session_info", id: randomBytes(4).toString("hex"), parentId, timestamp: new Date().toISOString(), name: params.name });
|
|
147
|
+
writeFileSync(params.childSessionFile, rows.map((row) => JSON.stringify(row)).join("\n") + "\n", "utf8");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseEntry(line: string): SessionEntry {
|
|
151
|
+
try {
|
|
152
|
+
return JSON.parse(line) as SessionEntry;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Invalid session entry: ${error instanceof Error ? error.message : String(error)}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function readEntries(sessionFile: string): SessionEntry[] {
|
|
161
|
+
return readFileSync(sessionFile, "utf8")
|
|
162
|
+
.split("\n")
|
|
163
|
+
.filter((line) => line.trim())
|
|
164
|
+
.map(parseEntry);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface TolerantSessionRead {
|
|
168
|
+
entries: SessionEntry[];
|
|
169
|
+
partialTrailingLine: boolean;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Read a session while ignoring one incomplete final JSONL record.
|
|
174
|
+
*
|
|
175
|
+
* Pi writes session entries atomically in normal operation, but a parent can
|
|
176
|
+
* observe the file between the write and the trailing newline. The next poll
|
|
177
|
+
* can read the same entry once it is complete, so the incomplete record is
|
|
178
|
+
* deliberately reported rather than treated as a session error.
|
|
179
|
+
*/
|
|
180
|
+
function readEntriesTolerant(sessionFile: string): TolerantSessionRead {
|
|
181
|
+
if (!existsSync(sessionFile)) {
|
|
182
|
+
return { entries: [], partialTrailingLine: false };
|
|
183
|
+
}
|
|
184
|
+
const lines = readFileSync(sessionFile, "utf8")
|
|
185
|
+
.split("\n")
|
|
186
|
+
.filter((line) => line.trim());
|
|
187
|
+
const entries: SessionEntry[] = [];
|
|
188
|
+
let partialTrailingLine = false;
|
|
189
|
+
for (let index = 0; index < lines.length; index++) {
|
|
190
|
+
try {
|
|
191
|
+
entries.push(parseEntry(lines[index]));
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (index === lines.length - 1) {
|
|
194
|
+
partialTrailingLine = true;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return { entries, partialTrailingLine };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Capture the session position and inherited assistant ids before a run. */
|
|
204
|
+
export function captureSessionBaseline(
|
|
205
|
+
sessionFile: string,
|
|
206
|
+
): SessionBaselineCursor {
|
|
207
|
+
const { entries } = readEntriesTolerant(sessionFile);
|
|
208
|
+
return {
|
|
209
|
+
sessionFile,
|
|
210
|
+
entryCount: entries.length,
|
|
211
|
+
leafId: entries.length > 0 ? entries[entries.length - 1].id : null,
|
|
212
|
+
assistantEntryIds: entries
|
|
213
|
+
.filter(
|
|
214
|
+
(entry) =>
|
|
215
|
+
entry.type === "message" &&
|
|
216
|
+
(entry as MessageEntry).message.role === "assistant",
|
|
217
|
+
)
|
|
218
|
+
.map((entry) => entry.id),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface EntriesAfterBaseline {
|
|
223
|
+
entries: SessionEntry[];
|
|
224
|
+
partialTrailingLine: boolean;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Read only records appended after a pre-run session baseline. */
|
|
228
|
+
export function readEntriesAfterBaseline(
|
|
229
|
+
sessionFile: string,
|
|
230
|
+
baseline: SessionBaselineCursor,
|
|
231
|
+
): EntriesAfterBaseline {
|
|
232
|
+
const read = readEntriesTolerant(sessionFile);
|
|
233
|
+
return {
|
|
234
|
+
entries: read.entries.slice(baseline.entryCount),
|
|
235
|
+
partialTrailingLine: read.partialTrailingLine,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Return the newest assistant entry in a post-baseline append. */
|
|
240
|
+
export function findNewestAppendedAssistant(
|
|
241
|
+
entries: readonly SessionEntry[],
|
|
242
|
+
): NewestAssistantEntry | null {
|
|
243
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
244
|
+
const entry = entries[index];
|
|
245
|
+
if (entry.type !== "message") continue;
|
|
246
|
+
const message = (entry as MessageEntry).message;
|
|
247
|
+
if (message.role !== "assistant") continue;
|
|
248
|
+
const textBlocks = message.content.filter(
|
|
249
|
+
(block) => block.type === "text" && typeof block.text === "string",
|
|
250
|
+
);
|
|
251
|
+
const text = textBlocks.map((block) => block.text as string).join("\n");
|
|
252
|
+
const stopReason = (message as { stopReason?: unknown }).stopReason;
|
|
253
|
+
const errorMessage = (message as { errorMessage?: unknown }).errorMessage;
|
|
254
|
+
return {
|
|
255
|
+
id: entry.id,
|
|
256
|
+
text: text.trim() ? text : null,
|
|
257
|
+
contentLength: text.length,
|
|
258
|
+
empty: !text.trim(),
|
|
259
|
+
...(typeof stopReason === "string" ? { stopReason } : {}),
|
|
260
|
+
...(typeof errorMessage === "string" && errorMessage.trim()
|
|
261
|
+
? { errorMessage: errorMessage.trim() }
|
|
262
|
+
: {}),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Read and inspect the newest assistant appended after a child baseline. */
|
|
269
|
+
export function inspectNewestAppendedAssistant(
|
|
270
|
+
sessionFile: string,
|
|
271
|
+
baseline: SessionBaselineCursor,
|
|
272
|
+
): NewestAssistantEntry | null {
|
|
273
|
+
return findNewestAppendedAssistant(
|
|
274
|
+
readEntriesAfterBaseline(sessionFile, baseline).entries,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Return the id of the last entry in the session file (current branch point / leaf).
|
|
280
|
+
*/
|
|
281
|
+
export function getLeafId(sessionFile: string): string | null {
|
|
282
|
+
const entries = readEntries(sessionFile);
|
|
283
|
+
return entries.length > 0 ? entries[entries.length - 1].id : null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Return entries added after `afterLine` (1-indexed count of existing entries).
|
|
288
|
+
*/
|
|
289
|
+
export function getNewEntries(
|
|
290
|
+
sessionFile: string,
|
|
291
|
+
afterLine: number,
|
|
292
|
+
): SessionEntry[] {
|
|
293
|
+
return readFileSync(sessionFile, "utf8")
|
|
294
|
+
.split("\n")
|
|
295
|
+
.filter((line) => line.trim())
|
|
296
|
+
.slice(afterLine)
|
|
297
|
+
.map(parseEntry);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Find the last assistant message text in a list of entries.
|
|
302
|
+
*
|
|
303
|
+
* Falls back to the `errorMessage` field when the last assistant message has
|
|
304
|
+
* `stopReason: "error"` and no usable text content — this happens when
|
|
305
|
+
* auto-retry exhausts on a provider overload / rate limit / server error, and
|
|
306
|
+
* without this fallback the parent would silently see a stale earlier message.
|
|
307
|
+
*/
|
|
308
|
+
export interface ObservedSessionRuntime {
|
|
309
|
+
provider?: string;
|
|
310
|
+
modelId?: string;
|
|
311
|
+
thinking?: string;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Read the effective model and thinking entries recorded by Pi at session startup. */
|
|
315
|
+
export function findObservedSessionRuntime(
|
|
316
|
+
entries: SessionEntry[],
|
|
317
|
+
): ObservedSessionRuntime {
|
|
318
|
+
const observed: ObservedSessionRuntime = {};
|
|
319
|
+
for (const entry of entries) {
|
|
320
|
+
if (entry.type === "model_change") {
|
|
321
|
+
if (typeof entry.provider === "string")
|
|
322
|
+
observed.provider = entry.provider;
|
|
323
|
+
if (typeof entry.modelId === "string") observed.modelId = entry.modelId;
|
|
324
|
+
} else if (
|
|
325
|
+
entry.type === "thinking_level_change" &&
|
|
326
|
+
typeof entry.thinkingLevel === "string"
|
|
327
|
+
) {
|
|
328
|
+
observed.thinking = entry.thinkingLevel;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return observed;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export interface FinalAssistantMessage {
|
|
335
|
+
text: string | null;
|
|
336
|
+
contentLength: number;
|
|
337
|
+
stopReason?: string;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Inspect only the final assistant message for completion evidence. */
|
|
341
|
+
export function inspectFinalAssistantMessage(
|
|
342
|
+
entries: SessionEntry[],
|
|
343
|
+
): FinalAssistantMessage {
|
|
344
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
345
|
+
const entry = entries[i];
|
|
346
|
+
if (entry.type !== "message") continue;
|
|
347
|
+
const msg = entry as MessageEntry;
|
|
348
|
+
if (msg.message.role !== "assistant") continue;
|
|
349
|
+
|
|
350
|
+
const texts = msg.message.content
|
|
351
|
+
.filter(
|
|
352
|
+
(block) => block.type === "text" && typeof block.text === "string",
|
|
353
|
+
)
|
|
354
|
+
.map((block) => block.text as string);
|
|
355
|
+
const text = texts.join("\n");
|
|
356
|
+
const stopReason = (msg.message as { stopReason?: unknown }).stopReason;
|
|
357
|
+
return {
|
|
358
|
+
text: text.trim() ? text : null,
|
|
359
|
+
contentLength: text.length,
|
|
360
|
+
...(typeof stopReason === "string" ? { stopReason } : {}),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
return { text: null, contentLength: 0 };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function findLastAssistantMessage(
|
|
367
|
+
entries: SessionEntry[],
|
|
368
|
+
): string | null {
|
|
369
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
370
|
+
const entry = entries[i];
|
|
371
|
+
if (entry.type !== "message") continue;
|
|
372
|
+
const msg = entry as MessageEntry;
|
|
373
|
+
if (msg.message.role !== "assistant") continue;
|
|
374
|
+
|
|
375
|
+
const texts = msg.message.content
|
|
376
|
+
.filter(
|
|
377
|
+
(block) =>
|
|
378
|
+
block.type === "text" &&
|
|
379
|
+
typeof block.text === "string" &&
|
|
380
|
+
block.text.trim() !== "",
|
|
381
|
+
)
|
|
382
|
+
.map((block) => block.text as string);
|
|
383
|
+
|
|
384
|
+
if (texts.length > 0 && texts.join("").trim()) return texts.join("\n");
|
|
385
|
+
|
|
386
|
+
const stopReason = (msg.message as { stopReason?: unknown }).stopReason;
|
|
387
|
+
const errorMessage = (msg.message as { errorMessage?: unknown })
|
|
388
|
+
.errorMessage;
|
|
389
|
+
if (
|
|
390
|
+
stopReason === "error" &&
|
|
391
|
+
typeof errorMessage === "string" &&
|
|
392
|
+
errorMessage.trim() !== ""
|
|
393
|
+
) {
|
|
394
|
+
return `Subagent error: ${errorMessage.trim()}`;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Append a branch_summary entry to the session file.
|
|
402
|
+
* Returns the new entry's id.
|
|
403
|
+
*/
|
|
404
|
+
export function appendBranchSummary(
|
|
405
|
+
sessionFile: string,
|
|
406
|
+
branchPointId: string,
|
|
407
|
+
fromId: string | null,
|
|
408
|
+
summary: string,
|
|
409
|
+
): string {
|
|
410
|
+
const id = randomBytes(4).toString("hex");
|
|
411
|
+
const entry = {
|
|
412
|
+
type: "branch_summary",
|
|
413
|
+
id,
|
|
414
|
+
parentId: branchPointId,
|
|
415
|
+
timestamp: new Date().toISOString(),
|
|
416
|
+
fromId: fromId ?? branchPointId,
|
|
417
|
+
summary,
|
|
418
|
+
};
|
|
419
|
+
appendFileSync(sessionFile, JSON.stringify(entry) + "\n", "utf8");
|
|
420
|
+
return id;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Copy the session file to destDir for parallel worker isolation.
|
|
425
|
+
* Returns the path of the copy.
|
|
426
|
+
*/
|
|
427
|
+
export function copySessionFile(sessionFile: string, destDir: string): string {
|
|
428
|
+
const id = randomBytes(4).toString("hex");
|
|
429
|
+
const dest = join(destDir, `subagent-${id}.jsonl`);
|
|
430
|
+
copyFileSync(sessionFile, dest);
|
|
431
|
+
return dest;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Read new entries from sourceFile (after afterLine), append them to targetFile.
|
|
436
|
+
* Returns the appended entries.
|
|
437
|
+
*/
|
|
438
|
+
export function mergeNewEntries(
|
|
439
|
+
sourceFile: string,
|
|
440
|
+
targetFile: string,
|
|
441
|
+
afterLine: number,
|
|
442
|
+
): SessionEntry[] {
|
|
443
|
+
const entries = getNewEntries(sourceFile, afterLine);
|
|
444
|
+
for (const entry of entries) {
|
|
445
|
+
appendFileSync(targetFile, JSON.stringify(entry) + "\n", "utf8");
|
|
446
|
+
}
|
|
447
|
+
return entries;
|
|
448
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export const SETTLED_OUTCOME_POLICY = {
|
|
2
|
+
clean: {
|
|
3
|
+
parentDelivery: "suppress",
|
|
4
|
+
childLifecycle: "auto-exit"
|
|
5
|
+
},
|
|
6
|
+
empty: {
|
|
7
|
+
parentDelivery: "suppress",
|
|
8
|
+
childLifecycle: "auto-exit"
|
|
9
|
+
},
|
|
10
|
+
error: {
|
|
11
|
+
parentDelivery: "suppress",
|
|
12
|
+
childLifecycle: "keep-open"
|
|
13
|
+
},
|
|
14
|
+
"intentional-abort": {
|
|
15
|
+
parentDelivery: "suppress",
|
|
16
|
+
childLifecycle: "keep-open"
|
|
17
|
+
},
|
|
18
|
+
"unexpected-abort": {
|
|
19
|
+
parentDelivery: "suppress",
|
|
20
|
+
childLifecycle: "keep-open"
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
export function classifySettledOutcome(evidence) {
|
|
24
|
+
const { assistant } = evidence;
|
|
25
|
+
if (assistant.stopReason === "error") return "error";
|
|
26
|
+
if (assistant.stopReason === "aborted") {
|
|
27
|
+
return evidence.interruptRequested ? "intentional-abort" : "unexpected-abort";
|
|
28
|
+
}
|
|
29
|
+
return assistant.empty || !assistant.text?.trim() ? "empty" : "clean";
|
|
30
|
+
}
|
|
31
|
+
export const SETTLED_TOOL_ERROR_POLICY = "recoverable-before-clean-final";
|
|
32
|
+
export function settledDeliveryKey(identity) {
|
|
33
|
+
return JSON.stringify([
|
|
34
|
+
identity.childId,
|
|
35
|
+
identity.sessionFile,
|
|
36
|
+
identity.assistantEntryId
|
|
37
|
+
]);
|
|
38
|
+
}
|
|
39
|
+
export function createDeliveryGates() {
|
|
40
|
+
return {
|
|
41
|
+
settled: {
|
|
42
|
+
lastActivitySequence: null,
|
|
43
|
+
delivered: new Set()
|
|
44
|
+
},
|
|
45
|
+
terminal: "pending"
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The assistant stop reasons Pi can persist for a settled turn.
|
|
3
|
+
*
|
|
4
|
+
* Keep this union open to newer Pi values so the contract can be consumed by
|
|
5
|
+
* a child running a newer provider without losing the recorded reason.
|
|
6
|
+
*/
|
|
7
|
+
export type AssistantStopReason =
|
|
8
|
+
| "stop"
|
|
9
|
+
| "length"
|
|
10
|
+
| "toolUse"
|
|
11
|
+
| "error"
|
|
12
|
+
| "aborted"
|
|
13
|
+
| "pending"
|
|
14
|
+
| "deferred"
|
|
15
|
+
| (string & {});
|
|
16
|
+
|
|
17
|
+
/** The newest assistant message observed at a settled session boundary. */
|
|
18
|
+
export interface NewestAssistantEntry {
|
|
19
|
+
/** The assistant message id from the session JSONL entry. */
|
|
20
|
+
id: string;
|
|
21
|
+
/** Joined text content, or null when the assistant response is empty. */
|
|
22
|
+
text: string | null;
|
|
23
|
+
/** Number of characters in the joined text before empty-text normalization. */
|
|
24
|
+
contentLength: number;
|
|
25
|
+
/** Pi's final stop reason, when one was persisted. */
|
|
26
|
+
stopReason?: AssistantStopReason;
|
|
27
|
+
/** Provider error detail, when Pi persisted one. */
|
|
28
|
+
errorMessage?: string;
|
|
29
|
+
/** True when this assistant entry has no usable text content. */
|
|
30
|
+
empty: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The five outcomes a settled assistant turn can have. */
|
|
34
|
+
export type SettledOutcomeKind =
|
|
35
|
+
| "clean"
|
|
36
|
+
| "empty"
|
|
37
|
+
| "error"
|
|
38
|
+
| "intentional-abort"
|
|
39
|
+
| "unexpected-abort";
|
|
40
|
+
|
|
41
|
+
/** Parent visibility and child lifecycle action for each settled outcome. */
|
|
42
|
+
export interface SettledOutcomePolicy {
|
|
43
|
+
parentDelivery: "suppress";
|
|
44
|
+
childLifecycle: "auto-exit" | "keep-open";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The approved delivery/retention policy for settled outcomes. */
|
|
48
|
+
export const SETTLED_OUTCOME_POLICY: Readonly<
|
|
49
|
+
Record<SettledOutcomeKind, SettledOutcomePolicy>
|
|
50
|
+
> = {
|
|
51
|
+
clean: { parentDelivery: "suppress", childLifecycle: "auto-exit" },
|
|
52
|
+
empty: { parentDelivery: "suppress", childLifecycle: "auto-exit" },
|
|
53
|
+
error: { parentDelivery: "suppress", childLifecycle: "keep-open" },
|
|
54
|
+
"intentional-abort": {
|
|
55
|
+
parentDelivery: "suppress",
|
|
56
|
+
childLifecycle: "keep-open",
|
|
57
|
+
},
|
|
58
|
+
"unexpected-abort": {
|
|
59
|
+
parentDelivery: "suppress",
|
|
60
|
+
childLifecycle: "keep-open",
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Evidence needed to classify one settled assistant turn.
|
|
66
|
+
*
|
|
67
|
+
* A tool error is deliberately not an outcome by itself. Pi may recover from
|
|
68
|
+
* a tool error and produce a clean final assistant entry in the same settled
|
|
69
|
+
* run; the final assistant entry remains authoritative.
|
|
70
|
+
*/
|
|
71
|
+
export interface SettledOutcomeEvidence {
|
|
72
|
+
assistant: NewestAssistantEntry;
|
|
73
|
+
/** True when the parent explicitly recorded an interrupt request. */
|
|
74
|
+
interruptRequested: boolean;
|
|
75
|
+
/** True when an earlier tool execution in this run reported an error. */
|
|
76
|
+
hadToolError?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Classify the final assistant evidence without consulting process state.
|
|
81
|
+
*
|
|
82
|
+
* Settled responses stay local. Errors and aborts keep the child open; clean
|
|
83
|
+
* and empty outcomes can drive auto-exit. Tool errors are recoverable when a
|
|
84
|
+
* later clean assistant response exists.
|
|
85
|
+
*/
|
|
86
|
+
export function classifySettledOutcome(
|
|
87
|
+
evidence: SettledOutcomeEvidence,
|
|
88
|
+
): SettledOutcomeKind {
|
|
89
|
+
const { assistant } = evidence;
|
|
90
|
+
if (assistant.stopReason === "error") return "error";
|
|
91
|
+
if (assistant.stopReason === "aborted") {
|
|
92
|
+
return evidence.interruptRequested ? "intentional-abort" : "unexpected-abort";
|
|
93
|
+
}
|
|
94
|
+
return assistant.empty || !assistant.text?.trim() ? "empty" : "clean";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Tool errors can recover before the final assistant response is settled. */
|
|
98
|
+
export type SettledToolErrorPolicy = "recoverable-before-clean-final";
|
|
99
|
+
|
|
100
|
+
/** The tool-error policy approved by the settled lifecycle plan. */
|
|
101
|
+
export const SETTLED_TOOL_ERROR_POLICY: SettledToolErrorPolicy =
|
|
102
|
+
"recoverable-before-clean-final";
|
|
103
|
+
|
|
104
|
+
/** The child/session/message tuple used to deduplicate settled delivery. */
|
|
105
|
+
export interface SettledDeliveryIdentity {
|
|
106
|
+
childId: string;
|
|
107
|
+
sessionFile: string;
|
|
108
|
+
assistantEntryId: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A stable key for a settled delivery identity. */
|
|
112
|
+
export type SettledDeliveryKey = string;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Serialize a delivery identity without relying on path or id delimiters.
|
|
116
|
+
* JSON tuple encoding keeps each component unambiguous.
|
|
117
|
+
*/
|
|
118
|
+
export function settledDeliveryKey(
|
|
119
|
+
identity: SettledDeliveryIdentity,
|
|
120
|
+
): SettledDeliveryKey {
|
|
121
|
+
return JSON.stringify([
|
|
122
|
+
identity.childId,
|
|
123
|
+
identity.sessionFile,
|
|
124
|
+
identity.assistantEntryId,
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The pre-run session position and inherited assistant ids. */
|
|
129
|
+
export interface SessionBaselineCursor {
|
|
130
|
+
sessionFile: string;
|
|
131
|
+
/** Number of non-empty JSONL entries present before the child starts. */
|
|
132
|
+
entryCount: number;
|
|
133
|
+
/** Last existing entry id, used to diagnose a moved or replaced session. */
|
|
134
|
+
leafId: string | null;
|
|
135
|
+
/** Assistant ids inherited by a fork and therefore not new responses. */
|
|
136
|
+
assistantEntryIds: readonly string[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The independent exactly-once state for settled turns. */
|
|
140
|
+
export interface SettledDeliveryGate {
|
|
141
|
+
lastActivitySequence: number | null;
|
|
142
|
+
delivered: Set<SettledDeliveryKey>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The independent exactly-once state for terminal completion. */
|
|
146
|
+
export type TerminalDeliveryGate = "pending" | "delivered" | "suppressed";
|
|
147
|
+
|
|
148
|
+
/** Both gates are kept so a settled turn never consumes terminal completion. */
|
|
149
|
+
export interface DeliveryGates {
|
|
150
|
+
settled: SettledDeliveryGate;
|
|
151
|
+
terminal: TerminalDeliveryGate;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Create empty delivery gates for a newly launched child. */
|
|
155
|
+
export function createDeliveryGates(): DeliveryGates {
|
|
156
|
+
return {
|
|
157
|
+
settled: { lastActivitySequence: null, delivered: new Set() },
|
|
158
|
+
terminal: "pending",
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Parent-facing payload for one settled assistant response. */
|
|
163
|
+
export interface SettledParentPayload {
|
|
164
|
+
kind: "settled";
|
|
165
|
+
childId: string;
|
|
166
|
+
sessionFile: string;
|
|
167
|
+
assistantEntryId: string;
|
|
168
|
+
activitySequence: number;
|
|
169
|
+
turnIndex: number | null;
|
|
170
|
+
outcome: SettledOutcomeKind;
|
|
171
|
+
text: string | null;
|
|
172
|
+
stopReason?: AssistantStopReason;
|
|
173
|
+
errorMessage?: string;
|
|
174
|
+
empty: boolean;
|
|
175
|
+
}
|