jeopi-cli 16.4.2 → 16.4.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.
Files changed (125) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/cli.js +3179 -3140
  3. package/dist/types/advisor/config.d.ts +4 -3
  4. package/dist/types/cli/args.d.ts +2 -0
  5. package/dist/types/cli/update-cli.d.ts +4 -1
  6. package/dist/types/cli/usage-error.d.ts +4 -0
  7. package/dist/types/commit/agentic/agent.d.ts +1 -0
  8. package/dist/types/config/model-resolver.d.ts +7 -1
  9. package/dist/types/config/settings-schema.d.ts +3 -3
  10. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  11. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  12. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  13. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  14. package/dist/types/internal-urls/memory-protocol.d.ts +6 -6
  15. package/dist/types/internal-urls/types.d.ts +7 -0
  16. package/dist/types/irc/bus.d.ts +4 -0
  17. package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
  18. package/dist/types/modes/components/move-overlay.d.ts +1 -1
  19. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  20. package/dist/types/modes/components/welcome.d.ts +4 -0
  21. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  22. package/dist/types/modes/orchestrate.d.ts +1 -1
  23. package/dist/types/modes/ultrathink.d.ts +1 -1
  24. package/dist/types/modes/workflow.d.ts +1 -1
  25. package/dist/types/session/agent-session.d.ts +5 -0
  26. package/dist/types/session/auth-broker-config.d.ts +2 -2
  27. package/dist/types/tools/bash-skill-urls.d.ts +3 -2
  28. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
  29. package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
  30. package/dist/types/tools/browser/launch.d.ts +1 -0
  31. package/dist/types/tools/browser/run-cancellation.d.ts +17 -2
  32. package/dist/types/tools/browser/run-output.d.ts +25 -0
  33. package/dist/types/tools/browser/tab-worker.d.ts +22 -0
  34. package/dist/types/utils/changelog.d.ts +35 -1
  35. package/package.json +12 -12
  36. package/src/advisor/__tests__/advisor.test.ts +13 -0
  37. package/src/advisor/__tests__/config.test.ts +36 -0
  38. package/src/advisor/config.ts +11 -8
  39. package/src/autolearn/controller.ts +14 -2
  40. package/src/cli/args.ts +12 -0
  41. package/src/cli/flag-tables.ts +21 -7
  42. package/src/cli/update-cli.ts +63 -13
  43. package/src/cli/usage-error.ts +7 -0
  44. package/src/commands/acp.ts +11 -2
  45. package/src/commands/launch.ts +12 -3
  46. package/src/commit/agentic/agent.ts +4 -0
  47. package/src/commit/agentic/index.ts +46 -21
  48. package/src/config/model-resolver.ts +24 -6
  49. package/src/config/settings-schema.ts +3 -3
  50. package/src/edit/index.ts +10 -76
  51. package/src/edit/renderer.ts +13 -10
  52. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  53. package/src/eval/__tests__/bridge-timeout.test.ts +60 -0
  54. package/src/eval/executor-base.ts +84 -7
  55. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  56. package/src/eval/py/tool-bridge.ts +13 -14
  57. package/src/exec/non-interactive-env.ts +0 -1
  58. package/src/export/ttsr.ts +0 -3
  59. package/src/extensibility/custom-tools/loader.ts +70 -37
  60. package/src/extensibility/custom-tools/types.ts +3 -0
  61. package/src/extensibility/extensions/runner.ts +3 -0
  62. package/src/extensibility/extensions/types.ts +3 -0
  63. package/src/extensibility/plugins/manager.ts +21 -0
  64. package/src/internal-urls/docs-index.generated.txt +1 -1
  65. package/src/internal-urls/memory-protocol.ts +13 -8
  66. package/src/internal-urls/skill-protocol.ts +1 -1
  67. package/src/internal-urls/types.ts +7 -0
  68. package/src/irc/bus.ts +48 -18
  69. package/src/lsp/client.ts +7 -1
  70. package/src/lsp/deferred-diagnostics.ts +66 -0
  71. package/src/main.ts +13 -13
  72. package/src/mcp/tool-bridge.ts +57 -6
  73. package/src/mcp/transports/stdio.test.ts +45 -1
  74. package/src/mcp/transports/stdio.ts +6 -3
  75. package/src/modes/acp/acp-agent.ts +73 -2
  76. package/src/modes/acp/acp-event-mapper.ts +5 -0
  77. package/src/modes/acp/acp-mode.ts +11 -0
  78. package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
  79. package/src/modes/components/advisor-config.ts +15 -7
  80. package/src/modes/components/move-overlay.ts +2 -3
  81. package/src/modes/components/plan-review-overlay.ts +7 -0
  82. package/src/modes/components/tips.txt +1 -1
  83. package/src/modes/components/welcome.ts +13 -14
  84. package/src/modes/controllers/command-controller.ts +8 -10
  85. package/src/modes/controllers/event-controller.ts +17 -30
  86. package/src/modes/controllers/input-controller.ts +9 -6
  87. package/src/modes/controllers/selector-controller.ts +3 -2
  88. package/src/modes/controllers/tan-command-controller.ts +40 -1
  89. package/src/modes/interactive-mode.ts +13 -0
  90. package/src/modes/magic-keyword-boundary.ts +23 -0
  91. package/src/modes/orchestrate.ts +6 -5
  92. package/src/modes/print-mode.ts +9 -0
  93. package/src/modes/ultrathink.ts +6 -5
  94. package/src/modes/workflow.ts +5 -4
  95. package/src/prompts/system/eager-task.md +2 -2
  96. package/src/prompts/system/tan-context-switch.md +17 -0
  97. package/src/prompts/tools/browser.md +5 -5
  98. package/src/sdk.ts +3 -0
  99. package/src/session/agent-session.ts +172 -25
  100. package/src/session/auth-broker-config.ts +4 -4
  101. package/src/session/snapcompact-inline.ts +3 -19
  102. package/src/session/streaming-output.ts +40 -12
  103. package/src/slash-commands/builtin-registry.ts +8 -8
  104. package/src/task/executor.ts +8 -0
  105. package/src/tools/bash-skill-urls.ts +7 -4
  106. package/src/tools/bash.ts +30 -22
  107. package/src/tools/browser/cmux/cmux-tab.ts +49 -50
  108. package/src/tools/browser/cmux/rpc.ts +50 -0
  109. package/src/tools/browser/launch.ts +8 -4
  110. package/src/tools/browser/run-cancellation.ts +44 -5
  111. package/src/tools/browser/run-output.ts +76 -0
  112. package/src/tools/browser/tab-worker.ts +280 -133
  113. package/src/tools/eval-render.ts +16 -14
  114. package/src/tools/glob.ts +20 -6
  115. package/src/tools/grep.ts +1 -0
  116. package/src/tools/image-gen.ts +3 -4
  117. package/src/tools/irc.ts +27 -13
  118. package/src/tools/read.ts +14 -7
  119. package/src/tools/write.ts +22 -5
  120. package/src/tools/yield.ts +29 -1
  121. package/src/utils/changelog.ts +106 -9
  122. package/src/utils/git.ts +0 -1
  123. package/src/utils/title-generator.ts +68 -7
  124. package/src/web/search/index.ts +14 -6
  125. package/src/web/search/providers/perplexity.ts +18 -2
@@ -2038,14 +2038,22 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2038
2038
  thinkingLevel: resolvedThinkingLevel,
2039
2039
  explicitThinkingLevel,
2040
2040
  authFallbackUsed,
2041
+ warning: modelResolutionWarning,
2041
2042
  } = await awaitAbortable(
2042
2043
  resolveModelOverrideWithAuthFallback(
2043
2044
  modelPatterns,
2044
2045
  options.parentActiveModelPattern,
2045
2046
  modelRegistry,
2046
2047
  settings,
2048
+ id,
2047
2049
  ),
2048
2050
  );
2051
+ if (modelResolutionWarning) {
2052
+ logger.warn("Subagent model resolution warning", {
2053
+ warning: modelResolutionWarning,
2054
+ requested: modelPatterns,
2055
+ });
2056
+ }
2049
2057
  if (authFallbackUsed && model) {
2050
2058
  logger.warn("Subagent model has no working credentials; falling back to parent session model", {
2051
2059
  requested: modelPatterns,
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import type { Skill } from "../extensibility/skills";
4
4
  import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
5
5
  import { validateRelativePath } from "../internal-urls/skill-protocol";
6
- import type { InternalResource } from "../internal-urls/types";
6
+ import type { InternalResource, ResolveContext } from "../internal-urls/types";
7
7
  import { normalizeLocalScheme } from "./path-utils";
8
8
  import { ToolError } from "./tool-errors";
9
9
 
@@ -19,7 +19,7 @@ type SupportedInternalScheme = (typeof SUPPORTED_INTERNAL_SCHEMES)[number];
19
19
 
20
20
  interface InternalUrlResolver {
21
21
  canHandle(input: string): boolean;
22
- resolve(input: string): Promise<InternalResource>;
22
+ resolve(input: string, context?: ResolveContext): Promise<InternalResource>;
23
23
  }
24
24
 
25
25
  export interface InternalUrlExpansionOptions {
@@ -27,6 +27,7 @@ export interface InternalUrlExpansionOptions {
27
27
  noEscape?: boolean;
28
28
  internalRouter?: InternalUrlResolver;
29
29
  localOptions?: LocalProtocolOptions;
30
+ cwd?: string;
30
31
  ensureLocalParentDirs?: boolean;
31
32
  }
32
33
 
@@ -66,7 +67,7 @@ export function resolveSkillUrlToPath(url: string, skills: readonly Skill[]): st
66
67
  const hasRelativePath = rawPath !== "" && rawPath !== "/";
67
68
 
68
69
  if (!hasRelativePath) {
69
- return path.resolve(skill.filePath);
70
+ return path.resolve(skill.baseDir);
70
71
  }
71
72
 
72
73
  let relativePath: string;
@@ -151,6 +152,7 @@ async function resolveInternalUrlToPath(
151
152
  internalRouter?: InternalUrlResolver,
152
153
  localOptions?: LocalProtocolOptions,
153
154
  ensureLocalParentDirs?: boolean,
155
+ cwd?: string,
154
156
  ): Promise<string> {
155
157
  const url = normalizeLocalScheme(rawUrl);
156
158
  const scheme = extractScheme(url);
@@ -184,7 +186,7 @@ async function resolveInternalUrlToPath(
184
186
 
185
187
  let resource: InternalResource;
186
188
  try {
187
- resource = await internalRouter.resolve(url);
189
+ resource = await internalRouter.resolve(url, { cwd });
188
190
  } catch (error) {
189
191
  const message = error instanceof Error ? error.message : String(error);
190
192
  throw new ToolError(`Failed to resolve ${scheme}:// URL in bash command: ${url}\n${message}`);
@@ -239,6 +241,7 @@ export async function expandInternalUrls(command: string, options: InternalUrlEx
239
241
  options.internalRouter,
240
242
  options.localOptions,
241
243
  options.ensureLocalParentDirs,
244
+ options.cwd,
242
245
  );
243
246
  const replacement = options.noEscape ? resolvedPath : shellEscape(resolvedPath);
244
247
  expanded = `${expanded.slice(0, index)}${replacement}${expanded.slice(index + token.length)}`;
package/src/tools/bash.ts CHANGED
@@ -163,10 +163,9 @@ type ManagedBashJobCompletion =
163
163
 
164
164
  interface ManagedBashJobHandle {
165
165
  jobId: string;
166
- label: string;
167
166
  completion: Promise<ManagedBashJobCompletion>;
168
167
  getLatestText: () => string;
169
- setBackgrounded: (backgrounded: boolean) => void;
168
+ stopUpdates: () => void;
170
169
  }
171
170
 
172
171
  function normalizeResultOutput(result: BashResult | BashInteractiveResult): string {
@@ -297,6 +296,10 @@ function formatExitCodeNotice(exitCode: number): string {
297
296
  return `Command exited with code ${exitCode}`;
298
297
  }
299
298
 
299
+ function formatBackgroundNotice(jobId: string): string {
300
+ return `Backgrounded as job ${jobId}; result will be delivered automatically.`;
301
+ }
302
+
300
303
  /**
301
304
  * Strip the trailing occurrence of `notice` (plus a single surrounding newline
302
305
  * on each side) so the TUI can echo the value via a styled footer label
@@ -325,6 +328,11 @@ function stripExitCodeNotice(text: string, exitCode: number | undefined): string
325
328
  return stripTrailingNotice(text, formatExitCodeNotice(exitCode));
326
329
  }
327
330
 
331
+ function stripBackgroundNotice(text: string, async: BashToolDetails["async"] | undefined): string {
332
+ if (async?.state !== "running") return text;
333
+ return stripTrailingNotice(text, formatBackgroundNotice(async.jobId));
334
+ }
335
+
328
336
  /**
329
337
  * Bash tool implementation.
330
338
  *
@@ -473,7 +481,6 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
473
481
 
474
482
  #buildBackgroundStartResult(
475
483
  jobId: string,
476
- label: string,
477
484
  previewText: string,
478
485
  timeoutSec: number,
479
486
  options: { requestedTimeoutSec?: number; notices?: readonly string[] } = {},
@@ -493,11 +500,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
493
500
  if (options.notices?.length) {
494
501
  lines.push(...options.notices, "");
495
502
  }
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
- );
503
+ lines.push(formatBackgroundNotice(jobId));
501
504
  return {
502
505
  content: [{ type: "text", text: lines.join("\n") }],
503
506
  details,
@@ -518,7 +521,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
518
521
 
519
522
  resolvedEnv?: Record<string, string>;
520
523
  onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
521
- startBackgrounded: boolean;
524
+ forwardUpdates: boolean;
522
525
  }): ManagedBashJobHandle {
523
526
  const manager = this.session.asyncJobManager;
524
527
  if (!manager) {
@@ -527,7 +530,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
527
530
 
528
531
  const label = options.command.length > 120 ? `${options.command.slice(0, 117)}...` : options.command;
529
532
  let latestText = "";
530
- let backgrounded = options.startBackgrounded;
533
+ let forwardUpdates = options.forwardUpdates;
531
534
  const completion = Promise.withResolvers<ManagedBashJobCompletion>();
532
535
 
533
536
  const jobId = manager.register(
@@ -583,11 +586,12 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
583
586
  },
584
587
  {
585
588
  ownerId: this.session.getAgentId?.() ?? undefined,
586
- onProgress: async (text, details) => {
589
+ onProgress: async text => {
587
590
  latestText = text;
591
+ if (!forwardUpdates) return;
588
592
  await options.onUpdate?.({
589
593
  content: [{ type: "text", text }],
590
- details: backgrounded ? ((details ?? {}) as BashToolDetails) : {},
594
+ details: {},
591
595
  });
592
596
  },
593
597
  },
@@ -595,11 +599,10 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
595
599
 
596
600
  return {
597
601
  jobId,
598
- label,
599
602
  completion: completion.promise,
600
603
  getLatestText: () => latestText,
601
- setBackgrounded: (nextBackgrounded: boolean) => {
602
- backgrounded = nextBackgrounded;
604
+ stopUpdates: () => {
605
+ forwardUpdates = false;
603
606
  },
604
607
  };
605
608
  }
@@ -701,6 +704,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
701
704
  const internalUrlOptions: InternalUrlExpansionOptions = {
702
705
  skills: this.session.skills ?? [],
703
706
  internalRouter: InternalUrlRouter.instance(),
707
+ cwd: this.session.cwd,
704
708
  localOptions: {
705
709
  getArtifactsDir: this.session.getArtifactsDir,
706
710
  getSessionId: this.session.getSessionId,
@@ -769,9 +773,9 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
769
773
 
770
774
  resolvedEnv,
771
775
  onUpdate,
772
- startBackgrounded: true,
776
+ forwardUpdates: false,
773
777
  });
774
- return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
778
+ return this.#buildBackgroundStartResult(job.jobId, "", timeoutSec, {
775
779
  requestedTimeoutSec,
776
780
  notices: pendingNotices,
777
781
  });
@@ -807,10 +811,10 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
807
811
 
808
812
  resolvedEnv,
809
813
  onUpdate,
810
- startBackgrounded,
814
+ forwardUpdates: !startBackgrounded,
811
815
  });
812
816
  if (startBackgrounded) {
813
- return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec, {
817
+ return this.#buildBackgroundStartResult(job.jobId, "", timeoutSec, {
814
818
  requestedTimeoutSec,
815
819
  notices: pendingNotices,
816
820
  });
@@ -830,9 +834,9 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
830
834
  autoBgManager.cancel(job.jobId);
831
835
  throw new ToolAbortError(job.getLatestText() || "Command aborted");
832
836
  }
833
- job.setBackgrounded(true);
837
+ job.stopUpdates();
834
838
  autoBgManager.resumeDeliveries([job.jobId]);
835
- return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec, {
839
+ return this.#buildBackgroundStartResult(job.jobId, job.getLatestText(), timeoutSec, {
836
840
  requestedTimeoutSec,
837
841
  notices: pendingNotices,
838
842
  });
@@ -1249,7 +1253,8 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1249
1253
  ) {
1250
1254
  return cachedLines;
1251
1255
  }
1252
- const strippedOutput = stripOutputNotice(rawOutput, details?.meta);
1256
+ const withoutBackground = stripBackgroundNotice(rawOutput, details?.async);
1257
+ const strippedOutput = stripOutputNotice(withoutBackground, details?.meta);
1253
1258
  const withoutExit = stripExitCodeNotice(strippedOutput, details?.exitCode);
1254
1259
  const withoutWall = stripWallTimeNotice(withoutExit, details?.wallTimeMs);
1255
1260
  const rawOutputArtifact = stripRawOutputArtifactNotice(withoutWall);
@@ -1262,6 +1267,9 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1262
1267
  const requestedTimeoutSeconds = details?.requestedTimeoutSeconds;
1263
1268
  const wallTimeMs = details?.wallTimeMs;
1264
1269
  const statsParts: string[] = [];
1270
+ if (details?.async?.state === "running") {
1271
+ statsParts.push(`Backgrounded: ${details.async.jobId}`);
1272
+ }
1265
1273
  if (wallTimeMs !== undefined) {
1266
1274
  statsParts.push(`Wall: ${formatWallTimeSeconds(wallTimeMs)}s`);
1267
1275
  }
@@ -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
+ }