@piagent/platform 1.4.0 → 1.5.1
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/CHANGELOG.md +151 -0
- package/README.md +12 -2
- package/README.vi.md +47 -0
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +7 -7
- package/docs/en/architecture.md +13 -0
- package/docs/languages.json +20 -4
- package/docs/mcp-and-tools.md +112 -1
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +42 -0
- package/docs/release-install-policy.md +21 -8
- package/docs/runtime-harness-standard.md +1 -1
- package/docs/security-threat-model.md +1 -1
- package/docs/task-implementation-contract.md +11 -4
- package/docs/team-onboarding.md +2 -2
- package/docs/usage-observability.md +70 -0
- package/docs/vi/architecture.md +13 -0
- package/docs-site/architecture.html +5 -1
- package/docs-site/benchmark.html +5 -1
- package/docs-site/commands.html +5 -1
- package/docs-site/content/en/index.html +49 -0
- package/docs-site/content/en/runtime.html +13 -0
- package/docs-site/content/index.html +49 -0
- package/docs-site/content/runtime.html +28 -0
- package/docs-site/context-engine.html +5 -1
- package/docs-site/en/architecture.html +5 -1
- package/docs-site/en/benchmark.html +5 -1
- package/docs-site/en/commands.html +5 -1
- package/docs-site/en/context-engine.html +5 -1
- package/docs-site/en/index.html +54 -1
- package/docs-site/en/mcp-approval.html +5 -1
- package/docs-site/en/mcp-auth.html +5 -1
- package/docs-site/en/mcp-servers.html +5 -1
- package/docs-site/en/mcp.html +5 -1
- package/docs-site/en/permissions.html +5 -1
- package/docs-site/en/profiles.html +5 -1
- package/docs-site/en/quickstart.html +5 -1
- package/docs-site/en/runtime.html +18 -1
- package/docs-site/en/scope.html +5 -1
- package/docs-site/en/security.html +5 -1
- package/docs-site/en/team.html +5 -1
- package/docs-site/en/whats-new.html +5 -1
- package/docs-site/en/workflows.html +5 -1
- package/docs-site/index.html +54 -1
- package/docs-site/mcp-approval.html +5 -1
- package/docs-site/mcp-auth.html +5 -1
- package/docs-site/mcp-servers.html +5 -1
- package/docs-site/mcp.html +5 -1
- package/docs-site/permissions.html +5 -1
- package/docs-site/profiles.html +5 -1
- package/docs-site/quickstart.html +5 -1
- package/docs-site/runtime.html +33 -1
- package/docs-site/scope.html +5 -1
- package/docs-site/security.html +5 -1
- package/docs-site/team.html +5 -1
- package/docs-site/whats-new.html +5 -1
- package/docs-site/workflows.html +5 -1
- package/evals/fixtures/piagent-webui/document-workspace-v1.invalid.json +17 -0
- package/evals/fixtures/piagent-webui/document-workspace-v1.valid.json +42 -0
- package/evals/fixtures/piagent-webui/session-command-v1.valid.json +2 -1
- package/package.json +14 -2
- package/packages/piagent-core/README.md +3 -2
- package/packages/piagent-core/capabilities/capability-verification-cache.js +17 -7
- package/packages/piagent-core/capabilities/runtime-integrity.js +51 -4
- package/packages/piagent-core/extensions/document-intake.ts +6 -3
- package/packages/piagent-core/extensions/piagent-guard.ts +20 -237
- package/packages/piagent-core/extensions/runtime-evidence.js +12 -1
- package/packages/piagent-core/extensions/shell-reach.ts +250 -0
- package/packages/piagent-core/mcp/mcp-command-actions.js +1 -1
- package/packages/piagent-core/mcp/mcp-server-catalog.js +135 -5
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +9 -2
- package/packages/piagent-core/runtime/input/attachment-document-command.ts +39 -0
- package/packages/piagent-core/runtime/input/attachment-reservation.ts +36 -0
- package/packages/{piagent-webui/extension → piagent-core/runtime/input}/attachment-store.ts +221 -44
- package/packages/piagent-core/runtime/inspection/document-workspace-projection.ts +276 -0
- package/packages/piagent-core/runtime/inspection/project-runtime-inspection.ts +20 -5
- package/packages/piagent-core/runtime/inspection/session-identity.ts +20 -0
- package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +25 -2
- package/packages/piagent-core/runtime/policy/block-remedy.ts +139 -0
- package/packages/piagent-core/runtime/registration/task-start-tool.ts +9 -13
- package/packages/piagent-core/runtime/registration/workflow-commands.ts +5 -4
- package/packages/piagent-core/runtime/session/token-economics.js +34 -0
- package/packages/piagent-core/runtime/tools/phase-tool-runtime.ts +2 -1
- package/packages/piagent-core/runtime/workflows/input-routing.ts +13 -2
- package/packages/piagent-core/runtime/workflows/task-intake.ts +5 -0
- package/packages/piagent-core/security/sensitive-data.js +7 -0
- package/packages/piagent-webui/dist/client/assets/MarkdownMessage-Uouhx1QO.js +29 -0
- package/packages/piagent-webui/dist/client/assets/index-BV-_03yl.css +1 -0
- package/packages/piagent-webui/dist/client/assets/index-MBZq8XLV.js +174 -0
- package/packages/piagent-webui/dist/client/index.html +2 -2
- package/packages/piagent-webui/extension/lifecycle-controller.ts +15 -0
- package/packages/piagent-webui/extension/piagent-webui.ts +19 -6
- package/packages/piagent-webui/extension/same-session-bridge.ts +12 -12
- package/packages/piagent-webui/gateway/ai-capability-inspection.ts +53 -0
- package/packages/piagent-webui/gateway/extension-authority.ts +47 -0
- package/packages/piagent-webui/gateway/gateway-service.ts +23 -3
- package/packages/piagent-webui/gateway/native-project-picker.ts +63 -7
- package/packages/piagent-webui/gateway/session-attachment-registry.ts +165 -0
- package/packages/piagent-webui/gateway/session-catalog.ts +62 -9
- package/packages/piagent-webui/gateway/session-command-controller.ts +47 -14
- package/packages/piagent-webui/gateway/session-inspection-registry.ts +67 -12
- package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +24 -11
- package/packages/piagent-webui/package.json +3 -1
- package/packages/piagent-webui/server/core-inspection-provider.ts +52 -15
- package/packages/piagent-webui/server/ipc-read-model-client.ts +2 -0
- package/packages/piagent-webui/server/loopback-server.ts +63 -25
- package/packages/piagent-webui/server/read-model-provider.ts +2 -0
- package/packages/piagent-webui/server/read-only-router.ts +6 -0
- package/packages/piagent-webui/server/transcript-projection.ts +58 -2
- package/schemas/piagent-webui/attachment-v1.schema.json +24 -4
- package/schemas/piagent-webui/capabilities-v1.schema.json +2 -2
- package/schemas/piagent-webui/catalog-v1.json +9 -0
- package/schemas/piagent-webui/document-workspace-v1.schema.json +155 -0
- package/schemas/piagent-webui/session-command-v1.schema.json +14 -3
- package/schemas/piagent-webui/transcript-v1.schema.json +27 -0
- package/scripts/build-docs-site.mjs +21 -1
- package/scripts/explain-command.mjs +240 -0
- package/scripts/install-global.sh +104 -3
- package/scripts/mcp-manage.mjs +17 -4
- package/scripts/pi-session-stats.sh +10 -2
- package/scripts/piagent-cli.mjs +14 -1
- package/scripts/piagent-dashboard.mjs +55 -2
- package/scripts/setup.sh +69 -33
- package/scripts/traffic-snapshot.mjs +214 -0
- package/scripts/typescript-loader.mjs +20 -4
- package/scripts/uninstall-global.sh +53 -2
- package/scripts/update-global.mjs +33 -1
- package/scripts/verify-local.sh +20 -1
- package/scripts/verify-translation-pairing.mjs +121 -0
- package/packages/piagent-webui/dist/client/assets/index-DjMntaK5.js +0 -172
- package/packages/piagent-webui/dist/client/assets/index-DwKCWbwd.css +0 -1
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
<meta name="csp-nonce" content="__PIAGENT_CSP_NONCE__" />
|
|
8
8
|
<meta name="piagent-webui-mode" content="__PIAGENT_WEBUI_MODE__" />
|
|
9
9
|
<title>Piagent WebUI</title>
|
|
10
|
-
<script type="module" crossorigin src="/assets/index-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-MBZq8XLV.js"></script>
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BV-_03yl.css">
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
14
|
<div id="root"></div>
|
|
@@ -131,6 +131,21 @@ export class LifecycleController {
|
|
|
131
131
|
return !inspectTaskControlState(current.cwd, task).dispatchBlocked && !this.#pendingStop;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
inputAllowed(ctx?: ExtensionContext): boolean {
|
|
135
|
+
const current = ctx ?? this.#ctx; if (!current || !this.#sameSession(current) || this.#pendingStop) return false;
|
|
136
|
+
const task = this.#task(current); if (!task) return true;
|
|
137
|
+
const state = inspectTaskControlState(current.cwd, task).state;
|
|
138
|
+
return state === "active" || state === "terminal";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
toolAllowed(toolName: string, ctx?: ExtensionContext): boolean {
|
|
142
|
+
if (this.dispatchAllowed(ctx)) return true;
|
|
143
|
+
const current = ctx ?? this.#ctx; if (!current || !this.#sameSession(current) || this.#pendingStop) return false;
|
|
144
|
+
const task = this.#task(current);
|
|
145
|
+
return toolName === "piagent_task_start" && Boolean(task)
|
|
146
|
+
&& inspectTaskControlState(current.cwd, task).state === "terminal";
|
|
147
|
+
}
|
|
148
|
+
|
|
134
149
|
observeAgentStart(ctx: ExtensionContext): void { if (!this.#sameSession(ctx)) return; this.#phase = "model"; }
|
|
135
150
|
observeToolStart(ctx: ExtensionContext): void { if (!this.#sameSession(ctx)) return; this.#toolDepth += 1; this.#phase = "tool"; }
|
|
136
151
|
observeToolEnd(ctx: ExtensionContext): void { if (!this.#sameSession(ctx)) return; this.#toolDepth = Math.max(0, this.#toolDepth - 1);
|
|
@@ -20,7 +20,7 @@ import { piSourceMutationGuard } from "../../piagent-core/runtime/policy/source-
|
|
|
20
20
|
import { CoreInspectionProvider } from "../server/core-inspection-provider.ts";
|
|
21
21
|
import type { SourceView } from "../server/read-model-provider.ts";
|
|
22
22
|
import { SameSessionPiBridge, type BridgeTaskFacts } from "./same-session-bridge.ts";
|
|
23
|
-
import { AttachmentStore } from "
|
|
23
|
+
import { AttachmentStore } from "../../piagent-core/runtime/input/attachment-store.ts";
|
|
24
24
|
import { HeldMessageQueue } from "./held-message-queue.ts";
|
|
25
25
|
import { LifecycleController } from "./lifecycle-controller.ts";
|
|
26
26
|
import { lifecycleRuntimeDraft } from "./lifecycle-event-adapter.ts";
|
|
@@ -78,6 +78,14 @@ function protectedPaths(ctx: ExtensionContext): string[] {
|
|
|
78
78
|
} catch { return [".pi/piagent-state/**", ".pi/context-index.json"]; }
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
// The same profile grant `piagent_document_read` honours. Read from the profile
|
|
82
|
+
// on every call rather than captured once, so an operator who adds a directory
|
|
83
|
+
// mid-session sees it in the workspace without restarting Pi.
|
|
84
|
+
function documentReadRoots(ctx: ExtensionContext): unknown {
|
|
85
|
+
try { return loadProjectContextIndexPolicy(PLATFORM_ROOT, ctx.cwd).profile.additionalReadRoots; }
|
|
86
|
+
catch { return []; }
|
|
87
|
+
}
|
|
88
|
+
|
|
81
89
|
function writeDescriptor(ctx: ExtensionContext, value: Record<string, unknown>): string {
|
|
82
90
|
const directory = ensurePrivateStateDirectory(ctx.cwd, descriptorDirectory(ctx.cwd), "WebUI launcher directory");
|
|
83
91
|
const target = resolveLocalStatePath(ctx.cwd, descriptorPath(ctx), { label: "WebUI launcher descriptor" });
|
|
@@ -144,6 +152,8 @@ function readMethod(provider: CoreInspectionProvider, queue: HeldMessageQueue, s
|
|
|
144
152
|
if (method === "handoffHistory" && args.length === 1 && typeof args[0] === "string" && REF.test(args[0])) return provider.handoffHistory(args[0]);
|
|
145
153
|
if (method === "subagentTree" && args.length === 1 && typeof args[0] === "string" && REF.test(args[0])) return provider.subagentTree(args[0]);
|
|
146
154
|
if (method === "releaseMonitor" && args.length === 0) return provider.releaseMonitor();
|
|
155
|
+
if (method === "documents" && args.length === 0) return provider.documents();
|
|
156
|
+
if (method === "document" && args.length === 1 && typeof args[0] === "string" && REF.test(args[0])) return provider.document(args[0]);
|
|
147
157
|
if (method === "logPreview" && args.length === 1 && typeof args[0] === "string" && REF.test(args[0])) return provider.logPreview(args[0]);
|
|
148
158
|
if (method === "transcript" && args.length === 2 && (args[0] === null || typeof args[0] === "string" && REF.test(args[0]))
|
|
149
159
|
&& Number.isInteger(args[1]) && Number(args[1]) >= 1 && Number(args[1]) <= 200) return provider.transcript(args[0] as string | null, Number(args[1]));
|
|
@@ -184,7 +194,9 @@ export default function piagentWebUiExtension(pi: ExtensionAPI): void {
|
|
|
184
194
|
let approvalBinding: { key: string; unbind(): void; unsubscribe(): void } | null = null;
|
|
185
195
|
const bridge = new SameSessionPiBridge(pi, { runtimeInstanceId, taskFacts,
|
|
186
196
|
prepareAttachments: (refs, request, identity, text) => {
|
|
187
|
-
if (!attachments) throw new Error("attachment-store-unavailable");
|
|
197
|
+
if (!attachments) throw new Error("attachment-store-unavailable");
|
|
198
|
+
const reservation = attachments.reserve(refs, request, identity, text);
|
|
199
|
+
return { ...reservation.prepared, commit: reservation.commit, release: reservation.release };
|
|
188
200
|
} });
|
|
189
201
|
try { attachments = new AttachmentStore({ runtimeInstanceId, bridgeSnapshot: () => bridge.snapshot(),
|
|
190
202
|
modelSupportsImages: () => Array.isArray(currentContext?.model?.input) && currentContext.model.input.includes("image") }); }
|
|
@@ -301,6 +313,7 @@ export default function piagentWebUiExtension(pi: ExtensionAPI): void {
|
|
|
301
313
|
task: () => activeSessionTask(ctx.cwd, ctx.sessionManager.getSessionId()),
|
|
302
314
|
activityEvents: () => readContextTelemetry(ctx.cwd, { limit: 5_000 }) as unknown[],
|
|
303
315
|
currentActivity: () => [], sessionEntries: () => ctx.sessionManager.getBranch(), protectedPaths: () => protectedPaths(ctx),
|
|
316
|
+
documentReadRoots: () => documentReadRoots(ctx),
|
|
304
317
|
contextUsage: () => ctx.getContextUsage(), model: () => ctx.model,
|
|
305
318
|
thinkingLevel: () => ctx.thinkingLevel ?? pi.getThinkingLevel(),
|
|
306
319
|
queueProjection: () => queue.projection(),
|
|
@@ -398,10 +411,10 @@ export default function piagentWebUiExtension(pi: ExtensionAPI): void {
|
|
|
398
411
|
}
|
|
399
412
|
});
|
|
400
413
|
|
|
401
|
-
pi.on("tool_call", (
|
|
414
|
+
pi.on("tool_call", (event, ctx) => { publishPersisted(ctx); if (!isGatewayRuntimeContext(ctx) && !terminalAdapter?.dispatchAllowed(ctx)) return { block: true,
|
|
402
415
|
reason: `Piagent cannot prove this terminal owns the session (${terminalAdapter?.reasonCode() ?? "terminal-session-adapter-unavailable"}).` };
|
|
403
|
-
if (!lifecycle.
|
|
404
|
-
reason: "Piagent lifecycle control blocks
|
|
416
|
+
if (!lifecycle.toolAllowed(String(event.toolName ?? ""), ctx)) return { block: true,
|
|
417
|
+
reason: "Piagent lifecycle control blocks tool work while the task is paused, stopping, or terminal. A terminal task may only start its successor." }; });
|
|
405
418
|
pi.on("tool_result", (_event, ctx) => publishPersisted(ctx));
|
|
406
419
|
pi.on("input", (event, ctx) => {
|
|
407
420
|
if (!isGatewayRuntimeContext(ctx) && !terminalAdapter?.dispatchAllowed(ctx)) {
|
|
@@ -409,7 +422,7 @@ export default function piagentWebUiExtension(pi: ExtensionAPI): void {
|
|
|
409
422
|
catch { /* optional UI */ }
|
|
410
423
|
return { action: "handled" } as const;
|
|
411
424
|
}
|
|
412
|
-
if (!lifecycle.
|
|
425
|
+
if (!lifecycle.inputAllowed(ctx)) {
|
|
413
426
|
try { ctx.ui.notify("Piagent task is paused or stopping. Resume it before sending new work.", "warning"); } catch { /* optional UI */ }
|
|
414
427
|
return { action: "handled" } as const;
|
|
415
428
|
}
|
|
@@ -5,11 +5,9 @@ import { webUiProjectRef, webUiSessionRef } from "../../piagent-core/runtime/ins
|
|
|
5
5
|
export const WEBUI_CONTROL_ENTRY_TYPE = "piagent-webui-control-receipt-v1";
|
|
6
6
|
const REF = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/, PUBLIC_REF = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/;
|
|
7
7
|
const REVISION = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,159}$/, DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
8
|
-
const IDEMPOTENCY = /^[A-Za-z0-9._~-]{32,128}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
export type BridgeRevisions = { runtimeRevision: string; taskRevision: NullableRef; controlRevision: NullableRef; workspaceRevision: NullableRef;
|
|
12
|
-
indexRevision: NullableRef; approvalRevision: NullableRef; sessionOptionRevision: NullableRef; queueRevision: NullableRef };
|
|
8
|
+
const IDEMPOTENCY = /^[A-Za-z0-9._~-]{32,128}$/, MAX_EVENTS = 256, MAX_SESSION_ENTRIES = 50_000; type NullableRef = string | null;
|
|
9
|
+
export type { BridgeIdentity, BridgeRevisions, BridgeSnapshot } from "../../piagent-core/runtime/inspection/session-identity.ts";
|
|
10
|
+
import type { BridgeIdentity, BridgeRevisions, BridgeSnapshot } from "../../piagent-core/runtime/inspection/session-identity.ts";
|
|
13
11
|
export type BridgeTaskFacts = { taskId: string; taskRunId: string; taskRevision: string; controlRevision: string;
|
|
14
12
|
controlState: "active" | "pause-requested" | "paused" | "terminal" | "unknown" } | null;
|
|
15
13
|
export type NewOperationChatCommand = {
|
|
@@ -29,13 +27,11 @@ export type ChatReceipt = {
|
|
|
29
27
|
};
|
|
30
28
|
export type BridgeEvent = { sequence: number; at: string; kind: "binding.ready" | "binding.closed" | "operation.started" | "operation.settled" | "command.receipt";
|
|
31
29
|
sessionRef: string | null; operationRef: string | null; commandId: string | null; resultCode: string | null };
|
|
32
|
-
export type BridgeSnapshot = { state: "unbound" | "ready" | "replacement-pending" | "shutdown"; identity: BridgeIdentity | null; revisions: BridgeRevisions | null;
|
|
33
|
-
liveness: "idle" | "running" | "unknown"; taskState: "none" | "active" | "pause-requested" | "paused" | "terminal" | "unknown"; eventSequence: number };
|
|
34
30
|
export type SessionOptionMutationPermit = { commitObservation(record: (before: BridgeRevisions, after: BridgeRevisions) => void): { before: BridgeRevisions; after: BridgeRevisions; recorded: boolean } | null; release(): void };
|
|
35
31
|
type Binding = { ctx: ExtensionContext; rawSessionId: string; identity: BridgeIdentity; revisions: BridgeRevisions; state: BridgeSnapshot["state"] };
|
|
36
32
|
type SessionOptionPermitState = { token: number; generation: number; rawSessionId: string; before: BridgeRevisions; externalConflict: boolean };
|
|
37
33
|
type DispatchContent = Parameters<ExtensionAPI["sendUserMessage"]>[0];
|
|
38
|
-
type PreparedDispatch = { content: DispatchContent; observedText: string };
|
|
34
|
+
type PreparedDispatch = { content: DispatchContent; observedText: string; commit?(): void; release?(): void };
|
|
39
35
|
type PendingDispatch = { command: NewOperationChatCommand; operationId: string; before: BridgeRevisions; leafBefore: string | null; correlationToken: string;
|
|
40
36
|
observedText: string; resolve(receipt: ChatReceipt): void; timer: NodeJS.Timeout; inputObserved: boolean };
|
|
41
37
|
type Stored = { receipt: ChatReceipt; messageRequestId?: string; contentDigest?: string; attachmentRefs?: string[] };
|
|
@@ -371,11 +367,11 @@ export class SameSessionPiBridge {
|
|
|
371
367
|
return this.#rejection(command, "stale-revision", "stale-revision", "The current Pi session changed before dispatch.");
|
|
372
368
|
if (this.#sessionOptionPermit)
|
|
373
369
|
return this.#rejection(command, "capability-unavailable", "session-option-change-in-progress", "A model or thinking change is being settled.");
|
|
370
|
+
const delivery = command.payload.delivery;
|
|
374
371
|
const controlState = this.#taskFacts(binding.ctx)?.controlState;
|
|
375
|
-
if (controlState && controlState !== "active")
|
|
372
|
+
if (controlState && controlState !== "active" && !(controlState === "terminal" && delivery === "new-operation"))
|
|
376
373
|
return this.#rejection(command, "capability-unavailable", controlState === "terminal" ? "task-terminal" : "task-control-gate-closed",
|
|
377
|
-
controlState === "terminal" ? "A
|
|
378
|
-
const delivery = command.payload.delivery;
|
|
374
|
+
controlState === "terminal" ? "A terminal task accepts only a new operation that can establish its successor." : "Task lifecycle control currently blocks dispatch.");
|
|
379
375
|
if (delivery === "new-operation" && (!binding.ctx.isIdle() || binding.identity.agentOperationId))
|
|
380
376
|
return this.#rejection(command, "dispatch-rejected", "agent-not-idle", "A new operation can start only while the Pi session is idle.");
|
|
381
377
|
if (delivery !== "new-operation" && (binding.ctx.isIdle() || !binding.identity.agentOperationId))
|
|
@@ -394,6 +390,7 @@ export class SameSessionPiBridge {
|
|
|
394
390
|
this.#queueCounter += 1; const before = copy(binding.revisions); this.#refresh();
|
|
395
391
|
const requested = this.#receipt(command, before, "requested", "dispatch-requested", null, false, operationId);
|
|
396
392
|
if (!this.#record(requested, command.payload.messageRequestId, command.payload.contentDigest, command.payload.attachmentRefs)) {
|
|
393
|
+
prepared.release?.();
|
|
397
394
|
if (delivery === "new-operation") {
|
|
398
395
|
binding.identity.agentOperationId = null; this.#operationCounter += 1; this.#refresh();
|
|
399
396
|
}
|
|
@@ -407,7 +404,10 @@ export class SameSessionPiBridge {
|
|
|
407
404
|
correlationToken, observedText: prepared.observedText, resolve, timer, inputObserved: false };
|
|
408
405
|
try { this.#dispatchContext.run(correlationToken, () => this.#pi.sendUserMessage(prepared.content,
|
|
409
406
|
delivery === "new-operation" ? undefined : { deliverAs: delivery === "steer" ? "steer" : "followUp" })); }
|
|
410
|
-
catch { this.#
|
|
407
|
+
catch { prepared.release?.(); this.#messageRequests.delete(command.payload.messageRequestId);
|
|
408
|
+
this.#settlePending("dispatch-rejected", "rejected", "pi-send-user-message-rejected", false); return; }
|
|
409
|
+
try { prepared.commit?.(); }
|
|
410
|
+
catch { this.#settlePending("dispatch-unknown", "uncertain", "attachment-commit-unavailable", false); }
|
|
411
411
|
});
|
|
412
412
|
}
|
|
413
413
|
#validate(command: NewOperationChatCommand): string | null {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const PACKAGE_NAME = "pi-web-access";
|
|
5
|
+
const VERSION = /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/;
|
|
6
|
+
const MAX_PACKAGE_MANIFEST_BYTES = 32 * 1024;
|
|
7
|
+
|
|
8
|
+
export type WebSearchCapabilityProjection = {
|
|
9
|
+
state: "configured" | "unavailable";
|
|
10
|
+
route: "codex-first" | "automatic" | null;
|
|
11
|
+
provider: "openai-codex" | null;
|
|
12
|
+
fallbackProvider: "exa" | null;
|
|
13
|
+
integration: { name: "pi-web-access"; version: string } | null;
|
|
14
|
+
reasonCode: string | null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function installedWebAccess(agentDir: string | undefined): { name: "pi-web-access"; version: string } | null {
|
|
18
|
+
if (!agentDir) return null;
|
|
19
|
+
const root = path.resolve(agentDir), manifest = path.resolve(root, "npm", "node_modules", PACKAGE_NAME, "package.json");
|
|
20
|
+
if (path.relative(root, manifest).startsWith("..")) return null;
|
|
21
|
+
try {
|
|
22
|
+
const canonicalRoot = fs.realpathSync.native(root), canonicalManifest = fs.realpathSync.native(manifest);
|
|
23
|
+
const relative = path.relative(canonicalRoot, canonicalManifest);
|
|
24
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
|
|
25
|
+
const inspected = fs.lstatSync(manifest);
|
|
26
|
+
if (!inspected.isFile() || inspected.isSymbolicLink() || inspected.size < 2 || inspected.size > MAX_PACKAGE_MANIFEST_BYTES) return null;
|
|
27
|
+
const parsed = JSON.parse(fs.readFileSync(manifest, "utf8")) as { name?: unknown; version?: unknown };
|
|
28
|
+
if (parsed.name !== PACKAGE_NAME || typeof parsed.version !== "string" || parsed.version.length > 64 || !VERSION.test(parsed.version)) return null;
|
|
29
|
+
return { name: PACKAGE_NAME, version: parsed.version };
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function inspectWebSearchCapability(input: {
|
|
36
|
+
agentDir?: string;
|
|
37
|
+
models: readonly Record<string, unknown>[];
|
|
38
|
+
}): WebSearchCapabilityProjection {
|
|
39
|
+
const integration = installedWebAccess(input.agentDir);
|
|
40
|
+
if (!integration) return {
|
|
41
|
+
state: "unavailable", route: null, provider: null, fallbackProvider: null, integration: null,
|
|
42
|
+
reasonCode: "web-search-integration-not-installed"
|
|
43
|
+
};
|
|
44
|
+
const codexAvailable = input.models.some((model) => model.provider === "openai-codex");
|
|
45
|
+
return {
|
|
46
|
+
state: "configured",
|
|
47
|
+
route: codexAvailable ? "codex-first" : "automatic",
|
|
48
|
+
provider: codexAvailable ? "openai-codex" : null,
|
|
49
|
+
fallbackProvider: "exa",
|
|
50
|
+
integration,
|
|
51
|
+
reasonCode: codexAvailable ? null : "codex-auth-not-available"
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
type ExtensionLoadResult = {
|
|
5
|
+
extensions: Array<{ path: string; resolvedPath: string }>;
|
|
6
|
+
errors: Array<{ path: string; error: string }>;
|
|
7
|
+
runtime: unknown;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const PIAGENT_GUARD_SUFFIX = path.join("packages", "piagent-core", "extensions", "piagent-guard.ts");
|
|
11
|
+
|
|
12
|
+
function canonicalExtensionPath(value: string): string {
|
|
13
|
+
try { return fs.realpathSync.native(value); }
|
|
14
|
+
catch { return path.resolve(value); }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isPiagentGuardPath(value: string): boolean {
|
|
18
|
+
const normalized = path.normalize(value);
|
|
19
|
+
return normalized === PIAGENT_GUARD_SUFFIX || normalized.endsWith(`${path.sep}${PIAGENT_GUARD_SUFFIX}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function preferAuthoritativePiagentGuard(
|
|
23
|
+
guardPath: string
|
|
24
|
+
): (base: ExtensionLoadResult) => ExtensionLoadResult {
|
|
25
|
+
const authoritative = canonicalExtensionPath(guardPath);
|
|
26
|
+
const authorityAliases = [guardPath, path.resolve(guardPath), authoritative].map((value) => path.normalize(value));
|
|
27
|
+
return (base) => {
|
|
28
|
+
const loadedAuthoritative = base.extensions.some((extension) =>
|
|
29
|
+
canonicalExtensionPath(extension.resolvedPath || extension.path) === authoritative);
|
|
30
|
+
if (!loadedAuthoritative) return base;
|
|
31
|
+
const removed = new Set<string>();
|
|
32
|
+
const extensions = base.extensions.filter((extension) => {
|
|
33
|
+
const resolved = canonicalExtensionPath(extension.resolvedPath || extension.path);
|
|
34
|
+
const duplicate = resolved !== authoritative && isPiagentGuardPath(resolved);
|
|
35
|
+
if (duplicate) removed.add(resolved);
|
|
36
|
+
return !duplicate;
|
|
37
|
+
});
|
|
38
|
+
if (removed.size === 0) return base;
|
|
39
|
+
const errors = base.errors.filter((diagnostic) => {
|
|
40
|
+
const diagnosticPath = canonicalExtensionPath(diagnostic.path);
|
|
41
|
+
return !(removed.has(diagnosticPath)
|
|
42
|
+
&& diagnostic.error.includes("conflicts with")
|
|
43
|
+
&& authorityAliases.some((value) => diagnostic.error.includes(value)));
|
|
44
|
+
});
|
|
45
|
+
return { ...base, extensions, errors };
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
|
|
5
5
|
import { redactSensitiveText } from "../../piagent-core/security/sensitive-data.js";
|
|
6
6
|
import type { PiagentGatewayCapabilityHandshakeV1 } from "../contracts/generated/gateway-capabilities-v1.ts";
|
|
7
|
+
import type { PiagentWebUICanonicalSnapshotV1 } from "../contracts/generated/snapshot-v1.ts";
|
|
7
8
|
import { startLoopbackServer } from "../server/loopback-server.ts";
|
|
8
9
|
import { startGatewayControlSocket, type GatewayControlResponse } from "./control-socket.ts";
|
|
9
10
|
import { loadPinnedPiHost } from "./pi-host.ts";
|
|
@@ -24,6 +25,7 @@ import { SessionCommandStore } from "./session-command-store.ts";
|
|
|
24
25
|
import { SessionCommandController } from "./session-command-controller.ts";
|
|
25
26
|
import { GatewayEventStore } from "./gateway-events.ts";
|
|
26
27
|
import { SessionInspectionRegistry } from "./session-inspection-registry.ts";
|
|
28
|
+
import { SessionAttachmentRegistry } from "./session-attachment-registry.ts";
|
|
27
29
|
import { ProjectRegistry } from "./project-registry.ts";
|
|
28
30
|
import { pickNativeProjectFolders } from "./native-project-picker.ts";
|
|
29
31
|
import { ProviderAuthBroker } from "./provider-auth-broker.ts";
|
|
@@ -91,6 +93,7 @@ export async function startPiagentGateway(options: {
|
|
|
91
93
|
let control: Awaited<ReturnType<typeof startGatewayControlSocket>> | null = null;
|
|
92
94
|
let runtimes: SessionRuntimeSupervisor | null = null;
|
|
93
95
|
let mcpAuth: McpAuthBroker | null = null;
|
|
96
|
+
let attachments: SessionAttachmentRegistry | null = null;
|
|
94
97
|
let closing: Promise<void> | null = null;
|
|
95
98
|
let settleWait: (() => void) | null = null;
|
|
96
99
|
const waited = new Promise<void>((resolve) => { settleWait = resolve; });
|
|
@@ -100,6 +103,10 @@ export async function startPiagentGateway(options: {
|
|
|
100
103
|
closing = (async () => {
|
|
101
104
|
removeGatewayDescriptor(state, gatewayInstanceRef);
|
|
102
105
|
await loopback?.close().catch(() => undefined);
|
|
106
|
+
// Staged bytes are private temp files. Closing deletes them rather than
|
|
107
|
+
// leaving a directory per session behind for the TTL sweep that will never
|
|
108
|
+
// run once this process is gone.
|
|
109
|
+
try { attachments?.close(); } catch { /* shutdown never fails on cleanup */ }
|
|
103
110
|
await mcpAuth?.close().catch(() => undefined);
|
|
104
111
|
await runtimes?.close().catch(() => undefined);
|
|
105
112
|
await control?.close().catch(() => undefined);
|
|
@@ -161,11 +168,23 @@ export async function startPiagentGateway(options: {
|
|
|
161
168
|
return { sessionRevision: session.sessionRevision, liveState: session.liveState };
|
|
162
169
|
});
|
|
163
170
|
const commands = new SessionCommandController({ catalog: readCatalog, runtimes, metadata,
|
|
164
|
-
store: new SessionCommandStore(state.root, key), events
|
|
171
|
+
store: new SessionCommandStore(state.root, key), events,
|
|
172
|
+
// Resolved at call time: the attachment registry is built after this
|
|
173
|
+
// controller, because it reads sessions through the inspection registry.
|
|
174
|
+
prepareAttachments: (sessionRef, refs, messageRequestId, text) => {
|
|
175
|
+
if (!attachments) throw new Error("session-attachment-unavailable");
|
|
176
|
+
return attachments.reserveForPrompt(sessionRef, refs, messageRequestId, text);
|
|
177
|
+
} });
|
|
165
178
|
const protocol = new GatewayProtocolService({ capabilities: () => capabilities(gatewayInstanceRef, true), catalog: readCatalog,
|
|
166
179
|
events, command: commands });
|
|
167
|
-
const inspections = new SessionInspectionRegistry({ gatewayInstanceRef, host, key, packageRoot: options.packageRoot,
|
|
168
|
-
models: inspectionModels, projects, mcpAuth
|
|
180
|
+
const inspections = new SessionInspectionRegistry({ gatewayInstanceRef, host, key, packageRoot: options.packageRoot, agentDir: state.agentDir,
|
|
181
|
+
models: inspectionModels, projects, mcpAuth, listSessions: () => runtimes!.listSessions(),
|
|
182
|
+
openLiveSession: (sessionRef) => runtimes!.liveSessionManager(sessionRef) });
|
|
183
|
+
// Staged bytes live beside the inspection projection they were checked
|
|
184
|
+
// against, so both read the same session through the same registry.
|
|
185
|
+
attachments = new SessionAttachmentRegistry({
|
|
186
|
+
inspect: async (sessionRef) => await (await inspections.provider(sessionRef)).snapshot() as PiagentWebUICanonicalSnapshotV1
|
|
187
|
+
});
|
|
169
188
|
loopback = await startLoopbackServer({
|
|
170
189
|
staticRoot,
|
|
171
190
|
mode: "gateway",
|
|
@@ -175,6 +194,7 @@ export async function startPiagentGateway(options: {
|
|
|
175
194
|
readSessionModel: (sessionRef) => inspections.provider(sessionRef),
|
|
176
195
|
readSessionConnections: (sessionRef) => inspections.connections(sessionRef),
|
|
177
196
|
executeSessionConnection: (command) => inspections.executeConnectionCommand(command),
|
|
197
|
+
executeSessionAttachment: (sessionRef, command) => attachments.execute(sessionRef, command),
|
|
178
198
|
executeApproval: (approvalRef, decision) => runtimes!.decideApproval(approvalRef, decision),
|
|
179
199
|
readMcpAuthJob: (jobRef) => mcpAuth!.read(jobRef),
|
|
180
200
|
cancelMcpAuthJob: (jobRef) => mcpAuth!.cancel(jobRef),
|
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
2
5
|
|
|
3
|
-
|
|
6
|
+
type PickerSpec = { executable: string; args: string[] };
|
|
7
|
+
type PickerEnvironment = Readonly<Record<string, string | undefined>>;
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
9
|
+
function executableOnPath(name: string, environment: PickerEnvironment): string | null {
|
|
10
|
+
for (const directory of String(environment.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
|
11
|
+
const candidate = path.join(directory, name);
|
|
12
|
+
try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch { /* try the next PATH entry */ }
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The browser deliberately cannot reveal an absolute directory path. The
|
|
18
|
+
// Gateway therefore uses the desktop's own picker and receives only the paths
|
|
19
|
+
// the operator selected. macOS ships AppleScript; Linux desktops commonly ship
|
|
20
|
+
// one of the freedesktop-facing dialog helpers below.
|
|
21
|
+
export function resolveNativeProjectPicker(options: { platform?: NodeJS.Platform; environment?: PickerEnvironment } = {}): PickerSpec | null {
|
|
22
|
+
const platform = options.platform ?? process.platform, environment = options.environment ?? process.env;
|
|
23
|
+
if (platform === "darwin") {
|
|
24
|
+
try { fs.accessSync("/usr/bin/osascript", fs.constants.X_OK); } catch { return null; }
|
|
8
25
|
const script = [
|
|
9
26
|
'set chosenFolders to choose folder with prompt "Choose project folders for Piagent" with multiple selections allowed',
|
|
10
27
|
'set output to ""',
|
|
@@ -13,9 +30,48 @@ export function pickNativeProjectFolders(): Promise<string[]> {
|
|
|
13
30
|
'end repeat',
|
|
14
31
|
'return output'
|
|
15
32
|
].join("\n");
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
33
|
+
return { executable: "/usr/bin/osascript", args: ["-e", script] };
|
|
34
|
+
}
|
|
35
|
+
if (platform !== "linux" || !(environment.DISPLAY || environment.WAYLAND_DISPLAY)) return null;
|
|
36
|
+
for (const name of ["zenity", "qarma"]) {
|
|
37
|
+
const executable = executableOnPath(name, environment);
|
|
38
|
+
if (executable) return { executable, args: ["--file-selection", "--directory", "--multiple", "--separator=\n",
|
|
39
|
+
"--title=Choose project folders for Piagent"] };
|
|
40
|
+
}
|
|
41
|
+
const kdialog = executableOnPath("kdialog", environment);
|
|
42
|
+
return kdialog ? { executable: kdialog, args: ["--getexistingdirectory", process.cwd(), "--title", "Choose a project folder for Piagent"] } : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function nativeProjectPickerAvailable(options: { platform?: NodeJS.Platform; environment?: PickerEnvironment } = {}): boolean {
|
|
46
|
+
return resolveNativeProjectPicker(options) !== null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function selectedFolders(stdout: string): string[] {
|
|
50
|
+
const selected: string[] = [];
|
|
51
|
+
for (const raw of stdout.split("\n")) {
|
|
52
|
+
const value = raw.trim(); if (!value) continue;
|
|
53
|
+
let candidate = value;
|
|
54
|
+
if (value.startsWith("file:")) {
|
|
55
|
+
try { candidate = fileURLToPath(value); } catch { continue; }
|
|
56
|
+
}
|
|
57
|
+
if (candidate.includes("\0") || /[\u0001-\u001f\u007f]/.test(candidate) || selected.includes(candidate)) continue;
|
|
58
|
+
selected.push(candidate);
|
|
59
|
+
if (selected.length === 32) break;
|
|
60
|
+
}
|
|
61
|
+
return selected;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function pickNativeProjectFolders(): Promise<string[]> {
|
|
65
|
+
const picker = resolveNativeProjectPicker();
|
|
66
|
+
if (!picker) return Promise.reject(new Error("native-project-picker-unavailable"));
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
execFile(picker.executable, picker.args, { timeout: 5 * 60_000, maxBuffer: 64 * 1024 }, (error, stdout) => {
|
|
69
|
+
if (error) {
|
|
70
|
+
const exitCode = typeof (error as NodeJS.ErrnoException & { code?: unknown }).code === "number"
|
|
71
|
+
? (error as unknown as { code: number }).code : null;
|
|
72
|
+
reject(new Error(exitCode === 1 ? "project-import-cancelled" : "project-import-picker-failed")); return;
|
|
73
|
+
}
|
|
74
|
+
const selected = selectedFolders(stdout);
|
|
19
75
|
if (!selected.length) { reject(new Error("project-import-cancelled")); return; }
|
|
20
76
|
resolve(selected);
|
|
21
77
|
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { PiagentWebUICanonicalSnapshotV1 } from "../contracts/generated/snapshot-v1.ts";
|
|
2
|
+
import { AttachmentStore, type DiscardReceipt, type PreparedAttachments,
|
|
3
|
+
type StageReceipt } from "../../piagent-core/runtime/input/attachment-store.ts";
|
|
4
|
+
import type { AttachmentReservation } from "../../piagent-core/runtime/input/attachment-reservation.ts";
|
|
5
|
+
import type { BridgeIdentity, BridgeSnapshot } from "../../piagent-core/runtime/inspection/session-identity.ts";
|
|
6
|
+
|
|
7
|
+
// Attachments for sessions the Gateway drives.
|
|
8
|
+
//
|
|
9
|
+
// The in-session WebUI stages attachments against a live `SameSessionPiBridge`,
|
|
10
|
+
// which knows the identity and revisions of the session it is bound to. The
|
|
11
|
+
// Gateway has no such bridge: it owns many sessions and reads each one through
|
|
12
|
+
// an inspection provider. What it does have is the exact snapshot the browser
|
|
13
|
+
// was given, and that snapshot carries the same identity and revisions the
|
|
14
|
+
// browser will quote back in a stage command.
|
|
15
|
+
//
|
|
16
|
+
// So the bridge the store needs is synthesised from that snapshot, immediately
|
|
17
|
+
// before the store reads it. The store's identity and revision checks then
|
|
18
|
+
// compare the browser's claim against the same projection the browser was
|
|
19
|
+
// answering, which is the property those checks exist to enforce — a command
|
|
20
|
+
// aimed at a session that has since changed is refused rather than applied to
|
|
21
|
+
// whatever the session became.
|
|
22
|
+
|
|
23
|
+
const MAX_SESSION_STORES = 16;
|
|
24
|
+
|
|
25
|
+
function bridgeIdentity(snapshot: PiagentWebUICanonicalSnapshotV1): BridgeIdentity {
|
|
26
|
+
const identity = snapshot.identity;
|
|
27
|
+
return { projectRef: identity.projectRef, runtimeInstanceId: identity.runtimeInstanceId, sessionRef: identity.sessionRef,
|
|
28
|
+
taskId: identity.taskId ?? null, taskRunId: identity.taskRunId ?? null, agentOperationId: identity.agentOperationId ?? null,
|
|
29
|
+
toolCallId: null };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function bridgeSnapshotOf(snapshot: PiagentWebUICanonicalSnapshotV1): BridgeSnapshot {
|
|
33
|
+
const revision = snapshot.revision;
|
|
34
|
+
// A terminal session refuses staging inside the store. Everything else the
|
|
35
|
+
// Gateway can answer for is a session the operator may still attach to.
|
|
36
|
+
const controlState = snapshot.session.controlState;
|
|
37
|
+
return {
|
|
38
|
+
state: "ready",
|
|
39
|
+
identity: bridgeIdentity(snapshot),
|
|
40
|
+
revisions: { runtimeRevision: revision.runtimeRevision, taskRevision: revision.taskRevision ?? null,
|
|
41
|
+
controlRevision: revision.controlRevision ?? null, workspaceRevision: revision.workspaceRevision ?? null,
|
|
42
|
+
indexRevision: revision.indexRevision ?? null, approvalRevision: revision.approvalRevision ?? null,
|
|
43
|
+
sessionOptionRevision: revision.sessionOptionRevision ?? null, queueRevision: revision.queueRevision ?? null },
|
|
44
|
+
liveness: snapshot.session.operation.liveness === "running" ? "running" : "idle",
|
|
45
|
+
taskState: controlState === "terminal" ? "terminal" : controlState === "active" ? "active" : "unknown",
|
|
46
|
+
eventSequence: 0
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function supportsImages(snapshot: PiagentWebUICanonicalSnapshotV1): boolean {
|
|
51
|
+
const model = snapshot.session.model;
|
|
52
|
+
// The contract types this as a tuple union to bound its length, which leaves
|
|
53
|
+
// includes() narrowed to never. The runtime value is a list of strings.
|
|
54
|
+
const capabilities = model.value?.inputCapabilities as readonly string[] | undefined;
|
|
55
|
+
return model.state === "known" && Boolean(capabilities?.includes("image"));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type Slot = { store: AttachmentStore; current: BridgeSnapshot; images: boolean };
|
|
59
|
+
|
|
60
|
+
export class SessionAttachmentRegistry {
|
|
61
|
+
readonly #slots = new Map<string, Slot>();
|
|
62
|
+
readonly #inspect: (sessionRef: string) => Promise<PiagentWebUICanonicalSnapshotV1>;
|
|
63
|
+
readonly #tempRoot: string | undefined;
|
|
64
|
+
readonly #now: (() => Date) | undefined;
|
|
65
|
+
#closed = false;
|
|
66
|
+
|
|
67
|
+
constructor(options: { inspect: (sessionRef: string) => Promise<PiagentWebUICanonicalSnapshotV1>; tempRoot?: string; now?: () => Date }) {
|
|
68
|
+
this.#inspect = options.inspect;
|
|
69
|
+
this.#tempRoot = options.tempRoot;
|
|
70
|
+
this.#now = options.now;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Every entry point refreshes the synthesised bridge first. A store that kept
|
|
74
|
+
// the snapshot it was created with would accept a command aimed at a revision
|
|
75
|
+
// the session has already moved past.
|
|
76
|
+
async #slot(sessionRef: string): Promise<Slot> {
|
|
77
|
+
if (this.#closed) throw new Error("session-attachment-registry-closed");
|
|
78
|
+
const snapshot = await this.#inspect(sessionRef);
|
|
79
|
+
const existing = this.#slots.get(sessionRef);
|
|
80
|
+
if (existing) {
|
|
81
|
+
existing.current = bridgeSnapshotOf(snapshot);
|
|
82
|
+
existing.images = supportsImages(snapshot);
|
|
83
|
+
// Refresh recency so the cap evicts the least recently used session.
|
|
84
|
+
this.#slots.delete(sessionRef); this.#slots.set(sessionRef, existing);
|
|
85
|
+
return existing;
|
|
86
|
+
}
|
|
87
|
+
const slot: Slot = { current: bridgeSnapshotOf(snapshot), images: supportsImages(snapshot), store: null as unknown as AttachmentStore };
|
|
88
|
+
slot.store = new AttachmentStore({
|
|
89
|
+
runtimeInstanceId: snapshot.identity.runtimeInstanceId,
|
|
90
|
+
bridgeSnapshot: () => slot.current,
|
|
91
|
+
modelSupportsImages: () => slot.images,
|
|
92
|
+
tempRoot: this.#tempRoot,
|
|
93
|
+
now: this.#now
|
|
94
|
+
});
|
|
95
|
+
this.#slots.set(sessionRef, slot);
|
|
96
|
+
// Each store owns a private directory and staged bytes, so an unbounded map
|
|
97
|
+
// is unbounded disk. The evicted store deletes what it held.
|
|
98
|
+
while (this.#slots.size > MAX_SESSION_STORES) {
|
|
99
|
+
const oldest = this.#slots.keys().next().value as string;
|
|
100
|
+
if (oldest === sessionRef) break;
|
|
101
|
+
this.#slots.get(oldest)?.store.close();
|
|
102
|
+
this.#slots.delete(oldest);
|
|
103
|
+
}
|
|
104
|
+
return slot;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async execute(sessionRef: string, command: unknown): Promise<StageReceipt | DiscardReceipt> {
|
|
108
|
+
return (await this.#slot(sessionRef)).store.execute(command);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async capability(sessionRef: string): Promise<{ kinds: Array<"file" | "image" | "document">; mimeTypes: string[] }> {
|
|
112
|
+
return (await this.#slot(sessionRef)).store.capability();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Claimed at dispatch, from the send path, which already holds the identity it
|
|
116
|
+
// is sending under. Refs are one-shot: a claim consumes them.
|
|
117
|
+
async claim(sessionRef: string, refs: string[], messageRequestId: string, text: string): Promise<PreparedAttachments> {
|
|
118
|
+
const slot = await this.#slot(sessionRef);
|
|
119
|
+
if (!slot.current.identity) throw new Error("session-attachment-identity-unavailable");
|
|
120
|
+
return slot.store.claim(refs, messageRequestId, slot.current.identity, text);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async reserve(sessionRef: string, refs: string[], messageRequestId: string, text: string): Promise<AttachmentReservation<PreparedAttachments>> {
|
|
124
|
+
const slot = await this.#slot(sessionRef);
|
|
125
|
+
if (!slot.current.identity) throw new Error("session-attachment-identity-unavailable");
|
|
126
|
+
return slot.store.reserve(refs, messageRequestId, slot.current.identity, text);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The shape the host prompt takes. Documents and text files were already
|
|
130
|
+
// turned into text when they were staged, so they join the prompt string;
|
|
131
|
+
// images ride the host's own `images` channel. Split here rather than in the
|
|
132
|
+
// supervisor so the knowledge of what a claim returns stays next to the store
|
|
133
|
+
// that produced it.
|
|
134
|
+
async claimForPrompt(sessionRef: string, refs: string[], messageRequestId: string, message: string):
|
|
135
|
+
Promise<{ text: string; images: unknown[] }> {
|
|
136
|
+
if (refs.length === 0) return { text: message, images: [] };
|
|
137
|
+
const prepared = await this.claim(sessionRef, refs, messageRequestId, message);
|
|
138
|
+
const parts = Array.isArray(prepared.content) ? prepared.content : [{ type: "text" as const, text: message }];
|
|
139
|
+
return {
|
|
140
|
+
text: parts.filter((part) => part.type === "text").map((part) => part.text).join("\n"),
|
|
141
|
+
images: parts.filter((part) => part.type === "image")
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async reserveForPrompt(sessionRef: string, refs: string[], messageRequestId: string, message: string):
|
|
147
|
+
Promise<{ text: string; images: unknown[]; commit(): void; release(): void }> {
|
|
148
|
+
if (refs.length === 0) return { text: message, images: [], commit() {}, release() {} };
|
|
149
|
+
const reservation = await this.reserve(sessionRef, refs, messageRequestId, message);
|
|
150
|
+
const parts = Array.isArray(reservation.prepared.content) ? reservation.prepared.content
|
|
151
|
+
: [{ type: "text" as const, text: message }];
|
|
152
|
+
return {
|
|
153
|
+
text: parts.filter((part) => part.type === "text").map((part) => part.text).join("\n"),
|
|
154
|
+
images: parts.filter((part) => part.type === "image"),
|
|
155
|
+
commit: reservation.commit,
|
|
156
|
+
release: reservation.release
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
close(): void {
|
|
161
|
+
this.#closed = true;
|
|
162
|
+
for (const slot of this.#slots.values()) slot.store.close();
|
|
163
|
+
this.#slots.clear();
|
|
164
|
+
}
|
|
165
|
+
}
|