@piagent/platform 1.11.0 → 1.12.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 (80) hide show
  1. package/CHANGELOG.md +34 -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 +22 -0
  16. package/docs-site/content/index.html +4 -4
  17. package/docs-site/content/whats-new.html +22 -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 +23 -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 +23 -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/extensions/piagent-guard.ts +11 -5
  56. package/packages/piagent-core/extensions/source-checkout-paths.ts +31 -0
  57. package/packages/piagent-core/managed/agent-skills.mjs +44 -0
  58. package/packages/piagent-core/managed/doctor.mjs +23 -2
  59. package/packages/piagent-core/managed/language-environment.mjs +118 -0
  60. package/packages/piagent-core/managed/resource-loader.mjs +5 -4
  61. package/packages/piagent-core/managed/sandbox-diagnostic.mjs +4 -1
  62. package/packages/piagent-core/managed/session.mjs +15 -3
  63. package/packages/piagent-core/managed/tool-boundary.mjs +110 -28
  64. package/packages/piagent-core/managed/tool-worker.mjs +3 -3
  65. package/packages/piagent-core/managed/toolchain.mjs +10 -8
  66. package/packages/piagent-core/package.json +1 -1
  67. package/packages/piagent-core/runtime/registration/agent-resources.ts +12 -0
  68. package/packages/piagent-core/runtime/resources/agent-resources.d.mts +6 -0
  69. package/packages/piagent-core/runtime/resources/agent-resources.mjs +140 -0
  70. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-D70xkf0d.js → MarkdownMessage-FWfOWZjV.js} +1 -1
  71. package/packages/piagent-webui/dist/client/assets/{index-BuIJR5rd.js → index-DFKHLVYk.js} +14 -14
  72. package/packages/piagent-webui/dist/client/index.html +1 -1
  73. package/packages/piagent-webui/gateway/gateway-service.ts +16 -0
  74. package/packages/piagent-webui/gateway/path-suggestions.ts +118 -0
  75. package/packages/piagent-webui/server/loopback-server.ts +2 -0
  76. package/packages/piagent-webui/server/member-routes.ts +22 -3
  77. package/packages/piagent-webui/server/transcript-projection.ts +7 -1
  78. package/scripts/dashboard-update-job.mjs +5 -1
  79. package/scripts/piagent-dashboard.mjs +8 -0
  80. package/scripts/update-global.mjs +2 -0
@@ -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-BuIJR5rd.js"></script>
10
+ <script type="module" crossorigin src="/assets/index-DFKHLVYk.js"></script>
11
11
  <link rel="stylesheet" crossorigin href="/assets/index-Z91z6SlN.css">
12
12
  </head>
13
13
  <body>
@@ -19,6 +19,8 @@ import {
19
19
  type GatewayDescriptor
20
20
  } from "./profile-state.ts";
21
21
  import { buildSessionCatalog, projectRefForCwd } from "./session-catalog.ts";
22
+ import { suggestPaths } from "./path-suggestions.ts";
23
+ import { listAgentCommands } from "../../piagent-core/runtime/resources/agent-resources.mjs";
22
24
  import { sessionRefForPath } from "../ownership/session-refs.ts";
23
25
  import { SessionMetadataStore } from "./session-metadata-store.ts";
24
26
  import { GatewayProtocolService } from "./gateway-protocol-service.ts";
@@ -222,6 +224,9 @@ export async function startPiagentGateway(options: {
222
224
  // Every revision the browser sees and sends back is the merged one.
223
225
  const hubCatalog = relay ? async () => relay!.merge(await readCatalog()) : readCatalog;
224
226
  const company = (sessionRef: unknown) => !!relay?.owns(sessionRef);
227
+ const projectFolder = async (projectRef: string) => projects.resolve(projectRef)
228
+ ?? (await runtimes!.listSessions()).find((info) => typeof info.cwd === "string" && projectRefForCwd(key, info.cwd) === projectRef)?.cwd
229
+ ?? (relay ? (await relay.folders()).find((folder) => projectRefForCwd(key, folder) === projectRef) : undefined);
225
230
  const relayPost = (path: string, body: unknown) => relay!.json("POST", path, body);
226
231
  const commands = new SessionCommandController({ catalog: hubCatalog, runtimes, metadata,
227
232
  store: new SessionCommandStore(state.root, key), events,
@@ -286,6 +291,17 @@ export async function startPiagentGateway(options: {
286
291
  // conversation runs (the company Gateway for a company conversation).
287
292
  readSessionQuestions: (sessionRef) => company(sessionRef)
288
293
  ? relay!.json("GET", `/api/v1/sessions/${encodeURIComponent(sessionRef)}/questions`) : runtimes!.questions(sessionRef),
294
+ // What an @ or a / in the composer offers, under a folder this dashboard
295
+ // knows: an imported project or the folder of a conversation (company
296
+ // ones too).
297
+ suggestPaths: async (projectRef, query) => {
298
+ const root = await projectFolder(projectRef);
299
+ return root ? await suggestPaths({ root, query }) : null;
300
+ },
301
+ listCommands: async (projectRef) => {
302
+ const root = await projectFolder(projectRef);
303
+ return root ? { commands: listAgentCommands({ cwd: root }) } : null;
304
+ },
289
305
  updates: { status: () => updates!.status(), check: () => updates!.check(), apply: (request) => updates!.apply(request) },
290
306
  answerSessionQuestion: (sessionRef, questionRef, answer) => company(sessionRef)
291
307
  ? relayPost(`/api/v1/sessions/${encodeURIComponent(sessionRef)}/questions/${encodeURIComponent(questionRef)}/answer`, answer)
@@ -0,0 +1,118 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ // What an @ in the composer offers, as Pi's terminal does: files and folders
7
+ // of the project matched loosely by name (fd, which honours .gitignore), and,
8
+ // after a folder and a slash (src/, ~/Documents/, /Volumes/), the names under
9
+ // that folder anywhere on this Mac. Only names reach the browser; no file is
10
+ // opened.
11
+ export type PathSuggestion = { value: string; label: string; detail: string; kind: "file" | "directory" };
12
+ type Entry = { path: string; directory: boolean };
13
+
14
+ const LIMIT = 20;
15
+ const SEARCH_RESULTS = 100;
16
+ const LISTED_ENTRIES = 5_000;
17
+ // Outside the project a search stays shallow and skips app data and
18
+ // dependency folders: a home folder is far larger than a repository.
19
+ const OUTSIDE_DEPTH = 4;
20
+ const fold = (value: string) => value.normalize("NFC").toLowerCase();
21
+ const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
22
+ // A name typed composed (Dự án) may be stored decomposed on disk: either form matches.
23
+ const either = (value: string) => value.normalize("NFC") === value.normalize("NFD") ? escape(value)
24
+ : `(?:${escape(value.normalize("NFC"))}|${escape(value.normalize("NFD"))})`;
25
+ const inside = (file: string, root: string) => file === root || file.startsWith(root + path.sep);
26
+
27
+ export function findFd(home = os.homedir()): string | null {
28
+ for (const candidate of ["/opt/homebrew/bin/fd", "/usr/local/bin/fd", path.join(home, ".pi/agent/bin/fd")]) {
29
+ try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch { /* next */ }
30
+ }
31
+ return null;
32
+ }
33
+
34
+ // Higher is better: the name itself, then its start, then anywhere in it,
35
+ // then anywhere in the path; a folder before a file with the same score.
36
+ function score(entry: string, query: string, directory: boolean): number {
37
+ const name = fold(path.basename(entry)), wanted = fold(query);
38
+ const value = name === wanted ? 100 : name.startsWith(wanted) ? 80 : name.includes(wanted) ? 50 : fold(entry).includes(wanted) ? 30 : 0;
39
+ return value && directory ? value + 10 : value;
40
+ }
41
+
42
+ function run(file: string, args: string[], signal?: AbortSignal, input?: string): Promise<string> {
43
+ return new Promise((resolve) => {
44
+ const child = execFile(file, args, { encoding: "utf8", timeout: 4_000, maxBuffer: 8 << 20, signal }, (error, stdout) => resolve(error ? "" : stdout));
45
+ child.stdin?.on("error", () => {}); child.stdin?.end(input ?? "");
46
+ });
47
+ }
48
+
49
+ // The folder's own names, without what its repository ignores (dist/,
50
+ // node_modules/), as fd lists them for Pi.
51
+ async function listed(folder: string, signal?: AbortSignal): Promise<Entry[]> {
52
+ const entries: Entry[] = [];
53
+ try {
54
+ for await (const entry of await fs.promises.opendir(folder)) {
55
+ let directory = entry.isDirectory();
56
+ if (entry.isSymbolicLink()) try { directory = fs.statSync(path.join(folder, entry.name)).isDirectory(); } catch { /* broken link */ }
57
+ entries.push({ path: entry.name, directory });
58
+ if (entries.length >= LISTED_ENTRIES) break;
59
+ }
60
+ } catch { /* unreadable */ }
61
+ if (!entries.length) return entries;
62
+ const ignored = new Set((await run("/usr/bin/git", ["-C", folder, "check-ignore", "-z", "--stdin"], signal, entries.map((entry) => `${entry.path}\0`).join("")))
63
+ .split("\0").filter(Boolean));
64
+ return ignored.size ? entries.filter((entry) => !ignored.has(entry.path)) : entries;
65
+ }
66
+
67
+ // Names deeper down that match: fd as Pi runs it in the project; shallow and
68
+ // without hidden folders elsewhere; without fd, the files git knows about.
69
+ async function searched(folder: string, query: string, options: { fd: string | null; project: boolean; atRoot: boolean; signal?: AbortSignal }): Promise<Entry[]> {
70
+ if (options.fd) {
71
+ const args = ["--base-directory", folder, "--type", "f", "--type", "d", "--max-results", String(SEARCH_RESULTS), "--ignore-case", "--exclude", ".git",
72
+ ...(options.project ? ["--follow", "--hidden"] : ["--max-depth", String(OUTSIDE_DEPTH), "--exclude", "Library", "--exclude", "node_modules", ...(query.startsWith(".") ? ["--hidden"] : [])]),
73
+ ...(query.includes("/") ? ["--full-path", query.split("/").filter(Boolean).map(either).join("/")] : [either(query)])];
74
+ return (await run(options.fd, args, options.signal)).split("\n").filter(Boolean)
75
+ .map((line) => ({ path: line.replace(/^\.\//, "").replace(/\/$/, ""), directory: line.endsWith("/") }));
76
+ }
77
+ if (!options.atRoot) return [];
78
+ const files = (await run("/usr/bin/git", ["-C", folder, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], options.signal))
79
+ .split("\0").filter(Boolean).slice(0, 50_000);
80
+ const folders = new Set<string>();
81
+ for (const file of files) for (let dir = path.posix.dirname(file); dir !== "."; dir = path.posix.dirname(dir)) folders.add(dir);
82
+ const wanted = fold(query);
83
+ return [...[...folders].map((dir) => ({ path: dir, directory: true })), ...files.map((file) => ({ path: file, directory: false }))]
84
+ .filter((entry) => fold(entry.path).includes(wanted));
85
+ }
86
+
87
+ export async function suggestPaths(options: { root: string; query: string; home?: string; fd?: string | null; signal?: AbortSignal }): Promise<{ suggestions: PathSuggestion[] }> {
88
+ const home = options.home ?? os.homedir(), fd = options.fd === undefined ? findFd(home) : options.fd;
89
+ const root = fs.realpathSync(options.root);
90
+ const typed = options.query.trim() === "~" ? "~/" : options.query.trim();
91
+ // Text up to the last slash names a folder: search under it (Pi's scoped
92
+ // search). When it is not a folder, the whole text is matched in the project.
93
+ const cut = typed.lastIndexOf("/") + 1;
94
+ let base = typed.slice(0, cut), name = typed.slice(cut), folder = root;
95
+ if (base) {
96
+ try {
97
+ const candidate = fs.realpathSync(base.startsWith("~/") ? path.join(home, base.slice(2)) : path.resolve(root, base));
98
+ if (fs.statSync(candidate).isDirectory()) folder = candidate; else { base = ""; name = typed; }
99
+ } catch { base = ""; name = typed; }
100
+ }
101
+ const project = inside(folder, root);
102
+ const near = await listed(folder, options.signal);
103
+ const deep = name ? await searched(folder, name, { fd, project, atRoot: folder === root, signal: options.signal }) : [];
104
+ const seen = new Set<string>(), entries: Array<Entry & { score: number }> = [];
105
+ for (const entry of [...near, ...deep]) {
106
+ if (seen.has(entry.path)) continue; seen.add(entry.path);
107
+ // Hidden entries (.github, .ssh, .zshrc) for a name typed with a dot, and
108
+ // in the project for any name typed, as Pi's search finds them there.
109
+ if (entry.path.split("/").some((part) => part.startsWith(".")) && !name.startsWith(".") && !(project && name)) continue;
110
+ const value = name ? score(entry.path, name.split("/").filter(Boolean).at(-1) ?? name, entry.directory) : 1;
111
+ if (value > 0) entries.push({ ...entry, score: value });
112
+ }
113
+ const depth = (entry: Entry) => entry.path.split("/").length;
114
+ entries.sort((a, b) => b.score - a.score || depth(a) - depth(b)
115
+ || (name ? a.path.length - b.path.length : Number(b.directory) - Number(a.directory)) || a.path.localeCompare(b.path));
116
+ return { suggestions: entries.slice(0, LIMIT).map((entry) => ({ value: `${base}${entry.path}${entry.directory ? "/" : ""}`,
117
+ label: path.basename(entry.path) + (entry.directory ? "/" : ""), detail: `${base}${entry.path}`, kind: entry.directory ? "directory" : "file" })) };
118
+ }
@@ -70,6 +70,8 @@ export async function startLoopbackServer(options: {
70
70
  executeApproval?: (approvalRef: string, decision: unknown) => unknown | Promise<unknown>;
71
71
  readSessionQuestions?: (sessionRef: string) => unknown | Promise<unknown>;
72
72
  answerSessionQuestion?: (sessionRef: string, questionRef: string, answer: unknown) => unknown | Promise<unknown>;
73
+ suggestPaths?: (projectRef: string, query: string) => unknown | Promise<unknown>;
74
+ listCommands?: (projectRef: string) => unknown | Promise<unknown>;
73
75
  updates?: UpdateRoutes;
74
76
  bootstrapTtlMs?: number;
75
77
  sessionTtlMs?: number;
@@ -4,12 +4,14 @@ import { errorResponse, jsonResponse, requestBody } from "./http-security.ts";
4
4
  import type { SessionAuthority } from "./session-auth.ts";
5
5
 
6
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.
7
+ // questions, what an @ or a / in the composer offers, and keeping
8
+ // Piagent itself up to date. Reads need the browser session; every change
9
+ // also needs this origin, the session's CSRF token and a share of the control
10
+ // rate.
10
11
  const MAX_CONTROL_BODY_BYTES = 70_000;
11
12
  const CURSOR = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/;
12
13
  const QUESTION_PATH = /^\/api\/v1\/sessions\/([^/]+)\/questions(?:\/([^/]+)\/answer)?$/;
14
+ const PROJECT_PATH = /^\/api\/v1\/projects\/([^/]+)\/(paths|commands)$/;
13
15
 
14
16
  export type UpdateRoutes = {
15
17
  // What is installed, what is available, and the last update run.
@@ -27,6 +29,11 @@ type Context = {
27
29
  type Options = {
28
30
  readSessionQuestions?: (sessionRef: string) => unknown | Promise<unknown>;
29
31
  answerSessionQuestion?: (sessionRef: string, questionRef: string, answer: unknown) => unknown | Promise<unknown>;
32
+ // Names under the project (or the folder typed after ~/ or /); null when the
33
+ // project is not one this dashboard knows.
34
+ suggestPaths?: (projectRef: string, query: string) => unknown | Promise<unknown>;
35
+ // The project's and the member's commands and skills; null as above.
36
+ listCommands?: (projectRef: string) => unknown | Promise<unknown>;
30
37
  updates?: UpdateRoutes;
31
38
  };
32
39
 
@@ -76,6 +83,18 @@ export async function routeMemberRequest(context: Context, options: Options): Pr
76
83
  }
77
84
  return true;
78
85
  }
86
+ const project = request.method === "GET" ? PROJECT_PATH.exec(url.pathname) : null;
87
+ const read = project?.[2] === "paths" ? options.suggestPaths : project ? options.listCommands : undefined;
88
+ if (project && read) {
89
+ if (!auth.authenticate(request)) { errorResponse(response, 401, "authentication-required"); return true; }
90
+ const projectRef = decodeURIComponent(project[1]), query = url.searchParams.get("query") ?? "";
91
+ if (!CURSOR.test(projectRef) || query.length > 512 || /[\x00-\x1f]/.test(query)) { errorResponse(response, 400, `invalid-${project[2] === "paths" ? "path" : "command"}-query`); return true; }
92
+ try {
93
+ const value = await read(projectRef, query);
94
+ if (value === null) errorResponse(response, 404, "project-not-found"); else jsonResponse(response, 200, value);
95
+ } catch { errorResponse(response, 503, `${project[2] === "paths" ? "path-suggestions" : "commands"}-unavailable`); }
96
+ return true;
97
+ }
79
98
 
80
99
  if (!options.updates || !url.pathname.startsWith("/api/v1/updates")) return false;
81
100
  const updates = options.updates;
@@ -98,7 +98,13 @@ function userMessageProjection(message: any): { text: string; attachments: Trans
98
98
  // hashing them here would turn the chat bubble into a document dump and make
99
99
  // a bounded UI digest an oracle over user files. The first block is the exact
100
100
  // text the operator typed; the remaining recognized blocks become cards.
101
- return { text: attachments.length ? String(textParts[0]?.text ?? "") : messageText(message), attachments: attachments.slice(0, 4) };
101
+ return { text: typedSkillCall(attachments.length ? String(textParts[0]?.text ?? "") : messageText(message)), attachments: attachments.slice(0, 4) };
102
+ }
103
+ // A skill the member called (/skill:name): the message holds the skill's
104
+ // instructions for the model; the chat shows what was typed.
105
+ function typedSkillCall(text: string): string {
106
+ const skill = /^<skill name="([^"\n]+)" location="[^"\n]+">\n[\s\S]*?\n<\/skill>(?:\n\n([\s\S]+))?$/.exec(text);
107
+ return skill ? `/skill:${skill[1]}${skill[2] ? ` ${skill[2]}` : ""}` : text;
102
108
  }
103
109
  // A company request fails with Studio's (or Agent Watch's) own code: who
104
110
  // failed, which kind of failure, and Studio's request id. Text is never shown.
@@ -68,5 +68,9 @@ try {
68
68
  // The new dashboard opens in a new browser tab: the old tab's browser session
69
69
  // ends with the old dashboard. Its launch link is never written to the log.
70
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 });
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 });
72
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();