@peakflo/agent-harness 0.2.11-rc.2 → 0.2.11-rc.4

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.
@@ -37,10 +37,205 @@
37
37
  * test suite asserts the bind address.
38
38
  */
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
- exports.McpAuthProxy = void 0;
40
+ exports.McpAuthProxy = exports.BROWSER_HOLD_ATTEMPT_TIMEOUT_MS = exports.BROWSER_HOLD_MAX_FAILURES = exports.BROWSER_HOLD_MAX_BODY_BYTES = void 0;
41
+ exports.listenLoopback = listenLoopback;
42
+ exports.loopbackPortFromEnv = loopbackPortFromEnv;
43
+ const node_crypto_1 = require("node:crypto");
41
44
  const node_http_1 = require("node:http");
42
45
  const node_https_1 = require("node:https");
43
- const node_crypto_1 = require("node:crypto");
46
+ const browser_takeover_1 = require("./browser-takeover");
47
+ const loopback_peer_1 = require("./loopback-peer");
48
+ /** Largest agent request body the browser hold buffers to re-send. */
49
+ exports.BROWSER_HOLD_MAX_BODY_BYTES = 1024 * 1024;
50
+ /** Consecutive upstream failures (network/5xx) a hold tolerates. */
51
+ exports.BROWSER_HOLD_MAX_FAILURES = 10;
52
+ /** A takeover 409 faster than this was not held by the gateway: back off. */
53
+ const BROWSER_HOLD_FAST_ANSWER_MS = 5_000;
54
+ /**
55
+ * Longest a held re-send may wait for response headers before it counts as a
56
+ * transient upstream failure. The gateway holds one await-handback request
57
+ * for 20 s; after a handback it runs the call (a broker command is at most
58
+ * ~65 s), so this only fires for a gateway that has silently gone away — a
59
+ * hung socket would otherwise park Pi until its 24 h MCP timeout.
60
+ */
61
+ exports.BROWSER_HOLD_ATTEMPT_TIMEOUT_MS = 180_000;
62
+ /** Largest 409 takeover body read (it is a small JSON document). */
63
+ const BROWSER_TAKEOVER_BODY_MAX_BYTES = 64 * 1024;
64
+ /**
65
+ * Listen on 127.0.0.1:`port` (0 = ephemeral). When a FIXED port is taken the
66
+ * listener falls back to an ephemeral one and says so loudly, rather than
67
+ * failing the harness start: the per-uid `BrowserPeerGuard` still refuses a
68
+ * chat browser on any port, and the fixed number only feeds a defence-in-depth
69
+ * deny rule. Resolves with the bound port.
70
+ */
71
+ function listenLoopback(server, port, logger, label) {
72
+ const attempt = (wanted) => new Promise((resolvePort, reject) => {
73
+ const onError = (error) => reject(error);
74
+ server.once('error', onError);
75
+ server.listen(wanted, '127.0.0.1', () => {
76
+ server.removeListener('error', onError);
77
+ const address = server.address();
78
+ if (typeof address === 'object' && address)
79
+ resolvePort(address.port);
80
+ else
81
+ reject(new Error(`${label} could not determine its address`));
82
+ });
83
+ });
84
+ if (!port)
85
+ return attempt(0);
86
+ return attempt(port).catch((error) => {
87
+ if (error && error.code === 'EADDRINUSE') {
88
+ logger.warn(`[${label}] fixed port ${port} is in use; falling back to an ephemeral port`);
89
+ return attempt(0);
90
+ }
91
+ throw error;
92
+ });
93
+ }
94
+ /** A fixed loopback port from the environment, or 0 (ephemeral) when unset or invalid. */
95
+ function loopbackPortFromEnv(value) {
96
+ if (!value)
97
+ return 0;
98
+ const port = Number(value);
99
+ return Number.isInteger(port) && port > 0 && port < 65536 ? port : 0;
100
+ }
101
+ /* -------------------------------------------------------------------------- */
102
+ /* Browser hold helpers */
103
+ /* -------------------------------------------------------------------------- */
104
+ function isTakeover(response) {
105
+ return (response.statusCode === browser_takeover_1.BROWSER_TAKEOVER_STATUS &&
106
+ response.headers[browser_takeover_1.BROWSER_TAKEOVER_HEADER] !== undefined);
107
+ }
108
+ /**
109
+ * Read a stream into a buffer. Over `limit`: null, or (with `truncate`) the
110
+ * first `limit` bytes. Never rejects; a broken stream yields what was read.
111
+ */
112
+ function readBounded(stream, limit, truncate = false) {
113
+ return new Promise((resolveBody) => {
114
+ const chunks = [];
115
+ let size = 0;
116
+ let over = false;
117
+ stream.on('data', (chunk) => {
118
+ if (over)
119
+ return;
120
+ size += chunk.length;
121
+ if (size > limit) {
122
+ over = true;
123
+ if (truncate) {
124
+ chunks.push(chunk.subarray(0, chunk.length - (size - limit)));
125
+ }
126
+ return;
127
+ }
128
+ chunks.push(chunk);
129
+ });
130
+ const done = () => resolveBody(over && !truncate ? null : Buffer.concat(chunks));
131
+ stream.once('end', done);
132
+ stream.once('error', done);
133
+ stream.once('close', done);
134
+ });
135
+ }
136
+ function parseJsonOrText(buffer) {
137
+ const text = buffer ? buffer.toString('utf8') : '';
138
+ try {
139
+ return JSON.parse(text);
140
+ }
141
+ catch {
142
+ return text;
143
+ }
144
+ }
145
+ /** Stream an upstream response back unchanged (SSE flushed per chunk). */
146
+ function pipeUpstreamResponse(proxyRes, res) {
147
+ if (res.destroyed) {
148
+ proxyRes.resume();
149
+ return;
150
+ }
151
+ const responseHeaders = { ...proxyRes.headers };
152
+ const contentType = String(proxyRes.headers['content-type'] || '');
153
+ const isSSE = contentType.includes('text/event-stream');
154
+ if (!isSSE)
155
+ delete responseHeaders['transfer-encoding'];
156
+ res.writeHead(proxyRes.statusCode || 502, responseHeaders);
157
+ if (isSSE) {
158
+ proxyRes.on('data', (chunk) => {
159
+ res.write(chunk);
160
+ });
161
+ proxyRes.on('end', () => res.end());
162
+ }
163
+ else {
164
+ proxyRes.pipe(res);
165
+ }
166
+ // An upstream reset mid-body (a Cloud Run instance going away) must not
167
+ // leave Pi waiting on a half-written answer: `pipe` never ends `res` then,
168
+ // and a clean `end()` would pass a truncated body off as complete.
169
+ proxyRes.once('close', () => {
170
+ if (!proxyRes.complete && !res.writableEnded)
171
+ res.destroy();
172
+ });
173
+ res.once('close', () => {
174
+ if (!proxyRes.complete)
175
+ proxyRes.destroy();
176
+ });
177
+ }
178
+ function writeJson(res, status, value) {
179
+ if (res.destroyed || res.headersSent) {
180
+ if (!res.writableEnded)
181
+ res.end();
182
+ return;
183
+ }
184
+ res.writeHead(status, { 'Content-Type': 'application/json' });
185
+ res.end(JSON.stringify(value));
186
+ }
187
+ /**
188
+ * Answer the agent's held call with a JSON-RPC error for ITS request id, so
189
+ * the tool call fails visibly instead of the turn hanging. A body without a
190
+ * single request id (batch, notification, unparsable) gets HTTP 502 JSON.
191
+ */
192
+ function writeJsonRpcError(res, body, message) {
193
+ const request = parseJsonOrText(body);
194
+ const id = request && typeof request === 'object' && !Array.isArray(request)
195
+ ? request.id
196
+ : undefined;
197
+ if (typeof id === 'string' || typeof id === 'number') {
198
+ writeJson(res, 200, {
199
+ jsonrpc: '2.0',
200
+ id,
201
+ error: { code: -32603, message }
202
+ });
203
+ return;
204
+ }
205
+ writeJson(res, 502, { error: message });
206
+ }
207
+ /** `promise`, or `onAbort` as soon as `signal` aborts (whichever is first). */
208
+ function untilAborted(promise, signal, onAbort) {
209
+ if (signal.aborted)
210
+ return Promise.resolve(onAbort);
211
+ return new Promise((resolveValue, rejectValue) => {
212
+ const abort = () => resolveValue(onAbort);
213
+ signal.addEventListener('abort', abort, { once: true });
214
+ promise.then((value) => {
215
+ signal.removeEventListener('abort', abort);
216
+ resolveValue(value);
217
+ }, (error) => {
218
+ signal.removeEventListener('abort', abort);
219
+ rejectValue(error);
220
+ });
221
+ });
222
+ }
223
+ function delay(ms, signal) {
224
+ return new Promise((resolveDelay) => {
225
+ if (signal.aborted || ms <= 0) {
226
+ resolveDelay();
227
+ return;
228
+ }
229
+ const pending = {};
230
+ const done = () => {
231
+ clearTimeout(pending.timer);
232
+ signal.removeEventListener('abort', done);
233
+ resolveDelay();
234
+ };
235
+ pending.timer = setTimeout(done, ms);
236
+ signal.addEventListener('abort', done, { once: true });
237
+ });
238
+ }
44
239
  class McpAuthProxy {
45
240
  options;
46
241
  server = null;
@@ -51,9 +246,14 @@ class McpAuthProxy {
51
246
  /** One notification per integration per process, not one per failed call. */
52
247
  notifiedFailures = new Set();
53
248
  logger;
249
+ /** Browser calls held during a takeover. Cancelled on `stop()`. */
250
+ browserHolds = new Set();
251
+ /** Chat browsers run as their own uids; they get nothing from this proxy. */
252
+ peerGuard;
54
253
  constructor(options) {
55
254
  this.options = options;
56
255
  this.logger = options.logger ?? console;
256
+ this.peerGuard = new loopback_peer_1.BrowserPeerGuard(options.peerUid);
57
257
  }
58
258
  getPort() {
59
259
  return this.port;
@@ -106,24 +306,45 @@ class McpAuthProxy {
106
306
  }
107
307
  return new Promise((resolvePort, reject) => {
108
308
  const server = (0, node_http_1.createServer)((req, res) => {
309
+ if (this.peerGuard.refuse(req.socket, res)) {
310
+ return;
311
+ }
109
312
  void this.handleRequest(req, res);
110
313
  });
314
+ this.peerGuard.attach(server);
111
315
  this.server = server;
112
- server.once('error', reject);
113
- // LOOPBACK ONLY. Never 0.0.0.0, never a fixed port.
114
- server.listen(0, '127.0.0.1', () => {
115
- const address = server.address();
116
- if (typeof address === 'object' && address) {
117
- this.port = address.port;
118
- resolvePort(address.port);
119
- }
120
- else {
121
- reject(new Error('MCP auth proxy could not determine its address'));
122
- }
123
- });
316
+ // LOOPBACK ONLY. Never 0.0.0.0. Ephemeral unless options.port pins it.
317
+ listenLoopback(server, this.options.port ?? 0, this.logger, 'McpAuthProxy')
318
+ .then((port) => {
319
+ this.port = port;
320
+ resolvePort(port);
321
+ })
322
+ .catch(reject);
124
323
  });
125
324
  }
325
+ /**
326
+ * Cancel every held browser call whose gateway URL starts with
327
+ * `targetUrlPrefix` (a full target URL, or `.../api/mcp/agent/<sessionId>/`
328
+ * for a whole session). Held calls are answered with a JSON-RPC error and
329
+ * the hook gets `end` / `cancelled`. Returns how many were cancelled.
330
+ */
331
+ cancelBrowserHolds(targetUrlPrefix) {
332
+ let count = 0;
333
+ for (const hold of this.browserHolds) {
334
+ if (hold.targetUrl.startsWith(targetUrlPrefix)) {
335
+ hold.controller.abort();
336
+ count += 1;
337
+ }
338
+ }
339
+ return count;
340
+ }
341
+ /** Number of browser calls currently held. For tests and diagnostics. */
342
+ activeBrowserHolds() {
343
+ return this.browserHolds.size;
344
+ }
126
345
  async stop() {
346
+ for (const hold of this.browserHolds)
347
+ hold.controller.abort();
127
348
  const server = this.server;
128
349
  this.server = null;
129
350
  this.port = null;
@@ -195,6 +416,10 @@ class McpAuthProxy {
195
416
  targetUrl.pathname.replace(/\/$/, '') + '/' + subPath.join('/');
196
417
  }
197
418
  targetUrl.search = reqUrl.search;
419
+ if ((0, browser_takeover_1.isBrowserTargetUrl)(target.url)) {
420
+ await this.handleBrowserRequest(req, res, id, target, targetUrl);
421
+ return;
422
+ }
198
423
  let token;
199
424
  try {
200
425
  token = await this.options.tokens.getToken(target.url);
@@ -288,6 +513,249 @@ class McpAuthProxy {
288
513
  }
289
514
  }
290
515
  }
516
+ /**
517
+ * THE BROWSER TAKEOVER HOLD (sandbox browser). Only for the built-in
518
+ * `pf-browser` gateway server; every other target takes the plain streaming
519
+ * path above, unchanged.
520
+ *
521
+ * While a person controls the chat browser, the gateway answers a waiting
522
+ * browser tool with 409 + `x-pf-browser-takeover`. That answer is NOT handed
523
+ * to Pi: the agent's call stays open (Pi's timeout for this server is 24 h)
524
+ * and the SAME body is re-sent with `x-pf-browser-await-handback: 1` — each
525
+ * with a fresh token — until the gateway answers anything else (it holds
526
+ * each such request up to 20 s and, after a handback, rewrites the call into
527
+ * a fresh snapshot). That answer is streamed back to Pi unchanged. The turn
528
+ * is never aborted; the SessionRunner counts the time as an intentional
529
+ * pause through `onBrowserPause`.
530
+ */
531
+ async handleBrowserRequest(req, res, id, target, targetUrl) {
532
+ const body = await readBounded(req, exports.BROWSER_HOLD_MAX_BODY_BYTES);
533
+ if (res.destroyed)
534
+ return;
535
+ if (body === null) {
536
+ res.writeHead(413, { 'Content-Type': 'application/json' });
537
+ res.end(JSON.stringify({ error: 'Browser MCP request body too large' }));
538
+ return;
539
+ }
540
+ let token;
541
+ try {
542
+ token = await this.options.tokens.getToken(target.url);
543
+ }
544
+ catch (error) {
545
+ this.reportTokenFailure(id, target.label, error);
546
+ if (res.destroyed)
547
+ return;
548
+ res.writeHead(502, { 'Content-Type': 'application/json' });
549
+ res.end(JSON.stringify({
550
+ error: `Failed to obtain auth token for "${target.label}"`
551
+ }));
552
+ return;
553
+ }
554
+ if (res.destroyed)
555
+ return;
556
+ // One controller for the request's whole life: Pi closing it, a cancel,
557
+ // or `stop()` all abort the in-flight upstream request and the backoff.
558
+ const hold = {
559
+ targetUrl: target.url,
560
+ controller: new AbortController()
561
+ };
562
+ const signal = hold.controller.signal;
563
+ let clientClosed = false;
564
+ const onClose = () => {
565
+ if (!res.writableEnded) {
566
+ clientClosed = true;
567
+ hold.controller.abort();
568
+ }
569
+ };
570
+ res.once('close', onClose);
571
+ this.browserHolds.add(hold);
572
+ let holding = false;
573
+ const endHold = (reason) => {
574
+ if (!holding)
575
+ return;
576
+ holding = false;
577
+ this.callBrowserHook(target.url, { state: 'end', reason });
578
+ };
579
+ try {
580
+ let outcome = await this.sendBuffered(req, targetUrl, body, token, false, signal);
581
+ if (outcome.kind === 'error') {
582
+ if (signal.aborted)
583
+ return;
584
+ this.logger.error(`[McpAuthProxy] Upstream error for "${target.label}": ${outcome.error.message}`);
585
+ writeJson(res, 502, {
586
+ error: `Upstream error: ${outcome.error.message}`
587
+ });
588
+ return;
589
+ }
590
+ if (!isTakeover(outcome.response)) {
591
+ pipeUpstreamResponse(outcome.response, res);
592
+ return;
593
+ }
594
+ const takeoverText = await readBounded(outcome.response, BROWSER_TAKEOVER_BODY_MAX_BYTES, true);
595
+ if (signal.aborted)
596
+ return;
597
+ holding = true;
598
+ const accepted = this.callBrowserHook(target.url, {
599
+ state: 'start',
600
+ body: parseJsonOrText(takeoverText)
601
+ });
602
+ if (accepted === false) {
603
+ hold.controller.abort();
604
+ }
605
+ let attempt = 0;
606
+ let failures = 0;
607
+ while (!signal.aborted) {
608
+ attempt += 1;
609
+ const startedAt = Date.now();
610
+ try {
611
+ // A FRESH token per attempt: a takeover can outlast the token. A
612
+ // token lookup that hangs must not keep a cancelled hold alive.
613
+ const attemptToken = await untilAborted(this.options.tokens.getToken(target.url), signal, null);
614
+ outcome = signal.aborted
615
+ ? { kind: 'error', error: new Error('aborted') }
616
+ : await this.sendBuffered(req, targetUrl, body, attemptToken, true, signal, this.options.browserHoldAttemptTimeoutMs ??
617
+ exports.BROWSER_HOLD_ATTEMPT_TIMEOUT_MS);
618
+ }
619
+ catch (error) {
620
+ outcome = {
621
+ kind: 'error',
622
+ error: error instanceof Error ? error : new Error(String(error))
623
+ };
624
+ }
625
+ if (signal.aborted) {
626
+ if (outcome.kind === 'response')
627
+ outcome.response.resume();
628
+ break;
629
+ }
630
+ let transient = false;
631
+ if (outcome.kind === 'error') {
632
+ transient = true;
633
+ }
634
+ else if (isTakeover(outcome.response)) {
635
+ outcome.response.resume();
636
+ failures = 0;
637
+ }
638
+ else if ((outcome.response.statusCode ?? 502) >= 500) {
639
+ outcome.response.resume();
640
+ transient = true;
641
+ }
642
+ else {
643
+ endHold('resolved');
644
+ pipeUpstreamResponse(outcome.response, res);
645
+ return;
646
+ }
647
+ if (transient) {
648
+ failures += 1;
649
+ const reason = outcome.kind === 'error'
650
+ ? outcome.error.message
651
+ : `HTTP ${outcome.response.statusCode}`;
652
+ this.logger.warn(`[McpAuthProxy] Browser hold for "${target.label}": upstream ` +
653
+ `failure ${failures}/${exports.BROWSER_HOLD_MAX_FAILURES}: ${reason}`);
654
+ if (failures >= exports.BROWSER_HOLD_MAX_FAILURES) {
655
+ endHold('failed');
656
+ writeJsonRpcError(res, body, `The browser call could not be completed: the gateway failed ` +
657
+ `${failures} times in a row while a person controlled the ` +
658
+ `browser (${reason}).`);
659
+ return;
660
+ }
661
+ }
662
+ const fast = Date.now() - startedAt < BROWSER_HOLD_FAST_ANSWER_MS;
663
+ if (transient || fast) {
664
+ await delay(this.holdBackoffMs(attempt, failures), signal);
665
+ }
666
+ }
667
+ // Aborted: Pi closed, cancelled, or the proxy is stopping.
668
+ endHold(clientClosed ? 'client_closed' : 'cancelled');
669
+ if (!clientClosed) {
670
+ writeJsonRpcError(res, body, 'The browser call was cancelled while a person controlled the ' +
671
+ 'browser (the turn ended).');
672
+ }
673
+ }
674
+ finally {
675
+ endHold(clientClosed ? 'client_closed' : 'cancelled');
676
+ this.browserHolds.delete(hold);
677
+ res.off('close', onClose);
678
+ }
679
+ }
680
+ holdBackoffMs(attempt, failures) {
681
+ if (this.options.browserHoldBackoffMs) {
682
+ return this.options.browserHoldBackoffMs(attempt, failures);
683
+ }
684
+ if (failures > 0) {
685
+ const base = Math.min(500 * 2 ** (failures - 1), 10_000);
686
+ return base / 2 + Math.random() * (base / 2);
687
+ }
688
+ return 500 + Math.random() * 1500;
689
+ }
690
+ callBrowserHook(targetUrl, event) {
691
+ try {
692
+ return this.options.onBrowserPause?.(targetUrl, event);
693
+ }
694
+ catch (error) {
695
+ // A hook must never break the request path.
696
+ this.logger.warn(`[McpAuthProxy] browser pause hook failed: ${String(error)}`);
697
+ return undefined;
698
+ }
699
+ }
700
+ /**
701
+ * One upstream request with a buffered body and a fresh token. With
702
+ * `headersTimeoutMs`, no response headers by then is an error outcome.
703
+ */
704
+ sendBuffered(req, targetUrl, body, token, awaitHandback, signal, headersTimeoutMs) {
705
+ const headers = {};
706
+ for (const [key, value] of Object.entries(req.headers)) {
707
+ const lower = key.toLowerCase();
708
+ if (lower === 'host' ||
709
+ lower === 'connection' ||
710
+ lower === 'authorization' ||
711
+ lower === 'content-length' ||
712
+ lower === 'transfer-encoding' ||
713
+ lower === browser_takeover_1.BROWSER_AWAIT_HANDBACK_HEADER) {
714
+ continue;
715
+ }
716
+ headers[key] = value;
717
+ }
718
+ if (token !== null)
719
+ headers['authorization'] = `Bearer ${token}`;
720
+ headers['host'] = targetUrl.host;
721
+ if (body.length > 0 || (req.method !== 'GET' && req.method !== 'HEAD')) {
722
+ headers['content-length'] = String(body.length);
723
+ }
724
+ if (awaitHandback)
725
+ headers[browser_takeover_1.BROWSER_AWAIT_HANDBACK_HEADER] = '1';
726
+ const send = targetUrl.protocol === 'https:' ? node_https_1.request : node_http_1.request;
727
+ return new Promise((resolveOutcome) => {
728
+ let settled = false;
729
+ const settle = (outcome) => {
730
+ if (settled) {
731
+ if (outcome.kind === 'response')
732
+ outcome.response.resume();
733
+ return;
734
+ }
735
+ settled = true;
736
+ resolveOutcome(outcome);
737
+ };
738
+ if (signal.aborted) {
739
+ settle({ kind: 'error', error: new Error('aborted') });
740
+ return;
741
+ }
742
+ const pending = {};
743
+ const upstreamReq = send(targetUrl.toString(), { method: req.method, headers, signal }, (response) => {
744
+ clearTimeout(pending.timer);
745
+ settle({ kind: 'response', response });
746
+ });
747
+ upstreamReq.on('error', (error) => {
748
+ clearTimeout(pending.timer);
749
+ settle({ kind: 'error', error });
750
+ });
751
+ if (headersTimeoutMs !== undefined && headersTimeoutMs > 0) {
752
+ pending.timer = setTimeout(() => {
753
+ upstreamReq.destroy(new Error(`no upstream answer within ${headersTimeoutMs} ms`));
754
+ }, headersTimeoutMs);
755
+ }
756
+ upstreamReq.end(body);
757
+ });
758
+ }
291
759
  /**
292
760
  * One notification per TARGET per process — keyed on the target id, not on
293
761
  * the human-readable label.
@@ -79,6 +79,14 @@ export interface SessionRunnerDeps {
79
79
  * Optional, so a runner constructed without one behaves exactly as before.
80
80
  */
81
81
  onApprovalStateChange?(sessionId: string, waiting: boolean): void;
82
+ /**
83
+ * Called when the turn starts or stops an intentional BROWSER pause: a
84
+ * person took control of the chat browser and the agent's browser call is
85
+ * held by the loopback MCP proxy until they hand back. Separate from
86
+ * `onApprovalStateChange` so an approval closing cannot relax polling while
87
+ * the browser is still held (and vice versa). Optional.
88
+ */
89
+ onBrowserPauseChange?(sessionId: string, paused: boolean): void;
82
90
  /**
83
91
  * Read the session's history from the CONTROL PLANE.
84
92
  *
@@ -379,12 +387,36 @@ export declare class SessionRunner {
379
387
  */
380
388
  private approvalWaitMs;
381
389
  private approvalWaitStartedAt;
390
+ /**
391
+ * Total wall-clock this turn has spent with its browser call HELD because a
392
+ * person controls the chat browser (takeover). Excluded from the idle guard
393
+ * and added back onto the hard ceiling exactly like approval time, but it
394
+ * never opens an approval and the approval timeout never applies: a takeover
395
+ * lasts as long as the person needs (owner decision), and the turn's abort
396
+ * and hard ceiling (minus the pause) still end it.
397
+ */
398
+ private browserPauseMs;
399
+ private browserPauseStartedAt;
400
+ /** When the last browser pause ended; the idle clock restarts from here. */
401
+ private browserPauseEndedAt;
382
402
  constructor(deps: SessionRunnerDeps);
383
403
  /**
384
404
  * True while a permission prompt is outstanding. Read by the harness to
385
405
  * decide how hard to poll the control plane for the answer.
386
406
  */
387
407
  isAwaitingApproval(): boolean;
408
+ /** True while a browser call is held because a person controls the browser. */
409
+ isBrowserPaused(): boolean;
410
+ /**
411
+ * Start or stop the browser-takeover pause clock. Called by the harness from
412
+ * the loopback MCP proxy's hold hook. Returns false when this runner has no
413
+ * live turn for `sessionId` — the caller should then cancel the hold rather
414
+ * than keep an agent call parked for a turn that is gone.
415
+ */
416
+ setBrowserPaused(sessionId: string, paused: boolean): boolean;
417
+ private closeBrowserPause;
418
+ /** Wall-clock spent in the browser pause we are in RIGHT NOW, if any. */
419
+ private liveBrowserPauseMs;
388
420
  /**
389
421
  * Run the leased turn to completion, or until `signal` aborts it.
390
422
  *