jeopi-cli 16.4.2 → 16.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/cli.js +3033 -2991
  3. package/dist/types/advisor/advise-tool.d.ts +5 -1
  4. package/dist/types/advisor/config.d.ts +4 -3
  5. package/dist/types/advisor/runtime.d.ts +15 -0
  6. package/dist/types/cli/args.d.ts +2 -0
  7. package/dist/types/cli/update-cli.d.ts +4 -1
  8. package/dist/types/cli/usage-error.d.ts +4 -0
  9. package/dist/types/commit/agentic/agent.d.ts +1 -0
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/model-resolver.d.ts +7 -1
  12. package/dist/types/config/models-config.d.ts +1 -1
  13. package/dist/types/config/settings-schema.d.ts +3 -13
  14. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  15. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  16. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  17. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  18. package/dist/types/internal-urls/memory-protocol.d.ts +6 -6
  19. package/dist/types/internal-urls/types.d.ts +7 -0
  20. package/dist/types/irc/bus.d.ts +4 -0
  21. package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
  22. package/dist/types/modes/components/move-overlay.d.ts +1 -1
  23. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  24. package/dist/types/modes/components/welcome.d.ts +4 -0
  25. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  26. package/dist/types/modes/orchestrate.d.ts +1 -1
  27. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  28. package/dist/types/modes/rpc/rpc-mode.d.ts +27 -7
  29. package/dist/types/modes/ultrathink.d.ts +1 -1
  30. package/dist/types/modes/utils/transcript-render-helpers.d.ts +9 -0
  31. package/dist/types/modes/workflow.d.ts +1 -1
  32. package/dist/types/session/agent-session.d.ts +6 -0
  33. package/dist/types/session/auth-broker-config.d.ts +2 -2
  34. package/dist/types/tools/bash-skill-urls.d.ts +3 -2
  35. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
  36. package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
  37. package/dist/types/tools/browser/launch.d.ts +1 -0
  38. package/dist/types/tools/browser/run-cancellation.d.ts +17 -2
  39. package/dist/types/tools/browser/run-output.d.ts +25 -0
  40. package/dist/types/tools/browser/tab-worker.d.ts +22 -0
  41. package/dist/types/utils/changelog.d.ts +35 -1
  42. package/package.json +12 -12
  43. package/src/advisor/__tests__/advisor.test.ts +13 -0
  44. package/src/advisor/__tests__/config.test.ts +36 -0
  45. package/src/advisor/advise-tool.ts +6 -1
  46. package/src/advisor/config.ts +11 -8
  47. package/src/advisor/runtime.ts +127 -0
  48. package/src/autolearn/controller.ts +15 -3
  49. package/src/cli/args.ts +12 -0
  50. package/src/cli/flag-tables.ts +21 -7
  51. package/src/cli/update-cli.ts +63 -13
  52. package/src/cli/usage-error.ts +7 -0
  53. package/src/commands/acp.ts +11 -2
  54. package/src/commands/launch.ts +12 -3
  55. package/src/commit/agentic/agent.ts +4 -0
  56. package/src/commit/agentic/index.ts +46 -21
  57. package/src/config/config-file.ts +30 -12
  58. package/src/config/model-registry.ts +7 -7
  59. package/src/config/model-resolver.ts +24 -6
  60. package/src/config/models-config.ts +1 -1
  61. package/src/config/settings-schema.ts +3 -15
  62. package/src/edit/index.ts +10 -76
  63. package/src/edit/renderer.ts +13 -10
  64. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  65. package/src/eval/__tests__/bridge-timeout.test.ts +60 -0
  66. package/src/eval/executor-base.ts +84 -7
  67. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  68. package/src/eval/py/tool-bridge.ts +13 -14
  69. package/src/exec/non-interactive-env.ts +0 -1
  70. package/src/export/ttsr.ts +0 -3
  71. package/src/extensibility/custom-tools/loader.ts +70 -37
  72. package/src/extensibility/custom-tools/types.ts +3 -0
  73. package/src/extensibility/extensions/runner.ts +3 -0
  74. package/src/extensibility/extensions/types.ts +3 -0
  75. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +9 -1
  76. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +30 -3
  77. package/src/extensibility/plugins/manager.ts +21 -0
  78. package/src/internal-urls/docs-index.generated.txt +1 -1
  79. package/src/internal-urls/memory-protocol.ts +13 -8
  80. package/src/internal-urls/skill-protocol.ts +1 -1
  81. package/src/internal-urls/types.ts +7 -0
  82. package/src/irc/bus.ts +48 -18
  83. package/src/lsp/client.ts +7 -1
  84. package/src/lsp/deferred-diagnostics.ts +66 -0
  85. package/src/main.ts +13 -13
  86. package/src/mcp/tool-bridge.ts +57 -6
  87. package/src/mcp/transports/stdio.test.ts +45 -1
  88. package/src/mcp/transports/stdio.ts +6 -3
  89. package/src/modes/acp/acp-agent.ts +73 -2
  90. package/src/modes/acp/acp-event-mapper.ts +5 -0
  91. package/src/modes/acp/acp-mode.ts +11 -0
  92. package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
  93. package/src/modes/components/advisor-config.ts +15 -7
  94. package/src/modes/components/chat-transcript-builder.ts +31 -11
  95. package/src/modes/components/move-overlay.ts +2 -3
  96. package/src/modes/components/plan-review-overlay.ts +7 -0
  97. package/src/modes/components/tips.txt +1 -1
  98. package/src/modes/components/welcome.ts +13 -14
  99. package/src/modes/controllers/command-controller.ts +8 -10
  100. package/src/modes/controllers/event-controller.ts +58 -42
  101. package/src/modes/controllers/input-controller.ts +9 -6
  102. package/src/modes/controllers/selector-controller.ts +3 -2
  103. package/src/modes/controllers/tan-command-controller.ts +40 -1
  104. package/src/modes/interactive-mode.ts +13 -0
  105. package/src/modes/magic-keyword-boundary.ts +23 -0
  106. package/src/modes/orchestrate.ts +6 -5
  107. package/src/modes/print-mode.ts +9 -0
  108. package/src/modes/rpc/host-tools.ts +13 -0
  109. package/src/modes/rpc/rpc-mode.ts +130 -56
  110. package/src/modes/ultrathink.ts +6 -5
  111. package/src/modes/utils/transcript-render-helpers.ts +44 -0
  112. package/src/modes/utils/ui-helpers.ts +16 -1
  113. package/src/modes/workflow.ts +5 -4
  114. package/src/prompts/system/eager-task.md +2 -2
  115. package/src/prompts/system/tan-context-switch.md +17 -0
  116. package/src/prompts/tools/browser.md +5 -5
  117. package/src/sdk.ts +3 -0
  118. package/src/session/agent-session.ts +267 -36
  119. package/src/session/auth-broker-config.ts +4 -4
  120. package/src/session/snapcompact-inline.ts +3 -19
  121. package/src/session/streaming-output.ts +40 -12
  122. package/src/slash-commands/builtin-registry.ts +8 -8
  123. package/src/task/executor.ts +8 -0
  124. package/src/task/render.ts +51 -44
  125. package/src/tools/bash-skill-urls.ts +7 -4
  126. package/src/tools/bash.ts +30 -33
  127. package/src/tools/browser/cmux/cmux-tab.ts +49 -50
  128. package/src/tools/browser/cmux/rpc.ts +50 -0
  129. package/src/tools/browser/launch.ts +8 -4
  130. package/src/tools/browser/run-cancellation.ts +44 -5
  131. package/src/tools/browser/run-output.ts +76 -0
  132. package/src/tools/browser/tab-worker.ts +280 -133
  133. package/src/tools/eval-render.ts +16 -14
  134. package/src/tools/glob.ts +20 -6
  135. package/src/tools/grep.ts +1 -0
  136. package/src/tools/image-gen.ts +3 -4
  137. package/src/tools/irc.ts +27 -13
  138. package/src/tools/path-utils.ts +6 -1
  139. package/src/tools/read.ts +14 -7
  140. package/src/tools/write.ts +22 -5
  141. package/src/tools/yield.ts +29 -1
  142. package/src/utils/changelog.ts +106 -9
  143. package/src/utils/git.ts +0 -1
  144. package/src/utils/title-generator.ts +72 -7
  145. package/src/web/search/index.ts +14 -6
  146. package/src/web/search/providers/perplexity.ts +18 -2
  147. package/dist/types/tools/bash-command-fixup.d.ts +0 -11
  148. package/src/tools/bash-command-fixup.ts +0 -37
@@ -5,7 +5,7 @@ import { getMemoryRoot } from "../memories";
5
5
  import { AgentRegistry } from "../registry/agent-registry";
6
6
  import { buildDirectoryResource } from "./filesystem-resource";
7
7
  import { validateRelativePath } from "./skill-protocol";
8
- import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
8
+ import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, UrlCompletion } from "./types";
9
9
 
10
10
  const DEFAULT_MEMORY_FILE = "memory_summary.md";
11
11
  const MEMORY_NAMESPACE = "root";
@@ -27,6 +27,11 @@ export function memoryRootsFromRegistry(): string[] {
27
27
  return roots;
28
28
  }
29
29
 
30
+ function memoryRootsForContext(context?: ResolveContext): string[] {
31
+ if (context?.cwd) return [getMemoryRoot(getAgentDir(), context.cwd)];
32
+ return memoryRootsFromRegistry();
33
+ }
34
+
30
35
  function ensureWithinRoot(targetPath: string, rootPath: string): void {
31
36
  if (targetPath !== rootPath && !targetPath.startsWith(`${rootPath}${path.sep}`)) {
32
37
  throw new Error("memory:// URL escapes memory root");
@@ -127,16 +132,16 @@ async function tryResolveInRoot(url: InternalUrl, memoryRoot: string): Promise<I
127
132
  /**
128
133
  * Protocol handler for memory:// URLs.
129
134
  *
130
- * Walks every active session's memory root. Worktree-based subagents have
131
- * their own root; first one containing the file wins. Parent and subagent
132
- * sharing a cwd see the same file regardless of order.
135
+ * Resolves file-backed roots against the calling session cwd when provided.
136
+ * Contextless callers fall back to the live-session registry for legacy
137
+ * cross-session lookups.
133
138
  */
134
139
  export class MemoryProtocolHandler implements ProtocolHandler {
135
140
  readonly scheme = "memory";
136
141
  readonly immutable = true;
137
142
 
138
- async resolve(url: InternalUrl): Promise<InternalResource> {
139
- const roots = memoryRootsFromRegistry();
143
+ async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
144
+ const roots = memoryRootsForContext(context);
140
145
 
141
146
  if (roots.length === 0) {
142
147
  throw new Error(
@@ -166,8 +171,8 @@ export class MemoryProtocolHandler implements ProtocolHandler {
166
171
  throw new Error(`Memory file not found: ${url.href}`);
167
172
  }
168
173
 
169
- async complete(): Promise<UrlCompletion[]> {
170
- if (memoryRootsFromRegistry().length === 0) return [];
174
+ async complete(_query?: string, context?: ResolveContext): Promise<UrlCompletion[]> {
175
+ if (memoryRootsForContext(context).length === 0) return [];
171
176
  return [{ value: MEMORY_NAMESPACE, description: "Project memory summary" }];
172
177
  }
173
178
  }
@@ -72,7 +72,7 @@ export class SkillProtocolHandler implements ProtocolHandler {
72
72
  throw new Error("Path traversal is not allowed");
73
73
  }
74
74
  } else {
75
- targetPath = skill.filePath;
75
+ targetPath = context?.pathOnly === true ? skill.baseDir : skill.filePath;
76
76
  }
77
77
 
78
78
  let stats: fsTypes.Stats;
@@ -107,6 +107,13 @@ export interface ResolveContext {
107
107
  * reject directory resources, so they never need the listing.
108
108
  */
109
109
  skipDirectoryListing?: boolean;
110
+ /**
111
+ * When set, handlers that support both a "document" and a "directory" form
112
+ * for the same bare URL (e.g. `skill://name` vs `skill://name/SKILL.md`)
113
+ * resolve to the directory instead of the document. Used by `glob`'s
114
+ * internal-URL find path, which needs a directory to walk.
115
+ */
116
+ pathOnly?: boolean;
110
117
  }
111
118
 
112
119
  /**
package/src/irc/bus.ts CHANGED
@@ -174,7 +174,7 @@ export class IrcBus {
174
174
  filter: { from?: string },
175
175
  timeoutMs: number,
176
176
  signal?: AbortSignal,
177
- options?: { drainPending?: boolean },
177
+ options?: { drainPending?: boolean; liveness?: { registry: AgentRegistry; senderId: string } },
178
178
  ): Promise<IrcMessage | null> {
179
179
  if (signal?.aborted) {
180
180
  throw signal.reason instanceof Error ? signal.reason : new Error("IRC wait aborted");
@@ -189,35 +189,49 @@ export class IrcBus {
189
189
  const { promise, resolve, reject } = Promise.withResolvers<IrcMessage | null>();
190
190
  let timer: NodeJS.Timeout | undefined;
191
191
  let onAbort: (() => void) | undefined;
192
+ let unsubscribeLiveness: (() => void) | undefined;
192
193
 
193
- const waiter: IrcWaiter = {
194
- from: filter.from,
195
- resolve: msg => {
196
- cleanup();
197
- resolve(msg);
198
- },
199
- cancel: () => {
200
- cleanup();
201
- },
194
+ const liveness = options?.liveness;
195
+ const livenessReason = filter.from
196
+ ? `IRC wait aborted: agent "${filter.from}" is not running`
197
+ : "IRC wait aborted: no running peers remain";
198
+
199
+ const settle = (
200
+ outcome: { kind: "message"; msg: IrcMessage } | { kind: "timeout" } | { kind: "abort"; error: Error },
201
+ ): void => {
202
+ cleanup();
203
+ if (outcome.kind === "message") {
204
+ resolve(outcome.msg);
205
+ } else if (outcome.kind === "timeout") {
206
+ resolve(null);
207
+ } else {
208
+ reject(outcome.error);
209
+ }
202
210
  };
211
+
203
212
  const cleanup = (): void => {
204
213
  this.#removeWaiter(agentId, waiter);
205
214
  clearTimeout(timer);
206
215
  if (signal && onAbort) signal.removeEventListener("abort", onAbort);
216
+ unsubscribeLiveness?.();
217
+ };
218
+
219
+ const waiter: IrcWaiter = {
220
+ from: filter.from,
221
+ resolve: msg => settle({ kind: "message", msg }),
222
+ cancel: () => cleanup(),
207
223
  };
208
224
 
209
225
  if (signal) {
210
- onAbort = () => {
211
- cleanup();
212
- reject(signal.reason instanceof Error ? signal.reason : new Error("IRC wait aborted"));
213
- };
226
+ onAbort = () =>
227
+ settle({
228
+ kind: "abort",
229
+ error: signal.reason instanceof Error ? signal.reason : new Error("IRC wait aborted"),
230
+ });
214
231
  signal.addEventListener("abort", onAbort, { once: true });
215
232
  }
216
233
  if (timeoutMs > 0) {
217
- timer = setTimeout(() => {
218
- cleanup();
219
- resolve(null);
220
- }, timeoutMs);
234
+ timer = setTimeout(() => settle({ kind: "timeout" }), timeoutMs);
221
235
  timer.unref?.();
222
236
  }
223
237
 
@@ -227,6 +241,22 @@ export class IrcBus {
227
241
  this.#waiters.set(agentId, waiters);
228
242
  }
229
243
  waiters.push(waiter);
244
+
245
+ if (liveness) {
246
+ const { registry, senderId } = liveness;
247
+ const hasRunningSender = (from?: string): boolean =>
248
+ registry.listVisibleTo(senderId).some(ref => ref.status === "running" && (!from || ref.id === from));
249
+ const check = filter.from ? () => hasRunningSender(filter.from) : () => hasRunningSender();
250
+ unsubscribeLiveness = registry.onChange(() => {
251
+ if (!check()) {
252
+ settle({ kind: "abort", error: new Error(livenessReason) });
253
+ }
254
+ });
255
+ if (!check()) {
256
+ settle({ kind: "abort", error: new Error(livenessReason) });
257
+ }
258
+ }
259
+
230
260
  return promise;
231
261
  }
232
262
 
package/src/lsp/client.ts CHANGED
@@ -737,8 +737,14 @@ export async function getOrCreateClient(
737
737
 
738
738
  client.serverCapabilities = initResult.capabilities as LspClient["serverCapabilities"];
739
739
 
740
- // Send initialized notification
740
+ // Finish the initialize handshake before publishing the client as ready.
741
741
  await sendNotification(client, "initialized", {}, signal);
742
+ await sendNotification(
743
+ client,
744
+ "workspace/didChangeConfiguration",
745
+ { settings: config.settings ?? {} },
746
+ signal,
747
+ );
742
748
 
743
749
  client.status = "ready";
744
750
  // Publish only after init succeeds: pre-init clients are reachable
@@ -0,0 +1,66 @@
1
+ import type { DeferredDiagnosticsEntry, ToolSession } from "../tools";
2
+ import { getDiagnosticsLedger } from "./diagnostics-ledger";
3
+ import type { FileDiagnosticsResult, WritethroughDeferredHandle } from "./index";
4
+
5
+ /** Coordinates late LSP diagnostics for one mutation tool instance. */
6
+ export class DeferredDiagnostics {
7
+ readonly #pendingFetches = new Map<string, AbortController>();
8
+ readonly #fallbackVersions = new Map<string, number>();
9
+
10
+ constructor(
11
+ private readonly session: ToolSession,
12
+ private readonly deduplicate: boolean,
13
+ ) {}
14
+
15
+ /** Begin a file mutation and return the handle consumed by LSP writethrough. */
16
+ begin(path: string): WritethroughDeferredHandle {
17
+ const existing = this.#pendingFetches.get(path);
18
+ if (existing) {
19
+ existing.abort();
20
+ this.#pendingFetches.delete(path);
21
+ }
22
+
23
+ const controller = new AbortController();
24
+ const mutationVersion = this.#bumpVersion(path);
25
+ return {
26
+ onDeferredDiagnostics: diagnostics => {
27
+ this.#pendingFetches.delete(path);
28
+ this.#inject(path, diagnostics, mutationVersion);
29
+ },
30
+ signal: controller.signal,
31
+ finalize: diagnostics => {
32
+ if (!diagnostics) {
33
+ this.#pendingFetches.set(path, controller);
34
+ } else {
35
+ controller.abort();
36
+ }
37
+ },
38
+ };
39
+ }
40
+
41
+ #inject(path: string, diagnostics: FileDiagnosticsResult, mutationVersion: number): void {
42
+ const effective = this.deduplicate ? getDiagnosticsLedger(this.session).reduce(path, diagnostics) : diagnostics;
43
+ if (this.deduplicate && effective.messages.length === 0) return;
44
+
45
+ const entry: DeferredDiagnosticsEntry = {
46
+ path,
47
+ summary: effective.summary ?? "",
48
+ messages: effective.messages ?? [],
49
+ errored: effective.errored,
50
+ isStale: () => this.#version(path) !== mutationVersion,
51
+ };
52
+ this.session.queueDeferredDiagnostics?.(entry);
53
+ }
54
+
55
+ #bumpVersion(path: string): number {
56
+ if (this.session.bumpFileMutationVersion) return this.session.bumpFileMutationVersion(path);
57
+ const next = (this.#fallbackVersions.get(path) ?? 0) + 1;
58
+ this.#fallbackVersions.set(path, next);
59
+ return next;
60
+ }
61
+
62
+ #version(path: string): number {
63
+ if (this.session.getFileMutationVersion) return this.session.getFileMutationVersion(path);
64
+ return this.#fallbackVersions.get(path) ?? 0;
65
+ }
66
+ }
package/src/main.ts CHANGED
@@ -80,9 +80,10 @@ import { concreteThinkingLevel, parseConfiguredThinkingLevel } from "./thinking"
80
80
  import type { LspStartupServerInfo } from "./tools";
81
81
  import {
82
82
  getChangelogPath,
83
- getNewEntries,
84
83
  parseChangelog,
84
+ parseChangelogVersion,
85
85
  readLastChangelogVersion,
86
+ selectStartupChangelog,
86
87
  writeLastChangelogVersion,
87
88
  } from "./utils/changelog";
88
89
  import { EventBus } from "./utils/event-bus";
@@ -641,6 +642,11 @@ async function getChangelogForDisplay(parsed: Args): Promise<string | undefined>
641
642
  }
642
643
 
643
644
  const lastVersion = await readLastChangelogVersion();
645
+ const parsedLastVersion = parseChangelogVersion(lastVersion);
646
+ if (!parsedLastVersion) {
647
+ await writeLastChangelogVersion(VERSION);
648
+ return undefined;
649
+ }
644
650
  if (lastVersion === VERSION) {
645
651
  // Steady state: user already saw the current version's changelog. Skip the file read + parse.
646
652
  return undefined;
@@ -648,18 +654,12 @@ async function getChangelogForDisplay(parsed: Args): Promise<string | undefined>
648
654
 
649
655
  const changelogPath = getChangelogPath();
650
656
  const entries = await parseChangelog(changelogPath);
651
-
652
- if (!lastVersion) {
653
- if (entries.length > 0) {
654
- await writeLastChangelogVersion(VERSION);
655
- return entries.map(e => e.content).join("\n\n");
656
- }
657
- } else {
658
- const newEntries = getNewEntries(entries, lastVersion);
659
- if (newEntries.length > 0) {
660
- await writeLastChangelogVersion(VERSION);
661
- return newEntries.map(e => e.content).join("\n\n");
662
- }
657
+ const startupChangelog = selectStartupChangelog(entries, lastVersion, VERSION);
658
+ if (startupChangelog.persistCurrentVersion) {
659
+ await writeLastChangelogVersion(VERSION);
660
+ }
661
+ if (startupChangelog.markdown) {
662
+ return startupChangelog.markdown;
663
663
  }
664
664
 
665
665
  return undefined;
@@ -15,8 +15,10 @@ import type {
15
15
  CustomToolResult,
16
16
  RenderResultOptions,
17
17
  } from "../extensibility/custom-tools/types";
18
+ import { resolveLocalUrlToFile } from "../internal-urls/local-protocol";
18
19
  import type { Theme } from "../modes/theme/theme";
19
20
  import type { OutputMeta } from "../tools/output-meta";
21
+ import { normalizeLocalScheme } from "../tools/path-utils";
20
22
  import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
21
23
  import { callTool } from "./client";
22
24
  import { renderMCPCall, renderMCPResult } from "./render";
@@ -104,13 +106,62 @@ function stripHarnessIntent(args: MCPToolArgs, inputSchema: MCPToolDefinition["i
104
106
  return rest;
105
107
  }
106
108
 
109
+ async function resolveOutboundLocalUrlArgs(
110
+ value: unknown,
111
+ context: CustomToolContext,
112
+ seen: WeakSet<object> = new WeakSet(),
113
+ ): Promise<unknown> {
114
+ if (typeof value === "string") {
115
+ const normalized = normalizeLocalScheme(value);
116
+ if (!normalized.startsWith("local://")) return value;
117
+ const localFile = await resolveLocalUrlToFile(normalized, {
118
+ cwd: context.sessionManager?.getCwd?.(),
119
+ settings: context.settings,
120
+ localProtocolOptions: context.localProtocolOptions,
121
+ });
122
+ return localFile?.path ?? value;
123
+ }
124
+ if (typeof value !== "object" || value === null) return value;
125
+ if (seen.has(value)) return value;
126
+ seen.add(value);
127
+
128
+ if (Array.isArray(value)) {
129
+ let resolved: unknown[] | undefined;
130
+ for (let index = 0; index < value.length; index++) {
131
+ const item = value[index];
132
+ const next = await resolveOutboundLocalUrlArgs(item, context, seen);
133
+ if (next === item && !resolved) continue;
134
+ resolved ??= value.slice();
135
+ resolved[index] = next;
136
+ }
137
+ return resolved ?? value;
138
+ }
139
+
140
+ const input = value as Record<string, unknown>;
141
+ let resolved: Record<string, unknown> | undefined;
142
+ for (const key in input) {
143
+ const item = input[key];
144
+ const next = await resolveOutboundLocalUrlArgs(item, context, seen);
145
+ if (next === item && !resolved) continue;
146
+ resolved ??= { ...input };
147
+ resolved[key] = next;
148
+ }
149
+ return resolved ?? value;
150
+ }
151
+
107
152
  /**
108
153
  * Normalize raw tool params into the outbound `tools/call` arguments: strip
109
- * the harness intent field, then drop optional empty placeholders the server
110
- * declares but doesn't require.
154
+ * the harness intent field, drop optional empty placeholders the server
155
+ * declares but doesn't require, then translate session-local files to paths
156
+ * external MCP servers can read.
111
157
  */
112
- function prepareOutboundArgs(params: unknown, inputSchema: MCPToolDefinition["inputSchema"]): MCPToolArgs {
113
- return omitUnusedOptionalArgs(stripHarnessIntent(normalizeToolArgs(params), inputSchema), inputSchema);
158
+ async function prepareOutboundArgs(
159
+ params: unknown,
160
+ inputSchema: MCPToolDefinition["inputSchema"],
161
+ context: CustomToolContext,
162
+ ): Promise<MCPToolArgs> {
163
+ const args = omitUnusedOptionalArgs(stripHarnessIntent(normalizeToolArgs(params), inputSchema), inputSchema);
164
+ return (await resolveOutboundLocalUrlArgs(args, context)) as MCPToolArgs;
114
165
  }
115
166
 
116
167
  /** Details included in MCP tool results for rendering */
@@ -316,7 +367,7 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
316
367
  signal?: AbortSignal,
317
368
  ): Promise<CustomToolResult<MCPToolDetails>> {
318
369
  throwIfAborted(signal);
319
- const args = prepareOutboundArgs(params, this.tool.inputSchema);
370
+ const args = await prepareOutboundArgs(params, this.tool.inputSchema, _ctx);
320
371
  const provider = this.connection._source?.provider;
321
372
  const providerName = this.connection._source?.providerName;
322
373
 
@@ -415,7 +466,7 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
415
466
  signal?: AbortSignal,
416
467
  ): Promise<CustomToolResult<MCPToolDetails>> {
417
468
  throwIfAborted(signal);
418
- const args = prepareOutboundArgs(params, this.tool.inputSchema);
469
+ const args = await prepareOutboundArgs(params, this.tool.inputSchema, _ctx);
419
470
  const provider = this.#fallbackProvider;
420
471
  const providerName = this.#fallbackProviderName;
421
472
 
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "bun:test";
1
+ import { describe, expect, it, spyOn } from "bun:test";
2
2
 
3
3
  import { resolveStdioSpawnCommand, StdioTransport } from "./stdio";
4
4
 
@@ -44,6 +44,50 @@ describe("resolveStdioSpawnCommand", () => {
44
44
  });
45
45
  });
46
46
 
47
+ describe("StdioTransport.connect", () => {
48
+ it("passes argv as Bun.spawn's first argument and process options as the second", async () => {
49
+ const cwd = process.cwd();
50
+ const envValue = "stdio-spawn-shape";
51
+ const argv = [process.execPath, "-e", "process.exit(0)"];
52
+ const transport = new StdioTransport({
53
+ command: argv[0],
54
+ args: argv.slice(1),
55
+ cwd,
56
+ env: {
57
+ JEOPI_STDIO_SPAWN_SHAPE: envValue,
58
+ },
59
+ });
60
+ const spawnSpy = spyOn(Bun, "spawn");
61
+
62
+ try {
63
+ await transport.connect();
64
+
65
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
66
+ const call = spawnSpy.mock.calls[0];
67
+ if (!call) throw new Error("expected StdioTransport.connect() to spawn exactly one subprocess");
68
+
69
+ const [spawnArgv, spawnOptions] = call;
70
+ expect(spawnArgv).toEqual(argv);
71
+ expect(spawnOptions).toEqual(
72
+ expect.objectContaining({
73
+ cwd,
74
+ detached: process.platform !== "win32",
75
+ env: expect.objectContaining({
76
+ JEOPI_STDIO_SPAWN_SHAPE: envValue,
77
+ }),
78
+ stderr: "pipe",
79
+ stdin: "pipe",
80
+ stdout: "pipe",
81
+ windowsHide: process.platform === "win32" ? expect.any(Boolean) : undefined,
82
+ }),
83
+ );
84
+ } finally {
85
+ await transport.close();
86
+ spawnSpy.mockRestore();
87
+ }
88
+ });
89
+ });
90
+
47
91
  // Regression for #3945: request() awaited stdin.write/flush, so a child that
48
92
  // stops draining stdin would park the async fn past the timeout timer and past
49
93
  // `return promise`, orphaning the deferred rejection and hanging the caller
@@ -7,7 +7,7 @@
7
7
 
8
8
  import * as fs from "node:fs/promises";
9
9
  import * as path from "node:path";
10
- import { type Subprocess, spawn } from "bun";
10
+ import type { Subprocess } from "bun";
11
11
  import { getProjectDir, readJsonl, Snowflake } from "jeopi-utils";
12
12
  import { hostHasInheritableConsole } from "../../eval/py/spawn-options";
13
13
  import type {
@@ -370,8 +370,11 @@ export class StdioTransport implements MCPTransport {
370
370
  // escape terminal job-control signals (SIGTSTP, SIGTTIN); Windows stays
371
371
  // attached, and only hides the child when the host has no console to
372
372
  // share. See `StdioSpawnCommand`.
373
- this.#process = spawn({
374
- cmd: spawnCommand.cmd,
373
+ // Keep this on Bun's argv-first overload. The eval JS kernel path that
374
+ // triggers macOS Apple Events TCC prompts uses the same shape; the
375
+ // one-object `{ cmd }` overload timed out before prompting for
376
+ // `mcpbridge` even with `detached: false` (upstream #5085).
377
+ this.#process = Bun.spawn(spawnCommand.cmd, {
375
378
  cwd,
376
379
  env,
377
380
  stdin: "pipe",
@@ -128,6 +128,15 @@ type PromptLifecycleError = Error & { readonly code: "ACP_SESSION_CLOSED" };
128
128
  type PromptTurnState = {
129
129
  cancelRequested: boolean;
130
130
  settled: boolean;
131
+ /**
132
+ * Delivery of streamed assistant `error` chunks this turn (the mapper
133
+ * surfaces them as `agent_message_chunk`s). Resolves `true` once at least
134
+ * one error chunk reached the client — the `agent_end` error fallback in
135
+ * {@link AcpAgent##flushUnreportedTurnError} awaits it and stays silent on
136
+ * success, so a fallback racing an in-flight delivery can neither duplicate
137
+ * the error nor drop it when delivery fails.
138
+ */
139
+ errorTextDelivery: Promise<boolean> | undefined;
131
140
  /**
132
141
  * `abort()` is in-flight (or its bounded-timeout race). `undefined` while the turn is
133
142
  * running normally and after cleanup completes. The turn occupies `record.promptTurn`
@@ -343,12 +352,19 @@ async function elicitFromAcpClient(
343
352
  finish(undefined);
344
353
  });
345
354
  const response = await promise;
346
- if (response?.action !== "accept" || !response.content) {
355
+ if (!isAcceptedElicitation(response) || !response.content) {
347
356
  return undefined;
348
357
  }
349
358
  return response.content.value;
350
359
  }
351
360
 
361
+ /** Narrows a `CreateElicitationResponse` to the accepted-with-content branch; the SDK's `action: string` catch-all arm otherwise defeats literal narrowing on `action !== "accept"`. */
362
+ function isAcceptedElicitation(
363
+ response: CreateElicitationResponse | undefined,
364
+ ): response is Extract<CreateElicitationResponse, { action: "accept" }> {
365
+ return response?.action === "accept";
366
+ }
367
+
352
368
  /**
353
369
  * Build an {@link ExtensionUIContext} that translates skill/extension UI
354
370
  * requests into ACP elicitations against `connection` for the session
@@ -684,6 +700,7 @@ export class AcpAgent implements Agent {
684
700
  record.promptTurn = {
685
701
  cancelRequested: false,
686
702
  settled: false,
703
+ errorTextDelivery: undefined,
687
704
  cleanup: undefined,
688
705
  usageBaseline: this.#cloneUsageStatistics(record.session.sessionManager.getUsageStatistics()),
689
706
  unsubscribe: undefined,
@@ -1197,6 +1214,10 @@ export class AcpAgent implements Agent {
1197
1214
  imageDataCache.set(key, resolved);
1198
1215
  return resolved;
1199
1216
  };
1217
+ const streamedAssistantError =
1218
+ event.type === "message_update" &&
1219
+ event.message.role === "assistant" &&
1220
+ event.assistantMessageEvent.type === "error";
1200
1221
  for (const notification of mapAgentSessionEventToAcpSessionUpdates(event, record.session.sessionId, {
1201
1222
  getMessageId: message => this.#getLiveMessageId(record, message),
1202
1223
  getMessageProgress: message => this.#getLiveMessageProgress(record, message),
@@ -1204,7 +1225,18 @@ export class AcpAgent implements Agent {
1204
1225
  cwd: record.session.sessionManager.getCwd(),
1205
1226
  resolveImageData: resolveImageDataForAcp,
1206
1227
  })) {
1207
- await this.#connection.sessionUpdate(notification);
1228
+ const delivery = this.#connection.sessionUpdate(notification);
1229
+ if (streamedAssistantError) {
1230
+ // Resolves true only once the error chunk actually reached the
1231
+ // client — a failed delivery keeps the agent_end fallback armed.
1232
+ const outcome = delivery.then(
1233
+ () => true,
1234
+ () => false,
1235
+ );
1236
+ const prior = promptTurn.errorTextDelivery;
1237
+ promptTurn.errorTextDelivery = prior ? Promise.all([prior, outcome]).then(([a, b]) => a || b) : outcome;
1238
+ }
1239
+ await delivery;
1208
1240
  }
1209
1241
  if (event.type === "tool_execution_end") {
1210
1242
  record.toolArgsById.delete(event.toolCallId);
@@ -1212,6 +1244,7 @@ export class AcpAgent implements Agent {
1212
1244
  this.#clearLiveAssistantMessageAfterEvent(record, event);
1213
1245
 
1214
1246
  if (event.type === "agent_end") {
1247
+ await this.#flushUnreportedTurnError(record, event);
1215
1248
  await this.#emitEndOfTurnUpdates(record);
1216
1249
  await this.#waitForAcpPromptIdle(record);
1217
1250
  this.#finishPrompt(record, {
@@ -1221,6 +1254,44 @@ export class AcpAgent implements Agent {
1221
1254
  }
1222
1255
  }
1223
1256
 
1257
+ /**
1258
+ * Surface a turn-fatal provider error that never reached the client. A
1259
+ * request that fails before streaming any assistant events — e.g. GitHub
1260
+ * Copilot's `HTTP 400 model_not_supported` after retries — emits only
1261
+ * `agent_end` with an empty assistant message carrying `errorMessage`
1262
+ * (`Agent#runLoop`'s catch), so no `message_update`/`message_end` ever maps
1263
+ * to a session update and the client sees the turn end silently. Errors
1264
+ * that did stream are tracked via {@link PromptTurnState.errorTextDelivery};
1265
+ * the fallback awaits that delivery and re-sends only when it failed.
1266
+ */
1267
+ async #flushUnreportedTurnError(
1268
+ record: ManagedSessionRecord,
1269
+ event: Extract<AgentSessionEvent, { type: "agent_end" }>,
1270
+ ): Promise<void> {
1271
+ const streamedDelivery = record.promptTurn?.errorTextDelivery;
1272
+ if (streamedDelivery && (await streamedDelivery)) {
1273
+ return;
1274
+ }
1275
+ const lastAssistant = [...event.messages]
1276
+ .reverse()
1277
+ .find((message): message is AssistantMessage => message.role === "assistant");
1278
+ if (lastAssistant?.stopReason !== "error") {
1279
+ return;
1280
+ }
1281
+ const errorMessage = lastAssistant.errorMessage;
1282
+ if (!errorMessage || isSilentAbort(lastAssistant)) {
1283
+ return;
1284
+ }
1285
+ await this.#connection.sessionUpdate({
1286
+ sessionId: record.session.sessionId,
1287
+ update: {
1288
+ sessionUpdate: "agent_message_chunk",
1289
+ content: { type: "text", text: errorMessage },
1290
+ messageId: record.liveMessageId ?? crypto.randomUUID(),
1291
+ },
1292
+ });
1293
+ }
1294
+
1224
1295
  async #waitForAcpPromptIdle(record: ManagedSessionRecord): Promise<void> {
1225
1296
  for (let pass = 0; pass < ACP_ASYNC_DELIVERY_DRAIN_MAX_PASSES; pass++) {
1226
1297
  await record.session.waitForIdle();
@@ -284,6 +284,11 @@ function mapAssistantMessageUpdate(
284
284
  case "error":
285
285
  sessionUpdate = "agent_message_chunk";
286
286
  text = event.assistantMessageEvent.error.errorMessage ?? "Unknown error";
287
+ // The surfaced error is the message's visible text: keeps the
288
+ // message_end / agent_end fallbacks from emitting again.
289
+ if (text.length > 0 && progress) {
290
+ progress.textEmitted = true;
291
+ }
287
292
  break;
288
293
  default:
289
294
  return [];
@@ -14,6 +14,17 @@ export function createAcpConnection(
14
14
  }
15
15
 
16
16
  export async function runAcpMode(createSession: AcpSessionFactory, initialSession?: AgentSession): Promise<never> {
17
+ // Humans who run `jeopi acp` by hand see a silent process and assume it is
18
+ // broken (stdout is the JSON-RPC transport, so nothing may be printed
19
+ // there). When stdin is a TTY no ACP client is attached — say so on stderr
20
+ // before the transport starts.
21
+ if (process.stdin.isTTY) {
22
+ process.stderr.write(
23
+ "jeopi acp: ACP server speaking JSON-RPC over stdio.\n" +
24
+ 'This command is meant to be spawned by an ACP client (e.g. Zed\'s "agent_servers" config), not run directly.\n' +
25
+ "Waiting for protocol frames on stdin; logs: ~/.jeopi/logs/\n",
26
+ );
27
+ }
17
28
  const input = stream.Writable.toWeb(process.stdout);
18
29
  const output = stream.Readable.toWeb(process.stdin);
19
30
  const transport = ndJsonStream(input, output);