moshcode 0.34.0 → 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,38 @@ 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
+
128
160
  ### A workspace: a few shells and an agent
129
161
 
130
162
  This is what most people actually want — a couple of shells to work in and an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.34.0",
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,6 +79,8 @@ 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 ui", "the clickable list — start here"],
83
+ ["", ""],
82
84
  ["# a workspace: two shells and an agent, none of which die with this terminal", ""],
83
85
  ["moshcode herd shell --name work", "a plain $SHELL you can come back to"],
84
86
  ["moshcode herd shell --name logs", "another one"],
@@ -736,6 +738,9 @@ export const HERD_VERBS = [
736
738
  ["--agent", "autonomous mode — bypasses the engine's approvals", ""],
737
739
  ["--json", "machine-readable", ""],
738
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: [] },
739
744
  { name: "run", description: "run ANY command in the herd — an agent moshcode does not ship, a build, a script",
740
745
  synopsis: [["moshcode herd run [--name <slug>] -- <command…>", "everything after -- is the command"]],
741
746
  flags: [
package/src/herd-cli.mjs CHANGED
@@ -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.`));
@@ -203,7 +205,7 @@ export function herdRun(argv, { write = console.log, shell = false } = {}) {
203
205
  const substrate = requireSubstrate(write);
204
206
  if (!substrate) return EXIT.usage;
205
207
 
206
- const flags = { name: null, cwd: process.cwd(), json: false };
208
+ const flags = { name: null, cwd: process.cwd(), json: false, herd: "main" };
207
209
  const command = [];
208
210
  let afterSeparator = false;
209
211
  for (let i = 0; i < argv.length; i++) {
@@ -215,6 +217,8 @@ export function herdRun(argv, { write = console.log, shell = false } = {}) {
215
217
  else if (a === "--name") flags.name = argv[++i];
216
218
  else if (a.startsWith("--name=")) flags.name = a.slice(7);
217
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));
218
222
  else if (a === "--json") flags.json = true;
219
223
  else command.push(a);
220
224
  }
@@ -242,9 +246,10 @@ export function herdRun(argv, { write = console.log, shell = false } = {}) {
242
246
  write(err(String(started.error?.message || started.error)));
243
247
  return EXIT.usage;
244
248
  }
249
+ rememberSession(name, { herd: flags.herd });
245
250
 
246
251
  if (flags.json) {
247
- write(JSON.stringify({ name, engine: label, cwd: flags.cwd, substrate }, null, 2));
252
+ write(JSON.stringify({ name, engine: label, herd: flags.herd, cwd: flags.cwd, substrate }, null, 2));
248
253
  return EXIT.matched;
249
254
  }
250
255
  write(ok(`${bone(name)} — ${label} running in the herd. the prompt is yours.`));
@@ -281,8 +286,8 @@ export function splitDetachArgs(args = []) {
281
286
  export function herdPs(argv, { write = console.log } = {}) {
282
287
  const rows = roster();
283
288
  if (argv.includes("--json")) {
284
- write(JSON.stringify(rows.map(({ name, engine, state, authority, cwd, age, alive, attached, substrate }) => ({
285
- 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,
286
291
  })), null, 2));
287
292
  return EXIT.matched;
288
293
  }
@@ -718,6 +723,9 @@ export function herdStop(argv, { write = console.log } = {}) {
718
723
  // ---------------------------------------------------------------------------
719
724
 
720
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),
721
729
  ps: herdPs, list: herdPs, status: herdStatus,
722
730
  start: herdStart, run: herdRun, shell: herdShell,
723
731
  attach: herdAttach, kill: herdKill, prune: herdPrune,
@@ -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,