@hanphone/dsh-a2a 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +101 -106
  2. package/README.zh.md +50 -74
  3. package/cordis.patch.yml +4 -7
  4. package/lib/client.js +217 -73
  5. package/lib/index.js +1258 -867
  6. package/lib/types/api.d.ts +40 -23
  7. package/lib/types/api.d.ts.map +1 -1
  8. package/lib/types/api.js +58 -23
  9. package/lib/types/api.js.map +1 -1
  10. package/lib/types/client/index.d.ts +50 -19
  11. package/lib/types/client/index.d.ts.map +1 -1
  12. package/lib/types/commands.d.ts +4 -2
  13. package/lib/types/commands.d.ts.map +1 -1
  14. package/lib/types/commands.js +59 -46
  15. package/lib/types/commands.js.map +1 -1
  16. package/lib/types/events.d.ts +32 -9
  17. package/lib/types/events.d.ts.map +1 -1
  18. package/lib/types/index.d.ts +21 -32
  19. package/lib/types/index.d.ts.map +1 -1
  20. package/lib/types/index.js +173 -272
  21. package/lib/types/index.js.map +1 -1
  22. package/lib/types/outbound/calls.d.ts.map +1 -1
  23. package/lib/types/outbound/calls.js +8 -6
  24. package/lib/types/outbound/calls.js.map +1 -1
  25. package/lib/types/protocol.d.ts +174 -109
  26. package/lib/types/protocol.d.ts.map +1 -1
  27. package/lib/types/protocol.js +58 -34
  28. package/lib/types/protocol.js.map +1 -1
  29. package/lib/types/server/a2a-server.d.ts +4 -0
  30. package/lib/types/server/a2a-server.d.ts.map +1 -1
  31. package/lib/types/server/a2a-server.js +6 -2
  32. package/lib/types/server/a2a-server.js.map +1 -1
  33. package/lib/types/server/card.d.ts +6 -30
  34. package/lib/types/server/card.d.ts.map +1 -1
  35. package/lib/types/server/card.js +13 -49
  36. package/lib/types/server/card.js.map +1 -1
  37. package/lib/types/server/exec/agent-runtime.d.ts +3 -0
  38. package/lib/types/server/exec/agent-runtime.d.ts.map +1 -1
  39. package/lib/types/server/exec/agent-runtime.js +2 -1
  40. package/lib/types/server/exec/agent-runtime.js.map +1 -1
  41. package/lib/types/server/routes.d.ts +5 -1
  42. package/lib/types/server/routes.d.ts.map +1 -1
  43. package/lib/types/server/routes.js +9 -5
  44. package/lib/types/server/routes.js.map +1 -1
  45. package/lib/types/server/store.d.ts +35 -1
  46. package/lib/types/server/store.d.ts.map +1 -1
  47. package/lib/types/server/store.js +10 -0
  48. package/lib/types/server/store.js.map +1 -1
  49. package/lib/types/servers/inbound-manager.d.ts +131 -0
  50. package/lib/types/servers/inbound-manager.d.ts.map +1 -0
  51. package/lib/types/servers/inbound-manager.js +312 -0
  52. package/lib/types/servers/inbound-manager.js.map +1 -0
  53. package/lib/types/servers/outbound-manager.d.ts +129 -0
  54. package/lib/types/servers/outbound-manager.d.ts.map +1 -0
  55. package/lib/types/servers/outbound-manager.js +238 -0
  56. package/lib/types/servers/outbound-manager.js.map +1 -0
  57. package/lib/types/service.d.ts +106 -40
  58. package/lib/types/service.d.ts.map +1 -1
  59. package/lib/types/service.js +36 -27
  60. package/lib/types/service.js.map +1 -1
  61. package/package.json +2 -2
  62. package/src/api.ts +67 -53
  63. package/src/client/index.ts +309 -148
  64. package/src/commands.ts +59 -43
  65. package/src/events.ts +14 -7
  66. package/src/index.ts +199 -310
  67. package/src/outbound/calls.ts +8 -6
  68. package/src/protocol.ts +204 -95
  69. package/src/server/a2a-server.ts +9 -2
  70. package/src/server/card.ts +13 -62
  71. package/src/server/exec/agent-runtime.ts +5 -2
  72. package/src/server/routes.ts +6 -4
  73. package/src/server/store.ts +45 -4
  74. package/src/servers/inbound-manager.ts +385 -0
  75. package/src/servers/outbound-manager.ts +289 -0
  76. package/src/service.ts +125 -40
  77. package/lib/tsconfig.client.tsbuildinfo +0 -1
  78. package/lib/tsconfig.tsbuildinfo +0 -1
@@ -15,7 +15,7 @@ import {
15
15
  type DomainFacility,
16
16
  type KvTable,
17
17
  } from '@deepseek-ai/dsh-storage-domain'
18
- import { TaskState, type Part } from '../protocol.ts'
18
+ import { TaskState, type AgentSkill, type Part } from '../protocol.ts'
19
19
 
20
20
  /** One durable inbound/outbound task record. */
21
21
  export interface TaskRecord {
@@ -32,6 +32,8 @@ export interface TaskRecord {
32
32
  readonly executor: 'session' | 'subagent'
33
33
  readonly summary: string | null
34
34
  readonly error?: { readonly code: string; readonly message: string }
35
+ /** The inbound server instance this task arrived through. */
36
+ readonly serverId?: string
35
37
  }
36
38
 
37
39
  /** One durable context→session binding (inbound conversation ↔ DSH session). */
@@ -50,6 +52,33 @@ export interface OutboundAgentRecord {
50
52
  readonly lastCardAt: string | null
51
53
  }
52
54
 
55
+ /** One durable inbound server instance. */
56
+ export interface InboundServerRecord {
57
+ readonly id: string
58
+ readonly name: string
59
+ readonly description: string
60
+ readonly version: string
61
+ readonly endpointPath: string
62
+ /** Agent preset id composing inbound sessions; absent = deployment default. */
63
+ readonly preset?: string
64
+ readonly authTokenEnv?: string
65
+ /** Advertised skills (creator-entered declarations, default = preset name). */
66
+ readonly skills: readonly AgentSkill[]
67
+ readonly enabled: boolean
68
+ }
69
+
70
+ /** One durable outbound server connection instance. */
71
+ export interface OutboundServerRecord {
72
+ readonly id: string
73
+ readonly name: string
74
+ readonly agentCardUrl: string
75
+ readonly bearerTokenEnv?: string
76
+ /** Preset composing this DSH's local session when talking to the remote. */
77
+ readonly preset?: string
78
+ readonly enabled: boolean
79
+ readonly timeoutMs: number
80
+ }
81
+
53
82
  /** JSON-encoded record schema shared by every `a2a` table. */
54
83
  const json = z.string()
55
84
 
@@ -62,6 +91,8 @@ export const a2aDomainSpec = defineDomain({
62
91
  contexts: domainTable<string, string>(json),
63
92
  agents: domainTable<string, string>(json),
64
93
  identity: domainTable<string, string>(json),
94
+ inbound_servers: domainTable<string, string>(json),
95
+ outbound_servers: domainTable<string, string>(json),
65
96
  },
66
97
  })
67
98
 
@@ -112,6 +143,14 @@ export class A2aDomain {
112
143
  return this.handle.table('identity')
113
144
  }
114
145
 
146
+ get inbound_servers(): KvTable<string, string> {
147
+ return this.handle.table('inbound_servers')
148
+ }
149
+
150
+ get outbound_servers(): KvTable<string, string> {
151
+ return this.handle.table('outbound_servers')
152
+ }
153
+
115
154
  async close(): Promise<void> {
116
155
  await this.handle.close()
117
156
  }
@@ -119,7 +158,7 @@ export class A2aDomain {
119
158
 
120
159
  /** Task-store contract shared by the domain-backed and memory stores. */
121
160
  export interface TaskStore {
122
- create(input: { contextId: string; skill: string; parts: readonly Part[]; remotePeerId: string | null }): TaskRecord
161
+ create(input: { contextId: string; skill: string; parts: readonly Part[]; remotePeerId: string | null; serverId?: string }): TaskRecord
123
162
  get(taskId: string): TaskRecord | undefined
124
163
  list(): TaskRecord[]
125
164
  setState(taskId: string, state: TaskState, message?: { code: string; text: string }): TaskRecord
@@ -146,7 +185,7 @@ export class MemoryTaskStore implements TaskStore {
146
185
  private tasks = new Map<string, TaskRecord>()
147
186
  private contexts = new Map<string, string>()
148
187
 
149
- create(input: { contextId: string; skill: string; parts: readonly Part[]; remotePeerId: string | null }): TaskRecord {
188
+ create(input: { contextId: string; skill: string; parts: readonly Part[]; remotePeerId: string | null; serverId?: string }): TaskRecord {
150
189
  const record: TaskRecord = {
151
190
  taskId: `a2a-${crypto.randomUUID()}`,
152
191
  contextId: input.contextId,
@@ -160,6 +199,7 @@ export class MemoryTaskStore implements TaskStore {
160
199
  artifacts: [],
161
200
  executor: 'session',
162
201
  summary: null,
202
+ ...(input.serverId !== undefined ? { serverId: input.serverId } : {}),
163
203
  }
164
204
  this.tasks.set(record.taskId, record)
165
205
  return record
@@ -244,7 +284,7 @@ export class DomainTaskStore implements TaskStore {
244
284
  }
245
285
  }
246
286
 
247
- create(input: { contextId: string; skill: string; parts: readonly Part[]; remotePeerId: string | null }): TaskRecord {
287
+ create(input: { contextId: string; skill: string; parts: readonly Part[]; remotePeerId: string | null; serverId?: string }): TaskRecord {
248
288
  const record: TaskRecord = {
249
289
  taskId: `a2a-${crypto.randomUUID()}`,
250
290
  contextId: input.contextId,
@@ -258,6 +298,7 @@ export class DomainTaskStore implements TaskStore {
258
298
  artifacts: [],
259
299
  executor: 'session',
260
300
  summary: null,
301
+ ...(input.serverId !== undefined ? { serverId: input.serverId } : {}),
261
302
  }
262
303
  this.records.set(record.taskId, record)
263
304
  // Persist eagerly; a failed write must fail the task, not silently lose it.
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Inbound server manager: owns every inbound A2A server instance. Each
3
+ * instance binds one agent preset (its session pool is composed from that
4
+ * preset), advertises creator-declared skills, and serves its own endpoint and
5
+ * AgentCard route. Instances are persisted in the `inbound_servers` domain
6
+ * table and assembled on boot; the manager owns route registration and the
7
+ * full lifecycle (add/enable/disable/update/remove) driven from the GUI and
8
+ * facade, and disposes every instance's environment on teardown.
9
+ *
10
+ * Skill declaration: the stored `skills` list is exactly what the creator
11
+ * declared. When a creator leaves it empty at creation time, the default skill
12
+ * derives from the bound preset's display name (else the built-in `chat`
13
+ * skill), per the v1.0 design — the v0.2 tool white-list derivation is gone.
14
+ * @module dsh-a2a/servers/inbound-manager
15
+ */
16
+
17
+ import type { Context } from '@deepseek-ai/cordis'
18
+ import { buildCard } from '../server/card.ts'
19
+ import { A2AServer } from '../server/a2a-server.ts'
20
+ import { A2aRoutes } from '../server/routes.ts'
21
+ import { ContextSessionPool, type AgentPresetsLike, type AgentRegistryLike } from '../server/exec/agent-runtime.ts'
22
+ import { createSessionExecutor } from '../server/exec/session.ts'
23
+ import { createSubagentExecutor, type SubagentsLike } from '../server/exec/subagent.ts'
24
+ import { ExecutorSet } from '../server/executor.ts'
25
+ import { LiveInboundRegistry } from '../server/inbound-registry.ts'
26
+ import type { TaskStore, InboundServerRecord } from '../server/store.ts'
27
+ import type { AgentSkill } from '../protocol.ts'
28
+ import type { GateInput, GateResult } from '../server/a2a-server.ts'
29
+ import type { InboundTaskDecision } from '../events.ts'
30
+
31
+ /** Web-server slice the manager registers instance routes on. */
32
+ export interface WebServerLike {
33
+ register(route: {
34
+ readonly kind: 'exact' | 'prefix'
35
+ readonly path: string
36
+ handler(...args: unknown[]): unknown
37
+ }): () => void
38
+ }
39
+
40
+ /** Host service slices one manager needs. */
41
+ export interface InboundManagerHost {
42
+ readonly ctx: Context
43
+ readonly webServer: WebServerLike
44
+ readonly tasks: TaskStore
45
+ readonly logger: { info(message: string): void; warn(message: string): void; error(message: string): void }
46
+ readonly agents?: AgentRegistryLike
47
+ readonly agentPresets?: AgentPresetsLike
48
+ readonly subagents?: SubagentsLike
49
+ readonly sessionCwd: string
50
+ readonly defaultBaseUrl: string
51
+ readonly subagentProvider: string
52
+ /** Resolve the deployment's default model options (may be undefined). */
53
+ readonly resolveDefaultModel?: () => { readonly provider?: string; readonly model?: string } | undefined
54
+ /** Allocate a fresh stable instance id. */
55
+ readonly newId: () => string
56
+ }
57
+
58
+ /** One live inbound instance's runtime handle. */
59
+ export interface LiveInboundServer {
60
+ readonly id: string
61
+ readonly record: InboundServerRecord
62
+ readonly card: ReturnType<typeof buildCard>
63
+ readonly server: A2AServer
64
+ readonly routes: A2aRoutes
65
+ readonly inbound: LiveInboundRegistry
66
+ readonly executors: ExecutorSet
67
+ readonly endpointPath: string
68
+ readonly cardPath: string
69
+ dispose(): void
70
+ }
71
+
72
+ /** Creator input for a new (or updated) inbound instance. */
73
+ export interface InboundCreateInput {
74
+ readonly name: string
75
+ readonly description: string
76
+ readonly version: string
77
+ readonly endpointPath?: string
78
+ readonly preset?: string
79
+ readonly authTokenEnv?: string
80
+ /** Declared skills; empty defaults to the preset name (or `chat`). */
81
+ readonly skills?: readonly AgentSkill[]
82
+ readonly enabled?: boolean
83
+ }
84
+
85
+ export interface OpResult {
86
+ readonly ok: boolean
87
+ readonly message: string
88
+ }
89
+
90
+ function refuseExecutor(reason: string) {
91
+ return {
92
+ name: 'refuse',
93
+ async execute(): Promise<{ parts: { text: string }[] }> {
94
+ throw new Error(`a2a: ${reason}; refusing inbound task`)
95
+ },
96
+ }
97
+ }
98
+
99
+ /** Default skkill when a creator declares none: the preset display name. */
100
+ export function defaultSkillFor(
101
+ skills: readonly AgentSkill[] | undefined,
102
+ presetName: string | undefined,
103
+ ): readonly AgentSkill[] {
104
+ if (skills !== undefined && skills.length > 0) return skills
105
+ if (presetName !== undefined && presetName.length > 0) {
106
+ return [{ id: 'chat', name: presetName, description: `Compose inbound sessions from the "${presetName}" agent preset.`, tags: ['preset'] }]
107
+ }
108
+ return [{ id: 'chat', name: 'chat', description: 'Conversational assistance over a DSH agent session.', tags: ['chat'] }]
109
+ }
110
+
111
+ /** Assemble one live instance from a persisted record. */
112
+ function assemble(record: InboundServerRecord, host: InboundManagerHost): LiveInboundServer {
113
+ const skills: readonly AgentSkill[] = record.skills
114
+ const endpointPath = record.endpointPath
115
+ const cardPath = `${endpointPath.replace(/\/$/, '')}/agent-card.json`
116
+ const card = buildCard({
117
+ baseUrl: host.defaultBaseUrl,
118
+ endpointPath,
119
+ name: record.name,
120
+ description: record.description,
121
+ version: record.version,
122
+ skills,
123
+ ...(record.authTokenEnv !== undefined && process.env[record.authTokenEnv] !== undefined
124
+ ? { authToken: process.env[record.authTokenEnv]! }
125
+ : {}),
126
+ })
127
+
128
+ // One preset-bound session pool per instance.
129
+ const sessionPool = host.agents
130
+ ? new ContextSessionPool(host.agents, {
131
+ cwd: host.sessionCwd,
132
+ ...(host.agentPresets !== undefined ? { agentPresets: host.agentPresets } : {}),
133
+ ...(record.preset !== undefined ? { presetId: () => record.preset } : {}),
134
+ // `?.()` yields `{ provider?, model? } | undefined`, matching the
135
+ // AgentRuntimeOptions.resolveAgentOptions signature under
136
+ // exactOptionalPropertyTypes.
137
+ resolveAgentOptions: () => host.resolveDefaultModel?.(),
138
+ })
139
+ : undefined
140
+ const sessionExecutor = sessionPool ? createSessionExecutor(sessionPool) : refuseExecutor('no agent loop mounted')
141
+ const subagentExecutor = sessionPool !== undefined && host.subagents !== undefined
142
+ ? createSubagentExecutor({ pool: sessionPool, subagents: host.subagents, provider: host.subagentProvider })
143
+ : undefined
144
+ const executors = new ExecutorSet({ chat: 'session' }, sessionExecutor, subagentExecutor)
145
+
146
+ const inbound = new LiveInboundRegistry()
147
+ const skillIds = new Set(skills.map((s) => s.id))
148
+ const gate = async (input: GateInput): Promise<GateResult> => {
149
+ if (!skillIds.has(input.skill)) {
150
+ return { ok: false, reason: `unknown skill "${input.skill}"` }
151
+ }
152
+ const decision: InboundTaskDecision = {
153
+ contextId: input.contextId,
154
+ skill: input.skill,
155
+ parts: input.parts,
156
+ remotePeerId: input.remotePeerId,
157
+ }
158
+ const decided = await host.ctx.waterfall('a2a/inbound-task', decision, async (d) => d)
159
+ host.logger.info(`[a2a:${record.id}] inbound skill=${decided.skill} rejected=${decided.rejected?.reason ?? 'no'}`)
160
+ if (decided.rejected !== undefined) return { ok: false, reason: decided.rejected.reason }
161
+ return { ok: true }
162
+ }
163
+
164
+ const server = new A2AServer({
165
+ card,
166
+ store: host.tasks,
167
+ executors,
168
+ ...(card.securitySchemes !== undefined ? { authToken: 'configured' } : {}),
169
+ cardPath,
170
+ gate,
171
+ onInbound: (facts) => inbound.note({
172
+ method: facts.method,
173
+ ...(facts.source !== undefined ? { source: facts.source } : {}),
174
+ taskIds: facts.taskIds,
175
+ streaming: facts.streaming,
176
+ }),
177
+ onTaskSettled: (taskId) => {
178
+ host.logger.info(`[a2a:${record.id}] task settled ${taskId}`)
179
+ inbound.settle(taskId)
180
+ },
181
+ })
182
+ const routes = new A2aRoutes(host.webServer, server, cardPath)
183
+
184
+ return {
185
+ id: record.id,
186
+ record,
187
+ card,
188
+ server,
189
+ routes,
190
+ inbound,
191
+ executors,
192
+ endpointPath,
193
+ cardPath,
194
+ dispose: () => {
195
+ routes.dispose()
196
+ void executors.disposeAll().catch(() => {})
197
+ },
198
+ }
199
+ }
200
+
201
+ /** Persisted-instance store over the inbound_servers table. */
202
+ export class DomainInboundStore {
203
+ private static KEY(id: string): string {
204
+ return `in:${id}`
205
+ }
206
+
207
+ constructor(private readonly table: {
208
+ get(key: string): string | undefined
209
+ put(key: string, value: string): Promise<void>
210
+ entries?(): IterableIterator<[string, string]>
211
+ keys?(): IterableIterator<string>
212
+ }) {}
213
+
214
+ list(): InboundServerRecord[] {
215
+ const out: InboundServerRecord[] = []
216
+ const entries = this.table.entries
217
+ if (entries === undefined) return out
218
+ // Call through the table (method `this` stays bound); the real KvTable's
219
+ // `entries()` reads instance state.
220
+ for (const [key, raw] of entries.call(this.table)) {
221
+ if (!key.startsWith('in:')) continue
222
+ const parsed = safeParse(raw)
223
+ if (parsed !== undefined) out.push(parsed)
224
+ }
225
+ return out
226
+ }
227
+
228
+ get(id: string): InboundServerRecord | undefined {
229
+ const raw = this.table.get(DomainInboundStore.KEY(id))
230
+ return raw === undefined ? undefined : safeParse(raw)
231
+ }
232
+
233
+ async save(record: InboundServerRecord): Promise<void> {
234
+ await this.table.put(DomainInboundStore.KEY(record.id), JSON.stringify(record))
235
+ }
236
+
237
+ async remove(id: string): Promise<void> {
238
+ await this.table.put(DomainInboundStore.KEY(id), '')
239
+ }
240
+ }
241
+
242
+ function safeParse(raw: string): InboundServerRecord | undefined {
243
+ try {
244
+ const parsed = JSON.parse(raw) as InboundServerRecord
245
+ if (typeof parsed.id !== 'string' || typeof parsed.name !== 'string' || !Array.isArray(parsed.skills)) return undefined
246
+ return parsed
247
+ } catch {
248
+ return undefined
249
+ }
250
+ }
251
+
252
+ /** Manages the live set of inbound server instances. */
253
+ export class InboundServerManager {
254
+ private readonly live = new Map<string, LiveInboundServer>()
255
+
256
+ constructor(
257
+ private readonly host: InboundManagerHost,
258
+ private readonly store: DomainInboundStore,
259
+ ) {}
260
+
261
+ /** Assemble every persisted enabled instance (boot). */
262
+ boot(): void {
263
+ for (const record of this.store.list()) {
264
+ try {
265
+ const live = assemble(record, this.host)
266
+ this.live.set(record.id, live)
267
+ if (record.enabled) live.routes.enable()
268
+ } catch (err) {
269
+ this.host.logger.error(`[a2a:${record.id}] failed to assemble: ${String((err as Error).message)}`)
270
+ }
271
+ }
272
+ }
273
+
274
+ list(): readonly LiveInboundServer[] {
275
+ return [...this.live.values()]
276
+ }
277
+
278
+ get(id: string): LiveInboundServer | undefined {
279
+ return this.live.get(id)
280
+ }
281
+
282
+ /** Resolve the display name of a preset id (undefined when unnamed/absent). */
283
+ private async presetDisplayName(preset: string | undefined): Promise<string | undefined> {
284
+ if (preset === undefined || this.host.agentPresets === undefined) return undefined
285
+ try {
286
+ const resolved = await this.host.agentPresets.resolve(preset)
287
+ return resolved.name ?? resolved.id
288
+ } catch {
289
+ return undefined
290
+ }
291
+ }
292
+
293
+ /** Create, persist, and assemble a new inbound instance. */
294
+ async add(input: InboundCreateInput): Promise<OpResult & { readonly id?: string }> {
295
+ const id = this.newId(input.name)
296
+ if (this.live.has(id)) return { ok: false, message: `inbound server ${id} already exists` }
297
+ const presetName = await this.presetDisplayName(input.preset)
298
+ const record: InboundServerRecord = {
299
+ id,
300
+ name: input.name,
301
+ description: input.description,
302
+ version: input.version,
303
+ endpointPath: input.endpointPath ?? `/a2a/${id}`,
304
+ ...(input.preset !== undefined ? { preset: input.preset } : {}),
305
+ ...(input.authTokenEnv !== undefined ? { authTokenEnv: input.authTokenEnv } : {}),
306
+ skills: [...defaultSkillFor(input.skills, presetName)],
307
+ enabled: input.enabled ?? true,
308
+ }
309
+ try {
310
+ const live = assemble(record, this.host)
311
+ await this.store.save(record)
312
+ this.live.set(id, live)
313
+ if (record.enabled) live.routes.enable()
314
+ return { ok: true, message: `inbound server "${record.name}" created`, id }
315
+ } catch (err) {
316
+ return { ok: false, message: `failed to create inbound server: ${String((err as Error).message)}` }
317
+ }
318
+ }
319
+
320
+ /** Persist, rebuild, and apply a patch onto a live instance. */
321
+ async update(id: string, patch: Partial<Pick<InboundServerRecord, 'name' | 'description' | 'version' | 'endpointPath' | 'preset' | 'authTokenEnv' | 'skills'>>): Promise<OpResult> {
322
+ const live = this.live.get(id)
323
+ const stored = this.store.get(id)
324
+ if (live === undefined || stored === undefined) return { ok: false, message: `inbound server ${id} not found` }
325
+ const next: InboundServerRecord = {
326
+ ...stored,
327
+ name: patch.name ?? stored.name,
328
+ description: patch.description ?? stored.description,
329
+ version: patch.version ?? stored.version,
330
+ endpointPath: patch.endpointPath ?? stored.endpointPath,
331
+ ...(patch.preset !== undefined ? { preset: patch.preset } : {}),
332
+ ...(patch.authTokenEnv !== undefined ? { authTokenEnv: patch.authTokenEnv } : {}),
333
+ skills: patch.skills ?? stored.skills,
334
+ enabled: stored.enabled,
335
+ }
336
+ try {
337
+ await this.store.save(next)
338
+ const replaced = assemble(next, this.host)
339
+ // Preserve the route registration state while swapping runtime halves.
340
+ const wasEnabled = live.routes.active
341
+ live.dispose()
342
+ this.live.set(id, replaced)
343
+ if (wasEnabled || next.enabled) replaced.routes.enable()
344
+ return { ok: true, message: `inbound server ${id} updated` }
345
+ } catch (err) {
346
+ return { ok: false, message: `failed to update inbound server: ${String((err as Error).message)}` }
347
+ }
348
+ }
349
+
350
+ /** Enable/disable an instance's routes. */
351
+ setEnabled(id: string, enabled: boolean): OpResult {
352
+ const live = this.live.get(id)
353
+ if (live === undefined) return { ok: false, message: `inbound server ${id} not found` }
354
+ if (enabled) live.routes.enable()
355
+ else live.routes.disable()
356
+ const stored = this.store.get(id)
357
+ if (stored !== undefined) {
358
+ void this.store.save({ ...stored, enabled }).catch(() => {})
359
+ }
360
+ return { ok: true, message: `inbound server ${id} ${enabled ? 'enabled' : 'disabled'}` }
361
+ }
362
+
363
+ /** Remove, dispose, and unpersist an instance. */
364
+ async remove(id: string): Promise<OpResult> {
365
+ const live = this.live.get(id)
366
+ if (live === undefined) return { ok: false, message: `inbound server ${id} not found` }
367
+ live.dispose()
368
+ this.live.delete(id)
369
+ await this.store.remove(id)
370
+ return { ok: true, message: `inbound server ${id} removed` }
371
+ }
372
+
373
+ /** Dispose every instance. */
374
+ disposeAll(): void {
375
+ for (const live of this.live.values()) live.dispose()
376
+ this.live.clear()
377
+ }
378
+
379
+ private newId(seed: string): string {
380
+ const slug = seed.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'server'
381
+ return `${slug}-${this.host.newId()}`
382
+ }
383
+ }
384
+
385
+ export type { AgentSkill }