This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Add pty exec, session_exec event, and remote session creation

Nathan Herald (Apr 15, 2026, 11:01 AM +0200) b4926719 bdebedeb

+327 -11
+18 -7
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## Unreleased 4 + 5 + ### pty exec 6 + - Add `pty exec -- <command> [args...]` to replace the current session's command from inside the session 7 + - Updates session metadata so the supervisor restarts the new command, not the original 8 + - Errors if not inside a pty session (`PTY_SESSION` not set) or if the session is managed by a pty.toml 9 + - Preserves existing tags and other metadata 10 + - Emits `session_exec` event with previous and new command 11 + - Interactive TUI shows "Create new session..." for spawn-enabled remote hosts 12 + 3 13 ## 0.8.0 4 14 5 15 ### Relay integration ··· 18 28 - `groupedSelectable` `renderHeader` callback now receives the full group object instead of `(title, count)` 19 29 - Empty groups are now rendered (header shown) instead of being silently skipped 20 30 31 + ## 0.7.2 32 + 21 33 ### launchd 22 34 - `pty supervisor launchd install` now compiles a small C wrapper binary (`pty-supervisor`) that validates Full Disk Access before exec'ing node — grant FDA to this binary, not to node itself 23 35 - Install flow checks FDA via a one-shot launchd job (no false positives from terminal's FDA) ··· 31 43 - Fix `events --wait` timeout not being cancelled when event is found (caused exit code 1 even on match) 32 44 - Fix `displayCommand` duplication in `pty list` for toml-spawned sessions 33 45 - `displayCommand` now includes full command + args for `pty run` sessions 46 + - Supervisor logs every skip reason in `doRestart` for debugging 47 + - Supervisor state directory moved to `~/.local/state/pty/supervisor/` (no longer pollutes session dir) 48 + 49 + ## 0.7.1 50 + 51 + ### Fixes 34 52 - `pty peek` now works on exited sessions by reading saved output from metadata 35 53 - `pty peek --wait` handles exited sessions: checks saved output, shows last lines and exit code if pattern not found 36 54 - `--wait` accepts multiple patterns (`--wait "passed" --wait "failed"`) — matches on any ··· 38 56 - Exit metadata saved twice: immediately in `onExit` (for status display) and again in `close()` (for complete output after all PTY data has flushed) 39 57 - Fix TUI race where session showed as "running" after exit (delay list refresh 200ms to let metadata flush) 40 58 - Fix SKILL.md examples to use multiple `--wait` flags instead of regex syntax 41 - - Supervisor logs every skip reason in `doRestart` for debugging 42 - - Supervisor state directory moved to `~/.local/state/pty/supervisor/` (no longer pollutes session dir) 43 - 44 - ### TUI framework 45 - - Export `SelectableGroup<T>` interface from `@myobie/pty/tui` 46 - - `groupedSelectable` `renderHeader` callback now receives the full group object instead of `(title, count)` — allows custom group rendering 47 - - Empty groups are now rendered (header shown) instead of being silently skipped 48 59 49 60 ## 0.7.0 50 61
+1
README.md
··· 63 63 64 64 pty attach myserver # reconnect to a session 65 65 pty attach -r myserver # reconnect, auto-restart if exited 66 + pty exec -- codex # replace this session's process (inside a session) 66 67 pty peek myserver # print current screen and exit 67 68 pty peek --plain myserver # print as plain text (no ANSI) 68 69 pty peek --full myserver # print full scrollback
+1 -1
completions/pty.bash
··· 6 6 COMPREPLY=() 7 7 cur="${COMP_WORDS[COMP_CWORD]}" 8 8 prev="${COMP_WORDS[COMP_CWORD-1]}" 9 - commands="run attach a peek send events list ls stats restart kill rm remove gc tag supervisor up down wrap unwrap test help" 9 + commands="run attach a exec peek send events list ls stats restart kill rm remove gc tag supervisor up down wrap unwrap test help" 10 10 11 11 # Complete subcommand 12 12 if [[ ${COMP_CWORD} -eq 1 ]]; then
+1
completions/pty.fish
··· 47 47 complete -c pty -n __pty_needs_command -a run -d 'Create a session and attach' 48 48 complete -c pty -n __pty_needs_command -a attach -d 'Attach to an existing session' 49 49 complete -c pty -n __pty_needs_command -a a -d 'Attach to an existing session' 50 + complete -c pty -n __pty_needs_command -a exec -d 'Replace the current session command' 50 51 complete -c pty -n __pty_needs_command -a peek -d 'Print current screen or follow output' 51 52 complete -c pty -n __pty_needs_command -a send -d 'Send text or keys to a session' 52 53 complete -c pty -n __pty_needs_command -a events -d 'Follow terminal events from sessions'
+1
completions/pty.zsh
··· 27 27 'run:Create a session and attach' 28 28 'attach:Attach to an existing session' 29 29 'a:Attach to an existing session' 30 + 'exec:Replace the current session command' 30 31 'peek:Print current screen or follow output' 31 32 'send:Send text or keys to a session' 32 33 'events:Follow terminal events from sessions'
+73 -1
src/cli.ts
··· 16 16 releaseLock, 17 17 updateTags, 18 18 readMetadata, 19 + writeMetadata, 19 20 getSessionDir, 20 21 type SessionInfo, 21 22 } from "./sessions.ts"; 22 23 import { spawnDaemon, resolveCommand } from "./spawn.ts"; 23 - import { EventFollower, readRecentEvents, formatEvent } from "./events.ts"; 24 + import { EventFollower, EventWriter, EventType, readRecentEvents, formatEvent } from "./events.ts"; 24 25 import { readPtyFile, type PtySessionDef } from "./ptyfile.ts"; 25 26 import { getSupervisorDir } from "./supervisor.ts"; 26 27 ··· 41 42 pty run --tag key=value -- <command> Tag a session with metadata 42 43 pty run --cwd /path -- <command> Run in a specific directory 43 44 pty attach <name> Attach to an existing session 45 + pty exec -- <command> [args...] Replace the current session's command 44 46 pty attach -r <name> Attach, auto-restart if exited 45 47 pty peek <name> Print current screen and exit 46 48 pty peek --plain <name> Print current screen as plain text (no ANSI) ··· 251 253 process.exit(1); 252 254 } 253 255 await cmdAttach(attachName, autoRestart); 256 + break; 257 + } 258 + 259 + case "exec": { 260 + // pty exec -- <command> [args...] 261 + const dashDash = args.indexOf("--", 1); 262 + if (dashDash === -1 || dashDash + 1 >= args.length) { 263 + console.error("Usage: pty exec -- <command> [args...]"); 264 + process.exit(1); 265 + } 266 + const execCmd = args[dashDash + 1]; 267 + const execArgs = args.slice(dashDash + 2); 268 + await cmdExec(execCmd, execArgs); 254 269 break; 255 270 } 256 271 ··· 821 836 onDetach: () => process.exit(0), 822 837 onExit: (code) => process.exit(code), 823 838 }); 839 + } 840 + 841 + async function cmdExec(command: string, cmdArgs: string[]): Promise<void> { 842 + const sessionName = process.env.PTY_SESSION; 843 + if (!sessionName) { 844 + console.error("pty exec: not inside a pty session (PTY_SESSION not set)."); 845 + process.exit(1); 846 + } 847 + 848 + const meta = readMetadata(sessionName); 849 + if (!meta) { 850 + console.error(`pty exec: session "${sessionName}" metadata not found.`); 851 + process.exit(1); 852 + } 853 + 854 + if (meta.tags?.ptyfile) { 855 + console.error(`pty exec: session "${sessionName}" is managed by ${meta.tags.ptyfile}`); 856 + console.error("Edit the pty.toml to change the command instead."); 857 + process.exit(1); 858 + } 859 + 860 + // Resolve the command to an absolute path 861 + let resolved: string; 862 + try { 863 + resolved = resolveCommand(command); 864 + } catch (e: any) { 865 + console.error(e.message); 866 + process.exit(1); 867 + } 868 + 869 + // Update metadata with the new command 870 + const previousCommand = meta.displayCommand ?? [meta.command, ...(meta.args ?? [])].join(" "); 871 + const displayCommand = [command, ...cmdArgs].join(" "); 872 + writeMetadata(sessionName, { 873 + ...meta, 874 + command: resolved, 875 + args: cmdArgs, 876 + displayCommand, 877 + }); 878 + 879 + // Emit exec event 880 + const writer = new EventWriter(sessionName); 881 + writer.append({ 882 + session: sessionName, 883 + type: EventType.SESSION_EXEC, 884 + ts: new Date().toISOString(), 885 + previousCommand, 886 + command: displayCommand, 887 + } as any); 888 + await writer.flush(); 889 + 890 + // Replace this process with the new command 891 + const result = spawnSync(resolved, cmdArgs, { 892 + stdio: "inherit", 893 + env: process.env, 894 + }); 895 + process.exit(result.status ?? 1); 824 896 } 825 897 826 898 async function cmdPeekWait(name: string, patterns: string[], timeoutSec: number, plain: boolean): Promise<void> {
+1 -1
src/client-api.ts
··· 33 33 type EventRecord, type EventBase, 34 34 type BellEvent, type TitleChangeEvent, type NotificationEvent, 35 35 type FocusRequestEvent, type CursorVisibleEvent, 36 - type SessionStartEvent, type SessionExitEvent, 36 + type SessionStartEvent, type SessionExitEvent, type SessionExecEvent, 37 37 type SessionRestartEvent, type SessionFailedEvent, 38 38 type SupervisorStartEvent, type SupervisorStopEvent, 39 39 type FollowerOptions,
+10
src/events.ts
··· 10 10 CURSOR_VISIBLE: "cursor_visible", 11 11 SESSION_START: "session_start", 12 12 SESSION_EXIT: "session_exit", 13 + SESSION_EXEC: "session_exec", 13 14 SESSION_RESTART: "session_restart", 14 15 SESSION_FAILED: "session_failed", 15 16 SUPERVISOR_START: "supervisor_start", ··· 58 59 exitCode: number; 59 60 } 60 61 62 + export interface SessionExecEvent extends EventBase { 63 + type: "session_exec"; 64 + previousCommand: string; 65 + command: string; 66 + } 67 + 61 68 export interface SessionRestartEvent extends EventBase { 62 69 type: "session_restart"; 63 70 restartCount: number; ··· 86 93 | CursorVisibleEvent 87 94 | SessionStartEvent 88 95 | SessionExitEvent 96 + | SessionExecEvent 89 97 | SessionRestartEvent 90 98 | SessionFailedEvent 91 99 | SupervisorStartEvent ··· 297 305 } 298 306 case "session_exit": 299 307 return `${prefix} exited (code ${event.exitCode})`; 308 + case "session_exec": 309 + return `${prefix} exec ${event.command} (was ${event.previousCommand})`; 300 310 case "session_restart": 301 311 return `${prefix} restarted (attempt ${event.restartCount}, backoff ${event.backoffMs}ms)`; 302 312 case "session_failed":
+1 -1
src/tui/interactive.ts
··· 233 233 } 234 234 235 235 if (item.type === "remote-create") { 236 - return [text(sel + "+ Spawn remote session...", selected ? "accent" : "muted", { bold: selected, truncate: true })]; 236 + return [text(sel + "+ Create new session...", selected ? "accent" : "muted", { bold: selected, truncate: true })]; 237 237 } 238 238 239 239 if (item.type === "remote" && item.remote) {
+220
tests/exec.test.ts
··· 1 + import { describe, it, expect, afterEach, afterAll } from "vitest"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 4 + import * as path from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + import { spawn, spawnSync } from "node:child_process"; 7 + import { readRecentEvents } from "../src/events.ts"; 8 + 9 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 10 + const nodeBin = process.execPath; 11 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 12 + const serverModule = path.join(__dirname, "..", "dist", "server.js"); 13 + 14 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-exec-")); 15 + afterAll(() => { 16 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 17 + }); 18 + 19 + let bgPids: number[] = []; 20 + let sessionDirs: string[] = []; 21 + 22 + function makeSessionDir(): string { 23 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 24 + sessionDirs.push(dir); 25 + return dir; 26 + } 27 + 28 + let nameCounter = 0; 29 + function uniqueName(): string { 30 + return `ex${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; 31 + } 32 + 33 + async function startDaemon( 34 + sessionDir: string, 35 + name: string, 36 + command: string, 37 + args: string[] = [], 38 + tags?: Record<string, string>, 39 + ): Promise<number> { 40 + const config = JSON.stringify({ 41 + name, command, args, displayCommand: [command, ...args].join(" "), 42 + cwd: os.tmpdir(), rows: 24, cols: 80, tags, 43 + }); 44 + const child = spawn(nodeBin, [serverModule], { 45 + detached: true, 46 + stdio: ["ignore", "ignore", "pipe"], 47 + env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir }, 48 + }); 49 + let stderr = ""; 50 + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 51 + let exitCode: number | null = null; 52 + child.on("exit", (code) => { exitCode = code; }); 53 + (child.stderr as any)?.unref?.(); 54 + child.unref(); 55 + 56 + const socketPath = path.join(sessionDir, `${name}.sock`); 57 + const start = Date.now(); 58 + while (Date.now() - start < 5000) { 59 + if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`); 60 + try { 61 + fs.statSync(socketPath); 62 + await new Promise((r) => setTimeout(r, 100)); 63 + bgPids.push(child.pid!); 64 + return child.pid!; 65 + } catch {} 66 + await new Promise((r) => setTimeout(r, 50)); 67 + } 68 + throw new Error("Timeout waiting for daemon"); 69 + } 70 + 71 + function readMeta(sessionDir: string, name: string): any { 72 + try { 73 + return JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf-8")); 74 + } catch { return null; } 75 + } 76 + 77 + afterEach(() => { 78 + for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} } 79 + bgPids = []; 80 + for (const dir of sessionDirs) { 81 + try { 82 + for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} } 83 + } catch {} 84 + } 85 + sessionDirs = []; 86 + }); 87 + 88 + describe("pty exec", () => { 89 + it("updates metadata and runs the new command", async () => { 90 + const dir = makeSessionDir(); 91 + const name = uniqueName(); 92 + await startDaemon(dir, name, "bash"); 93 + 94 + // Run pty exec inside the session (simulate via PTY_SESSION env var) 95 + const result = spawnSync(nodeBin, [cliPath, "exec", "--", "echo", "hello-from-exec"], { 96 + env: { ...process.env, PTY_SESSION_DIR: dir, PTY_SESSION: name }, 97 + encoding: "utf-8", 98 + timeout: 10000, 99 + }); 100 + 101 + expect(result.status).toBe(0); 102 + expect(result.stdout).toContain("hello-from-exec"); 103 + 104 + // Metadata should be updated 105 + const meta = readMeta(dir, name); 106 + expect(meta.displayCommand).toBe("echo hello-from-exec"); 107 + expect(meta.args).toEqual(["hello-from-exec"]); 108 + }, 15000); 109 + 110 + it("errors when not inside a pty session", () => { 111 + const dir = makeSessionDir(); 112 + const env = { ...process.env, PTY_SESSION_DIR: dir }; 113 + delete env.PTY_SESSION; 114 + 115 + const result = spawnSync(nodeBin, [cliPath, "exec", "--", "echo", "hi"], { 116 + env, 117 + encoding: "utf-8", 118 + timeout: 10000, 119 + }); 120 + 121 + expect(result.status).not.toBe(0); 122 + expect(result.stderr).toContain("not inside a pty session"); 123 + }, 15000); 124 + 125 + it("errors on toml-managed sessions", async () => { 126 + const dir = makeSessionDir(); 127 + const name = uniqueName(); 128 + await startDaemon(dir, name, "bash", [], { 129 + ptyfile: "/some/path/pty.toml", 130 + "ptyfile.session": "test", 131 + }); 132 + 133 + const result = spawnSync(nodeBin, [cliPath, "exec", "--", "echo", "hi"], { 134 + env: { ...process.env, PTY_SESSION_DIR: dir, PTY_SESSION: name }, 135 + encoding: "utf-8", 136 + timeout: 10000, 137 + }); 138 + 139 + expect(result.status).not.toBe(0); 140 + expect(result.stderr).toContain("pty.toml"); 141 + }, 15000); 142 + 143 + it("preserves existing tags when updating metadata", async () => { 144 + const dir = makeSessionDir(); 145 + const name = uniqueName(); 146 + await startDaemon(dir, name, "bash", [], { role: "dev", strategy: "permanent" }); 147 + 148 + spawnSync(nodeBin, [cliPath, "exec", "--", "echo", "tagged"], { 149 + env: { ...process.env, PTY_SESSION_DIR: dir, PTY_SESSION: name }, 150 + encoding: "utf-8", 151 + timeout: 10000, 152 + }); 153 + 154 + const meta = readMeta(dir, name); 155 + expect(meta.displayCommand).toBe("echo tagged"); 156 + expect(meta.tags.role).toBe("dev"); 157 + expect(meta.tags.strategy).toBe("permanent"); 158 + }, 15000); 159 + 160 + it("propagates exit code from the exec'd command", async () => { 161 + const dir = makeSessionDir(); 162 + const name = uniqueName(); 163 + await startDaemon(dir, name, "bash"); 164 + 165 + const result = spawnSync(nodeBin, [cliPath, "exec", "--", "sh", "-c", "exit 42"], { 166 + env: { ...process.env, PTY_SESSION_DIR: dir, PTY_SESSION: name }, 167 + encoding: "utf-8", 168 + timeout: 10000, 169 + }); 170 + 171 + expect(result.status).toBe(42); 172 + }, 15000); 173 + 174 + it("errors when no command provided", () => { 175 + const result = spawnSync(nodeBin, [cliPath, "exec"], { 176 + env: { ...process.env, PTY_SESSION: "test" }, 177 + encoding: "utf-8", 178 + timeout: 10000, 179 + }); 180 + 181 + expect(result.status).not.toBe(0); 182 + expect(result.stderr).toContain("Usage"); 183 + }, 15000); 184 + 185 + it("emits session_exec event", async () => { 186 + const dir = makeSessionDir(); 187 + const name = uniqueName(); 188 + await startDaemon(dir, name, "bash"); 189 + 190 + process.env.PTY_SESSION_DIR = dir; 191 + spawnSync(nodeBin, [cliPath, "exec", "--", "echo", "swapped"], { 192 + env: { ...process.env, PTY_SESSION_DIR: dir, PTY_SESSION: name }, 193 + encoding: "utf-8", 194 + timeout: 10000, 195 + }); 196 + 197 + const events = readRecentEvents(name); 198 + const execEvents = events.filter((e: any) => e.type === "session_exec"); 199 + expect(execEvents.length).toBeGreaterThanOrEqual(1); 200 + const ev = execEvents[execEvents.length - 1] as any; 201 + expect(ev.session).toBe(name); 202 + expect(ev.command).toBe("echo swapped"); 203 + expect(ev.previousCommand).toBeDefined(); 204 + }, 15000); 205 + 206 + it("errors on nonexistent command", async () => { 207 + const dir = makeSessionDir(); 208 + const name = uniqueName(); 209 + await startDaemon(dir, name, "bash"); 210 + 211 + const result = spawnSync(nodeBin, [cliPath, "exec", "--", "/nonexistent/cmd"], { 212 + env: { ...process.env, PTY_SESSION_DIR: dir, PTY_SESSION: name }, 213 + encoding: "utf-8", 214 + timeout: 10000, 215 + }); 216 + 217 + expect(result.status).not.toBe(0); 218 + expect(result.stderr).toContain("not found"); 219 + }, 15000); 220 + });