@swarmclawai/swarmclaw 1.9.21 → 1.9.23

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 +23 -5
  2. package/package.json +2 -2
  3. package/src/components/chat/activity-moment.tsx +4 -0
  4. package/src/components/chat/tool-call-bubble.tsx +6 -0
  5. package/src/components/schedules/schedule-console.tsx +3 -0
  6. package/src/lib/server/capability-router.test.ts +4 -4
  7. package/src/lib/server/capability-router.ts +1 -0
  8. package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +27 -0
  9. package/src/lib/server/chat-execution/chat-execution-utils.ts +21 -0
  10. package/src/lib/server/chat-execution/iteration-event-handler.ts +1 -1
  11. package/src/lib/server/chat-execution/stream-continuation.ts +6 -2
  12. package/src/lib/server/plugins-advanced.test.ts +7 -3
  13. package/src/lib/server/runtime/scheduler.test.ts +129 -0
  14. package/src/lib/server/runtime/scheduler.ts +62 -35
  15. package/src/lib/server/schedules/schedule-history.test.ts +14 -0
  16. package/src/lib/server/schedules/schedule-history.ts +1 -0
  17. package/src/lib/server/schedules/schedule-lifecycle.ts +5 -28
  18. package/src/lib/server/schedules/schedule-normalization.ts +6 -28
  19. package/src/lib/server/schedules/schedule-timing.test.ts +80 -0
  20. package/src/lib/server/schedules/schedule-timing.ts +179 -0
  21. package/src/lib/server/session-tools/web-crawl.test.ts +106 -0
  22. package/src/lib/server/session-tools/web-inputs.test.ts +5 -0
  23. package/src/lib/server/session-tools/web-utils.ts +8 -2
  24. package/src/lib/server/session-tools/web.ts +256 -29
  25. package/src/lib/server/storage.ts +2 -0
  26. package/src/lib/server/tasks/task-lifecycle.ts +35 -5
  27. package/src/lib/server/tool-aliases.ts +1 -1
  28. package/src/lib/server/tool-capability-policy-advanced.test.ts +3 -3
  29. package/src/lib/server/tool-capability-policy.ts +4 -1
  30. package/src/lib/server/tool-planning.test.ts +2 -1
  31. package/src/lib/server/tool-planning.ts +31 -0
  32. package/src/lib/server/untrusted-content.ts +2 -2
  33. package/src/types/schedule.ts +2 -2
  34. package/src/types/session.ts +2 -0
  35. package/src/types/task.ts +1 -0
package/README.md CHANGED
@@ -151,13 +151,13 @@ clawhub install swarmclaw
151
151
 
152
152
  [Browse on ClawHub](https://clawhub.ai/skills/swarmclaw)
153
153
 
154
- ## v1.9.17 Highlights
154
+ ## v1.9.23 Highlights
155
155
 
156
- Agent configuration history is now visible in the agent editor, so operators can review recent saved versions and restore prior settings without leaving the agent workflow.
156
+ Schedule reliability is now more deterministic for recurring autonomous work, especially after restarts or stale stored timing state.
157
157
 
158
- - **Agent sheet history.** Advanced agent settings list recent saved versions with timestamp, actor, and provider/model snapshot.
159
- - **One-click restore.** Restoring a prior version uses the existing config-version restore API, refreshes agent state, and closes the sheet to avoid stale form data.
160
- - **Regression coverage.** New tests cover config-version list/restore routes and UI summary formatting.
158
+ - **Cron drift repair.** Active schedules repair stale future cron slots before they skip the nearest run.
159
+ - **Stable stagger.** Staggered schedules keep a deterministic per-schedule offset.
160
+ - **Mission continuity.** Schedule-created board tasks keep a persistent mission link across recurring runs.
161
161
 
162
162
  ## Hosted Deploys
163
163
 
@@ -409,6 +409,24 @@ Operational docs: https://swarmclaw.ai/docs/observability
409
409
 
410
410
  ## Releases
411
411
 
412
+ ### v1.9.23 Highlights
413
+
414
+ Schedule reliability release: recurring work now repairs stale timing state before it can skip the nearest run, and scheduled board tasks keep mission context across repeat launches.
415
+
416
+ - **Cron drift repair.** Active cron schedules repair missing or invalid `nextRunAt` values and stale future cron slots before the scheduler decides whether work is due.
417
+ - **Tick-time advancement.** Cron and interval schedules now advance from the scheduler tick time instead of the process wall clock, making restart and catch-up behavior deterministic.
418
+ - **Stable stagger.** Schedule stagger offsets are deterministic per schedule, avoiding thundering-herd launches without moving a saved next-run target on every recompute.
419
+ - **Mission continuity.** Schedule-created board tasks attach to a persistent mission link, so recurring runs share the same operational context.
420
+
421
+ ### v1.9.22 Highlights
422
+
423
+ Research tools release: agents now get direct `web_extract` and `web_crawl` tools alongside `web_search`, `web_fetch`, and the unified `web` tool.
424
+
425
+ - **Source-grounded extraction.** `web_extract` returns a page title, canonical URL, and readable content for known source URLs.
426
+ - **Bounded crawls.** `web_crawl` walks same-origin links by default with conservative page and depth caps, plus an explicit external-link opt-in.
427
+ - **Better routing.** Tool aliases, capability policy, planning hints, continuation recovery, and the chat UI all recognize the granular research tools.
428
+ - **Regression coverage.** New tests cover action inference, tool-call translation, direct tool registration, extraction cleanup, and same-origin crawl bounds.
429
+
412
430
  ### v1.9.21 Highlights
413
431
 
414
432
  Provider diagnostics release: connection checks now return a structured step timeline across setup, provider settings, and agent editing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.9.21",
3
+ "version": "1.9.23",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -88,7 +88,7 @@
88
88
  "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/electron-after-pack.test.mjs scripts/electron-signing-config.test.mjs scripts/ensure-sandbox-browser-image.test.mjs scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
89
89
  "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
90
90
  "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/gateways/gateway-topology.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/gateways/topology-route.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
91
- "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/agent-planning-mode.test.ts src/lib/agent-config-history.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/provider-diagnostics.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/prompt-sections.planning-mode.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/chats/session-context-pack.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/runs/run-handoff.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/server/schedules/schedule-preview.test.ts src/lib/quality/release-readiness.test.ts src/lib/quality/architecture-health.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-execution-policy.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-pack-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/config-versions/config-versions-route.test.ts src/app/api/runs/run-handoff-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/preview/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
91
+ "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/agent-planning-mode.test.ts src/lib/agent-config-history.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/provider-diagnostics.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/prompt-sections.planning-mode.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/chats/session-context-pack.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/runs/run-handoff.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/server/schedules/schedule-timing.test.ts src/lib/server/schedules/schedule-preview.test.ts src/lib/quality/release-readiness.test.ts src/lib/quality/architecture-health.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-execution-policy.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/server/session-tools/web-crawl.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-pack-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/config-versions/config-versions-route.test.ts src/app/api/runs/run-handoff-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/preview/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
92
92
  "test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
93
93
  "test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
94
94
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
@@ -19,6 +19,9 @@ const NOTABLE_TOOLS: Record<string, { label: string; color: string; icon: 'brain
19
19
  delegate_to_agent: { label: 'Delegating task', color: '#6366F1', icon: 'delegate' },
20
20
  check_delegation_status: { label: 'Checking delegation', color: '#6366F1', icon: 'delegate' },
21
21
  web_search: { label: 'Searched the web', color: '#22C55E', icon: 'search' },
22
+ web_fetch: { label: 'Read a web page', color: '#22C55E', icon: 'search' },
23
+ web_extract: { label: 'Extracted a web page', color: '#22C55E', icon: 'search' },
24
+ web_crawl: { label: 'Crawled a site', color: '#22C55E', icon: 'search' },
22
25
  connector_message_tool: { label: 'Sent a message', color: '#F97316', icon: 'message' },
23
26
  }
24
27
 
@@ -35,6 +38,7 @@ function extractSnippet(toolName: string, toolInput: string): string | null {
35
38
  if (toolName === 'check_delegation_status' && parsed.agentName) return parsed.agentName
36
39
  if (toolName.startsWith('delegate_to_') && parsed.task) return parsed.task
37
40
  if (toolName === 'web_search' && parsed.query) return parsed.query
41
+ if ((toolName === 'web_fetch' || toolName === 'web_extract' || toolName === 'web_crawl') && parsed.url) return parsed.url
38
42
  if (toolName === 'connector_message_tool' && parsed.to) return parsed.to
39
43
  } catch { /* ignore parse errors */ }
40
44
  return null
@@ -20,6 +20,8 @@ const TOOL_COLORS: Record<string, string> = {
20
20
  create_spreadsheet: '#10B981',
21
21
  web_search: '#3B82F6',
22
22
  web_fetch: '#3B82F6',
23
+ web_extract: '#3B82F6',
24
+ web_crawl: '#3B82F6',
23
25
  spawn_subagent: '#8B5CF6',
24
26
  delegate_to_agent: '#6366F1',
25
27
  check_delegation_status: '#6366F1',
@@ -77,6 +79,8 @@ export const TOOL_LABELS: Record<string, string> = {
77
79
  create_spreadsheet: 'Create Spreadsheet',
78
80
  web_search: 'Web Search',
79
81
  web_fetch: 'Web Fetch',
82
+ web_extract: 'Web Extract',
83
+ web_crawl: 'Web Crawl',
80
84
  claude_code: 'Claude Code',
81
85
  codex_cli: 'Codex CLI',
82
86
  opencode_cli: 'OpenCode CLI',
@@ -127,6 +131,8 @@ export const TOOL_DESCRIPTIONS: Record<string, string> = {
127
131
  create_spreadsheet: 'Create Excel or CSV files from structured data',
128
132
  web_search: 'Search the web for information',
129
133
  web_fetch: 'Fetch and read web page content',
134
+ web_extract: 'Extract readable content from a source URL',
135
+ web_crawl: 'Crawl a bounded set of pages from one site',
130
136
  claude_code: 'Enable delegation to Claude Code CLI',
131
137
  codex_cli: 'Enable delegation to OpenAI Codex CLI',
132
138
  opencode_cli: 'Enable delegation to OpenCode CLI',
@@ -162,6 +162,8 @@ function historyActionLabel(action: ScheduleHistoryEntry['action']): string {
162
162
  return 'Skipped'
163
163
  case 'failed':
164
164
  return 'Failed'
165
+ case 'repaired':
166
+ return 'Repaired'
165
167
  default:
166
168
  return action
167
169
  }
@@ -171,6 +173,7 @@ function historyActionBadge(action: ScheduleHistoryEntry['action']): string {
171
173
  if (action === 'created' || action === 'restored' || action === 'run_started') return badgeClass('completed')
172
174
  if (action === 'failed') return badgeClass('failed')
173
175
  if (action === 'skipped' || action === 'archived') return badgeClass('paused')
176
+ if (action === 'repaired') return badgeClass('running')
174
177
  return badgeClass('running')
175
178
  }
176
179
 
@@ -26,7 +26,7 @@ test('routeTaskIntent keeps coding prompts prioritized over memory keywords', ()
26
26
  test('routeTaskIntent keeps hybrid research-plus-media prompts in research intent', () => {
27
27
  const decision = routeTaskIntent(
28
28
  'Can you tell me more if there is any news related to the US-Iran war, and can you send me some screenshots and give me a summary and maybe send me a voice note about it?',
29
- ['web_search', 'web_fetch', 'browser', 'manage_connectors'],
29
+ ['web_search', 'web_fetch', 'web_crawl', 'browser', 'manage_connectors'],
30
30
  null,
31
31
  makeClassification({
32
32
  taskIntent: 'research',
@@ -39,7 +39,7 @@ test('routeTaskIntent keeps hybrid research-plus-media prompts in research inten
39
39
  )
40
40
 
41
41
  assert.equal(decision.intent, 'research')
42
- assert.deepEqual(decision.preferredTools, ['web_search', 'web_fetch', 'browser', 'connector_message_tool'])
42
+ assert.deepEqual(decision.preferredTools, ['web_search', 'web_fetch', 'web_extract', 'web_crawl', 'browser', 'connector_message_tool'])
43
43
  })
44
44
 
45
45
  test('routeTaskIntent treats direct voice-note delivery as outreach', () => {
@@ -72,7 +72,7 @@ test('routeTaskIntent treats keep-watching update requests as research even with
72
72
  )
73
73
 
74
74
  assert.equal(decision.intent, 'research')
75
- assert.deepEqual(decision.preferredTools, ['web_search', 'web_fetch'])
75
+ assert.deepEqual(decision.preferredTools, ['web_search', 'web_fetch', 'web_extract', 'web_crawl'])
76
76
  })
77
77
 
78
78
  test('routeTaskIntent uses structured classification when available', () => {
@@ -99,7 +99,7 @@ test('routeTaskIntent uses structured classification when available', () => {
99
99
  )
100
100
 
101
101
  assert.equal(decision.intent, 'browsing')
102
- assert.deepEqual(decision.preferredTools, ['browser', 'web_fetch'])
102
+ assert.deepEqual(decision.preferredTools, ['browser', 'web_fetch', 'web_extract'])
103
103
  })
104
104
 
105
105
  function makeClassification(overrides: Partial<MessageClassification>): MessageClassification {
@@ -144,6 +144,7 @@ export function routeTaskIntent(
144
144
  [
145
145
  TOOL_CAPABILITY.researchSearch,
146
146
  TOOL_CAPABILITY.researchFetch,
147
+ TOOL_CAPABILITY.researchCrawl,
147
148
  ...(wantsScreenshots ? [TOOL_CAPABILITY.browserCapture] : []),
148
149
  ...(wantsVoiceDelivery ? [TOOL_CAPABILITY.deliveryVoiceNote] : []),
149
150
  ...(wantsOutboundDelivery ? [TOOL_CAPABILITY.deliveryMedia, TOOL_CAPABILITY.deliveryMessage] : []),
@@ -407,6 +407,33 @@ describe('translateRequestedToolInvocation advanced', () => {
407
407
  assert.equal(args.action, 'search')
408
408
  assert.equal(args.query, 'test query')
409
409
  })
410
+
411
+ it('maps web_extract to web with action=extract', () => {
412
+ const { toolName, args } = translateRequestedToolInvocation(
413
+ 'web_extract',
414
+ { url: 'https://example.com/source' },
415
+ '',
416
+ ['web'],
417
+ )
418
+ assert.equal(toolName, 'web')
419
+ assert.equal(args.action, 'extract')
420
+ assert.equal(args.url, 'https://example.com/source')
421
+ })
422
+
423
+ it('maps web_crawl to web with bounded crawl arguments', () => {
424
+ const { toolName, args } = translateRequestedToolInvocation(
425
+ 'web_crawl',
426
+ { url: 'https://example.com/', maxPages: 4, maxDepth: 1, includeExternal: false },
427
+ '',
428
+ ['web'],
429
+ )
430
+ assert.equal(toolName, 'web')
431
+ assert.equal(args.action, 'crawl')
432
+ assert.equal(args.url, 'https://example.com/')
433
+ assert.equal(args.maxPages, 4)
434
+ assert.equal(args.maxDepth, 1)
435
+ assert.equal(args.includeExternal, false)
436
+ })
410
437
  })
411
438
 
412
439
  // ---------------------------------------------------------------------------
@@ -127,6 +127,27 @@ export function translateRequestedToolInvocation(
127
127
  },
128
128
  }
129
129
  }
130
+ if (requestedName === 'web_extract') {
131
+ return {
132
+ toolName: 'web',
133
+ args: {
134
+ action: 'extract',
135
+ url: rawArgs.url,
136
+ },
137
+ }
138
+ }
139
+ if (requestedName === 'web_crawl') {
140
+ return {
141
+ toolName: 'web',
142
+ args: {
143
+ action: 'crawl',
144
+ url: rawArgs.url || rawArgs.query,
145
+ maxPages: rawArgs.maxPages ?? rawArgs.maxResults,
146
+ maxDepth: rawArgs.maxDepth,
147
+ includeExternal: rawArgs.includeExternal,
148
+ },
149
+ }
150
+ }
130
151
  if (requestedName === 'delegate_to_claude_code') {
131
152
  return { toolName: 'delegate', args: { ...rawArgs, backend: 'claude' } }
132
153
  }
@@ -349,7 +349,7 @@ export async function processIterationEvents(opts: ProcessIterationEventsOpts):
349
349
  }
350
350
  if (
351
351
  boundedExternalExecutionTask
352
- && ['http_request', 'web', 'web_search', 'web_fetch', 'browser'].includes(toolName)
352
+ && ['http_request', 'web', 'web_search', 'web_fetch', 'web_extract', 'web_crawl', 'browser'].includes(toolName)
353
353
  && countExternalExecutionResearchSteps(state.streamedToolEvents) >= 5
354
354
  && countDistinctExternalResearchHosts(state.streamedToolEvents) >= 3
355
355
  ) {
@@ -196,7 +196,7 @@ function getRequestedArtifactStatus(params: {
196
196
 
197
197
  export function countExternalExecutionResearchSteps(toolEvents: MessageToolEvent[]): number {
198
198
  return toolEvents.filter((event) => {
199
- return ['http_request', 'web', 'web_search', 'web_fetch', 'browser'].includes(event.name)
199
+ return ['http_request', 'web', 'web_search', 'web_fetch', 'web_extract', 'web_crawl', 'browser'].includes(event.name)
200
200
  }).length
201
201
  }
202
202
 
@@ -300,6 +300,8 @@ const RECOVERABLE_TOOL_ERROR_NAMES = new Set([
300
300
  'web',
301
301
  'web_search',
302
302
  'web_fetch',
303
+ 'web_extract',
304
+ 'web_crawl',
303
305
  'http_request',
304
306
  ])
305
307
 
@@ -390,6 +392,8 @@ export function getToolFrequencyHint(toolName: string, sessionExtensions: string
390
392
  case 'http_request':
391
393
  case 'web_search':
392
394
  case 'web_fetch':
395
+ case 'web_extract':
396
+ case 'web_crawl':
393
397
  return 'Hint: You have done extensive research. Stop gathering more sources and use the information you already have to complete the task.'
394
398
 
395
399
  case 'spawn_subagent':
@@ -490,7 +494,7 @@ function buildDeliverableFollowthroughPrompt(params: {
490
494
  }
491
495
 
492
496
  if (
493
- params.toolEvents.some((event) => ['web', 'web_search', 'web_fetch', 'browser', 'http_request'].includes(event.name))
497
+ params.toolEvents.some((event) => ['web', 'web_search', 'web_fetch', 'web_extract', 'web_crawl', 'browser', 'http_request'].includes(event.name))
494
498
  && !params.toolEvents.some((event) => ['files', 'write_file', 'edit_file', 'shell', 'execute_command'].includes(event.name))
495
499
  ) {
496
500
  lines.push(
@@ -135,11 +135,13 @@ describe('expandExtensionIds', () => {
135
135
  }
136
136
  })
137
137
 
138
- it('web expands to include web_search and web_fetch', () => {
138
+ it('web expands to include granular web tools', () => {
139
139
  const result = expandExtensionIds(['web'])
140
140
  assert.ok(result.includes('web'))
141
141
  assert.ok(result.includes('web_search'))
142
142
  assert.ok(result.includes('web_fetch'))
143
+ assert.ok(result.includes('web_extract'))
144
+ assert.ok(result.includes('web_crawl'))
143
145
  })
144
146
 
145
147
  it('removes duplicates after expansion', () => {
@@ -199,12 +201,14 @@ describe('expandExtensionIds', () => {
199
201
  // getExtensionAliases
200
202
  // ---------------------------------------------------------------------------
201
203
  describe('getExtensionAliases', () => {
202
- it('web returns [web, web_search, web_fetch]', () => {
204
+ it('web returns the full web alias group', () => {
203
205
  const result = getExtensionAliases('web')
204
206
  assert.ok(result.includes('web'))
205
207
  assert.ok(result.includes('web_search'))
206
208
  assert.ok(result.includes('web_fetch'))
207
- assert.equal(result.length, 5) // web, web_search, web_fetch, http_request, http
209
+ assert.ok(result.includes('web_extract'))
210
+ assert.ok(result.includes('web_crawl'))
211
+ assert.equal(result.length, 7) // web, web_search, web_fetch, web_extract, web_crawl, http_request, http
208
212
  })
209
213
 
210
214
  it('web_search returns the same group as web', () => {
@@ -202,6 +202,135 @@ describe('scheduler wake targeting', () => {
202
202
  assert.deepEqual(output.deliveryModes, ['silent'])
203
203
  })
204
204
 
205
+ it('repairs stale future cron next-run slots without launching a run', () => {
206
+ const output = runSchedulerWithTempDataDir(`
207
+ const storageMod = await import('@/lib/server/storage')
208
+ const schedulerMod = await import('@/lib/server/runtime/scheduler')
209
+ const storage = storageMod.default || storageMod
210
+ const scheduler = schedulerMod.default || schedulerMod
211
+
212
+ const now = Date.parse('2026-05-06T07:30:00.000Z')
213
+ const staleFuture = Date.parse('2026-05-12T08:00:00.000Z')
214
+
215
+ storage.saveSchedules({
216
+ 'sched-cron': {
217
+ id: 'sched-cron',
218
+ name: 'Daily status',
219
+ agentId: 'agent-1',
220
+ taskPrompt: 'Send the daily status.',
221
+ scheduleType: 'cron',
222
+ cron: '0 8 * * *',
223
+ timezone: 'UTC',
224
+ status: 'active',
225
+ nextRunAt: staleFuture,
226
+ createdAt: now - 10_000,
227
+ updatedAt: now - 10_000,
228
+ },
229
+ })
230
+
231
+ await scheduler.runSchedulerTickForTests(now)
232
+ const schedule = storage.loadSchedules()['sched-cron']
233
+
234
+ console.log(JSON.stringify({
235
+ status: schedule.status,
236
+ nextRunAt: schedule.nextRunAt,
237
+ taskCount: Object.keys(storage.loadTasks()).length,
238
+ historyAction: schedule.history?.[0]?.action || null,
239
+ historyReason: schedule.history?.[0]?.metadata?.reason || null,
240
+ }))
241
+ `)
242
+
243
+ assert.equal(output.status, 'active')
244
+ assert.equal(output.nextRunAt, Date.parse('2026-05-06T08:00:00.000Z'))
245
+ assert.equal(output.taskCount, 0)
246
+ assert.equal(output.historyAction, 'repaired')
247
+ assert.equal(output.historyReason, 'stale_future')
248
+ })
249
+
250
+ it('advances cron schedules from the scheduler tick time after firing', () => {
251
+ const output = runSchedulerWithTempDataDir(`
252
+ const storageMod = await import('@/lib/server/storage')
253
+ const schedulerMod = await import('@/lib/server/runtime/scheduler')
254
+ const heartbeatWakeMod = await import('@/lib/server/runtime/heartbeat-wake')
255
+ const storage = storageMod.default || storageMod
256
+ const scheduler = schedulerMod.default || schedulerMod
257
+ const heartbeatWake = heartbeatWakeMod.default || heartbeatWakeMod
258
+
259
+ const now = Date.parse('2030-01-01T08:00:30.000Z')
260
+ const dueAt = Date.parse('2030-01-01T08:00:00.000Z')
261
+
262
+ storage.saveAgents({
263
+ 'agent-1': {
264
+ id: 'agent-1',
265
+ name: 'Daily Agent',
266
+ description: '',
267
+ systemPrompt: '',
268
+ provider: 'openai',
269
+ model: 'gpt-test',
270
+ threadSessionId: 'thread-main',
271
+ createdAt: now - 10_000,
272
+ updatedAt: now - 10_000,
273
+ },
274
+ })
275
+
276
+ storage.saveSessions({
277
+ 'thread-main': {
278
+ id: 'thread-main',
279
+ name: 'Daily Agent',
280
+ cwd: process.env.WORKSPACE_DIR,
281
+ user: 'tester',
282
+ provider: 'openai',
283
+ model: 'gpt-test',
284
+ claudeSessionId: null,
285
+ messages: [],
286
+ createdAt: now - 10_000,
287
+ lastActiveAt: now - 5_000,
288
+ active: true,
289
+ currentRunId: null,
290
+ agentId: 'agent-1',
291
+ shortcutForAgentId: 'agent-1',
292
+ },
293
+ })
294
+
295
+ storage.saveSchedules({
296
+ 'sched-cron': {
297
+ id: 'sched-cron',
298
+ name: 'Daily wake',
299
+ agentId: 'agent-1',
300
+ taskPrompt: 'Wake for the daily status.',
301
+ taskMode: 'wake_only',
302
+ message: 'Run the daily status.',
303
+ scheduleType: 'cron',
304
+ cron: '0 8 * * *',
305
+ timezone: 'UTC',
306
+ status: 'active',
307
+ nextRunAt: dueAt,
308
+ createdInSessionId: 'thread-main',
309
+ createdAt: now - 10_000,
310
+ updatedAt: now - 10_000,
311
+ },
312
+ })
313
+
314
+ await scheduler.runSchedulerTickForTests(now)
315
+ const schedule = storage.loadSchedules()['sched-cron']
316
+ const wakes = heartbeatWake.snapshotPendingHeartbeatWakesForTests()
317
+
318
+ console.log(JSON.stringify({
319
+ status: schedule.status,
320
+ nextRunAt: schedule.nextRunAt,
321
+ runNumber: schedule.runNumber,
322
+ historyAction: schedule.history?.[0]?.action || null,
323
+ wakeCount: wakes.length,
324
+ }))
325
+ `)
326
+
327
+ assert.equal(output.status, 'active')
328
+ assert.equal(output.nextRunAt, Date.parse('2030-01-02T08:00:00.000Z'))
329
+ assert.equal(output.runNumber, 1)
330
+ assert.equal(output.historyAction, 'run_started')
331
+ assert.equal(output.wakeCount, 1)
332
+ })
333
+
205
334
  it('reuses a persistent mission for scheduled task runs', () => {
206
335
  const output = runSchedulerWithTempDataDir(`
207
336
  const storageMod = await import('@/lib/server/storage')
@@ -2,7 +2,6 @@ import { listAgents } from '@/lib/server/agents/agent-repository'
2
2
  import { loadSchedules, upsertSchedule, upsertSchedules } from '@/lib/server/schedules/schedule-repository'
3
3
  import { loadTasks, upsertTask } from '@/lib/server/tasks/task-repository'
4
4
  import { enqueueTask } from '@/lib/server/runtime/queue'
5
- import { CronExpressionParser } from 'cron-parser'
6
5
  import { pushMainLoopEventToMainSessions } from '@/lib/server/agents/main-agent-loop'
7
6
  import { getScheduleSignatureKey } from '@/lib/schedules/schedule-dedupe'
8
7
  import { dispatchWake } from '@/lib/server/runtime/wake-dispatcher'
@@ -14,6 +13,7 @@ import { hasActiveProtocolRunForSchedule, launchProtocolRunForSchedule } from '@
14
13
  import { hmrSingleton } from '@/lib/shared-utils'
15
14
  import { log } from '@/lib/server/logger'
16
15
  import { appendScheduleHistoryEntry } from '@/lib/server/schedules/schedule-history'
16
+ import { assessScheduleNextRunRepair, computeScheduleNextRunAt } from '@/lib/server/schedules/schedule-timing'
17
17
  import type { Schedule } from '@/types'
18
18
 
19
19
  const TAG = 'scheduler'
@@ -52,7 +52,7 @@ export function startScheduler() {
52
52
  if (schedulerState.intervalId) return
53
53
  log.info(TAG, 'Starting scheduler engine (60s tick)')
54
54
 
55
- // Compute initial nextRunAt for cron schedules missing it
55
+ // Compute initial timing and repair stale nextRunAt values before the first tick.
56
56
  computeNextRuns()
57
57
 
58
58
  schedulerState.intervalId = setInterval(tick, TICK_INTERVAL)
@@ -66,32 +66,64 @@ export function stopScheduler() {
66
66
  }
67
67
  }
68
68
 
69
- function computeNextRuns() {
69
+ function computeNextRuns(now = Date.now()): Record<string, Schedule> {
70
70
  const schedules = loadSchedules()
71
71
  const changedEntries: Array<[string, Schedule]> = []
72
72
  for (const schedule of Object.values(schedules)) {
73
73
  if (schedule.status !== 'active') continue
74
- if (schedule.scheduleType === 'cron' && schedule.cron && !schedule.nextRunAt) {
75
- try {
76
- const interval = CronExpressionParser.parse(
77
- schedule.cron,
78
- schedule.timezone ? { tz: schedule.timezone } : undefined,
79
- )
80
- schedule.nextRunAt = interval.next().getTime()
81
- changedEntries.push([schedule.id, schedule])
82
- } catch (err) {
83
- log.error(TAG, `Invalid cron for ${schedule.id}:`, err)
84
- schedule.status = 'failed'
85
- changedEntries.push([schedule.id, schedule])
86
- }
74
+ const assessment = assessScheduleNextRunRepair(schedule, now)
75
+ if (!assessment.ok) {
76
+ log.error(TAG, `Invalid cron for ${schedule.id}`)
77
+ const failedSchedule = appendScheduleHistoryEntry({
78
+ ...schedule,
79
+ status: 'failed',
80
+ updatedAt: now,
81
+ }, {
82
+ now,
83
+ actor: 'system',
84
+ action: 'failed',
85
+ summary: `Schedule failed because cron could not be parsed: "${schedule.name}"`,
86
+ changes: [{
87
+ field: 'status',
88
+ label: 'Status',
89
+ before: 'active',
90
+ after: 'failed',
91
+ }],
92
+ metadata: { reason: 'invalid_cron' },
93
+ })
94
+ schedules[schedule.id] = failedSchedule
95
+ changedEntries.push([schedule.id, failedSchedule])
96
+ continue
97
+ }
98
+ if (assessment.repair) {
99
+ const repairedSchedule = appendScheduleHistoryEntry({
100
+ ...schedule,
101
+ nextRunAt: assessment.nextRunAt,
102
+ updatedAt: now,
103
+ }, {
104
+ now,
105
+ actor: 'system',
106
+ action: 'repaired',
107
+ summary: `Schedule timing repaired: "${schedule.name}"`,
108
+ changes: [{
109
+ field: 'nextRunAt',
110
+ label: 'Next run',
111
+ before: assessment.previousNextRunAt == null ? null : String(assessment.previousNextRunAt),
112
+ after: String(assessment.nextRunAt),
113
+ }],
114
+ metadata: { reason: assessment.reason },
115
+ })
116
+ schedules[schedule.id] = repairedSchedule
117
+ changedEntries.push([schedule.id, repairedSchedule])
87
118
  }
88
119
  }
89
120
  if (changedEntries.length > 0) upsertSchedules(changedEntries)
121
+ return schedules
90
122
  }
91
123
 
92
124
  async function tick(now = Date.now()) {
93
125
  await processDueWatchJobs(now)
94
- const schedules = loadSchedules()
126
+ const schedules = computeNextRuns(now)
95
127
  const agents = listAgents()
96
128
  const tasks = loadTasks()
97
129
  const inFlightScheduleKeys = new Set<string>(
@@ -101,27 +133,22 @@ async function tick(now = Date.now()) {
101
133
  .filter((value: string) => value.length > 0),
102
134
  )
103
135
 
104
- const applyStagger = (ts: number, staggerSec: number | null | undefined): number => {
105
- if (!staggerSec || staggerSec <= 0) return ts
106
- return ts + Math.floor(Math.random() * staggerSec * 1000)
107
- }
108
-
109
136
  const advanceSchedule = (schedule: Schedule): void => {
110
- if (schedule.scheduleType === 'cron' && schedule.cron) {
111
- try {
112
- const interval = CronExpressionParser.parse(
113
- schedule.cron,
114
- schedule.timezone ? { tz: schedule.timezone } : undefined,
115
- )
116
- schedule.nextRunAt = applyStagger(interval.next().getTime(), schedule.staggerSec)
117
- } catch {
118
- schedule.status = 'failed'
119
- }
120
- } else if (schedule.scheduleType === 'interval' && schedule.intervalMs) {
121
- schedule.nextRunAt = applyStagger(now + schedule.intervalMs, schedule.staggerSec)
122
- } else if (schedule.scheduleType === 'once') {
137
+ if (schedule.scheduleType === 'once') {
123
138
  schedule.status = 'completed'
124
139
  schedule.nextRunAt = undefined
140
+ return
141
+ }
142
+
143
+ try {
144
+ const nextRunAt = computeScheduleNextRunAt(schedule, now)
145
+ if (nextRunAt == null) {
146
+ schedule.status = 'failed'
147
+ } else {
148
+ schedule.nextRunAt = nextRunAt
149
+ }
150
+ } catch {
151
+ schedule.status = 'failed'
125
152
  }
126
153
  }
127
154
 
@@ -118,4 +118,18 @@ describe('schedule history', () => {
118
118
  assert.equal(history[24].id, 'hist-5')
119
119
  assert.equal(schedule.revision, 30)
120
120
  })
121
+
122
+ it('retains scheduler repair history entries', () => {
123
+ const history = normalizeScheduleHistory([{
124
+ id: 'hist-repair',
125
+ at: 1_000,
126
+ actor: 'system',
127
+ action: 'repaired',
128
+ revision: 1,
129
+ summary: 'Schedule timing repaired',
130
+ }])
131
+
132
+ assert.equal(history.length, 1)
133
+ assert.equal(history[0].action, 'repaired')
134
+ })
121
135
  })
@@ -57,6 +57,7 @@ const HISTORY_ACTIONS = new Set<ScheduleHistoryAction>([
57
57
  'run_started',
58
58
  'skipped',
59
59
  'failed',
60
+ 'repaired',
60
61
  ])
61
62
 
62
63
  function cleanActor(value: string): string {