pi-roam 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Warren Winter
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # Roam for Pi (`pi-roam`)
2
+
3
+ Post-hoc handoff of a live Pi session into tmux for remote continuation. If you forgot to start Pi inside tmux, you can conveniently run `/roam` mid-session and continue from another device over SSH.
4
+
5
+ ## Install
6
+
7
+ From npm:
8
+
9
+ ```bash
10
+ pi install npm:pi-roam
11
+ ```
12
+
13
+ From the dot314 git bundle (filtered install):
14
+
15
+ ```json
16
+ {
17
+ "packages": [
18
+ {
19
+ "source": "git:github.com/w-winter/dot314",
20
+ "extensions": ["extensions/roam/index.ts"],
21
+ "skills": [],
22
+ "themes": [],
23
+ "prompts": []
24
+ }
25
+ ]
26
+ }
27
+ ```
28
+
29
+ ## Setup
30
+
31
+ Optional per-user config:
32
+
33
+ - copy `config.json.example` → `config.json`
34
+ - location: `~/.pi/agent/extensions/roam/config.json`
35
+
36
+ Example:
37
+
38
+ ```json
39
+ {
40
+ "tailscale": {
41
+ "account": "you@example.com",
42
+ "binary": "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
43
+ }
44
+ }
45
+ ```
46
+
47
+ ## Usage
48
+
49
+ ```text
50
+ /roam [window-name]
51
+ ```
52
+
53
+ Defaults window name to the current cwd basename.
54
+
55
+ ## What it does
56
+
57
+ 1. Pre-flight checks (interactive TTY, not already in tmux, `tmux` installed, session exists)
58
+ 2. On macOS, optionally runs `tailscale switch <account>` then `tailscale up` (non-fatal)
59
+ 3. Forks the current session, clears the fork header's `parentSession` pointer
60
+ 4. Creates/joins dedicated tmux server `-L pi`, one window per roamed session
61
+ 5. Attaches your terminal to tmux and leaves parent process as inert exit-code forwarder
62
+ 6. Best-effort trashes original session file (for standard `~/.pi/` session paths)
63
+
64
+ ## Notes
65
+
66
+ - Cross-platform tmux behavior; Tailscale integration is currently macOS-specific by default binary path
67
+ - Uses dedicated tmux socket (`-L pi`) plus per-socket config for isolation
68
+ - Config template is intentionally user-specific; do not commit your local `config.json`
@@ -0,0 +1,6 @@
1
+ {
2
+ "tailscale": {
3
+ "account": "you@example.com",
4
+ "binary": "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
5
+ }
6
+ }
@@ -0,0 +1,531 @@
1
+ /**
2
+ * Roam Extension
3
+ *
4
+ * Move the current Pi session into a tmux window for remote access
5
+ *
6
+ * Usage:
7
+ * /roam [window-name] (default: cwd basename)
8
+ *
9
+ * Optional config (~/.pi/agent/extensions/roam/config.json):
10
+ * - copy from extensions/roam/config.json.example
11
+ * {
12
+ * "tailscale": {
13
+ * "account": "you@example.com",
14
+ * "binary": "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
15
+ * }
16
+ * }
17
+ *
18
+ * All Pi sessions share a single tmux session ("pi") on a dedicated socket (-L pi),
19
+ * each in its own window. Uses a custom tmux config with dual prefix keys:
20
+ * - Ctrl+S (available on iOS Termius toolbar)
21
+ * - Ctrl+B (tmux default, for local use on Mac)
22
+ *
23
+ * The dedicated socket ensures the custom config is always applied, regardless
24
+ * of other tmux servers that may be running.
25
+ *
26
+ * From Termius:
27
+ * - Attach: tmux -L pi -f ~/.config/pi-tmux/tmux.conf -u attach -t pi
28
+ * - Window list: Ctrl+S, then w
29
+ * - Next/prev window: Ctrl+S, then n/p
30
+ * - Detach: Ctrl+S, then d
31
+ * - No time limit between prefix and command key
32
+ *
33
+ * Flow:
34
+ * 1. Pre-flight: TTY check, not already in tmux, session exists, tmux installed
35
+ * 2. Optionally switch Tailscale account, then ensure Tailscale is up (non-fatal, macOS only)
36
+ * 3. Fork the current Pi session to a new file (parentSession cleared)
37
+ * 4. Create a tmux window (or session if first time) running the fork
38
+ * 5. Tear down parent terminal, attach to tmux
39
+ * 6. Trash original session file if in standard sessions dir (no duplicates)
40
+ * 7. Parent becomes inert, forwarding exit code
41
+ */
42
+
43
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
44
+ import { SessionManager } from "@mariozechner/pi-coding-agent";
45
+ import { spawn } from "node:child_process";
46
+ import { basename, join, resolve } from "node:path";
47
+ import { homedir } from "node:os";
48
+ import {
49
+ writeFileSync, existsSync, mkdirSync, realpathSync, readFileSync,
50
+ openSync, readSync, writeSync, closeSync, renameSync, unlinkSync,
51
+ } from "node:fs";
52
+
53
+ const TMUX_SESSION = "pi";
54
+ const TMUX_SOCKET = "pi";
55
+ const TAILSCALE_BIN = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
56
+ const TAILSCALE_TIMEOUT_MS = 10_000;
57
+ const TRASH_TIMEOUT_MS = 5_000;
58
+ const HEADER_READ_MAX = 8192;
59
+ const COPY_CHUNK_SIZE = 65_536;
60
+
61
+ const TMUX_CONFIG_CONTENT = [
62
+ "# Pi roam config — only used by /roam sessions (dedicated socket: -L pi)",
63
+ "# Does not affect your global ~/.tmux.conf or other tmux servers",
64
+ "",
65
+ "# Dual prefix: Ctrl+S (iOS Termius toolbar) and Ctrl+B (default, for local use)",
66
+ "set -g prefix C-s",
67
+ "set -g prefix2 C-b",
68
+ "bind C-s send-prefix",
69
+ "bind C-b send-prefix -2",
70
+ "",
71
+ "# UTF-8 and modern terminal support",
72
+ "set -g default-terminal 'screen-256color'",
73
+ "set -ga terminal-overrides ',xterm-256color:Tc'",
74
+ "",
75
+ "# Mouse support (useful for Termius touch scrolling)",
76
+ "set -g mouse on",
77
+ "",
78
+ "# Start window numbering at 1 (easier to reach on mobile)",
79
+ "set -g base-index 1",
80
+ "setw -g pane-base-index 1",
81
+ "",
82
+ "# Window status shows the name clearly",
83
+ "set -g status-left '[pi] '",
84
+ "set -g status-right ''",
85
+ "",
86
+ ].join("\n");
87
+
88
+ type RoamConfig = {
89
+ tailscale?: {
90
+ account?: string;
91
+ binary?: string;
92
+ };
93
+ };
94
+
95
+ type ResolvedRoamConfig = {
96
+ tailscaleAccount: string | null;
97
+ tailscaleBinary: string;
98
+ };
99
+
100
+ function getAgentDir(): string {
101
+ return process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
102
+ }
103
+
104
+ function getRoamConfigPath(): string {
105
+ return join(getAgentDir(), "extensions", "roam", "config.json");
106
+ }
107
+
108
+ /**
109
+ * Load optional /roam config and validate shape.
110
+ * Missing config is treated as defaults.
111
+ */
112
+ function loadRoamConfig(configPath: string): ResolvedRoamConfig {
113
+ const defaults: ResolvedRoamConfig = {
114
+ tailscaleAccount: null,
115
+ tailscaleBinary: TAILSCALE_BIN,
116
+ };
117
+
118
+ if (!existsSync(configPath)) {
119
+ return defaults;
120
+ }
121
+
122
+ let parsed: RoamConfig;
123
+ try {
124
+ parsed = JSON.parse(readFileSync(configPath, "utf-8")) as RoamConfig;
125
+ } catch (error: any) {
126
+ throw new Error(`Invalid JSON in ${configPath}: ${error?.message ?? String(error)}`);
127
+ }
128
+
129
+ const rawAccount = parsed.tailscale?.account;
130
+ if (rawAccount !== undefined && typeof rawAccount !== "string") {
131
+ throw new Error(`Invalid tailscale.account in ${configPath}; expected a string`);
132
+ }
133
+
134
+ const rawBinary = parsed.tailscale?.binary;
135
+ if (rawBinary !== undefined && typeof rawBinary !== "string") {
136
+ throw new Error(`Invalid tailscale.binary in ${configPath}; expected a string`);
137
+ }
138
+
139
+ return {
140
+ tailscaleAccount: rawAccount?.trim() || null,
141
+ tailscaleBinary: rawBinary?.trim() || TAILSCALE_BIN,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Write the tmux config file; throws on FS errors (caller must handle)
147
+ */
148
+ function ensureTmuxConfig(): string {
149
+ const configDir = join(
150
+ process.env.HOME || process.env.USERPROFILE || "/tmp",
151
+ ".config", "pi-tmux"
152
+ );
153
+ const configPath = join(configDir, "tmux.conf");
154
+
155
+ if (!existsSync(configDir)) {
156
+ mkdirSync(configDir, { recursive: true });
157
+ }
158
+ writeFileSync(configPath, TMUX_CONFIG_CONTENT);
159
+
160
+ return configPath;
161
+ }
162
+
163
+ /**
164
+ * Remove the parentSession field from a forked session's JSONL header
165
+ * without reading the entire file into memory. Reads only the first line
166
+ * (header), and if modification is needed, rewrites the file via a temp
167
+ * file using chunked streaming for the remaining content.
168
+ *
169
+ * Throws on FS errors (caller must handle).
170
+ */
171
+ function clearParentSession(sessionFile: string): void {
172
+ const fd = openSync(sessionFile, "r");
173
+ const buf = Buffer.alloc(HEADER_READ_MAX);
174
+ const bytesRead = readSync(fd, buf, 0, HEADER_READ_MAX, 0);
175
+ const headerChunk = buf.toString("utf-8", 0, bytesRead);
176
+ const newlineIdx = headerChunk.indexOf("\n");
177
+
178
+ if (newlineIdx === -1) {
179
+ closeSync(fd);
180
+ return;
181
+ }
182
+
183
+ const header = JSON.parse(headerChunk.slice(0, newlineIdx));
184
+ if (!header.parentSession) {
185
+ closeSync(fd);
186
+ return;
187
+ }
188
+
189
+ // parentSession exists — stream-rewrite with modified header
190
+ delete header.parentSession;
191
+ const newHeaderLine = JSON.stringify(header) + "\n";
192
+ const originalHeaderBytes = Buffer.byteLength(
193
+ headerChunk.slice(0, newlineIdx + 1), "utf-8"
194
+ );
195
+
196
+ const tmpPath = sessionFile + ".roam-tmp";
197
+ let wfd: number | undefined;
198
+ try {
199
+ wfd = openSync(tmpPath, "w");
200
+ const headerBuf = Buffer.from(newHeaderLine, "utf-8");
201
+ writeSync(wfd, headerBuf, 0, headerBuf.length);
202
+
203
+ // Copy rest of file in chunks (avoids loading full session into memory)
204
+ const copyBuf = Buffer.alloc(COPY_CHUNK_SIZE);
205
+ let pos = originalHeaderBytes;
206
+ while (true) {
207
+ const n = readSync(fd, copyBuf, 0, COPY_CHUNK_SIZE, pos);
208
+ if (n === 0) break;
209
+ writeSync(wfd, copyBuf, 0, n);
210
+ pos += n;
211
+ }
212
+ closeSync(wfd);
213
+ wfd = undefined;
214
+ closeSync(fd);
215
+ renameSync(tmpPath, sessionFile);
216
+ } catch (error) {
217
+ // Clean up temp file on failure
218
+ if (wfd !== undefined) try { closeSync(wfd); } catch {}
219
+ closeSync(fd);
220
+ try { unlinkSync(tmpPath); } catch {}
221
+ throw error;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Strip control characters from a window name to prevent tmux breakage
227
+ * and stdout parsing issues. Returns null if the result is empty.
228
+ */
229
+ function sanitizeWindowName(name: string): string | null {
230
+ const cleaned = name.replace(/[\x00-\x1f\x7f]/g, "").trim();
231
+ return cleaned || null;
232
+ }
233
+
234
+ /**
235
+ * Check if a session file is inside the standard Pi sessions directory (~/.pi/).
236
+ * Custom --session paths (e.g. /some/custom/path.jsonl) should not be trashed.
237
+ * Handles symlinks (e.g. ~/.pi/agent -> ~/dot314/agent) via realpathSync.
238
+ */
239
+ function isInStandardSessionsDir(sessionFile: string): boolean {
240
+ const home = process.env.HOME || process.env.USERPROFILE;
241
+ if (!home) return false;
242
+ const piDir = join(home, ".pi");
243
+ try {
244
+ const resolvedFile = realpathSync(sessionFile);
245
+ const resolvedPiDir = realpathSync(piDir);
246
+ return resolvedFile.startsWith(resolvedPiDir + "/");
247
+ } catch {
248
+ // realpathSync can fail if file/dir doesn't exist; fall back to resolve()
249
+ return resolve(sessionFile).startsWith(resolve(piDir) + "/");
250
+ }
251
+ }
252
+
253
+ export default function (pi: ExtensionAPI) {
254
+ const trashFileBestEffort = async (filePath: string): Promise<boolean> => {
255
+ try {
256
+ const { code } = await pi.exec("trash", [filePath], { timeout: TRASH_TIMEOUT_MS });
257
+ return code === 0;
258
+ } catch {
259
+ return false;
260
+ }
261
+ };
262
+
263
+ pi.registerCommand("roam", {
264
+ description: "Move session into a tmux window for remote access via Tailscale",
265
+ handler: async (args, ctx) => {
266
+ await ctx.waitForIdle();
267
+
268
+ // --- Pre-flight checks ---
269
+
270
+ if (!ctx.hasUI || !process.stdin.isTTY || !process.stdout.isTTY) {
271
+ if (ctx.hasUI) {
272
+ ctx.ui.notify("/roam requires an interactive terminal", "error");
273
+ }
274
+ return;
275
+ }
276
+
277
+ if (process.env.TMUX) {
278
+ ctx.ui.notify("Already inside tmux. Use Ctrl+S d (or Ctrl+B d) to detach.", "error");
279
+ return;
280
+ }
281
+
282
+ const tmuxCheck = await pi.exec("which", ["tmux"]);
283
+ if (tmuxCheck.code !== 0) {
284
+ ctx.ui.notify("tmux is not installed", "error");
285
+ return;
286
+ }
287
+
288
+ const sourceSessionFile = ctx.sessionManager.getSessionFile();
289
+ if (!sourceSessionFile) {
290
+ ctx.ui.notify("No persistent session (started with --no-session?)", "error");
291
+ return;
292
+ }
293
+
294
+ const leafId = ctx.sessionManager.getLeafId();
295
+ if (!leafId) {
296
+ ctx.ui.notify("No messages yet — nothing to roam", "error");
297
+ return;
298
+ }
299
+
300
+ const cwd = ctx.cwd;
301
+
302
+ // Window name: from args or cwd basename, sanitized for tmux safety
303
+ const rawName = args.trim() || basename(cwd);
304
+ const windowName = sanitizeWindowName(rawName);
305
+ if (!windowName) {
306
+ ctx.ui.notify("Invalid window name (empty after sanitization)", "error");
307
+ return;
308
+ }
309
+
310
+ let tailscaleAccount: string | null = null;
311
+ let tailscaleBinary = TAILSCALE_BIN;
312
+ const roamConfigPath = getRoamConfigPath();
313
+ try {
314
+ const roamConfig = loadRoamConfig(roamConfigPath);
315
+ tailscaleAccount = roamConfig.tailscaleAccount;
316
+ tailscaleBinary = roamConfig.tailscaleBinary;
317
+ } catch (error: any) {
318
+ ctx.ui.notify(
319
+ `Ignoring invalid /roam config (${roamConfigPath}): ${error?.message ?? String(error)}`,
320
+ "warning"
321
+ );
322
+ }
323
+
324
+ // Ensure dedicated tmux config exists and is up to date
325
+ let tmuxConfig: string;
326
+ try {
327
+ tmuxConfig = ensureTmuxConfig();
328
+ } catch (error: any) {
329
+ ctx.ui.notify(`Failed to write tmux config: ${error?.message ?? String(error)}`, "error");
330
+ return;
331
+ }
332
+
333
+ // Common tmux flags: dedicated socket + config file
334
+ const tmuxBase = ["-L", TMUX_SOCKET, "-f", tmuxConfig];
335
+
336
+ // Check tmux state on our dedicated socket
337
+ let sessionExists = false;
338
+ try {
339
+ const { code } = await pi.exec("tmux", [...tmuxBase, "has-session", "-t", TMUX_SESSION]);
340
+ sessionExists = code === 0;
341
+ } catch {
342
+ // tmux server not running on this socket — we'll create a new session
343
+ }
344
+
345
+ // Source the latest config unconditionally — covers the case where the
346
+ // server is running but our session doesn't exist yet. If the server
347
+ // isn't running, this fails harmlessly. If the config has errors, we warn.
348
+ {
349
+ const { code: srcCode, stderr: srcStderr } = await pi.exec(
350
+ "tmux", [...tmuxBase, "source-file", tmuxConfig]
351
+ );
352
+ if (srcCode !== 0 && sessionExists) {
353
+ // Only warn if we know the server is running (otherwise failure
354
+ // just means "no server" which is expected and fine)
355
+ ctx.ui.notify(
356
+ `tmux config warning: ${srcStderr || "source-file failed"}`,
357
+ "warning"
358
+ );
359
+ }
360
+ }
361
+
362
+ if (sessionExists) {
363
+ // Check for duplicate window name
364
+ const { code, stdout } = await pi.exec("tmux", [
365
+ ...tmuxBase,
366
+ "list-windows", "-t", TMUX_SESSION, "-F", "#{window_name}",
367
+ ]);
368
+ if (code !== 0) {
369
+ ctx.ui.notify("Failed to list tmux windows — tmux may be in an unexpected state", "error");
370
+ return;
371
+ }
372
+ const existingWindows = stdout.trim().split("\n").filter(Boolean);
373
+ if (existingWindows.includes(windowName)) {
374
+ ctx.ui.notify(
375
+ `Window "${windowName}" already exists in tmux session "${TMUX_SESSION}". ` +
376
+ `Use: /roam <different-name>`,
377
+ "error"
378
+ );
379
+ return;
380
+ }
381
+ }
382
+
383
+ // --- Tailscale (non-fatal, macOS only) ---
384
+
385
+ if (process.platform === "darwin") {
386
+ if (tailscaleAccount) {
387
+ ctx.ui.notify(`Switching Tailscale account: ${tailscaleAccount}`, "info");
388
+ try {
389
+ const { code, stderr } = await pi.exec(
390
+ tailscaleBinary,
391
+ ["switch", tailscaleAccount],
392
+ { timeout: TAILSCALE_TIMEOUT_MS }
393
+ );
394
+ if (code !== 0) {
395
+ ctx.ui.notify(
396
+ `Tailscale switch warning: ${stderr || "switch command failed"}`,
397
+ "warning"
398
+ );
399
+ }
400
+ } catch {
401
+ ctx.ui.notify("Tailscale switch unavailable — continuing", "warning");
402
+ }
403
+ }
404
+
405
+ ctx.ui.notify("Bringing up Tailscale...", "info");
406
+ try {
407
+ const { code, stderr } = await pi.exec(tailscaleBinary, ["up"], {
408
+ timeout: TAILSCALE_TIMEOUT_MS,
409
+ });
410
+ if (code !== 0) {
411
+ ctx.ui.notify(`Tailscale warning: ${stderr || "failed to start"}`, "warning");
412
+ }
413
+ } catch {
414
+ ctx.ui.notify("Tailscale not available — continuing without it", "warning");
415
+ }
416
+ }
417
+
418
+ // --- Fork session ---
419
+ // waitForIdle() above ensures the agent has finished streaming. Pi persists
420
+ // entries via synchronous appendFileSync, so by the time waitForIdle() resolves
421
+ // and the command handler runs, all entries should be flushed to disk.
422
+
423
+ let destSessionFile: string;
424
+ try {
425
+ const forked = SessionManager.forkFrom(sourceSessionFile, cwd);
426
+ const dest = forked.getSessionFile();
427
+ if (!dest) {
428
+ ctx.ui.notify("Fork produced no session file", "error");
429
+ return;
430
+ }
431
+ destSessionFile = dest;
432
+ } catch (error: any) {
433
+ ctx.ui.notify(`Failed to fork session: ${error?.message ?? String(error)}`, "error");
434
+ return;
435
+ }
436
+
437
+ // Remove parentSession pointer since we intend to trash the original.
438
+ // A dangling parentSession would break session_lineage and session_ask.
439
+ try {
440
+ clearParentSession(destSessionFile);
441
+ } catch (error: any) {
442
+ // Non-fatal: the session still works, just has a dangling parentSession
443
+ ctx.ui.notify(
444
+ `Warning: could not clear parent session reference: ${error?.message ?? String(error)}`,
445
+ "warning"
446
+ );
447
+ }
448
+
449
+ // --- Create tmux window if session already exists ---
450
+ // (must happen before terminal teardown so pi.exec still works)
451
+
452
+ let tmuxArgs: string[];
453
+
454
+ if (sessionExists) {
455
+ // Add a new window to the existing "pi" session
456
+ const { code, stderr, stdout } = await pi.exec("tmux", [
457
+ ...tmuxBase,
458
+ "new-window", "-t", TMUX_SESSION, "-n", windowName, "-c", cwd,
459
+ "pi", "--session", destSessionFile,
460
+ ]);
461
+ if (code !== 0) {
462
+ ctx.ui.notify(
463
+ `Failed to create tmux window: ${stderr || stdout || "unknown error"}`,
464
+ "error"
465
+ );
466
+ return;
467
+ }
468
+ // Attach to the session (new window is now current)
469
+ tmuxArgs = [...tmuxBase, "-u", "attach", "-t", TMUX_SESSION];
470
+ } else {
471
+ // Create new session with first window (attaches automatically)
472
+ tmuxArgs = [
473
+ ...tmuxBase, "-u", "new-session",
474
+ "-s", TMUX_SESSION, "-n", windowName, "-c", cwd,
475
+ "--", "pi", "--session", destSessionFile,
476
+ ];
477
+ }
478
+
479
+ // --- Tear down parent terminal ---
480
+
481
+ process.stdout.write("\x1b[<u"); // Pop kitty keyboard protocol
482
+ process.stdout.write("\x1b[?2004l"); // Disable bracketed paste
483
+ process.stdout.write("\x1b[?25h"); // Show cursor
484
+ process.stdout.write("\r\n");
485
+
486
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
487
+ process.stdin.setRawMode(false);
488
+ }
489
+
490
+ // --- Spawn tmux ---
491
+
492
+ const child = spawn("tmux", tmuxArgs, { stdio: "inherit" });
493
+
494
+ child.once("spawn", () => {
495
+ // Trash the original session file to prevent duplicates in /resume,
496
+ // but only if it's in the standard Pi sessions directory. Custom
497
+ // --session paths should not be trashed as that would be surprising.
498
+ if (isInStandardSessionsDir(sourceSessionFile)) {
499
+ void trashFileBestEffort(sourceSessionFile).then((trashed) => {
500
+ if (!trashed) {
501
+ process.stderr.write(
502
+ `\nNote: Could not trash original session file. Remove manually:\n ${sourceSessionFile}\n`
503
+ );
504
+ }
505
+ });
506
+ } else {
507
+ process.stderr.write(
508
+ `\nNote: Session file is at a custom path and was not trashed:\n ${sourceSessionFile}\n` +
509
+ `The roamed session in tmux is independent. You may see duplicates in /resume.\n`
510
+ );
511
+ }
512
+
513
+ // Stop the parent from stealing keypresses
514
+ process.stdin.removeAllListeners();
515
+ process.stdin.destroy();
516
+
517
+ // Parent should not react to signals
518
+ process.removeAllListeners("SIGINT");
519
+ process.removeAllListeners("SIGTERM");
520
+ process.on("SIGINT", () => {});
521
+ process.on("SIGTERM", () => {});
522
+ });
523
+
524
+ child.on("exit", (code) => process.exit(code ?? 0));
525
+ child.on("error", (err) => {
526
+ process.stderr.write(`Failed to launch tmux: ${err.message}\n`);
527
+ process.exit(1);
528
+ });
529
+ },
530
+ });
531
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "pi-roam",
3
+ "version": "0.1.0",
4
+ "description": "Post-hoc handoff of a live Pi session into tmux for remote continuation, with optional Tailscale account switching",
5
+ "keywords": ["pi-package", "pi", "pi-coding-agent", "tmux", "tailscale", "remote"],
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/w-winter/dot314.git",
10
+ "directory": "packages/pi-roam"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/w-winter/dot314/issues"
14
+ },
15
+ "homepage": "https://github.com/w-winter/dot314#readme",
16
+ "pi": {
17
+ "extensions": ["extensions/roam/index.ts"]
18
+ },
19
+ "peerDependencies": {
20
+ "@mariozechner/pi-coding-agent": ">=0.51.0"
21
+ },
22
+ "scripts": {
23
+ "prepack": "node ../../scripts/pi-package-prepack.mjs"
24
+ },
25
+ "files": ["extensions/**", "README.md", "LICENSE", "package.json"],
26
+ "dot314Prepack": {
27
+ "copy": [
28
+ { "from": "../../extensions/roam/index.ts", "to": "extensions/roam/index.ts" },
29
+ { "from": "../../extensions/roam/config.json.example", "to": "extensions/roam/config.json.example" },
30
+ { "from": "../../LICENSE", "to": "LICENSE" }
31
+ ]
32
+ }
33
+ }