@huaqiu/dsh-eda-host 0.3.24 → 0.4.1

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/src/client.ts CHANGED
@@ -1,17 +1,21 @@
1
1
  /**
2
- * Netlist transport for `@huaqiu/dsh-eda-host`.
2
+ * Transport for `@huaqiu/dsh-eda-host`.
3
3
  *
4
4
  * DSH → dsh-eda-host → hq-edge → EDA Host is the ONLY production path. This
5
- * module fetches the semantic netlist from the hq-edge netlist router and
6
- * maps the HTTP status back to the semantic gRPC error categories. It never
7
- * parses schematic files and never touches KiCad.
5
+ * module fetches the semantic netlist and the EDA-independent host information
6
+ * from hq-edge, maps HTTP statuses back to the semantic gRPC error categories,
7
+ * and enforces the single end-to-end request budget. It never parses schematic
8
+ * files and never touches KiCad.
8
9
  *
9
10
  * @module
10
11
  */
11
12
 
12
- import { netlistUrlOf, type EdaHostConfig, type NetlistScope } from './config.js'
13
+ import { DEFAULT_REQUEST_TIMEOUT_MS, hostUrlOf, netlistUrlOf, pcbSelectionUrlOf, type EdaHostConfig, type NetlistScope } from './config.js'
13
14
  import {
14
15
  NetlistError,
16
+ type EdaHostCapability,
17
+ type EdaHostInfo,
18
+ type PcbSelection,
15
19
  type SchematicNetlist,
16
20
  } from './types.js'
17
21
 
@@ -29,49 +33,207 @@ export interface EdaHostClientDeps {
29
33
  baseUrlResolver?: () => string | undefined
30
34
  }
31
35
 
36
+ /** Per-call options. `signal` lets DSH cancel a request (see §6 of the task). */
37
+ export interface EdaHostRequestOptions {
38
+ signal?: AbortSignal
39
+ }
40
+
32
41
  export interface EdaHostClient {
33
42
  /** Netlist of the currently selected components. */
34
- getSelectionNetlist(): Promise<SchematicNetlist>
43
+ getSelectionNetlist(options?: EdaHostRequestOptions): Promise<SchematicNetlist>
35
44
  /** Complete logical netlist for the current project. */
36
- getProjectNetlist(): Promise<SchematicNetlist>
45
+ getProjectNetlist(options?: EdaHostRequestOptions): Promise<SchematicNetlist>
37
46
  /** Netlist of the current active schematic page. */
38
- getActivePageNetlist(): Promise<SchematicNetlist>
47
+ getActivePageNetlist(options?: EdaHostRequestOptions): Promise<SchematicNetlist>
48
+ /**
49
+ * EDA-independent identity / installation of the host. Presence of a result
50
+ * is the availability signal — `hq.host.v1` has no availability field.
51
+ */
52
+ getEdaHostInfo(options?: EdaHostRequestOptions): Promise<EdaHostInfo>
53
+ /** Capabilities the host currently provides. */
54
+ getEdaHostCapabilities(options?: EdaHostRequestOptions): Promise<EdaHostCapability[]>
55
+ /**
56
+ * Semantic PCB selection of the current PCB editor (hq.pcb.v1
57
+ * PcbSelectionService.GetSelection bridged through hq-edge). An empty
58
+ * selection resolves to an all-empty `PcbSelection` — never an error.
59
+ */
60
+ getPcbSelection(options?: EdaHostRequestOptions): Promise<PcbSelection>
39
61
  }
40
62
 
41
- /** HTTP status → semantic error kind (see routes/netlist.ts on hq-edge). */
63
+ /** HTTP status → semantic error kind (see routes/edaHostStatus.ts on hq-edge). */
42
64
  function statusToKind(status: number): NetlistError['kind'] {
43
65
  if (status === 412) return 'FAILED_PRECONDITION'
44
66
  if (status === 501) return 'UNIMPLEMENTED'
45
67
  if (status === 503) return 'UNAVAILABLE'
68
+ if (status === 504) return 'DEADLINE_EXCEEDED'
46
69
  return 'INTERNAL'
47
70
  }
48
71
 
72
+ /** True when a thrown fetch error is an abort/timeout rather than a transport error. */
73
+ function isAbortError(err: unknown): boolean {
74
+ const name = (err as Error | undefined)?.name
75
+ return name === 'AbortError' || name === 'TimeoutError'
76
+ }
77
+
78
+ /**
79
+ * Combine an optional caller signal with the plugin's own budget.
80
+ *
81
+ * `AbortSignal.any` is not available on every Node version DSH may run on, so
82
+ * fall back to whichever signal exists — the budget is always present, which is
83
+ * what guarantees no request can wait indefinitely.
84
+ */
85
+ function resolveSignal(deadlineMs: number, caller?: AbortSignal): AbortSignal | undefined {
86
+ const budget = deadlineMs > 0 ? AbortSignal.timeout(deadlineMs) : undefined
87
+ const signals = [caller, budget].filter((s): s is AbortSignal => Boolean(s))
88
+ if (signals.length === 0) return undefined
89
+ if (signals.length === 1) return signals[0]
90
+ const anyFn = (AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }).any
91
+ return typeof anyFn === 'function' ? anyFn.call(AbortSignal, signals) : signals[0]
92
+ }
93
+
94
+ /**
95
+ * Extract the semantic `SchematicNetlist` from an hq-edge netlist body.
96
+ *
97
+ * hq-edge now emits a single-level body: `{ netlist: { components, nets } }`.
98
+ * Older hq-edge builds serialized the protobuf envelope
99
+ * (`GetNetListResponse.oneof result`), which produced a second `netlist` level
100
+ * and made every populated design look empty. That legacy shape is unwrapped
101
+ * EXPLICITLY — not silently — and anything else is a hard error, because
102
+ * "malformed" must never masquerade as "empty design".
103
+ */
104
+ export function parseNetlistBody(body: unknown): SchematicNetlist {
105
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
106
+ throw new NetlistError('INTERNAL', 'eda-host: malformed netlist response from hq-edge')
107
+ }
108
+
109
+ let candidate: unknown = (body as { netlist?: unknown }).netlist
110
+
111
+ if (
112
+ candidate &&
113
+ typeof candidate === 'object' &&
114
+ !Array.isArray(candidate) &&
115
+ (candidate as { components?: unknown }).components === undefined &&
116
+ typeof (candidate as { netlist?: unknown }).netlist === 'object'
117
+ ) {
118
+ // Legacy double-nested envelope — unwrap exactly one level.
119
+ candidate = (candidate as { netlist: unknown }).netlist
120
+ }
121
+
122
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
123
+ throw new NetlistError('INTERNAL', 'eda-host: malformed netlist response from hq-edge')
124
+ }
125
+
126
+ const { components, nets } = candidate as { components?: unknown; nets?: unknown }
127
+
128
+ // proto3 JSON omits empty arrays, so `undefined` is a legitimate empty list.
129
+ if (components !== undefined && !Array.isArray(components)) {
130
+ throw new NetlistError('INTERNAL', 'eda-host: netlist.components is not an array')
131
+ }
132
+ if (nets !== undefined && !Array.isArray(nets)) {
133
+ throw new NetlistError('INTERNAL', 'eda-host: netlist.nets is not an array')
134
+ }
135
+
136
+ return {
137
+ components: (components ?? []) as SchematicNetlist['components'],
138
+ nets: (nets ?? []) as SchematicNetlist['nets'],
139
+ }
140
+ }
141
+
142
+ function str(value: unknown): string {
143
+ return typeof value === 'string' ? value : ''
144
+ }
145
+
146
+ /**
147
+ * Extract a complete `EdaHostInfo` from an hq-edge host-info body.
148
+ *
149
+ * `hq.host.v1` has no availability flags, so the only way to distinguish "the
150
+ * host is here" from "the host is not" is whether this call succeeded at all.
151
+ * That makes the *shape* the contract: proto3 JSON omits default-valued fields,
152
+ * so a host that legitimately reports nothing arrives as `{}`. Rather than let
153
+ * a half-empty object reach the agent — where a missing field could be misread
154
+ * as "not available" — absent values are filled with their proto3 defaults.
155
+ *
156
+ * Values that are present are never reinterpreted; unknown `hostType` strings
157
+ * pass through so a newer host cannot be silently downgraded.
158
+ */
159
+ export function parseEdaHostInfo(value: unknown): EdaHostInfo {
160
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
161
+ throw new NetlistError('INTERNAL', 'eda-host: malformed host info response from hq-edge')
162
+ }
163
+
164
+ const raw = value as {
165
+ identity?: { hostType?: unknown; hostName?: unknown; version?: unknown }
166
+ installation?: { applicationPath?: unknown; executables?: unknown }
167
+ }
168
+
169
+ const executables = Array.isArray(raw.installation?.executables)
170
+ ? (raw.installation?.executables as unknown[])
171
+ .filter((e): e is Record<string, unknown> => Boolean(e) && typeof e === 'object')
172
+ .map((e) => ({ name: str(e.name), path: str(e.path) }))
173
+ : []
174
+
175
+ return {
176
+ identity: {
177
+ hostType:
178
+ typeof raw.identity?.hostType === 'string'
179
+ ? (raw.identity.hostType as EdaHostInfo['identity']['hostType'])
180
+ : 'EDA_HOST_TYPE_UNSPECIFIED',
181
+ hostName: str(raw.identity?.hostName),
182
+ version: str(raw.identity?.version),
183
+ },
184
+ installation: {
185
+ applicationPath: str(raw.installation?.applicationPath),
186
+ executables,
187
+ },
188
+ }
189
+ }
190
+
49
191
  export function createEdaHostClient(
50
192
  config: EdaHostConfig,
51
193
  deps: EdaHostClientDeps = {},
52
194
  ): EdaHostClient {
53
195
  const fetchImpl = deps.fetchImpl ?? globalThis.fetch
54
196
 
55
- async function fetchScope(scope: NetlistScope): Promise<SchematicNetlist> {
56
- // Resolve the host endpoint per request. A resolver (ctx.hqEdge) wins over
57
- // the static config/env value; if neither yields a URL we degrade to the
58
- // same clear FAILED_PRECONDITION the standalone install path uses.
59
- const baseUrl = deps.baseUrlResolver?.()?.trim()
60
- ?? config.hqEdgeBaseUrl?.trim()
61
- ?? ''
197
+ /**
198
+ * Resolve the host endpoint per request. A resolver (ctx.hqEdge) wins over
199
+ * the static config/env value; if neither yields a URL we degrade to the
200
+ * same clear FAILED_PRECONDITION the standalone install path uses.
201
+ */
202
+ function resolveConfig(): EdaHostConfig {
203
+ const baseUrl = deps.baseUrlResolver?.()?.trim() ?? config.hqEdgeBaseUrl?.trim() ?? ''
62
204
  if (baseUrl.length === 0) {
63
205
  throw new NetlistError(
64
206
  'FAILED_PRECONDITION',
65
- 'eda-host: no hq-edge base URL configured (hqEdgeBaseUrl / HQ_EDGE_BASE_URL) — ' +
66
- 'netlist tools require the hq-edge EDA host bridge.',
207
+ 'eda-host: no hq-edge base URL configured (ctx.hqEdge.baseUrl / hqEdgeBaseUrl / ' +
208
+ 'HQ_EDGE_BASE_URL) — EDA host tools require the hq-edge bridge.',
67
209
  )
68
210
  }
69
- const url = netlistUrlOf({ ...config, hqEdgeBaseUrl: baseUrl }, scope)
211
+ return { ...config, hqEdgeBaseUrl: baseUrl }
212
+ }
213
+
214
+ /** Perform one GET and return the parsed JSON body, mapping failures. */
215
+ async function getJson(
216
+ url: string,
217
+ options: EdaHostRequestOptions | undefined,
218
+ what: string,
219
+ ): Promise<unknown> {
220
+ const signal = resolveSignal(config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, options?.signal)
70
221
 
71
222
  let response: Response
72
223
  try {
73
- response = await fetchImpl(url, { method: 'GET', headers: { Accept: 'application/json' } })
224
+ response = await fetchImpl(url, {
225
+ method: 'GET',
226
+ headers: { Accept: 'application/json' },
227
+ ...(signal ? { signal } : {}),
228
+ })
74
229
  } catch (err) {
230
+ // An aborted request is a timeout, never an empty result.
231
+ if (isAbortError(err)) {
232
+ throw new NetlistError(
233
+ 'DEADLINE_EXCEEDED',
234
+ `eda-host: ${what} request exceeded its time budget at ${url}`,
235
+ )
236
+ }
75
237
  // Connection-level failure: host not running / unreachable.
76
238
  throw new NetlistError(
77
239
  'UNAVAILABLE',
@@ -89,25 +251,87 @@ export function createEdaHostClient(
89
251
  }
90
252
  throw new NetlistError(
91
253
  statusToKind(response.status),
92
- `eda-host: netlist request failed (${response.status}${detail ? `: ${detail}` : ''})`,
254
+ `eda-host: ${what} request failed (${response.status}${detail ? `: ${detail}` : ''})`,
93
255
  )
94
256
  }
95
257
 
96
- // Valid (possibly empty) result: { netlist: { components, nets } }.
97
- const body = (await response.json()) as { netlist?: SchematicNetlist }
98
- if (!body || typeof body.netlist !== 'object' || body.netlist === null) {
99
- throw new NetlistError('INTERNAL', 'eda-host: malformed netlist response from hq-edge')
258
+ return response.json()
259
+ }
260
+
261
+ async function fetchScope(
262
+ scope: NetlistScope,
263
+ options?: EdaHostRequestOptions,
264
+ ): Promise<SchematicNetlist> {
265
+ const resolved = resolveConfig()
266
+ const url = netlistUrlOf(resolved, scope)
267
+ return parseNetlistBody(await getJson(url, options, 'netlist'))
268
+ }
269
+
270
+ function parsePcbSelection(value: unknown): PcbSelection {
271
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
272
+ throw new NetlistError(
273
+ 'INTERNAL',
274
+ 'eda-host: malformed PCB selection response from hq-edge',
275
+ )
100
276
  }
101
277
 
102
- const netlist = body.netlist
103
- netlist.components ??= []
104
- netlist.nets ??= []
105
- return netlist
278
+ const asArray = (v: unknown): unknown[] => (Array.isArray(v) ? v : [])
279
+
280
+ // proto3 JSON omits empty arrays; every field is a repeated list.
281
+ return {
282
+ footprints: asArray((value as Record<string, unknown>).footprints),
283
+ pads: asArray((value as Record<string, unknown>).pads),
284
+ tracks: asArray((value as Record<string, unknown>).tracks),
285
+ arcs: asArray((value as Record<string, unknown>).arcs),
286
+ vias: asArray((value as Record<string, unknown>).vias),
287
+ zones: asArray((value as Record<string, unknown>).zones),
288
+ shapes: asArray((value as Record<string, unknown>).shapes),
289
+ texts: asArray((value as Record<string, unknown>).texts),
290
+ dimensions: asArray((value as Record<string, unknown>).dimensions),
291
+ groups: asArray((value as Record<string, unknown>).groups),
292
+ nets: asArray((value as Record<string, unknown>).nets),
293
+ } as PcbSelection
106
294
  }
107
295
 
108
296
  return {
109
- getSelectionNetlist: () => fetchScope('selection'),
110
- getProjectNetlist: () => fetchScope('project'),
111
- getActivePageNetlist: () => fetchScope('active-page'),
297
+ getSelectionNetlist: (options) => fetchScope('selection', options),
298
+ getProjectNetlist: (options) => fetchScope('project', options),
299
+ getActivePageNetlist: (options) => fetchScope('active-page', options),
300
+
301
+ getEdaHostInfo: async (options) => {
302
+ const resolved = resolveConfig()
303
+ const url = hostUrlOf(resolved, 'info')
304
+ const body = (await getJson(url, options, 'host info')) as { info?: unknown }
305
+
306
+ if (!body || typeof body.info !== 'object' || body.info === null) {
307
+ throw new NetlistError('INTERNAL', 'eda-host: malformed host info response from hq-edge')
308
+ }
309
+ return parseEdaHostInfo(body.info)
310
+ },
311
+
312
+ getEdaHostCapabilities: async (options) => {
313
+ const resolved = resolveConfig()
314
+ const url = hostUrlOf(resolved, 'capabilities')
315
+ const body = (await getJson(url, options, 'host capabilities')) as {
316
+ capabilities?: unknown
317
+ }
318
+
319
+ if (!body || !Array.isArray(body.capabilities)) {
320
+ throw new NetlistError(
321
+ 'INTERNAL',
322
+ 'eda-host: malformed host capabilities response from hq-edge',
323
+ )
324
+ }
325
+ return body.capabilities.filter(
326
+ (c): c is EdaHostCapability => typeof c === 'string',
327
+ )
328
+ },
329
+
330
+ getPcbSelection: async (options) => {
331
+ const resolved = resolveConfig()
332
+ const url = pcbSelectionUrlOf(resolved)
333
+ const body = (await getJson(url, options, 'pcb selection')) as unknown
334
+ return parsePcbSelection(body)
335
+ },
112
336
  }
113
337
  }
package/src/config.ts CHANGED
@@ -15,10 +15,29 @@ export interface EdaHostConfig {
15
15
  hqEdgeBaseUrl?: string
16
16
  /** Path prefix on the host; default "/api/v1/netlist". */
17
17
  netlistPathPrefix?: string
18
+ /** Path prefix for PCB selection; default "/api/v1/pcb-selection". */
19
+ pcbSelectionPathPrefix?: string
20
+ /** Path prefix for host discovery; default "/api/v1/host". */
21
+ hostPathPrefix?: string
22
+ /**
23
+ * End-to-end budget for one EDA-host request, in milliseconds.
24
+ *
25
+ * This is the SINGLE request budget for the whole chain — the plugin does
26
+ * not define separate per-layer timeouts. It is enforced here (outermost)
27
+ * and mirrored by the KiCad UI-dispatch bound, so no agent request can wait
28
+ * indefinitely. See docs/tasks/expose-capability.md §6.
29
+ */
30
+ requestTimeoutMs?: number
18
31
  }
19
32
 
20
33
  export const DEFAULT_NETLIST_PATH_PREFIX = '/api/v1/netlist'
21
34
 
35
+ export const DEFAULT_PCB_SELECTION_PATH_PREFIX = '/api/v1/pcb-selection'
36
+
37
+ export const DEFAULT_HOST_PATH_PREFIX = '/api/v1/host'
38
+
39
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000
40
+
22
41
  /** Scope → route suffix on the host netlist router. */
23
42
  export type NetlistScope = 'project' | 'selection' | 'active-page'
24
43
 
@@ -35,9 +54,21 @@ export function resolveEdaHostConfig(
35
54
  const baseUrl = config?.hqEdgeBaseUrl ?? env.HQ_EDGE_BASE_URL ?? ''
36
55
  const pathPrefix =
37
56
  config?.netlistPathPrefix ?? env.HQ_EDGE_NETLIST_PATH ?? DEFAULT_NETLIST_PATH_PREFIX
57
+ const pcbSelectionPrefix =
58
+ config?.pcbSelectionPathPrefix ??
59
+ env.HQ_EDGE_PCB_SELECTION_PATH ??
60
+ DEFAULT_PCB_SELECTION_PATH_PREFIX
61
+ const hostPrefix =
62
+ config?.hostPathPrefix ?? env.HQ_EDGE_HOST_PATH ?? DEFAULT_HOST_PATH_PREFIX
63
+ const timeoutRaw = config?.requestTimeoutMs ?? env.HQ_EDGE_REQUEST_TIMEOUT_MS
64
+ const timeout = Number.parseInt(String(timeoutRaw ?? ''), 10)
38
65
  return {
39
66
  hqEdgeBaseUrl: baseUrl,
40
67
  netlistPathPrefix: pathPrefix,
68
+ pcbSelectionPathPrefix: pcbSelectionPrefix,
69
+ hostPathPrefix: hostPrefix,
70
+ requestTimeoutMs:
71
+ Number.isFinite(timeout) && timeout > 0 ? timeout : DEFAULT_REQUEST_TIMEOUT_MS,
41
72
  }
42
73
  }
43
74
 
@@ -52,3 +83,28 @@ export function netlistUrlOf(config: EdaHostConfig, scope: NetlistScope): string
52
83
  const prefix = (config.netlistPathPrefix ?? DEFAULT_NETLIST_PATH_PREFIX).replace(/^\/+|\/+$/g, '')
53
84
  return `${base}/${prefix}${SCOPE_ROUTE[scope]}`
54
85
  }
86
+
87
+ /** Build the absolute URL for the PCB selection route. */
88
+ export function pcbSelectionUrlOf(config: EdaHostConfig): string {
89
+ const base = (config.hqEdgeBaseUrl ?? '').replace(/\/+$/, '')
90
+ const prefix = (config.pcbSelectionPathPrefix ?? DEFAULT_PCB_SELECTION_PATH_PREFIX).replace(
91
+ /^\/+|\/+$/g,
92
+ '',
93
+ )
94
+ return `${base}/${prefix}`
95
+ }
96
+
97
+ /** Host discovery route suffix. */
98
+ export type HostRoute = 'info' | 'capabilities'
99
+
100
+ const HOST_ROUTE: Record<HostRoute, string> = {
101
+ info: '/info',
102
+ capabilities: '/capabilities',
103
+ }
104
+
105
+ /** Build the absolute URL for one host discovery route. */
106
+ export function hostUrlOf(config: EdaHostConfig, route: HostRoute): string {
107
+ const base = (config.hqEdgeBaseUrl ?? '').replace(/\/+$/, '')
108
+ const prefix = (config.hostPathPrefix ?? DEFAULT_HOST_PATH_PREFIX).replace(/^\/+|\/+$/g, '')
109
+ return `${base}/${prefix}${HOST_ROUTE[route]}`
110
+ }
package/src/index.ts CHANGED
@@ -2,11 +2,14 @@
2
2
  * `@huaqiu/dsh-eda-host` — node plugin entry.
3
3
  *
4
4
  * Provides the `edaHost` service (semantic EDA-host capability) and registers
5
- * three agent tools:
5
+ * six agent tools:
6
6
  *
7
7
  * get_project_netlist complete project netlist
8
8
  * get_selection_netlist currently selected components netlist
9
9
  * get_active_page_netlist active schematic page netlist
10
+ * get_pcb_selection semantic PCB selection of the current PCB editor
11
+ * get_eda_host_info which EDA host, version, installation
12
+ * get_eda_host_capabilities what the current host can actually do
10
13
  *
11
14
  * ── Architectural boundary (task: add-dsh-eda-host) ─────────────────────────
12
15
  * The ONLY production request path is DSH → dsh-eda-host → hq-edge → EDA host.
@@ -26,7 +29,7 @@ import type { Context } from '@deepseek-ai/cordis'
26
29
  import { getLogger } from '@huaqiu/dsh-plugin-log'
27
30
  import { createEdaHostClient, type EdaHostClient } from './client.js'
28
31
  import { hasHost, resolveEdaHostConfig, type EdaHostConfig } from './config.js'
29
- import { createNetListTools } from './tools.js'
32
+ import { createEdaHostTools, createNetListTools } from './tools.js'
30
33
 
31
34
  /** Plugin id — matches package.json. */
32
35
  export const name = '@huaqiu/dsh-eda-host'
@@ -50,17 +53,38 @@ export const name = '@huaqiu/dsh-eda-host'
50
53
  export const inject = ['hqEdge', 'tools'] as const
51
54
 
52
55
  export type { EdaHostConfig } from './config.js'
53
- export type { EdaHostClient } from './client.js'
56
+ export type { EdaHostClient, EdaHostRequestOptions } from './client.js'
54
57
  export type {
58
+ EdaHostCapability,
59
+ EdaHostExecutable,
60
+ EdaHostIdentity,
61
+ EdaHostInfo,
62
+ EdaHostInstallation,
63
+ EdaHostType,
55
64
  ElectricalNet,
56
65
  ElectricalType,
57
66
  NetlistErrorKind,
67
+ PcbArc,
68
+ PcbDimension,
69
+ PcbFootprint,
70
+ PcbGroup,
71
+ PcbNetRef,
72
+ PcbPad,
73
+ PcbPoint,
74
+ PcbSegment,
75
+ PcbSelection,
76
+ PcbShape,
77
+ PcbText,
78
+ PcbTrack,
79
+ PcbVia,
80
+ PcbZone,
58
81
  PinDefinition,
59
82
  PinReference,
60
83
  SchematicComponent,
61
84
  SchematicNetlist,
62
85
  } from './types.js'
63
86
  export { NetlistError } from './types.js'
87
+ export { parseNetlistBody } from './client.js'
64
88
 
65
89
  declare module '@deepseek-ai/cordis' {
66
90
  interface Context {
@@ -88,6 +112,12 @@ declare module '@deepseek-ai/cordis' {
88
112
  const COMPONENT = 'dsh-eda-host'
89
113
  const log = getLogger(COMPONENT)
90
114
 
115
+ // Emitted on import, before any Cordis dependency is resolved. Pairs with the
116
+ // "node half ready" marker in apply(): if this line is logged but that one is
117
+ // not, the edge-bridge never provided `hqEdge` and this plugin is still
118
+ // pending — which is otherwise completely silent.
119
+ log.info('dsh-eda-host: module loaded (waiting for the hqEdge + tools services)')
120
+
91
121
  /**
92
122
  * Host plugin body — provide `edaHost` and register the three netlist tools.
93
123
  *
@@ -113,30 +143,61 @@ export function apply(ctx: Context, config: Partial<EdaHostConfig> = {}): () =>
113
143
 
114
144
  const resolved = resolveEdaHostConfig(config)
115
145
 
146
+ // ── Explicit bridge dependency ───────────────────────────────────────────
147
+ // `hqEdge` is a REQUIRED inject, so Cordis only calls `apply()` once the
148
+ // edge-bridge has provided it. That means a missing bridge would otherwise
149
+ // leave this plugin pending forever with the tools never registered and
150
+ // nothing logged. Two things make that diagnosable:
151
+ //
152
+ // 1. `apply()` asserts the bridge actually gave us a usable endpoint and
153
+ // THROWS when it did not — a loud startup failure beats five tools that
154
+ // fail one by one at call time.
155
+ // 2. The module-level marker below is emitted on import. If
156
+ // "dsh-eda-host: module loaded" appears in the log but
157
+ // "dsh-eda-host: node half ready" never does, the bridge never provided
158
+ // `hqEdge` and this plugin is still pending.
159
+ //
160
+ // hq-edge is NOT optional here — this plugin must never become a standalone
161
+ // DSH plugin (docs/tasks/expose-capability.md §3, §7).
162
+ const hq = ctx.hqEdge
163
+ if (!hq || typeof hq.baseUrl !== 'string' || hq.baseUrl.trim().length === 0) {
164
+ throw new Error(
165
+ '@huaqiu/dsh-eda-host requires a usable hq-edge context: the edge-bridge ' +
166
+ 'plugin did not provide ctx.hqEdge.baseUrl. EDA host tools cannot work ' +
167
+ 'without hq-edge — check that the bridge started and that the HQ Edge ' +
168
+ 'port is valid.',
169
+ )
170
+ }
171
+
116
172
  // Late-bound host endpoint: prefer the edge-bridge service, then the overlay
117
173
  // config / env value. Consulted on every request (see client.ts).
118
174
  const getHqEdgeBaseUrl = (): string | undefined => {
119
- const hq = ctx.hqEdge
120
- return hq?.baseUrl && hq.baseUrl.trim().length > 0 ? hq.baseUrl : undefined
175
+ const current = ctx.hqEdge
176
+ return current?.baseUrl && current.baseUrl.trim().length > 0 ? current.baseUrl : undefined
121
177
  }
122
178
 
123
179
  log.info('applying dsh-eda-host node half', {
124
180
  hasConfigHost: hasHost(resolved),
125
181
  hqEdgeBaseUrlFromConfig: resolved.hqEdgeBaseUrl ?? null,
126
182
  netlistPathPrefix: resolved.netlistPathPrefix,
183
+ hostPathPrefix: resolved.hostPathPrefix,
184
+ requestTimeoutMs: resolved.requestTimeoutMs,
127
185
  })
128
186
 
129
187
  const client = createEdaHostClient(resolved, { baseUrlResolver: getHqEdgeBaseUrl })
130
188
 
131
189
  ctx.effect(() => ctx.provide('edaHost', client))
132
190
 
133
- const tools = createNetListTools({ client })
191
+ const tools = [...createNetListTools({ client }), ...createEdaHostTools({ client })]
134
192
  const disposers: Array<() => void> = []
135
193
  for (const tool of tools) {
136
194
  disposers.push(ctx.tools.register(tool))
137
195
  }
138
196
 
139
- log.info('dsh-eda-host node half ready', { tools: 3, configHostMode: hasHost(resolved) })
197
+ log.info('dsh-eda-host node half ready', {
198
+ tools: tools.length,
199
+ configHostMode: hasHost(resolved),
200
+ })
140
201
 
141
202
  return () => {
142
203
  for (const dispose of disposers) {