@codesona/runner 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +34 -2
  2. package/dist/cli.js +671 -116
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2276,7 +2276,7 @@ var require_websocket = __commonJS({
2276
2276
  var http2 = __require("http");
2277
2277
  var net2 = __require("net");
2278
2278
  var tls = __require("tls");
2279
- var { randomBytes: randomBytes8, createHash: createHash5 } = __require("crypto");
2279
+ var { randomBytes: randomBytes9, createHash: createHash5 } = __require("crypto");
2280
2280
  var { Duplex, Readable } = __require("stream");
2281
2281
  var { URL: URL2 } = __require("url");
2282
2282
  var PerMessageDeflate2 = require_permessage_deflate();
@@ -2827,7 +2827,7 @@ var require_websocket = __commonJS({
2827
2827
  }
2828
2828
  }
2829
2829
  const defaultPort = isSecure ? 443 : 80;
2830
- const key = randomBytes8(16).toString("base64");
2830
+ const key = randomBytes9(16).toString("base64");
2831
2831
  const request = isSecure ? https2.request : http2.request;
2832
2832
  const protocolSet = /* @__PURE__ */ new Set();
2833
2833
  let perMessageDeflate;
@@ -3722,9 +3722,9 @@ var require_websocket_server = __commonJS({
3722
3722
 
3723
3723
  // src/runner/doctor.ts
3724
3724
  import { randomUUID as randomUUID8 } from "node:crypto";
3725
- import fs11 from "node:fs";
3725
+ import fs12 from "node:fs";
3726
3726
  import os6 from "node:os";
3727
- import path18 from "node:path";
3727
+ import path19 from "node:path";
3728
3728
  import { parseArgs as parseArgs2 } from "node:util";
3729
3729
 
3730
3730
  // src/harness/host.ts
@@ -5383,12 +5383,12 @@ var NativeUsageReader = class {
5383
5383
  cursor = -1;
5384
5384
  childTrajectories = false;
5385
5385
  async read() {
5386
- const path27 = join5(this.home, "antigravity-acp", "conversations", `${this.sessionId}.db`);
5386
+ const path30 = join5(this.home, "antigravity-acp", "conversations", `${this.sessionId}.db`);
5387
5387
  let cursor = this.cursor;
5388
5388
  let complete = false;
5389
5389
  const result = [];
5390
5390
  for (let batch = 0; batch < 16; batch++) {
5391
- const rows = await this.readRows(path27, cursor);
5391
+ const rows = await this.readRows(path30, cursor);
5392
5392
  for (const row of rows) {
5393
5393
  if (!Number.isSafeInteger(row.idx) || row.idx <= cursor || !/^(?:[a-f0-9]{2})*$/i.test(row.data) || row.data.length > 8 * 1024 * 1024) throw new Error("Invalid native usage row");
5394
5394
  if (row.has_children) this.childTrajectories = true;
@@ -5406,11 +5406,11 @@ var NativeUsageReader = class {
5406
5406
  return { samples: result, complete, ...this.childTrajectories ? { incompleteScope: true } : {} };
5407
5407
  }
5408
5408
  };
5409
- async function queryNativeRowsWithCli(path27, cursor) {
5410
- const output = await readLocalCommand("sqlite3", ["-readonly", "-json", path27, QUERY.replace("idx>?", `idx>${cursor}`)], 16 * 1024 * 1024);
5409
+ async function queryNativeRowsWithCli(path30, cursor) {
5410
+ const output = await readLocalCommand("sqlite3", ["-readonly", "-json", path30, QUERY.replace("idx>?", `idx>${cursor}`)], 16 * 1024 * 1024);
5411
5411
  return output.trim() ? JSON.parse(output) : [];
5412
5412
  }
5413
- async function queryNativeRows(path27, cursor, loadSqlite = async () => {
5413
+ async function queryNativeRows(path30, cursor, loadSqlite = async () => {
5414
5414
  const module = "node:sqlite";
5415
5415
  return await import(module);
5416
5416
  }) {
@@ -5418,9 +5418,9 @@ async function queryNativeRows(path27, cursor, loadSqlite = async () => {
5418
5418
  try {
5419
5419
  sqlite = await loadSqlite();
5420
5420
  } catch {
5421
- return queryNativeRowsWithCli(path27, cursor);
5421
+ return queryNativeRowsWithCli(path30, cursor);
5422
5422
  }
5423
- const db = new sqlite.DatabaseSync(path27, { readOnly: true });
5423
+ const db = new sqlite.DatabaseSync(path30, { readOnly: true });
5424
5424
  try {
5425
5425
  return db.prepare(QUERY).all(cursor);
5426
5426
  } finally {
@@ -7309,14 +7309,14 @@ function normalizeFileChangeInput(item) {
7309
7309
  const changePaths = Array.isArray(item?.changes) ? item.changes.map((change) => change?.path) : [];
7310
7310
  for (const candidate of changePaths) {
7311
7311
  if (typeof candidate !== "string") continue;
7312
- const path27 = candidate.trim();
7313
- if (!path27 || seen.has(path27)) continue;
7314
- seen.add(path27);
7315
- paths.push(path27);
7312
+ const path30 = candidate.trim();
7313
+ if (!path30 || seen.has(path30)) continue;
7314
+ seen.add(path30);
7315
+ paths.push(path30);
7316
7316
  }
7317
7317
  if (paths.length === 0 && typeof item?.path === "string") {
7318
- const path27 = item.path.trim();
7319
- if (path27) paths.push(path27);
7318
+ const path30 = item.path.trim();
7319
+ if (path30) paths.push(path30);
7320
7320
  }
7321
7321
  return paths.length > 0 ? { path: paths.join(", ") } : {};
7322
7322
  }
@@ -10842,7 +10842,7 @@ var OpenCodeServiceLane = class {
10842
10842
  const document = record4(body);
10843
10843
  const paths = record4(document?.paths);
10844
10844
  const required = this.requiredApiPaths;
10845
- if (!paths || required.some((path27) => !record4(paths[path27]))) {
10845
+ if (!paths || required.some((path30) => !record4(paths[path30]))) {
10846
10846
  throw new OpenCodeIncompatibleError("Installed OpenCode service is missing required session capabilities");
10847
10847
  }
10848
10848
  }
@@ -10932,8 +10932,8 @@ var OpenCodeServiceLane = class {
10932
10932
  const controller = new AbortController();
10933
10933
  this.durableAbort = controller;
10934
10934
  try {
10935
- const path27 = `/api/session/${encodeURIComponent(this.sessionId)}/event?after=${this.lastDurableSeq}`;
10936
- const response = await this.openStream(path27, controller, "durable event stream");
10935
+ const path30 = `/api/session/${encodeURIComponent(this.sessionId)}/event?after=${this.lastDurableSeq}`;
10936
+ const response = await this.openStream(path30, controller, "durable event stream");
10937
10937
  await this.catchUpHistory(true);
10938
10938
  if (!this.isIdentity(identity)) return;
10939
10939
  this.durableGap = false;
@@ -11000,12 +11000,12 @@ var OpenCodeServiceLane = class {
11000
11000
  if (this.stopping) return;
11001
11001
  this.protocolFailure(`OpenCode ${kind} stream stopped unexpectedly`, error);
11002
11002
  }
11003
- async openStream(path27, lifecycle, operation) {
11003
+ async openStream(path30, lifecycle, operation) {
11004
11004
  const deadline = new AbortController();
11005
11005
  const timeout = setTimeout(() => deadline.abort(), this.options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS);
11006
11006
  timeout.unref?.();
11007
11007
  try {
11008
- const response = await this.fetchFn(`${this.baseUrl}${path27}`, {
11008
+ const response = await this.fetchFn(`${this.baseUrl}${path30}`, {
11009
11009
  method: "GET",
11010
11010
  headers: this.headers({ accept: "text/event-stream" }),
11011
11011
  signal: AbortSignal.any([lifecycle.signal, deadline.signal])
@@ -11437,8 +11437,8 @@ var OpenCodeServiceLane = class {
11437
11437
  turnOwner: root.receipt
11438
11438
  });
11439
11439
  }
11440
- async requestNoContent(path27, method, body, operation) {
11441
- const response = await this.fetchWithTimeout(path27, {
11440
+ async requestNoContent(path30, method, body, operation) {
11441
+ const response = await this.fetchWithTimeout(path30, {
11442
11442
  method,
11443
11443
  ...body ? {
11444
11444
  headers: { "content-type": "application/json" },
@@ -11447,14 +11447,14 @@ var OpenCodeServiceLane = class {
11447
11447
  }, operation);
11448
11448
  if (!response.ok) throw new OpenCodeHttpError(response.status, operation);
11449
11449
  }
11450
- async fetchWithTimeout(path27, init, operation, timeoutMs = this.options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS) {
11450
+ async fetchWithTimeout(path30, init, operation, timeoutMs = this.options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS) {
11451
11451
  if (this.stopping) throw new Error("OpenCode service lane is stopping");
11452
11452
  const controller = new AbortController();
11453
11453
  this.requestControllers.add(controller);
11454
11454
  const timer = setTimeout(() => controller.abort(), timeoutMs);
11455
11455
  timer.unref?.();
11456
11456
  try {
11457
- return await this.fetchFn(`${this.baseUrl}${path27}`, {
11457
+ return await this.fetchFn(`${this.baseUrl}${path30}`, {
11458
11458
  ...init,
11459
11459
  headers: this.headers(init.headers),
11460
11460
  signal: controller.signal
@@ -11466,7 +11466,7 @@ var OpenCodeServiceLane = class {
11466
11466
  this.requestControllers.delete(controller);
11467
11467
  }
11468
11468
  }
11469
- async fetchJsonWithTimeout(path27, init, operation, timeoutMs = this.options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS) {
11469
+ async fetchJsonWithTimeout(path30, init, operation, timeoutMs = this.options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS) {
11470
11470
  if (this.stopping) throw new OpenCodeStoppedError("OpenCode service lane is stopping");
11471
11471
  const controller = new AbortController();
11472
11472
  this.requestControllers.add(controller);
@@ -11477,7 +11477,7 @@ var OpenCodeServiceLane = class {
11477
11477
  }, timeoutMs);
11478
11478
  timer.unref?.();
11479
11479
  try {
11480
- const response = await this.fetchFn(`${this.baseUrl}${path27}`, {
11480
+ const response = await this.fetchFn(`${this.baseUrl}${path30}`, {
11481
11481
  ...init,
11482
11482
  headers: this.headers(init.headers),
11483
11483
  signal: controller.signal
@@ -14608,9 +14608,9 @@ function parseBffUrl(value) {
14608
14608
  const loopback = url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]";
14609
14609
  return url.protocol === "http:" && loopback ? url : void 0;
14610
14610
  }
14611
- function bffEndpoint(base, path27) {
14611
+ function bffEndpoint(base, path30) {
14612
14612
  const url = new URL(base.href);
14613
- url.pathname = `${url.pathname.replace(/\/+$/, "")}/${path27.replace(/^\/+/, "")}`;
14613
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}/${path30.replace(/^\/+/, "")}`;
14614
14614
  url.search = "";
14615
14615
  url.hash = "";
14616
14616
  return url;
@@ -14700,7 +14700,7 @@ function parseRunBundle(response, runId) {
14700
14700
  if (typeof response.sessionId !== "string" || !UUID.test(response.sessionId)) throw new BundleError("the bundle's session id is not a uuid");
14701
14701
  const raw = response.bundle;
14702
14702
  if (!raw || typeof raw !== "object") throw new BundleError("the run has no bundle");
14703
- if (raw.v !== 1) throw new BundleError(`bundle version ${String(raw.v)} is not one this Runner reads; update Codesona Runner`);
14703
+ if (raw.v !== 1 && raw.v !== 2) throw new BundleError(`bundle version ${String(raw.v)} is not one this Runner reads; update Codesona Runner`);
14704
14704
  if (raw.harness !== Harness.ClaudeCode) throw new BundleError(`the bundle is for ${String(raw.harness)}, which this Runner does not start`);
14705
14705
  if (typeof raw.instructions !== "string") throw new BundleError("the bundle has no instructions");
14706
14706
  const files = stringRecord(raw.files ?? {}, "files");
@@ -14716,7 +14716,7 @@ function parseRunBundle(response, runId) {
14716
14716
  sessionId: response.sessionId,
14717
14717
  resume: response.resume === true,
14718
14718
  bundle: {
14719
- v: 1,
14719
+ v: raw.v,
14720
14720
  harness: Harness.ClaudeCode,
14721
14721
  instructions: raw.instructions,
14722
14722
  files,
@@ -14726,10 +14726,71 @@ function parseRunBundle(response, runId) {
14726
14726
  limits: {
14727
14727
  wallSeconds: seconds(limits.wallSeconds, DEFAULT_LIMITS.wallSeconds),
14728
14728
  idleSeconds: seconds(limits.idleSeconds, DEFAULT_LIMITS.idleSeconds)
14729
- }
14729
+ },
14730
+ // A v1 bundle has none, whatever it carries.
14731
+ prepare: raw.v === 2 && raw.prepare != null ? parsePrepare(raw.prepare) : null
14730
14732
  }
14731
14733
  };
14732
14734
  }
14735
+ var SHA = /^[0-9a-f]{40}$/i;
14736
+ function parsePrepare(value) {
14737
+ if (typeof value !== "object" || Array.isArray(value)) throw new BundleError("the bundle's prepare is not an object");
14738
+ const list = value.repos ?? [];
14739
+ if (!Array.isArray(list)) throw new BundleError("the bundle's prepare.repos is not a list");
14740
+ const repos = list.map((item, index) => parseRepo(item, index));
14741
+ for (const key of ["repoId", "name"]) {
14742
+ const seen = /* @__PURE__ */ new Set();
14743
+ for (const repo of repos) {
14744
+ const folded = repo[key].toLowerCase();
14745
+ if (seen.has(folded)) throw new BundleError(`the bundle names the repository ${key} ${JSON.stringify(repo[key])} twice`);
14746
+ seen.add(folded);
14747
+ }
14748
+ }
14749
+ return { repos };
14750
+ }
14751
+ function parseRepo(value, index) {
14752
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new BundleError(`the bundle's repository ${index} is not an object`);
14753
+ const raw = value;
14754
+ const text = (field) => {
14755
+ const item = raw[field];
14756
+ if (typeof item !== "string" || !item) throw new BundleError(`the bundle's repository ${index} has no ${field}`);
14757
+ return item;
14758
+ };
14759
+ const repoId = text("repoId");
14760
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(repoId)) throw new BundleError(`the bundle's repository ${index} has an id the Runner cannot name a directory after`);
14761
+ const name = text("name");
14762
+ if (!isPathSegment(name)) throw new BundleError(`the bundle's repository name ${JSON.stringify(name.slice(0, 80))} is not one plain directory name`);
14763
+ const cloneUrl = text("cloneUrl");
14764
+ let url;
14765
+ try {
14766
+ url = new URL(cloneUrl);
14767
+ } catch {
14768
+ throw new BundleError(`the clone URL of ${name} is not a URL`);
14769
+ }
14770
+ if (url.protocol !== "https:") throw new BundleError(`the clone URL of ${name} is not https`);
14771
+ if (url.username || url.password || /^https:\/\/[^/]*@/i.test(cloneUrl)) throw new BundleError(`the clone URL of ${name} carries a user name or password`);
14772
+ const defaultBranch = branchName(text("defaultBranch"), "default branch", name);
14773
+ const taskBranch = branchName(text("taskBranch"), "task branch", name);
14774
+ const baseSha = text("baseSha");
14775
+ if (!SHA.test(baseSha)) throw new BundleError(`the base commit of ${name} is not a 40-character SHA`);
14776
+ const holding = raw.holdingRef ?? null;
14777
+ if (holding !== null && typeof holding !== "string") throw new BundleError(`the holding ref of ${name} is not a string`);
14778
+ const holdingRef = holding ? refName(holding, "holding ref", name) : null;
14779
+ return { repoId, name, cloneUrl, defaultBranch, taskBranch, baseSha: baseSha.toLowerCase(), holdingRef };
14780
+ }
14781
+ function isPathSegment(name) {
14782
+ return name.length <= 100 && name !== "." && name !== ".." && !name.startsWith("-") && !/[/\\\x00-\x1f\x7f]/.test(name) && name.trim() === name;
14783
+ }
14784
+ function refName(ref, what, repo) {
14785
+ const bad = ref.length > 255 || ref.startsWith("-") || ref.startsWith("/") || ref.endsWith("/") || ref.endsWith(".") || ref.endsWith(".lock") || ref.includes("..") || ref.includes("//") || ref.includes("@{") || /[\s~^:?*[\\\x00-\x1f\x7f]/.test(ref) || ref.split("/").some((part) => part.startsWith("."));
14786
+ if (bad) throw new BundleError(`the ${what} of ${repo} is not a ref name git takes`);
14787
+ return ref;
14788
+ }
14789
+ function branchName(branch, what, repo) {
14790
+ refName(branch, what, repo);
14791
+ if (branch.startsWith("refs/") || branch === "HEAD") throw new BundleError(`the ${what} of ${repo} is not a branch's short name`);
14792
+ return branch;
14793
+ }
14733
14794
  function stringRecord(value, what) {
14734
14795
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new BundleError(`the bundle's ${what} are not an object`);
14735
14796
  for (const [key, item] of Object.entries(value)) {
@@ -14741,7 +14802,7 @@ function seconds(value, fallback) {
14741
14802
  if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
14742
14803
  return Math.min(86400, Math.max(60, Math.round(value)));
14743
14804
  }
14744
- var RESERVED = /* @__PURE__ */ new Set(["AGENTS.md", "CLAUDE.md", ".codesona"]);
14805
+ var RESERVED = /* @__PURE__ */ new Set(["AGENTS.md", "CLAUDE.md", ".codesona", "repos"]);
14745
14806
  function bundleFilePath(name) {
14746
14807
  const segments = name.split("/");
14747
14808
  if (!name || path14.isAbsolute(name) || name.includes("\\") || name.includes("\0") || segments.some((segment) => !segment || segment === "." || segment === "..") || RESERVED.has(segments[0])) {
@@ -14941,6 +15002,8 @@ function runnerFiles(home) {
14941
15002
  spool: path16.join(home, "spool"),
14942
15003
  /** One working directory per agent: the same one every session, so `--resume` finds its transcript. */
14943
15004
  agents: path16.join(home, "agents"),
15005
+ /** One bare clone per repository (`<repoId>.git`), shared by the agents' worktrees of it (KT2 §4). */
15006
+ repos: path16.join(home, "repos"),
14944
15007
  /** The background service's log (`run --service`), turned over at 10 MB. */
14945
15008
  log: path16.join(state, "runner.log"),
14946
15009
  /** What the background service's process wrote outside its log (a crash), as launchd keeps it. */
@@ -15009,7 +15072,7 @@ var ExitCode = {
15009
15072
  Failed: 1,
15010
15073
  Usage: 2
15011
15074
  };
15012
- var runnerVersion = true ? "0.1.0" : "0.0.0-dev";
15075
+ var runnerVersion = true ? "0.2.0" : "0.0.0-dev";
15013
15076
 
15014
15077
  // src/runner/log.ts
15015
15078
  var LogLevel = {
@@ -15411,7 +15474,7 @@ var LaunchdError = class extends Error {
15411
15474
  };
15412
15475
  function runCommand(file, args, options = {}) {
15413
15476
  return new Promise((resolve4) => {
15414
- execFile(file, [...args], { timeout: options.timeoutMs ?? 15e3, encoding: "utf8", maxBuffer: 4 * 1024 * 1024, env: options.env }, (error, stdout, stderr) => {
15477
+ execFile(file, [...args], { timeout: options.timeoutMs ?? 15e3, encoding: "utf8", maxBuffer: 4 * 1024 * 1024, env: options.env, cwd: options.cwd }, (error, stdout, stderr) => {
15415
15478
  const exit = error?.code;
15416
15479
  const code = !error ? 0 : typeof exit === "number" ? exit : -1;
15417
15480
  resolve4({ code, stdout: stdout ?? "", stderr: stderr || (code === -1 && error ? error.message : "") });
@@ -15757,6 +15820,68 @@ async function statusCommand(args, io, env = process.env) {
15757
15820
  return ExitCode.Ok;
15758
15821
  }
15759
15822
 
15823
+ // src/runner/tools.ts
15824
+ import fs11 from "node:fs";
15825
+ import path18 from "node:path";
15826
+ var TOOL_TIMEOUT_MS = 1e4;
15827
+ function parseGitVersion(output) {
15828
+ return /git version (\S+)/.exec(output)?.[1] ?? null;
15829
+ }
15830
+ function parseDotnetSdks(output) {
15831
+ return output.split("\n").map((line) => /^(\d\S*)\s+\[/.exec(line.trim())?.[1]).filter((version) => version !== void 0);
15832
+ }
15833
+ function parseNodeVersion(output) {
15834
+ return /^v?(\d+\.\d+\.\d+\S*)/.exec(output.trim())?.[1] ?? null;
15835
+ }
15836
+ async function readMachineTools(environment = process.env, run = runCommand) {
15837
+ const options = { env: agentEnvironment(environment), timeoutMs: TOOL_TIMEOUT_MS, cwd: "/" };
15838
+ const look = (file, args) => run(file, args, options).catch(() => void 0);
15839
+ const [git, dotnet, node] = await Promise.all([look("git", ["--version"]), look("dotnet", ["--list-sdks"]), look("node", ["--version"])]);
15840
+ return {
15841
+ git: git?.code === 0 ? parseGitVersion(git.stdout) : null,
15842
+ dotnet: dotnet?.code === 0 ? parseDotnetSdks(dotnet.stdout) : null,
15843
+ node: node?.code === 0 ? parseNodeVersion(node.stdout) : null
15844
+ };
15845
+ }
15846
+ async function readGitSetup(environment = process.env, run = runCommand) {
15847
+ const options = { env: agentEnvironment(environment), timeoutMs: TOOL_TIMEOUT_MS, cwd: "/" };
15848
+ const look = (args) => run("git", args, options).catch(() => void 0);
15849
+ const version = await look(["--version"]);
15850
+ if (version?.code !== 0) return { version: null, credentialHelpers: [], commitSigning: false };
15851
+ const [helpers, signing] = await Promise.all([look(["config", "--get-all", "credential.helper"]), look(["config", "--get", "--type=bool", "commit.gpgsign"])]);
15852
+ return {
15853
+ version: parseGitVersion(version.stdout),
15854
+ // An empty value resets the list before it: what is after the last one counts.
15855
+ credentialHelpers: helpers?.code === 0 ? helpers.stdout.replace(/\n$/, "").split("\n").reduce((list, line) => line.trim() ? [...list, line.trim()] : [], []) : [],
15856
+ commitSigning: signing?.code === 0 && signing.stdout.trim() === "true"
15857
+ };
15858
+ }
15859
+ function isSshUrl(url) {
15860
+ if (/^(ssh|git\+ssh|ssh\+git):\/\//i.test(url)) return true;
15861
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url) || url.startsWith("/")) return false;
15862
+ return /^[^/:]+:/.test(url);
15863
+ }
15864
+ async function cachedSshRemotes(cacheDirectory, environment = process.env, run = runCommand) {
15865
+ let names;
15866
+ try {
15867
+ names = fs11.readdirSync(cacheDirectory).filter((name) => name.endsWith(".git"));
15868
+ } catch {
15869
+ return [];
15870
+ }
15871
+ const options = { env: agentEnvironment(environment), timeoutMs: TOOL_TIMEOUT_MS, cwd: "/" };
15872
+ const found = [];
15873
+ for (const name of names) {
15874
+ const directory = path18.join(cacheDirectory, name);
15875
+ for (const args of [["remote", "get-url", "--all", "origin"], ["remote", "get-url", "--push", "--all", "origin"]]) {
15876
+ const result = await run("git", ["-C", directory, ...args], options).catch(() => void 0);
15877
+ for (const url of result?.code === 0 ? result.stdout.split("\n").map((line) => line.trim()).filter(Boolean) : []) {
15878
+ if (isSshUrl(url) && !found.some((remote) => remote.repository === name && remote.url === url)) found.push({ repository: name, url });
15879
+ }
15880
+ }
15881
+ }
15882
+ return found;
15883
+ }
15884
+
15760
15885
  // src/runner/versions.ts
15761
15886
  function isOlder(version, minimum) {
15762
15887
  if (!minimum) return false;
@@ -15850,18 +15975,20 @@ function machineEnvironment(env = process.env, launchAgent, machine = {}) {
15850
15975
  // A Runner run by hand gives its sessions this terminal's environment, less Codesona's names.
15851
15976
  terminalClaudeLogin: service ? () => readClaudeLogin((file, args) => run(file, args, { env: agentEnvironment(env), timeoutMs: 2e4 })) : void 0,
15852
15977
  claudeSettings: () => readClaudeSettings(claudeConfigDir(sessions())),
15853
- terminalClaudeSettings: service ? () => path18.resolve(claudeConfigDir(env)) === path18.resolve(claudeConfigDir(sessions())) ? void 0 : readClaudeSettings(claudeConfigDir(env)) : void 0,
15978
+ terminalClaudeSettings: service ? () => path19.resolve(claudeConfigDir(env)) === path19.resolve(claudeConfigDir(sessions())) ? void 0 : readClaudeSettings(claudeConfigDir(env)) : void 0,
15854
15979
  shellClaudeSettings: service ? () => shellOnlyClaudeSettings(env, userHome) : void 0,
15855
15980
  powerSettings: () => readPowerSettings(run, platform),
15856
15981
  probeEdge: (baseUrl) => probeEdge(baseUrl, { userAgent: runnerUserAgent() }),
15857
15982
  launchAgent: service ? launchAgent ?? new LaunchAgent() : void 0,
15858
- claudeOnServicePath: () => currentLaunchAgentSpec(env, userHome).path.split(":").some((directory) => isExecutable(path18.join(directory, "claude")))
15983
+ claudeOnServicePath: () => currentLaunchAgentSpec(env, userHome).path.split(":").some((directory) => isExecutable(path19.join(directory, "claude"))),
15984
+ gitSetup: () => readGitSetup(sessions(), run),
15985
+ sshRemotes: () => cachedSshRemotes(runnerFiles(runnerHome(env)).repos, sessions(), run)
15859
15986
  };
15860
15987
  }
15861
15988
  function isExecutable(file) {
15862
15989
  try {
15863
- fs11.accessSync(file, fs11.constants.X_OK);
15864
- return fs11.statSync(file).isFile();
15990
+ fs12.accessSync(file, fs12.constants.X_OK);
15991
+ return fs12.statSync(file).isFile();
15865
15992
  } catch {
15866
15993
  return false;
15867
15994
  }
@@ -15974,6 +16101,35 @@ ${setting}`)).map(({ file, setting }) => {
15974
16101
  }
15975
16102
  });
15976
16103
  }
16104
+ function gitChecks(setup, sshRemotes) {
16105
+ if (!setup.version) {
16106
+ return [{
16107
+ status: CheckStatus.Warning,
16108
+ message: "git is not installed, or not on the sessions' PATH: a Developer's sessions need it (a Tester's do not)",
16109
+ hint: "Install it: xcode-select --install, or brew install git"
16110
+ }];
16111
+ }
16112
+ const checks = [{ status: CheckStatus.Ok, message: `git ${setup.version}` }];
16113
+ checks.push(setup.credentialHelpers.length > 0 ? { status: CheckStatus.Ok, message: `git has a credential helper (${setup.credentialHelpers.join(", ")})` } : {
16114
+ status: CheckStatus.Warning,
16115
+ message: "git has no credential helper: a Developer's sessions could not fetch or push over https",
16116
+ hint: "Set one up and sign in once in a terminal: Git Credential Manager (brew install --cask git-credential-manager), or gh auth setup-git"
16117
+ });
16118
+ if (setup.commitSigning) {
16119
+ checks.push({
16120
+ status: CheckStatus.Warning,
16121
+ message: "commit.gpgsign is true in your git config: sessions run with commit signing off, so the agents' commits are not signed with your key"
16122
+ });
16123
+ }
16124
+ for (const remote of sshRemotes) {
16125
+ checks.push({
16126
+ status: CheckStatus.Warning,
16127
+ message: `The cached repository ${remote.repository} reaches its remote over SSH (${remote.url}): the background service has no SSH agent, so its git cannot use your SSH key`,
16128
+ hint: "Use https with a credential helper; a url.<base>.insteadOf in your git config that turns https into SSH does the same"
16129
+ });
16130
+ }
16131
+ return checks;
16132
+ }
15977
16133
  function addressCheck(url, paired) {
15978
16134
  const which = paired ? "This machine's Codesona" : "Codesona (the default; no machine paired yet)";
15979
16135
  if (url.protocol === "https:") return { status: CheckStatus.Ok, message: `${which} is ${url.origin}, over https` };
@@ -16051,7 +16207,7 @@ function permissionChecks(home) {
16051
16207
  const files = runnerFiles(home);
16052
16208
  const wide = [home, files.config].filter((file) => {
16053
16209
  try {
16054
- return (fs11.statSync(file).mode & 63) !== 0;
16210
+ return (fs12.statSync(file).mode & 63) !== 0;
16055
16211
  } catch {
16056
16212
  return false;
16057
16213
  }
@@ -16094,6 +16250,7 @@ async function runChecks(environment, options = {}) {
16094
16250
  if (sessionSettings) checks.push(...hookChecks(sessionSettings));
16095
16251
  const terminalSettings = environment.terminalClaudeSettings && service && !service.installed ? environment.terminalClaudeSettings() : void 0;
16096
16252
  if (terminalSettings) checks.push(...hookChecks(terminalSettings, SESSIONS_WHERE[SessionPlace.Terminal], stopHookBlocks(sessionSettings ?? [])));
16253
+ if (environment.gitSetup) checks.push(...gitChecks(await environment.gitSetup(), await environment.sshRemotes?.() ?? []));
16097
16254
  if (unreadable) {
16098
16255
  checks.push({ status: CheckStatus.Failed, message: unreadable.message, hint: "Pair this machine again: codesona-runner logout --local, then the command Factory shows." });
16099
16256
  }
@@ -16148,8 +16305,8 @@ async function applyFix(fix, environment) {
16148
16305
  switch (fix) {
16149
16306
  case DoctorFix.Permissions: {
16150
16307
  const files = runnerFiles(environment.runnerHome);
16151
- fs11.chmodSync(environment.runnerHome, 448);
16152
- if (fs11.existsSync(files.config)) fs11.chmodSync(files.config, 384);
16308
+ fs12.chmodSync(environment.runnerHome, 448);
16309
+ if (fs12.existsSync(files.config)) fs12.chmodSync(files.config, 384);
16153
16310
  return `${environment.runnerHome} and its config can be read by this user only`;
16154
16311
  }
16155
16312
  case DoctorFix.InstallService:
@@ -16193,8 +16350,8 @@ async function doctorCommand(args, io, environment = machineEnvironment()) {
16193
16350
  }
16194
16351
 
16195
16352
  // src/runner/logout.ts
16196
- import fs13 from "node:fs";
16197
- import path20 from "node:path";
16353
+ import fs14 from "node:fs";
16354
+ import path21 from "node:path";
16198
16355
  import { parseArgs as parseArgs3 } from "node:util";
16199
16356
 
16200
16357
  // src/runner/bff.ts
@@ -16262,13 +16419,13 @@ var BffClient = class {
16262
16419
  async logout(connectionKey) {
16263
16420
  await this.request("POST", "v1/machine-auth/logout", connectionKey, void 0, { emptyAnswer: true });
16264
16421
  }
16265
- async request(method, path27, bearer, body, options = {}) {
16422
+ async request(method, path30, bearer, body, options = {}) {
16266
16423
  const headers = { "User-Agent": this.options.userAgent, Accept: "application/json" };
16267
16424
  if (bearer) headers.Authorization = `Bearer ${bearer}`;
16268
16425
  if (body !== void 0) headers["Content-Type"] = "application/json";
16269
16426
  let response;
16270
16427
  try {
16271
- response = await this.fetchImpl(bffEndpoint(this.options.baseUrl, path27), {
16428
+ response = await this.fetchImpl(bffEndpoint(this.options.baseUrl, path30), {
16272
16429
  method,
16273
16430
  headers,
16274
16431
  body: body === void 0 ? void 0 : JSON.stringify(body),
@@ -16276,7 +16433,7 @@ var BffClient = class {
16276
16433
  redirect: "error"
16277
16434
  });
16278
16435
  } catch (error) {
16279
- throw new BffError(`${method} /${path27}: ${describeFetchError(error)}`, 0);
16436
+ throw new BffError(`${method} /${path30}: ${describeFetchError(error)}`, 0);
16280
16437
  }
16281
16438
  const serverTime = Date.parse(response.headers.get("date") ?? "");
16282
16439
  if (Number.isFinite(serverTime)) {
@@ -16289,7 +16446,7 @@ var BffClient = class {
16289
16446
  const code = typeof problem?.code === "string" ? problem.code : void 0;
16290
16447
  const detail = typeof problem?.detail === "string" ? problem.detail : void 0;
16291
16448
  throw new BffError(
16292
- `${method} /${path27}: ${response.status}${code ? ` ${code}` : ""}${detail ? `: ${detail}` : ""}`,
16449
+ `${method} /${path30}: ${response.status}${code ? ` ${code}` : ""}${detail ? `: ${detail}` : ""}`,
16293
16450
  response.status,
16294
16451
  code,
16295
16452
  retryAfter(response.headers.get("retry-after"))
@@ -16298,7 +16455,7 @@ var BffClient = class {
16298
16455
  if (options.emptyAnswer) return void 0;
16299
16456
  const parsed = parseJson(text);
16300
16457
  if (parsed === void 0 || parsed === null || typeof parsed !== "object") {
16301
- throw new BffError(`${method} /${path27}: ${response.status} with a body that is not JSON`, 502);
16458
+ throw new BffError(`${method} /${path30}: ${response.status} with a body that is not JSON`, 502);
16302
16459
  }
16303
16460
  return parsed;
16304
16461
  }
@@ -16343,6 +16500,9 @@ var AuthRefusalCode = {
16343
16500
  AlreadyConnected: "already_connected",
16344
16501
  InvalidRequest: "invalid_request"
16345
16502
  };
16503
+ var LaunchErrorKind = {
16504
+ PrepareFailed: "PrepareFailed"
16505
+ };
16346
16506
  var EndResult = {
16347
16507
  Written: "Written",
16348
16508
  AlreadyEnded: "AlreadyEnded",
@@ -16386,6 +16546,11 @@ var StartRefusal = {
16386
16546
  BundleInvalid: "BundleInvalid",
16387
16547
  /** The run's bundle could not be read in time (the BFF away, or not this machine's run any more). */
16388
16548
  BundleUnavailable: "BundleUnavailable",
16549
+ /**
16550
+ * A repository the run works in cannot be reached with this machine's git (`git ls-remote` failed or did not answer in
16551
+ * time): holds until a person acts (the machine's credentials, its network, the repository's access).
16552
+ */
16553
+ RepoUnavailable: "RepoUnavailable",
16389
16554
  UpdateRequired: "UpdateRequired",
16390
16555
  ShuttingDown: "ShuttingDown"
16391
16556
  };
@@ -16498,14 +16663,14 @@ function refusalMessage(reason) {
16498
16663
  }
16499
16664
 
16500
16665
  // src/runner/lock.ts
16501
- import fs12 from "node:fs";
16666
+ import fs13 from "node:fs";
16502
16667
  import net from "node:net";
16503
- import path19 from "node:path";
16668
+ import path20 from "node:path";
16504
16669
  var TAKEOVER_STALE_MS = 3e4;
16505
16670
  var TAKEOVER_WAIT_MS = 50;
16506
16671
  var ATTEMPTS = 40;
16507
16672
  async function acquireInstanceLock(file) {
16508
- ensurePrivateDirectory(path19.dirname(file));
16673
+ ensurePrivateDirectory(path20.dirname(file));
16509
16674
  const takeover = `${file}.takeover`;
16510
16675
  for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
16511
16676
  const lock = await listen(file);
@@ -16517,10 +16682,10 @@ async function acquireInstanceLock(file) {
16517
16682
  }
16518
16683
  try {
16519
16684
  if (await isHeld(file)) return void 0;
16520
- fs12.rmSync(file, { force: true });
16685
+ fs13.rmSync(file, { force: true });
16521
16686
  return await listen(file);
16522
16687
  } finally {
16523
- fs12.rmSync(takeover, { recursive: true, force: true });
16688
+ fs13.rmSync(takeover, { recursive: true, force: true });
16524
16689
  }
16525
16690
  }
16526
16691
  return void 0;
@@ -16542,13 +16707,13 @@ async function listen(file) {
16542
16707
  }
16543
16708
  function enterTakeover(directory) {
16544
16709
  try {
16545
- fs12.mkdirSync(directory, { mode: 448 });
16710
+ fs13.mkdirSync(directory, { mode: 448 });
16546
16711
  return true;
16547
16712
  } catch (error) {
16548
16713
  if (error.code !== "EEXIST") throw error;
16549
16714
  }
16550
16715
  try {
16551
- if (Date.now() - fs12.statSync(directory).mtimeMs > TAKEOVER_STALE_MS) fs12.rmSync(directory, { recursive: true, force: true });
16716
+ if (Date.now() - fs13.statSync(directory).mtimeMs > TAKEOVER_STALE_MS) fs13.rmSync(directory, { recursive: true, force: true });
16552
16717
  } catch {
16553
16718
  }
16554
16719
  return false;
@@ -16630,7 +16795,7 @@ async function logoutCommand(args, io, environment = {}) {
16630
16795
  removeRunnerFiles(home);
16631
16796
  } finally {
16632
16797
  await lock.release();
16633
- fs13.rmSync(files.state, { recursive: true, force: true });
16798
+ fs14.rmSync(files.state, { recursive: true, force: true });
16634
16799
  removeIfEmpty(home);
16635
16800
  }
16636
16801
  const name = config?.machineName ? `"${config.machineName}"` : "This machine";
@@ -16671,30 +16836,378 @@ async function tellCodesona(store, config, fetchImpl) {
16671
16836
  }
16672
16837
  function removeRunnerFiles(home) {
16673
16838
  const files = runnerFiles(home);
16674
- fs13.rmSync(files.config, { force: true });
16675
- for (const directory of [files.outbox, files.spool, files.agents]) fs13.rmSync(directory, { recursive: true, force: true });
16839
+ fs14.rmSync(files.config, { force: true });
16840
+ for (const directory of [files.outbox, files.spool, files.agents]) fs14.rmSync(directory, { recursive: true, force: true });
16676
16841
  for (const name of safeList(files.state)) {
16677
- if (name !== path20.basename(files.lock)) fs13.rmSync(path20.join(files.state, name), { recursive: true, force: true });
16842
+ if (name !== path21.basename(files.lock)) fs14.rmSync(path21.join(files.state, name), { recursive: true, force: true });
16678
16843
  }
16679
16844
  }
16680
16845
  function safeList(directory) {
16681
16846
  try {
16682
- return fs13.readdirSync(directory);
16847
+ return fs14.readdirSync(directory);
16683
16848
  } catch {
16684
16849
  return [];
16685
16850
  }
16686
16851
  }
16687
16852
  function removeIfEmpty(directory) {
16688
16853
  try {
16689
- fs13.rmdirSync(directory);
16854
+ fs14.rmdirSync(directory);
16690
16855
  } catch {
16691
16856
  }
16692
16857
  }
16693
16858
 
16694
- // src/runner/restart.ts
16859
+ // src/runner/prune.ts
16860
+ import fs16 from "node:fs";
16861
+ import path23 from "node:path";
16695
16862
  import { parseArgs as parseArgs4 } from "node:util";
16863
+
16864
+ // src/runner/repos.ts
16865
+ import { execFile as execFile2 } from "node:child_process";
16866
+ import { randomBytes as randomBytes8 } from "node:crypto";
16867
+ import fs15 from "node:fs";
16868
+ import path22 from "node:path";
16869
+ function gitWith(environment = process.env) {
16870
+ const base = { ...agentEnvironment(environment), GIT_TERMINAL_PROMPT: "0" };
16871
+ return (args, options = {}) => new Promise((resolve4) => {
16872
+ execFile2("git", [...args], {
16873
+ cwd: options.cwd ?? "/",
16874
+ env: withGitEnvironment(base, options.env),
16875
+ timeout: options.timeoutMs ?? COMMAND_TIMEOUT_MS,
16876
+ maxBuffer: 16 * 1024 * 1024,
16877
+ encoding: "utf8",
16878
+ signal: options.signal
16879
+ }, (error, stdout, stderr) => {
16880
+ if (!error) return resolve4({ code: 0, stdout, stderr });
16881
+ const failure = error;
16882
+ if (failure.name === "AbortError") return resolve4({ code: -1, stdout: stdout ?? "", stderr: stderr ?? "", aborted: true });
16883
+ const code = typeof failure.code === "number" ? failure.code : -1;
16884
+ resolve4({ code, stdout: stdout ?? "", stderr: stderr || (code === -1 ? failure.message : ""), timedOut: failure.killed === true && code === -1 });
16885
+ });
16886
+ });
16887
+ }
16888
+ function withGitEnvironment(base, extra = {}) {
16889
+ const result = {};
16890
+ for (const [name, value] of Object.entries(base)) if (value !== void 0) result[name] = value;
16891
+ const extraCount = Number(extra.GIT_CONFIG_COUNT ?? 0);
16892
+ for (const [name, value] of Object.entries(extra)) if (!/^GIT_CONFIG_(COUNT|KEY_\d+|VALUE_\d+)$/.test(name)) result[name] = value;
16893
+ if (Number.isSafeInteger(extraCount) && extraCount > 0) {
16894
+ const baseCount = Number(result.GIT_CONFIG_COUNT ?? 0);
16895
+ const offset = Number.isSafeInteger(baseCount) && baseCount > 0 ? baseCount : 0;
16896
+ let added = 0;
16897
+ for (let index = 0; index < extraCount; index++) {
16898
+ const key = extra[`GIT_CONFIG_KEY_${index}`];
16899
+ const value = extra[`GIT_CONFIG_VALUE_${index}`];
16900
+ if (key === void 0 || value === void 0) continue;
16901
+ result[`GIT_CONFIG_KEY_${offset + added}`] = key;
16902
+ result[`GIT_CONFIG_VALUE_${offset + added}`] = value;
16903
+ added++;
16904
+ }
16905
+ result.GIT_CONFIG_COUNT = String(offset + added);
16906
+ }
16907
+ return result;
16908
+ }
16909
+ function sessionGitEnvironment(env) {
16910
+ return Object.fromEntries(Object.entries(env).filter(([name]) => /^GIT_(AUTHOR|COMMITTER)_(NAME|EMAIL)$|^GIT_CONFIG_(COUNT|KEY_\d+|VALUE_\d+)$/.test(name)));
16911
+ }
16912
+ var COMMAND_TIMEOUT_MS = 2 * 60 * 1e3;
16913
+ var FETCH_TIMEOUT_MS = 30 * 60 * 1e3;
16914
+ var LS_REMOTE_TIMEOUT_MS = 15e3;
16915
+ var KEPT_BUILD_CACHES = ["node_modules", "bin", "obj", ".nuget", ".gradle", "target", "dist"];
16916
+ function withoutUserinfo(text) {
16917
+ return text.replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, "$1");
16918
+ }
16919
+ function shortGitError(result, timeoutMs) {
16920
+ if (result.timedOut) return timeoutMs ? `git did not answer within ${Math.round(timeoutMs / 1e3)}s` : "git did not answer in time";
16921
+ const lines = result.stderr.split("\n").map((line2) => line2.trim()).filter(Boolean);
16922
+ const line = [...lines].reverse().find((candidate) => /^(fatal|error):/i.test(candidate)) ?? lines.at(-1) ?? `git exited with ${result.code}`;
16923
+ const clean = redactCredentials(withoutUserinfo(line)).replace(/^(fatal|error):\s*/i, "");
16924
+ return clean.length > 300 ? `${clean.slice(0, 297)}\u2026` : clean;
16925
+ }
16926
+ var PrepareError = class extends Error {
16927
+ };
16928
+ var PrepareAborted = class extends Error {
16929
+ };
16930
+ var repoLocks = /* @__PURE__ */ new Map();
16931
+ async function withRepoLock(key, work) {
16932
+ const before = repoLocks.get(key) ?? Promise.resolve();
16933
+ const current = before.catch(() => void 0).then(work);
16934
+ const settled = current.catch(() => void 0);
16935
+ repoLocks.set(key, settled);
16936
+ try {
16937
+ return await current;
16938
+ } finally {
16939
+ if (repoLocks.get(key) === settled) repoLocks.delete(key);
16940
+ }
16941
+ }
16942
+ var RepoPreparer = class {
16943
+ constructor(options) {
16944
+ this.options = options;
16945
+ }
16946
+ options;
16947
+ /**
16948
+ * Whether this machine's git reaches every repository (`git ls-remote` of its task and default branches), within
16949
+ * `withinMs` at most; undefined when it does, else a sentence for a person naming the first that it does not.
16950
+ */
16951
+ async checkAccess(repos, withinMs) {
16952
+ const timeoutMs = Math.max(1e3, Math.min(LS_REMOTE_TIMEOUT_MS, withinMs));
16953
+ const answers = await Promise.all(repos.map(async (repo) => {
16954
+ const result = await this.options.git(["ls-remote", "--quiet", repo.cloneUrl, `refs/heads/${repo.taskBranch}`, `refs/heads/${repo.defaultBranch}`], { timeoutMs });
16955
+ return result.code === 0 ? void 0 : `This Mac cannot reach the repository ${repo.name} (${withoutUserinfo(repo.cloneUrl)}) with its git: ${shortGitError(result, timeoutMs)}`;
16956
+ }));
16957
+ return answers.find((answer) => answer !== void 0);
16958
+ }
16959
+ /** Each repository's worktree under `<agentDirectory>/repos/`, on its task branch. Throws PrepareError, or PrepareAborted. */
16960
+ async prepare(agentDirectory, repos, options = {}) {
16961
+ for (const repo of repos) {
16962
+ const cache = path22.join(this.options.cacheDirectory, `${repo.repoId}.git`);
16963
+ await withRepoLock(cache, () => new RepoWork(this.options, repo, cache, path22.join(agentDirectory, "repos", repo.name), options).run());
16964
+ }
16965
+ }
16966
+ };
16967
+ var RepoWork = class {
16968
+ constructor(options, repo, cache, worktree, prepare) {
16969
+ this.options = options;
16970
+ this.repo = repo;
16971
+ this.cache = cache;
16972
+ this.worktree = worktree;
16973
+ this.prepare = prepare;
16974
+ }
16975
+ options;
16976
+ repo;
16977
+ cache;
16978
+ worktree;
16979
+ prepare;
16980
+ async run() {
16981
+ await this.updateCache();
16982
+ const start = await this.startPoint();
16983
+ const state = this.worktreeState(await this.commonDirectory());
16984
+ if (state !== "ours") {
16985
+ if (state === "other") {
16986
+ this.options.log.warn(`${this.repo.name}: ${this.worktree} is not a worktree of the repository's cache; it is made again`);
16987
+ fs15.rmSync(this.worktree, { recursive: true, force: true });
16988
+ }
16989
+ fs15.mkdirSync(path22.dirname(this.worktree), { recursive: true, mode: 448 });
16990
+ await this.git(["worktree", "prune"], "the cache's worktrees could not be pruned", { cwd: this.cache });
16991
+ await this.git(["worktree", "add", "--quiet", "-B", this.repo.taskBranch, this.worktree, start.ref], `the worktree could not be made on ${this.repo.taskBranch}`, { cwd: this.cache });
16992
+ if (start.local) await this.fastForward();
16993
+ } else {
16994
+ const current = (await this.exec(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: this.worktree })).stdout.trim();
16995
+ if (current !== this.repo.taskBranch) {
16996
+ await this.git(["reset", "--hard", "--quiet"], "the worktree could not be reset", { cwd: this.worktree });
16997
+ await this.git(["clean", "-f", "-d", "-x", "-q", ...KEPT_BUILD_CACHES.flatMap((name) => ["-e", name])], "the worktree could not be cleaned", { cwd: this.worktree });
16998
+ await this.git(["checkout", "--quiet", "-B", this.repo.taskBranch, start.ref], `the worktree could not be switched to ${this.repo.taskBranch}`, { cwd: this.worktree });
16999
+ if (start.local) await this.fastForward();
17000
+ } else {
17001
+ await this.fastForward();
17002
+ }
17003
+ }
17004
+ if (this.repo.holdingRef) await this.mergeHoldingRef(this.repo.holdingRef);
17005
+ }
17006
+ /**
17007
+ * The cache made (a bare repository with origin's branches under refs/remotes/origin, the layout `git clone --bare`
17008
+ * would give with that refspec, minus local copies of every branch) or brought up to date. The fetch prunes only
17009
+ * refs/remotes/origin: a local branch is never pruned, so work pushed to a holding ref and never to its feature
17010
+ * branch keeps its local branch.
17011
+ */
17012
+ async updateCache() {
17013
+ if (!fs15.existsSync(path22.join(this.cache, "HEAD"))) {
17014
+ ensurePrivateDirectory(path22.dirname(this.cache));
17015
+ const making = `${this.cache}.${process.pid}.${randomBytes8(4).toString("hex")}.tmp`;
17016
+ try {
17017
+ await this.git(["init", "--bare", "--quiet", making], "the repository's cache could not be made");
17018
+ await this.git(["remote", "add", "origin", this.repo.cloneUrl], "the repository's cache could not be made", { cwd: making });
17019
+ await this.fetch(making);
17020
+ fs15.renameSync(making, this.cache);
17021
+ } finally {
17022
+ fs15.rmSync(making, { recursive: true, force: true });
17023
+ }
17024
+ return;
17025
+ }
17026
+ await this.git(["config", "remote.origin.url", this.repo.cloneUrl], "the repository's cache could not be set up", { cwd: this.cache });
17027
+ await this.git(["config", "--replace-all", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"], "the repository's cache could not be set up", { cwd: this.cache });
17028
+ await this.fetch(this.cache);
17029
+ }
17030
+ async fetch(directory) {
17031
+ await this.git(["fetch", "--prune", "--quiet", "origin"], "fetching from origin failed", { cwd: directory, timeoutMs: FETCH_TIMEOUT_MS });
17032
+ }
17033
+ /** Where the task branch starts: the machine's own branch, else origin's, else the planned base commit. */
17034
+ async startPoint() {
17035
+ const branch = this.repo.taskBranch;
17036
+ if (await this.exists(`refs/heads/${branch}`)) return { ref: branch, local: true };
17037
+ if (await this.exists(`refs/remotes/origin/${branch}`)) return { ref: `refs/remotes/origin/${branch}`, local: false };
17038
+ if (!await this.exists(this.repo.baseSha)) {
17039
+ await this.exec(["fetch", "--quiet", "origin", this.repo.baseSha], { cwd: this.cache, timeoutMs: FETCH_TIMEOUT_MS });
17040
+ if (!await this.exists(this.repo.baseSha)) throw this.failure(`its base commit ${this.repo.baseSha.slice(0, 12)} is not in the repository`);
17041
+ }
17042
+ return { ref: this.repo.baseSha, local: false };
17043
+ }
17044
+ async exists(ref) {
17045
+ return (await this.exec(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { cwd: this.cache })).code === 0;
17046
+ }
17047
+ /** The repository the directory at the worktree's path belongs to, when it is a worktree at all. */
17048
+ async commonDirectory() {
17049
+ if (!fs15.existsSync(path22.join(this.worktree, ".git"))) return void 0;
17050
+ const result = await this.exec(["rev-parse", "--git-common-dir"], { cwd: this.worktree });
17051
+ return result.code === 0 && result.stdout.trim() ? path22.resolve(this.worktree, result.stdout.trim()) : void 0;
17052
+ }
17053
+ worktreeState(common) {
17054
+ if (!fs15.existsSync(this.worktree)) return "missing";
17055
+ if (!common) return "other";
17056
+ try {
17057
+ return fs15.realpathSync(common) === fs15.realpathSync(this.cache) ? "ours" : "other";
17058
+ } catch {
17059
+ return "other";
17060
+ }
17061
+ }
17062
+ /** The task branch brought up to origin's, only when it has no commit origin lacks: unpushed work is never dropped. */
17063
+ async fastForward() {
17064
+ const remote = `refs/remotes/origin/${this.repo.taskBranch}`;
17065
+ if (!await this.exists(remote)) return;
17066
+ const behind = await this.exec(["merge-base", "--is-ancestor", "HEAD", remote], { cwd: this.worktree });
17067
+ if (behind.code === 1) {
17068
+ this.options.log.info(`${this.repo.name}: ${this.repo.taskBranch} has commits origin does not; left as it is`);
17069
+ return;
17070
+ }
17071
+ if (behind.code !== 0) throw this.failure(`its branch could not be compared with origin's: ${shortGitError(behind)}`);
17072
+ const merged = await this.exec(["merge", "--ff-only", "--quiet", remote], { cwd: this.worktree });
17073
+ if (merged.code !== 0) this.options.log.warn(`${this.repo.name}: ${this.repo.taskBranch} could not be brought up to origin's (${shortGitError(merged)}); left as it is`);
17074
+ }
17075
+ /** The holding ref's work brought into the task branch: a fast-forward when it can be, else a merge; a conflict fails. */
17076
+ async mergeHoldingRef(ref) {
17077
+ await this.git(["fetch", "--quiet", "origin", ref], `the holding ref ${ref} could not be fetched`, { cwd: this.worktree, timeoutMs: FETCH_TIMEOUT_MS });
17078
+ if ((await this.exec(["merge-base", "--is-ancestor", "FETCH_HEAD", "HEAD"], { cwd: this.worktree })).code === 0) return;
17079
+ if ((await this.exec(["merge", "--ff-only", "--quiet", "FETCH_HEAD"], { cwd: this.worktree })).code === 0) return;
17080
+ const merged = await this.exec(["-c", "commit.gpgsign=false", "merge", "--no-edit", "--quiet", "FETCH_HEAD"], { cwd: this.worktree });
17081
+ if (merged.code === 0) return;
17082
+ await this.exec(["merge", "--abort"], { cwd: this.worktree });
17083
+ throw this.failure(`the holding ref ${ref} does not merge into ${this.repo.taskBranch}: ${shortGitError(merged)}`);
17084
+ }
17085
+ failure(what) {
17086
+ return new PrepareError(`${this.repo.name}: ${what}`);
17087
+ }
17088
+ /** git, failing the preparation with `what` and git's own words when it fails. */
17089
+ async git(args, what, options = {}) {
17090
+ const result = await this.exec(args, options);
17091
+ if (result.code !== 0) throw this.failure(`${what}: ${shortGitError(result, options.timeoutMs ?? COMMAND_TIMEOUT_MS)}`);
17092
+ return result;
17093
+ }
17094
+ async exec(args, options = {}) {
17095
+ if (this.prepare.signal?.aborted) throw new PrepareAborted("stopped");
17096
+ let result;
17097
+ try {
17098
+ result = await this.options.git(args, { ...options, env: { ...this.prepare.gitEnvironment, ...options.env }, signal: this.prepare.signal });
17099
+ } catch (error) {
17100
+ throw this.failure(`git could not be run: ${describeError(error)}`);
17101
+ }
17102
+ if (result.aborted) throw new PrepareAborted("stopped");
17103
+ return result;
17104
+ }
17105
+ };
17106
+
17107
+ // src/runner/prune.ts
17108
+ var PRUNE_AFTER_MS = 14 * 24 * 60 * 60 * 1e3;
17109
+ async function pruneCommand(args, io, environment = {}) {
17110
+ parseArgs4({ args, options: {}, strict: true, allowPositionals: false });
17111
+ const env = environment.env ?? process.env;
17112
+ const home = runnerHome(env);
17113
+ const darwin = (environment.platform ?? process.platform) === "darwin";
17114
+ const busy = await runningSessions(home, environment.launchAgent ?? (darwin ? new LaunchAgent() : void 0));
17115
+ if (busy.count > 0) {
17116
+ io.err(`Not pruned: Codesona Runner is running ${busy.count === 1 ? "a session" : `${busy.count} sessions`}. Run codesona-runner prune again once it is idle (codesona-runner status shows its sessions).`);
17117
+ return ExitCode.Failed;
17118
+ }
17119
+ const report = await pruneRepositories(runnerFiles(home), environment.git ?? gitWith(env), (environment.now ?? Date.now)());
17120
+ for (const worktree of report.removedWorktrees) io.out(`removed ${worktree}`);
17121
+ for (const branch of report.deletedBranches) io.out(`deleted branch ${branch}`);
17122
+ for (const sentence of report.kept) io.out(`kept ${sentence}`);
17123
+ if (report.removedWorktrees.length === 0 && report.deletedBranches.length === 0) io.out("Nothing to prune.");
17124
+ return ExitCode.Ok;
17125
+ }
17126
+ async function pruneRepositories(files, git, now, olderThanMs = PRUNE_AFTER_MS) {
17127
+ const report = { removedWorktrees: [], deletedBranches: [], kept: [] };
17128
+ let caches;
17129
+ try {
17130
+ caches = fs16.readdirSync(files.repos).filter((name) => name.endsWith(".git")).map((name) => path23.join(files.repos, name));
17131
+ } catch {
17132
+ return report;
17133
+ }
17134
+ for (const cache of caches) await pruneRepository(cache, git, now - olderThanMs, report);
17135
+ return report;
17136
+ }
17137
+ async function pruneRepository(cache, git, before, report) {
17138
+ const name = path23.basename(cache, ".git");
17139
+ const fetched = await git(["fetch", "--prune", "--quiet", "origin"], { cwd: cache, timeoutMs: 5 * 60 * 1e3 });
17140
+ if (fetched.code !== 0) {
17141
+ report.kept.push(`everything of ${name}: origin could not be asked (${shortGitError(fetched)})`);
17142
+ return;
17143
+ }
17144
+ await git(["worktree", "prune"], { cwd: cache });
17145
+ const onOrigin = async (branch) => (await git(["merge-base", "--is-ancestor", `refs/heads/${branch}`, `refs/remotes/origin/${branch}`], { cwd: cache })).code === 0;
17146
+ const worktrees = await listWorktrees(cache, git);
17147
+ const checkedOut = /* @__PURE__ */ new Set();
17148
+ for (const worktree of worktrees) {
17149
+ if (!worktree.branch) continue;
17150
+ checkedOut.add(worktree.branch);
17151
+ if (lastTouched(worktree.path) > before) continue;
17152
+ const status = await git(["status", "--porcelain"], { cwd: worktree.path });
17153
+ if (status.code !== 0 || status.stdout.trim()) {
17154
+ report.kept.push(`${worktree.path}: it has changes that were never committed`);
17155
+ continue;
17156
+ }
17157
+ if (!await onOrigin(worktree.branch)) {
17158
+ report.kept.push(`${worktree.path}: ${worktree.branch} has commits origin does not`);
17159
+ continue;
17160
+ }
17161
+ if ((await git(["worktree", "remove", "--force", worktree.path], { cwd: cache })).code !== 0) continue;
17162
+ report.removedWorktrees.push(worktree.path);
17163
+ checkedOut.delete(worktree.branch);
17164
+ }
17165
+ const refs = await git(["for-each-ref", "--format=%(refname:short)%09%(committerdate:unix)", "refs/heads"], { cwd: cache });
17166
+ for (const line of refs.code === 0 ? refs.stdout.split("\n").filter(Boolean) : []) {
17167
+ const [branch = "", committed = "0"] = line.split(" ");
17168
+ if (!branch || checkedOut.has(branch) || Number(committed) * 1e3 > before) continue;
17169
+ if (!await onOrigin(branch)) {
17170
+ report.kept.push(`branch ${branch} of ${name}: it has commits origin does not`);
17171
+ continue;
17172
+ }
17173
+ if ((await git(["branch", "-D", "--quiet", branch], { cwd: cache })).code === 0) report.deletedBranches.push(`${branch} (${name})`);
17174
+ }
17175
+ }
17176
+ async function listWorktrees(cache, git) {
17177
+ const listed = await git(["worktree", "list", "--porcelain"], { cwd: cache });
17178
+ if (listed.code !== 0) return [];
17179
+ const entries = [];
17180
+ for (const block of listed.stdout.split("\n\n")) {
17181
+ const lines = block.split("\n");
17182
+ const at = lines.find((line) => line.startsWith("worktree "))?.slice("worktree ".length);
17183
+ if (!at || lines.includes("bare")) continue;
17184
+ const branch = lines.find((line) => line.startsWith("branch refs/heads/"))?.slice("branch refs/heads/".length);
17185
+ entries.push({ path: at, branch });
17186
+ }
17187
+ return entries;
17188
+ }
17189
+ function lastTouched(worktree) {
17190
+ let gitDirectory;
17191
+ try {
17192
+ const pointer = /^gitdir:\s*(.+)$/m.exec(fs16.readFileSync(path23.join(worktree, ".git"), "utf8"))?.[1]?.trim();
17193
+ if (pointer) gitDirectory = path23.resolve(worktree, pointer);
17194
+ } catch {
17195
+ }
17196
+ const candidates = [worktree, ...gitDirectory ? ["index", "HEAD", path23.join("logs", "HEAD")].map((file) => path23.join(gitDirectory, file)) : []];
17197
+ let latest = 0;
17198
+ for (const file of candidates) {
17199
+ try {
17200
+ latest = Math.max(latest, fs16.statSync(file).mtimeMs);
17201
+ } catch {
17202
+ }
17203
+ }
17204
+ return latest;
17205
+ }
17206
+
17207
+ // src/runner/restart.ts
17208
+ import { parseArgs as parseArgs5 } from "node:util";
16696
17209
  async function restartCommand(args, io, environment = {}) {
16697
- const { values } = parseArgs4({ args, options: { force: { type: "boolean" } }, strict: true, allowPositionals: false });
17210
+ const { values } = parseArgs5({ args, options: { force: { type: "boolean" } }, strict: true, allowPositionals: false });
16698
17211
  const byHand = "A Runner run by hand (codesona-runner run) is restarted by stopping it (Ctrl-C) and running it again.";
16699
17212
  if ((environment.platform ?? process.platform) !== "darwin") {
16700
17213
  io.err(`codesona-runner restart restarts the background service, which Codesona Runner sets up on macOS only. ${byHand}`);
@@ -16750,11 +17263,11 @@ async function restartCommand(args, io, environment = {}) {
16750
17263
  }
16751
17264
 
16752
17265
  // src/runner/run.ts
16753
- import { parseArgs as parseArgs5 } from "node:util";
17266
+ import { parseArgs as parseArgs6 } from "node:util";
16754
17267
 
16755
17268
  // src/runner/logfile.ts
16756
- import fs14 from "node:fs";
16757
- import path21 from "node:path";
17269
+ import fs17 from "node:fs";
17270
+ import path24 from "node:path";
16758
17271
  var LOG_MAX_BYTES = 10 * 1024 * 1024;
16759
17272
  var RotatingLogFile = class {
16760
17273
  constructor(file, maxBytes = LOG_MAX_BYTES) {
@@ -16772,36 +17285,36 @@ var RotatingLogFile = class {
16772
17285
  const bytes = Buffer.byteLength(data);
16773
17286
  if (this.fd === void 0) this.open();
16774
17287
  if (this.size > 0 && this.size + bytes > this.maxBytes) this.turnOver();
16775
- fs14.writeSync(this.fd, data);
17288
+ fs17.writeSync(this.fd, data);
16776
17289
  this.size += bytes;
16777
17290
  } catch {
16778
17291
  }
16779
17292
  }
16780
17293
  close() {
16781
- if (this.fd !== void 0) fs14.closeSync(this.fd);
17294
+ if (this.fd !== void 0) fs17.closeSync(this.fd);
16782
17295
  this.fd = void 0;
16783
17296
  }
16784
17297
  open() {
16785
- ensurePrivateDirectory(path21.dirname(this.file));
16786
- this.fd = fs14.openSync(this.file, "a", 384);
16787
- this.size = fs14.fstatSync(this.fd).size;
17298
+ ensurePrivateDirectory(path24.dirname(this.file));
17299
+ this.fd = fs17.openSync(this.file, "a", 384);
17300
+ this.size = fs17.fstatSync(this.fd).size;
16788
17301
  }
16789
17302
  turnOver() {
16790
17303
  this.close();
16791
- fs14.renameSync(this.file, `${this.file}.1`);
17304
+ fs17.renameSync(this.file, `${this.file}.1`);
16792
17305
  this.open();
16793
17306
  }
16794
17307
  };
16795
17308
  function trimFile(file, maxBytes = LOG_MAX_BYTES) {
16796
17309
  try {
16797
- if (fs14.statSync(file).size > maxBytes) fs14.truncateSync(file, 0);
17310
+ if (fs17.statSync(file).size > maxBytes) fs17.truncateSync(file, 0);
16798
17311
  } catch {
16799
17312
  }
16800
17313
  }
16801
17314
 
16802
17315
  // src/runner/events.ts
16803
- import fs15 from "node:fs";
16804
- import path22 from "node:path";
17316
+ import fs18 from "node:fs";
17317
+ import path25 from "node:path";
16805
17318
  var MAX_TEXT = 2 * 1024;
16806
17319
  var MAX_BATCH_EVENTS = 32;
16807
17320
  var MAX_BATCH_BYTES = 24 * 1024;
@@ -16892,7 +17405,7 @@ var ActivityChannel = class {
16892
17405
  }
16893
17406
  try {
16894
17407
  ensurePrivateDirectory(this.options.spoolDirectory);
16895
- fs15.appendFileSync(path22.join(this.options.spoolDirectory, `${safeFileStem(runId)}.ndjson`), lines, { mode: 384 });
17408
+ fs18.appendFileSync(path25.join(this.options.spoolDirectory, `${safeFileStem(runId)}.ndjson`), lines, { mode: 384 });
16896
17409
  this.spooled += bytes;
16897
17410
  } catch (error) {
16898
17411
  this.options.log.warn(`Run ${runId}: activity could not be spooled (${describeError(error)})`);
@@ -16909,7 +17422,7 @@ var ActivityChannel = class {
16909
17422
  async replayFiles() {
16910
17423
  for (const name of listSpool(this.options.spoolDirectory)) {
16911
17424
  if (!this.online) return;
16912
- const file = path22.join(this.options.spoolDirectory, name);
17425
+ const file = path25.join(this.options.spoolDirectory, name);
16913
17426
  const runId = name.slice(0, -".ndjson".length);
16914
17427
  let events;
16915
17428
  try {
@@ -16926,7 +17439,7 @@ var ActivityChannel = class {
16926
17439
  }
16927
17440
  }
16928
17441
  if (!sentAll) return;
16929
- fs15.rmSync(file, { force: true });
17442
+ fs18.rmSync(file, { force: true });
16930
17443
  }
16931
17444
  this.spooled = spoolSize(this.options.spoolDirectory);
16932
17445
  this.droppedReported = false;
@@ -16954,7 +17467,7 @@ function batchBytes(events) {
16954
17467
  }
16955
17468
  function listSpool(directory) {
16956
17469
  try {
16957
- return fs15.readdirSync(directory).filter((name) => name.endsWith(".ndjson")).sort();
17470
+ return fs18.readdirSync(directory).filter((name) => name.endsWith(".ndjson")).sort();
16958
17471
  } catch {
16959
17472
  return [];
16960
17473
  }
@@ -16962,7 +17475,7 @@ function listSpool(directory) {
16962
17475
  function spoolSize(directory) {
16963
17476
  return listSpool(directory).reduce((total, name) => {
16964
17477
  try {
16965
- return total + fs15.statSync(path22.join(directory, name)).size;
17478
+ return total + fs18.statSync(path25.join(directory, name)).size;
16966
17479
  } catch {
16967
17480
  return total;
16968
17481
  }
@@ -16970,7 +17483,7 @@ function spoolSize(directory) {
16970
17483
  }
16971
17484
  function readSpool(file) {
16972
17485
  const events = [];
16973
- for (const line of fs15.readFileSync(file, "utf8").split("\n")) {
17486
+ for (const line of fs18.readFileSync(file, "utf8").split("\n")) {
16974
17487
  if (!line) continue;
16975
17488
  try {
16976
17489
  events.push(JSON.parse(line));
@@ -20029,7 +20542,7 @@ var HubClient = class {
20029
20542
  }
20030
20543
  this.stateValue = HubState.Connected;
20031
20544
  this.lastError = void 0;
20032
- this.factsTold = { connection, facts: { harnesses: hello.harnesses, sleepPrevented: hello.sleepPrevented } };
20545
+ this.factsTold = { connection, facts: { harnesses: hello.harnesses, sleepPrevented: hello.sleepPrevented, tools: hello.tools } };
20033
20546
  this.options.log.info(`Connected to Codesona as ${reply.machineName ?? reply.machineId} (${reply.agents?.length ?? 0} agent(s) bound)`);
20034
20547
  await this.accept(connection, reply, credential);
20035
20548
  const error = await closed;
@@ -20188,7 +20701,8 @@ var HubClient = class {
20188
20701
  });
20189
20702
  return {
20190
20703
  ...sameHarnesses(harnesses, told.facts.harnesses) ? {} : { harnesses },
20191
- ...now.sleepPrevented == null || now.sleepPrevented === told.facts.sleepPrevented ? {} : { sleepPrevented: now.sleepPrevented }
20704
+ ...now.sleepPrevented == null || now.sleepPrevented === told.facts.sleepPrevented ? {} : { sleepPrevented: now.sleepPrevented },
20705
+ ...now.tools == null || JSON.stringify(now.tools) === JSON.stringify(told.facts.tools ?? null) ? {} : { tools: now.tools }
20192
20706
  };
20193
20707
  }
20194
20708
  scheduleRenewal(credential) {
@@ -20296,11 +20810,11 @@ var SignalRLog = class {
20296
20810
  };
20297
20811
 
20298
20812
  // src/runner/leftovers.ts
20299
- import { execFile as execFile2 } from "node:child_process";
20300
- import fs16 from "node:fs";
20301
- import path23 from "node:path";
20813
+ import { execFile as execFile3 } from "node:child_process";
20814
+ import fs19 from "node:fs";
20815
+ import path26 from "node:path";
20302
20816
  var listProcesses = () => new Promise((resolve4) => {
20303
- execFile2("ps", ["-axww", "-o", "pid=,ppid=,pgid=,command="], { timeout: 5e3, maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => {
20817
+ execFile3("ps", ["-axww", "-o", "pid=,ppid=,pgid=,command="], { timeout: 5e3, maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => {
20304
20818
  if (error) {
20305
20819
  resolve4([]);
20306
20820
  return;
@@ -20335,7 +20849,7 @@ var LiveRuns = class {
20335
20849
  /** Never throws: it is called on the way out of a run, and a run that does not get out holds the service's stop. */
20336
20850
  remove(runId) {
20337
20851
  try {
20338
- fs16.rmSync(this.file(runId), { force: true });
20852
+ fs19.rmSync(this.file(runId), { force: true });
20339
20853
  } catch (error) {
20340
20854
  this.log.warn(`Run ${runId}: its state file could not be removed (${describeError(error)})`);
20341
20855
  }
@@ -20351,11 +20865,11 @@ var LiveRuns = class {
20351
20865
  const records = [];
20352
20866
  for (const name of listJsonFiles(this.directory)) {
20353
20867
  try {
20354
- const record6 = readJsonFile(path23.join(this.directory, name));
20868
+ const record6 = readJsonFile(path26.join(this.directory, name));
20355
20869
  if (record6?.runId) records.push(record6);
20356
20870
  } catch {
20357
20871
  }
20358
- fs16.rmSync(path23.join(this.directory, name), { force: true });
20872
+ fs19.rmSync(path26.join(this.directory, name), { force: true });
20359
20873
  }
20360
20874
  const withGroups = records.filter((record6) => Number.isSafeInteger(record6.processGroup) && record6.processGroup > 1);
20361
20875
  if (withGroups.length === 0) return 0;
@@ -20378,13 +20892,13 @@ var LiveRuns = class {
20378
20892
  }
20379
20893
  }
20380
20894
  file(runId) {
20381
- return path23.join(this.directory, `${safeFileStem(runId)}.json`);
20895
+ return path26.join(this.directory, `${safeFileStem(runId)}.json`);
20382
20896
  }
20383
20897
  };
20384
20898
 
20385
20899
  // src/runner/outbox.ts
20386
- import fs17 from "node:fs";
20387
- import path24 from "node:path";
20900
+ import fs20 from "node:fs";
20901
+ import path27 from "node:path";
20388
20902
  var OUTBOX_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
20389
20903
  var END_BODY_MAX_BYTES = 1024 * 1024;
20390
20904
  var END_BODY_RETRY_BYTES = 64 * 1024;
@@ -20446,26 +20960,26 @@ var Outbox = class {
20446
20960
  list(log) {
20447
20961
  const entries = [];
20448
20962
  for (const name of listJsonFiles(this.directory)) {
20449
- const file = path24.join(this.directory, name);
20963
+ const file = path27.join(this.directory, name);
20450
20964
  try {
20451
20965
  const entry = readJsonFile(file);
20452
20966
  if (!entry?.end?.runId) throw new Error("no run id");
20453
20967
  entries.push({ end: entry.end, recordedAt: Date.parse(entry.recordedAt) || 0 });
20454
20968
  } catch (error) {
20455
20969
  log.error(`Outbox entry ${name} cannot be read (${describeError(error)}); moved aside`);
20456
- fs17.renameSync(file, `${file}.unreadable`);
20970
+ fs20.renameSync(file, `${file}.unreadable`);
20457
20971
  }
20458
20972
  }
20459
20973
  return entries.sort((a, b) => a.recordedAt - b.recordedAt);
20460
20974
  }
20461
20975
  has(runId) {
20462
- return fs17.existsSync(this.file(runId));
20976
+ return fs20.existsSync(this.file(runId));
20463
20977
  }
20464
20978
  remove(runId) {
20465
- fs17.rmSync(this.file(runId), { force: true });
20979
+ fs20.rmSync(this.file(runId), { force: true });
20466
20980
  }
20467
20981
  file(runId) {
20468
- return path24.join(this.directory, `${safeFileStem(runId)}.json`);
20982
+ return path27.join(this.directory, `${safeFileStem(runId)}.json`);
20469
20983
  }
20470
20984
  };
20471
20985
  function deliveryOutcomeOf(result) {
@@ -20598,8 +21112,8 @@ var EndReporter = class {
20598
21112
  };
20599
21113
 
20600
21114
  // src/runner/supervisor.ts
20601
- import fs18 from "node:fs";
20602
- import path25 from "node:path";
21115
+ import fs21 from "node:fs";
21116
+ import path28 from "node:path";
20603
21117
 
20604
21118
  // src/harness/claude-result.ts
20605
21119
  var ZERO_USAGE = { costUsd: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 };
@@ -20856,6 +21370,7 @@ var Supervisor = class {
20856
21370
  constructor(options) {
20857
21371
  this.options = options;
20858
21372
  this.hubWatchdog = new HubWatchdog(() => this.hubLost(), options.hubOfflineLimitMs, options.clock);
21373
+ this.repos = options.repos ?? new RepoPreparer({ cacheDirectory: options.files.repos, git: gitWith(process.env), log: options.log });
20859
21374
  }
20860
21375
  options;
20861
21376
  sessions = /* @__PURE__ */ new Map();
@@ -20867,6 +21382,7 @@ var Supervisor = class {
20867
21382
  machinePaused = false;
20868
21383
  updateRequired = false;
20869
21384
  shuttingDown = false;
21385
+ repos;
20870
21386
  // ── What the hub says ──
20871
21387
  /** The machine's bindings, from Hello or AgentsChanged. Attachment is kept for an agent the hub still lists. */
20872
21388
  setAgents(assignments) {
@@ -20946,6 +21462,11 @@ var Supervisor = class {
20946
21462
  if (harness) return this.refuse(runId, harness.reason, harness.detail);
20947
21463
  const bundle = await this.readBundle(runId, Math.max(0, deadline - Date.now()));
20948
21464
  if ("reason" in bundle) return this.refuse(runId, bundle.reason, bundle.detail);
21465
+ const repos = bundle.bundle.prepare?.repos ?? [];
21466
+ if (repos.length > 0) {
21467
+ const unreachable = await this.repoRefusal(repos, Math.max(0, deadline - Date.now()));
21468
+ if (unreachable) return this.refuse(runId, StartRefusal.RepoUnavailable, unreachable);
21469
+ }
20949
21470
  const refusal = this.stateRefusal(message);
20950
21471
  if (refusal) return this.refuse(runId, refusal);
20951
21472
  const session = new RunSession(this, message);
@@ -21013,6 +21534,14 @@ var Supervisor = class {
21013
21534
  });
21014
21535
  return Promise.race([reading, late]).finally(() => clearTimeout(timer));
21015
21536
  }
21537
+ /** A sentence naming a repository this machine's git cannot reach, within `withinMs`; undefined when it reaches them all. */
21538
+ async repoRefusal(repos, withinMs) {
21539
+ try {
21540
+ return await this.repos.checkAccess(repos, withinMs);
21541
+ } catch (error) {
21542
+ return `This Mac could not check its access to the run's repositories: ${describeError(error)}`;
21543
+ }
21544
+ }
21016
21545
  stopSession(message, why = "Codesona stopped the run") {
21017
21546
  const session = this.sessions.get(message?.runId);
21018
21547
  if (!session) return { known: this.ended.has(message?.runId) };
@@ -21128,6 +21657,8 @@ var RunSession = class {
21128
21657
  turnEnded;
21129
21658
  cwd;
21130
21659
  over = false;
21660
+ /** Stops the git that prepares the run's repositories, when the run is stopped before its session opens. */
21661
+ preparing = new AbortController();
21131
21662
  done;
21132
21663
  resolveDone;
21133
21664
  get options() {
@@ -21154,6 +21685,7 @@ var RunSession = class {
21154
21685
  if (this.stop) return;
21155
21686
  this.stop = stop;
21156
21687
  this.options.log.info(`Run ${this.runId}: stopping (${why})`);
21688
+ this.preparing.abort();
21157
21689
  void this.stopDriver();
21158
21690
  }
21159
21691
  async reportStarted() {
@@ -21182,7 +21714,7 @@ var RunSession = class {
21182
21714
  async execute(bundle) {
21183
21715
  if (this.stop) return this.stoppedBeforeStart();
21184
21716
  const route = this.options.proxy.register(this.runId, this.ticket, (refusal) => this.requestStop(gatewayStop(refusal), `the gateway answered ${refusal}`));
21185
- const cwd = path25.join(this.options.files.agents, safeFileStem(this.agentId));
21717
+ const cwd = path28.join(this.options.files.agents, safeFileStem(this.agentId));
21186
21718
  this.cwd = cwd;
21187
21719
  let workspace;
21188
21720
  try {
@@ -21190,6 +21722,18 @@ var RunSession = class {
21190
21722
  } catch (error) {
21191
21723
  return this.endOf({ launchError: `The session's files could not be written: ${describeError(error)}` });
21192
21724
  }
21725
+ const repos = bundle.bundle.prepare?.repos ?? [];
21726
+ if (repos.length > 0) {
21727
+ try {
21728
+ await this.supervisor.repos.prepare(cwd, repos, { signal: this.preparing.signal, gitEnvironment: sessionGitEnvironment(bundle.bundle.env) });
21729
+ this.options.log.info(`Run ${this.runId}: ${repos.length} repositor${repos.length === 1 ? "y" : "ies"} prepared under ${path28.join(cwd, "repos")}`);
21730
+ } catch (error) {
21731
+ if (this.stop || error instanceof PrepareAborted) return this.stoppedBeforeStart();
21732
+ const why = error instanceof PrepareError ? error.message : `git failed: ${describeError(error)}`;
21733
+ return this.endOf({ launchError: `The repositories could not be prepared: ${why}`, launchErrorKind: LaunchErrorKind.PrepareFailed });
21734
+ }
21735
+ if (this.stop) return this.stoppedBeforeStart();
21736
+ }
21193
21737
  let resume = bundle.resume && this.transcriptExists(cwd, bundle.sessionId);
21194
21738
  if (bundle.resume && !resume) this.options.log.info(`Run ${this.runId}: no transcript of session ${bundle.sessionId} on this machine; starting it as a new conversation`);
21195
21739
  this.options.liveRuns.record({
@@ -21216,10 +21760,10 @@ var RunSession = class {
21216
21760
  transcriptExists(cwd, sessionId) {
21217
21761
  const candidates = /* @__PURE__ */ new Set([cwd]);
21218
21762
  try {
21219
- candidates.add(fs18.realpathSync(cwd));
21763
+ candidates.add(fs21.realpathSync(cwd));
21220
21764
  } catch {
21221
21765
  }
21222
- return [...candidates].some((directory) => fs18.existsSync(claudeTranscriptPath(this.options.claudeConfigDir, directory, sessionId)));
21766
+ return [...candidates].some((directory) => fs21.existsSync(claudeTranscriptPath(this.options.claudeConfigDir, directory, sessionId)));
21223
21767
  }
21224
21768
  async attempt(number3, bundle, workspace, resume) {
21225
21769
  const reader = new ClaudeResultReader();
@@ -21430,6 +21974,7 @@ var RunSession = class {
21430
21974
  harnessSessionId: this.harnessSessionId ?? null,
21431
21975
  stopReason: parts.stopReason ?? null,
21432
21976
  launchError: parts.launchError ?? null,
21977
+ launchErrorKind: parts.launchError ? parts.launchErrorKind ?? null : null,
21433
21978
  result
21434
21979
  };
21435
21980
  }
@@ -21448,7 +21993,7 @@ function sleep2(ms) {
21448
21993
  }
21449
21994
 
21450
21995
  // src/runner/usage.ts
21451
- import path26 from "node:path";
21996
+ import path29 from "node:path";
21452
21997
  var SessionUsageLedger = class {
21453
21998
  constructor(directory, log) {
21454
21999
  this.directory = directory;
@@ -21480,7 +22025,7 @@ var SessionUsageLedger = class {
21480
22025
  `);
21481
22026
  }
21482
22027
  file(sessionId) {
21483
- return path26.join(this.directory, `${safeFileStem(sessionId)}.json`);
22028
+ return path29.join(this.directory, `${safeFileStem(sessionId)}.json`);
21484
22029
  }
21485
22030
  };
21486
22031
  function number2(value) {
@@ -21519,7 +22064,8 @@ function sleep3(ms) {
21519
22064
  function setupFacts(harnesses, setup) {
21520
22065
  return {
21521
22066
  harnesses: harnesses.map((found) => found.harness === Harness.ClaudeCode && setup.claudeLogin ? { ...found, loggedIn: setup.claudeLogin.loggedIn } : found),
21522
- ...setup.sleepPrevented === void 0 ? {} : { sleepPrevented: setup.sleepPrevented }
22067
+ ...setup.sleepPrevented === void 0 ? {} : { sleepPrevented: setup.sleepPrevented },
22068
+ ...setup.tools === void 0 ? {} : { tools: setup.tools }
21523
22069
  };
21524
22070
  }
21525
22071
  function until(work, deadline) {
@@ -21570,6 +22116,8 @@ var RunnerService = class _RunnerService {
21570
22116
  this.supervisor = new Supervisor({
21571
22117
  files: this.files,
21572
22118
  host,
22119
+ // The owner's git, with their credential helper: the sessions' environment, as the session's own git has it.
22120
+ repos: new RepoPreparer({ cacheDirectory: this.files.repos, git: gitWith(options.environment ?? process.env), log }),
21573
22121
  sdk: createHarnessSdk(host),
21574
22122
  proxy: this.proxy,
21575
22123
  fetchBundle: async (runId) => {
@@ -21850,8 +22398,9 @@ var RunnerService = class _RunnerService {
21850
22398
  }
21851
22399
  }
21852
22400
  /**
21853
- * Claude Code's login, as the sessions' environment runs `claude auth status` (the CLI the sessions run), and `pmset -g`;
21854
- * looked at again after ten minutes, as the CLIs are. Neither holds Hello up for long, and neither fails it.
22401
+ * Claude Code's login, as the sessions' environment runs `claude auth status` (the CLI the sessions run), `pmset -g`, and
22402
+ * `git --version`, `dotnet --list-sdks` and `node --version` in that environment; looked at again after ten minutes, as
22403
+ * the CLIs are. Neither holds Hello up for long, and neither fails it.
21855
22404
  */
21856
22405
  machineSetup() {
21857
22406
  if (this.setup && Date.now() - this.setup.at < this.factsTtlMs) return Promise.resolve(this.setup.value);
@@ -21861,11 +22410,12 @@ var RunnerService = class _RunnerService {
21861
22410
  async lookAtSetup() {
21862
22411
  const environment = this.options.environment ?? process.env;
21863
22412
  const login = this.options.claudeLogin ?? (() => readClaudeLogin((file, args) => runCommand(file, args, { env: agentEnvironment(environment), timeoutMs: CLAUDE_LOGIN_TIMEOUT_MS }), this.options.claudeCommand));
21864
- const [claudeLogin, power] = await Promise.all([
22413
+ const [claudeLogin, power, tools] = await Promise.all([
21865
22414
  login().catch(() => void 0),
21866
- (this.options.powerSettings ?? (() => readPowerSettings()))().catch(() => void 0)
22415
+ (this.options.powerSettings ?? (() => readPowerSettings()))().catch(() => void 0),
22416
+ (this.options.machineTools ?? (() => readMachineTools(environment)))().catch(() => void 0)
21867
22417
  ]);
21868
- const value = { claudeLogin, sleepPrevented: power === void 0 ? void 0 : sleepPrevented(power) };
22418
+ const value = { claudeLogin, sleepPrevented: power === void 0 ? void 0 : sleepPrevented(power), tools };
21869
22419
  this.setup = { at: Date.now(), value };
21870
22420
  return value;
21871
22421
  }
@@ -21929,7 +22479,7 @@ var RunnerService = class _RunnerService {
21929
22479
 
21930
22480
  // src/runner/run.ts
21931
22481
  async function runCommand2(args, io, env = process.env) {
21932
- const { values } = parseArgs5({ args, options: { verbose: { type: "boolean" }, service: { type: "boolean" } }, strict: true, allowPositionals: false });
22482
+ const { values } = parseArgs6({ args, options: { verbose: { type: "boolean" }, service: { type: "boolean" } }, strict: true, allowPositionals: false });
21933
22483
  const home = runnerHome(env);
21934
22484
  const asService = values.service === true;
21935
22485
  let logFile;
@@ -21977,7 +22527,7 @@ async function serve(home, io, log, asService) {
21977
22527
 
21978
22528
  // src/runner/start.ts
21979
22529
  import os7 from "node:os";
21980
- import { parseArgs as parseArgs6 } from "node:util";
22530
+ import { parseArgs as parseArgs7 } from "node:util";
21981
22531
 
21982
22532
  // src/runner/pair.ts
21983
22533
  async function pairMachine(store, client, input) {
@@ -22064,7 +22614,7 @@ function machineKeyKind(key) {
22064
22614
  return KEY_PREFIXES[key.slice(0, 4)];
22065
22615
  }
22066
22616
  function parseStart(args) {
22067
- const { values } = parseArgs6({
22617
+ const { values } = parseArgs7({
22068
22618
  args,
22069
22619
  options: {
22070
22620
  "machine-key": { type: "string" },
@@ -22176,6 +22726,7 @@ var Command = {
22176
22726
  Doctor: "doctor",
22177
22727
  Status: "status",
22178
22728
  Restart: "restart",
22729
+ Prune: "prune",
22179
22730
  Logout: "logout"
22180
22731
  };
22181
22732
  var COMMANDS = {
@@ -22184,6 +22735,7 @@ var COMMANDS = {
22184
22735
  [Command.Doctor]: doctorCommand,
22185
22736
  [Command.Status]: statusCommand,
22186
22737
  [Command.Restart]: restartCommand,
22738
+ [Command.Prune]: (args, io) => pruneCommand(args, io),
22187
22739
  [Command.Logout]: logoutCommand
22188
22740
  };
22189
22741
  var HELP = `Codesona Runner ${runnerVersion}
@@ -22202,6 +22754,9 @@ Commands:
22202
22754
  restart the background service, so it connects anew and runs
22203
22755
  the Codesona Runner installed now; not while it runs sessions
22204
22756
  (--force: restart it all the same, ending them)
22757
+ prune remove the agents' repository worktrees and local task
22758
+ branches untouched for 14 days that origin already has;
22759
+ never unpushed work
22205
22760
  logout [--local]
22206
22761
  disconnect this machine from Codesona and remove the service
22207
22762
  (--local: remove this machine's side even if Codesona