@bermudi/pi-delegate 0.1.6 → 0.1.7
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/dispatch.ts +0 -2
- package/lifecycle.ts +3 -11
- package/migrate-delegate-sessions.ts +202 -0
- package/package.json +1 -1
- package/sessions.ts +9 -50
- package/types.ts +0 -2
package/dispatch.ts
CHANGED
|
@@ -293,7 +293,6 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
|
|
|
293
293
|
const asyncEnv: TaskRunEnv = {
|
|
294
294
|
signal: ticketSignal,
|
|
295
295
|
modelRegistry,
|
|
296
|
-
parentSessionManager: ctx.sessionManager,
|
|
297
296
|
ticketId,
|
|
298
297
|
delegateStartedAt: ticket.created,
|
|
299
298
|
telemetryCallId: callSpan?.id,
|
|
@@ -426,7 +425,6 @@ export async function dispatchSync(
|
|
|
426
425
|
const syncEnv: TaskRunEnv = {
|
|
427
426
|
signal,
|
|
428
427
|
modelRegistry: ctx.modelRegistry,
|
|
429
|
-
parentSessionManager: ctx.sessionManager,
|
|
430
428
|
ticketId: undefined,
|
|
431
429
|
delegateStartedAt: startedAt,
|
|
432
430
|
telemetryCallId: callSpan?.id,
|
package/lifecycle.ts
CHANGED
|
@@ -18,7 +18,6 @@ import { isSessionBusy } from "./tickets.ts";
|
|
|
18
18
|
import {
|
|
19
19
|
createSubagentSessionManager,
|
|
20
20
|
persistSessionHeader,
|
|
21
|
-
setParentSession,
|
|
22
21
|
} from "./sessions.ts";
|
|
23
22
|
import { runAgentSession, formatDeadlineExceededError } from "./runner.ts";
|
|
24
23
|
import { getGitChangedFiles } from "./file-tracking.ts";
|
|
@@ -329,8 +328,8 @@ async function sleepForWholeTaskRetry(
|
|
|
329
328
|
}
|
|
330
329
|
|
|
331
330
|
/** Build the AgentSession for a fresh or resumed subagent via createAgentSession.
|
|
332
|
-
* Reuses the caller-supplied sessionManager (so
|
|
333
|
-
*
|
|
331
|
+
* Reuses the caller-supplied sessionManager (so per-task .jsonl files stay under
|
|
332
|
+
* our control). Extension-free host deps may be cached, while
|
|
334
333
|
* provider-configured or allowlisted-extension deps are session-local because
|
|
335
334
|
* Pi binds mutable extension callbacks onto each loader runtime. */
|
|
336
335
|
async function buildDelegateSession(
|
|
@@ -484,10 +483,6 @@ async function acquireAgentSession(
|
|
|
484
483
|
};
|
|
485
484
|
}
|
|
486
485
|
|
|
487
|
-
// Link resumed session to parent for /resume discoverability.
|
|
488
|
-
const parentFile = env.parentSessionManager?.getSessionFile?.();
|
|
489
|
-
if (parentFile) setParentSession(resumed, parentFile);
|
|
490
|
-
|
|
491
486
|
const session = await buildDelegateSession(
|
|
492
487
|
task,
|
|
493
488
|
resumed,
|
|
@@ -508,10 +503,7 @@ async function acquireAgentSession(
|
|
|
508
503
|
// isolation. Keep scratch transcripts in memory only.
|
|
509
504
|
sessionManager = SessionManager.inMemory(task.cwd);
|
|
510
505
|
} else {
|
|
511
|
-
const fresh = createSubagentSessionManager(
|
|
512
|
-
env.parentSessionManager,
|
|
513
|
-
task.cwd,
|
|
514
|
-
);
|
|
506
|
+
const fresh = createSubagentSessionManager(task.cwd);
|
|
515
507
|
if (!fresh) {
|
|
516
508
|
return {
|
|
517
509
|
error: failTask(task, "Internal: could not create session file"),
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Move old pi-delegate sessions out of Pi's normal session index.
|
|
3
|
+
*
|
|
4
|
+
* This is intentionally a standalone migration, not extension startup code:
|
|
5
|
+
* `pi -r` indexes sessions before extensions are loaded.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* bun run migrate-delegate-sessions.ts # report only
|
|
9
|
+
* bun run migrate-delegate-sessions.ts --apply # unlink and move
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
type JsonObject = Record<string, unknown>;
|
|
16
|
+
|
|
17
|
+
const agentDir = getAgentDir();
|
|
18
|
+
const sourceDir = path.join(agentDir, "sessions");
|
|
19
|
+
const destinationDir = path.join(agentDir, "delegate-sessions");
|
|
20
|
+
const apply = process.argv.includes("--apply");
|
|
21
|
+
|
|
22
|
+
function isObject(value: unknown): value is JsonObject {
|
|
23
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isWithin(root: string, candidate: string): boolean {
|
|
27
|
+
const relative = path.relative(root, candidate);
|
|
28
|
+
return (
|
|
29
|
+
relative === "" ||
|
|
30
|
+
(!relative.startsWith(`..${path.sep}`) &&
|
|
31
|
+
relative !== ".." &&
|
|
32
|
+
!path.isAbsolute(relative))
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readSession(file: string): JsonObject[] | undefined {
|
|
37
|
+
try {
|
|
38
|
+
const lines = fs.readFileSync(file, "utf8").split(/\r?\n/);
|
|
39
|
+
const entries: JsonObject[] = [];
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
if (!line.trim()) continue;
|
|
42
|
+
const parsed: unknown = JSON.parse(line);
|
|
43
|
+
if (!isObject(parsed)) return undefined;
|
|
44
|
+
entries.push(parsed);
|
|
45
|
+
}
|
|
46
|
+
return entries;
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readSessionHeader(file: string): JsonObject | undefined {
|
|
53
|
+
try {
|
|
54
|
+
const firstLine = fs.readFileSync(file, "utf8").split(/\r?\n/, 1)[0];
|
|
55
|
+
const parsed: unknown = JSON.parse(firstLine);
|
|
56
|
+
return isObject(parsed) && parsed.type === "session" ? parsed : undefined;
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sessionHeader(entries: JsonObject[]): JsonObject | undefined {
|
|
63
|
+
const header = entries[0];
|
|
64
|
+
return header?.type === "session" ? header : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function entryKey(entry: JsonObject): string {
|
|
68
|
+
return JSON.stringify(entry);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Pi's forkFrom() copies every non-header entry from the source session before
|
|
73
|
+
* writing anything new. Old delegate sessions do not copy the parent history.
|
|
74
|
+
* This is the discriminator: parentSession by itself is deliberately not
|
|
75
|
+
* sufficient because genuine Pi forks also have it.
|
|
76
|
+
*/
|
|
77
|
+
function isPiFork(
|
|
78
|
+
childEntries: JsonObject[],
|
|
79
|
+
parentEntries: JsonObject[],
|
|
80
|
+
): boolean {
|
|
81
|
+
const childBody = childEntries.slice(1);
|
|
82
|
+
const parentBody = parentEntries.slice(1);
|
|
83
|
+
if (parentBody.length === 0 || childBody.length < parentBody.length) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
return parentBody.every(
|
|
87
|
+
(entry, index) => entryKey(entry) === entryKey(childBody[index]),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function findJsonlFiles(directory: string): string[] {
|
|
92
|
+
if (!fs.existsSync(directory)) return [];
|
|
93
|
+
const files: string[] = [];
|
|
94
|
+
const visit = (current: string): void => {
|
|
95
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
96
|
+
const candidate = path.join(current, entry.name);
|
|
97
|
+
if (entry.isDirectory()) visit(candidate);
|
|
98
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
99
|
+
files.push(candidate);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
visit(directory);
|
|
103
|
+
return files;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface MigrationCandidate {
|
|
107
|
+
source: string;
|
|
108
|
+
destination: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const candidates: MigrationCandidate[] = [];
|
|
112
|
+
let skipped = 0;
|
|
113
|
+
const parentCache = new Map<string, JsonObject[] | undefined>();
|
|
114
|
+
|
|
115
|
+
for (const file of findJsonlFiles(sourceDir)) {
|
|
116
|
+
const header = readSessionHeader(file);
|
|
117
|
+
const parent = header?.parentSession;
|
|
118
|
+
if (!header || typeof parent !== "string") continue;
|
|
119
|
+
|
|
120
|
+
const parentPath = path.resolve(parent);
|
|
121
|
+
if (!isWithin(sourceDir, parentPath) || !fs.existsSync(parentPath)) {
|
|
122
|
+
skipped++;
|
|
123
|
+
console.warn(`skip (parent unavailable): ${file}`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let parentEntries = parentCache.get(parentPath);
|
|
128
|
+
if (parentEntries === undefined && !parentCache.has(parentPath)) {
|
|
129
|
+
parentEntries = readSession(parentPath);
|
|
130
|
+
parentCache.set(parentPath, parentEntries);
|
|
131
|
+
}
|
|
132
|
+
const entries = readSession(file);
|
|
133
|
+
if (!entries) {
|
|
134
|
+
skipped++;
|
|
135
|
+
console.warn(`skip (invalid session): ${file}`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (!parentEntries || isPiFork(entries, parentEntries)) continue;
|
|
139
|
+
|
|
140
|
+
const relative = path.relative(sourceDir, file);
|
|
141
|
+
candidates.push({
|
|
142
|
+
source: file,
|
|
143
|
+
destination: path.join(destinationDir, relative),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log(
|
|
148
|
+
`${apply ? "Migrating" : "Found"} ${candidates.length} delegate session(s); ` +
|
|
149
|
+
`${skipped} skipped because their parent could not be verified.`,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
if (!apply) {
|
|
153
|
+
for (const candidate of candidates) {
|
|
154
|
+
console.log(`would move: ${candidate.source} -> ${candidate.destination}`);
|
|
155
|
+
}
|
|
156
|
+
console.log("Nothing changed. Re-run with --apply to perform the migration.");
|
|
157
|
+
process.exit(0);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let moved = 0;
|
|
161
|
+
for (const candidate of candidates) {
|
|
162
|
+
const entries = readSession(candidate.source);
|
|
163
|
+
const header = entries && sessionHeader(entries);
|
|
164
|
+
if (!entries || !header) {
|
|
165
|
+
console.warn(`skip (changed during migration): ${candidate.source}`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
delete header.parentSession;
|
|
170
|
+
const parent = path.dirname(candidate.destination);
|
|
171
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
172
|
+
|
|
173
|
+
const temporary = `${candidate.source}.delegate-migration-${process.pid}.tmp`;
|
|
174
|
+
try {
|
|
175
|
+
if (fs.existsSync(candidate.destination)) {
|
|
176
|
+
throw new Error("destination already exists");
|
|
177
|
+
}
|
|
178
|
+
fs.writeFileSync(
|
|
179
|
+
temporary,
|
|
180
|
+
`${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`,
|
|
181
|
+
{ flag: "wx", mode: 0o600 },
|
|
182
|
+
);
|
|
183
|
+
fs.renameSync(temporary, candidate.source);
|
|
184
|
+
fs.renameSync(candidate.source, candidate.destination);
|
|
185
|
+
console.log(`moved: ${candidate.source} -> ${candidate.destination}`);
|
|
186
|
+
moved++;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
try {
|
|
189
|
+
if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
|
|
190
|
+
} catch {
|
|
191
|
+
// Preserve the original error below; the temp file is harmless and
|
|
192
|
+
// uniquely named for this process.
|
|
193
|
+
}
|
|
194
|
+
console.error(
|
|
195
|
+
`failed: ${candidate.source}: ${
|
|
196
|
+
error instanceof Error ? error.message : String(error)
|
|
197
|
+
}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
console.log(`Moved ${moved}/${candidates.length} delegate session(s).`);
|
package/package.json
CHANGED
package/sessions.ts
CHANGED
|
@@ -1,67 +1,26 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { SessionManager, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
|
|
4
|
-
/**
|
|
5
|
-
export function
|
|
6
|
-
|
|
7
|
-
fileEntries: Array<{ type: string; parentSession?: string }>;
|
|
8
|
-
getSessionFile?: () => string | undefined;
|
|
9
|
-
_rewriteFile?: () => void;
|
|
10
|
-
};
|
|
11
|
-
const header = inner.fileEntries[0];
|
|
12
|
-
if (header && header.type === "session") {
|
|
13
|
-
header.parentSession = parentPath;
|
|
14
|
-
// For a *resumed* session the file already exists on disk and the manager
|
|
15
|
-
// is flushed (SessionManager.open/setSessionFile sets flushed=true). The
|
|
16
|
-
// in-memory header mutation above is otherwise lost: upstream _persist()
|
|
17
|
-
// only *appends* new entries once flushed — it never rewrites the header.
|
|
18
|
-
// So a resumeFrom session would never surface as a child in /resume despite
|
|
19
|
-
// the link being set in memory. Rewrite the whole file (header + entries)
|
|
20
|
-
// so the parentSession field is actually persisted. Fresh sessions skip
|
|
21
|
-
// this (file doesn't exist yet); their first _persist() writes the mutated
|
|
22
|
-
// header along with the rest, and rewriting early would trip the
|
|
23
|
-
// duplicate-header bug in _persist()'s not-yet-flushed path.
|
|
24
|
-
const file = inner.getSessionFile?.();
|
|
25
|
-
if (file && fs.existsSync(file)) {
|
|
26
|
-
try {
|
|
27
|
-
inner._rewriteFile?.();
|
|
28
|
-
} catch {
|
|
29
|
-
/* best effort — link stays in-memory; not fatal */
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
5
|
+
/** Persistent storage for delegate-only conversations. */
|
|
6
|
+
export function getDelegateSessionDir(): string {
|
|
7
|
+
return join(getAgentDir(), "delegate-sessions");
|
|
33
8
|
}
|
|
34
9
|
|
|
35
10
|
/**
|
|
36
11
|
* Create a session manager for a subagent run.
|
|
37
12
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* as a child of the parent session in `/resume`.
|
|
41
|
-
*
|
|
42
|
-
* Returns the concrete `SessionManager` (ready to hand to `createAgentSession`)
|
|
43
|
-
* and its file path (for result reporting + pool bookkeeping).
|
|
13
|
+
* Delegate sessions are deliberately standalone and live in their own
|
|
14
|
+
* directory. They are not attached to the parent's session tree.
|
|
44
15
|
*/
|
|
45
16
|
export function createSubagentSessionManager(
|
|
46
|
-
parentSessionManager: unknown,
|
|
47
17
|
cwd: string,
|
|
48
18
|
): { manager: SessionManager; file: string } | undefined {
|
|
49
|
-
//
|
|
50
|
-
const
|
|
51
|
-
parentSessionManager as
|
|
52
|
-
{ getSessionFile?(): string | undefined } | undefined
|
|
53
|
-
)?.getSessionFile?.();
|
|
54
|
-
|
|
55
|
-
// Always persist subagent work so the main agent can search it later.
|
|
56
|
-
const sm = SessionManager.create(cwd);
|
|
19
|
+
// Always persist subagent work separately from the parent's session tree.
|
|
20
|
+
const sm = SessionManager.create(cwd, getDelegateSessionDir());
|
|
57
21
|
const sessionFile = sm.getSessionFile();
|
|
58
22
|
if (!sessionFile) return undefined;
|
|
59
23
|
|
|
60
|
-
// Link to parent session so subagent appears as a child in /resume.
|
|
61
|
-
if (parentFile) {
|
|
62
|
-
setParentSession(sm, parentFile);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
24
|
return { manager: sm, file: sessionFile };
|
|
66
25
|
}
|
|
67
26
|
|
package/types.ts
CHANGED
|
@@ -251,8 +251,6 @@ export interface TaskRunEnv {
|
|
|
251
251
|
/** Abort signal — parent's for sync, ticket's for async. May be undefined when no parent signal is available. */
|
|
252
252
|
signal: AbortSignal | undefined;
|
|
253
253
|
modelRegistry: ModelRegistry;
|
|
254
|
-
/** Parent session manager — used to link subagent sessions for /resume. */
|
|
255
|
-
parentSessionManager: { getSessionFile?(): string | undefined } | undefined;
|
|
256
254
|
/** Ticket id for busy-guard self-checks. undefined for sync. */
|
|
257
255
|
ticketId?: string;
|
|
258
256
|
/** When the delegate started. Used for close/list progress (elapsed time). */
|