@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 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 parent-linking + per-task .jsonl
333
- * files stay under our control). Extension-free host deps may be cached, while
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bermudi/pi-delegate",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Delegate tool for the Pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
package/sessions.ts CHANGED
@@ -1,67 +1,26 @@
1
1
  import * as fs from "node:fs";
2
- import { SessionManager } from "@earendil-works/pi-coding-agent";
2
+ import { join } from "node:path";
3
+ import { SessionManager, getAgentDir } from "@earendil-works/pi-coding-agent";
3
4
 
4
- /** Link a subagent session to its parent and persist the header when possible. */
5
- export function setParentSession(sm: SessionManager, parentPath: string): void {
6
- const inner = sm as unknown as {
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
- * Always creates a standalone session file in the target cwd.
39
- * Sets `parentSession` in the header so subagent work is discoverable
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
- // Resolve parent session file path for linking.
50
- const parentFile = (
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). */