From 8d420b045cc6bcc0932c07f0e19d381b5099023a Mon Sep 17 00:00:00 2001 From: Matt Keenan Date: Wed, 11 Mar 2026 07:21:17 +0000 Subject: [PATCH 1/7] local: debian: add debian packaging --- debian/changelog | 5 +++++ debian/control | 17 +++++++++++++++++ debian/copyright | 23 +++++++++++++++++++++++ debian/rules | 22 ++++++++++++++++++++++ debian/source/format | 1 + 5 files changed, 68 insertions(+) create mode 100644 debian/changelog create mode 100644 debian/control create mode 100644 debian/copyright create mode 100755 debian/rules create mode 100644 debian/source/format diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..fa33f91 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +atch (0.3b~local) unstable; urgency=low + + * Local build. + + -- Matt Wed, 11 Mar 2026 00:00:00 +0000 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..5835a24 --- /dev/null +++ b/debian/control @@ -0,0 +1,17 @@ +Source: atch +Section: utils +Priority: optional +Maintainer: Matt +Build-Depends: debhelper-compat (= 13), pandoc +Standards-Version: 4.6.2 +Homepage: https://github.com/mattsawyer77/atch + +Package: atch +Architecture: any +Depends: ${shlibs:Depends}, ${misc:Depends} +Description: Attach and detach terminal sessions + atch is a small C utility that lets you attach and detach terminal sessions, + similar to the detach feature of screen/tmux but without terminal emulation, + multiple windows, or other overhead. The raw byte stream flows directly from + the pty to your terminal, so mouse, scroll, colours, and graphics all work + unmodified. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..a87dc0f --- /dev/null +++ b/debian/copyright @@ -0,0 +1,23 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ + +Files: * +Copyright: 2024-2026 Matt +License: GPL-2+ + This program is free software; you can redistribute it and/or modify it + under the terms of the GNU General Public License as published by the + Free Software Foundation; either version 2, or (at your option) any + later version. + . + On Debian systems the full text of the GNU General Public License v2 + can be found in /usr/share/common-licenses/GPL-2. + +Files: debian/* +Copyright: 2026 Matt +License: GPL-2+ + This program is free software; you can redistribute it and/or modify it + under the terms of the GNU General Public License as published by the + Free Software Foundation; either version 2, or (at your option) any + later version. + . + On Debian systems the full text of the GNU General Public License v2 + can be found in /usr/share/common-licenses/GPL-2. diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..ed478c4 --- /dev/null +++ b/debian/rules @@ -0,0 +1,22 @@ +#!/usr/bin/make -f + +export DEB_BUILD_MAINT_OPTIONS = hardening=+all + +DEB_VERSION = $(shell dpkg-parsechangelog -S Version | sed 's/~.*//') + +%: + dh $@ + +override_dh_auto_build: + $(MAKE) attach.o master.o atch.o VERSION=$(DEB_VERSION) + $(CC) -o atch $(LDFLAGS) attach.o master.o atch.o -lutil + $(MAKE) man + +override_dh_auto_install: + install -D -m 755 atch debian/atch/usr/bin/atch + install -D -m 644 atch.1 debian/atch/usr/share/man/man1/atch.1 + +override_dh_auto_test: + +override_dh_auto_clean: + $(MAKE) clean diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..89ae9db --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (native) From 52c9bfec874bbd13f7643308e8fbad83f83e5fd0 Mon Sep 17 00:00:00 2001 From: Matt Keenan Date: Sun, 5 Apr 2026 13:20:45 +0000 Subject: [PATCH 2/7] Show cursor after scrollback replay on attach Replayed log/ring data may contain cursor-hide sequences (e.g. from React terminal apps). Send DECTCEM show-cursor after replay so the cursor is always visible before going live. Co-Authored-By: Claude Opus 4.6 (1M context) --- attach.c | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/attach.c b/attach.c index 5e8453e..b09edfd 100644 --- a/attach.c +++ b/attach.c @@ -374,8 +374,11 @@ int attach_main(int noerror) ** the prompt is already visible and correctly placed. */ if (clear_method == CLEAR_MOVE && !no_ansiterm) { write_buf_or_fail(1, "\033c", 2); - } else if (!quiet && !skip_ring) { - write_buf_or_fail(1, "\r\n", 2); + } else { + if (!no_ansiterm) + write_buf_or_fail(1, "\033[?25h", 6); + if (!quiet && !skip_ring) + write_buf_or_fail(1, "\r\n", 2); } /* Tell the master that we want to attach. From 68b09339972b711e014e4c76dc1cc867ef545c16 Mon Sep 17 00:00:00 2001 From: Matt Keenan Date: Sun, 5 Apr 2026 13:26:17 +0000 Subject: [PATCH 3/7] Ignore debian build artefacts Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 420cca7..abc4ea3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,9 @@ /atch build/ release/ +debian/.debhelper/ +debian/*.debhelper.log +debian/*.substvars +debian/debhelper-build-stamp +debian/files +debian/atch/ From eaaf470d24982bf6f73b1531ed5d6d7ead5694f7 Mon Sep 17 00:00:00 2001 From: Matt Keenan Date: Sun, 5 Apr 2026 15:47:31 +0000 Subject: [PATCH 4/7] Use terminfo lookups instead of hardcoded VT100 escape sequences Replace the four hardcoded escape sequences with terminfo capability lookups (setupterm/tigetstr/tparm). Each site falls back to the original VT100 string when terminfo is unavailable. Rename cur_term to raw_term to avoid collision with . The makefile auto-detects -ltinfo (Linux) vs -lcurses (macOS). Co-Authored-By: Claude Opus 4.6 (1M context) --- atch.c | 3 ++ atch.h | 1 + attach.c | 78 +++++++++++++++++++++++++++++++++++++----------- build.dockerfile | 2 +- debian/control | 2 +- debian/rules | 2 +- makefile | 3 +- 7 files changed, 69 insertions(+), 22 deletions(-) diff --git a/atch.c b/atch.c index 9a516de..843ef6d 100644 --- a/atch.c +++ b/atch.c @@ -307,7 +307,10 @@ static void save_term(void) if (tcgetattr(0, &orig_term) < 0) { memset(&orig_term, 0, sizeof(struct termios)); dont_have_tty = 1; + return; } + if (!no_ansiterm) + init_terminfo(); } /* Print error and return 1 if no tty is available. */ diff --git a/atch.h b/atch.h index f747da0..7d51c0b 100644 --- a/atch.h +++ b/atch.h @@ -144,6 +144,7 @@ int list_main(int show_all); int kill_main(int force); char const * clear_csi_data(void); +void init_terminfo(void); #ifdef sun #define BROKEN_MASTER diff --git a/attach.c b/attach.c index b09edfd..7febd56 100644 --- a/attach.c +++ b/attach.c @@ -1,4 +1,6 @@ #include "atch.h" +#include +#include #ifndef VDISABLE #ifdef _POSIX_VDISABLE @@ -12,18 +14,46 @@ ** The current terminal settings. After coming back from a suspend, we ** restore this. */ -static struct termios cur_term; +static struct termios raw_term; /* 1 if the window size changed */ static int win_changed; /* Socket creation time, used to compute session age in messages. */ time_t session_start; +/* Terminfo capability strings, NULL when unavailable or -t is set. */ +static const char *ti_sgr0; /* reset attributes */ +static const char *ti_cnorm; /* show cursor (normal) */ +static const char *ti_rs2; /* full reset */ +static const char *ti_cup; /* cursor_address (parametric) */ + +static const char *ti_get(const char *cap) +{ + const char *s = tigetstr(cap); + return (s && s != (char *)-1) ? s : NULL; +} + +void init_terminfo(void) +{ + int err; + if (setupterm(NULL, 1, &err) != OK) + return; + ti_sgr0 = ti_get("sgr0"); + ti_cnorm = ti_get("cnorm"); + ti_rs2 = ti_get("rs2"); + ti_cup = ti_get("cup"); +} + char const *clear_csi_data(void) { if (no_ansiterm || clear_method == CLEAR_NONE || (clear_method == CLEAR_UNSPEC && dont_have_tty)) return "\r\n"; /* CLEAR_MOVE, or CLEAR_UNSPEC with a real tty: move to bottom */ + if (ti_cup) { + static char buf[64]; + snprintf(buf, sizeof(buf), "%s\r\n", tparm(ti_cup, 999, 0)); + return buf; + } return "\033[999H\r\n"; } @@ -87,7 +117,12 @@ static void restore_term(void) { tcsetattr(0, TCSADRAIN, &orig_term); if (!no_ansiterm) { - printf("\033[0m\033[?25h"); + if (ti_sgr0) + fputs(ti_sgr0, stdout); + if (ti_cnorm) + fputs(ti_cnorm, stdout); + if (!ti_sgr0 && !ti_cnorm) + printf("\033[0m\033[?25h"); } fflush(stdout); if (no_ansiterm) @@ -176,7 +211,7 @@ static RETSIGTYPE win_change(ATTRIBUTE_UNUSED int sig) static void process_kbd(int s, struct packet *pkt) { /* Suspend? */ - if (!no_suspend && (pkt->u.buf[0] == cur_term.c_cc[VSUSP])) { + if (!no_suspend && (pkt->u.buf[0] == raw_term.c_cc[VSUSP])) { /* Tell the master that we are suspending. */ pkt->type = MSG_DETACH; write_packet_or_fail(s, pkt); @@ -185,7 +220,7 @@ static void process_kbd(int s, struct packet *pkt) tcsetattr(0, TCSADRAIN, &orig_term); printf("%s", clear_csi_data()); kill(getpid(), SIGTSTP); - tcsetattr(0, TCSADRAIN, &cur_term); + tcsetattr(0, TCSADRAIN, &raw_term); /* Tell the master that we are returning. */ pkt->type = MSG_ATTACH; @@ -339,7 +374,7 @@ int attach_main(int noerror) /* The current terminal settings are equal to the original terminal ** settings at this point. */ - cur_term = orig_term; + raw_term = orig_term; /* Set a trap to restore the terminal when we die. */ atexit(restore_term); @@ -354,17 +389,17 @@ int attach_main(int noerror) signal(SIGWINCH, win_change); /* Set raw mode. */ - cur_term.c_iflag &= + raw_term.c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL); - cur_term.c_iflag &= ~(IXON | IXOFF); - cur_term.c_oflag &= ~(OPOST); - cur_term.c_lflag &= ~(ECHO | ECHONL | ICANON | ISIG | IEXTEN); - cur_term.c_cflag &= ~(CSIZE | PARENB); - cur_term.c_cflag |= CS8; - cur_term.c_cc[VLNEXT] = VDISABLE; - cur_term.c_cc[VMIN] = 1; - cur_term.c_cc[VTIME] = 0; - tcsetattr(0, TCSADRAIN, &cur_term); + raw_term.c_iflag &= ~(IXON | IXOFF); + raw_term.c_oflag &= ~(OPOST); + raw_term.c_lflag &= ~(ECHO | ECHONL | ICANON | ISIG | IEXTEN); + raw_term.c_cflag &= ~(CSIZE | PARENB); + raw_term.c_cflag |= CS8; + raw_term.c_cc[VLNEXT] = VDISABLE; + raw_term.c_cc[VMIN] = 1; + raw_term.c_cc[VTIME] = 0; + tcsetattr(0, TCSADRAIN, &raw_term); /* Clear the screen on attach. Only do a full reset when explicitly ** requested (CLEAR_MOVE); default/unspec just emits a blank line so @@ -373,10 +408,17 @@ int attach_main(int noerror) ** the separator: the log ends at the exact pty cursor position, so ** the prompt is already visible and correctly placed. */ if (clear_method == CLEAR_MOVE && !no_ansiterm) { - write_buf_or_fail(1, "\033c", 2); + if (ti_rs2) + write_buf_or_fail(1, ti_rs2, strlen(ti_rs2)); + else + write_buf_or_fail(1, "\033c", 2); } else { - if (!no_ansiterm) - write_buf_or_fail(1, "\033[?25h", 6); + if (!no_ansiterm) { + if (ti_cnorm) + write_buf_or_fail(1, ti_cnorm, strlen(ti_cnorm)); + else + write_buf_or_fail(1, "\033[?25h", 6); + } if (!quiet && !skip_ring) write_buf_or_fail(1, "\r\n", 2); } diff --git a/build.dockerfile b/build.dockerfile index 6c98127..42540f8 100644 --- a/build.dockerfile +++ b/build.dockerfile @@ -1,3 +1,3 @@ FROM alpine:latest -RUN apk add --no-cache gcc musl-dev make +RUN apk add --no-cache gcc musl-dev make ncurses-dev ncurses-static diff --git a/debian/control b/debian/control index 5835a24..9fd0c2a 100644 --- a/debian/control +++ b/debian/control @@ -2,7 +2,7 @@ Source: atch Section: utils Priority: optional Maintainer: Matt -Build-Depends: debhelper-compat (= 13), pandoc +Build-Depends: debhelper-compat (= 13), pandoc, libtinfo-dev Standards-Version: 4.6.2 Homepage: https://github.com/mattsawyer77/atch diff --git a/debian/rules b/debian/rules index ed478c4..4f0ee67 100755 --- a/debian/rules +++ b/debian/rules @@ -9,7 +9,7 @@ DEB_VERSION = $(shell dpkg-parsechangelog -S Version | sed 's/~.*//') override_dh_auto_build: $(MAKE) attach.o master.o atch.o VERSION=$(DEB_VERSION) - $(CC) -o atch $(LDFLAGS) attach.o master.o atch.o -lutil + $(CC) -o atch $(LDFLAGS) attach.o master.o atch.o -lutil -ltinfo $(MAKE) man override_dh_auto_install: diff --git a/makefile b/makefile index eb43a61..74243c0 100644 --- a/makefile +++ b/makefile @@ -2,7 +2,8 @@ VERSION ?= dev CC = gcc CFLAGS = -g -O2 -W -Wall -I. -DPACKAGE_VERSION=\"$(VERSION)\" LDFLAGS = -LIBS = -lutil +TERMINFO_LIB := $(shell echo 'int main(void){return 0;}' | $(CC) -static -ltinfo -o /dev/null -x c - 2>/dev/null && echo -ltinfo || echo -lcurses) +LIBS = -lutil $(TERMINFO_LIB) UNAME_S := $(shell uname -s) ifeq ($(UNAME_S),Darwin) From ad9b9b74fc802aff254e9e622857cd680e6bdf94 Mon Sep 17 00:00:00 2001 From: Matt Keenan Date: Sun, 12 Apr 2026 13:20:17 +0000 Subject: [PATCH 5/7] Add DEC private mode state tracker for terminal state preservation Track ESC[?Nh/l sequences (cursor visibility, alt screen, bracketed paste, etc.) across log rotations so re-attaching restores terminal state that would otherwise be lost when the log is trimmed. - tstate.c: generic scanner, preamble writer, layered config (compiled-in defaults, global ~/.config/atch/tstate.conf, per-session .tstate), and query/mutate API - Scan only during rotate_log(), not on the pty hot path - Replay .tpreamble before session log on attach - tstate subcommand for inspecting and managing mode state - push_bytes() for injecting escape sequences into live sessions Co-Authored-By: Claude Opus 4.6 (1M context) --- atch.c | 228 +++++++++++++++++-- atch.h | 22 ++ attach.c | 28 +++ debian/changelog | 8 + debian/rules | 4 +- makefile | 5 +- master.c | 23 ++ tstate.c | 565 +++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 863 insertions(+), 20 deletions(-) create mode 100644 tstate.c diff --git a/atch.c b/atch.c index 843ef6d..ea81f05 100644 --- a/atch.c +++ b/atch.c @@ -511,28 +511,31 @@ static int cmd_kill(int argc, char **argv) return kill_main(force); } +/* Helper: consume session name from args or fall back to ATCH_SESSION env. */ +static int consume_session_or_env(int *argc, char ***argv) +{ + if (*argc > 0) + return consume_session(argc, argv); + + const char *chain = getenv(SESSION_ENVVAR); + const char *last; + if (!chain || !*chain) { + printf("%s: No session was specified.\n", progname); + return 1; + } + last = strrchr(chain, ':'); + sockname = (char *)(last ? last + 1 : chain); + return 0; +} + /* atch clear — truncate the on-disk session log */ static int cmd_clear(int argc, char **argv) { char log_path[600]; int fd; - if (argc > 0) { - if (consume_session(&argc, &argv)) - return 1; - } else { - const char *chain = getenv(SESSION_ENVVAR); - const char *last; - - if (!chain || !*chain) { - printf("%s: No session was specified.\n", progname); - printf("Try '%s --help' for more information.\n", - progname); - return 1; - } - last = strrchr(chain, ':'); - sockname = (char *)(last ? last + 1 : chain); - } + if (consume_session_or_env(&argc, &argv)) + return 1; if (argc > 0) { printf("%s: Invalid number of arguments.\n", progname); printf("Try '%s --help' for more information.\n", progname); @@ -542,6 +545,7 @@ static int cmd_clear(int argc, char **argv) fd = open(log_path, O_WRONLY | O_TRUNC); if (fd >= 0) { close(fd); + tstate_cleanup(sockname); if (!quiet) printf("%s: session '%s' log cleared\n", progname, session_shortname()); @@ -692,6 +696,196 @@ static int cmd_rm(int argc, char **argv) return rm_main(0); } +static int cmd_tstate(int argc, char **argv) +{ + const char *subcmd; + int preamble_only = 0, push_only = 0, global = 0; + + /* Parse flags before subcmd */ + while (argc > 0 && argv[0][0] == '-' && argv[0][1] == '-') { + if (strcmp(argv[0], "--preamble-only") == 0) + preamble_only = 1; + else if (strcmp(argv[0], "--push-only") == 0) + push_only = 1; + else if (strcmp(argv[0], "--global") == 0) + global = 1; + else + break; + ++argv; --argc; + } + + /* Determine subcommand (default: show) */ + subcmd = (argc > 0) ? argv[0] : "show"; + if (argc > 0 && strcmp(subcmd, "show") != 0 && + strcmp(subcmd, "set") != 0 && strcmp(subcmd, "toggle") != 0 && + strcmp(subcmd, "reset") != 0 && strcmp(subcmd, "track") != 0 && + strcmp(subcmd, "notrack") != 0) { + subcmd = "show"; + } else if (argc > 0) { + ++argv; --argc; + } + + if (strcmp(subcmd, "show") == 0) { + if (consume_session_or_env(&argc, &argv)) + return 1; + tstate_show(sockname); + return 0; + } + + if (strcmp(subcmd, "reset") == 0) { + if (consume_session_or_env(&argc, &argv)) + return 1; + if (!push_only) + tstate_cleanup(sockname); + if (!preamble_only) { + unsigned char reset_buf[256]; + int rlen; + tstate_load_global_config(); + tstate_load_config(sockname); + rlen = tstate_reset_all(reset_buf, sizeof(reset_buf)); + if (rlen > 0) + push_bytes(reset_buf, (size_t)rlen); + } + if (!quiet) + printf("%s: terminal state reset for '%s'\n", + progname, session_shortname()); + return 0; + } + + if (strcmp(subcmd, "set") == 0 || strcmp(subcmd, "toggle") == 0) { + const char *mode_arg; + int mode, new_state = -1; + unsigned char seq_buf[32]; + int seq_len; + + if (argc < 1) { + printf("%s: tstate %s requires a mode\n", + progname, subcmd); + return 1; + } + mode_arg = argv[0]; + ++argv; --argc; + + mode = tstate_find_mode(mode_arg); + if (mode < 0) { + printf("%s: unknown mode '%s'\n", + progname, mode_arg); + return 1; + } + + if (strcmp(subcmd, "set") == 0) { + if (argc < 1) { + printf("%s: tstate set requires a value " + "(h/l/on/off or tiname)\n", progname); + return 1; + } + new_state = tstate_resolve_state(mode, argv[0]); + if (new_state < 0) { + printf("%s: unknown value '%s'\n", + progname, argv[0]); + return 1; + } + ++argv; --argc; + } + + if (consume_session_or_env(&argc, &argv)) + return 1; + + /* Load current state */ + tstate_load_global_config(); + tstate_load_config(sockname); + + if (strcmp(subcmd, "toggle") == 0) { + tstate_toggle_mode(mode); + } else { + tstate_set_mode(mode, new_state); + } + + if (!push_only) + tstate_write_preamble(sockname); + + if (!preamble_only) { + int push_state = (strcmp(subcmd, "toggle") == 0) + ? tstate_get_mode(mode) : new_state; + if (push_state >= 0) { + seq_len = tstate_mode_seq(mode, push_state, + seq_buf, sizeof(seq_buf)); + if (seq_len > 0) + push_bytes(seq_buf, (size_t)seq_len); + } + } + return 0; + } + + if (strcmp(subcmd, "track") == 0 || strcmp(subcmd, "notrack") == 0) { + const char *mode_arg; + int mode; + int tracked = (strcmp(subcmd, "track") == 0) ? 1 : 0; + const char *name = NULL; + + if (argc < 1) { + printf("%s: tstate %s requires a mode\n", + progname, subcmd); + return 1; + } + mode_arg = argv[0]; + ++argv; --argc; + + mode = tstate_find_mode(mode_arg); + if (mode < 0) { + printf("%s: unknown mode '%s'\n", + progname, mode_arg); + return 1; + } + + /* Optional name for new modes */ + if (tracked && argc > 0 && argv[0][0] != '-') { + /* Check if this arg looks like a name (not a session path) */ + if (!strchr(argv[0], '/')) { + name = argv[0]; + ++argv; --argc; + } + } + + if (!global) { + if (consume_session_or_env(&argc, &argv)) + return 1; + } + + if (global) + tstate_load_global_config(); + else { + tstate_load_global_config(); + tstate_load_config(sockname); + } + + if (tracked) + tstate_track_mode(mode, name); + else + tstate_notrack_mode(mode); + + if (global) + tstate_save_global_config(); + else + tstate_save_config(sockname); + + if (!quiet) { + if (global) + printf("%s: %s mode %d globally\n", progname, + tracked ? "tracking" : "not tracking", + mode); + else + printf("%s: %s mode %d for '%s'\n", progname, + tracked ? "tracking" : "not tracking", + mode, session_shortname()); + } + return 0; + } + + printf("%s: unknown tstate command '%s'\n", progname, subcmd); + return 1; +} + /* Default: atch [cmd...] — attach-or-create */ static int cmd_open(char *session, int argc, char **argv) { @@ -940,6 +1134,8 @@ int main(int argc, char **argv) return cmd_tail(argc, argv); if (is_cmd(cmd, "rm", NULL, NULL)) return cmd_rm(argc, argv); + if (is_cmd(cmd, "tstate", "ts", NULL)) + return cmd_tstate(argc, argv); /* Smart default: treat first arg as session name → attach-or-create */ return cmd_open((char *)cmd, argc, argv); diff --git a/atch.h b/atch.h index 7d51c0b..8ed9320 100644 --- a/atch.h +++ b/atch.h @@ -140,12 +140,34 @@ int attach_main(int noerror); int master_main(char **argv, int waitattach, int dontfork); int push_main(void); int rm_main(int all); +int push_bytes(const unsigned char *data, size_t datalen); int list_main(int show_all); int kill_main(int force); char const * clear_csi_data(void); void init_terminfo(void); +/* Terminal state tracking (tstate.c) */ +void tstate_scan(const unsigned char *buf, size_t len); +int tstate_is_dirty(void); +void tstate_write_preamble(const char *base); +void tstate_cleanup(const char *base); +int tstate_replay_preamble(const char *base); +void tstate_load_global_config(void); +void tstate_load_config(const char *base); +void tstate_save_config(const char *base); +void tstate_save_global_config(void); +void tstate_show(const char *base); +int tstate_find_mode(const char *name_or_number); +int tstate_resolve_state(int mode, const char *val); +int tstate_set_mode(int mode, int state); +int tstate_toggle_mode(int mode); +int tstate_track_mode(int mode, const char *name); +int tstate_notrack_mode(int mode); +int tstate_reset_all(unsigned char *buf, size_t buflen); +int tstate_mode_seq(int mode, int state, unsigned char *buf, size_t buflen); +int tstate_get_mode(int mode); /* returns state: -1=unseen, 0=l, 1=h */ + #ifdef sun #define BROKEN_MASTER #endif diff --git a/attach.c b/attach.c index 7febd56..de21188 100644 --- a/attach.c +++ b/attach.c @@ -266,6 +266,9 @@ int replay_session_log(int saved_errno) int logfd; const char *name; + if (!no_ansiterm) + tstate_replay_preamble(sockname); + snprintf(log_path, sizeof(log_path), "%s.log", sockname); logfd = open(log_path, O_RDONLY); if (logfd < 0) @@ -510,6 +513,31 @@ int attach_main(int noerror) return 0; } +int push_bytes(const unsigned char *data, size_t datalen) +{ + struct packet pkt; + int s; + + s = connect_socket(sockname); + if (s < 0) + return -1; + signal(SIGPIPE, SIG_IGN); + pkt.type = MSG_PUSH; + while (datalen > 0) { + size_t chunk = datalen > sizeof(pkt.u.buf) ? sizeof(pkt.u.buf) : datalen; + memcpy(pkt.u.buf, data, chunk); + pkt.len = chunk; + if (write(s, &pkt, sizeof(struct packet)) != sizeof(struct packet)) { + close(s); + return -1; + } + data += chunk; + datalen -= chunk; + } + close(s); + return 0; +} + int push_main() { struct packet pkt; diff --git a/debian/changelog b/debian/changelog index fa33f91..4a9137b 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,11 @@ +atch (0.3b~local2) unstable; urgency=low + + * Add DEC private mode state tracker for terminal state preservation. + * Add tstate subcommand for inspecting and managing mode state. + * Scan terminal state during log rotation only (not on hot path). + + -- Matt Keenan Sat, 12 Apr 2026 00:00:00 +0000 + atch (0.3b~local) unstable; urgency=low * Local build. diff --git a/debian/rules b/debian/rules index 4f0ee67..5a8c4d3 100755 --- a/debian/rules +++ b/debian/rules @@ -8,8 +8,8 @@ DEB_VERSION = $(shell dpkg-parsechangelog -S Version | sed 's/~.*//') dh $@ override_dh_auto_build: - $(MAKE) attach.o master.o atch.o VERSION=$(DEB_VERSION) - $(CC) -o atch $(LDFLAGS) attach.o master.o atch.o -lutil -ltinfo + $(MAKE) attach.o master.o atch.o tstate.o VERSION=$(DEB_VERSION) + $(CC) -o atch $(LDFLAGS) attach.o master.o atch.o tstate.o -lutil -ltinfo $(MAKE) man override_dh_auto_install: diff --git a/makefile b/makefile index 74243c0..825aeb7 100644 --- a/makefile +++ b/makefile @@ -12,8 +12,8 @@ else STATIC_FLAG = -static endif -OBJ = attach.o master.o atch.o -SRC = attach.c master.c atch.c +OBJ = attach.o master.o atch.o tstate.o +SRC = attach.c master.c atch.c tstate.c IMAGE = atch-builder BUILDDIR ?= . @@ -47,6 +47,7 @@ fmt-all: attach.o: ./attach.c ./atch.h config.h master.o: ./master.c ./atch.h config.h atch.o: ./atch.c ./atch.h config.h +tstate.o: ./tstate.c ./atch.h .PHONY: build-image build-image: diff --git a/master.c b/master.c index 16f29e3..c492fb8 100644 --- a/master.c +++ b/master.c @@ -60,6 +60,26 @@ static void rotate_log(void) size = lseek(log_fd, 0, SEEK_END); if (size > (off_t) log_max_size) { + off_t discard = size - (off_t) log_max_size; + unsigned char scan_buf[BUFSIZE]; + off_t pos; + + /* Scan the discarded prefix for terminal state sequences. */ + lseek(log_fd, 0, SEEK_SET); + for (pos = 0; pos < discard; ) { + size_t chunk = sizeof(scan_buf); + if ((off_t) chunk > discard - pos) + chunk = (size_t)(discard - pos); + n = read(log_fd, scan_buf, chunk); + if (n <= 0) + break; + tstate_scan(scan_buf, (size_t)n); + pos += n; + } + if (tstate_is_dirty()) + tstate_write_preamble(sockname); + + /* Keep the tail. */ buf = malloc(log_max_size); if (buf) { lseek(log_fd, size - (off_t) log_max_size, SEEK_SET); @@ -108,6 +128,7 @@ static void cleanup_session(void) close(log_fd); log_fd = -1; } + tstate_cleanup(sockname); unlink(sockname); } @@ -730,6 +751,8 @@ int master_main(char **argv, int waitattach, int dontfork) snprintf(log_path, sizeof(log_path), "%s.log", sockname); log_fd = open_log(log_path); } + tstate_load_global_config(); + tstate_load_config(sockname); master_start_time = time(NULL); #if defined(F_SETFD) && defined(FD_CLOEXEC) fcntl(s, F_SETFD, FD_CLOEXEC); diff --git a/tstate.c b/tstate.c new file mode 100644 index 0000000..fe44535 --- /dev/null +++ b/tstate.c @@ -0,0 +1,565 @@ +#include "atch.h" + +#include + +/* +** Generic DEC private mode state tracker. +** +** Scans pty output for ESC[?Nh and ESC[?Nl sequences, records the last-seen +** state per mode number, and persists a preamble file of non-default states. +** On re-attach the preamble is replayed before the session log, restoring +** terminal state that may have been lost to log rotation. +** +** Three configuration layers (each overrides the previous): +** 1. Compiled-in defaults (below) +** 2. Global config: ~/.config/atch/tstate.conf +** 3. Per-session config: sockname.tstate +*/ + +#define MAX_MODES 64 + +struct tstate_mode { + int mode; /* DEC private mode number */ + char name[48]; /* human-readable name */ + const char *tiname_set; /* terminfo name for 'h', or NULL */ + const char *tiname_reset; /* terminfo name for 'l', or NULL */ + int default_on; /* 1 if 'h' is normal, 0 if 'l' is normal */ + int tracked; /* 1 = track, 0 = skip */ + int state; /* -1 = unseen, 0 = 'l', 1 = 'h' */ +}; + +static struct tstate_mode modes[MAX_MODES]; +static int nmodes; +static int dirty; + +/* Compiled-in defaults. */ +static const struct { + int mode; + const char *name; + const char *tiname_set; + const char *tiname_reset; + int default_on; +} builtin_modes[] = { + { 25, "cursor_visibility", "civis", "cnorm", 1 }, + { 1049, "alt_screen", "smcup", "rmcup", 0 }, + { 2004, "bracketed_paste", NULL, NULL, 0 }, + { 1004, "focus_events", NULL, NULL, 0 }, + { 1000, "mouse_tracking", NULL, NULL, 0 }, + { 1006, "mouse_sgr", NULL, NULL, 0 }, +}; + +#define NBUILTINS (sizeof(builtin_modes) / sizeof(builtin_modes[0])) + +static void init_defaults(void) +{ + size_t i; + if (nmodes > 0) + return; + for (i = 0; i < NBUILTINS && nmodes < MAX_MODES; i++) { + modes[nmodes].mode = builtin_modes[i].mode; + snprintf(modes[nmodes].name, sizeof(modes[nmodes].name), + "%s", builtin_modes[i].name); + modes[nmodes].tiname_set = builtin_modes[i].tiname_set; + modes[nmodes].tiname_reset = builtin_modes[i].tiname_reset; + modes[nmodes].default_on = builtin_modes[i].default_on; + modes[nmodes].tracked = 1; + modes[nmodes].state = -1; + nmodes++; + } +} + +static struct tstate_mode *find_mode(int mode) +{ + int i; + for (i = 0; i < nmodes; i++) + if (modes[i].mode == mode) + return &modes[i]; + return NULL; +} + +static struct tstate_mode *ensure_mode(int mode, const char *name) +{ + struct tstate_mode *m = find_mode(mode); + if (m) + return m; + if (nmodes >= MAX_MODES) + return NULL; + m = &modes[nmodes++]; + m->mode = mode; + if (name) + snprintf(m->name, sizeof(m->name), "%s", name); + else + snprintf(m->name, sizeof(m->name), "mode_%d", mode); + m->tiname_set = NULL; + m->tiname_reset = NULL; + m->default_on = 0; + m->tracked = 1; + m->state = -1; + return m; +} + +/* ------------------------------------------------------------------ */ +/* Scanner */ +/* ------------------------------------------------------------------ */ + +void tstate_scan(const unsigned char *buf, size_t len) +{ + size_t i; + + init_defaults(); + + for (i = 0; i < len; i++) { + struct tstate_mode *m; + int mode_num, new_state; + size_t j; + + if (buf[i] != 0x1b) + continue; + /* Need at least ESC [ ? = 5 bytes */ + if (i + 4 >= len) + continue; + if (buf[i + 1] != '[' || buf[i + 2] != '?') + continue; + + /* Parse decimal mode number */ + j = i + 3; + mode_num = 0; + while (j < len && buf[j] >= '0' && buf[j] <= '9') { + mode_num = mode_num * 10 + (buf[j] - '0'); + j++; + } + if (j == i + 3 || j >= len) + continue; + if (buf[j] != 'h' && buf[j] != 'l') + continue; + new_state = (buf[j] == 'h') ? 1 : 0; + + m = find_mode(mode_num); + if (m && m->tracked && m->state != new_state) { + m->state = new_state; + dirty = 1; + } + i = j; /* skip past the sequence */ + } +} + +int tstate_is_dirty(void) +{ + return dirty; +} + +/* ------------------------------------------------------------------ */ +/* Preamble */ +/* ------------------------------------------------------------------ */ + +void tstate_write_preamble(const char *base) +{ + char path[PATH_MAX], tmp[PATH_MAX]; + unsigned char buf[MAX_MODES * 16]; + size_t pos = 0; + int i, any = 0; + + init_defaults(); + + snprintf(path, sizeof(path), "%s.tpreamble", base); + snprintf(tmp, sizeof(tmp), "%s.tpreamble.tmp", base); + + for (i = 0; i < nmodes; i++) { + int n; + if (!modes[i].tracked || modes[i].state < 0) + continue; + /* Skip if state matches the default */ + if (modes[i].state == modes[i].default_on) + continue; + n = snprintf((char *)buf + pos, sizeof(buf) - pos, + "\033[?%d%c", modes[i].mode, + modes[i].state ? 'h' : 'l'); + if (n < 0 || pos + (size_t)n >= sizeof(buf)) + break; + pos += (size_t)n; + any = 1; + } + + if (!any) { + unlink(path); + unlink(tmp); + dirty = 0; + return; + } + + { + int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd < 0) + return; + write(fd, buf, pos); + close(fd); + rename(tmp, path); + } + dirty = 0; +} + +void tstate_cleanup(const char *base) +{ + char path[PATH_MAX]; + + snprintf(path, sizeof(path), "%s.tpreamble", base); + unlink(path); + snprintf(path, sizeof(path), "%s.tpreamble.tmp", base); + unlink(path); +} + +int tstate_replay_preamble(const char *base) +{ + char path[PATH_MAX]; + unsigned char buf[512]; + ssize_t n; + int fd; + + snprintf(path, sizeof(path), "%s.tpreamble", base); + fd = open(path, O_RDONLY); + if (fd < 0) + return 0; + while ((n = read(fd, buf, sizeof(buf))) > 0) + write(1, buf, (size_t)n); + close(fd); + return 1; +} + +/* ------------------------------------------------------------------ */ +/* Config loading/saving */ +/* ------------------------------------------------------------------ */ + +static void load_config_file(const char *path) +{ + char line[128]; + FILE *f; + + f = fopen(path, "r"); + if (!f) + return; + while (fgets(line, sizeof(line), f)) { + char *p = line; + int tracked, mode_num; + char name_buf[48]; + struct tstate_mode *m; + + /* Skip comments and blank lines */ + while (*p == ' ' || *p == '\t') p++; + if (*p == '#' || *p == '\n' || *p == '\0') + continue; + + if (*p != '+' && *p != '-') + continue; + tracked = (*p == '+') ? 1 : 0; + p++; + + /* Parse mode number */ + mode_num = 0; + if (*p < '0' || *p > '9') + continue; + while (*p >= '0' && *p <= '9') { + mode_num = mode_num * 10 + (*p - '0'); + p++; + } + + /* Optional name */ + while (*p == ' ' || *p == '\t') p++; + name_buf[0] = '\0'; + if (*p && *p != '\n') { + size_t nlen; + char *end = p; + while (*end && *end != '\n') end++; + nlen = (size_t)(end - p); + if (nlen >= sizeof(name_buf)) + nlen = sizeof(name_buf) - 1; + memcpy(name_buf, p, nlen); + name_buf[nlen] = '\0'; + } + + m = ensure_mode(mode_num, + name_buf[0] ? name_buf : NULL); + if (m) + m->tracked = tracked; + } + fclose(f); +} + +static void get_global_config_path(char *buf, size_t size) +{ + const char *xdg = getenv("XDG_CONFIG_HOME"); + const char *home = getenv("HOME"); + + if (xdg && *xdg) + snprintf(buf, size, "%s/atch/tstate.conf", xdg); + else if (home && *home) + snprintf(buf, size, "%s/.config/atch/tstate.conf", home); + else + snprintf(buf, size, "/tmp/.atch-tstate.conf"); +} + +void tstate_load_global_config(void) +{ + char path[PATH_MAX]; + + init_defaults(); + get_global_config_path(path, sizeof(path)); + load_config_file(path); +} + +void tstate_load_config(const char *base) +{ + char path[PATH_MAX]; + + init_defaults(); + snprintf(path, sizeof(path), "%s.tstate", base); + load_config_file(path); +} + +static void save_config_to(const char *path) +{ + char tmp[PATH_MAX]; + FILE *f; + int i; + + snprintf(tmp, sizeof(tmp), "%s.tmp", path); + f = fopen(tmp, "w"); + if (!f) + return; + for (i = 0; i < nmodes; i++) + fprintf(f, "%c%d %s\n", modes[i].tracked ? '+' : '-', + modes[i].mode, modes[i].name); + fclose(f); + rename(tmp, path); +} + +void tstate_save_config(const char *base) +{ + char path[PATH_MAX]; + + snprintf(path, sizeof(path), "%s.tstate", base); + save_config_to(path); +} + +void tstate_save_global_config(void) +{ + char path[PATH_MAX], dir[PATH_MAX]; + char *slash; + + get_global_config_path(path, sizeof(path)); + + /* Ensure parent directory exists */ + snprintf(dir, sizeof(dir), "%s", path); + slash = strrchr(dir, '/'); + if (slash) { + *slash = '\0'; + mkdir(dir, 0700); + } + + save_config_to(path); +} + +/* ------------------------------------------------------------------ */ +/* Public query/mutate API */ +/* ------------------------------------------------------------------ */ + +int tstate_find_mode(const char *name_or_number) +{ + int i; + + init_defaults(); + + /* Try as a number first — return the number even if not yet tracked */ + if (name_or_number[0] >= '0' && name_or_number[0] <= '9') + return atoi(name_or_number); + /* Try as a name */ + for (i = 0; i < nmodes; i++) + if (strcmp(name_or_number, modes[i].name) == 0) + return modes[i].mode; + /* Try as a tiname */ + for (i = 0; i < nmodes; i++) { + if (modes[i].tiname_set && + strcmp(name_or_number, modes[i].tiname_set) == 0) + return modes[i].mode; + if (modes[i].tiname_reset && + strcmp(name_or_number, modes[i].tiname_reset) == 0) + return modes[i].mode; + } + return -1; +} + +/* Resolve a state value string to 0 or 1. Accepts "h", "l", "on", "off", +** or tiname aliases like "civis", "cnorm", "smcup", "rmcup". */ +int tstate_resolve_state(int mode, const char *val) +{ + struct tstate_mode *m; + int i; + + if (strcmp(val, "h") == 0 || strcmp(val, "on") == 0 || + strcmp(val, "1") == 0) + return 1; + if (strcmp(val, "l") == 0 || strcmp(val, "off") == 0 || + strcmp(val, "0") == 0) + return 0; + + /* Check tiname match */ + m = find_mode(mode); + if (m) { + if (m->tiname_set && strcmp(val, m->tiname_set) == 0) + return 1; + if (m->tiname_reset && strcmp(val, m->tiname_reset) == 0) + return 0; + } + + /* Check all modes for tiname (mode may be specified by number) */ + for (i = 0; i < nmodes; i++) { + if (modes[i].tiname_set && + strcmp(val, modes[i].tiname_set) == 0) + return 1; + if (modes[i].tiname_reset && + strcmp(val, modes[i].tiname_reset) == 0) + return 0; + } + return -1; +} + +int tstate_set_mode(int mode, int state) +{ + struct tstate_mode *m; + + init_defaults(); + m = find_mode(mode); + if (!m) + return -1; + if (m->state != state) { + m->state = state; + dirty = 1; + } + return 0; +} + +int tstate_toggle_mode(int mode) +{ + struct tstate_mode *m; + + init_defaults(); + m = find_mode(mode); + if (!m) + return -1; + if (m->state < 0) + m->state = m->default_on ? 0 : 1; /* flip from default */ + else + m->state = m->state ? 0 : 1; + dirty = 1; + return 0; +} + +int tstate_track_mode(int mode, const char *name) +{ + struct tstate_mode *m; + + init_defaults(); + m = ensure_mode(mode, name); + if (!m) + return -1; + m->tracked = 1; + return 0; +} + +int tstate_notrack_mode(int mode) +{ + struct tstate_mode *m; + + init_defaults(); + m = find_mode(mode); + if (!m) + return -1; + m->tracked = 0; + return 0; +} + +int tstate_get_mode(int mode) +{ + struct tstate_mode *m; + + init_defaults(); + m = find_mode(mode); + return m ? m->state : -1; +} + +int tstate_reset_all(unsigned char *buf, size_t buflen) +{ + size_t pos = 0; + int i; + + init_defaults(); + for (i = 0; i < nmodes; i++) { + int n; + int def_state = modes[i].default_on; + n = snprintf((char *)buf + pos, buflen - pos, + "\033[?%d%c", modes[i].mode, + def_state ? 'h' : 'l'); + if (n < 0 || pos + (size_t)n >= buflen) + break; + pos += (size_t)n; + } + return (int)pos; +} + +int tstate_mode_seq(int mode, int state, unsigned char *buf, size_t buflen) +{ + int n = snprintf((char *)buf, buflen, "\033[?%d%c", + mode, state ? 'h' : 'l'); + return (n > 0 && (size_t)n < buflen) ? n : -1; +} + +/* ------------------------------------------------------------------ */ +/* Display */ +/* ------------------------------------------------------------------ */ + +void tstate_show(const char *base) +{ + int i; + char path[PATH_MAX]; + unsigned char buf[512]; + ssize_t n; + int fd; + + tstate_load_global_config(); + tstate_load_config(base); + + /* Read preamble to populate active state */ + snprintf(path, sizeof(path), "%s.tpreamble", base); + fd = open(path, O_RDONLY); + if (fd >= 0) { + n = read(fd, buf, sizeof(buf)); + close(fd); + if (n > 0) + tstate_scan(buf, (size_t)n); + } + + printf(" %-6s %-20s %-8s %-8s %s\n", + "Mode", "Name", "State", "Default", "Tracked"); + printf(" %-6s %-20s %-8s %-8s %s\n", + "----", "----", "-----", "-------", "-------"); + for (i = 0; i < nmodes; i++) { + const char *state_str; + const char *default_str; + + if (modes[i].state < 0) + state_str = "-"; + else if (modes[i].state == 1) { + state_str = modes[i].tiname_set + ? modes[i].tiname_set : "h"; + } else { + state_str = modes[i].tiname_reset + ? modes[i].tiname_reset : "l"; + } + + default_str = modes[i].default_on ? "h" : "l"; + + printf(" %-6d %-20s %-8s %-8s %s\n", + modes[i].mode, modes[i].name, + state_str, default_str, + modes[i].tracked ? "yes" : "no"); + } +} From b42ff7a46442f33db0204f3a22d6a7b319958577 Mon Sep 17 00:00:00 2001 From: Matt Keenan Date: Wed, 15 Apr 2026 10:04:51 +0000 Subject: [PATCH 6/7] Remove unconditional cnorm after log replay on attach The tstate preamble now handles cursor visibility state, so sending cnorm unconditionally after replay would override a civis preamble. Co-Authored-By: Claude Opus 4.6 (1M context) --- attach.c | 6 ------ 1 file changed, 6 deletions(-) diff --git a/attach.c b/attach.c index de21188..fb1f8b9 100644 --- a/attach.c +++ b/attach.c @@ -416,12 +416,6 @@ int attach_main(int noerror) else write_buf_or_fail(1, "\033c", 2); } else { - if (!no_ansiterm) { - if (ti_cnorm) - write_buf_or_fail(1, ti_cnorm, strlen(ti_cnorm)); - else - write_buf_or_fail(1, "\033[?25h", 6); - } if (!quiet && !skip_ring) write_buf_or_fail(1, "\r\n", 2); } From 9ab8cdd6b4797bb095bd1a38d43aca4a6fb1abc1 Mon Sep 17 00:00:00 2001 From: Matt Keenan Date: Wed, 15 Apr 2026 21:14:08 +0000 Subject: [PATCH 7/7] debian: changelog entry for cnorm removal Co-Authored-By: Claude Opus 4.6 (1M context) --- debian/changelog | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/debian/changelog b/debian/changelog index 4a9137b..dc0c133 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,10 @@ +atch (0.3b~local3) unstable; urgency=low + + * Remove unconditional cnorm after log replay; tstate preamble handles + cursor visibility now. + + -- Matt Keenan Sat, 12 Apr 2026 00:00:00 +0000 + atch (0.3b~local2) unstable; urgency=low * Add DEC private mode state tracker for terminal state preservation.