@swarmclawai/swarmclaw 1.9.1 → 1.9.3

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 (35) hide show
  1. package/README.md +21 -1
  2. package/electron-dist/main.js +218 -0
  3. package/package.json +3 -3
  4. package/scripts/ensure-sandbox-browser-image.mjs +12 -2
  5. package/src/app/api/extensions/managed-resources/route.test.ts +117 -0
  6. package/src/app/api/extensions/managed-resources/route.ts +116 -0
  7. package/src/app/api/knowledge/hygiene/route.ts +19 -1
  8. package/src/app/api/portability/export/route.test.ts +17 -0
  9. package/src/app/api/portability/export/route.ts +11 -2
  10. package/src/cli/index.js +2 -0
  11. package/src/cli/spec.js +2 -0
  12. package/src/lib/server/agents/delegation-advisory.test.ts +1 -0
  13. package/src/lib/server/agents/delegation-advisory.ts +10 -0
  14. package/src/lib/server/chat-execution/iteration-event-handler.ts +24 -8
  15. package/src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts +117 -0
  16. package/src/lib/server/chat-execution/reasoning-tag-scrubber.ts +219 -0
  17. package/src/lib/server/extension-managed-resources.test.ts +159 -0
  18. package/src/lib/server/extension-managed-resources.ts +905 -0
  19. package/src/lib/server/extensions.ts +113 -2
  20. package/src/lib/server/knowledge-sources.test.ts +45 -0
  21. package/src/lib/server/knowledge-sources.ts +33 -0
  22. package/src/lib/server/portability/export.ts +10 -0
  23. package/src/lib/server/session-tools/crud.ts +25 -2
  24. package/src/lib/server/session-tools/extension-creator.ts +50 -0
  25. package/src/lib/server/session-tools/manage-tasks.test.ts +7 -2
  26. package/src/lib/server/tasks/task-route-service.ts +18 -1
  27. package/src/lib/server/tasks/task-service.test.ts +60 -2
  28. package/src/lib/server/tasks/task-service.ts +35 -0
  29. package/src/lib/validation/schemas.ts +2 -0
  30. package/src/types/agent.ts +2 -0
  31. package/src/types/app-settings.ts +8 -0
  32. package/src/types/extension.ts +132 -0
  33. package/src/types/misc.ts +1 -1
  34. package/src/types/schedule.ts +3 -0
  35. package/src/views/settings/extension-manager.tsx +157 -1
@@ -124,6 +124,7 @@ describe('delegation-advisory', () => {
124
124
  assert.equal(result.shouldDelegate, true)
125
125
  assert.equal(result.style, 'managerial')
126
126
  assert.equal(result.recommended?.agentId, 'builder')
127
+ assert.equal(result.recommended?.routeKey, 'coding:coding,debugging,implementation:builder')
127
128
  assert.match(advisory.formatDelegationRationale(result.recommended), /coding/i)
128
129
  })
129
130
 
@@ -20,6 +20,7 @@ export interface DelegationTaskProfile {
20
20
  export interface DelegationCandidateFit {
21
21
  agentId: string
22
22
  agentName: string
23
+ routeKey: string
23
24
  score: number
24
25
  availability: 'idle' | 'working' | 'unknown'
25
26
  matchedCapabilities: string[]
@@ -76,6 +77,14 @@ function matchedCapabilities(agentCapabilities: string[] | undefined, requiredCa
76
77
  return requiredCapabilities.filter((entry) => agentSet.has(entry.toLowerCase()))
77
78
  }
78
79
 
80
+ function buildRouteKey(agent: Agent, profile: DelegationTaskProfile): string {
81
+ const required = profile.requiredCapabilities
82
+ .map((entry) => entry.toLowerCase())
83
+ .sort()
84
+ .join(',')
85
+ return [profile.workType, required || 'general', agent.id].join(':')
86
+ }
87
+
79
88
  function roleAdjustment(agent: Agent, profile: DelegationTaskProfile): number {
80
89
  const role = agent.role === 'coordinator' ? 'coordinator' : 'worker'
81
90
  if (profile.workType === 'operations') {
@@ -141,6 +150,7 @@ function buildCandidateFit(
141
150
  return {
142
151
  agentId: agent.id,
143
152
  agentName: agent.name,
153
+ routeKey: buildRouteKey(agent, profile),
144
154
  score,
145
155
  availability,
146
156
  matchedCapabilities: matched,
@@ -30,6 +30,7 @@ import {
30
30
  createReasoningContentMetadata,
31
31
  extractReasoningContentDelta,
32
32
  } from '@/lib/providers/deepseek-reasoning-chat-openai'
33
+ import { StreamingReasoningTagScrubber } from '@/lib/server/chat-execution/reasoning-tag-scrubber'
33
34
 
34
35
  // ---------------------------------------------------------------------------
35
36
  // LangGraph event kind constants
@@ -92,10 +93,24 @@ export async function processIterationEvents(opts: ProcessIterationEventsOpts):
92
93
  let toolEndCount = 0
93
94
  const iterationText = { value: '' }
94
95
  const toolPerfEnds = new Map<string, (extra?: Record<string, unknown>) => number>()
96
+ const reasoningTagScrubber = new StreamingReasoningTagScrubber()
95
97
 
96
98
  /** Interval for progress checkpoint nudges */
97
99
  const PROGRESS_CHECK_INTERVAL = 10
98
100
 
101
+ const emitThinking = (text: string) => {
102
+ if (!text) return
103
+ state.accumulatedThinking += text
104
+ write(`data: ${JSON.stringify({ t: 'thinking', text })}\n\n`)
105
+ }
106
+
107
+ const appendScrubbedText = (text: string) => {
108
+ if (!text) return
109
+ const scrubbed = reasoningTagScrubber.feed(text)
110
+ if (scrubbed.reasoning) emitThinking(scrubbed.reasoning)
111
+ if (scrubbed.visible) state.appendText(scrubbed.visible, iterationText, write)
112
+ }
113
+
99
114
  for await (const event of eventStream) {
100
115
  const kind = event.event
101
116
 
@@ -104,27 +119,24 @@ export async function processIterationEvents(opts: ProcessIterationEventsOpts):
104
119
  const chunk = event.data?.chunk
105
120
  const reasoningDelta = extractReasoningContentDelta(chunk?.additional_kwargs as Record<string, unknown> | undefined)
106
121
  if (reasoningDelta) {
107
- state.accumulatedThinking += reasoningDelta
108
- write(`data: ${JSON.stringify({ t: 'thinking', text: reasoningDelta })}\n\n`)
122
+ emitThinking(reasoningDelta)
109
123
  write(`data: ${JSON.stringify({ t: 'md', text: JSON.stringify(createReasoningContentMetadata(reasoningDelta)) })}\n\n`)
110
124
  }
111
125
  if (chunk?.content) {
112
126
  if (Array.isArray(chunk.content)) {
113
127
  for (const block of chunk.content) {
114
128
  if (block.type === 'thinking' && block.thinking) {
115
- state.accumulatedThinking += block.thinking
116
- write(`data: ${JSON.stringify({ t: 'thinking', text: block.thinking })}\n\n`)
129
+ emitThinking(block.thinking)
117
130
  } else if (typeof block.text === 'string' && block.text.startsWith('[[thinking]]')) {
118
- state.accumulatedThinking += block.text.slice(12)
119
- write(`data: ${JSON.stringify({ t: 'thinking', text: block.text.slice(12) })}\n\n`)
131
+ emitThinking(block.text.slice(12))
120
132
  } else if (block.text) {
121
- state.appendText(block.text, iterationText, write)
133
+ appendScrubbedText(block.text)
122
134
  }
123
135
  }
124
136
  } else {
125
137
  const text = typeof chunk.content === 'string' ? chunk.content : ''
126
138
  if (text) {
127
- state.appendText(text, iterationText, write)
139
+ appendScrubbedText(text)
128
140
  }
129
141
  }
130
142
  }
@@ -351,6 +363,10 @@ export async function processIterationEvents(opts: ProcessIterationEventsOpts):
351
363
  }
352
364
  }
353
365
 
366
+ const finalScrubbed = reasoningTagScrubber.flush()
367
+ if (finalScrubbed.reasoning) emitThinking(finalScrubbed.reasoning)
368
+ if (finalScrubbed.visible) state.appendText(finalScrubbed.visible, iterationText, write)
369
+
354
370
  return {
355
371
  reachedExecutionBoundary,
356
372
  executionFollowthroughReason,
@@ -0,0 +1,117 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import { StreamingReasoningTagScrubber } from './reasoning-tag-scrubber'
4
+ import { ChatTurnState } from './chat-turn-state'
5
+ import { processIterationEvents } from './iteration-event-handler'
6
+
7
+ function drive(deltas: string[]) {
8
+ const scrubber = new StreamingReasoningTagScrubber()
9
+ let visible = ''
10
+ let reasoning = ''
11
+ for (const delta of deltas) {
12
+ const result = scrubber.feed(delta)
13
+ visible += result.visible
14
+ reasoning += result.reasoning
15
+ }
16
+ const final = scrubber.flush()
17
+ visible += final.visible
18
+ reasoning += final.reasoning
19
+ return { visible, reasoning }
20
+ }
21
+
22
+ test('strips closed reasoning pairs from visible text and captures reasoning', () => {
23
+ assert.deepEqual(
24
+ drive(['Hello <think>private note</think> world']),
25
+ { visible: 'Hello world', reasoning: 'private note' },
26
+ )
27
+ })
28
+
29
+ test('handles all supported tag variants case-insensitively', () => {
30
+ assert.deepEqual(
31
+ drive(['<THINKING>a</Thinking><reasoning>b</reasoning><thought>c</thought><REASONING_SCRATCHPAD>d</REASONING_SCRATCHPAD>done']),
32
+ { visible: 'done', reasoning: 'abcd' },
33
+ )
34
+ })
35
+
36
+ test('holds split opening tags across stream deltas', () => {
37
+ assert.deepEqual(
38
+ drive(['<', 'think>reasoning</think>', 'done']),
39
+ { visible: 'done', reasoning: 'reasoning' },
40
+ )
41
+ })
42
+
43
+ test('captures reasoning until a split close tag resolves', () => {
44
+ assert.deepEqual(
45
+ drive(['<think>first', ' second</th', 'ink>answer']),
46
+ { visible: 'answer', reasoning: 'first second' },
47
+ )
48
+ })
49
+
50
+ test('does not treat a mid-line prose mention as an open reasoning block', () => {
51
+ const text = 'Use the <think> element for examples'
52
+ assert.deepEqual(drive([text]), { visible: text, reasoning: '' })
53
+ })
54
+
55
+ test('does strip a bounded mid-line pair because it is model reasoning markup', () => {
56
+ assert.deepEqual(
57
+ drive(['Use <think>hidden</think>this answer']),
58
+ { visible: 'Use this answer', reasoning: 'hidden' },
59
+ )
60
+ })
61
+
62
+ test('drops unterminated reasoning content at flush after capturing streamed content', () => {
63
+ assert.deepEqual(
64
+ drive(['Visible\n<think>private reasoning with no close']),
65
+ { visible: 'Visible\n', reasoning: 'private reasoning with no close' },
66
+ )
67
+ })
68
+
69
+ test('reset clears an interrupted reasoning block and buffered partial tags', () => {
70
+ const scrubber = new StreamingReasoningTagScrubber()
71
+ assert.deepEqual(scrubber.feed('<think>hanging'), { visible: '', reasoning: 'hanging' })
72
+ scrubber.reset()
73
+ assert.deepEqual(scrubber.feed('fresh<'), { visible: 'fresh', reasoning: '' })
74
+ scrubber.reset()
75
+ assert.deepEqual(scrubber.feed('content'), { visible: 'content', reasoning: '' })
76
+ })
77
+
78
+ test('processIterationEvents routes split reasoning tags away from visible deltas', async () => {
79
+ async function* eventStream() {
80
+ yield { event: 'on_chat_model_stream', data: { chunk: { content: '<' } } }
81
+ yield { event: 'on_chat_model_stream', data: { chunk: { content: 'think>private' } } }
82
+ yield { event: 'on_chat_model_stream', data: { chunk: { content: ' reasoning</th' } } }
83
+ yield { event: 'on_chat_model_stream', data: { chunk: { content: 'ink>done' } } }
84
+ }
85
+
86
+ const state = new ChatTurnState()
87
+ const writes: string[] = []
88
+ const outcome = await processIterationEvents({
89
+ eventStream: eventStream(),
90
+ state,
91
+ timers: {
92
+ armIdleWatchdog: () => undefined,
93
+ clearIdleWatchdog: () => undefined,
94
+ clearRequiredToolKickoff: () => undefined,
95
+ } as never,
96
+ loopTracker: {} as never,
97
+ toolEventTracker: {} as never,
98
+ session: { id: 'sess_reasoning_tags', provider: 'openai', model: 'gpt-4o-mini' } as never,
99
+ message: 'answer briefly',
100
+ write: (data: string) => writes.push(data),
101
+ sessionExtensions: [],
102
+ boundedExternalExecutionTask: false,
103
+ toolToExtensionMap: {},
104
+ iterationController: new AbortController(),
105
+ })
106
+
107
+ assert.equal(outcome.iterationText, 'done')
108
+ assert.equal(state.fullText, 'done')
109
+ assert.equal(state.accumulatedThinking, 'private reasoning')
110
+
111
+ const rendered = writes.join('')
112
+ assert.match(rendered, /"t":"thinking"/)
113
+ assert.match(rendered, /"text":"private"/)
114
+ assert.match(rendered, /"text":" reasoning"/)
115
+ assert.doesNotMatch(rendered, /"t":"d"[^\n]*private/)
116
+ assert.doesNotMatch(rendered, /<think>|<\/think>/i)
117
+ })
@@ -0,0 +1,219 @@
1
+ export interface ReasoningTagScrubResult {
2
+ visible: string
3
+ reasoning: string
4
+ }
5
+
6
+ const TAG_NAMES = [
7
+ 'think',
8
+ 'thinking',
9
+ 'reasoning',
10
+ 'thought',
11
+ 'reasoning_scratchpad',
12
+ ] as const
13
+
14
+ const OPEN_TAGS = TAG_NAMES.map((name) => `<${name}>`)
15
+ const CLOSE_TAGS = TAG_NAMES.map((name) => `</${name}>`)
16
+ const MAX_TAG_LENGTH = Math.max(...OPEN_TAGS.map((tag) => tag.length), ...CLOSE_TAGS.map((tag) => tag.length))
17
+
18
+ function isWhitespace(text: string): boolean {
19
+ for (let i = 0; i < text.length; i++) {
20
+ const code = text.charCodeAt(i)
21
+ if (code !== 9 && code !== 10 && code !== 11 && code !== 12 && code !== 13 && code !== 32) return false
22
+ }
23
+ return true
24
+ }
25
+
26
+ function matchTagAt(lowerText: string, index: number, tags: string[]): number {
27
+ for (const tag of tags) {
28
+ if (lowerText.startsWith(tag, index)) return tag.length
29
+ }
30
+ return 0
31
+ }
32
+
33
+ function findFirstTag(text: string, tags: string[]): { index: number; length: number } {
34
+ const lowerText = text.toLowerCase()
35
+ let bestIndex = -1
36
+ let bestLength = 0
37
+ for (const tag of tags) {
38
+ const index = lowerText.indexOf(tag)
39
+ if (index !== -1 && (bestIndex === -1 || index < bestIndex)) {
40
+ bestIndex = index
41
+ bestLength = tag.length
42
+ }
43
+ }
44
+ return { index: bestIndex, length: bestLength }
45
+ }
46
+
47
+ function maxPartialSuffix(text: string, tags: string[]): number {
48
+ const lowerText = text.toLowerCase()
49
+ const maxLength = Math.min(MAX_TAG_LENGTH - 1, lowerText.length)
50
+ let best = 0
51
+ for (let length = 1; length <= maxLength; length++) {
52
+ const suffix = lowerText.slice(-length)
53
+ if (tags.some((tag) => tag.startsWith(suffix))) best = length
54
+ }
55
+ return best
56
+ }
57
+
58
+ function stripOrphanCloseTags(text: string): string {
59
+ const lowerText = text.toLowerCase()
60
+ let output = ''
61
+ let index = 0
62
+ while (index < text.length) {
63
+ const closeLength = matchTagAt(lowerText, index, CLOSE_TAGS)
64
+ if (closeLength > 0) {
65
+ index += closeLength
66
+ while (index < text.length && isWhitespace(text[index])) index++
67
+ continue
68
+ }
69
+ output += text[index]
70
+ index++
71
+ }
72
+ return output
73
+ }
74
+
75
+ export class StreamingReasoningTagScrubber {
76
+ private inBlock = false
77
+ private buffer = ''
78
+ private lastVisibleEndedNewline = true
79
+
80
+ reset(): void {
81
+ this.inBlock = false
82
+ this.buffer = ''
83
+ this.lastVisibleEndedNewline = true
84
+ }
85
+
86
+ feed(text: string): ReasoningTagScrubResult {
87
+ if (!text) return { visible: '', reasoning: '' }
88
+ let buffer = this.buffer + text
89
+ this.buffer = ''
90
+ let visible = ''
91
+ let reasoning = ''
92
+
93
+ while (buffer) {
94
+ if (this.inBlock) {
95
+ const close = findFirstTag(buffer, CLOSE_TAGS)
96
+ if (close.index === -1) {
97
+ const held = maxPartialSuffix(buffer, CLOSE_TAGS)
98
+ const captureEnd = held ? buffer.length - held : buffer.length
99
+ reasoning += buffer.slice(0, captureEnd)
100
+ this.buffer = held ? buffer.slice(-held) : ''
101
+ return { visible, reasoning }
102
+ }
103
+ reasoning += buffer.slice(0, close.index)
104
+ buffer = buffer.slice(close.index + close.length)
105
+ this.inBlock = false
106
+ continue
107
+ }
108
+
109
+ const pair = this.findEarliestClosedPair(buffer)
110
+ const open = this.findOpenAtBoundary(buffer)
111
+
112
+ if (pair && (open.index === -1 || pair.start <= open.index)) {
113
+ const preceding = stripOrphanCloseTags(buffer.slice(0, pair.start))
114
+ if (preceding) {
115
+ visible += preceding
116
+ this.lastVisibleEndedNewline = preceding.endsWith('\n')
117
+ }
118
+ reasoning += buffer.slice(pair.contentStart, pair.contentEnd)
119
+ buffer = buffer.slice(pair.end)
120
+ continue
121
+ }
122
+
123
+ if (open.index !== -1) {
124
+ const preceding = stripOrphanCloseTags(buffer.slice(0, open.index))
125
+ if (preceding) {
126
+ visible += preceding
127
+ this.lastVisibleEndedNewline = preceding.endsWith('\n')
128
+ }
129
+ this.inBlock = true
130
+ buffer = buffer.slice(open.index + open.length)
131
+ continue
132
+ }
133
+
134
+ const held = Math.max(maxPartialSuffix(buffer, OPEN_TAGS), maxPartialSuffix(buffer, CLOSE_TAGS))
135
+ const emitText = held ? buffer.slice(0, -held) : buffer
136
+ this.buffer = held ? buffer.slice(-held) : ''
137
+ if (emitText) {
138
+ const cleaned = stripOrphanCloseTags(emitText)
139
+ if (cleaned) {
140
+ visible += cleaned
141
+ this.lastVisibleEndedNewline = cleaned.endsWith('\n')
142
+ }
143
+ }
144
+ return { visible, reasoning }
145
+ }
146
+
147
+ return { visible, reasoning }
148
+ }
149
+
150
+ flush(): ReasoningTagScrubResult {
151
+ if (this.inBlock) {
152
+ this.inBlock = false
153
+ this.buffer = ''
154
+ return { visible: '', reasoning: '' }
155
+ }
156
+ const tail = stripOrphanCloseTags(this.buffer)
157
+ this.buffer = ''
158
+ if (tail) this.lastVisibleEndedNewline = tail.endsWith('\n')
159
+ return { visible: tail, reasoning: '' }
160
+ }
161
+
162
+ private findEarliestClosedPair(text: string): {
163
+ start: number
164
+ contentStart: number
165
+ contentEnd: number
166
+ end: number
167
+ } | null {
168
+ const lowerText = text.toLowerCase()
169
+ let best: { start: number; contentStart: number; contentEnd: number; end: number } | null = null
170
+ for (const name of TAG_NAMES) {
171
+ const openTag = `<${name}>`
172
+ const closeTag = `</${name}>`
173
+ let searchFrom = 0
174
+ while (searchFrom < lowerText.length) {
175
+ const start = lowerText.indexOf(openTag, searchFrom)
176
+ if (start === -1) break
177
+ const contentStart = start + openTag.length
178
+ const contentEnd = lowerText.indexOf(closeTag, contentStart)
179
+ if (contentEnd !== -1) {
180
+ const candidate = {
181
+ start,
182
+ contentStart,
183
+ contentEnd,
184
+ end: contentEnd + closeTag.length,
185
+ }
186
+ if (!best || candidate.start < best.start) best = candidate
187
+ break
188
+ }
189
+ searchFrom = contentStart
190
+ }
191
+ }
192
+ return best
193
+ }
194
+
195
+ private findOpenAtBoundary(text: string): { index: number; length: number } {
196
+ const lowerText = text.toLowerCase()
197
+ let best = { index: -1, length: 0 }
198
+ for (const openTag of OPEN_TAGS) {
199
+ let searchFrom = 0
200
+ while (searchFrom < lowerText.length) {
201
+ const index = lowerText.indexOf(openTag, searchFrom)
202
+ if (index === -1) break
203
+ if (this.isBlockBoundary(text.slice(0, index))) {
204
+ if (best.index === -1 || index < best.index) best = { index, length: openTag.length }
205
+ break
206
+ }
207
+ searchFrom = index + openTag.length
208
+ }
209
+ }
210
+ return best
211
+ }
212
+
213
+ private isBlockBoundary(preceding: string): boolean {
214
+ if (!preceding) return this.lastVisibleEndedNewline
215
+ const lastNewline = Math.max(preceding.lastIndexOf('\n'), preceding.lastIndexOf('\r'))
216
+ if (lastNewline !== -1) return isWhitespace(preceding.slice(lastNewline + 1))
217
+ return this.lastVisibleEndedNewline && isWhitespace(preceding)
218
+ }
219
+ }
@@ -0,0 +1,159 @@
1
+ import assert from 'node:assert/strict'
2
+ import test, { afterEach } from 'node:test'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import os from 'node:os'
6
+
7
+ import { getExtensionManager } from './extensions'
8
+ import {
9
+ inspectExtensionLocalFolder,
10
+ listExtensionLocalFolderEntries,
11
+ listExtensionManagedResources,
12
+ reconcileExtensionManagedResources,
13
+ setExtensionLocalFolderConfig,
14
+ } from './extension-managed-resources'
15
+ import { loadAgents, loadSchedules, loadSettings, saveAgents, saveSchedules, saveSettings } from './storage'
16
+
17
+ const originalAgents = loadAgents()
18
+ const originalSchedules = loadSchedules()
19
+ const originalSettings = loadSettings()
20
+
21
+ let seq = 0
22
+
23
+ function extensionId(prefix: string): string {
24
+ seq += 1
25
+ return `${prefix}_${Date.now()}_${seq}`
26
+ }
27
+
28
+ afterEach(() => {
29
+ saveAgents(originalAgents)
30
+ saveSchedules(originalSchedules)
31
+ saveSettings(originalSettings)
32
+ })
33
+
34
+ test('managed resources summary and reconcile create extension-owned agents and schedules', () => {
35
+ const id = extensionId('managed_resources')
36
+ getExtensionManager().registerBuiltin(id, {
37
+ name: 'Managed Resource Fixture',
38
+ description: 'Declares resources for tests.',
39
+ managedResources: {
40
+ agents: [
41
+ {
42
+ agentKey: 'researcher',
43
+ displayName: 'Managed Researcher',
44
+ description: 'Research managed by an extension.',
45
+ systemPrompt: 'Research carefully.',
46
+ provider: 'openai',
47
+ model: 'gpt-4o-mini',
48
+ capabilities: ['research'],
49
+ extensions: ['web'],
50
+ },
51
+ ],
52
+ routines: [
53
+ {
54
+ routineKey: 'daily-digest',
55
+ title: 'Daily Digest',
56
+ assigneeRef: { resourceKind: 'agent', resourceKey: 'researcher' },
57
+ taskPrompt: 'Prepare a digest.',
58
+ triggers: [{ kind: 'schedule', cronExpression: '0 9 * * *', timezone: 'UTC' }],
59
+ },
60
+ ],
61
+ gatewayPlatforms: [
62
+ {
63
+ platformKey: 'openai-api',
64
+ displayName: 'OpenAI-compatible API',
65
+ transport: 'http',
66
+ endpoint: 'http://127.0.0.1:8642/v1',
67
+ },
68
+ ],
69
+ setupChecks: [
70
+ { checkKey: 'api-key', displayName: 'API key configured', kind: 'env', target: 'OPENAI_API_KEY' },
71
+ ],
72
+ },
73
+ })
74
+
75
+ const before = listExtensionManagedResources().extensions.find((entry) => entry.extensionId === id)
76
+ assert.ok(before)
77
+ assert.equal(before.agents[0].status, 'missing')
78
+ assert.equal(before.schedules[0].status, 'missing_ref')
79
+
80
+ const result = reconcileExtensionManagedResources(id)
81
+ assert.equal(result.createdAgents.length, 1)
82
+ assert.equal(result.createdSchedules.length, 1)
83
+ assert.deepEqual(result.skipped, [])
84
+
85
+ const agents = loadAgents()
86
+ const agent = agents[result.createdAgents[0]]
87
+ assert.equal(agent.name, 'Managed Researcher')
88
+ assert.equal(agent.managedByExtension?.extensionId, id)
89
+ assert.equal(agent.managedByExtension?.resourceKey, 'researcher')
90
+ assert.ok(agent.extensions?.includes(id))
91
+ assert.ok(agent.extensions?.includes('web'))
92
+
93
+ const schedules = loadSchedules()
94
+ const schedule = schedules[result.createdSchedules[0]]
95
+ assert.equal(schedule.name, 'Daily Digest')
96
+ assert.equal(schedule.agentId, agent.id)
97
+ assert.equal(schedule.scheduleType, 'cron')
98
+ assert.equal(schedule.cron, '0 9 * * *')
99
+ assert.equal(schedule.status, 'paused')
100
+ assert.equal(schedule.managedByExtension?.resourceKey, 'daily-digest')
101
+
102
+ const after = listExtensionManagedResources().extensions.find((entry) => entry.extensionId === id)
103
+ assert.equal(after?.agents[0].status, 'resolved')
104
+ assert.equal(after?.schedules[0].status, 'resolved')
105
+ assert.equal(after?.gatewayPlatforms.length, 1)
106
+ assert.equal(after?.setupChecks.length, 1)
107
+ })
108
+
109
+ test('local folder inspection and listing stay inside configured roots', async () => {
110
+ const id = extensionId('managed_folder')
111
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-managed-folder-'))
112
+ fs.mkdirSync(path.join(tempDir, 'inputs'))
113
+ fs.mkdirSync(path.join(tempDir, 'outputs'))
114
+ fs.writeFileSync(path.join(tempDir, 'inputs', 'brief.txt'), 'hello\n')
115
+
116
+ getExtensionManager().registerBuiltin(id, {
117
+ name: 'Managed Folder Fixture',
118
+ managedResources: {
119
+ localFolders: [
120
+ {
121
+ folderKey: 'workspace',
122
+ displayName: 'Workspace Folder',
123
+ access: 'readWrite',
124
+ requiredDirectories: ['inputs', 'outputs'],
125
+ requiredFiles: ['inputs/brief.txt'],
126
+ },
127
+ ],
128
+ },
129
+ })
130
+
131
+ setExtensionLocalFolderConfig({
132
+ extensionId: id,
133
+ folderKey: 'workspace',
134
+ path: tempDir,
135
+ })
136
+
137
+ const status = await inspectExtensionLocalFolder({ extensionId: id, folderKey: 'workspace' })
138
+ assert.equal(status.healthy, true)
139
+ assert.equal(status.readable, true)
140
+ assert.equal(status.writable, true)
141
+
142
+ const listing = await listExtensionLocalFolderEntries({
143
+ extensionId: id,
144
+ folderKey: 'workspace',
145
+ recursive: true,
146
+ })
147
+ assert.ok(listing.entries.some((entry) => entry.path === 'inputs/brief.txt' && entry.kind === 'file'))
148
+
149
+ await assert.rejects(
150
+ () => listExtensionLocalFolderEntries({
151
+ extensionId: id,
152
+ folderKey: 'workspace',
153
+ relativePath: '../outside',
154
+ }),
155
+ /inside the configured root|traversal/,
156
+ )
157
+
158
+ fs.rmSync(tempDir, { recursive: true, force: true })
159
+ })