@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
@@ -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,11 +104,22 @@ 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 });
103
121
  const lifecycle = this.#input.lifecycleControl?.(), terminal = control.taskState === "terminal";
104
- const dispatchBlocked = terminal || lifecycle?.dispatchBlocked === true;
122
+ const dispatchBlocked = lifecycle?.dispatchBlocked === true && lifecycle.state !== "terminal";
105
123
  value.snapshot.identity = structuredClone(control.identity);
106
124
  value.snapshot.capabilities.identity = structuredClone(control.identity);
107
125
  for (const name of ["runtimeRevision", "taskRevision", "controlRevision", "sessionOptionRevision", "queueRevision"])
@@ -109,17 +127,10 @@ export class CoreInspectionProvider implements WebUiReadModelProvider {
109
127
  value.snapshot.capabilities.mode = "control-enabled";
110
128
  value.snapshot.capabilities.capabilities["control.chat"] = {
111
129
  status: "available", version: 1, reason: null, queuePersistence: "runtime-lifetime",
112
- actions: { send: dispatchBlocked ? disabled(terminal ? "task-terminal" : "task-control-gate-closed") : available, hold: available, editHeld: available,
113
- deleteHeld: available, dispatchHeld: dispatchBlocked ? disabled(terminal ? "task-terminal" : "task-control-gate-closed") : available,
114
- interruptAndSend: dispatchBlocked ? disabled(terminal ? "task-terminal" : "task-control-gate-closed") : control.liveness === "running" ? available : disabled("agent-not-running") }
130
+ actions: { send: dispatchBlocked ? disabled("task-control-gate-closed") : available, hold: available, editHeld: available,
131
+ deleteHeld: available, dispatchHeld: dispatchBlocked ? disabled("task-control-gate-closed") : available,
132
+ interruptAndSend: dispatchBlocked ? disabled("task-control-gate-closed") : control.liveness === "running" ? available : disabled("agent-not-running") }
115
133
  };
116
- if (!terminal && 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]); }
@@ -19,7 +19,8 @@ const MAX_JSON_RESPONSE_BYTES = 16 * 1024 * 1024;
19
19
  const CURSOR = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/;
20
20
 
21
21
  type ReadCapabilities = () => unknown | Promise<unknown>;
22
- type RateState = { windowStart: number; requests: number; bootstraps: number; controls: number };
22
+ type RateState = { windowStart: number; requests: number; bootstraps: number };
23
+ type ControlRateState = { windowStart: number; controls: number };
23
24
 
24
25
  export type LoopbackServer = {
25
26
  origin: string;
@@ -57,6 +58,7 @@ export async function startLoopbackServer(options: {
57
58
  readSessionCatalog?: () => unknown | Promise<unknown>;
58
59
  readSessionCreationOptions?: () => unknown | Promise<unknown>;
59
60
  readSessionModel?: (sessionRef: string) => WebUiReadModelProvider | Promise<WebUiReadModelProvider>;
61
+ executeSessionAttachment?: (sessionRef: string, command: unknown) => unknown | Promise<unknown>;
60
62
  readSessionConnections?: (sessionRef: string) => unknown | Promise<unknown>;
61
63
  executeSessionConnection?: (command: unknown) => unknown | Promise<unknown>;
62
64
  readMcpAuthJob?: (jobRef: string) => unknown | Promise<unknown>;
@@ -82,6 +84,15 @@ export async function startLoopbackServer(options: {
82
84
  const auth = new SessionAuthority({ bootstrapTtlMs: options.bootstrapTtlMs, sessionTtlMs: options.sessionTtlMs });
83
85
  const sse = options.readModel ? new SseHub(options.readModel) : null;
84
86
  const rates = new Map<string, RateState>();
87
+ const controlRates = new Map<string, ControlRateState>();
88
+ const consumeControl = (sessionId: string, now: number): boolean => {
89
+ for (const [id, value] of controlRates) if (now - value.windowStart >= 60_000) controlRates.delete(id);
90
+ const value = controlRates.get(sessionId) ?? { windowStart: now, controls: 0 };
91
+ if (now - value.windowStart >= 60_000) Object.assign(value, { windowStart: now, controls: 0 });
92
+ value.controls += 1; controlRates.set(sessionId, value);
93
+ while (controlRates.size > 64) controlRates.delete(controlRates.keys().next().value as string);
94
+ return value.controls <= CONTROLS_PER_MINUTE;
95
+ };
85
96
  let origin = "";
86
97
  const server = http.createServer(async (request, response) => {
87
98
  applySecurityHeaders(response, styleNonce);
@@ -90,9 +101,16 @@ export async function startLoopbackServer(options: {
90
101
  const requestOrigin = request.headers.origin;
91
102
  if (requestOrigin && requestOrigin !== origin) return errorResponse(response, 403, "origin-mismatch");
92
103
  const remote = request.socket.remoteAddress ?? "unknown", now = Date.now();
93
- const rate = rates.get(remote) ?? { windowStart: now, requests: 0, bootstraps: 0, controls: 0 };
94
- if (now - rate.windowStart >= 60_000) Object.assign(rate, { windowStart: now, requests: 0, bootstraps: 0, controls: 0 });
95
- rate.requests += 1; rates.set(remote, rate);
104
+ // A browser session is already a launch-scoped authenticated capability.
105
+ // Charging every tab and every prior launch to one localhost IP lets a
106
+ // refresh or an unauthenticated local process lock out the active operator.
107
+ const browserSession = auth.authenticate(request, now);
108
+ const rateKey = browserSession ? `${remote}:${browserSession.id}` : `remote:${remote}`;
109
+ for (const [key, value] of rates) if (now - value.windowStart >= 60_000) rates.delete(key);
110
+ const rate = rates.get(rateKey) ?? { windowStart: now, requests: 0, bootstraps: 0 };
111
+ if (now - rate.windowStart >= 60_000) Object.assign(rate, { windowStart: now, requests: 0, bootstraps: 0 });
112
+ rate.requests += 1; rates.set(rateKey, rate);
113
+ while (rates.size > 128) rates.delete(rates.keys().next().value as string);
96
114
  if (rate.requests > REQUESTS_PER_MINUTE) return errorResponse(response, 429, "rate-limit");
97
115
  let url: URL;
98
116
  try { url = new URL(request.url ?? "/", origin); }
@@ -122,10 +140,10 @@ export async function startLoopbackServer(options: {
122
140
 
123
141
  if (request.method === "POST" && ["/api/v1/chat/messages", "/api/v1/session-options", "/api/v1/lifecycle",
124
142
  "/api/v1/control/resume-and-continue", "/api/v1/reviews", "/api/v1/source-mutations", "/api/v1/source-handoffs"].includes(url.pathname) && options.executeControl) {
125
- rate.controls += 1;
126
- if (rate.controls > CONTROLS_PER_MINUTE) return errorResponse(response, 429, "control-rate-limit");
127
143
  if (requestOrigin !== origin) return errorResponse(response, 403, "origin-required");
128
- if (!auth.authorizeMutation(request)) return errorResponse(response, 403, "mutation-authority-rejected");
144
+ const mutationSession = auth.authorizeMutation(request);
145
+ if (!mutationSession) return errorResponse(response, 403, "mutation-authority-rejected");
146
+ if (!consumeControl(mutationSession.id, now)) return errorResponse(response, 429, "control-rate-limit");
129
147
  if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return errorResponse(response, 415, "content-type");
130
148
  let command: unknown;
131
149
  try { command = JSON.parse((await requestBody(request, MAX_CONTROL_BODY_BYTES)).toString("utf8")); }
@@ -135,10 +153,10 @@ export async function startLoopbackServer(options: {
135
153
  }
136
154
 
137
155
  if (request.method === "POST" && url.pathname === "/api/v1/projects/import" && options.executeProjectImport) {
138
- rate.controls += 1;
139
- if (rate.controls > CONTROLS_PER_MINUTE) return errorResponse(response, 429, "control-rate-limit");
140
156
  if (requestOrigin !== origin) return errorResponse(response, 403, "origin-required");
141
- if (!auth.authorizeMutation(request)) return errorResponse(response, 403, "mutation-authority-rejected");
157
+ const mutationSession = auth.authorizeMutation(request);
158
+ if (!mutationSession) return errorResponse(response, 403, "mutation-authority-rejected");
159
+ if (!consumeControl(mutationSession.id, now)) return errorResponse(response, 429, "control-rate-limit");
142
160
  if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return errorResponse(response, 415, "content-type");
143
161
  let command: unknown;
144
162
  try { command = JSON.parse((await requestBody(request, MAX_BOOTSTRAP_BODY_BYTES)).toString("utf8")); }
@@ -155,10 +173,10 @@ export async function startLoopbackServer(options: {
155
173
  }
156
174
 
157
175
  if (request.method === "POST" && url.pathname === "/api/v1/provider-auth" && options.executeProviderAuth) {
158
- rate.controls += 1;
159
- if (rate.controls > CONTROLS_PER_MINUTE) return errorResponse(response, 429, "control-rate-limit");
160
176
  if (requestOrigin !== origin) return errorResponse(response, 403, "origin-required");
161
- if (!auth.authorizeMutation(request)) return errorResponse(response, 403, "mutation-authority-rejected");
177
+ const mutationSession = auth.authorizeMutation(request);
178
+ if (!mutationSession) return errorResponse(response, 403, "mutation-authority-rejected");
179
+ if (!consumeControl(mutationSession.id, now)) return errorResponse(response, 429, "control-rate-limit");
162
180
  if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return errorResponse(response, 415, "content-type");
163
181
  let command: unknown;
164
182
  try { command = JSON.parse((await requestBody(request, MAX_CONTROL_BODY_BYTES)).toString("utf8")); }
@@ -172,10 +190,10 @@ export async function startLoopbackServer(options: {
172
190
  }
173
191
 
174
192
  if (request.method === "POST" && url.pathname === "/api/v1/session-connections" && options.executeSessionConnection) {
175
- rate.controls += 1;
176
- if (rate.controls > CONTROLS_PER_MINUTE) return errorResponse(response, 429, "control-rate-limit");
177
193
  if (requestOrigin !== origin) return errorResponse(response, 403, "origin-required");
178
- if (!auth.authorizeMutation(request)) return errorResponse(response, 403, "mutation-authority-rejected");
194
+ const mutationSession = auth.authorizeMutation(request);
195
+ if (!mutationSession) return errorResponse(response, 403, "mutation-authority-rejected");
196
+ if (!consumeControl(mutationSession.id, now)) return errorResponse(response, 429, "control-rate-limit");
179
197
  if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return errorResponse(response, 415, "content-type");
180
198
  let command: unknown;
181
199
  try { command = JSON.parse((await requestBody(request, MAX_CONTROL_BODY_BYTES)).toString("utf8")); }
@@ -188,9 +206,10 @@ export async function startLoopbackServer(options: {
188
206
  }
189
207
 
190
208
  if (request.method === "POST" && url.pathname.startsWith("/api/v1/mcp-auth/jobs/") && url.pathname.endsWith("/cancel") && options.cancelMcpAuthJob) {
191
- rate.controls += 1;
192
- if (rate.controls > CONTROLS_PER_MINUTE) return errorResponse(response, 429, "control-rate-limit");
193
- if (requestOrigin !== origin || !auth.authorizeMutation(request)) return errorResponse(response, 403, "mutation-authority-rejected");
209
+ if (requestOrigin !== origin) return errorResponse(response, 403, "mutation-authority-rejected");
210
+ const mutationSession = auth.authorizeMutation(request);
211
+ if (!mutationSession) return errorResponse(response, 403, "mutation-authority-rejected");
212
+ if (!consumeControl(mutationSession.id, now)) return errorResponse(response, 429, "control-rate-limit");
194
213
  const jobRef = url.pathname.slice("/api/v1/mcp-auth/jobs/".length, -"/cancel".length);
195
214
  if (!CURSOR.test(jobRef) || jobRef.includes("/") || url.search) return errorResponse(response, 400, "invalid-mcp-auth-job-ref");
196
215
  try { return jsonResponse(response, 200, await options.cancelMcpAuthJob(jobRef)); }
@@ -198,10 +217,10 @@ export async function startLoopbackServer(options: {
198
217
  }
199
218
 
200
219
  if (request.method === "POST" && url.pathname === "/api/v1/attachments" && options.executeAttachment) {
201
- rate.controls += 1;
202
- if (rate.controls > CONTROLS_PER_MINUTE) return errorResponse(response, 429, "control-rate-limit");
203
220
  if (requestOrigin !== origin) return errorResponse(response, 403, "origin-required");
204
- if (!auth.authorizeMutation(request)) return errorResponse(response, 403, "mutation-authority-rejected");
221
+ const mutationSession = auth.authorizeMutation(request);
222
+ if (!mutationSession) return errorResponse(response, 403, "mutation-authority-rejected");
223
+ if (!consumeControl(mutationSession.id, now)) return errorResponse(response, 429, "control-rate-limit");
205
224
  if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return errorResponse(response, 415, "content-type");
206
225
  let command: unknown;
207
226
  try { command = JSON.parse((await requestBody(request, MAX_ATTACHMENT_BODY_BYTES)).toString("utf8")); }
@@ -210,11 +229,30 @@ export async function startLoopbackServer(options: {
210
229
  catch { return errorResponse(response, 503, "attachment-runtime-unavailable"); }
211
230
  }
212
231
 
232
+ // The Gateway drives many sessions, so an attachment has to name the one it
233
+ // belongs to in the path. The body is the same bounded stage/discard command
234
+ // the single-session route takes, and every guard in front of it is the same.
235
+ if (request.method === "POST" && url.pathname.startsWith("/api/v1/sessions/") && url.pathname.endsWith("/attachments")
236
+ && options.executeSessionAttachment) {
237
+ if (requestOrigin !== origin) return errorResponse(response, 403, "origin-required");
238
+ const mutationSession = auth.authorizeMutation(request);
239
+ if (!mutationSession) return errorResponse(response, 403, "mutation-authority-rejected");
240
+ if (!consumeControl(mutationSession.id, now)) return errorResponse(response, 429, "control-rate-limit");
241
+ const sessionRef = url.pathname.slice("/api/v1/sessions/".length, -"/attachments".length);
242
+ if (!CURSOR.test(sessionRef) || sessionRef.includes("/")) return errorResponse(response, 400, "invalid-session-attachment-ref");
243
+ if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return errorResponse(response, 415, "content-type");
244
+ let command: unknown;
245
+ try { command = JSON.parse((await requestBody(request, MAX_ATTACHMENT_BODY_BYTES)).toString("utf8")); }
246
+ catch (error) { return errorResponse(response, (error as Error).message === "body-limit" ? 413 : 400, "invalid-attachment-command"); }
247
+ try { return jsonResponse(response, 200, await options.executeSessionAttachment(sessionRef, command)); }
248
+ catch { return errorResponse(response, 503, "attachment-runtime-unavailable"); }
249
+ }
250
+
213
251
  if (request.method === "POST" && url.pathname.startsWith("/api/v1/approvals/") && url.pathname.endsWith("/decision") && options.executeApproval) {
214
- rate.controls += 1;
215
- if (rate.controls > CONTROLS_PER_MINUTE) return errorResponse(response, 429, "control-rate-limit");
216
252
  if (requestOrigin !== origin) return errorResponse(response, 403, "origin-required");
217
- if (!auth.authorizeMutation(request)) return errorResponse(response, 403, "mutation-authority-rejected");
253
+ const mutationSession = auth.authorizeMutation(request);
254
+ if (!mutationSession) return errorResponse(response, 403, "mutation-authority-rejected");
255
+ if (!consumeControl(mutationSession.id, now)) return errorResponse(response, 429, "control-rate-limit");
218
256
  if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return errorResponse(response, 415, "content-type");
219
257
  const approvalRef = url.pathname.slice("/api/v1/approvals/".length, -"/decision".length);
220
258
  if (!CURSOR.test(approvalRef) || approvalRef.includes("/")) return errorResponse(response, 400, "invalid-approval-ref");
@@ -22,6 +22,8 @@ export type WebUiReadModelProvider = {
22
22
  handoffHistory(runRef: string): unknown | Promise<unknown>;
23
23
  subagentTree(runRef: string): unknown | Promise<unknown>;
24
24
  releaseMonitor(): unknown | Promise<unknown>;
25
+ documents(): unknown | Promise<unknown>;
26
+ document(documentRef: string): unknown | Promise<unknown>;
25
27
  activity(): unknown | Promise<unknown>;
26
28
  logPreview(activityRef: string): unknown | Promise<unknown>;
27
29
  transcript(beforeCursor: string | null, limit: number): unknown | Promise<unknown>;
@@ -29,6 +29,12 @@ export async function routeReadOnlyRequest(url: URL, provider: WebUiReadModelPro
29
29
  if (url.search || !OPAQUE_REF.test(ref) || ref.includes("/")) return { handled: true, status: 400, value: { error: { code: "invalid-task-run-ref" } } };
30
30
  return { handled: true, status: 200, value: await provider.subagentTree(ref) };
31
31
  }
32
+ if (url.pathname === "/api/v1/documents" && !url.search) return { handled: true, status: 200, value: await provider.documents() };
33
+ if (url.pathname.startsWith("/api/v1/documents/")) {
34
+ const ref = url.pathname.slice("/api/v1/documents/".length);
35
+ if (url.search || !OPAQUE_REF.test(ref) || ref.includes("/")) return { handled: true, status: 400, value: { error: { code: "invalid-document-ref" } } };
36
+ return { handled: true, status: 200, value: await provider.document(ref) };
37
+ }
32
38
  if (url.pathname === "/api/v1/chat/queue" && !url.search) return { handled: true, status: 200, value: await provider.queue() };
33
39
  if (url.pathname === "/api/v1/session-options/models" && !url.search) return { handled: true, status: 200, value: await provider.modelCatalog() };
34
40
  if (url.pathname.startsWith("/api/v1/approvals/")) {
@@ -13,8 +13,10 @@ type TranscriptRevision = { runtimeRevision: string; taskRevision: string | null
13
13
  indexRevision: string | null; approvalRevision: string | null; sessionOptionRevision: string | null; queueRevision: string | null };
14
14
  type TranscriptContent = { state: "available" | "redacted" | "unavailable"; text: string | null; textChars: number | null; digest: string | null;
15
15
  truncated: boolean; redacted: boolean; imageCount: number; reasonCode: string | null };
16
+ type TranscriptAttachment = { displayName: string; kind: "file" | "image" | "document"; mimeType: string; truncated: boolean };
16
17
  type TranscriptItem = { messageRef: string; parentMessageRef: string | null; role: "user" | "assistant" | "tool-result"; recordedAt: string;
17
18
  agentOperationId: string | null; turnIndex: number | null; content: TranscriptContent;
19
+ attachments?: TranscriptAttachment[];
18
20
  toolCalls: Array<{ toolCallRef: string; toolName: string; state: "requested" | "completed" | "failed" | "unknown" }> };
19
21
  export type TranscriptDocument = { schemaVersion: 1; version: "piagent-webui-transcript-v1"; generatedAt: string; identity: TranscriptIdentity;
20
22
  revision: TranscriptRevision; eventCursor: string; state: "ready" | "unavailable"; items: TranscriptItem[];
@@ -41,6 +43,50 @@ function messageText(message: any): string {
41
43
  if (!Array.isArray(message?.content)) return "";
42
44
  return message.content.filter((item: any) => item?.type === "text" && typeof item.text === "string").map((item: any) => item.text).join("\n");
43
45
  }
46
+ function safeAttachmentName(value: unknown): string {
47
+ const projected = safeText(String(value ?? "attachment"));
48
+ return projected.full.replace(/[\\/]/g, "_").replace(/\s+/g, " ").trim().slice(0, 160) || "attachment";
49
+ }
50
+ function userMessageProjection(message: any): { text: string; attachments: TranscriptAttachment[] } {
51
+ if (message?.role !== "user" || !Array.isArray(message?.content)) return { text: messageText(message), attachments: [] };
52
+ const textParts = message.content.filter((part: any) => part?.type === "text" && typeof part.text === "string");
53
+ const attachments: TranscriptAttachment[] = [];
54
+ for (const part of textParts.slice(1)) {
55
+ const lines = String(part.text).split("\n", 3);
56
+ if (!lines[0]?.startsWith("attached file: ") || !lines[1]?.startsWith("format: ")) continue;
57
+ let displayName: unknown;
58
+ try { displayName = JSON.parse(lines[0].slice("attached file: ".length)); } catch { continue; }
59
+ if (typeof displayName !== "string") continue;
60
+ const format = /^format: ([A-Za-z0-9][A-Za-z0-9.+-]*\/[A-Za-z0-9][A-Za-z0-9.+-]*)(, truncated)?$/.exec(lines[1]);
61
+ if (!format) continue;
62
+ const mimeType = format[1]!;
63
+ const kind = mimeType === "application/pdf" || mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
64
+ ? "document" as const : "file" as const;
65
+ attachments.push({ displayName: safeAttachmentName(displayName), kind, mimeType, truncated: Boolean(format[2]) });
66
+ }
67
+ for (const part of message.content) {
68
+ if (part?.type !== "image" || typeof part.mimeType !== "string" || !/^[A-Za-z0-9][A-Za-z0-9.+-]*\/[A-Za-z0-9][A-Za-z0-9.+-]*$/.test(part.mimeType)) continue;
69
+ attachments.push({ displayName: "Image", kind: "image", mimeType: part.mimeType.slice(0, 120), truncated: false });
70
+ }
71
+ // Attachment bodies are provider input, not transcript prose. Showing or
72
+ // hashing them here would turn the chat bubble into a document dump and make
73
+ // a bounded UI digest an oracle over user files. The first block is the exact
74
+ // text the operator typed; the remaining recognized blocks become cards.
75
+ return { text: attachments.length ? String(textParts[0]?.text ?? "") : messageText(message), attachments: attachments.slice(0, 4) };
76
+ }
77
+ function assistantFailureReason(message: any): string | null {
78
+ if (message?.role !== "assistant" || message?.stopReason !== "error") return null;
79
+ const detail = String(message?.errorMessage ?? "").toLowerCase();
80
+ if (/(?:authentication|auth|token|credential).{0,48}expired|expired.{0,48}(?:authentication|auth|token|credential)/.test(detail)) {
81
+ return "provider-auth-expired";
82
+ }
83
+ if (/(?:authentication|unauthorized|forbidden|credential|api[ _-]?key|log[ -]?in|sign[ -]?in)/.test(detail)) {
84
+ return "provider-auth-required";
85
+ }
86
+ if (/(?:rate[ _-]?limit|too many requests|quota)/.test(detail)) return "provider-rate-limited";
87
+ if (/(?:network|fetch failed|econn|timed? ?out|unavailable)/.test(detail)) return "provider-unavailable";
88
+ return "provider-response-failed";
89
+ }
44
90
  function safeText(value: string): { full: string; preview: string; redacted: boolean; truncated: boolean } {
45
91
  const clean = value.replace(ANSI, "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ");
46
92
  const redaction = redactSensitiveText(clean);
@@ -69,21 +115,31 @@ function toolCalls(message: any, sessionRef: string, role: TranscriptItem["role"
69
115
  function role(message: any): TranscriptItem["role"] | null {
70
116
  return message?.role === "user" ? "user" : message?.role === "assistant" ? "assistant" : message?.role === "toolResult" ? "tool-result" : null;
71
117
  }
118
+ function isInternalFreshTransition(message: any): boolean {
119
+ if (message?.role !== "user") return false;
120
+ return /^\/fresh\s+(?:task|scout|be-to-fe)\s+(?:--session-title\s+"[^"\r\n]{1,64}"\s+)?Read task intake from \.pi\/task-inbox\/[A-Za-z0-9._-]+\.md\.\s+Current session is near context limits; use a fresh governed session\.$/u
121
+ .test(messageText(message).trim());
122
+ }
72
123
  function unavailableContent(reasonCode: string): TranscriptContent {
73
124
  return { state: "unavailable", text: null, textChars: null, digest: null, truncated: false, redacted: false, imageCount: 0, reasonCode };
74
125
  }
75
126
  function item(entry: any, identity: TranscriptIdentity): TranscriptItem | null {
76
127
  if (!entry || entry.type !== "message" || !entry.message) return null;
128
+ if (isInternalFreshTransition(entry.message)) return null;
77
129
  const itemRole = role(entry.message), recordedAt = timestamp(entry.timestamp ?? entry.message.timestamp);
78
130
  if (!itemRole || !recordedAt || typeof entry.id !== "string" || entry.id.length === 0) return null;
131
+ const userProjection = userMessageProjection(entry.message);
79
132
  const content = itemRole === "tool-result" ? unavailableContent("tool-output-in-activity-preview") : (() => {
80
- const projected = safeText(messageText(entry.message));
133
+ const projected = safeText(itemRole === "user" ? userProjection.text : messageText(entry.message));
134
+ const failureReason = projected.full.length === 0 ? assistantFailureReason(entry.message) : null;
135
+ if (failureReason) return unavailableContent(failureReason);
81
136
  return { state: projected.redacted ? "redacted" as const : "available" as const, text: projected.preview,
82
137
  textChars: Math.min(1_000_000_000, projected.full.length), digest: `sha256:${hash(projected.full)}`, truncated: projected.truncated,
83
138
  redacted: projected.redacted, imageCount: imageCount(entry.message), reasonCode: projected.redacted ? "sensitive-values-redacted" as const : null };
84
139
  })();
85
140
  return { messageRef: opaque("message", [identity.sessionRef, entry.id]), parentMessageRef: null, role: itemRole, recordedAt,
86
- agentOperationId: null, turnIndex: null, content, toolCalls: toolCalls(entry.message, identity.sessionRef, itemRole) };
141
+ agentOperationId: null, turnIndex: null, content, ...(userProjection.attachments.length ? { attachments: userProjection.attachments } : {}),
142
+ toolCalls: toolCalls(entry.message, identity.sessionRef, itemRole) };
87
143
  }
88
144
  function unavailable(input: TranscriptProjectionInput, reasonCode: string, limit: number): TranscriptDocument {
89
145
  return { schemaVersion: 1, version: "piagent-webui-transcript-v1", generatedAt: input.generatedAt ?? new Date().toISOString(),
@@ -49,7 +49,12 @@
49
49
  "image/bmp",
50
50
  "text/plain",
51
51
  "text/markdown",
52
- "application/json"
52
+ "text/csv",
53
+ "text/tab-separated-values",
54
+ "application/json",
55
+ "application/yaml",
56
+ "application/pdf",
57
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
53
58
  ]
54
59
  },
55
60
  "dataBase64": {
@@ -86,7 +91,7 @@
86
91
  "attachment": {
87
92
  "type": "object",
88
93
  "additionalProperties": false,
89
- "required": ["attachmentRef", "messageRequestId", "displayName", "kind", "mimeType", "sizeBytes", "expiresAt"],
94
+ "required": ["attachmentRef", "messageRequestId", "displayName", "kind", "mimeType", "sizeBytes", "sourceBytes", "truncated", "expiresAt"],
90
95
  "properties": {
91
96
  "attachmentRef": { "$ref": "common-v1.schema.json#/$defs/opaqueRef" },
92
97
  "messageRequestId": { "$ref": "common-v1.schema.json#/$defs/opaqueRef" },
@@ -96,14 +101,29 @@
96
101
  "maxLength": 160,
97
102
  "pattern": "^[^\\u0000-\\u001F\\u007F/\\\\]*$"
98
103
  },
99
- "kind": { "enum": ["file", "image"] },
104
+ "kind": { "enum": ["file", "image", "document"] },
100
105
  "mimeType": {
101
106
  "type": "string",
102
107
  "minLength": 3,
103
108
  "maxLength": 127,
104
109
  "pattern": "^[a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+*-]+$"
105
110
  },
106
- "sizeBytes": { "type": "integer", "minimum": 1, "maximum": 8388608 },
111
+ "sizeBytes": {
112
+ "description": "Bytes held for dispatch: image bytes, or the UTF-8 text extracted from a text or document file.",
113
+ "type": "integer",
114
+ "minimum": 1,
115
+ "maximum": 8388608
116
+ },
117
+ "sourceBytes": {
118
+ "description": "Bytes of the file the operator selected, before extraction. Equal to sizeBytes for images.",
119
+ "type": "integer",
120
+ "minimum": 1,
121
+ "maximum": 8388608
122
+ },
123
+ "truncated": {
124
+ "description": "True when extraction produced more text than the per-attachment cap, so Pi receives a prefix.",
125
+ "type": "boolean"
126
+ },
107
127
  "expiresAt": { "$ref": "common-v1.schema.json#/$defs/timestamp" }
108
128
  }
109
129
  },
@@ -535,9 +535,9 @@
535
535
  "kinds": {
536
536
  "type": "array",
537
537
  "minItems": 1,
538
- "maxItems": 2,
538
+ "maxItems": 3,
539
539
  "uniqueItems": true,
540
- "items": { "enum": ["file", "image"] }
540
+ "items": { "enum": ["file", "image", "document"] }
541
541
  },
542
542
  "mimeTypes": {
543
543
  "type": "array",
@@ -173,6 +173,15 @@
173
173
  "supported": true,
174
174
  "current": true
175
175
  },
176
+ {
177
+ "name": "document-workspace-v1",
178
+ "file": "document-workspace-v1.schema.json",
179
+ "id": "https://piagent.io.vn/schemas/piagent-webui/document-workspace-v1.schema.json",
180
+ "documentVersion": 1,
181
+ "role": "read-model",
182
+ "supported": true,
183
+ "current": true
184
+ },
176
185
  {
177
186
  "name": "control-command-v1",
178
187
  "file": "control-command-v1.schema.json",