moshcode 0.37.0 → 0.38.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
@@ -219,6 +219,13 @@ content pane", and the real attach would be a tmux client inside a tmux client.
219
219
  Output grows the bar over the content for as long as you are reading it, then it
220
220
  collapses back to one row.
221
221
 
222
+ **`moshcode attach <name>` gets the bar too.** A session you attach to directly
223
+ grows the same one-line prompt along the bottom for as long as you are there,
224
+ and it is taken away again when you detach — so a member is a plain member when
225
+ nobody is looking at it. `show <name>` from that bar switches you to another
226
+ member (and gives that one a bar before you land in it). The bar is the bottom
227
+ row either way, which is why one key finds it in both places.
228
+
222
229
  The right-hand pane is not a picture of a session — it *is* the session's pane,
223
230
  moved in. tmux's model is session → window → pane, so moving between *windows*
224
231
  cannot keep anything on screen; but `join-pane` moves a running pane into an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.37.0",
3
+ "version": "0.38.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": {
@@ -115,10 +115,13 @@ export const CORE_CLI_COMMANDS = [
115
115
  name: "attach",
116
116
  group: "runtime",
117
117
  description: "attach this terminal to a herd session",
118
- synopsis: [["moshcode attach <name>", "detach again with Ctrl-b d (or Ctrl-] without tmux)"]],
118
+ synopsis: [["moshcode attach <name>", "F12 for the mosh bar · Ctrl-b d detaches (Ctrl-] without tmux)"]],
119
119
  examples: [["moshcode attach api", ""]],
120
120
  seeAlso: ["ps", "herd", "kill"],
121
- note: "detaching leaves the session running; ending it is `moshcode kill`. "
121
+ note: "under tmux the session gets a one-line mosh bar along the bottom for as long as you are "
122
+ + "attached, so the way out is on screen even when the agent has the keyboard: F12 reaches it, "
123
+ + "Esc goes back, `detach` leaves. it is taken away again when you detach. "
124
+ + "detaching leaves the session running; ending it is `moshcode kill`. "
122
125
  + "the whole herd shares one tmux server, so from inside any session Ctrl-b s picks another, "
123
126
  + "Ctrl-b ) and Ctrl-b ( step through them, and Ctrl-b L goes back to the last one — "
124
127
  + "no switcher under the no-tmux fallback, where Ctrl-] detaches instead.",
package/src/herd-bar.mjs CHANGED
@@ -54,6 +54,90 @@ export function paneRoles(target, { runner = spawnSync } = {}) {
54
54
  return roles;
55
55
  }
56
56
 
57
+ /** How a bar pane starts itself. Separated so tests can run a stand-in. */
58
+ export function barCommand(self = process.argv[1]) {
59
+ return `${process.execPath} ${self} herd bar`;
60
+ }
61
+
62
+ /** The window a pane lives in, as a target string. */
63
+ export function ownTarget({ runner = spawnSync, me = process.env.TMUX_PANE } = {}) {
64
+ if (!me) return null;
65
+ const r = tmux(["display-message", "-p", "-t", me, "#{session_name}:#{window_index}"], { runner });
66
+ return r.ok ? r.stdout.trim() || null : null;
67
+ }
68
+
69
+ /**
70
+ * Put a bar at the bottom of `target`, or find the one already there.
71
+ *
72
+ * Idempotent, because both the workspace and every attach want one and neither
73
+ * should care which of them got there first.
74
+ */
75
+ export function ensureBar(target, { runner = spawnSync, command = null } = {}) {
76
+ const existing = paneRoles(target, { runner }).bar;
77
+ if (existing) return { paneId: existing.paneId, created: false };
78
+ const made = tmux(
79
+ ["split-window", "-t", target, "-f", "-v", "-l", String(BAR_HEIGHT), "-P", "-F", "#{pane_id}", command],
80
+ { runner },
81
+ );
82
+ if (!made.ok) return { paneId: null, created: false };
83
+ const paneId = made.stdout.trim().split("\n")[0];
84
+ if (!paneId) return { paneId: null, created: false };
85
+ tmux(["select-pane", "-t", paneId, "-T", BAR_TITLE], { runner });
86
+ return { paneId, created: true };
87
+ }
88
+
89
+ /**
90
+ * Bind the key that reaches the bar.
91
+ *
92
+ * `{bottom-right}` rather than a pane id, so one binding serves the workspace
93
+ * and every attached session — in both, the bar is the bottom row. A pane id
94
+ * would have pinned the key to whichever bar happened to be built last.
95
+ *
96
+ * The root table is what makes it work at all: tmux claims the key before the
97
+ * pane's application ever sees it, which is the whole point when the pane holds
98
+ * an agent that has taken the keyboard.
99
+ */
100
+ export function bindJumpKey({ runner = spawnSync } = {}) {
101
+ return tmux(["bind-key", "-n", BAR_KEY, "select-pane", "-t", "{bottom-right}"], { runner }).ok;
102
+ }
103
+
104
+ /** Drop the bar from a window, leaving whatever else is in it alone. */
105
+ export function removeBar(target, { runner = spawnSync } = {}) {
106
+ const roles = paneRoles(target, { runner });
107
+ if (!roles.bar || !roles.content) return false;
108
+ return tmux(["kill-pane", "-t", roles.bar.paneId], { runner }).ok;
109
+ }
110
+
111
+ /**
112
+ * Take the bar back out of every session nobody is looking at.
113
+ *
114
+ * A bar left behind is not cosmetic: `kill` ends a member by killing its pane,
115
+ * so a session holding a leftover bar outlives the member it was named for and
116
+ * keeps showing up on the roster. Detaching cleans up after itself, but a
117
+ * crashed client cannot, so this also runs on the way in.
118
+ *
119
+ * Sessions with a client attached are skipped — someone else is using that bar.
120
+ */
121
+ export function sweepBars({ runner = spawnSync, except = null } = {}) {
122
+ const r = tmux(["list-panes", "-a", "-F",
123
+ "#{session_name}\t#{window_index}\t#{pane_title}\t#{session_attached}"], { runner });
124
+ if (!r.ok) return 0;
125
+ const windows = new Map();
126
+ for (const line of r.stdout.split("\n")) {
127
+ const [session, window, title, attached] = line.split("\t");
128
+ if (!session || session === except || attached !== "0") continue;
129
+ const key = `${session}:${window}`;
130
+ const seen = windows.get(key) || { bars: 0, others: 0 };
131
+ if (title === BAR_TITLE) seen.bars += 1; else seen.others += 1;
132
+ windows.set(key, seen);
133
+ }
134
+ let removed = 0;
135
+ for (const [target, seen] of windows) {
136
+ if (seen.bars && seen.others && removeBar(target, { runner })) removed += 1;
137
+ }
138
+ return removed;
139
+ }
140
+
57
141
  /* --------------------------------------------------------------- line editing */
58
142
 
59
143
  /**
@@ -118,10 +202,14 @@ export async function herdBar({
118
202
  stdin = process.stdin,
119
203
  stdout = process.stdout,
120
204
  runner = spawnSync,
121
- target = "herd:ui",
205
+ target = null,
122
206
  run = null,
123
207
  } = {}) {
124
208
  const me = process.env.TMUX_PANE;
209
+ // The bar runs in the workspace AND under a plain attach, so it asks where it
210
+ // is rather than assuming. Everything below keys off that one answer.
211
+ const here = target || ownTarget({ runner, me }) || "herd:ui";
212
+ const inWorkspace = () => !!paneRoles(here, { runner }).sidebar;
125
213
  const herdCommand = run || (async (argv, options) => (await import("./herd-cli.mjs")).herdCommand(argv, options));
126
214
 
127
215
  let line = "";
@@ -146,10 +234,26 @@ export async function herdBar({
146
234
  };
147
235
  /** Give the keyboard back to whatever is on screen. */
148
236
  const toContent = () => {
149
- const roles = paneRoles(target, { runner });
237
+ const roles = paneRoles(here, { runner });
150
238
  if (roles.content) tmux(["select-pane", "-t", roles.content.paneId], { runner });
151
239
  };
152
240
 
241
+ /**
242
+ * `show` means two different things and both are right.
243
+ *
244
+ * In the workspace it swaps the content pane. Under a plain attach there is
245
+ * no content pane to swap, so it switches the client to that member — and
246
+ * gives that member a bar first, or you would arrive somewhere with no way
247
+ * back out, which is the bug this whole thing exists to fix.
248
+ */
249
+ const showElsewhere = async (name) => {
250
+ const { paneIndex } = await import("./herd.mjs");
251
+ const found = paneIndex({ runner }).get(name);
252
+ if (!found) return false;
253
+ ensureBar(`${found.session}:${found.windowId}`, { runner, command: barCommand() });
254
+ return tmux(["switch-client", "-t", found.session], { runner }).ok;
255
+ };
256
+
153
257
  const submit = async () => {
154
258
  const typed = line;
155
259
  line = "";
@@ -160,8 +264,13 @@ export async function herdBar({
160
264
  if (command.kind === "detach") { tmux(["detach-client"], { runner }); return false; }
161
265
  if (command.kind === "show") {
162
266
  const [name] = command.argv;
163
- const { showMember } = await import("./herd-workspace.mjs");
164
- const okShown = name && showMember(name, { runner, me });
267
+ let okShown = false;
268
+ if (name && inWorkspace()) {
269
+ const { showMember } = await import("./herd-workspace.mjs");
270
+ okShown = showMember(name, { runner, me });
271
+ } else if (name) {
272
+ okShown = await showElsewhere(name);
273
+ }
165
274
  if (!okShown) { show([ash(`no session named ${JSON.stringify(name || "")} — try ps`)]); return true; }
166
275
  collapse(); draw(); toContent();
167
276
  return true;
@@ -173,8 +282,24 @@ export async function herdBar({
173
282
  return true;
174
283
  };
175
284
 
285
+ /**
286
+ * Keep the bar one row.
287
+ *
288
+ * tmux scales panes proportionally when the window resizes, so a bar built
289
+ * before a client attached came back three rows tall once one did — the pane
290
+ * was created against an 80x24 window and stretched to fit 100x30. Nothing
291
+ * outside can predict when that happens, but the bar gets a resize event for
292
+ * it, so the bar is the thing that fixes it.
293
+ */
294
+ const keepThin = () => {
295
+ if (open) return;
296
+ tmux(["resize-pane", "-t", me, "-y", String(BAR_HEIGHT)], { runner });
297
+ };
298
+
176
299
  try { stdin.setRawMode?.(true); } catch { /* not a tty */ }
177
300
  stdin.resume();
301
+ stdout.on?.("resize", () => { keepThin(); draw(); });
302
+ keepThin();
178
303
  draw();
179
304
 
180
305
  await new Promise((resolve) => {
package/src/herd-cli.mjs CHANGED
@@ -10,7 +10,7 @@ import path from "node:path";
10
10
 
11
11
  import {
12
12
  attachSession, capture, defaultName, detectSubstrate, forgetSession, HERD_SOCKET,
13
- herdDir, killSession, listSessions, readManifest, rememberSession, sendKeys, sendPrompt,
13
+ herdDir, killSession, listSessions, paneIndex, readManifest, rememberSession, sendKeys, sendPrompt,
14
14
  slugifyName, startSession, stopRuntime, substrateNote, validName, NAME_RE,
15
15
  } from "./herd.mjs";
16
16
  import { clearReport, reportState, STATES, withState } from "./herd-state.mjs";
@@ -328,9 +328,32 @@ export async function herdAttach(argv, { write = console.log } = {}) {
328
328
  // this whole feature is someone quitting a session they meant to leave
329
329
  // running, and the only defence is telling them the key first.
330
330
  const substrate = detectSubstrate();
331
- write(info(substrate === "tmux" ? "detach with Ctrl-b d — the session keeps running." : "detach with Ctrl-] — the session keeps running."));
331
+
332
+ // Give the session a mosh bar, so the way out is on screen the whole time
333
+ // rather than in a line that the agent's first repaint scrolls away. Only a
334
+ // member sitting in its own session: a tiled one shares a window with its
335
+ // neighbours and would be handing them a footer they did not ask for.
336
+ const bar = await import("./herd-bar.mjs");
337
+ let barTarget = null;
338
+ if (substrate === "tmux") {
339
+ bar.sweepBars({ runner: undefined, except: "herd" });
340
+ const found = paneIndex().get(name);
341
+ if (found && found.session === name) {
342
+ barTarget = `${found.session}:${found.windowId}`;
343
+ bar.ensureBar(barTarget, { command: bar.barCommand() });
344
+ bar.bindJumpKey({});
345
+ }
346
+ }
347
+
348
+ write(info(substrate === "tmux"
349
+ ? `detach with Ctrl-b d — the session keeps running.${barTarget ? ` ${bar.BAR_KEY} for the mosh bar.` : ""}`
350
+ : "detach with Ctrl-] — the session keeps running."));
332
351
 
333
352
  const result = await attachSession(name, { substrate });
353
+ // Take it back out on the way through, so a member is a member again: `kill`
354
+ // ends one by killing its pane, and a session still holding a bar would
355
+ // outlive the member and keep its name on the roster.
356
+ if (barTarget) bar.removeBar(barTarget, {});
334
357
  if (!result.ok) { write(err(String(result.error?.message || result.error))); return EXIT.usage; }
335
358
 
336
359
  const after = findSession(name);
@@ -21,7 +21,7 @@ import { spawn, spawnSync } from "node:child_process";
21
21
  import { HERD_SOCKET, detectSubstrate, paneIndex, readManifest, tmux } from "./herd.mjs";
22
22
  import { roster } from "./herd-cli.mjs";
23
23
  import { groupByHerd, parseInput } from "./herd-ui.mjs";
24
- import { BAR_HEIGHT, BAR_KEY, BAR_TITLE, SIDEBAR_TITLE, paneRoles } from "./herd-bar.mjs";
24
+ import { BAR_KEY, BAR_TITLE, SIDEBAR_TITLE, barCommand, bindJumpKey, ensureBar, paneRoles } from "./herd-bar.mjs";
25
25
  import { acid, amber, ash, bone, danger, dim, err, info, ok } from "./ui.mjs";
26
26
 
27
27
  export const WORKSPACE = "herd";
@@ -82,34 +82,11 @@ export async function herdUi(argv = [], { write = console.log, spawner = spawn,
82
82
 
83
83
  /* ------------------------------------------------------------------ the bar */
84
84
 
85
- /** How the bar pane starts itself. Separated so tests can run a stand-in. */
86
- export function barCommand(self = process.argv[1]) {
87
- return `${process.execPath} ${self} herd bar`;
88
- }
89
-
90
- /**
91
- * Add the one-line mosh prompt under the content, and the key that reaches it.
92
- *
93
- * The binding goes in tmux's root table, so it is claimed before the pane's
94
- * application ever sees it — that is what makes it work from inside an agent
95
- * that has taken the keyboard, which is the case the bar exists for. It also
96
- * switches the client first, so it is a way out of a member you attached to
97
- * directly and not only of the workspace.
98
- */
85
+ /** Add the one-line mosh prompt under the content, and the key that reaches it. */
99
86
  export function buildBar({ runner = spawnSync, command = barCommand() } = {}) {
100
- const made = tmux(
101
- ["split-window", "-t", TARGET, "-f", "-v", "-l", String(BAR_HEIGHT), "-P", "-F", "#{pane_id}", command],
102
- { runner },
103
- );
104
- if (!made.ok) return null;
105
- const paneId = made.stdout.trim().split("\n")[0];
87
+ const { paneId } = ensureBar(TARGET, { runner, command });
106
88
  if (!paneId) return null;
107
- tmux(["select-pane", "-t", paneId, "-T", BAR_TITLE], { runner });
108
- // One string, not separate arguments: a bare ";" argument ends the bind-key
109
- // command itself, so tmux binds the first command and runs the second once,
110
- // now. That silently produced a key that switched sessions and did nothing
111
- // else — the binding has to arrive as a single command sequence.
112
- tmux(["bind-key", "-n", BAR_KEY, `switch-client -t ${WORKSPACE} ; select-pane -t ${paneId}`], { runner });
89
+ bindJumpKey({ runner });
113
90
  tmux(["select-pane", "-t", `${TARGET}.0`], { runner });
114
91
  return paneId;
115
92
  }
package/src/herd.mjs CHANGED
@@ -629,7 +629,19 @@ export function killSession(name, { substrate = detectSubstrate(), runner = spaw
629
629
  if (substrate === "tmux") {
630
630
  // kill-pane, not kill-session: a tiled member shares its session with
631
631
  // every other tiled member, and killing that would take the lot.
632
+ const found = paneIndex({ runner }).get(name);
632
633
  const r = tmux(["kill-pane", "-t", target(name, { runner })], { runner });
634
+ // A member being attached to has a mosh bar under it, and the bar would
635
+ // hold the session open after its member is gone — an empty room still
636
+ // answering to the dead member's name on the roster. If that is all that is
637
+ // left, take the room too.
638
+ if (r.ok && found) {
639
+ const left = tmux(["list-panes", "-t", found.session, "-F", "#{pane_title}"], { runner });
640
+ const titles = left.ok ? left.stdout.split("\n").filter(Boolean) : [];
641
+ if (titles.length && titles.every((t) => t === "mosh-bar")) {
642
+ tmux(["kill-session", "-t", found.session], { runner });
643
+ }
644
+ }
633
645
  forgetSession(name);
634
646
  return r.ok ? { ok: true } : { ok: false, error: new Error(r.stderr.trim() || "no such session") };
635
647
  }