@piagent/platform 1.8.0 → 1.9.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.
- package/AGENTS.md +7 -0
- package/CHANGELOG.md +132 -2
- package/README.md +45 -70
- package/README.vi.md +34 -61
- package/architecture/layers.json +16 -3
- package/catalog/capabilities.json +14 -7
- package/catalog/claude-model-additions.json +42 -0
- package/docs/auto-delegation-policy.md +2 -0
- package/docs/command-reference-vietnamese.md +68 -90
- package/docs/context-window-policy.md +5 -5
- package/docs/distribution-standard.md +13 -16
- package/docs/en/architecture.md +9 -1
- package/docs/herdr-workflow.md +2 -3
- package/docs/languages.json +4 -4
- package/docs/managed-local-acceptance.md +403 -0
- package/docs/mcp-and-tools.md +5 -7
- package/docs/memory-policy.md +3 -3
- package/docs/model-options.md +2 -2
- package/docs/oauth-providers.md +1 -1
- package/docs/operator-manual-vietnamese.md +82 -127
- package/docs/package-architecture-notes.md +4 -4
- package/docs/piagent-core-improvement-plan.md +3 -5
- package/docs/piagent-core-plan-checkpoint-2026-09-06.json +2 -13
- package/docs/piagent-runtime-benchmark-execution-plan.md +10 -8
- package/docs/project-adapters.md +3 -3
- package/docs/project-onboarding.md +15 -16
- package/docs/publishing-for-teams.md +3 -1
- package/docs/quality-benchmark.md +2 -0
- package/docs/quickstart-vietnamese.md +23 -24
- package/docs/release-install-policy.md +3 -2
- package/docs/research/harness-next-evidence-review.md +7 -7
- package/docs/runtime-harness-standard.md +2 -0
- package/docs/runtime-policy-design.md +2 -0
- package/docs/runtime-quality-baseline.md +2 -0
- package/docs/subagent-orchestration-capabilities.md +1 -1
- package/docs/subagents-and-multiagent.md +10 -8
- package/docs/task-implementation-contract.md +2 -0
- package/docs/task-lifecycle-vietnamese.md +2 -0
- package/docs/team-onboarding.md +28 -59
- package/docs/usage-observability.md +2 -2
- package/docs/vi/architecture.md +9 -1
- package/docs/workflow-recipes.md +6 -10
- package/docs-site/architecture.html +4 -3
- package/docs-site/assets/docs.css +80 -0
- package/docs-site/benchmark.html +7 -1
- package/docs-site/commands.html +37 -23
- package/docs-site/content/architecture.html +2 -2
- package/docs-site/content/benchmark.html +5 -0
- package/docs-site/content/commands.html +35 -22
- package/docs-site/content/ecosystem.html +144 -0
- package/docs-site/content/en/architecture.html +1 -1
- package/docs-site/content/en/benchmark.html +5 -0
- package/docs-site/content/en/commands.html +30 -13
- package/docs-site/content/en/ecosystem.html +144 -0
- package/docs-site/content/en/index.html +24 -9
- package/docs-site/content/en/quickstart.html +10 -12
- package/docs-site/content/en/security.html +1 -1
- package/docs-site/content/en/team.html +2 -2
- package/docs-site/content/en/whats-new.html +92 -1
- package/docs-site/content/en/workflows.html +29 -31
- package/docs-site/content/index.html +24 -9
- package/docs-site/content/quickstart.html +10 -12
- package/docs-site/content/security.html +2 -2
- package/docs-site/content/team.html +2 -2
- package/docs-site/content/whats-new.html +92 -1
- package/docs-site/content/workflows.html +36 -38
- package/docs-site/context-engine.html +2 -1
- package/docs-site/ecosystem.html +283 -0
- package/docs-site/en/architecture.html +3 -2
- package/docs-site/en/benchmark.html +7 -1
- package/docs-site/en/commands.html +32 -14
- package/docs-site/en/context-engine.html +2 -1
- package/docs-site/en/ecosystem.html +283 -0
- package/docs-site/en/index.html +30 -14
- package/docs-site/en/mcp-approval.html +2 -1
- package/docs-site/en/mcp-auth.html +2 -1
- package/docs-site/en/mcp-servers.html +2 -1
- package/docs-site/en/mcp.html +2 -1
- package/docs-site/en/permissions.html +2 -1
- package/docs-site/en/profiles.html +2 -1
- package/docs-site/en/quickstart.html +17 -18
- package/docs-site/en/runtime.html +2 -1
- package/docs-site/en/scope.html +2 -1
- package/docs-site/en/security.html +6 -5
- package/docs-site/en/team.html +4 -3
- package/docs-site/en/whats-new.html +100 -8
- package/docs-site/en/workflows.html +34 -35
- package/docs-site/index.html +30 -14
- package/docs-site/mcp-approval.html +2 -1
- package/docs-site/mcp-auth.html +2 -1
- package/docs-site/mcp-servers.html +2 -1
- package/docs-site/mcp.html +2 -1
- package/docs-site/permissions.html +2 -1
- package/docs-site/profiles.html +2 -1
- package/docs-site/quickstart.html +17 -18
- package/docs-site/runtime.html +2 -1
- package/docs-site/scope.html +2 -1
- package/docs-site/security.html +7 -6
- package/docs-site/team.html +4 -3
- package/docs-site/whats-new.html +100 -8
- package/docs-site/workflows.html +41 -42
- package/evals/fs5-causal-arm.v1.json +2 -2
- package/evals/private-holdout-v1/CURRENT_BOUNDARY.md +17 -0
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- package/package.json +8 -7
- package/packages/piagent-core/README.md +15 -23
- package/packages/piagent-core/docs/context-efficiency-improvement-plan.md +2 -0
- package/packages/piagent-core/extensions/experiment-loop-policy.ts +75 -0
- package/packages/piagent-core/extensions/local-state-path.js +15 -0
- package/packages/piagent-core/extensions/piagent-guard.ts +108 -960
- package/packages/piagent-core/extensions/policy-core.js +2 -2
- package/packages/piagent-core/extensions/source-checkout-paths.ts +54 -0
- package/packages/piagent-core/managed/broker-client.mjs +49 -0
- package/packages/piagent-core/managed/capabilities.mjs +28 -0
- package/packages/piagent-core/managed/client-agent.mjs +6 -0
- package/packages/piagent-core/managed/compaction.mjs +63 -0
- package/packages/piagent-core/managed/doctor.mjs +107 -0
- package/packages/piagent-core/managed/helper-roles.mjs +57 -0
- package/packages/piagent-core/managed/native-catalog.mjs +42 -0
- package/packages/piagent-core/managed/patch-snapshot.mjs +48 -0
- package/packages/piagent-core/managed/repository-operation.mjs +84 -0
- package/packages/piagent-core/managed/request-stream.mjs +130 -0
- package/packages/piagent-core/managed/resource-loader.mjs +42 -0
- package/packages/piagent-core/managed/review-snapshot-worker.mjs +27 -0
- package/packages/piagent-core/managed/sandbox-diagnostic.mjs +12 -0
- package/packages/piagent-core/managed/search-fallback.mjs +93 -0
- package/packages/piagent-core/managed/session.mjs +493 -0
- package/packages/piagent-core/managed/store.d.mts +3 -0
- package/packages/piagent-core/managed/store.mjs +64 -0
- package/packages/piagent-core/managed/tool-boundary.mjs +275 -0
- package/packages/piagent-core/managed/tool-worker.mjs +36 -0
- package/packages/piagent-core/managed/toolchain.mjs +120 -0
- package/packages/piagent-core/managed/web-fetch.mjs +105 -0
- package/packages/piagent-core/managed/web-search.mjs +131 -0
- package/packages/piagent-core/managed/workflow.mjs +326 -0
- package/packages/piagent-core/package.json +2 -5
- package/packages/piagent-core/policies/base-policy.json +80 -26
- package/packages/piagent-core/runtime/hooks/agent-start-hook.ts +3 -3
- package/packages/piagent-core/runtime/hooks/input-hook.ts +35 -196
- package/packages/piagent-core/runtime/hooks/session-start-hook.ts +2 -0
- package/packages/piagent-core/runtime/inspection/approval-broker.ts +9 -1
- package/packages/piagent-core/runtime/managed-failure.d.mts +10 -0
- package/packages/piagent-core/runtime/managed-failure.mjs +166 -0
- package/packages/piagent-core/runtime/orchestration/minimal-delegation-policy.ts +8 -29
- package/packages/piagent-core/runtime/orchestration/owned-work-budget.ts +1 -1
- package/packages/piagent-core/runtime/policy/block-remedy.ts +5 -3
- package/packages/piagent-core/runtime/registration/context-commands.ts +1 -1
- package/packages/piagent-core/runtime/registration/knowledge-tools.ts +7 -7
- package/packages/piagent-core/runtime/registration/policy-tools.ts +2 -2
- package/packages/piagent-core/runtime/registration/session-commands.ts +11 -26
- package/packages/piagent-core/runtime/registration/task-evidence-tools.ts +1 -1
- package/packages/piagent-core/runtime/session/system-prompt.ts +6 -7
- package/packages/piagent-core/runtime/workflows/webui-runtime-command.ts +6 -6
- package/packages/piagent-core/runtime/workflows/webui-workflow.ts +6 -11
- package/packages/piagent-core/security/sensitive-text.js +9 -0
- package/packages/piagent-core/skills/piagent-ops/SKILL.md +8 -10
- package/packages/piagent-core/skills/test-audit/SKILL.md +101 -0
- package/packages/piagent-core/subagents/piagent-planner.md +1 -1
- package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-DMU7Xe_O.js → MarkdownMessage-BRPIMCRh.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/index-DNJ1vH5d.js +228 -0
- package/packages/piagent-webui/dist/client/assets/{index-DVahUVWy.css → index-DgSmcnP8.css} +1 -1
- package/packages/piagent-webui/dist/client/index.html +2 -2
- package/packages/piagent-webui/extension/piagent-webui.ts +11 -11
- package/packages/piagent-webui/extension/session-stream-adapter.ts +3 -1
- package/packages/piagent-webui/extension/terminal-session-adapter.ts +6 -1
- package/packages/piagent-webui/gateway/company-relay.ts +308 -0
- package/packages/piagent-webui/gateway/control-socket.ts +39 -12
- package/packages/piagent-webui/gateway/gateway-service.ts +109 -28
- package/packages/piagent-webui/gateway/gateway-session-stream.ts +58 -2
- package/packages/piagent-webui/gateway/managed-gateway.mjs +41 -0
- package/packages/piagent-webui/gateway/managed-launch.ts +145 -0
- package/packages/piagent-webui/gateway/managed-projection.ts +20 -0
- package/packages/piagent-webui/gateway/pi-host.ts +22 -4
- package/packages/piagent-webui/gateway/runtime-session-controls.ts +5 -2
- package/packages/piagent-webui/gateway/session-approval-authority.ts +28 -0
- package/packages/piagent-webui/gateway/session-catalog.ts +11 -3
- package/packages/piagent-webui/gateway/session-command-controller.ts +8 -2
- package/packages/piagent-webui/gateway/session-file-facts.ts +25 -0
- package/packages/piagent-webui/gateway/session-inspection-registry.ts +40 -9
- package/packages/piagent-webui/gateway/session-list-cache.ts +68 -0
- package/packages/piagent-webui/gateway/session-runtime-ownership.ts +8 -1
- package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +20 -27
- package/packages/piagent-webui/ownership/managed-terminal-lease.ts +27 -0
- package/packages/piagent-webui/ownership/profile-state.ts +2 -0
- package/packages/piagent-webui/ownership/session-lease-store.ts +22 -1
- package/packages/piagent-webui/server/approval-projection.ts +9 -0
- package/packages/piagent-webui/server/core-inspection-provider.ts +4 -9
- package/packages/piagent-webui/server/loopback-server.ts +37 -1
- package/packages/piagent-webui/server/message-stop-reason.ts +11 -0
- package/packages/piagent-webui/server/tool-call-summary.ts +185 -0
- package/packages/piagent-webui/server/transcript-projection.ts +86 -3
- package/packs/engineering-base/pack.json +4 -0
- package/schemas/piagent-webui/runtime-command-v1.schema.json +1 -2
- package/schemas/piagent-webui/session-catalog-v1.schema.json +8 -0
- package/schemas/piagent-webui/transcript-v1.schema.json +94 -1
- package/scripts/build-docs-site.mjs +23 -12
- package/scripts/check-public-wording.mjs +5 -6
- package/scripts/check-runtime-advisories.mjs +23 -6
- package/scripts/claude-model-additions.mjs +33 -0
- package/scripts/configure-model-scope.sh +30 -6
- package/scripts/configure-subagents.sh +1 -1
- package/scripts/explain-command.mjs +5 -6
- package/scripts/init-project.sh +0 -2
- package/scripts/install-global.sh +3 -2
- package/scripts/model-scope-prune.mjs +75 -0
- package/scripts/phase-tools-evaluation.mjs +9 -4
- package/scripts/piagent-cli.mjs +51 -2
- package/scripts/piagent-dashboard.mjs +27 -4
- package/scripts/piagent-installations.mjs +26 -0
- package/scripts/piagent-studio.mjs +155 -0
- package/scripts/runtime-advisory-exceptions.json +29 -0
- package/scripts/setup.sh +10 -6
- package/scripts/team-doctor.sh +20 -1
- package/scripts/typescript-loader.mjs +6 -0
- package/scripts/verify-local.sh +2 -2
- package/templates/global/settings.json +3 -1
- package/templates/project/.pi/project-context.md +3 -3
- package/templates/project/AGENTS.md +4 -5
- package/docs/piagent-core-reasoning-research-2026-09-06.md +0 -225
- package/docs/piagent-core-research-sources-2026-09-06.json +0 -416
- package/packages/piagent-webui/dist/client/assets/index-Cui9AAV_.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
|
-
|
|
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) >
|
|
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
|
-
|
|
38
|
-
|
|
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"
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
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(
|
|
99
|
+
server.listen(bound, resolve);
|
|
80
100
|
});
|
|
81
|
-
|
|
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
|
}
|