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