pipane 0.1.5 → 0.1.6
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 +34 -1
- package/THIRD_PARTY_NOTICES.md +29 -0
- 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/index-Dl_wdLZH.css +1 -0
- package/dist/client/assets/index-hNqbnG06.js +2482 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/bundled-extensions.js +6 -0
- package/dist/server/server/extension-status.js +31 -0
- package/dist/server/server/local-settings.js +15 -0
- package/dist/server/server/pi-runtime.js +1 -1
- package/dist/server/server/process-pool.js +160 -120
- package/dist/server/server/rest-api.js +7 -74
- package/dist/server/server/server.js +37 -49
- package/dist/server/server/session-actor.js +202 -0
- package/dist/server/server/session-index.js +18 -2
- package/dist/server/server/session-jsonl.js +49 -6
- package/dist/server/server/session-registry.js +67 -0
- package/dist/server/server/worktree-name.js +39 -0
- package/dist/server/server/ws-handler.js +577 -352
- package/dist/server/shared/jsonl-sync.js +41 -0
- package/dist/server/shared/rpc-timeouts.js +4 -0
- package/dist/server/shared/thinking-levels.js +94 -0
- package/dist/server/shared/ws-protocol.js +1 -0
- package/extensions/canvas.ts +1 -1
- package/package.json +26 -14
- 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
|
@@ -9,10 +9,9 @@
|
|
|
9
9
|
* switched to that session, and runs one turn. After the turn completes,
|
|
10
10
|
* the process is released back to the pool.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* these modules.
|
|
12
|
+
* Each session is owned by a serialized SessionActor registered in
|
|
13
|
+
* SessionRegistry. ProcessPool owns exclusive process leases, and WsHandler
|
|
14
|
+
* only routes transport commands and publishes actor state.
|
|
16
15
|
*/
|
|
17
16
|
import express from "express";
|
|
18
17
|
import { createServer } from "node:http";
|
|
@@ -22,25 +21,24 @@ import { hostname } from "node:os";
|
|
|
22
21
|
import { randomBytes } from "node:crypto";
|
|
23
22
|
import { existsSync, readFileSync, watch } from "node:fs";
|
|
24
23
|
import { WebSocketServer, WebSocket } from "ws";
|
|
25
|
-
import { getAgentDir } from "@
|
|
24
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
26
25
|
import { resolvePiLaunch } from "./pi-launch.js";
|
|
27
26
|
import { checkCommandAvailable, makePiNotFoundMessage } from "./pi-runtime.js";
|
|
28
27
|
import { registerRestApi } from "./rest-api.js";
|
|
29
|
-
import {
|
|
28
|
+
import { SessionRegistry } from "./session-registry.js";
|
|
30
29
|
import { ProcessPool } from "./process-pool.js";
|
|
31
30
|
import { WsHandler } from "./ws-handler.js";
|
|
32
|
-
import { LoadTraceStore } from "./load-trace-store.js";
|
|
33
31
|
import { LocalSettingsStore } from "./local-settings.js";
|
|
34
32
|
import { fetchLatestVersion, compareSemver } from "./update-check.js";
|
|
33
|
+
import { resolveUsageExtensionPath } from "./bundled-extensions.js";
|
|
35
34
|
const DEFAULT_PORT = process.env.NODE_ENV === "production" ? "8222" : "18111";
|
|
36
|
-
const
|
|
35
|
+
const REQUESTED_PORT = parseInt(process.env.PORT || DEFAULT_PORT, 10);
|
|
36
|
+
const INSTANCE_ID = process.env.PIPANE_INSTANCE_ID || null;
|
|
37
37
|
const PI_CWD = process.env.PI_CWD || process.cwd();
|
|
38
38
|
// Quiet mode: only show URLs unless --verbose or PIPANE_VERBOSE=1
|
|
39
39
|
const VERBOSE = process.argv.includes("--verbose") || process.env.PIPANE_VERBOSE === "1";
|
|
40
40
|
if (!VERBOSE) {
|
|
41
41
|
const origLog = console.log;
|
|
42
|
-
const origError = console.error;
|
|
43
|
-
const origWarn = console.warn;
|
|
44
42
|
// Suppress all console output; we'll use _log for the few lines we want
|
|
45
43
|
console.log = () => { };
|
|
46
44
|
console.error = () => { };
|
|
@@ -59,6 +57,7 @@ const PI_LAUNCH = resolvePiLaunch(PI_CLI);
|
|
|
59
57
|
const PI_AVAILABLE = checkCommandAvailable(PI_LAUNCH.command);
|
|
60
58
|
const PI_MAX_PROCESSES = parseInt(process.env.PI_MAX_PROCESSES || "24", 10);
|
|
61
59
|
const PI_PREWARM_COUNT = parseInt(process.env.PI_PREWARM_COUNT || "2", 10);
|
|
60
|
+
const USAGE_EXTENSION_ENABLED = process.env.PIPANE_USAGE_EXTENSION !== "0";
|
|
62
61
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
63
62
|
// Read own version from package.json
|
|
64
63
|
const PKG_JSON_PATH = path.resolve(__dirname, "../../../package.json");
|
|
@@ -74,7 +73,6 @@ const PKG_NAME = "pipane";
|
|
|
74
73
|
const AUTH_COOKIE_NAME = "pipane_auth";
|
|
75
74
|
const AUTH_TOKEN = process.env.PIPANE_AUTH_TOKEN || randomBytes(24).toString("base64url");
|
|
76
75
|
const PUBLIC_HOSTNAME = process.env.PI_PUBLIC_HOSTNAME || hostname();
|
|
77
|
-
const AUTH_URL = `http://${PUBLIC_HOSTNAME}:${PORT}/auth?token=${encodeURIComponent(AUTH_TOKEN)}`;
|
|
78
76
|
function parseCookies(header) {
|
|
79
77
|
const out = {};
|
|
80
78
|
if (!header)
|
|
@@ -159,36 +157,26 @@ app.use((req, res, next) => {
|
|
|
159
157
|
}
|
|
160
158
|
res.status(401).type("html").send("<h3>Unauthorized</h3><p>Open the one-time auth URL shown in the pipane terminal.</p>");
|
|
161
159
|
});
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
app.
|
|
165
|
-
|
|
166
|
-
if (!traceId) {
|
|
167
|
-
next();
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
const start = performance.now();
|
|
171
|
-
res.on("finish", () => {
|
|
172
|
-
traceStore.record(traceId, {
|
|
173
|
-
ts: new Date().toISOString(),
|
|
174
|
-
source: "backend",
|
|
175
|
-
kind: "span",
|
|
176
|
-
name: `http ${req.method} ${req.path}`,
|
|
177
|
-
durationMs: Number((performance.now() - start).toFixed(2)),
|
|
178
|
-
attrs: {
|
|
179
|
-
statusCode: res.statusCode,
|
|
180
|
-
},
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
next();
|
|
160
|
+
// A harness can provide a unique ID and verify that it reached the child it
|
|
161
|
+
// launched, rather than an unrelated process that happened to acquire a port.
|
|
162
|
+
app.get("/api/debug/health", (_req, res) => {
|
|
163
|
+
res.json({ ok: true, instanceId: INSTANCE_ID, pid: process.pid });
|
|
184
164
|
});
|
|
165
|
+
const localSettingsStore = new LocalSettingsStore();
|
|
166
|
+
const registry = new SessionRegistry();
|
|
185
167
|
// Serve static files in production
|
|
186
168
|
const clientDist = path.resolve(__dirname, "../../client");
|
|
187
169
|
app.use(express.static(clientDist));
|
|
188
170
|
// Register REST endpoints
|
|
189
171
|
registerRestApi(app, {
|
|
190
|
-
traceStore,
|
|
191
172
|
localSettingsStore,
|
|
173
|
+
runSessionMutation: (sessionPath, operation, mutation) => {
|
|
174
|
+
const actor = registry.get(sessionPath);
|
|
175
|
+
return actor.enqueue(operation, async () => {
|
|
176
|
+
actor.assertAvailable(operation);
|
|
177
|
+
await mutation();
|
|
178
|
+
});
|
|
179
|
+
},
|
|
192
180
|
onLocalSettingsReloaded: () => {
|
|
193
181
|
wss.clients.forEach((client) => {
|
|
194
182
|
if (client.readyState === WebSocket.OPEN) {
|
|
@@ -203,13 +191,16 @@ registerRestApi(app, {
|
|
|
203
191
|
// ============================================================================
|
|
204
192
|
// Core modules
|
|
205
193
|
// ============================================================================
|
|
206
|
-
|
|
207
|
-
// Resolve canvas extension path relative to project root
|
|
194
|
+
// Resolve bundled extension entrypoints once at startup.
|
|
208
195
|
const canvasExtension = path.resolve(__dirname, "../../../extensions/canvas.ts");
|
|
196
|
+
const usageExtension = resolveUsageExtensionPath();
|
|
209
197
|
const pool = new ProcessPool({
|
|
210
198
|
command: PI_LAUNCH.command,
|
|
211
199
|
baseArgs: () => {
|
|
212
200
|
const args = [...PI_LAUNCH.baseArgs, "--mode", "rpc"];
|
|
201
|
+
if (USAGE_EXTENSION_ENABLED) {
|
|
202
|
+
args.push("-e", usageExtension);
|
|
203
|
+
}
|
|
213
204
|
if (localSettingsStore.canvasEnabled) {
|
|
214
205
|
args.push("-e", canvasExtension);
|
|
215
206
|
}
|
|
@@ -219,12 +210,8 @@ const pool = new ProcessPool({
|
|
|
219
210
|
maxProcesses: PI_MAX_PROCESSES,
|
|
220
211
|
prewarmCount: PI_PREWARM_COUNT,
|
|
221
212
|
onProcessExit: (proc) => {
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
if (sessionPath) {
|
|
225
|
-
console.log(`[pool] pi#${proc.id} crashed while attached to ${path.basename(sessionPath)} — marking done`);
|
|
226
|
-
lifecycle.crash(sessionPath);
|
|
227
|
-
}
|
|
213
|
+
// WsHandler owns attached-session snapshots and listener bookkeeping.
|
|
214
|
+
wsHandler.handleProcessExit(proc);
|
|
228
215
|
// Replenish the pool for the default cwd
|
|
229
216
|
if (PI_AVAILABLE) {
|
|
230
217
|
pool.prewarm(PI_CWD);
|
|
@@ -232,7 +219,7 @@ const pool = new ProcessPool({
|
|
|
232
219
|
},
|
|
233
220
|
});
|
|
234
221
|
const wsHandler = new WsHandler({
|
|
235
|
-
|
|
222
|
+
registry,
|
|
236
223
|
pool,
|
|
237
224
|
defaultCwd: PI_CWD,
|
|
238
225
|
piLaunch: PI_LAUNCH,
|
|
@@ -242,7 +229,6 @@ const wsHandler = new WsHandler({
|
|
|
242
229
|
}
|
|
243
230
|
},
|
|
244
231
|
isRequestAuthorized: (req) => isAuthorizedRequest(req),
|
|
245
|
-
traceStore,
|
|
246
232
|
});
|
|
247
233
|
// Register WS handler
|
|
248
234
|
wsHandler.register(wss);
|
|
@@ -320,8 +306,7 @@ function startSessionsWatcher() {
|
|
|
320
306
|
clearTimeout(debounceTimer);
|
|
321
307
|
debounceTimer = setTimeout(() => {
|
|
322
308
|
const fullPath = path.join(SESSIONS_DIR, lastChangedFile);
|
|
323
|
-
//
|
|
324
|
-
// and push session_messages to the subscribed client if needed.
|
|
309
|
+
// Re-read detached session state and push it to active subscribers.
|
|
325
310
|
wsHandler.notifySessionFileChanged(fullPath);
|
|
326
311
|
// Also notify all WS clients about the file change (for sidebar refresh)
|
|
327
312
|
wss.clients.forEach((client) => {
|
|
@@ -348,7 +333,10 @@ if (PI_AVAILABLE) {
|
|
|
348
333
|
else {
|
|
349
334
|
console.log(`[pi] ${makePiNotFoundMessage(PI_LAUNCH.command)}`);
|
|
350
335
|
}
|
|
351
|
-
server.listen(
|
|
336
|
+
server.listen(REQUESTED_PORT, async () => {
|
|
337
|
+
const address = server.address();
|
|
338
|
+
const port = address && typeof address !== "string" ? address.port : REQUESTED_PORT;
|
|
339
|
+
const authUrl = `http://${PUBLIC_HOSTNAME}:${port}/auth?token=${encodeURIComponent(AUTH_TOKEN)}`;
|
|
352
340
|
log("");
|
|
353
341
|
log(" _ ");
|
|
354
342
|
log(" _ __ (_)_ __ __ _ _ __ ___ ");
|
|
@@ -358,8 +346,8 @@ server.listen(PORT, async () => {
|
|
|
358
346
|
log(" |_| |_| ");
|
|
359
347
|
log(` v${PKG_VERSION}`);
|
|
360
348
|
log("");
|
|
361
|
-
log(` Local: http://localhost:${
|
|
362
|
-
log(` Remote: ${
|
|
349
|
+
log(` Local: http://localhost:${port}`);
|
|
350
|
+
log(` Remote: ${authUrl}`);
|
|
363
351
|
if (!process.env.PIPANE_AUTH_TOKEN) {
|
|
364
352
|
log(`\n Auth token is random and changes on restart.`);
|
|
365
353
|
log(` Set PIPANE_AUTH_TOKEN to use a fixed token.`);
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
const LEGAL_TRANSITIONS = {
|
|
2
|
+
detached: ["attaching"],
|
|
3
|
+
attaching: ["ready", "failed", "releasing"],
|
|
4
|
+
ready: ["starting", "releasing", "failed"],
|
|
5
|
+
starting: ["running", "settling", "releasing", "failed"],
|
|
6
|
+
running: ["settling", "releasing", "failed"],
|
|
7
|
+
settling: ["releasing", "failed"],
|
|
8
|
+
releasing: ["detached"],
|
|
9
|
+
failed: ["releasing"],
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Serialized owner of one session's mutable runtime state.
|
|
13
|
+
*
|
|
14
|
+
* Long-running turns do not hold the command mailbox: prompt startup commits the
|
|
15
|
+
* `starting` phase and returns, Pi events are enqueued as internal commands, and
|
|
16
|
+
* final settlement is enqueued separately. This leaves steer/abort responsive
|
|
17
|
+
* while preventing compact/fork/rename from overlapping the turn.
|
|
18
|
+
*/
|
|
19
|
+
export class SessionActor {
|
|
20
|
+
sessionPath;
|
|
21
|
+
_phase = "detached";
|
|
22
|
+
_lease;
|
|
23
|
+
_session;
|
|
24
|
+
_turnGeneration = 0;
|
|
25
|
+
_steeringQueue = [];
|
|
26
|
+
_turnEventCleanup;
|
|
27
|
+
commandTail = Promise.resolve();
|
|
28
|
+
emitEvent;
|
|
29
|
+
constructor(sessionPath, emitEvent) {
|
|
30
|
+
this.sessionPath = sessionPath;
|
|
31
|
+
this.emitEvent = emitEvent;
|
|
32
|
+
}
|
|
33
|
+
get phase() { return this._phase; }
|
|
34
|
+
get process() { return this._lease?.process; }
|
|
35
|
+
get session() { return this._session; }
|
|
36
|
+
get turnGeneration() { return this._turnGeneration; }
|
|
37
|
+
get steeringQueue() { return this._steeringQueue; }
|
|
38
|
+
get isAttached() { return this._lease !== undefined; }
|
|
39
|
+
get isTurnActive() {
|
|
40
|
+
return this._phase === "starting" || this._phase === "running" || this._phase === "settling";
|
|
41
|
+
}
|
|
42
|
+
/** Serialize one command or internal event for this session. */
|
|
43
|
+
enqueue(operation, fn) {
|
|
44
|
+
const run = this.commandTail.then(fn);
|
|
45
|
+
this.commandTail = run.then(() => undefined, () => undefined);
|
|
46
|
+
return run.catch((error) => {
|
|
47
|
+
if (error instanceof Error) {
|
|
48
|
+
error.message = `[${operation}] ${error.message}`;
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
assertAvailable(operation) {
|
|
54
|
+
if (this.isTurnActive) {
|
|
55
|
+
throw new Error(`Cannot ${operation} while session turn is ${this._phase}`);
|
|
56
|
+
}
|
|
57
|
+
if (this._phase !== "detached" && this._phase !== "ready") {
|
|
58
|
+
throw new Error(`Cannot ${operation} while session is ${this._phase}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
attach(lease, session) {
|
|
62
|
+
if (this._phase !== "detached" || this._lease) {
|
|
63
|
+
throw new Error(`Session is already owned in phase ${this._phase}`);
|
|
64
|
+
}
|
|
65
|
+
this.transition("attaching");
|
|
66
|
+
this._lease = lease;
|
|
67
|
+
this._session = session;
|
|
68
|
+
this.transition("ready");
|
|
69
|
+
this.emitEvent({ type: "attached", proc: lease.process });
|
|
70
|
+
}
|
|
71
|
+
beginTurn() {
|
|
72
|
+
if (this._phase !== "ready" || !this._lease || !this._session) {
|
|
73
|
+
throw new Error(`Cannot start turn while session is ${this._phase}`);
|
|
74
|
+
}
|
|
75
|
+
this._turnGeneration += 1;
|
|
76
|
+
this.transition("starting");
|
|
77
|
+
return this._turnGeneration;
|
|
78
|
+
}
|
|
79
|
+
setTurnEventCleanup(generation, cleanup) {
|
|
80
|
+
if (generation !== this._turnGeneration || !this.isTurnActive) {
|
|
81
|
+
cleanup();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
this._turnEventCleanup?.();
|
|
85
|
+
this._turnEventCleanup = cleanup;
|
|
86
|
+
}
|
|
87
|
+
owns(proc, generation) {
|
|
88
|
+
return this.process === proc && (generation === undefined || generation === this._turnGeneration);
|
|
89
|
+
}
|
|
90
|
+
async applyProcessEvent(proc, generation, event, replacementMessages) {
|
|
91
|
+
return this.enqueue("process event", () => {
|
|
92
|
+
if (!this.owns(proc, generation) || !this._session) {
|
|
93
|
+
return { accepted: false, changed: false, started: false, ended: false, settled: false };
|
|
94
|
+
}
|
|
95
|
+
let started = false;
|
|
96
|
+
let ended = false;
|
|
97
|
+
let settled = false;
|
|
98
|
+
if (event.type === "agent_start") {
|
|
99
|
+
started = true;
|
|
100
|
+
if (this._phase === "starting")
|
|
101
|
+
this.transition("running");
|
|
102
|
+
}
|
|
103
|
+
if (event.type === "agent_end") {
|
|
104
|
+
ended = true;
|
|
105
|
+
if (this._phase === "starting" || this._phase === "running")
|
|
106
|
+
this.transition("settling");
|
|
107
|
+
this.clearSteering();
|
|
108
|
+
}
|
|
109
|
+
if (event.type === "agent_settled") {
|
|
110
|
+
started = true;
|
|
111
|
+
ended = true;
|
|
112
|
+
settled = true;
|
|
113
|
+
if (this._phase === "starting" || this._phase === "running")
|
|
114
|
+
this.transition("settling");
|
|
115
|
+
}
|
|
116
|
+
let changed = this._session.applyEvent(event);
|
|
117
|
+
if (replacementMessages) {
|
|
118
|
+
this._session.replaceMessages(replacementMessages);
|
|
119
|
+
changed = true;
|
|
120
|
+
}
|
|
121
|
+
if (event.type === "message_end" && event.message?.role === "user") {
|
|
122
|
+
const text = typeof event.message.content === "string"
|
|
123
|
+
? event.message.content
|
|
124
|
+
: (event.message.content || [])
|
|
125
|
+
.filter((content) => content.type === "text")
|
|
126
|
+
.map((content) => content.text)
|
|
127
|
+
.join(" ");
|
|
128
|
+
this.dequeueSteering(text);
|
|
129
|
+
}
|
|
130
|
+
return { accepted: true, changed, started, ended, settled };
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
enqueueSteering(message) {
|
|
134
|
+
this._steeringQueue = [...this._steeringQueue, message];
|
|
135
|
+
this.syncSteeringQueue();
|
|
136
|
+
}
|
|
137
|
+
removeSteeringByIndex(index) {
|
|
138
|
+
if (index < 0 || index >= this._steeringQueue.length)
|
|
139
|
+
return false;
|
|
140
|
+
this._steeringQueue = this._steeringQueue.filter((_, itemIndex) => itemIndex !== index);
|
|
141
|
+
this.syncSteeringQueue();
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
dequeueSteering(text) {
|
|
145
|
+
const index = this._steeringQueue.indexOf(text);
|
|
146
|
+
if (index === -1)
|
|
147
|
+
return false;
|
|
148
|
+
this._steeringQueue = this._steeringQueue.filter((_, itemIndex) => itemIndex !== index);
|
|
149
|
+
this.syncSteeringQueue();
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
clearSteering() {
|
|
153
|
+
if (this._steeringQueue.length === 0)
|
|
154
|
+
return;
|
|
155
|
+
this._steeringQueue = [];
|
|
156
|
+
this.syncSteeringQueue();
|
|
157
|
+
}
|
|
158
|
+
markFailed() {
|
|
159
|
+
if (this._phase === "detached" || this._phase === "releasing" || this._phase === "failed")
|
|
160
|
+
return;
|
|
161
|
+
this.transition("failed");
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Release all runtime ownership. Safe to call once the actor command holding
|
|
165
|
+
* the current mutation reaches a terminal path.
|
|
166
|
+
*/
|
|
167
|
+
detach() {
|
|
168
|
+
const lease = this._lease;
|
|
169
|
+
const liveSession = this._session;
|
|
170
|
+
if (!lease || !liveSession)
|
|
171
|
+
return undefined;
|
|
172
|
+
if (this._phase !== "releasing")
|
|
173
|
+
this.transition("releasing");
|
|
174
|
+
this._turnEventCleanup?.();
|
|
175
|
+
this._turnEventCleanup = undefined;
|
|
176
|
+
if (this._steeringQueue.length > 0) {
|
|
177
|
+
this._steeringQueue = [];
|
|
178
|
+
this.emitEvent({ type: "steering_queue_update", queue: [] });
|
|
179
|
+
}
|
|
180
|
+
const proc = lease.process;
|
|
181
|
+
this._lease = undefined;
|
|
182
|
+
this._session = undefined;
|
|
183
|
+
lease.release();
|
|
184
|
+
this.transition("detached");
|
|
185
|
+
this.emitEvent({ type: "detached", proc });
|
|
186
|
+
return { proc, liveSession };
|
|
187
|
+
}
|
|
188
|
+
syncSteeringQueue() {
|
|
189
|
+
if (this._session)
|
|
190
|
+
this._session.steeringQueue = [...this._steeringQueue];
|
|
191
|
+
this.emitEvent({ type: "steering_queue_update", queue: [...this._steeringQueue] });
|
|
192
|
+
}
|
|
193
|
+
transition(next) {
|
|
194
|
+
if (next === this._phase)
|
|
195
|
+
return;
|
|
196
|
+
if (!LEGAL_TRANSITIONS[this._phase].includes(next)) {
|
|
197
|
+
throw new Error(`Illegal session transition ${this._phase} -> ${next}`);
|
|
198
|
+
}
|
|
199
|
+
this._phase = next;
|
|
200
|
+
this.emitEvent({ type: "phase_change", phase: next });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
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 { resolveWorktreeName } from "./worktree-name.js";
|
|
4
5
|
const CACHE_FORMAT_VERSION = 1;
|
|
5
6
|
const DEFAULT_EXTRACTOR_VERSION = "2";
|
|
6
7
|
export class SessionIndex {
|
|
@@ -8,12 +9,14 @@ export class SessionIndex {
|
|
|
8
9
|
extractorVersion;
|
|
9
10
|
cacheFilePath;
|
|
10
11
|
cwdDisplayFormatter;
|
|
12
|
+
worktreeNameResolver;
|
|
11
13
|
inMemoryCache;
|
|
12
14
|
constructor(opts) {
|
|
13
15
|
this.agentDir = opts?.agentDir ?? getAgentDir();
|
|
14
16
|
this.extractorVersion = opts?.extractorVersion ?? DEFAULT_EXTRACTOR_VERSION;
|
|
15
17
|
this.cacheFilePath = path.join(this.agentDir, "cache", "pipane-session-index-v1.json");
|
|
16
18
|
this.cwdDisplayFormatter = opts?.cwdDisplayFormatter;
|
|
19
|
+
this.worktreeNameResolver = opts?.worktreeNameResolver ?? resolveWorktreeName;
|
|
17
20
|
}
|
|
18
21
|
async listSessions() {
|
|
19
22
|
const files = this.listSessionFiles();
|
|
@@ -80,7 +83,20 @@ export class SessionIndex {
|
|
|
80
83
|
// on read-only agent dirs or transient filesystem errors.
|
|
81
84
|
}
|
|
82
85
|
}
|
|
83
|
-
|
|
86
|
+
const worktreeNames = new Map();
|
|
87
|
+
return sessions.map((session) => {
|
|
88
|
+
let worktreeName = worktreeNames.get(session.cwd);
|
|
89
|
+
if (!worktreeName) {
|
|
90
|
+
try {
|
|
91
|
+
worktreeName = this.worktreeNameResolver(session.cwd) || "root";
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
worktreeName = "root";
|
|
95
|
+
}
|
|
96
|
+
worktreeNames.set(session.cwd, worktreeName);
|
|
97
|
+
}
|
|
98
|
+
return { ...session, worktreeName };
|
|
99
|
+
});
|
|
84
100
|
}
|
|
85
101
|
async invalidateAll() {
|
|
86
102
|
this.inMemoryCache = null;
|
|
@@ -12,7 +12,7 @@
|
|
|
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). */
|
|
@@ -52,8 +52,21 @@ export class SessionJsonl {
|
|
|
52
52
|
this._steeringQueue = q;
|
|
53
53
|
this.rebuildJson();
|
|
54
54
|
}
|
|
55
|
-
// Expose
|
|
55
|
+
// Expose controls for ws-handler reconciliation.
|
|
56
56
|
get model() { return this._model; }
|
|
57
|
+
get thinkingLevel() { return this._thinkingLevel; }
|
|
58
|
+
/** Atomically replace the effective model/thinking state reported by pi. */
|
|
59
|
+
setControlState(model, thinkingLevel) {
|
|
60
|
+
const sameModel = this._model?.provider === model?.provider
|
|
61
|
+
&& this._model?.modelId === model?.modelId;
|
|
62
|
+
if (sameModel && this._thinkingLevel === thinkingLevel)
|
|
63
|
+
return false;
|
|
64
|
+
this._model = model;
|
|
65
|
+
this._thinkingLevel = thinkingLevel;
|
|
66
|
+
this._version++;
|
|
67
|
+
this.rebuildJson();
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
57
70
|
// Expose messages for post-detach snapshot
|
|
58
71
|
get messages() { return this._messages; }
|
|
59
72
|
/**
|
|
@@ -109,6 +122,15 @@ export class SessionJsonl {
|
|
|
109
122
|
}
|
|
110
123
|
return false;
|
|
111
124
|
}
|
|
125
|
+
case "thinking_level_changed": {
|
|
126
|
+
const level = event.level ?? event.thinkingLevel;
|
|
127
|
+
if (typeof level !== "string" || level === this._thinkingLevel)
|
|
128
|
+
return false;
|
|
129
|
+
this._thinkingLevel = level;
|
|
130
|
+
this._version++;
|
|
131
|
+
this.rebuildJson();
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
112
134
|
case "tool_execution_start": {
|
|
113
135
|
const toolCallId = event.toolCallId;
|
|
114
136
|
if (toolCallId && !this._pendingToolCalls.includes(toolCallId)) {
|
|
@@ -212,6 +234,22 @@ export class SessionJsonl {
|
|
|
212
234
|
}
|
|
213
235
|
}
|
|
214
236
|
// ── Disk reader ────────────────────────────────────────────────────────────
|
|
237
|
+
/**
|
|
238
|
+
* Build the messages in chronological display order.
|
|
239
|
+
*
|
|
240
|
+
* Pi's LLM context intentionally puts the latest compaction summary before the
|
|
241
|
+
* retained pre-compaction messages. In the conversation timeline, however, the
|
|
242
|
+
* compaction happened after those retained messages. Restoring append order
|
|
243
|
+
* keeps the completed marker where the user ran /compact while still omitting
|
|
244
|
+
* the older messages replaced by the summary.
|
|
245
|
+
*/
|
|
246
|
+
export function buildSessionDisplayMessages(entries) {
|
|
247
|
+
const sessionEntries = entries.filter((entry) => entry.type !== "session");
|
|
248
|
+
const entryOrder = new Map(sessionEntries.map((entry, index) => [entry, index]));
|
|
249
|
+
return [...buildContextEntries(sessionEntries)]
|
|
250
|
+
.sort((left, right) => (entryOrder.get(left) ?? 0) - (entryOrder.get(right) ?? 0))
|
|
251
|
+
.flatMap(sessionEntryToContextMessages);
|
|
252
|
+
}
|
|
215
253
|
/**
|
|
216
254
|
* Read a session from disk and return a serialized SessionState JSON + hash.
|
|
217
255
|
*/
|
|
@@ -223,8 +261,9 @@ export function readSessionFromDisk(sessionPath) {
|
|
|
223
261
|
if (existsSync(sessionPath)) {
|
|
224
262
|
const content = readFileSync(sessionPath, "utf8");
|
|
225
263
|
const entries = parseSessionEntries(content);
|
|
226
|
-
const
|
|
227
|
-
|
|
264
|
+
const sessionEntries = entries.filter((entry) => entry.type !== "session");
|
|
265
|
+
const context = buildSessionContext(sessionEntries);
|
|
266
|
+
messages = buildSessionDisplayMessages(entries);
|
|
228
267
|
model = context.model ?? null;
|
|
229
268
|
thinkingLevel = context.thinkingLevel ?? "off";
|
|
230
269
|
}
|
|
@@ -240,10 +279,14 @@ export function readSessionFromDisk(sessionPath) {
|
|
|
240
279
|
thinkingLevel,
|
|
241
280
|
steeringQueue: [],
|
|
242
281
|
};
|
|
243
|
-
const json =
|
|
244
|
-
const hash = computeHashSync(json);
|
|
282
|
+
const { json, hash } = serializeSessionState(state);
|
|
245
283
|
return { json, hash, state };
|
|
246
284
|
}
|
|
285
|
+
/** Serialize a canonical session state using the wire protocol's hash format. */
|
|
286
|
+
export function serializeSessionState(state) {
|
|
287
|
+
const json = JSON.stringify(state);
|
|
288
|
+
return { json, hash: computeHashSync(json) };
|
|
289
|
+
}
|
|
247
290
|
/**
|
|
248
291
|
* Get the file size of a session file. Returns 0 if the file doesn't exist.
|
|
249
292
|
*/
|
|
@@ -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,39 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Return a short label for the Git checkout containing cwd.
|
|
5
|
+
*
|
|
6
|
+
* A linked worktree has a .git file whose target contains a `commondir`
|
|
7
|
+
* pointer. Regular checkouts (and non-Git directories) are labelled `root`.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveWorktreeName(cwd) {
|
|
10
|
+
if (!cwd)
|
|
11
|
+
return "root";
|
|
12
|
+
let dir = path.resolve(cwd);
|
|
13
|
+
while (true) {
|
|
14
|
+
const gitPath = path.join(dir, ".git");
|
|
15
|
+
if (existsSync(gitPath)) {
|
|
16
|
+
try {
|
|
17
|
+
const stat = statSync(gitPath);
|
|
18
|
+
if (stat.isDirectory())
|
|
19
|
+
return "root";
|
|
20
|
+
if (!stat.isFile())
|
|
21
|
+
return "root";
|
|
22
|
+
const marker = readFileSync(gitPath, "utf8").trim();
|
|
23
|
+
if (!marker.startsWith("gitdir: "))
|
|
24
|
+
return "root";
|
|
25
|
+
const gitDir = path.resolve(dir, marker.slice("gitdir: ".length).trim());
|
|
26
|
+
if (!existsSync(path.join(gitDir, "commondir")))
|
|
27
|
+
return "root";
|
|
28
|
+
return path.basename(dir) || "root";
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return "root";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const parent = path.dirname(dir);
|
|
35
|
+
if (parent === dir)
|
|
36
|
+
return "root";
|
|
37
|
+
dir = parent;
|
|
38
|
+
}
|
|
39
|
+
}
|