@piagent/platform 1.4.1 → 1.5.2

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 (92) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +5 -1
  3. package/README.vi.md +21 -1
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/distribution-standard.md +7 -7
  6. package/docs/languages.json +2 -2
  7. package/docs/operator-manual-vietnamese.md +7 -7
  8. package/docs/publishing-for-teams.md +9 -0
  9. package/docs/release-install-policy.md +6 -6
  10. package/docs/security-threat-model.md +1 -1
  11. package/docs/team-onboarding.md +2 -2
  12. package/docs-site/architecture.html +1 -1
  13. package/docs-site/benchmark.html +1 -1
  14. package/docs-site/commands.html +1 -1
  15. package/docs-site/context-engine.html +1 -1
  16. package/docs-site/en/architecture.html +1 -1
  17. package/docs-site/en/benchmark.html +1 -1
  18. package/docs-site/en/commands.html +1 -1
  19. package/docs-site/en/context-engine.html +1 -1
  20. package/docs-site/en/index.html +1 -1
  21. package/docs-site/en/mcp-approval.html +1 -1
  22. package/docs-site/en/mcp-auth.html +1 -1
  23. package/docs-site/en/mcp-servers.html +1 -1
  24. package/docs-site/en/mcp.html +1 -1
  25. package/docs-site/en/permissions.html +1 -1
  26. package/docs-site/en/profiles.html +1 -1
  27. package/docs-site/en/quickstart.html +1 -1
  28. package/docs-site/en/runtime.html +1 -1
  29. package/docs-site/en/scope.html +1 -1
  30. package/docs-site/en/security.html +1 -1
  31. package/docs-site/en/team.html +1 -1
  32. package/docs-site/en/whats-new.html +1 -1
  33. package/docs-site/en/workflows.html +1 -1
  34. package/docs-site/index.html +1 -1
  35. package/docs-site/mcp-approval.html +1 -1
  36. package/docs-site/mcp-auth.html +1 -1
  37. package/docs-site/mcp-servers.html +1 -1
  38. package/docs-site/mcp.html +1 -1
  39. package/docs-site/permissions.html +1 -1
  40. package/docs-site/profiles.html +1 -1
  41. package/docs-site/quickstart.html +1 -1
  42. package/docs-site/runtime.html +1 -1
  43. package/docs-site/scope.html +1 -1
  44. package/docs-site/security.html +1 -1
  45. package/docs-site/team.html +1 -1
  46. package/docs-site/whats-new.html +1 -1
  47. package/docs-site/workflows.html +1 -1
  48. package/evals/fixtures/piagent-webui/document-workspace-v1.invalid.json +17 -0
  49. package/evals/fixtures/piagent-webui/document-workspace-v1.valid.json +42 -0
  50. package/evals/fixtures/piagent-webui/session-command-v1.valid.json +2 -1
  51. package/package.json +1 -1
  52. package/packages/piagent-core/extensions/document-intake.ts +6 -3
  53. package/packages/piagent-core/extensions/piagent-guard.ts +2 -2
  54. package/packages/piagent-core/package.json +1 -1
  55. package/packages/piagent-core/runtime/input/attachment-document-command.ts +39 -0
  56. package/packages/piagent-core/runtime/input/attachment-reservation.ts +36 -0
  57. package/packages/{piagent-webui/extension → piagent-core/runtime/input}/attachment-store.ts +221 -44
  58. package/packages/piagent-core/runtime/inspection/document-workspace-projection.ts +276 -0
  59. package/packages/piagent-core/runtime/inspection/project-runtime-inspection.ts +20 -5
  60. package/packages/piagent-core/runtime/inspection/session-identity.ts +20 -0
  61. package/packages/piagent-core/runtime/registration/workflow-commands.ts +5 -4
  62. package/packages/piagent-core/runtime/workflows/input-routing.ts +13 -2
  63. package/packages/piagent-webui/dist/client/assets/MarkdownMessage-CbUK4gg2.js +29 -0
  64. package/packages/piagent-webui/dist/client/assets/index-BV-_03yl.css +1 -0
  65. package/packages/piagent-webui/dist/client/assets/index-CNbYWd-R.js +174 -0
  66. package/packages/piagent-webui/dist/client/index.html +2 -2
  67. package/packages/piagent-webui/extension/piagent-webui.ts +15 -2
  68. package/packages/piagent-webui/extension/same-session-bridge.ts +9 -9
  69. package/packages/piagent-webui/gateway/ai-capability-inspection.ts +53 -0
  70. package/packages/piagent-webui/gateway/gateway-service.ts +23 -3
  71. package/packages/piagent-webui/gateway/native-project-picker.ts +63 -7
  72. package/packages/piagent-webui/gateway/session-attachment-registry.ts +165 -0
  73. package/packages/piagent-webui/gateway/session-catalog.ts +62 -9
  74. package/packages/piagent-webui/gateway/session-command-controller.ts +47 -14
  75. package/packages/piagent-webui/gateway/session-inspection-registry.ts +30 -10
  76. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +19 -10
  77. package/packages/piagent-webui/package.json +3 -1
  78. package/packages/piagent-webui/server/core-inspection-provider.ts +48 -11
  79. package/packages/piagent-webui/server/ipc-read-model-client.ts +2 -0
  80. package/packages/piagent-webui/server/loopback-server.ts +63 -25
  81. package/packages/piagent-webui/server/read-model-provider.ts +2 -0
  82. package/packages/piagent-webui/server/read-only-router.ts +6 -0
  83. package/packages/piagent-webui/server/transcript-projection.ts +43 -2
  84. package/schemas/piagent-webui/attachment-v1.schema.json +24 -4
  85. package/schemas/piagent-webui/capabilities-v1.schema.json +2 -2
  86. package/schemas/piagent-webui/catalog-v1.json +9 -0
  87. package/schemas/piagent-webui/document-workspace-v1.schema.json +155 -0
  88. package/schemas/piagent-webui/session-command-v1.schema.json +14 -3
  89. package/schemas/piagent-webui/transcript-v1.schema.json +27 -0
  90. package/scripts/verify-local.sh +16 -1
  91. package/packages/piagent-webui/dist/client/assets/index-DwKCWbwd.css +0 -1
  92. package/packages/piagent-webui/dist/client/assets/index-bu7XpD9Q.js +0 -172
@@ -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-bu7XpD9Q.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-DwKCWbwd.css">
10
+ <script type="module" crossorigin src="/assets/index-CNbYWd-R.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>
@@ -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(),
@@ -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[] };
@@ -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
+ }
@@ -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
+ }
@@ -13,6 +13,7 @@ export type PiSessionInfo = {
13
13
  id: string;
14
14
  cwd: string;
15
15
  name?: string;
16
+ parentSessionPath?: string;
16
17
  created: Date;
17
18
  modified: Date;
18
19
  messageCount: number;
@@ -20,6 +21,26 @@ export type PiSessionInfo = {
20
21
  allMessagesText: string;
21
22
  };
22
23
 
24
+ const DELEGATED_TASK_MARKERS = [
25
+ "Task: You are a delegated subagent running from a fork of the parent session.",
26
+ "Task: You are reviving a previous subagent conversation."
27
+ ] as const;
28
+ const INTERNAL_HELPER_NAME = /^(?:subagent(?:-|$)|piagent-(?:planner|worker|scout|reviewer|oracle)(?:-|$))/i;
29
+
30
+ export function isUserConversationSession(info: PiSessionInfo): boolean {
31
+ const segments = path.resolve(info.path).split(path.sep);
32
+ const sessionsIndex = segments.lastIndexOf("sessions");
33
+ if (sessionsIndex >= 0 && segments[sessionsIndex + 1] === "subagent") return false;
34
+ const internalName = INTERNAL_HELPER_NAME.test(String(info.name ?? "").trim());
35
+ const hasParent = typeof info.parentSessionPath === "string" && info.parentSessionPath.length > 0;
36
+ const messages = typeof info.allMessagesText === "string" ? info.allMessagesText : "";
37
+ const internalPrompt = DELEGATED_TASK_MARKERS.some((marker) => messages.includes(marker));
38
+ // User-created forks also carry parentSessionPath, so lineage alone is not enough. A helper identity or the
39
+ // runtime-only delegation prompt plus lineage is high-confidence internal evidence. Keep this classification
40
+ // at the gateway boundary so catalog, read models, commands and live streams all fail closed together.
41
+ return !((internalName && (hasParent || internalPrompt)) || (hasParent && internalPrompt));
42
+ }
43
+
23
44
  function catalogRef(key: Buffer, namespace: string, value: string): string {
24
45
  return `${namespace}_${createHmac("sha256", key).update(value).digest("base64url").slice(0, 43)}`;
25
46
  }
@@ -33,6 +54,43 @@ function display(value: unknown, maximum: number, fallback: string): string {
33
54
  return (clean || fallback).slice(0, maximum);
34
55
  }
35
56
 
57
+ const INTERNAL_FRESH_TRANSITION = /(?:^|\b)(?:\/fresh\s+(?:task|scout|be-to-fe)|(?:task|scout|be-to-fe):)\b[\s\S]{0,180}\bRead task intake from \.pi\/task-inbox\//i;
58
+
59
+ function atWordBoundary(value: string, maximum: number): string {
60
+ if (value.length <= maximum) return value;
61
+ const slice = value.slice(0, maximum + 1), boundary = slice.lastIndexOf(" ");
62
+ return `${slice.slice(0, boundary >= Math.floor(maximum * .6) ? boundary : maximum).trim()}…`;
63
+ }
64
+
65
+ // Session titles are a local deterministic projection. Asking a model to name
66
+ // every chat would spend a turn and make opening the catalog non-deterministic.
67
+ // Runtime routing commands are control-plane text, so they must never become a
68
+ // title or preview merely because Pi records them as the first user entry.
69
+ export function projectedSessionTitle(info: Pick<PiSessionInfo, "name" | "firstMessage" | "cwd">): { title: string; preview: string } {
70
+ const projectLabel = display(info.cwd ? path.basename(info.cwd) : "", 120, "Unknown project");
71
+ const first = info.firstMessage === "(no messages)" ? "" : display(info.firstMessage, 500, "");
72
+ const rawName = display(info.name, 500, ""), generatedName = /^pi:/i.test(rawName);
73
+ const suppliedName = rawName.replace(/^pi:\s*/i, "");
74
+ const normalizedName = suppliedName.toLowerCase();
75
+ const genericName = !suppliedName || normalizedName === projectLabel.toLowerCase()
76
+ || ["working", "pi agent platform", "new conversation"].includes(normalizedName);
77
+ const source = generatedName ? suppliedName || first : genericName ? first || suppliedName : suppliedName;
78
+ const internal = INTERNAL_FRESH_TRANSITION.test(source) || INTERNAL_FRESH_TRANSITION.test(first)
79
+ || /\.pi\/task-inbox\//i.test(source);
80
+ const cleaned = display(source, 500, "")
81
+ .replace(/^\/(?:fresh\s+)?(?:task|scout|be-to-fe)\s+/i, "")
82
+ .replace(/^(?:task|scout|be-to-fe):\s*/i, "")
83
+ .replace(/^[-#*>\s]+/, "")
84
+ // Keep underscores: the shared redactor deliberately emits
85
+ // [REDACTED_SECRET], and title cleanup must not mutate that proof marker.
86
+ .replace(/[`*~]+/g, "")
87
+ .trim();
88
+ const title = internal ? "Continued task" : atWordBoundary(cleaned || "New conversation", 72);
89
+ const preview = INTERNAL_FRESH_TRANSITION.test(first) || /\.pi\/task-inbox\//i.test(first)
90
+ ? "Continued in a fresh session" : atWordBoundary(first || title, 180);
91
+ return { title, preview };
92
+ }
93
+
36
94
  function revision(key: Buffer, value: unknown): string {
37
95
  return `rev_${createHmac("sha256", key).update(JSON.stringify(value)).digest("hex")}`;
38
96
  }
@@ -40,18 +98,13 @@ function revision(key: Buffer, value: unknown): string {
40
98
  function row(key: Buffer, info: PiSessionInfo, metadata: SessionMetadata | undefined, metadataReason: string | null,
41
99
  ownership?: SessionOwnerProjection, sessionOptions?: SessionOptionProjection): SessionRow {
42
100
  const projectLabel = display(info.cwd ? path.basename(info.cwd) : "", 120, "Unknown project");
43
- const first = info.firstMessage === "(no messages)" ? "" : info.firstMessage;
44
- const suppliedName = display(info.name, 500, "").replace(/^pi:\s*/i, "");
45
- const normalizedName = suppliedName.toLowerCase();
46
- const genericName = !suppliedName || normalizedName === projectLabel.toLowerCase()
47
- || ["working", "pi agent platform", "new conversation"].includes(normalizedName);
48
- const title = display(genericName && first ? first : suppliedName || first, 500, "New conversation").replace(/^pi:\s*/i, "") || "New conversation";
101
+ const projected = projectedSessionTitle(info);
49
102
  return {
50
103
  sessionRef: sessionRefForPath(key, info.path),
51
104
  projectRef: projectRefForCwd(key, info.cwd),
52
- title,
105
+ title: projected.title,
53
106
  projectLabel,
54
- preview: display(first, 280, "No messages yet"),
107
+ preview: projected.preview,
55
108
  createdAt: info.created.toISOString(),
56
109
  updatedAt: info.modified.toISOString(),
57
110
  state: metadata?.archived ? "archived" : ownership?.state ?? "offline",
@@ -87,7 +140,7 @@ export async function buildSessionCatalog(options: {
87
140
  }): Promise<Catalog> {
88
141
  const limit = Math.max(1, Math.min(200, options.limit ?? 200));
89
142
  try {
90
- const found = await options.listSessions();
143
+ const found = (await options.listSessions()).filter(isUserConversationSession);
91
144
  const metadata = options.readMetadata?.() ?? { state: "ready", revision: null, sessions: new Map(), reasonCode: null };
92
145
  const sessions = found.map((info) => {
93
146
  const sessionRef = sessionRefForPath(options.key, info.path);