moshcode 0.33.1 → 0.35.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,6 +125,78 @@ 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
129
+
130
+ ```sh
131
+ moshcode herd ui
132
+ ```
133
+
134
+ ```
135
+ moshcode herd 3 members 1 waiting on you
136
+
137
+ MAIN (2)
138
+ ▸ ! api claude blocked ~/src/coinpay
139
+ · work shell idle ~/src/coinpay
140
+
141
+ SCRATCH (1)
142
+ · logs shell idle ~/src/ugig.net
143
+
144
+ click or ↑↓ to choose · enter to go in · r refresh · q quit
145
+ ```
146
+
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`.
150
+
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.
159
+
160
+ ### A workspace: a few shells and an agent
161
+
162
+ This is what most people actually want — a couple of shells to work in and an
163
+ agent or two running beside them, none of which die when the terminal does:
164
+
165
+ ```sh
166
+ cd ~/src/coinpay
167
+ moshcode herd shell --name work # a plain $SHELL
168
+ moshcode herd shell --name logs # another
169
+ moshcode agents claude -d --name api # and an agent
170
+ ```
171
+
172
+ ```sh
173
+ $ moshcode ps
174
+ api claude blocked ~/src/coinpay 3m
175
+ logs shell idle ~/src/coinpay 3m
176
+ work shell idle ~/src/coinpay 3m
177
+
178
+ ⚠ 1 waiting on you — moshcode attach api
179
+ ```
180
+
181
+ `moshcode attach work` puts you in one; `Ctrl-b s` hops between all three;
182
+ `Ctrl-b d` leaves the lot running. Close the laptop and they are still there.
183
+
184
+ ### Agents moshcode does not ship
185
+
186
+ `start` only knows the engines moshcode installs. `run` takes anything —
187
+ an agent with no install spec here, a build, a script:
188
+
189
+ ```sh
190
+ moshcode herd run --name cur -- cursor-agent
191
+ moshcode herd run --name build -- npm run watch
192
+ ```
193
+
194
+ Everything after `--` is the command, flags and all. These get a roster entry
195
+ and the same state detection as a known engine: the shared rules match what a
196
+ terminal *draws* — a y/n prompt, a numbered menu, "esc to interrupt" — not
197
+ anything engine-specific, so an agent moshcode has never heard of still shows up
198
+ `blocked` when it stops to ask you something.
199
+
128
200
  Close the terminal, drop the SSH link, come back tomorrow — `moshcode ps` still
129
201
  answers, and `moshcode attach` puts you back inside. In the pit the same verbs
130
202
  are `/ps`, `/attach`, `/kill`, and the roster prints on the way in.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.33.1",
3
+ "version": "0.35.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": {
@@ -79,12 +79,27 @@ export const CORE_CLI_COMMANDS = [
79
79
  verbs: "HERD_VERBS",
80
80
  flags: [["--json", "machine-readable, on every verb", ""]],
81
81
  examples: [
82
- ["moshcode herd start claude --name api", "start one and get your prompt back"],
83
- ["moshcode herd prompt api \"run the tests\" --wait", "hand it work and block until it lands"],
84
- ["moshcode herd read api --lines 40", "read its screen without attaching"],
82
+ ["moshcode herd ui", "the clickable list start here"],
83
+ ["", ""],
84
+ ["# a workspace: two shells and an agent, none of which die with this terminal", ""],
85
+ ["moshcode herd shell --name work", "a plain $SHELL you can come back to"],
86
+ ["moshcode herd shell --name logs", "another one"],
87
+ ["moshcode agents claude -d --name api", "an agent, detached"],
88
+ ["moshcode ps", "all three, and which one is blocked"],
89
+ ["moshcode attach api", "step in · Ctrl-b s switches · Ctrl-b d leaves it running"],
90
+ ["", ""],
91
+ ["# an agent moshcode has no install spec for", ""],
92
+ ["moshcode herd run --name cur -- cursor-agent", "anything at all runs in the herd"],
93
+ ["", ""],
94
+ ["# driving one without attaching", ""],
95
+ ["moshcode herd prompt api \"run the tests\" --wait", "hand it work, block until it lands"],
96
+ ["moshcode herd read api --lines 40", "read its screen"],
85
97
  ],
86
98
  seeAlso: ["ps", "attach", "wait", "restore", "start"],
87
- note: "sessions live in a tmux server moshcode owns, or under script(1) when there is no tmux. with neither, launches stay in the foreground and say so.",
99
+ note: "`start` is for the engines moshcode installs; `run` and `shell` take anything else, "
100
+ + "so an agent it has never heard of still gets a roster entry and blocked/idle detection. "
101
+ + "sessions live in a tmux server moshcode owns, or under script(1) when there is no tmux. "
102
+ + "with neither, launches stay in the foreground and say so.",
88
103
  },
89
104
  {
90
105
  name: "ps",
@@ -723,6 +738,19 @@ export const HERD_VERBS = [
723
738
  ["--agent", "autonomous mode — bypasses the engine's approvals", ""],
724
739
  ["--json", "machine-readable", ""],
725
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: [] },
744
+ { name: "run", description: "run ANY command in the herd — an agent moshcode does not ship, a build, a script",
745
+ synopsis: [["moshcode herd run [--name <slug>] -- <command…>", "everything after -- is the command"]],
746
+ flags: [
747
+ ["--name <slug>", "session name", "<command>-<dir>"],
748
+ ["--cwd <dir>", "where to run it", "this directory"],
749
+ ["--json", "machine-readable", ""],
750
+ ] },
751
+ { name: "shell", description: "a plain $SHELL in the herd",
752
+ synopsis: [["moshcode herd shell [--name <slug>]", ""]],
753
+ flags: [["--name <slug>", "session name", "shell-<dir>"], ["--cwd <dir>", "where to run it", "this directory"]] },
726
754
  { name: "attach", description: "put this terminal inside a session",
727
755
  synopsis: [["moshcode herd attach <name>", ""]] },
728
756
  { name: "kill", description: "end a session",
package/src/help.mjs CHANGED
@@ -167,7 +167,11 @@ export function wrap(text, indent = 0, width = WIDTH) {
167
167
 
168
168
  /** ` name description`, wrapped, for a two-column list. */
169
169
  function row(left, right, pad = 22) {
170
- const head = ` ${left.padEnd(pad)}`;
170
+ // padEnd does nothing when the left column is already wider than the pad, so
171
+ // a long example used to butt straight against its comment:
172
+ // `… "run the tests" --wait# hand it work`. One guaranteed space keeps the
173
+ // two readable however long the command gets.
174
+ const head = left.length >= pad ? ` ${left} ` : ` ${left.padEnd(pad)}`;
171
175
  if (!right) return head.trimEnd();
172
176
  const wrapped = wrap(right, head.length, WIDTH);
173
177
  return `${head}${wrapped}`;
package/src/herd-cli.mjs CHANGED
@@ -11,7 +11,7 @@ import path from "node:path";
11
11
  import {
12
12
  attachSession, capture, defaultName, detectSubstrate, forgetSession, HERD_SOCKET,
13
13
  herdDir, killSession, listSessions, readManifest, rememberSession, sendKeys, sendPrompt,
14
- startSession, stopRuntime, substrateNote, validName, NAME_RE,
14
+ slugifyName, startSession, stopRuntime, substrateNote, validName, NAME_RE,
15
15
  } from "./herd.mjs";
16
16
  import { clearReport, reportState, STATES, withState } from "./herd-state.mjs";
17
17
  import { ENGINES, resolveEngine, resolveExecutable, agentLaunchArgs } from "./engines.mjs";
@@ -130,13 +130,15 @@ export function herdStart(argv, { write = console.log } = {}) {
130
130
  const substrate = requireSubstrate(write);
131
131
  if (!substrate) return EXIT.usage;
132
132
 
133
- const flags = { name: null, cwd: process.cwd(), agent: false, json: false };
133
+ const flags = { name: null, cwd: process.cwd(), agent: false, json: false, herd: "main" };
134
134
  const rest = [];
135
135
  for (let i = 0; i < argv.length; i++) {
136
136
  const a = argv[i];
137
137
  if (a === "--name") flags.name = argv[++i];
138
138
  else if (a.startsWith("--name=")) flags.name = a.slice(7);
139
139
  else if (a === "--cwd") flags.cwd = path.resolve(argv[++i] || ".");
140
+ else if (a === "--herd") flags.herd = slugifyName(argv[++i]);
141
+ else if (a.startsWith("--herd=")) flags.herd = slugifyName(a.slice(7));
140
142
  else if (a === "--agent") flags.agent = true;
141
143
  else if (a === "--json") flags.json = true;
142
144
  else rest.push(a);
@@ -168,10 +170,10 @@ export function herdStart(argv, { write = console.log } = {}) {
168
170
  write(err(String(started.error?.message || started.error)));
169
171
  return EXIT.usage;
170
172
  }
171
- rememberSession(name, { agent: flags.agent });
173
+ rememberSession(name, { agent: flags.agent, herd: flags.herd });
172
174
 
173
175
  if (flags.json) {
174
- write(JSON.stringify({ name, engine: key, cwd: flags.cwd, substrate, agent: flags.agent }, null, 2));
176
+ write(JSON.stringify({ name, engine: key, herd: flags.herd, cwd: flags.cwd, substrate, agent: flags.agent }, null, 2));
175
177
  return EXIT.matched;
176
178
  }
177
179
  write(ok(`${bone(name)} — ${key} running in the herd. the prompt is yours.`));
@@ -182,6 +184,82 @@ export function herdStart(argv, { write = console.log } = {}) {
182
184
  return EXIT.matched;
183
185
  }
184
186
 
187
+ /**
188
+ * Run anything at all in the herd — a shell, or an agent moshcode does not ship
189
+ * an install spec for.
190
+ *
191
+ * `herd start` is deliberately limited to the engines in ENGINES, because it
192
+ * does engine-specific things: agent-mode flags, env stripping, resume args.
193
+ * That made the herd useless for the two most common things people actually
194
+ * want in it — a couple of shells, and whichever agent they use that moshcode
195
+ * has never heard of (cursor-agent, copilot, amp, a local script).
196
+ *
197
+ * So this is the escape hatch, and it is the same model herdr uses: a session
198
+ * holds a process, and an agent is just a process we happen to recognise.
199
+ * Detection still works, because the shared rules in herd-state.mjs match what
200
+ * a terminal draws — a y/n prompt, a numbered menu, "esc to interrupt" — rather
201
+ * than anything engine-specific. An unknown agent that stops to ask a question
202
+ * shows up `blocked` without moshcode knowing what it is.
203
+ */
204
+ export function herdRun(argv, { write = console.log, shell = false } = {}) {
205
+ const substrate = requireSubstrate(write);
206
+ if (!substrate) return EXIT.usage;
207
+
208
+ const flags = { name: null, cwd: process.cwd(), json: false, herd: "main" };
209
+ const command = [];
210
+ let afterSeparator = false;
211
+ for (let i = 0; i < argv.length; i++) {
212
+ const a = argv[i];
213
+ if (afterSeparator) { command.push(a); continue; }
214
+ // Everything after `--` belongs to the command, flags included — otherwise
215
+ // `herd run -- claude --json` would have its --json eaten by us.
216
+ if (a === "--") { afterSeparator = true; }
217
+ else if (a === "--name") flags.name = argv[++i];
218
+ else if (a.startsWith("--name=")) flags.name = a.slice(7);
219
+ else if (a === "--cwd") flags.cwd = path.resolve(argv[++i] || ".");
220
+ else if (a === "--herd") flags.herd = slugifyName(argv[++i]);
221
+ else if (a.startsWith("--herd=")) flags.herd = slugifyName(a.slice(7));
222
+ else if (a === "--json") flags.json = true;
223
+ else command.push(a);
224
+ }
225
+
226
+ if (shell && !command.length) {
227
+ command.push(process.env.SHELL || (process.platform === "win32" ? "cmd.exe" : "/bin/sh"));
228
+ }
229
+ if (!command.length) {
230
+ write(err('usage: moshcode herd run [--name <slug>] -- <command…>'));
231
+ write(info(`e.g. ${acid('moshcode herd run --name build -- npm run watch')}`));
232
+ return EXIT.usage;
233
+ }
234
+
235
+ const [bin, ...args] = command;
236
+ const label = shell ? "shell" : path.basename(bin);
237
+ const taken = listSessions().map((s) => s.name);
238
+ const name = flags.name || defaultName(slugifyName(label), flags.cwd, taken);
239
+ if (!validName(name)) {
240
+ write(err(`invalid name ${JSON.stringify(name)} — must match ${NAME_RE}`));
241
+ return EXIT.usage;
242
+ }
243
+
244
+ const started = startSession({ name, engine: label, bin, args, cwd: flags.cwd, substrate });
245
+ if (!started.ok) {
246
+ write(err(String(started.error?.message || started.error)));
247
+ return EXIT.usage;
248
+ }
249
+ rememberSession(name, { herd: flags.herd });
250
+
251
+ if (flags.json) {
252
+ write(JSON.stringify({ name, engine: label, herd: flags.herd, cwd: flags.cwd, substrate }, null, 2));
253
+ return EXIT.matched;
254
+ }
255
+ write(ok(`${bone(name)} — ${label} running in the herd. the prompt is yours.`));
256
+ write(info(`attach: ${acid(`moshcode attach ${name}`)} · roster: ${acid("moshcode ps")}`));
257
+ return EXIT.matched;
258
+ }
259
+
260
+ /** A plain $SHELL in the herd — the common case of herdRun. */
261
+ export const herdShell = (argv, options = {}) => herdRun(argv, { ...options, shell: true });
262
+
185
263
  /**
186
264
  * Pull the herd flags out of an engine launch (PRD 0009 R3).
187
265
  *
@@ -208,8 +286,8 @@ export function splitDetachArgs(args = []) {
208
286
  export function herdPs(argv, { write = console.log } = {}) {
209
287
  const rows = roster();
210
288
  if (argv.includes("--json")) {
211
- write(JSON.stringify(rows.map(({ name, engine, state, authority, cwd, age, alive, attached, substrate }) => ({
212
- name, engine, state, authority, cwd, ageMs: age, alive, attached, substrate,
289
+ write(JSON.stringify(rows.map(({ name, engine, herd, state, authority, cwd, age, alive, attached, substrate }) => ({
290
+ name, engine, herd, state, authority, cwd, ageMs: age, alive, attached, substrate,
213
291
  })), null, 2));
214
292
  return EXIT.matched;
215
293
  }
@@ -645,8 +723,12 @@ export function herdStop(argv, { write = console.log } = {}) {
645
723
  // ---------------------------------------------------------------------------
646
724
 
647
725
  const VERBS = {
726
+ // Lazy import: the UI pulls in escape-sequence machinery and only matters
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),
648
729
  ps: herdPs, list: herdPs, status: herdStatus,
649
- start: herdStart, attach: herdAttach, kill: herdKill, prune: herdPrune,
730
+ start: herdStart, run: herdRun, shell: herdShell,
731
+ attach: herdAttach, kill: herdKill, prune: herdPrune,
650
732
  read: herdRead, prompt: herdPrompt, "send-keys": herdSendKeys,
651
733
  wait: herdWait, restore: herdRestore, report: herdReport,
652
734
  notify: herdNotify, watch: herdWatch, stop: herdStop,
@@ -80,7 +80,25 @@ export const COMMON_RULES = {
80
80
  /\bctrl\+c to (?:stop|cancel|interrupt)\b/i,
81
81
  /\bpress esc to cancel\b/i,
82
82
  ],
83
- idle: [],
83
+ // A prompt sitting at the very end of the screen, waiting for a keystroke —
84
+ // a shell's `$`, a root `#`, zsh/starship's `❯`/`➜`, an agent's `>` composer.
85
+ // Anchored to the end of the *capture* rather than to any line, because a `$`
86
+ // in the middle of output is a dollar sign and not an invitation.
87
+ //
88
+ // Checked after blocked and working, so the cost of a false positive is only
89
+ // `idle` where `unknown` was already the honest answer. This is what stops a
90
+ // couple of shells in the herd reading `unknown` forever.
91
+ idle: [
92
+ // The glyph last: `… $`, `… #`, `… ❯`.
93
+ /[$%#>❯➜»]\s*$/,
94
+ // The glyph first, with the path after it: `➜ ~/src/api `, which is what
95
+ // zsh and starship actually draw. Restricted to ❯ and ➜ because those are
96
+ // prompt characters and almost nothing else; `>` and `#` in that position
97
+ // are markdown quotes and headings, which agents print all the time.
98
+ // Claude Code's `❯ 2. Dark mode` selector reaches the blocked rule first,
99
+ // so a menu still reads blocked rather than idle.
100
+ /(?:^|\n)[^\n]*[❯➜][^\n]*$/,
101
+ ],
84
102
  };
85
103
 
86
104
  /**
@@ -0,0 +1,296 @@
1
+ // `moshcode herd ui` — the clickable list of herds and their members.
2
+ //
3
+ // The roster answers "what is running" in a line each, which is right for a
4
+ // pipe and wrong for a person with five sessions who wants to get into one of
5
+ // them. This is the same information as a place you can point at: herds as
6
+ // headings, members under them, click one to go in, detach to come back.
7
+ //
8
+ // WHY THIS AND NOT A SIDEBAR. tmux's model is session > window > pane, and a
9
+ // pane belongs to exactly one window — so nothing can stay on screen across a
10
+ // switch. A list pinned beside a live session is therefore impossible with tmux
11
+ // underneath, and doing it anyway would mean rendering every session ourselves
12
+ // from `capture-pane` polls: no real cursor, no real mouse inside the agent,
13
+ // full-screen UIs flickering at the refresh rate. That is rebuilding herdr, and
14
+ // worse. So this list hands the whole terminal to a *real* attach and takes it
15
+ // back on detach — you lose side-by-side, you keep a real terminal.
16
+ //
17
+ // No dependencies, for the same reason as everything else here: moshcode
18
+ // installs by untarring a release and running node. Alternate screen, SGR mouse
19
+ // reporting and raw-mode keys are a few escape sequences, and every one of them
20
+ // is undone in a single restore path so a crash cannot leave a terminal with no
21
+ // cursor and the mouse captured.
22
+ import { attachSession, detectSubstrate, substrateNote } from "./herd.mjs";
23
+ import { roster } from "./herd-cli.mjs";
24
+ import { acid, amber, ash, bone, danger, dim } from "./ui.mjs";
25
+
26
+ /** The herd a session has no opinion about. */
27
+ export const DEFAULT_HERD = "main";
28
+
29
+ const ESC = {
30
+ altOn: "\x1b[?1049h", altOff: "\x1b[?1049l",
31
+ hideCursor: "\x1b[?25l", showCursor: "\x1b[?25h",
32
+ // 1000 = report button press/release, 1006 = SGR encoding, which is the only
33
+ // one that survives past column 95 — the older scheme packs coordinates into
34
+ // single bytes and simply cannot express a click on a wide terminal.
35
+ mouseOn: "\x1b[?1000h\x1b[?1006h", mouseOff: "\x1b[?1006l\x1b[?1000l",
36
+ clear: "\x1b[2J\x1b[H",
37
+ };
38
+
39
+ /**
40
+ * Group the roster into herds.
41
+ *
42
+ * Sessions carry their herd in the manifest; anything written before herds
43
+ * existed has none, and lands in `main` rather than in a group called
44
+ * "undefined".
45
+ */
46
+ export function groupByHerd(sessions) {
47
+ const herds = new Map();
48
+ for (const session of sessions) {
49
+ const key = session.herd || DEFAULT_HERD;
50
+ if (!herds.has(key)) herds.set(key, []);
51
+ herds.get(key).push(session);
52
+ }
53
+ return [...herds.entries()]
54
+ .sort(([a], [b]) => (a === DEFAULT_HERD ? -1 : b === DEFAULT_HERD ? 1 : a.localeCompare(b)))
55
+ .map(([name, members]) => ({ name, members: members.sort((a, b) => a.name.localeCompare(b.name)) }));
56
+ }
57
+
58
+ /**
59
+ * Flatten the groups into the rows the screen actually shows, so a click at
60
+ * line N and the highlighted row are the same lookup rather than two pieces of
61
+ * arithmetic that can disagree.
62
+ */
63
+ /**
64
+ * How many lines render() prints before the first group heading — the title and
65
+ * the blank under it.
66
+ *
67
+ * Shared by render() and layout() rather than written twice, because the two
68
+ * disagreeing is not a cosmetic bug: layout() is the click map, so a
69
+ * one-line drift silently sends every click to the row below the one under the
70
+ * pointer. A test pins them together.
71
+ */
72
+ export const HEADER_LINES = 2;
73
+
74
+ export function layout(groups, { top = HEADER_LINES + 1 } = {}) {
75
+ const rows = [];
76
+ for (const group of groups) {
77
+ rows.push({ kind: "herd", herd: group.name, count: group.members.length });
78
+ for (const session of group.members) rows.push({ kind: "session", session, herd: group.name });
79
+ rows.push({ kind: "gap" });
80
+ }
81
+ if (rows.length) rows.pop(); // no trailing gap
82
+ return rows.map((row, i) => ({ ...row, line: top + i }));
83
+ }
84
+
85
+ const MARK = { blocked: "!", working: "~", done: "✓", idle: "·", gone: "×", unknown: "?" };
86
+
87
+ const paint = (state, text) =>
88
+ state === "blocked" ? amber(text)
89
+ : state === "working" ? acid(text)
90
+ : state === "done" ? bone(text)
91
+ : state === "gone" ? danger(text)
92
+ : ash(text);
93
+
94
+ /** One frame. Pure, so the whole screen can be asserted in a test. */
95
+ export function render(rows, { selected = 0, width = 80, substrate = "tmux" } = {}) {
96
+ const out = [];
97
+ const total = rows.filter((r) => r.kind === "session").length;
98
+ const blocked = rows.filter((r) => r.kind === "session" && r.session.state === "blocked").length;
99
+ out.push(` ${bone("moshcode herd")}${ash(` ${total} member${total === 1 ? "" : "s"}`)}${blocked ? amber(` ${blocked} waiting on you`) : ""}`);
100
+ out.push("");
101
+
102
+ for (const row of rows) {
103
+ if (row.kind === "gap") { out.push(""); continue; }
104
+ if (row.kind === "herd") {
105
+ out.push(` ${ash(row.herd.toUpperCase())} ${dim(`(${row.count})`)}`);
106
+ continue;
107
+ }
108
+ const s = row.session;
109
+ const isSelected = rows[selected] === row;
110
+ const cursor = isSelected ? acid("▸") : " ";
111
+ const mark = paint(s.state, MARK[s.state] || "?");
112
+ const name = isSelected ? bone(s.name.padEnd(12)) : ash(s.name.padEnd(12));
113
+ const engine = ash(String(s.engine).padEnd(10));
114
+ const state = paint(s.state, s.state.padEnd(8));
115
+ const cwd = dim(tilde(s.cwd || "").slice(0, Math.max(10, width - 48)));
116
+ out.push(` ${cursor} ${mark} ${name} ${engine} ${state} ${cwd}`);
117
+ }
118
+
119
+ if (!total) {
120
+ out.push(` ${ash("the herd is empty.")}`);
121
+ out.push(` ${ash("start something with")} ${acid("moshcode herd shell")} ${ash("or")} ${acid("moshcode agents claude -d")}`);
122
+ }
123
+ out.push("");
124
+ out.push(` ${dim("click or ↑↓ to choose · enter to go in · r refresh · q quit")}`);
125
+ if (substrate !== "tmux") out.push(` ${dim(substrateNote(substrate) || "")}`);
126
+ return out.join("\r\n");
127
+ }
128
+
129
+ const tilde = (p) => {
130
+ const home = process.env.HOME || "";
131
+ return home && p.startsWith(home) ? `~${p.slice(home.length)}` : p;
132
+ };
133
+
134
+ /**
135
+ * Decode one SGR mouse report: ESC [ < button ; col ; row (M press | m release).
136
+ *
137
+ * Only presses matter here, and only button 0 (left) and the wheel. Returning
138
+ * null for everything else keeps the caller from acting on a release, which
139
+ * would otherwise fire every click twice.
140
+ */
141
+ export function parseMouse(sequence) {
142
+ const m = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(sequence);
143
+ if (!m) return null;
144
+ const [, button, col, row, kind] = m;
145
+ if (kind === "m") return null; // release
146
+ const b = Number(button);
147
+ if (b === 64) return { kind: "wheel", direction: -1 };
148
+ if (b === 65) return { kind: "wheel", direction: 1 };
149
+ if (b !== 0) return null;
150
+ return { kind: "click", col: Number(col), row: Number(row) };
151
+ }
152
+
153
+ /** Every mouse report in a chunk, so a fast click-drag cannot desync the parser. */
154
+ export function parseInput(buffer) {
155
+ const events = [];
156
+ const text = String(buffer);
157
+ const mouse = /\x1b\[<\d+;\d+;\d+[Mm]/g;
158
+ let match;
159
+ while ((match = mouse.exec(text))) {
160
+ const parsed = parseMouse(match[0]);
161
+ if (parsed) events.push(parsed);
162
+ }
163
+ if (events.length) return events;
164
+ for (const key of ["\x1b[A", "\x1b[B", "\r", "\n", "q", "\x03", "r", "j", "k"]) {
165
+ if (text.includes(key)) events.push({ kind: "key", key });
166
+ }
167
+ return events;
168
+ }
169
+
170
+ /** Move the selection to the next/previous *session* row, skipping headings. */
171
+ export function moveSelection(rows, from, delta) {
172
+ const selectable = rows.map((r, i) => (r.kind === "session" ? i : -1)).filter((i) => i >= 0);
173
+ if (!selectable.length) return from;
174
+ const at = selectable.indexOf(from);
175
+ if (at < 0) return selectable[0];
176
+ return selectable[Math.min(selectable.length - 1, Math.max(0, at + delta))];
177
+ }
178
+
179
+ /**
180
+ * The interactive list.
181
+ *
182
+ * Everything the terminal had is restored through one `restore()`, wired to
183
+ * normal exit and to the signals that otherwise kill a raw-mode process — a
184
+ * crash here must not leave someone with no cursor, no echo, and the mouse
185
+ * still captured by a program that is gone.
186
+ */
187
+ export async function herdUi({
188
+ stdin = process.stdin,
189
+ stdout = process.stdout,
190
+ attach = attachSession,
191
+ read = roster,
192
+ refreshMs = 2000,
193
+ } = {}) {
194
+ const substrate = detectSubstrate();
195
+ if (!stdin.isTTY || !stdout.isTTY) {
196
+ stdout.write("moshcode herd ui needs an interactive terminal — try `moshcode ps`\n");
197
+ return 1;
198
+ }
199
+
200
+ let rows = layout(groupByHerd(read()));
201
+ let selected = rows.findIndex((r) => r.kind === "session");
202
+ if (selected < 0) selected = 0;
203
+ let done = false;
204
+ let restored = false;
205
+
206
+ const wasRaw = Boolean(stdin.isRaw);
207
+ const restore = () => {
208
+ if (restored) return;
209
+ restored = true;
210
+ stdout.write(ESC.mouseOff + ESC.showCursor + ESC.altOff);
211
+ try { stdin.setRawMode?.(wasRaw); } catch { /* already gone */ }
212
+ stdin.pause();
213
+ };
214
+ const enter = () => {
215
+ restored = false;
216
+ stdout.write(ESC.altOn + ESC.hideCursor + ESC.mouseOn);
217
+ try { stdin.setRawMode?.(true); } catch { /* not a tty */ }
218
+ stdin.resume();
219
+ };
220
+ const onSignal = () => { restore(); process.exit(130); };
221
+ process.on("exit", restore);
222
+ process.on("SIGINT", onSignal);
223
+ process.on("SIGTERM", onSignal);
224
+
225
+ const draw = () => {
226
+ if (done) return;
227
+ stdout.write(ESC.clear + render(rows, { selected, width: stdout.columns || 80, substrate }));
228
+ };
229
+
230
+ const refresh = () => {
231
+ const previous = rows[selected];
232
+ rows = layout(groupByHerd(read()));
233
+ // Keep the highlight on the same session across a refresh, not on the same
234
+ // line number — a session finishing above it would otherwise slide the
235
+ // selection onto something else just as someone pressed enter.
236
+ const again = rows.findIndex((r) => r.kind === "session" && r.session?.name === previous?.session?.name);
237
+ selected = again >= 0 ? again : Math.max(0, rows.findIndex((r) => r.kind === "session"));
238
+ draw();
239
+ };
240
+
241
+ enter();
242
+ draw();
243
+ const timer = setInterval(refresh, refreshMs);
244
+ const onResize = () => draw();
245
+ stdout.on("resize", onResize);
246
+
247
+ const openSelected = async () => {
248
+ const row = rows[selected];
249
+ if (row?.kind !== "session" || !row.session.alive) return;
250
+ // Give the terminal back before handing it to tmux, and take it again after
251
+ // — attaching inside our alternate screen with the mouse captured would put
252
+ // the agent's own mouse handling in a fight with ours.
253
+ restore();
254
+ await attach(row.session.name, { substrate });
255
+ if (done) return;
256
+ enter();
257
+ refresh();
258
+ };
259
+
260
+ await new Promise((resolve) => {
261
+ const onData = async (buf) => {
262
+ for (const event of parseInput(buf)) {
263
+ if (event.kind === "key" && (event.key === "q" || event.key === "\x03")) {
264
+ done = true;
265
+ resolve();
266
+ return;
267
+ }
268
+ if (event.kind === "key" && (event.key === "\x1b[A" || event.key === "k")) selected = moveSelection(rows, selected, -1);
269
+ else if (event.kind === "key" && (event.key === "\x1b[B" || event.key === "j")) selected = moveSelection(rows, selected, 1);
270
+ else if (event.kind === "key" && event.key === "r") { refresh(); continue; }
271
+ else if (event.kind === "wheel") selected = moveSelection(rows, selected, event.direction);
272
+ else if (event.kind === "click") {
273
+ const hit = rows.find((r) => r.kind === "session" && r.line === event.row);
274
+ if (!hit) continue;
275
+ selected = rows.indexOf(hit);
276
+ draw();
277
+ await openSelected();
278
+ continue;
279
+ }
280
+ else if (event.kind === "key") { await openSelected(); continue; }
281
+ draw();
282
+ }
283
+ };
284
+ stdin.on("data", onData);
285
+ });
286
+
287
+ clearInterval(timer);
288
+ stdout.off("resize", onResize);
289
+ stdin.off("data", () => {});
290
+ restore();
291
+ process.off("exit", restore);
292
+ process.off("SIGINT", onSignal);
293
+ process.off("SIGTERM", onSignal);
294
+ stdout.write("\n");
295
+ return 0;
296
+ }
package/src/herd.mjs CHANGED
@@ -257,6 +257,10 @@ export function tmuxStartPlan({ name, cwd, command }) {
257
257
  "-f", "/dev/null",
258
258
  "new-session", "-d", "-s", name, "-c", cwd, command,
259
259
  ";", "set-option", "-t", name, "remain-on-exit", "on",
260
+ // Mouse on, so a click selects a pane and the status line's window list is
261
+ // clickable once you are inside. This server is moshcode's and starts from
262
+ // no config, so it is not overriding a preference anyone expressed.
263
+ ";", "set-option", "-t", name, "mouse", "on",
260
264
  ];
261
265
  }
262
266
 
@@ -734,6 +738,9 @@ export function listSessions({ substrate = detectSubstrate(), runner = spawnSync
734
738
  return {
735
739
  name,
736
740
  engine: meta.engine || "?",
741
+ // Sessions started before herds existed have none. They belong to `main`
742
+ // rather than to a group rendered as "undefined".
743
+ herd: meta.herd || "main",
737
744
  cwd: meta.cwd || "",
738
745
  created: meta.created || null,
739
746
  age: meta.created ? now - meta.created : null,