replicas-engine 0.1.1036 → 0.1.1038

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.
@@ -0,0 +1,2058 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire as __createRequire } from 'node:module';
3
+ const require = __createRequire(import.meta.url);
4
+ import {
5
+ ENGINE_ENV,
6
+ getCurrentBranch,
7
+ getOriginDefaultBranch,
8
+ monolithRequest,
9
+ readRepoHeadBranches,
10
+ resolveDiffBase,
11
+ runGitCommand,
12
+ setAgentCredentialSnapshot
13
+ } from "./chunk-5TYR5GFS.js";
14
+ import {
15
+ AppServerProcess,
16
+ buildCodexAgentEnv
17
+ } from "./chunk-SHHV4JCC.js";
18
+ import {
19
+ SUBPROCESS_MAX_BUFFER,
20
+ execFileAsync
21
+ } from "./chunk-XOSNMSSU.js";
22
+ import {
23
+ isRecord
24
+ } from "./chunk-UZSNFLDQ.js";
25
+ import {
26
+ CODEX_AUTH_ENV_KEYS,
27
+ CODEX_AUTH_ENV_KEYS_BY_METHOD,
28
+ CODEX_QUOTA_STATUS_EVENT_TYPE,
29
+ DEFAULT_CODEX_MODEL,
30
+ DEFAULT_HOOK_OUTPUT_PREVIEW_CHARS,
31
+ GIT_CREDENTIAL_HELPER_FILENAME,
32
+ IMAGE_MEDIA_TYPES,
33
+ InFlightMap,
34
+ SUBAGENT_MCP_SERVER_NAME,
35
+ SUBAGENT_MCP_TOOL_NAMES,
36
+ buildCodeHostPrUrl,
37
+ buildGitCredentialHelperScript,
38
+ buildOutputPreview,
39
+ codexAuthEnvFromResponse,
40
+ codexReasoningEffortForThinkingLevel,
41
+ createErrorResult,
42
+ createSuccessResult,
43
+ decodePathSegments,
44
+ detectAgentQuotaLimit,
45
+ fileContentDiff,
46
+ isGitHubUrl,
47
+ isSameCredential,
48
+ listOllamaModels,
49
+ listOpenAICompatibleModels,
50
+ normalizeCodexAspTranscriptStatus,
51
+ parseCodeHostPrUrl,
52
+ parseGitRemote,
53
+ readOllamaAuthEnv,
54
+ resolveOpenAICompatibleModelsUrl,
55
+ withTimeout
56
+ } from "./chunk-MALHGZWB.js";
57
+
58
+ // src/managers/codex-token-manager.ts
59
+ import { promises as fs } from "fs";
60
+ import path from "path";
61
+
62
+ // src/managers/auth-env-transition.ts
63
+ function applyAuthEnvTransition(params) {
64
+ const prevOwned = new Set(params.authKeysByMethod[params.prevMethod]);
65
+ for (const key of params.authKeys) {
66
+ const value = params.newEnvVars[key];
67
+ if (value !== void 0) {
68
+ for (const env of params.envs) {
69
+ env[key] = value;
70
+ }
71
+ } else if (prevOwned.has(key)) {
72
+ for (const env of params.envs) {
73
+ delete env[key];
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ // src/services/credential-fallbacks.ts
80
+ var fallbacksByAgent = /* @__PURE__ */ new Map();
81
+ var exhaustedByAgent = /* @__PURE__ */ new Map();
82
+ function clearCredentialFallbacks(provider) {
83
+ fallbacksByAgent.delete(provider);
84
+ exhaustedByAgent.delete(provider);
85
+ }
86
+ function recordCredentialFallback(notice) {
87
+ fallbacksByAgent.set(notice.provider, notice);
88
+ }
89
+ function listCredentialFallbacks() {
90
+ return [...fallbacksByAgent.values()].filter((notice) => {
91
+ const live = ENGINE_ENV.REPLICAS_AGENT_CREDENTIALS[notice.provider];
92
+ if (!live) return false;
93
+ return notice.status === "switched" ? live.method === notice.candidateMethod && live.scope === notice.candidateScope : live.method === notice.exhaustedMethod && live.scope === notice.exhaustedScope;
94
+ });
95
+ }
96
+ function listExhaustedCredentials(provider) {
97
+ return [...exhaustedByAgent.get(provider)?.values() ?? []];
98
+ }
99
+ function routineRefreshRequest(provider) {
100
+ const live = ENGINE_ENV.REPLICAS_AGENT_CREDENTIALS[provider];
101
+ const excludeCredentials = listExhaustedCredentials(provider).filter((spent) => !live || !isSameCredential(spent, live));
102
+ return {
103
+ ...excludeCredentials.length > 0 ? { excludeCredentials } : {},
104
+ ...live ? { currentCredential: live } : {}
105
+ };
106
+ }
107
+ function recordExhaustedCredential(provider, credential) {
108
+ const spent = exhaustedByAgent.get(provider) ?? /* @__PURE__ */ new Map();
109
+ spent.set(`${credential.method}|${credential.scope}|${credential.credentialId ?? ""}`, credential);
110
+ exhaustedByAgent.set(provider, spent);
111
+ }
112
+
113
+ // src/managers/base-refresh-manager.ts
114
+ function credentialSwapError(error) {
115
+ const message = error instanceof Error ? error.message : String(error);
116
+ return createErrorResult({
117
+ message,
118
+ code: message.includes('"code":"no_credentials"') ? "no_credentials" : "refresh_failed"
119
+ });
120
+ }
121
+ function refreshDelayMs(expiresAtMs, maxMs = 5 * 60 * 1e3) {
122
+ const ttlMs = expiresAtMs - Date.now();
123
+ return Number.isFinite(ttlMs) ? Math.max(1e3, Math.min(maxMs, Math.floor(ttlMs * 0.8))) : maxMs;
124
+ }
125
+ var BaseRefreshManager = class {
126
+ constructor(managerName, intervalMs = 15 * 60 * 1e3) {
127
+ this.managerName = managerName;
128
+ this.intervalMs = intervalMs;
129
+ this.health = {
130
+ isRunning: false,
131
+ intervalMs: this.intervalMs,
132
+ lastAttemptAt: null,
133
+ lastSuccessAt: null,
134
+ lastErrorAt: null,
135
+ lastErrorMessage: null
136
+ };
137
+ }
138
+ managerName;
139
+ intervalMs;
140
+ credentialGeneration = 0;
141
+ credentialUpdate = Promise.resolve();
142
+ intervalHandle = null;
143
+ pendingRefresh = null;
144
+ health;
145
+ async start(skipInitialRefresh = false) {
146
+ if (this.intervalHandle) {
147
+ return;
148
+ }
149
+ const skipReason = this.getSkipReason();
150
+ if (skipReason) {
151
+ console.log(`[${this.managerName}] Skipping: ${skipReason}`);
152
+ return;
153
+ }
154
+ console.log(`[${this.managerName}] Starting token refresh service`);
155
+ this.health.isRunning = true;
156
+ const config = this.getRuntimeConfig();
157
+ if (config && !skipInitialRefresh) {
158
+ this.health.lastAttemptAt = (/* @__PURE__ */ new Date()).toISOString();
159
+ for (let attempt = 1; attempt <= 3; attempt++) {
160
+ try {
161
+ await this.doRefresh(config);
162
+ this.health.lastSuccessAt = (/* @__PURE__ */ new Date()).toISOString();
163
+ this.health.lastErrorAt = null;
164
+ this.health.lastErrorMessage = null;
165
+ break;
166
+ } catch (error) {
167
+ const message = error instanceof Error ? error.message : "Unknown error";
168
+ this.health.lastErrorAt = (/* @__PURE__ */ new Date()).toISOString();
169
+ this.health.lastErrorMessage = message;
170
+ if (attempt < 3) {
171
+ console.warn(`[${this.managerName}] Initial refresh attempt ${attempt} failed, retrying in 2s...`);
172
+ await new Promise((resolve) => setTimeout(resolve, 2e3));
173
+ } else {
174
+ console.error(`[${this.managerName}] Initial refresh failed after 3 attempts:`, error);
175
+ }
176
+ }
177
+ }
178
+ }
179
+ this.scheduleNextRefresh();
180
+ }
181
+ async swapCredentials(params) {
182
+ if (!this.getRuntimeConfig()) {
183
+ return createErrorResult({ message: `${this.managerName} has no runtime config`, code: "not_configured" });
184
+ }
185
+ try {
186
+ console.log(`[${this.managerName}] Fetching fresh credentials from monolith (${params.failureKind})...`);
187
+ const excludeCredentials = listExhaustedCredentials(params.provider);
188
+ await params.refresh({
189
+ ...excludeCredentials.length > 0 ? { excludeCredentials } : {},
190
+ ...params.allowedMethods ? { allowedMethods: [...params.allowedMethods] } : {}
191
+ });
192
+ if (params.isOauthNow()) {
193
+ this.start().catch((error) => {
194
+ console.error(`[${this.managerName}] Failed to restart OAuth refresh service after fallback:`, error);
195
+ });
196
+ }
197
+ return createSuccessResult();
198
+ } catch (error) {
199
+ console.error(`[${this.managerName}] Failed to fetch fresh credentials:`, error);
200
+ return credentialSwapError(error);
201
+ }
202
+ }
203
+ async stop() {
204
+ if (this.intervalHandle) clearTimeout(this.intervalHandle);
205
+ this.intervalHandle = null;
206
+ this.health.isRunning = false;
207
+ await this.pendingRefresh;
208
+ console.log(`[${this.managerName}] Stopped`);
209
+ }
210
+ getHealthStatus() {
211
+ return { ...this.health };
212
+ }
213
+ getSkipReason() {
214
+ return null;
215
+ }
216
+ getNextRefreshDelayMs() {
217
+ return this.intervalMs;
218
+ }
219
+ applyCredentialUpdate(generation, apply) {
220
+ const update = this.credentialUpdate.then(async () => {
221
+ if (generation !== this.credentialGeneration) throw new Error("Credential changed during refresh; retry with the current credential.");
222
+ await apply();
223
+ });
224
+ this.credentialUpdate = update.catch(() => {
225
+ });
226
+ return update;
227
+ }
228
+ getRuntimeConfig() {
229
+ if (!ENGINE_ENV.REPLICAS_WORKSPACE_ID) {
230
+ return null;
231
+ }
232
+ return {
233
+ monolithUrl: ENGINE_ENV.REPLICAS_MONOLITH_URL,
234
+ workspaceId: ENGINE_ENV.REPLICAS_WORKSPACE_ID,
235
+ engineSecret: ENGINE_ENV.REPLICAS_ENGINE_SECRET
236
+ };
237
+ }
238
+ scheduleNextRefresh() {
239
+ const delayMs = this.getNextRefreshDelayMs();
240
+ this.health.intervalMs = delayMs;
241
+ this.intervalHandle = setTimeout(async () => {
242
+ this.pendingRefresh = this.refreshOnce();
243
+ await this.pendingRefresh;
244
+ if (this.intervalHandle) this.scheduleNextRefresh();
245
+ }, delayMs);
246
+ console.log(`[${this.managerName}] Token refresh scheduled in ${Math.round(delayMs / 1e3)} seconds`);
247
+ }
248
+ async refreshOnce(force = false) {
249
+ if (!force && this.getSkipReason()) {
250
+ return createSuccessResult();
251
+ }
252
+ const config = this.getRuntimeConfig();
253
+ if (!config) return createSuccessResult();
254
+ this.health.lastAttemptAt = (/* @__PURE__ */ new Date()).toISOString();
255
+ try {
256
+ await this.doRefresh(config);
257
+ this.health.lastSuccessAt = (/* @__PURE__ */ new Date()).toISOString();
258
+ this.health.lastErrorAt = null;
259
+ this.health.lastErrorMessage = null;
260
+ return createSuccessResult();
261
+ } catch (error) {
262
+ const message = error instanceof Error ? error.message : "Unknown error";
263
+ this.health.lastErrorAt = (/* @__PURE__ */ new Date()).toISOString();
264
+ this.health.lastErrorMessage = message;
265
+ console.error(`[${this.managerName}] Failed to refresh credentials:`, error);
266
+ return createErrorResult({ message });
267
+ }
268
+ }
269
+ };
270
+
271
+ // src/managers/codex-token-manager.ts
272
+ var CodexAspAuthMethodChangedError = class extends Error {
273
+ name = "CodexAspAuthMethodChangedError";
274
+ };
275
+ var CodexTokenManager = class extends BaseRefreshManager {
276
+ constructor() {
277
+ super("CodexTokenManager");
278
+ }
279
+ getSkipReason() {
280
+ if (!ENGINE_ENV.REPLICAS_CODEX_AUTH_METHOD && ENGINE_ENV.OPENAI_API_KEY) {
281
+ return "OPENAI_API_KEY is set";
282
+ }
283
+ return null;
284
+ }
285
+ async doRefresh(_config) {
286
+ await this.refreshWithRequest(routineRefreshRequest("codex"), true);
287
+ }
288
+ async switchCredentials(data) {
289
+ await this.applyCredentialUpdate(++this.credentialGeneration, async () => {
290
+ await this.applyCredentialsResponse(data);
291
+ setAgentCredentialSnapshot("codex", data);
292
+ const { restartCodexAspHostIfRunning: restartCodexAspHostIfRunning2 } = await import("./asp-host-NQA54PMC.js");
293
+ await restartCodexAspHostIfRunning2();
294
+ });
295
+ await this.start(true);
296
+ }
297
+ async refreshWithRequest(request, restartOnChange = false) {
298
+ const generation = this.credentialGeneration;
299
+ const previousEnv = CODEX_AUTH_ENV_KEYS.map((key) => process.env[key]);
300
+ console.log("[CodexTokenManager] Refreshing Codex credentials...");
301
+ const response = await monolithRequest("/v1/engine/codex/refresh-credentials", {
302
+ body: { supportsOpenAICompatibleAuth: true, ...request }
303
+ });
304
+ if (!response.ok) {
305
+ const errorText = await response.text();
306
+ throw new Error(`Credentials refresh failed: ${response.status} ${errorText}`);
307
+ }
308
+ const data = await response.json();
309
+ await this.applyCredentialUpdate(generation, async () => {
310
+ const accountChanged = ENGINE_ENV.REPLICAS_AGENT_CREDENTIALS.codex?.credentialId !== data.credentialId;
311
+ await this.applyCredentialsResponse(data);
312
+ if (restartOnChange && (accountChanged || CODEX_AUTH_ENV_KEYS.some((key, index) => process.env[key] !== previousEnv[index]))) {
313
+ const { restartCodexAspHostIfRunning: restartCodexAspHostIfRunning2 } = await import("./asp-host-NQA54PMC.js");
314
+ await restartCodexAspHostIfRunning2();
315
+ }
316
+ setAgentCredentialSnapshot("codex", data);
317
+ });
318
+ console.log(`[CodexTokenManager] Credentials refreshed (method=${data.type})`);
319
+ return data;
320
+ }
321
+ async prepareAspOauthOptions() {
322
+ if (ENGINE_ENV.REPLICAS_CODEX_AUTH_METHOD !== "oauth") return {};
323
+ const response = await this.refreshWithRequest(routineRefreshRequest("codex"));
324
+ if (response.type !== "oauth") return {};
325
+ const login = this.buildAspOauthLogin(response);
326
+ let credential = login.credential;
327
+ return {
328
+ chatgptAuthTokens: login.tokens,
329
+ refreshChatgptAuthTokens: async (params) => {
330
+ const refreshed = await this.refreshAspOauthCredentials(
331
+ credential,
332
+ `Codex ASP requested an external token refresh (${params.reason})`
333
+ );
334
+ if (!refreshed.ok) {
335
+ if (refreshed.error.code === "method_changed") {
336
+ throw new CodexAspAuthMethodChangedError(refreshed.error.message);
337
+ }
338
+ throw new Error(refreshed.error.message);
339
+ }
340
+ credential = refreshed.data.credential;
341
+ return refreshed.data.tokens;
342
+ }
343
+ };
344
+ }
345
+ async refreshAspOauthCredentials(failedCredential, failureReason) {
346
+ try {
347
+ const response = await this.refreshWithRequest({
348
+ failedMethod: "oauth",
349
+ ...failedCredential?.method === "oauth" ? { failedCredential } : {},
350
+ failureKind: "rejected",
351
+ failureReason
352
+ });
353
+ if (response.type !== "oauth") {
354
+ return createErrorResult({
355
+ message: `${failureReason}; credentials changed to ${response.type}, so the app server must restart`,
356
+ code: "method_changed"
357
+ });
358
+ }
359
+ return createSuccessResult(this.buildAspOauthLogin(response));
360
+ } catch (error) {
361
+ return credentialSwapError(error);
362
+ }
363
+ }
364
+ async fetchFreshCredentials(failureReason, failureKind = "rejected", failedCredential = ENGINE_ENV.REPLICAS_AGENT_CREDENTIALS.codex, allowedMethods) {
365
+ const failedMethod = failedCredential?.method === "ollama" || failedCredential?.method === "oauth" || failedCredential?.method === "api_key" || failedCredential?.method === "openai-compatible" || failedCredential?.method === "foundry" ? failedCredential.method : ENGINE_ENV.REPLICAS_CODEX_AUTH_METHOD;
366
+ return this.swapCredentials({
367
+ provider: "codex",
368
+ failureKind,
369
+ allowedMethods,
370
+ refresh: async (exclusions) => {
371
+ await this.refreshWithRequest(
372
+ failedMethod === "ollama" || failedMethod === "oauth" || failedMethod === "api_key" || failedMethod === "openai-compatible" || failedMethod === "foundry" ? {
373
+ failedMethod,
374
+ ...failedCredential?.method === failedMethod ? { failedCredential } : {},
375
+ failureReason,
376
+ failureKind,
377
+ ...exclusions
378
+ } : exclusions
379
+ );
380
+ },
381
+ isOauthNow: () => ENGINE_ENV.REPLICAS_CODEX_AUTH_METHOD === "oauth"
382
+ });
383
+ }
384
+ async applyCredentialsResponse(response) {
385
+ await this.removeOauthCredentialsFile();
386
+ const envVars = codexAuthEnvFromResponse(response);
387
+ applyAuthEnvTransition({
388
+ prevMethod: ENGINE_ENV.REPLICAS_CODEX_AUTH_METHOD ?? "none",
389
+ authKeys: CODEX_AUTH_ENV_KEYS,
390
+ authKeysByMethod: CODEX_AUTH_ENV_KEYS_BY_METHOD,
391
+ newEnvVars: envVars,
392
+ envs: [ENGINE_ENV, process.env]
393
+ });
394
+ if (response.type === "api_key") {
395
+ for (const key of CODEX_AUTH_ENV_KEYS_BY_METHOD.api_key) {
396
+ if (envVars[key] !== void 0) continue;
397
+ delete ENGINE_ENV[key];
398
+ delete process.env[key];
399
+ }
400
+ }
401
+ }
402
+ buildAspOauthLogin(response) {
403
+ const credential = ENGINE_ENV.REPLICAS_AGENT_CREDENTIALS.codex;
404
+ return {
405
+ tokens: {
406
+ accessToken: response.accessToken,
407
+ chatgptAccountId: response.accountId,
408
+ chatgptPlanType: null
409
+ },
410
+ ...credential?.method === "oauth" ? { credential } : {}
411
+ };
412
+ }
413
+ async removeOauthCredentialsFile() {
414
+ const authPath = path.join(ENGINE_ENV.HOME_DIR, ".codex", "auth.json");
415
+ try {
416
+ await fs.unlink(authPath);
417
+ } catch (error) {
418
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
419
+ }
420
+ }
421
+ };
422
+ var codexTokenManager = new CodexTokenManager();
423
+
424
+ // src/managers/codex-asp/mappers.ts
425
+ import { existsSync as existsSync3, readFileSync } from "fs";
426
+
427
+ // src/git/service.ts
428
+ import { readdir, readFile as readFile3, stat } from "fs/promises";
429
+ import { spawn } from "child_process";
430
+ import { isAbsolute, join as join3 } from "path";
431
+
432
+ // src/utils/state.ts
433
+ import { readFile as readFile2, mkdir as mkdir2 } from "fs/promises";
434
+ import { existsSync } from "fs";
435
+ import { join as join2 } from "path";
436
+ import { homedir } from "os";
437
+
438
+ // src/utils/file.ts
439
+ import { randomUUID } from "crypto";
440
+ import { mkdir, readFile, rename, unlink, writeFile } from "fs/promises";
441
+ import { dirname, join } from "path";
442
+
443
+ // src/utils/async-lock.ts
444
+ var AsyncLock = class {
445
+ chain = Promise.resolve();
446
+ run(operation) {
447
+ const result = this.chain.then(operation, operation);
448
+ this.chain = result.then(() => void 0, () => void 0);
449
+ return result;
450
+ }
451
+ async drain() {
452
+ await this.chain;
453
+ }
454
+ };
455
+
456
+ // src/utils/file.ts
457
+ async function atomicWriteFile(path2, data, options) {
458
+ const tmpFile = `${path2}.${process.pid}.${randomUUID()}.tmp`;
459
+ try {
460
+ await writeFile(tmpFile, data, { encoding: "utf-8", mode: options?.mode });
461
+ await rename(tmpFile, path2);
462
+ } catch (error) {
463
+ await unlink(tmpFile).catch(() => void 0);
464
+ throw error;
465
+ }
466
+ }
467
+ async function writeSecureCredentialFile(filePath, content, options) {
468
+ if (options?.ensureParentDir) {
469
+ await mkdir(dirname(filePath), { recursive: true, mode: 448 });
470
+ }
471
+ await atomicWriteFile(filePath, content, { mode: 384 });
472
+ }
473
+ async function configureManagedGitCredentialHelper(homeDir) {
474
+ const credentialsPath = join(homeDir, ".git-credentials");
475
+ const helperPath = join(homeDir, GIT_CREDENTIAL_HELPER_FILENAME);
476
+ await atomicWriteFile(helperPath, buildGitCredentialHelperScript(credentialsPath), { mode: 448 });
477
+ await execFileAsync("git", ["config", "--global", "credential.helper", helperPath], {
478
+ env: { ...process.env, HOME: homeDir }
479
+ });
480
+ await execFileAsync("git", ["config", "--global", "credential.https://github.com.useHttpPath", "true"], {
481
+ env: { ...process.env, HOME: homeDir }
482
+ });
483
+ }
484
+ var credentialFileLock = new AsyncLock();
485
+ function upsertCredentialFileLines(filePath, hosts, lines) {
486
+ return credentialFileLock.run(async () => {
487
+ let existing = [];
488
+ try {
489
+ existing = (await readFile(filePath, "utf-8")).split("\n").filter(Boolean);
490
+ } catch {
491
+ existing = [];
492
+ }
493
+ const kept = existing.filter((line) => !hosts.some((host) => line.endsWith(`@${host}`) || line.includes(`@${host}/`)));
494
+ await writeSecureCredentialFile(filePath, [...lines, ...kept, ""].join("\n"));
495
+ });
496
+ }
497
+ function removeCredentialFileLines(filePath, shouldRemove) {
498
+ return credentialFileLock.run(async () => {
499
+ let existing = [];
500
+ try {
501
+ existing = (await readFile(filePath, "utf-8")).split("\n").filter(Boolean);
502
+ } catch {
503
+ return;
504
+ }
505
+ const kept = existing.filter((line) => !shouldRemove(line));
506
+ await writeSecureCredentialFile(filePath, [...kept, ""].join("\n"));
507
+ });
508
+ }
509
+
510
+ // src/utils/state.ts
511
+ var STATE_DIR = join2(homedir(), ".replicas");
512
+ var STATE_FILE = join2(STATE_DIR, "engine-state.json");
513
+ var DEFAULT_STATE = {
514
+ repos: {},
515
+ removedRepos: {}
516
+ };
517
+ var stateWriteLock = new AsyncLock();
518
+ async function updateEngineState(updater) {
519
+ return stateWriteLock.run(async () => {
520
+ await mkdir2(STATE_DIR, { recursive: true });
521
+ const currentState = await loadEngineState();
522
+ const nextState = updater(currentState);
523
+ await atomicWriteFile(STATE_FILE, JSON.stringify(nextState, null, 2));
524
+ return nextState;
525
+ });
526
+ }
527
+ function isEngineRepoDiff(value) {
528
+ return isRecord(value) && typeof value.added === "number" && typeof value.removed === "number";
529
+ }
530
+ function coerceRepoState(value) {
531
+ if (!isRecord(value)) {
532
+ return null;
533
+ }
534
+ if (typeof value.name !== "string") return null;
535
+ if (typeof value.path !== "string") return null;
536
+ if (typeof value.defaultBranch !== "string") return null;
537
+ if (typeof value.currentBranch !== "string") return null;
538
+ if (!Array.isArray(value.prUrls)) return null;
539
+ const prUrls = [];
540
+ for (const entry of value.prUrls) {
541
+ if (typeof entry !== "string") return null;
542
+ if (!prUrls.includes(entry)) prUrls.push(entry);
543
+ }
544
+ if (!(value.gitDiff === null || isEngineRepoDiff(value.gitDiff))) return null;
545
+ if (typeof value.startHooksCompleted !== "boolean") return null;
546
+ return {
547
+ name: value.name,
548
+ path: value.path,
549
+ defaultBranch: value.defaultBranch,
550
+ currentBranch: value.currentBranch,
551
+ prUrls,
552
+ gitDiff: value.gitDiff,
553
+ startHooksCompleted: value.startHooksCompleted
554
+ };
555
+ }
556
+ function coerceEngineState(value) {
557
+ if (!isRecord(value)) {
558
+ return {};
559
+ }
560
+ const partial = {};
561
+ for (const key of ["repos", "removedRepos"]) {
562
+ const entries = value[key];
563
+ if (!isRecord(entries)) continue;
564
+ const repos = {};
565
+ for (const [repoName, repoState] of Object.entries(entries)) {
566
+ const coerced = coerceRepoState(repoState);
567
+ if (coerced) {
568
+ repos[repoName] = coerced;
569
+ }
570
+ }
571
+ partial[key] = repos;
572
+ }
573
+ return partial;
574
+ }
575
+ async function loadEngineState() {
576
+ try {
577
+ if (!existsSync(STATE_FILE)) {
578
+ return { ...DEFAULT_STATE };
579
+ }
580
+ const content = await readFile2(STATE_FILE, "utf-8");
581
+ const state = coerceEngineState(JSON.parse(content));
582
+ return {
583
+ ...DEFAULT_STATE,
584
+ ...state,
585
+ repos: state.repos ?? {},
586
+ removedRepos: state.removedRepos ?? {}
587
+ };
588
+ } catch (error) {
589
+ console.error("[EngineState] Failed to load state, using defaults:", error);
590
+ return { ...DEFAULT_STATE };
591
+ }
592
+ }
593
+ async function loadRepoState(repoName) {
594
+ const state = await loadEngineState();
595
+ return state.repos[repoName] ?? null;
596
+ }
597
+ async function saveRepoState(repoName, state, fallbackState) {
598
+ const updated = await updateEngineState((currentState) => {
599
+ const currentRepoState = currentState.repos[repoName] ?? fallbackState;
600
+ return {
601
+ ...currentState,
602
+ repos: {
603
+ ...currentState.repos,
604
+ [repoName]: {
605
+ ...currentRepoState,
606
+ ...state,
607
+ // Publication events and repository refreshes can finish in either order.
608
+ prUrls: [.../* @__PURE__ */ new Set([...currentRepoState.prUrls, ...state.prUrls ?? []])]
609
+ }
610
+ }
611
+ };
612
+ });
613
+ return updated.repos[repoName];
614
+ }
615
+ async function markRepoRemoved(repoName) {
616
+ await updateEngineState((currentState) => {
617
+ const { [repoName]: removed, ...repos } = currentState.repos;
618
+ if (!removed) return currentState;
619
+ return { ...currentState, repos, removedRepos: { ...currentState.removedRepos, [repoName]: removed } };
620
+ });
621
+ }
622
+
623
+ // src/git/service.ts
624
+ var FULL_DIFF_MAX_BUFFER = 50 * 1024 * 1024;
625
+ var MAX_DIFF_FILE_BYTES = 1024 * 1024;
626
+ var MAX_UNTRACKED_DIFF_BYTES = 10 * 1024 * 1024;
627
+ var UNTRACKED_STAT_CONCURRENCY = 64;
628
+ var BIG_FILE_THRESHOLD_CONFIG = ["-c", `core.bigFileThreshold=${MAX_DIFF_FILE_BYTES}`];
629
+ function appendUniqueUrl(urls, url) {
630
+ return urls.includes(url) ? urls : [...urls, url];
631
+ }
632
+ var GitService = class {
633
+ defaultBranchCache = /* @__PURE__ */ new Map();
634
+ cachedPrByRepo = /* @__PURE__ */ new Map();
635
+ // An 'unknown' result usually means the origin isn't wired up yet during provisioning, so caching it would permanently mask a remote added moments later.
636
+ originInfoCache = /* @__PURE__ */ new Map();
637
+ // A scratch path can be re-cloned from another repository between two polls, which only its origin reveals.
638
+ trackedCloneOrigins = /* @__PURE__ */ new Map();
639
+ // Broadcaster + UI /repos requests fan in here every ~2s; one shared run.
640
+ listReposInFlight = new InFlightMap();
641
+ diffStatsInFlight = new InFlightMap();
642
+ fullDiffInFlight = new InFlightMap();
643
+ getWorkspaceRoot() {
644
+ return ENGINE_ENV.WORKSPACE_ROOT;
645
+ }
646
+ async listRepositories() {
647
+ return (await this.listRepositoriesWithCompleteness()).repos;
648
+ }
649
+ async listRepositoriesWithCompleteness() {
650
+ const root = this.getWorkspaceRoot();
651
+ const rootStat = await this.safeStat(root);
652
+ if (!rootStat?.isDirectory()) {
653
+ return { repos: [], complete: false };
654
+ }
655
+ const entries = await readdir(root);
656
+ const repos = [];
657
+ let complete = true;
658
+ for (const entry of entries) {
659
+ const fullPath = join3(root, entry);
660
+ try {
661
+ const entryStat = await stat(fullPath);
662
+ if (!entryStat.isDirectory()) {
663
+ continue;
664
+ }
665
+ const hasGit = Boolean(await this.safeStat(join3(fullPath, ".git")));
666
+ if (!hasGit) {
667
+ continue;
668
+ }
669
+ repos.push({
670
+ name: entry,
671
+ path: fullPath,
672
+ defaultBranch: await this.resolveDefaultBranch(fullPath)
673
+ });
674
+ } catch {
675
+ complete = false;
676
+ }
677
+ }
678
+ return { repos: repos.sort((a, b) => a.name.localeCompare(b.name)), complete };
679
+ }
680
+ async listTrackedRepositories() {
681
+ const [discovered, state] = await Promise.all([this.listRepositoriesWithCompleteness(), loadEngineState()]);
682
+ const repos = [...discovered.repos];
683
+ const removed = new Map(Object.entries(state.removedRepos));
684
+ for (const repo of Object.values(state.repos)) {
685
+ if (!isAbsolute(repo.name)) continue;
686
+ const exists = Boolean(await this.safeStat(join3(repo.path, ".git")));
687
+ const origin = exists ? (await readFile3(join3(repo.path, ".git", "config"), "utf-8").catch(() => "")).match(/\[remote "origin"\][^[]*?\burl\s*=\s*(\S+)/)?.[1] ?? null : null;
688
+ const known = this.trackedCloneOrigins.has(repo.path) ? this.trackedCloneOrigins.get(repo.path) : origin;
689
+ if (exists && origin === known) {
690
+ this.trackedCloneOrigins.set(repo.path, origin);
691
+ repos.push({ name: repo.name, path: repo.path, defaultBranch: await this.resolveDefaultBranch(repo.path) });
692
+ } else {
693
+ this.trackedCloneOrigins.delete(repo.path);
694
+ this.defaultBranchCache.delete(repo.path);
695
+ this.originInfoCache.delete(repo.path);
696
+ this.cachedPrByRepo.delete(repo.name);
697
+ await markRepoRemoved(repo.name);
698
+ removed.set(repo.name, repo);
699
+ }
700
+ }
701
+ const deleted = [...removed.values()].filter((repo) => !repos.some((live) => live.name === repo.name)).map((repo) => ({ ...repo, gitDiff: { added: 0, removed: 0 } }));
702
+ return { repos, deleted, complete: discovered.complete };
703
+ }
704
+ async findTrackedRepository(name) {
705
+ return (await this.listTrackedRepositories()).repos.find((repo) => repo.name === name);
706
+ }
707
+ async trackPushedRepository(worktree) {
708
+ const commonDir = await runGitCommand(["rev-parse", "--path-format=absolute", "--git-common-dir"], worktree).catch(() => null);
709
+ if (!commonDir) return false;
710
+ const { repos } = await this.listTrackedRepositories();
711
+ if (repos.some((repo) => repo.path === worktree || join3(repo.path, ".git") === commonDir)) return false;
712
+ const defaultBranch = await this.resolveDefaultBranch(worktree);
713
+ await saveRepoState(worktree, {}, {
714
+ name: worktree,
715
+ path: worktree,
716
+ defaultBranch,
717
+ currentBranch: await getCurrentBranch(worktree) ?? defaultBranch,
718
+ prUrls: [],
719
+ gitDiff: null,
720
+ startHooksCompleted: true
721
+ });
722
+ return true;
723
+ }
724
+ async listRepos(options) {
725
+ return (await this.listReposWithCompleteness(options)).repos;
726
+ }
727
+ async listReposWithCompleteness(options) {
728
+ const includeDiffs = options?.includeDiffs === true;
729
+ const key = includeDiffs ? "diffs" : "base";
730
+ return this.listReposInFlight.run(key, () => this.computeListRepos(includeDiffs));
731
+ }
732
+ async computeListRepos(includeDiffs) {
733
+ const discovered = await this.listTrackedRepositories();
734
+ const states = [];
735
+ let complete = discovered.complete;
736
+ for (const repo of discovered.repos) {
737
+ try {
738
+ const [persistedState, currentBranchRaw, gitDiff, codeHost] = await Promise.all([
739
+ loadRepoState(repo.name),
740
+ getCurrentBranch(repo.path),
741
+ this.getGitDiffStats(repo.path, repo.defaultBranch),
742
+ this.resolveCodeHost(repo.path)
743
+ ]);
744
+ const currentBranch = currentBranchRaw ?? repo.defaultBranch;
745
+ const fullDiff = includeDiffs && gitDiff ? await this.getFullGitDiff(repo.path, repo.defaultBranch) : void 0;
746
+ if (gitDiff === null) complete = false;
747
+ if (fullDiff === null) complete = false;
748
+ states.push({
749
+ name: repo.name,
750
+ path: repo.path,
751
+ defaultBranch: repo.defaultBranch,
752
+ currentBranch,
753
+ prUrls: persistedState?.prUrls ?? [],
754
+ gitDiff: includeDiffs && gitDiff ? { ...gitDiff, ...fullDiff === null ? {} : { fullDiff } } : gitDiff,
755
+ startHooksCompleted: persistedState?.startHooksCompleted ?? false,
756
+ ...codeHost
757
+ });
758
+ } catch {
759
+ complete = false;
760
+ }
761
+ }
762
+ return { repos: [...states, ...discovered.deleted], complete };
763
+ }
764
+ async refreshRepos(observedBranchesByRepo, options) {
765
+ return (await this.refreshReposWithCompleteness(observedBranchesByRepo, options)).repos;
766
+ }
767
+ async refreshReposWithCompleteness(observedBranchesByRepo, options) {
768
+ const discovered = await this.listTrackedRepositories();
769
+ const states = [];
770
+ let complete = discovered.complete;
771
+ for (const repo of discovered.repos) {
772
+ try {
773
+ const persistedState = await loadRepoState(repo.name);
774
+ const currentBranch = await getCurrentBranch(repo.path) ?? repo.defaultBranch;
775
+ const startHooksCompleted = persistedState?.startHooksCompleted ?? false;
776
+ const observed = /* @__PURE__ */ new Set([
777
+ ...readRepoHeadBranches(repo.path),
778
+ ...observedBranchesByRepo?.get(repo.name) ?? []
779
+ ]);
780
+ const state = await this.refreshRepoMetadata(
781
+ repo,
782
+ currentBranch,
783
+ startHooksCompleted,
784
+ persistedState,
785
+ observed,
786
+ options?.includeDiffs === true
787
+ );
788
+ if (state.gitDiff === null || options?.includeDiffs === true && state.gitDiff.fullDiff === void 0) {
789
+ complete = false;
790
+ }
791
+ states.push(state);
792
+ } catch {
793
+ complete = false;
794
+ }
795
+ }
796
+ return { repos: [...states, ...discovered.deleted], complete };
797
+ }
798
+ async snapshotCurrentBranches() {
799
+ const { repos } = await this.listTrackedRepositories();
800
+ return new Map(repos.map((repo) => [repo.name, readRepoHeadBranches(repo.path)]));
801
+ }
802
+ async recordPublishedPullRequest(prUrl) {
803
+ if (!URL.canParse(prUrl)) return false;
804
+ const parsed = parseCodeHostPrUrl(prUrl);
805
+ if (!parsed || !Number.isSafeInteger(parsed.number) || parsed.number <= 0) return false;
806
+ const url = new URL(prUrl);
807
+ if (url.host.toLowerCase() !== parsed.host || !["https:", "http:"].includes(url.protocol)) return false;
808
+ const canonicalUrl = buildCodeHostPrUrl(parsed.repoUrl, parsed.provider, parsed.number);
809
+ const { repos } = await this.listTrackedRepositories();
810
+ for (const repo of repos) {
811
+ const remote = parseGitRemote(await runGitCommand(["remote", "get-url", "origin"], repo.path).catch(() => ""));
812
+ if (!remote || remote.host !== parsed.host) continue;
813
+ const sameRepository = parsed.provider === "github" ? remote.path.toLowerCase() === parsed.repositoryPath.toLowerCase() : remote.path === parsed.repositoryPath;
814
+ if (!sameRepository) continue;
815
+ await saveRepoState(repo.name, { prUrls: [canonicalUrl] }, {
816
+ ...repo,
817
+ currentBranch: await getCurrentBranch(repo.path) ?? repo.defaultBranch,
818
+ prUrls: [],
819
+ gitDiff: null,
820
+ startHooksCompleted: false
821
+ });
822
+ return true;
823
+ }
824
+ return false;
825
+ }
826
+ getGitDiffStats(repoPath, defaultBranch) {
827
+ return this.diffStatsInFlight.run(repoPath, async () => {
828
+ try {
829
+ const diffBase = await resolveDiffBase(repoPath, defaultBranch);
830
+ const shortstat = await runGitCommand([...BIG_FILE_THRESHOLD_CONFIG, "diff", diffBase, "--shortstat", "-M"], repoPath);
831
+ let added = 0;
832
+ let removed = 0;
833
+ const addedMatch = shortstat.match(/(\d+) insertion/);
834
+ const removedMatch = shortstat.match(/(\d+) deletion/);
835
+ if (addedMatch) {
836
+ added = parseInt(addedMatch[1], 10);
837
+ }
838
+ if (removedMatch) {
839
+ removed = parseInt(removedMatch[1], 10);
840
+ }
841
+ added += await this.countUntrackedAddedLines(repoPath);
842
+ return {
843
+ added,
844
+ removed
845
+ };
846
+ } catch (error) {
847
+ console.warn(`[GitService] getGitDiffStats failed for ${repoPath} (defaultBranch=${defaultBranch}):`, error instanceof Error ? error.message : error);
848
+ return null;
849
+ }
850
+ });
851
+ }
852
+ async listUntrackedFiles(repoPath) {
853
+ const { stdout } = await execFileAsync("git", ["ls-files", "--others", "--exclude-standard", "-z"], {
854
+ cwd: repoPath,
855
+ encoding: "utf-8",
856
+ maxBuffer: SUBPROCESS_MAX_BUFFER
857
+ });
858
+ const paths = stdout.split("\0").filter(Boolean);
859
+ const files = [];
860
+ for (let i = 0; i < paths.length; i += UNTRACKED_STAT_CONCURRENCY) {
861
+ const batch = await Promise.all(paths.slice(i, i + UNTRACKED_STAT_CONCURRENCY).map(async (path2) => {
862
+ try {
863
+ return { path: path2, size: (await stat(join3(repoPath, path2))).size };
864
+ } catch {
865
+ return null;
866
+ }
867
+ }));
868
+ files.push(...batch.filter((file) => file !== null));
869
+ }
870
+ let remainingBytes = MAX_UNTRACKED_DIFF_BYTES;
871
+ return files.map(({ path: path2, size }) => {
872
+ const tooLarge = size > MAX_DIFF_FILE_BYTES || size > remainingBytes;
873
+ if (!tooLarge) remainingBytes -= size;
874
+ return { path: path2, tooLarge };
875
+ });
876
+ }
877
+ async getUntrackedDiff(repoPath) {
878
+ const files = await this.listUntrackedFiles(repoPath);
879
+ const largeFilesDiff = files.filter((file) => file.tooLarge).map(({ path: path2 }) => {
880
+ const quote = (prefixed) => /[\x00-\x1f"\\]/.test(prefixed) ? JSON.stringify(prefixed) : prefixed;
881
+ return `diff --git ${quote(`a/${path2}`)} ${quote(`b/${path2}`)}
882
+ new file mode 100644
883
+ Binary files /dev/null and ${quote(`b/${path2}`)} differ
884
+ `;
885
+ }).join("");
886
+ const paths = files.filter((file) => !file.tooLarge).map((file) => file.path);
887
+ if (paths.length === 0) return largeFilesDiff;
888
+ try {
889
+ await execFileAsync("git", ["add", "--intent-to-add", "--", ...paths], {
890
+ cwd: repoPath,
891
+ maxBuffer: SUBPROCESS_MAX_BUFFER
892
+ });
893
+ return await this.readUntrackedDiff(repoPath, paths) + largeFilesDiff;
894
+ } finally {
895
+ try {
896
+ await execFileAsync("git", ["reset", "--", ...paths], {
897
+ cwd: repoPath,
898
+ maxBuffer: SUBPROCESS_MAX_BUFFER
899
+ });
900
+ } catch {
901
+ }
902
+ }
903
+ }
904
+ readUntrackedDiff(repoPath, paths) {
905
+ return new Promise((resolve) => {
906
+ const child = spawn("git", ["diff", "--", ...paths], {
907
+ cwd: repoPath,
908
+ stdio: ["ignore", "pipe", "pipe"]
909
+ });
910
+ const chunks = [];
911
+ let total = 0;
912
+ let truncated = false;
913
+ child.stdout.on("data", (chunk) => {
914
+ if (truncated) return;
915
+ total += chunk.length;
916
+ if (total > FULL_DIFF_MAX_BUFFER) {
917
+ truncated = true;
918
+ chunks.push(chunk.subarray(0, Math.max(0, FULL_DIFF_MAX_BUFFER - (total - chunk.length))));
919
+ child.kill("SIGTERM");
920
+ return;
921
+ }
922
+ chunks.push(chunk);
923
+ });
924
+ child.stderr.on("data", () => {
925
+ });
926
+ child.on("error", () => resolve(""));
927
+ child.on("close", (code) => {
928
+ if (code === 0 || code === 1 || truncated) {
929
+ resolve(Buffer.concat(chunks).toString("utf-8"));
930
+ } else {
931
+ resolve("");
932
+ }
933
+ });
934
+ });
935
+ }
936
+ async countUntrackedAddedLines(repoPath) {
937
+ try {
938
+ const files = await this.listUntrackedFiles(repoPath);
939
+ let total = 0;
940
+ for (const { path: path2, tooLarge } of files) {
941
+ if (tooLarge) continue;
942
+ try {
943
+ const contents = await readFile3(join3(repoPath, path2));
944
+ if (contents.length === 0 || contents.includes(0)) {
945
+ continue;
946
+ }
947
+ let lines = 0;
948
+ for (let index = contents.indexOf(10); index !== -1; index = contents.indexOf(10, index + 1)) {
949
+ lines += 1;
950
+ }
951
+ total += lines + (contents[contents.length - 1] === 10 ? 0 : 1);
952
+ } catch {
953
+ }
954
+ }
955
+ return total;
956
+ } catch (error) {
957
+ console.error("Error counting untracked added lines:", error);
958
+ return 0;
959
+ }
960
+ }
961
+ getFullGitDiff(repoPath, defaultBranch) {
962
+ return this.fullDiffInFlight.run(repoPath, async () => {
963
+ try {
964
+ const diffBase = await resolveDiffBase(repoPath, defaultBranch);
965
+ const { stdout: trackedDiff } = await execFileAsync("git", [...BIG_FILE_THRESHOLD_CONFIG, "diff", diffBase, "-M", "-C"], {
966
+ cwd: repoPath,
967
+ encoding: "utf-8",
968
+ maxBuffer: FULL_DIFF_MAX_BUFFER
969
+ });
970
+ const untrackedDiff = await this.getUntrackedAsDiff(repoPath);
971
+ return trackedDiff + untrackedDiff;
972
+ } catch {
973
+ return null;
974
+ }
975
+ });
976
+ }
977
+ async getUntrackedAsDiff(repoPath) {
978
+ try {
979
+ return await this.getUntrackedDiff(repoPath);
980
+ } catch (error) {
981
+ console.error("Error building untracked diff:", error);
982
+ return "";
983
+ }
984
+ }
985
+ async getPullRequestUrl(repoName, repoPath, currentBranchArg, persistedRepoStateArg) {
986
+ try {
987
+ const currentBranch = currentBranchArg ?? await getCurrentBranch(repoPath);
988
+ if (!currentBranch) {
989
+ return { status: "not_found" };
990
+ }
991
+ const cachedPr = this.cachedPrByRepo.get(repoName);
992
+ if (cachedPr && cachedPr.currentBranch === currentBranch) {
993
+ return { status: "found", url: cachedPr.prUrl };
994
+ }
995
+ const persistedRepoState = persistedRepoStateArg ?? await loadRepoState(repoName);
996
+ this.cachedPrByRepo.delete(repoName);
997
+ const result = await this.lookupPrOnRemote(repoName, repoPath, currentBranch);
998
+ if (result.status === "found") {
999
+ this.cachedPrByRepo.set(repoName, { prUrl: result.url, currentBranch });
1000
+ if (persistedRepoState && !persistedRepoState.prUrls.includes(result.url)) {
1001
+ await saveRepoState(
1002
+ repoName,
1003
+ { prUrls: appendUniqueUrl(persistedRepoState.prUrls, result.url) },
1004
+ persistedRepoState
1005
+ );
1006
+ }
1007
+ }
1008
+ return result;
1009
+ } catch (error) {
1010
+ console.error("Error checking for pull request:", error);
1011
+ return { status: "error" };
1012
+ }
1013
+ }
1014
+ async lookupPrOnRemote(repoName, repoPath, branch) {
1015
+ const origin = await this.getOriginInfo(repoPath);
1016
+ if (origin.provider === "unknown") {
1017
+ return { status: "not_found" };
1018
+ }
1019
+ try {
1020
+ const { stdout } = await execFileAsync("git", ["ls-remote", "--heads", "origin", branch], {
1021
+ cwd: repoPath,
1022
+ encoding: "utf-8",
1023
+ maxBuffer: SUBPROCESS_MAX_BUFFER
1024
+ });
1025
+ if (!stdout.trim()) {
1026
+ return { status: "not_found" };
1027
+ }
1028
+ } catch {
1029
+ return { status: "error" };
1030
+ }
1031
+ if (origin.provider === "gitlab") {
1032
+ return this.lookupGitLabMrOnRemote(repoName, origin.gitLab, branch);
1033
+ }
1034
+ try {
1035
+ const { stdout } = await execFileAsync("gh", ["pr", "view", branch, "--json", "url", "--jq", ".url"], {
1036
+ cwd: repoPath,
1037
+ encoding: "utf-8",
1038
+ maxBuffer: SUBPROCESS_MAX_BUFFER
1039
+ });
1040
+ const prInfo = stdout.trim();
1041
+ return prInfo ? { status: "found", url: prInfo } : { status: "not_found" };
1042
+ } catch (error) {
1043
+ const message = error instanceof Error ? error.message : String(error);
1044
+ console.warn(`[GitService] gh pr view ${branch} failed for ${repoName}: ${message}`);
1045
+ return { status: "error" };
1046
+ }
1047
+ }
1048
+ async lookupGitLabMrOnRemote(repoName, remote, branch) {
1049
+ const token = await this.getGitLabAccessToken(remote.host);
1050
+ if (!token) {
1051
+ return { status: "not_found" };
1052
+ }
1053
+ const url = `${remote.instanceUrl}/api/v4/projects/${encodeURIComponent(remote.projectPath)}/merge_requests?source_branch=${encodeURIComponent(branch)}&order_by=updated_at`;
1054
+ try {
1055
+ const response = await fetch(url, {
1056
+ headers: { Authorization: `Bearer ${token}` },
1057
+ signal: AbortSignal.timeout(1e4)
1058
+ });
1059
+ if (!response.ok) {
1060
+ console.warn(`[GitService] GitLab MR lookup failed for ${repoName}: ${response.status}`);
1061
+ return response.status === 404 ? { status: "not_found" } : { status: "error" };
1062
+ }
1063
+ const data = await response.json();
1064
+ if (!Array.isArray(data)) return { status: "not_found" };
1065
+ const branchMatches = data.filter((item) => isRecord(item) && item.source_branch === branch);
1066
+ const mergeRequest = branchMatches.find((item) => item.state === "opened") ?? branchMatches[0];
1067
+ if (!isRecord(mergeRequest)) return { status: "not_found" };
1068
+ const webUrl = mergeRequest.web_url;
1069
+ if (typeof webUrl === "string" && webUrl.length > 0) return { status: "found", url: webUrl };
1070
+ const iid = mergeRequest.iid;
1071
+ return typeof iid === "number" ? { status: "found", url: `${remote.instanceUrl}/${remote.projectPath}/-/merge_requests/${iid}` } : { status: "not_found" };
1072
+ } catch (error) {
1073
+ const message = error instanceof Error ? error.message : String(error);
1074
+ console.warn(`[GitService] GitLab MR lookup failed for ${repoName}: ${message}`);
1075
+ return { status: "error" };
1076
+ }
1077
+ }
1078
+ async resolveCodeHost(repoPath) {
1079
+ const origin = await this.getOriginInfo(repoPath);
1080
+ if (origin.provider === "unknown") return {};
1081
+ if (origin.provider === "github") return { provider: origin.provider };
1082
+ return {
1083
+ provider: origin.provider,
1084
+ repositoryUrl: `${origin.gitLab.instanceUrl}/${origin.gitLab.projectPath}`
1085
+ };
1086
+ }
1087
+ async getRepositoryOriginHost(repoPath, refresh = false) {
1088
+ if (refresh) this.originInfoCache.delete(repoPath);
1089
+ const origin = await this.getOriginInfo(repoPath);
1090
+ if (origin.provider === "github") return "github.com";
1091
+ if (origin.provider === "gitlab") return origin.gitLab.host;
1092
+ return null;
1093
+ }
1094
+ async getOriginInfo(repoPath) {
1095
+ const cached = this.originInfoCache.get(repoPath);
1096
+ if (cached !== void 0) {
1097
+ return cached;
1098
+ }
1099
+ let info = { provider: "unknown" };
1100
+ try {
1101
+ const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], {
1102
+ cwd: repoPath,
1103
+ encoding: "utf-8",
1104
+ maxBuffer: SUBPROCESS_MAX_BUFFER
1105
+ });
1106
+ const originUrl = stdout.trim();
1107
+ if (isGitHubUrl(originUrl)) {
1108
+ info = { provider: "github" };
1109
+ } else {
1110
+ const gitLabRemote = this.parseGitLabRemote(originUrl);
1111
+ if (gitLabRemote) info = { provider: "gitlab", gitLab: gitLabRemote };
1112
+ }
1113
+ } catch {
1114
+ info = { provider: "unknown" };
1115
+ }
1116
+ if (info.provider !== "unknown") this.originInfoCache.set(repoPath, info);
1117
+ return info;
1118
+ }
1119
+ parseGitLabRemote(remoteUrl) {
1120
+ const parsed = parseGitRemote(remoteUrl);
1121
+ if (!parsed) return null;
1122
+ const projectPath = decodePathSegments(parsed.path.split("/").filter(Boolean)).join("/");
1123
+ return {
1124
+ host: parsed.host,
1125
+ instanceUrl: `${parsed.protocol === "http" ? "http" : "https"}://${parsed.host}`,
1126
+ projectPath
1127
+ };
1128
+ }
1129
+ async getGitLabAccessToken(host) {
1130
+ try {
1131
+ const credentials = await readFile3(join3(ENGINE_ENV.HOME_DIR, ".git-credentials"), "utf-8");
1132
+ for (const line of credentials.split("\n")) {
1133
+ const trimmed = line.trim();
1134
+ if (!trimmed) continue;
1135
+ try {
1136
+ const parsed = new URL(trimmed);
1137
+ if (parsed.protocol === "https:" && parsed.host.toLowerCase() === host && parsed.password) {
1138
+ return decodeURIComponent(parsed.password);
1139
+ }
1140
+ } catch {
1141
+ }
1142
+ }
1143
+ } catch {
1144
+ }
1145
+ return null;
1146
+ }
1147
+ async resolveDefaultBranch(repoPath) {
1148
+ const cached = this.defaultBranchCache.get(repoPath);
1149
+ if (cached) {
1150
+ return cached;
1151
+ }
1152
+ const fromSymbolicRef = await getOriginDefaultBranch(repoPath);
1153
+ if (fromSymbolicRef) {
1154
+ this.defaultBranchCache.set(repoPath, fromSymbolicRef);
1155
+ return fromSymbolicRef;
1156
+ }
1157
+ return "main";
1158
+ }
1159
+ async refreshRepoMetadata(repo, currentBranch, startHooksCompleted, persistedState, observedBranches, includeDiffs = false) {
1160
+ const notFound = { status: "not_found" };
1161
+ const prResult = currentBranch === repo.defaultBranch ? notFound : await this.getPullRequestUrl(repo.name, repo.path, currentBranch, persistedState);
1162
+ let prUrls = persistedState?.prUrls ?? [];
1163
+ if (prResult.status === "found") {
1164
+ prUrls = appendUniqueUrl(prUrls, prResult.url);
1165
+ }
1166
+ if (observedBranches) {
1167
+ for (const branch of observedBranches) {
1168
+ if (branch === currentBranch || branch === repo.defaultBranch) continue;
1169
+ const branchResult = await this.lookupPrOnRemote(repo.name, repo.path, branch);
1170
+ if (branchResult.status === "found") {
1171
+ prUrls = appendUniqueUrl(prUrls, branchResult.url);
1172
+ }
1173
+ }
1174
+ }
1175
+ const gitDiff = await this.getGitDiffStats(repo.path, repo.defaultBranch);
1176
+ const fullDiff = includeDiffs && gitDiff ? await this.getFullGitDiff(repo.path, repo.defaultBranch) : null;
1177
+ const state = {
1178
+ name: repo.name,
1179
+ path: repo.path,
1180
+ defaultBranch: repo.defaultBranch,
1181
+ currentBranch,
1182
+ prUrls,
1183
+ gitDiff: includeDiffs && gitDiff ? { ...gitDiff, ...fullDiff === null ? {} : { fullDiff } } : gitDiff,
1184
+ startHooksCompleted,
1185
+ ...await this.resolveCodeHost(repo.path)
1186
+ };
1187
+ return saveRepoState(repo.name, state, state);
1188
+ }
1189
+ async safeStat(path2) {
1190
+ try {
1191
+ return await stat(path2);
1192
+ } catch {
1193
+ return null;
1194
+ }
1195
+ }
1196
+ };
1197
+ var gitService = new GitService();
1198
+
1199
+ // src/utils/agent-additional-directories.ts
1200
+ async function getAgentAdditionalDirectories() {
1201
+ try {
1202
+ const repos = await gitService.listRepositories();
1203
+ return repos.map((repo) => repo.path);
1204
+ } catch {
1205
+ return [];
1206
+ }
1207
+ }
1208
+
1209
+ // src/utils/image-utils.ts
1210
+ import { randomUUID as randomUUID2 } from "crypto";
1211
+ import { mkdir as mkdir3, unlink as unlink2, writeFile as writeFile2 } from "fs/promises";
1212
+ import { homedir as homedir2 } from "os";
1213
+ import { join as join4 } from "path";
1214
+ function isImageMediaType(value) {
1215
+ return IMAGE_MEDIA_TYPES.includes(value);
1216
+ }
1217
+ function inferMediaType(url, contentType) {
1218
+ if (contentType) {
1219
+ const normalized = contentType.toLowerCase().split(";")[0].trim();
1220
+ if (isImageMediaType(normalized)) {
1221
+ return normalized;
1222
+ }
1223
+ if (normalized === "image/jpg") {
1224
+ return "image/jpeg";
1225
+ }
1226
+ }
1227
+ const urlLower = url.toLowerCase();
1228
+ if (urlLower.includes(".png")) return "image/png";
1229
+ if (urlLower.includes(".jpg") || urlLower.includes(".jpeg")) return "image/jpeg";
1230
+ if (urlLower.includes(".gif")) return "image/gif";
1231
+ if (urlLower.includes(".webp")) return "image/webp";
1232
+ return "image/jpeg";
1233
+ }
1234
+ async function fetchImageAsBase64(url) {
1235
+ const hostname = new URL(url).hostname;
1236
+ const provider = hostname === "uploads.linear.app" ? "linear" : hostname === "files.slack.com" ? "slack" : null;
1237
+ const response = provider ? await monolithRequest(`/v1/engine/${provider}/download`, { body: { url } }) : await fetch(url, { redirect: "follow" });
1238
+ if (!response.ok) {
1239
+ throw new Error(`Failed to fetch image from ${url}: ${response.status} ${response.statusText}`);
1240
+ }
1241
+ const contentType = response.headers.get("content-type");
1242
+ if (contentType) {
1243
+ const normalizedContentType = contentType.toLowerCase().split(";")[0].trim();
1244
+ if (!normalizedContentType.startsWith("image/")) {
1245
+ throw new Error(
1246
+ `Expected image content from ${url} but received Content-Type: ${normalizedContentType}. This may indicate an authentication or redirect issue.`
1247
+ );
1248
+ }
1249
+ }
1250
+ const mediaType = inferMediaType(url, contentType);
1251
+ const arrayBuffer = await response.arrayBuffer();
1252
+ const buffer = Buffer.from(arrayBuffer);
1253
+ const data = buffer.toString("base64");
1254
+ return {
1255
+ type: "base64",
1256
+ media_type: mediaType,
1257
+ data
1258
+ };
1259
+ }
1260
+ async function normalizeImages(images) {
1261
+ const normalized = [];
1262
+ for (const image of images) {
1263
+ if (image.source.type === "base64") {
1264
+ normalized.push({
1265
+ type: "image",
1266
+ source: image.source
1267
+ });
1268
+ } else if (image.source.type === "url") {
1269
+ const base64Source = await fetchImageAsBase64(image.source.url);
1270
+ normalized.push({
1271
+ type: "image",
1272
+ source: base64Source
1273
+ });
1274
+ }
1275
+ }
1276
+ return normalized;
1277
+ }
1278
+ async function saveNormalizedImagesToTempFiles(images, tempImageDir = join4(homedir2(), ".replicas", "codex", "temp-images")) {
1279
+ await mkdir3(tempImageDir, { recursive: true });
1280
+ const tempPaths = [];
1281
+ try {
1282
+ for (const image of images) {
1283
+ const ext = image.source.media_type.split("/")[1] || "png";
1284
+ const filename = `img_${randomUUID2()}.${ext}`;
1285
+ const filepath = join4(tempImageDir, filename);
1286
+ await writeFile2(filepath, Buffer.from(image.source.data, "base64"));
1287
+ tempPaths.push(filepath);
1288
+ }
1289
+ } catch (error) {
1290
+ await removeTempImageFiles(tempPaths);
1291
+ throw error;
1292
+ }
1293
+ return tempPaths;
1294
+ }
1295
+ async function removeTempImageFiles(paths) {
1296
+ await Promise.allSettled(paths.map((path2) => unlink2(path2)));
1297
+ }
1298
+
1299
+ // src/utils/codex-quota.ts
1300
+ function buildCodexRateLimitsSnapshot(fields) {
1301
+ if (fields.authMethod !== "oauth") return null;
1302
+ if (fields.unlimited === true) return null;
1303
+ let state = "ok";
1304
+ if (fields.rateLimitResetType?.endsWith("_credits_depleted")) {
1305
+ state = "out_of_credits";
1306
+ } else if (fields.rateLimitResetType !== null) {
1307
+ state = "rate_limited";
1308
+ }
1309
+ return {
1310
+ state,
1311
+ balance: fields.balance,
1312
+ rateLimitResetType: fields.rateLimitResetType,
1313
+ planType: fields.planType
1314
+ };
1315
+ }
1316
+ var CodexQuotaStatusTracker = class {
1317
+ lastEmittedQuotaState = "ok";
1318
+ latestQuotaSnapshot = null;
1319
+ // OpenAI keeps `rate_limit_reached_type` set in snapshots even while requests
1320
+ // still succeed (the flag is sticky until an authoritative snapshot clears it),
1321
+ // so an out_of_credits reading on its own is not trustworthy. Record it here,
1322
+ // and only surface it via confirmOutOfCredits() once a turn actually fails
1323
+ // with a quota error.
1324
+ observe(snapshot) {
1325
+ this.latestQuotaSnapshot = snapshot;
1326
+ if (snapshot.state === "out_of_credits") return null;
1327
+ if (snapshot.state === this.lastEmittedQuotaState) return null;
1328
+ return this.emit(snapshot);
1329
+ }
1330
+ confirmOutOfCredits() {
1331
+ if (this.latestQuotaSnapshot?.state !== "out_of_credits") return null;
1332
+ return this.emit(this.latestQuotaSnapshot);
1333
+ }
1334
+ recordTurnSuccess() {
1335
+ if (this.lastEmittedQuotaState === "ok") return null;
1336
+ return this.emit({
1337
+ state: "ok",
1338
+ balance: this.latestQuotaSnapshot?.balance ?? null,
1339
+ rateLimitResetType: null,
1340
+ planType: this.latestQuotaSnapshot?.planType ?? null
1341
+ });
1342
+ }
1343
+ emit(snapshot) {
1344
+ this.lastEmittedQuotaState = snapshot.state;
1345
+ this.latestQuotaSnapshot = snapshot;
1346
+ const payload = {
1347
+ state: snapshot.state,
1348
+ balance: snapshot.balance,
1349
+ rateLimitResetType: snapshot.rateLimitResetType,
1350
+ planType: snapshot.planType
1351
+ };
1352
+ return {
1353
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1354
+ type: CODEX_QUOTA_STATUS_EVENT_TYPE,
1355
+ payload
1356
+ };
1357
+ }
1358
+ };
1359
+
1360
+ // src/managers/subagent-mcp-config.ts
1361
+ import { existsSync as existsSync2 } from "fs";
1362
+ import { dirname as dirname2, join as join5 } from "path";
1363
+ import { fileURLToPath } from "url";
1364
+ import { createRequire } from "module";
1365
+ var SUBAGENT_MCP_TOOL_TIMEOUT_MS = 24 * 60 * 60 * 1e3 + 6e4;
1366
+ function subagentMcpProcessConfig(parentChatId) {
1367
+ const directory = dirname2(fileURLToPath(import.meta.url));
1368
+ const builtEntry = join5(directory, "subagent-mcp.js");
1369
+ const sourceEntry = join5(directory, "..", "subagent-mcp.ts");
1370
+ const entry = existsSync2(builtEntry) ? builtEntry : sourceEntry;
1371
+ return {
1372
+ command: process.execPath,
1373
+ args: [
1374
+ ...entry.endsWith(".ts") ? ["--import", createRequire(import.meta.url).resolve("tsx/esm")] : [],
1375
+ entry,
1376
+ "--parent",
1377
+ parentChatId
1378
+ ]
1379
+ };
1380
+ }
1381
+ function subagentCodexMcpConfig(parentChatId) {
1382
+ return {
1383
+ ...subagentMcpProcessConfig(parentChatId),
1384
+ env_vars: ["REPLICAS_ENGINE_PORT", "REPLICAS_ENGINE_SECRET"],
1385
+ enabled_tools: [...SUBAGENT_MCP_TOOL_NAMES],
1386
+ required: true,
1387
+ default_tools_approval_mode: "approve",
1388
+ startup_timeout_sec: 10,
1389
+ tool_timeout_sec: Math.ceil(SUBAGENT_MCP_TOOL_TIMEOUT_MS / 1e3)
1390
+ };
1391
+ }
1392
+
1393
+ // src/managers/codex-asp/mappers.ts
1394
+ var localImageCache = /* @__PURE__ */ new Map();
1395
+ var DEFAULT_MODEL = DEFAULT_CODEX_MODEL;
1396
+ var THREAD_START_METHOD = "thread/start";
1397
+ var THREAD_RESUME_METHOD = "thread/resume";
1398
+ var THREAD_READ_METHOD = "thread/read";
1399
+ var THREAD_GOAL_SET_METHOD = "thread/goal/set";
1400
+ var THREAD_GOAL_GET_METHOD = "thread/goal/get";
1401
+ var THREAD_GOAL_CLEAR_METHOD = "thread/goal/clear";
1402
+ var THREAD_COMPACT_START_METHOD = "thread/compact/start";
1403
+ var THREAD_INJECT_ITEMS_METHOD = "thread/inject_items";
1404
+ var THREAD_SETTINGS_UPDATE_METHOD = "thread/settings/update";
1405
+ var TURN_START_METHOD = "turn/start";
1406
+ var TURN_INTERRUPT_METHOD = "turn/interrupt";
1407
+ var TURN_STEER_METHOD = "turn/steer";
1408
+ var ACCOUNT_RATE_LIMITS_READ_METHOD = "account/rateLimits/read";
1409
+ var MODEL_LIST_METHOD = "model/list";
1410
+ var SKILLS_LIST_METHOD = "skills/list";
1411
+ var MAX_CODEX_ASP_TRANSCRIPT_OUTPUT_CHARS = DEFAULT_HOOK_OUTPUT_PREVIEW_CHARS;
1412
+ function getReconciledActiveTurnId(error, expectedTurnId) {
1413
+ const message = error instanceof Error ? error.message : String(error);
1414
+ const match = message.match(/expected active turn id ([0-9a-f-]{36}) but found ([0-9a-f-]{36})/i);
1415
+ return match?.[1]?.toLowerCase() === expectedTurnId.toLowerCase() ? match[2] ?? null : null;
1416
+ }
1417
+ function codexApprovalPolicyOverrides() {
1418
+ return {
1419
+ approvalPolicy: "untrusted",
1420
+ approvalsReviewer: "user"
1421
+ };
1422
+ }
1423
+ function toReasoningEffort(thinkingLevel) {
1424
+ return codexReasoningEffortForThinkingLevel(thinkingLevel);
1425
+ }
1426
+ function extractRateLimitsSnapshot(rateLimits) {
1427
+ const credits = rateLimits.credits;
1428
+ return buildCodexRateLimitsSnapshot({
1429
+ authMethod: ENGINE_ENV.REPLICAS_CODEX_AUTH_METHOD ?? "none",
1430
+ unlimited: credits?.unlimited ?? null,
1431
+ balance: credits?.balance ?? null,
1432
+ rateLimitResetType: rateLimits.rateLimitReachedType || null,
1433
+ planType: rateLimits.planType
1434
+ });
1435
+ }
1436
+ function timestampFromSeconds(value) {
1437
+ return new Date((value ?? Date.now() / 1e3) * 1e3).toISOString();
1438
+ }
1439
+ function timestampFromMilliseconds(value) {
1440
+ return new Date(value ?? Date.now()).toISOString();
1441
+ }
1442
+ function stringifyToolOutput(value) {
1443
+ if (value === null || value === void 0) return void 0;
1444
+ if (typeof value === "string") return truncateCodexAspTranscriptOutput(value);
1445
+ if (typeof value === "object" && "content" in value && Array.isArray(value.content)) {
1446
+ const text = value.content.map((item) => {
1447
+ if (typeof item === "string") return item;
1448
+ if (item && typeof item === "object" && "text" in item && typeof item.text === "string") {
1449
+ return item.text;
1450
+ }
1451
+ return "";
1452
+ }).filter(Boolean).join("\n");
1453
+ if (text) return truncateCodexAspTranscriptOutput(text);
1454
+ }
1455
+ try {
1456
+ return truncateCodexAspTranscriptOutput(JSON.stringify(value));
1457
+ } catch {
1458
+ return truncateCodexAspTranscriptOutput(String(value));
1459
+ }
1460
+ }
1461
+ function stringifyCollabAgentStates(states) {
1462
+ const entries = Object.entries(states).map(([threadId, state]) => {
1463
+ const message = state?.message?.trim();
1464
+ return `${threadId}: ${state?.status ?? "unknown"}${message ? ` - ${message}` : ""}`;
1465
+ });
1466
+ return entries.length > 0 ? truncateCodexAspTranscriptOutput(entries.join("\n")) : void 0;
1467
+ }
1468
+ function codexSubagentTranscriptItem({
1469
+ id,
1470
+ description,
1471
+ prompt,
1472
+ receiverThreadIds,
1473
+ model,
1474
+ output,
1475
+ timestamp,
1476
+ status
1477
+ }) {
1478
+ return {
1479
+ type: "subagent",
1480
+ id,
1481
+ description,
1482
+ prompt,
1483
+ subagentType: "codex",
1484
+ receiverThreadIds,
1485
+ ...model ? { model } : {},
1486
+ ...output ? { output } : {},
1487
+ timestamp,
1488
+ status
1489
+ };
1490
+ }
1491
+ function truncateCodexAspTranscriptOutput(output, maxChars = MAX_CODEX_ASP_TRANSCRIPT_OUTPUT_CHARS) {
1492
+ return buildOutputPreview(output, {
1493
+ maxChars,
1494
+ truncateFrom: "tail",
1495
+ marker: "\n[output truncated: showing tail of output]\n"
1496
+ }).output;
1497
+ }
1498
+ function appendCodexAspTranscriptOutput(current, delta, maxChars = MAX_CODEX_ASP_TRANSCRIPT_OUTPUT_CHARS) {
1499
+ return truncateCodexAspTranscriptOutput(`${current ?? ""}${delta}`, maxChars);
1500
+ }
1501
+ function transcriptPatchOperation(change) {
1502
+ const action = change.kind.type;
1503
+ const diff = change.diff.trim().length === 0 ? void 0 : action === "update" ? change.diff : fileContentDiff(change.path, change.diff, action);
1504
+ return {
1505
+ action,
1506
+ path: change.path,
1507
+ ...change.kind.type === "update" && change.kind.move_path ? { moveTo: change.kind.move_path } : {},
1508
+ ...diff ? { diff } : {}
1509
+ };
1510
+ }
1511
+ function transcriptItemsForTurn(turn) {
1512
+ const userItems = turn.items.filter((item) => item.type === "userMessage");
1513
+ const otherItems = turn.items.filter((item) => item.type !== "userMessage");
1514
+ return [...userItems, ...otherItems];
1515
+ }
1516
+ function userImageForLocalPath(path2) {
1517
+ const cached = localImageCache.get(path2);
1518
+ if (cached) return cached;
1519
+ if (!existsSync3(path2)) return null;
1520
+ const image = {
1521
+ type: "image",
1522
+ mediaType: inferMediaType(path2),
1523
+ data: readFileSync(path2).toString("base64")
1524
+ };
1525
+ if (image.data.length > 0) localImageCache.set(path2, image);
1526
+ return image;
1527
+ }
1528
+ function userImagesForInput(input) {
1529
+ const images = input.filter((item) => item.type === "localImage").map((item) => userImageForLocalPath(item.path)).filter((image) => image !== null && image.data.length > 0);
1530
+ return images.length > 0 ? images : void 0;
1531
+ }
1532
+ function itemToTranscriptItem(item, timestamp, status) {
1533
+ if (item.type === "userMessage") {
1534
+ const content = item.content.filter((input) => input.type === "text").map((input) => input.text).join("\n");
1535
+ const skillNames = item.content.filter((input) => input.type === "skill").map((input) => input.name).filter((name) => name.trim().length > 0);
1536
+ return {
1537
+ type: "userMessage",
1538
+ id: item.id,
1539
+ content,
1540
+ ...skillNames.length > 0 ? { skillNames } : {},
1541
+ images: userImagesForInput(item.content),
1542
+ timestamp
1543
+ };
1544
+ }
1545
+ if (item.type === "agentMessage") {
1546
+ return {
1547
+ type: "agentMessage",
1548
+ id: item.id,
1549
+ text: item.text,
1550
+ phase: item.phase,
1551
+ timestamp
1552
+ };
1553
+ }
1554
+ if (item.type === "reasoning") {
1555
+ return {
1556
+ type: "reasoning",
1557
+ id: item.id,
1558
+ text: [...item.summary, ...item.content].filter(Boolean).join("\n").trim(),
1559
+ timestamp,
1560
+ status
1561
+ };
1562
+ }
1563
+ if (item.type === "commandExecution") {
1564
+ const exitCode = item.exitCode ?? null;
1565
+ return {
1566
+ type: "commandExecution",
1567
+ id: item.id,
1568
+ command: item.command,
1569
+ ...item.aggregatedOutput ? { output: truncateCodexAspTranscriptOutput(item.aggregatedOutput) } : {},
1570
+ exitCode,
1571
+ timestamp,
1572
+ status: normalizeCodexAspTranscriptStatus(item.status, typeof exitCode === "number" && exitCode !== 0)
1573
+ };
1574
+ }
1575
+ if (item.type === "fileChange") {
1576
+ return {
1577
+ type: "fileChange",
1578
+ id: item.id,
1579
+ operations: item.changes.map(transcriptPatchOperation),
1580
+ output: item.status,
1581
+ exitCode: item.status === "completed" ? 0 : 1,
1582
+ timestamp,
1583
+ status: normalizeCodexAspTranscriptStatus(item.status)
1584
+ };
1585
+ }
1586
+ if (item.type === "mcpToolCall") {
1587
+ return {
1588
+ type: "toolCall",
1589
+ id: item.id,
1590
+ server: item.server,
1591
+ tool: item.tool,
1592
+ input: item.arguments,
1593
+ output: item.error?.message ? truncateCodexAspTranscriptOutput(item.error.message) : stringifyToolOutput(item.result),
1594
+ timestamp,
1595
+ status: normalizeCodexAspTranscriptStatus(item.status, item.status === "failed")
1596
+ };
1597
+ }
1598
+ if (item.type === "dynamicToolCall") {
1599
+ if (item.namespace === "collab" && item.tool === "wait") {
1600
+ return null;
1601
+ }
1602
+ return {
1603
+ type: "toolCall",
1604
+ id: item.id,
1605
+ server: item.namespace ?? "dynamic",
1606
+ tool: item.tool,
1607
+ input: item.arguments,
1608
+ output: stringifyToolOutput(item.contentItems),
1609
+ timestamp,
1610
+ status: normalizeCodexAspTranscriptStatus(item.status, item.success === false)
1611
+ };
1612
+ }
1613
+ if (item.type === "collabAgentToolCall") {
1614
+ if (item.tool === "spawnAgent" && item.receiverThreadIds.length > 0) {
1615
+ return codexSubagentTranscriptItem({
1616
+ id: item.id,
1617
+ description: item.receiverThreadIds.length === 1 ? "Codex subagent" : `Codex subagents (${item.receiverThreadIds.length})`,
1618
+ prompt: item.prompt ?? "",
1619
+ receiverThreadIds: item.receiverThreadIds,
1620
+ model: item.model ?? void 0,
1621
+ output: stringifyCollabAgentStates(item.agentsStates),
1622
+ timestamp,
1623
+ status: normalizeCodexAspTranscriptStatus(item.status, item.status === "failed")
1624
+ });
1625
+ }
1626
+ if (item.tool === "wait") {
1627
+ return null;
1628
+ }
1629
+ return {
1630
+ type: "toolCall",
1631
+ id: item.id,
1632
+ server: "collab",
1633
+ tool: item.tool,
1634
+ input: item.prompt ?? void 0,
1635
+ output: stringifyToolOutput(item.agentsStates),
1636
+ timestamp,
1637
+ status: normalizeCodexAspTranscriptStatus(item.status, item.status === "failed")
1638
+ };
1639
+ }
1640
+ if (item.type === "subAgentActivity" && item.kind === "started") {
1641
+ return codexSubagentTranscriptItem({
1642
+ id: item.id,
1643
+ description: item.agentPath,
1644
+ prompt: "",
1645
+ receiverThreadIds: [item.agentThreadId],
1646
+ timestamp,
1647
+ status
1648
+ });
1649
+ }
1650
+ if (item.type === "webSearch") {
1651
+ return {
1652
+ type: "webSearch",
1653
+ id: item.id,
1654
+ query: item.query,
1655
+ timestamp,
1656
+ status
1657
+ };
1658
+ }
1659
+ if (item.type === "plan") {
1660
+ return {
1661
+ type: "plan",
1662
+ id: item.id,
1663
+ text: item.text,
1664
+ timestamp,
1665
+ status
1666
+ };
1667
+ }
1668
+ if (item.type === "contextCompaction") {
1669
+ return {
1670
+ type: "contextCompaction",
1671
+ id: item.id,
1672
+ timestamp,
1673
+ status
1674
+ };
1675
+ }
1676
+ if (item.type === "imageView" || item.type === "imageGeneration" || item.type === "hookPrompt") {
1677
+ return null;
1678
+ }
1679
+ if (item.type === "enteredReviewMode" || item.type === "exitedReviewMode") {
1680
+ return {
1681
+ type: "reasoning",
1682
+ id: item.id,
1683
+ text: item.review,
1684
+ timestamp,
1685
+ status
1686
+ };
1687
+ }
1688
+ return null;
1689
+ }
1690
+ function aspGoalToChatGoal(goal) {
1691
+ return {
1692
+ threadId: goal.threadId,
1693
+ objective: goal.objective,
1694
+ status: goal.status,
1695
+ tokenBudget: goal.tokenBudget,
1696
+ tokensUsed: goal.tokensUsed,
1697
+ timeUsedSeconds: goal.timeUsedSeconds,
1698
+ createdAt: timestampFromSeconds(goal.createdAt),
1699
+ updatedAt: timestampFromSeconds(goal.updatedAt)
1700
+ };
1701
+ }
1702
+ function isQuotaTurnFailure(turn) {
1703
+ const info = turn.error?.codexErrorInfo;
1704
+ if (!info) return false;
1705
+ if (info === "usageLimitExceeded") return true;
1706
+ if (typeof info === "object") {
1707
+ return Object.values(info).some((detail) => isRecord(detail) && detail.httpStatusCode === 429);
1708
+ }
1709
+ return false;
1710
+ }
1711
+ function isQuotaErrorMessage(error) {
1712
+ return error instanceof Error && detectAgentQuotaLimit(error.message) !== null;
1713
+ }
1714
+ function formatTurnFailure(turn) {
1715
+ const turnError = turn.error;
1716
+ if (!turnError) {
1717
+ return "Codex ASP turn failed without error details";
1718
+ }
1719
+ const parts = [`Codex ASP turn failed: ${turnError.message}`];
1720
+ if (turnError.codexErrorInfo !== null) {
1721
+ parts.push(`codexErrorInfo=${JSON.stringify(turnError.codexErrorInfo)}`);
1722
+ }
1723
+ if (turnError.additionalDetails) {
1724
+ parts.push(`details=${turnError.additionalDetails}`);
1725
+ }
1726
+ return parts.join(" ");
1727
+ }
1728
+ function threadToAspTranscript(thread) {
1729
+ return turnsToAspTranscript(thread.id, timestampFromSeconds(thread.updatedAt), thread.turns);
1730
+ }
1731
+ function turnsToAspTranscript(threadId, updatedAt, sourceTurns) {
1732
+ const turns = sourceTurns.map((turn, index) => ({ turn, index })).sort((a, b) => (a.turn.startedAt ?? a.turn.completedAt ?? Number.MAX_SAFE_INTEGER) - (b.turn.startedAt ?? b.turn.completedAt ?? Number.MAX_SAFE_INTEGER) || a.index - b.index).map(({ turn }) => {
1733
+ const startedAt = timestampFromSeconds(turn.startedAt);
1734
+ const completedAt = turn.completedAt === null ? null : timestampFromSeconds(turn.completedAt);
1735
+ const itemTimestamp = completedAt ?? startedAt;
1736
+ const status = normalizeCodexAspTranscriptStatus(turn.status);
1737
+ const items = [];
1738
+ for (const item of transcriptItemsForTurn(turn)) {
1739
+ const transcriptItem = itemToTranscriptItem(item, item.type === "userMessage" ? startedAt : itemTimestamp, status);
1740
+ if (transcriptItem) {
1741
+ items.push(transcriptItem);
1742
+ }
1743
+ }
1744
+ if (turn.error) {
1745
+ items.push({
1746
+ type: "error",
1747
+ id: `${turn.id}-error`,
1748
+ message: formatTurnFailure(turn),
1749
+ timestamp: itemTimestamp
1750
+ });
1751
+ }
1752
+ return {
1753
+ id: turn.id,
1754
+ status: turn.status,
1755
+ startedAt,
1756
+ completedAt,
1757
+ items
1758
+ };
1759
+ });
1760
+ return { threadId, updatedAt, turns };
1761
+ }
1762
+ async function buildThreadStartParams(workingDirectory, request, developerInstructions, serviceTier, subagentMcpParentChatId) {
1763
+ const additionalDirectories = await getAgentAdditionalDirectories();
1764
+ return {
1765
+ model: request.model ?? DEFAULT_MODEL,
1766
+ ...serviceTier !== void 0 ? { serviceTier } : {},
1767
+ cwd: workingDirectory,
1768
+ runtimeWorkspaceRoots: additionalDirectories,
1769
+ sandbox: "danger-full-access",
1770
+ ...codexApprovalPolicyOverrides(),
1771
+ developerInstructions: developerInstructions ?? null,
1772
+ config: {
1773
+ web_search: ENGINE_ENV.REPLICAS_CODEX_AUTH_METHOD === "ollama" ? "disabled" : "live",
1774
+ model_reasoning_summary: "auto",
1775
+ ...subagentMcpParentChatId ? {
1776
+ // Codex gates its native collaboration tools on `agents.enabled`; `features.multi_agent` is now a no-op.
1777
+ "agents.enabled": false,
1778
+ [`mcp_servers.${SUBAGENT_MCP_SERVER_NAME}`]: subagentCodexMcpConfig(subagentMcpParentChatId)
1779
+ } : {}
1780
+ },
1781
+ experimentalRawEvents: false
1782
+ };
1783
+ }
1784
+ async function buildThreadResumeParams(workingDirectory, threadId, request, developerInstructions, serviceTier, subagentMcpParentChatId) {
1785
+ const additionalDirectories = await getAgentAdditionalDirectories();
1786
+ return {
1787
+ threadId,
1788
+ model: request.model ?? DEFAULT_MODEL,
1789
+ ...serviceTier !== void 0 ? { serviceTier } : {},
1790
+ cwd: workingDirectory,
1791
+ runtimeWorkspaceRoots: additionalDirectories,
1792
+ sandbox: "danger-full-access",
1793
+ ...codexApprovalPolicyOverrides(),
1794
+ developerInstructions: developerInstructions ?? null,
1795
+ config: {
1796
+ web_search: ENGINE_ENV.REPLICAS_CODEX_AUTH_METHOD === "ollama" ? "disabled" : "live",
1797
+ model_reasoning_summary: "auto",
1798
+ ...subagentMcpParentChatId ? {
1799
+ // Codex gates its native collaboration tools on `agents.enabled`; `features.multi_agent` is now a no-op.
1800
+ "agents.enabled": false,
1801
+ [`mcp_servers.${SUBAGENT_MCP_SERVER_NAME}`]: subagentCodexMcpConfig(subagentMcpParentChatId)
1802
+ } : {}
1803
+ },
1804
+ excludeTurns: false
1805
+ };
1806
+ }
1807
+ async function buildTurnInput(request) {
1808
+ const input = [{
1809
+ type: "text",
1810
+ text: request.message,
1811
+ text_elements: []
1812
+ }];
1813
+ if (!request.images || request.images.length === 0) {
1814
+ return { input, tempImagePaths: [] };
1815
+ }
1816
+ const normalizedImages = await normalizeImages(request.images);
1817
+ const tempImagePaths = await saveNormalizedImagesToTempFiles(normalizedImages);
1818
+ input.push(...tempImagePaths.map((path2) => ({
1819
+ type: "localImage",
1820
+ path: path2
1821
+ })));
1822
+ return { input, tempImagePaths };
1823
+ }
1824
+ async function buildTurnStartParams(threadId, request, serviceTier) {
1825
+ const effort = toReasoningEffort(request.thinkingLevel);
1826
+ const model = request.model ?? DEFAULT_MODEL;
1827
+ const { input, tempImagePaths } = await buildTurnInput(request);
1828
+ return {
1829
+ params: {
1830
+ threadId,
1831
+ input,
1832
+ model,
1833
+ ...serviceTier !== void 0 ? { serviceTier } : {},
1834
+ ...codexApprovalPolicyOverrides(),
1835
+ ...effort ? { effort } : {},
1836
+ collaborationMode: {
1837
+ mode: request.planMode ? "plan" : "default",
1838
+ settings: {
1839
+ model,
1840
+ reasoning_effort: effort ?? null,
1841
+ developer_instructions: null
1842
+ }
1843
+ }
1844
+ },
1845
+ tempImagePaths
1846
+ };
1847
+ }
1848
+
1849
+ // src/managers/codex-asp/openai-compatible-model-catalog.ts
1850
+ import { writeFile as writeFile3 } from "fs/promises";
1851
+ import { tmpdir } from "os";
1852
+ import { join as join6 } from "path";
1853
+ var CATALOG_TTL_MS = 6e4;
1854
+ var CODEX_MODEL_CATALOG_PATH = join6(tmpdir(), "replicas-openai-compatible-model-catalog.json");
1855
+ var cachedCatalog = null;
1856
+ async function writeCodexModelCatalog(modelsUrl) {
1857
+ const ollama = ENGINE_ENV.REPLICAS_CODEX_AUTH_METHOD === "ollama" ? readOllamaAuthEnv("codex", process.env) : null;
1858
+ const [models, { stdout }] = await Promise.all([
1859
+ ollama ? listOllamaModels(ollama) : listOpenAICompatibleModels({
1860
+ apiKey: ENGINE_ENV.OPENAI_API_KEY,
1861
+ authorizationHeader: ENGINE_ENV.CODEX_OPENAI_AUTHORIZATION_HEADER,
1862
+ baseUrl: ENGINE_ENV.CODEX_OPENAI_BASE_URL ?? "",
1863
+ modelsUrl
1864
+ }),
1865
+ execFileAsync("codex", ["debug", "models"], { maxBuffer: SUBPROCESS_MAX_BUFFER })
1866
+ ]);
1867
+ const payload = JSON.parse(stdout);
1868
+ const template = isRecord(payload) && Array.isArray(payload.models) ? payload.models.find((model) => isRecord(model) && model.slug === "gpt-5.2") ?? payload.models.find(isRecord) : null;
1869
+ if (!isRecord(template)) throw new Error("Codex did not return a model catalog template");
1870
+ await writeFile3(CODEX_MODEL_CATALOG_PATH, JSON.stringify({
1871
+ models: models.flatMap((model) => model.contextWindow ? [{
1872
+ ...template,
1873
+ ...ollama ? { apply_patch_tool_type: null, support_verbosity: false, supports_search_tool: false, input_modalities: ["text"] } : {},
1874
+ slug: model.id,
1875
+ display_name: model.displayName,
1876
+ description: model.description ?? model.displayName,
1877
+ context_window: model.contextWindow,
1878
+ max_context_window: model.contextWindow,
1879
+ effective_context_window_percent: 100,
1880
+ auto_compact_token_limit: Math.floor(model.contextWindow * 0.9)
1881
+ }] : [])
1882
+ }));
1883
+ return [
1884
+ `model_catalog_json=${JSON.stringify(CODEX_MODEL_CATALOG_PATH)}`,
1885
+ "features.remote_models=false"
1886
+ ];
1887
+ }
1888
+ async function getOpenAICompatibleCodexModelCatalogConfig() {
1889
+ if (ENGINE_ENV.REPLICAS_CODEX_AUTH_METHOD !== "openai-compatible" && ENGINE_ENV.REPLICAS_CODEX_AUTH_METHOD !== "ollama" || !ENGINE_ENV.CODEX_OPENAI_BASE_URL) return [];
1890
+ const modelsUrl = resolveOpenAICompatibleModelsUrl({
1891
+ baseUrl: ENGINE_ENV.CODEX_OPENAI_BASE_URL,
1892
+ modelsUrl: ENGINE_ENV.CODEX_OPENAI_MODELS_URL ?? ""
1893
+ });
1894
+ const key = `${modelsUrl}:${JSON.stringify(ENGINE_ENV.REPLICAS_AGENT_CREDENTIALS.codex ?? null)}`;
1895
+ if (!cachedCatalog || cachedCatalog.key !== key || cachedCatalog.expiresAt <= Date.now()) {
1896
+ cachedCatalog = {
1897
+ key,
1898
+ expiresAt: Date.now() + CATALOG_TTL_MS,
1899
+ config: writeCodexModelCatalog(modelsUrl).catch((error) => {
1900
+ console.warn("[CodexAspManager] Failed to prepare OpenAI-compatible model catalog:", error);
1901
+ return [];
1902
+ })
1903
+ };
1904
+ }
1905
+ return cachedCatalog.config;
1906
+ }
1907
+
1908
+ // src/managers/codex-asp/asp-host.ts
1909
+ var CODEX_ULTRAFAST_SERVICE_TIER = "ultrafast";
1910
+ var RUNNING_MODEL_LIST_TIMEOUT_MS = 3e3;
1911
+ var hostPromise = null;
1912
+ var activeProcess = null;
1913
+ var restartPromise = null;
1914
+ var modelCatalog = null;
1915
+ async function getCodexAspHost() {
1916
+ if (restartPromise) {
1917
+ await restartPromise;
1918
+ }
1919
+ hostPromise ??= (async () => {
1920
+ try {
1921
+ const oauthOptions = await codexTokenManager.prepareAspOauthOptions();
1922
+ const process2 = new AppServerProcess({
1923
+ cwd: ENGINE_ENV.WORKSPACE_ROOT,
1924
+ env: buildCodexAgentEnv(),
1925
+ configOverrides: await getOpenAICompatibleCodexModelCatalogConfig(),
1926
+ ...oauthOptions
1927
+ });
1928
+ const { client } = await process2.start();
1929
+ activeProcess = process2;
1930
+ process2.on("exit", () => {
1931
+ if (activeProcess === process2) {
1932
+ activeProcess = null;
1933
+ }
1934
+ hostPromise = null;
1935
+ });
1936
+ return { client };
1937
+ } catch (error) {
1938
+ hostPromise = null;
1939
+ throw error;
1940
+ }
1941
+ })();
1942
+ return hostPromise;
1943
+ }
1944
+ async function restartCodexAspHost() {
1945
+ if (restartPromise) {
1946
+ return restartPromise;
1947
+ }
1948
+ restartPromise = (async () => {
1949
+ const process2 = activeProcess;
1950
+ hostPromise = null;
1951
+ activeProcess = null;
1952
+ if (process2) {
1953
+ await process2.stop();
1954
+ }
1955
+ })();
1956
+ try {
1957
+ await restartPromise;
1958
+ } finally {
1959
+ restartPromise = null;
1960
+ }
1961
+ }
1962
+ async function restartCodexAspHostIfRunning() {
1963
+ if (activeProcess) await restartCodexAspHost();
1964
+ }
1965
+ function listCodexModels(host) {
1966
+ if (modelCatalog?.client !== host.client) {
1967
+ const models = (async () => {
1968
+ const models2 = [];
1969
+ let cursor = null;
1970
+ do {
1971
+ const response = await host.client.request(
1972
+ MODEL_LIST_METHOD,
1973
+ { cursor, includeHidden: true }
1974
+ );
1975
+ models2.push(...response.data);
1976
+ cursor = response.nextCursor;
1977
+ } while (cursor);
1978
+ return models2;
1979
+ })();
1980
+ modelCatalog = { client: host.client, models };
1981
+ models.catch(() => {
1982
+ if (modelCatalog?.models === models) modelCatalog = null;
1983
+ });
1984
+ }
1985
+ return modelCatalog.models;
1986
+ }
1987
+ async function listRunningCodexModels() {
1988
+ if (!activeProcess || !hostPromise) return void 0;
1989
+ try {
1990
+ return await withTimeout(hostPromise.then(listCodexModels), RUNNING_MODEL_LIST_TIMEOUT_MS, "Codex model list timed out");
1991
+ } catch {
1992
+ return void 0;
1993
+ }
1994
+ }
1995
+
1996
+ export {
1997
+ clearCredentialFallbacks,
1998
+ recordCredentialFallback,
1999
+ listCredentialFallbacks,
2000
+ routineRefreshRequest,
2001
+ recordExhaustedCredential,
2002
+ refreshDelayMs,
2003
+ BaseRefreshManager,
2004
+ AsyncLock,
2005
+ atomicWriteFile,
2006
+ writeSecureCredentialFile,
2007
+ configureManagedGitCredentialHelper,
2008
+ upsertCredentialFileLines,
2009
+ removeCredentialFileLines,
2010
+ loadRepoState,
2011
+ saveRepoState,
2012
+ gitService,
2013
+ applyAuthEnvTransition,
2014
+ getAgentAdditionalDirectories,
2015
+ normalizeImages,
2016
+ removeTempImageFiles,
2017
+ CodexQuotaStatusTracker,
2018
+ SUBAGENT_MCP_TOOL_TIMEOUT_MS,
2019
+ subagentMcpProcessConfig,
2020
+ DEFAULT_MODEL,
2021
+ THREAD_START_METHOD,
2022
+ THREAD_RESUME_METHOD,
2023
+ THREAD_READ_METHOD,
2024
+ THREAD_GOAL_SET_METHOD,
2025
+ THREAD_GOAL_GET_METHOD,
2026
+ THREAD_GOAL_CLEAR_METHOD,
2027
+ THREAD_COMPACT_START_METHOD,
2028
+ THREAD_INJECT_ITEMS_METHOD,
2029
+ THREAD_SETTINGS_UPDATE_METHOD,
2030
+ TURN_START_METHOD,
2031
+ TURN_INTERRUPT_METHOD,
2032
+ TURN_STEER_METHOD,
2033
+ ACCOUNT_RATE_LIMITS_READ_METHOD,
2034
+ SKILLS_LIST_METHOD,
2035
+ getReconciledActiveTurnId,
2036
+ extractRateLimitsSnapshot,
2037
+ timestampFromSeconds,
2038
+ timestampFromMilliseconds,
2039
+ appendCodexAspTranscriptOutput,
2040
+ itemToTranscriptItem,
2041
+ aspGoalToChatGoal,
2042
+ isQuotaTurnFailure,
2043
+ isQuotaErrorMessage,
2044
+ formatTurnFailure,
2045
+ threadToAspTranscript,
2046
+ buildThreadStartParams,
2047
+ buildThreadResumeParams,
2048
+ buildTurnInput,
2049
+ buildTurnStartParams,
2050
+ CODEX_ULTRAFAST_SERVICE_TIER,
2051
+ getCodexAspHost,
2052
+ restartCodexAspHost,
2053
+ restartCodexAspHostIfRunning,
2054
+ listCodexModels,
2055
+ listRunningCodexModels,
2056
+ CodexAspAuthMethodChangedError,
2057
+ codexTokenManager
2058
+ };