cruo-agent 0.1.16 → 0.1.18

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
@@ -110,8 +110,9 @@ given up on work nobody abandoned.
110
110
  | `--worktree` | a checkout per card |
111
111
  | `--allow <tools>` | what the harness may use |
112
112
  | `--push` | publish the branch after a run that commits |
113
- | `--harness-timeout <seconds>` | kill a run that wedges (default 3000 — 50 minutes; keep it under an hour, which is how long a run's board session lasts) |
113
+ | `--harness-timeout <seconds>` | kill a run that wedges (default 3000 — 50 minutes). It used to have to stay under an hour, because that is how long a run's board session lasted; since 0.1.17 the session is renewed under a running card, so what this bounds now is spend, not credentials |
114
114
  | `--max-attempts <n>` | give up on a card after n unproductive runs (default 3) |
115
+ | `--max-cost <dollars>` | stop spending on one card once it has cost this much, and hand it to a human with what it has. Unset by default. Read from what the harness itself reported, so it only bites where those figures exist |
115
116
 
116
117
  The full list, and what each is for: <https://cruo.space/docs#agents>
117
118
 
package/dist/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.16
1
+ 0.1.18
package/dist/cli.js CHANGED
@@ -36803,6 +36803,7 @@ var init_dist6 = __esm({
36803
36803
  });
36804
36804
 
36805
36805
  // ../../packages/mcp-kit/dist/context.js
36806
+ import { readFileSync } from "node:fs";
36806
36807
  function decodeJwtExpiry(token) {
36807
36808
  const parts = token.split(".");
36808
36809
  if (parts.length !== 3)
@@ -36981,19 +36982,34 @@ function createRemoteContextResolver(options2) {
36981
36982
  return attempt;
36982
36983
  };
36983
36984
  }
36984
- function readDelegatedSession() {
36985
- const raw = process.env[DELEGATED_SESSION_ENV];
36986
- if (!raw || !raw.trim())
36985
+ function readSessionFile() {
36986
+ const path = process.env[DELEGATED_SESSION_FILE_ENV];
36987
+ if (!path || !path.trim())
36988
+ return null;
36989
+ let raw;
36990
+ try {
36991
+ raw = readFileSync(path.trim(), "utf8");
36992
+ } catch {
36993
+ return null;
36994
+ }
36995
+ return parseSession(raw, DELEGATED_SESSION_FILE_ENV, "lenient");
36996
+ }
36997
+ function parseSession(raw, source, mode) {
36998
+ if (!raw.trim())
36987
36999
  return null;
36988
37000
  let parsed;
36989
37001
  try {
36990
37002
  parsed = JSON.parse(raw);
36991
37003
  } catch {
36992
- throw new TokenRefused(`${DELEGATED_SESSION_ENV} is not valid JSON.`);
37004
+ if (mode === "strict")
37005
+ throw new TokenRefused(`${source} is not valid JSON.`);
37006
+ return null;
36993
37007
  }
36994
37008
  const s = parsed;
36995
37009
  if (!s.accessToken || !s.userId || !s.email || !s.workspaceId) {
36996
- throw new TokenRefused(`${DELEGATED_SESSION_ENV} is missing fields.`);
37010
+ if (mode === "strict")
37011
+ throw new TokenRefused(`${source} is missing fields.`);
37012
+ return null;
36997
37013
  }
36998
37014
  return {
36999
37015
  accessToken: s.accessToken,
@@ -37003,6 +37019,15 @@ function readDelegatedSession() {
37003
37019
  workspaceName: s.workspaceName ?? "workspace"
37004
37020
  };
37005
37021
  }
37022
+ function readDelegatedSession() {
37023
+ const fromFile = readSessionFile();
37024
+ if (fromFile)
37025
+ return fromFile;
37026
+ const raw = process.env[DELEGATED_SESSION_ENV];
37027
+ if (!raw)
37028
+ return null;
37029
+ return parseSession(raw, DELEGATED_SESSION_ENV, "strict");
37030
+ }
37006
37031
  function contextFromSession(schema, session) {
37007
37032
  return {
37008
37033
  client: createUserClientFor(session.accessToken, schema),
@@ -37030,7 +37055,7 @@ async function resolve(options2) {
37030
37055
  const principal = await authenticate({ config: config3, token, product, mintedAt });
37031
37056
  return mintContext({ config: config3, principal, schema });
37032
37057
  }
37033
- var TokenRefused, ASSUMED_SESSION_LIFETIME_MS, sessionInFlight, MINT_ATTEMPTS, DELEGATED_SESSION_ENV;
37058
+ var TokenRefused, ASSUMED_SESSION_LIFETIME_MS, sessionInFlight, MINT_ATTEMPTS, DELEGATED_SESSION_ENV, DELEGATED_SESSION_FILE_ENV;
37034
37059
  var init_context = __esm({
37035
37060
  "../../packages/mcp-kit/dist/context.js"() {
37036
37061
  "use strict";
@@ -37046,6 +37071,7 @@ var init_context = __esm({
37046
37071
  sessionInFlight = /* @__PURE__ */ new Map();
37047
37072
  MINT_ATTEMPTS = 3;
37048
37073
  DELEGATED_SESSION_ENV = "CRUO_SESSION";
37074
+ DELEGATED_SESSION_FILE_ENV = "CRUO_SESSION_FILE";
37049
37075
  }
37050
37076
  });
37051
37077
 
@@ -46048,6 +46074,7 @@ var init_options = __esm({
46048
46074
  "interval",
46049
46075
  "limit",
46050
46076
  "max-attempts",
46077
+ "max-cost",
46051
46078
  "max-interval",
46052
46079
  "model",
46053
46080
  "prepare",
@@ -46082,14 +46109,24 @@ function safeName(agent) {
46082
46109
  const cleaned = agent.toLowerCase().replace(/[^a-z0-9._-]/g, "").replace(/^\.+/, "");
46083
46110
  return cleaned === "" ? "default" : cleaned;
46084
46111
  }
46112
+ function isCanonicalProcStart(value) {
46113
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value);
46114
+ }
46085
46115
  async function processStartedAt(pid) {
46116
+ let raw;
46086
46117
  try {
46087
- const { stdout } = await exec("ps", ["-p", String(pid), "-o", "lstart="]);
46088
- const value = stdout.trim();
46089
- return value === "" ? null : value;
46118
+ const { stdout } = await exec("ps", ["-p", String(pid), "-o", "lstart="], { env: PS_ENV });
46119
+ raw = stdout.trim();
46090
46120
  } catch {
46091
46121
  return null;
46092
46122
  }
46123
+ if (raw === "") return null;
46124
+ const m = LSTART_PATTERN.exec(raw);
46125
+ if (!m) return null;
46126
+ const [, mon, day, hour, min, sec, year] = m;
46127
+ return new Date(
46128
+ Date.UTC(Number(year), LSTART_MONTHS.indexOf(mon), Number(day), Number(hour), Number(min), Number(sec))
46129
+ ).toISOString();
46093
46130
  }
46094
46131
  function isAlive(pid) {
46095
46132
  try {
@@ -46116,6 +46153,12 @@ async function readRecord(agent) {
46116
46153
  return null;
46117
46154
  }
46118
46155
  }
46156
+ async function looksLikeThisAgent(pid, agent) {
46157
+ const argv2 = await processArgv(pid);
46158
+ if (!argv2 || !entryPosition(argv2)) return false;
46159
+ const asName = asNameFrom(argv2);
46160
+ return asName === null || asName === agent;
46161
+ }
46119
46162
  async function liveness(agent, tidy = false) {
46120
46163
  const record2 = await readRecord(agent);
46121
46164
  if (!record2) return { state: "absent" };
@@ -46124,8 +46167,15 @@ async function liveness(agent, tidy = false) {
46124
46167
  return { state: "gone", record: record2 };
46125
46168
  }
46126
46169
  const now = await processStartedAt(record2.pid);
46170
+ if (record2.procStartedAt !== null && !isCanonicalProcStart(record2.procStartedAt)) {
46171
+ if (!await looksLikeThisAgent(record2.pid, record2.agent)) {
46172
+ return { state: "recycled", record: record2 };
46173
+ }
46174
+ const migrated = tidy && now !== null ? { ...record2, procStartedAt: now } : record2;
46175
+ if (migrated !== record2) await writeRecord(migrated);
46176
+ return { state: "running", record: migrated };
46177
+ }
46127
46178
  if (record2.procStartedAt !== null && now !== null && now !== record2.procStartedAt) {
46128
- if (tidy) await removeRecord(agent);
46129
46179
  return { state: "recycled", record: record2 };
46130
46180
  }
46131
46181
  return { state: "running", record: record2 };
@@ -46246,7 +46296,7 @@ function since(iso) {
46246
46296
  if (ms2 < 864e5) return `${Math.round(ms2 / 36e5)}h`;
46247
46297
  return `${Math.round(ms2 / 864e5)}d`;
46248
46298
  }
46249
- var exec, runDir, logDir, recordPath, CLI_BIN_NAMES, SUPERVISOR_BIN_NAMES, OWN_PACKAGE_MARKERS, NON_LOOPING_CLI_COMMANDS, NODE_FLAGS_WITH_VALUE, SIGNALS;
46299
+ var exec, runDir, logDir, recordPath, PS_ENV, LSTART_PATTERN, LSTART_MONTHS, CLI_BIN_NAMES, SUPERVISOR_BIN_NAMES, OWN_PACKAGE_MARKERS, NON_LOOPING_CLI_COMMANDS, NODE_FLAGS_WITH_VALUE, SIGNALS;
46250
46300
  var init_process = __esm({
46251
46301
  "src/process.ts"() {
46252
46302
  "use strict";
@@ -46255,6 +46305,9 @@ var init_process = __esm({
46255
46305
  runDir = () => join(homedir(), ".cruo", "run");
46256
46306
  logDir = () => join(homedir(), ".cruo", "logs");
46257
46307
  recordPath = (agent) => join(runDir(), `${safeName(agent)}.json`);
46308
+ PS_ENV = { ...process.env, LC_ALL: "C", LANG: "C", TZ: "UTC" };
46309
+ LSTART_PATTERN = /^\w{3}\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})$/;
46310
+ LSTART_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
46258
46311
  CLI_BIN_NAMES = /* @__PURE__ */ new Set(["cruo", "cruo-agent"]);
46259
46312
  SUPERVISOR_BIN_NAMES = /* @__PURE__ */ new Set(["cruo-supervisor"]);
46260
46313
  OWN_PACKAGE_MARKERS = ["@cruo/mcp", "cruo-agent", "apps/mcp", "packages/cli"];
@@ -46466,6 +46519,11 @@ predate cost recording (\`CRA-89\`, 2026-08-29).
46466
46519
  ` ${pad(nameOf(id), w)} ${rpad(String(a.runs), 5)} ${rpad(String(a.productive), 7)} ${rpad(String(a.neverRan), 5)} ${rpad(duration3(a.ms), 7)} ${rpad(compact(a.input + a.cacheRead), 8)} ${rpad(compact(a.output), 8)} ${rpad(openingAvg, 8)} ${rpad(a.cost > 0 ? `$${a.cost.toFixed(2)}` : "\u2014", 9)}`
46467
46520
  );
46468
46521
  }
46522
+ if (byAgent.size > 1) {
46523
+ console.log(
46524
+ ` ${pad("\u2014 total \u2014", w)} ${rpad(String(total.runs), 5)} ${rpad(String(total.productive), 7)} ${rpad(String(total.neverRan), 5)} ${rpad(duration3(total.ms), 7)} ${rpad(compact(total.input + total.cacheRead), 8)} ${rpad(compact(total.output), 8)} ${rpad("\u2014", 8)} ${rpad(total.cost > 0 ? `$${total.cost.toFixed(2)}` : "\u2014", 9)}`
46525
+ );
46526
+ }
46469
46527
  if (!opts.issue) {
46470
46528
  const byIssue = /* @__PURE__ */ new Map();
46471
46529
  for (const r of rows) {
@@ -46510,6 +46568,11 @@ predate cost recording (\`CRA-89\`, 2026-08-29).
46510
46568
  ${total.runs} run(s), ${total.productive} productive, ${total.neverRan} never reached a model.
46511
46569
  ${duration3(total.ms)} of wall clock, ${compact(total.bytes)} bytes of output.`
46512
46570
  );
46571
+ if (total.cacheRead > 0 || total.input > 0) {
46572
+ console.log(
46573
+ ` ${compact(total.cacheRead)} tokens read from cache, ${compact(total.input)} fresh input, ${compact(total.output)} written \u2014 the read figure is what the bill tracks.`
46574
+ );
46575
+ }
46513
46576
  if (total.measured === 0) {
46514
46577
  console.log(
46515
46578
  `
@@ -46571,6 +46634,7 @@ var init_usage = __esm({
46571
46634
  });
46572
46635
 
46573
46636
  // src/session-renewal.ts
46637
+ import { rename, writeFile as writeFile2 } from "node:fs/promises";
46574
46638
  async function withFreshSession(ctx, attempt, renewal) {
46575
46639
  const first = await attempt();
46576
46640
  if (!first.error || !isExpiredSessionError(first.error)) {
@@ -46600,12 +46664,23 @@ async function withFreshSession(ctx, attempt, renewal) {
46600
46664
  return first;
46601
46665
  }
46602
46666
  }
46603
- var warnedThisOutage;
46667
+ function sessionRefreshDue(expiresAtMs, nowMs, marginMs = SESSION_REFRESH_MARGIN_MS) {
46668
+ if (expiresAtMs === null) return true;
46669
+ return expiresAtMs - nowMs <= marginMs;
46670
+ }
46671
+ async function writeSessionFile(path, session) {
46672
+ const tmp = `${path}.${process.pid}.tmp`;
46673
+ await writeFile2(tmp, JSON.stringify(session), { mode: 384 });
46674
+ await rename(tmp, path);
46675
+ }
46676
+ var warnedThisOutage, SESSION_REFRESH_MARGIN_MS, SESSION_REFRESH_CHECK_MS;
46604
46677
  var init_session_renewal = __esm({
46605
46678
  "src/session-renewal.ts"() {
46606
46679
  "use strict";
46607
46680
  init_dist7();
46608
46681
  warnedThisOutage = false;
46682
+ SESSION_REFRESH_MARGIN_MS = 10 * 6e4;
46683
+ SESSION_REFRESH_CHECK_MS = 6e4;
46609
46684
  }
46610
46685
  });
46611
46686
 
@@ -46864,14 +46939,50 @@ var init_wake_clock = __esm({
46864
46939
  function blocksAgentPickup(state) {
46865
46940
  return state.requires_human === true;
46866
46941
  }
46942
+ function stillEligible(subject) {
46943
+ const { issue: issue2, state, project } = subject;
46944
+ if (!issue2) return quiet("no longer exists");
46945
+ if (!state || !project) return quiet("can no longer be read (state or project missing)");
46946
+ if (subject.onlyProject && project.key !== subject.onlyProject) {
46947
+ return quiet(`is not in --project ${subject.onlyProject}`);
46948
+ }
46949
+ if (blocksAgentPickup(state)) {
46950
+ return gate(`is in ${state.name}, which requires a human`);
46951
+ }
46952
+ if (subject.reason === "assigned") {
46953
+ if (issue2.assignee_id !== subject.agentId) {
46954
+ return quiet("is no longer assigned to this agent");
46955
+ }
46956
+ if (state.category === "completed" || state.category === "canceled") {
46957
+ return quiet(`is in ${state.name}, which is ${state.category}`);
46958
+ }
46959
+ const said = subject.lastSaidAt;
46960
+ if (said && issue2.updated_at && said > issue2.updated_at) {
46961
+ return quiet("has not changed since this agent last commented");
46962
+ }
46963
+ return { ok: true };
46964
+ }
46965
+ if (subject.reason === "state") {
46966
+ if (state.owner_function !== subject.jobFunction) {
46967
+ return quiet(`is in ${state.name}, which "${subject.jobFunction}" does not own`);
46968
+ }
46969
+ if (issue2.assignee_id !== null && issue2.assignee_id !== subject.agentId) {
46970
+ return quiet("is now assigned to someone else");
46971
+ }
46972
+ return { ok: true };
46973
+ }
46974
+ return { ok: true };
46975
+ }
46867
46976
  function byPriority(a, b) {
46868
46977
  return (PRIORITY_RANK[a.issue.priority] ?? 9) - (PRIORITY_RANK[b.issue.priority] ?? 9);
46869
46978
  }
46870
- var PRIORITY_RANK;
46979
+ var gate, quiet, PRIORITY_RANK;
46871
46980
  var init_queries = __esm({
46872
46981
  "src/queries.ts"() {
46873
46982
  "use strict";
46874
46983
  init_dist();
46984
+ gate = (why) => ({ ok: false, why, announce: true });
46985
+ quiet = (why) => ({ ok: false, why, announce: false });
46875
46986
  PRIORITY_RANK = {
46876
46987
  urgent: 0,
46877
46988
  high: 1,
@@ -46960,7 +47071,7 @@ async function createWorktree(ref, config3) {
46960
47071
  if (staged.length === 0) return null;
46961
47072
  const message = `wip(${ref}): saved by the supervisor when the run was cut short
46962
47073
 
46963
- The harness was ${reason} with this in the tree. NOT a handoff: the
47074
+ The run ended \u2014 ${reason} \u2014 with this in the tree. NOT a handoff: the
46964
47075
  agent did not choose to commit it and nothing here has been reviewed.
46965
47076
  The next attempt on this card starts from here rather than from scratch.
46966
47077
 
@@ -47024,7 +47135,7 @@ var init_worktree = __esm({
47024
47135
  var supervisor_exports = {};
47025
47136
  import { spawn } from "node:child_process";
47026
47137
  import { createRequire } from "node:module";
47027
- import { mkdtemp, readdir as readdir3, readFile as readFile2, rm as rm3, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
47138
+ import { mkdtemp, readdir as readdir3, readFile as readFile2, rm as rm3, stat as stat2, writeFile as writeFile3 } from "node:fs/promises";
47028
47139
  import { homedir as homedir2, tmpdir } from "node:os";
47029
47140
  import { join as join3 } from "node:path";
47030
47141
  import { fileURLToPath } from "node:url";
@@ -47053,7 +47164,7 @@ function resolveWorktreeRoot(identity) {
47053
47164
  const name = slug(identity.name) || slug(identity.email.split("@")[0] ?? "") || "agent";
47054
47165
  return join3(homedir2(), ".cruo-work", name);
47055
47166
  }
47056
- async function pollAssigned(ctx) {
47167
+ async function pollAssigned(ctx, identity) {
47057
47168
  const { data: issues, error: error51 } = await ctx.client.from("issues").select("*").eq("assignee_id", ctx.userId).order("created_at", { ascending: true });
47058
47169
  if (error51) throw new Error(`Assignment poll failed: ${error51.message}`);
47059
47170
  if (!issues || issues.length === 0) return [];
@@ -47072,17 +47183,23 @@ async function pollAssigned(ctx) {
47072
47183
  return issues.flatMap((issue2) => {
47073
47184
  const state = byState.get(issue2.status_id);
47074
47185
  const project = byProject.get(issue2.project_id);
47075
- if (!state || !project) return [];
47076
- if (options.project && project.key !== options.project) return [];
47077
- if (blocksAgentPickup(state)) {
47078
- log(
47079
- ` ${project.key}-${issue2.number} is in ${state.name}, which requires a human \u2014 not starting`
47080
- );
47186
+ const eligible = stillEligible({
47187
+ reason: "assigned",
47188
+ issue: issue2,
47189
+ state: state ?? null,
47190
+ project: project ?? null,
47191
+ agentId: ctx.userId,
47192
+ jobFunction: identity.jobFunction,
47193
+ onlyProject: options.project ?? null,
47194
+ lastSaidAt: lastSaid.get(issue2.id) ?? null
47195
+ });
47196
+ if (!eligible.ok) {
47197
+ if (eligible.announce && project) {
47198
+ log(` ${project.key}-${issue2.number} ${eligible.why} \u2014 not starting`);
47199
+ }
47081
47200
  return [];
47082
47201
  }
47083
- if (state.category === "completed" || state.category === "canceled") return [];
47084
- const said = lastSaid.get(issue2.id);
47085
- if (said && issue2.updated_at && said > issue2.updated_at) return [];
47202
+ if (!project || !state) return [];
47086
47203
  return [
47087
47204
  {
47088
47205
  ref: `${project.key}-${issue2.number}`,
@@ -47112,14 +47229,22 @@ async function poll(ctx, identity) {
47112
47229
  return issues.flatMap((issue2) => {
47113
47230
  const project = byProject.get(issue2.project_id);
47114
47231
  const state = byState.get(issue2.status_id);
47115
- if (!project || !state) return [];
47116
- if (options.project && project.key !== options.project) return [];
47117
- if (blocksAgentPickup(state)) {
47118
- log(
47119
- ` ${project.key}-${issue2.number} is in ${state.name}, which requires a human \u2014 not starting`
47120
- );
47232
+ const eligible = stillEligible({
47233
+ reason: "state",
47234
+ issue: issue2,
47235
+ state: state ?? null,
47236
+ project: project ?? null,
47237
+ agentId: ctx.userId,
47238
+ jobFunction: identity.jobFunction,
47239
+ onlyProject: options.project ?? null
47240
+ });
47241
+ if (!eligible.ok) {
47242
+ if (eligible.announce && project) {
47243
+ log(` ${project.key}-${issue2.number} ${eligible.why} \u2014 not starting`);
47244
+ }
47121
47245
  return [];
47122
47246
  }
47247
+ if (!project || !state) return [];
47123
47248
  return [
47124
47249
  {
47125
47250
  ref: `${project.key}-${issue2.number}`,
@@ -47161,8 +47286,22 @@ async function pollMentions(ctx, identity) {
47161
47286
  return issues.flatMap((issue2) => {
47162
47287
  const project = byProject.get(issue2.project_id);
47163
47288
  const state = byState.get(issue2.status_id);
47289
+ const eligible = stillEligible({
47290
+ reason: "mention",
47291
+ issue: issue2,
47292
+ state: state ?? null,
47293
+ project: project ?? null,
47294
+ agentId: ctx.userId,
47295
+ jobFunction: identity.jobFunction,
47296
+ onlyProject: options.project ?? null
47297
+ });
47298
+ if (!eligible.ok) {
47299
+ if (eligible.announce && project) {
47300
+ log(` ${project.key}-${issue2.number} ${eligible.why} \u2014 not starting`);
47301
+ }
47302
+ return [];
47303
+ }
47164
47304
  if (!project || !state) return [];
47165
- if (options.project && project.key !== options.project) return [];
47166
47305
  return [
47167
47306
  {
47168
47307
  ref: `${project.key}-${issue2.number}`,
@@ -47175,6 +47314,34 @@ async function pollMentions(ctx, identity) {
47175
47314
  ];
47176
47315
  }).sort(byPriority);
47177
47316
  }
47317
+ async function recheckEligibility(ctx, hit, identity) {
47318
+ const { data: issue2, error: error51 } = await ctx.client.from("issues").select("*").eq("id", hit.issue.id).maybeSingle();
47319
+ if (error51) {
47320
+ log(` ${hit.ref} could not be re-read (${error51.message}) \u2014 going on what the poll saw`);
47321
+ return { ok: true, hit };
47322
+ }
47323
+ if (!issue2) return { ok: false, why: "no longer exists" };
47324
+ const [{ data: state }, { data: said }] = await Promise.all([
47325
+ ctx.client.from("workflow_states").select("*").eq("id", issue2.status_id).maybeSingle(),
47326
+ // Only the `assigned` route reads this, and only to apply CRA-96's
47327
+ // already-answered rule; one query rather than a branch, because the cost
47328
+ // is a single indexed read next to a harness invocation.
47329
+ ctx.client.from("comments").select("created_at").eq("issue_id", issue2.id).eq("author_id", ctx.userId).order("created_at", { ascending: false }).limit(1).maybeSingle()
47330
+ ]);
47331
+ const eligible = stillEligible({
47332
+ reason: hit.reason,
47333
+ issue: issue2,
47334
+ state: state ?? null,
47335
+ project: hit.project,
47336
+ agentId: ctx.userId,
47337
+ jobFunction: identity.jobFunction,
47338
+ onlyProject: options.project ?? null,
47339
+ lastSaidAt: said?.created_at ?? null
47340
+ });
47341
+ if (!eligible.ok) return { ok: false, why: eligible.why };
47342
+ if (!state) return { ok: false, why: "can no longer be read (state missing)" };
47343
+ return { ok: true, hit: { ...hit, issue: issue2, state } };
47344
+ }
47178
47345
  async function claim(ctx, hit) {
47179
47346
  const ttlMs = options.harnessTimeoutMs + 3e4;
47180
47347
  const { data, error: error51 } = await withFreshSession(
@@ -47221,11 +47388,14 @@ async function writeMcpConfig() {
47221
47388
  const session = await childSession();
47222
47389
  const dir = await mkdtemp(join3(tmpdir(), CONFIG_DIR_PREFIX));
47223
47390
  const path = join3(dir, "mcp.json");
47391
+ const sessionPath = join3(dir, "session.json");
47392
+ const writeSession = (s) => writeSessionFile(sessionPath, s);
47393
+ await writeSession(session);
47224
47394
  const here = fileURLToPath(new URL(".", import.meta.url));
47225
47395
  const bundled = /[/\\]dist[/\\]?$/.test(here);
47226
47396
  const server = join3(here, bundled ? "index.js" : "index.ts");
47227
47397
  const loader = bundled ? null : createRequire(import.meta.url).resolve("tsx");
47228
- await writeFile2(
47398
+ await writeFile3(
47229
47399
  path,
47230
47400
  JSON.stringify(
47231
47401
  {
@@ -47239,7 +47409,12 @@ async function writeMcpConfig() {
47239
47409
  // Neither the service-role key nor the PAT. The child gets a
47240
47410
  // session already minted from that PAT, by this process, which
47241
47411
  // is the one entitled to hold the key that mints one.
47242
- [DELEGATED_SESSION_ENV]: JSON.stringify(session)
47412
+ // Both, deliberately (CRA-128). The file is what a renewal can
47413
+ // replace; the env var is the floor, so a child whose file is
47414
+ // unreadable for any reason behaves exactly as it did before
47415
+ // this existed rather than losing the board outright.
47416
+ [DELEGATED_SESSION_ENV]: JSON.stringify(session),
47417
+ [DELEGATED_SESSION_FILE_ENV]: sessionPath
47243
47418
  }
47244
47419
  }
47245
47420
  }
@@ -47249,7 +47424,42 @@ async function writeMcpConfig() {
47249
47424
  ),
47250
47425
  { mode: 384 }
47251
47426
  );
47252
- return { path, cleanup: () => rm3(dir, { recursive: true, force: true }) };
47427
+ const keepSessionFresh = () => {
47428
+ let current = session;
47429
+ let stopped = false;
47430
+ let timer = null;
47431
+ const tick2 = async () => {
47432
+ if (stopped) return;
47433
+ if (sessionRefreshDue(decodeJwtExpiry(current.accessToken), Date.now())) {
47434
+ try {
47435
+ const fresh = await childSession();
47436
+ if (stopped) return;
47437
+ await writeSession(fresh);
47438
+ current = fresh;
47439
+ log(` session for this run renewed before expiry`);
47440
+ } catch (error51) {
47441
+ log(
47442
+ ` could not renew this run's session (${error51 instanceof Error ? error51.message.split("\n")[0] : String(error51)}) \u2014 the harness keeps the one it has and this is retried`
47443
+ );
47444
+ }
47445
+ }
47446
+ if (!stopped) schedule();
47447
+ };
47448
+ const schedule = () => {
47449
+ timer = setTimeout(() => void tick2(), SESSION_REFRESH_CHECK_MS);
47450
+ timer.unref?.();
47451
+ };
47452
+ schedule();
47453
+ return () => {
47454
+ stopped = true;
47455
+ if (timer) clearTimeout(timer);
47456
+ };
47457
+ };
47458
+ return {
47459
+ path,
47460
+ keepSessionFresh,
47461
+ cleanup: () => rm3(dir, { recursive: true, force: true })
47462
+ };
47253
47463
  }
47254
47464
  async function sweepStaleConfigs() {
47255
47465
  const dir = tmpdir();
@@ -47422,7 +47632,8 @@ function headFor(head2) {
47422
47632
  }
47423
47633
  async function invokeHarness(ctx, identity, hit, worktree) {
47424
47634
  const cwd = worktree?.path ?? options.cwd;
47425
- const { path: mcpConfig, cleanup } = await writeMcpConfig();
47635
+ const { path: mcpConfig, cleanup, keepSessionFresh } = await writeMcpConfig();
47636
+ const stopSessionRefresh = keepSessionFresh();
47426
47637
  const streaming = options.usage === true;
47427
47638
  const builtinTools = [
47428
47639
  ...new Set(
@@ -47562,6 +47773,7 @@ async function invokeHarness(ctx, identity, hit, worktree) {
47562
47773
  });
47563
47774
  });
47564
47775
  } finally {
47776
+ stopSessionRefresh();
47565
47777
  await cleanup();
47566
47778
  }
47567
47779
  }
@@ -47576,6 +47788,19 @@ async function loadAttempts(ctx, hits) {
47576
47788
  for (const row of data ?? []) out.set(`${row.reason}:${row.issue_id}`, row.attempts);
47577
47789
  return out;
47578
47790
  }
47791
+ async function loadOutcomes(ctx, hits) {
47792
+ const out = /* @__PURE__ */ new Map();
47793
+ if (hits.length === 0) return out;
47794
+ const { data, error: error51 } = await ctx.client.from("issue_attempts").select("issue_id, reason, last_outcome").eq("agent_id", ctx.userId).in("issue_id", [...new Set(hits.map((h) => h.issue.id))]);
47795
+ if (error51) {
47796
+ log(` could not read why (${error51.message}) \u2014 logging without a reason`);
47797
+ return out;
47798
+ }
47799
+ for (const row of data ?? []) {
47800
+ if (row.last_outcome) out.set(`${row.reason}:${row.issue_id}`, row.last_outcome);
47801
+ }
47802
+ return out;
47803
+ }
47579
47804
  async function recordAttempt(ctx, hit, outcome, previous) {
47580
47805
  const attempts = previous + 1;
47581
47806
  const { error: error51 } = await withFreshSession(
@@ -47599,6 +47824,47 @@ async function recordAttempt(ctx, hit, outcome, previous) {
47599
47824
  if (error51) log(` could not record the attempt on ${hit.ref}: ${error51.message}`);
47600
47825
  return attempts;
47601
47826
  }
47827
+ async function loadSpend(ctx, hits) {
47828
+ const out = /* @__PURE__ */ new Map();
47829
+ if (hits.length === 0 || options.maxCostUsd === void 0) return out;
47830
+ const { data, error: error51 } = await ctx.client.from("harness_runs").select("issue_id, cost_usd").eq("agent_id", ctx.userId).in("issue_id", [...new Set(hits.map((h) => h.issue.id))]);
47831
+ if (error51) {
47832
+ log(` could not read spend so far (${error51.message}) \u2014 treating as $0`);
47833
+ return out;
47834
+ }
47835
+ for (const row of data ?? []) {
47836
+ if (row.cost_usd === null || row.issue_id === null) continue;
47837
+ out.set(row.issue_id, (out.get(row.issue_id) ?? 0) + Number(row.cost_usd));
47838
+ }
47839
+ return out;
47840
+ }
47841
+ async function handOffForCost(ctx, hit, spent) {
47842
+ const cap = options.maxCostUsd;
47843
+ const { error: commentError } = await ctx.client.from("comments").insert({
47844
+ issue_id: hit.issue.id,
47845
+ author_id: ctx.userId,
47846
+ body: `Spend ceiling reached: this card has cost **$${spent.toFixed(2)}**, at or over its \`--max-cost $${cap.toFixed(2)}\` limit, and is not finished. Stopping here rather than spending more on another run \u2014 a human should pick it up from whatever the runs so far left on it (state, comments, and any branch named on the card).`
47847
+ });
47848
+ if (commentError) {
47849
+ log(` ${hit.ref} could not post the spend-ceiling comment: ${commentError.message}`);
47850
+ }
47851
+ const { error: error51 } = await ctx.client.from("issue_attempts").upsert(
47852
+ {
47853
+ issue_id: hit.issue.id,
47854
+ agent_id: ctx.userId,
47855
+ reason: hit.reason,
47856
+ attempts: options.maxAttempts,
47857
+ last_tried_at: (/* @__PURE__ */ new Date()).toISOString(),
47858
+ last_outcome: `spend ceiling reached ($${spent.toFixed(2)} \u2265 $${cap.toFixed(2)})`,
47859
+ set_aside_at: (/* @__PURE__ */ new Date()).toISOString()
47860
+ },
47861
+ { onConflict: "issue_id,agent_id,reason" }
47862
+ );
47863
+ if (error51) log(` could not set aside ${hit.ref} for spend: ${error51.message}`);
47864
+ log(
47865
+ ` ${hit.ref} hit its $${cap.toFixed(2)} spend ceiling ($${spent.toFixed(2)} spent) \u2014 commented and handed to a human, not invoking again`
47866
+ );
47867
+ }
47602
47868
  async function clearAttempts(ctx, hit) {
47603
47869
  const { error: error51 } = await withFreshSession(
47604
47870
  ctx,
@@ -47714,25 +47980,49 @@ async function recordRun(ctx, hit, run, outcome) {
47714
47980
  }
47715
47981
  }
47716
47982
  async function tick(ctx, identity, deadTicks, allowance) {
47717
- const mentions = await pollMentions(ctx, identity);
47718
- const [assigned, stateWork] = await Promise.all([pollAssigned(ctx), poll(ctx, identity)]);
47719
- const seen = /* @__PURE__ */ new Set();
47720
- const hits = [...mentions, ...assigned, ...stateWork].filter((h) => {
47721
- if (seen.has(h.ref)) return false;
47722
- seen.add(h.ref);
47723
- return true;
47724
- });
47725
- const heldByOthers = await claimedElsewhere(ctx, hits);
47726
- const free = hits.filter((h) => !heldByOthers.has(h.issue.id));
47727
- if (heldByOthers.size > 0) {
47728
- log(`${heldByOthers.size} issue(s) already being worked by someone else`);
47729
- }
47730
- const attemptsByHit = await loadAttempts(ctx, free);
47731
- const attemptsFor = (h) => attemptsByHit.get(`${h.reason}:${h.issue.id}`) ?? 0;
47732
- const actionable = free.filter((h) => attemptsFor(h) < options.maxAttempts);
47983
+ const readQueue = async (exclude, quiet2) => {
47984
+ const mentions = await pollMentions(ctx, identity);
47985
+ const [assigned, stateWork] = await Promise.all([pollAssigned(ctx, identity), poll(ctx, identity)]);
47986
+ const seen = /* @__PURE__ */ new Set();
47987
+ const hits = [...mentions, ...assigned, ...stateWork].filter((h) => {
47988
+ if (seen.has(h.ref)) return false;
47989
+ seen.add(h.ref);
47990
+ return true;
47991
+ });
47992
+ const heldByOthers2 = await claimedElsewhere(ctx, hits);
47993
+ const free2 = hits.filter((h) => !heldByOthers2.has(h.issue.id));
47994
+ if (heldByOthers2.size > 0 && !quiet2) {
47995
+ log(`${heldByOthers2.size} issue(s) already being worked by someone else`);
47996
+ }
47997
+ const attemptsByHit = await loadAttempts(ctx, free2);
47998
+ const attemptsFor2 = (h) => attemptsByHit.get(`${h.reason}:${h.issue.id}`) ?? 0;
47999
+ const spendByHit = await loadSpend(ctx, free2);
48000
+ const spendFor = (h) => spendByHit.get(h.issue.id) ?? 0;
48001
+ const overBudget = options.maxCostUsd !== void 0 ? free2.filter(
48002
+ (h) => !exclude.has(h.issue.id) && attemptsFor2(h) < options.maxAttempts && spendFor(h) >= options.maxCostUsd
48003
+ ) : [];
48004
+ for (const hit of overBudget) {
48005
+ exclude.add(hit.issue.id);
48006
+ await handOffForCost(ctx, hit, spendFor(hit));
48007
+ }
48008
+ const actionable2 = free2.filter(
48009
+ (h) => !exclude.has(h.issue.id) && attemptsFor2(h) < options.maxAttempts && (options.maxCostUsd === void 0 || spendFor(h) < options.maxCostUsd)
48010
+ );
48011
+ return { free: free2, heldByOthers: heldByOthers2, actionable: actionable2, attemptsFor: attemptsFor2 };
48012
+ };
48013
+ const tried = /* @__PURE__ */ new Set();
48014
+ const queue = await readQueue(tried, false);
48015
+ const { free, heldByOthers } = queue;
48016
+ let { actionable, attemptsFor } = queue;
47733
48017
  if (actionable.length === 0) {
47734
- if (free.length > 0) log(`${free.length} issue(s) waiting, all set aside`);
47735
- else if (heldByOthers.size > 0) log(`nothing free \u2014 everything waiting is claimed`);
48018
+ if (free.length > 0) {
48019
+ log(`${free.length} issue(s) waiting, all set aside`);
48020
+ const outcomes = await loadOutcomes(ctx, free);
48021
+ for (const h of free) {
48022
+ const why = outcomes.get(`${h.reason}:${h.issue.id}`);
48023
+ log(` ${h.ref}: ${why ?? "attempts exhausted, no reason recorded"}`);
48024
+ }
48025
+ } else if (heldByOthers.size > 0) log(`nothing free \u2014 everything waiting is claimed`);
47736
48026
  await beat(ctx, { holding: 0, deadTicks, intervalMs: options.intervalMs });
47737
48027
  return { invoked: 0, failedToRun: 0, suppressed: false, waiting: 0 };
47738
48028
  }
@@ -47761,15 +48051,34 @@ async function tick(ctx, identity, deadTicks, allowance) {
47761
48051
  `${actionable.length} issue(s) for "${identity.jobFunction}"` + (mentionCount > 0 ? ` \u2014 ${mentionCount} from unread mentions` : "")
47762
48052
  );
47763
48053
  const cap = allowance === "probe" ? 1 : options.limit;
47764
- const taking = cap ? actionable.slice(0, cap) : actionable;
47765
- if (taking.length < actionable.length) {
48054
+ if (cap && cap < actionable.length) {
47766
48055
  log(
47767
- allowance === "probe" ? ` probing with ${taking.length} of ${actionable.length} \u2014 one card is enough to learn whether the harness works again` : ` taking ${taking.length} this tick (--limit ${options.limit})`
48056
+ allowance === "probe" ? ` probing with ${cap} of ${actionable.length} \u2014 one card is enough to learn whether the harness works again` : ` taking ${cap} this tick (--limit ${options.limit})`
47768
48057
  );
47769
48058
  }
47770
- for (const hit of taking) {
48059
+ const firstRead = new Set(actionable.map((h) => h.issue.id));
48060
+ let worked = 0;
48061
+ for (let first = true; ; first = false) {
48062
+ if (cap && worked >= cap) break;
48063
+ if (!first) {
48064
+ if (stopping || paused) break;
48065
+ ({ actionable, attemptsFor } = await readQueue(tried, true));
48066
+ }
48067
+ const queued = actionable.find((h) => !tried.has(h.issue.id));
48068
+ if (!queued) break;
48069
+ tried.add(queued.issue.id);
48070
+ if (!firstRead.has(queued.issue.id)) {
48071
+ log(` ${queued.ref} arrived since this pass began \u2014 taking it next`);
48072
+ }
48073
+ const rechecked = await recheckEligibility(ctx, queued, identity);
48074
+ if (!rechecked.ok) {
48075
+ log(` ${queued.ref} ${rechecked.why} \u2014 skipping`);
48076
+ continue;
48077
+ }
48078
+ const hit = rechecked.hit;
47771
48079
  const why = hit.reason === "mention" ? "mentioned" : hit.reason === "assigned" ? `assigned \xB7 ${hit.state.name}` : hit.state.name;
47772
48080
  log(`\u2192 ${hit.ref} "${hit.issue.title}" (${why})`);
48081
+ worked += 1;
47773
48082
  if (options.dryRun) {
47774
48083
  log(` dry run \u2014 would invoke: ${options.harness} -p \u2026 --allowedTools ${options.allow}`);
47775
48084
  continue;
@@ -47803,23 +48112,20 @@ async function tick(ctx, identity, deadTicks, allowance) {
47803
48112
  try {
47804
48113
  run = await invokeHarness(ctx, identity, hit, worktree);
47805
48114
  if (worktree) committed = await worktree.commits();
47806
- if (worktree && run.killed) {
48115
+ if (worktree) {
48116
+ const endedBecause = run.killed ? "killed at the harness timeout" : run.refusal ? `stopped by a harness refusal (${run.refusal})` : run.code === 0 ? "exited 0 without committing this" : `cut short by exit ${run.code}`;
47807
48117
  try {
47808
- salvaged = await worktree.saveWip("killed at the harness timeout");
48118
+ salvaged = await worktree.saveWip(endedBecause);
47809
48119
  if (salvaged) {
47810
48120
  log(
47811
- ` ${hit.ref} killed with an uncommitted tree \u2014 saved as ${salvaged.slice(0, 8)} on ${worktree.branch}; the next attempt resumes from it`
48121
+ ` ${hit.ref} ${run.killed ? "killed" : `exited ${run.code}`} with an uncommitted tree \u2014 saved as ${salvaged.slice(0, 8)} on ${worktree.branch}; the next attempt resumes from it`
47812
48122
  );
47813
48123
  }
47814
48124
  } catch (error51) {
47815
48125
  log(
47816
- ` ${hit.ref} could not save the killed run's tree: ${error51 instanceof Error ? error51.message.split("\n")[0] : String(error51)}`
48126
+ ` ${hit.ref} could not save the run's tree, and it goes with the worktree: ${error51 instanceof Error ? error51.message.split("\n")[0] : String(error51)}${options.keepFailed ? " (kept: --keep-failed)" : ""}`
47817
48127
  );
47818
48128
  }
47819
- } else if (worktree && committed === 0 && await worktree.dirty()) {
47820
- log(
47821
- ` ${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"}`
47822
- );
47823
48129
  }
47824
48130
  } finally {
47825
48131
  stopBusyBeat();
@@ -48099,6 +48405,7 @@ var init_supervisor = __esm({
48099
48405
  "src/supervisor.ts"() {
48100
48406
  "use strict";
48101
48407
  init_dist();
48408
+ init_session_renewal();
48102
48409
  init_dist7();
48103
48410
  init_env2();
48104
48411
  init_auth();
@@ -48164,10 +48471,30 @@ var init_supervisor = __esm({
48164
48471
  * test file — was killed at it twice, having written real work both times
48165
48472
  * and committed neither. Err long: the cheap failure is the slow one.
48166
48473
  */
48167
- // 50 minutes since CRA-122: roughly double the longest real run (26.3 min),
48168
- // and deliberately short of an hour — the run's own board session lasts
48169
- // exactly 60 minutes (see `writeMcpConfig`), so a longer limit lets a run
48170
- // lose the board in the minutes it hands the card on.
48474
+ // 50 minutes since CRA-122: roughly double the longest real run (26.3 min).
48475
+ //
48476
+ // WHAT THE CEILING IS, AND WHY — read this before raising it (CRA-128 AC4).
48477
+ //
48478
+ // It used to be an hour, hard: the run's own board session lasted exactly 60
48479
+ // minutes and was captured by the harness at start-up, so a longer limit let
48480
+ // a run lose the board in the minutes it comments and hands the card on —
48481
+ // the one part of a run nobody else can reconstruct. **That is fixed.**
48482
+ // `writeMcpConfig` now writes the session to a file the child re-reads on
48483
+ // every tool call and rewrites it ten minutes ahead of expiry, so a run can
48484
+ // outlast its session.
48485
+ //
48486
+ // What still argues against a very long ceiling is money, not credentials.
48487
+ // `CRA-127` measured it: cost tracks cached tokens READ, almost linearly, and
48488
+ // a long run re-reads its own accumulating context on every turn — $8.04 for
48489
+ // 22 minutes at the top of the range. So `--harness-timeout` is also a spend
48490
+ // ceiling, and `--max-cost` is the one that says so out loud.
48491
+ //
48492
+ // One mechanical limit remains: `sweepStaleConfigs` removes a temp config
48493
+ // older than `max(--harness-timeout + --prepare-timeout + 5min, 1h)`, read
48494
+ // from the options of whichever supervisor is STARTING. Two supervisors with
48495
+ // very different timeouts, and the short one can in principle sweep the long
48496
+ // one's config mid-run. Nothing has hit this; raising the ceiling far past an
48497
+ // hour is what would make it reachable.
48171
48498
  harnessTimeoutMs: ms("harness-timeout", 3e3),
48172
48499
  /**
48173
48500
  * Give up on an issue after this many unproductive runs. Persisted in
@@ -48175,6 +48502,24 @@ var init_supervisor = __esm({
48175
48502
  * forgot every set-aside and nothing outside the process could see one.
48176
48503
  */
48177
48504
  maxAttempts: num2("max-attempts", { fallback: 3, integer: true }),
48505
+ /**
48506
+ * Stop spending on ONE CARD once it has cost this many dollars, and hand it
48507
+ * to a human instead of trying again — `CRA-127`. Unset means no ceiling,
48508
+ * which is today's behaviour exactly: nothing stops a card costing $20.
48509
+ *
48510
+ * Read back from `harness_runs.cost_usd` — the same figure `cruo usage`
48511
+ * shows — rather than a separate counter, so the two can never disagree
48512
+ * about what a card has spent. That also means it only bites when the
48513
+ * harness is reporting cost at all (`--usage`, for Claude Code): a harness
48514
+ * that volunteers nothing is read as $0 spent, honestly, not metered
48515
+ * against a figure it never produced.
48516
+ *
48517
+ * Checked BEFORE a harness is spawned, not after — the whole finding on
48518
+ * this card is that a run's cost is paid turn by turn as it re-reads its
48519
+ * own context, so the only ceiling worth having is one that stops the next
48520
+ * invocation rather than one that bills it and complains afterwards.
48521
+ */
48522
+ maxCostUsd: num2("max-cost"),
48178
48523
  /**
48179
48524
  * Ask the harness to report what each run spent — `CRA-89`.
48180
48525
  *
@@ -48320,7 +48665,7 @@ init_dist();
48320
48665
  init_dist7();
48321
48666
  init_options();
48322
48667
  init_process();
48323
- import { chmod, mkdir as mkdir3, readFile as readFile3, rm as rm4, writeFile as writeFile3 } from "node:fs/promises";
48668
+ import { chmod, mkdir as mkdir3, readFile as readFile3, rm as rm4, writeFile as writeFile4 } from "node:fs/promises";
48324
48669
  import { homedir as homedir3 } from "node:os";
48325
48670
  import { join as join4 } from "node:path";
48326
48671
  var CONFIG_DIR = join4(homedir3(), ".cruo");
@@ -48398,7 +48743,7 @@ async function readConfig2() {
48398
48743
  }
48399
48744
  async function writeConfig(config3) {
48400
48745
  await mkdir3(CONFIG_DIR, { recursive: true, mode: 448 });
48401
- await writeFile3(CONFIG_PATH, JSON.stringify(config3, null, 2) + "\n", { mode: 384 });
48746
+ await writeFile4(CONFIG_PATH, JSON.stringify(config3, null, 2) + "\n", { mode: 384 });
48402
48747
  await chmod(CONFIG_PATH, 384);
48403
48748
  }
48404
48749
  async function lookup(token, timeoutMs = 5e3) {
@@ -48614,9 +48959,14 @@ Not signalled. Look at it yourself, then act if you are sure:
48614
48959
  ${name} is not running (it left a record behind; cleared).
48615
48960
  `);
48616
48961
  } else if (live.state === "recycled") {
48617
- console.error(`
48618
- ${name} is not running \u2014 its pid now belongs to something else. Cleared.
48619
- `);
48962
+ console.error(
48963
+ `
48964
+ ${name}'s record names pid ${live.record.pid}, which is alive but does not look like the same process anymore \u2014 cannot confirm it is still ${name}. Not touched: nothing here deletes the record of a pid that is alive. Look at it yourself, then act if you are sure:
48965
+
48966
+ ps -p ${live.record.pid} -o pid,lstart,command
48967
+ rm ~/.cruo/run/${safeName(name)}.json # only once you are sure it is not ${name}
48968
+ `
48969
+ );
48620
48970
  } else {
48621
48971
  console.error(`
48622
48972
  ${name} is not running. Start it with \`cruo start --as ${name}\`.
@@ -48736,8 +49086,9 @@ Nothing running. Start one with \`cruo start\`.
48736
49086
  console.log(` ${agent.padEnd(14)} \u2014 \u2014 died or was killed (record cleared)`);
48737
49087
  await removeRecord(agent);
48738
49088
  } else if (live.state === "recycled") {
48739
- console.log(` ${agent.padEnd(14)} \u2014 \u2014 gone; its pid is now someone else's (cleared)`);
48740
- await removeRecord(agent);
49089
+ console.log(
49090
+ ` ${agent.padEnd(14)} ${String(live.record.pid).padEnd(8)} \u2014 cannot confirm this is still the same process \u2014 left for you to check`
49091
+ );
48741
49092
  }
48742
49093
  }
48743
49094
  for (const proc of untracked) {
package/dist/index.js CHANGED
@@ -45048,7 +45048,7 @@ var require_dist_cjs13 = __commonJS({
45048
45048
  "../../node_modules/.pnpm/@aws-sdk+credential-provider-web-identity@3.972.71/node_modules/@aws-sdk/credential-provider-web-identity/dist-cjs/index.js"(exports) {
45049
45049
  var { setCredentialFeature: setCredentialFeature2 } = (init_client3(), __toCommonJS(client_exports2));
45050
45050
  var { CredentialsProviderError: CredentialsProviderError2, externalDataInterceptor: externalDataInterceptor2 } = (init_config2(), __toCommonJS(config_exports));
45051
- var { readFileSync } = __require("node:fs");
45051
+ var { readFileSync: readFileSync2 } = __require("node:fs");
45052
45052
  var fromWebToken = (init) => async (awsIdentityProperties) => {
45053
45053
  init.logger?.debug("@aws-sdk/credential-provider-web-identity - fromWebToken");
45054
45054
  const { roleArn, roleSessionName, webIdentityToken, providerId, policyArns, policy, durationSeconds } = init;
@@ -45089,7 +45089,7 @@ var require_dist_cjs13 = __commonJS({
45089
45089
  }
45090
45090
  const credentials = await fromWebToken({
45091
45091
  ...init,
45092
- webIdentityToken: externalDataInterceptor2?.getTokenRecord?.()[webIdentityTokenFile] ?? readFileSync(webIdentityTokenFile, { encoding: "ascii" }),
45092
+ webIdentityToken: externalDataInterceptor2?.getTokenRecord?.()[webIdentityTokenFile] ?? readFileSync2(webIdentityTokenFile, { encoding: "ascii" }),
45093
45093
  roleArn,
45094
45094
  roleSessionName
45095
45095
  })(awsIdentityProperties);
@@ -78141,6 +78141,7 @@ async function touchAccessToken(admin, tokenId) {
78141
78141
  }
78142
78142
 
78143
78143
  // ../../packages/mcp-kit/dist/context.js
78144
+ import { readFileSync } from "node:fs";
78144
78145
  function decodeJwtExpiry(token) {
78145
78146
  const parts = token.split(".");
78146
78147
  if (parts.length !== 3)
@@ -78255,6 +78256,7 @@ async function sessionFor(config3, principal) {
78255
78256
  return attempt;
78256
78257
  }
78257
78258
  var DELEGATED_SESSION_ENV = "CRUO_SESSION";
78259
+ var DELEGATED_SESSION_FILE_ENV = "CRUO_SESSION_FILE";
78258
78260
  async function fetchDelegatedSession(endpoint, token, options = {}) {
78259
78261
  let response;
78260
78262
  try {
@@ -78306,19 +78308,34 @@ function createRemoteContextResolver(options) {
78306
78308
  return attempt;
78307
78309
  };
78308
78310
  }
78309
- function readDelegatedSession() {
78310
- const raw = process.env[DELEGATED_SESSION_ENV];
78311
- if (!raw || !raw.trim())
78311
+ function readSessionFile() {
78312
+ const path = process.env[DELEGATED_SESSION_FILE_ENV];
78313
+ if (!path || !path.trim())
78314
+ return null;
78315
+ let raw;
78316
+ try {
78317
+ raw = readFileSync(path.trim(), "utf8");
78318
+ } catch {
78319
+ return null;
78320
+ }
78321
+ return parseSession(raw, DELEGATED_SESSION_FILE_ENV, "lenient");
78322
+ }
78323
+ function parseSession(raw, source, mode) {
78324
+ if (!raw.trim())
78312
78325
  return null;
78313
78326
  let parsed;
78314
78327
  try {
78315
78328
  parsed = JSON.parse(raw);
78316
78329
  } catch {
78317
- throw new TokenRefused(`${DELEGATED_SESSION_ENV} is not valid JSON.`);
78330
+ if (mode === "strict")
78331
+ throw new TokenRefused(`${source} is not valid JSON.`);
78332
+ return null;
78318
78333
  }
78319
78334
  const s2 = parsed;
78320
78335
  if (!s2.accessToken || !s2.userId || !s2.email || !s2.workspaceId) {
78321
- throw new TokenRefused(`${DELEGATED_SESSION_ENV} is missing fields.`);
78336
+ if (mode === "strict")
78337
+ throw new TokenRefused(`${source} is missing fields.`);
78338
+ return null;
78322
78339
  }
78323
78340
  return {
78324
78341
  accessToken: s2.accessToken,
@@ -78328,6 +78345,15 @@ function readDelegatedSession() {
78328
78345
  workspaceName: s2.workspaceName ?? "workspace"
78329
78346
  };
78330
78347
  }
78348
+ function readDelegatedSession() {
78349
+ const fromFile = readSessionFile();
78350
+ if (fromFile)
78351
+ return fromFile;
78352
+ const raw = process.env[DELEGATED_SESSION_ENV];
78353
+ if (!raw)
78354
+ return null;
78355
+ return parseSession(raw, DELEGATED_SESSION_ENV, "strict");
78356
+ }
78331
78357
  function contextFromSession(schema, session) {
78332
78358
  return {
78333
78359
  client: createUserClientFor(session.accessToken, schema),
@@ -89327,7 +89353,7 @@ function registerTools(server, getContext2, options = {}) {
89327
89353
  "delete_issue",
89328
89354
  {
89329
89355
  title: "Delete issue",
89330
- description: "Delete an issue (its comments, labels and activity cascade).",
89356
+ description: "Move an issue to the trash. It disappears from the board and every tool, with its comments, labels and activity; a person can restore it from Trash in the app for 30 days, after which it is deleted for good.",
89331
89357
  inputSchema: { issue: external_exports.string().describe("Issue reference, e.g. CRU-14") }
89332
89358
  },
89333
89359
  async (args, ctx) => {
@@ -89335,7 +89361,7 @@ function registerTools(server, getContext2, options = {}) {
89335
89361
  const ref = issueRef(project, issue2);
89336
89362
  const { error: error52 } = await ctx.client.from("issues").delete().eq("id", issue2.id);
89337
89363
  if (error52) throw new ToolError(error52.message);
89338
- return `Deleted ${ref}.`;
89364
+ return `Moved ${ref} to the trash. It can be restored from Trash in the app for 30 days.`;
89339
89365
  }
89340
89366
  );
89341
89367
  register(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cruo-agent",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
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",