@loicngr/kobo 1.7.5 → 1.7.6

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 (60) hide show
  1. package/README.md +8 -3
  2. package/dist/mcp-server/kobo-tasks-handlers.js +15 -0
  3. package/dist/mcp-server/kobo-tasks-server.js +117 -8
  4. package/dist/server/db/migrations.js +38 -0
  5. package/dist/server/db/schema.js +16 -0
  6. package/dist/server/index.js +2 -0
  7. package/dist/server/routes/health.js +68 -3
  8. package/dist/server/routes/workspaces.js +102 -1
  9. package/dist/server/services/agent/engines/claude-code/engine.js +13 -9
  10. package/dist/server/services/agent/engines/claude-code/event-mapper.js +95 -10
  11. package/dist/server/services/agent/orchestrator.js +41 -0
  12. package/dist/server/services/auto-loop-service.js +8 -3
  13. package/dist/server/services/cron-service.js +279 -0
  14. package/dist/server/services/wakeup-service.js +1 -1
  15. package/dist/server/services/workspace-service.js +18 -0
  16. package/dist/server/utils/git-ops.js +8 -1
  17. package/package.json +2 -1
  18. package/src/client/dist/spa/assets/{ActivityFeed-oW9PgZ8E.js → ActivityFeed-BboSPm4b.js} +2 -2
  19. package/src/client/dist/spa/assets/{ActivityFeed-DVBfmJWJ.css → ActivityFeed-tE4LVYck.css} +1 -1
  20. package/src/client/dist/spa/assets/{AutoLoopChip-Y53cnGfZ.js → AutoLoopChip-w8D77bI5.js} +1 -1
  21. package/src/client/dist/spa/assets/{CreatePage-CuD7sMR7.js → CreatePage-BDObLDJc.js} +1 -1
  22. package/src/client/dist/spa/assets/{DiffViewer-rc3tE9fq.js → DiffViewer-CblFgn8w.js} +3 -3
  23. package/src/client/dist/spa/assets/{DiffViewer-wFfQ9tcY.css → DiffViewer-DTdDcKZC.css} +1 -1
  24. package/src/client/dist/spa/assets/HealthPage-CBSw7e5q.js +1 -0
  25. package/src/client/dist/spa/assets/{MainLayout-B9i06p7n.js → MainLayout-DhaYycak.js} +17 -17
  26. package/src/client/dist/spa/assets/MainLayout-drolsINz.css +1 -0
  27. package/src/client/dist/spa/assets/{SearchPage-DdX7JZCD.js → SearchPage-cZTwP4Lf.js} +1 -1
  28. package/src/client/dist/spa/assets/{SettingsPage-Dnj1CWc3.js → SettingsPage-C1efO0VM.js} +1 -1
  29. package/src/client/dist/spa/assets/{WorkspacePage-DHp20nl-.js → WorkspacePage-3jcof896.js} +3 -3
  30. package/src/client/dist/spa/assets/{cssMode-DSB5jkRt.js → cssMode-BFLYiiEw.js} +1 -1
  31. package/src/client/dist/spa/assets/{editor.api-Bcw50eFD.js → editor.api-2asmmhth.js} +1 -1
  32. package/src/client/dist/spa/assets/{editor.main-D9piVGaH.js → editor.main-ChCYZyez.js} +3 -3
  33. package/src/client/dist/spa/assets/{expand-template-BIPuNAYV.js → expand-template-CXQFkQOJ.js} +1 -1
  34. package/src/client/dist/spa/assets/{freemarker2-CVh_Zh8H.js → freemarker2-BaBL9E9G.js} +1 -1
  35. package/src/client/dist/spa/assets/{handlebars-CpCgELpu.js → handlebars-BxDour4L.js} +1 -1
  36. package/src/client/dist/spa/assets/{html-ikWDpvWk.js → html-C6hnkfIL.js} +1 -1
  37. package/src/client/dist/spa/assets/{htmlMode-C9TTCKih.js → htmlMode-9zT3-dmz.js} +1 -1
  38. package/src/client/dist/spa/assets/i18n-CLY0XI9-.js +1 -0
  39. package/src/client/dist/spa/assets/index-D6wj_wQ9.js +2 -0
  40. package/src/client/dist/spa/assets/{javascript-C4OlkNeA.js → javascript-C3YjvKbE.js} +1 -1
  41. package/src/client/dist/spa/assets/{jsonMode-BiD34_86.js → jsonMode-DcJDgMzf.js} +1 -1
  42. package/src/client/dist/spa/assets/{liquid-Dty0Ui2c.js → liquid-CsT8SjJM.js} +1 -1
  43. package/src/client/dist/spa/assets/{mdx-yiUjOVv6.js → mdx-CT3yVSyc.js} +1 -1
  44. package/src/client/dist/spa/assets/{models-BDkLiht9.js → models-BsjWUKqM.js} +1 -1
  45. package/src/client/dist/spa/assets/{monaco.contribution-Bz9yFPWR.js → monaco.contribution-DKGNz1oQ.js} +2 -2
  46. package/src/client/dist/spa/assets/{purify.es-BIY760fF.js → purify.es-CPieV82n.js} +1 -1
  47. package/src/client/dist/spa/assets/{python-7SPSWQoD.js → python-Ca5miKgj.js} +1 -1
  48. package/src/client/dist/spa/assets/{razor-eagZawXK.js → razor-7qzusGRc.js} +1 -1
  49. package/src/client/dist/spa/assets/{render-chat-markdown-TvAqpDih.js → render-chat-markdown-Bqq2G-yI.js} +1 -1
  50. package/src/client/dist/spa/assets/{tsMode-CLYG2xeJ.js → tsMode-BdvO8jZ2.js} +1 -1
  51. package/src/client/dist/spa/assets/{typescript-CzOXM8yS.js → typescript-BfVNzhgs.js} +1 -1
  52. package/src/client/dist/spa/assets/{xml-2_0_6RAX.js → xml-DGNXGqXL.js} +1 -1
  53. package/src/client/dist/spa/assets/{yaml-CtpgNyXs.js → yaml-CtAtOyt5.js} +1 -1
  54. package/src/client/dist/spa/index.html +1 -1
  55. package/src/mcp-server/kobo-tasks-handlers.ts +20 -0
  56. package/src/mcp-server/kobo-tasks-server.ts +123 -7
  57. package/src/client/dist/spa/assets/HealthPage-Dz0yGGMB.js +0 -1
  58. package/src/client/dist/spa/assets/MainLayout-DDa3rGKA.css +0 -1
  59. package/src/client/dist/spa/assets/i18n-DZCb8dnb.js +0 -1
  60. package/src/client/dist/spa/assets/index-DuK38XN5.js +0 -2
package/README.md CHANGED
@@ -35,7 +35,9 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
35
35
  - **Attach existing worktrees** — Kōbō detects orphan git worktrees for the selected project (created outside Kōbō, or left over from an earlier install) and lets you attach them to a new workspace from the creation form, picking up the existing branch and folder instead of cloning a new one
36
36
  - **Persistent quota backoff** — when a Claude rate limit is hit mid-session, Kōbō schedules the retry at the actual reset time reported by the API (via `rate_limit.info.buckets[].resetsAt`), falling back to the OAuth usage poller, then to a 15 → 30 → 60 → 180 → 300 min ladder when both are missing. The pending backoff is **persisted in SQLite** and re-armed on server restart, so nothing is lost if the host reboots mid-window. A live banner counts down to the reset and lets the user cancel the wait. Only auto-loop workspaces resume automatically — others stay in `quota` status awaiting a manual nudge
37
37
  - **Workspace description fields** — every workspace has TWO independent description fields. The user-side `description` is editable via the header input or right-click **Modifier la description**, and stays under the user's control (the agent cannot overwrite it). The agent maintains its own `agent_description` via the `set_workspace_agent_description` MCP tool to broadcast a live status (e.g. "Investigating SERVICE-1600 → enriching local Notion file"). Both are visible: the sidebar shows `agent_description` when set, falling back to the user `description`; the workspace header shows the user input plus an italic read-only line for the agent's current focus
38
- - **Scheduled wakeups** — the `ScheduleWakeup` tool is honoured server-side: Kōbō persists the wakeup in SQLite, rehydrates on restart, and respawns the agent with `--resume` at the target time
38
+ - **Scheduled wakeups** — the `ScheduleWakeup` tool is honoured server-side: Kōbō persists the wakeup in SQLite, rehydrates on restart, and respawns the agent with `--resume` at the target time. Delay is clamped to `[60s, 6h]`. The kobo-tasks MCP server also exposes `kobo__schedule_wakeup` / `kobo__cancel_wakeup` for the same flow with first-class tool descriptors
39
+ - **Recurring & one-shot crons** — agents schedule recurring triggers via `kobo__cron_create(expression, prompt, label?, mode?, oneShot?)`. Standard 5-field cron expressions (`*/30 * * * *`) plus helpers (`@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly`). Two modes: `'resume'` (default) pins the cron to the session that scheduled it so each fire continues that conversation; `'fresh'` spawns a brand-new session per fire (clean context, ideal for periodic CI / dashboard checks). `oneShot: true` cancels the cron after the first real fire — useful for "trigger once at a specific date/time" without recurring. Crons are persisted in SQLite, re-armed on restart with skip-missed semantics (no catchup spam after downtime), and skip-if-active when a session is already running. Multiple crons per workspace. The native Claude Code `CronCreate` tool is also intercepted and mirrored as a kobo cron so even agents using the SDK-default tool benefit from persistence
40
+ - **Schedule panel in the right drawer** — dedicated tab listing every wakeup and cron currently armed for the focused workspace, with their next/last fire times, prompt preview, and a `×` button to cancel inline. Sidebar workspace cards display an `event_repeat` icon when one or more crons are scheduled, even for workspaces not currently focused — broadcast live via WebSocket events
39
41
 
40
42
  ## Tech stack
41
43
 
@@ -231,6 +233,8 @@ src/
231
233
  │ │ ├── content-migration-service.ts # legacy ws_events → normalised AgentEvent rows, with DB backup
232
234
  │ │ ├── usage/ # pluggable quota provider, 60s poller, persistence, WS broadcast
233
235
  │ │ ├── quota-backoff-service.ts # persisted Claude rate-limit backoff timers (re-armed on restart)
236
+ │ │ ├── cron-service.ts # persisted cron schedules (recurring + one-shot, resume/fresh modes)
237
+ │ │ ├── wakeup-service.ts # persisted one-shot session resumes (clamped to [60s, 6h])
234
238
  │ │ └── … # workspace, dev-server, ws, notion, sentry, settings, pr-template
235
239
  │ ├── routes/ # Hono handlers (workspaces, engines, migration, templates, usage, …)
236
240
  │ └── utils/ # git-ops, process-tracker, paths
@@ -261,6 +265,7 @@ See [`AGENTS.md`](./AGENTS.md) for a deeper dive into conventions, data model, W
261
265
  | `usage_snapshots` | latest quota snapshot per provider (one row per `provider_id`) — populated by the 60s polling loop, used for cold-start hydration of the chat-footer quota badge |
262
266
  | `pending_wakeups` | one row per scheduled wakeup, target time and resume context, re-armed on server restart |
263
267
  | `pending_quota_backoffs` | one row per workspace currently waiting on a Claude rate-limit reset, target time + reset metadata + retry count, re-armed on server restart |
268
+ | `pending_crons` | one row per scheduled cron — expression, prompt, label, optional pinned `agent_session_id` (= resume mode) or NULL (= fresh mode), `next_fire_at`, `last_fired_at`, `one_shot` flag, re-armed on server restart with skip-missed semantics |
264
269
 
265
270
  ## MCP server
266
271
 
@@ -273,9 +278,9 @@ Each workspace spawns its own `kobo-tasks` MCP server as a child process of the
273
278
  - **Dev server** — `get_dev_server_status`, `start_dev_server`, `stop_dev_server`, `get_dev_server_logs`
274
279
  - **External sources** — `get_notion_ticket`, `get_settings`
275
280
  - **Documents & search** — `list_documents`, `read_document`, `log_thought`, `search_codebase`, `list_workspace_images`
276
- - **Scheduling & telemetry** — `schedule_wakeup`, `cancel_wakeup`, `get_session_usage`
281
+ - **Scheduling & telemetry** — `schedule_wakeup`, `cancel_wakeup`, `cron_create`, `cron_delete`, `cron_list`, `get_session_usage`
277
282
 
278
- State-mutating tools that change UI-visible data (tasks, agent description, auto-loop readiness) write directly to SQLite for low latency, then fire-and-forget a `notify-*` HTTP call to the backend so the WebSocket layer broadcasts the change to every connected client.
283
+ State-mutating tools that change UI-visible data (tasks, agent description, auto-loop readiness) write directly to SQLite for low latency, then fire-and-forget a `notify-*` HTTP call to the backend so the WebSocket layer broadcasts the change to every connected client. Tools that arm in-memory timers (`cron_create`, `cron_delete`) route through the backend HTTP API instead — the timer Map lives in the backend process which owns the orchestrator, so the cron survives the MCP server's session-bound lifetime.
279
284
 
280
285
  The MCP server reads and writes the same SQLite database as the main backend. Isolation between workspaces is enforced via the `KOBO_WORKSPACE_ID` environment variable passed at spawn time and validated on every query.
281
286
 
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { nanoid } from 'nanoid';
4
+ import * as cronService from '../server/services/cron-service.js';
4
5
  import * as settingsService from '../server/services/settings-service.js';
5
6
  import { slugifyProjectName } from '../server/utils/project-slug.js';
6
7
  import { resolveWorkspaceWorktreePath } from '../server/utils/worktree-paths.js';
@@ -379,3 +380,17 @@ export function getSessionUsageHandler(db, workspaceId) {
379
380
  currentSession: { sessionId: currentSessionId, ...current },
380
381
  };
381
382
  }
383
+ // ── Crons ────────────────────────────────────────────────────────────────────
384
+ /**
385
+ * List every cron currently armed for a workspace.
386
+ *
387
+ * Note: cron_create and cron_delete are NOT exposed as handlers — they route
388
+ * through the backend HTTP API (`POST/DELETE /api/workspaces/:id/crons`)
389
+ * because their `setTimeout` must live in the backend process (which owns
390
+ * the orchestrator). Handlers here would arm timers in the MCP sub-process,
391
+ * which dies with the agent session, and fires would never reach a real
392
+ * session resume. The list handler is a pure read so it's safe to keep local.
393
+ */
394
+ export function cronListHandler(_db, workspaceId) {
395
+ return { ok: true, crons: cronService.listForWorkspace(workspaceId) };
396
+ }
@@ -4,8 +4,9 @@ import path from 'node:path';
4
4
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
5
5
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
6
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
7
- import Database from 'better-sqlite3';
8
- import { createTaskHandler, deleteTaskHandler, getDevServerStatusHandler, getSessionUsageHandler, getSettingsHandler, getWorkspaceInfoHandler, listDocumentsHandler, listTasksHandler, listWorkspaceImagesHandler, logThoughtHandler, markAutoLoopReadyHandler, markTaskDoneHandler, readDocumentHandler, setWorkspaceAgentDescriptionHandler, updateTaskHandler, } from './kobo-tasks-handlers.js';
7
+ import { getDb } from '../server/db/index.js';
8
+ import { runMigrations } from '../server/db/migrations.js';
9
+ import { createTaskHandler, cronListHandler, deleteTaskHandler, getDevServerStatusHandler, getSessionUsageHandler, getSettingsHandler, getWorkspaceInfoHandler, listDocumentsHandler, listTasksHandler, listWorkspaceImagesHandler, logThoughtHandler, markAutoLoopReadyHandler, markTaskDoneHandler, readDocumentHandler, setWorkspaceAgentDescriptionHandler, updateTaskHandler, } from './kobo-tasks-handlers.js';
9
10
  const workspaceId = process.env.KOBO_WORKSPACE_ID;
10
11
  const dbPath = process.env.KOBO_DB_PATH;
11
12
  const settingsPath = process.env.KOBO_SETTINGS_PATH;
@@ -20,10 +21,21 @@ if (!dbPath) {
20
21
  }
21
22
  let db;
22
23
  try {
23
- db = new Database(dbPath, { readonly: false });
24
- db.pragma('journal_mode = WAL');
25
- db.pragma('busy_timeout = 5000');
26
- db.pragma('foreign_keys = ON');
24
+ // Use the shared `getDb` singleton so any service that calls `getDb()`
25
+ // internally (e.g. cron-service.arm) hits the SAME connection as this
26
+ // MCP server, against the SAME DB file. Without this bootstrap, the
27
+ // singleton would resolve via getDbPath() getKoboHome() → KOBO_HOME
28
+ // env var, which the agent SDK does NOT pass to the MCP server (only
29
+ // KOBO_DB_PATH is passed). That would silently open a second connection
30
+ // against the user's prod DB at ~/.config/kobo/kobo.db, which may not
31
+ // even have the same schema as the dev DB the backend is using.
32
+ db = getDb(dbPath);
33
+ // Defensive: run migrations to ensure the schema is at the latest version.
34
+ // The backend already ran them at boot, but this MCP server might be the
35
+ // first to touch the DB (e.g. tests, race at first boot, or KOBO_DB_PATH
36
+ // pointing somewhere the backend hasn't migrated). Idempotent — no-op if
37
+ // the DB is already at SCHEMA_VERSION.
38
+ runMigrations(db);
27
39
  }
28
40
  catch (err) {
29
41
  console.error('[kobo-tasks-server] Failed to open database:', err);
@@ -190,6 +202,53 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
190
202
  required: ['description'],
191
203
  },
192
204
  },
205
+ {
206
+ name: 'cron_create',
207
+ description: 'Schedule a recurring trigger on THIS workspace. At each fire, Kōbō waits for the workspace to be idle (no active session) and then resumes the same conversation by injecting `prompt` as the next user message — same UX as `schedule_wakeup` but recurring. Skip-if-active: if a session is already running when the timer fires, that occurrence is skipped, the next occurrence is computed, and the cron continues. The cron persists across server restarts (skip-missed semantics on boot — no catchup spam). Delete with `cron_delete(id)`. Multiple crons per workspace are allowed.',
208
+ inputSchema: {
209
+ type: 'object',
210
+ properties: {
211
+ expression: {
212
+ type: 'string',
213
+ description: 'Standard 5-field cron expression (`min hour dom month dow`) or one of the helpers `@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly`. Example: `*/30 * * * *` = every 30 minutes; `0 9 * * 1` = every Monday at 9am. Validated at create time.',
214
+ },
215
+ prompt: {
216
+ type: 'string',
217
+ description: 'The prompt to inject as the next user message at each fire.',
218
+ },
219
+ label: {
220
+ type: 'string',
221
+ description: 'Optional human-readable label for the cron (shown in the UI).',
222
+ },
223
+ mode: {
224
+ type: 'string',
225
+ enum: ['resume', 'fresh'],
226
+ description: "How each fire is handled. 'resume' (default) pins the cron to the session you're calling from, so every fire continues THAT conversation by injecting `prompt` as the next user message — use this when the cron should follow up on ongoing work. 'fresh' starts a brand-new session at every fire with a clean context — use this for periodic checks (e.g. CI watch, daily standup) that don't need conversation continuity.",
227
+ },
228
+ oneShot: {
229
+ type: 'boolean',
230
+ description: "When true, the cron cancels itself after the first real fire (default false = recurring). Use this to schedule a single trigger at a specific cron-expressible time (e.g. `0 14 7 6 *` = next 7 June at 14:00) without it repeating yearly. Skip-active fires don't consume the one-shot — the cron retries at the next occurrence until it actually runs once.",
231
+ },
232
+ },
233
+ required: ['expression', 'prompt'],
234
+ },
235
+ },
236
+ {
237
+ name: 'cron_delete',
238
+ description: "Cancel a previously-armed cron by id. Idempotent — returns ok=true even if the id is unknown. Only the workspace's own crons can be cancelled (cron_list to see them).",
239
+ inputSchema: {
240
+ type: 'object',
241
+ properties: {
242
+ id: { type: 'string', description: 'The cron id returned by cron_create.' },
243
+ },
244
+ required: ['id'],
245
+ },
246
+ },
247
+ {
248
+ name: 'cron_list',
249
+ description: 'List all crons currently armed on THIS workspace, including their next and last fire times.',
250
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
251
+ },
193
252
  {
194
253
  name: 'get_git_info',
195
254
  description: 'CALL BEFORE creating a PR, committing in batches, or reporting progress to the user. Returns commit count ahead of source, files changed, insertions/deletions, and existing PR URL if any.',
@@ -324,13 +383,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
324
383
  },
325
384
  {
326
385
  name: 'schedule_wakeup',
327
- description: 'CALL to schedule a follow-up turn on THIS workspace after a delay. End the current turn normally; once it finishes and the workspace is idle, Kōbō waits `delaySeconds`, then resumes the same conversation by injecting `prompt` as the next user message. The wakeup is scoped to the current workspace and resumes its latest session — you cannot target another workspace or another session. If a turn is still active when the timer fires, the wakeup is skipped (status: `session-active`). Replaces any previously pending wakeup on this workspace. Delay is clamped to [60, 3600] seconds. Prefer this over the built-in `ScheduleWakeup` tool — it is the SDK-supported entry point.',
386
+ description: 'CALL to schedule a follow-up turn on THIS workspace after a delay. End the current turn normally; once it finishes and the workspace is idle, Kōbō waits `delaySeconds`, then resumes the same conversation by injecting `prompt` as the next user message. The wakeup is scoped to the current workspace and resumes its latest session — you cannot target another workspace or another session. If a turn is still active when the timer fires, the wakeup is skipped (status: `session-active`). Replaces any previously pending wakeup on this workspace. Delay is clamped to [60, 21600] seconds (1min to 6h). Prefer this over the built-in `ScheduleWakeup` tool — it is the SDK-supported entry point.',
328
387
  inputSchema: {
329
388
  type: 'object',
330
389
  properties: {
331
390
  delaySeconds: {
332
391
  type: 'number',
333
- description: 'Seconds from now until the wakeup fires. Clamped to [60, 3600].',
392
+ description: 'Seconds from now until the wakeup fires. Clamped to [60, 21600] (1min to 6h).',
334
393
  },
335
394
  prompt: {
336
395
  type: 'string',
@@ -436,6 +495,56 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
436
495
  }
437
496
  return ok(result);
438
497
  }
498
+ if (name === 'cron_create') {
499
+ const expression = a.expression;
500
+ const prompt = a.prompt;
501
+ const label = typeof a.label === 'string' ? a.label : undefined;
502
+ const mode = typeof a.mode === 'string' ? a.mode : undefined;
503
+ const oneShot = typeof a.oneShot === 'boolean' ? a.oneShot : undefined;
504
+ if (typeof expression !== 'string' || typeof prompt !== 'string') {
505
+ return fail('expression and prompt parameters are required');
506
+ }
507
+ if (mode !== undefined && mode !== 'resume' && mode !== 'fresh') {
508
+ return fail("mode must be 'resume' or 'fresh'");
509
+ }
510
+ // Route through the backend so the in-memory `setTimeout` lives in the
511
+ // backend process (which owns orchestrator + WS broadcast). Calling
512
+ // cron-service.arm() directly here would persist the row but arm the
513
+ // timer in the MCP server sub-process, which dies with the agent
514
+ // session — fires would never trigger a real session resume.
515
+ try {
516
+ const created = (await backendRequest('POST', `/api/workspaces/${workspaceId}/crons`, {
517
+ expression,
518
+ prompt,
519
+ label,
520
+ mode,
521
+ oneShot,
522
+ }));
523
+ return ok({ ok: true, cron: created.cron });
524
+ }
525
+ catch (err) {
526
+ const message = err instanceof Error ? err.message : String(err);
527
+ return ok({ ok: false, error: message });
528
+ }
529
+ }
530
+ if (name === 'cron_delete') {
531
+ const id = a.id;
532
+ if (typeof id !== 'string')
533
+ return fail('id parameter is required');
534
+ // Same reason as cron_create — the backend owns the timer Map.
535
+ try {
536
+ await backendRequest('DELETE', `/api/workspaces/${workspaceId}/crons/${id}`);
537
+ return ok({ ok: true });
538
+ }
539
+ catch (err) {
540
+ const message = err instanceof Error ? err.message : String(err);
541
+ return ok({ ok: false, error: message });
542
+ }
543
+ }
544
+ if (name === 'cron_list') {
545
+ const result = cronListHandler(db, workspaceId);
546
+ return ok(result);
547
+ }
439
548
  if (name === 'start_dev_server') {
440
549
  const result = await backendRequest('POST', `/api/dev-server/${workspaceId}/start`);
441
550
  return ok(result);
@@ -243,6 +243,44 @@ export const migrations = [
243
243
  db.prepare('ALTER TABLE workspaces ADD COLUMN agent_description TEXT').run();
244
244
  },
245
245
  },
246
+ {
247
+ version: 22,
248
+ name: 'add-pending-crons',
249
+ migrate: (db) => {
250
+ // Per-workspace cron schedules: each row arms a recurring agent prompt
251
+ // on a cron expression. Sibling timer table to pending_wakeups /
252
+ // pending_quota_backoffs. Many rows per workspace (unlike the one-row
253
+ // sibling tables), so id is the primary key. CASCADE on workspace
254
+ // delete to keep the timer set tidy. Idempotent via IF NOT EXISTS.
255
+ db.prepare(`CREATE TABLE IF NOT EXISTS pending_crons (
256
+ id TEXT PRIMARY KEY,
257
+ workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
258
+ expression TEXT NOT NULL,
259
+ prompt TEXT NOT NULL,
260
+ label TEXT,
261
+ agent_session_id TEXT,
262
+ next_fire_at TEXT NOT NULL,
263
+ last_fired_at TEXT,
264
+ created_at TEXT NOT NULL
265
+ )`).run();
266
+ db.prepare('CREATE INDEX IF NOT EXISTS idx_pending_crons_workspace ON pending_crons(workspace_id)').run();
267
+ db.prepare('CREATE INDEX IF NOT EXISTS idx_pending_crons_next_fire ON pending_crons(next_fire_at)').run();
268
+ },
269
+ },
270
+ {
271
+ version: 23,
272
+ name: 'add-pending-crons-one-shot',
273
+ migrate: (db) => {
274
+ // One-shot cron: fires once and cancels itself. Distinct from a wakeup
275
+ // because it still uses cron expressions (can target an absolute date,
276
+ // e.g. `0 14 7 6 *` = "next 7 June at 14:00") rather than a delay.
277
+ // Default 0 (recurring) preserves existing behaviour.
278
+ const cols = db.prepare('PRAGMA table_info(pending_crons)').all();
279
+ if (!cols.some((c) => c.name === 'one_shot')) {
280
+ db.prepare('ALTER TABLE pending_crons ADD COLUMN one_shot INTEGER NOT NULL DEFAULT 0').run();
281
+ }
282
+ },
283
+ },
246
284
  ];
247
285
  /** Current schema version — always equals the highest migration version. */
248
286
  export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
@@ -82,6 +82,22 @@ export function initSchema(db) {
82
82
  created_at TEXT NOT NULL
83
83
  );
84
84
 
85
+ CREATE TABLE IF NOT EXISTS pending_crons (
86
+ id TEXT PRIMARY KEY,
87
+ workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
88
+ expression TEXT NOT NULL,
89
+ prompt TEXT NOT NULL,
90
+ label TEXT,
91
+ agent_session_id TEXT,
92
+ next_fire_at TEXT NOT NULL,
93
+ last_fired_at TEXT,
94
+ one_shot INTEGER NOT NULL DEFAULT 0,
95
+ created_at TEXT NOT NULL
96
+ );
97
+
98
+ CREATE INDEX IF NOT EXISTS idx_pending_crons_workspace ON pending_crons(workspace_id);
99
+ CREATE INDEX IF NOT EXISTS idx_pending_crons_next_fire ON pending_crons(next_fire_at);
100
+
85
101
  CREATE TABLE IF NOT EXISTS usage_snapshots (
86
102
  provider_id TEXT PRIMARY KEY,
87
103
  status TEXT NOT NULL,
@@ -23,6 +23,7 @@ import workspacesRouter from './routes/workspaces.js';
23
23
  import { getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopWatchdog, } from './services/agent/orchestrator.js';
24
24
  import * as autoLoopService from './services/auto-loop-service.js';
25
25
  import { runContentMigrationIfNeeded } from './services/content-migration-service.js';
26
+ import * as cronService from './services/cron-service.js';
26
27
  import { createDailyDbBackupIfNeeded } from './services/db-backup-service.js';
27
28
  import { startDevServer, stopDevServer } from './services/dev-server-service.js';
28
29
  import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js';
@@ -60,6 +61,7 @@ autoLoopService.rehydrate();
60
61
  // from retryCount=0 and undo the progression.
61
62
  restoreRetryCountsFromDb();
62
63
  quotaBackoffService.restoreOnBoot((workspaceId) => autoLoopService.onQuotaBackoffExpired(workspaceId));
64
+ cronService.restoreOnBoot();
63
65
  startPrWatcher();
64
66
  startUsagePoller();
65
67
  // Create Hono app
@@ -63,15 +63,73 @@ app.get('/report', (c) => {
63
63
  worktreesMissing.push({ workspaceId: ws.id, name: ws.name, path: wtPath, exists: false });
64
64
  }
65
65
  }
66
- // Orphan agent sessions — marked running but PID no longer alive
66
+ // Orphan agent sessions — marked running but PID no longer alive.
67
+ // Also collect the alive ones for the active-state panel.
67
68
  const runningSessions = db
68
- .prepare("SELECT pid FROM agent_sessions WHERE status = 'running' AND pid IS NOT NULL")
69
+ .prepare(`
70
+ SELECT s.pid AS pid, s.workspace_id AS workspaceId, s.started_at AS startedAt, w.name AS workspaceName
71
+ FROM agent_sessions s
72
+ JOIN workspaces w ON w.id = s.workspace_id
73
+ WHERE s.status = 'running' AND s.pid IS NOT NULL
74
+ `)
69
75
  .all();
70
76
  let orphaned = 0;
77
+ const agentSessionsAlive = [];
71
78
  for (const s of runningSessions) {
72
- if (s.pid && !isProcessAlive(s.pid))
79
+ if (!s.pid)
80
+ continue;
81
+ if (isProcessAlive(s.pid)) {
82
+ agentSessionsAlive.push({
83
+ workspaceId: s.workspaceId,
84
+ workspaceName: s.workspaceName,
85
+ pid: s.pid,
86
+ startedAt: s.startedAt,
87
+ });
88
+ }
89
+ else {
73
90
  orphaned++;
91
+ }
74
92
  }
93
+ // Active state — features the user wants to see at a glance:
94
+ // pending quota backoffs, scheduled wakeups, armed auto-loops, running dev servers.
95
+ const quotaBackoffs = db
96
+ .prepare(`
97
+ SELECT q.workspace_id AS workspaceId, w.name AS name, q.target_at AS targetAt,
98
+ q.resets_at AS resetsAt, q.source AS source, q.retry_count AS retryCount
99
+ FROM pending_quota_backoffs q
100
+ JOIN workspaces w ON w.id = q.workspace_id
101
+ ORDER BY q.target_at ASC
102
+ `)
103
+ .all();
104
+ const pendingWakeups = db
105
+ .prepare(`
106
+ SELECT p.workspace_id AS workspaceId, w.name AS name, p.target_at AS targetAt, p.reason AS reason
107
+ FROM pending_wakeups p
108
+ JOIN workspaces w ON w.id = p.workspace_id
109
+ ORDER BY p.target_at ASC
110
+ `)
111
+ .all();
112
+ const autoLoopActiveRaw = db
113
+ .prepare(`
114
+ SELECT id AS workspaceId, name, auto_loop_ready AS ready
115
+ FROM workspaces
116
+ WHERE auto_loop = 1 AND archived_at IS NULL
117
+ ORDER BY name ASC
118
+ `)
119
+ .all();
120
+ const autoLoopActive = autoLoopActiveRaw.map((r) => ({
121
+ workspaceId: r.workspaceId,
122
+ name: r.name,
123
+ ready: r.ready === 1,
124
+ }));
125
+ const devServersRunning = db
126
+ .prepare(`
127
+ SELECT id AS workspaceId, name
128
+ FROM workspaces
129
+ WHERE dev_server_status = 'running' AND archived_at IS NULL
130
+ ORDER BY name ASC
131
+ `)
132
+ .all();
75
133
  const settingsRow = db.prepare('SELECT COUNT(*) as n FROM workspaces').get();
76
134
  const archivedRow = db.prepare('SELECT COUNT(*) as n FROM workspaces WHERE archived_at IS NOT NULL').get();
77
135
  const report = {
@@ -95,6 +153,13 @@ app.get('/report', (c) => {
95
153
  sentry: { configured: Boolean(healthGlobalSettings.sentryMcpKey) },
96
154
  editor: { configured: Boolean(healthGlobalSettings.editorCommand) },
97
155
  },
156
+ active: {
157
+ quotaBackoffs,
158
+ pendingWakeups,
159
+ autoLoopActive,
160
+ agentSessionsAlive,
161
+ devServersRunning,
162
+ },
98
163
  };
99
164
  return c.json(report);
100
165
  });
@@ -10,6 +10,7 @@ import { migrationGuard } from '../middleware/migration-guard.js';
10
10
  import { listEngines } from '../services/agent/engines/registry.js';
11
11
  import * as agentManager from '../services/agent/orchestrator.js';
12
12
  import * as autoLoopService from '../services/auto-loop-service.js';
13
+ import * as cronService from '../services/cron-service.js';
13
14
  import * as devServerService from '../services/dev-server-service.js';
14
15
  import { DEFAULT_NOTION_INITIAL_PROMPT, DEFAULT_SENTRY_INITIAL_PROMPT, renderNotionInitialPrompt, renderSentryInitialPrompt, } from '../services/initial-prompt-template-service.js';
15
16
  import * as notionService from '../services/notion-service.js';
@@ -587,6 +588,9 @@ app.post('/', migrationGuard, async (c) => {
587
588
  brainstormPrompt += `- get_notion_ticket() — retrieve the Notion ticket info (URL, ticket ID, extracted content)\n`;
588
589
  }
589
590
  brainstormPrompt += `- kobo__set_workspace_agent_description(description) — keep the workspace's agent_description up to date as a short one-line summary of what you're currently doing or have just accomplished. The user sees this in the sidebar without opening the workspace. Update it whenever your focus shifts (e.g. "Investigating SERVICE-1600 → enriching local Notion file", then "Writing failing test for FacturX validator"). Plain text, max 200 chars. The current value is in kobo__get_workspace_info.\nThere is also a separate user-controlled \`description\` field on the workspace — DO NOT touch it. Only set_workspace_agent_description is yours to write; the user owns the other one.\n`;
591
+ brainstormPrompt += `- kobo__cron_create(expression, prompt, label?, mode?, oneShot?) — schedule a (recurring or one-shot) trigger on THIS workspace. At each fire Kōbō waits for the workspace to be idle and then injects \`prompt\` as the next user message. \`expression\` is a standard 5-field cron (\`min hour dom month dow\`) or a helper (\`@hourly\`, \`@daily\`, \`@weekly\`, \`@monthly\`, \`@yearly\`). Examples: \`*/30 * * * *\` = every 30 min; \`0 9 * * 1\` = every Monday at 9am; \`0 14 7 6 *\` = 7 June at 14:00. \`mode\` is \`'resume'\` (default — every fire continues the SAME conversation that scheduled the cron, so you can chain follow-ups) or \`'fresh'\` (every fire starts a brand-new session with a clean context, ideal for periodic checks like CI watch). \`oneShot\` (default false): when true, the cron cancels itself after the first real fire — use this to trigger once at a specific time without recurring. Skip-if-active: occurrences fired while a session is running are skipped, the next is computed, and the cron continues. Persists across restarts. Returns a cron \`id\`.\n`;
592
+ brainstormPrompt += `- kobo__cron_delete(id) — cancel a previously-armed cron by id (idempotent).\n`;
593
+ brainstormPrompt += `- kobo__cron_list() — list every cron currently armed on THIS workspace, with their next/last fire times.\n`;
590
594
  if (effectiveSettings.gitConventions) {
591
595
  brainstormPrompt += `\n# Git conventions\nIMPORTANT: Before any git operation (commit, branch, rebase, merge, push), read and apply the conventions defined in \`.ai/.git-conventions.md\`. They are project-specific and override any default behavior. Re-read this file if you're unsure or if context was compacted.\n`;
592
596
  }
@@ -720,7 +724,8 @@ app.get('/auto-loop-states', (c) => {
720
724
  const rows = db
721
725
  .prepare(`SELECT w.id, w.auto_loop, w.auto_loop_ready, w.no_progress_streak,
722
726
  COUNT(t.id) AS tasks_total,
723
- SUM(CASE WHEN t.status = 'done' THEN 1 ELSE 0 END) AS tasks_done
727
+ SUM(CASE WHEN t.status = 'done' THEN 1 ELSE 0 END) AS tasks_done,
728
+ (SELECT COUNT(*) FROM pending_crons p WHERE p.workspace_id = w.id) AS crons_count
724
729
  FROM workspaces w
725
730
  LEFT JOIN tasks t ON t.workspace_id = w.id
726
731
  WHERE w.archived_at IS NULL
@@ -734,6 +739,7 @@ app.get('/auto-loop-states', (c) => {
734
739
  no_progress_streak: r.no_progress_streak,
735
740
  tasks_done: r.tasks_done ?? 0,
736
741
  tasks_total: r.tasks_total ?? 0,
742
+ crons_count: r.crons_count ?? 0,
737
743
  };
738
744
  }
739
745
  return c.json(out);
@@ -795,6 +801,85 @@ app.post('/:id/auto-loop-ready', (c) => {
795
801
  return c.json({ error: message }, 500);
796
802
  }
797
803
  });
804
+ // GET /api/workspaces/:id/crons — list pending crons for a workspace.
805
+ app.get('/:id/crons', (c) => {
806
+ try {
807
+ const id = c.req.param('id');
808
+ const workspace = workspaceService.getWorkspace(id);
809
+ if (!workspace)
810
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
811
+ return c.json({ crons: cronService.listForWorkspace(id) });
812
+ }
813
+ catch (err) {
814
+ const message = err instanceof Error ? err.message : String(err);
815
+ return c.json({ error: message }, 500);
816
+ }
817
+ });
818
+ // POST /api/workspaces/:id/crons — arm a new cron. Validates the expression
819
+ // in the service layer; invalid expressions surface as a 400.
820
+ app.post('/:id/crons', async (c) => {
821
+ try {
822
+ const id = c.req.param('id');
823
+ const workspace = workspaceService.getWorkspace(id);
824
+ if (!workspace)
825
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
826
+ const body = (await c.req.json().catch(() => ({})));
827
+ const expression = typeof body.expression === 'string' ? body.expression : '';
828
+ const prompt = typeof body.prompt === 'string' ? body.prompt : '';
829
+ const label = typeof body.label === 'string' ? body.label : undefined;
830
+ const rawMode = typeof body.mode === 'string' ? body.mode : 'resume';
831
+ if (rawMode !== 'resume' && rawMode !== 'fresh') {
832
+ return c.json({ error: "mode must be 'resume' or 'fresh'" }, 400);
833
+ }
834
+ const mode = rawMode;
835
+ const oneShot = body.oneShot === true;
836
+ if (!expression || !prompt) {
837
+ return c.json({ error: 'expression and prompt are required' }, 400);
838
+ }
839
+ try {
840
+ // Mode controls how each fire is handled:
841
+ // - 'resume' (default): pin the cron to the session that scheduled it,
842
+ // so each fire resumes THAT conversation. Same pattern as wakeup.
843
+ // - 'fresh': don't pin a session — every fire spawns a new session
844
+ // with a clean context. Useful for periodic checks (e.g. CI watch)
845
+ // that don't need conversation continuity.
846
+ // oneShot=true cancels the cron after the first real fire (skip-active
847
+ // ticks don't consume the one-shot — the cron retries at the next
848
+ // occurrence until it actually fires once).
849
+ // The DB encodes mode via `agent_session_id`: non-NULL = resume that
850
+ // session; NULL = fresh. When mode='resume' but no session is active
851
+ // at create time, fall back to NULL — fire will spawn fresh.
852
+ const agentSessionId = mode === 'resume' ? (agentManager.getActiveSessionId(id) ?? undefined) : undefined;
853
+ const cron = cronService.arm(id, { expression, prompt, label, agentSessionId, oneShot });
854
+ return c.json({ cron }, 201);
855
+ }
856
+ catch (err) {
857
+ const message = err instanceof Error ? err.message : String(err);
858
+ return c.json({ error: message }, 400);
859
+ }
860
+ }
861
+ catch (err) {
862
+ const message = err instanceof Error ? err.message : String(err);
863
+ return c.json({ error: message }, 500);
864
+ }
865
+ });
866
+ // DELETE /api/workspaces/:id/crons/:cronId — cancel a single cron. Idempotent:
867
+ // returns 204 even when the cron does not exist (matches pending-wakeup style).
868
+ app.delete('/:id/crons/:cronId', (c) => {
869
+ try {
870
+ const id = c.req.param('id');
871
+ const cronId = c.req.param('cronId');
872
+ const workspace = workspaceService.getWorkspace(id);
873
+ if (!workspace)
874
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
875
+ cronService.cancel(cronId, 'user');
876
+ return new Response(null, { status: 204 });
877
+ }
878
+ catch (err) {
879
+ const message = err instanceof Error ? err.message : String(err);
880
+ return c.json({ error: message }, 500);
881
+ }
882
+ });
798
883
  // GET /api/workspaces/:id/pending-wakeup — returns the pending wakeup or null.
799
884
  app.get('/:id/pending-wakeup', (c) => {
800
885
  try {
@@ -1122,6 +1207,22 @@ app.post('/:id/agent-description/notify-updated', (c) => {
1122
1207
  return c.json({ error: message }, 500);
1123
1208
  }
1124
1209
  });
1210
+ // POST /api/workspaces/:id/crons/notify-updated — broadcast cron list changed
1211
+ // after the MCP cron_create / cron_delete handlers wrote directly to DB.
1212
+ app.post('/:id/crons/notify-updated', (c) => {
1213
+ try {
1214
+ const id = c.req.param('id');
1215
+ const workspace = workspaceService.getWorkspace(id);
1216
+ if (!workspace)
1217
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
1218
+ wsService.emitEphemeral(id, 'cron:updated', { crons: cronService.listForWorkspace(id) });
1219
+ return new Response(null, { status: 204 });
1220
+ }
1221
+ catch (err) {
1222
+ const message = err instanceof Error ? err.message : String(err);
1223
+ return c.json({ error: message }, 500);
1224
+ }
1225
+ });
1125
1226
  // POST /api/workspaces/:id/tasks/:taskId/notify-done — broadcast task:updated event
1126
1227
  app.post('/:id/tasks/:taskId/notify-done', (c) => {
1127
1228
  try {
@@ -1,7 +1,7 @@
1
1
  import { query, } from '@anthropic-ai/claude-agent-sdk';
2
2
  import { nanoid } from 'nanoid';
3
3
  import { CLAUDE_CODE_CAPABILITIES } from './capabilities.js';
4
- import { createMapperState, mapSdkMessage } from './event-mapper.js';
4
+ import { createMapperState, mapSdkMessage, QUOTA_PATTERN, tryEmitQuota } from './event-mapper.js';
5
5
  import { buildClaudeOptions } from './options-builder.js';
6
6
  import { buildPreCompactCustomInstructions } from './precompact-hook.js';
7
7
  import { resolveClaudeBinaryPath } from './resolve-binary.js';
@@ -97,15 +97,19 @@ export function createClaudeCodeEngine() {
97
97
  hooks,
98
98
  canUseTool,
99
99
  stderr: (data) => {
100
+ // QUOTA_PATTERN covers the canonical surfaces (rate_limit,
101
+ // out of extra usage, usage limit, quota exceeded). The 429+rate
102
+ // combo is a CLI-only HTTP-level surface that the SDK never emits
103
+ // structurally, so it stays as a separate guard alongside.
100
104
  const lower = data.toLowerCase();
101
- if (lower.includes('rate limit exceeded') ||
102
- lower.includes('rate_limit_exceeded') ||
103
- (lower.includes('429') && lower.includes('rate')) ||
104
- lower.includes('quota exceeded') ||
105
- lower.includes('out of extra usage') ||
106
- // "Claude AI usage limit reached" Anthropic's 5h/4h cap message
107
- lower.includes('usage limit')) {
108
- onEvent({ kind: 'error', category: 'quota', message: data });
105
+ const isQuota = QUOTA_PATTERN.test(data) || (lower.includes('429') && lower.includes('rate'));
106
+ if (isQuota) {
107
+ // Share `mapperState.quotaErrorEmitted` with the SDK iterator so
108
+ // a single run that surfaces quota via BOTH stderr AND a
109
+ // structured SDK signal (assistant.error / rate_limit_event)
110
+ // does not double-fire `handleQuota` (which would double the
111
+ // retryCount and overwrite the persisted backoff row).
112
+ tryEmitQuota(mapperState, onEvent, data);
109
113
  }
110
114
  else if (lower.includes('no conversation found with session id')) {
111
115
  onEvent({ kind: 'error', category: 'resume_failed', message: data });