@voybio/ace-swarm 2.4.0 → 2.4.2

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 (80) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +502 -56
  3. package/assets/.agents/ACE/agent-qa/instructions.md +11 -0
  4. package/assets/agent-state/MODULES/schemas/RUNTIME_TOOL_SPEC_REGISTRY.schema.json +43 -0
  5. package/assets/agent-state/runtime-tool-specs.json +70 -2
  6. package/assets/instructions/ACE_Coder.instructions.md +13 -0
  7. package/assets/instructions/ACE_UI.instructions.md +11 -0
  8. package/dist/ace-context.js +70 -11
  9. package/dist/ace-internal-tools.d.ts +3 -1
  10. package/dist/ace-internal-tools.js +10 -2
  11. package/dist/agent-runtime/role-adapters.d.ts +18 -1
  12. package/dist/agent-runtime/role-adapters.js +49 -5
  13. package/dist/astgrep-index.d.ts +48 -0
  14. package/dist/astgrep-index.js +126 -1
  15. package/dist/cli.js +487 -17
  16. package/dist/discovery-runtime-wrappers.d.ts +108 -0
  17. package/dist/discovery-runtime-wrappers.js +615 -0
  18. package/dist/helpers/bootstrap.js +1 -1
  19. package/dist/helpers/constants.d.ts +4 -2
  20. package/dist/helpers/constants.js +8 -0
  21. package/dist/helpers/path-utils.d.ts +8 -1
  22. package/dist/helpers/path-utils.js +27 -8
  23. package/dist/helpers/store-resolution.js +7 -3
  24. package/dist/hermes/bridge-protocol.d.ts +41 -0
  25. package/dist/hermes/bridge-protocol.js +70 -0
  26. package/dist/hermes/launch-profile.d.ts +19 -0
  27. package/dist/hermes/launch-profile.js +81 -0
  28. package/dist/hermes/session-manager.d.ts +42 -0
  29. package/dist/hermes/session-manager.js +187 -0
  30. package/dist/job-scheduler.js +30 -4
  31. package/dist/json-sanitizer.d.ts +16 -0
  32. package/dist/json-sanitizer.js +26 -0
  33. package/dist/local-model-policy.d.ts +27 -0
  34. package/dist/local-model-policy.js +84 -0
  35. package/dist/local-model-runtime.d.ts +17 -0
  36. package/dist/local-model-runtime.js +77 -20
  37. package/dist/model-bridge.d.ts +6 -1
  38. package/dist/model-bridge.js +338 -21
  39. package/dist/orchestrator-supervisor.d.ts +42 -0
  40. package/dist/orchestrator-supervisor.js +110 -3
  41. package/dist/plan-proposal.d.ts +115 -0
  42. package/dist/plan-proposal.js +1073 -0
  43. package/dist/runtime-executor.d.ts +6 -1
  44. package/dist/runtime-executor.js +72 -5
  45. package/dist/runtime-tool-specs.d.ts +19 -1
  46. package/dist/runtime-tool-specs.js +67 -26
  47. package/dist/schemas.js +30 -1
  48. package/dist/server.d.ts +3 -0
  49. package/dist/server.js +73 -4
  50. package/dist/shared.d.ts +1 -0
  51. package/dist/shared.js +2 -0
  52. package/dist/store/bootstrap-store.d.ts +1 -0
  53. package/dist/store/bootstrap-store.js +8 -2
  54. package/dist/store/materializers/vericify-projector.js +3 -0
  55. package/dist/store/repositories/local-model-runtime-repository.d.ts +13 -1
  56. package/dist/store/repositories/local-model-runtime-repository.js +4 -1
  57. package/dist/store/repositories/vericify-repository.d.ts +1 -1
  58. package/dist/tools-agent.d.ts +20 -0
  59. package/dist/tools-agent.js +544 -29
  60. package/dist/tools-discovery.js +135 -0
  61. package/dist/tools-files.js +768 -66
  62. package/dist/tools-framework.js +80 -61
  63. package/dist/tools.d.ts +4 -1
  64. package/dist/tools.js +35 -13
  65. package/dist/tui/chat.d.ts +8 -0
  66. package/dist/tui/chat.js +74 -0
  67. package/dist/tui/index.d.ts +7 -0
  68. package/dist/tui/index.js +45 -2
  69. package/dist/tui/layout.d.ts +1 -0
  70. package/dist/tui/layout.js +4 -1
  71. package/dist/tui/ollama.d.ts +8 -1
  72. package/dist/tui/ollama.js +53 -12
  73. package/dist/tui/openai-compatible.d.ts +13 -0
  74. package/dist/tui/openai-compatible.js +305 -5
  75. package/dist/tui/provider-discovery.d.ts +1 -0
  76. package/dist/tui/provider-discovery.js +50 -24
  77. package/dist/vericify-bridge.d.ts +4 -1
  78. package/dist/vericify-bridge.js +3 -0
  79. package/package.json +2 -1
  80. package/scripts/hermes_bridge_worker.py +136 -0
package/dist/cli.js CHANGED
@@ -2,9 +2,9 @@
2
2
  import { ACE_TASKS_ROOT_REL, ALL_MCP_CLIENTS, ALL_LLM_PROVIDERS, DEFAULTS_ROOT, PACKAGE_ROOT, WORKSPACE_ROOT, fileExists, getAllMcpServerConfigSnippets, getMcpClientInstallHint, getMcpServerConfigSnippet, wsPath, } from "./helpers.js";
3
3
  import { refreshAstgrepIndex } from "./astgrep-index.js";
4
4
  import { scanWorkspaceDelta } from "./index-store.js";
5
- import { startStdioServer } from "./server.js";
5
+ import { startHermesShadowStdioServer, startStdioServer } from "./server.js";
6
6
  import { appendRunLedgerEntrySafe } from "./run-ledger.js";
7
- import { waitForPendingStatusEventMirrors } from "./status-events.js";
7
+ import { appendStatusEventSafe, waitForPendingStatusEventMirrors } from "./status-events.js";
8
8
  import { bootstrapStoreWorkspace } from "./store/bootstrap-store.js";
9
9
  import { HostFileMaterializer } from "./store/materializers/host-file-materializer.js";
10
10
  import { openStore } from "./store/ace-packed-store.js";
@@ -12,18 +12,24 @@ import { DiscoveryRepository } from "./store/repositories/discovery-repository.j
12
12
  import { withStoreWriteCoordinator } from "./store/write-coordinator.js";
13
13
  import { getWorkspaceStorePath, readStoreBlobSync, readStoreJsonSync, } from "./store/store-snapshot.js";
14
14
  import { ensureCanonicalWorkspaceStore } from "./store/workspace-store-paths.js";
15
- import { readFileSync } from "node:fs";
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { resolve } from "node:path";
17
+ import { spawn } from "node:child_process";
16
18
  import { runTui } from "./tui/index.js";
17
- import { buildOpenAiCompatibleBaseUrl, buildProviderDoctorCommands, defaultModelForProvider, discoverProviderContext, isLocalLlmProvider, normalizeLocalBaseUrl, normalizeProvider, scanLocalModelRuntimes, } from "./tui/provider-discovery.js";
19
+ import { buildOpenAiCompatibleBaseUrl, buildProviderDoctorCommands, defaultModelForProvider, discoverProviderContext, isLocalLlmProvider, normalizeLocalBaseUrl, normalizeLlamaCppHfModelName, normalizeProvider, scanLocalModelRuntimes, } from "./tui/provider-discovery.js";
18
20
  import { diagnoseChatRuntimeConfig, OpenAICompatibleClient, } from "./tui/openai-compatible.js";
21
+ import { runShellCommand } from "./runtime-command.js";
22
+ import { resolveHermesLaunchProfile } from "./hermes/launch-profile.js";
19
23
  function printHelp() {
20
24
  console.log(`ACE Swarm CLI
21
25
 
22
26
  Usage:
23
27
  ace mcp Start MCP server over stdio
28
+ ace mcp-shadow --tools <csv> Start filtered Hermes-local MCP shadow server over stdio
24
29
  ace serve Alias for mcp
25
30
  ace tui [options] Launch interactive TUI dashboard
26
31
  ace init [options] Bootstrap the ACE store into current workspace
32
+ ace connect [provider] Register a live local runtime or hosted provider in the store
27
33
  ace turnkey [options] Project minimal workspace bootstrap stubs from the ACE store
28
34
  ace doctor [options] Validate ACE runtime + MCP readiness
29
35
  ace cache [options] Cache ACE artifacts into ace-state.ace and optionally clean projections
@@ -37,6 +43,16 @@ Options for tui:
37
43
  --model <name> Model name override (defaults from profile/settings discovery)
38
44
  --base-url <url> Local runtime base URL override
39
45
  --ollama-url <url> Legacy alias for --base-url
46
+ --engine <name> direct|hermes_local execution engine
47
+ --hermes Alias for --engine hermes_local
48
+ --hermes-root <path> User-supplied Hermes checkout/import root
49
+ --hermes-python <path> User-supplied Python executable with Hermes deps
50
+ --hermes-uv-project <path> Optional uv project root for operator-managed Hermes
51
+ --hermes-command-json <json> Advanced command array override for Hermes Python
52
+
53
+ Hermes is optional and user-supplied. \`ace tui\` and \`ace doctor\` only resolve
54
+ Hermes when you pass \`--hermes\` / \`--engine hermes_local\` or set the matching
55
+ Hermes environment variables.
40
56
 
41
57
  Options for init:
42
58
  --project <name> Project name stored in agent-state/ace-state.ace metadata
@@ -48,12 +64,25 @@ Options for init:
48
64
  --base-url <url> Runtime base URL override (local or OpenAI-compatible)
49
65
  --ollama-url <url> Legacy alias for --base-url
50
66
 
67
+ Options for connect:
68
+ llama-server|llama-cli Optional launcher name for llama.cpp HF runtimes
69
+ --hf <repo> Hugging Face model repo or local model identifier
70
+ --model <name> Alias for --hf when you already know the model name
71
+ --base-url <url> OpenAI-compatible endpoint for the live runtime
72
+ --provider <name> Explicit provider override when not using a launcher alias
73
+
51
74
  Options for doctor:
52
75
  --llm <provider> ollama|llama.cpp|codex|claude|gemini|copilot|... (default: auto from agent-state/ace-state.ace)
53
76
  --model <name> Model name override
54
77
  --base-url <url> Runtime base URL override
55
78
  --ollama-url <url> Legacy alias for --base-url
56
79
  --scan Probe common local Ollama + llama.cpp endpoints when URL is unset
80
+ --hermes Also run optional Hermes-local readiness checks
81
+ --hermes-root <path> User-supplied Hermes checkout/import root (or ACE_HERMES_ROOT)
82
+ --hermes-python <path> User-supplied Python executable (or HERMES_PYTHON)
83
+ --hermes-uv-project <path> Optional uv project root for operator-managed Hermes
84
+ --hermes-command-json <json> Advanced command array override for Hermes Python
85
+ --repair-ollama Opt-in: run ollama pull <model> when doctor finds a missing Ollama model
57
86
 
58
87
  Options for cache:
59
88
  --dry-run Preview what would be cached and cleaned (no writes/deletes)
@@ -63,6 +92,11 @@ Options for mcp-config:
63
92
  --client <name> codex|vscode|copilot|claude|cursor|antigravity
64
93
  --all Print all client snippets for optional global install
65
94
 
95
+ Hermes environment:
96
+ ACE_HERMES_ROOT User-supplied Hermes checkout/import root
97
+ HERMES_PYTHON User-supplied Python with Hermes dependencies
98
+ ACE_HERMES_UV_PROJECT Optional operator-managed uv project root
99
+
66
100
  preconfig writes .mcp-config/ at the workspace root with ready-to-use config files for every
67
101
  supported MCP client plus a root .mcp.json for GitHub Copilot CLI. Run once after ace init.
68
102
  Each file includes the install hint for its client.
@@ -77,6 +111,180 @@ function readFlagValue(args, flag) {
77
111
  return undefined;
78
112
  return args[index + 1];
79
113
  }
114
+ async function runCommandArray(command, args, options) {
115
+ const [bin, ...prefixArgs] = command;
116
+ if (!bin)
117
+ return { ok: false, stdout: "", stderr: "", detail: "empty command array" };
118
+ return await new Promise((resolveCommand) => {
119
+ const child = spawn(bin, [...prefixArgs, ...args], {
120
+ cwd: options.cwd,
121
+ env: options.env,
122
+ stdio: ["ignore", "pipe", "pipe"],
123
+ });
124
+ let stdout = "";
125
+ let stderr = "";
126
+ const timeout = setTimeout(() => {
127
+ child.kill("SIGTERM");
128
+ }, options.timeoutMs ?? 5000);
129
+ child.stdout?.setEncoding("utf8");
130
+ child.stderr?.setEncoding("utf8");
131
+ child.stdout?.on("data", (chunk) => {
132
+ stdout += String(chunk);
133
+ });
134
+ child.stderr?.on("data", (chunk) => {
135
+ stderr += String(chunk);
136
+ });
137
+ child.on("error", (error) => {
138
+ clearTimeout(timeout);
139
+ resolveCommand({ ok: false, stdout, stderr, detail: error.message });
140
+ });
141
+ child.on("close", (code) => {
142
+ clearTimeout(timeout);
143
+ resolveCommand({
144
+ ok: code === 0,
145
+ stdout,
146
+ stderr,
147
+ detail: code === 0 ? "exit 0" : `exit ${code}; ${stderr.slice(0, 300)}`,
148
+ });
149
+ });
150
+ });
151
+ }
152
+ function mcpFrame(payload) {
153
+ const body = JSON.stringify(payload);
154
+ return `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`;
155
+ }
156
+ async function probeHermesShadowMcp(cliPath, toolAllowlist) {
157
+ return await new Promise((resolveProbe, rejectProbe) => {
158
+ const child = spawn(process.execPath, [cliPath, "mcp-shadow", "--tools", toolAllowlist.join(",")], {
159
+ cwd: PACKAGE_ROOT,
160
+ stdio: ["pipe", "pipe", "pipe"],
161
+ });
162
+ let stdout = "";
163
+ let stderr = "";
164
+ const timeout = setTimeout(() => {
165
+ child.kill("SIGTERM");
166
+ rejectProbe(new Error(`shadow MCP probe timed out${stderr ? `: ${stderr.slice(0, 200)}` : ""}`));
167
+ }, 2500);
168
+ child.stdout.on("data", (chunk) => {
169
+ stdout += chunk.toString("utf8");
170
+ if (stdout.includes('"tools"') && stdout.includes(toolAllowlist[0] ?? "")) {
171
+ clearTimeout(timeout);
172
+ child.kill("SIGTERM");
173
+ resolveProbe(`shadow MCP initialize/tools-list probe succeeded; tools=${toolAllowlist.join(",")}`);
174
+ }
175
+ });
176
+ child.stderr.on("data", (chunk) => {
177
+ stderr += chunk.toString("utf8");
178
+ });
179
+ child.on("error", (error) => {
180
+ clearTimeout(timeout);
181
+ rejectProbe(error);
182
+ });
183
+ child.on("close", () => {
184
+ clearTimeout(timeout);
185
+ if (stdout.includes('"tools"') && stdout.includes(toolAllowlist[0] ?? "")) {
186
+ resolveProbe(`shadow MCP initialize/tools-list probe succeeded; tools=${toolAllowlist.join(",")}`);
187
+ }
188
+ else {
189
+ rejectProbe(new Error(`shadow MCP probe did not return tools/list${stderr ? `: ${stderr.slice(0, 200)}` : ""}`));
190
+ }
191
+ });
192
+ child.stdin.write(mcpFrame({
193
+ jsonrpc: "2.0",
194
+ id: 1,
195
+ method: "initialize",
196
+ params: {
197
+ protocolVersion: "2024-11-05",
198
+ capabilities: {},
199
+ clientInfo: { name: "ace-doctor", version: "0.0.0" },
200
+ },
201
+ }));
202
+ child.stdin.write(mcpFrame({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }));
203
+ child.stdin.write(mcpFrame({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }));
204
+ });
205
+ }
206
+ async function appendHermesLocalReadinessChecks(checks, profile) {
207
+ const hermesRoot = profile.hermes_root;
208
+ const workerPath = profile.worker_path;
209
+ const cliPath = resolve(PACKAGE_ROOT, "dist", "cli.js");
210
+ const toolAllowlist = ["get_all_agents_summary"];
211
+ checks.push({
212
+ name: "Hermes launch profile",
213
+ ok: true,
214
+ detail: `source=${profile.source}; mode=${profile.mode}; command=${JSON.stringify(profile.command)}; hermes_root=${profile.hermes_root ?? "not configured"}`,
215
+ });
216
+ checks.push({
217
+ name: "Hermes worker packaged",
218
+ ok: existsSync(workerPath),
219
+ detail: existsSync(workerPath)
220
+ ? workerPath
221
+ : `Missing ${workerPath}; run npm run build.`,
222
+ });
223
+ checks.push({
224
+ name: "Hermes checkout import root",
225
+ ok: Boolean(hermesRoot &&
226
+ existsSync(resolve(hermesRoot, "run_agent.py")) &&
227
+ existsSync(resolve(hermesRoot, "tools", "mcp_tool.py"))),
228
+ detail: hermesRoot && existsSync(resolve(hermesRoot, "run_agent.py"))
229
+ ? hermesRoot
230
+ : "ACE does not install Hermes. Set ACE_HERMES_ROOT or pass --hermes-root to a Hermes checkout.",
231
+ });
232
+ const pythonResolves = await runCommandArray(profile.command, ["-c", "import sys; print(sys.executable)"], {
233
+ cwd: PACKAGE_ROOT,
234
+ timeoutMs: 5000,
235
+ });
236
+ checks.push({
237
+ name: "Hermes Python command",
238
+ ok: pythonResolves.ok,
239
+ detail: pythonResolves.ok ? `${JSON.stringify(profile.command)} -> ${pythonResolves.stdout.trim()}` : pythonResolves.detail,
240
+ });
241
+ const importEnv = {
242
+ ...process.env,
243
+ PYTHONPATH: [hermesRoot, process.env.PYTHONPATH].filter(Boolean).join(":"),
244
+ };
245
+ const importResult = hermesRoot
246
+ ? await runCommandArray(profile.command, ["-c", "from run_agent import AIAgent; print(AIAgent.__name__)"], {
247
+ cwd: hermesRoot,
248
+ env: importEnv,
249
+ timeoutMs: 5000,
250
+ })
251
+ : { ok: false, detail: "Hermes root not configured", stdout: "", stderr: "" };
252
+ checks.push({
253
+ name: "Hermes Python imports",
254
+ ok: importResult.ok,
255
+ detail: importResult.ok ? "run_agent.AIAgent import ok" : importResult.detail,
256
+ });
257
+ checks.push({
258
+ name: "ACE shadow MCP command",
259
+ ok: existsSync(cliPath),
260
+ detail: existsSync(cliPath)
261
+ ? JSON.stringify([process.execPath, cliPath, "mcp-shadow", "--tools", toolAllowlist.join(",")])
262
+ : `Missing ${cliPath}; run npm run build.`,
263
+ });
264
+ if (existsSync(cliPath)) {
265
+ try {
266
+ checks.push({
267
+ name: "ACE shadow MCP handshake",
268
+ ok: true,
269
+ detail: await probeHermesShadowMcp(cliPath, toolAllowlist),
270
+ });
271
+ }
272
+ catch (error) {
273
+ checks.push({
274
+ name: "ACE shadow MCP handshake",
275
+ ok: false,
276
+ detail: error instanceof Error ? error.message : String(error),
277
+ });
278
+ }
279
+ }
280
+ else {
281
+ checks.push({
282
+ name: "ACE shadow MCP handshake",
283
+ ok: false,
284
+ detail: `Cannot probe shadow MCP until ${cliPath} exists; run npm run build.`,
285
+ });
286
+ }
287
+ }
80
288
  function readLlmProfile() {
81
289
  const storeProfile = readStoreJsonSync(WORKSPACE_ROOT, "state/runtime/llm_profile") ??
82
290
  readStoreJsonSync(WORKSPACE_ROOT, "state/runtime/llm-profile");
@@ -98,6 +306,29 @@ function readLlmProfile() {
98
306
  function readBaseUrlFlag(args) {
99
307
  return normalizeLocalBaseUrl(readFlagValue(args, "--base-url") ?? readFlagValue(args, "--ollama-url"));
100
308
  }
309
+ function readConnectModelFlag(args) {
310
+ return (readFlagValue(args, "--hf") ??
311
+ readFlagValue(args, "-hf") ??
312
+ readFlagValue(args, "--model"))?.trim();
313
+ }
314
+ function parseConnectArgs(args) {
315
+ const firstToken = args[0];
316
+ const hasLauncher = Boolean(firstToken && !firstToken.startsWith("-"));
317
+ const launcher = hasLauncher ? firstToken?.trim().toLowerCase() : undefined;
318
+ const providerOverride = normalizeProvider(readFlagValue(args, "--provider")?.trim());
319
+ const provider = providerOverride ??
320
+ (launcher === "llama-server" || launcher === "llama-cli" ? "llama.cpp" : providerOverride);
321
+ const rawModel = readConnectModelFlag(args);
322
+ const model = provider === "llama.cpp" ? normalizeLlamaCppHfModelName(rawModel) ?? rawModel : rawModel;
323
+ const baseUrl = readBaseUrlFlag(args);
324
+ const launcherCommand = hasLauncher ? args.join(" ").trim() : undefined;
325
+ return {
326
+ provider: provider,
327
+ model,
328
+ baseUrl,
329
+ launcherCommand,
330
+ };
331
+ }
101
332
  function parseLlmOptions(args) {
102
333
  const profile = readLlmProfile();
103
334
  const selected = normalizeProvider(readFlagValue(args, "--llm")?.trim() ?? profile?.provider?.trim());
@@ -107,8 +338,11 @@ function parseLlmOptions(args) {
107
338
  throw new Error(`Unsupported LLM provider: ${selected}`);
108
339
  }
109
340
  const provider = selected;
110
- const defaultModel = defaultModelForProvider(provider);
111
- const llmModel = readFlagValue(args, "--model")?.trim() || profile?.model || defaultModel;
341
+ const explicitModel = readFlagValue(args, "--model")?.trim();
342
+ const profileModel = profile?.model?.trim();
343
+ const llmModel = explicitModel ||
344
+ profileModel ||
345
+ (provider === "ollama" ? defaultModelForProvider(provider) : undefined);
112
346
  const llmBaseUrl = readBaseUrlFlag(args) ?? normalizeLocalBaseUrl(profile?.base_url);
113
347
  return {
114
348
  llmProvider: provider,
@@ -121,9 +355,11 @@ async function writeLlmProfile(profile) {
121
355
  await withStoreWriteCoordinator(storePath, async () => {
122
356
  const payload = {
123
357
  provider: profile.provider,
124
- model: profile.model,
125
358
  generated_at: new Date().toISOString(),
126
359
  };
360
+ if (profile.model) {
361
+ payload.model = profile.model;
362
+ }
127
363
  if (profile.baseUrl) {
128
364
  payload.base_url = profile.baseUrl;
129
365
  if (profile.provider === "ollama") {
@@ -131,6 +367,9 @@ async function writeLlmProfile(profile) {
131
367
  payload.default_api_key = "ollama";
132
368
  }
133
369
  }
370
+ if (profile.launcherCommand) {
371
+ payload.launch_command = profile.launcherCommand;
372
+ }
134
373
  const doctorCommands = buildProviderDoctorCommands(profile.provider, profile.model, profile.baseUrl);
135
374
  const store = await openStore(storePath);
136
375
  try {
@@ -153,6 +392,40 @@ async function writeLlmProfile(profile) {
153
392
  }, { operation_label: "writeLlmProfile" });
154
393
  return `${storePath}#state/runtime/llm_profile`;
155
394
  }
395
+ async function runConnect(args) {
396
+ const parsed = parseConnectArgs(args);
397
+ if (!parsed.provider) {
398
+ throw new Error("ace connect expects a provider or launcher command. Try `ace connect llama-server -hf <model> --base-url http://127.0.0.1:8080`.");
399
+ }
400
+ if (!ALL_LLM_PROVIDERS.includes(parsed.provider)) {
401
+ throw new Error(`Unsupported LLM provider: ${parsed.provider}`);
402
+ }
403
+ if (isLocalLlmProvider(parsed.provider) && !parsed.model) {
404
+ throw new Error("ace connect for local runtimes requires an explicit model. Pass `--hf <repo>` or `--model <name>`.");
405
+ }
406
+ const storeResult = await bootstrapStoreWorkspace({
407
+ workspaceRoot: WORKSPACE_ROOT,
408
+ llm: parsed.provider,
409
+ model: parsed.model,
410
+ baseUrl: parsed.baseUrl,
411
+ includeMcpConfig: false,
412
+ includeClientConfigBundle: false,
413
+ launcherCommand: parsed.launcherCommand,
414
+ });
415
+ console.log("ACE connect complete");
416
+ console.log(`Workspace: ${WORKSPACE_ROOT}`);
417
+ console.log(`Store: ${storeResult.storePath}`);
418
+ console.log(`Provider: ${parsed.provider}`);
419
+ console.log(`Model: ${parsed.model ?? "(not set)"}`);
420
+ console.log(`Base URL: ${parsed.baseUrl ?? "(not set)"}`);
421
+ if (parsed.launcherCommand) {
422
+ console.log(`Launcher: ${parsed.launcherCommand}`);
423
+ }
424
+ console.log(`Profile path: ${storeResult.storePath}#state/runtime/llm_profile`);
425
+ for (const warning of storeResult.warnings) {
426
+ console.warn(` [store] ${warning}`);
427
+ }
428
+ }
156
429
  async function recordDiscoveryProfile(input) {
157
430
  const storePath = getWorkspaceStorePath(WORKSPACE_ROOT);
158
431
  await withStoreWriteCoordinator(storePath, async () => {
@@ -296,6 +569,13 @@ async function listLocalRuntimeModels(provider, baseUrl) {
296
569
  headers: { Accept: "application/json" },
297
570
  });
298
571
  if (!response.ok) {
572
+ const text = await response.text().catch(() => "");
573
+ if (response.status >= 500 && /unable to load model/i.test(text)) {
574
+ const error = new Error(`${response.status} ${response.statusText}: ollama_model_load_error. ` +
575
+ `Suggested remediation: run ollama pull <model> or ace doctor --repair-ollama.`);
576
+ error.reason_code = "ollama_model_load_error";
577
+ throw error;
578
+ }
299
579
  throw new Error(`${response.status} ${response.statusText}`);
300
580
  }
301
581
  const payload = (await response.json().catch(() => ({})));
@@ -321,6 +601,7 @@ async function listLocalRuntimeModels(provider, baseUrl) {
321
601
  }
322
602
  async function runDoctor(args) {
323
603
  const llm = parseLlmOptions(args);
604
+ const repairOllama = args.includes("--repair-ollama");
324
605
  const checks = [];
325
606
  const mcpConfigPaths = [wsPath(".vscode", "mcp.json")];
326
607
  const hasWorkspaceMcpConfig = mcpConfigPaths.some((path) => fileExists(path));
@@ -347,7 +628,9 @@ async function runDoctor(args) {
347
628
  let provider = llm.llmProvider;
348
629
  let model = llm.llmModel;
349
630
  let baseUrl = llm.llmBaseUrl;
350
- const shouldScan = args.includes("--scan") || (!provider && !baseUrl) || (isLocalLlmProvider(provider) && !baseUrl);
631
+ const shouldScan = args.includes("--scan") ||
632
+ (!provider && !baseUrl) ||
633
+ (isLocalLlmProvider(provider) && (!baseUrl || !model));
351
634
  if (shouldScan) {
352
635
  const scanned = await scanLocalModelRuntimes({
353
636
  workspaceRoot: WORKSPACE_ROOT,
@@ -359,7 +642,7 @@ async function runDoctor(args) {
359
642
  if (chosen) {
360
643
  provider = chosen.provider ?? provider;
361
644
  baseUrl = baseUrl ?? chosen.baseUrl;
362
- model = model || chosen.models[0] || defaultModelForProvider(provider);
645
+ model = model || chosen.models[0];
363
646
  checks.push({
364
647
  name: "Runtime endpoint discovered",
365
648
  ok: true,
@@ -367,7 +650,7 @@ async function runDoctor(args) {
367
650
  });
368
651
  const writtenProfilePath = await writeLlmProfile({
369
652
  provider: provider ?? chosen.provider,
370
- model: model ?? defaultModelForProvider(provider ?? chosen.provider),
653
+ model,
371
654
  baseUrl,
372
655
  });
373
656
  checks.push({
@@ -388,6 +671,30 @@ async function runDoctor(args) {
388
671
  throw new Error(`No runtime provider configured. Use --llm <provider>, bootstrap one into agent-state/ace-state.ace#state/runtime/llm_profile, or run \`ace doctor --scan\` for a local runtime.`);
389
672
  }
390
673
  if (!model) {
674
+ if (isLocalLlmProvider(provider)) {
675
+ checks.push({
676
+ name: "Runtime model configured",
677
+ ok: false,
678
+ detail: "No local runtime model is configured yet. Run `ace connect` with a user-supplied HF model or run `ace doctor --scan` after the runtime is up.",
679
+ });
680
+ const failed = checks.filter((check) => !check.ok);
681
+ console.log("ACE Doctor Report");
682
+ console.log(`Workspace: ${WORKSPACE_ROOT}`);
683
+ console.log(`Provider: ${provider}`);
684
+ console.log("Model: (not set)");
685
+ if (baseUrl) {
686
+ console.log(`Base URL: ${baseUrl}`);
687
+ }
688
+ console.log("");
689
+ for (const check of checks) {
690
+ const status = check.ok ? "PASS" : "FAIL";
691
+ console.log(`- [${status}] ${check.name}: ${check.detail}`);
692
+ }
693
+ if (failed.length > 0) {
694
+ process.exitCode = 1;
695
+ }
696
+ return;
697
+ }
391
698
  model = defaultModelForProvider(provider);
392
699
  }
393
700
  if (isLocalLlmProvider(provider) && !baseUrl) {
@@ -425,6 +732,23 @@ async function runDoctor(args) {
425
732
  });
426
733
  }
427
734
  catch (error) {
735
+ const reasonCode = typeof error === "object" && error !== null && "reason_code" in error
736
+ ? String(error.reason_code)
737
+ : undefined;
738
+ if (reasonCode === "ollama_model_load_error") {
739
+ await appendStatusEventSafe({
740
+ source_module: "capability-ops",
741
+ event_type: "OLLAMA_MODEL_LOAD_ERROR",
742
+ status: "blocked",
743
+ summary: `Ollama model-load error for ${model}: ${error instanceof Error ? error.message : String(error)}`,
744
+ objective_id: "ollama-doctor",
745
+ payload: {
746
+ reason_code: "ollama_model_load_error",
747
+ model,
748
+ base_url: baseUrl,
749
+ },
750
+ }).catch(() => undefined);
751
+ }
428
752
  checks.push({
429
753
  name: "Runtime endpoint reachable",
430
754
  ok: false,
@@ -450,6 +774,44 @@ async function runDoctor(args) {
450
774
  ? `${model} found`
451
775
  : `${model} not reported by llama.cpp (available: ${modelNames.join(", ") || "none"})`,
452
776
  });
777
+ if (provider === "ollama" && !hasModel && repairOllama) {
778
+ const repair = await runShellCommand(`ollama pull ${model}`, {
779
+ cwd: WORKSPACE_ROOT,
780
+ timeout_ms: 10 * 60_000,
781
+ });
782
+ await appendRunLedgerEntrySafe({
783
+ tool: "doctor",
784
+ category: repair.exit_code === 0 ? "info" : "regression",
785
+ message: `Opt-in Ollama repair attempted for ${model}`,
786
+ artifacts: [],
787
+ metadata: {
788
+ reason_code: "ollama_model_load_error",
789
+ model,
790
+ exit_code: repair.exit_code,
791
+ timed_out: repair.timed_out,
792
+ },
793
+ }).catch(() => undefined);
794
+ await appendStatusEventSafe({
795
+ source_module: "capability-ops",
796
+ event_type: "OLLAMA_REPAIR_ATTEMPTED",
797
+ status: repair.exit_code === 0 ? "done" : "fail",
798
+ summary: `Opt-in Ollama repair attempted for ${model}`,
799
+ objective_id: "ollama-doctor",
800
+ payload: {
801
+ reason_code: "ollama_model_load_error",
802
+ model,
803
+ exit_code: repair.exit_code,
804
+ timed_out: repair.timed_out,
805
+ },
806
+ }).catch(() => undefined);
807
+ checks.push({
808
+ name: "Ollama model repair attempted",
809
+ ok: repair.exit_code === 0,
810
+ detail: repair.exit_code === 0
811
+ ? `ollama pull ${model} completed`
812
+ : `ollama pull ${model} failed with exit ${repair.exit_code}: ${repair.stderr || repair.stdout || "no output"}`,
813
+ });
814
+ }
453
815
  }
454
816
  else {
455
817
  const diagnosis = diagnoseChatRuntimeConfig(provider, model, baseUrl ? { baseUrl } : undefined);
@@ -490,6 +852,25 @@ async function runDoctor(args) {
490
852
  }
491
853
  }
492
854
  }
855
+ if (isLocalLlmProvider(provider) && args.includes("--hermes")) {
856
+ try {
857
+ const hermesProfile = resolveHermesLaunchProfile({
858
+ workspaceRoot: WORKSPACE_ROOT,
859
+ cliHermesRoot: readFlagValue(args, "--hermes-root"),
860
+ cliHermesPython: readFlagValue(args, "--hermes-python"),
861
+ cliHermesUvProject: readFlagValue(args, "--hermes-uv-project"),
862
+ cliHermesCommandJson: readFlagValue(args, "--hermes-command-json"),
863
+ });
864
+ await appendHermesLocalReadinessChecks(checks, hermesProfile);
865
+ }
866
+ catch (error) {
867
+ checks.push({
868
+ name: "Hermes launch profile",
869
+ ok: false,
870
+ detail: error instanceof Error ? error.message : String(error),
871
+ });
872
+ }
873
+ }
493
874
  const failed = checks.filter((check) => !check.ok);
494
875
  console.log("ACE Doctor Report");
495
876
  console.log(`Workspace: ${WORKSPACE_ROOT}`);
@@ -576,11 +957,32 @@ async function main() {
576
957
  await startStdioServer();
577
958
  return;
578
959
  }
960
+ if (command === "mcp-shadow") {
961
+ const shadowArgs = args.slice(1);
962
+ const tools = (readFlagValue(shadowArgs, "--tools") ?? "")
963
+ .split(",")
964
+ .map((tool) => tool.trim())
965
+ .filter(Boolean);
966
+ await startHermesShadowStdioServer(tools);
967
+ return;
968
+ }
579
969
  if (command === "tui") {
580
970
  const tuiArgs = args.slice(1);
581
971
  const cliProvider = readFlagValue(tuiArgs, "--provider")?.trim() ||
582
972
  readFlagValue(tuiArgs, "--llm")?.trim();
583
973
  const cliModel = readFlagValue(tuiArgs, "--model")?.trim();
974
+ const cliEngine = readFlagValue(tuiArgs, "--engine")?.trim() ||
975
+ (tuiArgs.includes("--hermes") ? "hermes_local" : undefined);
976
+ const normalizedCliEngine = cliEngine?.toLowerCase().replace(/-/g, "_");
977
+ const hermesLaunchProfile = normalizedCliEngine === "hermes_local"
978
+ ? resolveHermesLaunchProfile({
979
+ workspaceRoot: WORKSPACE_ROOT,
980
+ cliHermesRoot: readFlagValue(tuiArgs, "--hermes-root"),
981
+ cliHermesPython: readFlagValue(tuiArgs, "--hermes-python"),
982
+ cliHermesUvProject: readFlagValue(tuiArgs, "--hermes-uv-project"),
983
+ cliHermesCommandJson: readFlagValue(tuiArgs, "--hermes-command-json"),
984
+ })
985
+ : undefined;
584
986
  const cliBaseUrl = readBaseUrlFlag(tuiArgs);
585
987
  const discovered = discoverProviderContext({
586
988
  workspaceRoot: WORKSPACE_ROOT,
@@ -588,14 +990,78 @@ async function main() {
588
990
  cliModel,
589
991
  cliBaseUrl,
590
992
  });
993
+ const needsLocalScan = isLocalLlmProvider(discovered.provider) &&
994
+ (!discovered.baseUrl || !discovered.model || discovered.model === defaultModelForProvider(discovered.provider));
995
+ const runtime = needsLocalScan
996
+ ? await scanLocalModelRuntimes({
997
+ workspaceRoot: WORKSPACE_ROOT,
998
+ preferredProvider: discovered.provider,
999
+ explicitBaseUrl: discovered.baseUrl,
1000
+ })
1001
+ : undefined;
1002
+ const scanned = runtime?.candidates.find((candidate) => candidate.provider === discovered.provider) ??
1003
+ runtime?.candidates[0];
1004
+ const resolvedTui = scanned?.models[0]
1005
+ ? {
1006
+ ...discovered,
1007
+ model: scanned.models[0],
1008
+ baseUrl: discovered.baseUrl ?? scanned.baseUrl,
1009
+ providerBaseUrls: {
1010
+ ...discovered.providerBaseUrls,
1011
+ [scanned.provider]: discovered.baseUrl ?? scanned.baseUrl,
1012
+ },
1013
+ }
1014
+ : discovered;
1015
+ // Merge any persisted discovery records from the workspace store so recent `ace doctor`
1016
+ // results are reflected immediately in the TUI startup options.
1017
+ try {
1018
+ const store = await openStore(getWorkspaceStorePath(WORKSPACE_ROOT), { readOnly: true });
1019
+ try {
1020
+ const discoveryRepo = new DiscoveryRepository(store);
1021
+ const stored = await discoveryRepo.listAll();
1022
+ const mergedProviders = new Set(resolvedTui.providers ?? []);
1023
+ const mergedModelsByProvider = { ...(resolvedTui.modelsByProvider ?? {}) };
1024
+ const mergedProviderBaseUrls = { ...(resolvedTui.providerBaseUrls ?? {}) };
1025
+ for (const p of stored) {
1026
+ if (!p || !p.provider)
1027
+ continue;
1028
+ const prov = String(p.provider).trim();
1029
+ if (!prov)
1030
+ continue;
1031
+ mergedProviders.add(prov);
1032
+ if (Array.isArray(p.models) && p.models.length > 0) {
1033
+ const existing = new Set(mergedModelsByProvider[prov] ?? []);
1034
+ for (const m of p.models)
1035
+ if (typeof m === "string" && m.trim())
1036
+ existing.add(m.trim());
1037
+ mergedModelsByProvider[prov] = [...existing].sort((a, b) => a.localeCompare(b));
1038
+ }
1039
+ if (p.endpoint && typeof p.endpoint === "string") {
1040
+ mergedProviderBaseUrls[prov] = p.endpoint;
1041
+ }
1042
+ }
1043
+ resolvedTui.providers = [...mergedProviders].sort((a, b) => a.localeCompare(b));
1044
+ resolvedTui.modelsByProvider = { ...(resolvedTui.modelsByProvider ?? {}), ...mergedModelsByProvider };
1045
+ resolvedTui.providerBaseUrls = { ...(resolvedTui.providerBaseUrls ?? {}), ...mergedProviderBaseUrls };
1046
+ }
1047
+ finally {
1048
+ await store.close();
1049
+ }
1050
+ }
1051
+ catch (err) {
1052
+ // Non-fatal — continue with discovered defaults if store read fails.
1053
+ console.warn(`Could not merge discovery records into TUI startup: ${err instanceof Error ? err.message : String(err)}`);
1054
+ }
591
1055
  await runTui({
592
- provider: discovered.provider,
593
- model: discovered.model,
594
- providers: discovered.providers,
595
- modelsByProvider: discovered.modelsByProvider,
596
- baseUrl: discovered.baseUrl,
597
- ollamaUrl: discovered.ollamaUrl,
598
- providerBaseUrls: discovered.providerBaseUrls,
1056
+ provider: resolvedTui.provider,
1057
+ engine: cliEngine,
1058
+ hermesLaunchProfile,
1059
+ model: resolvedTui.model,
1060
+ providers: resolvedTui.providers,
1061
+ modelsByProvider: resolvedTui.modelsByProvider,
1062
+ baseUrl: resolvedTui.baseUrl,
1063
+ ollamaUrl: resolvedTui.ollamaUrl,
1064
+ providerBaseUrls: resolvedTui.providerBaseUrls,
599
1065
  workspaceRoot: WORKSPACE_ROOT,
600
1066
  });
601
1067
  return;
@@ -604,6 +1070,10 @@ async function main() {
604
1070
  await runInit(args.slice(1), "init");
605
1071
  return;
606
1072
  }
1073
+ if (command === "connect") {
1074
+ await runConnect(args.slice(1));
1075
+ return;
1076
+ }
607
1077
  if (command === "turnkey") {
608
1078
  await runInit(args.slice(1), "turnkey");
609
1079
  return;