@loicngr/kobo 2.1.12 → 2.1.15

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 (113) hide show
  1. package/AGENTS.md +33 -6
  2. package/CHANGELOG.md +12 -0
  3. package/CONFIGURATION.md +34 -4
  4. package/README.md +1 -1
  5. package/dist/mcp-server/kobo-tasks-server.js +43 -1
  6. package/dist/server/db/auto-loop-final-review-schema.js +18 -0
  7. package/dist/server/db/migrations.js +10 -1
  8. package/dist/server/db/review-return-schema.js +18 -1
  9. package/dist/server/db/schema.js +2 -0
  10. package/dist/server/index.js +2 -1
  11. package/dist/server/routes/workspaces.js +61 -1
  12. package/dist/server/services/agent/engines/claude-code/engine.js +61 -5
  13. package/dist/server/services/agent/orchestrator.js +158 -38
  14. package/dist/server/services/auto-loop-final-review-service.js +329 -0
  15. package/dist/server/services/auto-loop-service.js +53 -10
  16. package/dist/server/services/review-launch-runtime.js +17 -0
  17. package/dist/server/services/review-return-service.js +58 -10
  18. package/dist/server/services/review-service.js +59 -11
  19. package/dist/server/services/workspace-service.js +11 -0
  20. package/dist/server/services/workspace-template-service.js +10 -0
  21. package/dist/shared/auto-loop-review.js +1 -0
  22. package/dist/shared/review-return.js +1 -0
  23. package/package.json +1 -1
  24. package/src/client/dist/pwa/assets/ActivityFeed-D7jouiuP.js +2 -0
  25. package/src/client/dist/pwa/assets/AutoLoopFinalReviewSettings-Q-OlSQ1c.css +1 -0
  26. package/src/client/dist/pwa/assets/AutoLoopFinalReviewSettings-lKjfaC6V.js +1 -0
  27. package/src/client/dist/pwa/assets/{ChangelogPage-DD90Nl6c.js → ChangelogPage-BplM7qEB.js} +1 -1
  28. package/src/client/dist/pwa/assets/{ComparisonPanel-D_BQFWiT.js → ComparisonPanel-DxK5v9w_.js} +1 -1
  29. package/src/client/dist/pwa/assets/CreatePage-D5ilP5kt.js +2 -0
  30. package/src/client/dist/pwa/assets/CreatePage-DxOes_cF.css +1 -0
  31. package/src/client/dist/pwa/assets/{DashboardPage-DyvTdmBg.js → DashboardPage-BsP-MATS.js} +1 -1
  32. package/src/client/dist/pwa/assets/DiffViewer-Cd15vFWj.js +8 -0
  33. package/src/client/dist/pwa/assets/{DocumentsPanel-bm639sIu.js → DocumentsPanel-CGrf8Q3H.js} +1 -1
  34. package/src/client/dist/pwa/assets/{DrawerToggleButton-KfwSzjeY.js → DrawerToggleButton-BANeLSsR.js} +1 -1
  35. package/src/client/dist/pwa/assets/GitPanel-CEPV5Qw0.js +3 -0
  36. package/src/client/dist/pwa/assets/{HealthPage-37IHOIW-.js → HealthPage-BVTNx-92.js} +1 -1
  37. package/src/client/dist/pwa/assets/MainLayout-C6nvIQjU.css +1 -0
  38. package/src/client/dist/pwa/assets/MainLayout-DBKtwilW.js +2 -0
  39. package/src/client/dist/pwa/assets/{MemoryPanel-CucZMhjy.js → MemoryPanel-C71rTpv9.js} +1 -1
  40. package/src/client/dist/pwa/assets/{MemoryProposalList-CUQybzVR.js → MemoryProposalList-CoqhUGrv.js} +1 -1
  41. package/src/client/dist/pwa/assets/{QBtnDropdown-x6qPyl_7.js → QBtnDropdown-U9Ey5W8h.js} +1 -1
  42. package/src/client/dist/pwa/assets/{QExpansionItem-D33aoRoG.js → QExpansionItem-Be_A1MT1.js} +1 -1
  43. package/src/client/dist/pwa/assets/{QScrollArea-Di-D6t2u.js → QScrollArea-D4lYzAPQ.js} +1 -1
  44. package/src/client/dist/pwa/assets/{QSelect-MdPXvwm4.js → QSelect-fS0DLOd1.js} +1 -1
  45. package/src/client/dist/pwa/assets/{QTooltip-BhwG7lp2.js → QTooltip-kStOgY0X.js} +1 -1
  46. package/src/client/dist/pwa/assets/{SchedulePanel-qFi9Wxzh.js → SchedulePanel-C7nTGdWE.js} +1 -1
  47. package/src/client/dist/pwa/assets/{SearchPage-DA9nRmsA.js → SearchPage-C6J72pkQ.js} +1 -1
  48. package/src/client/dist/pwa/assets/SettingsPage-SuH-aIzv.js +9 -0
  49. package/src/client/dist/pwa/assets/{SplitWorkspacePage-Brw2XsYJ.js → SplitWorkspacePage-Bqn4gdVw.js} +1 -1
  50. package/src/client/dist/pwa/assets/{TerminalPanel-DVP18-nk.js → TerminalPanel-zLeDCReI.js} +9 -9
  51. package/src/client/dist/pwa/assets/{ToolCallItem-cSHh3p_F.js → ToolCallItem-B8b_Sagj.js} +3 -3
  52. package/src/client/dist/pwa/assets/{TouchPan-C6LZZ3rw.js → TouchPan-Bl8VzIDK.js} +1 -1
  53. package/src/client/dist/pwa/assets/{TourReplayButton-Czlqo18H.js → TourReplayButton-pL0HCboU.js} +1 -1
  54. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-BDYGq0pC.js → WorkspaceAttentionLabels-9Eomx0rQ.js} +1 -1
  55. package/src/client/dist/pwa/assets/WorkspacePage-B248nKIU.css +1 -0
  56. package/src/client/dist/pwa/assets/WorkspacePage-B6clgURl.js +4 -0
  57. package/src/client/dist/pwa/assets/{browser-ifCwUGpE.js → browser-C-PJ4alw.js} +1 -1
  58. package/src/client/dist/pwa/assets/{build-path-tree-BBkGq3lN.js → build-path-tree-SW9rj7B_.js} +1 -1
  59. package/src/client/dist/pwa/assets/custom-sounds-toz-4drX.js +1 -0
  60. package/src/client/dist/pwa/assets/de-Ddxe8cc6.js +17 -0
  61. package/src/client/dist/pwa/assets/{editor.api-CV_seGJR.js → editor.api-BJTPhnh8.js} +1 -1
  62. package/src/client/dist/pwa/assets/{efforts-ttw0bL63.js → efforts-BO9xGn9q.js} +1 -1
  63. package/src/client/dist/pwa/assets/es-DBJIQeWW.js +17 -0
  64. package/src/client/dist/pwa/assets/{expand-template-C8biMRXo.js → expand-template-BOZMtWau.js} +1 -1
  65. package/src/client/dist/pwa/assets/{format-Cu95dbxt.js → format-hEnpRyit.js} +1 -1
  66. package/src/client/dist/pwa/assets/{formatters-C25D1RSd.js → formatters-DD_eFc4z.js} +1 -1
  67. package/src/client/dist/pwa/assets/fr-yBzIIPaQ.js +17 -0
  68. package/src/client/dist/pwa/assets/{freemarker2-DJEFr74q.js → freemarker2-B6nUofvT.js} +1 -1
  69. package/src/client/dist/pwa/assets/{handlebars-C_lzsMjX.js → handlebars-4P3gtM8p.js} +1 -1
  70. package/src/client/dist/pwa/assets/{html-CWmLFAmY.js → html-C9ZzWo-y.js} +1 -1
  71. package/src/client/dist/pwa/assets/i18n-2-ULabk2.js +1 -0
  72. package/src/client/dist/pwa/assets/index-Vk99jtxD.js +20 -0
  73. package/src/client/dist/pwa/assets/it-D44U-PT1.js +17 -0
  74. package/src/client/dist/pwa/assets/{javascript-tsuiEUe-.js → javascript-BGEC5ggd.js} +1 -1
  75. package/src/client/dist/pwa/assets/{liquid-BBr9BvUk.js → liquid-DNtF630O.js} +1 -1
  76. package/src/client/dist/pwa/assets/{mdx-gjBH1cXG.js → mdx-CICwyOd0.js} +1 -1
  77. package/src/client/dist/pwa/assets/{monaco-contributions-CvGcBIsb.js → monaco-contributions-BkuwvgrT.js} +1 -1
  78. package/src/client/dist/pwa/assets/{monaco.contribution-Uw-Lfb8Z.js → monaco.contribution-GX8XA-PE.js} +2 -2
  79. package/src/client/dist/pwa/assets/network-auth-KnWzjYIc.js +1 -0
  80. package/src/client/dist/pwa/assets/notify-theme-CWuZTz4Z.js +1 -0
  81. package/src/client/dist/pwa/assets/{project-color-BDQa2TgU.js → project-color-B1SMFjSe.js} +1 -1
  82. package/src/client/dist/pwa/assets/{python-DlIgD1n6.js → python-n5uPdTVS.js} +1 -1
  83. package/src/client/dist/pwa/assets/{razor-AraFPlpM.js → razor-AOj0nsUt.js} +1 -1
  84. package/src/client/dist/pwa/assets/{typescript-CB8UpXHM.js → typescript-Uhkrqihh.js} +1 -1
  85. package/src/client/dist/pwa/assets/{use-search-index-status-CSREHQM4.js → use-search-index-status-CW0BdpBZ.js} +1 -1
  86. package/src/client/dist/pwa/assets/{use-tours-6yxNVmjH.js → use-tours-CndFlyL-.js} +2 -2
  87. package/src/client/dist/pwa/assets/{use-worktree-restore-BM3Giig1.js → use-worktree-restore-BZxibZS5.js} +1 -1
  88. package/src/client/dist/pwa/assets/{workspace-templates-BPMFvTnQ.js → workspace-templates-W557e1-m.js} +1 -1
  89. package/src/client/dist/pwa/assets/{xml-uRjL1P43.js → xml-B-hnMgEs.js} +1 -1
  90. package/src/client/dist/pwa/assets/{yaml-C9CFR4qV.js → yaml-DqXkTvgm.js} +1 -1
  91. package/src/client/dist/pwa/index.html +1 -1
  92. package/src/client/dist/pwa/sw.js +1 -1
  93. package/src/mcp-server/README.md +26 -0
  94. package/src/mcp-server/kobo-tasks-server.ts +46 -1
  95. package/src/client/dist/pwa/assets/ActivityFeed-BTRzc60o.js +0 -2
  96. package/src/client/dist/pwa/assets/CreatePage-BZAoCukb.js +0 -2
  97. package/src/client/dist/pwa/assets/CreatePage-CdCs8PRS.css +0 -1
  98. package/src/client/dist/pwa/assets/DiffViewer-D0K2PlZD.js +0 -8
  99. package/src/client/dist/pwa/assets/GitPanel-C-Ejlc9_.js +0 -3
  100. package/src/client/dist/pwa/assets/MainLayout-BQOOup9o.css +0 -1
  101. package/src/client/dist/pwa/assets/MainLayout-DXQ1ouYl.js +0 -2
  102. package/src/client/dist/pwa/assets/SettingsPage-DcV6apqE.js +0 -9
  103. package/src/client/dist/pwa/assets/WorkspacePage-BwUkijpS.js +0 -4
  104. package/src/client/dist/pwa/assets/WorkspacePage-Bwfv559o.css +0 -1
  105. package/src/client/dist/pwa/assets/custom-sounds-CeSgjIY7.js +0 -1
  106. package/src/client/dist/pwa/assets/de-CHY2BAfL.js +0 -17
  107. package/src/client/dist/pwa/assets/es-DbcSKp38.js +0 -17
  108. package/src/client/dist/pwa/assets/fr-CyyJ8S2c.js +0 -17
  109. package/src/client/dist/pwa/assets/i18n-CobVQiTM.js +0 -1
  110. package/src/client/dist/pwa/assets/index-ImUH7Bbt.js +0 -20
  111. package/src/client/dist/pwa/assets/it-XQJW2Xkr.js +0 -17
  112. package/src/client/dist/pwa/assets/network-auth-D0Vm6a09.js +0 -1
  113. package/src/client/dist/pwa/assets/notify-theme-PMSxqP6t.js +0 -1
package/AGENTS.md CHANGED
@@ -116,7 +116,8 @@ src/
116
116
  | `tasks` | workspace sub-items: title, status, `is_acceptance_criterion`, sort_order; CASCADE DELETE on workspace |
117
117
  | `agent_sessions` | agent-engine sessions: pid where applicable, engine session id, status, timestamps, model, and name |
118
118
  | `ws_events` | persisted WebSocket events for replay on reconnect: type, payload, session_id, created_at |
119
- | `pending_review_returns` | one-shot return from a review to its original session and LLM configuration (migration v44) |
119
+ | `pending_review_returns` | durable review-to-source return, original LLM configuration and dispatch phase (v44, extended v52) |
120
+ | `auto_loop_final_reviews` | configured final reviewer, review/correction cycle, scoped verdict and progress tracking (v52) |
120
121
  | `pending_wakeups` | one-row-per-workspace scheduler for the `schedule_wakeup` MCP tool: target_at (ISO UTC), prompt, reason; CASCADE DELETE on workspace |
121
122
  | `pending_quota_backoffs` | persisted quota retries and their attempt counts |
122
123
  | `pending_crons` | recurring workspace schedules and their next run |
@@ -276,6 +277,10 @@ not normal cleanup. The SDK still constructs its runtime command; Kōbō's
276
277
  `spawnClaudeCodeProcess` hook tracks actual child exit and forwards stderr.
277
278
  `EngineProcess.closed` waits for both that exit and iterator completion because
278
279
  the SDK's internal cleanup wait is bounded and can return before child exit.
280
+ Liveness must also inspect non-empty SDK content deltas (text, thinking,
281
+ input JSON, signature), not just mapped UI events: long reasoning/tool argument
282
+ generation can otherwise be killed as idle. Do not count empty deltas or neutral
283
+ metadata, or let child deltas cancel a settled parent's drain/grace.
279
284
 
280
285
  **OpenAI Codex** (`codex/`): uses the **`codex app-server` JSON-RPC protocol** (line-delimited JSON over stdio with a long-lived `codex` subprocess). The engine layers are:
281
286
  - `jsonrpc/transport.ts` + `jsonrpc/peer.ts`: generic JSON-RPC 2.0 stdio peer (request correlation, notifications, server-initiated requests)
@@ -363,11 +368,33 @@ Resume selection is scoped to the target engine; never pass a reviewer's native
363
368
  conversation id to the original engine, even after cancellation or restart.
364
369
  The report reassembles text deltas/final snapshots, is bounded to 24,000 characters,
365
370
  and links back to the complete review session through the conversation tool.
366
- The receiving agent is asked to summarize and await instructions before fixes.
367
- The intent is consumed before delivery, preventing repeated ends from sending twice.
368
- Errors restore settings without automatically resuming; explicit stop cancels the
369
- return. Startup restores interrupted-review settings without replaying a possibly
370
- already-delivered handoff. Preserve these guards and the auto-loop interception.
371
+ For a manual review, the receiving agent summarizes and awaits instructions before fixes.
372
+ The durable phase tracks `reviewing`, `ready`, `dispatching`, `unknown` and `blocked`.
373
+ Client disconnect and server shutdown preserve the original session, configurations,
374
+ review prompt and completed return prompt. Startup resumes an interrupted reviewer
375
+ or a known-unsent return after admission and confirmed runtime closure. Claim the
376
+ return before dispatch; confirm it only once the engine accepts the initial input.
377
+ Claude's session initialization is insufficient: wait for a foreground acknowledgement
378
+ correlated with the initial message UUID (`EngineProcess.initialPromptAccepted`).
379
+ Codex's readiness follows the accepted `turn.start` response.
380
+ An interrupted dispatch becomes `unknown`, with explicit retry/cancel controls;
381
+ never automatically resend a possibly delivered summary. Explicit user stop cancels
382
+ the continuation. Errors retain recoverable intent and restore original settings.
383
+
384
+ `auto_loop_final_reviews` (migration v52) adds an optional final gate configured
385
+ before execution. Each pass starts a separate Plan review session with its own
386
+ engine/model/effort/instructions. Only the active reviewer capability may submit
387
+ the structured `kobo__submit_final_review` verdict. Findings become work tasks,
388
+ invalidate finalization, and return to the exact original working session for
389
+ automatic corrections under its original permissions. Repeat verification and a
390
+ fresh review until zero findings, then summarize in the source session before
391
+ completion. The source must come from the actual loop controller/ending writer, including
392
+ adoption of an already-running session; never infer it from UI selection. A
393
+ reviewer resume failure invalidates only its own native conversation, preserving
394
+ other source sessions. Confirmed capacity release wakes manual durable returns
395
+ as well as auto-loops. Preserve the loop's admission, quota, stop and stagnation safeguards;
396
+ prose alone cannot clear the gate. Persist this gate and manual review returns
397
+ across restart without allowing a second writer.
371
398
  `workspace:configuration` is ephemeral and updates all four LLM settings across tabs.
372
399
 
373
400
  ### Delete confirmation
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to Kōbō are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/). Each release is an `## <version>`
5
5
  section — the in-app "What's new" dialog reads this file.
6
6
 
7
+ ## 2.1.15
8
+
9
+ - feat(auto-loop): add durable final reviews and correction cycles
10
+
11
+ ## 2.1.14
12
+
13
+ - fix(auto-loop): correct banner visibility and allow dismissal
14
+
15
+ ## 2.1.13
16
+
17
+ - fix(claude): count streaming generation as watchdog activity
18
+
7
19
  ## 2.1.12
8
20
 
9
21
  - fix(claude): close settled SDK sessions without false watchdogs
package/CONFIGURATION.md CHANGED
@@ -615,9 +615,33 @@ receiving agent summarizes the findings and waits for your instructions before
615
615
  applying fixes. The complete review remains in its own session history.
616
616
 
617
617
  With the option off, subsequent conversation continues with the review LLM.
618
- When automatic return is enabled, stopping the review cancels it. If the review
619
- fails or Kobo restarts during it, the original settings are restored without launching the
620
- original agent automatically; review output already recorded remains available.
618
+ Closing the browser or shutting down Kōbō preserves the automatic return. After a
619
+ server restart, Kōbō resumes the interrupted review and returns its summary to the
620
+ original conversation when capacity and quotas permit. A completed review waiting
621
+ to return keeps its summary. Explicitly stopping the agent cancels the return.
622
+
623
+ If the server stops during summary delivery and cannot confirm whether it arrived,
624
+ the chat displays a pending return with **Retry** and **Cancel**. Inspect the original
625
+ conversation before retrying: it may already contain that summary. A failed review
626
+ also keeps a recoverable return instead of silently losing it.
627
+
628
+ ### Final review for auto-loop
629
+
630
+ Enable **Final review** in the creation form or the workspace's Auto-loop panel.
631
+ Configure the reviewer engine, model, reasoning effort and additional instructions
632
+ in advance. Workspace templates and remembered creation preferences retain these
633
+ settings. Each review uses a separate session in Plan mode and returns to the exact
634
+ working session with its original LLM settings and permissions.
635
+
636
+ After all work and final checks pass, Kōbō launches the reviewer. Its structured
637
+ findings become tasks for the original agent to correct automatically. The agent
638
+ verifies the corrections and completes final checks, then Kōbō launches a fresh
639
+ review. This cycle continues until a review reports zero findings; the original
640
+ session receives the result before auto-loop completes. Repeated unchanged findings
641
+ trigger a diagnostic and bounded further attempts, then block for attention.
642
+ Closing the client or restarting the server preserves this cycle as well as the
643
+ return. A manual review outside auto-loop still summarizes and waits for your
644
+ instructions before applying fixes.
621
645
 
622
646
  ## Workspace templates and duplication
623
647
 
@@ -1285,6 +1309,12 @@ agents, and 30 minutes for tools (including background Bash, workflow and MCP
1285
1309
  jobs). A compaction has a separate absolute 10-minute limit. Waiting for your
1286
1310
  answer or permission pauses the inactivity watchdog.
1287
1311
 
1312
+ Non-empty streaming text, reasoning, tool-argument JSON and signature fragments
1313
+ count as activity even when they are not displayed in chat. Empty deltas and
1314
+ neutral metadata do not reset inactivity, and child output cannot postpone a
1315
+ settled parent's closure. The inactivity limit measures silence, not total time
1316
+ spent generating a large plan or file.
1317
+
1288
1318
  Watchdog notices remain in conversation history, but do not display a red error
1289
1319
  banner. Other failures retain their banners. Watchdog shutdown requests both
1290
1320
  cancellation and closure of the SDK transport. Kōbō still requires confirmed
@@ -1978,7 +2008,7 @@ and across Unicode normalization boundaries.
1978
2008
 
1979
2009
  ## Auto-loop completion and recovery
1980
2010
 
1981
- Auto-loop remains enabled while work is unfinished. Its panel distinguishes grooming, execution and final verification from waiting, blocked, completed and stopped states. All todos and acceptance criteria must be complete, followed by a successful final verification. The final check runs again after task changes or new instructions. Empty custom finalization settings use the built-in final check.
2011
+ Auto-loop remains enabled while work is unfinished. Its panel distinguishes grooming, execution and final verification from waiting, blocked, completed and stopped states. All todos and acceptance criteria must be complete, followed by a successful final verification. The final check runs again after task changes or new instructions. Empty custom finalization settings use the built-in final check. With [Final review](#final-review-for-auto-loop) configured, completion also requires an independent review with zero findings and the summary returned to the original session. Review findings reopen work and final verification before another review.
1982
2012
 
1983
2013
  Completing an auto-loop task through MCP or HTTP requires structured verification: a non-empty `method`, `summary`, and `checks` array with named checks whose `status` is `passed`. A required test that could not run stays open (`not_run`); writing test source alone is insufficient. These are results reported by the agent, not independently executed by Kōbō.
1984
2014
 
package/README.md CHANGED
@@ -19,7 +19,7 @@ Kōbō (工房, "workshop") turns *one agent, one terminal* into a real workflow
19
19
  |---|---|
20
20
  | **🗂️ Isolated worktrees, two engines** | Every workspace is its own git worktree and branch — parallel sessions never collide. Pick Claude Code or OpenAI Codex per workspace, and [continue in a fresh session](#fresh-sessions-and-llm-handoffs) with the same LLM or a different model/engine, preserving the worktree and mission history. |
21
21
  | **💬 Live chat, real diff review** | Streaming responses, inline Edit/Write diffs, a reasoning panel, and a Monaco diff viewer with **inline file editing**, conflict resolution, and one-click `Sync` / `Push` / `Open PR` / `Merge`. |
22
- | **🔁 Auto-loop, cron, wakeups** | Turn an agent loose on the task list: it works through tasks, retries on rate limits, and stops itself when there's nothing left, progress stalls, or it needs you. Cron schedules and one-shot wakeups keep workspaces moving on their own timeline. |
22
+ | **🔁 Auto-loop, cron, wakeups** | Turn an agent loose on the task list: it works through tasks, retries on rate limits, and stops itself when there's nothing left, progress stalls, or it needs you. An optional [independent final review](CONFIGURATION.md#final-review-for-auto-loop) returns findings to the original session for automatic correction and repeats until none remain, with durable recovery after restart. Cron schedules and one-shot wakeups keep workspaces moving on their own timeline. |
23
23
  | **🔀 Create from a PR/MR, or from a ticket** | Pick an open pull/merge request from GitHub, GitLab, or Bitbucket and Kōbō resolves every local conflict for you before spinning up the workspace. Or start straight from a Notion page or a Sentry issue URL. |
24
24
 
25
25
  ## Quick start
@@ -8,6 +8,8 @@ import { WORKSPACE_DIALOGUE_TOOLS } from '../shared/workspace-dialogue-tools.js'
8
8
  import { createTaskHandler, cronListHandler, deleteTaskHandler, getDevServerStatusHandler, getSessionUsageHandler, getSettingsHandler, getTicketSourcesHandler, getWorkspaceInfoHandler, listDocumentsHandler, listTasksHandler, listWorkspaceImagesHandler, listWorkspacesHandler, logThoughtHandler, markAutoLoopReadyHandler, markTaskDoneHandler, readDocumentHandler, readWorkspaceEventsCsvHandler, setWorkspaceAgentDescriptionHandler, updateTaskHandler, } from './kobo-tasks-handlers.js';
9
9
  import { callMemoryTool, isMemoryOutputSuppressed } from './memory-client.js';
10
10
  import { callWorkspaceDialogueTool } from './workspace-dialogue-client.js';
11
+ const finalReviewToken = process.env.KOBO_FINAL_REVIEW_TOKEN;
12
+ const finalReviewSessionId = process.env.KOBO_FINAL_REVIEW_SESSION_ID;
11
13
  const handoffId = process.env.KOBO_HANDOFF_ID;
12
14
  const handoffToken = process.env.KOBO_HANDOFF_TOKEN;
13
15
  const workspaceId = process.env.KOBO_WORKSPACE_ID;
@@ -150,6 +152,33 @@ const TASK_ORDER_PROPERTIES = {
150
152
  },
151
153
  };
152
154
  const WORKSPACE_SCOPED_TOOLS = [
155
+ {
156
+ name: 'submit_final_review',
157
+ description: 'Submit the final structured verdict for this backend-owned auto-loop review, then end your turn. Include all actionable findings; use an empty findings array only when the entire mission is clear.',
158
+ inputSchema: {
159
+ type: 'object',
160
+ properties: {
161
+ summary: { type: 'string', minLength: 1, maxLength: 12000 },
162
+ findings: {
163
+ type: 'array',
164
+ maxItems: 100,
165
+ items: {
166
+ type: 'object',
167
+ properties: {
168
+ severity: { type: 'string', enum: ['critical', 'important', 'minor'] },
169
+ file: { type: 'string', minLength: 1, maxLength: 1000 },
170
+ line: { type: 'integer', minimum: 1 },
171
+ description: { type: 'string', minLength: 1, maxLength: 4000 },
172
+ recommendation: { type: 'string', minLength: 1, maxLength: 4000 },
173
+ },
174
+ required: ['severity', 'file', 'description', 'recommendation'],
175
+ },
176
+ },
177
+ },
178
+ required: ['summary', 'findings'],
179
+ },
180
+ annotations: { destructiveHint: false, openWorldHint: false },
181
+ },
153
182
  {
154
183
  name: 'submit_session_handoff',
155
184
  description: 'Submit the final Markdown handoff requested by Kōbō for this generation turn, then end your turn. Only available during a backend-owned session transfer; does not launch or stop sessions.',
@@ -643,7 +672,9 @@ function availableTools() {
643
672
  : GLOBAL_TOOLS;
644
673
  return tools.filter((tool) => handoffId && handoffToken
645
674
  ? tool.name === 'submit_session_handoff' || tool.annotations?.readOnlyHint === true
646
- : tool.name !== 'submit_session_handoff');
675
+ : finalReviewToken && finalReviewSessionId
676
+ ? tool.name === 'submit_final_review' || tool.annotations?.readOnlyHint === true
677
+ : tool.name !== 'submit_session_handoff' && tool.name !== 'submit_final_review');
647
678
  }
648
679
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: availableTools() }));
649
680
  /** Wrap a successful result as an MCP tool response with JSON text content. */
@@ -675,6 +706,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
675
706
  return fail(`Tool '${name}' requires a workspace-bound MCP session (KOBO_WORKSPACE_ID not set).`);
676
707
  }
677
708
  try {
709
+ if (finalReviewToken && finalReviewSessionId && !availableTools().some((tool) => tool.name === name))
710
+ return fail('This is a read-only final review. Only read tools and submit_final_review are available.');
711
+ if (name === 'submit_final_review') {
712
+ if (!workspaceId || !finalReviewToken || !finalReviewSessionId)
713
+ return fail('No final review is active for this MCP invocation.');
714
+ return ok(await backendRequest('POST', `/api/workspaces/${workspaceId}/auto-loop/final-review/report`, {
715
+ token: finalReviewToken,
716
+ sessionId: finalReviewSessionId,
717
+ report: a,
718
+ }));
719
+ }
678
720
  if (handoffId && handoffToken && !availableTools().some((tool) => tool.name === name))
679
721
  return fail('This turn is reserved for a handoff report. Only read tools and submit_session_handoff are available.');
680
722
  if (name === 'submit_session_handoff') {
@@ -0,0 +1,18 @@
1
+ export function initAutoLoopFinalReviewSchema(db) {
2
+ db.exec(`CREATE TABLE IF NOT EXISTS auto_loop_final_reviews (
3
+ workspace_id TEXT PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE,
4
+ configuration TEXT NOT NULL,
5
+ state TEXT NOT NULL DEFAULT 'pending',
6
+ cycle INTEGER NOT NULL DEFAULT 0,
7
+ findings_count INTEGER,
8
+ reason TEXT,
9
+ review_session_id TEXT REFERENCES agent_sessions(id) ON DELETE SET NULL,
10
+ original_session_id TEXT REFERENCES agent_sessions(id) ON DELETE SET NULL,
11
+ token TEXT,
12
+ verdict TEXT,
13
+ previous_findings TEXT,
14
+ stagnant_cycles INTEGER NOT NULL DEFAULT 0,
15
+ return_prompt TEXT,
16
+ updated_at TEXT NOT NULL
17
+ )`);
18
+ }
@@ -1,7 +1,8 @@
1
+ import { initAutoLoopFinalReviewSchema } from './auto-loop-final-review-schema.js';
1
2
  import { initAutoLoopSchema } from './auto-loop-schema.js';
2
3
  import { initMcpMessageSchema } from './mcp-message-schema.js';
3
4
  import { migrateMemorySchema } from './memory-schema.js';
4
- import { initReviewReturnSchema } from './review-return-schema.js';
5
+ import { initReviewReturnSchema, migrateDurableReviewReturns } from './review-return-schema.js';
5
6
  import { initSchema } from './schema.js';
6
7
  import { initSearchSchema } from './search-schema.js';
7
8
  import { initSessionHandoffSchema } from './session-handoff-schema.js';
@@ -799,6 +800,14 @@ export const migrations = [
799
800
  name: 'persistent-memory-schema',
800
801
  migrate: migrateMemorySchema,
801
802
  },
803
+ {
804
+ version: 52,
805
+ name: 'durable-final-reviews',
806
+ migrate(db) {
807
+ migrateDurableReviewReturns(db);
808
+ initAutoLoopFinalReviewSchema(db);
809
+ },
810
+ },
802
811
  ];
803
812
  /** Current schema version — always equals the highest migration version. */
804
813
  export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
@@ -5,6 +5,23 @@ export function initReviewReturnSchema(db) {
5
5
  original_session_id TEXT NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
6
6
  original_configuration TEXT NOT NULL,
7
7
  review_configuration TEXT NOT NULL,
8
- created_at TEXT NOT NULL
8
+ created_at TEXT NOT NULL,
9
+ phase TEXT NOT NULL DEFAULT 'reviewing',
10
+ review_prompt TEXT,
11
+ return_prompt TEXT,
12
+ last_error TEXT
9
13
  )`);
10
14
  }
15
+ /** v52: retain the return intent and its dispatch boundary across server restarts. */
16
+ export function migrateDurableReviewReturns(db) {
17
+ const columns = new Set(db.prepare('PRAGMA table_info(pending_review_returns)').all().map((row) => row.name));
18
+ for (const [name, declaration] of Object.entries({
19
+ phase: "TEXT NOT NULL DEFAULT 'reviewing'",
20
+ review_prompt: 'TEXT',
21
+ return_prompt: 'TEXT',
22
+ last_error: 'TEXT',
23
+ })) {
24
+ if (!columns.has(name))
25
+ db.exec(`ALTER TABLE pending_review_returns ADD COLUMN ${name} ${declaration}`);
26
+ }
27
+ }
@@ -1,3 +1,4 @@
1
+ import { initAutoLoopFinalReviewSchema } from './auto-loop-final-review-schema.js';
1
2
  import { initAutoLoopSchema } from './auto-loop-schema.js';
2
3
  import { initMcpMessageSchema } from './mcp-message-schema.js';
3
4
  import { initMemorySchema } from './memory-schema.js';
@@ -236,6 +237,7 @@ export function initSchema(db) {
236
237
  initMcpMessageSchema(db);
237
238
  initReviewReturnSchema(db);
238
239
  initAutoLoopSchema(db);
240
+ initAutoLoopFinalReviewSchema(db);
239
241
  initSessionHandoffSchema(db);
240
242
  initMemorySchema(db);
241
243
  }
@@ -34,7 +34,7 @@ import usageRoutes from './routes/usage.js';
34
34
  import voiceRouter from './routes/voice.js';
35
35
  import workspaceTemplatesRouter from './routes/workspace-templates.js';
36
36
  import workspacesRouter from './routes/workspaces.js';
37
- import { getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb, setBackendPort, startAgent, startWatchdog, stopAgentAndWait, stopAllAgents, stopWatchdog, } from './services/agent/orchestrator.js';
37
+ import { getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb, resumePendingReviewReturns, setBackendPort, startAgent, startWatchdog, stopAgentAndWait, stopAllAgents, stopWatchdog, } from './services/agent/orchestrator.js';
38
38
  import * as autoLoopService from './services/auto-loop-service.js';
39
39
  import { startAwaitingUserReminder, stopAwaitingUserReminder } from './services/awaiting-user-reminder-service.js';
40
40
  import { runContentMigrationIfNeeded } from './services/content-migration-service.js';
@@ -240,6 +240,7 @@ const server = serve({
240
240
  // ephemeral or SERVER_PORT-selected port. Quota ownership comes first.
241
241
  restoreRetryCountsFromDb();
242
242
  quotaBackoffService.restoreOnBoot((workspaceId, pending) => autoLoopService.onQuotaBackoffExpired(workspaceId, pending));
243
+ resumePendingReviewReturns();
243
244
  autoLoopService.rehydrate();
244
245
  wakeupService.rehydrate();
245
246
  cronService.restoreOnBoot();
@@ -2,6 +2,8 @@ import { execFile as execFileCb, execFileSync, spawn } from 'node:child_process'
2
2
  import { promisify } from 'node:util';
3
3
  import { nanoid } from 'nanoid';
4
4
  import { isWorkflowPolicy, resolveWorkflowPolicy, WORKFLOW_ACTIONS, } from '../../shared/workflow-policy.js';
5
+ import * as finalReviewService from '../services/auto-loop-final-review-service.js';
6
+ import { getReviewReturnStatus } from '../services/review-return-service.js';
5
7
  import { getSearchIndexStatus, searchEvents } from '../services/search-service.js';
6
8
  import { createSessionHandoff, decideSessionHandoff, getCurrentSessionHandoff, SessionHandoffError, submitSessionHandoff, waitForSessionHandoff, } from '../services/session-handoff-service.js';
7
9
  import { AgentStopError, assertAgentStopped } from '../utils/agent-stop-result.js';
@@ -466,6 +468,14 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
466
468
  let setupScriptConfigured = false;
467
469
  try {
468
470
  const { body, attachments } = await readWorkspaceCreationRequest(c.req.raw);
471
+ if (body.autoLoopFinalReview !== undefined) {
472
+ try {
473
+ finalReviewService.parseFinalReviewConfiguration(body.autoLoopFinalReview);
474
+ }
475
+ catch (error) {
476
+ return c.json({ error: error instanceof Error ? error.message : 'Invalid final review configuration' }, 400);
477
+ }
478
+ }
469
479
  if (body.workflowPolicy !== undefined && !isWorkflowPolicy(body.workflowPolicy))
470
480
  return c.json({ error: 'Invalid workflowPolicy' }, 400);
471
481
  // workingBranch is derived from git when worktreePath is provided, so
@@ -821,6 +831,8 @@ app.post('/', migrationGuard, creationBodyLimit, async (c) => {
821
831
  ...(useReusedWorktree ? {} : { worktreesPath: globalSettings.worktreesPath }),
822
832
  });
823
833
  createdWorkspace = workspace;
834
+ if (body.autoLoopFinalReview !== undefined)
835
+ finalReviewService.configureFinalReview(workspace.id, body.autoLoopFinalReview);
824
836
  // Enable auto-loop before starting the initial brainstorming session so
825
837
  // its model override is available when selecting that session's model.
826
838
  if (body.autoLoop === true) {
@@ -1700,6 +1712,54 @@ app.get('/auto-loop-states', (c) => {
1700
1712
  }
1701
1713
  });
1702
1714
  app.route('/', autoLoopMessagesRoutes);
1715
+ app.get('/:id/auto-loop/final-review', (c) => {
1716
+ const id = c.req.param('id');
1717
+ if (!workspaceService.getWorkspace(id))
1718
+ return c.json({ error: 'Workspace not found' }, 404);
1719
+ return c.json(finalReviewService.getFinalReviewStatus(id));
1720
+ });
1721
+ app.patch('/:id/auto-loop/final-review', migrationGuard, async (c) => {
1722
+ try {
1723
+ const body = await c.req.json();
1724
+ return c.json(finalReviewService.configureFinalReview(c.req.param('id'), body?.configuration));
1725
+ }
1726
+ catch (error) {
1727
+ return c.json({ error: error instanceof Error ? error.message : 'Invalid request' }, error instanceof ReviewRequestError ? error.status : 400);
1728
+ }
1729
+ });
1730
+ app.post('/:id/auto-loop/final-review/report', migrationGuard, async (c) => {
1731
+ try {
1732
+ const body = await c.req.json();
1733
+ return c.json(finalReviewService.submitFinalReviewReport(c.req.param('id'), body.sessionId, body.token, body.report));
1734
+ }
1735
+ catch (error) {
1736
+ return c.json({ error: error instanceof Error ? error.message : 'Invalid review report' }, error instanceof ReviewRequestError ? error.status : 400);
1737
+ }
1738
+ });
1739
+ app.get('/:id/review-return', (c) => {
1740
+ const id = c.req.param('id');
1741
+ if (!workspaceService.getWorkspace(id))
1742
+ return c.json({ error: 'Workspace not found' }, 404);
1743
+ return c.json(getReviewReturnStatus(id));
1744
+ });
1745
+ app.post('/:id/review-return/retry', migrationGuard, (c) => {
1746
+ try {
1747
+ agentManager.retryReviewReturn(c.req.param('id'));
1748
+ return c.json(getReviewReturnStatus(c.req.param('id')));
1749
+ }
1750
+ catch (error) {
1751
+ return c.json({ error: error instanceof Error ? error.message : 'Review recovery failed' }, 409);
1752
+ }
1753
+ });
1754
+ app.post('/:id/review-return/cancel', migrationGuard, (c) => {
1755
+ try {
1756
+ agentManager.cancelReviewReturn(c.req.param('id'));
1757
+ return c.json({ ok: true });
1758
+ }
1759
+ catch (error) {
1760
+ return c.json({ error: error instanceof Error ? error.message : 'Review cancellation failed' }, 409);
1761
+ }
1762
+ });
1703
1763
  // GET /api/workspaces/:id/auto-loop — current auto-loop status for one workspace.
1704
1764
  // Admission inspection never changes deadlines, pending schedules or lifecycle state.
1705
1765
  app.get('/:id/automatic-admission', (c) => {
@@ -1979,7 +2039,7 @@ app.delete('/:id/sessions/:sessionId', migrationGuard, (c) => {
1979
2039
  }
1980
2040
  catch (err) {
1981
2041
  const message = err instanceof Error ? err.message : String(err);
1982
- return c.json({ error: message }, message.includes('active session') ? 409 : 500);
2042
+ return c.json({ error: message }, message.includes('active session') || err instanceof workspaceService.ReviewSessionInUseError ? 409 : 500);
1983
2043
  }
1984
2044
  });
1985
2045
  // POST /api/workspaces/:id/deferred-tool-use/answer — resume a deferred
@@ -75,8 +75,19 @@ class ClaudeInputStream {
75
75
  closed = false;
76
76
  queuedForcedMessages = 0;
77
77
  unansweredMessages = new Set();
78
+ initialUuid;
79
+ initialYielded = false;
80
+ acceptInitial;
81
+ rejectInitial;
82
+ initialPromptAccepted = new Promise((resolve, reject) => {
83
+ this.acceptInitial = resolve;
84
+ this.rejectInitial = reject;
85
+ });
78
86
  constructor(initialPrompt) {
79
- this.messages = [{ message: this.toUserMessage(initialPrompt), forced: false }];
87
+ const initial = this.toUserMessage(initialPrompt);
88
+ this.initialUuid = initial.uuid;
89
+ this.messages = [{ message: initial, forced: false }];
90
+ void this.initialPromptAccepted.catch(() => { });
80
91
  }
81
92
  send(text) {
82
93
  if (this.closed)
@@ -91,6 +102,7 @@ class ClaudeInputStream {
91
102
  wake?.();
92
103
  }
93
104
  close() {
105
+ this.rejectInitial(new Error('Claude ended before confirming consumption of the initial prompt'));
94
106
  this.closed = true;
95
107
  const wake = this.waiting;
96
108
  this.waiting = undefined;
@@ -99,6 +111,19 @@ class ClaudeInputStream {
99
111
  hasUnansweredInput() {
100
112
  return this.queuedForcedMessages > 0 || this.unansweredMessages.size > 0;
101
113
  }
114
+ /** Only provider-correlated foreground frames acknowledge this new prompt, never init/history/children. */
115
+ acknowledgeInitialInput(message) {
116
+ if (!this.initialYielded || ('parent_tool_use_id' in message && message.parent_tool_use_id != null))
117
+ return;
118
+ if (!['assistant', 'stream_event', 'result', 'user'].includes(message.type))
119
+ return;
120
+ const frame = message;
121
+ const ids = [...(frame.user_message_uuids ?? []), ...(frame.user_message_uuid ? [frame.user_message_uuid] : [])];
122
+ if (frame.type === 'user' && frame.uuid)
123
+ ids.push(frame.uuid);
124
+ if (ids.includes(this.initialUuid))
125
+ this.acceptInitial();
126
+ }
102
127
  acknowledgeResult(result) {
103
128
  // One Claude turn can consume several queued prompts. A result counts
104
129
  // turns, not messages: only retire the sends explicitly covered by it.
@@ -133,6 +158,8 @@ class ClaudeInputStream {
133
158
  if (next.forced)
134
159
  this.queuedForcedMessages--;
135
160
  this.unansweredMessages.add(next.message.uuid);
161
+ if (next.message.uuid === this.initialUuid)
162
+ this.initialYielded = true;
136
163
  yield next.message;
137
164
  continue;
138
165
  }
@@ -145,6 +172,24 @@ class ClaudeInputStream {
145
172
  return { type: 'user', uuid: randomUUID(), message: { role: 'user', content: text }, parent_tool_use_id: null };
146
173
  }
147
174
  }
175
+ /** Generation can be active without any user-visible mapped event. */
176
+ function hasStreamingContent(msg) {
177
+ if (msg.type !== 'stream_event' || msg.event.type !== 'content_block_delta')
178
+ return false;
179
+ const delta = msg.event.delta;
180
+ switch (delta.type) {
181
+ case 'text_delta':
182
+ return delta.text.length > 0;
183
+ case 'thinking_delta':
184
+ return delta.thinking.length > 0;
185
+ case 'input_json_delta':
186
+ return delta.partial_json.length > 0;
187
+ case 'signature_delta':
188
+ return delta.signature.length > 0;
189
+ default:
190
+ return false;
191
+ }
192
+ }
148
193
  export function createClaudeCodeEngine() {
149
194
  return {
150
195
  id: 'claude-code',
@@ -211,6 +256,13 @@ export function createClaudeCodeEngine() {
211
256
  // would approve ExitPlanMode and then every edit. Anything that needs a
212
257
  // permission, a question included, is refused outright instead.
213
258
  if (options.readOnly) {
259
+ // This capability-scoped report changes orchestration metadata only.
260
+ // The backend validates its token and current reviewer session again.
261
+ if (toolName === 'mcp__kobo-tasks__submit_final_review' &&
262
+ options.mcpServers?.some((server) => server.name === 'kobo-tasks' &&
263
+ server.env.KOBO_FINAL_REVIEW_TOKEN &&
264
+ server.env.KOBO_FINAL_REVIEW_SESSION_ID))
265
+ return Promise.resolve({ behavior: 'allow', updatedInput: input });
214
266
  return Promise.resolve({
215
267
  behavior: 'deny',
216
268
  message: 'This is a read-only review session: do not modify anything or ask the user. Put the finding in your final report instead.',
@@ -659,13 +711,16 @@ export function createClaudeCodeEngine() {
659
711
  turnLiveness.start();
660
712
  try {
661
713
  for await (const msg of q) {
714
+ inputStream.acknowledgeInitialInput(msg);
662
715
  const events = mapSdkMessage(msg, mapperState);
663
716
  const isForeground = !('parent_tool_use_id' in msg && msg.parent_tool_use_id != null);
717
+ const streamingContent = hasStreamingContent(msg);
664
718
  const foregroundProgress = isForeground &&
665
- events.some((ev) => ev.kind === 'message:text' ||
666
- ev.kind === 'message:thinking' ||
667
- ev.kind === 'tool:call' ||
668
- ev.kind === 'tool:result');
719
+ (streamingContent ||
720
+ events.some((ev) => ev.kind === 'message:text' ||
721
+ ev.kind === 'message:thinking' ||
722
+ ev.kind === 'tool:call' ||
723
+ ev.kind === 'tool:result'));
669
724
  // A parent continuation owns the foreground again. Background
670
725
  // progress notifications alone do not end the between-turn wait.
671
726
  if (foregroundProgress) {
@@ -847,6 +902,7 @@ export function createClaudeCodeEngine() {
847
902
  })();
848
903
  const engineProcess = {
849
904
  ready,
905
+ initialPromptAccepted: inputStream.initialPromptAccepted,
850
906
  closed: iteratorPromise.then(() => runtimeClosed),
851
907
  get pid() {
852
908
  return undefined;