moshcode 0.74.0 → 0.76.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.74.0",
3
+ "version": "0.76.0",
4
4
  "type": "module",
5
5
  "description": "moshcode \u2014 a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
6
6
  "repository": {
package/src/billing.mjs CHANGED
@@ -19,6 +19,7 @@ import { spawnSync } from "node:child_process";
19
19
 
20
20
  import { loadBusiness, loadTimers, newId, updateBusiness, updateTimers } from "./business-store.mjs";
21
21
  import { clientLabel, parseFields, resolveClient } from "./clients.mjs";
22
+ import { captureSpec } from "./pty.mjs";
22
23
  import { GATEWAYS, defaultGateway, gatewayState } from "./payments.mjs";
23
24
  import { chargeFor, describeRate, formatMoney, isDollarPegged, isFiat, rateFor } from "./rates.mjs";
24
25
  import { humanDuration, selectEntries, windowFrom } from "./timer.mjs";
@@ -289,7 +290,12 @@ function handOff(record, invoice, business, fields, write, run) {
289
290
  return 0;
290
291
  }
291
292
 
292
- const result = run("coinpay", args, { stdio: "inherit" });
293
+ // Mirrored like every other hand-off: sending an invoice is exactly the kind
294
+ // of thing you want to read back from the session page afterwards.
295
+ const launch = captureSpec({ cmd: "coinpay", args });
296
+ let result;
297
+ try { result = run(launch.cmd, launch.args, { stdio: "inherit" }); }
298
+ finally { launch.stop(); }
293
299
  if (result?.error) { write(err(String(result.error.message || result.error))); return 1; }
294
300
  if (result?.status) { write(err(`coinpay exited ${result.status} — invoice ${record.id} is still a local draft`)); return result.status; }
295
301
  updateBusiness((data) => {
package/src/commands.mjs CHANGED
@@ -22,6 +22,7 @@ import { capture, killSession, remoteStatus, sendPrompt } from "./herd.mjs";
22
22
  import { herdStart, isRemoteMember, roster, waitForMany, waitMember } from "./herd-cli.mjs";
23
23
  import { endTask, findTask, readTasks, startTask } from "./herd-tasks.mjs";
24
24
  import { shellInvocation } from "./shell.mjs";
25
+ import { captureSpec } from "./pty.mjs";
25
26
  import { identity, loginAuto, logout as forgetCreds } from "./auth.mjs";
26
27
  import { expandAlias, getAlias, loadAliases, removeAlias, setAlias } from "./aliases.mjs";
27
28
  import { CORE_CLI_COMMAND_NAMES, PIT_COMMANDS } from "./cli-schema.mjs";
@@ -116,7 +117,15 @@ const SHELL = {
116
117
  // has the reasoning, including why a headless run stays non-interactive.
117
118
  const { shell: sh, args: shArgs } = shellInvocation(cmd);
118
119
  ctx.out(` ▶ shell: ${cmd}`);
119
- const res = spawnSync(sh, shArgs, { stdio: "inherit" });
120
+ // Captured for the session mirror like the pit's own `!cmd`. A blocking
121
+ // spawn holds the event loop, so the follower's poll never runs and the
122
+ // whole command arrives in the drain stop() does — batched rather than
123
+ // live, which is still the difference between reading it from a phone and
124
+ // not.
125
+ const launch = captureSpec({ cmd: sh, args: shArgs });
126
+ let res;
127
+ try { res = spawnSync(launch.cmd, launch.args, { stdio: "inherit" }); }
128
+ finally { launch.stop(); }
120
129
  if (res.error) throw res.error;
121
130
  const code = res.status ?? 1;
122
131
  if (code !== 0) {
package/src/engines.mjs CHANGED
@@ -32,11 +32,12 @@
32
32
  // a session that starts fresh is a small disappointment, and one that starts
33
33
  // with a flag the engine does not have is a crash.
34
34
  import { spawn } from "node:child_process";
35
- import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
36
- import { homedir, tmpdir } from "node:os";
35
+ import { existsSync, readFileSync, statSync } from "node:fs";
36
+ import { homedir } from "node:os";
37
37
  import path from "node:path";
38
38
 
39
- import { followFile, ptyEnabled, ptySpec, scriptFlavor, stripScriptBanner } from "./pty.mjs";
39
+ import { setActiveChildInput } from "./mirror.mjs";
40
+ import { captureSpec } from "./pty.mjs";
40
41
 
41
42
  export const ENGINES = {
42
43
  opencode: {
@@ -439,21 +440,29 @@ export function runCmd(cmd, args = [], { capture = false } = {}) {
439
440
  let child;
440
441
  const spec = spawnSpec(cmd, args);
441
442
  const stdio = capture ? ["inherit", "pipe", "pipe"] : "inherit";
442
- try { child = spawn(spec.cmd, spec.args, { stdio }); }
443
- catch (e) { resolve({ ok: false, error: e }); return; }
443
+ // The `capture` branch already reaches a watching browser: it re-writes
444
+ // every byte through this process's own stdout/stderr, which the mirror
445
+ // tees. The inherited branch does not — those bytes go to the tty and
446
+ // nowhere else — so it goes under a pty when a mirror is live. This is what
447
+ // an upgrade, a plugin install and an `mcp add` all run through, and all
448
+ // three used to be a rule, a blank stretch, and a result line.
449
+ const launch = capture ? { ...spec, stop: () => {} } : captureSpec(spec);
450
+ const finish = (result) => { try { launch.stop(); } catch { /* already drained */ } resolve(result); };
451
+ try { child = spawn(launch.cmd, launch.args, { stdio }); }
452
+ catch (e) { finish({ ok: false, error: e }); return; }
444
453
  let output = "";
445
454
  if (capture) {
446
455
  for (const [stream, sink] of [[child.stdout, process.stdout], [child.stderr, process.stderr]]) {
447
456
  stream?.on("data", (chunk) => { output += chunk.toString(); sink.write(chunk); });
448
457
  }
449
458
  }
450
- child.on("error", (e) => resolve({ ok: false, error: e, output }));
459
+ child.on("error", (e) => finish({ ok: false, error: e, output }));
451
460
  // "exit" fires as soon as the process is gone, which with pipes can leave
452
461
  // the last chunk still queued — the one line we are trying to read. "close"
453
462
  // waits for the streams too. With stdio inherited there are no streams, so
454
463
  // the two are the same moment and existing callers are unaffected; the
455
464
  // distinction is kept explicit so neither branch changes by accident.
456
- child.on(capture ? "close" : "exit", (code, signal) => resolve({ ok: true, code, signal, output }));
465
+ child.on(capture ? "close" : "exit", (code, signal) => finish({ ok: true, code, signal, output }));
457
466
  });
458
467
  }
459
468
 
@@ -501,38 +510,27 @@ export function openPassthrough(target, args = [], { onOutput } = {}) {
501
510
  // the child the tty's own file descriptors, so none of its bytes ever pass
502
511
  // through this process. See src/pty.mjs for why this is script(1) and not
503
512
  // a pipe or node-pty.
504
- let transcript = null;
505
- let workDir = null;
506
- let stopFollow = null;
507
- let launch = { ...spec, stdio: "inherit" };
508
- if (ptyEnabled(onOutput)) {
509
- try {
510
- workDir = mkdtempSync(path.join(tmpdir(), "moshcode-pty-"));
511
- transcript = path.join(workDir, "transcript");
512
- writeFileSync(transcript, "");
513
- const wrapped = ptySpec(spec.cmd, spec.args, transcript, scriptFlavor());
514
- if (wrapped) {
515
- launch = { ...wrapped, stdio: "inherit" };
516
- let first = true;
517
- stopFollow = followFile(transcript, (chunk) => {
518
- const clean = stripScriptBanner(chunk, first);
519
- first = false;
520
- if (clean) onOutput(clean);
521
- });
522
- }
523
- } catch {
524
- // Capture is a nicety; never let it stop the session from opening.
525
- transcript = null;
526
- }
527
- }
528
-
513
+ //
514
+ // `input` asks for the same pty to be one we can type into. An engine is
515
+ // the whole reason it exists: it puts up menus and trust prompts that only
516
+ // move for a keypress, and until we owned its stdin a session page could
517
+ // watch one of those appear and had no way to answer it.
518
+ const launch = captureSpec(spec, onOutput, { input: true });
519
+ // Route web keys here for as long as this child is up, and only when there
520
+ // is really somewhere for them to go — an unmirrored pit, or a box with no
521
+ // `script(1)`, still runs the plain inherited launch, and saying otherwise
522
+ // would have pressKey silently swallow keys the pit could have handled.
523
+ // Registered before the spawn on purpose: the fifo buffers, so a key that
524
+ // arrives while the engine is still starting is delivered, not dropped.
525
+ const typeable = launch.stdio !== "inherit";
526
+ if (typeable) setActiveChildInput(launch.write);
529
527
  const cleanup = () => {
530
- try { stopFollow?.(); } catch { /* nothing left to drain */ }
531
- if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* temp dir */ } }
528
+ if (typeable) setActiveChildInput(null);
529
+ launch.stop();
532
530
  };
533
531
 
534
532
  let child;
535
- try { child = spawn(launch.cmd, launch.args, { stdio: "inherit", env }); }
533
+ try { child = spawn(launch.cmd, launch.args, { stdio: launch.stdio, env }); }
536
534
  catch (e) { cleanup(); resolve({ ok: false, error: e }); return; }
537
535
  child.on("error", (e) => { cleanup(); resolve({ ok: false, error: e }); });
538
536
  child.on("exit", (code, signal) => { cleanup(); resolve({ ok: true, code, signal }); });
package/src/mirror.mjs CHANGED
@@ -6,9 +6,12 @@
6
6
  // every network call is swallowed, because a flaky link must never take down
7
7
  // the terminal you're actually working in.
8
8
  //
9
- // What it can't see: once an engine takes the terminal (`/agents claude`), the
10
- // child writes straight to the tty on its own fd those bytes never pass
11
- // through this process. The mirror shows the hand-off, not the engine's screen.
9
+ // A child that takes the terminal (`/agents claude`) writes straight to the tty
10
+ // on its own fd, so none of its bytes pass through this process on their own.
11
+ // Both directions are handled in src/pty.mjs instead: its output is copied out
12
+ // of a pty transcript, and its stdin is a fifo we hold, which is what lets a key
13
+ // pressed on the session page answer an engine's prompt rather than land in the
14
+ // pit behind it.
12
15
  import os from "node:os";
13
16
  import { loadCreds } from "./auth.mjs";
14
17
 
@@ -30,7 +33,7 @@ export function decodeKey(body) {
30
33
 
31
34
  // What each key looks like to a program reading the tty in raw mode, and the
32
35
  // keypress readline wants when it is the one holding the line.
33
- const KEY_BYTES = { up: "\u001b[A", down: "\u001b[B", right: "\u001b[C", left: "\u001b[D", enter: "\r" };
36
+ export const KEY_BYTES = { up: "\u001b[A", down: "\u001b[B", right: "\u001b[C", left: "\u001b[D", enter: "\r" };
34
37
  const KEY_PRESS = {
35
38
  up: { name: "up" }, down: { name: "down" }, right: { name: "right" },
36
39
  left: { name: "left" }, enter: { name: "return" },
@@ -43,6 +46,14 @@ const KEY_PRESS = {
43
46
  export function pressKey(name, rl = null, stdin = process.stdin) {
44
47
  const bytes = KEY_BYTES[name];
45
48
  if (!bytes) return false;
49
+ // A child engine takes precedence over everything below, because when one is
50
+ // running it is the thing the person on the session page can see. It reads a
51
+ // real file descriptor rather than this process's stdin object, so the bytes
52
+ // have to be *written* — the synthesised event further down reaches readline
53
+ // and the pit's own raw-mode readers, and nothing that was spawned. This is
54
+ // the line that decides whether an arrow key lands on Claude's trust prompt.
55
+ const toChild = activeChildInput();
56
+ if (toChild && toChild(bytes)) return true;
46
57
  // At the prompt readline owns the line editor, so hand it a keypress rather
47
58
  // than bytes: ↑/↓ walk the history, ←/→ move within the line, enter runs it.
48
59
  if (rl) {
@@ -56,6 +67,48 @@ export function pressKey(name, rl = null, stdin = process.stdin) {
56
67
  const FLUSH_MS = 150; // batch writes so a busy render is one request, not fifty
57
68
  const MAX_BUFFER = 16000; // flush early once a batch gets big
58
69
 
70
+ // Where a child process's output should be copied while a mirror is watching.
71
+ //
72
+ // Module-level rather than threaded through every call, because "is anyone
73
+ // watching this pit" is one fact about the process and the launchers that need
74
+ // it are scattered: the shell, the installers, the upgrader, the plugin/skill/
75
+ // MCP hand-offs. Passing it down by hand is what left most of them writing
76
+ // straight to the tty with the session page showing nothing — each new launcher
77
+ // had to remember, and none of them did. src/pty.mjs reads this as its default,
78
+ // so capture is what a launcher gets for free and opting out is the deliberate
79
+ // act.
80
+ let activeSink = null;
81
+
82
+ /** Point child capture at this mirror (or null when the pit stops mirroring). */
83
+ export function setActiveSink(sink) {
84
+ activeSink = typeof sink === "function" ? sink : null;
85
+ }
86
+
87
+ /** The sink a child's output should be copied to, or null when unmirrored. */
88
+ export function activeChildSink() {
89
+ return activeSink;
90
+ }
91
+
92
+ // The other direction: where to put bytes so the program currently holding the
93
+ // terminal reads them.
94
+ //
95
+ // Null almost always, and set only while a child owns the tty under a pty we
96
+ // opened (src/pty.mjs captureWithInput). It has to be module-level for the same
97
+ // reason the sink does — pressKey is called from the mirror's poll loop, which
98
+ // has no idea which launcher is mid-flight — and it is what makes a key pressed
99
+ // on the session page land in an engine rather than in the pit behind it.
100
+ let activeInput = null;
101
+
102
+ /** Point web keystrokes at a running child (or null when it exits). */
103
+ export function setActiveChildInput(write) {
104
+ activeInput = typeof write === "function" ? write : null;
105
+ }
106
+
107
+ /** How to type into whatever child owns the terminal, or null for the pit. */
108
+ export function activeChildInput() {
109
+ return activeInput;
110
+ }
111
+
59
112
  export function createMirror({
60
113
  version = "",
61
114
  cwd = process.cwd(),
package/src/payments.mjs CHANGED
@@ -22,6 +22,7 @@
22
22
  // `/payments connect stripe` records a *reference* — vault and key name — and
23
23
  // says out loud where the secret should go.
24
24
  import { spawnSync } from "node:child_process";
25
+ import { captureSpec } from "./pty.mjs";
25
26
 
26
27
  import { loadBusiness, updateBusiness } from "./business-store.mjs";
27
28
  import { parseFields } from "./clients.mjs";
@@ -178,7 +179,10 @@ function connectGateway(args, write, run) {
178
179
  return 1;
179
180
  }
180
181
  write(info(`handing you to ${bone(gateway.bin)} — it owns its own session`));
181
- const result = run(gateway.bin, gateway.connect, { stdio: "inherit" });
182
+ const launch = captureSpec({ cmd: gateway.bin, args: gateway.connect });
183
+ let result;
184
+ try { result = run(launch.cmd, launch.args, { stdio: "inherit" }); }
185
+ finally { launch.stop(); }
182
186
  if (result?.error) { write(err(String(result.error.message || result.error))); return 1; }
183
187
  if (result?.status) {
184
188
  write(err(`${gateway.bin} ${gateway.connect.join(" ")} exited ${result.status} — nothing recorded`));
package/src/pty.mjs CHANGED
@@ -19,8 +19,14 @@
19
19
  // `script` disagree on both flag names and argument order, and anything we
20
20
  // cannot positively identify falls back to today's plain `inherit`.
21
21
  import { spawnSync } from "node:child_process";
22
- import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
22
+ import {
23
+ closeSync, constants, existsSync, mkdtempSync, openSync,
24
+ readFileSync, readSync, rmSync, statSync, writeFileSync, writeSync,
25
+ } from "node:fs";
26
+ import { tmpdir } from "node:os";
27
+ import path from "node:path";
23
28
  import { StringDecoder } from "node:string_decoder";
29
+ import { activeChildSink } from "./mirror.mjs";
24
30
 
25
31
  /**
26
32
  * POSIX single-quote escaping, for argv that has to survive being flattened
@@ -75,6 +81,18 @@ export function ptySpec(cmd, args = [], transcript, flavor) {
75
81
  return null;
76
82
  }
77
83
 
84
+ /**
85
+ * The same thing for a shell *line* rather than an argv, which the input path
86
+ * needs: it prefixes the child with `stty` and `tty` so the session sizes
87
+ * itself and says where it landed, and those only exist as shell.
88
+ */
89
+ export function ptyShellSpec(command, transcript, flavor) {
90
+ if (!command || !transcript) return null;
91
+ if (flavor === "util-linux") return { cmd: "script", args: ["-q", "-e", "-f", "-c", command, transcript] };
92
+ if (flavor === "bsd") return { cmd: "script", args: ["-q", "-F", transcript, "sh", "-c", command] };
93
+ return null;
94
+ }
95
+
78
96
  /**
79
97
  * Follow a transcript as it is written, handing each new slice to `onChunk`.
80
98
  *
@@ -178,3 +196,272 @@ export function ptyEnabled(sink, flavor = scriptFlavor()) {
178
196
  if (process.env.MOSHCODE_MIRROR_PTY === "0") return false;
179
197
  return Boolean(flavor);
180
198
  }
199
+
200
+ /**
201
+ * Wrap a spawn spec so a copy of everything the child prints reaches `onOutput`
202
+ * while the child still owns the real terminal.
203
+ *
204
+ * The whole capture dance in one place — temp transcript, the flavour-specific
205
+ * `script` argv, the follower, the banner strip, the cleanup — because every
206
+ * launcher in the pit needs it, and each one growing its own copy is how a
207
+ * shell command ended up invisible in the mirror while `/agents claude` was
208
+ * captured: both spawn `inherit`, and only one of them had been taught this.
209
+ *
210
+ * `onOutput` defaults to whatever the live mirror is (src/mirror.mjs), so a
211
+ * launcher gets capture without having to know the mirror exists — the reverse
212
+ * of how this started, where each launcher had to be taught separately and only
213
+ * two ever were. Pass `null` to opt a launch out.
214
+ *
215
+ * Returns `{ cmd, args, stdio, write, stop }`. With nothing watching, or on a
216
+ * box with no `script(1)` we can drive, `cmd`/`args` come back exactly as
217
+ * passed in, `stdio` is "inherit" and `write` returns false — the caller spawns
218
+ * what it always spawned. `stop()` must be called once the child exits: it
219
+ * drains the tail of the transcript (the last lines of a command are usually
220
+ * the ones you were waiting for) and removes the temp dir.
221
+ *
222
+ * `input: true` additionally makes the child's stdin something we can type
223
+ * into, so the session page can drive it — see captureWithInput.
224
+ */
225
+ export function captureSpec(
226
+ { cmd, args = [] },
227
+ onOutput = activeChildSink(),
228
+ { flavor = scriptFlavor(), input = false, stdin = process.stdin, stdout = process.stdout } = {},
229
+ ) {
230
+ const plain = { cmd, args, stdio: "inherit", write: () => false, stop: () => {} };
231
+ if (!ptyEnabled(onOutput, flavor)) return plain;
232
+ if (input) {
233
+ const withInput = captureWithInput({ cmd, args }, onOutput, { flavor, stdin, stdout });
234
+ if (withInput) return withInput;
235
+ // No fifo, no local tty, nothing we could drive — fall through to the
236
+ // output-only capture rather than dropping capture altogether.
237
+ }
238
+ let workDir = null;
239
+ try {
240
+ workDir = mkdtempSync(path.join(tmpdir(), "moshcode-pty-"));
241
+ const transcript = path.join(workDir, "transcript");
242
+ writeFileSync(transcript, "");
243
+ const wrapped = ptySpec(cmd, args, transcript, flavor);
244
+ if (!wrapped) throw new Error("no script(1) spec for this flavour");
245
+ let first = true;
246
+ const stopFollow = followFile(transcript, (chunk) => {
247
+ const clean = stripScriptBanner(chunk, first);
248
+ first = false;
249
+ if (clean) onOutput(clean);
250
+ });
251
+ const dir = workDir;
252
+ return {
253
+ cmd: wrapped.cmd,
254
+ args: wrapped.args,
255
+ stdio: "inherit",
256
+ write: () => false,
257
+ stop() {
258
+ try { stopFollow(); } catch { /* nothing left to drain */ }
259
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* temp dir */ }
260
+ },
261
+ };
262
+ } catch {
263
+ // Capture is a nicety; never let it stop a command from running.
264
+ if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* temp dir */ } }
265
+ return plain;
266
+ }
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // Typing into the child
271
+ // ---------------------------------------------------------------------------
272
+
273
+ /** A terminal geometry we can hand a child, with a sane floor. */
274
+ function geometry(stdout) {
275
+ return { cols: Number(stdout?.columns) || 80, rows: Number(stdout?.rows) || 24 };
276
+ }
277
+
278
+ // Application cursor keys (DECCKM). A program that turns this on is saying "send
279
+ // me ESC O B for down, not ESC [ B", and a terminal obliges — which is why the
280
+ // distinction never comes up for the person at the keyboard, and why it bites
281
+ // the moment we start synthesising keys ourselves. `less` is the plain
282
+ // demonstration: fed the CSI form while it has DECCKM set, it does not scroll,
283
+ // it prints "ESC[B" on its own prompt line as if you had typed the characters.
284
+ //
285
+ // The mode is not something we can ask about, but it is announced: the child
286
+ // writes the escape on its way into full-screen mode, and every byte it writes
287
+ // is already passing under our nose on the way to the mirror.
288
+ const DECCKM = /\u001b\[\?1([hl])/g;
289
+
290
+ /** Track a DECCKM change announced in `text`; returns the mode after it. */
291
+ export function cursorKeyMode(text, current = false) {
292
+ const seen = [...String(text).matchAll(DECCKM)].pop();
293
+ return seen ? seen[1] === "h" : current;
294
+ }
295
+
296
+ /**
297
+ * Rewrite CSI cursor keys as SS3, for a child that asked for application mode.
298
+ *
299
+ * Only the four cursor keys move: everything else, including a literal ESC and
300
+ * anything the person at the keyboard typed, is left exactly as it arrived.
301
+ */
302
+ export function toApplicationCursor(buf) {
303
+ const out = Buffer.from(buf);
304
+ for (let i = 0; i + 2 < out.length; i += 1) {
305
+ // ESC [ A|B|C|D -> ESC O A|B|C|D
306
+ if (out[i] === 0x1b && out[i + 1] === 0x5b && out[i + 2] >= 0x41 && out[i + 2] <= 0x44) {
307
+ out[i + 1] = 0x4f;
308
+ }
309
+ }
310
+ return out;
311
+ }
312
+
313
+ /**
314
+ * The same capture, but with a stdin the mirror can write to.
315
+ *
316
+ * `inherit` hands the child the tty's own file descriptors, which is why a key
317
+ * pressed on the session page could never reach it: there is no fd in this
318
+ * process between the browser and the program, so the best the mirror could do
319
+ * was synthesise a `data` event on its own `process.stdin` — which the pit's
320
+ * readline hears and a child does not (see pressKey in src/mirror.mjs). To type
321
+ * into an engine we have to own its stdin, and a fifo is the one way to do that
322
+ * with nothing but the base system: `script(1)` reads it and copies it to the
323
+ * pty master, exactly as it would a terminal.
324
+ *
325
+ * Owning stdin costs two things back, and both are paid here rather than
326
+ * written off as limitations:
327
+ *
328
+ * - Size. `script` takes the pty's geometry from its own stdin, and a fifo has
329
+ * none, so the child would start on a 0x0 terminal — which full-screen
330
+ * engines do not survive. Nothing outside a pty can ioctl its master, but
331
+ * `stty` inside it can, so the session sizes itself on the way in.
332
+ * - Resize. For the same reason `script` can no longer forward SIGWINCH. The
333
+ * child records its pty path on the way in, which is enough to resize it
334
+ * from out here with `stty -F` when the real window changes, so dragging a
335
+ * window edge still reaches the engine.
336
+ *
337
+ * The person at the keyboard has to keep working throughout, so local stdin is
338
+ * relayed byte-for-byte into the same fifo. That means raw mode: this tty has
339
+ * to stop echoing and stop buffering lines, because the pty on the other end is
340
+ * now the one doing both.
341
+ *
342
+ * Returns null when this box can't do it (no `mkfifo`, no local tty), which
343
+ * leaves the caller on the output-only path it had before.
344
+ */
345
+ export function captureWithInput({ cmd, args = [] }, onOutput, { flavor, stdin, stdout } = {}) {
346
+ // Without a local terminal there is nothing to relay and raw mode is
347
+ // meaningless, so capture alone is the honest thing to offer.
348
+ if (!stdin?.isTTY || typeof stdin.setRawMode !== "function") return null;
349
+
350
+ let workDir = null;
351
+ let fd = null;
352
+ try {
353
+ workDir = mkdtempSync(path.join(tmpdir(), "moshcode-pty-"));
354
+ const transcript = path.join(workDir, "transcript");
355
+ const fifo = path.join(workDir, "input");
356
+ const ptsFile = path.join(workDir, "pts");
357
+ writeFileSync(transcript, "");
358
+
359
+ // node has no mkfifo, so this is the one call out to the system — and a box
360
+ // without it simply does not get the input path.
361
+ const made = spawnSync("mkfifo", [fifo]);
362
+ if (made.error || made.status !== 0) throw new Error("no mkfifo on this box");
363
+
364
+ const { cols, rows } = geometry(stdout);
365
+ const command = [
366
+ `tty > ${shQuote(ptsFile)} 2>/dev/null`,
367
+ `stty rows ${rows} cols ${cols} 2>/dev/null`,
368
+ // exec, so the engine *is* the process script is waiting on: its signals
369
+ // and its exit status pass straight through rather than via a shell.
370
+ `exec ${[cmd, ...args].map(shQuote).join(" ")}`,
371
+ ].join("; ");
372
+ const wrapped = ptyShellSpec(command, transcript, flavor);
373
+ if (!wrapped) throw new Error("no script(1) spec for this flavour");
374
+
375
+ // O_RDWR, not O_WRONLY: opening a fifo write-only blocks until a reader
376
+ // arrives, and the reader here is a child we have not spawned yet. Holding
377
+ // both ends also keeps the child from seeing EOF between writes.
378
+ fd = openSync(fifo, constants.O_RDWR);
379
+
380
+ let first = true;
381
+ // Which form of cursor key this child is asking for, learned from the same
382
+ // stream that goes to the mirror. Tracked on the raw chunk rather than the
383
+ // banner-stripped one: the mode switch is a control sequence, and nothing
384
+ // about the banner is in its way.
385
+ let appCursor = false;
386
+ const stopFollow = followFile(transcript, (chunk) => {
387
+ appCursor = cursorKeyMode(chunk, appCursor);
388
+ const clean = stripScriptBanner(chunk, first);
389
+ first = false;
390
+ if (clean) onOutput(clean);
391
+ });
392
+
393
+ let stopped = false;
394
+ /** Put bytes in front of the child, from the web or from the keyboard. */
395
+ const write = (data) => {
396
+ if (stopped || fd === null) return false;
397
+ const raw = typeof data === "string" ? Buffer.from(data, "latin1") : Buffer.from(data);
398
+ try { writeSync(fd, appCursor ? toApplicationCursor(raw) : raw); return true; }
399
+ catch { return false; }
400
+ };
401
+
402
+ // Raw, because the pty on the far end is now the one echoing and the one
403
+ // splitting lines. Leaving this tty cooked would double every character and
404
+ // hold Enter back until the child had already redrawn without it.
405
+ const wasRaw = Boolean(stdin.isRaw);
406
+ stdin.setRawMode(true);
407
+ stdin.resume();
408
+ const onData = (buf) => { write(buf); };
409
+ stdin.on("data", onData);
410
+
411
+ // The child's own tty, once its prelude has written it down. Read lazily:
412
+ // at the moment we spawn, that file does not exist yet.
413
+ let pts = null;
414
+ const childTty = () => {
415
+ if (pts) return pts;
416
+ try { pts = readFileSync(ptsFile, "utf8").trim() || null; } catch { pts = null; }
417
+ return pts;
418
+ };
419
+ let resizeTimer = null;
420
+ const onResize = () => {
421
+ clearTimeout(resizeTimer);
422
+ // Dragging an edge fires this continuously; settle for one ioctl per drag.
423
+ resizeTimer = setTimeout(() => {
424
+ resizeTimer = null;
425
+ const tty = childTty();
426
+ if (!tty || stopped) return;
427
+ const size = geometry(stdout);
428
+ // -F on util-linux, -f on BSD/macOS — the same disagreement as the
429
+ // script(1) flags above, and getting it wrong here is a usage error on
430
+ // every resize rather than anything visible.
431
+ const on = flavor === "bsd" ? "-f" : "-F";
432
+ try { spawnSync("stty", [on, tty, "rows", String(size.rows), "cols", String(size.cols)]); }
433
+ catch { /* the child owns it; a resize we lose is cosmetic */ }
434
+ }, 120);
435
+ resizeTimer.unref?.();
436
+ };
437
+ stdout?.on?.("resize", onResize);
438
+
439
+ const dir = workDir;
440
+ return {
441
+ cmd: wrapped.cmd,
442
+ args: wrapped.args,
443
+ // The fifo is the child's stdin; its output still goes straight to the
444
+ // real terminal, so the engine draws at full speed exactly as before.
445
+ stdio: [fd, "inherit", "inherit"],
446
+ write,
447
+ stop() {
448
+ if (stopped) return;
449
+ stopped = true;
450
+ clearTimeout(resizeTimer);
451
+ stdout?.off?.("resize", onResize);
452
+ stdin.off("data", onData);
453
+ // Hand the terminal back the way we found it. Getting this wrong leaves
454
+ // the pit with no echo, which reads as a hung shell.
455
+ try { stdin.setRawMode(wasRaw); } catch { /* not a tty any more */ }
456
+ stdin.pause();
457
+ try { stopFollow(); } catch { /* nothing left to drain */ }
458
+ if (fd !== null) { try { closeSync(fd); } catch { /* already gone */ } fd = null; }
459
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* temp dir */ }
460
+ },
461
+ };
462
+ } catch {
463
+ if (fd !== null) { try { closeSync(fd); } catch { /* already gone */ } }
464
+ if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* temp dir */ } }
465
+ return null;
466
+ }
467
+ }
package/src/tui.mjs CHANGED
@@ -18,7 +18,7 @@ import { createPrd, listPrds, authoringPrompt } from "./prd.mjs";
18
18
  import { loginAuto, whoami, logout } from "./auth.mjs";
19
19
  import { startAutoSync } from "./autosync.mjs";
20
20
  import { loadCommand, saveCommand } from "./settings-sync.mjs";
21
- import { createMirror, pressKey, teeOutput } from "./mirror.mjs";
21
+ import { activeChildInput, createMirror, pressKey, setActiveSink, teeOutput } from "./mirror.mjs";
22
22
  import { fetchMotdAd } from "./ads.mjs";
23
23
  import { runScript } from "./runtime.mjs";
24
24
  import { moshVocabulary } from "./commands.mjs";
@@ -28,6 +28,7 @@ import { cryptoCommand } from "./crypto.mjs";
28
28
  import { gamesCommand } from "./games.mjs";
29
29
  import { canOpenBrowser, openBrowser } from "./open-url.mjs";
30
30
  import { shellInvocation } from "./shell.mjs";
31
+ import { captureSpec } from "./pty.mjs";
31
32
  import { needsRootHere, primeEscalation } from "./escalate.mjs";
32
33
  import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion } from "./ui.mjs";
33
34
  import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs";
@@ -678,14 +679,23 @@ async function openWorkflowTool(key, tool, args) {
678
679
  // command string → `$SHELL +m -ic "<cmd>"` (one-off). Interactive so the command
679
680
  // can see the aliases and functions in ~/.zshrc — see src/shell.mjs for why
680
681
  // that is not optional. Resolves { ok, code, signal }.
681
- function runShell(rawCmd) {
682
+ //
683
+ // Captured through a pty when the mirror is watching, for the same reason an
684
+ // engine is: a shell command is where most of what a pit does actually happens
685
+ // — `!cmd`, /shell, and every shell-valued /alias land here — and with plain
686
+ // `inherit` none of its bytes, on stdout or stderr, ever pass through this
687
+ // process. The session page was showing the echoed command line and the exit
688
+ // note with nothing in between.
689
+ export function runShell(rawCmd, { onOutput } = {}) {
682
690
  return new Promise((resolve) => {
683
691
  const { shell, args } = shellInvocation(rawCmd);
692
+ const launch = captureSpec({ cmd: shell, args }, onOutput);
693
+ const done = (result) => { try { launch.stop(); } catch { /* already drained */ } resolve(result); };
684
694
  let child;
685
- try { child = spawn(shell, args, { stdio: "inherit" }); }
686
- catch (e) { resolve({ ok: false, error: e }); return; }
687
- child.on("error", (e) => resolve({ ok: false, error: e }));
688
- child.on("exit", (code, signal) => resolve({ ok: true, code, signal }));
695
+ try { child = spawn(launch.cmd, launch.args, { stdio: "inherit" }); }
696
+ catch (e) { done({ ok: false, error: e }); return; }
697
+ child.on("error", (e) => done({ ok: false, error: e }));
698
+ child.on("exit", (code, signal) => done({ ok: true, code, signal }));
689
699
  });
690
700
  }
691
701
 
@@ -700,7 +710,7 @@ async function openShell(rawCmd) {
700
710
  ? `${bone(shellName)} ${ash(flags)} ${ash(rawCmd)}`
701
711
  : `dropping to ${bone(shellName)} — ${ash("`exit` or Ctrl-D brings you back to the pit")}`));
702
712
  console.log(hr());
703
- const r = await runShell(rawCmd);
713
+ const r = await runShell(rawCmd, { onOutput: childSink() });
704
714
  console.log(hr());
705
715
  if (!r.ok) {
706
716
  console.log(err(`couldn't start shell: ${r.error?.message || r.error}`));
@@ -720,14 +730,22 @@ function installTarget(key) {
720
730
  // something the installer's output scrolled into view.
721
731
  if (needsRootHere(target)) primeEscalation({ what: key, out: (s) => console.log(info(s.replace(/^· /, ""))) });
722
732
  console.log(hr());
723
- const child = spawn(target.install.cmd, target.install.args, { stdio: "inherit" });
733
+ // Installers are long, chatty, and the thing you most want to read from a
734
+ // phone — so they go through the mirror's pty like everything else.
735
+ const launch = captureSpec(
736
+ { cmd: target.install.cmd, args: target.install.args },
737
+ childSink(),
738
+ );
739
+ const child = spawn(launch.cmd, launch.args, { stdio: "inherit" });
724
740
  child.on("error", (e) => {
741
+ launch.stop();
725
742
  console.log(hr());
726
743
  console.log(err(`install failed: ${e.message}`));
727
744
  if (e.code === "ENOENT" && target.installHelp) console.log(info(target.installHelp));
728
745
  resolve();
729
746
  });
730
747
  child.on("exit", (code) => {
748
+ launch.stop();
731
749
  console.log(hr());
732
750
  if (code !== 0) { console.log(err(`install exited ${code}`)); return resolve(); }
733
751
  console.log(ok(`${key} installed. 🤘`));
@@ -1292,6 +1310,10 @@ async function startMirror() {
1292
1310
  if (!started) return noop;
1293
1311
 
1294
1312
  activeMirror = mirror;
1313
+ // Every launcher that spawns a child reads this rather than being handed a
1314
+ // sink, so a command run from the pit is captured whether or not whoever
1315
+ // wrote that launcher knew the mirror existed.
1316
+ setActiveSink((chunk) => activeMirror?.write(chunk));
1295
1317
  const restoreTee = teeOutput((chunk) => mirror.write(chunk));
1296
1318
 
1297
1319
  // Commands arrive whenever; the prompt is only ready between engine
@@ -1310,7 +1332,18 @@ async function startMirror() {
1310
1332
  promptRl.write(`${body}\n`);
1311
1333
  }
1312
1334
  };
1313
- mirror.onCommand((body) => { queue.push(body); drainRemote(); });
1335
+ mirror.onCommand((body) => {
1336
+ // An engine has the terminal: send the line to it rather than parking it
1337
+ // for a prompt that will not come back until the engine exits. Without this
1338
+ // the arrow keys could answer a menu but nothing could answer a question,
1339
+ // which is half a session page. Typed straight in, so it arrives the way
1340
+ // the keyboard would deliver it — no `▸ (web)` note, because the engine
1341
+ // echoes it itself and printing over an engine's screen shifts it.
1342
+ const toChild = activeChildInput();
1343
+ if (toChild && toChild(`${body}\r`)) return;
1344
+ queue.push(body);
1345
+ drainRemote();
1346
+ });
1314
1347
 
1315
1348
  // Keys skip the queue: they are pressed the instant they arrive, whether the
1316
1349
  // prompt is armed or something else has the tty (a herd bar, the reader, a
@@ -1326,6 +1359,7 @@ async function startMirror() {
1326
1359
  async function stopMirror(restoreTee) {
1327
1360
  const mirror = activeMirror;
1328
1361
  activeMirror = null;
1362
+ setActiveSink(null);
1329
1363
  try { restoreTee?.(); } catch { /* noop */ }
1330
1364
  try { await mirror?.stop(); } catch { /* best effort */ }
1331
1365
  }