@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
@@ -35,7 +35,10 @@ function nullableRef(value: unknown): value is string | null { return value ===
35
35
 
36
36
  function validPayload(action: SessionAction, payload: Record<string, unknown>): boolean {
37
37
  if (["session.archive", "session.unarchive", "session.acquire", "session.release"].includes(action)) return exactKeys(payload, []);
38
- if (action === "session.send") return exactKeys(payload, ["delivery", "message", "messageRequestId", "expectedOperationRef"])
38
+ if (action === "session.send") return exactKeys(payload, ["delivery", "message", "messageRequestId", "expectedOperationRef", "attachmentRefs"])
39
+ && Array.isArray(payload.attachmentRefs) && payload.attachmentRefs.length <= 4
40
+ && new Set(payload.attachmentRefs as unknown[]).size === payload.attachmentRefs.length
41
+ && (payload.attachmentRefs as unknown[]).every((item) => typeof item === "string" && REF.test(item))
39
42
  && ["new-operation", "follow-up", "steer"].includes(String(payload.delivery)) && typeof payload.message === "string"
40
43
  && payload.message.length >= 1 && payload.message.length <= 32_768 && !payload.message.includes("\0") && REF.test(String(payload.messageRequestId))
41
44
  && nullableRef(payload.expectedOperationRef) && (payload.delivery === "new-operation" ? payload.expectedOperationRef === null : payload.expectedOperationRef !== null);
@@ -52,11 +55,12 @@ function validPayload(action: SessionAction, payload: Record<string, unknown>):
52
55
  if (action === "session.fork") return exactKeys(payload, ["entryRef", "title"]) && nullableRef(payload.entryRef)
53
56
  && (payload.title === null || typeof payload.title === "string" && payload.title.length >= 1 && payload.title.length <= 500
54
57
  && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(payload.title));
55
- return exactKeys(payload, ["projectRef", "placeRef", "modelRef", "thinkingLevel", "message", "messageRequestId"])
58
+ const createKeys = ["projectRef", "placeRef", "modelRef", "thinkingLevel", "message", "messageRequestId"];
59
+ return (exactKeys(payload, createKeys) || exactKeys(payload, [...createKeys, "deferInitialMessage"]))
56
60
  && REF.test(String(payload.projectRef)) && REF.test(String(payload.placeRef)) && nullableRef(payload.modelRef)
57
61
  && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(String(payload.thinkingLevel))
58
62
  && typeof payload.message === "string" && payload.message.length >= 1 && payload.message.length <= 32_768 && !payload.message.includes("\0")
59
- && REF.test(String(payload.messageRequestId));
63
+ && REF.test(String(payload.messageRequestId)) && (payload.deferInitialMessage === undefined || typeof payload.deferInitialMessage === "boolean");
60
64
  }
61
65
 
62
66
  function parseCommand(value: unknown): SessionCommand | null {
@@ -86,10 +90,15 @@ export class SessionCommandController {
86
90
  readonly #metadata: SessionMetadataStore | null;
87
91
  readonly #now: () => Date;
88
92
  readonly #chains = new Map<string, Promise<unknown>>();
93
+ readonly #prepareAttachments: ((sessionRef: string, refs: string[], messageRequestId: string, text: string) => Promise<{
94
+ text: string; images: unknown[]; commit(): void; release(): void }>) | undefined;
89
95
 
90
96
  constructor(options: { catalog(): Promise<Catalog>; runtimes: SessionRuntimeSupervisor; store: SessionCommandStore; events: GatewayEventStore;
91
97
  metadata?: SessionMetadataStore;
98
+ prepareAttachments?(sessionRef: string, refs: string[], messageRequestId: string, text: string): Promise<{
99
+ text: string; images: unknown[]; commit(): void; release(): void }>;
92
100
  now?: () => Date }) {
101
+ this.#prepareAttachments = options.prepareAttachments;
93
102
  this.#catalog = options.catalog; this.#runtimes = options.runtimes; this.#store = options.store; this.#events = options.events;
94
103
  this.#metadata = options.metadata ?? null;
95
104
  this.#now = options.now ?? (() => new Date());
@@ -129,8 +138,10 @@ export class SessionCommandController {
129
138
  try { this.#store.admit(command, this.#now()); }
130
139
  catch { return this.#rejected(command, current, row, "unavailable", "session-command-admission-failed"); }
131
140
  let targetSessionRef = command.sessionRef;
141
+ let attachmentReservation: { commit(): void; release(): void } | null = null;
142
+ let sendAttempted = false;
132
143
  try {
133
- let resultCode: "acquired" | "released" | "started" | "queued" | "steered" | "aborted" | "model-changed" | "thinking-changed" | "permission-changed"
144
+ let resultCode: "created" | "acquired" | "released" | "started" | "queued" | "steered" | "aborted" | "model-changed" | "thinking-changed" | "permission-changed"
134
145
  | "renamed" | "pinned" | "unpinned" | "archived" | "unarchived" | "forked" | "no-change",
135
146
  operationRef: string | null = null;
136
147
  if (command.action === "session.create") {
@@ -138,18 +149,32 @@ export class SessionCommandController {
138
149
  command.payload.modelRef === null ? null : String(command.payload.modelRef), String(command.payload.thinkingLevel));
139
150
  const created = await this.#readyCatalog(), createdRow = created.sessions.find((item) => item.sessionRef === targetSessionRef);
140
151
  if (!createdRow) throw new Error("session-catalog-refresh-failed");
141
- const sent = await this.#runtimes.send(targetSessionRef, { delivery: "new-operation", message: String(command.payload.message),
142
- expectedOperationRef: null }, createdRow.sessionRevision);
143
- resultCode = sent.resultCode === "started" ? "started" : sent.resultCode; operationRef = sent.operationRef;
152
+ if (command.payload.deferInitialMessage === true) resultCode = "created";
153
+ else {
154
+ const sent = await this.#runtimes.send(targetSessionRef, { delivery: "new-operation", message: String(command.payload.message),
155
+ expectedOperationRef: null }, createdRow.sessionRevision);
156
+ resultCode = sent.resultCode === "started" ? "started" : sent.resultCode; operationRef = sent.operationRef;
157
+ }
144
158
  } else if (command.action === "session.acquire") { await this.#runtimes.acquire(command.sessionRef!); resultCode = "acquired"; }
145
159
  else if (command.action === "session.release") { await this.#runtimes.release(command.sessionRef!); resultCode = "released"; }
146
160
  else if (command.action === "session.send") {
147
161
  await this.#runtimes.acquire(command.sessionRef!);
148
162
  const acquired = await this.#readyCatalog(), acquiredRow = acquired.sessions.find((item) => item.sessionRef === command.sessionRef);
149
163
  if (!acquiredRow) throw new Error("session-catalog-refresh-failed");
150
- const sent = await this.#runtimes.send(command.sessionRef!, command.payload as {
151
- delivery: "new-operation" | "follow-up" | "steer"; message: string; expectedOperationRef: string | null
152
- }, acquiredRow.sessionRevision);
164
+ const payload = command.payload as { delivery: "new-operation" | "follow-up" | "steer"; message: string;
165
+ expectedOperationRef: string | null; messageRequestId: string; attachmentRefs: string[] };
166
+ // Staged refs are claimed once, here, before the runtime is asked to
167
+ // dispatch. Documents were turned into text at staging so they join the
168
+ // message; images travel on the host's own channel.
169
+ const prepared = this.#prepareAttachments
170
+ ? await this.#prepareAttachments(command.sessionRef!, payload.attachmentRefs, payload.messageRequestId, payload.message)
171
+ : { text: payload.message, images: [] as unknown[], commit() {}, release() {} };
172
+ attachmentReservation = prepared;
173
+ if (!this.#prepareAttachments && payload.attachmentRefs.length > 0) throw new Error("session-attachment-unavailable");
174
+ sendAttempted = true;
175
+ const sent = await this.#runtimes.send(command.sessionRef!, { ...payload, message: prepared.text, images: prepared.images },
176
+ acquiredRow.sessionRevision);
177
+ prepared.commit();
153
178
  resultCode = sent.resultCode; operationRef = sent.operationRef;
154
179
  } else if (command.action === "session.abort") {
155
180
  operationRef = String(command.payload.operationRef);
@@ -195,10 +220,18 @@ export class SessionCommandController {
195
220
  return receipt;
196
221
  } catch (cause) {
197
222
  const after = await this.#readyCatalog(), afterRow = after.sessions.find((item) => item.sessionRef === targetSessionRef) ?? row;
198
- const code = cause instanceof Error && /recovery/.test(cause.message) ? "recovery-required"
199
- : cause instanceof Error && /(owner-conflict|operation-conflict|runtime-busy)/.test(cause.message) ? "owner-conflict" : "effect-unknown";
223
+ const message = cause instanceof Error ? cause.message : "session-command-failed";
224
+ const noDispatchEffect = command.action === "session.send" && (!sendAttempted
225
+ || /^(session-runtime-unavailable|session-operation-conflict|session-owner-conflict|session-runtime-busy)$/.test(message));
226
+ if (attachmentReservation) {
227
+ try { if (noDispatchEffect) attachmentReservation.release(); else attachmentReservation.commit(); }
228
+ catch { /* an attempted dispatch with an unprovable reservation remains uncertain */ }
229
+ }
230
+ const code = /recovery/.test(message) ? "recovery-required"
231
+ : /(owner-conflict|operation-conflict|runtime-busy)/.test(message) ? "owner-conflict"
232
+ : noDispatchEffect || command.action === "session.send" && !sendAttempted ? "unavailable" : "effect-unknown";
200
233
  const receipt = code === "effect-unknown" ? this.#uncertain(command, after, afterRow, "session-command-effect-unknown")
201
- : this.#rejected(command, after, afterRow, code, cause instanceof Error ? cause.message : "session-command-failed");
234
+ : this.#rejected(command, after, afterRow, code, message);
202
235
  try { this.#store.settle(command, receipt, this.#now()); } catch { /* intent remains uncertain */ }
203
236
  return receipt;
204
237
  }
@@ -219,7 +252,7 @@ export class SessionCommandController {
219
252
  sessionRevisionAfter: row?.sessionRevision ?? null, deduplicated: false };
220
253
  }
221
254
  #settled(command: SessionCommand, catalog: Catalog, row: SessionRow,
222
- resultCode: "acquired" | "released" | "started" | "queued" | "steered" | "aborted" | "model-changed" | "thinking-changed" | "permission-changed"
255
+ resultCode: "created" | "acquired" | "released" | "started" | "queued" | "steered" | "aborted" | "model-changed" | "thinking-changed" | "permission-changed"
223
256
  | "renamed" | "pinned" | "unpinned" | "archived" | "unarchived" | "forked" | "no-change",
224
257
  operationRef: string | null): Receipt {
225
258
  return { ...this.#base(command, catalog, row), operationRef, phase: "settled", resultCode, settledAt: this.#now().toISOString(),
@@ -3,16 +3,18 @@ import path from "node:path";
3
3
 
4
4
  import { readContextTelemetry } from "../../piagent-core/extensions/context-engine.js";
5
5
  import { activeSessionTask } from "../../piagent-core/extensions/task-state.js";
6
- import { runtimeConnectionDefinitions, runtimeProtectedPaths } from "../../piagent-core/runtime/inspection/project-runtime-inspection.ts";
6
+ import { runtimeConnectionDefinitions, runtimeDocumentReadRoots, runtimeProtectedPaths } from "../../piagent-core/runtime/inspection/project-runtime-inspection.ts";
7
7
  import { setRuntimeMcpEnabled } from "../../piagent-core/runtime/inspection/mcp-control.ts";
8
8
  import { webUiModelRef } from "../../piagent-core/runtime/inspection/webui-snapshot.ts";
9
9
  import { redactSensitiveText } from "../../piagent-core/security/sensitive-data.js";
10
10
  import { piApprovalBroker } from "../../piagent-core/runtime/inspection/approval-broker.ts";
11
+ import { attachmentCapability } from "../../piagent-core/runtime/input/attachment-store.ts";
11
12
  import { CoreInspectionProvider } from "../server/core-inspection-provider.ts";
12
13
  import { ReadModelNotFound, type WebUiReadModelProvider } from "../server/read-model-provider.ts";
13
- import { projectRefForCwd, sessionRefForPath, type PiSessionInfo } from "./session-catalog.ts";
14
+ import { isUserConversationSession, projectRefForCwd, sessionRefForPath, type PiSessionInfo } from "./session-catalog.ts";
14
15
  import { nativeProjectPickerAvailable } from "./native-project-picker.ts";
15
16
  import type { McpAuthBroker } from "./mcp-auth-broker.ts";
17
+ import { inspectWebSearchCapability } from "./ai-capability-inspection.ts";
16
18
 
17
19
  type PiHost = {
18
20
  SessionManager: {
@@ -95,19 +97,26 @@ function persistedContextUsage(host: PiHost, entries: unknown[], messages: unkno
95
97
  export class SessionInspectionRegistry {
96
98
  readonly #gatewayInstanceRef: string;
97
99
  readonly #host: PiHost;
100
+ readonly #listSessions: () => Promise<PiSessionInfo[]>;
101
+ readonly #openLiveSession: ((sessionRef: string) => ReturnType<PiHost["SessionManager"]["open"]> | null) | null;
98
102
  readonly #key: Buffer;
99
103
  readonly #packageRoot: string;
104
+ readonly #agentDir?: string;
100
105
  readonly #models?: ModelRuntime;
101
106
  readonly #projects?: ProjectSource;
102
107
  readonly #mcpAuth?: McpAuthBroker;
103
108
  readonly #providers = new Map<string, { modifiedAt: string; provider: CoreInspectionProvider }>();
104
109
 
105
- constructor(options: { gatewayInstanceRef: string; host: PiHost; key: Buffer; packageRoot: string; models?: ModelRuntime; projects?: ProjectSource;
106
- mcpAuth?: McpAuthBroker }) {
110
+ constructor(options: { gatewayInstanceRef: string; host: PiHost; key: Buffer; packageRoot: string; agentDir?: string; models?: ModelRuntime; projects?: ProjectSource;
111
+ mcpAuth?: McpAuthBroker; listSessions?: () => Promise<PiSessionInfo[]>;
112
+ openLiveSession?: (sessionRef: string) => ReturnType<PiHost["SessionManager"]["open"]> | null }) {
107
113
  this.#gatewayInstanceRef = options.gatewayInstanceRef;
108
114
  this.#host = options.host;
115
+ this.#listSessions = options.listSessions ?? (() => options.host.SessionManager.listAll());
116
+ this.#openLiveSession = options.openLiveSession ?? null;
109
117
  this.#key = options.key;
110
118
  this.#packageRoot = options.packageRoot;
119
+ this.#agentDir = options.agentDir;
111
120
  this.#models = options.models;
112
121
  this.#projects = options.projects;
113
122
  this.#mcpAuth = options.mcpAuth;
@@ -118,7 +127,7 @@ export class SessionInspectionRegistry {
118
127
  }
119
128
 
120
129
  async #sessionInfo(sessionRef: string): Promise<PiSessionInfo> {
121
- const sessions = await this.#host.SessionManager.listAll();
130
+ const sessions = (await this.#listSessions()).filter(isUserConversationSession);
122
131
  const found = sessions.filter((candidate) => sessionRefForPath(this.#key, candidate.path) === sessionRef);
123
132
  if (found.length !== 1) throw new ReadModelNotFound();
124
133
  return found[0]!;
@@ -168,7 +177,7 @@ export class SessionInspectionRegistry {
168
177
  }
169
178
 
170
179
  async creationOptions(): Promise<unknown> {
171
- const sessions = await this.#host.SessionManager.listAll();
180
+ const sessions = (await this.#listSessions()).filter(isUserConversationSession);
172
181
  const projects = new Map<string, { projectRef: string; placeRef: string; label: string }>();
173
182
  for (const info of sessions) {
174
183
  if (!info.cwd) continue;
@@ -179,7 +188,8 @@ export class SessionInspectionRegistry {
179
188
  for (const project of safeRead(() => this.#projects?.list() ?? [], [])) {
180
189
  if (!projects.has(project.projectRef)) projects.set(project.projectRef, project);
181
190
  }
182
- const models = (this.#models?.getAvailableSnapshot() ?? []).slice(0, 300).flatMap((value) => {
191
+ const availableModels = (this.#models?.getAvailableSnapshot() ?? []).slice(0, 300);
192
+ const models = availableModels.flatMap((value) => {
183
193
  const provider = safeModelId(value.provider);
184
194
  const modelId = safeModelId(value.id);
185
195
  if (!provider || !modelId || typeof value.reasoning !== "boolean") return [];
@@ -187,11 +197,12 @@ export class SessionInspectionRegistry {
187
197
  ? value.thinkingLevelMap as Record<string, unknown> : {};
188
198
  const thinkingLevels = value.reasoning ? ["off", "minimal", "low", "medium", "high", "xhigh", "max"]
189
199
  .filter((level) => mapping[level] !== null && !(["xhigh", "max"].includes(level) && mapping[level] === undefined)) : ["off"];
200
+ const inputs = Array.isArray(value.input) ? value.input.filter((item): item is string => typeof item === "string") : null;
190
201
  return [{ modelRef: webUiModelRef(provider, modelId), provider, modelId, displayName: safeName(value.name ?? modelId),
191
- reasoning: value.reasoning, thinkingLevels }];
202
+ reasoning: value.reasoning, imageInput: inputs ? inputs.includes("image") : null, thinkingLevels }];
192
203
  });
193
204
  return { schemaVersion: 1, version: "piagent-session-creation-options-v1", generatedAt: new Date().toISOString(),
194
- projects: [...projects.values()].slice(0, 200), models,
205
+ projects: [...projects.values()].slice(0, 200), models, webSearch: inspectWebSearchCapability({ agentDir: this.#agentDir, models: availableModels }),
195
206
  projectImport: nativeProjectPickerAvailable() ? { status: "available", reasonCode: null }
196
207
  : { status: "unavailable", reasonCode: "native-project-picker-unavailable" },
197
208
  reasonCode: projects.size ? null : "no-known-project" };
@@ -204,7 +215,7 @@ export class SessionInspectionRegistry {
204
215
  if (cached?.modifiedAt === modifiedAt) return cached.provider;
205
216
 
206
217
  let manager: ReturnType<PiHost["SessionManager"]["open"]>;
207
- try { manager = this.#host.SessionManager.open(info.path); }
218
+ try { manager = this.#openLiveSession?.(sessionRef) ?? this.#host.SessionManager.open(info.path); }
208
219
  catch { throw new ReadModelNotFound(); }
209
220
  const context = safeRead(() => manager.buildSessionContext(), { model: null, thinkingLevel: "unknown", messages: [] });
210
221
  const model = context.model ? this.#models?.getModel(context.model.provider, context.model.modelId) : undefined;
@@ -225,6 +236,15 @@ export class SessionInspectionRegistry {
225
236
  activityEvents: () => safeRead(() => readContextTelemetry(info.cwd, { limit: 5_000 }), []),
226
237
  sessionEntries: () => entries,
227
238
  protectedPaths: () => runtimeProtectedPaths(this.#packageRoot, info.cwd),
239
+ // The document workspace lists the project plus whatever the operator
240
+ // granted in the profile. Without this it silently shows the project only,
241
+ // which reads as "the grant does not work" rather than "it was not asked for".
242
+ documentReadRoots: () => safeRead(() => runtimeDocumentReadRoots(this.#packageRoot, info.cwd), []),
243
+ // Derived from the model and the host, with no store instance involved:
244
+ // the store for this session is built from the very snapshot this call
245
+ // helps produce, so asking the store would be circular.
246
+ attachmentCapability: () => attachmentCapability({
247
+ images: Array.isArray(model?.input) && model.input.includes("image"), now: Date.now() }),
228
248
  contextUsage: () => contextUsage,
229
249
  model: () => model,
230
250
  thinkingLevel: () => context.thinkingLevel,
@@ -7,7 +7,7 @@ import { activeSessionTask } from "../../piagent-core/extensions/task-state.js";
7
7
  import { inspectTaskControlState } from "../../piagent-core/runtime/inspection/task-control-journal.ts";
8
8
  import { piApprovalBroker, type ApprovalAuthority, type ApprovalBrokerEvent } from "../../piagent-core/runtime/inspection/approval-broker.ts";
9
9
  import type { SessionOwnerProjection, PiSessionInfo } from "./session-catalog.ts";
10
- import { projectRefForCwd, sessionRefForPath } from "./session-catalog.ts";
10
+ import { isUserConversationSession, projectRefForCwd, sessionRefForPath } from "./session-catalog.ts";
11
11
  import { SessionLeaseStore, type SessionLeaseSnapshot } from "./session-lease-store.ts";
12
12
  import { GatewayEventStore } from "./gateway-events.ts";
13
13
  import { GatewaySessionStream } from "./gateway-session-stream.ts";
@@ -20,7 +20,7 @@ type RuntimeHandle = { dispose(): Promise<void>; session?: any };
20
20
  type RuntimeFactory = (info: PiSessionInfo, runtimeInstanceRef: string, sessionManager?: any) => Promise<RuntimeHandle>;
21
21
  type ActiveRuntime = { runtime: RuntimeHandle; lease: SessionLeaseSnapshot; info: PiSessionInfo; operationRef: string | null;
22
22
  unsubscribe: (() => void) | null; completion: Promise<void> | null; settling: boolean; approvalWaiting: boolean;
23
- unbindApproval: (() => void) | null; unsubscribeApproval: (() => void) | null };
23
+ unbindApproval: (() => void) | null; unsubscribeApproval: (() => void) | null; sessionManager: any | null };
24
24
  type Projection = { sessionRevision: string; liveState: "offline" | "idle" | "running" | "paused" | "waiting-approval" | "uncertain" };
25
25
 
26
26
  function rpcUiContext(): object {
@@ -129,7 +129,7 @@ export class SessionRuntimeSupervisor {
129
129
  setProjectionReader(reader: (sessionRef: string) => Promise<Projection>): void { this.#readProjection = reader; }
130
130
 
131
131
  async listSessions(): Promise<PiSessionInfo[]> {
132
- const persisted = await this.#listSessions();
132
+ const persisted = (await this.#listSessions()).filter(isUserConversationSession);
133
133
  const paths = new Set(persisted.map((info) => info.path));
134
134
  for (const [sessionRef, info] of this.#created) {
135
135
  if (paths.has(info.path)) this.#created.delete(sessionRef);
@@ -140,7 +140,7 @@ export class SessionRuntimeSupervisor {
140
140
  async create(projectRef: string, placeRef: string, modelRef: string | null, thinkingLevel: string): Promise<string> {
141
141
  if (this.#closed || !this.#host) throw new Error("session-create-unavailable");
142
142
  if (placeRef !== projectRef) throw new Error("session-place-mismatch");
143
- const sessions = await this.#listSessions();
143
+ const sessions = (await this.#listSessions()).filter(isUserConversationSession);
144
144
  const imported = this.#resolveProject?.(projectRef) ?? null;
145
145
  const candidates = [...new Set([...sessions.filter((info) => projectRefForCwd(this.#key, info.cwd) === projectRef).map((info) => info.cwd),
146
146
  ...(imported ? [imported] : [])])];
@@ -166,6 +166,8 @@ export class SessionRuntimeSupervisor {
166
166
  return sessionRef;
167
167
  }
168
168
 
169
+ liveSessionManager(sessionRef: string): any | null { return this.#active.get(sessionRef)?.sessionManager ?? null; }
170
+
169
171
  async acquire(sessionRef: string): Promise<SessionLeaseSnapshot> {
170
172
  if (this.#closed) throw new Error("session-runtime-supervisor-closed");
171
173
  const active = this.#active.get(sessionRef);
@@ -179,7 +181,8 @@ export class SessionRuntimeSupervisor {
179
181
 
180
182
  async #acquire(sessionRef: string): Promise<SessionLeaseSnapshot> {
181
183
  if (this.#active.size + this.#opening.size > MAX_WARM_RUNTIMES) throw new Error("session-runtime-capacity");
182
- const found = (await this.#listSessions()).filter((info) => sessionRefForPath(this.#key, info.path) === sessionRef);
184
+ const found = (await this.#listSessions()).filter(isUserConversationSession)
185
+ .filter((info) => sessionRefForPath(this.#key, info.path) === sessionRef);
183
186
  if (found.length !== 1) throw new Error(found.length ? "session-ref-ambiguous" : "session-not-found");
184
187
  return await this.#activate(sessionRef, found[0]!);
185
188
  }
@@ -201,7 +204,8 @@ export class SessionRuntimeSupervisor {
201
204
  throw new Error("session-owner-continuity-lost");
202
205
  }
203
206
  const active: ActiveRuntime = { runtime, lease: current, info, operationRef: null, unsubscribe: null, completion: null,
204
- settling: false, approvalWaiting: false, unbindApproval: null, unsubscribeApproval: null };
207
+ settling: false, approvalWaiting: false, unbindApproval: null, unsubscribeApproval: null,
208
+ sessionManager: sessionManager ?? runtime.session?.sessionManager ?? null };
205
209
  this.#active.set(sessionRef, active);
206
210
  this.#bindApproval(sessionRef, active);
207
211
  return current;
@@ -271,15 +275,20 @@ export class SessionRuntimeSupervisor {
271
275
  };
272
276
  }
273
277
 
278
+ // `message` arrives already carrying any attached document text, and `images`
279
+ // already split out: the command controller claims staged refs before it calls
280
+ // here, so this layer only forwards what the host prompt takes.
274
281
  async send(sessionRef: string, payload: { delivery: "new-operation" | "follow-up" | "steer"; message: string;
275
- expectedOperationRef: string | null }, sessionRevision: string): Promise<{ resultCode: "started" | "queued" | "steered"; operationRef: string }> {
282
+ expectedOperationRef: string | null; images?: unknown[] }, sessionRevision: string):
283
+ Promise<{ resultCode: "started" | "queued" | "steered"; operationRef: string }> {
276
284
  await this.acquire(sessionRef);
277
285
  const active = this.#active.get(sessionRef), session = active?.runtime.session;
278
286
  if (!active || !session) throw new Error("session-runtime-unavailable");
279
287
  if (payload.delivery !== "new-operation") {
280
288
  if (!active.operationRef || active.operationRef !== payload.expectedOperationRef || !session.isStreaming) throw new Error("session-operation-conflict");
281
- if (payload.delivery === "follow-up") { await session.followUp(payload.message); return { resultCode: "queued", operationRef: active.operationRef }; }
282
- await session.steer(payload.message); return { resultCode: "steered", operationRef: active.operationRef };
289
+ const images = payload.images?.length ? payload.images : undefined;
290
+ if (payload.delivery === "follow-up") { await session.followUp(payload.message, images); return { resultCode: "queued", operationRef: active.operationRef }; }
291
+ await session.steer(payload.message, images); return { resultCode: "steered", operationRef: active.operationRef };
283
292
  }
284
293
  if (payload.expectedOperationRef !== null || active.operationRef || active.settling || !session.isIdle) throw new Error("session-operation-conflict");
285
294
  const operationRef = `operation_${randomBytes(24).toString("base64url")}`;
@@ -292,7 +301,7 @@ export class SessionRuntimeSupervisor {
292
301
  created.firstMessage = payload.message; created.allMessagesText = payload.message; created.messageCount = 1; created.modified = new Date();
293
302
  }
294
303
  let prompt: Promise<void>;
295
- try { prompt = session.prompt(payload.message); }
304
+ try { prompt = session.prompt(payload.message, payload.images?.length ? { images: payload.images } : undefined); }
296
305
  catch (error) { await this.#finishOperation(sessionRef, operationRef, stream); throw error; }
297
306
  active.completion = prompt.then(() => this.#finishOperation(sessionRef, operationRef, stream),
298
307
  () => this.#finishOperation(sessionRef, operationRef, stream));
@@ -17,7 +17,9 @@
17
17
  "@mui/icons-material": "^9.3.1",
18
18
  "@mui/material": "^9.3.1",
19
19
  "react": "19.2.8",
20
- "react-dom": "19.2.8"
20
+ "react-dom": "19.2.8",
21
+ "react-markdown": "^10.1.0",
22
+ "remark-gfm": "^4.0.1"
21
23
  },
22
24
  "devDependencies": {
23
25
  "@types/react": "19.2.18",
@@ -22,7 +22,7 @@ type EventStore = {
22
22
  };
23
23
 
24
24
  async function loadCoreInspection() {
25
- const [{ collectFileDiff }, { buildWebUiInspectionProjection }, { matchesProtectedPath }, reviewProjection, reviewStore, mutationProjection, revertProjection, openProjection, commitSummaryProjection, taskIndexProjection, taskTimelineProjection, recoveryHistoryProjection, handoffHistoryProjection, subagentTreeProjection, releaseMonitorProjection] = await Promise.all([
25
+ const [{ collectFileDiff }, { buildWebUiInspectionProjection }, { matchesProtectedPath }, reviewProjection, reviewStore, mutationProjection, revertProjection, openProjection, commitSummaryProjection, taskIndexProjection, taskTimelineProjection, recoveryHistoryProjection, handoffHistoryProjection, subagentTreeProjection, releaseMonitorProjection, documentWorkspaceProjection] = await Promise.all([
26
26
  import(`${CORE_ROOT}/runtime/inspection/diff-projection.ts`),
27
27
  import(`${CORE_ROOT}/runtime/inspection/webui-snapshot.ts`),
28
28
  import(`${CORE_ROOT}/extensions/policy-core.js`),
@@ -37,9 +37,10 @@ async function loadCoreInspection() {
37
37
  import(`${CORE_ROOT}/runtime/inspection/task-compaction-history.ts`),
38
38
  import(`${CORE_ROOT}/runtime/inspection/task-handoff-history.ts`),
39
39
  import(`${CORE_ROOT}/runtime/inspection/task-subagent-tree.ts`),
40
- import("./benchmark-release-monitor.ts")
40
+ import("./benchmark-release-monitor.ts"),
41
+ import(`${CORE_ROOT}/runtime/inspection/document-workspace-projection.ts`)
41
42
  ]);
42
- return { collectFileDiff, buildWebUiInspectionProjection, matchesProtectedPath, ...reviewProjection, ...reviewStore, ...mutationProjection, ...revertProjection, ...openProjection, ...commitSummaryProjection, ...taskIndexProjection, ...taskTimelineProjection, ...recoveryHistoryProjection, ...handoffHistoryProjection, ...subagentTreeProjection, ...releaseMonitorProjection };
43
+ return { collectFileDiff, buildWebUiInspectionProjection, matchesProtectedPath, ...reviewProjection, ...reviewStore, ...mutationProjection, ...revertProjection, ...openProjection, ...commitSummaryProjection, ...taskIndexProjection, ...taskTimelineProjection, ...recoveryHistoryProjection, ...handoffHistoryProjection, ...subagentTreeProjection, ...releaseMonitorProjection, ...documentWorkspaceProjection };
43
44
  }
44
45
 
45
46
  export type CoreInspectionInput = {
@@ -52,12 +53,16 @@ export type CoreInspectionInput = {
52
53
  currentActivity?: () => unknown[];
53
54
  sessionEntries?: () => unknown[];
54
55
  protectedPaths?: () => string[];
56
+ // Extra directories the operator granted through the profile. The document
57
+ // workspace lists them beside the project; without them it shows the project
58
+ // only, which is the same answer a host with no grants gives.
59
+ documentReadRoots?: () => unknown;
55
60
  contextUsage?: () => { tokens: number | null; contextWindow: number; percent: number | null } | undefined;
56
61
  model?: () => { provider?: unknown; id?: unknown; name?: unknown; reasoning?: unknown; input?: unknown; thinkingLevelMap?: unknown; contextWindow?: unknown; maxTokens?: unknown } | undefined;
57
62
  thinkingLevel?: () => unknown;
58
63
  queueProjection?: () => unknown;
59
64
  modelCatalog?: () => unknown;
60
- attachmentCapability?: () => { kinds: Array<"file" | "image">; mimeTypes: string[] };
65
+ attachmentCapability?: () => { kinds: Array<"file" | "image" | "document">; mimeTypes: string[] };
61
66
  approvalProjection?: () => { revision: string | null; summary: Record<string, unknown> };
62
67
  approvalDetail?: (approvalRef: string) => unknown | null;
63
68
  sourceMutationGuardAvailable?: () => boolean;
@@ -75,6 +80,7 @@ export class CoreInspectionProvider implements WebUiReadModelProvider {
75
80
  #cachedProjection: { at: number; value: any } | null = null;
76
81
  #invalidationEpoch = 0;
77
82
  readonly #diffCache = new Map<string, { at: number; value: unknown }>();
83
+ #documentCache: { at: number; value: unknown } | null = null;
78
84
  readonly #sourceRevertConfirmationKey = randomBytes(32);
79
85
 
80
86
  constructor(input: CoreInspectionInput) { this.#input = input; }
@@ -83,6 +89,7 @@ export class CoreInspectionProvider implements WebUiReadModelProvider {
83
89
  this.#invalidationEpoch += 1;
84
90
  this.#cachedProjection = null;
85
91
  this.#diffCache.clear();
92
+ this.#documentCache = null;
86
93
  }
87
94
 
88
95
  async #projection() {
@@ -97,6 +104,17 @@ export class CoreInspectionProvider implements WebUiReadModelProvider {
97
104
  contextUsage: this.#input.contextUsage?.(), model: this.#input.model?.(), thinkingLevel: this.#input.thinkingLevel?.(),
98
105
  eventCursor: this.#input.eventStore.currentCursor(), resyncRequired: this.#input.eventStore.resyncRequired(), eventReplay: retention
99
106
  });
107
+ // What the host will accept as an attachment is a property of the machine and
108
+ // the model, not of chat control. The Gateway drives sessions without a chat
109
+ // bridge at all, and a capability published only alongside that bridge left
110
+ // its composer believing no file type was supported.
111
+ if (this.#input.attachmentCapability) {
112
+ const attachment = this.#input.attachmentCapability();
113
+ value.snapshot.capabilities.capabilities.attachments = { status: "available", version: 1, reason: null,
114
+ kinds: attachment.kinds, mimeTypes: attachment.mimeTypes };
115
+ Object.assign(value.snapshot.capabilities.limits, { maxRequestBodyBytes: 11_250_000, maxAttachmentCount: 4,
116
+ maxAttachmentFileBytes: 8_388_608, maxAttachmentTotalBytes: 16_777_216 });
117
+ }
100
118
  const control = this.#input.chatControl?.();
101
119
  if (control?.state === "ready" && control.identity && control.revisions && !this.#input.eventStore.resyncRequired()) {
102
120
  const available = { available: true, reasonCode: null }, disabled = (reasonCode: string) => ({ available: false, reasonCode });
@@ -113,13 +131,6 @@ export class CoreInspectionProvider implements WebUiReadModelProvider {
113
131
  deleteHeld: available, dispatchHeld: dispatchBlocked ? disabled("task-control-gate-closed") : available,
114
132
  interruptAndSend: dispatchBlocked ? disabled("task-control-gate-closed") : control.liveness === "running" ? available : disabled("agent-not-running") }
115
133
  };
116
- if (this.#input.attachmentCapability) {
117
- const attachment = this.#input.attachmentCapability();
118
- value.snapshot.capabilities.capabilities.attachments = { status: "available", version: 1, reason: null,
119
- kinds: attachment.kinds, mimeTypes: attachment.mimeTypes };
120
- Object.assign(value.snapshot.capabilities.limits, { maxRequestBodyBytes: 11_250_000, maxAttachmentCount: 4,
121
- maxAttachmentFileBytes: 8_388_608, maxAttachmentTotalBytes: 16_777_216 });
122
- }
123
134
  let modelCatalog: any;
124
135
  try { modelCatalog = this.#input.modelCatalog?.(); }
125
136
  catch { modelCatalog = null; }
@@ -190,6 +201,32 @@ export class CoreInspectionProvider implements WebUiReadModelProvider {
190
201
  const source = (await this.#projection()).sourceViews;
191
202
  return view === "working-tree" ? source.workingTree : source[view];
192
203
  }
204
+ // The listing walks the project and every granted root, so it costs real
205
+ // filesystem work — hundreds of milliseconds on a large repository. It also
206
+ // changes only when files change, so a short cache keeps a browser that
207
+ // re-renders from re-walking the disk, without holding a stale view long
208
+ // enough for the operator to notice.
209
+ async documents(): Promise<unknown> {
210
+ const cached = this.#documentCache;
211
+ if (cached && Date.now() - cached.at <= 2_000) return cached.value;
212
+ const core = await loadCoreInspection();
213
+ const value = core.projectDocumentWorkspaceListing(this.#documentWorkspaceInput(core.matchesProtectedPath));
214
+ this.#documentCache = { at: Date.now(), value };
215
+ return value;
216
+ }
217
+
218
+ async document(documentRef: string): Promise<unknown> {
219
+ const core = await loadCoreInspection();
220
+ return core.projectDocumentWorkspaceDocument({ ...this.#documentWorkspaceInput(core.matchesProtectedPath), documentRef });
221
+ }
222
+
223
+ #documentWorkspaceInput(matchesProtectedPath: (candidate: string, patterns: string[]) => unknown) {
224
+ const protectedPaths = this.#input.protectedPaths?.() ?? [];
225
+ return { cwd: this.#input.cwd, profileRoots: this.#input.documentReadRoots?.(),
226
+ environmentRoots: process.env.PIAGENT_ADDITIONAL_READ_ROOTS,
227
+ isProtectedPath: (candidate: string) => Boolean(matchesProtectedPath(candidate, protectedPaths)) };
228
+ }
229
+
193
230
  async activity(): Promise<unknown> { return (await this.#projection()).snapshot.activity; }
194
231
  async logPreview(activityRef: string): Promise<unknown> {
195
232
  const activity = (await this.#projection()).snapshot.activity;
@@ -62,6 +62,8 @@ export class IpcReadModelClient implements WebUiReadModelProvider {
62
62
  handoffHistory(runRef: string): Promise<unknown> { return this.#request("handoffHistory", [runRef]); }
63
63
  subagentTree(runRef: string): Promise<unknown> { return this.#request("subagentTree", [runRef]); }
64
64
  releaseMonitor(): Promise<unknown> { return this.#request("releaseMonitor"); }
65
+ documents(): Promise<unknown> { return this.#request("documents"); }
66
+ document(documentRef: string): Promise<unknown> { return this.#request("document", [documentRef]); }
65
67
  activity(): Promise<unknown> { return this.#request("activity"); }
66
68
  logPreview(activityRef: string): Promise<unknown> { return this.#request("logPreview", [activityRef]); }
67
69
  transcript(beforeCursor: string | null, limit: number): Promise<unknown> { return this.#request("transcript", [beforeCursor, limit]); }