cruo-agent 0.1.9 → 0.1.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.
Files changed (3) hide show
  1. package/dist/VERSION +1 -1
  2. package/dist/cli.js +240 -25
  3. package/package.json +1 -1
package/dist/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.9
1
+ 0.1.10
package/dist/cli.js CHANGED
@@ -46105,6 +46105,83 @@ async function listRecords() {
46105
46105
  }
46106
46106
  return out;
46107
46107
  }
46108
+ function entryKindOf(token) {
46109
+ const normalised = token.replace(/\\/g, "/");
46110
+ const basename = normalised.slice(normalised.lastIndexOf("/") + 1);
46111
+ if (CLI_BIN_NAMES.has(basename)) return "cli";
46112
+ if (SUPERVISOR_BIN_NAMES.has(basename)) return "supervisor";
46113
+ const isCliFile = /^cli\.(?:js|ts)$/.test(basename);
46114
+ const isSupervisorFile = /^supervisor\.(?:js|ts)$/.test(basename);
46115
+ if (!isCliFile && !isSupervisorFile) return null;
46116
+ if (!OWN_PACKAGE_MARKERS.some((marker) => normalised.includes(marker))) return null;
46117
+ return isCliFile ? "cli" : "supervisor";
46118
+ }
46119
+ function parsePsLine(line) {
46120
+ const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
46121
+ return m ? { pid: Number(m[1]), uid: Number(m[2]), args: m[3] } : null;
46122
+ }
46123
+ function asNameFrom(tokens) {
46124
+ const i = tokens.indexOf("--as");
46125
+ const value = i >= 0 ? tokens[i + 1] : void 0;
46126
+ return value && !value.startsWith("-") ? safeName(value) : null;
46127
+ }
46128
+ async function processArgv(pid) {
46129
+ try {
46130
+ const { stdout } = await exec("ps", ["-p", String(pid), "-o", "args="]);
46131
+ const value = stdout.trim();
46132
+ return value === "" ? null : value.split(/\s+/).filter(Boolean);
46133
+ } catch {
46134
+ return null;
46135
+ }
46136
+ }
46137
+ async function findSupervisorProcesses() {
46138
+ const uid = process.getuid?.();
46139
+ if (uid === void 0) return [];
46140
+ let stdout;
46141
+ try {
46142
+ ({ stdout } = await exec("ps", ["-axwwo", "pid=,uid=,args="]));
46143
+ } catch {
46144
+ return [];
46145
+ }
46146
+ const out = [];
46147
+ for (const line of stdout.split("\n")) {
46148
+ const parsed = parsePsLine(line);
46149
+ if (!parsed || parsed.uid !== uid || parsed.pid === process.pid) continue;
46150
+ const tokens = parsed.args.trim().split(/\s+/).filter(Boolean);
46151
+ let scriptAt = -1;
46152
+ let kind = null;
46153
+ for (let i = 0; i < tokens.length; i++) {
46154
+ kind = entryKindOf(tokens[i]);
46155
+ if (kind) {
46156
+ scriptAt = i;
46157
+ break;
46158
+ }
46159
+ }
46160
+ if (!kind) continue;
46161
+ if (kind === "cli") {
46162
+ const next = tokens[scriptAt + 1];
46163
+ const isSubcommand = next !== void 0 && !next.startsWith("-") && NON_LOOPING_CLI_COMMANDS.includes(next);
46164
+ if (isSubcommand) continue;
46165
+ }
46166
+ out.push({
46167
+ pid: parsed.pid,
46168
+ asName: asNameFrom(tokens),
46169
+ procStartedAt: await processStartedAt(parsed.pid),
46170
+ argv: tokens
46171
+ });
46172
+ }
46173
+ return out;
46174
+ }
46175
+ async function untrackedSupervisors() {
46176
+ const found = await findSupervisorProcesses();
46177
+ if (found.length === 0) return found;
46178
+ const tracked = await listRecords();
46179
+ const trackedPids = /* @__PURE__ */ new Set();
46180
+ for (const { live } of tracked) {
46181
+ if (live.state === "running") trackedPids.add(live.record.pid);
46182
+ }
46183
+ return found.filter((p) => !trackedPids.has(p.pid));
46184
+ }
46108
46185
  function signal(pid, which) {
46109
46186
  try {
46110
46187
  process.kill(pid, SIGNALS[which]);
@@ -46121,14 +46198,19 @@ function since(iso) {
46121
46198
  if (ms2 < 864e5) return `${Math.round(ms2 / 36e5)}h`;
46122
46199
  return `${Math.round(ms2 / 864e5)}d`;
46123
46200
  }
46124
- var exec, runDir, logDir, recordPath, SIGNALS;
46201
+ var exec, runDir, logDir, recordPath, CLI_BIN_NAMES, SUPERVISOR_BIN_NAMES, OWN_PACKAGE_MARKERS, NON_LOOPING_CLI_COMMANDS, SIGNALS;
46125
46202
  var init_process = __esm({
46126
46203
  "src/process.ts"() {
46127
46204
  "use strict";
46205
+ init_options();
46128
46206
  exec = promisify(execFile);
46129
46207
  runDir = () => join(homedir(), ".cruo", "run");
46130
46208
  logDir = () => join(homedir(), ".cruo", "logs");
46131
46209
  recordPath = (agent) => join(runDir(), `${safeName(agent)}.json`);
46210
+ CLI_BIN_NAMES = /* @__PURE__ */ new Set(["cruo", "cruo-agent"]);
46211
+ SUPERVISOR_BIN_NAMES = /* @__PURE__ */ new Set(["cruo-supervisor"]);
46212
+ OWN_PACKAGE_MARKERS = ["@cruo/mcp", "cruo-agent", "apps/mcp", "packages/cli"];
46213
+ NON_LOOPING_CLI_COMMANDS = CRUO_COMMANDS.filter((c) => c !== "run");
46132
46214
  SIGNALS = {
46133
46215
  stop: "SIGTERM",
46134
46216
  pause: "SIGUSR1",
@@ -47111,6 +47193,14 @@ function systemPrompt(ctx, identity, worktree) {
47111
47193
  `- If you cannot do the work \u2014 missing information, ambiguous brief, outside`,
47112
47194
  ` your capabilities \u2014 say so in a comment and assign it to someone who can,`,
47113
47195
  ` or leave it for a human. Do not guess, and do not silently do nothing.`,
47196
+ `- Claim the work before you do it: move the issue to the state your workflow`,
47197
+ ` uses for work in progress, and do that BEFORE you start, not when you`,
47198
+ ` finish. However short the work looks \u2014 a run of a few minutes is exactly`,
47199
+ ` the case nobody sees. A board that only changes at the end shows an idle`,
47200
+ ` queue while the work is happening, and neither a colleague nor another`,
47201
+ ` agent choosing what to pick up can tell a card nobody has touched from one`,
47202
+ ` being worked right now. If the workflow has no such state, leave it where`,
47203
+ ` it is. A mention is the exception \u2014 see below.`,
47114
47204
  `- You may be given an issue because someone MENTIONED you on it rather than`,
47115
47205
  ` because your function owns its state. The prompt says which. A mention is`,
47116
47206
  ` a question from a colleague: read the comments with list_comments, answer`,
@@ -47131,14 +47221,16 @@ function userPrompt(hit) {
47131
47221
  if (hit.reason === "assigned") {
47132
47222
  return [
47133
47223
  `${hit.ref} \u2014 "${hit.issue.title}" \u2014 is assigned to you, in the "${hit.state.name}"`,
47134
- `state. Do it. When you are finished, hand it on: move it to the state that`,
47224
+ `state. Claim it first \u2014 move it to the state your workflow uses for work in`,
47225
+ `progress \u2014 then do it. When you are finished, hand it on: move it to the state that`,
47135
47226
  `comes next and assign it to whoever owns that step. Leaving it assigned to`,
47136
47227
  `you, in this state, means it comes back to you.`
47137
47228
  ].join(" ");
47138
47229
  }
47139
47230
  return [
47140
47231
  `${hit.ref} \u2014 "${hit.issue.title}" \u2014 is in the "${hit.state.name}" state,`,
47141
- `which your function owns. Pick it up and do your part.`
47232
+ `which your function owns. Claim it first \u2014 move it to the state your workflow`,
47233
+ `uses for work in progress \u2014 then do your part.`
47142
47234
  ].join(" ");
47143
47235
  }
47144
47236
  function headFor(head2) {
@@ -47490,6 +47582,7 @@ async function tick(ctx, identity, deadTicks, allowance) {
47490
47582
  let committed = 0;
47491
47583
  let salvaged = null;
47492
47584
  const stopBusyBeat = beatWhileBusy(ctx, deadTicks);
47585
+ currentRun = { ref: hit.ref, startedAt: (/* @__PURE__ */ new Date()).toISOString() };
47493
47586
  try {
47494
47587
  run = await invokeHarness(ctx, identity, hit, worktree);
47495
47588
  if (worktree) committed = await worktree.commits();
@@ -47513,6 +47606,7 @@ async function tick(ctx, identity, deadTicks, allowance) {
47513
47606
  }
47514
47607
  } finally {
47515
47608
  stopBusyBeat();
47609
+ currentRun = null;
47516
47610
  await release(ctx, hit);
47517
47611
  }
47518
47612
  const code = run.code;
@@ -47581,6 +47675,22 @@ async function tick(ctx, identity, deadTicks, allowance) {
47581
47675
  });
47582
47676
  return { invoked, failedToRun, suppressed: false, waiting: actionable.length };
47583
47677
  }
47678
+ function interruptibleWait(ms2) {
47679
+ return new Promise((resolve2) => {
47680
+ const timer = setTimeout(() => {
47681
+ pendingWait = null;
47682
+ resolve2();
47683
+ }, ms2);
47684
+ pendingWait = { timer, resolve: resolve2 };
47685
+ });
47686
+ }
47687
+ function interruptWait() {
47688
+ if (!pendingWait) return;
47689
+ clearTimeout(pendingWait.timer);
47690
+ const { resolve: resolve2 } = pendingWait;
47691
+ pendingWait = null;
47692
+ resolve2();
47693
+ }
47584
47694
  function installControls() {
47585
47695
  const requestStop = (why) => {
47586
47696
  if (stopping && Date.now() - stopRequestedAt < 500) return;
@@ -47590,7 +47700,14 @@ function installControls() {
47590
47700
  }
47591
47701
  stopping = true;
47592
47702
  stopRequestedAt = Date.now();
47593
- log(` ${why} \u2014 finishing the current run, then stopping. Again to leave now.`);
47703
+ if (currentRun) {
47704
+ log(
47705
+ ` ${why} \u2014 finishing the current run (${currentRun.ref}), running ${since(currentRun.startedAt)} so far, up to ${Math.round(options.harnessTimeoutMs / 1e3)}s before it is killed. Again to leave now.`
47706
+ );
47707
+ } else {
47708
+ log(` ${why} \u2014 idle, nothing to finish, leaving now.`);
47709
+ }
47710
+ interruptWait();
47594
47711
  };
47595
47712
  process.on("SIGTERM", () => requestStop("stop requested"));
47596
47713
  process.on("SIGINT", () => requestStop("interrupted"));
@@ -47638,6 +47755,27 @@ first with no way to be stopped except by pid.
47638
47755
 
47639
47756
  stop it cruo stop ${runName}
47640
47757
  look at it cruo ps
47758
+ `
47759
+ );
47760
+ process.exit(2);
47761
+ }
47762
+ const ownArgv = await processArgv(process.pid);
47763
+ const implicit = !ownArgv || asNameFrom(ownArgv) === null;
47764
+ const runningUntracked = (await untrackedSupervisors()).filter(
47765
+ (p) => p.asName === runName || implicit && p.asName === null
47766
+ );
47767
+ if (runningUntracked.length > 0) {
47768
+ const pids = runningUntracked.map((p) => p.pid).join(", ");
47769
+ console.error(
47770
+ `
47771
+ ${runName} looks like it is already running here \u2014 untracked, pid ${pids} (found in the process table, no run record).
47772
+
47773
+ Two supervisors for one agent share a token, a queue and a heartbeat,
47774
+ and starting this one anyway repeats the exact incident this check exists
47775
+ to catch: a second supervisor nothing could see or stop.
47776
+
47777
+ look at it cruo ps
47778
+ stop it cruo stop ${runName}
47641
47779
  `
47642
47780
  );
47643
47781
  process.exit(2);
@@ -47732,11 +47870,11 @@ first with no way to be stopped except by pid.
47732
47870
  }
47733
47871
  if (options.once) return;
47734
47872
  if (stopping) return;
47735
- await new Promise((r) => setTimeout(r, options.intervalMs));
47873
+ await interruptibleWait(options.intervalMs);
47736
47874
  if (stopping) return;
47737
47875
  }
47738
47876
  }
47739
- var argv, flag, opt, num2, ms, readOptions, options, log, worktreeConfig, lastRefusal, PRIORITY_RANK, CONFIG_DIR_PREFIX, CRUO_OWNED_ENV, HEAD_STORED, lastHolding, warnedAboutSpendTable, stopping, paused, stopRequestedAt;
47877
+ var argv, flag, opt, num2, ms, readOptions, options, log, worktreeConfig, lastRefusal, PRIORITY_RANK, CONFIG_DIR_PREFIX, CRUO_OWNED_ENV, HEAD_STORED, lastHolding, warnedAboutSpendTable, stopping, paused, stopRequestedAt, currentRun, pendingWait;
47740
47878
  var init_supervisor = __esm({
47741
47879
  "src/supervisor.ts"() {
47742
47880
  "use strict";
@@ -47930,6 +48068,8 @@ cruo-supervisor: ${error51.message}
47930
48068
  stopping = false;
47931
48069
  paused = false;
47932
48070
  stopRequestedAt = 0;
48071
+ currentRun = null;
48072
+ pendingWait = null;
47933
48073
  main().then(async () => {
47934
48074
  if (!options.once && !options.dryRun) {
47935
48075
  try {
@@ -48171,28 +48311,72 @@ async function targetName(explicit, asName) {
48171
48311
  const config3 = await readConfig2();
48172
48312
  return safeName(config3.default ?? "default");
48173
48313
  }
48314
+ async function untrackedMatchingForRefusal(name, implicit) {
48315
+ return (await untrackedSupervisors()).filter((p) => p.asName === name || implicit && p.asName === null);
48316
+ }
48317
+ function reportUnidentified(procs) {
48318
+ if (procs.length === 0) return false;
48319
+ const pids = procs.map((p) => p.pid).join(", ");
48320
+ console.error(
48321
+ `
48322
+ ${procs.length} more untracked ${procs.length === 1 ? "process" : "processes"} found with no \`--as\` of its own to confirm identity: pid ${pids}.
48323
+ Not signalled \u2014 a wrong \`stop\` is far more costly than a missed one. Look at it yourself:
48324
+
48325
+ cruo ps
48326
+ kill -TERM <pid> (if you are sure)
48327
+ `
48328
+ );
48329
+ return true;
48330
+ }
48174
48331
  async function targets(explicit, asName, all) {
48175
48332
  if (all) {
48176
48333
  const rows = await listRecords();
48177
- return rows.filter((r) => r.live.state === "running").map((r) => r.live.record);
48334
+ const tracked = rows.filter((r) => r.live.state === "running").map((r) => ({ agent: r.agent, pid: r.live.record.pid, untracked: false }));
48335
+ const discovered = await untrackedSupervisors();
48336
+ const untracked = discovered.filter((p) => p.asName !== null).map((p) => ({ agent: p.asName, pid: p.pid, untracked: true }));
48337
+ const reported = reportUnidentified(discovered.filter((p) => p.asName === null));
48338
+ if (!reported && tracked.length === 0 && untracked.length === 0) console.log(`
48339
+ Nothing running.
48340
+ `);
48341
+ return [...tracked, ...untracked];
48178
48342
  }
48179
48343
  const name = await targetName(explicit, asName);
48344
+ const implicit = explicit === null && asName === null;
48180
48345
  const live = await liveness(name, true);
48181
- if (live.state === "running") return [live.record];
48182
- if (live.state === "gone") {
48183
- console.error(`
48346
+ const out = [];
48347
+ if (live.state === "running") out.push({ agent: name, pid: live.record.pid, untracked: false });
48348
+ const matches = (await untrackedSupervisors()).filter((p) => p.asName === name);
48349
+ out.push(...matches.map((p) => ({ agent: name, pid: p.pid, untracked: true })));
48350
+ if (out.length === 0) {
48351
+ const unidentified = implicit ? (await untrackedSupervisors()).filter((p) => p.asName === null) : [];
48352
+ if (unidentified.length > 0) {
48353
+ const pids = unidentified.map((p) => p.pid).join(", ");
48354
+ console.error(
48355
+ `
48356
+ ${name} \u2014 can't confirm. ${unidentified.length} untracked ${unidentified.length === 1 ? "process" : "processes"} found with no \`--as\` of its own (pid ${pids}) \u2014 exactly how ${name} would look if it is running as this machine's default identity, but nothing here can tell that apart from an
48357
+ unrelated process sharing this account.
48358
+
48359
+ Not signalled. Look at it yourself, then act if you are sure:
48360
+
48361
+ cruo ps
48362
+ kill -TERM <pid>
48363
+ `
48364
+ );
48365
+ } else if (live.state === "gone") {
48366
+ console.error(`
48184
48367
  ${name} is not running (it left a record behind; cleared).
48185
48368
  `);
48186
- } else if (live.state === "recycled") {
48187
- console.error(`
48369
+ } else if (live.state === "recycled") {
48370
+ console.error(`
48188
48371
  ${name} is not running \u2014 its pid now belongs to something else. Cleared.
48189
48372
  `);
48190
- } else {
48191
- console.error(`
48373
+ } else {
48374
+ console.error(`
48192
48375
  ${name} is not running. Start it with \`cruo start --as ${name}\`.
48193
48376
  `);
48377
+ }
48194
48378
  }
48195
- return [];
48379
+ return out;
48196
48380
  }
48197
48381
  async function startDetached(argv2, asName) {
48198
48382
  const { spawn: spawn2 } = await import("node:child_process");
@@ -48220,6 +48404,22 @@ Stop it first, or use --as <name> to run a different agent.
48220
48404
  );
48221
48405
  process.exit(1);
48222
48406
  }
48407
+ const runningUntracked = await untrackedMatchingForRefusal(name, !positional && !asName);
48408
+ if (runningUntracked.length > 0) {
48409
+ const pids = runningUntracked.map((p) => p.pid).join(", ");
48410
+ console.error(
48411
+ `
48412
+ ${name} looks like it is already running here \u2014 untracked, pid ${pids} (found in the process table, no run record).
48413
+
48414
+ Two supervisors for one agent share a token, a queue and a heartbeat.
48415
+ Look at it, then stop it if it should not be there:
48416
+
48417
+ cruo ps
48418
+ cruo stop ${name}
48419
+ `
48420
+ );
48421
+ process.exit(2);
48422
+ }
48223
48423
  const config3 = await readConfig2();
48224
48424
  const storedKey = Object.keys(config3.agents).find((k) => safeName(k) === name) ?? null;
48225
48425
  const hasToken = Boolean(process.env.CRUO_TOKEN?.trim()) || argv2.includes("--token") || storedKey !== null;
@@ -48269,7 +48469,8 @@ https://cruo.space/docs#agents.
48269
48469
  async function listProcesses() {
48270
48470
  const rows = await listRecords();
48271
48471
  const running = rows.filter((r) => r.live.state === "running");
48272
- if (rows.length === 0) {
48472
+ const untracked = await untrackedSupervisors();
48473
+ if (rows.length === 0 && untracked.length === 0) {
48273
48474
  console.log(`
48274
48475
  Nothing running. Start one with \`cruo start\`.
48275
48476
  `);
@@ -48291,9 +48492,25 @@ Nothing running. Start one with \`cruo start\`.
48291
48492
  await removeRecord(agent);
48292
48493
  }
48293
48494
  }
48495
+ for (const proc of untracked) {
48496
+ const name = proc.asName ?? "(unknown identity)";
48497
+ const flags = proc.argv.filter((a) => a.startsWith("--")).join(" ") || "(no flags)";
48498
+ const up = proc.procStartedAt ? since(proc.procStartedAt) : "?";
48499
+ console.log(
48500
+ ` ${name.padEnd(14)} ${String(proc.pid).padEnd(8)} ${up.padEnd(8)} ${flags} \u2014 UNTRACKED (no run record; found in the process table)`
48501
+ );
48502
+ }
48503
+ if (untracked.length > 0) {
48504
+ const named = untracked.filter((p) => p.asName !== null).length;
48505
+ const unnamed = untracked.length - named;
48506
+ console.log(
48507
+ `
48508
+ ${untracked.length} untracked ${untracked.length === 1 ? "process" : "processes"} found with no run record.` + (named > 0 ? ` \`cruo stop <name>\` can reach ${named === 1 ? "the one" : `${named}`} whose own argv named it with --as.` : "") + (unnamed > 0 ? ` ${unnamed} ${unnamed === 1 ? "has" : "have"} no \`--as\` of its own \u2014 \`cruo stop\` will report ${unnamed === 1 ? "its" : "their"} pid but never signal ${unnamed === 1 ? "it" : "them"} without one.` : "")
48509
+ );
48510
+ }
48294
48511
  console.log(
48295
48512
  `
48296
- ${running.length} running. Whether each is actually picking up work is on the board, not here \u2014
48513
+ ${running.length + untracked.length} running. Whether each is actually picking up work is on the board, not here \u2014
48297
48514
  Cruo \u2192 Settings \u2192 Members shows what it is holding and when it last looked.
48298
48515
  `
48299
48516
  );
@@ -48301,29 +48518,27 @@ Nothing running. Start one with \`cruo start\`.
48301
48518
  async function control(which, explicit, asName, all) {
48302
48519
  const records = await targets(explicit, asName, all);
48303
48520
  if (records.length === 0) {
48304
- if (all) console.log(`
48305
- Nothing running.
48306
- `);
48307
48521
  process.exitCode = all ? 0 : 1;
48308
48522
  return;
48309
48523
  }
48310
48524
  for (const record2 of records) {
48525
+ const tag = record2.untracked ? ` (untracked, pid ${record2.pid})` : "";
48311
48526
  const sent = signal(record2.pid, which);
48312
48527
  if (!sent) {
48313
- console.error(` ${record2.agent}: could not signal pid ${record2.pid} \u2014 it may have just exited`);
48314
- await removeRecord(record2.agent);
48528
+ console.error(` ${record2.agent}${tag}: could not signal pid ${record2.pid} \u2014 it may have just exited`);
48529
+ if (!record2.untracked) await removeRecord(record2.agent);
48315
48530
  continue;
48316
48531
  }
48317
48532
  if (which === "stop") {
48318
48533
  console.log(
48319
- ` ${record2.agent}: asked to stop. It will finish the run it is on first \u2014
48534
+ ` ${record2.agent}${tag}: asked to stop. It will finish the run it is on first \u2014
48320
48535
  a harness mid-run keeps its work, which is the point of asking rather than killing.
48321
48536
  Watch it leave with: cruo logs ${record2.agent} -f`
48322
48537
  );
48323
48538
  } else if (which === "pause") {
48324
- console.log(` ${record2.agent}: paused. Still watching the board and still reporting; starts nothing new.`);
48539
+ console.log(` ${record2.agent}${tag}: paused. Still watching the board and still reporting; starts nothing new.`);
48325
48540
  } else {
48326
- console.log(` ${record2.agent}: resumed.`);
48541
+ console.log(` ${record2.agent}${tag}: resumed.`);
48327
48542
  }
48328
48543
  }
48329
48544
  console.log("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cruo-agent",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Run a Cruo agent: it watches your board, picks up the cards you assign it, and works them.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://cruo.space/docs#agents",