moshcode 0.59.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/README.md +121 -7
- package/bin/moshcode.mjs +2 -2
- package/package.json +1 -1
- package/prd/0011-herd-agent-protocol.md +391 -0
- package/prd/README.md +1 -0
- package/src/cli-schema.mjs +100 -6
- package/src/commands.mjs +84 -10
- package/src/cost.mjs +121 -2
- package/src/engines.mjs +32 -0
- package/src/herd-cli.mjs +812 -20
- package/src/herd-eval.mjs +301 -0
- package/src/herd-hooks.mjs +285 -0
- package/src/herd-remote.mjs +365 -0
- package/src/herd-serve.mjs +515 -0
- package/src/herd-state.mjs +167 -10
- package/src/herd-tasks.mjs +377 -0
- package/src/herd.mjs +89 -7
- package/src/templates.mjs +32 -5
- package/src/tools.mjs +43 -0
- package/src/tui.mjs +1 -1
package/src/cli-schema.mjs
CHANGED
|
@@ -95,6 +95,11 @@ export const CORE_CLI_COMMANDS = [
|
|
|
95
95
|
["# driving one without attaching", ""],
|
|
96
96
|
["moshcode herd prompt api \"run the tests\" --wait", "hand it work, block until it lands"],
|
|
97
97
|
["moshcode herd read api --lines 40", "read its screen"],
|
|
98
|
+
["", ""],
|
|
99
|
+
["# what happened while you slept", ""],
|
|
100
|
+
["moshcode herd hooks install claude", "state from the engine, not from its screen"],
|
|
101
|
+
["moshcode herd tasks api", "every prompt, and how long each one waited on you"],
|
|
102
|
+
["moshcode herd remote add research https://agents.do-ai.run/…/production", "a deployed agent, same verbs"],
|
|
98
103
|
],
|
|
99
104
|
seeAlso: ["ps", "attach", "wait", "restore", "start"],
|
|
100
105
|
note: "`start` is for the engines moshcode installs; `run` and `shell` take anything else, "
|
|
@@ -134,9 +139,12 @@ export const CORE_CLI_COMMANDS = [
|
|
|
134
139
|
+ "codex's rollout token counts, opencode's per-message cost, aider's chat history. a figure marked "
|
|
135
140
|
+ "`~` was worked out from published rates and is what the tokens WOULD cost on the api; unmarked "
|
|
136
141
|
+ "figures are the engine's own arithmetic. models with no rate show tokens and no cost — add yours "
|
|
137
|
-
+ "to ~/.moshcode/pricing.json. gemini, kimi,
|
|
142
|
+
+ "to ~/.moshcode/pricing.json. gemini, kimi, deepseek and openagents log nothing readable, "
|
|
138
143
|
+ "so they report no cost rather than zero.",
|
|
139
144
|
},
|
|
145
|
+
// Everyone who has used a coding agent's own `/usage` types that word first,
|
|
146
|
+
// and tokens-and-spend is exactly what `cost` already answers.
|
|
147
|
+
{ name: "usage", aliasOf: "cost", description: "alias for cost" },
|
|
140
148
|
{
|
|
141
149
|
name: "attach",
|
|
142
150
|
group: "runtime",
|
|
@@ -165,15 +173,25 @@ export const CORE_CLI_COMMANDS = [
|
|
|
165
173
|
name: "wait",
|
|
166
174
|
group: "runtime",
|
|
167
175
|
description: "block until a session is blocked, done, or idle",
|
|
168
|
-
synopsis: [
|
|
176
|
+
synopsis: [
|
|
177
|
+
["moshcode wait <name> [--state blocked,done] [--timeout 30m]", ""],
|
|
178
|
+
["moshcode wait --any <a> <b> …", "the first one there wins"],
|
|
179
|
+
["moshcode wait --all <a> <b> …", "join the whole fan-out"],
|
|
180
|
+
],
|
|
169
181
|
flags: [
|
|
170
182
|
["--state <list>", "states to wait for, comma-separated", "blocked,done"],
|
|
183
|
+
["--any", "return as soon as one of them reaches the state", ""],
|
|
184
|
+
["--all", "return when every one of them has", "implied by naming several"],
|
|
171
185
|
["--timeout <dur>", "give up after this long (30s, 10m, 2h)", "30m"],
|
|
172
186
|
["--json", "machine-readable", ""],
|
|
173
187
|
],
|
|
174
|
-
examples: [
|
|
188
|
+
examples: [
|
|
189
|
+
["moshcode wait api --state blocked --timeout 1h", "exit 0 matched · 2 timed out · 3 gone"],
|
|
190
|
+
["moshcode wait --any api web docs", "--json names the winner"],
|
|
191
|
+
],
|
|
175
192
|
seeAlso: ["herd", "ps"],
|
|
176
|
-
note: "exit codes are the point: 0 matched, 2 timed out, 3 no such session."
|
|
193
|
+
note: "exit codes are the point: 0 matched, 2 timed out, 3 no such session. "
|
|
194
|
+
+ "--any/--all are what fan-in scripts used to spell out as a polling loop; a remote member waits the same way a local one does.",
|
|
177
195
|
},
|
|
178
196
|
{
|
|
179
197
|
name: "restore",
|
|
@@ -963,11 +981,21 @@ export const HERD_VERBS = [
|
|
|
963
981
|
{ name: "send-keys", description: "send raw keys (Enter, Escape, C-c, literal text)",
|
|
964
982
|
synopsis: [["moshcode herd send-keys <name> <keys…>", ""]] },
|
|
965
983
|
{ name: "wait", description: "block until a session reaches a state",
|
|
966
|
-
synopsis: [
|
|
984
|
+
synopsis: [
|
|
985
|
+
["moshcode herd wait <name> [--state blocked,done]", ""],
|
|
986
|
+
["moshcode herd wait --any <a> <b> …", "returns on the first one to get there"],
|
|
987
|
+
["moshcode herd wait --all <a> <b> …", "returns when every one of them has"],
|
|
988
|
+
],
|
|
967
989
|
flags: [
|
|
968
990
|
["--state <list>", "states to wait for", "blocked,done"],
|
|
991
|
+
["--any", "return on the first session to reach the state", ""],
|
|
992
|
+
["--all", "return when every named session has", "implied by naming several"],
|
|
969
993
|
["--timeout <dur>", "give up after this long", "30m"],
|
|
970
994
|
["--json", "machine-readable", ""],
|
|
995
|
+
],
|
|
996
|
+
examples: [
|
|
997
|
+
["moshcode wait --any api web docs", "whichever finishes first"],
|
|
998
|
+
["moshcode wait --all api web --state done", "join the whole fan-out"],
|
|
971
999
|
] },
|
|
972
1000
|
{ name: "restore", description: "rebuild remembered sessions after a reboot",
|
|
973
1001
|
synopsis: [["moshcode herd restore [--resume] [--dry-run]", ""]],
|
|
@@ -988,6 +1016,72 @@ export const HERD_VERBS = [
|
|
|
988
1016
|
{ name: "stop", description: "stop the whole runtime and everything in it",
|
|
989
1017
|
synopsis: [["moshcode herd stop --yes", ""]],
|
|
990
1018
|
flags: [["--yes, -y", "required when sessions are running", ""]] },
|
|
1019
|
+
|
|
1020
|
+
// PRD 0011 — the engine speaks, the herd remembers, and the roster reaches
|
|
1021
|
+
// past this box.
|
|
1022
|
+
{ name: "hooks", description: "install the engine's own lifecycle hooks, so state comes from it and not from its screen",
|
|
1023
|
+
synopsis: [["moshcode herd hooks <install|remove|status> [<engine>|all]", ""]],
|
|
1024
|
+
flags: [
|
|
1025
|
+
["--dry-run", "print the change to the engine's settings file and write nothing", ""],
|
|
1026
|
+
["--json", "machine-readable", ""],
|
|
1027
|
+
],
|
|
1028
|
+
examples: [
|
|
1029
|
+
["moshcode herd hooks install claude", "3 hooks: stop, notification, prompt-submit"],
|
|
1030
|
+
["moshcode herd hooks status", "per-engine, and which events are current"],
|
|
1031
|
+
],
|
|
1032
|
+
note: "the file is merged, never clobbered, and remove takes out only what moshcode put in. "
|
|
1033
|
+
+ "a hook fired outside a herd session does nothing and exits 0, so installing one cannot break an engine you run by hand. "
|
|
1034
|
+
+ "screen rules stay as the fallback." },
|
|
1035
|
+
{ name: "doctor", description: "check the things that actually go wrong: substrate, manifest drift, stale reports, rules.json",
|
|
1036
|
+
synopsis: [["moshcode herd doctor [--json]", ""]],
|
|
1037
|
+
flags: [["--json", "machine-readable, for provisioning scripts", ""]],
|
|
1038
|
+
note: "a broken ~/.moshcode/herd/rules.json is ignored silently everywhere else by design — this is where it gets to be loud." },
|
|
1039
|
+
{ name: "tasks", description: "every prompt submitted to a session, and what came of it",
|
|
1040
|
+
synopsis: [["moshcode herd tasks <session> [--json]", ""]],
|
|
1041
|
+
flags: [["--json", "machine-readable", ""]] },
|
|
1042
|
+
{ name: "task", description: "one task: its state transitions and its output",
|
|
1043
|
+
synopsis: [["moshcode herd task <id> [--json]", ""]],
|
|
1044
|
+
flags: [["--json", "machine-readable", ""]] },
|
|
1045
|
+
{ name: "log", description: "the timestamped state history of a session",
|
|
1046
|
+
synopsis: [["moshcode herd log <session> [--json]", ""]],
|
|
1047
|
+
flags: [["--json", "machine-readable", ""]] },
|
|
1048
|
+
{ name: "stats", description: "time in state, including how long things sat blocked waiting on you",
|
|
1049
|
+
synopsis: [["moshcode herd stats [session] [--json]", ""]],
|
|
1050
|
+
flags: [["--json", "machine-readable", ""]] },
|
|
1051
|
+
{ name: "remote", description: "put a deployed agent on the roster — A2A, or a bare POST endpoint",
|
|
1052
|
+
synopsis: [["moshcode herd remote <list|add|remove|ping|card> [args…]", ""]],
|
|
1053
|
+
flags: [
|
|
1054
|
+
["--kind <a2a|run>", "A2A JSON-RPC, or a plain POST {prompt}", "run"],
|
|
1055
|
+
["--json", "machine-readable", ""],
|
|
1056
|
+
],
|
|
1057
|
+
examples: [
|
|
1058
|
+
["moshcode herd remote add research https://agents.do-ai.run/…/production --kind run", ""],
|
|
1059
|
+
["moshcode herd prompt research \"summarise the week\"", "the same verb as a local session"],
|
|
1060
|
+
],
|
|
1061
|
+
note: "auth comes from MOSHCODE_REMOTE_<NAME>_TOKEN in the environment — never written to the manifest, never synced. "
|
|
1062
|
+
+ "a remote's state is the remote's claim, and `ps` says `remote` in the from column so nobody mistakes it for something this box verified." },
|
|
1063
|
+
{ name: "serve", description: "expose the herd over A2A v0.3.0, behind your moshcode login",
|
|
1064
|
+
synopsis: [["moshcode herd serve [--port 7683] [--bind 127.0.0.1]", ""]],
|
|
1065
|
+
flags: [
|
|
1066
|
+
["--port <n>", "port to listen on", "7683"],
|
|
1067
|
+
["--bind <addr>", "interface to bind", "127.0.0.1"],
|
|
1068
|
+
["--expose-autonomous", "also serve sessions started with --agent", "off"],
|
|
1069
|
+
],
|
|
1070
|
+
note: "message/send is keystrokes into a real pty. there is no unauthenticated mode, loopback included, and sessions "
|
|
1071
|
+
+ "started with --agent are withheld unless you ask for them: an engine with approvals bypassed plus a network prompt "
|
|
1072
|
+
+ "is the worst pairing on the menu." },
|
|
1073
|
+
{ name: "eval", description: "run a dataset through several engines and score them",
|
|
1074
|
+
synopsis: [["moshcode herd eval --dataset <file> --engines a,b [--judge <engine>|rules]", ""]],
|
|
1075
|
+
flags: [
|
|
1076
|
+
["--dataset <file>", "jsonl, json or csv of { prompt, expect | rubric }", ""],
|
|
1077
|
+
["--engines <list>", "engines to compare", ""],
|
|
1078
|
+
["--judge <engine>|rules", "score with an engine, or with the dataset's own patterns", "rules"],
|
|
1079
|
+
["--threshold <0-1>", "the score every engine has to reach", "0.8"],
|
|
1080
|
+
["--keep", "leave the eval sessions running afterwards", ""],
|
|
1081
|
+
["--json", "machine-readable", ""],
|
|
1082
|
+
],
|
|
1083
|
+
note: "exit codes are distinct on purpose: 0 pass, 4 below the threshold, 5 the harness could not run — "
|
|
1084
|
+
+ "CI has to tell a worse agent from a broken box." },
|
|
991
1085
|
];
|
|
992
1086
|
|
|
993
1087
|
export const VERB_TABLES = {
|
|
@@ -1027,7 +1121,7 @@ export const PIT_COMMANDS = [
|
|
|
1027
1121
|
description: "sessions that keep running when you leave" },
|
|
1028
1122
|
{ name: "ps", cli: "ps",
|
|
1029
1123
|
description: "what the herd is running, and which one wants you" },
|
|
1030
|
-
{ name: "cost", args: "[name] [--all]", cli: "cost",
|
|
1124
|
+
{ name: "cost", aliases: ["usage"], args: "[name] [--all]", cli: "cost",
|
|
1031
1125
|
description: "what the herd is spending, from the engines' own logs" },
|
|
1032
1126
|
{ name: "attach", args: "<name>", cli: "attach",
|
|
1033
1127
|
description: "step into a herd session (detach leaves it running)" },
|
package/src/commands.mjs
CHANGED
|
@@ -18,8 +18,9 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
18
18
|
import { createRegistry } from "./registry.mjs";
|
|
19
19
|
import { cliVerb, aiVerb, runMoshcode } from "./cli.mjs";
|
|
20
20
|
import { ingestApproval, pollApproval } from "./notify.mjs";
|
|
21
|
-
import { capture, killSession, sendPrompt } from "./herd.mjs";
|
|
22
|
-
import { herdStart, roster,
|
|
21
|
+
import { capture, killSession, remoteStatus, sendPrompt } from "./herd.mjs";
|
|
22
|
+
import { herdStart, isRemoteMember, roster, waitForMany, waitMember } from "./herd-cli.mjs";
|
|
23
|
+
import { endTask, findTask, readTasks, startTask } from "./herd-tasks.mjs";
|
|
23
24
|
import { shellInvocation } from "./shell.mjs";
|
|
24
25
|
import { identity, loginAuto, logout as forgetCreds } from "./auth.mjs";
|
|
25
26
|
import { expandAlias, getAlias, loadAliases, removeAlias, setAlias } from "./aliases.mjs";
|
|
@@ -511,29 +512,62 @@ const COMMANDS = [
|
|
|
511
512
|
},
|
|
512
513
|
{
|
|
513
514
|
name: "herdPrompt",
|
|
514
|
-
summary: "type a prompt into a herd session",
|
|
515
|
+
summary: "type a prompt into a herd session (local or remote)",
|
|
515
516
|
usage: "herdPrompt(name, text)",
|
|
516
|
-
detail: "returns { ok }; does not wait — use herdWait() to join",
|
|
517
|
+
detail: "returns { ok, task }; does not wait — use herdWait() to join",
|
|
517
518
|
run(ctx, name, ...words) {
|
|
518
519
|
const text = words.join(" ");
|
|
519
520
|
if (!name || !text) throw new Error("moshscript: herdPrompt(name, text) requires both");
|
|
520
521
|
if (ctx.dryRun) { ctx.out(` 💬 herdPrompt(${name}) → would send: ${text}`); return { ok: true, dryRun: true }; }
|
|
521
522
|
ctx.out(` 💬 herdPrompt(${name}) → ${text.slice(0, 60)}${text.length > 60 ? "…" : ""}`);
|
|
522
|
-
const
|
|
523
|
-
|
|
523
|
+
const session = String(name);
|
|
524
|
+
|
|
525
|
+
// A remote member takes the same call with the same arguments — the point
|
|
526
|
+
// of PRD 0011 R12 is that a script fanning across a local pty and a
|
|
527
|
+
// deployed agent contains no `if (remote)`. This one stays synchronous
|
|
528
|
+
// like every other local verb, so the no-`await` style keeps working: the
|
|
529
|
+
// request is in flight when it returns, and herdWait() is how a script
|
|
530
|
+
// joins on it, exactly as for a local session.
|
|
531
|
+
if (isRemoteMember(session)) {
|
|
532
|
+
const task = startTask(session, text, { screen: "" });
|
|
533
|
+
import("./herd-remote.mjs")
|
|
534
|
+
.then((remote) => remote.promptRemote(session, text)
|
|
535
|
+
.then((sent) => endTask(session, task, { state: sent.state || "done", artifact: sent.artifact || String(sent.error?.message || "") })))
|
|
536
|
+
.catch(() => endTask(session, task, { state: "done", artifact: "the request never left this machine" }));
|
|
537
|
+
return { ok: true, task, remote: true };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const task = startTask(session, text, { screen: capture(session, { lines: 60 }) });
|
|
541
|
+
const sent = sendPrompt(session, text);
|
|
542
|
+
if (!sent.ok) endTask(session, task, { state: "done", artifact: String(sent.error?.message || sent.error) });
|
|
543
|
+
return { ok: Boolean(sent.ok), task };
|
|
524
544
|
},
|
|
525
545
|
},
|
|
526
546
|
{
|
|
527
547
|
name: "herdWait",
|
|
528
548
|
summary: "BLOCK until a herd session is blocked, done, or idle",
|
|
529
|
-
usage: "herdWait(name, { states, timeout })",
|
|
530
|
-
detail: "returns the state it reached. needs await",
|
|
549
|
+
usage: "herdWait(name | [names], { states, timeout, any })",
|
|
550
|
+
detail: "one name returns the state it reached; a list returns the winner's name (any) or every result (all). needs await",
|
|
531
551
|
async run(ctx, name, opts = {}) {
|
|
532
552
|
if (!name) throw new Error("moshscript: herdWait(name) requires a session name");
|
|
533
553
|
const states = opts.states || ["blocked", "done", "idle"];
|
|
554
|
+
const timeout = opts.timeout ? { timeoutMs: Number(opts.timeout) } : {};
|
|
555
|
+
|
|
556
|
+
// A list of names is a join (PRD 0011 R8) — the thing every fan-out
|
|
557
|
+
// script so far has spelled out by hand as a polling loop.
|
|
558
|
+
if (Array.isArray(name)) {
|
|
559
|
+
const names = name.map(String);
|
|
560
|
+
const mode = opts.any ? "any" : "all";
|
|
561
|
+
if (ctx.dryRun) { ctx.out(` ⏳ herdWait([${names.join(", ")}]) → would wait for ${mode} of them to reach ${states.join("/")}`); return mode === "any" ? names[0] : names.map((n) => ({ name: n, state: "idle" })); }
|
|
562
|
+
ctx.out(` ⏳ herdWait([${names.join(", ")}]) → waiting for ${mode}…`);
|
|
563
|
+
const result = await waitForMany(names, states, { mode, ...timeout });
|
|
564
|
+
ctx.out(` ${result.outcome === "matched" ? "✅" : "⌛"} ${mode === "any" ? `${result.winner} first` : `${result.outcome}`}`);
|
|
565
|
+
return mode === "any" ? result.winner : result.results;
|
|
566
|
+
}
|
|
567
|
+
|
|
534
568
|
if (ctx.dryRun) { ctx.out(` ⏳ herdWait(${name}) → would wait for ${states.join("/")}`); return "idle"; }
|
|
535
569
|
ctx.out(` ⏳ herdWait(${name}) → waiting for ${states.join("/")}…`);
|
|
536
|
-
const result = await
|
|
570
|
+
const result = await waitMember(String(name), states, timeout);
|
|
537
571
|
ctx.out(` ${result.outcome === "matched" ? "✅" : "⌛"} ${name} is ${result.state}`);
|
|
538
572
|
return result.state;
|
|
539
573
|
},
|
|
@@ -546,7 +580,11 @@ const COMMANDS = [
|
|
|
546
580
|
run(ctx, name, opts = {}) {
|
|
547
581
|
if (!name) throw new Error("moshscript: herdRead(name) requires a session name");
|
|
548
582
|
if (ctx.dryRun) { ctx.out(` 📖 herdRead(${name}) → would read its screen`); return ""; }
|
|
549
|
-
|
|
583
|
+
const session = String(name);
|
|
584
|
+
// A remote has no screen; what it has is the last thing it said, and
|
|
585
|
+
// that is what `read` means for it (PRD 0011 R12).
|
|
586
|
+
if (isRemoteMember(session)) return String(remoteStatus(session)?.artifact || "");
|
|
587
|
+
return capture(session, { lines: Number(opts.lines) || 60 });
|
|
550
588
|
},
|
|
551
589
|
},
|
|
552
590
|
{
|
|
@@ -572,6 +610,41 @@ const COMMANDS = [
|
|
|
572
610
|
},
|
|
573
611
|
},
|
|
574
612
|
|
|
613
|
+
// The ledger (PRD 0011 R6). Same contract as herdRead/herdList and for the
|
|
614
|
+
// same reason: a script fans work out and then has to read what came back.
|
|
615
|
+
// `[]`/`null` on anything missing, never a throw — a script joining on four
|
|
616
|
+
// agents must not die because one of them has no history yet.
|
|
617
|
+
//
|
|
618
|
+
// herdPrompt("api", "port the auth routes");
|
|
619
|
+
// await herdWait("api");
|
|
620
|
+
// const [last] = herdTasks("api").slice(-1);
|
|
621
|
+
// say(herdTask(last.id).artifact);
|
|
622
|
+
{
|
|
623
|
+
name: "herdTasks",
|
|
624
|
+
summary: "every prompt submitted to a session, and what came of it",
|
|
625
|
+
usage: "herdTasks(name)",
|
|
626
|
+
detail: "returns [{ id, text, state, status, submitted, durationMs }, …], oldest first",
|
|
627
|
+
run(ctx, name) {
|
|
628
|
+
if (!name) throw new Error("moshscript: herdTasks(name) requires a session name");
|
|
629
|
+
if (ctx.dryRun) { ctx.out(` 📒 herdTasks(${name}) → would read the ledger`); return []; }
|
|
630
|
+
try {
|
|
631
|
+
return readTasks(String(name)).map(({ id, text, state, status, submitted, endedAt, durationMs }) =>
|
|
632
|
+
({ id, text, state, status, submitted, endedAt, durationMs }));
|
|
633
|
+
} catch { return []; }
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
name: "herdTask",
|
|
638
|
+
summary: "one task by id — its transitions and its output",
|
|
639
|
+
usage: "herdTask(id)",
|
|
640
|
+
detail: "returns { id, session, text, transitions, artifact, state } or null",
|
|
641
|
+
run(ctx, id) {
|
|
642
|
+
if (!id) throw new Error("moshscript: herdTask(id) requires a task id");
|
|
643
|
+
if (ctx.dryRun) { ctx.out(` 📒 herdTask(${id}) → would read the ledger`); return null; }
|
|
644
|
+
try { return findTask(String(id)); } catch { return null; }
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
|
|
575
648
|
// CLI verbs — each is `moshcode <name> ...args`. This is the whole point:
|
|
576
649
|
// scripting the CLI. Add a capability by adding a line here.
|
|
577
650
|
//
|
|
@@ -601,6 +674,7 @@ const COMMANDS = [
|
|
|
601
674
|
cliVerb("supabase", "drive the Supabase CLI (local stack, migrations, functions)"),
|
|
602
675
|
cliVerb("doppler", "drive the Doppler CLI (secrets, env injection)"),
|
|
603
676
|
cliVerb("doctl", "drive the DigitalOcean CLI (droplets, apps, databases)"),
|
|
677
|
+
cliVerb("gradient", "drive the DigitalOcean Gradient ADK (init, run, deploy, logs, evaluate)"),
|
|
604
678
|
cliVerb("turso", "drive the Turso CLI (auth, databases, replicas)"),
|
|
605
679
|
cliVerb("tailscale", "drive the Tailscale CLI (mesh VPN: up, status, ssh, serve)"),
|
|
606
680
|
cliVerb("coral", "drive the Coral CLI (SQL over APIs, databases, and internal systems)"),
|
package/src/cost.mjs
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Every engine moshcode wraps already writes down what it used — Claude Code
|
|
4
4
|
// keeps a per-message `usage` block in ~/.claude/projects/**/<session>.jsonl,
|
|
5
|
-
// Codex emits cumulative `token_count` events into ~/.codex/sessions/…,
|
|
5
|
+
// Codex emits cumulative `token_count` events into ~/.codex/sessions/…, qwen
|
|
6
|
+
// appends a record per request to ~/.qwen/usage/token-usage-YYYY-MM.jsonl, and
|
|
6
7
|
// opencode stores a per-message `cost` it computed itself in SQLite. Nobody has
|
|
7
8
|
// to be instrumented and nothing has to be proxied: the numbers are on disk
|
|
8
9
|
// because the CLI put them there. This module reads them, normalises them into
|
|
@@ -408,6 +409,123 @@ async function opencodeRuns(engine, { since, cwd } = {}) {
|
|
|
408
409
|
return [...bySession.values()];
|
|
409
410
|
}
|
|
410
411
|
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// qwen — ~/.qwen/usage/token-usage-YYYY-MM.jsonl, one record per request
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
const qwenUsageDir = () => path.join(home(), ".qwen", "usage");
|
|
417
|
+
const qwenProjectsDir = () => path.join(home(), ".qwen", "projects");
|
|
418
|
+
|
|
419
|
+
/** Only the monthly usage logs; the directory also holds unrelated state. */
|
|
420
|
+
const QWEN_USAGE_FILE = /^token-usage-\d{4}-\d{2}\.jsonl$/;
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* sessionId → the directory qwen was started in.
|
|
424
|
+
*
|
|
425
|
+
* The usage log records no path, so on its own it can say what was spent but
|
|
426
|
+
* not where. qwen writes a tiny `<sessionId>.runtime.json` beside each chat
|
|
427
|
+
* with the `work_dir` in it, and that is the only exact answer on disk — the
|
|
428
|
+
* project directory those chats sit in is a lossy dash-slug of the same path,
|
|
429
|
+
* so it can group sessions but cannot be turned back into a directory.
|
|
430
|
+
*/
|
|
431
|
+
function qwenSessionDirs() {
|
|
432
|
+
const dirs = new Map();
|
|
433
|
+
const root = qwenProjectsDir();
|
|
434
|
+
for (const project of listDir(root)) {
|
|
435
|
+
if (!project.isDirectory()) continue;
|
|
436
|
+
const chats = path.join(root, project.name, "chats");
|
|
437
|
+
for (const entry of listDir(chats)) {
|
|
438
|
+
if (!entry.isFile() || !entry.name.endsWith(".runtime.json")) continue;
|
|
439
|
+
let meta;
|
|
440
|
+
try { meta = parseJson(fs.readFileSync(path.join(chats, entry.name), "utf8")); }
|
|
441
|
+
catch { continue; }
|
|
442
|
+
if (meta?.session_id && meta.work_dir) dirs.set(String(meta.session_id), String(meta.work_dir));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return dirs;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* One usage record → the shape every engine normalises into.
|
|
450
|
+
*
|
|
451
|
+
* `inputTokens` is the whole prompt and `cachedTokens` is the part of it that
|
|
452
|
+
* was a cache hit, so the fresh input is the difference — counting both would
|
|
453
|
+
* bill the cache twice at the full input rate.
|
|
454
|
+
*
|
|
455
|
+
* `thoughtsTokens` is the subtle one. On the OpenAI-compatible path — which is
|
|
456
|
+
* what `authType: "openai"` marks, and what qwen's own DashScope endpoint uses
|
|
457
|
+
* — `outputTokens` is `completion_tokens`, which already *contains* the
|
|
458
|
+
* reasoning tokens, and adding them would double-count the thinking. The native
|
|
459
|
+
* path reports Gemini's `candidatesTokenCount`, which excludes them, so that is
|
|
460
|
+
* the only case where they have to be added back.
|
|
461
|
+
*/
|
|
462
|
+
function qwenUsageOf(record) {
|
|
463
|
+
const cached = num(record.cachedTokens);
|
|
464
|
+
const thoughts = num(record.thoughtsTokens);
|
|
465
|
+
return {
|
|
466
|
+
input: Math.max(0, num(record.inputTokens) - cached),
|
|
467
|
+
output: num(record.outputTokens) + (record.authType === "openai" ? 0 : thoughts),
|
|
468
|
+
cacheRead: cached,
|
|
469
|
+
cacheWrite5m: 0,
|
|
470
|
+
cacheWrite1h: 0,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Every qwen session in the window, one run per session.
|
|
476
|
+
*
|
|
477
|
+
* The log is per-request and flat, so the session id is what makes a run: a
|
|
478
|
+
* subagent's requests carry the same one (`source` names the subagent) and
|
|
479
|
+
* belong to the session that spawned them.
|
|
480
|
+
*/
|
|
481
|
+
export function qwenRuns({ since, cwd } = {}) {
|
|
482
|
+
const dirs = qwenSessionDirs();
|
|
483
|
+
const sessions = new Map();
|
|
484
|
+
|
|
485
|
+
for (const entry of listDir(qwenUsageDir())) {
|
|
486
|
+
if (!entry.isFile() || !QWEN_USAGE_FILE.test(entry.name)) continue;
|
|
487
|
+
const file = path.join(qwenUsageDir(), entry.name);
|
|
488
|
+
const stat = safeStat(file);
|
|
489
|
+
// A month untouched since before the window holds nothing inside it.
|
|
490
|
+
if (!stat || (since != null && stat.mtimeMs < since)) continue;
|
|
491
|
+
|
|
492
|
+
let text;
|
|
493
|
+
try { text = fs.readFileSync(file, "utf8"); } catch { continue; }
|
|
494
|
+
for (const line of text.split("\n")) {
|
|
495
|
+
if (!line || line.charCodeAt(0) !== 123) continue; // fast reject: not "{"
|
|
496
|
+
const record = parseJson(line);
|
|
497
|
+
if (!record?.sessionId) continue;
|
|
498
|
+
const at = stamp(record.timestamp);
|
|
499
|
+
if (at != null && since != null && at < since) continue;
|
|
500
|
+
|
|
501
|
+
const id = String(record.sessionId);
|
|
502
|
+
const where = dirs.get(id) || "";
|
|
503
|
+
// A session whose directory is unknown cannot be claimed for the one that
|
|
504
|
+
// was asked for — reporting it there would invent an attribution.
|
|
505
|
+
if (cwd && !samePath(where, cwd)) continue;
|
|
506
|
+
|
|
507
|
+
let run = sessions.get(id);
|
|
508
|
+
if (!run) {
|
|
509
|
+
run = {
|
|
510
|
+
engine: "qwen", id, cwd: where,
|
|
511
|
+
usage: { ...EMPTY_USAGE }, byModel: new Map(),
|
|
512
|
+
start: null, end: null, engineCost: null,
|
|
513
|
+
};
|
|
514
|
+
sessions.set(id, run);
|
|
515
|
+
}
|
|
516
|
+
const one = qwenUsageOf(record);
|
|
517
|
+
const model = record.model || "unknown";
|
|
518
|
+
run.usage = addUsage(run.usage, one);
|
|
519
|
+
run.byModel.set(model, addUsage(run.byModel.get(model) || EMPTY_USAGE, one));
|
|
520
|
+
if (at != null) {
|
|
521
|
+
run.start = run.start == null ? at : Math.min(run.start, at);
|
|
522
|
+
run.end = run.end == null ? at : Math.max(run.end, at);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return [...sessions.values()];
|
|
527
|
+
}
|
|
528
|
+
|
|
411
529
|
// ---------------------------------------------------------------------------
|
|
412
530
|
// aider — it prints the running total into its own chat history
|
|
413
531
|
// ---------------------------------------------------------------------------
|
|
@@ -486,11 +604,12 @@ export const COST_READERS = {
|
|
|
486
604
|
codex: (opts) => codexRuns(opts),
|
|
487
605
|
opencode: (opts) => opencodeRuns("opencode", opts),
|
|
488
606
|
privacycode: (opts) => opencodeRuns("privacycode", opts),
|
|
607
|
+
qwen: (opts) => qwenRuns(opts),
|
|
489
608
|
aider: (opts) => aiderRuns(opts),
|
|
490
609
|
};
|
|
491
610
|
|
|
492
611
|
/** Engines moshcode can launch but cannot cost — named so the report can say so. */
|
|
493
|
-
export const UNCOSTED_ENGINES = ["gemini", "kimi", "
|
|
612
|
+
export const UNCOSTED_ENGINES = ["gemini", "kimi", "deepseek", "openagents"];
|
|
494
613
|
|
|
495
614
|
/**
|
|
496
615
|
* Finish a run: price it, and record where the price came from.
|
package/src/engines.mjs
CHANGED
|
@@ -19,6 +19,14 @@
|
|
|
19
19
|
// repeating; only put a pattern here when it is this engine's own wording.
|
|
20
20
|
// Every pattern is matched against the bottom of the screen with ANSI stripped.
|
|
21
21
|
//
|
|
22
|
+
// `hooks` (optional) is how this engine reports its own state, so the herd can
|
|
23
|
+
// stop reading paint (PRD 0011 R1). It sits here for the same reason `state`
|
|
24
|
+
// does — and because the two are the same fact at different confidence: a hook
|
|
25
|
+
// spec supersedes the screen rules below it, so an engine that gains one should
|
|
26
|
+
// keep its rules rather than delete them. A missing `hooks` is not a gap to
|
|
27
|
+
// paper over with a guess; it means this engine is classified from its screen,
|
|
28
|
+
// which is what every engine did before.
|
|
29
|
+
//
|
|
22
30
|
// `resume` (optional) is the argv that reopens this engine's last conversation,
|
|
23
31
|
// used by `moshcode restore --resume` after a reboot. Omit it rather than guess:
|
|
24
32
|
// a session that starts fresh is a small disappointment, and one that starts
|
|
@@ -82,6 +90,30 @@ export const ENGINES = {
|
|
|
82
90
|
"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_SESSION_ID", "CLAUDE_CODE_CHILD_SESSION",
|
|
83
91
|
],
|
|
84
92
|
resume: ["--continue"],
|
|
93
|
+
// The engine speaking for itself (PRD 0011 R1). Claude Code's lifecycle
|
|
94
|
+
// hooks are documented and stable, which is why it is the one engine that
|
|
95
|
+
// ships a spec here rather than a promise to write one: a hook schema we
|
|
96
|
+
// guessed at would be a rule that rots with no screen to fall back to.
|
|
97
|
+
//
|
|
98
|
+
// `file` is a function, not a path, because the whole install is a write to
|
|
99
|
+
// the user's home and the only honest way to test that is to move HOME.
|
|
100
|
+
hooks: {
|
|
101
|
+
format: "claude-settings",
|
|
102
|
+
file: () => path.join(homedir(), ".claude", "settings.json"),
|
|
103
|
+
// Stop fires when the turn ends, which is the end of the *task* — A2A
|
|
104
|
+
// calls the same moment `completed`. Notification covers both halves of
|
|
105
|
+
// blocked (a permission request and a plain question). UserPromptSubmit
|
|
106
|
+
// is the cheapest honest `working`: PreToolUse would also do it, at the
|
|
107
|
+
// cost of forking a moshcode per tool call for a state it is already in.
|
|
108
|
+
// `label` is what moshcode calls the event; `event` is what the engine
|
|
109
|
+
// calls it. They differ because one is a sentence and the other is a
|
|
110
|
+
// schema key, and printing the schema key at someone is not an answer.
|
|
111
|
+
events: [
|
|
112
|
+
{ event: "Stop", state: "done", label: "stop" },
|
|
113
|
+
{ event: "Notification", state: "blocked", label: "notification" },
|
|
114
|
+
{ event: "UserPromptSubmit", state: "working", label: "prompt-submit" },
|
|
115
|
+
],
|
|
116
|
+
},
|
|
85
117
|
state: {
|
|
86
118
|
// The permission dialog's own heading, and the selector on its first
|
|
87
119
|
// option — the generic numbered-menu pattern would catch the second only
|