moshcode 0.32.0 → 0.33.1
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 +121 -0
- package/bin/moshcode.mjs +31 -0
- package/package.json +1 -1
- package/prd/0009-persistent-agent-runtime.md +362 -0
- package/prd/README.md +1 -0
- package/src/cli-schema.mjs +166 -3
- package/src/commands.mjs +101 -0
- package/src/dns-system.mjs +5 -1
- package/src/dns.mjs +167 -1
- package/src/engines.mjs +46 -0
- package/src/herd-cli.mjs +665 -0
- package/src/herd-state.mjs +227 -0
- package/src/herd.mjs +746 -0
- package/src/pty.mjs +6 -2
- package/src/trust.mjs +60 -7
- package/src/tui.mjs +67 -4
package/src/cli-schema.mjs
CHANGED
|
@@ -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,95 @@ 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: [
|
|
57
|
-
|
|
58
|
-
|
|
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
|
+
+ "the whole herd shares one tmux server, so from inside any session Ctrl-b s picks another, "
|
|
108
|
+
+ "Ctrl-b ) and Ctrl-b ( step through them, and Ctrl-b L goes back to the last one — "
|
|
109
|
+
+ "no switcher under the no-tmux fallback, where Ctrl-] detaches instead.",
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "kill",
|
|
113
|
+
group: "runtime",
|
|
114
|
+
description: "end a herd session",
|
|
115
|
+
synopsis: [["moshcode kill <name…> | --all", ""]],
|
|
116
|
+
flags: [["--all", "end every session in the herd", ""]],
|
|
117
|
+
examples: [["moshcode kill api", ""]],
|
|
118
|
+
seeAlso: ["ps", "herd", "attach"],
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "wait",
|
|
122
|
+
group: "runtime",
|
|
123
|
+
description: "block until a session is blocked, done, or idle",
|
|
124
|
+
synopsis: [["moshcode wait <name> [--state blocked,done] [--timeout 30m]", ""]],
|
|
125
|
+
flags: [
|
|
126
|
+
["--state <list>", "states to wait for, comma-separated", "blocked,done"],
|
|
127
|
+
["--timeout <dur>", "give up after this long (30s, 10m, 2h)", "30m"],
|
|
128
|
+
["--json", "machine-readable", ""],
|
|
129
|
+
],
|
|
130
|
+
examples: [["moshcode wait api --state blocked --timeout 1h", "exit 0 matched · 2 timed out · 3 gone"]],
|
|
131
|
+
seeAlso: ["herd", "ps"],
|
|
132
|
+
note: "exit codes are the point: 0 matched, 2 timed out, 3 no such session.",
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: "restore",
|
|
136
|
+
group: "runtime",
|
|
137
|
+
description: "rebuild the herd's sessions after a reboot",
|
|
138
|
+
synopsis: [["moshcode restore [--resume] [--dry-run]", ""]],
|
|
139
|
+
flags: [
|
|
140
|
+
["--resume", "ask each engine to reopen its own last conversation", ""],
|
|
141
|
+
["--dry-run", "say what would come back, change nothing", ""],
|
|
142
|
+
],
|
|
143
|
+
examples: [["moshcode restore --dry-run", ""]],
|
|
144
|
+
seeAlso: ["herd", "ps"],
|
|
145
|
+
note: "this brings back the shape — sessions, directories, engines. the processes are new; work that was in flight is not still running.",
|
|
59
146
|
},
|
|
60
147
|
{
|
|
61
148
|
name: "install",
|
|
@@ -197,6 +284,7 @@ export const CORE_CLI_COMMANDS = [
|
|
|
197
284
|
["--port <n>", "port for the bridge", "5354"],
|
|
198
285
|
["--registry <url>", "registry to resolve against", "https://pit.moshcode.sh"],
|
|
199
286
|
["--no-trust", "with enable: route names but skip the local CA", ""],
|
|
287
|
+
["--no-proxy", "with enable: answer origins rather than the local proxy", ""],
|
|
200
288
|
],
|
|
201
289
|
examples: [
|
|
202
290
|
["sudo moshcode dns enable", "route Moshpit endings here"],
|
|
@@ -620,7 +708,70 @@ export const PLUGIN_VERBS = [
|
|
|
620
708
|
];
|
|
621
709
|
|
|
622
710
|
/** Sub-verb tables, by the name a command's `verbs` field refers to. */
|
|
711
|
+
export const HERD_VERBS = [
|
|
712
|
+
{ name: "ps", description: "the roster: every session and its state",
|
|
713
|
+
synopsis: [["moshcode herd ps [--json]", ""]],
|
|
714
|
+
flags: [["--json", "machine-readable", ""]] },
|
|
715
|
+
{ name: "status", description: "what the herd is running on, and how many sessions",
|
|
716
|
+
synopsis: [["moshcode herd status [--json]", ""]],
|
|
717
|
+
flags: [["--json", "machine-readable", ""]] },
|
|
718
|
+
{ name: "start", description: "start a session and hand the prompt back",
|
|
719
|
+
synopsis: [["moshcode herd start <engine> [--name <slug>] [--agent] [args…]", ""]],
|
|
720
|
+
flags: [
|
|
721
|
+
["--name <slug>", "session name", "<engine>-<dir>"],
|
|
722
|
+
["--cwd <dir>", "where to run it", "this directory"],
|
|
723
|
+
["--agent", "autonomous mode — bypasses the engine's approvals", ""],
|
|
724
|
+
["--json", "machine-readable", ""],
|
|
725
|
+
] },
|
|
726
|
+
{ name: "attach", description: "put this terminal inside a session",
|
|
727
|
+
synopsis: [["moshcode herd attach <name>", ""]] },
|
|
728
|
+
{ name: "kill", description: "end a session",
|
|
729
|
+
synopsis: [["moshcode herd kill <name…> | --all", ""]],
|
|
730
|
+
flags: [["--all", "every session", ""]] },
|
|
731
|
+
{ name: "prune", description: "forget sessions the runtime no longer has",
|
|
732
|
+
synopsis: [["moshcode herd prune", "never ends anything that is running"]] },
|
|
733
|
+
{ name: "read", description: "read a session's screen without attaching",
|
|
734
|
+
synopsis: [["moshcode herd read <name> [--lines N]", ""]],
|
|
735
|
+
flags: [["--lines <n>", "how much of the screen", "60"], ["--json", "machine-readable", ""]] },
|
|
736
|
+
{ name: "prompt", description: "type a prompt into a session",
|
|
737
|
+
synopsis: [['moshcode herd prompt <name> "<text>" [--wait]', ""]],
|
|
738
|
+
flags: [
|
|
739
|
+
["--wait", "block until the session stops working", ""],
|
|
740
|
+
["--timeout <dur>", "give up waiting after this long", "30m"],
|
|
741
|
+
["--json", "machine-readable", ""],
|
|
742
|
+
] },
|
|
743
|
+
{ name: "send-keys", description: "send raw keys (Enter, Escape, C-c, literal text)",
|
|
744
|
+
synopsis: [["moshcode herd send-keys <name> <keys…>", ""]] },
|
|
745
|
+
{ name: "wait", description: "block until a session reaches a state",
|
|
746
|
+
synopsis: [["moshcode herd wait <name> [--state blocked,done]", ""]],
|
|
747
|
+
flags: [
|
|
748
|
+
["--state <list>", "states to wait for", "blocked,done"],
|
|
749
|
+
["--timeout <dur>", "give up after this long", "30m"],
|
|
750
|
+
["--json", "machine-readable", ""],
|
|
751
|
+
] },
|
|
752
|
+
{ name: "restore", description: "rebuild remembered sessions after a reboot",
|
|
753
|
+
synopsis: [["moshcode herd restore [--resume] [--dry-run]", ""]],
|
|
754
|
+
flags: [["--resume", "reopen each engine's last conversation", ""], ["--dry-run", "change nothing", ""]] },
|
|
755
|
+
{ name: "report", description: "record an authoritative state (for engine hooks)",
|
|
756
|
+
synopsis: [["moshcode herd report <name> <state> [--ttl 15m]", ""]],
|
|
757
|
+
flags: [["--ttl <dur>", "how long the report stays authoritative", "15m"]] },
|
|
758
|
+
{ name: "notify", description: "page the operator when a session blocks",
|
|
759
|
+
synopsis: [["moshcode herd notify <on|off|status> [--state blocked,done] [--ask]", ""]],
|
|
760
|
+
flags: [
|
|
761
|
+
["--state <list>", "which transitions are worth a notification", "blocked"],
|
|
762
|
+
["--ask", "wait for a reply and type it into the session", ""],
|
|
763
|
+
["--no-ask", "notify only; never wait for a reply", ""],
|
|
764
|
+
] },
|
|
765
|
+
{ name: "watch", description: "deliver those notifications (run it inside the herd)",
|
|
766
|
+
synopsis: [["moshcode herd watch [--interval 5s]", ""]],
|
|
767
|
+
flags: [["--interval <dur>", "how often to look", "5s"], ["--force", "watch even with notifications off", ""]] },
|
|
768
|
+
{ name: "stop", description: "stop the whole runtime and everything in it",
|
|
769
|
+
synopsis: [["moshcode herd stop --yes", ""]],
|
|
770
|
+
flags: [["--yes, -y", "required when sessions are running", ""]] },
|
|
771
|
+
];
|
|
772
|
+
|
|
623
773
|
export const VERB_TABLES = {
|
|
774
|
+
HERD_VERBS,
|
|
624
775
|
MCP_VERBS,
|
|
625
776
|
SKILL_VERBS,
|
|
626
777
|
UPGRADE_TARGETS,
|
|
@@ -651,6 +802,18 @@ export const PIT_COMMANDS = [
|
|
|
651
802
|
description: "list engines, or launch one autonomously" },
|
|
652
803
|
{ name: "start", args: "<engine> [args…]", cli: "start",
|
|
653
804
|
description: "raw launch; inject no engine arguments" },
|
|
805
|
+
{ name: "herd", args: "[verb] [args…]", cli: "herd",
|
|
806
|
+
description: "sessions that keep running when you leave" },
|
|
807
|
+
{ name: "ps", cli: "ps",
|
|
808
|
+
description: "what the herd is running, and which one wants you" },
|
|
809
|
+
{ name: "attach", args: "<name>", cli: "attach",
|
|
810
|
+
description: "step into a herd session (detach leaves it running)" },
|
|
811
|
+
{ name: "kill", args: "<name…>", cli: "kill",
|
|
812
|
+
description: "end a herd session" },
|
|
813
|
+
{ name: "wait", args: "<name> [--state …]", cli: "wait",
|
|
814
|
+
description: "block until a session is blocked or done" },
|
|
815
|
+
{ name: "restore", args: "[--resume]", cli: "restore",
|
|
816
|
+
description: "rebuild the herd's sessions after a reboot" },
|
|
654
817
|
{ name: "tools", args: "[name] [args…]", cli: "tools",
|
|
655
818
|
description: "list workflow tools, or run one" },
|
|
656
819
|
{ 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"),
|
package/src/dns-system.mjs
CHANGED
|
@@ -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({
|
|
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
|
|