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.
Files changed (138) hide show
  1. package/README.md +45 -1
  2. package/THIRD_PARTY_NOTICES.md +29 -0
  3. package/bin/pipane-rendezvous.js +21 -0
  4. package/bin/pipane.js +21 -1
  5. package/dist/client/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  6. package/dist/client/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  7. package/dist/client/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  8. package/dist/client/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  9. package/dist/client/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  10. package/dist/client/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  11. package/dist/client/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  12. package/dist/client/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  13. package/dist/client/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  14. package/dist/client/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  15. package/dist/client/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  16. package/dist/client/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  17. package/dist/client/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  18. package/dist/client/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  19. package/dist/client/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  20. package/dist/client/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  21. package/dist/client/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  22. package/dist/client/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  23. package/dist/client/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  24. package/dist/client/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  25. package/dist/client/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  26. package/dist/client/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  27. package/dist/client/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  28. package/dist/client/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  29. package/dist/client/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  30. package/dist/client/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  31. package/dist/client/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  32. package/dist/client/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  33. package/dist/client/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  34. package/dist/client/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  35. package/dist/client/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  36. package/dist/client/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  37. package/dist/client/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  38. package/dist/client/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  39. package/dist/client/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  40. package/dist/client/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  41. package/dist/client/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  42. package/dist/client/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  43. package/dist/client/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  44. package/dist/client/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  45. package/dist/client/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  46. package/dist/client/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  47. package/dist/client/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  48. package/dist/client/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  49. package/dist/client/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  50. package/dist/client/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  51. package/dist/client/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  52. package/dist/client/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  53. package/dist/client/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  54. package/dist/client/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  55. package/dist/client/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  56. package/dist/client/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  57. package/dist/client/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  58. package/dist/client/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  59. package/dist/client/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  60. package/dist/client/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  61. package/dist/client/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  62. package/dist/client/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  63. package/dist/client/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  64. package/dist/client/assets/__vite-browser-external-DtLbhLGx.js +1 -0
  65. package/dist/client/assets/app-runtime-Rw-O-AwW.js +1 -0
  66. package/dist/client/assets/backend-landing-page-B7SbyIXQ.js +1 -0
  67. package/dist/client/assets/index-DwbBcYUf.js +2 -0
  68. package/dist/client/assets/index-iblQYBAl.css +1 -0
  69. package/dist/client/assets/main-LGItV9Aj.js +2807 -0
  70. package/dist/client/assets/pairing-page-D-WxlWZR.js +1 -0
  71. package/dist/client/assets/remote-backend-manager-BWuGh30I.js +1 -0
  72. package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
  73. package/dist/client/assets/webrtc-frame-transport-BijNnA7N.js +1 -0
  74. package/dist/client/assets/ws-agent-adapter-BRysf-Y7.js +6 -0
  75. package/dist/client/index.html +2 -2
  76. package/dist/server/rendezvous/rendezvous-hub.js +426 -0
  77. package/dist/server/rendezvous/server.js +247 -0
  78. package/dist/server/rendezvous/trust-store.js +432 -0
  79. package/dist/server/server/auth-guard.js +61 -0
  80. package/dist/server/server/backend-connection-authorizer.js +29 -0
  81. package/dist/server/server/backend-identity.js +167 -0
  82. package/dist/server/server/backend-protocol-handler.js +132 -0
  83. package/dist/server/server/backend-trust-store.js +157 -0
  84. package/dist/server/server/backend-webrtc.js +239 -0
  85. package/dist/server/server/bundled-extensions.js +6 -0
  86. package/dist/server/server/conversation-file-access.js +97 -0
  87. package/dist/server/server/extension-status.js +31 -0
  88. package/dist/server/server/frame-connection.js +48 -0
  89. package/dist/server/server/frame-router.js +45 -0
  90. package/dist/server/server/ice-servers.js +24 -0
  91. package/dist/server/server/local-backend-api.js +389 -0
  92. package/dist/server/server/local-settings.js +28 -0
  93. package/dist/server/server/pi-rpc-protocol.js +407 -0
  94. package/dist/server/server/pi-runtime.js +1 -1
  95. package/dist/server/server/process-pool.js +179 -136
  96. package/dist/server/server/rendezvous-client.js +282 -0
  97. package/dist/server/server/rest-api.js +121 -231
  98. package/dist/server/server/server.js +188 -134
  99. package/dist/server/server/session-actor.js +202 -0
  100. package/dist/server/server/session-index.js +111 -18
  101. package/dist/server/server/session-jsonl.js +128 -8
  102. package/dist/server/server/session-path.js +145 -0
  103. package/dist/server/server/session-registry.js +67 -0
  104. package/dist/server/server/update-api.js +28 -0
  105. package/dist/server/server/update-check.js +33 -13
  106. package/dist/server/server/update-manager.js +231 -0
  107. package/dist/server/server/worktree-name.js +124 -0
  108. package/dist/server/server/ws-handler.js +784 -478
  109. package/dist/server/shared/backend-api.js +1 -0
  110. package/dist/server/shared/backend-protocol.js +164 -0
  111. package/dist/server/shared/jsonl-sync.js +41 -0
  112. package/dist/server/shared/node-trust-crypto.js +61 -0
  113. package/dist/server/shared/rendezvous-protocol.js +243 -0
  114. package/dist/server/shared/rpc-timeouts.js +4 -0
  115. package/dist/server/shared/thinking-levels.js +94 -0
  116. package/dist/server/shared/tool-runtime.js +1 -0
  117. package/dist/server/shared/trust-protocol.js +97 -0
  118. package/dist/server/shared/updates.js +4 -0
  119. package/dist/server/shared/ws-protocol.js +473 -0
  120. package/docs/protocol.md +127 -0
  121. package/extensions/canvas.ts +1 -1
  122. package/package.json +42 -17
  123. package/dist/client/assets/_node-stub_node_crypto-C_7epg3G.js +0 -1
  124. package/dist/client/assets/_node-stub_node_fs-B-VOzeXW.js +0 -1
  125. package/dist/client/assets/_node-stub_node_http-CROXaVGJ.js +0 -1
  126. package/dist/client/assets/_node-stub_node_os-TTKJha15.js +0 -1
  127. package/dist/client/assets/_node-stub_node_path-DJCJiKv7.js +0 -1
  128. package/dist/client/assets/_node-stub_stream-DDexIdn_.js +0 -1
  129. package/dist/client/assets/index-BtDAXAo1.css +0 -1
  130. package/dist/client/assets/index-CAQqlK61.js +0 -131
  131. package/dist/client/assets/index-CJEeBmBj.js +0 -3
  132. package/dist/client/assets/index-DUEhKmPQ.js +0 -1
  133. package/dist/client/assets/index-XEVKscP-.js +0 -4443
  134. package/dist/client/assets/index-woI4agCy.js +0 -45
  135. package/dist/server/server/attached-session.js +0 -209
  136. package/dist/server/server/load-trace-store.js +0 -48
  137. package/dist/server/server/session-lifecycle.js +0 -155
  138. 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 "@mariozechner/pi-coding-agent";
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 = "2";
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 meta = this.extractSessionMeta(sessionPath, stat.mtimeMs);
42
- if (!meta) {
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
- return sessions;
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
- id: header.id,
176
- path: sessionPath,
177
- cwd,
178
- cwdDisplay: cwd && this.cwdDisplayFormatter ? this.cwdDisplayFormatter(cwd) : cwd,
179
- name,
180
- created: created.toISOString(),
181
- modified: modified.toISOString(),
182
- lastUserPromptTime: lastUserPromptTimeMs > 0 ? new Date(lastUserPromptTimeMs).toISOString() : undefined,
183
- messageCount,
184
- firstMessage: firstMessage || "(no messages)",
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 { 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). */
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 model for ws-handler
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, clientVersion) {
160
- if (this._version === clientVersion && clientHash === this._hash)
224
+ computeSyncOp(clientJson, clientHash, _clientVersion) {
225
+ // Some Pi lifecycle events advance the internal version without changing
226
+ // the serialized state. Do not emit a same-revision empty delta: wire
227
+ // revisions describe observable state changes, not internal event counts.
228
+ if (clientHash === this._hash)
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 context = buildSessionContext(entries);
227
- messages = context.messages ?? [];
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 = JSON.stringify(state);
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
+ }