@loicngr/kobo 1.7.3 → 1.7.5

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 +20 -8
  2. package/dist/mcp-server/kobo-tasks-handlers.js +26 -1
  3. package/dist/mcp-server/kobo-tasks-server.js +41 -1
  4. package/dist/server/db/migrations.js +49 -0
  5. package/dist/server/db/schema.js +11 -0
  6. package/dist/server/index.js +7 -1
  7. package/dist/server/routes/workspaces.js +83 -5
  8. package/dist/server/services/agent/engines/claude-code/engine.js +4 -1
  9. package/dist/server/services/agent/engines/claude-code/event-mapper.js +6 -2
  10. package/dist/server/services/agent/orchestrator.js +72 -71
  11. package/dist/server/services/auto-loop-service.js +8 -0
  12. package/dist/server/services/quota-backoff-service.js +127 -0
  13. package/dist/server/services/settings-service.js +3 -1
  14. package/dist/server/services/workspace-service.js +80 -0
  15. package/dist/server/utils/git-ops.js +48 -9
  16. package/package.json +1 -1
  17. package/src/client/dist/spa/assets/{ActivityFeed-CroojlsI.css → ActivityFeed-DVBfmJWJ.css} +1 -1
  18. package/src/client/dist/spa/assets/{ActivityFeed-CKSqMR2v.js → ActivityFeed-oW9PgZ8E.js} +2 -2
  19. package/src/client/dist/spa/assets/AutoLoopChip-Y53cnGfZ.js +1 -0
  20. package/src/client/dist/spa/assets/{CreatePage-7cP4h19f.js → CreatePage-CuD7sMR7.js} +1 -1
  21. package/src/client/dist/spa/assets/{DiffViewer-CdamEwIg.js → DiffViewer-rc3tE9fq.js} +3 -3
  22. package/src/client/dist/spa/assets/{HealthPage-m4z-x5bo.js → HealthPage-Dz0yGGMB.js} +1 -1
  23. package/src/client/dist/spa/assets/{MainLayout-CQBqYFNx.js → MainLayout-B9i06p7n.js} +17 -17
  24. package/src/client/dist/spa/assets/{MainLayout-DKurmqtk.css → MainLayout-DDa3rGKA.css} +1 -1
  25. package/src/client/dist/spa/assets/{SearchPage-DCRSQycR.js → SearchPage-DdX7JZCD.js} +1 -1
  26. package/src/client/dist/spa/assets/{SettingsPage-DStBGwIj.js → SettingsPage-Dnj1CWc3.js} +1 -1
  27. package/src/client/dist/spa/assets/{WorkspacePage-eymEd4kx.css → WorkspacePage-CCtIrBiR.css} +1 -1
  28. package/src/client/dist/spa/assets/WorkspacePage-DHp20nl-.js +4 -0
  29. package/src/client/dist/spa/assets/{cssMode-o7NS-Oil.js → cssMode-DSB5jkRt.js} +1 -1
  30. package/src/client/dist/spa/assets/{editor.api-CNo9KwlJ.js → editor.api-Bcw50eFD.js} +1 -1
  31. package/src/client/dist/spa/assets/{editor.main-UyvgnhP6.js → editor.main-D9piVGaH.js} +3 -3
  32. package/src/client/dist/spa/assets/{expand-template-DqZgks9E.js → expand-template-BIPuNAYV.js} +1 -1
  33. package/src/client/dist/spa/assets/{freemarker2-BKWtNRQ9.js → freemarker2-CVh_Zh8H.js} +1 -1
  34. package/src/client/dist/spa/assets/{handlebars-BUhKrn3k.js → handlebars-CpCgELpu.js} +1 -1
  35. package/src/client/dist/spa/assets/{html-CrcvRgdj.js → html-ikWDpvWk.js} +1 -1
  36. package/src/client/dist/spa/assets/{htmlMode-Djjp-0pZ.js → htmlMode-C9TTCKih.js} +1 -1
  37. package/src/client/dist/spa/assets/i18n-DZCb8dnb.js +1 -0
  38. package/src/client/dist/spa/assets/index-DuK38XN5.js +2 -0
  39. package/src/client/dist/spa/assets/{javascript-DN_zCJwt.js → javascript-C4OlkNeA.js} +1 -1
  40. package/src/client/dist/spa/assets/{jsonMode-B7uIpwZ9.js → jsonMode-BiD34_86.js} +1 -1
  41. package/src/client/dist/spa/assets/{liquid-f3BGSOBM.js → liquid-Dty0Ui2c.js} +1 -1
  42. package/src/client/dist/spa/assets/{mdx-jpEqsFXp.js → mdx-yiUjOVv6.js} +1 -1
  43. package/src/client/dist/spa/assets/{models-Bj-hfPO2.js → models-BDkLiht9.js} +1 -1
  44. package/src/client/dist/spa/assets/{monaco.contribution-D-UK6jlz.js → monaco.contribution-Bz9yFPWR.js} +2 -2
  45. package/src/client/dist/spa/assets/{purify.es-DyEEb_DH.js → purify.es-BIY760fF.js} +1 -1
  46. package/src/client/dist/spa/assets/{python-CoiTKs0q.js → python-7SPSWQoD.js} +1 -1
  47. package/src/client/dist/spa/assets/{razor-BubwMw_m.js → razor-eagZawXK.js} +1 -1
  48. package/src/client/dist/spa/assets/{render-chat-markdown-DwKtHD8J.js → render-chat-markdown-TvAqpDih.js} +1 -1
  49. package/src/client/dist/spa/assets/{tsMode-k_tAkDr_.js → tsMode-CLYG2xeJ.js} +1 -1
  50. package/src/client/dist/spa/assets/{typescript-DQQR6Y6R.js → typescript-CzOXM8yS.js} +1 -1
  51. package/src/client/dist/spa/assets/{xml-CaSyI8p6.js → xml-2_0_6RAX.js} +1 -1
  52. package/src/client/dist/spa/assets/{yaml-BYsGcXIZ.js → yaml-CtpgNyXs.js} +1 -1
  53. package/src/client/dist/spa/index.html +1 -1
  54. package/src/mcp-server/kobo-tasks-handlers.ts +35 -1
  55. package/src/mcp-server/kobo-tasks-server.ts +42 -0
  56. package/src/client/dist/spa/assets/WorkspacePage-BstBxgN8.js +0 -4
  57. package/src/client/dist/spa/assets/i18n-DD341qPX.js +0 -1
  58. package/src/client/dist/spa/assets/index-DR1y9t94.js +0 -2
  59. /package/src/client/dist/spa/assets/{QPage-ChUKoaKe.js → QPage-DFi3K093.js} +0 -0
  60. /package/src/client/dist/spa/assets/{formatters-BD0_hovB.js → formatters-DCAQ6ANJ.js} +0 -0
package/README.md CHANGED
@@ -31,9 +31,10 @@ 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
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
37
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
39
 
39
40
  ## Tech stack
@@ -101,8 +102,8 @@ npm start # runs the compiled server
101
102
  ### Test & lint
102
103
 
103
104
  ```bash
104
- npm test # backend vitest suite (950+ tests)
105
- npm run test:client # client vitest suite (Pinia stores + pure utils, 85+ tests)
105
+ npm test # backend vitest suite (1200+ tests)
106
+ npm run test:client # client vitest suite (Pinia stores + pure utils, 230+ tests)
106
107
  npm run test:all # backend + client suites
107
108
  npm run lint # biome check (lint + format verification)
108
109
  npm run lint:fix # biome check with safe auto-fixes
@@ -229,6 +230,7 @@ src/
229
230
  │ │ │ └── engines/claude-code/ # spawn + NDJSON stream-parser + args-builder + mcp-config + capabilities
230
231
  │ │ ├── content-migration-service.ts # legacy ws_events → normalised AgentEvent rows, with DB backup
231
232
  │ │ ├── usage/ # pluggable quota provider, 60s poller, persistence, WS broadcast
233
+ │ │ ├── quota-backoff-service.ts # persisted Claude rate-limit backoff timers (re-armed on restart)
232
234
  │ │ └── … # workspace, dev-server, ws, notion, sentry, settings, pr-template
233
235
  │ ├── routes/ # Hono handlers (workspaces, engines, migration, templates, usage, …)
234
236
  │ └── utils/ # git-ops, process-tracker, paths
@@ -252,18 +254,28 @@ See [`AGENTS.md`](./AGENTS.md) for a deeper dive into conventions, data model, W
252
254
 
253
255
  | Table | Purpose |
254
256
  |---|---|
255
- | `workspaces` | the unit of work — branch, `worktree_path`, status, model, engine, `archived_at`, `favorited_at`, `tags`, Notion link, … |
257
+ | `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
258
  | `tasks` | workspace sub-items — tasks and acceptance criteria |
257
259
  | `agent_sessions` | agent runs — pid, `engine_session_id`, lifecycle |
258
260
  | `ws_events` | persisted WebSocket events (chat history, `agent:event` stream, user messages) for replay on reconnect |
259
261
  | `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
+ | `pending_wakeups` | one row per scheduled wakeup, target time and resume context, re-armed on server restart |
263
+ | `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 |
260
264
 
261
265
  ## MCP server
262
266
 
263
- Each workspace spawns its own `kobo-tasks` MCP server as a child process of the Claude Code agent. It exposes two tools:
267
+ 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
268
 
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
269
+ - **Tasks**`list_tasks`, `create_task`, `update_task`, `delete_task`, `mark_task_done`
270
+ - **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`
271
+ - **Auto-loop** — `mark_auto_loop_ready` (flip the grooming gate when the brainstorm step is done)
272
+ - **Git** — `get_git_info` (branch, commit count, dirty state)
273
+ - **Dev server** — `get_dev_server_status`, `start_dev_server`, `stop_dev_server`, `get_dev_server_logs`
274
+ - **External sources** — `get_notion_ticket`, `get_settings`
275
+ - **Documents & search** — `list_documents`, `read_document`, `log_thought`, `search_codebase`, `list_workspace_images`
276
+ - **Scheduling & telemetry** — `schedule_wakeup`, `cancel_wakeup`, `get_session_usage`
277
+
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.
267
279
 
268
280
  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
281
 
@@ -37,6 +37,29 @@ export function markAutoLoopReadyHandler(db, workspaceId) {
37
37
  db.prepare('UPDATE workspaces SET auto_loop_ready = 1 WHERE id = ?').run(workspaceId);
38
38
  return { ok: true };
39
39
  }
40
+ /**
41
+ * Update the workspace's agent-side short description (≤ 200 chars). Empty /
42
+ * whitespace-only input clears the field (stored as NULL). This writes the
43
+ * `agent_description` column — a live status line owned by the agent — and
44
+ * leaves the user-side `description` column untouched. The handler does NOT
45
+ * emit a WS event; that's the route/service layer's responsibility. Live UI
46
+ * refresh on agent-driven updates is therefore deferred until next read.
47
+ */
48
+ export function setWorkspaceAgentDescriptionHandler(db, workspaceId, args) {
49
+ const raw = typeof args?.description === 'string' ? args.description : '';
50
+ const trimmed = raw.trim();
51
+ if (trimmed.length > 200) {
52
+ return { ok: false, error: `Description must be 200 characters or fewer (got ${trimmed.length})` };
53
+ }
54
+ const stored = trimmed.length > 0 ? trimmed : null;
55
+ const result = db
56
+ .prepare('UPDATE workspaces SET agent_description = ?, updated_at = ? WHERE id = ?')
57
+ .run(stored, new Date().toISOString(), workspaceId);
58
+ if (result.changes === 0) {
59
+ return { ok: false, error: `Workspace '${workspaceId}' not found` };
60
+ }
61
+ return { ok: true, description: stored };
62
+ }
40
63
  /** Set a task's status to "done" and return the updated task. */
41
64
  export function markTaskDoneHandler(db, workspaceId, taskId) {
42
65
  const now = new Date().toISOString();
@@ -155,7 +178,7 @@ export function getSettingsHandler(settingsPath, projectPath) {
155
178
  /** Fetch workspace metadata from the database, computing the worktree path from project_path and working_branch. */
156
179
  export function getWorkspaceInfoHandler(db, workspaceId) {
157
180
  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 = ?')
181
+ .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
182
  .get(workspaceId);
160
183
  if (!row) {
161
184
  throw new Error(`Workspace '${workspaceId}' not found`);
@@ -176,6 +199,8 @@ export function getWorkspaceInfoHandler(db, workspaceId) {
176
199
  model: row.model,
177
200
  notionUrl: row.notion_url,
178
201
  notionPageId: row.notion_page_id,
202
+ description: row.description ?? null,
203
+ agentDescription: row.agent_description ?? null,
179
204
  devServerStatus: row.dev_server_status,
180
205
  hasUnread: row.has_unread === 1,
181
206
  autoLoop: row.auto_loop === 1,
@@ -5,7 +5,7 @@ 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
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';
8
+ import { createTaskHandler, deleteTaskHandler, getDevServerStatusHandler, getSessionUsageHandler, getSettingsHandler, getWorkspaceInfoHandler, listDocumentsHandler, listTasksHandler, listWorkspaceImagesHandler, logThoughtHandler, markAutoLoopReadyHandler, markTaskDoneHandler, readDocumentHandler, setWorkspaceAgentDescriptionHandler, updateTaskHandler, } from './kobo-tasks-handlers.js';
9
9
  const workspaceId = process.env.KOBO_WORKSPACE_ID;
10
10
  const dbPath = process.env.KOBO_DB_PATH;
11
11
  const settingsPath = process.env.KOBO_SETTINGS_PATH;
@@ -68,6 +68,22 @@ async function notifyAutoLoopReady() {
68
68
  console.error('[kobo-tasks-server] notify-autoloop-ready failed:', err);
69
69
  }
70
70
  }
71
+ /**
72
+ * Fire-and-forget POST that lands on `/agent-description/notify-updated`,
73
+ * which emits the `workspace:agent-description-updated` WS event so the
74
+ * sidebar fallback display + the workspace header italic line refresh live
75
+ * across every connected client. The handler already wrote the column to DB;
76
+ * this call is ONLY for the event emission.
77
+ */
78
+ async function notifyAgentDescriptionUpdated() {
79
+ try {
80
+ const url = `${backendUrl}/api/workspaces/${workspaceId}/agent-description/notify-updated`;
81
+ await fetch(url, { method: 'POST' });
82
+ }
83
+ catch (err) {
84
+ console.error('[kobo-tasks-server] notify-agent-description-updated failed:', err);
85
+ }
86
+ }
71
87
  /** Generic HTTP request to the Kobo backend, returning parsed JSON or null. */
72
88
  async function backendRequest(method, pathname, body) {
73
89
  const url = `${backendUrl}${pathname}`;
@@ -160,6 +176,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
160
176
  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
177
  inputSchema: { type: 'object', properties: {}, required: [] },
162
178
  },
179
+ {
180
+ name: 'set_workspace_agent_description',
181
+ 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.",
182
+ inputSchema: {
183
+ type: 'object',
184
+ properties: {
185
+ description: {
186
+ type: 'string',
187
+ description: 'Plain text, max 200 characters. Empty string clears the description.',
188
+ },
189
+ },
190
+ required: ['description'],
191
+ },
192
+ },
163
193
  {
164
194
  name: 'get_git_info',
165
195
  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.',
@@ -396,6 +426,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
396
426
  if (name === 'get_workspace_info') {
397
427
  return ok(getWorkspaceInfoHandler(db, workspaceId));
398
428
  }
429
+ if (name === 'set_workspace_agent_description') {
430
+ const description = a.description;
431
+ if (typeof description !== 'string')
432
+ return fail('description parameter is required');
433
+ const result = setWorkspaceAgentDescriptionHandler(db, workspaceId, { description });
434
+ if ('ok' in result && result.ok) {
435
+ void notifyAgentDescriptionUpdated();
436
+ }
437
+ return ok(result);
438
+ }
399
439
  if (name === 'start_dev_server') {
400
440
  const result = await backendRequest('POST', `/api/dev-server/${workspaceId}/start`);
401
441
  return ok(result);
@@ -194,6 +194,55 @@ 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
+ },
197
246
  ];
198
247
  /** Current schema version — always equals the highest migration version. */
199
248
  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,15 @@ 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
+
74
85
  CREATE TABLE IF NOT EXISTS usage_snapshots (
75
86
  provider_id TEXT PRIMARY KEY,
76
87
  status TEXT NOT NULL,
@@ -20,12 +20,13 @@ 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
26
  import { createDailyDbBackupIfNeeded } from './services/db-backup-service.js';
27
27
  import { startDevServer, stopDevServer } from './services/dev-server-service.js';
28
28
  import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js';
29
+ import * as quotaBackoffService from './services/quota-backoff-service.js';
29
30
  import { createTerminal, destroyAllTerminals, getTerminal } from './services/terminal-service.js';
30
31
  import { startUsagePoller, stopUsagePoller } from './services/usage/index.js';
31
32
  import * as wakeupService from './services/wakeup-service.js';
@@ -54,6 +55,11 @@ reconcileOrphanSessions();
54
55
  startWatchdog();
55
56
  wakeupService.rehydrate();
56
57
  autoLoopService.rehydrate();
58
+ // Restore in-memory retry counts BEFORE re-arming the persisted backoff timers,
59
+ // otherwise the next arm() after restart would compute the next ladder rung
60
+ // from retryCount=0 and undo the progression.
61
+ restoreRetryCountsFromDb();
62
+ quotaBackoffService.restoreOnBoot((workspaceId) => autoLoopService.onQuotaBackoffExpired(workspaceId));
57
63
  startPrWatcher();
58
64
  startUsagePoller();
59
65
  // Create Hono app
@@ -15,6 +15,7 @@ import { DEFAULT_NOTION_INITIAL_PROMPT, DEFAULT_SENTRY_INITIAL_PROMPT, renderNot
15
15
  import * as notionService from '../services/notion-service.js';
16
16
  import { renderPrTemplate } from '../services/pr-template-service.js';
17
17
  import { getAllPrStates } from '../services/pr-watcher-service.js';
18
+ import * as quotaBackoffService from '../services/quota-backoff-service.js';
18
19
  import { DEFAULT_REVIEW_PROMPT_TEMPLATE, renderReviewTemplate } from '../services/review-template-service.js';
19
20
  import * as sentryService from '../services/sentry-service.js';
20
21
  import * as settingsService from '../services/settings-service.js';
@@ -585,6 +586,7 @@ app.post('/', migrationGuard, async (c) => {
585
586
  if (body.notionUrl) {
586
587
  brainstormPrompt += `- get_notion_ticket() — retrieve the Notion ticket info (URL, ticket ID, extracted content)\n`;
587
588
  }
589
+ 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`;
588
590
  if (effectiveSettings.gitConventions) {
589
591
  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`;
590
592
  }
@@ -713,8 +715,16 @@ app.get('/pr-states', (c) => {
713
715
  app.get('/auto-loop-states', (c) => {
714
716
  try {
715
717
  const db = getDb();
718
+ // Task counts via LEFT JOIN so workspaces without tasks still appear with 0/0.
719
+ // Drives the sidebar AutoLoopChip badge (X / Y) for non-focused workspaces.
716
720
  const rows = db
717
- .prepare('SELECT id, auto_loop, auto_loop_ready, no_progress_streak FROM workspaces WHERE archived_at IS NULL')
721
+ .prepare(`SELECT w.id, w.auto_loop, w.auto_loop_ready, w.no_progress_streak,
722
+ COUNT(t.id) AS tasks_total,
723
+ SUM(CASE WHEN t.status = 'done' THEN 1 ELSE 0 END) AS tasks_done
724
+ FROM workspaces w
725
+ LEFT JOIN tasks t ON t.workspace_id = w.id
726
+ WHERE w.archived_at IS NULL
727
+ GROUP BY w.id`)
718
728
  .all();
719
729
  const out = {};
720
730
  for (const r of rows) {
@@ -722,6 +732,8 @@ app.get('/auto-loop-states', (c) => {
722
732
  auto_loop: r.auto_loop === 1,
723
733
  auto_loop_ready: r.auto_loop_ready === 1,
724
734
  no_progress_streak: r.no_progress_streak,
735
+ tasks_done: r.tasks_done ?? 0,
736
+ tasks_total: r.tasks_total ?? 0,
725
737
  };
726
738
  }
727
739
  return c.json(out);
@@ -808,6 +820,37 @@ app.delete('/:id/pending-wakeup', (c) => {
808
820
  return c.json({ error: message }, 500);
809
821
  }
810
822
  });
823
+ // GET /api/workspaces/:id/quota-backoff — returns the pending quota backoff or null.
824
+ app.get('/:id/quota-backoff', (c) => {
825
+ try {
826
+ const id = c.req.param('id');
827
+ const ws = workspaceService.getWorkspace(id);
828
+ if (!ws)
829
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
830
+ const pending = quotaBackoffService.getPending(id);
831
+ c.header('Cache-Control', 'no-store');
832
+ return c.json(pending);
833
+ }
834
+ catch (err) {
835
+ const message = err instanceof Error ? err.message : String(err);
836
+ return c.json({ error: message }, 500);
837
+ }
838
+ });
839
+ // DELETE /api/workspaces/:id/quota-backoff — user-initiated cancel ("×" button).
840
+ app.delete('/:id/quota-backoff', (c) => {
841
+ try {
842
+ const id = c.req.param('id');
843
+ const ws = workspaceService.getWorkspace(id);
844
+ if (!ws)
845
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
846
+ quotaBackoffService.cancel(id, 'user');
847
+ return new Response(null, { status: 204 });
848
+ }
849
+ catch (err) {
850
+ const message = err instanceof Error ? err.message : String(err);
851
+ return c.json({ error: message }, 500);
852
+ }
853
+ });
811
854
  // POST /api/workspaces/:id/pending-wakeup — agent-initiated schedule via the
812
855
  // `kobo__schedule_wakeup` MCP tool. Replaces any existing pending wakeup.
813
856
  app.post('/:id/pending-wakeup', async (c) => {
@@ -1057,6 +1100,28 @@ app.post('/:id/tasks/notify-updated', (c) => {
1057
1100
  return c.json({ error: message }, 500);
1058
1101
  }
1059
1102
  });
1103
+ // POST /api/workspaces/:id/agent-description/notify-updated — broadcast
1104
+ // workspace:agent-description-updated after the MCP set_workspace_agent_description
1105
+ // handler wrote the column directly. Mirrors the notify-done / notify-updated
1106
+ // pattern: the route doesn't re-write, just emits the WS event so the sidebar
1107
+ // chip + workspace header italic line refresh in real time.
1108
+ app.post('/:id/agent-description/notify-updated', (c) => {
1109
+ try {
1110
+ const id = c.req.param('id');
1111
+ const workspace = workspaceService.getWorkspace(id);
1112
+ if (!workspace) {
1113
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
1114
+ }
1115
+ wsService.emitEphemeral(id, 'workspace:agent-description-updated', {
1116
+ agentDescription: workspace.agentDescription,
1117
+ });
1118
+ return new Response(null, { status: 204 });
1119
+ }
1120
+ catch (err) {
1121
+ const message = err instanceof Error ? err.message : String(err);
1122
+ return c.json({ error: message }, 500);
1123
+ }
1124
+ });
1060
1125
  // POST /api/workspaces/:id/tasks/:taskId/notify-done — broadcast task:updated event
1061
1126
  app.post('/:id/tasks/:taskId/notify-done', (c) => {
1062
1127
  try {
@@ -1270,6 +1335,10 @@ app.patch('/:id', migrationGuard, async (c) => {
1270
1335
  if (!workspace) {
1271
1336
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1272
1337
  }
1338
+ // agent_description is exclusively writable via the MCP tool, never via the API.
1339
+ if ('agent_description' in body) {
1340
+ return c.json({ error: 'agent_description must be set via the agent MCP tool, not via the API' }, 400);
1341
+ }
1273
1342
  let updated = workspace;
1274
1343
  if (body.model !== undefined) {
1275
1344
  updated = workspaceService.updateWorkspaceModel(id, body.model);
@@ -1289,12 +1358,20 @@ app.patch('/:id', migrationGuard, async (c) => {
1289
1358
  if (body.name !== undefined) {
1290
1359
  updated = workspaceService.updateWorkspaceName(id, body.name);
1291
1360
  }
1361
+ if ('description' in body) {
1362
+ const desc = body.description;
1363
+ if (desc !== null && typeof desc !== 'string') {
1364
+ return c.json({ error: 'description must be a string or null' }, 400);
1365
+ }
1366
+ updated = workspaceService.updateWorkspaceDescription(id, desc);
1367
+ }
1292
1368
  if (!body.status &&
1293
1369
  body.model === undefined &&
1294
1370
  body.reasoningEffort === undefined &&
1295
1371
  body.agentPermissionMode === undefined &&
1296
- body.name === undefined) {
1297
- return c.json({ error: 'Missing field: status, model, reasoningEffort, agentPermissionMode, or name' }, 400);
1372
+ body.name === undefined &&
1373
+ !('description' in body)) {
1374
+ return c.json({ error: 'Missing field: status, model, reasoningEffort, agentPermissionMode, name, or description' }, 400);
1298
1375
  }
1299
1376
  return c.json(updated);
1300
1377
  }
@@ -1305,7 +1382,8 @@ app.patch('/:id', migrationGuard, async (c) => {
1305
1382
  }
1306
1383
  if (message.includes('Invalid status transition') ||
1307
1384
  message.includes('name cannot be empty') ||
1308
- message.includes('name cannot exceed')) {
1385
+ message.includes('name cannot exceed') ||
1386
+ message.includes('Description must be')) {
1309
1387
  return c.json({ error: message }, 400);
1310
1388
  }
1311
1389
  return c.json({ error: message }, 500);
@@ -1619,7 +1697,7 @@ app.get('/:id/git-stats', async (c) => {
1619
1697
  const behindCount = gitOps.getCommitsBehind(worktreePath, workspace.sourceBranch, workspace.workingBranch);
1620
1698
  const diffStats = gitOps.getStructuredDiffStatsBetween(worktreePath, workspace.sourceBranch, workspace.workingBranch);
1621
1699
  const pr = await gitOps.getPrStatusAsync(workspace.projectPath, workspace.workingBranch);
1622
- const unpushedCount = await gitOps.getUnpushedCountAsync(worktreePath);
1700
+ const unpushedCount = await gitOps.getUnpushedCountAsync(worktreePath, workspace.workingBranch);
1623
1701
  const workingTree = gitOps.getWorkingTreeStatus(worktreePath);
1624
1702
  return c.json({
1625
1703
  commitCount,
@@ -101,7 +101,10 @@ export function createClaudeCodeEngine() {
101
101
  if (lower.includes('rate limit exceeded') ||
102
102
  lower.includes('rate_limit_exceeded') ||
103
103
  (lower.includes('429') && lower.includes('rate')) ||
104
- lower.includes('quota exceeded')) {
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')) {
105
108
  onEvent({ kind: 'error', category: 'quota', message: data });
106
109
  }
107
110
  else if (lower.includes('no conversation found with session id')) {
@@ -56,7 +56,7 @@ export function createMapperState() {
56
56
  return { sessionStartedEmitted: false, openMessages: new Map(), sawErrorResult: false };
57
57
  }
58
58
  /** Known SDK `result` subtypes that indicate the run failed. */
59
- const KNOWN_ERROR_RESULT_SUBTYPES = new Set(['error_max_turns', 'error_during_execution']);
59
+ export const KNOWN_ERROR_RESULT_SUBTYPES = new Set(['error_max_turns', 'error_during_execution']);
60
60
  function isErrorResultSubtype(subtype) {
61
61
  if (!subtype)
62
62
  return false;
@@ -211,8 +211,12 @@ export function mapSdkMessage(msg, state) {
211
211
  if (isErrorResultSubtype(subtype)) {
212
212
  state.sawErrorResult = true;
213
213
  const detail = (typeof parsed.error === 'string' && parsed.error) || (typeof parsed.result === 'string' && parsed.result) || '';
214
+ // "Claude AI usage limit reached" is Anthropic's 5h/4h cap surface — added
215
+ // to the regex so the orchestrator transitions the workspace to `quota`
216
+ // (not `error`) and the auto-loop backoff path engages.
217
+ const isQuota = /out of extra usage|rate limit|usage limit/i.test(detail);
214
218
  const message = detail ? `Agent run failed (${subtype}): ${detail}` : `Agent run failed (${subtype})`;
215
- events.push({ kind: 'error', category: 'other', message });
219
+ events.push({ kind: 'error', category: isQuota ? 'quota' : 'other', message });
216
220
  }
217
221
  const usage = parsed.usage;
218
222
  if (usage) {