@withtruing/companion 0.13.2 → 0.14.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 (57) hide show
  1. package/README.md +25 -26
  2. package/dist/anthropic-output-schema.js +41 -0
  3. package/dist/api-key-caller.js +9 -2
  4. package/dist/claude-cli-caller.js +107 -8
  5. package/dist/cli.js +837 -207
  6. package/dist/codex-binary.js +96 -0
  7. package/dist/codex-cli-caller.js +90 -16
  8. package/dist/config.js +177 -2
  9. package/dist/git-range.js +88 -46
  10. package/dist/http-client.js +95 -10
  11. package/dist/investigation-reader.js +12 -6
  12. package/dist/managed-repo.js +132 -0
  13. package/dist/observed-sources.js +132 -6
  14. package/dist/reader-result-contract.js +296 -2
  15. package/dist/repository-provider.js +608 -0
  16. package/dist/schema-entities.js +18 -18
  17. package/dist/service.js +7 -2
  18. package/dist/surface-mapper.js +53 -11
  19. package/dist/text-model.js +91 -8
  20. package/dist/whole-read/archive.js +196 -0
  21. package/dist/whole-read/checks.js +297 -0
  22. package/dist/whole-read/claims.js +196 -0
  23. package/dist/whole-read/coverage-ledger.js +51 -0
  24. package/dist/whole-read/decode.js +234 -0
  25. package/dist/whole-read/drafting.js +502 -0
  26. package/dist/whole-read/generated-rules.js +68 -0
  27. package/dist/whole-read/go-proof.js +255 -0
  28. package/dist/whole-read/grouping-prompt.js +19 -0
  29. package/dist/whole-read/grouping.js +247 -0
  30. package/dist/whole-read/identity.js +103 -0
  31. package/dist/whole-read/import-graph.js +293 -0
  32. package/dist/whole-read/inventory-collector.js +55 -0
  33. package/dist/whole-read/inventory-rules.js +141 -0
  34. package/dist/whole-read/lockfiles.js +947 -0
  35. package/dist/whole-read/notes.js +216 -0
  36. package/dist/whole-read/npm-proof.js +272 -0
  37. package/dist/whole-read/owner-decision-detectors.js +270 -0
  38. package/dist/whole-read/packing.js +612 -0
  39. package/dist/whole-read/pages.js +429 -0
  40. package/dist/whole-read/phase-policy.js +54 -0
  41. package/dist/whole-read/prd-build.js +279 -0
  42. package/dist/whole-read/prd-fidelity.js +14 -0
  43. package/dist/whole-read/python-proof.js +489 -0
  44. package/dist/whole-read/read-prompt.js +67 -0
  45. package/dist/whole-read/redaction.js +411 -0
  46. package/dist/whole-read/relationship-mapping.js +98 -0
  47. package/dist/whole-read/sanitize.js +266 -0
  48. package/dist/whole-read/secret-rules.js +26 -0
  49. package/dist/whole-read/structure.js +300 -0
  50. package/dist/whole-read/types.js +2 -0
  51. package/dist/whole-read-claims.js +12 -0
  52. package/dist/whole-read-git.js +340 -0
  53. package/dist/whole-read-pool.js +80 -0
  54. package/dist/whole-read-runner.js +945 -0
  55. package/dist/whole-read-shared-source.js +194 -0
  56. package/dist/whole-read-temp.js +479 -0
  57. package/package.json +8 -2
package/dist/cli.js CHANGED
@@ -1,22 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
  import Anthropic from '@anthropic-ai/sdk';
3
3
  import { existsSync } from 'node:fs';
4
- import { resolve } from 'node:path';
4
+ import { dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { addConnection, configPath, enableWorkspacePolling, loadConfig, managedReposDir, normalizeConsoleUrl, recordWorkspaceId, removePairing, } from './config.js';
7
- import { CompanionClient, CompanionHttpError, PairingHttpError, } from './http-client.js';
8
- import { assertIsRepo, checkRepositoryReadiness, currentCommit, GIT_UNAVAILABLE_MESSAGE, REMOTE_TROUBLESHOOTING_HINT, repositoryIdentity, RepositoryIdentityError, repositoryTopLevel, } from './local-scan.js';
6
+ import { addConnection, cleanupMigratedManagedRepositories, configPath, enableWorkspacePolling, hasTemporaryManagedRepository, loadConfig, managedReposDir, migrateManagedRepositoriesToTemporaryStorage, normalizeConsoleUrl, recordWorkspaceId, removePairing, } from './config.js';
7
+ import { CompanionClient, CompanionHttpError, CLAUDE_CLI_FAST_MODEL, CODEX_FAST_MODEL, PairingHttpError, } from './http-client.js';
8
+ import { assertIsRepo, currentCommit, GIT_UNAVAILABLE_MESSAGE, REMOTE_TROUBLESHOOTING_HINT, repositoryIdentity, RepositoryIdentityError, repositoryTopLevel, } from './local-scan.js';
9
9
  import { runAnalyseShipmentTask, runDesignPromptTask, runGenerateSpecBatchTask, runGenericTask, runProposeNewCapabilitiesTask, runProposeSkeletonTask, runStructuredModelCall, runVerifyModuleTask } from './api-key-caller.js';
10
- import { runAnalyseShipmentTaskViaCli, runDesignPromptTaskViaCli, runGenerateSpecBatchTaskViaCli, runGenericTaskViaCli, runProposeNewCapabilitiesTaskViaCli, runProposeSkeletonTaskViaCli, runStructuredModelCallViaCli, runVerifyModuleTaskViaCli } from './claude-cli-caller.js';
10
+ import { runAnalyseShipmentTaskViaCli, runDesignPromptTaskViaCli, runGenerateSpecBatchTaskViaCli, runGenericTaskViaCli, runProposeNewCapabilitiesTaskViaCli, runProposeSkeletonTaskViaCli, runStructuredModelCallViaCli, runVerifyModuleTaskViaCli, runWholeReadModelCallViaCli } from './claude-cli-caller.js';
11
11
  import { spawnClaude } from './claude-binary.js';
12
12
  import { checkCodexLogin, CODEX_CODE_MODE_MODELS, codexTextModel, defaultCodexHome, runStructuredModelCallViaCodex } from './codex-cli-caller.js';
13
13
  import { formatInvestigationReaderDiagnostic, INVESTIGATION_MODEL, runAndReportInvestigation } from './investigation-reader.js';
14
14
  import { anthropicTextModel } from './text-model.js';
15
- import { MappingFailure, runSurfaceMapping, SURFACE_SEED_MODEL } from './surface-mapper.js';
15
+ import { MappingFailure, readSurfaceSnapshotChangeSet, runSurfaceMapping, runSurfaceMappingCheckout, SURFACE_SEED_MODEL, } from './surface-mapper.js';
16
16
  import { assertSnapshotReadBoundary, createSandbox } from './sandbox.js';
17
17
  import { runGitRangeTask } from './git-range.js';
18
- import { gitAvailable, githubLabel, interactiveFetch, labelFromCopyPath, ManagedRepoError, managedCopyPath, parseGitHubRepo, prepareManagedCopy, readManagedState, relativeTime, removeManagedCopy, watchManagedCopy, } from './managed-repo.js';
18
+ import { configuredManagedRepository, gitAvailable, githubCloneUrl, githubLabel, interactiveFetch, labelFromCopyPath, ManagedRepoError, managedCopyPath, parseGitHubRepo, prepareManagedCopy, readManagedState, relativeTime, removeManagedCopy, watchManagedCopy, usesTemporaryManagedStorage, } from './managed-repo.js';
19
19
  import { isUsageLimitError, UsageLimitPause } from './usage-limit.js';
20
+ import { AttemptSourceCleanupError, createCompanionAttemptSourceManager, startAttemptSourceSweeper, stopAttemptSourceSweeper, withAttemptSource, } from './whole-read-temp.js';
21
+ import { createWholeReadAssignmentPool, } from './whole-read-pool.js';
22
+ import { createWholeReadSharedSourceManager, } from './whole-read-shared-source.js';
23
+ import { checkConnectionRepositoryReadiness, createRepositoryProvider, proveManagedRepositoryAccess, RepositoryProviderError, } from './repository-provider.js';
24
+ import { WholeReadGitError } from './whole-read-git.js';
25
+ import { acquireGithubCompanionSource, acquireAssignedSource, assignmentRouteReadinessCode, acquireLocalSource, dispatchWholeReadClaim, } from './whole-read-runner.js';
26
+ import { WHOLE_READ_PHASE_POLICY } from './whole-read/phase-policy.js';
20
27
  import { acquireInstanceLock, installService, isNpxCachePath, releaseInstanceLock, runDaemon, servicePaths, serviceStatus, startService, stopService, uninstallService, } from './service.js';
21
28
  const DEFAULT_CODEX_MODEL = 'gpt-6-astra';
22
29
  const SURFACE_MAPPING_UNAVAILABLE_STATUSES = new Set([401, 403, 404, 405]);
@@ -100,6 +107,21 @@ function testCloneUrl(repo) {
100
107
  const base = process.env.NODE_ENV === 'test' ? process.env.PC_TEST_GITHUB_CLONE_BASE : undefined;
101
108
  return base ? `${base.replace(/\/+$/, '')}/${repo.owner}/${repo.name}.git` : undefined;
102
109
  }
110
+ async function activateManagedCopyCleanup(url, workspaceId, output = console) {
111
+ try {
112
+ const cleanup = await cleanupMigratedManagedRepositories({
113
+ url, workspaceId, temporaryRepositories: true,
114
+ });
115
+ for (const path of cleanup.removed)
116
+ output.log(`Removed the companion's retired managed copy at ${path}.`);
117
+ for (const pending of cleanup.cleanupPending) {
118
+ output.error(`Managed-copy cleanup_pending at ${pending.path} (${pending.errorCode}); the companion will retry on restart.`);
119
+ }
120
+ }
121
+ catch {
122
+ output.error('Managed-copy cleanup_pending; the companion will retry on restart.');
123
+ }
124
+ }
103
125
  const restartHint = 'The companion is already running in the background — run "truing stop" then "truing start" so it picks up this new connection.';
104
126
  function logRestartHintIfRunning() {
105
127
  if (serviceStatus().running)
@@ -205,43 +227,60 @@ async function cmdConnect(argv) {
205
227
  console.log(`Found Claude Code: ${version}`);
206
228
  }
207
229
  const client = new CompanionClient(url, token);
208
- let workspaceId;
230
+ let serverIdentity;
209
231
  try {
210
- ({ workspaceId } = await client.whoami());
232
+ serverIdentity = await client.whoami();
211
233
  }
212
234
  catch {
213
235
  console.error('Could not verify the companion token. Double check that you copied the whole token, including the part after the dot.');
214
236
  process.exitCode = 1;
215
237
  return;
216
238
  }
239
+ const { workspaceId } = serverIdentity;
240
+ const temporaryManagedRepository = managedRepo !== null && (serverIdentity.temporaryRepositories === true
241
+ || hasTemporaryManagedRepository({ url, workspaceId, repository: managedRepo }));
217
242
  let managedPath = null;
243
+ let managedDefaultBranch = null;
218
244
  if (managedRepo) {
245
+ let reconnectMessageShown = false;
246
+ const label = githubLabel(managedRepo);
247
+ const getToken = async () => {
248
+ const response = await client.githubToken(label);
249
+ if (response.reason === 'github_reconnect_needed' && !reconnectMessageShown) {
250
+ reconnectMessageShown = true;
251
+ console.error(response.message ?? 'Reconnect GitHub in the Console.');
252
+ }
253
+ return response.token;
254
+ };
219
255
  try {
220
- let reconnectMessageShown = false;
221
- const label = githubLabel(managedRepo);
222
- const copy = await prepareManagedCopy({
223
- repo: managedRepo,
224
- branch: f.branch,
225
- reposDir: managedReposDir(),
226
- cloneUrl: testCloneUrl(managedRepo),
227
- getToken: async () => {
228
- const response = await client.githubToken(label);
229
- if (response.reason === 'github_reconnect_needed' && !reconnectMessageShown) {
230
- reconnectMessageShown = true;
231
- console.error(response.message ?? 'Reconnect GitHub in the Console.');
232
- }
233
- return response.token;
234
- },
235
- });
236
- managedPath = copy.path;
237
- if (copy.fetchOk === false) {
238
- console.error(reconnectMessageShown
239
- ? `Couldn't bring the copy of ${githubLabel(managedRepo)} up to date just now. The companion keeps trying; if it keeps failing, reconnect GitHub in the Console.`
240
- : `Couldn't bring the copy of ${githubLabel(managedRepo)} up to date just now. The companion keeps trying; if it keeps failing, run: truing signin`);
256
+ if (temporaryManagedRepository) {
257
+ const access = await proveManagedRepositoryAccess({
258
+ repository: managedRepo,
259
+ branch: f.branch,
260
+ credential: await getToken(),
261
+ }, { managedRepositoryUrl: (repository) => testCloneUrl(repository) ?? githubCloneUrl(repository) });
262
+ managedDefaultBranch = access.defaultBranch;
263
+ }
264
+ else {
265
+ const copy = await prepareManagedCopy({
266
+ repo: managedRepo,
267
+ branch: f.branch,
268
+ reposDir: managedReposDir(),
269
+ cloneUrl: testCloneUrl(managedRepo),
270
+ getToken,
271
+ });
272
+ managedPath = copy.path;
273
+ if (copy.fetchOk === false) {
274
+ console.error(reconnectMessageShown
275
+ ? `Couldn't bring the copy of ${githubLabel(managedRepo)} up to date just now. The companion keeps trying; if it keeps failing, reconnect GitHub in the Console.`
276
+ : `Couldn't bring the copy of ${githubLabel(managedRepo)} up to date just now. The companion keeps trying; if it keeps failing, run: truing signin`);
277
+ }
241
278
  }
242
279
  }
243
280
  catch (error) {
244
- console.error(error instanceof ManagedRepoError ? error.message : `Couldn't copy ${githubLabel(managedRepo)} from GitHub.`);
281
+ console.error(error instanceof ManagedRepoError ? error.message : temporaryManagedRepository
282
+ ? `Couldn't verify access to ${githubLabel(managedRepo)}. Reconnect GitHub in the Console, then run this command again.`
283
+ : `Couldn't copy ${githubLabel(managedRepo)} from GitHub.`);
245
284
  process.exitCode = 1;
246
285
  return;
247
286
  }
@@ -263,9 +302,46 @@ async function cmdConnect(argv) {
263
302
  return;
264
303
  }
265
304
  const requestedFolder = managedPath ?? resolve(f.path || '.');
266
- const topLevel = f['no-repo'] === 'true' ? null : await repositoryTopLevel(requestedFolder);
305
+ const topLevel = f['no-repo'] === 'true' || temporaryManagedRepository
306
+ ? null
307
+ : await repositoryTopLevel(requestedFolder);
267
308
  let registered = false;
268
- if (topLevel) {
309
+ if (managedRepo && temporaryManagedRepository && managedDefaultBranch) {
310
+ try {
311
+ const identity = {
312
+ host: 'github.com',
313
+ port: null,
314
+ path: githubLabel(managedRepo),
315
+ defaultBranch: managedDefaultBranch,
316
+ };
317
+ const { connection } = await client.registerConnection(identity);
318
+ addConnection({
319
+ url,
320
+ token,
321
+ workspaceId,
322
+ connection: {
323
+ connectionId: connection.id,
324
+ repoPath: managedCopyPath(managedReposDir(), managedRepo),
325
+ backend,
326
+ codexModel: backend === 'codex' && f['codex-model'] ? codexModel : undefined,
327
+ branch: identity.defaultBranch,
328
+ label: githubLabel(managedRepo),
329
+ managed: true,
330
+ repository: { host: identity.host, port: identity.port, path: identity.path },
331
+ repositoryStorage: 'temporary',
332
+ },
333
+ apiKey: backend === 'codex' ? undefined : apiKey,
334
+ });
335
+ console.log(`Connected ${githubLabel(managedRepo)}. The companion will access it temporarily when work needs it; no permanent code copy was created.`);
336
+ registered = true;
337
+ }
338
+ catch (error) {
339
+ console.error(error instanceof PairingHttpError
340
+ ? `${error.code}: ${error.message}`
341
+ : 'registration_failed: Could not register this repository with the console.');
342
+ }
343
+ }
344
+ else if (topLevel) {
269
345
  try {
270
346
  const { remote, ...identity } = await repositoryIdentity(topLevel, {
271
347
  remote: managedRepo ? 'origin' : f.remote,
@@ -282,6 +358,7 @@ async function cmdConnect(argv) {
282
358
  backend,
283
359
  codexModel: backend === 'codex' && f['codex-model'] ? codexModel : undefined,
284
360
  remote,
361
+ branch: identity.defaultBranch,
285
362
  ...(managedRepo ? { label: githubLabel(managedRepo), managed: true } : {}),
286
363
  },
287
364
  apiKey: backend === 'codex' ? undefined : apiKey,
@@ -307,6 +384,10 @@ async function cmdConnect(argv) {
307
384
  else if (f['no-repo'] !== 'true') {
308
385
  console.log("No project folder here — to connect your code, run this same command inside your project's folder.");
309
386
  }
387
+ if (serverIdentity.temporaryRepositories === true) {
388
+ migrateManagedRepositoriesToTemporaryStorage({ url, workspaceId });
389
+ await activateManagedCopyCleanup(url, workspaceId);
390
+ }
310
391
  if (!registered) {
311
392
  try {
312
393
  await client.recordPairingOutcome('workspace_only');
@@ -366,6 +447,7 @@ async function cmdConnect(argv) {
366
447
  backend,
367
448
  codexModel: backend === 'codex' && f['codex-model'] ? codexModel : undefined,
368
449
  remote: f.remote,
450
+ ...(f.branch ? { branch: f.branch } : {}),
369
451
  },
370
452
  apiKey: backend === 'codex' ? undefined : apiKey,
371
453
  });
@@ -461,7 +543,7 @@ export function codexReadinessLines(status) {
461
543
  }
462
544
  return [
463
545
  'Codex needs a one-time login for the companion\'s own home:',
464
- ' Windows PowerShell: New-Item -ItemType Directory -Force "$HOME\\.product-console-companion\\codex-home" | Out-Null; $env:CODEX_HOME="$HOME\\.product-console-companion\\codex-home"; codex login; Remove-Item Env:CODEX_HOME',
546
+ ' Windows PowerShell: New-Item -ItemType Directory -Force "$HOME\\.product-console-companion\\codex-home" | Out-Null; $env:CODEX_HOME="$HOME\\.product-console-companion\\codex-home"; codex.cmd login; Remove-Item Env:CODEX_HOME',
465
547
  ' macOS/Linux: mkdir -p "$HOME/.product-console-companion/codex-home" && CODEX_HOME="$HOME/.product-console-companion/codex-home" codex login',
466
548
  'This separate login keeps your own ~/.codex instructions out of the companion\'s calls.',
467
549
  ];
@@ -604,6 +686,9 @@ export function logInvestigationDiagnostic(diagnostic, output = console) {
604
686
  output.error(formatInvestigationReaderDiagnostic(diagnostic));
605
687
  }
606
688
  export function taskRunnerFor(backend, kind) {
689
+ if (kind.startsWith('whole-read-')) {
690
+ return async () => { throw new Error('build_task_kind_unsupported'); };
691
+ }
607
692
  if (backend === 'cli') {
608
693
  if (kind === 'verify_module')
609
694
  return runVerifyModuleTaskViaCli;
@@ -633,11 +718,18 @@ export function taskRunnerFor(backend, kind) {
633
718
  return runAnalyseShipmentTask;
634
719
  return runGenericTask;
635
720
  }
636
- export async function runTask(task, runtime, repoPath, connection) {
721
+ export async function runTask(task, runtime, repoPath, connection, repositoryLease) {
637
722
  if (task.kind === 'git_range') {
638
723
  if (!connection)
639
724
  return { result: { ok: false, code: 'history_failed' }, filesRead: [] };
640
- return runGitRangeTask(task, repoPath, connection);
725
+ if (!repositoryLease?.range)
726
+ return runGitRangeTask(task, repoPath, connection);
727
+ return runGitRangeTask(task, repositoryLease.gitDirectory, connection, {
728
+ ...repositoryLease.range,
729
+ environment: repositoryLease.gitEnvironment(),
730
+ signal: new AbortController().signal,
731
+ registerReader: (reader) => repositoryLease.registerReader(reader),
732
+ });
641
733
  }
642
734
  const runner = taskRunnerFor(runtime.backend, task.kind);
643
735
  const payload = task.payload;
@@ -648,6 +740,20 @@ export async function runTask(task, runtime, repoPath, connection) {
648
740
  }
649
741
  if (typeof payload.snapshotSha !== 'string')
650
742
  throw new Error('snapshot_unavailable');
743
+ if (repositoryLease) {
744
+ let checkout;
745
+ try {
746
+ checkout = await repositoryLease.checkout();
747
+ await assertSnapshotReadBoundary(checkout.path);
748
+ }
749
+ catch (error) {
750
+ throw error instanceof RepositoryProviderError ? error : new Error('snapshot_unavailable');
751
+ }
752
+ const outcome = runtime.backend === 'cli'
753
+ ? await runner(task, checkout.path)
754
+ : await runner(runtime.textModel, task, checkout.path);
755
+ return { ...outcome, snapshot: { sha: checkout.commit } };
756
+ }
651
757
  let sandbox;
652
758
  try {
653
759
  sandbox = await createSandbox(repoPath, payload.snapshotSha);
@@ -679,7 +785,7 @@ function formatCodexUsage(usage) {
679
785
  return 'unavailable';
680
786
  return Object.entries(usage).map(([name, value]) => `${name}=${value}`).join(', ');
681
787
  }
682
- export async function runOneTask(runtime, client, repoPath, task, connection, output = console) {
788
+ export async function runOneTask(runtime, client, repoPath, task, connection, output = console, repositoryProvider) {
683
789
  runtime.takeCodexUsage();
684
790
  if (runtime.backend === 'codex') {
685
791
  output.log(`[${new Date().toISOString()}] claimed ${task.kind} task ${task.id} via codex (dollar budget $${task.maxBudgetUsd.toFixed(2)} is not enforced on this backend)`);
@@ -687,16 +793,52 @@ export async function runOneTask(runtime, client, repoPath, task, connection, ou
687
793
  else {
688
794
  output.log(`[${new Date().toISOString()}] claimed ${task.kind} task ${task.id} via ${runtime.backend} (budget $${task.maxBudgetUsd.toFixed(2)})`);
689
795
  }
796
+ const owner = task.repositoryProvider?.owner;
797
+ const usesTemporaryRepository = repositoryProvider !== undefined && owner !== undefined && connection !== undefined
798
+ && task.payload.connectionless !== true;
799
+ const controller = new AbortController();
800
+ let lease;
801
+ let leaseLost = false;
690
802
  const heartbeat = setInterval(() => {
691
- void client.heartbeat(task.id).catch((err) => output.error(' heartbeat warning:', err instanceof Error ? err.message : err));
803
+ void (async () => {
804
+ try {
805
+ const renewed = await client.heartbeat(task.id, usesTemporaryRepository ? owner.claimToken : undefined);
806
+ if (usesTemporaryRepository && lease) {
807
+ if (typeof renewed.expiresAt !== 'string')
808
+ throw new Error('repository_lease_lost');
809
+ await lease.heartbeat({ claimToken: owner.claimToken, expiresAt: renewed.expiresAt });
810
+ }
811
+ }
812
+ catch (err) {
813
+ leaseLost = true;
814
+ controller.abort();
815
+ output.error(' heartbeat warning:', err instanceof Error ? err.message : err);
816
+ }
817
+ })();
692
818
  }, HEARTBEAT_MS);
693
819
  try {
694
- const { result, filesRead, snapshot } = await runTask(task, runtime, repoPath, connection);
820
+ if (usesTemporaryRepository) {
821
+ await repositoryProvider.sweepRepositories();
822
+ const target = task.kind === 'git_range'
823
+ ? { kind: 'range', base: task.payload.base,
824
+ head: String(task.payload.branch ?? '') }
825
+ : { kind: 'snapshot', sha: String(task.payload.snapshotSha ?? '') };
826
+ lease = await repositoryProvider.acquireRepository({
827
+ connection, target, owner, signal: controller.signal,
828
+ });
829
+ }
830
+ const { result, filesRead, snapshot } = await runTask(task, runtime, repoPath, connection, lease);
831
+ if (leaseLost)
832
+ throw new RepositoryProviderError('repository_lease_lost');
833
+ if (lease) {
834
+ await lease.release();
835
+ lease = undefined;
836
+ }
695
837
  clearInterval(heartbeat);
696
838
  if (task.kind === 'git_range')
697
839
  await client.reportGitRangeResult(task.id, result);
698
840
  else
699
- await client.reportResult(task.id, result, filesRead, snapshot);
841
+ await client.reportResult(task.id, result, filesRead, snapshot, usesTemporaryRepository ? owner.claimToken : undefined);
700
842
  const done = ` done — reported result (${filesRead.length} file${filesRead.length === 1 ? '' : 's'} read)`;
701
843
  output.log(runtime.backend === 'codex'
702
844
  ? `${done}; token usage: ${formatCodexUsage(runtime.takeCodexUsage())}; dollar budget is not enforced (Codex reports tokens and has no cost cap)`
@@ -704,7 +846,16 @@ export async function runOneTask(runtime, client, repoPath, task, connection, ou
704
846
  return null;
705
847
  }
706
848
  catch (err) {
707
- const message = err instanceof Error ? err.message : String(err);
849
+ let failure = err;
850
+ if (lease) {
851
+ try {
852
+ await lease.release();
853
+ }
854
+ catch (cleanupError) {
855
+ failure = cleanupError;
856
+ }
857
+ }
858
+ const message = failure instanceof Error ? failure.message : String(failure);
708
859
  if (runtime.backend === 'codex' && message.includes('CodexToolUseAttempted')) {
709
860
  output.error(` TOOL USE BLOCKED — ${message}`);
710
861
  }
@@ -712,19 +863,40 @@ export async function runOneTask(runtime, client, repoPath, task, connection, ou
712
863
  output.error(' failed:', message);
713
864
  }
714
865
  try {
715
- await client.reportError(task.id, message);
866
+ await client.reportError(task.id, message, usesTemporaryRepository ? owner.claimToken : undefined);
716
867
  }
717
868
  catch (reportErr) {
718
869
  output.error(' could not even report the error back:', reportErr instanceof Error ? reportErr.message : reportErr);
719
870
  }
720
- return runtime.backend === 'cli' && isUsageLimitError(err) ? message : null;
871
+ return runtime.backend === 'cli' && isUsageLimitError(failure) ? message : null;
721
872
  }
722
873
  finally {
723
874
  clearInterval(heartbeat);
724
875
  }
725
876
  }
877
+ // Every pairing shares one local lifecycle registry. Separate managers pointed at the same root
878
+ // would mistake another Console's live source for an orphan during a sweep.
879
+ const repositoryAttemptSourceManager = createCompanionAttemptSourceManager({
880
+ root: join(dirname(configPath), 'repository-attempts'),
881
+ // The Console's signed claim metadata gates acquisition; this is never invoked while the
882
+ // server-side rollout is off.
883
+ buildsEnabled: () => true,
884
+ });
885
+ function ordinaryTaskRepositoryProvider(pairing, client) {
886
+ return createRepositoryProvider({
887
+ sourceManager: repositoryAttemptSourceManager,
888
+ workspaceId: pairing.workspaceId ?? 'unpaired',
889
+ hostId: pairing.workspaceId ?? 'companion',
890
+ // Renewal first heartbeats the server below. A rejected heartbeat aborts this attempt.
891
+ isClaimLive: async () => true,
892
+ getCredential: async (_connection, repository) => (await client.githubToken(githubLabel(repository))).token,
893
+ managedRepositoryUrl: (repository) => testCloneUrl(repository) ?? githubCloneUrl(repository),
894
+ });
895
+ }
726
896
  export function backendForInvestigation(request, connections) {
727
897
  for (const source of request.requestedSources) {
898
+ if (source.kind === 'prd')
899
+ continue;
728
900
  const configured = connections.find((connection) => connection.connectionId === source.connectionId);
729
901
  if (configured)
730
902
  return configured.backend;
@@ -740,13 +912,14 @@ export function structuredModelCallerForRuntime(runtime, anthropic, callViaCodex
740
912
  }
741
913
  return (request) => runStructuredModelCall(anthropic, request);
742
914
  }
743
- async function runOneInvestigation(anthropic, client, connections, runtimes, request, output = console) {
915
+ async function runOneInvestigation(anthropic, client, connections, runtimes, request, output = console, repositoryProvider) {
744
916
  const backend = backendForInvestigation(request, connections);
745
917
  output.log(`[${new Date().toISOString()}] claimed investigation ${request.investigationId} attempt ${request.attempt} via ${backend} (${request.obligations.length} obligation${request.obligations.length === 1 ? '' : 's'})`);
746
918
  if (Date.now() >= Date.parse(request.claimExpiresAt)) {
747
919
  output.error(` CLAIM LEASE WARNING: ${request.investigationId} was already expired at ${request.claimExpiresAt}; the result may be rejected.`);
748
920
  }
749
921
  const runtime = request.requestedSources
922
+ .filter((source) => source.kind !== 'prd')
750
923
  .map((source) => runtimes.get(source.connectionId))
751
924
  .find((candidate) => !!candidate);
752
925
  const baseCallModel = runtime
@@ -769,6 +942,10 @@ async function runOneInvestigation(anthropic, client, connections, runtimes, req
769
942
  }
770
943
  : baseCallModel;
771
944
  const validationRejectedObligationIds = new Set();
945
+ const controller = new AbortController();
946
+ const repositoryOwner = request.repositoryProvider
947
+ ? { ...request.repositoryProvider.owner, expiresAt: request.claimExpiresAt }
948
+ : undefined;
772
949
  try {
773
950
  const result = await runAndReportInvestigation(request, connections, callModel, async (envelope) => {
774
951
  if (Date.now() >= Date.parse(request.claimExpiresAt)) {
@@ -790,26 +967,116 @@ async function runOneInvestigation(anthropic, client, connections, runtimes, req
790
967
  model: runtime?.backend === 'codex'
791
968
  ? runtime.codexOptions?.model
792
969
  : INVESTIGATION_MODEL,
793
- });
970
+ }, repositoryProvider && repositoryOwner
971
+ ? { repositoryProvider, owner: repositoryOwner, signal: controller.signal }
972
+ : undefined);
794
973
  logInvestigationResult(result, output, validationRejectedObligationIds);
795
974
  }
796
975
  catch (error) {
797
976
  output.error(` RESULT NOT DELIVERED for ${request.investigationId} attempt ${request.attempt}; no automatic retry will be made and the claim must expire:`, error instanceof Error ? error.message : error);
798
977
  }
978
+ finally {
979
+ controller.abort();
980
+ }
799
981
  return usageLimitMessage;
800
982
  }
801
- async function runOneSurfaceMapping(anthropic, client, connection, runtime, job, output = console) {
983
+ export async function runOneSurfaceMapping(anthropic, client, connection, runtime, job, output = console, repositoryProvider, options = {}) {
802
984
  output.log(`[${new Date().toISOString()}] claimed surface mapping ${job.jobId} attempt ${job.attempt} via ${runtime.backend}`);
985
+ const owner = job.repositoryProvider?.owner;
986
+ const usesTemporaryRepository = repositoryProvider !== undefined && owner !== undefined;
987
+ const controller = new AbortController();
988
+ const leases = [];
989
+ let leaseLost = false;
990
+ const releaseLeases = async () => {
991
+ const pending = leases.splice(0).reverse();
992
+ let failure;
993
+ for (const lease of pending) {
994
+ try {
995
+ await lease.release();
996
+ }
997
+ catch (error) {
998
+ failure ??= error;
999
+ }
1000
+ }
1001
+ if (failure)
1002
+ throw failure;
1003
+ };
1004
+ const heartbeat = usesTemporaryRepository ? setInterval(() => {
1005
+ void (async () => {
1006
+ try {
1007
+ const renewed = await client.heartbeatSurfaceMapping(job);
1008
+ for (const lease of leases) {
1009
+ await lease.heartbeat({ claimToken: owner.claimToken, expiresAt: renewed.expiresAt });
1010
+ }
1011
+ }
1012
+ catch (error) {
1013
+ leaseLost = true;
1014
+ controller.abort();
1015
+ output.error(' heartbeat warning:', error instanceof Error ? error.message : error);
1016
+ }
1017
+ })();
1018
+ }, options.heartbeatMs ?? HEARTBEAT_MS) : null;
803
1019
  try {
804
- const manifest = await runSurfaceMapping({
805
- claim: job,
806
- repoPath: connection.repoPath,
807
- callModel: structuredModelCallerForRuntime(runtime, anthropic),
808
- execution: {
809
- backend: runtime.backend,
810
- model: runtime.backend === 'codex' ? runtime.codexOptions?.model ?? DEFAULT_CODEX_MODEL : SURFACE_SEED_MODEL,
811
- },
812
- });
1020
+ const callModel = options.callModel ?? structuredModelCallerForRuntime(runtime, anthropic);
1021
+ const execution = {
1022
+ backend: runtime.backend,
1023
+ model: runtime.backend === 'codex' ? runtime.codexOptions?.model ?? DEFAULT_CODEX_MODEL : SURFACE_SEED_MODEL,
1024
+ };
1025
+ let manifest;
1026
+ if (usesTemporaryRepository) {
1027
+ await repositoryProvider.sweepRepositories();
1028
+ const headLease = await repositoryProvider.acquireRepository({
1029
+ connection, target: { kind: 'snapshot', sha: job.commit }, owner, signal: controller.signal,
1030
+ });
1031
+ leases.push(headLease);
1032
+ const head = await headLease.checkout();
1033
+ let reuseChangeSet = null;
1034
+ if (job.reuseCandidate) {
1035
+ try {
1036
+ const baseLease = await repositoryProvider.acquireRepository({
1037
+ connection,
1038
+ target: { kind: 'snapshot', sha: job.reuseCandidate.commit },
1039
+ owner,
1040
+ signal: controller.signal,
1041
+ });
1042
+ leases.push(baseLease);
1043
+ const base = await baseLease.checkout();
1044
+ reuseChangeSet = await readSurfaceSnapshotChangeSet(base.path, base.commit, head.path, head.commit);
1045
+ }
1046
+ catch (error) {
1047
+ if (!(error instanceof RepositoryProviderError)
1048
+ || !['snapshot_unavailable', 'repository_access_denied'].includes(error.code))
1049
+ throw error;
1050
+ reuseChangeSet = null;
1051
+ }
1052
+ }
1053
+ manifest = await runSurfaceMappingCheckout({
1054
+ claim: job,
1055
+ checkoutPath: head.path,
1056
+ callModel,
1057
+ execution,
1058
+ reuseChangeSet,
1059
+ assertLive: () => {
1060
+ if (leaseLost || controller.signal.aborted)
1061
+ throw new RepositoryProviderError('repository_lease_lost');
1062
+ for (const lease of leases)
1063
+ lease.assertLive();
1064
+ },
1065
+ });
1066
+ if (leaseLost)
1067
+ throw new RepositoryProviderError('repository_lease_lost');
1068
+ if (heartbeat)
1069
+ clearInterval(heartbeat);
1070
+ await releaseLeases();
1071
+ }
1072
+ else {
1073
+ manifest = await runSurfaceMapping({
1074
+ claim: job,
1075
+ repoPath: connection.repoPath,
1076
+ callModel,
1077
+ execution,
1078
+ });
1079
+ }
813
1080
  let acknowledgment;
814
1081
  try {
815
1082
  acknowledgment = await client.reportSurfaceMappingResult(job, manifest);
@@ -826,16 +1093,29 @@ async function runOneSurfaceMapping(anthropic, client, connection, runtime, job,
826
1093
  return null;
827
1094
  }
828
1095
  catch (error) {
1096
+ let failure = error;
1097
+ try {
1098
+ await releaseLeases();
1099
+ }
1100
+ catch (cleanupError) {
1101
+ failure = cleanupError;
1102
+ }
829
1103
  const usageLimitMessage = runtime.backend === 'cli' && isUsageLimitError(error)
830
1104
  ? (error instanceof Error ? error.message : String(error))
831
1105
  : null;
832
- const report = error instanceof MappingFailure
833
- ? { kind: error.kind, code: error.code, detail: error.message }
834
- : {
835
- kind: 'transient',
836
- code: 'mapper_crashed',
837
- detail: error instanceof Error ? error.message : String(error),
838
- };
1106
+ const report = failure instanceof MappingFailure
1107
+ ? { kind: failure.kind, code: failure.code, detail: failure.message }
1108
+ : failure instanceof RepositoryProviderError
1109
+ ? {
1110
+ kind: failure.code === 'snapshot_unavailable' ? 'stable' : 'transient',
1111
+ code: failure.code,
1112
+ detail: failure.code,
1113
+ }
1114
+ : {
1115
+ kind: 'transient',
1116
+ code: 'mapper_crashed',
1117
+ detail: failure instanceof Error ? failure.message : String(failure),
1118
+ };
839
1119
  output.error(` surface mapping ${job.jobId} failed (${report.code}): ${report.detail}`);
840
1120
  try {
841
1121
  await client.reportSurfaceMappingError(job, report);
@@ -850,6 +1130,223 @@ async function runOneSurfaceMapping(anthropic, client, connection, runtime, job,
850
1130
  }
851
1131
  return usageLimitMessage;
852
1132
  }
1133
+ finally {
1134
+ if (heartbeat)
1135
+ clearInterval(heartbeat);
1136
+ controller.abort();
1137
+ }
1138
+ }
1139
+ export function wholeReadModelRequest(request, callContext) {
1140
+ const policy = WHOLE_READ_PHASE_POLICY[request.phase];
1141
+ const thinking = policy.thinking[request.model] ?? 'adaptive';
1142
+ return { purpose: 'whole-read', model: request.model, effort: policy.effort, thinking,
1143
+ maxTokens: request.maxTokens, system: '', stableText: '', text: request.prompt, schema: request.schema,
1144
+ wholeRead: callContext };
1145
+ }
1146
+ function wholeReadBackend(runtime, callContext) {
1147
+ return async (request) => {
1148
+ const modelRequest = wholeReadModelRequest(request, callContext);
1149
+ const reply = runtime.backend === 'cli'
1150
+ ? await runWholeReadModelCallViaCli(modelRequest)
1151
+ : await runtime.textModel(modelRequest);
1152
+ let result;
1153
+ try {
1154
+ result = JSON.parse(reply.text);
1155
+ }
1156
+ catch {
1157
+ throw new Error('note_schema_invalid');
1158
+ }
1159
+ const inputTokens = reply.usage?.input_tokens ?? reply.usage?.inputTokens;
1160
+ const outputTokens = reply.usage?.output_tokens ?? reply.usage?.outputTokens;
1161
+ const usage = Number.isSafeInteger(inputTokens) && Number(inputTokens) >= 0
1162
+ && Number.isSafeInteger(outputTokens) && Number(outputTokens) >= 0
1163
+ ? { inputTokens: Number(inputTokens), outputTokens: Number(outputTokens) } : null;
1164
+ return { result, usage, metadata: reply.metadata };
1165
+ };
1166
+ }
1167
+ function wholeReadTransport(client, onUsageLimit) {
1168
+ return {
1169
+ putPage: (claim, page) => client.putBuildResultPage(claim, page),
1170
+ seal: (claim, request) => client.sealBuildResult(claim, request),
1171
+ accept: (claim, request) => client.acceptBuildResult(claim, request),
1172
+ fail: async (claim, failure, usage) => {
1173
+ if (failure.code === 'usage_limit')
1174
+ onUsageLimit();
1175
+ const result = await client.reportBuildError(claim, failure, usage);
1176
+ if (result.status === 'retrying')
1177
+ await new Promise((resolve) => setTimeout(resolve, 250));
1178
+ return result;
1179
+ },
1180
+ };
1181
+ }
1182
+ function wholeReadBackendName(runtime) {
1183
+ if (runtime.backend === 'api-key')
1184
+ return 'anthropic_api';
1185
+ if (runtime.backend === 'cli')
1186
+ return 'claude_cli';
1187
+ return 'codex';
1188
+ }
1189
+ async function runWholeReadAssignment(input) {
1190
+ const connection = input.connections.find((candidate) => candidate.connectionId === input.assignment.connectionId);
1191
+ const local = connection && !connection.managed ? connection : undefined;
1192
+ const runtime = [
1193
+ connection ? input.runtimes.get(connection.connectionId) : undefined,
1194
+ input.workspaceRuntime ?? undefined,
1195
+ ...input.runtimes.values(),
1196
+ ].find((candidate) => candidate !== undefined
1197
+ && wholeReadBackendName(candidate) === input.assignment.frozenBackend);
1198
+ const backendReady = runtime !== undefined
1199
+ && wholeReadBackendName(runtime) === input.assignment.frozenBackend;
1200
+ const routeReadiness = assignmentRouteReadinessCode(input.assignment.route);
1201
+ if (routeReadiness !== 'ready') {
1202
+ await input.client.acceptBuildAssignment(input.assignment, {
1203
+ lsRemoteVerified: false, exactCommitFetched: false,
1204
+ backend: input.assignment.frozenBackend, backendReady,
1205
+ routeSupported: false,
1206
+ });
1207
+ return { status: 'not_claimed' };
1208
+ }
1209
+ if (input.assignment.state === 'offered') {
1210
+ let verified = false;
1211
+ try {
1212
+ const acquired = await input.sharedSources.acquire(wholeReadSharedSourceKey(input.pairing, input.assignment), async (lease, signal) => {
1213
+ const source = await acquireAssignedSource({
1214
+ route: input.assignment.route,
1215
+ acquireLocal: () => local?.branch
1216
+ ? acquireLocalSource({ repoPath: local.repoPath, branch: local.branch,
1217
+ snapshotSha: input.assignment.snapshotSha, signal })
1218
+ : Promise.reject(new WholeReadGitError('snapshot_unavailable')),
1219
+ acquireGithubCompanion: () => {
1220
+ const repositoryUrl = `https://github.com/${githubLabel(parseGitHubRepo(input.assignment.canonicalRepository))}.git`;
1221
+ return acquireGithubCompanionSource({ lease, repositoryUrl,
1222
+ snapshotSha: input.assignment.snapshotSha, signal });
1223
+ },
1224
+ });
1225
+ if (!source)
1226
+ throw new WholeReadGitError('snapshot_unavailable');
1227
+ return source;
1228
+ });
1229
+ await acquired.release();
1230
+ verified = true;
1231
+ }
1232
+ catch {
1233
+ verified = false;
1234
+ }
1235
+ const acceptance = await input.client.acceptBuildAssignment(input.assignment, {
1236
+ lsRemoteVerified: verified, exactCommitFetched: verified,
1237
+ backend: input.assignment.frozenBackend, backendReady,
1238
+ routeSupported: routeReadiness === 'ready',
1239
+ });
1240
+ if (acceptance.status !== 'ready')
1241
+ return { status: 'not_claimed' };
1242
+ }
1243
+ if (!runtime || !backendReady || input.shouldStop())
1244
+ return { status: 'not_claimed' };
1245
+ const claimed = await input.client.claimBuildUnit(input.assignment, {
1246
+ backend: input.assignment.frozenBackend, backendReady: true,
1247
+ payerIdentities: input.assignment.payerIdentity ? [input.assignment.payerIdentity] : [],
1248
+ });
1249
+ if (claimed.status !== 'claimed')
1250
+ return {
1251
+ status: claimed.status === 'build_concurrency_limit' ? 'build_concurrency_limit' : 'not_claimed',
1252
+ };
1253
+ const claim = claimed;
1254
+ const controller = new AbortController();
1255
+ if (claim.kind === 'group' || claim.kind === 'prd') {
1256
+ try {
1257
+ await withAttemptSource({
1258
+ workspaceId: input.assignment.workspaceId,
1259
+ buildId: input.assignment.buildId,
1260
+ attemptId: claim.attemptId,
1261
+ hostId: input.pairing.workspaceId ?? 'companion',
1262
+ leaseExpiresAt: claim.leaseExpiresAt,
1263
+ }, async (lease) => dispatchWholeReadClaim(claim, {
1264
+ backend: wholeReadBackend(runtime, {
1265
+ attemptDirectory: lease.path,
1266
+ signal: controller.signal,
1267
+ registerReader: (reader) => lease.registerReader(reader),
1268
+ }),
1269
+ transport: wholeReadTransport(input.client, input.onUsageLimit),
1270
+ signal: controller.signal,
1271
+ }));
1272
+ return { status: 'claimed' };
1273
+ }
1274
+ catch (reason) {
1275
+ if (reason instanceof AttemptSourceCleanupError) {
1276
+ await input.client.reportBuildError(claim, { code: 'cleanup_pending', source: 'companion' });
1277
+ return { status: 'claimed' };
1278
+ }
1279
+ await input.client.reportBuildError(claim, {
1280
+ code: 'capability_failure', source: wholeReadBackendName(runtime),
1281
+ });
1282
+ return { status: 'claimed' };
1283
+ }
1284
+ }
1285
+ try {
1286
+ await withAttemptSource({
1287
+ workspaceId: input.assignment.workspaceId,
1288
+ buildId: input.assignment.buildId,
1289
+ attemptId: claim.attemptId,
1290
+ hostId: input.pairing.workspaceId ?? 'companion',
1291
+ leaseExpiresAt: claim.leaseExpiresAt,
1292
+ }, async (lease) => {
1293
+ const shared = await input.sharedSources.acquire(wholeReadSharedSourceKey(input.pairing, input.assignment), async (sourceLease, signal) => {
1294
+ const source = await acquireAssignedSource({
1295
+ route: input.assignment.route,
1296
+ acquireLocal: () => local?.branch
1297
+ ? acquireLocalSource({ repoPath: local.repoPath, branch: local.branch,
1298
+ snapshotSha: input.assignment.snapshotSha, signal })
1299
+ : Promise.reject(new WholeReadGitError('snapshot_unavailable')),
1300
+ acquireGithubCompanion: async () => {
1301
+ const repositoryUrl = `https://github.com/${githubLabel(parseGitHubRepo(input.assignment.canonicalRepository))}.git`;
1302
+ return acquireGithubCompanionSource({ lease: sourceLease, repositoryUrl,
1303
+ snapshotSha: input.assignment.snapshotSha, signal });
1304
+ },
1305
+ });
1306
+ if (!source)
1307
+ throw new WholeReadGitError('snapshot_unavailable');
1308
+ return source;
1309
+ }, controller.signal);
1310
+ try {
1311
+ await dispatchWholeReadClaim(claim, { source: shared.source,
1312
+ backend: wholeReadBackend(runtime, {
1313
+ attemptDirectory: lease.path,
1314
+ signal: controller.signal,
1315
+ registerReader: (reader) => lease.registerReader(reader),
1316
+ }),
1317
+ transport: wholeReadTransport(input.client, input.onUsageLimit), signal: controller.signal });
1318
+ }
1319
+ finally {
1320
+ await shared.release();
1321
+ }
1322
+ });
1323
+ return { status: 'claimed' };
1324
+ }
1325
+ catch (reason) {
1326
+ if (reason instanceof AttemptSourceCleanupError) {
1327
+ await input.client.reportBuildError(claim, { code: 'cleanup_pending', source: 'companion' });
1328
+ }
1329
+ else if (reason instanceof WholeReadGitError || (reason instanceof Error
1330
+ && reason.message === 'snapshot_unavailable')) {
1331
+ await input.client.reportBuildError(claim, { code: 'snapshot_unavailable', source: 'companion' });
1332
+ }
1333
+ else {
1334
+ await input.client.reportBuildError(claim, {
1335
+ code: 'capability_failure', source: wholeReadBackendName(runtime),
1336
+ });
1337
+ }
1338
+ return { status: 'claimed' };
1339
+ }
1340
+ }
1341
+ function wholeReadSharedSourceKey(pairing, assignment) {
1342
+ return {
1343
+ workspaceId: assignment.workspaceId,
1344
+ buildId: assignment.buildId,
1345
+ assignmentId: assignment.id,
1346
+ assignmentRevision: assignment.assignmentRevision,
1347
+ snapshotSha: assignment.snapshotSha,
1348
+ hostId: pairing.workspaceId ?? 'companion',
1349
+ };
853
1350
  }
854
1351
  function pairingPrefix(pairing) {
855
1352
  try {
@@ -864,16 +1361,24 @@ async function runPairing(pairing, options) {
864
1361
  const log = (...values) => console.log(prefix + String(values[0] ?? ''), ...values.slice(1));
865
1362
  const error = (...values) => console.error(prefix + String(values[0] ?? ''), ...values.slice(1));
866
1363
  const warn = (...values) => console.warn(prefix + String(values[0] ?? ''), ...values.slice(1));
867
- const client = new CompanionClient(pairing.url, pairing.token);
1364
+ const fastModelsByConnection = Object.fromEntries(pairing.connections.flatMap((connection) => {
1365
+ if (connection.backend === 'cli')
1366
+ return [[connection.connectionId, CLAUDE_CLI_FAST_MODEL]];
1367
+ if (connection.backend === 'codex')
1368
+ return [[connection.connectionId, CODEX_FAST_MODEL]];
1369
+ return [];
1370
+ }));
1371
+ const client = new CompanionClient(pairing.url, pairing.token, fastModelsByConnection);
868
1372
  const tokenRejected = () => {
869
1373
  error(`The Console at ${pairing.url} no longer accepts this computer's token — stopped working for it. Pair again from that Console to reconnect.`);
870
1374
  return 'token_rejected';
871
1375
  };
1376
+ let serverIdentity;
872
1377
  if (options.multiple) {
873
1378
  let failures = 0;
874
1379
  while (!options.shouldStop()) {
875
1380
  try {
876
- await client.whoami();
1381
+ serverIdentity = await client.whoami();
877
1382
  break;
878
1383
  }
879
1384
  catch (reason) {
@@ -889,13 +1394,23 @@ async function runPairing(pairing, options) {
889
1394
  return 'stopped';
890
1395
  }
891
1396
  else {
892
- await client.whoami();
1397
+ serverIdentity = await client.whoami();
1398
+ }
1399
+ const temporaryRepositories = serverIdentity.temporaryRepositories === true;
1400
+ if (temporaryRepositories) {
1401
+ const migrated = migrateManagedRepositoriesToTemporaryStorage({
1402
+ url: pairing.url,
1403
+ workspaceId: serverIdentity.workspaceId,
1404
+ });
1405
+ if (migrated)
1406
+ pairing.connections = migrated.connections;
1407
+ await activateManagedCopyCleanup(pairing.url, serverIdentity.workspaceId, { log, error });
893
1408
  }
894
1409
  const workspaceBackend = pairing.workspaceBackend ?? 'api-key';
895
1410
  const activeConnections = pairing.connections.filter((connection) => !options.unavailableConnections.has(connection.connectionId));
896
1411
  for (const connection of activeConnections) {
897
- if (connection.managed) {
898
- const label = connection.label ?? labelFromCopyPath(connection.repoPath);
1412
+ if (connection.managed && !temporaryRepositories && !usesTemporaryManagedStorage(connection)) {
1413
+ const label = githubLabel(configuredManagedRepository(connection));
899
1414
  watchManagedCopy(connection.repoPath, label, (message) => error(`[${new Date().toISOString()}] ${message}`), () => client.githubToken(label));
900
1415
  }
901
1416
  }
@@ -903,13 +1418,18 @@ async function runPairing(pairing, options) {
903
1418
  connection.connectionId,
904
1419
  createBackendRuntime(connection, options.anthropic),
905
1420
  ]));
1421
+ const repositoryProvider = ordinaryTaskRepositoryProvider(pairing, client);
1422
+ const repositoryReadinessFor = (connection) => checkConnectionRepositoryReadiness(connection, temporaryRepositories, {
1423
+ getCredential: async (_configured, repository) => (await client.githubToken(githubLabel(repository))).token,
1424
+ managedRepositoryUrl: (repository) => testCloneUrl(repository) ?? githubCloneUrl(repository),
1425
+ });
906
1426
  const workspaceRuntime = pairing.pollWorkspaceTasks && !options.workspaceTasksUnavailable
907
1427
  ? createBackendRuntime({ backend: workspaceBackend, codexModel: pairing.codexModel }, options.anthropic)
908
1428
  : null;
909
1429
  const repositoryReadiness = new Map();
910
1430
  for (const connection of activeConnections) {
911
1431
  repositoryReadiness.set(connection.connectionId, {
912
- readiness: await checkRepositoryReadiness(connection.repoPath),
1432
+ readiness: await repositoryReadinessFor(connection),
913
1433
  checkedAt: Date.now(),
914
1434
  });
915
1435
  }
@@ -924,6 +1444,10 @@ async function runPairing(pairing, options) {
924
1444
  for (const connection of activeConnections) {
925
1445
  const readiness = repositoryReadiness.get(connection.connectionId).readiness;
926
1446
  if (readiness.state === 'ready') {
1447
+ if (temporaryRepositories && connection.managed) {
1448
+ log(` - ${connection.connectionId} (${githubLabel(configuredManagedRepository(connection))}; temporary repository access ready) (${connection.backend})`);
1449
+ continue;
1450
+ }
927
1451
  const sha = await currentCommit(connection.repoPath).catch(() => '(unknown)');
928
1452
  log(options.safeStartupDiagnostics
929
1453
  ? ` - ${connection.connectionId} @ ${sha.slice(0, 7)} (${connection.backend})`
@@ -950,6 +1474,7 @@ async function runPairing(pairing, options) {
950
1474
  let investigationPollFailures = 0;
951
1475
  let nextInvestigationPollAt = 0;
952
1476
  let surfaceMappingPollingEnabled = true;
1477
+ let wholeReadBuildPollingEnabled = true;
953
1478
  const skippedConnections = new Set();
954
1479
  const loggedSkippedConnections = new Set();
955
1480
  const readinessReporting = {
@@ -964,164 +1489,217 @@ async function runPairing(pairing, options) {
964
1489
  loggedSkippedConnections.add(connectionId);
965
1490
  error(`Project ${connectionId} isn't part of the workspace at ${pairing.url} anymore — skipping it. Run connect again from that project's folder to re-add it.`);
966
1491
  };
967
- while (!options.shouldStop()) {
968
- try {
969
- let claimedAny = false;
970
- const pollableConnections = activeConnections.filter((connection) => !skippedConnections.has(connection.connectionId));
971
- const readyConnections = [];
972
- const readinessNow = Date.now();
973
- for (const connection of pollableConnections) {
974
- if (options.shouldStop())
975
- break;
976
- let cached = repositoryReadiness.get(connection.connectionId);
977
- if (readinessNow - cached.checkedAt >= REPOSITORY_RECHECK_MS) {
978
- const next = await checkRepositoryReadiness(connection.repoPath);
979
- if (next.state !== cached.readiness.state) {
980
- if (next.state === 'ready') {
981
- log(`[${new Date().toISOString()}] Repository ready for ${connection.connectionId}; resuming repository-aware polling.`);
982
- }
983
- else {
984
- error(options.safeStartupDiagnostics
985
- ? `[${new Date().toISOString()}] Repository not ready for ${connection.connectionId}.`
986
- : `[${new Date().toISOString()}] Repository not ready for ${connection.connectionId}: ${next.reason}`);
1492
+ let usageClaimLimit = 0;
1493
+ let asyncTokenRejected = false;
1494
+ const mayClaim = () => usageClaimLimit === null || usageClaimLimit > 0;
1495
+ const recordCompletedClaim = (usageLimitMessage) => {
1496
+ if (usageClaimLimit !== null)
1497
+ usageClaimLimit--;
1498
+ if (usageLimitMessage) {
1499
+ options.usageLimitPause.pause(usageLimitMessage);
1500
+ usageClaimLimit = 0;
1501
+ }
1502
+ };
1503
+ const sharedSources = createWholeReadSharedSourceManager({
1504
+ onCleanupPending: () => { error('[whole-read-temp] cleanup_pending'); },
1505
+ });
1506
+ const wholeReadPool = createWholeReadAssignmentPool({
1507
+ key: (assignment) => assignment.id,
1508
+ mayClaim,
1509
+ shouldStop: options.shouldStop,
1510
+ onStart: () => { if (usageClaimLimit !== null)
1511
+ usageClaimLimit--; },
1512
+ run: (assignment) => runWholeReadAssignment({ pairing, client, assignment,
1513
+ connections: activeConnections, runtimes, workspaceRuntime, sharedSources,
1514
+ onUsageLimit: () => recordCompletedClaim('usage limit reached'),
1515
+ shouldStop: options.shouldStop }),
1516
+ onError: (reason) => {
1517
+ if (reason instanceof CompanionHttpError && reason.status === 401)
1518
+ asyncTokenRejected = true;
1519
+ else
1520
+ error('Whole-read build attempt failed:', reason instanceof Error ? reason.message : reason);
1521
+ },
1522
+ });
1523
+ try {
1524
+ while (!options.shouldStop()) {
1525
+ try {
1526
+ if (asyncTokenRejected)
1527
+ return tokenRejected();
1528
+ let claimedAny = false;
1529
+ const pollableConnections = activeConnections.filter((connection) => !skippedConnections.has(connection.connectionId));
1530
+ const readyConnections = [];
1531
+ const readinessNow = Date.now();
1532
+ for (const connection of pollableConnections) {
1533
+ if (options.shouldStop())
1534
+ break;
1535
+ let cached = repositoryReadiness.get(connection.connectionId);
1536
+ if (readinessNow - cached.checkedAt >= REPOSITORY_RECHECK_MS) {
1537
+ const next = await repositoryReadinessFor(connection);
1538
+ if (next.state !== cached.readiness.state) {
1539
+ if (next.state === 'ready') {
1540
+ log(`[${new Date().toISOString()}] Repository ready for ${connection.connectionId}; resuming repository-aware polling.`);
1541
+ }
1542
+ else {
1543
+ error(options.safeStartupDiagnostics
1544
+ ? `[${new Date().toISOString()}] Repository not ready for ${connection.connectionId}.`
1545
+ : `[${new Date().toISOString()}] Repository not ready for ${connection.connectionId}: ${next.reason}`);
1546
+ }
987
1547
  }
1548
+ cached = { readiness: next, checkedAt: readinessNow };
1549
+ repositoryReadiness.set(connection.connectionId, cached);
988
1550
  }
989
- cached = { readiness: next, checkedAt: readinessNow };
990
- repositoryReadiness.set(connection.connectionId, cached);
991
- }
992
- if (cached.readiness.state === 'ready')
993
- readyConnections.push(connection);
994
- }
995
- const readinessOutcome = await reportLoopReadiness(client, buildReadinessReport(pollableConnections, options.unavailableConnections, repositoryReadiness), readinessReporting, { error });
996
- for (const connectionId of [...skippedConnections])
997
- skipConnection(connectionId);
998
- if (readinessOutcome === 'token_rejected')
999
- return tokenRejected();
1000
- let usageClaimLimit = options.usageLimitPause.claimLimit();
1001
- const mayClaim = () => usageClaimLimit === null || usageClaimLimit > 0;
1002
- const recordCompletedClaim = (usageLimitMessage) => {
1003
- if (usageClaimLimit !== null)
1004
- usageClaimLimit--;
1005
- if (usageLimitMessage) {
1006
- options.usageLimitPause.pause(usageLimitMessage);
1007
- usageClaimLimit = 0;
1551
+ if (cached.readiness.state === 'ready')
1552
+ readyConnections.push(connection);
1008
1553
  }
1009
- };
1010
- for (const connection of readyConnections) {
1011
- if (options.shouldStop() || !mayClaim())
1012
- break;
1013
- if (skippedConnections.has(connection.connectionId))
1014
- continue;
1015
- if (surfaceMappingPollingEnabled) {
1554
+ const repositoryPollableConnections = readyConnections;
1555
+ const readinessOutcome = await reportLoopReadiness(client, buildReadinessReport(temporaryRepositories
1556
+ ? pairing.connections.filter((connection) => !skippedConnections.has(connection.connectionId))
1557
+ : pollableConnections, options.unavailableConnections, repositoryReadiness), readinessReporting, { error });
1558
+ for (const connectionId of [...skippedConnections])
1559
+ skipConnection(connectionId);
1560
+ if (readinessOutcome === 'token_rejected')
1561
+ return tokenRejected();
1562
+ usageClaimLimit = options.usageLimitPause.claimLimit();
1563
+ if (!options.shouldStop() && wholeReadBuildPollingEnabled) {
1016
1564
  try {
1017
- const mapping = await client.claimNextSurfaceMapping(connection.connectionId);
1018
- if (mapping.job) {
1565
+ const { assignments } = await client.listBuildAssignments();
1566
+ void sharedSources.reconcile(assignments.map((assignment) => wholeReadSharedSourceKey(pairing, assignment))).catch((reason) => {
1567
+ error('[whole-read-temp] cleanup_failed:', reason instanceof Error ? reason.message : reason);
1568
+ });
1569
+ if (wholeReadPool.poll(assignments) > 0)
1019
1570
  claimedAny = true;
1020
- recordCompletedClaim(await runOneSurfaceMapping(options.anthropic, client, connection, runtimes.get(connection.connectionId), mapping.job, { log, error }));
1021
- }
1022
1571
  }
1023
1572
  catch (reason) {
1024
1573
  if (reason instanceof CompanionHttpError && reason.status === 401)
1025
1574
  return tokenRejected();
1026
- if (isUnknownConnectionError(reason)) {
1027
- skipConnection(connection.connectionId);
1028
- continue;
1029
- }
1030
- const status = surfaceMappingUnavailableStatus(reason);
1031
- if (status !== null) {
1032
- error(`Automatic mapping is not available on this server (HTTP ${status}); continuing without it.`);
1033
- surfaceMappingPollingEnabled = false;
1575
+ if (reason instanceof CompanionHttpError && [403, 404, 405].includes(reason.status)) {
1576
+ wholeReadBuildPollingEnabled = false;
1034
1577
  }
1035
1578
  else {
1036
- error(`Surface-mapping poll failed for ${connection.connectionId}:`, reason instanceof Error ? reason.message : reason);
1579
+ error('Whole-read build poll failed:', reason instanceof Error ? reason.message : reason);
1037
1580
  }
1038
1581
  }
1039
1582
  }
1040
- if (!mayClaim() || skippedConnections.has(connection.connectionId))
1041
- continue;
1042
- try {
1043
- const claim = await client.claimNext(connection.connectionId);
1044
- if (!claim.task)
1045
- continue;
1046
- claimedAny = true;
1047
- recordCompletedClaim(await runOneTask(runtimes.get(connection.connectionId), client, connection.repoPath, claim.task, connection, { log, error }));
1048
- }
1049
- catch (reason) {
1050
- if (reason instanceof CompanionHttpError && reason.status === 401)
1051
- return tokenRejected();
1052
- if (isUnknownConnectionError(reason)) {
1053
- skipConnection(connection.connectionId);
1583
+ for (const connection of repositoryPollableConnections) {
1584
+ if (options.shouldStop() || !mayClaim())
1585
+ break;
1586
+ if (skippedConnections.has(connection.connectionId))
1054
1587
  continue;
1588
+ if (surfaceMappingPollingEnabled) {
1589
+ try {
1590
+ const mapping = await client.claimNextSurfaceMapping(connection.connectionId);
1591
+ if (mapping.job) {
1592
+ claimedAny = true;
1593
+ recordCompletedClaim(await runOneSurfaceMapping(options.anthropic, client, connection, runtimes.get(connection.connectionId), mapping.job, { log, error }, repositoryProvider));
1594
+ }
1595
+ }
1596
+ catch (reason) {
1597
+ if (reason instanceof CompanionHttpError && reason.status === 401)
1598
+ return tokenRejected();
1599
+ if (isUnknownConnectionError(reason)) {
1600
+ skipConnection(connection.connectionId);
1601
+ continue;
1602
+ }
1603
+ const status = surfaceMappingUnavailableStatus(reason);
1604
+ if (status !== null) {
1605
+ error(`Automatic mapping is not available on this server (HTTP ${status}); continuing without it.`);
1606
+ surfaceMappingPollingEnabled = false;
1607
+ }
1608
+ else {
1609
+ error(`Surface-mapping poll failed for ${connection.connectionId}:`, reason instanceof Error ? reason.message : reason);
1610
+ }
1611
+ }
1055
1612
  }
1056
- error(`Poll failed for ${connection.connectionId}:`, reason instanceof Error ? reason.message : reason);
1057
- }
1058
- }
1059
- if (!options.shouldStop() && workspaceRuntime && mayClaim()) {
1060
- try {
1061
- const claim = await client.claimNext();
1062
- if (claim.task) {
1613
+ if (!mayClaim() || skippedConnections.has(connection.connectionId))
1614
+ continue;
1615
+ try {
1616
+ const claim = await client.claimNext(connection.connectionId);
1617
+ if (!claim.task)
1618
+ continue;
1063
1619
  claimedAny = true;
1064
- recordCompletedClaim(await runOneTask(workspaceRuntime, client, process.cwd(), claim.task, undefined, { log, error }));
1620
+ recordCompletedClaim(await runOneTask(runtimes.get(connection.connectionId), client, connection.repoPath, claim.task, connection, { log, error }, repositoryProvider));
1621
+ }
1622
+ catch (reason) {
1623
+ if (reason instanceof CompanionHttpError && reason.status === 401)
1624
+ return tokenRejected();
1625
+ if (isUnknownConnectionError(reason)) {
1626
+ skipConnection(connection.connectionId);
1627
+ continue;
1628
+ }
1629
+ error(`Poll failed for ${connection.connectionId}:`, reason instanceof Error ? reason.message : reason);
1065
1630
  }
1066
1631
  }
1067
- catch (reason) {
1068
- if (reason instanceof CompanionHttpError && reason.status === 401)
1069
- return tokenRejected();
1070
- error('Workspace-level poll failed:', reason instanceof Error ? reason.message : reason);
1071
- }
1072
- }
1073
- const remainingConfigured = pairing.connections.filter((connection) => !skippedConnections.has(connection.connectionId));
1074
- if (!options.shouldStop() && mayClaim() && readyConnections.length > 0
1075
- && readyConnections.length === pollableConnections.length
1076
- && pollableConnections.length === remainingConfigured.length
1077
- && Date.now() >= nextInvestigationPollAt) {
1078
- try {
1079
- const investigation = await client.claimNextInvestigation();
1080
- ({ consecutiveFailures: investigationPollFailures } = nextInvestigationPollBackoff(investigationPollFailures, true));
1081
- nextInvestigationPollAt = 0;
1082
- if (investigation) {
1083
- claimedAny = true;
1084
- recordCompletedClaim(await runOneInvestigation(options.anthropic, client, pollableConnections, runtimes, investigation, { log, error }));
1632
+ if (!options.shouldStop() && workspaceRuntime && mayClaim()) {
1633
+ try {
1634
+ const claim = await client.claimNext();
1635
+ if (claim.task) {
1636
+ claimedAny = true;
1637
+ recordCompletedClaim(await runOneTask(workspaceRuntime, client, process.cwd(), claim.task, undefined, { log, error }));
1638
+ }
1639
+ }
1640
+ catch (reason) {
1641
+ if (reason instanceof CompanionHttpError && reason.status === 401)
1642
+ return tokenRejected();
1643
+ error('Workspace-level poll failed:', reason instanceof Error ? reason.message : reason);
1085
1644
  }
1086
1645
  }
1087
- catch (reason) {
1088
- if (reason instanceof CompanionHttpError && reason.status === 401)
1089
- return tokenRejected();
1090
- const backoff = nextInvestigationPollBackoff(investigationPollFailures, false);
1091
- investigationPollFailures = backoff.consecutiveFailures;
1092
- nextInvestigationPollAt = Date.now() + backoff.delayMs;
1093
- if (investigationPollFailures === 1) {
1094
- error('Investigation poll failed:', reason instanceof Error ? reason.message : reason, `Retrying in ${backoff.delayMs / 1000}s.`);
1646
+ const remainingConfigured = pairing.connections.filter((connection) => !skippedConnections.has(connection.connectionId));
1647
+ if (!options.shouldStop() && mayClaim() && repositoryPollableConnections.length > 0
1648
+ && repositoryPollableConnections.length === pollableConnections.length
1649
+ && pollableConnections.length === remainingConfigured.length
1650
+ && Date.now() >= nextInvestigationPollAt) {
1651
+ try {
1652
+ const investigation = await client.claimNextInvestigation();
1653
+ ({ consecutiveFailures: investigationPollFailures } = nextInvestigationPollBackoff(investigationPollFailures, true));
1654
+ nextInvestigationPollAt = 0;
1655
+ if (investigation) {
1656
+ claimedAny = true;
1657
+ recordCompletedClaim(await runOneInvestigation(options.anthropic, client, pollableConnections, runtimes, investigation, { log, error }, repositoryProvider));
1658
+ }
1095
1659
  }
1096
- else {
1097
- warn(`Investigation poll still failing (${investigationPollFailures} consecutive); retrying in ${backoff.delayMs / 1000}s.`);
1660
+ catch (reason) {
1661
+ if (reason instanceof CompanionHttpError && reason.status === 401)
1662
+ return tokenRejected();
1663
+ const backoff = nextInvestigationPollBackoff(investigationPollFailures, false);
1664
+ investigationPollFailures = backoff.consecutiveFailures;
1665
+ nextInvestigationPollAt = Date.now() + backoff.delayMs;
1666
+ if (investigationPollFailures === 1) {
1667
+ error('Investigation poll failed:', reason instanceof Error ? reason.message : reason, `Retrying in ${backoff.delayMs / 1000}s.`);
1668
+ }
1669
+ else {
1670
+ warn(`Investigation poll still failing (${investigationPollFailures} consecutive); retrying in ${backoff.delayMs / 1000}s.`);
1671
+ }
1098
1672
  }
1099
1673
  }
1100
- }
1101
- if (claimedAny) {
1102
- lastIdleStatusAt = Date.now();
1103
- }
1104
- else if (!options.shouldStop()) {
1105
- const now = Date.now();
1106
- if (now - lastIdleStatusAt >= IDLE_STATUS_MS) {
1107
- log(`[${new Date(now).toISOString()}] still watching — no work claimed.`);
1108
- lastIdleStatusAt = now;
1674
+ if (claimedAny) {
1675
+ lastIdleStatusAt = Date.now();
1676
+ }
1677
+ else if (!options.shouldStop()) {
1678
+ const now = Date.now();
1679
+ if (now - lastIdleStatusAt >= IDLE_STATUS_MS) {
1680
+ log(`[${new Date(now).toISOString()}] still watching — no work claimed.`);
1681
+ lastIdleStatusAt = now;
1682
+ }
1683
+ await new Promise((done) => setTimeout(done, IDLE_POLL_MS));
1109
1684
  }
1110
- await new Promise((done) => setTimeout(done, IDLE_POLL_MS));
1111
1685
  }
1112
- }
1113
- catch (reason) {
1114
- if (reason instanceof CompanionHttpError && reason.status === 401)
1115
- return tokenRejected();
1116
- error('Unexpected run-loop error:', reason instanceof Error ? reason.message : reason);
1117
- if (!options.shouldStop())
1118
- await new Promise((done) => setTimeout(done, LOOP_RECOVERY_DELAY_MS));
1686
+ catch (reason) {
1687
+ if (reason instanceof CompanionHttpError && reason.status === 401)
1688
+ return tokenRejected();
1689
+ error('Unexpected run-loop error:', reason instanceof Error ? reason.message : reason);
1690
+ if (!options.shouldStop())
1691
+ await new Promise((done) => setTimeout(done, LOOP_RECOVERY_DELAY_MS));
1692
+ }
1119
1693
  }
1120
1694
  }
1695
+ finally {
1696
+ await wholeReadPool.shutdown();
1697
+ await sharedSources.shutdown();
1698
+ }
1121
1699
  log('Stopped.');
1122
1700
  return 'stopped';
1123
1701
  }
1124
- async function cmdRun(options = {}) {
1702
+ async function runCompanionLoop(options = {}) {
1125
1703
  let config;
1126
1704
  try {
1127
1705
  config = loadConfig();
@@ -1271,6 +1849,22 @@ async function cmdRun(options = {}) {
1271
1849
  }
1272
1850
  }));
1273
1851
  }
1852
+ async function cmdRun(options = {}) {
1853
+ // Cleanup is deliberately not flag-gated: source left by an older enabled build must still be
1854
+ // removed after rollback or while its Console is offline. Importing the module starts no timer.
1855
+ try {
1856
+ await startAttemptSourceSweeper();
1857
+ }
1858
+ catch {
1859
+ console.error('[whole-read-temp] cleanup_sweep_failed');
1860
+ }
1861
+ try {
1862
+ await runCompanionLoop(options);
1863
+ }
1864
+ finally {
1865
+ stopAttemptSourceSweeper();
1866
+ }
1867
+ }
1274
1868
  async function cmdRunWithInstance(options = {}) {
1275
1869
  if (process.env.PRODUCT_CONSOLE_COMPANION_DAEMON_CHILD === '1')
1276
1870
  return cmdRun(options);
@@ -1393,9 +1987,8 @@ async function cmdDisconnect(argv) {
1393
1987
  await removeManagedCopies(removed.flatMap((pairing) => pairing.connections));
1394
1988
  logRestartHintIfRunning();
1395
1989
  }
1396
- /** Deletes the companion's own copies behind removed connections. A copy another remaining
1397
- * connection still uses (the same repo connected to a second Console) is kept. Only paths inside
1398
- * <config dir>/repos/ are ever deleted, and only for connections marked managed. */
1990
+ /** Legacy disconnect behavior. B38-c owns deletion for migrated storage; until then those paths
1991
+ * are left untouched. A legacy copy another remaining connection uses is also kept. */
1399
1992
  async function removeManagedCopies(removedConnections) {
1400
1993
  const reposDir = managedReposDir();
1401
1994
  const stillUsed = new Set((loadConfig()?.pairings ?? [])
@@ -1403,7 +1996,7 @@ async function removeManagedCopies(removedConnections) {
1403
1996
  .map((connection) => resolve(connection.repoPath).toLowerCase()));
1404
1997
  const handled = new Set();
1405
1998
  for (const connection of removedConnections) {
1406
- if (!connection.managed)
1999
+ if (!connection.managed || usesTemporaryManagedStorage(connection))
1407
2000
  continue;
1408
2001
  const key = resolve(connection.repoPath).toLowerCase();
1409
2002
  if (stillUsed.has(key) || handled.has(key))
@@ -1427,7 +2020,7 @@ async function cmdSignin(argv) {
1427
2020
  let wanted = null;
1428
2021
  if (flags.repo !== undefined) {
1429
2022
  try {
1430
- wanted = managedCopyPath(managedReposDir(), parseGitHubRepo(flags.repo)).toLowerCase();
2023
+ wanted = githubLabel(parseGitHubRepo(flags.repo)).toLowerCase();
1431
2024
  }
1432
2025
  catch (error) {
1433
2026
  console.error(error instanceof ManagedRepoError ? error.message : String(error));
@@ -1435,24 +2028,39 @@ async function cmdSignin(argv) {
1435
2028
  return;
1436
2029
  }
1437
2030
  }
1438
- const copies = new Map();
2031
+ const targets = new Map();
1439
2032
  for (const pairing of loadConfig()?.pairings ?? []) {
1440
2033
  const client = new CompanionClient(pairing.url, pairing.token);
1441
2034
  for (const connection of pairing.connections) {
1442
2035
  if (!connection.managed)
1443
2036
  continue;
1444
- const key = resolve(connection.repoPath).toLowerCase();
1445
- if (copies.has(key) || (wanted !== null && key !== wanted))
2037
+ let repository;
2038
+ try {
2039
+ repository = configuredManagedRepository(connection);
2040
+ }
2041
+ catch {
2042
+ continue;
2043
+ }
2044
+ const label = githubLabel(repository);
2045
+ const key = `${normalizeConsoleUrl(pairing.url)}\0${pairing.workspaceId ?? ''}\0${label.toLowerCase()}`;
2046
+ if (targets.has(key) || (wanted !== null && label.toLowerCase() !== wanted))
1446
2047
  continue;
1447
- const label = connection.label ?? labelFromCopyPath(connection.repoPath);
1448
- copies.set(key, {
2048
+ targets.set(key, usesTemporaryManagedStorage(connection) ? {
2049
+ storage: 'temporary',
2050
+ repository,
2051
+ label,
2052
+ branch: connection.branch,
2053
+ getToken: () => client.githubToken(label).then((response) => response.token),
2054
+ } : {
2055
+ storage: 'persistent',
2056
+ repository,
1449
2057
  repoPath: connection.repoPath,
1450
2058
  label,
1451
2059
  getToken: () => client.githubToken(label).then((response) => response.token),
1452
2060
  });
1453
2061
  }
1454
2062
  }
1455
- if (copies.size === 0) {
2063
+ if (targets.size === 0) {
1456
2064
  console.log(wanted === null
1457
2065
  ? "There's no companion-kept copy of any repo on this computer."
1458
2066
  : `There's no companion-kept copy of ${flags.repo} on this computer.`);
@@ -1460,7 +2068,24 @@ async function cmdSignin(argv) {
1460
2068
  process.exitCode = 1;
1461
2069
  return;
1462
2070
  }
1463
- for (const { repoPath, label, getToken } of copies.values()) {
2071
+ for (const target of targets.values()) {
2072
+ const { label, getToken } = target;
2073
+ if (target.storage === 'temporary') {
2074
+ try {
2075
+ await proveManagedRepositoryAccess({
2076
+ repository: target.repository,
2077
+ branch: target.branch,
2078
+ credential: await getToken(),
2079
+ }, { managedRepositoryUrl: (repository) => testCloneUrl(repository) ?? githubCloneUrl(repository) });
2080
+ console.log(`GitHub access is ready for ${label}.`);
2081
+ }
2082
+ catch {
2083
+ console.log(`Still can't reach ${label} on GitHub. Reconnect GitHub in the Console.`);
2084
+ process.exitCode = 1;
2085
+ }
2086
+ continue;
2087
+ }
2088
+ const { repoPath } = target;
1464
2089
  if (!existsSync(repoPath)) {
1465
2090
  console.log(`The companion's copy of ${label} is missing. Run the connect command for it again.`);
1466
2091
  process.exitCode = 1;
@@ -1496,6 +2121,11 @@ function cmdStatus() {
1496
2121
  const workspace = pairing.workspaceId ? ` (workspace ${pairing.workspaceId.slice(0, 8)}…)` : '';
1497
2122
  console.log(` ${pairing.url}${workspace}`);
1498
2123
  for (const project of pairing.projects) {
2124
+ if (project.managed && project.repositoryStorage === 'temporary') {
2125
+ const label = project.repository?.path ?? project.label ?? labelFromCopyPath(project.repoPath);
2126
+ console.log(` ${project.connectionId} — ${label} — temporary repository access`);
2127
+ continue;
2128
+ }
1499
2129
  console.log(` ${project.connectionId} — ${project.repoPath}`);
1500
2130
  if (!project.managed)
1501
2131
  continue;