pipane 0.1.6 → 0.1.7

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 (55) hide show
  1. package/README.md +14 -3
  2. package/bin/pipane-rendezvous.js +21 -0
  3. package/bin/pipane.js +21 -1
  4. package/dist/client/assets/__vite-browser-external-DtLbhLGx.js +1 -0
  5. package/dist/client/assets/app-runtime-Rw-O-AwW.js +1 -0
  6. package/dist/client/assets/backend-landing-page-B7SbyIXQ.js +1 -0
  7. package/dist/client/assets/index-DwbBcYUf.js +2 -0
  8. package/dist/client/assets/index-iblQYBAl.css +1 -0
  9. package/dist/client/assets/main-LGItV9Aj.js +2807 -0
  10. package/dist/client/assets/pairing-page-D-WxlWZR.js +1 -0
  11. package/dist/client/assets/remote-backend-manager-BWuGh30I.js +1 -0
  12. package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
  13. package/dist/client/assets/webrtc-frame-transport-BijNnA7N.js +1 -0
  14. package/dist/client/assets/ws-agent-adapter-BRysf-Y7.js +6 -0
  15. package/dist/client/index.html +2 -2
  16. package/dist/server/rendezvous/rendezvous-hub.js +426 -0
  17. package/dist/server/rendezvous/server.js +247 -0
  18. package/dist/server/rendezvous/trust-store.js +432 -0
  19. package/dist/server/server/auth-guard.js +61 -0
  20. package/dist/server/server/backend-connection-authorizer.js +29 -0
  21. package/dist/server/server/backend-identity.js +167 -0
  22. package/dist/server/server/backend-protocol-handler.js +132 -0
  23. package/dist/server/server/backend-trust-store.js +157 -0
  24. package/dist/server/server/backend-webrtc.js +239 -0
  25. package/dist/server/server/conversation-file-access.js +97 -0
  26. package/dist/server/server/frame-connection.js +48 -0
  27. package/dist/server/server/frame-router.js +45 -0
  28. package/dist/server/server/ice-servers.js +24 -0
  29. package/dist/server/server/local-backend-api.js +389 -0
  30. package/dist/server/server/local-settings.js +17 -4
  31. package/dist/server/server/pi-rpc-protocol.js +407 -0
  32. package/dist/server/server/process-pool.js +27 -24
  33. package/dist/server/server/rendezvous-client.js +282 -0
  34. package/dist/server/server/rest-api.js +133 -176
  35. package/dist/server/server/server.js +163 -97
  36. package/dist/server/server/session-index.js +105 -28
  37. package/dist/server/server/session-jsonl.js +82 -5
  38. package/dist/server/server/session-path.js +145 -0
  39. package/dist/server/server/update-api.js +28 -0
  40. package/dist/server/server/update-check.js +33 -13
  41. package/dist/server/server/update-manager.js +231 -0
  42. package/dist/server/server/worktree-name.js +116 -31
  43. package/dist/server/server/ws-handler.js +267 -186
  44. package/dist/server/shared/backend-api.js +1 -0
  45. package/dist/server/shared/backend-protocol.js +164 -0
  46. package/dist/server/shared/node-trust-crypto.js +61 -0
  47. package/dist/server/shared/rendezvous-protocol.js +243 -0
  48. package/dist/server/shared/tool-runtime.js +1 -0
  49. package/dist/server/shared/trust-protocol.js +97 -0
  50. package/dist/server/shared/updates.js +4 -0
  51. package/dist/server/shared/ws-protocol.js +473 -1
  52. package/docs/protocol.md +127 -0
  53. package/package.json +21 -8
  54. package/dist/client/assets/index-Dl_wdLZH.css +0 -1
  55. package/dist/client/assets/index-hNqbnG06.js +0 -2482
@@ -19,6 +19,31 @@ import { computeSyncOp } from "../shared/jsonl-sync.js";
19
19
  function computeHashSync(data) {
20
20
  return createHash("sha256").update(data, "utf8").digest("hex");
21
21
  }
22
+ function messageTimestamp(message) {
23
+ const timestamp = message.timestamp;
24
+ return typeof timestamp === "number" && Number.isFinite(timestamp) ? timestamp : undefined;
25
+ }
26
+ function inferToolCallTimings(messages) {
27
+ const timings = {};
28
+ for (const message of messages) {
29
+ if (message.role === "assistant") {
30
+ const startedAt = messageTimestamp(message);
31
+ if (startedAt === undefined)
32
+ continue;
33
+ for (const part of message.content) {
34
+ if (part.type === "toolCall")
35
+ timings[part.id] ??= { startedAt };
36
+ }
37
+ }
38
+ else if (message.role === "toolResult") {
39
+ const completedAt = messageTimestamp(message);
40
+ const timing = timings[message.toolCallId];
41
+ if (timing && completedAt !== undefined)
42
+ timing.completedAt = completedAt;
43
+ }
44
+ }
45
+ return timings;
46
+ }
22
47
  // ── SessionJsonl ───────────────────────────────────────────────────────────
23
48
  export class SessionJsonl {
24
49
  // ── Internal state (used to build the JSON) ────────────────────────────
@@ -26,6 +51,7 @@ export class SessionJsonl {
26
51
  _streamMessage = null;
27
52
  _pendingToolCalls = [];
28
53
  _partialToolResults = {};
54
+ _toolCallTimings;
29
55
  _model;
30
56
  _thinkingLevel;
31
57
  _steeringQueue = [];
@@ -37,6 +63,7 @@ export class SessionJsonl {
37
63
  _version = 1;
38
64
  constructor(init) {
39
65
  this._messages = init.messages;
66
+ this._toolCallTimings = init.toolCallTimings ?? inferToolCallTimings(init.messages);
40
67
  this._model = init.model;
41
68
  this._thinkingLevel = init.thinkingLevel;
42
69
  // Build initial JSON
@@ -78,6 +105,11 @@ export class SessionJsonl {
78
105
  this._streamMessage = null;
79
106
  this._pendingToolCalls = [];
80
107
  this._partialToolResults = {};
108
+ const inferredTimings = inferToolCallTimings(messages);
109
+ this._toolCallTimings = Object.fromEntries(Object.keys(inferredTimings).map((id) => [
110
+ id,
111
+ { ...inferredTimings[id], ...this._toolCallTimings[id] },
112
+ ]));
81
113
  this._version++;
82
114
  this.rebuildJson();
83
115
  }
@@ -135,6 +167,10 @@ export class SessionJsonl {
135
167
  const toolCallId = event.toolCallId;
136
168
  if (toolCallId && !this._pendingToolCalls.includes(toolCallId)) {
137
169
  this._pendingToolCalls = [...this._pendingToolCalls, toolCallId];
170
+ this._toolCallTimings = {
171
+ ...this._toolCallTimings,
172
+ [toolCallId]: { startedAt: Date.now() },
173
+ };
138
174
  this._version++;
139
175
  this.rebuildJson();
140
176
  return true;
@@ -156,6 +192,13 @@ export class SessionJsonl {
156
192
  const toolCallId = event.toolCallId;
157
193
  if (toolCallId) {
158
194
  this._pendingToolCalls = this._pendingToolCalls.filter(id => id !== toolCallId);
195
+ const timing = this._toolCallTimings[toolCallId];
196
+ if (timing && timing.completedAt === undefined) {
197
+ this._toolCallTimings = {
198
+ ...this._toolCallTimings,
199
+ [toolCallId]: { ...timing, completedAt: Date.now() },
200
+ };
201
+ }
159
202
  if (toolCallId in this._partialToolResults) {
160
203
  const { [toolCallId]: _, ...rest } = this._partialToolResults;
161
204
  this._partialToolResults = rest;
@@ -178,8 +221,11 @@ export class SessionJsonl {
178
221
  * @param clientVersion - The client's last known version
179
222
  * @returns SyncOp to send, or null if nothing changed
180
223
  */
181
- computeSyncOp(clientJson, clientHash, clientVersion) {
182
- if (this._version === clientVersion && clientHash === this._hash)
224
+ computeSyncOp(clientJson, clientHash, _clientVersion) {
225
+ // Some Pi lifecycle events advance the internal version without changing
226
+ // the serialized state. Do not emit a same-revision empty delta: wire
227
+ // revisions describe observable state changes, not internal event counts.
228
+ if (clientHash === this._hash)
183
229
  return null;
184
230
  return computeSyncOp(clientJson, this._json, clientHash, this._hash);
185
231
  }
@@ -216,6 +262,7 @@ export class SessionJsonl {
216
262
  messages,
217
263
  isStreaming: true,
218
264
  pendingToolCalls: this._pendingToolCalls,
265
+ toolCallTimings: this._toolCallTimings,
219
266
  model: this._model,
220
267
  thinkingLevel: this._thinkingLevel,
221
268
  steeringQueue: this._steeringQueue,
@@ -243,12 +290,39 @@ export class SessionJsonl {
243
290
  * keeps the completed marker where the user ran /compact while still omitting
244
291
  * the older messages replaced by the summary.
245
292
  */
246
- export function buildSessionDisplayMessages(entries) {
293
+ function buildSessionDisplayEntries(entries) {
247
294
  const sessionEntries = entries.filter((entry) => entry.type !== "session");
248
295
  const entryOrder = new Map(sessionEntries.map((entry, index) => [entry, index]));
249
296
  return [...buildContextEntries(sessionEntries)]
250
- .sort((left, right) => (entryOrder.get(left) ?? 0) - (entryOrder.get(right) ?? 0))
251
- .flatMap(sessionEntryToContextMessages);
297
+ .sort((left, right) => (entryOrder.get(left) ?? 0) - (entryOrder.get(right) ?? 0));
298
+ }
299
+ export function buildSessionDisplayMessages(entries) {
300
+ return buildSessionDisplayEntries(entries).flatMap(sessionEntryToContextMessages);
301
+ }
302
+ function buildSessionToolCallTimings(entries) {
303
+ const timings = {};
304
+ for (const entry of buildSessionDisplayEntries(entries)) {
305
+ if (entry.type !== "message")
306
+ continue;
307
+ const entryTimestamp = new Date(entry.timestamp).getTime();
308
+ const timestamp = Number.isFinite(entryTimestamp)
309
+ ? entryTimestamp
310
+ : messageTimestamp(entry.message);
311
+ if (timestamp === undefined)
312
+ continue;
313
+ if (entry.message.role === "assistant") {
314
+ for (const part of entry.message.content) {
315
+ if (part.type === "toolCall")
316
+ timings[part.id] ??= { startedAt: timestamp };
317
+ }
318
+ }
319
+ else if (entry.message.role === "toolResult") {
320
+ const timing = timings[entry.message.toolCallId];
321
+ if (timing)
322
+ timing.completedAt = timestamp;
323
+ }
324
+ }
325
+ return timings;
252
326
  }
253
327
  /**
254
328
  * Read a session from disk and return a serialized SessionState JSON + hash.
@@ -257,6 +331,7 @@ export function readSessionFromDisk(sessionPath) {
257
331
  let messages = [];
258
332
  let model = null;
259
333
  let thinkingLevel = "off";
334
+ let toolCallTimings = {};
260
335
  try {
261
336
  if (existsSync(sessionPath)) {
262
337
  const content = readFileSync(sessionPath, "utf8");
@@ -264,6 +339,7 @@ export function readSessionFromDisk(sessionPath) {
264
339
  const sessionEntries = entries.filter((entry) => entry.type !== "session");
265
340
  const context = buildSessionContext(sessionEntries);
266
341
  messages = buildSessionDisplayMessages(entries);
342
+ toolCallTimings = buildSessionToolCallTimings(entries);
267
343
  model = context.model ?? null;
268
344
  thinkingLevel = context.thinkingLevel ?? "off";
269
345
  }
@@ -275,6 +351,7 @@ export function readSessionFromDisk(sessionPath) {
275
351
  messages,
276
352
  isStreaming: false,
277
353
  pendingToolCalls: [],
354
+ toolCallTimings,
278
355
  model,
279
356
  thinkingLevel,
280
357
  steeringQueue: [],
@@ -0,0 +1,145 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ export class SessionPathError extends Error {
5
+ code;
6
+ constructor(message, code) {
7
+ super(message);
8
+ this.code = code;
9
+ this.name = "SessionPathError";
10
+ }
11
+ }
12
+ function isWithin(root, candidate) {
13
+ const relative = path.relative(root, candidate);
14
+ return relative !== ""
15
+ && relative !== ".."
16
+ && !relative.startsWith(`..${path.sep}`)
17
+ && !path.isAbsolute(relative);
18
+ }
19
+ function errorCode(error) {
20
+ return typeof error === "object" && error !== null && "code" in error
21
+ ? String(error.code)
22
+ : undefined;
23
+ }
24
+ /** Canonicalizes and confines all client-provided Pi session file paths. */
25
+ export class SessionPathGuard {
26
+ configuredRoot;
27
+ constructor(sessionsRoot = path.join(getAgentDir(), "sessions")) {
28
+ this.configuredRoot = path.resolve(sessionsRoot);
29
+ }
30
+ resolveExisting(value) {
31
+ const candidate = this.resolveLexical(value);
32
+ const canonicalRoot = this.resolveRoot();
33
+ let canonicalCandidate;
34
+ try {
35
+ canonicalCandidate = realpathSync(candidate);
36
+ }
37
+ catch (error) {
38
+ if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") {
39
+ throw new SessionPathError("Session file not found", "not_found");
40
+ }
41
+ throw new SessionPathError("Invalid session path", "invalid");
42
+ }
43
+ if (path.extname(canonicalCandidate) !== ".jsonl" || !isWithin(canonicalRoot, canonicalCandidate)) {
44
+ throw new SessionPathError("Session path escapes the Pi sessions directory", "invalid");
45
+ }
46
+ try {
47
+ if (!statSync(canonicalCandidate).isFile()) {
48
+ throw new SessionPathError("Session path is not a file", "invalid");
49
+ }
50
+ }
51
+ catch (error) {
52
+ if (error instanceof SessionPathError)
53
+ throw error;
54
+ if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") {
55
+ throw new SessionPathError("Session file not found", "not_found");
56
+ }
57
+ throw new SessionPathError("Invalid session path", "invalid");
58
+ }
59
+ return canonicalCandidate;
60
+ }
61
+ /**
62
+ * Canonicalizes a path allocated by Pi before Pi has flushed the new file.
63
+ * Its existing parent is still resolved so a symlinked directory cannot escape.
64
+ */
65
+ resolvePending(value) {
66
+ const candidate = this.resolveLexical(value);
67
+ try {
68
+ return this.resolveExisting(candidate);
69
+ }
70
+ catch (error) {
71
+ if (!(error instanceof SessionPathError) || error.code !== "not_found")
72
+ throw error;
73
+ }
74
+ const canonicalRoot = this.resolveRoot();
75
+ let canonicalParent;
76
+ try {
77
+ canonicalParent = realpathSync(path.dirname(candidate));
78
+ }
79
+ catch {
80
+ throw new SessionPathError("Session parent directory not found", "not_found");
81
+ }
82
+ if (canonicalParent !== canonicalRoot && !isWithin(canonicalRoot, canonicalParent)) {
83
+ throw new SessionPathError("Session path escapes the Pi sessions directory", "invalid");
84
+ }
85
+ try {
86
+ if (!statSync(canonicalParent).isDirectory()) {
87
+ throw new SessionPathError("Invalid session parent directory", "invalid");
88
+ }
89
+ }
90
+ catch (error) {
91
+ if (error instanceof SessionPathError)
92
+ throw error;
93
+ throw new SessionPathError("Invalid session parent directory", "invalid");
94
+ }
95
+ return path.join(canonicalParent, path.basename(candidate));
96
+ }
97
+ /** Returns a confined, canonical destination for a server-generated filename. */
98
+ createPath(filename) {
99
+ if (!filename || path.basename(filename) !== filename || path.extname(filename) !== ".jsonl") {
100
+ throw new SessionPathError("Invalid generated session filename", "invalid");
101
+ }
102
+ const canonicalRoot = this.resolveRoot();
103
+ const candidate = path.join(canonicalRoot, filename);
104
+ if (!isWithin(canonicalRoot, candidate)) {
105
+ throw new SessionPathError("Generated session path escapes the Pi sessions directory", "invalid");
106
+ }
107
+ return candidate;
108
+ }
109
+ resolveLexical(value) {
110
+ if (typeof value !== "string" || !value || value.includes("\0")) {
111
+ throw new SessionPathError("Missing or invalid session path", "invalid");
112
+ }
113
+ if (!path.isAbsolute(value)) {
114
+ throw new SessionPathError("Session path must be absolute", "invalid");
115
+ }
116
+ const candidate = path.resolve(value);
117
+ if (path.extname(candidate) !== ".jsonl" || !isWithin(this.configuredRoot, candidate)) {
118
+ throw new SessionPathError("Session path must be a .jsonl file within the Pi sessions directory", "invalid");
119
+ }
120
+ return candidate;
121
+ }
122
+ resolveRoot() {
123
+ let canonicalRoot;
124
+ try {
125
+ canonicalRoot = realpathSync(this.configuredRoot);
126
+ }
127
+ catch (error) {
128
+ if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") {
129
+ throw new SessionPathError("Pi sessions directory not found", "not_found");
130
+ }
131
+ throw new SessionPathError("Invalid Pi sessions directory", "invalid");
132
+ }
133
+ try {
134
+ if (!statSync(canonicalRoot).isDirectory()) {
135
+ throw new SessionPathError("Invalid Pi sessions directory", "invalid");
136
+ }
137
+ }
138
+ catch (error) {
139
+ if (error instanceof SessionPathError)
140
+ throw error;
141
+ throw new SessionPathError("Invalid Pi sessions directory", "invalid");
142
+ }
143
+ return canonicalRoot;
144
+ }
145
+ }
@@ -0,0 +1,28 @@
1
+ import { isUpdateTarget } from "../shared/updates.js";
2
+ export function registerUpdateApi(app, manager) {
3
+ app.get("/api/updates", async (_req, res) => {
4
+ try {
5
+ res.json(await manager.check());
6
+ }
7
+ catch (error) {
8
+ res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
9
+ }
10
+ });
11
+ app.post("/api/updates/:target", async (req, res) => {
12
+ if (req.get("X-Pipane-Action") !== "update") {
13
+ res.status(400).json({ error: "Missing update action header." });
14
+ return;
15
+ }
16
+ if (!isUpdateTarget(req.params.target)) {
17
+ res.status(400).json({ error: `Unknown update target: ${req.params.target}` });
18
+ return;
19
+ }
20
+ try {
21
+ const result = await manager.run(req.params.target);
22
+ res.json({ result, snapshot: manager.currentSnapshot });
23
+ }
24
+ catch (error) {
25
+ res.status(409).json({ error: error instanceof Error ? error.message : String(error) });
26
+ }
27
+ });
28
+ }
@@ -1,24 +1,44 @@
1
- /**
2
- * Check the npm registry for the latest version of a package.
3
- * Returns null if the check fails (network error, timeout, etc.)
4
- */
5
- export async function fetchLatestVersion(packageName, timeoutMs = 3000) {
1
+ async function fetchJson(url, timeoutMs, headers) {
2
+ const controller = new AbortController();
3
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
6
4
  try {
7
- const controller = new AbortController();
8
- const timer = setTimeout(() => controller.abort(), timeoutMs);
9
- const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`, {
5
+ const response = await fetch(url, {
10
6
  signal: controller.signal,
11
- headers: { Accept: "application/json" },
7
+ headers,
12
8
  });
13
- clearTimeout(timer);
14
- if (!res.ok)
9
+ if (!response.ok)
15
10
  return null;
16
- const data = (await res.json());
17
- return data.version ?? null;
11
+ return await response.json();
18
12
  }
19
13
  catch {
20
14
  return null;
21
15
  }
16
+ finally {
17
+ clearTimeout(timer);
18
+ }
19
+ }
20
+ /** Check the npm registry for the latest version of a package. */
21
+ export async function fetchLatestVersion(packageName, timeoutMs = 3000) {
22
+ const data = await fetchJson(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, timeoutMs, { Accept: "application/json" });
23
+ return typeof data?.version === "string" && data.version.trim() ? data.version.trim() : null;
24
+ }
25
+ /** Check Pi's release endpoint, including package migration metadata. */
26
+ export async function fetchLatestPiRelease(currentVersion, timeoutMs = 3000) {
27
+ const data = await fetchJson("https://pi.dev/api/latest-version", timeoutMs, {
28
+ Accept: "application/json",
29
+ "User-Agent": `pipane/${currentVersion}`,
30
+ });
31
+ if (typeof data?.version !== "string" || !data.version.trim())
32
+ return null;
33
+ return {
34
+ version: data.version.trim(),
35
+ ...(typeof data.packageName === "string" && data.packageName.trim()
36
+ ? { packageName: data.packageName.trim() }
37
+ : {}),
38
+ ...(typeof data.note === "string" && data.note.trim()
39
+ ? { note: data.note.trim() }
40
+ : {}),
41
+ };
22
42
  }
23
43
  /**
24
44
  * Compare two semver strings. Returns:
@@ -0,0 +1,231 @@
1
+ import { spawn } from "node:child_process";
2
+ import { DefaultPackageManager, SettingsManager, getAgentDir, } from "@earendil-works/pi-coding-agent";
3
+ import { compareSemver, fetchLatestPiRelease, fetchLatestVersion, } from "./update-check.js";
4
+ const UPDATE_COMMAND_TIMEOUT_MS = 10 * 60_000;
5
+ const MAX_COMMAND_OUTPUT = 16 * 1024;
6
+ const NO_UPDATE_MESSAGES = {
7
+ pipane: "pipane is already up to date.",
8
+ pi: "Pi is already up to date.",
9
+ extensions: "Pi packages are already up to date.",
10
+ };
11
+ function appendBounded(current, chunk) {
12
+ const next = current + chunk.toString();
13
+ return next.length > MAX_COMMAND_OUTPUT ? next.slice(-MAX_COMMAND_OUTPUT) : next;
14
+ }
15
+ export function runUpdateCommand(command, args, cwd, timeoutMs = UPDATE_COMMAND_TIMEOUT_MS) {
16
+ return new Promise((resolve, reject) => {
17
+ const child = spawn(command, args, {
18
+ cwd,
19
+ env: process.env,
20
+ stdio: ["ignore", "pipe", "pipe"],
21
+ });
22
+ let stdout = "";
23
+ let stderr = "";
24
+ let timedOut = false;
25
+ let settled = false;
26
+ const timer = setTimeout(() => {
27
+ timedOut = true;
28
+ child.kill("SIGTERM");
29
+ }, timeoutMs);
30
+ child.stdout?.on("data", (chunk) => { stdout = appendBounded(stdout, chunk); });
31
+ child.stderr?.on("data", (chunk) => { stderr = appendBounded(stderr, chunk); });
32
+ child.on("error", (error) => {
33
+ if (settled)
34
+ return;
35
+ settled = true;
36
+ clearTimeout(timer);
37
+ reject(new Error(`Failed to start ${command}: ${error.message}`));
38
+ });
39
+ child.on("close", (code, signal) => {
40
+ if (settled)
41
+ return;
42
+ settled = true;
43
+ clearTimeout(timer);
44
+ if (timedOut) {
45
+ reject(new Error(`Update command timed out after ${Math.round(timeoutMs / 1000)} seconds.`));
46
+ return;
47
+ }
48
+ if (code !== 0) {
49
+ const detail = (stderr || stdout).trim();
50
+ const suffix = detail ? `: ${detail}` : signal ? ` (signal ${signal})` : "";
51
+ reject(new Error(`Update command failed with exit code ${code ?? "unknown"}${suffix}`));
52
+ return;
53
+ }
54
+ resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
55
+ });
56
+ });
57
+ }
58
+ async function getInstalledPiVersion(launch, cwd) {
59
+ try {
60
+ const result = await runUpdateCommand(launch.command, [...launch.baseArgs, "--version"], cwd, 10_000);
61
+ const match = `${result.stdout}\n${result.stderr}`.match(/\bv?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/);
62
+ return match?.[1] ?? null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ async function checkManagedExtensionUpdates(cwd) {
69
+ if (process.env.PI_OFFLINE)
70
+ return [];
71
+ const agentDir = getAgentDir();
72
+ // RPC mode does not prompt for project trust. Check the user-level package set,
73
+ // which is shared by every Pi worker, without loading untrusted project config.
74
+ const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false });
75
+ const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
76
+ const updates = await packageManager.checkForAvailableUpdates();
77
+ return updates.map((update) => update.displayName);
78
+ }
79
+ function cloneSnapshot(snapshot) {
80
+ return {
81
+ checkedAt: snapshot.checkedAt,
82
+ notices: snapshot.notices.map((notice) => ({
83
+ ...notice,
84
+ ...(notice.packages ? { packages: [...notice.packages] } : {}),
85
+ })),
86
+ };
87
+ }
88
+ export class UpdateManager {
89
+ options;
90
+ dependencies;
91
+ snapshot = { checkedAt: new Date(0).toISOString(), notices: [] };
92
+ checkPromise;
93
+ activeUpdate;
94
+ constructor(options) {
95
+ this.options = options;
96
+ this.dependencies = {
97
+ fetchLatestVersion,
98
+ fetchLatestPiRelease,
99
+ getPiVersion: getInstalledPiVersion,
100
+ checkExtensionUpdates: checkManagedExtensionUpdates,
101
+ runCommand: runUpdateCommand,
102
+ ...options.dependencies,
103
+ };
104
+ }
105
+ get currentSnapshot() {
106
+ return cloneSnapshot(this.snapshot);
107
+ }
108
+ check() {
109
+ if (!this.checkPromise) {
110
+ this.checkPromise = this.performCheck();
111
+ }
112
+ return this.checkPromise.then(cloneSnapshot);
113
+ }
114
+ async performCheck() {
115
+ if (this.options.skipChecks || process.env.PIPANE_SKIP_UPDATE_CHECK || process.env.PI_OFFLINE) {
116
+ this.snapshot = { checkedAt: new Date().toISOString(), notices: [] };
117
+ return this.snapshot;
118
+ }
119
+ const pipaneCheck = this.checkPipaneUpdate();
120
+ const piCheck = process.env.PI_SKIP_VERSION_CHECK
121
+ ? Promise.resolve(null)
122
+ : this.checkPiUpdate();
123
+ const extensionCheck = this.checkExtensionUpdate();
124
+ const notices = (await Promise.all([pipaneCheck, piCheck, extensionCheck]))
125
+ .filter((notice) => notice !== null);
126
+ this.snapshot = { checkedAt: new Date().toISOString(), notices };
127
+ return this.snapshot;
128
+ }
129
+ async checkPipaneUpdate() {
130
+ try {
131
+ const latestVersion = await this.dependencies.fetchLatestVersion(this.options.pipanePackageName);
132
+ if (!latestVersion || compareSemver(this.options.pipaneVersion, latestVersion) >= 0)
133
+ return null;
134
+ return {
135
+ target: "pipane",
136
+ currentVersion: this.options.pipaneVersion,
137
+ latestVersion,
138
+ };
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ }
144
+ async checkPiUpdate() {
145
+ try {
146
+ const currentVersion = await this.dependencies.getPiVersion(this.options.piLaunch, this.options.cwd);
147
+ if (!currentVersion)
148
+ return null;
149
+ const release = await this.dependencies.fetchLatestPiRelease(currentVersion);
150
+ if (!release || compareSemver(currentVersion, release.version) >= 0)
151
+ return null;
152
+ return {
153
+ target: "pi",
154
+ currentVersion,
155
+ latestVersion: release.version,
156
+ };
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ }
162
+ async checkExtensionUpdate() {
163
+ try {
164
+ const packages = [...new Set(await this.dependencies.checkExtensionUpdates(this.options.cwd))]
165
+ .sort((left, right) => left.localeCompare(right));
166
+ return packages.length > 0 ? { target: "extensions", packages } : null;
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
172
+ async run(target) {
173
+ await this.check();
174
+ if (this.activeUpdate) {
175
+ throw new Error(`An update is already running for ${this.activeUpdate}.`);
176
+ }
177
+ const notice = this.snapshot.notices.find((candidate) => candidate.target === target);
178
+ if (!notice) {
179
+ return {
180
+ target,
181
+ message: NO_UPDATE_MESSAGES[target],
182
+ restartRequired: false,
183
+ };
184
+ }
185
+ this.activeUpdate = target;
186
+ try {
187
+ const result = await this.performUpdate(notice);
188
+ this.snapshot = {
189
+ checkedAt: new Date().toISOString(),
190
+ notices: this.snapshot.notices.filter((candidate) => candidate.target !== target),
191
+ };
192
+ return result;
193
+ }
194
+ finally {
195
+ this.activeUpdate = undefined;
196
+ }
197
+ }
198
+ async performUpdate(notice) {
199
+ switch (notice.target) {
200
+ case "pipane": {
201
+ if (!notice.latestVersion)
202
+ throw new Error("The pipane update has no target version.");
203
+ await this.dependencies.runCommand("npm", ["install", "-g", "--ignore-scripts", `${this.options.pipanePackageName}@${notice.latestVersion}`], this.options.cwd);
204
+ return {
205
+ target: "pipane",
206
+ message: `pipane v${notice.latestVersion} installed. Restart pipane to use the new version.`,
207
+ restartRequired: true,
208
+ };
209
+ }
210
+ case "pi": {
211
+ await this.dependencies.runCommand(this.options.piLaunch.command, [...this.options.piLaunch.baseArgs, "update", "--self"], this.options.cwd);
212
+ await this.options.onPiRuntimeChanged?.();
213
+ return {
214
+ target: "pi",
215
+ message: `Pi${notice.latestVersion ? ` v${notice.latestVersion}` : ""} installed. Pi workers are restarting.`,
216
+ restartRequired: false,
217
+ };
218
+ }
219
+ case "extensions": {
220
+ await this.dependencies.runCommand(this.options.piLaunch.command, [...this.options.piLaunch.baseArgs, "update", "--extensions"], this.options.cwd);
221
+ await this.options.onPiRuntimeChanged?.();
222
+ const count = notice.packages?.length ?? 0;
223
+ return {
224
+ target: "extensions",
225
+ message: `${count} Pi package${count === 1 ? "" : "s"} updated. Pi workers are restarting.`,
226
+ restartRequired: false,
227
+ };
228
+ }
229
+ }
230
+ }
231
+ }