cruo-agent 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -93,7 +93,7 @@ given up on work nobody abandoned.
93
93
  | `--worktree` | a checkout per card |
94
94
  | `--allow <tools>` | what the harness may use |
95
95
  | `--push` | publish the branch after a run that commits |
96
- | `--harness-timeout <seconds>` | kill a run that wedges (default 600) |
96
+ | `--harness-timeout <seconds>` | kill a run that wedges (default 1800) |
97
97
  | `--max-attempts <n>` | give up on a card after n unproductive runs (default 3) |
98
98
 
99
99
  The full list, and what each is for: <https://cruo.space/docs#agents>
package/dist/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.4
1
+ 0.1.6
package/dist/cli.js CHANGED
@@ -15247,6 +15247,13 @@ var init_plans = __esm({
15247
15247
  }
15248
15248
  });
15249
15249
 
15250
+ // ../../packages/core/dist/reports.js
15251
+ var init_reports = __esm({
15252
+ "../../packages/core/dist/reports.js"() {
15253
+ "use strict";
15254
+ }
15255
+ });
15256
+
15250
15257
  // ../../packages/core/dist/mcp-connect.js
15251
15258
  var DOCS_URL, AGENT_DOCS_URL, MCP_DOCS_URL, CRUO_CLOUD;
15252
15259
  var init_mcp_connect = __esm({
@@ -15293,6 +15300,7 @@ var init_dist = __esm({
15293
15300
  init_identity();
15294
15301
  init_effort();
15295
15302
  init_plans();
15303
+ init_reports();
15296
15304
  init_mcp_connect();
15297
15305
  init_tree();
15298
15306
  init_place();
@@ -16158,7 +16166,7 @@ var require_FunctionsClient = __commonJS({
16158
16166
  let timeoutId;
16159
16167
  let timeoutController;
16160
16168
  try {
16161
- const { headers, method, body: functionArgs, signal, timeout } = options2;
16169
+ const { headers, method, body: functionArgs, signal: signal2, timeout } = options2;
16162
16170
  let _headers = {};
16163
16171
  let { region } = options2;
16164
16172
  if (!region) {
@@ -16191,13 +16199,13 @@ var require_FunctionsClient = __commonJS({
16191
16199
  body = functionArgs;
16192
16200
  }
16193
16201
  }
16194
- let effectiveSignal = signal;
16202
+ let effectiveSignal = signal2;
16195
16203
  if (timeout) {
16196
16204
  timeoutController = new AbortController();
16197
16205
  timeoutId = setTimeout(() => timeoutController.abort(), timeout);
16198
- if (signal) {
16206
+ if (signal2) {
16199
16207
  effectiveSignal = timeoutController.signal;
16200
- signal.addEventListener("abort", () => timeoutController.abort());
16208
+ signal2.addEventListener("abort", () => timeoutController.abort());
16201
16209
  } else {
16202
16210
  effectiveSignal = timeoutController.signal;
16203
16211
  }
@@ -16283,21 +16291,21 @@ var require_main = __commonJS({
16283
16291
  });
16284
16292
 
16285
16293
  // ../../node_modules/.pnpm/@supabase+postgrest-js@2.110.7/node_modules/@supabase/postgrest-js/dist/index.mjs
16286
- function sleep(ms2, signal) {
16294
+ function sleep(ms2, signal2) {
16287
16295
  return new Promise((resolve2) => {
16288
- if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
16296
+ if (signal2 === null || signal2 === void 0 ? void 0 : signal2.aborted) {
16289
16297
  resolve2();
16290
16298
  return;
16291
16299
  }
16292
16300
  const id = setTimeout(() => {
16293
- signal === null || signal === void 0 || signal.removeEventListener("abort", onAbort);
16301
+ signal2 === null || signal2 === void 0 || signal2.removeEventListener("abort", onAbort);
16294
16302
  resolve2();
16295
16303
  }, ms2);
16296
16304
  function onAbort() {
16297
16305
  clearTimeout(id);
16298
16306
  resolve2();
16299
16307
  }
16300
- signal === null || signal === void 0 || signal.addEventListener("abort", onAbort);
16308
+ signal2 === null || signal2 === void 0 || signal2.addEventListener("abort", onAbort);
16301
16309
  });
16302
16310
  }
16303
16311
  function shouldRetry(method, status, attemptCount, retryEnabled) {
@@ -17326,8 +17334,8 @@ ${cause.stack}`;
17326
17334
  *
17327
17335
  * ```
17328
17336
  */
17329
- abortSignal(signal) {
17330
- this.signal = signal;
17337
+ abortSignal(signal2) {
17338
+ this.signal = signal2;
17331
17339
  return this;
17332
17340
  }
17333
17341
  /**
@@ -23486,8 +23494,8 @@ var require_RealtimeChannel = __commonJS({
23486
23494
  }
23487
23495
  /** @internal */
23488
23496
  _notThisChannelEvent(event, ref) {
23489
- const { close, error: error51, leave, join: join4 } = constants_1.CHANNEL_EVENTS;
23490
- const events = [close, error51, leave, join4];
23497
+ const { close, error: error51, leave, join: join5 } = constants_1.CHANNEL_EVENTS;
23498
+ const events = [close, error51, leave, join5];
23491
23499
  return ref && events.includes(event) && ref !== this.joinPush.ref;
23492
23500
  }
23493
23501
  /** @internal */
@@ -30509,7 +30517,7 @@ var require_webauthn = __commonJS({
30509
30517
  * @see {@link https://w3c.github.io/webauthn/#sctn-credential-creation W3C WebAuthn Spec - Credential Creation}
30510
30518
  * @see {@link https://w3c.github.io/webauthn/#sctn-verifying-assertion W3C WebAuthn Spec - Verifying Assertion}
30511
30519
  */
30512
- async _challenge({ factorId, webauthn, friendlyName, signal }, overrides) {
30520
+ async _challenge({ factorId, webauthn, friendlyName, signal: signal2 }, overrides) {
30513
30521
  var _a3;
30514
30522
  try {
30515
30523
  const { data: challengeResponse, error: challengeError } = await this.client.mfa.challenge({
@@ -30519,7 +30527,7 @@ var require_webauthn = __commonJS({
30519
30527
  if (!challengeResponse) {
30520
30528
  return { data: null, error: challengeError };
30521
30529
  }
30522
- const abortSignal = signal !== null && signal !== void 0 ? signal : exports.webAuthnAbortService.createNewAbortSignal();
30530
+ const abortSignal = signal2 !== null && signal2 !== void 0 ? signal2 : exports.webAuthnAbortService.createNewAbortSignal();
30523
30531
  if (challengeResponse.webauthn.type === "create") {
30524
30532
  const { user } = challengeResponse.webauthn.credential_options.publicKey;
30525
30533
  if (!user.name) {
@@ -30623,7 +30631,7 @@ var require_webauthn = __commonJS({
30623
30631
  * @see {@link https://w3c.github.io/webauthn/#sctn-authentication W3C WebAuthn Spec - Authentication Ceremony}
30624
30632
  * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialRequestOptions MDN - PublicKeyCredentialRequestOptions}
30625
30633
  */
30626
- async _authenticate({ factorId, webauthn: { rpId = typeof window !== "undefined" ? window.location.hostname : void 0, rpOrigins = typeof window !== "undefined" ? [window.location.origin] : void 0, signal } = {} }, overrides) {
30634
+ async _authenticate({ factorId, webauthn: { rpId = typeof window !== "undefined" ? window.location.hostname : void 0, rpOrigins = typeof window !== "undefined" ? [window.location.origin] : void 0, signal: signal2 } = {} }, overrides) {
30627
30635
  if (!rpId) {
30628
30636
  return {
30629
30637
  data: null,
@@ -30640,7 +30648,7 @@ var require_webauthn = __commonJS({
30640
30648
  const { data: challengeResponse, error: challengeError } = await this.challenge({
30641
30649
  factorId,
30642
30650
  webauthn: { rpId, rpOrigins },
30643
- signal
30651
+ signal: signal2
30644
30652
  }, { request: overrides });
30645
30653
  if (!challengeResponse) {
30646
30654
  return { data: null, error: challengeError };
@@ -30681,7 +30689,7 @@ var require_webauthn = __commonJS({
30681
30689
  * @see {@link https://w3c.github.io/webauthn/#sctn-registering-a-new-credential W3C WebAuthn Spec - Registration Ceremony}
30682
30690
  * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions MDN - PublicKeyCredentialCreationOptions}
30683
30691
  */
30684
- async _register({ friendlyName, webauthn: { rpId = typeof window !== "undefined" ? window.location.hostname : void 0, rpOrigins = typeof window !== "undefined" ? [window.location.origin] : void 0, signal } = {} }, overrides) {
30692
+ async _register({ friendlyName, webauthn: { rpId = typeof window !== "undefined" ? window.location.hostname : void 0, rpOrigins = typeof window !== "undefined" ? [window.location.origin] : void 0, signal: signal2 } = {} }, overrides) {
30685
30693
  if (!rpId) {
30686
30694
  return {
30687
30695
  data: null,
@@ -30709,7 +30717,7 @@ var require_webauthn = __commonJS({
30709
30717
  factorId: factor.id,
30710
30718
  friendlyName: factor.friendly_name,
30711
30719
  webauthn: { rpId, rpOrigins },
30712
- signal
30720
+ signal: signal2
30713
30721
  }, {
30714
30722
  create: overrides
30715
30723
  });
@@ -35585,10 +35593,10 @@ var require_GoTrueClient = __commonJS({
35585
35593
  return this._returnResult({ data: null, error: optionsError });
35586
35594
  }
35587
35595
  const publicKeyOptions = (0, webauthn_1.deserializeCredentialRequestOptions)(options2.options);
35588
- const signal = (_c = (_b = credentials === null || credentials === void 0 ? void 0 : credentials.options) === null || _b === void 0 ? void 0 : _b.signal) !== null && _c !== void 0 ? _c : webauthn_1.webAuthnAbortService.createNewAbortSignal();
35596
+ const signal2 = (_c = (_b = credentials === null || credentials === void 0 ? void 0 : credentials.options) === null || _b === void 0 ? void 0 : _b.signal) !== null && _c !== void 0 ? _c : webauthn_1.webAuthnAbortService.createNewAbortSignal();
35589
35597
  const { data: credential, error: credentialError } = await (0, webauthn_1.getCredential)({
35590
35598
  publicKey: publicKeyOptions,
35591
- signal
35599
+ signal: signal2
35592
35600
  });
35593
35601
  if (credentialError || !credential) {
35594
35602
  return this._returnResult({
@@ -35633,10 +35641,10 @@ var require_GoTrueClient = __commonJS({
35633
35641
  return this._returnResult({ data: null, error: optionsError });
35634
35642
  }
35635
35643
  const publicKeyOptions = (0, webauthn_1.deserializeCredentialCreationOptions)(options2.options);
35636
- const signal = (_b = (_a3 = credentials === null || credentials === void 0 ? void 0 : credentials.options) === null || _a3 === void 0 ? void 0 : _a3.signal) !== null && _b !== void 0 ? _b : webauthn_1.webAuthnAbortService.createNewAbortSignal();
35644
+ const signal2 = (_b = (_a3 = credentials === null || credentials === void 0 ? void 0 : credentials.options) === null || _a3 === void 0 ? void 0 : _a3.signal) !== null && _b !== void 0 ? _b : webauthn_1.webAuthnAbortService.createNewAbortSignal();
35637
35645
  const { data: credential, error: credentialError } = await (0, webauthn_1.createCredential)({
35638
35646
  publicKey: publicKeyOptions,
35639
- signal
35647
+ signal: signal2
35640
35648
  });
35641
35649
  if (credentialError || !credential) {
35642
35650
  return this._returnResult({
@@ -36019,8 +36027,8 @@ function parseTraceParent(traceparent) {
36019
36027
  isSampled: (parseInt(traceFlags, 16) & 1) === 1
36020
36028
  };
36021
36029
  }
36022
- function shouldPropagateToTarget(targetUrl, targets) {
36023
- if (!targetUrl || !targets || targets.length === 0) return false;
36030
+ function shouldPropagateToTarget(targetUrl, targets2) {
36031
+ if (!targetUrl || !targets2 || targets2.length === 0) return false;
36024
36032
  let url3;
36025
36033
  if (targetUrl instanceof URL) url3 = targetUrl;
36026
36034
  else try {
@@ -36028,7 +36036,7 @@ function shouldPropagateToTarget(targetUrl, targets) {
36028
36036
  } catch (error51) {
36029
36037
  return false;
36030
36038
  }
36031
- for (const target of targets) try {
36039
+ for (const target of targets2) try {
36032
36040
  if (typeof target === "string") {
36033
36041
  if (matchStringTarget(url3.hostname, target)) return true;
36034
36042
  } else if (target instanceof RegExp) {
@@ -36052,15 +36060,15 @@ function matchStringTarget(hostname3, target) {
36052
36060
  return false;
36053
36061
  }
36054
36062
  function getDefaultPropagationTargets(supabaseUrl) {
36055
- const targets = [];
36063
+ const targets2 = [];
36056
36064
  try {
36057
36065
  const url3 = new URL(supabaseUrl);
36058
- targets.push(url3.hostname);
36066
+ targets2.push(url3.hostname);
36059
36067
  } catch (error51) {
36060
36068
  }
36061
- targets.push("*.supabase.co", "*.supabase.in");
36062
- targets.push("localhost", "127.0.0.1", "[::1]");
36063
- return targets;
36069
+ targets2.push("*.supabase.co", "*.supabase.in");
36070
+ targets2.push("localhost", "127.0.0.1", "[::1]");
36071
+ return targets2;
36064
36072
  }
36065
36073
  function _typeof3(o) {
36066
36074
  "@babel/helpers - typeof";
@@ -36113,8 +36121,8 @@ function _objectSpread23(e) {
36113
36121
  }
36114
36122
  return e;
36115
36123
  }
36116
- async function getTraceHeaders(input, targets, respectSampling) {
36117
- if (!shouldPropagateToTarget(typeof input === "string" ? input : input instanceof URL ? input : input.url, targets)) return null;
36124
+ async function getTraceHeaders(input, targets2, respectSampling) {
36125
+ if (!shouldPropagateToTarget(typeof input === "string" ? input : input instanceof URL ? input : input.url, targets2)) return null;
36118
36126
  const traceContext = await extractTraceContext();
36119
36127
  if (!traceContext || !traceContext.traceparent) return null;
36120
36128
  if (respectSampling) {
@@ -45949,6 +45957,110 @@ var init_options = __esm({
45949
45957
  }
45950
45958
  });
45951
45959
 
45960
+ // src/process.ts
45961
+ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
45962
+ import { execFile } from "node:child_process";
45963
+ import { homedir } from "node:os";
45964
+ import { join } from "node:path";
45965
+ import { promisify } from "node:util";
45966
+ function safeName(agent) {
45967
+ const cleaned = agent.toLowerCase().replace(/[^a-z0-9._-]/g, "").replace(/^\.+/, "");
45968
+ return cleaned === "" ? "default" : cleaned;
45969
+ }
45970
+ async function processStartedAt(pid) {
45971
+ try {
45972
+ const { stdout } = await exec("ps", ["-p", String(pid), "-o", "lstart="]);
45973
+ const value = stdout.trim();
45974
+ return value === "" ? null : value;
45975
+ } catch {
45976
+ return null;
45977
+ }
45978
+ }
45979
+ function isAlive(pid) {
45980
+ try {
45981
+ process.kill(pid, 0);
45982
+ return true;
45983
+ } catch (error51) {
45984
+ return error51.code === "EPERM";
45985
+ }
45986
+ }
45987
+ async function writeRecord(record2) {
45988
+ await mkdir(runDir(), { recursive: true });
45989
+ await writeFile(recordPath(record2.agent), JSON.stringify(record2, null, 2) + "\n", {
45990
+ mode: 384
45991
+ });
45992
+ }
45993
+ async function removeRecord(agent) {
45994
+ await rm(recordPath(agent), { force: true });
45995
+ }
45996
+ async function readRecord(agent) {
45997
+ try {
45998
+ const parsed = JSON.parse(await readFile(recordPath(agent), "utf8"));
45999
+ return typeof parsed?.pid === "number" ? parsed : null;
46000
+ } catch {
46001
+ return null;
46002
+ }
46003
+ }
46004
+ async function liveness(agent, tidy = false) {
46005
+ const record2 = await readRecord(agent);
46006
+ if (!record2) return { state: "absent" };
46007
+ if (!isAlive(record2.pid)) {
46008
+ if (tidy) await removeRecord(agent);
46009
+ return { state: "gone", record: record2 };
46010
+ }
46011
+ const now = await processStartedAt(record2.pid);
46012
+ if (record2.procStartedAt !== null && now !== null && now !== record2.procStartedAt) {
46013
+ if (tidy) await removeRecord(agent);
46014
+ return { state: "recycled", record: record2 };
46015
+ }
46016
+ return { state: "running", record: record2 };
46017
+ }
46018
+ async function listRecords() {
46019
+ let names;
46020
+ try {
46021
+ names = (await readdir(runDir())).filter((n2) => n2.endsWith(".json"));
46022
+ } catch {
46023
+ return [];
46024
+ }
46025
+ const out = [];
46026
+ for (const file2 of names.sort()) {
46027
+ const agent = file2.slice(0, -".json".length);
46028
+ out.push({ agent, live: await liveness(agent) });
46029
+ }
46030
+ return out;
46031
+ }
46032
+ function signal(pid, which) {
46033
+ try {
46034
+ process.kill(pid, SIGNALS[which]);
46035
+ return true;
46036
+ } catch {
46037
+ return false;
46038
+ }
46039
+ }
46040
+ function since(iso) {
46041
+ const ms2 = Date.now() - Date.parse(iso);
46042
+ if (!Number.isFinite(ms2) || ms2 < 0) return "?";
46043
+ if (ms2 < 6e4) return `${Math.round(ms2 / 1e3)}s`;
46044
+ if (ms2 < 36e5) return `${Math.round(ms2 / 6e4)}m`;
46045
+ if (ms2 < 864e5) return `${Math.round(ms2 / 36e5)}h`;
46046
+ return `${Math.round(ms2 / 864e5)}d`;
46047
+ }
46048
+ var exec, runDir, logDir, recordPath, SIGNALS;
46049
+ var init_process = __esm({
46050
+ "src/process.ts"() {
46051
+ "use strict";
46052
+ exec = promisify(execFile);
46053
+ runDir = () => join(homedir(), ".cruo", "run");
46054
+ logDir = () => join(homedir(), ".cruo", "logs");
46055
+ recordPath = (agent) => join(runDir(), `${safeName(agent)}.json`);
46056
+ SIGNALS = {
46057
+ stop: "SIGTERM",
46058
+ pause: "SIGUSR1",
46059
+ resume: "SIGUSR2"
46060
+ };
46061
+ }
46062
+ });
46063
+
45952
46064
  // src/env.ts
45953
46065
  function applyCloudDefaults() {
45954
46066
  const url3 = process.env.SUPABASE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL;
@@ -46040,8 +46152,8 @@ function compact(value) {
46040
46152
  }
46041
46153
  async function reportUsage(opts) {
46042
46154
  const ctx = await contextOrExplain();
46043
- const since = new Date(Date.now() - opts.days * 864e5).toISOString();
46044
- let query = ctx.client.from("harness_runs").select("*").gte("started_at", since).order("started_at", { ascending: false });
46155
+ const since2 = new Date(Date.now() - opts.days * 864e5).toISOString();
46156
+ let query = ctx.client.from("harness_runs").select("*").gte("started_at", since2).order("started_at", { ascending: false });
46045
46157
  let issueTitle = null;
46046
46158
  if (opts.issue) {
46047
46159
  const [key, num3] = opts.issue.split("-");
@@ -46237,15 +46349,77 @@ function usageIn(text) {
46237
46349
  const measured = Object.values(out).some((v) => v !== void 0);
46238
46350
  return measured ? out : null;
46239
46351
  }
46352
+ function renderEvent(line) {
46353
+ let event;
46354
+ try {
46355
+ event = JSON.parse(line);
46356
+ } catch {
46357
+ return { text: line, show: line };
46358
+ }
46359
+ if (typeof event !== "object" || event === null) return { text: line, show: line };
46360
+ const e = event;
46361
+ if (e.type === "assistant" || e.type === "user") {
46362
+ const message = e.message;
46363
+ const content = Array.isArray(message?.content) ? message.content : [];
46364
+ const said = [];
46365
+ const shown = [];
46366
+ for (const part of content) {
46367
+ if (typeof part !== "object" || part === null) continue;
46368
+ const p = part;
46369
+ if (p.type === "text" && typeof p.text === "string") {
46370
+ said.push(p.text);
46371
+ shown.push(p.text);
46372
+ } else if (p.type === "tool_use" && typeof p.name === "string") {
46373
+ shown.push(` \xB7 ${p.name}`);
46374
+ } else if (p.type === "tool_result") {
46375
+ const failed = p.is_error === true;
46376
+ if (failed) shown.push(" \xB7 tool error");
46377
+ }
46378
+ }
46379
+ return {
46380
+ text: said.length > 0 ? said.join("\n") : null,
46381
+ show: shown.length > 0 ? shown.join("\n") : null
46382
+ };
46383
+ }
46384
+ if (e.type === "result") {
46385
+ const summary = typeof e.result === "string" ? e.result : null;
46386
+ const usage = usageIn(line);
46387
+ const spend = usage ? ` \u2014 ${usage.inputTokens ?? "?"} in / ${usage.outputTokens ?? "?"} out` + (usage.costUsd !== void 0 ? `, $${usage.costUsd.toFixed(4)}` : "") : "";
46388
+ return { text: summary, show: ` [${String(e.subtype ?? "result")}${spend}]` };
46389
+ }
46390
+ if (e.type === "system") return { text: null, show: null };
46391
+ return { text: null, show: null };
46392
+ }
46240
46393
  var REFUSALS, num;
46241
46394
  var init_harness_signal = __esm({
46242
46395
  "src/harness-signal.ts"() {
46243
46396
  "use strict";
46244
46397
  REFUSALS = [
46245
- [/hit your (monthly |weekly |daily )?(spend|usage) limit/i, "the account's spend limit is reached"],
46246
- [/insufficient credit|out of credit|credit balance is too low/i, "the account is out of credit"],
46398
+ // "hit your" AND "reached your". The original had only the first, and the
46399
+ // harness ships the second `you have reached your weekly usage limit` is a
46400
+ // literal string in the 2.x binary. That one word cost 8,239 runs (CRA-89):
46401
+ // the refusal went unrecognised, so `neverRan` was false, so the backoff
46402
+ // never engaged and every card was charged for an outage.
46403
+ [
46404
+ /(hit|reached) your (monthly |weekly |daily )?(spend|usage) limit/i,
46405
+ "the account's usage limit is reached"
46406
+ ],
46407
+ // The passive voice of the same thing, which is what the harness prints when
46408
+ // it is reporting rather than addressing you.
46409
+ [/(usage|spend|usage credit) limit reached/i, "the account's usage limit is reached"],
46410
+ // `is` optional: the binary carries BOTH "credit balance is too low" and
46411
+ // "credit balance too low", and the pattern only knew the longer one.
46412
+ [
46413
+ /insufficient credit|out of credit|credit balance (is )?too low/i,
46414
+ "the account is out of credit"
46415
+ ],
46247
46416
  [/upgrade to (a paid plan|claude pro)/i, "the plan does not cover this"],
46248
46417
  [/invalid api key|authentication[_ ]error|please run \/login/i, "the harness is not signed in"],
46418
+ // Still requires the follow-up clause. A bare "rate limited" is left
46419
+ // UNMATCHED on purpose — it is a phrase a model writes about a codebase
46420
+ // ("the endpoint is rate limited"), and the note above is explicit that a
46421
+ // false positive is the worse error: it credits an issue an attempt it
46422
+ // should have been charged.
46249
46423
  [/rate limit(ed| exceeded|s? reached)\b.*\btry again/i, "the account is rate limited"]
46250
46424
  ];
46251
46425
  num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
@@ -46293,12 +46467,12 @@ var init_pacing = __esm({
46293
46467
  });
46294
46468
 
46295
46469
  // src/worktree.ts
46296
- import { execFile } from "node:child_process";
46297
- import { mkdir, readdir, rm, stat } from "node:fs/promises";
46298
- import { join } from "node:path";
46299
- import { promisify } from "node:util";
46470
+ import { execFile as execFile2 } from "node:child_process";
46471
+ import { mkdir as mkdir2, readdir as readdir2, rm as rm2, stat } from "node:fs/promises";
46472
+ import { join as join2 } from "node:path";
46473
+ import { promisify as promisify2 } from "node:util";
46300
46474
  async function git(repo, args) {
46301
- const { stdout } = await exec("git", ["-C", repo, ...args], { maxBuffer: 8 * 1024 * 1024 });
46475
+ const { stdout } = await exec2("git", ["-C", repo, ...args], { maxBuffer: 8 * 1024 * 1024 });
46302
46476
  return stdout.trim();
46303
46477
  }
46304
46478
  async function hasRef(repo, ref) {
@@ -46337,16 +46511,16 @@ async function remoteUrl(repo, remote) {
46337
46511
  }
46338
46512
  async function createWorktree(ref, config3) {
46339
46513
  const branch = branchFor(ref);
46340
- const path = join(config3.root, ref);
46341
- await mkdir(config3.root, { recursive: true });
46342
- await rm(path, { recursive: true, force: true });
46514
+ const path = join2(config3.root, ref);
46515
+ await mkdir2(config3.root, { recursive: true });
46516
+ await rm2(path, { recursive: true, force: true });
46343
46517
  await git(config3.repo, ["worktree", "prune"]);
46344
46518
  const existing = await hasRef(config3.repo, branch);
46345
46519
  const startPoint = existing ? [branch] : ["-b", branch, config3.base];
46346
46520
  await git(config3.repo, ["worktree", "add", path, ...startPoint]);
46347
46521
  const startCommit = await git(config3.repo, ["rev-parse", branch]);
46348
46522
  if (config3.prepare) {
46349
- await exec("/bin/sh", ["-c", config3.prepare], {
46523
+ await exec2("/bin/sh", ["-c", config3.prepare], {
46350
46524
  cwd: path,
46351
46525
  maxBuffer: 32 * 1024 * 1024,
46352
46526
  timeout: config3.prepareTimeoutMs ?? 15 * 6e4,
@@ -46357,10 +46531,28 @@ async function createWorktree(ref, config3) {
46357
46531
  const out = await git(config3.repo, ["rev-list", "--count", `${startCommit}..${branch}`]);
46358
46532
  return Number(out) || 0;
46359
46533
  };
46534
+ const isDirty = async () => (await git(path, ["status", "--porcelain"])).length > 0;
46360
46535
  return {
46361
46536
  path,
46362
46537
  branch,
46363
46538
  commits: countCommits,
46539
+ dirty: isDirty,
46540
+ async saveWip(reason) {
46541
+ if (!await isDirty()) return null;
46542
+ await git(path, ["add", "-A"]);
46543
+ const staged = await git(path, ["diff", "--cached", "--name-only"]);
46544
+ if (staged.length === 0) return null;
46545
+ const message = `wip(${ref}): saved by the supervisor when the run was cut short
46546
+
46547
+ The harness was ${reason} with this in the tree. NOT a handoff: the
46548
+ agent did not choose to commit it and nothing here has been reviewed.
46549
+ The next attempt on this card starts from here rather than from scratch.
46550
+
46551
+ Cruo-Wip: ${reason}
46552
+ `;
46553
+ await git(path, ["commit", "--no-verify", "-m", message]);
46554
+ return await git(path, ["rev-parse", "HEAD"]);
46555
+ },
46364
46556
  async push(remote) {
46365
46557
  assertPublishable(branch);
46366
46558
  if (await countCommits() === 0 && !await hasRef(config3.repo, `${remote}/${branch}`)) {
@@ -46371,7 +46563,7 @@ async function createWorktree(ref, config3) {
46371
46563
  },
46372
46564
  async remove() {
46373
46565
  await git(config3.repo, ["worktree", "remove", "--force", path]).catch(async () => {
46374
- await rm(path, { recursive: true, force: true });
46566
+ await rm2(path, { recursive: true, force: true });
46375
46567
  });
46376
46568
  await git(config3.repo, ["worktree", "prune"]);
46377
46569
  }
@@ -46381,14 +46573,14 @@ async function sweep(config3, maxRunMs = 0) {
46381
46573
  await git(config3.repo, ["worktree", "prune"]);
46382
46574
  let entries;
46383
46575
  try {
46384
- entries = await readdir(config3.root);
46576
+ entries = await readdir2(config3.root);
46385
46577
  } catch {
46386
46578
  return [];
46387
46579
  }
46388
46580
  const cutoff = Date.now() - Math.max(maxRunMs + 5 * 6e4, 60 * 6e4);
46389
46581
  const removed = [];
46390
46582
  for (const name of entries) {
46391
- const path = join(config3.root, name);
46583
+ const path = join2(config3.root, name);
46392
46584
  let touched;
46393
46585
  try {
46394
46586
  touched = (await stat(path)).mtimeMs;
@@ -46397,18 +46589,18 @@ async function sweep(config3, maxRunMs = 0) {
46397
46589
  }
46398
46590
  if (touched >= cutoff) continue;
46399
46591
  await git(config3.repo, ["worktree", "remove", "--force", path]).catch(async () => {
46400
- await rm(path, { recursive: true, force: true });
46592
+ await rm2(path, { recursive: true, force: true });
46401
46593
  });
46402
46594
  removed.push(name);
46403
46595
  }
46404
46596
  if (removed.length) await git(config3.repo, ["worktree", "prune"]);
46405
46597
  return removed;
46406
46598
  }
46407
- var exec;
46599
+ var exec2;
46408
46600
  var init_worktree = __esm({
46409
46601
  "src/worktree.ts"() {
46410
46602
  "use strict";
46411
- exec = promisify(execFile);
46603
+ exec2 = promisify2(execFile2);
46412
46604
  }
46413
46605
  });
46414
46606
 
@@ -46416,9 +46608,9 @@ var init_worktree = __esm({
46416
46608
  var supervisor_exports = {};
46417
46609
  import { spawn } from "node:child_process";
46418
46610
  import { createRequire } from "node:module";
46419
- import { mkdtemp, readdir as readdir2, readFile, rm as rm2, stat as stat2, writeFile } from "node:fs/promises";
46420
- import { homedir, tmpdir } from "node:os";
46421
- import { join as join2 } from "node:path";
46611
+ import { mkdtemp, readdir as readdir3, readFile as readFile2, rm as rm3, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
46612
+ import { homedir as homedir2, tmpdir } from "node:os";
46613
+ import { join as join3 } from "node:path";
46422
46614
  import { fileURLToPath } from "node:url";
46423
46615
  async function identify(ctx) {
46424
46616
  const { data: member, error: error51 } = await ctx.client.schema("public").from("members").select("kind, job_function, capabilities").eq("workspace_id", ctx.workspaceId).eq("user_id", ctx.userId).maybeSingle();
@@ -46443,7 +46635,7 @@ function resolveWorktreeRoot(identity) {
46443
46635
  if (options.worktreeRoot) return options.worktreeRoot;
46444
46636
  const slug = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
46445
46637
  const name = slug(identity.name) || slug(identity.email.split("@")[0] ?? "") || "agent";
46446
- return join2(homedir(), ".cruo-work", name);
46638
+ return join3(homedir2(), ".cruo-work", name);
46447
46639
  }
46448
46640
  async function pollAssigned(ctx) {
46449
46641
  const { data: issues, error: error51 } = await ctx.client.from("issues").select("*").eq("assignee_id", ctx.userId).order("created_at", { ascending: true });
@@ -46591,13 +46783,13 @@ async function markRead(ctx, notificationIds) {
46591
46783
  }
46592
46784
  async function writeMcpConfig() {
46593
46785
  const session = await childSession();
46594
- const dir = await mkdtemp(join2(tmpdir(), CONFIG_DIR_PREFIX));
46595
- const path = join2(dir, "mcp.json");
46786
+ const dir = await mkdtemp(join3(tmpdir(), CONFIG_DIR_PREFIX));
46787
+ const path = join3(dir, "mcp.json");
46596
46788
  const here = fileURLToPath(new URL(".", import.meta.url));
46597
46789
  const bundled = /[/\\]dist[/\\]?$/.test(here);
46598
- const server = join2(here, bundled ? "index.js" : "index.ts");
46790
+ const server = join3(here, bundled ? "index.js" : "index.ts");
46599
46791
  const loader = bundled ? null : createRequire(import.meta.url).resolve("tsx");
46600
- await writeFile(
46792
+ await writeFile2(
46601
46793
  path,
46602
46794
  JSON.stringify(
46603
46795
  {
@@ -46621,7 +46813,7 @@ async function writeMcpConfig() {
46621
46813
  ),
46622
46814
  { mode: 384 }
46623
46815
  );
46624
- return { path, cleanup: () => rm2(dir, { recursive: true, force: true }) };
46816
+ return { path, cleanup: () => rm3(dir, { recursive: true, force: true }) };
46625
46817
  }
46626
46818
  async function sweepStaleConfigs() {
46627
46819
  const dir = tmpdir();
@@ -46629,18 +46821,18 @@ async function sweepStaleConfigs() {
46629
46821
  const cutoff = Date.now() - Math.max(maxRunMs + 5 * 6e4, 60 * 6e4);
46630
46822
  let entries;
46631
46823
  try {
46632
- entries = await readdir2(dir);
46824
+ entries = await readdir3(dir);
46633
46825
  } catch {
46634
46826
  return 0;
46635
46827
  }
46636
46828
  let removed = 0;
46637
46829
  for (const name of entries) {
46638
46830
  if (!name.startsWith(CONFIG_DIR_PREFIX)) continue;
46639
- const path = join2(dir, name);
46831
+ const path = join3(dir, name);
46640
46832
  try {
46641
46833
  const info = await stat2(path);
46642
46834
  if (info.mtimeMs >= cutoff) continue;
46643
- await rm2(path, { recursive: true, force: true });
46835
+ await rm3(path, { recursive: true, force: true });
46644
46836
  removed += 1;
46645
46837
  } catch {
46646
46838
  }
@@ -46650,7 +46842,7 @@ async function sweepStaleConfigs() {
46650
46842
  async function preflight() {
46651
46843
  const { path: mcpConfig, cleanup } = await writeMcpConfig();
46652
46844
  try {
46653
- const parsed = JSON.parse(await readFile(mcpConfig, "utf8"));
46845
+ const parsed = JSON.parse(await readFile2(mcpConfig, "utf8"));
46654
46846
  const { command, args, env } = parsed.mcpServers.cruo;
46655
46847
  await new Promise((resolve2, reject) => {
46656
46848
  const child = spawn(command, args, {
@@ -46778,9 +46970,14 @@ function userPrompt(hit) {
46778
46970
  `which your function owns. Pick it up and do your part.`
46779
46971
  ].join(" ");
46780
46972
  }
46973
+ function headFor(head2) {
46974
+ const flat = head2.replace(/\s+/g, " ").trim();
46975
+ return flat === "" ? null : flat.slice(0, HEAD_STORED);
46976
+ }
46781
46977
  async function invokeHarness(ctx, identity, hit, worktree) {
46782
46978
  const cwd = worktree?.path ?? options.cwd;
46783
46979
  const { path: mcpConfig, cleanup } = await writeMcpConfig();
46980
+ const streaming = options.usage === true;
46784
46981
  const args = [
46785
46982
  "-p",
46786
46983
  userPrompt(hit),
@@ -46794,7 +46991,11 @@ async function invokeHarness(ctx, identity, hit, worktree) {
46794
46991
  options.allow,
46795
46992
  "--system-prompt",
46796
46993
  systemPrompt(ctx, identity, worktree),
46797
- ...options.model ? ["--model", options.model] : []
46994
+ ...options.model ? ["--model", options.model] : [],
46995
+ // `--verbose` is not optional alongside stream-json: without it the harness
46996
+ // emits only the final result and the live view is a blank terminal for the
46997
+ // length of the run.
46998
+ ...streaming ? ["--output-format", "stream-json", "--verbose"] : []
46798
46999
  ];
46799
47000
  try {
46800
47001
  return await new Promise((resolve2) => {
@@ -46810,14 +47011,34 @@ async function invokeHarness(ctx, identity, hit, worktree) {
46810
47011
  let stdoutBytes = 0;
46811
47012
  let tail = "";
46812
47013
  let head2 = "";
47014
+ let pending = "";
47015
+ const addHead = (text) => {
47016
+ if (head2.length < 2048) head2 += text.slice(0, 2048 - head2.length);
47017
+ };
46813
47018
  child.stdout?.on("data", (c) => {
46814
47019
  stdoutBytes += c.length;
46815
- if (head2.length < 2048) head2 += c.toString("utf8").slice(0, 2048 - head2.length);
46816
- tail = (tail + c.toString("utf8")).slice(-4096);
46817
- process.stdout.write(c);
47020
+ const chunk = c.toString("utf8");
47021
+ if (!streaming) {
47022
+ addHead(chunk);
47023
+ tail = (tail + chunk).slice(-4096);
47024
+ process.stdout.write(c);
47025
+ return;
47026
+ }
47027
+ pending += chunk;
47028
+ const lines = pending.split("\n");
47029
+ pending = lines.pop() ?? "";
47030
+ for (const line of lines) {
47031
+ if (line.trim() === "") continue;
47032
+ tail = line;
47033
+ const rendered = renderEvent(line);
47034
+ if (rendered.text) addHead(rendered.text + "\n");
47035
+ if (rendered.show) process.stdout.write(rendered.show + "\n");
47036
+ }
46818
47037
  });
46819
47038
  child.stderr?.on("data", (c) => process.stderr.write(c));
47039
+ let killed = false;
46820
47040
  const timer = setTimeout(() => {
47041
+ killed = true;
46821
47042
  log(` harness exceeded ${options.harnessTimeoutMs / 1e3}s \u2014 killing it`);
46822
47043
  child.kill("SIGTERM");
46823
47044
  setTimeout(() => child.kill("SIGKILL"), 1e4).unref();
@@ -46830,17 +47051,28 @@ async function invokeHarness(ctx, identity, hit, worktree) {
46830
47051
  stdoutBytes,
46831
47052
  refusal: refusalIn(head2),
46832
47053
  durationMs: Date.now() - startedAtMs,
46833
- usage: null
47054
+ usage: null,
47055
+ killed,
47056
+ stdoutHead: headFor(head2)
46834
47057
  });
46835
47058
  });
46836
47059
  child.on("close", (code) => {
46837
47060
  clearTimeout(timer);
47061
+ if (streaming && pending.trim() !== "") {
47062
+ tail = pending;
47063
+ const rendered = renderEvent(pending);
47064
+ if (rendered.text) addHead(rendered.text + "\n");
47065
+ if (rendered.show) process.stdout.write(rendered.show + "\n");
47066
+ pending = "";
47067
+ }
46838
47068
  resolve2({
46839
47069
  code: code ?? 1,
46840
47070
  stdoutBytes,
46841
47071
  refusal: refusalIn(head2),
46842
47072
  durationMs: Date.now() - startedAtMs,
46843
- usage: usageIn(tail)
47073
+ usage: usageIn(tail),
47074
+ killed,
47075
+ stdoutHead: headFor(head2)
46844
47076
  });
46845
47077
  });
46846
47078
  });
@@ -46882,8 +47114,8 @@ async function clearAttempts(ctx, hit) {
46882
47114
  const { error: error51 } = await ctx.client.from("issue_attempts").delete().eq("issue_id", hit.issue.id).eq("agent_id", ctx.userId).eq("reason", hit.reason);
46883
47115
  if (error51) log(` could not clear attempts on ${hit.ref}: ${error51.message}`);
46884
47116
  }
46885
- async function repliedSince(ctx, hit, since) {
46886
- const { count, error: error51 } = await ctx.client.from("comments").select("id", { count: "exact", head: true }).eq("issue_id", hit.issue.id).eq("author_id", ctx.userId).gt("created_at", since);
47117
+ async function repliedSince(ctx, hit, since2) {
47118
+ const { count, error: error51 } = await ctx.client.from("comments").select("id", { count: "exact", head: true }).eq("issue_id", hit.issue.id).eq("author_id", ctx.userId).gt("created_at", since2);
46887
47119
  if (error51) {
46888
47120
  log(` could not check for a reply on ${hit.ref}: ${error51.message}`);
46889
47121
  return false;
@@ -46918,11 +47150,29 @@ async function beat(ctx, fields) {
46918
47150
  last_seen_at: (/* @__PURE__ */ new Date()).toISOString(),
46919
47151
  holding: fields.holding,
46920
47152
  dead_ticks: fields.deadTicks,
46921
- poll_interval_ms: fields.intervalMs
47153
+ poll_interval_ms: fields.intervalMs,
47154
+ // CRA-102. Without this the board cannot tell an agent someone paused
47155
+ // from one that is wading through a backlog, nor one stopped on purpose
47156
+ // from one whose laptop shut its lid — all of them simply went quiet.
47157
+ status: fields.status ?? (paused ? "paused" : "running")
46922
47158
  },
46923
47159
  { onConflict: "user_id,workspace_id" }
46924
47160
  );
46925
47161
  if (error51) log(` could not record a heartbeat: ${error51.message}`);
47162
+ lastHolding = fields.holding;
47163
+ }
47164
+ function announce(status) {
47165
+ void (async () => {
47166
+ try {
47167
+ await beat(await getContext(), {
47168
+ holding: lastHolding,
47169
+ deadTicks: 0,
47170
+ intervalMs: options.intervalMs,
47171
+ status
47172
+ });
47173
+ } catch {
47174
+ }
47175
+ })();
46926
47176
  }
46927
47177
  async function recordRun(ctx, hit, run, outcome) {
46928
47178
  const { error: error51 } = await ctx.client.from("harness_runs").insert({
@@ -46933,6 +47183,7 @@ async function recordRun(ctx, hit, run, outcome) {
46933
47183
  duration_ms: run.durationMs,
46934
47184
  exit_code: run.code,
46935
47185
  stdout_bytes: run.stdoutBytes,
47186
+ stdout_head: run.stdoutHead,
46936
47187
  outcome,
46937
47188
  model: run.usage?.model ?? null,
46938
47189
  input_tokens: run.usage?.inputTokens ?? null,
@@ -47029,9 +47280,28 @@ async function tick(ctx, identity, deadTicks, allowance) {
47029
47280
  }
47030
47281
  let run;
47031
47282
  let committed = 0;
47283
+ let salvaged = null;
47032
47284
  try {
47033
47285
  run = await invokeHarness(ctx, identity, hit, worktree);
47034
47286
  if (worktree) committed = await worktree.commits();
47287
+ if (worktree && run.killed) {
47288
+ try {
47289
+ salvaged = await worktree.saveWip("killed at the harness timeout");
47290
+ if (salvaged) {
47291
+ log(
47292
+ ` ${hit.ref} killed with an uncommitted tree \u2014 saved as ${salvaged.slice(0, 8)} on ${worktree.branch}; the next attempt resumes from it`
47293
+ );
47294
+ }
47295
+ } catch (error51) {
47296
+ log(
47297
+ ` ${hit.ref} could not save the killed run's tree: ${error51 instanceof Error ? error51.message.split("\n")[0] : String(error51)}`
47298
+ );
47299
+ }
47300
+ } else if (worktree && committed === 0 && await worktree.dirty()) {
47301
+ log(
47302
+ ` ${hit.ref} exited ${run.code} leaving uncommitted changes \u2014 they go with the worktree${options.keepFailed ? " (kept: --keep-failed)" : "; use --keep-failed to inspect them"}`
47303
+ );
47304
+ }
47035
47305
  } finally {
47036
47306
  await release(ctx, hit);
47037
47307
  }
@@ -47071,7 +47341,8 @@ async function tick(ctx, identity, deadTicks, allowance) {
47071
47341
  }
47072
47342
  } else {
47073
47343
  const what = hit.reason === "mention" ? "no reply written" : "unchanged";
47074
- const n2 = await recordAttempt(ctx, hit, `${what} (exit ${code})`, attemptsFor(hit));
47344
+ const note = salvaged ? `${what} (exit ${code}); cut short, work saved as ${salvaged.slice(0, 8)}` : `${what} (exit ${code})`;
47345
+ const n2 = await recordAttempt(ctx, hit, note, attemptsFor(hit));
47075
47346
  log(
47076
47347
  ` ${hit.ref} ${what} after the run (exit ${code}) \u2014 attempt ${n2}/${options.maxAttempts}` + (n2 >= options.maxAttempts ? "; setting it aside, a human should look" : "")
47077
47348
  );
@@ -47100,6 +47371,35 @@ async function tick(ctx, identity, deadTicks, allowance) {
47100
47371
  });
47101
47372
  return { invoked, failedToRun, suppressed: false, waiting: actionable.length };
47102
47373
  }
47374
+ function installControls() {
47375
+ const requestStop = (why) => {
47376
+ if (stopping && Date.now() - stopRequestedAt < 500) return;
47377
+ if (stopping) {
47378
+ log(` ${why} again \u2014 leaving now; a harness still running will be orphaned`);
47379
+ process.exit(130);
47380
+ }
47381
+ stopping = true;
47382
+ stopRequestedAt = Date.now();
47383
+ log(` ${why} \u2014 finishing the current run, then stopping. Again to leave now.`);
47384
+ };
47385
+ process.on("SIGTERM", () => requestStop("stop requested"));
47386
+ process.on("SIGINT", () => requestStop("interrupted"));
47387
+ process.on("SIGUSR1", () => {
47388
+ if (paused) return;
47389
+ paused = true;
47390
+ log(` paused \u2014 still polling and still reporting, but starting no new runs`);
47391
+ announce("paused");
47392
+ });
47393
+ process.on("SIGUSR2", () => {
47394
+ if (!paused) return;
47395
+ paused = false;
47396
+ log(` resumed \u2014 taking work again`);
47397
+ announce("running");
47398
+ });
47399
+ }
47400
+ function identityName() {
47401
+ return safeName(process.env.CRUO_IDENTITY?.trim() || "default");
47402
+ }
47103
47403
  async function main() {
47104
47404
  log(
47105
47405
  `board: ${config2.url}${config2.url === CRUO_CLOUD.apiUrl ? " (Cruo cloud)" : " (configured)"}`
@@ -47113,6 +47413,18 @@ async function main() {
47113
47413
  }
47114
47414
  const ctx = await contextOrExplain();
47115
47415
  const identity = await identify(ctx);
47416
+ installControls();
47417
+ const runName = identityName();
47418
+ if (!options.once && !options.dryRun) {
47419
+ await writeRecord({
47420
+ agent: runName,
47421
+ pid: process.pid,
47422
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
47423
+ procStartedAt: await processStartedAt(process.pid),
47424
+ log: join3(logDir(), `${runName}.log`),
47425
+ argv: process.argv.slice(2)
47426
+ });
47427
+ }
47116
47428
  try {
47117
47429
  await preflight();
47118
47430
  } catch (error51) {
@@ -47166,7 +47478,7 @@ async function main() {
47166
47478
  await getContext(),
47167
47479
  identity,
47168
47480
  pacing.deadTicks,
47169
- allowanceAt(pacing, Date.now())
47481
+ paused ? "held" : allowanceAt(pacing, Date.now())
47170
47482
  );
47171
47483
  if (result.suppressed) {
47172
47484
  const now = Date.now();
@@ -47193,10 +47505,12 @@ async function main() {
47193
47505
  log(`poll error: ${e instanceof Error ? e.message : String(e)}`);
47194
47506
  }
47195
47507
  if (options.once) return;
47508
+ if (stopping) return;
47196
47509
  await new Promise((r) => setTimeout(r, options.intervalMs));
47510
+ if (stopping) return;
47197
47511
  }
47198
47512
  }
47199
- var argv, flag, opt, num2, ms, readOptions, options, log, worktreeConfig, lastRefusal, PRIORITY_RANK, CONFIG_DIR_PREFIX, CRUO_OWNED_ENV, warnedAboutSpendTable;
47513
+ 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;
47200
47514
  var init_supervisor = __esm({
47201
47515
  "src/supervisor.ts"() {
47202
47516
  "use strict";
@@ -47206,6 +47520,7 @@ var init_supervisor = __esm({
47206
47520
  init_auth();
47207
47521
  init_options();
47208
47522
  init_harness_signal();
47523
+ init_process();
47209
47524
  init_pacing();
47210
47525
  init_worktree();
47211
47526
  argv = process.argv.slice(2);
@@ -47250,14 +47565,40 @@ var init_supervisor = __esm({
47250
47565
  * supervisor down with it. Observed in the first live run: a harness that
47251
47566
  * produced no output and no writes for ten minutes. A killed run counts as
47252
47567
  * unproductive like any other, so the retry/set-aside path handles it.
47568
+ *
47569
+ * THIRTY MINUTES, and the number is a decision rather than a guess — the two
47570
+ * mistakes this sits between are nowhere near the same size (`CRA-105`).
47571
+ * Killing a wedged run late costs wall clock the supervisor was not using
47572
+ * anyway. Killing a WORKING run early costs everything it did: the checkout
47573
+ * is recreated from scratch on the next attempt, so an uncommitted tree is
47574
+ * discarded rather than resumed (`CRA-104`). Ten minutes was tuned on the
47575
+ * cards the loop was first exercised on, and `CRA-83` — four packages and a
47576
+ * test file — was killed at it twice, having written real work both times
47577
+ * and committed neither. Err long: the cheap failure is the slow one.
47253
47578
  */
47254
- harnessTimeoutMs: ms("harness-timeout", 600),
47579
+ harnessTimeoutMs: ms("harness-timeout", 1800),
47255
47580
  /**
47256
47581
  * Give up on an issue after this many unproductive runs. Persisted in
47257
47582
  * `pm.issue_attempts` since CRA-7 — it was in-memory, which meant a restart
47258
47583
  * forgot every set-aside and nothing outside the process could see one.
47259
47584
  */
47260
47585
  maxAttempts: num2("max-attempts", { fallback: 3, integer: true }),
47586
+ /**
47587
+ * Ask the harness to report what each run spent — `CRA-89`.
47588
+ *
47589
+ * Off by default and that is a real choice, not caution. It appends
47590
+ * `--output-format stream-json --verbose`, which are Claude Code's flags, and
47591
+ * the supervisor's whole design is that `--harness` takes any command that
47592
+ * accepts a prompt and exits. Turning this on says "my harness is Claude Code
47593
+ * shaped"; leaving it off keeps the promise.
47594
+ *
47595
+ * What it costs: the harness's stdout stops being prose and becomes NDJSON,
47596
+ * so the live view is reconstructed by `renderEvent` rather than passed
47597
+ * through. What it buys is the only numbers there are — without it every
47598
+ * token column in `pm.harness_runs` is null, which is what 8,335 rows of it
47599
+ * were.
47600
+ */
47601
+ usage: flag("usage"),
47261
47602
  /**
47262
47603
  * Ceiling on how long invocation is held back after a run of dead ticks.
47263
47604
  * Ten minutes: long enough that a broken harness stops being spawned over and
@@ -47350,9 +47691,31 @@ cruo-supervisor: ${error51.message}
47350
47691
  // stands in for. Cheap to list, and wrong to discover the hard way.
47351
47692
  "CRUO_SESSION"
47352
47693
  ];
47694
+ HEAD_STORED = 200;
47695
+ lastHolding = 0;
47353
47696
  warnedAboutSpendTable = false;
47354
- main().catch((e) => {
47697
+ stopping = false;
47698
+ paused = false;
47699
+ stopRequestedAt = 0;
47700
+ main().then(async () => {
47701
+ if (!options.once && !options.dryRun) {
47702
+ try {
47703
+ const ctx = await getContext();
47704
+ await beat(ctx, {
47705
+ holding: 0,
47706
+ deadTicks: 0,
47707
+ intervalMs: options.intervalMs,
47708
+ status: "stopped"
47709
+ });
47710
+ } catch {
47711
+ }
47712
+ await removeRecord(identityName()).catch(() => {
47713
+ });
47714
+ }
47715
+ }).catch(async (e) => {
47355
47716
  console.error(e instanceof Error ? e.message : e);
47717
+ await removeRecord(identityName()).catch(() => {
47718
+ });
47356
47719
  process.exit(1);
47357
47720
  });
47358
47721
  }
@@ -47362,11 +47725,12 @@ cruo-supervisor: ${error51.message}
47362
47725
  init_dist();
47363
47726
  init_dist7();
47364
47727
  init_options();
47365
- import { chmod, mkdir as mkdir2, readFile as readFile2, rm as rm3, writeFile as writeFile2 } from "node:fs/promises";
47366
- import { homedir as homedir2 } from "node:os";
47367
- import { join as join3 } from "node:path";
47368
- var CONFIG_DIR = join3(homedir2(), ".cruo");
47369
- var CONFIG_PATH = join3(CONFIG_DIR, "config.json");
47728
+ init_process();
47729
+ import { chmod, mkdir as mkdir3, readFile as readFile3, rm as rm4, writeFile as writeFile3 } from "node:fs/promises";
47730
+ import { homedir as homedir3 } from "node:os";
47731
+ import { join as join4 } from "node:path";
47732
+ var CONFIG_DIR = join4(homedir3(), ".cruo");
47733
+ var CONFIG_PATH = join4(CONFIG_DIR, "config.json");
47370
47734
  function nameFromEmail(email3) {
47371
47735
  const local = email3.split("@")[0]?.toLowerCase().replace(/[^a-z0-9._-]/g, "");
47372
47736
  return local && local.length > 0 ? local : null;
@@ -47377,9 +47741,22 @@ var USAGE = `cruo \u2014 run a Cruo agent
47377
47741
  cruo agents list what is remembered (never the tokens)
47378
47742
  cruo usage [--days 7] what the agents have been spending
47379
47743
  cruo logout [name] forget one, or --all
47380
- cruo [options] run the agent's supervisor
47744
+ cruo [options] run the agent's supervisor, in this terminal
47381
47745
  cruo --as <name> \u2026 run a particular one
47382
47746
 
47747
+ Running one in the background
47748
+ cruo start [options] same, detached \u2014 survives closing the terminal
47749
+ cruo ps what is running on this machine
47750
+ cruo stop [name] ask it to finish the run it is on, then exit
47751
+ cruo pause [name] keep watching the board, start nothing new
47752
+ cruo resume [name] take work again
47753
+ cruo logs [name] [-f] what a detached agent has been saying
47754
+ \u2026 --all stop/pause/resume every one of them
47755
+
47756
+ \`stop\` is a request, not a kill: a harness that is mid-run is allowed to
47757
+ finish, so the work it has done is not thrown away. Say it twice to give up
47758
+ waiting. macOS and Linux only \u2014 these are signals and pid files.
47759
+
47383
47760
  Getting a token
47384
47761
  Cruo \u2192 Settings \u2192 Members \u2192 Add an agent. It is shown once.
47385
47762
 
@@ -47391,6 +47768,7 @@ Common options
47391
47768
  --allow <tools> what the harness may use; default is the board only
47392
47769
  --push publish the branch after a run that commits
47393
47770
  --interval <seconds> how often to poll (default 20)
47771
+ --usage record what each run costs (needs a Claude Code harness)
47394
47772
 
47395
47773
  Full list and what each one is for: https://cruo.space/docs#agents
47396
47774
 
@@ -47404,7 +47782,7 @@ var EMPTY = { version: 2, default: null, agents: {} };
47404
47782
  async function readConfig2() {
47405
47783
  let parsed;
47406
47784
  try {
47407
- parsed = JSON.parse(await readFile2(CONFIG_PATH, "utf8"));
47785
+ parsed = JSON.parse(await readFile3(CONFIG_PATH, "utf8"));
47408
47786
  } catch {
47409
47787
  return { ...EMPTY };
47410
47788
  }
@@ -47423,8 +47801,8 @@ async function readConfig2() {
47423
47801
  return { ...EMPTY };
47424
47802
  }
47425
47803
  async function writeConfig(config3) {
47426
- await mkdir2(CONFIG_DIR, { recursive: true, mode: 448 });
47427
- await writeFile2(CONFIG_PATH, JSON.stringify(config3, null, 2) + "\n", { mode: 384 });
47804
+ await mkdir3(CONFIG_DIR, { recursive: true, mode: 448 });
47805
+ await writeFile3(CONFIG_PATH, JSON.stringify(config3, null, 2) + "\n", { mode: 384 });
47428
47806
  await chmod(CONFIG_PATH, 384);
47429
47807
  }
47430
47808
  async function identify2(token) {
@@ -47484,7 +47862,7 @@ async function logout(which, all) {
47484
47862
  const config3 = await readConfig2();
47485
47863
  const names = Object.keys(config3.agents);
47486
47864
  if (all) {
47487
- await rm3(CONFIG_PATH, { force: true });
47865
+ await rm4(CONFIG_PATH, { force: true });
47488
47866
  console.log(
47489
47867
  names.length === 0 ? `
47490
47868
  Nothing was stored.
@@ -47496,7 +47874,7 @@ Forgot ${names.length} token(s): ${names.join(", ")}. ${CONFIG_PATH} is gone.
47496
47874
  }
47497
47875
  if (!which) {
47498
47876
  if (names.length <= 1) {
47499
- await rm3(CONFIG_PATH, { force: true });
47877
+ await rm4(CONFIG_PATH, { force: true });
47500
47878
  console.log(
47501
47879
  names.length === 0 ? `
47502
47880
  Nothing was stored.
@@ -47552,6 +47930,156 @@ No tokens stored. \`cruo login <token>\` to add one.
47552
47930
  * is what a bare \`cruo\` runs. Use \`--as <name>\` for another.
47553
47931
  `);
47554
47932
  }
47933
+ async function targetName(explicit, asName) {
47934
+ if (explicit) return safeName(explicit);
47935
+ if (asName) return safeName(asName);
47936
+ const config3 = await readConfig2();
47937
+ return safeName(config3.default ?? "default");
47938
+ }
47939
+ async function targets(explicit, asName, all) {
47940
+ if (all) {
47941
+ const rows = await listRecords();
47942
+ return rows.filter((r) => r.live.state === "running").map((r) => r.live.record);
47943
+ }
47944
+ const name = await targetName(explicit, asName);
47945
+ const live = await liveness(name, true);
47946
+ if (live.state === "running") return [live.record];
47947
+ if (live.state === "gone") {
47948
+ console.error(`
47949
+ ${name} is not running (it left a record behind; cleared).
47950
+ `);
47951
+ } else if (live.state === "recycled") {
47952
+ console.error(`
47953
+ ${name} is not running \u2014 its pid now belongs to something else. Cleared.
47954
+ `);
47955
+ } else {
47956
+ console.error(`
47957
+ ${name} is not running. Start it with \`cruo start --as ${name}\`.
47958
+ `);
47959
+ }
47960
+ return [];
47961
+ }
47962
+ async function startDetached(argv2, asName) {
47963
+ const { spawn: spawn2 } = await import("node:child_process");
47964
+ const { open, mkdir: mkdir4 } = await import("node:fs/promises");
47965
+ const name = await targetName(null, asName);
47966
+ const already = await liveness(name, true);
47967
+ if (already.state === "running") {
47968
+ console.error(
47969
+ `
47970
+ ${name} is already running (pid ${already.record.pid}, up ${since(already.record.startedAt)}).
47971
+ Stop it first, or use --as <name> to run a different agent.
47972
+ `
47973
+ );
47974
+ process.exit(1);
47975
+ }
47976
+ await mkdir4(logDir(), { recursive: true });
47977
+ const logPath = join4(logDir(), `${name}.log`);
47978
+ const handle = await open(logPath, "a");
47979
+ const forwarded = argv2.slice(1).filter((a) => a !== "--all");
47980
+ const child = spawn2(process.execPath, [process.argv[1], ...forwarded], {
47981
+ detached: true,
47982
+ stdio: ["ignore", handle.fd, handle.fd],
47983
+ env: process.env
47984
+ });
47985
+ child.unref();
47986
+ await handle.close();
47987
+ console.log(
47988
+ `
47989
+ ${name} started (pid ${child.pid}).
47990
+
47991
+ logs cruo logs ${name} -f
47992
+ check cruo ps
47993
+ stop cruo stop ${name}
47994
+
47995
+ It survives this terminal closing. It does not survive the machine
47996
+ sleeping or shutting down \u2014 for that, see the launchd notes in
47997
+ https://cruo.space/docs#agents.
47998
+ `
47999
+ );
48000
+ }
48001
+ async function listProcesses() {
48002
+ const rows = await listRecords();
48003
+ const running = rows.filter((r) => r.live.state === "running");
48004
+ if (rows.length === 0) {
48005
+ console.log(`
48006
+ Nothing running. Start one with \`cruo start\`.
48007
+ `);
48008
+ return;
48009
+ }
48010
+ console.log("");
48011
+ console.log(" agent pid up started with");
48012
+ for (const { agent, live } of rows) {
48013
+ if (live.state === "running") {
48014
+ const flags = live.record.argv.filter((a) => a.startsWith("--")).join(" ") || "(no flags)";
48015
+ console.log(
48016
+ ` ${agent.padEnd(14)} ${String(live.record.pid).padEnd(8)} ${since(live.record.startedAt).padEnd(8)} ${flags}`
48017
+ );
48018
+ } else if (live.state === "gone") {
48019
+ console.log(` ${agent.padEnd(14)} \u2014 \u2014 died or was killed (record cleared)`);
48020
+ await removeRecord(agent);
48021
+ } else if (live.state === "recycled") {
48022
+ console.log(` ${agent.padEnd(14)} \u2014 \u2014 gone; its pid is now someone else's (cleared)`);
48023
+ await removeRecord(agent);
48024
+ }
48025
+ }
48026
+ console.log(
48027
+ `
48028
+ ${running.length} running. Whether each is actually picking up work is on the board, not here \u2014
48029
+ Cruo \u2192 Settings \u2192 Members shows what it is holding and when it last looked.
48030
+ `
48031
+ );
48032
+ }
48033
+ async function control(which, explicit, asName, all) {
48034
+ const records = await targets(explicit, asName, all);
48035
+ if (records.length === 0) {
48036
+ if (all) console.log(`
48037
+ Nothing running.
48038
+ `);
48039
+ process.exitCode = all ? 0 : 1;
48040
+ return;
48041
+ }
48042
+ for (const record2 of records) {
48043
+ const sent = signal(record2.pid, which);
48044
+ if (!sent) {
48045
+ console.error(` ${record2.agent}: could not signal pid ${record2.pid} \u2014 it may have just exited`);
48046
+ await removeRecord(record2.agent);
48047
+ continue;
48048
+ }
48049
+ if (which === "stop") {
48050
+ console.log(
48051
+ ` ${record2.agent}: asked to stop. It will finish the run it is on first \u2014
48052
+ a harness mid-run keeps its work, which is the point of asking rather than killing.
48053
+ Watch it leave with: cruo logs ${record2.agent} -f`
48054
+ );
48055
+ } else if (which === "pause") {
48056
+ console.log(` ${record2.agent}: paused. Still watching the board and still reporting; starts nothing new.`);
48057
+ } else {
48058
+ console.log(` ${record2.agent}: resumed.`);
48059
+ }
48060
+ }
48061
+ console.log("");
48062
+ }
48063
+ async function showLogs(explicit, asName, follow) {
48064
+ const { spawn: spawn2 } = await import("node:child_process");
48065
+ const name = await targetName(explicit, asName);
48066
+ const record2 = await readRecord(name);
48067
+ const path = record2?.log ?? join4(logDir(), `${name}.log`);
48068
+ const { access } = await import("node:fs/promises");
48069
+ try {
48070
+ await access(path);
48071
+ } catch {
48072
+ console.error(`
48073
+ No log for ${name} at ${path}.
48074
+ A detached run writes one; a foreground run prints to its terminal.
48075
+ `);
48076
+ process.exit(1);
48077
+ }
48078
+ const child = spawn2("tail", follow ? ["-f", "-n", "40", path] : ["-n", "200", path], {
48079
+ stdio: "inherit"
48080
+ });
48081
+ await new Promise((resolve2) => child.on("close", resolve2));
48082
+ }
47555
48083
  async function main2() {
47556
48084
  const argv2 = process.argv.slice(2);
47557
48085
  const [first] = argv2;
@@ -47574,12 +48102,25 @@ Usage: cruo login <token> [--as <name>]
47574
48102
  return logout(named, argv2.includes("--all"));
47575
48103
  }
47576
48104
  if (first === "agents" || first === "whoami") return listAgents();
48105
+ {
48106
+ const named = argv2[1] && !argv2[1].startsWith("--") ? argv2[1] : null;
48107
+ const all = argv2.includes("--all");
48108
+ const asName2 = flagValue("--as");
48109
+ if (first === "ps") return listProcesses();
48110
+ if (first === "stop") return control("stop", named, asName2, all);
48111
+ if (first === "pause") return control("pause", named, asName2, all);
48112
+ if (first === "resume") return control("resume", named, asName2, all);
48113
+ if (first === "logs") {
48114
+ return showLogs(named, asName2, argv2.includes("-f") || argv2.includes("--follow"));
48115
+ }
48116
+ }
47577
48117
  if (first === "usage") {
47578
48118
  }
47579
48119
  if (first === "help" || argv2.includes("--help") || argv2.includes("-h")) {
47580
48120
  console.log(USAGE);
47581
48121
  return;
47582
48122
  }
48123
+ if (first === "start") return startDetached(argv2, flagValue("--as"));
47583
48124
  const flagIndex = argv2.indexOf("--token");
47584
48125
  const fromFlag = flagIndex >= 0 ? argv2[flagIndex + 1] : void 0;
47585
48126
  if (fromFlag) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cruo-agent",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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",