jeopi-cli 16.4.2 → 16.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -0
- package/dist/cli.js +3179 -3140
- package/dist/types/advisor/config.d.ts +4 -3
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/update-cli.d.ts +4 -1
- package/dist/types/cli/usage-error.d.ts +4 -0
- package/dist/types/commit/agentic/agent.d.ts +1 -0
- package/dist/types/config/model-resolver.d.ts +7 -1
- package/dist/types/config/settings-schema.d.ts +3 -3
- package/dist/types/eval/py/tool-bridge.d.ts +1 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
- package/dist/types/extensibility/extensions/runner.d.ts +3 -1
- package/dist/types/extensibility/extensions/types.d.ts +3 -0
- package/dist/types/internal-urls/memory-protocol.d.ts +6 -6
- package/dist/types/internal-urls/types.d.ts +7 -0
- package/dist/types/irc/bus.d.ts +4 -0
- package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
- package/dist/types/modes/components/move-overlay.d.ts +1 -1
- package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
- package/dist/types/modes/components/welcome.d.ts +4 -0
- package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
- package/dist/types/modes/orchestrate.d.ts +1 -1
- package/dist/types/modes/ultrathink.d.ts +1 -1
- package/dist/types/modes/workflow.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +5 -0
- package/dist/types/session/auth-broker-config.d.ts +2 -2
- package/dist/types/tools/bash-skill-urls.d.ts +3 -2
- package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
- package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
- package/dist/types/tools/browser/launch.d.ts +1 -0
- package/dist/types/tools/browser/run-cancellation.d.ts +17 -2
- package/dist/types/tools/browser/run-output.d.ts +25 -0
- package/dist/types/tools/browser/tab-worker.d.ts +22 -0
- package/dist/types/utils/changelog.d.ts +35 -1
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +13 -0
- package/src/advisor/__tests__/config.test.ts +36 -0
- package/src/advisor/config.ts +11 -8
- package/src/autolearn/controller.ts +14 -2
- package/src/cli/args.ts +12 -0
- package/src/cli/flag-tables.ts +21 -7
- package/src/cli/update-cli.ts +63 -13
- package/src/cli/usage-error.ts +7 -0
- package/src/commands/acp.ts +11 -2
- package/src/commands/launch.ts +12 -3
- package/src/commit/agentic/agent.ts +4 -0
- package/src/commit/agentic/index.ts +46 -21
- package/src/config/model-resolver.ts +24 -6
- package/src/config/settings-schema.ts +3 -3
- package/src/edit/index.ts +10 -76
- package/src/edit/renderer.ts +13 -10
- package/src/eval/__tests__/agent-bridge.test.ts +19 -14
- package/src/eval/__tests__/bridge-timeout.test.ts +60 -0
- package/src/eval/executor-base.ts +84 -7
- package/src/eval/js/shared/rewrite-imports.ts +31 -13
- package/src/eval/py/tool-bridge.ts +13 -14
- package/src/exec/non-interactive-env.ts +0 -1
- package/src/export/ttsr.ts +0 -3
- package/src/extensibility/custom-tools/loader.ts +70 -37
- package/src/extensibility/custom-tools/types.ts +3 -0
- package/src/extensibility/extensions/runner.ts +3 -0
- package/src/extensibility/extensions/types.ts +3 -0
- package/src/extensibility/plugins/manager.ts +21 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/internal-urls/memory-protocol.ts +13 -8
- package/src/internal-urls/skill-protocol.ts +1 -1
- package/src/internal-urls/types.ts +7 -0
- package/src/irc/bus.ts +48 -18
- package/src/lsp/client.ts +7 -1
- package/src/lsp/deferred-diagnostics.ts +66 -0
- package/src/main.ts +13 -13
- package/src/mcp/tool-bridge.ts +57 -6
- package/src/mcp/transports/stdio.test.ts +45 -1
- package/src/mcp/transports/stdio.ts +6 -3
- package/src/modes/acp/acp-agent.ts +73 -2
- package/src/modes/acp/acp-event-mapper.ts +5 -0
- package/src/modes/acp/acp-mode.ts +11 -0
- package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
- package/src/modes/components/advisor-config.ts +15 -7
- package/src/modes/components/move-overlay.ts +2 -3
- package/src/modes/components/plan-review-overlay.ts +7 -0
- package/src/modes/components/tips.txt +1 -1
- package/src/modes/components/welcome.ts +13 -14
- package/src/modes/controllers/command-controller.ts +8 -10
- package/src/modes/controllers/event-controller.ts +17 -30
- package/src/modes/controllers/input-controller.ts +9 -6
- package/src/modes/controllers/selector-controller.ts +3 -2
- package/src/modes/controllers/tan-command-controller.ts +40 -1
- package/src/modes/interactive-mode.ts +13 -0
- package/src/modes/magic-keyword-boundary.ts +23 -0
- package/src/modes/orchestrate.ts +6 -5
- package/src/modes/print-mode.ts +9 -0
- package/src/modes/ultrathink.ts +6 -5
- package/src/modes/workflow.ts +5 -4
- package/src/prompts/system/eager-task.md +2 -2
- package/src/prompts/system/tan-context-switch.md +17 -0
- package/src/prompts/tools/browser.md +5 -5
- package/src/sdk.ts +3 -0
- package/src/session/agent-session.ts +172 -25
- package/src/session/auth-broker-config.ts +4 -4
- package/src/session/snapcompact-inline.ts +3 -19
- package/src/session/streaming-output.ts +40 -12
- package/src/slash-commands/builtin-registry.ts +8 -8
- package/src/task/executor.ts +8 -0
- package/src/tools/bash-skill-urls.ts +7 -4
- package/src/tools/bash.ts +30 -22
- package/src/tools/browser/cmux/cmux-tab.ts +49 -50
- package/src/tools/browser/cmux/rpc.ts +50 -0
- package/src/tools/browser/launch.ts +8 -4
- package/src/tools/browser/run-cancellation.ts +44 -5
- package/src/tools/browser/run-output.ts +76 -0
- package/src/tools/browser/tab-worker.ts +280 -133
- package/src/tools/eval-render.ts +16 -14
- package/src/tools/glob.ts +20 -6
- package/src/tools/grep.ts +1 -0
- package/src/tools/image-gen.ts +3 -4
- package/src/tools/irc.ts +27 -13
- package/src/tools/read.ts +14 -7
- package/src/tools/write.ts +22 -5
- package/src/tools/yield.ts +29 -1
- package/src/utils/changelog.ts +106 -9
- package/src/utils/git.ts +0 -1
- package/src/utils/title-generator.ts +68 -7
- package/src/web/search/index.ts +14 -6
- package/src/web/search/providers/perplexity.ts +18 -2
|
@@ -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
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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 =
|
|
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 (
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
//
|
|
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 (
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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;
|
package/src/mcp/tool-bridge.ts
CHANGED
|
@@ -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,
|
|
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(
|
|
113
|
-
|
|
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 {
|
|
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
|
|
374
|
-
|
|
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
|
|
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
|
-
|
|
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);
|