pi-term 0.0.0-stage → 0.1.1

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 Jainam Bhavasar
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 CHANGED
@@ -1,3 +1,52 @@
1
- # Temporary Holding Version
1
+ # pi-term
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Background terminals for [pi](https://pi.dev).
4
+
5
+ ![pi-term demo](https://raw.githubusercontent.com/jainam-bhavasar/pi-term/main/docs/demo.gif)
6
+
7
+ `bash` tool calls end when the command ends. That breaks for anything that has to keep running: dev servers, `flutter run`, REPLs, watchers, log tails. pi-term gives the agent named tmux sessions it can start, read and type into, without blocking the conversation.
8
+
9
+ ```bash
10
+ pi install npm:pi-term
11
+ ```
12
+
13
+ Requires `tmux` (`brew install tmux` or `apt install tmux`).
14
+
15
+ ## What you get
16
+
17
+ **`term` tool for the agent**
18
+
19
+ | action | does |
20
+ |---|---|
21
+ | `start` | start `pi-<name>` in a directory, optionally running a command. Never restarts an existing session. |
22
+ | `read` | capture the last N lines of output |
23
+ | `send` | type input, with or without Enter (e.g. `r` for Flutter hot reload) |
24
+ | `list` | list `pi-` sessions |
25
+ | `kill` | stop a session |
26
+ | `open` | open the session in a terminal window for you |
27
+
28
+ **`/term` command for you**
29
+
30
+ - `/term` lists running sessions and opens the one you pick
31
+ - `/term <name>` opens `pi-<name>`, creating it if needed
32
+
33
+ On macOS it opens Ghostty, falling back to Terminal.app. On other systems it prints the `tmux attach` command to run.
34
+
35
+ **`term` skill** that tells the agent when to use `term` instead of `bash`.
36
+
37
+ ## Cleanup
38
+
39
+ Every session records the pid of the pi process that created it. Quitting pi, `/new`, `/resume` or forking kills that process's sessions; `/reload` keeps them. If pi dies without a clean exit, the next pi start kills the leftovers. Sessions you start yourself without the `pi-` owner tag are never touched.
40
+
41
+ ## Example
42
+
43
+ > start the web dev server and tell me when it's ready
44
+
45
+ ```json
46
+ { "action": "start", "name": "web", "command": "npm run dev" }
47
+ { "action": "read", "name": "web" }
48
+ ```
49
+
50
+ ## License
51
+
52
+ MIT
@@ -0,0 +1,116 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+ import {
5
+ hasSession,
6
+ killOrphanedSessions,
7
+ killOwnedSessions,
8
+ killSession,
9
+ listSessions,
10
+ openViewer,
11
+ readSession,
12
+ sendSession,
13
+ sessionName,
14
+ startSession,
15
+ } from "./tmux.ts";
16
+
17
+ export default function termExtension(pi: ExtensionAPI) {
18
+ // Terminals live as long as the pi session that started them.
19
+ pi.on("session_start", async () => {
20
+ await killOrphanedSessions();
21
+ });
22
+ pi.on("session_shutdown", async (event) => {
23
+ // reload keeps the same session and process, so keep its terminals running
24
+ if (event.reason === "reload") return;
25
+ await killOwnedSessions();
26
+ });
27
+
28
+ pi.registerTool({
29
+ name: "term",
30
+ label: "Terminal",
31
+ description:
32
+ "Run a long-lived interactive program in a named tmux session. The call returns immediately. Read output later and send keys without attaching.",
33
+ promptSnippet: "Start, read, type into, list, or stop a background tmux terminal",
34
+ promptGuidelines: [
35
+ "Use term instead of bash when a command must keep running after the call returns, such as flutter run, a dev server, a REPL, or a log stream.",
36
+ "Use term action read to inspect a session. Do not assume the first read contains the final result.",
37
+ "Use term action send to type into a session. Never tmux attach from bash; attach blocks and dies when the tool call ends.",
38
+ "Do not start a new command in a session that already exists. term start reports the existing session instead of restarting it.",
39
+ ],
40
+ parameters: Type.Object({
41
+ action: StringEnum(["start", "read", "send", "list", "kill", "open"] as const),
42
+ name: Type.Optional(Type.String({ description: "Session name. Stored as pi-<name>." })),
43
+ command: Type.Optional(Type.String({ description: "Command to type into a new session. Ignored if the session exists." })),
44
+ cwd: Type.Optional(Type.String({ description: "Working directory for a new session. Defaults to the project cwd." })),
45
+ input: Type.Optional(Type.String({ description: "Text to type for action send." })),
46
+ submit: Type.Optional(Type.Boolean({ description: "Press Enter after input. Default true." })),
47
+ lines: Type.Optional(Type.Number({ description: "Scrollback lines to capture. Default 200." })),
48
+ }),
49
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
50
+ const cwd = params.cwd || ctx.cwd;
51
+ switch (params.action) {
52
+ case "list": {
53
+ const sessions = await listSessions();
54
+ return {
55
+ content: [{ type: "text", text: sessions.length ? sessions.join("\n") : "No pi- terminals." }],
56
+ details: { sessions },
57
+ };
58
+ }
59
+ case "start": {
60
+ if (!params.name) throw new Error("name is required");
61
+ const text = await startSession(params.name, cwd, params.command);
62
+ return { content: [{ type: "text", text }], details: { session: sessionName(params.name) } };
63
+ }
64
+ case "read": {
65
+ if (!params.name) throw new Error("name is required");
66
+ const text = await readSession(params.name, params.lines ?? 200);
67
+ return { content: [{ type: "text", text }], details: { session: sessionName(params.name) } };
68
+ }
69
+ case "send": {
70
+ if (!params.name) throw new Error("name is required");
71
+ const text = await sendSession(params.name, params.input ?? "", params.submit !== false);
72
+ const output = await readSession(params.name, 40);
73
+ return { content: [{ type: "text", text: `${text}\n\n${output}` }], details: {} };
74
+ }
75
+ case "kill": {
76
+ if (!params.name) throw new Error("name is required");
77
+ return { content: [{ type: "text", text: await killSession(params.name) }], details: {} };
78
+ }
79
+ case "open": {
80
+ if (!params.name) throw new Error("name is required");
81
+ return { content: [{ type: "text", text: await openViewer(params.name) }], details: {} };
82
+ }
83
+ }
84
+ },
85
+ });
86
+
87
+ pi.registerCommand("term", {
88
+ description: "List background terminals, or open one in Ghostty: /term <name>",
89
+ getArgumentCompletions: async (prefix) => {
90
+ const sessions = await listSessions();
91
+ const items = sessions
92
+ .map((session) => session.replace(/^pi-/, ""))
93
+ .filter((name) => name.startsWith(prefix))
94
+ .map((name) => ({ value: name, label: name }));
95
+ return items.length ? items : null;
96
+ },
97
+ handler: async (args, ctx) => {
98
+ const name = args.trim().split(/\s+/)[0];
99
+ if (!name) {
100
+ const sessions = await listSessions();
101
+ if (sessions.length === 0) {
102
+ ctx.ui.notify("No pi- terminals. Ask pi to term start one.", "info");
103
+ return;
104
+ }
105
+ const picked = await ctx.ui.select("Background terminals", sessions);
106
+ if (!picked) return;
107
+ ctx.ui.notify(await openViewer(picked.replace(/^pi-/, "")), "info");
108
+ return;
109
+ }
110
+ if (!(await hasSession(name))) {
111
+ await startSession(name, ctx.cwd);
112
+ }
113
+ ctx.ui.notify(await openViewer(name), "info");
114
+ },
115
+ });
116
+ }
@@ -0,0 +1,184 @@
1
+ import { execFile } from "node:child_process";
2
+ import { chmod, mkdir, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ const NAME_RE = /^[A-Za-z0-9_-]+$/;
10
+
11
+ export function sessionName(name: string): string {
12
+ if (!NAME_RE.test(name)) {
13
+ throw new Error(`Invalid terminal name "${name}". Use letters, digits, "-" and "_" only.`);
14
+ }
15
+ return name.startsWith("pi-") ? name : `pi-${name}`;
16
+ }
17
+
18
+ async function tmuxBin(): Promise<string> {
19
+ const candidates = [process.env.TMUX_BIN, "/opt/homebrew/bin/tmux", "/usr/local/bin/tmux", "tmux"].filter(
20
+ (value): value is string => Boolean(value),
21
+ );
22
+ for (const bin of candidates) {
23
+ try {
24
+ await execFileAsync(bin, ["-V"]);
25
+ return bin;
26
+ } catch {
27
+ // try the next candidate
28
+ }
29
+ }
30
+ throw new Error("tmux is not installed. Install it with: brew install tmux");
31
+ }
32
+
33
+ async function tmux(args: string[], cwd?: string): Promise<string> {
34
+ const bin = await tmuxBin();
35
+ try {
36
+ const { stdout, stderr } = await execFileAsync(bin, args, {
37
+ cwd,
38
+ maxBuffer: 1024 * 1024,
39
+ });
40
+ return `${stdout}${stderr}`.trimEnd();
41
+ } catch (error) {
42
+ const err = error as { stdout?: string; stderr?: string; message?: string; code?: number };
43
+ const detail = `${err.stderr ?? ""}${err.stdout ?? ""}`.trim();
44
+ if (err.code === 1 && args[0] === "has-session") return "";
45
+ throw new Error(detail || err.message || "tmux failed");
46
+ }
47
+ }
48
+
49
+ export async function hasSession(name: string): Promise<boolean> {
50
+ const bin = await tmuxBin();
51
+ try {
52
+ await execFileAsync(bin, ["has-session", "-t", sessionName(name)]);
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ export async function listSessions(): Promise<string[]> {
60
+ try {
61
+ const out = await tmux(["list-sessions", "-F", "#{session_name}"]);
62
+ return out
63
+ .split("\n")
64
+ .map((line) => line.trim())
65
+ .filter((line) => line.startsWith("pi-"));
66
+ } catch {
67
+ return [];
68
+ }
69
+ }
70
+
71
+ // Each session records the pid of the pi process that created it in the
72
+ // tmux user option @pi_owner, so pi can clean up after itself on exit.
73
+ const OWNER_OPT = "@pi_owner";
74
+
75
+ async function newSession(target: string, cwd: string): Promise<void> {
76
+ await tmux(["new-session", "-d", "-s", target, "-c", cwd]);
77
+ await tmux(["set-option", "-t", target, OWNER_OPT, String(process.pid)]);
78
+ }
79
+
80
+ async function sessionsWithOwner(): Promise<{ name: string; owner: number | null }[]> {
81
+ try {
82
+ const out = await tmux(["list-sessions", "-F", `#{session_name}\t#{${OWNER_OPT}}`]);
83
+ return out
84
+ .split("\n")
85
+ .map((line) => line.trim().split("\t"))
86
+ .filter(([name]) => name?.startsWith("pi-"))
87
+ .map(([name, owner]) => ({ name, owner: owner && /^\d+$/.test(owner) ? Number(owner) : null }));
88
+ } catch {
89
+ return [];
90
+ }
91
+ }
92
+
93
+ function pidAlive(pid: number): boolean {
94
+ try {
95
+ process.kill(pid, 0);
96
+ return true;
97
+ } catch (error) {
98
+ return (error as NodeJS.ErrnoException).code === "EPERM";
99
+ }
100
+ }
101
+
102
+ /** Kill every pi- session created by this pi process. Idempotent. */
103
+ export async function killOwnedSessions(): Promise<string[]> {
104
+ const mine = (await sessionsWithOwner()).filter((s) => s.owner === process.pid);
105
+ for (const s of mine) await tmux(["kill-session", "-t", s.name]).catch(() => {});
106
+ return mine.map((s) => s.name);
107
+ }
108
+
109
+ /** Kill pi- sessions whose owning pi process is gone (e.g. it crashed or the window was closed). */
110
+ export async function killOrphanedSessions(): Promise<string[]> {
111
+ const orphans = (await sessionsWithOwner()).filter((s) => s.owner !== null && s.owner !== process.pid && !pidAlive(s.owner));
112
+ for (const s of orphans) await tmux(["kill-session", "-t", s.name]).catch(() => {});
113
+ return orphans.map((s) => s.name);
114
+ }
115
+
116
+ export async function startSession(name: string, cwd: string, command?: string): Promise<string> {
117
+ const target = sessionName(name);
118
+ if (await hasSession(name)) {
119
+ return `exists ${target}\n${await readSession(name, 80)}`;
120
+ }
121
+ await newSession(target, cwd);
122
+ if (command?.trim()) {
123
+ await tmux(["send-keys", "-t", target, "-l", "--", command]);
124
+ await tmux(["send-keys", "-t", target, "Enter"]);
125
+ }
126
+ return `started ${target}${command?.trim() ? `\n$ ${command}` : ""}`;
127
+ }
128
+
129
+ export async function readSession(name: string, lines = 200): Promise<string> {
130
+ const target = sessionName(name);
131
+ if (!(await hasSession(name))) throw new Error(`No session ${target}`);
132
+ const text = await tmux(["capture-pane", "-p", "-t", target, "-S", `-${Math.max(1, lines)}`]);
133
+ return text.trim() ? text : `(${target} is empty)`;
134
+ }
135
+
136
+ export async function sendSession(name: string, input: string, submit = true): Promise<string> {
137
+ const target = sessionName(name);
138
+ if (!(await hasSession(name))) throw new Error(`No session ${target}`);
139
+ if (input) await tmux(["send-keys", "-t", target, "-l", "--", input]);
140
+ if (submit) await tmux(["send-keys", "-t", target, "Enter"]);
141
+ return `sent to ${target}`;
142
+ }
143
+
144
+ export async function killSession(name: string): Promise<string> {
145
+ const target = sessionName(name);
146
+ await tmux(["kill-session", "-t", target]);
147
+ return `killed ${target}`;
148
+ }
149
+
150
+ async function attachScript(target: string): Promise<string> {
151
+ const dir = join(homedir(), ".pi", "agent", "pi-term");
152
+ await mkdir(dir, { recursive: true });
153
+ const path = join(dir, `attach-${target}.sh`);
154
+ // Ghostty runs `-e` through /usr/bin/login, which execs that string as one path.
155
+ // A shebang script is a real executable and can see Homebrew's tmux.
156
+ const body = [
157
+ "#!/bin/zsh",
158
+ "export PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"",
159
+ `exec tmux attach -t ${JSON.stringify(target)}`,
160
+ "",
161
+ ].join("\n");
162
+ await writeFile(path, body);
163
+ await chmod(path, 0o755);
164
+ return path;
165
+ }
166
+
167
+ export async function openViewer(name: string): Promise<string> {
168
+ const target = sessionName(name);
169
+ if (!(await hasSession(name))) {
170
+ await newSession(target, process.cwd());
171
+ }
172
+ if (process.platform !== "darwin") {
173
+ return `run in another terminal: tmux attach -t ${target}`;
174
+ }
175
+ const script = await attachScript(target);
176
+ try {
177
+ await execFileAsync("open", ["-na", "Ghostty.app", "--args", "-e", script]);
178
+ return `opened Ghostty on ${target}`;
179
+ } catch {
180
+ const command = `export PATH=\"/opt/homebrew/bin:$PATH\"; tmux attach -t ${target}`;
181
+ await execFileAsync("osascript", ["-e", `tell application \"Terminal\" to do script ${JSON.stringify(command)}`]);
182
+ return `opened Terminal on ${target}`;
183
+ }
184
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "pi-term",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Background terminals for pi: let the agent start, read and type into long-running programs (dev servers, flutter run, REPLs, log tails) in named tmux sessions.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Jainam Bhavasar",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/jainam-bhavasar/pi-term.git"
11
+ },
12
+ "homepage": "https://github.com/jainam-bhavasar/pi-term#readme",
13
+ "bugs": "https://github.com/jainam-bhavasar/pi-term/issues",
14
+ "keywords": [
15
+ "pi-package",
16
+ "pi",
17
+ "tmux",
18
+ "terminal",
19
+ "dev-server",
20
+ "coding-agent"
21
+ ],
22
+ "files": [
23
+ "extensions",
24
+ "skills",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "pi": {
29
+ "image": "https://raw.githubusercontent.com/jainam-bhavasar/pi-term/main/docs/demo.gif",
30
+ "extensions": [
31
+ "./extensions/index.ts"
32
+ ],
33
+ "skills": [
34
+ "./skills"
35
+ ]
36
+ },
37
+ "peerDependencies": {
38
+ "@earendil-works/pi-ai": "*",
39
+ "@earendil-works/pi-coding-agent": "*",
40
+ "typebox": "*"
41
+ }
42
+ }
@@ -0,0 +1,35 @@
1
+ ---
2
+ name: term
3
+ description: Keep interactive programs running in named tmux sessions via the term tool. Use for dev servers, flutter run, REPLs, watchers and log streams that must outlive a single shell call.
4
+ ---
5
+
6
+ # term
7
+
8
+ Long-running programs go through the `term` tool, not `bash`. A session is `pi-<name>` and keeps running between tool calls until pi exits.
9
+
10
+ ## Actions
11
+
12
+ - `start` with `name`, optional `command`, optional `cwd`. If the session exists, it is not restarted.
13
+ - `read` with `name` and optional `lines`. Poll until the output you need appears.
14
+ - `send` with `name` and `input`. Set `submit: false` for a single key such as Flutter hot reload `r`.
15
+ - `list`, `kill`, `open`.
16
+
17
+ `open` shows the session in a terminal window so the user can watch or type. Do not `tmux attach` yourself.
18
+
19
+ ## Example
20
+
21
+ Start a dev server, then read it:
22
+
23
+ ```json
24
+ { "action": "start", "name": "web", "command": "npm run dev", "cwd": "/path/to/project" }
25
+ ```
26
+
27
+ ```json
28
+ { "action": "read", "name": "web", "lines": 120 }
29
+ ```
30
+
31
+ Hot reload a running Flutter app:
32
+
33
+ ```json
34
+ { "action": "send", "name": "app", "input": "r", "submit": false }
35
+ ```