@streichsbaer/pi-mesh 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,10 +12,10 @@
12
12
  ## Installation
13
13
 
14
14
  ```bash
15
- npm install -g @streichsbaer/pi-mesh
15
+ npm install -g @streichsbaer/pi-mesh --ignore-scripts
16
16
  ```
17
17
 
18
- The package installs the `pi-mesh` binary.
18
+ The package installs the `pi-mesh` binary and does not require npm install scripts.
19
19
 
20
20
  ## Goals
21
21
 
@@ -33,6 +33,7 @@ pi-mesh sessions list --include-pi # include recent unmanaged Pi sessions
33
33
  pi-mesh sessions find auth
34
34
  pi-mesh sessions list --folder ./api
35
35
  pi-mesh sessions list --label pi-mesh-development
36
+ pi-mesh sessions delete worker-api
36
37
  pi-mesh models list sonnet --folder ./api --scoped
37
38
  pi-mesh transcript <session> --last 3
38
39
  pi-mesh state <session>
@@ -76,7 +77,6 @@ State is stored outside the repo:
76
77
  ```text
77
78
  ~/.pi/agent/pi-mesh/
78
79
  registry.jsonl
79
- inbox/
80
80
  locks/
81
81
  socket-dir
82
82
  ```
@@ -87,6 +87,8 @@ Live control sockets use short hashed paths under a private randomized runtime d
87
87
 
88
88
  Already-running normal Pi sessions can be discovered and read from their JSONL files. `pi-mesh sessions list` shows managed sessions by default; pass `--include-pi` or `--all` to include recent unmanaged Pi sessions. To message one, close the original process first and attach/resume it through `pi-mesh attach` so pi-mesh can own the live control socket.
89
89
 
90
+ Use `pi-mesh sessions delete <session>` to remove a stopped managed session from the local registry. Active sessions, including records with a live process or socket, must be stopped first. The Pi JSONL session file is kept unless `--delete-file` is passed and confirmed; pass `--force` to skip that file-delete confirmation.
91
+
90
92
  ## Install for development
91
93
 
92
94
  ```bash
package/dist/cli.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { promises as fs } from "node:fs";
3
3
  import path from "node:path";
4
+ import { createInterface } from "node:readline/promises";
4
5
  import { exists } from "./utils.js";
5
6
  import { formatTimestamp, truncate, compactWhitespace } from "./utils.js";
6
7
  import { loadSessionData, loadSessionDataForSession, renderToolLine, resolveSessionSpec, searchSessions, tail } from "./pi-session-parser.js";
7
- import { createMeshId, filterManagedSessions, findManagedSessions, listManagedSessions, lockPathFor, normalizeLabels, socketPathFor, upsertManagedSession } from "./registry.js";
8
+ import { createMeshId, deleteManagedSession, filterManagedSessions, findManagedSessionById, findManagedSessions, listManagedSessions, lockPathFor, normalizeLabels, socketPathFor, upsertManagedSession } from "./registry.js";
8
9
  import { resolveMesh } from "./mesh.js";
9
10
  import { withDirectoryLock } from "./lock.js";
10
11
  import { mergeModelSelection } from "./model-selection.js";
@@ -137,6 +138,15 @@ async function getSessionSelector(args, spec) {
137
138
  function isLiveStatus(status) {
138
139
  return status === "running" || status === "idle" || status === "busy" || status === "starting";
139
140
  }
141
+ async function activeSessionReason(record) {
142
+ if (isLiveStatus(record.status))
143
+ return `status=${record.status}`;
144
+ if (record.socketPath && await exists(record.socketPath))
145
+ return `socket=${record.socketPath}`;
146
+ if (record.status === "error" && isProcessAlive(record.pid))
147
+ return `pid=${record.pid}`;
148
+ return undefined;
149
+ }
140
150
  function formatMatches(records) {
141
151
  return records.map((record) => `- ${record.meshId}${record.name ? ` name=${JSON.stringify(record.name)}` : ""} folder=${record.folder} status=${record.status}`).join("\n");
142
152
  }
@@ -147,14 +157,44 @@ function isProcessAlive(pid) {
147
157
  process.kill(pid, 0);
148
158
  return true;
149
159
  }
150
- catch {
151
- return false;
160
+ catch (error) {
161
+ return error.code === "EPERM";
152
162
  }
153
163
  }
154
164
  function isStaleSocketError(error) {
155
165
  const code = error?.code;
156
166
  return code === "ECONNREFUSED" || code === "ENOENT" || code === "EPIPE" || code === "ECONNRESET";
157
167
  }
168
+ async function confirmDeleteSessionFile(sessionFile) {
169
+ if (!process.stdin.isTTY || !process.stderr.isTTY) {
170
+ throw new Error("--delete-file requires an interactive terminal. Pass --force to delete the session file without confirmation.");
171
+ }
172
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
173
+ try {
174
+ for (;;) {
175
+ const answer = (await rl.question(`Delete session file ${sessionFile}? [Y/n] `)).trim().toLowerCase();
176
+ if (answer === "" || answer === "y" || answer === "yes")
177
+ return true;
178
+ if (answer === "n" || answer === "no")
179
+ return false;
180
+ console.error("Please answer y or n.");
181
+ }
182
+ }
183
+ finally {
184
+ rl.close();
185
+ }
186
+ }
187
+ async function removeExistingFile(filePath) {
188
+ try {
189
+ await fs.rm(filePath);
190
+ return true;
191
+ }
192
+ catch (error) {
193
+ if (error.code === "ENOENT")
194
+ return false;
195
+ throw error;
196
+ }
197
+ }
158
198
  async function refreshStaleManagedSessions(mesh, records) {
159
199
  const refreshed = [];
160
200
  for (const record of records) {
@@ -178,6 +218,7 @@ function printHelp() {
178
218
  Usage:
179
219
  pi-mesh sessions list [--folder <dir>] [--name <name>] [--label <label>] [--limit 25] [--json] [--include-pi|--all]
180
220
  pi-mesh sessions find <query> [--folder <dir>] [--name <name>] [--label <label>] [--limit 25] [--json] [--include-pi|--all]
221
+ pi-mesh sessions delete <session> [--folder <dir>] [--name <name>] [--label <label>] [--delete-file] [--force] [--json]
181
222
  pi-mesh transcript <session> [--folder <dir>] [--name <name>] [--label <label>] [--last 3] [--json] [--show-tools]
182
223
  pi-mesh state <session> [--folder <dir>] [--name <name>] [--label <label>] [--json]
183
224
  pi-mesh models list [search] [--folder <dir>] [--json] [--all] [--scoped]
@@ -256,6 +297,51 @@ async function cmdSessions(parsed) {
256
297
  }
257
298
  return;
258
299
  }
300
+ if (sub === "delete" || sub === "remove" || sub === "forget") {
301
+ const spec = parsed.positionals[2];
302
+ if (!spec)
303
+ throw new Error("Usage: pi-mesh sessions delete <session>");
304
+ const selector = await getSessionSelector(parsed, spec);
305
+ const matches = await findManagedSessions(mesh, selector);
306
+ if (matches.length === 0)
307
+ throw new Error(`No managed session matches ${JSON.stringify(spec)}.`);
308
+ if (matches.length > 1)
309
+ throw new Error(`Multiple managed sessions match ${JSON.stringify(spec)}; refine with --folder, --name, or --label:\n${formatMatches(matches)}`);
310
+ const deleteFileRequested = getBool(parsed, "delete-file");
311
+ const force = getBool(parsed, "force");
312
+ const confirmedSessionFile = matches[0].sessionFile;
313
+ await assertNotAlreadyActive(matches[0], "deleting it");
314
+ const deleteFileApproved = deleteFileRequested && (force || await confirmDeleteSessionFile(confirmedSessionFile));
315
+ const result = await withDirectoryLock(lockPathFor(mesh, matches[0].meshId), async () => {
316
+ const record = await findManagedSessionById(mesh, matches[0].meshId);
317
+ if (!record)
318
+ throw new Error(`Session ${matches[0].meshId} was deleted concurrently.`);
319
+ await assertNotAlreadyActive(record, "deleting it");
320
+ let deletedFile = false;
321
+ if (deleteFileApproved) {
322
+ if (!force && record.sessionFile !== confirmedSessionFile) {
323
+ throw new Error(`Session file changed from ${confirmedSessionFile} to ${record.sessionFile}; rerun delete to confirm the new file.`);
324
+ }
325
+ deletedFile = await removeExistingFile(record.sessionFile);
326
+ }
327
+ if (record.socketPath)
328
+ await fs.rm(record.socketPath, { force: true }).catch(() => undefined);
329
+ await deleteManagedSession(mesh, record.meshId);
330
+ return { record, deletedFile };
331
+ });
332
+ if (asJson) {
333
+ console.log(JSON.stringify({ ok: true, deleted: result.record, deletedFile: result.deletedFile }, null, 2));
334
+ return;
335
+ }
336
+ console.log(`Deleted managed session ${result.record.meshId} from the pi-mesh registry.`);
337
+ if (result.deletedFile)
338
+ console.log(`Deleted session file ${result.record.sessionFile}.`);
339
+ else if (deleteFileRequested)
340
+ console.log(`Kept session file ${result.record.sessionFile}.`);
341
+ else
342
+ console.log(`Kept session file ${result.record.sessionFile}. Pass --delete-file to remove it too.`);
343
+ return;
344
+ }
259
345
  if (sub === "find") {
260
346
  const query = parsed.positionals.slice(2).join(" ").trim();
261
347
  if (!query)
@@ -443,8 +529,8 @@ async function registerSession(mesh, input) {
443
529
  folder: input.folder,
444
530
  sessionFile: input.sessionFile,
445
531
  rawSessionId: input.rawSessionId,
446
- pid: process.pid,
447
- socketPath: input.socketPath,
532
+ pid: input.status === "offline" ? undefined : process.pid,
533
+ socketPath: input.status === "offline" ? undefined : input.socketPath,
448
534
  createdAt: now,
449
535
  updatedAt: now,
450
536
  lastError: input.lastError,
@@ -453,12 +539,29 @@ async function registerSession(mesh, input) {
453
539
  }
454
540
  async function upsertManagedStatus(mesh, record, patch) {
455
541
  const pendingModelSelection = record.pendingModelSelection && (await exists(record.sessionFile)) ? undefined : record.pendingModelSelection;
456
- return upsertManagedSession(mesh, { ...record, pendingModelSelection, ...patch });
542
+ const next = { ...record, pendingModelSelection, ...patch };
543
+ if (next.status === "offline") {
544
+ next.pid = undefined;
545
+ next.socketPath = undefined;
546
+ }
547
+ return upsertManagedSession(mesh, next);
457
548
  }
458
- function assertNotAlreadyLive(record, action) {
459
- if (!record || !isLiveStatus(record.status) || !isProcessAlive(record.pid))
549
+ async function assertNotAlreadyActive(record, action) {
550
+ if (!record)
551
+ return;
552
+ const activeReason = await activeSessionReason(record);
553
+ if (!activeReason)
460
554
  return;
461
- throw new Error(`Session ${record.meshId} is already live (${record.status}); use pi-mesh send or stop that TUI before ${action}.`);
555
+ throw new Error(`Session ${record.meshId} is already active (${activeReason}); use pi-mesh send or stop that TUI before ${action}.`);
556
+ }
557
+ async function claimManagedSessionForActivation(mesh, record, patch, action) {
558
+ return withDirectoryLock(lockPathFor(mesh, record.meshId), async () => {
559
+ const latest = await findManagedSessionById(mesh, record.meshId);
560
+ if (!latest)
561
+ throw new Error(`Session ${record.meshId} was deleted concurrently.`);
562
+ await assertNotAlreadyActive(latest, action);
563
+ return upsertManagedSession(mesh, { ...latest, ...patch });
564
+ });
462
565
  }
463
566
  async function cmdSpawn(parsed) {
464
567
  const folder = await resolveFolder(getString(parsed, "folder") || process.cwd());
@@ -524,6 +627,8 @@ async function cmdSpawn(parsed) {
524
627
  sessionFile: result.sessionFile || record.sessionFile,
525
628
  rawSessionId: result.rawSessionId,
526
629
  status: "offline",
630
+ pid: undefined,
631
+ socketPath: undefined,
527
632
  pendingModelSelection: undefined,
528
633
  });
529
634
  }
@@ -549,15 +654,26 @@ async function cmdRun(parsed) {
549
654
  throw new Error(`Multiple sessions named ${JSON.stringify(name)} match this folder/label selection; use a session id or --new:\n${formatMatches(matches)}`);
550
655
  existing = matches[0];
551
656
  }
552
- assertNotAlreadyLive(existing, "opening another live TUI");
553
- const sessionFile = existing?.sessionFile;
657
+ await assertNotAlreadyActive(existing, "opening another live TUI");
554
658
  const pendingModelSelection = existing && !(await exists(existing.sessionFile)) ? existing.pendingModelSelection : undefined;
555
659
  const seedModelSelection = mergeModelSelection(pendingModelSelection, rawModelSelection);
556
660
  const runFolder = existing?.folder || folder;
557
661
  const modelSelection = await validateCliModelSelection(runFolder, seedModelSelection);
558
- const meshId = existing?.meshId || createMeshId({ folder: runFolder, sessionFile });
662
+ const meshId = existing?.meshId || createMeshId({ folder: runFolder, sessionFile: existing?.sessionFile });
559
663
  const socketPath = await socketPathFor(mesh, meshId);
560
664
  let record = existing;
665
+ if (record) {
666
+ record = await claimManagedSessionForActivation(mesh, record, {
667
+ name,
668
+ labels: labels.length ? labels : record.labels,
669
+ kind: "interactive",
670
+ status: "starting",
671
+ pid: process.pid,
672
+ socketPath,
673
+ pendingModelSelection: modelSelection && !(await exists(record.sessionFile)) ? modelSelection : undefined,
674
+ }, "opening another live TUI");
675
+ }
676
+ const sessionFile = record?.sessionFile;
561
677
  const { runInteractive } = await import("./pi-runner.js");
562
678
  await runInteractive({
563
679
  cwd: runFolder,
@@ -602,7 +718,7 @@ async function cmdAttach(parsed) {
602
718
  const mesh = await getMesh();
603
719
  await refreshStaleManagedSessions(mesh, await listManagedSessions(mesh));
604
720
  const resolved = await resolveSessionFile(mesh, spec);
605
- assertNotAlreadyLive(resolved.managed, "attaching it again");
721
+ await assertNotAlreadyActive(resolved.managed, "attaching it again");
606
722
  const folder = getString(parsed, "folder") ? await resolveFolder(getString(parsed, "folder")) : resolved.folder;
607
723
  const pendingModelSelection = resolved.managed && !(await exists(resolved.managed.sessionFile)) ? resolved.managed.pendingModelSelection : undefined;
608
724
  const seedModelSelection = mergeModelSelection(pendingModelSelection, rawModelSelection);
@@ -612,13 +728,25 @@ async function cmdAttach(parsed) {
612
728
  const meshId = resolved.managed?.meshId || createMeshId({ folder, sessionFile: resolved.sessionFile });
613
729
  const socketPath = await socketPathFor(mesh, meshId);
614
730
  let record = resolved.managed;
731
+ if (record) {
732
+ record = await claimManagedSessionForActivation(mesh, record, {
733
+ name,
734
+ labels: labels.length ? labels : record.labels,
735
+ folder,
736
+ kind: "attached",
737
+ status: "starting",
738
+ pid: process.pid,
739
+ socketPath,
740
+ pendingModelSelection: modelSelection && !(await exists(record.sessionFile)) ? modelSelection : undefined,
741
+ }, "attaching it again");
742
+ }
615
743
  if (!resolved.managed) {
616
744
  console.error("Note: attaching an unmanaged Pi session. Close any other Pi process using the same JSONL file first.");
617
745
  }
618
746
  const { runInteractive } = await import("./pi-runner.js");
619
747
  await runInteractive({
620
748
  cwd: folder,
621
- sessionFile: resolved.sessionFile,
749
+ sessionFile: record?.sessionFile || resolved.sessionFile,
622
750
  name,
623
751
  socketPath,
624
752
  modelSelection,
@@ -696,6 +824,8 @@ async function deliverToManagedSession(mesh, managed, message, delivery, rawMode
696
824
  sessionFile: result.sessionFile || managed.sessionFile,
697
825
  rawSessionId: result.rawSessionId,
698
826
  status: "offline",
827
+ pid: undefined,
828
+ socketPath: undefined,
699
829
  lastError: undefined,
700
830
  pendingModelSelection: undefined,
701
831
  });
package/dist/mesh.js CHANGED
@@ -6,7 +6,6 @@ export async function resolveMesh() {
6
6
  id: "local",
7
7
  baseDir,
8
8
  registryFile: path.join(baseDir, "registry.jsonl"),
9
- inboxDir: path.join(baseDir, "inbox"),
10
9
  locksDir: path.join(baseDir, "locks"),
11
10
  socketDirFile: path.join(baseDir, "socket-dir"),
12
11
  };
@@ -16,7 +15,6 @@ export async function resolveMesh() {
16
15
  export async function ensureMesh(mesh) {
17
16
  await Promise.all([
18
17
  ensureDir(mesh.baseDir),
19
- ensureDir(mesh.inboxDir),
20
18
  ensureDir(mesh.locksDir),
21
19
  ]);
22
20
  }
@@ -17,8 +17,10 @@ export declare function listManagedSessions(mesh: MeshPaths): Promise<ManagedSes
17
17
  export declare function sessionMatchesSelector(record: ManagedSessionRecord, selector: SessionSelector): boolean;
18
18
  export declare function filterManagedSessions(records: ManagedSessionRecord[], selector: SessionSelector): ManagedSessionRecord[];
19
19
  export declare function findManagedSessions(mesh: MeshPaths, selector: SessionSelector): Promise<ManagedSessionRecord[]>;
20
+ export declare function findManagedSessionById(mesh: MeshPaths, meshId: string): Promise<ManagedSessionRecord | undefined>;
20
21
  export declare function findManagedSession(mesh: MeshPaths, spec: string): Promise<ManagedSessionRecord | undefined>;
21
22
  export declare function upsertManagedSession(mesh: MeshPaths, record: ManagedSessionRecord): Promise<ManagedSessionRecord>;
23
+ export declare function deleteManagedSession(mesh: MeshPaths, meshId: string): Promise<void>;
22
24
  export declare function markManagedSession(mesh: MeshPaths, meshId: string, patch: Partial<ManagedSessionRecord>): Promise<ManagedSessionRecord | undefined>;
23
25
  export declare function socketPathFor(mesh: MeshPaths, meshId: string): Promise<string>;
24
26
  export declare function lockPathFor(mesh: MeshPaths, meshId: string): string;
package/dist/registry.js CHANGED
@@ -77,6 +77,9 @@ export function filterManagedSessions(records, selector) {
77
77
  export async function findManagedSessions(mesh, selector) {
78
78
  return filterManagedSessions(await listManagedSessions(mesh), selector);
79
79
  }
80
+ export async function findManagedSessionById(mesh, meshId) {
81
+ return (await listManagedSessions(mesh)).find((record) => record.meshId === meshId);
82
+ }
80
83
  export async function findManagedSession(mesh, spec) {
81
84
  return (await findManagedSessions(mesh, { spec }))[0];
82
85
  }
@@ -97,6 +100,9 @@ export async function upsertManagedSession(mesh, record) {
97
100
  await appendRegistryEvent(mesh, { type: "upsert", record: next, timestamp: now });
98
101
  return next;
99
102
  }
103
+ export async function deleteManagedSession(mesh, meshId) {
104
+ await appendRegistryEvent(mesh, { type: "delete", meshId, timestamp: new Date().toISOString() });
105
+ }
100
106
  export async function markManagedSession(mesh, meshId, patch) {
101
107
  const current = await findManagedSession(mesh, meshId);
102
108
  if (!current)
package/dist/types.d.ts CHANGED
@@ -136,7 +136,6 @@ export interface MeshPaths {
136
136
  id: "local";
137
137
  baseDir: string;
138
138
  registryFile: string;
139
- inboxDir: string;
140
139
  locksDir: string;
141
140
  socketDirFile: string;
142
141
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@streichsbaer/pi-mesh",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "CLI and skill for coordinating Pi sessions through a local file/socket mesh",
5
5
  "type": "module",
6
6
  "packageManager": "npm@11.17.0",