huddle-cli 0.1.0 → 0.1.3
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/CHANGELOG.md +69 -0
- package/QUICKSTART.md +135 -0
- package/README.md +26 -4
- package/dist/adapters/amp.js +1 -1
- package/dist/adapters/antigravity.js +2 -0
- package/dist/adapters/claude.js +19 -3
- package/dist/adapters/codex.js +9 -1
- package/dist/adapters/cursor.js +12 -5
- package/dist/adapters/custom.js +4 -2
- package/dist/adapters/droid.js +1 -0
- package/dist/adapters/goose.js +2 -0
- package/dist/adapters/index.js +44 -18
- package/dist/adapters/kiro.js +1 -1
- package/dist/adapters/opencode.js +1 -1
- package/dist/app/live.js +142 -0
- package/dist/app/page.js +701 -0
- package/dist/app/server.js +262 -0
- package/dist/args.js +18 -0
- package/dist/attach/connect.js +7 -4
- package/dist/attach/guard.js +21 -2
- package/dist/attach/live.js +5 -1
- package/dist/attach/plug.js +21 -3
- package/dist/attach/screen.js +150 -0
- package/dist/attach/session.js +7 -2
- package/dist/cli.js +134 -42
- package/dist/mcp/context-tools.js +79 -0
- package/dist/mcp/server.js +57 -11
- package/dist/orchestrator/ask.js +13 -10
- package/dist/orchestrator/brain.js +16 -4
- package/dist/orchestrator/budget.js +283 -0
- package/dist/orchestrator/busy.js +106 -0
- package/dist/orchestrator/council.js +434 -87
- package/dist/orchestrator/goal.js +196 -0
- package/dist/orchestrator/handoff.js +2 -2
- package/dist/orchestrator/launcher.js +31 -13
- package/dist/orchestrator/learn.js +101 -0
- package/dist/orchestrator/planner.js +54 -9
- package/dist/orchestrator/qa.js +71 -15
- package/dist/orchestrator/radar.js +719 -0
- package/dist/orchestrator/scheduler.js +575 -50
- package/dist/orchestrator/takeover.js +155 -0
- package/dist/platform.js +377 -0
- package/dist/room/context.js +1285 -0
- package/dist/room/contract.js +132 -0
- package/dist/room/db.js +79 -16
- package/dist/room/followup.js +233 -0
- package/dist/room/identity.js +2 -2
- package/dist/room/lessons.js +275 -0
- package/dist/room/live.js +75 -20
- package/dist/room/mail.js +225 -0
- package/dist/room/progress.js +40 -1
- package/dist/room/server.js +220 -39
- package/dist/room/tunnel.js +6 -5
- package/dist/room/user.js +54 -0
- package/dist/sandbox/sync.js +4 -4
- package/dist/sandbox/worktree.js +145 -14
- package/dist/ui/app.js +963 -159
- package/dist/ui/brief.js +494 -0
- package/dist/ui/catalog.js +62 -24
- package/dist/ui/dashboard.js +269 -55
- package/dist/ui/doctor.js +15 -11
- package/dist/ui/export.js +1194 -0
- package/dist/ui/lessons.js +81 -0
- package/dist/ui/narrator.js +56 -0
- package/dist/ui/paste.js +515 -0
- package/dist/ui/remote.js +11 -5
- package/dist/ui/setup.js +152 -0
- package/dist/ui/story.js +716 -94
- package/dist/ui/talk.js +176 -0
- package/dist/ui/term.js +182 -16
- package/dist/ui/transcript.js +434 -0
- package/dist/ui/watch.js +149 -48
- package/package.json +6 -3
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// The cost guard. A run costs real money on someone's subscriptions: the
|
|
2
|
+
// round-6 capstone runs took ~4–6M tokens, an improve run once hit 13M. So:
|
|
3
|
+
//
|
|
4
|
+
// - Before a run, an estimate printed with the plan: "≈ 4–6M tokens, ~15–20
|
|
5
|
+
// min with 3 agents", from the plan's size, the agent count and what past
|
|
6
|
+
// runs in this repo's Room cost (built-in defaults from the capstone data
|
|
7
|
+
// until there is history). It is an estimate, and says so.
|
|
8
|
+
// - --budget=5M (or prefs "budget"): at 90% Huddle starts no new work — no
|
|
9
|
+
// new tasks, takeovers, QA rounds, buddy checks or pairings — and lets the
|
|
10
|
+
// running tasks finish; at 120% it stops the running agents, as Ctrl-C does.
|
|
11
|
+
// Either way the RESULT says what was left undone.
|
|
12
|
+
//
|
|
13
|
+
// What counts: every token the agents' meters report (usage.ts), cached
|
|
14
|
+
// context INCLUDED — the same number the live footer, the estimate and the
|
|
15
|
+
// RESULT's COST line show, so "--budget=5M" means the 5M you see there. Cached
|
|
16
|
+
// reads are billed at a fraction (~10%), so this is conservative: a
|
|
17
|
+
// cache-heavy run reaches its budget before its bill does. The COST line shows
|
|
18
|
+
// the cached share next to it. Read-only asks (reviews, buddy checks, council
|
|
19
|
+
// turns) report no usage, so the budget can't see them; the estimate counts
|
|
20
|
+
// council turns from its defaults.
|
|
21
|
+
import { fmtDuration, fmtTokens } from "../adapters/usage.js";
|
|
22
|
+
/** Stop new work at this share of the budget… */
|
|
23
|
+
export const SOFT_AT = 0.9;
|
|
24
|
+
/** …and stop the running agents at this one. */
|
|
25
|
+
export const HARD_AT = 1.2;
|
|
26
|
+
/** "5M", "800k", "1.5m", "250000", "2,000,000" → tokens; null if it isn't one. */
|
|
27
|
+
export function parseTokens(v) {
|
|
28
|
+
if (typeof v === "number")
|
|
29
|
+
return Number.isFinite(v) && v > 0 ? Math.round(v) : null;
|
|
30
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*([kmb])?\s*(?:tok(?:ens)?)?\s*$/i.exec(String(v ?? "").replace(/[,_]/g, ""));
|
|
31
|
+
if (!m)
|
|
32
|
+
return null;
|
|
33
|
+
const n = Number(m[1]) * ({ k: 1e3, m: 1e6, b: 1e9 }[(m[2] ?? "").toLowerCase()] ?? 1);
|
|
34
|
+
return n > 0 ? Math.round(n) : null;
|
|
35
|
+
}
|
|
36
|
+
/** `--budget=5M` (or `--budget 5M`) anywhere in a typed goal: the goal without it, and the value. */
|
|
37
|
+
export function budgetFlag(goal) {
|
|
38
|
+
const m = /(^|\s)--budget(?:=|\s+)(\S+)(?=\s|$)/.exec(goal);
|
|
39
|
+
if (!m)
|
|
40
|
+
return { goal };
|
|
41
|
+
return { goal: goal.replace(m[0], " ").replace(/\s+/g, " ").trim(), budget: m[2] };
|
|
42
|
+
}
|
|
43
|
+
export class Budget {
|
|
44
|
+
limit;
|
|
45
|
+
meters = new Map();
|
|
46
|
+
extra = { tokens: 0, cached: 0 };
|
|
47
|
+
listeners = [];
|
|
48
|
+
/** Only goes up: once new work stopped, it stays stopped for the run. */
|
|
49
|
+
level = "ok";
|
|
50
|
+
constructor(limit) {
|
|
51
|
+
this.limit = limit;
|
|
52
|
+
}
|
|
53
|
+
/** Count this agent launch's meter (read live, every check). `who`: the agent, for its pace. */
|
|
54
|
+
track(m, who = "", now = Date.now()) {
|
|
55
|
+
if (!this.meters.has(m))
|
|
56
|
+
this.meters.set(m, { who: who.split("#")[0], start: now, lastAt: now, last: 0 });
|
|
57
|
+
}
|
|
58
|
+
/** Tokens measured outside a meter (the planner's own). */
|
|
59
|
+
add(tokens, cached = 0) {
|
|
60
|
+
this.extra.tokens += Math.max(0, tokens || 0);
|
|
61
|
+
this.extra.cached += Math.max(0, cached || 0);
|
|
62
|
+
}
|
|
63
|
+
get tokens() {
|
|
64
|
+
let n = this.extra.tokens;
|
|
65
|
+
for (const m of this.meters.keys())
|
|
66
|
+
n += m.tokens;
|
|
67
|
+
return n;
|
|
68
|
+
}
|
|
69
|
+
get cached() {
|
|
70
|
+
let n = this.extra.cached;
|
|
71
|
+
for (const m of this.meters.keys())
|
|
72
|
+
n += m.cached;
|
|
73
|
+
return n;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Tokens used plus an estimate of what's in flight but not reported yet.
|
|
77
|
+
* Found live (0.1.2): Codex reports usage only when its whole turn ends, so
|
|
78
|
+
* a 1m45s task counted 0 and then 406k at once, and the 90% line fired at
|
|
79
|
+
* 108%. A launch that hasn't reported is estimated at its agent's own pace
|
|
80
|
+
* this run (tokens per second of its earlier launches), capped at twice
|
|
81
|
+
* that agent's average launch. Only the soft line reads this: stopping
|
|
82
|
+
* running agents (the hard line) waits for real numbers.
|
|
83
|
+
*/
|
|
84
|
+
projected(now = Date.now()) {
|
|
85
|
+
this.note(now);
|
|
86
|
+
const pace = new Map();
|
|
87
|
+
for (const [m, t] of this.meters) {
|
|
88
|
+
if (!m.seen || m.tokens <= 0)
|
|
89
|
+
continue;
|
|
90
|
+
const p = pace.get(t.who) ?? { tokens: 0, ms: 0, n: 0 };
|
|
91
|
+
p.tokens += m.tokens;
|
|
92
|
+
p.ms += Math.max(1000, t.lastAt - t.start);
|
|
93
|
+
p.n++;
|
|
94
|
+
pace.set(t.who, p);
|
|
95
|
+
}
|
|
96
|
+
let guess = 0;
|
|
97
|
+
for (const [m, t] of this.meters) {
|
|
98
|
+
if (m.seen)
|
|
99
|
+
continue;
|
|
100
|
+
const p = pace.get(t.who);
|
|
101
|
+
if (!p)
|
|
102
|
+
continue;
|
|
103
|
+
guess += Math.min((p.tokens / p.ms) * (now - t.start), (2 * p.tokens) / p.n);
|
|
104
|
+
}
|
|
105
|
+
return this.tokens + Math.round(guess);
|
|
106
|
+
}
|
|
107
|
+
/** Each meter's count went up: when (its pace). */
|
|
108
|
+
note(now) {
|
|
109
|
+
for (const [m, t] of this.meters)
|
|
110
|
+
if (m.tokens > t.last) {
|
|
111
|
+
t.last = m.tokens;
|
|
112
|
+
t.lastAt = now;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Share of the budget used (0 with no budget). */
|
|
116
|
+
get used() {
|
|
117
|
+
return this.limit ? this.tokens / this.limit : 0;
|
|
118
|
+
}
|
|
119
|
+
/** Past the soft line: start nothing new. */
|
|
120
|
+
get stopping() {
|
|
121
|
+
return this.check() !== "ok";
|
|
122
|
+
}
|
|
123
|
+
/** Re-read the meters; tells the listeners when the level goes up. */
|
|
124
|
+
check() {
|
|
125
|
+
if (!this.limit)
|
|
126
|
+
return this.level;
|
|
127
|
+
const u = this.used;
|
|
128
|
+
const next = u >= HARD_AT ? "hard" : u >= SOFT_AT || this.projected() / this.limit >= SOFT_AT ? "soft" : "ok";
|
|
129
|
+
const rank = { ok: 0, soft: 1, hard: 2 };
|
|
130
|
+
if (rank[next] <= rank[this.level])
|
|
131
|
+
return this.level;
|
|
132
|
+
// Straight to hard: the soft listeners still hear "soft" first (stop new work, then stop agents).
|
|
133
|
+
if (this.level === "ok" && next === "hard") {
|
|
134
|
+
this.level = "soft";
|
|
135
|
+
for (const f of this.listeners)
|
|
136
|
+
f("soft", this);
|
|
137
|
+
}
|
|
138
|
+
this.level = next;
|
|
139
|
+
for (const f of this.listeners)
|
|
140
|
+
f(next, this);
|
|
141
|
+
return this.level;
|
|
142
|
+
}
|
|
143
|
+
onLevel(f) {
|
|
144
|
+
this.listeners.push(f);
|
|
145
|
+
}
|
|
146
|
+
/** Check every `ms` until the returned stop() (agents' meters move between scheduler ticks). */
|
|
147
|
+
watch(ms = 1000) {
|
|
148
|
+
if (!this.limit)
|
|
149
|
+
return () => { };
|
|
150
|
+
const t = setInterval(() => this.check(), ms);
|
|
151
|
+
t.unref?.();
|
|
152
|
+
return () => clearInterval(t);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** "5.1M of 6M budget (85%)" — or "" with no budget. */
|
|
156
|
+
export function budgetUse(b) {
|
|
157
|
+
if (!b.limit)
|
|
158
|
+
return "";
|
|
159
|
+
return `${fmtTokens(b.tokens)} of ${fmtTokens(b.limit)} budget (${Math.round(b.used * 100)}%)`;
|
|
160
|
+
}
|
|
161
|
+
/** From the capstone runs (round 6): ~350k tokens a task, ~550k a QA round, ~60k a council turn. */
|
|
162
|
+
export const DEFAULT_COSTS = { perTask: 350_000, perQaRound: 550_000, perCouncilTurn: 60_000, taskMs: 4 * 60_000, qaRoundMs: 4 * 60_000 };
|
|
163
|
+
const avg = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
164
|
+
/**
|
|
165
|
+
* What past runs in this Room cost: tokens and agent time per task (merged or
|
|
166
|
+
* failed, a takeover's first rounds included) and per QA check. Each number
|
|
167
|
+
* falls back to its default until there are enough samples (3 tasks, 2 checks).
|
|
168
|
+
*/
|
|
169
|
+
export function runHistory(db) {
|
|
170
|
+
const costs = { ...DEFAULT_COSTS };
|
|
171
|
+
if (!db)
|
|
172
|
+
return { costs, tasks: 0, checks: 0 };
|
|
173
|
+
let evs = [];
|
|
174
|
+
try {
|
|
175
|
+
evs = db.eventsOfType(["run.merged", "run.failed", "run.takeover", "qa.done"], 0, 1000);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return { costs, tasks: 0, checks: 0 };
|
|
179
|
+
}
|
|
180
|
+
const p = (e) => {
|
|
181
|
+
try {
|
|
182
|
+
return JSON.parse(e.payload || "{}");
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return {};
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const tasks = [];
|
|
189
|
+
let handedOver = 0; // a rescued task's first agent's rounds: no merged/failed event carries them
|
|
190
|
+
const checks = [];
|
|
191
|
+
for (const e of evs) {
|
|
192
|
+
const x = p(e);
|
|
193
|
+
if (e.type === "run.takeover")
|
|
194
|
+
handedOver += x.kind === "rescue" ? Number(x.tokens) || 0 : 0;
|
|
195
|
+
else if (e.type === "qa.done") {
|
|
196
|
+
if (x.ok && Number(x.tokens) > 0)
|
|
197
|
+
checks.push({ tokens: Number(x.tokens), ms: Number(x.ms) || 0, bugs: Number(x.bugs) || 0 });
|
|
198
|
+
}
|
|
199
|
+
else if (Number(x.tokens) > 0)
|
|
200
|
+
tasks.push({ tokens: Number(x.tokens), ms: Number(x.ms) || 0 });
|
|
201
|
+
}
|
|
202
|
+
if (tasks.length >= 3) {
|
|
203
|
+
costs.perTask = Math.round((tasks.reduce((n, t) => n + t.tokens, 0) + handedOver) / tasks.length);
|
|
204
|
+
const ms = tasks.map((t) => t.ms).filter((m) => m > 0);
|
|
205
|
+
if (ms.length)
|
|
206
|
+
costs.taskMs = Math.round(avg(ms));
|
|
207
|
+
}
|
|
208
|
+
if (checks.length >= 2) {
|
|
209
|
+
const bugs = avg(checks.map((c) => c.bugs));
|
|
210
|
+
costs.perQaRound = Math.round(avg(checks.map((c) => c.tokens)) + bugs * costs.perTask);
|
|
211
|
+
costs.qaRoundMs = Math.round(avg(checks.map((c) => c.ms)) + (bugs > 0 ? costs.taskMs : 0));
|
|
212
|
+
}
|
|
213
|
+
return { costs, tasks: tasks.length >= 3 ? tasks.length : 0, checks: checks.length >= 2 ? checks.length : 0 };
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* A range, not a promise: tasks × per-task cost (±15%), QA as 1 round (low)
|
|
217
|
+
* to all rounds (high), plus the council's turns. Time: the tasks in waves of
|
|
218
|
+
* `agents` at a time, then QA.
|
|
219
|
+
*/
|
|
220
|
+
export function estimateRun(o) {
|
|
221
|
+
const h = o.history ?? { costs: DEFAULT_COSTS, tasks: 0, checks: 0 };
|
|
222
|
+
const c = h.costs;
|
|
223
|
+
const agents = Math.max(1, o.agents);
|
|
224
|
+
const council = (o.councilTurns ?? 0) * c.perCouncilTurn;
|
|
225
|
+
const qaLow = o.qaRounds ? 1 : 0;
|
|
226
|
+
const low = o.tasks * c.perTask * 0.85 + qaLow * c.perQaRound + council;
|
|
227
|
+
const high = o.tasks * c.perTask * 1.15 + o.qaRounds * c.perQaRound + council;
|
|
228
|
+
const waves = Math.ceil(o.tasks / agents);
|
|
229
|
+
const lowMs = waves * c.taskMs * 0.85 + qaLow * c.qaRoundMs;
|
|
230
|
+
const highMs = waves * c.taskMs * 1.15 + o.qaRounds * c.qaRoundMs;
|
|
231
|
+
const from = [h.tasks ? `${h.tasks} past task(s)` : "", h.checks ? `${h.checks} past QA check(s)` : ""].filter(Boolean);
|
|
232
|
+
const basis = from.length ? `from this repo's ${from.join(" and ")}` : "from Huddle's defaults (no history in this repo yet)";
|
|
233
|
+
return { low, high, lowMs, highMs, agents, basis };
|
|
234
|
+
}
|
|
235
|
+
/** Round a token range outward to steps that read well: 4–6M, 1.5–2M, 350–500k. */
|
|
236
|
+
export function fmtTokenRange(low, high) {
|
|
237
|
+
const step = high >= 3e6 ? 1e6 : high >= 1e6 ? 5e5 : high >= 1e5 ? 5e4 : 1e4;
|
|
238
|
+
const lo = Math.max(step, Math.floor(low / step) * step);
|
|
239
|
+
const hi = Math.max(lo + step, Math.ceil(high / step) * step);
|
|
240
|
+
const unit = (n) => (n >= 1e6 ? `${+(n / 1e6).toFixed(1)}` : `${Math.round(n / 1e3)}`);
|
|
241
|
+
const suffix = (n) => (n >= 1e6 ? "M" : "k");
|
|
242
|
+
return suffix(lo) === suffix(hi) ? `${unit(lo)}–${unit(hi)}${suffix(hi)}` : `${fmtTokens(lo)}–${fmtTokens(hi)}`;
|
|
243
|
+
}
|
|
244
|
+
/** "~15–20 min", "~1h05m–1h20m" */
|
|
245
|
+
export function fmtMsRange(low, high) {
|
|
246
|
+
const lo = Math.max(1, Math.round(low / 60_000));
|
|
247
|
+
const hi = Math.max(lo + 1, Math.round(high / 60_000));
|
|
248
|
+
return hi < 60 ? `~${lo}–${hi} min` : `~${fmtDuration(lo * 60_000)}–${fmtDuration(hi * 60_000)}`;
|
|
249
|
+
}
|
|
250
|
+
/** "≈ 4–6M tokens, ~15–20 min with 3 agents" */
|
|
251
|
+
export function estimateText(e) {
|
|
252
|
+
return `≈ ${fmtTokenRange(e.low, e.high)} tokens, ${fmtMsRange(e.lowMs, e.highMs)} with ${e.agents} agent${e.agents === 1 ? "" : "s"}`;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The line printed with the plan: "≈ 4–6M tokens, ~15–20 min with 3 agents ·
|
|
256
|
+
* an estimate, from …", plus what the budget will do.
|
|
257
|
+
*/
|
|
258
|
+
export function costPreview(o) {
|
|
259
|
+
const turns = o.council ? o.council.proposals.length + o.council.critiques.length + (o.council.ratify?.length ?? 0) : 0;
|
|
260
|
+
const e = estimateRun({ tasks: o.tasks, agents: o.agents, qaRounds: o.qaRounds, councilTurns: turns, history: o.history });
|
|
261
|
+
const lim = o.budget?.limit;
|
|
262
|
+
const over = lim && e.low > lim ? " — the low end is past it: expect a partial run" : "";
|
|
263
|
+
const b = lim ? ` · budget ${fmtTokens(lim)}: new work stops at ${fmtTokens(lim * SOFT_AT)}, agents at ${fmtTokens(lim * HARD_AT)}${over}` : "";
|
|
264
|
+
return `${estimateText(e)} · an estimate, ${e.basis}${b}`;
|
|
265
|
+
}
|
|
266
|
+
/** The RESULT's budget lines: why the run stopped short and what it left undone. [] when it didn't. */
|
|
267
|
+
export function budgetNotes(b, o) {
|
|
268
|
+
if (!b.limit || b.level === "ok")
|
|
269
|
+
return [];
|
|
270
|
+
const out = [
|
|
271
|
+
b.level === "hard"
|
|
272
|
+
? `budget: ${fmtTokens(b.tokens)} passed ${Math.round(HARD_AT * 100)}% of ${fmtTokens(b.limit)} — running agents were stopped`
|
|
273
|
+
: `budget: ${Math.round(SOFT_AT * 100)}% of ${fmtTokens(b.limit)} reached — no new work started after it; running tasks finished`,
|
|
274
|
+
];
|
|
275
|
+
if (o.left.length)
|
|
276
|
+
out.push(`left undone (${o.left.length}): ${o.left.join(" · ")}`);
|
|
277
|
+
if (o.qaCut)
|
|
278
|
+
out.push("left undone: QA (no round started past the budget line)");
|
|
279
|
+
if (o.left.length || o.qaCut)
|
|
280
|
+
out.push("what merged stays on main; a bigger --budget (or a follow-up goal) finishes the rest");
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
//# sourceMappingURL=budget.js.map
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Busy, or stuck? An agent that prints nothing and calls no huddle_* tool for
|
|
2
|
+
// the silence window used to be taken over. Found live (evolve run,
|
|
3
|
+
// 2026-10-16): claude was taken over by codex while it was running Huddle's
|
|
4
|
+
// own 8-minute test suite — quiet, but working. So before an agent counts as
|
|
5
|
+
// silent, Huddle looks at what it is doing:
|
|
6
|
+
//
|
|
7
|
+
// - a child or grandchild process used CPU since the last look (the test
|
|
8
|
+
// suite, a build, an install), or
|
|
9
|
+
// - files in its worktree changed since the last look, or
|
|
10
|
+
// - its log grew.
|
|
11
|
+
//
|
|
12
|
+
// Any of those = busy: the silence clock restarts from that look, so a long
|
|
13
|
+
// command gets its full window again once it ends. A hard cap (default 3× the
|
|
14
|
+
// window, HUDDLE_SILENCE_MAX_MIN) since its last real output still takes it
|
|
15
|
+
// over, so a runaway loop can't hold a task forever.
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { descendants } from "../platform.js";
|
|
19
|
+
/** Longest an agent may go without output or a huddle_* call, busy or not. HUDDLE_SILENCE_MAX_MIN overrides; default 3× the silence window. */
|
|
20
|
+
export function silenceMaxMsFromEnv(silenceMs) {
|
|
21
|
+
const v = Number(process.env.HUDDLE_SILENCE_MAX_MIN);
|
|
22
|
+
return Math.max(silenceMs, v > 0 ? v * 60_000 : 3 * silenceMs);
|
|
23
|
+
}
|
|
24
|
+
/** CPU a process tree must use between two looks to count as working: 2% of the wall time, at least 50ms. */
|
|
25
|
+
export const busyCpuMs = (elapsedMs) => Math.max(50, elapsedMs * 0.02);
|
|
26
|
+
/** Folders never walked for changed files: git's own, and installs too big to walk every tick. */
|
|
27
|
+
const SKIP_DIRS = new Set([".git", "node_modules"]);
|
|
28
|
+
const MAX_WALK = 5000;
|
|
29
|
+
/** A file under `dir` modified after `since`, or null. Bounded: at most MAX_WALK entries. */
|
|
30
|
+
export function changedSince(dir, since) {
|
|
31
|
+
const stack = [dir];
|
|
32
|
+
let n = 0;
|
|
33
|
+
while (stack.length && n < MAX_WALK) {
|
|
34
|
+
const d = stack.pop();
|
|
35
|
+
let entries;
|
|
36
|
+
try {
|
|
37
|
+
entries = fs.readdirSync(d, { withFileTypes: true });
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
for (const e of entries) {
|
|
43
|
+
if (++n > MAX_WALK)
|
|
44
|
+
break;
|
|
45
|
+
const p = path.join(d, e.name);
|
|
46
|
+
if (e.isDirectory()) {
|
|
47
|
+
if (!SKIP_DIRS.has(e.name))
|
|
48
|
+
stack.push(p);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
if (fs.statSync(p).mtimeMs > since)
|
|
53
|
+
return path.relative(dir, p);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* gone while we looked */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const sizeOf = (f) => {
|
|
63
|
+
try {
|
|
64
|
+
return fs.statSync(f).size;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
/** Remembers each agent's last look and compares the next one with it. */
|
|
71
|
+
export class BusyWatch {
|
|
72
|
+
looks = new Map();
|
|
73
|
+
/** Compare `t` (agent `id`) with its last look. `table` = one process snapshot (null: the OS wouldn't say). */
|
|
74
|
+
look(id, t, table, now = Date.now()) {
|
|
75
|
+
const kids = table ? descendants(table, t.pid) : [];
|
|
76
|
+
const cpu = new Map(kids.map((p) => [p.pid, p.cpuMs]));
|
|
77
|
+
const before = this.looks.get(id);
|
|
78
|
+
const log = sizeOf(t.logPath);
|
|
79
|
+
this.looks.set(id, { at: now, pid: t.pid, cpu, log });
|
|
80
|
+
if (!before || before.pid !== t.pid)
|
|
81
|
+
return null;
|
|
82
|
+
// A process born since the last look did all its work since then.
|
|
83
|
+
let used = 0;
|
|
84
|
+
let top = null;
|
|
85
|
+
for (const p of kids) {
|
|
86
|
+
const d = p.cpuMs - (before.cpu.get(p.pid) ?? 0);
|
|
87
|
+
if (d <= 0)
|
|
88
|
+
continue;
|
|
89
|
+
used += d;
|
|
90
|
+
if (!top || d > top.ms)
|
|
91
|
+
top = { name: p.name, ms: d };
|
|
92
|
+
}
|
|
93
|
+
if (used >= busyCpuMs(now - before.at))
|
|
94
|
+
return { busy: true, why: `${top?.name || "a child process"} is using CPU (${(used / 1000).toFixed(1)}s since the last look)` };
|
|
95
|
+
if (log > before.log)
|
|
96
|
+
return { busy: true, why: "its log is growing" };
|
|
97
|
+
const f = changedSince(t.worktree, before.at);
|
|
98
|
+
if (f)
|
|
99
|
+
return { busy: true, why: `files are changing (${f})` };
|
|
100
|
+
return { busy: false, why: kids.length ? `${kids.length} child process(es), none using CPU; no files changing` : "no child processes; no files changing" };
|
|
101
|
+
}
|
|
102
|
+
forget(id) {
|
|
103
|
+
this.looks.delete(id);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=busy.js.map
|