pi-tmux-thread 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 Aaron Weisberg
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,60 @@
1
+ # pi-tmux-thread
2
+
3
+ A small pi package that forks the current conversation into a separate pi process managed by tmux.
4
+
5
+ The new conversation starts from `pi --fork <current-session>` so it can continue with the same context, but anything you do there stays out of the current thread.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pi install git:github.com/aaronweisberg/pi-tmux-thread
11
+ ```
12
+
13
+ For local development:
14
+
15
+ ```bash
16
+ pi install /path/to/pi-tmux-thread
17
+ ```
18
+
19
+ Then reload pi:
20
+
21
+ ```text
22
+ /reload
23
+ ```
24
+
25
+ ## Commands
26
+
27
+ ```text
28
+ /tmux [--select|-s] [split|window] [initial prompt]
29
+ /tmux:split [--select|-s] [initial prompt]
30
+ /tmux:window [--select|-s] [initial prompt]
31
+ /tmux:fork [--select|-s] [split|window] [initial prompt]
32
+ /tmux:select [split|window] [initial prompt]
33
+ ```
34
+
35
+ - `/tmux --select` or `/tmux -s` starts the selectable fork workflow.
36
+ - `split` opens a side-by-side tmux pane in the current tmux window. It requires pi itself to be running inside tmux.
37
+ - `window` opens a new tmux window. If pi is not already inside tmux, it creates/reuses a detached per-project tmux session and tells you how to attach.
38
+ - The optional prompt is passed to the forked pi process as its first message.
39
+ - `--select`, `-s`, or `/tmux:select` opens a message picker showing only user messages and assistant text responses. The new tmux thread includes the selected message and excludes every later message from the current thread.
40
+
41
+ Examples:
42
+
43
+ ```text
44
+ /tmux:split investigate the failing test without changing this thread
45
+ /tmux:window brainstorm an alternate migration plan
46
+ /tmux:fork window review the current diff independently
47
+ /tmux:select window continue from the message I pick
48
+ /tmux:split --select try the other approach from this point
49
+ /tmux -s window continue from the message I pick
50
+ ```
51
+
52
+ ## Agent tool
53
+
54
+ The package also exposes a `tmux_thread` tool. The agent should only use it when you explicitly ask for a separate tmux-managed pi thread. Set `forkFrom` to `select` to ask you which message should be the last included message.
55
+
56
+ ## Notes
57
+
58
+ - Requires `tmux` on `PATH`.
59
+ - Requires a persisted pi session; it cannot fork an ephemeral `--no-session` run.
60
+ - Inspired by the tmux management approach in `@romansix/pi-tmux` and the hidden side-thread workflow in `pi-btw`.
@@ -0,0 +1,339 @@
1
+ import { DynamicBorder, SessionManager, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { StringEnum } from "@earendil-works/pi-ai";
3
+ import { Type, type Static } from "typebox";
4
+ import { Container, SelectList, Text, type SelectItem } from "@earendil-works/pi-tui";
5
+ import { execFileSync, execSync, spawnSync } from "node:child_process";
6
+ import { createHash } from "node:crypto";
7
+
8
+ const TOOL_NAME = "tmux_thread";
9
+ const DEFAULT_TITLE = "pi-fork";
10
+
11
+ type Target = "split" | "window";
12
+
13
+ const TmuxThreadParams = Type.Object({
14
+ target: StringEnum(["split", "window"] as const, {
15
+ description: "Where to open the forked pi conversation. Use split for a tmux pane in the current window, or window for a new tmux window.",
16
+ }),
17
+ prompt: Type.Optional(Type.String({ description: "Optional initial prompt to send to the forked conversation." })),
18
+ title: Type.Optional(Type.String({ description: "Optional tmux pane/window title. Defaults to pi-fork." })),
19
+ forkFrom: Type.Optional(StringEnum(["current", "select"] as const, {
20
+ description: "Fork from the current leaf, or interactively select a message to include as the last message in the new thread.",
21
+ default: "current",
22
+ })),
23
+ });
24
+
25
+ type TmuxThreadInput = Static<typeof TmuxThreadParams>;
26
+
27
+ function hasCommand(name: string): boolean {
28
+ const result = spawnSync("command", ["-v", name], { shell: true, stdio: "ignore" });
29
+ return result.status === 0;
30
+ }
31
+
32
+ function shellQuote(value: string): string {
33
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
34
+ }
35
+
36
+ function tmuxArg(value: string): string {
37
+ // tmux command arguments are shell parsed before tmux sees them, then passed as a single tmux argv.
38
+ return shellQuote(value);
39
+ }
40
+
41
+ function safeTitle(value: string | undefined): string {
42
+ return (value?.trim() || DEFAULT_TITLE).replace(/[\n\r]/g, " ").slice(0, 40);
43
+ }
44
+
45
+ function execText(command: string, cwd?: string): string {
46
+ return execSync(command, { cwd, encoding: "utf8", timeout: 10_000 }).trim();
47
+ }
48
+
49
+ function tmux(args: string[]): string {
50
+ return execFileSync("tmux", args, { encoding: "utf8", timeout: 10_000 }).trim();
51
+ }
52
+
53
+ function getGitRoot(cwd: string): string | null {
54
+ try {
55
+ return execText("git rev-parse --show-toplevel", cwd);
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ function projectSessionName(cwd: string): string {
62
+ const root = getGitRoot(cwd) ?? cwd;
63
+ const base = root.split("/").filter(Boolean).pop() ?? "project";
64
+ const slug = base.toLowerCase().replace(/[^a-z0-9_-]/g, "-").slice(0, 18) || "project";
65
+ const hash = createHash("md5").update(root).digest("hex").slice(0, 8);
66
+ return `${slug}-${hash}`;
67
+ }
68
+
69
+ function tmuxSessionExists(name: string): boolean {
70
+ try {
71
+ execSync(`tmux has-session -t ${tmuxArg(name)}`, { stdio: "ignore", timeout: 5_000 });
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ function createSessionAtEntry(ctx: ExtensionCommandContext | ExtensionContext, entryId: string): string {
79
+ const sessionFile = ctx.sessionManager.getSessionFile();
80
+ if (!sessionFile) {
81
+ throw new Error("Current pi session is ephemeral; start pi with session persistence before forking into tmux.");
82
+ }
83
+
84
+ const manager = SessionManager.open(sessionFile);
85
+ const forkedFile = manager.createBranchedSession(entryId);
86
+ if (!forkedFile) {
87
+ throw new Error("Could not create a persisted branched session for the selected message.");
88
+ }
89
+ return forkedFile;
90
+ }
91
+
92
+ function buildPiCommand(ctx: ExtensionCommandContext | ExtensionContext, prompt: string | undefined, thinkingLevel: string | undefined, entryId?: string): string {
93
+ const sessionFile = ctx.sessionManager.getSessionFile();
94
+ if (!sessionFile) {
95
+ throw new Error("Current pi session is ephemeral; start pi with session persistence before forking into tmux.");
96
+ }
97
+
98
+ const args = entryId ? ["pi", "--session", createSessionAtEntry(ctx, entryId)] : ["pi", "--fork", sessionFile];
99
+
100
+ if (ctx.model) {
101
+ args.push("--model", `${ctx.model.provider}/${ctx.model.id}`);
102
+ }
103
+
104
+ if (thinkingLevel) {
105
+ args.push("--thinking", thinkingLevel);
106
+ }
107
+
108
+ const trimmedPrompt = prompt?.trim();
109
+ if (trimmedPrompt) {
110
+ args.push(trimmedPrompt);
111
+ }
112
+
113
+ return args.map(shellQuote).join(" ");
114
+ }
115
+
116
+ function launchInTmux(ctx: ExtensionCommandContext | ExtensionContext, params: TmuxThreadInput, thinkingLevel?: string, entryId?: string): string {
117
+ if (!hasCommand("tmux")) {
118
+ throw new Error("tmux is not installed or not on PATH.");
119
+ }
120
+
121
+ const title = safeTitle(params.title);
122
+ const piCommand = buildPiCommand(ctx, params.prompt, thinkingLevel, entryId);
123
+ const command = `exec ${piCommand}`;
124
+ const cwdArg = tmuxArg(ctx.cwd);
125
+ const titleArg = tmuxArg(title);
126
+
127
+ if (params.target === "split") {
128
+ if (!process.env.TMUX) {
129
+ throw new Error("/tmux:split requires the current pi process to be running inside tmux. Use /tmux:window outside tmux.");
130
+ }
131
+
132
+ const paneId = tmux(["split-window", "-h", "-c", ctx.cwd, "-P", "-F", "#{pane_id}", command]);
133
+ if (paneId) {
134
+ try {
135
+ tmux(["select-pane", "-t", paneId, "-T", title]);
136
+ } catch {
137
+ // Pane titles are cosmetic; ignore failures.
138
+ }
139
+ }
140
+ return `Opened forked pi conversation in a tmux split: ${title}`;
141
+ }
142
+
143
+ if (process.env.TMUX) {
144
+ tmux(["new-window", "-c", ctx.cwd, "-n", title, command]);
145
+ return `Opened forked pi conversation in a new tmux window: ${title}`;
146
+ }
147
+
148
+ // Outside tmux, create/reuse a per-project detached tmux session so the window has somewhere to live.
149
+ const session = projectSessionName(ctx.cwd);
150
+ if (!tmuxSessionExists(session)) {
151
+ tmux(["new-session", "-d", "-s", session, "-c", ctx.cwd, "-n", "shell"]);
152
+ }
153
+ tmux(["new-window", "-t", session, "-c", ctx.cwd, "-n", title, command]);
154
+ return `Opened forked pi conversation in detached tmux session ${session}. Attach with: tmux attach -t ${session}`;
155
+ }
156
+
157
+ function parseTargetAndPrompt(args: string, fallback: Target): { target: Target; prompt: string; select: boolean } {
158
+ let trimmed = args.trim();
159
+ const select = /(?:^|\s)(?:--select|-s)(?=\s|$)/.test(trimmed);
160
+ trimmed = trimmed.replace(/(?:^|\s)(?:--select|-s)(?=\s|$)/g, " ").trim();
161
+ if (!trimmed) return { target: fallback, prompt: "", select };
162
+
163
+ const match = trimmed.match(/^(split|window)\b\s*/i);
164
+ if (!match) return { target: fallback, prompt: trimmed, select };
165
+
166
+ return {
167
+ target: match[1].toLowerCase() as Target,
168
+ prompt: trimmed.slice(match[0].length).trim(),
169
+ select,
170
+ };
171
+ }
172
+
173
+ function messageText(message: any): string {
174
+ const content = message?.content;
175
+ if (typeof content === "string") return content;
176
+ if (!Array.isArray(content)) return "";
177
+ return content
178
+ .map((part) => (part?.type === "text" ? part.text : ""))
179
+ .filter(Boolean)
180
+ .join(" ");
181
+ }
182
+
183
+ function isSelectableConversationMessage(entry: any): boolean {
184
+ if (entry?.type !== "message") return false;
185
+ const role = entry.message?.role;
186
+ if (role !== "user" && role !== "assistant") return false;
187
+ return compactLine(messageText(entry.message) || "").length > 0;
188
+ }
189
+
190
+ function compactLine(value: string, max = 120): string {
191
+ const line = value.replace(/\s+/g, " ").trim();
192
+ return line.length > max ? `${line.slice(0, max - 1)}…` : line;
193
+ }
194
+
195
+ async function selectOptionWithVisibleCursor(ctx: ExtensionCommandContext | ExtensionContext, options: string[]): Promise<number | undefined> {
196
+ if (!ctx.hasUI) {
197
+ throw new Error("Selecting a fork point requires interactive pi UI.");
198
+ }
199
+
200
+ const items: SelectItem[] = options.map((label, index) => ({ value: String(index), label }));
201
+ const selected = await ctx.ui.custom<string | null>((tui, theme, _keybindings, done) => {
202
+ const container = new Container();
203
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
204
+ container.addChild(new Text(theme.fg("accent", theme.bold("Fork tmux thread from message")), 1, 0));
205
+ container.addChild(new Text(theme.fg("dim", "Selected message is included; later messages are excluded."), 1, 0));
206
+
207
+ const selectList = new SelectList(items, Math.min(items.length, 18), {
208
+ selectedPrefix: (t) => theme.bg("selectedBg", theme.fg("text", t)),
209
+ selectedText: (t) => theme.bg("selectedBg", theme.fg("text", theme.bold(t))),
210
+ description: (t) => theme.fg("muted", t),
211
+ scrollInfo: (t) => theme.fg("dim", t),
212
+ noMatch: (t) => theme.fg("warning", t),
213
+ });
214
+ selectList.onSelect = (item) => done(item.value);
215
+ selectList.onCancel = () => done(null);
216
+ container.addChild(selectList);
217
+
218
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate • type to filter • enter select • esc cancel"), 1, 0));
219
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
220
+
221
+ return {
222
+ render: (width: number) => container.render(width),
223
+ invalidate: () => container.invalidate(),
224
+ handleInput: (data: string) => {
225
+ selectList.handleInput(data);
226
+ tui.requestRender();
227
+ },
228
+ };
229
+ });
230
+
231
+ if (selected === null || selected === undefined) return undefined;
232
+ const index = Number(selected);
233
+ return Number.isInteger(index) ? index : undefined;
234
+ }
235
+
236
+ async function selectForkEntry(ctx: ExtensionCommandContext | ExtensionContext): Promise<string | undefined> {
237
+ const branch = ctx.sessionManager
238
+ .getBranch()
239
+ .filter(isSelectableConversationMessage);
240
+
241
+ if (branch.length === 0) {
242
+ throw new Error("No user or assistant response messages are available to select as a fork point.");
243
+ }
244
+
245
+ const options = branch.map((entry: any, index: number) => {
246
+ const role = entry.message?.role === "assistant" ? "response" : "user";
247
+ return `${index + 1}. ${role} · ${compactLine(messageText(entry.message))}`;
248
+ });
249
+
250
+ const index = await selectOptionWithVisibleCursor(ctx, options);
251
+ if (index === undefined) return undefined;
252
+
253
+ const entry = branch[index];
254
+ if (!entry) {
255
+ throw new Error("Invalid message selection.");
256
+ }
257
+ return entry.id;
258
+ }
259
+
260
+ function notify(ctx: ExtensionCommandContext | ExtensionContext, message: string, level: "info" | "error" = "info"): void {
261
+ if (ctx.hasUI) {
262
+ ctx.ui.notify(message, level);
263
+ }
264
+ }
265
+
266
+ async function runLaunchCommand(args: string, ctx: ExtensionCommandContext, fallback: Target, thinkingLevel?: string, alwaysSelect = false): Promise<void> {
267
+ await ctx.waitForIdle();
268
+ const parsed = parseTargetAndPrompt(args, fallback);
269
+ try {
270
+ const entryId = parsed.select || alwaysSelect ? await selectForkEntry(ctx) : undefined;
271
+ if ((parsed.select || alwaysSelect) && !entryId) return;
272
+ const message = launchInTmux(ctx, {
273
+ target: parsed.target,
274
+ prompt: parsed.prompt || undefined,
275
+ title: parsed.prompt ? `pi: ${parsed.prompt}` : DEFAULT_TITLE,
276
+ }, thinkingLevel, entryId);
277
+ notify(ctx, message, "info");
278
+ } catch (error) {
279
+ notify(ctx, error instanceof Error ? error.message : String(error), "error");
280
+ }
281
+ }
282
+
283
+ export default function (pi: ExtensionAPI) {
284
+ pi.registerCommand("tmux", {
285
+ description: "Fork the current pi conversation into a tmux split/window. Usage: /tmux [--select|-s] [split|window] [initial prompt]",
286
+ handler: async (args, ctx) => runLaunchCommand(args, ctx, "split", pi.getThinkingLevel()),
287
+ });
288
+
289
+ pi.registerCommand("tmux:fork", {
290
+ description: "Fork the current pi conversation into a tmux split/window without adding messages to this thread. Usage: /tmux:fork [--select|-s] [split|window] [initial prompt]",
291
+ handler: async (args, ctx) => runLaunchCommand(args, ctx, "split", pi.getThinkingLevel()),
292
+ });
293
+
294
+ pi.registerCommand("tmux:split", {
295
+ description: "Fork the current pi conversation into a new tmux split. Usage: /tmux:split [--select|-s] [initial prompt]",
296
+ handler: async (args, ctx) => runLaunchCommand(args, ctx, "split", pi.getThinkingLevel()),
297
+ });
298
+
299
+ pi.registerCommand("tmux:window", {
300
+ description: "Fork the current pi conversation into a new tmux window. Usage: /tmux:window [--select|-s] [initial prompt]",
301
+ handler: async (args, ctx) => runLaunchCommand(args, ctx, "window", pi.getThinkingLevel()),
302
+ });
303
+
304
+ pi.registerCommand("tmux:select", {
305
+ description: "Pick a message, then fork the current pi conversation into tmux from that point inclusive. Usage: /tmux:select [split|window] [initial prompt]",
306
+ handler: async (args, ctx) => runLaunchCommand(args, ctx, "split", pi.getThinkingLevel(), true),
307
+ });
308
+
309
+ pi.registerTool({
310
+
311
+ name: TOOL_NAME,
312
+ label: "tmux thread",
313
+ description: "Fork the current pi conversation into a separate pi process in tmux. The fork continues from the current session file but does not add the side conversation to the current thread.",
314
+ promptSnippet: "Fork the current pi conversation into a separate tmux split/window when the user wants a clean parallel thread.",
315
+ promptGuidelines: [
316
+ "Use tmux_thread only when the user explicitly wants to continue in a separate tmux-managed pi conversation without polluting the current thread.",
317
+ "Use target=split for a side-by-side pane when pi is already running inside tmux; use target=window for a separate tmux window.",
318
+ ],
319
+ parameters: TmuxThreadParams,
320
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
321
+ try {
322
+ const entryId = params.forkFrom === "select" ? await selectForkEntry(ctx) : undefined;
323
+ if (params.forkFrom === "select" && !entryId) {
324
+ return { content: [{ type: "text", text: "No message selected; tmux thread was not opened." }] };
325
+ }
326
+ const message = launchInTmux(ctx, params, pi.getThinkingLevel(), entryId);
327
+ return {
328
+ content: [{ type: "text", text: message }],
329
+ details: { target: params.target, title: safeTitle(params.title) },
330
+ };
331
+ } catch (error) {
332
+ return {
333
+ content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
334
+ isError: true,
335
+ };
336
+ }
337
+ },
338
+ });
339
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "pi-tmux-thread",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension for forking conversations into tmux splits or windows",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Aaron Weisberg",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/aaronweisberg/pi-tmux-thread.git"
11
+ },
12
+ "homepage": "https://github.com/aaronweisberg/pi-tmux-thread#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/aaronweisberg/pi-tmux-thread/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi-extension",
19
+ "pi-coding-agent",
20
+ "tmux"
21
+ ],
22
+ "files": [
23
+ "extensions",
24
+ "skills",
25
+ "README.md"
26
+ ],
27
+ "pi": {
28
+ "extensions": [
29
+ "./extensions/tmux-thread.ts"
30
+ ],
31
+ "skills": [
32
+ "./skills"
33
+ ]
34
+ },
35
+ "peerDependencies": {
36
+ "@earendil-works/pi-ai": "*",
37
+ "@earendil-works/pi-coding-agent": "*",
38
+ "@earendil-works/pi-tui": "*",
39
+ "typebox": "*"
40
+ }
41
+ }