@loicngr/kobo 1.7.5 → 1.7.7

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 (106) hide show
  1. package/README.md +92 -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 +4 -0
  7. package/dist/server/routes/health.js +68 -3
  8. package/dist/server/routes/voice.js +149 -0
  9. package/dist/server/routes/workspaces.js +102 -1
  10. package/dist/server/services/agent/engines/claude-code/engine.js +13 -9
  11. package/dist/server/services/agent/engines/claude-code/event-mapper.js +95 -10
  12. package/dist/server/services/agent/orchestrator.js +41 -0
  13. package/dist/server/services/auto-loop-service.js +8 -3
  14. package/dist/server/services/cron-service.js +279 -0
  15. package/dist/server/services/settings-service.js +57 -0
  16. package/dist/server/services/transcription-service.js +206 -0
  17. package/dist/server/services/wakeup-service.js +1 -1
  18. package/dist/server/services/workspace-service.js +18 -0
  19. package/dist/server/utils/git-ops.js +8 -1
  20. package/package.json +13 -10
  21. package/src/client/dist/spa/assets/{ActivityFeed-oW9PgZ8E.js → ActivityFeed-DlPVoOGb.js} +2 -2
  22. package/src/client/dist/spa/assets/{ActivityFeed-DVBfmJWJ.css → ActivityFeed-tE4LVYck.css} +1 -1
  23. package/src/client/dist/spa/assets/{AutoLoopChip-Y53cnGfZ.js → AutoLoopChip-CkSzkC0C.js} +1 -1
  24. package/src/client/dist/spa/assets/{ClosePopup-D_UAdwkA.js → ClosePopup-DTcbxsC0.js} +1 -1
  25. package/src/client/dist/spa/assets/CreatePage-BoRappO3.css +1 -0
  26. package/src/client/dist/spa/assets/CreatePage-DpCVNwYk.js +2 -0
  27. package/src/client/dist/spa/assets/{DiffViewer-rc3tE9fq.js → DiffViewer-D-uNbBq0.js} +3 -3
  28. package/src/client/dist/spa/assets/{DiffViewer-wFfQ9tcY.css → DiffViewer-DTdDcKZC.css} +1 -1
  29. package/src/client/dist/spa/assets/HealthPage-xZ0PP4F-.js +1 -0
  30. package/src/client/dist/spa/assets/{MainLayout-B9i06p7n.js → MainLayout-DdkKM2ba.js} +17 -17
  31. package/src/client/dist/spa/assets/MainLayout-drolsINz.css +1 -0
  32. package/src/client/dist/spa/assets/{QBadge-DWH42dbo.js → QBadge-C7r6oPSi.js} +1 -1
  33. package/src/client/dist/spa/assets/{QBtn-a6jxWjmW.js → QBtn-DEuWKHbR.js} +1 -1
  34. package/src/client/dist/spa/assets/{QCheckbox-D5jfsxLV.js → QCheckbox-BvHfXBFY.js} +1 -1
  35. package/src/client/dist/spa/assets/{QChip-ByxK0Tuf.js → QChip-erWIZgxW.js} +1 -1
  36. package/src/client/dist/spa/assets/{QExpansionItem-CH1ipL9n.js → QExpansionItem-BGg74no1.js} +1 -1
  37. package/src/client/dist/spa/assets/QIcon-qfJNZLIW.js +1 -0
  38. package/src/client/dist/spa/assets/{QInput-Cm5-AGQ4.js → QInput-DCJEwE8V.js} +1 -1
  39. package/src/client/dist/spa/assets/{QItemLabel-DrTxqTqV.js → QItemLabel-CHkgkZVj.js} +1 -1
  40. package/src/client/dist/spa/assets/{QItemSection-5YpFpPDm.js → QItemSection-CQUDd0Vg.js} +1 -1
  41. package/src/client/dist/spa/assets/{QList-D0FtnQJI.js → QList-BbnN_oNX.js} +1 -1
  42. package/src/client/dist/spa/assets/{QMenu-B4xMxMGd.js → QMenu-D6uqosRg.js} +1 -1
  43. package/src/client/dist/spa/assets/{QPage-DFi3K093.js → QPage-Co2h9wd_.js} +1 -1
  44. package/src/client/dist/spa/assets/{QRadio-B3aKjCVu.js → QRadio-DJxOyOA3.js} +1 -1
  45. package/src/client/dist/spa/assets/QSpace-DKIph84L.js +1 -0
  46. package/src/client/dist/spa/assets/{QSpinnerDots-CszPQQ9J.js → QSpinnerDots-Bfl2RMy4.js} +1 -1
  47. package/src/client/dist/spa/assets/{QTabPanels-D2ks0UIA.js → QTabPanels-ClPY9y4T.js} +1 -1
  48. package/src/client/dist/spa/assets/{QToggle-1-N9qWq4.js → QToggle-DNOTC_3a.js} +1 -1
  49. package/src/client/dist/spa/assets/{QTooltip-fDNzBEfN.js → QTooltip-DUGPNNeQ.js} +1 -1
  50. package/src/client/dist/spa/assets/{SearchPage-DdX7JZCD.js → SearchPage-C07dgzT9.js} +1 -1
  51. package/src/client/dist/spa/assets/SettingsPage-CLNmI0Rr.css +1 -0
  52. package/src/client/dist/spa/assets/SettingsPage-D0CZNqkA.js +9 -0
  53. package/src/client/dist/spa/assets/{TouchPan-DoE24Io3.js → TouchPan-DvVlszwO.js} +1 -1
  54. package/src/client/dist/spa/assets/WorkspacePage-CKeCLPi0.js +4 -0
  55. package/src/client/dist/spa/assets/WorkspacePage-CRIcsASQ.css +1 -0
  56. package/src/client/dist/spa/assets/{build-path-tree-B1Lvvqto.js → build-path-tree-CCMckvpr.js} +1 -1
  57. package/src/client/dist/spa/assets/{cssMode-DSB5jkRt.js → cssMode-D6XTTdwy.js} +1 -1
  58. package/src/client/dist/spa/assets/{documents-kx0vLfSG.js → documents-soWtna0O.js} +1 -1
  59. package/src/client/dist/spa/assets/{editor.api-Bcw50eFD.js → editor.api-6hDVHddO.js} +1 -1
  60. package/src/client/dist/spa/assets/{editor.main-D9piVGaH.js → editor.main-DsLU1RWu.js} +3 -3
  61. package/src/client/dist/spa/assets/{expand-template-BIPuNAYV.js → expand-template-Crz1uiBt.js} +1 -1
  62. package/src/client/dist/spa/assets/{formatters-DCAQ6ANJ.js → formatters-guwb-rzl.js} +1 -1
  63. package/src/client/dist/spa/assets/{freemarker2-CVh_Zh8H.js → freemarker2-Bn1f0t2U.js} +1 -1
  64. package/src/client/dist/spa/assets/{handlebars-CpCgELpu.js → handlebars-O92Cbq66.js} +1 -1
  65. package/src/client/dist/spa/assets/{html-ikWDpvWk.js → html-Ck95BMBU.js} +1 -1
  66. package/src/client/dist/spa/assets/{htmlMode-C9TTCKih.js → htmlMode-DDYhH2FJ.js} +1 -1
  67. package/src/client/dist/spa/assets/i18n-BLgknHpf.js +1 -0
  68. package/src/client/dist/spa/assets/index-CdHDdk1y.js +2 -0
  69. package/src/client/dist/spa/assets/{javascript-C4OlkNeA.js → javascript-Cy2ddqHg.js} +1 -1
  70. package/src/client/dist/spa/assets/{jsonMode-BiD34_86.js → jsonMode-BIfVcp5z.js} +1 -1
  71. package/src/client/dist/spa/assets/{liquid-Dty0Ui2c.js → liquid-B287eegh.js} +1 -1
  72. package/src/client/dist/spa/assets/{mdx-yiUjOVv6.js → mdx-B8HSzGai.js} +1 -1
  73. package/src/client/dist/spa/assets/{models-BDkLiht9.js → models-Bd_v3W7Q.js} +1 -1
  74. package/src/client/dist/spa/assets/{monaco.contribution-Bz9yFPWR.js → monaco.contribution-CofcHzEf.js} +2 -2
  75. package/src/client/dist/spa/assets/{notifications-OnPq4FrH.js → notifications-BPnKFW60.js} +1 -1
  76. package/src/client/dist/spa/assets/{purify.es-BIY760fF.js → purify.es-BCEwTYRx.js} +1 -1
  77. package/src/client/dist/spa/assets/{python-7SPSWQoD.js → python-csaKR6_U.js} +1 -1
  78. package/src/client/dist/spa/assets/{razor-eagZawXK.js → razor-C2wEv-nX.js} +1 -1
  79. package/src/client/dist/spa/assets/{render-chat-markdown-TvAqpDih.js → render-chat-markdown-Bjcei0vn.js} +1 -1
  80. package/src/client/dist/spa/assets/runtime-core.esm-bundler-9Z0QAO_7.js +1 -0
  81. package/src/client/dist/spa/assets/{tsMode-CLYG2xeJ.js → tsMode-DGLVs57K.js} +1 -1
  82. package/src/client/dist/spa/assets/{typescript-CzOXM8yS.js → typescript-w0GWHzZ3.js} +1 -1
  83. package/src/client/dist/spa/assets/{use-checkbox-D7zmRxGI.js → use-checkbox-y_fOkYZN.js} +1 -1
  84. package/src/client/dist/spa/assets/{use-id-CuaR1RiE.js → use-id-_7wiRcgb.js} +1 -1
  85. package/src/client/dist/spa/assets/{use-panel-D-8nAQns.js → use-panel-CbJ44rqY.js} +1 -1
  86. package/src/client/dist/spa/assets/use-quasar-DQYS47mh.js +1 -0
  87. package/src/client/dist/spa/assets/{vue-i18n-BcfTCFFS.js → vue-i18n-DI-gS-CC.js} +1 -1
  88. package/src/client/dist/spa/assets/{xml-2_0_6RAX.js → xml-CTn-vnEd.js} +1 -1
  89. package/src/client/dist/spa/assets/{yaml-CtpgNyXs.js → yaml-CTyUSvLZ.js} +1 -1
  90. package/src/client/dist/spa/index.html +12 -12
  91. package/src/mcp-server/kobo-tasks-handlers.ts +20 -0
  92. package/src/mcp-server/kobo-tasks-server.ts +123 -7
  93. package/src/client/dist/spa/assets/CreatePage-CuD7sMR7.js +0 -2
  94. package/src/client/dist/spa/assets/CreatePage-DssmsAsV.css +0 -1
  95. package/src/client/dist/spa/assets/HealthPage-Dz0yGGMB.js +0 -1
  96. package/src/client/dist/spa/assets/MainLayout-DDa3rGKA.css +0 -1
  97. package/src/client/dist/spa/assets/QIcon-BJuyqdsT.js +0 -1
  98. package/src/client/dist/spa/assets/QSpace-CLtL3aPy.js +0 -1
  99. package/src/client/dist/spa/assets/SettingsPage-CMyeQ9_u.css +0 -1
  100. package/src/client/dist/spa/assets/SettingsPage-Dnj1CWc3.js +0 -1
  101. package/src/client/dist/spa/assets/WorkspacePage-CCtIrBiR.css +0 -1
  102. package/src/client/dist/spa/assets/WorkspacePage-DHp20nl-.js +0 -4
  103. package/src/client/dist/spa/assets/i18n-DZCb8dnb.js +0 -1
  104. package/src/client/dist/spa/assets/index-DuK38XN5.js +0 -2
  105. package/src/client/dist/spa/assets/runtime-core.esm-bundler-C3IgBgY5.js +0 -1
  106. package/src/client/dist/spa/assets/use-quasar-Sdcq6zzV.js +0 -1
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
 
@@ -153,6 +155,90 @@ If you need to pin a specific version of the Notion MCP server, use a fork, or a
153
155
 
154
156
  Without a valid token configured, the Notion import field in the workspace creation form will return an error when you click **Refresh** or submit a Notion URL — the rest of Kōbō (workspaces, agents, tasks, Git integration) keeps working independently.
155
157
 
158
+ ## Voice transcription (local Whisper)
159
+
160
+ Kōbō supports local voice transcription with push-to-talk in both:
161
+
162
+ - `WorkspacePage` (chat input)
163
+ - `CreatePage` (workspace instructions textarea)
164
+
165
+ ### Requirements
166
+
167
+ - `whisper-cli` from [`whisper.cpp`](https://github.com/ggml-org/whisper.cpp)
168
+ - `ffmpeg`
169
+ - `cmake` (required to build `whisper.cpp` from source)
170
+ - At least one Whisper model downloaded from **Settings → Voice**
171
+
172
+ ### Install `whisper.cpp` (Linux/macOS)
173
+
174
+ ```bash
175
+ git clone https://github.com/ggml-org/whisper.cpp.git
176
+ cd whisper.cpp
177
+ cmake -B build
178
+ cmake --build build -j
179
+ ```
180
+
181
+ This usually produces `build/bin/whisper-cli`.
182
+
183
+ You can also download a prebuilt archive from the `whisper.cpp` releases page (for example: <https://github.com/ggml-org/whisper.cpp/releases/tag/v1.8.4>) and point Kōbō to the extracted `whisper-cli` binary path.
184
+
185
+ ### Install `ffmpeg`
186
+
187
+ Ubuntu / Debian:
188
+
189
+ ```bash
190
+ sudo apt update
191
+ sudo apt install -y cmake build-essential ffmpeg
192
+ ```
193
+
194
+ Windows:
195
+
196
+ - Install `ffmpeg` (for example via Chocolatey: `choco install ffmpeg`, or via Scoop: `scoop install ffmpeg`)
197
+ - Verify in PowerShell:
198
+
199
+ ```powershell
200
+ where ffmpeg
201
+ ffmpeg -version
202
+ ```
203
+
204
+ ### Windows notes for `whisper.cpp`
205
+
206
+ Install CMake and Visual Studio Build Tools (C/C++), then build `whisper.cpp` (or use a prebuilt `whisper-cli`), then verify:
207
+
208
+ ```powershell
209
+ where whisper-cli
210
+ whisper-cli -h
211
+ ```
212
+
213
+ ### Configure in Kōbō
214
+
215
+ Open **Settings → Voice**:
216
+
217
+ - Enable voice transcription
218
+ - Optionally set:
219
+ - **Whisper binary path (optional)**
220
+ - **ffmpeg binary path (optional)**
221
+ - If left empty, Kōbō falls back to:
222
+ - `whisper-cli` from `PATH` (or `WHISPER_CPP_COMMAND` if set)
223
+ - `ffmpeg` from `PATH`
224
+ - Download a model (e.g. `base`) and select it as active
225
+
226
+ The Voice panel shows runtime status (`ready/missing`) for both Whisper and ffmpeg so setup issues are visible immediately.
227
+
228
+ ### Advanced voice parameters
229
+
230
+ Kōbō exposes additional transcription settings in **Settings → Voice**:
231
+
232
+ - **Temperature** (`0..1`) — decoding stability vs flexibility
233
+ - **Initial prompt** — optional context/jargon for better recognition
234
+ - **Translate to English** — translate non-English speech to English
235
+ - **Suppress non-speech tokens** — reduce non-speech artifacts in output
236
+
237
+ Recommended defaults by model:
238
+
239
+ - `tiny` / `base` → `0.1`
240
+ - `small` / `medium` / `large-v3` → `0.2`
241
+
156
242
  ## Sentry integration
157
243
 
158
244
  Kōbō can turn a Sentry issue into a dedicated "fix workspace" — you paste the issue URL at workspace creation and Kōbō extracts the stacktrace, culprit, tags, offending spans and extra context, writes them as a local markdown file inside the worktree (`.ai/thoughts/SENTRY-<id>.md`), and primes the Claude agent with a TDD fix workflow that points at that file. The agent also keeps access to the Sentry MCP tools (`search_issue_events`, `get_issue_tag_values`, `get_sentry_resource`) so it can dig deeper on its own. **This feature is opt-in and reuses the Sentry MCP configuration you already have for Claude Code** — Kōbō does not manage a Sentry token separately.
@@ -231,6 +317,8 @@ src/
231
317
  │ │ ├── content-migration-service.ts # legacy ws_events → normalised AgentEvent rows, with DB backup
232
318
  │ │ ├── usage/ # pluggable quota provider, 60s poller, persistence, WS broadcast
233
319
  │ │ ├── quota-backoff-service.ts # persisted Claude rate-limit backoff timers (re-armed on restart)
320
+ │ │ ├── cron-service.ts # persisted cron schedules (recurring + one-shot, resume/fresh modes)
321
+ │ │ ├── wakeup-service.ts # persisted one-shot session resumes (clamped to [60s, 6h])
234
322
  │ │ └── … # workspace, dev-server, ws, notion, sentry, settings, pr-template
235
323
  │ ├── routes/ # Hono handlers (workspaces, engines, migration, templates, usage, …)
236
324
  │ └── utils/ # git-ops, process-tracker, paths
@@ -261,6 +349,7 @@ See [`AGENTS.md`](./AGENTS.md) for a deeper dive into conventions, data model, W
261
349
  | `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
350
  | `pending_wakeups` | one row per scheduled wakeup, target time and resume context, re-armed on server restart |
263
351
  | `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 |
352
+ | `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
353
 
265
354
  ## MCP server
266
355
 
@@ -273,9 +362,9 @@ Each workspace spawns its own `kobo-tasks` MCP server as a child process of the
273
362
  - **Dev server** — `get_dev_server_status`, `start_dev_server`, `stop_dev_server`, `get_dev_server_logs`
274
363
  - **External sources** — `get_notion_ticket`, `get_settings`
275
364
  - **Documents & search** — `list_documents`, `read_document`, `log_thought`, `search_codebase`, `list_workspace_images`
276
- - **Scheduling & telemetry** — `schedule_wakeup`, `cancel_wakeup`, `get_session_usage`
365
+ - **Scheduling & telemetry** — `schedule_wakeup`, `cancel_wakeup`, `cron_create`, `cron_delete`, `cron_list`, `get_session_usage`
277
366
 
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.
367
+ 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
368
 
280
369
  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
370
 
@@ -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,
@@ -19,10 +19,12 @@ import sentryRouter from './routes/sentry.js';
19
19
  import settingsRouter from './routes/settings.js';
20
20
  import templatesRouter from './routes/templates.js';
21
21
  import usageRoutes from './routes/usage.js';
22
+ import voiceRouter from './routes/voice.js';
22
23
  import workspacesRouter from './routes/workspaces.js';
23
24
  import { getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopWatchdog, } from './services/agent/orchestrator.js';
24
25
  import * as autoLoopService from './services/auto-loop-service.js';
25
26
  import { runContentMigrationIfNeeded } from './services/content-migration-service.js';
27
+ import * as cronService from './services/cron-service.js';
26
28
  import { createDailyDbBackupIfNeeded } from './services/db-backup-service.js';
27
29
  import { startDevServer, stopDevServer } from './services/dev-server-service.js';
28
30
  import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js';
@@ -60,6 +62,7 @@ autoLoopService.rehydrate();
60
62
  // from retryCount=0 and undo the progression.
61
63
  restoreRetryCountsFromDb();
62
64
  quotaBackoffService.restoreOnBoot((workspaceId) => autoLoopService.onQuotaBackoffExpired(workspaceId));
65
+ cronService.restoreOnBoot();
63
66
  startPrWatcher();
64
67
  startUsagePoller();
65
68
  // Create Hono app
@@ -81,6 +84,7 @@ app.route('/api/search', searchRouter);
81
84
  app.route('/api/health', healthRouter);
82
85
  app.route('/api/engines', enginesRouter);
83
86
  app.route('/api/migration', migrationRouter);
87
+ app.route('/api/voice', voiceRouter);
84
88
  // Skills endpoint
85
89
  app.get('/api/skills', (c) => c.json(getAvailableSkills()));
86
90
  const PORT = parseInt(process.env.SERVER_PORT || process.env.PORT || '3000', 10);
@@ -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
  });