@oh-my-pi/pi-coding-agent 17.2.2 → 17.2.3

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.
@@ -25,7 +25,6 @@ interface AskDialogOptions {
25
25
  }
26
26
  export declare class AskDialogComponent implements Component {
27
27
  #private;
28
- private readonly questions;
29
28
  private readonly callbacks;
30
29
  private readonly options;
31
30
  constructor(questions: ExtensionAskDialogQuestion[], callbacks: AskDialogCallbacks, options?: AskDialogOptions);
@@ -37,7 +37,37 @@ export interface LaunchHeadlessResult {
37
37
  */
38
38
  userDataDir?: string;
39
39
  }
40
+ /**
41
+ * Base Chromium argv shared by process-local puppeteer launches and the
42
+ * broker-owned shared browser: sandbox/stealth flags, window size, and
43
+ * PUPPETEER_PROXY* env-derived proxy flags.
44
+ */
45
+ export declare function buildHeadlessLaunchArgs(viewport: {
46
+ width: number;
47
+ height: number;
48
+ }): string[];
40
49
  export declare function launchHeadlessBrowser(opts: LaunchHeadlessOptions): Promise<LaunchHeadlessResult>;
50
+ /** Fully resolved executable and argv for a broker-spawned shared Chromium. */
51
+ export interface SharedBrowserLaunchSpec {
52
+ executablePath: string;
53
+ args: string[];
54
+ }
55
+ /**
56
+ * Resolve the executable and complete argv for a shared Chromium the daemon
57
+ * broker spawns directly (no puppeteer inside the broker). Mirrors
58
+ * `launchHeadlessBrowser` flag assembly — puppeteer's default args minus the
59
+ * stealth-suppressed set — plus `--remote-debugging-port=0` so every client
60
+ * attaches over CDP. Returns null when no Chromium executable resolves;
61
+ * callers fall back to a process-local launch.
62
+ */
63
+ export declare function resolveSharedBrowserLaunchSpec(opts: {
64
+ headless: boolean;
65
+ userDataDir: string;
66
+ viewport?: {
67
+ width: number;
68
+ height: number;
69
+ };
70
+ }): Promise<SharedBrowserLaunchSpec | null>;
41
71
  /**
42
72
  * Remove an OMP-owned headless Chromium profile directory, tolerating the brief
43
73
  * window on Windows in which Chromium (or an orphaned browser subprocess) still
@@ -25,8 +25,13 @@ export interface PuppeteerBrowserHandle extends BrowserHandleCommon {
25
25
  browser: Browser;
26
26
  cdpUrl?: string;
27
27
  pid?: number;
28
- /** OMP-owned temp Chromium profile directory removed on dispose (headless launches). */
28
+ /** OMP-owned temp Chromium profile directory removed on dispose (process-local headless launches). */
29
29
  userDataDir?: string;
30
+ /** Broker daemon backing this handle; dispose disconnects instead of closing, kill routes to the broker. */
31
+ sharedDaemon?: {
32
+ name: string;
33
+ projectDir: string;
34
+ };
30
35
  subprocess?: Subprocess;
31
36
  stealth: {
32
37
  browserSession: CDPSession | null;
@@ -0,0 +1,26 @@
1
+ /** Broker-owned browser endpoint one omp process can attach to. */
2
+ export interface SharedBrowserEndpoint {
3
+ wsEndpoint: string;
4
+ daemonName: string;
5
+ /** Canonical project directory owning the broker (used to address later stop requests). */
6
+ projectDir: string;
7
+ }
8
+ /** Stable broker daemon name for the shared automation browser. */
9
+ export declare function sharedBrowserDaemonName(headless: boolean): string;
10
+ /**
11
+ * Ensure the project-shared automation Chromium is running and reachable,
12
+ * launching it under the daemon broker when needed. Idempotent across
13
+ * processes: losers of the start race adopt the winner's endpoint on the next
14
+ * describe round. Returns null when the shared path is unavailable (no
15
+ * resolvable Chromium, broker failure, or a daemon that never becomes
16
+ * reachable); callers fall back to a process-local launch.
17
+ */
18
+ export declare function ensureSharedBrowser(opts: {
19
+ projectDir: string;
20
+ headless: boolean;
21
+ viewport?: {
22
+ width: number;
23
+ height: number;
24
+ };
25
+ signal?: AbortSignal;
26
+ }): Promise<SharedBrowserEndpoint | null>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "17.2.2",
4
+ "version": "17.2.3",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -52,17 +52,17 @@
52
52
  "@agentclientprotocol/sdk": "1.2.1",
53
53
  "@babel/parser": "^7.29.7",
54
54
  "@mozilla/readability": "^0.6.0",
55
- "@oh-my-pi/hashline": "17.2.2",
56
- "@oh-my-pi/omp-stats": "17.2.2",
57
- "@oh-my-pi/pi-agent-core": "17.2.2",
58
- "@oh-my-pi/pi-ai": "17.2.2",
59
- "@oh-my-pi/pi-catalog": "17.2.2",
60
- "@oh-my-pi/pi-mnemopi": "17.2.2",
61
- "@oh-my-pi/pi-natives": "17.2.2",
62
- "@oh-my-pi/pi-tui": "17.2.2",
63
- "@oh-my-pi/pi-utils": "17.2.2",
64
- "@oh-my-pi/pi-wire": "17.2.2",
65
- "@oh-my-pi/snapcompact": "17.2.2",
55
+ "@oh-my-pi/hashline": "17.2.3",
56
+ "@oh-my-pi/omp-stats": "17.2.3",
57
+ "@oh-my-pi/pi-agent-core": "17.2.3",
58
+ "@oh-my-pi/pi-ai": "17.2.3",
59
+ "@oh-my-pi/pi-catalog": "17.2.3",
60
+ "@oh-my-pi/pi-mnemopi": "17.2.3",
61
+ "@oh-my-pi/pi-natives": "17.2.3",
62
+ "@oh-my-pi/pi-tui": "17.2.3",
63
+ "@oh-my-pi/pi-utils": "17.2.3",
64
+ "@oh-my-pi/pi-wire": "17.2.3",
65
+ "@oh-my-pi/snapcompact": "17.2.3",
66
66
  "@opentelemetry/api": "^1.9.1",
67
67
  "@opentelemetry/api-logs": "^0.220.0",
68
68
  "@opentelemetry/context-async-hooks": "^2.9.0",
@@ -342,6 +342,15 @@ class DaemonBroker {
342
342
  readonly #token: string;
343
343
  readonly #idleGraceMs: number;
344
344
  readonly #records = new Map<string, ManagedDaemon>();
345
+ /**
346
+ * Names reserved by an in-flight `start` before its record lands in
347
+ * `#records`. Requests dispatch concurrently, and `#start` awaits (cwd stat,
348
+ * log open) between the duplicate check and the record insert; without a
349
+ * synchronous reservation two clients can both pass the check and spawn
350
+ * duplicate processes — one exits on a held resource (e.g. a Chromium
351
+ * profile lock) or keeps running untracked.
352
+ */
353
+ readonly #startingNames = new Set<string>();
345
354
  readonly #clients = new Set<net.Socket>();
346
355
  readonly #finished = Promise.withResolvers<void>();
347
356
  readonly #sockets = new Set<net.Socket>();
@@ -500,50 +509,59 @@ class DaemonBroker {
500
509
  ) {
501
510
  throw new Error('Windows batch files require application "cmd.exe" with the batch path after "/c"');
502
511
  }
503
- const existing = this.#records.get(spec.name);
504
- if (existing) await this.#refreshDetached(existing);
505
- if (existing && !terminalState(existing.snapshot.state)) {
506
- throw new Error(`Daemon ${spec.name} is already ${existing.snapshot.state}`);
512
+ if (this.#startingNames.has(spec.name)) {
513
+ throw new Error(`Daemon ${spec.name} is already starting`);
507
514
  }
508
- if (spec.ready?.log) {
509
- try {
510
- new RegExp(spec.ready.log, "u");
511
- } catch (error) {
512
- throw new Error(`Invalid readiness regex: ${error instanceof Error ? error.message : String(error)}`);
515
+ this.#startingNames.add(spec.name);
516
+ let record: ManagedDaemon;
517
+ try {
518
+ const existing = this.#records.get(spec.name);
519
+ if (existing) await this.#refreshDetached(existing);
520
+ if (existing && !terminalState(existing.snapshot.state)) {
521
+ throw new Error(`Daemon ${spec.name} is already ${existing.snapshot.state}`);
522
+ }
523
+ if (spec.ready?.log) {
524
+ try {
525
+ new RegExp(spec.ready.log, "u");
526
+ } catch (error) {
527
+ throw new Error(`Invalid readiness regex: ${error instanceof Error ? error.message : String(error)}`);
528
+ }
513
529
  }
530
+ const stat = await fs.stat(spec.cwd);
531
+ if (!stat.isDirectory()) throw new Error(`Daemon cwd is not a directory: ${spec.cwd}`);
532
+ const dir = path.join(this.#runtimeDir, "daemons", spec.name);
533
+ const now = Date.now();
534
+ record = {
535
+ spec,
536
+ snapshot: {
537
+ name: spec.name,
538
+ id: crypto.randomUUID(),
539
+ state: "starting",
540
+ createdAt: now,
541
+ startedAt: now,
542
+ restartCount: 0,
543
+ outputBytes: 0,
544
+ owner,
545
+ persist: spec.persist,
546
+ detached: spec.detached,
547
+ },
548
+ dir,
549
+ log: await DaemonLog.open(dir),
550
+ generation: 0,
551
+ stopRequested: false,
552
+ logReady: !spec.ready?.log,
553
+ portReady: spec.ready?.port === undefined,
554
+ readinessBuffer: "",
555
+ outputOffset: 0,
556
+ readyPattern: spec.ready?.log ? new RegExp(spec.ready.log, "u") : undefined,
557
+ consecutiveFailures: 0,
558
+ persistQueue: Promise.resolve(),
559
+ };
560
+ syncReadyPending(record);
561
+ this.#records.set(spec.name, record);
562
+ } finally {
563
+ this.#startingNames.delete(spec.name);
514
564
  }
515
- const stat = await fs.stat(spec.cwd);
516
- if (!stat.isDirectory()) throw new Error(`Daemon cwd is not a directory: ${spec.cwd}`);
517
- const dir = path.join(this.#runtimeDir, "daemons", spec.name);
518
- const now = Date.now();
519
- const record: ManagedDaemon = {
520
- spec,
521
- snapshot: {
522
- name: spec.name,
523
- id: crypto.randomUUID(),
524
- state: "starting",
525
- createdAt: now,
526
- startedAt: now,
527
- restartCount: 0,
528
- outputBytes: 0,
529
- owner,
530
- persist: spec.persist,
531
- detached: spec.detached,
532
- },
533
- dir,
534
- log: await DaemonLog.open(dir),
535
- generation: 0,
536
- stopRequested: false,
537
- logReady: !spec.ready?.log,
538
- portReady: spec.ready?.port === undefined,
539
- readinessBuffer: "",
540
- outputOffset: 0,
541
- readyPattern: spec.ready?.log ? new RegExp(spec.ready.log, "u") : undefined,
542
- consecutiveFailures: 0,
543
- persistQueue: Promise.resolve(),
544
- };
545
- syncReadyPending(record);
546
- this.#records.set(spec.name, record);
547
565
  await this.#launch(record);
548
566
  let readyTimedOut = false;
549
567
  if (spec.ready && !terminalState(record.snapshot.state)) {
@@ -15,6 +15,7 @@ import {
15
15
  wrapTextWithAnsi,
16
16
  } from "@oh-my-pi/pi-tui";
17
17
  import type {
18
+ ExtensionAskDialogOption,
18
19
  ExtensionAskDialogQuestion,
19
20
  ExtensionAskDialogResultItem,
20
21
  ExtensionAskDialogSubmitResult,
@@ -337,6 +338,45 @@ function renderRowLabel(
337
338
  return lines;
338
339
  }
339
340
 
341
+ /**
342
+ * Coerce untrusted dialog questions into a render-safe shape. The live ask
343
+ * dialog is reached from the public `askDialog` extension surface and from
344
+ * streamed tool args, where a question entry can arrive with a missing or
345
+ * non-string `question` field. The render helpers (`replaceTabs`,
346
+ * `renderQuestionTitle`, `questionTabLabel`) assume strings, so a malformed
347
+ * entry throws and takes down the whole TUI render loop. Mirrors
348
+ * `normalizeRenderQuestions` on the transcript path.
349
+ */
350
+ function normalizeDialogQuestions(questions: ExtensionAskDialogQuestion[]): ExtensionAskDialogQuestion[] {
351
+ if (!Array.isArray(questions)) return [];
352
+ const out: ExtensionAskDialogQuestion[] = [];
353
+ for (const entry of questions) {
354
+ if (!entry || typeof entry !== "object") continue;
355
+ const q = entry as Partial<ExtensionAskDialogQuestion>;
356
+ const options: ExtensionAskDialogOption[] = [];
357
+ if (Array.isArray(q.options)) {
358
+ for (const opt of q.options) {
359
+ if (!opt || typeof opt !== "object") continue;
360
+ const o = opt as Partial<ExtensionAskDialogOption>;
361
+ options.push({
362
+ label: typeof o.label === "string" ? o.label : "",
363
+ ...(typeof o.description === "string" ? { description: o.description } : {}),
364
+ ...(typeof o.preview === "string" ? { preview: o.preview } : {}),
365
+ });
366
+ }
367
+ }
368
+ out.push({
369
+ id: typeof q.id === "string" ? q.id : "?",
370
+ question: typeof q.question === "string" ? q.question : "",
371
+ ...(typeof q.header === "string" ? { header: q.header } : {}),
372
+ options,
373
+ ...(typeof q.multi === "boolean" ? { multi: q.multi } : {}),
374
+ ...(Number.isInteger(q.recommended) ? { recommended: q.recommended } : {}),
375
+ });
376
+ }
377
+ return out;
378
+ }
379
+
340
380
  export class AskDialogComponent implements Component {
341
381
  #states: QuestionState[];
342
382
  #activeTabIndex = 0;
@@ -352,13 +392,15 @@ export class AskDialogComponent implements Component {
352
392
  #stableHeight: { key: string; total: number } | undefined;
353
393
  #previewCache: PreviewRenderCache = new Map();
354
394
  #overflowLayouts = new WeakMap<ExtensionAskDialogQuestion, Set<string>>();
395
+ readonly #questions: ExtensionAskDialogQuestion[];
355
396
 
356
397
  constructor(
357
- private readonly questions: ExtensionAskDialogQuestion[],
398
+ questions: ExtensionAskDialogQuestion[],
358
399
  private readonly callbacks: AskDialogCallbacks,
359
400
  private readonly options: AskDialogOptions = {},
360
401
  ) {
361
- this.#states = questions.map(question => {
402
+ this.#questions = normalizeDialogQuestions(questions);
403
+ this.#states = this.#questions.map(question => {
362
404
  const recommended = Number.isInteger(question.recommended) ? question.recommended : 0;
363
405
  const maxIndex = Math.max(0, question.options.length - 1);
364
406
  return {
@@ -474,8 +516,8 @@ export class AskDialogComponent implements Component {
474
516
  const tabBarRows = this.#hasSubmitTab() ? 1 : 0;
475
517
  const mdTheme = getMarkdownTheme();
476
518
  let needed = MIN_DIALOG_ROWS;
477
- for (let index = 0; index < this.questions.length; index++) {
478
- const question = this.questions[index];
519
+ for (let index = 0; index < this.#questions.length; index++) {
520
+ const question = this.#questions[index];
479
521
  const state = this.#states[index];
480
522
  if (!question || !state) continue;
481
523
  const headerRows = tabBarRows + renderQuestionTitle(question, width).length;
@@ -493,7 +535,7 @@ export class AskDialogComponent implements Component {
493
535
  if (this.#hasSubmitTab()) {
494
536
  // Warning line + blank, one summary line per question, blank, and
495
537
  // the Submit row; note lines added later scroll within the body.
496
- const body = 2 + this.questions.length + 2;
538
+ const body = 2 + this.#questions.length + 2;
497
539
  needed = Math.max(needed, chrome + tabBarRows + 1 + Math.max(MIN_BODY_ROWS, body));
498
540
  }
499
541
  return Math.min(needed, maxHeight);
@@ -507,11 +549,11 @@ export class AskDialogComponent implements Component {
507
549
  // Multi questions confirm on the Submit tab (Enter toggles, never
508
550
  // submits), so any multi question forces the tab even when there is
509
551
  // only one question.
510
- return this.questions.length > 1 || this.questions.some(question => question.multi);
552
+ return this.#questions.length > 1 || this.#questions.some(question => question.multi);
511
553
  }
512
554
 
513
555
  #submitTabIndex(): number {
514
- return this.questions.length;
556
+ return this.#questions.length;
515
557
  }
516
558
 
517
559
  #isSubmitTab(): boolean {
@@ -519,7 +561,7 @@ export class AskDialogComponent implements Component {
519
561
  }
520
562
 
521
563
  #currentQuestionIndex(): number {
522
- return clamp(this.#activeTabIndex, 0, Math.max(0, this.questions.length - 1));
564
+ return clamp(this.#activeTabIndex, 0, Math.max(0, this.#questions.length - 1));
523
565
  }
524
566
 
525
567
  #requestRender(): void {
@@ -530,7 +572,7 @@ export class AskDialogComponent implements Component {
530
572
  const lines: string[] = [];
531
573
  if (this.#hasSubmitTab()) {
532
574
  const tabs: Tab[] = [
533
- ...this.questions.map((question, index) => ({
575
+ ...this.#questions.map((question, index) => ({
534
576
  id: String(index),
535
577
  label: questionTabLabel(question, index),
536
578
  })),
@@ -545,7 +587,7 @@ export class AskDialogComponent implements Component {
545
587
  return lines;
546
588
  }
547
589
  const questionIndex = this.#currentQuestionIndex();
548
- const question = this.questions[questionIndex];
590
+ const question = this.#questions[questionIndex];
549
591
  if (!question) return lines;
550
592
  lines.push(...renderQuestionTitle(question, width));
551
593
  return lines;
@@ -559,7 +601,7 @@ export class AskDialogComponent implements Component {
559
601
  const scroll = indicator ? ` ${indicator} scroll ·` : "";
560
602
  return `Enter submit · ↑/↓ scroll ·${scroll} ${cancel}`;
561
603
  }
562
- const question = this.questions[this.#currentQuestionIndex()];
604
+ const question = this.#questions[this.#currentQuestionIndex()];
563
605
  const action = question?.multi ? "Space/Enter toggle · n note" : "Enter select · n note";
564
606
  const tabs = this.#hasSubmitTab() ? " · Tab/←/→" : "";
565
607
  if (this.#questionCanPage && indicator) {
@@ -585,7 +627,7 @@ export class AskDialogComponent implements Component {
585
627
  }
586
628
 
587
629
  #activeQuestionState(): { question: ExtensionAskDialogQuestion; state: QuestionState } | undefined {
588
- const question = this.questions[this.#currentQuestionIndex()];
630
+ const question = this.#questions[this.#currentQuestionIndex()];
589
631
  const state = this.#states[this.#currentQuestionIndex()];
590
632
  if (!question || !state) return undefined;
591
633
  return { question, state };
@@ -672,18 +714,18 @@ export class AskDialogComponent implements Component {
672
714
  }
673
715
 
674
716
  #switchTab(direction: 1 | -1): void {
675
- const tabCount = this.questions.length + 1;
717
+ const tabCount = this.#questions.length + 1;
676
718
  this.#activeTabIndex = (this.#activeTabIndex + direction + tabCount) % tabCount;
677
719
  this.#submitScrollOffset = 0;
678
720
  }
679
721
 
680
722
  #advanceAfterQuestion(): void {
681
723
  const current = this.#currentQuestionIndex();
682
- if (this.questions.length === 1) {
724
+ if (this.#questions.length === 1) {
683
725
  this.#finishSubmit();
684
726
  return;
685
727
  }
686
- this.#activeTabIndex = current + 1 < this.questions.length ? current + 1 : this.#submitTabIndex();
728
+ this.#activeTabIndex = current + 1 < this.#questions.length ? current + 1 : this.#submitTabIndex();
687
729
  this.#submitScrollOffset = 0;
688
730
  this.#requestRender();
689
731
  }
@@ -829,8 +871,8 @@ export class AskDialogComponent implements Component {
829
871
  );
830
872
  allLines.push("");
831
873
  }
832
- for (let index = 0; index < this.questions.length; index++) {
833
- const question = this.questions[index];
874
+ for (let index = 0; index < this.#questions.length; index++) {
875
+ const question = this.#questions[index];
834
876
  const state = this.#states[index];
835
877
  if (!question || !state) continue;
836
878
  const label = questionTabLabel(question, index);
@@ -895,8 +937,8 @@ export class AskDialogComponent implements Component {
895
937
 
896
938
  #unansweredCount(): number {
897
939
  let count = 0;
898
- for (let index = 0; index < this.questions.length; index++) {
899
- const question = this.questions[index];
940
+ for (let index = 0; index < this.#questions.length; index++) {
941
+ const question = this.#questions[index];
900
942
  const state = this.#states[index];
901
943
  if (!question || !state) continue;
902
944
  if (state.selectedOptions.size === 0 && state.customInput === undefined) count += 1;
@@ -911,8 +953,8 @@ export class AskDialogComponent implements Component {
911
953
  return;
912
954
  }
913
955
  this.options.onTimeout?.();
914
- for (let index = 0; index < this.questions.length; index++) {
915
- const question = this.questions[index];
956
+ for (let index = 0; index < this.#questions.length; index++) {
957
+ const question = this.#questions[index];
916
958
  const state = this.#states[index];
917
959
  if (!question || !state) continue;
918
960
  if (state.selectedOptions.size === 0 && state.customInput === undefined) {
@@ -952,8 +994,8 @@ export class AskDialogComponent implements Component {
952
994
 
953
995
  #buildResults(): ExtensionAskDialogResultItem[] {
954
996
  const results: ExtensionAskDialogResultItem[] = [];
955
- for (let index = 0; index < this.questions.length; index++) {
956
- const question = this.questions[index];
997
+ for (let index = 0; index < this.#questions.length; index++) {
998
+ const question = this.#questions[index];
957
999
  const state = this.#states[index];
958
1000
  if (!question || !state) continue;
959
1001
  const selectedOptions = question.options
@@ -7018,7 +7018,7 @@ export class AgentSession {
7018
7018
  // shared provider state map is still required so Codex can allocate
7019
7019
  // websocket state under that side-channel session id.
7020
7020
  sessionId: `${cacheSessionId}:side:${Snowflake.next()}`,
7021
- promptCacheKey: cacheSessionId,
7021
+ promptCacheKey: this.agent.promptCacheKey ?? this.agent.sessionId,
7022
7022
  preferWebsockets: this.#preferWebsockets,
7023
7023
  providerSessionState: this.#providerSessionState,
7024
7024
  reasoning: toReasoningEffort(this.thinkingLevel),
@@ -1539,7 +1539,7 @@ export class SessionMaintenance {
1539
1539
  thinkingLevel: this.#host.thinkingLevel(),
1540
1540
  tools: this.#host.agent.state.tools,
1541
1541
  sessionId: this.#host.sessionId(),
1542
- promptCacheKey: this.#host.sessionId(),
1542
+ promptCacheKey: this.#host.agent.promptCacheKey ?? this.#host.agent.sessionId,
1543
1543
  providerSessionState: this.#host.providerSessionState,
1544
1544
  // Route every summarization HTTP request through the
1545
1545
  // session's side-stream transport so the provider
@@ -2585,7 +2585,7 @@ export class SessionMaintenance {
2585
2585
  thinkingLevel: this.#host.thinkingLevel(),
2586
2586
  tools: this.#host.agent.state.tools,
2587
2587
  sessionId: this.#host.sessionId(),
2588
- promptCacheKey: this.#host.sessionId(),
2588
+ promptCacheKey: this.#host.agent.promptCacheKey ?? this.#host.agent.sessionId,
2589
2589
  providerSessionState: this.#host.providerSessionState,
2590
2590
  codexCompaction,
2591
2591
  },
package/src/tools/bash.ts CHANGED
@@ -843,13 +843,18 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
843
843
  return { kind: "steer" };
844
844
  }
845
845
 
846
+ // Cancellable threshold: a bare Bun.sleep(thresholdMs) leaves a live, ref'd
847
+ // timer for the full threshold after the command finishes (or abort/steer)
848
+ // wins the race first — delaying SDK/headless shutdown and accumulating
849
+ // timers under fast command rates. Settle a withResolvers promise from
850
+ // setTimeout so the finally can clear it regardless of which waiter wins.
851
+ const { promise: thresholdPromise, resolve: resolveThreshold } = Promise.withResolvers<{
852
+ kind: "running";
853
+ }>();
854
+ const thresholdTimer = setTimeout(() => resolveThreshold({ kind: "running" }), thresholdMs);
846
855
  const waiters: Array<
847
856
  Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "steer" } | { kind: "aborted" }>
848
- > = [job.completion, Bun.sleep(thresholdMs).then(() => ({ kind: "running" as const }))];
849
-
850
- if (!signal && !steeringSignal) {
851
- return await Promise.race(waiters);
852
- }
857
+ > = [job.completion, thresholdPromise];
853
858
 
854
859
  const { promise: abortedPromise, resolve: resolveAborted } = Promise.withResolvers<{ kind: "aborted" }>();
855
860
  const onAbort = () => resolveAborted({ kind: "aborted" });
@@ -866,6 +871,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
866
871
  try {
867
872
  return await Promise.race(waiters);
868
873
  } finally {
874
+ clearTimeout(thresholdTimer);
869
875
  signal?.removeEventListener("abort", onAbort);
870
876
  steeringSignal?.removeEventListener("abort", onSteer);
871
877
  }
@@ -296,19 +296,17 @@ export interface LaunchHeadlessResult {
296
296
  userDataDir?: string;
297
297
  }
298
298
 
299
- export async function launchHeadlessBrowser(opts: LaunchHeadlessOptions): Promise<LaunchHeadlessResult> {
300
- const vp = opts.viewport ?? DEFAULT_VIEWPORT;
301
- const initialViewport = {
302
- width: vp.width,
303
- height: vp.height,
304
- deviceScaleFactor: vp.deviceScaleFactor ?? DEFAULT_VIEWPORT.deviceScaleFactor,
305
- };
306
- const puppeteer = await loadPuppeteer();
299
+ /**
300
+ * Base Chromium argv shared by process-local puppeteer launches and the
301
+ * broker-owned shared browser: sandbox/stealth flags, window size, and
302
+ * PUPPETEER_PROXY* env-derived proxy flags.
303
+ */
304
+ export function buildHeadlessLaunchArgs(viewport: { width: number; height: number }): string[] {
307
305
  const launchArgs = [
308
306
  "--no-sandbox",
309
307
  "--disable-setuid-sandbox",
310
308
  "--disable-blink-features=AutomationControlled",
311
- `--window-size=${initialViewport.width},${initialViewport.height}`,
309
+ `--window-size=${viewport.width},${viewport.height}`,
312
310
  ];
313
311
  const proxy = process.env.PUPPETEER_PROXY;
314
312
  if (proxy) {
@@ -324,6 +322,18 @@ export async function launchHeadlessBrowser(opts: LaunchHeadlessOptions): Promis
324
322
  if (ignoreCert === "true" || ignoreCert === "1" || ignoreCert === "yes" || ignoreCert === "on") {
325
323
  launchArgs.push("--ignore-certificate-errors");
326
324
  }
325
+ return launchArgs;
326
+ }
327
+
328
+ export async function launchHeadlessBrowser(opts: LaunchHeadlessOptions): Promise<LaunchHeadlessResult> {
329
+ const vp = opts.viewport ?? DEFAULT_VIEWPORT;
330
+ const initialViewport = {
331
+ width: vp.width,
332
+ height: vp.height,
333
+ deviceScaleFactor: vp.deviceScaleFactor ?? DEFAULT_VIEWPORT.deviceScaleFactor,
334
+ };
335
+ const puppeteer = await loadPuppeteer();
336
+ const launchArgs = buildHeadlessLaunchArgs(initialViewport);
327
337
  for (const arg of opts.args ?? []) {
328
338
  if (!launchArgs.includes(arg)) launchArgs.push(arg);
329
339
  }
@@ -357,6 +367,41 @@ export async function launchHeadlessBrowser(opts: LaunchHeadlessOptions): Promis
357
367
  }
358
368
  }
359
369
 
370
+ /** Fully resolved executable and argv for a broker-spawned shared Chromium. */
371
+ export interface SharedBrowserLaunchSpec {
372
+ executablePath: string;
373
+ args: string[];
374
+ }
375
+
376
+ /**
377
+ * Resolve the executable and complete argv for a shared Chromium the daemon
378
+ * broker spawns directly (no puppeteer inside the broker). Mirrors
379
+ * `launchHeadlessBrowser` flag assembly — puppeteer's default args minus the
380
+ * stealth-suppressed set — plus `--remote-debugging-port=0` so every client
381
+ * attaches over CDP. Returns null when no Chromium executable resolves;
382
+ * callers fall back to a process-local launch.
383
+ */
384
+ export async function resolveSharedBrowserLaunchSpec(opts: {
385
+ headless: boolean;
386
+ userDataDir: string;
387
+ viewport?: { width: number; height: number };
388
+ }): Promise<SharedBrowserLaunchSpec | null> {
389
+ const executablePath = await ensureChromiumExecutable();
390
+ if (!executablePath) return null;
391
+ const puppeteer = await loadPuppeteer();
392
+ const vp = opts.viewport ?? DEFAULT_VIEWPORT;
393
+ const ignored = new Set(stealthIgnoreDefaultArgs(executablePath));
394
+ const defaults = await puppeteer.defaultArgs({
395
+ headless: opts.headless,
396
+ args: buildHeadlessLaunchArgs(vp),
397
+ userDataDir: opts.userDataDir,
398
+ });
399
+ return {
400
+ executablePath,
401
+ args: [...defaults.filter(arg => !ignored.has(arg)), "--remote-debugging-port=0"],
402
+ };
403
+ }
404
+
360
405
  /**
361
406
  * Remove an OMP-owned headless Chromium profile directory, tolerating the brief
362
407
  * window on Windows in which Chromium (or an orphaned browser subprocess) still