@swarmclawai/swarmclaw 0.7.2 → 0.7.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 (197) hide show
  1. package/README.md +81 -22
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/route.ts +26 -0
  4. package/src/app/api/agents/[id]/thread/route.ts +36 -7
  5. package/src/app/api/agents/route.ts +12 -1
  6. package/src/app/api/auth/route.ts +76 -7
  7. package/src/app/api/chatrooms/[id]/chat/route.ts +7 -2
  8. package/src/app/api/chats/[id]/browser/route.ts +5 -1
  9. package/src/app/api/chats/[id]/chat/route.ts +7 -3
  10. package/src/app/api/chats/[id]/main-loop/route.ts +7 -88
  11. package/src/app/api/chats/[id]/messages/route.ts +19 -13
  12. package/src/app/api/chats/[id]/route.ts +18 -0
  13. package/src/app/api/chats/[id]/stop/route.ts +6 -1
  14. package/src/app/api/chats/route.ts +16 -0
  15. package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
  16. package/src/app/api/connectors/doctor/route.ts +13 -0
  17. package/src/app/api/files/open/route.ts +16 -14
  18. package/src/app/api/memory/maintenance/route.ts +11 -1
  19. package/src/app/api/openclaw/agent-files/route.ts +27 -4
  20. package/src/app/api/openclaw/skills/route.ts +11 -3
  21. package/src/app/api/plugins/dependencies/route.ts +24 -0
  22. package/src/app/api/plugins/install/route.ts +15 -92
  23. package/src/app/api/plugins/route.ts +3 -26
  24. package/src/app/api/plugins/settings/route.ts +17 -12
  25. package/src/app/api/plugins/ui/route.ts +1 -0
  26. package/src/app/api/settings/route.ts +49 -7
  27. package/src/app/api/tasks/[id]/route.ts +15 -6
  28. package/src/app/api/tasks/bulk/route.ts +2 -2
  29. package/src/app/api/tasks/route.ts +9 -4
  30. package/src/app/api/webhooks/[id]/route.ts +8 -1
  31. package/src/app/page.tsx +9 -2
  32. package/src/cli/index.js +4 -0
  33. package/src/cli/index.ts +3 -10
  34. package/src/components/agents/agent-card.tsx +15 -12
  35. package/src/components/agents/agent-chat-list.tsx +101 -1
  36. package/src/components/agents/agent-list.tsx +46 -9
  37. package/src/components/agents/agent-sheet.tsx +207 -16
  38. package/src/components/agents/inspector-panel.tsx +108 -48
  39. package/src/components/auth/access-key-gate.tsx +36 -97
  40. package/src/components/chat/chat-area.tsx +29 -13
  41. package/src/components/chat/chat-card.tsx +4 -20
  42. package/src/components/chat/chat-header.tsx +255 -353
  43. package/src/components/chat/chat-list.tsx +7 -9
  44. package/src/components/chat/checkpoint-timeline.tsx +1 -1
  45. package/src/components/chat/message-list.tsx +3 -1
  46. package/src/components/chatrooms/chatroom-view.tsx +347 -205
  47. package/src/components/connectors/connector-list.tsx +265 -127
  48. package/src/components/connectors/connector-sheet.tsx +217 -0
  49. package/src/components/home/home-view.tsx +128 -4
  50. package/src/components/layout/app-layout.tsx +383 -194
  51. package/src/components/layout/mobile-header.tsx +26 -8
  52. package/src/components/plugins/plugin-list.tsx +15 -3
  53. package/src/components/plugins/plugin-sheet.tsx +118 -9
  54. package/src/components/projects/project-detail.tsx +183 -0
  55. package/src/components/shared/agent-picker-list.tsx +2 -2
  56. package/src/components/shared/command-palette.tsx +111 -24
  57. package/src/components/shared/settings/plugin-manager.tsx +20 -4
  58. package/src/components/shared/settings/section-capability-policy.tsx +105 -0
  59. package/src/components/shared/settings/section-heartbeat.tsx +77 -0
  60. package/src/components/shared/settings/section-orchestrator.tsx +3 -3
  61. package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
  62. package/src/components/shared/settings/section-secrets.tsx +6 -6
  63. package/src/components/shared/settings/section-user-preferences.tsx +1 -1
  64. package/src/components/shared/settings/section-voice.tsx +5 -1
  65. package/src/components/shared/settings/section-web-search.tsx +10 -2
  66. package/src/components/shared/settings/settings-page.tsx +245 -46
  67. package/src/components/tasks/approvals-panel.tsx +205 -18
  68. package/src/components/tasks/task-board.tsx +242 -46
  69. package/src/components/usage/metrics-dashboard.tsx +74 -1
  70. package/src/components/wallets/wallet-panel.tsx +17 -5
  71. package/src/components/webhooks/webhook-sheet.tsx +7 -7
  72. package/src/lib/auth.ts +17 -0
  73. package/src/lib/chat-streaming-state.test.ts +108 -0
  74. package/src/lib/chat-streaming-state.ts +108 -0
  75. package/src/lib/openclaw-agent-id.test.ts +14 -0
  76. package/src/lib/openclaw-agent-id.ts +31 -0
  77. package/src/lib/server/agent-assignment.test.ts +112 -0
  78. package/src/lib/server/agent-assignment.ts +169 -0
  79. package/src/lib/server/approval-connector-notify.test.ts +253 -0
  80. package/src/lib/server/approvals-auto-approve.test.ts +205 -0
  81. package/src/lib/server/approvals.ts +483 -75
  82. package/src/lib/server/autonomy-runtime.test.ts +341 -0
  83. package/src/lib/server/browser-state.test.ts +118 -0
  84. package/src/lib/server/browser-state.ts +123 -0
  85. package/src/lib/server/build-llm.test.ts +36 -0
  86. package/src/lib/server/build-llm.ts +11 -4
  87. package/src/lib/server/builtin-plugins.ts +34 -0
  88. package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
  89. package/src/lib/server/chat-execution-tool-events.test.ts +134 -0
  90. package/src/lib/server/chat-execution.ts +250 -61
  91. package/src/lib/server/chatroom-health.test.ts +26 -0
  92. package/src/lib/server/chatroom-health.ts +2 -3
  93. package/src/lib/server/chatroom-helpers.test.ts +67 -2
  94. package/src/lib/server/chatroom-helpers.ts +45 -5
  95. package/src/lib/server/connectors/discord.ts +175 -11
  96. package/src/lib/server/connectors/doctor.test.ts +80 -0
  97. package/src/lib/server/connectors/doctor.ts +116 -0
  98. package/src/lib/server/connectors/manager.ts +946 -110
  99. package/src/lib/server/connectors/policy.test.ts +222 -0
  100. package/src/lib/server/connectors/policy.ts +452 -0
  101. package/src/lib/server/connectors/slack.ts +188 -9
  102. package/src/lib/server/connectors/telegram.ts +65 -15
  103. package/src/lib/server/connectors/thread-context.test.ts +44 -0
  104. package/src/lib/server/connectors/thread-context.ts +72 -0
  105. package/src/lib/server/connectors/types.ts +41 -11
  106. package/src/lib/server/daemon-state.ts +59 -1
  107. package/src/lib/server/data-dir.ts +13 -0
  108. package/src/lib/server/delegation-jobs.test.ts +140 -0
  109. package/src/lib/server/delegation-jobs.ts +248 -0
  110. package/src/lib/server/document-utils.test.ts +47 -0
  111. package/src/lib/server/document-utils.ts +397 -0
  112. package/src/lib/server/heartbeat-service.ts +13 -39
  113. package/src/lib/server/heartbeat-source.test.ts +22 -0
  114. package/src/lib/server/heartbeat-source.ts +7 -0
  115. package/src/lib/server/identity-continuity.test.ts +77 -0
  116. package/src/lib/server/identity-continuity.ts +127 -0
  117. package/src/lib/server/mailbox-utils.ts +347 -0
  118. package/src/lib/server/main-agent-loop.ts +27 -967
  119. package/src/lib/server/memory-db.ts +4 -6
  120. package/src/lib/server/memory-tiers.ts +40 -0
  121. package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
  122. package/src/lib/server/openclaw-agent-resolver.ts +128 -0
  123. package/src/lib/server/openclaw-exec-config.ts +5 -6
  124. package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
  125. package/src/lib/server/openclaw-skills-normalize.ts +136 -0
  126. package/src/lib/server/openclaw-sync.ts +3 -2
  127. package/src/lib/server/orchestrator-lg.ts +17 -6
  128. package/src/lib/server/orchestrator.ts +2 -2
  129. package/src/lib/server/playwright-proxy.mjs +27 -3
  130. package/src/lib/server/plugins.test.ts +207 -0
  131. package/src/lib/server/plugins.ts +822 -69
  132. package/src/lib/server/provider-health.ts +33 -3
  133. package/src/lib/server/queue.ts +3 -20
  134. package/src/lib/server/scheduler.ts +2 -0
  135. package/src/lib/server/session-archive-memory.test.ts +85 -0
  136. package/src/lib/server/session-archive-memory.ts +230 -0
  137. package/src/lib/server/session-mailbox.ts +8 -18
  138. package/src/lib/server/session-reset-policy.test.ts +99 -0
  139. package/src/lib/server/session-reset-policy.ts +311 -0
  140. package/src/lib/server/session-run-manager.ts +33 -80
  141. package/src/lib/server/session-tools/autonomy-tools.test.ts +105 -0
  142. package/src/lib/server/session-tools/calendar.ts +2 -12
  143. package/src/lib/server/session-tools/connector.ts +109 -8
  144. package/src/lib/server/session-tools/context.ts +14 -2
  145. package/src/lib/server/session-tools/crawl.ts +447 -0
  146. package/src/lib/server/session-tools/crud.ts +70 -32
  147. package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
  148. package/src/lib/server/session-tools/delegate.ts +406 -20
  149. package/src/lib/server/session-tools/discovery.ts +22 -4
  150. package/src/lib/server/session-tools/document.ts +283 -0
  151. package/src/lib/server/session-tools/email.ts +1 -3
  152. package/src/lib/server/session-tools/extract.ts +137 -0
  153. package/src/lib/server/session-tools/file-normalize.test.ts +93 -0
  154. package/src/lib/server/session-tools/file-send.test.ts +84 -1
  155. package/src/lib/server/session-tools/file.ts +237 -24
  156. package/src/lib/server/session-tools/human-loop.ts +227 -0
  157. package/src/lib/server/session-tools/image-gen.ts +1 -3
  158. package/src/lib/server/session-tools/index.ts +56 -1
  159. package/src/lib/server/session-tools/mailbox.ts +276 -0
  160. package/src/lib/server/session-tools/memory.ts +35 -3
  161. package/src/lib/server/session-tools/monitor.ts +150 -7
  162. package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
  163. package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
  164. package/src/lib/server/session-tools/platform.ts +142 -4
  165. package/src/lib/server/session-tools/plugin-creator.ts +86 -23
  166. package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
  167. package/src/lib/server/session-tools/replicate.ts +1 -3
  168. package/src/lib/server/session-tools/schedule.ts +20 -10
  169. package/src/lib/server/session-tools/session-info.ts +36 -3
  170. package/src/lib/server/session-tools/session-tools-wiring.test.ts +31 -17
  171. package/src/lib/server/session-tools/subagent.ts +193 -27
  172. package/src/lib/server/session-tools/table.ts +587 -0
  173. package/src/lib/server/session-tools/wallet.ts +13 -10
  174. package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
  175. package/src/lib/server/session-tools/web.ts +896 -100
  176. package/src/lib/server/storage.ts +226 -7
  177. package/src/lib/server/stream-agent-chat.ts +46 -21
  178. package/src/lib/server/structured-extract.test.ts +72 -0
  179. package/src/lib/server/structured-extract.ts +373 -0
  180. package/src/lib/server/task-mention.test.ts +16 -2
  181. package/src/lib/server/task-mention.ts +61 -10
  182. package/src/lib/server/tool-aliases.ts +44 -7
  183. package/src/lib/server/tool-capability-policy.ts +6 -0
  184. package/src/lib/server/tool-retry.ts +2 -0
  185. package/src/lib/server/watch-jobs.test.ts +173 -0
  186. package/src/lib/server/watch-jobs.ts +532 -0
  187. package/src/lib/server/ws-hub.ts +5 -3
  188. package/src/lib/validation/schemas.test.ts +26 -0
  189. package/src/lib/validation/schemas.ts +7 -0
  190. package/src/lib/ws-client.ts +14 -12
  191. package/src/proxy.ts +5 -5
  192. package/src/stores/use-app-store.ts +0 -6
  193. package/src/stores/use-chat-store.ts +31 -2
  194. package/src/types/index.ts +287 -44
  195. package/src/components/chat/new-chat-sheet.tsx +0 -253
  196. package/src/lib/server/main-session.ts +0 -17
  197. package/src/lib/server/session-run-manager.test.ts +0 -26
@@ -0,0 +1,173 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { after, before, describe, it } from 'node:test'
6
+
7
+ const originalEnv = {
8
+ DATA_DIR: process.env.DATA_DIR,
9
+ WORKSPACE_DIR: process.env.WORKSPACE_DIR,
10
+ SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
11
+ }
12
+
13
+ let tempDir = ''
14
+ let watchJobs: typeof import('./watch-jobs')
15
+ let storage: typeof import('./storage')
16
+
17
+ before(async () => {
18
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-watch-jobs-'))
19
+ process.env.DATA_DIR = path.join(tempDir, 'data')
20
+ process.env.WORKSPACE_DIR = path.join(tempDir, 'workspace')
21
+ process.env.SWARMCLAW_BUILD_MODE = '1'
22
+ watchJobs = await import('./watch-jobs')
23
+ storage = await import('./storage')
24
+ })
25
+
26
+ after(() => {
27
+ if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
28
+ else process.env.DATA_DIR = originalEnv.DATA_DIR
29
+ if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
30
+ else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
31
+ if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
32
+ else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
33
+ fs.rmSync(tempDir, { recursive: true, force: true })
34
+ })
35
+
36
+ describe('watch-jobs', () => {
37
+ it('validates required targets for durable watches', async () => {
38
+ await assert.rejects(
39
+ watchJobs.createWatchJob({
40
+ type: 'http',
41
+ resumeMessage: 'resume',
42
+ target: {},
43
+ condition: {},
44
+ }),
45
+ /url target/,
46
+ )
47
+
48
+ await assert.rejects(
49
+ watchJobs.createWatchJob({
50
+ type: 'time',
51
+ resumeMessage: 'resume',
52
+ target: { source: 'test' },
53
+ condition: {},
54
+ }),
55
+ /runAt or delayMinutes/,
56
+ )
57
+ })
58
+
59
+ it('triggers time and task watches durably', async () => {
60
+ const tasks = storage.loadTasks()
61
+ tasks.task_done = {
62
+ id: 'task_done',
63
+ title: 'done',
64
+ status: 'completed',
65
+ result: 'ok',
66
+ createdAt: Date.now(),
67
+ updatedAt: Date.now(),
68
+ }
69
+ storage.saveTasks(tasks)
70
+
71
+ const timeJob = await watchJobs.createWatchJob({
72
+ type: 'time',
73
+ resumeMessage: 'wake up',
74
+ target: { source: 'schedule_wake' },
75
+ condition: {},
76
+ runAt: Date.now() - 1000,
77
+ })
78
+ const taskJob = await watchJobs.createWatchJob({
79
+ type: 'task',
80
+ resumeMessage: 'task finished',
81
+ target: { taskId: 'task_done' },
82
+ condition: { statusIn: ['completed'] },
83
+ })
84
+
85
+ const outcome = await watchJobs.processDueWatchJobs(Date.now())
86
+
87
+ assert.equal(outcome.triggered >= 2, true)
88
+ assert.equal(watchJobs.getWatchJob(timeJob.id)?.status, 'triggered')
89
+ assert.equal(watchJobs.getWatchJob(taskJob.id)?.status, 'triggered')
90
+ assert.equal(watchJobs.getWatchJob(taskJob.id)?.result?.status, 'completed')
91
+ })
92
+
93
+ it('captures file changes and webhook triggers', async () => {
94
+ const watchedFile = path.join(tempDir, 'watch.txt')
95
+ fs.writeFileSync(watchedFile, 'alpha')
96
+
97
+ const fileJob = await watchJobs.createWatchJob({
98
+ type: 'file',
99
+ resumeMessage: 'file changed',
100
+ target: { path: watchedFile },
101
+ condition: { changed: true },
102
+ })
103
+ const webhookJob = await watchJobs.createWatchJob({
104
+ type: 'webhook',
105
+ resumeMessage: 'webhook arrived',
106
+ target: { webhookId: 'wh_1' },
107
+ condition: { event: 'build.finished' },
108
+ })
109
+
110
+ fs.writeFileSync(watchedFile, 'beta')
111
+ await watchJobs.processDueWatchJobs(Date.now())
112
+ const webhookMatches = watchJobs.triggerWebhookWatchJobs({
113
+ webhookId: 'wh_1',
114
+ event: 'build.finished',
115
+ payloadPreview: '{"ok":true}',
116
+ })
117
+
118
+ assert.equal(watchJobs.getWatchJob(fileJob.id)?.status, 'triggered')
119
+ assert.match(String(watchJobs.getWatchJob(fileJob.id)?.result?.preview || ''), /beta/)
120
+ assert.equal(webhookMatches.length, 1)
121
+ assert.equal(watchJobs.getWatchJob(webhookJob.id)?.status, 'triggered')
122
+ })
123
+
124
+ it('wakes mailbox and approval watches from event triggers', async () => {
125
+ storage.upsertApproval('approval_1', {
126
+ id: 'approval_1',
127
+ category: 'human_loop',
128
+ title: 'Need approval',
129
+ description: 'Approve the action',
130
+ data: {},
131
+ createdAt: Date.now(),
132
+ updatedAt: Date.now(),
133
+ status: 'pending',
134
+ })
135
+
136
+ const mailboxJob = await watchJobs.createWatchJob({
137
+ type: 'mailbox',
138
+ resumeMessage: 'human replied',
139
+ target: { sessionId: 'session_1' },
140
+ condition: { type: 'human_reply', correlationId: 'corr_1' },
141
+ })
142
+ const approvalJob = await watchJobs.createWatchJob({
143
+ type: 'approval',
144
+ resumeMessage: 'approval updated',
145
+ target: { approvalId: 'approval_1' },
146
+ condition: { statusIn: ['approved'] },
147
+ })
148
+
149
+ const mailboxMatches = watchJobs.triggerMailboxWatchJobs({
150
+ sessionId: 'session_1',
151
+ envelope: {
152
+ id: 'env_1',
153
+ type: 'human_reply',
154
+ payload: 'approved',
155
+ toSessionId: 'session_1',
156
+ correlationId: 'corr_1',
157
+ status: 'new',
158
+ createdAt: Date.now(),
159
+ },
160
+ })
161
+ const approvalMatches = watchJobs.triggerApprovalWatchJobs({
162
+ approvalId: 'approval_1',
163
+ status: 'approved',
164
+ })
165
+
166
+ assert.equal(mailboxMatches.length, 1)
167
+ assert.equal(approvalMatches.length, 1)
168
+ assert.equal(watchJobs.getWatchJob(mailboxJob.id)?.status, 'triggered')
169
+ assert.equal(watchJobs.getWatchJob(approvalJob.id)?.status, 'triggered')
170
+ assert.equal(watchJobs.getWatchJob(mailboxJob.id)?.result?.correlationId, 'corr_1')
171
+ assert.equal(watchJobs.getWatchJob(approvalJob.id)?.result?.status, 'approved')
172
+ })
173
+ })
@@ -0,0 +1,532 @@
1
+ import crypto from 'crypto'
2
+ import fs from 'fs'
3
+ import * as cheerio from 'cheerio'
4
+ import { genId } from '@/lib/id'
5
+ import type { MailboxEnvelope, WatchJob } from '@/types'
6
+ import { requestHeartbeatNow } from './heartbeat-wake'
7
+ import { enqueueSystemEvent } from './system-events'
8
+ import { loadApprovals, loadTasks, loadWatchJobs, upsertWatchJob, upsertWatchJobs } from './storage'
9
+ import { notify } from './ws-hub'
10
+ import { fetchMailboxMessages, getMailboxHighwaterUid } from './mailbox-utils'
11
+
12
+ export interface CreateWatchJobInput {
13
+ type: WatchJob['type']
14
+ sessionId?: string | null
15
+ agentId?: string | null
16
+ createdByAgentId?: string | null
17
+ browserProfileId?: string | null
18
+ description?: string | null
19
+ resumeMessage: string
20
+ target: Record<string, unknown>
21
+ condition: Record<string, unknown>
22
+ runAt?: number | null
23
+ intervalMs?: number | null
24
+ timeoutAt?: number | null
25
+ }
26
+
27
+ function now() {
28
+ return Date.now()
29
+ }
30
+
31
+ function hashContent(value: string): string {
32
+ return crypto.createHash('sha1').update(value).digest('hex')
33
+ }
34
+
35
+ function cleanHtmlToText(html: string): string {
36
+ const $ = cheerio.load(html)
37
+ $('script, style, noscript').remove()
38
+ return $('body').text().replace(/\s+/g, ' ').trim()
39
+ }
40
+
41
+ function matchesRegex(body: string, pattern: unknown): boolean {
42
+ if (typeof pattern !== 'string' || !pattern.trim()) return false
43
+ try {
44
+ return new RegExp(pattern, 'i').test(body)
45
+ } catch {
46
+ return false
47
+ }
48
+ }
49
+
50
+ function scheduleNextCheck(job: WatchJob, at = now()): WatchJob {
51
+ const intervalMs = typeof job.intervalMs === 'number' && job.intervalMs > 0 ? job.intervalMs : 60_000
52
+ return {
53
+ ...job,
54
+ nextCheckAt: at + intervalMs,
55
+ updatedAt: at,
56
+ }
57
+ }
58
+
59
+ function notifyWatchJobsChanged() {
60
+ notify('watch_jobs')
61
+ }
62
+
63
+ function finalizeWatchJob(job: WatchJob, status: WatchJob['status'], result?: Record<string, unknown> | null, error?: string | null): WatchJob {
64
+ const updated: WatchJob = {
65
+ ...job,
66
+ status,
67
+ result: result ?? job.result ?? null,
68
+ lastError: error ?? null,
69
+ lastTriggeredAt: status === 'triggered' ? now() : job.lastTriggeredAt ?? null,
70
+ updatedAt: now(),
71
+ }
72
+ upsertWatchJob(updated.id, updated)
73
+ notifyWatchJobsChanged()
74
+ return updated
75
+ }
76
+
77
+ function wakeFromWatch(job: WatchJob, result?: Record<string, unknown> | null) {
78
+ const summary = job.description || `Watch ${job.id}`
79
+ const detail = result ? JSON.stringify(result).slice(0, 1200) : ''
80
+ if (job.sessionId) {
81
+ enqueueSystemEvent(
82
+ job.sessionId,
83
+ `[Watch Triggered] ${summary}\n${job.resumeMessage}${detail ? `\n\nObserved:\n${detail}` : ''}`,
84
+ )
85
+ requestHeartbeatNow({ sessionId: job.sessionId, reason: 'watch_job' })
86
+ } else if (job.agentId) {
87
+ requestHeartbeatNow({ agentId: job.agentId, reason: 'watch_job' })
88
+ }
89
+ }
90
+
91
+ export async function createWatchJob(input: CreateWatchJobInput): Promise<WatchJob> {
92
+ if (input.type === 'time' && typeof input.runAt !== 'number') {
93
+ throw new Error('Time watches require runAt or delayMinutes.')
94
+ }
95
+ if ((input.type === 'http' || input.type === 'page') && typeof input.target?.url !== 'string') {
96
+ throw new Error(`${input.type} watches require a url target.`)
97
+ }
98
+ if (input.type === 'file' && typeof input.target?.path !== 'string') {
99
+ throw new Error('File watches require a path target.')
100
+ }
101
+ if (input.type === 'task' && typeof input.target?.taskId !== 'string') {
102
+ throw new Error('Task watches require a taskId target.')
103
+ }
104
+ if (input.type === 'webhook' && typeof input.target?.webhookId !== 'string') {
105
+ throw new Error('Webhook watches require a webhookId target.')
106
+ }
107
+ if (input.type === 'email' && typeof input.target?.folder !== 'string' && typeof input.target?.folder !== 'undefined') {
108
+ throw new Error('Email watches expect a string folder when provided.')
109
+ }
110
+ if (input.type === 'mailbox' && typeof input.target?.sessionId !== 'string') {
111
+ throw new Error('Mailbox watches require a sessionId target.')
112
+ }
113
+ if (input.type === 'approval' && typeof input.target?.approvalId !== 'string') {
114
+ throw new Error('Approval watches require an approvalId target.')
115
+ }
116
+ const createdAt = now()
117
+ const job: WatchJob = {
118
+ id: genId(10),
119
+ type: input.type,
120
+ status: 'active',
121
+ description: input.description || null,
122
+ sessionId: input.sessionId || null,
123
+ agentId: input.agentId || null,
124
+ createdByAgentId: input.createdByAgentId || null,
125
+ browserProfileId: input.browserProfileId || null,
126
+ resumeMessage: input.resumeMessage,
127
+ target: { ...(input.target || {}) },
128
+ condition: { ...(input.condition || {}) },
129
+ runAt: input.runAt ?? null,
130
+ nextCheckAt: input.runAt ?? createdAt,
131
+ intervalMs: input.intervalMs ?? (input.type === 'time' ? null : 60_000),
132
+ timeoutAt: input.timeoutAt ?? null,
133
+ lastCheckedAt: null,
134
+ lastTriggeredAt: null,
135
+ lastError: null,
136
+ result: null,
137
+ createdAt,
138
+ updatedAt: createdAt,
139
+ }
140
+
141
+ // Capture initial baselines for change watches.
142
+ if ((job.type === 'http' || job.type === 'page') && typeof job.target.url === 'string' && job.condition.changed === true) {
143
+ try {
144
+ const res = await fetch(job.target.url, { signal: AbortSignal.timeout(15_000) })
145
+ if (res.ok) {
146
+ const text = job.type === 'page'
147
+ ? cleanHtmlToText(await res.text())
148
+ : await res.text()
149
+ job.target = { ...job.target, baselineHash: hashContent(text) }
150
+ }
151
+ } catch {
152
+ // Baseline creation is best-effort; the watch can still run later.
153
+ }
154
+ }
155
+
156
+ if (job.type === 'file' && typeof job.target.path === 'string' && job.condition.changed === true) {
157
+ try {
158
+ if (fs.existsSync(job.target.path)) {
159
+ const text = fs.readFileSync(job.target.path, 'utf8')
160
+ job.target = { ...job.target, baselineHash: hashContent(text) }
161
+ }
162
+ } catch {
163
+ // Best-effort baseline only.
164
+ }
165
+ }
166
+
167
+ if (job.type === 'email') {
168
+ try {
169
+ const baselineUid = await getMailboxHighwaterUid(undefined, typeof job.target.folder === 'string' ? job.target.folder : undefined)
170
+ job.target = {
171
+ ...job.target,
172
+ baselineUid,
173
+ }
174
+ } catch {
175
+ // best-effort baseline only
176
+ }
177
+ }
178
+
179
+ upsertWatchJob(job.id, job)
180
+ notifyWatchJobsChanged()
181
+ return job
182
+ }
183
+
184
+ export function cancelWatchJob(id: string): WatchJob | null {
185
+ const all = loadWatchJobs()
186
+ const current = all[id]
187
+ if (!current || typeof current !== 'object') return null
188
+ return finalizeWatchJob(current as WatchJob, 'cancelled', null, null)
189
+ }
190
+
191
+ export function getWatchJob(id: string): WatchJob | null {
192
+ const all = loadWatchJobs()
193
+ const current = all[id]
194
+ if (!current || typeof current !== 'object') return null
195
+ return current as WatchJob
196
+ }
197
+
198
+ export function listWatchJobs(filter?: { sessionId?: string | null; status?: WatchJob['status'] | null }): WatchJob[] {
199
+ return Object.values(loadWatchJobs())
200
+ .filter((job): job is WatchJob => !!job && typeof job === 'object')
201
+ .filter((job) => !filter?.sessionId || job.sessionId === filter.sessionId)
202
+ .filter((job) => !filter?.status || job.status === filter.status)
203
+ .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
204
+ }
205
+
206
+ async function evaluateHttpLikeJob(job: WatchJob, asPage: boolean): Promise<{ triggered: boolean; result: Record<string, unknown> }> {
207
+ const url = typeof job.target.url === 'string' ? job.target.url : ''
208
+ if (!url) return { triggered: false, result: { error: 'Missing url' } }
209
+ const startedAt = Date.now()
210
+ const res = await fetch(url, { signal: AbortSignal.timeout(15_000) })
211
+ const latencyMs = Date.now() - startedAt
212
+ const raw = await res.text()
213
+ const body = asPage ? cleanHtmlToText(raw) : raw
214
+ const bodyHash = hashContent(body)
215
+ const containsText = typeof job.condition.containsText === 'string' ? job.condition.containsText : ''
216
+ const textGone = typeof job.condition.textGone === 'string' ? job.condition.textGone : ''
217
+ const statusEquals = typeof job.condition.status === 'number' ? job.condition.status : null
218
+ const statusIn = Array.isArray(job.condition.statusIn)
219
+ ? job.condition.statusIn.filter((value): value is number => typeof value === 'number')
220
+ : []
221
+ const changed = job.condition.changed === true
222
+ const latencyThreshold = typeof job.condition.threshold === 'number' ? job.condition.threshold : null
223
+ const baselineHash = typeof job.target.baselineHash === 'string' ? job.target.baselineHash : ''
224
+ const regexMatched = matchesRegex(body, job.condition.regex)
225
+ const triggered =
226
+ (statusEquals !== null && res.status === statusEquals)
227
+ || (statusIn.length > 0 && statusIn.includes(res.status))
228
+ || (!!containsText && body.includes(containsText))
229
+ || (!!textGone && !body.includes(textGone))
230
+ || regexMatched
231
+ || (!!changed && !!baselineHash && baselineHash !== bodyHash)
232
+ || (latencyThreshold !== null && latencyMs >= latencyThreshold)
233
+ return {
234
+ triggered,
235
+ result: {
236
+ url,
237
+ status: res.status,
238
+ latencyMs,
239
+ containsText: containsText || undefined,
240
+ regex: typeof job.condition.regex === 'string' ? job.condition.regex : undefined,
241
+ changed: changed || undefined,
242
+ bodyHash,
243
+ preview: body.slice(0, 1200),
244
+ },
245
+ }
246
+ }
247
+
248
+ function evaluateFileJob(job: WatchJob): { triggered: boolean; result: Record<string, unknown> } {
249
+ const targetPath = typeof job.target.path === 'string' ? job.target.path : ''
250
+ if (!targetPath) return { triggered: false, result: { error: 'Missing path' } }
251
+ const exists = fs.existsSync(targetPath)
252
+ const expectExists = job.condition.exists !== false
253
+ const containsText = typeof job.condition.containsText === 'string' ? job.condition.containsText : ''
254
+ const changed = job.condition.changed === true
255
+ let text = ''
256
+ let bodyHash = ''
257
+ try {
258
+ if (exists) {
259
+ text = fs.readFileSync(targetPath, 'utf8')
260
+ bodyHash = hashContent(text)
261
+ }
262
+ } catch {
263
+ text = ''
264
+ }
265
+ const baselineHash = typeof job.target.baselineHash === 'string' ? job.target.baselineHash : ''
266
+ const triggered =
267
+ exists === expectExists
268
+ && (!containsText || text.includes(containsText))
269
+ && (!job.condition.regex || matchesRegex(text, job.condition.regex))
270
+ && (!changed || (!!baselineHash && baselineHash !== bodyHash))
271
+ return {
272
+ triggered,
273
+ result: {
274
+ path: targetPath,
275
+ exists,
276
+ regex: typeof job.condition.regex === 'string' ? job.condition.regex : undefined,
277
+ bodyHash: bodyHash || undefined,
278
+ preview: text.slice(0, 1200),
279
+ },
280
+ }
281
+ }
282
+
283
+ function evaluateTaskJob(job: WatchJob): { triggered: boolean; result: Record<string, unknown> } {
284
+ const taskId = typeof job.target.taskId === 'string' ? job.target.taskId : ''
285
+ if (!taskId) return { triggered: false, result: { error: 'Missing taskId' } }
286
+ const tasks = loadTasks()
287
+ const task = tasks[taskId] as Record<string, unknown> | undefined
288
+ const statuses = Array.isArray(job.condition.statusIn)
289
+ ? job.condition.statusIn.filter((value): value is string => typeof value === 'string')
290
+ : ['completed', 'failed']
291
+ const currentStatus = typeof task?.status === 'string' ? task.status : 'missing'
292
+ return {
293
+ triggered: statuses.includes(currentStatus),
294
+ result: {
295
+ taskId,
296
+ status: currentStatus,
297
+ title: typeof task?.title === 'string' ? task.title : null,
298
+ result: typeof task?.result === 'string' ? task.result.slice(0, 1000) : null,
299
+ error: typeof task?.error === 'string' ? task.error : null,
300
+ },
301
+ }
302
+ }
303
+
304
+ async function evaluateWatchJob(job: WatchJob): Promise<{ triggered: boolean; result: Record<string, unknown> }> {
305
+ if (job.type === 'time') {
306
+ const runAt = typeof job.runAt === 'number' ? job.runAt : 0
307
+ return { triggered: runAt > 0 && runAt <= now(), result: { runAt } }
308
+ }
309
+ if (job.type === 'http') return evaluateHttpLikeJob(job, false)
310
+ if (job.type === 'page') return evaluateHttpLikeJob(job, true)
311
+ if (job.type === 'file') return evaluateFileJob(job)
312
+ if (job.type === 'task') return evaluateTaskJob(job)
313
+ if (job.type === 'email') {
314
+ const folder = typeof job.target.folder === 'string' ? job.target.folder : undefined
315
+ const messages = await fetchMailboxMessages({
316
+ folder,
317
+ from: typeof job.condition.from === 'string' ? job.condition.from : undefined,
318
+ subjectContains: typeof job.condition.subjectContains === 'string' ? job.condition.subjectContains : undefined,
319
+ bodyContains: typeof job.condition.containsText === 'string' ? job.condition.containsText : undefined,
320
+ query: typeof job.condition.query === 'string' ? job.condition.query : undefined,
321
+ unreadOnly: job.condition.unreadOnly === true,
322
+ hasAttachments: job.condition.hasAttachments === true,
323
+ uidGreaterThan: typeof job.target.baselineUid === 'number' ? job.target.baselineUid : undefined,
324
+ limit: 20,
325
+ })
326
+ const match = messages[0]
327
+ return {
328
+ triggered: !!match,
329
+ result: match
330
+ ? {
331
+ uid: match.uid,
332
+ from: match.from,
333
+ subject: match.subject,
334
+ snippet: match.snippet,
335
+ attachmentCount: match.attachments.length,
336
+ messageId: match.messageId,
337
+ }
338
+ : {
339
+ folder: folder || 'INBOX',
340
+ baselineUid: typeof job.target.baselineUid === 'number' ? job.target.baselineUid : null,
341
+ },
342
+ }
343
+ }
344
+ return { triggered: false, result: { note: 'Webhook waits are triggered by inbound webhook delivery.' } }
345
+ }
346
+
347
+ export async function processDueWatchJobs(timestamp = now()): Promise<{ checked: number; triggered: number; failed: number }> {
348
+ const all = listWatchJobs({ status: 'active' })
349
+ let checked = 0
350
+ let triggered = 0
351
+ let failed = 0
352
+ const updates: Array<[string, WatchJob]> = []
353
+
354
+ for (const job of all) {
355
+ if (typeof job.timeoutAt === 'number' && job.timeoutAt > 0 && job.timeoutAt <= timestamp) {
356
+ failed += 1
357
+ updates.push([job.id, {
358
+ ...job,
359
+ status: 'failed',
360
+ lastError: 'Watch timed out before condition was met.',
361
+ updatedAt: timestamp,
362
+ }])
363
+ continue
364
+ }
365
+ if (typeof job.nextCheckAt === 'number' && job.nextCheckAt > timestamp) continue
366
+ if (job.type === 'webhook' || job.type === 'mailbox' || job.type === 'approval') continue
367
+
368
+ checked += 1
369
+ try {
370
+ const evaluation = await evaluateWatchJob(job)
371
+ if (evaluation.triggered) {
372
+ triggered += 1
373
+ const completed = {
374
+ ...job,
375
+ status: 'triggered' as const,
376
+ result: evaluation.result,
377
+ lastError: null,
378
+ lastCheckedAt: timestamp,
379
+ lastTriggeredAt: timestamp,
380
+ updatedAt: timestamp,
381
+ }
382
+ updates.push([job.id, completed])
383
+ wakeFromWatch(completed, evaluation.result)
384
+ } else {
385
+ updates.push([job.id, scheduleNextCheck({
386
+ ...job,
387
+ lastCheckedAt: timestamp,
388
+ result: evaluation.result,
389
+ }, timestamp)])
390
+ }
391
+ } catch (err: unknown) {
392
+ failed += 1
393
+ updates.push([job.id, scheduleNextCheck({
394
+ ...job,
395
+ lastCheckedAt: timestamp,
396
+ lastError: err instanceof Error ? err.message : String(err),
397
+ }, timestamp)])
398
+ }
399
+ }
400
+
401
+ if (updates.length > 0) {
402
+ upsertWatchJobs(updates)
403
+ notifyWatchJobsChanged()
404
+ }
405
+
406
+ return { checked, triggered, failed }
407
+ }
408
+
409
+ export function triggerWebhookWatchJobs(params: {
410
+ webhookId: string
411
+ event: string
412
+ payloadPreview?: string
413
+ }): WatchJob[] {
414
+ const matches = listWatchJobs({ status: 'active' }).filter((job) => {
415
+ if (job.type !== 'webhook') return false
416
+ const watchWebhookId = typeof job.target.webhookId === 'string' ? job.target.webhookId : ''
417
+ if (watchWebhookId !== params.webhookId) return false
418
+ const expectedEvent = typeof job.condition.event === 'string' ? job.condition.event.trim() : ''
419
+ return !expectedEvent || expectedEvent === params.event
420
+ })
421
+
422
+ const updated = matches.map((job) => {
423
+ const next: WatchJob = {
424
+ ...job,
425
+ status: 'triggered',
426
+ result: {
427
+ webhookId: params.webhookId,
428
+ event: params.event,
429
+ payloadPreview: params.payloadPreview?.slice(0, 1200) || '',
430
+ },
431
+ lastTriggeredAt: now(),
432
+ updatedAt: now(),
433
+ }
434
+ wakeFromWatch(next, next.result || null)
435
+ return [next.id, next] as [string, WatchJob]
436
+ })
437
+
438
+ if (updated.length > 0) {
439
+ upsertWatchJobs(updated)
440
+ notifyWatchJobsChanged()
441
+ }
442
+
443
+ return updated.map(([, job]) => job)
444
+ }
445
+
446
+ export function triggerMailboxWatchJobs(params: {
447
+ sessionId: string
448
+ envelope: MailboxEnvelope
449
+ }): WatchJob[] {
450
+ const matches = listWatchJobs({ status: 'active' }).filter((job) => {
451
+ if (job.type !== 'mailbox') return false
452
+ const targetSessionId = typeof job.target.sessionId === 'string' ? job.target.sessionId : ''
453
+ if (targetSessionId !== params.sessionId) return false
454
+ const expectedType = typeof job.condition.type === 'string' ? job.condition.type.trim() : ''
455
+ const correlationId = typeof job.condition.correlationId === 'string' ? job.condition.correlationId.trim() : ''
456
+ const fromSessionId = typeof job.condition.fromSessionId === 'string' ? job.condition.fromSessionId.trim() : ''
457
+ const payloadContains = typeof job.condition.containsText === 'string' ? job.condition.containsText.trim() : ''
458
+ if (expectedType && params.envelope.type !== expectedType) return false
459
+ if (correlationId && params.envelope.correlationId !== correlationId) return false
460
+ if (fromSessionId && params.envelope.fromSessionId !== fromSessionId) return false
461
+ if (payloadContains && !params.envelope.payload.includes(payloadContains)) return false
462
+ return true
463
+ })
464
+
465
+ const updated = matches.map((job) => {
466
+ const next: WatchJob = {
467
+ ...job,
468
+ status: 'triggered',
469
+ result: {
470
+ envelopeId: params.envelope.id,
471
+ type: params.envelope.type,
472
+ correlationId: params.envelope.correlationId || null,
473
+ payload: params.envelope.payload.slice(0, 1200),
474
+ fromSessionId: params.envelope.fromSessionId || null,
475
+ },
476
+ lastTriggeredAt: now(),
477
+ updatedAt: now(),
478
+ }
479
+ wakeFromWatch(next, next.result || null)
480
+ return [next.id, next] as [string, WatchJob]
481
+ })
482
+
483
+ if (updated.length > 0) {
484
+ upsertWatchJobs(updated)
485
+ notifyWatchJobsChanged()
486
+ }
487
+
488
+ return updated.map(([, job]) => job)
489
+ }
490
+
491
+ export function triggerApprovalWatchJobs(params: {
492
+ approvalId: string
493
+ status: 'approved' | 'rejected'
494
+ title?: string
495
+ description?: string
496
+ }): WatchJob[] {
497
+ const approvals = loadApprovals()
498
+ const approval = approvals[params.approvalId] as Record<string, unknown> | undefined
499
+ const matches = listWatchJobs({ status: 'active' }).filter((job) => {
500
+ if (job.type !== 'approval') return false
501
+ const targetApprovalId = typeof job.target.approvalId === 'string' ? job.target.approvalId : ''
502
+ if (targetApprovalId !== params.approvalId) return false
503
+ const statuses = Array.isArray(job.condition.statusIn)
504
+ ? job.condition.statusIn.filter((value): value is string => typeof value === 'string')
505
+ : ['approved']
506
+ return statuses.includes(params.status)
507
+ })
508
+
509
+ const updated = matches.map((job) => {
510
+ const next: WatchJob = {
511
+ ...job,
512
+ status: 'triggered',
513
+ result: {
514
+ approvalId: params.approvalId,
515
+ status: params.status,
516
+ title: params.title || (typeof approval?.title === 'string' ? approval.title : null),
517
+ description: params.description || (typeof approval?.description === 'string' ? approval.description : null),
518
+ },
519
+ lastTriggeredAt: now(),
520
+ updatedAt: now(),
521
+ }
522
+ wakeFromWatch(next, next.result || null)
523
+ return [next.id, next] as [string, WatchJob]
524
+ })
525
+
526
+ if (updated.length > 0) {
527
+ upsertWatchJobs(updated)
528
+ notifyWatchJobsChanged()
529
+ }
530
+
531
+ return updated.map(([, job]) => job)
532
+ }