From 3988d74d6dc095c4bec8e11d44ead1d6ae001b5f Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Sat, 11 Apr 2026 13:48:45 +0200 Subject: [PATCH] Initial commit Co-Authored-By: Claude Sonnet 4.6 --- README.md | 89 +++++++++++ ollie.el | 442 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 531 insertions(+) create mode 100644 README.md create mode 100644 ollie.el diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e96b46 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# ollie-el + +Emacs frontend for [ollie](https://github.com/lneely/ollie) via [ollie-9p](https://github.com/lneely/ollie-9p). Provides a chat buffer, prompt composition, interrupt handling, and session management. The agent core, backends, and tools are handled by the ollie-9p server. + +## Background + +ollie-tui proved that a full interactive client could be built on top of the ollie-9p filesystem using nothing but plain file I/O. ollie-el asks the same question in a different environment: can the same interface be built as an Emacs package, with no knowledge of ollie's internals and no dependencies beyond Emacs itself? + +The answer is yes. The implementation uses only built-in Emacs file operations: `write-region` to submit prompts, a half-second timer to tail the chat log by comparing file sizes, and `insert-file-contents` to read new content into a buffer. The ollie-9p filesystem is the entire API surface. + +## Prerequisites + +[olliesrv](https://github.com/lneely/ollie-9p) must be running and mounted before use: + +```sh +olliesrv start +``` + +By default the server mounts at `~/mnt/ollie`. Set `OLLIE_9MOUNT` to use a different path. + +## Installation + +Add `ollie.el` to your load path: + +```elisp +(add-to-list 'load-path "/path/to/ollie-el") +(require 'ollie) +``` + +Or with `use-package`: + +```elisp +(use-package ollie + :load-path "/path/to/ollie-el") +``` + +Optionally set a mount path and default session options: + +```elisp +(setq ollie-mount-directory "/custom/mount") +(setq ollie-default-session-opts '(("backend" . "ollama") ("model" . "qwen3:8b"))) +``` + +## Usage + +| Command | Description | +|---------|-------------| +| `M-x ollie` | Open chat; attach to last session or create a new one | +| `M-x ollie-new-and-open` | Force-create a new session and open chat | +| `M-x ollie-attach-and-open` | Attach to an existing session by ID | + +## UI + +In the `*ollie*` buffer: + +| Key | Action | +|-----|--------| +| `RET` | Open input window and compose a prompt | +| `C-c C-q` | Open input window and compose a queued prompt | +| `C-c C-c` | Interrupt the running turn | +| `C-c C-k` | Kill the current session | +| `C-c C-n` / `n` | New session | +| `C-c C-a` | Attach to a different session | +| `g` | Force-refresh the chat log | + +In the `*ollie-input*` window: + +| Key | Action | +|-----|--------| +| `C-c C-c` | Submit | +| `C-c C-k` | Cancel | + +The input window accepts multi-line prompts. Queued prompts are held until the current turn finishes. + +The mode line shows the active session ID and current state (`idle` or the running state name). + +## Credits + +Many sources of inspiration: + +- [Plan 9 from Bell Labs](https://9fans.net) — for an interesting system +- [@9fans](https://github.com/9fans) — for the Plan 9 port +- [Suckless](https://suckless.org) — for articulating good software development principles +- [@simonfxr](https://github.com/simonfxr) — for a solid agent baseline to "borrow" from, and other nifty ideas +- [@aws](https://github.com/aws/amazon-q-developer-cli) — for a solid open-source agent implementation + +## License + +GPLv3 diff --git a/ollie.el b/ollie.el new file mode 100644 index 0000000..a39c38e --- /dev/null +++ b/ollie.el @@ -0,0 +1,442 @@ +;;; ollie.el --- Emacs client for ollie-9p -*- lexical-binding: t -*- + +;; Version: 0.1.0 +;; Package-Requires: ((emacs "27.1")) +;; Keywords: tools, ai, chat + +;;; Commentary: +;; +;; ollie.el talks to an ollie-9p session through plain file I/O on the +;; mounted filesystem (default: ~/mnt/ollie, or $OLLIE_9MOUNT). +;; +;; Quick start: +;; +;; (require 'ollie) ; or use use-package / :load-path +;; M-x ollie ; opens *ollie*, attaches to last session or creates one +;; +;; In the *ollie* buffer: +;; +;; RET open an input window to compose and submit a prompt +;; C-c C-q open an input window to compose a queued prompt +;; C-c C-c interrupt the running turn +;; C-c C-k kill the current session +;; C-c C-n create a new session (also bound to "n" when idle) +;; C-c C-a attach to a different existing session +;; g force-refresh the chat log +;; +;; In the *ollie-input* window: +;; +;; C-c C-c submit +;; C-c C-k cancel + +;;; Code: + +(require 'ansi-color) + +;;;; ──────────────── Customization ──────────────── + +(defgroup ollie nil + "Emacs client for ollie-9p." + :group 'tools + :prefix "ollie-") + +(defcustom ollie-mount-directory + (or (getenv "OLLIE_9MOUNT") (expand-file-name "~/mnt/ollie")) + "Mount point for the ollie-9p filesystem." + :type 'directory + :group 'ollie) + +(defcustom ollie-default-session-opts nil + "Default options passed when creating a new session. +An alist of (KEY . VALUE) string pairs; valid keys are +\"backend\", \"model\", \"agent\", and \"workdir\"." + :type '(alist :key-type string :value-type string) + :group 'ollie) + +(defcustom ollie-poll-interval 0.5 + "Seconds between chat-file polls (used when file-notify is unavailable)." + :type 'number + :group 'ollie) + +(defcustom ollie-input-window-height 8 + "Height in lines of the prompt input window." + :type 'integer + :group 'ollie) + +;;;; ──────────────── Internal state ──────────────── + +(defvar ollie--session-id nil + "ID of the currently active session (global).") + +(defvar ollie--chat-size -1 + "Last known byte count of the chat file; -1 means unread.") + +(defvar ollie--poll-timer nil + "Timer for periodic refresh.") + +(defconst ollie--buf "*ollie*" "Name of the chat display buffer.") +(defconst ollie--ibuf "*ollie-input*" "Name of the prompt input buffer.") + +;;;; ──────────────── Paths ──────────────── + +(defun ollie--ctl-path () + (expand-file-name "ctl" ollie-mount-directory)) + +(defun ollie--sessions-dir () + (expand-file-name "s" ollie-mount-directory)) + +(defun ollie--session-file (name) + "Return the full path to file NAME inside the current session directory. +Signals `user-error' if no session is active." + (unless ollie--session-id + (user-error "No active ollie session")) + (expand-file-name name + (expand-file-name ollie--session-id (ollie--sessions-dir)))) + +(defun ollie--last-session-path () + (expand-file-name "~/.config/ollie/last-session")) + +;;;; ──────────────── Low-level file helpers ──────────────── + +(defun ollie--fwrite (path text) + "Write TEXT to PATH, suppressing the minibuffer message." + (write-region text nil path nil 'silent)) + +(defun ollie--fread (path) + "Return the contents of PATH as a string, or nil if it does not exist." + (when (file-exists-p path) + (with-temp-buffer + (insert-file-contents path) + (buffer-string)))) + +(defun ollie--write-root-ctl (cmd) + "Write CMD (without trailing newline) to the root ctl file." + (ollie--fwrite (ollie--ctl-path) (concat cmd "\n"))) + +;;;; ──────────────── Session enumeration ──────────────── + +(defun ollie--session-ids () + "Return a list of all current session IDs." + (let ((sdir (ollie--sessions-dir))) + (when (file-directory-p sdir) + (seq-filter + (lambda (name) (not (string-prefix-p "." name))) + (mapcar #'file-name-nondirectory + (seq-filter #'file-directory-p + (directory-files sdir t))))))) + +;;;; ──────────────── Last-session persistence ──────────────── + +(defun ollie--persist-session (id) + "Save ID to the last-session file." + (let ((path (ollie--last-session-path))) + (make-directory (file-name-directory path) t) + (ollie--fwrite path (concat id "\n")))) + +(defun ollie--recall-session () + "Return the last-used session ID if it still exists, else nil." + (when-let* ((raw (ollie--fread (ollie--last-session-path))) + (id (string-trim raw))) + (when (and (not (string-empty-p id)) + (file-directory-p (expand-file-name id (ollie--sessions-dir)))) + id))) + +;;;; ──────────────── Session lifecycle ──────────────── + +(defun ollie-new-session (&optional opts) + "Create a new ollie session and make it current. +OPTS is an alist of option pairs; defaults to `ollie-default-session-opts'. +Returns the new session ID." + (interactive) + (unless (file-directory-p ollie-mount-directory) + (user-error "ollie-9p not mounted at %s (customize `ollie-mount-directory')" + ollie-mount-directory)) + (let* ((effective-opts (or opts ollie-default-session-opts)) + (before (ollie--session-ids)) + (seen (make-hash-table :test 'equal)) + (cmd (concat "new" + (mapconcat (lambda (kv) + (format " %s=%s" (car kv) (cdr kv))) + effective-opts "")))) + (dolist (id before) (puthash id t seen)) + (ollie--write-root-ctl cmd) + (let ((deadline (+ (float-time) 5.0)) + result) + (while (and (not result) (< (float-time) deadline)) + (dolist (id (ollie--session-ids)) + (unless (gethash id seen) (setq result id))) + (unless result (sleep-for 0.05))) + (unless result + (user-error "Timed out waiting for a new ollie session")) + (setq ollie--session-id result) + (ollie--persist-session result) + (message "Created ollie session %s" result) + result))) + +(defun ollie-attach (id) + "Attach to an existing session by ID and make it current." + (interactive + (let ((ids (ollie--session-ids))) + (unless ids (user-error "No ollie sessions available")) + (list (completing-read "Attach to session: " ids nil t)))) + (unless (file-directory-p (expand-file-name id (ollie--sessions-dir))) + (user-error "Session not found: %s" id)) + (setq ollie--session-id id) + (ollie--persist-session id) + (message "Attached to ollie session %s" id)) + +(defun ollie-kill-session () + "Kill the current ollie session." + (interactive) + (unless ollie--session-id (user-error "No active session")) + (when (yes-or-no-p (format "Kill ollie session %s? " ollie--session-id)) + (ollie--write-root-ctl (concat "kill " ollie--session-id)) + (ollie--stop-watching) + (setq ollie--session-id nil + ollie--chat-size -1) + (when-let (buf (get-buffer ollie--buf)) + (with-current-buffer buf + (let ((inhibit-read-only t)) + (erase-buffer) + (insert "(session killed)\n\n") + (insert "Press 'n' or C-c C-n to start a new session.\n")))) + (message "Session killed"))) + +;;;; ──────────────── State ──────────────── + +(defun ollie--state () + "Return the current session state string, or \"unknown\" on any error." + (condition-case nil + (or (when-let (s (ollie--fread (ollie--session-file "state"))) + (string-trim s)) + "unknown") + (error "unknown"))) + +(defun ollie--idle-p () + "Return non-nil if the session is currently idle." + (ignore-errors (string= (ollie--state) "idle"))) + +;;;; ──────────────── Agent operations ──────────────── + +(defun ollie-submit (text) + "Submit TEXT as a prompt to the current session. +The server dispatches it asynchronously." + (when (string-empty-p (string-trim text)) + (user-error "Empty prompt")) + (ollie--fwrite (ollie--session-file "prompt") text)) + +(defun ollie-queue (text) + "Enqueue TEXT as a prompt to run after the current turn finishes." + (when (string-empty-p (string-trim text)) + (user-error "Empty prompt")) + (ollie--fwrite (ollie--session-file "enqueue") text)) + +(defun ollie-interrupt () + "Send a stop signal to interrupt the current turn." + (interactive) + (ollie--fwrite (ollie--session-file "ctl") "stop\n") + (message "ollie: interrupted")) + +;;;; ──────────────── Chat display ──────────────── + +;; We cache the mtime so we only re-read the file when it actually changes. +;; The mode-line state is updated on every refresh tick. + +(defvar-local ollie--mode-line-state "unknown" + "Cached session state string for the mode line.") + +(defun ollie--refresh () + "Refresh the chat buffer if the chat file has grown on disk. +Also updates the cached mode-line state." + (let* ((buf (get-buffer ollie--buf)) + (chat (condition-case nil (ollie--session-file "chat") (error nil))) + (attrs (and chat (file-attributes chat))) + (size (and attrs (file-attribute-size attrs)))) + (when buf + (with-current-buffer buf + (setq ollie--mode-line-state (ollie--state)) + ;; The chat log is append-only, so size increasing means new content. + (when (and size (> size ollie--chat-size)) + (setq ollie--chat-size size) + (let ((inhibit-read-only t) + (at-end (= (point) (point-max)))) + (erase-buffer) + (insert-file-contents chat) + (ansi-color-apply-on-region (point-min) (point-max)) + (when at-end (goto-char (point-max))))) + (force-mode-line-update))))) + +;;;; ──────────────── Polling ──────────────── + +;; inotify does not fire on 9P mounts, so we always use a timer. +;; The mtime check in `ollie--refresh' keeps this cheap. + +(defun ollie--start-watching () + "Start a periodic timer to tail the chat file." + (ollie--stop-watching) + (setq ollie--poll-timer + (run-with-timer ollie-poll-interval ollie-poll-interval #'ollie--refresh))) + +(defun ollie--stop-watching () + "Cancel the refresh timer." + (when ollie--poll-timer + (cancel-timer ollie--poll-timer) + (setq ollie--poll-timer nil))) + +;;;; ──────────────── Input buffer ──────────────── + +(defvar-local ollie--input-queued nil + "Non-nil when this input buffer will enqueue rather than submit.") + +(defvar ollie-input-mode-map + (let ((m (make-sparse-keymap))) + (define-key m (kbd "C-c C-c") #'ollie-input-submit) + (define-key m (kbd "C-c C-k") #'ollie-input-cancel) + m) + "Keymap for `ollie-input-mode'.") + +(define-derived-mode ollie-input-mode text-mode "Ollie-Input" + "Major mode for composing ollie prompts. +\\{ollie-input-mode-map}" + (setq-local header-line-format + '(:eval + (format " %s · C-c C-c submit · C-c C-k cancel" + (if ollie--input-queued + (propertize "Queue prompt" 'face 'warning) + (propertize "Send prompt" 'face 'success)))))) + +(defun ollie--open-input (&optional queued) + "Open the prompt input window below the current window. +QUEUED non-nil means the prompt will be enqueued, not submitted immediately." + (let ((buf (get-buffer-create ollie--ibuf))) + (with-current-buffer buf + (erase-buffer) + (ollie-input-mode) + (setq ollie--input-queued queued)) + (display-buffer buf + `(display-buffer-below-selected + (window-height . ,ollie-input-window-height))) + (when-let (win (get-buffer-window buf t)) + (select-window win)))) + +(defun ollie-input-submit () + "Submit the contents of the input buffer as a prompt." + (interactive) + (let ((text (buffer-string)) + (queued ollie--input-queued)) + (ollie--close-input-window) + (if queued + (ollie-queue text) + (ollie-submit text)))) + +(defun ollie-input-cancel () + "Discard the input and close the input window." + (interactive) + (ollie--close-input-window)) + +(defun ollie--close-input-window () + "Kill the input buffer and delete its window." + (when-let (win (get-buffer-window ollie--ibuf t)) + (delete-window win)) + (when-let (buf (get-buffer ollie--ibuf)) + (kill-buffer buf))) + +;;;; ──────────────── Chat major mode ──────────────── + +(defvar ollie-mode-map + (let ((m (make-sparse-keymap))) + (define-key m (kbd "RET") #'ollie-send-input) + (define-key m (kbd "C-c C-q") #'ollie-queue-input) + (define-key m (kbd "C-c C-c") #'ollie-interrupt) + (define-key m (kbd "C-c C-k") #'ollie-kill-session) + (define-key m (kbd "C-c C-n") #'ollie-new-and-open) + (define-key m (kbd "C-c C-a") #'ollie-attach-and-open) + (define-key m (kbd "n") #'ollie-new-and-open) + (define-key m (kbd "g") #'ollie-refresh) + m) + "Keymap for `ollie-mode'.") + +(defun ollie-send-input () + "Open the input window to compose and submit a prompt." + (interactive) + (ollie--open-input nil)) + +(defun ollie-queue-input () + "Open the input window to compose a queued prompt." + (interactive) + (ollie--open-input t)) + +(defun ollie-refresh () + "Force a refresh of the chat log from disk." + (interactive) + (setq ollie--chat-size -1) + (ollie--refresh)) + +(define-derived-mode ollie-mode special-mode "Ollie" + "Major mode for the ollie chat interface. + +Key bindings: +\\{ollie-mode-map}" + :group 'ollie + (buffer-disable-undo) + (setq-local mode-line-format + `("%e" + mode-line-front-space + (:eval + (propertize (format " ollie:%s " (or ollie--session-id "-")) + 'face 'mode-line-buffer-id)) + (:eval + (if (string= ollie--mode-line-state "idle") + (propertize "idle" 'face 'success) + (propertize ollie--mode-line-state 'face 'warning))) + " " + mode-line-end-spaces))) + +;;;; ──────────────── Entry points ──────────────── + +;;;###autoload +(defun ollie () + "Open the ollie chat interface. +Attaches to the last session if it still exists; otherwise creates a new one." + (interactive) + (unless (file-directory-p ollie-mount-directory) + (user-error "ollie-9p not mounted at %s (customize `ollie-mount-directory')" + ollie-mount-directory)) + (unless ollie--session-id + (if-let (last (ollie--recall-session)) + (progn + (setq ollie--session-id last) + (message "Attached to last ollie session %s" last)) + (ollie-new-session))) + (ollie--open-buf)) + +;;;###autoload +(defun ollie-new-and-open () + "Create a new ollie session and open the chat buffer." + (interactive) + (ollie-new-session) + (ollie--open-buf)) + +;;;###autoload +(defun ollie-attach-and-open (id) + "Attach to session ID and open the chat buffer." + (interactive + (let ((ids (ollie--session-ids))) + (unless ids (user-error "No ollie sessions available")) + (list (completing-read "Session: " ids nil t)))) + (ollie-attach id) + (ollie--open-buf)) + +(defun ollie--open-buf () + "Open or switch to the *ollie* chat buffer." + (let ((buf (get-buffer-create ollie--buf))) + (with-current-buffer buf + (unless (derived-mode-p 'ollie-mode) + (ollie-mode)) + (setq ollie--chat-size -1) + (ollie--refresh) + (ollie--start-watching)) + (pop-to-buffer buf))) + +(provide 'ollie) +;;; ollie.el ends here