@jmfederico/pi-web 1.202606.6 → 1.202607.0

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.
Files changed (103) hide show
  1. package/README.md +87 -293
  2. package/dist/cli.js +25 -7
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/CodeViewer-tXF3UTsY.js +4 -0
  5. package/dist/client/assets/TerminalPanel-D_ihiWAt.js +187 -0
  6. package/dist/client/assets/UnifiedDiffViewer-BziirXBJ.js +39 -0
  7. package/dist/client/assets/index-Brfk1lwl.js +2950 -0
  8. package/dist/client/assets/vendor-editor-core-vUi74DnD.js +12 -0
  9. package/dist/client/assets/vendor-editor-languages-DoaXUodB.js +46 -0
  10. package/dist/client/assets/vendor-editor-legacy-CYBnW6ZU.js +1 -0
  11. package/dist/client/assets/vendor-terminal-BrP-ENHg.css +1 -0
  12. package/dist/client/assets/vendor-terminal-D8k4UKM2.js +35 -0
  13. package/dist/client/index.html +6 -3
  14. package/dist/config.js +33 -1
  15. package/dist/config.js.map +1 -1
  16. package/dist/docker/piWebDockerCommandPlan.js +211 -0
  17. package/dist/docker/piWebDockerCommandPlan.js.map +1 -0
  18. package/dist/pi-web-plugins/updates/pi-web-plugin.js +15 -5
  19. package/dist/pi-web-plugins/updates/updatesLogic.js +37 -5
  20. package/dist/piWebVersionReport.js +4 -0
  21. package/dist/piWebVersionReport.js.map +1 -1
  22. package/dist/plugin-api.d.ts +30 -2
  23. package/dist/server/app.js +32 -5
  24. package/dist/server/app.js.map +1 -1
  25. package/dist/server/browserMessageProjection.js +56 -0
  26. package/dist/server/browserMessageProjection.js.map +1 -0
  27. package/dist/server/configRoutes.js +112 -2
  28. package/dist/server/configRoutes.js.map +1 -1
  29. package/dist/server/machines/machineClient.js +51 -13
  30. package/dist/server/machines/machineClient.js.map +1 -1
  31. package/dist/server/machines/machineProxyRoutes.js +72 -4
  32. package/dist/server/machines/machineProxyRoutes.js.map +1 -1
  33. package/dist/server/machines/machineService.js +4 -26
  34. package/dist/server/machines/machineService.js.map +1 -1
  35. package/dist/server/piPackageRoutes.js +87 -0
  36. package/dist/server/piPackageRoutes.js.map +1 -0
  37. package/dist/server/piPackageService.js +69 -0
  38. package/dist/server/piPackageService.js.map +1 -0
  39. package/dist/server/piWebPluginService.js +43 -8
  40. package/dist/server/piWebPluginService.js.map +1 -1
  41. package/dist/server/piWebStatus.js +57 -0
  42. package/dist/server/piWebStatus.js.map +1 -1
  43. package/dist/server/realtime/sessionEventHub.js +2 -1
  44. package/dist/server/realtime/sessionEventHub.js.map +1 -1
  45. package/dist/server/sessions/attachmentService.js +69 -11
  46. package/dist/server/sessions/attachmentService.js.map +1 -1
  47. package/dist/server/sessions/builtinCommands.js +1 -1
  48. package/dist/server/sessions/builtinCommands.js.map +1 -1
  49. package/dist/server/sessions/piSessionManagerGateway.js +20 -2
  50. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
  51. package/dist/server/sessions/piSessionService.js +905 -73
  52. package/dist/server/sessions/piSessionService.js.map +1 -1
  53. package/dist/server/sessions/sessionArchiveStore.js +51 -22
  54. package/dist/server/sessions/sessionArchiveStore.js.map +1 -1
  55. package/dist/server/sessions/sessionCleanup.js +157 -0
  56. package/dist/server/sessions/sessionCleanup.js.map +1 -0
  57. package/dist/server/sessions/sessionCommandService.js +16 -0
  58. package/dist/server/sessions/sessionCommandService.js.map +1 -1
  59. package/dist/server/sessions/sessionNameGenerator.js +27 -44
  60. package/dist/server/sessions/sessionNameGenerator.js.map +1 -1
  61. package/dist/server/sessions/sessionRoutes.js +55 -1
  62. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  63. package/dist/server/sessions/spawnSessionTool.js +9 -4
  64. package/dist/server/sessions/spawnSessionTool.js.map +1 -1
  65. package/dist/server/sessions/spawnSubsessionTool.js +28 -18
  66. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
  67. package/dist/server/workspaceExplorerRoutes.js +55 -3
  68. package/dist/server/workspaceExplorerRoutes.js.map +1 -1
  69. package/dist/server/workspaces/fileContentService.js +112 -1
  70. package/dist/server/workspaces/fileContentService.js.map +1 -1
  71. package/dist/server/workspaces/fileSuggestions.js +7 -3
  72. package/dist/server/workspaces/fileSuggestions.js.map +1 -1
  73. package/dist/server/workspaces/pathSafety.js +16 -1
  74. package/dist/server/workspaces/pathSafety.js.map +1 -1
  75. package/dist/server/workspaces/projectPiWebConfig.js +6 -1
  76. package/dist/server/workspaces/projectPiWebConfig.js.map +1 -1
  77. package/dist/shared/activity.js +0 -15
  78. package/dist/shared/activity.js.map +1 -1
  79. package/dist/shared/apiTypes.d.ts +146 -7
  80. package/dist/shared/apiTypes.js +5 -0
  81. package/dist/shared/apiTypes.js.map +1 -1
  82. package/dist/shared/capabilities.js +29 -2
  83. package/dist/shared/capabilities.js.map +1 -1
  84. package/dist/shared/federatedRoutes.js +15 -0
  85. package/dist/shared/federatedRoutes.js.map +1 -1
  86. package/dist/shared/piWebStatusParsing.js +20 -8
  87. package/dist/shared/piWebStatusParsing.js.map +1 -1
  88. package/dist/shared/promptAttachments.js +30 -11
  89. package/dist/shared/promptAttachments.js.map +1 -1
  90. package/dist/shared/thinkingLevels.d.ts +1 -1
  91. package/dist/shared/thinkingLevels.js +1 -1
  92. package/dist/shared/thinkingLevels.js.map +1 -1
  93. package/docs/config.md +62 -14
  94. package/docs/plugins.md +155 -17
  95. package/package.json +35 -31
  96. package/dist/client/assets/CodeViewer-Bv8l_MFi.js +0 -4
  97. package/dist/client/assets/TerminalPanel-BZKsaE9s.js +0 -126
  98. package/dist/client/assets/index-C9oPLpm7.js +0 -2408
  99. package/dist/client/assets/vendor-editor-core-Bc1A7SO7.js +0 -12
  100. package/dist/client/assets/vendor-editor-languages-D0CnB2js.js +0 -26
  101. package/dist/client/assets/vendor-editor-legacy-B4QLsWF8.js +0 -1
  102. package/dist/client/assets/vendor-terminal-DDGTF8rc.css +0 -1
  103. package/dist/client/assets/vendor-terminal-DjQ08hXu.js +0 -16
@@ -1,19 +1,21 @@
1
- import { readFile, writeFile } from "node:fs/promises";
1
+ import { statSync } from "node:fs";
2
+ import { open, readFile, writeFile } from "node:fs/promises";
2
3
  import { AuthStorage, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, defineTool, getAgentDir, ModelRegistry, SessionManager, } from "@earendil-works/pi-coding-agent";
3
4
  import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
4
5
  import { BUILTIN_COMMANDS } from "./builtinCommands.js";
5
6
  import { SessionCommandService } from "./sessionCommandService.js";
6
7
  import { SessionArchiveStore } from "./sessionArchiveStore.js";
7
8
  import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree } from "./sessionArchiveTree.js";
8
- import { fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
9
+ import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
9
10
  import { computeEditPreview } from "./editPreview.js";
10
11
  import { createPiSessionManagerGateway } from "./piSessionManagerGateway.js";
11
12
  import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
12
13
  import { parsePromptAttachments } from "../../shared/promptAttachments.js";
13
- import { cwdPathsEqual } from "../workingDirectory.js";
14
+ import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
14
15
  import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
15
16
  import { createSubsessionToolDefinitions } from "./spawnSubsessionTool.js";
16
17
  import { buildTranscriptView } from "./subsessionTranscript.js";
18
+ import { planSessionCleanup, summarizeSessionCleanupExecution } from "./sessionCleanup.js";
17
19
  const noopLogger = { info() { } };
18
20
  function noop() {
19
21
  // Intentionally empty default unsubscribe callback.
@@ -50,20 +52,50 @@ function parsePromptStreamingBehavior(value) {
50
52
  function defaultCreateAgentRuntime(createRuntime, options) {
51
53
  if (!(options.sessionManager instanceof SessionManager))
52
54
  throw new Error("Default runtime creation requires an SDK SessionManager");
53
- return createAgentSessionRuntime(createRuntime, { ...options, sessionManager: options.sessionManager });
55
+ const runtimeFactory = createRuntimeWithOneShotSessionOptions(createRuntime, options.initialModel, options.delegationToolsEnabled);
56
+ return createAgentSessionRuntime(runtimeFactory, {
57
+ cwd: options.cwd,
58
+ agentDir: options.agentDir,
59
+ sessionManager: options.sessionManager,
60
+ });
61
+ }
62
+ function createRuntimeWithOneShotSessionOptions(createRuntime, initialModel, delegationToolsEnabled) {
63
+ // These inputs belong only to the session being opened. A later runtime
64
+ // replacement resolves its own model and delegation capability.
65
+ let pendingInitialModel = initialModel;
66
+ let pendingDelegationToolsEnabled = delegationToolsEnabled;
67
+ return async (options) => {
68
+ const model = pendingInitialModel;
69
+ const toolsEnabled = pendingDelegationToolsEnabled;
70
+ pendingInitialModel = undefined;
71
+ pendingDelegationToolsEnabled = undefined;
72
+ return createRuntime({
73
+ ...options,
74
+ ...(model === undefined ? {} : { initialModel: model }),
75
+ ...(toolsEnabled === undefined ? {} : { delegationToolsEnabled: toolsEnabled }),
76
+ });
77
+ };
54
78
  }
55
- function createDefaultRuntimeFactory(authStorage, modelRegistry, spawn, subsessions) {
56
- return async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
79
+ export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn, subsessions) {
80
+ return [
81
+ createPiWebEditToolDefinition(cwd),
82
+ ...(delegationEnabled && spawn !== undefined ? [createSpawnSessionToolDefinition(cwd, { spawn })] : []),
83
+ ...(delegationEnabled && subsessions !== undefined ? createSubsessionToolDefinitions(cwd, subsessions) : []),
84
+ ];
85
+ }
86
+ function createDefaultRuntimeFactory(authStorage, modelRegistry, sessionManagers, spawn, subsessions) {
87
+ return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => {
57
88
  const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry });
58
- const customTools = [
59
- createPiWebEditToolDefinition(cwd),
60
- ...(spawn === undefined ? [] : [createSpawnSessionToolDefinition(cwd, { spawn })]),
61
- ...(subsessions === undefined ? [] : createSubsessionToolDefinitions(cwd, subsessions)),
62
- ];
63
- const options = sessionStartEvent === undefined
64
- ? { services, sessionManager, customTools }
65
- : { services, sessionManager, sessionStartEvent, customTools };
66
- const result = await createAgentSessionFromServices(options);
89
+ const resolvedDelegationToolsEnabled = delegationToolsEnabled
90
+ ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
91
+ const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions);
92
+ const result = await createAgentSessionFromServices({
93
+ services,
94
+ sessionManager,
95
+ customTools,
96
+ ...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
97
+ ...(initialModel === undefined ? {} : { model: initialModel }),
98
+ });
67
99
  return { ...result, services, diagnostics: services.diagnostics };
68
100
  };
69
101
  }
@@ -92,6 +124,7 @@ export class PiSessionService {
92
124
  constructor(events, deps = {}) {
93
125
  this.events = events;
94
126
  this.active = new Map();
127
+ this.pendingSessionOpens = new Map();
95
128
  this.activities = new Map();
96
129
  this.compactionPromptQueues = new Map();
97
130
  this.compactionDrainTimers = new Map();
@@ -100,6 +133,10 @@ export class PiSessionService {
100
133
  this.subsessionParents = new Map();
101
134
  /** Parent session id -> the set of tracked subsession ids it spawned. */
102
135
  this.subsessionChildren = new Map();
136
+ /** Tracked subsession id -> persisted recovery details for the child. */
137
+ this.subsessionLinks = new Map();
138
+ /** Parent id/file identities whose persisted links have already been loaded. */
139
+ this.subsessionHydratedParents = new Set();
103
140
  /**
104
141
  * Tracked subsession id -> whether a completion notification is armed.
105
142
  * Armed when the child starts working; firing on completion disarms it so a
@@ -112,14 +149,15 @@ export class PiSessionService {
112
149
  this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
113
150
  this.spawnTargets = deps.spawnTargets;
114
151
  this.logger = deps.logger ?? noopLogger;
152
+ this.now = deps.now ?? (() => new Date());
115
153
  // Subsessions are a beta capability gated behind their own flag, and they
116
154
  // also require the spawn capability (they share its project-scope resolver).
117
155
  const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
118
- this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRegistry.authStorage, this.modelRegistry, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
156
+ this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRegistry.authStorage, this.modelRegistry, this.sessionManager, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
119
157
  spawn: (input) => this.spawnSubsession(input),
120
- list: (parentSessionId) => this.listSubsessions(parentSessionId),
121
- check: (parentSessionId, sessionId) => this.checkSubsession(parentSessionId, sessionId),
122
- read: (parentSessionId, sessionId, query) => this.readSubsession(parentSessionId, sessionId, query),
158
+ list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
159
+ check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
160
+ read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile),
123
161
  });
124
162
  this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
125
163
  this.workspaceActivity = deps.workspaceActivity;
@@ -133,27 +171,79 @@ export class PiSessionService {
133
171
  this.publishActivity(session, result === "success" ? "compaction complete" : "compaction failed", result === "success" ? "idle" : "error", detail);
134
172
  this.publishStatus(session);
135
173
  },
174
+ reloadSession: (session) => this.reloadSessionRuntime(session),
136
175
  }, { listSessionNames: (cwd) => this.listSessionNames(cwd) });
137
176
  }
138
177
  activeCount() {
139
178
  return this.active.size;
140
179
  }
180
+ async cleanupPreview(request) {
181
+ return previewResponseFromPlan(await this.cleanupPlan(request));
182
+ }
183
+ async cleanup(request) {
184
+ const plan = await this.cleanupPlan(request);
185
+ if (plan.deleteRecords.length > 0 && this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined)
186
+ throw new Error("Archive store does not support deletion");
187
+ const archiveInputs = [];
188
+ const readyArchiveInputs = [];
189
+ const deleteRecords = [];
190
+ const readyDeleteRecords = [];
191
+ const skippedBusySessionIds = new Set(plan.skippedBusySessionIds);
192
+ for (const input of plan.archiveInputs) {
193
+ if (this.activeSessionHasWork(input.sessionId)) {
194
+ skippedBusySessionIds.add(input.sessionId);
195
+ continue;
196
+ }
197
+ await this.closeActive(input.sessionId);
198
+ readyArchiveInputs.push(input);
199
+ }
200
+ await this.archiveStoreArchiveMany(readyArchiveInputs);
201
+ archiveInputs.push(...readyArchiveInputs);
202
+ for (const record of plan.deleteRecords) {
203
+ if (this.activeSessionHasWork(record.sessionId)) {
204
+ skippedBusySessionIds.add(record.sessionId);
205
+ continue;
206
+ }
207
+ await this.closeActive(record.sessionId);
208
+ readyDeleteRecords.push(record);
209
+ }
210
+ await this.ensureArchivedRecordsMoved(readyDeleteRecords);
211
+ const deletedSessionIds = new Set(await this.archiveStoreDeleteArchivedMany(readyDeleteRecords.map((record) => record.sessionId)));
212
+ deleteRecords.push(...readyDeleteRecords.filter((record) => deletedSessionIds.has(record.sessionId)));
213
+ return summarizeSessionCleanupExecution({
214
+ archiveInputs,
215
+ deleteRecords,
216
+ thresholds: plan.thresholds,
217
+ generatedAt: plan.generatedAt,
218
+ skippedBusySessionIds: [...skippedBusySessionIds],
219
+ });
220
+ }
141
221
  async dispose() {
142
222
  clearInterval(this.heartbeat);
143
223
  this.clearCompactionDrainTimers();
224
+ const pendingOpens = this.pendingSessionOpenPromises();
225
+ if (pendingOpens.length > 0)
226
+ await Promise.allSettled(pendingOpens);
144
227
  const activeSessions = Array.from(new Set(this.active.values()));
145
228
  this.active.clear();
229
+ this.pendingSessionOpens.clear();
146
230
  this.activities.clear();
147
231
  this.compactionPromptQueues.clear();
148
232
  this.authLossWarnings.clear();
149
233
  this.subsessionParents.clear();
150
234
  this.subsessionChildren.clear();
235
+ this.subsessionLinks.clear();
236
+ this.subsessionHydratedParents.clear();
151
237
  this.subsessionNotifyArmed.clear();
152
238
  await Promise.all(activeSessions.map(async (active) => {
153
239
  active.unsubscribe();
154
240
  this.workspaceActivity?.removeSession(active.runtime.session.sessionId, active.runtime.session.sessionManager.getCwd());
155
- await active.runtime.session.abort();
156
- await active.runtime.dispose();
241
+ try {
242
+ await active.runtime.session.abort();
243
+ }
244
+ finally {
245
+ await active.runtime.dispose();
246
+ }
157
247
  }));
158
248
  }
159
249
  async list(cwd) {
@@ -171,20 +261,27 @@ export class PiSessionService {
171
261
  .filter(isDefined);
172
262
  return [...unarchivedSessions, ...archivedSessions];
173
263
  }
174
- async start(cwd, parentSession) {
175
- const active = await this.create(this.sessionManager.create(cwd, parentSession === undefined ? undefined : { parentSession }), cwd);
264
+ async start(cwd, options = {}) {
265
+ return this.startSession(cwd, options);
266
+ }
267
+ async startSession(cwd, options) {
268
+ const active = await this.create(this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession }), cwd, {
269
+ ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
270
+ ...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
271
+ });
176
272
  const { session } = active.runtime;
177
273
  const created = {
178
274
  id: session.sessionId,
179
275
  path: session.sessionFile ?? "",
180
276
  cwd,
277
+ persisted: sessionFileExists(session.sessionFile),
181
278
  created: new Date().toISOString(),
182
279
  modified: new Date().toISOString(),
183
280
  messageCount: session.messages.length,
184
281
  firstMessage: "",
185
282
  // Include the parent so listeners can nest the new session in the tree
186
283
  // immediately, instead of showing it flat until the next reload.
187
- ...(parentSession === undefined ? {} : { parentSessionPath: parentSession }),
284
+ ...(options.parentSession === undefined ? {} : { parentSessionPath: options.parentSession }),
188
285
  };
189
286
  // Broadcast so other clients (and the spawning agent's UI) can add the new
190
287
  // session to their list without a manual reload.
@@ -202,7 +299,7 @@ export class PiSessionService {
202
299
  const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
203
300
  if (!decision.allowed)
204
301
  throw spawnTargetError(decision);
205
- const created = await this.start(decision.cwd);
302
+ const created = await this.start(decision.cwd, input.model === undefined ? {} : { initialModel: input.model });
206
303
  await this.prompt(created.id, input.prompt);
207
304
  this.logger.info({ spawningCwd: input.spawningCwd, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_session started a new session");
208
305
  return { sessionId: created.id, cwd: decision.cwd };
@@ -219,69 +316,247 @@ export class PiSessionService {
219
316
  const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
220
317
  if (!decision.allowed)
221
318
  throw spawnTargetError(decision);
222
- const created = await this.start(decision.cwd, input.parentSessionFile);
223
- this.registerSubsession(input.parentSessionId, created.id);
319
+ const created = await this.startSession(decision.cwd, {
320
+ ...(input.parentSessionFile === undefined ? {} : { parentSession: input.parentSessionFile }),
321
+ ...(input.model === undefined ? {} : { initialModel: input.model }),
322
+ creationProvenance: "tracked-subsession",
323
+ });
324
+ const parentSessionFile = nonEmptyString(input.parentSessionFile);
325
+ const link = {
326
+ parentSessionId: input.parentSessionId,
327
+ childSessionId: created.id,
328
+ ...(created.path === "" ? {} : { childSessionFile: created.path }),
329
+ ...(parentSessionFile === undefined ? {} : { parentSessionFile }),
330
+ cwd: decision.cwd,
331
+ };
332
+ this.registerVerifiedSubsession(link);
333
+ this.persistSubsessionLink(link);
334
+ this.persistSubsessionChildMarker(input.parentSessionId, created.id);
224
335
  await this.prompt(created.id, input.prompt);
225
336
  this.logger.info({ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_subsession started a tracked child session");
226
337
  return { sessionId: created.id, cwd: decision.cwd };
227
338
  }
228
339
  /** Summaries of the tracked subsessions spawned by `parentSessionId`. */
229
- async listSubsessions(parentSessionId) {
340
+ async listSubsessions(parentSessionId, parentSessionFile) {
341
+ const parentFile = nonEmptyString(parentSessionFile);
342
+ await this.hydrateSubsessionsForParent(parentSessionId, parentFile);
230
343
  const childIds = this.subsessionChildren.get(parentSessionId);
231
344
  if (childIds === undefined)
232
345
  return [];
233
- return Promise.all([...childIds].map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) })));
346
+ const authorizedChildIds = [...childIds].filter((childId) => this.subsessionLinkBelongsToParent(parentSessionId, parentFile, childId));
347
+ return Promise.all(authorizedChildIds.map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) })));
234
348
  }
235
349
  /** Status and final result of a subsession, scoped to the caller's children. */
236
- async checkSubsession(parentSessionId, sessionId) {
237
- const session = await this.openSubsession(parentSessionId, sessionId);
350
+ async checkSubsession(parentSessionId, sessionId, parentSessionFile) {
351
+ const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
238
352
  const messages = historyMessages(session);
239
353
  return {
240
354
  sessionId,
241
355
  cwd: session.sessionManager.getCwd(),
242
- status: await this.subsessionStatus(session),
356
+ status: this.subsessionStatus(session),
243
357
  finalText: finalAssistantText(messages),
244
358
  messageCount: messages.length,
245
359
  };
246
360
  }
247
361
  /** Filtered, paginated transcript of a subsession, scoped to the caller's children. */
248
- async readSubsession(parentSessionId, sessionId, query) {
249
- const session = await this.openSubsession(parentSessionId, sessionId);
362
+ async readSubsession(parentSessionId, sessionId, query, parentSessionFile) {
363
+ const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
250
364
  const view = buildTranscriptView(historyMessages(session), query);
251
365
  return {
252
366
  sessionId,
253
367
  cwd: session.sessionManager.getCwd(),
254
- status: await this.subsessionStatus(session),
368
+ status: this.subsessionStatus(session),
255
369
  ...view,
256
370
  };
257
371
  }
258
372
  /** Open a session after verifying it is one of the caller's tracked children. */
259
- async openSubsession(parentSessionId, sessionId) {
260
- if (this.subsessionParents.get(sessionId) !== parentSessionId) {
373
+ async openSubsession(parentSessionId, sessionId, parentSessionFile) {
374
+ const parentFile = nonEmptyString(parentSessionFile);
375
+ await this.hydrateSubsessionsForParent(parentSessionId, parentFile);
376
+ if (this.subsessionParents.get(sessionId) !== parentSessionId || !this.subsessionLinkBelongsToParent(parentSessionId, parentFile, sessionId)) {
261
377
  throw new Error(`Session ${sessionId} is not one of your subsessions`);
262
378
  }
263
- return this.getOrOpen(sessionId);
379
+ return this.getOrOpenTrackedSubsession(sessionId);
380
+ }
381
+ subsessionLinkBelongsToParent(parentSessionId, parentSessionFile, childSessionId) {
382
+ const link = this.subsessionLinks.get(childSessionId);
383
+ if (link?.parentSessionId !== parentSessionId)
384
+ return false;
385
+ return parentSessionFile === undefined || trackedLinkParentFileMatches(link, parentSessionFile);
386
+ }
387
+ activeChildForSubsessionLink(link) {
388
+ const active = this.active.get(link.childSessionId);
389
+ if (active === undefined)
390
+ return undefined;
391
+ return activeSessionFileMatches(active, link.childSessionFile) ? active : undefined;
392
+ }
393
+ activeParentForSubsessionLink(link) {
394
+ const active = this.active.get(link.parentSessionId);
395
+ if (active === undefined)
396
+ return undefined;
397
+ return activeSessionFileMatches(active, link.parentSessionFile) ? active : undefined;
264
398
  }
265
- registerSubsession(parentSessionId, childSessionId) {
399
+ subsessionLinkForActiveChild(session) {
400
+ const childId = session.sessionId;
401
+ const parentId = this.subsessionParents.get(childId);
402
+ const link = this.subsessionLinks.get(childId);
403
+ if (parentId === undefined || link?.parentSessionId !== parentId)
404
+ return undefined;
405
+ return sessionFileMatches(session, link.childSessionFile) ? link : undefined;
406
+ }
407
+ registerVerifiedSubsession(link) {
408
+ const { childSessionId, parentSessionId } = link;
409
+ const previousParentId = this.subsessionParents.get(childSessionId);
410
+ if (previousParentId !== undefined && previousParentId !== parentSessionId) {
411
+ const previousChildren = this.subsessionChildren.get(previousParentId);
412
+ previousChildren?.delete(childSessionId);
413
+ if (previousChildren?.size === 0)
414
+ this.subsessionChildren.delete(previousParentId);
415
+ }
266
416
  this.subsessionParents.set(childSessionId, parentSessionId);
267
417
  const children = this.subsessionChildren.get(parentSessionId) ?? new Set();
268
418
  children.add(childSessionId);
269
419
  this.subsessionChildren.set(parentSessionId, children);
270
- this.subsessionNotifyArmed.set(childSessionId, false);
420
+ this.subsessionLinks.set(childSessionId, link);
421
+ if (!this.subsessionNotifyArmed.has(childSessionId))
422
+ this.subsessionNotifyArmed.set(childSessionId, false);
423
+ }
424
+ unregisterSubsession(childSessionId) {
425
+ const parentSessionId = this.subsessionParents.get(childSessionId);
426
+ this.subsessionParents.delete(childSessionId);
427
+ this.subsessionLinks.delete(childSessionId);
428
+ this.subsessionNotifyArmed.delete(childSessionId);
429
+ if (parentSessionId === undefined)
430
+ return;
431
+ const children = this.subsessionChildren.get(parentSessionId);
432
+ children?.delete(childSessionId);
433
+ if (children?.size === 0)
434
+ this.subsessionChildren.delete(parentSessionId);
435
+ }
436
+ persistSubsessionLink(link) {
437
+ const parent = this.activeParentForSubsessionLink(link)?.runtime.session;
438
+ if (parent === undefined)
439
+ return;
440
+ if (parent.sessionManager.appendCustomEntry === undefined)
441
+ return;
442
+ try {
443
+ parent.sessionManager.appendCustomEntry(SUBSESSION_LINK_CUSTOM_TYPE, persistedParentSubsessionLinkData(link));
444
+ }
445
+ catch (error) {
446
+ this.logger.info({ parentSessionId: link.parentSessionId, sessionId: link.childSessionId, error: error instanceof Error ? error.message : String(error) }, "failed to persist subsession link");
447
+ }
448
+ }
449
+ persistSubsessionChildMarker(parentSessionId, childSessionId) {
450
+ const child = this.active.get(childSessionId)?.runtime.session;
451
+ if (child === undefined)
452
+ return;
453
+ if (child.sessionManager.appendCustomEntry === undefined)
454
+ return;
455
+ try {
456
+ child.sessionManager.appendCustomEntry(SUBSESSION_CHILD_LINK_CUSTOM_TYPE, persistedChildSubsessionLinkData(parentSessionId, childSessionId));
457
+ }
458
+ catch (error) {
459
+ this.logger.info({ parentSessionId, sessionId: childSessionId, error: error instanceof Error ? error.message : String(error) }, "failed to persist subsession child marker");
460
+ }
461
+ }
462
+ async hydrateSubsessionsForParent(parentSessionId, parentSessionFile) {
463
+ const hydrationKey = subsessionHydratedParentKey(parentSessionId, parentSessionFile);
464
+ if (this.subsessionHydratedParents.has(hydrationKey))
465
+ return;
466
+ const activeParent = this.active.get(parentSessionId);
467
+ if (activeParent !== undefined && (parentSessionFile === undefined || activeSessionFileMatches(activeParent, parentSessionFile))) {
468
+ const activeParentFile = nonEmptyString(activeParent.runtime.session.sessionFile);
469
+ await this.registerPersistedSubsessionLinks(parentSessionId, activeParent.runtime.session.sessionManager, activeParentFile);
470
+ this.subsessionHydratedParents.add(hydrationKey);
471
+ return;
472
+ }
473
+ if (parentSessionFile === undefined)
474
+ return;
475
+ if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId) {
476
+ this.subsessionHydratedParents.add(hydrationKey);
477
+ return;
478
+ }
479
+ let parentManager;
480
+ try {
481
+ parentManager = this.sessionManager.open(parentSessionFile);
482
+ }
483
+ catch {
484
+ this.subsessionHydratedParents.add(hydrationKey);
485
+ return;
486
+ }
487
+ await this.registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile);
488
+ this.subsessionHydratedParents.add(hydrationKey);
489
+ }
490
+ async registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile) {
491
+ // Parent custom links are the authoritative recovery record: verify the
492
+ // exact live child file/header before tracking.
493
+ const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
494
+ for (const entry of entries) {
495
+ const link = parsePersistedParentSubsessionLink(entry);
496
+ if (link === undefined)
497
+ continue;
498
+ const verified = await this.verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link);
499
+ if (verified === undefined)
500
+ continue;
501
+ this.registerVerifiedSubsession(verified);
502
+ }
503
+ }
504
+ async verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link) {
505
+ if (parentSessionFile === undefined)
506
+ return undefined;
507
+ if (link.spawnedBySessionId !== parentSessionId)
508
+ return undefined;
509
+ if (!(await this.parentLinkHasValidChildTarget(parentSessionFile, link)))
510
+ return undefined;
511
+ return trackedSubsessionLinkFromParentLink(parentSessionId, link, parentSessionFile);
512
+ }
513
+ async parentLinkHasValidChildTarget(parentSessionFile, link) {
514
+ return link.spawnedSessionFile !== undefined
515
+ && await sessionFileHeaderMatches(link.spawnedSessionFile, { sessionId: link.spawnedSessionId, parentSessionFile });
516
+ }
517
+ async recoverSubsessionTrackingForOpenedSession(session) {
518
+ const link = await this.verifiedSubsessionLinkFromOpenedChild(session);
519
+ if (link === undefined)
520
+ return;
521
+ this.registerVerifiedSubsession(link);
522
+ }
523
+ verifiedSubsessionLinkFromOpenedChild(session) {
524
+ return verifiedTrackedSubsessionLink(this.sessionManager, {
525
+ sessionId: session.sessionId,
526
+ sessionFile: session.sessionFile,
527
+ sessionManager: session.sessionManager,
528
+ cwd: session.sessionManager.getCwd(),
529
+ });
530
+ }
531
+ async getOrOpenTrackedSubsession(sessionId) {
532
+ const link = this.subsessionLinks.get(sessionId);
533
+ if (link === undefined)
534
+ throw new Error("Session not found");
535
+ const active = this.activeChildForSubsessionLink(link);
536
+ if (active !== undefined)
537
+ return active.runtime.session;
538
+ if (link.childSessionFile !== undefined) {
539
+ if (!(await sessionFileHeaderMatches(link.childSessionFile, { sessionId, parentSessionFile: link.parentSessionFile })))
540
+ throw new Error("Session not found");
541
+ const sessionManager = this.sessionManager.open(link.childSessionFile);
542
+ return (await this.create(sessionManager, link.cwd ?? sessionManager.getCwd())).runtime.session;
543
+ }
544
+ throw new Error("Session not found");
271
545
  }
272
546
  async subsessionSummaryFields(childSessionId) {
273
- const active = this.active.get(childSessionId);
547
+ const link = this.subsessionLinks.get(childSessionId);
548
+ const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link);
274
549
  if (active !== undefined) {
275
- return { cwd: active.runtime.cwd, status: await this.subsessionStatus(active.runtime.session) };
550
+ return { cwd: active.runtime.cwd, status: this.subsessionStatus(active.runtime.session) };
276
551
  }
277
- const archived = await this.archiveStore.get(childSessionId);
278
- if (archived !== undefined)
279
- return { cwd: archived.cwd, status: "archived" };
552
+ if (link?.childSessionFile !== undefined && (await sessionFileHeaderMatches(link.childSessionFile, { sessionId: childSessionId, parentSessionFile: link.parentSessionFile }))) {
553
+ return { cwd: link.cwd ?? "", status: "idle" };
554
+ }
555
+ if (link?.cwd !== undefined)
556
+ return { cwd: link.cwd, status: "unknown" };
280
557
  return { cwd: "", status: "unknown" };
281
558
  }
282
- async subsessionStatus(session) {
283
- if (await this.archiveStore.isArchived(session.sessionId))
284
- return "archived";
559
+ subsessionStatus(session) {
285
560
  if (this.hasActiveWork(session))
286
561
  return "working";
287
562
  if (this.activities.get(session.sessionId)?.phase === "error")
@@ -295,10 +570,10 @@ export class PiSessionService {
295
570
  * parent is busy and delivers immediately when it is idle).
296
571
  */
297
572
  updateSubsessionTracking(session) {
298
- const childId = session.sessionId;
299
- const parentId = this.subsessionParents.get(childId);
300
- if (parentId === undefined)
573
+ const link = this.subsessionLinkForActiveChild(session);
574
+ if (link === undefined)
301
575
  return;
576
+ const childId = link.childSessionId;
302
577
  if (this.hasActiveWork(session)) {
303
578
  this.subsessionNotifyArmed.set(childId, true);
304
579
  return;
@@ -309,8 +584,24 @@ export class PiSessionService {
309
584
  const status = this.activities.get(childId)?.phase === "error" ? "error" : "idle";
310
585
  const finalText = finalAssistantText(historyMessages(session));
311
586
  const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText);
312
- const text = `Subsession ${childId} stopped working (status: ${status}). Latest output:\n\n${preview}\n\nUse check_subsession with sessionId "${childId}" for its status and latest output, or read_subsession to look through its full transcript.`;
313
- void this.notifyParentOfSubsession(parentId, childId, text);
587
+ const text = `Subsession ${childId} stopped working (status: ${status}). Latest output:\n\n${preview}\n\nStatus and latest output are available through check_subsession with sessionId "${childId}"; its full transcript is available through read_subsession.`;
588
+ void this.notifyParentOfSubsession(link.parentSessionId, childId, text);
589
+ }
590
+ async getOrOpenParentForSubsession(parentSessionId, childSessionId) {
591
+ const link = this.subsessionLinks.get(childSessionId);
592
+ if (link?.parentSessionId !== parentSessionId)
593
+ throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
594
+ const active = this.activeParentForSubsessionLink(link);
595
+ if (active !== undefined)
596
+ return active.runtime.session;
597
+ const parentSessionFile = link.parentSessionFile;
598
+ if (parentSessionFile === undefined)
599
+ throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
600
+ if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId) {
601
+ throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
602
+ }
603
+ const sessionManager = this.sessionManager.open(parentSessionFile);
604
+ return (await this.create(sessionManager, sessionManager.getCwd())).runtime.session;
314
605
  }
315
606
  /**
316
607
  * Deliver a subsession-completion notice to the parent as a system-authored
@@ -321,7 +612,7 @@ export class PiSessionService {
321
612
  */
322
613
  async notifyParentOfSubsession(parentId, childId, text) {
323
614
  try {
324
- const session = await this.getOrOpen(parentId);
615
+ const session = await this.getOrOpenParentForSubsession(parentId, childId);
325
616
  await session.sendCustomMessage({ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: text, display: true, details: { sessionId: childId } }, { triggerTurn: true, deliverAs: "followUp" });
326
617
  this.publishStatus(session);
327
618
  }
@@ -459,7 +750,7 @@ export class PiSessionService {
459
750
  this.publishStatus(session);
460
751
  }
461
752
  async saveAttachments(ref, attachments, folder) {
462
- const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false });
753
+ const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false, allowFileAttachments: true });
463
754
  if (parsed.length === 0)
464
755
  return [];
465
756
  await this.assertWritable(ref);
@@ -511,6 +802,23 @@ export class PiSessionService {
511
802
  const active = await this.getActive(ref);
512
803
  return this.commandService.respond(active.runtime.session.sessionId, requestId, value);
513
804
  }
805
+ async reloadSessionRuntime(session) {
806
+ if (this.hasActiveWork(session))
807
+ throw new Error("Stop current session activity before reloading");
808
+ this.publishActivity(session, "reloading resources", "active");
809
+ try {
810
+ await session.reload();
811
+ this.publishActivity(session, "resources reloaded", "idle");
812
+ this.publishStatus(session);
813
+ }
814
+ catch (error) {
815
+ const message = error instanceof Error ? error.message : String(error);
816
+ this.publishActivity(session, "reload failed", "error", message);
817
+ this.events.publish(session.sessionId, { type: "session.error", message });
818
+ this.publishStatus(session);
819
+ throw error;
820
+ }
821
+ }
514
822
  async archive(ref) {
515
823
  const session = await this.getOrOpen(ref);
516
824
  if (this.hasActiveWork(session))
@@ -519,6 +827,69 @@ export class PiSessionService {
519
827
  await this.closeActive(session.sessionId);
520
828
  await this.archiveStore.archive(archiveInput);
521
829
  }
830
+ async archiveMany(refs) {
831
+ const uniqueRefs = uniqueBulkSessionRefs(refs);
832
+ const [archivedRecords, sessionContext] = await Promise.all([
833
+ this.archiveStore.list(),
834
+ this.bulkSessionLookupContext(uniqueRefs),
835
+ ]);
836
+ const failures = [];
837
+ const alreadyArchivedSessionIds = [];
838
+ const planItems = [];
839
+ for (const ref of uniqueRefs) {
840
+ const archived = findArchivedRecordForBulkRef(archivedRecords, ref);
841
+ if (archived !== undefined) {
842
+ alreadyArchivedSessionIds.push(archived.sessionId);
843
+ continue;
844
+ }
845
+ const active = this.activeForLookup(bulkRefToLookup(ref));
846
+ const listed = findListedSessionForBulkRef(sessionContext, ref);
847
+ const resolvedSessionId = active?.runtime.session.sessionId ?? listed?.id ?? ref.id;
848
+ if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
849
+ failures.push({ sessionId: resolvedSessionId, error: "Stop current session activity before archiving" });
850
+ continue;
851
+ }
852
+ try {
853
+ if (listed !== undefined) {
854
+ planItems.push({ input: archiveInputFromListEntry(listed) });
855
+ }
856
+ else if (active !== undefined) {
857
+ planItems.push({ input: archiveInputFromActiveSession(active.runtime.session) });
858
+ }
859
+ else {
860
+ failures.push({ sessionId: ref.id, error: "Session not found" });
861
+ }
862
+ }
863
+ catch (error) {
864
+ failures.push({ sessionId: resolvedSessionId, error: errorMessage(error) });
865
+ }
866
+ }
867
+ const readyInputs = [];
868
+ for (const item of planItems) {
869
+ try {
870
+ await this.closeActive(item.input.sessionId);
871
+ readyInputs.push(item.input);
872
+ }
873
+ catch (error) {
874
+ failures.push({ sessionId: item.input.sessionId, error: errorMessage(error) });
875
+ }
876
+ }
877
+ const archivedSessionIds = [...alreadyArchivedSessionIds];
878
+ try {
879
+ const archived = await this.archiveStoreArchiveMany(readyInputs);
880
+ archivedSessionIds.push(...archived.map((record) => record.sessionId));
881
+ }
882
+ catch (error) {
883
+ for (const input of readyInputs)
884
+ failures.push({ sessionId: input.sessionId, error: errorMessage(error) });
885
+ }
886
+ return {
887
+ archived: true,
888
+ archivedSessionIds: uniqueStrings(archivedSessionIds),
889
+ failures,
890
+ generatedAt: new Date().toISOString(),
891
+ };
892
+ }
522
893
  async archiveTree(ref) {
523
894
  const session = await this.getOrOpen(ref);
524
895
  const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd());
@@ -530,8 +901,7 @@ export class PiSessionService {
530
901
  const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target));
531
902
  for (const input of archiveInputs)
532
903
  await this.closeActive(input.sessionId);
533
- for (const input of archiveInputs)
534
- await this.archiveStore.archive(input);
904
+ await this.archiveStoreArchiveMany(archiveInputs);
535
905
  return {
536
906
  archived: true,
537
907
  sessionIds: archiveInputs.map((input) => input.sessionId),
@@ -557,6 +927,57 @@ export class PiSessionService {
557
927
  await this.ensureArchivedRecordMoved(record);
558
928
  await this.archiveStore.deleteArchived(record.sessionId);
559
929
  }
930
+ async deleteArchivedMany(refs) {
931
+ if (this.archiveStore.deleteArchived === undefined && this.archiveStore.deleteArchivedMany === undefined)
932
+ throw new Error("Archive store does not support deletion");
933
+ const uniqueRefs = uniqueBulkSessionRefs(refs);
934
+ const archivedRecords = await this.archiveStore.list();
935
+ const failures = [];
936
+ const planItems = [];
937
+ for (const ref of uniqueRefs) {
938
+ const record = findArchivedRecordForBulkRef(archivedRecords, ref);
939
+ if (record === undefined) {
940
+ failures.push({ sessionId: ref.id, error: "Archived session not found" });
941
+ continue;
942
+ }
943
+ const active = this.activeForLookup({ id: record.sessionId, cwd: record.cwd });
944
+ if (active !== undefined && this.hasActiveWork(active.runtime.session)) {
945
+ failures.push({ sessionId: record.sessionId, error: "Stop current session activity before deleting archived session" });
946
+ continue;
947
+ }
948
+ planItems.push({ record });
949
+ }
950
+ const readyRecords = [];
951
+ for (const item of planItems) {
952
+ try {
953
+ await this.closeActive(item.record.sessionId);
954
+ readyRecords.push(item.record);
955
+ }
956
+ catch (error) {
957
+ failures.push({ sessionId: item.record.sessionId, error: errorMessage(error) });
958
+ }
959
+ }
960
+ const moveFailures = await this.moveLegacyArchivedRecordsForDelete(readyRecords);
961
+ failures.push(...moveFailures);
962
+ const moveFailureIds = new Set(moveFailures.map((failure) => failure.sessionId));
963
+ const deleteIds = readyRecords
964
+ .map((record) => record.sessionId)
965
+ .filter((sessionId) => !moveFailureIds.has(sessionId));
966
+ let deletedSessionIds = [];
967
+ try {
968
+ deletedSessionIds = await this.archiveStoreDeleteArchivedMany(deleteIds);
969
+ }
970
+ catch (error) {
971
+ for (const sessionId of deleteIds)
972
+ failures.push({ sessionId, error: errorMessage(error) });
973
+ }
974
+ return {
975
+ deleted: true,
976
+ deletedSessionIds,
977
+ failures,
978
+ generatedAt: new Date().toISOString(),
979
+ };
980
+ }
560
981
  async reload(ref) {
561
982
  await this.assertWritable(ref);
562
983
  const session = await this.getOrOpen(ref);
@@ -572,6 +993,8 @@ export class PiSessionService {
572
993
  if (sessionFile === undefined || sessionFile === "")
573
994
  throw new Error("Session is not persisted");
574
995
  await clearParentSession(sessionFile);
996
+ clearParentSessionHeader(session.sessionManager);
997
+ this.unregisterSubsession(session.sessionId);
575
998
  }
576
999
  async abort(ref) {
577
1000
  const active = this.activeForLookup(ref);
@@ -592,6 +1015,96 @@ export class PiSessionService {
592
1015
  // Best-effort shutdown; callers that need errors await closeActive directly.
593
1016
  });
594
1017
  }
1018
+ async bulkSessionLookupContext(refs) {
1019
+ const cwdSet = new Set();
1020
+ let needsAllSessions = false;
1021
+ for (const ref of refs) {
1022
+ if (ref.cwd === undefined)
1023
+ needsAllSessions = true;
1024
+ else
1025
+ cwdSet.add(ref.cwd);
1026
+ }
1027
+ const [sessionsByCwd, allSessions] = await Promise.all([
1028
+ this.listSessionsByCwd([...cwdSet]),
1029
+ needsAllSessions ? this.sessionManager.listAll?.() ?? Promise.resolve([]) : Promise.resolve(undefined),
1030
+ ]);
1031
+ return allSessions === undefined ? { sessionsByCwd } : { sessionsByCwd, allSessions };
1032
+ }
1033
+ async listSessionsByCwd(cwds) {
1034
+ const uniqueCwds = uniqueStrings(cwds);
1035
+ const entries = await Promise.all(uniqueCwds.map(async (cwd) => [cwd, await this.sessionManager.list(cwd)]));
1036
+ return new Map(entries);
1037
+ }
1038
+ async archiveStoreArchiveMany(inputs) {
1039
+ if (inputs.length === 0)
1040
+ return [];
1041
+ if (this.archiveStore.archiveMany !== undefined)
1042
+ return this.archiveStore.archiveMany(inputs);
1043
+ const records = [];
1044
+ for (const input of inputs)
1045
+ records.push(await this.archiveStore.archive(input));
1046
+ return records;
1047
+ }
1048
+ async archiveStoreDeleteArchivedMany(sessionIds) {
1049
+ if (sessionIds.length === 0)
1050
+ return [];
1051
+ if (this.archiveStore.deleteArchivedMany !== undefined)
1052
+ return this.archiveStore.deleteArchivedMany(sessionIds);
1053
+ if (this.archiveStore.deleteArchived === undefined)
1054
+ throw new Error("Archive store does not support deletion");
1055
+ for (const sessionId of sessionIds)
1056
+ await this.archiveStore.deleteArchived(sessionId);
1057
+ return [...sessionIds];
1058
+ }
1059
+ async moveLegacyArchivedRecordsForDelete(records) {
1060
+ const legacyRecords = records.filter((record) => record.archivePath === undefined);
1061
+ if (legacyRecords.length === 0)
1062
+ return [];
1063
+ let sessionsByCwd;
1064
+ try {
1065
+ sessionsByCwd = await this.listSessionsByCwd(legacyRecords.map((record) => record.cwd));
1066
+ }
1067
+ catch (error) {
1068
+ return legacyRecords.map((record) => ({ sessionId: record.sessionId, error: errorMessage(error) }));
1069
+ }
1070
+ const moveInputs = legacyRecords
1071
+ .map((record) => findSessionByIdOrPrefix(sessionsByCwd.get(record.cwd) ?? [], record.sessionId))
1072
+ .filter(isDefined)
1073
+ .map(archiveInputFromListEntry);
1074
+ if (moveInputs.length === 0)
1075
+ return [];
1076
+ try {
1077
+ await this.archiveStoreArchiveMany(moveInputs);
1078
+ return [];
1079
+ }
1080
+ catch (error) {
1081
+ const failedIds = new Set(moveInputs.map((input) => input.sessionId));
1082
+ return legacyRecords
1083
+ .filter((record) => failedIds.has(record.sessionId))
1084
+ .map((record) => ({ sessionId: record.sessionId, error: errorMessage(error) }));
1085
+ }
1086
+ }
1087
+ async cleanupPlan(request) {
1088
+ const [sessions, archivedRecords] = await Promise.all([this.sessionManager.listAll?.() ?? [], this.archiveStore.list()]);
1089
+ return planSessionCleanup({
1090
+ sessions,
1091
+ archivedRecords,
1092
+ activeSessions: this.cleanupActiveSessionStatuses(),
1093
+ thresholds: request.thresholds,
1094
+ ...(request.projectCwds === undefined ? {} : { projectCwds: request.projectCwds }),
1095
+ now: this.now(),
1096
+ });
1097
+ }
1098
+ cleanupActiveSessionStatuses() {
1099
+ return [...new Set(this.active.values())].map((active) => ({
1100
+ sessionId: active.runtime.session.sessionId,
1101
+ hasActiveWork: this.hasActiveWork(active.runtime.session),
1102
+ }));
1103
+ }
1104
+ activeSessionHasWork(sessionId) {
1105
+ const active = this.active.get(sessionId);
1106
+ return active !== undefined && this.hasActiveWork(active.runtime.session);
1107
+ }
595
1108
  reconcilableSessionIds(cwd, listedSessionIds, archivedById) {
596
1109
  const sessionIds = new Set(listedSessionIds);
597
1110
  for (const active of new Set(this.active.values())) {
@@ -615,7 +1128,19 @@ export class PiSessionService {
615
1128
  const session = (await this.sessionManager.list(record.cwd)).find((candidate) => candidate.id === record.sessionId);
616
1129
  if (session === undefined)
617
1130
  return record;
618
- return this.archiveStore.archive(archiveInputFromListEntry(session));
1131
+ const [moved] = await this.archiveStoreArchiveMany([archiveInputFromListEntry(session)]);
1132
+ return moved ?? record;
1133
+ }
1134
+ async ensureArchivedRecordsMoved(records) {
1135
+ const legacyRecords = records.filter((record) => record.archivePath === undefined);
1136
+ if (legacyRecords.length === 0)
1137
+ return;
1138
+ const sessionsByCwd = await this.listSessionsByCwd(legacyRecords.map((record) => record.cwd));
1139
+ const moveInputs = legacyRecords
1140
+ .map((record) => sessionsByCwd.get(record.cwd)?.find((candidate) => candidate.id === record.sessionId))
1141
+ .filter(isDefined)
1142
+ .map(archiveInputFromListEntry);
1143
+ await this.archiveStoreArchiveMany(moveInputs);
619
1144
  }
620
1145
  async archiveInputForSession(session) {
621
1146
  const cwd = session.sessionManager.getCwd();
@@ -678,6 +1203,9 @@ export class PiSessionService {
678
1203
  return [...names];
679
1204
  }
680
1205
  async closeActive(sessionId) {
1206
+ const pendingOpens = this.pendingSessionOpenPromises(sessionId);
1207
+ if (pendingOpens.length > 0)
1208
+ await Promise.allSettled(pendingOpens);
681
1209
  const active = this.active.get(sessionId);
682
1210
  if (!active)
683
1211
  return;
@@ -689,7 +1217,8 @@ export class PiSessionService {
689
1217
  // Disarm subsession notification before teardown so the abort below cannot
690
1218
  // emit a "stopped working" event that notifies the parent (e.g. on archive).
691
1219
  // The parent/children link is kept so the parent can still see the child.
692
- this.subsessionNotifyArmed.delete(sessionId);
1220
+ if (this.subsessionLinkForActiveChild(active.runtime.session) !== undefined)
1221
+ this.subsessionNotifyArmed.delete(sessionId);
693
1222
  clearSessionQueue(active.runtime.session);
694
1223
  active.unsubscribe();
695
1224
  try {
@@ -711,14 +1240,40 @@ export class PiSessionService {
711
1240
  if (active !== undefined)
712
1241
  return active;
713
1242
  const archived = await this.getArchived(ref);
714
- if (archived?.archivePath !== undefined)
715
- return this.create(this.sessionManager.open(archived.archivePath), archived.cwd);
1243
+ if (archived?.archivePath !== undefined) {
1244
+ const { archivePath } = archived;
1245
+ return this.openExistingSession(archived.sessionId, archived.cwd, () => this.sessionManager.open(archivePath));
1246
+ }
716
1247
  const match = isPiSessionRef(ref)
717
1248
  ? (await this.sessionManager.list(ref.cwd)).find((s) => s.id === ref.id || s.id.startsWith(ref.id))
718
1249
  : (await this.sessionManager.listAll?.() ?? []).find((s) => s.id === ref || s.id.startsWith(ref));
719
1250
  if (!match)
720
1251
  throw new Error("Session not found");
721
- return this.create(this.sessionManager.open(match.path), match.cwd);
1252
+ return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path));
1253
+ }
1254
+ openExistingSession(sessionId, cwd, openSessionManager) {
1255
+ const active = this.activeForLookup({ id: sessionId, cwd });
1256
+ if (active !== undefined)
1257
+ return Promise.resolve(active);
1258
+ const key = JSON.stringify([canonicalizeStoredCwd(cwd), sessionId]);
1259
+ const existing = this.pendingSessionOpens.get(key);
1260
+ if (existing !== undefined)
1261
+ return existing.promise;
1262
+ const pending = {
1263
+ sessionId,
1264
+ promise: this.create(openSessionManager(), cwd),
1265
+ };
1266
+ pending.promise = pending.promise.finally(() => {
1267
+ if (this.pendingSessionOpens.get(key) === pending)
1268
+ this.pendingSessionOpens.delete(key);
1269
+ });
1270
+ this.pendingSessionOpens.set(key, pending);
1271
+ return pending.promise;
1272
+ }
1273
+ pendingSessionOpenPromises(sessionId) {
1274
+ return [...this.pendingSessionOpens.values()]
1275
+ .filter((pending) => sessionId === undefined || pending.sessionId === sessionId)
1276
+ .map((pending) => pending.promise);
722
1277
  }
723
1278
  async getArchived(ref) {
724
1279
  const archived = await this.archiveStore.get(sessionIdFromLookup(ref));
@@ -739,18 +1294,53 @@ export class PiSessionService {
739
1294
  }
740
1295
  return undefined;
741
1296
  }
742
- async create(sessionManager, cwd) {
743
- const runtime = await this.createAgentRuntime(this.createRuntime, { cwd, agentDir: this.agentDir, sessionManager });
744
- await this.bindSessionExtensions(runtime.session);
1297
+ async create(sessionManager, cwd, options = {}) {
1298
+ const delegationToolsEnabled = options.creationProvenance !== "tracked-subsession"
1299
+ && await sessionAllowsDelegationTools(sessionManager, this.sessionManager);
1300
+ const runtime = await this.createAgentRuntime(this.createRuntime, {
1301
+ cwd,
1302
+ agentDir: this.agentDir,
1303
+ sessionManager,
1304
+ delegationToolsEnabled,
1305
+ ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
1306
+ });
745
1307
  const active = { runtime, unsubscribe: noop };
746
- this.bindRuntime(active);
747
- runtime.setRebindSession(async (session) => {
748
- await this.bindSessionExtensions(session);
1308
+ try {
1309
+ await this.bindSessionExtensions(runtime.session);
749
1310
  this.bindRuntime(active);
750
- });
751
- this.active.set(runtime.session.sessionId, active);
752
- this.publishStatus(runtime.session);
753
- return active;
1311
+ runtime.setRebindSession(async (session) => {
1312
+ await this.bindSessionExtensions(session);
1313
+ this.bindRuntime(active);
1314
+ await this.recoverSubsessionTrackingForOpenedSession(session);
1315
+ });
1316
+ this.active.set(runtime.session.sessionId, active);
1317
+ await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
1318
+ this.publishStatus(runtime.session);
1319
+ return active;
1320
+ }
1321
+ catch (error) {
1322
+ active.unsubscribe();
1323
+ let removedActive = false;
1324
+ for (const [sessionId, candidate] of this.active.entries()) {
1325
+ if (candidate !== active)
1326
+ continue;
1327
+ this.active.delete(sessionId);
1328
+ this.activities.delete(sessionId);
1329
+ this.clearAuthLossWarningsForSession(sessionId);
1330
+ this.clearCompactionPromptQueue(sessionId);
1331
+ removedActive = true;
1332
+ }
1333
+ if (removedActive) {
1334
+ this.workspaceActivity?.removeSession(runtime.session.sessionId, runtime.session.sessionManager.getCwd());
1335
+ }
1336
+ try {
1337
+ await runtime.session.abort();
1338
+ }
1339
+ finally {
1340
+ await runtime.dispose();
1341
+ }
1342
+ throw error;
1343
+ }
754
1344
  }
755
1345
  async bindSessionExtensions(session) {
756
1346
  await session.bindExtensions({
@@ -846,10 +1436,15 @@ export class PiSessionService {
846
1436
  maybeGenerateSessionName(session, firstMessage) {
847
1437
  if (session.sessionName !== undefined || session.messages.length !== 0 || session.isStreaming || session.isCompacting)
848
1438
  return;
1439
+ const deterministicName = deterministicSessionName(firstMessage);
1440
+ if (deterministicName !== undefined) {
1441
+ this.applyGeneratedSessionName(session, deterministicName);
1442
+ return;
1443
+ }
849
1444
  const model = session.model;
850
1445
  if (model === undefined)
851
1446
  return;
852
- void generateShortSessionName(this.modelRegistry, model, firstMessage).then((name) => {
1447
+ void generateShortSessionName(session.agent.streamFn, model, firstMessage).then((name) => {
853
1448
  this.applyGeneratedSessionName(session, name ?? fallbackSessionName(firstMessage));
854
1449
  }).catch(() => {
855
1450
  this.applyGeneratedSessionName(session, fallbackSessionName(firstMessage));
@@ -1026,6 +1621,7 @@ export class PiSessionService {
1026
1621
  const contextUsage = session.getContextUsage();
1027
1622
  return {
1028
1623
  sessionId: session.sessionId,
1624
+ persisted: sessionFileExists(session.sessionFile),
1029
1625
  ...(model === undefined ? {} : { model }),
1030
1626
  thinkingLevel: session.thinkingLevel,
1031
1627
  isStreaming: session.isStreaming,
@@ -1049,6 +1645,47 @@ export class PiSessionService {
1049
1645
  return queuedMessagesFromSession(session, this.compactionQueuedMessages(session.sessionId)).some((message) => message.text === text);
1050
1646
  }
1051
1647
  }
1648
+ function previewResponseFromPlan(plan) {
1649
+ return {
1650
+ generatedAt: plan.generatedAt,
1651
+ thresholds: plan.thresholds,
1652
+ projects: plan.projects,
1653
+ totals: plan.totals,
1654
+ ...(plan.skippedBusySessionIds.length === 0 ? {} : { skippedBusySessionIds: plan.skippedBusySessionIds }),
1655
+ };
1656
+ }
1657
+ function uniqueBulkSessionRefs(refs) {
1658
+ const seen = new Set();
1659
+ const unique = [];
1660
+ for (const ref of refs) {
1661
+ const key = `${ref.cwd ?? ""}\0${ref.id}`;
1662
+ if (seen.has(key))
1663
+ continue;
1664
+ seen.add(key);
1665
+ unique.push(ref);
1666
+ }
1667
+ return unique;
1668
+ }
1669
+ function bulkRefToLookup(ref) {
1670
+ return ref.cwd === undefined ? ref.id : { id: ref.id, cwd: ref.cwd };
1671
+ }
1672
+ function findArchivedRecordForBulkRef(records, ref) {
1673
+ return records.find((record) => (ref.cwd === undefined || record.cwd === ref.cwd) && (record.sessionId === ref.id || record.sessionId.startsWith(ref.id)));
1674
+ }
1675
+ function findListedSessionForBulkRef(context, ref) {
1676
+ if (ref.cwd !== undefined)
1677
+ return findSessionByIdOrPrefix(context.sessionsByCwd.get(ref.cwd) ?? [], ref.id);
1678
+ return context.allSessions === undefined ? undefined : findSessionByIdOrPrefix(context.allSessions, ref.id);
1679
+ }
1680
+ function findSessionByIdOrPrefix(sessions, sessionId) {
1681
+ return sessions.find((session) => session.id === sessionId) ?? sessions.find((session) => session.id.startsWith(sessionId));
1682
+ }
1683
+ function uniqueStrings(values) {
1684
+ return [...new Set(values)];
1685
+ }
1686
+ function errorMessage(error) {
1687
+ return error instanceof Error ? error.message : String(error);
1688
+ }
1052
1689
  function modelToClientModel(model) {
1053
1690
  if (model === undefined)
1054
1691
  return {};
@@ -1067,6 +1704,7 @@ function clientSessionFromListEntry(session) {
1067
1704
  id: session.id,
1068
1705
  path: session.path,
1069
1706
  cwd: session.cwd,
1707
+ persisted: true,
1070
1708
  ...(session.name === undefined ? {} : { name: session.name }),
1071
1709
  created: session.created.toISOString(),
1072
1710
  modified: session.modified.toISOString(),
@@ -1195,6 +1833,191 @@ function archivedTimestamp(record) {
1195
1833
  function isDefined(value) {
1196
1834
  return value !== undefined;
1197
1835
  }
1836
+ /**
1837
+ * Resolve the delegation capability from server-owned, persisted session
1838
+ * provenance. A copied marker is not enough: the child header and reciprocal
1839
+ * parent link must identify the exact same session files.
1840
+ */
1841
+ export async function sessionAllowsDelegationTools(sessionManager, managers) {
1842
+ const trackedLink = await verifiedTrackedSubsessionLink(managers, {
1843
+ sessionId: sessionManager.getSessionId(),
1844
+ sessionFile: sessionManager.getSessionFile(),
1845
+ sessionManager,
1846
+ cwd: sessionManager.getCwd(),
1847
+ });
1848
+ return trackedLink === undefined;
1849
+ }
1850
+ async function verifiedTrackedSubsessionLink(managers, session) {
1851
+ // Child markers are only hints; the current child header and reciprocal
1852
+ // parent custom link must agree on the exact ids and files before relinking.
1853
+ const entries = session.sessionManager.getEntries?.() ?? session.sessionManager.getBranch();
1854
+ let marker;
1855
+ for (const entry of entries) {
1856
+ const parsed = parsePersistedChildSubsessionLink(entry);
1857
+ if (parsed?.spawnedSessionId === session.sessionId)
1858
+ marker = parsed;
1859
+ }
1860
+ if (marker === undefined)
1861
+ return undefined;
1862
+ const childSessionFile = nonEmptyString(session.sessionFile);
1863
+ if (childSessionFile === undefined)
1864
+ return undefined;
1865
+ const childHeader = await readSessionHeaderSummary(childSessionFile);
1866
+ if (childHeader?.id !== session.sessionId)
1867
+ return undefined;
1868
+ const parentSessionFile = nonEmptyString(childHeader.parentSession);
1869
+ if (parentSessionFile === undefined)
1870
+ return undefined;
1871
+ const parentHeader = await readSessionHeaderSummary(parentSessionFile);
1872
+ if (parentHeader?.id !== marker.spawnedBySessionId)
1873
+ return undefined;
1874
+ const parentLink = findReciprocalParentSubsessionLink(managers, parentSessionFile, marker.spawnedBySessionId, session.sessionId, childSessionFile);
1875
+ if (parentLink === undefined)
1876
+ return undefined;
1877
+ return {
1878
+ parentSessionId: marker.spawnedBySessionId,
1879
+ childSessionId: session.sessionId,
1880
+ childSessionFile,
1881
+ parentSessionFile,
1882
+ cwd: parentLink.cwd ?? session.cwd,
1883
+ };
1884
+ }
1885
+ function findReciprocalParentSubsessionLink(managers, parentSessionFile, parentSessionId, childSessionId, childSessionFile) {
1886
+ let parentManager;
1887
+ try {
1888
+ parentManager = managers.open(parentSessionFile);
1889
+ }
1890
+ catch {
1891
+ return undefined;
1892
+ }
1893
+ const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
1894
+ for (const entry of entries) {
1895
+ const link = parsePersistedParentSubsessionLink(entry);
1896
+ if (link === undefined)
1897
+ continue;
1898
+ if (link.spawnedBySessionId !== parentSessionId || link.spawnedSessionId !== childSessionId)
1899
+ continue;
1900
+ if (link.spawnedSessionFile === undefined || !sessionPathsEqual(link.spawnedSessionFile, childSessionFile))
1901
+ continue;
1902
+ return link;
1903
+ }
1904
+ return undefined;
1905
+ }
1906
+ function trackedSubsessionLinkFromParentLink(parentSessionId, link, parentSessionFile) {
1907
+ return {
1908
+ parentSessionId,
1909
+ childSessionId: link.spawnedSessionId,
1910
+ ...(link.spawnedSessionFile === undefined ? {} : { childSessionFile: link.spawnedSessionFile }),
1911
+ parentSessionFile,
1912
+ ...(link.cwd === undefined ? {} : { cwd: link.cwd }),
1913
+ };
1914
+ }
1915
+ function persistedParentSubsessionLinkData(link) {
1916
+ return {
1917
+ version: 1,
1918
+ spawnedBySessionId: link.parentSessionId,
1919
+ spawnedSessionId: link.childSessionId,
1920
+ ...(link.childSessionFile === undefined ? {} : { spawnedSessionFile: link.childSessionFile }),
1921
+ ...(link.cwd === undefined ? {} : { cwd: link.cwd }),
1922
+ };
1923
+ }
1924
+ function persistedChildSubsessionLinkData(parentSessionId, childSessionId) {
1925
+ return {
1926
+ version: 1,
1927
+ spawnedBySessionId: parentSessionId,
1928
+ spawnedSessionId: childSessionId,
1929
+ };
1930
+ }
1931
+ function parsePersistedParentSubsessionLink(entry) {
1932
+ if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_LINK_CUSTOM_TYPE)
1933
+ return undefined;
1934
+ const data = entry["data"];
1935
+ if (!isRecord(data))
1936
+ return undefined;
1937
+ const spawnedBySessionId = getString(data, "spawnedBySessionId");
1938
+ const spawnedSessionId = getString(data, "spawnedSessionId");
1939
+ if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "")
1940
+ return undefined;
1941
+ const spawnedSessionFile = getString(data, "spawnedSessionFile");
1942
+ const cwd = getString(data, "cwd");
1943
+ return {
1944
+ spawnedBySessionId,
1945
+ spawnedSessionId,
1946
+ ...(spawnedSessionFile === undefined || spawnedSessionFile === "" ? {} : { spawnedSessionFile }),
1947
+ ...(cwd === undefined || cwd === "" ? {} : { cwd }),
1948
+ };
1949
+ }
1950
+ function parsePersistedChildSubsessionLink(entry) {
1951
+ if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_CHILD_LINK_CUSTOM_TYPE)
1952
+ return undefined;
1953
+ const data = entry["data"];
1954
+ if (!isRecord(data))
1955
+ return undefined;
1956
+ const spawnedBySessionId = getString(data, "spawnedBySessionId");
1957
+ const spawnedSessionId = getString(data, "spawnedSessionId");
1958
+ if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "")
1959
+ return undefined;
1960
+ return { spawnedBySessionId, spawnedSessionId };
1961
+ }
1962
+ function nonEmptyString(value) {
1963
+ return value === undefined || value === "" ? undefined : value;
1964
+ }
1965
+ function subsessionHydratedParentKey(parentSessionId, parentSessionFile) {
1966
+ return `${parentSessionId}\0${parentSessionFile ?? ""}`;
1967
+ }
1968
+ function sessionPathsEqual(a, b) {
1969
+ return cwdPathsEqual(a, b);
1970
+ }
1971
+ function sessionFileExists(sessionFile) {
1972
+ if (sessionFile === undefined || sessionFile === "")
1973
+ return false;
1974
+ try {
1975
+ return statSync(sessionFile).isFile();
1976
+ }
1977
+ catch {
1978
+ return false;
1979
+ }
1980
+ }
1981
+ function sessionFileMatches(session, expectedSessionFile) {
1982
+ const sessionFile = nonEmptyString(session.sessionFile);
1983
+ return sessionFile !== undefined && expectedSessionFile !== undefined && sessionPathsEqual(sessionFile, expectedSessionFile);
1984
+ }
1985
+ function activeSessionFileMatches(active, expectedSessionFile) {
1986
+ return sessionFileMatches(active.runtime.session, expectedSessionFile);
1987
+ }
1988
+ function trackedLinkParentFileMatches(link, parentSessionFile) {
1989
+ return link.parentSessionFile !== undefined && sessionPathsEqual(link.parentSessionFile, parentSessionFile);
1990
+ }
1991
+ async function readSessionHeaderSummary(sessionFile) {
1992
+ let file;
1993
+ try {
1994
+ file = await open(sessionFile, "r");
1995
+ const buffer = Buffer.alloc(4096);
1996
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
1997
+ const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n", 1)[0];
1998
+ if (firstLine === undefined || firstLine === "")
1999
+ return undefined;
2000
+ const header = JSON.parse(firstLine);
2001
+ if (!isRecord(header) || header["type"] !== "session" || typeof header["id"] !== "string")
2002
+ return undefined;
2003
+ const parentSession = getString(header, "parentSession");
2004
+ return { id: header["id"], ...(parentSession === undefined ? {} : { parentSession }) };
2005
+ }
2006
+ catch {
2007
+ return undefined;
2008
+ }
2009
+ finally {
2010
+ await file?.close().catch(() => undefined);
2011
+ }
2012
+ }
2013
+ async function sessionFileHeaderMatches(sessionFile, expected) {
2014
+ const header = await readSessionHeaderSummary(sessionFile);
2015
+ if (header?.id !== expected.sessionId)
2016
+ return false;
2017
+ if (expected.parentSessionFile === undefined)
2018
+ return true;
2019
+ return header.parentSession !== undefined && sessionPathsEqual(header.parentSession, expected.parentSessionFile);
2020
+ }
1198
2021
  async function clearParentSession(sessionFile) {
1199
2022
  const content = await readFile(sessionFile, "utf8");
1200
2023
  const newlineIndex = content.indexOf("\n");
@@ -1208,6 +2031,11 @@ async function clearParentSession(sessionFile) {
1208
2031
  delete header["parentSession"];
1209
2032
  await writeFile(sessionFile, `${JSON.stringify(header)}${rest}`, "utf8");
1210
2033
  }
2034
+ function clearParentSessionHeader(sessionManager) {
2035
+ const header = sessionManager.getHeader?.();
2036
+ if (header !== undefined && header !== null)
2037
+ delete header.parentSession;
2038
+ }
1211
2039
  function clearSessionQueue(session) {
1212
2040
  session.clearQueue();
1213
2041
  }
@@ -1262,6 +2090,10 @@ function historyMessages(session) {
1262
2090
  }
1263
2091
  return messages;
1264
2092
  }
2093
+ /** custom entry type used to persist parent -> child subsession links outside LLM context. */
2094
+ const SUBSESSION_LINK_CUSTOM_TYPE = "pi-web.subsession.link";
2095
+ /** custom entry type used to mark a child as created by spawn_subsession. */
2096
+ const SUBSESSION_CHILD_LINK_CUSTOM_TYPE = "pi-web.subsession.spawned";
1265
2097
  /** customType marking a parent-facing subsession-completion notice. */
1266
2098
  const SUBSESSION_NOTIFICATION_CUSTOM_TYPE = "subsession.completion";
1267
2099
  const SUBSESSION_NOTIFICATION_PREVIEW_CHARS = 2000;