@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.
Files changed (61) hide show
  1. package/AGENTS.md +4 -1
  2. package/README.md +3 -0
  3. package/dist/mcp-server/kobo-tasks-handlers.js +19 -1
  4. package/dist/mcp-server/kobo-tasks-server.js +27 -1
  5. package/dist/server/db/migrations.js +11 -0
  6. package/dist/server/db/schema.js +3 -0
  7. package/dist/server/index.js +2 -0
  8. package/dist/server/routes/workspaces.js +153 -18
  9. package/dist/server/services/agent/engines/claude-code/engine.js +5 -0
  10. package/dist/server/services/agent/engines/claude-code/stream-parser.js +162 -0
  11. package/dist/server/services/agent/orchestrator.js +167 -18
  12. package/dist/server/services/auto-loop-service.js +311 -0
  13. package/dist/server/services/workspace-service.js +22 -0
  14. package/dist/shared/auto-loop-prompts.js +28 -0
  15. package/package.json +1 -1
  16. package/src/client/dist/spa/assets/ActivityFeed-ChWogUP-.js +7 -0
  17. package/src/client/dist/spa/assets/{ActivityFeed-ZLFD0ABF.css → ActivityFeed-D3Y4qOBg.css} +1 -1
  18. package/src/client/dist/spa/assets/CreatePage-Bk5v8_20.css +1 -0
  19. package/src/client/dist/spa/assets/CreatePage-Cr7gCb6F.js +2 -0
  20. package/src/client/dist/spa/assets/{DiffViewer-CM3g7W7U.js → DiffViewer-DIwYNrvc.js} +2 -2
  21. package/src/client/dist/spa/assets/{HealthPage-BNv_dnMz.js → HealthPage-BsiMW46f.js} +1 -1
  22. package/src/client/dist/spa/assets/{MainLayout-BeKCjOA2.css → MainLayout-DKDosaB2.css} +1 -1
  23. package/src/client/dist/spa/assets/{MainLayout-NzuypipH.js → MainLayout-dWdvXPUq.js} +17 -17
  24. package/src/client/dist/spa/assets/{SearchPage-B3m_OWli.js → SearchPage-Cb5p2C1s.js} +1 -1
  25. package/src/client/dist/spa/assets/{SettingsPage-CpQm15XA.js → SettingsPage-n5CoKCHp.js} +1 -1
  26. package/src/client/dist/spa/assets/{WorkspacePage-CM676R3B.css → WorkspacePage-CI1BxN04.css} +1 -1
  27. package/src/client/dist/spa/assets/WorkspacePage-D0I1dB_Y.js +4 -0
  28. package/src/client/dist/spa/assets/{build-path-tree-DOPXkGhj.js → build-path-tree-Cx4Gbg4-.js} +1 -1
  29. package/src/client/dist/spa/assets/{cssMode-BPObkLMQ.js → cssMode-C_KSkvTO.js} +1 -1
  30. package/src/client/dist/spa/assets/{documents-DMvdjtPf.js → documents-CotyNumY.js} +1 -1
  31. package/src/client/dist/spa/assets/{editor.api-BpCtstKS.js → editor.api-C37o4gcc.js} +1 -1
  32. package/src/client/dist/spa/assets/{editor.main-C2h6FfOt.js → editor.main-B1LanICm.js} +3 -3
  33. package/src/client/dist/spa/assets/{freemarker2-DUmHGv4C.js → freemarker2-DElE6rHa.js} +1 -1
  34. package/src/client/dist/spa/assets/{handlebars-BU6pjzPg.js → handlebars-DgFLhirU.js} +1 -1
  35. package/src/client/dist/spa/assets/{html-A5-15bWl.js → html-Co1lVBCW.js} +1 -1
  36. package/src/client/dist/spa/assets/{htmlMode-C3KkomG3.js → htmlMode-Bou9uwBJ.js} +1 -1
  37. package/src/client/dist/spa/assets/i18n-BY0mxocP.js +1 -0
  38. package/src/client/dist/spa/assets/index-CbTmiNhf.js +2 -0
  39. package/src/client/dist/spa/assets/{javascript-ggaOKiy5.js → javascript-BzHMqYPo.js} +1 -1
  40. package/src/client/dist/spa/assets/{jsonMode-Bk-QMPGJ.js → jsonMode-DQriwWfG.js} +1 -1
  41. package/src/client/dist/spa/assets/{liquid-CJdzn-JB.js → liquid-DfnWCF9s.js} +1 -1
  42. package/src/client/dist/spa/assets/{mdx-D5wRO-st.js → mdx-C3N0ZOTO.js} +1 -1
  43. package/src/client/dist/spa/assets/{models-CwWSex3X.js → models-CuoIuROK.js} +1 -1
  44. package/src/client/dist/spa/assets/{monaco.contribution-CPqJifAu.js → monaco.contribution-BWEoU0OQ.js} +2 -2
  45. package/src/client/dist/spa/assets/{python-DHI9rQDm.js → python-XRtT3KuX.js} +1 -1
  46. package/src/client/dist/spa/assets/rate-limit-labels-EtqMmGAk.js +10 -0
  47. package/src/client/dist/spa/assets/{razor-CzQWNzhW.js → razor-K5_2jeu8.js} +1 -1
  48. package/src/client/dist/spa/assets/{tsMode-DPkpdkNr.js → tsMode-T4aykrTz.js} +1 -1
  49. package/src/client/dist/spa/assets/{typescript-Dgm0x_-O.js → typescript-CU2l4an1.js} +1 -1
  50. package/src/client/dist/spa/assets/{xml-BeXyffrj.js → xml-BXeGSs28.js} +1 -1
  51. package/src/client/dist/spa/assets/{yaml-D4UE_1wU.js → yaml-DkchexIG.js} +1 -1
  52. package/src/client/dist/spa/index.html +1 -1
  53. package/src/mcp-server/kobo-tasks-handlers.ts +24 -1
  54. package/src/mcp-server/kobo-tasks-server.ts +29 -0
  55. package/src/client/dist/spa/assets/ActivityFeed-Bn9tpyLw.js +0 -7
  56. package/src/client/dist/spa/assets/CreatePage-CYtKx6Ji.css +0 -1
  57. package/src/client/dist/spa/assets/CreatePage-DDPmb3I-.js +0 -2
  58. package/src/client/dist/spa/assets/WorkspacePage-BQzk5qfr.js +0 -4
  59. package/src/client/dist/spa/assets/i18n-CIduhxS0.js +0 -1
  60. package/src/client/dist/spa/assets/index-QcUb2Iwh.js +0 -2
  61. 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;
@@ -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
  );
@@ -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
- 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.`;
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 = db
760
- .prepare('SELECT * FROM ws_events WHERE workspace_id = ? AND rowid < ? ORDER BY rowid DESC LIMIT ?')
761
- .all(id, cursorRow.rowid, limit);
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 the oldest events
765
- rows = db
766
- .prepare('SELECT * FROM ws_events WHERE workspace_id = ? ORDER BY rowid ASC LIMIT ?')
767
- .all(id, limit);
768
- }
769
- // Reverse to chronological order (we queried DESC for "before" pagination)
770
- if (before)
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 (before && rows.length > 0) {
792
- const firstRow = db.prepare('SELECT rowid FROM ws_events WHERE id = ?').get(rows[0].id);
793
- if (firstRow) {
794
- const older = db
795
- .prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ? AND rowid < ?')
796
- .get(id, firstRow.rowid);
797
- hasMore = older.c > 0;
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({