@chainingintention/pi-web-cn 1.202606.24 → 1.202606.26

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 (88) hide show
  1. package/README.md +84 -277
  2. package/dist/cli.js +31 -11
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/CodeViewer-CEaJIjgA.js +4 -0
  5. package/dist/client/assets/{TerminalPanel-DR8I9BBO.js → TerminalPanel-BdMJbtLB.js} +1 -1
  6. package/dist/client/assets/UnifiedDiffViewer-Dkgg-4fh.js +34 -0
  7. package/dist/client/assets/index-iRSp25EB.js +2735 -0
  8. package/dist/client/assets/{vendor-editor-core-Bc1A7SO7.js → vendor-editor-core-C7aWiQy0.js} +2 -2
  9. package/dist/client/assets/{vendor-editor-languages-D0CnB2js.js → vendor-editor-languages-EW34FfVy.js} +1 -1
  10. package/dist/client/index.html +6 -3
  11. package/dist/config.js +9 -1
  12. package/dist/config.js.map +1 -1
  13. package/dist/plugin-api.d.ts +30 -2
  14. package/dist/server/app.js +35 -17
  15. package/dist/server/app.js.map +1 -1
  16. package/dist/server/gitRoutes.js +1 -1
  17. package/dist/server/gitRoutes.js.map +1 -1
  18. package/dist/server/machines/machineClient.js +33 -5
  19. package/dist/server/machines/machineClient.js.map +1 -1
  20. package/dist/server/machines/machineProxyRoutes.js +18 -3
  21. package/dist/server/machines/machineProxyRoutes.js.map +1 -1
  22. package/dist/server/managementEmbed.js +42 -22
  23. package/dist/server/managementEmbed.js.map +1 -1
  24. package/dist/server/sessiond/sessionProxyRoutes.js +1 -1
  25. package/dist/server/sessiond/sessionProxyRoutes.js.map +1 -1
  26. package/dist/server/sessiond.js +2 -2
  27. package/dist/server/sessiond.js.map +1 -1
  28. package/dist/server/sessions/attachmentService.js +68 -10
  29. package/dist/server/sessions/attachmentService.js.map +1 -1
  30. package/dist/server/sessions/authProviderOptions.js +1 -5
  31. package/dist/server/sessions/authProviderOptions.js.map +1 -1
  32. package/dist/server/sessions/piSessionManagerGateway.js +20 -2
  33. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
  34. package/dist/server/sessions/piSessionService.js +515 -51
  35. package/dist/server/sessions/piSessionService.js.map +1 -1
  36. package/dist/server/sessions/sessionCleanup.js +157 -0
  37. package/dist/server/sessions/sessionCleanup.js.map +1 -0
  38. package/dist/server/sessions/sessionNameGenerator.js +39 -2
  39. package/dist/server/sessions/sessionNameGenerator.js.map +1 -1
  40. package/dist/server/sessions/sessionRoutes.js +17 -0
  41. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  42. package/dist/server/sessions/spawnSessionTool.js +7 -2
  43. package/dist/server/sessions/spawnSessionTool.js.map +1 -1
  44. package/dist/server/sessions/spawnSubsessionTool.js +14 -4
  45. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
  46. package/dist/server/workspaceExplorerRoutes.js +74 -24
  47. package/dist/server/workspaceExplorerRoutes.js.map +1 -1
  48. package/dist/server/workspaces/effectivePathAccess.js +27 -0
  49. package/dist/server/workspaces/effectivePathAccess.js.map +1 -0
  50. package/dist/server/workspaces/fileContentService.js +118 -7
  51. package/dist/server/workspaces/fileContentService.js.map +1 -1
  52. package/dist/server/workspaces/fileOperationService.js +1 -19
  53. package/dist/server/workspaces/fileOperationService.js.map +1 -1
  54. package/dist/server/workspaces/fileSuggestions.js +311 -23
  55. package/dist/server/workspaces/fileSuggestions.js.map +1 -1
  56. package/dist/server/workspaces/fileTreeService.js +16 -7
  57. package/dist/server/workspaces/fileTreeService.js.map +1 -1
  58. package/dist/server/workspaces/imagePreviewService.js +5 -5
  59. package/dist/server/workspaces/imagePreviewService.js.map +1 -1
  60. package/dist/server/workspaces/pathAccessPolicy.js +90 -0
  61. package/dist/server/workspaces/pathAccessPolicy.js.map +1 -0
  62. package/dist/server/workspaces/pathSafety.js +19 -10
  63. package/dist/server/workspaces/pathSafety.js.map +1 -1
  64. package/dist/server/workspaces/projectPiWebConfig.js +61 -0
  65. package/dist/server/workspaces/projectPiWebConfig.js.map +1 -0
  66. package/dist/server/workspaces/workspaceDeletionRoutes.js +1 -1
  67. package/dist/server/workspaces/workspaceDeletionRoutes.js.map +1 -1
  68. package/dist/shared/apiTypes.d.ts +89 -6
  69. package/dist/shared/apiTypes.js +2 -0
  70. package/dist/shared/apiTypes.js.map +1 -1
  71. package/dist/shared/capabilities.js +4 -2
  72. package/dist/shared/capabilities.js.map +1 -1
  73. package/dist/shared/federatedRoutes.js +6 -1
  74. package/dist/shared/federatedRoutes.js.map +1 -1
  75. package/dist/shared/piWebConfigParsing.js +44 -3
  76. package/dist/shared/piWebConfigParsing.js.map +1 -1
  77. package/dist/shared/promptAttachments.js +30 -11
  78. package/dist/shared/promptAttachments.js.map +1 -1
  79. package/docs/assets/pi-web-desktop.png +0 -0
  80. package/docs/assets/pi-web-mobile.png +0 -0
  81. package/docs/assets/pi-web-tablet.png +0 -0
  82. package/docs/config.md +203 -0
  83. package/docs/plugins.md +128 -4
  84. package/package.json +3 -1
  85. package/dist/client/assets/CodeViewer-DA68s2LY.js +0 -4
  86. package/dist/client/assets/index-Bjpa_x8C.js +0 -2354
  87. package/docs/assets/pi-web-demo.gif +0 -0
  88. package/docs/assets/pi-web-demo.webm +0 -0
@@ -1,4 +1,4 @@
1
- import { readFile, writeFile } from "node:fs/promises";
1
+ import { open, readFile, writeFile } from "node:fs/promises";
2
2
  import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
3
3
  import { AuthStorage, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition, defineTool, getAgentDir, ModelRegistry, SessionManager, } from "@earendil-works/pi-coding-agent";
4
4
  import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
@@ -15,6 +15,7 @@ import { cwdPathsEqual } from "../workingDirectory.js";
15
15
  import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
16
16
  import { createSubsessionToolDefinitions } from "./spawnSubsessionTool.js";
17
17
  import { buildTranscriptView } from "./subsessionTranscript.js";
18
+ import { planSessionCleanup, summarizeSessionCleanupExecution } from "./sessionCleanup.js";
18
19
  import { createManagedAgentToolOptions, createManagedPythonToolDefinition } from "./managementAgentTools.js";
19
20
  import { PI_PERMISSION_SYSTEM_POLICY_AGENT_DIR, managementAgentToolNames, withRuntimeCreationEnvironment, writeManagementPermissionSystemPolicy } from "./managementPermissionSystem.js";
20
21
  import { archiveCandidateFromActiveSession, archiveCandidateFromArchivedRecord, archiveCandidateFromListEntry, archiveInputFromActiveSession, archiveInputFromCandidate, archiveInputFromListEntry, clientSessionFromArchivedRecord, compareArchivedRecords, } from "./sessionArchiveMapping.js";
@@ -85,19 +86,36 @@ function pathInsideOrEqual(parent, child) {
85
86
  function defaultCreateAgentRuntime(createRuntime, options) {
86
87
  if (!(options.sessionManager instanceof SessionManager))
87
88
  throw new Error("Default runtime creation requires an SDK SessionManager");
88
- const runtimeFactory = (runtimeOptions) => {
89
- return createRuntime(options.managementContext === undefined ? runtimeOptions : { ...runtimeOptions, managementContext: options.managementContext });
89
+ const runtimeFactory = createRuntimeWithContextAndOneShotInitialModel(createRuntime, options.managementContext, options.initialModel);
90
+ return createAgentSessionRuntime(runtimeFactory, {
91
+ cwd: options.cwd,
92
+ agentDir: options.agentDir,
93
+ sessionManager: options.sessionManager,
94
+ });
95
+ }
96
+ function createRuntimeWithContextAndOneShotInitialModel(createRuntime, managementContext, initialModel) {
97
+ // The inherited model belongs only to the session being spawned. Do not keep
98
+ // reapplying it if that runtime later creates/forks/switches sessions itself.
99
+ let pendingInitialModel = initialModel;
100
+ return async (options) => {
101
+ const model = pendingInitialModel;
102
+ pendingInitialModel = undefined;
103
+ return createRuntime({
104
+ ...options,
105
+ ...(managementContext === undefined ? {} : { managementContext }),
106
+ ...(model === undefined ? {} : { initialModel: model }),
107
+ });
90
108
  };
91
- return createAgentSessionRuntime(runtimeFactory, { ...options, sessionManager: options.sessionManager });
92
109
  }
93
110
  function createDefaultRuntimeFactory(authStorage, modelRegistry, spawn, subsessions) {
94
- return async ({ cwd, agentDir, sessionManager, sessionStartEvent, managementContext }) => {
111
+ return async ({ cwd, agentDir, sessionManager, sessionStartEvent, managementContext, initialModel }) => {
95
112
  if (managementContext !== undefined) {
96
113
  return createManagementRuntimeFactory(authStorage, modelRegistry, spawn, subsessions, managementContext)({
97
114
  cwd,
98
115
  agentDir,
99
116
  sessionManager,
100
117
  ...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
118
+ ...(initialModel === undefined ? {} : { initialModel }),
101
119
  });
102
120
  }
103
121
  const services = await createAgentSessionServices({ cwd, agentDir, authStorage, modelRegistry });
@@ -106,18 +124,18 @@ function createDefaultRuntimeFactory(authStorage, modelRegistry, spawn, subsessi
106
124
  ...(spawn === undefined ? [] : [defineTool(createSpawnSessionToolDefinition(cwd, { spawn }))]),
107
125
  ...(subsessions === undefined ? [] : createSubsessionToolDefinitions(cwd, subsessions).map((tool) => defineTool(tool))),
108
126
  ];
109
- const options = {
127
+ const result = await createAgentSessionFromServices({
110
128
  services,
111
129
  sessionManager,
112
- ...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
113
130
  customTools,
114
- };
115
- const result = await createAgentSessionFromServices(options);
131
+ ...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
132
+ ...(initialModel === undefined ? {} : { model: initialModel }),
133
+ });
116
134
  return { ...result, services, diagnostics: services.diagnostics };
117
135
  };
118
136
  }
119
137
  function createManagementRuntimeFactory(authStorage, modelRegistry, spawn, subsessions, managementContext) {
120
- return async ({ cwd, agentDir, sessionManager, sessionStartEvent }) => {
138
+ return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel }) => {
121
139
  const policyAgentDir = await writeManagementPermissionSystemPolicy(agentDir, cwd, managementContext);
122
140
  return withRuntimeCreationEnvironment({ [PI_PERMISSION_SYSTEM_POLICY_AGENT_DIR]: policyAgentDir }, async () => {
123
141
  const services = await createAgentSessionServices({
@@ -142,6 +160,7 @@ function createManagementRuntimeFactory(authStorage, modelRegistry, spawn, subse
142
160
  ...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
143
161
  customTools,
144
162
  tools: managementAgentToolNames(managementContext),
163
+ ...(initialModel === undefined ? {} : { model: initialModel }),
145
164
  };
146
165
  const result = await createAgentSessionFromServices(options);
147
166
  return { ...result, services, diagnostics: services.diagnostics };
@@ -193,6 +212,10 @@ export class PiSessionService {
193
212
  this.subsessionParents = new Map();
194
213
  /** Parent session id -> the set of tracked subsession ids it spawned. */
195
214
  this.subsessionChildren = new Map();
215
+ /** Tracked subsession id -> persisted recovery details for the child. */
216
+ this.subsessionLinks = new Map();
217
+ /** Parent id/file identities whose persisted links have already been loaded. */
218
+ this.subsessionHydratedParents = new Set();
196
219
  /**
197
220
  * Tracked subsession id -> whether a completion notification is armed.
198
221
  * Armed when the child starts working; firing on completion disarms it so a
@@ -205,14 +228,15 @@ export class PiSessionService {
205
228
  this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
206
229
  this.spawnTargets = deps.spawnTargets;
207
230
  this.logger = deps.logger ?? noopLogger;
231
+ this.now = deps.now ?? (() => new Date());
208
232
  // Subsessions are a beta capability gated behind their own flag, and they
209
233
  // also require the spawn capability (they share its project-scope resolver).
210
234
  const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
211
235
  this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRegistry.authStorage, this.modelRegistry, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
212
236
  spawn: (input) => this.spawnSubsession(input),
213
- list: (parentSessionId) => this.listSubsessions(parentSessionId),
214
- check: (parentSessionId, sessionId) => this.checkSubsession(parentSessionId, sessionId),
215
- read: (parentSessionId, sessionId, query) => this.readSubsession(parentSessionId, sessionId, query),
237
+ list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
238
+ check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
239
+ read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile),
216
240
  });
217
241
  this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
218
242
  this.workspaceActivity = deps.workspaceActivity;
@@ -231,6 +255,44 @@ export class PiSessionService {
231
255
  activeCount() {
232
256
  return this.active.size;
233
257
  }
258
+ async cleanupPreview(request) {
259
+ return previewResponseFromPlan(await this.cleanupPlan(request));
260
+ }
261
+ async cleanup(request) {
262
+ const plan = await this.cleanupPlan(request);
263
+ if (plan.deleteRecords.length > 0 && this.archiveStore.deleteArchived === undefined)
264
+ throw new Error("Archive store does not support deletion");
265
+ const archiveInputs = [];
266
+ const deleteRecords = [];
267
+ const skippedBusySessionIds = new Set(plan.skippedBusySessionIds);
268
+ for (const input of plan.archiveInputs) {
269
+ if (this.activeSessionHasWork(input.sessionId)) {
270
+ skippedBusySessionIds.add(input.sessionId);
271
+ continue;
272
+ }
273
+ await this.closeActive(input.sessionId);
274
+ await this.archiveStore.archive(input);
275
+ archiveInputs.push(input);
276
+ }
277
+ for (const record of plan.deleteRecords) {
278
+ if (this.activeSessionHasWork(record.sessionId)) {
279
+ skippedBusySessionIds.add(record.sessionId);
280
+ continue;
281
+ }
282
+ await this.closeActive(record.sessionId);
283
+ if (record.archivePath === undefined)
284
+ await this.ensureArchivedRecordMoved(record);
285
+ await this.archiveStore.deleteArchived?.(record.sessionId);
286
+ deleteRecords.push(record);
287
+ }
288
+ return summarizeSessionCleanupExecution({
289
+ archiveInputs,
290
+ deleteRecords,
291
+ thresholds: plan.thresholds,
292
+ generatedAt: plan.generatedAt,
293
+ skippedBusySessionIds: [...skippedBusySessionIds],
294
+ });
295
+ }
234
296
  async dispose() {
235
297
  clearInterval(this.heartbeat);
236
298
  this.clearCompactionDrainTimers();
@@ -241,6 +303,8 @@ export class PiSessionService {
241
303
  this.authLossWarnings.clear();
242
304
  this.subsessionParents.clear();
243
305
  this.subsessionChildren.clear();
306
+ this.subsessionLinks.clear();
307
+ this.subsessionHydratedParents.clear();
244
308
  this.subsessionNotifyArmed.clear();
245
309
  await Promise.all(activeSessions.map(async (active) => {
246
310
  active.unsubscribe();
@@ -265,7 +329,10 @@ export class PiSessionService {
265
329
  return [...unarchivedSessions, ...archivedSessions];
266
330
  }
267
331
  async start(cwd, options = {}) {
268
- const active = await this.create(this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession }), cwd, options.managementContext);
332
+ const active = await this.create(this.sessionManager.create(cwd, options.parentSession === undefined ? undefined : { parentSession: options.parentSession }), cwd, {
333
+ ...(options.managementContext === undefined ? {} : { managementContext: options.managementContext }),
334
+ ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
335
+ });
269
336
  const { session } = active.runtime;
270
337
  const created = {
271
338
  id: session.sessionId,
@@ -295,7 +362,7 @@ export class PiSessionService {
295
362
  const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
296
363
  if (!decision.allowed)
297
364
  throw spawnTargetError(decision);
298
- const created = await this.start(decision.cwd);
365
+ const created = await this.start(decision.cwd, input.model === undefined ? {} : { initialModel: input.model });
299
366
  await this.prompt(created.id, input.prompt);
300
367
  this.logger.info({ spawningCwd: input.spawningCwd, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_session started a new session");
301
368
  return { sessionId: created.id, cwd: decision.cwd };
@@ -312,69 +379,294 @@ export class PiSessionService {
312
379
  const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
313
380
  if (!decision.allowed)
314
381
  throw spawnTargetError(decision);
315
- const created = await this.start(decision.cwd, input.parentSessionFile === undefined ? undefined : { parentSession: input.parentSessionFile });
316
- this.registerSubsession(input.parentSessionId, created.id);
382
+ const created = await this.start(decision.cwd, {
383
+ ...(input.parentSessionFile === undefined ? {} : { parentSession: input.parentSessionFile }),
384
+ ...(input.model === undefined ? {} : { initialModel: input.model }),
385
+ });
386
+ const parentSessionFile = nonEmptyString(input.parentSessionFile);
387
+ const link = {
388
+ parentSessionId: input.parentSessionId,
389
+ childSessionId: created.id,
390
+ ...(created.path === "" ? {} : { childSessionFile: created.path }),
391
+ ...(parentSessionFile === undefined ? {} : { parentSessionFile }),
392
+ cwd: decision.cwd,
393
+ };
394
+ this.registerVerifiedSubsession(link);
395
+ this.persistSubsessionLink(link);
396
+ this.persistSubsessionChildMarker(input.parentSessionId, created.id);
317
397
  await this.prompt(created.id, input.prompt);
318
398
  this.logger.info({ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_subsession started a tracked child session");
319
399
  return { sessionId: created.id, cwd: decision.cwd };
320
400
  }
321
401
  /** Summaries of the tracked subsessions spawned by `parentSessionId`. */
322
- async listSubsessions(parentSessionId) {
402
+ async listSubsessions(parentSessionId, parentSessionFile) {
403
+ const parentFile = nonEmptyString(parentSessionFile);
404
+ await this.hydrateSubsessionsForParent(parentSessionId, parentFile);
323
405
  const childIds = this.subsessionChildren.get(parentSessionId);
324
406
  if (childIds === undefined)
325
407
  return [];
326
- return Promise.all([...childIds].map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) })));
408
+ const authorizedChildIds = [...childIds].filter((childId) => this.subsessionLinkBelongsToParent(parentSessionId, parentFile, childId));
409
+ return Promise.all(authorizedChildIds.map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) })));
327
410
  }
328
411
  /** Status and final result of a subsession, scoped to the caller's children. */
329
- async checkSubsession(parentSessionId, sessionId) {
330
- const session = await this.openSubsession(parentSessionId, sessionId);
412
+ async checkSubsession(parentSessionId, sessionId, parentSessionFile) {
413
+ const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
331
414
  const messages = historyMessages(session);
332
415
  return {
333
416
  sessionId,
334
417
  cwd: session.sessionManager.getCwd(),
335
- status: await this.subsessionStatus(session),
418
+ status: this.subsessionStatus(session),
336
419
  finalText: finalAssistantText(messages),
337
420
  messageCount: messages.length,
338
421
  };
339
422
  }
340
423
  /** Filtered, paginated transcript of a subsession, scoped to the caller's children. */
341
- async readSubsession(parentSessionId, sessionId, query) {
342
- const session = await this.openSubsession(parentSessionId, sessionId);
424
+ async readSubsession(parentSessionId, sessionId, query, parentSessionFile) {
425
+ const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
343
426
  const view = buildTranscriptView(historyMessages(session), query);
344
427
  return {
345
428
  sessionId,
346
429
  cwd: session.sessionManager.getCwd(),
347
- status: await this.subsessionStatus(session),
430
+ status: this.subsessionStatus(session),
348
431
  ...view,
349
432
  };
350
433
  }
351
434
  /** Open a session after verifying it is one of the caller's tracked children. */
352
- async openSubsession(parentSessionId, sessionId) {
353
- if (this.subsessionParents.get(sessionId) !== parentSessionId) {
435
+ async openSubsession(parentSessionId, sessionId, parentSessionFile) {
436
+ const parentFile = nonEmptyString(parentSessionFile);
437
+ await this.hydrateSubsessionsForParent(parentSessionId, parentFile);
438
+ if (this.subsessionParents.get(sessionId) !== parentSessionId || !this.subsessionLinkBelongsToParent(parentSessionId, parentFile, sessionId)) {
354
439
  throw new Error(`Session ${sessionId} is not one of your subsessions`);
355
440
  }
356
- return this.getOrOpen(sessionId);
441
+ return this.getOrOpenTrackedSubsession(sessionId);
442
+ }
443
+ subsessionLinkBelongsToParent(parentSessionId, parentSessionFile, childSessionId) {
444
+ const link = this.subsessionLinks.get(childSessionId);
445
+ if (link?.parentSessionId !== parentSessionId)
446
+ return false;
447
+ return parentSessionFile === undefined || trackedLinkParentFileMatches(link, parentSessionFile);
448
+ }
449
+ activeChildForSubsessionLink(link) {
450
+ const active = this.active.get(link.childSessionId);
451
+ if (active === undefined)
452
+ return undefined;
453
+ return activeSessionFileMatches(active, link.childSessionFile) ? active : undefined;
454
+ }
455
+ activeParentForSubsessionLink(link) {
456
+ const active = this.active.get(link.parentSessionId);
457
+ if (active === undefined)
458
+ return undefined;
459
+ return activeSessionFileMatches(active, link.parentSessionFile) ? active : undefined;
357
460
  }
358
- registerSubsession(parentSessionId, childSessionId) {
461
+ subsessionLinkForActiveChild(session) {
462
+ const childId = session.sessionId;
463
+ const parentId = this.subsessionParents.get(childId);
464
+ const link = this.subsessionLinks.get(childId);
465
+ if (parentId === undefined || link?.parentSessionId !== parentId)
466
+ return undefined;
467
+ return sessionFileMatches(session, link.childSessionFile) ? link : undefined;
468
+ }
469
+ registerVerifiedSubsession(link) {
470
+ const { childSessionId, parentSessionId } = link;
471
+ const previousParentId = this.subsessionParents.get(childSessionId);
472
+ if (previousParentId !== undefined && previousParentId !== parentSessionId) {
473
+ const previousChildren = this.subsessionChildren.get(previousParentId);
474
+ previousChildren?.delete(childSessionId);
475
+ if (previousChildren?.size === 0)
476
+ this.subsessionChildren.delete(previousParentId);
477
+ }
359
478
  this.subsessionParents.set(childSessionId, parentSessionId);
360
479
  const children = this.subsessionChildren.get(parentSessionId) ?? new Set();
361
480
  children.add(childSessionId);
362
481
  this.subsessionChildren.set(parentSessionId, children);
363
- this.subsessionNotifyArmed.set(childSessionId, false);
482
+ this.subsessionLinks.set(childSessionId, link);
483
+ if (!this.subsessionNotifyArmed.has(childSessionId))
484
+ this.subsessionNotifyArmed.set(childSessionId, false);
485
+ }
486
+ unregisterSubsession(childSessionId) {
487
+ const parentSessionId = this.subsessionParents.get(childSessionId);
488
+ this.subsessionParents.delete(childSessionId);
489
+ this.subsessionLinks.delete(childSessionId);
490
+ this.subsessionNotifyArmed.delete(childSessionId);
491
+ if (parentSessionId === undefined)
492
+ return;
493
+ const children = this.subsessionChildren.get(parentSessionId);
494
+ children?.delete(childSessionId);
495
+ if (children?.size === 0)
496
+ this.subsessionChildren.delete(parentSessionId);
497
+ }
498
+ persistSubsessionLink(link) {
499
+ const parent = this.activeParentForSubsessionLink(link)?.runtime.session;
500
+ if (parent === undefined)
501
+ return;
502
+ if (parent.sessionManager.appendCustomEntry === undefined)
503
+ return;
504
+ try {
505
+ parent.sessionManager.appendCustomEntry(SUBSESSION_LINK_CUSTOM_TYPE, persistedParentSubsessionLinkData(link));
506
+ }
507
+ catch (error) {
508
+ this.logger.info({ parentSessionId: link.parentSessionId, sessionId: link.childSessionId, error: error instanceof Error ? error.message : String(error) }, "failed to persist subsession link");
509
+ }
510
+ }
511
+ persistSubsessionChildMarker(parentSessionId, childSessionId) {
512
+ const child = this.active.get(childSessionId)?.runtime.session;
513
+ if (child === undefined)
514
+ return;
515
+ if (child.sessionManager.appendCustomEntry === undefined)
516
+ return;
517
+ try {
518
+ child.sessionManager.appendCustomEntry(SUBSESSION_CHILD_LINK_CUSTOM_TYPE, persistedChildSubsessionLinkData(parentSessionId, childSessionId));
519
+ }
520
+ catch (error) {
521
+ this.logger.info({ parentSessionId, sessionId: childSessionId, error: error instanceof Error ? error.message : String(error) }, "failed to persist subsession child marker");
522
+ }
523
+ }
524
+ async hydrateSubsessionsForParent(parentSessionId, parentSessionFile) {
525
+ const hydrationKey = subsessionHydratedParentKey(parentSessionId, parentSessionFile);
526
+ if (this.subsessionHydratedParents.has(hydrationKey))
527
+ return;
528
+ const activeParent = this.active.get(parentSessionId);
529
+ if (activeParent !== undefined && (parentSessionFile === undefined || activeSessionFileMatches(activeParent, parentSessionFile))) {
530
+ const activeParentFile = nonEmptyString(activeParent.runtime.session.sessionFile);
531
+ await this.registerPersistedSubsessionLinks(parentSessionId, activeParent.runtime.session.sessionManager, activeParentFile);
532
+ this.subsessionHydratedParents.add(hydrationKey);
533
+ return;
534
+ }
535
+ if (parentSessionFile === undefined)
536
+ return;
537
+ if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId) {
538
+ this.subsessionHydratedParents.add(hydrationKey);
539
+ return;
540
+ }
541
+ let parentManager;
542
+ try {
543
+ parentManager = this.sessionManager.open(parentSessionFile);
544
+ }
545
+ catch {
546
+ this.subsessionHydratedParents.add(hydrationKey);
547
+ return;
548
+ }
549
+ await this.registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile);
550
+ this.subsessionHydratedParents.add(hydrationKey);
551
+ }
552
+ async registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile) {
553
+ // Parent custom links are the authoritative recovery record: verify the
554
+ // exact live child file/header before tracking.
555
+ const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
556
+ for (const entry of entries) {
557
+ const link = parsePersistedParentSubsessionLink(entry);
558
+ if (link === undefined)
559
+ continue;
560
+ const verified = await this.verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link);
561
+ if (verified === undefined)
562
+ continue;
563
+ this.registerVerifiedSubsession(verified);
564
+ }
565
+ }
566
+ async verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link) {
567
+ if (parentSessionFile === undefined)
568
+ return undefined;
569
+ if (link.spawnedBySessionId !== parentSessionId)
570
+ return undefined;
571
+ if (!(await this.parentLinkHasValidChildTarget(parentSessionFile, link)))
572
+ return undefined;
573
+ return trackedSubsessionLinkFromParentLink(parentSessionId, link, parentSessionFile);
574
+ }
575
+ async parentLinkHasValidChildTarget(parentSessionFile, link) {
576
+ return link.spawnedSessionFile !== undefined
577
+ && await sessionFileHeaderMatches(link.spawnedSessionFile, { sessionId: link.spawnedSessionId, parentSessionFile });
578
+ }
579
+ async recoverSubsessionTrackingForOpenedSession(session) {
580
+ const link = await this.verifiedSubsessionLinkFromOpenedChild(session);
581
+ if (link === undefined)
582
+ return;
583
+ this.registerVerifiedSubsession(link);
584
+ }
585
+ async verifiedSubsessionLinkFromOpenedChild(session) {
586
+ // Child markers are only hints; the current child header and reciprocal
587
+ // parent custom link must agree on the exact ids and files before relinking.
588
+ const entries = session.sessionManager.getEntries?.() ?? session.sessionManager.getBranch();
589
+ let marker;
590
+ for (const entry of entries) {
591
+ const parsed = parsePersistedChildSubsessionLink(entry);
592
+ if (parsed?.spawnedSessionId === session.sessionId)
593
+ marker = parsed;
594
+ }
595
+ if (marker === undefined)
596
+ return undefined;
597
+ const childSessionFile = nonEmptyString(session.sessionFile);
598
+ if (childSessionFile === undefined)
599
+ return undefined;
600
+ const childHeader = await readSessionHeaderSummary(childSessionFile);
601
+ if (childHeader?.id !== session.sessionId)
602
+ return undefined;
603
+ const parentSessionFile = nonEmptyString(childHeader.parentSession);
604
+ if (parentSessionFile === undefined)
605
+ return undefined;
606
+ const parentHeader = await readSessionHeaderSummary(parentSessionFile);
607
+ if (parentHeader?.id !== marker.spawnedBySessionId)
608
+ return undefined;
609
+ const parentLink = this.findReciprocalParentSubsessionLink(parentSessionFile, marker.spawnedBySessionId, session.sessionId, childSessionFile);
610
+ if (parentLink === undefined)
611
+ return undefined;
612
+ return {
613
+ parentSessionId: marker.spawnedBySessionId,
614
+ childSessionId: session.sessionId,
615
+ childSessionFile,
616
+ parentSessionFile,
617
+ cwd: parentLink.cwd ?? session.sessionManager.getCwd(),
618
+ };
619
+ }
620
+ findReciprocalParentSubsessionLink(parentSessionFile, parentSessionId, childSessionId, childSessionFile) {
621
+ let parentManager;
622
+ try {
623
+ parentManager = this.sessionManager.open(parentSessionFile);
624
+ }
625
+ catch {
626
+ return undefined;
627
+ }
628
+ const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
629
+ for (const entry of entries) {
630
+ const link = parsePersistedParentSubsessionLink(entry);
631
+ if (link === undefined)
632
+ continue;
633
+ if (link.spawnedBySessionId !== parentSessionId || link.spawnedSessionId !== childSessionId)
634
+ continue;
635
+ if (link.spawnedSessionFile === undefined || !sessionPathsEqual(link.spawnedSessionFile, childSessionFile))
636
+ continue;
637
+ return link;
638
+ }
639
+ return undefined;
640
+ }
641
+ async getOrOpenTrackedSubsession(sessionId) {
642
+ const link = this.subsessionLinks.get(sessionId);
643
+ if (link === undefined)
644
+ throw new Error("Session not found");
645
+ const active = this.activeChildForSubsessionLink(link);
646
+ if (active !== undefined)
647
+ return active.runtime.session;
648
+ if (link.childSessionFile !== undefined) {
649
+ if (!(await sessionFileHeaderMatches(link.childSessionFile, { sessionId, parentSessionFile: link.parentSessionFile })))
650
+ throw new Error("Session not found");
651
+ const sessionManager = this.sessionManager.open(link.childSessionFile);
652
+ return (await this.create(sessionManager, link.cwd ?? sessionManager.getCwd())).runtime.session;
653
+ }
654
+ throw new Error("Session not found");
364
655
  }
365
656
  async subsessionSummaryFields(childSessionId) {
366
- const active = this.active.get(childSessionId);
657
+ const link = this.subsessionLinks.get(childSessionId);
658
+ const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link);
367
659
  if (active !== undefined) {
368
- return { cwd: active.runtime.cwd, status: await this.subsessionStatus(active.runtime.session) };
660
+ return { cwd: active.runtime.cwd, status: this.subsessionStatus(active.runtime.session) };
369
661
  }
370
- const archived = await this.archiveStore.get(childSessionId);
371
- if (archived !== undefined)
372
- return { cwd: archived.cwd, status: "archived" };
662
+ if (link?.childSessionFile !== undefined && (await sessionFileHeaderMatches(link.childSessionFile, { sessionId: childSessionId, parentSessionFile: link.parentSessionFile }))) {
663
+ return { cwd: link.cwd ?? "", status: "idle" };
664
+ }
665
+ if (link?.cwd !== undefined)
666
+ return { cwd: link.cwd, status: "unknown" };
373
667
  return { cwd: "", status: "unknown" };
374
668
  }
375
- async subsessionStatus(session) {
376
- if (await this.archiveStore.isArchived(session.sessionId))
377
- return "archived";
669
+ subsessionStatus(session) {
378
670
  if (this.hasActiveWork(session))
379
671
  return "working";
380
672
  if (this.activities.get(session.sessionId)?.phase === "error")
@@ -388,10 +680,10 @@ export class PiSessionService {
388
680
  * parent is busy and delivers immediately when it is idle).
389
681
  */
390
682
  updateSubsessionTracking(session) {
391
- const childId = session.sessionId;
392
- const parentId = this.subsessionParents.get(childId);
393
- if (parentId === undefined)
683
+ const link = this.subsessionLinkForActiveChild(session);
684
+ if (link === undefined)
394
685
  return;
686
+ const childId = link.childSessionId;
395
687
  if (this.hasActiveWork(session)) {
396
688
  this.subsessionNotifyArmed.set(childId, true);
397
689
  return;
@@ -403,7 +695,23 @@ export class PiSessionService {
403
695
  const finalText = finalAssistantText(historyMessages(session));
404
696
  const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText);
405
697
  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.`;
406
- void this.notifyParentOfSubsession(parentId, childId, text);
698
+ void this.notifyParentOfSubsession(link.parentSessionId, childId, text);
699
+ }
700
+ async getOrOpenParentForSubsession(parentSessionId, childSessionId) {
701
+ const link = this.subsessionLinks.get(childSessionId);
702
+ if (link?.parentSessionId !== parentSessionId)
703
+ throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
704
+ const active = this.activeParentForSubsessionLink(link);
705
+ if (active !== undefined)
706
+ return active.runtime.session;
707
+ const parentSessionFile = link.parentSessionFile;
708
+ if (parentSessionFile === undefined)
709
+ throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
710
+ if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId) {
711
+ throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
712
+ }
713
+ const sessionManager = this.sessionManager.open(parentSessionFile);
714
+ return (await this.create(sessionManager, sessionManager.getCwd())).runtime.session;
407
715
  }
408
716
  /**
409
717
  * Deliver a subsession-completion notice to the parent as a system-authored
@@ -414,7 +722,7 @@ export class PiSessionService {
414
722
  */
415
723
  async notifyParentOfSubsession(parentId, childId, text) {
416
724
  try {
417
- const session = await this.getOrOpen(parentId);
725
+ const session = await this.getOrOpenParentForSubsession(parentId, childId);
418
726
  await session.sendCustomMessage({ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: text, display: true, details: { sessionId: childId } }, { triggerTurn: true, deliverAs: "followUp" });
419
727
  this.publishStatus(session);
420
728
  }
@@ -552,7 +860,7 @@ export class PiSessionService {
552
860
  this.publishStatus(session);
553
861
  }
554
862
  async saveAttachments(ref, attachments, folder) {
555
- const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false });
863
+ const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false, allowFileAttachments: true });
556
864
  if (parsed.length === 0)
557
865
  return [];
558
866
  await this.assertWritable(ref);
@@ -665,6 +973,8 @@ export class PiSessionService {
665
973
  if (sessionFile === undefined || sessionFile === "")
666
974
  throw new Error("会话尚未持久化");
667
975
  await clearParentSession(sessionFile);
976
+ clearParentSessionHeader(session.sessionManager);
977
+ this.unregisterSubsession(session.sessionId);
668
978
  }
669
979
  async abort(ref) {
670
980
  const active = this.activeForLookupAny(ref);
@@ -685,6 +995,27 @@ export class PiSessionService {
685
995
  // Best-effort shutdown; callers that need errors await closeActive directly.
686
996
  });
687
997
  }
998
+ async cleanupPlan(request) {
999
+ const [sessions, archivedRecords] = await Promise.all([this.sessionManager.listAll?.() ?? [], this.archiveStore.list()]);
1000
+ return planSessionCleanup({
1001
+ sessions,
1002
+ archivedRecords,
1003
+ activeSessions: this.cleanupActiveSessionStatuses(),
1004
+ thresholds: request.thresholds,
1005
+ ...(request.projectCwds === undefined ? {} : { projectCwds: request.projectCwds }),
1006
+ now: this.now(),
1007
+ });
1008
+ }
1009
+ cleanupActiveSessionStatuses() {
1010
+ return [...new Set(this.active.values())].map((active) => ({
1011
+ sessionId: active.runtime.session.sessionId,
1012
+ hasActiveWork: this.hasActiveWork(active.runtime.session),
1013
+ }));
1014
+ }
1015
+ activeSessionHasWork(sessionId) {
1016
+ const active = this.active.get(sessionId);
1017
+ return active !== undefined && this.hasActiveWork(active.runtime.session);
1018
+ }
688
1019
  reconcilableSessionIds(cwd, listedSessionIds, archivedById) {
689
1020
  const sessionIds = new Set(listedSessionIds);
690
1021
  for (const active of new Set(this.active.values())) {
@@ -782,7 +1113,8 @@ export class PiSessionService {
782
1113
  // Disarm subsession notification before teardown so the abort below cannot
783
1114
  // emit a "stopped working" event that notifies the parent (e.g. on archive).
784
1115
  // The parent/children link is kept so the parent can still see the child.
785
- this.subsessionNotifyArmed.delete(sessionId);
1116
+ if (this.subsessionLinkForActiveChild(active.runtime.session) !== undefined)
1117
+ this.subsessionNotifyArmed.delete(sessionId);
786
1118
  clearSessionQueue(active.runtime.session);
787
1119
  active.unsubscribe();
788
1120
  try {
@@ -807,14 +1139,15 @@ export class PiSessionService {
807
1139
  if (mismatched !== undefined)
808
1140
  await this.closeActive(mismatched.runtime.session.sessionId);
809
1141
  const archived = await this.getArchived(ref);
810
- if (archived?.archivePath !== undefined)
811
- return this.create(this.sessionManager.open(archived.archivePath), archived.cwd, managementContext);
1142
+ if (archived?.archivePath !== undefined) {
1143
+ return this.create(this.sessionManager.open(archived.archivePath), archived.cwd, managementContext === undefined ? {} : { managementContext });
1144
+ }
812
1145
  const match = isPiSessionRef(ref)
813
1146
  ? (await this.sessionManager.list(ref.cwd)).find((s) => s.id === ref.id || s.id.startsWith(ref.id))
814
1147
  : (await this.sessionManager.listAll?.() ?? []).find((s) => s.id === ref || s.id.startsWith(ref));
815
1148
  if (!match)
816
1149
  throw new Error("未找到会话");
817
- return this.create(this.sessionManager.open(match.path), match.cwd, managementContext);
1150
+ return this.create(this.sessionManager.open(match.path), match.cwd, managementContext === undefined ? {} : { managementContext });
818
1151
  }
819
1152
  async getArchived(ref) {
820
1153
  const archived = await this.archiveStore.get(sessionIdFromLookup(ref));
@@ -847,16 +1180,24 @@ export class PiSessionService {
847
1180
  }
848
1181
  return undefined;
849
1182
  }
850
- async create(sessionManager, cwd, managementContext) {
851
- const runtime = await this.createAgentRuntime(this.createRuntime, managementContext === undefined ? { cwd, agentDir: this.agentDir, sessionManager } : { cwd, agentDir: this.agentDir, sessionManager, managementContext });
1183
+ async create(sessionManager, cwd, options = {}) {
1184
+ const runtime = await this.createAgentRuntime(this.createRuntime, {
1185
+ cwd,
1186
+ agentDir: this.agentDir,
1187
+ sessionManager,
1188
+ ...(options.managementContext === undefined ? {} : { managementContext: options.managementContext }),
1189
+ ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
1190
+ });
852
1191
  await this.bindSessionExtensions(runtime.session);
853
- const active = { runtime, unsubscribe: noop, managementContextKey: managementContextKey(managementContext) };
1192
+ const active = { runtime, unsubscribe: noop, managementContextKey: managementContextKey(options.managementContext) };
854
1193
  this.bindRuntime(active);
855
1194
  runtime.setRebindSession(async (session) => {
856
1195
  await this.bindSessionExtensions(session);
857
1196
  this.bindRuntime(active);
1197
+ await this.recoverSubsessionTrackingForOpenedSession(session);
858
1198
  });
859
1199
  this.active.set(runtime.session.sessionId, active);
1200
+ await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
860
1201
  this.publishStatus(runtime.session);
861
1202
  return active;
862
1203
  }
@@ -1157,6 +1498,15 @@ export class PiSessionService {
1157
1498
  return queuedMessagesFromSession(session, this.compactionQueuedMessages(session.sessionId)).some((message) => message.text === text);
1158
1499
  }
1159
1500
  }
1501
+ function previewResponseFromPlan(plan) {
1502
+ return {
1503
+ generatedAt: plan.generatedAt,
1504
+ thresholds: plan.thresholds,
1505
+ projects: plan.projects,
1506
+ totals: plan.totals,
1507
+ ...(plan.skippedBusySessionIds.length === 0 ? {} : { skippedBusySessionIds: plan.skippedBusySessionIds }),
1508
+ };
1509
+ }
1160
1510
  function modelToClientModel(model) {
1161
1511
  if (model === undefined)
1162
1512
  return {};
@@ -1197,6 +1547,111 @@ function addSessionName(names, name) {
1197
1547
  function isDefined(value) {
1198
1548
  return value !== undefined;
1199
1549
  }
1550
+ function trackedSubsessionLinkFromParentLink(parentSessionId, link, parentSessionFile) {
1551
+ return {
1552
+ parentSessionId,
1553
+ childSessionId: link.spawnedSessionId,
1554
+ ...(link.spawnedSessionFile === undefined ? {} : { childSessionFile: link.spawnedSessionFile }),
1555
+ parentSessionFile,
1556
+ ...(link.cwd === undefined ? {} : { cwd: link.cwd }),
1557
+ };
1558
+ }
1559
+ function persistedParentSubsessionLinkData(link) {
1560
+ return {
1561
+ version: 1,
1562
+ spawnedBySessionId: link.parentSessionId,
1563
+ spawnedSessionId: link.childSessionId,
1564
+ ...(link.childSessionFile === undefined ? {} : { spawnedSessionFile: link.childSessionFile }),
1565
+ ...(link.cwd === undefined ? {} : { cwd: link.cwd }),
1566
+ };
1567
+ }
1568
+ function persistedChildSubsessionLinkData(parentSessionId, childSessionId) {
1569
+ return {
1570
+ version: 1,
1571
+ spawnedBySessionId: parentSessionId,
1572
+ spawnedSessionId: childSessionId,
1573
+ };
1574
+ }
1575
+ function parsePersistedParentSubsessionLink(entry) {
1576
+ if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_LINK_CUSTOM_TYPE)
1577
+ return undefined;
1578
+ const data = entry["data"];
1579
+ if (!isRecord(data))
1580
+ return undefined;
1581
+ const spawnedBySessionId = getString(data, "spawnedBySessionId");
1582
+ const spawnedSessionId = getString(data, "spawnedSessionId");
1583
+ if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "")
1584
+ return undefined;
1585
+ const spawnedSessionFile = getString(data, "spawnedSessionFile");
1586
+ const cwd = getString(data, "cwd");
1587
+ return {
1588
+ spawnedBySessionId,
1589
+ spawnedSessionId,
1590
+ ...(spawnedSessionFile === undefined || spawnedSessionFile === "" ? {} : { spawnedSessionFile }),
1591
+ ...(cwd === undefined || cwd === "" ? {} : { cwd }),
1592
+ };
1593
+ }
1594
+ function parsePersistedChildSubsessionLink(entry) {
1595
+ if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_CHILD_LINK_CUSTOM_TYPE)
1596
+ return undefined;
1597
+ const data = entry["data"];
1598
+ if (!isRecord(data))
1599
+ return undefined;
1600
+ const spawnedBySessionId = getString(data, "spawnedBySessionId");
1601
+ const spawnedSessionId = getString(data, "spawnedSessionId");
1602
+ if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "")
1603
+ return undefined;
1604
+ return { spawnedBySessionId, spawnedSessionId };
1605
+ }
1606
+ function nonEmptyString(value) {
1607
+ return value === undefined || value === "" ? undefined : value;
1608
+ }
1609
+ function subsessionHydratedParentKey(parentSessionId, parentSessionFile) {
1610
+ return `${parentSessionId}\0${parentSessionFile ?? ""}`;
1611
+ }
1612
+ function sessionPathsEqual(a, b) {
1613
+ return cwdPathsEqual(a, b);
1614
+ }
1615
+ function sessionFileMatches(session, expectedSessionFile) {
1616
+ const sessionFile = nonEmptyString(session.sessionFile);
1617
+ return sessionFile !== undefined && expectedSessionFile !== undefined && sessionPathsEqual(sessionFile, expectedSessionFile);
1618
+ }
1619
+ function activeSessionFileMatches(active, expectedSessionFile) {
1620
+ return sessionFileMatches(active.runtime.session, expectedSessionFile);
1621
+ }
1622
+ function trackedLinkParentFileMatches(link, parentSessionFile) {
1623
+ return link.parentSessionFile !== undefined && sessionPathsEqual(link.parentSessionFile, parentSessionFile);
1624
+ }
1625
+ async function readSessionHeaderSummary(sessionFile) {
1626
+ let file;
1627
+ try {
1628
+ file = await open(sessionFile, "r");
1629
+ const buffer = Buffer.alloc(4096);
1630
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
1631
+ const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n", 1)[0];
1632
+ if (firstLine === undefined || firstLine === "")
1633
+ return undefined;
1634
+ const header = JSON.parse(firstLine);
1635
+ if (!isRecord(header) || header["type"] !== "session" || typeof header["id"] !== "string")
1636
+ return undefined;
1637
+ const parentSession = getString(header, "parentSession");
1638
+ return { id: header["id"], ...(parentSession === undefined ? {} : { parentSession }) };
1639
+ }
1640
+ catch {
1641
+ return undefined;
1642
+ }
1643
+ finally {
1644
+ await file?.close().catch(() => undefined);
1645
+ }
1646
+ }
1647
+ async function sessionFileHeaderMatches(sessionFile, expected) {
1648
+ const header = await readSessionHeaderSummary(sessionFile);
1649
+ if (header?.id !== expected.sessionId)
1650
+ return false;
1651
+ if (expected.parentSessionFile === undefined)
1652
+ return true;
1653
+ return header.parentSession !== undefined && sessionPathsEqual(header.parentSession, expected.parentSessionFile);
1654
+ }
1200
1655
  async function clearParentSession(sessionFile) {
1201
1656
  const content = await readFile(sessionFile, "utf8");
1202
1657
  const newlineIndex = content.indexOf("\n");
@@ -1210,6 +1665,11 @@ async function clearParentSession(sessionFile) {
1210
1665
  delete header["parentSession"];
1211
1666
  await writeFile(sessionFile, `${JSON.stringify(header)}${rest}`, "utf8");
1212
1667
  }
1668
+ function clearParentSessionHeader(sessionManager) {
1669
+ const header = sessionManager.getHeader?.();
1670
+ if (header !== undefined && header !== null)
1671
+ delete header.parentSession;
1672
+ }
1213
1673
  function clearSessionQueue(session) {
1214
1674
  session.clearQueue();
1215
1675
  }
@@ -1264,6 +1724,10 @@ function historyMessages(session) {
1264
1724
  }
1265
1725
  return messages;
1266
1726
  }
1727
+ /** custom entry type used to persist parent -> child subsession links outside LLM context. */
1728
+ const SUBSESSION_LINK_CUSTOM_TYPE = "pi-web.subsession.link";
1729
+ /** custom entry type used to mark a child as created by spawn_subsession. */
1730
+ const SUBSESSION_CHILD_LINK_CUSTOM_TYPE = "pi-web.subsession.spawned";
1267
1731
  /** customType marking a parent-facing subsession-completion notice. */
1268
1732
  const SUBSESSION_NOTIFICATION_CUSTOM_TYPE = "subsession.completion";
1269
1733
  const SUBSESSION_NOTIFICATION_PREVIEW_CHARS = 2000;