moshcode 0.73.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/README.md CHANGED
@@ -762,15 +762,50 @@ newer, and re-running `moshcode install alchemy` is its upgrade path.
762
762
  Where CoinPay is the payments product MoshCode ships alongside, Alchemy is the
763
763
  read side of the same world — the chain itself rather than one wallet's ledger.
764
764
 
765
+ ### yt-dlp, ffmpeg, ImageMagick — the media toolchain
766
+
767
+ ```sh
768
+ moshcode install yt-dlp # static binary → ~/.local/bin
769
+ moshcode install ffmpeg # your distro's package manager (needs sudo)
770
+ moshcode install imagemagick # likewise
771
+
772
+ moshcode yt-dlp https://… # or `dl https://…` from cli-tools
773
+ moshcode ffmpeg -i in.mkv out.mp4
774
+ ```
775
+
776
+ The odd three out: not workflow CLIs, but the media toolchain the rest of the
777
+ roster is built on. `cli-tools` fronts all three — `dl` for yt-dlp, `vid` for
778
+ ffmpeg, `img` for ImageMagick — and every one of them used to answer a missing
779
+ binary by telling you to go and install a system package by hand. Now the
780
+ registry that installs `cli-tools` installs what it runs on.
781
+
782
+ **yt-dlp** comes from its own releases as a self-contained binary, so it needs
783
+ no python and no package manager. That is deliberate rather than convenient:
784
+ extractors break whenever a site changes its markup, upstream ships a fix within
785
+ days, and a distro package of yt-dlp is frozen for the life of a release. Its
786
+ upgrade is `yt-dlp -U`, the project's own updater.
787
+
788
+ **ffmpeg** and **ImageMagick** exist only as distro packages — no vendor script,
789
+ and the static rebuilds floating around are unsigned third-party redistributions
790
+ of somebody else's codec stack, on the two tools most likely to be pointed at a
791
+ file from the internet. So they go through `apt`/`dnf`/`zypper`/`pacman`/`apk`,
792
+ or Homebrew on macOS, and ask for sudo everywhere but a Mac (see below).
793
+ Re-running the install upgrades them.
794
+
795
+ ImageMagick answers to two names: `magick` on version 7, `convert` on 6, both
796
+ current across supported distros under the same package name. MoshCode looks for
797
+ either, so a good install is never reported missing.
798
+
765
799
  `gh`, `supabase`, and `doctl` publish no cross-platform install script, so
766
800
  MoshCode resolves the latest GitHub release and drops the binary in
767
801
  `$MOSHCODE_BIN` (default `~/.local/bin`) — no sudo, no package manager. Set
768
802
  `MOSHCODE_BIN` to install elsewhere.
769
803
 
770
- `tailscale` and `spinifex` are the exceptions: both install system services
771
- rather than a user-local binary, so their official installers go through the
772
- distro's package manager and will ask for sudo (tailscale on macOS delegates to
773
- the App Store instead; Spinifex has no macOS build at all).
804
+ `tailscale`, `spinifex`, `ffmpeg` and `imagemagick` are the exceptions: none of
805
+ them is a user-local binary, so they go through the distro's package manager and
806
+ will ask for sudo (tailscale on macOS delegates to the App Store, and `ffmpeg`
807
+ and `imagemagick` to Homebrew, which refuses to run as root — so neither is
808
+ prompted for a password on a Mac; Spinifex has no macOS build at all).
774
809
 
775
810
  MoshCode asks for that password **before** starting the work rather than letting
776
811
  the installer stop for it partway through — which matters most in `moshcode
@@ -1144,12 +1179,35 @@ What syncs is an allowlist, not a directory walk:
1144
1179
  |---|---|
1145
1180
  | `~/.moshcode/aliases.json` | your pit aliases (`/alias`) |
1146
1181
  | `~/.moshcode/herd/rules.json` | herd state-detection overrides |
1182
+ | `~/.moshcode/herd/config.json` | herd notification preferences |
1183
+ | `~/.moshcode/feeds.opml` | your `tcfeed` rss feeds |
1184
+ | `~/.moshcode/news.opml` | `/news` and `/rss` subscriptions |
1185
+ | `~/.moshcode/pricing.json` | per-model price overrides for `/cost` |
1186
+ | `~/.moshcode/dns-filter/filter.json` | dns filter categories and your allow/block lists |
1187
+ | `~/.moshcode/business.json` | clients, teams, rates and invoices |
1147
1188
 
1148
- What never syncs, by name: `credentials.json` (the account token this very
1149
- feature authenticates with), `herd/sessions.json` (live state pinned to one tmux
1150
- server), `sync.json`, and the `pkg/` binary cache. Engine configuration
1151
- (`~/.claude.json` and friends) is deliberately left alone — those files carry
1152
- provider API keys.
1189
+ What never syncs, and why:
1190
+
1191
+ | not carried | because |
1192
+ |---|---|
1193
+ | `credentials.json` | the account token this very feature authenticates with |
1194
+ | `herd/sessions.json`, `herd/tasks/`, `*.transcript` | live state, prompt text and full screen captures of your sessions |
1195
+ | `dns-filter/stats.json` | it records the last domains you were blocked from reaching |
1196
+ | `timers.json` | a running work ledger; two machines appending would lose entries |
1197
+ | `news-last.json`, `news-found.json`, `lists/`, `dns-filter/lists/` | caches that refill themselves |
1198
+ | `moshpit-dns.pid`, `*.log`, `*.sock` | one box's daemon |
1199
+ | `sync.json`, `pkg/` | this feature's own bookkeeping, and moshcode itself |
1200
+
1201
+ Engine configuration (`~/.claude.json` and friends) is deliberately left alone —
1202
+ those files carry provider API keys. So is `~/.moshcode_history`, which holds
1203
+ whatever you typed at the prompt.
1204
+
1205
+ Two things worth knowing about the caps. A file over 64 KiB is reported as
1206
+ skipped rather than failing the save, so a `business.json` that grew a year of
1207
+ invoices stops being carried and says so. And the 256 KiB total is spent in the
1208
+ order of the table above, which is why the file most likely to grow is last —
1209
+ otherwise it would push your aliases out of the snapshot rather than being
1210
+ skipped itself.
1153
1211
 
1154
1212
  Nothing is overwritten quietly:
1155
1213
 
@@ -1164,6 +1222,29 @@ Nothing is overwritten quietly:
1164
1222
 
1165
1223
  Both verbs take `--json`, so a provisioning script can act on the result.
1166
1224
 
1225
+ ### It also syncs on its own
1226
+
1227
+ An open pit runs `/load` then `/save` every five minutes, so the aliases you
1228
+ made on the desktop are on the laptop by the time you sit down at it. It is on
1229
+ by default and there is nothing to configure.
1230
+
1231
+ What makes that safe is that it is never allowed to force. Both refusals above
1232
+ still apply to it: a tick that finds a settings file you edited locally leaves
1233
+ it alone and pushes it instead, and a tick that finds another machine got there
1234
+ first stops and tells you, rather than picking a winner. The order matters —
1235
+ loading first is what keeps the ordinary two-machine case from ever becoming a
1236
+ conflict you have to resolve by hand.
1237
+
1238
+ It is quiet on purpose. Nothing is printed when nothing changed, when you are
1239
+ not logged in, or when the network is down. Four things get a line: settings
1240
+ arriving from another machine (your aliases just changed under you), a revision
1241
+ this machine pushed, a conflict, and a credential the app rejected.
1242
+
1243
+ ```sh
1244
+ MOSHCODE_NO_AUTOSYNC=1 moshcode # turn it off for this pit
1245
+ MOSHCODE_AUTOSYNC_MS=900000 moshcode # every fifteen minutes instead
1246
+ ```
1247
+
1167
1248
  ## Browser terminal (`moshcode console`)
1168
1249
 
1169
1250
  A real terminal in the browser — arrow keys, history, full-screen TUIs — because
package/bin/moshcode.mjs CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  ENGINES,
11
11
  engineStatus,
12
12
  openSession,
13
+ primaryBin,
13
14
  resolveEngine,
14
15
  resolveExecutable,
15
16
  runCmd,
@@ -109,13 +110,15 @@ function printStatus(entries, json = false) {
109
110
  console.log(JSON.stringify(entries.map(({ key, desc, bin, installed }) => ({
110
111
  name: key,
111
112
  description: desc,
112
- binary: bin,
113
+ // One name, even for an entry that answers to several: `binary` is a
114
+ // documented string in this JSON and something is parsing it.
115
+ binary: primaryBin(bin),
113
116
  installed,
114
117
  })), null, 2));
115
118
  return;
116
119
  }
117
120
  for (const entry of entries) {
118
- console.log(`${entry.installed ? "●" : "○"} ${entry.key.padEnd(10)} ${entry.desc}`);
121
+ console.log(`${entry.installed ? "●" : "○"} ${entry.key.padEnd(11)} ${entry.desc}`);
119
122
  }
120
123
  }
121
124
 
@@ -375,7 +378,7 @@ async function main() {
375
378
  const r = await openTool(tool, translated.args);
376
379
  if (!r.ok) {
377
380
  console.error(r.error?.code === "ENOENT"
378
- ? `alpaca isn't installed (\`${tool.bin}\`). run: moshcode install alpaca`
381
+ ? `alpaca isn't installed (\`${primaryBin(tool.bin)}\`). run: moshcode install alpaca`
379
382
  : `launch failed: ${r.error?.message || r.error}`);
380
383
  process.exitCode = 1;
381
384
  return;
@@ -806,7 +809,7 @@ async function main() {
806
809
  const r = await openTool(tool, rest);
807
810
  if (!r.ok) {
808
811
  console.error(r.error?.code === "ENOENT"
809
- ? `${key} isn't installed (\`${tool.bin}\`). run: moshcode install ${key}`
812
+ ? `${key} isn't installed (\`${primaryBin(tool.bin)}\`). run: moshcode install ${key}`
810
813
  : `launch failed: ${r.error?.message || r.error}`);
811
814
  process.exitCode = 1;
812
815
  return;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.73.0",
3
+ "version": "0.75.0",
4
4
  "type": "module",
5
- "description": "moshcode a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
5
+ "description": "moshcode \u2014 a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/moshcoder/moshcode.git"
@@ -41,8 +41,13 @@ configuration is already there and already paired with every machine.
41
41
 
42
42
  ## Non-Goals
43
43
 
44
- - Continuous or background sync. Settings are edited by a person at a moment they
45
- can name; a daemon that pushes silently is a daemon that overwrites silently.
44
+ - Background sync that can *overwrite*. This line used to rule out background
45
+ sync altogether — "a daemon that pushes silently is a daemon that overwrites
46
+ silently" — and the reasoning was right about the daemon it imagined. R10
47
+ narrows it rather than dropping it: the pit does sync on its own, and is
48
+ allowed to because it is never permitted to force. Every refusal in R3 and R4
49
+ is what makes an unattended tick safe, and a background sync that could pass
50
+ `--force` would be exactly the thing this line was written to prevent.
46
51
  - Syncing engine configuration (`~/.claude.json`, `~/.codex`, MCP registrations).
47
52
  Those files carry provider API keys and are owned by other tools' schemas.
48
53
  - Syncing machine state: live herd sessions, the package cache, shell history.
@@ -62,9 +67,17 @@ configuration is already there and already paired with every machine.
62
67
 
63
68
  - R1 [P0] `/save` (and `moshcode save`) uploads this machine's pit settings to the
64
69
  logged-in account. `/load` (`moshcode load`) brings them back down.
65
- - R2 [P0] What syncs is an allowlist, not a directory walk: `aliases.json` and
66
- `herd/rules.json` today. `credentials.json`, `herd/sessions.json`, `sync.json`
67
- and `pkg/` are named as never-synced and asserted in tests.
70
+ - R2 [P0] What syncs is an allowlist, not a directory walk: the pit's settings
71
+ (`aliases.json`), herd's (`herd/rules.json`, `herd/config.json`), the feed and
72
+ news subscriptions, `pricing.json`, the DNS filter's policy, and
73
+ `business.json`. `~/.moshcode` is also where moshcode installs itself and
74
+ where the account token lives, so the allowlist is load-bearing rather than
75
+ tidy. `credentials.json`, `herd/sessions.json`, `sync.json` and `pkg/` are
76
+ named as never-synced and asserted in tests, alongside the state that is
77
+ meaningless or private off its own machine: task ledgers and transcripts,
78
+ `timers.json`, `dns-filter/stats.json` (a list of blocked domains is browsing
79
+ history), listing caches, and one box's pidfiles and logs. Directory and
80
+ extension rules are enforced, not only documented.
68
81
  - R3 [P0] Each save is a numbered revision. `/save` sends the revision it last
69
82
  agreed on and the app refuses the write if the account has moved past it, so
70
83
  two machines cannot silently erase one another.
@@ -83,6 +96,16 @@ configuration is already there and already paired with every machine.
83
96
  - R9 [P2] Not logged in, session expired, nothing saved yet, conflict: each is a
84
97
  sentence naming the command that resolves it (`/login`, `/save`, `/load`,
85
98
  `--force`).
99
+ - R10 [P1] The pit syncs on its own every five minutes: `/load` then `/save`, in
100
+ that order, never with `--force`. Loading first means the ordinary
101
+ two-machine case settles itself; when `/load` declines because of unsaved
102
+ local edits, the `/save` behind it carries exactly those edits up, which is
103
+ the resolution R4 already recommends. It is silent when logged out, silent
104
+ when nothing changed, and silent about network failure; it speaks only for
105
+ settings that arrived from another machine, a revision it pushed, and the two
106
+ states that need a person — a conflict and a rejected credential. On by
107
+ default. `MOSHCODE_NO_AUTOSYNC` turns it off, `MOSHCODE_AUTOSYNC_MS` retimes
108
+ it.
86
109
 
87
110
  ## UX Notes
88
111
 
@@ -0,0 +1,175 @@
1
+ // Automatic settings sync — the unattended `/load` then `/save`.
2
+ //
3
+ // PRD 0010 ruled background sync out, and the reason it gave was the right
4
+ // reason for the mechanism it had in mind: "a daemon that pushes silently is a
5
+ // daemon that overwrites silently." What makes this one allowed is that it is
6
+ // not permitted to overwrite anything. It never passes `--force`, and both
7
+ // verbs already refuse rather than guess — `/load` stops when a settings file
8
+ // changed locally since the last sync, `/save` stops on the 409 when another
9
+ // machine saved first. So the worst an unattended tick can do is decline and
10
+ // leave the decision exactly where it was: with the person at the prompt.
11
+ //
12
+ // The order is `/load` then `/save`, and that order is the whole design:
13
+ //
14
+ // - `/load` first means this machine is at the account's revision before it
15
+ // pushes, so the ordinary two-machine case settles itself and nobody is
16
+ // ever shown a conflict they would only have resolved by loading anyway.
17
+ // - When `/load` declines because there are unsaved local edits, the `/save`
18
+ // that follows pushes exactly those edits — which is the resolution the
19
+ // manual conflict message already recommends ("`/save` to keep them").
20
+ //
21
+ // Quiet is a feature. A tick that changed nothing prints nothing, because a
22
+ // line every five minutes saying "still fine" trains you to stop reading the
23
+ // pit. Three things do print: settings that arrived from another machine (your
24
+ // aliases just changed under you and you are owed that sentence), a revision
25
+ // this machine pushed, and the two states that need a human — a conflict, and
26
+ // credentials the app rejected. Network failures stay silent; a laptop on a
27
+ // train would otherwise narrate every tunnel.
28
+ import os from "node:os";
29
+ import { loadCreds } from "./auth.mjs";
30
+ import { loadCommand, saveCommand } from "./settings-sync.mjs";
31
+
32
+ /** Five minutes. Long enough that a tick is never in the way of typing. */
33
+ export const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
34
+
35
+ /**
36
+ * A floor, not a suggestion. `MOSHCODE_AUTOSYNC_MS=1` would turn the account
37
+ * into a write loop, so anything under this is treated as the minimum rather
38
+ * than refused — an env var is not the place to learn you typed milliseconds
39
+ * where you meant minutes.
40
+ */
41
+ export const MIN_INTERVAL_MS = 30 * 1000;
42
+
43
+ /**
44
+ * Off switch, in the shape the rest of the codebase already uses for one:
45
+ * presence disables, exactly like MOSHCODE_NO_MIRROR and MOSHCODE_NO_ADS.
46
+ */
47
+ export function autoSyncEnabled(env = process.env) {
48
+ return !env.MOSHCODE_NO_AUTOSYNC;
49
+ }
50
+
51
+ /** `Number(x) || default`, the MOSHCODE_AD_COLS idiom, with a floor. */
52
+ export function autoSyncInterval(env = process.env) {
53
+ const raw = Number(env.MOSHCODE_AUTOSYNC_MS);
54
+ if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_INTERVAL_MS;
55
+ return Math.max(MIN_INTERVAL_MS, raw);
56
+ }
57
+
58
+ /**
59
+ * Run one verb and read its answer as data rather than as prose.
60
+ *
61
+ * Both commands take `--json` and emit a single object through their `write`
62
+ * sink, which is the only reason this can be quiet: it can tell "loaded four
63
+ * files" from "already at revision 9" without matching on English.
64
+ */
65
+ async function runJson(command, argv, deps) {
66
+ const chunks = [];
67
+ const code = await command([...argv, "--json"], {
68
+ ...deps,
69
+ write: (line) => chunks.push(String(line)),
70
+ });
71
+ let body = null;
72
+ try { body = JSON.parse(chunks.join("\n")); } catch { /* not our business */ }
73
+ return { code, body, status: body?.status ?? null };
74
+ }
75
+
76
+ /**
77
+ * One tick: load, then save.
78
+ *
79
+ * Returns what happened, so the caller decides what is worth a line and the
80
+ * tests can assert on the sequence without reading output.
81
+ */
82
+ export async function syncOnce({
83
+ load = loadCommand,
84
+ save = saveCommand,
85
+ creds = loadCreds(),
86
+ write = () => {},
87
+ ...deps
88
+ } = {}) {
89
+ // Logged out is not an error and must never print. A pit that has never seen
90
+ // `/login` would otherwise nag about an account its owner has not asked for,
91
+ // every five minutes, forever.
92
+ if (!creds?.token) return { skipped: "not_logged_in" };
93
+
94
+ const loaded = await runJson(load, [], { ...deps, creds });
95
+
96
+ // `local_changes` is the expected, healthy half of this: you edited an alias
97
+ // and have not saved it. `/load` correctly declined to replace it, and the
98
+ // `/save` below is what carries it up. Anything else that failed is a reason
99
+ // to stop rather than push on top of a machine we could not read.
100
+ const loadBlocked = loaded.status === "expired";
101
+ if (loadBlocked) {
102
+ write("the app rejected this machine's credentials — run `/login` again");
103
+ return { load: loaded.status, save: null };
104
+ }
105
+
106
+ if (loaded.status === "loaded") {
107
+ const count = Array.isArray(loaded.body?.files) ? loaded.body.files.length : 0;
108
+ const from = loaded.body?.from;
109
+ write(`settings synced${from ? ` from ${from}` : ""} — ${count} file${count === 1 ? "" : "s"} changed (revision ${loaded.body?.revision ?? "?"})`);
110
+ }
111
+
112
+ const saved = await runJson(save, [], { ...deps, creds });
113
+
114
+ if (saved.status === "saved") {
115
+ write(`settings saved — revision ${saved.body?.revision ?? "?"}`);
116
+ } else if (saved.status === "conflict") {
117
+ // The one case an unattended tick cannot resolve: this machine loaded, and
118
+ // the account moved again between the load and the save. Say so once and
119
+ // stop; `--force` is a decision, not a retry.
120
+ write(`another machine saved first — \`/load\` to take theirs, or \`/save --force\` to keep this machine's`);
121
+ } else if (saved.status === "expired") {
122
+ write("the app rejected this machine's credentials — run `/login` again");
123
+ }
124
+
125
+ return { load: loaded.status, save: saved.status };
126
+ }
127
+
128
+ /**
129
+ * Start the timer. Returns the function that stops it.
130
+ *
131
+ * The caller must call that on the way out: `tui()` is re-entered after an
132
+ * engine session (bin/moshcode.mjs `backToPit`), so a timer left running would
133
+ * be joined by another on the next entry, and by a third after that.
134
+ */
135
+ export function startAutoSync({
136
+ intervalMs = autoSyncInterval(),
137
+ enabled = autoSyncEnabled(),
138
+ write = (line) => console.log(` ${line}`),
139
+ timers = { setInterval, clearInterval },
140
+ ...deps
141
+ } = {}) {
142
+ if (!enabled) return () => {};
143
+
144
+ // A tick that is still running when the next one fires would race two writes
145
+ // to the same files, so ticks are single-flight rather than queued: a sync
146
+ // this machine skipped is one it does five minutes later, unchanged.
147
+ let running = false;
148
+ let stopped = false;
149
+
150
+ const tick = async () => {
151
+ if (running || stopped) return;
152
+ running = true;
153
+ try { await syncOnce({ write, ...deps }); }
154
+ catch { /* a background sync never takes the pit down with it */ }
155
+ finally { running = false; }
156
+ };
157
+
158
+ // Deliberately no tick at startup. The pit is most likely to be typed into in
159
+ // the second after it opens, and that is the worst moment to rewrite the
160
+ // aliases under it — the first sync can wait five minutes.
161
+ const handle = timers.setInterval(tick, intervalMs);
162
+
163
+ // Never hold the process open for the sake of a sync. `pty.mjs` sets the
164
+ // precedent: a piped `moshcode` that has run out of stdin should exit now,
165
+ // not at the end of the interval.
166
+ handle?.unref?.();
167
+
168
+ return () => {
169
+ stopped = true;
170
+ try { timers.clearInterval(handle); } catch { /* already gone */ }
171
+ };
172
+ }
173
+
174
+ /** Exported for the tests; the pit has no reason to care. */
175
+ export const _internals = { runJson, hostname: os.hostname };
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/dns.mjs CHANGED
@@ -2274,7 +2274,7 @@ import { createParkingServer, DEFAULT_PARKING_HTTP_PORT } from "./parking-http.m
2274
2274
  // use it without importing this one back.
2275
2275
  export { pitNameUrl } from "./pit-url.mjs";
2276
2276
  import { pitNameUrl } from "./pit-url.mjs";
2277
- import { applyTrust, createAutoTrust, trustName, verifyStockTls } from "./trust.mjs";
2277
+ import { applyTrust, applyUntrust, createAutoTrust, trustName, verifyStockTls } from "./trust.mjs";
2278
2278
  import { readFile, writeFile } from "node:fs/promises";
2279
2279
  import { existsSync } from "node:fs";
2280
2280
  import { fileURLToPath } from "node:url";
@@ -2326,6 +2326,9 @@ const USAGE = `moshcode dns — resolve Moshpit names on this machine
2326
2326
  --no-trust with enable: route names but skip the local CA. They will
2327
2327
  resolve and then fail TLS, which is the state this flag exists
2328
2328
  to leave you in deliberately.
2329
+ --keep-trust with disable: put the routing back but leave the local CA in
2330
+ your trust store. For turning resolution off for an afternoon
2331
+ without paying to install the root again afterwards.
2329
2332
  --no-proxy with enable: answer each name's origin rather than the local
2330
2333
  pinned-TLS proxy. Only the proxy can hand a stock client a
2331
2334
  certificate it will accept, so this is the other half of the
@@ -2956,6 +2959,14 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
2956
2959
  const cleared2 = await applyPlan({ steps: [{ kind: "remove", path: manifestFile, why: "the restore point has been used" }] });
2957
2960
  if (cleared2.ok) out(` ok remove ${manifestFile}`);
2958
2961
  }
2962
+
2963
+ // The routing is back, which leaves the trust anchor as the last thing
2964
+ // `enable` did that is still on this machine — and it was the one change
2965
+ // the restore point never covered, because it is not a file in /etc.
2966
+ // Removing it by default is what makes `disable` mean "as it was".
2967
+ // `--keep-trust` is for turning resolution off for an afternoon without
2968
+ // paying for a re-install of the root afterwards.
2969
+ if (!rest.includes("--keep-trust")) await applyUntrust(out, deps);
2959
2970
  out("");
2960
2971
 
2961
2972
  // The line the old implementation printed unconditionally, now only when
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: {
@@ -283,20 +283,36 @@ export function resolveEngine(token) {
283
283
  // script unpacks to $HOME/.turso and only appends it to your shell profile).
284
284
  // Searching them after PATH keeps a real `turso` on PATH winning, while still
285
285
  // finding the one we just installed.
286
+ /**
287
+ * The name to print when talking about a `bin` that may be several.
288
+ *
289
+ * A `bin` is normally one string. ImageMagick is why it can be a list: the
290
+ * command is `magick` on ImageMagick 7 and `convert` on 6, both are current on
291
+ * supported distros at the same time, and picking either one alone makes a
292
+ * successful install report as missing on half of them. The first name is the
293
+ * one we prefer and the one worth naming in a message.
294
+ */
295
+ export function primaryBin(bin) {
296
+ return Array.isArray(bin) ? bin[0] : bin;
297
+ }
298
+
286
299
  function executableCandidates(bin, extraDirs = []) {
287
300
  const exts = process.platform === "win32" ? ["", ...(process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";")] : [""];
288
- const dirs = path.isAbsolute(bin) || bin.includes(path.sep)
289
- ? [""]
290
- : [...(process.env.PATH || "").split(path.delimiter).filter(Boolean), ...extraDirs.filter(Boolean)];
301
+ const names = (Array.isArray(bin) ? bin : [bin]).filter(Boolean);
291
302
  const seen = new Set();
292
303
  const candidates = [];
293
- for (const dir of dirs) {
294
- for (const ext of exts) {
295
- const candidate = dir ? path.join(dir, bin + ext) : bin + ext;
296
- const key = candidate.toLowerCase();
297
- if (!seen.has(key)) {
298
- seen.add(key);
299
- candidates.push(candidate);
304
+ for (const name of names) {
305
+ const dirs = path.isAbsolute(name) || name.includes(path.sep)
306
+ ? [""]
307
+ : [...(process.env.PATH || "").split(path.delimiter).filter(Boolean), ...extraDirs.filter(Boolean)];
308
+ for (const dir of dirs) {
309
+ for (const ext of exts) {
310
+ const candidate = dir ? path.join(dir, name + ext) : name + ext;
311
+ const key = candidate.toLowerCase();
312
+ if (!seen.has(key)) {
313
+ seen.add(key);
314
+ candidates.push(candidate);
315
+ }
300
316
  }
301
317
  }
302
318
  }
@@ -323,7 +339,9 @@ function nodeShebang(file) {
323
339
 
324
340
  function spawnSpec(bin, args = [], extraDirs = []) {
325
341
  const resolved = resolveExecutable(bin, extraDirs);
326
- if (!resolved) return { cmd: bin, args };
342
+ // Unresolved, so hand the spawn the preferred name and let it produce the
343
+ // ENOENT — a list would be spawned as a single nonsense filename.
344
+ if (!resolved) return { cmd: primaryBin(bin), args };
327
345
  if (process.platform === "win32" && path.extname(resolved) === "" && nodeShebang(resolved)) {
328
346
  return { cmd: process.execPath, args: [resolved, ...args] };
329
347
  }
@@ -421,21 +439,29 @@ export function runCmd(cmd, args = [], { capture = false } = {}) {
421
439
  let child;
422
440
  const spec = spawnSpec(cmd, args);
423
441
  const stdio = capture ? ["inherit", "pipe", "pipe"] : "inherit";
424
- try { child = spawn(spec.cmd, spec.args, { stdio }); }
425
- 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; }
426
452
  let output = "";
427
453
  if (capture) {
428
454
  for (const [stream, sink] of [[child.stdout, process.stdout], [child.stderr, process.stderr]]) {
429
455
  stream?.on("data", (chunk) => { output += chunk.toString(); sink.write(chunk); });
430
456
  }
431
457
  }
432
- child.on("error", (e) => resolve({ ok: false, error: e, output }));
458
+ child.on("error", (e) => finish({ ok: false, error: e, output }));
433
459
  // "exit" fires as soon as the process is gone, which with pipes can leave
434
460
  // the last chunk still queued — the one line we are trying to read. "close"
435
461
  // waits for the streams too. With stdio inherited there are no streams, so
436
462
  // the two are the same moment and existing callers are unaffected; the
437
463
  // distinction is kept explicit so neither branch changes by accident.
438
- 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 }));
439
465
  });
440
466
  }
441
467
 
@@ -483,35 +509,8 @@ export function openPassthrough(target, args = [], { onOutput } = {}) {
483
509
  // the child the tty's own file descriptors, so none of its bytes ever pass
484
510
  // through this process. See src/pty.mjs for why this is script(1) and not
485
511
  // a pipe or node-pty.
486
- let transcript = null;
487
- let workDir = null;
488
- let stopFollow = null;
489
- let launch = { ...spec, stdio: "inherit" };
490
- if (ptyEnabled(onOutput)) {
491
- try {
492
- workDir = mkdtempSync(path.join(tmpdir(), "moshcode-pty-"));
493
- transcript = path.join(workDir, "transcript");
494
- writeFileSync(transcript, "");
495
- const wrapped = ptySpec(spec.cmd, spec.args, transcript, scriptFlavor());
496
- if (wrapped) {
497
- launch = { ...wrapped, stdio: "inherit" };
498
- let first = true;
499
- stopFollow = followFile(transcript, (chunk) => {
500
- const clean = stripScriptBanner(chunk, first);
501
- first = false;
502
- if (clean) onOutput(clean);
503
- });
504
- }
505
- } catch {
506
- // Capture is a nicety; never let it stop the session from opening.
507
- transcript = null;
508
- }
509
- }
510
-
511
- const cleanup = () => {
512
- try { stopFollow?.(); } catch { /* nothing left to drain */ }
513
- if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* temp dir */ } }
514
- };
512
+ const launch = captureSpec(spec, onOutput);
513
+ const cleanup = () => launch.stop();
515
514
 
516
515
  let child;
517
516
  try { child = spawn(launch.cmd, launch.args, { stdio: "inherit", env }); }