@prohost/cli 0.8.3 → 0.9.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.
package/dist/agent/run.js CHANGED
@@ -10,9 +10,12 @@
10
10
  *
11
11
  * Two deliberate safety properties:
12
12
  *
13
- * * **Runs are serialized.** One local agent, one run at a time; concurrent
14
- * frames queue. Agents hold working directories and rate limits, and running
15
- * several at once is a good way to corrupt both.
13
+ * * **Runs are serialized by default.** One local agent, one run at a time;
14
+ * concurrent frames queue. Agents hold working directories and rate limits,
15
+ * and running several at once is a good way to corrupt both. `--concurrency`
16
+ * raises the limit for runs in *different* conversations only — two turns of
17
+ * one conversation never overlap (see `scheduler.ts`) — and `--repos` gives
18
+ * each conversation its own git checkouts to do it in (see `worktrees.ts`).
16
19
  * * **Runs are deduped on ``run_id``.** Webhook deliveries retry, and each
17
20
  * attempt is mirrored to this socket. Without dedupe a retry would execute
18
21
  * the command a second time and post a second reply.
@@ -40,9 +43,11 @@ import { HeartbeatLoop } from './heartbeat.js';
40
43
  import { buildPrompt, replyFromStdout } from './prompt.js';
41
44
  import { NO_CAPABILITIES, capabilityProbe } from './capabilities.js';
42
45
  import { RunStore } from './runstore.js';
46
+ import { MAX_CONCURRENCY, RunScheduler } from './scheduler.js';
43
47
  import { RUNTIME_STATUS_FEATURE, RuntimeStatusReporter, claudeQuotaProvider, codexConfiguredModel, codexQuotaProvider, modelFromExec, parseVersion, readCodexObservation, } from './runtime_status.js';
44
48
  import { SessionStore, sessionKeyFor } from './sessionstore.js';
45
49
  import { verifySignature } from './signature.js';
50
+ import { RUN_REPOS_ENV_VAR, WorktreeManager } from './worktrees.js';
46
51
  import { isWebhookEvent } from '../forward.js';
47
52
  import { DEFAULT_WS_URL } from '../listen.js';
48
53
  import { ServerLiveness, enableTcpKeepAlive, seconds, terminateSocket } from '../liveness.js';
@@ -89,6 +94,15 @@ const RECONNECT_MAX_DELAY_MS = 30_000;
89
94
  * would hang with neither `open` nor `close` to move the loop along.
90
95
  */
91
96
  const HANDSHAKE_TIMEOUT_MS = 30_000;
97
+ /**
98
+ * Set to `1` in the environment of every agent command this harness starts.
99
+ *
100
+ * Lets instructions the agent reads from disk tell a paired run — someone is
101
+ * waiting in a thread, and the run holds one of this agent's few slots — from
102
+ * the same file being read anywhere else. ProhostAI's own pull-request protocol
103
+ * uses it to stop a run from sitting on CI after the PR is open.
104
+ */
105
+ export const PAIRED_RUN_ENV_VAR = 'PROHOST_PAIRED_RUN';
92
106
  /** Ack status codes reported back over the socket for each frame. */
93
107
  const ACK_ACCEPTED = 200;
94
108
  const ACK_IGNORED = 204;
@@ -228,6 +242,16 @@ function resolveWorkdir(options, log) {
228
242
  return undefined;
229
243
  }
230
244
  }
245
+ /**
246
+ * The lane a run waits on: runs sharing one never overlap.
247
+ *
248
+ * The session key, because a session is exactly what two overlapping turns of
249
+ * one conversation would corrupt. A run with no surface resumes nothing and
250
+ * shares nothing, so it gets a lane of its own.
251
+ */
252
+ function laneFor(run) {
253
+ return sessionKeyFor(run) ?? `run:${run.run_id}`;
254
+ }
231
255
  /**
232
256
  * Assemble the per-process runtime: workspace, session ledger, and the
233
257
  * brain-specific MCP preparation used immediately before each run.
@@ -330,7 +354,11 @@ function buildRuntime(options, log) {
330
354
  if (!resolved.ok)
331
355
  return { mcpWired: false, error: resolved.error };
332
356
  const prepared = prepareMcp();
333
- return { ...prepared, execEnv: { ...account.spawnEnv(), ...prepared.execEnv } };
357
+ return {
358
+ ...prepared,
359
+ execEnv: { ...account.spawnEnv(), ...prepared.execEnv },
360
+ accountLabel: resolved.record.label,
361
+ };
334
362
  };
335
363
  log(`${timestamp()} ⚙ account: ${account.label()}`);
336
364
  return {
@@ -579,7 +607,9 @@ async function runTurn(run, prompt, options, runtime, mcp, control, log,
579
607
  * Where the streaming brain's tool-call events go, if anywhere. Only the
580
608
  * Claude Code reader produces any; absent, they are simply never collected.
581
609
  */
582
- toolEvents) {
610
+ toolEvents,
611
+ /** Harness-set variables for this run's command, over the brain's own. */
612
+ runEnv) {
583
613
  // `--timeout` is a ceiling on the run, not on each attempt: the retry below
584
614
  // must not let one run take twice what the operator allowed. Both attempts
585
615
  // share one deadline, which costs the retry nothing in practice — a rejected
@@ -667,7 +697,7 @@ toolEvents) {
667
697
  timeoutMs: capped ? Math.max(1, remainingMs()) : 0,
668
698
  idleTimeoutMs: idleMs,
669
699
  cwd: runtime.workdir,
670
- env: mcp.execEnv,
700
+ env: { ...mcp.execEnv, ...runEnv },
671
701
  spawnImpl: options.spawnImpl,
672
702
  cancelSignal: control.signal,
673
703
  onStdout: reader
@@ -842,11 +872,18 @@ toolEvents) {
842
872
  * Every path except a dry run — including every failure — calls the completion
843
873
  * endpoint before returning.
844
874
  */
845
- async function handleRun(run, options, runtime, api, log, sleep, control) {
875
+ async function handleRun(run, options, runtime, api, log, sleep, control, isolation,
876
+ /** Prepared by the caller, which also hands the run's account back. */
877
+ mcp) {
846
878
  const label = `run ${run.run_id.slice(0, 12)}`;
847
879
  const surface = run.conversation_id ?? run.kind ?? 'no conversation';
848
880
  log(`${timestamp()} ▶ ${label} started (${surface})`);
849
- const mcp = runtime.prepareMcp();
881
+ // Before anything is spawned: the prompt names these paths. A dry run makes
882
+ // no writes, and a worktree is one.
883
+ const checkouts = isolation.worktrees.enabled && !options.dryRun ? await isolation.worktrees.prepare(isolation.lane) : undefined;
884
+ for (const failure of checkouts?.failures ?? []) {
885
+ log(` ! no private checkout of ${failure.source} for ${label} (${failure.reason})`);
886
+ }
850
887
  // Only worth asking when the tools it describes are wired into this run.
851
888
  const capabilities = mcp.mcpWired ? await runtime.capabilities() : NO_CAPABILITIES;
852
889
  const prompt = buildPrompt(run, {
@@ -865,6 +902,9 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
865
902
  // The real numbers, not a vague "there is a limit" — see PromptContext.
866
903
  timeoutMs: options.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS,
867
904
  idleTimeoutMs: options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS,
905
+ parallel: isolation.parallel,
906
+ checkouts: checkouts?.checkouts,
907
+ checkoutFailures: checkouts?.failures,
868
908
  });
869
909
  // Tool-call narration for the thread. A dry run makes no writes, so it gets
870
910
  // no path and every event is dropped; so does a server that advertised none.
@@ -874,7 +914,10 @@ async function handleRun(run, options, runtime, api, log, sleep, control) {
874
914
  log,
875
915
  label,
876
916
  });
877
- const result = await runTurn(run, prompt, options, runtime, mcp, control, log, toolEvents);
917
+ const runEnv = { [PAIRED_RUN_ENV_VAR]: '1' };
918
+ if (checkouts && checkouts.checkouts.length > 0)
919
+ runEnv[RUN_REPOS_ENV_VAR] = checkouts.dir;
920
+ const result = await runTurn(run, prompt, options, runtime, mcp, control, log, toolEvents, runEnv);
878
921
  // Whatever the turn did, the timeline is whole BEFORE the reply is posted or
879
922
  // the run is closed — a completion that beats its own last tool row would
880
923
  // leave a `calling` row on a finished run. Never rejects, and after `stop`
@@ -1109,7 +1152,19 @@ export async function runAgentHarness(options) {
1109
1152
  }
1110
1153
  return true;
1111
1154
  };
1112
- let queue = Promise.resolve();
1155
+ const concurrency = Math.min(MAX_CONCURRENCY, Math.max(1, Math.floor(options.concurrency ?? 1)));
1156
+ const scheduler = new RunScheduler(concurrency);
1157
+ const worktrees = new WorktreeManager({ repos: options.repos ?? [], env: options.env, log });
1158
+ if (concurrency > 1) {
1159
+ log(`${timestamp()} ⚙ up to ${concurrency} runs at once — runs in the same conversation still take turns` +
1160
+ (worktrees.enabled
1161
+ ? ''
1162
+ : '. No --repos given: runs share every checkout on this machine, so name the ' +
1163
+ 'repositories the agent edits to give each conversation its own'));
1164
+ }
1165
+ if (worktrees.enabled) {
1166
+ log(`${timestamp()} ⚙ each conversation gets its own checkout of: ${(options.repos ?? []).join(', ')}`);
1167
+ }
1113
1168
  let fatal;
1114
1169
  let attempt = 0;
1115
1170
  /** Stop tracking a run — it has reached a terminal state, one way or another. */
@@ -1195,7 +1250,7 @@ export async function runAgentHarness(options) {
1195
1250
  // Let an in-flight run finish reporting before we hand back metrics — and
1196
1251
  // any cancellation the heartbeat loop started and deliberately did not wait
1197
1252
  // for, so the harness never exits with a run half-closed.
1198
- await queue;
1253
+ await scheduler.onIdle();
1199
1254
  status.detach();
1200
1255
  // Stopped first, so no further tick can start work after the wait below has
1201
1256
  // taken its snapshot of `background`.
@@ -1404,7 +1459,7 @@ export async function runAgentHarness(options) {
1404
1459
  // completion. Retry only the completion: re-running the agent could
1405
1460
  // repeat whatever it did with its credential the first time.
1406
1461
  metrics.duplicates += 1;
1407
- queue = queue.then(async () => {
1462
+ scheduler.submit(laneFor(run), async () => {
1408
1463
  log(`${timestamp()} ↻ run ${run.run_id.slice(0, 12)} was started before but never reported; ` +
1409
1464
  'reporting it now without re-running the agent');
1410
1465
  const recovered = await completeWithRetry(api, previous.completion_path || run.completion_path, { ok: false, error: 'the harness restarted before this run was reported' }, log, sleep);
@@ -1434,9 +1489,11 @@ export async function runAgentHarness(options) {
1434
1489
  },
1435
1490
  onResolved: () => resolveAccepted(entry),
1436
1491
  });
1437
- // Serialize execution but never block the socket reader: the ack and
1438
- // the heartbeat have to keep flowing while the agent thinks.
1439
- queue = queue.then(async () => {
1492
+ // Queue execution but never block the socket reader: the ack and the
1493
+ // heartbeat have to keep flowing while the agent thinks. The lane is
1494
+ // what keeps two turns of one conversation from overlapping.
1495
+ const lane = laneFor(run);
1496
+ scheduler.submit(lane, async () => {
1440
1497
  // Settled while it waited — cancelled, or closed server-side. Both
1441
1498
  // were dealt with where they were decided; there is nothing to run
1442
1499
  // and nothing left to report.
@@ -1465,17 +1522,32 @@ export async function runAgentHarness(options) {
1465
1522
  entry.progress = activity;
1466
1523
  },
1467
1524
  };
1468
- const outcome = await handleRun(run, options, runtime, api, log, sleep, control).catch((err) => {
1525
+ const isolation = { lane, parallel: concurrency > 1, worktrees };
1526
+ // Resolved here rather than inside `handleRun` so the account this
1527
+ // run is on can be handed back however the run ends.
1528
+ let mcp;
1529
+ try {
1530
+ mcp = runtime.prepareMcp();
1531
+ }
1532
+ catch (err) {
1533
+ // Fails the run closed, and — unlike a throw from here — still
1534
+ // reports it, so the server is not left waiting on it.
1535
+ mcp = { mcpWired: false, error: `could not prepare the run (${String(err)})` };
1536
+ }
1537
+ const outcome = await handleRun(run, options, runtime, api, log, sleep, control, isolation, mcp).catch((err) => {
1469
1538
  log(`${timestamp()} ✗ run ${run.run_id} crashed the harness handler: ${String(err)}`);
1470
1539
  return 'failed';
1471
1540
  });
1541
+ if (mcp.accountLabel !== undefined)
1542
+ runtime.account?.finishSpawn(mcp.accountLabel);
1472
1543
  // Held until here on purpose: heartbeats keep the row alive for the
1473
1544
  // whole of `handleRun`, including the ten minutes of completion
1474
1545
  // retries at the end of it.
1475
1546
  release(run.run_id);
1476
1547
  // An account picked mid-run was held back from the status frame
1477
- // until this run (on the old account) was done; report it now.
1478
- if (runtime.account?.changedSinceSpawn())
1548
+ // until this run (on the old account) was done; report it now. Asked
1549
+ // about *this* run's account: a newer run may already be on the pick.
1550
+ if (runtime.account?.changedSinceSpawn(mcp.accountLabel))
1479
1551
  status.accountsChanged();
1480
1552
  // Both terminal outcomes reached the completion endpoint; only then
1481
1553
  // is the run settled and safe to skip on redelivery.
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Which accepted runs execute now, and which wait.
3
+ *
4
+ * `agent run` used to chain every run onto one promise: one local agent, one
5
+ * run at a time. That is still the default, and with a limit of 1 this
6
+ * scheduler is exactly that chain — strict arrival order, nothing overlapping.
7
+ *
8
+ * `--concurrency N` lifts the limit without lifting the rule that actually
9
+ * protects an answer: **two runs on the same lane never overlap, and they start
10
+ * in the order they arrived.** A lane is a conversation (see `sessionKeyFor`).
11
+ * Each turn resumes the session the previous turn left, so running two turns of
12
+ * one thread side by side would have both resume the same session and the
13
+ * second answer without having seen the first. Runs on different lanes share
14
+ * nothing but the machine, and those are the ones allowed to overlap.
15
+ *
16
+ * Among the runs free to start, the oldest goes first. A lane that is busy does
17
+ * not hold up the lanes behind it — which is the whole point: a quick question
18
+ * in one thread no longer waits out a two-hour job in another.
19
+ */
20
+ /** Most runs one harness will execute at once, whatever the flag says. */
21
+ export declare const MAX_CONCURRENCY = 16;
22
+ export declare class RunScheduler {
23
+ private readonly limit;
24
+ private readonly pending;
25
+ private readonly busy;
26
+ private waiters;
27
+ constructor(limit?: number);
28
+ /** How many tasks are executing right now. */
29
+ get active(): number;
30
+ /** How many tasks are waiting for a slot or for their lane. */
31
+ get queued(): number;
32
+ /**
33
+ * Queue a task on a lane. It starts as soon as a slot is free and no earlier
34
+ * task on the same lane is still running or waiting.
35
+ *
36
+ * The task must not reject: a rejection is swallowed so one broken run can
37
+ * never wedge its lane, but nothing reports it either — callers catch inside.
38
+ */
39
+ submit(lane: string, task: () => Promise<void>): void;
40
+ /** Resolves once nothing is executing and nothing is waiting. */
41
+ onIdle(): Promise<void>;
42
+ private pump;
43
+ private execute;
44
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Which accepted runs execute now, and which wait.
3
+ *
4
+ * `agent run` used to chain every run onto one promise: one local agent, one
5
+ * run at a time. That is still the default, and with a limit of 1 this
6
+ * scheduler is exactly that chain — strict arrival order, nothing overlapping.
7
+ *
8
+ * `--concurrency N` lifts the limit without lifting the rule that actually
9
+ * protects an answer: **two runs on the same lane never overlap, and they start
10
+ * in the order they arrived.** A lane is a conversation (see `sessionKeyFor`).
11
+ * Each turn resumes the session the previous turn left, so running two turns of
12
+ * one thread side by side would have both resume the same session and the
13
+ * second answer without having seen the first. Runs on different lanes share
14
+ * nothing but the machine, and those are the ones allowed to overlap.
15
+ *
16
+ * Among the runs free to start, the oldest goes first. A lane that is busy does
17
+ * not hold up the lanes behind it — which is the whole point: a quick question
18
+ * in one thread no longer waits out a two-hour job in another.
19
+ */
20
+ /** Most runs one harness will execute at once, whatever the flag says. */
21
+ export const MAX_CONCURRENCY = 16;
22
+ export class RunScheduler {
23
+ limit;
24
+ pending = [];
25
+ busy = new Set();
26
+ waiters = [];
27
+ constructor(limit = 1) {
28
+ this.limit = limit;
29
+ if (!Number.isInteger(limit) || limit < 1) {
30
+ throw new RangeError(`concurrency must be a whole number of at least 1, got ${String(limit)}`);
31
+ }
32
+ }
33
+ /** How many tasks are executing right now. */
34
+ get active() {
35
+ return this.busy.size;
36
+ }
37
+ /** How many tasks are waiting for a slot or for their lane. */
38
+ get queued() {
39
+ return this.pending.length;
40
+ }
41
+ /**
42
+ * Queue a task on a lane. It starts as soon as a slot is free and no earlier
43
+ * task on the same lane is still running or waiting.
44
+ *
45
+ * The task must not reject: a rejection is swallowed so one broken run can
46
+ * never wedge its lane, but nothing reports it either — callers catch inside.
47
+ */
48
+ submit(lane, task) {
49
+ this.pending.push({ lane, task });
50
+ this.pump();
51
+ }
52
+ /** Resolves once nothing is executing and nothing is waiting. */
53
+ onIdle() {
54
+ if (this.busy.size === 0 && this.pending.length === 0)
55
+ return Promise.resolve();
56
+ return new Promise((resolve) => {
57
+ this.waiters.push(resolve);
58
+ });
59
+ }
60
+ pump() {
61
+ while (this.busy.size < this.limit) {
62
+ // First in arrival order whose lane is free. An earlier task on a busy
63
+ // lane is skipped, not waited for; a later task on that same lane is
64
+ // skipped with it, which is what keeps a lane in order.
65
+ const index = this.pending.findIndex((entry) => !this.busy.has(entry.lane));
66
+ if (index === -1)
67
+ break;
68
+ const [next] = this.pending.splice(index, 1);
69
+ if (!next)
70
+ break;
71
+ this.busy.add(next.lane);
72
+ void this.execute(next);
73
+ }
74
+ if (this.busy.size === 0 && this.pending.length === 0) {
75
+ const waiters = this.waiters;
76
+ this.waiters = [];
77
+ for (const resolve of waiters)
78
+ resolve();
79
+ }
80
+ }
81
+ async execute(entry) {
82
+ try {
83
+ await entry.task();
84
+ }
85
+ catch {
86
+ /* see `submit` — a task reports its own failure */
87
+ }
88
+ finally {
89
+ this.busy.delete(entry.lane);
90
+ this.pump();
91
+ }
92
+ }
93
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * A private checkout of each `--repos` repository, per conversation.
3
+ *
4
+ * A coding agent that opens pull requests does it in a git checkout, and until
5
+ * `--concurrency` there was only ever one run touching it. Two runs sharing one
6
+ * checkout switch each other's branch mid-edit and commit each other's
7
+ * half-finished files — the reason runs were serialized in the first place. So
8
+ * when the operator names the repositories the agent works on, each
9
+ * conversation gets its own `git worktree` of every one of them:
10
+ *
11
+ * $PROHOST_HOME/worktrees/<conversation>/<repo-name>
12
+ *
13
+ * Per *conversation*, not per run, for the same reason sessions are: a
14
+ * follow-up in the thread ("address the review comments") resumes the session
15
+ * that made the branch, and must find that branch where it left it. Runs of one
16
+ * conversation never overlap (see `scheduler.ts`), so one checkout per
17
+ * conversation is exactly as isolated as one per run and far cheaper.
18
+ *
19
+ * The harness makes the worktree; the agent never needs `git worktree add`.
20
+ * The agent command's working directory does not change — it stays the
21
+ * workspace, because that is where its `.claude/` configuration lives and what
22
+ * its sessions are keyed on. The checkouts are named in the prompt and in
23
+ * `$PROHOST_RUN_REPOS`.
24
+ *
25
+ * Worktrees share the source repository's object store, so a branch committed
26
+ * in one survives the worktree being removed. That is what makes pruning safe:
27
+ * `git worktree remove` (never `--force`) refuses a checkout with uncommitted
28
+ * or untracked files, and those are the only things a worktree holds alone.
29
+ */
30
+ /** Child-process env var naming the directory that holds this run's checkouts. */
31
+ export declare const RUN_REPOS_ENV_VAR = "PROHOST_RUN_REPOS";
32
+ /** A conversation's checkouts idle this long are removed, if clean. */
33
+ export declare const WORKTREE_RETENTION_MS: number;
34
+ export interface RunCheckout {
35
+ /** Directory name of the repository, e.g. `backend-service`. */
36
+ name: string;
37
+ /** This conversation's private worktree of it. */
38
+ path: string;
39
+ /** The operator's checkout it was made from — the one the agent must leave alone. */
40
+ source: string;
41
+ }
42
+ export interface CheckoutFailure {
43
+ source: string;
44
+ reason: string;
45
+ }
46
+ export interface PreparedCheckouts {
47
+ /** Directory holding the checkouts; exported as {@link RUN_REPOS_ENV_VAR}. */
48
+ dir: string;
49
+ checkouts: RunCheckout[];
50
+ failures: CheckoutFailure[];
51
+ }
52
+ export declare class ReposFlagError extends Error {
53
+ }
54
+ export declare function worktreesRoot(env?: NodeJS.ProcessEnv): string;
55
+ /**
56
+ * Directory name for a lane (see `sessionKeyFor`).
57
+ *
58
+ * Readable enough to recognise in `ls`, and suffixed with a digest of the whole
59
+ * key because the readable part is lossy: two keys that differ only in
60
+ * punctuation, or in the agent they belong to, must not share a checkout.
61
+ */
62
+ export declare function laneDirName(lane: string): string;
63
+ /**
64
+ * Read `--repos a,b` into absolute paths, refusing what can never work.
65
+ *
66
+ * An empty list and two checkouts with one directory name are always errors.
67
+ * A path that is not a git checkout is an error only when `requireCheckouts`
68
+ * is set — which `install-daemon` does and `agent run` does not. At install
69
+ * time a mistyped path should stop the install; at run time the same check
70
+ * would make a daemon whose repository was moved, deleted or is on an
71
+ * unmounted volume exit before connecting, and the service manager restart it
72
+ * forever. A running harness instead reports the checkout it could not make on
73
+ * each run, and keeps answering.
74
+ *
75
+ * @throws ReposFlagError naming the entry that is wrong.
76
+ */
77
+ export declare function parseReposFlag(raw: string, options?: {
78
+ requireCheckouts?: boolean;
79
+ }): string[];
80
+ /** Whether `repo` is the top of a git checkout (a clone, or a worktree of one). */
81
+ export declare function isCheckout(repo: string): boolean;
82
+ export interface WorktreeManagerOptions {
83
+ /** Absolute paths of the operator's checkouts. Empty disables the feature. */
84
+ repos: string[];
85
+ env?: NodeJS.ProcessEnv;
86
+ log?: (line: string) => void;
87
+ /** Clock seam for tests. */
88
+ now?: () => number;
89
+ retentionMs?: number;
90
+ }
91
+ /**
92
+ * Makes, reuses and prunes per-conversation worktrees for one harness.
93
+ *
94
+ * Every git mutation goes through one in-process queue. Lanes run side by
95
+ * side, and two `git worktree add` calls against the same repository race for
96
+ * the same administrative name; pruning, meanwhile, must never remove the
97
+ * directory a run arriving this instant is about to use.
98
+ */
99
+ export declare class WorktreeManager {
100
+ private readonly repos;
101
+ private readonly env;
102
+ private readonly log;
103
+ private readonly now;
104
+ private readonly retentionMs;
105
+ private chain;
106
+ private lastPrunedAt;
107
+ constructor(options: WorktreeManagerOptions);
108
+ get enabled(): boolean;
109
+ private exclusive;
110
+ /**
111
+ * Ensure this lane has a worktree of every repository, and say where.
112
+ *
113
+ * Never rejects and never fails the run: most runs are questions that touch
114
+ * no repository at all, so a checkout that could not be made is reported in
115
+ * {@link PreparedCheckouts.failures} for the prompt to tell the agent about.
116
+ */
117
+ prepare(lane: string): Promise<PreparedCheckouts>;
118
+ /** The repository a checkout's history lives in, as one comparable path. */
119
+ private commonDir;
120
+ /**
121
+ * Keep an existing checkout — once it is proven to be a worktree of `source`.
122
+ *
123
+ * The directory is named after the repository's basename, so pointing
124
+ * `--repos` from `/old/app` to `/new/app` leaves every conversation with an
125
+ * `app` checkout of the *old* repository. Handing that to the agent as a
126
+ * checkout of the new one would have it commit to the wrong repository.
127
+ */
128
+ private reuse;
129
+ /** Returns why the worktree could not be made, or `undefined` when it was. */
130
+ private add;
131
+ private touch;
132
+ private pruneIfDue;
133
+ /** Remove idle conversations' checkouts, never the one about to be used. */
134
+ private prune;
135
+ private removeLane;
136
+ }