Initial commit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-11 13:48:45 +02:00
commit 3988d74d6d
2 changed files with 531 additions and 0 deletions

89
README.md Normal file
View File

@ -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

442
ollie.el Normal file
View File

@ -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