@pitboss/cli 0.4.8 → 0.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -10864,7 +10864,7 @@ var require_lib2 = __commonJS({
10864
10864
  return matches;
10865
10865
  };
10866
10866
  exports.analyse = analyse;
10867
- var detectFile = (filepath, opts = {}) => new Promise((resolve8, reject) => {
10867
+ var detectFile = (filepath, opts = {}) => new Promise((resolve9, reject) => {
10868
10868
  let fd;
10869
10869
  const fs = (0, node_1.default)();
10870
10870
  const handler = (err, buffer) => {
@@ -10874,7 +10874,7 @@ var require_lib2 = __commonJS({
10874
10874
  if (err) {
10875
10875
  reject(err);
10876
10876
  } else if (buffer) {
10877
- resolve8((0, exports.detect)(buffer));
10877
+ resolve9((0, exports.detect)(buffer));
10878
10878
  } else {
10879
10879
  reject(new Error("No error and no buffer received"));
10880
10880
  }
@@ -17281,13 +17281,13 @@ var PromisePolyfill = class extends Promise {
17281
17281
  // Available starting from Node 22
17282
17282
  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers
17283
17283
  static withResolver() {
17284
- let resolve8;
17284
+ let resolve9;
17285
17285
  let reject;
17286
17286
  const promise = new Promise((res, rej) => {
17287
- resolve8 = res;
17287
+ resolve9 = res;
17288
17288
  reject = rej;
17289
17289
  });
17290
- return { promise, resolve: resolve8, reject };
17290
+ return { promise, resolve: resolve9, reject };
17291
17291
  }
17292
17292
  };
17293
17293
 
@@ -17324,7 +17324,7 @@ function createPrompt(view) {
17324
17324
  });
17325
17325
  output.mute();
17326
17326
  const screen = new ScreenManager(rl);
17327
- const { promise, resolve: resolve8, reject } = PromisePolyfill.withResolver();
17327
+ const { promise, resolve: resolve9, reject } = PromisePolyfill.withResolver();
17328
17328
  const cancel = () => reject(new CancelPromptError());
17329
17329
  if (signal) {
17330
17330
  const abort = () => reject(new AbortPromptError({ cause: signal.reason }));
@@ -17355,7 +17355,7 @@ function createPrompt(view) {
17355
17355
  try {
17356
17356
  const nextView = view(config, (value) => {
17357
17357
  if (effectsSettled) {
17358
- resolve8(value);
17358
+ resolve9(value);
17359
17359
  } else {
17360
17360
  pendingDone = { value };
17361
17361
  }
@@ -17378,7 +17378,7 @@ function createPrompt(view) {
17378
17378
  if (pendingDone !== null) {
17379
17379
  const { value } = pendingDone;
17380
17380
  pendingDone = null;
17381
- resolve8(value);
17381
+ resolve9(value);
17382
17382
  }
17383
17383
  });
17384
17384
  };
@@ -17533,7 +17533,7 @@ var ExternalEditor = class {
17533
17533
  }
17534
17534
  runAsync(callback) {
17535
17535
  this.createTempFile();
17536
- const promise = new Promise((resolve8, reject) => {
17536
+ const promise = new Promise((resolve9, reject) => {
17537
17537
  try {
17538
17538
  const editorProcess = spawn(this.editor.bin, this.editorArgs(), {
17539
17539
  shell: false,
@@ -17544,7 +17544,7 @@ var ExternalEditor = class {
17544
17544
  });
17545
17545
  editorProcess.once("exit", (code) => {
17546
17546
  this.lastExitStatus = code ?? 0;
17547
- resolve8();
17547
+ resolve9();
17548
17548
  });
17549
17549
  } catch (launchError) {
17550
17550
  reject(new LaunchEditorError(launchError));
@@ -19107,6 +19107,7 @@ function isWorkerType(value) {
19107
19107
  return typeof value === "string" && WORKER_TYPES.includes(value);
19108
19108
  }
19109
19109
  var DEFAULT_UNSTARTED_AFTER_MS = 4 * 60 * 60 * 1e3;
19110
+ var DEFAULT_REVIEW_SILENT_MS = 40 * 60 * 1e3;
19110
19111
  var POOL_STOPPED_REASON = "pool member stopped by pitboss node";
19111
19112
 
19112
19113
  // src/lib/runtimes.ts
@@ -20287,6 +20288,8 @@ function summarize(e, bodyChars = 80, agentId) {
20287
20288
  return `review ${p.review?.decision ?? "submitted"}: "${taskTitle}"`;
20288
20289
  case "task.review_overdue":
20289
20290
  return `review OVERDUE: "${taskTitle}"`;
20291
+ case "task.review_silent":
20292
+ return `review SILENT: "${taskTitle}"`;
20290
20293
  case "thread.created":
20291
20294
  return `new thread: "${threadTitle}"`;
20292
20295
  case "thread.concluded":
@@ -20324,6 +20327,7 @@ var KIND_STYLE = {
20324
20327
  "task.review_started": source_default.magenta,
20325
20328
  "task.review_submitted": source_default.magenta,
20326
20329
  "task.review_overdue": source_default.yellow,
20330
+ "task.review_silent": source_default.yellow,
20327
20331
  "thread.created": source_default.cyan,
20328
20332
  "thread.concluded": source_default.dim,
20329
20333
  "thread.message_ceiling": source_default.yellow
@@ -20599,14 +20603,13 @@ function hookCursorPromptCommand(opts) {
20599
20603
  process.stdout.write("{}");
20600
20604
  }
20601
20605
 
20602
- // src/commands/node.ts
20603
- import { execFileSync as execFileSync7, spawn as spawnChild } from "node:child_process";
20604
- import { closeSync, existsSync as existsSync11, mkdirSync as mkdirSync9, openSync, readFileSync as readFileSync12, realpathSync as realpathSync5 } from "node:fs";
20605
- import { dirname as dirname11, resolve as resolve7 } from "node:path";
20606
- import { fileURLToPath as fileURLToPath3 } from "node:url";
20606
+ // src/commands/onboard.ts
20607
+ import { execFileSync as execFileSync7 } from "node:child_process";
20608
+ import { existsSync as existsSync10, mkdirSync as mkdirSync9, readFileSync as readFileSync13, realpathSync as realpathSync5, writeFileSync as writeFileSync9 } from "node:fs";
20609
+ import { homedir as homedir6 } from "node:os";
20607
20610
 
20608
- // src/node/spec.ts
20609
- import { join as join15, resolve as resolve4, sep as sep2 } from "node:path";
20611
+ // src/node/onboard.ts
20612
+ import { join as join15, resolve as resolve4 } from "node:path";
20610
20613
 
20611
20614
  // ../claude-worker/src/session.ts
20612
20615
  import { spawn as spawn2 } from "child_process";
@@ -20667,7 +20670,8 @@ var BASE_TASK_LOOP = (agentId, repoPath) => `You are an AI worker agent (ID: ${a
20667
20670
  a one-line status on the task thread naming what you are checking) when you begin, then
20668
20671
  run the tests, compare the diff against the spec the task names, then
20669
20672
  \`mcp__relai__submit_review\` \u2014 \`approve\`, or \`reject\` with a specific note the author
20670
- can act on. Do this even when get_my_tasks is empty.
20673
+ can act on. When the task is a goal's build with a PR, pass \`headSha\`: the full sha of the
20674
+ commit you reviewed. Do this even when get_my_tasks is empty.
20671
20675
  If the change touches a long-running service or a third-party runtime integration (SSE/event streams, launchd/systemd, auth headers, a client library), run the changed path once before approving and quote what it printed. A type cast past a library's own types is a reason to stop and run it.
20672
20676
 
20673
20677
  4. Call \`mcp__relai__get_my_tasks\` \u2014 if it returns no tasks, stop immediately.
@@ -20909,7 +20913,11 @@ Find the default branch with \`git remote show origin\`.
20909
20913
  \`specialization\` or \`domains\` only to values agents in \`mcp__relai__list_agents\` declare:
20910
20914
  the router never hands out a task whose tag no agent matches. When the repo has \`writer\` agents, set
20911
20915
  \`specialization: "writer"\` on build tasks, so they wait for a writer instead of going to a reviewer.
20912
- Never name a build task's assignee as the goal's reviewer; the API refuses it. Make them small
20916
+ Before writing any build task, choose the goal's reviewer (for a \`reviewer_agent\` gate): a
20917
+ \`reviewer\` agent, on a different \`workerType\` from the writers when one exists. Never assign that
20918
+ agent a build task, and never leave the only reviewers busy building: a goal whose every reviewer
20919
+ built part of it has nobody left to review it. The API defaults a build task with no
20920
+ \`specialization\` to \`writer\` when the repo has writers. Make them small
20913
20921
  and independently testable so several agents can work at once. Use \`blockedBy\` only where
20914
20922
  one task truly needs another's result. Each description is self-contained: what to change,
20915
20923
  which files, how to test it, and the acceptance criteria.
@@ -21052,6 +21060,9 @@ var USAGE_NUMBER_KEYS = [
21052
21060
  "cache_read_input_tokens",
21053
21061
  "cache_creation_input_tokens"
21054
21062
  ];
21063
+ function notReportedUsage(reason) {
21064
+ return { notReported: true, reason };
21065
+ }
21055
21066
  function finiteNumber(value) {
21056
21067
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
21057
21068
  }
@@ -21119,14 +21130,32 @@ async function getTaskStatus(conn, id, logPrefix) {
21119
21130
  return void 0;
21120
21131
  }
21121
21132
  }
21122
- async function sessionTouchedTaskIds(conn, candidates, logPrefix) {
21133
+ async function listUpdatedSince(conn, since, logPrefix) {
21134
+ try {
21135
+ const res = await fetch(
21136
+ `${conn.apiUrl}/tasks?repoId=${conn.repoId}&assignedTo=${conn.agentId}&updatedAfter=${encodeURIComponent(since)}`,
21137
+ { headers: { Authorization: `Bearer ${conn.apiSecret}` } }
21138
+ );
21139
+ if (!res.ok) throw new Error(`task list failed (${res.status} ${res.statusText})`);
21140
+ const { data } = await res.json();
21141
+ return (data ?? []).filter((row) => typeof row.id === "string" && row.id.length > 0).filter((row) => row.status !== "assigned").map((row) => row.id);
21142
+ } catch (err) {
21143
+ console.error(
21144
+ `${logPrefix} Could not list tasks updated since the session started:`,
21145
+ err instanceof Error ? err.message : String(err)
21146
+ );
21147
+ return [];
21148
+ }
21149
+ }
21150
+ async function sessionTouchedTaskIds(conn, candidates, logPrefix, since) {
21123
21151
  const touched = [];
21124
21152
  for (const id of candidates) {
21125
21153
  const status = await getTaskStatus(conn, id, logPrefix);
21126
21154
  if (status !== void 0 && status !== "assigned") touched.push(id);
21127
21155
  }
21128
21156
  const inProgressAtClose = await listSessionTaskIds(conn, logPrefix, "in_progress");
21129
- return unionTaskIds(touched, inProgressAtClose);
21157
+ const updated = since ? await listUpdatedSince(conn, since, logPrefix) : [];
21158
+ return unionTaskIds(touched, inProgressAtClose, updated);
21130
21159
  }
21131
21160
  function unionTaskIds(...lists) {
21132
21161
  const seen = /* @__PURE__ */ new Set();
@@ -21140,34 +21169,99 @@ function unionTaskIds(...lists) {
21140
21169
  }
21141
21170
  return out;
21142
21171
  }
21143
- async function writeSessionUsage(conn, taskIds, usage, logPrefix) {
21144
- const sessionUsage = {
21145
- ...usage,
21146
- at: (/* @__PURE__ */ new Date()).toISOString(),
21147
- sessionTaskIds: [...taskIds]
21148
- };
21172
+ async function writeSessionUsage(conn, taskIds, usage, logPrefix, opts = {}) {
21149
21173
  const headers = { "Content-Type": "application/json", Authorization: `Bearer ${conn.apiSecret}` };
21150
21174
  for (const id of taskIds) {
21175
+ const slice = taskIds.length > 1 && !usage.notReported ? opts.slices?.[id] : void 0;
21176
+ const share = usage.notReported ? void 0 : slice || taskIds.length === 1 ? "task" : "session";
21177
+ const body = {
21178
+ usage: slice ?? usage,
21179
+ sessionTaskIds: taskIds,
21180
+ ...opts.model ? { model: opts.model } : {},
21181
+ ...share ? { share } : {}
21182
+ };
21151
21183
  try {
21152
- const put = await fetch(`${conn.apiUrl}/tasks/${id}`, {
21153
- method: "PUT",
21184
+ const res = await fetch(`${conn.apiUrl}/tasks/${id}/usage-report`, {
21185
+ method: "POST",
21154
21186
  headers,
21155
- body: JSON.stringify({ metadata: { sessionUsage } })
21187
+ body: JSON.stringify(body)
21156
21188
  });
21157
- if (!put.ok) {
21158
- console.error(`${logPrefix} Could not write sessionUsage on ${id} (${put.status} ${put.statusText})`);
21189
+ if (!res.ok) {
21190
+ console.error(`${logPrefix} Could not report usage on ${id} (${res.status} ${res.statusText})`);
21159
21191
  }
21160
21192
  } catch (err) {
21161
21193
  console.error(
21162
- `${logPrefix} Could not write sessionUsage on ${id}:`,
21194
+ `${logPrefix} Could not report usage on ${id}:`,
21163
21195
  err instanceof Error ? err.message : String(err)
21164
21196
  );
21165
21197
  }
21166
21198
  }
21167
21199
  }
21168
- async function recordSessionUsage(conn, startIds, usage, logPrefix) {
21200
+ async function recordSessionUsage(conn, startIds, usage, logPrefix, opts = {}) {
21169
21201
  const start = await startIds;
21170
- await writeSessionUsage(conn, await sessionTouchedTaskIds(conn, start, logPrefix), usage, logPrefix);
21202
+ const { since, ...rest } = opts;
21203
+ await writeSessionUsage(conn, await sessionTouchedTaskIds(conn, start, logPrefix, since), usage, logPrefix, rest);
21204
+ }
21205
+ var BOUNDARY_STATUSES = /* @__PURE__ */ new Set(["completed", "blocked", "pending_verification"]);
21206
+ function addUsage(into, from) {
21207
+ into.num_turns = (into.num_turns ?? 0) + (from.num_turns ?? 0);
21208
+ const total = into.usage ?? {};
21209
+ for (const key of USAGE_NUMBER_KEYS) {
21210
+ const n = from.usage?.[key];
21211
+ if (n !== void 0) total[key] = (total[key] ?? 0) + n;
21212
+ }
21213
+ into.usage = total;
21214
+ }
21215
+ function createUsageTracker() {
21216
+ let model;
21217
+ let pending = /* @__PURE__ */ new Map();
21218
+ const slices = {};
21219
+ let anon = 0;
21220
+ function cut(taskId) {
21221
+ const slice = slices[taskId] ?? {};
21222
+ for (const messageUsage of pending.values()) addUsage(slice, messageUsage);
21223
+ pending = /* @__PURE__ */ new Map();
21224
+ if (slice.usage) slices[taskId] = slice;
21225
+ }
21226
+ return {
21227
+ observe(event) {
21228
+ if (event.type === "system" && event.subtype === "init") {
21229
+ if (typeof event.model === "string" && event.model) model = event.model;
21230
+ return;
21231
+ }
21232
+ if (event.type !== "assistant") return;
21233
+ const message = event.message ?? {};
21234
+ const raw = message.usage;
21235
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
21236
+ const usage = pickNumbers(raw, USAGE_NUMBER_KEYS);
21237
+ if (Object.keys(usage).length > 0) {
21238
+ pending.set(typeof message.id === "string" ? message.id : `anon_${anon++}`, { num_turns: 1, usage });
21239
+ }
21240
+ }
21241
+ const content = Array.isArray(message.content) ? message.content : [];
21242
+ for (const block of content) {
21243
+ if (block.type !== "tool_use" || typeof block.name !== "string" || !block.name.endsWith("update_task_status")) continue;
21244
+ const input = block.input ?? {};
21245
+ if (typeof input.taskId === "string" && typeof input.status === "string" && BOUNDARY_STATUSES.has(input.status)) {
21246
+ cut(input.taskId);
21247
+ }
21248
+ }
21249
+ },
21250
+ model: () => model,
21251
+ opts: () => ({ ...model ? { model } : {}, slices })
21252
+ };
21253
+ }
21254
+
21255
+ // ../claude-worker/src/model.ts
21256
+ import { readFileSync as readFileSync11 } from "node:fs";
21257
+ function pinnedModel(env2 = process.env) {
21258
+ const file = env2.PITBOSS_MODEL_FILE;
21259
+ if (!file) return void 0;
21260
+ try {
21261
+ return readFileSync11(file, "utf-8").trim() || void 0;
21262
+ } catch {
21263
+ return void 0;
21264
+ }
21171
21265
  }
21172
21266
 
21173
21267
  // ../claude-worker/src/session.ts
@@ -21202,26 +21296,36 @@ function writeMcpConfig(config, prefix = "claude-worker") {
21202
21296
  writeFileSync6(path3, JSON.stringify(mcpConfig), { mode: 384 });
21203
21297
  return path3;
21204
21298
  }
21299
+ function claudeArgs(opts) {
21300
+ return [
21301
+ "--print",
21302
+ "--mcp-config",
21303
+ opts.mcpConfigPath,
21304
+ "--no-session-persistence",
21305
+ "--dangerously-skip-permissions",
21306
+ "--output-format",
21307
+ "stream-json",
21308
+ "--verbose",
21309
+ "--model",
21310
+ opts.model,
21311
+ "--add-dir",
21312
+ opts.repoPath,
21313
+ ...opts.isolateUserConfig ? ["--setting-sources", "project,local"] : []
21314
+ ];
21315
+ }
21205
21316
  async function runClaudeSession(config) {
21206
21317
  const prompt = buildPrompt(config);
21207
21318
  const mcpConfigPath = writeMcpConfig(config);
21319
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
21208
21320
  const taskIdsPromise = listSessionTaskIds(config, "[claude-worker]");
21209
21321
  try {
21210
- return await new Promise((resolve8, reject) => {
21211
- const args = [
21212
- "--print",
21213
- "--mcp-config",
21322
+ return await new Promise((resolve9, reject) => {
21323
+ const args = claudeArgs({
21214
21324
  mcpConfigPath,
21215
- "--no-session-persistence",
21216
- "--dangerously-skip-permissions",
21217
- "--output-format",
21218
- "stream-json",
21219
- "--verbose",
21220
- "--model",
21221
- config.model,
21222
- "--add-dir",
21223
- config.repoPath
21224
- ];
21325
+ model: pinnedModel() ?? config.model,
21326
+ repoPath: config.repoPath,
21327
+ isolateUserConfig: process.env.PITBOSS_ISOLATE_USER_CONFIG === "1"
21328
+ });
21225
21329
  console.log(`[claude-worker] Spawning: ${config.claudeBin} ${args.slice(0, 4).join(" ")} ...`);
21226
21330
  console.log(`[claude-worker] cwd: ${config.repoPath}`);
21227
21331
  const { ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, ...workerEnv } = process.env;
@@ -21237,6 +21341,8 @@ async function runClaudeSession(config) {
21237
21341
  let buffer = "";
21238
21342
  let resultError = "";
21239
21343
  let sessionUsage;
21344
+ let sawResult = false;
21345
+ const tracker = createUsageTracker();
21240
21346
  proc.stdout.on("data", (chunk) => {
21241
21347
  buffer += chunk.toString();
21242
21348
  const lines = buffer.split("\n");
@@ -21245,6 +21351,7 @@ async function runClaudeSession(config) {
21245
21351
  if (!line.trim()) continue;
21246
21352
  try {
21247
21353
  const event = JSON.parse(line);
21354
+ tracker.observe(event);
21248
21355
  if (event.type === "assistant") {
21249
21356
  const content = event.message?.content ?? [];
21250
21357
  for (const block of content) {
@@ -21258,6 +21365,7 @@ async function runClaudeSession(config) {
21258
21365
  }
21259
21366
  }
21260
21367
  } else if (event.type === "result") {
21368
+ sawResult = true;
21261
21369
  const parsed = parseResultUsage(event);
21262
21370
  if (parsed) sessionUsage = parsed;
21263
21371
  if (event.is_error) {
@@ -21279,15 +21387,15 @@ async function runClaudeSession(config) {
21279
21387
  proc.on("close", (code, signal) => {
21280
21388
  const unique = [...new Set(toolsUsed)];
21281
21389
  console.log(`[claude-worker] Done \u2014 tools used: ${unique.length ? unique.join(", ") : "none"}`);
21282
- const usage = sessionUsage;
21283
- if (usage) console.log(formatSessionUsageLine("[claude-worker]", usage));
21284
- const finish = usage ? recordSessionUsage(config, taskIdsPromise, usage, "[claude-worker]") : Promise.resolve();
21390
+ const usage = sessionUsage ?? notReportedUsage(sawResult ? "adapter" : "killed");
21391
+ if (sessionUsage) console.log(formatSessionUsageLine("[claude-worker]", sessionUsage));
21392
+ const finish = recordSessionUsage(config, taskIdsPromise, usage, "[claude-worker]", { since: startedAt, ...tracker.opts() });
21285
21393
  const settle = () => {
21286
21394
  if (signal || code !== 0 && code !== null) {
21287
21395
  const detail = (resultError || stderrOutput || "").trim();
21288
21396
  reject(new Error(`claude exited with ${signal ?? `code ${code}`}${detail ? `: ${detail}` : ""}`));
21289
21397
  } else {
21290
- resolve8(unique);
21398
+ resolve9(unique);
21291
21399
  }
21292
21400
  };
21293
21401
  finish.then(settle, settle);
@@ -21339,8 +21447,8 @@ function classifySessionError(text) {
21339
21447
 
21340
21448
  // ../claude-worker/src/adapters/cursor.ts
21341
21449
  var bin = () => process.env.CURSOR_BIN ?? "cursor-agent";
21342
- function cursorArgs(repoPath, prompt) {
21343
- return ["-p", "--trust", "--force", "--output-format", "stream-json", "--workspace", repoPath, prompt];
21450
+ function cursorArgs(repoPath, prompt, model) {
21451
+ return ["-p", "--trust", "--force", "--output-format", "stream-json", "--workspace", repoPath, ...model ? ["--model", model] : [], prompt];
21344
21452
  }
21345
21453
  function readCursorEvent(line) {
21346
21454
  let event;
@@ -21349,6 +21457,9 @@ function readCursorEvent(line) {
21349
21457
  } catch {
21350
21458
  return {};
21351
21459
  }
21460
+ if (event.type === "system" && event.subtype === "init") {
21461
+ return typeof event.model === "string" && event.model ? { model: event.model } : {};
21462
+ }
21352
21463
  if (event.type === "tool_call" && event.subtype === "started") {
21353
21464
  const call = event.tool_call ?? {};
21354
21465
  const [kind] = Object.keys(call);
@@ -21364,10 +21475,11 @@ function readCursorEvent(line) {
21364
21475
  return {};
21365
21476
  }
21366
21477
  async function runCursorSession(config) {
21367
- const args = cursorArgs(config.repoPath, buildPrompt(config));
21478
+ const args = cursorArgs(config.repoPath, buildPrompt(config), pinnedModel());
21368
21479
  console.log(`[cursor-worker] Spawning: ${bin()} ${args.slice(0, 5).join(" ")} ...`);
21480
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
21369
21481
  const taskIdsPromise = listSessionTaskIds(config, "[cursor-worker]");
21370
- return new Promise((resolve8, reject) => {
21482
+ return new Promise((resolve9, reject) => {
21371
21483
  const proc = spawn3(bin(), args, { cwd: config.repoPath, env: process.env, stdio: ["ignore", "pipe", "pipe"] });
21372
21484
  console.log(`[cursor-worker] Process PID: ${proc.pid}`);
21373
21485
  const toolsUsed = [];
@@ -21375,12 +21487,14 @@ async function runCursorSession(config) {
21375
21487
  let resultError = "";
21376
21488
  let stderrOutput = "";
21377
21489
  let sessionUsage;
21490
+ let model;
21378
21491
  proc.stdout.on("data", (chunk) => {
21379
21492
  buffer += chunk.toString();
21380
21493
  const lines = buffer.split("\n");
21381
21494
  buffer = lines.pop() ?? "";
21382
21495
  for (const line of lines) {
21383
- const { tool, error, usage } = readCursorEvent(line);
21496
+ const { tool, error, usage, model: initModel } = readCursorEvent(line);
21497
+ if (initModel) model = initModel;
21384
21498
  if (tool) {
21385
21499
  toolsUsed.push(tool);
21386
21500
  process.stdout.write(` \u2699 ${tool}
@@ -21398,15 +21512,15 @@ async function runCursorSession(config) {
21398
21512
  });
21399
21513
  proc.on("close", (code, signal) => {
21400
21514
  console.log(`[cursor-worker] Done \u2014 tools used: ${toolsUsed.length ? [...new Set(toolsUsed)].join(", ") : "none"}`);
21401
- const usage = sessionUsage;
21402
- if (usage) console.log(formatSessionUsageLine("[cursor-worker]", usage));
21403
- const finish = usage ? recordSessionUsage(config, taskIdsPromise, usage, "[cursor-worker]") : Promise.resolve();
21515
+ const usage = sessionUsage ?? notReportedUsage("killed");
21516
+ if (sessionUsage) console.log(formatSessionUsageLine("[cursor-worker]", sessionUsage));
21517
+ const finish = recordSessionUsage(config, taskIdsPromise, usage, "[cursor-worker]", { since: startedAt, ...model ? { model } : {} });
21404
21518
  const settle = () => {
21405
21519
  if (signal || code !== 0 && code !== null) {
21406
21520
  const detail = (resultError || stderrOutput).trim();
21407
21521
  reject(new Error(`cursor-agent exited with ${signal ?? `code ${code}`}${detail ? `: ${detail}` : ""}`));
21408
21522
  } else {
21409
- resolve8([...new Set(toolsUsed)]);
21523
+ resolve9([...new Set(toolsUsed)]);
21410
21524
  }
21411
21525
  };
21412
21526
  finish.then(settle, settle);
@@ -21445,10 +21559,11 @@ import { unlinkSync as unlinkSync2 } from "fs";
21445
21559
  var bin2 = () => process.env.COPILOT_BIN ?? "copilot";
21446
21560
  var POLICY_BLOCKS = [/access denied by policy/i, /third-party mcp servers are disabled/i];
21447
21561
  var QUOTA = [/premium request/i, /monthly (?:limit|allowance|quota)/i];
21448
- function copilotArgs(repoPath, prompt, mcpConfigPath) {
21562
+ function copilotArgs(repoPath, prompt, mcpConfigPath, model) {
21449
21563
  return [
21450
21564
  "-p",
21451
21565
  prompt,
21566
+ ...model ? ["--model", model] : [],
21452
21567
  "--allow-all-tools",
21453
21568
  "--output-format",
21454
21569
  "json",
@@ -21470,6 +21585,7 @@ function readCopilotEvent(line) {
21470
21585
  const type = typeof event.type === "string" ? event.type : "";
21471
21586
  const data = event.data ?? {};
21472
21587
  const message = typeof data.message === "string" ? data.message : "";
21588
+ if (type === "session.tools_updated" && typeof data.model === "string" && data.model) return { model: data.model };
21473
21589
  if (/^tool\..*start/.test(type) && typeof data.toolName === "string") return { tool: data.toolName };
21474
21590
  if (type === "session.error") return { error: message || "copilot session error" };
21475
21591
  if (type === "result" && typeof event.exitCode === "number" && event.exitCode !== 0) return { error: `copilot finished with exit code ${event.exitCode}` };
@@ -21478,21 +21594,25 @@ function readCopilotEvent(line) {
21478
21594
  }
21479
21595
  async function runCopilotSession(config) {
21480
21596
  const mcpConfigPath = writeMcpConfig(config, "copilot-worker");
21481
- const args = copilotArgs(config.repoPath, buildPrompt(config), mcpConfigPath);
21597
+ const args = copilotArgs(config.repoPath, buildPrompt(config), mcpConfigPath, pinnedModel());
21598
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
21599
+ const taskIdsPromise = listSessionTaskIds(config, "[copilot-worker]");
21482
21600
  console.log(`[copilot-worker] Spawning: ${bin2()} -p <prompt> ${args.slice(2, 6).join(" ")} ...`);
21483
- return new Promise((resolve8, reject) => {
21601
+ return new Promise((resolve9, reject) => {
21484
21602
  const proc = spawn4(bin2(), args, { cwd: config.repoPath, env: process.env, stdio: ["ignore", "pipe", "pipe"] });
21485
21603
  console.log(`[copilot-worker] Process PID: ${proc.pid}`);
21486
21604
  const toolsUsed = [];
21487
21605
  let buffer = "";
21488
21606
  let sessionError = "";
21489
21607
  let stderrOutput = "";
21608
+ let model;
21490
21609
  proc.stdout.on("data", (chunk) => {
21491
21610
  buffer += chunk.toString();
21492
21611
  const lines = buffer.split("\n");
21493
21612
  buffer = lines.pop() ?? "";
21494
21613
  for (const line of lines) {
21495
- const { tool, error } = readCopilotEvent(line);
21614
+ const { tool, error, model: initModel } = readCopilotEvent(line);
21615
+ if (initModel) model = initModel;
21496
21616
  if (tool) {
21497
21617
  toolsUsed.push(tool);
21498
21618
  process.stdout.write(` \u2699 ${tool}
@@ -21513,12 +21633,15 @@ async function runCopilotSession(config) {
21513
21633
  } catch {
21514
21634
  }
21515
21635
  console.log(`[copilot-worker] Done \u2014 tools used: ${toolsUsed.length ? [...new Set(toolsUsed)].join(", ") : "none"}`);
21516
- if (sessionError || signal || code !== 0 && code !== null) {
21517
- const detail = (sessionError || stderrOutput).trim();
21518
- reject(new Error(`copilot exited with ${signal ?? `code ${code}`}${detail ? `: ${detail}` : ""}`));
21519
- } else {
21520
- resolve8([...new Set(toolsUsed)]);
21521
- }
21636
+ const settle = () => {
21637
+ if (sessionError || signal || code !== 0 && code !== null) {
21638
+ const detail = (sessionError || stderrOutput).trim();
21639
+ reject(new Error(`copilot exited with ${signal ?? `code ${code}`}${detail ? `: ${detail}` : ""}`));
21640
+ } else {
21641
+ resolve9([...new Set(toolsUsed)]);
21642
+ }
21643
+ };
21644
+ recordSessionUsage(config, taskIdsPromise, notReportedUsage("adapter"), "[copilot-worker]", { since: startedAt, ...model ? { model } : {} }).then(settle, settle);
21522
21645
  });
21523
21646
  proc.on("error", (err) => {
21524
21647
  try {
@@ -21551,7 +21674,7 @@ var bin3 = () => process.env.CODEX_BIN ?? "codex";
21551
21674
  var codexHome = () => process.env.CODEX_HOME ?? join14(homedir5(), ".codex");
21552
21675
  var QUOTA2 = [/usage limit/i, /rate limit reached for/i, /upgrade to (plus|pro)/i];
21553
21676
  var CREDENTIALS = [/not logged in/i, /codex login/i, /401 unauthorized/i];
21554
- function codexArgs(repoPath, prompt, profile) {
21677
+ function codexArgs(repoPath, prompt, profile, model) {
21555
21678
  return [
21556
21679
  "exec",
21557
21680
  "--json",
@@ -21562,6 +21685,7 @@ function codexArgs(repoPath, prompt, profile) {
21562
21685
  repoPath,
21563
21686
  "-p",
21564
21687
  profile,
21688
+ ...model ? ["-m", model] : [],
21565
21689
  prompt
21566
21690
  ];
21567
21691
  }
@@ -21610,9 +21734,11 @@ async function runCodexSession(config) {
21610
21734
  } catch {
21611
21735
  }
21612
21736
  };
21613
- const args = codexArgs(config.repoPath, buildPrompt(config), profile);
21737
+ const args = codexArgs(config.repoPath, buildPrompt(config), profile, pinnedModel());
21738
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
21739
+ const taskIdsPromise = listSessionTaskIds(config, "[codex-worker]");
21614
21740
  console.log(`[codex-worker] Spawning: ${bin3()} ${args.slice(0, 6).join(" ")} ...`);
21615
- return new Promise((resolve8, reject) => {
21741
+ return new Promise((resolve9, reject) => {
21616
21742
  const proc = spawn5(bin3(), args, { cwd: config.repoPath, env: process.env, stdio: ["ignore", "pipe", "pipe"] });
21617
21743
  console.log(`[codex-worker] Process PID: ${proc.pid}`);
21618
21744
  const toolsUsed = [];
@@ -21642,12 +21768,15 @@ async function runCodexSession(config) {
21642
21768
  proc.on("close", (code, signal) => {
21643
21769
  cleanup();
21644
21770
  console.log(`[codex-worker] Done \u2014 tools used: ${toolsUsed.length ? [...new Set(toolsUsed)].join(", ") : "none"}`);
21645
- if (sessionError || signal || code !== 0 && code !== null) {
21646
- const detail = (sessionError || stderrOutput).trim();
21647
- reject(new Error(`codex exited with ${signal ?? `code ${code}`}${detail ? `: ${detail}` : ""}`));
21648
- } else {
21649
- resolve8([...new Set(toolsUsed)]);
21650
- }
21771
+ const settle = () => {
21772
+ if (sessionError || signal || code !== 0 && code !== null) {
21773
+ const detail = (sessionError || stderrOutput).trim();
21774
+ reject(new Error(`codex exited with ${signal ?? `code ${code}`}${detail ? `: ${detail}` : ""}`));
21775
+ } else {
21776
+ resolve9([...new Set(toolsUsed)]);
21777
+ }
21778
+ };
21779
+ recordSessionUsage(config, taskIdsPromise, notReportedUsage("adapter"), "[codex-worker]", { since: startedAt }).then(settle, settle);
21651
21780
  });
21652
21781
  proc.on("error", (err) => {
21653
21782
  cleanup();
@@ -21683,7 +21812,65 @@ var DEFAULT_UNAVAILABLE_UNTIL_MS = 60 * 60 * 1e3;
21683
21812
  // ../claude-worker/src/config.ts
21684
21813
  var VALID_SPECIALIZATIONS = ["reviewer", "architect", "writer", "tester", "devops"];
21685
21814
 
21815
+ // src/node/onboard.ts
21816
+ var MAX_POOL = 30;
21817
+ var GITHUB = /^(?:https:\/\/github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:)([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/;
21818
+ function planOnboard(url, opts) {
21819
+ const match = GITHUB.exec(url.trim());
21820
+ if (!match) throw new Error(`${url} is not a GitHub repo URL (https://github.com/<owner>/<repo> or git@github.com:<owner>/<repo>)`);
21821
+ const [, owner, name] = match;
21822
+ const writer = opts.writer ?? "claude";
21823
+ const reviewer = opts.reviewer ?? (writer === "claude" ? "copilot" : "claude");
21824
+ if (reviewer === writer) throw new Error(`the reviewer needs a different runtime from the writers (both are ${writer})`);
21825
+ for (const r of [writer, reviewer]) {
21826
+ if (!RUNTIMES3.includes(r)) throw new Error(`runtime "${r}" is not supported (have: ${RUNTIMES3.join(", ")})`);
21827
+ }
21828
+ const poolMax = opts.poolMax ?? 2;
21829
+ if (!Number.isInteger(poolMax) || poolMax < 1 || poolMax > MAX_POOL) {
21830
+ throw new Error(`pool size must be a whole number from 1 to ${MAX_POOL}, so the architect, reviewer and writers fit one approval`);
21831
+ }
21832
+ const id = name.replace(/^[^A-Za-z0-9]+/, "").slice(0, 40).replace(/[^A-Za-z0-9]+$/, "") || "repo";
21833
+ const parent = resolve4(opts.parent ?? join15(opts.home, "github"));
21834
+ const base2 = join15(parent, name);
21835
+ const nodeDir2 = join15(parent, `${name}-node`);
21836
+ const architect = join15(parent, `${name}-architect`);
21837
+ const review = join15(parent, `${name}-reviewer`);
21838
+ return {
21839
+ owner,
21840
+ name,
21841
+ cloneUrl: `https://github.com/${owner}/${name}.git`,
21842
+ base: base2,
21843
+ nodeDir: nodeDir2,
21844
+ specPath: join15(nodeDir2, "spec.json"),
21845
+ configDir: join15(opts.home, ".config", "pitboss-repos", owner.toLowerCase(), name.toLowerCase()),
21846
+ worktrees: [architect, review],
21847
+ spec: {
21848
+ api: "https://api.pitboss.dev",
21849
+ agents: [
21850
+ { name: `${id}-architect`, runtime: "claude", worktree: `../${name}-architect`, specialization: "architect", tier: 2 },
21851
+ { name: `${id}-reviewer`, runtime: reviewer, worktree: `../${name}-reviewer`, specialization: "reviewer" },
21852
+ {
21853
+ pool: `${id}-pool`,
21854
+ runtime: writer,
21855
+ base: `../${name}`,
21856
+ worktreeRoot: `../${name}-pool`,
21857
+ specialization: "writer",
21858
+ min: 0,
21859
+ max: poolMax
21860
+ }
21861
+ ]
21862
+ }
21863
+ };
21864
+ }
21865
+ function originHost(origin) {
21866
+ const url = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)/i.exec(origin.trim());
21867
+ if (url) return url[1].toLowerCase();
21868
+ const scp = /^(?:[^@/]+@)?([^/:]+):/.exec(origin.trim());
21869
+ return scp ? scp[1].toLowerCase() : null;
21870
+ }
21871
+
21686
21872
  // src/node/spec.ts
21873
+ import { join as join16, resolve as resolve5, sep as sep2 } from "node:path";
21687
21874
  var DEFAULT_API2 = "https://api.pitboss.dev";
21688
21875
  var MAX_POOL_SIZE = 32;
21689
21876
  var NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
@@ -21695,7 +21882,15 @@ var isWithin = (path3, dir) => path3 === dir || path3.startsWith(dir.endsWith(se
21695
21882
  function poolMembers(pool) {
21696
21883
  return Array.from({ length: pool.max }, (_, i) => {
21697
21884
  const name = `${pool.pool}-${i + 1}`;
21698
- return { name, runtime: pool.runtime, worktree: join15(pool.worktreeRoot, name), specialization: pool.specialization };
21885
+ return {
21886
+ name,
21887
+ runtime: pool.runtime,
21888
+ worktree: join16(pool.worktreeRoot, name),
21889
+ specialization: pool.specialization,
21890
+ ...pool.tier ? { tier: pool.tier } : {},
21891
+ ...pool.model ? { model: pool.model } : {},
21892
+ ...pool.isolateUserConfig ? { isolateUserConfig: true } : {}
21893
+ };
21699
21894
  });
21700
21895
  }
21701
21896
  var allAgents = (spec) => [...spec.agents, ...spec.pools.flatMap(poolMembers)];
@@ -21712,6 +21907,24 @@ function specializationOf(a, i) {
21712
21907
  }
21713
21908
  return specialization;
21714
21909
  }
21910
+ function tierOf(a, i, specialization) {
21911
+ if (a.tier === void 0) return specialization === "architect" ? { tier: 2 } : {};
21912
+ if (a.tier !== 1 && a.tier !== 2) throw new Error(`agents[${i}].tier must be 1 or 2`);
21913
+ return { tier: a.tier };
21914
+ }
21915
+ function modelOf(a, i) {
21916
+ if (a.model === void 0) return {};
21917
+ if (typeof a.model !== "string" || !/^[\w.:/@-]{1,100}$/.test(a.model)) {
21918
+ throw new Error(`agents[${i}].model must be a model name (letters, digits and . : / @ _ -), at most 100 characters`);
21919
+ }
21920
+ return { model: a.model };
21921
+ }
21922
+ function isolateOf(a, i, runtime) {
21923
+ if (a.isolateUserConfig === void 0 || a.isolateUserConfig === false) return {};
21924
+ if (a.isolateUserConfig !== true) throw new Error(`agents[${i}].isolateUserConfig must be true or false`);
21925
+ if (runtime !== "claude") throw new Error(`agents[${i}]: isolateUserConfig only applies to the claude runtime`);
21926
+ return { isolateUserConfig: true };
21927
+ }
21715
21928
  function parsePool(a, i, specDir) {
21716
21929
  if (a.name !== void 0 || a.worktree !== void 0) throw new Error(`agents[${i}] must have either name or pool, not both`);
21717
21930
  if (typeof a.pool !== "string" || !POOL_NAME.test(a.pool)) throw new Error(`agents[${i}].pool must be letters, digits, '.', '_' or '-' (at most 60)`);
@@ -21727,10 +21940,10 @@ function parsePool(a, i, specDir) {
21727
21940
  if (typeof min !== "number" || !Number.isInteger(min) || min < 0 || min > max) {
21728
21941
  throw new Error(`agents[${i}].min must be an integer from 0 to max`);
21729
21942
  }
21730
- const base2 = resolve4(specDir, a.base);
21731
- const worktreeRoot = resolve4(specDir, a.worktreeRoot);
21943
+ const base2 = resolve5(specDir, a.base);
21944
+ const worktreeRoot = resolve5(specDir, a.worktreeRoot);
21732
21945
  if (isWithin(worktreeRoot, base2)) throw new Error(`agents[${i}].worktreeRoot ${worktreeRoot} is inside base ${base2}; pool worktrees never go inside your checkout`);
21733
- return { pool: a.pool, runtime, base: base2, worktreeRoot, specialization, min, max };
21946
+ return { pool: a.pool, runtime, base: base2, worktreeRoot, specialization, ...tierOf(a, i, specialization), ...modelOf(a, i), ...isolateOf(a, i, runtime), min, max };
21734
21947
  }
21735
21948
  function parseSpec(text, specDir) {
21736
21949
  let raw;
@@ -21756,7 +21969,8 @@ function parseSpec(text, specDir) {
21756
21969
  if (typeof a.name !== "string" || !NAME.test(a.name)) throw new Error(`agents[${i}].name must be letters, digits, '.', '_' or '-'`);
21757
21970
  if (typeof a.worktree !== "string" || !a.worktree) throw new Error(`agents[${i}].worktree is required`);
21758
21971
  const runtime = runtimeOf(a, i);
21759
- agents.push({ name: a.name, runtime, worktree: resolve4(specDir, a.worktree), specialization: specializationOf(a, i) });
21972
+ const specialization = specializationOf(a, i);
21973
+ agents.push({ name: a.name, runtime, worktree: resolve5(specDir, a.worktree), specialization, ...tierOf(a, i, specialization), ...modelOf(a, i), ...isolateOf(a, i, runtime) });
21760
21974
  });
21761
21975
  for (const p of pools) {
21762
21976
  for (const a of agents) {
@@ -21783,13 +21997,15 @@ function parseSpec(text, specDir) {
21783
21997
  }
21784
21998
 
21785
21999
  // src/node/store.ts
21786
- import { existsSync as existsSync8, mkdirSync as mkdirSync7, readdirSync as readdirSync2, readFileSync as readFileSync11, renameSync as renameSync4, rmSync as rmSync4, writeFileSync as writeFileSync8 } from "node:fs";
21787
- import { dirname as dirname8, join as join16 } from "node:path";
22000
+ import { existsSync as existsSync8, mkdirSync as mkdirSync7, readdirSync as readdirSync2, readFileSync as readFileSync12, renameSync as renameSync4, rmSync as rmSync4, writeFileSync as writeFileSync8 } from "node:fs";
22001
+ import { dirname as dirname8, join as join17 } from "node:path";
21788
22002
  import { randomBytes as randomBytes4 } from "node:crypto";
21789
- var nodeDir = () => join16(configDir(), "node");
21790
- var credsPath = (name) => join16(nodeDir(), "agents", `${name}.json`);
21791
- var statePath2 = () => join16(nodeDir(), "state.json");
21792
- var logPath = (name) => join16(nodeDir(), "logs", `${name}.log`);
22003
+ var nodeDir = () => join17(configDir(), "node");
22004
+ var credsPath = (name) => join17(nodeDir(), "agents", `${name}.json`);
22005
+ var statePath2 = () => join17(nodeDir(), "state.json");
22006
+ var logPath = (name) => join17(nodeDir(), "logs", `${name}.log`);
22007
+ var activityPath = (name) => join17(nodeDir(), "activity", name);
22008
+ var modelPath = (name) => join17(nodeDir(), "model", name);
21793
22009
  function writePrivate(path3, value) {
21794
22010
  mkdirSync7(dirname8(path3), { recursive: true, mode: 448 });
21795
22011
  const tmp = `${path3}.relai-${randomBytes4(8).toString("hex")}`;
@@ -21798,7 +22014,7 @@ function writePrivate(path3, value) {
21798
22014
  }
21799
22015
  function readJson(path3) {
21800
22016
  if (!existsSync8(path3)) return null;
21801
- return JSON.parse(readFileSync11(path3, "utf-8"));
22017
+ return JSON.parse(readFileSync12(path3, "utf-8"));
21802
22018
  }
21803
22019
  var readCreds = (name) => readJson(credsPath(name));
21804
22020
  var writeCreds = (name, creds) => writePrivate(credsPath(name), creds);
@@ -21807,14 +22023,187 @@ var removeCreds = (name) => {
21807
22023
  if (existsSync8(path3)) rmSync4(path3);
21808
22024
  };
21809
22025
  var listStoredAgentNames = () => {
21810
- const dir = join16(nodeDir(), "agents");
22026
+ const dir = join17(nodeDir(), "agents");
21811
22027
  if (!existsSync8(dir)) return [];
21812
22028
  return readdirSync2(dir).filter((n) => n.endsWith(".json")).map((n) => n.slice(0, -".json".length)).sort();
21813
22029
  };
21814
22030
  var readState = () => readJson(statePath2());
21815
22031
  var writeState = (state) => writePrivate(statePath2(), state);
21816
22032
 
22033
+ // src/node/pool.ts
22034
+ import { execFileSync as execFileSync6 } from "node:child_process";
22035
+ import { existsSync as existsSync9, lstatSync as lstatSync3, mkdirSync as mkdirSync8, realpathSync as realpathSync4 } from "node:fs";
22036
+ import { basename as basename2, dirname as dirname9, join as join18, resolve as resolve6 } from "node:path";
22037
+ var IDLE_STOP_MS = 10 * 6e4;
22038
+ var RECONCILE_MS = 15e3;
22039
+ var UNAVAILABLE_FOR_MS = 7 * 24 * 60 * 6e4 - 6e4;
22040
+ var REMARK_AFTER_MS = 24 * 60 * 6e4;
22041
+ function positiveMs(raw, fallback2) {
22042
+ const n = Number(raw);
22043
+ return raw && Number.isInteger(n) && n > 0 ? n : fallback2;
22044
+ }
22045
+ function poolTimings(env2 = process.env) {
22046
+ return {
22047
+ reconcileMs: positiveMs(env2.PITBOSS_POOL_RECONCILE_MS, RECONCILE_MS),
22048
+ idleStopMs: positiveMs(env2.PITBOSS_POOL_IDLE_STOP_MS, IDLE_STOP_MS)
22049
+ };
22050
+ }
22051
+ function effectiveBounds(spec, desired) {
22052
+ if (!desired) return { min: spec.min, max: spec.max, desiredMax: null, capped: false };
22053
+ const max = Math.min(desired.max, spec.max);
22054
+ return { min: Math.min(desired.min, max), max, desiredMax: desired.max, capped: desired.max > spec.max };
22055
+ }
22056
+ function reconcilePool({ pending, members, min, max, idleStopMs }) {
22057
+ const isIdle = (m) => m.assigned === 0 && m.inProgress === 0;
22058
+ const running = members.filter((m) => m.running);
22059
+ const stopped = members.filter((m) => !m.running);
22060
+ let count = running.length;
22061
+ const start = [];
22062
+ const take = (m) => {
22063
+ start.push(m.name);
22064
+ count++;
22065
+ };
22066
+ for (const m of stopped) {
22067
+ if (count >= max) break;
22068
+ if (m.inProgress > 0 || m.readyAssigned > 0) take(m);
22069
+ }
22070
+ const spare = () => stopped.filter((m) => !start.includes(m.name)).sort((a, b) => Number(isIdle(b)) - Number(isIdle(a)));
22071
+ let demand = pending - running.filter(isIdle).length;
22072
+ for (const m of spare()) {
22073
+ if (demand <= 0 || count >= max) break;
22074
+ take(m);
22075
+ demand--;
22076
+ }
22077
+ for (const m of spare()) {
22078
+ if (count >= min || count >= max) break;
22079
+ take(m);
22080
+ }
22081
+ if (start.length > 0) return { start, stop: [] };
22082
+ const stop = [];
22083
+ let surplus = running.filter(isIdle).length - pending;
22084
+ const eligible = running.filter((m) => isIdle(m) && m.idleMs >= idleStopMs).sort((a, b) => b.idleMs - a.idleMs);
22085
+ for (const m of eligible) {
22086
+ if (surplus <= 0 || count <= min) break;
22087
+ stop.push(m.name);
22088
+ count--;
22089
+ surplus--;
22090
+ }
22091
+ return { start, stop };
22092
+ }
22093
+ function summarizeTasks(ready, held, members) {
22094
+ const nameOf = new Map(members.map((m) => [m.agentId, m.name]));
22095
+ const counts = Object.fromEntries(
22096
+ members.map((m) => [m.name, { assigned: 0, inProgress: 0, readyAssigned: 0 }])
22097
+ );
22098
+ const pending = ready.filter((t) => t.status === "pending" && t.autoAssign).length;
22099
+ let readyAssigned = 0;
22100
+ for (const t of ready) {
22101
+ const name = t.status === "assigned" && t.assignedTo ? nameOf.get(t.assignedTo) : void 0;
22102
+ if (!name) continue;
22103
+ counts[name].readyAssigned++;
22104
+ readyAssigned++;
22105
+ }
22106
+ for (const t of held) {
22107
+ const name = t.assignedTo ? nameOf.get(t.assignedTo) : void 0;
22108
+ if (!name) continue;
22109
+ if (t.status === "assigned") counts[name].assigned++;
22110
+ if (t.status === "in_progress") counts[name].inProgress++;
22111
+ }
22112
+ return { pending, ready: pending + readyAssigned, members: counts };
22113
+ }
22114
+ function poolRootProblem(root, protectedPaths) {
22115
+ for (const p of protectedPaths) {
22116
+ if (isWithin(root, p.path)) return `worktreeRoot ${root} is inside ${p.label} ${p.path}; pool worktrees never go inside a checkout`;
22117
+ }
22118
+ return null;
22119
+ }
22120
+ function memberPathProblem(s) {
22121
+ if (s.symlink) return `${s.path} is a symlink; refusing to use it as a pool worktree`;
22122
+ if (s.exists && s.commonDir !== s.baseCommonDir) return `${s.path} exists and is not a worktree of the pool's base clone`;
22123
+ if (!s.exists && s.tracked) return `${s.path} is tracked by git; refusing to create a pool worktree there`;
22124
+ return null;
22125
+ }
22126
+ function realpathAllowMissing(path3) {
22127
+ const abs = resolve6(path3);
22128
+ if (existsSync9(abs)) return realpathSync4.native(abs);
22129
+ return join18(realpathAllowMissing(dirname9(abs)), basename2(abs));
22130
+ }
22131
+ function run2(cwd, args) {
22132
+ try {
22133
+ return execFileSync6("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
22134
+ } catch (err) {
22135
+ const stderr = err.stderr?.toString().trim();
22136
+ throw new Error(`git ${args.join(" ")} in ${cwd} failed${stderr ? `: ${stderr}` : ""}`);
22137
+ }
22138
+ }
22139
+ var commonDir = (cwd) => {
22140
+ const dir = git(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd);
22141
+ return dir ? realpathSync4.native(dir) : null;
22142
+ };
22143
+ function defaultBranch(cwd) {
22144
+ const head = () => git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], cwd);
22145
+ let ref = head();
22146
+ if (!ref) {
22147
+ git(["remote", "set-head", "origin", "--auto"], cwd);
22148
+ ref = head();
22149
+ }
22150
+ if (!ref || !ref.startsWith("origin/")) throw new Error(`${cwd}: cannot tell origin's default branch; run git remote set-head origin --auto`);
22151
+ return ref.slice("origin/".length);
22152
+ }
22153
+ function ensurePoolWorktrees(pool, otherWorktrees = []) {
22154
+ if (!existsSync9(pool.base)) throw new Error(`pool ${pool.pool}: base ${pool.base} does not exist`);
22155
+ const baseTop = git(["rev-parse", "--show-toplevel"], pool.base);
22156
+ const baseCommon = commonDir(pool.base);
22157
+ if (!baseTop || !baseCommon) throw new Error(`pool ${pool.pool}: base ${pool.base} is not a git clone`);
22158
+ const root = realpathAllowMissing(pool.worktreeRoot);
22159
+ const rootProblem = poolRootProblem(root, [{ label: "base", path: realpathSync4.native(baseTop) }, ...otherWorktrees]);
22160
+ if (rootProblem) throw new Error(`pool ${pool.pool}: ${rootProblem}`);
22161
+ const members = poolMembers({ ...pool, worktreeRoot: root });
22162
+ for (const m of members) {
22163
+ let symlink = false;
22164
+ try {
22165
+ symlink = lstatSync3(m.worktree).isSymbolicLink();
22166
+ } catch {
22167
+ symlink = false;
22168
+ }
22169
+ const exists = !symlink && existsSync9(m.worktree);
22170
+ const problem = memberPathProblem({
22171
+ path: m.worktree,
22172
+ symlink,
22173
+ exists,
22174
+ baseCommonDir: baseCommon,
22175
+ commonDir: exists ? commonDir(m.worktree) : null,
22176
+ tracked: !exists && !symlink && isTracked(m.worktree)
22177
+ });
22178
+ if (problem) throw new Error(`pool ${pool.pool}: ${problem}`);
22179
+ }
22180
+ const missing = members.filter((m) => !existsSync9(m.worktree));
22181
+ if (missing.length > 0) {
22182
+ mkdirSync8(root, { recursive: true });
22183
+ run2(pool.base, ["fetch", "-q", "origin"]);
22184
+ const branch = defaultBranch(pool.base);
22185
+ run2(pool.base, ["worktree", "prune"]);
22186
+ for (const m of missing) run2(pool.base, ["worktree", "add", "-q", "--detach", m.worktree, `origin/${branch}`]);
22187
+ }
22188
+ return members.map((m) => realpathSync4.native(m.worktree));
22189
+ }
22190
+ function resetWorktree(worktree) {
22191
+ run2(worktree, ["fetch", "-q", "--prune", "origin"]);
22192
+ const target = `origin/${defaultBranch(worktree)}`;
22193
+ run2(worktree, ["checkout", "-q", "--detach", "--force", target]);
22194
+ run2(worktree, ["reset", "-q", "--hard", target]);
22195
+ run2(worktree, ["clean", "-fdq"]);
22196
+ }
22197
+
21817
22198
  // src/node/identity.ts
22199
+ function requestedAgents(agents) {
22200
+ return agents.map((a) => ({
22201
+ name: a.name,
22202
+ workerType: getAdapter(a.runtime).workerType,
22203
+ specialization: a.specialization,
22204
+ ...a.tier ? { tier: a.tier } : {}
22205
+ }));
22206
+ }
21818
22207
  function collectInvites(agentNames, flags, env2) {
21819
22208
  const byLower = new Map(agentNames.map((n) => [n.toLowerCase(), n]));
21820
22209
  const out = /* @__PURE__ */ new Map();
@@ -21851,6 +22240,7 @@ function matchInvites(agents, invites) {
21851
22240
  }
21852
22241
  return { matched, ungranted, extra: left, refused };
21853
22242
  }
22243
+ var MAX_REQUESTED = 32;
21854
22244
  function groupByRepo(agents, originOf2) {
21855
22245
  const groups = /* @__PURE__ */ new Map();
21856
22246
  for (const agent of agents) {
@@ -21858,7 +22248,10 @@ function groupByRepo(agents, originOf2) {
21858
22248
  if (!remote) throw new Error(`${agent.name}: ${agent.worktree} has no git origin; each worktree must be a clone of the agent's repo`);
21859
22249
  groups.set(remote, [...groups.get(remote) ?? [], agent]);
21860
22250
  }
21861
- return [...groups].map(([remote, members]) => ({ remote, agents: members }));
22251
+ return [...groups].flatMap(([remote, members]) => Array.from({ length: Math.ceil(members.length / MAX_REQUESTED) }, (_, i) => ({
22252
+ remote,
22253
+ agents: members.slice(i * MAX_REQUESTED, (i + 1) * MAX_REQUESTED)
22254
+ })));
21862
22255
  }
21863
22256
  var originOf = (worktree) => git(["remote", "get-url", "origin"], worktree);
21864
22257
  async function abandonMinted(spec, agent, minted, reason) {
@@ -21964,10 +22357,12 @@ async function ensureIdentities(spec, inviteCodes, specPath) {
21964
22357
  console.log(source_default.bold(`
21965
22358
  Approve ${repoName}`));
21966
22359
  console.log(` On the approval screen add these agents as workers:`);
21967
- for (const a of group.agents) console.log(` ${source_default.cyan(a.name)} ${source_default.dim(`${a.runtime}, ${a.specialization}`)}`);
22360
+ for (const a of group.agents) {
22361
+ console.log(` ${source_default.cyan(a.name)} ${source_default.dim(`${a.runtime}, ${a.specialization}${a.tier ? `, tier ${a.tier}` : ""}`)}`);
22362
+ }
21968
22363
  let grant;
21969
22364
  try {
21970
- grant = await requestDeviceGrant(spec.api, { repoName, remote: group.remote }, "pitboss node up");
22365
+ grant = await requestDeviceGrant(spec.api, { repoName, remote: group.remote, agents: requestedAgents(group.agents) }, "pitboss node up");
21971
22366
  } catch (err) {
21972
22367
  console.log(source_default.red(` \u2715 ${repoName}: ${err instanceof Error ? err.message : String(err)} ${group.agents.map((a) => a.name).join(", ")} will not start.`));
21973
22368
  continue;
@@ -22007,6 +22402,106 @@ async function ensureIdentities(spec, inviteCodes, specPath) {
22007
22402
  return have;
22008
22403
  }
22009
22404
 
22405
+ // src/commands/onboard.ts
22406
+ function isGitHubHost(host) {
22407
+ if (!host) return false;
22408
+ if (host === "github.com") return true;
22409
+ try {
22410
+ const resolved = execFileSync7("ssh", ["-G", host], { encoding: "utf-8" }).match(/^hostname (\S+)$/m)?.[1];
22411
+ return resolved?.toLowerCase() === "github.com";
22412
+ } catch {
22413
+ return false;
22414
+ }
22415
+ }
22416
+ var shellQuote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
22417
+ async function onboardCommand(url, opts) {
22418
+ try {
22419
+ const plan = planOnboard(url, {
22420
+ home: homedir6(),
22421
+ parent: opts.dir,
22422
+ writer: opts.writer,
22423
+ reviewer: opts.reviewer,
22424
+ poolMax: opts.poolMax === void 0 ? void 0 : Number(opts.poolMax)
22425
+ });
22426
+ const env2 = { ...process.env };
22427
+ if (opts.account) {
22428
+ env2.GH_TOKEN = execFileSync7("gh", ["auth", "token", "--hostname", "github.com", "--user", opts.account], { encoding: "utf-8" }).trim();
22429
+ }
22430
+ if (existsSync10(plan.base)) {
22431
+ const origin = execFileSync7("git", ["-C", plan.base, "remote", "get-url", "origin"], { encoding: "utf-8" }).trim();
22432
+ if (normalizeRepoUrl(origin) !== normalizeRepoUrl(plan.cloneUrl) || !isGitHubHost(originHost(origin))) {
22433
+ throw new Error(`${plan.base} exists but its origin is ${origin}, not ${plan.cloneUrl} on github.com`);
22434
+ }
22435
+ console.log(source_default.dim(` using the clone at ${plan.base}`));
22436
+ } else {
22437
+ execFileSync7("gh", ["repo", "clone", `${plan.owner}/${plan.name}`, plan.base], { stdio: "inherit", env: env2 });
22438
+ }
22439
+ const commonDir2 = (dir) => realpathSync5.native(
22440
+ execFileSync7("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf-8" }).trim()
22441
+ );
22442
+ for (const path3 of plan.worktrees) {
22443
+ if (!existsSync10(path3)) {
22444
+ execFileSync7("git", ["-C", plan.base, "worktree", "add", "--detach", path3], { stdio: "inherit" });
22445
+ } else if (commonDir2(path3) !== commonDir2(plan.base)) {
22446
+ throw new Error(`${path3} exists but is not a worktree of ${plan.base}; move it aside or pass --dir`);
22447
+ }
22448
+ }
22449
+ const specText = JSON.stringify(plan.spec, null, 2) + "\n";
22450
+ const parsed = parseSpec(specText, plan.nodeDir);
22451
+ if (existsSync10(plan.specPath) && readFileSync13(plan.specPath, "utf-8") !== specText && !opts.force) {
22452
+ throw new Error(`${plan.specPath} already exists with different contents; pass --force to replace it`);
22453
+ }
22454
+ mkdirSync9(plan.nodeDir, { recursive: true });
22455
+ writeFileSync9(plan.specPath, specText);
22456
+ process.env.PITBOSS_CONFIG_DIR = plan.configDir;
22457
+ mkdirSync9(plan.configDir, { recursive: true, mode: 448 });
22458
+ const specReal = realpathSync5.native(plan.specPath);
22459
+ const foreign = allAgents(parsed).map((a) => readCreds(a.name)).find((c) => c?.spec && c.spec !== specReal);
22460
+ if (foreign) throw new Error(`${plan.configDir} holds credentials for another node spec (${foreign.spec}); onboard will not overwrite them`);
22461
+ const plain = parsed.agents.map((a) => ({ ...a, worktree: realpathSync5.native(a.worktree) }));
22462
+ const ownToken = process.env.GH_TOKEN;
22463
+ if (env2.GH_TOKEN) process.env.GH_TOKEN = env2.GH_TOKEN;
22464
+ let pooled;
22465
+ try {
22466
+ pooled = parsed.pools.flatMap((pool) => {
22467
+ const paths = ensurePoolWorktrees(pool, plain.map((a) => ({ label: `agent ${a.name}'s worktree`, path: a.worktree })));
22468
+ return poolMembers(pool).map((m, i) => ({ ...m, worktree: paths[i] }));
22469
+ });
22470
+ } finally {
22471
+ if (ownToken === void 0) delete process.env.GH_TOKEN;
22472
+ else process.env.GH_TOKEN = ownToken;
22473
+ }
22474
+ const everyone = [...plain, ...pooled];
22475
+ const seen = /* @__PURE__ */ new Map();
22476
+ for (const a of everyone) {
22477
+ const other = seen.get(a.worktree);
22478
+ if (other) throw new Error(`agents "${other}" and "${a.name}" resolve to the same worktree ${a.worktree}; each needs its own`);
22479
+ seen.set(a.worktree, a.name);
22480
+ }
22481
+ const creds = await ensureIdentities({ ...parsed, agents: everyone }, /* @__PURE__ */ new Map(), specReal);
22482
+ const missing = everyone.filter((a) => !creds.has(a.name)).map((a) => a.name);
22483
+ if (missing.length === everyone.length) throw new Error("no agent was granted; approve the device code again by re-running onboard");
22484
+ if (missing.length > 0) process.exitCode = 1;
22485
+ console.log(source_default.bold(`
22486
+ ${plan.name} is onboarded${missing.length ? source_default.yellow(` except ${missing.join(", ")}`) : ""}.`));
22487
+ console.log(` spec ${plan.specPath}`);
22488
+ console.log(` config ${plan.configDir}`);
22489
+ console.log(`
22490
+ Start it when you want it running (long-running, foreground):`);
22491
+ console.log(source_default.cyan(` cd ${shellQuote(plan.nodeDir)} && PITBOSS_CONFIG_DIR=${shellQuote(plan.configDir)} pitboss node up spec.json`));
22492
+ if (opts.account) console.log(source_default.dim(` with GH_TOKEN from \`gh auth token --user ${opts.account}\` in that shell`));
22493
+ } catch (err) {
22494
+ console.error(source_default.red(` \u2715 ${err instanceof Error ? err.message : String(err)}`));
22495
+ process.exitCode = 1;
22496
+ }
22497
+ }
22498
+
22499
+ // src/commands/node.ts
22500
+ import { execFileSync as execFileSync8, spawn as spawnChild } from "node:child_process";
22501
+ import { closeSync, existsSync as existsSync12, mkdirSync as mkdirSync11, openSync, readFileSync as readFileSync15, realpathSync as realpathSync6, rmSync as rmSync6 } from "node:fs";
22502
+ import { dirname as dirname12, resolve as resolve8 } from "node:path";
22503
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
22504
+
22010
22505
  // src/node/supervisor.ts
22011
22506
  function nextDelay(consecutiveFailures, { baseMs, maxMs }) {
22012
22507
  return Math.min(baseMs * 2 ** Math.max(0, consecutiveFailures - 1), maxMs);
@@ -22131,44 +22626,67 @@ var Supervisor = class {
22131
22626
  }
22132
22627
  };
22133
22628
 
22629
+ // src/node/model.ts
22630
+ import { mkdirSync as mkdirSync10, readFileSync as readFileSync14, renameSync as renameSync5, rmSync as rmSync5, writeFileSync as writeFileSync10 } from "node:fs";
22631
+ import { dirname as dirname10 } from "node:path";
22632
+ function writeModelFiles(agents) {
22633
+ for (const a of agents) {
22634
+ const path3 = modelPath(a.name);
22635
+ if (!a.model) {
22636
+ rmSync5(path3, { force: true });
22637
+ continue;
22638
+ }
22639
+ let current = null;
22640
+ try {
22641
+ current = readFileSync14(path3, "utf-8");
22642
+ } catch {
22643
+ }
22644
+ if (current === a.model) continue;
22645
+ mkdirSync10(dirname10(path3), { recursive: true });
22646
+ const tmp = `${path3}.${process.pid}.tmp`;
22647
+ writeFileSync10(tmp, a.model);
22648
+ renameSync5(tmp, path3);
22649
+ }
22650
+ }
22651
+
22134
22652
  // src/node/entries.ts
22135
- import { existsSync as existsSync9 } from "node:fs";
22136
- import { dirname as dirname9, join as join17, resolve as resolve5 } from "node:path";
22653
+ import { existsSync as existsSync11 } from "node:fs";
22654
+ import { dirname as dirname11, join as join19, resolve as resolve7 } from "node:path";
22137
22655
  var MISSING_WORKER = "event-worker not found next to this CLI. Run node from a pitboss checkout, or set PITBOSS_EVENT_WORKER to packages/event-worker.";
22138
22656
  function checkoutWorker(dir) {
22139
22657
  return {
22140
- command: join17(dir, "node_modules", ".bin", "tsx"),
22141
- args: [join17(dir, "src", "index.ts")]
22658
+ command: join19(dir, "node_modules", ".bin", "tsx"),
22659
+ args: [join19(dir, "src", "index.ts")]
22142
22660
  };
22143
22661
  }
22144
22662
  function checkoutMcp(dir, execPath) {
22145
22663
  return {
22146
22664
  command: execPath,
22147
22665
  args: [
22148
- join17(dir, "node_modules", "tsx", "dist", "cli.mjs"),
22149
- join17(dir, "src", "index.ts")
22666
+ join19(dir, "node_modules", "tsx", "dist", "cli.mjs"),
22667
+ join19(dir, "src", "index.ts")
22150
22668
  ]
22151
22669
  };
22152
22670
  }
22153
22671
  function walkFor(here, packageName, exists) {
22154
- for (let dir = dirname9(here); dirname9(dir) !== dir; dir = dirname9(dir)) {
22155
- const candidate = join17(dir, packageName);
22156
- if (exists(join17(candidate, "src", "index.ts"))) return candidate;
22672
+ for (let dir = dirname11(here); dirname11(dir) !== dir; dir = dirname11(dir)) {
22673
+ const candidate = join19(dir, packageName);
22674
+ if (exists(join19(candidate, "src", "index.ts"))) return candidate;
22157
22675
  }
22158
22676
  return null;
22159
22677
  }
22160
22678
  function resolveEventWorker(opts) {
22161
22679
  const env2 = opts.env ?? process.env;
22162
22680
  const execPath = opts.execPath ?? process.execPath;
22163
- const exists = opts.exists ?? existsSync9;
22681
+ const exists = opts.exists ?? existsSync11;
22164
22682
  const override = env2.PITBOSS_EVENT_WORKER;
22165
22683
  if (override) {
22166
- const resolved = resolve5(override);
22684
+ const resolved = resolve7(override);
22167
22685
  if (resolved.endsWith(".js") && exists(resolved)) return { command: execPath, args: [resolved] };
22168
- if (exists(join17(resolved, "src", "index.ts"))) return checkoutWorker(resolved);
22686
+ if (exists(join19(resolved, "src", "index.ts"))) return checkoutWorker(resolved);
22169
22687
  throw new Error(MISSING_WORKER);
22170
22688
  }
22171
- const bundled = join17(dirname9(opts.here), "event-worker.js");
22689
+ const bundled = join19(dirname11(opts.here), "event-worker.js");
22172
22690
  if (exists(bundled)) return { command: execPath, args: [bundled] };
22173
22691
  const dir = walkFor(opts.here, "event-worker", exists);
22174
22692
  if (dir) return checkoutWorker(dir);
@@ -22176,8 +22694,8 @@ function resolveEventWorker(opts) {
22176
22694
  }
22177
22695
  function resolveMcpServer(opts) {
22178
22696
  const execPath = opts.execPath ?? process.execPath;
22179
- const exists = opts.exists ?? existsSync9;
22180
- const bundled = join17(dirname9(opts.here), "mcp.js");
22697
+ const exists = opts.exists ?? existsSync11;
22698
+ const bundled = join19(dirname11(opts.here), "mcp.js");
22181
22699
  if (exists(bundled)) return { command: execPath, args: [bundled] };
22182
22700
  const dir = walkFor(opts.here, "mcp-server", exists);
22183
22701
  if (dir) return checkoutMcp(dir, execPath);
@@ -22285,176 +22803,41 @@ function loadPruneTargets(names) {
22285
22803
  });
22286
22804
  }
22287
22805
 
22288
- // src/node/pool.ts
22289
- import { execFileSync as execFileSync6 } from "node:child_process";
22290
- import { existsSync as existsSync10, lstatSync as lstatSync3, mkdirSync as mkdirSync8, realpathSync as realpathSync4 } from "node:fs";
22291
- import { basename as basename2, dirname as dirname10, join as join18, resolve as resolve6 } from "node:path";
22292
- var IDLE_STOP_MS = 10 * 6e4;
22293
- var RECONCILE_MS = 15e3;
22294
- var UNAVAILABLE_FOR_MS = 7 * 24 * 60 * 6e4 - 6e4;
22295
- var REMARK_AFTER_MS = 24 * 60 * 6e4;
22296
- function positiveMs(raw, fallback2) {
22297
- const n = Number(raw);
22298
- return raw && Number.isInteger(n) && n > 0 ? n : fallback2;
22299
- }
22300
- function poolTimings(env2 = process.env) {
22301
- return {
22302
- reconcileMs: positiveMs(env2.PITBOSS_POOL_RECONCILE_MS, RECONCILE_MS),
22303
- idleStopMs: positiveMs(env2.PITBOSS_POOL_IDLE_STOP_MS, IDLE_STOP_MS)
22304
- };
22305
- }
22306
- function effectiveBounds(spec, desired) {
22307
- if (!desired) return { min: spec.min, max: spec.max, desiredMax: null, capped: false };
22308
- const max = Math.min(desired.max, spec.max);
22309
- return { min: Math.min(desired.min, max), max, desiredMax: desired.max, capped: desired.max > spec.max };
22310
- }
22311
- function reconcilePool({ pending, members, min, max, idleStopMs }) {
22312
- const isIdle = (m) => m.assigned === 0 && m.inProgress === 0;
22313
- const running = members.filter((m) => m.running);
22314
- const stopped = members.filter((m) => !m.running);
22315
- let count = running.length;
22316
- const start = [];
22317
- const take = (m) => {
22318
- start.push(m.name);
22319
- count++;
22320
- };
22321
- for (const m of stopped) {
22322
- if (count >= max) break;
22323
- if (m.inProgress > 0 || m.readyAssigned > 0) take(m);
22324
- }
22325
- const spare = () => stopped.filter((m) => !start.includes(m.name)).sort((a, b) => Number(isIdle(b)) - Number(isIdle(a)));
22326
- let demand = pending - running.filter(isIdle).length;
22327
- for (const m of spare()) {
22328
- if (demand <= 0 || count >= max) break;
22329
- take(m);
22330
- demand--;
22331
- }
22332
- for (const m of spare()) {
22333
- if (count >= min || count >= max) break;
22334
- take(m);
22335
- }
22336
- if (start.length > 0) return { start, stop: [] };
22337
- const stop = [];
22338
- let surplus = running.filter(isIdle).length - pending;
22339
- const eligible = running.filter((m) => isIdle(m) && m.idleMs >= idleStopMs).sort((a, b) => b.idleMs - a.idleMs);
22340
- for (const m of eligible) {
22341
- if (surplus <= 0 || count <= min) break;
22342
- stop.push(m.name);
22343
- count--;
22344
- surplus--;
22345
- }
22346
- return { start, stop };
22347
- }
22348
- function summarizeTasks(ready, held, members) {
22349
- const nameOf = new Map(members.map((m) => [m.agentId, m.name]));
22350
- const counts = Object.fromEntries(
22351
- members.map((m) => [m.name, { assigned: 0, inProgress: 0, readyAssigned: 0 }])
22352
- );
22353
- const pending = ready.filter((t) => t.status === "pending" && t.autoAssign).length;
22354
- let readyAssigned = 0;
22355
- for (const t of ready) {
22356
- const name = t.status === "assigned" && t.assignedTo ? nameOf.get(t.assignedTo) : void 0;
22357
- if (!name) continue;
22358
- counts[name].readyAssigned++;
22359
- readyAssigned++;
22360
- }
22361
- for (const t of held) {
22362
- const name = t.assignedTo ? nameOf.get(t.assignedTo) : void 0;
22363
- if (!name) continue;
22364
- if (t.status === "assigned") counts[name].assigned++;
22365
- if (t.status === "in_progress") counts[name].inProgress++;
22366
- }
22367
- return { pending, ready: pending + readyAssigned, members: counts };
22368
- }
22369
- function poolRootProblem(root, protectedPaths) {
22370
- for (const p of protectedPaths) {
22371
- if (isWithin(root, p.path)) return `worktreeRoot ${root} is inside ${p.label} ${p.path}; pool worktrees never go inside a checkout`;
22372
- }
22373
- return null;
22374
- }
22375
- function memberPathProblem(s) {
22376
- if (s.symlink) return `${s.path} is a symlink; refusing to use it as a pool worktree`;
22377
- if (s.exists && s.commonDir !== s.baseCommonDir) return `${s.path} exists and is not a worktree of the pool's base clone`;
22378
- if (!s.exists && s.tracked) return `${s.path} is tracked by git; refusing to create a pool worktree there`;
22379
- return null;
22380
- }
22381
- function realpathAllowMissing(path3) {
22382
- const abs = resolve6(path3);
22383
- if (existsSync10(abs)) return realpathSync4.native(abs);
22384
- return join18(realpathAllowMissing(dirname10(abs)), basename2(abs));
22385
- }
22386
- function run2(cwd, args) {
22387
- try {
22388
- return execFileSync6("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
22389
- } catch (err) {
22390
- const stderr = err.stderr?.toString().trim();
22391
- throw new Error(`git ${args.join(" ")} in ${cwd} failed${stderr ? `: ${stderr}` : ""}`);
22392
- }
22393
- }
22394
- var commonDir = (cwd) => {
22395
- const dir = git(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd);
22396
- return dir ? realpathSync4.native(dir) : null;
22397
- };
22398
- function defaultBranch(cwd) {
22399
- const head = () => git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], cwd);
22400
- let ref = head();
22401
- if (!ref) {
22402
- git(["remote", "set-head", "origin", "--auto"], cwd);
22403
- ref = head();
22404
- }
22405
- if (!ref || !ref.startsWith("origin/")) throw new Error(`${cwd}: cannot tell origin's default branch; run git remote set-head origin --auto`);
22406
- return ref.slice("origin/".length);
22407
- }
22408
- function ensurePoolWorktrees(pool, otherWorktrees = []) {
22409
- if (!existsSync10(pool.base)) throw new Error(`pool ${pool.pool}: base ${pool.base} does not exist`);
22410
- const baseTop = git(["rev-parse", "--show-toplevel"], pool.base);
22411
- const baseCommon = commonDir(pool.base);
22412
- if (!baseTop || !baseCommon) throw new Error(`pool ${pool.pool}: base ${pool.base} is not a git clone`);
22413
- const root = realpathAllowMissing(pool.worktreeRoot);
22414
- const rootProblem = poolRootProblem(root, [{ label: "base", path: realpathSync4.native(baseTop) }, ...otherWorktrees]);
22415
- if (rootProblem) throw new Error(`pool ${pool.pool}: ${rootProblem}`);
22416
- const members = poolMembers({ ...pool, worktreeRoot: root });
22417
- for (const m of members) {
22418
- let symlink = false;
22419
- try {
22420
- symlink = lstatSync3(m.worktree).isSymbolicLink();
22421
- } catch {
22422
- symlink = false;
22423
- }
22424
- const exists = !symlink && existsSync10(m.worktree);
22425
- const problem = memberPathProblem({
22426
- path: m.worktree,
22427
- symlink,
22428
- exists,
22429
- baseCommonDir: baseCommon,
22430
- commonDir: exists ? commonDir(m.worktree) : null,
22431
- tracked: !exists && !symlink && isTracked(m.worktree)
22432
- });
22433
- if (problem) throw new Error(`pool ${pool.pool}: ${problem}`);
22434
- }
22435
- const missing = members.filter((m) => !existsSync10(m.worktree));
22436
- if (missing.length > 0) {
22437
- mkdirSync8(root, { recursive: true });
22438
- run2(pool.base, ["fetch", "-q", "origin"]);
22439
- const branch = defaultBranch(pool.base);
22440
- run2(pool.base, ["worktree", "prune"]);
22441
- for (const m of missing) run2(pool.base, ["worktree", "add", "-q", "--detach", m.worktree, `origin/${branch}`]);
22442
- }
22443
- return members.map((m) => realpathSync4.native(m.worktree));
22444
- }
22445
- function resetWorktree(worktree) {
22446
- run2(worktree, ["fetch", "-q", "--prune", "origin"]);
22447
- const target = `origin/${defaultBranch(worktree)}`;
22448
- run2(worktree, ["checkout", "-q", "--detach", "--force", target]);
22449
- run2(worktree, ["reset", "-q", "--hard", target]);
22450
- run2(worktree, ["clean", "-fdq"]);
22806
+ // src/node/activity.ts
22807
+ var ACTIVITY_REPORT_MS = 15e3;
22808
+ var REVIEW_HEARTBEAT_MS = 6e4;
22809
+ function deriveActivity({ running, sessionActive, reviewing, waiting }) {
22810
+ if (!running) return "stopped";
22811
+ if (sessionActive) return "working";
22812
+ if (reviewing) return "reviewing";
22813
+ return waiting ? "waiting" : "idle";
22814
+ }
22815
+ function heldAgentIds(tasks) {
22816
+ const reviewing = /* @__PURE__ */ new Set();
22817
+ const waiting = /* @__PURE__ */ new Set();
22818
+ for (const t of tasks) {
22819
+ if (t.assignedTo && (t.status === "blocked" || t.status === "pending_verification")) waiting.add(t.assignedTo);
22820
+ if (t.status === "pending_verification" && t.verifyReviewerId && t.metadata?.reviewStartedBy === t.verifyReviewerId) reviewing.add(t.verifyReviewerId);
22821
+ }
22822
+ return { reviewing, waiting };
22823
+ }
22824
+ function startedReviewTaskIds(tasks) {
22825
+ const byAgent = /* @__PURE__ */ new Map();
22826
+ for (const t of tasks) {
22827
+ if (t.status !== "pending_verification" || !t.verifyReviewerId || t.metadata?.reviewStartedBy !== t.verifyReviewerId) continue;
22828
+ byAgent.set(t.verifyReviewerId, [...byAgent.get(t.verifyReviewerId) ?? [], t.id]);
22829
+ }
22830
+ return byAgent;
22831
+ }
22832
+ function reviewerIsLive({ running, sessionActive }) {
22833
+ return running && sessionActive;
22451
22834
  }
22452
22835
 
22453
22836
  // src/commands/node.ts
22454
22837
  var HERE = fileURLToPath3(import.meta.url);
22455
22838
  function inspectProcess(pid) {
22456
22839
  try {
22457
- const out = execFileSync7("ps", ["-o", "pgid=,command=", "-p", String(pid)], { encoding: "utf-8" }).trim();
22840
+ const out = execFileSync8("ps", ["-o", "pgid=,command=", "-p", String(pid)], { encoding: "utf-8" }).trim();
22458
22841
  const match = /^(\d+)\s+(.*)$/.exec(out);
22459
22842
  return match ? { pgid: Number(match[1]), command: match[2] } : null;
22460
22843
  } catch {
@@ -22462,7 +22845,7 @@ function inspectProcess(pid) {
22462
22845
  }
22463
22846
  }
22464
22847
  function childSpec(agent, creds, worker, onDemand = false) {
22465
- const { ANTHROPIC_API_KEY: _key, ANTHROPIC_AUTH_TOKEN: _tok, ...inherited } = process.env;
22848
+ const { ANTHROPIC_API_KEY: _key, ANTHROPIC_AUTH_TOKEN: _tok, PITBOSS_ISOLATE_USER_CONFIG: _iso, ...inherited } = process.env;
22466
22849
  return {
22467
22850
  name: agent.name,
22468
22851
  command: worker.command,
@@ -22478,7 +22861,10 @@ function childSpec(agent, creds, worker, onDemand = false) {
22478
22861
  REPO_ID: creds.repoId,
22479
22862
  REPO_PATH: agent.worktree,
22480
22863
  SPECIALIZATION: agent.specialization,
22481
- WORKER_RUNTIME: agent.runtime
22864
+ WORKER_RUNTIME: agent.runtime,
22865
+ PITBOSS_ACTIVITY_FILE: activityPath(agent.name),
22866
+ PITBOSS_MODEL_FILE: resolve8(modelPath(agent.name)),
22867
+ ...agent.isolateUserConfig ? { PITBOSS_ISOLATE_USER_CONFIG: "1" } : {}
22482
22868
  }
22483
22869
  };
22484
22870
  }
@@ -22494,12 +22880,13 @@ function prepareAgent(agent, creds, mcp) {
22494
22880
  excludeIfUntracked(root, target);
22495
22881
  },
22496
22882
  run(bin4, args, cwd) {
22497
- execFileSync7(bin4, args, { cwd, stdio: "ignore" });
22883
+ execFileSync8(bin4, args, { cwd, stdio: "ignore" });
22498
22884
  }
22499
22885
  });
22500
22886
  }
22501
22887
  function spawnWorker(spec) {
22502
- mkdirSync9(dirname11(spec.logPath), { recursive: true, mode: 448 });
22888
+ mkdirSync11(dirname12(spec.logPath), { recursive: true, mode: 448 });
22889
+ rmSync6(activityPath(spec.name), { force: true });
22503
22890
  const log = openSync(spec.logPath, "a", 384);
22504
22891
  try {
22505
22892
  const child = spawnChild(spec.command, spec.args, { cwd: spec.cwd, env: spec.env, stdio: ["ignore", log, log], detached: true });
@@ -22546,6 +22933,63 @@ async function reportScale(creds, event) {
22546
22933
  });
22547
22934
  if (!res.ok) throw new Error(`POST /agents/${creds.agentId}/pool-events returned HTTP ${res.status}`);
22548
22935
  }
22936
+ async function reportActivity(creds, state) {
22937
+ const res = await fetch(`${creds.api}/agents/${encodeURIComponent(creds.agentId)}/activity`, {
22938
+ method: "PUT",
22939
+ headers: { Authorization: `Bearer ${creds.token}`, "Content-Type": "application/json" },
22940
+ body: JSON.stringify({ state })
22941
+ });
22942
+ if (!res.ok) throw new Error(`PUT /agents/${creds.agentId}/activity returned HTTP ${res.status}`);
22943
+ }
22944
+ function startActivityLoop(agents, sup) {
22945
+ let busy = false;
22946
+ const sent = /* @__PURE__ */ new Map();
22947
+ const beatAt = /* @__PURE__ */ new Map();
22948
+ const heartbeatReviews = async (creds, name, live, taskIds) => {
22949
+ if (!live || taskIds.length === 0 || Date.now() - (beatAt.get(name) ?? 0) < REVIEW_HEARTBEAT_MS) return;
22950
+ beatAt.set(name, Date.now());
22951
+ for (const id of taskIds) {
22952
+ const res = await fetch(`${creds.api}/tasks/${encodeURIComponent(id)}/review/start`, {
22953
+ method: "POST",
22954
+ headers: { Authorization: `Bearer ${creds.token}` }
22955
+ });
22956
+ if (!res.ok) throw new Error(`POST /tasks/${id}/review/start returned HTTP ${res.status}`);
22957
+ }
22958
+ };
22959
+ const tick = async () => {
22960
+ if (busy) return;
22961
+ busy = true;
22962
+ try {
22963
+ const reader = agents[0].creds;
22964
+ const held = await fetchTasks(reader, "status=blocked,pending_verification");
22965
+ const { reviewing, waiting } = heldAgentIds(held);
22966
+ const reviewIds = startedReviewTaskIds(held);
22967
+ for (const { name, creds } of agents) {
22968
+ const signals2 = {
22969
+ running: sup.isActive(name),
22970
+ sessionActive: existsSync12(activityPath(name))
22971
+ };
22972
+ const live = reviewerIsLive(signals2);
22973
+ const state = deriveActivity({
22974
+ ...signals2,
22975
+ reviewing: reviewing.has(creds.agentId),
22976
+ waiting: waiting.has(creds.agentId)
22977
+ });
22978
+ await heartbeatReviews(creds, name, live, reviewIds.get(creds.agentId) ?? []);
22979
+ if (sent.get(name) === state) continue;
22980
+ await reportActivity(creds, state);
22981
+ sent.set(name, state);
22982
+ }
22983
+ } catch (err) {
22984
+ console.log(`${(/* @__PURE__ */ new Date()).toISOString()} activity: ${err instanceof Error ? err.message : String(err)}`);
22985
+ } finally {
22986
+ busy = false;
22987
+ }
22988
+ };
22989
+ void tick();
22990
+ const timer = setInterval(() => void tick(), ACTIVITY_REPORT_MS);
22991
+ return () => clearInterval(timer);
22992
+ }
22549
22993
  function startPoolLoop(run3, sup, record) {
22550
22994
  const { pool, members } = run3;
22551
22995
  const { reconcileMs, idleStopMs } = poolTimings();
@@ -22631,19 +23075,20 @@ function startPoolLoop(run3, sup, record) {
22631
23075
  const timer = setInterval(() => void tick(), reconcileMs);
22632
23076
  return () => clearInterval(timer);
22633
23077
  }
23078
+ var MODEL_REFRESH_MS = 15e3;
22634
23079
  async function nodeUpCommand(specFile, opts = {}) {
22635
23080
  try {
22636
- const specPath = resolve7(specFile);
22637
- const specRealpath = realpathSync5.native(specPath);
22638
- const parsed = parseSpec(readFileSync12(specPath, "utf-8"), dirname11(specPath));
23081
+ const specPath = resolve8(specFile);
23082
+ const specRealpath = realpathSync6.native(specPath);
23083
+ const parsed = parseSpec(readFileSync15(specPath, "utf-8"), dirname12(specPath));
22639
23084
  const previous = readState();
22640
23085
  if (previous && previous.supervisorPid !== process.pid && isAlive(previous.supervisorPid)) {
22641
23086
  throw new Error(`a node is already running (pid ${previous.supervisorPid}, ${previous.specPath}). Stop it first.`);
22642
23087
  }
22643
23088
  for (const a of parsed.agents) {
22644
- if (!existsSync11(a.worktree)) throw new Error(`${a.name}: worktree ${a.worktree} does not exist`);
23089
+ if (!existsSync12(a.worktree)) throw new Error(`${a.name}: worktree ${a.worktree} does not exist`);
22645
23090
  }
22646
- const plain = parsed.agents.map((a) => ({ ...a, worktree: realpathSync5.native(a.worktree) }));
23091
+ const plain = parsed.agents.map((a) => ({ ...a, worktree: realpathSync6.native(a.worktree) }));
22647
23092
  const pools = parsed.pools.map((pool) => {
22648
23093
  const paths = ensurePoolWorktrees(pool, plain.map((a) => ({ label: `agent ${a.name}'s worktree`, path: a.worktree })));
22649
23094
  return { pool, members: poolMembers(pool).map((m, i) => ({ ...m, worktree: paths[i] })) };
@@ -22673,6 +23118,20 @@ async function nodeUpCommand(specFile, opts = {}) {
22673
23118
  const inviteCodes = collectInvites(spec.agents.map((a) => a.name), opts.invite ?? [], process.env);
22674
23119
  const creds = await ensureIdentities(spec, inviteCodes, specRealpath);
22675
23120
  const runnable = spec.agents.filter((a) => creds.has(a.name));
23121
+ writeModelFiles(spec.agents);
23122
+ let modelError = "";
23123
+ const refreshModels = setInterval(() => {
23124
+ try {
23125
+ const fresh = new Map(allAgents(parseSpec(readFileSync15(specPath, "utf-8"), dirname12(specPath))).map((a) => [a.name, a.model]));
23126
+ writeModelFiles(spec.agents.map((a) => ({ name: a.name, model: fresh.get(a.name) })));
23127
+ modelError = "";
23128
+ } catch (err) {
23129
+ const message = err instanceof Error ? err.message : String(err);
23130
+ if (message !== modelError) console.log(source_default.yellow(` ! spec not re-read for models: ${message}`));
23131
+ modelError = message;
23132
+ }
23133
+ }, MODEL_REFRESH_MS);
23134
+ refreshModels.unref();
22676
23135
  if (runnable.length === 0) throw new Error("no agent has credentials, nothing to start");
22677
23136
  const mcp = resolveMcpServer({ here: HERE });
22678
23137
  for (const a of runnable) prepareAgent(a, creds.get(a.name), mcp);
@@ -22711,6 +23170,12 @@ async function nodeUpCommand(specFile, opts = {}) {
22711
23170
  process.on("SIGTERM", shutdown);
22712
23171
  process.on("SIGHUP", shutdown);
22713
23172
  sup.start();
23173
+ const byRepo = /* @__PURE__ */ new Map();
23174
+ for (const a of runnable) {
23175
+ const c = creds.get(a.name);
23176
+ byRepo.set(c.repoId, [...byRepo.get(c.repoId) ?? [], { name: a.name, creds: c }]);
23177
+ }
23178
+ for (const group of byRepo.values()) stopLoops.push(startActivityLoop(group, sup));
22714
23179
  for (const { pool, members } of pools) {
22715
23180
  const ready = members.filter((m) => creds.has(m.name)).map((agent) => ({ agent, creds: creds.get(agent.name) }));
22716
23181
  if (ready.length === 0) {
@@ -22731,9 +23196,9 @@ async function nodeUpCommand(specFile, opts = {}) {
22731
23196
  }
22732
23197
  async function nodePruneCommand(specFile, opts = {}) {
22733
23198
  try {
22734
- const specPath = resolve7(specFile);
22735
- const specRealpath = realpathSync5.native(specPath);
22736
- const spec = parseSpec(readFileSync12(specPath, "utf-8"), dirname11(specPath));
23199
+ const specPath = resolve8(specFile);
23200
+ const specRealpath = realpathSync6.native(specPath);
23201
+ const spec = parseSpec(readFileSync15(specPath, "utf-8"), dirname12(specPath));
22737
23202
  const { orphans: names, unattributed } = classifyStored(allAgents(spec).map((a) => a.name), specRealpath);
22738
23203
  if (names.length === 0) {
22739
23204
  console.log("No stored credentials are missing from the spec.");
@@ -22839,6 +23304,7 @@ var token = program2.command("token").description("Manage your agent's API token
22839
23304
  token.command("rotate").description("Issue a new token, revoke the old one, and list the configs that still hold it").action(tokenRotateCommand);
22840
23305
  token.command("list").description("List your agent's tokens, live and revoked, and mark the one in use").action(tokenListCommand);
22841
23306
  token.command("revoke <tokenId>").description("Revoke a specific token id").action(tokenRevokeCommand);
23307
+ program2.command("onboard <github-url>").description("Set a GitHub repo up for pitboss: clone it, write a node spec (tier-2 architect, reviewer, writer pool) and print one device code that grants every agent. Starts nothing").option("--account <login>", "GitHub account to clone and work as, from `gh auth` (for example a personal account)").option("--dir <path>", "Parent directory for the clone, worktrees and spec (default ~/github)").option("--writer <runtime>", "Runtime for the writer pool (default claude)").option("--reviewer <runtime>", "Runtime for the reviewer; must differ from the writers (default copilot, or claude for non-claude writers)").option("--pool-max <n>", "Writer pool size (default 2)").option("--force", "Replace an existing spec with different contents").action(onboardCommand);
22842
23308
  var node = program2.command("node").description("Run this machine's agents headless and keep them running");
22843
23309
  node.command("up <spec>").description("Start every agent in a spec file and restart any that exit (foreground; Ctrl-C stops them)").option(
22844
23310
  "--invite <name=code>",