@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
@@ -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);
@@ -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,25 +3,30 @@ 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: {
19
21
  listAll(): Promise<PiSessionInfo[]>;
20
22
  open(file: string): {
21
23
  getBranch(): unknown[];
22
- buildSessionContext(): { model: { provider: string; modelId: string } | null; thinkingLevel: string };
24
+ buildSessionContext(): { model: { provider: string; modelId: string } | null; thinkingLevel: string; messages: unknown[] };
23
25
  };
24
26
  };
27
+ calculateContextTokens(usage: unknown): number;
28
+ estimateTokens(message: unknown): number;
29
+ getLatestCompactionEntry(entries: unknown[]): unknown | null;
25
30
  };
26
31
 
27
32
  type ModelRuntime = {
@@ -59,22 +64,59 @@ function safeModelId(value: unknown): string | null {
59
64
  return redactSensitiveText(value).redacted ? null : value;
60
65
  }
61
66
 
67
+ function persistedContextUsage(host: PiHost, entries: unknown[], messages: unknown[], model: Record<string, unknown> | undefined) {
68
+ const contextWindow = Number(model?.contextWindow);
69
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) return undefined;
70
+ const latestCompaction = host.getLatestCompactionEntry(entries);
71
+ if (latestCompaction) {
72
+ const boundary = entries.lastIndexOf(latestCompaction);
73
+ let hasPostCompactionUsage = false;
74
+ for (let index = entries.length - 1; index > boundary; index -= 1) {
75
+ const entry = entries[index] as { type?: unknown; message?: { role?: unknown; stopReason?: unknown; usage?: unknown } } | undefined;
76
+ const message = entry?.type === "message" ? entry.message : undefined;
77
+ if (message?.role === "assistant" && message.usage && message.stopReason !== "aborted" && message.stopReason !== "error"
78
+ && host.calculateContextTokens(message.usage) > 0) {
79
+ hasPostCompactionUsage = true;
80
+ break;
81
+ }
82
+ }
83
+ if (!hasPostCompactionUsage) return { tokens: null, contextWindow, percent: null };
84
+ }
85
+ let lastUsageIndex = -1, tokens = 0;
86
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
87
+ const message = messages[index] as { role?: unknown; stopReason?: unknown; usage?: unknown } | undefined;
88
+ if (message?.role !== "assistant" || !message.usage || message.stopReason === "aborted" || message.stopReason === "error") continue;
89
+ const measured = host.calculateContextTokens(message.usage);
90
+ if (measured > 0) { tokens = measured; lastUsageIndex = index; break; }
91
+ }
92
+ for (let index = lastUsageIndex + 1; index < messages.length; index += 1) tokens += host.estimateTokens(messages[index]);
93
+ if (!Number.isFinite(tokens) || tokens < 0) return undefined;
94
+ return { tokens, contextWindow, percent: tokens / contextWindow * 100 };
95
+ }
96
+
62
97
  export class SessionInspectionRegistry {
63
98
  readonly #gatewayInstanceRef: string;
64
99
  readonly #host: PiHost;
100
+ readonly #listSessions: () => Promise<PiSessionInfo[]>;
101
+ readonly #openLiveSession: ((sessionRef: string) => ReturnType<PiHost["SessionManager"]["open"]> | null) | null;
65
102
  readonly #key: Buffer;
66
103
  readonly #packageRoot: string;
104
+ readonly #agentDir?: string;
67
105
  readonly #models?: ModelRuntime;
68
106
  readonly #projects?: ProjectSource;
69
107
  readonly #mcpAuth?: McpAuthBroker;
70
108
  readonly #providers = new Map<string, { modifiedAt: string; provider: CoreInspectionProvider }>();
71
109
 
72
- constructor(options: { gatewayInstanceRef: string; host: PiHost; key: Buffer; packageRoot: string; models?: ModelRuntime; projects?: ProjectSource;
73
- 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 }) {
74
113
  this.#gatewayInstanceRef = options.gatewayInstanceRef;
75
114
  this.#host = options.host;
115
+ this.#listSessions = options.listSessions ?? (() => options.host.SessionManager.listAll());
116
+ this.#openLiveSession = options.openLiveSession ?? null;
76
117
  this.#key = options.key;
77
118
  this.#packageRoot = options.packageRoot;
119
+ this.#agentDir = options.agentDir;
78
120
  this.#models = options.models;
79
121
  this.#projects = options.projects;
80
122
  this.#mcpAuth = options.mcpAuth;
@@ -85,7 +127,7 @@ export class SessionInspectionRegistry {
85
127
  }
86
128
 
87
129
  async #sessionInfo(sessionRef: string): Promise<PiSessionInfo> {
88
- const sessions = await this.#host.SessionManager.listAll();
130
+ const sessions = (await this.#listSessions()).filter(isUserConversationSession);
89
131
  const found = sessions.filter((candidate) => sessionRefForPath(this.#key, candidate.path) === sessionRef);
90
132
  if (found.length !== 1) throw new ReadModelNotFound();
91
133
  return found[0]!;
@@ -135,7 +177,7 @@ export class SessionInspectionRegistry {
135
177
  }
136
178
 
137
179
  async creationOptions(): Promise<unknown> {
138
- const sessions = await this.#host.SessionManager.listAll();
180
+ const sessions = (await this.#listSessions()).filter(isUserConversationSession);
139
181
  const projects = new Map<string, { projectRef: string; placeRef: string; label: string }>();
140
182
  for (const info of sessions) {
141
183
  if (!info.cwd) continue;
@@ -146,7 +188,8 @@ export class SessionInspectionRegistry {
146
188
  for (const project of safeRead(() => this.#projects?.list() ?? [], [])) {
147
189
  if (!projects.has(project.projectRef)) projects.set(project.projectRef, project);
148
190
  }
149
- 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) => {
150
193
  const provider = safeModelId(value.provider);
151
194
  const modelId = safeModelId(value.id);
152
195
  if (!provider || !modelId || typeof value.reasoning !== "boolean") return [];
@@ -154,11 +197,12 @@ export class SessionInspectionRegistry {
154
197
  ? value.thinkingLevelMap as Record<string, unknown> : {};
155
198
  const thinkingLevels = value.reasoning ? ["off", "minimal", "low", "medium", "high", "xhigh", "max"]
156
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;
157
201
  return [{ modelRef: webUiModelRef(provider, modelId), provider, modelId, displayName: safeName(value.name ?? modelId),
158
- reasoning: value.reasoning, thinkingLevels }];
202
+ reasoning: value.reasoning, imageInput: inputs ? inputs.includes("image") : null, thinkingLevels }];
159
203
  });
160
204
  return { schemaVersion: 1, version: "piagent-session-creation-options-v1", generatedAt: new Date().toISOString(),
161
- projects: [...projects.values()].slice(0, 200), models,
205
+ projects: [...projects.values()].slice(0, 200), models, webSearch: inspectWebSearchCapability({ agentDir: this.#agentDir, models: availableModels }),
162
206
  projectImport: nativeProjectPickerAvailable() ? { status: "available", reasonCode: null }
163
207
  : { status: "unavailable", reasonCode: "native-project-picker-unavailable" },
164
208
  reasonCode: projects.size ? null : "no-known-project" };
@@ -171,11 +215,12 @@ export class SessionInspectionRegistry {
171
215
  if (cached?.modifiedAt === modifiedAt) return cached.provider;
172
216
 
173
217
  let manager: ReturnType<PiHost["SessionManager"]["open"]>;
174
- try { manager = this.#host.SessionManager.open(info.path); }
218
+ try { manager = this.#openLiveSession?.(sessionRef) ?? this.#host.SessionManager.open(info.path); }
175
219
  catch { throw new ReadModelNotFound(); }
176
- const context = safeRead(() => manager.buildSessionContext(), { model: null, thinkingLevel: "unknown" });
220
+ const context = safeRead(() => manager.buildSessionContext(), { model: null, thinkingLevel: "unknown", messages: [] });
177
221
  const model = context.model ? this.#models?.getModel(context.model.provider, context.model.modelId) : undefined;
178
222
  const entries = safeRead(() => manager.getBranch(), []);
223
+ const contextUsage = safeRead(() => persistedContextUsage(this.#host, entries, context.messages, model), undefined);
179
224
  const eventStore = {
180
225
  retention: () => ({ eventRetentionCount: 0, eventRetentionSeconds: 0 }),
181
226
  currentCursor: () => null,
@@ -191,6 +236,16 @@ export class SessionInspectionRegistry {
191
236
  activityEvents: () => safeRead(() => readContextTelemetry(info.cwd, { limit: 5_000 }), []),
192
237
  sessionEntries: () => entries,
193
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() }),
248
+ contextUsage: () => contextUsage,
194
249
  model: () => model,
195
250
  thinkingLevel: () => context.thinkingLevel,
196
251
  approvalProjection: () => piApprovalBroker.projection(info.cwd, info.id),
@@ -7,11 +7,12 @@ 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";
14
14
  import { GATEWAY_RUNTIME_UI_MARKER } from "../ownership/gateway-runtime-context.ts";
15
+ import { preferAuthoritativePiagentGuard } from "./extension-authority.ts";
15
16
 
16
17
  const MAX_WARM_RUNTIMES = 10;
17
18
 
@@ -19,7 +20,7 @@ type RuntimeHandle = { dispose(): Promise<void>; session?: any };
19
20
  type RuntimeFactory = (info: PiSessionInfo, runtimeInstanceRef: string, sessionManager?: any) => Promise<RuntimeHandle>;
20
21
  type ActiveRuntime = { runtime: RuntimeHandle; lease: SessionLeaseSnapshot; info: PiSessionInfo; operationRef: string | null;
21
22
  unsubscribe: (() => void) | null; completion: Promise<void> | null; settling: boolean; approvalWaiting: boolean;
22
- unbindApproval: (() => void) | null; unsubscribeApproval: (() => void) | null };
23
+ unbindApproval: (() => void) | null; unsubscribeApproval: (() => void) | null; sessionManager: any | null };
23
24
  type Projection = { sessionRevision: string; liveState: "offline" | "idle" | "running" | "paused" | "waiting-approval" | "uncertain" };
24
25
 
25
26
  function rpcUiContext(): object {
@@ -55,7 +56,10 @@ function productionRuntimeFactory(options: { host: any; agentDir: string; packag
55
56
  const createRuntime = async ({ cwd, agentDir, sessionManager, sessionStartEvent }: any) => {
56
57
  const services = await options.host.createAgentSessionServices({
57
58
  cwd, agentDir, modelRuntime: options.modelRuntime,
58
- resourceLoaderOptions: { additionalExtensionPaths: [guard] }
59
+ resourceLoaderOptions: {
60
+ additionalExtensionPaths: [guard],
61
+ extensionsOverride: preferAuthoritativePiagentGuard(guard)
62
+ }
59
63
  });
60
64
  const extensionErrors = services.resourceLoader.getExtensions().errors;
61
65
  if (extensionErrors.length) throw new Error("session-runtime-extension-load-failed");
@@ -125,7 +129,7 @@ export class SessionRuntimeSupervisor {
125
129
  setProjectionReader(reader: (sessionRef: string) => Promise<Projection>): void { this.#readProjection = reader; }
126
130
 
127
131
  async listSessions(): Promise<PiSessionInfo[]> {
128
- const persisted = await this.#listSessions();
132
+ const persisted = (await this.#listSessions()).filter(isUserConversationSession);
129
133
  const paths = new Set(persisted.map((info) => info.path));
130
134
  for (const [sessionRef, info] of this.#created) {
131
135
  if (paths.has(info.path)) this.#created.delete(sessionRef);
@@ -136,7 +140,7 @@ export class SessionRuntimeSupervisor {
136
140
  async create(projectRef: string, placeRef: string, modelRef: string | null, thinkingLevel: string): Promise<string> {
137
141
  if (this.#closed || !this.#host) throw new Error("session-create-unavailable");
138
142
  if (placeRef !== projectRef) throw new Error("session-place-mismatch");
139
- const sessions = await this.#listSessions();
143
+ const sessions = (await this.#listSessions()).filter(isUserConversationSession);
140
144
  const imported = this.#resolveProject?.(projectRef) ?? null;
141
145
  const candidates = [...new Set([...sessions.filter((info) => projectRefForCwd(this.#key, info.cwd) === projectRef).map((info) => info.cwd),
142
146
  ...(imported ? [imported] : [])])];
@@ -162,6 +166,8 @@ export class SessionRuntimeSupervisor {
162
166
  return sessionRef;
163
167
  }
164
168
 
169
+ liveSessionManager(sessionRef: string): any | null { return this.#active.get(sessionRef)?.sessionManager ?? null; }
170
+
165
171
  async acquire(sessionRef: string): Promise<SessionLeaseSnapshot> {
166
172
  if (this.#closed) throw new Error("session-runtime-supervisor-closed");
167
173
  const active = this.#active.get(sessionRef);
@@ -175,7 +181,8 @@ export class SessionRuntimeSupervisor {
175
181
 
176
182
  async #acquire(sessionRef: string): Promise<SessionLeaseSnapshot> {
177
183
  if (this.#active.size + this.#opening.size > MAX_WARM_RUNTIMES) throw new Error("session-runtime-capacity");
178
- 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);
179
186
  if (found.length !== 1) throw new Error(found.length ? "session-ref-ambiguous" : "session-not-found");
180
187
  return await this.#activate(sessionRef, found[0]!);
181
188
  }
@@ -197,7 +204,8 @@ export class SessionRuntimeSupervisor {
197
204
  throw new Error("session-owner-continuity-lost");
198
205
  }
199
206
  const active: ActiveRuntime = { runtime, lease: current, info, operationRef: null, unsubscribe: null, completion: null,
200
- settling: false, approvalWaiting: false, unbindApproval: null, unsubscribeApproval: null };
207
+ settling: false, approvalWaiting: false, unbindApproval: null, unsubscribeApproval: null,
208
+ sessionManager: sessionManager ?? runtime.session?.sessionManager ?? null };
201
209
  this.#active.set(sessionRef, active);
202
210
  this.#bindApproval(sessionRef, active);
203
211
  return current;
@@ -267,15 +275,20 @@ export class SessionRuntimeSupervisor {
267
275
  };
268
276
  }
269
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.
270
281
  async send(sessionRef: string, payload: { delivery: "new-operation" | "follow-up" | "steer"; message: string;
271
- 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 }> {
272
284
  await this.acquire(sessionRef);
273
285
  const active = this.#active.get(sessionRef), session = active?.runtime.session;
274
286
  if (!active || !session) throw new Error("session-runtime-unavailable");
275
287
  if (payload.delivery !== "new-operation") {
276
288
  if (!active.operationRef || active.operationRef !== payload.expectedOperationRef || !session.isStreaming) throw new Error("session-operation-conflict");
277
- if (payload.delivery === "follow-up") { await session.followUp(payload.message); return { resultCode: "queued", operationRef: active.operationRef }; }
278
- 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 };
279
292
  }
280
293
  if (payload.expectedOperationRef !== null || active.operationRef || active.settling || !session.isIdle) throw new Error("session-operation-conflict");
281
294
  const operationRef = `operation_${randomBytes(24).toString("base64url")}`;
@@ -288,7 +301,7 @@ export class SessionRuntimeSupervisor {
288
301
  created.firstMessage = payload.message; created.allMessagesText = payload.message; created.messageCount = 1; created.modified = new Date();
289
302
  }
290
303
  let prompt: Promise<void>;
291
- try { prompt = session.prompt(payload.message); }
304
+ try { prompt = session.prompt(payload.message, payload.images?.length ? { images: payload.images } : undefined); }
292
305
  catch (error) { await this.#finishOperation(sessionRef, operationRef, stream); throw error; }
293
306
  active.completion = prompt.then(() => this.#finishOperation(sessionRef, operationRef, stream),
294
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",