pipane 0.1.5 → 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 +45 -1
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/bin/pipane-rendezvous.js +21 -0
- package/bin/pipane.js +21 -1
- package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- 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/bundled-extensions.js +6 -0
- package/dist/server/server/conversation-file-access.js +97 -0
- package/dist/server/server/extension-status.js +31 -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 +28 -0
- package/dist/server/server/pi-rpc-protocol.js +407 -0
- package/dist/server/server/pi-runtime.js +1 -1
- package/dist/server/server/process-pool.js +179 -136
- package/dist/server/server/rendezvous-client.js +282 -0
- package/dist/server/server/rest-api.js +121 -231
- package/dist/server/server/server.js +188 -134
- package/dist/server/server/session-actor.js +202 -0
- package/dist/server/server/session-index.js +111 -18
- package/dist/server/server/session-jsonl.js +128 -8
- package/dist/server/server/session-path.js +145 -0
- package/dist/server/server/session-registry.js +67 -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 +124 -0
- package/dist/server/server/ws-handler.js +784 -478
- package/dist/server/shared/backend-api.js +1 -0
- package/dist/server/shared/backend-protocol.js +164 -0
- package/dist/server/shared/jsonl-sync.js +41 -0
- package/dist/server/shared/node-trust-crypto.js +61 -0
- package/dist/server/shared/rendezvous-protocol.js +243 -0
- package/dist/server/shared/rpc-timeouts.js +4 -0
- package/dist/server/shared/thinking-levels.js +94 -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 -0
- package/docs/protocol.md +127 -0
- package/extensions/canvas.ts +1 -1
- package/package.json +42 -17
- package/dist/client/assets/_node-stub_node_crypto-C_7epg3G.js +0 -1
- package/dist/client/assets/_node-stub_node_fs-B-VOzeXW.js +0 -1
- package/dist/client/assets/_node-stub_node_http-CROXaVGJ.js +0 -1
- package/dist/client/assets/_node-stub_node_os-TTKJha15.js +0 -1
- package/dist/client/assets/_node-stub_node_path-DJCJiKv7.js +0 -1
- package/dist/client/assets/_node-stub_stream-DDexIdn_.js +0 -1
- package/dist/client/assets/index-BtDAXAo1.css +0 -1
- package/dist/client/assets/index-CAQqlK61.js +0 -131
- package/dist/client/assets/index-CJEeBmBj.js +0 -3
- package/dist/client/assets/index-DUEhKmPQ.js +0 -1
- package/dist/client/assets/index-XEVKscP-.js +0 -4443
- package/dist/client/assets/index-woI4agCy.js +0 -45
- package/dist/server/server/attached-session.js +0 -209
- package/dist/server/server/load-trace-store.js +0 -48
- package/dist/server/server/session-lifecycle.js +0 -155
- package/patches/@mariozechner+pi-web-ui+0.55.3.patch +0 -3437
|
@@ -1,19 +1,32 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { getAgentDir, parseSessionEntries } from "@
|
|
3
|
+
import { getAgentDir, parseSessionEntries } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { createWorktreeNameResolver, } from "./worktree-name.js";
|
|
4
5
|
const CACHE_FORMAT_VERSION = 1;
|
|
5
|
-
const DEFAULT_EXTRACTOR_VERSION = "
|
|
6
|
+
const DEFAULT_EXTRACTOR_VERSION = "3";
|
|
7
|
+
const MAX_RECENT_TOOL_PATHS = 16;
|
|
8
|
+
const PATH_ACTIVITY_TOOLS = new Set([
|
|
9
|
+
"edit",
|
|
10
|
+
"hypa_find",
|
|
11
|
+
"hypa_grep",
|
|
12
|
+
"hypa_ls",
|
|
13
|
+
"hypa_read",
|
|
14
|
+
"read",
|
|
15
|
+
"write",
|
|
16
|
+
]);
|
|
6
17
|
export class SessionIndex {
|
|
7
18
|
agentDir;
|
|
8
19
|
extractorVersion;
|
|
9
20
|
cacheFilePath;
|
|
10
21
|
cwdDisplayFormatter;
|
|
22
|
+
worktreeNameResolver;
|
|
11
23
|
inMemoryCache;
|
|
12
24
|
constructor(opts) {
|
|
13
25
|
this.agentDir = opts?.agentDir ?? getAgentDir();
|
|
14
26
|
this.extractorVersion = opts?.extractorVersion ?? DEFAULT_EXTRACTOR_VERSION;
|
|
15
27
|
this.cacheFilePath = path.join(this.agentDir, "cache", "pipane-session-index-v1.json");
|
|
16
28
|
this.cwdDisplayFormatter = opts?.cwdDisplayFormatter;
|
|
29
|
+
this.worktreeNameResolver = opts?.worktreeNameResolver;
|
|
17
30
|
}
|
|
18
31
|
async listSessions() {
|
|
19
32
|
const files = this.listSessionFiles();
|
|
@@ -38,17 +51,18 @@ export class SessionIndex {
|
|
|
38
51
|
sessions.push(cached.meta);
|
|
39
52
|
continue;
|
|
40
53
|
}
|
|
41
|
-
const
|
|
42
|
-
if (!
|
|
54
|
+
const extracted = this.extractSessionMeta(sessionPath, stat.mtimeMs);
|
|
55
|
+
if (!extracted) {
|
|
43
56
|
mutated = true;
|
|
44
57
|
continue;
|
|
45
58
|
}
|
|
46
59
|
nextEntries[sessionPath] = {
|
|
47
60
|
fileMtimeMs: stat.mtimeMs,
|
|
48
61
|
fileSize: stat.size,
|
|
49
|
-
meta,
|
|
62
|
+
meta: extracted.meta,
|
|
63
|
+
recentToolPaths: extracted.recentToolPaths,
|
|
50
64
|
};
|
|
51
|
-
sessions.push(meta);
|
|
65
|
+
sessions.push(extracted.meta);
|
|
52
66
|
mutated = true;
|
|
53
67
|
}
|
|
54
68
|
if (!mutated) {
|
|
@@ -80,7 +94,20 @@ export class SessionIndex {
|
|
|
80
94
|
// on read-only agent dirs or transient filesystem errors.
|
|
81
95
|
}
|
|
82
96
|
}
|
|
83
|
-
|
|
97
|
+
// A default resolver is scoped to one listing: its filesystem lookups are
|
|
98
|
+
// shared by every session, but discarded before the next request so newly
|
|
99
|
+
// created or removed worktrees are reflected immediately.
|
|
100
|
+
const worktreeNameResolver = this.worktreeNameResolver ?? createWorktreeNameResolver();
|
|
101
|
+
return sessions.map((session) => {
|
|
102
|
+
let worktreeName = "root";
|
|
103
|
+
try {
|
|
104
|
+
worktreeName = worktreeNameResolver(session.cwd, nextEntries[session.path]?.recentToolPaths ?? []) || "root";
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
worktreeName = "root";
|
|
108
|
+
}
|
|
109
|
+
return { ...session, worktreeName };
|
|
110
|
+
});
|
|
84
111
|
}
|
|
85
112
|
async invalidateAll() {
|
|
86
113
|
this.inMemoryCache = null;
|
|
@@ -125,11 +152,14 @@ export class SessionIndex {
|
|
|
125
152
|
const header = entries[0];
|
|
126
153
|
if (header?.type !== "session" || typeof header.id !== "string")
|
|
127
154
|
return null;
|
|
155
|
+
const cwd = typeof header.cwd === "string" ? header.cwd : "";
|
|
128
156
|
let name;
|
|
129
157
|
let messageCount = 0;
|
|
130
158
|
let firstMessage = "";
|
|
131
159
|
let lastActivityTime;
|
|
132
160
|
let lastUserPromptTimeMs = 0;
|
|
161
|
+
const pendingToolPaths = new Map();
|
|
162
|
+
const recentToolPaths = [];
|
|
133
163
|
for (const entry of entries) {
|
|
134
164
|
if (entry?.type === "session_info" && typeof entry.name === "string") {
|
|
135
165
|
const trimmed = entry.name.trim();
|
|
@@ -142,6 +172,19 @@ export class SessionIndex {
|
|
|
142
172
|
const msg = entry.message;
|
|
143
173
|
if (!msg || typeof msg.role !== "string" || !Object.prototype.hasOwnProperty.call(msg, "content"))
|
|
144
174
|
continue;
|
|
175
|
+
if (msg.role === "assistant") {
|
|
176
|
+
this.rememberPendingToolPaths(msg.content, cwd, pendingToolPaths);
|
|
177
|
+
}
|
|
178
|
+
else if (msg.role === "toolResult" && typeof msg.toolCallId === "string") {
|
|
179
|
+
const completedPaths = pendingToolPaths.get(msg.toolCallId);
|
|
180
|
+
pendingToolPaths.delete(msg.toolCallId);
|
|
181
|
+
if (msg.isError !== true && completedPaths) {
|
|
182
|
+
recentToolPaths.push(...completedPaths);
|
|
183
|
+
if (recentToolPaths.length > MAX_RECENT_TOOL_PATHS) {
|
|
184
|
+
recentToolPaths.splice(0, recentToolPaths.length - MAX_RECENT_TOOL_PATHS);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
145
188
|
if (msg.role !== "user" && msg.role !== "assistant")
|
|
146
189
|
continue;
|
|
147
190
|
const messageTs = typeof msg.timestamp === "number" ? msg.timestamp : undefined;
|
|
@@ -170,24 +213,74 @@ export class SessionIndex {
|
|
|
170
213
|
return new Date(createdMs);
|
|
171
214
|
return new Date(statMtimeMs);
|
|
172
215
|
})();
|
|
173
|
-
const cwd = typeof header.cwd === "string" ? header.cwd : "";
|
|
174
216
|
return {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
217
|
+
meta: {
|
|
218
|
+
id: header.id,
|
|
219
|
+
path: sessionPath,
|
|
220
|
+
cwd,
|
|
221
|
+
cwdDisplay: cwd && this.cwdDisplayFormatter ? this.cwdDisplayFormatter(cwd) : cwd,
|
|
222
|
+
name,
|
|
223
|
+
created: created.toISOString(),
|
|
224
|
+
modified: modified.toISOString(),
|
|
225
|
+
lastUserPromptTime: lastUserPromptTimeMs > 0 ? new Date(lastUserPromptTimeMs).toISOString() : undefined,
|
|
226
|
+
messageCount,
|
|
227
|
+
firstMessage: firstMessage || "(no messages)",
|
|
228
|
+
},
|
|
229
|
+
recentToolPaths,
|
|
185
230
|
};
|
|
186
231
|
}
|
|
187
232
|
catch {
|
|
188
233
|
return null;
|
|
189
234
|
}
|
|
190
235
|
}
|
|
236
|
+
rememberPendingToolPaths(content, cwd, pendingToolPaths) {
|
|
237
|
+
if (!Array.isArray(content))
|
|
238
|
+
return;
|
|
239
|
+
for (const chunk of content) {
|
|
240
|
+
if (chunk?.type !== "toolCall" || typeof chunk.id !== "string")
|
|
241
|
+
continue;
|
|
242
|
+
pendingToolPaths.set(chunk.id, this.extractToolPaths(chunk.name, chunk.arguments, cwd));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
extractToolPaths(toolName, rawArguments, cwd) {
|
|
246
|
+
if (typeof toolName !== "string")
|
|
247
|
+
return [];
|
|
248
|
+
const args = this.parseToolArguments(rawArguments);
|
|
249
|
+
if (!args)
|
|
250
|
+
return [];
|
|
251
|
+
if (Array.isArray(args.tool_uses)) {
|
|
252
|
+
return args.tool_uses.flatMap((toolUse) => this.extractToolPaths(toolUse?.recipient_name, toolUse?.parameters, cwd));
|
|
253
|
+
}
|
|
254
|
+
const shortName = toolName.toLowerCase().split(".").pop() ?? "";
|
|
255
|
+
if (!PATH_ACTIVITY_TOOLS.has(shortName))
|
|
256
|
+
return [];
|
|
257
|
+
const candidate = typeof args.path === "string"
|
|
258
|
+
? args.path
|
|
259
|
+
: typeof args.file_path === "string"
|
|
260
|
+
? args.file_path
|
|
261
|
+
: "";
|
|
262
|
+
if (!candidate || candidate.includes("\0"))
|
|
263
|
+
return [];
|
|
264
|
+
if (!path.isAbsolute(candidate) && !cwd)
|
|
265
|
+
return [];
|
|
266
|
+
return [path.resolve(cwd || path.parse(candidate).root, candidate)];
|
|
267
|
+
}
|
|
268
|
+
parseToolArguments(rawArguments) {
|
|
269
|
+
if (rawArguments && typeof rawArguments === "object" && !Array.isArray(rawArguments)) {
|
|
270
|
+
return rawArguments;
|
|
271
|
+
}
|
|
272
|
+
if (typeof rawArguments !== "string")
|
|
273
|
+
return null;
|
|
274
|
+
try {
|
|
275
|
+
const parsed = JSON.parse(rawArguments);
|
|
276
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
277
|
+
? parsed
|
|
278
|
+
: null;
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
191
284
|
extractTextContent(content) {
|
|
192
285
|
if (typeof content === "string")
|
|
193
286
|
return content;
|
|
@@ -12,13 +12,38 @@
|
|
|
12
12
|
* from jsonl-sync.ts.
|
|
13
13
|
*/
|
|
14
14
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
15
|
-
import {
|
|
15
|
+
import { buildContextEntries, buildSessionContext, parseSessionEntries, sessionEntryToContextMessages, } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { createHash } from "node:crypto";
|
|
17
17
|
import { computeSyncOp } from "../shared/jsonl-sync.js";
|
|
18
18
|
/** Synchronous SHA-256 hash (server-only, uses node:crypto). */
|
|
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
|
|
@@ -52,8 +79,21 @@ export class SessionJsonl {
|
|
|
52
79
|
this._steeringQueue = q;
|
|
53
80
|
this.rebuildJson();
|
|
54
81
|
}
|
|
55
|
-
// Expose
|
|
82
|
+
// Expose controls for ws-handler reconciliation.
|
|
56
83
|
get model() { return this._model; }
|
|
84
|
+
get thinkingLevel() { return this._thinkingLevel; }
|
|
85
|
+
/** Atomically replace the effective model/thinking state reported by pi. */
|
|
86
|
+
setControlState(model, thinkingLevel) {
|
|
87
|
+
const sameModel = this._model?.provider === model?.provider
|
|
88
|
+
&& this._model?.modelId === model?.modelId;
|
|
89
|
+
if (sameModel && this._thinkingLevel === thinkingLevel)
|
|
90
|
+
return false;
|
|
91
|
+
this._model = model;
|
|
92
|
+
this._thinkingLevel = thinkingLevel;
|
|
93
|
+
this._version++;
|
|
94
|
+
this.rebuildJson();
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
57
97
|
// Expose messages for post-detach snapshot
|
|
58
98
|
get messages() { return this._messages; }
|
|
59
99
|
/**
|
|
@@ -65,6 +105,11 @@ export class SessionJsonl {
|
|
|
65
105
|
this._streamMessage = null;
|
|
66
106
|
this._pendingToolCalls = [];
|
|
67
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
|
+
]));
|
|
68
113
|
this._version++;
|
|
69
114
|
this.rebuildJson();
|
|
70
115
|
}
|
|
@@ -109,10 +154,23 @@ export class SessionJsonl {
|
|
|
109
154
|
}
|
|
110
155
|
return false;
|
|
111
156
|
}
|
|
157
|
+
case "thinking_level_changed": {
|
|
158
|
+
const level = event.level ?? event.thinkingLevel;
|
|
159
|
+
if (typeof level !== "string" || level === this._thinkingLevel)
|
|
160
|
+
return false;
|
|
161
|
+
this._thinkingLevel = level;
|
|
162
|
+
this._version++;
|
|
163
|
+
this.rebuildJson();
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
112
166
|
case "tool_execution_start": {
|
|
113
167
|
const toolCallId = event.toolCallId;
|
|
114
168
|
if (toolCallId && !this._pendingToolCalls.includes(toolCallId)) {
|
|
115
169
|
this._pendingToolCalls = [...this._pendingToolCalls, toolCallId];
|
|
170
|
+
this._toolCallTimings = {
|
|
171
|
+
...this._toolCallTimings,
|
|
172
|
+
[toolCallId]: { startedAt: Date.now() },
|
|
173
|
+
};
|
|
116
174
|
this._version++;
|
|
117
175
|
this.rebuildJson();
|
|
118
176
|
return true;
|
|
@@ -134,6 +192,13 @@ export class SessionJsonl {
|
|
|
134
192
|
const toolCallId = event.toolCallId;
|
|
135
193
|
if (toolCallId) {
|
|
136
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
|
+
}
|
|
137
202
|
if (toolCallId in this._partialToolResults) {
|
|
138
203
|
const { [toolCallId]: _, ...rest } = this._partialToolResults;
|
|
139
204
|
this._partialToolResults = rest;
|
|
@@ -156,8 +221,11 @@ export class SessionJsonl {
|
|
|
156
221
|
* @param clientVersion - The client's last known version
|
|
157
222
|
* @returns SyncOp to send, or null if nothing changed
|
|
158
223
|
*/
|
|
159
|
-
computeSyncOp(clientJson, clientHash,
|
|
160
|
-
|
|
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)
|
|
161
229
|
return null;
|
|
162
230
|
return computeSyncOp(clientJson, this._json, clientHash, this._hash);
|
|
163
231
|
}
|
|
@@ -194,6 +262,7 @@ export class SessionJsonl {
|
|
|
194
262
|
messages,
|
|
195
263
|
isStreaming: true,
|
|
196
264
|
pendingToolCalls: this._pendingToolCalls,
|
|
265
|
+
toolCallTimings: this._toolCallTimings,
|
|
197
266
|
model: this._model,
|
|
198
267
|
thinkingLevel: this._thinkingLevel,
|
|
199
268
|
steeringQueue: this._steeringQueue,
|
|
@@ -212,6 +281,49 @@ export class SessionJsonl {
|
|
|
212
281
|
}
|
|
213
282
|
}
|
|
214
283
|
// ── Disk reader ────────────────────────────────────────────────────────────
|
|
284
|
+
/**
|
|
285
|
+
* Build the messages in chronological display order.
|
|
286
|
+
*
|
|
287
|
+
* Pi's LLM context intentionally puts the latest compaction summary before the
|
|
288
|
+
* retained pre-compaction messages. In the conversation timeline, however, the
|
|
289
|
+
* compaction happened after those retained messages. Restoring append order
|
|
290
|
+
* keeps the completed marker where the user ran /compact while still omitting
|
|
291
|
+
* the older messages replaced by the summary.
|
|
292
|
+
*/
|
|
293
|
+
function buildSessionDisplayEntries(entries) {
|
|
294
|
+
const sessionEntries = entries.filter((entry) => entry.type !== "session");
|
|
295
|
+
const entryOrder = new Map(sessionEntries.map((entry, index) => [entry, index]));
|
|
296
|
+
return [...buildContextEntries(sessionEntries)]
|
|
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;
|
|
326
|
+
}
|
|
215
327
|
/**
|
|
216
328
|
* Read a session from disk and return a serialized SessionState JSON + hash.
|
|
217
329
|
*/
|
|
@@ -219,12 +331,15 @@ export function readSessionFromDisk(sessionPath) {
|
|
|
219
331
|
let messages = [];
|
|
220
332
|
let model = null;
|
|
221
333
|
let thinkingLevel = "off";
|
|
334
|
+
let toolCallTimings = {};
|
|
222
335
|
try {
|
|
223
336
|
if (existsSync(sessionPath)) {
|
|
224
337
|
const content = readFileSync(sessionPath, "utf8");
|
|
225
338
|
const entries = parseSessionEntries(content);
|
|
226
|
-
const
|
|
227
|
-
|
|
339
|
+
const sessionEntries = entries.filter((entry) => entry.type !== "session");
|
|
340
|
+
const context = buildSessionContext(sessionEntries);
|
|
341
|
+
messages = buildSessionDisplayMessages(entries);
|
|
342
|
+
toolCallTimings = buildSessionToolCallTimings(entries);
|
|
228
343
|
model = context.model ?? null;
|
|
229
344
|
thinkingLevel = context.thinkingLevel ?? "off";
|
|
230
345
|
}
|
|
@@ -236,14 +351,19 @@ export function readSessionFromDisk(sessionPath) {
|
|
|
236
351
|
messages,
|
|
237
352
|
isStreaming: false,
|
|
238
353
|
pendingToolCalls: [],
|
|
354
|
+
toolCallTimings,
|
|
239
355
|
model,
|
|
240
356
|
thinkingLevel,
|
|
241
357
|
steeringQueue: [],
|
|
242
358
|
};
|
|
243
|
-
const json =
|
|
244
|
-
const hash = computeHashSync(json);
|
|
359
|
+
const { json, hash } = serializeSessionState(state);
|
|
245
360
|
return { json, hash, state };
|
|
246
361
|
}
|
|
362
|
+
/** Serialize a canonical session state using the wire protocol's hash format. */
|
|
363
|
+
export function serializeSessionState(state) {
|
|
364
|
+
const json = JSON.stringify(state);
|
|
365
|
+
return { json, hash: computeHashSync(json) };
|
|
366
|
+
}
|
|
247
367
|
/**
|
|
248
368
|
* Get the file size of a session file. Returns 0 if the file doesn't exist.
|
|
249
369
|
*/
|
|
@@ -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,67 @@
|
|
|
1
|
+
import { SessionActor } from "./session-actor.js";
|
|
2
|
+
/** Registry and process index for per-session actors. */
|
|
3
|
+
export class SessionRegistry {
|
|
4
|
+
actors = new Map();
|
|
5
|
+
actorsByProcess = new Map();
|
|
6
|
+
statuses = new Map();
|
|
7
|
+
listeners = new Set();
|
|
8
|
+
get(sessionPath) {
|
|
9
|
+
let actor = this.actors.get(sessionPath);
|
|
10
|
+
if (!actor) {
|
|
11
|
+
actor = new SessionActor(sessionPath, (event) => this.handleActorEvent(actor, event));
|
|
12
|
+
this.actors.set(sessionPath, actor);
|
|
13
|
+
}
|
|
14
|
+
return actor;
|
|
15
|
+
}
|
|
16
|
+
find(sessionPath) {
|
|
17
|
+
return this.actors.get(sessionPath);
|
|
18
|
+
}
|
|
19
|
+
getActorForProcess(proc) {
|
|
20
|
+
return this.actorsByProcess.get(proc);
|
|
21
|
+
}
|
|
22
|
+
subscribe(listener) {
|
|
23
|
+
this.listeners.add(listener);
|
|
24
|
+
return () => this.listeners.delete(listener);
|
|
25
|
+
}
|
|
26
|
+
getAllStatuses() {
|
|
27
|
+
return Object.fromEntries(this.statuses);
|
|
28
|
+
}
|
|
29
|
+
getAllSteeringQueues() {
|
|
30
|
+
const queues = {};
|
|
31
|
+
for (const [sessionPath, actor] of this.actors) {
|
|
32
|
+
if (actor.steeringQueue.length > 0)
|
|
33
|
+
queues[sessionPath] = [...actor.steeringQueue];
|
|
34
|
+
}
|
|
35
|
+
return queues;
|
|
36
|
+
}
|
|
37
|
+
get attachedCount() {
|
|
38
|
+
return this.actorsByProcess.size;
|
|
39
|
+
}
|
|
40
|
+
handleActorEvent(actor, event) {
|
|
41
|
+
switch (event.type) {
|
|
42
|
+
case "attached":
|
|
43
|
+
this.actorsByProcess.set(event.proc, actor);
|
|
44
|
+
this.statuses.set(actor.sessionPath, "running");
|
|
45
|
+
this.emit({ type: "status_change", sessionPath: actor.sessionPath, status: "running" });
|
|
46
|
+
this.emit({ type: "session_attached", sessionPath: actor.sessionPath, procId: event.proc.id });
|
|
47
|
+
break;
|
|
48
|
+
case "detached":
|
|
49
|
+
if (this.actorsByProcess.get(event.proc) === actor)
|
|
50
|
+
this.actorsByProcess.delete(event.proc);
|
|
51
|
+
this.statuses.set(actor.sessionPath, "done");
|
|
52
|
+
this.emit({ type: "status_change", sessionPath: actor.sessionPath, status: "done" });
|
|
53
|
+
this.emit({ type: "session_detached", sessionPath: actor.sessionPath, procId: event.proc.id });
|
|
54
|
+
break;
|
|
55
|
+
case "phase_change":
|
|
56
|
+
this.emit({ type: "phase_change", sessionPath: actor.sessionPath, phase: event.phase });
|
|
57
|
+
break;
|
|
58
|
+
case "steering_queue_update":
|
|
59
|
+
this.emit({ type: "steering_queue_update", sessionPath: actor.sessionPath, queue: event.queue });
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
emit(event) {
|
|
64
|
+
for (const listener of this.listeners)
|
|
65
|
+
listener(event);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -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
|
+
}
|