Integrated repository, worktree, git stacker, and project manager
0

Configure Feed

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

emacs: add pm-serve live sidebar + generic ghostel session tagging

pm-serve.el: a magit-section sidebar that streams session state from
`pm agent serve` over SSE, grouped by project, colored by status, mapping a
row back to its launching buffer via the injected PM_META_BUF. The SSE
connection is async (`:nowait`) and auto-reconnects on drop, so a daemon
restart never wedges or strands the sidebar.

pm-agent.el: inject the PM_META_* launch seeds into *every* ghostel session
via `ghostel-pre-spawn-hook` (installed by `pm-agent-setup-ghostel`), so an
agent started by hand in any ghostel buffer is tracked too — not only
`pm-agent-launch` dispatches. The ghostel dispatcher drops its now-redundant
manual env seeding.

Jordan Isaacs (Jun 22, 2026, 6:10 PM UTC) 1dc6a82d 9ecad9e8

+470 -11
+107 -11
integrations/emacs/pm-agent.el
··· 23 23 ;; Default behavior (`pm-agent-dispatch-function' = nil) stages the 24 24 ;; command on the kill-ring and prints a hint, so the package is 25 25 ;; harmless out of the box. 26 + ;; 27 + ;; Independently of the dispatchers, `pm-agent-setup-ghostel' (run 28 + ;; automatically once `ghostel' loads) installs a `ghostel-pre-spawn-hook' 29 + ;; that injects the PM_META_* launch seeds into *every* ghostel session, so 30 + ;; an agent started by hand in any ghostel buffer is tracked by `pm-serve' 31 + ;; just the same. 26 32 27 33 ;;; Code: 28 34 ··· 45 51 (declare-function vterm-send-return "ext:vterm" ()) 46 52 (declare-function term-mode "term" ()) 47 53 (declare-function term-char-mode "term" ()) 54 + (declare-function term-exec "term" (buffer name command startfile switches)) 48 55 49 56 (defvar ghostel-buffer-name) ; declared dynamic so the let-binding takes effect 50 57 (declare-function ghostel "ext:ghostel" (&optional arg)) ··· 71 78 (function :tag "Custom dispatcher")) 72 79 :group 'pm) 73 80 81 + ;;;; Session seeds injected into launched agents 82 + ;; 83 + ;; `pm agent serve' forwards every `PM_META_*' env var into the session's 84 + ;; metadata. We seed two on launch so the `pm-serve' sidebar can both filter 85 + ;; to this Emacs's agents and act on the exact buffer: 86 + ;; PM_META_SOURCE — a tag (default "emacs") to filter on 87 + ;; PM_META_BUF — a stable per-buffer id (not the mutable buffer name) 88 + 89 + ;;;###autoload 90 + (defcustom pm-agent-serve-source "emacs" 91 + "Value injected as `PM_META_SOURCE' into agents launched from Emacs. 92 + Lets the `pm-serve' sidebar filter to sessions this Emacs started 93 + \(meta.SOURCE = this value)." 94 + :type 'string 95 + :group 'pm) 96 + 97 + (defvar-local pm-agent-buffer-id nil 98 + "Stable id of this agent buffer, also injected as `PM_META_BUF'. 99 + Lets the `pm-serve' sidebar map a session row back to its buffer without 100 + relying on the mutable, possibly-duplicated buffer name. Set once at 101 + launch and never changed, so it survives the terminal renaming itself.") 102 + 103 + (defvar pm-agent--buffer-seq 0 104 + "Monotonic counter behind `pm-agent--new-buffer-id'.") 105 + 106 + (defun pm-agent--new-buffer-id () 107 + "Return a fresh agent-buffer id, unique within this Emacs process. 108 + Generated *before* the buffer exists so it can be injected via 109 + `process-environment' (no shell `export'); the same value is stamped 110 + buffer-local as `pm-agent-buffer-id' for the reverse lookup." 111 + (format "%d-%d" (emacs-pid) (cl-incf pm-agent--buffer-seq))) 112 + 113 + (defun pm-agent--seed-environment (buffer-id) 114 + "Return `process-environment' with the PM_META_* launch seeds prepended. 115 + The agent inherits these from its environment (no visible `export'), and 116 + its hooks report them to `pm agent serve' as session metadata: 117 + PM_META_SOURCE — `pm-agent-serve-source', a tag the sidebar filters on 118 + PM_META_BUF — BUFFER-ID, for mapping a session back to its buffer" 119 + (append (list (format "PM_META_SOURCE=%s" pm-agent-serve-source) 120 + (format "PM_META_BUF=%s" buffer-id)) 121 + process-environment)) 122 + 123 + (defun pm-agent-buffer-for-id (id) 124 + "Return the live buffer whose `pm-agent-buffer-id' equals ID, or nil." 125 + (and id (seq-find (lambda (b) (equal (buffer-local-value 'pm-agent-buffer-id b) id)) 126 + (buffer-list)))) 127 + 128 + (defun pm-agent--seed-ghostel-environment () 129 + "Inject the PM_META_* launch seeds into the ghostel process about to spawn. 130 + Written for `ghostel-pre-spawn-hook', which runs in the buffer that will 131 + host the new process with `process-environment' dynamically bound to the 132 + child's env. Unlike the per-dispatch seeding, this tags *every* ghostel 133 + session — including a `pm agent' a user starts by hand in any ghostel 134 + buffer — so the `pm-serve' sidebar still sees it and can map it back to 135 + its buffer: 136 + PM_META_SOURCE — `pm-agent-serve-source', the tag the sidebar filters on 137 + PM_META_BUF — this buffer's stable `pm-agent-buffer-id' 138 + The id is minted once and reused if the buffer ever respawns its shell, so 139 + the buffer keeps a single identity across restarts." 140 + (let ((buffer-id (or pm-agent-buffer-id (pm-agent--new-buffer-id)))) 141 + (setq-local pm-agent-buffer-id buffer-id) 142 + (setenv "PM_META_SOURCE" pm-agent-serve-source) 143 + (setenv "PM_META_BUF" buffer-id))) 144 + 145 + ;;;###autoload 146 + (defun pm-agent-setup-ghostel () 147 + "Tag every ghostel session with PM_META_* for the `pm-serve' sidebar. 148 + Adds `pm-agent--seed-ghostel-environment' to `ghostel-pre-spawn-hook'. 149 + Idempotent, and run automatically once `ghostel' loads (see end of file); 150 + exposed so you can wire or unwire it explicitly." 151 + (add-hook 'ghostel-pre-spawn-hook #'pm-agent--seed-ghostel-environment)) 152 + 74 153 ;;;; Dispatch implementation 75 154 76 155 (defun pm-agent--build-argv (agent session-id project) ··· 137 216 (agent (plist-get plist :agent)) 138 217 (project (plist-get plist :project)) 139 218 (name (format "pm-agent: %s/%s" project agent)) 140 - (default-directory cwd)) 141 - ;; `make-term' takes (NAME PROGRAM &optional STARTFILE &rest ARGS). 142 - ;; We let argv[0] be the program and pass the rest as args. 143 - (let ((buf (apply #'make-term name (car argv) nil (cdr argv)))) 144 - (with-current-buffer buf 145 - (term-mode) 146 - (term-char-mode)) 147 - (pop-to-buffer buf)))) 219 + (default-directory cwd) 220 + (buffer-id (pm-agent--new-buffer-id)) 221 + ;; Create the buffer first (mirrors `make-term': get-buffer-create + 222 + ;; term-mode + term-exec), then seed PM_META_* via the environment 223 + ;; before exec'ing the program. 224 + (buf (generate-new-buffer (concat "*" name "*")))) 225 + (with-current-buffer buf 226 + (setq-local pm-agent-buffer-id buffer-id) 227 + (term-mode) 228 + (let ((process-environment (pm-agent--seed-environment buffer-id))) 229 + ;; `term-exec' takes (BUFFER NAME PROGRAM STARTFILE SWITCHES). 230 + (term-exec buf name (car argv) nil (cdr argv))) 231 + (term-char-mode)) 232 + (pop-to-buffer buf))) 148 233 149 234 ;;;###autoload 150 235 (defun pm-agent-dispatch-vterm (plist) ··· 158 243 (cwd (plist-get plist :cwd)) 159 244 (agent (plist-get plist :agent)) 160 245 (project (plist-get plist :project)) 246 + (buffer-id (pm-agent--new-buffer-id)) 161 247 (default-directory cwd) 162 - (buffer-name (format "*pm-agent: %s/%s*" project agent))) 248 + (buffer-name (format "*pm-agent: %s/%s*" project agent)) 249 + ;; Seed the env before vterm starts its shell (no visible `export'). 250 + (process-environment (pm-agent--seed-environment buffer-id))) 163 251 (let ((vterm-buffer-name buffer-name)) 164 252 (vterm)) 253 + (setq-local pm-agent-buffer-id buffer-id) 165 254 (vterm-send-string (mapconcat #'shell-quote-argument argv " ")) 166 255 (vterm-send-return))) 167 256 ··· 185 274 (unless (require 'ghostel nil t) 186 275 (user-error "ghostel not installed; pick another `pm-agent-dispatch-function'")) 187 276 (let* ((argv (plist-get plist :argv)) 188 - (cwd (plist-get plist :cwd)) 189 277 (agent (plist-get plist :agent)) 190 278 (project (plist-get plist :project)) 191 - (default-directory cwd) 279 + (default-directory (plist-get plist :cwd)) 192 280 (ghostel-buffer-name (format "*pm-agent: %s/%s*" project agent)) 281 + ;; PM_META_* (SOURCE + a fresh per-buffer BUF id) is injected for us by 282 + ;; `pm-agent--seed-ghostel-environment' on `ghostel-pre-spawn-hook' 283 + ;; (installed when ghostel loads), which also stamps the buffer-local 284 + ;; `pm-agent-buffer-id'. So no manual env seeding here. 193 285 ;; Non-numeric, non-nil prefix arg → ghostel branches to 194 286 ;; `generate-new-buffer', so we never paste into an existing 195 287 ;; agent session by reusing its buffer name. ··· 410 502 (container-name default-directory) 411 503 (pm--read-project "Sessions for: ")))) 412 504 (pm-agent-list name nil)))) 505 + 506 + ;; Tag every ghostel session (not just `pm-agent-launch' dispatches) so 507 + ;; hand-started agents are tracked by the `pm-serve' sidebar too. 508 + (with-eval-after-load 'ghostel (pm-agent-setup-ghostel)) 413 509 414 510 (provide 'pm-agent) 415 511
+363
integrations/emacs/pm-serve.el
··· 1 + ;;; pm-serve.el --- Live agent-session sidebar over SSE -*- lexical-binding: t -*- 2 + 3 + ;;; Commentary: 4 + 5 + ;; A magit-section sidebar that streams live agent-session state from the 6 + ;; `pm agent serve' daemon (GET /api/stream, server-side filtered to the 7 + ;; sessions this Emacs launched) and renders them grouped by project, with 8 + ;; status color-coded and the "waiting on you" states highlighted. 9 + ;; 10 + ;; The daemon forwards each session's `PM_META_*' env vars into its metadata. 11 + ;; `pm-agent.el' seeds two on launch: `PM_META_SOURCE' (a tag we filter on) 12 + ;; and `PM_META_BUF' (the launching buffer's builtin id), so RET on a row can 13 + ;; jump straight to the agent's terminal buffer. 14 + ;; 15 + ;; Entry point: `pm-serve' (show), `pm-serve-quit' (hide). 16 + 17 + ;;; Code: 18 + 19 + (require 'cl-lib) 20 + (require 'subr-x) 21 + (require 'json) 22 + (require 'magit-section) 23 + 24 + (require 'pm-faces) 25 + (require 'pm-table) ; pm-propertize-face + section reset/cover/show helpers 26 + (require 'pm-agent) ; pm-agent-serve-source, pm-agent-buffer-for-id 27 + 28 + (defgroup pm-serve nil 29 + "Live agent-session sidebar fed by `pm agent serve'." 30 + :group 'pm) 31 + 32 + (defcustom pm-serve-port 8787 33 + "Loopback TCP port the `pm agent serve' daemon listens on." 34 + :type 'integer 35 + :group 'pm-serve) 36 + 37 + (defcustom pm-serve-width 29 38 + "Default total width of the sidebar side window, in columns." 39 + :type 'integer 40 + :group 'pm-serve) 41 + 42 + (defconst pm-serve-buffer-name "*pm-agents*") 43 + 44 + ;; --- faces ------------------------------------------------------------------ 45 + (defface pm-serve-working '((t :inherit success :weight bold)) "Agent is working.") 46 + (defface pm-serve-response '((t :inherit warning :weight bold)) "Awaiting your reply.") 47 + (defface pm-serve-permission '((t :inherit error :weight bold)) "Needs your approval.") 48 + (defface pm-serve-idle '((t :inherit shadow)) "Idle.") 49 + (defface pm-serve-age '((t :inherit shadow)) "Last-active age.") 50 + (defface pm-serve-detail '((t :inherit shadow :slant italic)) "Tool/activity detail line.") 51 + (defface pm-serve-rule '((t :inherit shadow)) "Separator rule.") 52 + 53 + ;; status string -> (glyph face row-emphasised?). 54 + (defconst pm-serve--status 55 + '(("working" "●" pm-serve-working nil) 56 + ("waiting_response" "◆" pm-serve-response t) 57 + ("waiting_permission" "▲" pm-serve-permission t) 58 + ("blocked" "◆" pm-serve-response t) ; pre-split daemons 59 + ("idle" "○" pm-serve-idle nil) 60 + ("unknown" "◌" pm-serve-idle nil))) 61 + 62 + (defconst pm-serve--age-width 5) 63 + 64 + ;; --- runtime state ---------------------------------------------------------- 65 + (defvar pm-serve--proc nil) 66 + (defvar pm-serve--acc "") 67 + (defvar pm-serve--headers-done nil) 68 + (defvar pm-serve--sessions (make-hash-table :test 'equal)) 69 + (defvar pm-serve--last-width nil) 70 + (defvar pm-serve--want-connection nil 71 + "Non-nil while the sidebar should stay connected; cleared by `pm-serve-quit'.") 72 + (defvar pm-serve--reconnect-timer nil 73 + "Pending reconnect timer, so a dropped stream (e.g. daemon restart) recovers.") 74 + 75 + ;; --- text helpers ----------------------------------------------------------- 76 + (defun pm-serve--ellipsize (str width) 77 + "Truncate STR to WIDTH with a trailing `...' (no padding)." 78 + (if (> (length str) width) 79 + (concat (substring str 0 (max 0 (- width 3))) "...") 80 + str)) 81 + 82 + (defun pm-serve--fit (str width) 83 + "Ellipsize STR to WIDTH, then right-pad with spaces to exactly WIDTH." 84 + (let ((cut (pm-serve--ellipsize str width))) 85 + (concat cut (make-string (max 0 (- width (length cut))) ?\s)))) 86 + 87 + (defun pm-serve--rjust (str width) 88 + (let ((len (length str))) 89 + (if (>= len width) str (concat (make-string (- width len) ?\s) str)))) 90 + 91 + (defun pm-serve--age (ms) 92 + (if (or (null ms) (<= ms 0)) "·" 93 + (let ((s (- (float-time) (/ ms 1000.0)))) 94 + (cond ((< s 60) "now") 95 + ((< s 3600) (format "%dm" (floor (/ s 60)))) 96 + ((< s 86400) (format "%dh" (floor (/ s 3600)))) 97 + (t (format "%dd" (floor (/ s 86400)))))))) 98 + 99 + (defun pm-serve--width () 100 + "Usable text width of the sidebar window (accounts for fringes/margin)." 101 + (let ((win (get-buffer-window pm-serve-buffer-name t))) 102 + (if win (max 24 (window-max-chars-per-line win)) pm-serve-width))) 103 + 104 + (defun pm-serve--rule (width) 105 + (pm-propertize-face (concat (make-string (max 0 width) ?─) "\n") 'pm-serve-rule)) 106 + 107 + (defun pm-serve--detail (s status) 108 + "Second-line tool/activity detail for session S, or nil to omit." 109 + (let ((tool (alist-get 'current_tool s)) 110 + (activity (alist-get 'activity s))) 111 + (cond 112 + ((equal status "waiting_permission") (format "approve: %s" (or tool "tool"))) 113 + ((equal status "waiting_response") "awaiting your reply") 114 + ((and tool activity (not (equal activity tool))) (format "%s · %s" activity tool)) 115 + (tool tool) 116 + ((equal status "working") (or activity "working")) 117 + (t nil)))) 118 + 119 + ;; --- rendering -------------------------------------------------------------- 120 + (defun pm-serve--session-key (s) 121 + (concat (or (alist-get 'agent s) "") "\0" (or (alist-get 'vendor_session_id s) ""))) 122 + 123 + (defun pm-serve--insert-session (s width) 124 + (let* ((status (or (alist-get 'status s) "unknown")) 125 + (spec (assoc status pm-serve--status)) 126 + (glyph (or (nth 1 spec) "◌")) 127 + (face (or (nth 2 spec) 'pm-serve-idle)) 128 + (emph (nth 3 spec)) 129 + (title (or (alist-get 'title s) "(untitled)")) 130 + (age (pm-serve--age (alist-get 'last_activity_at s))) 131 + (text-face (if emph face 'default)) 132 + (room (max 6 (- width 2 pm-serve--age-width 1))) 133 + (detail (pm-serve--detail s status))) 134 + ;; Each session is its own subsection: line 1 (status, title, age) is the 135 + ;; heading; the tool/activity detail is the body, so folding hides it. 136 + (magit-insert-section (pm-serve-session s) 137 + (magit-insert-heading 138 + (concat (pm-propertize-face glyph face) " " 139 + (pm-propertize-face (pm-serve--fit title room) text-face) 140 + " " (pm-propertize-face (pm-serve--rjust age pm-serve--age-width) 'pm-serve-age))) 141 + (when detail 142 + (insert " " 143 + (pm-propertize-face (concat "↳ " (pm-serve--ellipsize detail (- width 4))) 144 + 'pm-serve-detail) 145 + "\n"))))) 146 + 147 + (defun pm-serve--insert-legend () 148 + (insert (pm-propertize-face "● " 'pm-serve-working) (pm-propertize-face "working " 'pm-serve-idle) 149 + (pm-propertize-face "○ " 'pm-serve-idle) (pm-propertize-face "idle" 'pm-serve-idle) 150 + "\n" 151 + (pm-propertize-face "◆ " 'pm-serve-response) (pm-propertize-face "reply " 'pm-serve-idle) 152 + (pm-propertize-face "▲ " 'pm-serve-permission) (pm-propertize-face "approve" 'pm-serve-idle) 153 + "\n")) 154 + 155 + (defun pm-serve--render () 156 + (let ((buf (get-buffer-create pm-serve-buffer-name))) 157 + (with-current-buffer buf 158 + (unless (derived-mode-p 'pm-serve-mode) (pm-serve-mode)) 159 + (let ((width (pm-serve--width)) 160 + (inhibit-read-only t)) 161 + (setq pm-serve--last-width width) 162 + (pm-table-reset-section-state) 163 + (erase-buffer) 164 + (magit-insert-section (pm-serve-root) 165 + (insert (pm-table-banner "pm agents" (hash-table-count pm-serve--sessions))) 166 + (insert "\n" (pm-serve--rule width)) 167 + (if (zerop (hash-table-count pm-serve--sessions)) 168 + (insert "\n" (pm-propertize-face "no active sessions" 'pm-serve-idle) "\n") 169 + (let ((groups (make-hash-table :test 'equal)) (order '())) 170 + (maphash (lambda (_k s) 171 + (let ((p (or (alist-get 'project s) "(no project)"))) 172 + (unless (gethash p groups) (push p order)) 173 + (push s (gethash p groups)))) 174 + pm-serve--sessions) 175 + (dolist (p (nreverse order)) 176 + (insert "\n") 177 + (magit-insert-section (pm-serve-project p) 178 + (magit-insert-heading (pm-propertize-face p 'pm-group-heading)) 179 + (dolist (s (sort (gethash p groups) 180 + (lambda (a b) (> (or (alist-get 'last_activity_at a) 0) 181 + (or (alist-get 'last_activity_at b) 0))))) 182 + (pm-serve--insert-session s width)))))) 183 + (insert "\n" (pm-serve--rule width)) 184 + (pm-serve--insert-legend)) 185 + (pm-table-cover-root-section) 186 + (pm-table-show-root-section) 187 + (goto-char (point-min)))))) 188 + 189 + ;; --- actions ---------------------------------------------------------------- 190 + (defun pm-serve-visit () 191 + "Jump to the terminal buffer of the agent session at point. 192 + Uses the session's `PM_META_BUF' (the launching buffer's builtin id)." 193 + (interactive) 194 + (let* ((sec (magit-current-section)) 195 + (s (and sec (eq (oref sec type) 'pm-serve-session) (oref sec value))) 196 + (bufid (and s (alist-get 'BUF (alist-get 'meta s)))) 197 + (buf (pm-agent-buffer-for-id bufid))) 198 + (cond (buf (pop-to-buffer buf)) 199 + (s (user-error "No live buffer for this session (not launched here, or closed)")) 200 + (t (user-error "Point is not on a session"))))) 201 + 202 + ;; --- SSE plumbing ----------------------------------------------------------- 203 + (defun pm-serve--filter-query () 204 + (format "meta.SOURCE=%s" pm-agent-serve-source)) 205 + 206 + (defun pm-serve--handle (json) 207 + (when (> (length json) 0) 208 + (condition-case nil 209 + (let* ((obj (json-parse-string json :object-type 'alist :array-type 'list 210 + :null-object nil :false-object nil)) 211 + (type (alist-get 'type obj))) 212 + (cond 213 + ((equal type "snapshot") 214 + (clrhash pm-serve--sessions) 215 + (dolist (s (alist-get 'sessions obj)) 216 + (puthash (pm-serve--session-key s) s pm-serve--sessions))) 217 + ((equal type "update") 218 + (let ((s (alist-get 'session obj))) 219 + (puthash (pm-serve--session-key s) s pm-serve--sessions))) 220 + ((equal type "remove") 221 + ;; `agent'/`vendor_session_id' sit at the top level of the 222 + ;; remove payload, exactly the keys `session-key' reads. 223 + (remhash (pm-serve--session-key obj) pm-serve--sessions))) 224 + (pm-serve--render)) 225 + (error nil)))) 226 + 227 + (defun pm-serve--proc-filter (_proc chunk) 228 + ;; Normalize CRLF→LF and key off \n; skip HTTP headers once. 229 + (setq pm-serve--acc (concat pm-serve--acc (replace-regexp-in-string "\r" "" chunk))) 230 + (unless pm-serve--headers-done 231 + (let ((idx (string-search "\n\n" pm-serve--acc))) 232 + (when idx 233 + (setq pm-serve--acc (substring pm-serve--acc (+ idx 2)) 234 + pm-serve--headers-done t)))) 235 + (when pm-serve--headers-done 236 + (let (nl) 237 + (while (setq nl (string-search "\n" pm-serve--acc)) 238 + (let ((line (string-trim (substring pm-serve--acc 0 nl)))) 239 + (setq pm-serve--acc (substring pm-serve--acc (1+ nl))) 240 + (when (string-prefix-p "data:" line) 241 + (pm-serve--handle (string-trim (substring line 5))))))))) 242 + 243 + (defun pm-serve--schedule-reconnect () 244 + "Arm a one-shot reconnect, unless we deliberately disconnected. 245 + Lets the sidebar recover after the daemon restarts or briefly goes away." 246 + (when (and pm-serve--want-connection (not (timerp pm-serve--reconnect-timer))) 247 + (setq pm-serve--reconnect-timer 248 + (run-at-time 3 nil 249 + (lambda () 250 + (setq pm-serve--reconnect-timer nil) 251 + (when (and pm-serve--want-connection 252 + (get-buffer pm-serve-buffer-name)) 253 + (pm-serve--connect))))))) 254 + 255 + (defun pm-serve--send-request (proc) 256 + "Send the SSE GET request once PROC's connection is open." 257 + (process-send-string 258 + proc 259 + (format (concat "GET /api/stream?%s HTTP/1.1\r\nHost: 127.0.0.1\r\n" 260 + "Accept: text/event-stream\r\nConnection: keep-alive\r\n\r\n") 261 + (pm-serve--filter-query)))) 262 + 263 + (defun pm-serve--sentinel (proc event) 264 + "Drive the async SSE connection: send the request on open, reconnect on drop. 265 + PROC is created with `:nowait', so the connect completes here rather than 266 + blocking Emacs's main loop (a blocking connect to a restarting daemon would 267 + otherwise wedge the whole daemon)." 268 + (cond 269 + ((string-prefix-p "open" event) 270 + (pm-serve--send-request proc)) 271 + ((and pm-serve--want-connection (not (process-live-p proc))) 272 + ;; Connection failed or dropped (daemon restart / down) — retry later. 273 + (pm-serve--schedule-reconnect)))) 274 + 275 + (defun pm-serve--connect () 276 + ;; Detach the old proc's sentinel first so tearing it down here doesn't look 277 + ;; like an unexpected drop and trigger a spurious reconnect. 278 + (when (process-live-p pm-serve--proc) 279 + (set-process-sentinel pm-serve--proc #'ignore) 280 + (delete-process pm-serve--proc)) 281 + (setq pm-serve--acc "" pm-serve--headers-done nil pm-serve--want-connection t) 282 + (clrhash pm-serve--sessions) 283 + ;; `:nowait t' makes the connect asynchronous — make-network-process returns 284 + ;; immediately and the sentinel fires on open/failure. A blocking connect 285 + ;; (the default) stalls the entire Emacs main loop until it resolves, which 286 + ;; wedges the daemon when reconnecting to a mid-restart `pm agent serve'. 287 + (condition-case nil 288 + (setq pm-serve--proc 289 + (make-network-process 290 + :name "pm-serve-sse" :host "127.0.0.1" :service pm-serve-port 291 + :coding 'utf-8 :nowait t :filter #'pm-serve--proc-filter 292 + :sentinel #'pm-serve--sentinel)) 293 + (error (setq pm-serve--proc nil) 294 + (pm-serve--schedule-reconnect)))) 295 + 296 + (defun pm-serve--on-window-size-change (_frame) 297 + "Re-fit the sidebar when its window's usable width changes." 298 + (let ((win (get-buffer-window pm-serve-buffer-name t))) 299 + (when (and win (/= (max 24 (window-max-chars-per-line win)) (or pm-serve--last-width -1))) 300 + (pm-serve--render)))) 301 + 302 + ;; --- mode + entry points ---------------------------------------------------- 303 + (defvar pm-serve-mode-map 304 + (let ((map (make-sparse-keymap))) 305 + (set-keymap-parent map magit-section-mode-map) ; TAB fold, n/p nav, etc. 306 + (define-key map (kbd "RET") #'pm-serve-visit) 307 + (define-key map (kbd "g") #'pm-serve-refresh) 308 + (define-key map (kbd "q") #'quit-window) 309 + map) 310 + "Keymap for `pm-serve-mode'.") 311 + ;; Force the parent on reload — `defvar' won't update an existing keymap. 312 + (set-keymap-parent pm-serve-mode-map magit-section-mode-map) 313 + 314 + (define-derived-mode pm-serve-mode magit-section-mode "PM-Agents" 315 + "Live agent-session sidebar." 316 + (setq-local mode-line-format nil 317 + cursor-type nil 318 + truncate-lines t 319 + display-line-numbers nil) 320 + ;; Allocate the left margin for the section visibility indicator (uses the 321 + ;; config's `magit-section-visibility-indicators' as-is). 322 + (pm-section-setup-margin)) 323 + 324 + (defun pm-serve-refresh () 325 + "Reconnect to the daemon and redraw." 326 + (interactive) 327 + (pm-serve--connect) 328 + (pm-serve--render)) 329 + 330 + ;;;###autoload 331 + (defun pm-serve () 332 + "Show the live agent-session sidebar, streaming from `pm agent serve'." 333 + (interactive) 334 + (pm-serve--connect) 335 + (get-buffer-create pm-serve-buffer-name) 336 + (display-buffer 337 + (get-buffer pm-serve-buffer-name) 338 + `((display-buffer-in-side-window) (side . left) (slot . 0) 339 + (window-width . ,pm-serve-width) 340 + (window-parameters . ((no-delete-other-windows . t))))) 341 + (pm-serve--render) ; after display so it fits the real window width 342 + (add-hook 'window-size-change-functions #'pm-serve--on-window-size-change) 343 + pm-serve-buffer-name) 344 + 345 + (defun pm-serve-quit () 346 + "Disconnect and remove the sidebar." 347 + (interactive) 348 + ;; Stop auto-reconnect before tearing the stream down, else the sentinel 349 + ;; would immediately re-dial the daemon. 350 + (setq pm-serve--want-connection nil) 351 + (when (timerp pm-serve--reconnect-timer) 352 + (cancel-timer pm-serve--reconnect-timer) 353 + (setq pm-serve--reconnect-timer nil)) 354 + (remove-hook 'window-size-change-functions #'pm-serve--on-window-size-change) 355 + (when (process-live-p pm-serve--proc) 356 + (set-process-sentinel pm-serve--proc #'ignore) 357 + (delete-process pm-serve--proc)) 358 + (when-let ((b (get-buffer pm-serve-buffer-name))) 359 + (when-let ((w (get-buffer-window b t))) (delete-window w)) 360 + (kill-buffer b))) 361 + 362 + (provide 'pm-serve) 363 + ;;; pm-serve.el ends here