moshcode 0.32.0 → 0.33.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.
@@ -20,6 +20,7 @@
20
20
  */
21
21
  export const COMMAND_GROUPS = [
22
22
  { key: "engines", title: "engines" },
23
+ { key: "runtime", title: "runtime" },
23
24
  { key: "tools", title: "tools" },
24
25
  { key: "extend", title: "extend" },
25
26
  { key: "script", title: "script" },
@@ -53,9 +54,92 @@ export const CORE_CLI_COMMANDS = [
53
54
  name: "start",
54
55
  group: "engines",
55
56
  description: "launch an engine with its native defaults",
56
- synopsis: [["moshcode start <engine> [args…]", "no bypass flags, no agent view"]],
57
- examples: [["moshcode start opencode", ""]],
58
- seeAlso: ["agents", "engines"],
57
+ synopsis: [
58
+ ["moshcode start <engine> [args…]", "no bypass flags, no agent view"],
59
+ ["moshcode start <engine> --detach", "run it in the herd and keep your prompt"],
60
+ ],
61
+ flags: [
62
+ ["--detach, -d", "start in the herd instead of taking this terminal", ""],
63
+ ["--name <slug>", "name the herd session (implies --detach)", "<engine>-<dir>"],
64
+ ],
65
+ examples: [
66
+ ["moshcode start opencode", ""],
67
+ ["moshcode start claude --name api-refactor", "runs in the background; moshcode ps to see it"],
68
+ ],
69
+ seeAlso: ["agents", "engines", "herd", "ps"],
70
+ },
71
+ {
72
+ name: "herd",
73
+ group: "runtime",
74
+ description: "run agent sessions that outlive this terminal",
75
+ synopsis: [
76
+ ["moshcode herd", "the roster — same as moshcode ps"],
77
+ ["moshcode herd <verb> [args…]", "drive one session"],
78
+ ],
79
+ verbs: "HERD_VERBS",
80
+ flags: [["--json", "machine-readable, on every verb", ""]],
81
+ examples: [
82
+ ["moshcode herd start claude --name api", "start one and get your prompt back"],
83
+ ["moshcode herd prompt api \"run the tests\" --wait", "hand it work and block until it lands"],
84
+ ["moshcode herd read api --lines 40", "read its screen without attaching"],
85
+ ],
86
+ seeAlso: ["ps", "attach", "wait", "restore", "start"],
87
+ note: "sessions live in a tmux server moshcode owns, or under script(1) when there is no tmux. with neither, launches stay in the foreground and say so.",
88
+ },
89
+ {
90
+ name: "ps",
91
+ group: "runtime",
92
+ description: "list herd sessions and what each one is doing",
93
+ synopsis: [["moshcode ps [--json]", "name, engine, state, cwd, age"]],
94
+ flags: [["--json", "machine-readable", ""]],
95
+ examples: [["moshcode ps", "which agent is blocked?"]],
96
+ seeAlso: ["herd", "attach", "wait"],
97
+ note: "state is idle, working, blocked, done or unknown — unknown is a safe answer, not a failure.",
98
+ },
99
+ {
100
+ name: "attach",
101
+ group: "runtime",
102
+ description: "attach this terminal to a herd session",
103
+ synopsis: [["moshcode attach <name>", "detach again with Ctrl-b d (or Ctrl-] without tmux)"]],
104
+ examples: [["moshcode attach api", ""]],
105
+ seeAlso: ["ps", "herd", "kill"],
106
+ note: "detaching leaves the session running. ending it is `moshcode kill`.",
107
+ },
108
+ {
109
+ name: "kill",
110
+ group: "runtime",
111
+ description: "end a herd session",
112
+ synopsis: [["moshcode kill <name…> | --all", ""]],
113
+ flags: [["--all", "end every session in the herd", ""]],
114
+ examples: [["moshcode kill api", ""]],
115
+ seeAlso: ["ps", "herd", "attach"],
116
+ },
117
+ {
118
+ name: "wait",
119
+ group: "runtime",
120
+ description: "block until a session is blocked, done, or idle",
121
+ synopsis: [["moshcode wait <name> [--state blocked,done] [--timeout 30m]", ""]],
122
+ flags: [
123
+ ["--state <list>", "states to wait for, comma-separated", "blocked,done"],
124
+ ["--timeout <dur>", "give up after this long (30s, 10m, 2h)", "30m"],
125
+ ["--json", "machine-readable", ""],
126
+ ],
127
+ examples: [["moshcode wait api --state blocked --timeout 1h", "exit 0 matched · 2 timed out · 3 gone"]],
128
+ seeAlso: ["herd", "ps"],
129
+ note: "exit codes are the point: 0 matched, 2 timed out, 3 no such session.",
130
+ },
131
+ {
132
+ name: "restore",
133
+ group: "runtime",
134
+ description: "rebuild the herd's sessions after a reboot",
135
+ synopsis: [["moshcode restore [--resume] [--dry-run]", ""]],
136
+ flags: [
137
+ ["--resume", "ask each engine to reopen its own last conversation", ""],
138
+ ["--dry-run", "say what would come back, change nothing", ""],
139
+ ],
140
+ examples: [["moshcode restore --dry-run", ""]],
141
+ seeAlso: ["herd", "ps"],
142
+ note: "this brings back the shape — sessions, directories, engines. the processes are new; work that was in flight is not still running.",
59
143
  },
60
144
  {
61
145
  name: "install",
@@ -197,6 +281,7 @@ export const CORE_CLI_COMMANDS = [
197
281
  ["--port <n>", "port for the bridge", "5354"],
198
282
  ["--registry <url>", "registry to resolve against", "https://pit.moshcode.sh"],
199
283
  ["--no-trust", "with enable: route names but skip the local CA", ""],
284
+ ["--no-proxy", "with enable: answer origins rather than the local proxy", ""],
200
285
  ],
201
286
  examples: [
202
287
  ["sudo moshcode dns enable", "route Moshpit endings here"],
@@ -620,7 +705,70 @@ export const PLUGIN_VERBS = [
620
705
  ];
621
706
 
622
707
  /** Sub-verb tables, by the name a command's `verbs` field refers to. */
708
+ export const HERD_VERBS = [
709
+ { name: "ps", description: "the roster: every session and its state",
710
+ synopsis: [["moshcode herd ps [--json]", ""]],
711
+ flags: [["--json", "machine-readable", ""]] },
712
+ { name: "status", description: "what the herd is running on, and how many sessions",
713
+ synopsis: [["moshcode herd status [--json]", ""]],
714
+ flags: [["--json", "machine-readable", ""]] },
715
+ { name: "start", description: "start a session and hand the prompt back",
716
+ synopsis: [["moshcode herd start <engine> [--name <slug>] [--agent] [args…]", ""]],
717
+ flags: [
718
+ ["--name <slug>", "session name", "<engine>-<dir>"],
719
+ ["--cwd <dir>", "where to run it", "this directory"],
720
+ ["--agent", "autonomous mode — bypasses the engine's approvals", ""],
721
+ ["--json", "machine-readable", ""],
722
+ ] },
723
+ { name: "attach", description: "put this terminal inside a session",
724
+ synopsis: [["moshcode herd attach <name>", ""]] },
725
+ { name: "kill", description: "end a session",
726
+ synopsis: [["moshcode herd kill <name…> | --all", ""]],
727
+ flags: [["--all", "every session", ""]] },
728
+ { name: "prune", description: "forget sessions the runtime no longer has",
729
+ synopsis: [["moshcode herd prune", "never ends anything that is running"]] },
730
+ { name: "read", description: "read a session's screen without attaching",
731
+ synopsis: [["moshcode herd read <name> [--lines N]", ""]],
732
+ flags: [["--lines <n>", "how much of the screen", "60"], ["--json", "machine-readable", ""]] },
733
+ { name: "prompt", description: "type a prompt into a session",
734
+ synopsis: [['moshcode herd prompt <name> "<text>" [--wait]', ""]],
735
+ flags: [
736
+ ["--wait", "block until the session stops working", ""],
737
+ ["--timeout <dur>", "give up waiting after this long", "30m"],
738
+ ["--json", "machine-readable", ""],
739
+ ] },
740
+ { name: "send-keys", description: "send raw keys (Enter, Escape, C-c, literal text)",
741
+ synopsis: [["moshcode herd send-keys <name> <keys…>", ""]] },
742
+ { name: "wait", description: "block until a session reaches a state",
743
+ synopsis: [["moshcode herd wait <name> [--state blocked,done]", ""]],
744
+ flags: [
745
+ ["--state <list>", "states to wait for", "blocked,done"],
746
+ ["--timeout <dur>", "give up after this long", "30m"],
747
+ ["--json", "machine-readable", ""],
748
+ ] },
749
+ { name: "restore", description: "rebuild remembered sessions after a reboot",
750
+ synopsis: [["moshcode herd restore [--resume] [--dry-run]", ""]],
751
+ flags: [["--resume", "reopen each engine's last conversation", ""], ["--dry-run", "change nothing", ""]] },
752
+ { name: "report", description: "record an authoritative state (for engine hooks)",
753
+ synopsis: [["moshcode herd report <name> <state> [--ttl 15m]", ""]],
754
+ flags: [["--ttl <dur>", "how long the report stays authoritative", "15m"]] },
755
+ { name: "notify", description: "page the operator when a session blocks",
756
+ synopsis: [["moshcode herd notify <on|off|status> [--state blocked,done] [--ask]", ""]],
757
+ flags: [
758
+ ["--state <list>", "which transitions are worth a notification", "blocked"],
759
+ ["--ask", "wait for a reply and type it into the session", ""],
760
+ ["--no-ask", "notify only; never wait for a reply", ""],
761
+ ] },
762
+ { name: "watch", description: "deliver those notifications (run it inside the herd)",
763
+ synopsis: [["moshcode herd watch [--interval 5s]", ""]],
764
+ flags: [["--interval <dur>", "how often to look", "5s"], ["--force", "watch even with notifications off", ""]] },
765
+ { name: "stop", description: "stop the whole runtime and everything in it",
766
+ synopsis: [["moshcode herd stop --yes", ""]],
767
+ flags: [["--yes, -y", "required when sessions are running", ""]] },
768
+ ];
769
+
623
770
  export const VERB_TABLES = {
771
+ HERD_VERBS,
624
772
  MCP_VERBS,
625
773
  SKILL_VERBS,
626
774
  UPGRADE_TARGETS,
@@ -651,6 +799,18 @@ export const PIT_COMMANDS = [
651
799
  description: "list engines, or launch one autonomously" },
652
800
  { name: "start", args: "<engine> [args…]", cli: "start",
653
801
  description: "raw launch; inject no engine arguments" },
802
+ { name: "herd", args: "[verb] [args…]", cli: "herd",
803
+ description: "sessions that keep running when you leave" },
804
+ { name: "ps", cli: "ps",
805
+ description: "what the herd is running, and which one wants you" },
806
+ { name: "attach", args: "<name>", cli: "attach",
807
+ description: "step into a herd session (detach leaves it running)" },
808
+ { name: "kill", args: "<name…>", cli: "kill",
809
+ description: "end a herd session" },
810
+ { name: "wait", args: "<name> [--state …]", cli: "wait",
811
+ description: "block until a session is blocked or done" },
812
+ { name: "restore", args: "[--resume]", cli: "restore",
813
+ description: "rebuild the herd's sessions after a reboot" },
654
814
  { name: "tools", args: "[name] [args…]", cli: "tools",
655
815
  description: "list workflow tools, or run one" },
656
816
  { name: "trade", args: "<verb> [args…]", cli: "trade",
package/src/commands.mjs CHANGED
@@ -18,6 +18,8 @@ import { spawn, spawnSync } from "node:child_process";
18
18
  import { createRegistry } from "./registry.mjs";
19
19
  import { cliVerb, aiVerb } from "./cli.mjs";
20
20
  import { ingestApproval, pollApproval } from "./notify.mjs";
21
+ import { capture, killSession, sendPrompt } from "./herd.mjs";
22
+ import { herdStart, roster, waitFor } from "./herd-cli.mjs";
21
23
 
22
24
  // The moshcoding pit-anthem playlist. mosh() blasts this URL and, on a desktop
23
25
  // with a GUI, tries to open it in the default browser.
@@ -221,6 +223,103 @@ const COMMANDS = [
221
223
  },
222
224
  },
223
225
 
226
+ // The herd (PRD 0009 R12). These are local rather than cliVerbs on purpose:
227
+ // a cliVerb returns { ok, code }, and the whole reason a script wants the
228
+ // herd is to fan work out and then read what came back. `herdRead()` has to
229
+ // hand back a string and `herdList()` an array, which shelling out cannot do.
230
+ //
231
+ // const a = herdStart("claude", { name: "api" });
232
+ // const b = herdStart("codex", { name: "web" });
233
+ // herdPrompt("api", "port the auth routes");
234
+ // herdPrompt("web", "port the dashboard");
235
+ // await herdWait("api"); await herdWait("web"); // both, in parallel
236
+ // say(herdRead("api", { lines: 20 }));
237
+ {
238
+ name: "herdStart",
239
+ summary: "start an agent session that outlives this script",
240
+ usage: 'herdStart(engine, { name, cwd, agent })',
241
+ detail: "returns { ok, name }; the session keeps running after the script ends",
242
+ run(ctx, engine, opts = {}) {
243
+ if (!engine) throw new Error("moshscript: herdStart(engine) requires an engine name");
244
+ const argv = [String(engine), "--json"];
245
+ if (opts.name) argv.push("--name", String(opts.name));
246
+ if (opts.cwd) argv.push("--cwd", String(opts.cwd));
247
+ if (opts.agent) argv.push("--agent");
248
+ if (ctx.dryRun) {
249
+ ctx.out(` 🐑 herdStart(${engine}) → would run: moshcode herd start ${argv.join(" ")}`);
250
+ return { ok: true, name: opts.name || String(engine), dryRun: true };
251
+ }
252
+ let captured = "";
253
+ const code = herdStart(argv, { write: (s) => { captured += `${s}\n`; } });
254
+ if (code !== 0) { ctx.out(` ✗ herdStart(${engine}) → ${captured.trim()}`); return { ok: false, name: null }; }
255
+ const name = JSON.parse(captured).name;
256
+ ctx.out(` 🐑 herdStart(${engine}) → ${name}`);
257
+ return { ok: true, name };
258
+ },
259
+ },
260
+ {
261
+ name: "herdPrompt",
262
+ summary: "type a prompt into a herd session",
263
+ usage: "herdPrompt(name, text)",
264
+ detail: "returns { ok }; does not wait — use herdWait() to join",
265
+ run(ctx, name, ...words) {
266
+ const text = words.join(" ");
267
+ if (!name || !text) throw new Error("moshscript: herdPrompt(name, text) requires both");
268
+ if (ctx.dryRun) { ctx.out(` 💬 herdPrompt(${name}) → would send: ${text}`); return { ok: true, dryRun: true }; }
269
+ ctx.out(` 💬 herdPrompt(${name}) → ${text.slice(0, 60)}${text.length > 60 ? "…" : ""}`);
270
+ const sent = sendPrompt(String(name), text);
271
+ return { ok: Boolean(sent.ok) };
272
+ },
273
+ },
274
+ {
275
+ name: "herdWait",
276
+ summary: "BLOCK until a herd session is blocked, done, or idle",
277
+ usage: "herdWait(name, { states, timeout })",
278
+ detail: "returns the state it reached. needs await",
279
+ async run(ctx, name, opts = {}) {
280
+ if (!name) throw new Error("moshscript: herdWait(name) requires a session name");
281
+ const states = opts.states || ["blocked", "done", "idle"];
282
+ if (ctx.dryRun) { ctx.out(` ⏳ herdWait(${name}) → would wait for ${states.join("/")}`); return "idle"; }
283
+ ctx.out(` ⏳ herdWait(${name}) → waiting for ${states.join("/")}…`);
284
+ const result = await waitFor(String(name), states, opts.timeout ? { timeoutMs: Number(opts.timeout) } : {});
285
+ ctx.out(` ${result.outcome === "matched" ? "✅" : "⌛"} ${name} is ${result.state}`);
286
+ return result.state;
287
+ },
288
+ },
289
+ {
290
+ name: "herdRead",
291
+ summary: "read a herd session's screen as a string",
292
+ usage: "herdRead(name, { lines })",
293
+ detail: "returns the last `lines` rows of its screen (default 60)",
294
+ run(ctx, name, opts = {}) {
295
+ if (!name) throw new Error("moshscript: herdRead(name) requires a session name");
296
+ if (ctx.dryRun) { ctx.out(` 📖 herdRead(${name}) → would read its screen`); return ""; }
297
+ return capture(String(name), { lines: Number(opts.lines) || 60 });
298
+ },
299
+ },
300
+ {
301
+ name: "herdList",
302
+ summary: "every herd session and its state",
303
+ usage: "herdList()",
304
+ detail: "returns [{ name, engine, state, cwd, alive }, …]",
305
+ run(ctx) {
306
+ if (ctx.dryRun) { ctx.out(" 🐑 herdList() → would list the herd"); return []; }
307
+ return roster().map(({ name, engine, state, cwd, alive }) => ({ name, engine, state, cwd, alive }));
308
+ },
309
+ },
310
+ {
311
+ name: "herdKill",
312
+ summary: "end a herd session",
313
+ usage: "herdKill(name)",
314
+ detail: "returns { ok }",
315
+ run(ctx, name) {
316
+ if (!name) throw new Error("moshscript: herdKill(name) requires a session name");
317
+ if (ctx.dryRun) { ctx.out(` ⏹ herdKill(${name}) → would end it`); return { ok: true, dryRun: true }; }
318
+ ctx.out(` ⏹ herdKill(${name})`);
319
+ return { ok: Boolean(killSession(String(name)).ok) };
320
+ },
321
+ },
322
+
224
323
  // CLI verbs — each is `moshcode <name> ...args`. This is the whole point:
225
324
  // scripting the CLI. Add a capability by adding a line here.
226
325
  //
@@ -231,6 +330,8 @@ const COMMANDS = [
231
330
  // shortcut: ai() runs an engine headlessly and RETURNS its output (see PRD R17)
232
331
  aiVerb,
233
332
  cliVerb("agents", "launch an autonomous agent session (moshcode agents <engine>)"),
333
+ cliVerb("herd", "drive the herd (moshcode herd <verb>) — see herdStart/herdWait for values"),
334
+ cliVerb("ps", "print the herd roster"),
234
335
  cliVerb("start", "raw-launch an engine (moshcode start <engine>)"),
235
336
  cliVerb("install", "install an engine or workflow tool"),
236
337
  cliVerb("upgrade", "upgrade moshcode, engines, and tools"),
@@ -373,13 +373,17 @@ export async function daemonStatus(path = pidfilePath()) {
373
373
  * not survive a reboot. `moshcode dns status` says so plainly rather than
374
374
  * letting someone discover it when their names stop resolving.
375
375
  */
376
- export async function startDaemon({ port, registryBase, path = pidfilePath(), entry }) {
376
+ export async function startDaemon({ port, registryBase, path = pidfilePath(), entry, proxy = null }) {
377
377
  const existing = await daemonStatus(path);
378
378
  if (existing.running) return { started: false, pid: existing.pid, alreadyRunning: true };
379
379
 
380
380
  await mkdir(dirname(path), { recursive: true });
381
381
  const args = [entry, "dns", "start", "--port", String(port)];
382
382
  if (registryBase) args.push("--registry", registryBase);
383
+ // Passed at spawn time because it is what the resolver answers with, not
384
+ // something it can be told later — there is no channel to a detached daemon
385
+ // short of restarting it, which is why `enable` decides this before starting.
386
+ if (proxy) args.push("--proxy", proxy);
383
387
 
384
388
  const child = spawn(process.execPath, args, { detached: true, stdio: "ignore" });
385
389
  child.unref();
package/src/dns.mjs CHANGED
@@ -695,6 +695,111 @@ export function proxyReachable(address, port = 443, { connect = null, timeoutMs
695
695
  });
696
696
  }
697
697
 
698
+ /** The root moshpit-proxy signs with. Its leaves are how the proxy is recognised. */
699
+ export const PROXY_ROOT_CN = "Moshpit Local CA";
700
+
701
+ /**
702
+ * Is the thing on that address *our proxy*, or merely something on port 443?
703
+ *
704
+ * `proxyReachable` answers the second question, and on one common class of
705
+ * machine the two answers differ in the worst possible way. An origin runs
706
+ * nginx on `0.0.0.0:443`, which covers loopback — so a bare connect succeeds,
707
+ * proxy mode is turned on, and every live Moshpit name on the machine is
708
+ * pointed at a web server that knows nothing about them. That is not a
709
+ * certificate problem, it is every name on the machine serving the wrong site
710
+ * at once, and the connect probe cannot see it coming.
711
+ *
712
+ * So this asks the question that actually distinguishes them: complete a TLS
713
+ * handshake and look at who issued the certificate. The proxy mints a leaf per
714
+ * name from the root it generated on this machine, so the issuer is that root.
715
+ * Anything else — nginx with the origin's own self-signed certificate, some
716
+ * unrelated service — is issued by something else and is refused.
717
+ *
718
+ * `rejectUnauthorized` is off deliberately, and it is not a hole: nothing is
719
+ * sent, the peer certificate is read rather than trusted, and the only thing
720
+ * accepted from it is the issuer name. Verifying properly would require the
721
+ * root to already be installed, which is a step that has not happened yet at
722
+ * the point this runs.
723
+ */
724
+ export async function proxyServes(address, name, {
725
+ port = PROXY_PORT,
726
+ timeoutMs = 2500,
727
+ tlsConnect = null,
728
+ } = {}) {
729
+ const connectImpl = tlsConnect || (await import("node:tls")).connect;
730
+ return new Promise((resolve) => {
731
+ let socket;
732
+ const done = (result) => {
733
+ try { socket?.destroy(); } catch { /* already gone */ }
734
+ resolve(result);
735
+ };
736
+ try {
737
+ socket = connectImpl({
738
+ host: address,
739
+ port,
740
+ servername: name,
741
+ rejectUnauthorized: false,
742
+ // The proxy forces http/1.1; offering nothing keeps this a pure
743
+ // handshake rather than a protocol negotiation that could be declined.
744
+ ALPNProtocols: ["http/1.1"],
745
+ });
746
+ // Not unref'd, for the reason proxyReachable spells out: this timer is the
747
+ // only guarantee the promise settles.
748
+ const timer = setTimeout(() => done({ ok: false, why: "timed out" }), timeoutMs);
749
+ socket.once("secureConnect", () => {
750
+ clearTimeout(timer);
751
+ const cert = socket.getPeerCertificate?.() || {};
752
+ const issuer = cert.issuer?.CN || "";
753
+ if (issuer === PROXY_ROOT_CN) return done({ ok: true, issuer });
754
+ done({
755
+ ok: false,
756
+ issuer,
757
+ // Named as what it means rather than what was seen: "issuer is
758
+ // chovy.hacker" is a fact, "something else owns 443" is the reason
759
+ // proxy mode must stay off.
760
+ why: issuer
761
+ ? `something other than the proxy owns ${address}:${port} — it served a certificate issued by ${JSON.stringify(issuer)}`
762
+ : `something other than the proxy owns ${address}:${port}`,
763
+ });
764
+ });
765
+ socket.once("error", (err) => {
766
+ clearTimeout(timer);
767
+ done({ ok: false, why: err?.code || err?.message || "connection failed" });
768
+ });
769
+ } catch (err) {
770
+ resolve({ ok: false, why: err?.message || "connection failed" });
771
+ }
772
+ });
773
+ }
774
+
775
+ /**
776
+ * Which loopback addresses have the proxy behind them, if any.
777
+ *
778
+ * Both families are asked because answering one of them wrongly is an outage:
779
+ * a v6-only answer for a v4-only listener is a refused connection that reads as
780
+ * the site being down. `addressAnswer` handles the asymmetry; this just reports
781
+ * what is actually there.
782
+ */
783
+ export async function findLocalProxy(name, { candidates = ["127.0.0.1", "::1"], ...options } = {}) {
784
+ const reachable = [];
785
+ let why = null;
786
+ for (const address of candidates) {
787
+ const result = await proxyServes(address, name, options);
788
+ if (result.ok) reachable.push(address);
789
+ // Keep the most informative refusal: "something else owns 443" is worth
790
+ // saying out loud, where "ECONNREFUSED" just means no proxy is installed.
791
+ else if (result.issuer && !why) why = result.why;
792
+ }
793
+ return {
794
+ found: reachable.length > 0,
795
+ why,
796
+ address: {
797
+ v4: reachable.find((a) => isIP(a) === 4) || null,
798
+ v6: reachable.find((a) => isIP(a) === 6) || null,
799
+ },
800
+ };
801
+ }
802
+
698
803
  export async function addressAnswer(name, options = {}) {
699
804
  const { parkingAddress, wantsV6 = false, proxyAddress = null } = options;
700
805
  const plan = (kind, extra) => ({ exists: true, kind, records: [], address: null, cname: null, ...extra });
@@ -2088,6 +2193,10 @@ const USAGE = `moshcode dns — resolve Moshpit names on this machine
2088
2193
  --no-trust with enable: route names but skip the local CA. They will
2089
2194
  resolve and then fail TLS, which is the state this flag exists
2090
2195
  to leave you in deliberately.
2196
+ --no-proxy with enable: answer each name's origin rather than the local
2197
+ pinned-TLS proxy. Only the proxy can hand a stock client a
2198
+ certificate it will accept, so this is the other half of the
2199
+ same deliberate breakage.
2091
2200
 
2092
2201
  The registry speaks HTTP, not DNS, so nothing outside a browser can reach a
2093
2202
  Moshpit name until this bridge is running and your resolver points at it.
@@ -2123,6 +2232,7 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
2123
2232
  bridgeStatus = daemonStatus,
2124
2233
  startBridge = startDaemon,
2125
2234
  proxyReachableImpl = proxyReachable,
2235
+ findLocalProxyImpl = findLocalProxy,
2126
2236
  autoTrustImpl = createAutoTrust,
2127
2237
  stopBridge = stopDaemon,
2128
2238
  dropins = readDropins,
@@ -2723,9 +2833,65 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
2723
2833
  // serve is silently shadowed by the bridge it said would not be started.
2724
2834
  // Honoring the note is the whole of the fix.
2725
2835
  const reusing = cleared.holder && cleared.holderForwards ? cleared.holder : null;
2836
+
2837
+ // Proxy mode, decided here because the bridge cannot be told later: what a
2838
+ // resolver answers with is fixed when it starts.
2839
+ //
2840
+ // This is the step that was missing, and its absence is why the whole
2841
+ // feature read as broken. Everything else was built — the proxy verifies
2842
+ // origins against registry pins and re-signs with a root `dns enable`
2843
+ // installs, and `addressAnswer` knows how to point names at it — but
2844
+ // nothing ever turned it on, so names resolved straight to their origin and
2845
+ // a stock client got a certificate no CA had signed. Trust was installed
2846
+ // for a proxy that was never on the path.
2847
+ //
2848
+ // Refusing is the safe direction and the default: with proxy mode on and
2849
+ // nothing behind it, every Moshpit name on the machine resolves and then
2850
+ // refuses the connection.
2851
+ let proxyAddress = null;
2852
+ if (reusing) {
2853
+ // A bridge this run did not start keeps whatever mode it was started
2854
+ // with: `startDaemon` decides "already running" from our pidfile, and
2855
+ // there is no channel to a detached daemon to change its mind. So the
2856
+ // probe is skipped rather than run and then discarded — announcing a
2857
+ // proxy and retracting it two lines later is worse than not looking.
2858
+ out(" -- the bridge already running was not started by this run, so it keeps its own");
2859
+ out(" mode — to pick up proxy mode: moshcode dns disable && moshcode dns enable");
2860
+ } else if (!rest.includes("--no-proxy")) {
2861
+ const probeName = moshpitProbe || "";
2862
+ if (!probeName) {
2863
+ out(" -- proxy mode not checked — no Moshpit name to probe with");
2864
+ } else {
2865
+ const local = await findLocalProxyImpl(probeName);
2866
+ if (local.found) {
2867
+ proxyAddress = local.address;
2868
+ const at = [local.address.v4, local.address.v6].filter(Boolean).join(", ");
2869
+ out(` ok pinned-TLS proxy on ${at}:${PROXY_PORT} — every live name will answer there`);
2870
+ } else if (local.why) {
2871
+ // The origin case, and the one worth naming precisely. A machine that
2872
+ // serves Moshpit names has nginx on 443, so the proxy cannot be on the
2873
+ // path here and pointing names at loopback would hand all of them to
2874
+ // a web server that has never heard of them.
2875
+ out(` -- ${local.why}`);
2876
+ out(" proxy mode stays off — names will answer their origin.");
2877
+ } else {
2878
+ out(" -- no pinned-TLS proxy on this machine — names will answer their origin");
2879
+ out(" a stock client cannot verify those: https://github.com/profullstack/moshpit-proxy");
2880
+ }
2881
+ }
2882
+ }
2883
+
2726
2884
  const started = reusing
2727
2885
  ? { started: false, pid: reusing.pid, alreadyRunning: true, reused: true }
2728
- : await startBridge({ port: wanted, registryBase, entry: cliEntry() });
2886
+ : await startBridge({
2887
+ port: wanted,
2888
+ registryBase,
2889
+ entry: cliEntry(),
2890
+ // v4 by preference: `dns start --proxy` takes one address and probes
2891
+ // both families itself, so handing it the v4 loopback lets it find ::1
2892
+ // too rather than pinning the answer to one family.
2893
+ proxy: proxyAddress ? (proxyAddress.v4 || proxyAddress.v6) : null,
2894
+ });
2729
2895
  out(started.reused
2730
2896
  ? ` ok using the bridge already on ${DEFAULT_HOST}:${wanted} (pid ${reusing.pid || "?"}) — not starting a second one`
2731
2897
  : started.alreadyRunning
package/src/engines.mjs CHANGED
@@ -10,6 +10,19 @@
10
10
  // `agentArgs` — an autonomous session with native approvals
11
11
  // bypassed/auto-approved. Do not use a machine-readable, one-shot list command
12
12
  // as an agents view: `/agents` promises to hand the terminal to a live session.
13
+ //
14
+ // `state` (optional) is how the herd reads this engine's screen when it has no
15
+ // authoritative hook to go on (PRD 0009 R7). It lives here, next to the install
16
+ // spec, so a new engine ships its detection rules with itself rather than in a
17
+ // table somewhere else that nobody remembers to update. Shared patterns — bare
18
+ // y/n prompts, "esc to interrupt" — are in src/herd-state.mjs and do not need
19
+ // repeating; only put a pattern here when it is this engine's own wording.
20
+ // Every pattern is matched against the bottom of the screen with ANSI stripped.
21
+ //
22
+ // `resume` (optional) is the argv that reopens this engine's last conversation,
23
+ // used by `moshcode restore --resume` after a reboot. Omit it rather than guess:
24
+ // a session that starts fresh is a small disappointment, and one that starts
25
+ // with a flag the engine does not have is a crash.
13
26
  import { spawn } from "node:child_process";
14
27
  import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
15
28
  import { homedir, tmpdir } from "node:os";
@@ -24,6 +37,10 @@ export const ENGINES = {
24
37
  agentArgs: ["--auto"],
25
38
  install: { cmd: "bash", args: ["-c", "curl -fsSL https://opencode.ai/install | bash"] },
26
39
  upgrade: { cmd: "opencode", args: ["upgrade"] },
40
+ resume: ["--continue"],
41
+ state: {
42
+ blocked: [/\bpermission (?:request|required)\b/i, /\ballow this (?:command|tool)\b/i],
43
+ },
27
44
  // The installer appends this directory to a shell profile. The moshcode
28
45
  // process that ran it cannot see that PATH change, so search it directly.
29
46
  binDirs: [path.join(homedir(), ".opencode", "bin")],
@@ -35,6 +52,11 @@ export const ENGINES = {
35
52
  agentArgs: ["--auto"],
36
53
  install: { cmd: "sh", args: ["-c", "curl -fsSL https://getprivacycode.com/install | sh"] },
37
54
  binDirs: [path.join(homedir(), ".privacycode", "bin")],
55
+ // Same lineage, so the same screen wording and the same resume flag.
56
+ resume: ["--continue"],
57
+ state: {
58
+ blocked: [/\bpermission (?:request|required)\b/i, /\ballow this (?:command|tool)\b/i],
59
+ },
38
60
  // Deliberately no native updater. `privacycode upgrade` is opencode's, and
39
61
  // it works out how to update itself by recognising where it was installed —
40
62
  // it knows opencode's own locations, not this fork's ~/.privacycode/bin. It
@@ -59,18 +81,37 @@ export const ENGINES = {
59
81
  "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN",
60
82
  "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_SESSION_ID", "CLAUDE_CODE_CHILD_SESSION",
61
83
  ],
84
+ resume: ["--continue"],
85
+ state: {
86
+ // The permission dialog's own heading, and the selector on its first
87
+ // option — the generic numbered-menu pattern would catch the second only
88
+ // if the cursor happened to be resting there.
89
+ blocked: [/\bdo you want to (?:proceed|make this edit|create)\b/i, /^\s*❯\s*1\.\s*yes/im],
90
+ // Claude Code parks "? for shortcuts" under the composer when it is
91
+ // waiting on you and nothing else, which is as close to an explicit
92
+ // "idle" as it publishes.
93
+ idle: [/\?\s+for shortcuts/i],
94
+ },
62
95
  },
63
96
  codex: {
64
97
  desc: "Codex — OpenAI's coding CLI",
65
98
  bin: "codex",
66
99
  agentArgs: ["--dangerously-bypass-approvals-and-sandbox"],
67
100
  install: { cmd: "npm", args: ["install", "-g", "@openai/codex"] },
101
+ resume: ["resume", "--last"],
102
+ state: {
103
+ blocked: [/\ballow (?:this )?command\b/i, /\bapprove this (?:command|edit|change)\b/i],
104
+ working: [/\besc to interrupt\b/i, /^\s*working\b/im],
105
+ },
68
106
  },
69
107
  gemini: {
70
108
  desc: "Gemini CLI — Google's agentic CLI",
71
109
  bin: "gemini",
72
110
  agentArgs: ["--approval-mode=yolo"],
73
111
  install: { cmd: "npm", args: ["install", "-g", "@google/gemini-cli"] },
112
+ state: {
113
+ blocked: [/\bapply this change\?/i, /\ballow execution\b/i],
114
+ },
74
115
  },
75
116
  kimi: {
76
117
  desc: "Kimi Code — Moonshot AI's agentic CLI",
@@ -131,6 +172,11 @@ export const ENGINES = {
131
172
  agentArgs: ["--yes-always"],
132
173
  install: { cmd: "bash", args: ["-c", "curl -LsSf https://aider.chat/install.sh | sh"] },
133
174
  upgrade: { cmd: "aider", args: ["--upgrade"] },
175
+ state: {
176
+ // aider asks in prose and answers in (Y)es/(N)o, which the shared y/n
177
+ // pattern misses because of the parentheses around the letters.
178
+ blocked: [/\((?:Y\)es|N\)o)/, /\badd .* to the chat\?/i],
179
+ },
134
180
  },
135
181
  };
136
182