@therealjsmastery/jsm-hq 0.0.0-stage → 0.1.0

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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +119 -2
  3. package/TRADEMARKS.md +17 -0
  4. package/bin/jsm-hq.mjs +50 -0
  5. package/dist/assets/CanvasPool-O1IaWL-9.js +1 -0
  6. package/dist/assets/CanvasRenderer-Dd4pZNIN.js +1 -0
  7. package/dist/assets/RenderTargetSystem-BccyHTeF.js +184 -0
  8. package/dist/assets/WebGLRenderer-1zkR__s9.js +156 -0
  9. package/dist/assets/WebGPURenderer-CSiZQVKf.js +75 -0
  10. package/dist/assets/browserAll-K-v47ayM.js +14 -0
  11. package/dist/assets/canvasUtils-C3DGUQjp.js +1 -0
  12. package/dist/assets/getTexelRangeRects-D8_SvULv.js +195 -0
  13. package/dist/assets/index-BHNtDgvI.css +1 -0
  14. package/dist/assets/index-DPTN0V6X.js +311 -0
  15. package/dist/assets/pixelify-sans-cyrillic-400-normal-DfOZNMlW.woff +0 -0
  16. package/dist/assets/pixelify-sans-cyrillic-600-normal-B3EuUOZG.woff +0 -0
  17. package/dist/assets/pixelify-sans-cyrillic-700-normal-BrZTw8tu.woff +0 -0
  18. package/dist/assets/pixelify-sans-latin-400-normal-DLrIX9QE.woff2 +0 -0
  19. package/dist/assets/pixelify-sans-latin-400-normal-JapMNN6Z.woff +0 -0
  20. package/dist/assets/pixelify-sans-latin-600-normal-BNlgYbDL.woff +0 -0
  21. package/dist/assets/pixelify-sans-latin-600-normal-Dd_Dy7u5.woff2 +0 -0
  22. package/dist/assets/pixelify-sans-latin-700-normal-BctWAS0u.woff +0 -0
  23. package/dist/assets/pixelify-sans-latin-700-normal-D3Xxx3QE.woff2 +0 -0
  24. package/dist/assets/pixelify-sans-latin-ext-400-normal-BkfTUL4s.woff +0 -0
  25. package/dist/assets/pixelify-sans-latin-ext-400-normal-DdH6zlBQ.woff2 +0 -0
  26. package/dist/assets/pixelify-sans-latin-ext-600-normal-CDaIumwU.woff +0 -0
  27. package/dist/assets/pixelify-sans-latin-ext-600-normal-Dha6RiOG.woff2 +0 -0
  28. package/dist/assets/pixelify-sans-latin-ext-700-normal-BadXkHzZ.woff +0 -0
  29. package/dist/assets/pixelify-sans-latin-ext-700-normal-ClyJstAh.woff2 +0 -0
  30. package/dist/assets/vt323-latin-400-normal-Dqn9ZSPj.woff +0 -0
  31. package/dist/assets/vt323-latin-400-normal-wDgJuOC9.woff2 +0 -0
  32. package/dist/assets/vt323-latin-ext-400-normal-DqOq58ri.woff +0 -0
  33. package/dist/assets/vt323-latin-ext-400-normal-iUdYUaX4.woff2 +0 -0
  34. package/dist/assets/vt323-vietnamese-400-normal-D0VnvwZ9.woff2 +0 -0
  35. package/dist/assets/vt323-vietnamese-400-normal-D_5Q7Vcv.woff +0 -0
  36. package/dist/assets/webworkerAll-wf0zq30l.js +83 -0
  37. package/dist/index.html +14 -0
  38. package/dist-server/main.mjs +2342 -0
  39. package/package.json +79 -4
  40. package/scripts/fix-node-pty.mjs +17 -0
@@ -0,0 +1,2342 @@
1
+ // server/main.ts
2
+ import { existsSync as existsSync11 } from "node:fs";
3
+ import { dirname as dirname2, join as join10, resolve as resolve2 } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ // src/data/types.ts
7
+ var SERVICE_KEYS = ["anthropic", "openai", "github", "google", "groq", "mistral", "openrouter", "xai", "nebius", "copilot", "cursor", "devin", "droid"];
8
+
9
+ // server/lib/exec.ts
10
+ import { execFile } from "node:child_process";
11
+ function run(cmd, args, opts = {}) {
12
+ return new Promise((resolve3) => {
13
+ const child = execFile(
14
+ cmd,
15
+ args,
16
+ { cwd: opts.cwd, timeout: opts.timeoutMs ?? 2e4, maxBuffer: 8 * 1024 * 1024, env: process.env },
17
+ (err, stdout, stderr) => {
18
+ const code = err && typeof err.code === "number" ? Number(err.code) : err ? null : 0;
19
+ resolve3({ ok: !err, code: err ? code ?? 1 : 0, stdout: String(stdout), stderr: String(stderr || (err && !stderr ? err.message : "")) });
20
+ }
21
+ );
22
+ if (opts.input !== void 0) child.stdin?.end(opts.input);
23
+ });
24
+ }
25
+ async function has(cmd) {
26
+ const r = process.platform === "win32" ? await run("where", [cmd], { timeoutMs: 5e3 }) : await run("sh", ["-c", `command -v ${cmd}`], { timeoutMs: 5e3 });
27
+ return r.ok && r.stdout.trim().length > 0;
28
+ }
29
+ function shq(s) {
30
+ return `'${s.replace(/'/g, `'\\''`)}'`;
31
+ }
32
+
33
+ // src/theme/tokens.ts
34
+ var colors = {
35
+ ink: "#241a2e",
36
+ cream: "#fff6e9",
37
+ cream2: "#f0dfc2",
38
+ cream3: "#e2cba6",
39
+ muted: "#7d6b73",
40
+ navy: "#1d2b53",
41
+ navy2: "#16213f",
42
+ blue: "#29adff",
43
+ red: "#ff004d",
44
+ orange: "#ffa300",
45
+ green: "#00e436",
46
+ lav: "#83769c",
47
+ yellow: "#ffec27",
48
+ pink: "#ff77a8",
49
+ grass: "#4a8f3d",
50
+ white: "#fff1e8"
51
+ };
52
+ var stateStyle = {
53
+ working: { label: "Working", color: colors.blue, dim: "#1d6fb8" },
54
+ "needs-input": { label: "Needs input", color: colors.red, dim: "#7e0f33" },
55
+ "in-review": { label: "In review", color: colors.orange, dim: "#a86a00" },
56
+ "review-passed": { label: "Review passed", color: colors.green, dim: "#00873a" },
57
+ idle: { label: "Idle", color: colors.lav, dim: "#5a5170" }
58
+ };
59
+ var reviewStyle = {
60
+ pending: { label: "Queued", color: colors.lav, dim: "#5a5170" },
61
+ reviewing: { label: "Reviewing", color: colors.orange, dim: "#a86a00" },
62
+ "changes-requested": { label: "Changes", color: colors.red, dim: "#7e0f33" },
63
+ passed: { label: "Passed", color: colors.green, dim: "#00873a" }
64
+ };
65
+ var agentPalette = [
66
+ "#ff77a8",
67
+ // pink
68
+ "#29adff",
69
+ // blue
70
+ "#ffd23a",
71
+ // yellow
72
+ "#00e436",
73
+ // green
74
+ "#ff8a3d",
75
+ // orange
76
+ "#a98bff",
77
+ // violet
78
+ "#3ee0d0",
79
+ // teal
80
+ "#ff5c5c",
81
+ // coral
82
+ "#c2c3c7",
83
+ // silver
84
+ "#b0e04a",
85
+ // lime
86
+ "#f0a6ff",
87
+ // lilac
88
+ "#6fa8ff"
89
+ // periwinkle
90
+ ];
91
+
92
+ // server/planExport.ts
93
+ import { mkdirSync, writeFileSync } from "node:fs";
94
+ import { join } from "node:path";
95
+ var PLAN_EXPORT_PATH = "docs/plan.md";
96
+ var BOX = { todo: "[ ]", "in-progress": "[ ]", "in-review": "[ ]", blocked: "[ ]", done: "[x]" };
97
+ var STATUS = { todo: "", "in-progress": "in progress", "in-review": "in review", blocked: "blocked", done: "" };
98
+ function planMarkdown(config, plan) {
99
+ const name = (id) => id ? config.team.find((a) => a.id === id)?.name ?? id : null;
100
+ const lines = [`# ${config.project.name} plan`, "", "_Written by JSM HQ from .jsmhq/plan.json. Edits here are overwritten; change the plan in HQ._", ""];
101
+ for (const b of [...plan.boards].sort((x, y) => x.order - y.order)) {
102
+ lines.push(`## ${b.title} (${b.status})`, "");
103
+ const tasks = plan.tasks.filter((t2) => t2.boardId === b.id);
104
+ if (!tasks.length) lines.push("_No tasks._");
105
+ for (const t2 of tasks) {
106
+ const meta = [name(t2.ownerId), STATUS[t2.status], t2.prNumber ? `PR #${t2.prNumber}` : "", t2.needs.length ? `needs ${t2.needs.join(", ")}` : ""].filter(Boolean);
107
+ lines.push(`- ${BOX[t2.status]} **${t2.id}** ${t2.title}${meta.length ? ` \u2014 ${meta.join(" \xB7 ")}` : ""}`);
108
+ if (t2.description) lines.push(` - ${t2.description.replace(/\s*\n\s*/g, " ")}`);
109
+ if (t2.doneWhen) lines.push(` - Done when: ${t2.doneWhen.replace(/\s*\n\s*/g, " ")}`);
110
+ if (t2.note) lines.push(` - Note: ${t2.note}`);
111
+ }
112
+ lines.push("");
113
+ }
114
+ return lines.join("\n");
115
+ }
116
+ function writePlanExport(root, config, plan) {
117
+ const file = join(root, PLAN_EXPORT_PATH);
118
+ mkdirSync(join(file, ".."), { recursive: true });
119
+ writeFileSync(file, planMarkdown(config, plan));
120
+ }
121
+
122
+ // server/store.ts
123
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync2, renameSync, writeFileSync as writeFileSync3 } from "node:fs";
124
+ import { join as join3 } from "node:path";
125
+
126
+ // server/paths.ts
127
+ import { randomBytes } from "node:crypto";
128
+ import { existsSync, mkdirSync as mkdirSync2, readFileSync, writeFileSync as writeFileSync2 } from "node:fs";
129
+ import { homedir } from "node:os";
130
+ import { join as join2 } from "node:path";
131
+ var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || "project";
132
+ function projectHome(projectSlug) {
133
+ const dir2 = join2(homedir(), ".jsmhq", projectSlug);
134
+ mkdirSync2(dir2, { recursive: true });
135
+ return dir2;
136
+ }
137
+ function managedWorkspace(projectSlug, agentId) {
138
+ return join2(projectHome(projectSlug), agentId);
139
+ }
140
+ var sessionName = (projectSlug, agentId) => `hq-${projectSlug}-${agentId}`;
141
+ function agentToken(projectSlug) {
142
+ const file = join2(projectHome(projectSlug), "agent-token");
143
+ if (existsSync(file)) return readFileSync(file, "utf8").trim();
144
+ const tok = randomBytes(24).toString("hex");
145
+ writeFileSync2(file, tok, { mode: 384 });
146
+ return tok;
147
+ }
148
+ function browserToken(projectSlug, dev) {
149
+ if (!dev) return randomBytes(24).toString("hex");
150
+ const file = join2(projectHome(projectSlug), "dev-browser-token");
151
+ if (existsSync(file)) return readFileSync(file, "utf8").trim();
152
+ const tok = randomBytes(24).toString("hex");
153
+ writeFileSync2(file, tok, { mode: 384 });
154
+ return tok;
155
+ }
156
+
157
+ // server/store.ts
158
+ var dir = (root) => join3(root, ".jsmhq");
159
+ function readJson(file) {
160
+ if (!existsSync2(file)) return null;
161
+ return JSON.parse(readFileSync2(file, "utf8"));
162
+ }
163
+ function writeJson(file, value) {
164
+ mkdirSync3(join3(file, ".."), { recursive: true });
165
+ const tmp = `${file}.tmp`;
166
+ writeFileSync3(tmp, JSON.stringify(value, null, 2) + "\n");
167
+ renameSync(tmp, file);
168
+ }
169
+ var defaultSandbox = () => ({
170
+ resources: { cpus: null, memoryGb: null },
171
+ // sbx's own defaults
172
+ allow: [],
173
+ deny: []
174
+ });
175
+ function newAgent(id, name, role, color, isLead) {
176
+ return {
177
+ id,
178
+ name,
179
+ role,
180
+ isLead,
181
+ cli: "claude",
182
+ model: "",
183
+ // the CLI's default; users can pick with /model or "Start with model"
184
+ mode: "sandbox",
185
+ color,
186
+ skills: [],
187
+ roleMd: isLead ? `You are ${name}, the lead. You plan and assign work. You never write app code.` : `You are ${name}, a ${role.toLowerCase()} on the team. You build the tasks assigned to you.`,
188
+ sandbox: defaultSandbox(),
189
+ workspacePath: null
190
+ };
191
+ }
192
+ function defaultConfig(repo, port) {
193
+ return {
194
+ project: { id: slugify(repo.name), name: repo.name, repo: repo.github ?? repo.name, path: repo.root, hqPort: port },
195
+ team: [newAgent("john", "John", "Lead", "#ff77a8", true), newAgent("bob", "Bob", "Developer", "#29adff", false)],
196
+ workflow: {
197
+ review: { enabled: true, tool: repo.hasCodeRabbit ? "coderabbit" : "human", command: "" },
198
+ merge: { by: "human" },
199
+ deploy: { enabled: repo.hasInsta, tool: "instacloud", command: "", perAgentEnvironments: repo.hasInsta }
200
+ }
201
+ };
202
+ }
203
+ function loadConfig(repo, port) {
204
+ const file = join3(dir(repo.root), "config.json");
205
+ const found = readJson(file);
206
+ if (found) {
207
+ for (const a of found.team) {
208
+ a.workspacePath ??= null;
209
+ a.cli ??= "claude";
210
+ a.sandbox.resources ??= { cpus: null, memoryGb: null };
211
+ const old = a.sandbox;
212
+ delete old.policy;
213
+ delete old.proxy;
214
+ delete old.secrets;
215
+ }
216
+ found.project.id = slugify(found.project.id ?? found.project.name);
217
+ found.project.path = repo.root;
218
+ return { config: found, isNew: false };
219
+ }
220
+ return { config: defaultConfig(repo, port), isNew: true };
221
+ }
222
+ function saveConfig(root, cfg) {
223
+ const { path: _path, ...project } = cfg.project;
224
+ writeJson(join3(dir(root), "config.json"), { ...cfg, project: { ...project, path: "" } });
225
+ }
226
+ function loadPlan(root) {
227
+ return readJson(join3(dir(root), "plan.json")) ?? { boards: [{ id: "B1", title: "Backlog", status: "active", order: 0 }], tasks: [] };
228
+ }
229
+ function savePlan(root, plan) {
230
+ writeJson(join3(dir(root), "plan.json"), plan);
231
+ }
232
+
233
+ // server/hub.ts
234
+ var now = () => (/* @__PURE__ */ new Date()).toISOString();
235
+ var Hub = class _Hub {
236
+ constructor(repo, slug2, config, plan, setup) {
237
+ this.repo = repo;
238
+ this.slug = slug2;
239
+ this.snap = {
240
+ setup,
241
+ sbx: { basePolicy: "unknown", proxy: null, globalKeys: [], agents: {}, checkedAt: now() },
242
+ config,
243
+ agents: config.team.map((a) => _Hub.freshStatus(a.id)),
244
+ boards: plan.boards,
245
+ tasks: plan.tasks,
246
+ pullRequests: [],
247
+ environments: [],
248
+ messages: [],
249
+ activity: []
250
+ };
251
+ }
252
+ repo;
253
+ slug;
254
+ snap;
255
+ listeners = /* @__PURE__ */ new Set();
256
+ seq = 0;
257
+ static freshStatus(agentId) {
258
+ return {
259
+ agentId,
260
+ state: "idle",
261
+ currentTaskId: null,
262
+ stateSince: now(),
263
+ note: "Not started",
264
+ session: { state: "stopped", detail: null, workspace: null, sandbox: null }
265
+ };
266
+ }
267
+ subscribe(fn) {
268
+ this.listeners.add(fn);
269
+ return () => this.listeners.delete(fn);
270
+ }
271
+ emit(e) {
272
+ for (const fn of this.listeners) fn(e);
273
+ }
274
+ // ------------------------------------------------------------ lookups
275
+ cfg(id) {
276
+ return this.snap.config.team.find((a) => a.id === id);
277
+ }
278
+ status(id) {
279
+ return this.snap.agents.find((a) => a.agentId === id);
280
+ }
281
+ name(id) {
282
+ if (id === "you") return "You";
283
+ if (id === "hq") return "HQ";
284
+ return id && this.cfg(id)?.name || "Someone";
285
+ }
286
+ // ------------------------------------------------------------ mutations
287
+ setStatus(id, patch) {
288
+ const s = this.status(id);
289
+ if (!s) return;
290
+ if (patch.state !== void 0 && patch.state !== s.state) s.stateSince = now();
291
+ Object.assign(s, patch);
292
+ this.emit({ type: "agent.updated", agent: structuredClone(s) });
293
+ }
294
+ setSession(id, patch) {
295
+ const s = this.status(id);
296
+ if (!s) return;
297
+ s.session = { ...s.session, ...patch };
298
+ this.emit({ type: "agent.updated", agent: structuredClone(s) });
299
+ }
300
+ setSetup(setup) {
301
+ this.snap.setup = setup;
302
+ this.emit({ type: "setup.updated", setup });
303
+ }
304
+ activity(type, agentId, text) {
305
+ this.seq += 1;
306
+ const event = { id: `a${this.seq}`, at: now(), type, agentId, text };
307
+ this.snap.activity = [event, ...this.snap.activity].slice(0, 50);
308
+ this.emit({ type: "activity", event });
309
+ }
310
+ configChanged() {
311
+ saveConfig(this.repo.root, this.snap.config);
312
+ this.emit({ type: "config.updated", config: structuredClone(this.snap.config) });
313
+ }
314
+ planChanged() {
315
+ const plan = { boards: this.snap.boards, tasks: this.snap.tasks };
316
+ savePlan(this.repo.root, plan);
317
+ if (this.snap.config.project.exportPlan) writePlanExport(this.repo.root, this.snap.config, plan);
318
+ }
319
+ terminal(agentId, data) {
320
+ this.emit({ type: "terminal.output", agentId, data });
321
+ }
322
+ };
323
+
324
+ // server/commands.ts
325
+ var slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || "agent";
326
+ var str = (v, max = 2e3) => typeof v === "string" ? v.slice(0, max) : "";
327
+ var num = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
328
+ function makeCommands(hub, sessions, planner, inbox, sbx) {
329
+ const attempt = async (agentId, what, fn) => {
330
+ try {
331
+ await fn();
332
+ hub.activity("config", agentId, what);
333
+ } catch (err) {
334
+ hub.activity("config", agentId, `Couldn't ${what.charAt(0).toLowerCase() + what.slice(1)}: ${err instanceof Error ? err.message : err}`);
335
+ }
336
+ };
337
+ const team = () => hub.snap.config.team;
338
+ const commands = {
339
+ sendTerminalInput: (id, data) => sessions.input(str(id, 80), str(data, 1e5)),
340
+ resizeTerminal: (id, cols, rows) => sessions.resize(str(id, 80), num(cols), num(rows)),
341
+ startAgent: (id) => sessions.start(str(id, 80)),
342
+ stopAgent: (id) => sessions.stop(str(id, 80)),
343
+ openWorkspace: (id) => sessions.openWorkspace(str(id, 80)),
344
+ recreateSandbox: (id) => sessions.recreate(str(id, 80)),
345
+ recheckSetup: async () => {
346
+ await sessions.recheckSetup();
347
+ await sbx.refresh();
348
+ },
349
+ runSetupFix: (id) => sessions.runFix(str(id, 40)),
350
+ // You → agent: same delivery path as the lead's assign_task / send_message.
351
+ assignTask: (taskId, agentId) => planner.assign(str(taskId, 40), str(agentId, 80), "you"),
352
+ sendMessage: (toId, text) => planner.message("you", str(toId, 80), str(text, 2e3)),
353
+ addAgent: (raw) => {
354
+ const input = raw ?? {};
355
+ const name = str(input.name, 40).trim() || "Agent";
356
+ let id = slug(name);
357
+ for (let i = 2; team().some((a) => a.id === id); i++) id = `${slug(name)}-${i}`;
358
+ const used = new Set(team().map((a) => a.color));
359
+ const color = agentPalette.find((c) => !used.has(c)) ?? agentPalette[team().length % agentPalette.length];
360
+ const agent = newAgent(id, name, str(input.role, 40).trim() || "Developer", color, false);
361
+ agent.cli = input.cli === "codex" ? "codex" : "claude";
362
+ agent.model = str(input.model, 60).trim();
363
+ agent.mode = input.mode === "local" ? "local" : "sandbox";
364
+ const lead = team().find((a) => a.isLead);
365
+ if (lead) agent.sandbox = structuredClone(lead.sandbox);
366
+ team().push(agent);
367
+ const status = Hub.freshStatus(id);
368
+ hub.snap.agents.push(status);
369
+ hub.configChanged();
370
+ hub.emit({ type: "agent.updated", agent: structuredClone(status) });
371
+ hub.activity("config", null, `Added ${agent.name} (${agent.role}). Press Start to launch their session.`);
372
+ if (agent.cli === "codex") void sessions.recheckSetup();
373
+ },
374
+ updateAgent: (id, patch) => {
375
+ const agent = hub.cfg(str(id));
376
+ if (!agent) return;
377
+ const p = patch ?? {};
378
+ const before = `${agent.cli}/${agent.mode}`;
379
+ for (const k of ["name", "role", "model", "color", "roleMd"]) if (typeof p[k] === "string") agent[k] = str(p[k], 8e3);
380
+ if (p.mode === "sandbox" || p.mode === "local") agent.mode = p.mode;
381
+ if ((p.cli === "claude" || p.cli === "codex") && p.cli !== agent.cli) {
382
+ agent.cli = p.cli;
383
+ if (typeof p.model !== "string") agent.model = "";
384
+ }
385
+ if (Array.isArray(p.skills)) agent.skills = p.skills.map((s) => str(s, 60)).filter(Boolean);
386
+ if (p.workspacePath !== void 0) agent.workspacePath = p.workspacePath ? str(p.workspacePath, 500).trim() || null : null;
387
+ hub.configChanged();
388
+ if (`${agent.cli}/${agent.mode}` !== before) void sessions.recheckSetup();
389
+ },
390
+ inspectWorkspace: async (id) => {
391
+ const info = await sessions.inspectWorkspace(str(id, 80));
392
+ if (info) hub.emit({ type: "workspace.info", info });
393
+ },
394
+ removeAgent: async (id, opts) => {
395
+ const agent = hub.cfg(str(id));
396
+ if (!agent || agent.isLead) return;
397
+ const o = opts ?? {};
398
+ const removed = await sessions.removeResources(agent.id, { deleteSandbox: o.deleteSandbox !== false, deleteFolder: o.deleteFolder === true });
399
+ hub.snap.config.team = team().filter((a) => a.id !== agent.id);
400
+ hub.snap.agents = hub.snap.agents.filter((a) => a.agentId !== agent.id);
401
+ inbox.forget(agent.id);
402
+ for (const t2 of hub.snap.tasks)
403
+ if (t2.ownerId === agent.id && t2.status !== "done") {
404
+ t2.ownerId = null;
405
+ if (t2.status === "in-progress") t2.status = "todo";
406
+ hub.emit({ type: "task.updated", task: { ...t2 } });
407
+ }
408
+ hub.planChanged();
409
+ hub.emit({ type: "agent.removed", agentId: agent.id });
410
+ hub.configChanged();
411
+ const what = removed.length ? ` Deleted: ${removed.join(" and ")}.` : "";
412
+ const kept = removed.includes("folder") ? "" : " Their folder is kept.";
413
+ hub.activity("config", null, `Removed ${agent.name}.${what}${kept}`);
414
+ },
415
+ updateSandbox: (id, patch) => {
416
+ const agent = hub.cfg(str(id));
417
+ if (!agent) return;
418
+ const p = patch ?? {};
419
+ const clean = (list) => [...new Set(list.map((d) => str(d, 200).trim().toLowerCase()).filter(Boolean))];
420
+ const rulesChanged = Array.isArray(p.allow) || Array.isArray(p.deny);
421
+ if (Array.isArray(p.allow)) agent.sandbox.allow = clean(p.allow);
422
+ if (Array.isArray(p.deny)) agent.sandbox.deny = clean(p.deny);
423
+ if (p.resources) {
424
+ const m = hub.snap.setup.machine;
425
+ const cpus2 = p.resources.cpus === null ? null : Math.round(Math.min(m.cpus, Math.max(1, num(p.resources.cpus))));
426
+ const maxMem = Math.max(0.5, Math.floor(m.memoryGb * 0.75 * 2) / 2);
427
+ const memoryGb = p.resources.memoryGb === null ? null : Math.min(maxMem, Math.max(0.5, Math.round(num(p.resources.memoryGb) * 2) / 2));
428
+ agent.sandbox.resources = { cpus: cpus2, memoryGb };
429
+ }
430
+ hub.configChanged();
431
+ if (rulesChanged) void sbx.syncRules(agent.id);
432
+ },
433
+ setSecret: (id, service, value) => {
434
+ const agentId = id === null ? null : str(id, 80);
435
+ const svc = str(service, 40);
436
+ if (!SERVICE_KEYS.includes(svc) || typeof value !== "string" || !value.trim()) return;
437
+ if (agentId && !hub.cfg(agentId)) return;
438
+ return attempt(agentId, `Stored the ${svc} key ${agentId ? `for ${hub.name(agentId)}'s sandbox` : "for every sandbox"}`, () => sbx.setKey(agentId, svc, value));
439
+ },
440
+ removeSecret: (id, service) => {
441
+ const agentId = id === null ? null : str(id, 80);
442
+ const svc = str(service, 40);
443
+ if (!SERVICE_KEYS.includes(svc)) return;
444
+ return attempt(agentId, `Removed the ${svc} key ${agentId ? `from ${hub.name(agentId)}'s sandbox` : "from every sandbox"}`, () => sbx.removeKey(agentId, svc));
445
+ },
446
+ setProxy: (url) => {
447
+ const u = typeof url === "string" && url.trim() ? str(url, 300).trim() : null;
448
+ if (u && !/^https?:\/\/[^\s]+$/i.test(u)) return hub.activity("config", null, "Proxy must be an http(s):// URL");
449
+ return attempt(null, u ? `Set the sandbox proxy to ${u}` : "Removed the sandbox proxy", () => sbx.setProxy(u));
450
+ },
451
+ openEnvFile: async (id) => {
452
+ const agent = hub.cfg(str(id, 80));
453
+ if (!agent) return;
454
+ const file = sbx.ensureEnvFile(agent.id);
455
+ const [cmd, args] = process.platform === "darwin" ? ["open", ["-t", file]] : process.platform === "win32" ? ["notepad", [file]] : ["xdg-open", [file]];
456
+ await run(cmd, args, { timeoutMs: 5e3 });
457
+ await sbx.refresh();
458
+ },
459
+ refreshSbx: () => sbx.refresh(),
460
+ completeWelcome: async (raw) => {
461
+ if (!hub.snap.welcome) return;
462
+ const input = raw ?? {};
463
+ const cli = (c) => c === "codex" ? "codex" : "claude";
464
+ const used = /* @__PURE__ */ new Set();
465
+ const make = (name, role, agentCli, isLead) => {
466
+ const clean = str(name, 40).trim() || (isLead ? "Lead" : "Agent");
467
+ let id = slug(clean);
468
+ for (let i = 2; used.has(id); i++) id = `${slug(clean)}-${i}`;
469
+ used.add(id);
470
+ const agent = newAgent(id, clean, isLead ? "Lead" : str(role, 40).trim() || "Developer", agentPalette[used.size - 1] ?? agentPalette[0], isLead);
471
+ agent.cli = agentCli;
472
+ return agent;
473
+ };
474
+ const lead = make(input.lead?.name ?? "John", "Lead", cli(input.lead?.cli), true);
475
+ const workers = (Array.isArray(input.workers) ? input.workers : []).slice(0, 11).map((w) => make(w?.name ?? "", w?.role ?? "", cli(w?.cli), false));
476
+ if (!workers.length) return hub.activity("config", null, "Add at least one worker to the team.");
477
+ const cfg = hub.snap.config;
478
+ if (typeof input.projectName === "string" && input.projectName.trim()) cfg.project.name = str(input.projectName, 80).trim();
479
+ cfg.team = [lead, ...workers];
480
+ const tools = ["coderabbit", "greptile", "copilot", "human", "custom"];
481
+ if (tools.includes(input.reviewTool)) cfg.workflow.review.tool = input.reviewTool;
482
+ if (typeof input.reviewEnabled === "boolean") cfg.workflow.review.enabled = input.reviewEnabled;
483
+ hub.snap.agents = cfg.team.map((a) => Hub.freshStatus(a.id));
484
+ hub.snap.welcome = false;
485
+ hub.configChanged();
486
+ hub.planChanged();
487
+ await sessions.recheckSetup();
488
+ await sbx.refresh();
489
+ hub.emit({ type: "snapshot", snapshot: structuredClone(hub.snap) });
490
+ hub.activity("config", null, `Welcome! Team created: ${cfg.team.map((a) => a.name).join(", ")}. Press Start team when you're ready.`);
491
+ },
492
+ updateWorkflow: (patch) => {
493
+ const p = patch ?? {};
494
+ const wf = hub.snap.config.workflow;
495
+ Object.assign(wf.review, p.review ?? {});
496
+ Object.assign(wf.merge, p.merge ?? {});
497
+ Object.assign(wf.deploy, p.deploy ?? {});
498
+ hub.configChanged();
499
+ },
500
+ updateProject: (patch) => {
501
+ const p = patch ?? {};
502
+ const project = hub.snap.config.project;
503
+ if (typeof p.name === "string" && p.name.trim()) project.name = str(p.name, 80);
504
+ if (typeof p.repo === "string" && p.repo.trim()) project.repo = str(p.repo, 200);
505
+ const exportOn = typeof p.exportPlan === "boolean" && p.exportPlan && !project.exportPlan;
506
+ if (typeof p.exportPlan === "boolean") project.exportPlan = p.exportPlan;
507
+ hub.configChanged();
508
+ if (exportOn) {
509
+ hub.planChanged();
510
+ hub.activity("config", null, "Plan export on: HQ writes docs/plan.md whenever the plan changes.");
511
+ }
512
+ },
513
+ createBoard: (title) => planner.createBoard(str(title, 80)),
514
+ updateBoard: (id, patch) => {
515
+ const p = patch ?? {};
516
+ const status = p.status === "active" || p.status === "planned" || p.status === "done" ? p.status : void 0;
517
+ planner.updateBoard(str(id, 20), { title: typeof p.title === "string" ? p.title : void 0, status });
518
+ }
519
+ };
520
+ return commands;
521
+ }
522
+
523
+ // server/http.ts
524
+ import { createReadStream, existsSync as existsSync3, statSync } from "node:fs";
525
+ import { createServer } from "node:http";
526
+ import { extname, join as join4, normalize } from "node:path";
527
+ import { timingSafeEqual } from "node:crypto";
528
+ import { WebSocketServer } from "ws";
529
+
530
+ // server/hooks.ts
531
+ var clip = (s, n = 80) => s.length > n ? s.slice(0, n - 1) + "\u2026" : s;
532
+ var turnOpen = /* @__PURE__ */ new Map();
533
+ function handleHook(hub, inbox, agentId, event, body) {
534
+ const st = hub.status(agentId);
535
+ const name = hub.name(agentId);
536
+ if (!st) return { ok: false };
537
+ inbox.noteHook(agentId, event);
538
+ switch (event) {
539
+ case "SessionStart":
540
+ turnOpen.set(agentId, false);
541
+ hub.setStatus(agentId, { state: "idle", note: "Ready" });
542
+ break;
543
+ case "UserPromptSubmit":
544
+ turnOpen.set(agentId, true);
545
+ hub.setStatus(agentId, { state: "working", note: body.prompt ? clip(body.prompt.replace(/\s+/g, " ")) : null });
546
+ if (st.state !== "working") hub.activity("task.started", agentId, `${name} is working`);
547
+ break;
548
+ case "PreToolUse":
549
+ if (turnOpen.get(agentId) === false) break;
550
+ if (st.state !== "working" || st.note?.startsWith("Asking")) hub.setStatus(agentId, { state: "working", note: body.tool_name ? `Using ${body.tool_name}` : null });
551
+ break;
552
+ case "PermissionRequest": {
553
+ const what = body.tool_name ? `Approve ${body.tool_name}?` : "Approval needed in the terminal";
554
+ hub.setStatus(agentId, { state: "needs-input", note: `Asking: ${what}` });
555
+ hub.activity("agent.needs-input", agentId, `${name} needs you: ${what}`);
556
+ break;
557
+ }
558
+ case "Notification": {
559
+ const msg = body.message ?? "Needs your attention";
560
+ if (/waiting for your input/i.test(msg) || body.notification_type === "idle_prompt") {
561
+ hub.setStatus(agentId, { state: "idle", note: "Waiting for you" });
562
+ break;
563
+ }
564
+ hub.setStatus(agentId, { state: "needs-input", note: `Asking: ${clip(msg)}` });
565
+ hub.activity("agent.needs-input", agentId, `${name} needs you: ${clip(msg, 60)}`);
566
+ break;
567
+ }
568
+ case "Stop": {
569
+ const queued = inbox.takeForStop(agentId);
570
+ if (queued) {
571
+ turnOpen.set(agentId, true);
572
+ hub.setStatus(agentId, { state: "working", note: "Reading new messages" });
573
+ return { ok: true, response: { decision: "block", reason: queued } };
574
+ }
575
+ turnOpen.set(agentId, false);
576
+ hub.setStatus(agentId, { state: "idle", note: "Finished \u2014 ready for the next instruction" });
577
+ hub.activity("agent.answered", agentId, `${name} finished a turn`);
578
+ break;
579
+ }
580
+ case "SessionEnd":
581
+ turnOpen.set(agentId, false);
582
+ hub.setStatus(agentId, { state: "idle", note: "Session ended" });
583
+ break;
584
+ default:
585
+ return { ok: false };
586
+ }
587
+ return { ok: true };
588
+ }
589
+
590
+ // server/mcp.ts
591
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
592
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
593
+ import { z } from "zod";
594
+ var ok = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
595
+ var fail = (msg) => ({ content: [{ type: "text", text: msg }], isError: true });
596
+ function guard(fn) {
597
+ return async (args) => {
598
+ try {
599
+ return fn(args);
600
+ } catch (err) {
601
+ return fail(err instanceof Error ? err.message : String(err));
602
+ }
603
+ };
604
+ }
605
+ function buildServer(hub, planner, me) {
606
+ const server = new McpServer({ name: "hq", version: "0.1.0" });
607
+ const isLead = !!hub.cfg(me)?.isLead;
608
+ const leadOnly = () => {
609
+ if (!isLead) throw new Error("Only the lead can do this. Ask the lead with send_message.");
610
+ };
611
+ const taskView = (id) => {
612
+ const t2 = planner.task(id);
613
+ return t2 && { ...t2, owner: t2.ownerId ? hub.name(t2.ownerId) : null };
614
+ };
615
+ server.registerTool(
616
+ "list_workers",
617
+ { description: "The team: each agent's id, name, role, whether they are running, and what they are doing now." },
618
+ guard(
619
+ () => ok(
620
+ hub.snap.config.team.map((a) => {
621
+ const st = hub.status(a.id);
622
+ return {
623
+ id: a.id,
624
+ name: a.name,
625
+ role: a.role,
626
+ lead: a.isLead,
627
+ you: a.id === me,
628
+ running: st?.session.state === "running",
629
+ state: st?.state,
630
+ task: st?.currentTaskId ?? null,
631
+ free: st?.session.state === "running" && !a.isLead && !planner.currentTask(a.id)
632
+ };
633
+ })
634
+ )
635
+ )
636
+ );
637
+ server.registerTool(
638
+ "get_status",
639
+ { description: "Live status of every agent (state, note, current task) plus the active boards with task counts." },
640
+ guard(
641
+ () => ok({
642
+ agents: hub.snap.agents.map((s) => ({ id: s.agentId, name: hub.name(s.agentId), state: s.state, note: s.note, task: s.currentTaskId, session: s.session.state })),
643
+ boards: hub.snap.boards.map((b) => {
644
+ const ts = hub.snap.tasks.filter((t2) => t2.boardId === b.id);
645
+ const n = (st) => ts.filter((t2) => t2.status === st).length;
646
+ return { id: b.id, title: b.title, status: b.status, todo: n("todo"), inProgress: n("in-progress"), inReview: n("in-review"), blocked: n("blocked"), done: n("done") };
647
+ })
648
+ })
649
+ )
650
+ );
651
+ server.registerTool(
652
+ "list_tasks",
653
+ {
654
+ description: "Tasks, optionally filtered by board, status or owner.",
655
+ inputSchema: {
656
+ boardId: z.string().optional().describe("Board id, e.g. P3"),
657
+ status: z.enum(["todo", "in-progress", "in-review", "done", "blocked"]).optional(),
658
+ owner: z.string().optional().describe("Agent id or name")
659
+ }
660
+ },
661
+ guard(({ boardId, status, owner }) => {
662
+ const ownerId = owner ? planner.agentId(owner) : void 0;
663
+ const list = hub.snap.tasks.filter((t2) => (!boardId || t2.boardId === boardId) && (!status || t2.status === status) && (!owner || t2.ownerId === ownerId));
664
+ return ok(list.map((t2) => ({ id: t2.id, board: t2.boardId, title: t2.title, status: t2.status, owner: t2.ownerId ? hub.name(t2.ownerId) : null, needs: t2.needs })));
665
+ })
666
+ );
667
+ server.registerTool(
668
+ "create_board",
669
+ { description: "Lead only. Create a board (a phase or an area of work).", inputSchema: { title: z.string(), active: z.boolean().optional().describe("Agents only pick tasks from active boards") } },
670
+ guard(({ title, active }) => {
671
+ leadOnly();
672
+ return ok(planner.createBoard(title, active ? "active" : "planned"));
673
+ })
674
+ );
675
+ server.registerTool(
676
+ "create_task",
677
+ {
678
+ description: "Lead only. Add a task. Keep tasks small: one task = one branch = one PR.",
679
+ inputSchema: {
680
+ title: z.string(),
681
+ boardId: z.string().optional().describe("Defaults to the first active board"),
682
+ description: z.string().optional().describe("What to build and why"),
683
+ files: z.array(z.string()).optional().describe("The only files/folders this task may edit"),
684
+ doneWhen: z.string().optional().describe("Acceptance criteria"),
685
+ needs: z.array(z.string()).optional().describe("Task ids that must be done first")
686
+ }
687
+ },
688
+ guard((args) => {
689
+ leadOnly();
690
+ return ok(planner.createTask(args, me));
691
+ })
692
+ );
693
+ server.registerTool(
694
+ "update_task",
695
+ {
696
+ description: "Lead only. Edit a task's details or status.",
697
+ inputSchema: {
698
+ taskId: z.string(),
699
+ title: z.string().optional(),
700
+ description: z.string().optional(),
701
+ files: z.array(z.string()).optional(),
702
+ doneWhen: z.string().optional(),
703
+ needs: z.array(z.string()).optional(),
704
+ status: z.enum(["todo", "in-progress", "in-review", "done", "blocked"]).optional()
705
+ }
706
+ },
707
+ guard(({ taskId, ...patch }) => {
708
+ leadOnly();
709
+ return ok(planner.updateTask(taskId, patch));
710
+ })
711
+ );
712
+ server.registerTool(
713
+ "assign_task",
714
+ {
715
+ description: "Lead only. Give a task to an agent. HQ tells them to pull main and start it (right away if idle, otherwise when their current turn ends).",
716
+ inputSchema: { taskId: z.string(), agent: z.string().describe("Agent id or name") }
717
+ },
718
+ guard(({ taskId, agent }) => {
719
+ leadOnly();
720
+ const t2 = planner.assign(taskId, agent, me);
721
+ return ok(`Assigned ${t2.id} to ${hub.name(t2.ownerId)}. They have been told to start.`);
722
+ })
723
+ );
724
+ server.registerTool(
725
+ "get_my_task",
726
+ { description: "Your current task: title, description, the files you may edit, done-when, and what it needs. Marks it in progress." },
727
+ guard(() => {
728
+ const t2 = planner.currentTask(me);
729
+ if (!t2) return ok("You have no task right now. Wait for the lead, or ask with send_message.");
730
+ if (t2.status === "todo") planner.setStatus(me, "in-progress");
731
+ const blockedBy = t2.needs.filter((n) => planner.task(n)?.status !== "done");
732
+ return ok({ ...taskView(t2.id), waitingOn: blockedBy, rules: "Work only on this task, on its own branch. Edit only the listed files. Title the PR `[" + t2.id + "] " + t2.title + "`. When the PR is open, call set_my_status with in-review and the PR number." });
733
+ })
734
+ );
735
+ server.registerTool(
736
+ "set_my_status",
737
+ {
738
+ description: "Update your current task: in-progress, blocked (say why), in-review (PR opened), or done. The lead is told automatically.",
739
+ inputSchema: {
740
+ status: z.enum(["in-progress", "blocked", "in-review", "done"]),
741
+ note: z.string().optional().describe("Why you are blocked, or a short summary"),
742
+ prNumber: z.number().int().optional()
743
+ }
744
+ },
745
+ guard(({ status, note, prNumber }) => {
746
+ const t2 = planner.setStatus(me, status, note, prNumber);
747
+ return ok(`${t2.id} is now ${t2.status}.`);
748
+ })
749
+ );
750
+ server.registerTool(
751
+ "send_message",
752
+ {
753
+ description: 'Send a message to another agent (id, name, or "lead"). HQ delivers it: right away if they are idle, otherwise when their current turn ends.',
754
+ inputSchema: { to: z.string(), text: z.string() }
755
+ },
756
+ guard(({ to, text }) => {
757
+ const toId = planner.agentId(to);
758
+ if (toId === me) throw new Error("That's you.");
759
+ planner.message(me, to, text);
760
+ return ok(`Sent to ${hub.name(toId)}.`);
761
+ })
762
+ );
763
+ return server;
764
+ }
765
+ async function handleMcp(hub, planner, agentId, req, res, body) {
766
+ const server = buildServer(hub, planner, agentId);
767
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0, enableJsonResponse: true });
768
+ res.on("close", () => {
769
+ void transport.close();
770
+ void server.close();
771
+ });
772
+ await server.connect(transport);
773
+ await transport.handleRequest(req, res, body);
774
+ }
775
+
776
+ // server/lib/log.ts
777
+ var dim = (s) => `\x1B[2m${s}\x1B[0m`;
778
+ var log = {
779
+ info: (...a) => console.log(dim("[hq]"), ...a),
780
+ warn: (...a) => console.warn("\x1B[33m[hq]\x1B[0m", ...a),
781
+ error: (...a) => console.error("\x1B[31m[hq]\x1B[0m", ...a)
782
+ };
783
+
784
+ // server/http.ts
785
+ var MIME = {
786
+ ".html": "text/html; charset=utf-8",
787
+ ".js": "text/javascript",
788
+ ".css": "text/css",
789
+ ".svg": "image/svg+xml",
790
+ ".woff2": "font/woff2",
791
+ ".woff": "font/woff",
792
+ ".png": "image/png",
793
+ ".json": "application/json"
794
+ };
795
+ function safeEqual(a, b) {
796
+ const x = Buffer.from(a);
797
+ const y = Buffer.from(b);
798
+ return x.length === y.length && timingSafeEqual(x, y);
799
+ }
800
+ function readBody(req, limit = 256 * 1024) {
801
+ return new Promise((resolve3, reject) => {
802
+ let size = 0;
803
+ const chunks = [];
804
+ req.on("data", (c) => {
805
+ size += c.length;
806
+ if (size > limit) reject(new Error("too large"));
807
+ else chunks.push(c);
808
+ });
809
+ req.on("end", () => resolve3(Buffer.concat(chunks).toString("utf8")));
810
+ req.on("error", reject);
811
+ });
812
+ }
813
+ function startHttp(o) {
814
+ const send = (res, code, body, type = "text/plain") => {
815
+ res.writeHead(code, { "Content-Type": type });
816
+ res.end(body);
817
+ };
818
+ const server = createServer(async (req, res) => {
819
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
820
+ if (req.method === "POST" && url.pathname.startsWith("/hook/")) {
821
+ const auth = req.headers.authorization ?? "";
822
+ if (!safeEqual(auth, `Bearer ${o.agentToken}`)) return send(res, 401, "unauthorized");
823
+ const [, , agentId, event] = url.pathname.split("/");
824
+ try {
825
+ const body = await readBody(req);
826
+ const r = handleHook(o.hub, o.inbox, decodeURIComponent(agentId ?? ""), event ?? "", body ? JSON.parse(body) : {});
827
+ if (!r.ok) return send(res, 404, "");
828
+ if (r.response) return send(res, 200, JSON.stringify(r.response), "application/json");
829
+ res.writeHead(204);
830
+ return void res.end();
831
+ } catch {
832
+ return send(res, 400, "bad request");
833
+ }
834
+ }
835
+ if (url.pathname === "/mcp") {
836
+ const auth = req.headers.authorization ?? "";
837
+ const agentId = String(req.headers["x-hq-agent"] ?? "");
838
+ if (!safeEqual(auth, `Bearer ${o.agentToken}`) || !o.hub.cfg(agentId)) return send(res, 401, "unauthorized");
839
+ if (req.method === "GET" || req.method === "DELETE") return send(res, 405, "stateless server");
840
+ try {
841
+ const raw = await readBody(req, 1024 * 1024);
842
+ return await handleMcp(o.hub, o.planner, agentId, req, res, raw ? JSON.parse(raw) : void 0);
843
+ } catch {
844
+ return res.headersSent ? void res.end() : send(res, 400, "bad request");
845
+ }
846
+ }
847
+ if (url.pathname === "/api/health") return send(res, 200, JSON.stringify({ ok: true }), "application/json");
848
+ if (req.method === "GET" && o.distDir) {
849
+ const rel = normalize(url.pathname === "/" ? "/index.html" : url.pathname).replace(/^(\.\.[/\\])+/, "");
850
+ let file = join4(o.distDir, rel);
851
+ if (!file.startsWith(o.distDir) || !existsSync3(file) || statSync(file).isDirectory()) file = join4(o.distDir, "index.html");
852
+ res.writeHead(200, { "Content-Type": MIME[extname(file)] ?? "application/octet-stream" });
853
+ return void createReadStream(file).pipe(res);
854
+ }
855
+ send(res, 404, "not found");
856
+ });
857
+ const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
858
+ server.on("upgrade", (req, socket, head) => {
859
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
860
+ const origin = req.headers.origin ?? "";
861
+ const token = url.searchParams.get("token") ?? "";
862
+ if (url.pathname !== "/ws" || !safeEqual(token, o.browserToken) || origin && !o.allowedOrigins.includes(origin)) {
863
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
864
+ return socket.destroy();
865
+ }
866
+ wss.handleUpgrade(req, socket, head, (ws) => onClient(ws));
867
+ });
868
+ function onClient(ws) {
869
+ const sendEvent = (e) => ws.readyState === ws.OPEN && ws.send(JSON.stringify(e));
870
+ sendEvent({ type: "snapshot", snapshot: o.hub.snap });
871
+ const unsub = o.hub.subscribe(sendEvent);
872
+ ws.on("message", async (raw) => {
873
+ try {
874
+ const msg = JSON.parse(String(raw));
875
+ const fn = msg.cmd ? o.commands[msg.cmd] : void 0;
876
+ if (!fn) return log.warn("unknown command", msg.cmd);
877
+ await fn(...Array.isArray(msg.args) ? msg.args : []);
878
+ } catch (err) {
879
+ log.warn("command failed:", err instanceof Error ? err.message : err);
880
+ }
881
+ });
882
+ ws.on("close", () => unsub());
883
+ }
884
+ return new Promise((resolve3, reject) => {
885
+ server.once("error", reject);
886
+ server.listen(o.port, "127.0.0.1", () => resolve3(server));
887
+ });
888
+ }
889
+
890
+ // server/github.ts
891
+ var PR_FIELDS = `
892
+ number title url headRefName createdAt mergedAt state mergeable isDraft reviewDecision
893
+ reviews(last: 20) { nodes { author { login } state submittedAt } }
894
+ reviewThreads(first: 100) { nodes { isResolved isOutdated comments(first: 1) { nodes { author { login } } } } }
895
+ commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: 50) { nodes {
896
+ __typename
897
+ ... on CheckRun { name status conclusion }
898
+ ... on StatusContext { context state description }
899
+ } } } } } }`;
900
+ var QUERY = `query($owner: String!, $name: String!) {
901
+ repository(owner: $owner, name: $name) {
902
+ open: pullRequests(states: OPEN, first: 30, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { ${PR_FIELDS} } }
903
+ merged: pullRequests(states: MERGED, first: 15, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { ${PR_FIELDS} } }
904
+ }
905
+ }`;
906
+ async function fetchPrs(repo) {
907
+ const [owner, name] = repo.split("/");
908
+ const r = await run("gh", ["api", "graphql", "-f", `query=${QUERY}`, "-F", `owner=${owner}`, "-F", `name=${name}`], { timeoutMs: 3e4 });
909
+ if (!r.ok) throw new Error(r.stderr.trim().split("\n")[0] || "gh api failed");
910
+ const data = JSON.parse(r.stdout);
911
+ if (!data.data?.repository) throw new Error(data.errors?.[0]?.message ?? "Repository not found");
912
+ return { open: data.data.repository.open.nodes, merged: data.data.repository.merged.nodes };
913
+ }
914
+ var BOTS = {
915
+ coderabbit: { logins: ["coderabbitai", "coderabbitai[bot]"], check: /coderabbit/i, label: "CodeRabbit" },
916
+ greptile: { logins: ["greptile-apps", "greptile-apps[bot]"], check: /greptile/i, label: "Greptile" },
917
+ copilot: { logins: ["copilot-pull-request-reviewer", "copilot-pull-request-reviewer[bot]", "Copilot"], check: /copilot/i, label: "Copilot" }
918
+ };
919
+ var contexts = (pr) => pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? [];
920
+ var openThreads = (pr, logins) => pr.reviewThreads.nodes.filter((t2) => !t2.isResolved && !t2.isOutdated && (!logins || logins.includes(t2.comments.nodes[0]?.author?.login ?? ""))).length;
921
+ function reviewOf(pr, tool) {
922
+ if (tool === "custom") return { review: "pending", openComments: openThreads(pr), note: "Custom review checks aren't run by HQ yet" };
923
+ if (tool === "human") {
924
+ const decided = pr.reviewDecision ?? [...pr.reviews.nodes].reverse().find((r) => r.state === "APPROVED" || r.state === "CHANGES_REQUESTED")?.state ?? null;
925
+ const open2 = openThreads(pr);
926
+ if (decided === "APPROVED") return { review: "passed", openComments: open2, note: null };
927
+ if (decided === "CHANGES_REQUESTED") return { review: "changes-requested", openComments: open2, note: null };
928
+ return { review: "pending", openComments: open2, note: "Waiting for a reviewer" };
929
+ }
930
+ const bot = BOTS[tool];
931
+ const open = openThreads(pr, bot.logins);
932
+ const ctx = contexts(pr).find((c) => bot.check.test(c.__typename === "CheckRun" ? c.name : c.context));
933
+ const pending = ctx && (ctx.__typename === "CheckRun" ? ctx.status !== "COMPLETED" : ctx.state === "PENDING" || ctx.state === "EXPECTED");
934
+ if (pending) return { review: "reviewing", openComments: open, note: null };
935
+ if (open > 0) return { review: "changes-requested", openComments: open, note: null };
936
+ const desc = ctx?.__typename === "StatusContext" ? ctx.description ?? "" : "";
937
+ if (/rate.?limit|skip|paused|disabled/i.test(desc)) return { review: "pending", openComments: 0, note: `${bot.label}: ${desc.toLowerCase()}` };
938
+ const ok2 = ctx && (ctx.__typename === "CheckRun" ? ctx.conclusion === "SUCCESS" || ctx.conclusion === "NEUTRAL" : ctx.state === "SUCCESS");
939
+ if (ok2) return { review: "passed", openComments: 0, note: null };
940
+ if (ctx) return { review: "changes-requested", openComments: 0, note: `${bot.label} check failed` };
941
+ const botReviewed = pr.reviews.nodes.some((r) => bot.logins.includes(r.author?.login ?? ""));
942
+ return botReviewed ? { review: "passed", openComments: 0, note: null } : { review: "pending", openComments: 0, note: `Waiting for ${bot.label}` };
943
+ }
944
+ var TASK_ID = /([A-Z][A-Z0-9]*-T\d+)/i;
945
+ function taskIdOf(pr) {
946
+ const inTitle = pr.title.match(/\[([A-Z][A-Z0-9]*-T\d+)\]/i);
947
+ if (inTitle) return inTitle[1].toUpperCase();
948
+ const inBranch = pr.headRefName.match(TASK_ID);
949
+ return inBranch ? inBranch[1].toUpperCase() : null;
950
+ }
951
+ function branchAgent(branch) {
952
+ const m = branch.match(/^([a-z0-9][a-z0-9-]*)\//i);
953
+ return m ? m[1].toLowerCase() : null;
954
+ }
955
+ function toPullRequest(pr, tool, reviewEnabled, taskId, authorId) {
956
+ const merged = pr.state === "MERGED";
957
+ const r = reviewEnabled ? reviewOf(pr, tool) : { review: "passed", openComments: 0, note: null };
958
+ return {
959
+ number: pr.number,
960
+ title: pr.title,
961
+ taskId,
962
+ authorId,
963
+ review: merged && r.review !== "changes-requested" ? "passed" : r.review,
964
+ openComments: r.openComments,
965
+ mergeable: pr.mergeable === "MERGEABLE",
966
+ merged,
967
+ openedAt: pr.createdAt,
968
+ url: pr.url,
969
+ branch: pr.headRefName,
970
+ closed: pr.state === "CLOSED",
971
+ conflict: pr.mergeable === "CONFLICTING",
972
+ reviewNote: pr.isDraft ? "Draft" : r.note
973
+ };
974
+ }
975
+
976
+ // server/githubSync.ts
977
+ var POLL_MS = 2e4;
978
+ var label = { coderabbit: "CodeRabbit", greptile: "Greptile", copilot: "Copilot", human: "The reviewer", custom: "The review check" };
979
+ var GitHubSync = class {
980
+ constructor(hub, planner, repo) {
981
+ this.hub = hub;
982
+ this.planner = planner;
983
+ this.repo = repo;
984
+ }
985
+ hub;
986
+ planner;
987
+ repo;
988
+ prev = /* @__PURE__ */ new Map();
989
+ first = true;
990
+ timer = null;
991
+ nudgeTimer = null;
992
+ busy = false;
993
+ lastError = null;
994
+ start() {
995
+ if (!this.repo) {
996
+ log.warn("No GitHub remote: PRs and reviews are off.");
997
+ return;
998
+ }
999
+ void this.refresh();
1000
+ this.timer = setInterval(() => void this.refresh(), POLL_MS);
1001
+ let reviewCfg = JSON.stringify(this.hub.snap.config.workflow.review);
1002
+ this.hub.subscribe((e) => {
1003
+ if (e.type === "config.updated" && JSON.stringify(e.config.workflow.review) !== reviewCfg) {
1004
+ reviewCfg = JSON.stringify(e.config.workflow.review);
1005
+ this.first = true;
1006
+ this.nudge(500);
1007
+ }
1008
+ if (e.type === "activity" && e.event.type === "agent.answered") this.nudge();
1009
+ if (e.type === "task.updated" && e.task.status === "in-review") this.nudge(1500);
1010
+ });
1011
+ }
1012
+ stop() {
1013
+ if (this.timer) clearInterval(this.timer);
1014
+ }
1015
+ /** Refresh soon (debounced): an agent just opened a PR or finished a turn. */
1016
+ nudge(delayMs = 3e3) {
1017
+ if (!this.repo) return;
1018
+ if (this.nudgeTimer) clearTimeout(this.nudgeTimer);
1019
+ this.nudgeTimer = setTimeout(() => void this.refresh(), delayMs);
1020
+ }
1021
+ async refresh() {
1022
+ if (!this.repo || this.busy) return;
1023
+ if (!this.hub.snap.setup.checks.find((c) => c.id === "gh" && c.status === "ok")) return;
1024
+ this.busy = true;
1025
+ try {
1026
+ const { open, merged } = await fetchPrs(this.repo);
1027
+ if (this.lastError) log.info("GitHub reachable again");
1028
+ this.lastError = null;
1029
+ const wf = this.hub.snap.config.workflow;
1030
+ const team = new Set(this.hub.snap.config.team.map((a) => a.id));
1031
+ const next = [...open, ...merged].map((gh) => {
1032
+ const taskId = taskIdOf(gh);
1033
+ const task = taskId ? this.planner.task(taskId) : void 0;
1034
+ const fromBranch = branchAgent(gh.headRefName);
1035
+ const authorId = task?.ownerId ?? (fromBranch && team.has(fromBranch) ? fromBranch : "");
1036
+ return toPullRequest(gh, wf.review.tool, wf.review.enabled, task?.id ?? taskId ?? "", authorId);
1037
+ });
1038
+ const seen = new Set(next.map((p) => p.number));
1039
+ for (const old of this.prev.values()) if (!seen.has(old.number) && !old.merged && !old.closed) next.push({ ...old, closed: true });
1040
+ for (const pr of next) {
1041
+ const old = this.prev.get(pr.number);
1042
+ if (JSON.stringify(old) !== JSON.stringify(pr)) this.hub.emit({ type: "pr.updated", pr: { ...pr } });
1043
+ if (!this.first) this.react(old, pr);
1044
+ this.syncTask(pr);
1045
+ }
1046
+ this.hub.snap.pullRequests = next;
1047
+ this.prev = new Map(next.map((p) => [p.number, p]));
1048
+ this.first = false;
1049
+ } catch (err) {
1050
+ const msg = err instanceof Error ? err.message : String(err);
1051
+ if (msg !== this.lastError) log.warn(`GitHub: ${msg}`);
1052
+ this.lastError = msg;
1053
+ } finally {
1054
+ this.busy = false;
1055
+ }
1056
+ }
1057
+ /** Keep the task board in line with its PR. */
1058
+ syncTask(pr) {
1059
+ const task = pr.taskId ? this.planner.task(pr.taskId) : void 0;
1060
+ if (!task || pr.closed) return;
1061
+ if (pr.merged && task.status !== "done") this.planner.updateTask(task.id, { status: "done", prNumber: pr.number });
1062
+ else if (!pr.merged && (task.status === "todo" || task.status === "in-progress")) this.planner.updateTask(task.id, { status: "in-review", prNumber: pr.number });
1063
+ else if (!pr.merged && task.prNumber !== pr.number) this.planner.updateTask(task.id, { prNumber: pr.number });
1064
+ }
1065
+ react(old, pr) {
1066
+ const hub = this.hub;
1067
+ const who = pr.authorId || null;
1068
+ const tool = label[hub.snap.config.workflow.review.tool];
1069
+ const lead = hub.snap.config.team.find((a) => a.isLead);
1070
+ const ref = `#${pr.number} ${pr.title}`;
1071
+ if (!old && !pr.merged && !pr.closed) hub.activity("pr.opened", who, `${hub.name(who)} opened PR ${ref}`);
1072
+ if (pr.closed && !old?.closed) return hub.activity("config", who, `PR #${pr.number} was closed without merging`);
1073
+ if (pr.merged && !old?.merged) {
1074
+ hub.activity("pr.merged", null, `PR ${ref} was merged`);
1075
+ if (lead) this.planner.notify(lead.id, `PR #${pr.number} (${pr.title}) was merged${pr.taskId ? `; ${pr.taskId} is done` : ""}. Assign the next task if anything is waiting.`);
1076
+ return;
1077
+ }
1078
+ if (pr.merged) return;
1079
+ if (pr.review === "reviewing" && old?.review !== "reviewing") hub.activity("review.started", null, `${tool} is reviewing #${pr.number}`);
1080
+ const newComments = pr.review === "changes-requested" && (old?.review !== "changes-requested" || pr.openComments > (old?.openComments ?? 0));
1081
+ if (newComments) {
1082
+ const n = pr.openComments;
1083
+ hub.activity("review.changes", who, `${tool} left ${n || "new"} comment${n === 1 ? "" : "s"} on #${pr.number}`);
1084
+ if (who)
1085
+ this.planner.notify(
1086
+ who,
1087
+ `${tool} reviewed your PR #${pr.number} (${pr.title}) and left ${n || "new"} open comment${n === 1 ? "" : "s"}. Read them with \`gh pr view ${pr.number} --comments\` (or the GitHub API), fix them on the same branch, push, and resolve each thread you fixed.`
1088
+ );
1089
+ }
1090
+ if (pr.review === "passed" && old?.review !== "passed") {
1091
+ hub.activity("review.passed", who, `${tool} approved #${pr.number}`);
1092
+ if (lead) this.planner.notify(lead.id, `PR #${pr.number} (${pr.title}) by ${hub.name(who)} passed review and is ready to merge.`);
1093
+ }
1094
+ if (pr.conflict && !old?.conflict && who)
1095
+ this.planner.notify(who, `Your PR #${pr.number} has merge conflicts with main. Pull main, rebase or merge it into your branch, fix the conflicts, and push.`);
1096
+ }
1097
+ };
1098
+
1099
+ // server/inbox.ts
1100
+ var QUIET_MS = 1e3;
1101
+ var SUBMIT_CHECK_MS = 3e3;
1102
+ var Inbox = class {
1103
+ constructor(hub, keys) {
1104
+ this.hub = hub;
1105
+ this.keys = keys;
1106
+ this.timer = setInterval(() => {
1107
+ for (const id of this.queues.keys()) void this.tryType(id);
1108
+ }, 2e3);
1109
+ }
1110
+ hub;
1111
+ keys;
1112
+ queues = /* @__PURE__ */ new Map();
1113
+ sessionUp = /* @__PURE__ */ new Set();
1114
+ // SessionStart seen: Claude Code's prompt exists
1115
+ lastHook = /* @__PURE__ */ new Map();
1116
+ lastSubmit = /* @__PURE__ */ new Map();
1117
+ typing = /* @__PURE__ */ new Set();
1118
+ timer;
1119
+ stop() {
1120
+ clearInterval(this.timer);
1121
+ }
1122
+ push(agentId, item) {
1123
+ const q = this.queues.get(agentId) ?? [];
1124
+ q.push(item);
1125
+ this.queues.set(agentId, q);
1126
+ void this.tryType(agentId);
1127
+ }
1128
+ pending(agentId) {
1129
+ return this.queues.get(agentId)?.length ?? 0;
1130
+ }
1131
+ /** Every hook event goes through here (before the state change it causes). */
1132
+ noteHook(agentId, event) {
1133
+ this.lastHook.set(agentId, Date.now());
1134
+ if (event === "SessionStart") this.sessionUp.add(agentId);
1135
+ if (event === "SessionEnd") this.sessionUp.delete(agentId);
1136
+ if (event === "UserPromptSubmit") this.lastSubmit.set(agentId, Date.now());
1137
+ if (event === "Stop" || event === "SessionStart" || event === "Notification") setTimeout(() => void this.tryType(agentId), QUIET_MS + 100);
1138
+ }
1139
+ /** An already-running session was reattached: its prompt exists even though HQ missed SessionStart. */
1140
+ sessionReattached(agentId) {
1141
+ this.sessionUp.add(agentId);
1142
+ }
1143
+ /** The session stopped or restarted: Claude Code's prompt is gone until the next SessionStart. */
1144
+ sessionDown(agentId) {
1145
+ this.sessionUp.delete(agentId);
1146
+ }
1147
+ forget(agentId) {
1148
+ this.queues.delete(agentId);
1149
+ this.sessionUp.delete(agentId);
1150
+ }
1151
+ /** Called from the Stop hook: everything queued, as the reason Claude continues with. */
1152
+ takeForStop(agentId) {
1153
+ const q = this.queues.get(agentId);
1154
+ if (!q?.length) return null;
1155
+ this.queues.set(agentId, []);
1156
+ return this.format(q);
1157
+ }
1158
+ canType(agentId) {
1159
+ const st = this.hub.status(agentId);
1160
+ return !!st && this.sessionUp.has(agentId) && st.session.state === "running" && st.state === "idle" && !this.typing.has(agentId) && Date.now() - (this.lastHook.get(agentId) ?? 0) >= QUIET_MS;
1161
+ }
1162
+ whyNot(agentId) {
1163
+ const st = this.hub.status(agentId);
1164
+ return JSON.stringify({ up: this.sessionUp.has(agentId), session: st?.session.state, state: st?.state, note: st?.note, typing: this.typing.has(agentId), quietMs: Date.now() - (this.lastHook.get(agentId) ?? 0) });
1165
+ }
1166
+ /** Types queued items into an idle agent. Safe to call any time. */
1167
+ async tryType(agentId) {
1168
+ const q = this.queues.get(agentId);
1169
+ if (!q?.length) return;
1170
+ if (!this.canType(agentId)) {
1171
+ if (process.env.HQ_DEBUG) log.info(`inbox ${agentId}: holding ${q.length} \u2014 ${this.whyNot(agentId)}`);
1172
+ return;
1173
+ }
1174
+ const items = q.splice(0);
1175
+ this.typing.add(agentId);
1176
+ const typedAt = Date.now();
1177
+ try {
1178
+ if (!await this.keys.type(agentId, this.format(items))) {
1179
+ log.warn(`could not type into ${agentId}; keeping ${items.length} message(s) queued`);
1180
+ this.queues.set(agentId, [...items, ...this.queues.get(agentId) ?? []]);
1181
+ return;
1182
+ }
1183
+ await new Promise((r) => setTimeout(r, SUBMIT_CHECK_MS));
1184
+ if ((this.lastSubmit.get(agentId) ?? 0) < typedAt && this.hub.status(agentId)?.state === "idle") {
1185
+ log.warn(`${agentId}: no prompt submit after typing; pressing Enter again`);
1186
+ await this.keys.enter(agentId);
1187
+ }
1188
+ } finally {
1189
+ this.typing.delete(agentId);
1190
+ }
1191
+ }
1192
+ format(items) {
1193
+ const lines = items.map(
1194
+ (i) => i.kind === "message" ? `[HQ] Message from ${this.hub.name(i.fromId)}: ${i.text}` : `[HQ] ${i.text}`
1195
+ );
1196
+ const fromAgent = items.some((i) => i.kind === "message" && i.fromId !== "you");
1197
+ return (lines.join(" \xB7 ") + (fromAgent ? " (Reply with the hq send_message tool only if a reply is needed.)" : "")).replace(/\s*\n\s*/g, " ");
1198
+ }
1199
+ };
1200
+
1201
+ // server/planner.ts
1202
+ var now2 = () => (/* @__PURE__ */ new Date()).toISOString();
1203
+ var Planner = class {
1204
+ constructor(hub, inbox) {
1205
+ this.hub = hub;
1206
+ this.inbox = inbox;
1207
+ }
1208
+ hub;
1209
+ inbox;
1210
+ seq = 0;
1211
+ get snap() {
1212
+ return this.hub.snap;
1213
+ }
1214
+ task(id) {
1215
+ return this.snap.tasks.find((t2) => t2.id.toLowerCase() === id.trim().toLowerCase());
1216
+ }
1217
+ /** An agent by id or (case-insensitive) name; "lead" means the lead. */
1218
+ agentId(ref) {
1219
+ const r = ref.trim().toLowerCase();
1220
+ const team = this.snap.config.team;
1221
+ if (r === "lead") return team.find((a) => a.isLead)?.id ?? null;
1222
+ return team.find((a) => a.id === r || a.name.toLowerCase() === r)?.id ?? null;
1223
+ }
1224
+ defaultBoard() {
1225
+ const active = this.snap.boards.filter((b) => b.status === "active").sort((a, b) => a.order - b.order);
1226
+ if (active[0]) return active[0];
1227
+ return this.createBoard("Backlog", "active");
1228
+ }
1229
+ // ------------------------------------------------------------ boards
1230
+ createBoard(title, status = "planned") {
1231
+ const clean = title.trim().slice(0, 80) || "Board";
1232
+ const base = clean.split(/\s+/).map((w) => w[0]).join("").toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 3) || "B";
1233
+ let id = base;
1234
+ for (let i = 2; this.snap.boards.some((b) => b.id === id); i++) id = `${base}${i}`;
1235
+ const board = { id, title: clean, status, order: this.snap.boards.length };
1236
+ this.snap.boards.push(board);
1237
+ this.hub.emit({ type: "board.updated", board: { ...board } });
1238
+ this.hub.planChanged();
1239
+ return board;
1240
+ }
1241
+ updateBoard(id, patch) {
1242
+ const board = this.snap.boards.find((b) => b.id === id);
1243
+ if (!board) throw new Error(`No board ${id}`);
1244
+ if (patch.title?.trim()) board.title = patch.title.trim().slice(0, 80);
1245
+ if (patch.status) board.status = patch.status;
1246
+ this.hub.emit({ type: "board.updated", board: { ...board } });
1247
+ this.hub.planChanged();
1248
+ return board;
1249
+ }
1250
+ // ------------------------------------------------------------ tasks
1251
+ createTask(input, byId) {
1252
+ const board = input.boardId ? this.snap.boards.find((b) => b.id === input.boardId) : this.defaultBoard();
1253
+ if (!board) throw new Error(`No board ${input.boardId}. Boards: ${this.snap.boards.map((b) => b.id).join(", ")}`);
1254
+ let n = this.snap.tasks.filter((t2) => t2.boardId === board.id).length + 1;
1255
+ let id = `${board.id}-T${String(n).padStart(2, "0")}`;
1256
+ while (this.task(id)) id = `${board.id}-T${String(++n).padStart(2, "0")}`;
1257
+ const task = {
1258
+ id,
1259
+ boardId: board.id,
1260
+ title: input.title.trim().slice(0, 120),
1261
+ ownerId: null,
1262
+ status: "todo",
1263
+ needs: input.needs ?? [],
1264
+ prNumber: null,
1265
+ description: input.description ?? "",
1266
+ files: input.files ?? [],
1267
+ doneWhen: input.doneWhen ?? "",
1268
+ note: null
1269
+ };
1270
+ this.snap.tasks.push(task);
1271
+ this.hub.emit({ type: "task.updated", task: { ...task } });
1272
+ this.hub.activity("task.created", byId === "you" ? null : byId, `${this.hub.name(byId)} added ${task.id} ${task.title}`);
1273
+ this.hub.planChanged();
1274
+ return task;
1275
+ }
1276
+ updateTask(id, patch) {
1277
+ const task = this.task(id);
1278
+ if (!task) throw new Error(`No task ${id}`);
1279
+ Object.assign(task, Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== void 0)));
1280
+ if (task.status === "done") {
1281
+ for (const a of this.snap.agents) if (a.currentTaskId === task.id) this.hub.setStatus(a.agentId, { currentTaskId: null });
1282
+ }
1283
+ this.hub.emit({ type: "task.updated", task: { ...task } });
1284
+ this.hub.planChanged();
1285
+ return task;
1286
+ }
1287
+ /** Gives a task to an agent and tells them to start. */
1288
+ assign(taskId, agentRef, byId) {
1289
+ const task = this.task(taskId);
1290
+ const agentId = this.agentId(agentRef);
1291
+ if (!task) throw new Error(`No task ${taskId}`);
1292
+ if (!agentId) throw new Error(`No agent "${agentRef}". Team: ${this.snap.config.team.map((a) => a.id).join(", ")}`);
1293
+ const agent = this.hub.cfg(agentId);
1294
+ task.ownerId = agentId;
1295
+ if (task.status === "done") task.status = "todo";
1296
+ this.hub.emit({ type: "task.updated", task: { ...task } });
1297
+ this.hub.setStatus(agentId, { currentTaskId: task.id });
1298
+ this.hub.planChanged();
1299
+ this.hub.activity("task.assigned", byId === "you" ? null : byId, `${this.hub.name(byId)} assigned ${task.id} ${task.title} to ${agent.name}`);
1300
+ const by = byId === "you" ? "The user (your human)" : this.hub.name(byId);
1301
+ const text = `${by} assigned you ${task.id} (${task.title}). Pull main, start ${task.id}. Call the hq get_my_task tool for the details.`;
1302
+ this.record(byId, agentId, `Start ${task.id}: ${task.title}`);
1303
+ this.inbox.push(agentId, { fromId: byId, kind: "assign", text });
1304
+ return task;
1305
+ }
1306
+ setStatus(agentId, status, note, prNumber) {
1307
+ const task = this.currentTask(agentId);
1308
+ if (!task) throw new Error("You have no task. Ask the lead for one.");
1309
+ task.status = status;
1310
+ task.note = note?.slice(0, 300) || null;
1311
+ if (prNumber) task.prNumber = prNumber;
1312
+ if (status === "done") this.hub.setStatus(agentId, { currentTaskId: null });
1313
+ else this.hub.setStatus(agentId, { currentTaskId: task.id });
1314
+ this.hub.emit({ type: "task.updated", task: { ...task } });
1315
+ this.hub.planChanged();
1316
+ const name = this.hub.name(agentId);
1317
+ const words = { "in-progress": "started", blocked: "is blocked on", "in-review": "sent for review", done: "finished" }[status];
1318
+ this.hub.activity(status === "blocked" ? "agent.needs-input" : "task.started", agentId, `${name} ${words} ${task.id}${note ? `: ${note}` : ""}`);
1319
+ const lead = this.snap.config.team.find((a) => a.isLead);
1320
+ if (lead && lead.id !== agentId && (status === "blocked" || status === "in-review" || status === "done"))
1321
+ this.message(agentId, lead.id, `${task.id} ${words}${note ? `: ${note}` : ""}${prNumber ? ` (PR #${prNumber})` : ""}`);
1322
+ return task;
1323
+ }
1324
+ currentTask(agentId) {
1325
+ const st = this.hub.status(agentId);
1326
+ const byCurrent = st?.currentTaskId ? this.task(st.currentTaskId) : void 0;
1327
+ if (byCurrent && byCurrent.ownerId === agentId) return byCurrent;
1328
+ const mine = this.snap.tasks.filter((t2) => t2.ownerId === agentId && t2.status !== "done");
1329
+ return mine.find((t2) => t2.status === "in-progress") ?? mine.find((t2) => t2.status === "todo") ?? mine[0];
1330
+ }
1331
+ // ------------------------------------------------------------ messages
1332
+ /** Records a message, shows it in the office, and delivers it. */
1333
+ message(fromId, toRef, text) {
1334
+ const toId = this.agentId(toRef);
1335
+ if (!toId) throw new Error(`No agent "${toRef}". Team: ${this.snap.config.team.map((a) => a.id).join(", ")}`);
1336
+ const clean = text.trim().slice(0, 2e3);
1337
+ if (!clean) throw new Error("Empty message");
1338
+ this.record(fromId, toId, clean);
1339
+ this.hub.activity("message", fromId === "you" ? null : fromId, `${this.hub.name(fromId)} \u2192 ${this.hub.name(toId)}: ${clean.slice(0, 80)}`);
1340
+ this.inbox.push(toId, { fromId, kind: "message", text: clean });
1341
+ return toId;
1342
+ }
1343
+ /** A notice from HQ itself (review results, merges) to one agent. */
1344
+ notify(agentId, text) {
1345
+ if (!this.hub.cfg(agentId)) return;
1346
+ this.record("hq", agentId, text);
1347
+ this.inbox.push(agentId, { fromId: "hq", kind: "notice", text });
1348
+ }
1349
+ record(fromId, toId, text) {
1350
+ this.seq += 1;
1351
+ const msg = { id: `m${Date.now().toString(36)}${this.seq}`, at: now2(), fromId, toId, text };
1352
+ this.snap.messages = [msg, ...this.snap.messages].slice(0, 100);
1353
+ this.hub.emit({ type: "message", message: msg });
1354
+ }
1355
+ };
1356
+
1357
+ // server/sbx.ts
1358
+ import { chmodSync, existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
1359
+ import { join as join5 } from "node:path";
1360
+ var key = (decision, resource) => `${decision} ${resource}`;
1361
+ var Sbx = class {
1362
+ // sandbox name → "allow foo.com" entries HQ added
1363
+ constructor(hub) {
1364
+ this.hub = hub;
1365
+ this.ownedFile = join5(projectHome(hub.slug), "sbx-owned.json");
1366
+ this.owned = existsSync4(this.ownedFile) ? JSON.parse(readFileSync3(this.ownedFile, "utf8")) : {};
1367
+ this.state = { basePolicy: "unknown", proxy: null, globalKeys: [], agents: {}, checkedAt: (/* @__PURE__ */ new Date()).toISOString() };
1368
+ }
1369
+ hub;
1370
+ state;
1371
+ ownedFile;
1372
+ owned;
1373
+ saveOwned() {
1374
+ writeFileSync4(this.ownedFile, JSON.stringify(this.owned, null, 2));
1375
+ }
1376
+ name(agentId) {
1377
+ return sessionName(this.hub.slug, agentId);
1378
+ }
1379
+ envFile(agentId) {
1380
+ return join5(projectHome(this.hub.slug), `${agentId}.env`);
1381
+ }
1382
+ publish() {
1383
+ this.state.checkedAt = (/* @__PURE__ */ new Date()).toISOString();
1384
+ this.hub.snap.sbx = this.state;
1385
+ this.hub.emit({ type: "sbx.updated", sbx: structuredClone(this.state) });
1386
+ }
1387
+ // ------------------------------------------------------------ read back
1388
+ async rules() {
1389
+ const r = await run("sbx", ["policy", "ls", "--json", "--type", "network"], { timeoutMs: 15e3 });
1390
+ if (!r.ok) return [];
1391
+ try {
1392
+ return (JSON.parse(r.stdout).rules ?? []).filter((x) => x.resource_type === "network" && x.status !== "inactive");
1393
+ } catch {
1394
+ return [];
1395
+ }
1396
+ }
1397
+ async basePolicy() {
1398
+ const allowed = async (target) => /^Allowed/m.test((await run("sbx", ["policy", "check", "network", target], { timeoutMs: 1e4 })).stdout);
1399
+ if (await allowed("hq-probe.invalid")) return "allow-all";
1400
+ if (await allowed("registry.npmjs.org")) return "balanced";
1401
+ return "deny-all";
1402
+ }
1403
+ /** Re-reads everything from sbx and publishes it. */
1404
+ async refresh() {
1405
+ if (!this.hub.snap.setup.sandboxReady) {
1406
+ this.state.agents = Object.fromEntries(this.hub.snap.config.team.map((a) => [a.id, this.emptyAgent(a.id)]));
1407
+ return this.publish();
1408
+ }
1409
+ const [rules, keysOut, proxyOut, base] = await Promise.all([
1410
+ this.rules(),
1411
+ run("sbx", ["secret", "ls", "--json"], { timeoutMs: 15e3 }),
1412
+ run("sbx", ["settings", "get", "proxy.sandbox"], { timeoutMs: 1e4 }),
1413
+ this.basePolicy()
1414
+ ]);
1415
+ let secrets = [];
1416
+ try {
1417
+ secrets = JSON.parse(keysOut.stdout).secrets ?? [];
1418
+ } catch {
1419
+ }
1420
+ this.state.basePolicy = base;
1421
+ this.state.proxy = proxyOut.stdout.trim() || null;
1422
+ this.state.globalKeys = secrets.filter((s) => s.type === "service" && s.scope === "global").map((s) => s.name);
1423
+ const prevErrors = this.state.agents;
1424
+ this.state.agents = {};
1425
+ for (const a of this.hub.snap.config.team) {
1426
+ const name = this.name(a.id);
1427
+ const mine = new Set(this.owned[name] ?? []);
1428
+ const sandboxRules = rules.filter((r) => r.applies_to === `sandbox:${name}`).map((r) => ({
1429
+ id: r.id,
1430
+ decision: r.decision,
1431
+ resources: r.resources,
1432
+ source: r.layer === "org" ? "org" : r.origin === "kit" || ["default", "provisioned"].includes(r.provenance?.created_via ?? "") ? "kit" : r.resources.every((x) => mine.has(key(r.decision, x))) ? "hq" : "cli"
1433
+ }));
1434
+ this.state.agents[a.id] = {
1435
+ rules: sandboxRules,
1436
+ // sbx reports a sandbox scope as the bare sandbox name.
1437
+ keys: secrets.filter((s) => s.type === "service" && (s.scope === name || s.scope === `sandbox:${name}`)).map((s) => s.name),
1438
+ envFile: { path: this.envFile(a.id), exists: existsSync4(this.envFile(a.id)) },
1439
+ syncError: prevErrors[a.id]?.syncError ?? null
1440
+ };
1441
+ }
1442
+ this.publish();
1443
+ }
1444
+ emptyAgent(agentId) {
1445
+ return { rules: [], keys: [], envFile: { path: this.envFile(agentId), exists: existsSync4(this.envFile(agentId)) }, syncError: null };
1446
+ }
1447
+ // ------------------------------------------------------------ apply
1448
+ /** True if sbx knows a sandbox with this name. */
1449
+ async exists(agentId) {
1450
+ const r = await run("sbx", ["ls", "--json"], { timeoutMs: 1e4 });
1451
+ try {
1452
+ return (JSON.parse(r.stdout).sandboxes ?? []).some((s) => s.name === this.name(agentId));
1453
+ } catch {
1454
+ return false;
1455
+ }
1456
+ }
1457
+ /**
1458
+ * Makes the sandbox's allow/deny rules match the agent's lists. Adds what's
1459
+ * missing; removes only rules HQ added earlier. Needs the sandbox to exist.
1460
+ */
1461
+ async syncRules(agentId) {
1462
+ const agent = this.hub.cfg(agentId);
1463
+ if (!agent || agent.mode !== "sandbox" || !await this.exists(agentId)) return;
1464
+ const name = this.name(agentId);
1465
+ const want = /* @__PURE__ */ new Set([...agent.sandbox.allow.map((r) => key("allow", r)), ...agent.sandbox.deny.map((r) => key("deny", r))]);
1466
+ const owned = new Set(this.owned[name] ?? []);
1467
+ const rules = (await this.rules()).filter((r) => r.applies_to === `sandbox:${name}`);
1468
+ const have = new Set(rules.flatMap((r) => r.resources.map((x) => key(r.decision, x))));
1469
+ const errors = [];
1470
+ for (const entry of want) {
1471
+ if (have.has(entry)) continue;
1472
+ const [decision, resource] = entry.split(" ");
1473
+ const r = await run("sbx", ["policy", decision, "network", "--sandbox", name, resource], { timeoutMs: 15e3 });
1474
+ if (r.ok) owned.add(entry);
1475
+ else errors.push(`${decision} ${resource}: ${r.stderr.trim().split("\n")[0]}`);
1476
+ }
1477
+ for (const entry of [...owned]) {
1478
+ if (want.has(entry)) continue;
1479
+ const [decision, resource] = entry.split(" ");
1480
+ const rule = rules.find((r2) => r2.decision === decision && r2.resources.includes(resource));
1481
+ const r = rule ? await run("sbx", ["policy", "rm", "network", "--sandbox", name, "--id", rule.id, "--force"], { timeoutMs: 15e3 }) : { ok: true, stderr: "" };
1482
+ if (r.ok) owned.delete(entry);
1483
+ else errors.push(`remove ${resource}: ${r.stderr.trim().split("\n")[0]}`);
1484
+ }
1485
+ this.owned[name] = [...owned];
1486
+ this.saveOwned();
1487
+ if (errors.length) log.warn(`sbx rules for ${name}:`, errors.join("; "));
1488
+ await this.refresh();
1489
+ if (this.state.agents[agentId]) {
1490
+ this.state.agents[agentId].syncError = errors.length ? errors.join("; ") : null;
1491
+ this.publish();
1492
+ }
1493
+ }
1494
+ /** Stores a service key (value via stdin, never kept). agentId null = every sandbox. */
1495
+ async setKey(agentId, service, value) {
1496
+ const args = ["secret", "set", service, ...agentId ? ["--sandbox", this.name(agentId)] : [], "--force"];
1497
+ const r = await run("sbx", args, { timeoutMs: 2e4, input: value.trim() + "\n" });
1498
+ if (!r.ok) throw new Error(r.stderr.trim().split("\n")[0] || "sbx secret set failed");
1499
+ await this.refresh();
1500
+ }
1501
+ async removeKey(agentId, service) {
1502
+ const r = await run("sbx", ["secret", "rm", service, ...agentId ? ["--sandbox", this.name(agentId)] : [], "--force"], { timeoutMs: 2e4 });
1503
+ if (!r.ok) throw new Error(r.stderr.trim().split("\n")[0] || "sbx secret rm failed");
1504
+ await this.refresh();
1505
+ }
1506
+ async setProxy(url) {
1507
+ const r = url ? await run("sbx", ["settings", "set", "proxy.sandbox", url], { timeoutMs: 15e3 }) : await run("sbx", ["settings", "unset", "proxy.sandbox"], { timeoutMs: 15e3 });
1508
+ if (!r.ok) throw new Error(r.stderr.trim().split("\n")[0] || "sbx settings failed");
1509
+ await this.refresh();
1510
+ }
1511
+ /** Creates the agent's env file (0600) if missing. Returns its path. Never reads values. */
1512
+ ensureEnvFile(agentId) {
1513
+ const file = this.envFile(agentId);
1514
+ if (!existsSync4(file)) {
1515
+ writeFileSync4(
1516
+ file,
1517
+ `# App variables for ${this.hub.name(agentId)} (${this.hub.snap.config.project.name}).
1518
+ # One KEY=value per line. These values ENTER the agent's VM \u2014 use test/dev credentials.
1519
+ # Restart the agent after editing. JSM HQ never reads or commits this file.
1520
+ `,
1521
+ { mode: 384 }
1522
+ );
1523
+ }
1524
+ chmodSync(file, 384);
1525
+ return file;
1526
+ }
1527
+ };
1528
+
1529
+ // server/lib/ptyFix.ts
1530
+ import { chmodSync as chmodSync2, existsSync as existsSync5, readdirSync, statSync as statSync2 } from "node:fs";
1531
+ import { createRequire } from "node:module";
1532
+ import { dirname, join as join6 } from "node:path";
1533
+ function ensurePtyHelperExecutable() {
1534
+ try {
1535
+ const root = dirname(createRequire(import.meta.url).resolve("node-pty/package.json"));
1536
+ const prebuilds = join6(root, "prebuilds");
1537
+ if (!existsSync5(prebuilds)) return;
1538
+ for (const dir2 of readdirSync(prebuilds)) {
1539
+ const helper = join6(prebuilds, dir2, "spawn-helper");
1540
+ if (existsSync5(helper) && !(statSync2(helper).mode & 73)) chmodSync2(helper, 493);
1541
+ }
1542
+ } catch {
1543
+ }
1544
+ }
1545
+
1546
+ // server/repo.ts
1547
+ import { existsSync as existsSync6 } from "node:fs";
1548
+ import { basename, join as join7 } from "node:path";
1549
+ async function detectRepo(start) {
1550
+ const top = await run("git", ["rev-parse", "--show-toplevel"], { cwd: start });
1551
+ if (!top.ok) throw new Error(`${start} is not inside a git repository. Run HQ from your project folder or pass --repo <path>.`);
1552
+ const root = top.stdout.trim();
1553
+ const origin = await run("git", ["remote", "get-url", "origin"], { cwd: root });
1554
+ const originUrl = origin.ok ? origin.stdout.trim() : null;
1555
+ const m = originUrl?.match(/github\.com[:/]([^/]+\/[^/]+?)(\.git)?$/);
1556
+ return {
1557
+ root,
1558
+ name: basename(root),
1559
+ originUrl,
1560
+ github: m ? m[1] : null,
1561
+ hasCodeRabbit: existsSync6(join7(root, ".coderabbit.yaml")) || existsSync6(join7(root, ".coderabbit.yml")),
1562
+ hasInsta: existsSync6(join7(root, ".insta"))
1563
+ };
1564
+ }
1565
+
1566
+ // server/sessions.ts
1567
+ import { spawn as spawnPty } from "node-pty";
1568
+ import { existsSync as existsSync10, rmSync } from "node:fs";
1569
+ import { resolve, sep } from "node:path";
1570
+
1571
+ // server/adapters.ts
1572
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync6 } from "node:fs";
1573
+ import { join as join9 } from "node:path";
1574
+
1575
+ // server/agentFiles.ts
1576
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "node:fs";
1577
+ import { join as join8 } from "node:path";
1578
+ var MCP_CONFIG = ".claude/hq-mcp.json";
1579
+ var HQ_FILES = ["ROLE.md", ".claude/settings.local.json", MCP_CONFIG];
1580
+ var MARK = "# jsmhq-hook";
1581
+ var HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "Notification", "Stop", "SessionEnd"];
1582
+ function hqUrl(agent, port) {
1583
+ return agent.mode === "sandbox" ? `http://host.docker.internal:${port}` : `http://127.0.0.1:${port}`;
1584
+ }
1585
+ function roleMd(agent, cfg) {
1586
+ const team = cfg.team.map((a) => `- ${a.name} \u2014 ${a.role}${a.isLead ? " (lead)" : ""}${a.id === agent.id ? " \u2190 you" : ""}`).join("\n");
1587
+ const rules = agent.isLead ? [
1588
+ "You plan the work and assign it to the team. You never write app code.",
1589
+ "Keep tasks small: one task = one branch = one PR. Give each task the files it may edit and a clear done-when.",
1590
+ "Assign only to agents that are running and free (check list_workers).",
1591
+ "Decide merge order and resolve conflicts between PRs."
1592
+ ] : [
1593
+ "Work only on the task you were given. One task = one branch = one PR.",
1594
+ "When HQ tells you to start a task, call get_my_task first, then `git pull origin main` and create a branch.",
1595
+ "Edit only the files the task lists. Need another file? Ask the lead with send_message.",
1596
+ "Title PRs `[TASK-ID] Short title` and open them with `gh pr create`, then call set_my_status in-review with the PR number.",
1597
+ "set_my_status tells the lead automatically. Don't also send_message the same news.",
1598
+ "If you are blocked or need a decision, call set_my_status blocked with the reason."
1599
+ ];
1600
+ const tools = agent.isLead ? "list_workers, get_status, list_tasks, create_board, create_task, update_task, assign_task, send_message" : "get_my_task, set_my_status, list_workers, get_status, list_tasks, send_message";
1601
+ return [
1602
+ `# ${agent.name} \u2014 ${agent.role}`,
1603
+ "",
1604
+ agent.roleMd.trim(),
1605
+ "",
1606
+ "## Team",
1607
+ team,
1608
+ "",
1609
+ "## Rules",
1610
+ ...rules.map((r) => `- ${r}`),
1611
+ "",
1612
+ "## Team MCP (server `hq`)",
1613
+ `Your tools: ${tools}.`,
1614
+ "Messages that start with `[HQ]` come from JSM HQ: assignments from the lead, or messages from teammates. Act on them.",
1615
+ "Teammates work in their own sandboxes and cannot see your files or terminal. Talk to them only with send_message.",
1616
+ "",
1617
+ `Project: ${cfg.project.name} (${cfg.project.repo}). You are managed by JSM HQ.`,
1618
+ ""
1619
+ ].join("\n");
1620
+ }
1621
+ function hookCommand(url, token, agentId, event) {
1622
+ const out = event === "Stop" ? "2>/dev/null" : ">/dev/null 2>&1";
1623
+ return `curl -sf -m 4 -X POST -H 'Authorization: Bearer ${token}' -H 'Content-Type: application/json' --data-binary @- '${url}/hook/${agentId}/${event}' ${out} || true ${MARK}`;
1624
+ }
1625
+ function writeSettings(dir2, agent, port, token) {
1626
+ const file = join8(dir2, ".claude", "settings.local.json");
1627
+ mkdirSync4(join8(dir2, ".claude"), { recursive: true });
1628
+ let settings = {};
1629
+ if (existsSync7(file)) {
1630
+ try {
1631
+ settings = JSON.parse(readFileSync4(file, "utf8"));
1632
+ } catch {
1633
+ settings = {};
1634
+ }
1635
+ }
1636
+ const hooks = { ...settings.hooks ?? {} };
1637
+ const url = hqUrl(agent, port);
1638
+ for (const event of HOOK_EVENTS) {
1639
+ const kept = (hooks[event] ?? []).map((e) => ({ ...e, hooks: e.hooks.filter((h) => !h.command.includes(MARK)) })).filter((e) => e.hooks.length);
1640
+ const ours = [{ type: "command", command: hookCommand(url, token, agent.id, event), timeout: 5 }];
1641
+ if (event === "SessionStart") ours.unshift({ type: "command", command: `cat "$CLAUDE_PROJECT_DIR/ROLE.md" 2>/dev/null || true ${MARK}` });
1642
+ hooks[event] = [...kept, event === "PreToolUse" ? { matcher: "*", hooks: ours } : { hooks: ours }];
1643
+ }
1644
+ const allow = /* @__PURE__ */ new Set([...settings.permissions?.allow ?? [], "mcp__hq"]);
1645
+ writeFileSync5(file, JSON.stringify({ ...settings, hooks, permissions: { ...settings.permissions ?? {}, allow: [...allow] } }, null, 2) + "\n");
1646
+ }
1647
+ function writeMcpConfig(dir2, agent, port, token) {
1648
+ const config = {
1649
+ mcpServers: {
1650
+ hq: { type: "http", url: `${hqUrl(agent, port)}/mcp`, headers: { Authorization: `Bearer ${token}`, "X-HQ-Agent": agent.id } }
1651
+ }
1652
+ };
1653
+ mkdirSync4(join8(dir2, ".claude"), { recursive: true });
1654
+ writeFileSync5(join8(dir2, MCP_CONFIG), JSON.stringify(config, null, 2) + "\n", { mode: 384 });
1655
+ }
1656
+ function writeClaudeFiles(dir2, agent, cfg, token) {
1657
+ writeFileSync5(join8(dir2, "ROLE.md"), roleMd(agent, cfg));
1658
+ writeSettings(dir2, agent, cfg.project.hqPort, token);
1659
+ writeMcpConfig(dir2, agent, cfg.project.hqPort, token);
1660
+ excludeFromGit(dir2, HQ_FILES);
1661
+ }
1662
+ function excludeFromGit(dir2, paths) {
1663
+ const infoDir = join8(dir2, ".git", "info");
1664
+ if (!existsSync7(join8(dir2, ".git"))) return;
1665
+ mkdirSync4(infoDir, { recursive: true });
1666
+ const file = join8(infoDir, "exclude");
1667
+ const current = existsSync7(file) ? readFileSync4(file, "utf8") : "";
1668
+ const missing = paths.filter((p) => !current.split("\n").includes(p));
1669
+ if (missing.length) writeFileSync5(file, current + (current.endsWith("\n") || !current ? "" : "\n") + "# JSM HQ\n" + missing.join("\n") + "\n");
1670
+ }
1671
+
1672
+ // server/adapters.ts
1673
+ var claude = {
1674
+ async writeFiles(dir2, agent, cfg, token) {
1675
+ writeClaudeFiles(dir2, agent, cfg, token);
1676
+ },
1677
+ launch(agent, dir2) {
1678
+ const m = agent.model.trim();
1679
+ const model = !m || /^default$/i.test(m) ? null : /^(opus|sonnet|haiku)$/i.test(m) ? m.toLowerCase() : m;
1680
+ return { sbxAgent: "claude", args: ["--mcp-config", shq(`${dir2}/${MCP_CONFIG}`), ...model ? ["--model", shq(model)] : []] };
1681
+ }
1682
+ };
1683
+ var CODEX_CONFIG = ".codex/config.toml";
1684
+ var CODEX_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PermissionRequest", "Stop", "SessionEnd"];
1685
+ var t = (s) => JSON.stringify(s);
1686
+ function codexConfig(agent, cfg, token) {
1687
+ const url = hqUrl(agent, cfg.project.hqPort);
1688
+ const lines = [
1689
+ "# Written by JSM HQ for this agent (git-excluded). Codex loads it once you trust this folder.",
1690
+ `developer_instructions = ${t(roleMd(agent, cfg))}`,
1691
+ "",
1692
+ "[features]",
1693
+ "hooks = true",
1694
+ "",
1695
+ "[mcp_servers.hq]",
1696
+ `url = ${t(`${url}/mcp`)}`,
1697
+ `http_headers = { Authorization = ${t(`Bearer ${token}`)}, "X-HQ-Agent" = ${t(agent.id)} }`
1698
+ ];
1699
+ for (const event of CODEX_EVENTS)
1700
+ lines.push("", `[[hooks.${event}]]`, `[[hooks.${event}.hooks]]`, 'type = "command"', `command = ${t(hookCommand(url, token, agent.id, event))}`);
1701
+ return lines.join("\n") + "\n";
1702
+ }
1703
+ var codex = {
1704
+ async writeFiles(dir2, agent, cfg, token) {
1705
+ const tracked = await run("git", ["ls-files", "--error-unmatch", CODEX_CONFIG], { cwd: dir2 });
1706
+ if (tracked.ok) throw new Error(`This repo commits ${CODEX_CONFIG}, so HQ can't add its Codex settings without changing a tracked file. Use Claude Code for this agent, or remove that file from the repo.`);
1707
+ writeFileSync6(join9(dir2, "ROLE.md"), roleMd(agent, cfg));
1708
+ mkdirSync5(join9(dir2, ".codex"), { recursive: true });
1709
+ writeFileSync6(join9(dir2, CODEX_CONFIG), codexConfig(agent, cfg, token), { mode: 384 });
1710
+ excludeFromGit(dir2, ["ROLE.md", CODEX_CONFIG]);
1711
+ },
1712
+ launch(agent) {
1713
+ const model = agent.model.trim() && !/^default$/i.test(agent.model.trim()) ? agent.model.trim() : null;
1714
+ return { sbxAgent: "codex", args: model ? ["-m", shq(model)] : [] };
1715
+ }
1716
+ };
1717
+ var adapters = { claude, codex };
1718
+ var localBinary = { claude: "claude", codex: "codex" };
1719
+
1720
+ // server/setup.ts
1721
+ import { existsSync as existsSync8, readFileSync as readFileSync5 } from "node:fs";
1722
+ import { arch, cpus, platform, release, totalmem } from "node:os";
1723
+ var SBX_DOCS = "https://docs.docker.com/ai/sandboxes/install/";
1724
+ var os = platform();
1725
+ var fix = (command, runnable, docs) => ({ command, runnable, docs });
1726
+ async function checkOs() {
1727
+ const base = { id: "os", label: "Machine supports sandboxes", neededFor: "sandbox", fix: fix("", false, SBX_DOCS) };
1728
+ if (os === "darwin") {
1729
+ const v = (await run("sw_vers", ["-productVersion"])).stdout.trim();
1730
+ const major = Number(v.split(".")[0]);
1731
+ if (arch() !== "arm64") return { ...base, status: "unsupported", detail: `macOS ${v} on Intel. Sandboxes need Apple silicon \u2014 use Local mode.` };
1732
+ if (major < 14) return { ...base, status: "unsupported", detail: `macOS ${v}. Sandboxes need macOS 14 (Sonoma) or later.` };
1733
+ return { ...base, status: "ok", detail: `macOS ${v} \xB7 Apple silicon`, fix: null };
1734
+ }
1735
+ if (os === "win32") {
1736
+ const build = Number(release().split(".")[2] ?? 0);
1737
+ return build >= 22e3 ? { ...base, status: "ok", detail: "Windows 11 (needs Windows Hypervisor Platform enabled)", fix: null } : { ...base, status: "unsupported", detail: "Windows 10. Sandboxes need Windows 11 \u2014 use Local mode." };
1738
+ }
1739
+ const osRelease = existsSync8("/etc/os-release") ? readFileSync5("/etc/os-release", "utf8") : "";
1740
+ const id = osRelease.match(/^ID=(.*)$/m)?.[1]?.replace(/"/g, "");
1741
+ const ver = Number(osRelease.match(/^VERSION_ID="?([\d.]+)/m)?.[1] ?? 0);
1742
+ if (id === "ubuntu" && ver >= 24.04) return { ...base, status: "ok", detail: `Ubuntu ${ver} (needs KVM)`, fix: null };
1743
+ return { ...base, status: "warning", detail: `${id ?? "Linux"} ${ver || ""} \u2014 Docker documents Ubuntu 24.04+ with KVM.` };
1744
+ }
1745
+ async function checkSbx(brew) {
1746
+ const base = { id: "sbx", label: "Docker Sandboxes (sbx)", neededFor: "sandbox" };
1747
+ const v = await run("sbx", ["version"], { timeoutMs: 8e3 });
1748
+ if (v.ok) return { ...base, status: "ok", detail: v.stdout.trim().split(/\s+/)[2] ?? v.stdout.trim(), fix: null };
1749
+ const cmd = os === "darwin" ? "brew trust docker/tap && brew install docker/tap/sbx" : os === "win32" ? "winget install -h Docker.sbx" : "curl -fsSL https://get.docker.com | sudo SBX=1 sh";
1750
+ const runnable = os === "darwin" ? brew : os === "win32";
1751
+ return { ...base, status: "missing", detail: "Not installed.", fix: fix(cmd, runnable, SBX_DOCS) };
1752
+ }
1753
+ async function checkSbxLogin(sbxOk) {
1754
+ const base = { id: "sbx-login", label: "Signed in to Docker", neededFor: "sandbox" };
1755
+ if (!sbxOk) return { ...base, status: "missing", detail: "Waiting for sbx.", fix: null };
1756
+ const r = await run("sbx", ["ls", "--json"], { timeoutMs: 1e4 });
1757
+ if (r.ok) {
1758
+ const n = JSON.parse(r.stdout || "{}").sandboxes?.length ?? 0;
1759
+ return { ...base, status: "ok", detail: `Signed in \xB7 ${n} sandbox${n === 1 ? "" : "es"} on this machine`, fix: null };
1760
+ }
1761
+ const msg = (r.stderr || r.stdout).trim();
1762
+ const loggedOut = /log ?in|sign ?in|unauthori[sz]ed|not authenticated/i.test(msg);
1763
+ return {
1764
+ ...base,
1765
+ status: loggedOut ? "missing" : "warning",
1766
+ detail: loggedOut ? "Not signed in. Sign-in opens your browser." : msg.split("\n")[0] || "sbx did not respond.",
1767
+ fix: fix(loggedOut ? "sbx login" : "sbx diagnose", true, SBX_DOCS)
1768
+ };
1769
+ }
1770
+ async function checkTmux(brew) {
1771
+ const base = { id: "tmux", label: "tmux (keeps agents running if HQ closes)", neededFor: "all" };
1772
+ const v = await run("tmux", ["-V"], { timeoutMs: 5e3 });
1773
+ if (v.ok) return { ...base, status: "ok", detail: v.stdout.trim(), fix: null };
1774
+ if (os === "win32") return { ...base, status: "unsupported", detail: "tmux needs WSL on Windows. Run HQ inside WSL.", fix: fix("wsl --install", false, "https://learn.microsoft.com/windows/wsl/install") };
1775
+ return {
1776
+ ...base,
1777
+ status: "missing",
1778
+ detail: "Not installed.",
1779
+ fix: os === "darwin" ? fix("brew install tmux", brew, "https://github.com/tmux/tmux/wiki/Installing") : fix("sudo apt install tmux", false, "https://github.com/tmux/tmux/wiki/Installing")
1780
+ };
1781
+ }
1782
+ async function checkClaude() {
1783
+ const base = { id: "claude", label: "Claude Code (for Local mode)", neededFor: "local" };
1784
+ const v = await run("claude", ["--version"], { timeoutMs: 8e3 });
1785
+ if (v.ok) return { ...base, status: "ok", detail: v.stdout.trim(), fix: null };
1786
+ return {
1787
+ ...base,
1788
+ status: "missing",
1789
+ detail: "Not installed. Only needed for agents in Local mode.",
1790
+ fix: fix(os === "win32" ? "irm https://claude.ai/install.ps1 | iex" : "curl -fsSL https://claude.ai/install.sh | bash", false, "https://docs.claude.com/en/docs/claude-code/setup")
1791
+ };
1792
+ }
1793
+ async function checkGh(brew) {
1794
+ const base = { id: "gh", label: "GitHub CLI (reads PRs and reviews)", neededFor: "github" };
1795
+ if (!await has("gh"))
1796
+ return {
1797
+ ...base,
1798
+ status: "missing",
1799
+ detail: "Not installed.",
1800
+ fix: fix(os === "darwin" ? "brew install gh" : os === "win32" ? "winget install --id GitHub.cli" : "sudo apt install gh", os === "darwin" ? brew : os === "win32", "https://cli.github.com")
1801
+ };
1802
+ const r = await run("gh", ["auth", "status"], { timeoutMs: 1e4 });
1803
+ const out = r.stdout + r.stderr;
1804
+ if (r.ok) return { ...base, status: "ok", detail: `Signed in as ${out.match(/account (\S+)/)?.[1] ?? "you"}`, fix: null };
1805
+ return { ...base, status: "missing", detail: "Not signed in.", fix: fix("gh auth login", true, "https://cli.github.com/manual/gh_auth_login") };
1806
+ }
1807
+ function checkRemote(repo) {
1808
+ const base = { id: "gh-remote", label: "GitHub remote", neededFor: "github" };
1809
+ if (repo.github) return { ...base, status: "ok", detail: repo.github, fix: null };
1810
+ return {
1811
+ ...base,
1812
+ status: "warning",
1813
+ detail: repo.originUrl ? `origin is ${repo.originUrl} (not GitHub)` : "No origin remote. Agents can't push or open PRs.",
1814
+ fix: fix("gh repo create --source . --private --push", false, "https://cli.github.com/manual/gh_repo_create")
1815
+ };
1816
+ }
1817
+ async function checkGithubSecret(sbxOk) {
1818
+ const base = { id: "github-secret", label: "GitHub access inside sandboxes", neededFor: "sandbox" };
1819
+ if (!sbxOk) return { ...base, status: "missing", detail: "Waiting for sbx.", fix: null };
1820
+ const r = await run("sbx", ["secret", "ls"], { timeoutMs: 1e4 });
1821
+ if (r.ok && /\bgithub\b/.test(r.stdout)) return { ...base, status: "ok", detail: "Stored in Docker's secret store; injected by the proxy.", fix: null };
1822
+ return {
1823
+ ...base,
1824
+ status: "missing",
1825
+ detail: "Agents can't push or open PRs until this is set.",
1826
+ fix: fix("sbx secret set github --command 'gh auth token'", true, "https://docs.docker.com/ai/sandboxes/get-started/")
1827
+ };
1828
+ }
1829
+ async function checkCodex() {
1830
+ const base = { id: "codex", label: "Codex (for Codex agents in Local mode)", neededFor: "local" };
1831
+ const v = await run("codex", ["--version"], { timeoutMs: 8e3 });
1832
+ if (v.ok) return { ...base, status: "ok", detail: v.stdout.trim(), fix: null };
1833
+ return { ...base, status: "missing", detail: "Not installed. Only needed for Codex agents in Local mode (sandboxes have it).", fix: fix("npm install -g @openai/codex", false, "https://developers.openai.com/codex/cli") };
1834
+ }
1835
+ async function checkOpenAiSecret(sbxOk) {
1836
+ const base = { id: "openai-secret", label: "OpenAI sign-in for Codex agents", neededFor: "sandbox" };
1837
+ if (!sbxOk) return { ...base, status: "missing", detail: "Waiting for sbx.", fix: null };
1838
+ const r = await run("sbx", ["secret", "ls", "--json"], { timeoutMs: 1e4 });
1839
+ let has2 = false;
1840
+ try {
1841
+ has2 = (JSON.parse(r.stdout).secrets ?? []).some((s) => s.name === "openai" && s.scope === "global");
1842
+ } catch {
1843
+ }
1844
+ if (has2) return { ...base, status: "ok", detail: "Stored in Docker's secret store.", fix: null };
1845
+ return { ...base, status: "missing", detail: "Codex agents can't sign in until this is set. Opens your browser to sign in with ChatGPT.", fix: fix("sbx secret set openai --oauth", true, "https://docs.docker.com/ai/sandboxes/") };
1846
+ }
1847
+ async function runSetupChecks(repo, team = []) {
1848
+ const brew = os === "darwin" && await has("brew");
1849
+ const [osCheck, sbx, tmux, claude2, gh] = await Promise.all([checkOs(), checkSbx(brew), checkTmux(brew), checkClaude(), checkGh(brew)]);
1850
+ const sbxOk = sbx.status === "ok";
1851
+ const [login, secret] = await Promise.all([checkSbxLogin(sbxOk), checkGithubSecret(sbxOk)]);
1852
+ const checks = [osCheck, sbx, login, secret, tmux, claude2, gh, checkRemote(repo)];
1853
+ const codexAgents = team.filter((a) => a.cli === "codex");
1854
+ if (codexAgents.some((a) => a.mode === "sandbox")) checks.splice(4, 0, await checkOpenAiSecret(sbxOk));
1855
+ if (codexAgents.some((a) => a.mode === "local")) checks.push(await checkCodex());
1856
+ const ok2 = (c) => c.status === "ok";
1857
+ return {
1858
+ machine: { cpus: cpus().length, memoryGb: Math.round(totalmem() / 1024 ** 3) },
1859
+ checks,
1860
+ sandboxReady: osCheck.status !== "unsupported" && ok2(sbx) && ok2(login) && ok2(tmux),
1861
+ localReady: ok2(tmux) && ok2(claude2),
1862
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString()
1863
+ };
1864
+ }
1865
+
1866
+ // server/tmux.ts
1867
+ async function hasSession(name) {
1868
+ return (await run("tmux", ["has-session", "-t", `=${name}`], { timeoutMs: 5e3 })).ok;
1869
+ }
1870
+ async function newSession(name, cwd, command, env = {}) {
1871
+ const args = ["new-session", "-d", "-s", name, "-x", "200", "-y", "50", "-c", cwd];
1872
+ for (const [k, v] of Object.entries(env)) args.push("-e", `${k}=${v}`);
1873
+ args.push(command);
1874
+ const r = await run("tmux", args, { timeoutMs: 1e4 });
1875
+ if (!r.ok) throw new Error(`tmux: ${r.stderr.trim()}`);
1876
+ await run("tmux", ["set-option", "-t", name, "remain-on-exit", "on"], { timeoutMs: 5e3 });
1877
+ await run("tmux", ["set-option", "-t", name, "mouse", "on"], { timeoutMs: 5e3 });
1878
+ await run("tmux", ["set-option", "-t", name, "status", "off"], { timeoutMs: 5e3 });
1879
+ await run("tmux", ["set-option", "-t", name, "focus-events", "on"], { timeoutMs: 5e3 });
1880
+ }
1881
+ async function killSession(name) {
1882
+ await run("tmux", ["kill-session", "-t", `=${name}`], { timeoutMs: 5e3 });
1883
+ }
1884
+ async function paneInfo(name) {
1885
+ const r = await run("tmux", ["display-message", "-p", "-t", `=${name}:`, "#{pane_dead} #{pane_dead_status}"], { timeoutMs: 5e3 });
1886
+ if (!r.ok) return null;
1887
+ const [dead, status] = r.stdout.trim().split(" ");
1888
+ return { dead: dead === "1", exitStatus: status ? Number(status) : null };
1889
+ }
1890
+ async function tailText(name, n = 6) {
1891
+ const r = await run("tmux", ["capture-pane", "-p", "-t", `=${name}:`], { timeoutMs: 5e3 });
1892
+ return r.stdout.split("\n").map((l) => l.trimEnd()).filter(Boolean).slice(-n).join("\n");
1893
+ }
1894
+
1895
+ // server/workspace.ts
1896
+ import { existsSync as existsSync9 } from "node:fs";
1897
+ function workspaceFor(agent, projectSlug) {
1898
+ return agent.workspacePath || managedWorkspace(projectSlug, agent.id);
1899
+ }
1900
+ async function ensureWorkspace(agent, projectSlug, repo) {
1901
+ const dir2 = workspaceFor(agent, projectSlug);
1902
+ if (agent.workspacePath) {
1903
+ if (!existsSync9(dir2)) throw new Error(`Folder not found: ${dir2}`);
1904
+ if (!(await run("git", ["rev-parse", "--git-dir"], { cwd: dir2 })).ok) throw new Error(`${dir2} is not a git repository.`);
1905
+ return dir2;
1906
+ }
1907
+ if (!existsSync9(dir2)) {
1908
+ const clone = await run("git", ["clone", "--quiet", repo.root, dir2], { timeoutMs: 3e5 });
1909
+ if (!clone.ok) throw new Error(`git clone failed: ${clone.stderr.trim()}`);
1910
+ }
1911
+ if (repo.originUrl) await run("git", ["remote", "set-url", "origin", repo.originUrl], { cwd: dir2 });
1912
+ return dir2;
1913
+ }
1914
+
1915
+ // server/sessions.ts
1916
+ var TERM_ENV = { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor" };
1917
+ var Sessions = class {
1918
+ constructor(hub, agentToken2) {
1919
+ this.hub = hub;
1920
+ this.agentToken = agentToken2;
1921
+ }
1922
+ hub;
1923
+ agentToken;
1924
+ ptys = /* @__PURE__ */ new Map();
1925
+ sizes = /* @__PURE__ */ new Map();
1926
+ fixes = /* @__PURE__ */ new Map();
1927
+ policyOk = false;
1928
+ /** Called whenever an agent's Claude Code session goes away (stop, restart, crash). */
1929
+ onSessionDown = () => {
1930
+ };
1931
+ /** Called once an agent's sandbox exists after Start (to apply its network rules). */
1932
+ onSandboxReady = () => {
1933
+ };
1934
+ /** The agent's env file, if it has one (passed with --env-file / sourced). */
1935
+ envFileFor = () => null;
1936
+ /** Called when HQ reattaches to a session that was already running (e.g. after an HQ restart). */
1937
+ onSessionReattached = () => {
1938
+ };
1939
+ polling = null;
1940
+ name(agentId) {
1941
+ return sessionName(this.hub.slug, agentId);
1942
+ }
1943
+ // ------------------------------------------------------------ lifecycle
1944
+ async start(id) {
1945
+ const hub = this.hub;
1946
+ const agent = hub.cfg(id);
1947
+ const st = hub.status(id);
1948
+ if (!agent || !st || st.session.state === "running" || st.session.state === "starting") return;
1949
+ const setup = hub.snap.setup;
1950
+ const fail2 = (detail) => {
1951
+ hub.setSession(id, { state: "failed", detail });
1952
+ hub.setStatus(id, { state: "idle", note: "Could not start" });
1953
+ hub.activity("session", id, `${agent.name} could not start: ${detail.split("\n")[0]}`);
1954
+ };
1955
+ if (!setup.checks.find((c) => c.id === "tmux" && c.status === "ok")) return fail2("tmux is not installed. See the setup checklist.");
1956
+ if (agent.mode === "sandbox" && !setup.sandboxReady) return fail2("Docker Sandboxes isn't ready on this machine. Finish the setup checklist, or switch this agent to Local mode.");
1957
+ const check = (cid) => setup.checks.find((c) => c.id === cid)?.status;
1958
+ if (agent.mode === "local" && agent.cli === "claude" && check("claude") !== "ok") return fail2("Claude Code isn't installed. See the setup checklist.");
1959
+ if (agent.mode === "local" && agent.cli === "codex" && check("codex") !== "ok") return fail2("Codex isn't installed on this machine. See the setup checklist, or run this agent in a sandbox.");
1960
+ if (agent.mode === "sandbox" && agent.cli === "codex" && check("openai-secret") !== "ok")
1961
+ return fail2("Codex needs an OpenAI key in sbx. Run `sbx secret set openai --oauth` (Setup checklist \u2192 Run in terminal).");
1962
+ const name = this.name(id);
1963
+ this.onSessionDown(id);
1964
+ try {
1965
+ hub.setSession(id, { state: "starting", detail: "Preparing folder\u2026", workspace: workspaceFor(agent, hub.slug) });
1966
+ const dir2 = await ensureWorkspace(agent, hub.slug, hub.repo);
1967
+ const adapter = adapters[agent.cli];
1968
+ await adapter.writeFiles(dir2, agent, hub.snap.config, this.agentToken);
1969
+ if (!await hasSession(name)) {
1970
+ const launch = adapter.launch(agent, dir2);
1971
+ const envFile = this.envFileFor(id);
1972
+ let command = (envFile ? `set -a && . ${shq(envFile)} && set +a && ` : "") + [localBinary[agent.cli], ...launch.args].join(" ");
1973
+ if (agent.mode === "sandbox") {
1974
+ hub.setSession(id, { detail: "Allowing the sandbox to reach HQ\u2026" });
1975
+ await this.ensurePolicy();
1976
+ hub.setSession(id, { detail: "Starting sandbox\u2026" });
1977
+ const { cpus: cpus2, memoryGb } = agent.sandbox.resources;
1978
+ const size = [cpus2 ? `--cpus ${cpus2}` : "", memoryGb ? `--memory ${Math.round(memoryGb * 1024)}m` : ""];
1979
+ const env = envFile ? ["--env-file", shq(envFile)] : [];
1980
+ const args = launch.args.length ? ["--", ...launch.args] : [];
1981
+ command = ["sbx run --name", shq(name), ...size, ...env, launch.sbxAgent, shq(dir2), ...args].filter(Boolean).join(" ");
1982
+ }
1983
+ await newSession(name, dir2, command);
1984
+ }
1985
+ this.attach(id);
1986
+ hub.setSession(id, { state: "running", detail: null, workspace: dir2 });
1987
+ hub.setStatus(id, { state: "idle", note: agent.mode === "sandbox" ? "Sandbox running" : "Running locally" });
1988
+ hub.activity("session", id, `${agent.name} started (${agent.cli === "codex" ? "Codex" : "Claude Code"}, ${agent.mode === "sandbox" ? "Docker Sandbox" : "Local mode"})`);
1989
+ if (agent.mode === "sandbox") {
1990
+ setTimeout(() => void this.refreshSandboxSizes(), 4e3);
1991
+ void this.whenSandboxExists(id).then((ok2) => ok2 && this.onSandboxReady(id));
1992
+ }
1993
+ } catch (err) {
1994
+ fail2(err instanceof Error ? err.message : String(err));
1995
+ }
1996
+ }
1997
+ async stop(id) {
1998
+ const hub = this.hub;
1999
+ const agent = hub.cfg(id);
2000
+ if (!agent) return;
2001
+ const name = this.name(id);
2002
+ this.onSessionDown(id);
2003
+ this.ptys.get(id)?.kill();
2004
+ this.ptys.delete(id);
2005
+ await killSession(name);
2006
+ if (agent.mode === "sandbox") void run("sbx", ["stop", name], { timeoutMs: 3e4 });
2007
+ hub.setSession(id, { state: "stopped", detail: null });
2008
+ hub.setStatus(id, { state: "idle", currentTaskId: null, note: "Stopped" });
2009
+ hub.activity("session", id, `${agent.name} stopped`);
2010
+ }
2011
+ /** What removing this agent would delete: folder state and whether a sandbox exists. */
2012
+ async inspectWorkspace(id) {
2013
+ const agent = this.hub.cfg(id);
2014
+ if (!agent) return null;
2015
+ const dir2 = workspaceFor(agent, this.hub.slug);
2016
+ const exists = existsSync10(dir2);
2017
+ let changedFiles = 0;
2018
+ let unpushedCommits = 0;
2019
+ if (exists) {
2020
+ const status = await run("git", ["status", "--porcelain"], { cwd: dir2 });
2021
+ changedFiles = status.stdout.split("\n").filter(Boolean).length;
2022
+ const log2 = await run("git", ["log", "--oneline", "--branches", "--not", "--remotes"], { cwd: dir2 });
2023
+ unpushedCommits = log2.stdout.split("\n").filter(Boolean).length;
2024
+ }
2025
+ const name = this.name(id);
2026
+ const ls = await run("sbx", ["ls", "--json"], { timeoutMs: 1e4 });
2027
+ let sandboxExists = false;
2028
+ try {
2029
+ sandboxExists = ls.ok && (JSON.parse(ls.stdout).sandboxes ?? []).some((s) => s.name === name);
2030
+ } catch {
2031
+ }
2032
+ return { agentId: id, path: dir2, managed: !agent.workspacePath, exists, changedFiles, unpushedCommits, sandboxName: name, sandboxExists };
2033
+ }
2034
+ /** Stops the agent and deletes what was asked. Only HQ-made folders inside HQ's home can be deleted. */
2035
+ async removeResources(id, opts) {
2036
+ const agent = this.hub.cfg(id);
2037
+ if (!agent) return [];
2038
+ const done = [];
2039
+ const name = this.name(id);
2040
+ this.onSessionDown(id);
2041
+ this.ptys.get(id)?.kill();
2042
+ this.ptys.delete(id);
2043
+ await killSession(name);
2044
+ if (opts.deleteSandbox) {
2045
+ const rm = await run("sbx", ["rm", "-f", name], { timeoutMs: 6e4 });
2046
+ if (rm.ok) done.push("sandbox");
2047
+ else if (!/not found|no such/i.test(rm.stderr)) log.warn(`sbx rm ${name}: ${rm.stderr.trim()}`);
2048
+ } else if (agent.mode === "sandbox") void run("sbx", ["stop", name], { timeoutMs: 3e4 });
2049
+ if (opts.deleteFolder && !agent.workspacePath) {
2050
+ const home = resolve(projectHome(this.hub.slug));
2051
+ const dir2 = resolve(workspaceFor(agent, this.hub.slug));
2052
+ if (dir2.startsWith(home + sep) && dir2 !== home && existsSync10(dir2)) {
2053
+ rmSync(dir2, { recursive: true, force: true });
2054
+ done.push("folder");
2055
+ }
2056
+ }
2057
+ return done;
2058
+ }
2059
+ /** Waits (up to ~90s) for sbx to list the agent's sandbox — creation can take a while the first time. */
2060
+ async whenSandboxExists(id) {
2061
+ for (let i = 0; i < 45; i++) {
2062
+ const r = await run("sbx", ["ls", "--json"], { timeoutMs: 1e4 });
2063
+ try {
2064
+ if ((JSON.parse(r.stdout).sandboxes ?? []).some((s) => s.name === this.name(id))) return true;
2065
+ } catch {
2066
+ }
2067
+ await new Promise((res) => setTimeout(res, 2e3));
2068
+ }
2069
+ return false;
2070
+ }
2071
+ /** Removes the agent's sandbox and starts a fresh one (applies new CPU/memory). The folder is kept. */
2072
+ async recreate(id) {
2073
+ const agent = this.hub.cfg(id);
2074
+ if (!agent || agent.mode !== "sandbox") return;
2075
+ const name = this.name(id);
2076
+ if (this.hub.status(id)?.session.state === "running") await this.stop(id);
2077
+ this.hub.setSession(id, { state: "starting", detail: "Removing the old sandbox\u2026", sandbox: null });
2078
+ const rm = await run("sbx", ["rm", "-f", name], { timeoutMs: 6e4 });
2079
+ if (!rm.ok && !/not found|no such/i.test(rm.stderr)) {
2080
+ this.hub.setSession(id, { state: "failed", detail: `sbx rm failed: ${rm.stderr.trim()}` });
2081
+ return;
2082
+ }
2083
+ this.hub.setSession(id, { state: "stopped", detail: null });
2084
+ this.hub.activity("session", id, `Recreating ${agent.name}'s sandbox with new resources`);
2085
+ await this.start(id);
2086
+ }
2087
+ /** Reads each HQ sandbox's real CPU/memory from `sbx ls --json`. */
2088
+ async refreshSandboxSizes() {
2089
+ const r = await run("sbx", ["ls", "--json"], { timeoutMs: 1e4 });
2090
+ if (!r.ok) return;
2091
+ let list = [];
2092
+ try {
2093
+ list = JSON.parse(r.stdout).sandboxes ?? [];
2094
+ } catch {
2095
+ return;
2096
+ }
2097
+ for (const agent of this.hub.snap.config.team) {
2098
+ const found = list.find((s) => s.name === this.name(agent.id));
2099
+ const m = this.hub.snap.setup.machine;
2100
+ const size = !found ? null : found.cpus && found.memory_mib ? { cpus: found.cpus, memoryGb: Math.round(found.memory_mib / 1024 * 10) / 10 } : { cpus: m.cpus, memoryGb: Math.min(32, Math.max(0.5, m.memoryGb / 2)) };
2101
+ const cur = this.hub.status(agent.id)?.session.sandbox;
2102
+ if (JSON.stringify(cur ?? null) !== JSON.stringify(size)) this.hub.setSession(agent.id, { sandbox: size });
2103
+ }
2104
+ }
2105
+ /**
2106
+ * Lets sandboxes reach HQ's port (checked once per run, added only if missing).
2107
+ * The sandbox proxy evaluates host.docker.internal as localhost, so both are allowed.
2108
+ */
2109
+ async ensurePolicy() {
2110
+ if (this.policyOk) return;
2111
+ const port = this.hub.snap.config.project.hqPort;
2112
+ for (const target of [`localhost:${port}`, `host.docker.internal:${port}`]) {
2113
+ const check = await run("sbx", ["policy", "check", "network", target], { timeoutMs: 1e4 });
2114
+ if (/^Allowed/m.test(check.stdout)) continue;
2115
+ const add = await run("sbx", ["policy", "allow", "network", target], { timeoutMs: 1e4 });
2116
+ if (!add.ok) throw new Error(`Could not allow ${target}: ${add.stderr.trim()}`);
2117
+ log.info(`added sandbox network rule: allow ${target}`);
2118
+ }
2119
+ this.policyOk = true;
2120
+ }
2121
+ // ------------------------------------------------------------ terminals
2122
+ /** One pty per agent, attached to its tmux session; output goes to every browser. */
2123
+ attach(id) {
2124
+ if (this.ptys.has(id)) return;
2125
+ const size = this.sizes.get(id) ?? { cols: 120, rows: 36 };
2126
+ const pty = spawnPty("tmux", ["attach-session", "-t", `=${this.name(id)}`], { name: "xterm-256color", cols: size.cols, rows: size.rows, env: TERM_ENV });
2127
+ this.ptys.set(id, pty);
2128
+ pty.onData((d) => this.hub.terminal(id, d));
2129
+ pty.onExit(() => {
2130
+ if (this.ptys.get(id) === pty) this.ptys.delete(id);
2131
+ });
2132
+ }
2133
+ /** Types a line into an agent's Claude Code prompt and submits it. */
2134
+ async typeInto(agentId, text) {
2135
+ const target = `=${this.name(agentId)}:`;
2136
+ if (!await hasSession(this.name(agentId))) return false;
2137
+ if (!(await run("tmux", ["send-keys", "-t", target, "-l", text], { timeoutMs: 5e3 })).ok) return false;
2138
+ await new Promise((r) => setTimeout(r, 200));
2139
+ return (await run("tmux", ["send-keys", "-t", target, "Enter"], { timeoutMs: 5e3 })).ok;
2140
+ }
2141
+ async pressEnter(agentId) {
2142
+ return (await run("tmux", ["send-keys", "-t", `=${this.name(agentId)}:`, "Enter"], { timeoutMs: 5e3 })).ok;
2143
+ }
2144
+ input(id, data) {
2145
+ if (id.startsWith("setup:")) return this.fixes.get(id)?.write(data);
2146
+ this.ptys.get(id)?.write(data);
2147
+ }
2148
+ resize(id, cols, rows) {
2149
+ const c = Math.max(20, Math.min(400, Math.floor(cols)));
2150
+ const r = Math.max(5, Math.min(200, Math.floor(rows)));
2151
+ const same = this.sizes.get(id)?.cols === c && this.sizes.get(id)?.rows === r;
2152
+ this.sizes.set(id, { cols: c, rows: r });
2153
+ const pty = id.startsWith("setup:") ? this.fixes.get(id) : this.ptys.get(id);
2154
+ if (!pty) return;
2155
+ if (same) {
2156
+ pty.resize(c, Math.max(5, r - 1));
2157
+ setTimeout(() => pty.resize(c, r), 60);
2158
+ } else pty.resize(c, r);
2159
+ }
2160
+ async openWorkspace(id) {
2161
+ const st = this.hub.status(id);
2162
+ const agent = this.hub.cfg(id);
2163
+ const dir2 = st?.session.workspace ?? (agent ? workspaceFor(agent, this.hub.slug) : null);
2164
+ if (!dir2) return;
2165
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
2166
+ await run(opener, [dir2], { timeoutMs: 5e3 });
2167
+ }
2168
+ // ------------------------------------------------------------ health
2169
+ /** Keeps session state honest: picks up sessions from before a restart, notices crashes. */
2170
+ startPolling() {
2171
+ const tick = async () => {
2172
+ if (this.hub.snap.setup.checks.find((c) => c.id === "tmux")?.status !== "ok") return;
2173
+ for (const agent of this.hub.snap.config.team) {
2174
+ const st = this.hub.status(agent.id);
2175
+ if (!st || st.session.state === "starting") continue;
2176
+ const name = this.name(agent.id);
2177
+ const alive = await hasSession(name);
2178
+ if (alive) {
2179
+ const pane = await paneInfo(name);
2180
+ if (pane?.dead) {
2181
+ const detail = await tailText(name) || `Exited with code ${pane.exitStatus}`;
2182
+ await killSession(name);
2183
+ this.ptys.delete(agent.id);
2184
+ this.onSessionDown(agent.id);
2185
+ this.hub.setSession(agent.id, { state: "failed", detail });
2186
+ this.hub.setStatus(agent.id, { state: "idle", note: "Session ended" });
2187
+ this.hub.activity("session", agent.id, `${agent.name}'s session ended`);
2188
+ continue;
2189
+ }
2190
+ this.attach(agent.id);
2191
+ if (st.session.state !== "running") {
2192
+ this.hub.setSession(agent.id, { state: "running", detail: null, workspace: workspaceFor(agent, this.hub.slug) });
2193
+ this.hub.setStatus(agent.id, { note: "Reattached" });
2194
+ this.onSessionReattached(agent.id);
2195
+ }
2196
+ if (agent.cli === "codex") await this.watchCodexScreen(agent.id, name);
2197
+ } else if (st.session.state === "running") {
2198
+ this.ptys.delete(agent.id);
2199
+ this.onSessionDown(agent.id);
2200
+ this.hub.setSession(agent.id, { state: "stopped", detail: null });
2201
+ this.hub.setStatus(agent.id, { state: "idle", note: "Stopped" });
2202
+ }
2203
+ }
2204
+ };
2205
+ void tick();
2206
+ let n = 0;
2207
+ this.polling = setInterval(() => {
2208
+ void tick().catch((e) => log.warn("poll:", e));
2209
+ if (n++ % 10 === 0 && this.hub.snap.setup.sandboxReady) void this.refreshSandboxSizes();
2210
+ }, 3e3);
2211
+ }
2212
+ /**
2213
+ * Codex fires SessionStart only on the first prompt, so HQ reads its screen:
2214
+ * the trust prompts mean "needs you"; the empty prompt means HQ can type.
2215
+ */
2216
+ async watchCodexScreen(agentId, name) {
2217
+ const st = this.hub.status(agentId);
2218
+ if (!st) return;
2219
+ const screen = await tailText(name, 14);
2220
+ const TRUST_NOTE = "Asking: approve Codex's folder and hook prompts in the terminal (choose Yes, then Trust all)";
2221
+ if (/Do you trust the contents of this directory|Hooks need review/i.test(screen)) {
2222
+ if (st.note !== TRUST_NOTE) {
2223
+ this.hub.setStatus(agentId, { state: "needs-input", note: TRUST_NOTE });
2224
+ this.hub.activity("agent.needs-input", agentId, `${this.hub.name(agentId)} needs you: approve Codex's trust prompts`);
2225
+ }
2226
+ return;
2227
+ }
2228
+ if (/Ask Codex to do anything/i.test(screen)) {
2229
+ this.onSessionReattached(agentId);
2230
+ if (st.note === TRUST_NOTE || st.note === "Sandbox running" || st.note === "Running locally" || st.note === "Reattached")
2231
+ this.hub.setStatus(agentId, { state: "idle", note: "Ready" });
2232
+ }
2233
+ }
2234
+ // ------------------------------------------------------------ setup helper
2235
+ async recheckSetup() {
2236
+ this.hub.setSetup(await runSetupChecks(this.hub.repo, this.hub.snap.config.team));
2237
+ }
2238
+ /** Runs a check's fix command in a visible terminal. Only commands HQ itself suggested. */
2239
+ runFix(checkId) {
2240
+ const check = this.hub.snap.setup.checks.find((c) => c.id === checkId);
2241
+ if (!check?.fix?.runnable || !check.fix.command) return;
2242
+ const id = `setup:${checkId}`;
2243
+ if (this.fixes.has(id)) return;
2244
+ const shell = process.platform === "win32" ? "powershell.exe" : process.env.SHELL || "/bin/sh";
2245
+ const args = process.platform === "win32" ? ["-NoProfile", "-Command", check.fix.command] : ["-lc", check.fix.command];
2246
+ const size = this.sizes.get(id) ?? { cols: 100, rows: 16 };
2247
+ const pty = spawnPty(shell, args, { name: "xterm-256color", cols: size.cols, rows: size.rows, cwd: this.hub.repo.root, env: TERM_ENV });
2248
+ this.fixes.set(id, pty);
2249
+ this.hub.terminal(id, `\x1B[2m$ ${check.fix.command}\x1B[0m\r
2250
+ `);
2251
+ pty.onData((d) => this.hub.terminal(id, d));
2252
+ pty.onExit(({ exitCode }) => {
2253
+ this.fixes.delete(id);
2254
+ this.hub.terminal(id, `\r
2255
+ \x1B[${exitCode === 0 ? "32m\u2713 Done" : `31m\u2717 Exited with code ${exitCode}`}\x1B[0m\r
2256
+ `);
2257
+ void this.recheckSetup();
2258
+ });
2259
+ }
2260
+ shutdown() {
2261
+ if (this.polling) clearInterval(this.polling);
2262
+ for (const p of this.ptys.values()) p.kill();
2263
+ for (const p of this.fixes.values()) p.kill();
2264
+ }
2265
+ };
2266
+
2267
+ // server/main.ts
2268
+ function arg(name) {
2269
+ const i = process.argv.indexOf(`--${name}`);
2270
+ return i > -1 ? process.argv[i + 1] : void 0;
2271
+ }
2272
+ var flag = (name) => process.argv.includes(`--${name}`);
2273
+ async function main() {
2274
+ ensurePtyHelperExecutable();
2275
+ const dev = flag("dev");
2276
+ const port = Number(arg("port") ?? process.env.HQ_PORT ?? 7420);
2277
+ const repo = await detectRepo(resolve2(arg("repo") ?? process.env.HQ_REPO ?? process.cwd()));
2278
+ const { config, isNew } = loadConfig(repo, port);
2279
+ config.project.hqPort = port;
2280
+ const slug2 = config.project.id;
2281
+ const token = agentToken(slug2);
2282
+ const uiToken = browserToken(slug2, dev);
2283
+ log.info(`repo ${repo.root}${repo.github ? ` (github.com/${repo.github})` : ""}`);
2284
+ log.info("checking setup\u2026");
2285
+ const setup = await runSetupChecks(repo, config.team);
2286
+ for (const c of setup.checks) log.info(` ${c.status === "ok" ? "\u2713" : c.status === "warning" ? "!" : "\u2717"} ${c.label}: ${c.detail}`);
2287
+ const hub = new Hub(repo, slug2, config, loadPlan(repo.root), setup);
2288
+ hub.snap.welcome = isNew;
2289
+ const sessions = new Sessions(hub, token);
2290
+ const inbox = new Inbox(hub, { type: (id, text) => sessions.typeInto(id, text), enter: (id) => sessions.pressEnter(id) });
2291
+ sessions.onSessionDown = (id) => inbox.sessionDown(id);
2292
+ sessions.onSessionReattached = (id) => inbox.sessionReattached(id);
2293
+ const sbx = new Sbx(hub);
2294
+ sessions.envFileFor = (id) => existsSync11(sbx.envFile(id)) ? sbx.envFile(id) : null;
2295
+ sessions.onSandboxReady = (id) => void sbx.syncRules(id);
2296
+ void sbx.refresh();
2297
+ const planner = new Planner(hub, inbox);
2298
+ const root = dirname2(dirname2(fileURLToPath(import.meta.url)));
2299
+ const distDir = join10(root, "dist");
2300
+ const uiPort = Number(process.env.HQ_UI_PORT ?? 5173);
2301
+ const uiOrigin = dev ? `http://localhost:${uiPort}` : `http://127.0.0.1:${port}`;
2302
+ if (!dev && !existsSync11(join10(distDir, "index.html"))) log.warn("No built UI found. Run `pnpm build` first, or use `pnpm dev:hq`.");
2303
+ await startHttp({
2304
+ hub,
2305
+ inbox,
2306
+ planner,
2307
+ port,
2308
+ browserToken: uiToken,
2309
+ agentToken: token,
2310
+ distDir: dev ? null : distDir,
2311
+ allowedOrigins: [uiOrigin, `http://127.0.0.1:${uiPort}`, `http://localhost:${port}`, `http://127.0.0.1:${port}`],
2312
+ commands: makeCommands(hub, sessions, planner, inbox, sbx)
2313
+ });
2314
+ sessions.startPolling();
2315
+ const github = new GitHubSync(hub, planner, repo.github);
2316
+ github.start();
2317
+ const url = `${uiOrigin}/?token=${uiToken}`;
2318
+ log.info(`
2319
+
2320
+ JSM HQ is running \u2192 ${url}
2321
+ `);
2322
+ if (!flag("no-open")) {
2323
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
2324
+ setTimeout(() => void run(opener, [url]), dev ? 1500 : 0);
2325
+ }
2326
+ const quit = () => {
2327
+ log.info("stopping HQ \u2014 agent sessions keep running in tmux");
2328
+ sessions.shutdown();
2329
+ github.stop();
2330
+ process.exit(0);
2331
+ };
2332
+ process.on("SIGINT", quit);
2333
+ process.on("SIGTERM", quit);
2334
+ }
2335
+ main().catch((err) => {
2336
+ if (err?.code === "EADDRINUSE") {
2337
+ log.error(`Port ${arg("port") ?? 7420} is in use \u2014 another HQ may be running. Stop it, or pick another port: npx @therealjsmastery/jsm-hq --port 7421`);
2338
+ process.exit(1);
2339
+ }
2340
+ log.error(err instanceof Error ? err.message : err);
2341
+ process.exit(1);
2342
+ });