@loicngr/kobo 1.7.4 → 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 (65) hide show
  1. package/README.md +26 -9
  2. package/dist/mcp-server/kobo-tasks-handlers.js +41 -1
  3. package/dist/mcp-server/kobo-tasks-server.js +157 -8
  4. package/dist/server/db/migrations.js +87 -0
  5. package/dist/server/db/schema.js +27 -0
  6. package/dist/server/index.js +9 -1
  7. package/dist/server/routes/health.js +68 -3
  8. package/dist/server/routes/workspaces.js +183 -4
  9. package/dist/server/services/agent/engines/claude-code/engine.js +13 -6
  10. package/dist/server/services/agent/engines/claude-code/event-mapper.js +96 -7
  11. package/dist/server/services/agent/orchestrator.js +113 -71
  12. package/dist/server/services/auto-loop-service.js +16 -3
  13. package/dist/server/services/cron-service.js +279 -0
  14. package/dist/server/services/quota-backoff-service.js +127 -0
  15. package/dist/server/services/wakeup-service.js +1 -1
  16. package/dist/server/services/workspace-service.js +98 -0
  17. package/dist/server/utils/git-ops.js +8 -1
  18. package/package.json +2 -1
  19. package/src/client/dist/spa/assets/{ActivityFeed-ClJLeAXJ.js → ActivityFeed-BboSPm4b.js} +2 -2
  20. package/src/client/dist/spa/assets/{ActivityFeed-DVBfmJWJ.css → ActivityFeed-tE4LVYck.css} +1 -1
  21. package/src/client/dist/spa/assets/AutoLoopChip-w8D77bI5.js +1 -0
  22. package/src/client/dist/spa/assets/{CreatePage-BOkt0Psl.js → CreatePage-BDObLDJc.js} +1 -1
  23. package/src/client/dist/spa/assets/{DiffViewer-Dls1jFCN.js → DiffViewer-CblFgn8w.js} +3 -3
  24. package/src/client/dist/spa/assets/{DiffViewer-wFfQ9tcY.css → DiffViewer-DTdDcKZC.css} +1 -1
  25. package/src/client/dist/spa/assets/HealthPage-CBSw7e5q.js +1 -0
  26. package/src/client/dist/spa/assets/{MainLayout-DHNIerYJ.js → MainLayout-DhaYycak.js} +17 -17
  27. package/src/client/dist/spa/assets/MainLayout-drolsINz.css +1 -0
  28. package/src/client/dist/spa/assets/{SearchPage-BEnZ-CLq.js → SearchPage-cZTwP4Lf.js} +1 -1
  29. package/src/client/dist/spa/assets/{SettingsPage-DeCbWvPb.js → SettingsPage-C1efO0VM.js} +1 -1
  30. package/src/client/dist/spa/assets/WorkspacePage-3jcof896.js +4 -0
  31. package/src/client/dist/spa/assets/{WorkspacePage-eymEd4kx.css → WorkspacePage-CCtIrBiR.css} +1 -1
  32. package/src/client/dist/spa/assets/{cssMode-AlflsawW.js → cssMode-BFLYiiEw.js} +1 -1
  33. package/src/client/dist/spa/assets/{editor.api-DtvjQlUm.js → editor.api-2asmmhth.js} +1 -1
  34. package/src/client/dist/spa/assets/{editor.main-Ccy_gjVD.js → editor.main-ChCYZyez.js} +3 -3
  35. package/src/client/dist/spa/assets/{expand-template-AQsvbQ8_.js → expand-template-CXQFkQOJ.js} +1 -1
  36. package/src/client/dist/spa/assets/{freemarker2-DdQktlXK.js → freemarker2-BaBL9E9G.js} +1 -1
  37. package/src/client/dist/spa/assets/{handlebars-CE3ee2NH.js → handlebars-BxDour4L.js} +1 -1
  38. package/src/client/dist/spa/assets/{html-CCKX8Xv9.js → html-C6hnkfIL.js} +1 -1
  39. package/src/client/dist/spa/assets/{htmlMode-Dh8jDJum.js → htmlMode-9zT3-dmz.js} +1 -1
  40. package/src/client/dist/spa/assets/i18n-CLY0XI9-.js +1 -0
  41. package/src/client/dist/spa/assets/index-D6wj_wQ9.js +2 -0
  42. package/src/client/dist/spa/assets/{javascript-DhmZNdUp.js → javascript-C3YjvKbE.js} +1 -1
  43. package/src/client/dist/spa/assets/{jsonMode-B0xAtnNK.js → jsonMode-DcJDgMzf.js} +1 -1
  44. package/src/client/dist/spa/assets/{liquid-ByL0HpZ0.js → liquid-CsT8SjJM.js} +1 -1
  45. package/src/client/dist/spa/assets/{mdx-DX4pehAZ.js → mdx-CT3yVSyc.js} +1 -1
  46. package/src/client/dist/spa/assets/{models-ClWoqWeC.js → models-BsjWUKqM.js} +1 -1
  47. package/src/client/dist/spa/assets/{monaco.contribution-Fegh8Y1Y.js → monaco.contribution-DKGNz1oQ.js} +2 -2
  48. package/src/client/dist/spa/assets/{purify.es-BWZjBa9F.js → purify.es-CPieV82n.js} +1 -1
  49. package/src/client/dist/spa/assets/{python-COS2MM8n.js → python-Ca5miKgj.js} +1 -1
  50. package/src/client/dist/spa/assets/{razor-Cc3xCJU7.js → razor-7qzusGRc.js} +1 -1
  51. package/src/client/dist/spa/assets/{render-chat-markdown-DcGIpMoe.js → render-chat-markdown-Bqq2G-yI.js} +1 -1
  52. package/src/client/dist/spa/assets/{tsMode-eQIJjERk.js → tsMode-BdvO8jZ2.js} +1 -1
  53. package/src/client/dist/spa/assets/{typescript-DwIlacVU.js → typescript-BfVNzhgs.js} +1 -1
  54. package/src/client/dist/spa/assets/{xml-DP-09Aih.js → xml-DGNXGqXL.js} +1 -1
  55. package/src/client/dist/spa/assets/{yaml-BhrtimeA.js → yaml-CtAtOyt5.js} +1 -1
  56. package/src/client/dist/spa/index.html +1 -1
  57. package/src/mcp-server/kobo-tasks-handlers.ts +55 -1
  58. package/src/mcp-server/kobo-tasks-server.ts +165 -7
  59. package/src/client/dist/spa/assets/HealthPage-CMxH3SBS.js +0 -1
  60. package/src/client/dist/spa/assets/MainLayout-DKurmqtk.css +0 -1
  61. package/src/client/dist/spa/assets/WorkspacePage-DFAFT5OW.js +0 -4
  62. package/src/client/dist/spa/assets/i18n-BOsrrRj4.js +0 -1
  63. package/src/client/dist/spa/assets/index-_ZaIBxd6.js +0 -2
  64. /package/src/client/dist/spa/assets/{QPage-ChUKoaKe.js → QPage-DFi3K093.js} +0 -0
  65. /package/src/client/dist/spa/assets/{formatters-BD0_hovB.js → formatters-DCAQ6ANJ.js} +0 -0
package/README.md CHANGED
@@ -31,10 +31,13 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
31
31
  - **Resizable right drawer** — drag-to-resize horizontally and vertically, with tab state and split ratio persisted to localStorage
32
32
  - **Soft interrupt** — pause an agent mid-execution (SIGINT, like pressing Escape in Claude Code) without killing the process; the agent stops the current tool and waits for the next message
33
33
  - **Archive instead of delete** — soft-remove workspaces without losing the worktree, branches, or history; unarchive restores the exact pre-archive state
34
- - **Auto-loop mode** — opt-in, per-workspace: when enabled, Kōbō spawns a fresh Claude session for the next pending task after every `session:ended`, walking through the task list until all are `done`. Stops automatically on error, on stall (3 consecutive sessions with no task completed), or when the user clicks Stop. A grooming step (`/kobo-prep-autoloop`) ensures tasks are atomic before the loop runs; Notion-imported workspaces with both todos and acceptance criteria are auto-unlocked. **E2E grooming** — when a project declares an E2E framework in Settings (Cypress, Playwright, Vitest, etc.), the grooming phase injects an `[E2E] ` test sub-task between every parent task; each iteration then runs the matching E2E suite as part of its acceptance check
34
+ - **Auto-loop mode** — opt-in, per-workspace: when enabled, Kōbō spawns a fresh Claude session for the next pending task after every `session:ended`, walking through the task list until all are `done`. Stops automatically on error, on stall (3 consecutive sessions with no task completed), or when the user clicks Stop. A grooming step (`/kobo-prep-autoloop`) ensures tasks are atomic before the loop runs; Notion-imported workspaces with both todos and acceptance criteria are auto-unlocked. **E2E grooming** — when a project declares an E2E framework in Settings (Cypress, Playwright, Vitest, etc.), the grooming phase injects an `[E2E] ` test sub-task between every parent task; each iteration then runs the matching E2E suite as part of its acceptance check. **Sidebar progress chip** — auto-loop workspaces show an `X / Y tâches` badge in the workspace list, fed by the same task counts used by the in-page chip
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
- - **Quota-aware retry 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 a 15 → 30 → 60 → 180 → 300 min ladder only when the reset info is missing or implausible
37
- - **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
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
+ - **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. 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
38
41
 
39
42
  ## Tech stack
40
43
 
@@ -101,8 +104,8 @@ npm start # runs the compiled server
101
104
  ### Test & lint
102
105
 
103
106
  ```bash
104
- npm test # backend vitest suite (950+ tests)
105
- npm run test:client # client vitest suite (Pinia stores + pure utils, 85+ tests)
107
+ npm test # backend vitest suite (1200+ tests)
108
+ npm run test:client # client vitest suite (Pinia stores + pure utils, 230+ tests)
106
109
  npm run test:all # backend + client suites
107
110
  npm run lint # biome check (lint + format verification)
108
111
  npm run lint:fix # biome check with safe auto-fixes
@@ -229,6 +232,9 @@ src/
229
232
  │ │ │ └── engines/claude-code/ # spawn + NDJSON stream-parser + args-builder + mcp-config + capabilities
230
233
  │ │ ├── content-migration-service.ts # legacy ws_events → normalised AgentEvent rows, with DB backup
231
234
  │ │ ├── usage/ # pluggable quota provider, 60s poller, persistence, WS broadcast
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])
232
238
  │ │ └── … # workspace, dev-server, ws, notion, sentry, settings, pr-template
233
239
  │ ├── routes/ # Hono handlers (workspaces, engines, migration, templates, usage, …)
234
240
  │ └── utils/ # git-ops, process-tracker, paths
@@ -252,18 +258,29 @@ See [`AGENTS.md`](./AGENTS.md) for a deeper dive into conventions, data model, W
252
258
 
253
259
  | Table | Purpose |
254
260
  |---|---|
255
- | `workspaces` | the unit of work — branch, `worktree_path`, status, model, engine, `archived_at`, `favorited_at`, `tags`, Notion link, … |
261
+ | `workspaces` | the unit of work — branch, `worktree_path`, status, model, engine, `archived_at`, `favorited_at`, `tags`, Notion link, plus the two independent description columns (`description` user-controlled, `agent_description` agent-controlled), … |
256
262
  | `tasks` | workspace sub-items — tasks and acceptance criteria |
257
263
  | `agent_sessions` | agent runs — pid, `engine_session_id`, lifecycle |
258
264
  | `ws_events` | persisted WebSocket events (chat history, `agent:event` stream, user messages) for replay on reconnect |
259
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 |
266
+ | `pending_wakeups` | one row per scheduled wakeup, target time and resume context, re-armed on server restart |
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 |
260
269
 
261
270
  ## MCP server
262
271
 
263
- Each workspace spawns its own `kobo-tasks` MCP server as a child process of the Claude Code agent. It exposes two tools:
272
+ Each workspace spawns its own `kobo-tasks` MCP server as a child process of the Claude Code agent. It exposes a curated tool surface tailored for agents working inside a Kōbō workspace, grouped by intent:
264
273
 
265
- - `list_tasks()`returns all tasks & acceptance criteria for the current workspace with their IDs and status
266
- - `mark_task_done(task_id)` marks a task as done and notifies the backend over HTTP so the UI updates live
274
+ - **Tasks**`list_tasks`, `create_task`, `update_task`, `delete_task`, `mark_task_done`
275
+ - **Workspace metadata** — `get_workspace_info` (returns name, branch, status, both `description` and `agentDescription`, etc.), `set_workspace_agent_description` (live status the user sees in the sidebar without opening the workspace), `set_workspace_status`
276
+ - **Auto-loop** — `mark_auto_loop_ready` (flip the grooming gate when the brainstorm step is done)
277
+ - **Git** — `get_git_info` (branch, commit count, dirty state)
278
+ - **Dev server** — `get_dev_server_status`, `start_dev_server`, `stop_dev_server`, `get_dev_server_logs`
279
+ - **External sources** — `get_notion_ticket`, `get_settings`
280
+ - **Documents & search** — `list_documents`, `read_document`, `log_thought`, `search_codebase`, `list_workspace_images`
281
+ - **Scheduling & telemetry** — `schedule_wakeup`, `cancel_wakeup`, `cron_create`, `cron_delete`, `cron_list`, `get_session_usage`
282
+
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.
267
284
 
268
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.
269
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';
@@ -37,6 +38,29 @@ export function markAutoLoopReadyHandler(db, workspaceId) {
37
38
  db.prepare('UPDATE workspaces SET auto_loop_ready = 1 WHERE id = ?').run(workspaceId);
38
39
  return { ok: true };
39
40
  }
41
+ /**
42
+ * Update the workspace's agent-side short description (≤ 200 chars). Empty /
43
+ * whitespace-only input clears the field (stored as NULL). This writes the
44
+ * `agent_description` column — a live status line owned by the agent — and
45
+ * leaves the user-side `description` column untouched. The handler does NOT
46
+ * emit a WS event; that's the route/service layer's responsibility. Live UI
47
+ * refresh on agent-driven updates is therefore deferred until next read.
48
+ */
49
+ export function setWorkspaceAgentDescriptionHandler(db, workspaceId, args) {
50
+ const raw = typeof args?.description === 'string' ? args.description : '';
51
+ const trimmed = raw.trim();
52
+ if (trimmed.length > 200) {
53
+ return { ok: false, error: `Description must be 200 characters or fewer (got ${trimmed.length})` };
54
+ }
55
+ const stored = trimmed.length > 0 ? trimmed : null;
56
+ const result = db
57
+ .prepare('UPDATE workspaces SET agent_description = ?, updated_at = ? WHERE id = ?')
58
+ .run(stored, new Date().toISOString(), workspaceId);
59
+ if (result.changes === 0) {
60
+ return { ok: false, error: `Workspace '${workspaceId}' not found` };
61
+ }
62
+ return { ok: true, description: stored };
63
+ }
40
64
  /** Set a task's status to "done" and return the updated task. */
41
65
  export function markTaskDoneHandler(db, workspaceId, taskId) {
42
66
  const now = new Date().toISOString();
@@ -155,7 +179,7 @@ export function getSettingsHandler(settingsPath, projectPath) {
155
179
  /** Fetch workspace metadata from the database, computing the worktree path from project_path and working_branch. */
156
180
  export function getWorkspaceInfoHandler(db, workspaceId) {
157
181
  const row = db
158
- .prepare('SELECT id, name, project_path, source_branch, working_branch, worktree_path, status, notion_url, notion_page_id, model, dev_server_status, has_unread, auto_loop, auto_loop_ready, created_at, updated_at FROM workspaces WHERE id = ?')
182
+ .prepare('SELECT id, name, project_path, source_branch, working_branch, worktree_path, status, notion_url, notion_page_id, description, agent_description, model, dev_server_status, has_unread, auto_loop, auto_loop_ready, created_at, updated_at FROM workspaces WHERE id = ?')
159
183
  .get(workspaceId);
160
184
  if (!row) {
161
185
  throw new Error(`Workspace '${workspaceId}' not found`);
@@ -176,6 +200,8 @@ export function getWorkspaceInfoHandler(db, workspaceId) {
176
200
  model: row.model,
177
201
  notionUrl: row.notion_url,
178
202
  notionPageId: row.notion_page_id,
203
+ description: row.description ?? null,
204
+ agentDescription: row.agent_description ?? null,
179
205
  devServerStatus: row.dev_server_status,
180
206
  hasUnread: row.has_unread === 1,
181
207
  autoLoop: row.auto_loop === 1,
@@ -354,3 +380,17 @@ export function getSessionUsageHandler(db, workspaceId) {
354
380
  currentSession: { sessionId: currentSessionId, ...current },
355
381
  };
356
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, 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);
@@ -68,6 +80,22 @@ async function notifyAutoLoopReady() {
68
80
  console.error('[kobo-tasks-server] notify-autoloop-ready failed:', err);
69
81
  }
70
82
  }
83
+ /**
84
+ * Fire-and-forget POST that lands on `/agent-description/notify-updated`,
85
+ * which emits the `workspace:agent-description-updated` WS event so the
86
+ * sidebar fallback display + the workspace header italic line refresh live
87
+ * across every connected client. The handler already wrote the column to DB;
88
+ * this call is ONLY for the event emission.
89
+ */
90
+ async function notifyAgentDescriptionUpdated() {
91
+ try {
92
+ const url = `${backendUrl}/api/workspaces/${workspaceId}/agent-description/notify-updated`;
93
+ await fetch(url, { method: 'POST' });
94
+ }
95
+ catch (err) {
96
+ console.error('[kobo-tasks-server] notify-agent-description-updated failed:', err);
97
+ }
98
+ }
71
99
  /** Generic HTTP request to the Kobo backend, returning parsed JSON or null. */
72
100
  async function backendRequest(method, pathname, body) {
73
101
  const url = `${backendUrl}${pathname}`;
@@ -160,6 +188,67 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
160
188
  description: 'CALL EARLY in a session to confirm project path, working/source branch, worktree path, model, and notion link. Cheap read — useful when the user refers to "this workspace" or when you need the worktree path to locate files.',
161
189
  inputSchema: { type: 'object', properties: {}, required: [] },
162
190
  },
191
+ {
192
+ name: 'set_workspace_agent_description',
193
+ description: "Set or clear the workspace's agent-side description (≤ 200 chars). Pass an empty string to clear. The user sees this string under the workspace title in the sidebar (it takes precedence over the user-controlled `description` field). Plain text only. The current value is available via get_workspace_info as agentDescription. NOTE: there is a separate user-controlled `description` field — do NOT try to write it; it has no MCP tool.",
194
+ inputSchema: {
195
+ type: 'object',
196
+ properties: {
197
+ description: {
198
+ type: 'string',
199
+ description: 'Plain text, max 200 characters. Empty string clears the description.',
200
+ },
201
+ },
202
+ required: ['description'],
203
+ },
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
+ },
163
252
  {
164
253
  name: 'get_git_info',
165
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.',
@@ -294,13 +383,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
294
383
  },
295
384
  {
296
385
  name: 'schedule_wakeup',
297
- 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.',
298
387
  inputSchema: {
299
388
  type: 'object',
300
389
  properties: {
301
390
  delaySeconds: {
302
391
  type: 'number',
303
- 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).',
304
393
  },
305
394
  prompt: {
306
395
  type: 'string',
@@ -396,6 +485,66 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
396
485
  if (name === 'get_workspace_info') {
397
486
  return ok(getWorkspaceInfoHandler(db, workspaceId));
398
487
  }
488
+ if (name === 'set_workspace_agent_description') {
489
+ const description = a.description;
490
+ if (typeof description !== 'string')
491
+ return fail('description parameter is required');
492
+ const result = setWorkspaceAgentDescriptionHandler(db, workspaceId, { description });
493
+ if ('ok' in result && result.ok) {
494
+ void notifyAgentDescriptionUpdated();
495
+ }
496
+ return ok(result);
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
+ }
399
548
  if (name === 'start_dev_server') {
400
549
  const result = await backendRequest('POST', `/api/dev-server/${workspaceId}/start`);
401
550
  return ok(result);
@@ -194,6 +194,93 @@ export const migrations = [
194
194
  db.prepare('ALTER TABLE pending_wakeups ADD COLUMN agent_session_id TEXT').run();
195
195
  },
196
196
  },
197
+ {
198
+ version: 19,
199
+ name: 'add-pending-quota-backoffs',
200
+ migrate: (db) => {
201
+ // Per-workspace quota-backoff scheduler: tracks the moment a workspace
202
+ // becomes eligible to retry after hitting a quota limit. Mirrors the
203
+ // shape of pending_wakeups (one row per workspace, FK CASCADE) but holds
204
+ // distinct fields (resets_at, source, retry_count) tied to the quota
205
+ // detection layer (rate-limit info, usage API, fallback ladder).
206
+ db.prepare(`CREATE TABLE IF NOT EXISTS pending_quota_backoffs (
207
+ workspace_id TEXT PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE,
208
+ target_at TEXT NOT NULL,
209
+ resets_at TEXT,
210
+ source TEXT NOT NULL CHECK (source IN ('rate_limit_info', 'usage_api', 'fallback_ladder')),
211
+ retry_count INTEGER NOT NULL DEFAULT 0,
212
+ created_at TEXT NOT NULL
213
+ )`).run();
214
+ },
215
+ },
216
+ {
217
+ version: 20,
218
+ name: 'add-workspace-description',
219
+ migrate: (db) => {
220
+ // Free-form, optional summary of the mission shown in the sidebar and
221
+ // editable from the header. Nullable by design — pre-existing workspaces
222
+ // keep description = NULL until the user (or the brainstorm agent) sets
223
+ // one. Idempotent: skip the ALTER if the column is already present
224
+ // (covers re-runs and the case where a fresh-install ran initSchema first).
225
+ const cols = db.prepare('PRAGMA table_info(workspaces)').all();
226
+ if (cols.some((c) => c.name === 'description'))
227
+ return;
228
+ db.prepare('ALTER TABLE workspaces ADD COLUMN description TEXT').run();
229
+ },
230
+ },
231
+ {
232
+ version: 21,
233
+ name: 'add-workspace-agent-description',
234
+ migrate: (db) => {
235
+ // Agent-authored, optional summary of the mission written by the agent
236
+ // (typically at the end of brainstorm) via the renamed
237
+ // `set_workspace_agent_description` MCP tool. Distinct from the v20
238
+ // `description` column, which stays human-editable. Nullable, no default.
239
+ // Idempotent: skip the ALTER if the column already exists.
240
+ const cols = db.prepare('PRAGMA table_info(workspaces)').all();
241
+ if (cols.some((c) => c.name === 'agent_description'))
242
+ return;
243
+ db.prepare('ALTER TABLE workspaces ADD COLUMN agent_description TEXT').run();
244
+ },
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
+ },
197
284
  ];
198
285
  /** Current schema version — always equals the highest migration version. */
199
286
  export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
@@ -27,6 +27,8 @@ export function initSchema(db) {
27
27
  no_progress_streak INTEGER NOT NULL DEFAULT 0,
28
28
  permission_profile TEXT NOT NULL DEFAULT 'bypass',
29
29
  agent_permission_mode TEXT NOT NULL DEFAULT 'bypass',
30
+ description TEXT,
31
+ agent_description TEXT,
30
32
  created_at TEXT NOT NULL,
31
33
  updated_at TEXT NOT NULL
32
34
  );
@@ -71,6 +73,31 @@ export function initSchema(db) {
71
73
  agent_session_id TEXT
72
74
  );
73
75
 
76
+ CREATE TABLE IF NOT EXISTS pending_quota_backoffs (
77
+ workspace_id TEXT PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE,
78
+ target_at TEXT NOT NULL,
79
+ resets_at TEXT,
80
+ source TEXT NOT NULL CHECK (source IN ('rate_limit_info', 'usage_api', 'fallback_ladder')),
81
+ retry_count INTEGER NOT NULL DEFAULT 0,
82
+ created_at TEXT NOT NULL
83
+ );
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
+
74
101
  CREATE TABLE IF NOT EXISTS usage_snapshots (
75
102
  provider_id TEXT PRIMARY KEY,
76
103
  status TEXT NOT NULL,
@@ -20,12 +20,14 @@ import settingsRouter from './routes/settings.js';
20
20
  import templatesRouter from './routes/templates.js';
21
21
  import usageRoutes from './routes/usage.js';
22
22
  import workspacesRouter from './routes/workspaces.js';
23
- import { getAvailableSkills, reconcileOrphanSessions, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopWatchdog, } from './services/agent/orchestrator.js';
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';
30
+ import * as quotaBackoffService from './services/quota-backoff-service.js';
29
31
  import { createTerminal, destroyAllTerminals, getTerminal } from './services/terminal-service.js';
30
32
  import { startUsagePoller, stopUsagePoller } from './services/usage/index.js';
31
33
  import * as wakeupService from './services/wakeup-service.js';
@@ -54,6 +56,12 @@ reconcileOrphanSessions();
54
56
  startWatchdog();
55
57
  wakeupService.rehydrate();
56
58
  autoLoopService.rehydrate();
59
+ // Restore in-memory retry counts BEFORE re-arming the persisted backoff timers,
60
+ // otherwise the next arm() after restart would compute the next ladder rung
61
+ // from retryCount=0 and undo the progression.
62
+ restoreRetryCountsFromDb();
63
+ quotaBackoffService.restoreOnBoot((workspaceId) => autoLoopService.onQuotaBackoffExpired(workspaceId));
64
+ cronService.restoreOnBoot();
57
65
  startPrWatcher();
58
66
  startUsagePoller();
59
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
  });