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.
Files changed (99) hide show
  1. package/README.md +34 -1
  2. package/THIRD_PARTY_NOTICES.md +29 -0
  3. package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  4. package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  5. package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  6. package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  7. package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  8. package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  9. package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  10. package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  11. package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  12. package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  13. package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  14. package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  15. package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  16. package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  17. package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  18. package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  19. package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  20. package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  21. package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  22. package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  23. package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  24. package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  25. package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  26. package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  27. package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  28. package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  29. package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  30. package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  31. package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  32. package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  33. package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  34. package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  35. package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  36. package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  37. package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  38. package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  39. package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  40. package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  41. package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  42. package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  43. package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  44. package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  45. package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  46. package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  47. package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  48. package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  49. package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  50. package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  51. package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  52. package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  53. package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  54. package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  55. package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  56. package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  57. package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  58. package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  59. package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  60. package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  61. package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  62. package/dist/client/assets/index-Dl_wdLZH.css +1 -0
  63. package/dist/client/assets/index-hNqbnG06.js +2482 -0
  64. package/dist/client/index.html +2 -2
  65. package/dist/server/server/bundled-extensions.js +6 -0
  66. package/dist/server/server/extension-status.js +31 -0
  67. package/dist/server/server/local-settings.js +15 -0
  68. package/dist/server/server/pi-runtime.js +1 -1
  69. package/dist/server/server/process-pool.js +160 -120
  70. package/dist/server/server/rest-api.js +7 -74
  71. package/dist/server/server/server.js +37 -49
  72. package/dist/server/server/session-actor.js +202 -0
  73. package/dist/server/server/session-index.js +18 -2
  74. package/dist/server/server/session-jsonl.js +49 -6
  75. package/dist/server/server/session-registry.js +67 -0
  76. package/dist/server/server/worktree-name.js +39 -0
  77. package/dist/server/server/ws-handler.js +577 -352
  78. package/dist/server/shared/jsonl-sync.js +41 -0
  79. package/dist/server/shared/rpc-timeouts.js +4 -0
  80. package/dist/server/shared/thinking-levels.js +94 -0
  81. package/dist/server/shared/ws-protocol.js +1 -0
  82. package/extensions/canvas.ts +1 -1
  83. package/package.json +26 -14
  84. package/dist/client/assets/_node-stub_node_crypto-C_7epg3G.js +0 -1
  85. package/dist/client/assets/_node-stub_node_fs-B-VOzeXW.js +0 -1
  86. package/dist/client/assets/_node-stub_node_http-CROXaVGJ.js +0 -1
  87. package/dist/client/assets/_node-stub_node_os-TTKJha15.js +0 -1
  88. package/dist/client/assets/_node-stub_node_path-DJCJiKv7.js +0 -1
  89. package/dist/client/assets/_node-stub_stream-DDexIdn_.js +0 -1
  90. package/dist/client/assets/index-BtDAXAo1.css +0 -1
  91. package/dist/client/assets/index-CAQqlK61.js +0 -131
  92. package/dist/client/assets/index-CJEeBmBj.js +0 -3
  93. package/dist/client/assets/index-DUEhKmPQ.js +0 -1
  94. package/dist/client/assets/index-XEVKscP-.js +0 -4443
  95. package/dist/client/assets/index-woI4agCy.js +0 -45
  96. package/dist/server/server/attached-session.js +0 -209
  97. package/dist/server/server/load-trace-store.js +0 -48
  98. package/dist/server/server/session-lifecycle.js +0 -155
  99. 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
- * Session lifecycle is managed by SessionLifecycle (single source of truth
13
- * for session→process mappings and status). Process spawning and pooling
14
- * is handled by ProcessPool. The WsHandler routes WebSocket commands to
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 "@mariozechner/pi-coding-agent";
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 { SessionLifecycle } from "./session-lifecycle.js";
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 PORT = parseInt(process.env.PORT || DEFAULT_PORT, 10);
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
- const traceStore = new LoadTraceStore();
163
- const localSettingsStore = new LocalSettingsStore();
164
- app.use((req, res, next) => {
165
- const traceId = typeof req.headers["x-pi-trace-id"] === "string" ? req.headers["x-pi-trace-id"] : "";
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
- const lifecycle = new SessionLifecycle();
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
- // If the process was attached to a session, handle the crash
223
- const sessionPath = lifecycle.getAttachedSessionForProcess(proc);
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
- lifecycle,
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
- // Notify the message cache it will re-read from disk if changed
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(PORT, async () => {
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:${PORT}`);
362
- log(` Remote: ${AUTH_URL}`);
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 "@mariozechner/pi-coding-agent";
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
- return sessions;
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 { parseSessionEntries, buildSessionContext, } from "@mariozechner/pi-coding-agent";
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 model for ws-handler
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 context = buildSessionContext(entries);
227
- messages = context.messages ?? [];
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 = JSON.stringify(state);
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
+ }