moshcode 0.58.0 → 0.60.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/src/herd.mjs CHANGED
@@ -142,6 +142,54 @@ export function forgetSession(name) {
142
142
  return true;
143
143
  }
144
144
 
145
+ // ---------------------------------------------------------------------------
146
+ // Remote members — the last thing a URL told us (PRD 0011 R11)
147
+ // ---------------------------------------------------------------------------
148
+ //
149
+ // A remote member has no pane to capture and no pid to signal, so its state can
150
+ // only come from a request. Requests are slow and the roster is drawn on every
151
+ // pit start, so what `ps` reads is a *cache*: whatever the last call to that
152
+ // remote observed, with the time it was observed at. The alternative — a roster
153
+ // that opens N sockets before it prints a line — makes `moshcode ps` as fast as
154
+ // the slowest agent someone registered, which is not a trade worth making for a
155
+ // column that already says `authority: remote`.
156
+ //
157
+ // It lives here rather than in herd-remote.mjs so that herd-state.mjs can read
158
+ // it without importing the module that makes network calls.
159
+
160
+ const remoteFile = (name) => path.join(herdDir(), "remote", `${name}.json`);
161
+
162
+ /** Record what a remote just told us. Best effort: a failed write loses a poll. */
163
+ export function recordRemoteStatus(name, status = {}) {
164
+ try {
165
+ fs.mkdirSync(path.join(herdDir(), "remote"), { recursive: true, mode: 0o700 });
166
+ const file = remoteFile(name);
167
+ fs.writeFileSync(file, JSON.stringify({ ...status, at: status.at ?? Date.now() }), { mode: 0o600 });
168
+ fs.chmodSync(file, 0o600);
169
+ return true;
170
+ } catch { return false; }
171
+ }
172
+
173
+ /**
174
+ * The cached status of a remote member, or null when it has never answered.
175
+ *
176
+ * Deliberately un-expiring. A hook report has a TTL because a stale one would
177
+ * outrank a screen that could be read instead; there is nothing better to fall
178
+ * back to here, and "it was idle an hour ago" beats "unknown" as long as the
179
+ * age travels with it — which it does, in `--json` and in `herd remote list`.
180
+ */
181
+ export function remoteStatus(name) {
182
+ try {
183
+ const raw = JSON.parse(fs.readFileSync(remoteFile(name), "utf8"));
184
+ return raw && typeof raw === "object" ? raw : null;
185
+ } catch { return null; }
186
+ }
187
+
188
+ export function clearRemoteStatus(name) {
189
+ try { fs.rmSync(remoteFile(name), { force: true }); return true; }
190
+ catch { return false; }
191
+ }
192
+
145
193
  // ---------------------------------------------------------------------------
146
194
  // Substrate detection
147
195
  // ---------------------------------------------------------------------------
@@ -226,10 +274,17 @@ export function tmux(args, { runner = spawnSync, env = process.env, encoding = "
226
274
  * (an inherited ANTHROPIC_API_KEY hijacks its stored login — see ENGINES), and
227
275
  * `-e KEY=` sets an empty value, which is not the same as unset.
228
276
  */
229
- export function sessionCommand({ bin, args = [], stripEnv = [], exec = true }) {
277
+ export function sessionCommand({ bin, args = [], stripEnv = [], setEnv = {}, exec = true }) {
230
278
  const unset = stripEnv.flatMap((key) => ["-u", key]);
279
+ // Set through the same `env` prefix rather than tmux's `-e`, for two reasons:
280
+ // `-e` is a 3.2+ flag and the pty substrate has no equivalent at all, so one
281
+ // prefix is the only spelling both substrates can share.
282
+ const set = Object.entries(setEnv)
283
+ .filter(([key, value]) => key && value !== undefined && value !== null)
284
+ .map(([key, value]) => `${key}=${String(value)}`);
231
285
  const command = [bin, ...args].map(shQuote).join(" ");
232
- const withEnv = unset.length ? `env ${unset.map(shQuote).join(" ")} ${command}` : command;
286
+ const prefix = [...unset, ...set];
287
+ const withEnv = prefix.length ? `env ${prefix.map(shQuote).join(" ")} ${command}` : command;
233
288
  // `exec` so the engine replaces the shell rather than sitting under it — one
234
289
  // less process between a signal and the thing meant to receive it. The pty
235
290
  // substrate passes exec:false because it needs the shell to outlive the
@@ -302,6 +357,24 @@ export function pidAlive(pid) {
302
357
  * never sees EOF and waits for input forever, which is what an idle agent
303
358
  * should do.
304
359
  */
360
+ /**
361
+ * What every session knows about itself (PRD 0011 R2).
362
+ *
363
+ * A lifecycle hook fires inside the engine's own process tree and has to name
364
+ * the session it is reporting for. Nothing else in that tree knows the name, so
365
+ * the herd puts it there at launch. `MOSHCODE_HERD_DIR` rides along because a
366
+ * hook shells out to `moshcode herd report`, and a box whose herd lives
367
+ * somewhere non-default would otherwise have its reports written to the default
368
+ * directory nobody is reading.
369
+ *
370
+ * A hook fired outside a herd session sees neither, which is the signal to exit
371
+ * quietly — see `herdReport`. Hooks must never break an engine that is not in
372
+ * the herd.
373
+ */
374
+ export function sessionEnv(name) {
375
+ return { MOSHCODE_HERD_NAME: name, MOSHCODE_HERD_DIR: herdDir() };
376
+ }
377
+
305
378
  function ptyStart({ name, cwd, bin, args, stripEnv, env, spawner = spawn, runner = spawnSync, size = {} }) {
306
379
  ensureDir();
307
380
  const cols = Number(size.cols) || Number(env.COLUMNS) || process.stdout.columns || 80;
@@ -328,7 +401,7 @@ function ptyStart({ name, cwd, bin, args, stripEnv, env, spawner = spawn, runner
328
401
  // that a session which finishes on its own leaves proof it finished.
329
402
  const command = [
330
403
  `stty rows ${rows} cols ${cols} 2>/dev/null`,
331
- sessionCommand({ bin, args, stripEnv, exec: false }),
404
+ sessionCommand({ bin, args, stripEnv, setEnv: sessionEnv(name), exec: false }),
332
405
  `printf '%s' "$?" > ${shQuote(exit)}`,
333
406
  ].join("; ");
334
407
  // Reuse ptySpec's flag knowledge rather than re-deriving it: util-linux and
@@ -347,7 +420,7 @@ function ptyStart({ name, cwd, bin, args, stripEnv, env, spawner = spawn, runner
347
420
  cwd,
348
421
  // Belt and braces with the stty above: some toolkits read COLUMNS/LINES
349
422
  // before they ever ask the terminal.
350
- env: { ...env, COLUMNS: String(cols), LINES: String(rows), MOSHCODE_HERD_SESSION: name },
423
+ env: { ...env, COLUMNS: String(cols), LINES: String(rows), MOSHCODE_HERD_SESSION: name, ...sessionEnv(name) },
351
424
  stdio: [stdin, "ignore", "ignore"],
352
425
  detached: true,
353
426
  });
@@ -565,7 +638,7 @@ export function startSession({
565
638
  };
566
639
 
567
640
  if (substrate === "tmux") {
568
- const command = sessionCommand({ bin, args, stripEnv });
641
+ const command = sessionCommand({ bin, args, stripEnv, setEnv: sessionEnv(name) });
569
642
  const started = tmux(tmuxStartPlan({ name, cwd, command }), { runner, env });
570
643
  if (!started.ok) {
571
644
  return { ok: false, error: new Error(started.stderr.trim() || started.error?.message || "tmux could not start the session") };
@@ -802,12 +875,21 @@ export function listSessions({ substrate = detectSubstrate(), runner = spawnSync
802
875
  const names = [...new Set([...live, ...Object.keys(manifest.sessions)])].sort();
803
876
  return names.map((name) => {
804
877
  const meta = manifest.sessions[name] || {};
805
- const alive = live.has(name);
806
- const exited = !alive ? null
878
+ // A remote member (PRD 0011 R11) has no pane and no pid — it is a URL. The
879
+ // substrate can only ever report it as absent, so liveness for those rows
880
+ // means "still registered", and whether the far end answers is a question
881
+ // for herd-remote.mjs's cache rather than for tmux.
882
+ const remote = meta.kind === "remote";
883
+ const alive = remote ? true : live.has(name);
884
+ const exited = remote ? false
885
+ : !alive ? null
807
886
  : panes ? Boolean(panes.get(name)?.dead)
808
887
  : sessionExited(name, { substrate, runner });
809
888
  return {
810
889
  name,
890
+ kind: meta.kind || "local",
891
+ url: meta.url || null,
892
+ remoteKind: meta.remoteKind || null,
811
893
  engine: meta.engine || "?",
812
894
  // Sessions started before herds existed have none. They belong to `main`
813
895
  // rather than to a group rendered as "undefined".
package/src/templates.mjs CHANGED
@@ -41,6 +41,23 @@ function isOwnManifest(relative) {
41
41
 
42
42
  /* ------------------------------------------------------------------ listing */
43
43
 
44
+ /**
45
+ * Template collections worth knowing about that this repo does not ship
46
+ * (PRD 0011 R15).
47
+ *
48
+ * A pointer rather than a copy, deliberately. `template install` already takes
49
+ * an `owner/repo`, so vendoring somebody else's templates would mean carrying
50
+ * their updates by hand forever — and pinning a stale copy of an SDK's starter
51
+ * kit is worse than not having one. What is missing is only that nobody knows
52
+ * the name to type, so the listing says it.
53
+ */
54
+ export const TEMPLATE_POINTERS = [
55
+ {
56
+ spec: "digitalocean/gradient-adk-templates",
57
+ description: "DigitalOcean Gradient ADK — agent starters (A2A-capable; pairs with `moshcode install gradient`)",
58
+ },
59
+ ];
60
+
44
61
  /** The bundled templates, each with whatever its manifest says about it. */
45
62
  export async function listTemplates(dir = BUNDLED_DIR) {
46
63
  let entries;
@@ -347,19 +364,29 @@ export async function templateCommand(
347
364
  }
348
365
  const templates = await listTemplates();
349
366
  if (rest.includes("--json")) {
367
+ // The bundled list stays an array, because that is what it has always
368
+ // been and something is parsing it. Pointers are named separately rather
369
+ // than mixed in: `template install <name>` works for one and not the
370
+ // other, and a listing that hid that difference would be a listing that
371
+ // lies about what it can do.
350
372
  out(JSON.stringify(templates, null, 2));
351
373
  return 0;
352
374
  }
353
375
  if (!templates.length) {
354
376
  out("no templates bundled with this install");
355
- return 0;
377
+ } else {
378
+ const width = Math.max(...templates.map((t) => t.name.length));
379
+ for (const { name, description } of templates) {
380
+ out(` ${name.padEnd(width)} ${description}`);
381
+ }
356
382
  }
357
- const width = Math.max(...templates.map((t) => t.name.length));
358
- for (const { name, description } of templates) {
359
- out(` ${name.padEnd(width)} ${description}`);
383
+ out("");
384
+ out("elsewhere:");
385
+ for (const { spec, description } of TEMPLATE_POINTERS) {
386
+ out(` ${spec} ${description}`);
360
387
  }
361
388
  out("");
362
- out("install one with: moshcode template install <name>");
389
+ out("install one with: moshcode template install <name|owner/repo|url>");
363
390
  return 0;
364
391
  }
365
392
 
package/src/tools.mjs CHANGED
@@ -160,6 +160,49 @@ export const TOOLS = {
160
160
  },
161
161
  installHelp: "Go is required to install Alpaca; install Go, then retry `moshcode install alpaca`.",
162
162
  },
163
+ gradient: {
164
+ desc: "DigitalOcean Gradient ADK — build, run, deploy and evaluate agents (A2A-capable)",
165
+ bin: "gradient",
166
+ // The one tool here that is not a self-contained binary. gradient-adk is a
167
+ // Python package, and moshcode stays Node: the tool owns its runtime, the
168
+ // same way CoinPay owns Node 20. So the install spec checks for a Python
169
+ // the package can actually run on and NAMES the requirement when it is
170
+ // missing, rather than letting pip fail three screens later with a
171
+ // resolution error nobody reads. `--user` keeps it out of the system
172
+ // site-packages, which is also the only place a non-root install can go on
173
+ // a modern distro.
174
+ install: {
175
+ cmd: "sh",
176
+ args: [
177
+ "-c",
178
+ 'python3 -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)" 2>/dev/null '
179
+ + '|| { echo "gradient-adk needs Python 3.10 or newer on PATH as python3 — install it, then re-run: moshcode install gradient" >&2; exit 1; }; '
180
+ + "python3 -m pip install --user --upgrade gradient-adk",
181
+ ],
182
+ },
183
+ installHelp: "gradient-adk is a Python package: it needs python3 (3.10+) and pip. moshcode does not install Python for you.",
184
+ // pip --user drops console scripts here, and appends nothing to PATH for
185
+ // the shell that ran the install — the same gap turso and kimi have.
186
+ binDirs: [path.join(homedir(), ".local", "bin")],
187
+ // How the ADK's dev server reads in the herd (PRD 0011 R15). `gradient
188
+ // agent run --dev` is uvicorn underneath, and its startup banner is a clear
189
+ // "I am up and waiting", which is `idle`.
190
+ //
191
+ // There is deliberately no `working` rule. uvicorn writes its access line
192
+ // when a request has FINISHED, so a screen showing one is a screen showing
193
+ // a server that is free again — a rule matching it would pin the tile to
194
+ // `working` from the first request until the line scrolled away, which is
195
+ // the exact kind of rot the sub-kinds and hooks exist to get away from. So
196
+ // the completed request counts as idle too, and it is right both times.
197
+ // Watching a *deployed* agent's state is what `herd remote add` is for.
198
+ state: {
199
+ idle: [
200
+ /\buvicorn running on\b/i,
201
+ /\bapplication startup complete\b/i,
202
+ /"(?:POST|GET|PUT) \/[^"]*" \d{3}\b/,
203
+ ],
204
+ },
205
+ },
163
206
  mcpjam: {
164
207
  desc: "MCPJam — test, debug, and validate MCP servers (health, OAuth, tool-surface diffs)",
165
208
  bin: "mcpjam",
package/src/tui.mjs CHANGED
@@ -833,6 +833,7 @@ export async function tui() {
833
833
  // sessions run somewhere that outlives it.
834
834
  if (cmd === "herd") { await herdCommand(rest); continue; }
835
835
  if (cmd === "ps") { await herdCommand(["ps", ...rest]); continue; }
836
+ if (cmd === "cost" || cmd === "usage") { await herdCommand(["cost", ...rest]); continue; }
836
837
  if (cmd === "kill") { await herdCommand(["kill", ...rest]); continue; }
837
838
  if (cmd === "wait") { await herdCommand(["wait", ...rest]); continue; }
838
839
  if (cmd === "restore") { await herdCommand(["restore", ...rest]); continue; }