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 +72 -0
- package/package.json +1 -1
- package/src/cli-schema.mjs +32 -4
- package/src/help.mjs +5 -1
- package/src/herd-cli.mjs +89 -7
- package/src/herd-state.mjs +19 -1
- package/src/herd-ui.mjs +296 -0
- package/src/herd.mjs +7 -0
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
package/src/cli-schema.mjs
CHANGED
|
@@ -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
|
|
83
|
-
["
|
|
84
|
-
["
|
|
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: "
|
|
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
|
-
|
|
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,
|
|
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,
|
package/src/herd-state.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/herd-ui.mjs
ADDED
|
@@ -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,
|