@rallycry/conveyor-agent 10.13.71 → 10.13.72

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.
@@ -1,1930 +1,87 @@
1
1
  import {
2
- CLONE_TIMEOUT_MS,
3
- DEFAULT_RETRY_DELAY_MS,
4
- FETCH_TIMEOUT_MS,
5
- GIT_PREP_MAX_RETRIES,
6
2
  mapChatHistory,
7
- readAgentVersion,
8
- registerBootMilestoneSocketFallback,
9
- reportBootMilestone
10
- } from "./chunk-QU53HND5.js";
11
- import {
12
- LoopLagMonitor,
13
- buildConveyorSocketOptions,
14
- callWithAck,
15
- heartbeatStatusFor,
16
- loopStatusForRunnerStatus,
17
- waitForConnected
18
- } from "./chunk-IA45XHOA.js";
19
- import {
20
- WorkbenchError,
21
- getWorkbenchClient
22
- } from "./chunk-EXQ6AHOY.js";
23
- import {
24
- workbenchEnabled
25
- } from "./chunk-KMB3BU4S.js";
26
- import {
27
- MAX_BETWEEN_TURN_BUFFER,
28
- MAX_DIAGNOSTIC_OUTPUT,
29
- buildExitErrors,
30
- buildPromptBytes,
31
- buildSpawnArgs,
32
- cleanTerminalOutput,
33
- describeTokenFile,
34
- ghHostsExternallyOwned,
35
- gitCredentialHelper,
36
- githubTokenFilePath,
37
- inheritedEnv,
38
- killPtyWithEscalation,
39
- needsRawReadyGate,
40
- parseUserQuestions,
41
- renderPromptContentText,
42
- resolveClaudeBinary,
43
- resolvePlanDialogTiming,
44
- resolvePtySpawn,
45
- resolveRawTuiProbeTiming,
46
- resolveSubmitNudgeTiming,
47
- resolveSubmitRedeliveryMaxAttempts,
48
- resolveSubmitSettleMs,
49
- sawTerminalSetup,
50
- sentinelEchoed,
51
- sessionTempBase,
52
- sleep,
53
- spawnOptionsFingerprint,
54
- syncGithubTokenFiles,
55
- transcriptSize,
56
- turnOptionsFrom,
57
- writeGitCredential
58
- } from "./chunk-GJXAAPJ6.js";
59
-
60
- // src/setup/bootstrap.ts
61
- var BOOTSTRAP_TIMEOUT_MS = 3e4;
62
- var RETRY_DELAYS_MS = [5e3, 1e4, 2e4];
63
- function emitFailureEvent(payload) {
64
- process.stderr.write(JSON.stringify(payload) + "\n");
65
- }
66
- async function singleBootstrapAttempt(apiUrl, instanceName, bootstrapToken, timeoutMs) {
67
- const controller = new AbortController();
68
- const timer = setTimeout(() => controller.abort(), timeoutMs);
69
- try {
70
- const headers = {};
71
- if (bootstrapToken) headers["x-codespace-token"] = bootstrapToken;
72
- const response = await fetch(`${apiUrl}/api/codespace/bootstrap/${instanceName}`, {
73
- headers,
74
- signal: controller.signal
75
- });
76
- if (!response.ok) {
77
- const errorText2 = await response.text().catch(() => "");
78
- return {
79
- ok: false,
80
- status: response.status,
81
- errorText: errorText2.slice(0, 500),
82
- reason: response.status === 401 || response.status === 403 ? "auth_rejected" : "http_error"
83
- };
84
- }
85
- const body = await response.json();
86
- return { ok: true, body };
87
- } catch (err) {
88
- const message = err instanceof Error ? err.message : String(err);
89
- const reason = controller.signal.aborted ? "timeout" : "network_error";
90
- return { ok: false, errorText: message.slice(0, 500), reason };
91
- } finally {
92
- clearTimeout(timer);
93
- }
94
- }
95
- function buildFailure(reason, attempts, status, detail) {
96
- const out = { ok: false, reason, attempts };
97
- if (status === void 0) {
98
- } else {
99
- out.status = status;
100
- }
101
- if (detail) out.detail = detail;
102
- return out;
103
- }
104
- function isRetryable(reason, retryOnHttpError) {
105
- if (reason === "timeout" || reason === "network_error") return true;
106
- return retryOnHttpError === true && reason === "http_error";
107
- }
108
- async function fetchBootstrap(opts) {
109
- const timeoutMs = opts.timeoutMs ?? BOOTSTRAP_TIMEOUT_MS;
110
- const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
111
- const maxAttempts = delays.length + 1;
112
- const hasBootstrapToken = Boolean(opts.bootstrapToken);
113
- const hasTaskToken = Boolean(process.env.CONVEYOR_TASK_TOKEN);
114
- let lastReason = "unknown";
115
- let lastStatus;
116
- let lastDetail;
117
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
118
- const result = await singleBootstrapAttempt(
119
- opts.apiUrl,
120
- opts.instanceName,
121
- opts.bootstrapToken,
122
- timeoutMs
123
- );
124
- if (result.ok && result.body) {
125
- return { ok: true, config: result.body, attempts: attempt };
126
- }
127
- lastReason = result.reason ?? "unknown";
128
- lastStatus = result.status;
129
- lastDetail = result.errorText;
130
- const failurePayload = {
131
- event: "bootstrap_failed",
132
- reason: lastReason,
133
- apiUrl: opts.apiUrl,
134
- instanceName: opts.instanceName,
135
- hasBootstrapToken,
136
- hasTaskToken,
137
- attempt
138
- };
139
- if (lastStatus === void 0) {
140
- } else {
141
- failurePayload.status = lastStatus;
142
- }
143
- if (lastDetail) failurePayload.detail = lastDetail;
144
- emitFailureEvent(failurePayload);
145
- if (!isRetryable(lastReason, opts.retryOnHttpError) || attempt >= maxAttempts) {
146
- return buildFailure(lastReason, attempt, lastStatus, lastDetail);
147
- }
148
- await sleep(delays[attempt - 1]);
149
- }
150
- return buildFailure(lastReason, maxAttempts, lastStatus, lastDetail);
151
- }
152
- function applyBootstrapToEnv(config) {
153
- for (const [key, value] of Object.entries(config.envVars ?? {})) {
154
- process.env[key] = value;
155
- }
156
- if (config.mode === "project") {
157
- if (config.projectToken) process.env.CONVEYOR_PROJECT_TOKEN = config.projectToken;
158
- if (config.projectId) process.env.CONVEYOR_PROJECT_ID = config.projectId;
159
- if (config.workspaceBranch) process.env.CONVEYOR_WORKSPACE_BRANCH = config.workspaceBranch;
160
- return;
161
- }
162
- if (config.taskId) process.env.CONVEYOR_TASK_ID = config.taskId;
163
- if (config.sessionId) process.env.CONVEYOR_SESSION_ID = config.sessionId;
164
- if (config.taskToken) process.env.CONVEYOR_TASK_TOKEN = config.taskToken;
165
- if (config.agentMode !== void 0) process.env.CONVEYOR_AGENT_MODE = config.agentMode;
166
- if (config.isAuto !== void 0) process.env.CONVEYOR_IS_AUTO = config.isAuto;
167
- if (config.runnerMode) process.env.CONVEYOR_MODE = config.runnerMode;
168
- if (config.taskBranch) process.env.CONVEYOR_TASK_BRANCH = config.taskBranch;
169
- }
170
-
171
- // src/utils/logger.ts
172
- function createServiceLogger(service) {
173
- const prefix = `[conveyor-agent:${service}]`;
174
- return {
175
- info(message, data) {
176
- const extra = data ? ` ${JSON.stringify(data)}` : "";
177
- process.stderr.write(`${prefix} ${message}${extra}
178
- `);
179
- },
180
- warn(message, data) {
181
- const extra = data ? ` ${JSON.stringify(data)}` : "";
182
- process.stderr.write(`${prefix} WARN ${message}${extra}
183
- `);
184
- },
185
- error(message, data) {
186
- const extra = data ? ` ${JSON.stringify(data)}` : "";
187
- process.stderr.write(`${prefix} ERROR ${message}${extra}
188
- `);
189
- }
190
- };
191
- }
192
-
193
- // src/connection/agent-connection.ts
194
- import { existsSync } from "fs";
195
- import { fileURLToPath } from "url";
196
- import { Worker } from "worker_threads";
197
- import { io } from "socket.io-client";
198
-
199
- // src/setup/bootstrap-poll.ts
200
- var PollUntilBoundHttpError = class extends Error {
201
- constructor(status) {
202
- super(`pollUntilBound got unexpected status ${status}`);
203
- this.status = status;
204
- this.name = "PollUntilBoundHttpError";
205
- }
206
- status;
207
- };
208
- async function pollUntilBound(opts) {
209
- const pollIntervalMs = opts.pollIntervalMs ?? 2e3;
210
- const maxWaitMs = opts.maxWaitMs ?? 30 * 60 * 1e3;
211
- const deadline = Date.now() + maxWaitMs;
212
- while (true) {
213
- const response = await fetch(`${opts.apiUrl}/api/v3/pods/bootstrap`, {
214
- headers: { Authorization: `Bearer ${opts.bootstrapToken}` }
215
- });
216
- if (response.status === 200) {
217
- return await response.json();
218
- }
219
- if (response.status === 204) {
220
- if (Date.now() >= deadline) {
221
- throw new Error(`pollUntilBound timed out after ${maxWaitMs}ms waiting for pod bind`);
222
- }
223
- await sleep(pollIntervalMs);
224
- continue;
225
- }
226
- throw new PollUntilBoundHttpError(response.status);
227
- }
228
- }
229
-
230
- // src/connection/bundle-credentials.ts
231
- function readBundleIdentity(sessionJwt) {
232
- if (!sessionJwt) return {};
233
- const segments = sessionJwt.split(".");
234
- if (segments.length !== 3) return {};
235
- try {
236
- const json = Buffer.from(segments[1], "base64url").toString("utf8");
237
- const claims = JSON.parse(json);
238
- return {
239
- ...typeof claims.sessionId === "string" ? { sessionId: claims.sessionId } : {},
240
- ...typeof claims.role === "string" ? { role: claims.role } : {}
241
- };
242
- } catch {
243
- return {};
244
- }
245
- }
246
- function bundleMayWriteGithubFiles(bundle, self) {
247
- const identity = readBundleIdentity(bundle.sessionJwt);
248
- if (identity.sessionId && self.sessionId && identity.sessionId !== self.sessionId) {
249
- return {
250
- allowed: false,
251
- reason: `bundle resolved to session ${identity.sessionId}, not ours (${self.sessionId})`
252
- };
253
- }
254
- if (identity.role === "reader" && self.role && self.role !== "reader") {
255
- return {
256
- allowed: false,
257
- reason: `bundle carries a reader-scoped token but this session is a ${self.role}`
258
- };
259
- }
260
- return { allowed: true };
261
- }
262
- function applyBundleGithubToken(bundle, self) {
263
- if (!bundle.githubToken) return { written: false, reason: "bundle carried no GitHub token" };
264
- const permitted = bundleMayWriteGithubFiles(bundle, self);
265
- if (!permitted.allowed) {
266
- return { written: false, ...permitted.reason ? { reason: permitted.reason } : {} };
267
- }
268
- syncGithubTokenFiles(bundle.githubToken);
269
- return { written: true };
270
- }
271
- function syncBundleGithubToken(token) {
272
- syncGithubTokenFiles(token);
273
- }
274
-
275
- // src/connection/agent-connection.ts
276
- var logger = createServiceLogger("agent-connection");
277
- var EVENT_BATCH_MS = 500;
278
- var MAX_EVENT_BUFFER = 5e3;
279
- var TOKEN_REFRESH_INTERVAL_MS = 45 * 60 * 1e3;
280
- var AgentConnection = class _AgentConnection {
281
- socket = null;
282
- config;
283
- eventBuffer = [];
284
- flushTimer = null;
285
- tokenRefreshTimer = null;
286
- lastEmittedStatus = null;
287
- lastReportedStatus = null;
288
- droppedEventCount = 0;
289
- // Pending answer resolvers for askUserQuestion room-event fallback
290
- pendingAnswerResolvers = /* @__PURE__ */ new Map();
291
- // Dedup: suppress near-identical messages within a short window
292
- recentMessages = [];
293
- static DEDUP_WINDOW_MS = 3e4;
294
- static DEDUP_SIMILARITY_THRESHOLD = 0.7;
295
- static DEDUP_PREVIEW_LIMIT = 120;
296
- // Early-buffering: events that arrive before callbacks are registered
297
- earlyMessages = [];
298
- earlyStop = false;
299
- earlySoftStop = false;
300
- earlyModeChanges = [];
301
- // Registered callbacks
302
- messageCallback = null;
303
- stopCallback = null;
304
- softStopCallback = null;
305
- modeChangeCallback = null;
306
- apiKeyUpdateCallback = null;
307
- pullBranchCallback = null;
308
- runStartCommandCallback = null;
309
- earlyRunStartCommand = false;
310
- earlyPullBranches = [];
311
- spawnReviewCallback = null;
312
- earlySpawnReviews = [];
313
- spawnTuiCallback = null;
314
- earlySpawnTuis = [];
315
- probeUsageCallback = null;
316
- earlyProbeUsage = false;
317
- // PTY relay (S5 terminal). Single-slot callbacks, set per PtySession run.
318
- ptyInputCallback = null;
319
- ptyResizeCallback = null;
320
- constructor(config) {
321
- this.config = config;
322
- }
323
- get sessionId() {
324
- return this.config.sessionId;
325
- }
326
- get connected() {
327
- return this.socket?.connected ?? false;
328
- }
329
- // ── Typed service method call ──────────────────────────────────────────
330
- // Socket.IO keeps the SAME Socket instance across transport-level
331
- // reconnects (it only goes null on an explicit disconnect() teardown), so a
332
- // brief flap leaves `this.socket` non-null but `.connected === false`. Rather
333
- // than failing a tool call instantly (which the spawned `claude` surfaces as
334
- // "Conveyor MCP disconnected" and an excuse to go idle), we wait out a short
335
- // reconnect window, then emit with an ack timeout so a buffered packet whose
336
- // ack never returns can't hang the call forever. We do NOT auto-retry the
337
- // emit — re-sending a write could double-apply it; the agent prompt instructs
338
- // the model to retry the tool, which is the safe place to decide idempotency.
339
- static CALL_CONNECT_WAIT_MS = 2e4;
340
- static CALL_ACK_TIMEOUT_MS = 3e4;
341
- // ── Proactive socket recycle ───────────────────────────────────────────
342
- // Cloud Run severs every WebSocket at its request timeout (3600s is the
343
- // platform ceiling), so a socket that lives past ~60 minutes is killed at a
344
- // random moment — historically mid-tool-call, which let the spawned CLI
345
- // abandon its MCP session. Recycle the transport at a QUIET moment (no
346
- // in-flight RPC) before the platform deadline instead: an engine-level close
347
- // looks like a transport drop, so Socket.IO's auto-reconnect and the
348
- // io "reconnect" → reconnectToSession() recovery path run unchanged. Jitter
349
- // keeps a fleet of pods from recycling in one thundering herd.
350
- static SOCKET_RECYCLE_BASE_MS = 52 * 60 * 1e3;
351
- static SOCKET_RECYCLE_JITTER_MS = 4 * 60 * 1e3;
352
- static SOCKET_RECYCLE_BUSY_POLL_MS = 15e3;
353
- recycleTimer = null;
354
- pendingCalls = 0;
355
- async call(method, payload) {
356
- const socket = this.socket;
357
- if (!socket) {
358
- throw new Error(
359
- `Not connected (method: ${String(method)}, session: ${this.config.sessionId})`
360
- );
361
- }
362
- this.pendingCalls++;
363
- try {
364
- if (!socket.connected) {
365
- await this.waitForConnected(socket, _AgentConnection.CALL_CONNECT_WAIT_MS, String(method));
366
- }
367
- return await this.emitWithAck(socket, method, payload);
368
- } finally {
369
- this.pendingCalls--;
370
- }
371
- }
372
- /** (Re)arm the recycle timer — called on every successful (re)connect. */
373
- scheduleSocketRecycle() {
374
- this.clearSocketRecycle();
375
- const delay2 = _AgentConnection.SOCKET_RECYCLE_BASE_MS + Math.random() * _AgentConnection.SOCKET_RECYCLE_JITTER_MS;
376
- this.armRecycleTimer(delay2);
377
- }
378
- clearSocketRecycle() {
379
- if (this.recycleTimer) {
380
- clearTimeout(this.recycleTimer);
381
- this.recycleTimer = null;
382
- }
383
- }
384
- armRecycleTimer(delay2) {
385
- this.recycleTimer = setTimeout(() => {
386
- this.recycleTimer = null;
387
- this.attemptSocketRecycle();
388
- }, delay2);
389
- this.recycleTimer.unref?.();
390
- }
391
- attemptSocketRecycle() {
392
- const socket = this.socket;
393
- if (!socket?.connected) return;
394
- if (this.pendingCalls > 0) {
395
- this.armRecycleTimer(_AgentConnection.SOCKET_RECYCLE_BUSY_POLL_MS);
396
- return;
397
- }
398
- process.stderr.write(
399
- "[conveyor-agent] Recycling socket ahead of the platform request timeout\n"
400
- );
401
- socket.io.engine?.close?.();
402
- }
403
- /** Resolve once `socket` reports connected, or reject after `timeoutMs`. */
404
- waitForConnected(socket, timeoutMs, method) {
405
- return waitForConnected(socket, timeoutMs, () => {
406
- return new Error(
407
- `Not connected \u2014 socket did not reconnect within ${timeoutMs / 1e3}s (method: ${method}, session: ${this.config.sessionId}). Transient; retry.`
408
- );
409
- });
410
- }
411
- /** Emit an RPC and resolve on ack, rejecting if no ack arrives in time. */
412
- emitWithAck(socket, method, payload) {
413
- return callWithAck(
414
- socket,
415
- `agentSessionService:${String(method)}`,
416
- payload,
417
- {
418
- timeoutMs: _AgentConnection.CALL_ACK_TIMEOUT_MS,
419
- requireData: true,
420
- makeTimeoutError: () => new Error(
421
- `Service call timed out after ${_AgentConnection.CALL_ACK_TIMEOUT_MS / 1e3}s (method: ${String(method)}, session: ${this.config.sessionId}). Usually a transient reconnect; retry.`
422
- ),
423
- makeFailureError: (error) => new Error(error ?? `Service call failed: ${String(method)}`)
424
- }
425
- );
426
- }
427
- // ── Connection lifecycle ───────────────────────────────────────────────
428
- // oxlint-disable-next-line max-lines-per-function -- socket setup requires registering many co-located event handlers
429
- connect() {
430
- if (!this.config.apiUrl) {
431
- return Promise.reject(new Error("Cannot connect: apiUrl is empty"));
432
- }
433
- this.startProactiveTokenRefresh();
434
- return new Promise((resolve, reject) => {
435
- let settled = false;
436
- let attempts = 0;
437
- const maxInitialAttempts = 30;
438
- process.stderr.write(
439
- `[conveyor-agent] Connecting to ${this.config.apiUrl} (mode: ${this.config.runnerMode ?? "task"}, session: ${this.config.sessionId})
440
- `
441
- );
442
- this.socket = io(
443
- this.config.apiUrl,
444
- buildConveyorSocketOptions({
445
- taskToken: this.config.taskToken,
446
- runnerMode: this.config.runnerMode ?? "task"
447
- })
448
- );
449
- this.socket.on("session:message", (msg) => {
450
- const incoming = {
451
- content: msg.content,
452
- userId: msg.userId,
453
- ...msg.source && { source: msg.source },
454
- ...msg.files && { files: msg.files },
455
- ...msg.delivery === "prefill" && { delivery: msg.delivery }
456
- };
457
- if (this.messageCallback) this.messageCallback(incoming);
458
- else this.earlyMessages.push(incoming);
459
- });
460
- this.socket.on("session:stop", () => {
461
- if (this.stopCallback) this.stopCallback();
462
- else this.earlyStop = true;
463
- });
464
- this.socket.on("session:softStop", () => {
465
- if (this.softStopCallback) this.softStopCallback();
466
- else this.earlySoftStop = true;
467
- });
468
- this.socket.on("session:modeChange", (data) => {
469
- if (this.modeChangeCallback) this.modeChangeCallback(data);
470
- else this.earlyModeChanges.push(data);
471
- });
472
- this.socket.on(
473
- "session:answerQuestion",
474
- (data) => {
475
- const resolver = this.pendingAnswerResolvers.get(data.requestId);
476
- if (resolver) resolver(data.answers);
477
- }
478
- );
479
- this.socket.on("agentRunner:updateApiKey", (data) => {
480
- if (this.apiKeyUpdateCallback) this.apiKeyUpdateCallback(data);
481
- });
482
- this.socket.on("session:pullBranch", (data) => {
483
- if (this.pullBranchCallback) this.pullBranchCallback(data);
484
- else this.earlyPullBranches.push(data);
485
- });
486
- this.socket.on("session:spawnReview", (data) => {
487
- if (this.spawnReviewCallback) this.spawnReviewCallback(data);
488
- else this.earlySpawnReviews.push(data);
489
- });
490
- this.socket.on("session:spawnTui", (data) => {
491
- if (this.spawnTuiCallback) this.spawnTuiCallback(data);
492
- else this.earlySpawnTuis.push(data);
493
- });
494
- this.socket.on("session:probeUsage", () => {
495
- if (this.probeUsageCallback) this.probeUsageCallback();
496
- else this.earlyProbeUsage = true;
497
- });
498
- this.socket.on("session:runStartCommand", () => {
499
- if (this.runStartCommandCallback) this.runStartCommandCallback();
500
- else this.earlyRunStartCommand = true;
501
- });
502
- this.socket.on("pty:input", (data) => {
503
- if (data.sessionId && data.sessionId !== this.config.sessionId) return;
504
- this.ptyInputCallback?.(data.data);
505
- });
506
- this.socket.on("pty:resize", (data) => {
507
- if (data.sessionId && data.sessionId !== this.config.sessionId) return;
508
- this.ptyResizeCallback?.(data.cols, data.rows);
509
- });
510
- this.socket.on("connect", () => {
511
- process.stderr.write("[conveyor-agent] Socket connected\n");
512
- this.scheduleSocketRecycle();
513
- if (!settled) {
514
- settled = true;
515
- resolve();
516
- }
517
- });
518
- this.socket.on("connect_error", (err) => {
519
- attempts++;
520
- process.stderr.write(
521
- `[conveyor-agent] Connection error (attempt ${attempts}/${maxInitialAttempts}): ${err.message}
522
- `
523
- );
524
- if (!settled && attempts >= maxInitialAttempts) {
525
- settled = true;
526
- reject(
527
- new Error(
528
- `Failed to connect to ${this.config.apiUrl} after ${maxInitialAttempts} attempts: ${err.message}`
529
- )
530
- );
531
- }
532
- });
533
- this.socket.on("disconnect", (reason) => {
534
- process.stderr.write(`[conveyor-agent] Disconnected: ${reason}
535
- `);
536
- if (reason === "io server disconnect" || reason === "server namespace disconnect") {
537
- this.scheduleReconnectAfterServerDisconnect();
538
- }
539
- });
540
- this.socket.on("auth:rejected", () => {
541
- process.stderr.write("[conveyor-agent] Auth rejected by server, refreshing taskToken\n");
542
- void this.refreshTaskTokenFromBootstrap().catch(() => {
543
- });
544
- });
545
- this.socket.io.on("reconnect", (reconnectAttempts) => {
546
- process.stderr.write(
547
- `[conveyor-agent] Reconnected (attempts: ${reconnectAttempts}, ${(/* @__PURE__ */ new Date()).toISOString()})
548
- `
549
- );
550
- this.sendHeartbeat();
551
- void this.reconnectToSession();
552
- });
553
- this.socket.io.on("reconnect_attempt", () => {
554
- });
555
- });
556
- }
557
- disconnect() {
558
- this.stopProactiveTokenRefresh();
559
- this.clearSocketRecycle();
560
- this.stopHeartbeatWorker();
561
- void this.flushEvents();
562
- if (this.socket) {
563
- this.socket.io.reconnection(false);
564
- this.socket.removeAllListeners();
565
- this.socket.disconnect();
566
- this.socket = null;
567
- }
568
- }
569
- // ── Reconnect with retry ────────────────────────────────────────────
570
- //
571
- // Socket.IO already retries the transport forever. This higher-level helper
572
- // re-issues the `connectAgent` RPC after a successful reconnect to re-join
573
- // the session room and drain pending messages. We retry indefinitely with a
574
- // capped exponential backoff — a stranded codespace with a missing agent is
575
- // worse than a long-running reconnect loop, and a transient API outage
576
- // shouldn't kill the agent process.
577
- static RECONNECT_BASE_DELAY_MS = 2e3;
578
- static RECONNECT_MAX_DELAY_MS = 6e4;
579
- static RECONNECT_STATUS_EVERY_N = 3;
580
- isReconnecting = false;
581
- reconnectingAfterServerDisconnect = false;
582
- /** Capped exponential backoff (2s, 4s, 8s, 16s, 32s, then 60s steady) shared
583
- * by both reconnect loops (connectAgent-RPC and server-disconnect). */
584
- static backoffDelayMs(attempt) {
585
- return Math.min(
586
- _AgentConnection.RECONNECT_BASE_DELAY_MS * 2 ** Math.min(attempt - 1, 5),
587
- _AgentConnection.RECONNECT_MAX_DELAY_MS
588
- );
589
- }
590
- /** Sleep `ms`, unref'd so it never holds the process open on its own. */
591
- static delay(ms) {
592
- return new Promise((resolve) => {
593
- const timer = setTimeout(resolve, ms);
594
- timer.unref?.();
595
- });
596
- }
597
- /**
598
- * Invoked after every successful session reconnect (the `connectAgent` RPC
599
- * re-established the session room). The runner uses this to force a TUI
600
- * repaint: the reconnect may have landed on a different/restarted API
601
- * process whose PTY scrollback ring is empty, and a quiet terminal would
602
- * otherwise never re-seed it.
603
- */
604
- onReconnected;
605
- async reconnectToSession() {
606
- if (this.isReconnecting) return;
607
- this.isReconnecting = true;
608
- try {
609
- let attempt = 0;
610
- while (this.socket) {
611
- attempt++;
612
- try {
613
- const { pendingMessages } = await this.call("connectAgent", {
614
- sessionId: this.config.sessionId
615
- });
616
- this.drainPendingMessages(pendingMessages);
617
- process.stderr.write(
618
- `[conveyor-agent] Reconnected to session successfully (attempts: ${attempt})
619
- `
620
- );
621
- if (this.lastEmittedStatus && this.lastEmittedStatus !== this.lastReportedStatus) {
622
- const status = this.lastEmittedStatus;
623
- void this.call("reportAgentStatus", {
624
- sessionId: this.config.sessionId,
625
- status
626
- }).then(() => {
627
- this.lastReportedStatus = status;
628
- }).catch(() => {
629
- });
630
- }
631
- this.sendEvent({
632
- type: "agent_runner_status",
633
- reason: "reconnected",
634
- attempts: attempt
635
- });
636
- try {
637
- this.onReconnected?.();
638
- } catch {
639
- }
640
- return;
641
- } catch (err) {
642
- const errMsg = err instanceof Error ? err.message : String(err);
643
- const delayMs = _AgentConnection.backoffDelayMs(attempt);
644
- process.stderr.write(
645
- `[conveyor-agent] connectAgent failed (attempt ${attempt}): ${errMsg} \u2014 retrying in ${delayMs / 1e3}s
646
- `
647
- );
648
- if (this.looksLikeAuthError(errMsg)) {
649
- void this.refreshTaskTokenFromBootstrap().catch(() => {
650
- });
651
- }
652
- if (attempt % _AgentConnection.RECONNECT_STATUS_EVERY_N === 0) {
653
- this.sendEvent({
654
- type: "agent_runner_status",
655
- reason: "reconnecting",
656
- attempt
657
- });
658
- }
659
- await _AgentConnection.delay(delayMs);
660
- }
661
- }
662
- } finally {
663
- this.isReconnecting = false;
664
- }
665
- }
666
- /**
667
- * Drive a bounded reconnect after a server-initiated disconnect. Loops until
668
- * the socket reconnects or is torn down, nudging socket.connect() on each
669
- * pass with a capped exponential backoff. A token refresh is attempted every
670
- * pass (rate-limited to once/60s inside refreshTaskTokenFromBootstrap) but
671
- * its result NEVER gates the reconnect — the socket must recover even when
672
- * there is no fresh token to apply.
673
- */
674
- scheduleReconnectAfterServerDisconnect() {
675
- if (this.reconnectingAfterServerDisconnect) return;
676
- this.reconnectingAfterServerDisconnect = true;
677
- void this.reconnectAfterServerDisconnect().finally(() => {
678
- this.reconnectingAfterServerDisconnect = false;
679
- });
680
- }
681
- async reconnectAfterServerDisconnect() {
682
- let attempt = 0;
683
- while (this.socket && !this.socket.connected) {
684
- attempt++;
685
- try {
686
- await this.refreshTaskTokenFromBootstrap();
687
- } catch {
688
- }
689
- const socket = this.socket;
690
- if (!socket || socket.connected) return;
691
- socket.connect();
692
- try {
693
- await this.waitForConnected(
694
- socket,
695
- _AgentConnection.CALL_CONNECT_WAIT_MS,
696
- "server-disconnect-reconnect"
697
- );
698
- this.sendHeartbeat();
699
- void this.reconnectToSession();
700
- return;
701
- } catch {
702
- const delayMs = _AgentConnection.backoffDelayMs(attempt);
703
- process.stderr.write(
704
- `[conveyor-agent] server-disconnect reconnect attempt ${attempt} did not connect within ${_AgentConnection.CALL_CONNECT_WAIT_MS / 1e3}s \u2014 retrying in ${delayMs / 1e3}s
705
- `
706
- );
707
- await _AgentConnection.delay(delayMs);
708
- }
709
- }
710
- }
711
- looksLikeAuthError(message) {
712
- return /unauthor|forbid|auth|token|session (?:not found|expired|invalid)|invalid session/i.test(
713
- message
714
- );
715
- }
716
- // ── Proactive task-token refresh ────────────────────────────────────────
717
- //
718
- // Socket.IO only re-presents the taskToken on a (re)connect handshake, and
719
- // the server only re-validates the JWT then. So a token that expires while
720
- // the socket stays connected goes unnoticed until the next RPC fails. Re-mint
721
- // periodically from the bootstrap endpoint — refreshFromBootstrap() updates
722
- // both this.config.taskToken and socket.auth.taskToken, so any later
723
- // reconnect carries a fresh token. No-ops for project mode / missing
724
- // codespace env, and is rate-limited to once/60s inside refreshFromBootstrap.
725
- startProactiveTokenRefresh() {
726
- if (this.tokenRefreshTimer) return;
727
- this.tokenRefreshTimer = setInterval(() => {
728
- void this.refreshTaskTokenFromBootstrap().catch(() => {
729
- });
730
- }, TOKEN_REFRESH_INTERVAL_MS);
731
- this.tokenRefreshTimer.unref?.();
732
- }
733
- stopProactiveTokenRefresh() {
734
- if (this.tokenRefreshTimer) {
735
- clearInterval(this.tokenRefreshTimer);
736
- this.tokenRefreshTimer = null;
737
- }
738
- }
739
- drainPendingMessages(messages) {
740
- for (const msg of messages) {
741
- if (!msg.content) continue;
742
- if (this.messageCallback) {
743
- this.messageCallback({ content: msg.content, userId: msg.userId });
744
- } else {
745
- this.earlyMessages.push({ content: msg.content, userId: msg.userId });
746
- }
747
- }
748
- }
749
- // ── Callback registration with early-buffer draining ───────────────
750
- onMessage(callback) {
751
- this.messageCallback = callback;
752
- for (const msg of this.earlyMessages) callback(msg);
753
- this.earlyMessages = [];
754
- }
755
- onStop(callback) {
756
- this.stopCallback = callback;
757
- if (this.earlyStop) {
758
- callback();
759
- this.earlyStop = false;
760
- }
761
- }
762
- onSoftStop(callback) {
763
- this.softStopCallback = callback;
764
- if (this.earlySoftStop) {
765
- callback();
766
- this.earlySoftStop = false;
767
- }
768
- }
769
- onModeChange(callback) {
770
- this.modeChangeCallback = callback;
771
- for (const data of this.earlyModeChanges) callback(data);
772
- this.earlyModeChanges = [];
773
- }
774
- onApiKeyUpdate(callback) {
775
- this.apiKeyUpdateCallback = callback;
776
- }
777
- onPullBranch(callback) {
778
- this.pullBranchCallback = callback;
779
- for (const data of this.earlyPullBranches) callback(data);
780
- this.earlyPullBranches = [];
781
- }
782
- onSpawnReview(callback) {
783
- this.spawnReviewCallback = callback;
784
- for (const data of this.earlySpawnReviews) callback(data);
785
- this.earlySpawnReviews = [];
786
- }
787
- /**
788
- * Report that a same-pod review child failed to spawn (fire-and-forget).
789
- * The server Ends the orphaned review session and falls back to a dedicated
790
- * review pod. sessionId is OUR (builder) session — the task-identity guard runs on
791
- * it; the review session is identified separately.
792
- */
793
- reportReviewSpawnFailure(reviewSessionId, error) {
794
- if (!this.socket) return;
795
- void this.call("reportReviewSpawnFailure", {
796
- sessionId: this.config.sessionId,
797
- reviewSessionId,
798
- ...error ? { error: error.slice(0, 2e3) } : {}
799
- }).catch(() => {
800
- });
801
- }
802
- /**
803
- * Report that this pod's git credential is dead and refreshing did not fix
804
- * it (fire-and-forget).
805
- *
806
- * Purely diagnostic. Until this existed a pod could lose git entirely and
807
- * leave no server-side trace at all — the refresh RPC succeeded every time,
808
- * so the failure was visible only in pod stderr, which is why it took two
809
- * investigations to attribute. `tokenShape` describes the served credential
810
- * (length, prefix class, mtime) and NEVER carries its value.
811
- */
812
- reportCredentialFailure(details) {
813
- if (!this.socket) return;
814
- void this.call("reportCredentialFailure", {
815
- sessionId: this.config.sessionId,
816
- ...details.error ? { error: details.error.slice(0, 2e3) } : {},
817
- ...details.tokenShape ? { tokenShape: details.tokenShape.slice(0, 500) } : {},
818
- ...details.healed === void 0 ? {} : { healed: details.healed }
819
- }).catch(() => {
820
- });
821
- }
822
- /**
823
- * Ask the server to destroy and recreate this pod (fire-and-forget). The
824
- * agent calls this only when it has proven it cannot recover in place — the
825
- * shared `~/.claude` GCS FUSE mount is dead and no in-container action can
826
- * remount it. The server rate-limits the recycle and posts `reason` to the
827
- * card; old servers that don't know the method reject harmlessly, leaving
828
- * today's behavior (a failed turn with a chat warning).
829
- */
830
- requestWorkspaceRecycle(reason) {
831
- if (!this.socket) return;
832
- void this.call("requestWorkspaceRecycle", {
833
- sessionId: this.config.sessionId,
834
- reason: reason.slice(0, 2e3)
835
- }).catch(() => {
836
- });
837
- }
838
- onSpawnTui(callback) {
839
- this.spawnTuiCallback = callback;
840
- for (const data of this.earlySpawnTuis) callback(data);
841
- this.earlySpawnTuis = [];
842
- }
843
- /** Register the on-demand usage-refresh handler; drains an early-buffered
844
- * `session:probeUsage` that arrived before the runner was ready. */
845
- onProbeUsage(callback) {
846
- this.probeUsageCallback = callback;
847
- if (this.earlyProbeUsage) {
848
- this.earlyProbeUsage = false;
849
- callback();
850
- }
851
- }
852
- /**
853
- * Report that a same-pod TUI/shell child failed to spawn (fire-and-forget).
854
- * The server Ends the orphaned session — no fallback pod (unlike review).
855
- * sessionId is OUR (builder) session — the task-identity guard runs on it.
856
- */
857
- reportSessionSpawnFailure(spawnedSessionId, error) {
858
- if (!this.socket) return;
859
- void this.call("reportSessionSpawnFailure", {
860
- sessionId: this.config.sessionId,
861
- spawnedSessionId,
862
- ...error ? { error: error.slice(0, 2e3) } : {}
863
- }).catch(() => {
864
- });
865
- }
866
- /** Register the restart handler, draining a `session:runStartCommand` that
867
- * arrived before the supervisor was ready. Collapsed to one drain: two
868
- * clicks during boot should produce one restart, not two competing ones. */
869
- onRunStartCommand(callback) {
870
- this.runStartCommandCallback = callback;
871
- if (this.earlyRunStartCommand) {
872
- this.earlyRunStartCommand = false;
873
- callback();
874
- }
875
- }
876
- // ── PTY relay (S5 Connected-TUI terminal) ──────────────────────────
877
- /**
878
- * Forward a raw chunk of terminal output to the S2 relay (fire-and-forget).
879
- * The first chunk creates the server-side scrollback ring, which is what
880
- * surfaces the terminal in the UI. `dims` seed/refresh the ring geometry.
881
- */
882
- sendPtyOutput(data, dims) {
883
- if (!this.socket) return;
884
- void this.call("ptyOutput", {
885
- sessionId: this.config.sessionId,
886
- data,
887
- ...dims ? { cols: dims.cols, rows: dims.rows } : {}
888
- }).catch(() => {
889
- });
890
- }
891
- /**
892
- * Forward one compact chat-proxy event derived from the transcript JSONL to
893
- * the relay (fire-and-forget). Feeds the experimental chat PTY proxy ring.
894
- * Old servers that don't know the method reject harmlessly.
895
- */
896
- sendPtyChatEvent(event) {
897
- if (!this.socket) return;
898
- void this.call("ptyChatEvent", {
899
- sessionId: this.config.sessionId,
900
- event
901
- }).catch(() => {
902
- });
903
- }
904
- /**
905
- * Report that the interactive CLI process for this session has died and no
906
- * respawn is imminent (fire-and-forget). The server clears the scrollback
907
- * ring and broadcasts pty:ended so clients hide the Connected-TUI tab. Old
908
- * servers that don't know the method reject harmlessly.
909
- */
910
- sendPtyEnded() {
911
- if (!this.socket) return;
912
- void this.call("ptyEnded", { sessionId: this.config.sessionId }).catch(() => {
913
- });
914
- }
915
- /**
916
- * Report the port this pod's in-pod PTY stream server bound to, or null when
917
- * it stopped (fire-and-forget). The server persists it so a viewer can be
918
- * handed a port-scoped tunnel URL and stream the TUI straight from the pod.
919
- * Old servers that don't know the method reject harmlessly — the session then
920
- * just stays on the relay transport.
921
- */
922
- reportPtyStream(port) {
923
- if (!this.socket) return;
924
- void this.call("reportPtyStream", {
925
- sessionId: this.config.sessionId,
926
- port
927
- }).catch(() => {
928
- });
929
- }
930
- /** Subscribe to relayed keystrokes. Returns an unsubscribe fn. */
931
- onPtyInput(handler) {
932
- this.ptyInputCallback = handler;
933
- return () => {
934
- if (this.ptyInputCallback === handler) this.ptyInputCallback = null;
935
- };
936
- }
937
- /** Subscribe to relayed (reconciled) terminal resizes. Returns an unsubscribe fn. */
938
- onPtyResize(handler) {
939
- this.ptyResizeCallback = handler;
940
- return () => {
941
- if (this.ptyResizeCallback === handler) this.ptyResizeCallback = null;
942
- };
943
- }
944
- // ── Convenience methods (thin wrappers around call / emit) ─────────
945
- async emitStatus(status, reason, questionText) {
946
- this.lastEmittedStatus = status;
947
- await this.flushEvents();
948
- const payload = {
949
- sessionId: this.config.sessionId,
950
- status,
951
- ...reason ? { reason } : {},
952
- // Only sent with a pending TUI questionnaire (reason "user_question") so
953
- // the server can surface the real question text in the notification.
954
- ...questionText ? { questionText } : {}
955
- };
956
- const AWAIT_STATUSES = ["idle", "waiting_for_input", "connected"];
957
- if (AWAIT_STATUSES.includes(status)) {
958
- try {
959
- await this.call("reportAgentStatus", payload);
960
- this.lastReportedStatus = status;
961
- } catch {
962
- }
963
- } else {
964
- void this.call("reportAgentStatus", payload).then(() => {
965
- this.lastReportedStatus = status;
966
- }).catch(() => {
967
- });
968
- }
969
- }
970
- postChatMessage(content, milestone) {
971
- if (!this.socket) return;
972
- if (this.suppressIfDuplicate(content)) return;
973
- void this.call("postAgentMessage", {
974
- sessionId: this.config.sessionId,
975
- content,
976
- milestone
977
- }).catch(() => {
978
- });
979
- }
980
- // Awaitable variant of postChatMessage for callers that need to guarantee
981
- // the message is acknowledged by the server before proceeding (e.g. before
982
- // aborting the session). Dedup still applies; a suppressed message resolves
983
- // immediately without hitting the wire.
984
- async postChatMessageAwait(content, milestone) {
985
- if (!this.socket) return;
986
- if (this.suppressIfDuplicate(content)) return;
987
- try {
988
- await this.call("postAgentMessage", {
989
- sessionId: this.config.sessionId,
990
- content,
991
- milestone
992
- });
993
- } catch (err) {
994
- process.stderr.write(
995
- `[conveyor-agent] postChatMessageAwait failed: ${err instanceof Error ? err.message : String(err)}
996
- `
997
- );
998
- }
999
- }
1000
- suppressIfDuplicate(content) {
1001
- const d = this.checkAndTrackDuplicate(content);
1002
- if (!d.duplicate) return false;
1003
- process.stderr.write(
1004
- `[dedup] Suppressed near-duplicate (matched: "${d.matchedMessagePreview}")
1005
- `
1006
- );
1007
- return true;
1008
- }
1009
- // Exposed so `post_to_chat` can surface suppression back to the agent.
1010
- checkAndTrackDuplicate(content) {
1011
- const now = Date.now();
1012
- this.recentMessages = this.recentMessages.filter(
1013
- (m) => now - m.timestamp < _AgentConnection.DEDUP_WINDOW_MS
1014
- );
1015
- const words = new Set(
1016
- content.toLowerCase().replace(/[^\w\s]/g, "").split(/\s+/).filter((w) => w.length >= 3)
1017
- );
1018
- if (words.size === 0) return { duplicate: false };
1019
- for (const recent of this.recentMessages) {
1020
- let intersection = 0;
1021
- for (const w of words) if (recent.words.has(w)) intersection++;
1022
- const union = (/* @__PURE__ */ new Set([...words, ...recent.words])).size;
1023
- if (union > 0 && intersection / union > _AgentConnection.DEDUP_SIMILARITY_THRESHOLD) {
1024
- return { duplicate: true, matchedMessagePreview: recent.preview };
1025
- }
1026
- }
1027
- const max = _AgentConnection.DEDUP_PREVIEW_LIMIT;
1028
- const preview = content.length > max ? content.slice(0, max) + "\u2026" : content;
1029
- this.recentMessages.push({ words, timestamp: now, preview });
1030
- if (this.recentMessages.length > 3) this.recentMessages.shift();
1031
- return { duplicate: false };
1032
- }
1033
- /**
1034
- * @param loopStatus overrides the status derived from the last emitted
1035
- * runner status. SessionRunner passes it so an idle runner that still has
1036
- * background work outstanding in the pod beats as `waiting` (→ `active` on
1037
- * the wire) rather than `idle`, which would let the workspace activity
1038
- * clock expire mid-gate. See connection/loop-lag.ts `heartbeatStatusFor`.
1039
- *
1040
- * Without an override the status comes from `loopStatusForRunnerStatus`, the
1041
- * same total classifier SessionRunner uses, so both paths agree. This used to
1042
- * be a partial map covering 5 of the 11 `AgentRunnerStatus` values with a
1043
- * `?? "active"` fallback, which meant a parked runner (`waiting_for_input`,
1044
- * `finished`, `error`, `stopping`, `disconnected`) beat as ACTIVE on every
1045
- * no-arg call site — the reconnect paths below, and the shell/project/adhoc
1046
- * runners, which never pass a loop status at all. That renewed the workspace
1047
- * activity clock for an agent doing nothing, so the card stayed "active" and
1048
- * its pod stayed up long past the project's inactivity window.
1049
- */
1050
- sendHeartbeat(loopLagMs, loopStatus) {
1051
- if (!this.socket) return;
1052
- const heartbeatStatus = heartbeatStatusFor(
1053
- loopStatus ?? loopStatusForRunnerStatus(this.lastEmittedStatus)
1054
- );
1055
- void this.call("heartbeat", {
1056
- sessionId: this.config.sessionId,
1057
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1058
- status: heartbeatStatus,
1059
- ...loopLagMs !== void 0 && loopLagMs > 0 ? { loopLagMs: Math.round(loopLagMs) } : {}
1060
- }).catch(() => {
1061
- });
1062
- }
1063
- // ── Starvation-proof heartbeat worker ────────────────────────────────
1064
- //
1065
- // A worker thread with its own event loop + Socket.IO connection keeps the
1066
- // v3 session lease renewed even when the MAIN loop is stalled (the failure
1067
- // mode where a heavy gate got the session declared stranded and restarted
1068
- // mid-run). Best-effort by design: any spawn/runtime failure just degrades
1069
- // heartbeats to main-loop-only. See heartbeat-worker.ts for the policy.
1070
- heartbeatWorker = null;
1071
- startHeartbeatWorker(sharedBuffer, intervalMs = 3e4) {
1072
- if (this.heartbeatWorker) return;
1073
- try {
1074
- const workerUrl = new URL("./heartbeat-worker.js", import.meta.url);
1075
- if (!existsSync(fileURLToPath(workerUrl))) {
1076
- process.stderr.write(
1077
- "[conveyor-agent] heartbeat worker bundle not found \u2014 main-loop heartbeat only\n"
1078
- );
1079
- return;
1080
- }
1081
- const worker = new Worker(workerUrl, {
1082
- workerData: {
1083
- apiUrl: this.config.apiUrl,
1084
- taskToken: this.config.taskToken,
1085
- sessionId: this.config.sessionId,
1086
- runnerMode: this.config.runnerMode ?? "task",
1087
- sharedBuffer,
1088
- intervalMs
1089
- }
1090
- });
1091
- worker.unref();
1092
- worker.on("error", (err) => {
1093
- const message = err instanceof Error ? err.message : String(err);
1094
- process.stderr.write(`[conveyor-agent] heartbeat worker error: ${message}
1095
- `);
1096
- this.heartbeatWorker = null;
1097
- });
1098
- worker.on("exit", (code) => {
1099
- if (code !== 0) {
1100
- process.stderr.write(`[conveyor-agent] heartbeat worker exited (code ${code})
1101
- `);
1102
- }
1103
- this.heartbeatWorker = null;
1104
- });
1105
- this.heartbeatWorker = worker;
1106
- process.stderr.write("[conveyor-agent] heartbeat worker started\n");
1107
- } catch (err) {
1108
- process.stderr.write(
1109
- `[conveyor-agent] heartbeat worker failed to start: ${err instanceof Error ? err.message : String(err)}
1110
- `
1111
- );
1112
- this.heartbeatWorker = null;
1113
- }
1114
- }
1115
- stopHeartbeatWorker() {
1116
- const worker = this.heartbeatWorker;
1117
- this.heartbeatWorker = null;
1118
- if (worker) void worker.terminate();
1119
- }
1120
- emitModeChanged(agentMode) {
1121
- this.sendEvent({ type: "mode_changed", agentMode });
1122
- }
1123
- async updateTaskFields(fields) {
1124
- if (!this.socket) return { ok: false, error: "socket not connected" };
1125
- try {
1126
- await this.call("updateTaskFields", { sessionId: this.config.sessionId, ...fields });
1127
- return { ok: true };
1128
- } catch (err) {
1129
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
1130
- }
1131
- }
1132
- storeSessionId(sdkSessionId) {
1133
- void this.call("storeSessionId", { sessionId: this.config.sessionId, sdkSessionId }).catch(
1134
- () => {
1135
- }
1136
- );
1137
- }
1138
- /** Report the full current set of runtime-discovered listening ports.
1139
- * Throws on failure so the PortDiscovery poller can retry on its next
1140
- * tick (a swallowed error here would silently drop the delta). */
1141
- async reportDiscoveredPorts(ports) {
1142
- await this.call("reportDiscoveredPorts", { sessionId: this.config.sessionId, ports });
1143
- }
1144
- /** Boot-milestone report over the socket — the codespace-parity fallback
1145
- * for the GKE pod bootstrap-token route. Fire-and-forget: a failed report
1146
- * must never delay or fail the boot path. */
1147
- reportBootMilestone(key) {
1148
- void this.call("reportBootMilestone", { sessionId: this.config.sessionId, key }).catch(
1149
- () => {
1150
- }
1151
- );
1152
- }
1153
- // ── Typing indicators ───────────────────────────────────────────────
1154
- sendTypingStart() {
1155
- this.sendEvent({ type: "agent_typing_start" });
1156
- }
1157
- sendTypingStop() {
1158
- this.sendEvent({ type: "agent_typing_stop" });
1159
- }
1160
- // ── RPC convenience wrappers (v6 compat, will migrate to call()) ───
1161
- emitRateLimitPause(resetsAt) {
1162
- this.sendEvent({ type: "rate_limit_update", resetsAt });
1163
- }
1164
- updateStatus(status) {
1165
- this.emitStatus(status);
1166
- }
1167
- /**
1168
- * The session's key hit a hard usage cap — ask the server to stamp it
1169
- * limited and hand back the best remaining key's credential env (or a
1170
- * requeue confirmation when none is left). Awaited: the caller swaps
1171
- * credentials and resumes on success, so it needs the real response.
1172
- */
1173
- async cycleCodingAgentKey(rateLimitType, resetsAt) {
1174
- return await this.call("cycleCodingAgentKey", {
1175
- sessionId: this.config.sessionId,
1176
- rateLimitType,
1177
- ...resetsAt ? { resetsAt } : {}
1178
- });
1179
- }
1180
- // ── Question handling ──────────────────────────────────────────────
1181
- async askUserQuestion(questions) {
1182
- const questionText = questions.map(
1183
- (q) => `**${q.header}**
1184
- ${q.question}${q.options.length ? "\n" + q.options.map((o) => `- ${o.label}: ${o.description}`).join("\n") : ""}`
1185
- ).join("\n\n");
1186
- const requestId = crypto.randomUUID();
1187
- const roomEventPromise = new Promise((resolve) => {
1188
- this.pendingAnswerResolvers.set(requestId, resolve);
1189
- });
1190
- const rpcPromise = this.call("askUserQuestion", {
1191
- sessionId: this.config.sessionId,
1192
- question: questionText,
1193
- requestId,
1194
- questions
1195
- }).then((res) => res.answers);
1196
- try {
1197
- return await Promise.race([rpcPromise, roomEventPromise]);
1198
- } finally {
1199
- this.pendingAnswerResolvers.delete(requestId);
1200
- }
1201
- }
1202
- // ── Typed service method wrappers ───────────────────────────────────
1203
- getTaskProperties() {
1204
- return this.call("getTaskProperties", { sessionId: this.config.sessionId });
1205
- }
1206
- triggerIdentification() {
1207
- return this.call("triggerIdentification", { sessionId: this.config.sessionId });
1208
- }
1209
- handoffToImplementer(payload) {
1210
- return this.call("handoffToImplementer", {
1211
- sessionId: this.config.sessionId,
1212
- ...payload
1213
- });
1214
- }
1215
- async refreshAuthToken() {
1216
- const result = await this.refreshFromBootstrap();
1217
- return result.refreshedClaude;
1218
- }
1219
- /**
1220
- * Refresh the in-process `CONVEYOR_TASK_TOKEN` from the bootstrap endpoint.
1221
- * Returns true if a new token was applied. Rate-limited locally to once per
1222
- * 60s so a tight auth-rejected loop can't hammer the bootstrap endpoint —
1223
- * the server enforces the same window via `lastBootstrapAt`.
1224
- */
1225
- lastTaskTokenRefreshAt = 0;
1226
- async refreshTaskTokenFromBootstrap() {
1227
- const result = await this.refreshFromBootstrap();
1228
- return result.refreshedTaskToken;
1229
- }
1230
- refreshFromBootstrap() {
1231
- const none = Promise.resolve({ refreshedClaude: false, refreshedTaskToken: false });
1232
- const podBootstrapToken = process.env.POD_BOOTSTRAP_TOKEN;
1233
- const codespaceName = process.env.CODESPACE_NAME;
1234
- const apiUrl = this.config.apiUrl;
1235
- if (!apiUrl || !podBootstrapToken && !codespaceName) {
1236
- return none;
1237
- }
1238
- const now = Date.now();
1239
- if (now - this.lastTaskTokenRefreshAt < 6e4) {
1240
- return none;
1241
- }
1242
- this.lastTaskTokenRefreshAt = now;
1243
- if (podBootstrapToken) {
1244
- return this.refreshFromV3Bootstrap(apiUrl, podBootstrapToken);
1245
- }
1246
- if (!codespaceName) return none;
1247
- return this.refreshFromCodespaceBootstrap(apiUrl, codespaceName);
1248
- }
1249
- /** Legacy GitHub Codespaces refresh path — keys on instance name. */
1250
- async refreshFromCodespaceBootstrap(apiUrl, codespaceName) {
1251
- const bootstrapToken = process.env.CONVEYOR_BOOTSTRAP_TOKEN;
1252
- const result = await fetchBootstrap({
1253
- apiUrl,
1254
- instanceName: codespaceName,
1255
- bootstrapToken
1256
- // Do not retry on http errors during a runtime refresh — a 401/403
1257
- // means the token is consumed / session terminal and retrying won't
1258
- // help. Network/timeout still retry inside fetchBootstrap.
1259
- });
1260
- if (!result.ok) {
1261
- logger.warn("bootstrap refresh abandoned \u2014 pod credentials will go stale", {
1262
- path: "codespace",
1263
- reason: result.reason,
1264
- status: result.status,
1265
- attempts: result.attempts,
1266
- detail: result.detail
1267
- });
1268
- return { refreshedClaude: false, refreshedTaskToken: false };
1269
- }
1270
- const previousTaskToken = process.env.CONVEYOR_TASK_TOKEN;
1271
- applyBootstrapToEnv(result.config);
1272
- const env = result.config.envVars ?? {};
1273
- syncBundleGithubToken(env.CONVEYOR_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN);
1274
- const refreshedTaskToken = result.config.mode !== "project" && Boolean(result.config.taskToken) && result.config.taskToken !== previousTaskToken;
1275
- if (refreshedTaskToken && result.config.taskToken) {
1276
- this.config.taskToken = result.config.taskToken;
1277
- if (this.socket) {
1278
- const auth = this.socket.auth;
1279
- if (auth && typeof auth === "object") {
1280
- auth.taskToken = result.config.taskToken;
1281
- }
1282
- }
1283
- this.heartbeatWorker?.postMessage({ taskToken: result.config.taskToken });
1284
- }
1285
- const refreshedClaude = Boolean(result.config.envVars?.CLAUDE_CODE_OAUTH_TOKEN);
1286
- return { refreshedClaude, refreshedTaskToken };
1287
- }
1288
- /**
1289
- * v3 refresh: re-fetch the full bootstrap bundle from the pod's bound v3
1290
- * route and swap the credentials in place. The GitHub installation token
1291
- * dies at ~1h and the sessionJwt at 24h; re-polling the bootstrap GET with
1292
- * the same pod token is the designed refresh mechanism.
1293
- */
1294
- async refreshFromV3Bootstrap(apiUrl, bootstrapToken) {
1295
- const bundle = await this.pollBundleWithRateLimitRetry(apiUrl, bootstrapToken);
1296
- if (!bundle) {
1297
- return { refreshedClaude: false, refreshedTaskToken: false };
1298
- }
1299
- const previousTaskToken = process.env.CONVEYOR_TASK_TOKEN;
1300
- for (const [key, value] of Object.entries(bundle.envVars ?? {})) {
1301
- process.env[key] = value;
1302
- }
1303
- if (bundle.githubToken) {
1304
- process.env.CONVEYOR_GITHUB_TOKEN = bundle.githubToken;
1305
- this.applyBundleCredentialFiles(bundle, previousTaskToken);
1306
- }
1307
- if (bundle.anthropicKey) process.env.ANTHROPIC_API_KEY = bundle.anthropicKey;
1308
- if (bundle.gcpToken) process.env.CLOUDSDK_AUTH_ACCESS_TOKEN = bundle.gcpToken;
1309
- const refreshedTaskToken = Boolean(bundle.sessionJwt) && bundle.sessionJwt !== previousTaskToken;
1310
- if (refreshedTaskToken) {
1311
- process.env.CONVEYOR_TASK_TOKEN = bundle.sessionJwt;
1312
- this.config.taskToken = bundle.sessionJwt;
1313
- if (this.socket) {
1314
- const auth = this.socket.auth;
1315
- if (auth && typeof auth === "object") {
1316
- auth.taskToken = bundle.sessionJwt;
1317
- }
1318
- }
1319
- this.heartbeatWorker?.postMessage({ taskToken: bundle.sessionJwt });
1320
- }
1321
- const refreshedClaude = Boolean(bundle.envVars?.CLAUDE_CODE_OAUTH_TOKEN);
1322
- return { refreshedClaude, refreshedTaskToken };
1323
- }
1324
- /**
1325
- * Write the bundle's GitHub token to the shared credential files — unless the
1326
- * bundle belongs to another session.
1327
- *
1328
- * The bootstrap GET is keyed by the POD, so on a pod also hosting a same-pod
1329
- * review it can resolve to the reader session, whose token is read-only.
1330
- * Writing that over the shared files silently downgrades the builder's push
1331
- * credential. Our own taskToken carries the same claims, so it is what we
1332
- * compare against.
1333
- *
1334
- * The legitimate case this path exists for — our own session's bundle
1335
- * refreshing the token when the RPC is failing — is unaffected.
1336
- */
1337
- applyBundleCredentialFiles(bundle, previousTaskToken) {
1338
- const self = readBundleIdentity(previousTaskToken);
1339
- const result = applyBundleGithubToken(bundle, {
1340
- sessionId: this.config.sessionId || self.sessionId,
1341
- ...self.role ? { role: self.role } : {}
1342
- });
1343
- if (!result.written && result.reason) {
1344
- process.stderr.write(
1345
- `[conveyor-agent] Skipped writing GitHub credential files from the bootstrap bundle: ${result.reason}
1346
- `
1347
- );
1348
- }
1349
- }
1350
- /**
1351
- * Poll the bootstrap bundle once (maxWaitMs 0), retrying only on a transient
1352
- * 429 (standby-pool pods share one Cloud NAT IP against podBootstrapLimiter)
1353
- * with a short backoff. Returns null when the refresh should be abandoned —
1354
- * a non-429 error, or 429s past the retry budget — so the caller no-ops
1355
- * instead of parking a RUNNING pod in a poll loop.
1356
- */
1357
- async pollBundleWithRateLimitRetry(apiUrl, bootstrapToken) {
1358
- const retryDelaysMs = [1e3, 3e3];
1359
- for (let attempt = 0; ; attempt++) {
1360
- try {
1361
- return await pollUntilBound({ apiUrl, bootstrapToken, maxWaitMs: 0 });
1362
- } catch (err) {
1363
- const isRateLimited = err instanceof PollUntilBoundHttpError && err.status === 429;
1364
- if (!isRateLimited || attempt >= retryDelaysMs.length) {
1365
- logger.warn("bootstrap refresh abandoned \u2014 pod credentials will go stale", {
1366
- path: "v3",
1367
- attempt,
1368
- rateLimited: isRateLimited,
1369
- error: err instanceof Error ? err.message : String(err)
1370
- });
1371
- return null;
1372
- }
1373
- await new Promise((resolve) => {
1374
- setTimeout(resolve, retryDelaysMs[attempt]);
1375
- });
1376
- }
1377
- }
1378
- }
1379
- // ── Event buffering ────────────────────────────────────────────────
1380
- sendEvent(event) {
1381
- if (!this.socket) return;
1382
- this.enqueueEvents([{ event }], false);
1383
- }
1384
- /** Append (or, on `toFront`, prepend for a failed-flush re-queue) events to
1385
- * the buffer, then cap + arm the flush timer. Single owner of the overflow
1386
- * policy so append and re-queue can't diverge on the drop accounting. */
1387
- enqueueEvents(entries, toFront) {
1388
- if (toFront) this.eventBuffer.unshift(...entries);
1389
- else this.eventBuffer.push(...entries);
1390
- while (this.eventBuffer.length > MAX_EVENT_BUFFER) {
1391
- this.eventBuffer.shift();
1392
- this.droppedEventCount++;
1393
- if (this.droppedEventCount === 1 || this.droppedEventCount % 500 === 0) {
1394
- process.stderr.write(
1395
- `[conveyor-agent] eventBuffer overflow \u2014 dropped ${this.droppedEventCount} event(s) (cap: ${MAX_EVENT_BUFFER})
1396
- `
1397
- );
1398
- }
1399
- }
1400
- if (this.socket && !this.flushTimer) {
1401
- this.flushTimer = setTimeout(() => void this.flushEvents(), EVENT_BATCH_MS);
1402
- }
1403
- }
1404
- async flushEvents() {
1405
- if (this.flushTimer) {
1406
- clearTimeout(this.flushTimer);
1407
- this.flushTimer = null;
1408
- }
1409
- if (!this.socket || this.eventBuffer.length === 0) return;
1410
- const entries = this.eventBuffer;
1411
- this.eventBuffer = [];
1412
- const events = entries.map((entry) => entry.event);
1413
- try {
1414
- await this.call("emitAgentEvent", { sessionId: this.config.sessionId, events });
1415
- } catch {
1416
- this.requeueFailedEvents(entries);
1417
- }
1418
- }
1419
- /** Put a failed flush's events back at the FRONT of the buffer, preserving
1420
- * order, via the shared cap-and-arm path. */
1421
- requeueFailedEvents(entries) {
1422
- this.enqueueEvents(entries, true);
1423
- }
1424
- };
1425
-
1426
- // src/connection/auth-errors.ts
1427
- function isPermissionDeniedError(err) {
1428
- const message = err instanceof Error ? err.message : String(err);
1429
- return /insufficient permissions|authentication required/i.test(message);
1430
- }
1431
-
1432
- // src/runner/git-run.ts
1433
- import { execFile } from "child_process";
1434
- import { promisify } from "util";
1435
- var execFileAsync = promisify(execFile);
1436
- var GIT_TIMEOUT_MS = 6e4;
1437
- var GIT_SLOW_TIMEOUT_MS = 12e4;
1438
- var GIT_MAX_BUFFER = 16 * 1024 * 1024;
1439
- async function git(cwd, args, timeoutMs = GIT_TIMEOUT_MS) {
1440
- if (workbenchEnabled()) {
1441
- try {
1442
- const { stdout: stdout2 } = await getWorkbenchClient().execFile("git", args, {
1443
- cwd,
1444
- timeout: timeoutMs,
1445
- maxBuffer: GIT_MAX_BUFFER
1446
- });
1447
- return stdout2.trim();
1448
- } catch (err) {
1449
- if (err instanceof Error && err.message.startsWith("Command timed out:")) {
1450
- err.killed = true;
1451
- }
1452
- throw err;
1453
- }
1454
- }
1455
- const { stdout } = await execFileAsync("git", args, {
1456
- cwd,
1457
- timeout: timeoutMs,
1458
- maxBuffer: GIT_MAX_BUFFER
1459
- });
1460
- return stdout.toString().trim();
1461
- }
1462
-
1463
- // src/runner/git-credential-ops.ts
1464
- function credentialErrorText(err) {
1465
- const raw = err instanceof Error ? err.message : String(err);
1466
- return raw.replace(/\/\/[^@\s]+@/g, "//***@");
1467
- }
1468
- async function updateRemoteCredential(cwd, credential) {
1469
- const result = { ok: false, storeWritten: false, helperConfigured: false };
1470
- try {
1471
- const currentUrl = await git(cwd, ["remote", "get-url", "origin"]);
1472
- const cloneUrl = credential.cloneUrl ?? currentUrl;
1473
- const normalizedUrl = writeGitCredential(cwd, cloneUrl, credential);
1474
- result.storeWritten = true;
1475
- if (currentUrl !== normalizedUrl) {
1476
- await git(cwd, ["remote", "set-url", "origin", normalizedUrl]);
1477
- }
1478
- await git(cwd, ["config", "--local", "credential.helper", gitCredentialHelper(cwd)]);
1479
- result.helperConfigured = true;
1480
- result.ok = true;
1481
- } catch (err) {
1482
- result.error = credentialErrorText(err);
1483
- }
1484
- return result;
1485
- }
1486
- async function updateRemoteToken(cwd, token) {
1487
- const username = process.env.CONVEYOR_GIT_USERNAME || "x-access-token";
1488
- const cloneUrl = process.env.CONVEYOR_GIT_CLONE_URL || void 0;
1489
- const credential = await updateRemoteCredential(cwd, { username, secret: token, cloneUrl });
1490
- process.env.CONVEYOR_GIT_SECRET = token;
1491
- const files = syncGithubTokenFiles(token);
1492
- return { credential, files };
1493
- }
1494
- async function verifyGitCredential(cwd) {
1495
- try {
1496
- await git(cwd, ["ls-remote", "--heads", "origin"], 3e4);
1497
- return { ok: true, outcome: "ok" };
1498
- } catch (err) {
1499
- const killed = err.killed === true;
1500
- return {
1501
- ok: false,
1502
- outcome: killed ? "timeout" : "denied",
1503
- error: credentialErrorText(err)
1504
- };
1505
- }
1506
- }
1507
-
1508
- // src/runner/git-utils.ts
1509
- import { realpathSync } from "fs";
1510
-
1511
- // src/runner/force-fresh-cooldown.ts
1512
- var FORCE_FRESH_COOLDOWN_MS = 30 * 60 * 1e3;
1513
- var blockedUntil = 0;
1514
- function forceFreshCooldownRemainingMs() {
1515
- return Math.max(0, blockedUntil - Date.now());
1516
- }
1517
- function forceFreshMintBlocked() {
1518
- return forceFreshCooldownRemainingMs() > 0;
1519
- }
1520
- function recordForceFreshFailure() {
1521
- blockedUntil = Date.now() + FORCE_FRESH_COOLDOWN_MS;
1522
- }
1523
- function clearForceFreshCooldown() {
1524
- blockedUntil = 0;
1525
- }
1526
- function forceFreshCooldownNotice() {
1527
- const minutes = Math.ceil(forceFreshCooldownRemainingMs() / 6e4);
1528
- return `- the force-fresh retry was SKIPPED: one already failed against this pod, so it is on a ${Math.round(FORCE_FRESH_COOLDOWN_MS / 6e4)}-minute cooldown (${minutes} min left). Re-minting cannot fix a credential the pod cannot serve.`;
1529
- }
1530
-
1531
- // src/runner/git-utils.ts
1532
- async function ensureOnTaskBranch(cwd, taskBranch, baseBranch) {
1533
- if (!taskBranch) return true;
1534
- try {
1535
- if (await getCurrentBranch(cwd) === taskBranch) return true;
1536
- let existsOnOrigin = true;
1537
- try {
1538
- await git(cwd, [
1539
- "fetch",
1540
- "origin",
1541
- `+refs/heads/${taskBranch}:refs/remotes/origin/${taskBranch}`
1542
- ]);
1543
- } catch (err) {
1544
- if (String(err).includes("couldn't find remote ref")) existsOnOrigin = false;
1545
- else throw err;
1546
- }
1547
- if (existsOnOrigin) {
1548
- await git(cwd, ["checkout", "-B", taskBranch, `origin/${taskBranch}`], 3e4);
1549
- process.stderr.write(`[conveyor-agent] Checked out task branch ${taskBranch}
1550
- `);
1551
- return true;
1552
- }
1553
- if (!baseBranch) {
1554
- process.stderr.write(
1555
- `[conveyor-agent] Warning: task branch ${taskBranch} missing on origin and no base branch given
1556
- `
1557
- );
1558
- return false;
1559
- }
1560
- await git(cwd, [
1561
- "fetch",
1562
- "origin",
1563
- `+refs/heads/${baseBranch}:refs/remotes/origin/${baseBranch}`
1564
- ]);
1565
- await git(cwd, ["checkout", "-B", taskBranch, `origin/${baseBranch}`], 3e4);
1566
- await git(cwd, ["push", "-u", "origin", taskBranch], 3e4);
1567
- process.stderr.write(
1568
- `[conveyor-agent] Created task branch ${taskBranch} from origin/${baseBranch} and pushed
1569
- `
1570
- );
1571
- return true;
1572
- } catch {
1573
- process.stderr.write(`[conveyor-agent] Warning: ensureOnTaskBranch(${taskBranch}) failed
1574
- `);
1575
- return false;
1576
- }
1577
- }
1578
- async function hasUncommittedChanges(cwd) {
1579
- const status = await git(cwd, ["status", "--porcelain"], GIT_SLOW_TIMEOUT_MS);
1580
- return status.length > 0;
1581
- }
1582
- async function getCurrentBranch(cwd) {
1583
- try {
1584
- const branch = await git(cwd, ["branch", "--show-current"]);
1585
- return branch || null;
1586
- } catch {
1587
- return null;
1588
- }
1589
- }
1590
- async function hasUnpushedCommits(cwd) {
1591
- try {
1592
- const currentBranch = await getCurrentBranch(cwd);
1593
- if (!currentBranch) return false;
1594
- try {
1595
- await git(cwd, ["rev-parse", `origin/${currentBranch}`]);
1596
- } catch {
1597
- try {
1598
- await git(cwd, ["rev-parse", "HEAD"]);
1599
- return true;
1600
- } catch {
1601
- return false;
1602
- }
1603
- }
1604
- const ahead = await git(cwd, [
1605
- "rev-list",
1606
- "--count",
1607
- "HEAD",
1608
- "--not",
1609
- `origin/${currentBranch}`
1610
- ]);
1611
- return parseInt(ahead, 10) > 0;
1612
- } catch {
1613
- return false;
1614
- }
1615
- }
1616
- async function remoteMatchesLocalHead(cwd, branch) {
1617
- try {
1618
- const [remote, local] = await Promise.all([
1619
- git(cwd, ["ls-remote", "origin", `refs/heads/${branch}`], GIT_SLOW_TIMEOUT_MS),
1620
- git(cwd, ["rev-parse", "HEAD"])
1621
- ]);
1622
- const remoteSha = remote.split(/\s+/)[0] ?? "";
1623
- return /^[0-9a-f]{40}$/i.test(remoteSha) && remoteSha === local.trim();
1624
- } catch {
1625
- return false;
1626
- }
1627
- }
1628
- async function stageAndCommit(cwd, message) {
1629
- try {
1630
- await git(cwd, ["add", "-A"], GIT_SLOW_TIMEOUT_MS);
1631
- if (!await hasUncommittedChanges(cwd)) return null;
1632
- await git(cwd, ["commit", "-m", message], GIT_SLOW_TIMEOUT_MS);
1633
- return await git(cwd, ["rev-parse", "HEAD"]);
1634
- } catch {
1635
- return null;
1636
- }
1637
- }
1638
- function errLooksLikeAuth(err) {
1639
- if (err.killed) return true;
1640
- const stderr = err.stderr?.toString() ?? "";
1641
- const stdout = err.stdout?.toString() ?? "";
1642
- const msg = stderr || stdout || (err instanceof Error ? err.message : "");
1643
- return /authentication|authorization|403|401|token/i.test(msg);
1644
- }
1645
- async function tryPush(cwd, branch, skipVerify = false) {
1646
- const noVerify = skipVerify ? ["--no-verify"] : [];
1647
- try {
1648
- await git(cwd, ["push", ...noVerify, "origin", branch], 3e4);
1649
- return true;
1650
- } catch (err) {
1651
- if (errLooksLikeAuth(err)) return false;
1652
- process.stderr.write(
1653
- `[conveyor-agent] Plain push of ${branch} failed \u2014 retrying with --force-with-lease
1654
- `
1655
- );
1656
- try {
1657
- await git(cwd, ["push", ...noVerify, "--force-with-lease", "origin", branch], 3e4);
1658
- return true;
1659
- } catch {
1660
- return false;
1661
- }
1662
- }
1663
- }
1664
- async function isAuthError(cwd) {
1665
- try {
1666
- await git(cwd, ["push", "--dry-run"], 3e4);
1667
- return false;
1668
- } catch (err) {
1669
- return errLooksLikeAuth(err);
1670
- }
1671
- }
1672
- function wipRefForBranch(branch) {
1673
- return `conveyor-wip/${branch}`;
1674
- }
1675
- var wipRefPushed = /* @__PURE__ */ new Set();
1676
- var wipRefPreserved = /* @__PURE__ */ new Set();
1677
- async function createWipSnapshot(cwd, message) {
1678
- let savedIndexTree;
1679
- try {
1680
- savedIndexTree = await git(cwd, ["write-tree"], GIT_SLOW_TIMEOUT_MS);
1681
- } catch {
1682
- return null;
1683
- }
1684
- try {
1685
- await git(cwd, ["add", "-A"], GIT_SLOW_TIMEOUT_MS);
1686
- const sha = await git(cwd, ["stash", "create", message], GIT_SLOW_TIMEOUT_MS);
1687
- return sha || null;
1688
- } catch {
1689
- return null;
1690
- } finally {
1691
- try {
1692
- await git(cwd, ["read-tree", savedIndexTree], GIT_SLOW_TIMEOUT_MS);
1693
- } catch {
1694
- }
1695
- }
1696
- }
1697
- async function tryPushRefspec(cwd, refspec, force = false) {
1698
- try {
1699
- const forceArgs = force ? ["--force"] : [];
1700
- await git(cwd, ["push", "--no-verify", ...forceArgs, "origin", refspec], 3e4);
1701
- return true;
1702
- } catch {
1703
- return false;
1704
- }
1705
- }
1706
- async function refreshRemoteToken(cwd, refreshToken) {
1707
- if (!refreshToken) return;
1708
- try {
1709
- const token = await refreshToken();
1710
- if (token) {
1711
- await updateRemoteToken(cwd, token);
1712
- process.env.GITHUB_TOKEN = token;
1713
- process.env.GH_TOKEN = token;
1714
- }
1715
- } catch {
1716
- }
1717
- }
1718
- async function restoreWipSnapshot(cwd, branch) {
1719
- if (!branch) return "none";
1720
- const ref = wipRefForBranch(branch);
1721
- try {
1722
- await git(cwd, ["fetch", "origin", `+refs/heads/${ref}:refs/remotes/origin/${ref}`]);
1723
- } catch (err) {
1724
- if (isMissingRefError(err)) return "none";
1725
- wipRefPreserved.add(cwd);
1726
- return "failed";
1727
- }
1728
- try {
1729
- const sha = await git(cwd, ["rev-parse", `refs/remotes/origin/${ref}`]);
1730
- const parent = await git(cwd, ["rev-parse", `${sha}^`]);
1731
- const head = await git(cwd, ["rev-parse", "HEAD"]);
1732
- if (parent !== head) {
1733
- try {
1734
- await git(cwd, ["stash", "apply", sha], GIT_SLOW_TIMEOUT_MS);
1735
- wipRefPushed.add(cwd);
1736
- wipRefPreserved.delete(cwd);
1737
- return "applied";
1738
- } catch {
1739
- try {
1740
- await git(cwd, ["reset", "--merge"], GIT_SLOW_TIMEOUT_MS);
1741
- } catch {
1742
- }
1743
- wipRefPreserved.add(cwd);
1744
- return "stale";
1745
- }
1746
- }
1747
- await git(cwd, ["stash", "apply", sha], GIT_SLOW_TIMEOUT_MS);
1748
- wipRefPushed.add(cwd);
1749
- wipRefPreserved.delete(cwd);
1750
- return "applied";
1751
- } catch {
1752
- wipRefPreserved.add(cwd);
1753
- return "failed";
1754
- }
1755
- }
1756
- function isMissingRefError(err) {
1757
- const stderr = err.stderr?.toString() ?? "";
1758
- const msg = stderr || (err instanceof Error ? err.message : String(err));
1759
- return /couldn't find remote ref|couldn't find remote|no such ref|not our ref/i.test(msg);
1760
- }
1761
- async function flushPendingChanges(cwd, opts) {
1762
- let committed = false;
1763
- let pushed = false;
1764
- let hadWork = false;
1765
- try {
1766
- const branch = await getCurrentBranch(cwd);
1767
- if (!branch) return { committed, pushed, hadWork };
1768
- const dirty = await hasUncommittedChanges(cwd);
1769
- const unpushed = await hasUnpushedCommits(cwd);
1770
- if (!dirty && !unpushed) {
1771
- await dropStaleWipRef(cwd, branch, opts?.refreshToken);
1772
- return { committed, pushed, hadWork };
1773
- }
1774
- hadWork = true;
1775
- await refreshRemoteToken(cwd, opts?.refreshToken);
1776
- if (unpushed) {
1777
- pushed = await pushToOrigin(cwd, opts?.refreshToken);
1778
- }
1779
- if (dirty && !wipRefPreserved.has(cwd)) {
1780
- const message = opts?.wipMessage ?? "WIP: conveyor-agent snapshot";
1781
- const sha = await createWipSnapshot(cwd, message);
1782
- if (sha) {
1783
- committed = await tryPushRefspec(cwd, `${sha}:refs/heads/${wipRefForBranch(branch)}`, true);
1784
- if (committed) wipRefPushed.add(cwd);
1785
- }
1786
- }
1787
- } catch {
1788
- }
1789
- return { committed, pushed, hadWork };
1790
- }
1791
- async function dropStaleWipRef(cwd, branch, refreshToken) {
1792
- if (wipRefPreserved.has(cwd) || !wipRefPushed.has(cwd)) return;
1793
- await refreshRemoteToken(cwd, refreshToken);
1794
- if (await tryPushRefspec(cwd, `:refs/heads/${wipRefForBranch(branch)}`)) {
1795
- wipRefPushed.delete(cwd);
1796
- }
1797
- }
1798
- async function pushToOrigin(cwd, refreshToken, skipVerify = false) {
1799
- try {
1800
- const currentBranch = await getCurrentBranch(cwd);
1801
- if (!currentBranch) return false;
1802
- if (refreshToken) {
1803
- try {
1804
- const token = await refreshToken();
1805
- if (token) {
1806
- await updateRemoteToken(cwd, token);
1807
- process.env.GITHUB_TOKEN = token;
1808
- process.env.GH_TOKEN = token;
1809
- }
1810
- } catch {
1811
- }
1812
- }
1813
- if (await tryPush(cwd, currentBranch, skipVerify)) {
1814
- clearForceFreshCooldown();
1815
- return true;
1816
- }
1817
- if (refreshToken && !forceFreshMintBlocked() && await isAuthError(cwd)) {
1818
- const token = await refreshToken({ forceFresh: true });
1819
- if (token) {
1820
- await updateRemoteToken(cwd, token);
1821
- process.env.GITHUB_TOKEN = token;
1822
- process.env.GH_TOKEN = token;
1823
- const pushed = await tryPush(cwd, currentBranch, skipVerify);
1824
- if (pushed) clearForceFreshCooldown();
1825
- else recordForceFreshFailure();
1826
- return pushed;
1827
- }
1828
- }
1829
- return false;
1830
- } catch {
1831
- return false;
1832
- }
1833
- }
1834
- function branchBackupRef(branch) {
1835
- return `conveyor-wip/branches/${branch}`;
1836
- }
1837
- async function listWorktrees(cwd) {
1838
- try {
1839
- const out = await git(cwd, ["worktree", "list", "--porcelain"]);
1840
- const result = [];
1841
- for (const entry of out.split("\n\n")) {
1842
- const lines = entry.trim().split("\n");
1843
- const wl = lines.find((l) => l.startsWith("worktree "));
1844
- if (!wl) continue;
1845
- const bl = lines.find((l) => l.startsWith("branch "));
1846
- result.push({
1847
- path: wl.slice("worktree ".length),
1848
- branch: bl ? bl.slice("branch refs/heads/".length) : null
1849
- });
1850
- }
1851
- return result;
1852
- } catch {
1853
- return [];
1854
- }
1855
- }
1856
- async function listLocalBranches(cwd) {
1857
- try {
1858
- const out = await git(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads/"]);
1859
- return out.split("\n").map((s) => s.trim()).filter(Boolean);
1860
- } catch {
1861
- return [];
1862
- }
1863
- }
1864
- async function branchUnpushedCount(cwd, branch) {
1865
- try {
1866
- const n = await git(cwd, ["rev-list", "--count", branch, "--not", "--remotes=origin"]);
1867
- return Number.parseInt(n, 10) || 0;
1868
- } catch {
1869
- return 0;
1870
- }
1871
- }
1872
- function samePath(a, b) {
1873
- try {
1874
- return realpathSync(a) === realpathSync(b);
1875
- } catch {
1876
- return a === b;
1877
- }
1878
- }
1879
- async function flushAllPendingWork(cwd, opts) {
1880
- try {
1881
- const primary = await flushPendingChanges(cwd, opts);
1882
- await refreshRemoteToken(cwd, opts?.refreshToken);
1883
- const currentBranch = await getCurrentBranch(cwd);
1884
- const worktreesSnapshotted = await snapshotOtherWorktrees(cwd, opts?.wipMessage);
1885
- const branchesBackedUp = await backupOtherBranches(cwd, currentBranch);
1886
- return {
1887
- hadWork: primary.hadWork || worktreesSnapshotted > 0 || branchesBackedUp > 0,
1888
- branchesBackedUp,
1889
- worktreesSnapshotted
1890
- };
1891
- } catch {
1892
- return { hadWork: false, branchesBackedUp: 0, worktreesSnapshotted: 0 };
1893
- }
1894
- }
1895
- async function snapshotOtherWorktrees(cwd, wipMessage) {
1896
- let count = 0;
1897
- for (const wt of await listWorktrees(cwd)) {
1898
- if (samePath(wt.path, cwd) || !wt.branch) continue;
1899
- try {
1900
- if (!await hasUncommittedChanges(wt.path)) continue;
1901
- const sha = await createWipSnapshot(wt.path, wipMessage ?? "WIP: conveyor-agent snapshot");
1902
- if (sha && await tryPushRefspec(wt.path, `${sha}:refs/heads/${wipRefForBranch(wt.branch)}`, true)) {
1903
- wipRefPushed.add(wt.path);
1904
- count++;
1905
- }
1906
- } catch {
1907
- }
1908
- }
1909
- return count;
1910
- }
1911
- async function backupOtherBranches(cwd, currentBranch) {
1912
- let count = 0;
1913
- for (const branch of await listLocalBranches(cwd)) {
1914
- if (branch === currentBranch || branch.startsWith("conveyor-wip/")) continue;
1915
- try {
1916
- if (await branchUnpushedCount(cwd, branch) === 0) continue;
1917
- if (await tryPushRefspec(
1918
- cwd,
1919
- `refs/heads/${branch}:refs/heads/${branchBackupRef(branch)}`,
1920
- true
1921
- )) {
1922
- count++;
1923
- }
1924
- } catch {
1925
- }
1926
- }
1927
- return count;
3
+ readAgentVersion
4
+ } from "./chunk-XORJ6SII.js";
5
+ import {
6
+ AgentConnection,
7
+ CodespacePortVisibility,
8
+ DEFAULT_LIFECYCLE_CONFIG,
9
+ Lifecycle,
10
+ PortDiscovery,
11
+ awaitGitReady,
12
+ clearForceFreshCooldown,
13
+ createServiceLogger,
14
+ ensureOnTaskBranch,
15
+ flushAllPendingWork,
16
+ flushPendingChanges,
17
+ forceFreshCooldownNotice,
18
+ forceFreshMintBlocked,
19
+ getCurrentBranch,
20
+ hasUncommittedChanges,
21
+ hasUnpushedCommits,
22
+ pushToOrigin,
23
+ readWorkspaceBytes,
24
+ readWorkspaceDir,
25
+ readWorkspaceFile,
26
+ recordForceFreshFailure,
27
+ remoteMatchesLocalHead,
28
+ restoreWipSnapshot,
29
+ stageAndCommit,
30
+ statWorkspacePath,
31
+ updateRemoteToken,
32
+ verifyGitCredential
33
+ } from "./chunk-LSZ2KLJY.js";
34
+ import {
35
+ registerBootMilestoneSocketFallback,
36
+ reportBootMilestone
37
+ } from "./chunk-WMMBAKPE.js";
38
+ import {
39
+ LoopLagMonitor,
40
+ loopStatusForRunnerStatus
41
+ } from "./chunk-IA45XHOA.js";
42
+ import {
43
+ getWorkbenchClient
44
+ } from "./chunk-EXQ6AHOY.js";
45
+ import {
46
+ workbenchEnabled
47
+ } from "./chunk-KMB3BU4S.js";
48
+ import {
49
+ MAX_BETWEEN_TURN_BUFFER,
50
+ MAX_DIAGNOSTIC_OUTPUT,
51
+ buildExitErrors,
52
+ buildPromptBytes,
53
+ buildSpawnArgs,
54
+ cleanTerminalOutput,
55
+ inheritedEnv,
56
+ killPtyWithEscalation,
57
+ needsRawReadyGate,
58
+ parseUserQuestions,
59
+ renderPromptContentText,
60
+ resolveClaudeBinary,
61
+ resolvePlanDialogTiming,
62
+ resolvePtySpawn,
63
+ resolveRawTuiProbeTiming,
64
+ resolveSubmitNudgeTiming,
65
+ resolveSubmitRedeliveryMaxAttempts,
66
+ resolveSubmitSettleMs,
67
+ sawTerminalSetup,
68
+ sentinelEchoed,
69
+ sessionTempBase,
70
+ spawnOptionsFingerprint,
71
+ transcriptSize,
72
+ turnOptionsFrom
73
+ } from "./chunk-3F4ZZKCA.js";
74
+ import {
75
+ describeTokenFile,
76
+ ghHostsExternallyOwned,
77
+ githubTokenFilePath,
78
+ sleep
79
+ } from "./chunk-W4LZ7R6Z.js";
80
+
81
+ // src/connection/auth-errors.ts
82
+ function isPermissionDeniedError(err) {
83
+ const message = err instanceof Error ? err.message : String(err);
84
+ return /insufficient permissions|authentication required/i.test(message);
1928
85
  }
1929
86
 
1930
87
  // ../shared/dist/chunk-6RHVH33O.js
@@ -4027,141 +2184,6 @@ var ModeController = class {
4027
2184
  }
4028
2185
  };
4029
2186
 
4030
- // src/runner/lifecycle.ts
4031
- var DEFAULT_LIFECYCLE_CONFIG = {
4032
- idleTimeoutMs: 30 * 60 * 1e3,
4033
- dormantTimeoutMs: 60 * 60 * 1e3,
4034
- heartbeatIntervalMs: 3e4,
4035
- tokenRefreshIntervalMs: 45 * 60 * 1e3,
4036
- gitFlushIntervalMs: 2 * 60 * 1e3,
4037
- usageSampleIntervalMs: 5 * 60 * 1e3,
4038
- usageSampleInitialDelayMs: 3e4
4039
- };
4040
- var Lifecycle = class {
4041
- config;
4042
- callbacks;
4043
- heartbeatTimer = null;
4044
- tokenRefreshTimer = null;
4045
- idleTimer = null;
4046
- idleCheckInterval = null;
4047
- dormantTimer = null;
4048
- gitFlushTimer = null;
4049
- usageSampleTimer = null;
4050
- constructor(config, callbacks) {
4051
- this.config = config;
4052
- this.callbacks = callbacks;
4053
- }
4054
- // ── Heartbeat ──────────────────────────────────────────────────────
4055
- startHeartbeat() {
4056
- this.stopHeartbeat();
4057
- this.heartbeatTimer = setInterval(() => {
4058
- this.callbacks.onHeartbeat();
4059
- }, this.config.heartbeatIntervalMs);
4060
- }
4061
- stopHeartbeat() {
4062
- if (this.heartbeatTimer) {
4063
- clearInterval(this.heartbeatTimer);
4064
- this.heartbeatTimer = null;
4065
- }
4066
- }
4067
- // ── Token refresh ─────────────────────────────────────────────────
4068
- startTokenRefresh() {
4069
- this.stopTokenRefresh();
4070
- this.callbacks.onTokenRefresh();
4071
- this.tokenRefreshTimer = setInterval(() => {
4072
- this.callbacks.onTokenRefresh();
4073
- }, this.config.tokenRefreshIntervalMs);
4074
- }
4075
- stopTokenRefresh() {
4076
- if (this.tokenRefreshTimer) {
4077
- clearInterval(this.tokenRefreshTimer);
4078
- this.tokenRefreshTimer = null;
4079
- }
4080
- }
4081
- // ── Periodic git flush ────────────────────────────────────────────
4082
- startGitFlush() {
4083
- this.stopGitFlush();
4084
- if (this.config.gitFlushIntervalMs <= 0) return;
4085
- this.gitFlushTimer = setInterval(() => {
4086
- this.callbacks.onGitFlush();
4087
- }, this.config.gitFlushIntervalMs);
4088
- }
4089
- stopGitFlush() {
4090
- if (this.gitFlushTimer) {
4091
- clearInterval(this.gitFlushTimer);
4092
- this.gitFlushTimer = null;
4093
- }
4094
- }
4095
- // ── Claude key usage sampling ─────────────────────────────────────
4096
- startUsageSample() {
4097
- this.stopUsageSample();
4098
- if (this.config.usageSampleIntervalMs <= 0) return;
4099
- this.usageSampleTimer = setTimeout(() => {
4100
- this.callbacks.onUsageSample();
4101
- this.usageSampleTimer = setInterval(() => {
4102
- this.callbacks.onUsageSample();
4103
- }, this.config.usageSampleIntervalMs);
4104
- }, this.config.usageSampleInitialDelayMs);
4105
- }
4106
- stopUsageSample() {
4107
- if (this.usageSampleTimer) {
4108
- clearInterval(this.usageSampleTimer);
4109
- this.usageSampleTimer = null;
4110
- }
4111
- }
4112
- // ── Idle timer ─────────────────────────────────────────────────────
4113
- startIdleTimer() {
4114
- this.clearIdleTimers();
4115
- this.idleTimer = setTimeout(() => {
4116
- this.callbacks.onIdleTimeout();
4117
- }, this.config.idleTimeoutMs);
4118
- }
4119
- cancelIdleTimer() {
4120
- this.clearIdleTimers();
4121
- }
4122
- // ── Dormant timer ──────────────────────────────────────────────────
4123
- /** Start (or restart) the dormant timer.
4124
- * @param overrideMs Optional custom delay in ms. When provided, the timer
4125
- * fires after exactly that delay instead of `dormantTimeoutMs`. SessionRunner
4126
- * uses this to enforce an *absolute* deadline across cycles: even if the
4127
- * dormant wait is interrupted by an inbound message, the next iteration
4128
- * passes the remaining time, so the agent shuts down at the original
4129
- * deadline regardless of message volume. */
4130
- startDormantTimer(overrideMs) {
4131
- this.cancelDormantTimer();
4132
- const delay2 = Math.max(0, overrideMs ?? this.config.dormantTimeoutMs);
4133
- this.dormantTimer = setTimeout(() => {
4134
- this.callbacks.onDormantTimeout();
4135
- }, delay2);
4136
- }
4137
- cancelDormantTimer() {
4138
- if (this.dormantTimer) {
4139
- clearTimeout(this.dormantTimer);
4140
- this.dormantTimer = null;
4141
- }
4142
- }
4143
- // ── Cleanup ────────────────────────────────────────────────────────
4144
- destroy() {
4145
- this.stopHeartbeat();
4146
- this.stopTokenRefresh();
4147
- this.stopGitFlush();
4148
- this.stopUsageSample();
4149
- this.clearIdleTimers();
4150
- this.cancelDormantTimer();
4151
- }
4152
- // ── Private ────────────────────────────────────────────────────────
4153
- clearIdleTimers() {
4154
- if (this.idleTimer) {
4155
- clearTimeout(this.idleTimer);
4156
- this.idleTimer = null;
4157
- }
4158
- if (this.idleCheckInterval) {
4159
- clearInterval(this.idleCheckInterval);
4160
- this.idleCheckInterval = null;
4161
- }
4162
- }
4163
- };
4164
-
4165
2187
  // src/harness/types.ts
4166
2188
  function isExternalMcpStdioServer(value) {
4167
2189
  if (typeof value !== "object" || value === null) return false;
@@ -5833,11 +3855,11 @@ var READ_BACK_DELAYS_MS = [250, 500, 1e3, 2e3];
5833
3855
  var defaultSleep = (ms) => new Promise((resolve) => {
5834
3856
  setTimeout(resolve, ms);
5835
3857
  });
5836
- async function writeWithReadBackRetry(io2, contents, delaysMs = READ_BACK_DELAYS_MS) {
5837
- const sleep2 = io2.sleep ?? defaultSleep;
3858
+ async function writeWithReadBackRetry(io, contents, delaysMs = READ_BACK_DELAYS_MS) {
3859
+ const sleep2 = io.sleep ?? defaultSleep;
5838
3860
  for (let attempt = 0; ; attempt++) {
5839
- await io2.write(contents);
5840
- if (await io2.read() === contents) return true;
3861
+ await io.write(contents);
3862
+ if (await io.read() === contents) return true;
5841
3863
  if (attempt >= delaysMs.length) return false;
5842
3864
  await sleep2(delaysMs[attempt]);
5843
3865
  }
@@ -7619,7 +5641,7 @@ function findOnPath(binary, env = process.env) {
7619
5641
  import { promises as fs } from "fs";
7620
5642
  import { dirname as dirname2, join as join9 } from "path";
7621
5643
  import { homedir as homedir4 } from "os";
7622
- var logger2 = createServiceLogger("opencode-auth");
5644
+ var logger = createServiceLogger("opencode-auth");
7623
5645
  var OPENCODE_CODEX_PLUGIN = "opencode-openai-codex-auth@4.4.0";
7624
5646
  var PLUGIN_PACKAGE = "opencode-openai-codex-auth";
7625
5647
  function opencodeAuthPath(env) {
@@ -7666,7 +5688,7 @@ async function ensureAuthEntry(env, seed) {
7666
5688
  const path2 = opencodeAuthPath(env);
7667
5689
  const store = await readJsonFile(path2);
7668
5690
  if (!shouldSeed(store.openai, seed)) {
7669
- logger2.info("opencode oauth store is fresher than the seed; leaving it alone");
5691
+ logger.info("opencode oauth store is fresher than the seed; leaving it alone");
7670
5692
  return;
7671
5693
  }
7672
5694
  store.openai = {
@@ -7676,7 +5698,7 @@ async function ensureAuthEntry(env, seed) {
7676
5698
  expires: seed.expires
7677
5699
  };
7678
5700
  await writeJsonFile(path2, store);
7679
- logger2.info("seeded opencode oauth store entry");
5701
+ logger.info("seeded opencode oauth store entry");
7680
5702
  }
7681
5703
  async function ensurePluginConfig(env) {
7682
5704
  const path2 = opencodeConfigPath(env);
@@ -7689,7 +5711,7 @@ async function ensurePluginConfig(env) {
7689
5711
  const kept = plugins.filter((p) => !isOurs(p));
7690
5712
  config.plugin = [...kept, OPENCODE_CODEX_PLUGIN];
7691
5713
  await writeJsonFile(path2, config);
7692
- logger2.info("ensured opencode codex-auth plugin in config");
5714
+ logger.info("ensured opencode codex-auth plugin in config");
7693
5715
  }
7694
5716
  async function seedOpenCodeOauth(env) {
7695
5717
  const seed = parseOauthSeed(env.CONVEYOR_OPENCODE_OAUTH);
@@ -7698,7 +5720,7 @@ async function seedOpenCodeOauth(env) {
7698
5720
  await ensureAuthEntry(env, seed);
7699
5721
  await ensurePluginConfig(env);
7700
5722
  } catch (err) {
7701
- logger2.warn(
5723
+ logger.warn(
7702
5724
  `failed to seed opencode oauth store: ${err instanceof Error ? err.message : String(err)}`
7703
5725
  );
7704
5726
  }
@@ -8067,14 +6089,14 @@ function resolveTuiAdapter(kind = "claude-code") {
8067
6089
 
8068
6090
  // src/harness/pty/stream-server.ts
8069
6091
  import net from "net";
8070
- var logger3 = createServiceLogger("PtyStreamServer");
6092
+ var logger2 = createServiceLogger("PtyStreamServer");
8071
6093
  var RING_MAX_CHARS = 256 * 1024;
8072
6094
  var PtyStreamServer = class {
8073
6095
  constructor(options) {
8074
6096
  this.options = options;
8075
6097
  this.server = net.createServer((socket) => this.handleConnection(socket));
8076
6098
  this.server.on("error", (err) => {
8077
- logger3.warn(`PTY stream server error: ${err.message}`);
6099
+ logger2.warn(`PTY stream server error: ${err.message}`);
8078
6100
  });
8079
6101
  }
8080
6102
  options;
@@ -8115,7 +6137,7 @@ var PtyStreamServer = class {
8115
6137
  return port;
8116
6138
  }
8117
6139
  }
8118
- logger3.warn(`PTY stream server could not bind any port in ${base}..${base + attempts - 1}`);
6140
+ logger2.warn(`PTY stream server could not bind any port in ${base}..${base + attempts - 1}`);
8119
6141
  return null;
8120
6142
  }
8121
6143
  tryListen(port) {
@@ -8236,7 +6258,7 @@ var PtyStreamServer = class {
8236
6258
  };
8237
6259
 
8238
6260
  // src/harness/pty/direct-stream.ts
8239
- var logger4 = createServiceLogger("PtyDirectStream");
6261
+ var logger3 = createServiceLogger("PtyDirectStream");
8240
6262
  var RELAY_COALESCE_MS = 2e3;
8241
6263
  var RELAY_MAX_BUFFER_CHARS = 48 * 1024;
8242
6264
  var DirectStreamController = class {
@@ -8285,7 +6307,7 @@ var DirectStreamController = class {
8285
6307
  });
8286
6308
  void created.listen().then((port) => this.onListening(created, port)).catch((err) => {
8287
6309
  this.starting = false;
8288
- logger4.warn(
6310
+ logger3.warn(
8289
6311
  `PTY stream server failed to start: ${err instanceof Error ? err.message : String(err)}`
8290
6312
  );
8291
6313
  });
@@ -8298,7 +6320,7 @@ var DirectStreamController = class {
8298
6320
  }
8299
6321
  this.server = created;
8300
6322
  this.reporter.reportPtyStream(port);
8301
- logger4.info(`PTY stream server listening on ${port} (session ${this.reporter.sessionId})`);
6323
+ logger3.info(`PTY stream server listening on ${port} (session ${this.reporter.sessionId})`);
8302
6324
  }
8303
6325
  /** Push the min box across both transports to the pty. */
8304
6326
  applyDims() {
@@ -8375,7 +6397,7 @@ function wrapBridgeWithDirectStream(inner, reporter, options = {}) {
8375
6397
 
8376
6398
  // src/execution/query-executor.ts
8377
6399
  import { createHash as createHash2 } from "crypto";
8378
- import { existsSync as existsSync2, readFileSync as readFileSync3, truncateSync } from "fs";
6400
+ import { existsSync, readFileSync as readFileSync3, truncateSync } from "fs";
8379
6401
 
8380
6402
  // src/execution/chat-instructions.ts
8381
6403
  function buildChatInstructions(context, scenario, newMessages) {
@@ -8830,45 +6852,6 @@ function formatIncidents(incidents) {
8830
6852
  return parts;
8831
6853
  }
8832
6854
 
8833
- // src/workbench/fs.ts
8834
- import {
8835
- readFile as localReadFile,
8836
- readdir as localReaddir,
8837
- stat as localStat
8838
- } from "fs/promises";
8839
- async function readWorkspaceFile(path2) {
8840
- if (workbenchEnabled()) {
8841
- return (await getWorkbenchClient().readFile(path2)).toString("utf8");
8842
- }
8843
- return localReadFile(path2, "utf-8");
8844
- }
8845
- function readWorkspaceBytes(path2) {
8846
- if (workbenchEnabled()) return getWorkbenchClient().readFile(path2);
8847
- return localReadFile(path2);
8848
- }
8849
- function readWorkspaceDir(path2) {
8850
- if (workbenchEnabled()) return getWorkbenchClient().readdir(path2);
8851
- return localReaddir(path2);
8852
- }
8853
- async function statWorkspacePath(path2) {
8854
- if (workbenchEnabled()) return getWorkbenchClient().stat(path2);
8855
- try {
8856
- const s = await localStat(path2);
8857
- return {
8858
- exists: true,
8859
- isFile: s.isFile(),
8860
- isDirectory: s.isDirectory(),
8861
- size: s.size,
8862
- mtimeMs: s.mtimeMs
8863
- };
8864
- } catch {
8865
- return { exists: false, isFile: false, isDirectory: false, size: 0, mtimeMs: 0 };
8866
- }
8867
- }
8868
- async function workspacePathExists(path2) {
8869
- return (await statWorkspacePath(path2)).exists;
8870
- }
8871
-
8872
6855
  // src/execution/tag-context-resolver.ts
8873
6856
  var TYPE_PRIORITY = { rule: 0, doc: 1, file: 2, folder: 3 };
8874
6857
  var SUMMARY_SCAN_CHARS = 4e3;
@@ -13051,8 +11034,8 @@ function buildDriveTools(connection, projectId) {
13051
11034
  }
13052
11035
 
13053
11036
  // src/tools/code-review-tools.ts
13054
- import { execFile as execFile2 } from "child_process";
13055
- import { promisify as promisify2 } from "util";
11037
+ import { execFile } from "child_process";
11038
+ import { promisify } from "util";
13056
11039
  import { z as z18 } from "zod";
13057
11040
  async function endReviewSession(connection, reason) {
13058
11041
  await connection.call("endReviewSession", {
@@ -13116,7 +11099,7 @@ async function resolveGitHeadSha(cwd) {
13116
11099
  const stdout = workbenchEnabled() ? (await getWorkbenchClient().execFile("git", ["rev-parse", "HEAD"], {
13117
11100
  cwd,
13118
11101
  timeout: 1e4
13119
- })).stdout : (await promisify2(execFile2)("git", ["rev-parse", "HEAD"], { cwd, timeout: 1e4 })).stdout;
11102
+ })).stdout : (await promisify(execFile)("git", ["rev-parse", "HEAD"], { cwd, timeout: 1e4 })).stdout;
13120
11103
  const sha = stdout.trim();
13121
11104
  return /^[0-9a-f]{40}$/i.test(sha) ? sha : null;
13122
11105
  } catch {
@@ -13385,7 +11368,7 @@ function resolvePlaywrightMcpServer(env = process.env) {
13385
11368
  }
13386
11369
 
13387
11370
  // src/execution/event-handlers.ts
13388
- var logger5 = createServiceLogger("event-handlers");
11371
+ var logger4 = createServiceLogger("event-handlers");
13389
11372
  function safeVoid(promise, context) {
13390
11373
  if (promise && typeof promise.catch === "function") {
13391
11374
  promise.catch((err) => {
@@ -13426,7 +11409,7 @@ async function processAssistantEvent(event, host, turnToolCalls) {
13426
11409
  var API_ERROR_PATTERN = /API Error: (?:[45]\d\d|terminated)/;
13427
11410
  var IMAGE_ERROR_PATTERN = /Could not process image/i;
13428
11411
  var AUTH_ERROR_PATTERN = /Not logged in|Please run \/login|authentication failed|invalid.*token|unauthorized/i;
13429
- function isAuthError2(msg) {
11412
+ function isAuthError(msg) {
13430
11413
  return AUTH_ERROR_PATTERN.test(msg);
13431
11414
  }
13432
11415
  function isRetriableMessage(msg) {
@@ -13492,7 +11475,7 @@ function handleErrorResult(event, host) {
13492
11475
  if (isStaleSession) {
13493
11476
  return { retriable: false, staleSession: true };
13494
11477
  }
13495
- if (isAuthError2(errorMsg)) {
11478
+ if (isAuthError(errorMsg)) {
13496
11479
  host.connection.sendEvent({ type: "error", message: errorMsg });
13497
11480
  return { retriable: false, authError: true };
13498
11481
  }
@@ -13540,7 +11523,7 @@ async function emitResultEvent(event, host, context, startTime, lastAssistantUsa
13540
11523
  }
13541
11524
  function handleRateLimitEvent(event, host) {
13542
11525
  const { rate_limit_info } = event;
13543
- logger5.info("Rate limit event received", { rate_limit_info });
11526
+ logger4.info("Rate limit event received", { rate_limit_info });
13544
11527
  const status = rate_limit_info.status;
13545
11528
  const utilization = rate_limit_info.utilization ?? (status === "rejected" ? 1 : void 0);
13546
11529
  if (utilization !== void 0 && rate_limit_info.rateLimitType) {
@@ -14309,9 +12292,9 @@ function buildCanUseTool(host) {
14309
12292
  }
14310
12293
 
14311
12294
  // src/execution/query-executor.ts
14312
- var logger6 = createServiceLogger("QueryExecutor");
12295
+ var logger5 = createServiceLogger("QueryExecutor");
14313
12296
  var IMAGE_ERROR_PATTERN2 = /Could not process image/i;
14314
- var RETRY_DELAYS_MS2 = [6e4, 12e4, 18e4, 3e5];
12297
+ var RETRY_DELAYS_MS = [6e4, 12e4, 18e4, 3e5];
14315
12298
  function buildHooks(host) {
14316
12299
  return {
14317
12300
  PostToolUse: [
@@ -14361,7 +12344,7 @@ function sessionLineageKey(taskId, agentMode, runnerMode) {
14361
12344
  }
14362
12345
  function sessionFileExists(sessionUuid, cwd) {
14363
12346
  try {
14364
- return existsSync2(sessionTranscriptPath(cwd, sessionUuid));
12347
+ return existsSync(sessionTranscriptPath(cwd, sessionUuid));
14365
12348
  } catch {
14366
12349
  return false;
14367
12350
  }
@@ -14380,7 +12363,7 @@ function resolveSessionStart(lineageKey, cwd) {
14380
12363
  }
14381
12364
  function repairTornSessionFile(path2) {
14382
12365
  try {
14383
- if (!existsSync2(path2)) return false;
12366
+ if (!existsSync(path2)) return false;
14384
12367
  const content = readFileSync3(path2, "utf8");
14385
12368
  if (content.length === 0) return false;
14386
12369
  let keepEnd = content.length;
@@ -14401,7 +12384,7 @@ function repairTornSessionFile(path2) {
14401
12384
  }
14402
12385
  if (keepEnd === content.length) return false;
14403
12386
  truncateSync(path2, Buffer.byteLength(content.slice(0, keepEnd), "utf8"));
14404
- logger6.warn("Repaired torn transcript before resume", {
12387
+ logger5.warn("Repaired torn transcript before resume", {
14405
12388
  path: path2,
14406
12389
  trimmedBytes: content.length - keepEnd
14407
12390
  });
@@ -14482,7 +12465,7 @@ function buildQueryOptions(host, context) {
14482
12465
  disallowedTools: buildDisallowedTools(settings, mode, host.hasExitedPlanMode),
14483
12466
  enableFileCheckpointing: settings.enableFileCheckpointing,
14484
12467
  stderr: (data) => {
14485
- logger6.warn("Claude Code stderr", { data: data.trimEnd() });
12468
+ logger5.warn("Claude Code stderr", { data: data.trimEnd() });
14486
12469
  }
14487
12470
  };
14488
12471
  }
@@ -14913,11 +12896,11 @@ function classifyImageError(error) {
14913
12896
  async function emitRetryStatus(host, attempt, delayMs) {
14914
12897
  const delayMin = Math.round(delayMs / 6e4);
14915
12898
  host.connection.postChatMessage(
14916
- `API error encountered. Retrying in ${delayMin} minute${delayMin > 1 ? "s" : ""}... (attempt ${attempt + 1}/${RETRY_DELAYS_MS2.length})`
12899
+ `API error encountered. Retrying in ${delayMin} minute${delayMin > 1 ? "s" : ""}... (attempt ${attempt + 1}/${RETRY_DELAYS_MS.length})`
14917
12900
  );
14918
12901
  host.connection.sendEvent({
14919
12902
  type: "error",
14920
- message: `API error, retrying in ${delayMin}m (${attempt + 1}/${RETRY_DELAYS_MS2.length})`
12903
+ message: `API error, retrying in ${delayMin}m (${attempt + 1}/${RETRY_DELAYS_MS.length})`
14921
12904
  });
14922
12905
  host.connection.emitStatus("waiting_for_input");
14923
12906
  await host.callbacks.onStatusChange("waiting_for_input");
@@ -14978,7 +12961,7 @@ function handleRetryError(error, context, host, options, prevImageError) {
14978
12961
  if (isStaleOrExitedSession(error, context) && context.claudeSessionId) {
14979
12962
  return handleStaleSession(context, host, options);
14980
12963
  }
14981
- if (isAuthError2(getErrorMessage(error))) {
12964
+ if (isAuthError(getErrorMessage(error))) {
14982
12965
  return handleAuthError(context, host, options);
14983
12966
  }
14984
12967
  if (!isRetriableError(error)) throw error;
@@ -15012,7 +12995,7 @@ function handleProcessResult(result, context, host, options) {
15012
12995
  }
15013
12996
  async function runWithRetry(initialQuery, context, host, options) {
15014
12997
  let lastErrorWasImage = false;
15015
- for (let attempt = 0; attempt <= RETRY_DELAYS_MS2.length; attempt++) {
12998
+ for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
15016
12999
  if (host.isStopped()) return;
15017
13000
  const agentQuery = attempt === 0 ? initialQuery : await buildRetryQuery(host, context, options, lastErrorWasImage);
15018
13001
  try {
@@ -15027,18 +13010,18 @@ async function runWithRetry(initialQuery, context, host, options) {
15027
13010
  if (outcome.action === "return") return;
15028
13011
  lastErrorWasImage = outcome.lastErrorWasImage;
15029
13012
  }
15030
- if (attempt >= RETRY_DELAYS_MS2.length) {
13013
+ if (attempt >= RETRY_DELAYS_MS.length) {
15031
13014
  host.connection.postChatMessage(
15032
- `Agent shutting down after ${RETRY_DELAYS_MS2.length} failed retry attempts due to API errors. The task will resume automatically when the codespace restarts.`
13015
+ `Agent shutting down after ${RETRY_DELAYS_MS.length} failed retry attempts due to API errors. The task will resume automatically when the codespace restarts.`
15033
13016
  );
15034
13017
  return;
15035
13018
  }
15036
- await emitRetryStatus(host, attempt, RETRY_DELAYS_MS2[attempt]);
13019
+ await emitRetryStatus(host, attempt, RETRY_DELAYS_MS[attempt]);
15037
13020
  }
15038
13021
  }
15039
13022
 
15040
13023
  // src/runner/query-bridge.ts
15041
- var logger7 = createServiceLogger("QueryBridge");
13024
+ var logger6 = createServiceLogger("QueryBridge");
15042
13025
  function resolveHarnessKind() {
15043
13026
  if (process.env.CONVEYOR_FORCE_SDK_CARDS === "1") return "sdk";
15044
13027
  return "pty";
@@ -15220,9 +13203,9 @@ var QueryBridge = class {
15220
13203
  const msg = err instanceof Error ? err.message : String(err);
15221
13204
  const isAbort = this._stopped || /abort/i.test(msg);
15222
13205
  if (isAbort) {
15223
- logger7.info("Query stopped by user", { error: msg });
13206
+ logger6.info("Query stopped by user", { error: msg });
15224
13207
  } else {
15225
- logger7.error("Query execution failed", { error: msg });
13208
+ logger6.error("Query execution failed", { error: msg });
15226
13209
  this.connection.sendEvent({ type: "error", message: msg });
15227
13210
  }
15228
13211
  } finally {
@@ -15248,9 +13231,9 @@ var QueryBridge = class {
15248
13231
  const msg = err instanceof Error ? err.message : String(err);
15249
13232
  const isAbort = this._stopped || /abort/i.test(msg);
15250
13233
  if (isAbort) {
15251
- logger7.info("Passive turn stopped", { error: msg });
13234
+ logger6.info("Passive turn stopped", { error: msg });
15252
13235
  } else {
15253
- logger7.error("Passive turn failed", { error: msg });
13236
+ logger6.error("Passive turn failed", { error: msg });
15254
13237
  this.connection.sendEvent({ type: "error", message: msg });
15255
13238
  }
15256
13239
  } finally {
@@ -15346,7 +13329,7 @@ var QueryBridge = class {
15346
13329
  };
15347
13330
 
15348
13331
  // src/execution/usage-sampler.ts
15349
- import { existsSync as existsSync3 } from "fs";
13332
+ import { existsSync as existsSync2 } from "fs";
15350
13333
 
15351
13334
  // src/usage/reset-parse.ts
15352
13335
  var MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
@@ -15577,7 +13560,7 @@ async function runUsageProbe(deps = {}) {
15577
13560
  }
15578
13561
 
15579
13562
  // src/execution/usage-sampler.ts
15580
- var logger8 = createServiceLogger("usage-sampler");
13563
+ var logger7 = createServiceLogger("usage-sampler");
15581
13564
  var NO_SAMPLES = { samples: [], unmeasurable: null };
15582
13565
  function isAttributable(identity, sessionToken) {
15583
13566
  if (!identity) return { ok: true };
@@ -15589,12 +13572,12 @@ function isAttributable(identity, sessionToken) {
15589
13572
  }
15590
13573
  return { ok: true };
15591
13574
  }
15592
- async function sampleKeyUsage(token, probe = () => runUsageProbe(), hasSubscriptionCredentials = () => existsSync3(claudeCredentialsPath()), readIdentity = readCredentialsIdentity) {
13575
+ async function sampleKeyUsage(token, probe = () => runUsageProbe(), hasSubscriptionCredentials = () => existsSync2(claudeCredentialsPath()), readIdentity = readCredentialsIdentity) {
15593
13576
  if (!token && !hasSubscriptionCredentials()) return NO_SAMPLES;
15594
13577
  try {
15595
13578
  const attributable = isAttributable(await readIdentity(), token);
15596
13579
  if (!attributable.ok) {
15597
- logger8.info("usage sample skipped \u2014 credentials not attributable to this session's key", {
13580
+ logger7.info("usage sample skipped \u2014 credentials not attributable to this session's key", {
15598
13581
  reason: attributable.reason
15599
13582
  });
15600
13583
  return { samples: [], unmeasurable: { reason: attributable.reason ?? "unattributable" } };
@@ -15621,14 +13604,14 @@ async function sampleKeyUsage(token, probe = () => runUsageProbe(), hasSubscript
15621
13604
  });
15622
13605
  }
15623
13606
  if (samples.length === 0) {
15624
- logger8.info("usage sample produced no gauges", {
13607
+ logger7.info("usage sample produced no gauges", {
15625
13608
  stdoutLength: stdout.length,
15626
13609
  stdoutHead: stdout.slice(0, 200).replaceAll("\n", " ")
15627
13610
  });
15628
13611
  }
15629
13612
  return { samples, unmeasurable: null };
15630
13613
  } catch (error) {
15631
- logger8.info("usage sample failed", {
13614
+ logger7.info("usage sample failed", {
15632
13615
  error: error instanceof Error ? error.message : String(error)
15633
13616
  });
15634
13617
  return NO_SAMPLES;
@@ -15654,391 +13637,10 @@ function buildUnmeasurableEvent(reason, codingAgentKeyId) {
15654
13637
  };
15655
13638
  }
15656
13639
 
15657
- // src/setup/git-ready.ts
15658
- var GATE_MARGIN_MS = 5 * 6e4;
15659
- var DEFAULT_TIMEOUT_MS = GIT_PREP_MAX_RETRIES * (CLONE_TIMEOUT_MS + FETCH_TIMEOUT_MS) + (GIT_PREP_MAX_RETRIES - 1) * DEFAULT_RETRY_DELAY_MS + GATE_MARGIN_MS;
15660
- var DEFAULT_POLL_MS = 200;
15661
- function awaitGitReady(opts = {}) {
15662
- if (!workbenchEnabled()) {
15663
- return Promise.resolve("not-gated");
15664
- }
15665
- const clientFn = opts.clientFn ?? getWorkbenchClient;
15666
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
15667
- const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
15668
- opts.onLog?.("waiting for workspace git (workbench daemon)");
15669
- return pollDaemon(clientFn, timeoutMs, pollMs, opts.onLog, opts.signal);
15670
- }
15671
- function delay(ms, signal) {
15672
- if (!signal) {
15673
- return new Promise((resolve) => {
15674
- setTimeout(resolve, ms);
15675
- });
15676
- }
15677
- if (signal.aborted) return Promise.resolve();
15678
- return new Promise((resolve) => {
15679
- const timer = setTimeout(() => {
15680
- signal.removeEventListener("abort", onAbort);
15681
- resolve();
15682
- }, ms);
15683
- const onAbort = () => {
15684
- clearTimeout(timer);
15685
- resolve();
15686
- };
15687
- signal.addEventListener("abort", onAbort, { once: true });
15688
- });
15689
- }
15690
- async function pollOnce(clientFn, reportError) {
15691
- try {
15692
- const frame = await clientFn().gitStatus();
15693
- if (frame.state === "ready") return { state: "ready", log: "workspace git ready" };
15694
- if (frame.state === "failed") {
15695
- return {
15696
- state: "failed",
15697
- log: `workspace git preparation failed: ${frame.reason ?? "unknown reason"}`
15698
- };
15699
- }
15700
- return { state: null };
15701
- } catch (err) {
15702
- if (err instanceof WorkbenchError && err.code === "unauthorized") {
15703
- return {
15704
- state: "failed",
15705
- log: "workspace git gate unauthorized \u2014 workbench token missing or invalid, giving up"
15706
- };
15707
- }
15708
- const message = err instanceof Error ? err.message : String(err);
15709
- return reportError ? { state: null, log: `workspace git poll error (retrying): ${message}` } : { state: null };
15710
- }
15711
- }
15712
- async function pollDaemon(clientFn, timeoutMs, pollMs, onLog, signal) {
15713
- const deadline = Date.now() + timeoutMs;
15714
- let loggedError = false;
15715
- for (; ; ) {
15716
- if (signal?.aborted) {
15717
- onLog?.("workspace git wait aborted \u2014 giving up");
15718
- return "timeout";
15719
- }
15720
- const outcome = await pollOnce(clientFn, !loggedError);
15721
- if (outcome.log !== void 0) {
15722
- loggedError = true;
15723
- onLog?.(outcome.log);
15724
- }
15725
- if (outcome.state !== null) return outcome.state;
15726
- if (Date.now() >= deadline) {
15727
- onLog?.(`workspace git not ready after ${timeoutMs}ms \u2014 giving up`);
15728
- return "timeout";
15729
- }
15730
- await delay(pollMs, signal);
15731
- }
15732
- }
15733
-
15734
- // src/runner/port-discovery.ts
15735
- import { readFile as readFile3 } from "fs/promises";
15736
- import { execFile as execFile3 } from "child_process";
15737
- var PROC_TCP_LISTEN_STATE = "0A";
15738
- function isLoopbackHexAddress(hex) {
15739
- const addr = hex.toUpperCase();
15740
- if (addr.length === 8) {
15741
- return addr.slice(6, 8) === "7F";
15742
- }
15743
- if (addr.length === 32) {
15744
- if (addr === "00000000000000000000000001000000") return true;
15745
- if (addr.slice(0, 16) === "0000000000000000" && addr.slice(16, 24) === "FFFF0000") {
15746
- return addr.slice(30, 32) === "7F";
15747
- }
15748
- return false;
15749
- }
15750
- return false;
15751
- }
15752
- function parseProcNetTcpListeners(content) {
15753
- const sockets = [];
15754
- const lines = content.split("\n");
15755
- for (let i = 1; i < lines.length; i++) {
15756
- const line = lines[i];
15757
- if (!line) continue;
15758
- const cols = line.trim().split(/\s+/);
15759
- if (cols.length < 4 || cols[3] !== PROC_TCP_LISTEN_STATE) continue;
15760
- const local = cols[1];
15761
- if (!local) continue;
15762
- const [addrHex, portHex] = local.split(":");
15763
- if (!addrHex || !portHex) continue;
15764
- const port = Number.parseInt(portHex, 16);
15765
- if (!Number.isInteger(port) || port < 1 || port > 65535) continue;
15766
- sockets.push({ port, loopback: isLoopbackHexAddress(addrHex) });
15767
- }
15768
- return sockets;
15769
- }
15770
- function collectScan(sockets) {
15771
- const ports = /* @__PURE__ */ new Set();
15772
- const hasExternal = /* @__PURE__ */ new Set();
15773
- for (const { port, loopback } of sockets) {
15774
- ports.add(port);
15775
- if (!loopback) hasExternal.add(port);
15776
- }
15777
- const loopbackOnly = /* @__PURE__ */ new Set();
15778
- for (const port of ports) {
15779
- if (!hasExternal.has(port)) loopbackOnly.add(port);
15780
- }
15781
- return { ports, loopbackOnly };
15782
- }
15783
- var DEFAULT_PROC_PATHS = ["/proc/net/tcp", "/proc/net/tcp6"];
15784
- async function readProcListeningPorts(procPaths = DEFAULT_PROC_PATHS) {
15785
- const sockets = [];
15786
- let readable = false;
15787
- for (const path2 of procPaths) {
15788
- try {
15789
- const content = await readFile3(path2, "utf8");
15790
- readable = true;
15791
- sockets.push(...parseProcNetTcpListeners(content));
15792
- } catch {
15793
- }
15794
- }
15795
- return readable ? collectScan(sockets) : null;
15796
- }
15797
- async function readNetstatListeningPorts() {
15798
- const output = await new Promise((resolve) => {
15799
- execFile3("netstat", ["-an", "-p", "tcp"], { timeout: 5e3 }, (err, stdout) => {
15800
- resolve(err ? null : stdout);
15801
- });
15802
- });
15803
- if (output === null) return null;
15804
- const sockets = [];
15805
- for (const line of output.split("\n")) {
15806
- if (!line.includes("LISTEN")) continue;
15807
- const cols = line.trim().split(/\s+/);
15808
- const local = cols[3];
15809
- if (!local) continue;
15810
- const lastDot = local.lastIndexOf(".");
15811
- if (lastDot < 0) continue;
15812
- const host = local.slice(0, lastDot);
15813
- const port = Number(local.slice(lastDot + 1));
15814
- if (!Number.isInteger(port) || port < 1 || port > 65535) continue;
15815
- const loopback = host.startsWith("127.") || host === "::1" || host === "localhost";
15816
- sockets.push({ port, loopback });
15817
- }
15818
- return collectScan(sockets);
15819
- }
15820
- async function readListeningPorts() {
15821
- const proc = await readProcListeningPorts();
15822
- if (proc !== null) return proc;
15823
- if (process.platform !== "linux") return readNetstatListeningPorts();
15824
- return null;
15825
- }
15826
- var DEFAULT_EXCLUDED_PORTS = [2222, 5432, 6379, 9200];
15827
- var DEFAULT_EPHEMERAL_PORT_MIN = 32768;
15828
- var DEFAULT_DISCOVERY_INTERVAL_MS = 15e3;
15829
- var DEFAULT_MAX_PORTS = 16;
15830
- var CONFIRM_SCANS = 2;
15831
- var PortDiscovery = class {
15832
- opts;
15833
- intervalMs;
15834
- maxPorts;
15835
- excluded;
15836
- ephemeralPortMin;
15837
- scan;
15838
- now;
15839
- log;
15840
- baseline = null;
15841
- tracked = /* @__PURE__ */ new Map();
15842
- /** Loopback-only candidates already warned about (once per port). */
15843
- warnedLoopback = /* @__PURE__ */ new Set();
15844
- timer = null;
15845
- ticking = false;
15846
- disabled = false;
15847
- stopped = false;
15848
- /** Set when the confirmed set changed (or a report failed) — cleared only
15849
- * after a successful report, so transient RPC failures retry next tick. */
15850
- reportPending = false;
15851
- lastReportedKey = "";
15852
- constructor(options) {
15853
- this.opts = options;
15854
- this.intervalMs = options.intervalMs ?? DEFAULT_DISCOVERY_INTERVAL_MS;
15855
- this.maxPorts = options.maxPorts ?? DEFAULT_MAX_PORTS;
15856
- this.excluded = new Set(options.excludedPorts ?? DEFAULT_EXCLUDED_PORTS);
15857
- this.ephemeralPortMin = options.ephemeralPortMin ?? DEFAULT_EPHEMERAL_PORT_MIN;
15858
- this.scan = options.scan ?? readListeningPorts;
15859
- this.now = options.now ?? (() => /* @__PURE__ */ new Date());
15860
- this.log = options.log ?? ((m) => process.stderr.write(`[conveyor-agent] ${m}
15861
- `));
15862
- }
15863
- /** Take the baseline scan and start polling. Safe to call once. */
15864
- async start() {
15865
- if (this.timer || this.disabled || this.stopped) return;
15866
- const baseline = await this.scanSafe();
15867
- if (this.stopped) return;
15868
- if (baseline === null) {
15869
- this.disabled = true;
15870
- this.log("Port discovery disabled: no listening-socket source available");
15871
- return;
15872
- }
15873
- this.baseline = baseline.ports;
15874
- this.timer = setInterval(() => void this.tick(), this.intervalMs);
15875
- this.timer.unref?.();
15876
- }
15877
- stop() {
15878
- this.stopped = true;
15879
- if (this.timer) {
15880
- clearInterval(this.timer);
15881
- this.timer = null;
15882
- }
15883
- }
15884
- /** One poll cycle. Exposed for tests (deterministic, no timers needed). */
15885
- async tick() {
15886
- if (this.ticking || this.disabled || !this.baseline) return;
15887
- this.ticking = true;
15888
- try {
15889
- const current = await this.scanSafe();
15890
- if (current === null) return;
15891
- this.updateTracking(current);
15892
- if (this.reportPending) await this.flushReport();
15893
- } finally {
15894
- this.ticking = false;
15895
- }
15896
- }
15897
- async scanSafe() {
15898
- try {
15899
- return await this.scan();
15900
- } catch {
15901
- return null;
15902
- }
15903
- }
15904
- isCandidate(port) {
15905
- if (this.baseline?.has(port)) return false;
15906
- if (this.excluded.has(port)) return false;
15907
- if (port >= this.ephemeralPortMin) return false;
15908
- return true;
15909
- }
15910
- updateTracking(current) {
15911
- const reachable = /* @__PURE__ */ new Set();
15912
- for (const port of current.ports) {
15913
- if (!this.isCandidate(port)) continue;
15914
- if (current.loopbackOnly.has(port)) {
15915
- if (!this.warnedLoopback.has(port)) {
15916
- this.warnedLoopback.add(port);
15917
- this.log(
15918
- `Port ${port} is listening on loopback only and cannot be previewed \u2014 bind 0.0.0.0 (or the pod IP) to make it reachable through the preview proxy`
15919
- );
15920
- }
15921
- continue;
15922
- }
15923
- reachable.add(port);
15924
- }
15925
- for (const port of reachable) {
15926
- const entry = this.tracked.get(port);
15927
- if (!entry) {
15928
- this.tracked.set(port, { seen: 1, missed: 0, confirmed: false, detectedAt: "" });
15929
- continue;
15930
- }
15931
- entry.seen += 1;
15932
- entry.missed = 0;
15933
- if (!entry.confirmed && entry.seen >= CONFIRM_SCANS) {
15934
- entry.confirmed = true;
15935
- entry.detectedAt = this.now().toISOString();
15936
- }
15937
- }
15938
- for (const [port, entry] of this.tracked) {
15939
- if (reachable.has(port)) continue;
15940
- entry.missed += 1;
15941
- entry.seen = 0;
15942
- if (entry.missed >= CONFIRM_SCANS || !entry.confirmed) this.tracked.delete(port);
15943
- }
15944
- const key = this.confirmedKey();
15945
- if (key !== this.lastReportedKey) this.reportPending = true;
15946
- }
15947
- confirmedPorts() {
15948
- const confirmed = [...this.tracked.entries()].filter(([, entry]) => entry.confirmed).sort(([a], [b]) => a - b).slice(0, this.maxPorts);
15949
- return confirmed.map(([port, entry]) => ({
15950
- port,
15951
- protocol: "tcp",
15952
- detectedAt: entry.detectedAt
15953
- }));
15954
- }
15955
- confirmedKey() {
15956
- return this.confirmedPorts().map(({ port }) => port).join(",");
15957
- }
15958
- async flushReport() {
15959
- const ports = this.confirmedPorts();
15960
- const key = ports.map(({ port }) => port).join(",");
15961
- try {
15962
- await this.opts.report(ports);
15963
- this.lastReportedKey = key;
15964
- this.reportPending = false;
15965
- this.log(`Discovered preview ports: [${key || "none"}]`);
15966
- } catch {
15967
- return;
15968
- }
15969
- try {
15970
- await this.opts.onReported?.(ports);
15971
- } catch {
15972
- }
15973
- }
15974
- };
15975
-
15976
- // src/runner/codespace-port-visibility.ts
15977
- import { execFile as execFile4 } from "child_process";
15978
- var GH_TIMEOUT_MS = 15e3;
15979
- var VISIBILITIES = ["org", "public"];
15980
- function runGh(args) {
15981
- return new Promise((resolve) => {
15982
- execFile4("gh", [...args], { timeout: GH_TIMEOUT_MS }, (error, _stdout, stderr) => {
15983
- resolve({ ok: !error, stderr: (stderr || (error ? String(error.message) : "")).trim() });
15984
- });
15985
- });
15986
- }
15987
- function isCodespaceEnvironment(env = process.env) {
15988
- return env.CODESPACES === "true" && !!env.CODESPACE_NAME;
15989
- }
15990
- var CodespacePortVisibility = class {
15991
- env;
15992
- run;
15993
- log;
15994
- /** Ports already attempted (success or failure) — one try per process. */
15995
- attempted = /* @__PURE__ */ new Set();
15996
- constructor(options = {}) {
15997
- this.env = options.env ?? process.env;
15998
- this.run = options.run ?? runGh;
15999
- this.log = options.log ?? ((m) => process.stderr.write(`[conveyor-agent] ${m}
16000
- `));
16001
- }
16002
- /** Flip every not-yet-attempted port. Resolves even when everything fails. */
16003
- async ensureVisible(ports) {
16004
- if (!isCodespaceEnvironment(this.env)) return;
16005
- const codespaceName = this.env.CODESPACE_NAME;
16006
- for (const port of ports) {
16007
- if (this.attempted.has(port)) continue;
16008
- this.attempted.add(port);
16009
- await this.flip(port, codespaceName);
16010
- }
16011
- }
16012
- async flip(port, codespaceName) {
16013
- let lastError = "";
16014
- for (const visibility of VISIBILITIES) {
16015
- const result = await this.run([
16016
- "codespace",
16017
- "ports",
16018
- "visibility",
16019
- `${port}:${visibility}`,
16020
- "-c",
16021
- codespaceName
16022
- ]).catch((error) => ({
16023
- ok: false,
16024
- stderr: error instanceof Error ? error.message : String(error)
16025
- }));
16026
- if (result.ok) {
16027
- this.log(`Forwarded port ${port} set to ${visibility} visibility`);
16028
- return;
16029
- }
16030
- lastError = result.stderr;
16031
- }
16032
- this.log(
16033
- `Could not change visibility of forwarded port ${port} \u2014 the preview URL may 404 for other users${lastError ? `: ${lastError}` : ""}`
16034
- );
16035
- }
16036
- };
16037
-
16038
13640
  // src/runner/parent-pull-handler.ts
16039
- import { execFile as execFile5 } from "child_process";
16040
- import { promisify as promisify3 } from "util";
16041
- var execFileAsync2 = promisify3(execFile5);
13641
+ import { execFile as execFile2 } from "child_process";
13642
+ import { promisify as promisify2 } from "util";
13643
+ var execFileAsync = promisify2(execFile2);
16042
13644
  async function handlePullBranch(workDir, branch) {
16043
13645
  if (!branch) return;
16044
13646
  const current = await getCurrentBranch(workDir);
@@ -16057,14 +13659,14 @@ async function handlePullBranch(workDir, branch) {
16057
13659
  return;
16058
13660
  }
16059
13661
  try {
16060
- await execFileAsync2("git", ["fetch", "origin", branch], { cwd: workDir, timeout: 6e4 });
13662
+ await execFileAsync("git", ["fetch", "origin", branch], { cwd: workDir, timeout: 6e4 });
16061
13663
  } catch {
16062
13664
  process.stderr.write(`[conveyor-agent] pull_branch: fetch failed for ${branch}
16063
13665
  `);
16064
13666
  return;
16065
13667
  }
16066
13668
  try {
16067
- await execFileAsync2("git", ["pull", "--ff-only", "origin", branch], {
13669
+ await execFileAsync("git", ["pull", "--ff-only", "origin", branch], {
16068
13670
  cwd: workDir,
16069
13671
  timeout: 6e4
16070
13672
  });
@@ -17250,49 +14852,6 @@ ${outcome.failures.join("\n")}
17250
14852
  }
17251
14853
  };
17252
14854
 
17253
- // src/setup/config.ts
17254
- import { join as join14 } from "path";
17255
- var DEVCONTAINER_PATH = ".devcontainer/conveyor/devcontainer.json";
17256
- var DEVCONTAINER_PORT_DENY_LIST = /* @__PURE__ */ new Set([5432, 6379, 9200]);
17257
- async function loadForwardPorts(workspaceDir) {
17258
- try {
17259
- const raw = await readWorkspaceFile(join14(workspaceDir, DEVCONTAINER_PATH));
17260
- const parsed = JSON.parse(raw);
17261
- const ports = (parsed.forwardPorts ?? []).filter(
17262
- (p) => typeof p === "number" && !DEVCONTAINER_PORT_DENY_LIST.has(p)
17263
- );
17264
- const attributes = {};
17265
- for (const [key, value] of Object.entries(parsed.portsAttributes ?? {})) {
17266
- if (!value || typeof value !== "object") continue;
17267
- const entry = {};
17268
- if (typeof value.label === "string") entry.label = value.label;
17269
- if (value.visibility === "public" || value.visibility === "private") {
17270
- entry.visibility = value.visibility;
17271
- }
17272
- attributes[key] = entry;
17273
- }
17274
- return { ports, attributes };
17275
- } catch {
17276
- return { ports: [], attributes: {} };
17277
- }
17278
- }
17279
- function buildSessionPreviewPorts(result) {
17280
- return result.ports.filter((port) => !DEVCONTAINER_PORT_DENY_LIST.has(port)).map((port) => {
17281
- const attr = result.attributes[String(port)];
17282
- const entry = { port };
17283
- if (attr?.label) entry.label = attr.label;
17284
- if (attr?.visibility) entry.visibility = attr.visibility;
17285
- return entry;
17286
- });
17287
- }
17288
- function loadConveyorConfig() {
17289
- const envStart = process.env.CONVEYOR_START_COMMAND;
17290
- if (envStart) {
17291
- return { startCommand: envStart };
17292
- }
17293
- return null;
17294
- }
17295
-
17296
14855
  // src/setup/codespace.ts
17297
14856
  import { execSync } from "child_process";
17298
14857
  function unshallowRepo(workspaceDir) {
@@ -17307,30 +14866,13 @@ function unshallowRepo(workspaceDir) {
17307
14866
  }
17308
14867
 
17309
14868
  export {
17310
- fetchBootstrap,
17311
- applyBootstrapToEnv,
17312
- createServiceLogger,
17313
- AgentConnection,
17314
14869
  DEFAULT_SONNET_MODEL,
17315
14870
  isPermissionDeniedError,
17316
- DEFAULT_LIFECYCLE_CONFIG,
17317
- Lifecycle,
17318
14871
  buildSynthesizedCredentials,
17319
14872
  claudeJsonPath,
17320
14873
  PtyHarness,
17321
14874
  resolveTuiKindFromEnv,
17322
14875
  resolveTuiAdapter,
17323
- readWorkspaceBytes,
17324
- statWorkspacePath,
17325
- workspacePathExists,
17326
- GIT_TIMEOUT_MS,
17327
- updateRemoteToken,
17328
- hasUncommittedChanges,
17329
- getCurrentBranch,
17330
- hasUnpushedCommits,
17331
- stageAndCommit,
17332
- flushPendingChanges,
17333
- pushToOrigin,
17334
14876
  buildProjectTools,
17335
14877
  resolvePlaywrightMcpServer,
17336
14878
  resolveSessionStart,
@@ -17339,12 +14881,6 @@ export {
17339
14881
  sampleKeyUsage,
17340
14882
  buildRateLimitEvents,
17341
14883
  buildUnmeasurableEvent,
17342
- awaitGitReady,
17343
- PortDiscovery,
17344
- CodespacePortVisibility,
17345
14884
  SessionRunner,
17346
- loadForwardPorts,
17347
- buildSessionPreviewPorts,
17348
- loadConveyorConfig,
17349
14885
  unshallowRepo
17350
14886
  };