dsh-agora-plugin 0.6.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 (79) hide show
  1. package/README.md +520 -0
  2. package/client/index.js +492 -0
  3. package/cordis.patch.yml +14 -0
  4. package/dsh.plugin.json +7 -0
  5. package/lib/agora-client.d.ts +73 -0
  6. package/lib/agora-client.d.ts.map +1 -0
  7. package/lib/agora-client.js +207 -0
  8. package/lib/agora-client.js.map +1 -0
  9. package/lib/client.js +492 -0
  10. package/lib/command-adapter.d.ts +44 -0
  11. package/lib/command-adapter.d.ts.map +1 -0
  12. package/lib/command-adapter.js +70 -0
  13. package/lib/command-adapter.js.map +1 -0
  14. package/lib/command.d.ts +53 -0
  15. package/lib/command.d.ts.map +1 -0
  16. package/lib/command.js +375 -0
  17. package/lib/command.js.map +1 -0
  18. package/lib/context-types.d.ts +51 -0
  19. package/lib/context-types.d.ts.map +1 -0
  20. package/lib/context-types.js +2 -0
  21. package/lib/context-types.js.map +1 -0
  22. package/lib/contracts.d.ts +402 -0
  23. package/lib/contracts.d.ts.map +1 -0
  24. package/lib/contracts.js +2 -0
  25. package/lib/contracts.js.map +1 -0
  26. package/lib/extension-sdk.d.ts +74 -0
  27. package/lib/extension-sdk.d.ts.map +1 -0
  28. package/lib/extension-sdk.js +160 -0
  29. package/lib/extension-sdk.js.map +1 -0
  30. package/lib/harness-runtime.d.ts +29 -0
  31. package/lib/harness-runtime.d.ts.map +1 -0
  32. package/lib/harness-runtime.js +422 -0
  33. package/lib/harness-runtime.js.map +1 -0
  34. package/lib/http-api.d.ts +10 -0
  35. package/lib/http-api.d.ts.map +1 -0
  36. package/lib/http-api.js +282 -0
  37. package/lib/http-api.js.map +1 -0
  38. package/lib/im-bridge-v1.d.ts +37 -0
  39. package/lib/im-bridge-v1.d.ts.map +1 -0
  40. package/lib/im-bridge-v1.js +43 -0
  41. package/lib/im-bridge-v1.js.map +1 -0
  42. package/lib/im-gateway.d.ts +24 -0
  43. package/lib/im-gateway.d.ts.map +1 -0
  44. package/lib/im-gateway.js +53 -0
  45. package/lib/im-gateway.js.map +1 -0
  46. package/lib/index.d.ts +46 -0
  47. package/lib/index.d.ts.map +1 -0
  48. package/lib/index.js +210 -0
  49. package/lib/index.js.map +1 -0
  50. package/lib/node-worker.d.ts +52 -0
  51. package/lib/node-worker.d.ts.map +1 -0
  52. package/lib/node-worker.js +372 -0
  53. package/lib/node-worker.js.map +1 -0
  54. package/lib/service.d.ts +51 -0
  55. package/lib/service.d.ts.map +1 -0
  56. package/lib/service.js +196 -0
  57. package/lib/service.js.map +1 -0
  58. package/lib/tool.d.ts +72 -0
  59. package/lib/tool.d.ts.map +1 -0
  60. package/lib/tool.js +206 -0
  61. package/lib/tool.js.map +1 -0
  62. package/package.json +74 -0
  63. package/patches/dsh-im/@xmanrui__dsh-im@2.1.0.patch +920 -0
  64. package/patches/dsh-im/@xmanrui__dsh-im@2.3.0.patch +984 -0
  65. package/patches/dsh-im/README.md +82 -0
  66. package/src/agora-client.ts +333 -0
  67. package/src/command-adapter.ts +106 -0
  68. package/src/command.ts +424 -0
  69. package/src/context-types.ts +54 -0
  70. package/src/contracts.ts +413 -0
  71. package/src/extension-sdk.ts +225 -0
  72. package/src/harness-runtime.ts +518 -0
  73. package/src/http-api.ts +269 -0
  74. package/src/im-bridge-v1.ts +73 -0
  75. package/src/im-gateway.ts +82 -0
  76. package/src/index.ts +260 -0
  77. package/src/node-worker.ts +442 -0
  78. package/src/service.ts +262 -0
  79. package/src/tool.ts +269 -0
@@ -0,0 +1,269 @@
1
+ import { timingSafeEqual } from 'node:crypto'
2
+ import type { IncomingMessage, ServerResponse } from 'node:http'
3
+ import type { AgoraRequestContext, DshAgoraServiceApi } from './contracts.js'
4
+ import type { DshWebServer } from './context-types.js'
5
+
6
+ export const API_PREFIX = '/dsh-agora/api'
7
+ const MAX_BODY_BYTES = 1_048_576
8
+
9
+ export interface HttpApiOptions {
10
+ readonly accessToken?: string | undefined
11
+ }
12
+
13
+ export function registerHttpApi(webServer: DshWebServer, service: DshAgoraServiceApi, options: HttpApiOptions): () => void {
14
+ return webServer.register({
15
+ kind: 'prefix',
16
+ path: API_PREFIX,
17
+ handler: (request, response) => handleHttpRequest(request, response, service, options),
18
+ })
19
+ }
20
+
21
+ export async function handleHttpRequest(
22
+ request: IncomingMessage,
23
+ response: ServerResponse,
24
+ service: DshAgoraServiceApi,
25
+ options: HttpApiOptions,
26
+ ): Promise<void> {
27
+ try {
28
+ if (!authorized(request, options.accessToken)) {
29
+ writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
30
+ return
31
+ }
32
+ if (request.method !== 'POST') {
33
+ writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'POST required' } })
34
+ return
35
+ }
36
+ const pathname = new URL(request.url ?? '/', 'http://dsh.internal').pathname
37
+ const method = pathname.startsWith(`${API_PREFIX}/`) ? pathname.slice(API_PREFIX.length + 1) : ''
38
+ if (method === '' || method.includes('/')) throw new HttpApiError(404, 'not-found', 'unknown dsh-agora API method')
39
+ const payload = asRecord(await readJsonBody(request))
40
+ const signal = AbortSignal.timeout(300_000)
41
+ let value: unknown
42
+ switch (method) {
43
+ case 'snapshot': value = service.snapshot(); break
44
+ case 'health': value = await service.health(signal); break
45
+ case 'nodes': value = await service.listRuntimeNodes(signal); break
46
+ case 'agents': value = await service.listRuntimeTargets(signal); break
47
+ case 'tasks': value = await service.listTasks(optionalString(payload.state), optionalString(payload.projectId), signal); break
48
+ case 'task': value = await service.getTask(requiredString(payload.taskId, 'taskId'), signal); break
49
+ case 'status': value = await service.taskStatus(requiredString(payload.taskId, 'taskId'), signal); break
50
+ case 'dispatch-status': value = await service.getRuntimeDispatch(requiredString(payload.dispatchId, 'dispatchId'), signal); break
51
+ case 'dispatch-progress': value = await service.listRuntimeDispatchProgress(requiredString(payload.dispatchId, 'dispatchId'), signal); break
52
+ case 'coordination-runs': value = await service.listCoordinationRuns(
53
+ optionalCoordinationStatus(payload.status),
54
+ signal,
55
+ ); break
56
+ case 'coordination-run': value = await service.getCoordinationRun(requiredString(payload.runId, 'runId'), signal); break
57
+ case 'scorecards': value = await service.listAgentScorecards(optionalString(payload.taskType), signal); break
58
+ case 'coordination-create': {
59
+ const mode = requiredCoordinationMode(payload.mode)
60
+ const taskId = optionalString(payload.taskId)
61
+ const taskType = optionalString(payload.taskType)
62
+ const verifierTargetRef = optionalString(payload.verifierTargetRef)
63
+ value = await service.createCoordinationRun({
64
+ prompt: requiredString(payload.prompt, 'prompt'),
65
+ mode,
66
+ candidates: requiredStringArray(payload.runtimeTargetRefs, 'runtimeTargetRefs').map(runtime_target_ref => ({ runtime_target_ref })),
67
+ idempotency_key: requiredString(payload.idempotencyKey, 'idempotencyKey'),
68
+ ...(taskId === undefined ? {} : { task_id: taskId }),
69
+ ...(taskType === undefined ? {} : { task_type: taskType }),
70
+ ...(verifierTargetRef === undefined ? {} : { verifier_target_ref: verifierTargetRef }),
71
+ ...(payload.budget === undefined ? {} : { budget: coordinationBudget(payload.budget) }),
72
+ }, signal)
73
+ break
74
+ }
75
+ case 'dispatch': {
76
+ const taskId = optionalString(payload.taskId)
77
+ const participantBindingId = optionalString(payload.participantBindingId)
78
+ const sessionId = optionalString(payload.sessionId)
79
+ const workspaceAlias = optionalString(payload.workspaceAlias)
80
+ const agentPreset = optionalString(payload.agentPreset)
81
+ const sourceSessionId = optionalString(payload.sourceSessionId)
82
+ const presentationMode = optionalString(payload.presentationMode)
83
+ const waitTimeoutMs = optionalInteger(payload.waitTimeoutMs, 'waitTimeoutMs', 0, 600_000)
84
+ if (presentationMode !== undefined && !['source_bot', 'destination_bot', 'silent'].includes(presentationMode)) {
85
+ throw new HttpApiError(400, 'bad-request', 'presentationMode is invalid')
86
+ }
87
+ value = await service.dispatchAgent({
88
+ runtime_target_ref: requiredString(payload.runtimeTargetRef, 'runtimeTargetRef'),
89
+ prompt: requiredString(payload.prompt, 'prompt'),
90
+ idempotency_key: requiredString(payload.idempotencyKey, 'idempotencyKey'),
91
+ ...(taskId === undefined ? {} : { task_id: taskId }),
92
+ ...(participantBindingId === undefined ? {} : { participant_binding_id: participantBindingId }),
93
+ ...(sessionId === undefined ? {} : { session_id: sessionId }),
94
+ ...(workspaceAlias === undefined ? {} : { workspace_alias: workspaceAlias }),
95
+ ...(agentPreset === undefined ? {} : { agent_preset: agentPreset }),
96
+ ...(sourceSessionId === undefined ? {} : { source_session_id: sourceSessionId }),
97
+ ...(waitTimeoutMs === undefined ? {} : { wait_timeout_ms: waitTimeoutMs }),
98
+ ...(presentationMode === undefined ? {} : {
99
+ presentation_mode: presentationMode as 'source_bot' | 'destination_bot' | 'silent',
100
+ }),
101
+ }, signal)
102
+ break
103
+ }
104
+ case 'attach-session': value = await service.bindRuntimeSession(
105
+ requiredString(payload.taskId, 'taskId'),
106
+ requiredString(payload.participantBindingId, 'participantBindingId'),
107
+ requiredString(payload.sessionId, 'sessionId'),
108
+ optionalString(payload.runtimeTargetRef),
109
+ signal,
110
+ ); break
111
+ case 'create': {
112
+ const type = optionalString(payload.type)
113
+ const creator = optionalString(payload.creator)
114
+ const description = optionalString(payload.description)
115
+ const projectId = optionalString(payload.projectId)
116
+ value = await service.createTask({
117
+ title: requiredString(payload.title, 'title'),
118
+ ...(type === undefined ? {} : { type }),
119
+ ...(creator === undefined ? {} : { creator }),
120
+ ...(description === undefined ? {} : { description }),
121
+ ...(projectId === undefined ? {} : { projectId }),
122
+ }, signal)
123
+ break
124
+ }
125
+ case 'command': value = await service.executeCommand(
126
+ optionalString(payload.input) ?? '',
127
+ requestContext(payload.context),
128
+ signal,
129
+ ); break
130
+ case 'command-event': value = await service.executeCommandEvent(payload as unknown as import('./command-adapter.js').DshAgoraCommandEventV1, signal); break
131
+ default: throw new HttpApiError(404, 'not-found', `unknown dsh-agora API method "${method}"`)
132
+ }
133
+ writeJson(response, 200, { ok: true, value })
134
+ } catch (error) {
135
+ if (error instanceof HttpApiError) {
136
+ writeJson(response, error.status, { ok: false, error: { code: error.code, message: error.message } })
137
+ return
138
+ }
139
+ writeJson(response, 502, { ok: false, error: { code: 'upstream-error', message: error instanceof Error ? error.message : String(error) } })
140
+ }
141
+ }
142
+
143
+ class HttpApiError extends Error {
144
+ constructor(readonly status: number, readonly code: string, message: string) {
145
+ super(message)
146
+ }
147
+ }
148
+
149
+ function authorized(request: IncomingMessage, configuredToken: string | undefined): boolean {
150
+ if (isLoopback(request.socket.remoteAddress)) return true
151
+ const token = configuredToken?.trim()
152
+ if (!token) return false
153
+ const authorization = request.headers.authorization
154
+ if (!authorization?.startsWith('Bearer ')) return false
155
+ const supplied = authorization.slice(7)
156
+ const expectedBytes = Buffer.from(token)
157
+ const suppliedBytes = Buffer.from(supplied)
158
+ return expectedBytes.length === suppliedBytes.length && timingSafeEqual(expectedBytes, suppliedBytes)
159
+ }
160
+
161
+ function isLoopback(address: string | undefined): boolean {
162
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
163
+ }
164
+
165
+ async function readJsonBody(request: IncomingMessage): Promise<unknown> {
166
+ const chunks: Buffer[] = []
167
+ let length = 0
168
+ for await (const chunk of request) {
169
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
170
+ length += bytes.length
171
+ if (length > MAX_BODY_BYTES) throw new HttpApiError(413, 'body-too-large', 'request body exceeds 1 MiB')
172
+ chunks.push(bytes)
173
+ }
174
+ const text = Buffer.concat(chunks).toString('utf8').trim()
175
+ if (text === '') return {}
176
+ try {
177
+ return JSON.parse(text) as unknown
178
+ } catch {
179
+ throw new HttpApiError(400, 'bad-json', 'request body must be valid JSON')
180
+ }
181
+ }
182
+
183
+ function asRecord(value: unknown): Record<string, unknown> {
184
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new HttpApiError(400, 'bad-request', 'request body must be an object')
185
+ return value as Record<string, unknown>
186
+ }
187
+
188
+ function optionalString(value: unknown): string | undefined {
189
+ if (value === undefined || value === null) return undefined
190
+ if (typeof value !== 'string') throw new HttpApiError(400, 'bad-request', 'expected a string field')
191
+ const normalized = value.trim()
192
+ return normalized === '' ? undefined : normalized
193
+ }
194
+
195
+ function requiredString(value: unknown, field: string): string {
196
+ const normalized = optionalString(value)
197
+ if (normalized === undefined) throw new HttpApiError(400, 'bad-request', `${field} is required`)
198
+ return normalized
199
+ }
200
+
201
+ function optionalInteger(value: unknown, field: string, minimum: number, maximum: number): number | undefined {
202
+ if (value === undefined || value === null) return undefined
203
+ if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) {
204
+ throw new HttpApiError(400, 'bad-request', `${field} must be an integer between ${minimum} and ${maximum}`)
205
+ }
206
+ return value as number
207
+ }
208
+
209
+ function requiredStringArray(value: unknown, field: string): string[] {
210
+ if (!Array.isArray(value) || value.length === 0) throw new HttpApiError(400, 'bad-request', `${field} must be a non-empty string array`)
211
+ return value.map(item => requiredString(item, field))
212
+ }
213
+
214
+ function requiredCoordinationMode(value: unknown): 'single' | 'fanout' | 'review' | 'debate' | 'council' {
215
+ const mode = requiredString(value, 'mode')
216
+ if (mode === 'single' || mode === 'fanout' || mode === 'review' || mode === 'debate' || mode === 'council') return mode
217
+ throw new HttpApiError(400, 'bad-request', 'mode must be single, fanout, review, debate, or council')
218
+ }
219
+
220
+ function optionalCoordinationStatus(value: unknown): import('./contracts.js').CoordinationRunStatus | undefined {
221
+ const status = optionalString(value)
222
+ if (status === undefined) return undefined
223
+ if (status === 'running' || status === 'verifying' || status === 'completed' || status === 'partial'
224
+ || status === 'failed' || status === 'cancelled' || status === 'budget_exhausted') return status
225
+ throw new HttpApiError(400, 'bad-request', 'coordination status is invalid')
226
+ }
227
+
228
+ function coordinationBudget(value: unknown): Partial<import('./contracts.js').CoordinationBudget> {
229
+ const record = asRecord(value)
230
+ const fields = [
231
+ ['max_agents', 1, 32],
232
+ ['max_dispatches', 1, 64],
233
+ ['max_wall_clock_seconds', 15, 86_400],
234
+ ['max_tokens', 1, Number.MAX_SAFE_INTEGER],
235
+ ['max_tool_calls', 1, Number.MAX_SAFE_INTEGER],
236
+ ] as const
237
+ const budget: Record<string, number> = {}
238
+ for (const [field, minimum, maximum] of fields) {
239
+ const item = optionalInteger(record[field], field, minimum, maximum)
240
+ if (item !== undefined) budget[field] = item
241
+ }
242
+ if (record.max_cost_usd !== undefined) {
243
+ if (typeof record.max_cost_usd !== 'number' || record.max_cost_usd <= 0) throw new HttpApiError(400, 'bad-request', 'max_cost_usd must be positive')
244
+ budget.max_cost_usd = record.max_cost_usd
245
+ }
246
+ return budget
247
+ }
248
+
249
+ function requestContext(value: unknown): AgoraRequestContext {
250
+ if (value === undefined) return {}
251
+ const record = asRecord(value)
252
+ const actorId = optionalString(record.actorId)
253
+ const provider = optionalString(record.provider)
254
+ const conversationRef = optionalString(record.conversationRef)
255
+ const threadRef = optionalString(record.threadRef)
256
+ return {
257
+ ...(actorId === undefined ? {} : { actorId }),
258
+ ...(provider === undefined ? {} : { provider }),
259
+ ...(conversationRef === undefined ? {} : { conversationRef }),
260
+ ...(threadRef === undefined ? {} : { threadRef }),
261
+ }
262
+ }
263
+
264
+ function writeJson(response: ServerResponse, status: number, body: unknown): void {
265
+ response.statusCode = status
266
+ response.setHeader('Content-Type', 'application/json; charset=utf-8')
267
+ response.setHeader('Cache-Control', 'no-store')
268
+ response.end(JSON.stringify(body))
269
+ }
@@ -0,0 +1,73 @@
1
+ import type { DshAgoraImStatus, RuntimeNodeBot } from './contracts.js'
2
+
3
+ export const DSH_IM_BRIDGE_PROTOCOL = 'dsh-im.bridge/v1' as const
4
+
5
+ export interface DshImSessionRouteV1 {
6
+ readonly provider: string
7
+ readonly bot_ref: string
8
+ readonly session_id: string
9
+ readonly actor_ref: string
10
+ readonly conversation_ref: string
11
+ readonly thread_ref: string | null
12
+ readonly reply_to_message_ref: string | null
13
+ readonly updated_at: string
14
+ }
15
+
16
+ export interface DshImSendRequestV1 {
17
+ readonly provider: string
18
+ readonly bot_ref?: string | null
19
+ readonly conversation_ref: string
20
+ readonly thread_ref?: string | null
21
+ readonly reply_to_message_ref?: string | null
22
+ readonly text: string
23
+ readonly idempotency_key: string
24
+ }
25
+
26
+ export interface DshImBridgeV1 {
27
+ readonly protocol: typeof DSH_IM_BRIDGE_PROTOCOL
28
+ listBots(): readonly RuntimeNodeBot[] | Promise<readonly RuntimeNodeBot[]>
29
+ resolveSession(sessionId: string): DshImSessionRouteV1 | null | Promise<DshImSessionRouteV1 | null>
30
+ send(request: DshImSendRequestV1): Promise<{ readonly provider_message_refs: readonly string[] }>
31
+ }
32
+
33
+ export interface ImBridgeDiscovery {
34
+ readonly status: DshAgoraImStatus
35
+ readonly bridge: DshImBridgeV1 | null
36
+ }
37
+
38
+ export function discoverImBridge(ctx: { get?(name: string): unknown }, serviceNames: readonly string[]): ImBridgeDiscovery {
39
+ for (const serviceName of serviceNames) {
40
+ const candidate = safeGet(ctx, serviceName)
41
+ if (candidate === undefined) continue
42
+ if (!isBridge(candidate)) {
43
+ return {
44
+ status: {
45
+ state: 'incompatible', service: serviceName,
46
+ reason: `service does not implement ${DSH_IM_BRIDGE_PROTOCOL}`,
47
+ },
48
+ bridge: null,
49
+ }
50
+ }
51
+ return {
52
+ status: { state: 'connected', service: serviceName, protocol: candidate.protocol },
53
+ bridge: candidate,
54
+ }
55
+ }
56
+ return {
57
+ status: { state: 'unavailable', reason: `no ${DSH_IM_BRIDGE_PROTOCOL} provider is installed` },
58
+ bridge: null,
59
+ }
60
+ }
61
+
62
+ function safeGet(ctx: { get?(name: string): unknown }, name: string): unknown {
63
+ try { return ctx.get?.(name) } catch { return undefined }
64
+ }
65
+
66
+ function isBridge(value: unknown): value is DshImBridgeV1 {
67
+ if (typeof value !== 'object' || value === null) return false
68
+ const bridge = value as Partial<DshImBridgeV1>
69
+ return bridge.protocol === DSH_IM_BRIDGE_PROTOCOL
70
+ && typeof bridge.listBots === 'function'
71
+ && typeof bridge.resolveSession === 'function'
72
+ && typeof bridge.send === 'function'
73
+ }
@@ -0,0 +1,82 @@
1
+ import type { AgoraCommandResult, AgoraRequestContext, DshAgoraImStatus } from './contracts.js'
2
+
3
+ export const DSH_IM_COMMAND_GATEWAY_PROTOCOL = 'dsh-im.command-gateway/v1'
4
+
5
+ export interface DshImCommandInvocationV1 extends AgoraRequestContext {
6
+ readonly rawInput: string
7
+ readonly signal?: AbortSignal
8
+ }
9
+
10
+ export interface DshImCommandDefinitionV1 {
11
+ readonly name: string
12
+ readonly description: string
13
+ execute(invocation: DshImCommandInvocationV1): Promise<AgoraCommandResult>
14
+ }
15
+
16
+ export interface DshImCommandGatewayV1 {
17
+ readonly protocol: typeof DSH_IM_COMMAND_GATEWAY_PROTOCOL
18
+ registerCommand(definition: DshImCommandDefinitionV1): () => void
19
+ }
20
+
21
+ export interface ImGatewayContext {
22
+ get?(name: string): unknown
23
+ }
24
+
25
+ export interface ImRegistration {
26
+ readonly status: DshAgoraImStatus
27
+ readonly dispose?: () => void
28
+ }
29
+
30
+ export function registerImCommand(
31
+ ctx: ImGatewayContext,
32
+ serviceNames: readonly string[],
33
+ definition: DshImCommandDefinitionV1,
34
+ ): ImRegistration {
35
+ for (const serviceName of serviceNames) {
36
+ const candidate = safeGet(ctx, serviceName)
37
+ if (candidate === undefined) continue
38
+ if (!isGateway(candidate)) {
39
+ return {
40
+ status: {
41
+ state: 'incompatible',
42
+ service: serviceName,
43
+ reason: `service does not implement ${DSH_IM_COMMAND_GATEWAY_PROTOCOL}`,
44
+ },
45
+ }
46
+ }
47
+ try {
48
+ return {
49
+ status: { state: 'connected', service: serviceName, protocol: candidate.protocol },
50
+ dispose: candidate.registerCommand(definition),
51
+ }
52
+ } catch (error) {
53
+ return {
54
+ status: {
55
+ state: 'incompatible',
56
+ service: serviceName,
57
+ reason: `gateway rejected command registration: ${error instanceof Error ? error.message : String(error)}`,
58
+ },
59
+ }
60
+ }
61
+ }
62
+ return {
63
+ status: {
64
+ state: 'unavailable',
65
+ reason: `no ${DSH_IM_COMMAND_GATEWAY_PROTOCOL} provider is installed`,
66
+ },
67
+ }
68
+ }
69
+
70
+ function safeGet(ctx: ImGatewayContext, serviceName: string): unknown {
71
+ try {
72
+ return ctx.get?.(serviceName)
73
+ } catch {
74
+ return undefined
75
+ }
76
+ }
77
+
78
+ function isGateway(value: unknown): value is DshImCommandGatewayV1 {
79
+ if (typeof value !== 'object' || value === null) return false
80
+ const candidate = value as Partial<DshImCommandGatewayV1>
81
+ return candidate.protocol === DSH_IM_COMMAND_GATEWAY_PROTOCOL && typeof candidate.registerCommand === 'function'
82
+ }
package/src/index.ts ADDED
@@ -0,0 +1,260 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { hostname } from 'node:os'
3
+ import { AgoraClient } from './agora-client.js'
4
+ import type { DshAgoraContext, DshWebServer } from './context-types.js'
5
+ import { registerHttpApi } from './http-api.js'
6
+ import { registerImCommand } from './im-gateway.js'
7
+ import { discoverImBridge } from './im-bridge-v1.js'
8
+ import { DshAgoraService } from './service.js'
9
+ import { createAgoraTool } from './tool.js'
10
+ import { DshAgoraExtensionRegistry, DSH_AGORA_EXTENSION_PROTOCOL } from './extension-sdk.js'
11
+ import { HarnessRuntimeAdapter, type ConfiguredDshAgent } from './harness-runtime.js'
12
+ import { RuntimeNodeWorker } from './node-worker.js'
13
+
14
+ export { AgoraApiError, AgoraClient } from './agora-client.js'
15
+ export { AgoraCommandParseError, executeAgoraCommand, parseAgoraCommand } from './command.js'
16
+ export * from './contracts.js'
17
+ export { API_PREFIX, handleHttpRequest, registerHttpApi } from './http-api.js'
18
+ export * from './im-gateway.js'
19
+ export * from './im-bridge-v1.js'
20
+ export * from './command-adapter.js'
21
+ export * from './extension-sdk.js'
22
+ export * from './harness-runtime.js'
23
+ export { RuntimeNodeWorker } from './node-worker.js'
24
+ export { DshAgoraService } from './service.js'
25
+ export * from './tool.js'
26
+
27
+ export const name = 'dsh-agora'
28
+ // webServer is required for both the local Harness RPC adapter and the runtime
29
+ // node worker. Declaring it here also makes Cordis delay apply() until the web
30
+ // host is initialized instead of silently starting in command-only mode.
31
+ export const inject = ['commands', 'tools', 'webServer']
32
+ const PLUGIN_VERSION = '0.6.0'
33
+
34
+ export interface Config {
35
+ readonly serverUrl?: string
36
+ readonly apiToken?: string
37
+ readonly nodeApiToken?: string
38
+ readonly requestTimeoutMs?: number
39
+ readonly defaultCreator?: string
40
+ readonly commandName?: string
41
+ readonly apiAccessToken?: string
42
+ readonly imGatewayServices?: readonly string[]
43
+ readonly imBridgeServices?: readonly string[]
44
+ readonly nodeEnabled?: boolean
45
+ readonly nodeId?: string
46
+ readonly heartbeatIntervalMs?: number
47
+ readonly dispatchPollIntervalMs?: number
48
+ readonly nodeLeaseSeconds?: number
49
+ readonly dispatchLeaseSeconds?: number
50
+ readonly dispatchRenewIntervalMs?: number
51
+ readonly deliveryPollIntervalMs?: number
52
+ readonly deliveryLeaseSeconds?: number
53
+ readonly maxConcurrent?: number
54
+ readonly runtimeReplyTimeoutMs?: number
55
+ readonly runtimeAgents?: readonly ConfiguredDshAgent[]
56
+ readonly nodeMetadata?: Readonly<Record<string, unknown>>
57
+ readonly extensionSecurity?: {
58
+ readonly requireSignedThirdParty?: boolean
59
+ readonly trustedPublicKeys?: Readonly<Record<string, string>>
60
+ }
61
+ }
62
+
63
+ export function apply(ctx: DshAgoraContext, config: Config = {}): void {
64
+ const commandName = normalizeCommandName(config.commandName ?? 'agora')
65
+ const nodeId = normalizeNodeId(config.nodeId ?? process.env.DSH_AGORA_NODE_ID ?? hostname())
66
+ const webServer = safeGet(ctx, 'webServer')
67
+ const imBridgeDiscovery = discoverImBridge(
68
+ ctx,
69
+ unique(config.imBridgeServices ?? ['dshImBridge', 'dshImAgoraBridge']),
70
+ )
71
+ let activeImBridge = imBridgeDiscovery.bridge
72
+ let worker: RuntimeNodeWorker | null = null
73
+ const registry = new DshAgoraExtensionRegistry({
74
+ ...(config.extensionSecurity?.requireSignedThirdParty === undefined
75
+ ? {}
76
+ : { requireSignedThirdParty: config.extensionSecurity.requireSignedThirdParty }),
77
+ ...(config.extensionSecurity?.trustedPublicKeys === undefined
78
+ ? {}
79
+ : { trustedPublicKeys: config.extensionSecurity.trustedPublicKeys }),
80
+ builtInExtensionIds: ['dsh-runtime'],
81
+ })
82
+ const client = new AgoraClient({
83
+ serverUrl: config.serverUrl ?? process.env.AGORA_SERVER_URL ?? 'http://127.0.0.1:18008',
84
+ apiToken: config.apiToken ?? process.env.AGORA_API_TOKEN,
85
+ timeoutMs: config.requestTimeoutMs,
86
+ })
87
+ const workerClient = new AgoraClient({
88
+ serverUrl: config.serverUrl ?? process.env.AGORA_SERVER_URL ?? 'http://127.0.0.1:18008',
89
+ apiToken: config.nodeApiToken ?? process.env.AGORA_NODE_API_TOKEN ?? config.apiToken ?? process.env.AGORA_API_TOKEN,
90
+ timeoutMs: config.requestTimeoutMs,
91
+ })
92
+ const service = new DshAgoraService({
93
+ client,
94
+ commandName,
95
+ defaultCreator: config.defaultCreator?.trim() || 'dsh',
96
+ registry,
97
+ imBridge: imBridgeDiscovery.bridge,
98
+ nodeId,
99
+ })
100
+ service.setImBridgeStatus(imBridgeDiscovery.status)
101
+
102
+ const refreshImBridge = (): void => {
103
+ const discovery = discoverImBridge(
104
+ ctx,
105
+ unique(config.imBridgeServices ?? ['dshImBridge', 'dshImAgoraBridge']),
106
+ )
107
+ // A bridge discovered by the optional Cordis injection below is scoped to
108
+ // its child context and is intentionally not visible from this parent.
109
+ // Do not let the legacy poller overwrite that live injected bridge.
110
+ if (discovery.bridge === null && activeImBridge !== null) return
111
+ activeImBridge = discovery.bridge
112
+ service.setImBridge(discovery.bridge, discovery.status)
113
+ worker?.setImBridge(discovery.bridge)
114
+ }
115
+ const imBridgeTimer = setInterval(refreshImBridge, 5_000)
116
+ imBridgeTimer.unref?.()
117
+ own(ctx, () => clearInterval(imBridgeTimer), 'dsh-agora: dsh-im bridge discovery')
118
+
119
+ // Optional dependency: this child fiber stays pending when dsh-im is absent,
120
+ // while the main dsh-agora plugin remains fully usable in headless mode. When
121
+ // dsh-im appears or reloads, Cordis activates this callback automatically.
122
+ ctx.inject?.(['dshImBridge'], bridgeCtx => {
123
+ const discovery = discoverImBridge(bridgeCtx, ['dshImBridge'])
124
+ if (discovery.bridge === null) return
125
+ activeImBridge = discovery.bridge
126
+ service.setImBridge(discovery.bridge, discovery.status)
127
+ worker?.setImBridge(discovery.bridge)
128
+ return () => {
129
+ if (activeImBridge !== discovery.bridge) return
130
+ activeImBridge = null
131
+ worker?.setImBridge(null)
132
+ service.setImBridge(null, {
133
+ state: 'unavailable',
134
+ reason: 'dsh-im.bridge/v1 provider was unloaded',
135
+ })
136
+ }
137
+ })
138
+
139
+ if (isWebServer(webServer) && Number.isInteger(webServer.port)) {
140
+ const runtime = new HarnessRuntimeAdapter({
141
+ baseUrl: `http://127.0.0.1:${webServer.port}`,
142
+ agents: config.runtimeAgents ?? [{ id: 'default', displayName: 'DeepSeek Harness', workspace: process.cwd() }],
143
+ ...(config.runtimeReplyTimeoutMs === undefined ? {} : { replyTimeoutMs: config.runtimeReplyTimeoutMs }),
144
+ })
145
+ const unregisterRuntime = service.registerExtension({
146
+ protocol: DSH_AGORA_EXTENSION_PROTOCOL,
147
+ id: 'dsh-runtime',
148
+ kind: 'runtime',
149
+ capabilities: ['runtime.execute', 'session.create', 'session.resume', 'session.prompt', 'session.cancel'],
150
+ runtime,
151
+ })
152
+ own(ctx, unregisterRuntime, 'dsh-agora: built-in DSH runtime adapter')
153
+ }
154
+
155
+ const unregisterCommand = ctx.commands.register({
156
+ name: commandName,
157
+ description: 'create and inspect governed Agora tasks',
158
+ input: { hint: '[health|nodes|agents|list|show|status|dispatch-status|create|dashboard|im]' },
159
+ handler: invocation => service.executeCommand(
160
+ invocation.rawInput,
161
+ { actorId: invocation.agent.id ?? invocation.agent.session?.id ?? 'dsh' },
162
+ invocation.signal,
163
+ ),
164
+ })
165
+ // commands.register() is already owned by the current Cordis effect. Keeping
166
+ // the returned capability alive is enough; wrapping it would double-dispose.
167
+ void unregisterCommand
168
+
169
+ // Primary IM-independent path: dsh-im only delivers text into a DSH Session;
170
+ // the Agent uses this normal DSH tool and dsh-im relays the ordinary reply.
171
+ const unregisterTool = ctx.tools.register(createAgoraTool(service))
172
+ void unregisterTool
173
+
174
+ const imRegistration = registerImCommand(
175
+ ctx,
176
+ unique(config.imGatewayServices ?? ['dshImCommandGateway', 'dshImGateway']),
177
+ {
178
+ name: commandName,
179
+ description: 'create and inspect governed Agora tasks',
180
+ execute: invocation => service.executeCommand(invocation.rawInput, invocation, invocation.signal),
181
+ },
182
+ )
183
+ service.setImStatus(imRegistration.status)
184
+ if (imRegistration.dispose !== undefined) own(ctx, imRegistration.dispose, 'dsh-agora: dsh-im command gateway')
185
+
186
+ if (isWebServer(webServer)) {
187
+ own(ctx, registerHttpApi(webServer, service, {
188
+ accessToken: config.apiAccessToken ?? process.env.DSH_AGORA_API_TOKEN,
189
+ }), 'dsh-agora: host API')
190
+ }
191
+
192
+ if (config.nodeEnabled !== false && isWebServer(webServer) && Number.isInteger(webServer.port)) {
193
+ worker = new RuntimeNodeWorker({
194
+ client: workerClient,
195
+ registry,
196
+ nodeId,
197
+ instanceId: randomUUID(),
198
+ pluginVersion: PLUGIN_VERSION,
199
+ ...(config.heartbeatIntervalMs === undefined ? {} : { heartbeatIntervalMs: config.heartbeatIntervalMs }),
200
+ ...(config.dispatchPollIntervalMs === undefined ? {} : { dispatchPollIntervalMs: config.dispatchPollIntervalMs }),
201
+ ...(config.nodeLeaseSeconds === undefined ? {} : { leaseSeconds: config.nodeLeaseSeconds }),
202
+ ...(config.dispatchLeaseSeconds === undefined ? {} : { dispatchLeaseSeconds: config.dispatchLeaseSeconds }),
203
+ ...(config.dispatchRenewIntervalMs === undefined ? {} : { dispatchRenewIntervalMs: config.dispatchRenewIntervalMs }),
204
+ ...(config.deliveryPollIntervalMs === undefined ? {} : { deliveryPollIntervalMs: config.deliveryPollIntervalMs }),
205
+ ...(config.deliveryLeaseSeconds === undefined ? {} : { deliveryLeaseSeconds: config.deliveryLeaseSeconds }),
206
+ ...(config.maxConcurrent === undefined ? {} : { maxConcurrent: config.maxConcurrent }),
207
+ imBridge: activeImBridge,
208
+ ...(config.nodeMetadata === undefined ? {} : { metadata: config.nodeMetadata }),
209
+ onStatus: status => service.setNodeStatus(status),
210
+ })
211
+ worker.start()
212
+ own(ctx, () => worker?.stop(), 'dsh-agora: runtime node worker')
213
+ } else {
214
+ service.setNodeStatus({ state: 'disabled', nodeId })
215
+ }
216
+
217
+ ctx.accessor?.('dshAgora', { get: () => service })
218
+ ctx.accessor?.('dshAgoraCommandAdapter', { get: () => service.commandAdapter })
219
+ }
220
+
221
+ function normalizeNodeId(value: string): string {
222
+ const nodeId = value.trim().toLowerCase().replace(/[^a-z0-9._-]+/gu, '-')
223
+ if (!/^[a-z0-9][a-z0-9._-]{0,127}$/u.test(nodeId)) throw new TypeError('nodeId is invalid')
224
+ return nodeId
225
+ }
226
+
227
+ function normalizeCommandName(value: string): string {
228
+ const name = value.trim().toLowerCase()
229
+ if (!/^[a-z][a-z0-9-]*$/u.test(name)) throw new TypeError('commandName must be lowercase letters, digits, or hyphens')
230
+ return name
231
+ }
232
+
233
+ function unique(values: readonly string[]): string[] {
234
+ return [...new Set(values.map(value => value.trim()).filter(Boolean))]
235
+ }
236
+
237
+ function safeGet(ctx: DshAgoraContext, name: string): unknown {
238
+ try {
239
+ // Cordis accessors (such as the public dsh-im bridge) are resolved through
240
+ // the context proxy, while ctx.get() only reads provided service values.
241
+ const accessorValue = Reflect.get(ctx as object, name)
242
+ if (accessorValue !== undefined) return accessorValue
243
+ } catch {
244
+ // Unknown, non-injected proxy properties throw by design; fall through to
245
+ // the provider lookup for ordinary Cordis services.
246
+ }
247
+ try {
248
+ return ctx.get?.(name)
249
+ } catch {
250
+ return undefined
251
+ }
252
+ }
253
+
254
+ function isWebServer(value: unknown): value is DshWebServer {
255
+ return typeof value === 'object' && value !== null && typeof (value as Partial<DshWebServer>).register === 'function'
256
+ }
257
+
258
+ function own(ctx: DshAgoraContext, dispose: () => void, label: string): void {
259
+ if (ctx.effect !== undefined) ctx.effect(() => dispose, label)
260
+ }