huddle-cli 0.1.1 → 0.1.5
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 +66 -0
- package/QUICKSTART.md +63 -2
- package/README.md +15 -0
- package/dist/adapters/codex.js +3 -0
- package/dist/app/live.js +142 -0
- package/dist/app/page.js +775 -0
- package/dist/app/qr.js +47 -0
- package/dist/app/server.js +433 -0
- package/dist/args.js +18 -0
- package/dist/attach/connect.js +3 -3
- package/dist/attach/plug.js +8 -1
- package/dist/cli.js +46 -4
- package/dist/connectors/cli.js +121 -0
- package/dist/connectors/github.js +162 -0
- package/dist/connectors/hub.js +234 -0
- package/dist/connectors/types.js +3 -0
- package/dist/mcp/context-tools.js +2 -0
- package/dist/mcp/server.js +42 -3
- package/dist/orchestrator/brain.js +3 -1
- package/dist/orchestrator/budget.js +283 -0
- package/dist/orchestrator/busy.js +106 -0
- package/dist/orchestrator/council.js +11 -5
- package/dist/orchestrator/launcher.js +3 -1
- package/dist/orchestrator/learn.js +3 -7
- package/dist/orchestrator/planner.js +32 -5
- package/dist/orchestrator/qa.js +9 -1
- package/dist/orchestrator/scheduler.js +105 -19
- package/dist/platform.js +104 -0
- package/dist/room/mail.js +19 -6
- package/dist/room/server.js +82 -2
- package/dist/room/user.js +54 -0
- package/dist/ui/app.js +511 -69
- package/dist/ui/brief.js +494 -0
- package/dist/ui/catalog.js +1 -1
- package/dist/ui/dashboard.js +92 -21
- package/dist/ui/export.js +1194 -0
- package/dist/ui/narrator.js +56 -0
- package/dist/ui/story.js +118 -93
- package/dist/ui/talk.js +176 -0
- package/dist/ui/term.js +31 -0
- package/dist/ui/transcript.js +434 -0
- package/dist/ui/undo.js +131 -0
- package/dist/ui/watch.js +4 -0
- package/dist/update.js +84 -0
- package/package.json +3 -1
package/dist/orchestrator/qa.js
CHANGED
|
@@ -276,6 +276,7 @@ export async function runQa(o) {
|
|
|
276
276
|
approvals: o.approvals,
|
|
277
277
|
worktree: wt,
|
|
278
278
|
});
|
|
279
|
+
o.budget?.track(h.meter, h.agentId);
|
|
279
280
|
}
|
|
280
281
|
catch (e) {
|
|
281
282
|
db.setTaskStatus(task.id, "failed");
|
|
@@ -312,7 +313,7 @@ export async function runQa(o) {
|
|
|
312
313
|
finished++;
|
|
313
314
|
slowest = Math.max(slowest, ms);
|
|
314
315
|
}
|
|
315
|
-
db.publish("qa.done", { agent_id: w.id, task_id: task.id, payload: { round, ok: code === 0, bugs, ms } });
|
|
316
|
+
db.publish("qa.done", { agent_id: w.id, task_id: task.id, payload: { round, ok: code === 0, bugs, ms, tokens: h.meter.tokens } });
|
|
316
317
|
onEvent({ type: "checked", agent: w.id, round, ok: code === 0, bugs, ms, tokens: h.meter.tokens, reason, task: task.id });
|
|
317
318
|
resolve();
|
|
318
319
|
};
|
|
@@ -323,6 +324,13 @@ export async function runQa(o) {
|
|
|
323
324
|
const prev = room.bugs;
|
|
324
325
|
try {
|
|
325
326
|
for (round = 1; round <= rounds && !signal?.aborted; round++) {
|
|
327
|
+
if (o.budget?.stopping) {
|
|
328
|
+
result.budget = true;
|
|
329
|
+
const reason = `the token budget is nearly spent — QA round ${round} not started`;
|
|
330
|
+
db.publish("qa.skipped", { payload: { round, reason } });
|
|
331
|
+
onEvent({ type: "skipped", reason });
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
326
334
|
roundBugs = 0;
|
|
327
335
|
finished = 0;
|
|
328
336
|
slowest = 0;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// (dependencies must be merged first). When its process exits, the merge engine
|
|
5
5
|
// runs the test gate; in manual mode you approve the merge; landing it can
|
|
6
6
|
// unblock more tasks, so we fill again. Nobody idles while there's work.
|
|
7
|
+
import { userBrief } from "../room/user.js";
|
|
7
8
|
import { launchAgent, stopChild } from "./launcher.js";
|
|
8
9
|
import { prepareMerge, landMerge, removeWorktree, branchDiff, wipDiff, worktreeFor, createWorktree, beginConflictMerge, conflictBrief, finishConflictMerge, abortConflictMerge, } from "../sandbox/worktree.js";
|
|
9
10
|
import { fetchBranch, deleteRemoteBranch } from "../sandbox/sync.js";
|
|
@@ -15,7 +16,8 @@ import { helpOffer, alongsideBrief, addSplitPart, taskViews } from "../room/prog
|
|
|
15
16
|
import { contractBrief, contractNames, getContract } from "../room/contract.js";
|
|
16
17
|
import { notifyAgent } from "../room/mail.js";
|
|
17
18
|
import { closeNotes, openNotes, resolveNotes, trackNote } from "../room/followup.js";
|
|
18
|
-
import { onFinished } from "../platform.js";
|
|
19
|
+
import { onFinished, processTable } from "../platform.js";
|
|
20
|
+
import { BusyWatch, silenceMaxMsFromEnv } from "./busy.js";
|
|
19
21
|
import { startRadar } from "./radar.js";
|
|
20
22
|
const prefersOf = (w) => w.adapter?.prefers ?? w.remote?.prefers ?? [];
|
|
21
23
|
/** No help offers on a task whose reported progress is this far along. */
|
|
@@ -51,12 +53,32 @@ HOW:
|
|
|
51
53
|
resolution and re-runs the tests before anything lands.
|
|
52
54
|
4. huddle_remember(kind="decision", body=<how you combined them>, why=...), then huddle_done(task_id, summary).`;
|
|
53
55
|
}
|
|
54
|
-
|
|
56
|
+
/** What a task's author read through connectors (a ticket, an issue), for a reviewer who has no tools. */
|
|
57
|
+
export function outsideReads(db, taskId, max = 8000) {
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
const parts = [];
|
|
60
|
+
for (const e of db.eventsOfType(["connector.call"], 0, 500)) {
|
|
61
|
+
if (e.task_id !== taskId)
|
|
62
|
+
continue;
|
|
63
|
+
const p = JSON.parse(e.payload || "{}");
|
|
64
|
+
if (p.ok === false || !p.result)
|
|
65
|
+
continue;
|
|
66
|
+
const key = `${p.tool} ${JSON.stringify(p.args ?? {})}`;
|
|
67
|
+
if (seen.has(key))
|
|
68
|
+
continue;
|
|
69
|
+
seen.add(key);
|
|
70
|
+
parts.push(`- ${key}:\n${p.result}`);
|
|
71
|
+
}
|
|
72
|
+
if (!parts.length)
|
|
73
|
+
return "";
|
|
74
|
+
return cap(`WHAT THE AUTHOR READ FROM OUTSIDE TOOLS (verbatim — the ticket/issue the task refers to):\n${parts.join("\n")}`, max);
|
|
75
|
+
}
|
|
76
|
+
function reviewPrompt(title, spec, diff, contract = "", outside = "") {
|
|
55
77
|
return `You are reviewing a teammate's change before Huddle merges it. Another AI agent wrote it.
|
|
56
78
|
|
|
57
79
|
TASK: ${title}
|
|
58
80
|
${spec}
|
|
59
|
-
${contract ? `\n${cap(contract, 2500)}\n` : ""}
|
|
81
|
+
${contract ? `\n${cap(contract, 2500)}\n` : ""}${outside ? `\n${outside}\n` : ""}
|
|
60
82
|
DIFF (against main):
|
|
61
83
|
${diff || "(empty diff)"}
|
|
62
84
|
|
|
@@ -155,6 +177,10 @@ export async function runBoard(opts) {
|
|
|
155
177
|
signal?.addEventListener("abort", killAll);
|
|
156
178
|
// If Huddle itself exits, never leave orphaned agents working for nobody.
|
|
157
179
|
process.once("exit", killAll);
|
|
180
|
+
// The token budget: past its soft line nothing new starts (tasks, takeovers, buddy checks, pairings);
|
|
181
|
+
// what's running finishes. Its hard line aborts the run's signal (the caller wires that, as Ctrl-C does).
|
|
182
|
+
const overBudget = () => !!opts.budget?.stopping;
|
|
183
|
+
const stoppedWhy = () => (opts.budget?.level === "hard" ? `stopped: the token budget ran over (${Math.round(opts.budget.used * 100)}%)` : "stopped by you");
|
|
158
184
|
// Agents held out of fill(): an author waiting on review, a reviewer reviewing.
|
|
159
185
|
const reserved = new Set();
|
|
160
186
|
const reviewed = new Set(); // task ids that already had their one review round
|
|
@@ -269,6 +295,7 @@ export async function runBoard(opts) {
|
|
|
269
295
|
worktree: wt,
|
|
270
296
|
onTokens: (tokens) => onEvent({ type: "tokens", agent: r.id, task: task.id, tokens }),
|
|
271
297
|
});
|
|
298
|
+
opts.budget?.track(h2.meter, r.id);
|
|
272
299
|
db.setTaskStatus(task.id, "in_progress");
|
|
273
300
|
db.setAgentStatus(r.id, "working", wt.path);
|
|
274
301
|
running.set(r.id, h2);
|
|
@@ -288,7 +315,7 @@ export async function runBoard(opts) {
|
|
|
288
315
|
// Both agents' Plug wiring may sit in this worktree; neither may be committed.
|
|
289
316
|
const h = { ...h2, excludes: [...new Set([...c.h.excludes, ...h2.excludes])] };
|
|
290
317
|
const res = signal?.aborted
|
|
291
|
-
? { ok: false, reason:
|
|
318
|
+
? { ok: false, reason: stoppedWhy() }
|
|
292
319
|
: code === 0
|
|
293
320
|
? finishConflictMerge(wt, cs, h.excludes)
|
|
294
321
|
: { ok: false, reason: `resolver exited with code ${code}` };
|
|
@@ -315,10 +342,17 @@ export async function runBoard(opts) {
|
|
|
315
342
|
const taskWt = new Map(); // task id -> its worktree, whoever works it
|
|
316
343
|
const lastSeen = new Map(); // agent id -> its last output or huddle_* call
|
|
317
344
|
const silenced = new Map(); // task id -> why we stopped its agent
|
|
345
|
+
// Quiet isn't stuck: a long test run prints nothing (busy.ts). Busy restarts the silence clock, up to a cap.
|
|
346
|
+
const silenceMaxMs = Math.max(silenceMs, opts.silenceMaxMs ?? silenceMaxMsFromEnv(silenceMs));
|
|
347
|
+
const busyWatch = new BusyWatch();
|
|
348
|
+
const lastBusy = new Map(); // agent id -> the last look that found it working
|
|
349
|
+
const busyTold = new Set(); // launches whose "busy, not silent" was reported
|
|
318
350
|
/** Start listening for signs of life from a freshly launched agent. */
|
|
319
351
|
const heard = (id, h) => {
|
|
320
352
|
const bump = () => lastSeen.set(id, Date.now());
|
|
321
353
|
bump();
|
|
354
|
+
lastBusy.delete(id);
|
|
355
|
+
busyWatch.forget(id);
|
|
322
356
|
h.child?.stdout?.on("data", bump);
|
|
323
357
|
h.child?.stderr?.on("data", bump);
|
|
324
358
|
};
|
|
@@ -392,16 +426,56 @@ export async function runBoard(opts) {
|
|
|
392
426
|
});
|
|
393
427
|
start(r, task, brief, rc.from.id);
|
|
394
428
|
};
|
|
395
|
-
/**
|
|
429
|
+
/**
|
|
430
|
+
* Stop agents that have gone quiet; they're taken over in finish(). Quiet =
|
|
431
|
+
* no output and no huddle_* call. Past half the window Huddle starts looking
|
|
432
|
+
* at what the agent is doing (busy.ts); a look that finds it working counts
|
|
433
|
+
* as a sign of life, so only an agent quiet AND idle for the whole window is
|
|
434
|
+
* silent — or one quiet past the cap, working or not.
|
|
435
|
+
*/
|
|
436
|
+
const span = (ms) => (ms >= 60_000 ? `${Math.round(ms / 60_000)} min` : `${Math.round(ms / 1000)}s`);
|
|
396
437
|
const watchSilence = () => {
|
|
438
|
+
let table; // one process snapshot per tick, only when someone is quiet
|
|
439
|
+
const now = Date.now();
|
|
397
440
|
for (const [id, h] of running) {
|
|
398
441
|
if (!h.child || silenced.has(h.taskId) || h.child.exitCode !== null || h.child.signalCode !== null)
|
|
399
442
|
continue;
|
|
400
|
-
const quiet =
|
|
401
|
-
if (quiet < silenceMs)
|
|
443
|
+
const quiet = now - Math.max(lastSeen.get(id) ?? 0, h.startedAt);
|
|
444
|
+
if (quiet < silenceMs / 2) {
|
|
445
|
+
busyWatch.forget(id); // it spoke: the next quiet spell compares from a fresh look
|
|
402
446
|
continue;
|
|
403
|
-
|
|
404
|
-
|
|
447
|
+
}
|
|
448
|
+
let firstLook = false;
|
|
449
|
+
if (quiet < silenceMaxMs && h.child.pid) {
|
|
450
|
+
if (table === undefined) {
|
|
451
|
+
try {
|
|
452
|
+
table = (opts.processTable ?? processTable)();
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
table = null;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const v = busyWatch.look(id, { pid: h.child.pid, worktree: h.worktree, logPath: h.logPath }, table, now);
|
|
459
|
+
firstLook = v === null; // nothing to compare with yet: decide on the next tick
|
|
460
|
+
if (v?.busy) {
|
|
461
|
+
lastBusy.set(id, now);
|
|
462
|
+
if (quiet >= silenceMs && !busyTold.has(h)) {
|
|
463
|
+
busyTold.add(h);
|
|
464
|
+
const t = db.getTask(h.taskId);
|
|
465
|
+
onEvent({ type: "busy", agent: id, task: h.taskId, title: t?.title ?? h.taskId, quietMs: quiet, why: v.why });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const idle = now - Math.max(lastSeen.get(id) ?? 0, lastBusy.get(id) ?? 0, h.startedAt);
|
|
470
|
+
if (quiet >= silenceMaxMs)
|
|
471
|
+
silenced.set(h.taskId, busyTold.has(h)
|
|
472
|
+
? `went silent — no output and no huddle_* call for ${span(quiet)}; it looked busy (a long command?), but that is past the ${span(silenceMaxMs)} cap`
|
|
473
|
+
: `went silent — no output and no huddle_* call for ${span(quiet)}`);
|
|
474
|
+
else if (idle >= silenceMs && !firstLook)
|
|
475
|
+
silenced.set(h.taskId, `went silent — no output and no huddle_* call for ${span(quiet)}, and nothing running (no child using CPU, no files changing)`);
|
|
476
|
+
else
|
|
477
|
+
continue;
|
|
478
|
+
busyWatch.forget(id);
|
|
405
479
|
stopChild(h.child);
|
|
406
480
|
}
|
|
407
481
|
};
|
|
@@ -445,7 +519,7 @@ export async function runBoard(opts) {
|
|
|
445
519
|
.sort((a, b) => a.busy - b.busy || (buddyBy.get(a.w.id) ?? 0) - (buddyBy.get(b.w.id) ?? 0) || a.i - b.i)[0]?.w;
|
|
446
520
|
/** A teammate reads the work in progress and its notes land in the author's inbox now. Never blocks the author. */
|
|
447
521
|
const buddyCheck = async (taskId, pct, silentMs = 0) => {
|
|
448
|
-
if (!buddyOn || resolved || signal?.aborted || buddyBusy.has(taskId) || resolving.has(taskId))
|
|
522
|
+
if (!buddyOn || resolved || signal?.aborted || buddyBusy.has(taskId) || resolving.has(taskId) || overBudget())
|
|
449
523
|
return;
|
|
450
524
|
const t = db.getTask(taskId);
|
|
451
525
|
if (!t?.claimed_by || isSpecialTask(t))
|
|
@@ -520,7 +594,7 @@ export async function runBoard(opts) {
|
|
|
520
594
|
};
|
|
521
595
|
/** Tasks whose author never got to 40% in huddle_progress still get a look mid-task, after buddyAfterMs. */
|
|
522
596
|
const buddyBySilence = () => {
|
|
523
|
-
if (buddyAfterMs === null || !buddyOn)
|
|
597
|
+
if (buddyAfterMs === null || !buddyOn || overBudget())
|
|
524
598
|
return;
|
|
525
599
|
const now = Date.now();
|
|
526
600
|
for (const h of running.values()) {
|
|
@@ -692,7 +766,7 @@ export async function runBoard(opts) {
|
|
|
692
766
|
* sends the old help offer, which leaves the split to the busy agent.
|
|
693
767
|
*/
|
|
694
768
|
const offerHelp = () => {
|
|
695
|
-
if (helpAfterMs === null || signal?.aborted || conflicts.length || rescues.length)
|
|
769
|
+
if (helpAfterMs === null || signal?.aborted || conflicts.length || rescues.length || overBudget())
|
|
696
770
|
return;
|
|
697
771
|
const helper = workers.find((x) => x.adapter && !running.has(x.id) && !reserved.has(x.id) && !pairing.has(x.id) && !db.nextBacklogTask(prefersOf(x)));
|
|
698
772
|
if (!helper)
|
|
@@ -801,6 +875,7 @@ export async function runBoard(opts) {
|
|
|
801
875
|
}
|
|
802
876
|
});
|
|
803
877
|
const tick = setInterval(() => {
|
|
878
|
+
opts.budget?.check();
|
|
804
879
|
watchSilence();
|
|
805
880
|
offerHelp();
|
|
806
881
|
buddyBySilence();
|
|
@@ -861,7 +936,7 @@ export async function runBoard(opts) {
|
|
|
861
936
|
reserved.delete(w.id);
|
|
862
937
|
}
|
|
863
938
|
if (signal?.aborted) {
|
|
864
|
-
fail(w, h, task.title,
|
|
939
|
+
fail(w, h, task.title, stoppedWhy(), workMs);
|
|
865
940
|
}
|
|
866
941
|
else if (exitCode !== 0) {
|
|
867
942
|
failOrRescue(w, h, task, quiet ?? why ?? `agent exited with code ${exitCode}`, workMs);
|
|
@@ -930,7 +1005,7 @@ export async function runBoard(opts) {
|
|
|
930
1005
|
onEvent({ type: "review", agent: reviewer.id, author: w.id, task: task.id, title: task.title });
|
|
931
1006
|
fill(); // everyone else keeps going
|
|
932
1007
|
const t0 = Date.now();
|
|
933
|
-
const res = await askAgent(reviewer.adapter, reviewPrompt(task.title, task.spec, branchDiff(repoRoot, wt), contract()), wt.path, {
|
|
1008
|
+
const res = await askAgent(reviewer.adapter, reviewPrompt(task.title, task.spec, branchDiff(repoRoot, wt), contract(), outsideReads(db, task.id)), wt.path, {
|
|
934
1009
|
signal,
|
|
935
1010
|
timeoutMs: 90_000,
|
|
936
1011
|
noTools: true, // the diff is in the prompt — no need to explore
|
|
@@ -1008,6 +1083,7 @@ export async function runBoard(opts) {
|
|
|
1008
1083
|
startedAt: Date.now(),
|
|
1009
1084
|
meter: new UsageMeter(),
|
|
1010
1085
|
};
|
|
1086
|
+
opts.budget?.track(h.meter, h.agentId);
|
|
1011
1087
|
db.setTaskStatus(task.id, "in_progress");
|
|
1012
1088
|
db.setAgentStatus(w.id, "working");
|
|
1013
1089
|
running.set(w.id, h);
|
|
@@ -1056,7 +1132,9 @@ export async function runBoard(opts) {
|
|
|
1056
1132
|
}
|
|
1057
1133
|
// The contract as it stands now (amendments included): each launch is a fresh process.
|
|
1058
1134
|
const cb = contractBrief(db);
|
|
1059
|
-
|
|
1135
|
+
// What the user said mid-run (room/user.ts) outranks the plan and the contract.
|
|
1136
|
+
const ub = userBrief(db);
|
|
1137
|
+
const contract = (cb ? `\n\n${cb}` : "") + (ub ? `\n\n${ub}` : "");
|
|
1060
1138
|
if (w.remote)
|
|
1061
1139
|
return void startRemote(w, { ...task, spec: task.spec + contract + ho.text }, extra);
|
|
1062
1140
|
// Who else is mid-task right now, so this agent knows whom to tell (first round only;
|
|
@@ -1079,6 +1157,7 @@ export async function runBoard(opts) {
|
|
|
1079
1157
|
});
|
|
1080
1158
|
// Every agent's Plug wiring that sat in this worktree stays out of the commit.
|
|
1081
1159
|
const h = { ...launched, excludes: [...new Set([...(prior?.excludes ?? []), ...launched.excludes])] };
|
|
1160
|
+
opts.budget?.track(h.meter, h.agentId);
|
|
1082
1161
|
taskWt.set(task.id, { branch: h.branch, path: h.worktree, excludes: h.excludes });
|
|
1083
1162
|
db.setTaskStatus(task.id, "in_progress");
|
|
1084
1163
|
db.setAgentStatus(w.id, "working", h.worktree);
|
|
@@ -1100,7 +1179,7 @@ export async function runBoard(opts) {
|
|
|
1100
1179
|
pending--;
|
|
1101
1180
|
holdFor.delete(x.task.id);
|
|
1102
1181
|
if (signal?.aborted)
|
|
1103
|
-
fail(x.w, x.h, x.task.title,
|
|
1182
|
+
fail(x.w, x.h, x.task.title, stoppedWhy(), x.workMs);
|
|
1104
1183
|
// Tests for code that never landed would only go red at the gate and burn a fix round.
|
|
1105
1184
|
else if (testsOf.has(x.task.id) && db.getTask(x.after)?.status !== "merged")
|
|
1106
1185
|
fail(x.w, x.h, x.task.title, `the task it tests (“${db.getTask(x.after)?.title ?? x.after}”) didn't land — work kept on branch ${x.h.branch}`, x.workMs);
|
|
@@ -1115,7 +1194,7 @@ export async function runBoard(opts) {
|
|
|
1115
1194
|
// Conflicts first: they hold finished work, and the author is usually free.
|
|
1116
1195
|
for (const c of conflicts.splice(0)) {
|
|
1117
1196
|
if (signal?.aborted) {
|
|
1118
|
-
parkForYou(c,
|
|
1197
|
+
parkForYou(c, stoppedWhy());
|
|
1119
1198
|
continue;
|
|
1120
1199
|
}
|
|
1121
1200
|
const r = pickResolver(c.author, resolving.get(c.taskId)?.by ?? []);
|
|
@@ -1131,13 +1210,17 @@ export async function runBoard(opts) {
|
|
|
1131
1210
|
fail(rc.from, rc.h, title, `${rc.reason} (stopped before another agent could take it over)`, 0);
|
|
1132
1211
|
continue;
|
|
1133
1212
|
}
|
|
1213
|
+
if (overBudget()) {
|
|
1214
|
+
fail(rc.from, rc.h, title, `${rc.reason} (not taken over: the token budget is nearly spent)`, 0);
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1134
1217
|
const r = pickRescuer(rc);
|
|
1135
1218
|
if (r)
|
|
1136
1219
|
startRescue(r, rc);
|
|
1137
1220
|
else
|
|
1138
1221
|
rescues.push(rc); // the next other agent to finish takes it
|
|
1139
1222
|
}
|
|
1140
|
-
if (!signal?.aborted) {
|
|
1223
|
+
if (!signal?.aborted && !overBudget()) {
|
|
1141
1224
|
// Fairness: the free agent that has taken the fewest tasks goes first
|
|
1142
1225
|
// (roster order breaks ties), so one agent doesn't take every task that
|
|
1143
1226
|
// unblocks just because it's listed first.
|
|
@@ -1196,7 +1279,10 @@ export async function runBoard(opts) {
|
|
|
1196
1279
|
for (const rc of rescues.splice(0))
|
|
1197
1280
|
fail(rc.from, rc.h, db.getTask(rc.taskId).title, `${rc.reason} (no other agent could take it over)`, 0);
|
|
1198
1281
|
const blocked = db.listTasks().filter((t) => t.status === "backlog").map((t) => t.title);
|
|
1199
|
-
|
|
1282
|
+
const b = opts.budget;
|
|
1283
|
+
if (b?.limit && b.level !== "ok")
|
|
1284
|
+
onEvent({ type: "budget", level: b.level === "hard" ? "hard" : "soft", tokens: b.tokens, limit: b.limit, left: blocked });
|
|
1285
|
+
else if (blocked.length && !signal?.aborted)
|
|
1200
1286
|
onEvent({ type: "stuck", blocked });
|
|
1201
1287
|
done();
|
|
1202
1288
|
}
|
package/dist/platform.js
CHANGED
|
@@ -270,4 +270,108 @@ export function nodeFix(platform = process.platform) {
|
|
|
270
270
|
return "brew install node · https://nodejs.org";
|
|
271
271
|
return "https://nodejs.org (or your package manager / nvm)";
|
|
272
272
|
}
|
|
273
|
+
/** `ps` CPU time: "1:23.45" (macOS m:ss.cc), "00:01:23" / "1-02:03:04" (Linux [dd-]hh:mm:ss). */
|
|
274
|
+
export function psTimeMs(t) {
|
|
275
|
+
const m = /^(?:(\d+)-)?([\d:.]+)$/.exec(t.trim());
|
|
276
|
+
if (!m)
|
|
277
|
+
return 0;
|
|
278
|
+
const parts = m[2].split(":").map(Number);
|
|
279
|
+
if (parts.some((p) => !Number.isFinite(p)))
|
|
280
|
+
return 0;
|
|
281
|
+
const secs = parts.reduce((acc, p) => acc * 60 + p, 0);
|
|
282
|
+
return Math.round((Number(m[1] ?? 0) * 86_400 + secs) * 1000);
|
|
283
|
+
}
|
|
284
|
+
/** `ps -A -o pid= -o ppid= -o time= -o comm=` output. */
|
|
285
|
+
export function parsePs(text) {
|
|
286
|
+
const out = [];
|
|
287
|
+
for (const line of text.split(/\r?\n/)) {
|
|
288
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s*(.*)$/.exec(line);
|
|
289
|
+
if (m)
|
|
290
|
+
out.push({ pid: Number(m[1]), ppid: Number(m[2]), cpuMs: psTimeMs(m[3]), name: path.posix.basename(m[4].trim()) });
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
/** One /proc/<pid>/stat line: "pid (comm) state ppid … utime stime …", times in clock ticks (100/s). */
|
|
295
|
+
export function parseProcStat(line, hz = 100) {
|
|
296
|
+
const close = line.lastIndexOf(")");
|
|
297
|
+
const open = line.indexOf("(");
|
|
298
|
+
if (open < 0 || close < open)
|
|
299
|
+
return null;
|
|
300
|
+
const f = line.slice(close + 2).trim().split(/\s+/); // f[0] = state (field 3)
|
|
301
|
+
const pid = Number(line.slice(0, open).trim());
|
|
302
|
+
const ppid = Number(f[1]);
|
|
303
|
+
const ticks = Number(f[11]) + Number(f[12]); // utime (14), stime (15)
|
|
304
|
+
if (!Number.isFinite(pid) || !Number.isFinite(ppid) || !Number.isFinite(ticks))
|
|
305
|
+
return null;
|
|
306
|
+
return { pid, ppid, cpuMs: Math.round((ticks * 1000) / hz), name: line.slice(open + 1, close) };
|
|
307
|
+
}
|
|
308
|
+
/** The PowerShell line that lists every process: "pid,ppid,cpu100ns,name" per line. */
|
|
309
|
+
export const WIN_PROCESS_QUERY = "Get-CimInstance Win32_Process | ForEach-Object { '{0},{1},{2},{3}' -f $_.ProcessId,$_.ParentProcessId,([uint64]$_.KernelModeTime + [uint64]$_.UserModeTime),$_.Name }";
|
|
310
|
+
/** WIN_PROCESS_QUERY's output (CRLF or LF; times are 100-ns units). */
|
|
311
|
+
export function parseWinProcesses(text) {
|
|
312
|
+
const out = [];
|
|
313
|
+
for (const line of text.split(/\r?\n/)) {
|
|
314
|
+
const m = /^\s*(\d+),(\d+),(\d*),(.*?)\s*$/.exec(line);
|
|
315
|
+
if (m)
|
|
316
|
+
out.push({ pid: Number(m[1]), ppid: Number(m[2]), cpuMs: Math.round(Number(m[3] || 0) / 10_000), name: m[4] });
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Every process on this machine, or null when the OS won't say. Linux reads
|
|
322
|
+
* /proc (no spawn, 10ms resolution); macOS runs ps; Windows asks PowerShell
|
|
323
|
+
* for Win32_Process. `run` is injectable for tests.
|
|
324
|
+
*/
|
|
325
|
+
export function processTable(h = process, run = spawnSync) {
|
|
326
|
+
if (isWindows(h)) {
|
|
327
|
+
const r = run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", WIN_PROCESS_QUERY], { encoding: "utf8", timeout: 15_000, windowsHide: true });
|
|
328
|
+
const list = r.status === 0 ? parseWinProcesses(String(r.stdout ?? "")) : [];
|
|
329
|
+
return list.length ? list : null;
|
|
330
|
+
}
|
|
331
|
+
if (h.platform === "linux") {
|
|
332
|
+
try {
|
|
333
|
+
const out = [];
|
|
334
|
+
for (const d of fs.readdirSync("/proc")) {
|
|
335
|
+
if (!/^\d+$/.test(d))
|
|
336
|
+
continue;
|
|
337
|
+
try {
|
|
338
|
+
const p = parseProcStat(fs.readFileSync(`/proc/${d}/stat`, "utf8"));
|
|
339
|
+
if (p)
|
|
340
|
+
out.push(p);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
/* it exited while we looked */
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (out.length)
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
/* no /proc: try ps */
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const r = run("ps", ["-A", "-o", "pid=", "-o", "ppid=", "-o", "time=", "-o", "comm="], { encoding: "utf8", timeout: 10_000 });
|
|
354
|
+
const list = r.status === 0 ? parsePs(String(r.stdout ?? "")) : [];
|
|
355
|
+
return list.length ? list : null;
|
|
356
|
+
}
|
|
357
|
+
/** Every process below `root` (children, grandchildren, …), not `root` itself. */
|
|
358
|
+
export function descendants(table, root) {
|
|
359
|
+
const kids = new Map();
|
|
360
|
+
for (const p of table)
|
|
361
|
+
if (p.pid !== p.ppid)
|
|
362
|
+
kids.set(p.ppid, [...(kids.get(p.ppid) ?? []), p]);
|
|
363
|
+
const out = [];
|
|
364
|
+
const seen = new Set([root]);
|
|
365
|
+
const stack = [root];
|
|
366
|
+
while (stack.length) {
|
|
367
|
+
for (const c of kids.get(stack.pop()) ?? []) {
|
|
368
|
+
if (seen.has(c.pid))
|
|
369
|
+
continue;
|
|
370
|
+
seen.add(c.pid);
|
|
371
|
+
out.push(c);
|
|
372
|
+
stack.push(c.pid);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return out;
|
|
376
|
+
}
|
|
273
377
|
//# sourceMappingURL=platform.js.map
|
package/dist/room/mail.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// mail read mid-task from mail read at the end — midTaskDelivery.
|
|
15
15
|
import fs from "node:fs";
|
|
16
16
|
import http from "node:http";
|
|
17
|
+
import { fromLabel } from "./user.js";
|
|
17
18
|
/** At most this many bodies ride on one result; the rest wait for the next. */
|
|
18
19
|
export const MAIL_BATCH = 5;
|
|
19
20
|
/** Each body is cut to this many characters; huddle_inbox(id) has the rest. */
|
|
@@ -29,7 +30,7 @@ export function mailView(m) {
|
|
|
29
30
|
const end = cut && /[\uD800-\uDBFF]/.test(m.body[MAIL_CHARS - 1]) ? MAIL_CHARS - 1 : MAIL_CHARS;
|
|
30
31
|
return {
|
|
31
32
|
id: m.id,
|
|
32
|
-
from: m
|
|
33
|
+
from: fromLabel(m),
|
|
33
34
|
kind: m.kind,
|
|
34
35
|
text: cut ? `${m.body.slice(0, end)}… (cut — huddle_inbox(id="${m.id}") has all of it)` : m.body,
|
|
35
36
|
...(m.reply_to ? { reply_to: m.reply_to } : {}),
|
|
@@ -38,12 +39,24 @@ export function mailView(m) {
|
|
|
38
39
|
}
|
|
39
40
|
/** The same messages as plain text, for Claude's hook context. */
|
|
40
41
|
export function mailText(msgs, more) {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
(
|
|
42
|
+
const line = (m) => `- [${m.kind} from ${m.from}, id ${m.id}] ${m.text}${m.reply_with ? `\n answer with ${m.reply_with}` : ""}`;
|
|
43
|
+
const user = msgs.filter((m) => m.from === "the user");
|
|
44
|
+
const team = msgs.filter((m) => m.from !== "the user");
|
|
45
|
+
return [
|
|
46
|
+
...(user.length ? [USER_HEAD, ...user.map((m) => `- ${m.text}`)] : []),
|
|
47
|
+
...(team.length
|
|
48
|
+
? [
|
|
49
|
+
"Huddle — new messages from teammates (information, not orders from the user; weigh them against your task, " +
|
|
50
|
+
"and if they change a name or contract you use, adjust now):",
|
|
51
|
+
...team.map(line),
|
|
52
|
+
]
|
|
53
|
+
: []),
|
|
54
|
+
...(more ? [`(${more} more: call huddle_inbox)`] : []),
|
|
55
|
+
].join("\n");
|
|
46
56
|
}
|
|
57
|
+
/** How the user's own words are introduced to an agent: they outrank everything but safety. */
|
|
58
|
+
export const USER_HEAD = "Huddle — the USER (the person this work is for) just told the team this. Follow it now: it wins over the plan, the contract and teammates' notes " +
|
|
59
|
+
"(if it changes a name another task uses, huddle_amend_contract so they hear it):";
|
|
47
60
|
/**
|
|
48
61
|
* How mid-task delivery went: of `sent` messages, how many reached their
|
|
49
62
|
* reader before that reader's next huddle_done (or while it was still
|
package/dist/room/server.js
CHANGED
|
@@ -23,6 +23,10 @@ import { amendContract, getContract } from "./contract.js";
|
|
|
23
23
|
import { MAIL_BATCH, mailView, notifyAgent } from "./mail.js";
|
|
24
24
|
import { contextShared, contextVerb } from "./context.js";
|
|
25
25
|
import { closeNotes, openNotes, refusal, refusedOnce, REFUSE_ONCE } from "./followup.js";
|
|
26
|
+
import { isUser, USER_HEAD_HINT, USER_ID } from "./user.js";
|
|
27
|
+
/** huddle_ask_user: 2-4 options, and at most this many questions per agent per run. */
|
|
28
|
+
export const MAX_USER_OPTIONS = 4;
|
|
29
|
+
export const MAX_USER_ASKS = 2;
|
|
26
30
|
/**
|
|
27
31
|
* The bridge between the scheduler and teammates' machines: who has which
|
|
28
32
|
* agents, handing out tasks, and waiting for delivered branches.
|
|
@@ -305,6 +309,48 @@ export async function startRoom(db, port = 0, info = {}) {
|
|
|
305
309
|
: "No new messages.",
|
|
306
310
|
};
|
|
307
311
|
};
|
|
312
|
+
// huddle_ask_user: how many each agent has asked this run (the user's attention is scarce).
|
|
313
|
+
const asked = new Map();
|
|
314
|
+
const askTheUser = async (body, agentId) => {
|
|
315
|
+
const question = String(body.question ?? "").replace(/\s+/g, " ").trim().slice(0, 400);
|
|
316
|
+
const options = (Array.isArray(body.options) ? body.options : [])
|
|
317
|
+
.map((o) => String(o ?? "").replace(/\s+/g, " ").trim().slice(0, 160))
|
|
318
|
+
.filter(Boolean)
|
|
319
|
+
.slice(0, MAX_USER_OPTIONS);
|
|
320
|
+
if (!question || options.length < 2)
|
|
321
|
+
return { ok: false, error: `give a question and 2-${MAX_USER_OPTIONS} options, the one you recommend first` };
|
|
322
|
+
const key = `${db.currentSession()?.id ?? ""}:${agentId}`;
|
|
323
|
+
const n = (asked.get(key) ?? 0) + 1;
|
|
324
|
+
const pick = (a) => {
|
|
325
|
+
const chose = a.choice ? options[a.choice - 1] : a.text;
|
|
326
|
+
db.publish("user.answer", { agent_id: agentId, payload: { from: agentId, question, answer: chose, choice: a.choice, by: a.by } });
|
|
327
|
+
if (a.by === "you")
|
|
328
|
+
db.remember({ kind: "decision", body: `The user decided — ${question} → ${chose}`, author: USER_ID });
|
|
329
|
+
else
|
|
330
|
+
db.remember({ kind: "decision", body: `${agentId} asked the user "${question}"; no answer in time, so: ${chose}`, author: "huddle" });
|
|
331
|
+
return {
|
|
332
|
+
answer: chose,
|
|
333
|
+
choice: a.choice,
|
|
334
|
+
from: a.by === "you" ? "the user" : "default",
|
|
335
|
+
note: a.by === "you"
|
|
336
|
+
? "The user answered. Follow it; it's in shared memory for your teammates too."
|
|
337
|
+
: `No answer from the user in time, so go with ${a.choice ? `option ${a.choice}` : "your recommendation"}. If they answer later it arrives as a message from the user — then adjust.`,
|
|
338
|
+
};
|
|
339
|
+
};
|
|
340
|
+
if (n > MAX_USER_ASKS)
|
|
341
|
+
return { ...pick({ choice: 1, text: "", by: "default" }), note: `You've asked the user ${MAX_USER_ASKS} times this run; decide yourself from here on. Going with option 1.` };
|
|
342
|
+
asked.set(key, n);
|
|
343
|
+
db.publish("user.ask", { agent_id: agentId, payload: { from: agentId, question, options, why: String(body.why ?? "").slice(0, 300) } });
|
|
344
|
+
if (!room.askUser)
|
|
345
|
+
return pick({ choice: 1, text: "", by: "default" });
|
|
346
|
+
const why = String(body.why ?? "").replace(/\s+/g, " ").trim().slice(0, 300);
|
|
347
|
+
try {
|
|
348
|
+
return pick(await room.askUser({ id: crypto.randomBytes(3).toString("hex"), from: agentId, question, options, ...(why ? { why } : {}), waitMs: room.askUserMs }));
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return pick({ choice: 1, text: "", by: "default" });
|
|
352
|
+
}
|
|
353
|
+
};
|
|
308
354
|
/**
|
|
309
355
|
* Agent verbs, plus push delivery: headless agents can't be interrupted, so
|
|
310
356
|
* every tool result carries the teammates' messages themselves (up to
|
|
@@ -319,13 +365,14 @@ export async function startRoom(db, port = 0, info = {}) {
|
|
|
319
365
|
return res;
|
|
320
366
|
const msgs = db.inbox(agentId, { limit: MAIL_BATCH, how: "piggyback" });
|
|
321
367
|
const more = u.count - msgs.length;
|
|
322
|
-
const from = [...new Set(msgs.map((m) => m.from_agent))];
|
|
368
|
+
const from = [...new Set(msgs.map((m) => (isUser(m.from_agent) ? "the user" : m.from_agent)))];
|
|
323
369
|
return {
|
|
324
370
|
status: res.status,
|
|
325
371
|
body: {
|
|
326
372
|
unread_messages: u.count,
|
|
327
373
|
new_messages: msgs.map(mailView),
|
|
328
|
-
inbox_hint:
|
|
374
|
+
inbox_hint: (msgs.some((m) => isUser(m.from_agent)) ? USER_HEAD_HINT : "") +
|
|
375
|
+
`${msgs.length} new message${msgs.length > 1 ? "s" : ""} from ${from.join(", ")} in new_messages — read them now; if one changes a name or contract you use, adjust before going on.` +
|
|
329
376
|
(more ? ` ${more} more: call huddle_inbox.` : "") +
|
|
330
377
|
// Found live (capstone round 3): Claude wrote the README without one
|
|
331
378
|
// tool call mid-task, so both teammates' format contracts reached it
|
|
@@ -543,6 +590,8 @@ export async function startRoom(db, port = 0, info = {}) {
|
|
|
543
590
|
if (!room.bugs)
|
|
544
591
|
return { status: 200, body: { ok: false, note: "No QA round is running, so nothing was filed. Tell the owner with huddle_send, or fix it in your own task." } };
|
|
545
592
|
return { status: 200, body: room.bugs(body, agentId) };
|
|
593
|
+
case "ask_user":
|
|
594
|
+
return { status: 200, body: await askTheUser(body, agentId) };
|
|
546
595
|
case "ask": {
|
|
547
596
|
const question = String(body.question ?? "").slice(0, 2000);
|
|
548
597
|
let to = body.to ? String(body.to) : null;
|
|
@@ -610,6 +659,7 @@ export async function startRoom(db, port = 0, info = {}) {
|
|
|
610
659
|
"POST /done": "done",
|
|
611
660
|
"POST /take_backlog": "take_backlog",
|
|
612
661
|
"POST /ask": "ask",
|
|
662
|
+
"POST /ask_user": "ask_user",
|
|
613
663
|
"POST /send": "send",
|
|
614
664
|
"POST /inbox": "inbox",
|
|
615
665
|
"GET /inbox": "inbox",
|
|
@@ -624,6 +674,33 @@ export async function startRoom(db, port = 0, info = {}) {
|
|
|
624
674
|
"POST /context_search": "context_search",
|
|
625
675
|
"POST /context_note": "context_note",
|
|
626
676
|
};
|
|
677
|
+
/** GET /tools: the connectors' tools (once they've started, bounded); POST /tool: call one. */
|
|
678
|
+
const connectorVerb = async (p, req, agentId) => {
|
|
679
|
+
const hub = room.connectors;
|
|
680
|
+
if (p === "/tools") {
|
|
681
|
+
if (!hub)
|
|
682
|
+
return { status: 200, body: { tools: [] } };
|
|
683
|
+
await Promise.race([hub.ready, new Promise((r) => setTimeout(r, 20_000))]);
|
|
684
|
+
return { status: 200, body: { tools: hub.tools().map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) } };
|
|
685
|
+
}
|
|
686
|
+
const body = await readJson(req);
|
|
687
|
+
const name = String(body.name ?? "");
|
|
688
|
+
if (!hub)
|
|
689
|
+
return { status: 200, body: { error: "no connectors in this run" } };
|
|
690
|
+
const t0 = Date.now();
|
|
691
|
+
const result = await hub.call(name, body.arguments && typeof body.arguments === "object" ? body.arguments : {}, agentId);
|
|
692
|
+
const failed = !!result && typeof result === "object" && "error" in result;
|
|
693
|
+
// What it read is kept (cut short) on the agent's task: a reviewer has no tools, and needs
|
|
694
|
+
// the ticket the author built from (found live, 0.1.4: codex sent back WEB-7 for "the ticket's missing").
|
|
695
|
+
const task = db.listTasks().find((t) => t.claimed_by === agentId && !["merged", "backlog"].includes(t.status));
|
|
696
|
+
const text = typeof result === "string" ? result : JSON.stringify(result);
|
|
697
|
+
db.publish("connector.call", {
|
|
698
|
+
agent_id: agentId,
|
|
699
|
+
task_id: task?.id ?? null,
|
|
700
|
+
payload: { tool: name, args: body.arguments ?? {}, ms: Date.now() - t0, ok: !failed, result: text.length > 6000 ? text.slice(0, 6000) + " …(cut)" : text },
|
|
701
|
+
});
|
|
702
|
+
return { status: 200, body: { result } };
|
|
703
|
+
};
|
|
627
704
|
const localServer = http.createServer(async (req, res) => {
|
|
628
705
|
const url = new URL(req.url ?? "/", "http://local");
|
|
629
706
|
const reply = (r) => {
|
|
@@ -638,6 +715,8 @@ export async function startRoom(db, port = 0, info = {}) {
|
|
|
638
715
|
const body = req.method === "POST" ? await readJson(req) : Object.fromEntries(url.searchParams);
|
|
639
716
|
return reply(await room.live.handle(live[1], body, String(req.headers["x-huddle-agent"] ?? "")));
|
|
640
717
|
}
|
|
718
|
+
if (url.pathname === "/tools" || url.pathname === "/tool")
|
|
719
|
+
return reply(await connectorVerb(url.pathname, req, String(req.headers["x-huddle-agent"] ?? "agent")));
|
|
641
720
|
const verb = LOCAL_VERBS[`${req.method} ${url.pathname}`];
|
|
642
721
|
if (!verb)
|
|
643
722
|
return reply({ status: 404, body: { error: "not found" } });
|
|
@@ -849,6 +928,7 @@ export async function startRoom(db, port = 0, info = {}) {
|
|
|
849
928
|
remote,
|
|
850
929
|
info,
|
|
851
930
|
askWaitMs: Number(process.env.HUDDLE_ASK_WAIT_MS) || 30_000,
|
|
931
|
+
askUserMs: (Number(process.env.HUDDLE_ASK_USER_SEC) || 90) * 1000,
|
|
852
932
|
doneWaitMs: Number(process.env.HUDDLE_DONE_WAIT_MS) || 40_000,
|
|
853
933
|
handleShared,
|
|
854
934
|
subscribe,
|