@swarmclawai/swarmclaw 1.2.1 → 1.2.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 (149) hide show
  1. package/README.md +16 -85
  2. package/bin/server-cmd.js +64 -1
  3. package/package.json +2 -2
  4. package/skills/coding-agent/SKILL.md +111 -0
  5. package/skills/github/SKILL.md +140 -0
  6. package/skills/nano-banana-pro/SKILL.md +62 -0
  7. package/skills/nano-banana-pro/scripts/generate_image.py +235 -0
  8. package/skills/nano-pdf/SKILL.md +53 -0
  9. package/skills/openai-image-gen/SKILL.md +78 -0
  10. package/skills/openai-image-gen/scripts/gen.py +328 -0
  11. package/skills/resourceful-problem-solving/SKILL.md +49 -0
  12. package/skills/skill-creator/SKILL.md +147 -0
  13. package/skills/skill-creator/scripts/init_skill.py +378 -0
  14. package/skills/skill-creator/scripts/quick_validate.py +159 -0
  15. package/skills/summarize/SKILL.md +77 -0
  16. package/src/app/api/auth/route.ts +20 -5
  17. package/src/app/api/chats/[id]/devserver/route.ts +13 -19
  18. package/src/app/api/chats/[id]/messages/route.ts +13 -15
  19. package/src/app/api/chats/[id]/route.ts +9 -10
  20. package/src/app/api/chats/[id]/stop/route.ts +5 -7
  21. package/src/app/api/chats/messages-route.test.ts +8 -6
  22. package/src/app/api/chats/route.ts +9 -10
  23. package/src/app/api/ip/route.ts +2 -2
  24. package/src/app/api/preview-server/route.ts +1 -1
  25. package/src/app/api/projects/[id]/route.ts +7 -46
  26. package/src/cli/server-cmd.test.js +74 -0
  27. package/src/components/chat/chat-area.tsx +45 -23
  28. package/src/components/chat/message-bubble.test.ts +35 -0
  29. package/src/components/chat/message-bubble.tsx +19 -9
  30. package/src/components/chat/message-list.tsx +37 -3
  31. package/src/components/input/chat-input.tsx +34 -14
  32. package/src/components/openclaw/openclaw-deploy-panel.tsx +4 -0
  33. package/src/instrumentation.ts +1 -1
  34. package/src/lib/chat/assistant-render-id.ts +3 -0
  35. package/src/lib/chat/chat-streaming-state.test.ts +42 -3
  36. package/src/lib/chat/chat-streaming-state.ts +20 -8
  37. package/src/lib/chat/queued-message-queue.test.ts +23 -1
  38. package/src/lib/chat/queued-message-queue.ts +11 -2
  39. package/src/lib/providers/cli-utils.test.ts +124 -0
  40. package/src/lib/server/activity/activity-log.ts +21 -0
  41. package/src/lib/server/agents/agent-availability.test.ts +10 -5
  42. package/src/lib/server/agents/agent-cascade.ts +79 -59
  43. package/src/lib/server/agents/agent-registry.ts +3 -1
  44. package/src/lib/server/agents/agent-repository.ts +90 -0
  45. package/src/lib/server/agents/delegation-job-repository.ts +53 -0
  46. package/src/lib/server/agents/delegation-jobs.ts +11 -4
  47. package/src/lib/server/agents/guardian-checkpoint-repository.ts +35 -0
  48. package/src/lib/server/agents/guardian.ts +2 -2
  49. package/src/lib/server/agents/main-agent-loop.ts +10 -3
  50. package/src/lib/server/agents/main-loop-state-repository.ts +38 -0
  51. package/src/lib/server/agents/subagent-runtime.ts +9 -6
  52. package/src/lib/server/agents/subagent-swarm.ts +3 -2
  53. package/src/lib/server/agents/task-session.ts +3 -4
  54. package/src/lib/server/approvals/approval-repository.ts +30 -0
  55. package/src/lib/server/autonomy/supervisor-incident-repository.ts +42 -0
  56. package/src/lib/server/chat-execution/chat-execution-types.ts +38 -0
  57. package/src/lib/server/chat-execution/chat-execution-utils.ts +1 -1
  58. package/src/lib/server/chat-execution/chat-execution.ts +84 -1926
  59. package/src/lib/server/chat-execution/chat-turn-finalization.ts +620 -0
  60. package/src/lib/server/chat-execution/chat-turn-partial-persistence.ts +221 -0
  61. package/src/lib/server/chat-execution/chat-turn-preflight.ts +133 -0
  62. package/src/lib/server/chat-execution/chat-turn-preparation.ts +817 -0
  63. package/src/lib/server/chat-execution/chat-turn-stream-execution.ts +296 -0
  64. package/src/lib/server/chat-execution/chat-turn-tool-routing.ts +5 -5
  65. package/src/lib/server/chat-execution/message-classifier.test.ts +329 -0
  66. package/src/lib/server/chat-execution/post-stream-finalization.ts +1 -1
  67. package/src/lib/server/chat-execution/prompt-builder.ts +11 -0
  68. package/src/lib/server/chat-execution/prompt-sections.ts +5 -6
  69. package/src/lib/server/chat-execution/situational-awareness.ts +12 -7
  70. package/src/lib/server/chat-execution/stream-agent-chat.ts +16 -13
  71. package/src/lib/server/chatrooms/chatroom-repository.ts +32 -0
  72. package/src/lib/server/connectors/connector-repository.ts +58 -0
  73. package/src/lib/server/connectors/runtime-state.test.ts +117 -0
  74. package/src/lib/server/credentials/credential-repository.ts +7 -0
  75. package/src/lib/server/gateways/gateway-profile-repository.ts +4 -0
  76. package/src/lib/server/memory/memory-abstract.test.ts +59 -0
  77. package/src/lib/server/missions/mission-repository.ts +74 -0
  78. package/src/lib/server/missions/mission-service/actions.ts +6 -0
  79. package/src/lib/server/missions/mission-service/bindings.ts +9 -0
  80. package/src/lib/server/missions/mission-service/context.ts +4 -0
  81. package/src/lib/server/missions/mission-service/core.ts +2269 -0
  82. package/src/lib/server/missions/mission-service/queries.ts +12 -0
  83. package/src/lib/server/missions/mission-service/recovery.ts +5 -0
  84. package/src/lib/server/missions/mission-service/ticks.ts +9 -0
  85. package/src/lib/server/missions/mission-service.test.ts +9 -2
  86. package/src/lib/server/missions/mission-service.ts +6 -2266
  87. package/src/lib/server/openclaw/deploy.test.ts +42 -3
  88. package/src/lib/server/openclaw/deploy.ts +26 -12
  89. package/src/lib/server/persistence/repository-utils.ts +154 -0
  90. package/src/lib/server/persistence/storage-context.ts +51 -0
  91. package/src/lib/server/persistence/transaction.ts +1 -0
  92. package/src/lib/server/projects/project-repository.ts +36 -0
  93. package/src/lib/server/projects/project-service.ts +79 -0
  94. package/src/lib/server/protocols/protocol-normalization.test.ts +6 -4
  95. package/src/lib/server/runtime/alert-dispatch.ts +1 -1
  96. package/src/lib/server/runtime/daemon-policy.ts +1 -1
  97. package/src/lib/server/runtime/daemon-state/core.ts +1570 -0
  98. package/src/lib/server/runtime/daemon-state/health.ts +6 -0
  99. package/src/lib/server/runtime/daemon-state/policy.ts +7 -0
  100. package/src/lib/server/runtime/daemon-state/supervisor.ts +6 -0
  101. package/src/lib/server/runtime/daemon-state.test.ts +48 -0
  102. package/src/lib/server/runtime/daemon-state.ts +3 -1470
  103. package/src/lib/server/runtime/estop-repository.ts +4 -0
  104. package/src/lib/server/runtime/estop.ts +3 -1
  105. package/src/lib/server/runtime/heartbeat-service.test.ts +2 -2
  106. package/src/lib/server/runtime/heartbeat-service.ts +55 -34
  107. package/src/lib/server/runtime/heartbeat-wake.ts +6 -4
  108. package/src/lib/server/runtime/idle-window.ts +2 -2
  109. package/src/lib/server/runtime/network.ts +11 -0
  110. package/src/lib/server/runtime/orchestrator-events.ts +2 -2
  111. package/src/lib/server/runtime/queue/claims.ts +4 -0
  112. package/src/lib/server/runtime/queue/core.ts +2079 -0
  113. package/src/lib/server/runtime/queue/execution.ts +7 -0
  114. package/src/lib/server/runtime/queue/followups.ts +4 -0
  115. package/src/lib/server/runtime/queue/queries.ts +12 -0
  116. package/src/lib/server/runtime/queue/recovery.ts +7 -0
  117. package/src/lib/server/runtime/queue-recovery.test.ts +48 -13
  118. package/src/lib/server/runtime/queue-repository.ts +17 -0
  119. package/src/lib/server/runtime/queue.ts +5 -2061
  120. package/src/lib/server/runtime/run-ledger.ts +6 -5
  121. package/src/lib/server/runtime/run-repository.ts +73 -0
  122. package/src/lib/server/runtime/runtime-lock-repository.ts +8 -0
  123. package/src/lib/server/runtime/runtime-settings.ts +1 -1
  124. package/src/lib/server/runtime/runtime-state.ts +99 -0
  125. package/src/lib/server/runtime/scheduler.ts +4 -2
  126. package/src/lib/server/runtime/session-run-manager/cancellation.ts +157 -0
  127. package/src/lib/server/runtime/session-run-manager/drain.ts +246 -0
  128. package/src/lib/server/runtime/session-run-manager/enqueue.ts +287 -0
  129. package/src/lib/server/runtime/session-run-manager/queries.ts +117 -0
  130. package/src/lib/server/runtime/session-run-manager/recovery.ts +238 -0
  131. package/src/lib/server/runtime/session-run-manager/state.ts +441 -0
  132. package/src/lib/server/runtime/session-run-manager/types.ts +74 -0
  133. package/src/lib/server/runtime/session-run-manager.ts +72 -1377
  134. package/src/lib/server/runtime/watch-job-repository.ts +35 -0
  135. package/src/lib/server/runtime/watch-jobs.ts +3 -1
  136. package/src/lib/server/schedules/schedule-repository.ts +42 -0
  137. package/src/lib/server/sessions/session-repository.ts +85 -0
  138. package/src/lib/server/settings/settings-repository.ts +25 -0
  139. package/src/lib/server/skills/skill-discovery.test.ts +2 -2
  140. package/src/lib/server/skills/skill-discovery.ts +2 -2
  141. package/src/lib/server/skills/skill-repository.ts +14 -0
  142. package/src/lib/server/storage.ts +13 -24
  143. package/src/lib/server/tasks/task-repository.ts +54 -0
  144. package/src/lib/server/usage/usage-repository.ts +30 -0
  145. package/src/lib/server/webhooks/webhook-repository.ts +10 -0
  146. package/src/lib/strip-internal-metadata.test.ts +42 -41
  147. package/src/stores/use-chat-store.test.ts +54 -0
  148. package/src/stores/use-chat-store.ts +21 -5
  149. /package/{bundled-skills → skills}/google-workspace/SKILL.md +0 -0
@@ -43,16 +43,17 @@ describe('chat-streaming-state', () => {
43
43
  localStreaming: true,
44
44
  hasLiveArtifacts: true,
45
45
  assistantRenderId: 'render-1',
46
+ showLiveRow: true,
47
+ syntheticAssistant: messages[1],
46
48
  } as StreamingAwareMessageListOptions),
47
49
  [
48
50
  { role: 'user', text: 'hello', time: 1 },
49
51
  {
50
52
  role: 'assistant',
51
- text: 'final answer',
52
- time: 0,
53
+ text: 'partial',
54
+ time: 2,
53
55
  kind: 'chat',
54
56
  streaming: true,
55
- thinking: 'working...',
56
57
  clientRenderId: 'render-1',
57
58
  },
58
59
  ],
@@ -69,6 +70,7 @@ describe('chat-streaming-state', () => {
69
70
  localStreaming: true,
70
71
  hasLiveArtifacts: true,
71
72
  assistantRenderId: 'render-2',
73
+ showLiveRow: true,
72
74
  } as StreamingAwareMessageListOptions),
73
75
  [
74
76
  { role: 'user', text: 'hello', time: 1 },
@@ -84,6 +86,43 @@ describe('chat-streaming-state', () => {
84
86
  )
85
87
  })
86
88
 
89
+ it('can show a synthetic live row for server-driven runs using the latest persisted streaming artifact', () => {
90
+ const messages: Message[] = [
91
+ { role: 'user', text: 'queued follow-up', time: 1 },
92
+ {
93
+ role: 'assistant',
94
+ text: 'Drafting the handoff now.',
95
+ time: 2,
96
+ streaming: true,
97
+ thinking: 'Collecting the completion details',
98
+ toolEvents: [{ name: 'send_file', input: '{}', output: '/api/uploads/deck.pdf' }],
99
+ },
100
+ ]
101
+
102
+ assert.deepEqual(
103
+ buildStreamingAwareMessageList(messages, {
104
+ localStreaming: true,
105
+ hasLiveArtifacts: false,
106
+ assistantRenderId: 'render-server',
107
+ showLiveRow: true,
108
+ syntheticAssistant: messages[1],
109
+ }),
110
+ [
111
+ { role: 'user', text: 'queued follow-up', time: 1 },
112
+ {
113
+ role: 'assistant',
114
+ text: 'Drafting the handoff now.',
115
+ time: 2,
116
+ kind: 'chat',
117
+ streaming: true,
118
+ thinking: 'Collecting the completion details',
119
+ toolEvents: [{ name: 'send_file', input: '{}', output: '/api/uploads/deck.pdf' }],
120
+ clientRenderId: 'render-server',
121
+ },
122
+ ],
123
+ )
124
+ })
125
+
87
126
  it('replaces trailing streaming assistant messages with the completed assistant message', () => {
88
127
  const messages: Message[] = [
89
128
  { role: 'user', text: 'hello', time: 1 },
@@ -10,6 +10,8 @@ export interface StreamingAwareMessageListOptions {
10
10
  localStreaming: boolean
11
11
  hasLiveArtifacts: boolean
12
12
  assistantRenderId?: string | null
13
+ showLiveRow?: boolean
14
+ syntheticAssistant?: Partial<Message> | null
13
15
  }
14
16
 
15
17
  function isStreamingAssistantMessage(
@@ -27,13 +29,13 @@ function isStreamingAssistantMessage(
27
29
 
28
30
  export function shouldHidePersistedStreamingAssistantMessage(
29
31
  message: Message,
30
- opts: { localStreaming: boolean; hasLiveArtifacts: boolean },
32
+ opts: { localStreaming: boolean; hasLiveArtifacts: boolean; showLiveRow?: boolean },
31
33
  ): boolean {
34
+ const showLiveRow = opts.showLiveRow ?? (opts.localStreaming && opts.hasLiveArtifacts)
32
35
  return (
33
- opts.localStreaming
36
+ showLiveRow
34
37
  && message.role === 'assistant'
35
38
  && message.streaming === true
36
- && opts.hasLiveArtifacts
37
39
  )
38
40
  }
39
41
 
@@ -41,22 +43,32 @@ export function buildStreamingAwareMessageList(
41
43
  messages: Message[],
42
44
  opts: StreamingAwareMessageListOptions,
43
45
  ): Message[] {
44
- const nextMessages = opts.localStreaming && opts.hasLiveArtifacts
46
+ const showLiveRow = opts.showLiveRow ?? (opts.localStreaming && opts.hasLiveArtifacts)
47
+ const nextMessages = showLiveRow
45
48
  ? messages.filter((message) => !shouldHidePersistedStreamingAssistantMessage(message, opts))
46
49
  : messages
47
50
 
48
- if (!opts.localStreaming || !opts.hasLiveArtifacts || !opts.assistantRenderId) {
51
+ if (!showLiveRow || !opts.assistantRenderId) {
49
52
  return nextMessages
50
53
  }
51
54
 
52
55
  const syntheticAssistantMessage: Message = {
53
56
  role: 'assistant',
54
- text: '',
55
- time: 0,
56
- kind: 'chat',
57
+ text: typeof opts.syntheticAssistant?.text === 'string' ? opts.syntheticAssistant.text : '',
58
+ time: typeof opts.syntheticAssistant?.time === 'number' ? opts.syntheticAssistant.time : 0,
59
+ kind: opts.syntheticAssistant?.kind || 'chat',
57
60
  streaming: true,
58
61
  clientRenderId: opts.assistantRenderId,
59
62
  }
63
+ if (typeof opts.syntheticAssistant?.thinking === 'string') {
64
+ syntheticAssistantMessage.thinking = opts.syntheticAssistant.thinking
65
+ }
66
+ if (Array.isArray(opts.syntheticAssistant?.toolEvents)) {
67
+ syntheticAssistantMessage.toolEvents = opts.syntheticAssistant.toolEvents
68
+ }
69
+ if (opts.syntheticAssistant?.source) {
70
+ syntheticAssistantMessage.source = opts.syntheticAssistant.source
71
+ }
60
72
 
61
73
  return [
62
74
  ...nextMessages,
@@ -27,7 +27,7 @@ describe('queued-message-queue', () => {
27
27
  it('replaces queued items only for the requested session', () => {
28
28
  const replaced = replaceQueuedMessagesForSession(queue, 'session-a', [
29
29
  { runId: 'q4', sessionId: 'session-a', text: 'replacement', queuedAt: 4, position: 1 },
30
- ])
30
+ ], { activeRunId: null })
31
31
  assert.deepEqual(
32
32
  listQueuedMessagesForSession(replaced, 'session-a').map((item) => item.runId),
33
33
  ['q4'],
@@ -38,6 +38,28 @@ describe('queued-message-queue', () => {
38
38
  )
39
39
  })
40
40
 
41
+ it('keeps only the newly active run as a sending placeholder when it disappears from the queue snapshot', () => {
42
+ const replaced = replaceQueuedMessagesForSession(queue, 'session-a', [
43
+ { runId: 'q3', sessionId: 'session-a', text: 'second a', queuedAt: 3, position: 1 },
44
+ ], { activeRunId: 'q1' })
45
+
46
+ assert.deepEqual(
47
+ listQueuedMessagesForSession(replaced, 'session-a').map((item) => [item.runId, item.sending === true]),
48
+ [['q1', true], ['q3', false]],
49
+ )
50
+ })
51
+
52
+ it('drops missing stale queue rows that are not the active run', () => {
53
+ const replaced = replaceQueuedMessagesForSession(queue, 'session-a', [
54
+ { runId: 'q3', sessionId: 'session-a', text: 'second a', queuedAt: 3, position: 1 },
55
+ ], { activeRunId: 'run-other' })
56
+
57
+ assert.deepEqual(
58
+ listQueuedMessagesForSession(replaced, 'session-a').map((item) => item.runId),
59
+ ['q3'],
60
+ )
61
+ })
62
+
41
63
  it('removes queued items by stable id', () => {
42
64
  assert.deepEqual(removeQueuedMessageById(queue, 'q2').map((item) => item.runId), ['q1', 'q3'])
43
65
  })
@@ -41,18 +41,27 @@ export function snapshotToQueuedMessages(snapshot: SessionQueueSnapshot): Queued
41
41
  return snapshot.items.map((item) => ({ ...item }))
42
42
  }
43
43
 
44
+ interface ReplaceQueuedMessagesOptions {
45
+ activeRunId?: string | null
46
+ }
47
+
44
48
  export function replaceQueuedMessagesForSession(
45
49
  queue: QueuedSessionMessage[],
46
50
  sessionId: string,
47
51
  nextItems: QueuedSessionMessage[],
52
+ options: ReplaceQueuedMessagesOptions = {},
48
53
  ): QueuedSessionMessage[] {
49
54
  const otherSessions = queue.filter((item) => item.sessionId !== sessionId)
50
55
  const previousForSession = queue.filter((item) => item.sessionId === sessionId && !item.sending)
51
56
  // Detect consumed messages: items in local state but not in server snapshot.
52
- // Keep them visible as "sending" so they don't vanish from the UI.
57
+ // Keep only the run that actually became active visible as "sending" so it
58
+ // doesn't vanish from the UI before the transcript refresh catches up.
53
59
  const nextRunIds = new Set(nextItems.map((item) => item.runId))
60
+ const activeRunId = typeof options.activeRunId === 'string' && options.activeRunId.trim()
61
+ ? options.activeRunId
62
+ : null
54
63
  const consumed = previousForSession
55
- .filter((item) => !item.optimistic && !nextRunIds.has(item.runId))
64
+ .filter((item) => !item.optimistic && !nextRunIds.has(item.runId) && activeRunId === item.runId)
56
65
  .map((item) => ({ ...item, sending: true }))
57
66
  return [
58
67
  ...otherSessions,
@@ -0,0 +1,124 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import { isStderrNoise, buildCliEnv, isCliProvider, CLI_PROVIDER_CAPABILITIES } from './cli-utils'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // isStderrNoise
8
+ // ---------------------------------------------------------------------------
9
+
10
+ describe('isStderrNoise', () => {
11
+ it('returns true for MallocStackLogging lines', () => {
12
+ assert.equal(isStderrNoise('MallocStackLogging: could not tag MSL'), true)
13
+ })
14
+
15
+ it('returns true for blank/whitespace lines', () => {
16
+ assert.equal(isStderrNoise(''), true)
17
+ assert.equal(isStderrNoise(' '), true)
18
+ assert.equal(isStderrNoise('\t'), true)
19
+ })
20
+
21
+ it('returns false for real error text', () => {
22
+ assert.equal(isStderrNoise('Error: connection refused'), false)
23
+ assert.equal(isStderrNoise('FATAL: segfault'), false)
24
+ assert.equal(isStderrNoise('Permission denied'), false)
25
+ })
26
+ })
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // buildCliEnv
30
+ // ---------------------------------------------------------------------------
31
+
32
+ describe('buildCliEnv', () => {
33
+ it('strips SWARMCLAW_ prefixed vars from env', () => {
34
+ const orig = process.env.SWARMCLAW_TEST_VAR
35
+ process.env.SWARMCLAW_TEST_VAR = 'should_be_stripped'
36
+ try {
37
+ const env = buildCliEnv()
38
+ assert.equal(env.SWARMCLAW_TEST_VAR, undefined)
39
+ } finally {
40
+ if (orig === undefined) delete process.env.SWARMCLAW_TEST_VAR
41
+ else process.env.SWARMCLAW_TEST_VAR = orig
42
+ }
43
+ })
44
+
45
+ it('deletes MallocStackLogging', () => {
46
+ const orig = process.env.MallocStackLogging
47
+ process.env.MallocStackLogging = '1'
48
+ try {
49
+ const env = buildCliEnv()
50
+ assert.equal(env.MallocStackLogging, undefined)
51
+ } finally {
52
+ if (orig === undefined) delete process.env.MallocStackLogging
53
+ else process.env.MallocStackLogging = orig
54
+ }
55
+ })
56
+
57
+ it('injects provided key-value pairs', () => {
58
+ const env = buildCliEnv({ inject: { MY_CUSTOM_KEY: 'hello' } })
59
+ assert.equal(env.MY_CUSTOM_KEY, 'hello')
60
+ })
61
+
62
+ it('preserves unrelated user env vars', () => {
63
+ const env = buildCliEnv()
64
+ assert.equal(env.PATH, process.env.PATH)
65
+ })
66
+
67
+ it('supports custom stripPrefixes', () => {
68
+ const orig = process.env.CUSTOM_PREFIX_VAR
69
+ process.env.CUSTOM_PREFIX_VAR = 'should_be_stripped'
70
+ try {
71
+ const env = buildCliEnv({ stripPrefixes: ['CUSTOM_PREFIX_'] })
72
+ assert.equal(env.CUSTOM_PREFIX_VAR, undefined)
73
+ } finally {
74
+ if (orig === undefined) delete process.env.CUSTOM_PREFIX_VAR
75
+ else process.env.CUSTOM_PREFIX_VAR = orig
76
+ }
77
+ })
78
+
79
+ it('sets TERM=dumb and NO_COLOR=1', () => {
80
+ const env = buildCliEnv()
81
+ assert.equal(env.TERM, 'dumb')
82
+ assert.equal(env.NO_COLOR, '1')
83
+ })
84
+ })
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // isCliProvider
88
+ // ---------------------------------------------------------------------------
89
+
90
+ describe('isCliProvider', () => {
91
+ it('returns true for known CLI providers', () => {
92
+ assert.equal(isCliProvider('claude-cli'), true)
93
+ assert.equal(isCliProvider('codex-cli'), true)
94
+ assert.equal(isCliProvider('opencode-cli'), true)
95
+ assert.equal(isCliProvider('gemini-cli'), true)
96
+ })
97
+
98
+ it('returns false for non-CLI providers', () => {
99
+ assert.equal(isCliProvider('openai'), false)
100
+ assert.equal(isCliProvider('anthropic'), false)
101
+ assert.equal(isCliProvider(''), false)
102
+ assert.equal(isCliProvider('google'), false)
103
+ })
104
+ })
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // CLI_PROVIDER_CAPABILITIES
108
+ // ---------------------------------------------------------------------------
109
+
110
+ describe('CLI_PROVIDER_CAPABILITIES', () => {
111
+ it('has entries for all 4 CLI providers', () => {
112
+ assert.ok('claude-cli' in CLI_PROVIDER_CAPABILITIES)
113
+ assert.ok('codex-cli' in CLI_PROVIDER_CAPABILITIES)
114
+ assert.ok('opencode-cli' in CLI_PROVIDER_CAPABILITIES)
115
+ assert.ok('gemini-cli' in CLI_PROVIDER_CAPABILITIES)
116
+ })
117
+
118
+ it('each entry is a non-empty string', () => {
119
+ for (const [key, value] of Object.entries(CLI_PROVIDER_CAPABILITIES)) {
120
+ assert.equal(typeof value, 'string', `${key} should be a string`)
121
+ assert.ok(value.length > 0, `${key} should be non-empty`)
122
+ }
123
+ })
124
+ })
@@ -0,0 +1,21 @@
1
+ import { loadActivity as loadStoredActivity, logActivity as writeActivityLog } from '@/lib/server/storage'
2
+ import { perf } from '@/lib/server/runtime/perf'
3
+
4
+ export function loadActivity() {
5
+ return perf.measureSync('repository', 'activity.list', () => loadStoredActivity())
6
+ }
7
+
8
+ export function logActivity(entry: {
9
+ entityType: string
10
+ entityId: string
11
+ action: string
12
+ actor: string
13
+ actorId?: string
14
+ summary: string
15
+ detail?: Record<string, unknown>
16
+ }) {
17
+ perf.measureSync('repository', 'activity.log', () => writeActivityLog(entry), {
18
+ entityType: entry.entityType,
19
+ action: entry.action,
20
+ })
21
+ }
@@ -1,20 +1,25 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { describe, it } from 'node:test'
3
+ import type { Agent, ProviderType } from '@/types'
3
4
  import { isWorkerOnlyAgent, buildWorkerOnlyAgentMessage } from './agent-availability'
4
5
 
5
6
  describe('isWorkerOnlyAgent', () => {
6
- const CLI_PROVIDERS = ['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'openclaw']
7
- const NON_CLI_PROVIDERS = ['openai', 'anthropic', 'google', 'deepseek', 'groq', 'custom']
7
+ const CLI_PROVIDERS = ['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'openclaw'] satisfies ProviderType[]
8
+ const NON_CLI_PROVIDERS = ['openai', 'anthropic', 'google', 'deepseek', 'groq', 'together'] satisfies ProviderType[]
9
+
10
+ function withProvider(provider: unknown): Pick<Agent, 'provider'> {
11
+ return { provider } as Pick<Agent, 'provider'>
12
+ }
8
13
 
9
14
  for (const provider of CLI_PROVIDERS) {
10
15
  it(`returns true for ${provider}`, () => {
11
- assert.equal(isWorkerOnlyAgent({ provider }), true)
16
+ assert.equal(isWorkerOnlyAgent(withProvider(provider)), true)
12
17
  })
13
18
  }
14
19
 
15
20
  for (const provider of NON_CLI_PROVIDERS) {
16
21
  it(`returns false for ${provider}`, () => {
17
- assert.equal(isWorkerOnlyAgent({ provider }), false)
22
+ assert.equal(isWorkerOnlyAgent(withProvider(provider)), false)
18
23
  })
19
24
  }
20
25
 
@@ -27,7 +32,7 @@ describe('isWorkerOnlyAgent', () => {
27
32
  })
28
33
 
29
34
  it('returns false for empty provider string', () => {
30
- assert.equal(isWorkerOnlyAgent({ provider: '' }), false)
35
+ assert.equal(isWorkerOnlyAgent(withProvider('')), false)
31
36
  })
32
37
  })
33
38
 
@@ -3,22 +3,21 @@
3
3
  *
4
4
  * When an agent is trashed, related entities (tasks, schedules, watch jobs,
5
5
  * connectors, webhooks, delegation jobs, chatroom memberships) must be
6
- * suspended to prevent phantom daemon activity. On permanent delete the
6
+ * suspended to prevent phantom daemon activity. On permanent delete the
7
7
  * referencing rows are hard-removed.
8
8
  */
9
9
 
10
10
  import {
11
- loadTasks,
12
- upsertStoredItems,
13
- loadSchedules,
14
- loadWatchJobs,
15
- loadConnectors,
11
+ deleteDelegationJob,
16
12
  loadDelegationJobs,
17
- loadWebhooks,
18
- loadChatrooms,
19
- deleteStoredItem,
20
- } from '@/lib/server/storage'
21
- import type { StorageCollection } from '@/lib/server/storage'
13
+ saveDelegationJobRecords,
14
+ } from '@/lib/server/agents/delegation-job-repository'
15
+ import { loadChatrooms, saveChatrooms } from '@/lib/server/chatrooms/chatroom-repository'
16
+ import { loadConnectors, saveConnectors } from '@/lib/server/connectors/connector-repository'
17
+ import { deleteWatchJob, loadWatchJobs, upsertWatchJobs } from '@/lib/server/runtime/watch-job-repository'
18
+ import { deleteSchedule, loadSchedules, upsertSchedules } from '@/lib/server/schedules/schedule-repository'
19
+ import { deleteTask, loadTasks, saveTaskMany } from '@/lib/server/tasks/task-repository'
20
+ import { loadWebhooks, saveWebhooks } from '@/lib/server/webhooks/webhook-repository'
22
21
 
23
22
  interface CascadeCounts {
24
23
  tasks: number
@@ -39,7 +38,7 @@ export function suspendAgentReferences(agentId: string): CascadeCounts {
39
38
 
40
39
  // 1. Tasks — cancel active ones
41
40
  const tasks = loadTasks()
42
- const taskUpdates: Array<[string, unknown]> = []
41
+ const taskUpdates: Array<[string, Record<string, unknown>]> = []
43
42
  for (const t of Object.values(tasks) as unknown as Array<Record<string, unknown>>) {
44
43
  if (!t || t.agentId !== agentId) continue
45
44
  const status = t.status as string | undefined
@@ -50,13 +49,13 @@ export function suspendAgentReferences(agentId: string): CascadeCounts {
50
49
  }
51
50
  }
52
51
  if (taskUpdates.length) {
53
- upsertStoredItems('tasks', taskUpdates)
52
+ saveTaskMany(taskUpdates)
54
53
  counts.tasks = taskUpdates.length
55
54
  }
56
55
 
57
56
  // 2. Schedules — pause (with marker for restore)
58
57
  const schedules = loadSchedules()
59
- const schedUpdates: Array<[string, unknown]> = []
58
+ const schedUpdates: Array<[string, Record<string, unknown>]> = []
60
59
  for (const s of Object.values(schedules) as unknown as Array<Record<string, unknown>>) {
61
60
  if (!s || s.agentId !== agentId) continue
62
61
  if (s.enabled === false) continue
@@ -65,13 +64,13 @@ export function suspendAgentReferences(agentId: string): CascadeCounts {
65
64
  schedUpdates.push([s.id as string, s])
66
65
  }
67
66
  if (schedUpdates.length) {
68
- upsertStoredItems('schedules', schedUpdates)
67
+ upsertSchedules(schedUpdates)
69
68
  counts.schedules = schedUpdates.length
70
69
  }
71
70
 
72
71
  // 3. Watch jobs — cancel active
73
72
  const watchJobs = loadWatchJobs()
74
- const wjUpdates: Array<[string, unknown]> = []
73
+ const wjUpdates: Array<[string, Record<string, unknown>]> = []
75
74
  for (const w of Object.values(watchJobs) as unknown as Array<Record<string, unknown>>) {
76
75
  if (!w || w.agentId !== agentId) continue
77
76
  if (w.status === 'cancelled') continue
@@ -79,26 +78,28 @@ export function suspendAgentReferences(agentId: string): CascadeCounts {
79
78
  wjUpdates.push([w.id as string, w])
80
79
  }
81
80
  if (wjUpdates.length) {
82
- upsertStoredItems('watch_jobs', wjUpdates)
81
+ upsertWatchJobs(wjUpdates)
83
82
  counts.watchJobs = wjUpdates.length
84
83
  }
85
84
 
86
85
  // 4. Connectors — detach agent (keep connector alive but unrouted)
87
- const connectors = loadConnectors()
88
- const connUpdates: Array<[string, unknown]> = []
89
- for (const c of Object.values(connectors) as unknown as Array<Record<string, unknown>>) {
90
- if (!c || c.agentId !== agentId) continue
91
- c.agentId = null
92
- connUpdates.push([c.id as string, c])
93
- }
94
- if (connUpdates.length) {
95
- upsertStoredItems('connectors', connUpdates)
96
- counts.connectors = connUpdates.length
86
+ {
87
+ const connectors = loadConnectors()
88
+ let connectorUpdates = 0
89
+ for (const c of Object.values(connectors)) {
90
+ if (!c || c.agentId !== agentId) continue
91
+ c.agentId = null
92
+ connectorUpdates += 1
93
+ }
94
+ if (connectorUpdates > 0) {
95
+ saveConnectors(connectors)
96
+ counts.connectors = connectorUpdates
97
+ }
97
98
  }
98
99
 
99
100
  // 5. Delegation jobs — cancel queued/running
100
101
  const delegationJobs = loadDelegationJobs()
101
- const djUpdates: Array<[string, unknown]> = []
102
+ const djUpdates: Array<[string, Record<string, unknown>]> = []
102
103
  for (const d of Object.values(delegationJobs) as unknown as Array<Record<string, unknown>>) {
103
104
  if (!d || d.agentId !== agentId) continue
104
105
  const status = d.status as string | undefined
@@ -108,22 +109,22 @@ export function suspendAgentReferences(agentId: string): CascadeCounts {
108
109
  }
109
110
  }
110
111
  if (djUpdates.length) {
111
- upsertStoredItems('delegation_jobs', djUpdates)
112
+ saveDelegationJobRecords(djUpdates)
112
113
  counts.delegationJobs = djUpdates.length
113
114
  }
114
115
 
115
116
  // 6. Webhooks — disable
116
117
  const webhooks = loadWebhooks()
117
- const whUpdates: Array<[string, unknown]> = []
118
- for (const w of Object.values(webhooks) as unknown as Array<Record<string, unknown>>) {
118
+ let webhookUpdates = 0
119
+ for (const w of Object.values(webhooks)) {
119
120
  if (!w || w.agentId !== agentId) continue
120
121
  if (w.enabled === false) continue
121
122
  w.enabled = false
122
- whUpdates.push([w.id as string, w])
123
+ webhookUpdates += 1
123
124
  }
124
- if (whUpdates.length) {
125
- upsertStoredItems('webhooks', whUpdates)
126
- counts.webhooks = whUpdates.length
125
+ if (webhookUpdates > 0) {
126
+ saveWebhooks(webhooks)
127
+ counts.webhooks = webhookUpdates
127
128
  }
128
129
 
129
130
  // 7. Chatrooms — remove agent from member arrays
@@ -138,23 +139,25 @@ export function suspendAgentReferences(agentId: string): CascadeCounts {
138
139
  export function purgeAgentReferences(agentId: string): CascadeCounts {
139
140
  const counts: CascadeCounts = { tasks: 0, schedules: 0, watchJobs: 0, connectors: 0, delegationJobs: 0, webhooks: 0, chatrooms: 0 }
140
141
 
141
- counts.tasks = deleteMatching('tasks', loadTasks(), agentId)
142
- counts.schedules = deleteMatching('schedules', loadSchedules(), agentId)
143
- counts.watchJobs = deleteMatching('watch_jobs', loadWatchJobs(), agentId)
144
- counts.delegationJobs = deleteMatching('delegation_jobs', loadDelegationJobs(), agentId)
145
- counts.webhooks = deleteMatching('webhooks', loadWebhooks(), agentId)
142
+ counts.tasks = deleteMatching(loadTasks(), agentId, deleteTask)
143
+ counts.schedules = deleteMatching(loadSchedules(), agentId, deleteSchedule)
144
+ counts.watchJobs = deleteMatching(loadWatchJobs(), agentId, deleteWatchJob)
145
+ counts.delegationJobs = deleteMatching(loadDelegationJobs(), agentId, deleteDelegationJob)
146
+ counts.webhooks = purgeWebhooks(agentId)
146
147
 
147
148
  // Connectors: detach agent but keep the connector record
148
- const connectors = loadConnectors()
149
- const connUpdates: Array<[string, unknown]> = []
150
- for (const c of Object.values(connectors) as unknown as Array<Record<string, unknown>>) {
151
- if (!c || c.agentId !== agentId) continue
152
- c.agentId = null
153
- connUpdates.push([c.id as string, c])
154
- }
155
- if (connUpdates.length) {
156
- upsertStoredItems('connectors', connUpdates)
157
- counts.connectors = connUpdates.length
149
+ {
150
+ const connectors = loadConnectors()
151
+ let connectorUpdates = 0
152
+ for (const c of Object.values(connectors)) {
153
+ if (!c || c.agentId !== agentId) continue
154
+ c.agentId = null
155
+ connectorUpdates += 1
156
+ }
157
+ if (connectorUpdates > 0) {
158
+ saveConnectors(connectors)
159
+ counts.connectors = connectorUpdates
160
+ }
158
161
  }
159
162
 
160
163
  counts.chatrooms = removeAgentFromChatrooms(agentId)
@@ -167,7 +170,7 @@ export function purgeAgentReferences(agentId: string): CascadeCounts {
167
170
  /** Re-enable schedules that were paused by trash. */
168
171
  export function restoreAgentSchedules(agentId: string): number {
169
172
  const schedules = loadSchedules()
170
- const updates: Array<[string, unknown]> = []
173
+ const updates: Array<[string, Record<string, unknown>]> = []
171
174
  for (const s of Object.values(schedules) as unknown as Array<Record<string, unknown>>) {
172
175
  if (!s || s.agentId !== agentId) continue
173
176
  if (!s.suspendedByTrash) continue
@@ -176,7 +179,7 @@ export function restoreAgentSchedules(agentId: string): number {
176
179
  updates.push([s.id as string, s])
177
180
  }
178
181
  if (updates.length) {
179
- upsertStoredItems('schedules', updates)
182
+ upsertSchedules(updates)
180
183
  }
181
184
  return updates.length
182
185
  }
@@ -184,15 +187,15 @@ export function restoreAgentSchedules(agentId: string): number {
184
187
  // ── Internals ───────────────────────────────────────────────────────────
185
188
 
186
189
  function deleteMatching<T extends { agentId?: string | null; id?: string | null }>(
187
- table: StorageCollection,
188
190
  collection: Record<string, T>,
189
191
  agentId: string,
192
+ deleteItem: (id: string) => void,
190
193
  ): number {
191
194
  let count = 0
192
195
  for (const item of Object.values(collection)) {
193
196
  if (!item || item.agentId !== agentId) continue
194
197
  if (!item.id) continue
195
- deleteStoredItem(table, item.id)
198
+ deleteItem(item.id)
196
199
  count++
197
200
  }
198
201
  return count
@@ -200,7 +203,7 @@ function deleteMatching<T extends { agentId?: string | null; id?: string | null
200
203
 
201
204
  function removeAgentFromChatrooms(agentId: string): number {
202
205
  const chatrooms = loadChatrooms()
203
- const updates: Array<[string, unknown]> = []
206
+ let changedCount = 0
204
207
  for (const room of Object.values(chatrooms) as unknown as Array<Record<string, unknown>>) {
205
208
  if (!room) continue
206
209
  let changed = false
@@ -220,10 +223,27 @@ function removeAgentFromChatrooms(agentId: string): number {
220
223
  room.agentIds = agentIds.filter((id) => id !== agentId)
221
224
  if ((room.agentIds as string[]).length !== before) changed = true
222
225
  }
223
- if (changed) updates.push([room.id as string, room])
226
+ if (changed) changedCount += 1
224
227
  }
225
- if (updates.length) {
226
- upsertStoredItems('chatrooms', updates)
228
+ if (changedCount > 0) {
229
+ saveChatrooms(chatrooms)
227
230
  }
228
- return updates.length
231
+ return changedCount
232
+ }
233
+
234
+ function purgeWebhooks(agentId: string): number {
235
+ const webhooks = loadWebhooks() as Record<string, Record<string, unknown>>
236
+ const remaining: Record<string, Record<string, unknown>> = {}
237
+ let count = 0
238
+ for (const [id, webhook] of Object.entries(webhooks)) {
239
+ if (webhook && webhook.agentId === agentId) {
240
+ count += 1
241
+ continue
242
+ }
243
+ remaining[id] = webhook
244
+ }
245
+ if (count > 0) {
246
+ saveWebhooks(remaining)
247
+ }
248
+ return count
229
249
  }
@@ -1,5 +1,7 @@
1
- import { loadAgents, loadTasks, loadSessions } from '@/lib/server/storage'
2
1
  import type { Agent, BoardTask } from '@/types'
2
+ import { loadAgents } from '@/lib/server/agents/agent-repository'
3
+ import { loadSessions } from '@/lib/server/sessions/session-repository'
4
+ import { loadTasks } from '@/lib/server/tasks/task-repository'
3
5
 
4
6
  export interface AgentDirectoryEntry {
5
7
  id: string