@piagent/platform 1.9.3 → 1.11.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 (88) hide show
  1. package/CHANGELOG.md +44 -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/en/architecture.md +1 -1
  7. package/docs/languages.json +4 -4
  8. package/docs/managed-local-acceptance.md +35 -0
  9. package/docs/operator-manual-vietnamese.md +7 -7
  10. package/docs/publishing-for-teams.md +2 -0
  11. package/docs/release-install-policy.md +2 -2
  12. package/docs/team-onboarding.md +2 -2
  13. package/docs/vi/architecture.md +1 -1
  14. package/docs-site/architecture.html +1 -1
  15. package/docs-site/benchmark.html +1 -1
  16. package/docs-site/commands.html +1 -1
  17. package/docs-site/content/en/index.html +4 -4
  18. package/docs-site/content/en/whats-new.html +22 -0
  19. package/docs-site/content/index.html +4 -4
  20. package/docs-site/content/whats-new.html +22 -0
  21. package/docs-site/context-engine.html +1 -1
  22. package/docs-site/ecosystem.html +1 -1
  23. package/docs-site/en/architecture.html +1 -1
  24. package/docs-site/en/benchmark.html +1 -1
  25. package/docs-site/en/commands.html +1 -1
  26. package/docs-site/en/context-engine.html +1 -1
  27. package/docs-site/en/ecosystem.html +1 -1
  28. package/docs-site/en/index.html +5 -5
  29. package/docs-site/en/mcp-approval.html +1 -1
  30. package/docs-site/en/mcp-auth.html +1 -1
  31. package/docs-site/en/mcp-servers.html +1 -1
  32. package/docs-site/en/mcp.html +1 -1
  33. package/docs-site/en/permissions.html +1 -1
  34. package/docs-site/en/profiles.html +1 -1
  35. package/docs-site/en/quickstart.html +1 -1
  36. package/docs-site/en/runtime.html +1 -1
  37. package/docs-site/en/scope.html +1 -1
  38. package/docs-site/en/security.html +1 -1
  39. package/docs-site/en/team.html +1 -1
  40. package/docs-site/en/whats-new.html +23 -1
  41. package/docs-site/en/workflows.html +1 -1
  42. package/docs-site/index.html +5 -5
  43. package/docs-site/mcp-approval.html +1 -1
  44. package/docs-site/mcp-auth.html +1 -1
  45. package/docs-site/mcp-servers.html +1 -1
  46. package/docs-site/mcp.html +1 -1
  47. package/docs-site/permissions.html +1 -1
  48. package/docs-site/profiles.html +1 -1
  49. package/docs-site/quickstart.html +1 -1
  50. package/docs-site/runtime.html +1 -1
  51. package/docs-site/scope.html +1 -1
  52. package/docs-site/security.html +1 -1
  53. package/docs-site/team.html +1 -1
  54. package/docs-site/whats-new.html +23 -1
  55. package/docs-site/workflows.html +1 -1
  56. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  57. package/package.json +1 -1
  58. package/packages/piagent-core/managed/grant-share.mjs +18 -0
  59. package/packages/piagent-core/managed/helper-roles.mjs +8 -4
  60. package/packages/piagent-core/managed/helper-run.mjs +116 -0
  61. package/packages/piagent-core/managed/member-questions.d.mts +15 -0
  62. package/packages/piagent-core/managed/member-questions.mjs +178 -0
  63. package/packages/piagent-core/managed/objections.mjs +73 -0
  64. package/packages/piagent-core/managed/patch-snapshot.mjs +108 -22
  65. package/packages/piagent-core/managed/request-stream.mjs +54 -7
  66. package/packages/piagent-core/managed/review-snapshot-worker.mjs +31 -13
  67. package/packages/piagent-core/managed/session.mjs +33 -85
  68. package/packages/piagent-core/managed/web-search.mjs +23 -2
  69. package/packages/piagent-core/managed/workflow.mjs +137 -51
  70. package/packages/piagent-core/package.json +1 -1
  71. package/packages/piagent-core/runtime/managed-failure.mjs +6 -2
  72. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-IaGBsZNX.js → MarkdownMessage-D70xkf0d.js} +1 -1
  73. package/packages/piagent-webui/dist/client/assets/index-BuIJR5rd.js +228 -0
  74. package/packages/piagent-webui/dist/client/assets/{index-DgSmcnP8.css → index-Z91z6SlN.css} +1 -1
  75. package/packages/piagent-webui/dist/client/index.html +2 -2
  76. package/packages/piagent-webui/gateway/gateway-service.ts +15 -1
  77. package/packages/piagent-webui/gateway/managed-projection.ts +1 -1
  78. package/packages/piagent-webui/gateway/session-questions.ts +15 -0
  79. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +7 -2
  80. package/packages/piagent-webui/gateway/update-center.ts +201 -0
  81. package/packages/piagent-webui/server/http-security.ts +15 -1
  82. package/packages/piagent-webui/server/loopback-server.ts +9 -16
  83. package/packages/piagent-webui/server/member-routes.ts +99 -0
  84. package/packages/piagent-webui/server/transcript-projection.ts +17 -4
  85. package/schemas/piagent-webui/session-catalog-v1.schema.json +1 -1
  86. package/schemas/piagent-webui/transcript-v1.schema.json +29 -2
  87. package/scripts/dashboard-update-job.mjs +72 -0
  88. package/packages/piagent-webui/dist/client/assets/index-Cv5u0MN8.js +0 -228
@@ -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");
@@ -81,6 +68,9 @@ export async function startLoopbackServer(options: {
81
68
  executeControl?: (command: unknown) => unknown | Promise<unknown>;
82
69
  executeAttachment?: (command: unknown) => unknown | Promise<unknown>;
83
70
  executeApproval?: (approvalRef: string, decision: unknown) => unknown | Promise<unknown>;
71
+ readSessionQuestions?: (sessionRef: string) => unknown | Promise<unknown>;
72
+ answerSessionQuestion?: (sessionRef: string, questionRef: string, answer: unknown) => unknown | Promise<unknown>;
73
+ updates?: UpdateRoutes;
84
74
  bootstrapTtlMs?: number;
85
75
  sessionTtlMs?: number;
86
76
  }): Promise<LoopbackServer> {
@@ -302,6 +292,9 @@ export async function startLoopbackServer(options: {
302
292
  catch { return errorResponse(response, 503, "attachment-runtime-unavailable"); }
303
293
  }
304
294
 
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;
297
+
305
298
  if (request.method === "POST" && url.pathname.startsWith("/api/v1/approvals/") && url.pathname.endsWith("/decision") && options.executeApproval) {
306
299
  if (requestOrigin !== origin) return errorResponse(response, 403, "origin-required");
307
300
  const mutationSession = auth.authorizeMutation(request);
@@ -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
+ }
@@ -35,8 +35,10 @@ type TranscriptItem = { messageRef: string; parentMessageRef: string | null; rol
35
35
  toolCalls: Array<{ toolCallRef: string; toolName: string; state: "requested" | "completed" | "failed" | "unknown";
36
36
  summary?: ToolSummary; change?: ToolChange; result?: ToolResult }>; usage?: TurnUsage; model?: string; process?: HarnessProcess };
37
37
  type HarnessFinding = { severity: "blocking" | "major" | "minor"; file: string; line: number | null; issue: string };
38
- type HarnessProcess = { phase: "plan" | "verify" | "review" | "final"; outcome?: string; loop?: number; maxLoops?: number; verified?: boolean; reviewed?: boolean;
39
- blockingOpen?: number; planOpen?: number; planSkipped?: true; verifyPolicy?: string; reviewPolicy?: string; reviewUnavailable?: string; findings?: HarnessFinding[] };
38
+ type HarnessIssue = { kind: "wrong_premise" | "conflicts_with_request" | "ambiguous" | "out_of_scope"; detail: string };
39
+ type HarnessProcess = { phase: "plan" | "verify" | "review" | "final" | "objection" | "answer" | "claims" | "rejudge" | "dispute"; outcome?: string; loop?: number; maxLoops?: number;
40
+ verified?: boolean; reviewed?: boolean; blockingOpen?: number; planOpen?: number; planSkipped?: true; verifyPolicy?: string; reviewPolicy?: string; reviewUnavailable?: string;
41
+ findings?: HarnessFinding[]; role?: "scout" | "research" | "verify" | "review"; by?: "main" | "harness"; disputes?: number; unchanged?: true; issues?: HarnessIssue[]; claims?: { claim: string }[] };
40
42
  export type TranscriptDocument = { schemaVersion: 1; version: "piagent-webui-transcript-v1"; generatedAt: string; identity: TranscriptIdentity;
41
43
  revision: TranscriptRevision; eventCursor: string; state: "ready" | "unavailable"; items: TranscriptItem[];
42
44
  page: { beforeCursor: string | null; nextBeforeCursor: string | null; hasOlder: boolean; limit: number; truncated: boolean }; reasonCode: string | null };
@@ -163,10 +165,13 @@ function toolCalls(message: any, sessionRef: string, role: TranscriptItem["role"
163
165
  }
164
166
  // The harness's process notes ("agent-watch-process"): what it asked of the
165
167
  // agent before a code-changing turn ended, and how that turn ended.
166
- const PROCESS_OUTCOMES = ["no_change", "interrupted", "blocking_open", "unverified", "review_unavailable", "unreviewed", "clean"];
168
+ const PROCESS_OUTCOMES = ["no_change", "interrupted", "disputed", "blocking_open", "unverified", "review_unavailable", "unreviewed", "clean"];
169
+ // A helper's objection to its brief, a failed claim, a disagreement handed to the member.
170
+ const PROCESS_PHASES = ["plan", "verify", "review", "final", "objection", "answer", "claims", "rejudge", "dispute"];
171
+ const ISSUE_KINDS = ["wrong_premise", "conflicts_with_request", "ambiguous", "out_of_scope"], HELPER_ROLES = ["scout", "research", "verify", "review"];
167
172
  const POLICY_MODES = ["off", "suggest", "require"];
168
173
  function harnessProcess(details: any): HarnessProcess | null {
169
- if (!["plan", "verify", "review", "final"].includes(details?.phase)) return null;
174
+ if (!PROCESS_PHASES.includes(details?.phase)) return null;
170
175
  const loop = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 0 && (n as number) <= 3;
171
176
  const out: HarnessProcess = { phase: details.phase };
172
177
  if (PROCESS_OUTCOMES.includes(details.outcome)) out.outcome = details.outcome;
@@ -184,6 +189,14 @@ function harnessProcess(details: any): HarnessProcess | null {
184
189
  .filter((f: any) => ["blocking", "major", "minor"].includes(f?.severity) && typeof f.issue === "string" && f.issue.trim()).slice(0, 10)
185
190
  .map((f: any) => ({ severity: f.severity, file: safeText(String(f.file ?? "")).full.slice(0, 300), line: Number.isSafeInteger(f.line) && f.line > 0 ? f.line : null,
186
191
  issue: safeText(f.issue).full.slice(0, 500) }));
192
+ if (HELPER_ROLES.includes(details.role)) out.role = details.role;
193
+ if (["main", "harness"].includes(details.by)) out.by = details.by;
194
+ if (Number.isInteger(details.disputes) && details.disputes >= 1) out.disputes = Math.min(details.disputes, 1000);
195
+ if (details.unchanged === true) out.unchanged = true;
196
+ if (Array.isArray(details.issues)) out.issues = details.issues.filter((i: any) => ISSUE_KINDS.includes(i?.kind) && typeof i.detail === "string" && i.detail.trim()).slice(0, 10)
197
+ .map((i: any) => ({ kind: i.kind, detail: safeText(i.detail).full.slice(0, 400) }));
198
+ if (Array.isArray(details.claims)) out.claims = details.claims.filter((c: any) => typeof c?.claim === "string" && c.claim.trim()).slice(0, 10)
199
+ .map((c: any) => ({ claim: safeText(c.claim).full.slice(0, 500) }));
187
200
  return out;
188
201
  }
189
202
  function role(message: any): TranscriptItem["role"] | null {
@@ -97,7 +97,7 @@
97
97
  "steps": { "type": "array", "minItems": 1, "maxItems": 30, "items": { "type": "object", "additionalProperties": false, "required": ["step", "status"], "properties": {
98
98
  "step": { "type": "string", "minLength": 1, "maxLength": 200 }, "status": { "enum": ["pending", "in_progress", "completed"] } } } } } },
99
99
  "managedProcess": { "type": "object", "additionalProperties": false, "required": ["outcome", "verified", "reviewed", "blockingOpen"], "properties": {
100
- "outcome": { "enum": ["no_change", "interrupted", "blocking_open", "unverified", "review_unavailable", "unreviewed", "clean"] },
100
+ "outcome": { "enum": ["no_change", "interrupted", "disputed", "blocking_open", "unverified", "review_unavailable", "unreviewed", "clean"] },
101
101
  "verified": { "type": "boolean" }, "reviewed": { "type": "boolean" }, "blockingOpen": { "type": "integer", "minimum": 0, "maximum": 1000 }, "planSkipped": { "const": true } } },
102
102
  "modelLabel": { "$ref": "common-v1.schema.json#/$defs/nullableDisplayText" },
103
103
  "thinkingLevel": { "enum": ["off", "minimal", "low", "medium", "high", "xhigh", "max", "unknown"] },
@@ -157,8 +157,35 @@
157
157
  "additionalProperties": false,
158
158
  "required": ["phase"],
159
159
  "properties": {
160
- "phase": { "enum": ["plan", "verify", "review", "final"] },
161
- "outcome": { "enum": ["no_change", "interrupted", "blocking_open", "unverified", "review_unavailable", "unreviewed", "clean"] },
160
+ "phase": { "enum": ["plan", "verify", "review", "final", "objection", "answer", "claims", "rejudge", "dispute"] },
161
+ "outcome": { "enum": ["no_change", "interrupted", "disputed", "blocking_open", "unverified", "review_unavailable", "unreviewed", "clean"] },
162
+ "role": { "enum": ["scout", "research", "verify", "review"] },
163
+ "by": { "enum": ["main", "harness"] },
164
+ "disputes": { "type": "integer", "minimum": 1, "maximum": 1000 },
165
+ "unchanged": { "const": true },
166
+ "issues": {
167
+ "type": "array",
168
+ "maxItems": 10,
169
+ "items": {
170
+ "type": "object",
171
+ "additionalProperties": false,
172
+ "required": ["kind", "detail"],
173
+ "properties": {
174
+ "kind": { "enum": ["wrong_premise", "conflicts_with_request", "ambiguous", "out_of_scope"] },
175
+ "detail": { "type": "string", "maxLength": 400 }
176
+ }
177
+ }
178
+ },
179
+ "claims": {
180
+ "type": "array",
181
+ "maxItems": 10,
182
+ "items": {
183
+ "type": "object",
184
+ "additionalProperties": false,
185
+ "required": ["claim"],
186
+ "properties": { "claim": { "type": "string", "maxLength": 500 } }
187
+ }
188
+ },
162
189
  "loop": { "type": "integer", "minimum": 0, "maximum": 3 },
163
190
  "maxLoops": { "type": "integer", "minimum": 0, "maximum": 3 },
164
191
  "verified": { "type": "boolean" },
@@ -0,0 +1,72 @@
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
+ const restart = spawnSync(process.execPath, [dashboard, "restart", "--json"], { stdio: ["ignore", "ignore", log], env, timeout: 3 * 60 * 1000 });
72
+ finish("succeeded", { installed, ...(bindingChanged ? { bindingChanged } : {}), ...(restart.status === 0 ? {} : { reason: "dashboard-restart-failed" }) });