@nodii/dev 0.6.37 → 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
@@ -856,14 +856,53 @@ first.
856
856
  coordinator run and shows its running lanes, orphaned runs and unclaimed
857
857
  results. A live second coordinator is refused; a coordinator whose heartbeat
858
858
  watchdog has recorded it orphaned is recorded as the predecessor of the
859
- 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.
860
896
 
861
897
  `lane launch KEY` replaces the manual worktree/start/process sequence. It
862
898
  requires the issue (whose title supplies both lane title and objective), done
863
- 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
864
901
  repeated `--repo NAME=/SOURCE` plus
865
902
  `--branch NAME=BRANCH` pairs. The Hub preflight checks the coordinator waiter,
866
- 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,
867
906
  lane limit, open issues and (when `--figma` is present) the authenticated Figma
868
907
  account. Only after that passes does the CLI create one worktree per repository,
869
908
  atomically register the lane and watched run, and launch the runtime with the
@@ -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/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,
@@ -150,7 +151,8 @@ Usage: nodii-dev <command> [options] [--json]
150
151
  harness check # 0 current, 1 differs from Dev, 2 not installed
151
152
  harness update --check # the same check
152
153
  watchdog install [--interval SECONDS] # install/update the per-Mac launchd liveness sweep (default 60s)
153
- 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
154
156
  docs check|validate|upload <path...> --class ruling|conformance|editorial
155
157
  [--base CODE=VERSION] [--decision FILE] [--conformance-ref REF]
156
158
  [--meaning-unchanged REASON] [--wait]
@@ -176,10 +178,14 @@ Usage: nodii-dev <command> [options] [--json]
176
178
  gather timings [--model M] [--last N] # this Mac's lane Gather picks: warm-up and first-pick p50/p95
177
179
  # (each lane gather logs one; the first pick has 120 s once warm)
178
180
 
179
- 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
180
185
  lane launch <key> --done-check TEXT --kind KIND --side SIDE
181
186
  --issue KEY --repo NAME=/SOURCE --branch NAME=BRANCH [--repo ... --branch ...]
182
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
183
189
  [--base NAME=REF] [--worktree NAME=PATH] [--owned-path NAME=PATH]
184
190
  [--route /app/... ...] [--figma FILE_KEY[=COMPONENT_SET_KEY[,KEY]]] [--notes TEXT]
185
191
  [--narrowing-note TEXT --narrowing-remainder DESTINATION]
@@ -228,6 +234,15 @@ Usage: nodii-dev <command> [options] [--json]
228
234
  # local document pick; prints Lane contract then Clause map
229
235
  built list | built ack <key...>
230
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
231
246
  agent lane-limit|grant|revoke
232
247
  people reveal <id> --reason TEXT | people refresh-names
233
248
 
@@ -698,6 +713,8 @@ export async function main(argv = process.argv.slice(2)) {
698
713
  if (command === "lane") return emitCommand(await laneCommand(action, rest, f));
699
714
  if (command === "next") return emitCommand(await nextCommand(action, f));
700
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));
701
718
  if (command === "machine") return emitCommand(await machineCommand(action, rest, f));
702
719
  if (command === "people" && ["reveal", "refresh-names"].includes(action))
703
720
  return emitCommand(await peopleCommand(action, rest, f));
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.37";
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/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/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.37",
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": {