@swarmclawai/swarmclaw 1.9.17 → 1.9.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -407,6 +407,33 @@ Operational docs: https://swarmclaw.ai/docs/observability
407
407
 
408
408
  ## Releases
409
409
 
410
+ ### v1.9.19 Highlights
411
+
412
+ Output hygiene release: final assistant responses now use the shared internal metadata scrubber before persistence, UI reset, connector delivery, and completion hooks.
413
+
414
+ - **Multi-block scrubbing.** Repeated internal metadata payloads are stripped in one pass instead of stopping after the first block.
415
+ - **Malformed prelude cleanup.** When a validated internal block is followed by a malformed internal fragment, the leftover prelude is removed before user-facing text is delivered.
416
+ - **Shared finalizer path.** Post-stream finalization now uses the same metadata scrubber as the chat UI, keeping stored, streamed, and connector-visible output aligned.
417
+ - **Regression coverage.** Tests cover repeated classifier-shape blocks, malformed follow-on fragments, and false-positive protection for malformed text without a prior validated strip.
418
+
419
+ ### v1.9.18 Highlights
420
+
421
+ Schedule preflight release: schedules now show server-backed timing forecasts before save, with timezone-aware cron previews and warnings for risky drafts.
422
+
423
+ - **Schedule preview API.** `POST /api/schedules/preview` validates a draft schedule through the same normalization path as saved schedules and returns the next calculated runs.
424
+ - **Timezone-aware schedule sheet.** Cron schedules can set an explicit timezone, preview the next runs from the server, and see warnings before saving.
425
+ - **Stagger and one-shot controls.** Operators can add a stagger window to recurring schedules and choose a run-once delay from the schedule workflow.
426
+ - **CLI access.** `swarmclaw schedules preview --data '{...}'` exposes the same forecast for scripts and release automation.
427
+
428
+ ### v1.9.17 Highlights
429
+
430
+ Agent configuration history release: SwarmClaw now surfaces saved agent versions directly in the agent editor, giving operators a fast rollback path for agent settings.
431
+
432
+ - **Agent sheet history.** Advanced settings list recent saved versions with relative time, actor, and provider/model snapshot.
433
+ - **One-click restore.** Operators can restore a prior agent configuration through the existing version-restore API without leaving the agent workflow.
434
+ - **Stale-form protection.** Successful restore reloads agent state and closes the sheet so operators reopen the refreshed record.
435
+ - **Regression coverage.** New tests cover config-version list/restore routes and summary formatting.
436
+
410
437
  ### v1.9.16 Highlights
411
438
 
412
439
  Agent planning controls release: strict planning is now a first-class agent setting instead of a hidden persisted field, so operators can decide which agents must expose machine-readable plans before multi-step work.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.9.17",
3
+ "version": "1.9.19",
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/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/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/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/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/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",
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",
@@ -0,0 +1,48 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import { POST as previewSchedule } from './route'
5
+
6
+ test('POST /api/schedules/preview returns a timing forecast without persisting', async () => {
7
+ const agentId = `missing-preview-agent-${Date.now()}`
8
+ const response = await previewSchedule(new Request('http://local/api/schedules/preview', {
9
+ method: 'POST',
10
+ headers: { 'content-type': 'application/json' },
11
+ body: JSON.stringify({
12
+ agentId,
13
+ name: 'Preview smoke',
14
+ taskPrompt: 'Review the queue',
15
+ scheduleType: 'cron',
16
+ cron: '0 9 * * *',
17
+ timezone: 'UTC',
18
+ status: 'active',
19
+ }),
20
+ }))
21
+
22
+ assert.equal(response.status, 200)
23
+ const payload = await response.json() as Record<string, unknown>
24
+ assert.equal(payload.ok, true)
25
+ assert.equal(payload.scheduleType, 'cron')
26
+ assert.equal(payload.timezone, 'UTC')
27
+ assert.equal(Array.isArray(payload.nextRuns), true)
28
+ assert.equal((payload.nextRuns as unknown[]).length, 5)
29
+ assert.match(String((payload.warnings as string[]).join('\n')), /Agent not found/)
30
+ })
31
+
32
+ test('POST /api/schedules/preview rejects invalid draft timing', async () => {
33
+ const response = await previewSchedule(new Request('http://local/api/schedules/preview', {
34
+ method: 'POST',
35
+ headers: { 'content-type': 'application/json' },
36
+ body: JSON.stringify({
37
+ agentId: 'schedule-preview-agent-invalid',
38
+ taskPrompt: 'Review the queue',
39
+ scheduleType: 'cron',
40
+ cron: '0 9 * * *',
41
+ timezone: 'Not/AZone',
42
+ }),
43
+ }))
44
+
45
+ assert.equal(response.status, 400)
46
+ const payload = await response.json() as Record<string, unknown>
47
+ assert.match(String(payload.error || ''), /invalid timezone/)
48
+ })
@@ -0,0 +1,14 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { safeParseBody } from '@/lib/server/safe-parse-body'
3
+ import { previewScheduleFromRoute } from '@/lib/server/schedules/schedule-route-service'
4
+
5
+ export const dynamic = 'force-dynamic'
6
+
7
+ export async function POST(req: Request) {
8
+ const { data: body, error } = await safeParseBody(req)
9
+ if (error) return error
10
+ const result = previewScheduleFromRoute((body || {}) as Record<string, unknown>)
11
+ return result.ok
12
+ ? NextResponse.json(result.payload)
13
+ : NextResponse.json(result.payload, { status: result.status })
14
+ }
package/src/cli/index.js CHANGED
@@ -582,6 +582,7 @@ const COMMAND_GROUPS = [
582
582
  cmd('list', 'GET', '/schedules', 'List schedules'),
583
583
  cmd('get', 'GET', '/schedules/:id', 'Get schedule by id'),
584
584
  cmd('history', 'GET', '/schedules/:id/history', 'Get schedule revision history'),
585
+ cmd('preview', 'POST', '/schedules/preview', 'Preview schedule timing and validation', { expectsJsonBody: true }),
585
586
  cmd('create', 'POST', '/schedules', 'Create schedule', { expectsJsonBody: true }),
586
587
  cmd('update', 'PUT', '/schedules/:id', 'Update schedule', { expectsJsonBody: true }),
587
588
  cmd('delete', 'DELETE', '/schedules/:id', 'Delete schedule'),
package/src/cli/spec.js CHANGED
@@ -418,6 +418,7 @@ const COMMAND_GROUPS = {
418
418
  description: 'Scheduled task automation',
419
419
  commands: {
420
420
  list: { description: 'List schedules', method: 'GET', path: '/schedules' },
421
+ preview: { description: 'Preview schedule timing and validation', method: 'POST', path: '/schedules/preview' },
421
422
  create: { description: 'Create schedule', method: 'POST', path: '/schedules' },
422
423
  get: { description: 'Get schedule by id (from list)', virtualGet: true, collectionPath: '/schedules', params: ['id'] },
423
424
  history: { description: 'Get schedule revision history', method: 'GET', path: '/schedules/:id/history', params: ['id'] },
@@ -59,7 +59,8 @@ function badgeClass(status: string): string {
59
59
 
60
60
  function formatScheduleCadence(schedule: Schedule): string {
61
61
  if (schedule.scheduleType === 'cron' && schedule.cron) {
62
- return cronToHuman(schedule.cron)
62
+ const timezone = schedule.timezone ? ` (${schedule.timezone})` : ''
63
+ return `${cronToHuman(schedule.cron)}${timezone}`
63
64
  }
64
65
  if (schedule.scheduleType === 'interval' && schedule.intervalMs) {
65
66
  const minutes = Math.round(schedule.intervalMs / 60_000)
@@ -2,13 +2,13 @@
2
2
 
3
3
  import { useEffect, useState, useMemo } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
- import { createSchedule, updateSchedule, deleteSchedule } from '@/lib/schedules/schedules'
5
+ import { createSchedule, updateSchedule, deleteSchedule, previewSchedule } from '@/lib/schedules/schedules'
6
6
  import { BottomSheet } from '@/components/shared/bottom-sheet'
7
7
  import { AgentPickerList } from '@/components/shared/agent-picker-list'
8
8
  import { ConfirmDialog } from '@/components/shared/confirm-dialog'
9
9
  import { inputClass } from '@/components/shared/form-styles'
10
10
  import { AgentAvatar } from '@/components/agents/agent-avatar'
11
- import type { ScheduleTaskMode, ScheduleType, ScheduleStatus } from '@/types'
11
+ import type { Schedule, SchedulePreviewResponse, ScheduleTaskMode, ScheduleType, ScheduleStatus } from '@/types'
12
12
  import cronstrue from 'cronstrue'
13
13
  import { SectionLabel } from '@/components/shared/section-label'
14
14
  import { SCHEDULE_TEMPLATES, type ScheduleTemplate } from '@/lib/schedules/schedule-templates'
@@ -32,20 +32,6 @@ const CRON_PRESETS = [
32
32
  { label: 'Weekly Mon 9am', cron: '0 9 * * 1' },
33
33
  ]
34
34
 
35
- async function getNextRunsAsync(cron: string, count: number = 3): Promise<Date[]> {
36
- try {
37
- const { CronExpressionParser } = await import('cron-parser')
38
- const interval = CronExpressionParser.parse(cron)
39
- const runs: Date[] = []
40
- for (let i = 0; i < count; i++) {
41
- runs.push(interval.next().toDate())
42
- }
43
- return runs
44
- } catch {
45
- return []
46
- }
47
- }
48
-
49
35
  function formatCronHuman(cron: string): string {
50
36
  try {
51
37
  return cronstrue.toString(cron, { use24HourTimeFormat: false })
@@ -54,11 +40,6 @@ function formatCronHuman(cron: string): string {
54
40
  }
55
41
  }
56
42
 
57
- function formatDate(d: Date): string {
58
- return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }) +
59
- ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
60
- }
61
-
62
43
  const STEPS_CREATE = ['Template', 'What', 'When', 'Review'] as const
63
44
  const STEPS_EDIT = ['What', 'When', 'Review'] as const
64
45
  type Step = 0 | 1 | 2 | 3
@@ -107,6 +88,11 @@ export function ScheduleSheet() {
107
88
  const [taskMode, setTaskMode] = useState<ScheduleTaskMode>('task')
108
89
  const [message, setMessage] = useState('')
109
90
  const [customCron, setCustomCron] = useState(false)
91
+ const [timezone, setTimezone] = useState('')
92
+ const [staggerSec, setStaggerSec] = useState(0)
93
+ const [timingPreview, setTimingPreview] = useState<SchedulePreviewResponse | null>(null)
94
+ const [timingPreviewLoading, setTimingPreviewLoading] = useState(false)
95
+ const [timingPreviewError, setTimingPreviewError] = useState('')
110
96
  const [confirmDelete, setConfirmDelete] = useState(false)
111
97
  const [deleting, setDeleting] = useState(false)
112
98
 
@@ -136,6 +122,8 @@ export function ScheduleSheet() {
136
122
  setTaskMode(editing.taskMode === 'wake_only' ? 'wake_only' : editing.taskMode === 'protocol' ? 'protocol' : 'task')
137
123
  setMessage(editing.message || '')
138
124
  setCustomCron(!CRON_PRESETS.some((p) => p.cron === editing.cron))
125
+ setTimezone(editing.timezone || '')
126
+ setStaggerSec(editing.staggerSec || 0)
139
127
  } else if (templatePrefill) {
140
128
  // Opened from a quick-start card with pre-filled values
141
129
  setName(templatePrefill.name)
@@ -148,6 +136,10 @@ export function ScheduleSheet() {
148
136
  if (templatePrefill.intervalMs) setIntervalMs(templatePrefill.intervalMs)
149
137
  setAgentId('')
150
138
  setStatus('active')
139
+ setTaskMode('task')
140
+ setMessage('')
141
+ setTimezone('')
142
+ setStaggerSec(0)
151
143
  setStep(1) // Skip template picker, go to "What" step
152
144
  setTemplatePrefill(null)
153
145
  } else {
@@ -162,26 +154,22 @@ export function ScheduleSheet() {
162
154
  setTaskMode('task')
163
155
  setMessage('')
164
156
  setCustomCron(false)
157
+ setTimezone('')
158
+ setStaggerSec(0)
165
159
  }
160
+ setTimingPreview(null)
161
+ setTimingPreviewError('')
162
+ setTimingPreviewLoading(false)
166
163
  }
167
164
  // eslint-disable-next-line react-hooks/exhaustive-deps
168
165
  }, [open, editingId])
169
166
 
170
167
  const cronHuman = useMemo(() => formatCronHuman(cron), [cron])
171
- const [nextRuns, setNextRuns] = useState<Date[]>([])
172
- useEffect(() => {
173
- getNextRunsAsync(cron).then(setNextRuns)
174
- }, [cron])
175
-
176
- const onClose = () => {
177
- setConfirmDelete(false)
178
- setDeleting(false)
179
- setOpen(false)
180
- setEditingId(null)
181
- }
182
168
 
183
- const handleSave = async () => {
184
- const data = {
169
+ const buildScheduleData = () => {
170
+ const trimmedTimezone = timezone.trim()
171
+ const normalizedStaggerSec = Math.max(0, Math.trunc(staggerSec || 0))
172
+ return {
185
173
  name: name.trim(),
186
174
  agentId,
187
175
  taskPrompt: taskMode === 'wake_only' ? message : taskPrompt,
@@ -192,8 +180,67 @@ export function ScheduleSheet() {
192
180
  cron: scheduleType === 'cron' ? cron : undefined,
193
181
  intervalMs: scheduleType === 'interval' ? intervalMs : undefined,
194
182
  runAt: scheduleType === 'once' ? Date.now() + intervalMs : undefined,
183
+ timezone: scheduleType === 'cron' && trimmedTimezone ? trimmedTimezone : undefined,
184
+ staggerSec: normalizedStaggerSec > 0 ? normalizedStaggerSec : undefined,
195
185
  status,
196
186
  }
187
+ }
188
+
189
+ useEffect(() => {
190
+ if (!open || step === templateStep) {
191
+ setTimingPreview(null)
192
+ setTimingPreviewError('')
193
+ setTimingPreviewLoading(false)
194
+ return
195
+ }
196
+
197
+ const taskText = taskMode === 'wake_only' ? message.trim() : taskPrompt.trim()
198
+ const hasTiming = scheduleType === 'cron'
199
+ ? cron.trim().length > 0
200
+ : intervalMs > 0
201
+ if (!agentId || !taskText || !hasTiming) {
202
+ setTimingPreview(null)
203
+ setTimingPreviewError('')
204
+ setTimingPreviewLoading(false)
205
+ return
206
+ }
207
+
208
+ let cancelled = false
209
+ const timer = window.setTimeout(() => {
210
+ setTimingPreviewLoading(true)
211
+ setTimingPreviewError('')
212
+ previewSchedule(buildScheduleData() as Partial<Schedule>)
213
+ .then((result) => {
214
+ if (cancelled) return
215
+ setTimingPreview(result)
216
+ setTimingPreviewError(result.ok ? '' : result.error)
217
+ })
218
+ .catch((err: unknown) => {
219
+ if (cancelled) return
220
+ setTimingPreview(null)
221
+ setTimingPreviewError(err instanceof Error ? err.message : 'Failed to preview schedule')
222
+ })
223
+ .finally(() => {
224
+ if (!cancelled) setTimingPreviewLoading(false)
225
+ })
226
+ }, 300)
227
+
228
+ return () => {
229
+ cancelled = true
230
+ window.clearTimeout(timer)
231
+ }
232
+ // eslint-disable-next-line react-hooks/exhaustive-deps
233
+ }, [open, step, templateStep, name, agentId, taskPrompt, taskMode, message, scheduleType, cron, intervalMs, status, timezone, staggerSec])
234
+
235
+ const onClose = () => {
236
+ setConfirmDelete(false)
237
+ setDeleting(false)
238
+ setOpen(false)
239
+ setEditingId(null)
240
+ }
241
+
242
+ const handleSave = async () => {
243
+ const data = buildScheduleData()
197
244
  try {
198
245
  if (editing) {
199
246
  await updateSchedule(editing.id, data)
@@ -227,10 +274,56 @@ export function ScheduleSheet() {
227
274
 
228
275
  // Step validation
229
276
  const step0Valid = name.trim().length > 0 && agentId.length > 0 && (taskMode === 'wake_only' ? message.trim().length > 0 : taskPrompt.trim().length > 0)
230
- const step1Valid = scheduleType === 'cron' ? cron.trim().length > 0 : intervalMs > 0
277
+ const step1Valid = (scheduleType === 'cron' ? cron.trim().length > 0 : intervalMs > 0) && !timingPreviewError
231
278
 
232
279
  const selectedAgent = agentId ? agents[agentId] : null
233
280
  const creatorAgent = editing?.createdByAgentId ? agents[editing.createdByAgentId] : null
281
+ const previewOk = timingPreview && timingPreview.ok ? timingPreview : null
282
+
283
+ const timingPreviewPanel = (
284
+ <div className="p-4 rounded-[14px] bg-surface border border-white/[0.06]">
285
+ <div className="flex flex-wrap items-center gap-2 mb-2">
286
+ <div className="text-[14px] text-text-2 font-600">
287
+ {scheduleType === 'cron' ? cronHuman : previewOk?.cadence || (scheduleType === 'once' ? 'Run once' : `Every ${Math.round(intervalMs / 60000)} minutes`)}
288
+ </div>
289
+ {previewOk?.timezone && (
290
+ <span className="rounded-[999px] bg-white/[0.05] px-2 py-0.5 text-[11px] font-600 text-text-3">
291
+ {previewOk.timezone}
292
+ </span>
293
+ )}
294
+ </div>
295
+ {scheduleType === 'cron' && cron && (
296
+ <div className="font-mono text-[12px] text-text-3/50 mb-3">{cron}</div>
297
+ )}
298
+ {timingPreviewLoading && (
299
+ <div className="text-[12px] text-text-3">Checking schedule...</div>
300
+ )}
301
+ {timingPreviewError && (
302
+ <div className="text-[12px] text-red-400">{timingPreviewError}</div>
303
+ )}
304
+ {previewOk && !timingPreviewLoading && (
305
+ <>
306
+ {previewOk.nextRuns.length > 0 ? (
307
+ <div className="space-y-1.5">
308
+ <div className="text-[11px] text-text-3/60 uppercase tracking-wider font-600">Next runs</div>
309
+ {previewOk.nextRuns.map((run) => (
310
+ <div key={run.iso} className="text-[12px] text-text-3 font-mono">{run.label}</div>
311
+ ))}
312
+ </div>
313
+ ) : (
314
+ <div className="text-[12px] text-text-3">No future runs calculated.</div>
315
+ )}
316
+ {previewOk.warnings.length > 0 && (
317
+ <div className="mt-3 space-y-1.5">
318
+ {previewOk.warnings.map((warning) => (
319
+ <div key={warning} className="text-[12px] text-amber-300">{warning}</div>
320
+ ))}
321
+ </div>
322
+ )}
323
+ </>
324
+ )}
325
+ </div>
326
+ )
234
327
 
235
328
  return (
236
329
  <BottomSheet open={open} onClose={onClose} wide>
@@ -501,21 +594,32 @@ export function ScheduleSheet() {
501
594
  <input type="text" value={cron} onChange={(e) => setCron(e.target.value)} placeholder="0 * * * *" className={`${inputClass} font-mono text-[14px] mb-3`} />
502
595
  )}
503
596
 
504
- {/* Human-readable preview */}
505
- <div className="p-4 rounded-[14px] bg-surface border border-white/[0.06]">
506
- <div className="text-[14px] text-text-2 font-600 mb-2">{cronHuman}</div>
507
- {cron && (
508
- <div className="font-mono text-[12px] text-text-3/50 mb-3">{cron}</div>
509
- )}
510
- {nextRuns.length > 0 && (
511
- <div className="space-y-1.5">
512
- <div className="text-[11px] text-text-3/60 uppercase tracking-wider font-600">Next runs</div>
513
- {nextRuns.map((d, i) => (
514
- <div key={i} className="text-[12px] text-text-3 font-mono">{formatDate(d)}</div>
515
- ))}
516
- </div>
517
- )}
597
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
598
+ <div>
599
+ <SectionLabel>Timezone</SectionLabel>
600
+ <input
601
+ type="text"
602
+ value={timezone}
603
+ onChange={(e) => setTimezone(e.target.value)}
604
+ placeholder="UTC or America/New_York"
605
+ className={inputClass}
606
+ style={{ fontFamily: 'inherit' }}
607
+ />
608
+ </div>
609
+ <div>
610
+ <SectionLabel>Stagger (seconds)</SectionLabel>
611
+ <input
612
+ type="number"
613
+ min={0}
614
+ value={staggerSec}
615
+ onChange={(e) => setStaggerSec(Math.max(0, parseInt(e.target.value, 10) || 0))}
616
+ className={inputClass}
617
+ style={{ fontFamily: 'inherit' }}
618
+ />
619
+ </div>
518
620
  </div>
621
+
622
+ {timingPreviewPanel}
519
623
  </div>
520
624
  )}
521
625
 
@@ -529,6 +633,34 @@ export function ScheduleSheet() {
529
633
  className={inputClass}
530
634
  style={{ fontFamily: 'inherit' }}
531
635
  />
636
+ <div className="mt-3 mb-3">
637
+ <SectionLabel>Stagger (seconds)</SectionLabel>
638
+ <input
639
+ type="number"
640
+ min={0}
641
+ value={staggerSec}
642
+ onChange={(e) => setStaggerSec(Math.max(0, parseInt(e.target.value, 10) || 0))}
643
+ className={inputClass}
644
+ style={{ fontFamily: 'inherit' }}
645
+ />
646
+ </div>
647
+ {timingPreviewPanel}
648
+ </div>
649
+ )}
650
+
651
+ {scheduleType === 'once' && (
652
+ <div className="mb-8">
653
+ <SectionLabel>Run After (minutes)</SectionLabel>
654
+ <input
655
+ type="number"
656
+ value={Math.round(intervalMs / 60000)}
657
+ onChange={(e) => setIntervalMs(Math.max(1, parseInt(e.target.value) || 1) * 60000)}
658
+ className={inputClass}
659
+ style={{ fontFamily: 'inherit' }}
660
+ />
661
+ <div className="mt-3">
662
+ {timingPreviewPanel}
663
+ </div>
532
664
  </div>
533
665
  )}
534
666
 
@@ -620,6 +752,15 @@ export function ScheduleSheet() {
620
752
  {scheduleType === 'once' && (
621
753
  <div className="text-[12px] text-text-3 font-mono mt-0.5">Run once</div>
622
754
  )}
755
+ {scheduleType === 'cron' && timezone.trim() && (
756
+ <div className="text-[12px] text-text-3 mt-1">Timezone: {timezone.trim()}</div>
757
+ )}
758
+ {staggerSec > 0 && (
759
+ <div className="text-[12px] text-text-3 mt-1">Stagger: up to {staggerSec} seconds</div>
760
+ )}
761
+ {previewOk?.nextRuns[0] && (
762
+ <div className="text-[12px] text-text-3 mt-1">Next: {previewOk.nextRuns[0].label}</div>
763
+ )}
623
764
  </div>
624
765
  {editing && (
625
766
  <div>
@@ -1,5 +1,5 @@
1
1
  import { api } from '@/lib/app/api-client'
2
- import type { Schedule, ScheduleHistoryEntry } from '@/types'
2
+ import type { Schedule, ScheduleHistoryEntry, SchedulePreviewResponse } from '@/types'
3
3
 
4
4
  export interface ScheduleArchiveResponse {
5
5
  ok: boolean
@@ -31,6 +31,9 @@ export const fetchSchedules = (includeArchived = false) =>
31
31
  export const createSchedule = (data: Omit<Schedule, 'id' | 'createdAt' | 'lastRunAt' | 'nextRunAt'>) =>
32
32
  api<Schedule>('POST', '/schedules', data)
33
33
 
34
+ export const previewSchedule = (data: Partial<Schedule>) =>
35
+ api<SchedulePreviewResponse>('POST', '/schedules/preview', data, { timeoutMs: 8_000 })
36
+
34
37
  export const updateSchedule = (id: string, data: Partial<Schedule>) =>
35
38
  api<Schedule>('PUT', `/schedules/${id}`, data)
36
39
 
@@ -33,6 +33,28 @@ describe('stripLeakedClassificationJson', () => {
33
33
  assert.equal(cleaned.includes('taskIntent'), false)
34
34
  })
35
35
 
36
+ it('strips multiple leaked classification JSON blocks', () => {
37
+ const input = `${VALID_LEAK}\n${VALID_LEAK}\nTask created and delegated.`
38
+ const { cleaned, stripped } = stripLeakedClassificationJson(input)
39
+ assert.equal(stripped, true)
40
+ assert.equal(cleaned, 'Task created and delegated.')
41
+ })
42
+
43
+ it('strips a malformed internal prelude after a validated leaked block', () => {
44
+ const malformedPrelude = [
45
+ '{',
46
+ ' "taskIntent": "research",',
47
+ ' "isBroadGoal":{',
48
+ ' false,',
49
+ ' "isLightweightDirectChat": false,',
50
+ '}',
51
+ ].join('\n')
52
+ const input = `${VALID_LEAK}\n${malformedPrelude}\nAll five research bundles reviewed.`
53
+ const { cleaned, stripped } = stripLeakedClassificationJson(input)
54
+ assert.equal(stripped, true)
55
+ assert.equal(cleaned, 'All five research bundles reviewed.')
56
+ })
57
+
36
58
  it('leaves normal assistant text untouched', () => {
37
59
  const input = 'Your favorite color is blue.'
38
60
  const { cleaned, stripped } = stripLeakedClassificationJson(input)
@@ -8,6 +8,7 @@
8
8
  import type { KnowledgeRetrievalTrace, Session, UsageRecord } from '@/types'
9
9
  import { log } from '@/lib/server/logger'
10
10
  import type { ChatTurnState } from '@/lib/server/chat-execution/chat-turn-state'
11
+ import { stripAllInternalMetadata } from '@/lib/strip-internal-metadata'
11
12
 
12
13
  const TAG = 'post-stream'
13
14
  import { extractSuggestions } from '@/lib/server/suggestions'
@@ -20,7 +21,6 @@ import {
20
21
  shouldForceExternalServiceSummary,
21
22
  } from '@/lib/server/chat-execution/chat-streaming-utils'
22
23
  import {
23
- MessageClassificationSchema,
24
24
  type MessageClassification,
25
25
  } from '@/lib/server/chat-execution/message-classifier'
26
26
  import {
@@ -28,48 +28,9 @@ import {
28
28
  } from '@/lib/server/chat-execution/stream-continuation'
29
29
  import { buildForcedExternalServiceSummary } from '@/lib/server/chat-execution/prompt-builder'
30
30
 
31
- // ---------------------------------------------------------------------------
32
- // Classification JSON leak detection — strips MessageClassification objects
33
- // that some models echo verbatim into their response text. Candidate JSON
34
- // substrings are found by brace-matching, then validated against the actual
35
- // MessageClassificationSchema — the single source of truth for what a
36
- // classifier object looks like.
37
- // ---------------------------------------------------------------------------
38
-
39
- /** Returns the index just past the balanced `}` for the `{` at `start`, or -1. */
40
- function findBalancedObjectEnd(text: string, start: number): number {
41
- let depth = 0
42
- let inString = false
43
- let escape = false
44
- for (let i = start; i < text.length; i++) {
45
- const ch = text[i]
46
- if (escape) { escape = false; continue }
47
- if (inString) {
48
- if (ch === '\\') escape = true
49
- else if (ch === '"') inString = false
50
- continue
51
- }
52
- if (ch === '"') inString = true
53
- else if (ch === '{') depth += 1
54
- else if (ch === '}') {
55
- depth -= 1
56
- if (depth === 0) return i + 1
57
- }
58
- }
59
- return -1
60
- }
61
-
62
31
  export function stripLeakedClassificationJson(text: string): { cleaned: string; stripped: boolean } {
63
- for (let i = text.indexOf('{'); i !== -1; i = text.indexOf('{', i + 1)) {
64
- const end = findBalancedObjectEnd(text, i)
65
- if (end === -1) break
66
- let parsed: unknown
67
- try { parsed = JSON.parse(text.slice(i, end)) } catch { continue }
68
- if (!MessageClassificationSchema.safeParse(parsed).success) continue
69
- log.warn(TAG, 'Stripped leaked classification JSON from model output')
70
- return { cleaned: (text.slice(0, i) + text.slice(end)).trimStart(), stripped: true }
71
- }
72
- return { cleaned: text, stripped: false }
32
+ const cleaned = stripAllInternalMetadata(text)
33
+ return { cleaned, stripped: cleaned !== text }
73
34
  }
74
35
 
75
36
  // StreamAgentChatResult is defined inline to avoid circular dependency with stream-agent-chat.ts
@@ -174,9 +135,10 @@ export async function finalizeStreamResult(opts: FinalizeStreamResultOpts): Prom
174
135
  }
175
136
  }
176
137
 
177
- // Strip leaked classification JSON from model output (e.g. `{ "isDeliverableTask": true, ... }`)
138
+ // Strip leaked internal metadata from model output (e.g. `{ "isDeliverableTask": true, ... }`)
178
139
  const leakResult = stripLeakedClassificationJson(state.fullText)
179
140
  if (leakResult.stripped) {
141
+ log.warn(TAG, 'Stripped leaked internal metadata from model output')
180
142
  state.fullText = leakResult.cleaned
181
143
  // Emit a reset so the frontend re-renders with the cleaned text
182
144
  write(`data: ${JSON.stringify({ t: 'reset', text: leakResult.cleaned })}\n\n`)
@@ -51,6 +51,15 @@ function normalizeScheduleTimestamp(value: unknown): number | null {
51
51
  return Math.trunc(parsedTime)
52
52
  }
53
53
 
54
+ function isValidTimeZone(value: string): boolean {
55
+ try {
56
+ new Intl.DateTimeFormat('en-US', { timeZone: value })
57
+ return true
58
+ } catch {
59
+ return false
60
+ }
61
+ }
62
+
54
63
  /**
55
64
  * Parse natural "at HH:MM" time expressions into a cron string.
56
65
  * Supports: "at 09:00", "at 14:30", "at 9am", "at 2:30pm", "daily at 09:00"
@@ -226,7 +235,14 @@ export function normalizeSchedulePayload(payload: SchedulePayload, opts: Normali
226
235
 
227
236
  // Preserve timezone and stagger
228
237
  const timezone = trimString(normalized.timezone)
229
- if (timezone) normalized.timezone = timezone
238
+ if (timezone) {
239
+ if (!isValidTimeZone(timezone)) {
240
+ return { ok: false, error: `Error: invalid timezone: ${timezone}.` }
241
+ }
242
+ normalized.timezone = timezone
243
+ } else if (normalized.timezone != null) {
244
+ delete normalized.timezone
245
+ }
230
246
  const staggerSec = normalizePositiveInt(normalized.staggerSec)
231
247
  if (staggerSec != null) normalized.staggerSec = staggerSec
232
248
  else if (normalized.staggerSec != null) delete normalized.staggerSec
@@ -0,0 +1,89 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import { previewSchedulePayload } from './schedule-preview'
5
+
6
+ const NOW = Date.parse('2026-05-06T12:00:00.000Z')
7
+
8
+ function basePayload(overrides: Record<string, unknown> = {}) {
9
+ return {
10
+ agentId: 'agent-1',
11
+ name: 'Preview schedule',
12
+ taskPrompt: 'Review the queue',
13
+ status: 'active',
14
+ ...overrides,
15
+ }
16
+ }
17
+
18
+ describe('previewSchedulePayload', () => {
19
+ it('forecasts cron schedules with an explicit timezone', () => {
20
+ const preview = previewSchedulePayload(basePayload({
21
+ scheduleType: 'cron',
22
+ cron: '0 9 * * *',
23
+ timezone: 'UTC',
24
+ }), { now: NOW })
25
+
26
+ assert.equal(preview.ok, true)
27
+ if (!preview.ok) return
28
+ assert.equal(preview.scheduleType, 'cron')
29
+ assert.equal(preview.timezone, 'UTC')
30
+ assert.equal(preview.nextRuns.length, 5)
31
+ assert.equal(preview.nextRunAt, Date.parse('2026-05-07T09:00:00.000Z'))
32
+ assert.equal(preview.nextRuns[0].iso, '2026-05-07T09:00:00.000Z')
33
+ assert.deepEqual(preview.warnings, [])
34
+ })
35
+
36
+ it('warns when cron schedules rely on host local time', () => {
37
+ const preview = previewSchedulePayload(basePayload({
38
+ scheduleType: 'cron',
39
+ cron: '0 9 * * *',
40
+ }), { now: NOW, count: 2 })
41
+
42
+ assert.equal(preview.ok, true)
43
+ if (!preview.ok) return
44
+ assert.equal(preview.nextRuns.length, 2)
45
+ assert.match(preview.warnings.join('\n'), /host local timezone/)
46
+ })
47
+
48
+ it('rejects invalid timezone values before saving', () => {
49
+ const preview = previewSchedulePayload(basePayload({
50
+ scheduleType: 'cron',
51
+ cron: '0 9 * * *',
52
+ timezone: 'Not/AZone',
53
+ }), { now: NOW })
54
+
55
+ assert.equal(preview.ok, false)
56
+ if (preview.ok) return
57
+ assert.match(preview.error, /invalid timezone/)
58
+ })
59
+
60
+ it('forecasts interval schedules from the preview time', () => {
61
+ const preview = previewSchedulePayload(basePayload({
62
+ scheduleType: 'interval',
63
+ intervalMs: 30 * 60_000,
64
+ }), { now: NOW, count: 3 })
65
+
66
+ assert.equal(preview.ok, true)
67
+ if (!preview.ok) return
68
+ assert.equal(preview.scheduleType, 'interval')
69
+ assert.equal(preview.nextRuns.length, 3)
70
+ assert.deepEqual(preview.nextRuns.map((run) => run.at), [
71
+ NOW + 30 * 60_000,
72
+ NOW + 60 * 60_000,
73
+ NOW + 90 * 60_000,
74
+ ])
75
+ })
76
+
77
+ it('warns when a run-once schedule is already in the past', () => {
78
+ const preview = previewSchedulePayload(basePayload({
79
+ scheduleType: 'once',
80
+ runAt: NOW - 60_000,
81
+ }), { now: NOW })
82
+
83
+ assert.equal(preview.ok, true)
84
+ if (!preview.ok) return
85
+ assert.equal(preview.nextRunAt, null)
86
+ assert.equal(preview.nextRuns.length, 0)
87
+ assert.match(preview.warnings.join('\n'), /past/)
88
+ })
89
+ })
@@ -0,0 +1,171 @@
1
+ import { CronExpressionParser } from 'cron-parser'
2
+
3
+ import { normalizeSchedulePayload } from '@/lib/server/schedules/schedule-normalization'
4
+ import type { SchedulePreviewResponse, SchedulePreviewRun, ScheduleType } from '@/types'
5
+
6
+ type SchedulePayload = Record<string, unknown>
7
+
8
+ export interface PreviewScheduleOptions {
9
+ cwd?: string | null
10
+ now?: number
11
+ count?: number
12
+ }
13
+
14
+ const DEFAULT_PREVIEW_RUNS = 5
15
+
16
+ function trimString(value: unknown): string {
17
+ return typeof value === 'string' ? value.trim() : ''
18
+ }
19
+
20
+ function positiveNumber(value: unknown): number | null {
21
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null
22
+ return Math.trunc(value)
23
+ }
24
+
25
+ function formatDuration(ms: number): string {
26
+ const minutes = Math.round(ms / 60_000)
27
+ if (minutes <= 1) return 'Every minute'
28
+ if (minutes < 60) return `Every ${minutes} minutes`
29
+ const hours = Math.round(minutes / 60)
30
+ if (hours < 24) return hours === 1 ? 'Every hour' : `Every ${hours} hours`
31
+ const days = Math.round(hours / 24)
32
+ return days === 1 ? 'Every day' : `Every ${days} days`
33
+ }
34
+
35
+ function formatRunLabel(timestamp: number, timezone: string | null): string {
36
+ const date = new Date(timestamp)
37
+ try {
38
+ return new Intl.DateTimeFormat('en-US', {
39
+ weekday: 'short',
40
+ month: 'short',
41
+ day: 'numeric',
42
+ hour: '2-digit',
43
+ minute: '2-digit',
44
+ ...(timezone ? { timeZone: timezone, timeZoneName: 'short' as const } : {}),
45
+ }).format(date)
46
+ } catch {
47
+ return date.toISOString()
48
+ }
49
+ }
50
+
51
+ function buildPreviewRun(timestamp: number, timezone: string | null): SchedulePreviewRun {
52
+ return {
53
+ at: timestamp,
54
+ iso: new Date(timestamp).toISOString(),
55
+ label: formatRunLabel(timestamp, timezone),
56
+ }
57
+ }
58
+
59
+ function buildCronRuns(cron: string, timezone: string | null, now: number, count: number): SchedulePreviewRun[] {
60
+ const interval = CronExpressionParser.parse(cron, {
61
+ currentDate: new Date(now),
62
+ ...(timezone ? { tz: timezone } : {}),
63
+ })
64
+ const runs: SchedulePreviewRun[] = []
65
+ for (let index = 0; index < count; index += 1) {
66
+ runs.push(buildPreviewRun(interval.next().getTime(), timezone))
67
+ }
68
+ return runs
69
+ }
70
+
71
+ function buildIntervalRuns(intervalMs: number, timezone: string | null, now: number, count: number): SchedulePreviewRun[] {
72
+ const runs: SchedulePreviewRun[] = []
73
+ for (let index = 1; index <= count; index += 1) {
74
+ runs.push(buildPreviewRun(now + (intervalMs * index), timezone))
75
+ }
76
+ return runs
77
+ }
78
+
79
+ function cadenceLabel(scheduleType: ScheduleType, payload: SchedulePayload): string {
80
+ if (scheduleType === 'cron') {
81
+ const cron = trimString(payload.cron)
82
+ return cron ? `Cron ${cron}` : 'Cron'
83
+ }
84
+ if (scheduleType === 'interval') {
85
+ const intervalMs = positiveNumber(payload.intervalMs)
86
+ return intervalMs ? formatDuration(intervalMs) : 'Interval'
87
+ }
88
+ return 'Run once'
89
+ }
90
+
91
+ export function previewSchedulePayload(
92
+ payload: SchedulePayload,
93
+ options: PreviewScheduleOptions = {},
94
+ ): SchedulePreviewResponse {
95
+ const now = typeof options.now === 'number' ? options.now : Date.now()
96
+ const count = Math.max(1, Math.min(10, Math.trunc(options.count ?? DEFAULT_PREVIEW_RUNS)))
97
+ const normalized = normalizeSchedulePayload({
98
+ ...payload,
99
+ nextRunAt: undefined,
100
+ }, {
101
+ cwd: options.cwd,
102
+ now,
103
+ })
104
+ if (!normalized.ok) {
105
+ return { ok: false, error: normalized.error }
106
+ }
107
+
108
+ const value = normalized.value
109
+ const scheduleType = value.scheduleType === 'cron' || value.scheduleType === 'once'
110
+ ? value.scheduleType
111
+ : 'interval'
112
+ const timezone = trimString(value.timezone) || null
113
+ const warnings: string[] = []
114
+ const staggerSec = positiveNumber(value.staggerSec)
115
+
116
+ if (scheduleType === 'cron' && !timezone) {
117
+ warnings.push('Timezone is not set, so cron runs use the host local timezone.')
118
+ }
119
+ if (staggerSec) {
120
+ warnings.push(`Stagger may delay each run by up to ${staggerSec} seconds.`)
121
+ }
122
+ if (value.status === 'paused') {
123
+ warnings.push('Paused schedules keep their forecast but will not run until reactivated.')
124
+ }
125
+ if (value.status === 'archived') {
126
+ warnings.push('Archived schedules do not run until restored.')
127
+ }
128
+
129
+ let nextRuns: SchedulePreviewRun[] = []
130
+ try {
131
+ if (scheduleType === 'cron') {
132
+ const cron = trimString(value.cron)
133
+ if (!cron) {
134
+ return { ok: false, error: 'Error: cron schedules require a cron expression.' }
135
+ }
136
+ nextRuns = buildCronRuns(cron, timezone, now, count)
137
+ } else if (scheduleType === 'interval') {
138
+ const intervalMs = positiveNumber(value.intervalMs)
139
+ if (!intervalMs) {
140
+ return { ok: false, error: 'Error: interval schedules require intervalMs.' }
141
+ }
142
+ nextRuns = buildIntervalRuns(intervalMs, timezone, now, count)
143
+ } else {
144
+ const runAt = positiveNumber(value.runAt)
145
+ if (!runAt) {
146
+ warnings.push('Run-once schedules need a runAt timestamp before they can be queued.')
147
+ } else if (runAt <= now) {
148
+ warnings.push('Run-once timestamp is in the past.')
149
+ } else {
150
+ nextRuns = [buildPreviewRun(runAt, timezone)]
151
+ }
152
+ }
153
+ } catch {
154
+ return { ok: false, error: 'Error: invalid schedule timing.' }
155
+ }
156
+
157
+ if (nextRuns.length === 0 && value.status !== 'archived') {
158
+ warnings.push('No future runs could be calculated from this schedule.')
159
+ }
160
+
161
+ return {
162
+ ok: true,
163
+ scheduleType,
164
+ cadence: cadenceLabel(scheduleType, value),
165
+ timezone,
166
+ nextRunAt: nextRuns[0]?.at ?? null,
167
+ nextRuns,
168
+ warnings,
169
+ normalized: value,
170
+ }
171
+ }
@@ -19,7 +19,8 @@ import { pushMainLoopEventToMainSessions } from '@/lib/server/agents/main-agent-
19
19
  import { WORKSPACE_DIR } from '@/lib/server/data-dir'
20
20
  import { notify } from '@/lib/server/ws-hub'
21
21
  import { appendScheduleHistoryEntry } from '@/lib/server/schedules/schedule-history'
22
- import type { Schedule } from '@/types'
22
+ import { previewSchedulePayload } from '@/lib/server/schedules/schedule-preview'
23
+ import type { Schedule, SchedulePreviewResponse } from '@/types'
23
24
  import type { ScheduleLike } from '@/lib/schedules/schedule-dedupe'
24
25
  import type { ServiceResult } from '@/lib/server/service-result'
25
26
 
@@ -39,6 +40,29 @@ export function listSchedulesForApi(includeArchived: boolean) {
39
40
  return filtered
40
41
  }
41
42
 
43
+ export function previewScheduleFromRoute(body: Record<string, unknown>): ServiceResult<SchedulePreviewResponse> {
44
+ const result = previewSchedulePayload(body, {
45
+ cwd: WORKSPACE_DIR,
46
+ now: Date.now(),
47
+ })
48
+ if (!result.ok) return serviceFail(400, result.error)
49
+
50
+ const agents = loadAgents()
51
+ const agentId = typeof result.normalized.agentId === 'string' ? result.normalized.agentId : ''
52
+ const agent = agentId ? agents[agentId] : null
53
+ const warnings = [...result.warnings]
54
+ if (agentId && !agent) {
55
+ warnings.push(`Agent not found: ${agentId}`)
56
+ } else if (agent && isAgentDisabled(agent)) {
57
+ warnings.push(buildAgentDisabledMessage(agent, 'take scheduled work'))
58
+ }
59
+
60
+ return serviceOk({
61
+ ...result,
62
+ warnings,
63
+ })
64
+ }
65
+
42
66
  export function createScheduleFromRoute(body: Record<string, unknown>): ServiceResult<ScheduleLike> {
43
67
  const now = Date.now()
44
68
  const schedules = loadSchedules()
@@ -64,6 +64,41 @@ describe('stripInternalJson', () => {
64
64
  assert.doesNotMatch(result, /isDeliverableTask/)
65
65
  assert.match(result, /\{ "foo": "bar" \}/)
66
66
  })
67
+
68
+ it('removes multiple leading internal JSON blocks', () => {
69
+ const input = [
70
+ '{ "isDeliverableTask": true, "confidence": 0.9 }',
71
+ '{ "factsUpsert": [], "questionsUpsert": [] }',
72
+ 'All queued work is complete.',
73
+ ].join('\n')
74
+ assert.equal(stripInternalJson(input), 'All queued work is complete.')
75
+ })
76
+
77
+ it('removes a malformed internal prelude only after a strict leading strip', () => {
78
+ const input = [
79
+ '{ "isDeliverableTask": true, "confidence": 0.9 }',
80
+ '{',
81
+ ' "taskIntent": "research",',
82
+ ' "isBroadGoal":{',
83
+ ' false,',
84
+ ' "isLightweightDirectChat": false,',
85
+ '}',
86
+ 'All queued work is complete.',
87
+ ].join('\n')
88
+ assert.equal(stripInternalJson(input), 'All queued work is complete.')
89
+ })
90
+
91
+ it('preserves malformed internal-looking text without a strict leading strip', () => {
92
+ const input = [
93
+ '{',
94
+ ' "taskIntent": "research",',
95
+ ' "isBroadGoal":{',
96
+ ' false,',
97
+ '}',
98
+ 'Visible answer.',
99
+ ].join('\n')
100
+ assert.equal(stripInternalJson(input), input)
101
+ })
67
102
  })
68
103
 
69
104
  // ---------------------------------------------------------------------------
@@ -25,6 +25,9 @@ const INTERNAL_JSON_KEYS = [
25
25
 
26
26
  export const INTERNAL_KEY_RE = new RegExp(`"(?:${INTERNAL_JSON_KEYS.join('|')})"`)
27
27
 
28
+ const TaskIntentLikeSchema = z.enum(['coding', 'research', 'browsing', 'outreach', 'scheduling', 'general']).optional()
29
+ const WorkTypeLikeSchema = z.enum(['coding', 'research', 'writing', 'review', 'operations', 'general']).optional()
30
+
28
31
  const WorkingStatePatchLikeSchema = z.object({
29
32
  factsUpsert: z.array(z.unknown()).optional(),
30
33
  artifactsUpsert: z.array(z.unknown()).optional(),
@@ -37,13 +40,15 @@ const WorkingStatePatchLikeSchema = z.object({
37
40
  }).passthrough()
38
41
 
39
42
  const MessageClassificationLikeSchema = z.object({
40
- taskIntent: z.string().optional(),
43
+ taskIntent: TaskIntentLikeSchema,
41
44
  isLightweightDirectChat: z.boolean().optional(),
42
45
  isDeliverableTask: z.boolean().optional(),
43
46
  isBroadGoal: z.boolean().optional(),
44
47
  hasHumanSignals: z.boolean().optional(),
48
+ hasSignificantEvent: z.boolean().optional(),
45
49
  explicitToolRequests: z.array(z.unknown()).optional(),
46
50
  isResearchSynthesis: z.boolean().optional(),
51
+ workType: WorkTypeLikeSchema,
47
52
  confidence: z.number().optional(),
48
53
  }).passthrough()
49
54
 
@@ -104,6 +109,13 @@ function objectIsInternalMetadata(obj: Record<string, unknown>): boolean {
104
109
  return false
105
110
  }
106
111
 
112
+ function isDistinctiveInternalKey(key: string): boolean {
113
+ for (const { distinctiveKeys } of INTERNAL_PAYLOAD_RULES) {
114
+ if (distinctiveKeys.includes(key)) return true
115
+ }
116
+ return false
117
+ }
118
+
107
119
  function findBalancedJsonObjectEnd(text: string, start: number): number {
108
120
  if (text.charAt(start) !== '{') return -1
109
121
  let depth = 0
@@ -130,6 +142,109 @@ function findBalancedJsonObjectEnd(text: string, start: number): number {
130
142
  return -1
131
143
  }
132
144
 
145
+ function parseQuotedKeyAt(text: string, start: number): { key: string; end: number } | null {
146
+ if (text.charAt(start) !== '"') return null
147
+ let key = ''
148
+ let escaped = false
149
+ for (let i = start + 1; i < text.length; i += 1) {
150
+ const c = text.charAt(i)
151
+ if (escaped) {
152
+ key += c
153
+ escaped = false
154
+ continue
155
+ }
156
+ if (c === '\\') {
157
+ escaped = true
158
+ continue
159
+ }
160
+ if (c !== '"') {
161
+ key += c
162
+ continue
163
+ }
164
+ let cursor = i + 1
165
+ while (cursor < text.length && /\s/.test(text.charAt(cursor))) cursor += 1
166
+ if (text.charAt(cursor) !== ':') return null
167
+ return { key, end: cursor + 1 }
168
+ }
169
+ return null
170
+ }
171
+
172
+ function lineHasDistinctiveInternalKey(line: string): boolean {
173
+ for (let i = 0; i < line.length; i += 1) {
174
+ const parsed = parseQuotedKeyAt(line, i)
175
+ if (!parsed) continue
176
+ if (isDistinctiveInternalKey(parsed.key)) return true
177
+ i = parsed.end - 1
178
+ }
179
+ return false
180
+ }
181
+
182
+ function startsWithJsonLiteral(text: string, value: string): boolean {
183
+ if (!text.startsWith(value)) return false
184
+ const next = text.charAt(value.length)
185
+ return next === '' || next === ',' || next === '}' || next === ']' || /\s/.test(next)
186
+ }
187
+
188
+ function isMalformedJsonFragmentLine(line: string): boolean {
189
+ const trimmed = line.trim()
190
+ if (!trimmed) return true
191
+ const first = trimmed.charAt(0)
192
+ if (first === '{' || first === '}' || first === '[' || first === ']' || first === '"' || first === ',' || first === ':') {
193
+ return true
194
+ }
195
+ if (startsWithJsonLiteral(trimmed, 'true')) return true
196
+ if (startsWithJsonLiteral(trimmed, 'false')) return true
197
+ if (startsWithJsonLiteral(trimmed, 'null')) return true
198
+ if (trimmed.startsWith('...')) return true
199
+ return false
200
+ }
201
+
202
+ function findInlineVisibleTextAfterClosingBrace(line: string): number {
203
+ for (let i = 0; i < line.length; i += 1) {
204
+ if (line.charAt(i) !== '}') continue
205
+ let cursor = i + 1
206
+ while (cursor < line.length && /\s/.test(line.charAt(cursor))) cursor += 1
207
+ const next = line.charAt(cursor)
208
+ if (!next || next === ',' || next === '}' || next === ']') continue
209
+ return i + 1
210
+ }
211
+ return -1
212
+ }
213
+
214
+ function findMalformedInternalPreludeEnd(text: string): number {
215
+ let leading = 0
216
+ while (leading < text.length && /\s/.test(text.charAt(leading))) leading += 1
217
+ if (text.charAt(leading) !== '{') return -1
218
+
219
+ let cursor = leading
220
+ let sawDistinctiveKey = false
221
+ let consumedEnd = -1
222
+ while (cursor < text.length) {
223
+ const newlineAt = text.indexOf('\n', cursor)
224
+ const lineEnd = newlineAt === -1 ? text.length : newlineAt
225
+ const line = text.slice(cursor, lineEnd)
226
+ if (lineHasDistinctiveInternalKey(line)) sawDistinctiveKey = true
227
+
228
+ if (!isMalformedJsonFragmentLine(line)) {
229
+ return sawDistinctiveKey && consumedEnd > leading ? consumedEnd : -1
230
+ }
231
+
232
+ const inlineEnd = sawDistinctiveKey ? findInlineVisibleTextAfterClosingBrace(line) : -1
233
+ if (inlineEnd >= 0) return cursor + inlineEnd
234
+
235
+ consumedEnd = newlineAt === -1 ? lineEnd : lineEnd + 1
236
+ cursor = consumedEnd
237
+ }
238
+
239
+ return -1
240
+ }
241
+
242
+ function stripMalformedInternalPreludeAfterStrictStrip(text: string): string {
243
+ const end = findMalformedInternalPreludeEnd(text)
244
+ if (end < 0) return text
245
+ return text.slice(end).trimStart()
246
+ }
247
+
133
248
  /**
134
249
  * Remove top-level `{ ... }` blocks that contain known internal classification keys.
135
250
  * Handles nested and multi-line JSON. Only strips blocks where at least one
@@ -137,6 +252,7 @@ function findBalancedJsonObjectEnd(text: string, start: number): number {
137
252
  */
138
253
  export function stripInternalJson(text: string): string {
139
254
  let out = text || ''
255
+ let removedLeadingInternalJson = false
140
256
  for (let guard = 0; guard < 32; guard += 1) {
141
257
  let removed = false
142
258
  for (let i = 0; i < out.length; i += 1) {
@@ -152,12 +268,16 @@ export function stripInternalJson(text: string): string {
152
268
  }
153
269
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue
154
270
  if (!objectIsInternalMetadata(parsed as Record<string, unknown>)) continue
271
+ if (!out.slice(0, i).trim()) removedLeadingInternalJson = true
155
272
  out = (out.slice(0, i).replace(/\s+$/, '') + ' ' + out.slice(end).replace(/^\s+/, '')).trim()
156
273
  removed = true
157
274
  break
158
275
  }
159
276
  if (!removed) break
160
277
  }
278
+ if (removedLeadingInternalJson) {
279
+ out = stripMalformedInternalPreludeAfterStrictStrip(out)
280
+ }
161
281
  return out
162
282
  }
163
283
 
@@ -82,3 +82,26 @@ export interface Schedule {
82
82
  createdAt: number
83
83
  updatedAt?: number
84
84
  }
85
+
86
+ export interface SchedulePreviewRun {
87
+ at: number
88
+ iso: string
89
+ label: string
90
+ }
91
+
92
+ export type SchedulePreviewResponse =
93
+ | {
94
+ ok: true
95
+ scheduleType: ScheduleType
96
+ cadence: string
97
+ timezone: string | null
98
+ nextRunAt: number | null
99
+ nextRuns: SchedulePreviewRun[]
100
+ warnings: string[]
101
+ normalized: Partial<Schedule> & Record<string, unknown>
102
+ }
103
+ | {
104
+ ok: false
105
+ error: string
106
+ warnings?: string[]
107
+ }