@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
@@ -3,6 +3,7 @@ import { nanoid } from 'nanoid';
3
3
  import { getDb } from '../../db/index.js';
4
4
  import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getKoboHome, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../../utils/paths.js';
5
5
  import { unregisterProcess } from '../../utils/process-tracker.js';
6
+ import * as autoLoopService from '../auto-loop-service.js';
6
7
  import { getEffectiveSettings } from '../settings-service.js';
7
8
  import * as wakeupService from '../wakeup-service.js';
8
9
  import { emitEphemeral } from '../websocket-service.js';
@@ -33,6 +34,8 @@ let availableSkills = (() => {
33
34
  })();
34
35
  /** workspaceId -> retry count (for quota backoff) */
35
36
  const retryCounts = new Map();
37
+ /** Tracks workspaces where the current session failed due to a stale --resume session ID. */
38
+ const resumeFailedSet = new Set();
36
39
  /** workspaceId -> backoff timer */
37
40
  const backoffTimers = new Map();
38
41
  // ── Watchdog ──────────────────────────────────────────────────────────────────
@@ -251,8 +254,33 @@ function reuseOrCreateFreshSession(workspaceId, existingSessionId) {
251
254
  return agentSessionId;
252
255
  }
253
256
  // ── Event handler ─────────────────────────────────────────────────────────────
257
+ /**
258
+ * Snapshot of the `tasks` done-count at `session:started`, read back and
259
+ * cleared at `session:ended` to compute the per-session delta. Used by
260
+ * `auto-loop-service.onSessionEnded` for stall detection.
261
+ */
262
+ const tasksDoneSnapshot = new Map();
263
+ function getDoneTaskCount(workspaceId) {
264
+ const db = getDb();
265
+ const row = db
266
+ .prepare('SELECT COUNT(*) AS c FROM tasks WHERE workspace_id = ? AND status = ?')
267
+ .get(workspaceId, 'done');
268
+ return row.c;
269
+ }
270
+ /** Clear the in-memory done-count snapshot for a workspace (called on delete). */
271
+ export function forgetTasksDoneSnapshot(workspaceId) {
272
+ tasksDoneSnapshot.delete(workspaceId);
273
+ }
254
274
  function handleEvent(workspaceId, agentSessionId, ev) {
255
275
  routeEvent(workspaceId, agentSessionId, ev);
276
+ if (ev.kind === 'rate_limit') {
277
+ latestRateLimitInfo.set(workspaceId, ev.info);
278
+ }
279
+ // Snapshot the done-count at session start so the session:ended hook below
280
+ // can compute a delta for auto-loop stall detection.
281
+ if (ev.kind === 'session:started') {
282
+ tasksDoneSnapshot.set(workspaceId, getDoneTaskCount(workspaceId));
283
+ }
256
284
  if (ev.kind === 'tool:call' && ev.name === 'ScheduleWakeup') {
257
285
  const input = ev.input;
258
286
  const delay = typeof input?.delaySeconds === 'number' ? input.delaySeconds : 0;
@@ -274,7 +302,10 @@ function handleEvent(workspaceId, agentSessionId, ev) {
274
302
  }
275
303
  if (ev.kind === 'session:brainstorm-complete') {
276
304
  try {
277
- updateWorkspaceStatus(workspaceId, 'executing');
305
+ const ws = getWs(workspaceId);
306
+ if (ws && ws.status !== 'executing') {
307
+ updateWorkspaceStatus(workspaceId, 'executing');
308
+ }
278
309
  }
279
310
  catch (err) {
280
311
  console.error('[orchestrator] Failed to transition to executing:', err);
@@ -283,8 +314,31 @@ function handleEvent(workspaceId, agentSessionId, ev) {
283
314
  if (ev.kind === 'error' && ev.category === 'quota') {
284
315
  handleQuota(workspaceId, agentSessionId);
285
316
  }
317
+ if (ev.kind === 'error' && ev.category === 'resume_failed') {
318
+ resumeFailedSet.add(workspaceId);
319
+ clearStaleEngineSessionId(workspaceId);
320
+ }
286
321
  if (ev.kind === 'session:ended') {
287
- onSessionEnded(workspaceId, agentSessionId, ev.exitCode);
322
+ // Pop the resume_failed flag before any cleanup so both onSessionEnded paths see it.
323
+ const isResumeFailed = resumeFailedSet.delete(workspaceId);
324
+ // Compute the auto-loop done-delta BEFORE the internal cleanup because
325
+ // onSessionEnded(internal) may throw / trigger follow-ups; also read the
326
+ // snapshot FIRST so a later re-entry can't overwrite it.
327
+ const before = tasksDoneSnapshot.get(workspaceId) ?? getDoneTaskCount(workspaceId);
328
+ const after = getDoneTaskCount(workspaceId);
329
+ const delta = Math.max(0, after - before);
330
+ tasksDoneSnapshot.delete(workspaceId);
331
+ // Internal cleanup REMOVES the controller from the map. This must run
332
+ // BEFORE autoLoopService.onSessionEnded → spawnNextIteration → startAgent,
333
+ // otherwise startAgent throws "Agent already running" because the
334
+ // just-ended controller is still in the map.
335
+ onSessionEnded(workspaceId, agentSessionId, ev.exitCode, isResumeFailed);
336
+ // When a resume failed the session exited with an error but there's
337
+ // nothing wrong with the workspace — the stale session ID has been cleared
338
+ // and the next iteration will start fresh. Treat it as 'completed' so
339
+ // auto-loop continues without disabling.
340
+ const effectiveReason = isResumeFailed ? 'completed' : ev.reason;
341
+ autoLoopService.onSessionEnded(workspaceId, effectiveReason, delta);
288
342
  }
289
343
  if (ev.kind === 'session:started' && ev.engineSessionId) {
290
344
  sessionIds.set(workspaceId, ev.engineSessionId);
@@ -312,7 +366,9 @@ function handleEvent(workspaceId, agentSessionId, ev) {
312
366
  }
313
367
  }
314
368
  }
315
- function onSessionEnded(workspaceId, agentSessionId, exitCode) {
369
+ function onSessionEnded(workspaceId, agentSessionId, exitCode, resumeFailed = false) {
370
+ const currentWorkspace = getWs(workspaceId);
371
+ const preserveQuotaBackoff = currentWorkspace?.status === 'quota';
316
372
  const ctrl = controllers.get(workspaceId);
317
373
  const wasStopping = ctrl?.status === 'stopping';
318
374
  // Identity-preserving cleanup: only remove the controller if the map still
@@ -322,7 +378,9 @@ function onSessionEnded(workspaceId, agentSessionId, exitCode) {
322
378
  controllers.delete(workspaceId);
323
379
  }
324
380
  unregisterProcess(workspaceId);
325
- retryCounts.delete(workspaceId);
381
+ if (!preserveQuotaBackoff) {
382
+ retryCounts.delete(workspaceId);
383
+ }
326
384
  // Update the agent_sessions row
327
385
  try {
328
386
  const db = getDb();
@@ -336,13 +394,27 @@ function onSessionEnded(workspaceId, agentSessionId, exitCode) {
336
394
  // the "stopped" status. No legacy emit needed.
337
395
  return;
338
396
  }
339
- // Clear any pending backoff timer on non-stopping exits
340
- const pendingBackoff = backoffTimers.get(workspaceId);
341
- if (pendingBackoff) {
342
- clearTimeout(pendingBackoff);
343
- backoffTimers.delete(workspaceId);
397
+ // When the session hit quota, handleQuota() already transitioned the
398
+ // workspace to `quota` and armed the retry timer. Keep that timer alive
399
+ // and preserve the `quota` status so auto-loop can resume after reset.
400
+ if (!preserveQuotaBackoff) {
401
+ const pendingBackoff = backoffTimers.get(workspaceId);
402
+ if (pendingBackoff) {
403
+ clearTimeout(pendingBackoff);
404
+ backoffTimers.delete(workspaceId);
405
+ }
406
+ }
407
+ if (preserveQuotaBackoff) {
408
+ try {
409
+ markWorkspaceUnread(workspaceId);
410
+ emitEphemeral(workspaceId, 'workspace:unread', { hasUnread: true });
411
+ }
412
+ catch {
413
+ // best-effort
414
+ }
415
+ return;
344
416
  }
345
- if (exitCode !== null && exitCode !== 0) {
417
+ if (exitCode !== null && exitCode !== 0 && !resumeFailed) {
346
418
  try {
347
419
  updateWorkspaceStatus(workspaceId, 'error');
348
420
  }
@@ -517,12 +589,81 @@ export function getRunningCount() {
517
589
  return controllers.size;
518
590
  }
519
591
  /** Kobo built-in slash commands injected into the skill list (without leading /). */
520
- const KOBO_COMMANDS = ['kobo-check-progress'];
592
+ const KOBO_COMMANDS = ['kobo-check-progress', 'kobo-prep-autoloop'];
521
593
  /** Cached list of slash commands discovered from the last agent init, plus Kobo built-ins. */
522
594
  export function getAvailableSkills() {
523
595
  return [...KOBO_COMMANDS, ...availableSkills];
524
596
  }
525
597
  // ── Quota handling ────────────────────────────────────────────────────────────
598
+ /**
599
+ * Last `rate_limit.info` received per workspace. Used by handleQuota to
600
+ * schedule the backoff at the actual reset time instead of a hardcoded
601
+ * exponential. In-memory only — rebuilt on the next rate_limit event after
602
+ * a server restart.
603
+ */
604
+ const latestRateLimitInfo = new Map();
605
+ /** Clear the rate-limit info cache for a workspace (called on deleteWorkspace). */
606
+ export function forgetRateLimitInfo(workspaceId) {
607
+ latestRateLimitInfo.delete(workspaceId);
608
+ }
609
+ /**
610
+ * Null out the engine_session_id on all agent_sessions rows for a workspace
611
+ * and clear the in-memory sessionIds cache. Called when a --resume attempt
612
+ * fails ("No conversation found with session ID") so that the next startAgent
613
+ * call starts a fresh conversation instead of retrying the stale ID.
614
+ */
615
+ function clearStaleEngineSessionId(workspaceId) {
616
+ try {
617
+ const db = getDb();
618
+ db.prepare('UPDATE agent_sessions SET engine_session_id = NULL WHERE workspace_id = ?').run(workspaceId);
619
+ sessionIds.delete(workspaceId);
620
+ }
621
+ catch (err) {
622
+ console.error('[orchestrator] Failed to clear stale engine session ID:', err);
623
+ }
624
+ }
625
+ const QUOTA_SAFETY_MARGIN_MS = 30_000;
626
+ const QUOTA_MAX_BACKOFF_MS = 24 * 60 * 60 * 1000;
627
+ const QUOTA_SATURATION_THRESHOLD_PCT = 95;
628
+ /**
629
+ * Fallback backoff ladder (in minutes) used when the `rate_limit` info
630
+ * isn't usable. Indexed by `retryCount`; anything past the last entry
631
+ * clamps to the final value (5 h) — long enough to cross a weekly
632
+ * bucket reset if the rate_limit info truly never arrives.
633
+ */
634
+ const QUOTA_FALLBACK_LADDER_MINUTES = [15, 30, 60, 180, 300];
635
+ /**
636
+ * Compute the delay before retrying a workspace hit by quota.
637
+ *
638
+ * Prefers the `resetsAt` of the saturated bucket with the furthest-future
639
+ * reset (a tighter bucket will unlock by then anyway). Falls back to a
640
+ * fixed ladder (15 → 30 → 60 → 180 → 300 min) whenever the rate_limit
641
+ * info is missing, malformed, or implausible.
642
+ */
643
+ export function computeQuotaBackoffMs(workspaceId, retryCount) {
644
+ const info = latestRateLimitInfo.get(workspaceId);
645
+ if (info?.buckets?.length) {
646
+ const candidates = info.buckets
647
+ .filter((b) => b.usedPct >= QUOTA_SATURATION_THRESHOLD_PCT && typeof b.resetsAt === 'string')
648
+ .map((b) => ({ resetsAt: b.resetsAt, ts: new Date(b.resetsAt).getTime() }))
649
+ .filter((x) => !Number.isNaN(x.ts))
650
+ .sort((a, b) => b.ts - a.ts);
651
+ const chosen = candidates[0];
652
+ if (chosen) {
653
+ const delta = chosen.ts - Date.now() + QUOTA_SAFETY_MARGIN_MS;
654
+ if (delta > 0 && delta <= QUOTA_MAX_BACKOFF_MS) {
655
+ return { delayMs: delta, resetsAt: chosen.resetsAt, source: 'rate_limit_info' };
656
+ }
657
+ }
658
+ }
659
+ const idx = Math.min(Math.max(0, retryCount), QUOTA_FALLBACK_LADDER_MINUTES.length - 1);
660
+ const backoffMinutes = QUOTA_FALLBACK_LADDER_MINUTES[idx];
661
+ return { delayMs: backoffMinutes * 60 * 1000, source: 'exponential_fallback' };
662
+ }
663
+ /** @internal Test-only. */
664
+ export function _test_setRateLimitInfo(workspaceId, info) {
665
+ latestRateLimitInfo.set(workspaceId, info);
666
+ }
526
667
  function handleQuota(workspaceId, _agentSessionId) {
527
668
  try {
528
669
  updateWorkspaceStatus(workspaceId, 'quota');
@@ -533,16 +674,19 @@ function handleQuota(workspaceId, _agentSessionId) {
533
674
  // The quota state is already signalled by the `error { category: 'quota' }`
534
675
  // AgentEvent that triggered this handler. No legacy `agent:status { quota }`
535
676
  // emit needed.
536
- // 15min first, then 30min, then 60min cap
677
+ // Prefer the actual resetsAt from the last rate_limit event; fall back to
678
+ // the 15/30/60min exponential schedule when that info isn't usable.
537
679
  const retryCount = retryCounts.get(workspaceId) ?? 0;
538
- const backoffMinutes = Math.min(15 * 2 ** retryCount, 60);
539
- const backoffMs = backoffMinutes * 60 * 1000;
680
+ const { delayMs, resetsAt, source } = computeQuotaBackoffMs(workspaceId, retryCount);
681
+ const backoffMs = delayMs;
540
682
  retryCounts.set(workspaceId, retryCount + 1);
541
683
  // Surface the backoff schedule as an ephemeral event so the UI can display
542
684
  // retry count / wait time without polluting the persistent event log.
543
685
  emitEphemeral(workspaceId, 'agent:quota-backoff', {
544
686
  retryCount: retryCount + 1,
545
- backoffMinutes,
687
+ backoffMinutes: Math.round(delayMs / 60_000),
688
+ resetsAt,
689
+ source,
546
690
  });
547
691
  const timer = setTimeout(() => {
548
692
  backoffTimers.delete(workspaceId);
@@ -552,8 +696,13 @@ function handleQuota(workspaceId, _agentSessionId) {
552
696
  return;
553
697
  }
554
698
  try {
555
- const freshWorkingDir = `${freshWs.projectPath}/.worktrees/${freshWs.workingBranch}`;
556
- startAgent(workspaceId, freshWorkingDir, 'Continue the previous task where you left off.', undefined, true);
699
+ if (freshWs.autoLoop) {
700
+ autoLoopService.onQuotaBackoffExpired(workspaceId);
701
+ }
702
+ else {
703
+ const freshWorkingDir = `${freshWs.projectPath}/.worktrees/${freshWs.workingBranch}`;
704
+ startAgent(workspaceId, freshWorkingDir, 'Continue the previous task where you left off.', undefined, true);
705
+ }
557
706
  }
558
707
  catch (err) {
559
708
  console.error(`[orchestrator] Quota retry for workspace '${workspaceId}' failed:`, err);
@@ -597,4 +746,4 @@ export function _runWatchdogForTest() {
597
746
  runWatchdog();
598
747
  }
599
748
  /** Test-only export. Not part of the public module API. */
600
- export const __test__ = { handleEvent };
749
+ export const __test__ = { handleEvent, handleQuota };
@@ -0,0 +1,311 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getDb } from '../db/index.js';
4
+ import * as orchestrator from './agent/orchestrator.js';
5
+ import { emit, emitEphemeral } from './websocket-service.js';
6
+ import { listTasks } from './workspace-service.js';
7
+ const NO_PROGRESS_STALL_THRESHOLD = 3;
8
+ function getRow(workspaceId) {
9
+ const db = getDb();
10
+ const row = db
11
+ .prepare(`SELECT id, project_path, working_branch, model, permission_mode, reasoning_effort,
12
+ status, auto_loop, auto_loop_ready, no_progress_streak, archived_at
13
+ FROM workspaces WHERE id = ?`)
14
+ .get(workspaceId);
15
+ return row ?? null;
16
+ }
17
+ function countPendingTasks(workspaceId) {
18
+ const db = getDb();
19
+ const row = db
20
+ .prepare('SELECT COUNT(*) AS c FROM tasks WHERE workspace_id = ? AND status != ?')
21
+ .get(workspaceId, 'done');
22
+ return row.c;
23
+ }
24
+ function countDoneTasks(workspaceId) {
25
+ const db = getDb();
26
+ const row = db
27
+ .prepare('SELECT COUNT(*) AS c FROM tasks WHERE workspace_id = ? AND status = ?')
28
+ .get(workspaceId, 'done');
29
+ return row.c;
30
+ }
31
+ export function getStatus(workspaceId) {
32
+ const row = getRow(workspaceId);
33
+ if (!row)
34
+ return { auto_loop: false, auto_loop_ready: false, no_progress_streak: 0 };
35
+ return {
36
+ auto_loop: row.auto_loop === 1,
37
+ auto_loop_ready: row.auto_loop_ready === 1,
38
+ no_progress_streak: row.no_progress_streak,
39
+ };
40
+ }
41
+ /**
42
+ * Enable auto-loop for the workspace. Spawns immediately if idle + pending
43
+ * tasks. If the initial spawn fails (e.g. worktree missing, engine misconfig),
44
+ * re-throws so the HTTP caller gets a 4xx instead of a silent 200 — the
45
+ * workspace will already have been auto-disabled by `spawnNextIteration`.
46
+ */
47
+ export function enable(workspaceId) {
48
+ const row = getRow(workspaceId);
49
+ if (!row)
50
+ throw new Error(`Workspace '${workspaceId}' not found`);
51
+ if (row.auto_loop_ready !== 1) {
52
+ throw new Error(`Workspace '${workspaceId}' is not ready for auto-loop (run grooming first)`);
53
+ }
54
+ const db = getDb();
55
+ db.prepare('UPDATE workspaces SET auto_loop = 1, no_progress_streak = 0 WHERE id = ?').run(workspaceId);
56
+ emitEphemeral(workspaceId, 'autoloop:enabled', {});
57
+ const pending = countPendingTasks(workspaceId);
58
+ if (pending === 0)
59
+ return;
60
+ if (orchestrator.hasController(workspaceId))
61
+ return;
62
+ // spawnNextIteration throws on initial spawn failure (see flag).
63
+ spawnNextIteration(workspaceId, { throwOnStartAgentError: true });
64
+ }
65
+ /**
66
+ * Disable auto-loop. Idempotent: if the flag is already 0, returns without
67
+ * emitting a duplicate `autoloop:disabled` event.
68
+ */
69
+ export function disable(workspaceId, reason) {
70
+ const row = getRow(workspaceId);
71
+ if (!row || row.auto_loop !== 1)
72
+ return;
73
+ const db = getDb();
74
+ db.prepare('UPDATE workspaces SET auto_loop = 0 WHERE id = ?').run(workspaceId);
75
+ emitEphemeral(workspaceId, 'autoloop:disabled', { reason });
76
+ }
77
+ /**
78
+ * Route a `session:ended` event into the auto-loop state machine.
79
+ *
80
+ * Called by orchestrator.handleEvent. The delta is the number of tasks that
81
+ * transitioned to `done` during this session (computed from a snapshot taken
82
+ * at `session:started`).
83
+ *
84
+ * When status is `quota` we skip spawning: the orchestrator's handleQuota
85
+ * already scheduled a backoff timer and will call `onQuotaBackoffExpired` once
86
+ * the window closes — that function owns the next spawn in that case.
87
+ */
88
+ export function onSessionEnded(workspaceId, reason, tasksDoneDelta) {
89
+ const row = getRow(workspaceId);
90
+ if (!row)
91
+ return;
92
+ if (row.auto_loop !== 1)
93
+ return;
94
+ // When a quota backoff is in flight (orchestrator.handleQuota scheduled a
95
+ // timer), let that timer own the next spawn so the backoff delay is respected.
96
+ if (row.status === 'quota')
97
+ return;
98
+ if (reason === 'error' || reason === 'killed') {
99
+ disable(workspaceId, 'error');
100
+ return;
101
+ }
102
+ // When grooming hasn't run yet (auto_loop_ready=false), the loop is "armed"
103
+ // but waiting for tasks to be created. Skip streak tracking and task checks —
104
+ // onAutoLoopReadySet() will trigger the first spawn once grooming completes.
105
+ if (row.auto_loop_ready !== 1)
106
+ return;
107
+ const db = getDb();
108
+ let streak;
109
+ if (tasksDoneDelta > 0) {
110
+ db.prepare('UPDATE workspaces SET no_progress_streak = 0 WHERE id = ?').run(workspaceId);
111
+ streak = 0;
112
+ }
113
+ else {
114
+ db.prepare('UPDATE workspaces SET no_progress_streak = no_progress_streak + 1 WHERE id = ?').run(workspaceId);
115
+ streak = row.no_progress_streak + 1;
116
+ }
117
+ if (streak >= NO_PROGRESS_STALL_THRESHOLD) {
118
+ disable(workspaceId, 'stall');
119
+ return;
120
+ }
121
+ if (countPendingTasks(workspaceId) === 0) {
122
+ disable(workspaceId, 'completed');
123
+ return;
124
+ }
125
+ spawnNextIteration(workspaceId);
126
+ }
127
+ /**
128
+ * Re-register auto-loop timers at server boot. For every non-archived workspace
129
+ * with `auto_loop=true` and no active controller, either spawn the next
130
+ * iteration (if pending tasks exist) or disable with reason=completed.
131
+ */
132
+ export function rehydrate() {
133
+ try {
134
+ const db = getDb();
135
+ const rows = db.prepare('SELECT id FROM workspaces WHERE auto_loop = 1 AND archived_at IS NULL').all();
136
+ for (const { id } of rows) {
137
+ try {
138
+ if (orchestrator.hasController(id))
139
+ continue;
140
+ if (countPendingTasks(id) === 0) {
141
+ disable(id, 'completed');
142
+ continue;
143
+ }
144
+ spawnNextIteration(id);
145
+ }
146
+ catch (err) {
147
+ console.error(`[auto-loop-service] rehydrate failed for workspace ${id}:`, err);
148
+ }
149
+ }
150
+ }
151
+ catch (err) {
152
+ console.error('[auto-loop-service] rehydrate failed:', err);
153
+ }
154
+ }
155
+ /** Clear in-memory state on workspace delete. Placeholder — nothing cached today. */
156
+ export function forgetAutoLoopState(workspaceId) {
157
+ void workspaceId;
158
+ }
159
+ // ── Internal ──────────────────────────────────────────────────────────────────
160
+ const PROMPT_TEMPLATE = `[Kōbō auto-loop — iteration #{n}]
161
+
162
+ Current pending task (highest priority, non-acceptance-criterion first):
163
+ - Task ID: {taskId}
164
+ - Title: {taskTitle}
165
+ - Is acceptance criterion: {isAcceptanceCriterion}
166
+
167
+ Your job this iteration:
168
+ 1. Read \`kobo__list_tasks\` to see all tasks and the big picture.
169
+ 2. Implement the SINGLE task above and nothing else. Do not pick a different task.
170
+ 3. Run the project's quality checks (lint, typecheck, tests). Check \`.ai/.git-conventions.md\` for the exact commands if unclear.
171
+ 4. If checks fail, fix until they pass. If blocked, leave the task unchanged and explain in chat.
172
+ 5. Commit with a conventional message (\`feat: [short description]\` or similar per repo conventions).
173
+ 6. Code review gate — BEFORE marking the task done, dispatch an independent code-reviewer subagent via the Task tool with \`subagent_type: "code-reviewer"\` (or \`"superpowers:code-reviewer"\` / \`"pr-review-toolkit:code-reviewer"\` — use whichever exists in this environment; fall back to \`superpowers:requesting-code-review\` skill if none is available). Brief the reviewer with: what you just implemented, the task title, and the commit SHA (via \`git rev-parse HEAD\`). Ask specifically whether the change matches the task scope, whether edge cases are handled, and whether the commit is clean.
174
+ 7. Act on the review:
175
+ - If Critical/Important issues: fix them, amend or add a fix-up commit, re-run checks from step 3. Do NOT mark_task_done.
176
+ - If only Minor issues: fix them if trivial (< 2 min), otherwise note them in the chat and proceed.
177
+ - If approved with no issues: proceed.
178
+ 8. Only if the review cleared (or only minor notes remain), call \`kobo__mark_task_done(taskId="{taskId}")\`.
179
+
180
+ Do NOT modify other tasks' state. Do NOT create a PR. Do NOT skip the checks.
181
+ Do NOT run \`kill\`, \`pkill\`, \`killall\`, \`pgrep -k\`, or any process-killing command — you may tear down the Kōbō server itself or sibling dev servers. If a dev server needs restarting, let the user do it.
182
+ When you're done (success or blocked), end your turn cleanly.`;
183
+ function pickNextTask(workspaceId) {
184
+ const pending = listTasks(workspaceId).filter((t) => t.status !== 'done');
185
+ if (pending.length === 0)
186
+ return null;
187
+ // Rule D: non-acceptance first, each group in sort_order (listTasks orders).
188
+ const nonCriteria = pending.filter((t) => !t.isAcceptanceCriterion);
189
+ const criteria = pending.filter((t) => t.isAcceptanceCriterion);
190
+ return [...nonCriteria, ...criteria][0] ?? null;
191
+ }
192
+ function computeIterationNumber(workspaceId) {
193
+ const done = countDoneTasks(workspaceId);
194
+ const status = getStatus(workspaceId);
195
+ return done + status.no_progress_streak + 1;
196
+ }
197
+ /**
198
+ * Pick the next task, build the prompt, call `orchestrator.startAgent`.
199
+ *
200
+ * When called by `onSessionEnded` / `rehydrate`, `startAgent` throws are
201
+ * swallowed and the loop auto-disables (`reason: 'error'`). When called from
202
+ * `enable` (initial user-driven spawn), we want the HTTP endpoint to surface
203
+ * the failure instead of lying with 200, so the caller passes
204
+ * `throwOnStartAgentError: true` and we re-throw after disabling.
205
+ *
206
+ * Worktree-missing edge: if the worktree directory has been deleted on disk,
207
+ * `orchestrator.startAgent` throws during engine.start — caught below.
208
+ */
209
+ function spawnNextIteration(workspaceId, opts = {}) {
210
+ const row = getRow(workspaceId);
211
+ if (!row)
212
+ return;
213
+ const task = pickNextTask(workspaceId);
214
+ if (!task) {
215
+ disable(workspaceId, 'completed');
216
+ return;
217
+ }
218
+ const iterationNumber = computeIterationNumber(workspaceId);
219
+ const prompt = PROMPT_TEMPLATE.replaceAll('{n}', String(iterationNumber))
220
+ .replaceAll('{taskId}', task.id)
221
+ .replaceAll('{taskTitle}', task.title)
222
+ .replaceAll('{isAcceptanceCriterion}', String(task.isAcceptanceCriterion));
223
+ const worktreePath = path.join(row.project_path, '.worktrees', row.working_branch);
224
+ // Auto-loop iterations always run in auto-accept mode. Plan mode blocks MCP
225
+ // tools (kobo__mark_task_done, etc.) and Edit/Write/Bash — everything the
226
+ // iteration needs — so honoring a 'plan' setting here would deadlock the loop.
227
+ const permissionMode = 'auto-accept';
228
+ // Pre-check: if the worktree directory is gone (user `rm -rf`-ed it),
229
+ // fail loudly rather than letting startAgent throw a deep engine error.
230
+ if (!fs.existsSync(worktreePath)) {
231
+ const msg = `Worktree directory missing: ${worktreePath}`;
232
+ console.error('[auto-loop-service]', msg);
233
+ disable(workspaceId, 'error');
234
+ if (opts.throwOnStartAgentError)
235
+ throw new Error(msg);
236
+ return;
237
+ }
238
+ let agentSessionId;
239
+ try {
240
+ const agent = orchestrator.startAgent(workspaceId, worktreePath, prompt, row.model, false, // resume=false — fresh context for each iteration
241
+ permissionMode, undefined, row.reasoning_effort);
242
+ agentSessionId = agent.agentSessionId;
243
+ }
244
+ catch (err) {
245
+ console.error('[auto-loop-service] startAgent failed:', err);
246
+ disable(workspaceId, 'error');
247
+ if (opts.throwOnStartAgentError)
248
+ throw err;
249
+ return;
250
+ }
251
+ // Persist the iteration prompt as a system-prompt message so the user can
252
+ // see exactly what was sent to Claude when they switch to this auto-loop
253
+ // session in the UI. Tagged with the new agentSessionId so the per-session
254
+ // filter picks it up only on its own session.
255
+ emit(workspaceId, 'user:message', { content: prompt, sender: 'system-prompt' }, agentSessionId);
256
+ const tasksPending = countPendingTasks(workspaceId);
257
+ const tasksDone = countDoneTasks(workspaceId);
258
+ emitEphemeral(workspaceId, 'autoloop:iteration-started', {
259
+ iterationNumber,
260
+ taskId: task.id,
261
+ taskTitle: task.title,
262
+ tasksPending,
263
+ tasksDone,
264
+ });
265
+ }
266
+ /**
267
+ * Called by orchestrator.handleQuota's backoff timer when auto-loop is enabled.
268
+ * Spawns the next auto-loop iteration if the workspace is still in quota status
269
+ * with auto_loop active; no-ops otherwise (race-safe).
270
+ */
271
+ export function onQuotaBackoffExpired(workspaceId) {
272
+ const row = getRow(workspaceId);
273
+ if (!row)
274
+ return;
275
+ if (row.auto_loop !== 1)
276
+ return;
277
+ if (row.status !== 'quota')
278
+ return;
279
+ spawnNextIteration(workspaceId);
280
+ }
281
+ /**
282
+ * Called when a workspace transitions to auto_loop_ready=true (grooming done).
283
+ * If the loop is armed (auto_loop=1) and tasks exist, spawns the first iteration.
284
+ * This handles the creation-time autoLoop flag: the brainstorming session ends
285
+ * without triggering a spawn (auto_loop_ready was false), so this function
286
+ * starts the loop once the user completes grooming.
287
+ */
288
+ export function onAutoLoopReadySet(workspaceId) {
289
+ const row = getRow(workspaceId);
290
+ if (!row)
291
+ return;
292
+ if (row.auto_loop !== 1)
293
+ return;
294
+ if (row.archived_at !== null)
295
+ return;
296
+ if (orchestrator.hasController(workspaceId))
297
+ return;
298
+ if (countPendingTasks(workspaceId) === 0)
299
+ return;
300
+ spawnNextIteration(workspaceId);
301
+ }
302
+ // ── Test-only ─────────────────────────────────────────────────────────────────
303
+ /** @internal */
304
+ export function _test_setAutoLoopReady(workspaceId, ready) {
305
+ const db = getDb();
306
+ db.prepare('UPDATE workspaces SET auto_loop_ready = ? WHERE id = ?').run(ready ? 1 : 0, workspaceId);
307
+ }
308
+ /** @internal */
309
+ export function _test_pickNextTask(workspaceId) {
310
+ return pickNextTask(workspaceId);
311
+ }
@@ -1,5 +1,7 @@
1
1
  import { nanoid } from 'nanoid';
2
2
  import { getDb } from '../db/index.js';
3
+ import * as orchestrator from './agent/orchestrator.js';
4
+ import * as autoLoopService from './auto-loop-service.js';
3
5
  import * as wakeupService from './wakeup-service.js';
4
6
  /** Allowed status transitions per current status. Enforced by updateWorkspaceStatus. */
5
7
  const VALID_TRANSITIONS = {
@@ -36,6 +38,9 @@ function mapWorkspace(row) {
36
38
  favoritedAt: row.favorited_at,
37
39
  tags: parseTags(row.tags),
38
40
  engine: row.engine ?? 'claude-code',
41
+ autoLoop: row.auto_loop === 1,
42
+ autoLoopReady: row.auto_loop_ready === 1,
43
+ noProgressStreak: row.no_progress_streak ?? 0,
39
44
  createdAt: row.created_at,
40
45
  updatedAt: row.updated_at,
41
46
  };
@@ -213,6 +218,11 @@ export function deleteWorkspace(id) {
213
218
  // The DB row is removed via ON DELETE CASCADE, but the timer would
214
219
  // otherwise fire and hit an empty workspace.
215
220
  wakeupService.cancel(id, 'deleted');
221
+ // Drop the cached rate_limit.info so memory doesn't leak on workspace
222
+ // churn. The Map has no FK to clean up for it automatically.
223
+ orchestrator.forgetRateLimitInfo(id);
224
+ orchestrator.forgetTasksDoneSnapshot(id);
225
+ autoLoopService.forgetAutoLoopState(id);
216
226
  const db = getDb();
217
227
  db.prepare('DELETE FROM workspaces WHERE id = ?').run(id);
218
228
  }
@@ -296,6 +306,9 @@ export function archiveWorkspace(id) {
296
306
  }
297
307
  // Cancel any pending wakeup — archived workspaces should not wake up.
298
308
  wakeupService.cancel(id, 'archived');
309
+ // Disable auto-loop — archived workspaces should not keep looping.
310
+ // Idempotent: no-op if auto_loop was already 0.
311
+ autoLoopService.disable(id, 'user-action');
299
312
  const now = new Date().toISOString();
300
313
  db.prepare('UPDATE workspaces SET archived_at = ?, updated_at = ? WHERE id = ?').run(now, now, id);
301
314
  return getWorkspace(id);
@@ -324,6 +337,15 @@ export function setFavorite(id) {
324
337
  }
325
338
  return getWorkspace(id);
326
339
  }
340
+ /** Flip the `auto_loop_ready` flag for a workspace. Used by the grooming MCP tool + the "Force ready" override. */
341
+ export function setAutoLoopReady(id, ready) {
342
+ const workspace = getWorkspace(id);
343
+ if (!workspace)
344
+ throw new Error(`Workspace '${id}' not found`);
345
+ const db = getDb();
346
+ db.prepare('UPDATE workspaces SET auto_loop_ready = ? WHERE id = ?').run(ready ? 1 : 0, id);
347
+ return getWorkspace(id);
348
+ }
327
349
  /** Remove a workspace from favorites. Idempotent: safe to call on a non-favorite, though `updated_at` still refreshes. */
328
350
  export function unsetFavorite(id) {
329
351
  const db = getDb();
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Single source of truth for the auto-loop grooming instructions.
3
+ *
4
+ * Imported BOTH by the client (PREP_AUTOLOOP_PROMPT sent by the "Prepare
5
+ * for auto-loop" button) and by the server (extended brainstorm prompt
6
+ * injected at workspace creation when autoLoop=true). Keeping these two
7
+ * paths aligned was a copy-paste hazard — this file eliminates that.
8
+ *
9
+ * `AUTO_LOOP_GROOMING_STEPS` is the numbered workflow, ready to be spliced
10
+ * into a larger prompt with a one-line intro.
11
+ *
12
+ * `AUTO_LOOP_HARD_RULES` is the trailing hard-rules block, same for both.
13
+ */
14
+ export const AUTO_LOOP_GROOMING_STEPS = `1. Call \`kobo__list_tasks\` FIRST to inspect any pre-existing tasks (they may have been seeded from Notion, a template, or the CreatePage form).
15
+ 2. If tasks already exist: DO NOT delete or recreate them from scratch. Read each one, judge whether it is atomic and implementable in one session with clear completion criteria. Improve them in place:
16
+ - Use \`kobo__update_task\` to rename unclear titles, add completion criteria, or flip \`is_acceptance_criterion\` when needed.
17
+ - Use \`kobo__create_task\` to SPLIT a task that is too large into smaller atomic pieces (keep the original only if it still makes sense, otherwise update it to one of the split pieces and create the rest).
18
+ - Use \`kobo__create_task\` to ADD missing acceptance criteria or missing implementation steps the plan requires.
19
+ 3. If no tasks exist:
20
+ - If a plan file exists in \`docs/superpowers/plans/\` or similar, read it and derive the task list from it.
21
+ - If no plan exists, ask the user what the workspace goal is and propose tasks accordingly.
22
+ - Create the tasks via \`kobo__create_task\`. For each task, decide \`is_acceptance_criterion\` appropriately.
23
+ 4. Call \`kobo__mark_auto_loop_ready\`. This will automatically start the auto-loop, which will pick up the tasks one by one in fresh sessions.`;
24
+ export const AUTO_LOOP_HARD_RULES = `Hard rules:
25
+ - Do NOT touch any source file. No Edit, no Write, no Bash that changes the repo.
26
+ - Do NOT run \`kill\`, \`pkill\`, \`killall\`, \`pgrep -k\`, or any process-killing command — you may tear down the Kōbō server itself or sibling dev servers.
27
+ - Do NOT start implementation even if a plan is ready — auto-loop will drive that afterwards, one task per iteration.
28
+ - If the user asks for changes, re-apply via MCP and re-check. Do NOT mark ready until they approve.`;