@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.
Files changed (132) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +12 -2
  3. package/README.vi.md +47 -0
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/distribution-standard.md +7 -7
  6. package/docs/en/architecture.md +13 -0
  7. package/docs/languages.json +20 -4
  8. package/docs/mcp-and-tools.md +112 -1
  9. package/docs/operator-manual-vietnamese.md +7 -7
  10. package/docs/publishing-for-teams.md +42 -0
  11. package/docs/release-install-policy.md +21 -8
  12. package/docs/runtime-harness-standard.md +1 -1
  13. package/docs/security-threat-model.md +1 -1
  14. package/docs/task-implementation-contract.md +11 -4
  15. package/docs/team-onboarding.md +2 -2
  16. package/docs/usage-observability.md +70 -0
  17. package/docs/vi/architecture.md +13 -0
  18. package/docs-site/architecture.html +5 -1
  19. package/docs-site/benchmark.html +5 -1
  20. package/docs-site/commands.html +5 -1
  21. package/docs-site/content/en/index.html +49 -0
  22. package/docs-site/content/en/runtime.html +13 -0
  23. package/docs-site/content/index.html +49 -0
  24. package/docs-site/content/runtime.html +28 -0
  25. package/docs-site/context-engine.html +5 -1
  26. package/docs-site/en/architecture.html +5 -1
  27. package/docs-site/en/benchmark.html +5 -1
  28. package/docs-site/en/commands.html +5 -1
  29. package/docs-site/en/context-engine.html +5 -1
  30. package/docs-site/en/index.html +54 -1
  31. package/docs-site/en/mcp-approval.html +5 -1
  32. package/docs-site/en/mcp-auth.html +5 -1
  33. package/docs-site/en/mcp-servers.html +5 -1
  34. package/docs-site/en/mcp.html +5 -1
  35. package/docs-site/en/permissions.html +5 -1
  36. package/docs-site/en/profiles.html +5 -1
  37. package/docs-site/en/quickstart.html +5 -1
  38. package/docs-site/en/runtime.html +18 -1
  39. package/docs-site/en/scope.html +5 -1
  40. package/docs-site/en/security.html +5 -1
  41. package/docs-site/en/team.html +5 -1
  42. package/docs-site/en/whats-new.html +5 -1
  43. package/docs-site/en/workflows.html +5 -1
  44. package/docs-site/index.html +54 -1
  45. package/docs-site/mcp-approval.html +5 -1
  46. package/docs-site/mcp-auth.html +5 -1
  47. package/docs-site/mcp-servers.html +5 -1
  48. package/docs-site/mcp.html +5 -1
  49. package/docs-site/permissions.html +5 -1
  50. package/docs-site/profiles.html +5 -1
  51. package/docs-site/quickstart.html +5 -1
  52. package/docs-site/runtime.html +33 -1
  53. package/docs-site/scope.html +5 -1
  54. package/docs-site/security.html +5 -1
  55. package/docs-site/team.html +5 -1
  56. package/docs-site/whats-new.html +5 -1
  57. package/docs-site/workflows.html +5 -1
  58. package/evals/fixtures/piagent-webui/document-workspace-v1.invalid.json +17 -0
  59. package/evals/fixtures/piagent-webui/document-workspace-v1.valid.json +42 -0
  60. package/evals/fixtures/piagent-webui/session-command-v1.valid.json +2 -1
  61. package/package.json +14 -2
  62. package/packages/piagent-core/README.md +3 -2
  63. package/packages/piagent-core/capabilities/capability-verification-cache.js +17 -7
  64. package/packages/piagent-core/capabilities/runtime-integrity.js +51 -4
  65. package/packages/piagent-core/extensions/document-intake.ts +6 -3
  66. package/packages/piagent-core/extensions/piagent-guard.ts +20 -237
  67. package/packages/piagent-core/extensions/runtime-evidence.js +12 -1
  68. package/packages/piagent-core/extensions/shell-reach.ts +250 -0
  69. package/packages/piagent-core/mcp/mcp-command-actions.js +1 -1
  70. package/packages/piagent-core/mcp/mcp-server-catalog.js +135 -5
  71. package/packages/piagent-core/package.json +1 -1
  72. package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +9 -2
  73. package/packages/piagent-core/runtime/input/attachment-document-command.ts +39 -0
  74. package/packages/piagent-core/runtime/input/attachment-reservation.ts +36 -0
  75. package/packages/{piagent-webui/extension → piagent-core/runtime/input}/attachment-store.ts +221 -44
  76. package/packages/piagent-core/runtime/inspection/document-workspace-projection.ts +276 -0
  77. package/packages/piagent-core/runtime/inspection/project-runtime-inspection.ts +20 -5
  78. package/packages/piagent-core/runtime/inspection/session-identity.ts +20 -0
  79. package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +25 -2
  80. package/packages/piagent-core/runtime/policy/block-remedy.ts +139 -0
  81. package/packages/piagent-core/runtime/registration/task-start-tool.ts +9 -13
  82. package/packages/piagent-core/runtime/registration/workflow-commands.ts +5 -4
  83. package/packages/piagent-core/runtime/session/token-economics.js +34 -0
  84. package/packages/piagent-core/runtime/tools/phase-tool-runtime.ts +2 -1
  85. package/packages/piagent-core/runtime/workflows/input-routing.ts +13 -2
  86. package/packages/piagent-core/runtime/workflows/task-intake.ts +5 -0
  87. package/packages/piagent-core/security/sensitive-data.js +7 -0
  88. package/packages/piagent-webui/dist/client/assets/MarkdownMessage-Uouhx1QO.js +29 -0
  89. package/packages/piagent-webui/dist/client/assets/index-BV-_03yl.css +1 -0
  90. package/packages/piagent-webui/dist/client/assets/index-MBZq8XLV.js +174 -0
  91. package/packages/piagent-webui/dist/client/index.html +2 -2
  92. package/packages/piagent-webui/extension/lifecycle-controller.ts +15 -0
  93. package/packages/piagent-webui/extension/piagent-webui.ts +19 -6
  94. package/packages/piagent-webui/extension/same-session-bridge.ts +12 -12
  95. package/packages/piagent-webui/gateway/ai-capability-inspection.ts +53 -0
  96. package/packages/piagent-webui/gateway/extension-authority.ts +47 -0
  97. package/packages/piagent-webui/gateway/gateway-service.ts +23 -3
  98. package/packages/piagent-webui/gateway/native-project-picker.ts +63 -7
  99. package/packages/piagent-webui/gateway/session-attachment-registry.ts +165 -0
  100. package/packages/piagent-webui/gateway/session-catalog.ts +62 -9
  101. package/packages/piagent-webui/gateway/session-command-controller.ts +47 -14
  102. package/packages/piagent-webui/gateway/session-inspection-registry.ts +67 -12
  103. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +24 -11
  104. package/packages/piagent-webui/package.json +3 -1
  105. package/packages/piagent-webui/server/core-inspection-provider.ts +52 -15
  106. package/packages/piagent-webui/server/ipc-read-model-client.ts +2 -0
  107. package/packages/piagent-webui/server/loopback-server.ts +63 -25
  108. package/packages/piagent-webui/server/read-model-provider.ts +2 -0
  109. package/packages/piagent-webui/server/read-only-router.ts +6 -0
  110. package/packages/piagent-webui/server/transcript-projection.ts +58 -2
  111. package/schemas/piagent-webui/attachment-v1.schema.json +24 -4
  112. package/schemas/piagent-webui/capabilities-v1.schema.json +2 -2
  113. package/schemas/piagent-webui/catalog-v1.json +9 -0
  114. package/schemas/piagent-webui/document-workspace-v1.schema.json +155 -0
  115. package/schemas/piagent-webui/session-command-v1.schema.json +14 -3
  116. package/schemas/piagent-webui/transcript-v1.schema.json +27 -0
  117. package/scripts/build-docs-site.mjs +21 -1
  118. package/scripts/explain-command.mjs +240 -0
  119. package/scripts/install-global.sh +104 -3
  120. package/scripts/mcp-manage.mjs +17 -4
  121. package/scripts/pi-session-stats.sh +10 -2
  122. package/scripts/piagent-cli.mjs +14 -1
  123. package/scripts/piagent-dashboard.mjs +55 -2
  124. package/scripts/setup.sh +69 -33
  125. package/scripts/traffic-snapshot.mjs +214 -0
  126. package/scripts/typescript-loader.mjs +20 -4
  127. package/scripts/uninstall-global.sh +53 -2
  128. package/scripts/update-global.mjs +33 -1
  129. package/scripts/verify-local.sh +20 -1
  130. package/scripts/verify-translation-pairing.mjs +121 -0
  131. package/packages/piagent-webui/dist/client/assets/index-DjMntaK5.js +0 -172
  132. 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-DjMntaK5.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-DwKCWbwd.css">
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 "./attachment-store.ts";
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"); return attachments.claim(refs, request, identity, text);
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", (_event, ctx) => { publishPersisted(ctx); if (!isGatewayRuntimeContext(ctx) && !terminalAdapter?.dispatchAllowed(ctx)) return { block: true,
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.dispatchAllowed(ctx)) return { block: true,
404
- reason: "Piagent lifecycle control blocks new tool work while the task is paused or stopping." }; });
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.dispatchAllowed(ctx)) {
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
- const MAX_EVENTS = 256, MAX_SESSION_ENTRIES = 50_000; type NullableRef = string | null;
10
- export type BridgeIdentity = { projectRef: string; runtimeInstanceId: string; sessionRef: string; taskId: string | null; taskRunId: string | null; agentOperationId: string | null; toolCallId: null };
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 completed task cannot accept another dispatch." : "Task lifecycle control currently blocks dispatch.");
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.#settlePending("dispatch-rejected", "rejected", "pi-send-user-message-rejected", false); }
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
- export function nativeProjectPickerAvailable(): boolean { return process.platform === "darwin"; }
6
+ type PickerSpec = { executable: string; args: string[] };
7
+ type PickerEnvironment = Readonly<Record<string, string | undefined>>;
4
8
 
5
- export function pickNativeProjectFolders(): Promise<string[]> {
6
- if (!nativeProjectPickerAvailable()) return Promise.reject(new Error("native-project-picker-unavailable"));
7
- return new Promise((resolve, reject) => {
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
- execFile("/usr/bin/osascript", ["-e", script], { timeout: 5 * 60_000, maxBuffer: 64 * 1024 }, (error, stdout) => {
17
- if (error) { reject(new Error("project-import-cancelled")); return; }
18
- const selected = [...new Set(stdout.split("\n").map((value) => value.trim()).filter(Boolean))].slice(0, 32);
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
+ }