@loicngr/kobo 1.6.8 → 1.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -1
- package/README.md +4 -1
- package/dist/mcp-server/kobo-tasks-handlers.js +19 -1
- package/dist/mcp-server/kobo-tasks-server.js +27 -1
- package/dist/server/db/migrations.js +22 -0
- package/dist/server/db/schema.js +4 -0
- package/dist/server/index.js +2 -0
- package/dist/server/routes/images.js +59 -0
- package/dist/server/routes/workspaces.js +211 -21
- package/dist/server/services/agent/engines/claude-code/args-builder.js +6 -0
- package/dist/server/services/agent/engines/claude-code/engine.js +6 -0
- package/dist/server/services/agent/engines/claude-code/stream-parser.js +162 -0
- package/dist/server/services/agent/orchestrator.js +171 -18
- package/dist/server/services/auto-loop-service.js +311 -0
- package/dist/server/services/workspace-service.js +47 -0
- package/dist/server/utils/git-ops.js +13 -4
- package/dist/shared/auto-loop-prompts.js +28 -0
- package/package.json +1 -1
- package/src/client/dist/spa/assets/{ActivityFeed-ZLFD0ABF.css → ActivityFeed-DtM6pJvz.css} +1 -1
- package/src/client/dist/spa/assets/ActivityFeed-jxfDBgtk.js +7 -0
- package/src/client/dist/spa/assets/{ClosePopup-DTgXzcoa.js → ClosePopup-DkLittac.js} +1 -1
- package/src/client/dist/spa/assets/CreatePage-Bk5v8_20.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-uBHjVyx5.js +2 -0
- package/src/client/dist/spa/assets/DiffViewer-B5spOKjh.js +2 -0
- package/src/client/dist/spa/assets/{HealthPage-BNv_dnMz.js → HealthPage-DnUDXD7f.js} +1 -1
- package/src/client/dist/spa/assets/MainLayout-CDR4Le5c.css +1 -0
- package/src/client/dist/spa/assets/{MainLayout-NzuypipH.js → MainLayout-Cu2p6Yzp.js} +17 -17
- package/src/client/dist/spa/assets/QChip-bl3YRhax.js +1 -0
- package/src/client/dist/spa/assets/{QExpansionItem-HLBjHx-0.js → QExpansionItem-CWw6ZujM.js} +1 -1
- package/src/client/dist/spa/assets/{QItemSection-BzWLL-V-.js → QItemSection-CiY_LK5Y.js} +1 -1
- package/src/client/dist/spa/assets/{QScrollArea-CBW6shMb.js → QScrollArea-DpCqRRE0.js} +1 -1
- package/src/client/dist/spa/assets/QTabPanels-C4bZGqml.js +1 -0
- package/src/client/dist/spa/assets/{QTooltip-DbEBexRN.js → QTooltip-BIDjo2hJ.js} +1 -1
- package/src/client/dist/spa/assets/{SearchPage-B3m_OWli.js → SearchPage-BL03e4yO.js} +1 -1
- package/src/client/dist/spa/assets/SettingsPage-DODqugln.js +1 -0
- package/src/client/dist/spa/assets/{TouchPan-Y_Bxzun2.js → TouchPan-vsl78kxF.js} +1 -1
- package/src/client/dist/spa/assets/{WorkspacePage-CM676R3B.css → WorkspacePage-CI1BxN04.css} +1 -1
- package/src/client/dist/spa/assets/WorkspacePage-CvR1wkIu.js +4 -0
- package/src/client/dist/spa/assets/{build-path-tree-DOPXkGhj.js → build-path-tree-BOfvTwdg.js} +1 -1
- package/src/client/dist/spa/assets/{cssMode-BPObkLMQ.js → cssMode-CoOgcS9Q.js} +1 -1
- package/src/client/dist/spa/assets/{documents-DMvdjtPf.js → documents-Capxg1Is.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-BpCtstKS.js → editor.api-BXQZAhGS.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-C2h6FfOt.js → editor.main-DFavPtYi.js} +3 -3
- package/src/client/dist/spa/assets/{formatters-D7eTm7uK.js → formatters-CX2gvLFv.js} +1 -1
- package/src/client/dist/spa/assets/{freemarker2-DUmHGv4C.js → freemarker2-CxnHsTrj.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-BU6pjzPg.js → handlebars-MdkEOy37.js} +1 -1
- package/src/client/dist/spa/assets/{html-A5-15bWl.js → html-BWqDGW4J.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-C3KkomG3.js → htmlMode-CO3tFPX5.js} +1 -1
- package/src/client/dist/spa/assets/i18n-BshFP-3_.js +1 -0
- package/src/client/dist/spa/assets/index-ljurK0Xv.js +2 -0
- package/src/client/dist/spa/assets/is-DUKatk8N.js +1 -0
- package/src/client/dist/spa/assets/{javascript-ggaOKiy5.js → javascript-I8UtlP5w.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-Bk-QMPGJ.js → jsonMode-Z4_dv7Ex.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-CJdzn-JB.js → liquid-MmYIYsxN.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-D5wRO-st.js → mdx-05Yi5ibq.js} +1 -1
- package/src/client/dist/spa/assets/models-BWwzb9Qz.js +1 -0
- package/src/client/dist/spa/assets/{monaco.contribution-CPqJifAu.js → monaco.contribution-BcmbPJhi.js} +2 -2
- package/src/client/dist/spa/assets/{python-DHI9rQDm.js → python-DApFIC6r.js} +1 -1
- package/src/client/dist/spa/assets/rate-limit-labels-BeAbIcPH.js +10 -0
- package/src/client/dist/spa/assets/{razor-CzQWNzhW.js → razor-IqeohLNL.js} +1 -1
- package/src/client/dist/spa/assets/{scroll-C-Vz5BD9.js → scroll-CYWyxBdv.js} +1 -1
- package/src/client/dist/spa/assets/settings-CAILUJXO.js +1 -0
- package/src/client/dist/spa/assets/{tsMode-DPkpdkNr.js → tsMode-B6nLj3Ks.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-Dgm0x_-O.js → typescript-DHsUK_D5.js} +1 -1
- package/src/client/dist/spa/assets/{use-checkbox-BduGd8xg.js → use-checkbox-B_o-iLG2.js} +1 -1
- package/src/client/dist/spa/assets/{xml-BeXyffrj.js → xml-B_o_LoiA.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-D4UE_1wU.js → yaml-mPCNKMRE.js} +1 -1
- package/src/client/dist/spa/index.html +9 -8
- package/src/mcp-server/kobo-tasks-handlers.ts +24 -1
- package/src/mcp-server/kobo-tasks-server.ts +29 -0
- package/src/client/dist/spa/assets/ActivityFeed-Bn9tpyLw.js +0 -7
- package/src/client/dist/spa/assets/CreatePage-CYtKx6Ji.css +0 -1
- package/src/client/dist/spa/assets/CreatePage-DDPmb3I-.js +0 -2
- package/src/client/dist/spa/assets/DiffViewer-CM3g7W7U.js +0 -2
- package/src/client/dist/spa/assets/MainLayout-BeKCjOA2.css +0 -1
- package/src/client/dist/spa/assets/QChip-1nQ_KMFF.js +0 -1
- package/src/client/dist/spa/assets/QDialog-G448EJG4.js +0 -1
- package/src/client/dist/spa/assets/QTabPanels-Cw4nnIbR.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-CpQm15XA.js +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-BQzk5qfr.js +0 -4
- package/src/client/dist/spa/assets/i18n-CIduhxS0.js +0 -1
- package/src/client/dist/spa/assets/index-QcUb2Iwh.js +0 -2
- package/src/client/dist/spa/assets/models-CwWSex3X.js +0 -1
- package/src/client/dist/spa/assets/rate-limit-labels-Su-L56A2.js +0 -6
- /package/src/client/dist/spa/assets/{QBadge-Di02fu2H.js → QBadge-DqtcDv8D.js} +0 -0
- /package/src/client/dist/spa/assets/{QBtn-CyzfM9-_.js → QBtn-DHwAb18J.js} +0 -0
- /package/src/client/dist/spa/assets/{QItemLabel-Czw5g0px.js → QItemLabel-Codqjisk.js} +0 -0
- /package/src/client/dist/spa/assets/{QList-D2GuTeLl.js → QList-Bl9824vi.js} +0 -0
- /package/src/client/dist/spa/assets/{QPage-BTzNQlb1.js → QPage-Dn4E3GHB.js} +0 -0
- /package/src/client/dist/spa/assets/{QSlideTransition-s6ZkYsLs.js → QSlideTransition-BQxI8l5r.js} +0 -0
- /package/src/client/dist/spa/assets/{QSpace-0zdF1m5x.js → QSpace-BNr0AftG.js} +0 -0
- /package/src/client/dist/spa/assets/{QSpinnerDots-By20ptst.js → QSpinnerDots-DEiRooBD.js} +0 -0
- /package/src/client/dist/spa/assets/{_plugin-vue_export-helper-Cj6tcsj6.js → _plugin-vue_export-helper-r4mAJOHR.js} +0 -0
- /package/src/client/dist/spa/assets/{abap-DiwvWnMr.js → abap-Bgec7Keq.js} +0 -0
- /package/src/client/dist/spa/assets/{apex-CmtZjKlf.js → apex-VBlPwEoQ.js} +0 -0
- /package/src/client/dist/spa/assets/{azcli-DL2My_i-.js → azcli-DKqrEFBx.js} +0 -0
- /package/src/client/dist/spa/assets/{bat-B-nC98wG.js → bat-DdgQWy_0.js} +0 -0
- /package/src/client/dist/spa/assets/{bicep-Ju5MwOgh.js → bicep-CRMM43EB.js} +0 -0
- /package/src/client/dist/spa/assets/{cameligo-8Eu1TyBr.js → cameligo-UatALtML.js} +0 -0
- /package/src/client/dist/spa/assets/{clojure-u-RpMkH3.js → clojure-D8JU08RA.js} +0 -0
- /package/src/client/dist/spa/assets/{coffee-CdA7bbTe.js → coffee-C56wu358.js} +0 -0
- /package/src/client/dist/spa/assets/{cpp-CzNFP8ks.js → cpp-CyZLvhJG.js} +0 -0
- /package/src/client/dist/spa/assets/{csharp-j1LThmcE.js → csharp-BJl3ixva.js} +0 -0
- /package/src/client/dist/spa/assets/{csp-CLRC61y6.js → csp-CxEKxmO-.js} +0 -0
- /package/src/client/dist/spa/assets/{css-r6rC_7P2.js → css-B0t_muXd.js} +0 -0
- /package/src/client/dist/spa/assets/{cypher-CW08XVUh.js → cypher-D1hqiMFD.js} +0 -0
- /package/src/client/dist/spa/assets/{dart-Cs9aL5T_.js → dart-Bz550Pyv.js} +0 -0
- /package/src/client/dist/spa/assets/{dockerfile-BWM0M184.js → dockerfile-CIXgVAuA.js} +0 -0
- /package/src/client/dist/spa/assets/{ecl-MJJuer5P.js → ecl-D9qbvZoA.js} +0 -0
- /package/src/client/dist/spa/assets/{elixir-D2AIuXqn.js → elixir-b2M38fAy.js} +0 -0
- /package/src/client/dist/spa/assets/{flow9-B2H24giC.js → flow9-Dq1UYMkt.js} +0 -0
- /package/src/client/dist/spa/assets/{fsharp-CMk2OIJN.js → fsharp-CFNadkg7.js} +0 -0
- /package/src/client/dist/spa/assets/{go-BrMkuJg0.js → go-dSur1iB2.js} +0 -0
- /package/src/client/dist/spa/assets/{graphql-PSR1UKGv.js → graphql-qyhAo11d.js} +0 -0
- /package/src/client/dist/spa/assets/{hcl-DAQrbDOW.js → hcl-DFzjMyzm.js} +0 -0
- /package/src/client/dist/spa/assets/{ini-0TG5BxW0.js → ini-TdzA8TIl.js} +0 -0
- /package/src/client/dist/spa/assets/{java-rgorz17v.js → java-CSGA9pkE.js} +0 -0
- /package/src/client/dist/spa/assets/{julia-C8VMdHm8.js → julia-9izz5OsY.js} +0 -0
- /package/src/client/dist/spa/assets/{kotlin-CllWo3gX.js → kotlin-DuPK7AtF.js} +0 -0
- /package/src/client/dist/spa/assets/{less-Cgca25AP.js → less-B8d93iCg.js} +0 -0
- /package/src/client/dist/spa/assets/{lexon-D0GHdBaw.js → lexon-DWtEIyu7.js} +0 -0
- /package/src/client/dist/spa/assets/{lua-DmRsNG-P.js → lua-Ciq0OGgt.js} +0 -0
- /package/src/client/dist/spa/assets/{m3-BgL5dNKT.js → m3-Cki6JWj_.js} +0 -0
- /package/src/client/dist/spa/assets/{markdown-BuJfycGS.js → markdown-Cu47xwU0.js} +0 -0
- /package/src/client/dist/spa/assets/{mips-C9m_93PR.js → mips-BM8ui995.js} +0 -0
- /package/src/client/dist/spa/assets/{msdax-CpFHC9OI.js → msdax-DqLio0_c.js} +0 -0
- /package/src/client/dist/spa/assets/{mysql-qFvltsqN.js → mysql-v1wbjJOq.js} +0 -0
- /package/src/client/dist/spa/assets/{objective-c-Bnmr858J.js → objective-c-CQl3PGSB.js} +0 -0
- /package/src/client/dist/spa/assets/{pascal-WP0_D5AO.js → pascal-D4iW0ZtD.js} +0 -0
- /package/src/client/dist/spa/assets/{pascaligo-Blom4Rij.js → pascaligo-BdC9CZdj.js} +0 -0
- /package/src/client/dist/spa/assets/{perl-B-vk8g64.js → perl-BL10m4XD.js} +0 -0
- /package/src/client/dist/spa/assets/{pgsql-Cgvz6v67.js → pgsql-Be_oqVo3.js} +0 -0
- /package/src/client/dist/spa/assets/{php-8a3Lrw9m.js → php-BtvXSFRI.js} +0 -0
- /package/src/client/dist/spa/assets/{pla-DuFqEZ8V.js → pla-B2vUy15C.js} +0 -0
- /package/src/client/dist/spa/assets/{postiats-DkLtSgkp.js → postiats-CbmTTfXr.js} +0 -0
- /package/src/client/dist/spa/assets/{powerquery-BJ1aNepW.js → powerquery-DszLhJGx.js} +0 -0
- /package/src/client/dist/spa/assets/{powershell-rE98k687.js → powershell-B0dYktF6.js} +0 -0
- /package/src/client/dist/spa/assets/{private.use-form-C5G_3nU5.js → private.use-form-Dlb0iQZh.js} +0 -0
- /package/src/client/dist/spa/assets/{protobuf-CUheFacr.js → protobuf-CZvaj1VX.js} +0 -0
- /package/src/client/dist/spa/assets/{pug-LDcAMD8w.js → pug-CPDx1B3S.js} +0 -0
- /package/src/client/dist/spa/assets/{qsharp-DUKSQoR1.js → qsharp-CDP9TFLl.js} +0 -0
- /package/src/client/dist/spa/assets/{r-D-QApv87.js → r-8DbbFX2l.js} +0 -0
- /package/src/client/dist/spa/assets/{redis-SXdDyWR9.js → redis-DRWj9MtJ.js} +0 -0
- /package/src/client/dist/spa/assets/{redshift-Y6lsCryn.js → redshift-C6cElE_5.js} +0 -0
- /package/src/client/dist/spa/assets/{restructuredtext-edObr9a8.js → restructuredtext-W9pS9n3m.js} +0 -0
- /package/src/client/dist/spa/assets/{ruby-CNnUfF-8.js → ruby-BKnzWnk-.js} +0 -0
- /package/src/client/dist/spa/assets/{rust-IHUZWzBr.js → rust-YPCclWwe.js} +0 -0
- /package/src/client/dist/spa/assets/{sb-DrUvY44N.js → sb-BgM4DTFb.js} +0 -0
- /package/src/client/dist/spa/assets/{scala-B4hbXGLM.js → scala-fz1OPLMl.js} +0 -0
- /package/src/client/dist/spa/assets/{scheme-BGrd12j3.js → scheme-8Uz1RIbu.js} +0 -0
- /package/src/client/dist/spa/assets/{scss-x5G1ES4U.js → scss-Djo3IYXr.js} +0 -0
- /package/src/client/dist/spa/assets/{shell-DOehe2Y8.js → shell-CINF5Tx_.js} +0 -0
- /package/src/client/dist/spa/assets/{solidity-BeRvcwWV.js → solidity-GgiNEuUm.js} +0 -0
- /package/src/client/dist/spa/assets/{sophia-DZbkUNjy.js → sophia-Culj97P9.js} +0 -0
- /package/src/client/dist/spa/assets/{sparql-B7_oi5-h.js → sparql-C2ZlpxOY.js} +0 -0
- /package/src/client/dist/spa/assets/{sql-CTlsFWVE.js → sql-BEf5Pg7Y.js} +0 -0
- /package/src/client/dist/spa/assets/{st-DJVEJdPE.js → st-CT6UUoeH.js} +0 -0
- /package/src/client/dist/spa/assets/{swift-CwhT3fYa.js → swift-B5g0xTG3.js} +0 -0
- /package/src/client/dist/spa/assets/{systemverilog-BQN63pkN.js → systemverilog-CEgQz9DR.js} +0 -0
- /package/src/client/dist/spa/assets/{tcl-DqwfpskA.js → tcl-D0qL2L0I.js} +0 -0
- /package/src/client/dist/spa/assets/{touch-B2uuAH_y.js → touch-Bj_Fr4kC.js} +0 -0
- /package/src/client/dist/spa/assets/{twig-BiyenUgc.js → twig-BFUAVf1E.js} +0 -0
- /package/src/client/dist/spa/assets/{typespec-CWOJribt.js → typespec-CjVVcNKm.js} +0 -0
- /package/src/client/dist/spa/assets/{use-id-BmXMngYX.js → use-id-C93QQwrt.js} +0 -0
- /package/src/client/dist/spa/assets/{use-quasar-BBrzedjR.js → use-quasar-Cc4smfg5.js} +0 -0
- /package/src/client/dist/spa/assets/{vb-Cq5F87m3.js → vb-CZJr-DQz.js} +0 -0
- /package/src/client/dist/spa/assets/{vue-i18n-eUDnMrPl.js → vue-i18n-BJlZEYnA.js} +0 -0
- /package/src/client/dist/spa/assets/{wgsl-BAvW2lVr.js → wgsl-ivoXUo2e.js} +0 -0
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
|
@@ -12,7 +12,7 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
|
|
|
12
12
|
## Features
|
|
13
13
|
|
|
14
14
|
- **Isolated git worktrees** — every workspace runs on its own branch in its own directory, so concurrent Claude sessions never step on each other
|
|
15
|
-
- **Pluggable agent engine** — Kōbō talks to agents through an `AgentEngine` contract with a normalised `AgentEvent` stream (`src/server/services/agent/engines/`). Claude Code is the first engine; dropping in another runtime (e.g. the Claude Agent SDK) only requires a new adapter, not a rewrite of the UI or orchestration layer
|
|
15
|
+
- **Pluggable agent engine** — Kōbō talks to agents through an `AgentEngine` contract with a normalised `AgentEvent` stream (`src/server/services/agent/engines/`). Claude Code is the first engine; dropping in another runtime (e.g. the Claude Agent SDK) only requires a new adapter, not a rewrite of the UI or orchestration layer. Migration to the official Claude Agent SDK is tracked in [#9](https://github.com/loicngr/Kobo/issues/9)
|
|
16
16
|
- **Rich chat feed** — live streaming text, thinking blocks, inline tool calls with expandable diffs for Edit/Write, per-turn session cards, markdown rendering, jump-to-previous-user-message button, and infinite scroll-up over persisted history
|
|
17
17
|
- **Task & acceptance criteria tracking** — the agent reports progress through a dedicated MCP server (`kobo-tasks`) that reads and updates tasks directly from the SQLite database
|
|
18
18
|
- **Documents panel** — tree view in the right drawer that surfaces every AI-generated markdown file under `docs/plans/`, `docs/superpowers/`, and `.ai/thoughts/`. Paths mentioned in chat messages are auto-detected against the catalogue and become one-click deep-links into the panel
|
|
@@ -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,28 @@ 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
|
+
},
|
|
115
|
+
{
|
|
116
|
+
version: 13,
|
|
117
|
+
name: 'add-permission-profile-column',
|
|
118
|
+
migrate: (db) => {
|
|
119
|
+
// 'bypass' (default, pre-existing behavior) or 'strict' (respects
|
|
120
|
+
// the project's .claude/settings.json allow/deny lists — needed when
|
|
121
|
+
// the user wants to authorize writes under .claude/** or .github/workflows/**
|
|
122
|
+
// which the CLI hard-denies under --dangerously-skip-permissions).
|
|
123
|
+
db.prepare("ALTER TABLE workspaces ADD COLUMN permission_profile TEXT NOT NULL DEFAULT 'bypass'").run();
|
|
124
|
+
},
|
|
125
|
+
},
|
|
104
126
|
];
|
|
105
127
|
/** Current schema version — always equals the highest migration version. */
|
|
106
128
|
export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
|
package/dist/server/db/schema.js
CHANGED
|
@@ -19,6 +19,10 @@ 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,
|
|
25
|
+
permission_profile TEXT NOT NULL DEFAULT 'bypass',
|
|
22
26
|
created_at TEXT NOT NULL,
|
|
23
27
|
updated_at TEXT NOT NULL
|
|
24
28
|
);
|
package/dist/server/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import settingsRouter from './routes/settings.js';
|
|
|
21
21
|
import templatesRouter from './routes/templates.js';
|
|
22
22
|
import workspacesRouter from './routes/workspaces.js';
|
|
23
23
|
import { getAvailableSkills, reconcileOrphanSessions, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopWatchdog, } from './services/agent/orchestrator.js';
|
|
24
|
+
import * as autoLoopService from './services/auto-loop-service.js';
|
|
24
25
|
import { runContentMigrationIfNeeded } from './services/content-migration-service.js';
|
|
25
26
|
import { createDailyDbBackupIfNeeded } from './services/db-backup-service.js';
|
|
26
27
|
import { startDevServer, stopDevServer } from './services/dev-server-service.js';
|
|
@@ -59,6 +60,7 @@ initProcessCleanup();
|
|
|
59
60
|
reconcileOrphanSessions();
|
|
60
61
|
startWatchdog();
|
|
61
62
|
wakeupService.rehydrate();
|
|
63
|
+
autoLoopService.rehydrate();
|
|
62
64
|
startPrWatcher();
|
|
63
65
|
// Create Hono app
|
|
64
66
|
const app = new Hono();
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
1
3
|
import { Hono } from 'hono';
|
|
2
4
|
import * as imageService from '../services/image-service.js';
|
|
3
5
|
import * as workspaceService from '../services/workspace-service.js';
|
|
@@ -5,6 +7,14 @@ import * as workspaceService from '../services/workspace-service.js';
|
|
|
5
7
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
6
8
|
/** MIME types accepted for image uploads. */
|
|
7
9
|
const ALLOWED_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
|
10
|
+
/** File extension → MIME type, used to serve uploaded images back to the frontend. */
|
|
11
|
+
const EXT_TO_MIME = {
|
|
12
|
+
'.png': 'image/png',
|
|
13
|
+
'.jpg': 'image/jpeg',
|
|
14
|
+
'.jpeg': 'image/jpeg',
|
|
15
|
+
'.gif': 'image/gif',
|
|
16
|
+
'.webp': 'image/webp',
|
|
17
|
+
};
|
|
8
18
|
/** Hono sub-router for workspace image upload and deletion. */
|
|
9
19
|
const app = new Hono();
|
|
10
20
|
// POST /:id/images — upload an image
|
|
@@ -37,6 +47,55 @@ app.post('/:id/images', async (c) => {
|
|
|
37
47
|
return c.json({ error: message }, 500);
|
|
38
48
|
}
|
|
39
49
|
});
|
|
50
|
+
// GET /:id/images/file?path=.ai/images/<file>
|
|
51
|
+
// Serve an uploaded image file from the worktree so the chat feed can show
|
|
52
|
+
// inline previews for `[image: <path>]` tokens the user pastes in messages.
|
|
53
|
+
//
|
|
54
|
+
// Security: only paths under `.ai/images/` are served, the resolved absolute
|
|
55
|
+
// path must stay inside the worktree's images directory (defense against
|
|
56
|
+
// `..` traversal and absolute paths). Anything outside → 400.
|
|
57
|
+
app.get('/:id/images/file', async (c) => {
|
|
58
|
+
try {
|
|
59
|
+
const { id } = c.req.param();
|
|
60
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
61
|
+
if (!workspace) {
|
|
62
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
63
|
+
}
|
|
64
|
+
const requested = c.req.query('path');
|
|
65
|
+
if (!requested || typeof requested !== 'string') {
|
|
66
|
+
return c.json({ error: 'Missing required query param: path' }, 400);
|
|
67
|
+
}
|
|
68
|
+
// Allowlist: only the upload storage layout is served. No symlink escape
|
|
69
|
+
// either — we resolve and check containment.
|
|
70
|
+
if (!/^(\.ai\/images\/|images\/)[^/]/.test(requested) || requested.includes('..')) {
|
|
71
|
+
return c.json({ error: 'Invalid or disallowed image path' }, 400);
|
|
72
|
+
}
|
|
73
|
+
const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
|
|
74
|
+
const imagesRoot = path.resolve(worktreePath, '.ai/images');
|
|
75
|
+
const fullPath = path.resolve(worktreePath, requested);
|
|
76
|
+
// Containment check: fullPath must be a descendant of imagesRoot. `+ path.sep`
|
|
77
|
+
// guards against prefix collisions (`.../images-evil`).
|
|
78
|
+
if (fullPath !== imagesRoot && !fullPath.startsWith(imagesRoot + path.sep)) {
|
|
79
|
+
return c.json({ error: 'Path escapes images root' }, 400);
|
|
80
|
+
}
|
|
81
|
+
let buffer;
|
|
82
|
+
try {
|
|
83
|
+
buffer = await fs.promises.readFile(fullPath);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return c.json({ error: 'Image not found' }, 404);
|
|
87
|
+
}
|
|
88
|
+
const mime = EXT_TO_MIME[path.extname(fullPath).toLowerCase()] ?? 'application/octet-stream';
|
|
89
|
+
c.header('Content-Type', mime);
|
|
90
|
+
// Uploads are immutable content-addressed (uid filename) — cache aggressively.
|
|
91
|
+
c.header('Cache-Control', 'private, max-age=3600, immutable');
|
|
92
|
+
return c.body(new Uint8Array(buffer));
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
96
|
+
return c.json({ error: message }, 500);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
40
99
|
// DELETE /:id/images/:uid — delete an uploaded image
|
|
41
100
|
app.delete('/:id/images/:uid', async (c) => {
|
|
42
101
|
try {
|
|
@@ -4,10 +4,12 @@ const execFileAsync = promisify(execFileCb);
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { Hono } from 'hono';
|
|
7
|
+
import { AUTO_LOOP_GROOMING_STEPS, AUTO_LOOP_HARD_RULES } from '../../shared/auto-loop-prompts.js';
|
|
7
8
|
import { getDb } from '../db/index.js';
|
|
8
9
|
import { migrationGuard } from '../middleware/migration-guard.js';
|
|
9
10
|
import { listEngines } from '../services/agent/engines/registry.js';
|
|
10
11
|
import * as agentManager from '../services/agent/orchestrator.js';
|
|
12
|
+
import * as autoLoopService from '../services/auto-loop-service.js';
|
|
11
13
|
import * as devServerService from '../services/dev-server-service.js';
|
|
12
14
|
import * as notionService from '../services/notion-service.js';
|
|
13
15
|
import { renderPrTemplate } from '../services/pr-template-service.js';
|
|
@@ -453,7 +455,23 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
453
455
|
brainstormPrompt += `\n# Git conventions\nIMPORTANT: Before any git operation (commit, branch, rebase, merge, push), read and apply the conventions defined in \`.ai/.git-conventions.md\`. They are project-specific and override any default behavior. Re-read this file if you're unsure or if context was compacted.\n`;
|
|
454
456
|
}
|
|
455
457
|
brainstormPrompt += `\nIMPORTANT: Start by reading CLAUDE.md and/or AGENTS.md at the project root if they exist — they contain project conventions and instructions you must follow.`;
|
|
456
|
-
|
|
458
|
+
if (body.autoLoop === true) {
|
|
459
|
+
// Auto-loop is armed — brainstorm must end with task seeding + mark-ready,
|
|
460
|
+
// NOT with implementation. The auto-loop will drive implementation after.
|
|
461
|
+
// The grooming steps + hard rules are shared with the PREP_AUTOLOOP_PROMPT
|
|
462
|
+
// sent by the "Prepare for auto-loop" button (src/shared/auto-loop-prompts.ts).
|
|
463
|
+
brainstormPrompt += `\n\nThen brainstorm the implementation approach. Explore the codebase to understand the existing structure. Ask clarifying questions if needed. When you have a clear plan, create a plan file.
|
|
464
|
+
|
|
465
|
+
Auto-loop mode is active for this workspace. After the plan is ready, DO NOT implement anything. Instead:
|
|
466
|
+
|
|
467
|
+
${AUTO_LOOP_GROOMING_STEPS}
|
|
468
|
+
5. Output [BRAINSTORM_COMPLETE] on its own line and end your turn cleanly.
|
|
469
|
+
|
|
470
|
+
${AUTO_LOOP_HARD_RULES}`;
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
brainstormPrompt += `\n\nThen brainstorm the implementation approach. Explore the codebase to understand the existing structure. Ask clarifying questions if needed. When you're done brainstorming and have a clear plan, create a plan file and proceed with implementation. Once you have completed the brainstorming phase, output [BRAINSTORM_COMPLETE] on its own line.`;
|
|
474
|
+
}
|
|
457
475
|
try {
|
|
458
476
|
const agent = agentManager.startAgent(workspace.id, worktreePath, brainstormPrompt, workspace.model, false, workspace.permissionMode, undefined, workspace.reasoningEffort);
|
|
459
477
|
// Persist the initial prompt in the feed so it's visible in the chat,
|
|
@@ -471,6 +489,22 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
471
489
|
}
|
|
472
490
|
}
|
|
473
491
|
}
|
|
492
|
+
// Apply the auto-loop checkbox from CreatePage. Notion-imported workspaces
|
|
493
|
+
// with both todos AND gherkin features auto-unlock `auto_loop_ready=1` —
|
|
494
|
+
// they're considered good enough to drive the loop without grooming.
|
|
495
|
+
if (body.autoLoop === true) {
|
|
496
|
+
const notionProducedTasks = body.notionUrl !== undefined &&
|
|
497
|
+
notionContent != null &&
|
|
498
|
+
notionContent.todos.length > 0 &&
|
|
499
|
+
notionContent.gherkinFeatures.length > 0;
|
|
500
|
+
const db = getDb();
|
|
501
|
+
db.prepare('UPDATE workspaces SET auto_loop = 1, auto_loop_ready = ? WHERE id = ?').run(notionProducedTasks ? 1 : 0, workspace.id);
|
|
502
|
+
// Emit events so the frontend refreshes autoLoopStates without F5.
|
|
503
|
+
wsService.emitEphemeral(workspace.id, 'autoloop:enabled', {});
|
|
504
|
+
if (notionProducedTasks) {
|
|
505
|
+
wsService.emitEphemeral(workspace.id, 'autoloop:ready-flipped', {});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
474
508
|
// Return created workspace with tasks
|
|
475
509
|
const workspaceWithTasks = workspaceService.getWorkspaceWithTasks(workspace.id);
|
|
476
510
|
return c.json(workspaceWithTasks, 201);
|
|
@@ -531,6 +565,81 @@ app.get('/pr-states', (c) => {
|
|
|
531
565
|
return c.json({ error: message }, 500);
|
|
532
566
|
}
|
|
533
567
|
});
|
|
568
|
+
// GET /api/workspaces/auto-loop-states — batch snapshot keyed by workspace id.
|
|
569
|
+
// Used by the drawer + Pinia store. Static path — must be BEFORE /:id.
|
|
570
|
+
app.get('/auto-loop-states', (c) => {
|
|
571
|
+
try {
|
|
572
|
+
const db = getDb();
|
|
573
|
+
const rows = db
|
|
574
|
+
.prepare('SELECT id, auto_loop, auto_loop_ready, no_progress_streak FROM workspaces WHERE archived_at IS NULL')
|
|
575
|
+
.all();
|
|
576
|
+
const out = {};
|
|
577
|
+
for (const r of rows) {
|
|
578
|
+
out[r.id] = {
|
|
579
|
+
auto_loop: r.auto_loop === 1,
|
|
580
|
+
auto_loop_ready: r.auto_loop_ready === 1,
|
|
581
|
+
no_progress_streak: r.no_progress_streak,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
return c.json(out);
|
|
585
|
+
}
|
|
586
|
+
catch (err) {
|
|
587
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
588
|
+
return c.json({ error: message }, 500);
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
// GET /api/workspaces/:id/auto-loop — current auto-loop status for one workspace.
|
|
592
|
+
app.get('/:id/auto-loop', (c) => {
|
|
593
|
+
try {
|
|
594
|
+
return c.json(autoLoopService.getStatus(c.req.param('id')));
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
598
|
+
return c.json({ error: message }, 500);
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
// POST /api/workspaces/:id/auto-loop — enable the loop (user toggle ON).
|
|
602
|
+
app.post('/:id/auto-loop', (c) => {
|
|
603
|
+
try {
|
|
604
|
+
autoLoopService.enable(c.req.param('id'));
|
|
605
|
+
return c.json({ ok: true });
|
|
606
|
+
}
|
|
607
|
+
catch (err) {
|
|
608
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
609
|
+
return c.json({ error: message }, 400);
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
// DELETE /api/workspaces/:id/auto-loop — disable the loop (user toggle OFF).
|
|
613
|
+
app.delete('/:id/auto-loop', (c) => {
|
|
614
|
+
try {
|
|
615
|
+
autoLoopService.disable(c.req.param('id'), 'user-action');
|
|
616
|
+
return c.json({ ok: true });
|
|
617
|
+
}
|
|
618
|
+
catch (err) {
|
|
619
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
620
|
+
return c.json({ error: message }, 500);
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
// POST /api/workspaces/:id/auto-loop-ready — force auto_loop_ready=true.
|
|
624
|
+
// Used by the "Force ready (skip grooming)" UI button AND by the MCP tool
|
|
625
|
+
// `kobo__mark_auto_loop_ready` at the end of a grooming session. Emits a
|
|
626
|
+
// WS event so any live frontend refreshes the toggle state immediately.
|
|
627
|
+
app.post('/:id/auto-loop-ready', (c) => {
|
|
628
|
+
try {
|
|
629
|
+
const id = c.req.param('id');
|
|
630
|
+
const ws = workspaceService.getWorkspace(id);
|
|
631
|
+
if (!ws)
|
|
632
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
633
|
+
workspaceService.setAutoLoopReady(id, true);
|
|
634
|
+
wsService.emitEphemeral(id, 'autoloop:ready-flipped', {});
|
|
635
|
+
autoLoopService.onAutoLoopReadySet(id);
|
|
636
|
+
return c.json({ ok: true });
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
640
|
+
return c.json({ error: message }, 500);
|
|
641
|
+
}
|
|
642
|
+
});
|
|
534
643
|
// GET /api/workspaces/:id/pending-wakeup — returns the pending wakeup or null.
|
|
535
644
|
app.get('/:id/pending-wakeup', (c) => {
|
|
536
645
|
try {
|
|
@@ -747,6 +856,9 @@ app.get('/:id/events', (c) => {
|
|
|
747
856
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
748
857
|
}
|
|
749
858
|
const before = c.req.query('before'); // event ID cursor
|
|
859
|
+
// optional: scope to a session view. Session views also include
|
|
860
|
+
// workspace-level rows where session_id IS NULL (legacy/pre-session items).
|
|
861
|
+
const session = c.req.query('session');
|
|
750
862
|
const limit = Math.min(parseInt(c.req.query('limit') ?? '100', 10) || 100, 500);
|
|
751
863
|
const db = getDb();
|
|
752
864
|
let rows;
|
|
@@ -756,18 +868,29 @@ app.get('/:id/events', (c) => {
|
|
|
756
868
|
if (!cursorRow) {
|
|
757
869
|
return c.json({ events: [], hasMore: false });
|
|
758
870
|
}
|
|
759
|
-
rows =
|
|
760
|
-
|
|
761
|
-
|
|
871
|
+
rows = session
|
|
872
|
+
? db
|
|
873
|
+
.prepare('SELECT * FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL) AND rowid < ? ORDER BY rowid DESC LIMIT ?')
|
|
874
|
+
.all(id, session, cursorRow.rowid, limit)
|
|
875
|
+
: db
|
|
876
|
+
.prepare('SELECT * FROM ws_events WHERE workspace_id = ? AND rowid < ? ORDER BY rowid DESC LIMIT ?')
|
|
877
|
+
.all(id, cursorRow.rowid, limit);
|
|
762
878
|
}
|
|
763
879
|
else {
|
|
764
|
-
// No cursor — return
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
880
|
+
// No cursor — return events. When filtering by session, we want the
|
|
881
|
+
// MOST RECENT events of that session first (so the feed renders from
|
|
882
|
+
// the end), reversed to chronological order below.
|
|
883
|
+
rows = session
|
|
884
|
+
? db
|
|
885
|
+
.prepare('SELECT * FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL) ORDER BY rowid DESC LIMIT ?')
|
|
886
|
+
.all(id, session, limit)
|
|
887
|
+
: db
|
|
888
|
+
.prepare('SELECT * FROM ws_events WHERE workspace_id = ? ORDER BY rowid ASC LIMIT ?')
|
|
889
|
+
.all(id, limit);
|
|
890
|
+
}
|
|
891
|
+
// Reverse to chronological order (we queried DESC for "before" pagination,
|
|
892
|
+
// or for the "session + no cursor" case where we fetched the newest first).
|
|
893
|
+
if (before || session)
|
|
771
894
|
rows.reverse();
|
|
772
895
|
const events = rows.map((row) => {
|
|
773
896
|
let parsedPayload;
|
|
@@ -788,13 +911,25 @@ app.get('/:id/events', (c) => {
|
|
|
788
911
|
});
|
|
789
912
|
// Check if there are more older events beyond what we returned
|
|
790
913
|
let hasMore = false;
|
|
791
|
-
if (
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
914
|
+
if (rows.length > 0) {
|
|
915
|
+
if (before) {
|
|
916
|
+
const firstRow = db.prepare('SELECT rowid FROM ws_events WHERE id = ?').get(rows[0].id);
|
|
917
|
+
if (firstRow) {
|
|
918
|
+
const older = session
|
|
919
|
+
? db
|
|
920
|
+
.prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL) AND rowid < ?')
|
|
921
|
+
.get(id, session, firstRow.rowid)
|
|
922
|
+
: db
|
|
923
|
+
.prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ? AND rowid < ?')
|
|
924
|
+
.get(id, firstRow.rowid);
|
|
925
|
+
hasMore = older.c > 0;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
else if (session) {
|
|
929
|
+
const total = db
|
|
930
|
+
.prepare('SELECT COUNT(*) as c FROM ws_events WHERE workspace_id = ? AND (session_id = ? OR session_id IS NULL)')
|
|
931
|
+
.get(id, session);
|
|
932
|
+
hasMore = total.c > rows.length;
|
|
798
933
|
}
|
|
799
934
|
}
|
|
800
935
|
return c.json({ events, hasMore });
|
|
@@ -898,6 +1033,12 @@ app.patch('/:id', migrationGuard, async (c) => {
|
|
|
898
1033
|
}
|
|
899
1034
|
updated = workspaceService.updateWorkspacePermissionMode(id, body.permissionMode);
|
|
900
1035
|
}
|
|
1036
|
+
if (body.permissionProfile !== undefined) {
|
|
1037
|
+
if (body.permissionProfile !== 'bypass' && body.permissionProfile !== 'strict') {
|
|
1038
|
+
return c.json({ error: "Invalid permission profile. Must be 'bypass' or 'strict'." }, 400);
|
|
1039
|
+
}
|
|
1040
|
+
updated = workspaceService.setPermissionProfile(id, body.permissionProfile);
|
|
1041
|
+
}
|
|
901
1042
|
if (body.status) {
|
|
902
1043
|
updated = workspaceService.updateWorkspaceStatus(id, body.status);
|
|
903
1044
|
}
|
|
@@ -908,8 +1049,9 @@ app.patch('/:id', migrationGuard, async (c) => {
|
|
|
908
1049
|
body.model === undefined &&
|
|
909
1050
|
body.reasoningEffort === undefined &&
|
|
910
1051
|
body.permissionMode === undefined &&
|
|
1052
|
+
body.permissionProfile === undefined &&
|
|
911
1053
|
body.name === undefined) {
|
|
912
|
-
return c.json({ error: 'Missing field: status, model, reasoningEffort, permissionMode, or name' }, 400);
|
|
1054
|
+
return c.json({ error: 'Missing field: status, model, reasoningEffort, permissionMode, permissionProfile, or name' }, 400);
|
|
913
1055
|
}
|
|
914
1056
|
return c.json(updated);
|
|
915
1057
|
}
|
|
@@ -1086,6 +1228,13 @@ app.delete('/:id', migrationGuard, async (c) => {
|
|
|
1086
1228
|
catch {
|
|
1087
1229
|
// Terminal may not exist — ignore
|
|
1088
1230
|
}
|
|
1231
|
+
// Collected best-effort warnings: the DB deletion always proceeds, but
|
|
1232
|
+
// side-effects (worktree, local/remote branches) can fail independently.
|
|
1233
|
+
// We surface a user-friendly message per failure so the UI can show a
|
|
1234
|
+
// sticky toast with a copy-pasteable recovery command — common case:
|
|
1235
|
+
// Docker leaves root-owned files inside the worktree, git worktree
|
|
1236
|
+
// remove fails with EACCES.
|
|
1237
|
+
const warnings = [];
|
|
1089
1238
|
// Remove worktree
|
|
1090
1239
|
const worktreesDir = `${workspace.projectPath}/.worktrees`;
|
|
1091
1240
|
const worktreePath = `${worktreesDir}/${workspace.workingBranch}`;
|
|
@@ -1095,6 +1244,11 @@ app.delete('/:id', migrationGuard, async (c) => {
|
|
|
1095
1244
|
catch (err) {
|
|
1096
1245
|
const message = err instanceof Error ? err.message : String(err);
|
|
1097
1246
|
console.error(`[workspaces] Failed to remove worktree: ${message}`);
|
|
1247
|
+
warnings.push(`Failed to remove worktree directory '${worktreePath}'. The git entry may still reference it. ` +
|
|
1248
|
+
`Fix manually:\n` +
|
|
1249
|
+
` sudo rm -rf '${worktreePath}'\n` +
|
|
1250
|
+
` cd '${workspace.projectPath}' && git worktree prune\n` +
|
|
1251
|
+
`Reason: ${message}`);
|
|
1098
1252
|
}
|
|
1099
1253
|
// Delete local branch if requested
|
|
1100
1254
|
if (body.deleteLocalBranch) {
|
|
@@ -1104,6 +1258,9 @@ app.delete('/:id', migrationGuard, async (c) => {
|
|
|
1104
1258
|
catch (err) {
|
|
1105
1259
|
const message = err instanceof Error ? err.message : String(err);
|
|
1106
1260
|
console.error(`[workspaces] Failed to delete local branch: ${message}`);
|
|
1261
|
+
warnings.push(`Failed to delete local branch '${workspace.workingBranch}'. Fix manually:\n` +
|
|
1262
|
+
` cd '${workspace.projectPath}' && git branch -D '${workspace.workingBranch}'\n` +
|
|
1263
|
+
`Reason: ${message}`);
|
|
1107
1264
|
}
|
|
1108
1265
|
}
|
|
1109
1266
|
// Delete remote branch if requested
|
|
@@ -1114,11 +1271,20 @@ app.delete('/:id', migrationGuard, async (c) => {
|
|
|
1114
1271
|
catch (err) {
|
|
1115
1272
|
const message = err instanceof Error ? err.message : String(err);
|
|
1116
1273
|
console.error(`[workspaces] Failed to delete remote branch: ${message}`);
|
|
1274
|
+
warnings.push(`Failed to delete remote branch '${workspace.workingBranch}'. Fix manually:\n` +
|
|
1275
|
+
` cd '${workspace.projectPath}' && git push origin --delete '${workspace.workingBranch}'\n` +
|
|
1276
|
+
`Reason: ${message}`);
|
|
1117
1277
|
}
|
|
1118
1278
|
}
|
|
1119
1279
|
// Delete workspace from DB (cascades to tasks, sessions, events)
|
|
1120
1280
|
workspaceService.deleteWorkspace(id);
|
|
1121
|
-
|
|
1281
|
+
// When everything worked cleanly we keep the legacy 204 response so
|
|
1282
|
+
// existing clients aren't surprised by a JSON body. Warnings promote the
|
|
1283
|
+
// response to 200 so the body is readable.
|
|
1284
|
+
if (warnings.length === 0) {
|
|
1285
|
+
return new Response(null, { status: 204 });
|
|
1286
|
+
}
|
|
1287
|
+
return c.json({ ok: true, warnings }, 200);
|
|
1122
1288
|
}
|
|
1123
1289
|
catch (err) {
|
|
1124
1290
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1364,6 +1530,20 @@ app.post('/:id/resync-branch', (c) => {
|
|
|
1364
1530
|
if (!actual || actual === workspace.workingBranch) {
|
|
1365
1531
|
return c.json({ ok: true, changed: false, workingBranch: workspace.workingBranch });
|
|
1366
1532
|
}
|
|
1533
|
+
// Branch was renamed in-place by the agent (`git branch -m ...`). The
|
|
1534
|
+
// worktree directory is still at .worktrees/<old-name>; move it so it
|
|
1535
|
+
// matches the new ref, otherwise Kōbō's path resolver (projectPath +
|
|
1536
|
+
// .worktrees + workingBranch) breaks and subsequent session spawns fail
|
|
1537
|
+
// with ENOENT on .mcp.json. Best-effort: if the move fails (dir already
|
|
1538
|
+
// moved, lockfile, dirty tree), we still update the DB so git ops stay
|
|
1539
|
+
// aligned with the current ref name — the user can repair the dir manually.
|
|
1540
|
+
const newWorktreePath = path.join(workspace.projectPath, '.worktrees', actual);
|
|
1541
|
+
try {
|
|
1542
|
+
gitOps.moveWorktree(workspace.projectPath, worktreePath, newWorktreePath);
|
|
1543
|
+
}
|
|
1544
|
+
catch (err) {
|
|
1545
|
+
console.error('[workspaces] resync-branch: moveWorktree failed (DB update proceeds):', err);
|
|
1546
|
+
}
|
|
1367
1547
|
const updated = workspaceService.updateWorkingBranch(id, actual);
|
|
1368
1548
|
return c.json({ ok: true, changed: true, workingBranch: updated.workingBranch });
|
|
1369
1549
|
}
|
|
@@ -1380,9 +1560,19 @@ app.post('/:id/push', async (c) => {
|
|
|
1380
1560
|
if (!workspace) {
|
|
1381
1561
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1382
1562
|
}
|
|
1563
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1564
|
+
const force = body?.force === true;
|
|
1383
1565
|
const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
|
|
1384
1566
|
try {
|
|
1385
|
-
|
|
1567
|
+
// Only pass an options arg when force is requested — keeps the
|
|
1568
|
+
// no-options call shape identical to before for callers/tests that
|
|
1569
|
+
// assert on argument count.
|
|
1570
|
+
if (force) {
|
|
1571
|
+
gitOps.pushBranch(worktreePath, workspace.workingBranch, { force: true });
|
|
1572
|
+
}
|
|
1573
|
+
else {
|
|
1574
|
+
gitOps.pushBranch(worktreePath, workspace.workingBranch);
|
|
1575
|
+
}
|
|
1386
1576
|
}
|
|
1387
1577
|
catch (err) {
|
|
1388
1578
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -24,6 +24,12 @@ export function buildClaudeArgs(input) {
|
|
|
24
24
|
if (input.permissionMode === 'plan') {
|
|
25
25
|
args.push('--permission-mode', 'plan');
|
|
26
26
|
}
|
|
27
|
+
else if (input.permissionProfile === 'strict') {
|
|
28
|
+
// Strict profile: respect the project's settings.json allow/deny.
|
|
29
|
+
// `acceptEdits` auto-accepts Edit/Write but enforces the allow list,
|
|
30
|
+
// so `Edit(.claude/**)` and similar take effect (unlike in bypass mode).
|
|
31
|
+
args.push('--permission-mode', 'acceptEdits');
|
|
32
|
+
}
|
|
27
33
|
else if (input.skipPermissions) {
|
|
28
34
|
args.push('--dangerously-skip-permissions');
|
|
29
35
|
}
|