@piagent/platform 1.10.0 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +1 -1
  3. package/README.vi.md +1 -1
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/distribution-standard.md +6 -6
  6. package/docs/languages.json +2 -2
  7. package/docs/operator-manual-vietnamese.md +7 -7
  8. package/docs/publishing-for-teams.md +2 -0
  9. package/docs/release-install-policy.md +2 -2
  10. package/docs/team-onboarding.md +2 -2
  11. package/docs-site/architecture.html +1 -1
  12. package/docs-site/benchmark.html +1 -1
  13. package/docs-site/commands.html +1 -1
  14. package/docs-site/content/en/index.html +4 -4
  15. package/docs-site/content/en/whats-new.html +21 -0
  16. package/docs-site/content/index.html +4 -4
  17. package/docs-site/content/whats-new.html +21 -0
  18. package/docs-site/context-engine.html +1 -1
  19. package/docs-site/ecosystem.html +1 -1
  20. package/docs-site/en/architecture.html +1 -1
  21. package/docs-site/en/benchmark.html +1 -1
  22. package/docs-site/en/commands.html +1 -1
  23. package/docs-site/en/context-engine.html +1 -1
  24. package/docs-site/en/ecosystem.html +1 -1
  25. package/docs-site/en/index.html +5 -5
  26. package/docs-site/en/mcp-approval.html +1 -1
  27. package/docs-site/en/mcp-auth.html +1 -1
  28. package/docs-site/en/mcp-servers.html +1 -1
  29. package/docs-site/en/mcp.html +1 -1
  30. package/docs-site/en/permissions.html +1 -1
  31. package/docs-site/en/profiles.html +1 -1
  32. package/docs-site/en/quickstart.html +1 -1
  33. package/docs-site/en/runtime.html +1 -1
  34. package/docs-site/en/scope.html +1 -1
  35. package/docs-site/en/security.html +1 -1
  36. package/docs-site/en/team.html +1 -1
  37. package/docs-site/en/whats-new.html +22 -1
  38. package/docs-site/en/workflows.html +1 -1
  39. package/docs-site/index.html +5 -5
  40. package/docs-site/mcp-approval.html +1 -1
  41. package/docs-site/mcp-auth.html +1 -1
  42. package/docs-site/mcp-servers.html +1 -1
  43. package/docs-site/mcp.html +1 -1
  44. package/docs-site/permissions.html +1 -1
  45. package/docs-site/profiles.html +1 -1
  46. package/docs-site/quickstart.html +1 -1
  47. package/docs-site/runtime.html +1 -1
  48. package/docs-site/scope.html +1 -1
  49. package/docs-site/security.html +1 -1
  50. package/docs-site/team.html +1 -1
  51. package/docs-site/whats-new.html +22 -1
  52. package/docs-site/workflows.html +1 -1
  53. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  54. package/package.json +1 -1
  55. package/packages/piagent-core/package.json +1 -1
  56. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-CnSRvuah.js → MarkdownMessage-D70xkf0d.js} +1 -1
  57. package/packages/piagent-webui/dist/client/assets/index-BuIJR5rd.js +228 -0
  58. package/packages/piagent-webui/dist/client/index.html +1 -1
  59. package/packages/piagent-webui/gateway/gateway-service.ts +8 -1
  60. package/packages/piagent-webui/gateway/update-center.ts +201 -0
  61. package/packages/piagent-webui/server/http-security.ts +15 -1
  62. package/packages/piagent-webui/server/loopback-server.ts +6 -42
  63. package/packages/piagent-webui/server/member-routes.ts +99 -0
  64. package/scripts/dashboard-update-job.mjs +76 -0
  65. package/scripts/piagent-dashboard.mjs +8 -0
  66. package/scripts/update-global.mjs +2 -0
  67. package/packages/piagent-webui/dist/client/assets/index-yg9s8sA_.js +0 -228
@@ -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-yg9s8sA_.js"></script>
10
+ <script type="module" crossorigin src="/assets/index-BuIJR5rd.js"></script>
11
11
  <link rel="stylesheet" crossorigin href="/assets/index-Z91z6SlN.css">
12
12
  </head>
13
13
  <body>
@@ -9,6 +9,7 @@ import type { PiagentWebUICanonicalSnapshotV1 } from "../contracts/generated/sna
9
9
  import { startLoopbackServer } from "../server/loopback-server.ts";
10
10
  import { startGatewayControlSocket, type GatewayControlResponse } from "./control-socket.ts";
11
11
  import { loadPinnedPiHost } from "./pi-host.ts";
12
+ import { UpdateCenter } from "./update-center.ts";
12
13
  import {
13
14
  gatewayProfileState,
14
15
  profileRef,
@@ -113,6 +114,7 @@ export async function startPiagentGateway(options: {
113
114
  let mcpAuth: McpAuthBroker | null = null;
114
115
  let attachments: SessionAttachmentRegistry | null = null;
115
116
  let relay: CompanyRelay | null = null;
117
+ let updates: UpdateCenter | null = null;
116
118
  let closing: Promise<void> | null = null;
117
119
  let settleWait: (() => void) | null = null;
118
120
  const waited = new Promise<void>((resolve) => { settleWait = resolve; });
@@ -121,7 +123,7 @@ export async function startPiagentGateway(options: {
121
123
  if (closing) return await closing;
122
124
  closing = (async () => {
123
125
  removeGatewayDescriptor(state, gatewayInstanceRef);
124
- relay?.close();
126
+ relay?.close(); updates?.close();
125
127
  await loopback?.close().catch(() => undefined);
126
128
  // Staged bytes are private temp files. Closing deletes them rather than
127
129
  // leaving a directory per session behind for the TTL sweep that will never
@@ -242,6 +244,10 @@ export async function startPiagentGateway(options: {
242
244
  attachments = new SessionAttachmentRegistry({
243
245
  inspect: async (sessionRef) => await (await inspections.provider(sessionRef)).snapshot() as PiagentWebUICanonicalSnapshotV1
244
246
  });
247
+ // Piagent's own updates: Pi and Piagent together, never while a turn runs.
248
+ updates = new UpdateCenter({ packageRoot: options.packageRoot, piVersion: options.expectedPiVersion, managed: Boolean(options.managed),
249
+ busy: async () => runtimes!.currentOperations().length + (relay ? (await relay.operations()).length : 0) });
250
+ updates.start();
245
251
  loopback = await startLoopbackServer({
246
252
  staticRoot,
247
253
  mode: "gateway",
@@ -280,6 +286,7 @@ export async function startPiagentGateway(options: {
280
286
  // conversation runs (the company Gateway for a company conversation).
281
287
  readSessionQuestions: (sessionRef) => company(sessionRef)
282
288
  ? relay!.json("GET", `/api/v1/sessions/${encodeURIComponent(sessionRef)}/questions`) : runtimes!.questions(sessionRef),
289
+ updates: { status: () => updates!.status(), check: () => updates!.check(), apply: (request) => updates!.apply(request) },
283
290
  answerSessionQuestion: (sessionRef, questionRef, answer) => company(sessionRef)
284
291
  ? relayPost(`/api/v1/sessions/${encodeURIComponent(sessionRef)}/questions/${encodeURIComponent(questionRef)}/answer`, answer)
285
292
  : runtimes!.answerQuestion(sessionRef, questionRef, answer),
@@ -0,0 +1,201 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { execFile, spawn } from "node:child_process";
5
+
6
+ import { compareReleaseVersions, releaseVersion, writeUpdateCache } from "../../piagent-core/extensions/update-check.js";
7
+
8
+ // Piagent updates from the dashboard: what is installed, what the registry
9
+ // offers, and one click that moves the machine to it. Pi is never offered
10
+ // past the version the newest Piagent pins (its peer dependency): that is the
11
+ // host its managed runtime is qualified on. A newer Pi is shown as untested.
12
+ //
13
+ // The registry is asked in the background, at most every 6 hours unless the
14
+ // member asks now; the dashboard reads the cached answer. Everything read from
15
+ // outside (registry answers, the cache and job files any local process could
16
+ // write) is accepted only as release versions and known codes.
17
+ const PLATFORM = "@piagent/platform";
18
+ const HOST = "@earendil-works/pi-coding-agent";
19
+ export const CHECK_EVERY_MS = 6 * 60 * 60 * 1000;
20
+ const FORCED_CHECK_GAP_MS = 30_000;
21
+ const PROBE_TIMEOUT_MS = 20_000;
22
+ const MAX_FILE_BYTES = 4096;
23
+ const STATUS_FILE = "piagent-update-status.json", JOB_FILE = "piagent-update-job.json";
24
+ // A run that has not finished in this time died with its process.
25
+ const JOB_STALE_MS = 45 * 60 * 1000;
26
+ // A finished run is reported for a day, then no longer.
27
+ const JOB_REPORTED_MS = 24 * 60 * 60 * 1000;
28
+ const JOB_STATES = new Set(["starting", "running", "succeeded", "failed"]);
29
+ const JOB_REASONS = new Set(["update-command-failed", "update-version-mismatch", "dashboard-restart-failed", "update-interrupted", "update-start-failed"]);
30
+
31
+ export type Registry = { latest?: string; latestHost?: string; hostLatest?: string; checkedAt?: number };
32
+ export type UpdateJob = { state: "starting" | "running" | "succeeded" | "failed"; from: string; to: string; startedAt: string;
33
+ finishedAt?: string; pid?: number; reason?: string; installed?: string; bindingChanged?: boolean };
34
+ export type UpdateStatus = {
35
+ schemaVersion: 1; version: "piagent-update-status-v1";
36
+ installable: boolean; reason: "working-copy" | "company-runtime" | null;
37
+ checkedAt: string | null; checking: boolean; checkEveryHours: number;
38
+ piagent: { installed: string | null; latest: string | null; updateAvailable: boolean };
39
+ pi: { installed: string | null; required: string | null; latest: string | null; updateAvailable: boolean; newerUntested: boolean };
40
+ updateAvailable: boolean; runningConversations: number; job: UpdateJob | null;
41
+ };
42
+
43
+ const stateDirectory = (home: string) => path.join(home, ".pi");
44
+ function readSmallJson(file: string): any {
45
+ try { if (fs.statSync(file).size > MAX_FILE_BYTES) return undefined; return JSON.parse(fs.readFileSync(file, "utf8")); }
46
+ catch { return undefined; }
47
+ }
48
+ function writeJson(file: string, value: unknown): void {
49
+ fs.mkdirSync(path.dirname(file), { recursive: true });
50
+ const temporary = `${file}.${process.pid}.tmp`;
51
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
52
+ fs.renameSync(temporary, file);
53
+ }
54
+ const version = (value: unknown) => releaseVersion(value) ?? undefined;
55
+
56
+ export function readRegistryCache(home: string): Registry {
57
+ const value = readSmallJson(path.join(stateDirectory(home), STATUS_FILE)), checkedAt = Date.parse(value?.checkedAt);
58
+ return { latest: version(value?.latest), latestHost: version(value?.latestHost), hostLatest: version(value?.hostLatest),
59
+ checkedAt: Number.isFinite(checkedAt) ? checkedAt : undefined };
60
+ }
61
+ export function writeRegistryCache(home: string, registry: Registry, now: number): void {
62
+ writeJson(path.join(stateDirectory(home), STATUS_FILE), { schemaVersion: 1, latest: registry.latest ?? null, latestHost: registry.latestHost ?? null,
63
+ hostLatest: registry.hostLatest ?? null, checkedAt: new Date(now).toISOString() });
64
+ // The Terminal's own notice reads the shared cache: keep both on one answer.
65
+ if (registry.latest) writeUpdateCache(home, registry.latest, now);
66
+ }
67
+
68
+ export function readUpdateJob(home: string, now = Date.now(), alive = processAlive): UpdateJob | null {
69
+ const value = readSmallJson(path.join(stateDirectory(home), JOB_FILE));
70
+ const from = version(value?.from), to = version(value?.to), startedAt = Date.parse(value?.startedAt);
71
+ if (!value || !JOB_STATES.has(value.state) || !from || !to || !Number.isFinite(startedAt)) return null;
72
+ const job: UpdateJob = { state: value.state, from, to, startedAt: new Date(startedAt).toISOString() };
73
+ const finishedAt = Date.parse(value.finishedAt);
74
+ if (Number.isFinite(finishedAt)) job.finishedAt = new Date(finishedAt).toISOString();
75
+ if ((job.state === "succeeded" || job.state === "failed") && Number.isFinite(finishedAt) && now - finishedAt > JOB_REPORTED_MS) return null;
76
+ if (Number.isSafeInteger(value.pid) && value.pid > 0) job.pid = value.pid;
77
+ if (JOB_REASONS.has(value.reason)) job.reason = value.reason;
78
+ if (version(value.installed)) job.installed = version(value.installed);
79
+ if (value.bindingChanged === true) job.bindingChanged = true;
80
+ // A run whose process is gone (or never reported one) did not finish.
81
+ if ((job.state === "running" || job.state === "starting") && (now - startedAt > JOB_STALE_MS || (job.pid ? !alive(job.pid) : now - startedAt > 120_000)))
82
+ return { ...job, state: "failed", reason: "update-interrupted" };
83
+ return job;
84
+ }
85
+ export function writeUpdateJob(home: string, job: UpdateJob): void { writeJson(path.join(stateDirectory(home), JOB_FILE), job); }
86
+ function processAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; } }
87
+
88
+ // The npm helper this dashboard runs from can update itself; a working copy
89
+ // is updated with git, and a company runtime by the dashboard that owns it.
90
+ export function updateTarget(packageRoot: string, managed: boolean): { installable: boolean; reason: UpdateStatus["reason"] } {
91
+ if (managed) return { installable: false, reason: "company-runtime" };
92
+ const parts = path.resolve(packageRoot).split(path.sep);
93
+ const installed = parts.includes("node_modules") && !parts.some((part) => part === "_npx" || part === "_cacache");
94
+ return installed ? { installable: true, reason: null } : { installable: false, reason: "working-copy" };
95
+ }
96
+
97
+ export function composeUpdateStatus(input: { installedPiagent?: string; installedPi?: string; target: ReturnType<typeof updateTarget>;
98
+ registry: Registry; job: UpdateJob | null; runningConversations: number; checking: boolean }): UpdateStatus {
99
+ const installedPiagent = version(input.installedPiagent) ?? null, installedPi = version(input.installedPi) ?? null;
100
+ const { latest, latestHost, hostLatest } = input.registry;
101
+ const piagentNewer = Boolean(installedPiagent && latest && compareReleaseVersions(latest, installedPiagent) > 0);
102
+ // The Pi update is the version the newest Piagent pins, whichever way it
103
+ // moves; it is only offered together with that Piagent.
104
+ const piChange = Boolean(latestHost && installedPi !== latestHost && (piagentNewer || installedPiagent === latest));
105
+ return { schemaVersion: 1, version: "piagent-update-status-v1", installable: input.target.installable, reason: input.target.reason,
106
+ checkedAt: input.registry.checkedAt ? new Date(input.registry.checkedAt).toISOString() : null, checking: input.checking,
107
+ checkEveryHours: CHECK_EVERY_MS / 3_600_000,
108
+ piagent: { installed: installedPiagent, latest: latest ?? null, updateAvailable: piagentNewer },
109
+ pi: { installed: installedPi, required: latestHost ?? null, latest: hostLatest ?? null, updateAvailable: piChange,
110
+ newerUntested: Boolean(hostLatest && latestHost && compareReleaseVersions(hostLatest, latestHost) > 0) },
111
+ updateAvailable: piagentNewer || piChange, runningConversations: input.runningConversations, job: input.job };
112
+ }
113
+
114
+ type NpmView = (spec: string, field: string) => Promise<unknown>;
115
+ const npmView: NpmView = (spec, field) => new Promise((resolve) => {
116
+ execFile("npm", ["view", spec, field, "--json"], { timeout: PROBE_TIMEOUT_MS, windowsHide: true, maxBuffer: 256 * 1024 },
117
+ (error, stdout) => { if (error) return resolve(undefined); try { const value = JSON.parse(String(stdout).trim() || "null"); resolve(Array.isArray(value) && value.length === 1 ? value[0] : value); } catch { resolve(undefined); } });
118
+ });
119
+
120
+ // One answer from the registry, or null when it could not be read.
121
+ export async function probeRegistry(view: NpmView = npmView): Promise<Registry | null> {
122
+ const latest = version(await view(PLATFORM, "version"));
123
+ if (!latest) return null;
124
+ const peers = await view(`${PLATFORM}@${latest}`, "peerDependencies") as Record<string, unknown> | undefined;
125
+ return { latest, latestHost: version(peers?.[HOST]), hostLatest: version(await view(HOST, "version")) };
126
+ }
127
+
128
+ export class UpdateCenter {
129
+ #home: string; #packageRoot: string; #piVersion?: string; #target: ReturnType<typeof updateTarget>;
130
+ #busy: () => Promise<number>; #view: NpmView; #now: () => number; #spawnJob: (args: string[]) => number | undefined;
131
+ #probe: Promise<void> | null = null; #lastForced = 0; #timer: NodeJS.Timeout | null = null;
132
+
133
+ constructor(options: { packageRoot: string; piVersion?: string; managed: boolean; busy: () => Promise<number>; home?: string;
134
+ view?: NpmView; now?: () => number; spawnJob?: (args: string[]) => number | undefined }) {
135
+ this.#home = options.home ?? os.homedir(); this.#packageRoot = options.packageRoot; this.#piVersion = options.piVersion;
136
+ this.#target = updateTarget(options.packageRoot, options.managed); this.#busy = options.busy;
137
+ this.#view = options.view ?? npmView; this.#now = options.now ?? Date.now;
138
+ this.#spawnJob = options.spawnJob ?? ((args) => {
139
+ const child = spawn(process.execPath, args, { detached: true, stdio: "ignore", windowsHide: true, cwd: this.#home,
140
+ env: { ...process.env, PIAGENT_NO_UPDATE_CHECK: "1" } });
141
+ child.unref(); return child.pid;
142
+ });
143
+ }
144
+
145
+ // Only a machine that can update itself asks the registry (a working copy
146
+ // or a company runtime never does, nor any run with PIAGENT_NO_UPDATE_CHECK).
147
+ #asks(): boolean { return this.#target.installable && !process.env.PIAGENT_NO_UPDATE_CHECK?.trim(); }
148
+ // Checks now when the cached answer is older than the period, then on it.
149
+ start(): void {
150
+ if (this.#timer || !this.#asks()) return;
151
+ void this.#refreshIfStale();
152
+ this.#timer = setInterval(() => void this.#refreshIfStale(), 15 * 60 * 1000); this.#timer.unref();
153
+ }
154
+ close(): void { if (this.#timer) clearInterval(this.#timer); this.#timer = null; }
155
+
156
+ #installedPiagent(): string | undefined {
157
+ try { return version(JSON.parse(fs.readFileSync(path.join(this.#packageRoot, "package.json"), "utf8")).version); } catch { return undefined; }
158
+ }
159
+ async #refreshIfStale(): Promise<void> {
160
+ const checkedAt = readRegistryCache(this.#home).checkedAt;
161
+ if (!checkedAt || this.#now() - checkedAt >= CHECK_EVERY_MS) await this.#refresh();
162
+ }
163
+ #refresh(): Promise<void> {
164
+ this.#probe ??= (async () => {
165
+ try { const registry = await probeRegistry(this.#view); if (registry) writeRegistryCache(this.#home, registry, this.#now()); }
166
+ catch { /* the next period tries again */ }
167
+ finally { this.#probe = null; }
168
+ })();
169
+ return this.#probe;
170
+ }
171
+
172
+ async status(): Promise<UpdateStatus> {
173
+ return composeUpdateStatus({ installedPiagent: this.#installedPiagent(), installedPi: this.#piVersion, target: this.#target,
174
+ registry: readRegistryCache(this.#home), job: readUpdateJob(this.#home, this.#now()), runningConversations: await this.#busy().catch(() => 0),
175
+ checking: this.#probe !== null });
176
+ }
177
+ async check(): Promise<UpdateStatus> {
178
+ if (!this.#asks()) return this.status();
179
+ if (this.#now() - this.#lastForced >= FORCED_CHECK_GAP_MS) { this.#lastForced = this.#now(); await this.#refresh(); }
180
+ else if (this.#probe) await this.#probe;
181
+ return this.status();
182
+ }
183
+ // `request.version`: the Piagent version the member chose to install.
184
+ async apply(request: unknown): Promise<{ job: UpdateJob }> {
185
+ const wanted = version((request as { version?: unknown } | null)?.version);
186
+ if (!wanted) throw new Error("update-request-invalid");
187
+ const status = await this.status();
188
+ if (!status.installable) throw new Error("update-not-installable");
189
+ if (status.job && (status.job.state === "running" || status.job.state === "starting")) throw new Error("update-already-running");
190
+ if (status.runningConversations > 0) throw new Error("update-blocked-running");
191
+ if (!status.updateAvailable) throw new Error("update-not-available");
192
+ if (wanted !== status.piagent.latest) throw new Error("update-version-changed");
193
+ const from = status.piagent.installed ?? wanted;
194
+ const job: UpdateJob = { state: "starting", from, to: wanted, startedAt: new Date(this.#now()).toISOString() };
195
+ writeUpdateJob(this.#home, job);
196
+ const pid = this.#spawnJob([path.join(this.#packageRoot, "scripts", "dashboard-update-job.mjs"), "--from", from, "--to", wanted]);
197
+ if (!pid) { const failed: UpdateJob = { ...job, state: "failed", reason: "update-start-failed", finishedAt: new Date(this.#now()).toISOString() }; writeUpdateJob(this.#home, failed); return { job: failed }; }
198
+ const started: UpdateJob = { ...job, pid }; writeUpdateJob(this.#home, started);
199
+ return { job: started };
200
+ }
201
+ }
@@ -1,4 +1,4 @@
1
- import type { ServerResponse } from "node:http";
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
 
3
3
  export const SECURITY_HEADERS = Object.freeze({
4
4
  "Cross-Origin-Opener-Policy": "same-origin",
@@ -25,3 +25,17 @@ export function jsonResponse(response: ServerResponse, status: number, value: un
25
25
  export function errorResponse(response: ServerResponse, status: number, code: string): void {
26
26
  jsonResponse(response, status, { error: { code } });
27
27
  }
28
+
29
+ export function requestBody(request: IncomingMessage, maximumBytes: number): Promise<Buffer> {
30
+ return new Promise((resolve, reject) => {
31
+ const chunks: Buffer[] = []; let bytes = 0, failed = false;
32
+ request.on("data", (chunk: Buffer) => {
33
+ if (failed) return;
34
+ bytes += chunk.length;
35
+ if (bytes > maximumBytes) { failed = true; reject(new Error("body-limit")); }
36
+ else chunks.push(chunk);
37
+ });
38
+ request.on("end", () => { if (!failed) resolve(Buffer.concat(chunks)); });
39
+ request.on("error", (error) => { if (!failed) reject(error); });
40
+ });
41
+ }
@@ -1,7 +1,8 @@
1
- import http, { type IncomingMessage, type ServerResponse } from "node:http";
1
+ import http, { type ServerResponse } from "node:http";
2
2
  import { randomBytes } from "node:crypto";
3
3
 
4
- import { errorResponse, applySecurityHeaders, jsonResponse } from "./http-security.ts";
4
+ import { errorResponse, applySecurityHeaders, jsonResponse, requestBody } from "./http-security.ts";
5
+ import { routeMemberRequest, type UpdateRoutes } from "./member-routes.ts";
5
6
  import { ReadModelNotFound, type WebUiReadModelProvider } from "./read-model-provider.ts";
6
7
  import { routeReadOnlyRequest } from "./read-only-router.ts";
7
8
  import { SessionAuthority } from "./session-auth.ts";
@@ -32,20 +33,6 @@ export type LoopbackServer = {
32
33
  close(): Promise<void>;
33
34
  };
34
35
 
35
- function requestBody(request: IncomingMessage, maximumBytes: number): Promise<Buffer> {
36
- return new Promise((resolve, reject) => {
37
- const chunks: Buffer[] = []; let bytes = 0, failed = false;
38
- request.on("data", (chunk: Buffer) => {
39
- if (failed) return;
40
- bytes += chunk.length;
41
- if (bytes > maximumBytes) { failed = true; reject(new Error("body-limit")); }
42
- else chunks.push(chunk);
43
- });
44
- request.on("end", () => { if (!failed) resolve(Buffer.concat(chunks)); });
45
- request.on("error", (error) => { if (!failed) reject(error); });
46
- });
47
- }
48
-
49
36
  function serveAsset(response: ServerResponse, asset: StaticAsset): void {
50
37
  response.statusCode = 200;
51
38
  response.setHeader("Cache-Control", "no-store");
@@ -83,6 +70,7 @@ export async function startLoopbackServer(options: {
83
70
  executeApproval?: (approvalRef: string, decision: unknown) => unknown | Promise<unknown>;
84
71
  readSessionQuestions?: (sessionRef: string) => unknown | Promise<unknown>;
85
72
  answerSessionQuestion?: (sessionRef: string, questionRef: string, answer: unknown) => unknown | Promise<unknown>;
73
+ updates?: UpdateRoutes;
86
74
  bootstrapTtlMs?: number;
87
75
  sessionTtlMs?: number;
88
76
  }): Promise<LoopbackServer> {
@@ -304,32 +292,8 @@ export async function startLoopbackServer(options: {
304
292
  catch { return errorResponse(response, 503, "attachment-runtime-unavailable"); }
305
293
  }
306
294
 
307
- // The main agent's questions to the member of a conversation, and the answer.
308
- const questionPath = /^\/api\/v1\/sessions\/([^/]+)\/questions(?:\/([^/]+)\/answer)?$/.exec(url.pathname);
309
- if (request.method === "GET" && questionPath && !questionPath[2] && options.readSessionQuestions) {
310
- if (!auth.authenticate(request)) return errorResponse(response, 401, "authentication-required");
311
- const sessionRef = decodeURIComponent(questionPath[1]);
312
- if (!CURSOR.test(sessionRef)) return errorResponse(response, 400, "invalid-session-ref");
313
- try { return jsonResponse(response, 200, await options.readSessionQuestions(sessionRef)); }
314
- catch { return errorResponse(response, 503, "questions-unavailable"); }
315
- }
316
- if (request.method === "POST" && questionPath && questionPath[2] && options.answerSessionQuestion) {
317
- if (requestOrigin !== origin) return errorResponse(response, 403, "origin-required");
318
- const mutationSession = auth.authorizeMutation(request);
319
- if (!mutationSession) return errorResponse(response, 403, "mutation-authority-rejected");
320
- if (!consumeControl(mutationSession.id, now)) return errorResponse(response, 429, "control-rate-limit");
321
- if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) return errorResponse(response, 415, "content-type");
322
- const sessionRef = decodeURIComponent(questionPath[1]), questionRef = decodeURIComponent(questionPath[2]);
323
- if (!CURSOR.test(sessionRef) || !/^question\.[0-9a-f-]{36}$/.test(questionRef)) return errorResponse(response, 400, "invalid-question-ref");
324
- let answer: unknown;
325
- try { answer = JSON.parse((await requestBody(request, MAX_CONTROL_BODY_BYTES)).toString("utf8")); }
326
- catch (error) { return errorResponse(response, (error as Error).message === "body-limit" ? 413 : 400, "invalid-question-answer"); }
327
- try { return jsonResponse(response, 200, await options.answerSessionQuestion(sessionRef, questionRef, answer)); }
328
- catch (error) {
329
- const code = error instanceof Error ? error.message : "questions-unavailable";
330
- return errorResponse(response, code === "question-not-pending" ? 409 : code === "question-answer-invalid" ? 400 : 503, code);
331
- }
332
- }
295
+ // The main agent's questions to the member, and Piagent's own updates.
296
+ if (await routeMemberRequest({ request, response, url, origin, requestOrigin, auth, now, consumeControl }, options)) return;
333
297
 
334
298
  if (request.method === "POST" && url.pathname.startsWith("/api/v1/approvals/") && url.pathname.endsWith("/decision") && options.executeApproval) {
335
299
  if (requestOrigin !== origin) return errorResponse(response, 403, "origin-required");
@@ -0,0 +1,99 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+
3
+ import { errorResponse, jsonResponse, requestBody } from "./http-security.ts";
4
+ import type { SessionAuthority } from "./session-auth.ts";
5
+
6
+ // Requests a member makes beside a conversation: answering the main agent's
7
+ // questions, and keeping Piagent itself up to date. Reads need the browser
8
+ // session; every change also needs this origin, the session's CSRF token and
9
+ // a share of the control rate.
10
+ const MAX_CONTROL_BODY_BYTES = 70_000;
11
+ const CURSOR = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/;
12
+ const QUESTION_PATH = /^\/api\/v1\/sessions\/([^/]+)\/questions(?:\/([^/]+)\/answer)?$/;
13
+
14
+ export type UpdateRoutes = {
15
+ // What is installed, what is available, and the last update run.
16
+ status(): unknown | Promise<unknown>;
17
+ // Asks the registry now instead of waiting for the next periodic check.
18
+ check(): unknown | Promise<unknown>;
19
+ // Starts the update to the version the member saw; refused when it moved.
20
+ apply(request: unknown): unknown | Promise<unknown>;
21
+ };
22
+
23
+ type Context = {
24
+ request: IncomingMessage; response: ServerResponse; url: URL; origin: string; requestOrigin: string | undefined;
25
+ auth: SessionAuthority; now: number; consumeControl(sessionId: string, now: number): boolean;
26
+ };
27
+ type Options = {
28
+ readSessionQuestions?: (sessionRef: string) => unknown | Promise<unknown>;
29
+ answerSessionQuestion?: (sessionRef: string, questionRef: string, answer: unknown) => unknown | Promise<unknown>;
30
+ updates?: UpdateRoutes;
31
+ };
32
+
33
+ // The checks every change shares; a refusal is answered here and false returned.
34
+ function authorizeChange(context: Context): boolean {
35
+ const { request, response, origin, requestOrigin, auth, now, consumeControl } = context;
36
+ if (requestOrigin !== origin) { errorResponse(response, 403, "origin-required"); return false; }
37
+ const session = auth.authorizeMutation(request);
38
+ if (!session) { errorResponse(response, 403, "mutation-authority-rejected"); return false; }
39
+ if (!consumeControl(session.id, now)) { errorResponse(response, 429, "control-rate-limit"); return false; }
40
+ if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) { errorResponse(response, 415, "content-type"); return false; }
41
+ return true;
42
+ }
43
+
44
+ async function jsonBody(context: Context, code: string): Promise<{ ok: true; value: unknown } | { ok: false }> {
45
+ try { return { ok: true, value: JSON.parse((await requestBody(context.request, MAX_CONTROL_BODY_BYTES)).toString("utf8")) }; }
46
+ catch (error) { errorResponse(context.response, (error as Error).message === "body-limit" ? 413 : 400, code); return { ok: false }; }
47
+ }
48
+
49
+ const UPDATE_STATUS: Record<string, number> = {
50
+ "update-not-installable": 409, "update-blocked-running": 409, "update-already-running": 409,
51
+ "update-version-changed": 409, "update-not-available": 409, "update-request-invalid": 400
52
+ };
53
+
54
+ // True when the request was one of these routes (answered, or refused).
55
+ export async function routeMemberRequest(context: Context, options: Options): Promise<boolean> {
56
+ const { request, response, url, auth } = context;
57
+ const question = QUESTION_PATH.exec(url.pathname);
58
+ if (request.method === "GET" && question && !question[2] && options.readSessionQuestions) {
59
+ if (!auth.authenticate(request)) { errorResponse(response, 401, "authentication-required"); return true; }
60
+ const sessionRef = decodeURIComponent(question[1]);
61
+ if (!CURSOR.test(sessionRef)) { errorResponse(response, 400, "invalid-session-ref"); return true; }
62
+ try { jsonResponse(response, 200, await options.readSessionQuestions(sessionRef)); }
63
+ catch { errorResponse(response, 503, "questions-unavailable"); }
64
+ return true;
65
+ }
66
+ if (request.method === "POST" && question && question[2] && options.answerSessionQuestion) {
67
+ if (!authorizeChange(context)) return true;
68
+ const sessionRef = decodeURIComponent(question[1]), questionRef = decodeURIComponent(question[2]);
69
+ if (!CURSOR.test(sessionRef) || !/^question\.[0-9a-f-]{36}$/.test(questionRef)) { errorResponse(response, 400, "invalid-question-ref"); return true; }
70
+ const body = await jsonBody(context, "invalid-question-answer");
71
+ if (!body.ok) return true;
72
+ try { jsonResponse(response, 200, await options.answerSessionQuestion(sessionRef, questionRef, body.value)); }
73
+ catch (error) {
74
+ const code = error instanceof Error ? error.message : "questions-unavailable";
75
+ errorResponse(response, code === "question-not-pending" ? 409 : code === "question-answer-invalid" ? 400 : 503, code);
76
+ }
77
+ return true;
78
+ }
79
+
80
+ if (!options.updates || !url.pathname.startsWith("/api/v1/updates")) return false;
81
+ const updates = options.updates;
82
+ if (request.method === "GET" && url.pathname === "/api/v1/updates") {
83
+ if (!auth.authenticate(request)) { errorResponse(response, 401, "authentication-required"); return true; }
84
+ try { jsonResponse(response, 200, await updates.status()); }
85
+ catch { errorResponse(response, 503, "update-status-unavailable"); }
86
+ return true;
87
+ }
88
+ const action = request.method === "POST" ? /^\/api\/v1\/updates\/(check|apply)$/.exec(url.pathname)?.[1] : undefined;
89
+ if (!action) { errorResponse(response, 404, "not-found"); return true; }
90
+ if (!authorizeChange(context)) return true;
91
+ const body = await jsonBody(context, "update-request-invalid");
92
+ if (!body.ok) return true;
93
+ try { jsonResponse(response, action === "apply" ? 202 : 200, action === "apply" ? await updates.apply(body.value) : await updates.check()); }
94
+ catch (error) {
95
+ const code = error instanceof Error ? error.message : "update-unavailable";
96
+ errorResponse(response, UPDATE_STATUS[code] ?? 503, UPDATE_STATUS[code] ? code : "update-unavailable");
97
+ }
98
+ return true;
99
+ }
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ // One update started from the dashboard, run apart from it: the dashboard is
3
+ // restarted at the end, so this process must outlive it. It moves Pi and
4
+ // Piagent to the chosen release with `piagent-update` (Pi first, to the
5
+ // version that release pins), restarts the dashboard on the new code, and
6
+ // leaves the outcome in ~/.pi/piagent-update-job.json for the new dashboard to
7
+ // show. The full output goes to ~/.pi/piagent-update-job.log.
8
+ //
9
+ // dashboard-update-job.mjs --from 1.10.0 --to 1.11.0
10
+ // [--updater <script>] [--dashboard <script>] [--home <dir>] (tests)
11
+ import crypto from "node:crypto";
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { spawnSync } from "node:child_process";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const RELEASE = /^(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})$/;
19
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
20
+
21
+ function option(name, fallback) {
22
+ const index = process.argv.indexOf(`--${name}`);
23
+ return index > 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
24
+ }
25
+ const from = option("from"), to = option("to");
26
+ const home = path.resolve(option("home", os.homedir()));
27
+ const updater = option("updater", path.join(packageRoot, "scripts", "update-global.mjs"));
28
+ const dashboard = option("dashboard", path.join(packageRoot, "scripts", "piagent-dashboard.mjs"));
29
+ if (!RELEASE.test(from ?? "") || !RELEASE.test(to ?? "")) {
30
+ process.stderr.write("usage: dashboard-update-job.mjs --from X.Y.Z --to X.Y.Z\n");
31
+ process.exit(2);
32
+ }
33
+
34
+ const directory = path.join(home, ".pi"), jobFile = path.join(directory, "piagent-update-job.json");
35
+ fs.mkdirSync(directory, { recursive: true });
36
+ const startedAt = (() => {
37
+ try { const value = JSON.parse(fs.readFileSync(jobFile, "utf8")); return value?.to === to && Number.isFinite(Date.parse(value.startedAt)) ? value.startedAt : undefined; }
38
+ catch { return undefined; }
39
+ })() ?? new Date().toISOString();
40
+ function record(fields) {
41
+ const temporary = `${jobFile}.${process.pid}.tmp`;
42
+ fs.writeFileSync(temporary, `${JSON.stringify({ from, to, startedAt, pid: process.pid, ...fields }, null, 2)}\n`, { mode: 0o600 });
43
+ fs.renameSync(temporary, jobFile);
44
+ }
45
+ const finish = (state, fields = {}) => { record({ state, finishedAt: new Date().toISOString(), ...fields }); process.exit(state === "succeeded" ? 0 : 1); };
46
+
47
+ record({ state: "running" });
48
+ const log = fs.openSync(path.join(directory, "piagent-update-job.log"), "w", 0o600);
49
+ const env = { ...process.env, PIAGENT_NO_UPDATE_CHECK: "1" };
50
+ fs.writeSync(log, `Piagent update ${from} -> ${to} (${new Date().toISOString()})\n`);
51
+ const update = spawnSync(process.execPath, [updater, "--version", to], { stdio: ["ignore", log, log], env, timeout: 30 * 60 * 1000 });
52
+ if (update.status !== 0) finish("failed", { reason: "update-command-failed" });
53
+
54
+ let installed;
55
+ try { installed = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")).version; } catch { installed = undefined; }
56
+ if (installed !== to) finish("failed", { reason: "update-version-mismatch", ...(RELEASE.test(installed ?? "") ? { installed } : {}) });
57
+
58
+ // Agent Watch pins the company launcher it starts; a release that changed
59
+ // the launcher needs the binding imported again in Agent Watch.
60
+ let bindingChanged = false;
61
+ try {
62
+ const agentDir = process.env.PI_CODING_AGENT_DIR?.trim() || path.join(home, ".pi", "agent");
63
+ const binding = JSON.parse(fs.readFileSync(path.join(agentDir, "agent-watch-managed.json"), "utf8"));
64
+ if (typeof binding?.entrypoint === "string" && typeof binding?.entrypoint_sha256 === "string")
65
+ bindingChanged = crypto.createHash("sha256").update(fs.readFileSync(binding.entrypoint)).digest("hex") !== binding.entrypoint_sha256;
66
+ } catch { /* no company binding on this machine */ }
67
+
68
+ // The new dashboard opens in a new browser tab: the old tab's browser session
69
+ // ends with the old dashboard. Its launch link is never written to the log.
70
+ fs.writeSync(log, "\nRestarting the dashboard on the new release\n");
71
+ // Like the \`piagent\` command, with the package's TypeScript loader: an npm
72
+ // install sits under node_modules, where Node does not strip types itself.
73
+ const loader = path.join(packageRoot, "scripts", "register-typescript-loader.mjs");
74
+ const restart = spawnSync(process.execPath, ["--disable-warning=ExperimentalWarning", "--import", loader, dashboard, "restart", "--json"],
75
+ { stdio: ["ignore", "ignore", log], env, timeout: 3 * 60 * 1000 });
76
+ finish("succeeded", { installed, ...(bindingChanged ? { bindingChanged } : {}), ...(restart.status === 0 ? {} : { reason: "dashboard-restart-failed" }) });
@@ -155,6 +155,14 @@ async function waitForGatewayStopped(previous, timeoutMs = 8_000) {
155
155
  }
156
156
 
157
157
  async function ensureStarted() {
158
+ // After an update the Gateway still running is the earlier release, with
159
+ // its pages loaded: it is replaced rather than reused.
160
+ const running = readGatewayDescriptor(gatewayProfileState(agentDir));
161
+ if (running && processAlive(running.pid) && typeof running.packageVersion === "string" && running.packageVersion !== packageJson.version) {
162
+ process.stderr.write(`Piagent ${running.packageVersion} is still running; restarting the dashboard on ${packageJson.version}.\n`);
163
+ try { await control("stop"); } catch { /* already stopping */ }
164
+ await waitForGatewayStopped(running);
165
+ }
158
166
  const existing = await currentLaunchUrl() ?? await waitForRunningGateway();
159
167
  if (existing) return existing;
160
168
  const effectiveAgentDir = path.resolve(agentDir ?? process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent"));
@@ -526,6 +526,8 @@ function main() {
526
526
  console.log(options.dryRun
527
527
  ? `\nDRY RUN: nothing was installed. Rerun without --dry-run to apply v${targetHelper}.`
528
528
  : `\nPASS: updated to v${targetHelper}.`);
529
+ // A dashboard started before the update still serves the earlier release.
530
+ if (!options.dryRun && helperNeedsChange) console.log("Open the dashboard with `piagent dashboard`: one still running an earlier release is restarted on the new one.");
529
531
  }
530
532
 
531
533
  main();