jeopi-cli 16.4.1 → 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 +80 -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 +237 -50
  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
@@ -735,6 +735,7 @@ export class OutputSink {
735
735
  #truncated = false;
736
736
  #lastChunkTime = 0;
737
737
  #pendingChunk = "";
738
+ #pendingChunkTimer: Timer | undefined;
738
739
 
739
740
  // Per-line column cap streaming state (persists across `push` calls so a
740
741
  // long line split across chunks still trips the same trigger).
@@ -806,20 +807,18 @@ export class OutputSink {
806
807
  push(chunk: string): void {
807
808
  chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
808
809
 
809
- // Throttled onChunk: coalesce chunks arriving inside the throttle window
810
- // and flush the buffered concatenation on the next eligible tick (plus a
811
- // final flush in dump()) so the preview never has silent gaps.
810
+ // Throttled onChunk: coalesce chunks arriving inside the throttle window.
811
+ // A timer flushes quiet tails at the throttle boundary; dump() catches a
812
+ // final pending chunk when the process exits before that timer fires.
812
813
  // Live preview gets the raw (pre-cap) chunk so the TUI never lags behind
813
814
  // what reached the sink — the column cap is for the persisted LLM view.
814
815
  if (this.#onChunk) {
815
816
  const now = Date.now();
816
817
  if (now - this.#lastChunkTime >= this.#chunkThrottleMs) {
817
- this.#lastChunkTime = now;
818
- const merged = this.#pendingChunk + chunk;
819
- this.#pendingChunk = "";
820
- this.#onChunk(merged);
818
+ this.#emitPendingChunkWith(chunk, now);
821
819
  } else {
822
820
  this.#pendingChunk += chunk;
821
+ this.#schedulePendingChunkFlush();
823
822
  }
824
823
  }
825
824
 
@@ -1121,6 +1120,7 @@ export class OutputSink {
1121
1120
  * branch in `dump()` against stale totals.
1122
1121
  */
1123
1122
  replace(text: string): void {
1123
+ this.#clearPendingChunkTimer();
1124
1124
  this.#buffer = text;
1125
1125
  this.#bufferBytes = Buffer.byteLength(text, "utf-8");
1126
1126
  this.#head = "";
@@ -1138,6 +1138,38 @@ export class OutputSink {
1138
1138
  this.#pendingChunk = "";
1139
1139
  }
1140
1140
 
1141
+ #clearPendingChunkTimer(): void {
1142
+ if (!this.#pendingChunkTimer) return;
1143
+ clearTimeout(this.#pendingChunkTimer);
1144
+ this.#pendingChunkTimer = undefined;
1145
+ }
1146
+
1147
+ #emitPendingChunkWith(chunk: string, now: number): void {
1148
+ this.#clearPendingChunkTimer();
1149
+ this.#lastChunkTime = now;
1150
+ const merged = this.#pendingChunk + chunk;
1151
+ this.#pendingChunk = "";
1152
+ this.#onChunk?.(merged);
1153
+ }
1154
+
1155
+ #flushPendingChunk(): void {
1156
+ if (this.#pendingChunk.length === 0) {
1157
+ this.#clearPendingChunkTimer();
1158
+ return;
1159
+ }
1160
+ this.#emitPendingChunkWith("", Date.now());
1161
+ }
1162
+
1163
+ #schedulePendingChunkFlush(): void {
1164
+ if (this.#chunkThrottleMs <= 0 || this.#pendingChunkTimer) return;
1165
+ const elapsed = Date.now() - this.#lastChunkTime;
1166
+ const delay = Math.max(0, this.#chunkThrottleMs - elapsed);
1167
+ this.#pendingChunkTimer = setTimeout(() => {
1168
+ this.#pendingChunkTimer = undefined;
1169
+ this.#flushPendingChunk();
1170
+ }, delay);
1171
+ }
1172
+
1141
1173
  /**
1142
1174
  * Replay the rolling tail ring back into the artifact sink. When bytes
1143
1175
  * were actually dropped from the middle (the head budget was exhausted
@@ -1179,11 +1211,7 @@ export class OutputSink {
1179
1211
 
1180
1212
  // Flush any chunk still held back by the throttle so the live preview
1181
1213
  // ends with the complete stream.
1182
- if (this.#onChunk && this.#pendingChunk.length > 0) {
1183
- const pending = this.#pendingChunk;
1184
- this.#pendingChunk = "";
1185
- this.#onChunk(pending);
1186
- }
1214
+ this.#flushPendingChunk();
1187
1215
  const totalLines = this.#sawData ? this.#totalLines + 1 : 0;
1188
1216
 
1189
1217
  if (this.#file) {
@@ -34,7 +34,12 @@ import { resolveResumableSession } from "../session/session-listing";
34
34
  import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
35
35
  import { expandTilde, resolveToCwd } from "../tools/path-utils";
36
36
  import { urlHyperlinkAlways } from "../tui";
37
- import { getChangelogPath, parseChangelog } from "../utils/changelog";
37
+ import {
38
+ getChangelogPath,
39
+ parseChangelog,
40
+ RECENT_CHANGELOG_ENTRY_LIMIT,
41
+ renderChangelogEntries,
42
+ } from "../utils/changelog";
38
43
  import { copyToClipboard } from "../utils/clipboard";
39
44
  import { CollabQrCodeComponent } from "./helpers/collab-qrcode";
40
45
  import { buildContextReportText } from "./helpers/context-report";
@@ -1100,17 +1105,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1100
1105
  const changelogPath = getChangelogPath();
1101
1106
  const allEntries = await parseChangelog(changelogPath);
1102
1107
  const showFull = command.args.trim().toLowerCase() === "full";
1103
- const entriesToShow = showFull ? allEntries : allEntries.slice(0, 3);
1108
+ const entriesToShow = showFull ? allEntries : allEntries.slice(0, RECENT_CHANGELOG_ENTRY_LIMIT);
1104
1109
  if (entriesToShow.length === 0) {
1105
1110
  await runtime.output("No changelog entries found.");
1106
1111
  return commandConsumed();
1107
1112
  }
1108
- await runtime.output(
1109
- [...entriesToShow]
1110
- .reverse()
1111
- .map(entry => entry.content)
1112
- .join("\n\n"),
1113
- );
1113
+ await runtime.output(renderChangelogEntries(entriesToShow).markdown);
1114
1114
  return commandConsumed();
1115
1115
  },
1116
1116
  handleTui: async (command, runtime) => {
@@ -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;