@jmfederico/pi-web 1.202606.6 → 1.202606.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +84 -296
  2. package/dist/cli.js +25 -7
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/CodeViewer-CQU9mJ74.js +4 -0
  5. package/dist/client/assets/{TerminalPanel-BZKsaE9s.js → TerminalPanel-DtfVBsqI.js} +1 -1
  6. package/dist/client/assets/UnifiedDiffViewer-BZQQK99S.js +34 -0
  7. package/dist/client/assets/index-CbE7df66.js +2742 -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 +3 -3
  11. package/dist/config.js +33 -1
  12. package/dist/config.js.map +1 -1
  13. package/dist/plugin-api.d.ts +30 -2
  14. package/dist/server/app.js +16 -4
  15. package/dist/server/app.js.map +1 -1
  16. package/dist/server/configRoutes.js +4 -1
  17. package/dist/server/configRoutes.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/sessions/attachmentService.js +69 -11
  23. package/dist/server/sessions/attachmentService.js.map +1 -1
  24. package/dist/server/sessions/piSessionManagerGateway.js +20 -2
  25. package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
  26. package/dist/server/sessions/piSessionService.js +466 -33
  27. package/dist/server/sessions/piSessionService.js.map +1 -1
  28. package/dist/server/sessions/sessionCleanup.js +157 -0
  29. package/dist/server/sessions/sessionCleanup.js.map +1 -0
  30. package/dist/server/sessions/sessionRoutes.js +17 -0
  31. package/dist/server/sessions/sessionRoutes.js.map +1 -1
  32. package/dist/server/sessions/spawnSubsessionTool.js +6 -3
  33. package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
  34. package/dist/server/workspaceExplorerRoutes.js +55 -3
  35. package/dist/server/workspaceExplorerRoutes.js.map +1 -1
  36. package/dist/server/workspaces/fileContentService.js +112 -1
  37. package/dist/server/workspaces/fileContentService.js.map +1 -1
  38. package/dist/server/workspaces/pathSafety.js +16 -1
  39. package/dist/server/workspaces/pathSafety.js.map +1 -1
  40. package/dist/server/workspaces/projectPiWebConfig.js +6 -1
  41. package/dist/server/workspaces/projectPiWebConfig.js.map +1 -1
  42. package/dist/shared/apiTypes.d.ts +83 -6
  43. package/dist/shared/apiTypes.js +1 -0
  44. package/dist/shared/apiTypes.js.map +1 -1
  45. package/dist/shared/capabilities.js +3 -2
  46. package/dist/shared/capabilities.js.map +1 -1
  47. package/dist/shared/federatedRoutes.js +5 -0
  48. package/dist/shared/federatedRoutes.js.map +1 -1
  49. package/dist/shared/promptAttachments.js +30 -11
  50. package/dist/shared/promptAttachments.js.map +1 -1
  51. package/docs/config.md +40 -3
  52. package/docs/plugins.md +127 -3
  53. package/package.json +1 -1
  54. package/dist/client/assets/CodeViewer-Bv8l_MFi.js +0 -4
  55. package/dist/client/assets/index-C9oPLpm7.js +0 -2408
@@ -1,4 +1,4 @@
1
- import { readFile, writeFile } from "node:fs/promises";
1
+ import { open, readFile, writeFile } from "node:fs/promises";
2
2
  import { AuthStorage, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, defineTool, getAgentDir, ModelRegistry, SessionManager, } from "@earendil-works/pi-coding-agent";
3
3
  import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
4
4
  import { BUILTIN_COMMANDS } from "./builtinCommands.js";
@@ -14,6 +14,7 @@ import { cwdPathsEqual } from "../workingDirectory.js";
14
14
  import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
15
15
  import { createSubsessionToolDefinitions } from "./spawnSubsessionTool.js";
16
16
  import { buildTranscriptView } from "./subsessionTranscript.js";
17
+ import { planSessionCleanup, summarizeSessionCleanupExecution } from "./sessionCleanup.js";
17
18
  const noopLogger = { info() { } };
18
19
  function noop() {
19
20
  // Intentionally empty default unsubscribe callback.
@@ -100,6 +101,10 @@ export class PiSessionService {
100
101
  this.subsessionParents = new Map();
101
102
  /** Parent session id -> the set of tracked subsession ids it spawned. */
102
103
  this.subsessionChildren = new Map();
104
+ /** Tracked subsession id -> persisted recovery details for the child. */
105
+ this.subsessionLinks = new Map();
106
+ /** Parent id/file identities whose persisted links have already been loaded. */
107
+ this.subsessionHydratedParents = new Set();
103
108
  /**
104
109
  * Tracked subsession id -> whether a completion notification is armed.
105
110
  * Armed when the child starts working; firing on completion disarms it so a
@@ -112,14 +117,15 @@ export class PiSessionService {
112
117
  this.modelRegistry = deps.modelRegistry ?? ModelRegistry.create(AuthStorage.create());
113
118
  this.spawnTargets = deps.spawnTargets;
114
119
  this.logger = deps.logger ?? noopLogger;
120
+ this.now = deps.now ?? (() => new Date());
115
121
  // Subsessions are a beta capability gated behind their own flag, and they
116
122
  // also require the spawn capability (they share its project-scope resolver).
117
123
  const subsessionsActive = this.spawnTargets !== undefined && deps.subsessionsEnabled === true;
118
124
  this.createRuntime = deps.createRuntime ?? createDefaultRuntimeFactory(this.modelRegistry.authStorage, this.modelRegistry, this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : {
119
125
  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),
126
+ list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile),
127
+ check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
128
+ read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile),
123
129
  });
124
130
  this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
125
131
  this.workspaceActivity = deps.workspaceActivity;
@@ -138,6 +144,44 @@ export class PiSessionService {
138
144
  activeCount() {
139
145
  return this.active.size;
140
146
  }
147
+ async cleanupPreview(request) {
148
+ return previewResponseFromPlan(await this.cleanupPlan(request));
149
+ }
150
+ async cleanup(request) {
151
+ const plan = await this.cleanupPlan(request);
152
+ if (plan.deleteRecords.length > 0 && this.archiveStore.deleteArchived === undefined)
153
+ throw new Error("Archive store does not support deletion");
154
+ const archiveInputs = [];
155
+ const deleteRecords = [];
156
+ const skippedBusySessionIds = new Set(plan.skippedBusySessionIds);
157
+ for (const input of plan.archiveInputs) {
158
+ if (this.activeSessionHasWork(input.sessionId)) {
159
+ skippedBusySessionIds.add(input.sessionId);
160
+ continue;
161
+ }
162
+ await this.closeActive(input.sessionId);
163
+ await this.archiveStore.archive(input);
164
+ archiveInputs.push(input);
165
+ }
166
+ for (const record of plan.deleteRecords) {
167
+ if (this.activeSessionHasWork(record.sessionId)) {
168
+ skippedBusySessionIds.add(record.sessionId);
169
+ continue;
170
+ }
171
+ await this.closeActive(record.sessionId);
172
+ if (record.archivePath === undefined)
173
+ await this.ensureArchivedRecordMoved(record);
174
+ await this.archiveStore.deleteArchived?.(record.sessionId);
175
+ deleteRecords.push(record);
176
+ }
177
+ return summarizeSessionCleanupExecution({
178
+ archiveInputs,
179
+ deleteRecords,
180
+ thresholds: plan.thresholds,
181
+ generatedAt: plan.generatedAt,
182
+ skippedBusySessionIds: [...skippedBusySessionIds],
183
+ });
184
+ }
141
185
  async dispose() {
142
186
  clearInterval(this.heartbeat);
143
187
  this.clearCompactionDrainTimers();
@@ -148,6 +192,8 @@ export class PiSessionService {
148
192
  this.authLossWarnings.clear();
149
193
  this.subsessionParents.clear();
150
194
  this.subsessionChildren.clear();
195
+ this.subsessionLinks.clear();
196
+ this.subsessionHydratedParents.clear();
151
197
  this.subsessionNotifyArmed.clear();
152
198
  await Promise.all(activeSessions.map(async (active) => {
153
199
  active.unsubscribe();
@@ -220,68 +266,290 @@ export class PiSessionService {
220
266
  if (!decision.allowed)
221
267
  throw spawnTargetError(decision);
222
268
  const created = await this.start(decision.cwd, input.parentSessionFile);
223
- this.registerSubsession(input.parentSessionId, created.id);
269
+ const parentSessionFile = nonEmptyString(input.parentSessionFile);
270
+ const link = {
271
+ parentSessionId: input.parentSessionId,
272
+ childSessionId: created.id,
273
+ ...(created.path === "" ? {} : { childSessionFile: created.path }),
274
+ ...(parentSessionFile === undefined ? {} : { parentSessionFile }),
275
+ cwd: decision.cwd,
276
+ };
277
+ this.registerVerifiedSubsession(link);
278
+ this.persistSubsessionLink(link);
279
+ this.persistSubsessionChildMarker(input.parentSessionId, created.id);
224
280
  await this.prompt(created.id, input.prompt);
225
281
  this.logger.info({ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, "spawn_subsession started a tracked child session");
226
282
  return { sessionId: created.id, cwd: decision.cwd };
227
283
  }
228
284
  /** Summaries of the tracked subsessions spawned by `parentSessionId`. */
229
- async listSubsessions(parentSessionId) {
285
+ async listSubsessions(parentSessionId, parentSessionFile) {
286
+ const parentFile = nonEmptyString(parentSessionFile);
287
+ await this.hydrateSubsessionsForParent(parentSessionId, parentFile);
230
288
  const childIds = this.subsessionChildren.get(parentSessionId);
231
289
  if (childIds === undefined)
232
290
  return [];
233
- return Promise.all([...childIds].map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) })));
291
+ const authorizedChildIds = [...childIds].filter((childId) => this.subsessionLinkBelongsToParent(parentSessionId, parentFile, childId));
292
+ return Promise.all(authorizedChildIds.map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) })));
234
293
  }
235
294
  /** 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);
295
+ async checkSubsession(parentSessionId, sessionId, parentSessionFile) {
296
+ const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
238
297
  const messages = historyMessages(session);
239
298
  return {
240
299
  sessionId,
241
300
  cwd: session.sessionManager.getCwd(),
242
- status: await this.subsessionStatus(session),
301
+ status: this.subsessionStatus(session),
243
302
  finalText: finalAssistantText(messages),
244
303
  messageCount: messages.length,
245
304
  };
246
305
  }
247
306
  /** 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);
307
+ async readSubsession(parentSessionId, sessionId, query, parentSessionFile) {
308
+ const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile);
250
309
  const view = buildTranscriptView(historyMessages(session), query);
251
310
  return {
252
311
  sessionId,
253
312
  cwd: session.sessionManager.getCwd(),
254
- status: await this.subsessionStatus(session),
313
+ status: this.subsessionStatus(session),
255
314
  ...view,
256
315
  };
257
316
  }
258
317
  /** 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) {
318
+ async openSubsession(parentSessionId, sessionId, parentSessionFile) {
319
+ const parentFile = nonEmptyString(parentSessionFile);
320
+ await this.hydrateSubsessionsForParent(parentSessionId, parentFile);
321
+ if (this.subsessionParents.get(sessionId) !== parentSessionId || !this.subsessionLinkBelongsToParent(parentSessionId, parentFile, sessionId)) {
261
322
  throw new Error(`Session ${sessionId} is not one of your subsessions`);
262
323
  }
263
- return this.getOrOpen(sessionId);
324
+ return this.getOrOpenTrackedSubsession(sessionId);
325
+ }
326
+ subsessionLinkBelongsToParent(parentSessionId, parentSessionFile, childSessionId) {
327
+ const link = this.subsessionLinks.get(childSessionId);
328
+ if (link?.parentSessionId !== parentSessionId)
329
+ return false;
330
+ return parentSessionFile === undefined || trackedLinkParentFileMatches(link, parentSessionFile);
331
+ }
332
+ activeChildForSubsessionLink(link) {
333
+ const active = this.active.get(link.childSessionId);
334
+ if (active === undefined)
335
+ return undefined;
336
+ return activeSessionFileMatches(active, link.childSessionFile) ? active : undefined;
264
337
  }
265
- registerSubsession(parentSessionId, childSessionId) {
338
+ activeParentForSubsessionLink(link) {
339
+ const active = this.active.get(link.parentSessionId);
340
+ if (active === undefined)
341
+ return undefined;
342
+ return activeSessionFileMatches(active, link.parentSessionFile) ? active : undefined;
343
+ }
344
+ subsessionLinkForActiveChild(session) {
345
+ const childId = session.sessionId;
346
+ const parentId = this.subsessionParents.get(childId);
347
+ const link = this.subsessionLinks.get(childId);
348
+ if (parentId === undefined || link?.parentSessionId !== parentId)
349
+ return undefined;
350
+ return sessionFileMatches(session, link.childSessionFile) ? link : undefined;
351
+ }
352
+ registerVerifiedSubsession(link) {
353
+ const { childSessionId, parentSessionId } = link;
354
+ const previousParentId = this.subsessionParents.get(childSessionId);
355
+ if (previousParentId !== undefined && previousParentId !== parentSessionId) {
356
+ const previousChildren = this.subsessionChildren.get(previousParentId);
357
+ previousChildren?.delete(childSessionId);
358
+ if (previousChildren?.size === 0)
359
+ this.subsessionChildren.delete(previousParentId);
360
+ }
266
361
  this.subsessionParents.set(childSessionId, parentSessionId);
267
362
  const children = this.subsessionChildren.get(parentSessionId) ?? new Set();
268
363
  children.add(childSessionId);
269
364
  this.subsessionChildren.set(parentSessionId, children);
270
- this.subsessionNotifyArmed.set(childSessionId, false);
365
+ this.subsessionLinks.set(childSessionId, link);
366
+ if (!this.subsessionNotifyArmed.has(childSessionId))
367
+ this.subsessionNotifyArmed.set(childSessionId, false);
368
+ }
369
+ unregisterSubsession(childSessionId) {
370
+ const parentSessionId = this.subsessionParents.get(childSessionId);
371
+ this.subsessionParents.delete(childSessionId);
372
+ this.subsessionLinks.delete(childSessionId);
373
+ this.subsessionNotifyArmed.delete(childSessionId);
374
+ if (parentSessionId === undefined)
375
+ return;
376
+ const children = this.subsessionChildren.get(parentSessionId);
377
+ children?.delete(childSessionId);
378
+ if (children?.size === 0)
379
+ this.subsessionChildren.delete(parentSessionId);
380
+ }
381
+ persistSubsessionLink(link) {
382
+ const parent = this.activeParentForSubsessionLink(link)?.runtime.session;
383
+ if (parent === undefined)
384
+ return;
385
+ if (parent.sessionManager.appendCustomEntry === undefined)
386
+ return;
387
+ try {
388
+ parent.sessionManager.appendCustomEntry(SUBSESSION_LINK_CUSTOM_TYPE, persistedParentSubsessionLinkData(link));
389
+ }
390
+ catch (error) {
391
+ this.logger.info({ parentSessionId: link.parentSessionId, sessionId: link.childSessionId, error: error instanceof Error ? error.message : String(error) }, "failed to persist subsession link");
392
+ }
393
+ }
394
+ persistSubsessionChildMarker(parentSessionId, childSessionId) {
395
+ const child = this.active.get(childSessionId)?.runtime.session;
396
+ if (child === undefined)
397
+ return;
398
+ if (child.sessionManager.appendCustomEntry === undefined)
399
+ return;
400
+ try {
401
+ child.sessionManager.appendCustomEntry(SUBSESSION_CHILD_LINK_CUSTOM_TYPE, persistedChildSubsessionLinkData(parentSessionId, childSessionId));
402
+ }
403
+ catch (error) {
404
+ this.logger.info({ parentSessionId, sessionId: childSessionId, error: error instanceof Error ? error.message : String(error) }, "failed to persist subsession child marker");
405
+ }
406
+ }
407
+ async hydrateSubsessionsForParent(parentSessionId, parentSessionFile) {
408
+ const hydrationKey = subsessionHydratedParentKey(parentSessionId, parentSessionFile);
409
+ if (this.subsessionHydratedParents.has(hydrationKey))
410
+ return;
411
+ const activeParent = this.active.get(parentSessionId);
412
+ if (activeParent !== undefined && (parentSessionFile === undefined || activeSessionFileMatches(activeParent, parentSessionFile))) {
413
+ const activeParentFile = nonEmptyString(activeParent.runtime.session.sessionFile);
414
+ await this.registerPersistedSubsessionLinks(parentSessionId, activeParent.runtime.session.sessionManager, activeParentFile);
415
+ this.subsessionHydratedParents.add(hydrationKey);
416
+ return;
417
+ }
418
+ if (parentSessionFile === undefined)
419
+ return;
420
+ if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId) {
421
+ this.subsessionHydratedParents.add(hydrationKey);
422
+ return;
423
+ }
424
+ let parentManager;
425
+ try {
426
+ parentManager = this.sessionManager.open(parentSessionFile);
427
+ }
428
+ catch {
429
+ this.subsessionHydratedParents.add(hydrationKey);
430
+ return;
431
+ }
432
+ await this.registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile);
433
+ this.subsessionHydratedParents.add(hydrationKey);
434
+ }
435
+ async registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile) {
436
+ // Parent custom links are the authoritative recovery record: verify the
437
+ // exact live child file/header before tracking.
438
+ const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
439
+ for (const entry of entries) {
440
+ const link = parsePersistedParentSubsessionLink(entry);
441
+ if (link === undefined)
442
+ continue;
443
+ const verified = await this.verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link);
444
+ if (verified === undefined)
445
+ continue;
446
+ this.registerVerifiedSubsession(verified);
447
+ }
448
+ }
449
+ async verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link) {
450
+ if (parentSessionFile === undefined)
451
+ return undefined;
452
+ if (link.spawnedBySessionId !== parentSessionId)
453
+ return undefined;
454
+ if (!(await this.parentLinkHasValidChildTarget(parentSessionFile, link)))
455
+ return undefined;
456
+ return trackedSubsessionLinkFromParentLink(parentSessionId, link, parentSessionFile);
457
+ }
458
+ async parentLinkHasValidChildTarget(parentSessionFile, link) {
459
+ return link.spawnedSessionFile !== undefined
460
+ && await sessionFileHeaderMatches(link.spawnedSessionFile, { sessionId: link.spawnedSessionId, parentSessionFile });
461
+ }
462
+ async recoverSubsessionTrackingForOpenedSession(session) {
463
+ const link = await this.verifiedSubsessionLinkFromOpenedChild(session);
464
+ if (link === undefined)
465
+ return;
466
+ this.registerVerifiedSubsession(link);
467
+ }
468
+ async verifiedSubsessionLinkFromOpenedChild(session) {
469
+ // Child markers are only hints; the current child header and reciprocal
470
+ // parent custom link must agree on the exact ids and files before relinking.
471
+ const entries = session.sessionManager.getEntries?.() ?? session.sessionManager.getBranch();
472
+ let marker;
473
+ for (const entry of entries) {
474
+ const parsed = parsePersistedChildSubsessionLink(entry);
475
+ if (parsed?.spawnedSessionId === session.sessionId)
476
+ marker = parsed;
477
+ }
478
+ if (marker === undefined)
479
+ return undefined;
480
+ const childSessionFile = nonEmptyString(session.sessionFile);
481
+ if (childSessionFile === undefined)
482
+ return undefined;
483
+ const childHeader = await readSessionHeaderSummary(childSessionFile);
484
+ if (childHeader?.id !== session.sessionId)
485
+ return undefined;
486
+ const parentSessionFile = nonEmptyString(childHeader.parentSession);
487
+ if (parentSessionFile === undefined)
488
+ return undefined;
489
+ const parentHeader = await readSessionHeaderSummary(parentSessionFile);
490
+ if (parentHeader?.id !== marker.spawnedBySessionId)
491
+ return undefined;
492
+ const parentLink = this.findReciprocalParentSubsessionLink(parentSessionFile, marker.spawnedBySessionId, session.sessionId, childSessionFile);
493
+ if (parentLink === undefined)
494
+ return undefined;
495
+ return {
496
+ parentSessionId: marker.spawnedBySessionId,
497
+ childSessionId: session.sessionId,
498
+ childSessionFile,
499
+ parentSessionFile,
500
+ cwd: parentLink.cwd ?? session.sessionManager.getCwd(),
501
+ };
502
+ }
503
+ findReciprocalParentSubsessionLink(parentSessionFile, parentSessionId, childSessionId, childSessionFile) {
504
+ let parentManager;
505
+ try {
506
+ parentManager = this.sessionManager.open(parentSessionFile);
507
+ }
508
+ catch {
509
+ return undefined;
510
+ }
511
+ const entries = parentManager.getEntries?.() ?? parentManager.getBranch();
512
+ for (const entry of entries) {
513
+ const link = parsePersistedParentSubsessionLink(entry);
514
+ if (link === undefined)
515
+ continue;
516
+ if (link.spawnedBySessionId !== parentSessionId || link.spawnedSessionId !== childSessionId)
517
+ continue;
518
+ if (link.spawnedSessionFile === undefined || !sessionPathsEqual(link.spawnedSessionFile, childSessionFile))
519
+ continue;
520
+ return link;
521
+ }
522
+ return undefined;
523
+ }
524
+ async getOrOpenTrackedSubsession(sessionId) {
525
+ const link = this.subsessionLinks.get(sessionId);
526
+ if (link === undefined)
527
+ throw new Error("Session not found");
528
+ const active = this.activeChildForSubsessionLink(link);
529
+ if (active !== undefined)
530
+ return active.runtime.session;
531
+ if (link.childSessionFile !== undefined) {
532
+ if (!(await sessionFileHeaderMatches(link.childSessionFile, { sessionId, parentSessionFile: link.parentSessionFile })))
533
+ throw new Error("Session not found");
534
+ const sessionManager = this.sessionManager.open(link.childSessionFile);
535
+ return (await this.create(sessionManager, link.cwd ?? sessionManager.getCwd())).runtime.session;
536
+ }
537
+ throw new Error("Session not found");
271
538
  }
272
539
  async subsessionSummaryFields(childSessionId) {
273
- const active = this.active.get(childSessionId);
540
+ const link = this.subsessionLinks.get(childSessionId);
541
+ const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link);
274
542
  if (active !== undefined) {
275
- return { cwd: active.runtime.cwd, status: await this.subsessionStatus(active.runtime.session) };
543
+ return { cwd: active.runtime.cwd, status: this.subsessionStatus(active.runtime.session) };
276
544
  }
277
- const archived = await this.archiveStore.get(childSessionId);
278
- if (archived !== undefined)
279
- return { cwd: archived.cwd, status: "archived" };
545
+ if (link?.childSessionFile !== undefined && (await sessionFileHeaderMatches(link.childSessionFile, { sessionId: childSessionId, parentSessionFile: link.parentSessionFile }))) {
546
+ return { cwd: link.cwd ?? "", status: "idle" };
547
+ }
548
+ if (link?.cwd !== undefined)
549
+ return { cwd: link.cwd, status: "unknown" };
280
550
  return { cwd: "", status: "unknown" };
281
551
  }
282
- async subsessionStatus(session) {
283
- if (await this.archiveStore.isArchived(session.sessionId))
284
- return "archived";
552
+ subsessionStatus(session) {
285
553
  if (this.hasActiveWork(session))
286
554
  return "working";
287
555
  if (this.activities.get(session.sessionId)?.phase === "error")
@@ -295,10 +563,10 @@ export class PiSessionService {
295
563
  * parent is busy and delivers immediately when it is idle).
296
564
  */
297
565
  updateSubsessionTracking(session) {
298
- const childId = session.sessionId;
299
- const parentId = this.subsessionParents.get(childId);
300
- if (parentId === undefined)
566
+ const link = this.subsessionLinkForActiveChild(session);
567
+ if (link === undefined)
301
568
  return;
569
+ const childId = link.childSessionId;
302
570
  if (this.hasActiveWork(session)) {
303
571
  this.subsessionNotifyArmed.set(childId, true);
304
572
  return;
@@ -310,7 +578,23 @@ export class PiSessionService {
310
578
  const finalText = finalAssistantText(historyMessages(session));
311
579
  const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText);
312
580
  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);
581
+ void this.notifyParentOfSubsession(link.parentSessionId, childId, text);
582
+ }
583
+ async getOrOpenParentForSubsession(parentSessionId, childSessionId) {
584
+ const link = this.subsessionLinks.get(childSessionId);
585
+ if (link?.parentSessionId !== parentSessionId)
586
+ throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
587
+ const active = this.activeParentForSubsessionLink(link);
588
+ if (active !== undefined)
589
+ return active.runtime.session;
590
+ const parentSessionFile = link.parentSessionFile;
591
+ if (parentSessionFile === undefined)
592
+ throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
593
+ if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId) {
594
+ throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`);
595
+ }
596
+ const sessionManager = this.sessionManager.open(parentSessionFile);
597
+ return (await this.create(sessionManager, sessionManager.getCwd())).runtime.session;
314
598
  }
315
599
  /**
316
600
  * Deliver a subsession-completion notice to the parent as a system-authored
@@ -321,7 +605,7 @@ export class PiSessionService {
321
605
  */
322
606
  async notifyParentOfSubsession(parentId, childId, text) {
323
607
  try {
324
- const session = await this.getOrOpen(parentId);
608
+ const session = await this.getOrOpenParentForSubsession(parentId, childId);
325
609
  await session.sendCustomMessage({ customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: text, display: true, details: { sessionId: childId } }, { triggerTurn: true, deliverAs: "followUp" });
326
610
  this.publishStatus(session);
327
611
  }
@@ -459,7 +743,7 @@ export class PiSessionService {
459
743
  this.publishStatus(session);
460
744
  }
461
745
  async saveAttachments(ref, attachments, folder) {
462
- const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false });
746
+ const parsed = parsePromptAttachments(attachments, { enforceInlineSizeLimit: false, allowFileAttachments: true });
463
747
  if (parsed.length === 0)
464
748
  return [];
465
749
  await this.assertWritable(ref);
@@ -572,6 +856,8 @@ export class PiSessionService {
572
856
  if (sessionFile === undefined || sessionFile === "")
573
857
  throw new Error("Session is not persisted");
574
858
  await clearParentSession(sessionFile);
859
+ clearParentSessionHeader(session.sessionManager);
860
+ this.unregisterSubsession(session.sessionId);
575
861
  }
576
862
  async abort(ref) {
577
863
  const active = this.activeForLookup(ref);
@@ -592,6 +878,27 @@ export class PiSessionService {
592
878
  // Best-effort shutdown; callers that need errors await closeActive directly.
593
879
  });
594
880
  }
881
+ async cleanupPlan(request) {
882
+ const [sessions, archivedRecords] = await Promise.all([this.sessionManager.listAll?.() ?? [], this.archiveStore.list()]);
883
+ return planSessionCleanup({
884
+ sessions,
885
+ archivedRecords,
886
+ activeSessions: this.cleanupActiveSessionStatuses(),
887
+ thresholds: request.thresholds,
888
+ ...(request.projectCwds === undefined ? {} : { projectCwds: request.projectCwds }),
889
+ now: this.now(),
890
+ });
891
+ }
892
+ cleanupActiveSessionStatuses() {
893
+ return [...new Set(this.active.values())].map((active) => ({
894
+ sessionId: active.runtime.session.sessionId,
895
+ hasActiveWork: this.hasActiveWork(active.runtime.session),
896
+ }));
897
+ }
898
+ activeSessionHasWork(sessionId) {
899
+ const active = this.active.get(sessionId);
900
+ return active !== undefined && this.hasActiveWork(active.runtime.session);
901
+ }
595
902
  reconcilableSessionIds(cwd, listedSessionIds, archivedById) {
596
903
  const sessionIds = new Set(listedSessionIds);
597
904
  for (const active of new Set(this.active.values())) {
@@ -689,7 +996,8 @@ export class PiSessionService {
689
996
  // Disarm subsession notification before teardown so the abort below cannot
690
997
  // emit a "stopped working" event that notifies the parent (e.g. on archive).
691
998
  // The parent/children link is kept so the parent can still see the child.
692
- this.subsessionNotifyArmed.delete(sessionId);
999
+ if (this.subsessionLinkForActiveChild(active.runtime.session) !== undefined)
1000
+ this.subsessionNotifyArmed.delete(sessionId);
693
1001
  clearSessionQueue(active.runtime.session);
694
1002
  active.unsubscribe();
695
1003
  try {
@@ -747,8 +1055,10 @@ export class PiSessionService {
747
1055
  runtime.setRebindSession(async (session) => {
748
1056
  await this.bindSessionExtensions(session);
749
1057
  this.bindRuntime(active);
1058
+ await this.recoverSubsessionTrackingForOpenedSession(session);
750
1059
  });
751
1060
  this.active.set(runtime.session.sessionId, active);
1061
+ await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
752
1062
  this.publishStatus(runtime.session);
753
1063
  return active;
754
1064
  }
@@ -1049,6 +1359,15 @@ export class PiSessionService {
1049
1359
  return queuedMessagesFromSession(session, this.compactionQueuedMessages(session.sessionId)).some((message) => message.text === text);
1050
1360
  }
1051
1361
  }
1362
+ function previewResponseFromPlan(plan) {
1363
+ return {
1364
+ generatedAt: plan.generatedAt,
1365
+ thresholds: plan.thresholds,
1366
+ projects: plan.projects,
1367
+ totals: plan.totals,
1368
+ ...(plan.skippedBusySessionIds.length === 0 ? {} : { skippedBusySessionIds: plan.skippedBusySessionIds }),
1369
+ };
1370
+ }
1052
1371
  function modelToClientModel(model) {
1053
1372
  if (model === undefined)
1054
1373
  return {};
@@ -1195,6 +1514,111 @@ function archivedTimestamp(record) {
1195
1514
  function isDefined(value) {
1196
1515
  return value !== undefined;
1197
1516
  }
1517
+ function trackedSubsessionLinkFromParentLink(parentSessionId, link, parentSessionFile) {
1518
+ return {
1519
+ parentSessionId,
1520
+ childSessionId: link.spawnedSessionId,
1521
+ ...(link.spawnedSessionFile === undefined ? {} : { childSessionFile: link.spawnedSessionFile }),
1522
+ parentSessionFile,
1523
+ ...(link.cwd === undefined ? {} : { cwd: link.cwd }),
1524
+ };
1525
+ }
1526
+ function persistedParentSubsessionLinkData(link) {
1527
+ return {
1528
+ version: 1,
1529
+ spawnedBySessionId: link.parentSessionId,
1530
+ spawnedSessionId: link.childSessionId,
1531
+ ...(link.childSessionFile === undefined ? {} : { spawnedSessionFile: link.childSessionFile }),
1532
+ ...(link.cwd === undefined ? {} : { cwd: link.cwd }),
1533
+ };
1534
+ }
1535
+ function persistedChildSubsessionLinkData(parentSessionId, childSessionId) {
1536
+ return {
1537
+ version: 1,
1538
+ spawnedBySessionId: parentSessionId,
1539
+ spawnedSessionId: childSessionId,
1540
+ };
1541
+ }
1542
+ function parsePersistedParentSubsessionLink(entry) {
1543
+ if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_LINK_CUSTOM_TYPE)
1544
+ return undefined;
1545
+ const data = entry["data"];
1546
+ if (!isRecord(data))
1547
+ return undefined;
1548
+ const spawnedBySessionId = getString(data, "spawnedBySessionId");
1549
+ const spawnedSessionId = getString(data, "spawnedSessionId");
1550
+ if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "")
1551
+ return undefined;
1552
+ const spawnedSessionFile = getString(data, "spawnedSessionFile");
1553
+ const cwd = getString(data, "cwd");
1554
+ return {
1555
+ spawnedBySessionId,
1556
+ spawnedSessionId,
1557
+ ...(spawnedSessionFile === undefined || spawnedSessionFile === "" ? {} : { spawnedSessionFile }),
1558
+ ...(cwd === undefined || cwd === "" ? {} : { cwd }),
1559
+ };
1560
+ }
1561
+ function parsePersistedChildSubsessionLink(entry) {
1562
+ if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_CHILD_LINK_CUSTOM_TYPE)
1563
+ return undefined;
1564
+ const data = entry["data"];
1565
+ if (!isRecord(data))
1566
+ return undefined;
1567
+ const spawnedBySessionId = getString(data, "spawnedBySessionId");
1568
+ const spawnedSessionId = getString(data, "spawnedSessionId");
1569
+ if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "")
1570
+ return undefined;
1571
+ return { spawnedBySessionId, spawnedSessionId };
1572
+ }
1573
+ function nonEmptyString(value) {
1574
+ return value === undefined || value === "" ? undefined : value;
1575
+ }
1576
+ function subsessionHydratedParentKey(parentSessionId, parentSessionFile) {
1577
+ return `${parentSessionId}\0${parentSessionFile ?? ""}`;
1578
+ }
1579
+ function sessionPathsEqual(a, b) {
1580
+ return cwdPathsEqual(a, b);
1581
+ }
1582
+ function sessionFileMatches(session, expectedSessionFile) {
1583
+ const sessionFile = nonEmptyString(session.sessionFile);
1584
+ return sessionFile !== undefined && expectedSessionFile !== undefined && sessionPathsEqual(sessionFile, expectedSessionFile);
1585
+ }
1586
+ function activeSessionFileMatches(active, expectedSessionFile) {
1587
+ return sessionFileMatches(active.runtime.session, expectedSessionFile);
1588
+ }
1589
+ function trackedLinkParentFileMatches(link, parentSessionFile) {
1590
+ return link.parentSessionFile !== undefined && sessionPathsEqual(link.parentSessionFile, parentSessionFile);
1591
+ }
1592
+ async function readSessionHeaderSummary(sessionFile) {
1593
+ let file;
1594
+ try {
1595
+ file = await open(sessionFile, "r");
1596
+ const buffer = Buffer.alloc(4096);
1597
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
1598
+ const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n", 1)[0];
1599
+ if (firstLine === undefined || firstLine === "")
1600
+ return undefined;
1601
+ const header = JSON.parse(firstLine);
1602
+ if (!isRecord(header) || header["type"] !== "session" || typeof header["id"] !== "string")
1603
+ return undefined;
1604
+ const parentSession = getString(header, "parentSession");
1605
+ return { id: header["id"], ...(parentSession === undefined ? {} : { parentSession }) };
1606
+ }
1607
+ catch {
1608
+ return undefined;
1609
+ }
1610
+ finally {
1611
+ await file?.close().catch(() => undefined);
1612
+ }
1613
+ }
1614
+ async function sessionFileHeaderMatches(sessionFile, expected) {
1615
+ const header = await readSessionHeaderSummary(sessionFile);
1616
+ if (header?.id !== expected.sessionId)
1617
+ return false;
1618
+ if (expected.parentSessionFile === undefined)
1619
+ return true;
1620
+ return header.parentSession !== undefined && sessionPathsEqual(header.parentSession, expected.parentSessionFile);
1621
+ }
1198
1622
  async function clearParentSession(sessionFile) {
1199
1623
  const content = await readFile(sessionFile, "utf8");
1200
1624
  const newlineIndex = content.indexOf("\n");
@@ -1208,6 +1632,11 @@ async function clearParentSession(sessionFile) {
1208
1632
  delete header["parentSession"];
1209
1633
  await writeFile(sessionFile, `${JSON.stringify(header)}${rest}`, "utf8");
1210
1634
  }
1635
+ function clearParentSessionHeader(sessionManager) {
1636
+ const header = sessionManager.getHeader?.();
1637
+ if (header !== undefined && header !== null)
1638
+ delete header.parentSession;
1639
+ }
1211
1640
  function clearSessionQueue(session) {
1212
1641
  session.clearQueue();
1213
1642
  }
@@ -1262,6 +1691,10 @@ function historyMessages(session) {
1262
1691
  }
1263
1692
  return messages;
1264
1693
  }
1694
+ /** custom entry type used to persist parent -> child subsession links outside LLM context. */
1695
+ const SUBSESSION_LINK_CUSTOM_TYPE = "pi-web.subsession.link";
1696
+ /** custom entry type used to mark a child as created by spawn_subsession. */
1697
+ const SUBSESSION_CHILD_LINK_CUSTOM_TYPE = "pi-web.subsession.spawned";
1265
1698
  /** customType marking a parent-facing subsession-completion notice. */
1266
1699
  const SUBSESSION_NOTIFICATION_CUSTOM_TYPE = "subsession.completion";
1267
1700
  const SUBSESSION_NOTIFICATION_PREVIEW_CHARS = 2000;