@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 +74 -6
- package/dist/accounts.js +342 -0
- package/dist/api.js +90 -4
- package/dist/cli.js +51 -8
- package/dist/core.js +2 -2
- package/dist/nodii-dev.js +13 -8
- package/dist/safety.js +112 -1
- package/dist/setup.js +1 -1
- package/dist/starting.js +120 -4
- package/dist/stdout.js +26 -0
- package/dist/watchdog.js +37 -1
- package/package.json +1 -1
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,
|
|
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,
|
|
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
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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,
|
package/dist/accounts.js
ADDED
|
@@ -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 {
|
|
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
|
|
7
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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()
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|