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.
@@ -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, qwen, deepseek and openagents log nothing readable, "
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: [["moshcode wait <name> [--state blocked,done] [--timeout 30m]", ""]],
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: [["moshcode wait api --state blocked --timeout 1h", "exit 0 matched · 2 timed out · 3 gone"]],
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: [["moshcode herd wait <name> [--state blocked,done]", ""]],
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, waitFor } from "./herd-cli.mjs";
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 sent = sendPrompt(String(name), text);
523
- return { ok: Boolean(sent.ok) };
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 waitFor(String(name), states, opts.timeout ? { timeoutMs: Number(opts.timeout) } : {});
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
- return capture(String(name), { lines: Number(opts.lines) || 60 });
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/…, and
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", "qwen", "deepseek", "openagents"];
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