@goodandready/dsh-agent-orchestrator 0.1.9 → 0.1.11

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/lib/client.js CHANGED
@@ -897,7 +897,14 @@ window.__ModuleLoader__.load({
897
897
  if (res.ok) {
898
898
  const data = await res.json()
899
899
  const active = (data.activePipelines || [])[0] || null
900
- setActivePipeline(active)
900
+ setActivePipeline((prev) => {
901
+ if (!prev && !active) return null
902
+ if (!prev || !active) return active
903
+ if (prev.pipelineId === active.pipelineId && prev.status === active.status && prev.durationMs === active.durationMs) {
904
+ return prev
905
+ }
906
+ return active
907
+ })
901
908
 
902
909
  // If no active, check recent completed/failed within last 60 seconds
903
910
  if (!active && data.recentPipelines && data.recentPipelines.length > 0) {
@@ -917,12 +924,14 @@ window.__ModuleLoader__.load({
917
924
  }
918
925
  }, [dismissedId])
919
926
 
927
+ const hasActive = Boolean(activePipeline)
928
+
920
929
  useEffect(() => {
921
930
  pollStatus()
922
- const intervalMs = activePipeline ? 2000 : 5000
931
+ const intervalMs = hasActive ? 2000 : 5000
923
932
  const timer = setInterval(pollStatus, intervalMs)
924
933
  return () => clearInterval(timer)
925
- }, [activePipeline, pollStatus])
934
+ }, [hasActive, pollStatus])
926
935
 
927
936
  const handleCancel = async (pipelineId) => {
928
937
  try {
package/lib/http-guard.js CHANGED
@@ -3,9 +3,10 @@
3
3
  * against drive-by CSRF, unauthorized cross-network access, and cross-origin tampering.
4
4
  *
5
5
  * Implements Issue #113:
6
- * 1. Loopback / Origin / Host validation
7
- * 2. Sec-Fetch-Site and Referer verification
8
- * 3. Body size limiter (default 1MB) with fail-closed rejection
6
+ * 1. Loopback / Origin / Host validation with LAN reverse proxy (X-Forwarded-Host) support
7
+ * 2. Sec-Fetch-Site and Referer verification (Fetch Metadata is not authentication)
8
+ * 3. Explicit Bearer token authorization support (bridge path)
9
+ * 4. Body size limiter (default 1MB) with fail-closed rejection
9
10
  */
10
11
 
11
12
  export const DEFAULT_MAX_BODY_BYTES = 1024 * 1024 // 1MB
@@ -32,56 +33,97 @@ export function getClientIp(req) {
32
33
  /**
33
34
  * Checks whether an incoming HTTP request is safe for mutation or sensitive dispatch.
34
35
  *
35
- * Requirements:
36
- * 1. If Origin header is present:
37
- * - must not be "null" or empty
38
- * - parsed Origin host must match Host header
39
- * 2. If Referer header is present:
40
- * - parsed Referer host must match Host header
41
- * 3. If Sec-Fetch-Site header is present:
42
- * - must be "same-origin" or "none" (cross-site and same-site are rejected)
43
- * 4. If neither Origin nor Sec-Fetch-Site is present:
44
- * - only local loopback clients (127.0.0.1, ::1) are permitted.
36
+ * Requirements (Issue #113):
37
+ * 1. Authorization header:
38
+ * - Bearer token accepted if matching server tokens or valid non-empty token when no static secret enforced.
39
+ * 2. Sec-Fetch-Site header:
40
+ * - Must NOT be "cross-site" or "same-site" (fail-closed rejection).
41
+ * 3. Origin / Referer:
42
+ * - If present, must not be "null" or empty.
43
+ * - Parsed Origin host must match Host or X-Forwarded-Host.
44
+ * 4. Browser Same-Origin:
45
+ * - When Origin is present and matches Host / X-Forwarded-Host, request is permitted.
46
+ * 5. Non-browser / header-only callers:
47
+ * - Sec-Fetch-Site alone without matching Origin is NOT auth.
48
+ * - Without Origin or Authorization, only local loopback (127.0.0.1, ::1) callers are permitted.
45
49
  */
46
50
  export function isTrustedWriteRequest(req) {
47
51
  const headers = req.headers || {}
48
- const host = headers.host || ''
52
+ const rawHost = headers.host || ''
53
+ const forwardedHost = headers['x-forwarded-host'] || ''
54
+ const effectiveHost = forwardedHost || rawHost
55
+
56
+ // 1. Explicit Authorization header (Bridge / CLI / API token)
57
+ const authHeader = headers.authorization
58
+ const expectedToken = process.env.DSH_AUTH_TOKEN || process.env.DSH_TOKEN || process.env.ORCHESTRATOR_API_KEY
59
+ if (authHeader && typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
60
+ const token = authHeader.slice(7).trim()
61
+ if (token && (!expectedToken || token === expectedToken)) {
62
+ return true
63
+ }
64
+ }
49
65
 
66
+ // 2. Fetch Metadata check: reject cross-site and same-site requests upfront
67
+ const site = headers['sec-fetch-site']
68
+ if (site !== undefined && site) {
69
+ if (site !== 'same-origin' && site !== 'none') {
70
+ return false
71
+ }
72
+ }
73
+
74
+ // 3. Origin check if present
50
75
  const origin = headers.origin
51
76
  if (origin !== undefined) {
52
77
  if (origin === 'null' || !origin) return false
53
78
  try {
54
79
  const parsed = new URL(origin)
55
- if (parsed.host && host && parsed.host !== host) return false
80
+ const originHost = parsed.host
81
+ if (!originHost) return false
82
+ if (originHost !== effectiveHost && originHost !== rawHost) {
83
+ return false
84
+ }
56
85
  } catch {
57
86
  return false
58
87
  }
59
88
  }
60
89
 
90
+ // 4. Referer check if present
61
91
  const referer = headers.referer
62
92
  if (referer !== undefined && referer) {
63
93
  try {
64
94
  const parsed = new URL(referer)
65
- if (parsed.host && host && parsed.host !== host) return false
95
+ const refHost = parsed.host
96
+ if (!refHost) return false
97
+ if (refHost !== effectiveHost && refHost !== rawHost) {
98
+ return false
99
+ }
66
100
  } catch {
67
101
  return false
68
102
  }
69
103
  }
70
104
 
71
- const site = headers['sec-fetch-site']
72
- if (site !== undefined && site) {
73
- if (site !== 'same-origin' && site !== 'none') return false
74
- }
75
-
76
- // If both origin and sec-fetch-site are absent, permit only loopback callers
77
- if (origin === undefined && site === undefined) {
78
- const ip = getClientIp(req)
79
- if (!isLoopbackAddress(ip)) {
105
+ // 5. If origin is present and matches effectiveHost/rawHost:
106
+ if (origin && (effectiveHost || rawHost)) {
107
+ try {
108
+ const parsed = new URL(origin)
109
+ if (parsed.host === effectiveHost || parsed.host === rawHost) {
110
+ return true
111
+ }
112
+ } catch {
80
113
  return false
81
114
  }
82
115
  }
83
116
 
84
- return true
117
+ // 6. If origin is absent, check for local loopback:
118
+ // Fetch metadata alone (e.g. Sec-Fetch-Site: same-origin) is NOT authentication.
119
+ // Header-only remote callers without Origin or Authorization are rejected.
120
+ const ip = getClientIp(req)
121
+ if (isLoopbackAddress(ip)) {
122
+ // Local loopback caller without origin (CLI, server scripts) is allowed.
123
+ return true
124
+ }
125
+
126
+ return false
85
127
  }
86
128
 
87
129
  export function rejectUntrustedRequest(req, res) {
package/lib/index.js CHANGED
@@ -8,10 +8,16 @@
8
8
  import { randomUUID } from 'crypto'
9
9
  // Safe peerDependency resolution with standalone fallback
10
10
  function createSchemaStub(target = {}) {
11
- return new Proxy(target, {
11
+ const fn = (...args) => createSchemaStub({ ...target, args })
12
+ fn.shape = target.shape || {}
13
+ fn.meta = target.meta || {}
14
+ fn.volatile = () => createSchemaStub({ ...target, meta: { ...(target.meta || {}), volatile: true } })
15
+ return new Proxy(fn, {
12
16
  get(t, prop) {
13
17
  if (prop === 'shape') return t.shape || {}
14
- return (...args) => createSchemaStub({ ...t, [prop]: args[0] })
18
+ if (prop === 'meta') return t.meta || {}
19
+ if (prop === 'volatile') return () => createSchemaStub({ ...target, meta: { ...(target.meta || {}), volatile: true } })
20
+ return (...args) => createSchemaStub({ ...target, [prop]: args[0] })
15
21
  }
16
22
  })
17
23
  }
@@ -28,6 +34,18 @@ try {
28
34
  number: () => createSchemaStub({ type: 'number' }),
29
35
  union: () => createSchemaStub({ type: 'union' }),
30
36
  array: () => createSchemaStub({ type: 'array' }),
37
+ any: () => createSchemaStub({ type: 'any' }),
38
+ }
39
+ }
40
+
41
+ // DSH SettingsForms describe only publishes fields marked volatile. Without them the
42
+ // namespace is omitted and the configuration page stays unavailable.
43
+ if (typeof z.prototype?.volatile !== 'function') {
44
+ if (z.prototype) {
45
+ z.prototype.volatile = function volatile() {
46
+ if (this.meta && this.meta.volatile) return this
47
+ return typeof this.extra === 'function' ? this.extra('volatile', true) : this
48
+ }
31
49
  }
32
50
  }
33
51
 
@@ -58,16 +76,19 @@ export const name = '@goodandready/dsh-agent-orchestrator'
58
76
  export const inject = ['settings', 'webServer', 'llm', 'tools', 'commands', 'systemPrompt', 'subagents']
59
77
 
60
78
  export const Config = z.object({
61
- enabled: z.boolean().default(true).description('Enable Multi-Agent Orchestrator pipeline dispatcher'),
62
- defaultScenario: z.string().default('auto').description('Default complexity scenario (auto, hotfix, simple, medium, complex, enterprise)'),
63
- disableNestedDelegation: z.boolean().default(false).description('Anti-Matryoshka Guard: Disallow subagents from spawning nested subagents (Issue #19)'),
64
- maxConcurrentSubagents: z.number().default(3).description('Maximum concurrent subagents per parent session (Issue #93)'),
65
- subagentGracePeriodMs: z.number().default(180000).description('Grace period before archiving completed one-shot subagents (Issue #56)'),
66
- smartModelRouting: z.boolean().default(false).description('Smart Model Routing: dynamically select model based on task complexity (Issue #48)'),
67
- allowedProviders: z.array(z.string()).default([]).description('Whitelist of allowed LLM provider IDs (Issue #48)'),
68
- failFastModelValidation: z.boolean().default(false).description('Fail-fast when requested model is not found in provider registry (Issue #82)'),
69
- maxStoredSessions: z.number().default(400).description('Capacity recycling threshold for subagent sessions (Issue #67)'),
70
- sessionRetentionMs: z.number().default(86400000).description('Retention duration in ms for sessions-archive before physical deletion (Issue #57)'),
79
+ enabled: z.boolean().default(true).description('Enable Multi-Agent Orchestrator pipeline dispatcher').volatile(),
80
+ defaultScenario: z.string().default('auto').description('Default complexity scenario (auto, hotfix, simple, medium, complex, enterprise)').volatile(),
81
+ disableNestedDelegation: z.boolean().default(false).description('Anti-Matryoshka Guard: Disallow subagents from spawning nested subagents (Issue #19)').volatile(),
82
+ maxConcurrentSubagents: z.number().default(3).description('Maximum concurrent subagents per parent session (Issue #93)').volatile(),
83
+ subagentGracePeriodMs: z.number().default(180000).description('Grace period before archiving completed one-shot subagents (Issue #56)').volatile(),
84
+ smartModelRouting: z.boolean().default(false).description('Smart Model Routing: dynamically select model based on task complexity (Issue #48)').volatile(),
85
+ allowedProviders: z.array(z.string()).default([]).description('Whitelist of allowed LLM provider IDs (Issue #48)').volatile(),
86
+ failFastModelValidation: z.boolean().default(false).description('Fail-fast when requested model is not found in provider registry (Issue #82)').volatile(),
87
+ maxStoredSessions: z.number().default(400).description('Capacity recycling threshold for subagent sessions (Issue #67)').volatile(),
88
+ sessionRetentionMs: z.number().default(86400000).description('Retention duration in ms for sessions-archive before physical deletion (Issue #57)').volatile(),
89
+ roles: (typeof z.any === 'function' ? z.any() : z.object({})).default(null).description('Custom specialist roles roster').volatile(),
90
+ scenarios: (typeof z.any === 'function' ? z.any() : z.object({})).default(null).description('Custom execution scenarios DAG dictionary').volatile(),
91
+ kanbanSync: (typeof z.any === 'function' ? z.any() : z.object({})).default(null).description('Kanban board synchronization configuration').volatile(),
71
92
  })
72
93
 
73
94
  export function apply(ctx, config = {}) {
@@ -96,45 +117,142 @@ export function apply(ctx, config = {}) {
96
117
  },
97
118
  }
98
119
 
99
- let settingsScope = null
120
+ const applyLoadedConfig = (saved) => {
121
+ if (!saved || typeof saved !== 'object') return
122
+ const mergedRoles = saved.roles || currentSettings.roles || getDefaultRoles()
123
+ const mergedScenarios = saved.scenarios || currentSettings.scenarios || getDefaultScenarios()
124
+ const syncRes = syncDefaultPresets({
125
+ currentRoles: mergedRoles,
126
+ currentScenarios: mergedScenarios,
127
+ logger,
128
+ })
129
+ currentSettings = {
130
+ ...currentSettings,
131
+ ...saved,
132
+ disableNestedDelegation: saved.disableNestedDelegation ?? currentSettings.disableNestedDelegation,
133
+ smartModelRouting: saved.smartModelRouting ?? currentSettings.smartModelRouting,
134
+ allowedProviders: Array.isArray(saved.allowedProviders) ? saved.allowedProviders : currentSettings.allowedProviders,
135
+ failFastModelValidation: saved.failFastModelValidation ?? currentSettings.failFastModelValidation,
136
+ maxStoredSessions: saved.maxStoredSessions ?? currentSettings.maxStoredSessions,
137
+ sessionRetentionMs: saved.sessionRetentionMs ?? currentSettings.sessionRetentionMs,
138
+ roles: syncRes.roles,
139
+ scenarios: syncRes.scenarios,
140
+ kanbanSync: { ...currentSettings.kanbanSync, ...(saved.kanbanSync || {}) },
141
+ }
142
+ }
100
143
 
101
- // Register settings scope
102
- ctx.inject(['settings'], (sctx) => {
103
- try {
104
- const scope = sctx.settings.register(NS, Config, { base: config })
105
- if (scope) {
106
- settingsScope = scope
107
- const saved = scope.get()
108
- if (saved) {
109
- currentSettings = {
110
- ...currentSettings,
111
- ...saved,
112
- disableNestedDelegation: saved.disableNestedDelegation ?? currentSettings.disableNestedDelegation,
113
- roles: saved.roles || getDefaultRoles(),
114
- scenarios: saved.scenarios || getDefaultScenarios(),
115
- kanbanSync: { ...currentSettings.kanbanSync, ...(saved.kanbanSync || {}) },
144
+ let settingsAdapter = null
145
+
146
+ const createSettingsAdapter = (svc) => {
147
+ if (!svc) return null
148
+
149
+ // Legacy DSH settings service (v0.1.6 and earlier)
150
+ if (typeof svc.register === 'function') {
151
+ const scope = svc.register(NS, Config, { base: config })
152
+ return {
153
+ type: 'legacy',
154
+ get: () => scope?.get?.(),
155
+ persist: async (fullConfig) => {
156
+ if (!scope?.set) return
157
+ for (const [k, v] of Object.entries(fullConfig)) {
158
+ try {
159
+ await scope.set(k, v)
160
+ } catch (err) {
161
+ logger.debug('[dsh-agent-orchestrator] Legacy setting set warning:', k, err?.message || err)
162
+ }
116
163
  }
164
+ },
165
+ watch: (cb) => {
166
+ if (typeof scope?.watch === 'function') return scope.watch(cb)
167
+ return () => {}
168
+ },
169
+ }
170
+ }
171
+
172
+ // Modern DSH SettingsForms service (v0.1.7-rc.1+)
173
+ if (typeof svc.replace === 'function' || typeof svc.update === 'function') {
174
+ const resolveNsAndRevision = () => {
175
+ try {
176
+ const list = typeof svc.describe === 'function' ? svc.describe() : []
177
+ const entry = list.find((row) => row.ns === name || row.ns === NS || row.ns?.includes('dsh-agent-orchestrator'))
178
+ if (entry) {
179
+ return { ns: entry.ns, revision: entry.revision, value: entry.value }
180
+ }
181
+ } catch (err) {
182
+ logger.debug('[dsh-agent-orchestrator] Settings describe error:', err?.message || err)
117
183
  }
184
+ return { ns: NS, revision: undefined, value: undefined }
185
+ }
186
+
187
+ return {
188
+ type: 'modern',
189
+ get: () => {
190
+ const { value } = resolveNsAndRevision()
191
+ return value
192
+ },
193
+ persist: async (fullConfig) => {
194
+ const { ns: targetNs, revision } = resolveNsAndRevision()
195
+ if (typeof svc.replace === 'function') {
196
+ await svc.replace(targetNs, fullConfig, revision)
197
+ } else if (typeof svc.update === 'function') {
198
+ await svc.update(targetNs, fullConfig, revision)
199
+ } else {
200
+ throw new Error('Settings service does not support replace or update')
201
+ }
202
+ },
203
+ watch: (cb) => {
204
+ if (typeof ctx.on === 'function') {
205
+ return ctx.on('settings/document-updated', (id) => {
206
+ if (id === name || id === NS || id?.includes('dsh-agent-orchestrator')) {
207
+ const { value } = resolveNsAndRevision()
208
+ cb(value)
209
+ }
210
+ })
211
+ }
212
+ return () => {}
213
+ },
118
214
  }
119
- } catch (e) {
120
- logger.warn('[dsh-agent-orchestrator] Settings register warning:', e?.message || e)
121
215
  }
122
- })
123
216
 
124
- const getConfig = () => currentSettings
125
- const updateConfig = async (partial) => {
126
- currentSettings = { ...currentSettings, ...partial }
127
- if (settingsScope) {
217
+ return null
218
+ }
219
+
220
+ // Register settings scope or adapter
221
+ if (typeof ctx.inject === 'function') {
222
+ ctx.inject(['settings'], (sctx) => {
128
223
  try {
129
- for (const [k, v] of Object.entries(partial)) {
130
- if (k === 'enabled' || k === 'defaultScenario' || k === 'disableNestedDelegation') {
131
- await settingsScope.set(k, v)
224
+ settingsAdapter = createSettingsAdapter(sctx.settings)
225
+ if (settingsAdapter) {
226
+ const saved = settingsAdapter.get()
227
+ if (saved) {
228
+ applyLoadedConfig(saved)
229
+ }
230
+ if (typeof sctx.effect === 'function') {
231
+ sctx.effect(() => settingsAdapter.watch((next) => {
232
+ if (next) applyLoadedConfig(next)
233
+ }), 'dsh-agent-orchestrator: settings watch')
132
234
  }
133
235
  }
134
- } catch (err) {
135
- logger.debug('[dsh-agent-orchestrator] Settings update warning:', err?.message || err)
236
+ } catch (e) {
237
+ logger.warn('[dsh-agent-orchestrator] Settings register warning:', e?.message || e)
136
238
  }
239
+ })
240
+ }
241
+
242
+ const getConfig = () => currentSettings
243
+ const updateConfig = async (partial) => {
244
+ if (!settingsAdapter) {
245
+ throw new Error('Settings service is unavailable')
246
+ }
247
+ const nextSettings = {
248
+ ...currentSettings,
249
+ ...partial,
250
+ kanbanSync: partial?.kanbanSync
251
+ ? { ...currentSettings.kanbanSync, ...partial.kanbanSync }
252
+ : currentSettings.kanbanSync,
137
253
  }
254
+ await settingsAdapter.persist(nextSettings)
255
+ currentSettings = nextSettings
138
256
  }
139
257
 
140
258
  // Self-Syncing Presets & Roles (Issue #107)
@@ -292,10 +410,22 @@ export function apply(ctx, config = {}) {
292
410
  }
293
411
  }
294
412
 
413
+ const activeAbortControllers = new Map()
414
+
295
415
  /**
296
416
  * Runner object to start, execute, and monitor pipelines
297
417
  */
298
418
  const runner = {
419
+ cancelPipeline(pipelineId) {
420
+ const controller = activeAbortControllers.get(pipelineId)
421
+ if (controller) {
422
+ controller.abort()
423
+ activeAbortControllers.delete(pipelineId)
424
+ return true
425
+ }
426
+ return false
427
+ },
428
+
299
429
  async startPipeline({ taskTitle, taskDescription, scenarioId, taskId }) {
300
430
  const cfg = getConfig()
301
431
  const plan = decomposeTask({
@@ -369,17 +499,24 @@ export function apply(ctx, config = {}) {
369
499
  }
370
500
  }
371
501
 
502
+ const abortController = new AbortController()
503
+ activeAbortControllers.set(plan.pipelineId, abortController)
504
+
372
505
  executeDAG({
373
506
  stages: plan.stages,
374
507
  executor,
375
508
  concurrency: 3,
509
+ signal: abortController.signal,
376
510
  })
377
511
  .then(async (dagResult) => {
378
- store.updatePipeline(plan.pipelineId, {
379
- status: 'completed',
380
- completedAt: Date.now(),
512
+ activeAbortControllers.delete(plan.pipelineId)
513
+ const currentP = store.getPipeline(plan.pipelineId)
514
+ if (currentP?.status === 'failed' && currentP?.error === 'Cancelled by user') {
515
+ return
516
+ }
517
+ store.recordCompletion(plan.pipelineId, {
518
+ ...dagResult,
381
519
  durationMs: Date.now() - pipelineData.startedAt,
382
- artifacts: dagResult.artifacts,
383
520
  })
384
521
 
385
522
  // Sync to Kanban if enabled
@@ -399,7 +536,7 @@ export function apply(ctx, config = {}) {
399
536
  if (syncConfig.createChecklist) {
400
537
  const checklistItems = plan.stages.map((s) => ({
401
538
  title: `${s.name} (${s.roleName})`,
402
- completed: dagResult.statusMap[s.id] === 'completed',
539
+ completed: dagResult?.stateMap?.[s.id]?.status === 'completed',
403
540
  }))
404
541
  await kanbanBridge.syncStageChecklist(targetTaskId, checklistItems)
405
542
  }
@@ -419,7 +556,7 @@ export function apply(ctx, config = {}) {
419
556
  id: plan.pipelineId,
420
557
  title: plan.taskTitle,
421
558
  scenarioId: plan.scenarioId,
422
- status: dagResult.allSucceeded ? 'completed' : 'failed',
559
+ status: dagResult?.success ? 'completed' : 'failed',
423
560
  stages: plan.stages,
424
561
  durationMs: Date.now() - pipelineData.startedAt,
425
562
  })
@@ -428,9 +565,13 @@ export function apply(ctx, config = {}) {
428
565
  }
429
566
  })
430
567
  .catch((dagErr) => {
431
- store.updatePipeline(plan.pipelineId, {
432
- status: 'failed',
433
- completedAt: Date.now(),
568
+ activeAbortControllers.delete(plan.pipelineId)
569
+ const currentP = store.getPipeline(plan.pipelineId)
570
+ if (currentP?.status === 'failed' && currentP?.error === 'Cancelled by user') {
571
+ return
572
+ }
573
+ store.recordCompletion(plan.pipelineId, {
574
+ success: false,
434
575
  durationMs: Date.now() - pipelineData.startedAt,
435
576
  error: dagErr?.message || String(dagErr),
436
577
  })
@@ -813,8 +954,11 @@ export function apply(ctx, config = {}) {
813
954
  }
814
955
  }
815
956
 
957
+ let hasCommandsService = false
958
+
816
959
  // Chat slash commands: /orchestrate and /orc via commands service
817
960
  ctx.inject(['commands'], (cctx) => {
961
+ hasCommandsService = true
818
962
  try {
819
963
  if (typeof cctx.commands?.register !== 'function') return
820
964
 
@@ -904,6 +1048,7 @@ export function apply(ctx, config = {}) {
904
1048
 
905
1049
  if (typeof cctx.effect === 'function') {
906
1050
  cctx.effect(() => () => {
1051
+ hasCommandsService = false
907
1052
  try {
908
1053
  if (typeof unregister1 === 'function') unregister1()
909
1054
  if (typeof unregister2 === 'function') unregister2()
@@ -938,6 +1083,11 @@ export function apply(ctx, config = {}) {
938
1083
  const text = String(event.text || event.content || '').trim()
939
1084
  const intent = detectOrchestratorIntent(text)
940
1085
 
1086
+ // Prevent duplicate dispatch: if commands service is active, slash commands are handled by cctx.commands (#148)
1087
+ if (hasCommandsService && intent.triggerKind === 'slash') {
1088
+ return
1089
+ }
1090
+
941
1091
  if (intent.isTrigger && intent.action === 'on') {
942
1092
  await handlePipelineDispatch({
943
1093
  taskTitle: intent.taskTitle || 'Interactive Orchestrated Task',
@@ -149,12 +149,14 @@ export async function executeDAG(stagesOrOptions, executorArg, optionsArg = {})
149
149
  let onNodeComplete = () => {}
150
150
  let onNodeFail = () => {}
151
151
  let onNodeBlocked = () => {}
152
+ let signal = null
152
153
 
153
154
  if (Array.isArray(stagesOrOptions)) {
154
155
  stages = stagesOrOptions
155
156
  executor = executorArg
156
157
  concurrency = optionsArg.concurrency || optionsArg.maxConcurrency || 4
157
158
  initialContext = optionsArg.initialContext || {}
159
+ signal = optionsArg.signal || null
158
160
  if (typeof optionsArg.onNodeStart === 'function') onNodeStart = optionsArg.onNodeStart
159
161
  if (typeof optionsArg.onNodeComplete === 'function') onNodeComplete = optionsArg.onNodeComplete
160
162
  if (typeof optionsArg.onNodeFail === 'function') onNodeFail = optionsArg.onNodeFail
@@ -164,6 +166,7 @@ export async function executeDAG(stagesOrOptions, executorArg, optionsArg = {})
164
166
  executor = stagesOrOptions.executor
165
167
  concurrency = stagesOrOptions.concurrency || stagesOrOptions.maxConcurrency || 4
166
168
  initialContext = stagesOrOptions.initialContext || {}
169
+ signal = stagesOrOptions.signal || null
167
170
  if (typeof stagesOrOptions.onNodeStart === 'function') onNodeStart = stagesOrOptions.onNodeStart
168
171
  if (typeof stagesOrOptions.onNodeComplete === 'function') onNodeComplete = stagesOrOptions.onNodeComplete
169
172
  if (typeof stagesOrOptions.onNodeFail === 'function') onNodeFail = stagesOrOptions.onNodeFail
@@ -220,13 +223,33 @@ export async function executeDAG(stagesOrOptions, executorArg, optionsArg = {})
220
223
  function triggerNext() {
221
224
  if (isDone) return
222
225
 
226
+ if (signal?.aborted) {
227
+ if (!isDone && runningCount === 0) {
228
+ isDone = true
229
+ for (const s of stages) {
230
+ const st = stateMap.get(s.id)
231
+ if (st.status === NODE_STATUS.PENDING) {
232
+ st.status = NODE_STATUS.SKIPPED
233
+ }
234
+ }
235
+ resolvePromise({
236
+ success: false,
237
+ cancelled: true,
238
+ durationMs: Date.now() - startTime,
239
+ stateMap: Object.fromEntries(stateMap),
240
+ artifacts: Object.fromEntries(artifacts),
241
+ })
242
+ }
243
+ return
244
+ }
245
+
223
246
  // Propagate blockers first
224
247
  if (markBlockedStages(stages, stateMap)) {
225
248
  for (const s of stages) {
226
249
  const st = stateMap.get(s.id)
227
250
  if (st.status === NODE_STATUS.BLOCKED && !st.notifiedBlocked) {
228
251
  st.notifiedBlocked = true
229
- onNodeBlocked(st)
252
+ try { onNodeBlocked(st) } catch (_) { /* Safe ignore notification error */ }
230
253
  }
231
254
  }
232
255
  }
@@ -241,7 +264,7 @@ export async function executeDAG(stagesOrOptions, executorArg, optionsArg = {})
241
264
  nodeState.startedAt = Date.now()
242
265
  runningCount++
243
266
 
244
- onNodeStart(nodeState)
267
+ try { onNodeStart(nodeState) } catch (_) { /* Safe ignore notification error */ }
245
268
 
246
269
  // Run task asynchronously
247
270
  const upstreamOutputs = {}
@@ -266,17 +289,28 @@ export async function executeDAG(stagesOrOptions, executorArg, optionsArg = {})
266
289
 
267
290
  artifacts.set(nextStage.id, nodeState.output)
268
291
  runningCount--
269
- onNodeComplete(nodeState)
292
+ try {
293
+ onNodeComplete(nodeState)
294
+ } catch (_) {
295
+ /* Callback error must not alter completed state or double-decrement runningCount */
296
+ }
270
297
  triggerNext()
271
298
  })
272
299
  .catch((err) => {
300
+ if (nodeState.status === NODE_STATUS.COMPLETED) {
301
+ return
302
+ }
273
303
  nodeState.status = NODE_STATUS.FAILED
274
304
  nodeState.completedAt = Date.now()
275
305
  nodeState.durationMs = nodeState.completedAt - nodeState.startedAt
276
306
  nodeState.error = err?.message || String(err)
277
307
 
278
308
  runningCount--
279
- onNodeFail(nodeState, err)
309
+ try {
310
+ onNodeFail(nodeState, err)
311
+ } catch (_) {
312
+ /* Callback error must not crash the engine */
313
+ }
280
314
  triggerNext()
281
315
  })
282
316
  }
@@ -284,6 +318,12 @@ export async function executeDAG(stagesOrOptions, executorArg, optionsArg = {})
284
318
  checkCompletion()
285
319
  }
286
320
 
321
+ if (signal) {
322
+ signal.addEventListener('abort', () => {
323
+ triggerNext()
324
+ }, { once: true })
325
+ }
326
+
287
327
  // Initial trigger
288
328
  triggerNext()
289
329
  })
@@ -152,8 +152,18 @@ export function normalizeRoleId(input, availableRoles = []) {
152
152
  if (role.displayName && role.displayName.toLowerCase() === lower) return role.id
153
153
  }
154
154
 
155
+ function matchesRoleAlias(str, alias) {
156
+ if (str === alias) return true
157
+ if (alias.length <= 3) {
158
+ const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
159
+ const regex = new RegExp('(^|[^a-z0-9а-яё])' + escaped + '([^a-z0-9а-яё]|$)', 'i')
160
+ return regex.test(str)
161
+ }
162
+ return str.includes(alias)
163
+ }
164
+
155
165
  for (const [alias, id] of Object.entries(ROLE_INPUT_ALIASES)) {
156
- if (lower === alias || lower.includes(alias)) {
166
+ if (matchesRoleAlias(lower, alias)) {
157
167
  const found = rolesList.find((r) => r.id === id)
158
168
  if (found) return found.id
159
169
  if (id === 'code') {
@@ -51,12 +51,13 @@ export function detectOrchestratorIntent(text) {
51
51
  if (slashMatch) {
52
52
  const rest = (slashMatch[1] || '').trim()
53
53
  if (rest.toLowerCase() === 'off') {
54
- return { isTrigger: true, action: 'off' }
54
+ return { isTrigger: true, action: 'off', triggerKind: 'slash' }
55
55
  }
56
56
  const { scenarioId, taskTitle } = extractScenarioAndTask(rest)
57
57
  return {
58
58
  isTrigger: true,
59
59
  action: 'on',
60
+ triggerKind: 'slash',
60
61
  scenarioId,
61
62
  taskTitle,
62
63
  }
@@ -73,6 +74,7 @@ export function detectOrchestratorIntent(text) {
73
74
  return {
74
75
  isTrigger: true,
75
76
  action: 'on',
77
+ triggerKind: 'nl',
76
78
  scenarioId,
77
79
  taskTitle,
78
80
  }
@@ -89,6 +91,7 @@ export function detectOrchestratorIntent(text) {
89
91
  return {
90
92
  isTrigger: true,
91
93
  action: 'on',
94
+ triggerKind: 'nl',
92
95
  scenarioId,
93
96
  taskTitle,
94
97
  }
@@ -569,7 +569,6 @@ export async function fetchModelCatalog(ctx, options = {}) {
569
569
  const defaultPool = [
570
570
  { provider: 'deepseek-official', model: 'deepseek-chat', maxTokens: 4096 },
571
571
  { provider: 'deepseek-official', model: 'deepseek-reasoner', maxTokens: 8192 },
572
- { provider: 'anthropic', model: 'claude-3-5-sonnet', maxTokens: 8192 },
573
572
  ]
574
573
  for (const item of defaultPool) {
575
574
  const capabilities = inferModelCapabilities(item.model, item.provider)
@@ -23,6 +23,7 @@ export class SnapshotManager {
23
23
  this.baseDir = baseDir || path.join(os.homedir(), '.dsh', 'orchestrator-snapshots')
24
24
  this.maxSnapshots = maxSnapshots
25
25
  this.logger = logger || null
26
+ this._snapshotsCache = null
26
27
  this._ensureDir()
27
28
  }
28
29
 
@@ -65,6 +66,9 @@ export class SnapshotManager {
65
66
  */
66
67
  listSnapshots() {
67
68
  this._ensureDir()
69
+ if (this._snapshotsCache) {
70
+ return [...this._snapshotsCache]
71
+ }
68
72
  try {
69
73
  const files = fs.readdirSync(this.baseDir)
70
74
  const snapshots = []
@@ -91,7 +95,8 @@ export class SnapshotManager {
91
95
  return timeB - timeA
92
96
  })
93
97
 
94
- return snapshots
98
+ this._snapshotsCache = snapshots
99
+ return [...snapshots]
95
100
  } catch (err) {
96
101
  if (this.logger?.error) {
97
102
  this.logger.error('[SnapshotManager] Error listing snapshots:', err?.message || err)
@@ -158,7 +163,7 @@ export class SnapshotManager {
158
163
  const tmpPath = path.join(this.baseDir, `${snapshotId}.tmp-${Date.now()}`)
159
164
 
160
165
  try {
161
- fs.writeFileSync(tmpPath, JSON.stringify(record, null, 2), 'utf8')
166
+ fs.writeFileSync(tmpPath, JSON.stringify(record), 'utf8')
162
167
  fs.renameSync(tmpPath, filePath)
163
168
  } catch (err) {
164
169
  try {
@@ -169,6 +174,7 @@ export class SnapshotManager {
169
174
  throw err
170
175
  }
171
176
 
177
+ this._snapshotsCache = null
172
178
  // Enforce FIFO retention policy: if file count > maxSnapshots, prune oldest
173
179
  this._enforceRetention()
174
180
 
@@ -179,6 +185,7 @@ export class SnapshotManager {
179
185
  * Prunes oldest snapshot files when total exceeds maxSnapshots ceiling.
180
186
  */
181
187
  _enforceRetention() {
188
+ this._snapshotsCache = null
182
189
  try {
183
190
  const files = fs.readdirSync(this.baseDir).filter((f) => f.endsWith('.json'))
184
191
  if (files.length <= this.maxSnapshots) return
@@ -84,6 +84,7 @@ export async function executeStageWorker(stage, context) {
84
84
  output: artContent || '',
85
85
  })
86
86
  }
87
+ priorArtifactsList.sort((a, b) => a.stageId.localeCompare(b.stageId))
87
88
  const cumulativeArtifacts = formatCumulativeArtifacts(priorArtifactsList)
88
89
 
89
90
  // 4. Assemble canonical prompt (Layer 1 + 2 + 3 in System, Layer 4 in User)
package/lib/routes.js CHANGED
@@ -20,19 +20,47 @@ function jsonReply(res, data, status = 200) {
20
20
  res.end(JSON.stringify(data))
21
21
  }
22
22
 
23
+ export function toPublicPipelineDto(pipe) {
24
+ if (!pipe) return null
25
+ const stages = Array.isArray(pipe.stages)
26
+ ? pipe.stages.map((s) => ({
27
+ id: s.id,
28
+ name: s.name,
29
+ roleId: s.roleId,
30
+ status: s.status,
31
+ }))
32
+ : []
33
+ return {
34
+ pipelineId: pipe.pipelineId || pipe.id,
35
+ scenarioId: pipe.scenarioId,
36
+ scenarioTitle: pipe.scenarioTitle,
37
+ status: pipe.status,
38
+ startedAt: pipe.startedAt || pipe.createdAt || null,
39
+ completedAt: pipe.completedAt || null,
40
+ durationMs: pipe.durationMs || 0,
41
+ stages,
42
+ }
43
+ }
44
+
45
+
23
46
  export function registerOrchestratorRoutes(ctx, services) {
24
47
  const { store, runner, getConfig, updateConfig, callLlm, snapshotManager = new SnapshotManager() } = services
25
48
 
26
49
  // 1. Status & Global Metrics (Read-only status overview)
27
- // Conscious architectural choice (Issue #113): Public read-only endpoint returning non-sensitive metrics
50
+ // Conscious architectural choice (Issue #113): Public read-only endpoint returning non-sensitive metrics only
28
51
  ctx.effect(() => ctx.webServer.register({
29
52
  kind: 'exact',
30
53
  path: '/dsh-agent-orchestrator/status',
31
54
  handler: (req, res) => {
55
+ if (req.method !== 'GET') {
56
+ return jsonReply(res, { error: 'Method not allowed' }, 405)
57
+ }
58
+ const active = store.getActivePipelines().map(toPublicPipelineDto)
59
+ const recent = store.getAllPipelines().slice(0, 10).map(toPublicPipelineDto)
32
60
  jsonReply(res, {
33
- activePipelines: store.getActivePipelines(),
61
+ activePipelines: active,
34
62
  metrics: store.getMetrics(),
35
- recentPipelines: store.getAllPipelines().slice(0, 10),
63
+ recentPipelines: recent,
36
64
  })
37
65
  },
38
66
  }), 'dsh-agent-orchestrator: status route')
@@ -112,6 +140,10 @@ export function registerOrchestratorRoutes(ctx, services) {
112
140
  kind: 'prefix',
113
141
  path: '/dsh-agent-orchestrator/pipeline',
114
142
  handler: (req, res) => {
143
+ if (req.method !== 'GET') {
144
+ return jsonReply(res, { error: 'Method not allowed' }, 405)
145
+ }
146
+ if (rejectUntrustedRequest(req, res)) return
115
147
  const parts = req.url.split('?')[0].split('/')
116
148
  const pipelineId = parts[parts.length - 1]
117
149
  const pipeline = store.getPipeline(pipelineId)
@@ -140,6 +172,7 @@ export function registerOrchestratorRoutes(ctx, services) {
140
172
  }
141
173
 
142
174
  const pipelineId = body.pipelineId
175
+ runner?.cancelPipeline?.(pipelineId)
143
176
  const p = store.getPipeline(pipelineId)
144
177
  if (p && (p.status === 'running' || p.status === 'pending')) {
145
178
  store.updatePipeline(pipelineId, {
@@ -175,9 +208,17 @@ export function registerOrchestratorRoutes(ctx, services) {
175
208
  }
176
209
  }
177
210
  }
178
- await updateConfig(body)
179
- return jsonReply(res, { success: true, config: getConfig() })
211
+ try {
212
+ await updateConfig(body)
213
+ return jsonReply(res, { success: true, config: getConfig() })
214
+ } catch (err) {
215
+ return jsonReply(res, { success: false, error: err?.message || 'Failed to persist configuration' }, 500)
216
+ }
180
217
  }
218
+ if (req.method !== 'GET') {
219
+ return jsonReply(res, { error: 'Method not allowed' }, 405)
220
+ }
221
+ if (rejectUntrustedRequest(req, res)) return
181
222
  jsonReply(res, { config: getConfig() })
182
223
  },
183
224
  }), 'dsh-agent-orchestrator: config route')
@@ -187,6 +228,9 @@ export function registerOrchestratorRoutes(ctx, services) {
187
228
  kind: 'exact',
188
229
  path: '/dsh-agent-orchestrator/models',
189
230
  handler: async (req, res) => {
231
+ if (req.method !== 'GET') {
232
+ return jsonReply(res, { error: 'Method not allowed' }, 405)
233
+ }
190
234
  const selection = readModelSelection(ctx)
191
235
  const catalog = await fetchModelCatalog(ctx, {
192
236
  allowedRoutes: selection.allowedRoutes,
@@ -204,6 +248,9 @@ export function registerOrchestratorRoutes(ctx, services) {
204
248
  kind: 'exact',
205
249
  path: '/dsh-agent-orchestrator/catalog/models',
206
250
  handler: async (req, res) => {
251
+ if (req.method !== 'GET') {
252
+ return jsonReply(res, { error: 'Method not allowed' }, 405)
253
+ }
207
254
  try {
208
255
  const urlObj = new URL(req.url, 'http://localhost')
209
256
  const provider = urlObj.searchParams.get('provider') || undefined
@@ -225,6 +272,10 @@ export function registerOrchestratorRoutes(ctx, services) {
225
272
  kind: 'exact',
226
273
  path: '/dsh-agent-orchestrator/snapshots',
227
274
  handler: (req, res) => {
275
+ if (req.method !== 'GET') {
276
+ return jsonReply(res, { error: 'Method not allowed' }, 405)
277
+ }
278
+ if (rejectUntrustedRequest(req, res)) return
228
279
  try {
229
280
  const snapshots = snapshotManager.listSnapshots()
230
281
  jsonReply(res, { snapshots, total: snapshots.length })
package/lib/store.js CHANGED
@@ -12,9 +12,10 @@ export class OrchestratorStore {
12
12
  constructor(options = {}) {
13
13
  const opts = typeof options === 'string' ? { storageDir: options } : (options || {})
14
14
  const baseDir = opts.storageDir || join(homedir(), '.dsh')
15
- this.storagePath = join(baseDir, 'orchestrator-pipelines.json')
15
+ this.storagePath = opts.storagePath || join(baseDir, 'orchestrator-pipelines.json')
16
16
  this.logger = opts.logger || null
17
17
  this.pipelines = new Map()
18
+ this._saveTimer = null
18
19
  this.globalMetrics = {
19
20
  totalPipelines: 0,
20
21
  completedPipelines: 0,
@@ -55,12 +56,23 @@ export class OrchestratorStore {
55
56
  globalMetrics: this.globalMetrics,
56
57
  updatedAt: Date.now(),
57
58
  }
58
- writeFileSync(this.storagePath, JSON.stringify(serialized, null, 2), 'utf8')
59
+ writeFileSync(this.storagePath, JSON.stringify(serialized), 'utf8')
59
60
  } catch (e) {
60
61
  if (this.logger?.warn) this.logger.warn('[dsh-agent-orchestrator] Store save warning:', e?.message || e)
61
62
  }
62
63
  }
63
64
 
65
+ saveDebounced(delayMs = 150) {
66
+ if (this._saveTimer) return
67
+ this._saveTimer = setTimeout(() => {
68
+ this._saveTimer = null
69
+ this.save()
70
+ }, delayMs)
71
+ if (this._saveTimer && typeof this._saveTimer.unref === 'function') {
72
+ this._saveTimer.unref()
73
+ }
74
+ }
75
+
64
76
  recordPipeline(pipelineData) {
65
77
  this.pipelines.set(pipelineData.pipelineId, pipelineData)
66
78
  this.globalMetrics.totalPipelines++
@@ -107,7 +119,7 @@ export class OrchestratorStore {
107
119
  }
108
120
  }
109
121
 
110
- this.save()
122
+ this.saveDebounced()
111
123
  return updated
112
124
  }
113
125
 
@@ -127,6 +139,7 @@ export class OrchestratorStore {
127
139
  p.durationMs = summary.durationMs || 0
128
140
  p.stateMap = summary.stateMap || {}
129
141
  p.artifacts = summary.artifacts || {}
142
+ if (summary.error) p.error = summary.error
130
143
 
131
144
  if (summary.success) {
132
145
  this.globalMetrics.completedPipelines++
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-agent-orchestrator",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Multi-agent task decomposition, DAG workflow orchestration, and prompt caching optimizer for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",