@edeng23/pines 0.1.0

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 (98) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +37 -0
  3. package/dist/cli.js +83 -0
  4. package/dist/cli.js.map +1 -0
  5. package/dist/client/ansi.js +37 -0
  6. package/dist/client/ansi.js.map +1 -0
  7. package/dist/client/app.js +1868 -0
  8. package/dist/client/app.js.map +1 -0
  9. package/dist/client/boot.js +221 -0
  10. package/dist/client/boot.js.map +1 -0
  11. package/dist/client/commands.js +133 -0
  12. package/dist/client/commands.js.map +1 -0
  13. package/dist/client/daemon-client.js +99 -0
  14. package/dist/client/daemon-client.js.map +1 -0
  15. package/dist/client/forest/camera.js +88 -0
  16. package/dist/client/forest/camera.js.map +1 -0
  17. package/dist/client/forest/canvas.js +175 -0
  18. package/dist/client/forest/canvas.js.map +1 -0
  19. package/dist/client/forest/sidebar.js +182 -0
  20. package/dist/client/forest/sidebar.js.map +1 -0
  21. package/dist/client/forest/status.js +39 -0
  22. package/dist/client/forest/status.js.map +1 -0
  23. package/dist/client/forest/style.js +70 -0
  24. package/dist/client/forest/style.js.map +1 -0
  25. package/dist/client/forest/styles.js +429 -0
  26. package/dist/client/forest/styles.js.map +1 -0
  27. package/dist/client/forest/view.js +181 -0
  28. package/dist/client/forest/view.js.map +1 -0
  29. package/dist/client/forest/wordmark.js +26 -0
  30. package/dist/client/forest/wordmark.js.map +1 -0
  31. package/dist/client/input.js +89 -0
  32. package/dist/client/input.js.map +1 -0
  33. package/dist/client/theme.js +27 -0
  34. package/dist/client/theme.js.map +1 -0
  35. package/dist/client/tree/asciitree.js +277 -0
  36. package/dist/client/tree/asciitree.js.map +1 -0
  37. package/dist/client/tree/convmap.js +260 -0
  38. package/dist/client/tree/convmap.js.map +1 -0
  39. package/dist/client/tree/lanegraph.js +260 -0
  40. package/dist/client/tree/lanegraph.js.map +1 -0
  41. package/dist/client/tree/merge.js +161 -0
  42. package/dist/client/tree/merge.js.map +1 -0
  43. package/dist/client/tree/sessionview.js +163 -0
  44. package/dist/client/tree/sessionview.js.map +1 -0
  45. package/dist/client/tree/view.js +238 -0
  46. package/dist/client/tree/view.js.map +1 -0
  47. package/dist/client/uistate.js +45 -0
  48. package/dist/client/uistate.js.map +1 -0
  49. package/dist/daemon/agents.js +102 -0
  50. package/dist/daemon/agents.js.map +1 -0
  51. package/dist/daemon/branch.js +149 -0
  52. package/dist/daemon/branch.js.map +1 -0
  53. package/dist/daemon/embed-worker.js +74 -0
  54. package/dist/daemon/embed-worker.js.map +1 -0
  55. package/dist/daemon/essence.js +194 -0
  56. package/dist/daemon/essence.js.map +1 -0
  57. package/dist/daemon/extbridge.js +138 -0
  58. package/dist/daemon/extbridge.js.map +1 -0
  59. package/dist/daemon/lifecycle.js +134 -0
  60. package/dist/daemon/lifecycle.js.map +1 -0
  61. package/dist/daemon/pty-compat.js +53 -0
  62. package/dist/daemon/pty-compat.js.map +1 -0
  63. package/dist/daemon/semantic.js +337 -0
  64. package/dist/daemon/semantic.js.map +1 -0
  65. package/dist/daemon/server.js +808 -0
  66. package/dist/daemon/server.js.map +1 -0
  67. package/dist/daemon/supervisor.js +311 -0
  68. package/dist/daemon/supervisor.js.map +1 -0
  69. package/dist/extension/pines-extension.js +191 -0
  70. package/dist/extension/pines-extension.js.map +1 -0
  71. package/dist/layout/lexical.js +104 -0
  72. package/dist/layout/lexical.js.map +1 -0
  73. package/dist/layout/pca.js +91 -0
  74. package/dist/layout/pca.js.map +1 -0
  75. package/dist/layout/relax.js +47 -0
  76. package/dist/layout/relax.js.map +1 -0
  77. package/dist/session/ingest.js +95 -0
  78. package/dist/session/ingest.js.map +1 -0
  79. package/dist/session/parser.js +254 -0
  80. package/dist/session/parser.js.map +1 -0
  81. package/dist/shared/config.js +30 -0
  82. package/dist/shared/config.js.map +1 -0
  83. package/dist/shared/paths.js +47 -0
  84. package/dist/shared/paths.js.map +1 -0
  85. package/dist/shared/protocol.js +9 -0
  86. package/dist/shared/protocol.js.map +1 -0
  87. package/dist/shared/types.js +3 -0
  88. package/dist/shared/types.js.map +1 -0
  89. package/dist/shared/wire.js +51 -0
  90. package/dist/shared/wire.js.map +1 -0
  91. package/dist/store/db.js +203 -0
  92. package/dist/store/db.js.map +1 -0
  93. package/dist/store/watcher.js +69 -0
  94. package/dist/store/watcher.js.map +1 -0
  95. package/package.json +80 -0
  96. package/scripts/fix-node-pty.mjs +34 -0
  97. package/src/extension/pines-extension.ts +192 -0
  98. package/src/store/schema.sql +60 -0
@@ -0,0 +1,808 @@
1
+ /**
2
+ * The pines daemon: owns all pi PTYs, serves thin clients and pi-extension
3
+ * connections over one Unix socket with a role handshake, and keeps the
4
+ * SQLite forest state in sync with pi's session files on disk.
5
+ *
6
+ * Runs detached from any terminal (spawned via lifecycle.spawnServerDaemon).
7
+ * Continues running after every client disconnects — that is the whole point.
8
+ */
9
+ import { createServer } from "node:net";
10
+ import { appendFileSync, existsSync, unlinkSync } from "node:fs";
11
+ import { basename } from "node:path";
12
+ import { Wire } from "../shared/wire.js";
13
+ import { PROTOCOL_VERSION, isHello, } from "../shared/protocol.js";
14
+ import { daemonLogPath, ensurePinesHome, socketPath } from "../shared/paths.js";
15
+ import { clearDaemonPid, isServerListening, readDaemonPid, writeDaemonPid, } from "./lifecycle.js";
16
+ import { piVersion, Supervisor } from "./supervisor.js";
17
+ import { ExtBridge } from "./extbridge.js";
18
+ import { openDb, allTrees, deleteTree, treeBySessionPath, upsertTree, } from "../store/db.js";
19
+ import { watchSessions } from "../store/watcher.js";
20
+ import { ingestSessionFile } from "../session/ingest.js";
21
+ import { parseSessionFile, toTreeDetail } from "../session/parser.js";
22
+ import { assignLexicalPositions, isSanePosition } from "../layout/lexical.js";
23
+ /** Human-readable fallback name for sessions that never got an explicit one. */
24
+ function deriveTreeName(parsed) {
25
+ const clip = (s) => {
26
+ const t = s.replace(/\s+/g, " ").trim();
27
+ return t.length > 28 ? t.slice(0, 27) + "…" : t;
28
+ };
29
+ // Branched sessions share their parent's opening message — the divergence
30
+ // point (their leaf-most text) is what distinguishes them.
31
+ if (parsed.header.parentSession) {
32
+ for (let i = parsed.entries.length - 1; i >= 0; i--) {
33
+ const text = parsed.entries[i].text;
34
+ if (text)
35
+ return clip(text);
36
+ }
37
+ }
38
+ if (parsed.firstUserText)
39
+ return clip(parsed.firstUserText);
40
+ // Deliberately no directory fallback: a dozen trees named after the same
41
+ // repo tell you nothing. Clients render cwd dimmed while name is null.
42
+ return null;
43
+ }
44
+ import { relax } from "../layout/relax.js";
45
+ import { branchInPlace, branchToNewTree, setNodeLabel } from "./branch.js";
46
+ import { SemanticLayout } from "./semantic.js";
47
+ import { searchFts, similarTrees } from "../store/db.js";
48
+ /** How long a version-rejected connection stays open to accept `shutdown`. */
49
+ const REJECTED_GRACE_MS = 2000;
50
+ function log(msg) {
51
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
52
+ try {
53
+ appendFileSync(daemonLogPath(), line);
54
+ }
55
+ catch {
56
+ // Logging must never take the daemon down.
57
+ }
58
+ }
59
+ export class Daemon {
60
+ supervisor = new Supervisor();
61
+ db;
62
+ extBridge;
63
+ semantic;
64
+ clients = new Set();
65
+ server;
66
+ watcher;
67
+ shuttingDown = false;
68
+ /** Resolved once at startup; hellos sent before it lands simply omit it. */
69
+ piVersion;
70
+ constructor() {
71
+ this.db = openDb();
72
+ this.semantic = new SemanticLayout({
73
+ db: this.db,
74
+ supervisor: this.supervisor,
75
+ log,
76
+ toast: (text) => this.broadcast({ t: "toast", level: "info", text }),
77
+ });
78
+ this.extBridge = new ExtBridge(this.supervisor, {
79
+ claimSession: (treeId, hello) => this.claimSession(treeId, hello),
80
+ onSessionActivity: (sessionPath) => void this.ingest(sessionPath),
81
+ isAttached: (treeId) => this.isAttached(treeId),
82
+ onSettled: (treeId) => void this.runPendingNav(treeId),
83
+ log,
84
+ });
85
+ void piVersion().then((v) => {
86
+ this.piVersion = v;
87
+ });
88
+ this.loadPersistedTrees();
89
+ // Re-embed trees whose stored vectors predate the current chunking scheme
90
+ // (or the configured model). No-op on an up-to-date store.
91
+ this.semantic.enqueueStale();
92
+ this.supervisor.on("update", (rec) => {
93
+ this.persist(rec);
94
+ this.broadcast({ t: "forest_update", upsert: [this.supervisor.summary(rec)] });
95
+ });
96
+ this.supervisor.on("output", (treeId, data) => {
97
+ const b64 = Buffer.from(data, "utf8").toString("base64");
98
+ for (const c of this.clients) {
99
+ if (c.attached.has(treeId))
100
+ c.wire.send({ t: "output", treeId, data: b64 });
101
+ }
102
+ });
103
+ this.supervisor.on("exit", (treeId, code) => {
104
+ const rec = this.supervisor.trees.get(treeId);
105
+ if (rec?.sessionPath) {
106
+ // Keep the final screen for post-mortem peeks (best effort).
107
+ if (existsSync(rec.sessionPath))
108
+ void this.ingest(rec.sessionPath);
109
+ // A crash before the session file ever appeared must stay visible —
110
+ // deleting the tree would erase the only evidence anything failed.
111
+ else if (code === 0)
112
+ this.removeSession(rec.sessionPath);
113
+ else
114
+ log(`agent of ${treeId} crashed (exit ${code}) before writing ${rec.sessionPath}`);
115
+ }
116
+ for (const c of this.clients) {
117
+ if (c.attached.has(treeId))
118
+ c.wire.send({ t: "agent_exit", treeId, code });
119
+ }
120
+ });
121
+ }
122
+ loadPersistedTrees() {
123
+ for (const row of allTrees(this.db)) {
124
+ if (!existsSync(row.session_path)) {
125
+ deleteTree(this.db, row.tree_id);
126
+ continue;
127
+ }
128
+ // Corrupt persisted positions (from the old relax() explosion bug)
129
+ // reset to the unplaced sentinel so assignPositions re-places them.
130
+ const sane = isSanePosition(row.x, row.y);
131
+ if (!sane)
132
+ log(`resetting corrupt position of ${row.tree_id} (${row.x}, ${row.y})`);
133
+ const rec = this.supervisor.upsertDormant({
134
+ sessionPath: row.session_path,
135
+ sessionId: row.session_id,
136
+ name: row.name,
137
+ cwd: row.cwd,
138
+ parentSessionPath: row.parent_session_path,
139
+ leafId: row.leaf_id,
140
+ nodeCount: row.node_count,
141
+ x: sane ? row.x : 0,
142
+ y: sane ? row.y : 0,
143
+ mtime: row.mtime,
144
+ status: "dormant",
145
+ archived: row.archived === 1,
146
+ }, row.tree_id);
147
+ }
148
+ // Fork points (graft ordinal 0) hydrate parentEntryId for fork chips.
149
+ try {
150
+ const rows = this.db
151
+ .prepare("SELECT tree_id, parent_entry_id FROM graft_parents WHERE ordinal = 0")
152
+ .all();
153
+ for (const r of rows) {
154
+ const rec = this.supervisor.trees.get(r.tree_id);
155
+ if (rec)
156
+ rec.parentEntryId = r.parent_entry_id;
157
+ }
158
+ }
159
+ catch {
160
+ /* older DBs without the table */
161
+ }
162
+ log(`loaded ${this.supervisor.trees.size} persisted tree(s)`);
163
+ this.assignPositions();
164
+ }
165
+ persist(rec) {
166
+ if (!rec.sessionPath)
167
+ return;
168
+ upsertTree(this.db, {
169
+ tree_id: rec.treeId,
170
+ session_path: rec.sessionPath,
171
+ session_id: rec.sessionId,
172
+ name: rec.name,
173
+ cwd: rec.cwd,
174
+ parent_session_path: rec.parentSessionPath,
175
+ status: rec.status,
176
+ seen: rec.seen ? 1 : 0,
177
+ leaf_id: rec.leafId,
178
+ node_count: rec.nodeCount,
179
+ mtime: rec.mtime,
180
+ x: rec.x,
181
+ y: rec.y,
182
+ archived: rec.archived ? 1 : 0,
183
+ });
184
+ }
185
+ /** Place any unpositioned trees with the lexical (cwd + recency) layout. */
186
+ assignPositions() {
187
+ const all = [...this.supervisor.trees.values()];
188
+ const changed = new Set(assignLexicalPositions(all));
189
+ if (changed.size === 0)
190
+ return;
191
+ // Push newly placed trees out of each other's (and existing trees')
192
+ // space — minis have real extent at z1, so spacing must respect it.
193
+ const points = all.map((rec) => ({ x: rec.x, y: rec.y, pinned: !changed.has(rec), rec }));
194
+ relax(points, 3.6, 40);
195
+ for (const p of points) {
196
+ if (p.pinned)
197
+ continue;
198
+ p.rec.x = p.x;
199
+ p.rec.y = p.y;
200
+ this.supervisor.notify(p.rec);
201
+ }
202
+ }
203
+ isAttached(treeId) {
204
+ for (const c of this.clients)
205
+ if (c.attached.has(treeId))
206
+ return true;
207
+ return false;
208
+ }
209
+ /**
210
+ * Ingest a session file and sync the supervisor record for it (creating a
211
+ * dormant record for sessions discovered outside pines).
212
+ */
213
+ async ingest(sessionPath, preferredTreeId) {
214
+ if (!existsSync(sessionPath))
215
+ return undefined;
216
+ try {
217
+ const { treeId, parsed } = await ingestSessionFile(this.db, sessionPath, {
218
+ preferredTreeId,
219
+ });
220
+ let rec = [...this.supervisor.trees.values()].find((t) => t.sessionPath === sessionPath);
221
+ if (!rec) {
222
+ // Install the DB identity before the first update is broadcast. A
223
+ // provisional update followed by an in-memory rename leaves connected
224
+ // clients with a ghost node they can never remove.
225
+ rec = this.supervisor.upsertDormant({ sessionPath }, treeId);
226
+ }
227
+ rec.sessionId = parsed.header.sessionId;
228
+ rec.cwd = parsed.header.cwd ?? rec.cwd;
229
+ rec.parentSessionPath = parsed.header.parentSession;
230
+ rec.nodeCount = parsed.entries.length;
231
+ // Naming: explicit session name wins; otherwise derive from content.
232
+ // Also heal names persisted by the old directory-basename fallback —
233
+ // they made every tree in a repo show the same word.
234
+ if (parsed.name)
235
+ rec.name = parsed.name;
236
+ else if (!rec.name || rec.name === basename(rec.cwd ?? "")) {
237
+ rec.name = deriveTreeName(parsed) ?? rec.name;
238
+ }
239
+ // A live extension is the leaf authority; files are authoritative when dormant.
240
+ if (!rec.extensionConnected)
241
+ rec.leafId = parsed.leafId;
242
+ if (rec.parentSessionPath && !rec.parentEntryId) {
243
+ void this.computeForkPoint(rec, parsed);
244
+ }
245
+ this.supervisor.notify(rec); // persist + broadcast
246
+ if (rec.x === 0 && rec.y === 0)
247
+ this.assignPositions();
248
+ this.semantic.enqueue(rec);
249
+ return rec;
250
+ }
251
+ catch (err) {
252
+ log(`ingest failed for ${sessionPath}: ${err instanceof Error ? err.message : String(err)}`);
253
+ return undefined;
254
+ }
255
+ }
256
+ /** Remove a dormant session consistently from memory, SQLite, and clients. */
257
+ removeSession(sessionPath) {
258
+ const rec = [...this.supervisor.trees.values()].find((t) => t.sessionPath === sessionPath);
259
+ // A live pi may briefly rotate or defer its file. Its exit or a later add
260
+ // event will settle the final state.
261
+ if (rec?.agent?.isRunning)
262
+ return;
263
+ const treeId = rec?.treeId ?? treeBySessionPath(this.db, sessionPath)?.tree_id;
264
+ if (!treeId)
265
+ return;
266
+ this.supervisor.trees.delete(treeId);
267
+ deleteTree(this.db, treeId);
268
+ this.broadcast({ t: "forest_update", remove: [treeId] });
269
+ log(`removed missing session ${sessionPath}`);
270
+ }
271
+ /**
272
+ * An extension said hello: bind its session file to the tree record of the
273
+ * pi process we spawned (provisional treeId from env), reconciling identity
274
+ * with any record the file watcher created first for the same session path.
275
+ */
276
+ async claimSession(envTreeId, hello) {
277
+ const rec = await this.claimSessionInner(envTreeId, hello);
278
+ // A live pi on an archived tree would be a working agent hidden from the
279
+ // forest — an extension claiming a session always un-archives it.
280
+ if (rec?.archived) {
281
+ rec.archived = false;
282
+ this.supervisor.notify(rec);
283
+ }
284
+ return rec;
285
+ }
286
+ async claimSessionInner(envTreeId, hello) {
287
+ const spawned = this.supervisor.trees.get(envTreeId);
288
+ const sessionPath = hello.sessionPath;
289
+ if (!sessionPath)
290
+ return spawned;
291
+ if (spawned) {
292
+ // Absorb any watcher-created dormant duplicate for the same file.
293
+ const dup = [...this.supervisor.trees.values()].find((t) => t !== spawned && t.sessionPath === sessionPath && !t.agent);
294
+ if (dup) {
295
+ this.supervisor.trees.delete(dup.treeId);
296
+ this.broadcast({ t: "forest_update", remove: [dup.treeId] });
297
+ }
298
+ spawned.sessionPath = sessionPath;
299
+ const dbId = (await this.ingest(sessionPath, spawned.treeId))?.treeId;
300
+ // ingest() found rec by sessionPath === spawned, so identity only diverges
301
+ // when the DB already had a row (watcher won the race): adopt the DB id.
302
+ const row = this.db
303
+ .prepare("SELECT tree_id FROM trees WHERE session_path = ?")
304
+ .get(sessionPath);
305
+ const canonical = row?.tree_id ?? dbId ?? spawned.treeId;
306
+ // Never adopt an identity another live agent already owns — two pi
307
+ // processes claiming one session path must stay separate records.
308
+ const owner = canonical !== spawned.treeId ? this.supervisor.trees.get(canonical) : undefined;
309
+ if (canonical !== spawned.treeId && !owner?.agent?.isRunning) {
310
+ this.supervisor.trees.delete(spawned.treeId);
311
+ this.broadcast({ t: "forest_update", remove: [spawned.treeId] });
312
+ spawned.treeId = canonical;
313
+ this.supervisor.trees.set(canonical, spawned);
314
+ this.supervisor.setStatus(spawned, spawned.status);
315
+ }
316
+ return spawned;
317
+ }
318
+ // No spawned record (e.g. daemon restarted under a live pi): fall back to
319
+ // the session-path record.
320
+ return this.ingest(sessionPath);
321
+ }
322
+ broadcast(msg) {
323
+ for (const c of this.clients)
324
+ c.wire.send(msg);
325
+ }
326
+ async listen(path = socketPath()) {
327
+ ensurePinesHome();
328
+ if (existsSync(path)) {
329
+ // Unlinking a *live* socket does not fail — it silently steals the path
330
+ // and leaves the previous daemon running headless, still holding its
331
+ // ptys, its session watcher, and a writable handle on the same SQLite
332
+ // file. Probe before removing; only a socket with no listener is stale.
333
+ if (await isServerListening(path)) {
334
+ throw new Error(`another pines daemon is already listening on ${path} (pid ${readDaemonPid() ?? "unknown"}) — run 'pines kill' first`);
335
+ }
336
+ try {
337
+ unlinkSync(path);
338
+ }
339
+ catch {
340
+ /* ignore */
341
+ }
342
+ }
343
+ this.server = createServer((sock) => this.onConnection(new Wire(sock)));
344
+ await new Promise((resolve, reject) => {
345
+ this.server.once("error", reject);
346
+ this.server.listen(path, resolve);
347
+ });
348
+ writeDaemonPid();
349
+ this.watcher = watchSessions((file) => void this.ingest(file), {
350
+ onUnlink: (file) => this.removeSession(file),
351
+ });
352
+ log(`daemon listening on ${path} (pid ${process.pid})`);
353
+ }
354
+ onConnection(wire) {
355
+ let role;
356
+ let client;
357
+ // Set when we rejected the handshake but are still willing to accept the
358
+ // one message that must never depend on protocol agreement: shutdown.
359
+ let rejected = false;
360
+ wire.on("error", (err) => log(`conn error: ${err.message}`));
361
+ wire.on("close", () => {
362
+ if (client) {
363
+ this.clients.delete(client);
364
+ log(`client disconnected (${this.clients.size} remain)`);
365
+ }
366
+ });
367
+ wire.on("msg", (raw) => {
368
+ if (rejected) {
369
+ // A rejected connection gets exactly one useful verb. Without this, a
370
+ // daemon running stale code cannot be stopped by the very command its
371
+ // own error message recommends.
372
+ if (raw.t === "shutdown") {
373
+ log("shutdown requested by version-mismatched client");
374
+ void this.shutdown();
375
+ return;
376
+ }
377
+ wire.end();
378
+ return;
379
+ }
380
+ if (role === undefined) {
381
+ if (!isHello(raw)) {
382
+ wire.send({ t: "hello_err", reason: "other", message: "first message must be hello" });
383
+ wire.end();
384
+ return;
385
+ }
386
+ if (raw.protocolVersion !== PROTOCOL_VERSION) {
387
+ wire.send({
388
+ t: "hello_err",
389
+ reason: "version",
390
+ serverProtocolVersion: PROTOCOL_VERSION,
391
+ daemonPid: process.pid,
392
+ });
393
+ // Deliberately not end()ing: give the peer a grace window to ask for
394
+ // shutdown. Ending here also meant the *next* already-buffered frame
395
+ // tried to write to a closed wire ("write after end" in the log).
396
+ rejected = true;
397
+ setTimeout(() => wire.end(), REJECTED_GRACE_MS).unref?.();
398
+ return;
399
+ }
400
+ role = raw.role;
401
+ if (role === "client") {
402
+ const hello = raw;
403
+ client = {
404
+ wire,
405
+ cols: hello.cols ?? 80,
406
+ rows: hello.rows ?? 24,
407
+ attached: new Set(),
408
+ };
409
+ this.clients.add(client);
410
+ wire.send({
411
+ t: "hello_ok",
412
+ protocolVersion: PROTOCOL_VERSION,
413
+ daemonPid: process.pid,
414
+ piVersion: this.piVersion,
415
+ forest: this.supervisor.forest(),
416
+ });
417
+ log(`client connected (${this.clients.size} total)`);
418
+ }
419
+ else {
420
+ this.extBridge.handle(wire, raw);
421
+ }
422
+ return;
423
+ }
424
+ if (role === "client" && client) {
425
+ this.routeClient(client, raw).catch((err) => {
426
+ log(`client msg error: ${err instanceof Error ? err.stack : String(err)}`);
427
+ });
428
+ }
429
+ else {
430
+ if (!this.extBridge.handle(wire, raw)) {
431
+ log(`extension msg (unhandled): ${JSON.stringify(raw).slice(0, 200)}`);
432
+ }
433
+ }
434
+ });
435
+ }
436
+ async routeClient(client, msg) {
437
+ switch (msg.t) {
438
+ case "spawn_tree":
439
+ return this.handleSpawn(client, msg);
440
+ case "resume_tree":
441
+ return this.handleResume(client, msg);
442
+ case "attach":
443
+ return this.handleAttach(client, msg);
444
+ case "input": {
445
+ const rec = this.supervisor.trees.get(msg.treeId);
446
+ rec?.agent?.write(Buffer.from(msg.data, "base64").toString("utf8"));
447
+ return;
448
+ }
449
+ case "resize": {
450
+ const rec = this.supervisor.trees.get(msg.treeId);
451
+ rec?.agent?.resize(msg.cols, msg.rows);
452
+ return;
453
+ }
454
+ case "detach":
455
+ client.attached.delete(msg.treeId);
456
+ return;
457
+ case "ack_seen":
458
+ this.supervisor.ackSeen(msg.treeId);
459
+ return;
460
+ case "kill_agent":
461
+ this.supervisor.killAgent(msg.treeId);
462
+ return;
463
+ case "branch":
464
+ return this.handleBranch(client, msg);
465
+ case "search":
466
+ return this.handleSearch(client, msg);
467
+ case "similar":
468
+ return this.handleSimilar(client, msg);
469
+ case "get_tree":
470
+ return this.handleGetTree(client, msg);
471
+ case "rename_tree":
472
+ return this.handleRename(client, msg);
473
+ case "set_label":
474
+ return this.handleSetLabel(client, msg);
475
+ case "set_archived":
476
+ return this.handleSetArchived(client, msg);
477
+ case "relayout":
478
+ this.semantic.refit();
479
+ client.wire.send({ t: "result", re: msg.id, ok: true });
480
+ return;
481
+ case "shutdown":
482
+ log("shutdown requested by client");
483
+ await this.shutdown();
484
+ return;
485
+ case "hello":
486
+ return; // duplicate hello — ignore
487
+ }
488
+ }
489
+ fail(client, id, err) {
490
+ client.wire.send({
491
+ t: "result",
492
+ re: id,
493
+ ok: false,
494
+ err: err instanceof Error ? err.message : String(err),
495
+ });
496
+ }
497
+ async handleSpawn(client, msg) {
498
+ if (!existsSync(msg.cwd)) {
499
+ return this.fail(client, msg.id, `cwd does not exist: ${msg.cwd}`);
500
+ }
501
+ try {
502
+ const rec = this.supervisor.spawnAgent({
503
+ cwd: msg.cwd,
504
+ // No directory-name fallback: the first prompt becomes the title on
505
+ // ingest; until then clients render the cwd dimmed as a placeholder.
506
+ name: msg.name,
507
+ prompt: msg.prompt,
508
+ cols: client.cols,
509
+ rows: client.rows,
510
+ });
511
+ // Place the new tree immediately — don't wait for its session file to
512
+ // appear on disk before it shows up in the forest.
513
+ this.assignPositions();
514
+ client.wire.send({ t: "result", re: msg.id, ok: true, newTreeId: rec.treeId });
515
+ }
516
+ catch (err) {
517
+ this.fail(client, msg.id, err);
518
+ }
519
+ }
520
+ async handleResume(client, msg) {
521
+ const rec = this.supervisor.trees.get(msg.treeId);
522
+ if (!rec)
523
+ return this.fail(client, msg.id, "unknown tree");
524
+ if (rec.agent?.isRunning) {
525
+ client.wire.send({ t: "result", re: msg.id, ok: true, newTreeId: rec.treeId });
526
+ return;
527
+ }
528
+ if (!rec.sessionPath || !rec.cwd) {
529
+ return this.fail(client, msg.id, "tree has no session file");
530
+ }
531
+ if (!existsSync(rec.cwd)) {
532
+ // Spawning in a missing directory fails opaquely inside node-pty;
533
+ // detect it here so the user learns the actual reason.
534
+ return this.fail(client, msg.id, `cwd no longer exists: ${rec.cwd} (project moved or deleted?)`);
535
+ }
536
+ try {
537
+ this.supervisor.spawnAgent({
538
+ cwd: rec.cwd,
539
+ sessionPath: rec.sessionPath,
540
+ existing: rec,
541
+ cols: client.cols,
542
+ rows: client.rows,
543
+ });
544
+ client.wire.send({ t: "result", re: msg.id, ok: true, newTreeId: rec.treeId });
545
+ }
546
+ catch (err) {
547
+ this.fail(client, msg.id, err);
548
+ }
549
+ }
550
+ async handleAttach(client, msg) {
551
+ const rec = this.supervisor.trees.get(msg.treeId);
552
+ if (!rec?.agent?.isRunning) {
553
+ const why = rec?.lastExitCode != null && rec.lastExitCode !== 0
554
+ ? `agent crashed (exit ${rec.lastExitCode}) — see ${daemonLogPath()}`
555
+ : "no live agent for tree";
556
+ return this.fail(client, msg.id, why);
557
+ }
558
+ client.cols = msg.cols;
559
+ client.rows = msg.rows;
560
+ rec.agent.resize(msg.cols, msg.rows);
561
+ const snapshot = await rec.agent.snapshot();
562
+ client.attached.add(msg.treeId);
563
+ client.wire.send({
564
+ t: "attach_ok",
565
+ re: msg.id,
566
+ snapshot: Buffer.from(snapshot, "utf8").toString("base64"),
567
+ cols: rec.agent.cols,
568
+ rows: rec.agent.rows,
569
+ });
570
+ this.supervisor.ackSeen(msg.treeId);
571
+ }
572
+ async handleGetTree(client, msg) {
573
+ const rec = this.supervisor.trees.get(msg.treeId);
574
+ if (!rec?.sessionPath)
575
+ return this.fail(client, msg.id, "tree has no session file");
576
+ try {
577
+ const parsed = await parseSessionFile(rec.sessionPath);
578
+ const detail = toTreeDetail(rec.treeId, parsed);
579
+ if (rec.extensionConnected && rec.leafId)
580
+ detail.leafId = rec.leafId;
581
+ client.wire.send({ t: "result", re: msg.id, ok: true, tree: detail });
582
+ }
583
+ catch (err) {
584
+ this.fail(client, msg.id, err);
585
+ }
586
+ }
587
+ async handleRename(client, msg) {
588
+ const rec = this.supervisor.trees.get(msg.treeId);
589
+ if (!rec)
590
+ return this.fail(client, msg.id, "unknown tree");
591
+ if (rec.agent?.isRunning && this.extBridge.hasExtension(rec.treeId)) {
592
+ const res = await this.extBridge.command(rec.treeId, {
593
+ op: "set_session_name",
594
+ name: msg.name,
595
+ });
596
+ if (!res.ok)
597
+ return this.fail(client, msg.id, res.err);
598
+ }
599
+ else if (rec.sessionPath) {
600
+ // Dormant: daemon may write via pi's SDK (ownership rule).
601
+ try {
602
+ const { SessionManager } = await import("@earendil-works/pi-coding-agent");
603
+ SessionManager.open(rec.sessionPath).appendSessionInfo(msg.name);
604
+ }
605
+ catch (err) {
606
+ return this.fail(client, msg.id, err);
607
+ }
608
+ }
609
+ else {
610
+ return this.fail(client, msg.id, "tree has no session file");
611
+ }
612
+ rec.name = msg.name;
613
+ this.supervisor.notify(rec);
614
+ client.wire.send({ t: "result", re: msg.id, ok: true });
615
+ }
616
+ /**
617
+ * Fork-point fallback for sessions forked outside pines: the deepest entry
618
+ * of the child that also exists in the parent (ids are preserved on copy).
619
+ * Persisted to graft_parents so it computes once.
620
+ */
621
+ async computeForkPoint(rec, parsed) {
622
+ const parentPath = rec.parentSessionPath;
623
+ if (!parentPath || !existsSync(parentPath))
624
+ return;
625
+ try {
626
+ const parent = await parseSessionFile(parentPath);
627
+ const parentIds = new Set(parent.order);
628
+ for (let i = parsed.order.length - 1; i >= 0; i--) {
629
+ const id = parsed.order[i];
630
+ if (parentIds.has(id)) {
631
+ rec.parentEntryId = id;
632
+ this.db
633
+ .prepare(`INSERT INTO graft_parents (tree_id, parent_session_path, parent_entry_id, ordinal)
634
+ VALUES (?, ?, ?, 0)
635
+ ON CONFLICT(tree_id, ordinal) DO UPDATE SET
636
+ parent_session_path = excluded.parent_session_path,
637
+ parent_entry_id = excluded.parent_entry_id`)
638
+ .run(rec.treeId, parentPath, id);
639
+ this.supervisor.notify(rec);
640
+ return;
641
+ }
642
+ }
643
+ }
644
+ catch {
645
+ /* parent unreadable — chips fall back to end-of-session anchoring */
646
+ }
647
+ }
648
+ /** In-place navigations deferred behind busy agents (treeId → nodeId). */
649
+ pendingNav = new Map();
650
+ /** Run a deferred navigation once its agent settles to idle. */
651
+ async runPendingNav(treeId) {
652
+ const nodeId = this.pendingNav.get(treeId);
653
+ if (!nodeId)
654
+ return;
655
+ this.pendingNav.delete(treeId);
656
+ const rec = this.supervisor.trees.get(treeId);
657
+ if (!rec?.agent?.isRunning)
658
+ return;
659
+ const name = rec.name ?? treeId;
660
+ const res = await this.extBridge.command(treeId, { op: "navigate_tree", targetId: nodeId });
661
+ if (res.ok) {
662
+ rec.agent.write("\r");
663
+ this.broadcast({ t: "toast", level: "info", text: `${name}: continued from the requested node` });
664
+ log(`ran queued navigation for ${treeId} → ${nodeId}`);
665
+ }
666
+ else {
667
+ this.broadcast({
668
+ t: "toast",
669
+ level: "info",
670
+ text: `${name}: couldn't move to the requested node (${res.err})`,
671
+ });
672
+ }
673
+ }
674
+ branchDeps() {
675
+ return {
676
+ supervisor: this.supervisor,
677
+ extBridge: this.extBridge,
678
+ queueNav: (treeId, nodeId) => this.pendingNav.set(treeId, nodeId),
679
+ ingest: (sessionPath, preferredTreeId) => this.ingest(sessionPath, preferredTreeId),
680
+ setGraftParent: (treeId, parentSessionPath, parentEntryId) => {
681
+ const rec = this.supervisor.trees.get(treeId);
682
+ if (rec)
683
+ rec.parentEntryId = parentEntryId;
684
+ this.db
685
+ .prepare(`INSERT INTO graft_parents (tree_id, parent_session_path, parent_entry_id, ordinal)
686
+ VALUES (?, ?, ?, 0)
687
+ ON CONFLICT(tree_id, ordinal) DO UPDATE SET
688
+ parent_session_path = excluded.parent_session_path,
689
+ parent_entry_id = excluded.parent_entry_id`)
690
+ .run(treeId, parentSessionPath, parentEntryId);
691
+ },
692
+ log,
693
+ };
694
+ }
695
+ async handleBranch(client, msg) {
696
+ const rec = this.supervisor.trees.get(msg.treeId);
697
+ if (!rec)
698
+ return this.fail(client, msg.id, "unknown tree");
699
+ const res = msg.mode === "in-place"
700
+ ? await branchInPlace(this.branchDeps(), rec, msg.nodeId)
701
+ : await branchToNewTree(this.branchDeps(), rec, msg.nodeId, {
702
+ spawn: msg.spawn ?? false,
703
+ cols: client.cols,
704
+ rows: client.rows,
705
+ });
706
+ client.wire.send({
707
+ t: "result",
708
+ re: msg.id,
709
+ ok: res.ok,
710
+ err: res.err,
711
+ note: res.note,
712
+ newTreeId: res.newTreeId,
713
+ });
714
+ }
715
+ async handleSearch(client, msg) {
716
+ const hits = searchFts(this.db, msg.query, 40, msg.treeId);
717
+ const mapped = hits.map((h) => ({
718
+ treeId: h.tree_id,
719
+ entryId: h.entry_id,
720
+ kind: h.kind,
721
+ snippet: h.snippet,
722
+ rank: h.rank,
723
+ }));
724
+ client.wire.send({ t: "result", re: msg.id, ok: true, hits: mapped });
725
+ }
726
+ async handleSetArchived(client, msg) {
727
+ const rec = this.supervisor.trees.get(msg.treeId);
728
+ if (!rec)
729
+ return this.fail(client, msg.id, "unknown tree");
730
+ if (msg.archived && rec.agent?.isRunning) {
731
+ return this.fail(client, msg.id, "tree has a live agent — kill it first");
732
+ }
733
+ if (rec.archived !== msg.archived) {
734
+ rec.archived = msg.archived;
735
+ this.supervisor.notify(rec); // persist + broadcast
736
+ }
737
+ client.wire.send({ t: "result", re: msg.id, ok: true });
738
+ }
739
+ async handleSimilar(client, msg) {
740
+ // A tree with no embedding yet is not an error — the model may still be
741
+ // warming (or unavailable); the client renders the empty list accordingly.
742
+ const rows = similarTrees(this.db, msg.treeId, msg.k ?? 8);
743
+ const similar = rows.map((r) => ({ treeId: r.tree_id, score: r.score }));
744
+ client.wire.send({ t: "result", re: msg.id, ok: true, similar });
745
+ }
746
+ async handleSetLabel(client, msg) {
747
+ const rec = this.supervisor.trees.get(msg.treeId);
748
+ if (!rec)
749
+ return this.fail(client, msg.id, "unknown tree");
750
+ const res = await setNodeLabel(this.branchDeps(), rec, msg.nodeId, msg.label);
751
+ client.wire.send({ t: "result", re: msg.id, ok: res.ok, err: res.err });
752
+ }
753
+ async shutdown() {
754
+ // SIGTERM and a client `shutdown` can arrive together; tearing the
755
+ // supervisor down twice is not safe.
756
+ if (this.shuttingDown)
757
+ return;
758
+ this.shuttingDown = true;
759
+ log("daemon shutting down");
760
+ await this.watcher?.close();
761
+ await this.semantic.dispose();
762
+ await this.supervisor.shutdown();
763
+ for (const c of this.clients)
764
+ c.wire.end();
765
+ await new Promise((resolve) => {
766
+ if (!this.server)
767
+ return resolve();
768
+ this.server.close(() => resolve());
769
+ });
770
+ try {
771
+ this.db.close();
772
+ }
773
+ catch {
774
+ /* ignore */
775
+ }
776
+ const path = socketPath();
777
+ if (existsSync(path)) {
778
+ try {
779
+ unlinkSync(path);
780
+ }
781
+ catch {
782
+ /* ignore */
783
+ }
784
+ }
785
+ clearDaemonPid();
786
+ process.exit(0);
787
+ }
788
+ }
789
+ export async function runServer() {
790
+ const daemon = new Daemon();
791
+ process.on("SIGTERM", () => void daemon.shutdown());
792
+ process.on("SIGINT", () => void daemon.shutdown());
793
+ process.on("uncaughtException", (err) => log(`uncaught: ${err.stack ?? err.message}`));
794
+ process.on("unhandledRejection", (err) => log(`unhandled rejection: ${String(err)}`));
795
+ try {
796
+ await daemon.listen();
797
+ }
798
+ catch (err) {
799
+ // Losing the race to another daemon is a normal outcome, not a crash: exit
800
+ // quietly and leave the incumbent — and its running agents — alone.
801
+ const message = err instanceof Error ? err.message : String(err);
802
+ log(`refusing to start: ${message}`);
803
+ process.stderr.write(`pines: ${message}\n`);
804
+ process.exit(1);
805
+ }
806
+ // Keep the process alive forever; the socket server holds the loop open.
807
+ }
808
+ //# sourceMappingURL=server.js.map