@gajae-code/coding-agent 0.11.2 → 0.11.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 (101) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/cli/launch-disposition.d.ts +23 -0
  3. package/dist/types/cli/worktree-cli.d.ts +8 -25
  4. package/dist/types/commands/gc.d.ts +4 -0
  5. package/dist/types/commands/worktree.d.ts +7 -32
  6. package/dist/types/config/settings-schema.d.ts +85 -10
  7. package/dist/types/gjc-runtime/gc-runtime.d.ts +9 -0
  8. package/dist/types/gjc-runtime/state-argv.d.ts +38 -0
  9. package/dist/types/main.d.ts +1 -0
  10. package/dist/types/modes/components/custom-editor.d.ts +8 -3
  11. package/dist/types/modes/components/tool-transcript-format.d.ts +19 -0
  12. package/dist/types/modes/controllers/input-controller.d.ts +8 -2
  13. package/dist/types/modes/prompt-action-autocomplete.d.ts +4 -0
  14. package/dist/types/sdk/broker/broker.d.ts +1 -0
  15. package/dist/types/sdk/broker/discovery.d.ts +10 -0
  16. package/dist/types/sdk/broker/ensure.d.ts +24 -1
  17. package/dist/types/sdk/broker/internal.d.ts +3 -0
  18. package/dist/types/sdk/broker/session-index.d.ts +12 -0
  19. package/dist/types/sdk/broker/state-version.d.ts +2 -1
  20. package/dist/types/sdk/bus/index.d.ts +2 -0
  21. package/dist/types/sdk/session.d.ts +2 -0
  22. package/dist/types/tools/ask.d.ts +39 -12
  23. package/dist/types/tools/browser/screenshot-format.d.ts +8 -0
  24. package/dist/types/tools/browser/tab-protocol.d.ts +11 -0
  25. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -1
  26. package/dist/types/tools/browser/tab-worker-path-resolver.d.ts +2 -0
  27. package/dist/types/tools/browser/tab-worker-smoke.d.ts +16 -0
  28. package/dist/types/tools/path-utils.d.ts +9 -14
  29. package/dist/types/tools/read.d.ts +2 -0
  30. package/dist/types/tools/render-utils.d.ts +1 -8
  31. package/dist/types/tools/resource-gc.d.ts +14 -0
  32. package/dist/types/tools/subagent.d.ts +1 -0
  33. package/dist/types/utils/file-mentions.d.ts +3 -3
  34. package/dist/types/utils/image-loading.d.ts +23 -0
  35. package/dist/types/utils/image-resize.d.ts +2 -0
  36. package/dist/types/utils/pasted-image-loading.d.ts +39 -0
  37. package/dist/types/utils/pasted-image-path.d.ts +21 -19
  38. package/dist/types/web/scrapers/html-to-markdown.d.ts +5 -0
  39. package/dist/types/web/scrapers/types.d.ts +1 -5
  40. package/package.json +24 -8
  41. package/scripts/run-threat-model-check.ts +60 -0
  42. package/src/cli/launch-disposition.ts +42 -0
  43. package/src/cli/worktree-cli.ts +9 -287
  44. package/src/cli.ts +10 -9
  45. package/src/commands/gc.ts +11 -1
  46. package/src/commands/sdk.ts +3 -2
  47. package/src/commands/worktree.ts +10 -53
  48. package/src/config/model-profiles.ts +15 -15
  49. package/src/config/settings-schema.ts +55 -10
  50. package/src/eval/js/shared/runtime.ts +1 -1
  51. package/src/gjc-runtime/gc-render.ts +19 -1
  52. package/src/gjc-runtime/gc-runtime.ts +68 -3
  53. package/src/gjc-runtime/state-argv.ts +292 -0
  54. package/src/gjc-runtime/state-runtime.ts +51 -212
  55. package/src/main.ts +99 -22
  56. package/src/modes/components/custom-editor.ts +103 -32
  57. package/src/modes/components/custom-provider-wizard.ts +26 -1
  58. package/src/modes/components/session-observer-overlay.ts +10 -23
  59. package/src/modes/components/tool-transcript-format.ts +72 -0
  60. package/src/modes/components/transcript-viewer-overlay.ts +42 -2
  61. package/src/modes/controllers/input-controller.ts +98 -37
  62. package/src/modes/controllers/selector-controller.ts +1 -1
  63. package/src/modes/interactive-mode.ts +13 -2
  64. package/src/modes/prompt-action-autocomplete.ts +7 -0
  65. package/src/sdk/broker/broker.ts +139 -32
  66. package/src/sdk/broker/discovery.ts +125 -0
  67. package/src/sdk/broker/ensure.ts +103 -19
  68. package/src/sdk/broker/internal.ts +10 -0
  69. package/src/sdk/broker/session-index.ts +228 -15
  70. package/src/sdk/broker/state-version.ts +5 -2
  71. package/src/sdk/bus/index.ts +4 -1
  72. package/src/sdk/bus/telegram-daemon.ts +50 -10
  73. package/src/sdk/host/host.ts +13 -1
  74. package/src/sdk/session.ts +4 -1
  75. package/src/session/internal/managed-session-scope.ts +15 -1
  76. package/src/tools/ask.ts +216 -41
  77. package/src/tools/bash-allowed-prefixes.ts +79 -29
  78. package/src/tools/browser/launch.ts +3 -1
  79. package/src/tools/browser/readable.ts +1 -1
  80. package/src/tools/browser/screenshot-format.ts +34 -0
  81. package/src/tools/browser/tab-protocol.ts +3 -0
  82. package/src/tools/browser/tab-supervisor.ts +125 -122
  83. package/src/tools/browser/tab-worker-entry.ts +33 -14
  84. package/src/tools/browser/tab-worker-path-resolver.ts +67 -0
  85. package/src/tools/browser/tab-worker-smoke.ts +87 -0
  86. package/src/tools/browser/tab-worker.ts +7 -5
  87. package/src/tools/output-meta.ts +6 -2
  88. package/src/tools/path-utils.ts +42 -35
  89. package/src/tools/read.ts +189 -64
  90. package/src/tools/render-utils.ts +1 -27
  91. package/src/tools/resource-gc.ts +123 -34
  92. package/src/tools/subagent.ts +43 -2
  93. package/src/utils/file-mentions.ts +4 -4
  94. package/src/utils/image-loading.ts +85 -34
  95. package/src/utils/image-resize.ts +16 -9
  96. package/src/utils/pasted-image-loading.ts +514 -0
  97. package/src/utils/pasted-image-path.ts +223 -98
  98. package/src/web/scrapers/html-to-markdown.ts +68 -0
  99. package/src/web/scrapers/readthedocs.ts +1 -1
  100. package/src/web/scrapers/types.ts +1 -68
  101. package/src/web/search/providers/openai-compatible.ts +76 -23
@@ -19,7 +19,6 @@ import type {
19
19
  RunResultOk,
20
20
  SessionSnapshot,
21
21
  Transferable,
22
- Transport,
23
22
  WorkerInbound,
24
23
  WorkerInitPayload,
25
24
  WorkerOutbound,
@@ -38,8 +37,8 @@ interface WorkerHandle {
38
37
  send(msg: WorkerInbound, transferList?: Transferable[]): void;
39
38
  onMessage(handler: (msg: WorkerOutbound) => void): () => void;
40
39
  onError(handler: (error: Error) => void): () => void;
40
+ onClose(handler: () => void): () => void;
41
41
  terminate(): Promise<void>;
42
- readonly mode: "worker" | "inline";
43
42
  }
44
43
 
45
44
  export type DialogPolicy = "accept" | "dismiss";
@@ -118,6 +117,13 @@ let workerInitializerForTest:
118
117
  | ((worker: WorkerHandle, payload: WorkerInitPayload, timeoutMs: number) => Promise<ReadyInfo>)
119
118
  | undefined;
120
119
  const GRACE_MS = 750;
120
+ const TAB_WORKER_MODE = "native-free";
121
+
122
+ function startupError(stage: string): ToolError {
123
+ return new ToolError(
124
+ `Tab worker startup failed (stage=${stage}, mode=${TAB_WORKER_MODE}, platform=${process.platform}).`,
125
+ );
126
+ }
121
127
 
122
128
  interface DeadTabDescriptor {
123
129
  tab: TabSession;
@@ -152,7 +158,7 @@ async function awaitWithinBudget(op: Promise<unknown>, remainingMs: number, labe
152
158
  logger.debug("close teardown budget already spent; detaching step", { label });
153
159
  return false;
154
160
  }
155
- let timer: ReturnType<typeof setTimeout> | undefined;
161
+ let timer: NodeJS.Timeout | undefined;
156
162
  const timedOut = new Promise<boolean>(resolve => {
157
163
  timer = setTimeout(() => resolve(false), remainingMs);
158
164
  });
@@ -358,64 +364,69 @@ export async function acquireTab(
358
364
  if (replacementHold) await releaseBrowser(browser, { kill: false });
359
365
  throw error;
360
366
  }
361
- let worker = await spawnAcquireWorker();
362
- let info: ReadyInfo;
367
+ let worker: WorkerHandle;
363
368
  try {
364
- info = await initializeAcquireWorker(worker, initPayload, opts.timeoutMs + GRACE_MS);
365
- } catch (error) {
366
- // `BuildMessage`-class failures arrive asynchronously via the worker's `error` event,
367
- // after `spawnTabWorker`'s synchronous try/catch has already returned. Fall back to
368
- // the inline worker here so module-resolution failures don't poison every tab open.
369
- await worker.terminate().catch(() => undefined);
370
- if (worker.mode === "inline") {
371
- if (replacementHold) await releaseBrowser(browser, { kill: false });
372
- else if (browser.refCount === 0) await releaseBrowser(browser, { kill: false });
373
- throw error;
374
- }
375
- logger.warn("Tab worker init failed; retrying with inline tab worker (no sync-loop guard)", {
376
- error: error instanceof Error ? error.message : String(error),
377
- });
378
- worker = await (workerFactoryForTest?.() ?? spawnInlineWorker());
379
- try {
380
- info = await initializeAcquireWorker(worker, initPayload, opts.timeoutMs + GRACE_MS);
381
- } catch (inlineError) {
382
- await worker.terminate().catch(() => undefined);
383
- if (replacementHold) await releaseBrowser(browser, { kill: false });
384
- else if (browser.refCount === 0) await releaseBrowser(browser, { kill: false });
385
- const finalError = new ToolError(
386
- `Failed to start browser tab worker (inline fallback also failed): ${inlineError instanceof Error ? inlineError.message : String(inlineError)}`,
387
- );
388
- (finalError as { cause?: unknown }).cause = error;
389
- throw finalError;
369
+ worker = await spawnAcquireWorker();
370
+ } catch {
371
+ if (replacementHold) await releaseBrowser(browser, { kill: false });
372
+ else if (browser.refCount === 0) await releaseBrowser(browser, { kill: false });
373
+ throw startupError("spawn");
374
+ }
375
+ let registeredTab: TabSession | undefined;
376
+ let startupFailure: Error | undefined;
377
+ let browserHeld = false;
378
+ const observeFailure = (error: Error): void => {
379
+ if (registeredTab) markTabDead(registeredTab, error);
380
+ else startupFailure ??= error;
381
+ };
382
+ const unlistenLifetimeMessages = worker.onMessage(msg => {
383
+ if (msg.type === "closed") observeFailure(startupError("protocol-closed"));
384
+ });
385
+ const unlistenLifetimeErrors = worker.onError(() => observeFailure(startupError("error")));
386
+ const unlistenLifetimeClose = worker.onClose(() => observeFailure(startupError("physical-close")));
387
+ try {
388
+ const info = await initializeAcquireWorker(worker, initPayload, opts.timeoutMs + GRACE_MS);
389
+ afterWorkerInitializationForTest?.(name);
390
+ if (startupFailure) throw startupFailure;
391
+ if (opts.requireVacantName && tabs.has(name))
392
+ throw new ToolError(`Tab ${JSON.stringify(name)} was replaced during recovery.`);
393
+
394
+ holdBrowser(browser);
395
+ browserHeld = true;
396
+ if (replacementHold) {
397
+ await releaseBrowser(browser, { kill: false });
398
+ replacementHold = false;
390
399
  }
391
- }
392
-
393
- afterWorkerInitializationForTest?.(name);
394
- if (opts.requireVacantName && tabs.has(name)) {
400
+ if (startupFailure) throw startupFailure;
401
+
402
+ const tab: TabSession = {
403
+ name,
404
+ browser,
405
+ targetId: info.targetId,
406
+ worker,
407
+ state: "alive",
408
+ info,
409
+ pending: new Map(),
410
+ dialogPolicy: opts.dialogs,
411
+ kindTag: browser.kind.kind,
412
+ ownerId: opts.ownerId,
413
+ lastUsedAt: Date.now(),
414
+ recoveryOpts: freezeRecoveryOptions(opts),
415
+ };
416
+ registeredTab = tab;
417
+ worker.onMessage(msg => handleTabMessage(tab, msg));
418
+ tabs.set(name, tab);
419
+ return { tab, created: true };
420
+ } catch (error) {
421
+ unlistenLifetimeMessages();
422
+ unlistenLifetimeErrors();
423
+ unlistenLifetimeClose();
395
424
  await worker.terminate().catch(() => undefined);
425
+ if (browserHeld) await releaseBrowser(browser, { kill: false });
396
426
  if (replacementHold) await releaseBrowser(browser, { kill: false });
397
- throw new ToolError(`Tab ${JSON.stringify(name)} was replaced during recovery.`);
427
+ else if (!browserHeld && browser.refCount === 0) await releaseBrowser(browser, { kill: false });
428
+ throw error;
398
429
  }
399
- holdBrowser(browser);
400
- if (replacementHold) await releaseBrowser(browser, { kill: false });
401
- const tab: TabSession = {
402
- name,
403
- browser,
404
- targetId: info.targetId,
405
- worker,
406
- state: "alive",
407
- info,
408
- pending: new Map(),
409
- dialogPolicy: opts.dialogs,
410
- kindTag: browser.kind.kind,
411
- ownerId: opts.ownerId,
412
- lastUsedAt: Date.now(),
413
- recoveryOpts: freezeRecoveryOptions(opts),
414
- };
415
- worker.onMessage(msg => handleTabMessage(tab, msg));
416
- worker.onError(error => markTabDead(tab, error));
417
- tabs.set(name, tab);
418
- return { tab, created: true };
419
430
  }
420
431
 
421
432
  export async function runInTab(name: string, opts: RunInTabOptions): Promise<RunResultOk> {
@@ -871,22 +882,14 @@ async function raceWithTimeout<T>(
871
882
  }
872
883
 
873
884
  async function spawnTabWorker(): Promise<WorkerHandle> {
874
- try {
875
- const worker = isCompiledBinary()
876
- ? new Worker("./packages/coding-agent/src/tools/browser/tab-worker-entry.ts", { type: "module" })
877
- : new Worker(new URL("./tab-worker-entry.ts", import.meta.url).href, { type: "module" });
878
- return wrapBunWorker(worker);
879
- } catch (err) {
880
- logger.warn("Bun Worker spawn failed; using inline tab worker (no sync-loop guard)", {
881
- error: err instanceof Error ? err.message : String(err),
882
- });
883
- return spawnInlineWorker();
884
- }
885
+ const worker = isCompiledBinary()
886
+ ? new Worker("./packages/coding-agent/src/tools/browser/tab-worker-entry.ts", { type: "module" })
887
+ : new Worker(new URL("./tab-worker-entry.ts", import.meta.url).href, { type: "module" });
888
+ return wrapBunWorker(worker);
885
889
  }
886
890
 
887
891
  function wrapBunWorker(worker: Worker): WorkerHandle {
888
892
  return {
889
- mode: "worker",
890
893
  send(msg, transferList) {
891
894
  worker.postMessage(msg, { transfer: transferList ?? [] });
892
895
  },
@@ -896,9 +899,8 @@ function wrapBunWorker(worker: Worker): WorkerHandle {
896
899
  return () => worker.removeEventListener("message", wrap);
897
900
  },
898
901
  onError(handler) {
899
- const onError = (event: ErrorEvent): void => handler(errorFromWorkerEvent(event));
900
- const onMessageError = (event: MessageEvent): void =>
901
- handler(new ToolError(`Tab worker message error: ${String(event.data)}`));
902
+ const onError = (): void => handler(startupError("error"));
903
+ const onMessageError = (): void => handler(startupError("messageerror"));
902
904
  worker.addEventListener("error", onError);
903
905
  worker.addEventListener("messageerror", onMessageError);
904
906
  return () => {
@@ -906,69 +908,76 @@ function wrapBunWorker(worker: Worker): WorkerHandle {
906
908
  worker.removeEventListener("messageerror", onMessageError);
907
909
  };
908
910
  },
911
+ onClose(handler) {
912
+ const onClose = (): void => handler();
913
+ worker.addEventListener("close", onClose);
914
+ return () => worker.removeEventListener("close", onClose);
915
+ },
909
916
  async terminate() {
910
917
  worker.terminate();
911
918
  },
912
919
  };
913
920
  }
914
921
 
915
- /**
916
- * Inline fallback for environments where Bun cannot compile or spawn the worker
917
- * entry. This preserves normal browser behavior but cannot interrupt synchronous
918
- * infinite loops because user code runs on the main thread.
919
- */
920
- async function spawnInlineWorker(): Promise<WorkerHandle> {
921
- const hostListeners = new Set<(message: WorkerOutbound) => void>();
922
- const workerListeners = new Set<(message: WorkerInbound) => void>();
923
- const workerTransport: Transport = {
924
- send: msg =>
925
- queueMicrotask(() => {
926
- for (const listener of hostListeners) listener(msg as WorkerOutbound);
927
- }),
928
- onMessage: handler => {
929
- const typed = handler as (message: WorkerInbound) => void;
930
- workerListeners.add(typed);
931
- return () => workerListeners.delete(typed);
932
- },
933
- close: () => {},
934
- };
935
- const { WorkerCore } = await import("./tab-worker");
936
- new WorkerCore(workerTransport);
937
- return {
938
- mode: "inline",
939
- send: msg =>
940
- queueMicrotask(() => {
941
- for (const listener of workerListeners) listener(msg);
942
- }),
943
- onMessage: handler => {
944
- hostListeners.add(handler);
945
- return () => hostListeners.delete(handler);
946
- },
947
- onError: () => () => {},
948
- async terminate() {},
949
- };
950
- }
951
-
952
922
  async function initializeTabWorker(
953
923
  worker: WorkerHandle,
954
924
  payload: WorkerInitPayload,
955
925
  timeoutMs: number,
956
926
  ): Promise<ReadyInfo> {
957
- const { promise, resolve, reject } = Promise.withResolvers<ReadyInfo>();
927
+ type StartupPhase = "await-bootstrap" | "bootstrap-confirmed" | "await-init";
928
+ let phase: StartupPhase = "await-bootstrap";
929
+ const { promise: bootstrap, resolve: bootstrapReady, reject: rejectBootstrap } = Promise.withResolvers<void>();
930
+ const { promise: initialized, resolve: ready, reject: rejectInitialized } = Promise.withResolvers<ReadyInfo>();
931
+ void initialized.catch(() => undefined);
932
+ const fail = (stage: string): void => {
933
+ const error = startupError(stage);
934
+ rejectBootstrap(error);
935
+ rejectInitialized(error);
936
+ };
958
937
  const unlisten = worker.onMessage(msg => {
959
- if (msg.type === "ready") resolve(msg.info);
960
- else if (msg.type === "init-failed") reject(errorFromPayload(msg.error));
961
- else if (msg.type === "log") logWorkerMessage(msg);
962
- });
963
- const unlistenError = worker.onError(error => {
964
- reject(new ToolError(`Tab worker failed during startup: ${error.message}`));
938
+ if (msg.type === "log") {
939
+ logWorkerMessage(msg);
940
+ return;
941
+ }
942
+ if (phase === "await-bootstrap") {
943
+ if (msg.type === "bootstrap-ready" && msg.version === 1 && msg.mode === TAB_WORKER_MODE) {
944
+ phase = "bootstrap-confirmed";
945
+ bootstrapReady();
946
+ } else if (msg.type === "bootstrap-failed") {
947
+ fail("bootstrap");
948
+ } else if (msg.type === "closed") {
949
+ fail("protocol-closed");
950
+ } else {
951
+ fail("protocol-phase");
952
+ }
953
+ return;
954
+ }
955
+ if (phase !== "await-init") {
956
+ fail("protocol-phase");
957
+ return;
958
+ }
959
+ if (msg.type === "ready") {
960
+ ready(msg.info);
961
+ } else if (msg.type === "init-failed") {
962
+ fail("init");
963
+ } else if (msg.type === "closed") {
964
+ fail("protocol-closed");
965
+ } else {
966
+ fail("protocol-phase");
967
+ }
965
968
  });
969
+ const unlistenError = worker.onError(() => fail("error"));
970
+ const unlistenClose = worker.onClose(() => fail("physical-close"));
966
971
  try {
972
+ worker.send({ type: "bootstrap", version: 1, mode: TAB_WORKER_MODE });
973
+ await raceWithTimeout(bootstrap, timeoutMs, startupError("bootstrap-timeout").message);
974
+ phase = "await-init";
967
975
  worker.send({ type: "init", payload });
968
- return await raceWithTimeout(promise, timeoutMs, "Timed out initializing browser tab worker");
976
+ return await raceWithTimeout(initialized, timeoutMs, startupError("init-timeout").message);
969
977
  } finally {
970
978
  unlisten();
971
979
  unlistenError();
980
+ unlistenClose();
972
981
  }
973
982
  }
974
983
 
@@ -979,9 +988,3 @@ export function initializeTabWorkerForTest(
979
988
  ): Promise<ReadyInfo> {
980
989
  return initializeTabWorker(worker, payload, timeoutMs);
981
990
  }
982
-
983
- function errorFromWorkerEvent(event: ErrorEvent): Error {
984
- if (event.error instanceof Error) return event.error;
985
- if (event.message) return new Error(event.message);
986
- return new Error("Unknown tab worker error");
987
- }
@@ -3,19 +3,38 @@ import type { Transport, WorkerInbound, WorkerOutbound } from "./tab-protocol";
3
3
  import { WorkerCore } from "./tab-worker";
4
4
 
5
5
  if (!parentPort) throw new Error("tab-worker-entry: missing parentPort");
6
+ const port = parentPort;
7
+ port.once("message", bootstrap => {
8
+ if (!isBootstrap(bootstrap)) {
9
+ port.postMessage({ type: "bootstrap-failed", error: "Invalid tab worker bootstrap." });
10
+ port.close();
11
+ return;
12
+ }
6
13
 
7
- const transport: Transport = {
8
- send(msg, transferList) {
9
- parentPort!.postMessage(msg, transferList ?? []);
10
- },
11
- onMessage(handler) {
12
- const wrap = (message: unknown): void => handler(message as WorkerOutbound | WorkerInbound);
13
- parentPort!.on("message", wrap);
14
- return () => parentPort!.off("message", wrap);
15
- },
16
- close() {
17
- parentPort!.close();
18
- },
19
- };
14
+ const transport: Transport = {
15
+ send(msg, transferList) {
16
+ port.postMessage(msg, transferList ?? []);
17
+ },
18
+ onMessage(handler) {
19
+ const wrap = (message: unknown): void => handler(message as WorkerOutbound | WorkerInbound);
20
+ port.on("message", wrap);
21
+ return () => port.off("message", wrap);
22
+ },
23
+ close() {
24
+ port.close();
25
+ },
26
+ };
20
27
 
21
- new WorkerCore(transport);
28
+ new WorkerCore(transport);
29
+ port.postMessage({ type: "bootstrap-ready", version: 1, mode: "native-free" });
30
+ });
31
+
32
+ function isBootstrap(message: unknown): message is Extract<WorkerInbound, { type: "bootstrap" }> {
33
+ return (
34
+ typeof message === "object" &&
35
+ message !== null &&
36
+ (message as { type?: unknown }).type === "bootstrap" &&
37
+ (message as { version?: unknown }).version === 1 &&
38
+ (message as { mode?: unknown }).mode === "native-free"
39
+ );
40
+ }
@@ -0,0 +1,67 @@
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
+ import * as url from "node:url";
4
+
5
+ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
6
+ const TOP_LEVEL_INTERNAL_URL_PREFIXES = ["agent://", "artifact://", "rule://", "local://"] as const;
7
+
8
+ function normalizeAtPrefix(filePath: string): string {
9
+ if (!filePath.startsWith("@")) return filePath;
10
+
11
+ const withoutAt = filePath.slice(1);
12
+ if (
13
+ withoutAt.startsWith("/") ||
14
+ withoutAt === "~" ||
15
+ withoutAt.startsWith("~/") ||
16
+ path.win32.isAbsolute(withoutAt) ||
17
+ withoutAt.startsWith("agent://") ||
18
+ withoutAt.startsWith("artifact://") ||
19
+ withoutAt.startsWith("rule://") ||
20
+ withoutAt.startsWith("local:")
21
+ ) {
22
+ return withoutAt;
23
+ }
24
+
25
+ return filePath;
26
+ }
27
+
28
+ function expandPath(filePath: string): string {
29
+ const normalized = normalizeAtPrefix(filePath).replace(UNICODE_SPACES, " ");
30
+ if (normalized.toLowerCase().startsWith("file://")) {
31
+ try {
32
+ return expandTilde(url.fileURLToPath(normalized));
33
+ } catch {
34
+ return normalized;
35
+ }
36
+ }
37
+ return expandTilde(normalized);
38
+ }
39
+
40
+ function expandTilde(filePath: string): string {
41
+ const home = os.homedir();
42
+ if (filePath === "~") return home;
43
+ if (filePath.startsWith("~/") || filePath.startsWith("~\\")) return home + filePath.slice(1);
44
+ if (filePath.startsWith("~")) return path.join(home, filePath.slice(1));
45
+ return filePath;
46
+ }
47
+
48
+ function normalizeLocalScheme(filePath: string): string {
49
+ return filePath.replace(/^(local:)\/(?!\/)/, "$1//");
50
+ }
51
+
52
+ /** Resolve filesystem-only worker inputs without evaluating the broad path utility module. */
53
+ export function resolveTabWorkerPath(filePath: string, cwd: string): string {
54
+ const normalized = normalizeLocalScheme(filePath);
55
+ const expanded = expandPath(normalized);
56
+ const expandedAndNormalized = normalizeLocalScheme(expanded);
57
+ for (const prefix of TOP_LEVEL_INTERNAL_URL_PREFIXES) {
58
+ if (expandedAndNormalized.startsWith(prefix)) {
59
+ throw new Error(
60
+ `Path "${normalized}" uses internal scheme "${prefix}" and must be resolved through the proper protocol handler, not as a filesystem path.`,
61
+ );
62
+ }
63
+ }
64
+
65
+ if (/^\/+$/u.test(expanded)) return cwd;
66
+ return path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded);
67
+ }
@@ -0,0 +1,87 @@
1
+ import { isCompiledBinary } from "@gajae-code/utils/env";
2
+ import type { WorkerInbound, WorkerOutbound } from "./tab-protocol";
3
+
4
+ interface SmokeWorkerHandle {
5
+ postMessage(message: WorkerInbound): void;
6
+ addEventListener(type: string, listener: EventListener): void;
7
+ removeEventListener(type: string, listener: EventListener): void;
8
+ terminate(): void;
9
+ }
10
+
11
+ const SMOKE_TIMEOUT_MS = 5_000;
12
+ const TAB_WORKER_MODE = "native-free";
13
+
14
+ function smokeStartupError(stage: string): Error {
15
+ return new Error(
16
+ `Tab worker startup failed (stage=${stage}, mode=${TAB_WORKER_MODE}, platform=${process.platform}).`,
17
+ );
18
+ }
19
+
20
+ /**
21
+ * Starts the real tab worker entry without connecting to a browser, then proves
22
+ * its bootstrap and shutdown protocol. The compiled branch deliberately keeps
23
+ * a static literal for Bun's --compile worker discovery.
24
+ */
25
+ export async function smokeTestTabWorker(timeoutMs = SMOKE_TIMEOUT_MS): Promise<void> {
26
+ const worker = isCompiledBinary()
27
+ ? new Worker("./packages/coding-agent/src/tools/browser/tab-worker-entry.ts", { type: "module" })
28
+ : new Worker(new URL("./tab-worker-entry.ts", import.meta.url).href, { type: "module" });
29
+ await smokeTestTabWorkerWithWorkerForTest(worker, timeoutMs);
30
+ }
31
+
32
+ /** Test-only: verify the smoke handshake against a controlled worker transport. */
33
+ export async function smokeTestTabWorkerWithWorkerForTest(worker: SmokeWorkerHandle, timeoutMs: number): Promise<void> {
34
+ type SmokePhase = "await-bootstrap" | "await-closed";
35
+ let phase: SmokePhase = "await-bootstrap";
36
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
37
+ let settled = false;
38
+ let timeout: NodeJS.Timeout | undefined;
39
+
40
+ const cleanup = (): void => {
41
+ if (timeout) clearTimeout(timeout);
42
+ worker.removeEventListener("message", onMessage);
43
+ worker.removeEventListener("error", onError);
44
+ worker.removeEventListener("messageerror", onMessageError);
45
+ };
46
+ const succeed = (): void => {
47
+ if (settled) return;
48
+ settled = true;
49
+ resolve();
50
+ };
51
+ const fail = (stage: string): void => {
52
+ if (settled) return;
53
+ settled = true;
54
+ reject(smokeStartupError(stage));
55
+ };
56
+ const onMessage: EventListener = event => {
57
+ const message = (event as MessageEvent<WorkerOutbound>).data;
58
+ if (message.type === "log") return;
59
+ if (phase === "await-bootstrap") {
60
+ if (message.type === "bootstrap-ready" && message.version === 1 && message.mode === TAB_WORKER_MODE) {
61
+ phase = "await-closed";
62
+ worker.postMessage({ type: "close" } satisfies WorkerInbound);
63
+ } else if (message.type === "bootstrap-failed") {
64
+ fail("bootstrap");
65
+ } else {
66
+ fail("protocol-phase");
67
+ }
68
+ return;
69
+ }
70
+ if (message.type === "closed") succeed();
71
+ else fail("protocol-phase");
72
+ };
73
+ const onError: EventListener = () => fail("error");
74
+ const onMessageError: EventListener = () => fail("messageerror");
75
+
76
+ worker.addEventListener("message", onMessage);
77
+ worker.addEventListener("error", onError);
78
+ worker.addEventListener("messageerror", onMessageError);
79
+ timeout = setTimeout(() => fail("smoke-timeout"), timeoutMs);
80
+ try {
81
+ worker.postMessage({ type: "bootstrap", version: 1, mode: TAB_WORKER_MODE } satisfies WorkerInbound);
82
+ await promise;
83
+ } finally {
84
+ cleanup();
85
+ worker.terminate();
86
+ }
87
+ }
@@ -2,7 +2,8 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
 
5
- import { Snowflake, untilAborted } from "@gajae-code/utils";
5
+ import { untilAborted } from "@gajae-code/utils/abortable";
6
+ import { Snowflake } from "@gajae-code/utils/snowflake";
6
7
  import type { HTMLElement } from "linkedom";
7
8
  import type {
8
9
  Browser,
@@ -17,8 +18,6 @@ import type {
17
18
  import { JsRuntime, type RuntimeHooks } from "../../eval/js/shared/runtime";
18
19
  import type { JsDisplayOutput } from "../../eval/js/shared/types";
19
20
  import { resizeImage } from "../../utils/image-resize";
20
- import { resolveToCwd } from "../path-utils";
21
- import { formatScreenshot } from "../render-utils";
22
21
  import { ToolAbortError, ToolError, throwIfAborted } from "../tool-errors";
23
22
  import {
24
23
  applyStealthPatches,
@@ -28,6 +27,7 @@ import {
28
27
  loadPuppeteerInWorker,
29
28
  } from "./launch";
30
29
  import { extractReadableFromHtml, type ReadableFormat, type ReadableResult } from "./readable";
30
+ import { formatScreenshot } from "./screenshot-format";
31
31
  import type {
32
32
  Observation,
33
33
  ObservationEntry,
@@ -41,6 +41,7 @@ import type {
41
41
  WorkerInbound,
42
42
  WorkerInitPayload,
43
43
  } from "./tab-protocol";
44
+ import { resolveTabWorkerPath } from "./tab-worker-path-resolver";
44
45
 
45
46
  declare global {
46
47
  interface Element extends HTMLElement {}
@@ -813,7 +814,8 @@ export class WorkerCore {
813
814
  { type: "image", data: buffer.toBase64(), mimeType: "image/png" },
814
815
  { maxWidth: 1024, maxHeight: 1024, maxBytes: 150 * 1024, jpegQuality: 70 },
815
816
  );
816
- const explicitPath = opts.save ? resolveToCwd(opts.save, session.cwd) : undefined;
817
+ const explicitPath = opts.save ? resolveTabWorkerPath(opts.save, session.cwd) : undefined;
818
+
817
819
  const dest =
818
820
  explicitPath ??
819
821
  (session.browserScreenshotDir
@@ -950,7 +952,7 @@ export class WorkerCore {
950
952
  page.locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle(),
951
953
  )) as ElementHandle;
952
954
  try {
953
- const absolute = filePaths.map(filePath => resolveToCwd(filePath, session.cwd));
955
+ const absolute = filePaths.map(filePath => resolveTabWorkerPath(filePath, session.cwd));
954
956
  const upload = handle as unknown as { uploadFile: (...paths: string[]) => Promise<void> };
955
957
  const tagName = (await untilAborted(signal, () =>
956
958
  handle.evaluate(el => (el as unknown as { tagName: string }).tagName),
@@ -605,6 +605,10 @@ async function spillLargeResultToArtifact(
605
605
  toolName: string,
606
606
  context: AgentToolContext | undefined,
607
607
  ): Promise<AgentToolResult> {
608
+ if (toolName === "read" && (result.details as { spillEligible?: boolean } | undefined)?.spillEligible !== true) {
609
+ return result;
610
+ }
611
+
608
612
  const artifactCapability = artifactCapabilityForContext(context);
609
613
  if (!artifactCapability) return result;
610
614
  const { threshold, readThreshold, tailBytes, tailLines, headBytes } = getSpillConfig(context?.settings);
@@ -709,8 +713,8 @@ async function spillLargeResultToArtifact(
709
713
  /**
710
714
  * Absolute inline-size backstop enforced after {@link spillLargeResultToArtifact}.
711
715
  *
712
- * The threshold-based spill above has escape hatches: it early-returns for the
713
- * `read` tool and for results that already carry an `artifactId` (a tool may set
716
+ * The threshold-based spill above has escape hatches: it skips ineligible `read`
717
+ * results and results that already carry an `artifactId` (a tool may set
714
718
  * partial truncation meta yet still emit oversized inline text). This backstop
715
719
  * closes those gaps: when `tools.maxInlineResultBytes` is configured (> 0), any
716
720
  * final result whose inline text exceeds the cap is force-saved to an artifact