diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f012a1..fdf30ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +### Attach and follow trailers identify the session + +- Detaching from `pty attach` or `pty peek -f` now prints the stable session id, + its display name, cwd, command and visible tags, followed by an actionable + reattach command. Session exits include the runtime and a restart hint only + when the registry retains the session. Remote attach hints include the peer. +- When a daemon closes its socket without an exit packet (for example after + `pty kill`), attach and follow-mode peek now say that the session ended, + including the same summary but no restart hint. A client-rejected malformed + frame remains a connection error, not a session-end announcement. +- Attach banners include the display name when available. Machine attach + streams remain framed, with no new stderr output on detach or EXIT. + ### `pty metadata patch` waits out the attach window - An attached child starts while `pty run` still holds the per-session diff --git a/src/cli.ts b/src/cli.ts index ecd3c50..3d9100a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import { randomBytes } from "node:crypto"; import { attach, peek, send, queryStats, resolveSeqDelayMs, validateAttachStreamFdV1, type StatsResult, isSessionAlive, } from "./client.ts"; +import { plainLabel, renderLabel, renderTags, shortPath, strategyMarker } from "./session-presentation.ts"; import { printVersion } from "./version.ts"; import { parseSeqValue } from "./keys.ts"; import { @@ -62,9 +63,9 @@ import { emitUserEvent, } from "./events.ts"; import { readPtyFile, type PtySessionDef } from "./ptyfile.ts"; -import { extractFilterTags as extractFilterTagsImpl, matchesAllTags, isReservedTagKey } from "./tags.ts"; +import { extractFilterTags as extractFilterTagsImpl, matchesAllTags } from "./tags.ts"; import { parseDuration, formatDuration } from "./duration.ts"; -import { serveRemoteControl, runRemoteServeStdio, fetchRemoteList, dialAndRoute, RouteRefusedError, PTY_REMOTE_ALPN, FABRIC_BIN } from "./remote.ts"; +import { serveRemoteControl, runRemoteServeStdio, fetchRemoteList, dialPeer, routeSocket, dialAndRoute, RouteRefusedError, PTY_REMOTE_ALPN, FABRIC_BIN, type RemoteSessionRow } from "./remote.ts"; import { RECOVERY_PROTOCOL, assertPrivateRecoveryPaths, @@ -1907,8 +1908,13 @@ async function handleDeadSession( } function doAttach(name: string, attachStreamFdV1?: number): void { + const metadata = readMetadata(name); + if (attachStreamFdV1 === undefined) { + process.stderr.write(`[attached to ${plainLabel({ name, displayName: metadata?.displayName })} — press Ctrl+\\ to detach]\n`); + } attach({ name, + metadata, ...(attachStreamFdV1 !== undefined ? { attachStreamFdV1 } : {}), onDetach: () => process.exit(0), onExit: (code) => process.exit(code), @@ -2058,6 +2064,7 @@ async function cmdPeek(name: string, follow: boolean, plain: boolean, full = fal peek({ name, + metadata: session?.metadata, follow, plain, full, @@ -2077,14 +2084,19 @@ async function cmdPeekRemote( full: boolean, ): Promise { let socket; + let row: RemoteSessionRow | undefined; try { - socket = await dialAndRoute(peer, name); + const socketPath = dialPeer(peer); + row = follow ? await fetchPeerRow(socketPath, name) : undefined; + socket = await routeSocket(socketPath, name); } catch (e) { console.error(`pty peek --remote ${peer}: ${(e as Error).message}`); process.exit(1); } peek({ name, + row, + peer, follow, plain, full, @@ -2120,19 +2132,37 @@ async function cmdSendRemote( }); } +// Query before opening the routed socket: otherwise the daemon can send its +// first packet while the CLI awaits metadata and no client listener exists. +async function fetchPeerRow(socketPath: string, name: string): Promise { + try { + return (await fetchRemoteList(socketPath)).find((row) => row.name === name); + } catch { + return undefined; + } +} + /** `pty attach --remote `: dial the peer's exposed pty control * socket over fabric, route it to the named remote session, and attach over * that tunnel — the resilient shell is a long-lived remote pty you attach to. */ async function cmdAttachRemote(peer: string, name: string, attachStreamFdV1?: number): Promise { let socket; + let row: RemoteSessionRow | undefined; try { - socket = await dialAndRoute(peer, name); + const socketPath = dialPeer(peer); + row = await fetchPeerRow(socketPath, name); + socket = await routeSocket(socketPath, name); } catch (e) { console.error(`pty attach --remote ${peer}: ${(e as Error).message}`); process.exit(1); } + if (attachStreamFdV1 === undefined) { + process.stderr.write(`[attached to ${plainLabel({ name, displayName: row?.displayName })} — press Ctrl+\\ to detach]\n`); + } attach({ name, + row, + peer, socket, ...(attachStreamFdV1 !== undefined ? { attachStreamFdV1 } : {}), // On a loud fabric close, re-dial + re-route to the same remote session and @@ -2395,28 +2425,6 @@ async function cmdList(opts: ListOptions = {}): Promise { const exited = sessions.filter((s) => s.status === "exited"); const vanished = sessions.filter((s) => s.status === "vanished"); - // Render tags as hashtags. When `showAll` is false, hide reserved keys - // (pty-internal bookkeeping like `ptyfile*`/`strategy`, plus any key - // starting with `:` which is the tool-owned-tag convention — e.g., - // pty-layout's `:l-` view membership markers). `--tags` - // (showAll=true) shows everything. - const renderTags = (tags: Record | undefined, showAll: boolean): string => { - if (!tags) return ""; - const entries = Object.entries(tags).filter(([k]) => showAll || !isReservedTagKey(k)); - return entries.length > 0 ? " " + entries.map(([k, v]) => `#${k}=${v}`).join(" ") : ""; - }; - - // Render the session's primary label. If displayName is set, it appears - // first with the stable id in parens for disambiguation; otherwise just - // the id. Users can match either in any CLI command. - const renderLabel = (session: SessionInfo, boldCode: string): string => { - const dn = session.metadata?.displayName; - if (dn) { - return `${boldCode}${dn}\x1b[0m \x1b[2m(${session.name})\x1b[0m`; - } - return `${boldCode}${session.name}\x1b[0m`; - }; - if (running.length > 0) { console.log("Active sessions:"); for (const session of running) { @@ -2428,7 +2436,7 @@ async function cmdList(opts: ListOptions = {}): Promise { : ""; const tagStr = renderTags(session.metadata?.tags, showTags); const marker = strategyMarker(session.metadata?.tags); - const label = renderLabel(session, "\x1b[1;36m"); + const label = renderLabel(session.name, session.metadata?.displayName, "\x1b[1;36m"); console.log(` ${label}${marker}${tagStr} (pid: ${session.pid}) — ${cwd} — \x1b[2m${cmd}\x1b[0m`); } } @@ -2446,7 +2454,7 @@ async function cmdList(opts: ListOptions = {}): Promise { : ""; const tagStr = renderTags(meta?.tags, showTags); const marker = strategyMarker(meta?.tags); - const label = renderLabel(session, "\x1b[1m"); + const label = renderLabel(session.name, session.metadata?.displayName, "\x1b[1m"); console.log(` ${label}${marker}${tagStr} (exited with code ${code}, ${ago}) — ${cwd} — \x1b[2m${cmd}\x1b[0m`); } } @@ -2465,7 +2473,7 @@ async function cmdList(opts: ListOptions = {}): Promise { const cmd = meta ? meta.displayCommand : ""; const tagStr = renderTags(meta?.tags, showTags); const marker = strategyMarker(meta?.tags); - const label = renderLabel(session, "\x1b[1;33m"); + const label = renderLabel(session.name, session.metadata?.displayName, "\x1b[1;33m"); console.log(` \u26a0 ${label}${marker}${tagStr} (vanished, started ${ago}) — ${cwd} — \x1b[2m${cmd}\x1b[0m`); } } @@ -4213,24 +4221,6 @@ function clearFlappingBookkeeping( return Object.keys(out).length > 0 ? out : undefined; } -function strategyMarker(tags?: Record): string { - if (!tags) return ""; - // Flapping supersedes permanent visually because it's what changed the - // operator's expectation ("gc stopped respawning this on purpose"). - // Rendered red so it stands out from the yellow [permanent]. - if (tags["strategy.status"] === "flapping") { - return " \x1b[31m[flapping]\x1b[0m"; - } - if (tags.strategy === "permanent") return " \x1b[33m[permanent]\x1b[0m"; - return ""; -} - -function shortPath(p: string): string { - const home = os.homedir(); - if (p === home) return "~"; - if (p.startsWith(home + "/")) return "~" + p.slice(home.length); - return p; -} function timeAgo(date: Date): string { const seconds = Math.floor((Date.now() - date.getTime()) / 1000); diff --git a/src/client.ts b/src/client.ts index b1d6308..5bf37cf 100644 --- a/src/client.ts +++ b/src/client.ts @@ -13,7 +13,10 @@ import { encodeStatus, decodeExit, } from "./protocol.ts"; -import { getSocketPath } from "./sessions.ts"; +import { getSocketPath, readMetadata, reapOnExitDefault, shouldReapAtExit, type SessionMetadata } from "./sessions.ts"; +import { formatDuration } from "./duration.ts"; +import { sessionSummary, type SessionPresentation } from "./session-presentation.ts"; +import type { RemoteSessionRow } from "./remote.ts"; import { stripAnsi } from "./tui/colors.ts"; import { BRACKETED_PASTE_START, BRACKETED_PASTE_END } from "./paste.ts"; @@ -81,6 +84,45 @@ export const TERMINAL_SANITIZE = // "[detached]") appear below the session content, not mid-screen. const CURSOR_TO_BOTTOM = "\x1b[999;1H"; +export interface TrailerSource { + name: string; + metadata?: SessionMetadata | null; + row?: RemoteSessionRow; + peer?: string; +} + +const trailerInfo = (source: TrailerSource): SessionPresentation | undefined => { + if (source.peer) { + if (!source.row) return undefined; + return { name: source.name, displayName: source.row.displayName, cwd: source.row.cwd, + displayCommand: source.row.command, tags: source.row.tags }; + } + const meta = readMetadata(source.name) ?? source.metadata; + if (!meta) return undefined; + return { name: source.name, displayName: meta.displayName, cwd: meta.cwd, + displayCommand: meta.displayCommand, tags: meta.tags, createdAt: meta.createdAt, + ephemeral: meta.ephemeral }; +}; + +export const trailer = ( + source: TrailerSource, + header: string, + hint?: string, + plain = false, + sanitize = true, +): string => { + const info = trailerInfo(source); + const lines = [header, ...(info ? [sessionSummary(info)] : []), ...(hint ? [` ${hint}`] : [])]; + const body = lines.map((line) => plain ? stripAnsi(line) : line).join("\r\n") + "\r\n"; + return (sanitize ? TERMINAL_SANITIZE + CURSOR_TO_BOTTOM : "") + "\r\n" + body; +}; + +export const exitHeader = (name: string, code: number, info?: SessionPresentation): string => { + const created = info?.createdAt ? Date.parse(info.createdAt) : NaN; + const elapsed = Number.isFinite(created) ? ` after ${formatDuration(Math.max(0, Date.now() - created))}` : ""; + return `[${name} exited with code ${code}${elapsed}]`; +}; + export interface PeekOptions { name: string; follow?: boolean; // If true, stay connected and stream (like tail -f). If false, print screen and exit. @@ -93,6 +135,9 @@ export interface PeekOptions { * control-server-routed socket that transparently pipes to the remote * session's daemon. When set, `name` is only used for display. */ socket?: net.Socket; + metadata?: SessionMetadata | null; + row?: RemoteSessionRow; + peer?: string; } /** Read-only view of a session. Input is ignored by the server. */ @@ -101,6 +146,8 @@ export function peek(options: PeekOptions): void { const socket = options.socket ?? net.createConnection(getSocketPath(options.name)); const stdout = process.stdout; const follow = options.follow ?? false; + let completed = false; + let failed = false; const onReady = () => { socket.write(encodePeek(options.plain, options.full)); @@ -115,8 +162,11 @@ export function peek(options: PeekOptions): void { for (let i = 0; i < data.length; i++) { if (data[i] === DETACH_KEY) { if (stdin.isTTY) stdin.setRawMode(false); + completed = true; socket.destroy(); - stdout.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + "\r\n[detached]\r\n"); + stdout.write(trailer(options, `[detached from ${options.name}]`, + `reattach: pty peek -f ${options.peer ? `--remote ${options.peer} ` : ""}${options.name}`, + options.plain)); options.onDetach?.(); return; } @@ -141,6 +191,7 @@ export function peek(options: PeekOptions): void { socket.on("data", (data: Buffer) => { let packets; try { packets = reader.feed(data); } catch (err: any) { + failed = true; console.error(`pty client: dropping connection — ${err.message}`); try { socket.destroy(); } catch {} return; @@ -155,6 +206,7 @@ export function peek(options: PeekOptions): void { stdout.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM); } stdout.write("\n"); + completed = true; socket.destroy(); return; } @@ -168,12 +220,16 @@ export function peek(options: PeekOptions): void { case MessageType.EXIT: { const code = decodeExit(packet.payload); + completed = true; socket.destroy(); if (!options.plain) { stdout.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM); } if (follow) { - stdout.write(`\r\n[${options.name} exited with code ${code}]\r\n`); + const info = trailerInfo(options); + const restart = !options.peer && info && !shouldReapAtExit(info.tags, info.ephemeral ?? false, reapOnExitDefault()); + stdout.write(trailer(options, exitHeader(options.name, code, info), + restart ? `restart: pty attach ${options.name}` : undefined, options.plain, false)); } options.onExit?.(code); return; @@ -183,6 +239,7 @@ export function peek(options: PeekOptions): void { }); socket.on("error", (err: NodeJS.ErrnoException) => { + failed = true; // ECONNRESET/EPIPE also mean "gone": a `--remote` route to a missing session // has the control server close the tunnel as we write the first frame. const notReachable = err.code === "ENOENT" || err.code === "ECONNREFUSED" @@ -203,6 +260,12 @@ export function peek(options: PeekOptions): void { if (process.stdin.isTTY && process.stdin.isRaw) { process.stdin.setRawMode(false); } + if (follow && !completed && !failed) { + completed = true; + stdout.write(trailer(options, `[${options.name} session ended]`, + undefined, options.plain, !options.plain)); + options.onExit?.(0); + } // Closed in one-shot mode before any screen — the (possibly remote) session // isn't reachable. Don't exit 0 with no output. if (!follow && !gotScreen) { @@ -433,6 +496,9 @@ export interface AttachOptions { * descriptor. stdin/stdout remain the controlling terminal for input and * geometry; terminal output is emitted only as framed protocol packets. */ attachStreamFdV1?: number; + metadata?: SessionMetadata | null; + row?: RemoteSessionRow; + peer?: string; } /** Validate a dedicated inherited descriptor without taking ownership of it. */ @@ -480,6 +546,7 @@ export function attach(options: AttachOptions): void { let detaching = false; let rawWasSet = false; let exitCode = 0; + let protocolFailed = false; let exitHandled = false; let exitCompleted = false; let sessionExited = false; // saw an EXIT packet — the session really ended; don't reconnect @@ -540,7 +607,8 @@ export function attach(options: AttachOptions): void { } else { try { socket.write(encodeDetach()); } catch {} cleanExit(); - stdout.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + "\r\n[detached]\r\n"); + stdout.write(trailer(options, `[detached from ${options.name}]`, + `reattach: pty attach ${options.peer ? `--remote ${options.peer} ` : ""}${options.name}`)); completeDetach(); } } @@ -611,6 +679,7 @@ export function attach(options: AttachOptions): void { let packets; try { packets = reader.feed(data); } catch (err: any) { + protocolFailed = true; console.error(`pty client: dropping connection — ${err.message}`); try { socket.destroy(); } catch {} return; @@ -674,7 +743,11 @@ export function attach(options: AttachOptions): void { case MessageType.EXIT: exitCode = decodeExit(packet.payload); sessionExited = true; // real exit — never reconnect past it - stdout.write(TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + `\r\n[${options.name} exited with code ${exitCode}]\r\n`); + const info = trailerInfo(options); + const restart = !options.peer && info && !shouldReapAtExit(info.tags, info.ephemeral ?? false, reapOnExitDefault()); + cleanExit(); + stdout.write(trailer(options, exitHeader(options.name, exitCode, info), + restart ? `restart: pty attach ${options.name}` : undefined)); finish(exitCode); return; } @@ -707,6 +780,10 @@ export function attach(options: AttachOptions): void { console.error("pty attach: machine stream truncated before EXIT: connection closed"); finish(1); } else { + if (!sessionExited && !protocolFailed) { + cleanExit(); + stdout.write(trailer(options, `[${options.name} session ended]`)); + } finish(exitCode); } } @@ -714,6 +791,7 @@ export function attach(options: AttachOptions): void { function bindSocket(s: net.Socket, preConnected: boolean): void { reader = new PacketReader(); + protocolFailed = false; machineInitialState = "geometry"; if (streamBackpressured) s.pause(); s.on("data", handleData); @@ -744,9 +822,10 @@ export function attach(options: AttachOptions): void { // (Transport failures resolve null, so we keep retrying below.) if (detaching || exitHandled) break; reconnecting = false; + cleanExit(); status.write(attachStream ? `[${options.name} session ended]\n` - : TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + `\r\n[${options.name} session ended]\r\n`); + : trailer(options, `[${options.name} session ended]`)); finish(attachStream ? 1 : 0); return; } @@ -762,9 +841,11 @@ export function attach(options: AttachOptions): void { // ends it (besides the user's Ctrl-\ / Ctrl-C). if (++attempt >= RECONNECT_MAX_ATTEMPTS) { reconnecting = false; + cleanExit(); status.write(attachStream - ? `[${options.name}: connection lost — re-run \`pty attach --remote\` to reconnect]\n` - : TERMINAL_SANITIZE + CURSOR_TO_BOTTOM + `\r\n[${options.name}: connection lost — re-run \`pty attach --remote\` to reconnect]\r\n`); + ? `[connection lost to ${options.name}]\n` + : trailer(options, `[connection lost to ${options.name}]`, + `reconnect: pty attach --remote ${options.peer} ${options.name}`)); finish(1); return; } diff --git a/src/remote.ts b/src/remote.ts index 3d41853..de51f15 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -195,26 +195,20 @@ export function runRemoteServeStdio(): void { process.stdin.resume(); } -/** Dial a fabric peer's exposed pty control socket and route it to a specific - * remote session. The resolved socket is a transparent pipe to that session's - * daemon socket, ready for the ordinary per-session protocol (attach/peek/send - * client code runs over it unchanged). */ -export function dialAndRoute(peer: string, name: string, timeoutMs = 10000): Promise { +/** Resolve the peer's control socket before starting any routed session stream. */ +export function dialPeer(peer: string, timeoutMs = 10000): string { + const dialSock = execFileSync(FABRIC_BIN, ["dial", peer, PTY_REMOTE_ALPN], { + encoding: "utf-8", + timeout: timeoutMs, + }).trim(); + if (!dialSock) throw new Error(`fabric dial ${peer} returned no socket`); + return dialSock; +} + +/** Route a dialed control socket to a remote session. Install client listeners + * immediately after this promise resolves: the route is already live. */ +export function routeSocket(dialSock: string, name: string, timeoutMs = 10000): Promise { return new Promise((resolve, reject) => { - let dialSock: string; - try { - dialSock = execFileSync(FABRIC_BIN, ["dial", peer, PTY_REMOTE_ALPN], { - encoding: "utf-8", - timeout: timeoutMs, - }).trim(); - } catch (e) { - reject(e instanceof Error ? e : new Error(String(e))); - return; - } - if (!dialSock) { - reject(new Error(`fabric dial ${peer} returned no socket`)); - return; - } const sock = net.createConnection(dialSock); let acked = false; let buf: Buffer = Buffer.alloc(0); @@ -263,6 +257,11 @@ export function dialAndRoute(peer: string, name: string, timeoutMs = 10000): Pro }); } +/** Dial and route when no metadata lookup is needed (send and reconnect). */ +export async function dialAndRoute(peer: string, name: string, timeoutMs = 10000): Promise { + return routeSocket(dialPeer(peer, timeoutMs), name, timeoutMs); +} + /** Connect to a control socket (a local path, or one handed to us by * `fabric dial`), request the session list, and return the rows. */ export function fetchRemoteList(socketPath: string, timeoutMs = 10000): Promise { diff --git a/src/session-presentation.ts b/src/session-presentation.ts new file mode 100644 index 0000000..d704e21 --- /dev/null +++ b/src/session-presentation.ts @@ -0,0 +1,43 @@ +import * as os from "node:os"; +import { isReservedTagKey } from "./tags.ts"; + +export interface SessionPresentation { + name: string; + displayName?: string; + cwd?: string; + displayCommand?: string; + tags?: Record; + createdAt?: string; + ephemeral?: boolean; +} + +export const renderTags = (tags: Record | undefined, showAll: boolean): string => { + if (!tags) return ""; + const entries = Object.entries(tags).filter(([k]) => showAll || !isReservedTagKey(k)); + return entries.length > 0 ? " " + entries.map(([k, v]) => `#${k}=${v}`).join(" ") : ""; +}; + +export const renderLabel = (name: string, displayName: string | undefined, boldCode: string): string => + displayName + ? `${boldCode}${displayName}\x1b[0m \x1b[2m(${name})\x1b[0m` + : `${boldCode}${name}\x1b[0m`; + +export const strategyMarker = (tags?: Record): string => { + if (!tags) return ""; + if (tags["strategy.status"] === "flapping") return " \x1b[31m[flapping]\x1b[0m"; + if (tags.strategy === "permanent") return " \x1b[33m[permanent]\x1b[0m"; + return ""; +}; + +export const shortPath = (p: string): string => { + const home = os.homedir(); + if (p === home) return "~"; + if (p.startsWith(home + "/")) return "~" + p.slice(home.length); + return p; +}; + +export const sessionSummary = (s: SessionPresentation): string => + ` ${renderLabel(s.name, s.displayName, "\x1b[1m")}${strategyMarker(s.tags)}${renderTags(s.tags, false)} — ${s.cwd ? shortPath(s.cwd) : ""} — \x1b[2m${s.displayCommand ?? ""}\x1b[0m`; + +export const plainLabel = (s: SessionPresentation): string => + s.displayName ? `${s.displayName} (${s.name})` : s.name; diff --git a/src/tui/interactive.ts b/src/tui/interactive.ts index 7e79989..bb7a9b6 100644 --- a/src/tui/interactive.ts +++ b/src/tui/interactive.ts @@ -7,7 +7,7 @@ import { execFileSync, spawn as spawnChild, spawnSync } from "node:child_process import { attach } from "../client.ts"; import { listSessions, - cleanupAll, getSession, getSessionDir, type SessionInfo, + cleanupAll, getSession, getSessionDir, readMetadata, type SessionInfo, } from "../sessions.ts"; import { spawnDaemon, spawnDaemonWithCreationLock } from "../spawn.ts"; import { matchesAllTags, isReservedTagKey } from "../tags.ts"; @@ -592,6 +592,7 @@ function doAttach(name: string): void { pauseApp(); attach({ name, + metadata: readMetadata(name) ?? sessions.peek().find((session) => session.name === name)?.metadata, onDetach: async () => { // Preserve filter + in-bounds selection so the user returns to the // overview where they were. Closes #27. The selection clamps when diff --git a/tests/attach-no-restart.test.ts b/tests/attach-no-restart.test.ts index 06437a4..bded467 100644 --- a/tests/attach-no-restart.test.ts +++ b/tests/attach-no-restart.test.ts @@ -6,6 +6,8 @@ import * as nodePty from "node-pty"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { terminateAndWait } from "./setup/processes.ts"; +import { stripAnsi } from "../src/tui/colors.ts"; +import { shortPath } from "../src/session-presentation.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const cliPath = path.join(__dirname, "..", "dist", "cli.js"); @@ -246,7 +248,9 @@ describe("pty attach --no-restart", () => { new Promise((_, reject) => setTimeout(() => reject(new Error(`attach did not exit\n${output}`)), 8_000)), ]); expect(result.exitCode).toBe(37); - expect(output).toContain(`${name} exited with code 37`); + expect(output).toMatch(/\[running-target exited with code 37 after \d+[smhd]\]/); + expect(output).toContain(" restart: pty attach running-target"); + expect(output).toContain(" #keep=true — "); try { attached.write("future-relay-input\r"); } catch {} await new Promise((resolve) => setTimeout(resolve, 300)); @@ -254,6 +258,82 @@ describe("pty attach --no-restart", () => { expect(readEventCount(root, name, "session_start")).toBe(1); }, 20_000); + it("re-reads a display name changed during attach for its trailer", async () => { + const root = fs.mkdtempSync(path.join(testRoot, "renamed-live-")); + const created = runCli(root, ["run", "-d", "--id", "live", "--", "sh", "-c", "echo LIVE_READY; cat"]); + expect(created.status, created.stderr).toBe(0); + daemonPids.add(Number(fs.readFileSync(path.join(root, "live.pid"), "utf8").trim())); + const attached = nodePty.spawn(nodeBin, [cliPath, "attach", "live"], { + name: "xterm-256color", cols: 100, rows: 30, env: env(root), + }); + let output = ""; + attached.onData((data) => { output += data; }); + try { + await new Promise((resolve, reject) => { + const started = Date.now(); + const poll = () => { + if (output.includes("LIVE_READY")) resolve(); + else if (Date.now() - started > 8000) reject(new Error(`attach did not become ready\n${output}`)); + else setTimeout(poll, 25); + }; + poll(); + }); + expect(output).toContain("[attached to"); + expect(output).not.toContain("Renamed Live"); + const renamed = runCli(root, ["rename", "live", "Renamed Live"]); + expect(renamed.status, renamed.stderr).toBe(0); + const exited = new Promise((resolve) => attached.onExit(({ exitCode }) => resolve(exitCode))); + attached.write("\x1c"); + expect(await exited).toBe(0); + expect(stripAnsi(output)).toContain("Renamed Live (live)"); + expect(output).toContain("reattach: pty attach live"); + } finally { + try { attached.kill(); } catch {} + } + }, 20_000); + + it("prints the renamed display label, command and exact detach hint", async () => { + const root = fs.mkdtempSync(path.join(testRoot, "detach-")); + const name = "worker"; + const created = runCli(root, ["run", "-d", "--id", name, "--", "sh", "-c", "echo READY; cat"]); + expect(created.status, created.stderr).toBe(0); + daemonPids.add(Number(fs.readFileSync(path.join(root, `${name}.pid`), "utf8").trim())); + const renamed = runCli(root, ["rename", name, "My Worker"]); + expect(renamed.status, renamed.stderr).toBe(0); + const result = await runInTerminal(root, ["attach", name], { text: "\x1c", delayMs: 500 }); + expect(result.code).toBe(0); + expect(result.output).toContain("[attached to My Worker (worker) — press Ctrl+\\ to detach]"); + expect(result.output).toContain("[detached from worker]"); + expect(stripAnsi(result.output)).toContain(`My Worker (worker) — ${shortPath(process.cwd())} — sh -c echo READY; cat`); + expect(result.output).toContain("reattach: pty attach worker"); + }, 20_000); + + it("prints a plain follow summary and a peek-specific reattach command", async () => { + const root = fs.mkdtempSync(path.join(testRoot, "peek-follow-")); + const name = "follow"; + const created = runCli(root, ["run", "-d", "--id", name, "--", "sh", "-c", "echo PEEK_READY; cat"]); + expect(created.status, created.stderr).toBe(0); + daemonPids.add(Number(fs.readFileSync(path.join(root, `${name}.pid`), "utf8").trim())); + const result = await runInTerminal(root, ["peek", "-f", "--plain", name], { text: "\x1c", delayMs: 500 }); + expect(result.code).toBe(0); + const afterHeader = result.output.slice(result.output.indexOf("[detached from follow]")); + expect(afterHeader).toContain(" reattach: pty peek -f follow"); + expect(afterHeader).toContain(` — ${shortPath(process.cwd())} — sh -c echo PEEK_READY; cat`); + expect(afterHeader).not.toContain("\x1b"); + }, 20_000); + + it("omits restart hint when a session is reaped on exit", async () => { + const root = fs.mkdtempSync(path.join(testRoot, "reap-")); + const name = "reaped"; + const created = runCli(root, ["run", "-d", "--id", name, "--", "sh", "-c", "echo READY; read line; exit 7"]); + expect(created.status, created.stderr).toBe(0); + daemonPids.add(Number(fs.readFileSync(path.join(root, `${name}.pid`), "utf8").trim())); + const result = await runInTerminal(root, ["attach", name], { text: "finish\r", delayMs: 500 }); + expect(result.code).toBe(7); + expect(result.output).toMatch(/\[reaped exited with code 7 after \d+[smhd]\]/); + expect(result.output).not.toContain(" restart:"); + }, 20_000); + it("preserves the default prompt-and-restart behavior", async () => { const root = fs.mkdtempSync(path.join(testRoot, "legacy-")); const marker = path.join(root, "invocations"); diff --git a/tests/remote-reconnect.test.ts b/tests/remote-reconnect.test.ts index 34c2721..50cbb00 100644 --- a/tests/remote-reconnect.test.ts +++ b/tests/remote-reconnect.test.ts @@ -161,6 +161,21 @@ describe("attach --remote reconnect harness", () => { } }, 25000); + it("shows the remote peer in the detach hint and the dial-time session row", async () => { + const session = Session.spawn(nodeBin, [cliPath, "attach", "--remote", "testpeer", "rshell"], { + rows: 24, cols: 80, + env: { PTY_ROOT: cliRoot, PTY_ROOT_LEGACY_SILENT: "1", PTY_FABRIC_BIN: fakeFabric }, + }); + try { + await session.waitForText("RECONNECT_READY", 8000); + session.sendKeys("\x1c"); + await session.waitForText("reattach: pty attach --remote testpeer rshell", 8000); + await session.waitForText("rshell)", 8000); + } finally { + await session.close(); + } + }, 20000); + it("survives a tunnel drop: re-dials, re-attaches, and resumes without exiting", async () => { const session = Session.spawn(nodeBin, [cliPath, "attach", "--remote", "testpeer", "rshell"], { rows: 24, cols: 80, diff --git a/tests/session-trailers.test.ts b/tests/session-trailers.test.ts new file mode 100644 index 0000000..1889edf --- /dev/null +++ b/tests/session-trailers.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; +import { TERMINAL_SANITIZE, exitHeader, trailer } from "../src/client.ts"; +import * as net from "node:net"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { encodeScreen } from "../src/protocol.ts"; + +const SAN = TERMINAL_SANITIZE + "\x1b[999;1H"; +const clientUrl = new URL("../dist/client.js", import.meta.url).href; + +const runCleanClose = async (command: "attach" | "peek", plain = false, malformed = false): Promise<{ stdout: string; stderr: string; code: number | null }> => { + const server = net.createServer((socket) => { + socket.once("data", () => socket.end(malformed + ? Buffer.from([5, 0xff, 0xff, 0xff, 0xff]) + : encodeScreen("ready"))); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("expected TCP listener"); + const script = ` + import net from "node:net"; + import { attach, peek } from ${JSON.stringify(clientUrl)}; + const socket = net.createConnection({ host: "127.0.0.1", port: ${address.port} }); + socket.once("connect", () => ${command}({ + name: "gone", socket, peer: "box", follow: true, plain: ${plain}, + row: { name: "gone", status: "running", displayName: "My Worker", + cwd: "/opt/proj", command: "sh" }, + onExit: (code) => process.exit(code), + })); + `; + const child = spawn(process.execPath, ["--input-type=module", "-e", script], { + stdio: ["pipe", "pipe", "pipe"], + }); + child.stdin.end(); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on("data", (chunk) => stdout.push(chunk)); + child.stderr.on("data", (chunk) => stderr.push(chunk)); + const [code] = await once(child, "close") as [number | null]; + server.close(); + return { code, stdout: Buffer.concat(stdout).toString(), stderr: Buffer.concat(stderr).toString() }; +}; +describe("session trailer bytes", () => { + it("names a remote detach, displays the dial-time row and includes the peer", () => { + const actual = trailer({ name: "web-3f2a", peer: "box", row: { + name: "web-3f2a", status: "running", displayName: "My Web Server", + cwd: "/opt/proj", command: "node app.js", tags: { role: "web" }, + } }, "[detached from web-3f2a]", "reattach: pty attach --remote box web-3f2a"); + expect(actual).toBe(SAN + "\r\n[detached from web-3f2a]\r\n" + + " \x1b[1mMy Web Server\x1b[0m \x1b[2m(web-3f2a)\x1b[0m #role=web — /opt/proj — \x1b[2mnode app.js\x1b[0m\r\n" + + " reattach: pty attach --remote box web-3f2a\r\n"); + }); + + it("prints exit duration and the kept local session's restart hint", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-09-24T12:14:00.000Z")); + const metadata = { + command: "node", args: ["app.js"], displayCommand: "node app.js", + cwd: "/opt/proj", createdAt: "2026-09-24T10:00:00.000Z", + displayName: "My Web Server", tags: { keep: "true" }, + }; + const actual = trailer({ name: "web-3f2a", metadata }, + exitHeader("web-3f2a", 1, { name: "web-3f2a", createdAt: metadata.createdAt }), + "restart: pty attach web-3f2a"); + expect(actual).toBe(SAN + "\r\n[web-3f2a exited with code 1 after 2h14m]\r\n" + + " \x1b[1mMy Web Server\x1b[0m \x1b[2m(web-3f2a)\x1b[0m #keep=true — /opt/proj — \x1b[2mnode app.js\x1b[0m\r\n" + + " restart: pty attach web-3f2a\r\n"); + } finally { + vi.useRealTimers(); + } + }); + + it("omits the summary if a remote row was unavailable", () => { + expect(trailer({ name: "gone", peer: "box" }, "[gone session ended]")).toBe( + SAN + "\r\n[gone session ended]\r\n"); + }); + it("prints the close trailer on a clean attach socket EOF without an EXIT", async () => { + const result = await runCleanClose("attach"); + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toBe("\x1b[2J\x1b[Hready" + SAN + + "\r\n[gone session ended]\r\n" + + " \x1b[1mMy Worker\x1b[0m \x1b[2m(gone)\x1b[0m — /opt/proj — \x1b[2msh\x1b[0m\r\n"); + }); + + it("prints the close trailer on a clean follow socket EOF, plain and styled", async () => { + const styled = await runCleanClose("peek"); + expect(styled.code).toBe(0); + expect(styled.stderr).toBe(""); + expect(styled.stdout).toBe("ready" + SAN + "\r\n[gone session ended]\r\n" + + " \x1b[1mMy Worker\x1b[0m \x1b[2m(gone)\x1b[0m — /opt/proj — \x1b[2msh\x1b[0m\r\n"); + const plain = await runCleanClose("peek", true); + expect(plain.code).toBe(0); + expect(plain.stdout).toBe("ready\r\n[gone session ended]\r\n" + + " My Worker (gone) — /opt/proj — sh\r\n"); + }); + + it("does not mistake a client-rejected frame for a daemon-ended session", async () => { + for (const command of ["attach", "peek"] as const) { + const result = await runCleanClose(command, false, true); + expect(result.code).toBe(0); + expect(result.stderr).toContain("pty client: dropping connection — Packet length"); + expect(result.stdout).toBe(""); + } + }); + +});