···2233## Unreleased
4455+- Add `pty up` / `pty down` commands to start and stop sessions defined in a `pty.toml` project file
66+- `pty.toml` supports named sessions with commands and optional tags
77+- `pty up` accepts a directory argument (`pty up ./backend`) and session name filtering (`pty up dev serve`)
88+- Add `--tags` flag to `pty list` to display tags as `#key=value` hashtags
99+- Colorize `pty list` output: bold cyan session names, dimmed commands
510- Add `--cwd` flag to `pty run` to start a session in a specific directory without needing to `cd` first
611712## 0.6.0
+27
README.md
···5656pty run --tag owner=forge -- node srv.js # tag a session with metadata
57575858pty list # show active sessions
5959+pty list --tags # show sessions with tags (#key=value)
5960pty list --json # show as JSON
60616162pty attach myserver # reconnect to a session
···8283pty kill myserver # terminate a running session
8384pty rm myserver # remove an exited session's metadata
8485pty gc # remove all exited sessions
8686+8787+pty up # start all sessions from ./pty.toml
8888+pty up ./backend # start sessions from ./backend/pty.toml
8989+pty up claude dev # start specific sessions from ./pty.toml
9090+pty down # stop all sessions from ./pty.toml
9191+pty down claude # stop specific sessions
85928693pty wrap claude # auto-wrap claude in pty sessions
8794pty unwrap claude # remove the wrapper
···125132```
126133127134Event files auto-truncate at 1,000 lines and are cleaned up with the 24-hour dead session TTL.
135135+136136+### Project Files
137137+138138+A project can include a `pty.toml` to declare its sessions:
139139+140140+```toml
141141+[sessions.claude]
142142+command = "claude --dangerously-skip-permissions"
143143+tags = { role = "agent" }
144144+145145+[sessions.dev]
146146+command = "deno task dev"
147147+tags = { role = "build" }
148148+149149+[sessions.serve]
150150+command = "bin/serve"
151151+tags = { role = "server" }
152152+```
153153+154154+Run `pty up` in the project directory (or `pty up /path/to/project`) to start all sessions. Run `pty down` to stop them. You can also start specific sessions: `pty up dev serve`.
128155129156### Plugins
130157
+2-2
completions/pty.bash
···66 COMPREPLY=()
77 cur="${COMP_WORDS[COMP_CWORD]}"
88 prev="${COMP_WORDS[COMP_CWORD-1]}"
99- commands="run attach a peek send events list ls stats restart kill rm remove gc wrap unwrap test help"
99+ commands="run attach a peek send events list ls stats restart kill rm remove gc up down wrap unwrap test help"
10101111 # Complete subcommand
1212 if [[ ${COMP_CWORD} -eq 1 ]]; then
···3737 ;;
3838 list|ls)
3939 if [[ "${cur}" == -* ]]; then
4040- COMPREPLY=($(compgen -W "--json" -- "${cur}"))
4040+ COMPREPLY=($(compgen -W "--json --tags" -- "${cur}"))
4141 fi
4242 ;;
4343 gc)
+4
completions/pty.fish
···5858complete -c pty -n __pty_needs_command -a rm -d 'Remove an exited session'
5959complete -c pty -n __pty_needs_command -a remove -d 'Remove an exited session'
6060complete -c pty -n __pty_needs_command -a gc -d 'Remove all exited sessions'
6161+complete -c pty -n __pty_needs_command -a up -d 'Start sessions from pty.toml'
6262+complete -c pty -n __pty_needs_command -a down -d 'Stop sessions from pty.toml'
6163complete -c pty -n __pty_needs_command -a wrap -d 'Auto-wrap a command in pty sessions'
6264complete -c pty -n __pty_needs_command -a unwrap -d 'Remove a wrapper'
6365complete -c pty -n __pty_needs_command -a test -d 'Run tests (vitest)'
···96989799# list: flags
98100complete -c pty -n '__pty_using_command list' -l json -d 'Output as JSON'
101101+complete -c pty -n '__pty_using_command list' -l tags -d 'Show tags as #key=value'
99102complete -c pty -n '__pty_using_command ls' -l json -d 'Output as JSON'
103103+complete -c pty -n '__pty_using_command ls' -l tags -d 'Show tags as #key=value'
100104101105# stats: session names and flags
102106complete -c pty -n '__pty_using_command stats' -a '(__pty_sessions)' -d 'Session'
+4-1
completions/pty.zsh
···3838 'rm:Remove an exited session'
3939 'remove:Remove an exited session'
4040 'gc:Remove all exited sessions'
4141+ 'up:Start sessions from pty.toml'
4242+ 'down:Stop sessions from pty.toml'
4143 'wrap:Auto-wrap a command in pty sessions'
4244 'unwrap:Remove a wrapper'
4345 'test:Run tests (vitest)'
···9496 ;;
9597 list|ls)
9698 _arguments \
9797- '--json[Output as JSON]'
9999+ '--json[Output as JSON]' \
100100+ '--tags[Show tags as #key=value]'
98101 ;;
99102 gc)
100103 # No arguments
+1-1
flake.nix
···29293030 # Generated from package-lock.json.
3131 # Regenerate with: nix run nixpkgs#prefetch-npm-deps -- package-lock.json
3232- npmDepsHash = "sha256-G+w8aIHp1GsNJb1tZwuyY638L6F5Hlp0neN8UwBMTLs=";
3232+ npmDepsHash = "sha256-a7g3tIDb+6JKQxCTqnL1i3gps4LjTVjt8wf+TrDtPzY=";
33333434 # node-pty has native code that needs these at build time
3535 nativeBuildInputs = with pkgs; [ python3 pkg-config ];
···1818} from "./sessions.ts";
1919import { spawnDaemon, resolveCommand } from "./spawn.ts";
2020import { EventFollower, readRecentEvents, formatEvent } from "./events.ts";
2121+import { readPtyFile, type PtySessionDef } from "./ptyfile.ts";
21222223// Lazy-load the interactive TUI so non-interactive commands don't crash when
2324// the caller's cwd was deleted (the TUI module evaluates process.cwd() at load).
···5051 pty events --recent <name> Show recent events and exit
5152 pty events --json <name> Output raw JSONL
5253 pty list List active sessions
5454+ pty list --tags List sessions with tags (#key=value)
5355 pty list --json List sessions as JSON
5656+ pty up Start all sessions from pty.toml
5757+ pty up <dir> Start sessions from <dir>/pty.toml
5858+ pty up <name> [<name>...] Start specific sessions from pty.toml
5959+ pty down Stop all sessions from pty.toml
6060+ pty down <dir> Stop sessions from <dir>/pty.toml
6161+ pty down <name> [<name>...] Stop specific sessions from pty.toml
5462 pty kill <name> Kill or remove a session
5563 pty gc Remove all exited sessions
5664 pty wrap <command> Auto-wrap a command in pty sessions
···350358 case "list":
351359 case "ls": {
352360 const jsonFlag = args.includes("--json");
353353- await cmdList(jsonFlag);
361361+ const tagsFlag = args.includes("--tags");
362362+ await cmdList(jsonFlag, tagsFlag);
354363 break;
355364 }
356365···398407 break;
399408 }
400409410410+ case "up": {
411411+ if (args[1] === "-h" || args[1] === "--help") {
412412+ console.log("Usage: pty up [dir] [name...]\n\nStart sessions defined in pty.toml.");
413413+ break;
414414+ }
415415+ // pty up [dir] [name...]
416416+ const upArgs = args.slice(1);
417417+ let dir: string | undefined;
418418+ const names: string[] = [];
419419+420420+ for (const arg of upArgs) {
421421+ if (arg.startsWith("-")) break;
422422+ // First arg: directory if pty.toml exists there, otherwise session name
423423+ if (!dir && names.length === 0 && hasPtyFile(arg)) {
424424+ dir = arg;
425425+ } else {
426426+ names.push(arg);
427427+ }
428428+ }
429429+430430+ await cmdUp(dir, names);
431431+ break;
432432+ }
433433+434434+ case "down": {
435435+ if (args[1] === "-h" || args[1] === "--help") {
436436+ console.log("Usage: pty down [dir] [name...]\n\nStop sessions defined in pty.toml.");
437437+ break;
438438+ }
439439+ // pty down [dir] [name...]
440440+ const downArgs = args.slice(1);
441441+ let dir: string | undefined;
442442+ const names: string[] = [];
443443+444444+ for (const arg of downArgs) {
445445+ if (arg.startsWith("-")) break;
446446+ if (!dir && names.length === 0 && hasPtyFile(arg)) {
447447+ dir = arg;
448448+ } else {
449449+ names.push(arg);
450450+ }
451451+ }
452452+453453+ await cmdDown(dir, names);
454454+ break;
455455+ }
456456+401457 case "rm":
402458 case "remove": {
403459 if (args.length < 2) {
···606662 });
607663}
608664609609-async function cmdList(json = false): Promise<void> {
665665+async function cmdList(json = false, showTags = false): Promise<void> {
610666 const sessions = await listSessions();
611667612668 if (json) {
···644700 const cwd = session.metadata?.cwd
645701 ? shortPath(session.metadata.cwd)
646702 : "";
647647- console.log(` ${session.name} (pid: ${session.pid}) — ${cwd} — ${cmd}`);
703703+ const tagStr = showTags && session.metadata?.tags
704704+ ? " " + Object.entries(session.metadata.tags).map(([k, v]) => `#${k}=${v}`).join(" ")
705705+ : "";
706706+ console.log(` \x1b[1;36m${session.name}\x1b[0m${tagStr} (pid: ${session.pid}) — ${cwd} — \x1b[2m${cmd}\x1b[0m`);
648707 }
649708 }
650709···659718 const cmd = meta
660719 ? [meta.displayCommand, ...meta.args].join(" ")
661720 : "";
662662- console.log(` ${session.name} (exited with code ${code}, ${ago}) — ${cwd} — ${cmd}`);
721721+ const tagStr = showTags && meta?.tags
722722+ ? " " + Object.entries(meta.tags).map(([k, v]) => `#${k}=${v}`).join(" ")
723723+ : "";
724724+ console.log(` \x1b[1m${session.name}\x1b[0m${tagStr} (exited with code ${code}, ${ago}) — ${cwd} — \x1b[2m${cmd}\x1b[0m`);
663725 }
664726 }
665727}
···869931 console.log(`Removed: ${name}`);
870932 }
871933 console.log(`Cleaned up ${removed.length} exited session${removed.length === 1 ? "" : "s"}.`);
934934+}
935935+936936+function hasPtyFile(dir: string): boolean {
937937+ try {
938938+ return fs.statSync(path.join(path.resolve(dir), "pty.toml")).isFile();
939939+ } catch {
940940+ return false;
941941+ }
942942+}
943943+944944+async function cmdUp(dir: string | undefined, names: string[]): Promise<void> {
945945+ let ptyFile;
946946+ try {
947947+ ptyFile = readPtyFile(dir);
948948+ } catch (e: any) {
949949+ console.error(e.message);
950950+ process.exit(1);
951951+ }
952952+953953+ let sessions = ptyFile.sessions;
954954+ if (names.length > 0) {
955955+ const nameSet = new Set(names);
956956+ const matchesName = (s: PtySessionDef) => nameSet.has(s.name) || nameSet.has(s.shortName);
957957+ const unknown = names.filter((n) => !sessions.some((s) => s.name === n || s.shortName === n));
958958+ if (unknown.length > 0) {
959959+ console.error(`Unknown session${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}`);
960960+ console.error(`Available: ${sessions.map((s) => s.shortName).join(", ")}`);
961961+ process.exit(1);
962962+ }
963963+ sessions = sessions.filter(matchesName);
964964+ }
965965+966966+ const existing = await listSessions();
967967+ const runningNames = new Set(existing.filter((s) => s.status === "running").map((s) => s.name));
968968+969969+ let started = 0;
970970+ let skipped = 0;
971971+972972+ for (const sess of sessions) {
973973+ if (runningNames.has(sess.name)) {
974974+ console.log(` ● ${sess.name} (already running)`);
975975+ skipped++;
976976+ continue;
977977+ }
978978+979979+ // Clean up exited session with the same name
980980+ const existingSession = existing.find((s) => s.name === sess.name);
981981+ if (existingSession?.status === "exited") {
982982+ cleanupAll(sess.name);
983983+ }
984984+985985+ try {
986986+ await spawnDaemon({
987987+ name: sess.name,
988988+ command: "/bin/sh",
989989+ args: ["-c", sess.command],
990990+ displayCommand: sess.command,
991991+ cwd: ptyFile.dir,
992992+ tags: sess.tags,
993993+ });
994994+ console.log(` ● ${sess.name} (started)`);
995995+ started++;
996996+ } catch (e: any) {
997997+ console.error(` ✗ ${sess.name}: ${e.message}`);
998998+ }
999999+ }
10001000+10011001+ if (started === 0 && skipped === sessions.length) {
10021002+ console.log("All sessions already running.");
10031003+ } else if (started > 0) {
10041004+ console.log(`Started ${started} session${started === 1 ? "" : "s"}.`);
10051005+ }
10061006+}
10071007+10081008+async function cmdDown(dir: string | undefined, names: string[]): Promise<void> {
10091009+ let ptyFile;
10101010+ try {
10111011+ ptyFile = readPtyFile(dir);
10121012+ } catch (e: any) {
10131013+ console.error(e.message);
10141014+ process.exit(1);
10151015+ }
10161016+10171017+ let sessions = ptyFile.sessions;
10181018+ if (names.length > 0) {
10191019+ const nameSet = new Set(names);
10201020+ sessions = sessions.filter((s) => nameSet.has(s.name) || nameSet.has(s.shortName));
10211021+ }
10221022+10231023+ const existing = await listSessions();
10241024+ let stopped = 0;
10251025+10261026+ for (const sess of sessions) {
10271027+ const existingSession = existing.find((s) => s.name === sess.name);
10281028+ if (!existingSession) continue;
10291029+10301030+ if (existingSession.status === "running" && existingSession.pid) {
10311031+ try {
10321032+ process.kill(existingSession.pid, "SIGTERM");
10331033+ console.log(` ○ ${sess.name} (stopped)`);
10341034+ stopped++;
10351035+ } catch {
10361036+ console.error(` ✗ ${sess.name}: failed to stop`);
10371037+ }
10381038+ cleanupSocket(sess.name);
10391039+ } else if (existingSession.status === "exited") {
10401040+ cleanupAll(sess.name);
10411041+ console.log(` ○ ${sess.name} (cleaned up)`);
10421042+ stopped++;
10431043+ }
10441044+ }
10451045+10461046+ if (stopped === 0) {
10471047+ console.log("No sessions to stop.");
10481048+ } else {
10491049+ console.log(`Stopped ${stopped} session${stopped === 1 ? "" : "s"}.`);
10501050+ }
8721051}
87310528741053async function cmdRestart(name: string, force = false): Promise<void> {
+3
src/client-api.ts
···3636 type FollowerOptions,
3737} from "./events.ts";
38383939+// Project files
4040+export { readPtyFile, type PtyFile, type PtySessionDef } from "./ptyfile.ts";
4141+3942// Keys
4043export { resolveKey, parseSeqValue } from "./keys.ts";
4144
+73
src/ptyfile.ts
···11+import * as fs from "node:fs";
22+import * as path from "node:path";
33+import { parse as parseToml } from "smol-toml";
44+55+const PTY_TOML = "pty.toml";
66+77+export interface PtySessionDef {
88+ name: string; // full session name (with prefix if set)
99+ shortName: string; // name as written in the toml (for filtering)
1010+ command: string;
1111+ tags?: Record<string, string>;
1212+}
1313+1414+export interface PtyFile {
1515+ dir: string;
1616+ prefix: string | null;
1717+ sessions: PtySessionDef[];
1818+}
1919+2020+/** Find and parse a pty.toml file. Searches the given directory (or cwd). */
2121+export function readPtyFile(dir?: string): PtyFile {
2222+ const resolvedDir = dir ? path.resolve(dir) : process.cwd();
2323+ const filePath = path.join(resolvedDir, PTY_TOML);
2424+2525+ let content: string;
2626+ try {
2727+ content = fs.readFileSync(filePath, "utf-8");
2828+ } catch (err: any) {
2929+ if (err?.code === "ENOENT") {
3030+ throw new Error(`No ${PTY_TOML} found in ${resolvedDir}`);
3131+ }
3232+ throw err;
3333+ }
3434+3535+ let parsed: any;
3636+ try {
3737+ parsed = parseToml(content);
3838+ } catch (err: any) {
3939+ throw new Error(`Invalid ${PTY_TOML} in ${resolvedDir}: ${err?.message ?? String(err)}`);
4040+ }
4141+4242+ const prefix = typeof parsed.prefix === "string" && parsed.prefix.length > 0 ? parsed.prefix : null;
4343+ const sessions: PtySessionDef[] = [];
4444+4545+ if (parsed.sessions && typeof parsed.sessions === "object") {
4646+ for (const [rawName, def] of Object.entries(parsed.sessions)) {
4747+ const name = prefix ? `${prefix}-${rawName}` : rawName;
4848+ if (!def || typeof def !== "object") {
4949+ throw new Error(`Invalid session "${name}" in ${filePath}: expected a table`);
5050+ }
5151+ const d = def as Record<string, unknown>;
5252+ if (typeof d.command !== "string" || d.command.length === 0) {
5353+ throw new Error(`Session "${name}" in ${filePath} is missing a "command" field`);
5454+ }
5555+5656+ let tags: Record<string, string> | undefined;
5757+ if (d.tags && typeof d.tags === "object") {
5858+ tags = {};
5959+ for (const [k, v] of Object.entries(d.tags as Record<string, unknown>)) {
6060+ tags[k] = String(v);
6161+ }
6262+ }
6363+6464+ sessions.push({ name, shortName: rawName, command: d.command, tags });
6565+ }
6666+ }
6767+6868+ if (sessions.length === 0) {
6969+ throw new Error(`No sessions defined in ${filePath}`);
7070+ }
7171+7272+ return { dir: resolvedDir, prefix, sessions };
7373+}