@withtruing/companion 0.0.0-stage → 0.13.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/LICENSE +21 -0
- package/README.md +329 -3
- package/dist/api-key-caller.js +388 -0
- package/dist/claude-binary.js +122 -0
- package/dist/claude-cli-caller.js +344 -0
- package/dist/cli-arguments.js +11 -0
- package/dist/cli.js +1486 -0
- package/dist/codex-cli-caller.js +536 -0
- package/dist/config.js +119 -0
- package/dist/design-context.js +54 -0
- package/dist/git-range.js +291 -0
- package/dist/http-client.js +214 -0
- package/dist/import-resolver.js +644 -0
- package/dist/investigation-reader.js +1303 -0
- package/dist/local-scan.js +243 -0
- package/dist/managed-repo.js +408 -0
- package/dist/observed-sources.js +85 -0
- package/dist/reader-result-contract.js +467 -0
- package/dist/sandbox.js +215 -0
- package/dist/schema-entities.js +755 -0
- package/dist/service.js +710 -0
- package/dist/shipment.js +115 -0
- package/dist/strict-schema.js +187 -0
- package/dist/surface-mapper.js +357 -0
- package/dist/text-model.js +121 -0
- package/dist/types.js +11 -0
- package/dist/usage-limit.js +130 -0
- package/package.json +50 -6
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1486 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
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';
|
|
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';
|
|
11
|
+
import { spawnClaude } from './claude-binary.js';
|
|
12
|
+
import { checkCodexLogin, CODEX_CODE_MODE_MODELS, codexTextModel, defaultCodexHome, runStructuredModelCallViaCodex } from './codex-cli-caller.js';
|
|
13
|
+
import { formatInvestigationReaderDiagnostic, INVESTIGATION_MODEL, runAndReportInvestigation } from './investigation-reader.js';
|
|
14
|
+
import { anthropicTextModel } from './text-model.js';
|
|
15
|
+
import { MappingFailure, runSurfaceMapping, SURFACE_SEED_MODEL } from './surface-mapper.js';
|
|
16
|
+
import { assertSnapshotReadBoundary, createSandbox } from './sandbox.js';
|
|
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';
|
|
19
|
+
import { isUsageLimitError, UsageLimitPause } from './usage-limit.js';
|
|
20
|
+
import { acquireInstanceLock, installService, isNpxCachePath, releaseInstanceLock, runDaemon, servicePaths, serviceStatus, startService, stopService, uninstallService, } from './service.js';
|
|
21
|
+
const DEFAULT_CODEX_MODEL = 'gpt-6-astra';
|
|
22
|
+
const SURFACE_MAPPING_UNAVAILABLE_STATUSES = new Set([401, 403, 404, 405]);
|
|
23
|
+
let expectedExitReason = null;
|
|
24
|
+
let unexpectedExitReason = null;
|
|
25
|
+
function asError(reason) {
|
|
26
|
+
return reason instanceof Error ? reason : new Error(String(reason));
|
|
27
|
+
}
|
|
28
|
+
function surfaceMappingUnavailableStatus(reason) {
|
|
29
|
+
return reason instanceof CompanionHttpError && SURFACE_MAPPING_UNAVAILABLE_STATUSES.has(reason.status)
|
|
30
|
+
? reason.status
|
|
31
|
+
: null;
|
|
32
|
+
}
|
|
33
|
+
function logFatal(label, reason) {
|
|
34
|
+
const error = asError(reason);
|
|
35
|
+
console.error(`${label}: ${error.message}`);
|
|
36
|
+
console.error(error.stack ?? '(no stack available)');
|
|
37
|
+
return error;
|
|
38
|
+
}
|
|
39
|
+
function installProcessHandlers() {
|
|
40
|
+
process.on('uncaughtException', (reason) => {
|
|
41
|
+
const error = logFatal('FATAL uncaught exception', reason);
|
|
42
|
+
unexpectedExitReason = `uncaught exception: ${error.message}`;
|
|
43
|
+
process.exit(1);
|
|
44
|
+
});
|
|
45
|
+
process.on('unhandledRejection', (reason) => {
|
|
46
|
+
const error = logFatal('FATAL unhandled rejection', reason);
|
|
47
|
+
unexpectedExitReason = `unhandled rejection: ${error.message}`;
|
|
48
|
+
process.exit(1);
|
|
49
|
+
});
|
|
50
|
+
process.on('exit', (code) => {
|
|
51
|
+
const timestamp = new Date().toISOString();
|
|
52
|
+
if (unexpectedExitReason) {
|
|
53
|
+
const reason = /[.!?]$/.test(unexpectedExitReason) ? unexpectedExitReason : `${unexpectedExitReason}.`;
|
|
54
|
+
console.error(`[${timestamp}] Companion exited unexpectedly (code ${code}): ${reason} Restart it to keep watching for work.`);
|
|
55
|
+
}
|
|
56
|
+
else if (expectedExitReason) {
|
|
57
|
+
const reason = /[.!?]$/.test(expectedExitReason) ? expectedExitReason : `${expectedExitReason}.`;
|
|
58
|
+
console.log(`[${timestamp}] Companion exited normally (code ${code}): ${reason}`);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
console.error(`[${timestamp}] Companion exited unexpectedly (code ${code}) without completing its command. Restart it to keep watching for work.`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/** A flag with nothing after it (end of argv, or the next token is itself another "--flag") is
|
|
66
|
+
* treated as a bare boolean ("true") rather than swallowing the following flag as its value —
|
|
67
|
+
* needed for --no-run below. Every existing "--flag value" pair still parses exactly as before. */
|
|
68
|
+
function parseFlags(argv) {
|
|
69
|
+
const flags = {};
|
|
70
|
+
for (let i = 0; i < argv.length; i++) {
|
|
71
|
+
const a = argv[i];
|
|
72
|
+
if (a?.startsWith('--')) {
|
|
73
|
+
const next = argv[i + 1];
|
|
74
|
+
if (next === undefined || next.startsWith('--')) {
|
|
75
|
+
flags[a.slice(2)] = 'true';
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
flags[a.slice(2)] = next;
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return flags;
|
|
84
|
+
}
|
|
85
|
+
async function checkClaudeCliAvailable() {
|
|
86
|
+
return new Promise((resolvePromise) => {
|
|
87
|
+
const child = spawnClaude(['--version']);
|
|
88
|
+
let out = '';
|
|
89
|
+
child.stdout?.on('data', (chunk) => (out += chunk.toString()));
|
|
90
|
+
child.on('error', () => resolvePromise(null)); // e.g. ENOENT — not installed / not on PATH
|
|
91
|
+
child.on('close', (code) => resolvePromise(code === 0 ? out.trim() : null));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const GIT_MISSING_MESSAGE = 'This needs Git. Install it from https://git-scm.com/downloads, then run this command again.';
|
|
95
|
+
const SIGN_IN_NEEDED_HINT = 'GitHub sign-in needed: run truing signin';
|
|
96
|
+
const RECONNECT_IN_CONSOLE_HINT = 'GitHub sign-in needed: Reconnect GitHub in the Console';
|
|
97
|
+
/** Test-only: lets the CLI tests clone from a local bare repo instead of github.com. origin is
|
|
98
|
+
* still recorded as the GitHub URL. Ignored outside NODE_ENV=test. */
|
|
99
|
+
function testCloneUrl(repo) {
|
|
100
|
+
const base = process.env.NODE_ENV === 'test' ? process.env.PC_TEST_GITHUB_CLONE_BASE : undefined;
|
|
101
|
+
return base ? `${base.replace(/\/+$/, '')}/${repo.owner}/${repo.name}.git` : undefined;
|
|
102
|
+
}
|
|
103
|
+
const restartHint = 'The companion is already running in the background — run "truing stop" then "truing start" so it picks up this new connection.';
|
|
104
|
+
function logRestartHintIfRunning() {
|
|
105
|
+
if (serviceStatus().running)
|
|
106
|
+
console.log(restartHint);
|
|
107
|
+
}
|
|
108
|
+
async function resolveLegacyPairings(url) {
|
|
109
|
+
const config = loadConfig();
|
|
110
|
+
if (!config)
|
|
111
|
+
return;
|
|
112
|
+
for (const pairing of config.pairings) {
|
|
113
|
+
if (pairing.workspaceId !== undefined || normalizeConsoleUrl(pairing.url) !== normalizeConsoleUrl(url))
|
|
114
|
+
continue;
|
|
115
|
+
try {
|
|
116
|
+
const identity = await new CompanionClient(pairing.url, pairing.token).whoami();
|
|
117
|
+
recordWorkspaceId(pairing.url, pairing.token, identity.workspaceId);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// An old token that can no longer be verified remains separate from the new Console entry.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function logConnectedConsoles() {
|
|
125
|
+
const config = loadConfig();
|
|
126
|
+
if (!config || config.pairings.length < 2)
|
|
127
|
+
return;
|
|
128
|
+
console.log(`This computer is now connected to ${config.pairings.length} Consoles: `
|
|
129
|
+
+ `${config.pairings.map((pairing) => pairing.url).join(' and ')}. Each keeps its own projects.`);
|
|
130
|
+
}
|
|
131
|
+
async function cmdConnect(argv) {
|
|
132
|
+
const f = parseFlags(argv);
|
|
133
|
+
const url = f.url?.replace(/\/+$/, '');
|
|
134
|
+
const token = f.token;
|
|
135
|
+
const connectionId = f.connection;
|
|
136
|
+
const apiKey = f['api-key'];
|
|
137
|
+
const backend = (f.backend || 'api-key');
|
|
138
|
+
const codexModel = f['codex-model'] || DEFAULT_CODEX_MODEL;
|
|
139
|
+
const background = f.background === 'true';
|
|
140
|
+
// Pairing on its own does nothing useful — see cmdRun below — so this defaults to starting
|
|
141
|
+
// right away, which is what someone following the app's own copy-pasted command wants: one
|
|
142
|
+
// paste, and it's working. --no-run is the escape hatch for pairing a second/third project
|
|
143
|
+
// before starting one shared `run` for all of them (see the README's own multi-connection note).
|
|
144
|
+
// --background takes the other route: it installs/reinstalls the login service instead.
|
|
145
|
+
const shouldRunAfter = f['no-run'] !== 'true';
|
|
146
|
+
if (background && !shouldRunAfter) {
|
|
147
|
+
console.error("--background can't be used together with --no-run.");
|
|
148
|
+
process.exitCode = 1;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!url || !token) {
|
|
152
|
+
console.error('Usage: truing connect --url <your Truing link> --token <publicId.secret> --connection <connectionId> ' +
|
|
153
|
+
'[--path <folder — defaults to the folder you\'re in right now>] [--remote <name>] [--branch <name>] [--backend api-key|cli|codex] [--api-key <your Anthropic key>] [--codex-model <model>] [--background] [--no-run]\n' +
|
|
154
|
+
' or: truing connect --url <your Truing link> --token <publicId.secret> [--path <folder>] [--remote <name>] [--branch <name>] [--no-repo] [--backend api-key|cli|codex] [--api-key <your Anthropic key>] [--codex-model <model>] [--background] [--no-run]\n' +
|
|
155
|
+
' or: truing connect --url <your Truing link> --token <publicId.secret> --repo <owner/name> [--branch <name>] [--backend api-key|cli|codex] [--api-key <your Anthropic key>] [--codex-model <model>] [--background] [--no-run]');
|
|
156
|
+
process.exitCode = 1;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
let managedRepo = null;
|
|
160
|
+
if (f.repo !== undefined) {
|
|
161
|
+
const conflicting = ['path', 'connection', 'no-repo', 'remote'].filter((name) => f[name] !== undefined);
|
|
162
|
+
if (conflicting.length > 0) {
|
|
163
|
+
console.error(`--repo can't be used together with ${conflicting.map((name) => `--${name}`).join(', ')}. `
|
|
164
|
+
+ 'Use --repo without them and the companion keeps its own copy of the code.');
|
|
165
|
+
process.exitCode = 1;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
managedRepo = parseGitHubRepo(f.repo);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
console.error(error instanceof ManagedRepoError ? error.message : String(error));
|
|
173
|
+
process.exitCode = 1;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (backend !== 'api-key' && backend !== 'cli' && backend !== 'codex') {
|
|
178
|
+
console.error(`--backend must be "api-key", "cli", or "codex", got "${backend}"`);
|
|
179
|
+
process.exitCode = 1;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (f['codex-model'] && !validateCodexModel(codexModel))
|
|
183
|
+
return;
|
|
184
|
+
if (managedRepo && !await gitAvailable()) {
|
|
185
|
+
console.error(GIT_MISSING_MESSAGE);
|
|
186
|
+
process.exitCode = 1;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (backend === 'codex') {
|
|
190
|
+
const status = await checkCodexLogin(defaultCodexHome());
|
|
191
|
+
if (status !== 'logged-in') {
|
|
192
|
+
logCodexReadiness(status);
|
|
193
|
+
process.exitCode = 1;
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (!connectionId) {
|
|
198
|
+
if (backend === 'cli') {
|
|
199
|
+
const version = await checkClaudeCliAvailable();
|
|
200
|
+
if (!version) {
|
|
201
|
+
console.error('This needs Claude Code installed and you logged into it first: npm install -g @anthropic-ai/claude-code, then run `claude` and follow the login prompt.');
|
|
202
|
+
process.exitCode = 1;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
console.log(`Found Claude Code: ${version}`);
|
|
206
|
+
}
|
|
207
|
+
const client = new CompanionClient(url, token);
|
|
208
|
+
let workspaceId;
|
|
209
|
+
try {
|
|
210
|
+
({ workspaceId } = await client.whoami());
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
console.error('Could not verify the companion token. Double check that you copied the whole token, including the part after the dot.');
|
|
214
|
+
process.exitCode = 1;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
let managedPath = null;
|
|
218
|
+
if (managedRepo) {
|
|
219
|
+
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`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
console.error(error instanceof ManagedRepoError ? error.message : `Couldn't copy ${githubLabel(managedRepo)} from GitHub.`);
|
|
245
|
+
process.exitCode = 1;
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
await resolveLegacyPairings(url);
|
|
251
|
+
enableWorkspacePolling({
|
|
252
|
+
url,
|
|
253
|
+
token,
|
|
254
|
+
workspaceId,
|
|
255
|
+
apiKey: backend === 'codex' ? undefined : apiKey,
|
|
256
|
+
backend,
|
|
257
|
+
codexModel: backend === 'codex' && f['codex-model'] ? codexModel : undefined,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
console.error('Could not save the companion configuration. Check that your user account can write its configuration folder.');
|
|
262
|
+
process.exitCode = 1;
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const requestedFolder = managedPath ?? resolve(f.path || '.');
|
|
266
|
+
const topLevel = f['no-repo'] === 'true' ? null : await repositoryTopLevel(requestedFolder);
|
|
267
|
+
let registered = false;
|
|
268
|
+
if (topLevel) {
|
|
269
|
+
try {
|
|
270
|
+
const { remote, ...identity } = await repositoryIdentity(topLevel, {
|
|
271
|
+
remote: managedRepo ? 'origin' : f.remote,
|
|
272
|
+
branch: f.branch,
|
|
273
|
+
});
|
|
274
|
+
const { connection } = await client.registerConnection(identity);
|
|
275
|
+
addConnection({
|
|
276
|
+
url,
|
|
277
|
+
token,
|
|
278
|
+
workspaceId,
|
|
279
|
+
connection: {
|
|
280
|
+
connectionId: connection.id,
|
|
281
|
+
repoPath: topLevel,
|
|
282
|
+
backend,
|
|
283
|
+
codexModel: backend === 'codex' && f['codex-model'] ? codexModel : undefined,
|
|
284
|
+
remote,
|
|
285
|
+
...(managedRepo ? { label: githubLabel(managedRepo), managed: true } : {}),
|
|
286
|
+
},
|
|
287
|
+
apiKey: backend === 'codex' ? undefined : apiKey,
|
|
288
|
+
});
|
|
289
|
+
console.log(managedRepo
|
|
290
|
+
? `Connected ${githubLabel(managedRepo)}. The companion keeps its own copy of the code up to date. You never need to open it or pull it.`
|
|
291
|
+
: `Connected ${connection.repo} (this folder).`);
|
|
292
|
+
registered = true;
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
if (error instanceof RepositoryIdentityError) {
|
|
296
|
+
console.error(`${error.code}: ${error.message}`);
|
|
297
|
+
console.error(REMOTE_TROUBLESHOOTING_HINT);
|
|
298
|
+
}
|
|
299
|
+
else if (error instanceof PairingHttpError) {
|
|
300
|
+
console.error(`${error.code}: ${error.message}`);
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
console.error('registration_failed: Could not register this repository with the console.');
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
else if (f['no-repo'] !== 'true') {
|
|
308
|
+
console.log("No project folder here — to connect your code, run this same command inside your project's folder.");
|
|
309
|
+
}
|
|
310
|
+
if (!registered) {
|
|
311
|
+
try {
|
|
312
|
+
await client.recordPairingOutcome('workspace_only');
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
console.error(error instanceof PairingHttpError
|
|
316
|
+
? `${error.code}: ${error.message}`
|
|
317
|
+
: 'pairing_outcome_failed: Setup completion could not be confirmed. Retry this command or return to the console.');
|
|
318
|
+
}
|
|
319
|
+
console.log('Connected for workspace tasks only.');
|
|
320
|
+
}
|
|
321
|
+
logConnectedConsoles();
|
|
322
|
+
if (!await finishConnect({ background, shouldRunAfter, safeStartupDiagnostics: true }))
|
|
323
|
+
process.exitCode = 1;
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// --path is optional — defaults to wherever this command is actually run from, so "open a
|
|
327
|
+
// terminal in your project's folder, paste this" just works without typing a path at all.
|
|
328
|
+
const repoPath = resolve(f.path || '.');
|
|
329
|
+
try {
|
|
330
|
+
await assertIsRepo(repoPath);
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
console.error((err instanceof Error ? err.message : String(err)) + ' Run this from inside your project\'s folder, or pass --path <folder>.');
|
|
334
|
+
process.exitCode = 1;
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (backend === 'cli') {
|
|
338
|
+
const version = await checkClaudeCliAvailable();
|
|
339
|
+
if (!version) {
|
|
340
|
+
console.error('This needs Claude Code installed and you logged into it first: npm install -g @anthropic-ai/claude-code, then run `claude` and follow the login prompt.');
|
|
341
|
+
process.exitCode = 1;
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
console.log(`Found Claude Code: ${version}`);
|
|
345
|
+
}
|
|
346
|
+
const client = new CompanionClient(url, token);
|
|
347
|
+
let workspaceId;
|
|
348
|
+
try {
|
|
349
|
+
({ workspaceId } = await client.whoami());
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
console.error(`Hmm, that didn't work — could not verify your token against ${url}. Double check you copied the ` +
|
|
353
|
+
`WHOLE thing, including the part after the dot.`, err instanceof Error ? err.message : err);
|
|
354
|
+
process.exitCode = 1;
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
await resolveLegacyPairings(url);
|
|
359
|
+
addConnection({
|
|
360
|
+
url,
|
|
361
|
+
token,
|
|
362
|
+
workspaceId,
|
|
363
|
+
connection: {
|
|
364
|
+
connectionId,
|
|
365
|
+
repoPath,
|
|
366
|
+
backend,
|
|
367
|
+
codexModel: backend === 'codex' && f['codex-model'] ? codexModel : undefined,
|
|
368
|
+
remote: f.remote,
|
|
369
|
+
},
|
|
370
|
+
apiKey: backend === 'codex' ? undefined : apiKey,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
console.error('Could not save the companion configuration. Check that your user account can write its configuration folder.');
|
|
375
|
+
process.exitCode = 1;
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
console.log(`Connected! This computer will now read this project's code and run AI calls for it. (Saved to ${configPath}.)`);
|
|
379
|
+
logConnectedConsoles();
|
|
380
|
+
if (!await finishConnect({ background, shouldRunAfter }))
|
|
381
|
+
process.exitCode = 1;
|
|
382
|
+
}
|
|
383
|
+
/** How often an idle connection is re-polled when nothing was claimed — a short client-side
|
|
384
|
+
* poll, not a server-held long-poll (see the plan's own reasoning: this app has already been
|
|
385
|
+
* burned once by a proxy timing out a held-open request). */
|
|
386
|
+
const TEST_POLL_MS = process.env.NODE_ENV === 'test'
|
|
387
|
+
? Number(process.env.PC_TEST_COMPANION_POLL_MS)
|
|
388
|
+
: Number.NaN;
|
|
389
|
+
const IDLE_POLL_MS = Number.isFinite(TEST_POLL_MS) && TEST_POLL_MS > 0 ? TEST_POLL_MS : 5000;
|
|
390
|
+
const REPOSITORY_RECHECK_MS = process.env.NODE_ENV === 'test' && Number.isFinite(TEST_POLL_MS) && TEST_POLL_MS > 0
|
|
391
|
+
? TEST_POLL_MS
|
|
392
|
+
: 60_000;
|
|
393
|
+
export const INVESTIGATION_POLL_BACKOFF_CAP_MS = 5 * 60_000;
|
|
394
|
+
/** How often to extend a claimed task's lease while it's in flight — comfortably inside the
|
|
395
|
+
* server's ~2-minute lease window (server/store.ts's LOCAL_TASK_LEASE_MS). */
|
|
396
|
+
const HEARTBEAT_MS = 30_000;
|
|
397
|
+
/** How long to wait before retrying after something escapes the per-operation error handling. */
|
|
398
|
+
const LOOP_RECOVERY_DELAY_MS = 1000;
|
|
399
|
+
/** A quiet proof-of-life while no task is claimed. */
|
|
400
|
+
const IDLE_STATUS_MS = 5 * 60_000;
|
|
401
|
+
export function backendRequirements(backends) {
|
|
402
|
+
return {
|
|
403
|
+
needsApiKey: backends.some((backend) => backend === 'api-key'),
|
|
404
|
+
needsCli: backends.some((backend) => backend === 'cli'),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
export function createBackendRuntime(config, anthropic, createCodexTextModel = codexTextModel) {
|
|
408
|
+
let usage = null;
|
|
409
|
+
const codexOptions = config.backend === 'codex'
|
|
410
|
+
? { model: config.codexModel ?? DEFAULT_CODEX_MODEL, codexHome: defaultCodexHome() }
|
|
411
|
+
: null;
|
|
412
|
+
// Server task tiers are Claude model names, and there is no evidence they map to ChatGPT-plan
|
|
413
|
+
// model tiers. Keep one owner-selected Codex model for every task; request effort still passes
|
|
414
|
+
// through unchanged inside codexTextModel.
|
|
415
|
+
const textModel = config.backend === 'codex'
|
|
416
|
+
? createCodexTextModel({
|
|
417
|
+
...codexOptions,
|
|
418
|
+
onUsage: (callUsage) => {
|
|
419
|
+
if (!callUsage)
|
|
420
|
+
return;
|
|
421
|
+
usage ??= {};
|
|
422
|
+
for (const [name, value] of Object.entries(callUsage)) {
|
|
423
|
+
usage[name] = (usage[name] ?? 0) + value;
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
})
|
|
427
|
+
: config.backend === 'api-key'
|
|
428
|
+
? anthropicTextModel(anthropic)
|
|
429
|
+
: null;
|
|
430
|
+
return {
|
|
431
|
+
backend: config.backend,
|
|
432
|
+
textModel,
|
|
433
|
+
codexOptions,
|
|
434
|
+
takeCodexUsage() {
|
|
435
|
+
const current = usage;
|
|
436
|
+
usage = null;
|
|
437
|
+
return current;
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
export function codexReadinessLines(status) {
|
|
442
|
+
if (status === 'not-installed') {
|
|
443
|
+
return ['This needs the Codex CLI installed first: npm install -g @openai/codex'];
|
|
444
|
+
}
|
|
445
|
+
return [
|
|
446
|
+
'Codex needs a one-time login for the companion\'s own home:',
|
|
447
|
+
' 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',
|
|
448
|
+
' macOS/Linux: mkdir -p "$HOME/.product-console-companion/codex-home" && CODEX_HOME="$HOME/.product-console-companion/codex-home" codex login',
|
|
449
|
+
'This separate login keeps your own ~/.codex instructions out of the companion\'s calls.',
|
|
450
|
+
];
|
|
451
|
+
}
|
|
452
|
+
function logCodexReadiness(status) {
|
|
453
|
+
for (const line of codexReadinessLines(status))
|
|
454
|
+
console.error(line);
|
|
455
|
+
}
|
|
456
|
+
function validateCodexModel(model) {
|
|
457
|
+
if (CODEX_CODE_MODE_MODELS.includes(model))
|
|
458
|
+
return true;
|
|
459
|
+
console.error(`--codex-model must be one of: ${CODEX_CODE_MODE_MODELS.join(', ')}; got "${model}". ` +
|
|
460
|
+
'Only these models were verified to refuse tool use when their tools are switched off.');
|
|
461
|
+
process.exitCode = 1;
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
export function nextInvestigationPollBackoff(consecutiveFailures, succeeded) {
|
|
465
|
+
const nextFailures = succeeded ? 0 : consecutiveFailures + 1;
|
|
466
|
+
return {
|
|
467
|
+
consecutiveFailures: nextFailures,
|
|
468
|
+
delayMs: nextFailures === 0
|
|
469
|
+
? 0
|
|
470
|
+
: Math.min(IDLE_POLL_MS * (2 ** (nextFailures - 1)), INVESTIGATION_POLL_BACKOFF_CAP_MS),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
export function buildReadinessReport(connections, backendUnavailable, repositoryReadiness) {
|
|
474
|
+
return Object.fromEntries(connections.map((connection) => {
|
|
475
|
+
if (backendUnavailable.has(connection.connectionId)) {
|
|
476
|
+
return [connection.connectionId, 'backend_unavailable'];
|
|
477
|
+
}
|
|
478
|
+
return [
|
|
479
|
+
connection.connectionId,
|
|
480
|
+
repositoryReadiness.get(connection.connectionId)?.readiness.state === 'ready'
|
|
481
|
+
? 'ready'
|
|
482
|
+
: 'not_ready',
|
|
483
|
+
];
|
|
484
|
+
}));
|
|
485
|
+
}
|
|
486
|
+
function isUnknownConnectionError(error) {
|
|
487
|
+
return error instanceof CompanionHttpError
|
|
488
|
+
&& (error.status === 400 || error.status === 404)
|
|
489
|
+
&& /foreign_connection_id|unknown connectionid|connection.*unknown/i.test(error.message);
|
|
490
|
+
}
|
|
491
|
+
export async function reportLoopReadiness(client, report, state, logger = console) {
|
|
492
|
+
if (!state.enabled || Object.keys(report).length === 0)
|
|
493
|
+
return 'continue';
|
|
494
|
+
try {
|
|
495
|
+
await client.reportReadiness(report);
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
if (error instanceof CompanionHttpError && error.status === 401)
|
|
499
|
+
return 'token_rejected';
|
|
500
|
+
if (isUnknownConnectionError(error)) {
|
|
501
|
+
const entries = Object.entries(report);
|
|
502
|
+
if (entries.length === 1) {
|
|
503
|
+
state.foreignConnectionIds?.add(entries[0][0]);
|
|
504
|
+
return 'continue';
|
|
505
|
+
}
|
|
506
|
+
for (const [connectionId, readiness] of entries) {
|
|
507
|
+
try {
|
|
508
|
+
await client.reportReadiness({ [connectionId]: readiness });
|
|
509
|
+
}
|
|
510
|
+
catch (singleError) {
|
|
511
|
+
if (singleError instanceof CompanionHttpError && singleError.status === 401)
|
|
512
|
+
return 'token_rejected';
|
|
513
|
+
if (isUnknownConnectionError(singleError))
|
|
514
|
+
state.foreignConnectionIds?.add(connectionId);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return 'continue';
|
|
518
|
+
}
|
|
519
|
+
if (error instanceof CompanionHttpError && (error.status === 404 || error.status === 405)) {
|
|
520
|
+
const key = `unsupported:${error.status}`;
|
|
521
|
+
if (!state.loggedErrors.has(key)) {
|
|
522
|
+
logger.error(`Readiness reporting is not available on this server (HTTP ${error.status}); continuing without it.`);
|
|
523
|
+
state.loggedErrors.add(key);
|
|
524
|
+
}
|
|
525
|
+
state.enabled = false;
|
|
526
|
+
return 'continue';
|
|
527
|
+
}
|
|
528
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
529
|
+
const key = error instanceof CompanionHttpError ? `${error.status}:${message}` : message;
|
|
530
|
+
if (!state.loggedErrors.has(key)) {
|
|
531
|
+
logger.error(`Readiness report failed: ${message}`);
|
|
532
|
+
state.loggedErrors.add(key);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return 'continue';
|
|
536
|
+
}
|
|
537
|
+
const EXECUTION_FAILURE_REASONS = new Set([
|
|
538
|
+
'reader_crashed',
|
|
539
|
+
'budget_exhausted',
|
|
540
|
+
'backend_incompatible',
|
|
541
|
+
'contention_timeout',
|
|
542
|
+
'fetch_failed',
|
|
543
|
+
'source_identity_unavailable',
|
|
544
|
+
]);
|
|
545
|
+
function investigationResultLog(result, validationRejectedObligationIds) {
|
|
546
|
+
const outcomeCounts = new Map();
|
|
547
|
+
const reasonCounts = new Map();
|
|
548
|
+
for (const assessment of result.assessments) {
|
|
549
|
+
outcomeCounts.set(assessment.outcome, (outcomeCounts.get(assessment.outcome) ?? 0) + 1);
|
|
550
|
+
if (assessment.outcome === 'not_established' && assessment.outcomeReason) {
|
|
551
|
+
reasonCounts.set(assessment.outcomeReason, (reasonCounts.get(assessment.outcomeReason) ?? 0) + 1);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const outcomes = [...outcomeCounts].map(([outcome, count]) => `${outcome}=${count}`).join(', ');
|
|
555
|
+
const reasons = [...reasonCounts].map(([reason, count]) => `${reason}=${count}`).join(', ');
|
|
556
|
+
const assessmentCount = `${result.assessments.length} assessment${result.assessments.length === 1 ? '' : 's'}`;
|
|
557
|
+
const evidenceCount = `${result.evidence.length} evidence row${result.evidence.length === 1 ? '' : 's'}`;
|
|
558
|
+
const breakdown = `${outcomes}${reasons ? `; reasons: ${reasons}` : ''}`;
|
|
559
|
+
const allExecutionFailures = result.assessments.length > 0 && result.assessments.every((assessment) => assessment.outcome === 'not_established'
|
|
560
|
+
&& !!assessment.outcomeReason
|
|
561
|
+
&& EXECUTION_FAILURE_REASONS.has(assessment.outcomeReason));
|
|
562
|
+
const allValidationRejections = result.assessments.length > 0 && result.assessments.every((assessment) => validationRejectedObligationIds.has(assessment.obligationId));
|
|
563
|
+
return allValidationRejections
|
|
564
|
+
? {
|
|
565
|
+
level: 'error',
|
|
566
|
+
message: ` VALIDATION DISAGREEMENT — rejected model answers for all ${assessmentCount}; ${evidenceCount}; outcomes: ${breakdown}`,
|
|
567
|
+
}
|
|
568
|
+
: allExecutionFailures
|
|
569
|
+
? {
|
|
570
|
+
level: 'error',
|
|
571
|
+
message: ` OPERATIONAL FAILURE — reported ${assessmentCount} with ${evidenceCount}; outcomes: ${breakdown}`,
|
|
572
|
+
}
|
|
573
|
+
: {
|
|
574
|
+
level: 'log',
|
|
575
|
+
message: ` done — reported ${assessmentCount} with ${evidenceCount}; outcomes: ${breakdown}`,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
export function logInvestigationResult(result, output = console, validationRejectedObligationIds = new Set()) {
|
|
579
|
+
const outcomeLog = investigationResultLog(result, validationRejectedObligationIds);
|
|
580
|
+
output[outcomeLog.level](outcomeLog.message);
|
|
581
|
+
}
|
|
582
|
+
export function logInvestigationDiagnostic(diagnostic, output = console) {
|
|
583
|
+
if (diagnostic.kind === 'model_call_failed' && diagnostic.message.includes('CodexToolUseAttempted')) {
|
|
584
|
+
output.error(` TOOL USE BLOCKED — ${diagnostic.message}`);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
output.error(formatInvestigationReaderDiagnostic(diagnostic));
|
|
588
|
+
}
|
|
589
|
+
export function taskRunnerFor(backend, kind) {
|
|
590
|
+
if (backend === 'cli') {
|
|
591
|
+
if (kind === 'verify_module')
|
|
592
|
+
return runVerifyModuleTaskViaCli;
|
|
593
|
+
if (kind === 'generate_spec_batch')
|
|
594
|
+
return runGenerateSpecBatchTaskViaCli;
|
|
595
|
+
if (kind === 'propose_skeleton')
|
|
596
|
+
return runProposeSkeletonTaskViaCli;
|
|
597
|
+
if (kind === 'propose_new_capabilities')
|
|
598
|
+
return runProposeNewCapabilitiesTaskViaCli;
|
|
599
|
+
if (kind === 'design_prompt')
|
|
600
|
+
return runDesignPromptTaskViaCli;
|
|
601
|
+
if (kind === 'analyse_shipment')
|
|
602
|
+
return runAnalyseShipmentTaskViaCli;
|
|
603
|
+
return runGenericTaskViaCli;
|
|
604
|
+
}
|
|
605
|
+
if (kind === 'verify_module')
|
|
606
|
+
return runVerifyModuleTask;
|
|
607
|
+
if (kind === 'generate_spec_batch')
|
|
608
|
+
return runGenerateSpecBatchTask;
|
|
609
|
+
if (kind === 'propose_skeleton')
|
|
610
|
+
return runProposeSkeletonTask;
|
|
611
|
+
if (kind === 'propose_new_capabilities')
|
|
612
|
+
return runProposeNewCapabilitiesTask;
|
|
613
|
+
if (kind === 'design_prompt')
|
|
614
|
+
return runDesignPromptTask;
|
|
615
|
+
if (kind === 'analyse_shipment')
|
|
616
|
+
return runAnalyseShipmentTask;
|
|
617
|
+
return runGenericTask;
|
|
618
|
+
}
|
|
619
|
+
export async function runTask(task, runtime, repoPath, connection) {
|
|
620
|
+
if (task.kind === 'git_range') {
|
|
621
|
+
if (!connection)
|
|
622
|
+
return { result: { ok: false, code: 'history_failed' }, filesRead: [] };
|
|
623
|
+
return runGitRangeTask(task, repoPath, connection);
|
|
624
|
+
}
|
|
625
|
+
const runner = taskRunnerFor(runtime.backend, task.kind);
|
|
626
|
+
const payload = task.payload;
|
|
627
|
+
if (payload.connectionless === true) {
|
|
628
|
+
return runtime.backend === 'cli'
|
|
629
|
+
? runner(task, repoPath)
|
|
630
|
+
: runner(runtime.textModel, task, repoPath);
|
|
631
|
+
}
|
|
632
|
+
if (typeof payload.snapshotSha !== 'string')
|
|
633
|
+
throw new Error('snapshot_unavailable');
|
|
634
|
+
let sandbox;
|
|
635
|
+
try {
|
|
636
|
+
sandbox = await createSandbox(repoPath, payload.snapshotSha);
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
throw new Error('snapshot_unavailable');
|
|
640
|
+
}
|
|
641
|
+
if (sandbox.obtainedBy === 'unavailable')
|
|
642
|
+
throw new Error('snapshot_unavailable');
|
|
643
|
+
try {
|
|
644
|
+
await assertSnapshotReadBoundary(sandbox.path);
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
await sandbox.cleanup();
|
|
648
|
+
throw new Error('snapshot_unavailable');
|
|
649
|
+
}
|
|
650
|
+
try {
|
|
651
|
+
const outcome = runtime.backend === 'cli'
|
|
652
|
+
? await runner(task, sandbox.path)
|
|
653
|
+
: await runner(runtime.textModel, task, sandbox.path);
|
|
654
|
+
return { ...outcome, snapshot: { sha: sandbox.resolvedCommit } };
|
|
655
|
+
}
|
|
656
|
+
finally {
|
|
657
|
+
await sandbox.cleanup();
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
function formatCodexUsage(usage) {
|
|
661
|
+
if (!usage)
|
|
662
|
+
return 'unavailable';
|
|
663
|
+
return Object.entries(usage).map(([name, value]) => `${name}=${value}`).join(', ');
|
|
664
|
+
}
|
|
665
|
+
export async function runOneTask(runtime, client, repoPath, task, connection, output = console) {
|
|
666
|
+
runtime.takeCodexUsage();
|
|
667
|
+
if (runtime.backend === 'codex') {
|
|
668
|
+
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)`);
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
output.log(`[${new Date().toISOString()}] claimed ${task.kind} task ${task.id} via ${runtime.backend} (budget $${task.maxBudgetUsd.toFixed(2)})`);
|
|
672
|
+
}
|
|
673
|
+
const heartbeat = setInterval(() => {
|
|
674
|
+
void client.heartbeat(task.id).catch((err) => output.error(' heartbeat warning:', err instanceof Error ? err.message : err));
|
|
675
|
+
}, HEARTBEAT_MS);
|
|
676
|
+
try {
|
|
677
|
+
const { result, filesRead, snapshot } = await runTask(task, runtime, repoPath, connection);
|
|
678
|
+
clearInterval(heartbeat);
|
|
679
|
+
if (task.kind === 'git_range')
|
|
680
|
+
await client.reportGitRangeResult(task.id, result);
|
|
681
|
+
else
|
|
682
|
+
await client.reportResult(task.id, result, filesRead, snapshot);
|
|
683
|
+
const done = ` done — reported result (${filesRead.length} file${filesRead.length === 1 ? '' : 's'} read)`;
|
|
684
|
+
output.log(runtime.backend === 'codex'
|
|
685
|
+
? `${done}; token usage: ${formatCodexUsage(runtime.takeCodexUsage())}; dollar budget is not enforced (Codex reports tokens and has no cost cap)`
|
|
686
|
+
: done);
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
catch (err) {
|
|
690
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
691
|
+
if (runtime.backend === 'codex' && message.includes('CodexToolUseAttempted')) {
|
|
692
|
+
output.error(` TOOL USE BLOCKED — ${message}`);
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
output.error(' failed:', message);
|
|
696
|
+
}
|
|
697
|
+
try {
|
|
698
|
+
await client.reportError(task.id, message);
|
|
699
|
+
}
|
|
700
|
+
catch (reportErr) {
|
|
701
|
+
output.error(' could not even report the error back:', reportErr instanceof Error ? reportErr.message : reportErr);
|
|
702
|
+
}
|
|
703
|
+
return runtime.backend === 'cli' && isUsageLimitError(err) ? message : null;
|
|
704
|
+
}
|
|
705
|
+
finally {
|
|
706
|
+
clearInterval(heartbeat);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
export function backendForInvestigation(request, connections) {
|
|
710
|
+
for (const source of request.requestedSources) {
|
|
711
|
+
const configured = connections.find((connection) => connection.connectionId === source.connectionId);
|
|
712
|
+
if (configured)
|
|
713
|
+
return configured.backend;
|
|
714
|
+
}
|
|
715
|
+
// No configured source means pass 0 will abstain before a model call, so this fallback is inert.
|
|
716
|
+
return 'api-key';
|
|
717
|
+
}
|
|
718
|
+
export function structuredModelCallerForRuntime(runtime, anthropic, callViaCodex = runStructuredModelCallViaCodex) {
|
|
719
|
+
if (runtime.backend === 'cli')
|
|
720
|
+
return runStructuredModelCallViaCli;
|
|
721
|
+
if (runtime.backend === 'codex') {
|
|
722
|
+
return (request) => callViaCodex(request, runtime.codexOptions);
|
|
723
|
+
}
|
|
724
|
+
return (request) => runStructuredModelCall(anthropic, request);
|
|
725
|
+
}
|
|
726
|
+
async function runOneInvestigation(anthropic, client, connections, runtimes, request, output = console) {
|
|
727
|
+
const backend = backendForInvestigation(request, connections);
|
|
728
|
+
output.log(`[${new Date().toISOString()}] claimed investigation ${request.investigationId} attempt ${request.attempt} via ${backend} (${request.obligations.length} obligation${request.obligations.length === 1 ? '' : 's'})`);
|
|
729
|
+
if (Date.now() >= Date.parse(request.claimExpiresAt)) {
|
|
730
|
+
output.error(` CLAIM LEASE WARNING: ${request.investigationId} was already expired at ${request.claimExpiresAt}; the result may be rejected.`);
|
|
731
|
+
}
|
|
732
|
+
const runtime = request.requestedSources
|
|
733
|
+
.map((source) => runtimes.get(source.connectionId))
|
|
734
|
+
.find((candidate) => !!candidate);
|
|
735
|
+
const baseCallModel = runtime
|
|
736
|
+
? structuredModelCallerForRuntime(runtime, anthropic)
|
|
737
|
+
: (modelRequest) => runStructuredModelCall(anthropic, modelRequest);
|
|
738
|
+
let usageLimitMessage = null;
|
|
739
|
+
const callModel = backend === 'cli'
|
|
740
|
+
? async (modelRequest) => {
|
|
741
|
+
if (usageLimitMessage)
|
|
742
|
+
throw new Error(usageLimitMessage);
|
|
743
|
+
try {
|
|
744
|
+
return await baseCallModel(modelRequest);
|
|
745
|
+
}
|
|
746
|
+
catch (error) {
|
|
747
|
+
if (isUsageLimitError(error)) {
|
|
748
|
+
usageLimitMessage = error instanceof Error ? error.message : String(error);
|
|
749
|
+
}
|
|
750
|
+
throw error;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
: baseCallModel;
|
|
754
|
+
const validationRejectedObligationIds = new Set();
|
|
755
|
+
try {
|
|
756
|
+
const result = await runAndReportInvestigation(request, connections, callModel, async (envelope) => {
|
|
757
|
+
if (Date.now() >= Date.parse(request.claimExpiresAt)) {
|
|
758
|
+
output.error(` CLAIM LEASE WARNING: reading ${request.investigationId} outlived its ${request.claimExpiresAt} lease; attempting the honest result without hiding the expiry.`);
|
|
759
|
+
}
|
|
760
|
+
const acknowledgment = await client.reportInvestigationResult(request.investigationId, envelope);
|
|
761
|
+
if (acknowledgment.status === 'rejected') {
|
|
762
|
+
throw new Error(acknowledgment.error || acknowledgment.note || 'server rejected the reader result');
|
|
763
|
+
}
|
|
764
|
+
}, (diagnostic) => {
|
|
765
|
+
if (diagnostic.kind === 'validation_rejection') {
|
|
766
|
+
for (const obligationId of diagnostic.obligationIds) {
|
|
767
|
+
validationRejectedObligationIds.add(obligationId);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
logInvestigationDiagnostic(diagnostic, output);
|
|
771
|
+
}, {
|
|
772
|
+
backend,
|
|
773
|
+
model: runtime?.backend === 'codex'
|
|
774
|
+
? runtime.codexOptions?.model
|
|
775
|
+
: INVESTIGATION_MODEL,
|
|
776
|
+
});
|
|
777
|
+
logInvestigationResult(result, output, validationRejectedObligationIds);
|
|
778
|
+
}
|
|
779
|
+
catch (error) {
|
|
780
|
+
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);
|
|
781
|
+
}
|
|
782
|
+
return usageLimitMessage;
|
|
783
|
+
}
|
|
784
|
+
async function runOneSurfaceMapping(anthropic, client, connection, runtime, job, output = console) {
|
|
785
|
+
output.log(`[${new Date().toISOString()}] claimed surface mapping ${job.jobId} attempt ${job.attempt} via ${runtime.backend}`);
|
|
786
|
+
try {
|
|
787
|
+
const manifest = await runSurfaceMapping({
|
|
788
|
+
claim: job,
|
|
789
|
+
repoPath: connection.repoPath,
|
|
790
|
+
callModel: structuredModelCallerForRuntime(runtime, anthropic),
|
|
791
|
+
execution: {
|
|
792
|
+
backend: runtime.backend,
|
|
793
|
+
model: runtime.backend === 'codex' ? runtime.codexOptions?.model ?? DEFAULT_CODEX_MODEL : SURFACE_SEED_MODEL,
|
|
794
|
+
},
|
|
795
|
+
});
|
|
796
|
+
let acknowledgment;
|
|
797
|
+
try {
|
|
798
|
+
acknowledgment = await client.reportSurfaceMappingResult(job, manifest);
|
|
799
|
+
}
|
|
800
|
+
catch (error) {
|
|
801
|
+
const status = surfaceMappingUnavailableStatus(error);
|
|
802
|
+
if (status !== null) {
|
|
803
|
+
output.error(` automatic mapping result could not be reported (HTTP ${status}); abandoning the job so its lease can expire.`);
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
throw error;
|
|
807
|
+
}
|
|
808
|
+
output.log(` automatic map ${acknowledgment.mapId} delivered (${manifest.expansion.files.length} files, expansion ${manifest.expansion.status})`);
|
|
809
|
+
return null;
|
|
810
|
+
}
|
|
811
|
+
catch (error) {
|
|
812
|
+
const usageLimitMessage = runtime.backend === 'cli' && isUsageLimitError(error)
|
|
813
|
+
? (error instanceof Error ? error.message : String(error))
|
|
814
|
+
: null;
|
|
815
|
+
const report = error instanceof MappingFailure
|
|
816
|
+
? { kind: error.kind, code: error.code, detail: error.message }
|
|
817
|
+
: {
|
|
818
|
+
kind: 'transient',
|
|
819
|
+
code: 'mapper_crashed',
|
|
820
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
821
|
+
};
|
|
822
|
+
output.error(` surface mapping ${job.jobId} failed (${report.code}): ${report.detail}`);
|
|
823
|
+
try {
|
|
824
|
+
await client.reportSurfaceMappingError(job, report);
|
|
825
|
+
}
|
|
826
|
+
catch (reportError) {
|
|
827
|
+
const status = surfaceMappingUnavailableStatus(reportError);
|
|
828
|
+
if (status !== null) {
|
|
829
|
+
output.error(` automatic mapping error could not be reported (HTTP ${status}); abandoning the job so its lease can expire.`);
|
|
830
|
+
return usageLimitMessage;
|
|
831
|
+
}
|
|
832
|
+
output.error(' could not report the mapping error:', reportError instanceof Error ? reportError.message : reportError);
|
|
833
|
+
}
|
|
834
|
+
return usageLimitMessage;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
function pairingPrefix(pairing) {
|
|
838
|
+
try {
|
|
839
|
+
return `[${new URL(pairing.url).host}]`;
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
return `[${pairing.url}]`;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
async function runPairing(pairing, options) {
|
|
846
|
+
const prefix = options.multiple ? `${pairingPrefix(pairing)} ` : '';
|
|
847
|
+
const log = (...values) => console.log(prefix + String(values[0] ?? ''), ...values.slice(1));
|
|
848
|
+
const error = (...values) => console.error(prefix + String(values[0] ?? ''), ...values.slice(1));
|
|
849
|
+
const warn = (...values) => console.warn(prefix + String(values[0] ?? ''), ...values.slice(1));
|
|
850
|
+
const client = new CompanionClient(pairing.url, pairing.token);
|
|
851
|
+
const tokenRejected = () => {
|
|
852
|
+
error(`The Console at ${pairing.url} no longer accepts this computer's token — stopped working for it. Pair again from that Console to reconnect.`);
|
|
853
|
+
return 'token_rejected';
|
|
854
|
+
};
|
|
855
|
+
if (options.multiple) {
|
|
856
|
+
let failures = 0;
|
|
857
|
+
while (!options.shouldStop()) {
|
|
858
|
+
try {
|
|
859
|
+
await client.whoami();
|
|
860
|
+
break;
|
|
861
|
+
}
|
|
862
|
+
catch (reason) {
|
|
863
|
+
if (reason instanceof CompanionHttpError && reason.status === 401)
|
|
864
|
+
return tokenRejected();
|
|
865
|
+
const backoff = nextInvestigationPollBackoff(failures, false);
|
|
866
|
+
failures = backoff.consecutiveFailures;
|
|
867
|
+
error(`Could not reach the Console at ${pairing.url} — retrying in ${backoff.delayMs / 1000}s.`);
|
|
868
|
+
await new Promise((done) => setTimeout(done, backoff.delayMs));
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
if (options.shouldStop())
|
|
872
|
+
return 'stopped';
|
|
873
|
+
}
|
|
874
|
+
else {
|
|
875
|
+
await client.whoami();
|
|
876
|
+
}
|
|
877
|
+
const workspaceBackend = pairing.workspaceBackend ?? 'api-key';
|
|
878
|
+
const activeConnections = pairing.connections.filter((connection) => !options.unavailableConnections.has(connection.connectionId));
|
|
879
|
+
for (const connection of activeConnections) {
|
|
880
|
+
if (connection.managed) {
|
|
881
|
+
const label = connection.label ?? labelFromCopyPath(connection.repoPath);
|
|
882
|
+
watchManagedCopy(connection.repoPath, label, (message) => error(`[${new Date().toISOString()}] ${message}`), () => client.githubToken(label));
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
const runtimes = new Map(activeConnections.map((connection) => [
|
|
886
|
+
connection.connectionId,
|
|
887
|
+
createBackendRuntime(connection, options.anthropic),
|
|
888
|
+
]));
|
|
889
|
+
const workspaceRuntime = pairing.pollWorkspaceTasks && (workspaceBackend !== 'codex' || options.codexAvailable)
|
|
890
|
+
? createBackendRuntime({ backend: workspaceBackend, codexModel: pairing.codexModel }, options.anthropic)
|
|
891
|
+
: null;
|
|
892
|
+
const repositoryReadiness = new Map();
|
|
893
|
+
for (const connection of activeConnections) {
|
|
894
|
+
repositoryReadiness.set(connection.connectionId, {
|
|
895
|
+
readiness: await checkRepositoryReadiness(connection.repoPath),
|
|
896
|
+
checkedAt: Date.now(),
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
if (activeConnections.length !== pairing.connections.length && activeConnections.length > 0) {
|
|
900
|
+
error('Skipping investigation polling while a configured Codex connection is unavailable, so the companion cannot claim an investigation it cannot run.');
|
|
901
|
+
}
|
|
902
|
+
const projectCount = activeConnections.length;
|
|
903
|
+
const watching = workspaceRuntime
|
|
904
|
+
? `${projectCount > 0 ? `${projectCount} project${projectCount === 1 ? '' : 's'} and ` : ''}workspace-level tasks`
|
|
905
|
+
: `${projectCount} project${projectCount === 1 ? '' : 's'}`;
|
|
906
|
+
log(`Connected to ${pairing.url}. Watching ${watching}:`);
|
|
907
|
+
for (const connection of activeConnections) {
|
|
908
|
+
const readiness = repositoryReadiness.get(connection.connectionId).readiness;
|
|
909
|
+
if (readiness.state === 'ready') {
|
|
910
|
+
const sha = await currentCommit(connection.repoPath).catch(() => '(unknown)');
|
|
911
|
+
log(options.safeStartupDiagnostics
|
|
912
|
+
? ` - ${connection.connectionId} @ ${sha.slice(0, 7)} (${connection.backend})`
|
|
913
|
+
: ` - ${connection.connectionId} → ${connection.repoPath} @ ${sha.slice(0, 7)} (${connection.backend})`);
|
|
914
|
+
}
|
|
915
|
+
else {
|
|
916
|
+
const summary = options.safeStartupDiagnostics
|
|
917
|
+
? (readiness.state === 'git_unavailable' ? GIT_UNAVAILABLE_MESSAGE : 'project folder is not ready')
|
|
918
|
+
: readiness.state === 'repository_missing' ? 'project folder missing' : readiness.reason;
|
|
919
|
+
log(options.safeStartupDiagnostics
|
|
920
|
+
? ` - ${connection.connectionId} (not ready: ${summary}) (${connection.backend})`
|
|
921
|
+
: ` - ${connection.connectionId} → ${connection.repoPath} (not ready: ${summary}) (${connection.backend})`);
|
|
922
|
+
if (readiness.state === 'repository_missing') {
|
|
923
|
+
error(options.safeStartupDiagnostics
|
|
924
|
+
? `[${new Date().toISOString()}] Repository not ready for ${connection.connectionId}.`
|
|
925
|
+
: `[${new Date().toISOString()}] Repository not ready for ${connection.connectionId}: ${readiness.reason}`);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
if (workspaceRuntime)
|
|
930
|
+
log(` - workspace-level (connection-less / PRD-only) tasks (${workspaceBackend})`);
|
|
931
|
+
log('Waiting for work — leave this running. Ctrl+C any time to stop.');
|
|
932
|
+
let lastIdleStatusAt = Date.now();
|
|
933
|
+
let investigationPollFailures = 0;
|
|
934
|
+
let nextInvestigationPollAt = 0;
|
|
935
|
+
let surfaceMappingPollingEnabled = true;
|
|
936
|
+
const skippedConnections = new Set();
|
|
937
|
+
const loggedSkippedConnections = new Set();
|
|
938
|
+
const readinessReporting = {
|
|
939
|
+
enabled: true,
|
|
940
|
+
loggedErrors: new Set(),
|
|
941
|
+
foreignConnectionIds: skippedConnections,
|
|
942
|
+
};
|
|
943
|
+
const skipConnection = (connectionId) => {
|
|
944
|
+
skippedConnections.add(connectionId);
|
|
945
|
+
if (loggedSkippedConnections.has(connectionId))
|
|
946
|
+
return;
|
|
947
|
+
loggedSkippedConnections.add(connectionId);
|
|
948
|
+
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.`);
|
|
949
|
+
};
|
|
950
|
+
while (!options.shouldStop()) {
|
|
951
|
+
try {
|
|
952
|
+
let claimedAny = false;
|
|
953
|
+
const pollableConnections = activeConnections.filter((connection) => !skippedConnections.has(connection.connectionId));
|
|
954
|
+
const readyConnections = [];
|
|
955
|
+
const readinessNow = Date.now();
|
|
956
|
+
for (const connection of pollableConnections) {
|
|
957
|
+
if (options.shouldStop())
|
|
958
|
+
break;
|
|
959
|
+
let cached = repositoryReadiness.get(connection.connectionId);
|
|
960
|
+
if (readinessNow - cached.checkedAt >= REPOSITORY_RECHECK_MS) {
|
|
961
|
+
const next = await checkRepositoryReadiness(connection.repoPath);
|
|
962
|
+
if (next.state !== cached.readiness.state) {
|
|
963
|
+
if (next.state === 'ready') {
|
|
964
|
+
log(`[${new Date().toISOString()}] Repository ready for ${connection.connectionId}; resuming repository-aware polling.`);
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
error(options.safeStartupDiagnostics
|
|
968
|
+
? `[${new Date().toISOString()}] Repository not ready for ${connection.connectionId}.`
|
|
969
|
+
: `[${new Date().toISOString()}] Repository not ready for ${connection.connectionId}: ${next.reason}`);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
cached = { readiness: next, checkedAt: readinessNow };
|
|
973
|
+
repositoryReadiness.set(connection.connectionId, cached);
|
|
974
|
+
}
|
|
975
|
+
if (cached.readiness.state === 'ready')
|
|
976
|
+
readyConnections.push(connection);
|
|
977
|
+
}
|
|
978
|
+
const readinessOutcome = await reportLoopReadiness(client, buildReadinessReport(pollableConnections, options.unavailableConnections, repositoryReadiness), readinessReporting, { error });
|
|
979
|
+
for (const connectionId of [...skippedConnections])
|
|
980
|
+
skipConnection(connectionId);
|
|
981
|
+
if (readinessOutcome === 'token_rejected')
|
|
982
|
+
return tokenRejected();
|
|
983
|
+
let usageClaimLimit = options.usageLimitPause.claimLimit();
|
|
984
|
+
const mayClaim = () => usageClaimLimit === null || usageClaimLimit > 0;
|
|
985
|
+
const recordCompletedClaim = (usageLimitMessage) => {
|
|
986
|
+
if (usageClaimLimit !== null)
|
|
987
|
+
usageClaimLimit--;
|
|
988
|
+
if (usageLimitMessage) {
|
|
989
|
+
options.usageLimitPause.pause(usageLimitMessage);
|
|
990
|
+
usageClaimLimit = 0;
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
for (const connection of readyConnections) {
|
|
994
|
+
if (options.shouldStop() || !mayClaim())
|
|
995
|
+
break;
|
|
996
|
+
if (skippedConnections.has(connection.connectionId))
|
|
997
|
+
continue;
|
|
998
|
+
if (surfaceMappingPollingEnabled) {
|
|
999
|
+
try {
|
|
1000
|
+
const mapping = await client.claimNextSurfaceMapping(connection.connectionId);
|
|
1001
|
+
if (mapping.job) {
|
|
1002
|
+
claimedAny = true;
|
|
1003
|
+
recordCompletedClaim(await runOneSurfaceMapping(options.anthropic, client, connection, runtimes.get(connection.connectionId), mapping.job, { log, error }));
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
catch (reason) {
|
|
1007
|
+
if (reason instanceof CompanionHttpError && reason.status === 401)
|
|
1008
|
+
return tokenRejected();
|
|
1009
|
+
if (isUnknownConnectionError(reason)) {
|
|
1010
|
+
skipConnection(connection.connectionId);
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
const status = surfaceMappingUnavailableStatus(reason);
|
|
1014
|
+
if (status !== null) {
|
|
1015
|
+
error(`Automatic mapping is not available on this server (HTTP ${status}); continuing without it.`);
|
|
1016
|
+
surfaceMappingPollingEnabled = false;
|
|
1017
|
+
}
|
|
1018
|
+
else {
|
|
1019
|
+
error(`Surface-mapping poll failed for ${connection.connectionId}:`, reason instanceof Error ? reason.message : reason);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
if (!mayClaim() || skippedConnections.has(connection.connectionId))
|
|
1024
|
+
continue;
|
|
1025
|
+
try {
|
|
1026
|
+
const claim = await client.claimNext(connection.connectionId);
|
|
1027
|
+
if (!claim.task)
|
|
1028
|
+
continue;
|
|
1029
|
+
claimedAny = true;
|
|
1030
|
+
recordCompletedClaim(await runOneTask(runtimes.get(connection.connectionId), client, connection.repoPath, claim.task, connection, { log, error }));
|
|
1031
|
+
}
|
|
1032
|
+
catch (reason) {
|
|
1033
|
+
if (reason instanceof CompanionHttpError && reason.status === 401)
|
|
1034
|
+
return tokenRejected();
|
|
1035
|
+
if (isUnknownConnectionError(reason)) {
|
|
1036
|
+
skipConnection(connection.connectionId);
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
error(`Poll failed for ${connection.connectionId}:`, reason instanceof Error ? reason.message : reason);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
if (!options.shouldStop() && workspaceRuntime && mayClaim()) {
|
|
1043
|
+
try {
|
|
1044
|
+
const claim = await client.claimNext();
|
|
1045
|
+
if (claim.task) {
|
|
1046
|
+
claimedAny = true;
|
|
1047
|
+
recordCompletedClaim(await runOneTask(workspaceRuntime, client, process.cwd(), claim.task, undefined, { log, error }));
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
catch (reason) {
|
|
1051
|
+
if (reason instanceof CompanionHttpError && reason.status === 401)
|
|
1052
|
+
return tokenRejected();
|
|
1053
|
+
error('Workspace-level poll failed:', reason instanceof Error ? reason.message : reason);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
const remainingConfigured = pairing.connections.filter((connection) => !skippedConnections.has(connection.connectionId));
|
|
1057
|
+
if (!options.shouldStop() && mayClaim() && readyConnections.length > 0
|
|
1058
|
+
&& readyConnections.length === pollableConnections.length
|
|
1059
|
+
&& pollableConnections.length === remainingConfigured.length
|
|
1060
|
+
&& Date.now() >= nextInvestigationPollAt) {
|
|
1061
|
+
try {
|
|
1062
|
+
const investigation = await client.claimNextInvestigation();
|
|
1063
|
+
({ consecutiveFailures: investigationPollFailures } = nextInvestigationPollBackoff(investigationPollFailures, true));
|
|
1064
|
+
nextInvestigationPollAt = 0;
|
|
1065
|
+
if (investigation) {
|
|
1066
|
+
claimedAny = true;
|
|
1067
|
+
recordCompletedClaim(await runOneInvestigation(options.anthropic, client, pollableConnections, runtimes, investigation, { log, error }));
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
catch (reason) {
|
|
1071
|
+
if (reason instanceof CompanionHttpError && reason.status === 401)
|
|
1072
|
+
return tokenRejected();
|
|
1073
|
+
const backoff = nextInvestigationPollBackoff(investigationPollFailures, false);
|
|
1074
|
+
investigationPollFailures = backoff.consecutiveFailures;
|
|
1075
|
+
nextInvestigationPollAt = Date.now() + backoff.delayMs;
|
|
1076
|
+
if (investigationPollFailures === 1) {
|
|
1077
|
+
error('Investigation poll failed:', reason instanceof Error ? reason.message : reason, `Retrying in ${backoff.delayMs / 1000}s.`);
|
|
1078
|
+
}
|
|
1079
|
+
else {
|
|
1080
|
+
warn(`Investigation poll still failing (${investigationPollFailures} consecutive); retrying in ${backoff.delayMs / 1000}s.`);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
if (claimedAny) {
|
|
1085
|
+
lastIdleStatusAt = Date.now();
|
|
1086
|
+
}
|
|
1087
|
+
else if (!options.shouldStop()) {
|
|
1088
|
+
const now = Date.now();
|
|
1089
|
+
if (now - lastIdleStatusAt >= IDLE_STATUS_MS) {
|
|
1090
|
+
log(`[${new Date(now).toISOString()}] still watching — no work claimed.`);
|
|
1091
|
+
lastIdleStatusAt = now;
|
|
1092
|
+
}
|
|
1093
|
+
await new Promise((done) => setTimeout(done, IDLE_POLL_MS));
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
catch (reason) {
|
|
1097
|
+
if (reason instanceof CompanionHttpError && reason.status === 401)
|
|
1098
|
+
return tokenRejected();
|
|
1099
|
+
error('Unexpected run-loop error:', reason instanceof Error ? reason.message : reason);
|
|
1100
|
+
if (!options.shouldStop())
|
|
1101
|
+
await new Promise((done) => setTimeout(done, LOOP_RECOVERY_DELAY_MS));
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
log('Stopped.');
|
|
1105
|
+
return 'stopped';
|
|
1106
|
+
}
|
|
1107
|
+
async function cmdRun(options = {}) {
|
|
1108
|
+
let config;
|
|
1109
|
+
try {
|
|
1110
|
+
config = loadConfig();
|
|
1111
|
+
}
|
|
1112
|
+
catch (error) {
|
|
1113
|
+
if (!options.safeStartupDiagnostics)
|
|
1114
|
+
throw error;
|
|
1115
|
+
console.error('Could not read the companion configuration. Fix or remove the configuration file, then run connect again.');
|
|
1116
|
+
process.exitCode = 1;
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
const runnablePairings = config?.pairings.filter((pairing) => pairing.connections.length > 0 || pairing.pollWorkspaceTasks) ?? [];
|
|
1120
|
+
if (!config || runnablePairings.length === 0) {
|
|
1121
|
+
console.error(options.safeStartupDiagnostics
|
|
1122
|
+
? 'Nothing is set up yet. Run connect first.'
|
|
1123
|
+
: `Nothing set up yet — ${configPath} is missing or empty. Run "connect" first.`);
|
|
1124
|
+
process.exitCode = 1;
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
const allBackends = runnablePairings.flatMap((pairing) => [
|
|
1128
|
+
...pairing.connections.map((connection) => connection.backend),
|
|
1129
|
+
...(pairing.pollWorkspaceTasks ? [pairing.workspaceBackend ?? 'api-key'] : []),
|
|
1130
|
+
]);
|
|
1131
|
+
const { needsApiKey, needsCli } = backendRequirements(allBackends);
|
|
1132
|
+
const apiKey = config.apiKey || process.env.ANTHROPIC_API_KEY;
|
|
1133
|
+
if (needsApiKey && !apiKey) {
|
|
1134
|
+
console.error('No Anthropic API key found. Get one free at console.anthropic.com, then either run '
|
|
1135
|
+
+ '"connect" again adding --api-key <your key>, or set it yourself: '
|
|
1136
|
+
+ '"set ANTHROPIC_API_KEY=<your key>" (Windows) / "export ANTHROPIC_API_KEY=<your key>" (Mac/Linux), then run "run" again.');
|
|
1137
|
+
process.exitCode = 1;
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
if (needsCli && !await checkClaudeCliAvailable()) {
|
|
1141
|
+
console.error('This needs Claude Code installed and you logged into it first: npm install -g @anthropic-ai/claude-code, then run `claude` and follow the login prompt.');
|
|
1142
|
+
process.exitCode = 1;
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
const unavailableConnections = new Set();
|
|
1146
|
+
let codexAvailable = true;
|
|
1147
|
+
if (allBackends.includes('codex')) {
|
|
1148
|
+
const status = await checkCodexLogin(defaultCodexHome());
|
|
1149
|
+
codexAvailable = status === 'logged-in';
|
|
1150
|
+
if (!codexAvailable) {
|
|
1151
|
+
logCodexReadiness(status);
|
|
1152
|
+
for (const pairing of runnablePairings) {
|
|
1153
|
+
for (const connection of pairing.connections) {
|
|
1154
|
+
if (connection.backend !== 'codex')
|
|
1155
|
+
continue;
|
|
1156
|
+
console.error(`Skipping ${connection.connectionId}: Codex is ${status === 'not-installed' ? 'not installed' : 'not logged in'}; no work will be claimed for this connection.`);
|
|
1157
|
+
unavailableConnections.add(connection.connectionId);
|
|
1158
|
+
}
|
|
1159
|
+
if (pairing.pollWorkspaceTasks && (pairing.workspaceBackend ?? 'api-key') === 'codex') {
|
|
1160
|
+
console.error(`Skipping workspace-level tasks for ${pairing.url}: Codex is ${status === 'not-installed' ? 'not installed' : 'not logged in'}; no work will be claimed.`);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
const anthropic = needsApiKey ? new Anthropic({ apiKey }) : null;
|
|
1166
|
+
const usageLimitPause = new UsageLimitPause();
|
|
1167
|
+
let stopping = false;
|
|
1168
|
+
process.on('SIGINT', () => {
|
|
1169
|
+
if (stopping) {
|
|
1170
|
+
expectedExitReason = 'forced stop after a second Ctrl+C';
|
|
1171
|
+
process.exit(1);
|
|
1172
|
+
}
|
|
1173
|
+
console.log('\nStopping after the current poll — Ctrl+C again to force quit.');
|
|
1174
|
+
expectedExitReason = 'stopped by Ctrl+C';
|
|
1175
|
+
stopping = true;
|
|
1176
|
+
});
|
|
1177
|
+
let activePairings = runnablePairings.length;
|
|
1178
|
+
await Promise.all(runnablePairings.map(async (pairing) => {
|
|
1179
|
+
try {
|
|
1180
|
+
return await runPairing(pairing, {
|
|
1181
|
+
...options,
|
|
1182
|
+
multiple: runnablePairings.length > 1,
|
|
1183
|
+
anthropic,
|
|
1184
|
+
unavailableConnections,
|
|
1185
|
+
codexAvailable,
|
|
1186
|
+
usageLimitPause,
|
|
1187
|
+
shouldStop: () => stopping,
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
catch (reason) {
|
|
1191
|
+
if (runnablePairings.length === 1)
|
|
1192
|
+
throw reason;
|
|
1193
|
+
console.error(`${pairingPrefix(pairing)} The Console at ${pairing.url} stopped because of an unexpected error: ${reason instanceof Error ? reason.message : reason}`);
|
|
1194
|
+
return 'stopped';
|
|
1195
|
+
}
|
|
1196
|
+
finally {
|
|
1197
|
+
activePairings--;
|
|
1198
|
+
if (activePairings === 0 && !stopping) {
|
|
1199
|
+
expectedExitReason = 'every connected Console stopped accepting work';
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
}));
|
|
1203
|
+
}
|
|
1204
|
+
async function cmdRunWithInstance(options = {}) {
|
|
1205
|
+
if (process.env.PRODUCT_CONSOLE_COMPANION_DAEMON_CHILD === '1')
|
|
1206
|
+
return cmdRun(options);
|
|
1207
|
+
const paths = servicePaths();
|
|
1208
|
+
if (!acquireInstanceLock('run', paths)) {
|
|
1209
|
+
console.log('The companion is already running (in the background or another terminal), so this run will exit.');
|
|
1210
|
+
console.log(restartHint);
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
try {
|
|
1214
|
+
await cmdRun(options);
|
|
1215
|
+
}
|
|
1216
|
+
finally {
|
|
1217
|
+
releaseInstanceLock(paths);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
const defaultFinishConnectDependencies = {
|
|
1221
|
+
status: () => serviceStatus(),
|
|
1222
|
+
stop: () => stopService(),
|
|
1223
|
+
install: (cliPath) => installService({ cliPath }),
|
|
1224
|
+
run: (options) => cmdRunWithInstance(options),
|
|
1225
|
+
isNpxPath: isNpxCachePath,
|
|
1226
|
+
output: console,
|
|
1227
|
+
};
|
|
1228
|
+
async function reinstallBackgroundService(cliPath, dependencies = defaultFinishConnectDependencies) {
|
|
1229
|
+
const current = dependencies.status();
|
|
1230
|
+
// A reinstall replaces the saved settings; a daemon that is already running would keep the old ones.
|
|
1231
|
+
if (current.running && current.role === 'daemon')
|
|
1232
|
+
await dependencies.stop();
|
|
1233
|
+
return dependencies.install(cliPath);
|
|
1234
|
+
}
|
|
1235
|
+
/** Finishes a successful pairing without making CLI tests touch the real service or home folder. */
|
|
1236
|
+
export async function finishConnect(options, dependencies = defaultFinishConnectDependencies) {
|
|
1237
|
+
if (options.background && !options.shouldRunAfter) {
|
|
1238
|
+
dependencies.output.error("--background can't be used together with --no-run.");
|
|
1239
|
+
return false;
|
|
1240
|
+
}
|
|
1241
|
+
if (options.background) {
|
|
1242
|
+
const cliPath = options.cliPath ?? fileURLToPath(import.meta.url);
|
|
1243
|
+
if (dependencies.isNpxPath(cliPath)) {
|
|
1244
|
+
const windows = (options.platform ?? process.platform) === 'win32';
|
|
1245
|
+
dependencies.output.log('Paired. To keep the companion running in the background, install it once and turn it on:');
|
|
1246
|
+
dependencies.output.log(`${windows ? 'npm.cmd' : 'npm'} install -g @withtruing/companion@latest`);
|
|
1247
|
+
dependencies.output.log(`${windows ? 'truing.cmd' : 'truing'} install`);
|
|
1248
|
+
return true;
|
|
1249
|
+
}
|
|
1250
|
+
try {
|
|
1251
|
+
for (const line of await reinstallBackgroundService(cliPath, dependencies))
|
|
1252
|
+
dependencies.output.log(line);
|
|
1253
|
+
dependencies.output.log('Done. The companion now runs in the background and starts by itself when you sign in to this computer. You can close this window.');
|
|
1254
|
+
return true;
|
|
1255
|
+
}
|
|
1256
|
+
catch (error) {
|
|
1257
|
+
const reason = (error instanceof Error ? error.message : String(error)).replace(/\s*[\r\n]+\s*/g, ' ');
|
|
1258
|
+
dependencies.output.error(reason);
|
|
1259
|
+
dependencies.output.error('Your pairing is saved. Run `truing install` to try again.');
|
|
1260
|
+
return false;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
if (dependencies.status().running)
|
|
1264
|
+
dependencies.output.log(restartHint);
|
|
1265
|
+
if (!options.shouldRunAfter) {
|
|
1266
|
+
dependencies.output.log('Run "truing run" whenever you\'re ready to start.');
|
|
1267
|
+
return true;
|
|
1268
|
+
}
|
|
1269
|
+
dependencies.output.log('Starting now — Ctrl+C any time to stop.\n');
|
|
1270
|
+
await dependencies.run({ safeStartupDiagnostics: options.safeStartupDiagnostics });
|
|
1271
|
+
return true;
|
|
1272
|
+
}
|
|
1273
|
+
/** Service commands report expected problems (not installed, running from the npx cache) as one
|
|
1274
|
+
* plain sentence and a non-zero exit, instead of the top-level fatal-error trace. */
|
|
1275
|
+
async function withServiceErrors(action) {
|
|
1276
|
+
try {
|
|
1277
|
+
await action();
|
|
1278
|
+
}
|
|
1279
|
+
catch (error) {
|
|
1280
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1281
|
+
process.exitCode = 1;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
async function cmdInstall() {
|
|
1285
|
+
for (const line of await reinstallBackgroundService(fileURLToPath(import.meta.url)))
|
|
1286
|
+
console.log(line);
|
|
1287
|
+
}
|
|
1288
|
+
async function cmdStop() {
|
|
1289
|
+
const stopped = await stopService();
|
|
1290
|
+
console.log(stopped
|
|
1291
|
+
? 'The Truing companion was asked to stop. Automatic startup is still installed.'
|
|
1292
|
+
: 'No running Truing companion was found. Automatic startup is still installed.');
|
|
1293
|
+
}
|
|
1294
|
+
async function cmdStart() {
|
|
1295
|
+
const status = serviceStatus();
|
|
1296
|
+
if (status.running) {
|
|
1297
|
+
console.log(`The Truing companion is already running (PID ${status.pid}).`);
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
await startService();
|
|
1301
|
+
console.log('The Truing companion was started silently in the background.');
|
|
1302
|
+
}
|
|
1303
|
+
async function cmdUninstall() {
|
|
1304
|
+
for (const line of await uninstallService())
|
|
1305
|
+
console.log(line);
|
|
1306
|
+
}
|
|
1307
|
+
async function cmdDisconnect(argv) {
|
|
1308
|
+
const flags = parseFlags(argv);
|
|
1309
|
+
const url = flags.url?.replace(/\/+$/, '');
|
|
1310
|
+
if (!url) {
|
|
1311
|
+
console.error('Usage: truing disconnect --url <your Truing link> [--workspace <workspaceId>]');
|
|
1312
|
+
process.exitCode = 1;
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
const removed = removePairing(url, flags.workspace);
|
|
1316
|
+
if (removed.length === 0) {
|
|
1317
|
+
console.error(`This computer is not connected to ${url}${flags.workspace ? ` for workspace ${flags.workspace}` : ''}.`);
|
|
1318
|
+
process.exitCode = 1;
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
const projectCount = removed.reduce((total, pairing) => total + pairing.connections.length, 0);
|
|
1322
|
+
console.log(`Disconnected from ${url}; removed ${projectCount} project${projectCount === 1 ? '' : 's'}.`);
|
|
1323
|
+
await removeManagedCopies(removed.flatMap((pairing) => pairing.connections));
|
|
1324
|
+
logRestartHintIfRunning();
|
|
1325
|
+
}
|
|
1326
|
+
/** Deletes the companion's own copies behind removed connections. A copy another remaining
|
|
1327
|
+
* connection still uses (the same repo connected to a second Console) is kept. Only paths inside
|
|
1328
|
+
* <config dir>/repos/ are ever deleted, and only for connections marked managed. */
|
|
1329
|
+
async function removeManagedCopies(removedConnections) {
|
|
1330
|
+
const reposDir = managedReposDir();
|
|
1331
|
+
const stillUsed = new Set((loadConfig()?.pairings ?? [])
|
|
1332
|
+
.flatMap((pairing) => pairing.connections)
|
|
1333
|
+
.map((connection) => resolve(connection.repoPath).toLowerCase()));
|
|
1334
|
+
const handled = new Set();
|
|
1335
|
+
for (const connection of removedConnections) {
|
|
1336
|
+
if (!connection.managed)
|
|
1337
|
+
continue;
|
|
1338
|
+
const key = resolve(connection.repoPath).toLowerCase();
|
|
1339
|
+
if (stillUsed.has(key) || handled.has(key))
|
|
1340
|
+
continue;
|
|
1341
|
+
handled.add(key);
|
|
1342
|
+
const label = connection.label ?? labelFromCopyPath(connection.repoPath);
|
|
1343
|
+
try {
|
|
1344
|
+
await removeManagedCopy(connection.repoPath, reposDir);
|
|
1345
|
+
console.log(`Removed the companion's copy of ${label}.`);
|
|
1346
|
+
}
|
|
1347
|
+
catch (error) {
|
|
1348
|
+
console.error(error instanceof ManagedRepoError
|
|
1349
|
+
? error.message
|
|
1350
|
+
: `Couldn't remove the companion's copy of ${label} at ${connection.repoPath}. Stop the companion, then delete that folder.`);
|
|
1351
|
+
process.exitCode = 1;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
async function cmdSignin(argv) {
|
|
1356
|
+
const flags = parseFlags(argv);
|
|
1357
|
+
let wanted = null;
|
|
1358
|
+
if (flags.repo !== undefined) {
|
|
1359
|
+
try {
|
|
1360
|
+
wanted = managedCopyPath(managedReposDir(), parseGitHubRepo(flags.repo)).toLowerCase();
|
|
1361
|
+
}
|
|
1362
|
+
catch (error) {
|
|
1363
|
+
console.error(error instanceof ManagedRepoError ? error.message : String(error));
|
|
1364
|
+
process.exitCode = 1;
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
const copies = new Map();
|
|
1369
|
+
for (const pairing of loadConfig()?.pairings ?? []) {
|
|
1370
|
+
const client = new CompanionClient(pairing.url, pairing.token);
|
|
1371
|
+
for (const connection of pairing.connections) {
|
|
1372
|
+
if (!connection.managed)
|
|
1373
|
+
continue;
|
|
1374
|
+
const key = resolve(connection.repoPath).toLowerCase();
|
|
1375
|
+
if (copies.has(key) || (wanted !== null && key !== wanted))
|
|
1376
|
+
continue;
|
|
1377
|
+
const label = connection.label ?? labelFromCopyPath(connection.repoPath);
|
|
1378
|
+
copies.set(key, {
|
|
1379
|
+
repoPath: connection.repoPath,
|
|
1380
|
+
label,
|
|
1381
|
+
getToken: () => client.githubToken(label).then((response) => response.token),
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
if (copies.size === 0) {
|
|
1386
|
+
console.log(wanted === null
|
|
1387
|
+
? "There's no companion-kept copy of any repo on this computer."
|
|
1388
|
+
: `There's no companion-kept copy of ${flags.repo} on this computer.`);
|
|
1389
|
+
if (wanted !== null)
|
|
1390
|
+
process.exitCode = 1;
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
for (const { repoPath, label, getToken } of copies.values()) {
|
|
1394
|
+
if (!existsSync(repoPath)) {
|
|
1395
|
+
console.log(`The companion's copy of ${label} is missing. Run the connect command for it again.`);
|
|
1396
|
+
process.exitCode = 1;
|
|
1397
|
+
continue;
|
|
1398
|
+
}
|
|
1399
|
+
const outcome = await interactiveFetch(repoPath, getToken);
|
|
1400
|
+
if (outcome.ok) {
|
|
1401
|
+
console.log(`Signed in, ${label} is up to date.`);
|
|
1402
|
+
}
|
|
1403
|
+
else {
|
|
1404
|
+
console.log(`Still can't reach ${label} on GitHub: ${readManagedState(repoPath)?.lastProblem ?? 'Git could not reach GitHub'}`);
|
|
1405
|
+
process.exitCode = 1;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
function cmdStatus() {
|
|
1410
|
+
const status = serviceStatus();
|
|
1411
|
+
console.log(`Automatic startup: ${status.installed ? 'installed' : 'not installed'}`);
|
|
1412
|
+
console.log(status.running
|
|
1413
|
+
? `Companion: running (${status.role ?? 'process'} PID ${status.pid})`
|
|
1414
|
+
: 'Companion: not running');
|
|
1415
|
+
if (status.logLines.length > 0) {
|
|
1416
|
+
console.log('Latest log lines:');
|
|
1417
|
+
for (const line of status.logLines)
|
|
1418
|
+
console.log(` ${line}`);
|
|
1419
|
+
}
|
|
1420
|
+
else {
|
|
1421
|
+
console.log('Latest log lines: none');
|
|
1422
|
+
}
|
|
1423
|
+
if (status.pairings.length > 0) {
|
|
1424
|
+
console.log('Connected Consoles:');
|
|
1425
|
+
for (const pairing of status.pairings) {
|
|
1426
|
+
const workspace = pairing.workspaceId ? ` (workspace ${pairing.workspaceId.slice(0, 8)}…)` : '';
|
|
1427
|
+
console.log(` ${pairing.url}${workspace}`);
|
|
1428
|
+
for (const project of pairing.projects) {
|
|
1429
|
+
console.log(` ${project.connectionId} — ${project.repoPath}`);
|
|
1430
|
+
if (!project.managed)
|
|
1431
|
+
continue;
|
|
1432
|
+
const state = readManagedState(project.repoPath);
|
|
1433
|
+
console.log(state?.signInNeeded
|
|
1434
|
+
? ` ${state.reconnectInConsole ? RECONNECT_IN_CONSOLE_HINT : SIGN_IN_NEEDED_HINT}`
|
|
1435
|
+
: ` code copy kept up to date by the companion, last updated ${relativeTime(state?.lastUpdatedAt)}`);
|
|
1436
|
+
}
|
|
1437
|
+
if (pairing.workspaceTasks)
|
|
1438
|
+
console.log(` workspace tasks (${pairing.workspaceTasks.backend})`);
|
|
1439
|
+
if (pairing.projects.length === 0 && !pairing.workspaceTasks)
|
|
1440
|
+
console.log(' (no projects)');
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
else {
|
|
1444
|
+
console.log('Connected Consoles: none');
|
|
1445
|
+
}
|
|
1446
|
+
if (!status.running)
|
|
1447
|
+
process.exitCode = 3;
|
|
1448
|
+
}
|
|
1449
|
+
async function main() {
|
|
1450
|
+
const [, , cmd, ...rest] = process.argv;
|
|
1451
|
+
if (cmd === 'connect')
|
|
1452
|
+
return cmdConnect(rest);
|
|
1453
|
+
if (cmd === 'disconnect')
|
|
1454
|
+
return withServiceErrors(() => cmdDisconnect(rest));
|
|
1455
|
+
if (cmd === 'run')
|
|
1456
|
+
return cmdRunWithInstance();
|
|
1457
|
+
if (cmd === 'install')
|
|
1458
|
+
return withServiceErrors(cmdInstall);
|
|
1459
|
+
if (cmd === 'daemon')
|
|
1460
|
+
return runDaemon();
|
|
1461
|
+
if (cmd === 'stop')
|
|
1462
|
+
return withServiceErrors(cmdStop);
|
|
1463
|
+
if (cmd === 'start')
|
|
1464
|
+
return withServiceErrors(cmdStart);
|
|
1465
|
+
if (cmd === 'uninstall')
|
|
1466
|
+
return withServiceErrors(cmdUninstall);
|
|
1467
|
+
if (cmd === 'status')
|
|
1468
|
+
return withServiceErrors(cmdStatus);
|
|
1469
|
+
if (cmd === 'signin')
|
|
1470
|
+
return cmdSignin(rest);
|
|
1471
|
+
console.log('Usage: truing <connect|disconnect|run|install|start|stop|status|signin|uninstall> [flags]');
|
|
1472
|
+
console.log(' Connect without a local checkout: truing connect --url <your Truing link> --token <publicId.secret> --repo <owner/name>');
|
|
1473
|
+
process.exitCode = cmd ? 1 : 0;
|
|
1474
|
+
}
|
|
1475
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
1476
|
+
installProcessHandlers();
|
|
1477
|
+
main()
|
|
1478
|
+
.then(() => {
|
|
1479
|
+
expectedExitReason ??= 'command completed';
|
|
1480
|
+
})
|
|
1481
|
+
.catch((reason) => {
|
|
1482
|
+
const error = logFatal('FATAL top-level error', reason);
|
|
1483
|
+
unexpectedExitReason = `top-level error: ${error.message}`;
|
|
1484
|
+
process.exitCode = 1;
|
|
1485
|
+
});
|
|
1486
|
+
}
|