Skip to content

About

Emacs side conversations for agent-shell: fork your AI coding session to ask a quick question without derailing it. Modelled on Codex's /side. Experimental.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

agent-shell-side

Ask a quick question without derailing the conversation you are in.

M-x agent-shell-side forks the current agent-shell session into a second shell whose inherited history is demoted to reference material. The agent can still read everything that came before, so the question needs no re-explaining, but it is told not to continue the parent's task, not to touch sub-agents, and not to change anything unless this conversation asks.

Modelled on the /side command in OpenAI's Codex TUI.

Install

Needs agent-shell 0.74.3 or newer and acp 0.13.1 or newer.

(use-package agent-shell-side
  :after agent-shell
  :bind (:map agent-shell-mode-map
         ("C-c C-b" . agent-shell-side)))

Once a side conversation exists, agent-shell-side-mode turns on in both buffers and binds its own keys, so the binding above is only needed to start one.

A side conversation's buffer name ends in agent-shell-side-buffer-name-suffix ( [side]) whatever your agent-shell-buffer-name-format does with the agent name, so it can be told apart in buffer lists.

Commands

Command Key (in linked buffers) What it does
agent-shell-side Fork this conversation into a side one and switch to it
agent-shell-side-toggle C-c C-b Move between the side conversation and its parent
agent-shell-side-conclude C-c C-q Summarise the side conversation into the parent, then close it
agent-shell-side-send-region C-c C-r Quote the region into the other end's compose buffer; with a prefix argument, pick any open side conversation
agent-shell-side-dismiss C-c C-k Close the side conversation, discarding what it learned
agent-shell-side-resume Reopen a side conversation that was kept; with a prefix argument, from any conversation
agent-shell-side-list Switch to one of the side conversations that are open
agent-shell-side-describe Echo what this buffer is and how to leave it

Asking about part of an answer

Select the passage in the parent conversation, then run agent-shell-side. The side conversation opens with the selection quoted as a markdown block quote in its compose buffer and point on the line below, so you write your question under exactly the part you meant. Nothing is sent until you send it, with the compose buffer's own keys: send, queue, or steer, as for any prompt. Because it is a compose buffer, you can paste images into the question too: agent-shell's agent-shell-yank-dwim inserts an @path mention, which goes out as an image. With no selection, the side conversation opens blank.

If something stops the side conversation from starting, such as an agent that cannot fork, it refuses before anything happens and the selection stays active. Sending before the fork is ready is refused with "Starting agent, please wait", and your draft is kept. Backing out of the compose buffer without sending leaves a blank side conversation open; close it with agent-shell-side-dismiss.

Once a side conversation is open, agent-shell-side-send-region (C-c C-r) quotes another passage into it. The quote lands in the side's compose buffer, after any draft, with point below it for your question, and nothing is sent until you send it. Run from the side conversation, it quotes into the parent instead. From a shell with no side conversation open, it starts one, as agent-shell-side does.

From a buffer that is not a conversation, such as a source file, it goes to the side conversation that is open, asking which when there are several. A shell links to one side conversation at a time, so with a prefix argument it asks too, offering every open one. The side keys are off in viewport buffers, so from a viewport run it with M-x.

Carrying findings back

agent-shell-side-conclude (C-c C-q) asks the side conversation for a summary addressed to the parent, waits for it, seeds it into a compose buffer for the parent, and closes the side conversation.

The summary is never sent for you. It arrives as a draft you can edit, and the compose buffer's own keys decide what happens to it: C-c C-c sends, C-c C-q queues it behind the parent's running turn, C-c C-i steers it into that turn, and C-c C-k throws it away. That is the same set of choices you have for any prompt, which is the point.

This is one path, not two. It does not depend on agent-shell-prefer-viewport-interaction: that setting decides where your own prompts go and has no business deciding what happens to findings. A compose buffer is also the only surface that works while the parent is mid-turn, since a busy shell cannot take text at its prompt. shell-maker appends output at the end of the buffer and would swallow it.

The draft comes to you. Summarising takes as long as the agent takes, so the compose buffer opens and is selected whenever the summary lands, which may be after you have moved on to something else. If the compose buffer already held an unsent draft of your own, the findings are appended below it and your draft's queue-or-steer choice is kept.

If the summary comes back empty, or the parent is gone, the side conversation is left open rather than closed on nothing. Customize agent-shell-side-handback-prompt to change what is asked for.

While you are in the side conversation, the parent keeps running. When it finishes, fails, or asks for approval, the side conversation's mode line says so ( Side:main needs approval) and, while the side conversation is on screen, echoes it once. Set agent-shell-side-report-parent-status to nil for the mode line only.

Where it opens

A side conversation runs beside the conversation it was forked from, so it opens in a window of its own on the right and the parent stays visible. That is the arrangement a terminal cannot offer, and the reason agent-shell-side-display-action exists separately from agent-shell-display-action, which is left to decide where ordinary shells go.

agent-shell-side-toggle (C-c C-b) then moves point between the two windows rather than rearranging them. Closing the side conversation deletes its window.

To get the old behaviour, where the side conversation takes over the parent's window and toggling swaps the two, set it to same-window:

(setq agent-shell-side-display-action '(display-buffer-same-window))

Everything follows from the action: toggling still selects the other window when both happen to be visible, and falls back to displaying through the action when they are not. An action that opens an ordinary window rather than a side window works too: closing frees that window when the parent is already on screen, and gives it to the parent otherwise.

Under viewport interaction, toggling to a conversation that is off screen shows its last exchange rather than an empty compose buffer. agent-shell's viewport show is its compose path — with the shell idle it opens the compose buffer — and toggling is reading, not typing. Starting a side conversation still composes: there is nothing to read yet.

A compose draft in progress is never disturbed by displaying its conversation: toggling to a parent or side whose compose buffer holds text shows that buffer as it stands, rather than re-entering the viewport, which while the shell is busy would flip it to view mode and lose the draft. A draft the viewport put aside to show history is left alone for the same reason: the show puts it back, and switching to view mode afterwards would wipe it with nothing left to restore.

If your layout lives in perspectives, tabs, or workspaces, add a function to agent-shell-side-before-display-functions. It is called with the shell buffer about to be shown, before it is shown, so you can switch to where that buffer belongs. A window the switch brings on screen is then selected rather than the buffer being displayed a second time.

A display-buffer-alist entry that matches the buffer wins over any action a package passes, so if you route buffers through one, that entry decides. To give side conversations an entry of their own, use agent-shell-side-buffer-p as its condition. It accepts the buffer name those conditions receive, and recognizes a side conversation's viewport buffer as well as its shell:

(add-to-list 'display-buffer-alist
             '(agent-shell-side-buffer-p
               (display-buffer-in-side-window)
               (side . right) (window-width . 0.4)))

Keeping track of what is open

A side conversation lives until you close it, so they collect quietly. agent-shell-side-list offers the ones still open for completion, each labelled with what it was forked from, how its parent is doing, and how long it has been sitting there. Picking one switches to it.

It covers every side conversation in this Emacs, across parents and projects, because that is the scope the problem has. The ones whose parent is already gone are the easiest to forget, and they are listed too.

Nothing is closed for you. Codex discards its side thread when you navigate to a third thread; that does not port to Emacs, where switching buffers is constant and auto-discard would throw away work mid-thought. An idle reaper would do the same on a timer. So this reports and leaves the decision to you.

Reading the link from another package

agent-shell-side-parent-buffer and agent-shell-side-children are the public accessors for the link between a side conversation and its parent, for a package that wants to draw the two together rather than as unrelated sessions — a sidebar nesting a side under its parent, say. agent-shell-side-parent-buffer answers nil once the parent it named is dead, so a caller never needs agent-shell-side-buffer-p first or has to worry about which package's kill-buffer-hook ran first.

How the restriction works

Two channels carry the side instructions, because no single one reaches every agent:

  1. A boundary block prepended to the first session/prompt. An ordinary content block, so it works with any agent. This is the channel that actually carries the policy.
  2. _meta.systemPrompt.append on the fork request, restating the policy at system level. Honored by claude-agent-acp; other adapters ignore unknown _meta keys, which is what _meta is for. An append your own agent config already carries is kept ahead of the side text, as Codex keeps its existing developer instructions: that is your configuration, not a convention of the parent conversation.

Neither is a sandbox. Like Codex's version, the restriction is instruction text the agent is asked to follow. The full text is in agent-shell-side-boundary-prompt and agent-shell-side-instructions.

Both texts cancel the parent's standing conventions as well as its task. That clause is not decoration: against a live claude-agent-acp, a boundary without it left the fork still obeying an "end every reply with X" rule set in the parent. The model correctly declined to continue the parent's task, but read a formatting rule as a persistent convention rather than an instruction the boundary had cancelled. With the clause, the same probe came back clean.

The inherited turns are hidden from the display, not from the model: session/fork does not replay history, so the side buffer starts empty while the agent keeps the whole transcript.

How the side is named

The buffer name carries agent-shell-side-buffer-name-suffix ( [side] by default), so a side conversation is recognisable in a buffer list.

The session title is a separate thing: it is what the agent stores, what session/list reports, and what claude --resume shows. Left alone, an agent names a fork after the conversation it was forked from — claude-agent-acp's SDK derives <parent title> (fork) and never revisits it, which says nothing about what this conversation is for. So the fork request carries two more _meta keys:

  • _meta.sessionTitle — the first line of the question the side opens with, used as its title straight away. Only agent-shell-side called from Lisp with a message has one; started interactively, the side has no question yet, so the key is left out and the side carries <parent title> (fork) until its first turn ends.
  • _meta.generateSessionTitle — asks the agent to title the side after its own turns once it has taken one. What you opened with is what you asked, not what the conversation turned out to be about.

Both are claude-agent-acp's, documented in its docs/fork-title-extension.md; an agent that does not know them names the fork its own way. Neither marks the title as a side conversation: a generated title would not carry a marker, and the buffer name already does.

Agent support

Requires an agent that advertises the ACP session/fork capability.

Adapter session/fork session/delete _meta.systemPrompt _meta.sessionTitle
claude-agent-acp yes yes yes yes
pi-acp yes yes no no
codex-acp no yes no no

An agent without session/fork is refused with a message naming it. This refusal is not cosmetic: when a fork is requested from an agent that cannot do it, agent-shell quietly starts a new, empty session instead, and a side conversation that silently lost its inherited history is worse than an error.

What happens to the forked session

ACP has no ephemeral session, so a fork always leaves one behind in the agent's own store. agent-shell-side-on-dismiss decides what to do when you close one:

  • delete (the default) — ask the agent to drop it (session/delete). A side conversation is meant to be ephemeral, as Codex's is, and a question on every close is friction on the common path.
  • keep — leave it, and record it in agent-shell-side-links-file so agent-shell-side-resume can find it again.
  • ask — ask each time.

A kept session is recorded as which fork belonged to which parent, with the agent identifier, the parent config's name, and the working directory. The identifier matters: resuming rebuilds the side config from it so the side instructions are re-sent. Agents read _meta.systemPrompt again on resume and would otherwise drop the side policy.

The config is found by agent-shell-side-resolve-config-function. The default looks in agent-shell-agent-configs for the identifier, takes a single match, prefers the one whose name the record carries when there are several, and asks otherwise, since several configs for one agent are usually profiles of it. If your configs are built on demand or live elsewhere, set the function to one that maps a record to a config.

Records also carry a boundary version. A record written under older instruction text is still resumable, but says so, since both texts then apply to that conversation.

A resumed side conversation is a side conversation again, with the same keys, lighter, and place in agent-shell-side-list, but it stands on its own: nothing records which buffer its parent was, so there is nothing to toggle to or hand findings back to. Close the resumed one with keep and it is recorded afresh.

The record is consumed once the session actually comes back, so one that resumes is not offered twice. One that does not is kept: if the agent rejects the load, the record stays so you can try again.

With agent-shell-prefer-viewport-interaction set, or when started from a viewport buffer, the side conversation opens through a viewport as agent-shell-fork would. agent-shell-side-display-action decides where that viewport goes, and toggling and closing treat a visible viewport as the conversation being on screen.

The keys above are deliberately not bound in a viewport compose buffer. agent-shell-viewport-edit-mode already uses C-c C-k to discard the draft and C-c C-q to queue it, and a minor mode would outrank both, so cancelling a draft would delete a forked session instead. The commands still work there by name, under M-x, and you can bind them to keys of your own.

agent-shell internals

Three things this package needs are not in agent-shell's public API: starting a shell that forks a session with a caller-supplied config, reading whether the agent advertised session/fork, and reading the current session id. Two more are borrowed for the user's sake: the prompt queue, so findings can reach a busy parent, and the viewport, so a side conversation opens where a viewport user looks. All of them live in agent-shell-side-compat.el so an upstream change breaks one file. Each shim checks what it needs and names the missing piece rather than failing deeper in.

Everything else uses public API: agent-shell-start, agent-shell-get-config, agent-shell-shell-buffer, agent-shell-subscribe-to, agent-shell-status, agent-shell-interrupt, and agent-shell-agent-configs.

What has been tested live

Against claude-agent-acp 0.70, end to end:

  • session/fork returns a new session id, and the fork answers a question that only the parent's history could answer, so history is inherited
  • the boundary block arrives as a second content block on the first prompt
  • the fork describes itself as a side conversation and declines to continue the parent's task
  • a standing formatting instruction from the parent does not leak (it did before the standing-conventions clause; see above)
  • session/delete succeeds

make live-check covers the rest. It last passed 10 of 10 against claude-agent-acp 0.70, before the handback moved to a compose buffer:

  • the handback summarises, reaches the parent's compose buffer as an editable draft rather than being sent, and closes the side conversation
  • a handback whose parent is killed mid-summary leaves the side conversation open rather than discarding it
  • a kept session resumes and still answers from the parent's history
  • forking mid-turn works. Asked while the parent was in the middle of a slow task, the fork answered "this side conversation has no task in progress; the .el file review belongs to the parent conversation". It inherits the history and does not adopt the unfinished turn, so no extra instruction text is needed for it.

One limit the live run found: a conversation that has not taken a turn cannot be forked. claude-agent-acp answers session/fork with -32002 Resource not found, because there is no transcript to fork yet. Having a session id is not enough — session/new hands one out before anything is said.

agent-shell-side refuses that up front, as Codex does, and asks you to send a message first. The test is whether the conversation holds a completed exchange, or was resumed by id: a resumed session replays nothing into its buffer, so it reads as empty while the session behind it is full, and forking one works. Should a fork fail anyway, the side conversation is closed with the agent's own error rather than left as a shell whose input goes nowhere.

Development

make check        # byte compile, then run the unit tests
make live-check   # drive a real agent (see below)

Unit tests run against stubs in tests/support, so no agent process is started.

make live-check is separate on purpose. It starts a real claude-agent-acp, spends real API tokens, and waits on model output that is not deterministic, so it has no place in a suite you run on every edit. Run it when the adapter or ACP version changes, and before tagging a release. It expects the real stack next door:

make live-check AGENT_SHELL_DIR=... ACP_DIR=... SHELL_MAKER_DIR=...

The probes cover history inheritance, the standing-conventions clause, the handback (including a parent killed mid-summary), resuming a kept session, and forking while the parent's turn is in flight. Permissions are answered by a read-only auto-approver, so an unattended run cannot let the agent change the checkout it is running in; a probe needing more is left to time out rather than approved.

License

GPL-3.0-or-later.

About

Emacs side conversations for agent-shell: fork your AI coding session to ask a quick question without derailing it. Modelled on Codex's /side. Experimental.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages