@piagent/platform 1.7.1 → 1.9.0

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 (227) hide show
  1. package/AGENTS.md +7 -0
  2. package/CHANGELOG.md +113 -1
  3. package/README.md +45 -70
  4. package/README.vi.md +34 -61
  5. package/architecture/layers.json +16 -3
  6. package/catalog/capabilities.json +14 -7
  7. package/catalog/claude-model-additions.json +42 -0
  8. package/docs/auto-delegation-policy.md +2 -0
  9. package/docs/command-reference-vietnamese.md +69 -91
  10. package/docs/context-window-policy.md +5 -5
  11. package/docs/distribution-standard.md +16 -19
  12. package/docs/en/architecture.md +9 -1
  13. package/docs/herdr-workflow.md +2 -3
  14. package/docs/languages.json +4 -4
  15. package/docs/managed-local-acceptance.md +403 -0
  16. package/docs/mcp-and-tools.md +5 -7
  17. package/docs/memory-policy.md +3 -3
  18. package/docs/model-options.md +22 -17
  19. package/docs/oauth-providers.md +1 -1
  20. package/docs/operator-manual-vietnamese.md +85 -130
  21. package/docs/package-architecture-notes.md +4 -4
  22. package/docs/piagent-core-improvement-plan.md +3 -5
  23. package/docs/piagent-core-plan-checkpoint-2026-09-06.json +2 -13
  24. package/docs/piagent-runtime-benchmark-execution-plan.md +10 -8
  25. package/docs/project-adapters.md +3 -3
  26. package/docs/project-onboarding.md +15 -16
  27. package/docs/publishing-for-teams.md +3 -1
  28. package/docs/quality-benchmark.md +2 -0
  29. package/docs/quickstart-vietnamese.md +24 -25
  30. package/docs/release-install-policy.md +16 -11
  31. package/docs/research/harness-next-evidence-review.md +7 -7
  32. package/docs/runtime-harness-standard.md +2 -0
  33. package/docs/runtime-policy-design.md +2 -0
  34. package/docs/runtime-quality-baseline.md +2 -0
  35. package/docs/subagent-orchestration-capabilities.md +1 -1
  36. package/docs/subagents-and-multiagent.md +10 -8
  37. package/docs/task-implementation-contract.md +2 -0
  38. package/docs/task-lifecycle-vietnamese.md +2 -0
  39. package/docs/team-onboarding.md +33 -64
  40. package/docs/usage-observability.md +2 -2
  41. package/docs/vi/architecture.md +9 -1
  42. package/docs/workflow-recipes.md +6 -10
  43. package/docs-site/architecture.html +4 -3
  44. package/docs-site/assets/docs.css +80 -0
  45. package/docs-site/benchmark.html +7 -1
  46. package/docs-site/commands.html +37 -23
  47. package/docs-site/content/architecture.html +2 -2
  48. package/docs-site/content/benchmark.html +5 -0
  49. package/docs-site/content/commands.html +35 -22
  50. package/docs-site/content/ecosystem.html +144 -0
  51. package/docs-site/content/en/architecture.html +1 -1
  52. package/docs-site/content/en/benchmark.html +5 -0
  53. package/docs-site/content/en/commands.html +30 -13
  54. package/docs-site/content/en/ecosystem.html +144 -0
  55. package/docs-site/content/en/index.html +24 -9
  56. package/docs-site/content/en/quickstart.html +10 -12
  57. package/docs-site/content/en/runtime.html +1 -1
  58. package/docs-site/content/en/security.html +1 -1
  59. package/docs-site/content/en/team.html +2 -2
  60. package/docs-site/content/en/whats-new.html +89 -1
  61. package/docs-site/content/en/workflows.html +29 -31
  62. package/docs-site/content/index.html +24 -9
  63. package/docs-site/content/quickstart.html +10 -12
  64. package/docs-site/content/runtime.html +2 -2
  65. package/docs-site/content/security.html +2 -2
  66. package/docs-site/content/team.html +2 -2
  67. package/docs-site/content/whats-new.html +89 -1
  68. package/docs-site/content/workflows.html +36 -38
  69. package/docs-site/context-engine.html +2 -1
  70. package/docs-site/ecosystem.html +283 -0
  71. package/docs-site/en/architecture.html +3 -2
  72. package/docs-site/en/benchmark.html +7 -1
  73. package/docs-site/en/commands.html +32 -14
  74. package/docs-site/en/context-engine.html +2 -1
  75. package/docs-site/en/ecosystem.html +283 -0
  76. package/docs-site/en/index.html +30 -14
  77. package/docs-site/en/mcp-approval.html +2 -1
  78. package/docs-site/en/mcp-auth.html +2 -1
  79. package/docs-site/en/mcp-servers.html +2 -1
  80. package/docs-site/en/mcp.html +2 -1
  81. package/docs-site/en/permissions.html +2 -1
  82. package/docs-site/en/profiles.html +2 -1
  83. package/docs-site/en/quickstart.html +17 -18
  84. package/docs-site/en/runtime.html +3 -2
  85. package/docs-site/en/scope.html +2 -1
  86. package/docs-site/en/security.html +6 -5
  87. package/docs-site/en/team.html +4 -3
  88. package/docs-site/en/whats-new.html +97 -8
  89. package/docs-site/en/workflows.html +34 -35
  90. package/docs-site/index.html +30 -14
  91. package/docs-site/mcp-approval.html +2 -1
  92. package/docs-site/mcp-auth.html +2 -1
  93. package/docs-site/mcp-servers.html +2 -1
  94. package/docs-site/mcp.html +2 -1
  95. package/docs-site/permissions.html +2 -1
  96. package/docs-site/profiles.html +2 -1
  97. package/docs-site/quickstart.html +17 -18
  98. package/docs-site/runtime.html +4 -3
  99. package/docs-site/scope.html +2 -1
  100. package/docs-site/security.html +7 -6
  101. package/docs-site/team.html +4 -3
  102. package/docs-site/whats-new.html +97 -8
  103. package/docs-site/workflows.html +41 -42
  104. package/evals/fs5-causal-arm.v1.json +2 -2
  105. package/evals/private-holdout-v1/CURRENT_BOUNDARY.md +17 -0
  106. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  107. package/package.json +10 -9
  108. package/packages/piagent-core/README.md +15 -23
  109. package/packages/piagent-core/docs/context-efficiency-improvement-plan.md +2 -0
  110. package/packages/piagent-core/extensions/experiment-loop-policy.ts +75 -0
  111. package/packages/piagent-core/extensions/local-state-path.js +15 -0
  112. package/packages/piagent-core/extensions/piagent-guard.ts +108 -960
  113. package/packages/piagent-core/extensions/policy-core.js +2 -2
  114. package/packages/piagent-core/extensions/source-checkout-paths.ts +54 -0
  115. package/packages/piagent-core/managed/broker-client.mjs +49 -0
  116. package/packages/piagent-core/managed/capabilities.mjs +28 -0
  117. package/packages/piagent-core/managed/compaction.mjs +63 -0
  118. package/packages/piagent-core/managed/doctor.mjs +107 -0
  119. package/packages/piagent-core/managed/native-catalog.mjs +42 -0
  120. package/packages/piagent-core/managed/patch-snapshot.mjs +48 -0
  121. package/packages/piagent-core/managed/repository-operation.mjs +84 -0
  122. package/packages/piagent-core/managed/request-stream.mjs +129 -0
  123. package/packages/piagent-core/managed/resource-loader.mjs +42 -0
  124. package/packages/piagent-core/managed/review-snapshot-worker.mjs +27 -0
  125. package/packages/piagent-core/managed/sandbox-diagnostic.mjs +12 -0
  126. package/packages/piagent-core/managed/search-fallback.mjs +93 -0
  127. package/packages/piagent-core/managed/session.mjs +472 -0
  128. package/packages/piagent-core/managed/store.d.mts +3 -0
  129. package/packages/piagent-core/managed/store.mjs +64 -0
  130. package/packages/piagent-core/managed/tool-boundary.mjs +243 -0
  131. package/packages/piagent-core/managed/tool-worker.mjs +36 -0
  132. package/packages/piagent-core/managed/toolchain.mjs +120 -0
  133. package/packages/piagent-core/managed/web-fetch.mjs +105 -0
  134. package/packages/piagent-core/managed/web-search.mjs +96 -0
  135. package/packages/piagent-core/managed/workflow.mjs +299 -0
  136. package/packages/piagent-core/package.json +3 -6
  137. package/packages/piagent-core/policies/base-policy.json +80 -26
  138. package/packages/piagent-core/runtime/hooks/agent-start-hook.ts +3 -3
  139. package/packages/piagent-core/runtime/hooks/input-hook.ts +35 -196
  140. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +2 -0
  141. package/packages/piagent-core/runtime/inspection/approval-broker.ts +9 -1
  142. package/packages/piagent-core/runtime/inspection/same-process-bridge-proof.ts +1 -1
  143. package/packages/piagent-core/runtime/inspection/webui-snapshot.ts +1 -1
  144. package/packages/piagent-core/runtime/managed-failure.d.mts +10 -0
  145. package/packages/piagent-core/runtime/managed-failure.mjs +164 -0
  146. package/packages/piagent-core/runtime/model/model-route-policy.ts +33 -13
  147. package/packages/piagent-core/runtime/model/model-route-types.ts +10 -6
  148. package/packages/piagent-core/runtime/orchestration/minimal-delegation-policy.ts +8 -29
  149. package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +1 -1
  150. package/packages/piagent-core/runtime/policy/block-remedy.ts +5 -3
  151. package/packages/piagent-core/runtime/registration/context-commands.ts +1 -1
  152. package/packages/piagent-core/runtime/registration/knowledge-tools.ts +7 -7
  153. package/packages/piagent-core/runtime/registration/policy-tools.ts +2 -2
  154. package/packages/piagent-core/runtime/registration/session-commands.ts +11 -26
  155. package/packages/piagent-core/runtime/registration/task-evidence-tools.ts +1 -1
  156. package/packages/piagent-core/runtime/session/system-prompt.ts +6 -7
  157. package/packages/piagent-core/runtime/workflows/webui-runtime-command.ts +6 -6
  158. package/packages/piagent-core/runtime/workflows/webui-workflow.ts +6 -11
  159. package/packages/piagent-core/security/sensitive-text.js +9 -0
  160. package/packages/piagent-core/skills/piagent-ops/SKILL.md +8 -10
  161. package/packages/piagent-core/skills/test-audit/SKILL.md +101 -0
  162. package/packages/piagent-core/subagents/piagent-planner.md +1 -1
  163. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-DGk34zJT.js → MarkdownMessage-Dd9wPk-v.js} +1 -1
  164. package/packages/piagent-webui/dist/client/assets/index-7I-50Q2U.js +228 -0
  165. package/packages/piagent-webui/dist/client/assets/{index-DVahUVWy.css → index-DgSmcnP8.css} +1 -1
  166. package/packages/piagent-webui/dist/client/index.html +2 -2
  167. package/packages/piagent-webui/extension/piagent-webui.ts +11 -11
  168. package/packages/piagent-webui/extension/session-stream-adapter.ts +3 -1
  169. package/packages/piagent-webui/extension/terminal-session-adapter.ts +6 -1
  170. package/packages/piagent-webui/gateway/company-relay.ts +308 -0
  171. package/packages/piagent-webui/gateway/control-socket.ts +39 -12
  172. package/packages/piagent-webui/gateway/gateway-service.ts +109 -28
  173. package/packages/piagent-webui/gateway/gateway-session-stream.ts +58 -2
  174. package/packages/piagent-webui/gateway/managed-gateway.mjs +41 -0
  175. package/packages/piagent-webui/gateway/managed-launch.ts +145 -0
  176. package/packages/piagent-webui/gateway/managed-projection.ts +20 -0
  177. package/packages/piagent-webui/gateway/pi-host.ts +22 -4
  178. package/packages/piagent-webui/gateway/runtime-session-controls.ts +5 -2
  179. package/packages/piagent-webui/gateway/session-approval-authority.ts +28 -0
  180. package/packages/piagent-webui/gateway/session-catalog.ts +11 -3
  181. package/packages/piagent-webui/gateway/session-command-controller.ts +8 -2
  182. package/packages/piagent-webui/gateway/session-file-facts.ts +25 -0
  183. package/packages/piagent-webui/gateway/session-inspection-registry.ts +42 -12
  184. package/packages/piagent-webui/gateway/session-list-cache.ts +68 -0
  185. package/packages/piagent-webui/gateway/session-runtime-ownership.ts +8 -1
  186. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +20 -27
  187. package/packages/piagent-webui/ownership/managed-terminal-lease.ts +27 -0
  188. package/packages/piagent-webui/ownership/profile-state.ts +2 -0
  189. package/packages/piagent-webui/ownership/session-lease-store.ts +22 -1
  190. package/packages/piagent-webui/server/approval-projection.ts +9 -0
  191. package/packages/piagent-webui/server/core-inspection-provider.ts +4 -9
  192. package/packages/piagent-webui/server/loopback-server.ts +37 -1
  193. package/packages/piagent-webui/server/message-stop-reason.ts +11 -0
  194. package/packages/piagent-webui/server/tool-call-summary.ts +185 -0
  195. package/packages/piagent-webui/server/transcript-projection.ts +86 -3
  196. package/packs/engineering-base/pack.json +4 -0
  197. package/schemas/piagent-webui/runtime-command-v1.schema.json +1 -2
  198. package/schemas/piagent-webui/session-catalog-v1.schema.json +8 -0
  199. package/schemas/piagent-webui/transcript-v1.schema.json +94 -1
  200. package/scripts/build-docs-site.mjs +23 -12
  201. package/scripts/check-public-wording.mjs +5 -6
  202. package/scripts/check-runtime-advisories.mjs +23 -6
  203. package/scripts/claude-model-additions.mjs +33 -0
  204. package/scripts/configure-model-scope.sh +36 -8
  205. package/scripts/configure-subagents.sh +1 -1
  206. package/scripts/explain-command.mjs +5 -6
  207. package/scripts/install-global.sh +2 -2
  208. package/scripts/model-route-evaluation-core.ts +1 -0
  209. package/scripts/model-scope-prune.mjs +75 -0
  210. package/scripts/phase-tools-evaluation.mjs +9 -4
  211. package/scripts/pi-model-route.mjs +1 -1
  212. package/scripts/piagent-cli.mjs +51 -2
  213. package/scripts/piagent-dashboard.mjs +27 -4
  214. package/scripts/piagent-installations.mjs +26 -0
  215. package/scripts/piagent-studio.mjs +155 -0
  216. package/scripts/runtime-advisory-exceptions.json +29 -0
  217. package/scripts/setup.sh +2 -2
  218. package/scripts/team-doctor.sh +20 -1
  219. package/scripts/typescript-loader.mjs +6 -0
  220. package/scripts/verify-local.sh +5 -5
  221. package/scripts/verify-release-identity.mjs +1 -1
  222. package/templates/global/settings.json +8 -2
  223. package/templates/project/.pi/project-context.md +3 -3
  224. package/templates/project/AGENTS.md +4 -5
  225. package/docs/piagent-core-reasoning-research-2026-09-06.md +0 -225
  226. package/docs/piagent-core-research-sources-2026-09-06.json +0 -416
  227. package/packages/piagent-webui/dist/client/assets/index-DiKRmCEP.js +0 -176
@@ -0,0 +1,308 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { WebSocket } from "ws";
3
+
4
+ import type { Catalog, SessionRow } from "../contracts/generated/session-catalog-v1.ts";
5
+ import { webUiModelRef } from "../../piagent-core/runtime/inspection/webui-snapshot.ts";
6
+ import { requestGatewayControl } from "./control-socket.ts";
7
+ import type { GatewayEventKind, GatewayEventStore } from "./gateway-events.ts";
8
+
9
+ // The personal dashboard lists and drives company sessions through this relay.
10
+ // They run in the company Gateway, a separate process holding the Studio
11
+ // grants and never loading personal extensions. The relay authenticates to it
12
+ // like a browser (one-time launch capability, cookie, CSRF), re-publishes its
13
+ // events in this Gateway's single ordered stream, and forwards the requests of
14
+ // its sessions. Company content passes through this process; grants do not.
15
+ export const COMPANY_MODEL_REF = webUiModelRef("agent_watch_managed", "agent-watch-auto");
16
+ export type CompanyConnector = {
17
+ configured(): boolean;
18
+ // Starts the company Gateway if needed (may ask for Keychain access).
19
+ ensure(): Promise<string>;
20
+ // The control socket of an already running company Gateway, or null.
21
+ attach(): Promise<string | null>;
22
+ };
23
+ export type CompanyStatus = { schemaVersion: 1; version: "piagent-company-status-v1"; model: "agent-watch-auto";
24
+ available: boolean; state: "unconfigured" | "connecting" | "ready" | "unavailable"; reasonCode: string | null };
25
+ type Link = { origin: string; cookie: string; csrf: string; socket: string };
26
+ type Pending = { resolve(value: unknown): void; reject(error: Error): void; timer: NodeJS.Timeout };
27
+
28
+ const KINDS = new Set<GatewayEventKind>(["catalog.changed", "session.changed", "runtime.changed", "message.delta",
29
+ "message.completed", "operation.settled", "tool.started", "tool.completed"]);
30
+ const LOOPBACK = /^http:\/\/127\.0\.0\.1:\d{1,5}$/;
31
+ const CATALOG_REUSE_MS = 5_000;
32
+ const failure = (error: unknown, fallback: string) => error instanceof Error && /^[a-z][a-z0-9:._-]{2,95}$/.test(error.message) ? error.message : fallback;
33
+
34
+ export class CompanyRelay {
35
+ readonly #connector: CompanyConnector;
36
+ readonly #events: GatewayEventStore;
37
+ readonly #hubProject: (cwd: string) => string;
38
+ readonly #folder: (projectRef: string) => Promise<string | null>;
39
+ #link: Link | null = null;
40
+ #ws: WebSocket | null = null;
41
+ #pending = new Map<string, Pending>();
42
+ #catalog: Catalog | null = null;
43
+ #models: unknown[] | null = null;
44
+ #paths = new Map<string, string>();
45
+ #owned = new Set<string>();
46
+ #fresh = false;
47
+ #fetchedAt = 0;
48
+ #state: CompanyStatus["state"] = "connecting";
49
+ #reason: string | null = null;
50
+ #connecting: Promise<void> | null = null;
51
+ #launching = false;
52
+ #retry: NodeJS.Timeout | null = null;
53
+ #closed = false;
54
+ #lastAttach = 0;
55
+
56
+ constructor(options: { connector: CompanyConnector; events: GatewayEventStore; hubProject(cwd: string): string;
57
+ folder(projectRef: string): Promise<string | null> }) {
58
+ this.#connector = options.connector; this.#events = options.events;
59
+ this.#hubProject = options.hubProject; this.#folder = options.folder;
60
+ }
61
+
62
+ status(): CompanyStatus {
63
+ const available = this.#connector.configured();
64
+ return { schemaVersion: 1, version: "piagent-company-status-v1", model: "agent-watch-auto", available,
65
+ state: available ? this.#state : "unconfigured", reasonCode: available ? this.#reason : null };
66
+ }
67
+
68
+ // At dashboard start the company runtime is attached or started, so its
69
+ // history is listed at once. Starting may ask for Keychain access once
70
+ // after an Agent Watch update; a refusal waits for an explicit reconnect.
71
+ start(): void { if (this.#connector.configured()) void this.connect(true); else this.#state = "unconfigured"; }
72
+
73
+ connect(launch = true): Promise<void> {
74
+ // An attach in flight is followed by one launch when launching was asked
75
+ // for; a launch in flight (maybe waiting on Keychain) is never repeated.
76
+ if (this.#connecting && launch && !this.#launching) {
77
+ return this.#connecting.then(() => this.#state === "ready" ? undefined : this.connect(true));
78
+ }
79
+ if (!this.#connecting) this.#launching = launch;
80
+ this.#connecting ??= (async () => {
81
+ if (this.#closed) return;
82
+ if (this.#retry) { clearTimeout(this.#retry); this.#retry = null; }
83
+ // An attach that finds nothing running keeps the more useful reason of
84
+ // the last start attempt (e.g. Keychain refused).
85
+ const previous = this.#reason;
86
+ this.#state = "connecting"; this.#reason = null;
87
+ try {
88
+ const socket = launch ? await this.#connector.ensure() : await this.#connector.attach();
89
+ if (!socket) throw new Error("company-gateway-stopped");
90
+ this.#link = await this.#bootstrap(socket);
91
+ await this.#open(this.#link);
92
+ this.#state = "ready"; this.#fresh = false;
93
+ } catch (error) {
94
+ const reason = failure(error, "company-connect-failed");
95
+ // Agent Watch re-imports after an update on its own, so a changed
96
+ // binding is not kept once a later attach finds nothing running.
97
+ this.#state = "unavailable";
98
+ this.#reason = !launch && reason === "company-gateway-stopped" && previous && previous !== "managed-launch-binding-changed" ? previous : reason;
99
+ // Keep re-attaching (never launching: no Keychain prompt) so a company
100
+ // runtime started later, e.g. from Agent Watch, shows up by itself.
101
+ if (!this.#closed) { this.#retry = setTimeout(() => void this.connect(false), 30_000); this.#retry.unref(); }
102
+ }
103
+ this.#events.publish("catalog.changed", { reasonCode: this.#state === "ready" ? "company-connected" : "company-unavailable" });
104
+ })().finally(() => { this.#connecting = null; });
105
+ return this.#connecting;
106
+ }
107
+
108
+ close(): void {
109
+ this.#closed = true;
110
+ if (this.#retry) clearTimeout(this.#retry);
111
+ this.#ws?.terminate();
112
+ }
113
+
114
+ owns(sessionRef: unknown): boolean {
115
+ return typeof sessionRef === "string" && (this.#owned.has(sessionRef) || !!this.#catalog?.sessions.some((row) => row.sessionRef === sessionRef));
116
+ }
117
+
118
+ handles(command: unknown): boolean {
119
+ const value = command as { action?: unknown; sessionRef?: unknown; payload?: { modelRef?: unknown } } | null;
120
+ return !!value && (value.action === "session.create" ? value.payload?.modelRef === COMPANY_MODEL_REF : this.owns(value.sessionRef));
121
+ }
122
+
123
+ async #bootstrap(socket: string): Promise<Link> {
124
+ const answer = await requestGatewayControl(socket, { action: "issue-launch-url" });
125
+ const launch = answer.ok ? (answer.value as { launchUrl?: unknown } | null)?.launchUrl : null;
126
+ if (typeof launch !== "string") throw new Error("company-gateway-starting");
127
+ const url = new URL(launch), capability = new URLSearchParams(url.hash.slice(1)).get("bootstrap");
128
+ if (!LOOPBACK.test(url.origin) || !capability) throw new Error("company-gateway-invalid");
129
+ const response = await fetch(`${url.origin}/api/v1/bootstrap`, { method: "POST", redirect: "error",
130
+ headers: { Origin: url.origin, "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ capability }) });
131
+ const cookie = response.headers.get("set-cookie")?.split(";", 1)[0], body = await response.json().catch(() => null) as { csrfToken?: unknown } | null;
132
+ if (!response.ok || !cookie || typeof body?.csrfToken !== "string") throw new Error("company-gateway-auth-failed");
133
+ return { origin: url.origin, cookie, csrf: body.csrfToken, socket };
134
+ }
135
+
136
+ #open(link: Link): Promise<void> {
137
+ return new Promise((resolve, reject) => {
138
+ const ws = new WebSocket(`${link.origin.replace("http:", "ws:")}/api/v1/gateway`, "piagent.gateway.v1",
139
+ { headers: { Origin: link.origin, Cookie: link.cookie }, maxPayload: 70_000, perMessageDeflate: false });
140
+ let hello = false;
141
+ const timer = setTimeout(() => { ws.terminate(); reject(new Error("company-gateway-timeout")); }, 5_000);
142
+ ws.on("open", () => ws.send(JSON.stringify({ schemaVersion: 1, version: "piagent-gateway-protocol-v1", messageType: "connect",
143
+ clientRef: "dashboard-company-relay", minimumProtocol: 1, maximumProtocol: 1, lastEventSequence: null, catalogRevision: null })));
144
+ ws.on("message", (data) => {
145
+ let frame: Record<string, any>;
146
+ try { frame = JSON.parse(String(data)); } catch { ws.close(1007, "invalid-json"); return; }
147
+ if (frame.messageType === "hello") { hello = true; clearTimeout(timer); this.#ws = ws; resolve(); return; }
148
+ if (frame.messageType === "response") {
149
+ const pending = this.#pending.get(String(frame.requestId));
150
+ if (!pending) return;
151
+ this.#pending.delete(String(frame.requestId)); clearTimeout(pending.timer);
152
+ if (frame.ok === true) pending.resolve(frame.result); else pending.reject(new Error(String(frame.error?.code ?? "company-request-failed")));
153
+ return;
154
+ }
155
+ if (frame.messageType !== "event") return;
156
+ this.#fresh = false; this.#models = null;
157
+ const payload = frame.payload && typeof frame.payload === "object" ? frame.payload as Record<string, unknown> : {};
158
+ if (typeof payload.sessionRef === "string") this.#owned.add(payload.sessionRef);
159
+ if (KINDS.has(frame.kind)) this.#events.publish(frame.kind, payload);
160
+ else this.#events.publish("catalog.changed", { reasonCode: "company-resync" });
161
+ });
162
+ ws.on("error", () => { /* close follows */ });
163
+ ws.on("close", () => {
164
+ clearTimeout(timer);
165
+ if (!hello) { reject(new Error("company-gateway-rejected")); return; }
166
+ if (this.#ws !== ws) return;
167
+ this.#ws = null; this.#link = null;
168
+ for (const [, pending] of this.#pending) { clearTimeout(pending.timer); pending.reject(new Error("company-gateway-disconnected")); }
169
+ this.#pending.clear();
170
+ this.#state = "unavailable"; this.#reason = "company-gateway-disconnected";
171
+ this.#events.publish("catalog.changed", { reasonCode: "company-unavailable" });
172
+ // A restarted company Gateway is re-attached; nothing is launched here.
173
+ if (!this.#closed) this.#retry = setTimeout(() => void this.connect(false), 3_000);
174
+ });
175
+ });
176
+ }
177
+
178
+ // Reads only attach; starting the company runtime (and possibly asking for
179
+ // Keychain access) is left to commands and the explicit connect route.
180
+ async #ready(launch = false): Promise<Link> {
181
+ if (this.#state !== "ready" || !this.#link || !this.#ws) await this.connect(launch);
182
+ if (!this.#link || !this.#ws) throw new Error(this.#reason ?? "company-unavailable");
183
+ return this.#link;
184
+ }
185
+
186
+ // One authenticated request to the company Gateway; a lapsed browser
187
+ // session is renewed once through the control socket.
188
+ async http(method: "GET" | "POST", path: string, body?: unknown): Promise<{ status: number; value: unknown }> {
189
+ for (let attempt = 0; ; attempt += 1) {
190
+ const link = await this.#ready();
191
+ const response = await fetch(`${link.origin}${path}`, { method, redirect: "error",
192
+ headers: { Accept: "application/json", Origin: link.origin, Cookie: link.cookie,
193
+ ...(method === "POST" ? { "Content-Type": "application/json", "X-Piagent-CSRF": link.csrf } : {}) },
194
+ body: method === "POST" ? JSON.stringify(body) : undefined });
195
+ if (response.status === 401 && attempt === 0) { this.#link = await this.#bootstrap(link.socket); continue; }
196
+ // The browser's own session is not the one refused here.
197
+ if (response.status === 401 || response.status === 403) return { status: 503, value: { error: { code: "company-auth-failed" } } };
198
+ return { status: response.status, value: await response.json().catch(() => null) };
199
+ }
200
+ }
201
+
202
+ async json(method: "GET" | "POST", path: string, body?: unknown): Promise<any> {
203
+ const result = await this.http(method, path, body);
204
+ if (result.status >= 200 && result.status < 300) return result.value;
205
+ throw new Error(String((result.value as { error?: { code?: unknown } } | null)?.error?.code ?? "company-request-failed"));
206
+ }
207
+
208
+ #request(method: "sessions.command", params: Record<string, unknown>): Promise<unknown> {
209
+ return this.#ready().then(() => new Promise((resolve, reject) => {
210
+ const requestId = randomBytes(24).toString("base64url");
211
+ const timer = setTimeout(() => { this.#pending.delete(requestId); reject(new Error("company-request-timeout")); }, 120_000);
212
+ this.#pending.set(requestId, { resolve, reject, timer });
213
+ this.#ws!.send(JSON.stringify({ schemaVersion: 1, version: "piagent-gateway-protocol-v1", messageType: "request", requestId, method, params }));
214
+ }));
215
+ }
216
+
217
+ async #control(request: { action: "project.paths" } | { action: "project.register"; cwd: string }): Promise<unknown> {
218
+ const link = await this.#ready();
219
+ const answer = await requestGatewayControl(link.socket, request);
220
+ if (!answer.ok) throw new Error((answer as { error: string }).error);
221
+ return answer.value;
222
+ }
223
+
224
+ // Company rows under this Gateway's project refs, cached until an event or
225
+ // for a few seconds: work run outside the company Gateway (its Terminal,
226
+ // scripts) raises no event there, so the rows are re-read soon.
227
+ // Reads never start the company runtime but re-attach to a running one,
228
+ // at most every 10 s, so an earlier failed start does not hide company
229
+ // sessions until the member clicks connect.
230
+ #attachSoon(): void {
231
+ if (this.#closed || this.#connecting || this.#state === "ready" || !this.#connector.configured()) return;
232
+ const now = Date.now();
233
+ if (now - this.#lastAttach < 10_000) return;
234
+ this.#lastAttach = now; void this.connect(false);
235
+ }
236
+
237
+ async catalog(): Promise<Catalog | null> {
238
+ if (this.#state !== "ready") { this.#attachSoon(); return this.#catalog; }
239
+ if (this.#fresh && this.#catalog && Date.now() - this.#fetchedAt < CATALOG_REUSE_MS) return this.#catalog;
240
+ // Folders only place rows under the hub's projects: failing to read them
241
+ // keeps the last known folders, never the last known rows.
242
+ const [catalog, paths] = await Promise.allSettled([this.json("GET", "/api/v1/session-catalog") as Promise<Catalog>,
243
+ this.#control({ action: "project.paths" }) as Promise<Array<{ projectRef: string; cwd: string }>>]);
244
+ if (paths.status === "fulfilled") this.#paths = new Map(paths.value.map((item) => [item.projectRef, item.cwd]));
245
+ if (catalog.status === "fulfilled") { this.#catalog = catalog.value; this.#fresh = true; this.#fetchedAt = Date.now(); }
246
+ return this.#catalog;
247
+ }
248
+
249
+ async merge(personal: Catalog): Promise<Catalog> {
250
+ const company = await this.catalog();
251
+ if (personal.state !== "ready" || !personal.catalogRevision) return personal;
252
+ const ready = this.#state === "ready";
253
+ const rows: SessionRow[] = (company?.state === "ready" ? company.sessions : []).map((row) => {
254
+ const folder = this.#paths.get(row.projectRef);
255
+ return { ...row, projectRef: folder ? this.#hubProject(folder) : row.projectRef, composerAvailable: ready && row.composerAvailable };
256
+ });
257
+ const sessions = [...personal.sessions, ...rows].sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
258
+ const digest = createHash("sha256").update(JSON.stringify([personal.catalogRevision, company?.catalogRevision ?? null, ready])).digest("hex");
259
+ return { ...personal, catalogRevision: `rev_${digest}`, sessions,
260
+ page: { ...personal.page, returned: sessions.length, total: personal.page.total + rows.length } };
261
+ }
262
+
263
+ // Folders of company conversations, so the dashboard can offer them as
264
+ // projects for new chats (company or personal).
265
+ async folders(): Promise<string[]> {
266
+ if (this.#state !== "ready") { this.#attachSoon(); return []; }
267
+ // Registering a project (Agent Watch's WebUI button) emits no event.
268
+ try {
269
+ const paths = await this.#control({ action: "project.paths" }) as Array<{ projectRef: string; cwd: string }>;
270
+ this.#paths = new Map(paths.map((item) => [item.projectRef, item.cwd]));
271
+ } catch { /* keep the last known folders */ }
272
+ return [...new Set(this.#paths.values())];
273
+ }
274
+
275
+ // The company entry of the new-chat model list.
276
+ async models(): Promise<unknown[]> {
277
+ if (this.#state !== "ready") { this.#attachSoon(); return []; }
278
+ try { this.#models ??= ((await this.json("GET", "/api/v1/session-creation-options")) as { models?: unknown[] }).models ?? []; }
279
+ catch { return []; }
280
+ return this.#models;
281
+ }
282
+
283
+ async operations(): Promise<unknown[]> {
284
+ if (this.#state !== "ready") return [];
285
+ try { return ((await this.json("GET", "/api/v1/session-live-state")) as { operations?: unknown[] }).operations ?? []; }
286
+ catch { return []; }
287
+ }
288
+
289
+ // Revisions are checked against the merged catalog the browser saw, then
290
+ // rewritten for the company Gateway and back.
291
+ async command(command: Record<string, any>, hubRevision: () => Promise<string | null>): Promise<unknown> {
292
+ await this.#ready(true);
293
+ this.#fresh = false;
294
+ const company = await this.catalog(), current = await hubRevision();
295
+ const forwarded: Record<string, any> = { ...command, expectedCatalogRevision: command.expectedCatalogRevision === current && company?.catalogRevision
296
+ ? company.catalogRevision : command.expectedCatalogRevision };
297
+ if (command.action === "session.create") {
298
+ const folder = await this.#folder(String(command.payload?.projectRef ?? ""));
299
+ if (!folder) throw new Error("session-project-not-found");
300
+ const project = await this.#control({ action: "project.register", cwd: folder }) as { projectRef: string; placeRef: string };
301
+ forwarded.payload = { ...command.payload, projectRef: project.projectRef, placeRef: project.placeRef };
302
+ }
303
+ const receipt = await this.#request("sessions.command", { command: forwarded }) as Record<string, unknown>;
304
+ if (typeof receipt?.sessionRef === "string") this.#owned.add(receipt.sessionRef);
305
+ this.#fresh = false;
306
+ return { ...receipt, catalogRevisionAfter: await hubRevision() ?? receipt.catalogRevisionAfter };
307
+ }
308
+ }
@@ -2,11 +2,16 @@ import fs from "node:fs";
2
2
  import net from "node:net";
3
3
 
4
4
  const MAX_MESSAGE_BYTES = 4_096;
5
+ // The folder list grows with every project the company runtime has seen.
6
+ const MAX_RESPONSE_BYTES: Partial<Record<string, number>> = { "project.paths": 1_048_576 };
5
7
 
6
- export type GatewayControlRequest = { action: "health" | "issue-launch-url" | "stop" };
8
+ // The project actions exist for the personal dashboard's company relay: it
9
+ // maps its projects to the company Gateway's opaque refs by folder.
10
+ export type GatewayControlRequest = { action: "health" | "issue-launch-url" | "stop" | "project.paths" } | { action: "project.register"; cwd: string };
7
11
  export type GatewayControlResponse = { ok: true; value: unknown } | { ok: false; error: string };
8
12
 
9
13
  export async function requestGatewayControl(socketPath: string, request: GatewayControlRequest, timeoutMs = 1_500): Promise<GatewayControlResponse> {
14
+ const limit = MAX_RESPONSE_BYTES[request.action] ?? MAX_MESSAGE_BYTES;
10
15
  return await new Promise((resolve, reject) => {
11
16
  const socket = net.createConnection({ path: socketPath });
12
17
  let body = "", settled = false;
@@ -16,7 +21,7 @@ export async function requestGatewayControl(socketPath: string, request: Gateway
16
21
  socket.once("connect", () => socket.write(`${JSON.stringify(request)}\n`));
17
22
  socket.on("data", (chunk) => {
18
23
  body += chunk;
19
- if (Buffer.byteLength(body) > MAX_MESSAGE_BYTES) return finish(() => reject(new Error("gateway-control-response-limit")));
24
+ if (Buffer.byteLength(body) > limit) return finish(() => reject(new Error("gateway-control-response-limit")));
20
25
  const newline = body.indexOf("\n");
21
26
  if (newline < 0) return;
22
27
  finish(() => {
@@ -33,17 +38,24 @@ async function removeStaleSocket(socketPath: string): Promise<void> {
33
38
  try { before = fs.lstatSync(socketPath); }
34
39
  catch { return; }
35
40
  if (!before.isSocket() || before.isSymbolicLink()) throw new Error("gateway-control-path-invalid");
41
+ // Only a socket nobody listens on is stale. A Gateway that accepted the
42
+ // connection but answers late is alive and busy (a large catalog, a loaded
43
+ // machine): taking its socket left it running unreachable, still serving its
44
+ // pages and holding its conversations beside a second Gateway.
36
45
  try {
37
- const live = await requestGatewayControl(socketPath, { action: "health" }, 500);
38
- if (live.ok) throw new Error("gateway-already-running");
46
+ await requestGatewayControl(socketPath, { action: "health" }, 500);
47
+ throw new Error("gateway-already-running");
39
48
  } catch (error) {
40
- if (error instanceof Error && error.message === "gateway-already-running") throw error;
49
+ if (error instanceof Error && (error.message === "gateway-already-running" || error.message === "gateway-control-timeout"
50
+ || error.message.startsWith("gateway-control-response"))) throw new Error("gateway-already-running");
41
51
  }
42
52
  const after = fs.lstatSync(socketPath);
43
53
  if (!after.isSocket() || after.dev !== before.dev || after.ino !== before.ino) throw new Error("gateway-control-path-race");
44
54
  fs.unlinkSync(socketPath);
45
55
  }
46
56
 
57
+ let bindings = 0;
58
+
47
59
  export async function startGatewayControlSocket(options: {
48
60
  socketPath: string;
49
61
  handle(request: GatewayControlRequest): Promise<GatewayControlResponse> | GatewayControlResponse;
@@ -63,9 +75,11 @@ export async function startGatewayControlSocket(options: {
63
75
  void (async () => {
64
76
  let request: GatewayControlRequest;
65
77
  try {
66
- const parsed = JSON.parse(body.slice(0, newline)) as Partial<GatewayControlRequest>;
67
- if (!parsed || !["health", "issue-launch-url", "stop"].includes(String(parsed.action))
68
- || Object.keys(parsed).some((key) => key !== "action")) throw new Error("invalid");
78
+ const parsed = JSON.parse(body.slice(0, newline)) as Record<string, unknown>;
79
+ const register = parsed?.action === "project.register";
80
+ if (!parsed || !["health", "issue-launch-url", "stop", "project.paths", "project.register"].includes(String(parsed.action))
81
+ || Object.keys(parsed).some((key) => key !== "action" && !(register && key === "cwd"))
82
+ || register && (typeof parsed.cwd !== "string" || !parsed.cwd.startsWith("/") || parsed.cwd.length > 4096)) throw new Error("invalid");
69
83
  request = parsed as GatewayControlRequest;
70
84
  } catch { socket.end(`${JSON.stringify({ ok: false, error: "invalid-request" })}\n`); return; }
71
85
  try { socket.end(`${JSON.stringify(await options.handle(request))}\n`); }
@@ -74,19 +88,32 @@ export async function startGatewayControlSocket(options: {
74
88
  });
75
89
  socket.once("error", () => { socket.destroy(); });
76
90
  });
91
+ // Closing a server removes the path it was bound to, whoever holds that path
92
+ // by then: a Gateway that exited after another had taken the path removed the
93
+ // running Gateway's socket, which could no longer be stopped or asked for a
94
+ // URL. The socket is bound under a name of its own and linked into place;
95
+ // the link fails if another Gateway holds the path.
96
+ const bound = `${options.socketPath}.${process.pid}-${++bindings}`;
77
97
  await new Promise<void>((resolve, reject) => {
78
98
  server.once("error", reject);
79
- server.listen(options.socketPath, resolve);
99
+ server.listen(bound, resolve);
80
100
  });
81
- fs.chmodSync(options.socketPath, 0o600);
101
+ try {
102
+ fs.chmodSync(bound, 0o600);
103
+ fs.linkSync(bound, options.socketPath);
104
+ } catch (error) {
105
+ server.close();
106
+ throw error && typeof error === "object" && "code" in error && error.code === "EEXIST" ? new Error("gateway-already-running") : error;
107
+ } finally { fs.rmSync(bound, { force: true }); }
82
108
  const identity = fs.lstatSync(options.socketPath);
109
+ let closed: Promise<void> | null = null;
83
110
  return {
84
- close: async () => {
111
+ close: () => closed ??= (async () => {
85
112
  await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
86
113
  try {
87
114
  const current = fs.lstatSync(options.socketPath);
88
115
  if (current.isSocket() && current.dev === identity.dev && current.ino === identity.ino) fs.unlinkSync(options.socketPath);
89
116
  } catch { /* already removed */ }
90
- }
117
+ })()
91
118
  };
92
119
  }