jeopi-cli 16.4.2 → 16.4.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.
Files changed (148) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/cli.js +3033 -2991
  3. package/dist/types/advisor/advise-tool.d.ts +5 -1
  4. package/dist/types/advisor/config.d.ts +4 -3
  5. package/dist/types/advisor/runtime.d.ts +15 -0
  6. package/dist/types/cli/args.d.ts +2 -0
  7. package/dist/types/cli/update-cli.d.ts +4 -1
  8. package/dist/types/cli/usage-error.d.ts +4 -0
  9. package/dist/types/commit/agentic/agent.d.ts +1 -0
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/model-resolver.d.ts +7 -1
  12. package/dist/types/config/models-config.d.ts +1 -1
  13. package/dist/types/config/settings-schema.d.ts +3 -13
  14. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  15. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  16. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  17. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  18. package/dist/types/internal-urls/memory-protocol.d.ts +6 -6
  19. package/dist/types/internal-urls/types.d.ts +7 -0
  20. package/dist/types/irc/bus.d.ts +4 -0
  21. package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
  22. package/dist/types/modes/components/move-overlay.d.ts +1 -1
  23. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  24. package/dist/types/modes/components/welcome.d.ts +4 -0
  25. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  26. package/dist/types/modes/orchestrate.d.ts +1 -1
  27. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  28. package/dist/types/modes/rpc/rpc-mode.d.ts +27 -7
  29. package/dist/types/modes/ultrathink.d.ts +1 -1
  30. package/dist/types/modes/utils/transcript-render-helpers.d.ts +9 -0
  31. package/dist/types/modes/workflow.d.ts +1 -1
  32. package/dist/types/session/agent-session.d.ts +6 -0
  33. package/dist/types/session/auth-broker-config.d.ts +2 -2
  34. package/dist/types/tools/bash-skill-urls.d.ts +3 -2
  35. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
  36. package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
  37. package/dist/types/tools/browser/launch.d.ts +1 -0
  38. package/dist/types/tools/browser/run-cancellation.d.ts +17 -2
  39. package/dist/types/tools/browser/run-output.d.ts +25 -0
  40. package/dist/types/tools/browser/tab-worker.d.ts +22 -0
  41. package/dist/types/utils/changelog.d.ts +35 -1
  42. package/package.json +12 -12
  43. package/src/advisor/__tests__/advisor.test.ts +13 -0
  44. package/src/advisor/__tests__/config.test.ts +36 -0
  45. package/src/advisor/advise-tool.ts +6 -1
  46. package/src/advisor/config.ts +11 -8
  47. package/src/advisor/runtime.ts +127 -0
  48. package/src/autolearn/controller.ts +15 -3
  49. package/src/cli/args.ts +12 -0
  50. package/src/cli/flag-tables.ts +21 -7
  51. package/src/cli/update-cli.ts +63 -13
  52. package/src/cli/usage-error.ts +7 -0
  53. package/src/commands/acp.ts +11 -2
  54. package/src/commands/launch.ts +12 -3
  55. package/src/commit/agentic/agent.ts +4 -0
  56. package/src/commit/agentic/index.ts +46 -21
  57. package/src/config/config-file.ts +30 -12
  58. package/src/config/model-registry.ts +7 -7
  59. package/src/config/model-resolver.ts +24 -6
  60. package/src/config/models-config.ts +1 -1
  61. package/src/config/settings-schema.ts +3 -15
  62. package/src/edit/index.ts +10 -76
  63. package/src/edit/renderer.ts +13 -10
  64. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  65. package/src/eval/__tests__/bridge-timeout.test.ts +60 -0
  66. package/src/eval/executor-base.ts +84 -7
  67. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  68. package/src/eval/py/tool-bridge.ts +13 -14
  69. package/src/exec/non-interactive-env.ts +0 -1
  70. package/src/export/ttsr.ts +0 -3
  71. package/src/extensibility/custom-tools/loader.ts +70 -37
  72. package/src/extensibility/custom-tools/types.ts +3 -0
  73. package/src/extensibility/extensions/runner.ts +3 -0
  74. package/src/extensibility/extensions/types.ts +3 -0
  75. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +9 -1
  76. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +30 -3
  77. package/src/extensibility/plugins/manager.ts +21 -0
  78. package/src/internal-urls/docs-index.generated.txt +1 -1
  79. package/src/internal-urls/memory-protocol.ts +13 -8
  80. package/src/internal-urls/skill-protocol.ts +1 -1
  81. package/src/internal-urls/types.ts +7 -0
  82. package/src/irc/bus.ts +48 -18
  83. package/src/lsp/client.ts +7 -1
  84. package/src/lsp/deferred-diagnostics.ts +66 -0
  85. package/src/main.ts +13 -13
  86. package/src/mcp/tool-bridge.ts +57 -6
  87. package/src/mcp/transports/stdio.test.ts +45 -1
  88. package/src/mcp/transports/stdio.ts +6 -3
  89. package/src/modes/acp/acp-agent.ts +73 -2
  90. package/src/modes/acp/acp-event-mapper.ts +5 -0
  91. package/src/modes/acp/acp-mode.ts +11 -0
  92. package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
  93. package/src/modes/components/advisor-config.ts +15 -7
  94. package/src/modes/components/chat-transcript-builder.ts +31 -11
  95. package/src/modes/components/move-overlay.ts +2 -3
  96. package/src/modes/components/plan-review-overlay.ts +7 -0
  97. package/src/modes/components/tips.txt +1 -1
  98. package/src/modes/components/welcome.ts +13 -14
  99. package/src/modes/controllers/command-controller.ts +8 -10
  100. package/src/modes/controllers/event-controller.ts +58 -42
  101. package/src/modes/controllers/input-controller.ts +9 -6
  102. package/src/modes/controllers/selector-controller.ts +3 -2
  103. package/src/modes/controllers/tan-command-controller.ts +40 -1
  104. package/src/modes/interactive-mode.ts +13 -0
  105. package/src/modes/magic-keyword-boundary.ts +23 -0
  106. package/src/modes/orchestrate.ts +6 -5
  107. package/src/modes/print-mode.ts +9 -0
  108. package/src/modes/rpc/host-tools.ts +13 -0
  109. package/src/modes/rpc/rpc-mode.ts +130 -56
  110. package/src/modes/ultrathink.ts +6 -5
  111. package/src/modes/utils/transcript-render-helpers.ts +44 -0
  112. package/src/modes/utils/ui-helpers.ts +16 -1
  113. package/src/modes/workflow.ts +5 -4
  114. package/src/prompts/system/eager-task.md +2 -2
  115. package/src/prompts/system/tan-context-switch.md +17 -0
  116. package/src/prompts/tools/browser.md +5 -5
  117. package/src/sdk.ts +3 -0
  118. package/src/session/agent-session.ts +267 -36
  119. package/src/session/auth-broker-config.ts +4 -4
  120. package/src/session/snapcompact-inline.ts +3 -19
  121. package/src/session/streaming-output.ts +40 -12
  122. package/src/slash-commands/builtin-registry.ts +8 -8
  123. package/src/task/executor.ts +8 -0
  124. package/src/task/render.ts +51 -44
  125. package/src/tools/bash-skill-urls.ts +7 -4
  126. package/src/tools/bash.ts +30 -33
  127. package/src/tools/browser/cmux/cmux-tab.ts +49 -50
  128. package/src/tools/browser/cmux/rpc.ts +50 -0
  129. package/src/tools/browser/launch.ts +8 -4
  130. package/src/tools/browser/run-cancellation.ts +44 -5
  131. package/src/tools/browser/run-output.ts +76 -0
  132. package/src/tools/browser/tab-worker.ts +280 -133
  133. package/src/tools/eval-render.ts +16 -14
  134. package/src/tools/glob.ts +20 -6
  135. package/src/tools/grep.ts +1 -0
  136. package/src/tools/image-gen.ts +3 -4
  137. package/src/tools/irc.ts +27 -13
  138. package/src/tools/path-utils.ts +6 -1
  139. package/src/tools/read.ts +14 -7
  140. package/src/tools/write.ts +22 -5
  141. package/src/tools/yield.ts +29 -1
  142. package/src/utils/changelog.ts +106 -9
  143. package/src/utils/git.ts +0 -1
  144. package/src/utils/title-generator.ts +72 -7
  145. package/src/web/search/index.ts +14 -6
  146. package/src/web/search/providers/perplexity.ts +18 -2
  147. package/dist/types/tools/bash-command-fixup.d.ts +0 -11
  148. package/src/tools/bash-command-fixup.ts +0 -37
package/src/tools/bash.ts CHANGED
@@ -23,7 +23,6 @@ import { CachedOutputBlock, markFramedBlockComponent, outputBlockContentWidth }
23
23
  import { getSixelLineMask } from "../utils/sixel";
24
24
  import type { ToolSession } from ".";
25
25
  import { truncateForPrompt } from "./approval";
26
- import { applyBashFixups } from "./bash-command-fixup";
27
26
  import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive";
28
27
  import { checkBashInterception } from "./bash-interceptor";
29
28
  import { canUseInteractiveBashPty } from "./bash-pty-selection";
@@ -163,10 +162,9 @@ type ManagedBashJobCompletion =
163
162
 
164
163
  interface ManagedBashJobHandle {
165
164
  jobId: string;
166
- label: string;
167
165
  completion: Promise<ManagedBashJobCompletion>;
168
166
  getLatestText: () => string;
169
- setBackgrounded: (backgrounded: boolean) => void;
167
+ stopUpdates: () => void;
170
168
  }
171
169
 
172
170
  function normalizeResultOutput(result: BashResult | BashInteractiveResult): string {
@@ -297,6 +295,10 @@ function formatExitCodeNotice(exitCode: number): string {
297
295
  return `Command exited with code ${exitCode}`;
298
296
  }
299
297
 
298
+ function formatBackgroundNotice(jobId: string): string {
299
+ return `Backgrounded as job ${jobId}; result will be delivered automatically.`;
300
+ }
301
+
300
302
  /**
301
303
  * Strip the trailing occurrence of `notice` (plus a single surrounding newline
302
304
  * on each side) so the TUI can echo the value via a styled footer label
@@ -325,6 +327,11 @@ function stripExitCodeNotice(text: string, exitCode: number | undefined): string
325
327
  return stripTrailingNotice(text, formatExitCodeNotice(exitCode));
326
328
  }
327
329
 
330
+ function stripBackgroundNotice(text: string, async: BashToolDetails["async"] | undefined): string {
331
+ if (async?.state !== "running") return text;
332
+ return stripTrailingNotice(text, formatBackgroundNotice(async.jobId));
333
+ }
334
+
328
335
  /**
329
336
  * Bash tool implementation.
330
337
  *
@@ -473,7 +480,6 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
473
480
 
474
481
  #buildBackgroundStartResult(
475
482
  jobId: string,
476
- label: string,
477
483
  previewText: string,
478
484
  timeoutSec: number,
479
485
  options: { requestedTimeoutSec?: number; notices?: readonly string[] } = {},
@@ -493,11 +499,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
493
499
  if (options.notices?.length) {
494
500
  lines.push(...options.notices, "");
495
501
  }
496
- lines.push(`Background job ${jobId} started: ${label}`);
497
- lines.push("Result will be delivered automatically when complete.");
498
- lines.push(
499
- `You can use \`job\` to poll until complete, but prefer to continue with another task in the meanwhile if it's not blocking.`,
500
- );
502
+ lines.push(formatBackgroundNotice(jobId));
501
503
  return {
502
504
  content: [{ type: "text", text: lines.join("\n") }],
503
505
  details,
@@ -518,7 +520,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
518
520
 
519
521
  resolvedEnv?: Record<string, string>;
520
522
  onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
521
- startBackgrounded: boolean;
523
+ forwardUpdates: boolean;
522
524
  }): ManagedBashJobHandle {
523
525
  const manager = this.session.asyncJobManager;
524
526
  if (!manager) {
@@ -527,7 +529,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
527
529
 
528
530
  const label = options.command.length > 120 ? `${options.command.slice(0, 117)}...` : options.command;
529
531
  let latestText = "";
530
- let backgrounded = options.startBackgrounded;
532
+ let forwardUpdates = options.forwardUpdates;
531
533
  const completion = Promise.withResolvers<ManagedBashJobCompletion>();
532
534
 
533
535
  const jobId = manager.register(
@@ -583,11 +585,12 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
583
585
  },
584
586
  {
585
587
  ownerId: this.session.getAgentId?.() ?? undefined,
586
- onProgress: async (text, details) => {
588
+ onProgress: async text => {
587
589
  latestText = text;
590
+ if (!forwardUpdates) return;
588
591
  await options.onUpdate?.({
589
592
  content: [{ type: "text", text }],
590
- details: backgrounded ? ((details ?? {}) as BashToolDetails) : {},
593
+ details: {},
591
594
  });
592
595
  },
593
596
  },
@@ -595,11 +598,10 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
595
598
 
596
599
  return {
597
600
  jobId,
598
- label,
599
601
  completion: completion.promise,
600
602
  getLatestText: () => latestText,
601
- setBackgrounded: (nextBackgrounded: boolean) => {
602
- backgrounded = nextBackgrounded;
603
+ stopUpdates: () => {
604
+ forwardUpdates = false;
603
605
  },
604
606
  };
605
607
  }
@@ -657,16 +659,6 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
657
659
  let command = rawCommand;
658
660
  const env = normalizeBashEnv(rawEnv);
659
661
 
660
- // Apply conservative bash fixups (strip trailing `| head|tail` and redundant
661
- // `2>&1`). The helper is single-line only and refuses anything that could
662
- // change semantics.
663
- if (this.session.settings.get("bash.stripTrailingHeadTail")) {
664
- const fixup = applyBashFixups(command);
665
- if (fixup.stripped.length > 0) {
666
- command = fixup.command;
667
- }
668
- }
669
-
670
662
  // Extract leading `cd <path> && ...` into cwd when the model ignores the cwd parameter.
671
663
  // Constrained to a single line so a `&&` that sits on a later line of a multiline
672
664
  // script can't pull the entire script into the "cwd" capture.
@@ -701,6 +693,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
701
693
  const internalUrlOptions: InternalUrlExpansionOptions = {
702
694
  skills: this.session.skills ?? [],
703
695
  internalRouter: InternalUrlRouter.instance(),
696
+ cwd: this.session.cwd,
704
697
  localOptions: {
705
698
  getArtifactsDir: this.session.getArtifactsDir,
706
699
  getSessionId: this.session.getSessionId,
@@ -769,9 +762,9 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
769
762
 
770
763
  resolvedEnv,
771
764
  onUpdate,
772
- startBackgrounded: true,
765
+ forwardUpdates: false,
773
766
  });
774
- return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
767
+ return this.#buildBackgroundStartResult(job.jobId, "", timeoutSec, {
775
768
  requestedTimeoutSec,
776
769
  notices: pendingNotices,
777
770
  });
@@ -807,10 +800,10 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
807
800
 
808
801
  resolvedEnv,
809
802
  onUpdate,
810
- startBackgrounded,
803
+ forwardUpdates: !startBackgrounded,
811
804
  });
812
805
  if (startBackgrounded) {
813
- return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
806
+ return this.#buildBackgroundStartResult(job.jobId, "", timeoutSec, {
814
807
  requestedTimeoutSec,
815
808
  notices: pendingNotices,
816
809
  });
@@ -830,9 +823,9 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
830
823
  autoBgManager.cancel(job.jobId);
831
824
  throw new ToolAbortError(job.getLatestText() || "Command aborted");
832
825
  }
833
- job.setBackgrounded(true);
826
+ job.stopUpdates();
834
827
  autoBgManager.resumeDeliveries([job.jobId]);
835
- return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec, {
828
+ return this.#buildBackgroundStartResult(job.jobId, job.getLatestText(), timeoutSec, {
836
829
  requestedTimeoutSec,
837
830
  notices: pendingNotices,
838
831
  });
@@ -1249,7 +1242,8 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1249
1242
  ) {
1250
1243
  return cachedLines;
1251
1244
  }
1252
- const strippedOutput = stripOutputNotice(rawOutput, details?.meta);
1245
+ const withoutBackground = stripBackgroundNotice(rawOutput, details?.async);
1246
+ const strippedOutput = stripOutputNotice(withoutBackground, details?.meta);
1253
1247
  const withoutExit = stripExitCodeNotice(strippedOutput, details?.exitCode);
1254
1248
  const withoutWall = stripWallTimeNotice(withoutExit, details?.wallTimeMs);
1255
1249
  const rawOutputArtifact = stripRawOutputArtifactNotice(withoutWall);
@@ -1262,6 +1256,9 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1262
1256
  const requestedTimeoutSeconds = details?.requestedTimeoutSeconds;
1263
1257
  const wallTimeMs = details?.wallTimeMs;
1264
1258
  const statsParts: string[] = [];
1259
+ if (details?.async?.state === "running") {
1260
+ statsParts.push(`Backgrounded: ${details.async.jobId}`);
1261
+ }
1265
1262
  if (wallTimeMs !== undefined) {
1266
1263
  statsParts.push(`Wall: ${formatWallTimeSeconds(wallTimeMs)}s`);
1267
1264
  }
@@ -3,7 +3,6 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { logger, Snowflake, untilAborted } from "jeopi-utils";
5
5
  import { JsRuntime, type RuntimeHooks } from "../../../eval/js/shared/runtime";
6
- import type { JsDisplayOutput } from "../../../eval/js/shared/types";
7
6
  import { callSessionTool } from "../../../eval/js/tool-bridge";
8
7
  import { resizeImage } from "../../../utils/image-resize";
9
8
  import type { ToolSession } from "../../index";
@@ -13,7 +12,8 @@ import { ToolAbortError, ToolError, throwIfAborted } from "../../tool-errors";
13
12
  import { type AriaSnapshotOptions, buildAriaSnapshotScript } from "../aria/aria-snapshot";
14
13
  import { DEFAULT_VIEWPORT } from "../launch";
15
14
  import { extractReadableFromHtml, type ReadableFormat } from "../readable";
16
- import { bindBrowserRunFacade, waitForBrowserRun } from "../run-cancellation";
15
+ import { bindBrowserRunFacade, type WaitPredicateOptions, waitForBrowserRun } from "../run-cancellation";
16
+ import { cloneSafe, RunOutput } from "../run-output";
17
17
  import type { Observation, ReadyInfo, RunResultOk, ScreenshotResult, SessionSnapshot } from "../tab-protocol";
18
18
  import {
19
19
  type CmuxEvalResult,
@@ -24,7 +24,8 @@ import {
24
24
  cmuxSnapshotToObservation,
25
25
  GEOMETRY_SCRIPT,
26
26
  mapWaitUntil,
27
- serializeEval,
27
+ serializeEvalWithEnvelope,
28
+ unwrapEvalEnvelope,
28
29
  } from "./rpc";
29
30
  import type { CmuxSocketClient } from "./socket-client";
30
31
 
@@ -43,7 +44,7 @@ interface ObserveOptions {
43
44
 
44
45
  interface RunContext {
45
46
  session: SessionSnapshot;
46
- displays: RunResultOk["displays"];
47
+ output: RunOutput;
47
48
  screenshots: ScreenshotResult[];
48
49
  signal: AbortSignal;
49
50
  timeoutMs: number;
@@ -445,10 +446,14 @@ export class CmuxTab {
445
446
  fn: string | ((...args: TArgs) => TResult | Promise<TResult>),
446
447
  ...args: TArgs
447
448
  ): Promise<TResult> {
448
- const result = (await this.#request("browser.eval", {
449
- script: serializeEval(fn as string | ((...args: unknown[]) => unknown), args),
450
- })) as CmuxEvalResult;
451
- return result.value as TResult;
449
+ // A script that throws inside the daemon comes back as a bare
450
+ // `js_error: A JavaScript exception occurred` with no message or stack.
451
+ // Catch page-side instead so the exception is diagnosable, and turn the
452
+ // daemon's other blind spot — Promise return values it cannot
453
+ // serialize — into an actionable error instead of "unsupported type".
454
+ const script = serializeEvalWithEnvelope(fn as string | ((...args: unknown[]) => unknown), args);
455
+ const result = (await this.#request("browser.eval", { script })) as CmuxEvalResult;
456
+ return unwrapEvalEnvelope<TResult>(result.value, "tab.evaluate()");
452
457
  }
453
458
 
454
459
  async scrollIntoView(selector: string): Promise<void> {
@@ -479,10 +484,22 @@ export class CmuxTab {
479
484
 
480
485
  async screenshot(opts: ScreenshotOptions = {}): Promise<ScreenshotResult> {
481
486
  const context = this.#requireRunContext("tab.screenshot()");
487
+ // The cmux daemon's `browser.screenshot` captures the surface viewport
488
+ // only — it has no element-clip or full-page mode, and Bun.Image cannot
489
+ // crop locally. Degrade transparently instead of silently mislabeling
490
+ // the capture: scroll the element into view, then TELL the model the
491
+ // image is the full viewport (reports showed selector captures being
492
+ // consumed as element crops).
493
+ const captureNotes: string[] = [];
482
494
  if (opts.selector) {
483
495
  await this.scrollIntoView(opts.selector);
496
+ captureNotes.push(
497
+ `selector ${JSON.stringify(opts.selector)} was scrolled into view, but this surface cannot clip to an element — the image is the full viewport`,
498
+ );
499
+ }
500
+ if (opts.fullPage) {
501
+ captureNotes.push("fullPage is unavailable on this surface — the image is the viewport only");
484
502
  }
485
- void opts.fullPage;
486
503
  const result = await this.#captureScreenshotPng(context.timeoutMs);
487
504
  const buffer = Buffer.from(result.png_base64, "base64");
488
505
  const captureMime = "image/png";
@@ -528,8 +545,11 @@ export class CmuxTab {
528
545
  dest,
529
546
  resized,
530
547
  });
531
- context.displays.push({ type: "text", text: lines.join("\n") });
532
- context.displays.push({ type: "image", data: resized.data, mimeType: resized.mimeType });
548
+ if (captureNotes.length > 0) {
549
+ lines.push(`[cmux surface: ${captureNotes.join("; ")}]`);
550
+ }
551
+ context.output.push({ type: "text", text: lines.join("\n") });
552
+ context.output.push({ type: "image", data: resized.data, mimeType: resized.mimeType });
533
553
  }
534
554
  return info;
535
555
  }
@@ -724,7 +744,12 @@ export class CmuxTab {
724
744
  const callable = (0, eval)("(" + source + ")");
725
745
  return callable(element, ...args);
726
746
  })()`;
727
- return await this.#evalScript<TResult>(script);
747
+ // Envelope so a stale selector or a throwing callback reports its actual
748
+ // error instead of the daemon's generic js_error (see tab.evaluate()).
749
+ const result = (await this.#request("browser.eval", {
750
+ script: serializeEvalWithEnvelope(script, []),
751
+ })) as CmuxEvalResult;
752
+ return unwrapEvalEnvelope<TResult>(result.value, "elementHandle.evaluate()");
728
753
  }
729
754
 
730
755
  async pageContent(): Promise<string> {
@@ -1097,6 +1122,10 @@ class CmuxElementHandle {
1097
1122
  await this.#tab.fill(this.#selector, value);
1098
1123
  }
1099
1124
 
1125
+ async press(key: string): Promise<void> {
1126
+ await this.#tab.press(key, { selector: this.#selector });
1127
+ }
1128
+
1100
1129
  async focus(): Promise<void> {
1101
1130
  await this.#tab.focus(this.#selector);
1102
1131
  }
@@ -1284,10 +1313,10 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
1284
1313
  const signal = AbortSignal.any(
1285
1314
  opts.signal ? [timeoutSignal, opts.signal, runAc.signal] : [timeoutSignal, runAc.signal],
1286
1315
  );
1287
- const displays: RunResultOk["displays"] = [];
1316
+ const output = new RunOutput();
1288
1317
  const screenshots: ScreenshotResult[] = [];
1289
1318
  const runId = crypto.randomUUID();
1290
- tab.setRunContext({ session: opts.snapshot, displays, screenshots, signal, timeoutMs: opts.timeoutMs });
1319
+ tab.setRunContext({ session: opts.snapshot, output, screenshots, signal, timeoutMs: opts.timeoutMs });
1291
1320
  const runtime = tab.ensureRuntime(opts.snapshot);
1292
1321
  runtime.setCwd(opts.snapshot.cwd);
1293
1322
  const runTab = bindBrowserRunFacade(tab, signal);
@@ -1298,7 +1327,8 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
1298
1327
  assert: (cond: unknown, text?: string): void => {
1299
1328
  if (!cond) throw new ToolError(text ?? "Assertion failed");
1300
1329
  },
1301
- wait: (ms: number): Promise<void> => waitForBrowserRun(ms, signal),
1330
+ wait: (msOrPredicate: number | (() => unknown), opts?: WaitPredicateOptions): Promise<unknown> =>
1331
+ waitForBrowserRun(msOrPredicate, signal, opts),
1302
1332
  });
1303
1333
 
1304
1334
  const { promise: cancelRejection, reject } = Promise.withResolvers<never>();
@@ -1316,11 +1346,12 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
1316
1346
  const hooks: RuntimeHooks = {
1317
1347
  onText: chunk => {
1318
1348
  throwIfAborted(signal);
1349
+ output.pushText(chunk);
1319
1350
  logger.debug(chunk.replace(/\n$/, ""));
1320
1351
  },
1321
- onDisplay: output => {
1352
+ onDisplay: displayed => {
1322
1353
  throwIfAborted(signal);
1323
- pushDisplay(displays, output);
1354
+ output.pushDisplay(displayed);
1324
1355
  },
1325
1356
  callTool: (name, args) => {
1326
1357
  throwIfAborted(signal);
@@ -1333,7 +1364,7 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
1333
1364
  runtime.run(opts.code, `cmux-run-${runId}.js`, hooks, { runId, cwd: opts.snapshot.cwd }),
1334
1365
  cancelRejection,
1335
1366
  ]);
1336
- return { displays, returnValue: cloneSafe(returnValue), screenshots };
1367
+ return { displays: output.finish(), returnValue: cloneSafe(returnValue), screenshots };
1337
1368
  } finally {
1338
1369
  signal.removeEventListener("abort", onAbort);
1339
1370
  runAc.abort(new ToolAbortError("Browser run ended"));
@@ -1341,38 +1372,6 @@ export async function runCmuxCode(tab: CmuxTab, opts: RunCmuxCodeOptions): Promi
1341
1372
  }
1342
1373
  }
1343
1374
 
1344
- function pushDisplay(displays: RunResultOk["displays"], output: JsDisplayOutput): void {
1345
- if (output.type === "image") {
1346
- displays.push({ type: "image", data: output.data, mimeType: output.mimeType });
1347
- return;
1348
- }
1349
- if (output.type === "json") {
1350
- displays.push({ type: "text", text: safeJsonStringify(output.data) });
1351
- return;
1352
- }
1353
- displays.push({ type: "text", text: safeJsonStringify(output.event) });
1354
- }
1355
-
1356
- function safeJsonStringify(value: unknown): string {
1357
- try {
1358
- return JSON.stringify(value, null, 2);
1359
- } catch {
1360
- return String(value);
1361
- }
1362
- }
1363
-
1364
- function cloneSafe(value: unknown): unknown {
1365
- if (value === undefined) return undefined;
1366
- try {
1367
- structuredClone(value);
1368
- return value;
1369
- } catch {}
1370
- try {
1371
- return JSON.parse(JSON.stringify(value)) as unknown;
1372
- } catch {}
1373
- return String(value);
1374
- }
1375
-
1376
1375
  function numberFrom(value: unknown, fallback: number): number {
1377
1376
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
1378
1377
  }
@@ -1,3 +1,4 @@
1
+ import { ToolError } from "../../tool-errors";
1
2
  import type { Observation, ObservationEntry } from "../tab-protocol";
2
3
 
3
4
  export interface CmuxKind {
@@ -120,6 +121,55 @@ export function serializeEval(fn: string | ((...args: unknown[]) => unknown), ar
120
121
  return `(${fn.toString()})(${args.map(arg => JSON.stringify(arg)).join(",")})`;
121
122
  }
122
123
 
124
+ /**
125
+ * Like {@link serializeEval}, but wraps the expression in a page-side
126
+ * try/catch envelope so a throwing script surfaces its message + stack
127
+ * instead of the daemon's opaque `js_error: A JavaScript exception occurred`,
128
+ * and a Promise return (which the daemon cannot serialize) is flagged
129
+ * explicitly rather than failing as "unsupported type".
130
+ *
131
+ * String scripts run through indirect eval to keep global-scope semantics;
132
+ * function sources are already expressions and are invoked directly.
133
+ * `undefined` results come back as `null` (JSON cannot carry `undefined`).
134
+ * Decode with {@link unwrapEvalEnvelope}.
135
+ */
136
+ export function serializeEvalWithEnvelope(fn: string | ((...args: unknown[]) => unknown), args: unknown[]): string {
137
+ const inner = serializeEval(fn, args);
138
+ const expr = typeof fn === "string" ? `(0, eval)(${JSON.stringify(inner)})` : inner;
139
+ return `(() => {
140
+ try {
141
+ const __v = (${expr});
142
+ if (__v && typeof __v.then === "function") return { __jeopiPromise: true };
143
+ return { __jeopiOk: __v === undefined ? null : __v };
144
+ } catch (e) {
145
+ return { __jeopiErr: (e && (e.stack || e.message)) || String(e) };
146
+ }
147
+ })()`;
148
+ }
149
+
150
+ /**
151
+ * Decode a {@link serializeEvalWithEnvelope} result: rethrow page-side
152
+ * exceptions as rich {@link ToolError}s, reject unserializable Promise
153
+ * returns with an actionable message, and pass through values from daemons
154
+ * that did not run the wrapper.
155
+ */
156
+ export function unwrapEvalEnvelope<TResult>(value: unknown, label: string): TResult {
157
+ if (value && typeof value === "object") {
158
+ if ("__jeopiErr" in value && typeof value.__jeopiErr === "string") {
159
+ throw new ToolError(`${label} threw a JavaScript exception:\n${value.__jeopiErr}`);
160
+ }
161
+ if ("__jeopiPromise" in value && value.__jeopiPromise === true) {
162
+ throw new ToolError(
163
+ `${label} returned a Promise, but this surface evaluates synchronously and cannot await it — return a plain value (poll with waitForFunction for async state instead)`,
164
+ );
165
+ }
166
+ if ("__jeopiOk" in value) {
167
+ return value.__jeopiOk as TResult;
168
+ }
169
+ }
170
+ return value as TResult;
171
+ }
172
+
123
173
  export function mapWaitUntil(waitUntil: string | undefined): "interactive" | "complete" {
124
174
  return waitUntil === "domcontentloaded" ? "interactive" : "complete";
125
175
  }
@@ -99,12 +99,16 @@ async function loadBrowsers(): Promise<typeof BrowsersNs> {
99
99
  }
100
100
 
101
101
  /**
102
- * Lazily download Chromium on first browser launch via @puppeteer/browsers.
103
- * Skipped when a system Chromium (NixOS) or PUPPETEER_EXECUTABLE_PATH is set.
104
- * The browser is cached under ~/.jeopi/puppeteer (getPuppeteerDir).
102
+ * Resolve the Chromium executable puppeteer will launch, lazily downloading it
103
+ * on first use via @puppeteer/browsers. Skipped when a system Chromium (NixOS)
104
+ * or PUPPETEER_EXECUTABLE_PATH is set. The browser is cached under
105
+ * ~/.jeopi/puppeteer (getPuppeteerDir). Returns undefined when platform
106
+ * detection fails (puppeteer default resolution takes over). Exported so
107
+ * real-browser tests can probe launchability and skip on hosts missing
108
+ * Chrome's system libraries.
105
109
  */
106
110
  let chromiumExecutablePromise: Promise<string | undefined> | undefined;
107
- async function ensureChromiumExecutable(): Promise<string | undefined> {
111
+ export async function ensureChromiumExecutable(): Promise<string | undefined> {
108
112
  const sysChrome = resolveSystemChromium();
109
113
  if (sysChrome) return sysChrome;
110
114
  const envPath = process.env.PUPPETEER_EXECUTABLE_PATH;
@@ -1,11 +1,50 @@
1
1
  import { untilAborted } from "jeopi-utils";
2
- import { throwIfAborted } from "../tool-errors";
2
+ import { ToolError, throwIfAborted } from "../tool-errors";
3
3
 
4
- /** Sleeps inside evaluated browser code while honoring the owning run's cancellation signal. */
5
- export async function waitForBrowserRun(ms: number, signal: AbortSignal): Promise<void> {
6
- throwIfAborted(signal);
7
- await untilAborted(signal, () => Bun.sleep(ms));
4
+ /** Options for the predicate form of the run-scoped `wait()` helper. */
5
+ export interface WaitPredicateOptions {
6
+ /** Max time to poll before failing, in ms (default 30_000). */
7
+ timeout?: number;
8
+ /** Poll interval in ms (default 100, floor 10). */
9
+ interval?: number;
10
+ }
11
+
12
+ /**
13
+ * Run-scoped `wait()` helper for evaluated browser code, honoring the owning run's
14
+ * cancellation signal.
15
+ *
16
+ * - `wait(ms)` sleeps for `ms` milliseconds.
17
+ * - `wait(fn, { timeout?, interval? })` polls `fn` (sync or async) until it returns a
18
+ * truthy value and resolves with that value; throws a named `ToolError` on timeout
19
+ * instead of stalling into the whole-cell deadline. Predicate errors propagate.
20
+ */
21
+ export async function waitForBrowserRun(
22
+ msOrPredicate: number | (() => unknown),
23
+ signal: AbortSignal,
24
+ opts?: WaitPredicateOptions,
25
+ ): Promise<unknown> {
8
26
  throwIfAborted(signal);
27
+ if (typeof msOrPredicate === "number") {
28
+ await untilAborted(signal, async () => await Bun.sleep(msOrPredicate));
29
+ throwIfAborted(signal);
30
+ return undefined;
31
+ }
32
+ if (typeof msOrPredicate !== "function") {
33
+ throw new ToolError("wait(...) expects milliseconds (number) or a predicate function to poll");
34
+ }
35
+ const timeout =
36
+ opts?.timeout !== undefined && Number.isFinite(opts.timeout) && opts.timeout > 0 ? opts.timeout : 30_000;
37
+ const interval = Math.max(opts?.interval ?? 100, 10);
38
+ const deadline = Date.now() + timeout;
39
+ for (;;) {
40
+ const value = await untilAborted(signal, async () => await msOrPredicate());
41
+ throwIfAborted(signal);
42
+ if (value) return value;
43
+ if (Date.now() + interval > deadline) {
44
+ throw new ToolError(`wait(predicate) timed out after ${timeout}ms — predicate never returned truthy`);
45
+ }
46
+ await untilAborted(signal, async () => await Bun.sleep(interval));
47
+ }
9
48
  }
10
49
 
11
50
  /** Binds a long-lived browser facade to one evaluated run's abort signal. */
@@ -0,0 +1,76 @@
1
+ import type { ImageContent, TextContent } from "jeopi-ai";
2
+ import type { JsDisplayOutput } from "../../eval/js/shared/types";
3
+
4
+ /**
5
+ * Accumulates a browser run's result entries: explicit `display()` payloads,
6
+ * screenshot captions/images, and buffered stream text (`console.*`, `print`,
7
+ * `display()` of strings/primitives — `JsRuntime.displayValue` emits those via
8
+ * `onText`). Stream text is buffered and flushed as one entry before the next
9
+ * display/screenshot (and on `finish()`) so it reaches the tool result in
10
+ * order instead of vanishing into the debug log.
11
+ */
12
+ export class RunOutput {
13
+ readonly #displays: Array<TextContent | ImageContent> = [];
14
+ #textBuffer = "";
15
+
16
+ /** Buffer a stream-text chunk; it joins the entries at the next push or on finish(). */
17
+ pushText(chunk: string): void {
18
+ this.#textBuffer += chunk;
19
+ }
20
+
21
+ /** Append a `display()` payload (image/json/status), flushing buffered text first. */
22
+ pushDisplay(output: JsDisplayOutput): void {
23
+ if (output.type === "image") {
24
+ this.push({ type: "image", data: output.data, mimeType: output.mimeType });
25
+ return;
26
+ }
27
+ if (output.type === "json") {
28
+ this.push({ type: "text", text: safeJsonStringify(output.data) });
29
+ return;
30
+ }
31
+ // status — surface as compact JSON so helper side effects (read/write/env) appear in
32
+ // the cell result alongside explicit display() output.
33
+ this.push({ type: "text", text: safeJsonStringify(output.event) });
34
+ }
35
+
36
+ /** Append a pre-built entry (e.g. a screenshot caption/image), flushing buffered text first. */
37
+ push(entry: TextContent | ImageContent): void {
38
+ this.#flush();
39
+ this.#displays.push(entry);
40
+ }
41
+
42
+ /** Flush any remaining stream text and return the ordered entries. */
43
+ finish(): Array<TextContent | ImageContent> {
44
+ this.#flush();
45
+ return this.#displays;
46
+ }
47
+
48
+ #flush(): void {
49
+ if (!this.#textBuffer) return;
50
+ // Entries are newline-joined at render; drop the stream's trailing newline.
51
+ this.#displays.push({ type: "text", text: this.#textBuffer.replace(/\n$/, "") });
52
+ this.#textBuffer = "";
53
+ }
54
+ }
55
+
56
+ /** JSON.stringify that never throws (cycles/BigInt → String(value)). */
57
+ export function safeJsonStringify(value: unknown): string {
58
+ try {
59
+ return JSON.stringify(value, null, 2);
60
+ } catch {
61
+ return String(value);
62
+ }
63
+ }
64
+
65
+ /** Pass a return value across the run boundary: structured-cloneable as-is, else JSON round-trip, else String. */
66
+ export function cloneSafe(value: unknown): unknown {
67
+ if (value === undefined) return undefined;
68
+ try {
69
+ structuredClone(value);
70
+ return value;
71
+ } catch {}
72
+ try {
73
+ return JSON.parse(JSON.stringify(value)) as unknown;
74
+ } catch {}
75
+ return String(value);
76
+ }