@piagent/platform 1.5.2 → 1.5.4

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 (128) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/README.md +8 -2
  3. package/README.vi.md +23 -1
  4. package/benchmarks/capability-v1/DESIGN.md +30 -0
  5. package/benchmarks/capability-v1/coverage.json +23 -1
  6. package/benchmarks/capability-v1/grade.mjs +246 -0
  7. package/benchmarks/capability-v1/project/apps/web/src/control-view.js +3 -0
  8. package/benchmarks/capability-v1/project/apps/web/src/timeline-view.js +3 -0
  9. package/benchmarks/capability-v1/project/packages/session/src/admission.js +7 -0
  10. package/benchmarks/capability-v1/project/packages/session/src/runtime-route.js +3 -0
  11. package/benchmarks/capability-v1/project/packages/timeline/src/checkpoint.js +7 -0
  12. package/benchmarks/capability-v1/project/packages/timeline/src/project.js +7 -0
  13. package/benchmarks/capability-v1/project/test/smoke.test.js +19 -1
  14. package/benchmarks/capability-v1/prompts/causal-timeline-recovery.md +47 -0
  15. package/benchmarks/capability-v1/prompts/durable-session-control-plane.md +47 -0
  16. package/benchmarks/capability-v1/references/causal-timeline-recovery/apps/web/src/timeline-view.js +9 -0
  17. package/benchmarks/capability-v1/references/causal-timeline-recovery/packages/timeline/src/checkpoint.js +22 -0
  18. package/benchmarks/capability-v1/references/causal-timeline-recovery/packages/timeline/src/project.js +77 -0
  19. package/benchmarks/capability-v1/references/durable-session-control-plane/apps/web/src/control-view.js +4 -0
  20. package/benchmarks/capability-v1/references/durable-session-control-plane/packages/session/src/admission.js +52 -0
  21. package/benchmarks/capability-v1/references/durable-session-control-plane/packages/session/src/runtime-route.js +27 -0
  22. package/benchmarks/capability-v1/rubric.json +26 -0
  23. package/benchmarks/capability-v1/suite.json +32 -4
  24. package/benchmarks/capability-v1/variant.mjs +11 -0
  25. package/benchmarks/deep-logic-v1/grade.mjs +165 -0
  26. package/benchmarks/deep-logic-v1/project/README.md +3 -0
  27. package/benchmarks/deep-logic-v1/project/package.json +6 -0
  28. package/benchmarks/deep-logic-v1/project/src/config-transaction.js +3 -0
  29. package/benchmarks/deep-logic-v1/project/src/context-pack.js +3 -0
  30. package/benchmarks/deep-logic-v1/project/src/event-reconcile.js +3 -0
  31. package/benchmarks/deep-logic-v1/project/src/policy.js +3 -0
  32. package/benchmarks/deep-logic-v1/project/src/scheduler.js +3 -0
  33. package/benchmarks/deep-logic-v1/project/src/stream.js +3 -0
  34. package/benchmarks/deep-logic-v1/project/test/smoke.test.js +20 -0
  35. package/benchmarks/deep-logic-v1/prompts/budgeted-context-graph.md +13 -0
  36. package/benchmarks/deep-logic-v1/prompts/fair-dependency-scheduler.md +13 -0
  37. package/benchmarks/deep-logic-v1/prompts/layered-policy-resolution.md +14 -0
  38. package/benchmarks/deep-logic-v1/prompts/resumable-stream-assembly.md +13 -0
  39. package/benchmarks/deep-logic-v1/prompts/revision-event-reconciliation.md +14 -0
  40. package/benchmarks/deep-logic-v1/prompts/transactional-config-merge.md +13 -0
  41. package/benchmarks/deep-logic-v1/suite.json +95 -0
  42. package/benchmarks/deep-logic-v1/variant.mjs +28 -0
  43. package/docs/command-reference-vietnamese.md +5 -2
  44. package/docs/distribution-standard.md +7 -7
  45. package/docs/languages.json +2 -2
  46. package/docs/operator-manual-vietnamese.md +7 -7
  47. package/docs/publishing-for-teams.md +7 -0
  48. package/docs/quality-benchmark.md +49 -10
  49. package/docs/release-install-policy.md +6 -6
  50. package/docs/security-threat-model.md +1 -1
  51. package/docs/team-onboarding.md +2 -2
  52. package/docs-site/architecture.html +1 -1
  53. package/docs-site/benchmark.html +1 -1
  54. package/docs-site/commands.html +1 -1
  55. package/docs-site/context-engine.html +1 -1
  56. package/docs-site/en/architecture.html +1 -1
  57. package/docs-site/en/benchmark.html +1 -1
  58. package/docs-site/en/commands.html +1 -1
  59. package/docs-site/en/context-engine.html +1 -1
  60. package/docs-site/en/index.html +1 -1
  61. package/docs-site/en/mcp-approval.html +1 -1
  62. package/docs-site/en/mcp-auth.html +1 -1
  63. package/docs-site/en/mcp-servers.html +1 -1
  64. package/docs-site/en/mcp.html +1 -1
  65. package/docs-site/en/permissions.html +1 -1
  66. package/docs-site/en/profiles.html +1 -1
  67. package/docs-site/en/quickstart.html +1 -1
  68. package/docs-site/en/runtime.html +1 -1
  69. package/docs-site/en/scope.html +1 -1
  70. package/docs-site/en/security.html +1 -1
  71. package/docs-site/en/team.html +1 -1
  72. package/docs-site/en/whats-new.html +1 -1
  73. package/docs-site/en/workflows.html +1 -1
  74. package/docs-site/index.html +1 -1
  75. package/docs-site/mcp-approval.html +1 -1
  76. package/docs-site/mcp-auth.html +1 -1
  77. package/docs-site/mcp-servers.html +1 -1
  78. package/docs-site/mcp.html +1 -1
  79. package/docs-site/permissions.html +1 -1
  80. package/docs-site/profiles.html +1 -1
  81. package/docs-site/quickstart.html +1 -1
  82. package/docs-site/runtime.html +1 -1
  83. package/docs-site/scope.html +1 -1
  84. package/docs-site/security.html +1 -1
  85. package/docs-site/team.html +1 -1
  86. package/docs-site/whats-new.html +1 -1
  87. package/docs-site/workflows.html +1 -1
  88. package/evals/fixtures/benchmark-assurance-evidence.invalid.json +2 -2
  89. package/evals/fixtures/benchmark-assurance-evidence.valid.json +2 -2
  90. package/evals/fixtures/piagent-webui/runtime-command-v1.invalid.json +12 -0
  91. package/evals/fixtures/piagent-webui/runtime-command-v1.valid.json +11 -0
  92. package/evals/fs4-readiness-matrix.v1.json +2 -2
  93. package/evals/fs5-causal-arm.v1.json +1 -1
  94. package/evals/fs5-pilot-protocol.v1.json +1 -1
  95. package/evals/fs5-pilot-protocol.v4.json +1 -1
  96. package/evals/fs5-pilot-protocol.v5.json +1 -1
  97. package/evals/private-holdout-v1/public-exposure.v1.json +3 -3
  98. package/evals/real-task-taxonomy.v1.json +5 -1
  99. package/package.json +3 -1
  100. package/packages/piagent-core/benchmark/benchmark-bootstrap.js +3 -2
  101. package/packages/piagent-core/benchmark/benchmark-cli.js +5 -1
  102. package/packages/piagent-core/benchmark/benchmark-matrix.js +1 -1
  103. package/packages/piagent-core/benchmark/benchmark-suite-runtime.js +1 -0
  104. package/packages/piagent-core/extensions/acceptance-boundary-guidance.js +64 -0
  105. package/packages/piagent-core/extensions/acceptance-contract-semantics.js +3 -2
  106. package/packages/piagent-core/package.json +1 -1
  107. package/packages/piagent-core/runtime/quality/performance-assurance.ts +6 -4
  108. package/packages/piagent-core/runtime/registration/task-start-tool.ts +2 -2
  109. package/packages/piagent-core/runtime/workflows/webui-runtime-command.ts +89 -0
  110. package/packages/piagent-core/runtime/workflows/webui-workflow.ts +51 -0
  111. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-CbUK4gg2.js → MarkdownMessage-rD9WJKxs.js} +1 -1
  112. package/packages/piagent-webui/dist/client/assets/index-BZRFw1u6.js +174 -0
  113. package/packages/piagent-webui/dist/client/index.html +1 -1
  114. package/packages/piagent-webui/gateway/gateway-service.ts +3 -0
  115. package/packages/piagent-webui/gateway/gateway-session-stream.ts +10 -0
  116. package/packages/piagent-webui/gateway/rpc-ui-context.ts +28 -0
  117. package/packages/piagent-webui/gateway/runtime-command-controller.ts +126 -0
  118. package/packages/piagent-webui/gateway/runtime-session-controls.ts +29 -0
  119. package/packages/piagent-webui/gateway/session-command-controller.ts +23 -7
  120. package/packages/piagent-webui/gateway/session-inspection-registry.ts +22 -1
  121. package/packages/piagent-webui/gateway/session-operation-start.ts +34 -0
  122. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +24 -42
  123. package/packages/piagent-webui/package.json +2 -1
  124. package/packages/piagent-webui/server/loopback-server.ts +17 -0
  125. package/schemas/piagent-webui/catalog-v1.json +9 -0
  126. package/schemas/piagent-webui/runtime-command-v1.schema.json +70 -0
  127. package/schemas/piagent-webui/session-command-v1.schema.json +8 -0
  128. package/packages/piagent-webui/dist/client/assets/index-CNbYWd-R.js +0 -174
@@ -7,7 +7,7 @@
7
7
  <meta name="csp-nonce" content="__PIAGENT_CSP_NONCE__" />
8
8
  <meta name="piagent-webui-mode" content="__PIAGENT_WEBUI_MODE__" />
9
9
  <title>Piagent WebUI</title>
10
- <script type="module" crossorigin src="/assets/index-CNbYWd-R.js"></script>
10
+ <script type="module" crossorigin src="/assets/index-BZRFw1u6.js"></script>
11
11
  <link rel="stylesheet" crossorigin href="/assets/index-BV-_03yl.css">
12
12
  </head>
13
13
  <body>
@@ -23,6 +23,7 @@ import { SessionLeaseStore } from "./session-lease-store.ts";
23
23
  import { SessionRuntimeSupervisor } from "./session-runtime-supervisor.ts";
24
24
  import { SessionCommandStore } from "./session-command-store.ts";
25
25
  import { SessionCommandController } from "./session-command-controller.ts";
26
+ import { RuntimeCommandController } from "./runtime-command-controller.ts";
26
27
  import { GatewayEventStore } from "./gateway-events.ts";
27
28
  import { SessionInspectionRegistry } from "./session-inspection-registry.ts";
28
29
  import { SessionAttachmentRegistry } from "./session-attachment-registry.ts";
@@ -175,6 +176,7 @@ export async function startPiagentGateway(options: {
175
176
  if (!attachments) throw new Error("session-attachment-unavailable");
176
177
  return attachments.reserveForPrompt(sessionRef, refs, messageRequestId, text);
177
178
  } });
179
+ const runtimeCommands = new RuntimeCommandController({ catalog: readCatalog, runtimes, events });
178
180
  const protocol = new GatewayProtocolService({ capabilities: () => capabilities(gatewayInstanceRef, true), catalog: readCatalog,
179
181
  events, command: commands });
180
182
  const inspections = new SessionInspectionRegistry({ gatewayInstanceRef, host, key, packageRoot: options.packageRoot, agentDir: state.agentDir,
@@ -194,6 +196,7 @@ export async function startPiagentGateway(options: {
194
196
  readSessionModel: (sessionRef) => inspections.provider(sessionRef),
195
197
  readSessionConnections: (sessionRef) => inspections.connections(sessionRef),
196
198
  executeSessionConnection: (command) => inspections.executeConnectionCommand(command),
199
+ executeRuntimeCommand: (command) => runtimeCommands.execute(command),
197
200
  executeSessionAttachment: (sessionRef, command) => attachments.execute(sessionRef, command),
198
201
  executeApproval: (approvalRef, decision) => runtimes!.decideApproval(approvalRef, decision),
199
202
  readMcpAuthJob: (jobRef) => mcpAuth!.read(jobRef),
@@ -31,6 +31,8 @@ export class GatewaySessionStream {
31
31
  #truncated = false;
32
32
  #started = false;
33
33
  #startListeners: Array<() => void> = [];
34
+ #settled = false;
35
+ #settleListeners: Array<() => void> = [];
34
36
  readonly #toolRefs = new Map<string, string>();
35
37
 
36
38
  constructor(options: { sessionRef: string; operationRef: string; events: GatewayEventStore }) {
@@ -42,10 +44,18 @@ export class GatewaySessionStream {
42
44
  return new Promise((resolve) => this.#startListeners.push(resolve));
43
45
  }
44
46
 
47
+ settled(): Promise<void> {
48
+ if (this.#settled) return Promise.resolve();
49
+ return new Promise((resolve) => this.#settleListeners.push(resolve));
50
+ }
51
+
45
52
  observe(event: any): void {
46
53
  if (event?.type === "agent_start") {
47
54
  this.#started = true; for (const resolve of this.#startListeners.splice(0)) resolve(); return;
48
55
  }
56
+ if (event?.type === "agent_settled") {
57
+ this.#settled = true; for (const resolve of this.#settleListeners.splice(0)) resolve(); return;
58
+ }
49
59
  if (event?.type === "message_start" && event.message?.role === "assistant") {
50
60
  this.#messageRef = ref("message"); this.#buffer = ""; return;
51
61
  }
@@ -0,0 +1,28 @@
1
+ import { GATEWAY_RUNTIME_UI_MARKER } from "../ownership/gateway-runtime-context.ts";
2
+
3
+ export function rpcUiContext(): object {
4
+ const plain = (text: unknown): string => String(text ?? "");
5
+ const theme = Object.freeze({
6
+ fg: (_color: unknown, text: unknown) => plain(text),
7
+ bg: (_color: unknown, text: unknown) => plain(text),
8
+ bold: plain,
9
+ italic: plain,
10
+ underline: plain,
11
+ inverse: plain,
12
+ strikethrough: plain,
13
+ getFgAnsi: () => "",
14
+ getBgAnsi: () => "",
15
+ getColorMode: () => "truecolor",
16
+ getThinkingBorderColor: () => plain,
17
+ getBashModeBorderColor: () => plain
18
+ });
19
+ return new Proxy({}, {
20
+ get(_target, property) {
21
+ if (property === GATEWAY_RUNTIME_UI_MARKER) return true;
22
+ if (property === "theme") return theme;
23
+ if (property === "confirm") return () => new Promise<boolean>(() => undefined);
24
+ if (property === "select" || property === "input") return async () => undefined;
25
+ return () => undefined;
26
+ }
27
+ });
28
+ }
@@ -0,0 +1,126 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import {
4
+ WEBUI_RUNTIME_ACTIONS,
5
+ buildWebUiRuntimeCommand,
6
+ isWebUiRuntimeActionId
7
+ } from "../../piagent-core/runtime/workflows/webui-runtime-command.ts";
8
+ import type { Catalog } from "../contracts/generated/session-catalog-v1.ts";
9
+ import type { Action, Command, Receipt } from "../contracts/generated/runtime-command-v1.ts";
10
+ import { GatewayEventStore } from "./gateway-events.ts";
11
+ import { SessionRuntimeSupervisor } from "./session-runtime-supervisor.ts";
12
+
13
+ const REF = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/;
14
+ const REVISION = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,159}$/;
15
+ const MAX_REPLAYS = 200;
16
+
17
+ type Replay = { digest: string; receipt: Receipt };
18
+
19
+ function record(value: unknown): value is Record<string, unknown> {
20
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
21
+ }
22
+
23
+ function parseCommand(value: unknown): Command | null {
24
+ if (!record(value)) return null;
25
+ const expected = ["schemaVersion", "version", "messageType", "requestId", "sessionRef", "expectedSessionRevision", "action", "argument", "confirmed"];
26
+ const keys = Object.keys(value);
27
+ if (keys.length !== expected.length || !keys.every((key) => expected.includes(key))) return null;
28
+ if (value.schemaVersion !== 1 || value.version !== "piagent-runtime-command-v1" || value.messageType !== "command"
29
+ || typeof value.requestId !== "string" || !REF.test(value.requestId)
30
+ || typeof value.sessionRef !== "string" || !REF.test(value.sessionRef)
31
+ || typeof value.expectedSessionRevision !== "string" || !REVISION.test(value.expectedSessionRevision)
32
+ || !isWebUiRuntimeActionId(value.action)
33
+ || !(value.argument === null || typeof value.argument === "string" && value.argument.length <= 2_048 && !value.argument.includes("\0"))
34
+ || typeof value.confirmed !== "boolean") return null;
35
+ return value as unknown as Command;
36
+ }
37
+
38
+ function commandDigest(command: Command): string {
39
+ return createHash("sha256").update(JSON.stringify({ sessionRef: command.sessionRef,
40
+ expectedSessionRevision: command.expectedSessionRevision, action: command.action,
41
+ argument: command.argument, confirmed: command.confirmed })).digest("hex");
42
+ }
43
+
44
+ export class RuntimeCommandController {
45
+ readonly #catalog: () => Promise<Catalog>;
46
+ readonly #runtimes: SessionRuntimeSupervisor;
47
+ readonly #events: GatewayEventStore;
48
+ readonly #replays = new Map<string, Replay>();
49
+ readonly #chains = new Map<string, Promise<unknown>>();
50
+
51
+ constructor(options: { catalog(): Promise<Catalog>; runtimes: SessionRuntimeSupervisor; events: GatewayEventStore }) {
52
+ this.#catalog = options.catalog;
53
+ this.#runtimes = options.runtimes;
54
+ this.#events = options.events;
55
+ }
56
+
57
+ execute(input: unknown): Promise<Receipt> {
58
+ const command = parseCommand(input);
59
+ if (!command) return Promise.reject(new Error("runtime-command-invalid"));
60
+ const prior = this.#chains.get(command.sessionRef) ?? Promise.resolve();
61
+ const run = prior.then(() => this.#execute(command), () => this.#execute(command));
62
+ const tail = run.catch(() => undefined);
63
+ this.#chains.set(command.sessionRef, tail);
64
+ return run.finally(() => { if (this.#chains.get(command.sessionRef) === tail) this.#chains.delete(command.sessionRef); });
65
+ }
66
+
67
+ async #execute(command: Command): Promise<Receipt> {
68
+ const digest = commandDigest(command), replay = this.#replays.get(command.requestId);
69
+ if (replay) {
70
+ if (replay.digest !== digest) return this.#receipt(command, "rejected", "invalid-command", "idempotency-payload-mismatch");
71
+ return replay.receipt;
72
+ }
73
+ const catalog = await this.#readyCatalog();
74
+ const row = catalog.sessions.find((session) => session.sessionRef === command.sessionRef);
75
+ if (!row || row.archived) return this.#settle(command, digest,
76
+ this.#receipt(command, "rejected", "unavailable", row ? "session-archived" : "session-not-found"));
77
+ if (row.sessionRevision !== command.expectedSessionRevision) return this.#settle(command, digest,
78
+ this.#receipt(command, "rejected", "stale-revision", "session-revision-stale", row.sessionRevision));
79
+
80
+ let built: ReturnType<typeof buildWebUiRuntimeCommand>;
81
+ try { built = buildWebUiRuntimeCommand({ action: command.action, argument: command.argument, confirmed: command.confirmed }); }
82
+ catch (error) {
83
+ const reason = error instanceof Error ? error.message : "runtime-command-invalid";
84
+ return this.#settle(command, digest, this.#receipt(command, "rejected",
85
+ reason === "runtime-command-confirmation-required" ? "confirmation-required" : "invalid-command", reason, row.sessionRevision));
86
+ }
87
+
88
+ try {
89
+ const result = await this.#runtimes.runRuntimeCommand(command.sessionRef, built.command);
90
+ const after = await this.#readyCatalog();
91
+ const afterRow = after.sessions.find((session) => session.sessionRef === command.sessionRef);
92
+ const zeroTurnViolation = built.spec.effect === "read-only" && result.modelCallObserved;
93
+ const receipt = this.#receipt(command, zeroTurnViolation ? "uncertain" : "settled",
94
+ zeroTurnViolation ? "effect-unknown" : "completed",
95
+ zeroTurnViolation ? "read-only-command-started-model-call" : null,
96
+ afterRow?.sessionRevision ?? null, result.modelCallObserved, result.outputs as Receipt["outputs"]);
97
+ this.#events.publish("session.changed", { catalogRevision: after.catalogRevision, session: afterRow ?? row });
98
+ return this.#settle(command, digest, receipt);
99
+ } catch (error) {
100
+ const reason = error instanceof Error ? error.message : "runtime-command-unavailable";
101
+ const resultCode = reason.includes("busy") ? "runtime-busy" : "unavailable";
102
+ return this.#settle(command, digest,
103
+ this.#receipt(command, "rejected", resultCode, reason, row.sessionRevision));
104
+ }
105
+ }
106
+
107
+ async #readyCatalog(): Promise<Catalog> {
108
+ const catalog = await this.#catalog();
109
+ if (catalog.state !== "ready" || !catalog.catalogRevision) throw new Error(catalog.reasonCode ?? "catalog-unavailable");
110
+ return catalog;
111
+ }
112
+
113
+ #receipt(command: Command, state: Receipt["state"], resultCode: Receipt["resultCode"], reasonCode: string | null,
114
+ sessionRevisionAfter: string | null = null, modelCallObserved = false, outputs: Receipt["outputs"] = []): Receipt {
115
+ const spec = WEBUI_RUNTIME_ACTIONS.find((item) => item.id === command.action)!;
116
+ return { schemaVersion: 1, version: "piagent-runtime-receipt-v1", messageType: "receipt",
117
+ requestId: command.requestId, sessionRef: command.sessionRef, action: command.action as Action,
118
+ state, resultCode, effect: spec.effect, modelCallObserved, outputs, sessionRevisionAfter, reasonCode };
119
+ }
120
+
121
+ #settle(command: Command, digest: string, receipt: Receipt): Receipt {
122
+ this.#replays.set(command.requestId, { digest, receipt });
123
+ while (this.#replays.size > MAX_REPLAYS) this.#replays.delete(this.#replays.keys().next().value as string);
124
+ return receipt;
125
+ }
126
+ }
@@ -0,0 +1,29 @@
1
+ import { redactSensitiveText } from "../../piagent-core/security/sensitive-data.js";
2
+
3
+ export async function executePermissionCommand(session: any,
4
+ permissionMode: "read-only" | "workspace-write" | "trusted-full-access"): Promise<void> {
5
+ const before = Array.isArray(session.messages) ? session.messages.length : 0;
6
+ await session.prompt(`/permission ${permissionMode}`);
7
+ const messages = Array.isArray(session.messages) ? session.messages.slice(before) : [];
8
+ const observed = messages.reverse().find((message: any) => message?.role === "custom"
9
+ && message.customType === "piagent-permission-profile");
10
+ const profile = observed?.details?.permissionProfile;
11
+ if (!profile || profile.mode !== permissionMode || profile.warning) throw new Error("session-permission-unavailable");
12
+ }
13
+
14
+ export async function executeRuntimeCommand(session: any, command: string): Promise<{
15
+ outputs: Array<{ customType: string; content: string; truncated: boolean; redacted: boolean }>;
16
+ modelCallObserved: boolean;
17
+ }> {
18
+ const before = Array.isArray(session.messages) ? session.messages.length : 0;
19
+ await session.prompt(command);
20
+ const added = Array.isArray(session.messages) ? session.messages.slice(before) : [];
21
+ const outputs = added.filter((message: any) => message?.role === "custom").slice(-8).map((message: any) => {
22
+ const source = typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? "");
23
+ const bounded = source.slice(0, 12_000), redacted = redactSensitiveText(bounded);
24
+ const rawType = String(message.customType ?? "runtime-output");
25
+ return { customType: /^[A-Za-z0-9._-]{1,120}$/.test(rawType) ? rawType : "runtime-output", content: redacted.text,
26
+ truncated: source.length > bounded.length, redacted: redacted.redacted };
27
+ });
28
+ return { outputs, modelCallObserved: added.some((message: any) => message?.role === "assistant") };
29
+ }
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
2
2
 
3
3
  import type { Catalog, SessionRow } from "../contracts/generated/session-catalog-v1.ts";
4
4
  import type { Receipt } from "../contracts/generated/session-command-v1.ts";
5
+ import { buildWebUiWorkflowCommand, isWebUiWorkflowId, type WebUiWorkflowId } from "../../piagent-core/runtime/workflows/webui-workflow.ts";
5
6
  import { GatewayEventStore } from "./gateway-events.ts";
6
7
  import { SessionCommandStore, type SessionAction, type SessionCommandIdentity } from "./session-command-store.ts";
7
8
  import { SessionRuntimeSupervisor } from "./session-runtime-supervisor.ts";
@@ -27,6 +28,10 @@ function record(value: unknown): value is Record<string, unknown> { return Boole
27
28
  function exactKeys(value: Record<string, unknown>, keys: string[]): boolean {
28
29
  const found = Object.keys(value); return found.length === keys.length && found.every((key) => keys.includes(key));
29
30
  }
31
+ function requiredOptionalKeys(value: Record<string, unknown>, required: string[], optional: string[]): boolean {
32
+ const found = Object.keys(value), allowed = new Set([...required, ...optional]);
33
+ return required.every((key) => found.includes(key)) && found.every((key) => allowed.has(key));
34
+ }
30
35
  function exactTimestamp(value: unknown): value is string {
31
36
  return typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)
32
37
  && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
@@ -35,13 +40,15 @@ function nullableRef(value: unknown): value is string | null { return value ===
35
40
 
36
41
  function validPayload(action: SessionAction, payload: Record<string, unknown>): boolean {
37
42
  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", "attachmentRefs"])
43
+ if (action === "session.send") return requiredOptionalKeys(payload,
44
+ ["delivery", "message", "messageRequestId", "expectedOperationRef", "attachmentRefs"], ["workflow"])
39
45
  && Array.isArray(payload.attachmentRefs) && payload.attachmentRefs.length <= 4
40
46
  && new Set(payload.attachmentRefs as unknown[]).size === payload.attachmentRefs.length
41
47
  && (payload.attachmentRefs as unknown[]).every((item) => typeof item === "string" && REF.test(item))
42
48
  && ["new-operation", "follow-up", "steer"].includes(String(payload.delivery)) && typeof payload.message === "string"
43
49
  && payload.message.length >= 1 && payload.message.length <= 32_768 && !payload.message.includes("\0") && REF.test(String(payload.messageRequestId))
44
- && nullableRef(payload.expectedOperationRef) && (payload.delivery === "new-operation" ? payload.expectedOperationRef === null : payload.expectedOperationRef !== null);
50
+ && nullableRef(payload.expectedOperationRef) && (payload.delivery === "new-operation" ? payload.expectedOperationRef === null : payload.expectedOperationRef !== null)
51
+ && (payload.workflow === undefined || payload.delivery === "new-operation" && isWebUiWorkflowId(payload.workflow));
45
52
  if (action === "session.abort") return exactKeys(payload, ["operationRef", "clearQueued"])
46
53
  && REF.test(String(payload.operationRef)) && typeof payload.clearQueued === "boolean";
47
54
  if (action === "session.set-model") return exactKeys(payload, ["modelRef"]) && REF.test(String(payload.modelRef));
@@ -56,11 +63,13 @@ function validPayload(action: SessionAction, payload: Record<string, unknown>):
56
63
  && (payload.title === null || typeof payload.title === "string" && payload.title.length >= 1 && payload.title.length <= 500
57
64
  && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(payload.title));
58
65
  const createKeys = ["projectRef", "placeRef", "modelRef", "thinkingLevel", "message", "messageRequestId"];
59
- return (exactKeys(payload, createKeys) || exactKeys(payload, [...createKeys, "deferInitialMessage"]))
66
+ return requiredOptionalKeys(payload, createKeys, ["deferInitialMessage", "permissionMode", "workflow"])
60
67
  && REF.test(String(payload.projectRef)) && REF.test(String(payload.placeRef)) && nullableRef(payload.modelRef)
61
68
  && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(String(payload.thinkingLevel))
62
69
  && typeof payload.message === "string" && payload.message.length >= 1 && payload.message.length <= 32_768 && !payload.message.includes("\0")
63
- && REF.test(String(payload.messageRequestId)) && (payload.deferInitialMessage === undefined || typeof payload.deferInitialMessage === "boolean");
70
+ && REF.test(String(payload.messageRequestId)) && (payload.deferInitialMessage === undefined || typeof payload.deferInitialMessage === "boolean")
71
+ && (payload.permissionMode === undefined || ["read-only", "workspace-write", "trusted-full-access"].includes(String(payload.permissionMode)))
72
+ && (payload.workflow === undefined || isWebUiWorkflowId(payload.workflow));
64
73
  }
65
74
 
66
75
  function parseCommand(value: unknown): SessionCommand | null {
@@ -147,11 +156,17 @@ export class SessionCommandController {
147
156
  if (command.action === "session.create") {
148
157
  targetSessionRef = await this.#runtimes.create(String(command.payload.projectRef), String(command.payload.placeRef),
149
158
  command.payload.modelRef === null ? null : String(command.payload.modelRef), String(command.payload.thinkingLevel));
159
+ if (command.payload.permissionMode !== undefined) {
160
+ await this.#runtimes.setPermission(targetSessionRef, String(command.payload.permissionMode) as
161
+ "read-only" | "workspace-write" | "trusted-full-access");
162
+ }
150
163
  const created = await this.#readyCatalog(), createdRow = created.sessions.find((item) => item.sessionRef === targetSessionRef);
151
164
  if (!createdRow) throw new Error("session-catalog-refresh-failed");
152
165
  if (command.payload.deferInitialMessage === true) resultCode = "created";
153
166
  else {
154
- const sent = await this.#runtimes.send(targetSessionRef, { delivery: "new-operation", message: String(command.payload.message),
167
+ const message = buildWebUiWorkflowCommand((command.payload.workflow as WebUiWorkflowId | undefined) ?? null,
168
+ String(command.payload.message));
169
+ const sent = await this.#runtimes.send(targetSessionRef, { delivery: "new-operation", message,
155
170
  expectedOperationRef: null }, createdRow.sessionRevision);
156
171
  resultCode = sent.resultCode === "started" ? "started" : sent.resultCode; operationRef = sent.operationRef;
157
172
  }
@@ -162,7 +177,7 @@ export class SessionCommandController {
162
177
  const acquired = await this.#readyCatalog(), acquiredRow = acquired.sessions.find((item) => item.sessionRef === command.sessionRef);
163
178
  if (!acquiredRow) throw new Error("session-catalog-refresh-failed");
164
179
  const payload = command.payload as { delivery: "new-operation" | "follow-up" | "steer"; message: string;
165
- expectedOperationRef: string | null; messageRequestId: string; attachmentRefs: string[] };
180
+ expectedOperationRef: string | null; messageRequestId: string; attachmentRefs: string[]; workflow?: WebUiWorkflowId };
166
181
  // Staged refs are claimed once, here, before the runtime is asked to
167
182
  // dispatch. Documents were turned into text at staging so they join the
168
183
  // message; images travel on the host's own channel.
@@ -172,7 +187,8 @@ export class SessionCommandController {
172
187
  attachmentReservation = prepared;
173
188
  if (!this.#prepareAttachments && payload.attachmentRefs.length > 0) throw new Error("session-attachment-unavailable");
174
189
  sendAttempted = true;
175
- const sent = await this.#runtimes.send(command.sessionRef!, { ...payload, message: prepared.text, images: prepared.images },
190
+ const message = buildWebUiWorkflowCommand(payload.workflow ?? null, prepared.text);
191
+ const sent = await this.#runtimes.send(command.sessionRef!, { ...payload, message, images: prepared.images },
176
192
  acquiredRow.sessionRevision);
177
193
  prepared.commit();
178
194
  resultCode = sent.resultCode; operationRef = sent.operationRef;
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
2
3
  import path from "node:path";
3
4
 
4
5
  import { readContextTelemetry } from "../../piagent-core/extensions/context-engine.js";
@@ -9,6 +10,8 @@ import { webUiModelRef } from "../../piagent-core/runtime/inspection/webui-snaps
9
10
  import { redactSensitiveText } from "../../piagent-core/security/sensitive-data.js";
10
11
  import { piApprovalBroker } from "../../piagent-core/runtime/inspection/approval-broker.ts";
11
12
  import { attachmentCapability } from "../../piagent-core/runtime/input/attachment-store.ts";
13
+ import { WEBUI_WORKFLOW_OPTIONS } from "../../piagent-core/runtime/workflows/webui-workflow.ts";
14
+ import { WEBUI_RUNTIME_ACTIONS } from "../../piagent-core/runtime/workflows/webui-runtime-command.ts";
12
15
  import { CoreInspectionProvider } from "../server/core-inspection-provider.ts";
13
16
  import { ReadModelNotFound, type WebUiReadModelProvider } from "../server/read-model-provider.ts";
14
17
  import { isUserConversationSession, projectRefForCwd, sessionRefForPath, type PiSessionInfo } from "./session-catalog.ts";
@@ -189,6 +192,16 @@ export class SessionInspectionRegistry {
189
192
  if (!projects.has(project.projectRef)) projects.set(project.projectRef, project);
190
193
  }
191
194
  const availableModels = (this.#models?.getAvailableSnapshot() ?? []).slice(0, 300);
195
+ const profiles = safeRead(() => fs.readdirSync(path.join(this.#packageRoot, "adapters")).flatMap((entry) => {
196
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(entry)) return [];
197
+ const file = path.join(this.#packageRoot, "adapters", entry, "profile.json");
198
+ const stat = fs.lstatSync(file);
199
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1_048_576) return [];
200
+ const value = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
201
+ return [{ id: entry, displayName: safeName(value.displayName ?? entry),
202
+ permissionMode: ["read-only", "workspace-write", "trusted-full-access"].includes(String(value.permissionProfile))
203
+ ? String(value.permissionProfile) : null }];
204
+ }).slice(0, 100), []);
192
205
  const models = availableModels.flatMap((value) => {
193
206
  const provider = safeModelId(value.provider);
194
207
  const modelId = safeModelId(value.id);
@@ -201,8 +214,16 @@ export class SessionInspectionRegistry {
201
214
  return [{ modelRef: webUiModelRef(provider, modelId), provider, modelId, displayName: safeName(value.name ?? modelId),
202
215
  reasoning: value.reasoning, imageInput: inputs ? inputs.includes("image") : null, thinkingLevels }];
203
216
  });
217
+ // Keep the WebUI new-session baseline deterministic and visible. If the
218
+ // preferred model is not authenticated, the host's own default remains the
219
+ // safe fallback instead of silently substituting another catalog entry.
220
+ const defaultModel = models.find((value) => value.provider === "openai-codex" && value.modelId === "gpt-5.6-sol");
204
221
  return { schemaVersion: 1, version: "piagent-session-creation-options-v1", generatedAt: new Date().toISOString(),
205
- projects: [...projects.values()].slice(0, 200), models, webSearch: inspectWebSearchCapability({ agentDir: this.#agentDir, models: availableModels }),
222
+ projects: [...projects.values()].slice(0, 200), models,
223
+ defaultModelRef: defaultModel?.modelRef ?? null, defaultThinkingLevel: defaultModel ? "high" : null,
224
+ profiles, workflows: WEBUI_WORKFLOW_OPTIONS,
225
+ runtimeActions: WEBUI_RUNTIME_ACTIONS,
226
+ webSearch: inspectWebSearchCapability({ agentDir: this.#agentDir, models: availableModels }),
206
227
  projectImport: nativeProjectPickerAvailable() ? { status: "available", reasonCode: null }
207
228
  : { status: "unavailable", reasonCode: "native-project-picker-unavailable" },
208
229
  reasonCode: projects.size ? null : "no-known-project" };
@@ -0,0 +1,34 @@
1
+ import { GatewaySessionStream } from "./gateway-session-stream.ts";
2
+
3
+ const DEFERRED_OPERATION_START_GRACE_MS = 5_000;
4
+
5
+ export async function waitForOperationStart(
6
+ stream: GatewaySessionStream,
7
+ prompt: Promise<void>
8
+ ): Promise<"prompt-owned" | "deferred"> {
9
+ const started = stream.started();
10
+ const first = await Promise.race([
11
+ started.then(() => ({ state: "started" as const })),
12
+ prompt.then(() => ({ state: "prompt-resolved" as const }),
13
+ (error: unknown) => ({ state: "prompt-rejected" as const, error }))
14
+ ]);
15
+ if (first.state === "started") return "prompt-owned";
16
+ if (first.state === "prompt-rejected") throw first.error;
17
+
18
+ // Extension commands such as `/workflow` launch their agent turn through
19
+ // `pi.sendUserMessage()`. The outer prompt resolves before that deferred turn
20
+ // emits `agent_start`, so retain ownership until the real operation appears.
21
+ let timer: ReturnType<typeof setTimeout> | undefined;
22
+ try {
23
+ await Promise.race([
24
+ started,
25
+ new Promise<never>((_resolve, reject) => {
26
+ timer = setTimeout(() => reject(new Error("session-operation-start-unobserved")),
27
+ DEFERRED_OPERATION_START_GRACE_MS);
28
+ })
29
+ ]);
30
+ return "deferred";
31
+ } finally {
32
+ if (timer) clearTimeout(timer);
33
+ }
34
+ }
@@ -11,8 +11,10 @@ import { isUserConversationSession, projectRefForCwd, sessionRefForPath } from "
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
- import { GATEWAY_RUNTIME_UI_MARKER } from "../ownership/gateway-runtime-context.ts";
15
14
  import { preferAuthoritativePiagentGuard } from "./extension-authority.ts";
15
+ import { executePermissionCommand, executeRuntimeCommand } from "./runtime-session-controls.ts";
16
+ import { rpcUiContext } from "./rpc-ui-context.ts";
17
+ import { waitForOperationStart } from "./session-operation-start.ts";
16
18
 
17
19
  const MAX_WARM_RUNTIMES = 10;
18
20
 
@@ -22,33 +24,7 @@ type ActiveRuntime = { runtime: RuntimeHandle; lease: SessionLeaseSnapshot; info
22
24
  unsubscribe: (() => void) | null; completion: Promise<void> | null; settling: boolean; approvalWaiting: boolean;
23
25
  unbindApproval: (() => void) | null; unsubscribeApproval: (() => void) | null; sessionManager: any | null };
24
26
  type Projection = { sessionRevision: string; liveState: "offline" | "idle" | "running" | "paused" | "waiting-approval" | "uncertain" };
25
-
26
- function rpcUiContext(): object {
27
- const plain = (text: unknown): string => String(text ?? "");
28
- const theme = Object.freeze({
29
- fg: (_color: unknown, text: unknown) => plain(text),
30
- bg: (_color: unknown, text: unknown) => plain(text),
31
- bold: plain,
32
- italic: plain,
33
- underline: plain,
34
- inverse: plain,
35
- strikethrough: plain,
36
- getFgAnsi: () => "",
37
- getBgAnsi: () => "",
38
- getColorMode: () => "truecolor",
39
- getThinkingBorderColor: () => plain,
40
- getBashModeBorderColor: () => plain
41
- });
42
- return new Proxy({}, {
43
- get(_target, property) {
44
- if (property === GATEWAY_RUNTIME_UI_MARKER) return true;
45
- if (property === "theme") return theme;
46
- if (property === "confirm") return () => new Promise<boolean>(() => undefined);
47
- if (property === "select" || property === "input") return async () => undefined;
48
- return () => undefined;
49
- }
50
- });
51
- }
27
+ type RuntimeCommandResult = Awaited<ReturnType<typeof executeRuntimeCommand>>;
52
28
 
53
29
  function productionRuntimeFactory(options: { host: any; agentDir: string; packageRoot: string; modelRuntime?: any }): RuntimeFactory {
54
30
  return async (info, _runtimeInstanceRef, sessionManager) => {
@@ -303,12 +279,18 @@ export class SessionRuntimeSupervisor {
303
279
  let prompt: Promise<void>;
304
280
  try { prompt = session.prompt(payload.message, payload.images?.length ? { images: payload.images } : undefined); }
305
281
  catch (error) { await this.#finishOperation(sessionRef, operationRef, stream); throw error; }
306
- active.completion = prompt.then(() => this.#finishOperation(sessionRef, operationRef, stream),
282
+ const started = waitForOperationStart(stream, prompt);
283
+ // A normal prompt resolves after the agent settles. A workflow extension
284
+ // command resolves before its nested agent turn starts. In both cases the
285
+ // canonical completion boundary is `agent_settled`, not the outer promise.
286
+ const lifecycle = started.then(async (mode) => {
287
+ if (mode === "deferred") await stream.settled();
288
+ else await Promise.race([stream.settled(), prompt]);
289
+ });
290
+ active.completion = lifecycle.then(() => this.#finishOperation(sessionRef, operationRef, stream),
307
291
  () => this.#finishOperation(sessionRef, operationRef, stream));
308
- await Promise.race([
309
- stream.started(),
310
- prompt.then(() => { throw new Error("session-operation-start-unobserved"); }, (error: unknown) => { throw error; })
311
- ]);
292
+ try { await started; }
293
+ catch (error) { await active.completion; throw error; }
312
294
  return { resultCode: "started", operationRef };
313
295
  }
314
296
 
@@ -389,23 +371,23 @@ export class SessionRuntimeSupervisor {
389
371
  return "thinking-changed";
390
372
  }
391
373
 
392
- async setPermission(sessionRef: string, permissionMode: "read-only" | "workspace-write" | "trusted-full-access"):
393
- Promise<"permission-changed"> {
374
+ async setPermission(sessionRef: string, permissionMode: "read-only" | "workspace-write" | "trusted-full-access"): Promise<"permission-changed"> {
394
375
  await this.acquire(sessionRef);
395
376
  const active = this.#active.get(sessionRef), session = active?.runtime.session;
396
377
  if (!active || !session) throw new Error("session-runtime-unavailable");
397
378
  if (active.operationRef || !session.isIdle) throw new Error("session-runtime-busy");
398
- const before = Array.isArray(session.messages) ? session.messages.length : 0;
399
- await session.prompt(`/permission ${permissionMode}`);
400
- const messages = Array.isArray(session.messages) ? session.messages.slice(before) : [];
401
- const observed = messages.reverse().find((message: any) => message?.role === "custom"
402
- && message.customType === "piagent-permission-profile");
403
- const profile = observed?.details?.permissionProfile;
404
- if (!profile || profile.mode !== permissionMode || profile.warning) throw new Error("session-permission-unavailable");
379
+ await executePermissionCommand(session, permissionMode);
405
380
  this.#touchCreated(sessionRef);
406
381
  return "permission-changed";
407
382
  }
408
383
 
384
+ async runRuntimeCommand(sessionRef: string, command: string): Promise<RuntimeCommandResult> {
385
+ await this.acquire(sessionRef);
386
+ const active = this.#active.get(sessionRef), session = active?.runtime.session;
387
+ if (!active || !session) throw new Error("session-runtime-unavailable");
388
+ if (active.operationRef || active.settling || !session.isIdle) throw new Error("session-runtime-busy");
389
+ const result = await executeRuntimeCommand(session, command); this.#touchCreated(sessionRef); return result;
390
+ }
409
391
  approvalProjection(sessionRef: string): { revision: string | null; summary: Record<string, unknown> } {
410
392
  const active = this.#active.get(sessionRef);
411
393
  if (!active) return { revision: null, summary: { state: "unknown", pending: [], recent: [],
@@ -8,7 +8,8 @@
8
8
  "check:contracts": "node scripts/generate-contracts.mjs --check",
9
9
  "typecheck": "tsc -p tsconfig.json --noEmit",
10
10
  "build": "npm run check:contracts && vite build",
11
- "benchmark": "node benchmark/benchmark.mjs"
11
+ "benchmark": "node benchmark/benchmark.mjs",
12
+ "benchmark:parity": "node benchmark/parity-benchmark.mjs"
12
13
  },
13
14
  "dependencies": {
14
15
  "@emotion/cache": "^11.14.0",
@@ -61,6 +61,7 @@ export async function startLoopbackServer(options: {
61
61
  executeSessionAttachment?: (sessionRef: string, command: unknown) => unknown | Promise<unknown>;
62
62
  readSessionConnections?: (sessionRef: string) => unknown | Promise<unknown>;
63
63
  executeSessionConnection?: (command: unknown) => unknown | Promise<unknown>;
64
+ executeRuntimeCommand?: (command: unknown) => unknown | Promise<unknown>;
64
65
  readMcpAuthJob?: (jobRef: string) => unknown | Promise<unknown>;
65
66
  cancelMcpAuthJob?: (jobRef: string) => unknown | Promise<unknown>;
66
67
  readProviderAuthCatalog?: () => unknown | Promise<unknown>;
@@ -205,6 +206,22 @@ export async function startLoopbackServer(options: {
205
206
  }
206
207
  }
207
208
 
209
+ if (request.method === "POST" && url.pathname === "/api/v1/runtime-commands" && options.executeRuntimeCommand) {
210
+ if (requestOrigin !== origin) return errorResponse(response, 403, "origin-required");
211
+ const mutationSession = auth.authorizeMutation(request);
212
+ if (!mutationSession) return errorResponse(response, 403, "mutation-authority-rejected");
213
+ if (!consumeControl(mutationSession.id, now)) return errorResponse(response, 429, "control-rate-limit");
214
+ if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return errorResponse(response, 415, "content-type");
215
+ let command: unknown;
216
+ try { command = JSON.parse((await requestBody(request, MAX_CONTROL_BODY_BYTES)).toString("utf8")); }
217
+ catch (error) { return errorResponse(response, (error as Error).message === "body-limit" ? 413 : 400, "invalid-runtime-command"); }
218
+ try { return jsonResponse(response, 200, await options.executeRuntimeCommand(command)); }
219
+ catch (error) {
220
+ const code = error instanceof Error ? error.message : "runtime-command-unavailable";
221
+ return errorResponse(response, code.includes("invalid") ? 400 : 503, code);
222
+ }
223
+ }
224
+
208
225
  if (request.method === "POST" && url.pathname.startsWith("/api/v1/mcp-auth/jobs/") && url.pathname.endsWith("/cancel") && options.cancelMcpAuthJob) {
209
226
  if (requestOrigin !== origin) return errorResponse(response, 403, "mutation-authority-rejected");
210
227
  const mutationSession = auth.authorizeMutation(request);
@@ -227,6 +227,15 @@
227
227
  "supported": true,
228
228
  "current": true
229
229
  },
230
+ {
231
+ "name": "runtime-command-v1",
232
+ "file": "runtime-command-v1.schema.json",
233
+ "id": "https://piagent.io.vn/schemas/piagent-webui/runtime-command-v1.schema.json",
234
+ "documentVersion": 1,
235
+ "role": "command",
236
+ "supported": true,
237
+ "current": true
238
+ },
230
239
  {
231
240
  "name": "gateway-capabilities-v1",
232
241
  "file": "gateway-capabilities-v1.schema.json",