@jmfederico/pi-web 1.202608.1 → 1.202609.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +220 -108
- package/dist/cli.js.map +1 -1
- package/dist/client/apple-touch-icon-dev.png +0 -0
- package/dist/client/assets/{CodeViewer-mEiBZFA5.js → CodeViewer-CGAlg9S8.js} +1 -1
- package/dist/client/assets/{TerminalPanel-O9381Eme.js → TerminalPanel-xhJRhOas.js} +1 -1
- package/dist/client/assets/{index-uI2AGFUy.js → index-DXQKhn1P.js} +570 -335
- package/dist/client/assets/vendor-editor-core-CXO8gGab.js +12 -0
- package/dist/client/assets/vendor-editor-languages-CpW4sJsX.js +46 -0
- package/dist/client/favicon-dev.svg +8 -0
- package/dist/client/index.html +4 -4
- package/dist/client/pwa-icon-dev-192.png +0 -0
- package/dist/client/pwa-icon-dev-512.png +0 -0
- package/dist/config.js +21 -0
- package/dist/config.js.map +1 -1
- package/dist/environment.js +4 -0
- package/dist/environment.js.map +1 -0
- package/dist/nativeServices/installedServiceDefinitions.js +601 -0
- package/dist/nativeServices/installedServiceDefinitions.js.map +1 -0
- package/dist/nativeServices/serviceAction.js +174 -0
- package/dist/nativeServices/serviceAction.js.map +1 -0
- package/dist/nativeServices/serviceDoctor.js +374 -71
- package/dist/nativeServices/serviceDoctor.js.map +1 -1
- package/dist/{pi-web-plugins → pi-packages}/relays/markdownDocument.js +1 -1
- package/dist/pi-packages/relays/package.json +11 -0
- package/dist/{pi-web-plugins → pi-packages}/relays/pi-web-plugin.js +1 -1
- package/dist/pi-packages/relays/prompts/relay-worktree.md +28 -0
- package/dist/pi-packages/relays/prompts/relay.md +45 -0
- package/dist/{pi-web-plugins → pi-packages}/relays/relayDiscovery.js +3 -3
- package/dist/{pi-web-plugins → pi-packages}/relays/relaysPanelElement.js +2 -2
- package/dist/pi-packages/relays/skills/relay/SKILL.md +94 -0
- package/dist/pi-packages/relays/skills/relay-runner/SKILL.md +245 -0
- package/dist/pi-web-plugins/info/infoInternals.js +15 -0
- package/dist/pi-web-plugins/updates/pi-web-plugin.js +44 -34
- package/dist/piWebVersionReport.js +63 -10
- package/dist/piWebVersionReport.js.map +1 -1
- package/dist/server/app.js +19 -3
- package/dist/server/app.js.map +1 -1
- package/dist/server/configRoutes.js +6 -1
- package/dist/server/configRoutes.js.map +1 -1
- package/dist/server/deploymentIdentity.js +67 -0
- package/dist/server/deploymentIdentity.js.map +1 -0
- package/dist/server/deploymentIdentityRoutes.js +22 -0
- package/dist/server/deploymentIdentityRoutes.js.map +1 -0
- package/dist/server/knownAutoInstallPiPackages.js +30 -0
- package/dist/server/knownAutoInstallPiPackages.js.map +1 -0
- package/dist/server/machines/machineService.js +1 -0
- package/dist/server/machines/machineService.js.map +1 -1
- package/dist/server/notices/serverNoticeRoutes.js +34 -0
- package/dist/server/notices/serverNoticeRoutes.js.map +1 -0
- package/dist/server/notices/serverNoticeService.js +26 -0
- package/dist/server/notices/serverNoticeService.js.map +1 -0
- package/dist/server/notices/serverNoticeStore.js +78 -0
- package/dist/server/notices/serverNoticeStore.js.map +1 -0
- package/dist/server/piPackageIdentity.js +25 -0
- package/dist/server/piPackageIdentity.js.map +1 -0
- package/dist/server/piPackageService.js +69 -8
- package/dist/server/piPackageService.js.map +1 -1
- package/dist/server/piWebStatus.js +14 -3
- package/dist/server/piWebStatus.js.map +1 -1
- package/dist/server/projectTrustRoutes.js +80 -0
- package/dist/server/projectTrustRoutes.js.map +1 -0
- package/dist/server/projects/projectService.js +3 -1
- package/dist/server/projects/projectService.js.map +1 -1
- package/dist/server/sessiond/autoInstallPiPackages.js +63 -0
- package/dist/server/sessiond/autoInstallPiPackages.js.map +1 -0
- package/dist/server/sessiond/sessionProxyRoutes.js +2 -0
- package/dist/server/sessiond/sessionProxyRoutes.js.map +1 -1
- package/dist/server/sessiond/sessionServiceDependencies.js +1 -0
- package/dist/server/sessiond/sessionServiceDependencies.js.map +1 -1
- package/dist/server/sessiond.js +36 -5
- package/dist/server/sessiond.js.map +1 -1
- package/dist/server/sessions/attachmentService.js +1 -5
- package/dist/server/sessions/attachmentService.js.map +1 -1
- package/dist/server/sessions/piSessionManagerGateway.js +171 -1
- package/dist/server/sessions/piSessionManagerGateway.js.map +1 -1
- package/dist/server/sessions/piSessionService.js +535 -20
- package/dist/server/sessions/piSessionService.js.map +1 -1
- package/dist/server/sessions/sessionModelScope.js +160 -0
- package/dist/server/sessions/sessionModelScope.js.map +1 -0
- package/dist/server/sessions/sessionNameGenerator.js +6 -6
- package/dist/server/sessions/sessionNameGenerator.js.map +1 -1
- package/dist/server/sessions/sessionRoutes.js +41 -0
- package/dist/server/sessions/sessionRoutes.js.map +1 -1
- package/dist/server/sessions/transcriptBranchCache.js +81 -0
- package/dist/server/sessions/transcriptBranchCache.js.map +1 -0
- package/dist/server/storage/piPackageDismissalStore.js +78 -0
- package/dist/server/storage/piPackageDismissalStore.js.map +1 -0
- package/dist/server/terminals/terminalService.js +18 -1
- package/dist/server/terminals/terminalService.js.map +1 -1
- package/dist/server/workspaces/projectPiWebConfig.js +6 -1
- package/dist/server/workspaces/projectPiWebConfig.js.map +1 -1
- package/dist/server/workspaces/workspaceRemovalService.js +16 -5
- package/dist/server/workspaces/workspaceRemovalService.js.map +1 -1
- package/dist/shared/apiTypes.js.map +1 -1
- package/dist/shared/federatedRoutes.js +10 -0
- package/dist/shared/federatedRoutes.js.map +1 -1
- package/dist/shared/piWebStatusParsing.js +4 -0
- package/dist/shared/piWebStatusParsing.js.map +1 -1
- package/dist/shared/pluginApiTypes.d.ts +2 -0
- package/docs/config.md +45 -4
- package/docs/plugins.md +25 -11
- package/package.json +6 -6
- package/dist/client/assets/vendor-editor-core-vUi74DnD.js +0 -12
- package/dist/client/assets/vendor-editor-languages-DoaXUodB.js +0 -46
- package/dist/pi-web-plugins/relays/package.json +0 -9
- /package/dist/{pi-web-plugins → pi-packages}/relays/vendor/README.md +0 -0
- /package/dist/{pi-web-plugins → pi-packages}/relays/vendor/marked.esm.js +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
-
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, defineTool, readStoredCredential, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createEditToolDefinition, defineTool, hasTrustRequiringProjectResources, ProjectTrustStore, readStoredCredential, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { projectBrowserMessage } from "../browserMessageProjection.js";
|
|
6
6
|
import { pageMessagesAtSafeBoundary } from "./messagePaging.js";
|
|
7
7
|
import { BUILTIN_COMMANDS } from "./builtinCommands.js";
|
|
@@ -12,6 +12,7 @@ import { findArchiveCandidateByIdOrPrefix, planSessionArchiveTree } from "./sess
|
|
|
12
12
|
import { deterministicSessionName, fallbackSessionName, generateShortSessionName } from "./sessionNameGenerator.js";
|
|
13
13
|
import { computeEditPreview } from "./editPreview.js";
|
|
14
14
|
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
|
15
|
+
import { loadEffectiveProjectAttachmentsConfig } from "../workspaces/projectPiWebConfig.js";
|
|
15
16
|
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
|
|
16
17
|
import { ASK_USER_ANSWERS_CUSTOM_TYPE, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
|
|
17
18
|
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
|
@@ -28,6 +29,7 @@ import { planSessionCleanup, summarizeSessionCleanupExecution } from "./sessionC
|
|
|
28
29
|
import { SessionNotificationStore, } from "./sessionNotificationStore.js";
|
|
29
30
|
import { plainTextTheme } from "./plainTextTheme.js";
|
|
30
31
|
import { SessionUnreadStore } from "./sessionUnreadStore.js";
|
|
32
|
+
import { applyEnabledModelToggle, catalogWithEnabledFirst, modelScopeId, persistedEnabledModelPatterns, resolveEnabledModelIds, resolveSessionModelOptions, scopedModelsFromEnabledIds } from "./sessionModelScope.js";
|
|
31
33
|
const noopLogger = { info() { } };
|
|
32
34
|
const DEFAULT_UNREAD_PUBLICATION_RETRY_MS = 1_000;
|
|
33
35
|
/**
|
|
@@ -46,6 +48,15 @@ const STARTUP_PHASE_EXTENSIONS = "Loading session extensions";
|
|
|
46
48
|
const STARTUP_CONCURRENT_CATALOG_REFRESH = "provider model lists are refreshing";
|
|
47
49
|
const MAX_UNREAD_PUBLICATION_RETRY_MS = 30_000;
|
|
48
50
|
const MAX_PENDING_UNREAD_MUTATIONS = SESSION_UNREAD_LIMIT + 1;
|
|
51
|
+
/**
|
|
52
|
+
* Upper bound on how often one idle runtime re-resolves its transcript file.
|
|
53
|
+
* A runtime created in memory and never persisted has no session file, so
|
|
54
|
+
* every poll would otherwise rescan the session directory; throttling keeps
|
|
55
|
+
* steady-state polling of such a session O(1). The window can only delay
|
|
56
|
+
* noticing a file the runtime did not write itself: once the runtime
|
|
57
|
+
* persists, `getSessionFile()` answers and this throttle is bypassed.
|
|
58
|
+
*/
|
|
59
|
+
const IDLE_SESSION_FILE_RESOLUTION_THROTTLE_MS = 30_000;
|
|
49
60
|
function noop() {
|
|
50
61
|
// Intentionally empty default unsubscribe callback.
|
|
51
62
|
}
|
|
@@ -281,13 +292,130 @@ export function createPiWebCustomToolDefinitions(cwd, delegationEnabled, spawn,
|
|
|
281
292
|
...(askUser === undefined ? [] : [createAskUserToolDefinition(askUser)]),
|
|
282
293
|
];
|
|
283
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* Run the `project_trust` event over a pre-trust extension set, mirroring
|
|
297
|
+
* `emitProjectTrustEvent` in the SDK's `dist/core/extensions/runner.js`. That
|
|
298
|
+
* helper is not part of the package's public exports (the main index exports
|
|
299
|
+
* only the `ProjectTrust*` types and `ProjectTrustStore`, and the package's
|
|
300
|
+
* `exports` map blocks subpath imports, so `resolveProjectTrusted`/
|
|
301
|
+
* `emitProjectTrustEvent` are not callable from here), so PI WEB reimplements
|
|
302
|
+
* its documented decision loop over the SDK-provided extension objects: per
|
|
303
|
+
* extension, the registered `project_trust` handlers run in order; the first
|
|
304
|
+
* handler returning `yes`/`no` decides and `undecided` falls through to the
|
|
305
|
+
* next handler/extension; a throwing handler is collected as an error and
|
|
306
|
+
* later handlers still get their chance.
|
|
307
|
+
*/
|
|
308
|
+
export async function emitWebProjectTrustEvent(extensionsResult, event, ctx) {
|
|
309
|
+
const errors = [];
|
|
310
|
+
for (const extension of extensionsResult.extensions) {
|
|
311
|
+
// A single extension may register multiple handlers for the same event.
|
|
312
|
+
// The handlers map is keyed exactly as the extension registered it, so a
|
|
313
|
+
// `project_trust` key guarantees `ProjectTrustHandler` entries — the same
|
|
314
|
+
// assumption the SDK's emitProjectTrustEvent makes.
|
|
315
|
+
const handlers = extension.handlers.get("project_trust");
|
|
316
|
+
if (handlers === undefined || handlers.length === 0)
|
|
317
|
+
continue;
|
|
318
|
+
for (const handler of handlers) {
|
|
319
|
+
try {
|
|
320
|
+
const handlerResult = await handler(event, ctx);
|
|
321
|
+
// The SDK reads `trusted` straight off the handler result, so a
|
|
322
|
+
// non-object would throw there; PI WEB reports it as a handler error
|
|
323
|
+
// and lets the next handler/extension try.
|
|
324
|
+
if (typeof handlerResult !== "object" || handlerResult === null) {
|
|
325
|
+
errors.push({ extensionPath: extension.path, error: "project_trust handler returned a non-object result" });
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const trusted = "trusted" in handlerResult ? handlerResult.trusted : undefined;
|
|
329
|
+
if (trusted === "undecided") {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
// Rebuild the decision so only the documented `trusted`/`remember`
|
|
333
|
+
// fields carry over — the SDK's resolver reads exactly those two.
|
|
334
|
+
const remember = "remember" in handlerResult ? handlerResult.remember : undefined;
|
|
335
|
+
return {
|
|
336
|
+
result: {
|
|
337
|
+
trusted: trusted === "yes" ? "yes" : "no",
|
|
338
|
+
...(remember === true ? { remember: true } : {}),
|
|
339
|
+
},
|
|
340
|
+
errors,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
errors.push({
|
|
345
|
+
extensionPath: extension.path,
|
|
346
|
+
error: error instanceof Error ? error.message : String(error),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return { errors };
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* PI WEB's headless project-trust context. `hasUI` is false because there is
|
|
355
|
+
* no browser trust prompt (chartered behavior: `ask` never loads untrusted
|
|
356
|
+
* resources), so the UI methods are inert — the same no-UI shape the SDK
|
|
357
|
+
* passes when pi runs without a trust UI. The host mode is `rpc`, mirroring
|
|
358
|
+
* how PI WEB binds its session extension contexts.
|
|
359
|
+
*/
|
|
360
|
+
function webProjectTrustContext(cwd) {
|
|
361
|
+
return {
|
|
362
|
+
cwd,
|
|
363
|
+
mode: "rpc",
|
|
364
|
+
hasUI: false,
|
|
365
|
+
ui: {
|
|
366
|
+
select: () => Promise.resolve(undefined),
|
|
367
|
+
confirm: () => Promise.resolve(false),
|
|
368
|
+
input: () => Promise.resolve(undefined),
|
|
369
|
+
notify: () => undefined,
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Resolve whether a workspace's project-local `.pi/` resources may load, the
|
|
375
|
+
* way `pi` resolves it — a faithful mirror of the SDK's `resolveProjectTrusted`
|
|
376
|
+
* (`dist/core/project-trust.js`, also not a public export). PI WEB has no
|
|
377
|
+
* browser trust prompt, so the precedence is, in order:
|
|
378
|
+
*
|
|
379
|
+
* 1. Nothing trust-requiring under `cwd` → trusted.
|
|
380
|
+
* 2. Pre-trust extensions (user/global — project-local ones are not loaded
|
|
381
|
+
* yet) may decide via the `project_trust` event; `remember: true` persists
|
|
382
|
+
* the decision to the agent dir's `trust.json`. Handler errors are reported
|
|
383
|
+
* through {@link WebProjectTrustResolution.onExtensionError} and never
|
|
384
|
+
* abort resolution.
|
|
385
|
+
* 3. Otherwise the saved `trust.json` decision wins.
|
|
386
|
+
* 4. Otherwise `defaultProjectTrust` decides (`always` trusts; `never`/`ask`
|
|
387
|
+
* do not — `ask` cannot prompt in the browser, matching `pi` run without a
|
|
388
|
+
* trust UI).
|
|
389
|
+
*/
|
|
390
|
+
export async function resolveWebProjectTrusted(resolution) {
|
|
391
|
+
const { cwd, trustStore, settingsManager } = resolution;
|
|
392
|
+
if (!hasTrustRequiringProjectResources(cwd))
|
|
393
|
+
return true;
|
|
394
|
+
if (resolution.extensionsResult) {
|
|
395
|
+
const { result, errors } = await emitWebProjectTrustEvent(resolution.extensionsResult, { type: "project_trust", cwd }, webProjectTrustContext(cwd));
|
|
396
|
+
for (const error of errors) {
|
|
397
|
+
resolution.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`);
|
|
398
|
+
}
|
|
399
|
+
if (result) {
|
|
400
|
+
const trusted = result.trusted === "yes";
|
|
401
|
+
if (result.remember === true) {
|
|
402
|
+
trustStore.set(cwd, trusted);
|
|
403
|
+
}
|
|
404
|
+
return trusted;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const saved = trustStore.get(cwd);
|
|
408
|
+
if (saved !== null)
|
|
409
|
+
return saved;
|
|
410
|
+
return settingsManager.getDefaultProjectTrust() === "always";
|
|
411
|
+
}
|
|
284
412
|
/**
|
|
285
413
|
* Resource-loader options that append PI WEB's own system-prompt sections.
|
|
286
414
|
*
|
|
287
415
|
* `appendSystemPromptOverride` composes with what the loader already resolved,
|
|
288
416
|
* so the operator's `SYSTEM.md` / `APPEND_SYSTEM.md` files keep their content
|
|
289
417
|
* and PI WEB's sections land after them. Returns `undefined` when there is
|
|
290
|
-
* nothing to
|
|
418
|
+
* nothing to append, leaving the loader exactly as pi configures it.
|
|
291
419
|
*/
|
|
292
420
|
export function piWebResourceLoaderOptions(appendSystemPromptSections) {
|
|
293
421
|
if (appendSystemPromptSections.length === 0)
|
|
@@ -297,12 +425,49 @@ export function piWebResourceLoaderOptions(appendSystemPromptSections) {
|
|
|
297
425
|
function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subsessions, askUser, appendSystemPromptSections = []) {
|
|
298
426
|
const resourceLoaderOptions = piWebResourceLoaderOptions(appendSystemPromptSections);
|
|
299
427
|
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled }) => {
|
|
428
|
+
// PI WEB always honors pi's project-trust model. When the workspace ships
|
|
429
|
+
// trust-requiring resources, trust is resolved exactly once, mirroring the
|
|
430
|
+
// SDK's flow: the resource loader first loads the pre-trust extension set
|
|
431
|
+
// (user/global; project-local ones stay out) and calls back with it, so
|
|
432
|
+
// those extensions may decide via the `project_trust` event; the resolved
|
|
433
|
+
// value then lands in the SettingsManager before any project-local
|
|
434
|
+
// resource (extensions, packages, settings, prompts) loads. With no
|
|
435
|
+
// browser trust prompt, an untrusted project's resources are skipped
|
|
436
|
+
// (matching `pi` run without a UI). Projects without trust-requiring
|
|
437
|
+
// resources skip resolution entirely and are trusted, as before.
|
|
438
|
+
const projectTrustRequiring = hasTrustRequiringProjectResources(cwd);
|
|
439
|
+
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: !projectTrustRequiring });
|
|
440
|
+
// Pre-session-creation trust failures (`project_trust` handler errors)
|
|
441
|
+
// land in the runtime diagnostics next to the services diagnostics,
|
|
442
|
+
// exactly as the CLI appends its project-trust diagnostics.
|
|
443
|
+
const projectTrustDiagnostics = [];
|
|
300
444
|
const services = await createAgentSessionServices({
|
|
301
445
|
cwd,
|
|
302
446
|
agentDir,
|
|
303
447
|
modelRuntime,
|
|
448
|
+
settingsManager,
|
|
304
449
|
...(resourceLoaderOptions === undefined ? {} : { resourceLoaderOptions }),
|
|
450
|
+
...(projectTrustRequiring
|
|
451
|
+
? {
|
|
452
|
+
resourceLoaderReloadOptions: {
|
|
453
|
+
resolveProjectTrust: async ({ extensionsResult }) => resolveWebProjectTrusted({
|
|
454
|
+
cwd,
|
|
455
|
+
trustStore: new ProjectTrustStore(agentDir),
|
|
456
|
+
settingsManager,
|
|
457
|
+
extensionsResult,
|
|
458
|
+
onExtensionError: (message) => projectTrustDiagnostics.push({ type: "warning", message }),
|
|
459
|
+
}),
|
|
460
|
+
},
|
|
461
|
+
}
|
|
462
|
+
: {}),
|
|
305
463
|
});
|
|
464
|
+
const modelOptions = await resolveSessionModelOptions({
|
|
465
|
+
services,
|
|
466
|
+
hasExistingSession: sessionManager.buildSessionContext().messages.length > 0,
|
|
467
|
+
...(initialModel === undefined ? {} : { initialModel }),
|
|
468
|
+
...(initialThinkingLevel === undefined ? {} : { initialThinkingLevel }),
|
|
469
|
+
});
|
|
470
|
+
services.diagnostics.push(...modelOptions.diagnostics);
|
|
306
471
|
const resolvedDelegationToolsEnabled = delegationToolsEnabled
|
|
307
472
|
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
|
|
308
473
|
const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, askUser);
|
|
@@ -311,10 +476,11 @@ function createDefaultRuntimeFactory(modelRuntime, sessionManagers, spawn, subse
|
|
|
311
476
|
sessionManager,
|
|
312
477
|
customTools,
|
|
313
478
|
...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
|
|
314
|
-
...(
|
|
315
|
-
...(
|
|
479
|
+
...(modelOptions.model === undefined ? {} : { model: modelOptions.model }),
|
|
480
|
+
...(modelOptions.thinkingLevel === undefined ? {} : { thinkingLevel: modelOptions.thinkingLevel }),
|
|
481
|
+
...(modelOptions.scopedModels.length === 0 ? {} : { scopedModels: modelOptions.scopedModels }),
|
|
316
482
|
});
|
|
317
|
-
return { ...result, services, diagnostics: services.diagnostics };
|
|
483
|
+
return { ...result, services, diagnostics: [...projectTrustDiagnostics, ...services.diagnostics] };
|
|
318
484
|
};
|
|
319
485
|
}
|
|
320
486
|
function createPiWebEditToolDefinition(cwd) {
|
|
@@ -354,8 +520,24 @@ export class PiSessionService {
|
|
|
354
520
|
this.activities = new Map();
|
|
355
521
|
/** Runtime-identity gate held while Pi may await abandoned-branch summarization. */
|
|
356
522
|
this.treeNavigations = new WeakSet();
|
|
523
|
+
/**
|
|
524
|
+
* Bare live leaf selected without an appended summary entry. Recording that
|
|
525
|
+
* leaf distinguishes the unpersisted move from a later runtime append that
|
|
526
|
+
* can anchor the selected branch on disk.
|
|
527
|
+
*/
|
|
528
|
+
this.unpersistedTreeBranchLeaves = new WeakMap();
|
|
357
529
|
/** Counts async operations that may append an entry before they settle. */
|
|
358
530
|
this.sessionEntryMutationCounts = new WeakMap();
|
|
531
|
+
/** Settings-wide queue preventing enabled-model read/modify/write races across sessions. */
|
|
532
|
+
this.modelScopeMutationQueue = Promise.resolve();
|
|
533
|
+
/** Global and workspace-owned enabled-model states; live session scopes project one entry lazily. */
|
|
534
|
+
this.modelScopeStates = new Map();
|
|
535
|
+
/** Coalesces initialization reads independently for the global and each workspace scope. */
|
|
536
|
+
this.modelScopeInitializations = new Map();
|
|
537
|
+
/** Monotonic revision published for global scope changes and consumed by clients. */
|
|
538
|
+
this.modelScopeRevision = 0;
|
|
539
|
+
/** Last scope/catalog revision projected into each runtime, synchronized lazily on use. */
|
|
540
|
+
this.modelScopeCache = new WeakMap();
|
|
359
541
|
/** Runtime/session-identity reservations for operations that must not overlap tree navigation. */
|
|
360
542
|
this.treeExclusiveRuntimeOperationCounts = new WeakMap();
|
|
361
543
|
this.treeExclusiveSessionOperationCounts = new Map();
|
|
@@ -379,6 +561,8 @@ export class PiSessionService {
|
|
|
379
561
|
*/
|
|
380
562
|
this.subsessionNotifyArmed = new Map();
|
|
381
563
|
this.notificationGenerationBySession = new WeakMap();
|
|
564
|
+
/** Last idle-poll transcript file resolution per runtime, throttled; entries die with their runtime. */
|
|
565
|
+
this.idleSessionFileResolutions = new WeakMap();
|
|
382
566
|
/** The parked extension Promise resolvers behind the store's open dialogs. */
|
|
383
567
|
this.dialogWaiters = new ExtensionDialogWaiters();
|
|
384
568
|
this.pendingUnreadMutations = [];
|
|
@@ -398,6 +582,7 @@ export class PiSessionService {
|
|
|
398
582
|
this.pendingExtensionDialogStore = deps.pendingExtensionDialogStore ?? new PendingExtensionDialogStore();
|
|
399
583
|
this.extensionDialogsTimeoutMs = deps.extensionDialogsTimeoutMs ?? DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS;
|
|
400
584
|
this.catalogRefreshStatus = deps.catalogRefreshStatus;
|
|
585
|
+
this.config = deps.config;
|
|
401
586
|
this.unreadPublicationRetryInitialMs = Math.max(0, deps.unreadPublicationRetryDelayMs ?? DEFAULT_UNREAD_PUBLICATION_RETRY_MS);
|
|
402
587
|
this.unreadPublicationRetryDelayMs = this.unreadPublicationRetryInitialMs;
|
|
403
588
|
// Subsessions are gated behind their own flag, and they
|
|
@@ -678,17 +863,117 @@ export class PiSessionService {
|
|
|
678
863
|
};
|
|
679
864
|
}
|
|
680
865
|
/**
|
|
681
|
-
*
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
|
|
866
|
+
* Resolve the settings domain for a session. Pi's settings manager deep-merges
|
|
867
|
+
* objects, but an `enabledModels` array in `.pi/settings.json` replaces the
|
|
868
|
+
* global array, so that workspace gets its own effective scope and is not
|
|
869
|
+
* affected by global picker edits.
|
|
870
|
+
*/
|
|
871
|
+
modelScopeContext(session) {
|
|
872
|
+
const projectSettings = session.settingsManager.getProjectSettings?.() ?? {};
|
|
873
|
+
if (!Object.hasOwn(projectSettings, "enabledModels")) {
|
|
874
|
+
return { key: "global", source: "global", editable: true };
|
|
875
|
+
}
|
|
876
|
+
const workspace = canonicalizeStoredCwd(session.sessionManager.getCwd());
|
|
877
|
+
return { key: `workspace:${workspace}`, source: "workspace", editable: false };
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Read the effective scope for one settings domain. Global sessions share one
|
|
881
|
+
* snapshot; project-overridden sessions share a snapshot keyed by workspace.
|
|
882
|
+
* Neither path enumerates or mutates other active sessions.
|
|
883
|
+
*/
|
|
884
|
+
async modelScopeSnapshot(session, context = this.modelScopeContext(session)) {
|
|
885
|
+
const existing = this.modelScopeStates.get(context.key);
|
|
886
|
+
if (existing !== undefined)
|
|
887
|
+
return existing;
|
|
888
|
+
const pending = this.modelScopeInitializations.get(context.key);
|
|
889
|
+
if (pending !== undefined)
|
|
890
|
+
return pending;
|
|
891
|
+
const initialization = (async () => {
|
|
892
|
+
const enabledIds = await resolveEnabledModelIds(sessionScopeSource(session));
|
|
893
|
+
const snapshot = {
|
|
894
|
+
enabledIds: enabledIds === null ? null : [...enabledIds],
|
|
895
|
+
revision: 0,
|
|
896
|
+
};
|
|
897
|
+
this.modelScopeStates.set(context.key, snapshot);
|
|
898
|
+
return snapshot;
|
|
899
|
+
})();
|
|
900
|
+
this.modelScopeInitializations.set(context.key, initialization);
|
|
901
|
+
try {
|
|
902
|
+
return await initialization;
|
|
903
|
+
}
|
|
904
|
+
finally {
|
|
905
|
+
if (this.modelScopeInitializations.get(context.key) === initialization)
|
|
906
|
+
this.modelScopeInitializations.delete(context.key);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Project one settings-domain scope into one SDK session only when that
|
|
911
|
+
* session is used. `PiAgentSession` needs an in-memory array for cycling, but
|
|
912
|
+
* that array is a cache; the keyed snapshot above is the source of truth.
|
|
913
|
+
*/
|
|
914
|
+
async synchronizeSessionModelScope(session, available) {
|
|
915
|
+
const context = this.modelScopeContext(session);
|
|
916
|
+
const snapshot = await this.modelScopeSnapshot(session, context);
|
|
917
|
+
const catalogKey = available.map(modelScopeId).join("\0");
|
|
918
|
+
const cached = this.modelScopeCache.get(session);
|
|
919
|
+
if (cached?.scopeKey === context.key && cached.revision === snapshot.revision && cached.catalogKey === catalogKey)
|
|
920
|
+
return;
|
|
921
|
+
session.setScopedModels(scopedModelsFromEnabledIds(available, snapshot.enabledIds, session.scopedModels));
|
|
922
|
+
this.modelScopeCache.set(session, { scopeKey: context.key, revision: snapshot.revision, catalogKey });
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* The models a session may pick from: its synchronized scoped set when
|
|
926
|
+
* model-scoped, otherwise the runtime's available snapshot. Refreshes the
|
|
927
|
+
* shared runtime catalog first so callers see newly configured providers and
|
|
928
|
+
* models. The refresh stays local (`allowNetwork: false`); network refreshes
|
|
929
|
+
* belong to the bounded background catalog refresher, not this request path.
|
|
686
930
|
*/
|
|
687
931
|
async sessionModelCandidates(session) {
|
|
688
932
|
await session.modelRuntime.refresh({ allowNetwork: false });
|
|
933
|
+
const available = session.modelRuntime.getAvailableSnapshot();
|
|
934
|
+
await this.synchronizeSessionModelScope(session, available);
|
|
689
935
|
return session.scopedModels.length > 0
|
|
690
936
|
? session.scopedModels.map((scoped) => scoped.model)
|
|
691
|
-
:
|
|
937
|
+
: available;
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* The session machine's full available catalog with per-model enabled state,
|
|
941
|
+
* ordered enabled-first for scope semantics while retaining each model's
|
|
942
|
+
* natural catalog index for stable picker placement. The enabled state comes
|
|
943
|
+
* from the keyed scope snapshot rather than a session-local SettingsManager.
|
|
944
|
+
*/
|
|
945
|
+
async enabledModelCatalog(session) {
|
|
946
|
+
await session.modelRuntime.refresh({ allowNetwork: false });
|
|
947
|
+
const available = session.modelRuntime.getAvailableSnapshot();
|
|
948
|
+
await this.synchronizeSessionModelScope(session, available);
|
|
949
|
+
const context = this.modelScopeContext(session);
|
|
950
|
+
const enabledIds = (await this.modelScopeSnapshot(session, context)).enabledIds;
|
|
951
|
+
return catalogWithEnabledFirst(available, enabledIds);
|
|
952
|
+
}
|
|
953
|
+
runModelScopeMutation(operation) {
|
|
954
|
+
const queued = this.modelScopeMutationQueue.then(operation);
|
|
955
|
+
this.modelScopeMutationQueue = queued.then(() => undefined, () => undefined);
|
|
956
|
+
return queued;
|
|
957
|
+
}
|
|
958
|
+
/** Persist one global enabled-model selection and publish its new revision. */
|
|
959
|
+
applyEnabledModelScope(session, available, enabledIds) {
|
|
960
|
+
const availableIds = available.map(modelScopeId);
|
|
961
|
+
const persisted = persistedEnabledModelPatterns(enabledIds, availableIds);
|
|
962
|
+
const nextState = {
|
|
963
|
+
// Pi normalizes a scope covering the complete current catalog to an
|
|
964
|
+
// omitted setting, which is the global canonical all-enabled state.
|
|
965
|
+
enabledIds: persisted === undefined ? null : enabledIds === null ? [] : [...enabledIds],
|
|
966
|
+
revision: ++this.modelScopeRevision,
|
|
967
|
+
};
|
|
968
|
+
session.settingsManager.setEnabledModels(persisted);
|
|
969
|
+
this.modelScopeStates.set("global", nextState);
|
|
970
|
+
session.setScopedModels(scopedModelsFromEnabledIds(available, nextState.enabledIds, session.scopedModels));
|
|
971
|
+
this.modelScopeCache.set(session, {
|
|
972
|
+
scopeKey: "global",
|
|
973
|
+
revision: nextState.revision,
|
|
974
|
+
catalogKey: available.map(modelScopeId).join("\0"),
|
|
975
|
+
});
|
|
976
|
+
this.events.publishGlobal({ type: "models.changed", revision: nextState.revision });
|
|
692
977
|
}
|
|
693
978
|
/**
|
|
694
979
|
* Resolve a strict `provider/model-id` spec from a spawn tool against the
|
|
@@ -1254,10 +1539,14 @@ export class PiSessionService {
|
|
|
1254
1539
|
}
|
|
1255
1540
|
async messages(ref, page) {
|
|
1256
1541
|
const session = await this.getOrOpen(ref);
|
|
1257
|
-
return pageMessagesAtSafeBoundary(
|
|
1542
|
+
return pageMessagesAtSafeBoundary(historyMessagesFromEntries(await this.readableSessionBranch(ref, session)), page);
|
|
1258
1543
|
}
|
|
1259
1544
|
async status(ref) {
|
|
1260
|
-
|
|
1545
|
+
const session = await this.sessionForStatusOrDialogClose(ref);
|
|
1546
|
+
if (this.hasActiveWork(session))
|
|
1547
|
+
return this.statusFromSession(session);
|
|
1548
|
+
const branch = await this.readableSessionBranch(ref, session);
|
|
1549
|
+
return this.statusFromSession(session, transcriptMessageCount(branch));
|
|
1261
1550
|
}
|
|
1262
1551
|
/**
|
|
1263
1552
|
* Join-time snapshot of the in-flight assistant stream. The `seq` watermark and
|
|
@@ -1283,6 +1572,68 @@ export class PiSessionService {
|
|
|
1283
1572
|
const models = await this.sessionModelCandidates(session);
|
|
1284
1573
|
return models.map(modelToClientModel);
|
|
1285
1574
|
}
|
|
1575
|
+
async modelCatalog(ref) {
|
|
1576
|
+
const session = await this.getOrOpen(ref);
|
|
1577
|
+
const scope = this.modelScopeContext(session);
|
|
1578
|
+
return (await this.enabledModelCatalog(session)).map((entry) => catalogEntryToClientModel(entry, scope.editable));
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* Add/remove one model to/from the global pi `enabledModels` scope. A
|
|
1582
|
+
* workspace `.pi/settings.json` override remains authoritative for that
|
|
1583
|
+
* workspace and is intentionally read-only in this picker.
|
|
1584
|
+
*/
|
|
1585
|
+
async setModelEnabled(ref, provider, modelId, enabled) {
|
|
1586
|
+
await this.assertWritable(ref);
|
|
1587
|
+
const session = await this.getOrOpen(ref);
|
|
1588
|
+
return this.runModelScopeMutation(async () => {
|
|
1589
|
+
const scope = this.modelScopeContext(session);
|
|
1590
|
+
if (scope.source !== "global")
|
|
1591
|
+
throw new Error("Model availability is controlled by this workspace's .pi/settings.json");
|
|
1592
|
+
this.assertTreeNavigationInactive(session, "change enabled models");
|
|
1593
|
+
await session.modelRuntime.refresh({ allowNetwork: false });
|
|
1594
|
+
const currentIds = (await this.modelScopeSnapshot(session, scope)).enabledIds;
|
|
1595
|
+
const available = session.modelRuntime.getAvailableSnapshot();
|
|
1596
|
+
const availableIds = available.map(modelScopeId);
|
|
1597
|
+
const targetId = `${provider}/${modelId}`;
|
|
1598
|
+
if (!availableIds.includes(targetId))
|
|
1599
|
+
throw new Error(`Model not found: ${targetId}`);
|
|
1600
|
+
if (!enabled && session.model !== undefined && modelScopeId(session.model) === targetId) {
|
|
1601
|
+
throw new Error("Current model cannot be disabled");
|
|
1602
|
+
}
|
|
1603
|
+
const nextIds = applyEnabledModelToggle(currentIds, availableIds, targetId, enabled);
|
|
1604
|
+
this.assertTreeNavigationInactive(session, "change enabled models");
|
|
1605
|
+
if (nextIds !== currentIds)
|
|
1606
|
+
this.applyEnabledModelScope(session, available, nextIds);
|
|
1607
|
+
// Respond from a fresh post-edit read so the response is exactly what
|
|
1608
|
+
// GET models/catalog returns after the edit, including pi's normalizations.
|
|
1609
|
+
return (await this.enabledModelCatalog(session)).map((entry) => catalogEntryToClientModel(entry));
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
/** Atomically expose every model or narrow the global enabled scope to the current model. */
|
|
1613
|
+
async setModelScope(ref, mode) {
|
|
1614
|
+
await this.assertWritable(ref);
|
|
1615
|
+
const session = await this.getOrOpen(ref);
|
|
1616
|
+
return this.runModelScopeMutation(async () => {
|
|
1617
|
+
const scope = this.modelScopeContext(session);
|
|
1618
|
+
if (scope.source !== "global")
|
|
1619
|
+
throw new Error("Model availability is controlled by this workspace's .pi/settings.json");
|
|
1620
|
+
this.assertTreeNavigationInactive(session, "change enabled models");
|
|
1621
|
+
await session.modelRuntime.refresh({ allowNetwork: false });
|
|
1622
|
+
const available = session.modelRuntime.getAvailableSnapshot();
|
|
1623
|
+
const availableIds = available.map(modelScopeId);
|
|
1624
|
+
let nextIds = null;
|
|
1625
|
+
if (mode === "current") {
|
|
1626
|
+
const current = session.model;
|
|
1627
|
+
if (current === undefined || !availableIds.includes(modelScopeId(current))) {
|
|
1628
|
+
throw new Error("Current model is unavailable");
|
|
1629
|
+
}
|
|
1630
|
+
nextIds = [modelScopeId(current)];
|
|
1631
|
+
}
|
|
1632
|
+
this.assertTreeNavigationInactive(session, "change enabled models");
|
|
1633
|
+
this.applyEnabledModelScope(session, available, nextIds);
|
|
1634
|
+
return (await this.enabledModelCatalog(session)).map((entry) => catalogEntryToClientModel(entry));
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1286
1637
|
async setModel(ref, provider, modelId) {
|
|
1287
1638
|
await this.assertWritable(ref);
|
|
1288
1639
|
const session = await this.getOrOpen(ref);
|
|
@@ -1301,7 +1652,11 @@ export class PiSessionService {
|
|
|
1301
1652
|
async cycleModel(ref, direction) {
|
|
1302
1653
|
await this.assertWritable(ref);
|
|
1303
1654
|
const session = await this.getOrOpen(ref);
|
|
1304
|
-
const result = await this.
|
|
1655
|
+
const result = await this.runModelScopeMutation(async () => {
|
|
1656
|
+
await session.modelRuntime.refresh({ allowNetwork: false });
|
|
1657
|
+
await this.synchronizeSessionModelScope(session, session.modelRuntime.getAvailableSnapshot());
|
|
1658
|
+
return this.runSessionEntryMutation(session, "change models", () => session.cycleModel(direction));
|
|
1659
|
+
});
|
|
1305
1660
|
if (result === undefined)
|
|
1306
1661
|
throw new Error(session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available");
|
|
1307
1662
|
this.publishActivity(session, `model: ${result.model.id}`, "idle", result.model.provider);
|
|
@@ -1410,7 +1765,25 @@ export class PiSessionService {
|
|
|
1410
1765
|
return [];
|
|
1411
1766
|
await this.assertWritable(ref);
|
|
1412
1767
|
const active = await this.getActive(ref);
|
|
1413
|
-
|
|
1768
|
+
const cwd = active.runtime.cwd;
|
|
1769
|
+
// An explicit request folder wins; the config lookup below is only the
|
|
1770
|
+
// fallback for folder-less calls (see workspaceAttachmentsConfig).
|
|
1771
|
+
const effectiveFolder = folder ?? (await this.workspaceAttachmentsConfig(cwd)).defaultFolder;
|
|
1772
|
+
return saveAttachmentsToWorkspace(cwd, parsed, effectiveFolder === undefined ? {} : { folder: effectiveFolder });
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Fallback attachments config for save requests that omit an explicit
|
|
1776
|
+
* folder: the live global config merged with the session cwd's own
|
|
1777
|
+
* project-local override. Unlike `workspaceEffectiveConfig` in app.ts (which
|
|
1778
|
+
* resolves from the owning project's path), this lookup keys off the cwd
|
|
1779
|
+
* itself, so for secondary (worktree) workspaces it cannot see the owning
|
|
1780
|
+
* project's override. The composer therefore always sends the
|
|
1781
|
+
* workspace-effective folder it displayed explicitly; this cwd-based
|
|
1782
|
+
* resolution only governs folder-less API calls.
|
|
1783
|
+
*/
|
|
1784
|
+
async workspaceAttachmentsConfig(cwd) {
|
|
1785
|
+
const globalConfig = this.config === undefined ? {} : (await this.config.read()).effectiveConfig;
|
|
1786
|
+
return loadEffectiveProjectAttachmentsConfig(cwd, globalConfig);
|
|
1414
1787
|
}
|
|
1415
1788
|
async shell(ref, text) {
|
|
1416
1789
|
await this.assertWritable(ref);
|
|
@@ -1475,18 +1848,53 @@ export class PiSessionService {
|
|
|
1475
1848
|
// may enter this runtime until Pi's potentially asynchronous summary settles.
|
|
1476
1849
|
this.treeNavigations.add(session);
|
|
1477
1850
|
try {
|
|
1478
|
-
|
|
1851
|
+
const oldLeafId = session.sessionManager.getLeafId();
|
|
1852
|
+
if (oldLeafId !== request.expectedLeafId) {
|
|
1479
1853
|
throw new Error("The session changed since /tree was opened. Reopen /tree and try again.");
|
|
1480
1854
|
}
|
|
1855
|
+
const activeEditableTargetParentId = activeEditableTreeTargetParentId(session.sessionManager, request.targetId, oldLeafId);
|
|
1481
1856
|
this.publishActivity(session, options.summarize ? "summarizing branch" : "navigating session tree", "active");
|
|
1482
1857
|
this.publishStatus(session);
|
|
1483
|
-
|
|
1858
|
+
let result = await session.navigateTree(request.targetId, options);
|
|
1859
|
+
if (activeEditableTargetParentId !== undefined
|
|
1860
|
+
&& !result.cancelled
|
|
1861
|
+
&& result.editorText === undefined
|
|
1862
|
+
&& result.summaryEntry === undefined
|
|
1863
|
+
&& session.sessionManager.getLeafId() === oldLeafId) {
|
|
1864
|
+
// Supported Pi versions can return early when the target is already the
|
|
1865
|
+
// leaf, before applying their user/custom-message edit semantics. Move
|
|
1866
|
+
// to the target's parent and delegate again so Pi still owns editor-text
|
|
1867
|
+
// extraction, agent-context rebuilding, and tree extension events.
|
|
1868
|
+
setSessionTreeLeaf(session.sessionManager, activeEditableTargetParentId);
|
|
1869
|
+
try {
|
|
1870
|
+
result = await session.navigateTree(request.targetId, options);
|
|
1871
|
+
}
|
|
1872
|
+
catch (error) {
|
|
1873
|
+
session.sessionManager.branch(request.targetId);
|
|
1874
|
+
throw error;
|
|
1875
|
+
}
|
|
1876
|
+
if (result.cancelled)
|
|
1877
|
+
session.sessionManager.branch(request.targetId);
|
|
1878
|
+
}
|
|
1484
1879
|
if (result.cancelled) {
|
|
1485
1880
|
if (this.isCurrentActiveSession(session)) {
|
|
1486
1881
|
this.publishActivity(session, result.aborted === true ? "branch summary aborted" : "tree navigation cancelled", "idle");
|
|
1487
1882
|
}
|
|
1488
1883
|
return { cancelled: true, ...(result.aborted === undefined ? {} : { aborted: result.aborted }) };
|
|
1489
1884
|
}
|
|
1885
|
+
if (result.summaryEntry !== undefined) {
|
|
1886
|
+
// A summary entry durably identifies the selected branch as the file's
|
|
1887
|
+
// newest leaf, superseding any earlier bare selection.
|
|
1888
|
+
this.unpersistedTreeBranchLeaves.delete(session);
|
|
1889
|
+
}
|
|
1890
|
+
else {
|
|
1891
|
+
const selectedLeafId = session.sessionManager.getLeafId();
|
|
1892
|
+
if (selectedLeafId !== oldLeafId) {
|
|
1893
|
+
// SessionManager.branch()/resetLeaf() only move Pi's in-memory leaf.
|
|
1894
|
+
// Keep that branch authoritative until a later append makes disk agree.
|
|
1895
|
+
this.unpersistedTreeBranchLeaves.set(session, selectedLeafId);
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1490
1898
|
if (this.isCurrentActiveSession(session))
|
|
1491
1899
|
this.publishActivity(session, "session tree navigated", "idle");
|
|
1492
1900
|
return { cancelled: false, ...(result.editorText === undefined ? {} : { editorText: result.editorText }) };
|
|
@@ -2097,6 +2505,63 @@ export class PiSessionService {
|
|
|
2097
2505
|
async getOrOpen(ref) {
|
|
2098
2506
|
return (await this.getActive(ref)).runtime.session;
|
|
2099
2507
|
}
|
|
2508
|
+
/**
|
|
2509
|
+
* An idle runtime is only Pi Web's cached control view. Another Pi process may
|
|
2510
|
+
* keep appending to the same JSONL file, so transcript reads must open a fresh
|
|
2511
|
+
* read-only snapshot rather than serving that cached branch forever. Active
|
|
2512
|
+
* runtimes remain authoritative and are never replaced, reloaded, or aborted.
|
|
2513
|
+
*/
|
|
2514
|
+
async readableSessionBranch(ref, session) {
|
|
2515
|
+
if (this.hasActiveWork(session) || this.sessionManager.readBranch === undefined)
|
|
2516
|
+
return session.sessionManager.getBranch();
|
|
2517
|
+
const sessionFile = session.sessionFile ?? session.sessionManager.getSessionFile();
|
|
2518
|
+
const resolvedPath = sessionFile === undefined ? await this.idleSessionFilePath(ref, session) : undefined;
|
|
2519
|
+
if (this.hasActiveWork(session))
|
|
2520
|
+
return session.sessionManager.getBranch();
|
|
2521
|
+
const path = sessionFile ?? resolvedPath;
|
|
2522
|
+
if (path === undefined)
|
|
2523
|
+
return session.sessionManager.getBranch();
|
|
2524
|
+
const snapshot = await this.sessionManager.readBranch(path);
|
|
2525
|
+
// No snapshot exists when the transcript file is absent (a session known
|
|
2526
|
+
// by path but never persisted, or externally removed): the runtime branch
|
|
2527
|
+
// is the only readable branch then.
|
|
2528
|
+
if (snapshot === undefined)
|
|
2529
|
+
return session.sessionManager.getBranch();
|
|
2530
|
+
// Reading also yields. A prompt that started meanwhile must still win over
|
|
2531
|
+
// the completed disk snapshot and its potentially older event watermark.
|
|
2532
|
+
if (this.hasActiveWork(session))
|
|
2533
|
+
return session.sessionManager.getBranch();
|
|
2534
|
+
const selectedLeafId = this.unpersistedTreeBranchLeaves.get(session);
|
|
2535
|
+
if (selectedLeafId !== undefined) {
|
|
2536
|
+
const runtimeLeafId = session.sessionManager.getLeafId();
|
|
2537
|
+
const selectedBranchIsAnchored = runtimeLeafId !== null
|
|
2538
|
+
&& runtimeLeafId !== selectedLeafId
|
|
2539
|
+
&& transcriptBranchIncludesEntry(snapshot, runtimeLeafId);
|
|
2540
|
+
if (!selectedBranchIsAnchored)
|
|
2541
|
+
return session.sessionManager.getBranch();
|
|
2542
|
+
// A later runtime append now anchors the live selection in this persisted
|
|
2543
|
+
// branch. Resume ordinary idle snapshots, including external descendants.
|
|
2544
|
+
this.unpersistedTreeBranchLeaves.delete(session);
|
|
2545
|
+
}
|
|
2546
|
+
return snapshot;
|
|
2547
|
+
}
|
|
2548
|
+
/**
|
|
2549
|
+
* Resolve the transcript file for an idle runtime that does not know one,
|
|
2550
|
+
* at most once per {@link IDLE_SESSION_FILE_RESOLUTION_THROTTLE_MS}. The
|
|
2551
|
+
* negative result (no file yet) is the hot case: a never-persisted session
|
|
2552
|
+
* is polled every few seconds, and re-scanning its directory each tick is
|
|
2553
|
+
* pure waste. `getActive` never uses this path, so prompt routing always
|
|
2554
|
+
* sees a fresh resolution.
|
|
2555
|
+
*/
|
|
2556
|
+
async idleSessionFilePath(ref, session) {
|
|
2557
|
+
const cached = this.idleSessionFileResolutions.get(session);
|
|
2558
|
+
const at = this.now().getTime();
|
|
2559
|
+
if (cached !== undefined && at - cached.at < IDLE_SESSION_FILE_RESOLUTION_THROTTLE_MS)
|
|
2560
|
+
return cached.path;
|
|
2561
|
+
const match = await this.sessionManager.resolveSessionFile(ref.cwd, ref.id);
|
|
2562
|
+
this.idleSessionFileResolutions.set(session, { at, path: match?.path });
|
|
2563
|
+
return match?.path;
|
|
2564
|
+
}
|
|
2100
2565
|
async getActive(ref, options = {}) {
|
|
2101
2566
|
const active = this.activeForRef(ref);
|
|
2102
2567
|
if (active !== undefined)
|
|
@@ -2938,7 +3403,7 @@ export class PiSessionService {
|
|
|
2938
3403
|
this.events.publish(session.sessionId, { type: "activity.update", activity });
|
|
2939
3404
|
this.events.publishGlobal({ type: "activity.update", activity });
|
|
2940
3405
|
}
|
|
2941
|
-
statusFromSession(session) {
|
|
3406
|
+
statusFromSession(session, messageCount = session.messages.length) {
|
|
2942
3407
|
const stats = session.getSessionStats();
|
|
2943
3408
|
const model = session.model === undefined ? undefined : modelToClientModel(session.model);
|
|
2944
3409
|
const contextUsage = session.getContextUsage();
|
|
@@ -2955,7 +3420,7 @@ export class PiSessionService {
|
|
|
2955
3420
|
isBashRunning: session.isBashRunning,
|
|
2956
3421
|
pendingMessageCount: this.pendingMessageCount(session),
|
|
2957
3422
|
queuedMessages: queuedMessagesFromSession(session, this.compactionQueuedMessages(session.sessionId)),
|
|
2958
|
-
messageCount
|
|
3423
|
+
messageCount,
|
|
2959
3424
|
tokens: stats.tokens,
|
|
2960
3425
|
cost: stats.cost,
|
|
2961
3426
|
...(contextUsage === undefined ? {} : { contextUsage }),
|
|
@@ -3040,6 +3505,20 @@ function modelToClientModel(model) {
|
|
|
3040
3505
|
...(reasoning === undefined ? {} : { reasoning }),
|
|
3041
3506
|
};
|
|
3042
3507
|
}
|
|
3508
|
+
/** The scope source pi's enabled-id precedence reads from: live scope, settings patterns, runtime catalog. */
|
|
3509
|
+
function sessionScopeSource(session) {
|
|
3510
|
+
return { settingsManager: session.settingsManager, modelRuntime: session.modelRuntime, scopedModels: session.scopedModels };
|
|
3511
|
+
}
|
|
3512
|
+
function catalogEntryToClientModel(entry, editable = true) {
|
|
3513
|
+
return {
|
|
3514
|
+
...modelToClientModel(entry.model),
|
|
3515
|
+
provider: entry.model.provider,
|
|
3516
|
+
id: entry.model.id,
|
|
3517
|
+
enabled: entry.enabled,
|
|
3518
|
+
catalogIndex: entry.catalogIndex,
|
|
3519
|
+
...(editable ? {} : { editable: false }),
|
|
3520
|
+
};
|
|
3521
|
+
}
|
|
3043
3522
|
function notificationIdentityForSession(session) {
|
|
3044
3523
|
return {
|
|
3045
3524
|
sessionId: session.sessionId,
|
|
@@ -3413,11 +3892,14 @@ function annotateAssistantThinkingLevel(message, thinkingLevel) {
|
|
|
3413
3892
|
return { ...message, thinkingLevel };
|
|
3414
3893
|
}
|
|
3415
3894
|
function historyMessages(session) {
|
|
3895
|
+
return historyMessagesFromEntries(session.sessionManager.getBranch());
|
|
3896
|
+
}
|
|
3897
|
+
function historyMessagesFromEntries(entries) {
|
|
3416
3898
|
const messages = [];
|
|
3417
3899
|
// Pi records the initial level at session creation and every later change, so
|
|
3418
3900
|
// walking the branch yields the level in effect for each assistant message.
|
|
3419
3901
|
let thinkingLevel;
|
|
3420
|
-
for (const entry of
|
|
3902
|
+
for (const entry of entries) {
|
|
3421
3903
|
if (!isRecord(entry))
|
|
3422
3904
|
continue;
|
|
3423
3905
|
if (entry["type"] === "message")
|
|
@@ -3436,6 +3918,39 @@ function historyMessages(session) {
|
|
|
3436
3918
|
}
|
|
3437
3919
|
return messages;
|
|
3438
3920
|
}
|
|
3921
|
+
function transcriptMessageCount(entries) {
|
|
3922
|
+
let count = 0;
|
|
3923
|
+
for (const entry of entries) {
|
|
3924
|
+
if (isRecord(entry) && entry["type"] === "message")
|
|
3925
|
+
count += 1;
|
|
3926
|
+
}
|
|
3927
|
+
return count;
|
|
3928
|
+
}
|
|
3929
|
+
function activeEditableTreeTargetParentId(manager, targetId, activeLeafId) {
|
|
3930
|
+
if (activeLeafId !== targetId)
|
|
3931
|
+
return undefined;
|
|
3932
|
+
const entry = manager.getBranch().at(-1);
|
|
3933
|
+
if (!isRecord(entry) || entry["id"] !== targetId)
|
|
3934
|
+
return undefined;
|
|
3935
|
+
const isUserMessage = entry["type"] === "message"
|
|
3936
|
+
&& isRecord(entry["message"])
|
|
3937
|
+
&& entry["message"]["role"] === "user";
|
|
3938
|
+
if (!isUserMessage && entry["type"] !== "custom_message")
|
|
3939
|
+
return undefined;
|
|
3940
|
+
const parentId = entry["parentId"];
|
|
3941
|
+
if (parentId === targetId)
|
|
3942
|
+
return undefined;
|
|
3943
|
+
return parentId === null || typeof parentId === "string" ? parentId : undefined;
|
|
3944
|
+
}
|
|
3945
|
+
function setSessionTreeLeaf(manager, leafId) {
|
|
3946
|
+
if (leafId === null)
|
|
3947
|
+
manager.resetLeaf();
|
|
3948
|
+
else
|
|
3949
|
+
manager.branch(leafId);
|
|
3950
|
+
}
|
|
3951
|
+
function transcriptBranchIncludesEntry(entries, entryId) {
|
|
3952
|
+
return entries.some((entry) => isRecord(entry) && entry["id"] === entryId);
|
|
3953
|
+
}
|
|
3439
3954
|
/** custom entry type used to persist parent -> child subsession links outside LLM context. */
|
|
3440
3955
|
const SUBSESSION_LINK_CUSTOM_TYPE = "pi-web.subsession.link";
|
|
3441
3956
|
/** custom entry type used to mark a child as created by spawn_subsession. */
|