huddle-cli 0.1.1 → 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 +38 -0
- package/QUICKSTART.md +34 -1
- package/README.md +3 -0
- package/dist/adapters/codex.js +3 -0
- 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 +3 -3
- package/dist/attach/plug.js +8 -1
- package/dist/cli.js +26 -2
- package/dist/mcp/server.js +19 -0
- 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 +10 -4
- package/dist/orchestrator/launcher.js +3 -1
- package/dist/orchestrator/learn.js +3 -7
- package/dist/orchestrator/planner.js +31 -4
- package/dist/orchestrator/qa.js +9 -1
- package/dist/orchestrator/scheduler.js +82 -16
- package/dist/platform.js +104 -0
- package/dist/room/mail.js +19 -6
- package/dist/room/server.js +53 -2
- package/dist/room/user.js +54 -0
- package/dist/ui/app.js +452 -69
- package/dist/ui/brief.js +494 -0
- 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/watch.js +4 -0
- package/package.json +1 -1
|
@@ -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. */
|
|
@@ -155,6 +157,10 @@ export async function runBoard(opts) {
|
|
|
155
157
|
signal?.addEventListener("abort", killAll);
|
|
156
158
|
// If Huddle itself exits, never leave orphaned agents working for nobody.
|
|
157
159
|
process.once("exit", killAll);
|
|
160
|
+
// The token budget: past its soft line nothing new starts (tasks, takeovers, buddy checks, pairings);
|
|
161
|
+
// what's running finishes. Its hard line aborts the run's signal (the caller wires that, as Ctrl-C does).
|
|
162
|
+
const overBudget = () => !!opts.budget?.stopping;
|
|
163
|
+
const stoppedWhy = () => (opts.budget?.level === "hard" ? `stopped: the token budget ran over (${Math.round(opts.budget.used * 100)}%)` : "stopped by you");
|
|
158
164
|
// Agents held out of fill(): an author waiting on review, a reviewer reviewing.
|
|
159
165
|
const reserved = new Set();
|
|
160
166
|
const reviewed = new Set(); // task ids that already had their one review round
|
|
@@ -269,6 +275,7 @@ export async function runBoard(opts) {
|
|
|
269
275
|
worktree: wt,
|
|
270
276
|
onTokens: (tokens) => onEvent({ type: "tokens", agent: r.id, task: task.id, tokens }),
|
|
271
277
|
});
|
|
278
|
+
opts.budget?.track(h2.meter, r.id);
|
|
272
279
|
db.setTaskStatus(task.id, "in_progress");
|
|
273
280
|
db.setAgentStatus(r.id, "working", wt.path);
|
|
274
281
|
running.set(r.id, h2);
|
|
@@ -288,7 +295,7 @@ export async function runBoard(opts) {
|
|
|
288
295
|
// Both agents' Plug wiring may sit in this worktree; neither may be committed.
|
|
289
296
|
const h = { ...h2, excludes: [...new Set([...c.h.excludes, ...h2.excludes])] };
|
|
290
297
|
const res = signal?.aborted
|
|
291
|
-
? { ok: false, reason:
|
|
298
|
+
? { ok: false, reason: stoppedWhy() }
|
|
292
299
|
: code === 0
|
|
293
300
|
? finishConflictMerge(wt, cs, h.excludes)
|
|
294
301
|
: { ok: false, reason: `resolver exited with code ${code}` };
|
|
@@ -315,10 +322,17 @@ export async function runBoard(opts) {
|
|
|
315
322
|
const taskWt = new Map(); // task id -> its worktree, whoever works it
|
|
316
323
|
const lastSeen = new Map(); // agent id -> its last output or huddle_* call
|
|
317
324
|
const silenced = new Map(); // task id -> why we stopped its agent
|
|
325
|
+
// Quiet isn't stuck: a long test run prints nothing (busy.ts). Busy restarts the silence clock, up to a cap.
|
|
326
|
+
const silenceMaxMs = Math.max(silenceMs, opts.silenceMaxMs ?? silenceMaxMsFromEnv(silenceMs));
|
|
327
|
+
const busyWatch = new BusyWatch();
|
|
328
|
+
const lastBusy = new Map(); // agent id -> the last look that found it working
|
|
329
|
+
const busyTold = new Set(); // launches whose "busy, not silent" was reported
|
|
318
330
|
/** Start listening for signs of life from a freshly launched agent. */
|
|
319
331
|
const heard = (id, h) => {
|
|
320
332
|
const bump = () => lastSeen.set(id, Date.now());
|
|
321
333
|
bump();
|
|
334
|
+
lastBusy.delete(id);
|
|
335
|
+
busyWatch.forget(id);
|
|
322
336
|
h.child?.stdout?.on("data", bump);
|
|
323
337
|
h.child?.stderr?.on("data", bump);
|
|
324
338
|
};
|
|
@@ -392,16 +406,56 @@ export async function runBoard(opts) {
|
|
|
392
406
|
});
|
|
393
407
|
start(r, task, brief, rc.from.id);
|
|
394
408
|
};
|
|
395
|
-
/**
|
|
409
|
+
/**
|
|
410
|
+
* Stop agents that have gone quiet; they're taken over in finish(). Quiet =
|
|
411
|
+
* no output and no huddle_* call. Past half the window Huddle starts looking
|
|
412
|
+
* at what the agent is doing (busy.ts); a look that finds it working counts
|
|
413
|
+
* as a sign of life, so only an agent quiet AND idle for the whole window is
|
|
414
|
+
* silent — or one quiet past the cap, working or not.
|
|
415
|
+
*/
|
|
416
|
+
const span = (ms) => (ms >= 60_000 ? `${Math.round(ms / 60_000)} min` : `${Math.round(ms / 1000)}s`);
|
|
396
417
|
const watchSilence = () => {
|
|
418
|
+
let table; // one process snapshot per tick, only when someone is quiet
|
|
419
|
+
const now = Date.now();
|
|
397
420
|
for (const [id, h] of running) {
|
|
398
421
|
if (!h.child || silenced.has(h.taskId) || h.child.exitCode !== null || h.child.signalCode !== null)
|
|
399
422
|
continue;
|
|
400
|
-
const quiet =
|
|
401
|
-
if (quiet < silenceMs)
|
|
423
|
+
const quiet = now - Math.max(lastSeen.get(id) ?? 0, h.startedAt);
|
|
424
|
+
if (quiet < silenceMs / 2) {
|
|
425
|
+
busyWatch.forget(id); // it spoke: the next quiet spell compares from a fresh look
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
let firstLook = false;
|
|
429
|
+
if (quiet < silenceMaxMs && h.child.pid) {
|
|
430
|
+
if (table === undefined) {
|
|
431
|
+
try {
|
|
432
|
+
table = (opts.processTable ?? processTable)();
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
table = null;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const v = busyWatch.look(id, { pid: h.child.pid, worktree: h.worktree, logPath: h.logPath }, table, now);
|
|
439
|
+
firstLook = v === null; // nothing to compare with yet: decide on the next tick
|
|
440
|
+
if (v?.busy) {
|
|
441
|
+
lastBusy.set(id, now);
|
|
442
|
+
if (quiet >= silenceMs && !busyTold.has(h)) {
|
|
443
|
+
busyTold.add(h);
|
|
444
|
+
const t = db.getTask(h.taskId);
|
|
445
|
+
onEvent({ type: "busy", agent: id, task: h.taskId, title: t?.title ?? h.taskId, quietMs: quiet, why: v.why });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const idle = now - Math.max(lastSeen.get(id) ?? 0, lastBusy.get(id) ?? 0, h.startedAt);
|
|
450
|
+
if (quiet >= silenceMaxMs)
|
|
451
|
+
silenced.set(h.taskId, busyTold.has(h)
|
|
452
|
+
? `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`
|
|
453
|
+
: `went silent — no output and no huddle_* call for ${span(quiet)}`);
|
|
454
|
+
else if (idle >= silenceMs && !firstLook)
|
|
455
|
+
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)`);
|
|
456
|
+
else
|
|
402
457
|
continue;
|
|
403
|
-
|
|
404
|
-
silenced.set(h.taskId, `went silent — no output and no huddle_* call for ${span}`);
|
|
458
|
+
busyWatch.forget(id);
|
|
405
459
|
stopChild(h.child);
|
|
406
460
|
}
|
|
407
461
|
};
|
|
@@ -445,7 +499,7 @@ export async function runBoard(opts) {
|
|
|
445
499
|
.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
500
|
/** A teammate reads the work in progress and its notes land in the author's inbox now. Never blocks the author. */
|
|
447
501
|
const buddyCheck = async (taskId, pct, silentMs = 0) => {
|
|
448
|
-
if (!buddyOn || resolved || signal?.aborted || buddyBusy.has(taskId) || resolving.has(taskId))
|
|
502
|
+
if (!buddyOn || resolved || signal?.aborted || buddyBusy.has(taskId) || resolving.has(taskId) || overBudget())
|
|
449
503
|
return;
|
|
450
504
|
const t = db.getTask(taskId);
|
|
451
505
|
if (!t?.claimed_by || isSpecialTask(t))
|
|
@@ -520,7 +574,7 @@ export async function runBoard(opts) {
|
|
|
520
574
|
};
|
|
521
575
|
/** Tasks whose author never got to 40% in huddle_progress still get a look mid-task, after buddyAfterMs. */
|
|
522
576
|
const buddyBySilence = () => {
|
|
523
|
-
if (buddyAfterMs === null || !buddyOn)
|
|
577
|
+
if (buddyAfterMs === null || !buddyOn || overBudget())
|
|
524
578
|
return;
|
|
525
579
|
const now = Date.now();
|
|
526
580
|
for (const h of running.values()) {
|
|
@@ -692,7 +746,7 @@ export async function runBoard(opts) {
|
|
|
692
746
|
* sends the old help offer, which leaves the split to the busy agent.
|
|
693
747
|
*/
|
|
694
748
|
const offerHelp = () => {
|
|
695
|
-
if (helpAfterMs === null || signal?.aborted || conflicts.length || rescues.length)
|
|
749
|
+
if (helpAfterMs === null || signal?.aborted || conflicts.length || rescues.length || overBudget())
|
|
696
750
|
return;
|
|
697
751
|
const helper = workers.find((x) => x.adapter && !running.has(x.id) && !reserved.has(x.id) && !pairing.has(x.id) && !db.nextBacklogTask(prefersOf(x)));
|
|
698
752
|
if (!helper)
|
|
@@ -801,6 +855,7 @@ export async function runBoard(opts) {
|
|
|
801
855
|
}
|
|
802
856
|
});
|
|
803
857
|
const tick = setInterval(() => {
|
|
858
|
+
opts.budget?.check();
|
|
804
859
|
watchSilence();
|
|
805
860
|
offerHelp();
|
|
806
861
|
buddyBySilence();
|
|
@@ -861,7 +916,7 @@ export async function runBoard(opts) {
|
|
|
861
916
|
reserved.delete(w.id);
|
|
862
917
|
}
|
|
863
918
|
if (signal?.aborted) {
|
|
864
|
-
fail(w, h, task.title,
|
|
919
|
+
fail(w, h, task.title, stoppedWhy(), workMs);
|
|
865
920
|
}
|
|
866
921
|
else if (exitCode !== 0) {
|
|
867
922
|
failOrRescue(w, h, task, quiet ?? why ?? `agent exited with code ${exitCode}`, workMs);
|
|
@@ -1008,6 +1063,7 @@ export async function runBoard(opts) {
|
|
|
1008
1063
|
startedAt: Date.now(),
|
|
1009
1064
|
meter: new UsageMeter(),
|
|
1010
1065
|
};
|
|
1066
|
+
opts.budget?.track(h.meter, h.agentId);
|
|
1011
1067
|
db.setTaskStatus(task.id, "in_progress");
|
|
1012
1068
|
db.setAgentStatus(w.id, "working");
|
|
1013
1069
|
running.set(w.id, h);
|
|
@@ -1056,7 +1112,9 @@ export async function runBoard(opts) {
|
|
|
1056
1112
|
}
|
|
1057
1113
|
// The contract as it stands now (amendments included): each launch is a fresh process.
|
|
1058
1114
|
const cb = contractBrief(db);
|
|
1059
|
-
|
|
1115
|
+
// What the user said mid-run (room/user.ts) outranks the plan and the contract.
|
|
1116
|
+
const ub = userBrief(db);
|
|
1117
|
+
const contract = (cb ? `\n\n${cb}` : "") + (ub ? `\n\n${ub}` : "");
|
|
1060
1118
|
if (w.remote)
|
|
1061
1119
|
return void startRemote(w, { ...task, spec: task.spec + contract + ho.text }, extra);
|
|
1062
1120
|
// Who else is mid-task right now, so this agent knows whom to tell (first round only;
|
|
@@ -1079,6 +1137,7 @@ export async function runBoard(opts) {
|
|
|
1079
1137
|
});
|
|
1080
1138
|
// Every agent's Plug wiring that sat in this worktree stays out of the commit.
|
|
1081
1139
|
const h = { ...launched, excludes: [...new Set([...(prior?.excludes ?? []), ...launched.excludes])] };
|
|
1140
|
+
opts.budget?.track(h.meter, h.agentId);
|
|
1082
1141
|
taskWt.set(task.id, { branch: h.branch, path: h.worktree, excludes: h.excludes });
|
|
1083
1142
|
db.setTaskStatus(task.id, "in_progress");
|
|
1084
1143
|
db.setAgentStatus(w.id, "working", h.worktree);
|
|
@@ -1100,7 +1159,7 @@ export async function runBoard(opts) {
|
|
|
1100
1159
|
pending--;
|
|
1101
1160
|
holdFor.delete(x.task.id);
|
|
1102
1161
|
if (signal?.aborted)
|
|
1103
|
-
fail(x.w, x.h, x.task.title,
|
|
1162
|
+
fail(x.w, x.h, x.task.title, stoppedWhy(), x.workMs);
|
|
1104
1163
|
// Tests for code that never landed would only go red at the gate and burn a fix round.
|
|
1105
1164
|
else if (testsOf.has(x.task.id) && db.getTask(x.after)?.status !== "merged")
|
|
1106
1165
|
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 +1174,7 @@ export async function runBoard(opts) {
|
|
|
1115
1174
|
// Conflicts first: they hold finished work, and the author is usually free.
|
|
1116
1175
|
for (const c of conflicts.splice(0)) {
|
|
1117
1176
|
if (signal?.aborted) {
|
|
1118
|
-
parkForYou(c,
|
|
1177
|
+
parkForYou(c, stoppedWhy());
|
|
1119
1178
|
continue;
|
|
1120
1179
|
}
|
|
1121
1180
|
const r = pickResolver(c.author, resolving.get(c.taskId)?.by ?? []);
|
|
@@ -1131,13 +1190,17 @@ export async function runBoard(opts) {
|
|
|
1131
1190
|
fail(rc.from, rc.h, title, `${rc.reason} (stopped before another agent could take it over)`, 0);
|
|
1132
1191
|
continue;
|
|
1133
1192
|
}
|
|
1193
|
+
if (overBudget()) {
|
|
1194
|
+
fail(rc.from, rc.h, title, `${rc.reason} (not taken over: the token budget is nearly spent)`, 0);
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1134
1197
|
const r = pickRescuer(rc);
|
|
1135
1198
|
if (r)
|
|
1136
1199
|
startRescue(r, rc);
|
|
1137
1200
|
else
|
|
1138
1201
|
rescues.push(rc); // the next other agent to finish takes it
|
|
1139
1202
|
}
|
|
1140
|
-
if (!signal?.aborted) {
|
|
1203
|
+
if (!signal?.aborted && !overBudget()) {
|
|
1141
1204
|
// Fairness: the free agent that has taken the fewest tasks goes first
|
|
1142
1205
|
// (roster order breaks ties), so one agent doesn't take every task that
|
|
1143
1206
|
// unblocks just because it's listed first.
|
|
@@ -1196,7 +1259,10 @@ export async function runBoard(opts) {
|
|
|
1196
1259
|
for (const rc of rescues.splice(0))
|
|
1197
1260
|
fail(rc.from, rc.h, db.getTask(rc.taskId).title, `${rc.reason} (no other agent could take it over)`, 0);
|
|
1198
1261
|
const blocked = db.listTasks().filter((t) => t.status === "backlog").map((t) => t.title);
|
|
1199
|
-
|
|
1262
|
+
const b = opts.budget;
|
|
1263
|
+
if (b?.limit && b.level !== "ok")
|
|
1264
|
+
onEvent({ type: "budget", level: b.level === "hard" ? "hard" : "soft", tokens: b.tokens, limit: b.limit, left: blocked });
|
|
1265
|
+
else if (blocked.length && !signal?.aborted)
|
|
1200
1266
|
onEvent({ type: "stuck", blocked });
|
|
1201
1267
|
done();
|
|
1202
1268
|
}
|
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",
|
|
@@ -849,6 +899,7 @@ export async function startRoom(db, port = 0, info = {}) {
|
|
|
849
899
|
remote,
|
|
850
900
|
info,
|
|
851
901
|
askWaitMs: Number(process.env.HUDDLE_ASK_WAIT_MS) || 30_000,
|
|
902
|
+
askUserMs: (Number(process.env.HUDDLE_ASK_USER_SEC) || 90) * 1000,
|
|
852
903
|
doneWaitMs: Number(process.env.HUDDLE_DONE_WAIT_MS) || 40_000,
|
|
853
904
|
handleShared,
|
|
854
905
|
subscribe,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The person at the keyboard, as a member of the room.
|
|
2
|
+
//
|
|
3
|
+
// Found live (0.1.2 test): mid-run there was no way to tell the agents
|
|
4
|
+
// anything; a typed line only queued the next goal. Now a line typed during a
|
|
5
|
+
// run reaches every agent that's working, sent as "you", and it outranks
|
|
6
|
+
// teammates' notes: they are information; this is the person the work is for.
|
|
7
|
+
// Agents that start later get every such note in their launch prompt.
|
|
8
|
+
/** The user's id in messages and shared memory. Never an agent's id. */
|
|
9
|
+
export const USER_ID = "you";
|
|
10
|
+
export const isUser = (from) => from === USER_ID;
|
|
11
|
+
/** The prefix of a note from the user in shared memory. */
|
|
12
|
+
const SAID = "The user said: ";
|
|
13
|
+
/**
|
|
14
|
+
* Send what the user typed to the agents working now (or to `to`, an agent
|
|
15
|
+
* id), and keep it in shared memory so every task that starts later gets it.
|
|
16
|
+
* Returns who got it now.
|
|
17
|
+
*/
|
|
18
|
+
export function sayToAgents(db, text, to) {
|
|
19
|
+
const body = text.trim();
|
|
20
|
+
if (!body)
|
|
21
|
+
return { now: [], saved: false };
|
|
22
|
+
const working = db.listAgents().filter((a) => a.status === "working").map((a) => a.id);
|
|
23
|
+
const want = to?.trim().replace(/^@/, "").toLowerCase();
|
|
24
|
+
const now = want ? working.filter((id) => id.toLowerCase() === want || id.toLowerCase().split("#")[0] === want) : working;
|
|
25
|
+
for (const id of now)
|
|
26
|
+
db.sendMessage({ from: USER_ID, to: id, body, kind: "note" });
|
|
27
|
+
// Addressed to one agent: it's for that agent's task, not every later one.
|
|
28
|
+
if (!want)
|
|
29
|
+
db.remember({ kind: "decision", body: SAID + body, author: USER_ID });
|
|
30
|
+
return { now, saved: !want };
|
|
31
|
+
}
|
|
32
|
+
/** Everything the user said this run, for the launch prompt of a task that starts now. */
|
|
33
|
+
export function userBrief(db) {
|
|
34
|
+
const since = db.currentSession()?.created_at ?? 0;
|
|
35
|
+
const said = db
|
|
36
|
+
.recall()
|
|
37
|
+
.filter((m) => m.author === USER_ID && m.created_at >= since)
|
|
38
|
+
.reverse()
|
|
39
|
+
.map((m) => `- ${m.body.startsWith(SAID) ? m.body.slice(SAID.length) : m.body}`);
|
|
40
|
+
if (!said.length)
|
|
41
|
+
return "";
|
|
42
|
+
return [
|
|
43
|
+
"FROM THE USER — the person this work is for said this during the run. Follow it: it wins over the plan and the contract",
|
|
44
|
+
"(if it changes a name another task uses, huddle_amend_contract so they hear it too):",
|
|
45
|
+
...said.slice(-10),
|
|
46
|
+
].join("\n");
|
|
47
|
+
}
|
|
48
|
+
/** How a message reads in an agent's tool result: the user's are marked as theirs. */
|
|
49
|
+
export function fromLabel(m) {
|
|
50
|
+
return isUser(m.from_agent) ? "the user" : m.from_agent;
|
|
51
|
+
}
|
|
52
|
+
/** Leads a tool result's inbox hint when the user wrote. */
|
|
53
|
+
export const USER_HEAD_HINT = 'The USER (the person this work is for) wrote to the team — see new_messages sent by "the user". Follow it now: it wins over the plan, the contract and teammates\' notes. ';
|
|
54
|
+
//# sourceMappingURL=user.js.map
|