aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-24 06:43:45 -0400
committerCraig Jennings <c@cjennings.net>2026-06-24 06:43:45 -0400
commit4e1401d1499be3d24df78edd97310d200e719e10 (patch)
tree9fe55839f6ca7741eaf45ac9f286f5b1bef49089
parent69931ef2923363e071cb125661ff2f0ed91e890e (diff)
downloaddotemacs-4e1401d1499be3d24df78edd97310d200e719e10.tar.gz
dotemacs-4e1401d1499be3d24df78edd97310d200e719e10.zip
feat(ai-term): wrap-teardown + shutdown entry points for rulesets
Add the three headless functions the rulesets wrap-it-up workflow calls via emacsclient -e, since this module owns the aiv- session naming, the agent buffer, and the geometry restore. cj/ai-term-quit kills a project's tmux session and agent buffer and restores the layout, idempotent and safe when already gone. cj/ai-term-live-count returns the integer count of live aiv- sessions for the shutdown safety gate. cj/ai-term-shutdown-countdown re-checks that gate, then runs an abort-able run-at-time countdown in the echo area and, uncancelled, runs the shutdown command (a defcustom so tests stub it). Reuses the existing kill/close helpers. 13 ERT tests cover the live-count parsing, the quit kill-and-idempotency, and the gate-abort/cancel/tick logic; the tmux and shutdown side effects are manual. Claude-Session: https://claude.ai/code/session_01BqrdWUo9GcznYX2pZr76gZ
-rw-r--r--modules/ai-term.el89
-rw-r--r--tests/test-ai-term--live-count.el60
-rw-r--r--tests/test-ai-term--quit.el65
-rw-r--r--tests/test-ai-term--shutdown-countdown.el73
4 files changed, 287 insertions, 0 deletions
diff --git a/modules/ai-term.el b/modules/ai-term.el
index 1c98dd5ee..04ff9f6c7 100644
--- a/modules/ai-term.el
+++ b/modules/ai-term.el
@@ -1049,6 +1049,95 @@ picker and C-; a k closes an agent."
(add-to-list 'ghostel-keymap-exceptions "M-SPC")
(ghostel--rebuild-semi-char-keymap))
+;; ------------------- Wrap-it-up teardown + shutdown -------------------------
+;;
+;; Headless entry points the rulesets wrap-it-up workflow calls via
+;; `emacsclient -e' (its Stop hook ~/.claude/hooks/ai-wrap-teardown.sh). All
+;; three must work with no interactive frame guaranteed. rulesets owns the
+;; workflow + hook that call these; this module owns the aiv- session naming,
+;; the agent buffer, and the geometry restore, so the functions live here.
+;; See docs/design/2026-06-23-wrap-teardown-shutdown-proposal.org (rulesets).
+
+(defcustom cj/ai-term-shutdown-command "sudo shutdown now"
+ "Shell command run when the shutdown countdown completes uncancelled.
+A defcustom so development and tests can stub it instead of powering off
+\(sudo is NOPASSWD on Craig's machines, so the default really shuts down)."
+ :type 'string
+ :group 'cj)
+
+(defun cj/ai-term-quit (&optional project)
+ "Tear down PROJECT's AI-term: kill its tmux session, buffer, and restore layout.
+PROJECT is a project basename (as the rulesets Stop hook passes) or a directory;
+nil means the current project (`default-directory'). Kills the `aiv-<name>'
+tmux session (taking the agent process with it), then, when the agent buffer is
+live, swaps its window back to the working buffer and kills it. Idempotent and
+safe headless: a session or buffer already gone is a no-op, not an error."
+ (let* ((key (or project default-directory))
+ (session (cj/--ai-term-tmux-session-name key))
+ (buffer (get-buffer (cj/--ai-term-buffer-name key))))
+ (cj/--ai-term-kill-tmux-session session)
+ (when (cj/--ai-term-buffer-p buffer)
+ (let ((win (get-buffer-window buffer)))
+ (when (window-live-p win)
+ (cj/--ai-term-swap-to-working-buffer win)))
+ (let ((kill-buffer-query-functions nil))
+ (kill-buffer buffer)))
+ session))
+
+(defun cj/ai-term-live-count ()
+ "Return the integer count of live AI-term (aiv-*) tmux sessions.
+0 when tmux has no server or no AI-term sessions. The shutdown safety gate:
+`emacsclient -e (cj/ai-term-live-count)' prints the integer for the hook."
+ (length (cj/--ai-term-live-tmux-sessions)))
+
+(defvar cj/--ai-term-shutdown-timer nil
+ "The active shutdown-countdown repeating timer, or nil when none is running.")
+
+(defun cj/--ai-term-shutdown-clear-timer ()
+ "Cancel and forget the shutdown-countdown timer, if any."
+ (when (timerp cj/--ai-term-shutdown-timer)
+ (cancel-timer cj/--ai-term-shutdown-timer))
+ (setq cj/--ai-term-shutdown-timer nil))
+
+(defun cj/ai-term-shutdown-cancel ()
+ "Cancel an in-progress AI-term shutdown countdown."
+ (interactive)
+ (when cj/--ai-term-shutdown-timer
+ (cj/--ai-term-shutdown-clear-timer)
+ (message "Shutdown cancelled.")))
+
+(defun cj/ai-term-shutdown-countdown (&optional seconds)
+ "Count down SECONDS (default 10) in the echo area, then shut the machine down.
+Re-checks the safety gate first (a TOCTOU guard against the workflow's earlier
+check): aborts with a message when more than one `aiv-*' session is live. The
+countdown is an abort-able `run-at-time' timer -- `C-g' (while the countdown
+owns the keymap) or \\[cj/ai-term-shutdown-cancel] stops it. On reaching zero
+uncancelled it runs `cj/ai-term-shutdown-command'. Returns immediately so the
+Stop hook does not block; the daemon ticks the timer asynchronously."
+ (if (> (cj/ai-term-live-count) 1)
+ (progn
+ (message "Shutdown aborted: %d AI-term sessions still live."
+ (cj/ai-term-live-count))
+ nil)
+ (cj/--ai-term-shutdown-clear-timer)
+ (let ((remaining (or seconds 10)))
+ (set-transient-map
+ (let ((m (make-sparse-keymap)))
+ (define-key m (kbd "C-g") #'cj/ai-term-shutdown-cancel)
+ m)
+ (lambda () (and cj/--ai-term-shutdown-timer t)))
+ (setq cj/--ai-term-shutdown-timer
+ (run-at-time
+ 0 1
+ (lambda ()
+ (if (<= remaining 0)
+ (progn
+ (cj/--ai-term-shutdown-clear-timer)
+ (shell-command cj/ai-term-shutdown-command))
+ (message "Shutting down in %d… (C-g to cancel)" remaining)
+ (setq remaining (1- remaining))))))
+ nil)))
+
;; ---------- emacsclient: keep opened files off the agent terminal ----------
;;
;; `server-start' (in system-defaults.el) leaves `server-window' nil, so
diff --git a/tests/test-ai-term--live-count.el b/tests/test-ai-term--live-count.el
new file mode 100644
index 000000000..1432599cc
--- /dev/null
+++ b/tests/test-ai-term--live-count.el
@@ -0,0 +1,60 @@
+;;; test-ai-term--live-count.el --- Tests for cj/ai-term-live-count -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The shutdown safety gate: the integer count of live AI-term (aiv-*) tmux
+;; sessions, read by the rulesets wrap-it-up workflow via emacsclient -e. No
+;; server / no sessions is 0, not an error.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(defmacro test-ai-term-live-count--with-tmux (exit-code output &rest body)
+ "Run BODY with `process-file' mocked to a tmux list-sessions response.
+EXIT-CODE is returned (or the symbol `error' to signal); OUTPUT is written to
+the stdout destination buffer."
+ (declare (indent 2))
+ `(cl-letf (((symbol-function 'process-file)
+ (lambda (_program _infile destination _display &rest _args)
+ (when (eq ,exit-code 'error) (error "tmux: command not found"))
+ (let ((buffer (cond ((eq destination t) (current-buffer))
+ ((bufferp destination) destination)
+ ((consp destination)
+ (and (eq (car destination) t) (current-buffer))))))
+ (when (bufferp buffer)
+ (with-current-buffer buffer (insert ,output))))
+ ,exit-code)))
+ (let ((cj/ai-term-tmux-session-prefix "aiv-"))
+ ,@body)))
+
+(ert-deftest test-ai-term-live-count-counts-matching-sessions ()
+ "Normal: two aiv-* sessions among others count as 2."
+ (test-ai-term-live-count--with-tmux 0 "aiv-foo\nrandom\naiv-bar\n"
+ (should (= (cj/ai-term-live-count) 2))))
+
+(ert-deftest test-ai-term-live-count-single-session ()
+ "Boundary: a sole aiv-* session counts as 1."
+ (test-ai-term-live-count--with-tmux 0 "aiv-only\nother\n"
+ (should (= (cj/ai-term-live-count) 1))))
+
+(ert-deftest test-ai-term-live-count-no-matching-sessions ()
+ "Boundary: a running server with no aiv-* sessions is 0."
+ (test-ai-term-live-count--with-tmux 0 "other-a\nother-b\n"
+ (should (= (cj/ai-term-live-count) 0))))
+
+(ert-deftest test-ai-term-live-count-no-server ()
+ "Error: tmux exits non-zero (no server) -> 0, not a signal."
+ (test-ai-term-live-count--with-tmux 1 "no server running\n"
+ (should (= (cj/ai-term-live-count) 0))))
+
+(ert-deftest test-ai-term-live-count-tmux-missing ()
+ "Error: tmux not installed -> 0."
+ (test-ai-term-live-count--with-tmux 'error ""
+ (should (= (cj/ai-term-live-count) 0))))
+
+(provide 'test-ai-term--live-count)
+;;; test-ai-term--live-count.el ends here
diff --git a/tests/test-ai-term--quit.el b/tests/test-ai-term--quit.el
new file mode 100644
index 000000000..55ace81db
--- /dev/null
+++ b/tests/test-ai-term--quit.el
@@ -0,0 +1,65 @@
+;;; test-ai-term--quit.el --- Tests for cj/ai-term-quit -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Headless teardown of a project's AI-term: kill the aiv-<name> tmux session,
+;; then the agent buffer. Driven by the rulesets Stop hook via emacsclient -e,
+;; keyed by project basename. Must be idempotent (a no-op when already gone).
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(defmacro test-ai-term-quit--with-tmux (calls-var &rest body)
+ "Run BODY with `process-file' mocked to record arg lists into CALLS-VAR (0 exit)."
+ (declare (indent 1))
+ `(cl-letf (((symbol-function 'process-file)
+ (lambda (_program _infile _destination _display &rest args)
+ (push args ,calls-var) 0)))
+ ,@body))
+
+(ert-deftest test-ai-term-quit-kills-session-and-buffer ()
+ "Normal: quit kills the project's aiv- session and its agent buffer."
+ (let ((buf (get-buffer-create "agent [myproj]"))
+ (calls nil))
+ (unwind-protect
+ (test-ai-term-quit--with-tmux calls
+ (cj/ai-term-quit "myproj")
+ (should (member '("kill-session" "-t" "aiv-myproj") calls))
+ (should-not (buffer-live-p buf)))
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+(ert-deftest test-ai-term-quit-sanitizes-dotted-basename ()
+ "Boundary: a dotted basename maps to the sanitized session tmux really uses."
+ (let ((buf (get-buffer-create "agent [.emacs.d]"))
+ (calls nil))
+ (unwind-protect
+ (test-ai-term-quit--with-tmux calls
+ (cj/ai-term-quit ".emacs.d")
+ (should (member '("kill-session" "-t" "aiv-_emacs_d") calls))
+ (should-not (buffer-live-p buf)))
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+(ert-deftest test-ai-term-quit-idempotent-when-gone ()
+ "Error/Boundary: a second quit (session + buffer already gone) does not error."
+ (let ((calls nil))
+ (test-ai-term-quit--with-tmux calls
+ ;; No buffer named "agent [ghost]" exists; session kill is a no-op in tmux.
+ (should (stringp (cj/ai-term-quit "ghost")))
+ (should (member '("kill-session" "-t" "aiv-ghost") calls)))))
+
+(ert-deftest test-ai-term-quit-leaves-non-agent-buffers ()
+ "Error: a same-named-but-non-agent buffer is never killed (prefix guard)."
+ (let ((buf (get-buffer-create "notes-myproj"))
+ (calls nil))
+ (unwind-protect
+ (test-ai-term-quit--with-tmux calls
+ (cj/ai-term-quit "myproj")
+ (should (buffer-live-p buf)))
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+(provide 'test-ai-term--quit)
+;;; test-ai-term--quit.el ends here
diff --git a/tests/test-ai-term--shutdown-countdown.el b/tests/test-ai-term--shutdown-countdown.el
new file mode 100644
index 000000000..6500e9634
--- /dev/null
+++ b/tests/test-ai-term--shutdown-countdown.el
@@ -0,0 +1,73 @@
+;;; test-ai-term--shutdown-countdown.el --- Tests for the shutdown countdown -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The "wrap it up and shutdown" countdown. The testable logic is the safety
+;; gate (abort when more than one aiv-* session is live) and the cancel/timer
+;; bookkeeping; the tick rendering and the actual shutdown side effect are
+;; manual (see the spec). shell-command is stubbed throughout so no test can
+;; power the machine off, and timers are cancelled rather than allowed to fire.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'ai-term)
+
+(defmacro test-ai-term-shutdown--with (live-count shell-var &rest body)
+ "Run BODY with `cj/ai-term-live-count' mocked to LIVE-COUNT and `shell-command'
+recording its argument into SHELL-VAR; the timer is cleared before and after."
+ (declare (indent 2))
+ `(progn
+ (cj/--ai-term-shutdown-clear-timer)
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/ai-term-live-count) (lambda () ,live-count))
+ ((symbol-function 'shell-command)
+ (lambda (cmd &rest _) (setq ,shell-var cmd) 0)))
+ ,@body)
+ (cj/--ai-term-shutdown-clear-timer))))
+
+(ert-deftest test-ai-term-shutdown-aborts-when-other-sessions-live ()
+ "Normal: more than one live session aborts -- no timer, no shutdown."
+ (let ((shell nil))
+ (test-ai-term-shutdown--with 2 shell
+ (should-not (cj/ai-term-shutdown-countdown 3))
+ (should-not cj/--ai-term-shutdown-timer)
+ (should-not shell))))
+
+(ert-deftest test-ai-term-shutdown-schedules-timer-when-sole-session ()
+ "Normal: the sole live session schedules the countdown timer (does not fire here)."
+ (let ((shell nil))
+ (test-ai-term-shutdown--with 1 shell
+ (cj/ai-term-shutdown-countdown 3)
+ (should (timerp cj/--ai-term-shutdown-timer))
+ ;; The timer has not ticked (no event loop in batch), so no shutdown yet.
+ (should-not shell))))
+
+(ert-deftest test-ai-term-shutdown-cancel-clears-the-timer ()
+ "Normal: cancel stops an in-progress countdown."
+ (let ((shell nil))
+ (test-ai-term-shutdown--with 1 shell
+ (cj/ai-term-shutdown-countdown 5)
+ (should (timerp cj/--ai-term-shutdown-timer))
+ (cj/ai-term-shutdown-cancel)
+ (should-not cj/--ai-term-shutdown-timer)
+ (should-not shell))))
+
+(ert-deftest test-ai-term-shutdown-tick-fires-shutdown-at-zero ()
+ "Boundary: invoking the timer function at zero remaining runs the shutdown
+command and clears the timer. Drives the tick directly rather than waiting."
+ (let ((shell nil))
+ (test-ai-term-shutdown--with 1 shell
+ (cj/ai-term-shutdown-countdown 1)
+ (let ((fn (timer--function cj/--ai-term-shutdown-timer)))
+ ;; remaining starts at 1: first call renders, second call hits zero.
+ (funcall fn)
+ (should-not shell)
+ (funcall fn)
+ (should (equal shell cj/ai-term-shutdown-command))
+ (should-not cj/--ai-term-shutdown-timer)))))
+
+(provide 'test-ai-term--shutdown-countdown)
+;;; test-ai-term--shutdown-countdown.el ends here