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 +21 -0
- package/README.md +68 -0
- package/extensions/roam/config.json.example +6 -0
- package/extensions/roam/index.ts +531 -0
- package/package.json +33 -0
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,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
|
+
}
|