@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.
- package/README.md +84 -296
- package/dist/cli.js +25 -7
- package/dist/cli.js.map +1 -1
- package/dist/client/assets/CodeViewer-CQU9mJ74.js +4 -0
- package/dist/client/assets/{TerminalPanel-BZKsaE9s.js → TerminalPanel-DtfVBsqI.js} +1 -1
- package/dist/client/assets/UnifiedDiffViewer-BZQQK99S.js +34 -0
- package/dist/client/assets/index-CbE7df66.js +2742 -0
- package/dist/client/assets/{vendor-editor-core-Bc1A7SO7.js → vendor-editor-core-C7aWiQy0.js} +2 -2
- package/dist/client/assets/{vendor-editor-languages-D0CnB2js.js → vendor-editor-languages-EW34FfVy.js} +1 -1
- package/dist/client/index.html +3 -3
- package/dist/config.js +33 -1
- package/dist/config.js.map +1 -1
- package/dist/plugin-api.d.ts +30 -2
- package/dist/server/app.js +16 -4
- package/dist/server/app.js.map +1 -1
- package/dist/server/configRoutes.js +4 -1
- package/dist/server/configRoutes.js.map +1 -1
- package/dist/server/machines/machineClient.js +33 -5
- package/dist/server/machines/machineClient.js.map +1 -1
- package/dist/server/machines/machineProxyRoutes.js +18 -3
- package/dist/server/machines/machineProxyRoutes.js.map +1 -1
- package/dist/server/sessions/attachmentService.js +69 -11
- package/dist/server/sessions/attachmentService.js.map +1 -1
- package/dist/server/sessions/piSessionManagerGateway.js +20 -2
- package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
- package/dist/server/sessions/piSessionService.js +466 -33
- package/dist/server/sessions/piSessionService.js.map +1 -1
- package/dist/server/sessions/sessionCleanup.js +157 -0
- package/dist/server/sessions/sessionCleanup.js.map +1 -0
- package/dist/server/sessions/sessionRoutes.js +17 -0
- package/dist/server/sessions/sessionRoutes.js.map +1 -1
- package/dist/server/sessions/spawnSubsessionTool.js +6 -3
- package/dist/server/sessions/spawnSubsessionTool.js.map +1 -1
- package/dist/server/workspaceExplorerRoutes.js +55 -3
- package/dist/server/workspaceExplorerRoutes.js.map +1 -1
- package/dist/server/workspaces/fileContentService.js +112 -1
- package/dist/server/workspaces/fileContentService.js.map +1 -1
- package/dist/server/workspaces/pathSafety.js +16 -1
- package/dist/server/workspaces/pathSafety.js.map +1 -1
- package/dist/server/workspaces/projectPiWebConfig.js +6 -1
- package/dist/server/workspaces/projectPiWebConfig.js.map +1 -1
- package/dist/shared/apiTypes.d.ts +83 -6
- package/dist/shared/apiTypes.js +1 -0
- package/dist/shared/apiTypes.js.map +1 -1
- package/dist/shared/capabilities.js +3 -2
- package/dist/shared/capabilities.js.map +1 -1
- package/dist/shared/federatedRoutes.js +5 -0
- package/dist/shared/federatedRoutes.js.map +1 -1
- package/dist/shared/promptAttachments.js +30 -11
- package/dist/shared/promptAttachments.js.map +1 -1
- package/docs/config.md +40 -3
- package/docs/plugins.md +127 -3
- package/package.json +1 -1
- package/dist/client/assets/CodeViewer-Bv8l_MFi.js +0 -4
- 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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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:
|
|
543
|
+
return { cwd: active.runtime.cwd, status: this.subsessionStatus(active.runtime.session) };
|
|
276
544
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
|
299
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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;
|