llmwiki-bridge-start 0.0.1 → 0.0.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.
Files changed (3) hide show
  1. package/README.md +22 -3
  2. package/package.json +1 -1
  3. package/src/index.mjs +789 -53
package/README.md CHANGED
@@ -35,6 +35,14 @@ scripts that support MCP over HTTP; exact client configuration syntax varies by
35
35
  client. `llmwiki-agent-bridge` can still be added later when you want source
36
36
  fan-out or one normalized bridge across sources.
37
37
 
38
+ After a quickstart run, inspect what is still running and whether each source
39
+ is registered with the bridge:
40
+
41
+ ```bash
42
+ npx llmwiki-bridge-start@latest status --bridge http://127.0.0.1:8788
43
+ npx llmwiki-bridge-start@latest status --json
44
+ ```
45
+
38
46
  If `llmwiki-serve` is not on `PATH`, a sibling `../llmwiki-serve` checkout with
39
47
  an existing `.venv` is used automatically when available. Otherwise point the
40
48
  harness at a local checkout or environment explicitly, for example:
@@ -71,7 +79,7 @@ transcripts are unchanged. Use `--no-clear-screen` or
71
79
  visible. If
72
80
  you opt in, quickstart uses an already running bridge or prints copy-pasteable
73
81
  PowerShell and POSIX manual-start examples such as
74
- `LLMWIKI_AGENT_BRIDGE_HOST='127.0.0.1' LLMWIKI_AGENT_BRIDGE_PORT='8788' npx --yes llmwiki-agent-bridge@0.2.0`;
82
+ `LLMWIKI_AGENT_BRIDGE_HOST='127.0.0.1' LLMWIKI_AGENT_BRIDGE_PORT='8788' npx --yes llmwiki-agent-bridge@0.3.0`;
75
83
  custom `--bridge http://host:port` values are reflected in those env
76
84
  assignments. The command does not install a global package.
77
85
  Before starting that bridge command, quickstart asks how to configure the LLM
@@ -100,6 +108,12 @@ endpoint is shown, it came from an explicit CLI flag, the standard
100
108
  such as Hermes `/health`; blank Enter uses that verified/configured default,
101
109
  and `skip` forces evidence-only. If no default endpoint is shown, blank Enter
102
110
  or `skip` continues with evidence-only bridge smoke.
111
+ Configured and reachable are separate states. Quickstart and delegated-runtime
112
+ smoke do not treat an endpoint as usable just because a default or settings
113
+ value exists. Hermes endpoints must answer `/health` or `/v1/health`; generic
114
+ OpenAI-compatible endpoints must answer a minimal `/models` probe. Evidence-only
115
+ smoke explicitly skips the delegated-runtime check and verifies source
116
+ retrieval only.
103
117
  DeepAgents ACP is an explicit bridge adapter path, not the default DeepAgents
104
118
  profile behavior. Use `--runtime-adapter deepagents-acp` when you want
105
119
  QuickStart to configure `runtimeAdapter=deepagents-acp`; a background bridge
@@ -119,7 +133,10 @@ endpoint (`POST /message:send`), MCP-style JSON-RPC endpoint (`POST /mcp`),
119
133
  settings UI (`/settings`), and a compact operational details section that
120
134
  points to `.llmwiki-bridge-start/quickstart-handoff.md` for PIDs, log paths,
121
135
  and stop guidance. The bridge endpoint handoff is separate from direct source
122
- MCP Streamable HTTP URLs (`/mcp/stream`). If an explicit LLM
136
+ MCP Streamable HTTP URLs (`/mcp/stream`). Use an `llmwiki-agent-bridge` build
137
+ with MCP lifecycle support when registering the bridge `POST /mcp` endpoint
138
+ with spec-compliant MCP clients; direct source URLs continue to use
139
+ `/mcp/stream`. If an explicit LLM
123
140
  endpoint is configured through flags, quickstart treats it as a preconfigured
124
141
  compatibility path without adding it to the interactive first-run menu.
125
142
 
@@ -179,6 +196,7 @@ npx llmwiki-bridge-start@latest discover --home
179
196
  npx llmwiki-bridge-start@latest discover --path . --validate
180
197
  npx llmwiki-bridge-start@latest start --path ./wiki --port 11001
181
198
  npx llmwiki-bridge-start@latest register --bridge http://127.0.0.1:8788 --config .llmwiki-bridge-start/sources.json
199
+ npx llmwiki-bridge-start@latest status --bridge http://127.0.0.1:8788
182
200
  npx llmwiki-bridge-start@latest smoke --bridge http://127.0.0.1:8788
183
201
  ```
184
202
 
@@ -217,7 +235,8 @@ longer compatibility notes and non-goals.
217
235
  | `discover` | Find local LLMWiki Markdown, Native LLMWiki/OpenWiki, Obsidian, Logseq, Foam, Dendron, Quartz, and generic Markdown candidates. |
218
236
  | `start` | Start `llmwiki-serve` on one or more selected local wiki paths. |
219
237
  | `register` | Upsert started or existing source URLs into `llmwiki-agent-bridge`. Use `--replace` only when intentionally replacing the registry. |
220
- | `smoke` | Run a bridge smoke request; defaults to evidence-only and accepts delegated-runtime or hybrid with `--mode`. |
238
+ | `status` / `ls` | List local started sources from `.llmwiki-bridge-start/sources.json`, live source health, and bridge registration state when the bridge is reachable. |
239
+ | `smoke` | Run a bridge smoke request; defaults to evidence-only and accepts delegated-runtime or hybrid with `--mode`. Evidence-only reports that delegated-runtime was not checked; delegated-runtime verifies runtime reachability first. |
221
240
  | `doctor` | Check local prerequisites and optional bridge reachability. |
222
241
 
223
242
  ## Boundary
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llmwiki-bridge-start",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Local adoption harness for connecting pre-built LLMWiki knowledge sources to agent bridges and coding-agent clients.",
package/src/index.mjs CHANGED
@@ -23,11 +23,14 @@ const FAST_DISCOVERY_MAX_BUFFER = 32 * 1024 * 1024
23
23
  const ASYNC_DISCOVERY_YIELD_EVERY = 64
24
24
  const DEFAULT_SOURCE_HEALTH_TIMEOUT_MS = 15000
25
25
  const DEFAULT_SOURCE_HEALTH_INTERVAL_MS = 500
26
+ const DEFAULT_STATUS_SOURCE_HEALTH_TIMEOUT_MS = 2000
27
+ const DEFAULT_STATUS_BRIDGE_TIMEOUT_MS = 3000
28
+ const DEFAULT_RUNTIME_REACHABILITY_TIMEOUT_MS = 2000
26
29
  const SOURCE_PORT_PROBE_MAX_ATTEMPTS = 200
27
30
  const DEFAULT_DISCOVERY_PROGRESS_INTERVAL_MS = 1000
28
31
  const DEFAULT_DISCOVERY_PROGRESS_MESSAGE = 'Searching local folders for LLMWiki candidates...'
29
32
  const DEFAULT_TERMINAL_ROW_FALLBACK = 1000
30
- const DEFAULT_BRIDGE_PACKAGE_SPEC = 'llmwiki-agent-bridge@0.2.0'
33
+ const DEFAULT_BRIDGE_PACKAGE_SPEC = 'llmwiki-agent-bridge@0.3.0'
31
34
  const DEFAULT_BRIDGE_RUNTIME_BASE_URL = 'http://127.0.0.1:8642/v1'
32
35
  const DEFAULT_RUNTIME_FRAMEWORK_DETECTION_TIMEOUT_MS = 1500
33
36
  const DEFAULT_RUNTIME_INSTALL_TIMEOUT_MS = 10 * 60 * 1000
@@ -410,6 +413,14 @@ export async function runCli(argv, io = { stdin: process.stdin, stdout: process.
410
413
  writeResult(result, options, io)
411
414
  return
412
415
  }
416
+ if (command === 'status' || command === 'ls') {
417
+ const result = await statusSources({
418
+ bridgeUrl: stringOption(options.bridge, DEFAULT_BRIDGE_URL),
419
+ configPath: stringOption(options.config, defaultConfigPath()),
420
+ })
421
+ writeResult(result, options, io)
422
+ return
423
+ }
413
424
  if (command === 'start') {
414
425
  const result = await startSources({
415
426
  paths: arrayOption(options.path),
@@ -440,6 +451,8 @@ export async function runCli(argv, io = { stdin: process.stdin, stdout: process.
440
451
  bridgeUrl: stringOption(options.bridge, DEFAULT_BRIDGE_URL),
441
452
  query: stringOption(options.query, 'What LLMWiki sources are available and what are they for?'),
442
453
  mode: bridgeModeOption(options.mode ?? options['orchestration-mode'], BRIDGE_MODE_EVIDENCE_ONLY),
454
+ runtime: detectLlmRuntime(options, process.env),
455
+ preflightRuntime: true,
443
456
  })
444
457
  writeResult(result, options, io)
445
458
  return
@@ -567,6 +580,7 @@ export async function quickstart(options = {}, io = { stdin: process.stdin, stdo
567
580
  output.write(formatSelectedSourceEcho(selected, candidatePlan.visibleCandidates))
568
581
  writeQuickstartStep(output, ui, 3, QUICKSTART_STEP_TOTAL, 'Validate and start local sources')
569
582
  }
583
+ assertNoSelectedSourcePathConflicts(selected, { action: 'quickstart source selection' })
570
584
  if (!await confirmQuickstart(prompter, `Start ${selected.length} selected source server(s) on loopback?\nThis validates each selected folder first.`, true)) {
571
585
  writeStatus(output, ui, 'skip', 'Skipped source startup. No source servers were started.')
572
586
  result.skipped.push('start', 'bridge-setup', 'register', 'smoke')
@@ -598,6 +612,7 @@ export async function quickstart(options = {}, io = { stdin: process.stdin, stdo
598
612
  result.skipped.push('start', 'bridge-setup', 'register', 'smoke')
599
613
  return result
600
614
  }
615
+ assertNoValidatedSourceIdConflicts(startable, { action: 'quickstart source selection' })
601
616
  result.started = await runtime.startSources({
602
617
  paths: startable.map((candidate) => candidate.path),
603
618
  host: stringOption(options.host, DEFAULT_HOST),
@@ -681,13 +696,23 @@ export async function quickstart(options = {}, io = { stdin: process.stdin, stdo
681
696
  })
682
697
  writeStatus(output, ui, 'ok', formatQuickstartRegistrationSuccess(result.registered, quickstartSelectedIds))
683
698
 
684
- const smokePlan = await runtime.selectBridgeSmokeMode({ options: smokeOptions, bridgeUrl, env: process.env })
699
+ const smokePlan = await runtime.selectBridgeSmokeMode({
700
+ options: smokeOptions,
701
+ bridgeUrl,
702
+ env: process.env,
703
+ probeRuntime: runtime.probeRuntimeEndpoint || probeRuntimeEndpoint,
704
+ })
685
705
  result.smokeMode = smokePlan.mode
706
+ if (smokePlan.mode === BRIDGE_MODE_EVIDENCE_ONLY) {
707
+ writeStatus(output, ui, 'info', 'Evidence-only smoke checks source retrieval only; delegated-runtime reachability was not checked.')
708
+ }
686
709
  writeStatus(output, ui, 'run', `Running bridge smoke in ${formatBridgeModeLabel(smokePlan.mode)} mode (${smokePlan.reason}).`)
687
710
  result.smoked = await runtime.smokeBridge({
688
711
  bridgeUrl,
689
712
  query: stringOption(options.query, 'What LLMWiki sources are available and what are they for?'),
690
713
  mode: smokePlan.mode,
714
+ runtime: detectLlmRuntime(smokeOptions, process.env),
715
+ preflightRuntime: smokePlan.mode !== BRIDGE_MODE_EVIDENCE_ONLY,
691
716
  })
692
717
  writeStatus(output, ui, 'ok', `Smoke complete: ${result.smoked.status?.state || result.smoked.status?.message?.kind || 'ok'}`)
693
718
  result.runSummary = writeQuickstartRunSummary({
@@ -700,7 +725,7 @@ export async function quickstart(options = {}, io = { stdin: process.stdin, stdo
700
725
  smoked: result.smoked,
701
726
  mode: 'bridge',
702
727
  })
703
- output.write(formatBridgeHandoff(bridgeUrl))
728
+ output.write(formatBridgeHandoff(bridgeUrl, { smokeMode: result.smokeMode }))
704
729
  output.write(formatLocalProcessLifecycleNote({ sources: result.started.sources, bridgeSetup: result.bridgeSetup, mode: 'bridge', runSummary: result.runSummary }))
705
730
  return result
706
731
  } finally {
@@ -765,11 +790,24 @@ async function guideRuntimeSetup({ prompter, output, ui, options, runtime, io =
765
790
  if (!requestedChoice) {
766
791
  const preconfigured = detectLlmRuntime(options, {})
767
792
  if (preconfigured.configured) {
768
- writeStatus(output, ui, 'ok', `Using preconfigured LLM runtime from explicit flags: ${formatRuntimeDetails(preconfigured)}`)
769
793
  const choice = preconfigured.runtimeAdapter === RUNTIME_ADAPTER_DEEPAGENTS_ACP
770
794
  ? runtimeSetupChoiceById(RUNTIME_SETUP_DEEPAGENTS)
771
795
  : runtimeSetupChoiceById(RUNTIME_SETUP_EXISTING)
772
- return configuredRuntimeSetup(choice, preconfigured, runtimeOptionsFromRuntimeInfo(choice, preconfigured))
796
+ writeStatus(output, ui, 'info', `Checking preconfigured LLM runtime from explicit flags: ${formatRuntimeDetails(preconfigured)}`)
797
+ const readiness = await verifyPreconfiguredRuntimeSetup({
798
+ choice,
799
+ runtimeInfo: preconfigured,
800
+ output,
801
+ ui,
802
+ probe: runtime.probeRuntimeEndpoint || probeRuntimeEndpoint,
803
+ })
804
+ if (!readiness.ok) {
805
+ const nextAction = `Start the configured runtime endpoint, verify it responds, then rerun with --llm-endpoint ${preconfigured.baseUrl || '<runtime-url>'} --llm-model ${preconfigured.model} --runtime-profile ${preconfigured.profile}, or use bridge /settings.`
806
+ writeStatus(output, ui, 'skip', `Preconfigured runtime was not reachable. Continuing with evidence-only bridge mode. Next action: ${nextAction}`)
807
+ return unconfiguredRuntimeSetup(choice, nextAction)
808
+ }
809
+ writeStatus(output, ui, 'ok', `Using reachable preconfigured LLM runtime from explicit flags: ${formatRuntimeDetails(preconfigured)}`)
810
+ return configuredRuntimeSetup(choice, { ...preconfigured, reachable: true, reachability: readiness }, runtimeOptionsFromRuntimeInfo(choice, preconfigured))
773
811
  }
774
812
  }
775
813
 
@@ -1482,7 +1520,11 @@ async function promptRuntimeConnection({ prompter, output, ui, options, choice,
1482
1520
  runtimeProfile: profile || choice.profile,
1483
1521
  ...(runtimeAdapter ? { runtimeAdapter } : {}),
1484
1522
  }
1485
- const runtimeInfo = detectLlmRuntime(runtimeOptions, {})
1523
+ const runtimeInfo = {
1524
+ ...detectLlmRuntime(runtimeOptions, {}),
1525
+ reachable: true,
1526
+ reachability: endpointProbe,
1527
+ }
1486
1528
  writeStatus(output, ui, 'ok', `Runtime configured for bridge start: ${formatRuntimeDetails(runtimeInfo)}`)
1487
1529
  return {
1488
1530
  choice: choice.id,
@@ -1590,15 +1632,14 @@ function writeRuntimeEndpointDefaultNotice(output, ui, choice, endpointDefault =
1590
1632
  }
1591
1633
 
1592
1634
  async function verifyRuntimeEndpointForChoice({ choice, baseUrl, output, ui, probe = probeRuntimeEndpoint } = {}) {
1593
- if (choice.id !== RUNTIME_SETUP_HERMES) {
1594
- return { ok: true, skipped: true, reason: `${choice.label} has no registered framework health probe in quickstart yet` }
1595
- }
1596
1635
  const health = await probe({ baseUrl, profile: choice.profile })
1597
1636
  if (health.ok) {
1598
- writeStatus(output, ui, 'ok', `${choice.label} health check passed: ${health.url}`)
1637
+ const checkName = choice.id === RUNTIME_SETUP_HERMES || choice.profile === RUNTIME_SETUP_HERMES ? 'health check' : 'OpenAI-compatible /models check'
1638
+ writeStatus(output, ui, 'ok', `${choice.label} ${checkName} passed: ${health.url}`)
1599
1639
  return health
1600
1640
  }
1601
- writeStatus(output, ui, 'fail', `${choice.label} health check failed for ${baseUrl}: ${health.error || 'unreachable'}`)
1641
+ const checkName = choice.id === RUNTIME_SETUP_HERMES || choice.profile === RUNTIME_SETUP_HERMES ? 'health check' : 'OpenAI-compatible /models check'
1642
+ writeStatus(output, ui, 'fail', `${choice.label} ${checkName} failed for ${baseUrl}: ${health.error || 'unreachable'}`)
1602
1643
  return health
1603
1644
  }
1604
1645
 
@@ -1742,6 +1783,8 @@ function configuredRuntimeSetup(choice, runtime, runtimeOptions = {}) {
1742
1783
  model: runtime.model,
1743
1784
  profile: runtime.profile,
1744
1785
  runtimeAdapter: runtime.runtimeAdapter || '',
1786
+ reachable: runtime.reachable === true || runtime.reachability?.ok === true,
1787
+ reachability: runtime.reachability,
1745
1788
  runtimeOptions,
1746
1789
  runtime,
1747
1790
  }
@@ -1759,6 +1802,22 @@ function configuredDeepAgentsAcpRuntimeSetup({ choice, options = {}, output, ui
1759
1802
  return configuredRuntimeSetup(choice, runtimeInfo, runtimeOptions)
1760
1803
  }
1761
1804
 
1805
+ async function verifyPreconfiguredRuntimeSetup({ choice, runtimeInfo, output, ui, probe = probeRuntimeEndpoint } = {}) {
1806
+ if (runtimeInfo.runtimeAdapter === RUNTIME_ADAPTER_DEEPAGENTS_ACP && !runtimeInfo.baseUrl) {
1807
+ return { ok: true, skipped: true, reason: 'adapter-only runtime path' }
1808
+ }
1809
+ if (!runtimeInfo.baseUrl) {
1810
+ return { ok: false, error: 'no runtime base URL configured' }
1811
+ }
1812
+ return verifyRuntimeEndpointForChoice({
1813
+ choice: { ...choice, profile: runtimeInfo.profile, label: choice.label || runtimeInfo.profile },
1814
+ baseUrl: runtimeInfo.baseUrl,
1815
+ output,
1816
+ ui,
1817
+ probe,
1818
+ })
1819
+ }
1820
+
1762
1821
  function runtimeOptionsFromRuntimeInfo(choice, runtime) {
1763
1822
  return {
1764
1823
  runtimeSetup: choice.id,
@@ -2245,6 +2304,100 @@ function sourceUrlsFromStartedSources(sources = []) {
2245
2304
  return sources.map((source) => source.url).filter(Boolean)
2246
2305
  }
2247
2306
 
2307
+ function assertNoSelectedSourcePathConflicts(sources = [], { action = 'source selection' } = {}) {
2308
+ const conflicts = selectedSourcePathConflicts(sources)
2309
+ if (conflicts.length) {
2310
+ throw new Error(formatSourceConflictError(conflicts, {
2311
+ action,
2312
+ intro: 'Selected source folders overlap by ancestor path.',
2313
+ remediation: 'Select only one folder from each overlap group, then rerun. If both folders must be served, rebuild one source with a distinct source_id and start it separately.',
2314
+ }))
2315
+ }
2316
+ }
2317
+
2318
+ function assertNoValidatedSourceIdConflicts(sources = [], { action = 'source startup' } = {}) {
2319
+ const conflicts = selectedSourceIdConflicts(sources)
2320
+ if (conflicts.length) {
2321
+ throw new Error(formatSourceConflictError(conflicts, {
2322
+ action,
2323
+ intro: 'Selected source folders resolve to duplicate source_id values.',
2324
+ remediation: 'Select only one folder for each source_id, or rebuild/rename one source so every started source has a unique source_id.',
2325
+ }))
2326
+ }
2327
+ }
2328
+
2329
+ function selectedSourcePathConflicts(sources = []) {
2330
+ const entries = sources
2331
+ .map((source) => ({ source, path: source.path ? resolve(source.path) : '' }))
2332
+ .filter((entry) => entry.path)
2333
+ const conflicts = []
2334
+ for (let leftIndex = 0; leftIndex < entries.length; leftIndex += 1) {
2335
+ for (let rightIndex = leftIndex + 1; rightIndex < entries.length; rightIndex += 1) {
2336
+ const left = entries[leftIndex]
2337
+ const right = entries[rightIndex]
2338
+ if (pathsOverlapAsAncestor(left.path, right.path)) {
2339
+ conflicts.push({
2340
+ code: 'overlapping_source_paths',
2341
+ detail: 'ancestor/descendant paths would create ambiguous bridge ownership',
2342
+ sources: [left.source, right.source],
2343
+ })
2344
+ }
2345
+ }
2346
+ }
2347
+ return conflicts
2348
+ }
2349
+
2350
+ function selectedSourceIdConflicts(sources = []) {
2351
+ const groups = new Map()
2352
+ for (const source of sources) {
2353
+ const id = sourceIdForConflictCheck(source)
2354
+ if (!id) {
2355
+ continue
2356
+ }
2357
+ const group = groups.get(id) || []
2358
+ group.push(source)
2359
+ groups.set(id, group)
2360
+ }
2361
+ return [...groups.entries()]
2362
+ .filter(([, group]) => group.length > 1)
2363
+ .map(([id, group]) => ({
2364
+ code: 'duplicate_source_id',
2365
+ detail: `duplicate source_id "${id}"`,
2366
+ sources: group,
2367
+ }))
2368
+ }
2369
+
2370
+ function sourceIdForConflictCheck(source = {}) {
2371
+ return String(source.id || source.sourceId || source.source_id || source.manifest?.source_id || '').trim()
2372
+ }
2373
+
2374
+ function pathsOverlapAsAncestor(leftPath, rightPath) {
2375
+ const left = normalizePath(leftPath)
2376
+ const right = normalizePath(rightPath)
2377
+ if (!left || !right || left === right) {
2378
+ return false
2379
+ }
2380
+ return isPathAtOrBelowRoot(left, right) || isPathAtOrBelowRoot(right, left)
2381
+ }
2382
+
2383
+ function formatSourceConflictError(conflicts = [], { action, intro, remediation } = {}) {
2384
+ const lines = [
2385
+ `Cannot continue ${action}: ${intro}`,
2386
+ ]
2387
+ for (const conflict of conflicts) {
2388
+ lines.push(`- ${conflict.code}: ${conflict.detail}`)
2389
+ for (const source of conflict.sources || []) {
2390
+ const id = sourceIdForConflictCheck(source)
2391
+ const idText = id ? ` id=${id}` : ''
2392
+ const location = source.path ? `path=${source.path}` : `url=${source.url || 'n/a'}`
2393
+ lines.push(` -${idText} ${location}`)
2394
+ }
2395
+ }
2396
+ lines.push(remediation)
2397
+ lines.push('No source startup or registration was performed for this conflicting selection.')
2398
+ return lines.join('\n')
2399
+ }
2400
+
2248
2401
  function writeQuickstartPortFallbackInfo(output, ui, sources = []) {
2249
2402
  for (const source of sources) {
2250
2403
  const requestedPort = sourceRequestedPort(source)
@@ -2521,21 +2674,24 @@ function sourceMcpStreamUrl(sourceUrl) {
2521
2674
 
2522
2675
  function sourceEndpointUrl(sourceUrl, endpointPath) {
2523
2676
  const parsed = new URL(sourceUrl)
2524
- const basePath = parsed.pathname.replace(/\/+$/, '')
2677
+ const basePath = trimTrailingSlash(parsed.pathname)
2525
2678
  parsed.pathname = `${basePath}${endpointPath}`
2526
2679
  parsed.search = ''
2527
2680
  parsed.hash = ''
2528
2681
  return trimTrailingSlash(parsed.toString())
2529
2682
  }
2530
2683
 
2531
- function formatBridgeHandoff(bridgeUrl) {
2684
+ function formatBridgeHandoff(bridgeUrl, { smokeMode = '' } = {}) {
2532
2685
  const baseUrl = trimTrailingSlash(bridgeUrl)
2686
+ const bodyLines = smokeMode === BRIDGE_MODE_EVIDENCE_ONLY
2687
+ ? ['Bridge endpoints responded; evidence-only smoke did not check delegated-runtime reachability.']
2688
+ : ['Ready bridge endpoints']
2533
2689
  return formatSummaryCard('Bridge handoff', [
2534
2690
  ['MCP JSON-RPC', `POST ${sourceEndpointUrl(baseUrl, '/mcp')}`],
2535
2691
  ['A2A answer', `POST ${sourceEndpointUrl(baseUrl, '/message:send')}`],
2536
2692
  ['Settings', sourceEndpointUrl(baseUrl, '/settings')],
2537
2693
  ['Base URL', baseUrl],
2538
- ], { bodyLines: ['Ready bridge endpoints'] }) + [
2694
+ ], { bodyLines }) + [
2539
2695
  'Use the endpoint your agent or script supports; exact client configuration syntax varies by client.',
2540
2696
  ].join('\n') + '\n'
2541
2697
  }
@@ -4440,25 +4596,337 @@ export async function doctor({ bridgeUrl = DEFAULT_BRIDGE_URL, serveInvocation =
4440
4596
  return { checks }
4441
4597
  }
4442
4598
 
4599
+ export async function statusSources({ bridgeUrl = DEFAULT_BRIDGE_URL, configPath = defaultConfigPath(), sourceHealthTimeoutMs = DEFAULT_STATUS_SOURCE_HEALTH_TIMEOUT_MS, bridgeTimeoutMs = DEFAULT_STATUS_BRIDGE_TIMEOUT_MS } = {}) {
4600
+ const resolvedConfigPath = resolve(configPath)
4601
+ const warnings = []
4602
+ let localSources = []
4603
+ let configError = ''
4604
+ const configExists = safeIsFile(resolvedConfigPath)
4605
+ if (configExists) {
4606
+ try {
4607
+ localSources = readSourceConfig(resolvedConfigPath)
4608
+ } catch (error) {
4609
+ configError = error.message
4610
+ warnings.push(statusWarning('source_config_unreadable', `Could not read source config: ${error.message}`))
4611
+ }
4612
+ } else {
4613
+ warnings.push(statusWarning('source_config_missing', `Source config not found: ${resolvedConfigPath}`))
4614
+ }
4615
+
4616
+ const bridge = await readBridgeStatus(bridgeUrl, { timeoutMs: bridgeTimeoutMs })
4617
+ if (!bridge.health.ok) {
4618
+ warnings.push(statusWarning('bridge_unreachable', `Bridge is not reachable at ${bridge.url}: ${bridge.health.error}`))
4619
+ } else if (!bridge.registry.ok) {
4620
+ warnings.push(statusWarning('bridge_registry_unreadable', `Bridge registry could not be read at ${bridge.url}: ${bridge.registry.error}`))
4621
+ }
4622
+
4623
+ const registrySources = bridge.registry.sources || []
4624
+ const sources = await Promise.all(localSources.map(async (source) => {
4625
+ const liveHealth = await probeStatusSourceHealth(source, { timeoutMs: sourceHealthTimeoutMs })
4626
+ const registration = reconcileSourceRegistration(source, registrySources, bridge)
4627
+ return statusSourceDescriptor(source, {
4628
+ health: liveHealth,
4629
+ registration,
4630
+ })
4631
+ }))
4632
+
4633
+ warnings.push(...statusConflictWarnings(sources))
4634
+ warnings.push(...sources
4635
+ .filter((source) => source.registration.state === 'id-conflict')
4636
+ .map((source) => statusWarning(
4637
+ 'source_id_registered_elsewhere',
4638
+ `Source ${source.id} is started at ${source.url} but the bridge registry points that id at ${source.registration.registeredUrl}.`,
4639
+ [source.id],
4640
+ )))
4641
+
4642
+ return {
4643
+ command: 'status',
4644
+ generatedAt: new Date().toISOString(),
4645
+ configPath: resolvedConfigPath,
4646
+ configExists,
4647
+ ...(configError ? { configError } : {}),
4648
+ bridgeUrl: trimTrailingSlash(bridgeUrl),
4649
+ bridge,
4650
+ sources,
4651
+ startedCount: sources.length,
4652
+ healthyCount: sources.filter((source) => source.health.ok).length,
4653
+ registeredCount: sources.filter((source) => source.registered === true).length,
4654
+ unregisteredCount: sources.filter((source) => source.registered === false).length,
4655
+ unknownRegistrationCount: sources.filter((source) => source.registered === null).length,
4656
+ warningCount: warnings.length,
4657
+ warnings,
4658
+ }
4659
+ }
4660
+
4661
+ async function readBridgeStatus(bridgeUrl, { timeoutMs = DEFAULT_STATUS_BRIDGE_TIMEOUT_MS } = {}) {
4662
+ const url = trimTrailingSlash(bridgeUrl)
4663
+ const health = await probeBridgeStatusHealth(url, { timeoutMs })
4664
+ if (!health.ok) {
4665
+ return {
4666
+ url,
4667
+ reachable: false,
4668
+ health,
4669
+ registry: { ok: false, sources: [], error: 'bridge health check failed' },
4670
+ }
4671
+ }
4672
+ try {
4673
+ const registry = await fetchJson(new URL('/settings/sources.json', url), { timeoutMs })
4674
+ const sources = Array.isArray(registry.sources)
4675
+ ? registry.sources.map(statusRegistrySourceDescriptor)
4676
+ : []
4677
+ return {
4678
+ url,
4679
+ reachable: true,
4680
+ health,
4681
+ registry: {
4682
+ ok: true,
4683
+ sources,
4684
+ registeredCount: sources.length,
4685
+ persistence: registry.persistence,
4686
+ },
4687
+ }
4688
+ } catch (error) {
4689
+ return {
4690
+ url,
4691
+ reachable: true,
4692
+ health,
4693
+ registry: {
4694
+ ok: false,
4695
+ sources: [],
4696
+ error: error.message,
4697
+ },
4698
+ }
4699
+ }
4700
+ }
4701
+
4702
+ async function probeBridgeStatusHealth(bridgeUrl, { timeoutMs = DEFAULT_STATUS_BRIDGE_TIMEOUT_MS } = {}) {
4703
+ try {
4704
+ const health = await fetchJson(new URL('/health', bridgeUrl), { timeoutMs })
4705
+ return {
4706
+ ok: true,
4707
+ status: health.status || 'reachable',
4708
+ url: bridgeUrl,
4709
+ sourceRegistry: health.sourceRegistry,
4710
+ runtimeConnection: health.runtimeConnection,
4711
+ }
4712
+ } catch (error) {
4713
+ return { ok: false, error: error.message, url: bridgeUrl }
4714
+ }
4715
+ }
4716
+
4717
+ async function probeStatusSourceHealth(source, { timeoutMs = DEFAULT_STATUS_SOURCE_HEALTH_TIMEOUT_MS } = {}) {
4718
+ if (!source?.url) {
4719
+ return { ok: false, error: 'missing source URL' }
4720
+ }
4721
+ try {
4722
+ const health = await fetchJson(new URL('/health', source.url), { timeoutMs })
4723
+ return {
4724
+ ok: true,
4725
+ status: health.status || 'reachable',
4726
+ url: normalizeStatusUrl(source.url),
4727
+ }
4728
+ } catch (error) {
4729
+ return {
4730
+ ok: false,
4731
+ error: error.message,
4732
+ url: normalizeStatusUrl(source.url),
4733
+ }
4734
+ }
4735
+ }
4736
+
4737
+ function reconcileSourceRegistration(source, registrySources = [], bridge = {}) {
4738
+ if (!bridge.reachable || !bridge.registry?.ok) {
4739
+ return {
4740
+ state: 'unknown',
4741
+ registered: null,
4742
+ reason: bridge.reachable ? 'bridge registry unavailable' : 'bridge unreachable',
4743
+ }
4744
+ }
4745
+ const sourceUrl = normalizeStatusUrl(source.url)
4746
+ const idMatches = registrySources.filter((candidate) => String(candidate.id || '') === String(source.id || ''))
4747
+ const exactIdMatch = idMatches.find((candidate) => normalizeStatusUrl(candidate.url) === sourceUrl)
4748
+ if (exactIdMatch) {
4749
+ return {
4750
+ state: 'registered',
4751
+ registered: true,
4752
+ match: 'id+url',
4753
+ registeredId: exactIdMatch.id,
4754
+ registeredUrl: exactIdMatch.url,
4755
+ selected: exactIdMatch.selected,
4756
+ }
4757
+ }
4758
+ const urlMatch = registrySources.find((candidate) => normalizeStatusUrl(candidate.url) === sourceUrl)
4759
+ if (urlMatch) {
4760
+ return {
4761
+ state: 'registered',
4762
+ registered: true,
4763
+ match: 'url',
4764
+ registeredId: urlMatch.id,
4765
+ registeredUrl: urlMatch.url,
4766
+ selected: urlMatch.selected,
4767
+ }
4768
+ }
4769
+ if (idMatches.length) {
4770
+ const registered = idMatches[0]
4771
+ return {
4772
+ state: 'id-conflict',
4773
+ registered: false,
4774
+ match: 'id',
4775
+ registeredId: registered.id,
4776
+ registeredUrl: registered.url,
4777
+ selected: registered.selected,
4778
+ reason: 'same source id is registered with a different URL',
4779
+ }
4780
+ }
4781
+ return {
4782
+ state: 'not-registered',
4783
+ registered: false,
4784
+ reason: 'not found in bridge registry',
4785
+ }
4786
+ }
4787
+
4788
+ function statusSourceDescriptor(source = {}, { health, registration } = {}) {
4789
+ const processId = source.processId || source.pid || null
4790
+ const manifest = source.manifest || {}
4791
+ return {
4792
+ id: source.id || manifest.source_id || '',
4793
+ name: source.name || source.title || source.id || '',
4794
+ title: source.title || source.name || source.id || '',
4795
+ url: normalizeStatusUrl(source.url || ''),
4796
+ path: source.path || source.root || '',
4797
+ root: source.root || source.path || '',
4798
+ protocol: source.protocol || 'llmwiki-http',
4799
+ status: source.status || '',
4800
+ selected: source.selected !== false,
4801
+ processId,
4802
+ processAlive: processId ? processIsAlive(processId) : null,
4803
+ runnerProcessId: source.runnerProcessId,
4804
+ manifest,
4805
+ bundle_id: manifest.bundle_id,
4806
+ bundleId: manifest.bundleId || manifest.bundle_id,
4807
+ page_count: manifest.page_count,
4808
+ pageCount: manifest.pageCount ?? manifest.page_count,
4809
+ approved_page_count: manifest.approved_page_count,
4810
+ approvedPageCount: manifest.approvedPageCount ?? manifest.approved_page_count,
4811
+ health,
4812
+ registered: registration?.registered ?? null,
4813
+ registration: registration || { state: 'unknown', registered: null },
4814
+ logs: source.logs,
4815
+ }
4816
+ }
4817
+
4818
+ function statusRegistrySourceDescriptor(source = {}) {
4819
+ return {
4820
+ id: source.id || '',
4821
+ name: source.name || source.title || source.id || '',
4822
+ title: source.title || source.name || source.id || '',
4823
+ url: normalizeStatusUrl(source.url || ''),
4824
+ protocol: source.protocol || 'llmwiki-http',
4825
+ status: source.status || '',
4826
+ selected: source.selected !== false,
4827
+ root: source.root || source.path || '',
4828
+ bundleId: source.bundleId || source.bundle_id,
4829
+ pageCount: source.pageCount ?? source.page_count,
4830
+ approvedPageCount: source.approvedPageCount ?? source.approved_page_count,
4831
+ }
4832
+ }
4833
+
4834
+ function statusConflictWarnings(sources = []) {
4835
+ return [
4836
+ ...selectedSourceIdConflicts(sources).map((conflict) => statusWarning(
4837
+ conflict.code,
4838
+ conflict.detail,
4839
+ conflict.sources.map((source) => source.id).filter(Boolean),
4840
+ )),
4841
+ ...selectedSourcePathConflicts(sources).map((conflict) => statusWarning(
4842
+ conflict.code,
4843
+ conflict.detail,
4844
+ conflict.sources.map((source) => source.id).filter(Boolean),
4845
+ )),
4846
+ ]
4847
+ }
4848
+
4849
+ function statusWarning(code, message, sourceIds = []) {
4850
+ return {
4851
+ severity: 'warning',
4852
+ code,
4853
+ message,
4854
+ ...(sourceIds.length ? { sourceIds } : {}),
4855
+ }
4856
+ }
4857
+
4858
+ function processIsAlive(pid) {
4859
+ if (!pid) {
4860
+ return null
4861
+ }
4862
+ try {
4863
+ process.kill(Number(pid), 0)
4864
+ return true
4865
+ } catch {
4866
+ return false
4867
+ }
4868
+ }
4869
+
4870
+ function normalizeStatusUrl(value) {
4871
+ const text = String(value || '').trim()
4872
+ if (!text) {
4873
+ return ''
4874
+ }
4875
+ try {
4876
+ return redactUrlCredentials(text)
4877
+ } catch {
4878
+ return trimTrailingSlash(text)
4879
+ }
4880
+ }
4881
+
4882
+ function redactUrlCredentials(value) {
4883
+ if (!value) {
4884
+ return ''
4885
+ }
4886
+ const parsed = new URL(value)
4887
+ parsed.username = ''
4888
+ parsed.password = ''
4889
+ return trimTrailingSlash(parsed.toString())
4890
+ }
4891
+
4443
4892
  export async function startSources({ paths, host = DEFAULT_HOST, portStart = DEFAULT_PORT_START, ports = [], serveInvocation = resolveServeInvocation({}), configPath = defaultConfigPath(), logDir = defaultLogDir(), healthTimeoutMs = DEFAULT_SOURCE_HEALTH_TIMEOUT_MS, healthIntervalMs = DEFAULT_SOURCE_HEALTH_INTERVAL_MS } = {}) {
4444
4893
  if (!paths?.length) {
4445
4894
  throw new Error('start requires at least one --path')
4446
4895
  }
4447
4896
  mkdirSync(logDir, { recursive: true })
4897
+ const sourcePlans = []
4898
+ for (let index = 0; index < paths.length; index += 1) {
4899
+ const path = resolve(paths[index])
4900
+ if (!safeIsDirectory(path)) {
4901
+ throw new Error(`Source path is not a directory: ${path}`)
4902
+ }
4903
+ sourcePlans.push({ index, path })
4904
+ }
4905
+ assertNoSelectedSourcePathConflicts(sourcePlans, { action: 'source startup' })
4906
+ for (const plan of sourcePlans) {
4907
+ const manifest = await llmwikiServeJson(serveInvocation, ['manifest', plan.path], { timeoutMs: 30000 })
4908
+ const sourceId = manifest.source_id || slug(manifest.title || basename(plan.path))
4909
+ Object.assign(plan, {
4910
+ manifest,
4911
+ sourceId,
4912
+ summarizedManifest: summarizeManifest(manifest),
4913
+ })
4914
+ }
4915
+ assertNoValidatedSourceIdConflicts(sourcePlans.map((plan) => ({
4916
+ id: plan.sourceId,
4917
+ path: plan.path,
4918
+ manifest: plan.summarizedManifest,
4919
+ })), { action: 'source startup' })
4920
+
4448
4921
  const sources = []
4449
4922
  const startedProcesses = []
4450
4923
  const assignedPorts = new Set()
4451
4924
  try {
4452
- for (let index = 0; index < paths.length; index += 1) {
4453
- const path = resolve(paths[index])
4454
- if (!safeIsDirectory(path)) {
4455
- throw new Error(`Source path is not a directory: ${path}`)
4456
- }
4925
+ for (const plan of sourcePlans) {
4926
+ const { index, path, manifest, sourceId, summarizedManifest } = plan
4457
4927
  const requestedPort = ports[index] || portStart + index
4458
4928
  const port = await nextAvailablePort(requestedPort, { host, unavailable: assignedPorts })
4459
4929
  assignedPorts.add(port)
4460
- const manifest = await llmwikiServeJson(serveInvocation, ['manifest', path], { timeoutMs: 30000 })
4461
- const sourceId = manifest.source_id || slug(manifest.title || basename(path))
4462
4930
  const out = join(logDir, `${sourceId}-${port}.out.log`)
4463
4931
  const err = join(logDir, `${sourceId}-${port}.err.log`)
4464
4932
  const outFd = openSync(out, 'a')
@@ -4507,7 +4975,7 @@ export async function startSources({ paths, host = DEFAULT_HOST, portStart = DEF
4507
4975
  ...(port !== requestedPort ? { portFallback: { requestedPort, assignedPort: port, reason: 'requested-port-occupied' } } : {}),
4508
4976
  processId,
4509
4977
  runnerProcessId: serverProcessId && serverProcessId !== child.pid ? child.pid : undefined,
4510
- manifest: summarizeManifest(manifest),
4978
+ manifest: summarizedManifest,
4511
4979
  health,
4512
4980
  logs,
4513
4981
  })
@@ -4821,6 +5289,11 @@ export async function registerSources({ bridgeUrl = DEFAULT_BRIDGE_URL, configPa
4821
5289
  const normalized = sources.map((source, index) => normalizeBridgeSource(source, {
4822
5290
  selected: selectedIds.size ? selectedIds.has(source.id) : (source.selected ?? (selectFirst && index === 0)),
4823
5291
  }))
5292
+ assertNoValidatedSourceIdConflicts(normalized.map((source) => ({
5293
+ id: source.id,
5294
+ path: source.path,
5295
+ url: source.url,
5296
+ })), { action: 'source registration' })
4824
5297
 
4825
5298
  const existing = replace ? [] : await readExistingBridgeSources(bridgeUrl)
4826
5299
  const merged = applySelectedIds(mergeBridgeSources(existing, normalized), selectedIds)
@@ -4894,7 +5367,7 @@ export function mergeBridgeSources(existing, incoming) {
4894
5367
 
4895
5368
  function sameBridgeSource(left, right) {
4896
5369
  return Boolean(left.id && right.id && left.id === right.id)
4897
- || Boolean(left.url && right.url && left.url.replace(/\/+$/, '') === right.url.replace(/\/+$/, ''))
5370
+ || Boolean(left.url && right.url && trimTrailingSlash(left.url) === trimTrailingSlash(right.url))
4898
5371
  }
4899
5372
 
4900
5373
  function assertSafeSourceUrl(value) {
@@ -4905,7 +5378,7 @@ function assertSafeSourceUrl(value) {
4905
5378
  if (parsed.username || parsed.password) {
4906
5379
  throw new Error('Source URL must not contain credentials.')
4907
5380
  }
4908
- return parsed.toString().replace(/\/+$/, '')
5381
+ return trimTrailingSlash(parsed.toString())
4909
5382
  }
4910
5383
 
4911
5384
  export function bridgeStartPlan(options = {}) {
@@ -5246,13 +5719,12 @@ export async function probeRuntimeEndpoint({ baseUrl, profile = 'generic', timeo
5246
5719
  const normalizedBaseUrl = normalizeRuntimeBaseUrl(baseUrl)
5247
5720
  const runtimeProfile = parseRuntimeProfile(profile, 'generic')
5248
5721
  if (runtimeProfile !== RUNTIME_SETUP_HERMES) {
5249
- return {
5250
- ok: true,
5251
- skipped: true,
5252
- profile: runtimeProfile,
5722
+ return probeOpenAiCompatibleRuntimeEndpoint({
5253
5723
  baseUrl: normalizedBaseUrl,
5254
- reason: 'no registered framework health probe',
5255
- }
5724
+ profile: runtimeProfile,
5725
+ timeoutMs,
5726
+ fetchRuntimeJson,
5727
+ })
5256
5728
  }
5257
5729
  const healthUrls = hermesHealthUrls(normalizedBaseUrl)
5258
5730
  const errors = []
@@ -5283,10 +5755,43 @@ export async function probeRuntimeEndpoint({ baseUrl, profile = 'generic', timeo
5283
5755
  }
5284
5756
  }
5285
5757
 
5758
+ async function probeOpenAiCompatibleRuntimeEndpoint({ baseUrl, profile, timeoutMs = DEFAULT_RUNTIME_REACHABILITY_TIMEOUT_MS, fetchRuntimeJson = fetchJson } = {}) {
5759
+ const modelsUrl = openAiCompatibleModelsUrl(baseUrl)
5760
+ try {
5761
+ await fetchRuntimeJson(modelsUrl, { timeoutMs })
5762
+ return {
5763
+ ok: true,
5764
+ profile,
5765
+ baseUrl,
5766
+ url: modelsUrl,
5767
+ status: 'reachable',
5768
+ endpoint: 'models',
5769
+ }
5770
+ } catch (error) {
5771
+ return {
5772
+ ok: false,
5773
+ profile,
5774
+ baseUrl,
5775
+ url: modelsUrl,
5776
+ endpoint: 'models',
5777
+ error: error.message,
5778
+ }
5779
+ }
5780
+ }
5781
+
5782
+ function openAiCompatibleModelsUrl(baseUrl) {
5783
+ const parsed = new URL(baseUrl)
5784
+ const basePath = trimTrailingSlash(parsed.pathname)
5785
+ parsed.pathname = `${basePath || ''}/models`
5786
+ parsed.search = ''
5787
+ parsed.hash = ''
5788
+ return parsed.toString()
5789
+ }
5790
+
5286
5791
  function hermesHealthUrls(baseUrl) {
5287
5792
  const parsed = new URL(baseUrl)
5288
5793
  const origin = `${parsed.protocol}//${parsed.host}`
5289
- const basePath = parsed.pathname.replace(/\/+$/, '')
5794
+ const basePath = trimTrailingSlash(parsed.pathname)
5290
5795
  const paths = ['/health']
5291
5796
  if (basePath && basePath !== '/') {
5292
5797
  paths.push(`${basePath}/health`)
@@ -5418,10 +5923,15 @@ function bridgeModeOption(value, fallback) {
5418
5923
  }
5419
5924
 
5420
5925
  function trimTrailingSlash(value) {
5421
- return String(value || '').replace(/\/+$/, '')
5926
+ const text = String(value || '')
5927
+ let end = text.length
5928
+ while (end > 0 && text.charCodeAt(end - 1) === 47) {
5929
+ end -= 1
5930
+ }
5931
+ return end === text.length ? text : text.slice(0, end)
5422
5932
  }
5423
5933
 
5424
- export async function selectBridgeSmokeMode({ options = {}, bridgeUrl = DEFAULT_BRIDGE_URL, env = process.env, inspectBridgeRuntime = inspectBridgeRuntimeConfiguration } = {}) {
5934
+ export async function selectBridgeSmokeMode({ options = {}, bridgeUrl = DEFAULT_BRIDGE_URL, env = process.env, inspectBridgeRuntime = inspectBridgeRuntimeConfiguration, probeRuntime = probeRuntimeEndpoint } = {}) {
5425
5935
  const requestedMode = stringOption(options.mode ?? options['orchestration-mode'], '')
5426
5936
  if (requestedMode) {
5427
5937
  return { mode: bridgeModeOption(requestedMode, BRIDGE_MODE_EVIDENCE_ONLY), reason: 'requested with --mode' }
@@ -5433,8 +5943,18 @@ export async function selectBridgeSmokeMode({ options = {}, bridgeUrl = DEFAULT_
5433
5943
 
5434
5944
  const runtimeInfo = detectLlmRuntime(options, env)
5435
5945
  if (runtimeInfo.configured) {
5946
+ if (runtimeInfo.baseUrl) {
5947
+ const readiness = await probeRuntime({ baseUrl: runtimeInfo.baseUrl, profile: runtimeInfo.profile })
5948
+ if (!readiness.ok) {
5949
+ return {
5950
+ mode: BRIDGE_MODE_EVIDENCE_ONLY,
5951
+ reason: `explicit LLM endpoint was configured but not reachable (${readiness.error || 'runtime probe failed'})`,
5952
+ runtimeReachability: readiness,
5953
+ }
5954
+ }
5955
+ }
5436
5956
  const reason = runtimeInfo.baseUrl
5437
- ? `explicit LLM endpoint configured (${runtimeInfo.baseUrl})`
5957
+ ? `explicit LLM endpoint configured and reachable (${runtimeInfo.baseUrl})`
5438
5958
  : `explicit runtime adapter configured (${runtimeInfo.runtimeAdapter})`
5439
5959
  return { mode: BRIDGE_MODE_DELEGATED_RUNTIME, reason }
5440
5960
  }
@@ -5451,30 +5971,163 @@ export async function selectBridgeSmokeMode({ options = {}, bridgeUrl = DEFAULT_
5451
5971
  }
5452
5972
 
5453
5973
  async function inspectBridgeRuntimeConfiguration(bridgeUrl) {
5454
- const settings = await fetchJson(new URL('/settings.json', bridgeUrl), { timeoutMs: 2000 })
5455
- const connection = settings.runtimeConnection || {}
5456
- const baseUrl = String(connection.baseUrl || '')
5457
- const configuredBaseUrl = baseUrl
5458
- && baseUrl !== 'none'
5459
- && trimTrailingSlash(baseUrl) !== trimTrailingSlash(DEFAULT_BRIDGE_RUNTIME_BASE_URL)
5460
- if (connection.modelConfigured && (configuredBaseUrl || connection.apiKeyConfigured)) {
5461
- return { configured: true, reason: 'LLM endpoint configured in bridge settings' }
5974
+ const runtimeStatus = await inspectBridgeRuntimeForSmoke(bridgeUrl)
5975
+ if (runtimeStatus.configured && runtimeStatus.reachable) {
5976
+ return { configured: true, reason: runtimeStatus.reason, runtimeStatus }
5977
+ }
5978
+ return {
5979
+ configured: false,
5980
+ reason: runtimeStatus.reason || 'no reachable LLM endpoint detected in bridge settings',
5981
+ runtimeStatus,
5462
5982
  }
5463
- return { configured: false, reason: 'no explicit LLM endpoint detected in bridge settings' }
5464
5983
  }
5465
5984
 
5466
- export async function smokeBridge({ bridgeUrl = DEFAULT_BRIDGE_URL, query, mode = BRIDGE_MODE_EVIDENCE_ONLY } = {}) {
5467
- const smokeMode = bridgeModeOption(mode, BRIDGE_MODE_EVIDENCE_ONLY)
5468
- const response = await fetchJson(new URL('/message:send', bridgeUrl), {
5469
- method: 'POST',
5470
- body: JSON.stringify({ data: { query, mode: smokeMode } }),
5471
- headers: { 'content-type': 'application/json' },
5472
- timeoutMs: 30000,
5985
+ async function inspectBridgeRuntimeForSmoke(bridgeUrl, { runtime = null, probeRuntime = probeRuntimeEndpoint, timeoutMs = DEFAULT_RUNTIME_REACHABILITY_TIMEOUT_MS } = {}) {
5986
+ const runtimeInfo = runtime?.configured
5987
+ ? runtime
5988
+ : await bridgeRuntimeInfoFromSettings(bridgeUrl)
5989
+ if (!runtimeInfo.configured) {
5990
+ return {
5991
+ configured: false,
5992
+ reachable: false,
5993
+ reason: runtimeInfo.reason || 'no runtime endpoint is configured',
5994
+ runtime: runtimeInfo,
5995
+ }
5996
+ }
5997
+ if (runtimeInfo.runtimeAdapter === RUNTIME_ADAPTER_DEEPAGENTS_ACP && !runtimeInfo.baseUrl) {
5998
+ return {
5999
+ configured: true,
6000
+ reachable: true,
6001
+ checked: false,
6002
+ reason: 'explicit runtime adapter configured; OpenAI-compatible endpoint probe is not applicable',
6003
+ runtime: runtimeInfo,
6004
+ }
6005
+ }
6006
+ if (!runtimeInfo.baseUrl) {
6007
+ return {
6008
+ configured: false,
6009
+ reachable: false,
6010
+ reason: 'runtime is configured without a base URL',
6011
+ runtime: runtimeInfo,
6012
+ }
6013
+ }
6014
+ const reachability = await probeRuntime({
6015
+ baseUrl: runtimeInfo.baseUrl,
6016
+ profile: runtimeInfo.profile || 'generic',
6017
+ timeoutMs,
5473
6018
  })
6019
+ if (!reachability.ok) {
6020
+ return {
6021
+ configured: true,
6022
+ reachable: false,
6023
+ reason: `runtime endpoint is configured but not reachable (${reachability.error || 'probe failed'})`,
6024
+ runtime: runtimeInfo,
6025
+ reachability,
6026
+ }
6027
+ }
6028
+ return {
6029
+ configured: true,
6030
+ reachable: true,
6031
+ checked: true,
6032
+ reason: `runtime endpoint is configured and reachable (${runtimeInfo.baseUrl})`,
6033
+ runtime: runtimeInfo,
6034
+ reachability,
6035
+ }
6036
+ }
6037
+
6038
+ async function bridgeRuntimeInfoFromSettings(bridgeUrl) {
6039
+ try {
6040
+ const settings = await fetchJson(new URL('/settings.json', bridgeUrl), { timeoutMs: DEFAULT_STATUS_BRIDGE_TIMEOUT_MS })
6041
+ const connection = settings.runtimeConnection || {}
6042
+ const runtime = settings.runtime || {}
6043
+ const runtimeAdapter = parseRuntimeAdapterOption(runtime.adapter || settings.runtimeAdapter) || ''
6044
+ const profile = parseRuntimeProfile(runtime.profile || settings.runtimeProfile || connection.runtimeProfile || 'generic', 'generic')
6045
+ const model = String(connection.model || settings.model || '').trim()
6046
+ || (connection.modelConfigured ? 'configured-model' : '')
6047
+ const rawBaseUrl = String(connection.baseUrl || settings.baseUrl || '').trim()
6048
+ const baseUrl = runtimeBaseUrlFromSettings(rawBaseUrl)
6049
+ const configured = Boolean(
6050
+ runtimeAdapter === RUNTIME_ADAPTER_DEEPAGENTS_ACP
6051
+ || (baseUrl && baseUrl !== 'none' && connection.modelConfigured),
6052
+ )
6053
+ return {
6054
+ configured,
6055
+ baseUrl: baseUrl === 'none' ? '' : baseUrl,
6056
+ model,
6057
+ profile,
6058
+ runtimeAdapter,
6059
+ modelConfigured: Boolean(connection.modelConfigured),
6060
+ baseUrlSource: connection.baseUrlSource || '',
6061
+ reason: configured ? 'runtime settings found on bridge' : 'no model runtime configured in bridge settings',
6062
+ }
6063
+ } catch (error) {
6064
+ return {
6065
+ configured: false,
6066
+ baseUrl: '',
6067
+ model: '',
6068
+ profile: 'generic',
6069
+ runtimeAdapter: '',
6070
+ reason: `could not inspect bridge runtime settings: ${error.message}`,
6071
+ error: error.message,
6072
+ }
6073
+ }
6074
+ }
6075
+
6076
+ function runtimeBaseUrlFromSettings(value) {
6077
+ if (!value || value === 'none') {
6078
+ return ''
6079
+ }
6080
+ try {
6081
+ return normalizeRuntimeBaseUrl(value)
6082
+ } catch {
6083
+ return ''
6084
+ }
6085
+ }
6086
+
6087
+ function formatDelegatedRuntimePreflightError(bridgeUrl, runtimeStatus = {}) {
6088
+ const settingsUrl = sourceEndpointUrl(trimTrailingSlash(bridgeUrl), '/settings')
6089
+ const runtime = runtimeStatus.runtime || {}
6090
+ const target = runtime.baseUrl ? ` Runtime endpoint: ${runtime.baseUrl}.` : ''
6091
+ return [
6092
+ `Delegated-runtime smoke requires a reachable model runtime before it can be reported ready.${target}`,
6093
+ runtimeStatus.reason || 'Runtime reachability could not be verified.',
6094
+ `Configure the bridge runtime at ${settingsUrl}, start the endpoint, or rerun smoke with --mode evidence-only to test sources only.`,
6095
+ ].join(' ')
6096
+ }
6097
+
6098
+ export async function smokeBridge({ bridgeUrl = DEFAULT_BRIDGE_URL, query, mode = BRIDGE_MODE_EVIDENCE_ONLY, runtime = null, preflightRuntime = true, probeRuntime = probeRuntimeEndpoint } = {}) {
6099
+ const smokeMode = bridgeModeOption(mode, BRIDGE_MODE_EVIDENCE_ONLY)
6100
+ const warnings = []
6101
+ let runtimeReachability = null
6102
+ if (smokeMode === BRIDGE_MODE_EVIDENCE_ONLY) {
6103
+ warnings.push('Evidence-only smoke did not check delegated-runtime reachability.')
6104
+ } else if (preflightRuntime) {
6105
+ runtimeReachability = await inspectBridgeRuntimeForSmoke(bridgeUrl, { runtime, probeRuntime })
6106
+ if (!runtimeReachability.configured || !runtimeReachability.reachable) {
6107
+ throw new Error(formatDelegatedRuntimePreflightError(bridgeUrl, runtimeReachability))
6108
+ }
6109
+ }
6110
+ let response
6111
+ try {
6112
+ response = await fetchJson(new URL('/message:send', bridgeUrl), {
6113
+ method: 'POST',
6114
+ body: JSON.stringify({ data: { query, mode: smokeMode } }),
6115
+ headers: { 'content-type': 'application/json' },
6116
+ timeoutMs: 30000,
6117
+ })
6118
+ } catch (error) {
6119
+ if (smokeMode !== BRIDGE_MODE_EVIDENCE_ONLY) {
6120
+ throw new Error(`${error.message} Configure the model runtime at ${sourceEndpointUrl(trimTrailingSlash(bridgeUrl), '/settings')}, verify it is reachable, or rerun with --mode evidence-only to test sources only.`)
6121
+ }
6122
+ throw error
6123
+ }
5474
6124
  return {
5475
6125
  bridgeUrl,
5476
6126
  query,
5477
6127
  mode: smokeMode,
6128
+ runtimeChecked: smokeMode !== BRIDGE_MODE_EVIDENCE_ONLY && Boolean(preflightRuntime),
6129
+ ...(runtimeReachability ? { runtimeReachability } : {}),
6130
+ ...(warnings.length ? { warnings } : {}),
5478
6131
  status: response.status,
5479
6132
  text: response.status?.message?.parts?.find((part) => part.kind === 'text')?.text || '',
5480
6133
  }
@@ -5677,9 +6330,89 @@ function writeResult(result, options, io) {
5677
6330
  io.stdout.write(formatCandidates(result.candidates))
5678
6331
  return
5679
6332
  }
6333
+ if (result?.command === 'status') {
6334
+ io.stdout.write(formatStatusResult(result))
6335
+ return
6336
+ }
5680
6337
  io.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
5681
6338
  }
5682
6339
 
6340
+ function formatStatusResult(result = {}) {
6341
+ const lines = []
6342
+ const bridgeState = result.bridge?.health?.ok
6343
+ ? `reachable (${result.bridge.health.status || 'ok'})`
6344
+ : `unreachable (${result.bridge?.health?.error || 'not checked'})`
6345
+ const registryState = result.bridge?.registry?.ok
6346
+ ? `${result.bridge.registry.registeredCount || 0} registered`
6347
+ : `unavailable (${result.bridge?.registry?.error || 'not checked'})`
6348
+ lines.push(`Source config: ${result.configExists ? result.configPath : `${result.configPath} (missing)`}`)
6349
+ lines.push(`Bridge: ${result.bridgeUrl} - ${bridgeState}; registry ${registryState}`)
6350
+ lines.push(`Sources: ${result.startedCount || 0} local, ${result.healthyCount || 0} healthy, ${result.registeredCount || 0} registered`)
6351
+ if (!result.sources?.length) {
6352
+ lines.push('No local started sources found.')
6353
+ } else {
6354
+ lines.push(formatStatusSourceTable(result.sources))
6355
+ }
6356
+ if (result.warnings?.length) {
6357
+ lines.push('')
6358
+ lines.push('Warnings:')
6359
+ for (const warning of result.warnings) {
6360
+ lines.push(`- ${warning.code}: ${warning.message}`)
6361
+ }
6362
+ }
6363
+ lines.push('')
6364
+ lines.push(`Note: ${pathRedactionNoticeText()}`)
6365
+ return `${lines.join('\n')}\n`
6366
+ }
6367
+
6368
+ function formatStatusSourceTable(sources = []) {
6369
+ const rows = [
6370
+ ['ID', 'URL', 'ROOT', 'HEALTH', 'REGISTERED', 'PID'],
6371
+ ...sources.map((source) => [
6372
+ source.id || '',
6373
+ source.url || '',
6374
+ source.root || source.path || '',
6375
+ formatStatusHealth(source),
6376
+ formatStatusRegistration(source),
6377
+ formatStatusPid(source),
6378
+ ]),
6379
+ ]
6380
+ const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => String(row[column] || '').length)))
6381
+ return rows
6382
+ .map((row) => row.map((cell, column) => String(cell || '').padEnd(widths[column])).join(' ').trimEnd())
6383
+ .join('\n')
6384
+ }
6385
+
6386
+ function formatStatusHealth(source = {}) {
6387
+ if (source.health?.ok) {
6388
+ return source.health.status || 'ok'
6389
+ }
6390
+ return source.health?.error ? 'fail' : 'unknown'
6391
+ }
6392
+
6393
+ function formatStatusRegistration(source = {}) {
6394
+ if (source.registration?.state === 'registered') {
6395
+ return source.registration.selected === false ? 'yes (unselected)' : 'yes'
6396
+ }
6397
+ if (source.registration?.state === 'id-conflict') {
6398
+ return `no (id at ${source.registration.registeredUrl || 'other URL'})`
6399
+ }
6400
+ if (source.registration?.state === 'not-registered') {
6401
+ return 'no'
6402
+ }
6403
+ return 'unknown'
6404
+ }
6405
+
6406
+ function formatStatusPid(source = {}) {
6407
+ if (!source.processId) {
6408
+ return ''
6409
+ }
6410
+ if (source.processAlive === null) {
6411
+ return String(source.processId)
6412
+ }
6413
+ return `${source.processId} ${source.processAlive ? 'alive' : 'stale'}`
6414
+ }
6415
+
5683
6416
  function formatCandidates(candidates) {
5684
6417
  if (!candidates.length) {
5685
6418
  return 'No LLMWiki candidates found.\n'
@@ -5708,7 +6441,8 @@ Usage:
5708
6441
  llmwiki-bridge-start discover [--home|--workspace|--cwd|--path DIR] [--validate] [--min-score 30] [--serve-command CMD] [--json]
5709
6442
  llmwiki-bridge-start start --path DIR [--port 11001] [--serve-command CMD]
5710
6443
  llmwiki-bridge-start register [--bridge URL] [--config FILE] [--replace]
5711
- llmwiki-bridge-start smoke [--bridge URL] [--query TEXT] [--mode evidence-only|delegated-runtime|hybrid]
6444
+ llmwiki-bridge-start status|ls [--bridge URL] [--config FILE] [--json]
6445
+ llmwiki-bridge-start smoke [--bridge URL] [--query TEXT] [--mode evidence-only|delegated-runtime|hybrid] [--llm-endpoint URL] [--runtime-profile PROFILE]
5712
6446
  llmwiki-bridge-start doctor [--bridge URL]
5713
6447
 
5714
6448
  Commands:
@@ -5716,7 +6450,9 @@ Commands:
5716
6450
  discover Find likely LLMWiki Markdown, Native, Obsidian, Logseq, Dendron, Foam, or Quartz roots.
5717
6451
  start Start llmwiki-serve for explicit source paths and write a source config.
5718
6452
  register Upsert started or explicit sources in llmwiki-agent-bridge settings.
5719
- smoke Run a small bridge query; defaults to evidence-only.
6453
+ status List local started sources with live health and bridge registration state.
6454
+ ls Alias for status.
6455
+ smoke Run a small bridge query; defaults to evidence-only and says when delegated runtime was not checked.
5720
6456
  doctor Check local tool and bridge readiness.
5721
6457
 
5722
6458
  Quickstart shows recommended LLMWiki source folders first; use --include-additional for advanced/lower-priority app vaults, examples, demos, and starter/e2e sources.
@@ -5728,7 +6464,7 @@ Bridge setup is optional. Started source URLs can be used directly without llmwi
5728
6464
  After bridge setup approval, quickstart asks for runtime setup: skip/evidence-only, Hermes, or DeepAgents.
5729
6465
  Use --runtime-adapter deepagents-acp only when explicitly opting into the DeepAgents ACP bridge adapter; DeepAgents without that flag keeps the existing endpoint-based compatibility path.
5730
6466
  If a selected runtime CLI is missing, interactive quickstart may offer an official installer after explicit approval; --yes automation still requires --install-runtime.
5731
- Bridge smoke defaults to evidence-only unless --mode or quickstart runtime setup/detection selects another mode.
6467
+ Bridge smoke defaults to evidence-only unless --mode or quickstart runtime setup/detection selects another mode. Delegated-runtime smoke verifies runtime reachability first; generic OpenAI-compatible endpoints use /models and Hermes uses /health or /v1/health.
5732
6468
  Use --serve-command/--serve-arg/--serve-cwd when llmwiki-serve is not on PATH.
5733
6469
  `
5734
6470
  }