@nodii/dev 0.6.36 → 0.6.38

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 CHANGED
@@ -327,6 +327,29 @@ nodii-dev hook nudge-pre # guideline nudges before a tool call; nev
327
327
  nodii-dev hook nudge-stop # guideline nudges when a lane session stops; asks once
328
328
  nodii-dev figma checked CS-n --unused-properties --stale-placeholders --dialog-body
329
329
  nodii-dev hook print-settings # prints managed-settings JSON; writes nothing
330
+ ```
331
+
332
+ `hook safety-pre` (installed by `harness install` for Claude Code and Codex)
333
+ blocks, silently when satisfied (AGENT-HARNESS-15):
334
+
335
+ - **Everyone, in a lane or not:** pattern kills (`pkill`, `killall`,
336
+ `kill $(pgrep …)`, `ps … | xargs kill`, `kill -1`). Kill only a PID you
337
+ started and saved.
338
+ - **In a lane:** `git stash`; checking out or creating another branch;
339
+ `git add -A`; `git reset` onto another branch; `git worktree remove --force`;
340
+ a force push (`-f`, `--force`, `--force-with-lease`, `+ref`); a push without
341
+ a review receipt for HEAD and origin/main; `--no-verify` and its aliases
342
+ (`LEFTHOOK=0`, `HUSKY=0`, `core.hooksPath`); `nodii-dev login`, `logout`,
343
+ `agent register`; `nodii/prod` secrets, `NODE_ENV=test`, `skipAuth`;
344
+ `docker compose down -v` or volume deletes on stacks the run didn't create;
345
+ deploy/apply without a `GO deploy <sha>` grant; shell writes outside the
346
+ worktree (redirects, `tee`, `cp`, `mv`, `rm`, `mkdir`, `touch` …; temp space
347
+ is fine) and mutating `git -C` into another checkout; Edit/Write outside the
348
+ worktree, outside owned paths or on generated files.
349
+ - **A coordinator** may use `--no-verify` only with `NODII_DEV_NO_VERIFY_REASON`
350
+ set; the reason is recorded in private Git metadata.
351
+
352
+ ```bash
330
353
 
331
354
  nodii-dev lane start KEY --title TEXT --kind dev_fix|new_build|docs_change|uat_fix|prod_fix
332
355
  --side backend|frontend|both [--repo REPO] [--issue ISSUE]
@@ -833,14 +856,53 @@ first.
833
856
  coordinator run and shows its running lanes, orphaned runs and unclaimed
834
857
  results. A live second coordinator is refused; a coordinator whose heartbeat
835
858
  watchdog has recorded it orphaned is recorded as the predecessor of the
836
- takeover.
859
+ takeover. Each `--account RUNTIME:PROFILE` (for example
860
+ `--account claude:/Users/me/.claude-work`) registers that model account on
861
+ the machine, and the command then reports every registered account's usage.
862
+
863
+ ### Account capacity (MW1-38.1, MW1-38.2)
864
+
865
+ Each Claude or Codex profile a machine launches lanes on is a Dev runner of
866
+ kind `account`, owned by the machine's coordinator.
867
+ `machine accounts register --runtime claude|codex --profile PATH` registers
868
+ one and reports its usage at once; `machine accounts list` shows them;
869
+ `machine usage report [--profile PATH]` re-reads and reports each one. The
870
+ reading is read-only and never prints a token: Claude reads
871
+ `GET https://api.anthropic.com/api/oauth/usage` with the profile's own login
872
+ (the macOS keychain, or `PROFILE/.credentials.json`), and an expired login is
873
+ left for Claude to renew, never refreshed here; Codex reads the newest
874
+ rollout's `rate_limits`, which counts only while it is under 15 minutes old
875
+ (MW1-38.1: an older reading is unknown). An idle Codex account can opt in with
876
+ `machine accounts register --runtime codex --profile PATH --probe` (and out with
877
+ `--no-probe`): when its
878
+ newest reading is 10 minutes old, a `lane launch` on it or an explicit
879
+ `machine usage report --probe` (never the watch loop, since a probe spends
880
+ quota) runs one minimal
881
+ `codex exec --skip-git-repo-check -s read-only "reply ok"` under that
882
+ `CODEX_HOME` and reads the fresh rollout. Dev places a launch by the higher of the 5-hour and
883
+ weekly windows: under 80% anything; 80–94% no new long lanes; 95–99% only
884
+ `no-build` work (a one-file fix, a publish or verify relaunch, an
885
+ evidence-only step); 100% nothing. A reading older than 15 minutes counts as
886
+ unknown. `lane launch` refreshes its own account's reading first, and the
887
+ watchdog sweep re-reports every account every 5 minutes (install it with
888
+ `watchdog install` under the coordinator's identity).
889
+
890
+ `coordinator start` also launches a detached heartbeater
891
+ (`coordinator heartbeat`, its PID saved in
892
+ `coordinator-NAME.heartbeat.pid` next to the config) tied to the coordinator
893
+ session's process. It heartbeats the run at half its expected interval while
894
+ the session lives, ends the run (`done`) when the session exits, and stops
895
+ without touching a run that a later `coordinator start` replaced.
837
896
 
838
897
  `lane launch KEY` replaces the manual worktree/start/process sequence. It
839
898
  requires the issue (whose title supplies both lane title and objective), done
840
- check, build kind and side, runtime profile and model, subagent quota, and
899
+ check, build kind and side, runtime profile and model, subagent quota, the
900
+ lane's class (`--class long|short|no-build`, MW1-38.3), and
841
901
  repeated `--repo NAME=/SOURCE` plus
842
902
  `--branch NAME=BRANCH` pairs. The Hub preflight checks the coordinator waiter,
843
- target agent and machine, fresh account-capacity telemetry, repository grants,
903
+ target agent and machine, the account's fresh usage band against the lane's
904
+ class (refusing with `ACCOUNT_LIMIT_UNAVAILABLE` or `ACCOUNT_BAND_REFUSED` and
905
+ the command that fixes it), repository grants,
844
906
  lane limit, open issues and (when `--figma` is present) the authenticated Figma
845
907
  account. Only after that passes does the CLI create one worktree per repository,
846
908
  atomically register the lane and watched run, and launch the runtime with the
@@ -996,9 +1058,15 @@ nodii-dev requests resolve UUID --commit-sha abc1234 --resolution-md "shipped"
996
1058
  nodii-dev open-questions answer UUID --decision-id D1420
997
1059
  ```
998
1060
 
999
- The CLI bundles Dev's OpenAPI document as the edge routes it (the edge does not
1000
- serve Dev's own document), so `api list` works offline and lists only
1001
- operations the edge exposes. The first-class resource families cover every tag: services,
1061
+ `api` and the resource commands read Dev's live catalog through the edge
1062
+ (`GET /devops/api/v1/openapi.json`), so an operation Dev adds after a CLI
1063
+ release is callable at once. The catalog is cached for 15 minutes in
1064
+ `~/.cache/nodii-dev/api-catalog.json` (`NODII_DEV_API_CATALOG_CACHE`),
1065
+ stamped with Dev's version and the document's sha256. When Dev cannot be read
1066
+ the CLI uses the last good copy, else the OpenAPI document bundled at release
1067
+ (the edge's routes only, so it works offline). `api catalog [--refresh]` shows
1068
+ which one is in use and why; `NODII_DEV_API_CATALOG=bundled` never asks Dev.
1069
+ The first-class resource families cover every tag: services,
1002
1070
  feature docs, contracts (gRPC, REST, outbox, sagas, tables), libraries,
1003
1071
  workers, projects, slices, SQL lookups, issues, backlogs, builds, changes,
1004
1072
  runtime, people, agents, inbox/gates, design, runbooks,
@@ -0,0 +1,342 @@
1
+ // Model-account capacity (MW1-38.1, AGENT-HARNESS-43). Each Claude or Codex
2
+ // profile a machine launches lanes on is a Dev runner of kind `account`; its
3
+ // subscription usage ceiling carries the account's measured 5-hour and weekly
4
+ // windows, and `lane launch` refuses an account with no reading under 15
5
+ // minutes old. Readings are read-only: a token is never printed or refreshed.
6
+ import { execFile as execFileCallback } from "node:child_process";
7
+ import { createHash } from "node:crypto";
8
+ import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
9
+ import { homedir, tmpdir } from "node:os";
10
+ import { dirname, isAbsolute, join, normalize } from "node:path";
11
+ import { promisify } from "node:util";
12
+ import { CliError, configPath, devRequest, encode, readConfig } from "./core.js";
13
+
14
+ const execFile = promisify(execFileCallback);
15
+ export const RUNTIMES = ["claude", "codex"];
16
+ export const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
17
+ // The watchdog sweeps every minute; it re-reports at this cadence so a launch
18
+ // always finds a reading well inside Dev's 15-minute window.
19
+ export const USAGE_REFRESH_MS = 5 * 60_000;
20
+
21
+ /**
22
+ * MW1-38.2 bands, placed by the higher of the windows: under 80% ok (normal
23
+ * placement), 80-94% warning (no new long lanes), 95-99% short_only (a
24
+ * one-file fix, a publish or verify relaunch, an evidence-only step), 100%
25
+ * exhausted (nothing new).
26
+ */
27
+ export function usageBand(windows) {
28
+ const peak = Math.max(...Object.values(windows).map((window) => window?.utilization ?? 0), 0);
29
+ return peak >= 100 ? "exhausted" : peak >= 95 ? "short_only" : peak >= 80 ? "warning" : "ok";
30
+ }
31
+
32
+ /**
33
+ * The ceiling's stored state (ok | warning | exhausted). Dev places a launch
34
+ * by the band it computes from the percentages in counters, so 95-99% is
35
+ * stored as warning without losing the stricter band.
36
+ */
37
+ export function usageState(windows) {
38
+ const band = usageBand(windows);
39
+ return band === "short_only" ? "warning" : band;
40
+ }
41
+
42
+ /** One spelling per profile directory: Dev matches an account's profile exactly. */
43
+ export const profilePath = (value) => (typeof value === "string" && value.length > 1 ? value.replace(/\/+$/, "") : value);
44
+
45
+ function checkAccount({ runtime, profile }) {
46
+ if (!RUNTIMES.includes(runtime)) throw new CliError("USAGE", "--runtime must be claude or codex.", 2);
47
+ if (!profile || !isAbsolute(profile) || normalize(profile) !== profile)
48
+ throw new CliError("USAGE", "--profile must be an absolute, normalized runtime profile path.", 2);
49
+ }
50
+
51
+ /**
52
+ * The keychain services that may hold a profile's Claude login, in order.
53
+ * With CLAUDE_CONFIG_DIR set (as lane launch always sets it), Claude Code
54
+ * suffixes the service with the directory's hash; only the default
55
+ * ~/.claude also has the plain service, from sessions started without it.
56
+ */
57
+ export function claudeKeychainServices(profile, home = homedir()) {
58
+ const hashed = `Claude Code-credentials-${createHash("sha256").update(profile).digest("hex").slice(0, 8)}`;
59
+ return profile === join(home, ".claude") ? [hashed, "Claude Code-credentials"] : [hashed];
60
+ }
61
+
62
+ async function claudeCredential(profile, { platform = process.platform, run = execFile, read = readFile } = {}) {
63
+ if (platform !== "darwin") return JSON.parse(await read(join(profile, ".credentials.json"), "utf8"))?.claudeAiOauth ?? null;
64
+ for (const service of claudeKeychainServices(profile)) {
65
+ try { return JSON.parse((await run("security", ["find-generic-password", "-s", service, "-w"], { encoding: "utf8" })).stdout)?.claudeAiOauth ?? null; }
66
+ catch {}
67
+ }
68
+ return null;
69
+ }
70
+
71
+ const isoOrNull = (value) => {
72
+ if (value === null || value === undefined || value === "") return null;
73
+ const date = typeof value === "number" ? new Date(value * 1000) : new Date(value);
74
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
75
+ };
76
+
77
+ /** The account's own windows from Anthropic's read-only usage endpoint. */
78
+ export async function readClaudeUsage(profile, { credential = claudeCredential, fetchImpl = fetch, now = () => new Date() } = {}) {
79
+ let oauth;
80
+ try { oauth = await credential(profile); }
81
+ catch { throw new CliError("ACCOUNT_CREDENTIAL_UNAVAILABLE", `No Claude login found for ${profile}.`, 1); }
82
+ if (!oauth?.accessToken) throw new CliError("ACCOUNT_CREDENTIAL_UNAVAILABLE", `No Claude login found for ${profile}.`, 1);
83
+ // Lanes share this login: refreshing it here could sign them out, so an
84
+ // expired token is reported as unreadable and left for the runtime to renew.
85
+ if (oauth.expiresAt && Number(oauth.expiresAt) <= now().getTime())
86
+ throw new CliError("ACCOUNT_TOKEN_EXPIRED", `The Claude login for ${profile} has expired. Lanes share it, so it is not refreshed here: start Claude once on that profile (CLAUDE_CONFIG_DIR=${profile}) to renew it, then report again.`, 1);
87
+ let response;
88
+ try {
89
+ response = await fetchImpl(CLAUDE_USAGE_URL, {
90
+ headers: { authorization: `Bearer ${oauth.accessToken}`, "anthropic-beta": "oauth-2025-04-20" },
91
+ signal: AbortSignal.timeout(15_000),
92
+ });
93
+ } catch (error) {
94
+ throw new CliError("ACCOUNT_USAGE_UNAVAILABLE", `Anthropic's usage endpoint was unreachable for ${profile} (${error?.cause?.code ?? error?.name ?? "network error"}).`, 1);
95
+ }
96
+ if (!response.ok) throw new CliError("ACCOUNT_USAGE_UNAVAILABLE", `Anthropic's usage endpoint answered ${response.status} for ${profile}.`, 1);
97
+ const body = await response.json();
98
+ const window = (value) => value && typeof value.utilization === "number"
99
+ ? { utilization: value.utilization, resetsAt: isoOrNull(value.resets_at) }
100
+ : null;
101
+ // Model-scoped weekly windows (seven_day_opus, seven_day_sonnet, …) bind
102
+ // too when present, so they count toward the band.
103
+ const scoped = Object.entries(body)
104
+ .filter(([name, value]) => /^seven_day_/.test(name) && window(value))
105
+ .map(([name, value]) => [name.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()), window(value)]);
106
+ return {
107
+ source: "anthropic-oauth-usage",
108
+ measuredAt: now().toISOString(),
109
+ windows: { fiveHour: window(body.five_hour), sevenDay: window(body.seven_day), ...Object.fromEntries(scoped) },
110
+ };
111
+ }
112
+
113
+ /** The newest rollouts first, walking sessions/YYYY/MM/DD newest-first and stopping at `limit`. */
114
+ async function newestRollouts(profile, limit = 20) {
115
+ const files = [];
116
+ const names = async (dir) => {
117
+ try { return (await readdir(dir, { withFileTypes: true })).sort((a, b) => b.name.localeCompare(a.name)); }
118
+ catch { return []; }
119
+ };
120
+ async function walk(dir) {
121
+ for (const entry of await names(dir)) {
122
+ if (files.length >= limit) return;
123
+ const path = join(dir, entry.name);
124
+ if (entry.isDirectory()) await walk(path);
125
+ else if (/^rollout-.*\.jsonl$/.test(entry.name)) files.push(path);
126
+ }
127
+ }
128
+ await walk(join(profile, "sessions"));
129
+ return files;
130
+ }
131
+
132
+ function lastRateLimits(text) {
133
+ const lines = text.trimEnd().split("\n");
134
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
135
+ if (!lines[index].includes('"rate_limits"')) continue;
136
+ let record;
137
+ try { record = JSON.parse(lines[index]); } catch { continue; }
138
+ const limits = record?.payload?.rate_limits ?? record?.rate_limits;
139
+ if (limits && (limits.primary || limits.secondary)) return { limits, at: record.timestamp };
140
+ }
141
+ return null;
142
+ }
143
+
144
+ // MW1-38.1: a reading older than 15 minutes is unknown, never free. A Codex
145
+ // rollout is only as fresh as the last Codex run on that profile.
146
+ export const MAX_READING_AGE_MS = 15 * 60_000;
147
+ // A probing account takes a fresh reading before its newest one gets close
148
+ // to that limit when a launch or an explicit report asks for one.
149
+ export const PROBE_AFTER_MS = 10 * 60_000;
150
+
151
+ /** The newest rate-limit reading in the profile's rollouts, or null. */
152
+ async function newestCodexReading(profile, { rollouts, read, now }) {
153
+ for (const path of await rollouts(profile)) {
154
+ const found = lastRateLimits(await read(path, "utf8"));
155
+ if (!found) continue;
156
+ const windows = { fiveHour: null, sevenDay: null };
157
+ for (const limit of [found.limits.primary, found.limits.secondary]) {
158
+ if (!limit || typeof limit.used_percent !== "number") continue;
159
+ const resetsAt = isoOrNull(limit.resets_at);
160
+ // A window that has reset since the rollout was written is empty now.
161
+ const utilization = resetsAt && Date.parse(resetsAt) <= now().getTime() ? 0 : limit.used_percent;
162
+ const name = limit.window_minutes === 300 ? "fiveHour" : limit.window_minutes === 10_080 ? "sevenDay" : `window${limit.window_minutes}m`;
163
+ windows[name] = { utilization, resetsAt };
164
+ }
165
+ return { source: "codex-rollout", measuredAt: isoOrNull(found.at), windows };
166
+ }
167
+ return null;
168
+ }
169
+
170
+ /**
171
+ * One minimal Codex turn under the profile's CODEX_HOME, so Codex writes a
172
+ * fresh rollout with the account's current rate limits (opt-in per account).
173
+ */
174
+ export async function probeCodex(profile, { codexBin = "codex", run = execFile } = {}) {
175
+ // The probe must run on the profile's own ChatGPT login, never an API key.
176
+ const { OPENAI_API_KEY: _key, OPENAI_BASE_URL: _base, ...env } = process.env;
177
+ try {
178
+ await run(codexBin, ["exec", "--skip-git-repo-check", "-s", "read-only", "reply ok"], {
179
+ cwd: tmpdir(), env: { ...env, CODEX_HOME: profile }, timeout: 120_000, encoding: "utf8",
180
+ });
181
+ } catch (error) {
182
+ throw new CliError("ACCOUNT_PROBE_FAILED", `The Codex probe on ${profile} failed (${error?.code ?? error?.signal ?? "error"}).`, 1);
183
+ }
184
+ }
185
+
186
+ /** The account's windows from the newest Codex rollout that recorded them, under 15 minutes old. */
187
+ export async function readCodexUsage(profile, { probe = null, rollouts = newestRollouts, read = readFile, now = () => new Date(), runProbe = probeCodex } = {}) {
188
+ const age = (reading) => (reading?.measuredAt ? now().getTime() - Date.parse(reading.measuredAt) : Number.POSITIVE_INFINITY);
189
+ let reading = await newestCodexReading(profile, { rollouts, read, now });
190
+ if (probe && age(reading) >= PROBE_AFTER_MS) {
191
+ try {
192
+ await runProbe(profile, probe);
193
+ reading = await newestCodexReading(profile, { rollouts, read, now });
194
+ } catch (error) {
195
+ // A failed probe never discards a reading that still counts.
196
+ if (age(reading) > MAX_READING_AGE_MS) throw error;
197
+ }
198
+ }
199
+ if (!reading)
200
+ throw new CliError("ACCOUNT_USAGE_UNAVAILABLE", `No Codex rollout under ${profile}/sessions records rate limits yet.`, 1);
201
+ if (age(reading) > MAX_READING_AGE_MS)
202
+ throw new CliError("ACCOUNT_USAGE_STALE", `The newest Codex reading on ${profile} is ${Math.round(age(reading) / 60_000)} min old, and MW1-38.1 counts it as unknown. Let it probe with \`nodii-dev machine accounts register --runtime codex --profile ${profile} --probe\`, or run Codex on it.`, 1);
203
+ return reading;
204
+ }
205
+
206
+ // Probing is opt-in per account and runs on this machine, so the choice and
207
+ // the Codex binary (launchd's PATH has no user bin directories) live here.
208
+ const probesPath = () => join(dirname(configPath()), "account-probes.json");
209
+ export async function readProbes(read = readFile) {
210
+ try { return JSON.parse(await read(probesPath(), "utf8")); } catch { return {}; }
211
+ }
212
+ async function saveProbe(profile, codexBin) {
213
+ const probes = await readProbes();
214
+ if (codexBin) probes[profile] = { codexBin };
215
+ else delete probes[profile];
216
+ await mkdir(dirname(probesPath()), { recursive: true, mode: 0o700 });
217
+ const temporary = `${probesPath()}.tmp-${process.pid}`;
218
+ await writeFile(temporary, `${JSON.stringify(probes, null, 2)}\n`, { mode: 0o600 });
219
+ await rename(temporary, probesPath());
220
+ }
221
+ async function resolveCodexBin(run = execFile) {
222
+ if (process.env.NODII_DEV_CODEX_BIN) return process.env.NODII_DEV_CODEX_BIN;
223
+ try { return (await run("/usr/bin/which", ["codex"], { encoding: "utf8" })).stdout.trim() || null; }
224
+ catch { return null; }
225
+ }
226
+
227
+ const READERS = { claude: readClaudeUsage, codex: readCodexUsage };
228
+
229
+ /** The usage ceiling body Dev stores for one reading. */
230
+ export function usageReport(reading) {
231
+ const state = usageState(reading.windows);
232
+ const binding = Object.values(reading.windows)
233
+ .filter(Boolean)
234
+ .sort((a, b) => (b.utilization ?? 0) - (a.utilization ?? 0))[0];
235
+ return {
236
+ ceiling: "subscription",
237
+ state,
238
+ resetsAt: state === "ok" ? null : binding?.resetsAt ?? null,
239
+ counters: { source: reading.source, measuredAt: reading.measuredAt, ...reading.windows },
240
+ };
241
+ }
242
+
243
+ /** The coordinator's machine: from its saved coordinator run, else its agent record. */
244
+ export async function coordinatorMachineId({ request = devRequest, config = readConfig } = {}) {
245
+ const c = await config();
246
+ const agent = (await request("GET", `/api/v1/agents/${encode(c.agentId)}`)).data;
247
+ if (agent?.role !== "coordinator" || !agent.machineId)
248
+ throw new CliError("MACHINE_COORDINATOR_REQUIRED", "Account capacity is reported by the machine's coordinator; run this with the coordinator's Dev identity.", 1);
249
+ return agent.machineId;
250
+ }
251
+
252
+ export async function listAccounts(machineId, { request = devRequest } = {}) {
253
+ const runners = (await request("GET", "/api/v1/runners?kind=account")).data ?? [];
254
+ return runners.filter((runner) => runner.machineId === machineId);
255
+ }
256
+
257
+ export async function registerAccount(machineId, { runtime, profile: given }, { request = devRequest } = {}) {
258
+ const profile = profilePath(given);
259
+ checkAccount({ runtime, profile });
260
+ return (await request("POST", "/api/v1/runners", { kind: "account", machineId, runtime, profile, status: "online" })).data;
261
+ }
262
+
263
+ /**
264
+ * Read and report each account's windows; one failure never hides the rest.
265
+ * `probe` lets an opted-in Codex account take a fresh reading. Only an
266
+ * explicit report or a launch probes, never the watch loop: a probe spends
267
+ * the account's quota (hub-58, 30 Oct).
268
+ */
269
+ export async function reportUsage(accounts, { request = devRequest, readers = READERS, probes = readProbes, probe = false } = {}) {
270
+ const results = [];
271
+ const probing = probe && accounts.some((account) => account.runtime === "codex") ? await probes() : {};
272
+ for (const account of accounts) {
273
+ try {
274
+ const reading = await readers[account.runtime](account.profile, probing[account.profile] ? { probe: probing[account.profile] } : {});
275
+ const body = usageReport(reading);
276
+ await request("POST", `/api/v1/runners/${encode(account.id)}/usage`, body);
277
+ // Bump liveness only: a status someone set (offline) is theirs to change.
278
+ await request("POST", `/api/v1/runners/${encode(account.id)}/heartbeat`, {});
279
+ results.push({ runnerId: account.id, runtime: account.runtime, profile: account.profile, state: body.state, band: usageBand(reading.windows), resetsAt: body.resetsAt, ...reading.windows });
280
+ } catch (error) {
281
+ results.push({ runnerId: account.id, runtime: account.runtime, profile: account.profile, error: { code: error?.code ?? "ACCOUNT_USAGE_UNAVAILABLE", message: error?.message ?? String(error) } });
282
+ }
283
+ }
284
+ return results;
285
+ }
286
+
287
+ const percent = (window) => window ? `${Math.round(window.utilization)}%` : "n/a";
288
+ export const usageLine = (result) => result.error
289
+ ? `${result.runtime} ${result.profile}: not reported (${result.error.code}: ${result.error.message})`
290
+ : `${result.runtime} ${result.profile}: ${result.band ?? result.state} (5h ${percent(result.fiveHour)}, week ${percent(result.sevenDay)})`;
291
+
292
+ function parseAccountFlag(value) {
293
+ const match = /^(claude|codex):(.+)$/.exec(String(value));
294
+ if (!match) throw new CliError("USAGE", "--account must be RUNTIME:PROFILE, for example claude:/Users/me/.claude-work.", 2);
295
+ const account = { runtime: match[1], profile: profilePath(match[2]) };
296
+ checkAccount(account);
297
+ return account;
298
+ }
299
+ export const accountFlags = (value) => (value === undefined ? [] : Array.isArray(value) ? value : [value]).map(parseAccountFlag);
300
+
301
+ /** The coordinator run `coordinator start --machine NAME` saved, if any. */
302
+ async function savedCoordinatorRun(machine) {
303
+ try { return JSON.parse(await readFile(join(dirname(configPath()), `coordinator-${encodeURIComponent(machine)}.json`), "utf8")); }
304
+ catch { return null; }
305
+ }
306
+
307
+ /** `machine accounts register|list` and `machine usage report`. */
308
+ export async function machineCapacityCommand(area, action, flags, { request = devRequest, config = readConfig, readers = READERS, savedRun = savedCoordinatorRun } = {}) {
309
+ const machineId = flags["machine-id"] ?? await coordinatorMachineId({ request, config });
310
+ // A coordinator coordinates one machine; --machine NAME must be that one.
311
+ const saved = flags.machine ? await savedRun(flags.machine) : null;
312
+ if (saved?.machineId && saved.machineId !== machineId)
313
+ throw new CliError("MACHINE_COORDINATOR_REQUIRED", `This Dev identity coordinates a different machine than ${flags.machine}.`, 1);
314
+ if (area === "accounts" && action === "register") {
315
+ if ((flags.probe || flags["no-probe"]) && flags.runtime !== "codex") throw new CliError("USAGE", "--probe and --no-probe apply to a codex account only.", 2);
316
+ const account = await registerAccount(machineId, { runtime: flags.runtime, profile: flags.profile }, { request });
317
+ if (flags.probe) {
318
+ const codexBin = await resolveCodexBin();
319
+ if (!codexBin) throw new CliError("CODEX_NOT_FOUND", "--probe needs the codex binary: put it on PATH or set NODII_DEV_CODEX_BIN.", 1);
320
+ await saveProbe(account.profile, codexBin);
321
+ }
322
+ if (flags["no-probe"]) await saveProbe(account.profile, null);
323
+ const [result] = await reportUsage([account], { request, readers });
324
+ return { payload: { data: { account, usage: result } }, human: `Registered ${account.runtime} account ${account.profile} (${account.id}).\n${usageLine(result)}` };
325
+ }
326
+ if (area === "accounts" && action === "list") {
327
+ const accounts = await listAccounts(machineId, { request });
328
+ return { payload: { data: accounts }, human: accounts.map((a) => `${a.id}\t${a.runtime}\t${a.profile}\t${a.status}`).join("\n") || "No accounts registered on this machine." };
329
+ }
330
+ if (area === "usage" && action === "report") {
331
+ const all = await listAccounts(machineId, { request });
332
+ const accounts = flags.profile ? all.filter((a) => a.profile === profilePath(flags.profile)) : all;
333
+ if (!accounts.length)
334
+ throw new CliError("ACCOUNT_NOT_REGISTERED", `No account${flags.profile ? ` ${flags.profile}` : "s"} registered on this machine. Run nodii-dev machine accounts register --runtime claude|codex --profile PATH first.`, 1);
335
+ const results = await reportUsage(accounts, { request, readers, probe: Boolean(flags.probe) });
336
+ const failed = results.filter((r) => r.error);
337
+ if (failed.length === results.length)
338
+ throw new CliError(failed[0].error.code, results.map(usageLine).join("\n"), 1);
339
+ return { payload: { data: results }, human: results.map(usageLine).join("\n") };
340
+ }
341
+ throw new CliError("USAGE", "machine accounts register --runtime claude|codex --profile PATH | machine accounts list | machine usage report [--profile PATH]", 2);
342
+ }
package/dist/api.js CHANGED
@@ -1,16 +1,102 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
2
5
  import { stdin } from "node:process";
3
- import { CliError, devRawRequest, encode } from "./core.js";
6
+ import { CliError, VERSION, devExchange, devRawRequest, encode, readConfig } from "./core.js";
4
7
 
5
8
  const methods = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
6
- // The Dev API as the platform edge routes it, bundled with the package (the
7
- // edge does not serve Dev's own document). Refresh: `npm run openapi:snapshot`.
9
+ // The Dev API as the platform edge routes it, bundled with the package: the
10
+ // fallback when Dev's live catalog cannot be read. Refresh: `npm run openapi:snapshot`.
8
11
  let bundled;
9
12
  export async function loadOpenApi() {
10
13
  bundled ??= JSON.parse(await readFile(new URL("./devops-openapi.json", import.meta.url), "utf8"));
11
14
  return bundled;
12
15
  }
13
16
 
17
+ // Dev issue 99: an operation Dev added after this release is still callable.
18
+ // The live catalog is read through the edge, cached for CATALOG_TTL_MS per
19
+ // edge and stamped with Dev's version and the document's sha256. A refused or
20
+ // malformed read is cached too, so a Dev without the route costs one request
21
+ // per TTL. Unreachable or signed out: the last good copy, else the bundled one.
22
+ export const LIVE_CATALOG_PATH = "/api/v1/openapi.json";
23
+ export const CATALOG_TTL_MS = 15 * 60 * 1000;
24
+ const CATALOG_TIMEOUT_MS = 4000;
25
+ const TRANSIENT = new Set(["DEV_UNREACHABLE", "DEV_TIMEOUT", "NOT_LOGGED_IN", "LOGIN_REQUIRED", "OAUTH_LOGIN_REQUIRED", "KEYCHAIN_UNAVAILABLE"]);
26
+ export const catalogCachePath = (env = process.env) =>
27
+ env.NODII_DEV_API_CATALOG_CACHE || join(homedir(), ".cache", "nodii-dev", "api-catalog.json");
28
+
29
+ const isSpec = (value) => value && typeof value === "object" && typeof value.openapi === "string" && value.paths && typeof value.paths === "object";
30
+ // Only Dev's own API is reachable through the edge's /devops prefix.
31
+ const edgeReachable = (spec) => ({ ...spec, paths: Object.fromEntries(Object.entries(spec.paths).filter(([path]) => path.startsWith("/api/v1/"))) });
32
+
33
+ async function readCache(path) {
34
+ try {
35
+ return JSON.parse(await readFile(path, "utf8"));
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ async function writeCache(path, value) {
42
+ try {
43
+ await mkdir(dirname(path), { recursive: true });
44
+ const tmp = `${path}.${process.pid}.tmp`;
45
+ await writeFile(tmp, JSON.stringify(value), { mode: 0o600 });
46
+ await rename(tmp, path);
47
+ } catch {
48
+ // A cache that cannot be written only costs a refetch next time.
49
+ }
50
+ }
51
+
52
+ const bundledCatalog = async (reason) => ({ spec: await loadOpenApi(), source: "bundled", version: `@nodii/dev ${VERSION}`, reason });
53
+ const cachedCatalog = (good, source, reason) => ({ spec: good.spec, source, version: good.version, sha256: good.sha256, fetchedAt: good.fetchedAt, reason });
54
+
55
+ /**
56
+ * The operation catalog: `{ spec, source: live|cache|stale|bundled, version, sha256?, fetchedAt?, reason? }`.
57
+ * NODII_DEV_API_CATALOG=bundled skips Dev; `refresh` ignores the TTL.
58
+ */
59
+ export async function loadCatalog({ refresh = false, env = process.env, now = Date.now() } = {}) {
60
+ if (env.NODII_DEV_API_CATALOG === "bundled") return bundledCatalog("NODII_DEV_API_CATALOG=bundled");
61
+ const config = await readConfig(false).catch(() => null);
62
+ if (!config?.edgeUrl) return bundledCatalog("not signed in");
63
+ const path = catalogCachePath(env);
64
+ const cache = await readCache(path);
65
+ const mine = cache?.edgeUrl === config.edgeUrl ? cache : null;
66
+ const fresh = mine && now - Date.parse(mine.checkedAt) < CATALOG_TTL_MS;
67
+ if (fresh && !refresh)
68
+ return mine.failure
69
+ ? mine.good ? cachedCatalog(mine.good, "stale", mine.failure) : bundledCatalog(mine.failure)
70
+ : cachedCatalog(mine.good, "cache");
71
+
72
+ let failure;
73
+ try {
74
+ const { payload } = await devExchange("GET", LIVE_CATALOG_PATH, undefined, { signal: AbortSignal.timeout(CATALOG_TIMEOUT_MS) });
75
+ if (!isSpec(payload)) failure = `Dev's ${LIVE_CATALOG_PATH} is not an OpenAPI document`;
76
+ else {
77
+ const spec = edgeReachable(payload);
78
+ const sha256 = createHash("sha256").update(JSON.stringify(spec)).digest("hex");
79
+ const good = { spec, version: `Dev ${payload.info?.version ?? "unknown"}`, sha256, fetchedAt: new Date(now).toISOString() };
80
+ await writeCache(path, { edgeUrl: config.edgeUrl, checkedAt: good.fetchedAt, good, failure: null });
81
+ return cachedCatalog(good, "live");
82
+ }
83
+ } catch (error) {
84
+ const reason = `${error?.code ?? "ERROR"}: ${error?.message ?? String(error)}`;
85
+ // Signed out, offline, timed out or a 5xx says nothing about the route: not cached.
86
+ if (TRANSIENT.has(error?.code) || /^HTTP_5\d\d$/.test(error?.code ?? "") || !(error instanceof CliError))
87
+ return mine?.good ? cachedCatalog(mine.good, "stale", reason) : bundledCatalog(reason);
88
+ failure = reason;
89
+ }
90
+ await writeCache(path, { edgeUrl: config.edgeUrl, checkedAt: new Date(now).toISOString(), good: mine?.good ?? null, failure });
91
+ return mine?.good ? cachedCatalog(mine.good, "stale", failure) : bundledCatalog(failure);
92
+ }
93
+
94
+ export function describeCatalog(catalog) {
95
+ const stamp = [catalog.version, catalog.sha256 && `sha256 ${catalog.sha256.slice(0, 12)}`, catalog.fetchedAt && `fetched ${catalog.fetchedAt}`].filter(Boolean).join(", ");
96
+ const why = catalog.reason ? `; live catalog unavailable: ${catalog.reason}` : "";
97
+ return `catalog: ${catalog.source} (${stamp})${why}`;
98
+ }
99
+
14
100
  export function operationsFrom(spec) {
15
101
  const out = [];
16
102
  for (const [path, item] of Object.entries(spec.paths ?? {}))
package/dist/cli.js CHANGED
@@ -59,6 +59,7 @@ import { docsCommand, rulebookCommand, rulesCommand } from "./upload.js";
59
59
  import { changeSetCommand, guidelineCommand } from "./guidelines.js";
60
60
  import { designCheck, designCheckText, designRoles, designRolesText } from "./design.js";
61
61
  import { watchdogCommand } from "./watchdog.js";
62
+ import { machineCapacityCommand } from "./accounts.js";
62
63
  import {
63
64
  ALIASES,
64
65
  GROUPS,
@@ -67,7 +68,8 @@ import {
67
68
  findFirstClass,
68
69
  flagsToOperation,
69
70
  refusePersonOnly,
70
- loadOpenApi,
71
+ describeCatalog,
72
+ loadCatalog,
71
73
  operationsFrom,
72
74
  parseParams,
73
75
  readBody,
@@ -111,7 +113,7 @@ Usage: nodii-dev <command> [options] [--json]
111
113
  # blocked and wont_do need --reason; done needs --dev-check
112
114
  inbox list|show
113
115
  drift file | request file
114
- decisions list|get|search
116
+ decisions list|get|search # get DEC-n reads the AD decision with its amends
115
117
  design entries|entry|guide
116
118
  design guideline list [--drafted] [--all] | show ENTRY
117
119
  design guideline propose ENTRY --file FILE.md [--summary TEXT] [--dry-run]
@@ -149,7 +151,8 @@ Usage: nodii-dev <command> [options] [--json]
149
151
  harness check # 0 current, 1 differs from Dev, 2 not installed
150
152
  harness update --check # the same check
151
153
  watchdog install [--interval SECONDS] # install/update the per-Mac launchd liveness sweep (default 60s)
152
- watchdog sweep # launchd entrypoint: report dead runs, stale sockets, unpushed worktrees
154
+ watchdog sweep # launchd entrypoint: report dead runs, stale sockets, unpushed worktrees,
155
+ # and re-report account usage every 5 min
153
156
  docs check|validate|upload <path...> --class ruling|conformance|editorial
154
157
  [--base CODE=VERSION] [--decision FILE] [--conformance-ref REF]
155
158
  [--meaning-unchanged REASON] [--wait]
@@ -175,10 +178,14 @@ Usage: nodii-dev <command> [options] [--json]
175
178
  gather timings [--model M] [--last N] # this Mac's lane Gather picks: warm-up and first-pick p50/p95
176
179
  # (each lane gather logs one; the first pick has 120 s once warm)
177
180
 
178
- coordinator start --machine NAME # register this machine's one watched coordinator; refuses a live second
181
+ coordinator start --machine NAME [--account RUNTIME:PROFILE ...]
182
+ # register this machine's one watched coordinator; refuses a live second;
183
+ # registers each --account and reports every account's usage; a background
184
+ # heartbeater keeps the run alive while this session lives, then ends it
179
185
  lane launch <key> --done-check TEXT --kind KIND --side SIDE
180
186
  --issue KEY --repo NAME=/SOURCE --branch NAME=BRANCH [--repo ... --branch ...]
181
187
  --machine NAME --runtime claude|codex --profile PATH --model MODEL --subagents N
188
+ --class long|short|no-build # MW1-38.3 class, weighed against the account's MW1-38.2 band
182
189
  [--base NAME=REF] [--worktree NAME=PATH] [--owned-path NAME=PATH]
183
190
  [--route /app/... ...] [--figma FILE_KEY[=COMPONENT_SET_KEY[,KEY]]] [--notes TEXT]
184
191
  [--narrowing-note TEXT --narrowing-remainder DESTINATION]
@@ -227,11 +234,23 @@ Usage: nodii-dev <command> [options] [--json]
227
234
  # local document pick; prints Lane contract then Clause map
228
235
  built list | built ack <key...>
229
236
  machine list|show|set-coordinator|assign|report
237
+ machine accounts register --runtime claude|codex --profile PATH [--probe|--no-probe] [--machine NAME]
238
+ # coordinator: one runner per model account; reads and reports its usage;
239
+ # --probe (codex): opt in to one minimal codex turn, at launch or an explicit
240
+ # report --probe, when its reading is 10+ min old (never from the watch loop)
241
+ machine accounts list [--machine NAME]
242
+ machine usage report [--profile PATH] [--probe] [--machine NAME]
243
+ # coordinator: each account's 5-hour and weekly windows, read-only
244
+ # (Claude usage endpoint / newest Codex rollout); lane launch needs one
245
+ # under 15 min old, and the watchdog sweep re-reports every 5 min
230
246
  agent lane-limit|grant|revoke
231
247
  people reveal <id> --reason TEXT | people refresh-names
232
248
 
233
249
  api list [--tag TAG] # staff-only operations are marked (ratifyAdDocument:
234
250
  # agents get OPERATOR_REQUIRED by design)
251
+ api catalog [--refresh] # which catalog api and resource commands use: Dev's live one
252
+ # (cached 15 min, version + sha256 stamped) or the bundled
253
+ # fallback, and why; NODII_DEV_API_CATALOG=bundled skips Dev
235
254
  api <operationId> [--param k=v] [--body @file|-|JSON]
236
255
  <resource> <action> [ID ...] [--field VALUE]
237
256
 
@@ -560,8 +579,14 @@ export async function main(argv = process.argv.slice(2)) {
560
579
  const [command, action, ...rest] = args;
561
580
  if (!command || f.help || command === "help") return console.log(HELP);
562
581
  if (command === "api") {
563
- const operations = operationsFrom(await loadOpenApi());
582
+ const catalog = await loadCatalog({ refresh: action === "catalog" && f.refresh === true });
583
+ const operations = operationsFrom(catalog.spec);
584
+ if (action === "catalog") {
585
+ const { spec: _spec, ...stamp } = catalog;
586
+ return emit({ ...stamp, operations: operations.length }, json, `${describeCatalog(catalog)}\n${operations.length} operations`);
587
+ }
564
588
  if (action === "list") {
589
+ if (!json) console.error(`# ${describeCatalog(catalog)}`);
565
590
  const selected = f.tag === undefined || f.tag === true
566
591
  ? operations
567
592
  : operations.filter((x) => x.tags.some((t) => t.toLowerCase() === String(f.tag).toLowerCase()));
@@ -570,7 +595,7 @@ export async function main(argv = process.argv.slice(2)) {
570
595
  }
571
596
  if (!action) throw new CliError("USAGE", "api needs an operationId or list", 2);
572
597
  const entry = operations.find((x) => x.operationId === action);
573
- if (!entry) throw new CliError("UNKNOWN_OPERATION", `No OpenAPI operationId: ${action}`, 2);
598
+ if (!entry) throw new CliError("UNKNOWN_OPERATION", `No OpenAPI operationId: ${action} (${describeCatalog(catalog)})`, 2);
574
599
  refusePersonOnly(entry.operationId);
575
600
  const parsed = await readBody(f.body);
576
601
  const request = buildOperationRequest(entry, parseParams(f.param), parsed?.value);
@@ -688,6 +713,8 @@ export async function main(argv = process.argv.slice(2)) {
688
713
  if (command === "lane") return emitCommand(await laneCommand(action, rest, f));
689
714
  if (command === "next") return emitCommand(await nextCommand(action, f));
690
715
  if (command === "built") return emitCommand(await builtCommand(action, rest, f));
716
+ if (command === "machine" && ["accounts", "usage"].includes(action))
717
+ return emitCommand(await machineCapacityCommand(action, rest[0], f));
691
718
  if (command === "machine") return emitCommand(await machineCommand(action, rest, f));
692
719
  if (command === "people" && ["reveal", "refresh-names"].includes(action))
693
720
  return emitCommand(await peopleCommand(action, rest, f));
@@ -832,6 +859,21 @@ export async function main(argv = process.argv.slice(2)) {
832
859
  f.requestHash = requestHash({ toService: f.toService, kind: requireFlag(f, "kind"), specMd });
833
860
  }
834
861
  if (command === "decisions") {
862
+ // A DEC-* id is an AD-era decision: getAdDecision carries its amends; the
863
+ // pre-dev /decisions route knows none of them.
864
+ if (action === "get" && /^DEC-\d+$/i.test(rest[0] ?? ""))
865
+ return emit(
866
+ await devRequest("GET", `/api/v1/ad-decisions/${encode(rest[0].toUpperCase())}`),
867
+ json,
868
+ ({ data: d }) => [
869
+ `${d.id}\t${d.status}\t${d.title}`,
870
+ ...(d.ruling ? ["", d.ruling] : []),
871
+ "",
872
+ d.amends?.length
873
+ ? `Amends (${d.amends.length}):\n${d.amends.map((a) => ` ${a.clauseId ?? a.docCode}\t${a.effect}\t${a.docCode} ${a.previousVersion ?? "-"} -> ${a.version}${a.documentTitle ? `\t${a.documentTitle}` : ""}`).join("\n")}`
874
+ : "Amends: none",
875
+ ].join("\n"),
876
+ );
835
877
  if (action === "get")
836
878
  return emit(
837
879
  await devRequest("GET", `/api/v1/decisions/${encode(rest[0])}`),
@@ -1084,7 +1126,8 @@ export async function main(argv = process.argv.slice(2)) {
1084
1126
  }
1085
1127
  }
1086
1128
  if (Object.hasOwn(GROUPS, command)) {
1087
- const operations = operationsFrom(await loadOpenApi());
1129
+ const catalog = await loadCatalog();
1130
+ const operations = operationsFrom(catalog.spec);
1088
1131
  let entry = findFirstClass(operations, command, action);
1089
1132
  if (command === "drift" && action === "resolve" && f.status === "accepted-as-canonical")
1090
1133
  entry = operations.find((x) => x.operationId === "acceptDrift");
@@ -1099,7 +1142,7 @@ export async function main(argv = process.argv.slice(2)) {
1099
1142
  if (!entry) {
1100
1143
  const tags = Array.isArray(GROUPS[command]) ? GROUPS[command] : [GROUPS[command]];
1101
1144
  const available = operations.filter((x) => x.tags.some((t) => tags.includes(t))).map((x) => x.operationId).join(", ");
1102
- throw new CliError("USAGE", `Unknown ${command} action: ${action}. OpenAPI operations: ${available}`, 2);
1145
+ throw new CliError("USAGE", `Unknown ${command} action: ${action}. OpenAPI operations: ${available} (${describeCatalog(catalog)})`, 2);
1103
1146
  }
1104
1147
  const built = flagsToOperation(entry, rest, f);
1105
1148
  if (command === "requests" && action === "answer")
package/dist/core.js CHANGED
@@ -4,7 +4,7 @@ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { homedir, platform } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
 
7
- export const VERSION = "0.6.36";
7
+ export const VERSION = "0.6.38";
8
8
  export const configPath = () =>
9
9
  process.env.NODII_DEV_CONFIG_PATH ??
10
10
  join(homedir(), ".config/nodii-dev/config.json");
@@ -222,7 +222,7 @@ export async function checkLogin() {
222
222
  export const encode = (value) => encodeURIComponent(value);
223
223
  // Switches never take the next token as their value, so
224
224
  // `docs upload --wait rulebook/` keeps rulebook/ as a path.
225
- const SWITCHES = new Set(["json", "wait", "pending", "dry-run", "check", "all", "fleet", "help", "yes", "mine", "no-browser", "include-drafts", "drafted", "library", "async", "sync", "force", "system", "delta"]);
225
+ const SWITCHES = new Set(["json", "wait", "pending", "dry-run", "check", "all", "fleet", "help", "yes", "mine", "no-browser", "include-drafts", "drafted", "library", "async", "sync", "force", "system", "delta", "probe", "no-probe"]);
226
226
  export function parseArgs(argv) {
227
227
  const flags = {};
228
228
  const args = [];
package/dist/nodii-dev.js CHANGED
@@ -2,6 +2,9 @@
2
2
  import { fail, main } from "./cli.js";
3
3
  import { regroundCurrentLane, shouldReground } from "./freeze.js";
4
4
  import { isHookDenial } from "./hooks.js";
5
+ import { drainStdout, routeStdout } from "./stdout.js";
6
+
7
+ routeStdout();
5
8
 
6
9
  // Hooks run in the agent's environment, which may be incomplete or may not be
7
10
  // a git worktree. An unavailable hook must never turn into a refused tool call.
@@ -16,11 +19,13 @@ async function run() {
16
19
  await main(argv);
17
20
  }
18
21
 
19
- run().catch((error) => {
20
- const denial = isHookDenial(error?.code);
21
- if (argv[0] === "hook" && !denial) {
22
- process.exitCode = 0;
23
- return;
24
- }
25
- fail(error);
26
- });
22
+ run()
23
+ .catch((error) => {
24
+ const denial = isHookDenial(error?.code);
25
+ if (argv[0] === "hook" && !denial) {
26
+ process.exitCode = 0;
27
+ return;
28
+ }
29
+ fail(error);
30
+ })
31
+ .finally(drainStdout);
package/dist/safety.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { appendFile, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
3
+ import { homedir, tmpdir } from "node:os";
3
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
5
  import { promisify } from "node:util";
5
6
  import { CliError, configPath, devRequest, encode, readConfig } from "./core.js";
@@ -109,7 +110,103 @@ function createsBranch(after) {
109
110
  return after.some((item) => ["-b", "-B", "-c", "-C", "--orphan", "--create", "--force-create"].includes(item));
110
111
  }
111
112
 
112
- const usesNoVerify = (command) => gitInvocations(words(command)).some((x) => x.args.includes("--no-verify"));
113
+ // A hook bypass by another name is still --no-verify: lefthook's and husky's
114
+ // off switches, and pointing Git's hooks somewhere else.
115
+ const HOOK_BYPASS = /(?:^|[\s;&|(])(?:export\s+|env\s+)?(?:LEFTHOOK\s*=\s*["']?(?:0|false|off)\b|HUSKY\s*=\s*["']?0\b)|\bcore\.hooks[Pp]ath\b/;
116
+ const usesNoVerify = (command) => gitInvocations(words(command)).some((x) => x.args.includes("--no-verify")) || HOOK_BYPASS.test(command);
117
+
118
+ // Operator, 29 Oct: a pattern kill on macOS once killed every agent on the
119
+ // machine. Only a PID the caller started (and saved) may be killed.
120
+ const AT_COMMAND = String.raw`(?:^|[;&|(\x60{\n]|\$\()\s*(?:(?:sudo|exec|nohup|time|command)(?:\s+-\S+)*\s+)*`;
121
+ const PATTERN_KILLS = [
122
+ new RegExp(`${AT_COMMAND}(?:\\S*/)?(?:pkill|killall)\\b`),
123
+ /(?:^|[;&|(\s])kill\b[^;&|\n]*(?:\$\(|`)\s*(?:\S*\/)?(?:pgrep|pidof|ps|lsof|jobs)\b/,
124
+ /\b(?:pgrep|pidof|ps|lsof)\b[^;&\n]*\|(?:[^;&\n]*\|)?\s*xargs\b[^;&|\n]*\bkill\b/,
125
+ /(?:^|[;&|(\s])kill\s+(?:-(?:\d+|[A-Z]+|s\s+\S+)\s+)*(?:--\s+)?-1\b/,
126
+ ];
127
+
128
+ /** Blocks that hold for every caller, in a lane or not, and before any lane discovery. */
129
+ export function machineViolations(command) {
130
+ if (PATTERN_KILLS.some((pattern) => pattern.test(command)))
131
+ return [{ code: "SAFETY_PATTERN_KILL", message: "Pattern kills (pkill, killall, kill $(pgrep ...), ps | xargs kill, kill -1) can kill every agent on this machine. Kill only a PID you started and saved to a .pid file." }];
132
+ return [];
133
+ }
134
+
135
+ function forcePush(invocation) {
136
+ if (invocation.verb !== "push") return false;
137
+ return invocation.args.some((arg) => ["-f", "--force", "--force-if-includes", "--mirror"].includes(arg) || arg.startsWith("--force-with-lease") || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(arg) || /^\+[^\s]/.test(arg));
138
+ }
139
+
140
+ // Writes a lane may make outside its worktree: temporary and scratch space.
141
+ const WRITABLE_ELSEWHERE = () => [tmpdir(), "/tmp", "/private/tmp", "/var/folders", "/private/var/folders", "/dev/null", "/dev/stdout", "/dev/stderr", "/dev/fd", "/dev/tty"];
142
+ const WRITE_COMMANDS = new Map([["cp", "last"], ["mv", "last"], ["ln", "last"], ["install", "last"], ["rsync", "last"], ["rm", "all"], ["rmdir", "all"], ["touch", "all"], ["mkdir", "all"], ["truncate", "all"], ["chmod", "rest"], ["chown", "rest"], ["tee", "all"]]);
143
+ const READ_ONLY_GIT = new Set(["status", "diff", "log", "show", "rev-parse", "rev-list", "branch", "ls-files", "ls-tree", "cat-file", "describe", "blame", "grep", "merge-base", "config", "remote", "fetch", "worktree", "for-each-ref", "symbolic-ref", "shortlog", "reflog", "name-rev", "check-ignore", "var", "help", "version"]);
144
+
145
+ /** Shell words with their quoting kept, split on control operators, for write detection. */
146
+ function segments(command) {
147
+ const out = [[]];
148
+ const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(&&|\|\||[;|\n])|([^\s"';|&]+(?:&(?![&])[^\s"';|&]*)*)|(&)/g;
149
+ for (const match of command.matchAll(re)) {
150
+ if (match[3] || match[5]) out.push([]);
151
+ else out.at(-1).push({ text: match[1] ?? match[2] ?? match[4], quoted: match[4] === undefined });
152
+ }
153
+ return out.filter((x) => x.length);
154
+ }
155
+
156
+ function writeTargets(command) {
157
+ const targets = [];
158
+ for (const segment of segments(command)) {
159
+ for (let i = 0; i < segment.length; i += 1) {
160
+ const { text, quoted } = segment[i];
161
+ if (quoted) continue;
162
+ const redirect = /^(?:\d*|&)>{1,2}\|?(.*)$/.exec(text);
163
+ if (redirect) {
164
+ const target = redirect[1] || segment[i + 1]?.text;
165
+ if (target && !target.startsWith("&")) targets.push(target);
166
+ }
167
+ }
168
+ const words = segment.filter((x, i) => !(i === 0 && /^[A-Z_][A-Z0-9_]*=/.test(x.text)));
169
+ const at = words.findIndex((x) => !["sudo", "command", "exec", "nohup"].includes(x.text));
170
+ const name = basename(words[at]?.text ?? "");
171
+ const mode = WRITE_COMMANDS.get(name);
172
+ if (!mode) continue;
173
+ const args = words.slice(at + 1).map((x) => x.text).filter((x) => !x.startsWith("-") && !/^\d*>/.test(x));
174
+ if (mode === "last") targets.push(...args.slice(-1));
175
+ else if (mode === "rest") targets.push(...args.slice(1));
176
+ else targets.push(...args);
177
+ }
178
+ return targets;
179
+ }
180
+
181
+ function outsideWorktree(target, context, relativeKnown) {
182
+ let path = target;
183
+ if (path === "~" || path.startsWith("~/")) path = join(homedir(), path.slice(1));
184
+ else if (/^\$\{?HOME\}?(?:\/|$)/.test(path)) path = join(homedir(), path.replace(/^\$\{?HOME\}?/, ""));
185
+ if (path.includes("$") || path.includes("`")) return false;
186
+ if (!isAbsolute(path)) {
187
+ if (!relativeKnown) return false;
188
+ path = resolve(context.cwd, path);
189
+ }
190
+ const inside = (root) => { const rel = relative(root, path); return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); };
191
+ return !inside(context.root) && !WRITABLE_ELSEWHERE().some(inside);
192
+ }
193
+
194
+ function gitOutsideWorktree(tokens, context) {
195
+ for (let at = 0; at < tokens.length; at += 1) {
196
+ if (tokens[at] !== "git" && !tokens[at].endsWith("/git")) continue;
197
+ let cursor = at + 1;
198
+ let target = null;
199
+ while (cursor < tokens.length && tokens[cursor].startsWith("-")) {
200
+ const option = tokens[cursor];
201
+ if (["-C", "--git-dir", "--work-tree"].includes(option)) target = tokens[cursor + 1] ?? null;
202
+ else if (/^--(?:git-dir|work-tree)=/.test(option)) target = option.slice(option.indexOf("=") + 1);
203
+ cursor += ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"].includes(option) ? 2 : 1;
204
+ }
205
+ const verb = tokens[cursor];
206
+ if (target && verb && !READ_ONLY_GIT.has(verb) && outsideWorktree(target, context, true)) return { target, verb };
207
+ }
208
+ return null;
209
+ }
113
210
 
114
211
  function deployCommand(tokens, command) {
115
212
  if (commandHas(tokens, "tofu", "apply")) return true;
@@ -189,6 +286,16 @@ export function commandViolations(command, context) {
189
286
  }
190
287
  if (gitCommands.some((x) => x.verb === "worktree" && x.args[0] === "remove" && x.args.some((arg) => arg === "-f" || arg === "--force")))
191
288
  block("SAFETY_WORKTREE_FORCE", "git worktree remove --force is forbidden; the coordinator removes a proven-safe worktree without --force.");
289
+ if (gitCommands.some(forcePush))
290
+ block("SAFETY_FORCE_PUSH", "A force push rewrites history others may hold; a lane pushes fast-forward only. Ask the coordinator.");
291
+ if (context.root) {
292
+ const other = gitOutsideWorktree(tokens, context);
293
+ if (other) block("SAFETY_OTHER_WORKTREE", `git ${other.verb} in ${other.target} touches a checkout outside this lane's worktree ${context.root}.`);
294
+ const relativeKnown = !/(?:^|[;&|(\s])(?:cd|pushd)\s/.test(command);
295
+ const outside = writeTargets(command).filter((target) => outsideWorktree(target, context, relativeKnown));
296
+ if (outside.length)
297
+ block("SAFETY_WRITE_OUTSIDE_WORKTREE", `Write blocked outside the lane worktree ${context.root}: ${[...new Set(outside)].join(", ")}. Use the worktree or a temp/scratch directory.`);
298
+ }
192
299
 
193
300
  if (usesNoVerify(command)) {
194
301
  if (lane) block("SAFETY_NO_VERIFY", "--no-verify is forbidden in a lane or subagent.");
@@ -387,6 +494,10 @@ function blocked(violation) {
387
494
  export async function safetyPreHook(payload, contextOrResolver = safetyContext) {
388
495
  const tool = payload.tool_name ?? "";
389
496
  if (!SHELL_TOOLS.has(tool) && !EDIT_TOOLS.has(tool)) return { allowed: true, ignored: true };
497
+ if (SHELL_TOOLS.has(tool)) {
498
+ const machine = machineViolations(String(payload.tool_input?.command ?? ""))[0];
499
+ if (machine) blocked(machine);
500
+ }
390
501
  const cwd = payload.cwd ?? process.cwd();
391
502
  const context = typeof contextOrResolver === "function" ? await contextOrResolver(cwd) : contextOrResolver;
392
503
  context.cwd = await realpath(cwd).catch(() => resolve(cwd));
package/dist/setup.js CHANGED
@@ -18,7 +18,7 @@ description: Drive every Nodii build lane through nodii-dev from start to verifi
18
18
 
19
19
  Use \`nodii-dev\` as the control plane. It talks only to Dev, through the platform edge, signed in with \`nodii-dev login\` as the platform tenant's OAuth app. The canonical policy is the build-workflow rulebook in Dev (\`nodii-dev rulebook list\`), §§1–8 (operator rulings A-020/A-021). Never change lane state in Dev by another route.
20
20
 
21
- 1. The machine coordinator runs \`nodii-dev coordinator start --machine NAME\`, then \`nodii-dev lane launch KEY\` with the issue (whose title is the objective), done check, build kind/side, repeated repository/branch pairs, runtime profile/model and subagent quota. A launched agent begins with \`nodii-dev lane gather\` from its worktree.
21
+ 1. The machine coordinator runs \`nodii-dev coordinator start --machine NAME [--account RUNTIME:PROFILE ...]\` (each model account a lane may run on is registered once; its usage is reported then, before each launch and by the watchdog), then \`nodii-dev lane launch KEY\` with the issue (whose title is the objective), done check, build kind/side, repeated repository/branch pairs, runtime profile/model, subagent quota and the lane's class (\`--class long|short|no-build\`, weighed against the account's usage band). A launched agent begins with \`nodii-dev lane gather\` from its worktree.
22
22
  2. Re-run \`nodii-dev lane gather\` for the current steps and rules; start, complete, or block the reported API step with \`nodii-dev lane step\`. After an authoritative \`lane amend\`, every run is blocked until its own \`lane gather --delta\` acknowledgement.
23
23
  3. Record durable evidence with \`nodii-dev lane evidence KEY --pr|--commit|--figma|--deploy|--dev-check REF\` and heartbeat long-running work with \`nodii-dev lane heartbeat KEY --note "..."\` (notes are at most 500 characters).
24
24
  4. Record the review gate with \`nodii-dev lane review KEY\`. After merge, run \`nodii-dev lane land KEY\`. Done still means deployed and checked live on dev as the right user: \`nodii-dev lane verify KEY --dev-check REF\`.
package/dist/starting.js CHANGED
@@ -7,6 +7,7 @@ import { CliError, configPath, devRequest, encode, query, readConfig, requireFla
7
7
  import { assertKnownSets, checkComponentSetKey, checkFileKey, loadDsCatalog, mergeScopes, scopesFromRoutes } from "./figma-scope.js";
8
8
  import { GATHER_TIMEOUT_MS, gatherDeadline } from "./gather.js";
9
9
  import { laneCommand, nextCommand } from "./lanes.js";
10
+ import { accountFlags, listAccounts, profilePath, registerAccount, reportUsage, usageLine } from "./accounts.js";
10
11
 
11
12
  const execFile = promisify(execFileCallback);
12
13
  const via = { via: "nodii-dev" };
@@ -147,17 +148,99 @@ async function watchCoordinator(runId) {
147
148
  });
148
149
  }
149
150
 
150
- export async function coordinatorCommand(action, flags, { request = devRequest, config = readConfig, save = saveJson, watch = watchCoordinator } = {}) {
151
+ const heartbeatPidFile = (machine) => join(dirname(configPath()), `coordinator-${encodeURIComponent(machine)}.heartbeat.pid`);
152
+
153
+ /**
154
+ * Start the coordinator run's heartbeater: a detached `coordinator heartbeat`
155
+ * process tied to the coordinator session's PID (hub-58, 30 Oct). Its PID is
156
+ * saved next to the coordinator state so only it is ever signalled.
157
+ */
158
+ async function spawnHeartbeater({ runId, machine, pid, processStartedAt, intervalSeconds }) {
159
+ const { spawn } = await import("node:child_process");
160
+ const { openSync } = await import("node:fs");
161
+ const log = openSync(join(dirname(configPath()), `coordinator-${encodeURIComponent(machine)}.heartbeat.log`), "a", 0o600);
162
+ const child = spawn(process.execPath, [
163
+ resolve(process.argv[1]), "coordinator", "heartbeat", "--machine", machine, "--run", runId, "--pid", String(pid),
164
+ ...(processStartedAt ? ["--pid-started-at", processStartedAt] : []), "--interval", String(intervalSeconds),
165
+ ], { detached: true, stdio: ["ignore", log, log], env: { ...process.env, NODII_DEV_CONFIG_PATH: configPath() } });
166
+ child.unref();
167
+ await writeFile(heartbeatPidFile(machine), `${child.pid}\n`, { mode: 0o600 });
168
+ return child.pid;
169
+ }
170
+
171
+ /**
172
+ * The heartbeater's loop: heartbeat the coordinator run while its session
173
+ * lives; when the session exits, end the run and stop. A run that a later
174
+ * `coordinator start` replaced, or that Dev already ended, is left alone.
175
+ */
176
+ export async function coordinatorHeartbeatLoop({ machine, runId, pid, processStartedAt, intervalMs }, {
177
+ request = devRequest,
178
+ isAlive = async (target, startedAt) => (await import("./watchdog.js")).isProcessAlive(target, startedAt),
179
+ currentRunId = async () => (await loadJson(stateFile(machine)))?.runId,
180
+ sleep = (ms) => delay(ms),
181
+ } = {}) {
182
+ for (;;) {
183
+ if ((await currentRunId()) !== runId) return { stopped: "replaced" };
184
+ if (!(await isAlive(pid, processStartedAt))) {
185
+ try {
186
+ await request("POST", `/api/v1/runs/${encode(runId)}/end`, {
187
+ status: "done",
188
+ evidence: [{ kind: "process", ref: `pid ${pid}`, note: "The coordinator session exited." }],
189
+ });
190
+ } catch (error) {
191
+ if (error?.code !== "RUN_ALREADY_TERMINAL") throw error;
192
+ }
193
+ return { stopped: "session_exited" };
194
+ }
195
+ try {
196
+ await request("POST", `/api/v1/runs/${encode(runId)}/heartbeat`, { note: `coordinator pid ${pid} alive` });
197
+ } catch (error) {
198
+ if (error?.code === "RUN_ALREADY_TERMINAL") return { stopped: "terminal" };
199
+ process.stderr.write(`coordinator heartbeat failed (${error?.code ?? error?.message ?? "error"}); retrying.\n`);
200
+ }
201
+ await sleep(intervalMs);
202
+ }
203
+ }
204
+
205
+ /** Register each --account, then report every account's usage (MW1-38.1). */
206
+ async function coordinatorCapacity(machineId, flags, { request, readers }) {
207
+ // The run is already started: a refused registration is reported, not thrown.
208
+ const refused = [];
209
+ for (const account of accountFlags(flags.account)) {
210
+ try { await registerAccount(machineId, account, { request }); }
211
+ catch (error) { refused.push({ ...account, error: { code: error?.code ?? "ACCOUNT_REGISTER_FAILED", message: error?.message ?? String(error) } }); }
212
+ }
213
+ try {
214
+ const reported = await reportUsage(await listAccounts(machineId, { request }), { request, ...(readers ? { readers } : {}) });
215
+ return [...refused, ...reported];
216
+ } catch (error) {
217
+ return [...refused, { runtime: "accounts", profile: "(all)", error: { code: error?.code ?? "ACCOUNT_USAGE_REPORT_FAILED", message: error?.message ?? String(error) } }];
218
+ }
219
+ }
220
+
221
+ export async function coordinatorCommand(action, flags, { request = devRequest, config = readConfig, save = saveJson, watch = watchCoordinator, readers, heartbeater = spawnHeartbeater, heartbeatLoop = coordinatorHeartbeatLoop } = {}) {
222
+ if (action === "heartbeat") {
223
+ const result = await heartbeatLoop({
224
+ machine: requireFlag(flags, "machine"),
225
+ runId: requireFlag(flags, "run"),
226
+ pid: Number(requireFlag(flags, "pid")),
227
+ processStartedAt: flags["pid-started-at"] ?? null,
228
+ intervalMs: Number(flags.interval ?? 30) * 1000,
229
+ }, { request });
230
+ return { payload: { data: result }, human: `Coordinator heartbeater stopped: ${result.stopped}.` };
231
+ }
151
232
  if (action !== "start") throw new CliError("USAGE", `Unknown coordinator action: ${action}`, 2);
152
233
  const machine = requireFlag(flags, "machine");
234
+ accountFlags(flags.account);
153
235
  const payload = await request("POST", "/api/v1/coordinator-runs/start", {
154
236
  machine,
155
237
  expectedHeartbeatSeconds: flags["heartbeat-seconds"] ? Number(flags["heartbeat-seconds"]) : 60,
156
238
  ...via,
157
239
  });
158
240
  const c = await config();
241
+ let watched;
159
242
  try {
160
- await watch(payload.data.run.id);
243
+ watched = await watch(payload.data.run.id);
161
244
  } catch (error) {
162
245
  try {
163
246
  await request("POST", `/api/v1/runs/${encode(payload.data.run.id)}/process-exit`, {
@@ -175,14 +258,30 @@ export async function coordinatorCommand(action, flags, { request = devRequest,
175
258
  agentId: c.agentId,
176
259
  startedAt: payload.data.run.startedAt,
177
260
  });
261
+ // The run expects a heartbeat every expectedHeartbeatSeconds; heartbeat at
262
+ // half that so one late beat never makes it quiet.
263
+ const expected = payload.data.run.expectedHeartbeatSeconds ?? (flags["heartbeat-seconds"] ? Number(flags["heartbeat-seconds"]) : 60);
264
+ let heartbeaterPid = null;
265
+ let heartbeaterError = null;
266
+ if (watched?.pid) {
267
+ try { heartbeaterPid = await heartbeater({ runId: payload.data.run.id, machine, pid: watched.pid, processStartedAt: watched.processStartedAt ?? null, intervalSeconds: Math.max(5, Math.floor(expected / 2)) }); }
268
+ catch (error) { heartbeaterError = error?.message ?? String(error); }
269
+ }
270
+ const accounts = await coordinatorCapacity(payload.data.machine.id, flags, { request, readers });
178
271
  return {
179
- payload,
272
+ payload: { ...payload, accounts, heartbeaterPid },
180
273
  human: (p) => [
181
274
  `Coordinator ${p.data.run.id} is watching ${p.data.machine.name}.`,
182
275
  `running lanes: ${p.data.runningLanes.length}`,
183
276
  `orphans: ${p.data.orphanedRuns.length}`,
184
277
  `unclaimed results: ${p.data.unclaimedResults.length}`,
185
278
  ...(p.data.takeoverOfRunId ? [`takeover recorded from ${p.data.takeoverOfRunId}`] : []),
279
+ heartbeaterPid
280
+ ? `heartbeater ${heartbeaterPid} keeps the run alive while session ${watched.pid} lives`
281
+ : `heartbeater not started${heartbeaterError ? ` (${heartbeaterError})` : ""}: the run goes quiet without heartbeats`,
282
+ ...(accounts.length
283
+ ? accounts.map((result) => `account ${usageLine(result)}`)
284
+ : [`accounts: none registered; add one with --account claude:/path/to/profile (lane launch needs its usage)`]),
186
285
  ].join("\n"),
187
286
  };
188
287
  }
@@ -277,8 +376,16 @@ export async function figmaRateLimit(task, {
277
376
  }
278
377
  }
279
378
 
379
+ async function refreshAccountUsage(machineId, runtime, profile, { request }) {
380
+ const accounts = (await listAccounts(machineId, { request })).filter((a) => a.runtime === runtime && a.profile === profile);
381
+ // A launch is about to spend this account anyway, so an opted-in Codex
382
+ // account may probe for a fresh reading here.
383
+ return reportUsage(accounts, { request, probe: true });
384
+ }
385
+
280
386
  export async function launchLaneCommand(key, flags, {
281
387
  request = devRequest,
388
+ capacity = refreshAccountUsage,
282
389
  config = readConfig,
283
390
  state = loadJson,
284
391
  worktrees = createWorktrees,
@@ -310,8 +417,12 @@ export async function launchLaneCommand(key, flags, {
310
417
  throw new CliError("LANE_IDENTITY_REQUIRED", `Lane ${key} needs its provisioned Dev identity at ${devConfigPath}.`, 1);
311
418
  const scopes = await launchFigmaScopes(flags, { catalog });
312
419
  const figmaSeatVerified = scopes.length ? await figmaSeat() : false;
313
- const profile = requireFlag(flags, "profile");
420
+ const profile = profilePath(requireFlag(flags, "profile"));
314
421
  if (!isAbsolute(profile)) throw new CliError("USAGE", "--profile must be an absolute runtime profile path.", 2);
422
+ // MW1-38.3: the coordinator classes the lane; Dev weighs it against the
423
+ // account's MW1-38.2 band.
424
+ const workClass = { long: "long", short: "short", "no-build": "no_build" }[requireFlag(flags, "class")];
425
+ if (!workClass) throw new CliError("USAGE", "--class must be long, short or no-build (a one-file fix, a publish or verify relaunch, or an evidence-only step).", 2);
315
426
  const body = {
316
427
  key,
317
428
  doneCheck: requireFlag(flags, "done-check"),
@@ -327,6 +438,7 @@ export async function launchLaneCommand(key, flags, {
327
438
  devConfigPath,
328
439
  model: requireFlag(flags, "model"),
329
440
  subagentQuota: Number(requireFlag(flags, "subagents")),
441
+ workClass,
330
442
  ...(flags.notes ? { notes: flags.notes } : {}),
331
443
  ...(flags["narrowing-note"] ? { narrowingNote: flags["narrowing-note"] } : {}),
332
444
  ...(flags["narrowing-remainder"] ? { narrowingRemainder: flags["narrowing-remainder"] } : {}),
@@ -337,6 +449,10 @@ export async function launchLaneCommand(key, flags, {
337
449
  };
338
450
  if (!Number.isInteger(body.subagentQuota) || body.subagentQuota < 0 || body.subagentQuota > 100)
339
451
  throw new CliError("USAGE", "--subagents must be an integer from 0 to 100.", 2);
452
+ // MW1-38.1: read the account's limits before every launch. A failed read is
453
+ // not fatal here; Dev's preflight refuses a missing or stale reading and
454
+ // names the command that fixes it.
455
+ try { await capacity(coordinator.machineId, runtime, profile, { request }); } catch {}
340
456
  await request("POST", "/api/v1/lanes/launch", { phase: "preflight", ...body });
341
457
  await worktrees(repositories);
342
458
  const lastPushedCommit = await head(repositories);
package/dist/stdout.js ADDED
@@ -0,0 +1,26 @@
1
+ import { format } from "node:util";
2
+
3
+ // Dev issue 37: on Bun, once any module imports from `node:process`,
4
+ // console.log to a pipe stops at 65,536 bytes and the rest is lost at exit, so
5
+ // a large `--json` reached `jq` cut off. process.stdout.write delivers it all.
6
+ // The executable routes console.log through it and waits for the stream to
7
+ // drain before it returns. A reader that closes early (`| head -c`) is not an
8
+ // error.
9
+ let installed = false;
10
+
11
+ export function routeStdout(stream = process.stdout) {
12
+ if (installed) return;
13
+ installed = true;
14
+ stream.on("error", (error) => {
15
+ if (error?.code !== "EPIPE") throw error;
16
+ stream.destroy();
17
+ });
18
+ console.log = (...values) => {
19
+ if (!stream.destroyed) stream.write(`${format(...values)}\n`);
20
+ };
21
+ }
22
+
23
+ export function drainStdout(stream = process.stdout) {
24
+ if (stream.destroyed || !stream.writable) return Promise.resolve();
25
+ return new Promise((resolve) => stream.write("", () => resolve()));
26
+ }
package/dist/watchdog.js CHANGED
@@ -4,6 +4,7 @@ import { homedir, platform } from "node:os";
4
4
  import { dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { CliError, configPath, devRequest, encode, readConfig } from "./core.js";
7
+ import { USAGE_REFRESH_MS, listAccounts, reportUsage } from "./accounts.js";
7
8
 
8
9
  const execFile = promisify(execFileCallback);
9
10
  export const WATCHDOG_LABEL = "com.nodii.watchdog";
@@ -173,6 +174,29 @@ const observationBody = ({ observation, machineId, pid, observedAt, detail }) =>
173
174
  observation, machineId, ...(pid === undefined ? {} : { pid }), observedAt, ...(detail ? { detail } : {}),
174
175
  });
175
176
 
177
+ const usageStampPath = (home = homedir()) => join(watchdogHome(home), "usage-reported-at");
178
+
179
+ /**
180
+ * Keep the machine's account readings fresh (MW1-38.1: at least every 10
181
+ * minutes): re-report every USAGE_REFRESH_MS, so lane launch's 15-minute
182
+ * window holds without anyone remembering (AGENT-HARNESS-43).
183
+ */
184
+ export async function refreshMachineUsage(machineId, {
185
+ request = devRequest,
186
+ now = () => new Date(),
187
+ readStamp = async () => { try { return Date.parse(await readFile(usageStampPath(), "utf8")); } catch { return Number.NaN; } },
188
+ writeStamp = (at) => atomicWrite(usageStampPath(), at),
189
+ list = listAccounts,
190
+ report = reportUsage,
191
+ } = {}) {
192
+ const last = await readStamp();
193
+ if (Number.isFinite(last) && now().getTime() - last < USAGE_REFRESH_MS) return null;
194
+ // Never probe here: a probe spends the account's quota (hub-58, 30 Oct).
195
+ const results = await report(await list(machineId, { request }), { request, probe: false });
196
+ await writeStamp(now().toISOString());
197
+ return results;
198
+ }
199
+
176
200
  /** One launchd sweep. The machine coordinator reports every observation through Dev's run watchdog contract. */
177
201
  export async function sweepWatchdog({
178
202
  records,
@@ -182,6 +206,7 @@ export async function sweepWatchdog({
182
206
  saveRecord = saveWatchedRun,
183
207
  machineId,
184
208
  now = () => new Date(),
209
+ refreshUsage = refreshMachineUsage,
185
210
  } = {}) {
186
211
  const loaded = records ? { records, errors: [] } : await loadRecords();
187
212
  const facts = await collect(loaded.records, { errors: [...loaded.errors] });
@@ -241,7 +266,18 @@ export async function sweepWatchdog({
241
266
  facts.errors.push({ kind: "run", runId: record.runId, observation: "process_dead", code: error?.code ?? "RUN_WATCHDOG_REPORT_FAILED" });
242
267
  }
243
268
  }
244
- const report = { ...facts, orphanedRuns };
269
+ // A coordinator session has no command loop to heartbeat its run: while its
270
+ // process lives, the sweep does (a lane heartbeats its own run, so a stuck
271
+ // lane still goes quiet).
272
+ let accounts = null;
273
+ try {
274
+ accounts = await refreshUsage(effectiveMachineId, { request, now });
275
+ for (const result of accounts ?? [])
276
+ if (result.error) facts.errors.push({ kind: "usage", runnerId: result.runnerId, code: result.error.code });
277
+ } catch (error) {
278
+ facts.errors.push({ kind: "usage", code: error?.code ?? "ACCOUNT_USAGE_REPORT_FAILED" });
279
+ }
280
+ const report = { ...facts, orphanedRuns, accounts };
245
281
  return report;
246
282
  }
247
283
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.6.36",
3
+ "version": "0.6.38",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {