moshcode 0.74.0 → 0.75.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.75.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,11 @@
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 { captureSpec } from "./pty.mjs";
40
40
 
41
41
  export const ENGINES = {
42
42
  opencode: {
@@ -439,21 +439,29 @@ export function runCmd(cmd, args = [], { capture = false } = {}) {
439
439
  let child;
440
440
  const spec = spawnSpec(cmd, args);
441
441
  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; }
442
+ // The `capture` branch already reaches a watching browser: it re-writes
443
+ // every byte through this process's own stdout/stderr, which the mirror
444
+ // tees. The inherited branch does not — those bytes go to the tty and
445
+ // nowhere else — so it goes under a pty when a mirror is live. This is what
446
+ // an upgrade, a plugin install and an `mcp add` all run through, and all
447
+ // three used to be a rule, a blank stretch, and a result line.
448
+ const launch = capture ? { ...spec, stop: () => {} } : captureSpec(spec);
449
+ const finish = (result) => { try { launch.stop(); } catch { /* already drained */ } resolve(result); };
450
+ try { child = spawn(launch.cmd, launch.args, { stdio }); }
451
+ catch (e) { finish({ ok: false, error: e }); return; }
444
452
  let output = "";
445
453
  if (capture) {
446
454
  for (const [stream, sink] of [[child.stdout, process.stdout], [child.stderr, process.stderr]]) {
447
455
  stream?.on("data", (chunk) => { output += chunk.toString(); sink.write(chunk); });
448
456
  }
449
457
  }
450
- child.on("error", (e) => resolve({ ok: false, error: e, output }));
458
+ child.on("error", (e) => finish({ ok: false, error: e, output }));
451
459
  // "exit" fires as soon as the process is gone, which with pipes can leave
452
460
  // the last chunk still queued — the one line we are trying to read. "close"
453
461
  // waits for the streams too. With stdio inherited there are no streams, so
454
462
  // the two are the same moment and existing callers are unaffected; the
455
463
  // distinction is kept explicit so neither branch changes by accident.
456
- child.on(capture ? "close" : "exit", (code, signal) => resolve({ ok: true, code, signal, output }));
464
+ child.on(capture ? "close" : "exit", (code, signal) => finish({ ok: true, code, signal, output }));
457
465
  });
458
466
  }
459
467
 
@@ -501,35 +509,8 @@ export function openPassthrough(target, args = [], { onOutput } = {}) {
501
509
  // the child the tty's own file descriptors, so none of its bytes ever pass
502
510
  // through this process. See src/pty.mjs for why this is script(1) and not
503
511
  // 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
-
529
- const cleanup = () => {
530
- try { stopFollow?.(); } catch { /* nothing left to drain */ }
531
- if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* temp dir */ } }
532
- };
512
+ const launch = captureSpec(spec, onOutput);
513
+ const cleanup = () => launch.stop();
533
514
 
534
515
  let child;
535
516
  try { child = spawn(launch.cmd, launch.args, { stdio: "inherit", env }); }
package/src/mirror.mjs CHANGED
@@ -56,6 +56,28 @@ export function pressKey(name, rl = null, stdin = process.stdin) {
56
56
  const FLUSH_MS = 150; // batch writes so a busy render is one request, not fifty
57
57
  const MAX_BUFFER = 16000; // flush early once a batch gets big
58
58
 
59
+ // Where a child process's output should be copied while a mirror is watching.
60
+ //
61
+ // Module-level rather than threaded through every call, because "is anyone
62
+ // watching this pit" is one fact about the process and the launchers that need
63
+ // it are scattered: the shell, the installers, the upgrader, the plugin/skill/
64
+ // MCP hand-offs. Passing it down by hand is what left most of them writing
65
+ // straight to the tty with the session page showing nothing — each new launcher
66
+ // had to remember, and none of them did. src/pty.mjs reads this as its default,
67
+ // so capture is what a launcher gets for free and opting out is the deliberate
68
+ // act.
69
+ let activeSink = null;
70
+
71
+ /** Point child capture at this mirror (or null when the pit stops mirroring). */
72
+ export function setActiveSink(sink) {
73
+ activeSink = typeof sink === "function" ? sink : null;
74
+ }
75
+
76
+ /** The sink a child's output should be copied to, or null when unmirrored. */
77
+ export function activeChildSink() {
78
+ return activeSink;
79
+ }
80
+
59
81
  export function createMirror({
60
82
  version = "",
61
83
  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,11 @@
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 { closeSync, existsSync, mkdtempSync, openSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import path from "node:path";
23
25
  import { StringDecoder } from "node:string_decoder";
26
+ import { activeChildSink } from "./mirror.mjs";
24
27
 
25
28
  /**
26
29
  * POSIX single-quote escaping, for argv that has to survive being flattened
@@ -178,3 +181,57 @@ export function ptyEnabled(sink, flavor = scriptFlavor()) {
178
181
  if (process.env.MOSHCODE_MIRROR_PTY === "0") return false;
179
182
  return Boolean(flavor);
180
183
  }
184
+
185
+ /**
186
+ * Wrap a spawn spec so a copy of everything the child prints reaches `onOutput`
187
+ * while the child still owns the real terminal.
188
+ *
189
+ * The whole capture dance in one place — temp transcript, the flavour-specific
190
+ * `script` argv, the follower, the banner strip, the cleanup — because every
191
+ * launcher in the pit needs it, and each one growing its own copy is how a
192
+ * shell command ended up invisible in the mirror while `/agents claude` was
193
+ * captured: both spawn `inherit`, and only one of them had been taught this.
194
+ *
195
+ * `onOutput` defaults to whatever the live mirror is (src/mirror.mjs), so a
196
+ * launcher gets capture without having to know the mirror exists — the reverse
197
+ * of how this started, where each launcher had to be taught separately and only
198
+ * two ever were. Pass `null` to opt a launch out.
199
+ *
200
+ * Returns `{ cmd, args, stop }`. With nothing watching, or on a box with no
201
+ * `script(1)` we can drive, `cmd`/`args` come back exactly as passed in and
202
+ * `stop` is a no-op — the caller spawns what it always spawned. `stop()` must
203
+ * be called once the child exits: it drains the tail of the transcript (the
204
+ * last lines of a command are usually the ones you were waiting for) and
205
+ * removes the temp dir.
206
+ */
207
+ export function captureSpec({ cmd, args = [] }, onOutput = activeChildSink(), { flavor = scriptFlavor() } = {}) {
208
+ const plain = { cmd, args, stop: () => {} };
209
+ if (!ptyEnabled(onOutput, flavor)) return plain;
210
+ let workDir = null;
211
+ try {
212
+ workDir = mkdtempSync(path.join(tmpdir(), "moshcode-pty-"));
213
+ const transcript = path.join(workDir, "transcript");
214
+ writeFileSync(transcript, "");
215
+ const wrapped = ptySpec(cmd, args, transcript, flavor);
216
+ if (!wrapped) throw new Error("no script(1) spec for this flavour");
217
+ let first = true;
218
+ const stopFollow = followFile(transcript, (chunk) => {
219
+ const clean = stripScriptBanner(chunk, first);
220
+ first = false;
221
+ if (clean) onOutput(clean);
222
+ });
223
+ const dir = workDir;
224
+ return {
225
+ cmd: wrapped.cmd,
226
+ args: wrapped.args,
227
+ stop() {
228
+ try { stopFollow(); } catch { /* nothing left to drain */ }
229
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* temp dir */ }
230
+ },
231
+ };
232
+ } catch {
233
+ // Capture is a nicety; never let it stop a command from running.
234
+ if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* temp dir */ } }
235
+ return plain;
236
+ }
237
+ }
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 { 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
@@ -1326,6 +1348,7 @@ async function startMirror() {
1326
1348
  async function stopMirror(restoreTee) {
1327
1349
  const mirror = activeMirror;
1328
1350
  activeMirror = null;
1351
+ setActiveSink(null);
1329
1352
  try { restoreTee?.(); } catch { /* noop */ }
1330
1353
  try { await mirror?.stop(); } catch { /* best effort */ }
1331
1354
  }