@rallycry/conveyor-agent 10.0.6 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -212,6 +212,20 @@ var AgentConnection = class _AgentConnection {
212
212
  // the model to retry the tool, which is the safe place to decide idempotency.
213
213
  static CALL_CONNECT_WAIT_MS = 2e4;
214
214
  static CALL_ACK_TIMEOUT_MS = 3e4;
215
+ // ── Proactive socket recycle ───────────────────────────────────────────
216
+ // Cloud Run severs every WebSocket at its request timeout (3600s is the
217
+ // platform ceiling), so a socket that lives past ~60 minutes is killed at a
218
+ // random moment — historically mid-tool-call, which let the spawned CLI
219
+ // abandon its MCP session. Recycle the transport at a QUIET moment (no
220
+ // in-flight RPC) before the platform deadline instead: an engine-level close
221
+ // looks like a transport drop, so Socket.IO's auto-reconnect and the
222
+ // io "reconnect" → reconnectToSession() recovery path run unchanged. Jitter
223
+ // keeps a fleet of pods from recycling in one thundering herd.
224
+ static SOCKET_RECYCLE_BASE_MS = 52 * 60 * 1e3;
225
+ static SOCKET_RECYCLE_JITTER_MS = 4 * 60 * 1e3;
226
+ static SOCKET_RECYCLE_BUSY_POLL_MS = 15e3;
227
+ recycleTimer = null;
228
+ pendingCalls = 0;
215
229
  async call(method, payload) {
216
230
  const socket = this.socket;
217
231
  if (!socket) {
@@ -219,10 +233,46 @@ var AgentConnection = class _AgentConnection {
219
233
  `Not connected (method: ${String(method)}, session: ${this.config.sessionId})`
220
234
  );
221
235
  }
222
- if (!socket.connected) {
223
- await this.waitForConnected(socket, _AgentConnection.CALL_CONNECT_WAIT_MS, String(method));
236
+ this.pendingCalls++;
237
+ try {
238
+ if (!socket.connected) {
239
+ await this.waitForConnected(socket, _AgentConnection.CALL_CONNECT_WAIT_MS, String(method));
240
+ }
241
+ return await this.emitWithAck(socket, method, payload);
242
+ } finally {
243
+ this.pendingCalls--;
244
+ }
245
+ }
246
+ /** (Re)arm the recycle timer — called on every successful (re)connect. */
247
+ scheduleSocketRecycle() {
248
+ this.clearSocketRecycle();
249
+ const delay = _AgentConnection.SOCKET_RECYCLE_BASE_MS + Math.random() * _AgentConnection.SOCKET_RECYCLE_JITTER_MS;
250
+ this.armRecycleTimer(delay);
251
+ }
252
+ clearSocketRecycle() {
253
+ if (this.recycleTimer) {
254
+ clearTimeout(this.recycleTimer);
255
+ this.recycleTimer = null;
256
+ }
257
+ }
258
+ armRecycleTimer(delay) {
259
+ this.recycleTimer = setTimeout(() => {
260
+ this.recycleTimer = null;
261
+ this.attemptSocketRecycle();
262
+ }, delay);
263
+ this.recycleTimer.unref?.();
264
+ }
265
+ attemptSocketRecycle() {
266
+ const socket = this.socket;
267
+ if (!socket?.connected) return;
268
+ if (this.pendingCalls > 0) {
269
+ this.armRecycleTimer(_AgentConnection.SOCKET_RECYCLE_BUSY_POLL_MS);
270
+ return;
224
271
  }
225
- return await this.emitWithAck(socket, method, payload);
272
+ process.stderr.write(
273
+ "[conveyor-agent] Recycling socket ahead of the platform request timeout\n"
274
+ );
275
+ socket.io.engine?.close?.();
226
276
  }
227
277
  /** Resolve once `socket` reports connected, or reject after `timeoutMs`. */
228
278
  waitForConnected(socket, timeoutMs, method) {
@@ -357,6 +407,7 @@ var AgentConnection = class _AgentConnection {
357
407
  });
358
408
  this.socket.on("connect", () => {
359
409
  process.stderr.write("[conveyor-agent] Socket connected\n");
410
+ this.scheduleSocketRecycle();
360
411
  if (!settled) {
361
412
  settled = true;
362
413
  resolve();
@@ -404,6 +455,7 @@ var AgentConnection = class _AgentConnection {
404
455
  }
405
456
  disconnect() {
406
457
  this.stopProactiveTokenRefresh();
458
+ this.clearSocketRecycle();
407
459
  void this.flushEvents();
408
460
  if (this.socket) {
409
461
  this.socket.io.reconnection(false);
@@ -590,6 +642,17 @@ var AgentConnection = class _AgentConnection {
590
642
  }).catch(() => {
591
643
  });
592
644
  }
645
+ /**
646
+ * Report that the interactive CLI process for this session has died and no
647
+ * respawn is imminent (fire-and-forget). The server clears the scrollback
648
+ * ring and broadcasts pty:ended so clients hide the Connected-TUI tab. Old
649
+ * servers that don't know the method reject harmlessly.
650
+ */
651
+ sendPtyEnded() {
652
+ if (!this.socket) return;
653
+ void this.call("ptyEnded", { sessionId: this.config.sessionId }).catch(() => {
654
+ });
655
+ }
593
656
  /** Subscribe to relayed keystrokes. Returns an unsubscribe fn. */
594
657
  onPtyInput(handler) {
595
658
  this.ptyInputCallback = handler;
@@ -2493,6 +2556,9 @@ var GetUiCliHistoryRequestSchema = z.object({
2493
2556
  var GetActivePtySessionRequestSchema = z.object({
2494
2557
  taskId: z.string()
2495
2558
  });
2559
+ var ListActivePtySessionsRequestSchema = z.object({
2560
+ taskId: z.string()
2561
+ });
2496
2562
  var SendSoftStopRequestSchema = z.object({
2497
2563
  taskId: z.string()
2498
2564
  });
@@ -2555,6 +2621,9 @@ var PtyOutputRequestSchema = z.object({
2555
2621
  cols: z.number().int().positive().max(PTY_MAX_DIMENSION).optional(),
2556
2622
  rows: z.number().int().positive().max(PTY_MAX_DIMENSION).optional()
2557
2623
  });
2624
+ var PtyEndedRequestSchema = z.object({
2625
+ sessionId: z.string()
2626
+ });
2558
2627
  var PtyInputRequestSchema = z.object({
2559
2628
  sessionId: z.string(),
2560
2629
  data: z.string().max(PTY_FRAME_MAX_CHARS)
@@ -2873,6 +2942,7 @@ var PM_CHAT_HISTORY_LIMIT = 40;
2873
2942
  var AGENT_CHAT_HISTORY_FETCH_LIMIT = Math.max(TASK_CHAT_HISTORY_LIMIT, PM_CHAT_HISTORY_LIMIT) + 10;
2874
2943
 
2875
2944
  // src/runner/mode-controller.ts
2945
+ var FRESH_AUTO_STATUSES = /* @__PURE__ */ new Set(["Planning", "Open"]);
2876
2946
  var ModeController = class {
2877
2947
  _mode;
2878
2948
  _hasExitedPlanMode = false;
@@ -2956,12 +3026,25 @@ var ModeController = class {
2956
3026
  }
2957
3027
  return this._mode;
2958
3028
  }
2959
- /** Check if auto-mode can bypass planning (task already has plan + properties) */
3029
+ /**
3030
+ * A FRESH auto task runs the read-only plan turn first (`--permission-mode plan`):
3031
+ * resolveInitialMode leaves it in `auto` with `_hasExitedPlanMode = false`, and
3032
+ * once the agent finishes it calls ExitPlanMode → building. Only tasks that have
3033
+ * already progressed past the fresh stage bypass planning and build immediately
3034
+ * with `--dangerously-skip-permissions`:
3035
+ *
3036
+ * - a build already underway (status past `Planning`/`Open`),
3037
+ * - a PR already open (safety net if status lags),
3038
+ * - a release (booted `InProgress`), or
3039
+ * - a pod restart mid-build (DB `agentMode` stays `"auto"` but status advanced).
3040
+ *
3041
+ * Runtime Build-after-Discovery never reaches this gate as `auto` — the server's
3042
+ * `resolveModeToSend` rewrites it to `"building"` for any non-`Planning` task.
3043
+ */
2960
3044
  canBypassPlanning(context) {
2961
3045
  if (this._mode !== "auto") return false;
2962
- if (context.status !== "Planning") return true;
2963
- if (context.plan?.trim() && context.storyPointId) return true;
2964
- return false;
3046
+ if (!FRESH_AUTO_STATUSES.has(context.status)) return true;
3047
+ return !!context.githubPRUrl;
2965
3048
  }
2966
3049
  // ── Mode transitions ───────────────────────────────────────────────
2967
3050
  /** Handle mode change from server */
@@ -3024,7 +3107,8 @@ var DEFAULT_LIFECYCLE_CONFIG = {
3024
3107
  dormantTimeoutMs: 60 * 60 * 1e3,
3025
3108
  heartbeatIntervalMs: 3e4,
3026
3109
  tokenRefreshIntervalMs: 45 * 60 * 1e3,
3027
- gitFlushIntervalMs: 2 * 60 * 1e3
3110
+ gitFlushIntervalMs: 2 * 60 * 1e3,
3111
+ usageSampleIntervalMs: 5 * 60 * 1e3
3028
3112
  };
3029
3113
  var Lifecycle = class {
3030
3114
  config;
@@ -3035,6 +3119,7 @@ var Lifecycle = class {
3035
3119
  idleCheckInterval = null;
3036
3120
  dormantTimer = null;
3037
3121
  gitFlushTimer = null;
3122
+ usageSampleTimer = null;
3038
3123
  constructor(config, callbacks) {
3039
3124
  this.config = config;
3040
3125
  this.callbacks = callbacks;
@@ -3080,6 +3165,21 @@ var Lifecycle = class {
3080
3165
  this.gitFlushTimer = null;
3081
3166
  }
3082
3167
  }
3168
+ // ── Claude key usage sampling ─────────────────────────────────────
3169
+ startUsageSample() {
3170
+ this.stopUsageSample();
3171
+ if (this.config.usageSampleIntervalMs <= 0) return;
3172
+ this.callbacks.onUsageSample();
3173
+ this.usageSampleTimer = setInterval(() => {
3174
+ this.callbacks.onUsageSample();
3175
+ }, this.config.usageSampleIntervalMs);
3176
+ }
3177
+ stopUsageSample() {
3178
+ if (this.usageSampleTimer) {
3179
+ clearInterval(this.usageSampleTimer);
3180
+ this.usageSampleTimer = null;
3181
+ }
3182
+ }
3083
3183
  // ── Idle timer ─────────────────────────────────────────────────────
3084
3184
  startIdleTimer() {
3085
3185
  this.clearIdleTimers();
@@ -3116,6 +3216,7 @@ var Lifecycle = class {
3116
3216
  this.stopHeartbeat();
3117
3217
  this.stopTokenRefresh();
3118
3218
  this.stopGitFlush();
3219
+ this.stopUsageSample();
3119
3220
  this.clearIdleTimers();
3120
3221
  this.cancelDormantTimer();
3121
3222
  }
@@ -3175,7 +3276,7 @@ var ClaudeCodeHarness = class {
3175
3276
  };
3176
3277
 
3177
3278
  // src/harness/pty/session.ts
3178
- import { mkdtemp as mkdtemp2, mkdir as mkdir2, rm as rm4, stat as stat3 } from "fs/promises";
3279
+ import { mkdtemp as mkdtemp2, mkdir as mkdir2, rm as rm4 } from "fs/promises";
3179
3280
  import { tmpdir as tmpdir4 } from "os";
3180
3281
  import { join as join4, dirname } from "path";
3181
3282
 
@@ -3556,6 +3657,14 @@ function buildSpawnArgs(input) {
3556
3657
  }
3557
3658
  return args;
3558
3659
  }
3660
+ function spawnOptionsFingerprint(input) {
3661
+ return JSON.stringify([
3662
+ input.model,
3663
+ input.permissionMode,
3664
+ input.appendSystemPrompt ?? "",
3665
+ input.cwd
3666
+ ]);
3667
+ }
3559
3668
  var ANSI_CSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[ -/]*[@-~]`, "g");
3560
3669
  function cleanTerminalOutput(raw, maxChars = 1200) {
3561
3670
  const noAnsi = raw.replace(ANSI_CSI, "");
@@ -3648,6 +3757,7 @@ import { join as join3 } from "path";
3648
3757
  import { randomBytes } from "crypto";
3649
3758
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3650
3759
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3760
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
3651
3761
 
3652
3762
  // src/harness/pty/mcp-server.ts
3653
3763
  var PtyMcpServer = class {
@@ -3669,6 +3779,7 @@ var PtyMcpServer = class {
3669
3779
 
3670
3780
  // src/harness/pty/tool-server.ts
3671
3781
  var LOOPBACK = "127.0.0.1";
3782
+ var MAX_SESSIONS = 16;
3672
3783
  var PtyToolServer = class {
3673
3784
  constructor(name, tools) {
3674
3785
  this.name = name;
@@ -3677,22 +3788,11 @@ var PtyToolServer = class {
3677
3788
  name;
3678
3789
  tools;
3679
3790
  http = null;
3680
- transport = null;
3681
- mcp = null;
3791
+ sessions = /* @__PURE__ */ new Map();
3682
3792
  token = randomBytes(24).toString("base64url");
3683
3793
  async start() {
3684
- const mcp = new McpServer({ name: this.name, version: "1.0.0" });
3685
- const register = mcp.tool.bind(mcp);
3686
- for (const tool2 of this.tools) {
3687
- register(tool2.name, tool2.description, tool2.schema, (args) => tool2.handler(args));
3688
- }
3689
- const transport = new StreamableHTTPServerTransport({
3690
- sessionIdGenerator: () => randomBytes(16).toString("hex"),
3691
- enableJsonResponse: true
3692
- });
3693
- await mcp.connect(transport);
3694
3794
  const server = createServer2((req, res) => {
3695
- void this.handle(req, res, transport);
3795
+ void this.handle(req, res);
3696
3796
  });
3697
3797
  await new Promise((resolve, reject) => {
3698
3798
  server.once("error", reject);
@@ -3701,33 +3801,84 @@ var PtyToolServer = class {
3701
3801
  const address = server.address();
3702
3802
  const port = address && typeof address === "object" ? address.port : 0;
3703
3803
  this.http = server;
3704
- this.transport = transport;
3705
- this.mcp = mcp;
3706
3804
  return { url: `http://${LOOPBACK}:${port}/mcp`, token: this.token };
3707
3805
  }
3708
- async handle(req, res, transport) {
3806
+ /** Fresh McpServer with every tool registered — one per MCP session. */
3807
+ buildMcpServer() {
3808
+ const mcp = new McpServer({ name: this.name, version: "1.0.0" });
3809
+ const register = mcp.tool.bind(mcp);
3810
+ for (const tool2 of this.tools) {
3811
+ register(tool2.name, tool2.description, tool2.schema, (args) => tool2.handler(args));
3812
+ }
3813
+ return mcp;
3814
+ }
3815
+ async handle(req, res) {
3709
3816
  if (req.headers.authorization !== `Bearer ${this.token}`) {
3710
3817
  res.writeHead(401).end();
3711
3818
  return;
3712
3819
  }
3713
3820
  try {
3714
- await transport.handleRequest(req, res);
3821
+ const sessionId = req.headers["mcp-session-id"];
3822
+ if (typeof sessionId === "string") {
3823
+ const session = this.sessions.get(sessionId);
3824
+ if (!session) {
3825
+ jsonRpcError(res, 404, -32001, "Session not found");
3826
+ return;
3827
+ }
3828
+ await session.transport.handleRequest(req, res);
3829
+ return;
3830
+ }
3831
+ if (req.method !== "POST") {
3832
+ jsonRpcError(res, 400, -32600, "Bad Request: Mcp-Session-Id header is required");
3833
+ return;
3834
+ }
3835
+ const body = await readJsonBody(req);
3836
+ const messages = Array.isArray(body) ? body : [body];
3837
+ if (!messages.some((m) => isInitializeRequest(m))) {
3838
+ jsonRpcError(res, 400, -32600, "Bad Request: Mcp-Session-Id header is required");
3839
+ return;
3840
+ }
3841
+ await this.openSession(req, res, body);
3715
3842
  } catch {
3716
3843
  if (res.headersSent) res.end();
3717
3844
  else res.writeHead(500).end();
3718
3845
  }
3719
3846
  }
3720
- async close() {
3721
- try {
3722
- await this.transport?.close();
3723
- } catch {
3847
+ /** Connect a fresh transport + McpServer pair and serve the initialize. */
3848
+ async openSession(req, res, parsedBody) {
3849
+ const mcp = this.buildMcpServer();
3850
+ const transport = new StreamableHTTPServerTransport({
3851
+ sessionIdGenerator: () => randomBytes(16).toString("hex"),
3852
+ enableJsonResponse: true,
3853
+ onsessioninitialized: (sid) => {
3854
+ this.sessions.set(sid, { transport, mcp });
3855
+ this.evictOverflow(sid);
3856
+ }
3857
+ });
3858
+ transport.onclose = () => {
3859
+ const sid = transport.sessionId;
3860
+ if (sid && this.sessions.get(sid)?.transport === transport) {
3861
+ this.sessions.delete(sid);
3862
+ }
3863
+ };
3864
+ await mcp.connect(transport);
3865
+ await transport.handleRequest(req, res, parsedBody);
3866
+ }
3867
+ /** Drop oldest abandoned sessions past MAX_SESSIONS (never the newest). */
3868
+ evictOverflow(currentSid) {
3869
+ for (const [sid, session] of this.sessions) {
3870
+ if (this.sessions.size <= MAX_SESSIONS) break;
3871
+ if (sid === currentSid) continue;
3872
+ this.sessions.delete(sid);
3873
+ void closeSession(session);
3724
3874
  }
3725
- this.transport = null;
3726
- try {
3727
- await this.mcp?.close();
3728
- } catch {
3875
+ }
3876
+ async close() {
3877
+ const sessions = [...this.sessions.values()];
3878
+ this.sessions.clear();
3879
+ for (const session of sessions) {
3880
+ await closeSession(session);
3729
3881
  }
3730
- this.mcp = null;
3731
3882
  const http = this.http;
3732
3883
  this.http = null;
3733
3884
  if (http) {
@@ -3737,6 +3888,27 @@ var PtyToolServer = class {
3737
3888
  }
3738
3889
  }
3739
3890
  };
3891
+ async function closeSession(session) {
3892
+ try {
3893
+ await session.transport.close();
3894
+ } catch {
3895
+ }
3896
+ try {
3897
+ await session.mcp.close();
3898
+ } catch {
3899
+ }
3900
+ }
3901
+ function jsonRpcError(res, status, code, message) {
3902
+ res.writeHead(status, { "Content-Type": "application/json" });
3903
+ res.end(JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: null }));
3904
+ }
3905
+ async function readJsonBody(req) {
3906
+ const chunks = [];
3907
+ for await (const chunk of req) {
3908
+ chunks.push(chunk);
3909
+ }
3910
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
3911
+ }
3740
3912
  async function startToolServers(mcpServers, tempDir) {
3741
3913
  const servers = [];
3742
3914
  const config = {};
@@ -3754,8 +3926,23 @@ async function startToolServers(mcpServers, tempDir) {
3754
3926
  return { servers, mcpConfigPath };
3755
3927
  }
3756
3928
 
3757
- // src/harness/pty/session.ts
3929
+ // src/harness/pty/pty-support.ts
3930
+ import { stat as stat3 } from "fs/promises";
3758
3931
  var MAX_DIAGNOSTIC_OUTPUT = 4e3;
3932
+ var MAX_BETWEEN_TURN_BUFFER = 500;
3933
+ var SUBMIT_SETTLE_MS = 300;
3934
+ var SUBMIT_NUDGE_INTERVAL_MS = 2e3;
3935
+ var SUBMIT_NUDGE_MAX_PRESSES = 5;
3936
+ var SUBMIT_NUDGE_SLOW_INTERVAL_MS = 5e3;
3937
+ var SUBMIT_NUDGE_WINDOW_MS = 9e4;
3938
+ function turnOptionsFrom(options) {
3939
+ return {
3940
+ canUseTool: options.canUseTool,
3941
+ promptDelivery: options.promptDelivery,
3942
+ planDialogAutoAccept: options.planDialogAutoAccept,
3943
+ abortController: options.abortController
3944
+ };
3945
+ }
3759
3946
  function isRecord2(value) {
3760
3947
  return typeof value === "object" && value !== null;
3761
3948
  }
@@ -3778,16 +3965,13 @@ function inheritedEnv(socketPath) {
3778
3965
  if (typeof value === "string") env[key] = value;
3779
3966
  }
3780
3967
  env.CONVEYOR_HOOK_SOCKET = socketPath;
3968
+ env.MCP_TIMEOUT ??= "60000";
3969
+ env.MCP_TOOL_TIMEOUT ??= "180000";
3781
3970
  return env;
3782
3971
  }
3783
3972
  function buildPromptBytes(text) {
3784
3973
  return `\x1B[200~${text}\x1B[201~`;
3785
3974
  }
3786
- var SUBMIT_SETTLE_MS = 300;
3787
- var SUBMIT_NUDGE_INTERVAL_MS = 2e3;
3788
- var SUBMIT_NUDGE_MAX_PRESSES = 5;
3789
- var SUBMIT_NUDGE_SLOW_INTERVAL_MS = 5e3;
3790
- var SUBMIT_NUDGE_WINDOW_MS = 9e4;
3791
3975
  function sleep3(ms) {
3792
3976
  return new Promise((resolve) => {
3793
3977
  setTimeout(resolve, ms);
@@ -3819,18 +4003,41 @@ function parseUserQuestions(input) {
3819
4003
  }
3820
4004
  return questions;
3821
4005
  }
4006
+
4007
+ // src/harness/pty/session.ts
3822
4008
  var PtySession = class {
3823
4009
  constructor(prompt, options, resume, bridge) {
3824
- this.prompt = prompt;
3825
4010
  this.options = options;
3826
4011
  this.resume = resume;
3827
4012
  this.bridge = bridge;
4013
+ this.turnPrompt = prompt;
4014
+ this.turn = turnOptionsFrom(options);
3828
4015
  }
3829
- prompt;
3830
4016
  options;
3831
4017
  resume;
3832
4018
  bridge;
3833
- queue = new AsyncEventQueue();
4019
+ // The current turn's event stream. Null between turns (process parked) — the
4020
+ // process outlives any single turn, so the queue is per-turn, not per-process.
4021
+ activeQueue = null;
4022
+ // The drain generator for the current turn, captured at turn-start and bound
4023
+ // to that turn's queue instance. events() returns THIS, not a fresh drain of
4024
+ // activeQueue — a turn can end (closing + nulling activeQueue) synchronously
4025
+ // within beginPassiveTurn (a buffered result) before the consumer calls
4026
+ // events(), and the consumer must still drain the already-buffered records.
4027
+ turnStream = null;
4028
+ // Transcript events that arrive with no turn draining (parked window). A
4029
+ // passive turn replays these; beginTurn drops them.
4030
+ betweenTurnBuffer = [];
4031
+ // Leading-edge guard so a burst of parked activity fires onPassiveActivity
4032
+ // once, not per event. Reset when a turn (re)starts.
4033
+ passiveSignaled = false;
4034
+ passiveListener = null;
4035
+ // Set once the CLI process actually exits (distinct from _toreDown, which is
4036
+ // our own teardown). Blocks reuse of a dead process.
4037
+ exited = false;
4038
+ // Whether the turn that just ended did so via a clean transcript `result`
4039
+ // (eligible for parking) vs. an abort/error (must teardown).
4040
+ lastTurnCleanResult = false;
3834
4041
  socket = null;
3835
4042
  tailer = null;
3836
4043
  pty = null;
@@ -3859,27 +4066,173 @@ var PtySession = class {
3859
4066
  // Submit-nudge state (see armSubmitNudge).
3860
4067
  pendingSubmitNudge = false;
3861
4068
  submitNudgeTimer = null;
4069
+ // Per-turn state: the prompt to feed and the per-turn options subset. Both
4070
+ // start from the constructor args (turn 1) and are replaced by beginTurn.
4071
+ turnPrompt;
4072
+ turn;
3862
4073
  onIdle(listener) {
3863
4074
  this.idleListeners.push(listener);
3864
4075
  }
3865
4076
  onExit(listener) {
3866
4077
  this.exitListeners.push(listener);
3867
4078
  }
4079
+ /** Subscribe to parked-window transcript activity (human typed into the idle
4080
+ * TUI). Returns unsubscribe. Only one listener is retained. */
4081
+ onPassiveActivity(listener) {
4082
+ this.passiveListener = listener;
4083
+ return () => {
4084
+ if (this.passiveListener === listener) this.passiveListener = null;
4085
+ };
4086
+ }
3868
4087
  get isToreDown() {
3869
4088
  return this._toreDown;
3870
4089
  }
4090
+ /** True once the underlying CLI process has exited. */
4091
+ get hasExited() {
4092
+ return this.exited;
4093
+ }
4094
+ /** True when the last turn ended via a clean transcript result (parkable). */
4095
+ get endedCleanly() {
4096
+ return this.lastTurnCleanResult;
4097
+ }
4098
+ /** The deterministic session UUID this process is bound to. */
4099
+ get sessionUuid() {
4100
+ return this.resume ?? this.options.sessionId;
4101
+ }
4102
+ /** Fingerprint of the spawn-time options a reused process cannot change. */
4103
+ get spawnFingerprint() {
4104
+ return spawnOptionsFingerprint({
4105
+ model: this.options.model,
4106
+ permissionMode: this.options.permissionMode,
4107
+ ...this.options.appendSystemPrompt ? { appendSystemPrompt: this.options.appendSystemPrompt } : {},
4108
+ cwd: this.options.cwd
4109
+ });
4110
+ }
4111
+ /**
4112
+ * Whether this parked process can serve a turn wanting `resume`/`fingerprint`.
4113
+ * Requires: live (not torn down / not exited), idle (no turn draining), a
4114
+ * matching resume target, and matching spawn options.
4115
+ */
4116
+ canReuse(resume, fingerprint) {
4117
+ return !this._toreDown && !this.exited && this.activeQueue === null && resume !== void 0 && resume === this.sessionUuid && fingerprint === this.spawnFingerprint;
4118
+ }
3871
4119
  get hookSocketPath() {
3872
4120
  return this.socket ? this.socket.socketPath : null;
3873
4121
  }
3874
4122
  events() {
3875
- return this.queue.drain();
4123
+ if (!this.turnStream) {
4124
+ const empty = new AsyncEventQueue();
4125
+ empty.close();
4126
+ return empty.drain();
4127
+ }
4128
+ return this.turnStream;
4129
+ }
4130
+ /**
4131
+ * Route a harness event to the current turn's queue, or — when no turn is
4132
+ * draining (the process is parked) — into the between-turn buffer, firing the
4133
+ * passive-activity signal once per parked window.
4134
+ */
4135
+ pushEvent(event) {
4136
+ if (this.activeQueue) {
4137
+ this.activeQueue.push(event);
4138
+ return;
4139
+ }
4140
+ this.betweenTurnBuffer.push(event);
4141
+ if (this.betweenTurnBuffer.length > MAX_BETWEEN_TURN_BUFFER) {
4142
+ this.betweenTurnBuffer.shift();
4143
+ }
4144
+ if (!this.passiveSignaled) {
4145
+ this.passiveSignaled = true;
4146
+ this.passiveListener?.();
4147
+ }
4148
+ }
4149
+ /**
4150
+ * Begin a follow-up turn on the ALREADY-RUNNING process: fresh per-turn state
4151
+ * and queue, rewire the abort listener to this turn's controller, then feed
4152
+ * the prompt into the live pty. The caller must have verified canReuse().
4153
+ */
4154
+ async beginTurn(prompt, options) {
4155
+ this.turnPrompt = prompt;
4156
+ this.turn = turnOptionsFrom(options);
4157
+ this.resetForTurn();
4158
+ this.betweenTurnBuffer = [];
4159
+ this.activeQueue = new AsyncEventQueue();
4160
+ this.turnStream = this.activeQueue.drain();
4161
+ if (!this.rearmAbort()) return;
4162
+ await this.feedPrompt();
4163
+ }
4164
+ /**
4165
+ * Begin a PASSIVE turn: a human typed into the parked TUI and the CLI is
4166
+ * producing transcript records with no prompt from us. Replay the buffered
4167
+ * records into a fresh queue (a buffered `result` ends the turn at once) and
4168
+ * let live records continue to flow.
4169
+ */
4170
+ beginPassiveTurn(turn) {
4171
+ this.turn = turn;
4172
+ this.resetForTurn();
4173
+ const buffered = this.betweenTurnBuffer;
4174
+ this.betweenTurnBuffer = [];
4175
+ this.activeQueue = new AsyncEventQueue();
4176
+ this.turnStream = this.activeQueue.drain();
4177
+ if (!this.rearmAbort()) return;
4178
+ for (const event of buffered) {
4179
+ this.activeQueue?.push(event);
4180
+ if (event.type === "result") this.endTurn(true);
4181
+ }
4182
+ }
4183
+ /** Reset per-turn state shared by beginTurn/beginPassiveTurn. */
4184
+ resetForTurn() {
4185
+ this.sawResult = false;
4186
+ this.lastTurnCleanResult = false;
4187
+ this.passiveSignaled = false;
4188
+ this.disarmSubmitNudge();
4189
+ this.disarmPlanDialogAutoAccept();
4190
+ }
4191
+ /**
4192
+ * (Re)register the abort→teardown listener on the current turn's controller,
4193
+ * removing any prior one. Returns false (after tearing down) when the signal
4194
+ * is already aborted, so callers can bail.
4195
+ */
4196
+ rearmAbort() {
4197
+ if (this.abortHandler && this.turn.abortController) {
4198
+ this.turn.abortController.signal.removeEventListener("abort", this.abortHandler);
4199
+ }
4200
+ this.abortHandler = null;
4201
+ const signal = this.turn.abortController?.signal;
4202
+ if (!signal) return true;
4203
+ if (signal.aborted) {
4204
+ void this.teardown();
4205
+ return false;
4206
+ }
4207
+ this.abortHandler = () => {
4208
+ void this.teardown();
4209
+ };
4210
+ signal.addEventListener("abort", this.abortHandler, { once: true });
4211
+ return true;
4212
+ }
4213
+ /**
4214
+ * End the current turn WITHOUT killing the process: close+null the queue so
4215
+ * the consumer's generator completes, and unhook the (now-finished) turn's
4216
+ * abort listener so a later abort of that controller can't teardown a process
4217
+ * we intend to reuse. The process stays alive (parked) for the next turn.
4218
+ */
4219
+ endTurn(clean) {
4220
+ this.lastTurnCleanResult = clean;
4221
+ this.activeQueue?.close();
4222
+ this.activeQueue = null;
4223
+ if (this.abortHandler && this.turn.abortController) {
4224
+ this.turn.abortController.signal.removeEventListener("abort", this.abortHandler);
4225
+ this.abortHandler = null;
4226
+ }
3876
4227
  }
3877
4228
  async start() {
3878
4229
  const sessionId = this.resume ?? this.options.sessionId;
3879
4230
  if (!sessionId) {
3880
4231
  throw new Error("PtySession requires options.sessionId or a resume target");
3881
4232
  }
3882
- const signal = this.options.abortController?.signal;
4233
+ this.activeQueue = new AsyncEventQueue();
4234
+ this.turnStream = this.activeQueue.drain();
4235
+ const signal = this.turn.abortController?.signal;
3883
4236
  if (signal?.aborted) {
3884
4237
  await this.teardown();
3885
4238
  return;
@@ -3959,7 +4312,7 @@ var PtySession = class {
3959
4312
  this.unsubResize?.();
3960
4313
  this.unsubResize = null;
3961
4314
  if (this.abortHandler) {
3962
- this.options.abortController?.signal.removeEventListener("abort", this.abortHandler);
4315
+ this.turn.abortController?.signal.removeEventListener("abort", this.abortHandler);
3963
4316
  this.abortHandler = null;
3964
4317
  }
3965
4318
  try {
@@ -3983,7 +4336,8 @@ var PtySession = class {
3983
4336
  }
3984
4337
  this.toolServers = [];
3985
4338
  this.mcpConfigPath = null;
3986
- this.queue.close();
4339
+ this.activeQueue?.close();
4340
+ this.activeQueue = null;
3987
4341
  if (this.tempDir) {
3988
4342
  await rm4(this.tempDir, { recursive: true, force: true });
3989
4343
  this.tempDir = "";
@@ -4044,18 +4398,18 @@ var PtySession = class {
4044
4398
  }
4045
4399
  async deliverPrompt(text) {
4046
4400
  this.writeStdin(buildPromptBytes(text));
4047
- if (this.options.promptDelivery === "prefill") return;
4401
+ if (this.turn.promptDelivery === "prefill") return;
4048
4402
  await sleep3(SUBMIT_SETTLE_MS);
4049
4403
  if (this._toreDown) return;
4050
4404
  this.writeStdin("\r");
4051
4405
  this.armSubmitNudge();
4052
4406
  }
4053
4407
  async feedPrompt() {
4054
- if (typeof this.prompt === "string") {
4055
- await this.deliverPrompt(this.prompt);
4408
+ if (typeof this.turnPrompt === "string") {
4409
+ await this.deliverPrompt(this.turnPrompt);
4056
4410
  return;
4057
4411
  }
4058
- for await (const message of this.prompt) {
4412
+ for await (const message of this.turnPrompt) {
4059
4413
  const content = message.message.content;
4060
4414
  const text = typeof content === "string" ? content : JSON.stringify(content);
4061
4415
  await this.deliverPrompt(text);
@@ -4103,7 +4457,7 @@ var PtySession = class {
4103
4457
  }
4104
4458
  }
4105
4459
  handleProgress(progress) {
4106
- this.queue.push({
4460
+ this.pushEvent({
4107
4461
  type: "tool_progress",
4108
4462
  ...progress.tool_name === void 0 ? {} : { tool_name: progress.tool_name },
4109
4463
  ...progress.elapsed_time_seconds === void 0 ? {} : { elapsed_time_seconds: progress.elapsed_time_seconds }
@@ -4119,10 +4473,13 @@ var PtySession = class {
4119
4473
  async handlePreToolUse(request) {
4120
4474
  if (request.tool_name === "AskUserQuestion") {
4121
4475
  this.disarmSubmitNudge();
4122
- this.queue.push({ type: "user_question", questions: parseUserQuestions(request.tool_input) });
4476
+ this.pushEvent({
4477
+ type: "user_question",
4478
+ questions: parseUserQuestions(request.tool_input)
4479
+ });
4123
4480
  return { decision: "allow" };
4124
4481
  }
4125
- const canUseTool = this.options.canUseTool;
4482
+ const canUseTool = this.turn.canUseTool;
4126
4483
  let verdict;
4127
4484
  if (canUseTool) {
4128
4485
  const result = await canUseTool(request.tool_name, request.tool_input);
@@ -4130,7 +4487,7 @@ var PtySession = class {
4130
4487
  } else {
4131
4488
  verdict = { decision: "allow" };
4132
4489
  }
4133
- if (verdict.decision === "allow" && request.tool_name === "ExitPlanMode" && this.options.planDialogAutoAccept) {
4490
+ if (verdict.decision === "allow" && request.tool_name === "ExitPlanMode" && this.turn.planDialogAutoAccept) {
4134
4491
  this.armPlanDialogAutoAccept();
4135
4492
  }
4136
4493
  return verdict;
@@ -4166,29 +4523,31 @@ var PtySession = class {
4166
4523
  handleTranscriptEvent(event) {
4167
4524
  if (this.pendingPlanApproval) this.disarmPlanDialogAutoAccept();
4168
4525
  if (this.pendingSubmitNudge) this.disarmSubmitNudge();
4169
- this.queue.push(event);
4526
+ this.pushEvent(event);
4170
4527
  if (event.type === "result") {
4171
4528
  this.sawResult = true;
4172
4529
  for (const listener of this.idleListeners) listener();
4173
- this.queue.close();
4530
+ this.endTurn(true);
4174
4531
  }
4175
4532
  }
4176
4533
  async finalizeOnExit(exitCode) {
4177
4534
  this.coalescer?.flush();
4535
+ this.exited = true;
4178
4536
  if (this._toreDown) return;
4179
4537
  if (this.tailer) {
4180
4538
  this.tailer.close();
4181
4539
  await this.tailer.flush();
4182
4540
  }
4183
- if (!this.sawResult) {
4184
- this.queue.push({
4541
+ if (!this.sawResult && this.activeQueue) {
4542
+ this.activeQueue.push({
4185
4543
  type: "result",
4186
4544
  subtype: "error",
4187
4545
  errors: buildExitErrors(exitCode, this.recentOutput, resolveClaudeBinary())
4188
4546
  });
4189
4547
  }
4190
4548
  for (const listener of this.exitListeners) listener(exitCode);
4191
- this.queue.close();
4549
+ this.activeQueue?.close();
4550
+ this.activeQueue = null;
4192
4551
  }
4193
4552
  };
4194
4553
 
@@ -4358,6 +4717,15 @@ async function removeConveyorCredentials(env = process.env) {
4358
4717
  }
4359
4718
 
4360
4719
  // src/harness/pty/index.ts
4720
+ var ENDED_GRACE_MS = 4e3;
4721
+ function fingerprintOf(options) {
4722
+ return spawnOptionsFingerprint({
4723
+ model: options.model,
4724
+ permissionMode: options.permissionMode,
4725
+ ...options.appendSystemPrompt ? { appendSystemPrompt: options.appendSystemPrompt } : {},
4726
+ cwd: options.cwd
4727
+ });
4728
+ }
4361
4729
  var PtyHarness = class {
4362
4730
  /**
4363
4731
  * `bridge` relays raw terminal I/O to/from the S2 server (and on to the S5
@@ -4370,35 +4738,144 @@ var PtyHarness = class {
4370
4738
  bridge;
4371
4739
  /** The session currently streaming events, if any — repaint target. */
4372
4740
  activeSession = null;
4741
+ /** A completed-but-alive session kept for the next turn (keep-alive). */
4742
+ parked = null;
4743
+ /** Pending "process died" → `pty:ended` timer (grace window). */
4744
+ endedTimer = null;
4745
+ /** Passive-activity subscriber, re-attached to whichever session is parked. */
4746
+ passiveHandler = null;
4373
4747
  /**
4374
- * Wiggle the live pty's size so the CLI repaints its whole screen. No-op
4375
- * between queries or on the SDK harness. Called after a socket reconnect so
4376
- * the server's (per-process, possibly brand-new) scrollback ring re-seeds
4377
- * and the Connected-TUI terminal reappears.
4748
+ * Wiggle the live pty's size so the CLI repaints its whole screen. No-op on
4749
+ * the SDK harness. Falls back to the parked session so an API reconnect while
4750
+ * the CLI is idle still re-seeds the server's scrollback ring.
4378
4751
  */
4379
4752
  forceRepaint() {
4380
- this.activeSession?.forceRepaint();
4753
+ (this.activeSession ?? this.parked)?.forceRepaint();
4381
4754
  }
4382
4755
  async *executeQuery(opts) {
4383
- const session = new PtySession(
4384
- opts.prompt,
4385
- opts.options,
4386
- opts.resume ?? opts.options.resume,
4387
- this.bridge
4388
- );
4389
- await ensureClaudeCredentials();
4390
- await ensureClaudeOnboarding(process.env, opts.options.cwd);
4391
- await session.start();
4756
+ const want = opts.resume ?? opts.options.resume;
4757
+ const fingerprint = fingerprintOf(opts.options);
4758
+ let session;
4759
+ if (this.parked?.canReuse(want, fingerprint)) {
4760
+ session = this.parked;
4761
+ this.parked = null;
4762
+ this.cancelEndedTimer();
4763
+ await session.beginTurn(opts.prompt, opts.options);
4764
+ } else {
4765
+ if (this.parked) {
4766
+ const stale = this.parked;
4767
+ this.parked = null;
4768
+ this.cancelEndedTimer();
4769
+ await stale.teardown();
4770
+ }
4771
+ session = new PtySession(opts.prompt, opts.options, want, this.bridge);
4772
+ await ensureClaudeCredentials();
4773
+ await ensureClaudeOnboarding(process.env, opts.options.cwd);
4774
+ session.onExit(() => this.handleSessionExit(session));
4775
+ await session.start();
4776
+ }
4777
+ yield* this.drain(session);
4778
+ }
4779
+ /**
4780
+ * PTY-only: subscribe to "the parked CLI produced transcript activity with no
4781
+ * query running" (a human typed into the idle Connected-TUI). Attaches to the
4782
+ * currently-parked session and to any session parked later.
4783
+ */
4784
+ onPassiveActivity(handler) {
4785
+ this.passiveHandler = handler;
4786
+ const unsubParked = this.parked?.onPassiveActivity(handler);
4787
+ return () => {
4788
+ if (this.passiveHandler === handler) this.passiveHandler = null;
4789
+ unsubParked?.();
4790
+ };
4791
+ }
4792
+ /**
4793
+ * PTY-only: drain a passive turn against the parked process — the human
4794
+ * already submitted in the TUI, so no prompt is fed. Empty stream when nothing
4795
+ * is parked.
4796
+ */
4797
+ async *executePassiveTurn(options) {
4798
+ const session = this.parked;
4799
+ if (!session) return;
4800
+ this.parked = null;
4801
+ this.cancelEndedTimer();
4802
+ session.beginPassiveTurn(options);
4803
+ yield* this.drain(session);
4804
+ }
4805
+ /**
4806
+ * Shared query/passive-turn drain: stream the session's events, then either
4807
+ * park it (clean result → keep the process for the next turn) or tear it down
4808
+ * (abort/error/early-return → real death, schedule the ended signal).
4809
+ */
4810
+ async *drain(session) {
4392
4811
  this.activeSession = session;
4812
+ let clean = false;
4393
4813
  try {
4394
4814
  for await (const event of session.events()) {
4395
4815
  yield event;
4396
4816
  }
4817
+ clean = session.endedCleanly && !session.isToreDown && !session.hasExited;
4397
4818
  } finally {
4398
4819
  if (this.activeSession === session) this.activeSession = null;
4399
- await session.teardown();
4820
+ if (clean) {
4821
+ this.park(session);
4822
+ } else {
4823
+ await session.teardown();
4824
+ this.scheduleEnded();
4825
+ }
4400
4826
  }
4401
4827
  }
4828
+ /** Park a live, idle session for reuse and (re)wire the passive listener. */
4829
+ park(session) {
4830
+ this.parked = session;
4831
+ if (this.passiveHandler) session.onPassiveActivity(this.passiveHandler);
4832
+ }
4833
+ /**
4834
+ * Parked/active process exited on its own (crash). A mid-turn death is
4835
+ * handled by drain()'s finally; here we only act on a death while PARKED —
4836
+ * tear down the husk and report ended immediately (no respawn is coming).
4837
+ */
4838
+ handleSessionExit(session) {
4839
+ if (this.parked !== session) return;
4840
+ this.parked = null;
4841
+ void session.teardown();
4842
+ this.notifyEnded();
4843
+ }
4844
+ scheduleEnded() {
4845
+ if (this.endedTimer) return;
4846
+ this.endedTimer = setTimeout(() => {
4847
+ this.endedTimer = null;
4848
+ this.notifyEnded();
4849
+ }, ENDED_GRACE_MS);
4850
+ }
4851
+ cancelEndedTimer() {
4852
+ if (this.endedTimer) {
4853
+ clearTimeout(this.endedTimer);
4854
+ this.endedTimer = null;
4855
+ }
4856
+ }
4857
+ notifyEnded() {
4858
+ this.cancelEndedTimer();
4859
+ this.bridge?.sendEnded?.();
4860
+ }
4861
+ /**
4862
+ * Tear down every live process (parked + active) and signal ended. Called by
4863
+ * the runner on stop/shutdown so the parked CLI never outlives the session and
4864
+ * the Connected-TUI tab hides promptly.
4865
+ */
4866
+ async dispose() {
4867
+ this.cancelEndedTimer();
4868
+ const toKill = [this.parked, this.activeSession].filter((s) => s !== null);
4869
+ this.parked = null;
4870
+ this.activeSession = null;
4871
+ for (const session of toKill) {
4872
+ try {
4873
+ await session.teardown();
4874
+ } catch {
4875
+ }
4876
+ }
4877
+ if (toKill.length > 0) this.notifyEnded();
4878
+ }
4402
4879
  createMcpServer(config) {
4403
4880
  return new PtyMcpServer(config.name, config.tools);
4404
4881
  }
@@ -8049,6 +8526,18 @@ async function runPrefilledFollowUp(host, context, options, resume, followUpCont
8049
8526
  });
8050
8527
  await trackAndRun(host, context, queryOptions, agentQuery);
8051
8528
  }
8529
+ async function runPassiveTurn(host, context) {
8530
+ if (host.isStopped()) return;
8531
+ if (!host.harness.executePassiveTurn) return;
8532
+ const options = buildQueryOptions(host, context);
8533
+ const passiveQuery = host.harness.executePassiveTurn(options);
8534
+ host.activeQuery = passiveQuery;
8535
+ try {
8536
+ await processEvents(passiveQuery, context, host);
8537
+ } finally {
8538
+ host.activeQuery = null;
8539
+ }
8540
+ }
8052
8541
  async function trackAndRun(host, context, options, agentQuery) {
8053
8542
  if (host.harnessKind === "pty" && options.promptDelivery !== "prefill") {
8054
8543
  agentQuery = watchForParkedTui(agentQuery, host);
@@ -8384,6 +8873,7 @@ function resolveHarnessKind() {
8384
8873
  function buildPtyBridge(connection) {
8385
8874
  return {
8386
8875
  sendOutput: (data, dims) => connection.sendPtyOutput(data, dims),
8876
+ sendEnded: () => connection.sendPtyEnded(),
8387
8877
  onInput: (handler) => connection.onPtyInput(handler),
8388
8878
  onResize: (handler) => connection.onPtyResize(handler)
8389
8879
  };
@@ -8455,6 +8945,24 @@ var QueryBridge = class {
8455
8945
  resume() {
8456
8946
  this._stopped = false;
8457
8947
  }
8948
+ /**
8949
+ * Tear down any parked/active CLI process the harness is keeping alive between
8950
+ * turns (PTY keep-alive). Called by SessionRunner on stop/shutdown so the
8951
+ * process never outlives the session and the Connected-TUI tab hides. No-op on
8952
+ * the SDK harness.
8953
+ */
8954
+ async dispose() {
8955
+ await this.harness.dispose?.();
8956
+ }
8957
+ /**
8958
+ * Subscribe to "a human typed into the parked, idle TUI" (PTY keep-alive).
8959
+ * SessionRunner uses this to wake a passive turn. Returns unsubscribe; no-op
8960
+ * (returns a noop unsubscribe) on the SDK harness.
8961
+ */
8962
+ onPassiveActivity(handler) {
8963
+ return this.harness.onPassiveActivity?.(handler) ?? (() => {
8964
+ });
8965
+ }
8458
8966
  /**
8459
8967
  * How the INITIAL instructions for this task would be delivered — "prefill"
8460
8968
  * means the TUI input is pre-filled but unsubmitted, awaiting the human.
@@ -8503,6 +9011,34 @@ var QueryBridge = class {
8503
9011
  this._abortController = null;
8504
9012
  }
8505
9013
  }
9014
+ /**
9015
+ * Drive a passive turn: the parked CLI is producing transcript records because
9016
+ * a human typed into the idle Connected-TUI. Mirrors execute()'s setup (fresh
9017
+ * abort controller so a superseding chat message can abort, host construction,
9018
+ * error classification) but runs the promptless passive drain instead of a
9019
+ * full query.
9020
+ */
9021
+ async executePassive(context) {
9022
+ this._stopped = false;
9023
+ this._wasRateLimited = false;
9024
+ this._abortController = new AbortController();
9025
+ const host = this.buildHost();
9026
+ try {
9027
+ await runPassiveTurn(host, context);
9028
+ } catch (err) {
9029
+ const msg = err instanceof Error ? err.message : String(err);
9030
+ const isAbort = this._stopped || /abort/i.test(msg);
9031
+ if (isAbort) {
9032
+ logger3.info("Passive turn stopped", { error: msg });
9033
+ } else {
9034
+ logger3.error("Passive turn failed", { error: msg });
9035
+ this.connection.sendEvent({ type: "error", message: msg });
9036
+ }
9037
+ } finally {
9038
+ this.mode.pendingModeRestart = false;
9039
+ this._abortController = null;
9040
+ }
9041
+ }
8506
9042
  // ── QueryHost construction ──────────────────────────────────────────
8507
9043
  buildHost() {
8508
9044
  const bridge = this;
@@ -8622,6 +9158,80 @@ function readAgentVersion() {
8622
9158
  return null;
8623
9159
  }
8624
9160
 
9161
+ // src/execution/usage-sampler.ts
9162
+ var logger4 = createServiceLogger("usage-sampler");
9163
+ var OAUTH_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
9164
+ var OAUTH_USAGE_BETA = "oauth-2025-04-20";
9165
+ var KNOWN_RATE_LIMIT_TYPES = [
9166
+ "five_hour",
9167
+ "seven_day",
9168
+ "seven_day_opus",
9169
+ "seven_day_sonnet"
9170
+ ];
9171
+ function normalizeUtilization(raw) {
9172
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return null;
9173
+ const fraction = raw > 1 ? raw / 100 : raw;
9174
+ return Math.max(0, Math.min(1, fraction));
9175
+ }
9176
+ function extractBucket(value) {
9177
+ if (typeof value === "number") {
9178
+ const utilization = normalizeUtilization(value);
9179
+ return utilization === null ? null : { utilization, status: "allowed" };
9180
+ }
9181
+ if (value && typeof value === "object") {
9182
+ const obj = value;
9183
+ const utilization = normalizeUtilization(
9184
+ obj.utilization ?? obj.used ?? obj.percent ?? obj.percentage
9185
+ );
9186
+ if (utilization === null) return null;
9187
+ const status = typeof obj.status === "string" ? obj.status : "allowed";
9188
+ return { utilization, status };
9189
+ }
9190
+ return null;
9191
+ }
9192
+ function mapUsageResponse(data) {
9193
+ if (!data || typeof data !== "object") return [];
9194
+ const root = data;
9195
+ const candidates = [root];
9196
+ for (const key of ["usage", "rate_limits", "rateLimits"]) {
9197
+ const nested = root[key];
9198
+ if (nested && typeof nested === "object") {
9199
+ candidates.push(nested);
9200
+ }
9201
+ }
9202
+ for (const container of candidates) {
9203
+ const samples = [];
9204
+ for (const type of KNOWN_RATE_LIMIT_TYPES) {
9205
+ const bucket = extractBucket(container[type]);
9206
+ if (bucket) samples.push({ rateLimitType: type, ...bucket });
9207
+ }
9208
+ if (samples.length > 0) return samples;
9209
+ }
9210
+ return [];
9211
+ }
9212
+ async function sampleKeyUsage(token) {
9213
+ if (!token) return [];
9214
+ try {
9215
+ const res = await fetch(OAUTH_USAGE_URL, {
9216
+ headers: {
9217
+ Authorization: `Bearer ${token}`,
9218
+ "anthropic-beta": OAUTH_USAGE_BETA
9219
+ }
9220
+ });
9221
+ if (!res.ok) {
9222
+ logger4.info("OAuth usage endpoint returned non-200", { status: res.status });
9223
+ return [];
9224
+ }
9225
+ const data = await res.json();
9226
+ return mapUsageResponse(data);
9227
+ } catch (error) {
9228
+ logger4.info("OAuth usage sample failed", {
9229
+ error: error instanceof Error ? error.message : String(error)
9230
+ });
9231
+ return [];
9232
+ }
9233
+ }
9234
+
8625
9235
  // src/runner/port-discovery.ts
8626
9236
  import { readFile as readFile4 } from "fs/promises";
8627
9237
  import { execFile } from "child_process";
@@ -8920,7 +9530,8 @@ var SessionRunner = class _SessionRunner {
8920
9530
  }
8921
9531
  },
8922
9532
  onTokenRefresh: () => void this.refreshGithubToken(),
8923
- onGitFlush: () => void this.periodicGitFlush()
9533
+ onGitFlush: () => void this.periodicGitFlush(),
9534
+ onUsageSample: () => void this.sampleAndReportKeyUsage()
8924
9535
  });
8925
9536
  }
8926
9537
  get state() {
@@ -8946,6 +9557,7 @@ var SessionRunner = class _SessionRunner {
8946
9557
  this.wireConnectionCallbacks();
8947
9558
  this.lifecycle.startHeartbeat();
8948
9559
  this.lifecycle.startTokenRefresh();
9560
+ this.lifecycle.startUsageSample();
8949
9561
  const { pendingMessages: serverMessages } = await this.connection.call("connectAgent", {
8950
9562
  sessionId: this.sessionId
8951
9563
  });
@@ -9102,16 +9714,21 @@ var SessionRunner = class _SessionRunner {
9102
9714
  break;
9103
9715
  }
9104
9716
  this.interrupted = false;
9105
- await this.callbacks.onEvent({
9106
- type: "user_message",
9107
- content: msg.content,
9108
- userId: msg.userId
9109
- });
9110
- if (this.prefillEligible(msg)) {
9111
- await this.runPrefilledMessage(msg, this.mode.effectiveMode);
9112
- } else {
9717
+ if (msg.source === "pty_passive") {
9113
9718
  await this.setState("running");
9114
- await this.executeQuery(msg.content);
9719
+ await this.executePassive();
9720
+ } else {
9721
+ await this.callbacks.onEvent({
9722
+ type: "user_message",
9723
+ content: msg.content,
9724
+ userId: msg.userId
9725
+ });
9726
+ if (this.prefillEligible(msg)) {
9727
+ await this.runPrefilledMessage(msg, this.mode.effectiveMode);
9728
+ } else {
9729
+ await this.setState("running");
9730
+ await this.executeQuery(msg.content);
9731
+ }
9115
9732
  }
9116
9733
  if (this.queryBridge?.isDiscoveryCompleted) {
9117
9734
  process.stderr.write(
@@ -9338,6 +9955,28 @@ var SessionRunner = class _SessionRunner {
9338
9955
  throw err;
9339
9956
  }
9340
9957
  }
9958
+ /**
9959
+ * A human typed into the parked, idle Connected-TUI (keep-alive PTY). Wake the
9960
+ * core loop with a sentinel so it drains a passive turn. No-op once stopped.
9961
+ */
9962
+ onPassiveTuiActivity() {
9963
+ if (this.stopped) return;
9964
+ this.injectMessage({ content: "", userId: "human", source: "pty_passive" });
9965
+ }
9966
+ /** Run queryBridge.executePassive, swallowing abort errors from stop/softStop. */
9967
+ async executePassive() {
9968
+ if (!this.fullContext || !this.queryBridge) return;
9969
+ this.agentSpokeThisTurn = false;
9970
+ try {
9971
+ await this.queryBridge.executePassive(this.fullContext);
9972
+ } catch (err) {
9973
+ if (this.interrupted || this.stopped) {
9974
+ process.stderr.write("[conveyor-agent] Passive turn aborted by stop/softStop signal\n");
9975
+ return;
9976
+ }
9977
+ throw err;
9978
+ }
9979
+ }
9341
9980
  // ── Stop / soft-stop ───────────────────────────────────────────────
9342
9981
  /** Periodic best-effort WIP commit + push during normal agent execution.
9343
9982
  * Covers ungraceful pod termination (OOMKilled, node crash/eviction) where
@@ -9372,6 +10011,24 @@ var SessionRunner = class _SessionRunner {
9372
10011
  this.periodicFlushInFlight = false;
9373
10012
  }
9374
10013
  }
10014
+ /** Sample the running Claude subscription key's rate-limit utilization from the
10015
+ * Anthropic OAuth usage endpoint and report it as `rate_limit_update` events.
10016
+ * The API (`persistRateLimitSnapshot`) attributes them to the key this session
10017
+ * launched under, keeping User Settings + the PtY-tab usage widget fresh and
10018
+ * `selectBestKey` rotation honest. Best-effort — never throws, no-op when the
10019
+ * pod has no OAuth token (e.g. API-key projects). */
10020
+ async sampleAndReportKeyUsage() {
10021
+ if (this.stopped) return;
10022
+ const samples = await sampleKeyUsage(process.env.CLAUDE_CODE_OAUTH_TOKEN);
10023
+ for (const sample of samples) {
10024
+ this.connection.sendEvent({
10025
+ type: "rate_limit_update",
10026
+ rateLimitType: sample.rateLimitType,
10027
+ utilization: sample.utilization,
10028
+ status: sample.status
10029
+ });
10030
+ }
10031
+ }
9375
10032
  /** PUT the WorkPreservation snapshot tar to the bundle's capability URL
9376
10033
  * when this pod is v3 (CONVEYOR_SNAPSHOT_UPLOAD_URL set). Never throws. */
9377
10034
  async uploadGcsSnapshotIfConfigured() {
@@ -9422,6 +10079,8 @@ var SessionRunner = class _SessionRunner {
9422
10079
  stop() {
9423
10080
  this.stopped = true;
9424
10081
  this.queryBridge?.stop();
10082
+ void this.queryBridge?.dispose().catch(() => {
10083
+ });
9425
10084
  this.portDiscovery?.stop();
9426
10085
  this.lifecycle.destroy();
9427
10086
  this.connection.disconnect();
@@ -9609,6 +10268,7 @@ var SessionRunner = class _SessionRunner {
9609
10268
  this.connection.emitModeChanged(newMode);
9610
10269
  this.softStop();
9611
10270
  };
10271
+ bridge.onPassiveActivity(() => this.onPassiveTuiActivity());
9612
10272
  return bridge;
9613
10273
  }
9614
10274
  // ── Private helpers ────────────────────────────────────────────────
@@ -9725,6 +10385,10 @@ var SessionRunner = class _SessionRunner {
9725
10385
  this.connection.sendEvent({ type: "shutdown", reason: finalState });
9726
10386
  this.portDiscovery?.stop();
9727
10387
  this.lifecycle.destroy();
10388
+ try {
10389
+ await this.queryBridge?.dispose();
10390
+ } catch {
10391
+ }
9728
10392
  try {
9729
10393
  await this.setState(finalState);
9730
10394
  } catch {
@@ -9904,6 +10568,7 @@ export {
9904
10568
  flushPendingChanges,
9905
10569
  pushToOrigin,
9906
10570
  PlanSync,
10571
+ sampleKeyUsage,
9907
10572
  awaitGitReady,
9908
10573
  uploadSnapshotToGcs,
9909
10574
  captureSnapshot,
@@ -9920,4 +10585,4 @@ export {
9920
10585
  runStartCommand,
9921
10586
  unshallowRepo
9922
10587
  };
9923
- //# sourceMappingURL=chunk-37ZIDX6S.js.map
10588
+ //# sourceMappingURL=chunk-DAHP6JPD.js.map