@loicngr/kobo 1.6.8 → 1.6.9
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/AGENTS.md +4 -1
- package/README.md +3 -0
- package/dist/mcp-server/kobo-tasks-handlers.js +19 -1
- package/dist/mcp-server/kobo-tasks-server.js +27 -1
- package/dist/server/db/migrations.js +11 -0
- package/dist/server/db/schema.js +3 -0
- package/dist/server/index.js +2 -0
- package/dist/server/routes/workspaces.js +153 -18
- package/dist/server/services/agent/engines/claude-code/engine.js +5 -0
- package/dist/server/services/agent/engines/claude-code/stream-parser.js +162 -0
- package/dist/server/services/agent/orchestrator.js +167 -18
- package/dist/server/services/auto-loop-service.js +311 -0
- package/dist/server/services/workspace-service.js +22 -0
- package/dist/shared/auto-loop-prompts.js +28 -0
- package/package.json +1 -1
- package/src/client/dist/spa/assets/ActivityFeed-ChWogUP-.js +7 -0
- package/src/client/dist/spa/assets/{ActivityFeed-ZLFD0ABF.css → ActivityFeed-D3Y4qOBg.css} +1 -1
- package/src/client/dist/spa/assets/CreatePage-Bk5v8_20.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-Cr7gCb6F.js +2 -0
- package/src/client/dist/spa/assets/{DiffViewer-CM3g7W7U.js → DiffViewer-DIwYNrvc.js} +2 -2
- package/src/client/dist/spa/assets/{HealthPage-BNv_dnMz.js → HealthPage-BsiMW46f.js} +1 -1
- package/src/client/dist/spa/assets/{MainLayout-BeKCjOA2.css → MainLayout-DKDosaB2.css} +1 -1
- package/src/client/dist/spa/assets/{MainLayout-NzuypipH.js → MainLayout-dWdvXPUq.js} +17 -17
- package/src/client/dist/spa/assets/{SearchPage-B3m_OWli.js → SearchPage-Cb5p2C1s.js} +1 -1
- package/src/client/dist/spa/assets/{SettingsPage-CpQm15XA.js → SettingsPage-n5CoKCHp.js} +1 -1
- package/src/client/dist/spa/assets/{WorkspacePage-CM676R3B.css → WorkspacePage-CI1BxN04.css} +1 -1
- package/src/client/dist/spa/assets/WorkspacePage-D0I1dB_Y.js +4 -0
- package/src/client/dist/spa/assets/{build-path-tree-DOPXkGhj.js → build-path-tree-Cx4Gbg4-.js} +1 -1
- package/src/client/dist/spa/assets/{cssMode-BPObkLMQ.js → cssMode-C_KSkvTO.js} +1 -1
- package/src/client/dist/spa/assets/{documents-DMvdjtPf.js → documents-CotyNumY.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-BpCtstKS.js → editor.api-C37o4gcc.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-C2h6FfOt.js → editor.main-B1LanICm.js} +3 -3
- package/src/client/dist/spa/assets/{freemarker2-DUmHGv4C.js → freemarker2-DElE6rHa.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-BU6pjzPg.js → handlebars-DgFLhirU.js} +1 -1
- package/src/client/dist/spa/assets/{html-A5-15bWl.js → html-Co1lVBCW.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-C3KkomG3.js → htmlMode-Bou9uwBJ.js} +1 -1
- package/src/client/dist/spa/assets/i18n-BY0mxocP.js +1 -0
- package/src/client/dist/spa/assets/index-CbTmiNhf.js +2 -0
- package/src/client/dist/spa/assets/{javascript-ggaOKiy5.js → javascript-BzHMqYPo.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-Bk-QMPGJ.js → jsonMode-DQriwWfG.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-CJdzn-JB.js → liquid-DfnWCF9s.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-D5wRO-st.js → mdx-C3N0ZOTO.js} +1 -1
- package/src/client/dist/spa/assets/{models-CwWSex3X.js → models-CuoIuROK.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-CPqJifAu.js → monaco.contribution-BWEoU0OQ.js} +2 -2
- package/src/client/dist/spa/assets/{python-DHI9rQDm.js → python-XRtT3KuX.js} +1 -1
- package/src/client/dist/spa/assets/rate-limit-labels-EtqMmGAk.js +10 -0
- package/src/client/dist/spa/assets/{razor-CzQWNzhW.js → razor-K5_2jeu8.js} +1 -1
- package/src/client/dist/spa/assets/{tsMode-DPkpdkNr.js → tsMode-T4aykrTz.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-Dgm0x_-O.js → typescript-CU2l4an1.js} +1 -1
- package/src/client/dist/spa/assets/{xml-BeXyffrj.js → xml-BXeGSs28.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-D4UE_1wU.js → yaml-DkchexIG.js} +1 -1
- package/src/client/dist/spa/index.html +1 -1
- package/src/mcp-server/kobo-tasks-handlers.ts +24 -1
- package/src/mcp-server/kobo-tasks-server.ts +29 -0
- package/src/client/dist/spa/assets/ActivityFeed-Bn9tpyLw.js +0 -7
- package/src/client/dist/spa/assets/CreatePage-CYtKx6Ji.css +0 -1
- package/src/client/dist/spa/assets/CreatePage-DDPmb3I-.js +0 -2
- package/src/client/dist/spa/assets/WorkspacePage-BQzk5qfr.js +0 -4
- package/src/client/dist/spa/assets/i18n-CIduhxS0.js +0 -1
- package/src/client/dist/spa/assets/index-QcUb2Iwh.js +0 -2
- package/src/client/dist/spa/assets/rate-limit-labels-Su-L56A2.js +0 -6
package/AGENTS.md
CHANGED
|
@@ -96,15 +96,18 @@ src/
|
|
|
96
96
|
|
|
97
97
|
| Table | Purpose |
|
|
98
98
|
|---|---|
|
|
99
|
-
| `workspaces` | the unit of work — id, name, project_path, source_branch, working_branch, status, notion_url, model, dev_server_status, `archived_at`, timestamps |
|
|
99
|
+
| `workspaces` | the unit of work — id, name, project_path, source_branch, working_branch, status, notion_url, model, dev_server_status, `archived_at`, `auto_loop`, `auto_loop_ready`, `no_progress_streak`, timestamps |
|
|
100
100
|
| `tasks` | workspace sub-items — title, status, `is_acceptance_criterion`, sort_order; CASCADE DELETE on workspace |
|
|
101
101
|
| `agent_sessions` | Claude Code CLI invocations — pid, `claude_session_id`, status, started_at, ended_at, `name` |
|
|
102
102
|
| `ws_events` | persisted WebSocket events for replay on reconnect — type, payload, session_id, created_at |
|
|
103
|
+
| `pending_wakeups` | one-row-per-workspace scheduler for the `ScheduleWakeup` tool — target_at (ISO UTC), prompt, reason; CASCADE DELETE on workspace |
|
|
103
104
|
|
|
104
105
|
`status` enum: `created | extracting | brainstorming | executing | completed | idle | error | quota`. Transitions are validated in `updateWorkspaceStatus` against `VALID_TRANSITIONS`.
|
|
105
106
|
|
|
106
107
|
`archived_at` is **orthogonal** to `status` — archiving is a visibility flag, not a lifecycle state. Unarchive restores the exact pre-archive `status`.
|
|
107
108
|
|
|
109
|
+
`auto_loop` (bool, default 0), `auto_loop_ready` (bool, default 0) and `no_progress_streak` (int, default 0) drive the auto-loop feature: when `auto_loop=1`, `session:ended` triggers `auto-loop-service.onSessionEnded` which either spawns the next iteration via a fresh `startAgent(resume=false)`, disables with `reason='completed'` (no pending tasks), or disables with `reason='stall'` (3 consecutive sessions without a task completed). Archive + delete both auto-disable.
|
|
110
|
+
|
|
108
111
|
## Database migrations
|
|
109
112
|
|
|
110
113
|
**The project is in production**. Every schema change MUST ship as an incremental migration that preserves existing data. Never drop-and-recreate, never rely on `initSchema` alone to patch running databases.
|
package/README.md
CHANGED
|
@@ -30,6 +30,9 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
|
|
|
30
30
|
- **Resizable right drawer** — drag-to-resize horizontally and vertically, with tab state and split ratio persisted to localStorage
|
|
31
31
|
- **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
|
|
32
32
|
- **Archive instead of delete** — soft-remove workspaces without losing the worktree, branches, or history; unarchive restores the exact pre-archive state
|
|
33
|
+
- **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
|
|
34
|
+
- **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
|
|
35
|
+
- **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
|
|
33
36
|
|
|
34
37
|
## Tech stack
|
|
35
38
|
|
|
@@ -18,6 +18,22 @@ export function listTasksHandler(db, workspaceId) {
|
|
|
18
18
|
.all(workspaceId);
|
|
19
19
|
return rows.map(rowToDto);
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Flip the workspace's `auto_loop_ready` flag. Called at the end of a
|
|
23
|
+
* `/kobo-prep-autoloop` grooming session to unlock the auto-loop toggle.
|
|
24
|
+
*
|
|
25
|
+
* The DB write itself happens here; the caller in kobo-tasks-server.ts
|
|
26
|
+
* also fires a notify-autoloop-ready POST so the backend emits
|
|
27
|
+
* `autoloop:ready-flipped` over WebSocket and any live frontend refreshes.
|
|
28
|
+
*/
|
|
29
|
+
export function markAutoLoopReadyHandler(db, workspaceId) {
|
|
30
|
+
const row = db.prepare('SELECT id FROM workspaces WHERE id = ?').get(workspaceId);
|
|
31
|
+
if (!row) {
|
|
32
|
+
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
33
|
+
}
|
|
34
|
+
db.prepare('UPDATE workspaces SET auto_loop_ready = 1 WHERE id = ?').run(workspaceId);
|
|
35
|
+
return { ok: true };
|
|
36
|
+
}
|
|
21
37
|
/** Set a task's status to "done" and return the updated task. */
|
|
22
38
|
export function markTaskDoneHandler(db, workspaceId, taskId) {
|
|
23
39
|
const now = new Date().toISOString();
|
|
@@ -136,7 +152,7 @@ export function getSettingsHandler(settingsPath, projectPath) {
|
|
|
136
152
|
/** Fetch workspace metadata from the database, computing the worktree path from project_path and working_branch. */
|
|
137
153
|
export function getWorkspaceInfoHandler(db, workspaceId) {
|
|
138
154
|
const row = db
|
|
139
|
-
.prepare('SELECT id, name, project_path, source_branch, working_branch, status, notion_url, notion_page_id, model, dev_server_status, has_unread, created_at, updated_at FROM workspaces WHERE id = ?')
|
|
155
|
+
.prepare('SELECT id, name, project_path, source_branch, working_branch, 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 = ?')
|
|
140
156
|
.get(workspaceId);
|
|
141
157
|
if (!row) {
|
|
142
158
|
throw new Error(`Workspace '${workspaceId}' not found`);
|
|
@@ -154,6 +170,8 @@ export function getWorkspaceInfoHandler(db, workspaceId) {
|
|
|
154
170
|
notionPageId: row.notion_page_id,
|
|
155
171
|
devServerStatus: row.dev_server_status,
|
|
156
172
|
hasUnread: row.has_unread === 1,
|
|
173
|
+
autoLoop: row.auto_loop === 1,
|
|
174
|
+
autoLoopReady: row.auto_loop_ready === 1,
|
|
157
175
|
createdAt: row.created_at,
|
|
158
176
|
updatedAt: row.updated_at,
|
|
159
177
|
};
|
|
@@ -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, markTaskDoneHandler, readDocumentHandler, updateTaskHandler, } from './kobo-tasks-handlers.js';
|
|
8
|
+
import { createTaskHandler, deleteTaskHandler, getDevServerStatusHandler, getSessionUsageHandler, getSettingsHandler, getWorkspaceInfoHandler, listDocumentsHandler, listTasksHandler, listWorkspaceImagesHandler, logThoughtHandler, markAutoLoopReadyHandler, markTaskDoneHandler, readDocumentHandler, 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;
|
|
@@ -52,6 +52,22 @@ async function notifyTasksUpdated() {
|
|
|
52
52
|
console.error('[kobo-tasks-server] notify-updated failed:', err);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Fire-and-forget POST that lands on `/auto-loop-ready`, which itself emits
|
|
57
|
+
* the `autoloop:ready-flipped` WS event so the frontend's toggle unlocks
|
|
58
|
+
* immediately after the grooming session completes. The handler already
|
|
59
|
+
* flipped the DB flag; this call is ONLY for the event emission + the
|
|
60
|
+
* (harmless) idempotent second write.
|
|
61
|
+
*/
|
|
62
|
+
async function notifyAutoLoopReady() {
|
|
63
|
+
try {
|
|
64
|
+
const url = `${backendUrl}/api/workspaces/${workspaceId}/auto-loop-ready`;
|
|
65
|
+
await fetch(url, { method: 'POST' });
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
console.error('[kobo-tasks-server] notify-autoloop-ready failed:', err);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
55
71
|
/** Generic HTTP request to the Kobo backend, returning parsed JSON or null. */
|
|
56
72
|
async function backendRequest(method, pathname, body) {
|
|
57
73
|
const url = `${backendUrl}${pathname}`;
|
|
@@ -87,6 +103,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
87
103
|
required: ['task_id'],
|
|
88
104
|
},
|
|
89
105
|
},
|
|
106
|
+
{
|
|
107
|
+
name: 'mark_auto_loop_ready',
|
|
108
|
+
description: 'CALL ONLY at the end of a `/kobo-prep-autoloop` grooming session, once all tasks look atomic and implementable in one session. Flips a flag on the workspace that unlocks the auto-loop toggle in the UI. Do NOT call during normal sessions.',
|
|
109
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
110
|
+
},
|
|
90
111
|
{
|
|
91
112
|
name: 'create_task',
|
|
92
113
|
description: 'CALL WHEN you discover follow-up work that was not in the original list and needs to stick around (e.g. "refactor this helper later", "add a test for edge case"). Appends at the end of the list. Do not use it for ephemeral internal notes — prefer log_thought for those.',
|
|
@@ -296,6 +317,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
296
317
|
void notifyBackend(taskId);
|
|
297
318
|
return ok(result);
|
|
298
319
|
}
|
|
320
|
+
if (name === 'mark_auto_loop_ready') {
|
|
321
|
+
const result = markAutoLoopReadyHandler(db, workspaceId);
|
|
322
|
+
void notifyAutoLoopReady();
|
|
323
|
+
return ok(result);
|
|
324
|
+
}
|
|
299
325
|
if (name === 'create_task') {
|
|
300
326
|
const title = a.title;
|
|
301
327
|
if (!title)
|
|
@@ -101,6 +101,17 @@ export const migrations = [
|
|
|
101
101
|
)`).run();
|
|
102
102
|
},
|
|
103
103
|
},
|
|
104
|
+
{
|
|
105
|
+
version: 12,
|
|
106
|
+
name: 'add-auto-loop-columns',
|
|
107
|
+
migrate: (db) => {
|
|
108
|
+
db.transaction(() => {
|
|
109
|
+
db.prepare('ALTER TABLE workspaces ADD COLUMN auto_loop INTEGER NOT NULL DEFAULT 0').run();
|
|
110
|
+
db.prepare('ALTER TABLE workspaces ADD COLUMN auto_loop_ready INTEGER NOT NULL DEFAULT 0').run();
|
|
111
|
+
db.prepare('ALTER TABLE workspaces ADD COLUMN no_progress_streak INTEGER NOT NULL DEFAULT 0').run();
|
|
112
|
+
})();
|
|
113
|
+
},
|
|
114
|
+
},
|
|
104
115
|
];
|
|
105
116
|
/** Current schema version — always equals the highest migration version. */
|
|
106
117
|
export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
|
package/dist/server/db/schema.js
CHANGED
|
@@ -19,6 +19,9 @@ export function initSchema(db) {
|
|
|
19
19
|
favorited_at TEXT,
|
|
20
20
|
tags TEXT NOT NULL DEFAULT '[]',
|
|
21
21
|
engine TEXT NOT NULL DEFAULT 'claude-code',
|
|
22
|
+
auto_loop INTEGER NOT NULL DEFAULT 0,
|
|
23
|
+
auto_loop_ready INTEGER NOT NULL DEFAULT 0,
|
|
24
|
+
no_progress_streak INTEGER NOT NULL DEFAULT 0,
|
|
22
25
|
created_at TEXT NOT NULL,
|
|
23
26
|
updated_at TEXT NOT NULL
|
|
24
27
|
);
|
package/dist/server/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import settingsRouter from './routes/settings.js';
|
|
|
21
21
|
import templatesRouter from './routes/templates.js';
|
|
22
22
|
import workspacesRouter from './routes/workspaces.js';
|
|
23
23
|
import { getAvailableSkills, reconcileOrphanSessions, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopWatchdog, } from './services/agent/orchestrator.js';
|
|
24
|
+
import * as autoLoopService from './services/auto-loop-service.js';
|
|
24
25
|
import { runContentMigrationIfNeeded } from './services/content-migration-service.js';
|
|
25
26
|
import { createDailyDbBackupIfNeeded } from './services/db-backup-service.js';
|
|
26
27
|
import { startDevServer, stopDevServer } from './services/dev-server-service.js';
|
|
@@ -59,6 +60,7 @@ initProcessCleanup();
|
|
|
59
60
|
reconcileOrphanSessions();
|
|
60
61
|
startWatchdog();
|
|
61
62
|
wakeupService.rehydrate();
|
|
63
|
+
autoLoopService.rehydrate();
|
|
62
64
|
startPrWatcher();
|
|
63
65
|
// Create Hono app
|
|
64
66
|
const app = new Hono();
|
|
@@ -4,10 +4,12 @@ const execFileAsync = promisify(execFileCb);
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { Hono } from 'hono';
|
|
7
|
+
import { AUTO_LOOP_GROOMING_STEPS, AUTO_LOOP_HARD_RULES } from '../../shared/auto-loop-prompts.js';
|
|
7
8
|
import { getDb } from '../db/index.js';
|
|
8
9
|
import { migrationGuard } from '../middleware/migration-guard.js';
|
|
9
10
|
import { listEngines } from '../services/agent/engines/registry.js';
|
|
10
11
|
import * as agentManager from '../services/agent/orchestrator.js';
|
|
12
|
+
import * as autoLoopService from '../services/auto-loop-service.js';
|
|
11
13
|
import * as devServerService from '../services/dev-server-service.js';
|
|
12
14
|
import * as notionService from '../services/notion-service.js';
|
|
13
15
|
import { renderPrTemplate } from '../services/pr-template-service.js';
|
|
@@ -453,7 +455,23 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
453
455
|
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`;
|
|
454
456
|
}
|
|
455
457
|
brainstormPrompt += `\nIMPORTANT: Start by reading CLAUDE.md and/or AGENTS.md at the project root if they exist — they contain project conventions and instructions you must follow.`;
|
|
456
|
-
|
|
458
|
+
if (body.autoLoop === true) {
|
|
459
|
+
// Auto-loop is armed — brainstorm must end with task seeding + mark-ready,
|
|
460
|
+
// NOT with implementation. The auto-loop will drive implementation after.
|
|
461
|
+
// The grooming steps + hard rules are shared with the PREP_AUTOLOOP_PROMPT
|
|
462
|
+
// sent by the "Prepare for auto-loop" button (src/shared/auto-loop-prompts.ts).
|
|
463
|
+
brainstormPrompt += `\n\nThen brainstorm the implementation approach. Explore the codebase to understand the existing structure. Ask clarifying questions if needed. When you have a clear plan, create a plan file.
|
|
464
|
+
|
|
465
|
+
Auto-loop mode is active for this workspace. After the plan is ready, DO NOT implement anything. Instead:
|
|
466
|
+
|
|
467
|
+
${AUTO_LOOP_GROOMING_STEPS}
|
|
468
|
+
5. Output [BRAINSTORM_COMPLETE] on its own line and end your turn cleanly.
|
|
469
|
+
|
|
470
|
+
${AUTO_LOOP_HARD_RULES}`;
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
brainstormPrompt += `\n\nThen brainstorm the implementation approach. Explore the codebase to understand the existing structure. Ask clarifying questions if needed. When you're done brainstorming and have a clear plan, create a plan file and proceed with implementation. Once you have completed the brainstorming phase, output [BRAINSTORM_COMPLETE] on its own line.`;
|
|
474
|
+
}
|
|
457
475
|
try {
|
|
458
476
|
const agent = agentManager.startAgent(workspace.id, worktreePath, brainstormPrompt, workspace.model, false, workspace.permissionMode, undefined, workspace.reasoningEffort);
|
|
459
477
|
// Persist the initial prompt in the feed so it's visible in the chat,
|
|
@@ -471,6 +489,22 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
471
489
|
}
|
|
472
490
|
}
|
|
473
491
|
}
|
|
492
|
+
// Apply the auto-loop checkbox from CreatePage. Notion-imported workspaces
|
|
493
|
+
// with both todos AND gherkin features auto-unlock `auto_loop_ready=1` —
|
|
494
|
+
// they're considered good enough to drive the loop without grooming.
|
|
495
|
+
if (body.autoLoop === true) {
|
|
496
|
+
const notionProducedTasks = body.notionUrl !== undefined &&
|
|
497
|
+
notionContent != null &&
|
|
498
|
+
notionContent.todos.length > 0 &&
|
|
499
|
+
notionContent.gherkinFeatures.length > 0;
|
|
500
|
+
const db = getDb();
|
|
501
|
+
db.prepare('UPDATE workspaces SET auto_loop = 1, auto_loop_ready = ? WHERE id = ?').run(notionProducedTasks ? 1 : 0, workspace.id);
|
|
502
|
+
// Emit events so the frontend refreshes autoLoopStates without F5.
|
|
503
|
+
wsService.emitEphemeral(workspace.id, 'autoloop:enabled', {});
|
|
504
|
+
if (notionProducedTasks) {
|
|
505
|
+
wsService.emitEphemeral(workspace.id, 'autoloop:ready-flipped', {});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
474
508
|
// Return created workspace with tasks
|
|
475
509
|
const workspaceWithTasks = workspaceService.getWorkspaceWithTasks(workspace.id);
|
|
476
510
|
return c.json(workspaceWithTasks, 201);
|
|
@@ -531,6 +565,81 @@ app.get('/pr-states', (c) => {
|
|
|
531
565
|
return c.json({ error: message }, 500);
|
|
532
566
|
}
|
|
533
567
|
});
|
|
568
|
+
// GET /api/workspaces/auto-loop-states — batch snapshot keyed by workspace id.
|
|
569
|
+
// Used by the drawer + Pinia store. Static path — must be BEFORE /:id.
|
|
570
|
+
app.get('/auto-loop-states', (c) => {
|
|
571
|
+
try {
|
|
572
|
+
const db = getDb();
|
|
573
|
+
const rows = db
|
|
574
|
+
.prepare('SELECT id, auto_loop, auto_loop_ready, no_progress_streak FROM workspaces WHERE archived_at IS NULL')
|
|
575
|
+
.all();
|
|
576
|
+
const out = {};
|
|
577
|
+
for (const r of rows) {
|
|
578
|
+
out[r.id] = {
|
|
579
|
+
auto_loop: r.auto_loop === 1,
|
|
580
|
+
auto_loop_ready: r.auto_loop_ready === 1,
|
|
581
|
+
no_progress_streak: r.no_progress_streak,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
return c.json(out);
|
|
585
|
+
}
|
|
586
|
+
catch (err) {
|
|
587
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
588
|
+
return c.json({ error: message }, 500);
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
// GET /api/workspaces/:id/auto-loop — current auto-loop status for one workspace.
|
|
592
|
+
app.get('/:id/auto-loop', (c) => {
|
|
593
|
+
try {
|
|
594
|
+
return c.json(autoLoopService.getStatus(c.req.param('id')));
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
598
|
+
return c.json({ error: message }, 500);
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
// POST /api/workspaces/:id/auto-loop — enable the loop (user toggle ON).
|
|
602
|
+
app.post('/:id/auto-loop', (c) => {
|
|
603
|
+
try {
|
|
604
|
+
autoLoopService.enable(c.req.param('id'));
|
|
605
|
+
return c.json({ ok: true });
|
|
606
|
+
}
|
|
607
|
+
catch (err) {
|
|
608
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
609
|
+
return c.json({ error: message }, 400);
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
// DELETE /api/workspaces/:id/auto-loop — disable the loop (user toggle OFF).
|
|
613
|
+
app.delete('/:id/auto-loop', (c) => {
|
|
614
|
+
try {
|
|
615
|
+
autoLoopService.disable(c.req.param('id'), 'user-action');
|
|
616
|
+
return c.json({ ok: true });
|
|
617
|
+
}
|
|
618
|
+
catch (err) {
|
|
619
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
620
|
+
return c.json({ error: message }, 500);
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
// POST /api/workspaces/:id/auto-loop-ready — force auto_loop_ready=true.
|
|
624
|
+
// Used by the "Force ready (skip grooming)" UI button AND by the MCP tool
|
|
625
|
+
// `kobo__mark_auto_loop_ready` at the end of a grooming session. Emits a
|
|
626
|
+
// WS event so any live frontend refreshes the toggle state immediately.
|
|
627
|
+
app.post('/:id/auto-loop-ready', (c) => {
|
|
628
|
+
try {
|
|
629
|
+
const id = c.req.param('id');
|
|
630
|
+
const ws = workspaceService.getWorkspace(id);
|
|
631
|
+
if (!ws)
|
|
632
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
633
|
+
workspaceService.setAutoLoopReady(id, true);
|
|
634
|
+
wsService.emitEphemeral(id, 'autoloop:ready-flipped', {});
|
|
635
|
+
autoLoopService.onAutoLoopReadySet(id);
|
|
636
|
+
return c.json({ ok: true });
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
640
|
+
return c.json({ error: message }, 500);
|
|
641
|
+
}
|
|
642
|
+
});
|
|
534
643
|
// GET /api/workspaces/:id/pending-wakeup — returns the pending wakeup or null.
|
|
535
644
|
app.get('/:id/pending-wakeup', (c) => {
|
|
536
645
|
try {
|
|
@@ -747,6 +856,9 @@ app.get('/:id/events', (c) => {
|
|
|
747
856
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
748
857
|
}
|
|
749
858
|
const before = c.req.query('before'); // event ID cursor
|
|
859
|
+
// optional: scope to a session view. Session views also include
|
|
860
|
+
// workspace-level rows where session_id IS NULL (legacy/pre-session items).
|
|
861
|
+
const session = c.req.query('session');
|
|
750
862
|
const limit = Math.min(parseInt(c.req.query('limit') ?? '100', 10) || 100, 500);
|
|
751
863
|
const db = getDb();
|
|
752
864
|
let rows;
|
|
@@ -756,18 +868,29 @@ app.get('/:id/events', (c) => {
|
|
|
756
868
|
if (!cursorRow) {
|
|
757
869
|
return c.json({ events: [], hasMore: false });
|
|
758
870
|
}
|
|
759
|
-
rows =
|
|
760
|
-
|
|
761
|
-
|
|
871
|
+
rows = session
|
|
872
|
+
? db
|
|
873
|
+
.prepare('SELECT * FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL) AND rowid < ? ORDER BY rowid DESC LIMIT ?')
|
|
874
|
+
.all(id, session, cursorRow.rowid, limit)
|
|
875
|
+
: db
|
|
876
|
+
.prepare('SELECT * FROM ws_events WHERE workspace_id = ? AND rowid < ? ORDER BY rowid DESC LIMIT ?')
|
|
877
|
+
.all(id, cursorRow.rowid, limit);
|
|
762
878
|
}
|
|
763
879
|
else {
|
|
764
|
-
// No cursor — return
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
880
|
+
// No cursor — return events. When filtering by session, we want the
|
|
881
|
+
// MOST RECENT events of that session first (so the feed renders from
|
|
882
|
+
// the end), reversed to chronological order below.
|
|
883
|
+
rows = session
|
|
884
|
+
? db
|
|
885
|
+
.prepare('SELECT * FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL) ORDER BY rowid DESC LIMIT ?')
|
|
886
|
+
.all(id, session, limit)
|
|
887
|
+
: db
|
|
888
|
+
.prepare('SELECT * FROM ws_events WHERE workspace_id = ? ORDER BY rowid ASC LIMIT ?')
|
|
889
|
+
.all(id, limit);
|
|
890
|
+
}
|
|
891
|
+
// Reverse to chronological order (we queried DESC for "before" pagination,
|
|
892
|
+
// or for the "session + no cursor" case where we fetched the newest first).
|
|
893
|
+
if (before || session)
|
|
771
894
|
rows.reverse();
|
|
772
895
|
const events = rows.map((row) => {
|
|
773
896
|
let parsedPayload;
|
|
@@ -788,13 +911,25 @@ app.get('/:id/events', (c) => {
|
|
|
788
911
|
});
|
|
789
912
|
// Check if there are more older events beyond what we returned
|
|
790
913
|
let hasMore = false;
|
|
791
|
-
if (
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
914
|
+
if (rows.length > 0) {
|
|
915
|
+
if (before) {
|
|
916
|
+
const firstRow = db.prepare('SELECT rowid FROM ws_events WHERE id = ?').get(rows[0].id);
|
|
917
|
+
if (firstRow) {
|
|
918
|
+
const older = session
|
|
919
|
+
? db
|
|
920
|
+
.prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL) AND rowid < ?')
|
|
921
|
+
.get(id, session, firstRow.rowid)
|
|
922
|
+
: db
|
|
923
|
+
.prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ? AND rowid < ?')
|
|
924
|
+
.get(id, firstRow.rowid);
|
|
925
|
+
hasMore = older.c > 0;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
else if (session) {
|
|
929
|
+
const total = db
|
|
930
|
+
.prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL)')
|
|
931
|
+
.get(id, session);
|
|
932
|
+
hasMore = total.c > rows.length;
|
|
798
933
|
}
|
|
799
934
|
}
|
|
800
935
|
return c.json({ events, hasMore });
|
|
@@ -70,9 +70,14 @@ export function createClaudeCodeEngine() {
|
|
|
70
70
|
lower.includes('rate_limit_exceeded') ||
|
|
71
71
|
(lower.includes('429') && lower.includes('rate')) ||
|
|
72
72
|
lower.includes('quota exceeded');
|
|
73
|
+
const isResumeFailed = lower.includes('no conversation found with session id');
|
|
73
74
|
if (isQuota) {
|
|
74
75
|
onEvent({ kind: 'error', category: 'quota', message: line });
|
|
75
76
|
}
|
|
77
|
+
else if (isResumeFailed) {
|
|
78
|
+
onEvent({ kind: 'error', category: 'resume_failed', message: line });
|
|
79
|
+
console.warn(`[claude-engine stderr] ${line}`);
|
|
80
|
+
}
|
|
76
81
|
else if (line.trim().length > 0 && !isBenignStderr(line)) {
|
|
77
82
|
console.warn(`[claude-engine stderr] ${line}`);
|
|
78
83
|
}
|
|
@@ -26,6 +26,161 @@ function makeBucket(id, source) {
|
|
|
26
26
|
const details = used !== undefined && limit !== undefined ? `${String(used)} / ${String(limit)}` : undefined;
|
|
27
27
|
return { id, label, usedPct: Math.max(0, Math.min(100, usedPct)), resetsAt, details };
|
|
28
28
|
}
|
|
29
|
+
// ── Text-based quota detection ────────────────────────────────────────────────
|
|
30
|
+
// When the `claude -p` CLI hits a rate limit mid-turn, it often does NOT emit
|
|
31
|
+
// a structured `rate_limit_event` nor an error on stderr — it just writes a
|
|
32
|
+
// plain-text message like:
|
|
33
|
+
// "You've hit your limit · resets 1:20pm (Europe/Paris)"
|
|
34
|
+
// and exits cleanly. Without the pattern below we'd see `session:ended` with
|
|
35
|
+
// `reason: 'completed'` and the auto-loop would spawn the next iteration
|
|
36
|
+
// immediately, only to hit the same wall again, N times, until stall kicks in.
|
|
37
|
+
//
|
|
38
|
+
// The regex captures the reset time (e.g. "1:20pm") and the timezone in
|
|
39
|
+
// parentheses (e.g. "Europe/Paris") so we can convert to an ISO 8601
|
|
40
|
+
// timestamp and feed handleQuota the same way a structured event would.
|
|
41
|
+
const QUOTA_TEXT_PATTERN = /you(?:'ve|\s+have)\s+hit\s+your\s+limit[^.\n]*?resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)]+)\))?/i;
|
|
42
|
+
function extractZonedDateTimeParts(date, timeZone) {
|
|
43
|
+
try {
|
|
44
|
+
const formatter = new Intl.DateTimeFormat('en-CA', {
|
|
45
|
+
timeZone,
|
|
46
|
+
year: 'numeric',
|
|
47
|
+
month: '2-digit',
|
|
48
|
+
day: '2-digit',
|
|
49
|
+
hour: '2-digit',
|
|
50
|
+
minute: '2-digit',
|
|
51
|
+
second: '2-digit',
|
|
52
|
+
hour12: false,
|
|
53
|
+
});
|
|
54
|
+
const parts = formatter.formatToParts(date);
|
|
55
|
+
const read = (type) => {
|
|
56
|
+
const value = parts.find((part) => part.type === type)?.value;
|
|
57
|
+
if (!value)
|
|
58
|
+
return null;
|
|
59
|
+
const parsed = Number.parseInt(value, 10);
|
|
60
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
61
|
+
};
|
|
62
|
+
const year = read('year');
|
|
63
|
+
const month = read('month');
|
|
64
|
+
const day = read('day');
|
|
65
|
+
const hour = read('hour');
|
|
66
|
+
const minute = read('minute');
|
|
67
|
+
const second = read('second');
|
|
68
|
+
if ([year, month, day, hour, minute, second].some((value) => value === null))
|
|
69
|
+
return null;
|
|
70
|
+
return {
|
|
71
|
+
year: year,
|
|
72
|
+
month: month,
|
|
73
|
+
day: day,
|
|
74
|
+
hour: hour,
|
|
75
|
+
minute: minute,
|
|
76
|
+
second: second,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function dateTimePartsToUtcMs(parts) {
|
|
84
|
+
return Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second, 0);
|
|
85
|
+
}
|
|
86
|
+
function zonedDateTimeToUtc(parts, timeZone) {
|
|
87
|
+
let guess = new Date(dateTimePartsToUtcMs(parts));
|
|
88
|
+
for (let i = 0; i < 4; i += 1) {
|
|
89
|
+
const observed = extractZonedDateTimeParts(guess, timeZone);
|
|
90
|
+
if (!observed)
|
|
91
|
+
return undefined;
|
|
92
|
+
const diffMs = dateTimePartsToUtcMs(parts) - dateTimePartsToUtcMs(observed);
|
|
93
|
+
if (diffMs === 0)
|
|
94
|
+
return guess.toISOString();
|
|
95
|
+
guess = new Date(guess.getTime() + diffMs);
|
|
96
|
+
}
|
|
97
|
+
const observed = extractZonedDateTimeParts(guess, timeZone);
|
|
98
|
+
if (observed &&
|
|
99
|
+
observed.year === parts.year &&
|
|
100
|
+
observed.month === parts.month &&
|
|
101
|
+
observed.day === parts.day &&
|
|
102
|
+
observed.hour === parts.hour &&
|
|
103
|
+
observed.minute === parts.minute) {
|
|
104
|
+
return guess.toISOString();
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Parse the time string Claude outputs (e.g. "1:20pm" + "Europe/Paris")
|
|
110
|
+
* into an ISO 8601 UTC timestamp. Returns `undefined` if anything looks
|
|
111
|
+
* off so handleQuota falls back to its ladder.
|
|
112
|
+
*/
|
|
113
|
+
function parseQuotaResetsAt(hourStr, minuteStr, ampm, tz, now = new Date()) {
|
|
114
|
+
const hour24 = (() => {
|
|
115
|
+
const h = Number.parseInt(hourStr, 10);
|
|
116
|
+
if (Number.isNaN(h) || h < 0 || h > 23)
|
|
117
|
+
return null;
|
|
118
|
+
if (!ampm)
|
|
119
|
+
return h;
|
|
120
|
+
const pm = ampm.toLowerCase() === 'pm';
|
|
121
|
+
if (h === 12)
|
|
122
|
+
return pm ? 12 : 0;
|
|
123
|
+
return pm ? h + 12 : h;
|
|
124
|
+
})();
|
|
125
|
+
if (hour24 === null)
|
|
126
|
+
return undefined;
|
|
127
|
+
const minute = minuteStr ? Number.parseInt(minuteStr, 10) : 0;
|
|
128
|
+
if (Number.isNaN(minute) || minute < 0 || minute > 59)
|
|
129
|
+
return undefined;
|
|
130
|
+
if (tz) {
|
|
131
|
+
const zonedNow = extractZonedDateTimeParts(now, tz);
|
|
132
|
+
if (zonedNow) {
|
|
133
|
+
const targetDate = new Date(Date.UTC(zonedNow.year, zonedNow.month - 1, zonedNow.day));
|
|
134
|
+
const nowInZoneMs = dateTimePartsToUtcMs(zonedNow);
|
|
135
|
+
const targetTodayMs = Date.UTC(zonedNow.year, zonedNow.month - 1, zonedNow.day, hour24, minute, 0, 0);
|
|
136
|
+
if (targetTodayMs < nowInZoneMs - 60_000) {
|
|
137
|
+
targetDate.setUTCDate(targetDate.getUTCDate() + 1);
|
|
138
|
+
}
|
|
139
|
+
const zonedIso = zonedDateTimeToUtc({
|
|
140
|
+
year: targetDate.getUTCFullYear(),
|
|
141
|
+
month: targetDate.getUTCMonth() + 1,
|
|
142
|
+
day: targetDate.getUTCDate(),
|
|
143
|
+
hour: hour24,
|
|
144
|
+
minute,
|
|
145
|
+
second: 0,
|
|
146
|
+
}, tz);
|
|
147
|
+
if (zonedIso)
|
|
148
|
+
return zonedIso;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Fallback for missing/invalid timezone names: interpret the wall-clock time
|
|
152
|
+
// in the local machine timezone so quota handling still works on plain
|
|
153
|
+
// "resets 11am" messages.
|
|
154
|
+
const target = new Date(now);
|
|
155
|
+
target.setHours(hour24, minute, 0, 0);
|
|
156
|
+
if (target.getTime() < now.getTime() - 60_000) {
|
|
157
|
+
target.setDate(target.getDate() + 1);
|
|
158
|
+
}
|
|
159
|
+
return target.toISOString();
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Scan a text block for a quota announcement. Returns synthetic
|
|
163
|
+
* `rate_limit` + `error { category: 'quota' }` events when the pattern
|
|
164
|
+
* matches, otherwise `null`. The caller pushes both events into the
|
|
165
|
+
* stream so handleQuota picks up the reset time and schedules the
|
|
166
|
+
* retry at the parsed moment.
|
|
167
|
+
*/
|
|
168
|
+
function extractQuotaFromText(text) {
|
|
169
|
+
const match = QUOTA_TEXT_PATTERN.exec(text);
|
|
170
|
+
if (!match)
|
|
171
|
+
return null;
|
|
172
|
+
const [, hourStr, minuteStr, ampm, tz] = match;
|
|
173
|
+
const resetsAt = parseQuotaResetsAt(hourStr, minuteStr, ampm, tz);
|
|
174
|
+
const bucket = {
|
|
175
|
+
id: 'text-detected',
|
|
176
|
+
usedPct: 100,
|
|
177
|
+
resetsAt,
|
|
178
|
+
};
|
|
179
|
+
return {
|
|
180
|
+
error: { kind: 'error', category: 'quota', message: match[0] },
|
|
181
|
+
rateLimit: { kind: 'rate_limit', info: { buckets: [bucket] } },
|
|
182
|
+
};
|
|
183
|
+
}
|
|
29
184
|
function normalizeRateLimitInfo(info) {
|
|
30
185
|
const buckets = [];
|
|
31
186
|
if (typeof info.rateLimitType === 'string') {
|
|
@@ -149,6 +304,13 @@ export function parseClaudeLine(line, state) {
|
|
|
149
304
|
if (blockType === 'text' && typeof block.text === 'string') {
|
|
150
305
|
events.push({ kind: 'message:text', messageId, text: block.text, streaming: true });
|
|
151
306
|
msgState.sawText = true;
|
|
307
|
+
// Synthesise quota events when the CLI reports the limit in prose.
|
|
308
|
+
// See extractQuotaFromText above for why this is needed in `-p` mode.
|
|
309
|
+
const synth = extractQuotaFromText(block.text);
|
|
310
|
+
if (synth) {
|
|
311
|
+
events.push(synth.rateLimit);
|
|
312
|
+
events.push(synth.error);
|
|
313
|
+
}
|
|
152
314
|
}
|
|
153
315
|
if (blockType === 'tool_use') {
|
|
154
316
|
events.push({
|