moshcode 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -125,37 +125,81 @@ moshcode attach api # step in; Ctrl-b d steps back out
125
125
  moshcode kill api # end it
126
126
  ```
127
127
 
128
- ### The clickable list
128
+ ### Everything on screen at once
129
129
 
130
130
  ```sh
131
- moshcode herd ui
131
+ moshcode herd tile
132
132
  ```
133
133
 
134
134
  ```
135
- moshcode herd 3 members 1 waiting on you
135
+ ┌─ work ──────────────────┬─ logs ──────────────────┐
136
+ │ $ npm test │ tailing deploy.log │
137
+ │ ✓ 1492 passing │ 12:04 build ok │
138
+ ├─ api ───────────────────┴─────────────────────────┤
139
+ │ claude — Do you want to proceed? │
140
+ │ ❯ 1. Yes 2. No │
141
+ └───────────────────────────────────────────────────┘
142
+ herd S:shell A:agent X:stop B:pop out z:zoom
143
+ ```
144
+
145
+ Every member becomes a tile in one window. Click a tile to focus it, `Ctrl-b z`
146
+ to blow it up full-screen and again to come back, `Ctrl-b d` to leave the lot
147
+ running. Start and stop without leaving:
136
148
 
137
- MAIN (2)
138
- ▸ ! api claude blocked ~/src/coinpay
139
- · work shell idle ~/src/coinpay
149
+ | key | |
150
+ |---|---|
151
+ | `Ctrl-b S` | new shell tile |
152
+ | `Ctrl-b A` | new claude tile |
153
+ | `Ctrl-b X` | stop the focused tile |
154
+ | `Ctrl-b B` | pop it out into its own session |
155
+ | `Ctrl-b z` | zoom / unzoom |
140
156
 
141
- SCRATCH (1)
142
- · logs shell idle ~/src/ugig.net
157
+ `moshcode herd untile` puts them all back in their own sessions. Tiling is just
158
+ a view — the processes never restart, and a tiled member stays on `moshcode ps`
159
+ and answers `read`, `prompt` and `wait` exactly as before.
143
160
 
144
- click or ↑↓ to choose · enter to go in · r refresh · q quit
145
- ```
161
+ Needs tmux. The `script(1)` fallback gives each session its own pty with no way
162
+ to lay them out together, so it says so and points at the list instead.
146
163
 
147
- Click a member to go into it; detach and you land back on the list. Group
148
- sessions with `--herd <name>` when you start them; anything without one is in
149
- `main`.
164
+ ### The workspace
150
165
 
151
- It hands the terminal to a **real** attach rather than drawing the session
152
- itself, so what you get inside is a genuine terminal — full colour, real cursor,
153
- the agent's own mouse handling. The cost is that the list is not on screen at
154
- the same time as the session. tmux's model is session → window → pane and a pane
155
- belongs to exactly one window, so nothing can persist across a switch; a pinned
156
- sidebar would mean rendering every session from polled snapshots and losing all
157
- of the above. Once you are attached, `Ctrl-b s` is a clickable picker and the
158
- status line is a clickable list.
166
+ ```sh
167
+ moshcode herd ui
168
+ ```
169
+
170
+ ```
171
+ herd ──────┬─ api ─────────────────────────────┐
172
+ herd │ │
173
+ │ │ claude │
174
+ │ MAIN │ Do you want to proceed? │
175
+ │ ▸ ! api │ ❯ 1. Yes │
176
+ │ · work │ 2. No │
177
+ │ SCRATCH │ │
178
+ │ · logs │ │
179
+ │ │ │
180
+ │ ACTIONS │ │
181
+ │ + shell │ │
182
+ │ + agent │ │
183
+ │ ✕ stop │ │
184
+ │ ⊞ tile │ │
185
+ │ ← detach │ │
186
+ └────────────┴───────────────────────────────────┘
187
+ ```
188
+
189
+ Members and actions down the left, the selected member's **real terminal** on
190
+ the right. Click a member to show it; click an action to start a shell, start an
191
+ agent, or stop the selected one. `q` detaches and leaves everything running.
192
+
193
+ The right-hand pane is not a picture of a session — it *is* the session's pane,
194
+ moved in. tmux's model is session → window → pane, so moving between *windows*
195
+ cannot keep anything on screen; but `join-pane` moves a running pane into an
196
+ existing window, so swapping only the content pane leaves the sidebar untouched.
197
+ Both panes keep their process and their scrollback because tmux is moving the
198
+ real thing, not redrawing it.
199
+
200
+ Group sessions with `--herd <name>` when you start them; anything without one is
201
+ in `main`. Without tmux there is nothing to swap panes with, so `herd ui` falls
202
+ back to a plain clickable list.
159
203
 
160
204
  ### A workspace: a few shells and an agent
161
205
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "type": "module",
5
5
  "description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
6
6
  "repository": {
@@ -738,9 +738,16 @@ export const HERD_VERBS = [
738
738
  ["--agent", "autonomous mode — bypasses the engine's approvals", ""],
739
739
  ["--json", "machine-readable", ""],
740
740
  ] },
741
- { name: "ui", description: "the clickable list of herds and their members",
742
- synopsis: [["moshcode herd ui", "click or ↑↓ to choose, enter to go in, q to quit"]],
743
- flags: [] },
741
+ { name: "tile", description: "every member on screen at once, in a tiled window",
742
+ synopsis: [["moshcode herd tile [herd]", "click a tile to focus · Ctrl-b z zooms · Ctrl-b d leaves them running"]],
743
+ flags: [],
744
+ examples: [["moshcode herd tile", "all of them"], ["moshcode herd tile scratch", "one herd"]] },
745
+ { name: "untile", description: "put tiled members back in their own sessions",
746
+ synopsis: [["moshcode herd untile", ""]] },
747
+ { name: "ui", description: "sidebar of members and actions, selected one beside it",
748
+ synopsis: [["moshcode herd ui", "click a member to show it · s shell · a agent · x stop · q detach"]],
749
+ flags: [],
750
+ examples: [["moshcode herd ui", "the workspace — start here"]] },
744
751
  { name: "run", description: "run ANY command in the herd — an agent moshcode does not ship, a build, a script",
745
752
  synopsis: [["moshcode herd run [--name <slug>] -- <command…>", "everything after -- is the command"]],
746
753
  flags: [
package/src/herd-cli.mjs CHANGED
@@ -725,7 +725,12 @@ export function herdStop(argv, { write = console.log } = {}) {
725
725
  const VERBS = {
726
726
  // Lazy import: the UI pulls in escape-sequence machinery and only matters
727
727
  // when someone asks for it, and herd-ui imports roster() from this file.
728
- ui: async (argv, options) => (await import("./herd-ui.mjs")).herdUi(options),
728
+ // `ui` is the sidebar workspace; the old modal list lives on as the fallback
729
+ // inside it for machines with no tmux to swap panes on.
730
+ ui: async (argv, options) => (await import("./herd-workspace.mjs")).herdUi(argv, options),
731
+ sidebar: async (argv, options) => (await import("./herd-workspace.mjs")).herdSidebar(options),
732
+ tile: async (argv, options) => (await import("./herd-tile.mjs")).herdTile(argv, options),
733
+ untile: async (argv, options) => (await import("./herd-tile.mjs")).herdUntile(argv, options),
729
734
  ps: herdPs, list: herdPs, status: herdStatus,
730
735
  start: herdStart, run: herdRun, shell: herdShell,
731
736
  attach: herdAttach, kill: herdKill, prune: herdPrune,
@@ -0,0 +1,168 @@
1
+ // `moshcode herd tile` — every member on screen at once, in a tiled window.
2
+ //
3
+ // The clickable list was modal: you saw the herd OR a session, never both, and
4
+ // nothing on it could start or stop anything. This is the other shape — all
5
+ // members visible, click one to focus it, Ctrl-b z to blow it up, keys to start
6
+ // and stop without leaving.
7
+ //
8
+ // It is tmux doing the work, not us. `join-pane` moves a running pane out of
9
+ // its own session and into a shared window with its content and process intact;
10
+ // `select-layout tiled` arranges them; `break-pane` puts one back. Every pane
11
+ // stays a real terminal the whole time, which is the thing a hand-rolled
12
+ // side-by-side renderer cannot promise.
13
+ //
14
+ // This is why a member is identified by its pane title rather than its session
15
+ // (see paneIndex in herd.mjs): a tiled member's session no longer exists, and
16
+ // the roster, `read`, `prompt` and `wait` all have to keep working anyway.
17
+ import { spawn, spawnSync } from "node:child_process";
18
+
19
+ import { HERD_SOCKET, detectSubstrate, paneIndex, readManifest, tmux } from "./herd.mjs";
20
+ import { acid, ash, err, info, ok } from "./ui.mjs";
21
+
22
+ /** The window every tiled member is gathered into. */
23
+ export const TILE_SESSION = "tile";
24
+
25
+ /**
26
+ * Keys bound inside the tiled window.
27
+ *
28
+ * Bound on moshcode's own server, which starts from no config, so this
29
+ * overrides nobody's preference. They are deliberately shifted letters: tmux's
30
+ * own lowercase bindings (z zoom, x kill, arrows, o next) still work, and this
31
+ * only adds what tmux has no opinion about.
32
+ */
33
+ export function tileBindings({ moshcode = "moshcode" } = {}) {
34
+ return [
35
+ // Start: a shell, or an agent, as a new tile in the same directory.
36
+ ["S", `split-window -c "#{pane_current_path}" ; select-layout tiled`],
37
+ ["A", `split-window -c "#{pane_current_path}" ${moshcode} agents claude ; select-layout tiled`],
38
+ // Stop, without the confirm-before-kill prompt tmux puts on lowercase x —
39
+ // this is a tile you are looking at, not a blind target.
40
+ ["X", "kill-pane ; select-layout tiled"],
41
+ // Send the focused member back to a session of its own.
42
+ ["B", "break-pane"],
43
+ // Re-tile after any manual resize.
44
+ ["T", "select-layout tiled"],
45
+ ];
46
+ }
47
+
48
+ const STATUS_LEFT = ` ${" "}#[bold]herd#[default] `;
49
+ const STATUS_RIGHT = " S:shell A:agent X:stop B:pop out z:zoom T:re-tile ";
50
+
51
+ /**
52
+ * Gather every live member into one tiled window and attach to it.
53
+ *
54
+ * Members already in the tile window are left alone, so running this twice is
55
+ * not destructive and picks up anything started since.
56
+ */
57
+ export async function herdTile(argv = [], { write = console.log, spawner = spawn, runner = spawnSync } = {}) {
58
+ const substrate = detectSubstrate();
59
+ if (substrate !== "tmux") {
60
+ write(err("tiling needs tmux."));
61
+ write(info(substrate === "pty"
62
+ ? "the script(1) fallback has one pty per session and no way to lay them out together — `moshcode herd ui` still works."
63
+ : "install tmux, or use `moshcode ps`."));
64
+ return 1;
65
+ }
66
+
67
+ const wanted = argv.find((a) => !a.startsWith("-")) || null;
68
+ const manifest = readManifest();
69
+ const panes = paneIndex({ runner });
70
+ const members = [...panes.entries()]
71
+ .filter(([name]) => name in manifest.sessions)
72
+ .filter(([name]) => !wanted || (manifest.sessions[name].herd || "main") === wanted);
73
+
74
+ if (!members.length) {
75
+ write(info("nothing to tile — start something with `moshcode herd shell` or `moshcode agents claude -d`."));
76
+ return 0;
77
+ }
78
+
79
+ // The window everything lands in. Created from the first member rather than
80
+ // as an empty shell, so the tile has no spare pane sitting in it doing
81
+ // nothing. If it already exists we just join into it.
82
+ const existing = tmux(["has-session", "-t", TILE_SESSION], { runner });
83
+ if (!existing.ok) {
84
+ const [firstName, first] = members[0];
85
+ const made = tmux(["new-session", "-d", "-s", TILE_SESSION, "-n", "herd"], { runner });
86
+ if (!made.ok) { write(err(made.stderr.trim() || "could not create the tile window")); return 1; }
87
+ tmux(["join-pane", "-s", first.paneId, "-t", `${TILE_SESSION}:herd`], { runner });
88
+ // new-session made a placeholder pane; the first member replaced nothing,
89
+ // so drop the placeholder now that there is something real beside it.
90
+ const inWindow = tmux(["list-panes", "-t", `${TILE_SESSION}:herd`, "-F", "#{pane_id}\t#{pane_title}"], { runner });
91
+ for (const line of inWindow.stdout.split("\n")) {
92
+ const [paneId, title] = line.split("\t");
93
+ if (paneId && title !== firstName && !manifest.sessions[title]) {
94
+ tmux(["kill-pane", "-t", paneId], { runner });
95
+ }
96
+ }
97
+ }
98
+
99
+ let joined = 0;
100
+ for (const [, pane] of members) {
101
+ if (pane.session === TILE_SESSION) continue; // already on the layout
102
+ const r = tmux(["join-pane", "-s", pane.paneId, "-t", `${TILE_SESSION}:herd`], { runner });
103
+ if (r.ok) joined++;
104
+ }
105
+
106
+ for (const [key, command] of tileBindings()) {
107
+ tmux(["bind-key", "-T", "prefix", key, ...command.split(" ")], { runner });
108
+ }
109
+ tmux(["set-option", "-t", TILE_SESSION, "mouse", "on"], { runner });
110
+ tmux(["set-option", "-t", TILE_SESSION, "pane-border-status", "top"], { runner });
111
+ // The border carries the member's name, so a tiled screen is readable without
112
+ // a legend anywhere else.
113
+ tmux(["set-option", "-t", TILE_SESSION, "pane-border-format", " #{pane_title} "], { runner });
114
+ tmux(["set-option", "-t", TILE_SESSION, "status-left-length", "20"], { runner });
115
+ tmux(["set-option", "-t", TILE_SESSION, "status-right-length", "80"], { runner });
116
+ tmux(["set-option", "-t", TILE_SESSION, "status-left", STATUS_LEFT], { runner });
117
+ tmux(["set-option", "-t", TILE_SESSION, "status-right", STATUS_RIGHT], { runner });
118
+ tmux(["select-layout", "-t", `${TILE_SESSION}:herd`, "tiled"], { runner });
119
+
120
+ write(ok(`tiling ${members.length} member${members.length === 1 ? "" : "s"}${joined ? "" : " (already laid out)"} — Ctrl-b d to leave them running`));
121
+
122
+ return new Promise((resolve) => {
123
+ let child;
124
+ try { child = spawner("tmux", ["-L", HERD_SOCKET, "attach-session", "-t", TILE_SESSION], { stdio: "inherit" }); }
125
+ catch (error) { write(err(String(error.message || error))); resolve(1); return; }
126
+ child.on("error", (error) => { write(err(String(error.message || error))); resolve(1); });
127
+ child.on("exit", () => {
128
+ write(info(`detached — everything is still running. ${acid("moshcode ps")} · ${acid("moshcode herd tile")}`));
129
+ resolve(0);
130
+ });
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Put every tiled member back into a session of its own.
136
+ *
137
+ * The inverse of tiling, and worth having explicitly: a layout is a view, and
138
+ * anyone who wants their sessions back should not have to know that
139
+ * `break-pane` is the word for it.
140
+ */
141
+ export function herdUntile(argv = [], { write = console.log, runner = spawnSync } = {}) {
142
+ if (detectSubstrate() !== "tmux") { write(err("tiling needs tmux.")); return 1; }
143
+ const manifest = readManifest();
144
+ const tiled = [...paneIndex({ runner }).entries()]
145
+ .filter(([name, pane]) => pane.session === TILE_SESSION && name in manifest.sessions);
146
+ if (!tiled.length) { write(info("nothing is tiled.")); return 0; }
147
+
148
+ // `break-pane` moves a pane into a new *window*, always in a session that
149
+ // already exists — its `-t` is a destination window, not a name to create.
150
+ // Getting a pane into a session of its own is therefore the same dance as
151
+ // tiling, run backwards: make the session, join the pane into it, then drop
152
+ // the placeholder pane the new session was born with.
153
+ let restored = 0;
154
+ for (const [name, pane] of tiled) {
155
+ const made = tmux(["new-session", "-d", "-s", name, "-n", name], { runner });
156
+ if (!made.ok) { write(err(`${name}: ${made.stderr.trim() || "could not recreate its session"}`)); continue; }
157
+ const placeholder = tmux(["list-panes", "-t", name, "-F", "#{pane_id}"], { runner }).stdout.trim();
158
+ const joined = tmux(["join-pane", "-s", pane.paneId, "-t", `${name}:${name}`], { runner });
159
+ if (!joined.ok) { write(err(`${name}: ${joined.stderr.trim() || "could not move it back"}`)); continue; }
160
+ if (placeholder) tmux(["kill-pane", "-t", placeholder], { runner });
161
+ restored++;
162
+ }
163
+ // Nothing is left in it, and an empty tile window on the roster is confusing.
164
+ tmux(["kill-session", "-t", TILE_SESSION], { runner });
165
+ write(ok(`${restored} member${restored === 1 ? "" : "s"} back in their own sessions.`));
166
+ write(info(`${ash("attach one with")} ${acid("moshcode attach <name>")}`));
167
+ return 0;
168
+ }
package/src/herd-ui.mjs CHANGED
@@ -121,7 +121,12 @@ export function render(rows, { selected = 0, width = 80, substrate = "tmux" } =
121
121
  out.push(` ${ash("start something with")} ${acid("moshcode herd shell")} ${ash("or")} ${acid("moshcode agents claude -d")}`);
122
122
  }
123
123
  out.push("");
124
- out.push(` ${dim("click or ↑↓ to choose · enter to go in · r refresh · q quit")}`);
124
+ // Two lines, grouped by what they do, and the way *back* stated before you
125
+ // ever go in. The old single crammed line never mentioned it at all, so
126
+ // clicking into a session was a one-way door as far as the screen was
127
+ // concerned.
128
+ out.push(` ${ash("move")} ${dim("click · ↑↓ · wheel")} ${ash("open")} ${dim("enter or double-click")}`);
129
+ out.push(` ${ash("back")} ${dim("Ctrl-b d from inside")} ${ash("also")} ${dim("t tile all · r refresh · q quit")}`);
125
130
  if (substrate !== "tmux") out.push(` ${dim(substrateNote(substrate) || "")}`);
126
131
  return out.join("\r\n");
127
132
  }
@@ -161,7 +166,7 @@ export function parseInput(buffer) {
161
166
  if (parsed) events.push(parsed);
162
167
  }
163
168
  if (events.length) return events;
164
- for (const key of ["\x1b[A", "\x1b[B", "\r", "\n", "q", "\x03", "r", "j", "k"]) {
169
+ for (const key of ["\x1b[A", "\x1b[B", "\r", "\n", "q", "\x03", "r", "t", "j", "k"]) {
165
170
  if (text.includes(key)) events.push({ kind: "key", key });
166
171
  }
167
172
  return events;
@@ -268,13 +273,30 @@ export async function herdUi({
268
273
  if (event.kind === "key" && (event.key === "\x1b[A" || event.key === "k")) selected = moveSelection(rows, selected, -1);
269
274
  else if (event.kind === "key" && (event.key === "\x1b[B" || event.key === "j")) selected = moveSelection(rows, selected, 1);
270
275
  else if (event.kind === "key" && event.key === "r") { refresh(); continue; }
276
+ else if (event.kind === "key" && event.key === "t") {
277
+ // The list and the tiled layout are two views of one herd, so getting
278
+ // between them should not mean quitting and typing another command.
279
+ restore();
280
+ const { herdTile } = await import("./herd-tile.mjs");
281
+ await herdTile([], { write: (s) => process.stdout.write(`${s}\n`) });
282
+ if (done) return;
283
+ enter();
284
+ refresh();
285
+ continue;
286
+ }
271
287
  else if (event.kind === "wheel") selected = moveSelection(rows, selected, event.direction);
272
288
  else if (event.kind === "click") {
273
289
  const hit = rows.find((r) => r.kind === "session" && r.line === event.row);
274
290
  if (!hit) continue;
275
- selected = rows.indexOf(hit);
291
+ const index = rows.indexOf(hit);
292
+ // A single click SELECTS; only a second click on the row already
293
+ // selected opens it. Opening on first click made one stray click a
294
+ // one-way trip into a session, which is most of why this felt bad to
295
+ // navigate — you could not point at a row to read it.
296
+ const opening = index === selected;
297
+ selected = index;
276
298
  draw();
277
- await openSelected();
299
+ if (opening) await openSelected();
278
300
  continue;
279
301
  }
280
302
  else if (event.kind === "key") { await openSelected(); continue; }
@@ -0,0 +1,292 @@
1
+ // `moshcode herd ui` — a sidebar of members and actions, with the selected
2
+ // member's real terminal beside it.
3
+ //
4
+ // This replaces the modal list, which was the wrong answer to the question.
5
+ // The list showed you the herd OR a session and never both, so getting into one
6
+ // was a one-way trip and nothing on the list could start or stop anything.
7
+ //
8
+ // HOW THE SIDEBAR SURVIVES A SWITCH. tmux's model is session > window > pane,
9
+ // and a pane belongs to exactly one window — which is why moving between
10
+ // *windows* cannot keep anything on screen. But `join-pane` moves a running
11
+ // pane into an existing window, so swapping only the *content* pane leaves the
12
+ // sidebar untouched. Selecting a member parks the current content pane back
13
+ // into a session of its own and joins the new one in; both keep their processes
14
+ // and their scrollback, because tmux is moving the real pane rather than
15
+ // redrawing a picture of it.
16
+ //
17
+ // Two processes, therefore: the launcher below builds the window and attaches,
18
+ // and `herdSidebar` is what runs *inside* the left pane doing the swapping.
19
+ import { spawn, spawnSync } from "node:child_process";
20
+
21
+ import { HERD_SOCKET, detectSubstrate, paneIndex, readManifest, tmux } from "./herd.mjs";
22
+ import { roster } from "./herd-cli.mjs";
23
+ import { groupByHerd, parseInput } from "./herd-ui.mjs";
24
+ import { acid, amber, ash, bone, danger, dim, err, info, ok } from "./ui.mjs";
25
+
26
+ export const WORKSPACE = "herd";
27
+ export const WINDOW = "ui";
28
+ export const TARGET = `${WORKSPACE}:${WINDOW}`;
29
+ const SIDEBAR_WIDTH = 26;
30
+
31
+ /** The rows in the sidebar that are not members. */
32
+ export const ACTIONS = [
33
+ { key: "s", label: "+ shell", run: "shell" },
34
+ { key: "a", label: "+ agent", run: "agent" },
35
+ { key: "x", label: "✕ stop", run: "stop" },
36
+ { key: "t", label: "⊞ tile all", run: "tile" },
37
+ { key: "q", label: "← detach", run: "detach" },
38
+ ];
39
+
40
+ /* -------------------------------------------------------------- the layout */
41
+
42
+ /**
43
+ * Build the window and attach to it.
44
+ *
45
+ * Falls back to the plain list where there is no tmux, because the swap this
46
+ * is built on is a tmux operation and the script(1) substrate has one pty per
47
+ * session with no way to put two of them side by side.
48
+ */
49
+ export async function herdUi(argv = [], { write = console.log, spawner = spawn, runner = spawnSync } = {}) {
50
+ const substrate = detectSubstrate();
51
+ if (substrate !== "tmux") {
52
+ const { herdUi: list } = await import("./herd-ui.mjs");
53
+ return list({});
54
+ }
55
+
56
+ const existing = tmux(["has-session", "-t", WORKSPACE], { runner });
57
+ if (!existing.ok) {
58
+ const self = process.argv[1];
59
+ const sidebar = `${process.execPath} ${self} herd sidebar`;
60
+ const made = tmux(["new-session", "-d", "-s", WORKSPACE, "-n", WINDOW, sidebar], { runner });
61
+ if (!made.ok) { write(err(made.stderr.trim() || "could not open the workspace")); return 1; }
62
+ // The sidebar is the "main" pane of a main-vertical layout, which is what
63
+ // pins it to the left at a fixed width while the content pane takes the
64
+ // rest and follows the terminal when it resizes.
65
+ tmux(["set-option", "-t", WORKSPACE, "main-pane-width", String(SIDEBAR_WIDTH)], { runner });
66
+ tmux(["set-option", "-t", WORKSPACE, "mouse", "on"], { runner });
67
+ tmux(["set-option", "-t", WORKSPACE, "status", "off"], { runner });
68
+ tmux(["set-option", "-t", WORKSPACE, "pane-border-status", "top"], { runner });
69
+ tmux(["set-option", "-t", WORKSPACE, "pane-border-format", " #{pane_title} "], { runner });
70
+ tmux(["select-pane", "-t", `${TARGET}.0`, "-T", "herd"], { runner });
71
+ }
72
+
73
+ return new Promise((resolve) => {
74
+ let child;
75
+ try { child = spawner("tmux", ["-L", HERD_SOCKET, "attach-session", "-t", WORKSPACE], { stdio: "inherit" }); }
76
+ catch (error) { write(err(String(error.message || error))); resolve(1); return; }
77
+ child.on("error", (error) => { write(err(String(error.message || error))); resolve(1); });
78
+ child.on("exit", () => {
79
+ write(info(`detached — everything is still running. ${acid("moshcode ps")} · ${acid("moshcode herd ui")}`));
80
+ resolve(0);
81
+ });
82
+ });
83
+ }
84
+
85
+ /* ------------------------------------------------------------- the swapping */
86
+
87
+ /** The content pane currently on the right, if there is one. */
88
+ export function contentPane({ runner = spawnSync, me = process.env.TMUX_PANE } = {}) {
89
+ const r = tmux(["list-panes", "-t", TARGET, "-F", "#{pane_id}\t#{pane_title}"], { runner });
90
+ if (!r.ok) return null;
91
+ for (const line of r.stdout.split("\n")) {
92
+ const [paneId, title] = line.split("\t");
93
+ if (!paneId || paneId === me) continue;
94
+ return { paneId, title };
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * Send a pane back to a session of its own.
101
+ *
102
+ * `break-pane` cannot do this: its `-t` is a destination window that has to
103
+ * exist already, not a name to create. So it is the join dance backwards —
104
+ * make the session, move the pane in, drop the placeholder the session was
105
+ * born with.
106
+ */
107
+ export function parkPane(paneId, name, { runner = spawnSync } = {}) {
108
+ if (!name) return false;
109
+ const made = tmux(["new-session", "-d", "-s", name, "-n", name], { runner });
110
+ if (!made.ok && !/duplicate session/i.test(made.stderr || "")) return false;
111
+ const placeholder = made.ok
112
+ ? tmux(["list-panes", "-t", name, "-F", "#{pane_id}"], { runner }).stdout.trim().split("\n")[0]
113
+ : null;
114
+ const joined = tmux(["join-pane", "-s", paneId, "-t", `${name}:${name}`], { runner });
115
+ if (!joined.ok) return false;
116
+ if (placeholder) tmux(["kill-pane", "-t", placeholder], { runner });
117
+ return true;
118
+ }
119
+
120
+ /** Put `name` in the content pane, parking whatever was there. */
121
+ export function showMember(name, { runner = spawnSync, me = process.env.TMUX_PANE } = {}) {
122
+ const current = contentPane({ runner, me });
123
+ if (current?.title === name) return true; // already showing
124
+ const panes = paneIndex({ runner });
125
+ const wanted = panes.get(name);
126
+ if (!wanted) return false;
127
+
128
+ if (current) parkPane(current.paneId, current.title, { runner });
129
+ const joined = tmux(["join-pane", "-s", wanted.paneId, "-t", TARGET], { runner });
130
+ if (!joined.ok) return false;
131
+ tmux(["select-layout", "-t", TARGET, "main-vertical"], { runner });
132
+ // main-vertical resets the main pane's width from the option, so re-assert it
133
+ // after every swap or the sidebar creeps wider each time.
134
+ tmux(["set-option", "-t", WORKSPACE, "main-pane-width", String(SIDEBAR_WIDTH)], { runner });
135
+ tmux(["select-pane", "-t", me], { runner });
136
+ return true;
137
+ }
138
+
139
+ /* --------------------------------------------------------------- the render */
140
+
141
+ const MARK = { blocked: "!", working: "~", done: "✓", idle: "·", gone: "×", unknown: "?" };
142
+ const paintState = (state, text) =>
143
+ state === "blocked" ? amber(text)
144
+ : state === "working" ? acid(text)
145
+ : state === "done" ? bone(text)
146
+ : state === "gone" ? danger(text)
147
+ : ash(text);
148
+
149
+ /**
150
+ * The sidebar's rows, and the line each one sits on — one list so a click and
151
+ * the highlight cannot disagree (the bug that made the first list send every
152
+ * click to the row below the pointer).
153
+ */
154
+ export function sidebarRows(sessions) {
155
+ const rows = [{ kind: "title" }, { kind: "gap" }];
156
+ for (const group of groupByHerd(sessions)) {
157
+ rows.push({ kind: "herd", herd: group.name });
158
+ for (const session of group.members) rows.push({ kind: "session", session });
159
+ }
160
+ rows.push({ kind: "gap" }, { kind: "heading", text: "ACTIONS" });
161
+ for (const action of ACTIONS) rows.push({ kind: "action", action });
162
+ return rows.map((row, i) => ({ ...row, line: i + 1 }));
163
+ }
164
+
165
+ export function renderSidebar(rows, { selected, showing, width = SIDEBAR_WIDTH } = {}) {
166
+ const out = [];
167
+ for (const row of rows) {
168
+ if (row.kind === "title") { out.push(` ${bone("herd")}`); continue; }
169
+ if (row.kind === "gap") { out.push(""); continue; }
170
+ if (row.kind === "heading") { out.push(` ${ash(row.text)}`); continue; }
171
+ if (row.kind === "herd") { out.push(` ${ash(row.herd.toUpperCase())}`); continue; }
172
+ if (row.kind === "session") {
173
+ const s = row.session;
174
+ const here = s.name === showing ? acid("▸") : " ";
175
+ const label = s.name.slice(0, width - 7);
176
+ const text = s.name === selected ? bone(label) : ash(label);
177
+ out.push(`${here} ${paintState(s.state, MARK[s.state] || "?")} ${text}`);
178
+ continue;
179
+ }
180
+ const isSel = row.action.key === selected;
181
+ out.push(` ${isSel ? bone(row.action.label) : ash(row.action.label)}`);
182
+ }
183
+ return out.join("\r\n");
184
+ }
185
+
186
+ /* -------------------------------------------------------- the sidebar itself */
187
+
188
+ /**
189
+ * Runs inside the left pane. Draws the list, and turns a click into a swap.
190
+ *
191
+ * It does not take the alternate screen: it *is* a pane, and the pane is the
192
+ * screen. Mouse reporting is enabled for this program specifically, which tmux
193
+ * forwards rather than consuming once an application asks for it.
194
+ */
195
+ export async function herdSidebar({
196
+ stdin = process.stdin, stdout = process.stdout, read = roster, refreshMs = 2000, runner = spawnSync,
197
+ } = {}) {
198
+ const me = process.env.TMUX_PANE;
199
+ let sessions = read();
200
+ let rows = sidebarRows(sessions);
201
+ let selected = sessions[0]?.name || ACTIONS[0].key;
202
+ let showing = null;
203
+
204
+ const draw = () => {
205
+ stdout.write("\x1b[2J\x1b[H" + renderSidebar(rows, { selected, showing }));
206
+ };
207
+ const refresh = () => {
208
+ sessions = read();
209
+ rows = sidebarRows(sessions);
210
+ const current = contentPane({ runner, me });
211
+ showing = current?.title || null;
212
+ draw();
213
+ };
214
+
215
+ // Open on something rather than an empty right-hand side.
216
+ const first = sessions.find((s) => s.alive);
217
+ if (first) { showMember(first.name, { runner, me }); showing = first.name; }
218
+
219
+ stdout.write("\x1b[?1000h\x1b[?1006h\x1b[?25l");
220
+ try { stdin.setRawMode?.(true); } catch { /* not a tty */ }
221
+ stdin.resume();
222
+ const restore = () => stdout.write("\x1b[?1006l\x1b[?1000l\x1b[?25h");
223
+ process.on("exit", restore);
224
+
225
+ draw();
226
+ const timer = setInterval(refresh, refreshMs);
227
+
228
+ const act = async (what) => {
229
+ if (what === "detach") { tmux(["detach-client"], { runner }); return; }
230
+ if (what === "tile") {
231
+ const { herdTile } = await import("./herd-tile.mjs");
232
+ await herdTile([], { write: () => {}, spawner: () => ({ on: (e, cb) => e === "exit" && cb(0) }) });
233
+ refresh();
234
+ return;
235
+ }
236
+ if (what === "stop") {
237
+ const target = sessions.find((s) => s.name === selected);
238
+ if (!target) return;
239
+ const { killSession } = await import("./herd.mjs");
240
+ killSession(target.name);
241
+ refresh();
242
+ const next = read().find((s) => s.alive);
243
+ if (next) { showMember(next.name, { runner, me }); }
244
+ refresh();
245
+ return;
246
+ }
247
+ // shell / agent: start it detached, then bring it into the content pane so
248
+ // the thing you just asked for is the thing you are looking at.
249
+ const { herdShell, herdStart } = await import("./herd-cli.mjs");
250
+ let created = null;
251
+ const capture = (line) => { const m = /^\S*\s*(\S+)\s+—/.exec(String(line).replace(/\x1b\[[0-9;]*m/g, "")); if (m) created = m[1]; };
252
+ if (what === "shell") herdShell([], { write: capture });
253
+ else herdStart(["claude", "--agent"], { write: capture });
254
+ refresh();
255
+ if (created) { showMember(created, { runner, me }); refresh(); }
256
+ };
257
+
258
+ await new Promise((resolve) => {
259
+ stdin.on("data", async (buf) => {
260
+ for (const event of parseInput(buf)) {
261
+ if (event.kind === "click") {
262
+ const hit = rows.find((r) => r.line === event.row && (r.kind === "session" || r.kind === "action"));
263
+ if (!hit) continue;
264
+ if (hit.kind === "session") {
265
+ selected = hit.session.name;
266
+ if (hit.session.alive) { showMember(hit.session.name, { runner, me }); showing = hit.session.name; }
267
+ draw();
268
+ } else {
269
+ selected = hit.action.key;
270
+ draw();
271
+ await act(hit.action.run);
272
+ }
273
+ continue;
274
+ }
275
+ if (event.kind !== "key") continue;
276
+ const action = ACTIONS.find((a) => a.key === event.key);
277
+ if (action) { await act(action.run); if (action.run === "detach") { resolve(); return; } continue; }
278
+ if (event.key === "\x03") { resolve(); return; }
279
+ const names = sessions.filter((s) => s.alive).map((s) => s.name);
280
+ const at = names.indexOf(selected);
281
+ if (event.key === "\x1b[A" || event.key === "k") selected = names[Math.max(0, at - 1)] || selected;
282
+ if (event.key === "\x1b[B" || event.key === "j") selected = names[Math.min(names.length - 1, at + 1)] || selected;
283
+ if (event.key === "\r" || event.key === "\n") { showMember(selected, { runner, me }); showing = selected; }
284
+ draw();
285
+ }
286
+ });
287
+ });
288
+
289
+ clearInterval(timer);
290
+ restore();
291
+ return 0;
292
+ }
package/src/herd.mjs CHANGED
@@ -261,6 +261,11 @@ export function tmuxStartPlan({ name, cwd, command }) {
261
261
  // clickable once you are inside. This server is moshcode's and starts from
262
262
  // no config, so it is not overriding a preference anyone expressed.
263
263
  ";", "set-option", "-t", name, "mouse", "on",
264
+ // The pane's title is the member's durable handle — it survives being
265
+ // moved into a tiled window and back, which the session name does not.
266
+ // Set in the same invocation as the rest so a fast-exiting command cannot
267
+ // finish before it lands.
268
+ ";", "select-pane", "-t", name, "-T", name,
264
269
  ];
265
270
  }
266
271
 
@@ -420,9 +425,11 @@ function ptyWrite(name, data) {
420
425
  /** Names the substrate says are live right now. */
421
426
  export function liveNames({ substrate = detectSubstrate(), runner = spawnSync } = {}) {
422
427
  if (substrate === "tmux") {
423
- const r = tmux(["list-sessions", "-F", "#{session_name}"], { runner });
424
- if (!r.ok) return []; // no server yet is not an error, it is an empty herd
425
- return r.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
428
+ // Panes, not sessions: a tiled member's session is gone but the member is
429
+ // very much alive. Only names the manifest knows about count, so a stray
430
+ // pane title on the server cannot invent a roster entry.
431
+ const known = new Set(Object.keys(readManifest().sessions));
432
+ return [...paneIndex({ runner }).keys()].filter((name) => known.has(name));
426
433
  }
427
434
  if (substrate === "pty") {
428
435
  // A finished session is still one the runtime has: it stays on the roster
@@ -463,6 +470,42 @@ export function sessionExited(name, { substrate = detectSubstrate(), runner = sp
463
470
  * meant a fork per field per row, so a herd of six cost thirteen processes to
464
471
  * draw one screen. tmux will format the whole server in one pass.
465
472
  */
473
+ /**
474
+ * Where every member's pane actually is, keyed by member name.
475
+ *
476
+ * A member is a *pane*, not a session. It starts life as the only pane in a
477
+ * session of the same name, but `herd tile` moves panes into one window to lay
478
+ * them out side by side — and a session whose last pane leaves is gone. Keying
479
+ * off session names would make every tiled member vanish from the roster, from
480
+ * `read`, from `prompt` and from `wait`, which is a steep price for a layout.
481
+ *
482
+ * The pane's *title* is the durable handle: it is set at creation, it travels
483
+ * with the pane through join-pane and break-pane, and tmux will report it from
484
+ * anywhere on the server.
485
+ */
486
+ export function paneIndex({ runner = spawnSync } = {}) {
487
+ const r = tmux(["list-panes", "-a", "-F", "#{pane_title}\t#{pane_id}\t#{session_name}\t#{window_id}\t#{pane_dead}"], { runner });
488
+ const index = new Map();
489
+ if (!r.ok) return index;
490
+ for (const line of r.stdout.split("\n")) {
491
+ if (!line.trim()) continue;
492
+ const [title, paneId, session, windowId, dead] = line.split("\t");
493
+ if (!title) continue;
494
+ index.set(title, { paneId, session, windowId, dead: dead.trim() === "1" });
495
+ }
496
+ return index;
497
+ }
498
+
499
+ /**
500
+ * The tmux target for a member: its pane id when we can find one, else its
501
+ * session name. The fallback matters for a session created before pane titles
502
+ * were set, which would otherwise become unreachable after an upgrade.
503
+ */
504
+ export function target(name, { runner = spawnSync, index } = {}) {
505
+ const found = (index || paneIndex({ runner })).get(name);
506
+ return found ? found.paneId : name;
507
+ }
508
+
466
509
  function tmuxSnapshot({ runner = spawnSync } = {}) {
467
510
  const sessions = tmux(["list-sessions", "-F", "#{session_name}\t#{session_attached}"], { runner });
468
511
  const attached = new Map();
@@ -540,7 +583,7 @@ export function startSession({
540
583
  /** The last `lines` rows of a session's screen — what the classifier reads. */
541
584
  export function capture(name, { lines = 60, substrate = detectSubstrate(), runner = spawnSync } = {}) {
542
585
  if (substrate === "tmux") {
543
- const r = tmux(["capture-pane", "-p", "-t", name, "-S", `-${Math.max(0, lines)}`], { runner });
586
+ const r = tmux(["capture-pane", "-p", "-t", target(name, { runner }), "-S", `-${Math.max(0, lines)}`], { runner });
544
587
  return r.ok ? r.stdout.replace(/\n+$/, "") : "";
545
588
  }
546
589
  if (substrate === "pty") return ptyCapture(name, lines);
@@ -550,7 +593,7 @@ export function capture(name, { lines = 60, substrate = detectSubstrate(), runne
550
593
  /** Raw key relay. `keys` is passed through to tmux's own key vocabulary. */
551
594
  export function sendKeys(name, keys, { substrate = detectSubstrate(), runner = spawnSync } = {}) {
552
595
  if (substrate === "tmux") {
553
- const r = tmux(["send-keys", "-t", name, ...(Array.isArray(keys) ? keys : [keys])], { runner });
596
+ const r = tmux(["send-keys", "-t", target(name, { runner }), ...(Array.isArray(keys) ? keys : [keys])], { runner });
554
597
  return r.ok ? { ok: true } : { ok: false, error: new Error(r.stderr.trim() || "send-keys failed") };
555
598
  }
556
599
  if (substrate === "pty") {
@@ -571,9 +614,10 @@ export function sendKeys(name, keys, { substrate = detectSubstrate(), runner = s
571
614
  */
572
615
  export function sendPrompt(name, text, { substrate = detectSubstrate(), runner = spawnSync } = {}) {
573
616
  if (substrate === "tmux") {
574
- const typed = tmux(["send-keys", "-t", name, "-l", String(text)], { runner });
617
+ const pane = target(name, { runner });
618
+ const typed = tmux(["send-keys", "-t", pane, "-l", String(text)], { runner });
575
619
  if (!typed.ok) return { ok: false, error: new Error(typed.stderr.trim() || "send-keys failed") };
576
- const entered = tmux(["send-keys", "-t", name, "Enter"], { runner });
620
+ const entered = tmux(["send-keys", "-t", pane, "Enter"], { runner });
577
621
  return entered.ok ? { ok: true } : { ok: false, error: new Error(entered.stderr.trim() || "send-keys failed") };
578
622
  }
579
623
  if (substrate === "pty") return ptyWrite(name, `${String(text)}\r`);
@@ -583,7 +627,9 @@ export function sendPrompt(name, text, { substrate = detectSubstrate(), runner =
583
627
  /** End a session and forget it. */
584
628
  export function killSession(name, { substrate = detectSubstrate(), runner = spawnSync } = {}) {
585
629
  if (substrate === "tmux") {
586
- const r = tmux(["kill-session", "-t", name], { runner });
630
+ // kill-pane, not kill-session: a tiled member shares its session with
631
+ // every other tiled member, and killing that would take the lot.
632
+ const r = tmux(["kill-pane", "-t", target(name, { runner })], { runner });
587
633
  forgetSession(name);
588
634
  return r.ok ? { ok: true } : { ok: false, error: new Error(r.stderr.trim() || "no such session") };
589
635
  }
@@ -630,9 +676,19 @@ export async function attachSession(name, {
630
676
  stdout = process.stdout,
631
677
  } = {}) {
632
678
  if (substrate === "tmux") {
679
+ // A member that has been tiled shares a window with its neighbours, so
680
+ // attaching has to select its pane and zoom it — otherwise you land on
681
+ // whichever pane happened to have focus, at a quarter of the screen.
682
+ const found = paneIndex().get(name);
683
+ const argv = found
684
+ ? ["attach-session", "-t", found.session,
685
+ ";", "select-window", "-t", found.windowId,
686
+ ";", "select-pane", "-t", found.paneId,
687
+ ";", "resize-pane", "-Z", "-t", found.paneId]
688
+ : ["attach-session", "-t", name];
633
689
  return new Promise((resolve) => {
634
690
  let child;
635
- try { child = spawner("tmux", tmuxArgs(["attach-session", "-t", name]), { stdio: "inherit", env }); }
691
+ try { child = spawner("tmux", tmuxArgs(argv), { stdio: "inherit", env }); }
636
692
  catch (error) { resolve({ ok: false, error }); return; }
637
693
  child.on("error", (error) => resolve({ ok: false, error }));
638
694
  child.on("exit", (code, signal) => resolve({ ok: code === 0, code, signal }));
@@ -726,14 +782,17 @@ export function ptyAttachSession(name, { stdin = process.stdin, stdout = process
726
782
  */
727
783
  export function listSessions({ substrate = detectSubstrate(), runner = spawnSync, now = Date.now() } = {}) {
728
784
  const manifest = readManifest();
785
+ const panes = substrate === "tmux" ? paneIndex({ runner }) : null;
729
786
  const snapshot = substrate === "tmux" ? tmuxSnapshot({ runner }) : null;
730
- const live = new Set(snapshot ? snapshot.attached.keys() : liveNames({ substrate, runner }));
787
+ const live = new Set(panes
788
+ ? [...panes.keys()].filter((name) => name in manifest.sessions)
789
+ : liveNames({ substrate, runner }));
731
790
  const names = [...new Set([...live, ...Object.keys(manifest.sessions)])].sort();
732
791
  return names.map((name) => {
733
792
  const meta = manifest.sessions[name] || {};
734
793
  const alive = live.has(name);
735
794
  const exited = !alive ? null
736
- : snapshot ? !snapshot.anyLive.get(name)
795
+ : panes ? Boolean(panes.get(name)?.dead)
737
796
  : sessionExited(name, { substrate, runner });
738
797
  return {
739
798
  name,
@@ -746,7 +805,11 @@ export function listSessions({ substrate = detectSubstrate(), runner = spawnSync
746
805
  age: meta.created ? now - meta.created : null,
747
806
  alive,
748
807
  exited,
749
- attached: alive && snapshot ? snapshot.attached.get(name) || 0 : 0,
808
+ // Where it currently sits null when it is in its own session, the
809
+ // window it was tiled into otherwise. The UI needs this to know whether
810
+ // a member is already on a layout somewhere.
811
+ window: alive && panes ? panes.get(name)?.windowId || null : null,
812
+ attached: alive && snapshot ? snapshot.attached.get(panes?.get(name)?.session) || 0 : 0,
750
813
  substrate: meta.substrate || substrate,
751
814
  };
752
815
  });