Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
dc9a821
feat(ipc): add multi-client sync messages and output hold flag
artyomsv Sep 25, 2026
5ebbe34
feat(daemon): elect a size master among attached clients
artyomsv Sep 25, 2026
3a3a704
feat(daemon): apply pane resizes from the size master only
artyomsv Sep 25, 2026
7b7cd5b
fix(daemon): deliver attach replay and live output exactly once
artyomsv Sep 25, 2026
d11db90
fix(daemon): send an attach's master change to other clients only
artyomsv Sep 25, 2026
6553632
fix(daemon): pin the output hold's finish recheck and drain abort
artyomsv Sep 25, 2026
9567511
fix(daemon): send the attached-client count when it changes
artyomsv Sep 25, 2026
2a6ad62
feat(daemon): version tab layouts and broadcast accepted changes
artyomsv Sep 25, 2026
644c406
feat(daemon): target MCP focus and close at one client
artyomsv Sep 25, 2026
e928ce5
fix(daemon): dedupe defaultCWD probes and firm up MCP target tests
artyomsv Sep 25, 2026
9731a1c
feat(tui): follow the daemon's size master
artyomsv Sep 25, 2026
3880aa9
fix(tui): stop resizeAllPanes racing the size-master map
artyomsv Sep 25, 2026
c2440c5
feat(tui): render follower panes at the master's size
artyomsv Sep 25, 2026
ab2086e
test(tui): cover both follower cut markers at once
artyomsv Sep 25, 2026
2b58949
fix: number pane sizes so a stale broadcast cannot undo them
artyomsv Sep 25, 2026
b1ffd4e
feat(tui): sync tab layouts between clients by revision
artyomsv Sep 25, 2026
73573d3
fix(tui): keep a re-seated reservation through its adopting pass
artyomsv Sep 25, 2026
81c50de
fix(tui): drop a reservation once its placeholder is pruned
artyomsv Sep 25, 2026
e39e838
feat(tui): guard typing across a remote tab switch
artyomsv Sep 25, 2026
c98a4f1
fix(tui): correct four typing-guard review findings
artyomsv Sep 25, 2026
954ec4a
fix(tui): retire the create token on no-op broadcasts, reject stale f…
artyomsv Sep 25, 2026
ba74b75
docs: describe multi-client sync and the size master
artyomsv Sep 25, 2026
e322501
docs: fix SetTabLayout location and stale layout-sync claims
artyomsv Sep 25, 2026
5441512
fix(daemon): let a cold start take a restart-reserved master slot
artyomsv Sep 26, 2026
68e09b8
fix(tui): record a requested-tab token on every pane jump
artyomsv Sep 26, 2026
2601955
fix(tui): adopt the daemon's tab when a held stale-reject tab is gone
artyomsv Sep 26, 2026
89da314
test(daemon): probe the hold-begin park instead of sleeping
artyomsv Sep 26, 2026
f01322a
docs: correct four stale multi-client comments
artyomsv Sep 26, 2026
39871c7
docs: close the multi-client doc gaps from the branch review
artyomsv Sep 26, 2026
d442bc2
fix(tui): record a requested-tab token on the attention-queue jump
artyomsv Sep 26, 2026
9ac06fa
fix(daemon): only an attach with a client id clears the restart reserve
artyomsv Sep 26, 2026
6b10203
fix(tui): set reattach only when this process attached there before
artyomsv Sep 26, 2026
48d789a
docs: count 36 MCP tools in the MCP server roadmap page
artyomsv Sep 26, 2026
9d1aa9c
docs: state the attachedOnce and client-id rules for the restart reserve
artyomsv Sep 26, 2026
fe08807
chore(dev): stop the dev daemon and TUIs before a build
artyomsv Sep 26, 2026
6e4587c
docs: tidy auto-stop and client-id wording before the push
artyomsv Sep 26, 2026
1987b44
fix(daemon): publish pane output and broadcast it under one gate
artyomsv Sep 26, 2026
305026f
fix(tui): retire the typing guard on explicit local navigation
artyomsv Sep 26, 2026
aee41f8
fix(daemon): elect the size master on the raw window size at attach
artyomsv Sep 26, 2026
26d75d0
docs: correct the mouse-mode ordering note and a merged doc comment
artyomsv Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions .claude/CLAUDE.md

Large diffs are not rendered by default.

254 changes: 247 additions & 7 deletions .claude/rules/daemon-lifecycle.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .claude/rules/dev-environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Every code change in this repo follows the same loop:

1. Edit code (`internal/…`, `cmd/…`).
2. Rebuild: `./scripts/dev.sh build`.
3. If a dev daemon from a previous iteration is running, stop it by PID from `./.quil/quild.pid` (or manually via Task Manager / `kill`).
3. Nothing to do by hand: `./scripts/dev.sh build` (and `clean`) now stops this directory's dev daemon (gracefully, via `quil-dev daemon stop`) and any dev TUI first, matching `quil-dev`/`quild-dev` by full executable path. It never stops `quil`/`quil-debug` or their daemons. By design it also stops `quil-dev mcp` bridges from this folder (they hold `quil-dev.exe`) and any dev TUI you had open — that window may be left in mouse-tracking / alt-screen mode, so close it. Running the build from a pane hosted by this folder's dev daemon ends that pane's shell. The macOS branch is best-effort and untested. If the build still refuses, something else holds a binary here — stop a stray dev daemon by PID from `./.quil/quild.pid` only, never from `~/.quil/quild.pid`.
4. Launch: `./scripts/quil-dev.ps1` (Windows) or `./scripts/quil-dev.sh` (Unix).
5. Verify `[dev]` is visible in the status bar before testing.
6. Test the change. When done, close the dev TUI — do NOT run any `kill-daemon` / `reset-daemon` helper scripts.
Expand Down
2 changes: 1 addition & 1 deletion .claude/rules/projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ unnamed and its root IS the daemon's default, so writing it back is a no-op.

**Reachability is checked in the client, because the send cannot report it.**
`Router.Send` DROPS a message aimed at a dest it has no conn for, logs, and
returns nil — deliberately, so `resizeAllPanes`/`sendAllLayouts` cannot break
returns nil — deliberately, so `resizeAllPanes` (or `markLayoutChanged` for the changed tab) cannot break
mid-iteration. Every `if err := send(…)` in the dialog is therefore blind to the
likeliest failure of all. `destReachable` guards the whole `projectFormDest != ""`
branch rather than the fold alone, because a host that disconnects between the
Expand Down
2 changes: 1 addition & 1 deletion .claude/rules/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ No branch: one completed state frame. Branch: preparing, existing swap, complete

The template dialog routes all text/paste by `templateTextTarget`; selector rows swallow paste. Character input comes from `msg.Text`. The directory row is the shared `cwdBrowse*` browser, reset through `resetDirBrowseState` on open, Esc and a successful reply, and committed as `m.cwdBrowseDir` — never a typed field, so it is absent from `templateTextTarget`. Submission is blocked while `m.browse.pending`, or a create lands on the daemon default rather than the directory on screen. Enter creates from every row except the task editor (newline) and the directory row (descend); Ctrl+S creates from all four. Browsing and creation are stamped with the destination pinned on open. Only the requesting client's correlated response arms focus. Prompts are queued in listed order after all panes exist; substitution is single-pass. No prompt is delivered at all once any pane failed to be created: `{{panes}}` lists only panes that EXIST, so briefing a short roster starts a team without a worker it was told to use. The failed panes stay visible rather than rolling back a checkout that took minutes. A version number cannot gate a request type added on a branch: `dev.sh` stamps `VERSION` into every variant, dev included, so a locally built pair and a released daemon can report the same string. `VersionRespPayload.Requests` carries the gated types the daemon actually handles and `requireRequest` reads it; an absent list means "cannot say" and the version floor still decides there. Never re-derive this from the number in either direction — too strict refuses the daemon the client was built beside, too loose sends a released daemon a request it drops in silence. Codex output is not a reliable answer channel: request a file in prompts that need results.

The ordinary MCP server exposes 35 tools. `create_from_template` requires daemon 1.74.0; older project/tab/task tools retain 1.72.0. See `docs/workspace-templates.md` and `docs/mcp.md`.
The ordinary MCP server exposes 36 tools. `create_from_template` requires daemon 1.74.0; older project/tab/task tools retain 1.72.0. See `docs/workspace-templates.md` and `docs/mcp.md`.
246 changes: 243 additions & 3 deletions .claude/rules/tui-rendering.md

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions changelog.d/added-multi-client-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
headline: Two Quil windows can now share one daemon's workspace
---
- **Two or more Quil windows can attach to the same daemon and see one shared workspace** — the same projects, tabs, panes, names, colours and layout. A pane either window creates appears in both, and closing it in one closes it in the other.
- One window is the size master (the oldest attached, with a large enough terminal of its own) and sets pane sizes; the status bar shows `[master]` or `[follower]` while two or more windows are attached. A follower's panes are cropped to fit its own box rather than resized, with a `…` marker on the cut edge, and pad rather than crop when they are smaller than it. **Take control** (unbound by default — bind `client.take_control`, or run it from the command palette) makes the current window the master immediately. If the master's window closes normally the next one takes over at once; if its connection merely drops, its slot is held for a few minutes (`[daemon] master_grace_minutes`, default 3) so a following window is not resized out from under it.
- Splitting, closing, dragging a pane or a split border, and arranging a tab in one window is mirrored in every other attached window immediately, and survives closing and reopening both.
- Switching the shared active tab from one window no longer steals keystrokes out from under someone typing in another — a short guard keeps your next few keys in the pane you were in and shows a flash saying another client switched.
- Dismissing a notification, or clearing a pane's unseen mark, updates every attached window's sidebar.
- A new MCP tool, `list_clients`, lists every attached window; `set_active_pane` and `close_tui` gain an optional `client` field to target one window instead of whichever typed most recently.
- With a single window attached, two small costs remain: each layout change now costs one workspace-state frame back from the daemon (coalesced over 50 ms), and attaching can wait up to 2 s for a busy live-output queue to drain so each pane's history and live output arrive exactly once.
2 changes: 2 additions & 0 deletions cmd/quil/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,8 @@ const mcpInstructions = "Quil is a terminal multiplexer with projects, tabs and
"- Projects: list_projects, create_project, update_project, switch_project, destroy_project; tabs: create_tab, rename_tab, destroy_tab.\n" +
"- Hosts: list_hosts shows the remote daemons this bridge reaches. Ids you discovered route to their host automatically; " +
"pass host explicitly to create things on a remote. delegate_task with notify only works when requester and target share a host.\n" +
"- Several TUIs can share one daemon: list_clients shows them, and its client id targets set_active_pane or close_tui at " +
"one of them instead of the one that typed most recently.\n" +
"- Destructive tools (restart_pane, destroy_pane, destroy_tab, destroy_project, close_tui): always confirm with the user before using.\n" +
"- watch_notifications: blocks until an event fires on specified panes (replaces polling). Use after starting long-running tasks.\n" +
"- get_notifications: returns all pending notification events without blocking.\n" +
Expand Down
56 changes: 53 additions & 3 deletions cmd/quil/mcp_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func registerMCPTools(s *mcp.Server, r *mcpRouter, mcpLog *mcpLogger) {
registerDestroyPaneTool(s, r, mcpLog)
registerSetActivePaneTool(s, r, mcpLog)
registerCloseTUITool(s, r, mcpLog)
registerListClientsTool(s, r, mcpLog)
// Notification tools
registerGetNotificationsTool(s, r, mcpLog)
registerWatchNotificationsTool(s, r, mcpLog)
Expand Down Expand Up @@ -625,6 +626,7 @@ func registerDestroyPaneTool(s *mcp.Server, r *mcpRouter, mcpLog *mcpLogger) {
func registerSetActivePaneTool(s *mcp.Server, r *mcpRouter, mcpLog *mcpLogger) {
type Input struct {
PaneID string `json:"pane_id" jsonschema:"pane to focus (switches tab if needed)"`
Client string `json:"client,omitempty" jsonschema:"optional client id from list_clients; default: the client with the most recent input"`
Host string `json:"host,omitempty" jsonschema:"daemon host from list_hosts (empty = the host the id was discovered on, else local)"`
}

Expand All @@ -636,7 +638,7 @@ func registerSetActivePaneTool(s *mcp.Server, r *mcpRouter, mcpLog *mcpLogger) {
if err != nil {
return nil, nil, fmt.Errorf("set_active_pane: %w", err)
}
msg, err := ipc.NewMessage(ipc.MsgSetActivePane, ipc.SetActivePanePayload{PaneID: input.PaneID})
msg, err := ipc.NewMessage(ipc.MsgSetActivePane, ipc.SetActivePanePayload{PaneID: input.PaneID, Client: input.Client})
if err != nil {
return nil, nil, fmt.Errorf("set_active_pane: %w", err)
}
Expand All @@ -650,7 +652,8 @@ func registerSetActivePaneTool(s *mcp.Server, r *mcpRouter, mcpLog *mcpLogger) {

func registerCloseTUITool(s *mcp.Server, r *mcpRouter, mcpLog *mcpLogger) {
type Input struct {
Host string `json:"host,omitempty" jsonschema:"daemon host from list_hosts (default: local)"`
Client string `json:"client,omitempty" jsonschema:"optional client id from list_clients; default: the client with the most recent input"`
Host string `json:"host,omitempty" jsonschema:"daemon host from list_hosts (default: local)"`
}

mcp.AddTool(s, &mcp.Tool{
Expand All @@ -662,7 +665,7 @@ func registerCloseTUITool(s *mcp.Server, r *mcpRouter, mcpLog *mcpLogger) {
if err != nil {
return nil, nil, fmt.Errorf("close_tui: %w", err)
}
msg, err := ipc.NewMessage(ipc.MsgCloseTUI, nil)
msg, err := ipc.NewMessage(ipc.MsgCloseTUI, ipc.CloseTUIPayload{Client: input.Client})
if err != nil {
return nil, nil, fmt.Errorf("close_tui: %w", err)
}
Expand All @@ -673,6 +676,53 @@ func registerCloseTUITool(s *mcp.Server, r *mcpRouter, mcpLog *mcpLogger) {
})
}

// registerListClientsTool lists every ATTACHED client — every TUI sharing
// this daemon, never an MCP bridge, which is a connected conn but never
// attaches — so an agent can target one explicitly with set_active_pane or
// close_tui instead of landing on the implicit most-recently-active one.
func registerListClientsTool(s *mcp.Server, r *mcpRouter, mcpLog *mcpLogger) {
type Input struct {
Host string `json:"host,omitempty" jsonschema:"limit to one host (default: every connected host)"`
}
type hostedClient struct {
ipc.ClientInfo
Host string `json:"host,omitempty"`
}

mcp.AddTool(s, &mcp.Tool{
Name: "list_clients",
Description: "List every attached TUI client: id, attach time, window size, whether it holds size master " +
"(the client whose geometry sizes every pane), and its last input time. Pass a client id to set_active_pane " +
"or close_tui to target that client instead of the one that typed most recently.",
}, func(_ context.Context, _ *mcp.CallToolRequest, input Input) (*mcp.CallToolResult, any, error) {
var out []hostedClient
err := r.forEachHost(input.Host, func(hb hostBridge) error {
if err := hb.bridge.requireRequest("list_clients", ipc.MsgListClientsReq, listClientsMinVersion); err != nil {
return err
}
resp, err := hb.bridge.request(ipc.MsgListClientsReq, nil)
if err != nil {
return fmt.Errorf("list_clients%s: %w", hostSuffix(hb.host), err)
}
var payload ipc.ListClientsRespPayload
if err := resp.DecodePayload(&payload); err != nil {
return fmt.Errorf("list_clients decode: %w", err)
}
for _, c := range payload.Clients {
out = append(out, hostedClient{ClientInfo: c, Host: hb.host})
}
return nil
})
if err != nil {
return nil, nil, fmt.Errorf("list_clients: %w", err)
}
if out == nil {
out = []hostedClient{}
}
return jsonResult(out), nil, nil
})
}

// Notification tools

// hostedEvent is a PaneEventPayload plus the host it came from, which
Expand Down
10 changes: 7 additions & 3 deletions cmd/quil/mcp_tools_router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ func newFakeIPCDaemonVersion(t *testing.T, paneID, version string) *fakeIPCDaemo
resp, _ = ipc.NewMessage(ipc.MsgDelegateTaskResp, ipc.DelegateTaskRespPayload{Task: ipc.TaskInfo{ID: "task-1", ToPane: req.ToPane, FromPane: req.FromPane, State: "sent"}})
case ipc.MsgListProjectsReq:
resp, _ = ipc.NewMessage(ipc.MsgListProjectsResp, ipc.ListProjectsRespPayload{Projects: []ipc.ProjectInfo{{ID: "proj-" + f.paneID, Name: f.paneID}}})
case ipc.MsgListClientsReq:
resp, _ = ipc.NewMessage(ipc.MsgListClientsResp, ipc.ListClientsRespPayload{Clients: []ipc.ClientInfo{
{Client: "tui-" + f.paneID, AttachedAt: "2026-01-01T00:00:00Z", Cols: 200, Rows: 50, Master: true},
}})
case ipc.MsgCreateFromTemplateReq:
var req ipc.CreateFromTemplateReqPayload
if err := m.DecodePayload(&req); err != nil {
Expand Down Expand Up @@ -309,7 +313,7 @@ func TestCreatePaneSchema_ExposesDialogOptions(t *testing.T) {
}
for _, want := range []string{"create_pane", "create_tab", "create_from_template", "list_projects", "create_project", "update_project", "destroy_project",
"switch_project", "rename_tab", "destroy_tab", "rename_pane", "list_plugins", "list_sessions", "list_hosts",
"delegate_task", "get_task", "wait_task", "list_tasks"} {
"delegate_task", "get_task", "wait_task", "list_tasks", "list_clients"} {
if byName[want] == nil {
t.Errorf("tool %s not registered", want)
}
Expand All @@ -320,7 +324,7 @@ func TestCreatePaneSchema_ExposesDialogOptions(t *testing.T) {
t.Errorf("create_pane schema lacks %s:\n%s", prop, schema)
}
}
if len(tools.Tools) != 35 {
t.Errorf("tool count = %d, want 35 (update docs/mcp.md and CLAUDE.md if this changed on purpose)", len(tools.Tools))
if len(tools.Tools) != 36 {
t.Errorf("tool count = %d, want 36 (update docs/mcp.md and CLAUDE.md if this changed on purpose)", len(tools.Tools))
}
}
Loading
Loading