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.
- package/README.md +14 -3
- package/bin/pipane-rendezvous.js +21 -0
- package/bin/pipane.js +21 -1
- package/dist/client/assets/__vite-browser-external-DtLbhLGx.js +1 -0
- package/dist/client/assets/app-runtime-Rw-O-AwW.js +1 -0
- package/dist/client/assets/backend-landing-page-B7SbyIXQ.js +1 -0
- package/dist/client/assets/index-DwbBcYUf.js +2 -0
- package/dist/client/assets/index-iblQYBAl.css +1 -0
- package/dist/client/assets/main-LGItV9Aj.js +2807 -0
- package/dist/client/assets/pairing-page-D-WxlWZR.js +1 -0
- package/dist/client/assets/remote-backend-manager-BWuGh30I.js +1 -0
- package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
- package/dist/client/assets/webrtc-frame-transport-BijNnA7N.js +1 -0
- package/dist/client/assets/ws-agent-adapter-BRysf-Y7.js +6 -0
- package/dist/client/index.html +2 -2
- package/dist/server/rendezvous/rendezvous-hub.js +426 -0
- package/dist/server/rendezvous/server.js +247 -0
- package/dist/server/rendezvous/trust-store.js +432 -0
- package/dist/server/server/auth-guard.js +61 -0
- package/dist/server/server/backend-connection-authorizer.js +29 -0
- package/dist/server/server/backend-identity.js +167 -0
- package/dist/server/server/backend-protocol-handler.js +132 -0
- package/dist/server/server/backend-trust-store.js +157 -0
- package/dist/server/server/backend-webrtc.js +239 -0
- package/dist/server/server/conversation-file-access.js +97 -0
- package/dist/server/server/frame-connection.js +48 -0
- package/dist/server/server/frame-router.js +45 -0
- package/dist/server/server/ice-servers.js +24 -0
- package/dist/server/server/local-backend-api.js +389 -0
- package/dist/server/server/local-settings.js +17 -4
- package/dist/server/server/pi-rpc-protocol.js +407 -0
- package/dist/server/server/process-pool.js +27 -24
- package/dist/server/server/rendezvous-client.js +282 -0
- package/dist/server/server/rest-api.js +133 -176
- package/dist/server/server/server.js +163 -97
- package/dist/server/server/session-index.js +105 -28
- package/dist/server/server/session-jsonl.js +82 -5
- package/dist/server/server/session-path.js +145 -0
- package/dist/server/server/update-api.js +28 -0
- package/dist/server/server/update-check.js +33 -13
- package/dist/server/server/update-manager.js +231 -0
- package/dist/server/server/worktree-name.js +116 -31
- package/dist/server/server/ws-handler.js +267 -186
- package/dist/server/shared/backend-api.js +1 -0
- package/dist/server/shared/backend-protocol.js +164 -0
- package/dist/server/shared/node-trust-crypto.js +61 -0
- package/dist/server/shared/rendezvous-protocol.js +243 -0
- package/dist/server/shared/tool-runtime.js +1 -0
- package/dist/server/shared/trust-protocol.js +97 -0
- package/dist/server/shared/updates.js +4 -0
- package/dist/server/shared/ws-protocol.js +473 -1
- package/docs/protocol.md +127 -0
- package/package.json +21 -8
- package/dist/client/assets/index-Dl_wdLZH.css +0 -1
- 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,
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
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
|
|
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
|
|
7
|
+
headers,
|
|
12
8
|
});
|
|
13
|
-
|
|
14
|
-
if (!res.ok)
|
|
9
|
+
if (!response.ok)
|
|
15
10
|
return null;
|
|
16
|
-
|
|
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
|
+
}
|