@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.
- package/README.md +20 -8
- package/dist/mcp-server/kobo-tasks-handlers.js +26 -1
- package/dist/mcp-server/kobo-tasks-server.js +41 -1
- package/dist/server/db/migrations.js +49 -0
- package/dist/server/db/schema.js +11 -0
- package/dist/server/index.js +7 -1
- package/dist/server/routes/workspaces.js +83 -5
- package/dist/server/services/agent/engines/claude-code/engine.js +4 -1
- package/dist/server/services/agent/engines/claude-code/event-mapper.js +6 -2
- package/dist/server/services/agent/orchestrator.js +72 -71
- package/dist/server/services/auto-loop-service.js +8 -0
- package/dist/server/services/quota-backoff-service.js +127 -0
- package/dist/server/services/settings-service.js +3 -1
- package/dist/server/services/workspace-service.js +80 -0
- package/dist/server/utils/git-ops.js +48 -9
- package/package.json +1 -1
- package/src/client/dist/spa/assets/{ActivityFeed-CroojlsI.css → ActivityFeed-DVBfmJWJ.css} +1 -1
- package/src/client/dist/spa/assets/{ActivityFeed-CKSqMR2v.js → ActivityFeed-oW9PgZ8E.js} +2 -2
- package/src/client/dist/spa/assets/AutoLoopChip-Y53cnGfZ.js +1 -0
- package/src/client/dist/spa/assets/{CreatePage-7cP4h19f.js → CreatePage-CuD7sMR7.js} +1 -1
- package/src/client/dist/spa/assets/{DiffViewer-CdamEwIg.js → DiffViewer-rc3tE9fq.js} +3 -3
- package/src/client/dist/spa/assets/{HealthPage-m4z-x5bo.js → HealthPage-Dz0yGGMB.js} +1 -1
- package/src/client/dist/spa/assets/{MainLayout-CQBqYFNx.js → MainLayout-B9i06p7n.js} +17 -17
- package/src/client/dist/spa/assets/{MainLayout-DKurmqtk.css → MainLayout-DDa3rGKA.css} +1 -1
- package/src/client/dist/spa/assets/{SearchPage-DCRSQycR.js → SearchPage-DdX7JZCD.js} +1 -1
- package/src/client/dist/spa/assets/{SettingsPage-DStBGwIj.js → SettingsPage-Dnj1CWc3.js} +1 -1
- package/src/client/dist/spa/assets/{WorkspacePage-eymEd4kx.css → WorkspacePage-CCtIrBiR.css} +1 -1
- package/src/client/dist/spa/assets/WorkspacePage-DHp20nl-.js +4 -0
- package/src/client/dist/spa/assets/{cssMode-o7NS-Oil.js → cssMode-DSB5jkRt.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-CNo9KwlJ.js → editor.api-Bcw50eFD.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-UyvgnhP6.js → editor.main-D9piVGaH.js} +3 -3
- package/src/client/dist/spa/assets/{expand-template-DqZgks9E.js → expand-template-BIPuNAYV.js} +1 -1
- package/src/client/dist/spa/assets/{freemarker2-BKWtNRQ9.js → freemarker2-CVh_Zh8H.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-BUhKrn3k.js → handlebars-CpCgELpu.js} +1 -1
- package/src/client/dist/spa/assets/{html-CrcvRgdj.js → html-ikWDpvWk.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-Djjp-0pZ.js → htmlMode-C9TTCKih.js} +1 -1
- package/src/client/dist/spa/assets/i18n-DZCb8dnb.js +1 -0
- package/src/client/dist/spa/assets/index-DuK38XN5.js +2 -0
- package/src/client/dist/spa/assets/{javascript-DN_zCJwt.js → javascript-C4OlkNeA.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-B7uIpwZ9.js → jsonMode-BiD34_86.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-f3BGSOBM.js → liquid-Dty0Ui2c.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-jpEqsFXp.js → mdx-yiUjOVv6.js} +1 -1
- package/src/client/dist/spa/assets/{models-Bj-hfPO2.js → models-BDkLiht9.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-D-UK6jlz.js → monaco.contribution-Bz9yFPWR.js} +2 -2
- package/src/client/dist/spa/assets/{purify.es-DyEEb_DH.js → purify.es-BIY760fF.js} +1 -1
- package/src/client/dist/spa/assets/{python-CoiTKs0q.js → python-7SPSWQoD.js} +1 -1
- package/src/client/dist/spa/assets/{razor-BubwMw_m.js → razor-eagZawXK.js} +1 -1
- package/src/client/dist/spa/assets/{render-chat-markdown-DwKtHD8J.js → render-chat-markdown-TvAqpDih.js} +1 -1
- package/src/client/dist/spa/assets/{tsMode-k_tAkDr_.js → tsMode-CLYG2xeJ.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-DQQR6Y6R.js → typescript-CzOXM8yS.js} +1 -1
- package/src/client/dist/spa/assets/{xml-CaSyI8p6.js → xml-2_0_6RAX.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-BYsGcXIZ.js → yaml-CtpgNyXs.js} +1 -1
- package/src/client/dist/spa/index.html +1 -1
- package/src/mcp-server/kobo-tasks-handlers.ts +35 -1
- package/src/mcp-server/kobo-tasks-server.ts +42 -0
- package/src/client/dist/spa/assets/WorkspacePage-BstBxgN8.js +0 -4
- package/src/client/dist/spa/assets/i18n-DD341qPX.js +0 -1
- package/src/client/dist/spa/assets/index-DR1y9t94.js +0 -2
- /package/src/client/dist/spa/assets/{QPage-ChUKoaKe.js → QPage-DFi3K093.js} +0 -0
- /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
|
-
- **
|
|
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 (
|
|
105
|
-
npm run test:client # client vitest suite (Pinia stores + pure utils,
|
|
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
|
|
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
|
-
-
|
|
266
|
-
- `
|
|
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;
|
package/dist/server/db/schema.js
CHANGED
|
@@ -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,
|
package/dist/server/index.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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) {
|