pi-codemcp 1.4.0 → 1.5.0

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/README.md CHANGED
@@ -5,6 +5,7 @@ Fast, typed, sandboxed **Code Mode for every MCP server configured in Pi**.
5
5
  Instead of putting every upstream MCP tool definition into the model context, pi-codemcp gives the agent a small interface for discovery, execution, and reuse:
6
6
 
7
7
  - `codemcp_search` ranks capabilities or pages through a compact inventory without loading full schemas.
8
+ - Optional Jev discovery lets the agent route complete MCP tasks on demand, returning relevant calls, composition guidance, and exact contracts through bounded parallel TypeSafe requests.
8
9
  - `codemcp_inspect` returns exact typed SDK stubs only for selected calls.
9
10
  - `codemcp_execute` runs one sandboxed Python call graph across one or many MCP servers.
10
11
  - `codemcp_edit` applies one exact replacement to the previous execution and reruns it without resending the full code.
@@ -133,6 +134,7 @@ Settings live at `<agent-dir>/pi-codemcp/settings.json` and can also be edited i
133
134
  ```json
134
135
  {
135
136
  "version": 2,
137
+ "jevEnabled": false,
136
138
  "backgroundWarmup": true,
137
139
  "cacheTtlHours": 24,
138
140
  "executionTimeoutSeconds": 30,
@@ -148,6 +150,12 @@ Settings live at `<agent-dir>/pi-codemcp/settings.json` and can also be edited i
148
150
 
149
151
  The Python sidecar enforces catalog cache TTL, execution timeout, per-tool timeout, max MCP calls, result size, and disabled-tool policy. The TypeScript Pi layer uses `backgroundWarmup` and `outputLimitKiB` for session warmup and rendered-output truncation; the sidecar still validates those fields so the settings file has one strict shared schema. Version-one files are migrated when loaded, and the removed `outputLineLimit` field is omitted on the next save.
150
152
 
153
+ ### Jev discovery mode
154
+
155
+ Enable Jev in `/codemcp` and provide `TYPESAFE_API_KEY`. This replaces `codemcp_search` with the on-demand `codemcp_route` tool, so ordinary messages have no routing delay. When a task may need MCP, the agent sends the complete task plus every enabled MCP call's name and description to TypeSafe in bounded parallel chunks. Jev scores every call, classifies its workflow role, and checks whether an intermediate model/user checkpoint is required; pi-codemcp merges those raw answers into parallel or dependent composition guidance. The result includes exact typed contracts and directs the agent into the appropriate `codemcp_execute` program.
156
+
157
+ Jev mode is opt-in because routed task text and enabled tool descriptions leave the machine. If the API key is missing, pi-codemcp keeps local `codemcp_search` active. If a Jev route fails, search is activated as an in-session fallback. `TYPESAFE_BASE_URL` and `TYPESAFE_DEFAULT_MODEL` are honored by the official TypeSafe SDK.
158
+
151
159
  ## Search and execute flow
152
160
 
153
161
  The agent searches for a capability, inspects the selected exact stub when needed, and executes a compact plan. Unscoped searches discover stale or missing server catalogs independently: available servers still return results, while `discovery_failures` explicitly reports unavailable servers. Server-scoped searches remain fail-fast.
@@ -1,12 +1,16 @@
1
- import { join } from "node:path";
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
2
3
  import {
3
4
  CONFIG_DIR_NAME,
4
5
  type ExtensionAPI,
5
6
  type ExtensionCommandContext,
6
7
  } from "@earendil-works/pi-coding-agent";
8
+ import { TypeSafeClient } from "@typesafe-ai/sdk";
7
9
  import { newCodeMcpTraceId, SavedChainManager } from "../src/chains.js";
8
10
  import { setMcpServerEnabled } from "../src/config.js";
9
11
  import { summarizeError } from "../src/errors.js";
12
+ import { JevRouter } from "../src/jev-router.js";
13
+ import { readJsonObject, writeJsonObjectAtomically } from "../src/json-file.js";
10
14
  import { CodeMcpLifecycle } from "../src/lifecycle.js";
11
15
  import type { SidecarClientOptions } from "../src/mcp-client.js";
12
16
  import {
@@ -22,13 +26,21 @@ import {
22
26
  setEditableSetting,
23
27
  setToolEnabled,
24
28
  } from "../src/settings.js";
25
- import { registerCodeMcpTools } from "../src/tools.js";
29
+ import { registerCodeMcpTools, registerJevRouteTool } from "../src/tools.js";
26
30
 
27
- export function createCodeMcpExtension(options: SidecarClientOptions = {}) {
31
+ export interface CodeMcpExtensionOptions extends SidecarClientOptions {
32
+ jevClient?: TypeSafeClient;
33
+ }
34
+
35
+ export function createCodeMcpExtension(options: CodeMcpExtensionOptions = {}) {
28
36
  return function codeMcpExtension(pi: ExtensionAPI): void {
29
- const lifecycle = new CodeMcpLifecycle(options);
37
+ const { jevClient, ...sidecarOptions } = options;
38
+ const lifecycle = new CodeMcpLifecycle(sidecarOptions);
30
39
  const chains = new SavedChainManager(pi, lifecycle);
40
+ const routerClient = jevClient ?? createJevClient();
41
+ const jevRouter = routerClient ? new JevRouter(lifecycle, routerClient) : undefined;
31
42
  registerCodeMcpTools(pi, lifecycle, chains);
43
+ registerJevRouteTool(pi, () => jevRouter);
32
44
 
33
45
  pi.registerCommand("codemcp", {
34
46
  description: "Manage CodeMCP servers, saved chains, tools, and settings",
@@ -71,7 +83,11 @@ export function createCodeMcpExtension(options: SidecarClientOptions = {}) {
71
83
  onSetSetting: async (key, value) => {
72
84
  const updated = setEditableSetting(lifecycle.loadSettings(), key, value);
73
85
  saveCodeMcpSettings(lifecycle.settingsPath, updated);
74
- await lifecycle.request("reload_settings", {});
86
+ if (key !== "jevEnabled") await lifecycle.request("reload_settings", {});
87
+ setDiscoveryTools(pi, updated.jevEnabled && jevRouter !== undefined);
88
+ if (updated.jevEnabled && !jevRouter) {
89
+ ctx.ui.notify("Jev mode requires TYPESAFE_API_KEY; using local search", "warning");
90
+ }
75
91
  return updated;
76
92
  },
77
93
  onSetChainEnabled: async (chain, enabled) => {
@@ -97,6 +113,11 @@ export function createCodeMcpExtension(options: SidecarClientOptions = {}) {
97
113
  });
98
114
 
99
115
  pi.on("session_start", (_event, ctx) => {
116
+ try {
117
+ showChangelogOnce(ctx, join(dirname(lifecycle.settingsPath), "changelog.json"));
118
+ } catch (error) {
119
+ ctx.ui.notify(`CodeMCP changelog failed: ${summarizeError(error)}`, "warning");
120
+ }
100
121
  bindProjectChainScope(ctx, lifecycle, chains);
101
122
  chains.activatePersisted();
102
123
  for (const error of chains.startupErrors) ctx.ui.notify(error, "warning");
@@ -107,6 +128,11 @@ export function createCodeMcpExtension(options: SidecarClientOptions = {}) {
107
128
  ctx.ui.notify(`CodeMCP settings failed: ${summarizeError(error)}`, "warning");
108
129
  return;
109
130
  }
131
+ const useJev = settings.jevEnabled && jevRouter !== undefined;
132
+ setDiscoveryTools(pi, useJev);
133
+ if (settings.jevEnabled && !jevRouter) {
134
+ ctx.ui.notify("Jev mode requires TYPESAFE_API_KEY; using local search", "warning");
135
+ }
110
136
  if (!settings.backgroundWarmup) return;
111
137
  void lifecycle.warmup().catch((error: unknown) => {
112
138
  ctx.ui.notify(`CodeMCP background warmup failed: ${summarizeError(error)}`, "warning");
@@ -154,6 +180,38 @@ export async function setServerEnabledFromManager(
154
180
 
155
181
  export default createCodeMcpExtension();
156
182
 
183
+ const CHANGELOG_ID = "jev-routing-v1";
184
+ const CHANGELOG_MESSAGE =
185
+ "pi-codemcp update: optional Jev routing is now available! Enable Jev in /codemcp → Settings to let Jev select and compose MCP calls, it's pretty cool";
186
+
187
+ export function showChangelogOnce(
188
+ ctx: Pick<ExtensionCommandContext, "mode" | "ui">,
189
+ path: string,
190
+ ): void {
191
+ if (ctx.mode !== "tui") return;
192
+ const state = existsSync(path) ? readJsonObject(path, "CodeMCP changelog state") : {};
193
+ if (state.lastSeen === CHANGELOG_ID) return;
194
+ ctx.ui.notify(CHANGELOG_MESSAGE, "info");
195
+ writeJsonObjectAtomically(path, { lastSeen: CHANGELOG_ID });
196
+ }
197
+
198
+ function createJevClient(): TypeSafeClient | undefined {
199
+ return process.env.TYPESAFE_API_KEY?.trim() ? new TypeSafeClient() : undefined;
200
+ }
201
+
202
+ function setDiscoveryTools(pi: ExtensionAPI, useJev: boolean): void {
203
+ const selected = useJev ? "codemcp_route" : "codemcp_search";
204
+ const current = pi.getActiveTools();
205
+ if (
206
+ current.includes(selected) &&
207
+ !current.includes(useJev ? "codemcp_search" : "codemcp_route")
208
+ ) {
209
+ return;
210
+ }
211
+ const active = current.filter((name) => name !== "codemcp_search" && name !== "codemcp_route");
212
+ pi.setActiveTools([...active, selected]);
213
+ }
214
+
157
215
  export async function promptForProblemReport(
158
216
  pi: Pick<ExtensionAPI, "sendUserMessage">,
159
217
  ctx: Pick<ExtensionCommandContext, "ui">,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codemcp",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Typed, sandboxed Code Mode access to configured MCP servers for Pi",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.10",
@@ -52,7 +52,8 @@
52
52
  "image": "https://raw.githubusercontent.com/yolonir/pi-codemcp/main/media/preview.png"
53
53
  },
54
54
  "dependencies": {
55
- "@modelcontextprotocol/sdk": "1.29.0"
55
+ "@modelcontextprotocol/sdk": "1.29.0",
56
+ "@typesafe-ai/sdk": "0.6.0"
56
57
  },
57
58
  "optionalDependencies": {
58
59
  "@manzt/uv-darwin-arm64": "0.8.13",
@@ -93,11 +93,13 @@ class ExecutionContext:
93
93
  call_tool: ContextToolCall,
94
94
  settings: ExecutionSettings,
95
95
  deadline: float,
96
+ progress: ProgressReporter | None = None,
96
97
  ) -> None:
97
98
  self.catalog = catalog
98
99
  self.call_tool = call_tool
99
100
  self.settings = settings
100
101
  self.deadline = deadline
102
+ self.progress = progress
101
103
  self.calls_made = 0
102
104
  self.chain_calls = 0
103
105
  self.metrics = ExecutionMetrics()
@@ -117,6 +119,7 @@ class ExecutionContext:
117
119
 
118
120
  ToolCall = Callable[[str, JsonObject], Awaitable[JsonValue]]
119
121
  ContextToolCall = Callable[[str, JsonObject, ExecutionContext], Awaitable[JsonValue]]
122
+ ProgressReporter = Callable[[int, str], Awaitable[None]]
120
123
  ExternalFunction = Callable[..., Awaitable[JsonValue]]
121
124
  ResultRetainer = Callable[[JsonValue], RetainedResult | None]
122
125
 
@@ -291,9 +294,10 @@ class MontyExecutor:
291
294
  *,
292
295
  input_value: JsonValue = None,
293
296
  retain_result: ResultRetainer | None = None,
297
+ progress: ProgressReporter | None = None,
294
298
  ) -> ExecutionResponse:
295
299
  async with self._execution_lock:
296
- context = self._new_context(self.catalog, call_tool)
300
+ context = self._new_context(self.catalog, call_tool, progress=progress)
297
301
  return await self._execute_program(
298
302
  code,
299
303
  context,
@@ -397,6 +401,8 @@ class MontyExecutor:
397
401
  self,
398
402
  catalog: ToolCatalog,
399
403
  call_tool: ContextToolCall,
404
+ *,
405
+ progress: ProgressReporter | None = None,
400
406
  ) -> ExecutionContext:
401
407
  loop = asyncio.get_running_loop()
402
408
  return ExecutionContext(
@@ -404,6 +410,7 @@ class MontyExecutor:
404
410
  call_tool=call_tool,
405
411
  settings=self.settings,
406
412
  deadline=loop.time() + self.settings.timeout_seconds,
413
+ progress=progress,
407
414
  )
408
415
 
409
416
  async def _execute_program( # ruff:ignore[complex-structure, too-many-statements]
@@ -502,9 +509,13 @@ class MontyExecutor:
502
509
  raise ValueError(message) from error
503
510
  if spec.kind == "saved_chain":
504
511
  context.chain_calls += 1
512
+ if context.progress is not None:
513
+ await context.progress(context.total_calls, spec.call)
505
514
  return await context.call_tool(name, validated, context)
506
515
 
507
516
  context.calls_made += 1
517
+ if context.progress is not None:
518
+ await context.progress(context.total_calls, spec.call)
508
519
  remaining = context.remaining_seconds()
509
520
  if remaining <= 0:
510
521
  raise TimeoutError
@@ -607,6 +618,8 @@ class MontyExecutor:
607
618
  "max_duration_secs": remaining,
608
619
  "max_memory": context.settings.max_memory_bytes,
609
620
  }
621
+ if context.progress is not None:
622
+ await context.progress(context.total_calls, "executing")
610
623
  runtime_started = time.perf_counter()
611
624
  try:
612
625
  async with asyncio.timeout(remaining + 0.1):
@@ -11,7 +11,7 @@ from contextlib import AsyncExitStack, asynccontextmanager, suppress
11
11
  from typing import TYPE_CHECKING, Literal, NamedTuple, Protocol, cast
12
12
 
13
13
  import pydantic_monty
14
- from fastmcp import Client, FastMCP
14
+ from fastmcp import Client, Context, FastMCP
15
15
  from fastmcp.mcp_config import RemoteMCPServer, StdioMCPServer
16
16
  from pydantic_core import to_json
17
17
  from rapidfuzz import fuzz, process
@@ -33,6 +33,7 @@ from .executor import (
33
33
  ExecutionFailureInfo,
34
34
  ExecutionResponse,
35
35
  MontyExecutor,
36
+ ProgressReporter,
36
37
  )
37
38
  from .mcp_config import NormalizedConfig, load_mcp_json, normalize_mcp_config
38
39
  from .models import (
@@ -601,15 +602,17 @@ class GatewayRuntime:
601
602
  code: str,
602
603
  trace_id: str,
603
604
  input_ref: str | None = None,
605
+ progress: ProgressReporter | None = None,
604
606
  ) -> ExecutionResponse:
605
607
  async with self._execute_lock:
606
- return await self._execute(code, trace_id, input_ref)
608
+ return await self._execute(code, trace_id, input_ref, progress)
607
609
 
608
610
  async def edit_execute(
609
611
  self,
610
612
  old_text: str,
611
613
  new_text: str,
612
614
  trace_id: str,
615
+ progress: ProgressReporter | None = None,
613
616
  ) -> ExecutionResponse:
614
617
  async with self._execute_lock:
615
618
  previous = self._last_execution
@@ -625,13 +628,14 @@ class GatewayRuntime:
625
628
  f"old_text must match the previous code exactly once; found {matches} matches"
626
629
  )
627
630
  code = previous.code.replace(old_text, new_text, 1)
628
- return await self._execute(code, trace_id, previous.input_ref)
631
+ return await self._execute(code, trace_id, previous.input_ref, progress)
629
632
 
630
633
  async def _execute(
631
634
  self,
632
635
  code: str,
633
636
  trace_id: str,
634
637
  input_ref: str | None,
638
+ progress: ProgressReporter | None = None,
635
639
  ) -> ExecutionResponse:
636
640
  self._last_execution = None
637
641
  started = time.perf_counter()
@@ -682,6 +686,7 @@ class GatewayRuntime:
682
686
  self._dispatch,
683
687
  input_value=input_value,
684
688
  retain_result=self.refinement_cache.retain,
689
+ progress=progress,
685
690
  )
686
691
  except BaseException as error:
687
692
  cancelled = isinstance(error, asyncio.CancelledError)
@@ -1615,6 +1620,13 @@ def _require_runtime() -> GatewayRuntime:
1615
1620
  return _runtime_state.runtime
1616
1621
 
1617
1622
 
1623
+ def _progress_reporter(ctx: Context) -> ProgressReporter:
1624
+ async def report(total_calls: int, message: str) -> None:
1625
+ await ctx.report_progress(progress=float(total_calls), message=message)
1626
+
1627
+ return report
1628
+
1629
+
1618
1630
  def configure_runtime_paths(paths: RuntimePaths | None) -> None:
1619
1631
  _runtime_state.paths = paths
1620
1632
 
@@ -1714,10 +1726,16 @@ async def set_chain_enabled(
1714
1726
  async def execute(
1715
1727
  trace_id: str,
1716
1728
  code: str,
1729
+ ctx: Context,
1717
1730
  input_ref: str | None = None,
1718
1731
  ) -> ExecutionResponse:
1719
1732
  """Type-check and run one sandboxed Python MCP SDK chain."""
1720
- return await _require_runtime().execute(code, trace_id, input_ref)
1733
+ return await _require_runtime().execute(
1734
+ code,
1735
+ trace_id,
1736
+ input_ref,
1737
+ _progress_reporter(ctx),
1738
+ )
1721
1739
 
1722
1740
 
1723
1741
  @mcp.tool
@@ -1725,9 +1743,15 @@ async def edit_execute(
1725
1743
  trace_id: str,
1726
1744
  old_text: str,
1727
1745
  new_text: str,
1746
+ ctx: Context,
1728
1747
  ) -> ExecutionResponse:
1729
1748
  """Patch and rerun the last sandbox execution."""
1730
- return await _require_runtime().edit_execute(old_text, new_text, trace_id)
1749
+ return await _require_runtime().edit_execute(
1750
+ old_text,
1751
+ new_text,
1752
+ trace_id,
1753
+ _progress_reporter(ctx),
1754
+ )
1731
1755
 
1732
1756
 
1733
1757
  @mcp.tool
@@ -26,6 +26,7 @@ class CodeMcpSettings(BaseModel):
26
26
  )
27
27
 
28
28
  version: Literal[2] = 2
29
+ jev_enabled: bool = False
29
30
  background_warmup: bool = True
30
31
  cache_ttl_hours: int = Field(default=24, ge=0, le=720)
31
32
  execution_timeout_seconds: int = Field(default=30, ge=1, le=300)
@@ -60,4 +61,10 @@ def load_settings(path: Path) -> CodeMcpSettings:
60
61
  if isinstance(value, dict) and value.get("version", 1) == 1:
61
62
  value = {key: item for key, item in value.items() if key != "outputLineLimit"}
62
63
  value["version"] = 2
64
+ if isinstance(value, dict) and "discoveryMode" in value:
65
+ mode = value.pop("discoveryMode")
66
+ if not isinstance(mode, str) or mode not in {"search", "jev"}:
67
+ value["discoveryMode"] = mode
68
+ else:
69
+ value.setdefault("jevEnabled", mode == "jev")
63
70
  return CodeMcpSettings.model_validate(value)
@@ -16,6 +16,12 @@ interface RenderResult {
16
16
  details?: unknown;
17
17
  }
18
18
 
19
+ /** Details sent with partial updates while an execution is running. */
20
+ export interface ExecutionProgressDetails {
21
+ callsMade: number;
22
+ currentCall?: string;
23
+ }
24
+
19
25
  interface ExecutionRendererOptions {
20
26
  partialText?: string;
21
27
  expandDescription?: string;
@@ -28,6 +34,16 @@ export function renderExecutionResult(
28
34
  options: ExecutionRendererOptions = {},
29
35
  ): Text {
30
36
  if (state.isPartial) {
37
+ const progress = result.details as ExecutionProgressDetails | undefined;
38
+ if (typeof progress?.callsMade === "number") {
39
+ const calls = progress.callsMade;
40
+ const current = progress.currentCall ? ` · ${progress.currentCall}` : "";
41
+ return new Text(
42
+ `\n${theme.fg("warning", `Executing · ${calls} MCP ${calls === 1 ? "call" : "calls"}${current}...`)}`,
43
+ 0,
44
+ 0,
45
+ );
46
+ }
31
47
  return new Text(
32
48
  `\n${theme.fg("warning", options.partialText ?? "Preflight check, then execution...")}`,
33
49
  0,
@@ -0,0 +1,323 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { choice, noul, type Questions, type TypeSafeClient } from "@typesafe-ai/sdk";
3
+ import type { CodeMcpLifecycle } from "./lifecycle.js";
4
+
5
+ const TOOL_RELEVANCE_THRESHOLD = 0.65;
6
+ const NOUL_THRESHOLD = 0.5;
7
+ // ponytail: bound injected schemas; raise only if routing evals show recall loss.
8
+ const MAX_SELECTED_TOOLS = 8;
9
+ const CATALOG_PAGE_SIZE = 20;
10
+ const JEV_CHUNK_SIZE = 40;
11
+
12
+ type ToolRole = "source" | "enrichment" | "sink" | "standalone" | "unspecified";
13
+ const TOOL_ROLE_BY_CHOICE: Readonly<Record<string, ToolRole>> = {
14
+ source: "source",
15
+ enrichment: "enrichment",
16
+ sink: "sink",
17
+ standalone: "standalone",
18
+ irrelevant: "unspecified",
19
+ };
20
+ type WorkflowShape = "single_call" | "parallel" | "pipeline" | "mixed";
21
+
22
+ interface CatalogTool {
23
+ call: string;
24
+ description?: string;
25
+ }
26
+
27
+ interface IndexedTool {
28
+ index: number;
29
+ tool: CatalogTool;
30
+ }
31
+
32
+ interface ChunkResult {
33
+ tools: JevSelectedTool[];
34
+ needsAnyTool?: number;
35
+ needsCheckpoint?: number;
36
+ }
37
+
38
+ export interface JevSelectedTool extends CatalogTool {
39
+ relevance: number;
40
+ role: ToolRole;
41
+ }
42
+
43
+ export interface JevRoute {
44
+ prompt: string;
45
+ selected: JevSelectedTool[];
46
+ needsAnyTool: number;
47
+ workflowShape: WorkflowShape;
48
+ needsCheckpoint: number;
49
+ }
50
+
51
+ export class JevRouter {
52
+ constructor(
53
+ private readonly lifecycle: Pick<CodeMcpLifecycle, "request">,
54
+ private readonly client: TypeSafeClient,
55
+ ) {}
56
+
57
+ async route(task: string, recentContext = "", signal?: AbortSignal): Promise<JevRoute> {
58
+ const catalog = await this.loadCatalog(signal);
59
+ if (catalog.length === 0) return emptyRoute();
60
+
61
+ const chunks = chunk(
62
+ catalog.map((tool, index) => ({ tool, index })),
63
+ JEV_CHUNK_SIZE,
64
+ );
65
+ const results = await Promise.all(
66
+ chunks.map((tools, index) =>
67
+ this.evaluateChunk(task, recentContext, tools, index === 0, signal),
68
+ ),
69
+ );
70
+ const needsAnyTool = results[0]?.needsAnyTool ?? 0;
71
+ const needsCheckpoint = results[0]?.needsCheckpoint ?? 0;
72
+ const ranked = results
73
+ .flatMap((result) => result.tools)
74
+ .sort(
75
+ (left, right) => right.relevance - left.relevance || left.call.localeCompare(right.call),
76
+ );
77
+ const selected =
78
+ needsAnyTool >= NOUL_THRESHOLD
79
+ ? ranked
80
+ .filter((tool) => tool.relevance >= TOOL_RELEVANCE_THRESHOLD)
81
+ .slice(0, MAX_SELECTED_TOOLS)
82
+ : [];
83
+ const workflowShape = workflowShapeFor(selected);
84
+ if (selected.length === 0) {
85
+ return { ...emptyRoute(), needsAnyTool, needsCheckpoint, workflowShape };
86
+ }
87
+
88
+ const inspection = await this.lifecycle.request(
89
+ "inspect",
90
+ {
91
+ calls: selected.map((tool) => tool.call),
92
+ trace_id: `jev-${randomUUID()}`,
93
+ },
94
+ signal,
95
+ );
96
+ const prelude = typeof inspection.prelude === "string" ? inspection.prelude : "";
97
+ const stubs = Array.isArray(inspection.results)
98
+ ? inspection.results.flatMap((item) => {
99
+ if (!isRecord(item) || typeof item.stub !== "string") return [];
100
+ return [item.stub];
101
+ })
102
+ : [];
103
+ if (stubs.length !== selected.length) {
104
+ throw new Error("CodeMCP inspect returned incomplete Jev-selected contracts");
105
+ }
106
+
107
+ return {
108
+ prompt: jevPrompt(
109
+ selected,
110
+ workflowShape,
111
+ needsCheckpoint,
112
+ [prelude, ...stubs].filter(Boolean).join("\n\n"),
113
+ ),
114
+ selected,
115
+ needsAnyTool,
116
+ workflowShape,
117
+ needsCheckpoint,
118
+ };
119
+ }
120
+
121
+ private async evaluateChunk(
122
+ task: string,
123
+ recentContext: string,
124
+ tools: IndexedTool[],
125
+ includeTaskQuestions: boolean,
126
+ signal?: AbortSignal,
127
+ ): Promise<ChunkResult> {
128
+ const questions: Questions = {};
129
+ if (includeTaskQuestions) {
130
+ questions.needs_any_tool = noul(
131
+ "Does satisfying the user's request require at least one configured external-service or saved-workflow tool?",
132
+ {
133
+ true: "The request needs current, private, or external state, or asks for an external action.",
134
+ false: "Explanation, reasoning, or local coding tools can fully satisfy the request.",
135
+ },
136
+ );
137
+ questions.needs_checkpoint = noul(
138
+ "Must the agent inspect an intermediate result, make a semantic decision, or obtain user approval before the next external call?",
139
+ {
140
+ true: "A model or user decision is required between tool stages.",
141
+ false: "One deterministic CodeMCP program can safely run the complete workflow.",
142
+ },
143
+ );
144
+ }
145
+ for (const { index, tool } of tools) {
146
+ const toolDescription = {
147
+ call: tool.call,
148
+ description: tool.description ?? tool.call,
149
+ };
150
+ questions[`tool_${index}`] = noul(
151
+ {
152
+ question:
153
+ "Is this exact tool necessary to satisfy an explicit part of the user's request?",
154
+ tool: toolDescription,
155
+ },
156
+ {
157
+ true: "The minimal correct workflow needs this capability.",
158
+ false: "The tool is unrelated, redundant, optional, or merely adjacent.",
159
+ },
160
+ );
161
+ questions[`role_${index}`] = choice(
162
+ {
163
+ question: "What role should this tool have in the minimal requested workflow?",
164
+ tool: toolDescription,
165
+ },
166
+ {
167
+ source: "Retrieves initial data.",
168
+ enrichment: "Retrieves data using an earlier result.",
169
+ sink: "Performs a downstream action using earlier results.",
170
+ standalone: "Independently completes one requested action.",
171
+ irrelevant: "Should not be used for this task.",
172
+ },
173
+ );
174
+ }
175
+
176
+ const response = await this.client.systemOne(
177
+ {
178
+ state: {
179
+ task,
180
+ ...(recentContext ? { recent_context: recentContext } : {}),
181
+ },
182
+ questions,
183
+ },
184
+ signal ? { signal } : undefined,
185
+ );
186
+ return {
187
+ tools: tools.map(({ index, tool }) => ({
188
+ ...tool,
189
+ relevance: noulValue(response.answers[`tool_${index}`]),
190
+ role: toolRoleValue(response.answers[`role_${index}`]),
191
+ })),
192
+ ...(includeTaskQuestions
193
+ ? {
194
+ needsAnyTool: noulValue(response.answers.needs_any_tool),
195
+ needsCheckpoint: noulValue(response.answers.needs_checkpoint),
196
+ }
197
+ : {}),
198
+ };
199
+ }
200
+
201
+ private async loadCatalog(signal?: AbortSignal): Promise<CatalogTool[]> {
202
+ const tools: CatalogTool[] = [];
203
+ let cursor = 0;
204
+ while (true) {
205
+ const page = await this.lifecycle.request(
206
+ "search",
207
+ {
208
+ mode: "inventory",
209
+ detail: "names",
210
+ limit: CATALOG_PAGE_SIZE,
211
+ cursor,
212
+ trace_id: `jev-${randomUUID()}`,
213
+ },
214
+ signal,
215
+ );
216
+ if (Array.isArray(page.results)) {
217
+ for (const item of page.results) {
218
+ if (!isRecord(item) || typeof item.call !== "string") continue;
219
+ tools.push({
220
+ call: item.call,
221
+ ...(typeof item.description === "string" ? { description: item.description } : {}),
222
+ });
223
+ }
224
+ }
225
+ if (typeof page.next_cursor !== "number") break;
226
+ if (page.next_cursor <= cursor) throw new Error("CodeMCP inventory cursor did not advance");
227
+ cursor = page.next_cursor;
228
+ }
229
+ return tools;
230
+ }
231
+ }
232
+
233
+ function emptyRoute(): JevRoute {
234
+ return {
235
+ prompt: "Jev found no configured MCP tool relevant to this request.",
236
+ selected: [],
237
+ needsAnyTool: 0,
238
+ workflowShape: "single_call",
239
+ needsCheckpoint: 0,
240
+ };
241
+ }
242
+
243
+ function workflowShapeFor(selected: JevSelectedTool[]): WorkflowShape {
244
+ if (selected.length <= 1) return "single_call";
245
+ const independent = selected.filter(
246
+ (tool) => tool.role === "source" || tool.role === "standalone",
247
+ ).length;
248
+ const downstream = selected.some((tool) => tool.role === "enrichment" || tool.role === "sink");
249
+ if (!downstream) return "parallel";
250
+ return independent > 1 ? "mixed" : "pipeline";
251
+ }
252
+
253
+ function jevPrompt(
254
+ selected: JevSelectedTool[],
255
+ workflowShape: WorkflowShape,
256
+ needsCheckpoint: number,
257
+ contracts: string,
258
+ ): string {
259
+ const roles = selected.map((tool) => `- ${tool.call}: ${tool.role}`).join("\n");
260
+ const execution =
261
+ needsCheckpoint >= NOUL_THRESHOLD
262
+ ? "Run only the first stage in codemcp_execute, return compact decision data, then preserve a model/user checkpoint before downstream calls."
263
+ : workflowInstruction(workflowShape);
264
+ return [
265
+ `Jev selected these MCP calls:\n${roles}`,
266
+ `Composition: ${workflowShape}.`,
267
+ `Execution recommendation: ${execution}`,
268
+ "Use these exact typed SDK contracts:",
269
+ "```python",
270
+ contracts,
271
+ "```",
272
+ ].join("\n\n");
273
+ }
274
+
275
+ function workflowInstruction(shape: WorkflowShape): string {
276
+ switch (shape) {
277
+ case "single_call":
278
+ return "Write and run one minimal codemcp_execute program using the selected call.";
279
+ case "parallel":
280
+ return "Write and run one codemcp_execute program using asyncio.gather for independent calls, then combine their results locally.";
281
+ case "pipeline":
282
+ return "Write and run one codemcp_execute program that passes earlier outputs into dependent calls.";
283
+ case "mixed":
284
+ return "Write and run one codemcp_execute program that gathers independent source calls, then feeds their results into downstream calls.";
285
+ }
286
+ }
287
+
288
+ function toolRoleValue(answer: unknown): ToolRole {
289
+ const role = TOOL_ROLE_BY_CHOICE[choiceValue(answer)];
290
+ if (!role) throw new Error("TypeSafe returned an invalid Jev tool role");
291
+ return role;
292
+ }
293
+
294
+ function choiceValue(answer: unknown): string {
295
+ if (!isRecord(answer) || answer.type !== "choice" || typeof answer.choice !== "string") {
296
+ throw new Error("TypeSafe returned an invalid Jev routing choice");
297
+ }
298
+ return answer.choice;
299
+ }
300
+
301
+ function noulValue(answer: unknown): number {
302
+ if (
303
+ !isRecord(answer) ||
304
+ answer.type !== "noul" ||
305
+ typeof answer.noul !== "number" ||
306
+ !Number.isFinite(answer.noul)
307
+ ) {
308
+ throw new Error("TypeSafe returned an invalid Jev routing answer");
309
+ }
310
+ return answer.noul;
311
+ }
312
+
313
+ function chunk<T>(values: T[], size: number): T[][] {
314
+ const chunks: T[][] = [];
315
+ for (let index = 0; index < values.length; index += size) {
316
+ chunks.push(values.slice(index, index + size));
317
+ }
318
+ return chunks;
319
+ }
320
+
321
+ function isRecord(value: unknown): value is Record<string, unknown> {
322
+ return typeof value === "object" && value !== null && !Array.isArray(value);
323
+ }
package/src/lifecycle.ts CHANGED
@@ -1,4 +1,9 @@
1
- import { SidecarClient, type SidecarClientOptions, type SidecarToolName } from "./mcp-client.js";
1
+ import {
2
+ SidecarClient,
3
+ type SidecarClientOptions,
4
+ type SidecarProgressCallback,
5
+ type SidecarToolName,
6
+ } from "./mcp-client.js";
2
7
  import { type CodeMcpSettings, loadCodeMcpSettings } from "./settings.js";
3
8
 
4
9
  export class CodeMcpLifecycle {
@@ -37,9 +42,10 @@ export class CodeMcpLifecycle {
37
42
  name: SidecarToolName,
38
43
  args: Record<string, unknown>,
39
44
  signal?: AbortSignal,
45
+ onProgress?: SidecarProgressCallback,
40
46
  ): Promise<Record<string, unknown>> {
41
47
  await this.reloadBarrier;
42
- return this.sidecar.call(name, args, signal);
48
+ return this.sidecar.call(name, args, signal, onProgress);
43
49
  }
44
50
 
45
51
  async warmup(): Promise<void> {
package/src/mcp-client.ts CHANGED
@@ -66,6 +66,14 @@ export function sidecarToolTimeoutMs(
66
66
  return DEFAULT_TOOL_TIMEOUT_MS;
67
67
  }
68
68
 
69
+ export interface SidecarProgress {
70
+ progress: number;
71
+ total?: number | undefined;
72
+ message?: string | undefined;
73
+ }
74
+
75
+ export type SidecarProgressCallback = (progress: SidecarProgress) => void;
76
+
69
77
  export interface SidecarClientOptions {
70
78
  packageRoot?: string;
71
79
  agentDir?: string;
@@ -153,7 +161,12 @@ export class SidecarClient {
153
161
  return this.client !== undefined && this.transport?.pid !== null;
154
162
  }
155
163
 
156
- async call(name: SidecarToolName, args: JsonObject, signal?: AbortSignal): Promise<JsonObject> {
164
+ async call(
165
+ name: SidecarToolName,
166
+ args: JsonObject,
167
+ signal?: AbortSignal,
168
+ onProgress?: SidecarProgressCallback,
169
+ ): Promise<JsonObject> {
157
170
  await this.ensureStarted(signal);
158
171
  const client = this.client;
159
172
  if (!client) throw new Error("Sidecar client failed to initialize");
@@ -165,6 +178,7 @@ export class SidecarClient {
165
178
  const result = await client.callTool({ name, arguments: args }, undefined, {
166
179
  timeout,
167
180
  ...(signal === undefined ? {} : { signal }),
181
+ ...(onProgress === undefined ? {} : { onprogress: onProgress }),
168
182
  });
169
183
 
170
184
  if (result.isError) {
package/src/modal.ts CHANGED
@@ -13,7 +13,12 @@ import {
13
13
  } from "@earendil-works/pi-tui";
14
14
  import type { ChainScope, SavedChainView } from "./chains.js";
15
15
  import { summarizeError } from "./errors.js";
16
- import type { CodeMcpSettings, EditableSettingKey, EditableSettingValue } from "./settings.js";
16
+ import {
17
+ type CodeMcpSettings,
18
+ type EditableSettingKey,
19
+ type EditableSettingValue,
20
+ setEditableSetting,
21
+ } from "./settings.js";
17
22
 
18
23
  export interface ToolModalState {
19
24
  name: string;
@@ -136,13 +141,22 @@ const OVERLAY_OPTIONS = {
136
141
  minWidth: 72,
137
142
  maxHeight: "85%",
138
143
  } as const;
139
-
140
144
  const PROBLEM_REPORT_LABEL = "Extension is broken!";
141
145
  const PROBLEM_REPORT_DESCRIPTION =
142
146
  "Well, that sucks. With this button you can ask the agent to describe the problem and prepare a GitHub issue for review. The goal is to make pi-codemcp usable for everyone, don't be lazy - submit an issue. Don't worry, you will see all prompts, this is a transparent process.";
143
147
  const PROBLEM_REPORT_SHORTCUT = "Report issue: R";
144
148
 
145
149
  const SETTING_DEFINITIONS: SettingDefinition[] = [
150
+ {
151
+ key: "jevEnabled",
152
+ label: "Enable Jev",
153
+ description:
154
+ "Let the agent call Jev on demand to select and compose relevant MCP contracts instead of local search. Requires TYPESAFE_API_KEY and sends routed tasks and enabled tool descriptions to TypeSafe.",
155
+ choices: [
156
+ { value: false, label: "false" },
157
+ { value: true, label: "true" },
158
+ ],
159
+ },
146
160
  {
147
161
  key: "backgroundWarmup",
148
162
  label: "Background warmup",
@@ -327,6 +341,7 @@ class ServerManagerModal implements Component, Focusable {
327
341
  private selectedToolIndex = 0;
328
342
  private selectedChainIndex = 0;
329
343
  private selectedSettingIndex = 0;
344
+ private settingBusy = false;
330
345
  private settingsError: string | undefined;
331
346
  private _focused = false;
332
347
 
@@ -1005,8 +1020,9 @@ class ServerManagerModal implements Component, Focusable {
1005
1020
 
1006
1021
  private cycleSelectedSetting(direction: -1 | 1): void {
1007
1022
  const definition = SETTING_DEFINITIONS[this.selectedSettingIndex];
1008
- if (!definition) return;
1009
- const current = this.options.settings[definition.key];
1023
+ if (!definition || this.settingBusy) return;
1024
+ const previous = this.options.settings;
1025
+ const current = previous[definition.key];
1010
1026
  const currentIndex = Math.max(
1011
1027
  0,
1012
1028
  definition.choices.findIndex((choice) => choice.value === current),
@@ -1014,16 +1030,23 @@ class ServerManagerModal implements Component, Focusable {
1014
1030
  const choice =
1015
1031
  definition.choices[cycleIndex(currentIndex, direction, definition.choices.length)];
1016
1032
  if (!choice) return;
1033
+ this.settingBusy = true;
1017
1034
  this.settingsError = undefined;
1035
+ this.options.settings = setEditableSetting(previous, definition.key, choice.value);
1036
+ this.requestRender();
1018
1037
  void this.options
1019
1038
  .onSetSetting(definition.key, choice.value)
1020
1039
  .then((settings) => {
1021
1040
  this.options.settings = settings;
1022
1041
  })
1023
1042
  .catch((error: unknown) => {
1043
+ this.options.settings = previous;
1024
1044
  this.settingsError = summarizeError(error);
1025
1045
  })
1026
- .finally(() => this.requestRender());
1046
+ .finally(() => {
1047
+ this.settingBusy = false;
1048
+ this.requestRender();
1049
+ });
1027
1050
  }
1028
1051
 
1029
1052
  private focusProblemReport(): void {
package/src/settings.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { readJsonObject, requireJsonObject, writeJsonObjectAtomically } from "./json-file.js";
3
3
 
4
4
  export interface CodeMcpSettings {
5
+ jevEnabled: boolean;
5
6
  backgroundWarmup: boolean;
6
7
  cacheTtlHours: number;
7
8
  executionTimeoutSeconds: number;
@@ -16,6 +17,7 @@ export type EditableSettingKey = Exclude<keyof CodeMcpSettings, "disabledTools">
16
17
  export type EditableSettingValue = boolean | number;
17
18
 
18
19
  export const DEFAULT_CODEMCP_SETTINGS: Readonly<CodeMcpSettings> = {
20
+ jevEnabled: false,
19
21
  backgroundWarmup: true,
20
22
  cacheTtlHours: 24,
21
23
  executionTimeoutSeconds: 30,
@@ -28,6 +30,7 @@ export const DEFAULT_CODEMCP_SETTINGS: Readonly<CodeMcpSettings> = {
28
30
 
29
31
  const ALLOWED_KEYS = new Set([
30
32
  "version",
33
+ "jevEnabled",
31
34
  "backgroundWarmup",
32
35
  "cacheTtlHours",
33
36
  "executionTimeoutSeconds",
@@ -45,16 +48,18 @@ export function loadCodeMcpSettings(path: string): CodeMcpSettings {
45
48
  if (version !== 1 && version !== 2) {
46
49
  throw new Error(`Unsupported CodeMCP settings version: ${String(version)}`);
47
50
  }
48
- const migrated =
51
+ const versionMigrated =
49
52
  version === 1
50
53
  ? Object.fromEntries(Object.entries(root).filter(([key]) => key !== "outputLineLimit"))
51
54
  : root;
55
+ const migrated = migrateDiscoveryMode(versionMigrated);
52
56
  const unknown = Object.keys(migrated).filter((key) => !ALLOWED_KEYS.has(key));
53
57
  if (unknown.length > 0) {
54
58
  throw new Error(`Unknown CodeMCP settings: ${unknown.join(", ")}`);
55
59
  }
56
60
 
57
61
  return {
62
+ jevEnabled: booleanSetting(migrated, "jevEnabled"),
58
63
  backgroundWarmup: booleanSetting(migrated, "backgroundWarmup"),
59
64
  cacheTtlHours: integerSetting(migrated, "cacheTtlHours", 0, 720),
60
65
  executionTimeoutSeconds: integerSetting(migrated, "executionTimeoutSeconds", 1, 300),
@@ -69,6 +74,7 @@ export function loadCodeMcpSettings(path: string): CodeMcpSettings {
69
74
  export function saveCodeMcpSettings(path: string, settings: CodeMcpSettings): void {
70
75
  writeJsonObjectAtomically(path, {
71
76
  version: 2,
77
+ jevEnabled: settings.jevEnabled,
72
78
  backgroundWarmup: settings.backgroundWarmup,
73
79
  cacheTtlHours: settings.cacheTtlHours,
74
80
  executionTimeoutSeconds: settings.executionTimeoutSeconds,
@@ -85,7 +91,7 @@ export function setEditableSetting(
85
91
  key: EditableSettingKey,
86
92
  value: EditableSettingValue,
87
93
  ): CodeMcpSettings {
88
- if (key === "backgroundWarmup") {
94
+ if (key === "jevEnabled" || key === "backgroundWarmup") {
89
95
  if (typeof value !== "boolean") throw new TypeError(`${key} must be a boolean`);
90
96
  return { ...settings, [key]: value };
91
97
  }
@@ -112,7 +118,21 @@ function cloneDefaults(): CodeMcpSettings {
112
118
  return { ...DEFAULT_CODEMCP_SETTINGS, disabledTools: {} };
113
119
  }
114
120
 
115
- function booleanSetting(root: Record<string, unknown>, key: "backgroundWarmup"): boolean {
121
+ function migrateDiscoveryMode(root: Record<string, unknown>): Record<string, unknown> {
122
+ if (root.discoveryMode === undefined) return root;
123
+ if (root.discoveryMode !== "search" && root.discoveryMode !== "jev") {
124
+ throw new TypeError("discoveryMode must be search or jev");
125
+ }
126
+ return {
127
+ ...Object.fromEntries(Object.entries(root).filter(([key]) => key !== "discoveryMode")),
128
+ jevEnabled: root.jevEnabled ?? root.discoveryMode === "jev",
129
+ };
130
+ }
131
+
132
+ function booleanSetting(
133
+ root: Record<string, unknown>,
134
+ key: "jevEnabled" | "backgroundWarmup",
135
+ ): boolean {
116
136
  const value = root[key] ?? DEFAULT_CODEMCP_SETTINGS[key];
117
137
  if (typeof value !== "boolean") throw new TypeError(`${key} must be a boolean`);
118
138
  return value;
@@ -120,7 +140,7 @@ function booleanSetting(root: Record<string, unknown>, key: "backgroundWarmup"):
120
140
 
121
141
  function integerSetting(
122
142
  root: Record<string, unknown>,
123
- key: Exclude<EditableSettingKey, "backgroundWarmup">,
143
+ key: Exclude<EditableSettingKey, "jevEnabled" | "backgroundWarmup">,
124
144
  minimum: number,
125
145
  maximum: number,
126
146
  ): number {
package/src/tools.ts CHANGED
@@ -12,7 +12,9 @@ import {
12
12
  previewExecutionValue,
13
13
  renderExecutionResult,
14
14
  } from "./execution-rendering.js";
15
+ import type { JevRouter } from "./jev-router.js";
15
16
  import type { CodeMcpLifecycle } from "./lifecycle.js";
17
+ import type { SidecarProgress } from "./mcp-client.js";
16
18
  import { type CodeMcpOutputDetails, formatCodeMcpOutput } from "./output.js";
17
19
  import {
18
20
  EXECUTE_PROMPT_GUIDELINES,
@@ -73,6 +75,8 @@ const SearchParameters = Type.Object({
73
75
  ),
74
76
  });
75
77
 
78
+ const JevRouteParameters = Type.Object({});
79
+
76
80
  const InspectParameters = Type.Object({
77
81
  calls: Type.Array(Type.String({ minLength: 1 }), {
78
82
  minItems: 1,
@@ -162,6 +166,76 @@ const EditExecuteParameters = Type.Object({
162
166
  }),
163
167
  });
164
168
 
169
+ export function registerJevRouteTool(
170
+ pi: ExtensionAPI,
171
+ getRouter: () => Pick<JevRouter, "route"> | undefined,
172
+ ): void {
173
+ pi.registerTool({
174
+ name: "codemcp_route",
175
+ label: "Jev MCP Route",
176
+ description:
177
+ "Use Jev to select every configured MCP call relevant to a complete task, classify each call's workflow role, recommend parallel or dependent composition, and return exact typed SDK contracts. Use when the task may require external services or saved workflows. If no configured capability applies, returns no calls.",
178
+ promptSnippet: "Select and compose MCP calls for a complete task with Jev",
179
+ promptGuidelines: [
180
+ "Use codemcp_route once per distinct task when MCP capabilities may be needed; route again only if the task changes or the selected contracts cannot complete it. It reads the current request and recent conversation context automatically.",
181
+ "After codemcp_route returns contracts, immediately write and run the recommended minimal codemcp_execute program instead of stopping to describe the plan.",
182
+ "Follow codemcp_route composition guidance: gather independent calls, sequence dependent calls, and preserve a model turn only for semantic decisions or approvals.",
183
+ ],
184
+ parameters: JevRouteParameters,
185
+ async execute(_toolCallId, _params, signal, onUpdate, ctx) {
186
+ const router = getRouter();
187
+ if (!router) throw new Error("Jev routing requires TYPESAFE_API_KEY");
188
+ const { task, recentContext } = currentRouteTask(ctx.sessionManager.buildContextEntries());
189
+ onUpdate?.({
190
+ content: [{ type: "text", text: "Jev is selecting and composing MCP calls..." }],
191
+ details: undefined,
192
+ });
193
+ try {
194
+ const route = await router.route(task, recentContext, signal);
195
+ return {
196
+ content: [{ type: "text", text: route.prompt }],
197
+ details: {
198
+ selected: route.selected.map((tool) => ({
199
+ call: tool.call,
200
+ relevance: tool.relevance,
201
+ role: tool.role,
202
+ })),
203
+ needsAnyTool: route.needsAnyTool,
204
+ workflowShape: route.workflowShape,
205
+ needsCheckpoint: route.needsCheckpoint,
206
+ },
207
+ };
208
+ } catch (error) {
209
+ const active = pi.getActiveTools();
210
+ if (!active.includes("codemcp_search")) {
211
+ pi.setActiveTools([...active, "codemcp_search"]);
212
+ }
213
+ throw error;
214
+ }
215
+ },
216
+ renderCall(_args, theme) {
217
+ return new Text(theme.fg("toolTitle", theme.bold("Jev MCP Route")), 0, 0);
218
+ },
219
+ renderResult(result, { expanded, isPartial }, theme) {
220
+ if (isPartial) return new Text(theme.fg("warning", "Jev is routing..."), 0, 0);
221
+ if (expanded) return renderExpandedJson(result.content);
222
+ const details = result.details as
223
+ | { selected?: Array<{ call?: string }>; workflowShape?: string }
224
+ | undefined;
225
+ const selected = details?.selected ?? [];
226
+ let text = theme.fg(
227
+ "success",
228
+ `\n${selected.length} calls · ${details?.workflowShape ?? "no workflow"}`,
229
+ );
230
+ for (const tool of selected.slice(0, 4)) {
231
+ if (tool.call) text += `\n${theme.fg("dim", ` ${tool.call}`)}`;
232
+ }
233
+ text += `\n${theme.fg("muted", keyHint("app.tools.expand", "routing details"))}`;
234
+ return new Text(text, 0, 0);
235
+ },
236
+ });
237
+ }
238
+
165
239
  export function registerCodeMcpTools(
166
240
  pi: ExtensionAPI,
167
241
  lifecycle: CodeMcpLifecycle,
@@ -330,6 +404,7 @@ export function registerCodeMcpTools(
330
404
  ...(params.inputRef === undefined ? {} : { input_ref: params.inputRef }),
331
405
  },
332
406
  signal,
407
+ (progress) => onUpdate?.(executionProgressUpdate(progress)),
333
408
  );
334
409
  return formatExecutionResponse(result, lifecycle);
335
410
  },
@@ -384,6 +459,7 @@ export function registerCodeMcpTools(
384
459
  trace_id: toolCallId,
385
460
  },
386
461
  signal,
462
+ (progress) => onUpdate?.(executionProgressUpdate(progress)),
387
463
  );
388
464
  return formatExecutionResponse(result, lifecycle);
389
465
  },
@@ -577,6 +653,17 @@ export function registerCodeMcpTools(
577
653
  });
578
654
  }
579
655
 
656
+ function executionProgressUpdate(progress: SidecarProgress) {
657
+ const currentCall = progress.message === "executing" ? undefined : progress.message;
658
+ return {
659
+ content: [{ type: "text" as const, text: progress.message ?? "Executing MCP calls..." }],
660
+ details: {
661
+ callsMade: progress.progress,
662
+ ...(currentCall === undefined ? {} : { currentCall }),
663
+ },
664
+ };
665
+ }
666
+
580
667
  function formatExecutionResponse(result: Record<string, unknown>, lifecycle: CodeMcpLifecycle) {
581
668
  const ok = result.ok === true;
582
669
  const modelValue = ok
@@ -636,6 +723,40 @@ function outputLimits(lifecycle: CodeMcpLifecycle): { maxBytes: number } {
636
723
  return { maxBytes: settings.outputLimitKiB * 1024 };
637
724
  }
638
725
 
726
+ function currentRouteTask(entries: readonly unknown[]): {
727
+ task: string;
728
+ recentContext: string;
729
+ } {
730
+ const messages: Array<{ role: "user" | "assistant"; text: string }> = [];
731
+ for (const entry of entries) {
732
+ if (!isRecord(entry) || !isRecord(entry.message)) continue;
733
+ const role = entry.message.role;
734
+ if (role !== "user" && role !== "assistant") continue;
735
+ const text = messageText(entry.message.content).trim();
736
+ if (text) messages.push({ role, text });
737
+ }
738
+ let currentIndex = messages.length - 1;
739
+ while (currentIndex >= 0 && messages[currentIndex]?.role !== "user") currentIndex -= 1;
740
+ const current = messages[currentIndex];
741
+ if (!current) throw new Error("Jev routing requires a text user request");
742
+ const recentContext = messages
743
+ .slice(Math.max(0, currentIndex - 3), currentIndex)
744
+ .map((message) => `${message.role === "user" ? "User" : "Assistant"}: ${message.text}`)
745
+ .join("\n\n")
746
+ .slice(-6_000);
747
+ return { task: current.text, recentContext };
748
+ }
749
+
750
+ function messageText(content: unknown): string {
751
+ if (typeof content === "string") return content;
752
+ if (!Array.isArray(content)) return "";
753
+ return content
754
+ .flatMap((item) =>
755
+ isRecord(item) && item.type === "text" && typeof item.text === "string" ? [item.text] : [],
756
+ )
757
+ .join("\n");
758
+ }
759
+
639
760
  function truncate(value: string, maxLength: number): string {
640
761
  return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`;
641
762
  }