@loicngr/kobo 1.7.0 → 1.7.2
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/README.md +9 -4
- package/dist/mcp-server/kobo-tasks-handlers.js +2 -1
- package/dist/server/routes/health.js +2 -2
- package/dist/server/routes/settings.js +2 -1
- package/dist/server/routes/workspaces.js +5 -11
- package/dist/server/services/agent/engines/claude-code/engine.js +6 -0
- package/dist/server/services/agent/engines/claude-code/resolve-binary.js +50 -0
- package/dist/server/services/agent/orchestrator.js +14 -5
- package/dist/server/services/auto-loop-service.js +2 -2
- package/dist/server/services/dev-server-service.js +2 -2
- package/dist/server/services/pr-watcher-service.js +63 -13
- package/dist/server/services/settings-service.js +62 -1
- package/dist/server/services/wakeup-service.js +2 -2
- package/dist/server/services/workspace-service.js +23 -1
- package/dist/server/services/worktree-service.js +17 -7
- package/dist/server/utils/git-ops.js +12 -4
- package/dist/server/utils/worktree-paths.js +134 -0
- package/dist/shared/consts.js +1 -0
- package/package.json +1 -1
- package/src/client/dist/spa/assets/ActivityFeed-B85xav_e.js +7 -0
- package/src/client/dist/spa/assets/ClosePopup-BP025_cK.js +1 -0
- package/src/client/dist/spa/assets/{CreatePage-U6TtJzNe.js → CreatePage-DyR33jFM.js} +2 -2
- package/src/client/dist/spa/assets/{DiffViewer-Di85TBIi.js → DiffViewer-CqhpTkym.js} +3 -3
- package/src/client/dist/spa/assets/{HealthPage-B7aWFxAZ.js → HealthPage-CkHv5qMK.js} +1 -1
- package/src/client/dist/spa/assets/{MainLayout-Dba6SdpU.css → MainLayout-B07zv82Z.css} +1 -1
- package/src/client/dist/spa/assets/{MainLayout-BHBrz4c9.js → MainLayout-l91ohFQA.js} +4 -4
- package/src/client/dist/spa/assets/{QBadge-fsQ2AokU.js → QBadge-DWH42dbo.js} +1 -1
- package/src/client/dist/spa/assets/{QBtn-DHwAb18J.js → QBtn-a6jxWjmW.js} +1 -1
- package/src/client/dist/spa/assets/{QCheckbox-CcY7ZSk9.js → QCheckbox-D5jfsxLV.js} +1 -1
- package/src/client/dist/spa/assets/{QChip-BhT0W2Dg.js → QChip-ByxK0Tuf.js} +1 -1
- package/src/client/dist/spa/assets/QExpansionItem-BaQJkGb-.js +1 -0
- package/src/client/dist/spa/assets/{QIcon-B0-pH3Qs.js → QIcon-BJuyqdsT.js} +1 -1
- package/src/client/dist/spa/assets/QInput-Cm5-AGQ4.js +1 -0
- package/src/client/dist/spa/assets/{QItemLabel-DWwenW2S.js → QItemLabel-DrTxqTqV.js} +1 -1
- package/src/client/dist/spa/assets/{QItemSection-KFAnxzMK.js → QItemSection-5YpFpPDm.js} +1 -1
- package/src/client/dist/spa/assets/{QList-NmIE6Rd9.js → QList-D0FtnQJI.js} +1 -1
- package/src/client/dist/spa/assets/QMenu-DgWZe7Uh.js +1 -0
- package/src/client/dist/spa/assets/QPage-ChUKoaKe.js +1 -0
- package/src/client/dist/spa/assets/{QRadio-DaZhdLCg.js → QRadio-B3aKjCVu.js} +1 -1
- package/src/client/dist/spa/assets/QScrollArea-usfgatuS.js +1 -0
- package/src/client/dist/spa/assets/{QSpace-COlmM_4F.js → QSpace-CLtL3aPy.js} +1 -1
- package/src/client/dist/spa/assets/{QSpinnerDots-DwtnRN2r.js → QSpinnerDots-CszPQQ9J.js} +1 -1
- package/src/client/dist/spa/assets/QTabPanels-CjpZTIJg.js +1 -0
- package/src/client/dist/spa/assets/{QToggle-CGpiJLDJ.js → QToggle-1-N9qWq4.js} +1 -1
- package/src/client/dist/spa/assets/QTooltip-D_hSPb7r.js +1 -0
- package/src/client/dist/spa/assets/{SearchPage-CVm-sqxH.js → SearchPage-B1WhFCUf.js} +1 -1
- package/src/client/dist/spa/assets/SettingsPage-B7S5fXGG.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-kHd651y8.css +1 -0
- package/src/client/dist/spa/assets/TouchPan-1PETKHN0.js +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-D3MBshNH.js +4 -0
- package/src/client/dist/spa/assets/{WorkspacePage-DQxGe62K.css → WorkspacePage-d_B0-LNG.css} +1 -1
- package/src/client/dist/spa/assets/{build-path-tree-CdY1A6aP.js → build-path-tree-w3SEPAbh.js} +1 -1
- package/src/client/dist/spa/assets/{cssMode-BVNBMOxh.js → cssMode-B6CD4qMI.js} +1 -1
- package/src/client/dist/spa/assets/{documents-D6A3wRry.js → documents-kx0vLfSG.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-D6Vfp5yv.js → editor.api-wizjkvCK.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-CTCYF6V4.js → editor.main-Bn6fpPLF.js} +3 -3
- package/src/client/dist/spa/assets/expand-template-Cu5GSLCM.js +1 -0
- package/src/client/dist/spa/assets/{formatters-ejxELb0M.js → formatters-BD0_hovB.js} +1 -1
- package/src/client/dist/spa/assets/{freemarker2-nmzwPmzi.js → freemarker2-DW-DFUis.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-CI9lR7Ef.js → handlebars-CSSQFRHS.js} +1 -1
- package/src/client/dist/spa/assets/{html-BQ21REnv.js → html-Ba5lfQna.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-io5J5Qr1.js → htmlMode-ocrlHn5h.js} +1 -1
- package/src/client/dist/spa/assets/i18n-CqK8B0Nz.js +1 -0
- package/src/client/dist/spa/assets/index-DE3PxEjy.js +2 -0
- package/src/client/dist/spa/assets/{javascript--u9PDBCv.js → javascript-DL3j24x3.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-DBG5llk4.js → jsonMode-CtFp2BJe.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-DxAS4nYF.js → liquid-B_GGNnlJ.js} +1 -1
- package/src/client/dist/spa/assets/marked.esm-D4t0_2pc.js +60 -0
- package/src/client/dist/spa/assets/{mdx-BNXTiODW.js → mdx-BXe8MrIz.js} +1 -1
- package/src/client/dist/spa/assets/models-BMOYJtwv.js +1 -0
- package/src/client/dist/spa/assets/{monaco.contribution-CT3LAK0J.js → monaco.contribution-DSSRKV2r.js} +2 -2
- package/src/client/dist/spa/assets/notifications-CG-oL2m2.js +1 -0
- package/src/client/dist/spa/assets/{python-DztNww13.js → python-DPtBXcrE.js} +1 -1
- package/src/client/dist/spa/assets/{razor-Cyr82NZF.js → razor-y1p5VjhT.js} +1 -1
- package/src/client/dist/spa/assets/{tsMode-CbQVgsIP.js → tsMode-CV2CQlAd.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-UHOe4d1S.js → typescript-DsjWQLAN.js} +1 -1
- package/src/client/dist/spa/assets/use-checkbox-D7zmRxGI.js +1 -0
- package/src/client/dist/spa/assets/{use-id-CDuXkR0Z.js → use-id-CuaR1RiE.js} +1 -1
- package/src/client/dist/spa/assets/use-panel-D2MjPZiL.js +1 -0
- package/src/client/dist/spa/assets/{xml-DC88eFpV.js → xml-AQhpP8em.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-DSTsIRJr.js → yaml-zZFlU7RD.js} +1 -1
- package/src/client/dist/spa/index.html +11 -13
- package/src/client/dist/spa/sounds/ca_va_peter.mp3 +0 -0
- package/src/client/dist/spa/sounds/dry-fart.mp3 +0 -0
- package/src/client/dist/spa/sounds/faaah.mp3 +0 -0
- package/src/client/dist/spa/sounds/for-shure.mp3 +0 -0
- package/src/client/dist/spa/sounds/travail_termine.mp3 +0 -0
- package/src/mcp-server/kobo-tasks-handlers.ts +2 -1
- package/src/client/dist/spa/assets/ActivityFeed-CIJPN8TH.js +0 -7
- package/src/client/dist/spa/assets/ClosePopup-DzB3mDtj.js +0 -1
- package/src/client/dist/spa/assets/QExpansionItem-VS4b4eY6.js +0 -1
- package/src/client/dist/spa/assets/QInput-D4WJro4e.js +0 -1
- package/src/client/dist/spa/assets/QMenu-CchbRXbp.js +0 -1
- package/src/client/dist/spa/assets/QPage-Cu7zkfc6.js +0 -1
- package/src/client/dist/spa/assets/QResizeObserver-Cf79V-VZ.js +0 -1
- package/src/client/dist/spa/assets/QScrollArea-DrVTDLU0.js +0 -1
- package/src/client/dist/spa/assets/QTabPanels-HXz-evuj.js +0 -1
- package/src/client/dist/spa/assets/QTooltip-DjJYMTkN.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-ayDKGo9H.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-wTBCvK6t.css +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-xaVy8s5i.js +0 -4
- package/src/client/dist/spa/assets/expand-template-vHV2iwXf.js +0 -1
- package/src/client/dist/spa/assets/i18n-Do8Kn8n0.js +0 -1
- package/src/client/dist/spa/assets/index-C_e7KOYh.js +0 -2
- package/src/client/dist/spa/assets/is-BbsvEMaT.js +0 -1
- package/src/client/dist/spa/assets/marked.esm-DuOsJx63.js +0 -60
- package/src/client/dist/spa/assets/models-DNYEhFF7.js +0 -1
- package/src/client/dist/spa/assets/settings-Dbx1_ksA.js +0 -1
- package/src/client/dist/spa/assets/symbols-BVRrMH2r.js +0 -1
- package/src/client/dist/spa/assets/use-checkbox-DzHmcu7s.js +0 -1
- package/src/client/dist/spa/assets/use-panel-Br8QNRMk.js +0 -1
- /package/src/client/dist/spa/assets/{_plugin-vue_export-helper-B8bB5DBd.js → _plugin-vue_export-helper-Cj6tcsj6.js} +0 -0
- /package/src/client/dist/spa/assets/{abap-DzK-OTGh.js → abap-DiwvWnMr.js} +0 -0
- /package/src/client/dist/spa/assets/{apex-Bj60_dRt.js → apex-CmtZjKlf.js} +0 -0
- /package/src/client/dist/spa/assets/{azcli-B6NwaBAZ.js → azcli-DL2My_i-.js} +0 -0
- /package/src/client/dist/spa/assets/{bat-bf7wXV68.js → bat-B-nC98wG.js} +0 -0
- /package/src/client/dist/spa/assets/{bicep-C_bg8UgA.js → bicep-Ju5MwOgh.js} +0 -0
- /package/src/client/dist/spa/assets/{cameligo-CTWw4D4B.js → cameligo-8Eu1TyBr.js} +0 -0
- /package/src/client/dist/spa/assets/{clojure-CgdPoH0r.js → clojure-u-RpMkH3.js} +0 -0
- /package/src/client/dist/spa/assets/{coffee-gHQfdA5M.js → coffee-CdA7bbTe.js} +0 -0
- /package/src/client/dist/spa/assets/{cpp-BM4Jj4aW.js → cpp-CzNFP8ks.js} +0 -0
- /package/src/client/dist/spa/assets/{csharp-D8-bh4Cd.js → csharp-j1LThmcE.js} +0 -0
- /package/src/client/dist/spa/assets/{csp-CXBxRx0n.js → csp-CLRC61y6.js} +0 -0
- /package/src/client/dist/spa/assets/{css-DKjIxrmY.js → css-r6rC_7P2.js} +0 -0
- /package/src/client/dist/spa/assets/{cypher-C5e5inIh.js → cypher-CW08XVUh.js} +0 -0
- /package/src/client/dist/spa/assets/{dart-BhRHHm4x.js → dart-Cs9aL5T_.js} +0 -0
- /package/src/client/dist/spa/assets/{dockerfile-DW5REF8E.js → dockerfile-BWM0M184.js} +0 -0
- /package/src/client/dist/spa/assets/{ecl-Bw4Hg3n_.js → ecl-MJJuer5P.js} +0 -0
- /package/src/client/dist/spa/assets/{elixir-DHmoBvpZ.js → elixir-D2AIuXqn.js} +0 -0
- /package/src/client/dist/spa/assets/{flow9-BsFExz3v.js → flow9-B2H24giC.js} +0 -0
- /package/src/client/dist/spa/assets/{fsharp-BaeLhgfq.js → fsharp-CMk2OIJN.js} +0 -0
- /package/src/client/dist/spa/assets/{go-Bd-NFKIC.js → go-BrMkuJg0.js} +0 -0
- /package/src/client/dist/spa/assets/{graphql-DZVerJfy.js → graphql-PSR1UKGv.js} +0 -0
- /package/src/client/dist/spa/assets/{hcl-CAVzrZfH.js → hcl-DAQrbDOW.js} +0 -0
- /package/src/client/dist/spa/assets/{ini-CyXdX58t.js → ini-0TG5BxW0.js} +0 -0
- /package/src/client/dist/spa/assets/{java-B5pNgvhy.js → java-rgorz17v.js} +0 -0
- /package/src/client/dist/spa/assets/{julia-XRhmV3AN.js → julia-C8VMdHm8.js} +0 -0
- /package/src/client/dist/spa/assets/{kobo-commands-DiUm1Y34.js → kobo-commands-w8VepGvD.js} +0 -0
- /package/src/client/dist/spa/assets/{kotlin-DOd3J5vr.js → kotlin-CllWo3gX.js} +0 -0
- /package/src/client/dist/spa/assets/{less-veZSnyw6.js → less-Cgca25AP.js} +0 -0
- /package/src/client/dist/spa/assets/{lexon-QWGkuK0H.js → lexon-D0GHdBaw.js} +0 -0
- /package/src/client/dist/spa/assets/{lua-CYGpjuO5.js → lua-DmRsNG-P.js} +0 -0
- /package/src/client/dist/spa/assets/{m3-yNnrZkdc.js → m3-BgL5dNKT.js} +0 -0
- /package/src/client/dist/spa/assets/{markdown-BCSWEPSX.js → markdown-BuJfycGS.js} +0 -0
- /package/src/client/dist/spa/assets/{mips-OpYmcC30.js → mips-C9m_93PR.js} +0 -0
- /package/src/client/dist/spa/assets/{msdax-2oxoTO9Z.js → msdax-CpFHC9OI.js} +0 -0
- /package/src/client/dist/spa/assets/{mysql-5KlC-K_9.js → mysql-qFvltsqN.js} +0 -0
- /package/src/client/dist/spa/assets/{objective-c-CcDCgtLx.js → objective-c-Bnmr858J.js} +0 -0
- /package/src/client/dist/spa/assets/{pascal-BZGsbaEV.js → pascal-WP0_D5AO.js} +0 -0
- /package/src/client/dist/spa/assets/{pascaligo-DtD5qU3G.js → pascaligo-Blom4Rij.js} +0 -0
- /package/src/client/dist/spa/assets/{perl-C1jNNS3E.js → perl-B-vk8g64.js} +0 -0
- /package/src/client/dist/spa/assets/{pgsql-CT0fhiZa.js → pgsql-Cgvz6v67.js} +0 -0
- /package/src/client/dist/spa/assets/{php-D6DrXoPM.js → php-8a3Lrw9m.js} +0 -0
- /package/src/client/dist/spa/assets/{pla-b3-HN2pF.js → pla-DuFqEZ8V.js} +0 -0
- /package/src/client/dist/spa/assets/{postiats-Bin2ApVS.js → postiats-DkLtSgkp.js} +0 -0
- /package/src/client/dist/spa/assets/{powerquery-7ASnn-ZG.js → powerquery-BJ1aNepW.js} +0 -0
- /package/src/client/dist/spa/assets/{powershell-t4p7sU1H.js → powershell-rE98k687.js} +0 -0
- /package/src/client/dist/spa/assets/{protobuf-BUGeWa_j.js → protobuf-CUheFacr.js} +0 -0
- /package/src/client/dist/spa/assets/{pug-BuKcgC9s.js → pug-LDcAMD8w.js} +0 -0
- /package/src/client/dist/spa/assets/{qsharp-DSMtI_O7.js → qsharp-DUKSQoR1.js} +0 -0
- /package/src/client/dist/spa/assets/{r-DMlFgn7A.js → r-D-QApv87.js} +0 -0
- /package/src/client/dist/spa/assets/{redis-cXItkC5u.js → redis-SXdDyWR9.js} +0 -0
- /package/src/client/dist/spa/assets/{redshift-BZVbW7HE.js → redshift-Y6lsCryn.js} +0 -0
- /package/src/client/dist/spa/assets/{restructuredtext-BzjxwS8h.js → restructuredtext-edObr9a8.js} +0 -0
- /package/src/client/dist/spa/assets/{ruby-C5nyLV4l.js → ruby-CNnUfF-8.js} +0 -0
- /package/src/client/dist/spa/assets/{rust-BcmMsHdf.js → rust-IHUZWzBr.js} +0 -0
- /package/src/client/dist/spa/assets/{sb-Dnb1iy6B.js → sb-DrUvY44N.js} +0 -0
- /package/src/client/dist/spa/assets/{scala-anMIFYpA.js → scala-B4hbXGLM.js} +0 -0
- /package/src/client/dist/spa/assets/{scheme-BItQTe08.js → scheme-BGrd12j3.js} +0 -0
- /package/src/client/dist/spa/assets/{scss-BOv51BJ5.js → scss-x5G1ES4U.js} +0 -0
- /package/src/client/dist/spa/assets/{shell-BsRYRTNN.js → shell-DOehe2Y8.js} +0 -0
- /package/src/client/dist/spa/assets/{solidity-BtuLgGDx.js → solidity-BeRvcwWV.js} +0 -0
- /package/src/client/dist/spa/assets/{sophia-B0Vkc5MF.js → sophia-DZbkUNjy.js} +0 -0
- /package/src/client/dist/spa/assets/{sparql-B7lvkZQM.js → sparql-B7_oi5-h.js} +0 -0
- /package/src/client/dist/spa/assets/{sql-DvP5MpA3.js → sql-CTlsFWVE.js} +0 -0
- /package/src/client/dist/spa/assets/{st-GVUeyB3U.js → st-DJVEJdPE.js} +0 -0
- /package/src/client/dist/spa/assets/{swift-DSPIoCjm.js → swift-CwhT3fYa.js} +0 -0
- /package/src/client/dist/spa/assets/{systemverilog-Icj2-k23.js → systemverilog-BQN63pkN.js} +0 -0
- /package/src/client/dist/spa/assets/{tcl-Cd8KQcm-.js → tcl-DqwfpskA.js} +0 -0
- /package/src/client/dist/spa/assets/{touch-Co9pfjUU.js → touch-HRdTUO2o.js} +0 -0
- /package/src/client/dist/spa/assets/{twig-CBHmt8z3.js → twig-BiyenUgc.js} +0 -0
- /package/src/client/dist/spa/assets/{typespec-Ckc037mq.js → typespec-CWOJribt.js} +0 -0
- /package/src/client/dist/spa/assets/{use-quasar-Cc4smfg5.js → use-quasar-Sdcq6zzV.js} +0 -0
- /package/src/client/dist/spa/assets/{vb-B97GW9Wb.js → vb-Cq5F87m3.js} +0 -0
- /package/src/client/dist/spa/assets/{vue-i18n-eUDnMrPl.js → vue-i18n-BcfTCFFS.js} +0 -0
- /package/src/client/dist/spa/assets/{wgsl-DIKmb3YH.js → wgsl-BAvW2lVr.js} +0 -0
- /package/src/client/dist/spa/{notification.mp3 → sounds/hey.mp3} +0 -0
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
|
|
|
11
11
|
|
|
12
12
|
## Features
|
|
13
13
|
|
|
14
|
-
- **Isolated git worktrees** — every workspace runs on its own branch in its own directory, so concurrent Claude sessions never step on each other
|
|
14
|
+
- **Isolated git worktrees** — every workspace runs on its own branch in its own directory, with a configurable global worktrees root for new workspaces, so concurrent Claude sessions never step on each other
|
|
15
15
|
- **Pluggable agent engine** — Kōbō talks to agents through an `AgentEngine` contract with a normalised `AgentEvent` stream (`src/server/services/agent/engines/`). The `claude-code` engine runs on the official [`@anthropic-ai/claude-agent-sdk`](https://github.com/anthropics/claude-agent-sdk-typescript); adding a second runtime (e.g. Codex) only requires a new adapter, not a rewrite of the UI or orchestration layer
|
|
16
16
|
- **Interactive `AskUserQuestion`** — when the agent invokes `AskUserQuestion`, Kōbō pauses the session via the SDK's `defer` pattern, surfaces a question panel in the UI, and resumes the agent once the user answers. The session does not occupy any resources while it waits
|
|
17
17
|
- **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
|
|
@@ -32,7 +32,7 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
|
|
|
32
32
|
- **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
|
|
33
33
|
- **Archive instead of delete** — soft-remove workspaces without losing the worktree, branches, or history; unarchive restores the exact pre-archive state
|
|
34
34
|
- **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. **E2E grooming** — when a project declares an E2E framework in Settings (Cypress, Playwright, Vitest, etc.), the grooming phase injects an `[E2E] ` test sub-task between every parent task; each iteration then runs the matching E2E suite as part of its acceptance check
|
|
35
|
-
- **Attach existing worktrees** — Kōbō detects orphan worktrees
|
|
35
|
+
- **Attach existing worktrees** — Kōbō detects orphan git worktrees for the selected project (created outside Kōbō, or left over from an earlier install) and lets you attach them to a new workspace from the creation form, picking up the existing branch and folder instead of cloning a new one
|
|
36
36
|
- **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
|
|
37
37
|
- **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
|
|
38
38
|
|
|
@@ -252,7 +252,7 @@ See [`AGENTS.md`](./AGENTS.md) for a deeper dive into conventions, data model, W
|
|
|
252
252
|
|
|
253
253
|
| Table | Purpose |
|
|
254
254
|
|---|---|
|
|
255
|
-
| `workspaces` | the unit of work — branch, status, model, engine, `archived_at`, `favorited_at`, `tags`, Notion link, … |
|
|
255
|
+
| `workspaces` | the unit of work — branch, `worktree_path`, status, model, engine, `archived_at`, `favorited_at`, `tags`, Notion link, … |
|
|
256
256
|
| `tasks` | workspace sub-items — tasks and acceptance criteria |
|
|
257
257
|
| `agent_sessions` | agent runs — pid, `engine_session_id`, lifecycle |
|
|
258
258
|
| `ws_events` | persisted WebSocket events (chat history, `agent:event` stream, user messages) for replay on reconnect |
|
|
@@ -269,9 +269,10 @@ The MCP server reads and writes the same SQLite database as the main backend. Is
|
|
|
269
269
|
|
|
270
270
|
## Configuration
|
|
271
271
|
|
|
272
|
-
Kōbō reads settings from `~/.config/kobo/settings.json` (or falls back to defaults). Global settings
|
|
272
|
+
Kōbō reads settings from `~/.config/kobo/settings.json` (or falls back to defaults). Global settings define defaults, with per-project overrides for project-scoped fields:
|
|
273
273
|
|
|
274
274
|
- `defaultModel` — Claude model to use (e.g. `claude-opus-4-6`)
|
|
275
|
+
- `worktreesPath` — where new workspace worktrees are created. Defaults to `.worktrees`, resolved relative to the project. Absolute Linux/macOS paths, Windows paths (`C:\kobo\worktrees`, UNC shares), `$HOME/...`, `${HOME}/...`, `~/...`, and `%USERPROFILE%\...` are accepted. Paths containing parent-directory traversal (`..`) or drive-relative Windows syntax (`C:foo`) are rejected.
|
|
275
276
|
- `prPromptTemplate` — template rendered when opening a PR via the `/open-pr` endpoint; supports `{{pr_number}}`, `{{pr_url}}`, `{{branch_name}}`, `{{diff_stats}}`, `{{commits}}`, etc.
|
|
276
277
|
- `gitConventions` — markdown-formatted git conventions written to `.ai/.git-conventions.md` in every workspace so the agent follows them when committing
|
|
277
278
|
- `devServer` — per-project `startCommand` / `stopCommand` for launching workspace-scoped dev servers
|
|
@@ -288,6 +289,10 @@ This is a personal tool, but PRs and issues are welcome. Before submitting:
|
|
|
288
289
|
|
|
289
290
|
CI runs lint + type check + tests on every PR to `develop`.
|
|
290
291
|
|
|
292
|
+
## Release
|
|
293
|
+
|
|
294
|
+
Releases are cut from `main`. Bump `package.json` and `package-lock.json` on `develop`, merge `develop` into `main`, then push `main`. The release workflow builds, tests, publishes the current package version to npm, tags it as `v<version>`, and creates the GitHub Release. If the npm version or tag already exists, the workflow fails before publishing.
|
|
295
|
+
|
|
291
296
|
## License
|
|
292
297
|
|
|
293
298
|
GNU General Public License v3.0 or later. See [`LICENSE`](./LICENSE) for the full text.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { nanoid } from 'nanoid';
|
|
4
|
+
import { resolveWorkspaceWorktreePath } from '../server/utils/worktree-paths.js';
|
|
4
5
|
/** Allowed task status values. */
|
|
5
6
|
export const VALID_TASK_STATUSES = ['pending', 'in_progress', 'done'];
|
|
6
7
|
function rowToDto(row) {
|
|
@@ -163,7 +164,7 @@ export function getWorkspaceInfoHandler(db, workspaceId) {
|
|
|
163
164
|
projectPath: row.project_path,
|
|
164
165
|
sourceBranch: row.source_branch,
|
|
165
166
|
workingBranch: row.working_branch,
|
|
166
|
-
worktreePath: row.worktree_path ??
|
|
167
|
+
worktreePath: row.worktree_path ?? resolveWorkspaceWorktreePath(row.project_path, row.working_branch),
|
|
167
168
|
status: row.status,
|
|
168
169
|
model: row.model,
|
|
169
170
|
notionUrl: row.notion_url,
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
3
|
import { Hono } from 'hono';
|
|
5
4
|
import { getDb } from '../db/index.js';
|
|
6
5
|
import { SCHEMA_VERSION } from '../db/migrations.js';
|
|
7
6
|
import { getGlobalSettings, SETTINGS_SCHEMA_VERSION } from '../services/settings-service.js';
|
|
8
7
|
import { getDbPath, getKoboHome } from '../utils/paths.js';
|
|
8
|
+
import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
9
9
|
const app = new Hono();
|
|
10
10
|
function checkClaudeCli() {
|
|
11
11
|
try {
|
|
@@ -51,7 +51,7 @@ app.get('/report', (c) => {
|
|
|
51
51
|
for (const ws of workspaces) {
|
|
52
52
|
if (ws.archived_at)
|
|
53
53
|
continue;
|
|
54
|
-
const wtPath = ws.worktree_path ??
|
|
54
|
+
const wtPath = ws.worktree_path ?? resolveWorkspaceWorktreePath(ws.project_path, ws.working_branch);
|
|
55
55
|
if (!fs.existsSync(wtPath)) {
|
|
56
56
|
worktreesMissing.push({ workspaceId: ws.id, name: ws.name, path: wtPath, exists: false });
|
|
57
57
|
}
|
|
@@ -45,7 +45,8 @@ app.put('/global', async (c) => {
|
|
|
45
45
|
}
|
|
46
46
|
catch (err) {
|
|
47
47
|
const message = err instanceof Error ? err.message : String(err);
|
|
48
|
-
|
|
48
|
+
const status = err instanceof Error && err.name === 'InvalidWorktreesPathError' ? 400 : 500;
|
|
49
|
+
return c.json({ error: message }, status);
|
|
49
50
|
}
|
|
50
51
|
});
|
|
51
52
|
// GET /api/settings/projects — list all projects
|
|
@@ -23,6 +23,7 @@ import * as wsService from '../services/websocket-service.js';
|
|
|
23
23
|
import * as workspaceService from '../services/workspace-service.js';
|
|
24
24
|
import * as worktreeService from '../services/worktree-service.js';
|
|
25
25
|
import * as gitOps from '../utils/git-ops.js';
|
|
26
|
+
import { resolveSiblingWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
26
27
|
/** Hono sub-router for workspace CRUD, tasks, agent lifecycle, git operations, and PR creation. */
|
|
27
28
|
const app = new Hono();
|
|
28
29
|
/** Tracks workspaces currently running a setup script to prevent concurrent executions. */
|
|
@@ -197,6 +198,7 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
197
198
|
reasoningEffort: body.reasoningEffort,
|
|
198
199
|
agentPermissionMode: resolveCreateAgentPermissionMode(body.agentPermissionMode, body.projectPath, globalSettings),
|
|
199
200
|
engine: body.engine,
|
|
201
|
+
...(useReusedWorktree ? {} : { worktreesPath: globalSettings.worktreesPath }),
|
|
200
202
|
});
|
|
201
203
|
// Auto-tag the workspace based on its creation source — `notion` when
|
|
202
204
|
// imported from a Notion page, `sentry` when bootstrapped from a Sentry
|
|
@@ -287,7 +289,7 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
287
289
|
}
|
|
288
290
|
else {
|
|
289
291
|
try {
|
|
290
|
-
worktreePath = worktreeService.createWorktree(body.projectPath, workingBranch, body.sourceBranch);
|
|
292
|
+
worktreePath = worktreeService.createWorktree(body.projectPath, workingBranch, body.sourceBranch, globalSettings.worktreesPath);
|
|
291
293
|
}
|
|
292
294
|
catch (err) {
|
|
293
295
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1725,11 +1727,7 @@ app.post('/:id/rename-branch', async (c) => {
|
|
|
1725
1727
|
// Sibling rename: keep the same worktrees-root, swap the branch leaf.
|
|
1726
1728
|
// Cannot use `path.dirname` directly because branches with slashes
|
|
1727
1729
|
// (e.g. `feature/x`) make the dirname end one level too deep.
|
|
1728
|
-
const
|
|
1729
|
-
const worktreesRoot = oldWorktreePath.endsWith(oldSuffix)
|
|
1730
|
-
? oldWorktreePath.slice(0, -oldSuffix.length)
|
|
1731
|
-
: path.join(workspace.projectPath, '.worktrees');
|
|
1732
|
-
const newWorktreePath = path.join(worktreesRoot, newName);
|
|
1730
|
+
const newWorktreePath = resolveSiblingWorkspaceWorktreePath(workspace.projectPath, oldWorktreePath, workspace.workingBranch, newName);
|
|
1733
1731
|
// Reject early if the target name is already in use — either as a local
|
|
1734
1732
|
// branch or on origin. Avoids git's generic "already exists" error and
|
|
1735
1733
|
// protects against the same silent-fallback trap the create flow has.
|
|
@@ -1799,11 +1797,7 @@ app.post('/:id/resync-branch', (c) => {
|
|
|
1799
1797
|
// if the move fails (dir already moved, lockfile, dirty tree), we still
|
|
1800
1798
|
// update the DB so git ops stay aligned with the current ref name — the
|
|
1801
1799
|
// user can repair the dir manually.
|
|
1802
|
-
const
|
|
1803
|
-
const worktreesRoot = worktreePath.endsWith(oldSuffix)
|
|
1804
|
-
? worktreePath.slice(0, -oldSuffix.length)
|
|
1805
|
-
: path.join(workspace.projectPath, '.worktrees');
|
|
1806
|
-
const newWorktreePath = path.join(worktreesRoot, actual);
|
|
1800
|
+
const newWorktreePath = resolveSiblingWorkspaceWorktreePath(workspace.projectPath, worktreePath, workspace.workingBranch, actual);
|
|
1807
1801
|
try {
|
|
1808
1802
|
gitOps.moveWorktree(workspace.projectPath, worktreePath, newWorktreePath);
|
|
1809
1803
|
workspaceService.updateWorktreePath(id, newWorktreePath);
|
|
@@ -4,6 +4,7 @@ import { CLAUDE_CODE_CAPABILITIES } from './capabilities.js';
|
|
|
4
4
|
import { createMapperState, mapSdkMessage } from './event-mapper.js';
|
|
5
5
|
import { buildClaudeOptions } from './options-builder.js';
|
|
6
6
|
import { buildPreCompactCustomInstructions } from './precompact-hook.js';
|
|
7
|
+
import { resolveClaudeBinaryPath } from './resolve-binary.js';
|
|
7
8
|
function toMcpServersMap(specs) {
|
|
8
9
|
if (!specs || specs.length === 0)
|
|
9
10
|
return undefined;
|
|
@@ -112,6 +113,11 @@ export function createClaudeCodeEngine() {
|
|
|
112
113
|
},
|
|
113
114
|
});
|
|
114
115
|
sdkOptions.abortController = abortController;
|
|
116
|
+
// Override the SDK's libc-blind binary resolution on Linux glibc — see
|
|
117
|
+
// resolve-binary.ts for the full rationale. No-op on macOS/Windows/musl.
|
|
118
|
+
const explicitBinary = resolveClaudeBinaryPath();
|
|
119
|
+
if (explicitBinary)
|
|
120
|
+
sdkOptions.pathToClaudeCodeExecutable = explicitBinary;
|
|
115
121
|
const q = query({ prompt: effectivePrompt, options: sdkOptions });
|
|
116
122
|
let discoveredSessionId;
|
|
117
123
|
// A throwing onEvent handler (e.g. DB query against a closed connection
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
const localRequire = createRequire(import.meta.url);
|
|
3
|
+
/**
|
|
4
|
+
* Detect the live host's platform/arch/libc from `process`. `glibcVersionRuntime`
|
|
5
|
+
* lives in the diagnostic report header on glibc systems and is absent on musl.
|
|
6
|
+
* Available since Node 12; Kōbō requires Node ≥ 20 (see AGENTS.md).
|
|
7
|
+
*/
|
|
8
|
+
export function detectPlatform() {
|
|
9
|
+
const report = process.report;
|
|
10
|
+
const glibcVersion = report?.getReport().header?.glibcVersionRuntime;
|
|
11
|
+
return {
|
|
12
|
+
platform: process.platform,
|
|
13
|
+
arch: process.arch,
|
|
14
|
+
isGlibc: typeof glibcVersion === 'string' && glibcVersion.length > 0,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Resolve an explicit path to the Claude CLI binary that matches the host
|
|
19
|
+
* libc — or `undefined` to fall back to the SDK's built-in resolution.
|
|
20
|
+
*
|
|
21
|
+
* Why this exists: on Linux glibc systems npm installs both
|
|
22
|
+
* `@anthropic-ai/claude-agent-sdk-linux-${arch}` (glibc) and
|
|
23
|
+
* `@anthropic-ai/claude-agent-sdk-linux-${arch}-musl` because npm doesn't
|
|
24
|
+
* filter `optionalDependencies` by the `libc` field. The SDK's internal
|
|
25
|
+
* resolver tries the musl variant first and returns its path; that binary
|
|
26
|
+
* then ENOENTs at exec because its dynamic linker (`/lib/ld-musl-*.so.1`)
|
|
27
|
+
* is absent on glibc systems, surfacing the misleading "Claude Code native
|
|
28
|
+
* binary not found" error.
|
|
29
|
+
*
|
|
30
|
+
* Returning the explicit glibc path here overrides the SDK's choice and
|
|
31
|
+
* sidesteps the bug.
|
|
32
|
+
*
|
|
33
|
+
* On every other platform (musl Linux, macOS, Windows, unknown libc, or
|
|
34
|
+
* unsupported arch), this returns `undefined` so the SDK keeps its default
|
|
35
|
+
* resolution behaviour.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveClaudeBinaryPath(probe = detectPlatform(), resolveFn = (id) => localRequire.resolve(id)) {
|
|
38
|
+
if (probe.platform !== 'linux')
|
|
39
|
+
return undefined;
|
|
40
|
+
if (!probe.isGlibc)
|
|
41
|
+
return undefined;
|
|
42
|
+
if (probe.arch !== 'x64' && probe.arch !== 'arm64')
|
|
43
|
+
return undefined;
|
|
44
|
+
try {
|
|
45
|
+
return resolveFn(`@anthropic-ai/claude-agent-sdk-linux-${probe.arch}/claude`);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -590,11 +590,20 @@ function onSessionEnded(workspaceId, agentSessionId, exitCode, reason, resumeFai
|
|
|
590
590
|
// `resumeFailed` is benign: stale id cleared, next iteration starts fresh.
|
|
591
591
|
const isErrorOutcome = !resumeFailed && (reason === 'error' || (exitCode !== null && exitCode !== 0));
|
|
592
592
|
const targetStatus = isErrorOutcome ? 'error' : 'completed';
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
593
|
+
// Skip the transition when the workspace is already in a terminal state.
|
|
594
|
+
// This happens when stopAgent (or an equivalent caller) synchronously
|
|
595
|
+
// normalised the status before the engine's async stop emitted session:ended
|
|
596
|
+
// — typically `awaiting-user → idle`. Without this guard we'd attempt e.g.
|
|
597
|
+
// `idle → completed` and log a benign error on every such manual stop.
|
|
598
|
+
const currentStatus = currentWorkspace?.status;
|
|
599
|
+
const alreadyTerminal = currentStatus === 'idle' || currentStatus === 'completed' || currentStatus === 'error' || currentStatus === 'quota';
|
|
600
|
+
if (!alreadyTerminal) {
|
|
601
|
+
try {
|
|
602
|
+
updateWorkspaceStatus(workspaceId, targetStatus);
|
|
603
|
+
}
|
|
604
|
+
catch (err) {
|
|
605
|
+
console.error('[orchestrator] Failed to update workspace status on exit:', err);
|
|
606
|
+
}
|
|
598
607
|
}
|
|
599
608
|
try {
|
|
600
609
|
markWorkspaceUnread(workspaceId);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
2
|
import { buildE2eIterationBlock, buildFinalizationIterationBlock } from '../../shared/auto-loop-prompts.js';
|
|
4
3
|
import { getDb } from '../db/index.js';
|
|
4
|
+
import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
5
5
|
import * as orchestrator from './agent/orchestrator.js';
|
|
6
6
|
import * as settingsService from './settings-service.js';
|
|
7
7
|
import { emit, emitEphemeral } from './websocket-service.js';
|
|
@@ -252,7 +252,7 @@ function spawnNextIteration(workspaceId, opts = {}) {
|
|
|
252
252
|
.replaceAll('{taskTitle}', task.title)
|
|
253
253
|
.replaceAll('{isAcceptanceCriterion}', String(task.isAcceptanceCriterion))
|
|
254
254
|
.replaceAll('{overrideBlock}', overrideBlock);
|
|
255
|
-
const worktreePath = row.worktree_path ??
|
|
255
|
+
const worktreePath = row.worktree_path ?? resolveWorkspaceWorktreePath(row.project_path, row.working_branch);
|
|
256
256
|
// Plan mode would deadlock the loop (blocks MCP + edits) — promote to bypass.
|
|
257
257
|
// Other modes (bypass/strict/interactive) are honored.
|
|
258
258
|
const stored = (row.agent_permission_mode ?? 'bypass');
|
|
@@ -164,7 +164,7 @@ export function startDevServer(workspaceId) {
|
|
|
164
164
|
const instanceName = sanitizeBranchName(workspace.workingBranch);
|
|
165
165
|
// Execute as bash script (supports multi-line scripts)
|
|
166
166
|
const worktreePath = workspace.worktreePath;
|
|
167
|
-
const cwd = existsSync(worktreePath) ? worktreePath : workspace.projectPath;
|
|
167
|
+
const cwd = worktreePath && existsSync(worktreePath) ? worktreePath : workspace.projectPath;
|
|
168
168
|
const proc = spawn('bash', ['-c', settings.devServer.startCommand], {
|
|
169
169
|
cwd,
|
|
170
170
|
env: {
|
|
@@ -230,7 +230,7 @@ export function stopDevServer(workspaceId) {
|
|
|
230
230
|
const config = resolveInstance(workspace.projectPath, workspace.workingBranch);
|
|
231
231
|
const instanceName = config?.instanceName ?? sanitizeBranchName(workspace.workingBranch);
|
|
232
232
|
const worktreePath = workspace.worktreePath;
|
|
233
|
-
const cwd = existsSync(worktreePath) ? worktreePath : workspace.projectPath;
|
|
233
|
+
const cwd = worktreePath && existsSync(worktreePath) ? worktreePath : workspace.projectPath;
|
|
234
234
|
// Kill tracked process first (covers Node servers and any spawned process)
|
|
235
235
|
const tracked = trackedProcesses.get(workspaceId);
|
|
236
236
|
if (tracked) {
|
|
@@ -2,7 +2,7 @@ import { getPrStatusAsync } from '../utils/git-ops.js';
|
|
|
2
2
|
import { stopDevServer } from './dev-server-service.js';
|
|
3
3
|
import { destroyTerminal } from './terminal-service.js';
|
|
4
4
|
import { emitEphemeral } from './websocket-service.js';
|
|
5
|
-
import { archiveWorkspace, listWorkspaces } from './workspace-service.js';
|
|
5
|
+
import { archiveWorkspace, listWorkspaces, updateWorkspaceSourceBranch } from './workspace-service.js';
|
|
6
6
|
// ── PR Watcher ────────────────────────────────────────────────────────────────
|
|
7
7
|
// Polls GitHub every POLL_INTERVAL_MS to detect merged/closed PRs and
|
|
8
8
|
// automatically archive the corresponding workspace.
|
|
@@ -14,8 +14,7 @@ import { archiveWorkspace, listWorkspaces } from './workspace-service.js';
|
|
|
14
14
|
const POLL_INTERVAL_MS = 30 * 1000; // 30 seconds
|
|
15
15
|
let timer = null;
|
|
16
16
|
let checking = false;
|
|
17
|
-
|
|
18
|
-
const lastKnownState = new Map();
|
|
17
|
+
const lastKnownPr = new Map();
|
|
19
18
|
/**
|
|
20
19
|
* Read-only snapshot of PR states known to the watcher, keyed by workspace id.
|
|
21
20
|
* Used by the drawer to show a small PR-open indicator without N separate
|
|
@@ -25,17 +24,24 @@ const lastKnownState = new Map();
|
|
|
25
24
|
*/
|
|
26
25
|
export function getAllPrStates() {
|
|
27
26
|
const out = {};
|
|
28
|
-
for (const [id,
|
|
29
|
-
out[id] = state;
|
|
27
|
+
for (const [id, known] of lastKnownPr) {
|
|
28
|
+
out[id] = known.state;
|
|
30
29
|
}
|
|
31
30
|
return out;
|
|
32
31
|
}
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Test-only escape hatch — drops the in-memory cache so each test starts
|
|
34
|
+
* from a clean slate. Not part of the public API.
|
|
35
|
+
*/
|
|
36
|
+
export function _resetForTest() {
|
|
37
|
+
lastKnownPr.clear();
|
|
38
|
+
}
|
|
39
|
+
export async function checkPrStatuses() {
|
|
34
40
|
const workspaces = listWorkspaces(false); // non-archived only
|
|
35
41
|
// Clean up entries for workspaces that no longer exist
|
|
36
|
-
for (const id of
|
|
42
|
+
for (const id of lastKnownPr.keys()) {
|
|
37
43
|
if (!workspaces.some((ws) => ws.id === id)) {
|
|
38
|
-
|
|
44
|
+
lastKnownPr.delete(id);
|
|
39
45
|
}
|
|
40
46
|
}
|
|
41
47
|
for (const ws of workspaces) {
|
|
@@ -46,10 +52,15 @@ async function checkPrStatuses() {
|
|
|
46
52
|
const pr = await getPrStatusAsync(ws.projectPath, ws.workingBranch);
|
|
47
53
|
if (!pr)
|
|
48
54
|
continue;
|
|
49
|
-
const prev =
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
|
|
55
|
+
const prev = lastKnownPr.get(ws.id);
|
|
56
|
+
// We delay updating `lastKnownPr` until after the actions succeed.
|
|
57
|
+
// Setting it eagerly would poison the cache: if updateWorkspaceSourceBranch
|
|
58
|
+
// throws (transient DB issue, race with workspace deletion), the cache
|
|
59
|
+
// already holds the new base and the user never sees the toast — the
|
|
60
|
+
// next tick computes `prev.base === pr.base` and treats it as no-op.
|
|
61
|
+
// Archive on a transition FROM OPEN to CLOSED/MERGED. Skips the
|
|
62
|
+
// base-change detection below — archiving wins.
|
|
63
|
+
if (prev?.state === 'OPEN' && (pr.state === 'MERGED' || pr.state === 'CLOSED')) {
|
|
53
64
|
console.log(`[pr-watcher] PR ${pr.state.toLowerCase()} for workspace '${ws.name}' — archiving`);
|
|
54
65
|
// Best-effort cleanup (same as manual archive): stop dev server + terminal.
|
|
55
66
|
// Agent is already not running here (guarded above).
|
|
@@ -66,12 +77,51 @@ async function checkPrStatuses() {
|
|
|
66
77
|
// Terminal may not exist — ignore
|
|
67
78
|
}
|
|
68
79
|
archiveWorkspace(ws.id);
|
|
69
|
-
|
|
80
|
+
lastKnownPr.delete(ws.id);
|
|
70
81
|
emitEphemeral(ws.id, 'workspace:archived', {
|
|
71
82
|
reason: `PR ${pr.state.toLowerCase()}`,
|
|
72
83
|
prUrl: pr.url,
|
|
73
84
|
});
|
|
85
|
+
continue; // do not run base-change detection on a workspace we just archived
|
|
86
|
+
}
|
|
87
|
+
// Base-branch change detection. Only relevant for OPEN PRs — closed/
|
|
88
|
+
// merged PRs don't accept base changes. Skip if the GitHub response
|
|
89
|
+
// didn't include a baseRefName (defensive against malformed data).
|
|
90
|
+
if (pr.state !== 'OPEN' || !pr.base) {
|
|
91
|
+
// Still update the cache for the state — keeps the OPEN→CLOSED/MERGED
|
|
92
|
+
// archiving logic working on the next tick.
|
|
93
|
+
lastKnownPr.set(ws.id, { state: pr.state, base: prev?.base });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// Comparison baseline:
|
|
97
|
+
// - If we've seen this workspace before, use the previous `base`.
|
|
98
|
+
// - Otherwise (first sight after boot/unarchive), compare with the
|
|
99
|
+
// `sourceBranch` recorded in the database — that catches base changes
|
|
100
|
+
// that happened while Kobo was offline.
|
|
101
|
+
const previousBase = prev?.base ?? ws.sourceBranch;
|
|
102
|
+
if (previousBase === pr.base) {
|
|
103
|
+
// No-op path: still record the base so subsequent ticks have a baseline.
|
|
104
|
+
lastKnownPr.set(ws.id, { state: pr.state, base: pr.base });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
console.log(`[pr-watcher] PR base changed for workspace '${ws.name}': ${previousBase} → ${pr.base}`);
|
|
108
|
+
try {
|
|
109
|
+
updateWorkspaceSourceBranch(ws.id, pr.base);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
console.error(`[pr-watcher] updateWorkspaceSourceBranch failed for '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
113
|
+
// Don't poison the cache: leave the previous entry (or absence) so
|
|
114
|
+
// the next tick retries the detection.
|
|
115
|
+
continue;
|
|
74
116
|
}
|
|
117
|
+
// Both the persistence and the emit are part of "we successfully
|
|
118
|
+
// observed a base change" — only NOW commit the new state to the cache.
|
|
119
|
+
lastKnownPr.set(ws.id, { state: pr.state, base: pr.base });
|
|
120
|
+
emitEphemeral(ws.id, 'pr:base-changed', {
|
|
121
|
+
oldBase: previousBase,
|
|
122
|
+
newBase: pr.base,
|
|
123
|
+
prUrl: pr.url,
|
|
124
|
+
});
|
|
75
125
|
}
|
|
76
126
|
catch (err) {
|
|
77
127
|
console.error(`[pr-watcher] Failed to check PR for workspace '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { WORKTREES_PATH } from '../../shared/consts.js';
|
|
3
4
|
import { listClaudeMcpEntries } from '../utils/mcp-client.js';
|
|
4
5
|
import { getSettingsPath } from '../utils/paths.js';
|
|
6
|
+
import { InvalidWorktreesPathError, resolveGlobalWorktreesRoot, sanitizeWorktreesPath, validateWorktreesPath, } from '../utils/worktree-paths.js';
|
|
5
7
|
const DEFAULT_GIT_CONVENTIONS = `# Git conventions
|
|
6
8
|
|
|
7
9
|
## Commits
|
|
@@ -191,6 +193,32 @@ const settingsMigrations = [
|
|
|
191
193
|
}
|
|
192
194
|
},
|
|
193
195
|
},
|
|
196
|
+
{
|
|
197
|
+
version: 11,
|
|
198
|
+
name: 'add-global-worktrees-path',
|
|
199
|
+
migrate({ global }) {
|
|
200
|
+
global.worktreesPath = sanitizeWorktreesPath(global.worktreesPath);
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
version: 12,
|
|
205
|
+
name: 'add-audio-notification-sound',
|
|
206
|
+
migrate({ global }) {
|
|
207
|
+
if (typeof global.audioNotificationSound !== 'string' || global.audioNotificationSound.length === 0) {
|
|
208
|
+
global.audioNotificationSound = 'hey.mp3';
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
version: 13,
|
|
214
|
+
name: 'add-audio-notification-volume',
|
|
215
|
+
migrate({ global }) {
|
|
216
|
+
const v = global.audioNotificationVolume;
|
|
217
|
+
if (typeof v !== 'number' || Number.isNaN(v) || v < 0 || v > 1) {
|
|
218
|
+
global.audioNotificationVolume = 1;
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
},
|
|
194
222
|
];
|
|
195
223
|
/** Current settings schema version — always equals the highest migration version. */
|
|
196
224
|
export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
|
|
@@ -225,12 +253,15 @@ function defaultSettings() {
|
|
|
225
253
|
editorCommand: '',
|
|
226
254
|
browserNotifications: true,
|
|
227
255
|
audioNotifications: true,
|
|
256
|
+
audioNotificationSound: 'hey.mp3',
|
|
257
|
+
audioNotificationVolume: 1,
|
|
228
258
|
notionStatusProperty: '',
|
|
229
259
|
notionInProgressStatus: '',
|
|
230
260
|
defaultPermissionMode: 'plan',
|
|
231
261
|
notionMcpKey: '',
|
|
232
262
|
sentryMcpKey: '',
|
|
233
263
|
tags: [...DEFAULT_WORKSPACE_TAGS],
|
|
264
|
+
worktreesPath: WORKTREES_PATH,
|
|
234
265
|
},
|
|
235
266
|
projects: [],
|
|
236
267
|
};
|
|
@@ -278,6 +309,7 @@ export function runSettingsMigrations(raw) {
|
|
|
278
309
|
version = m.version;
|
|
279
310
|
}
|
|
280
311
|
}
|
|
312
|
+
current.global.worktreesPath = sanitizeWorktreesPath(current.global.worktreesPath);
|
|
281
313
|
current.schemaVersion = version;
|
|
282
314
|
return current;
|
|
283
315
|
}
|
|
@@ -310,9 +342,11 @@ function readSettings() {
|
|
|
310
342
|
// Restore any global fields that may have been removed by external edits.
|
|
311
343
|
// Defaults act as fallback for missing keys; existing values are preserved.
|
|
312
344
|
const globalDefaults = defaultSettings().global;
|
|
345
|
+
const globalBeforeDefaults = JSON.stringify(migrated.global);
|
|
313
346
|
migrated.global = { ...globalDefaults, ...migrated.global };
|
|
347
|
+
const restoredGlobalFields = JSON.stringify(migrated.global) !== globalBeforeDefaults;
|
|
314
348
|
// Persist if migrations bumped the version, or if global fields were restored.
|
|
315
|
-
if (migrated.schemaVersion !== originalVersion) {
|
|
349
|
+
if (migrated.schemaVersion !== originalVersion || restoredGlobalFields) {
|
|
316
350
|
writeSettings(migrated);
|
|
317
351
|
}
|
|
318
352
|
return migrated;
|
|
@@ -444,12 +478,15 @@ export function updateGlobalSettings(data) {
|
|
|
444
478
|
'editorCommand',
|
|
445
479
|
'browserNotifications',
|
|
446
480
|
'audioNotifications',
|
|
481
|
+
'audioNotificationSound',
|
|
482
|
+
'audioNotificationVolume',
|
|
447
483
|
'notionStatusProperty',
|
|
448
484
|
'notionInProgressStatus',
|
|
449
485
|
'defaultPermissionMode',
|
|
450
486
|
'notionMcpKey',
|
|
451
487
|
'sentryMcpKey',
|
|
452
488
|
'tags',
|
|
489
|
+
'worktreesPath',
|
|
453
490
|
];
|
|
454
491
|
const filtered = pickKnownKeys(data, allowedGlobalKeys);
|
|
455
492
|
if (filtered.tags !== undefined) {
|
|
@@ -459,10 +496,34 @@ export function updateGlobalSettings(data) {
|
|
|
459
496
|
.filter((t) => t.length > 0 && t.length <= 50)))
|
|
460
497
|
: settings.global.tags;
|
|
461
498
|
}
|
|
499
|
+
if (filtered.audioNotificationVolume !== undefined) {
|
|
500
|
+
const v = Number(filtered.audioNotificationVolume);
|
|
501
|
+
filtered.audioNotificationVolume = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1;
|
|
502
|
+
}
|
|
503
|
+
if (filtered.worktreesPath !== undefined) {
|
|
504
|
+
filtered.worktreesPath = validateWorktreesPath(filtered.worktreesPath, { allowEmpty: false });
|
|
505
|
+
ensureGlobalWorktreesRootExists(filtered.worktreesPath);
|
|
506
|
+
}
|
|
462
507
|
settings.global = { ...settings.global, ...filtered };
|
|
463
508
|
writeSettings(settings, { backup: true });
|
|
464
509
|
return settings.global;
|
|
465
510
|
}
|
|
511
|
+
function ensureGlobalWorktreesRootExists(worktreesPath) {
|
|
512
|
+
const root = resolveGlobalWorktreesRoot(worktreesPath);
|
|
513
|
+
if (!root || isNonNativeWindowsPath(root))
|
|
514
|
+
return;
|
|
515
|
+
try {
|
|
516
|
+
fs.mkdirSync(root, { recursive: true });
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
520
|
+
throw new InvalidWorktreesPathError(`Cannot create worktrees directory '${root}': ${message}`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
function isNonNativeWindowsPath(value) {
|
|
524
|
+
return (process.platform !== 'win32' &&
|
|
525
|
+
(/^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('//')));
|
|
526
|
+
}
|
|
466
527
|
/** Create or update project-specific settings. Merges devServer, e2e, and finalization fields on update. */
|
|
467
528
|
export function upsertProject(projectPath, data) {
|
|
468
529
|
const allowedProjectKeys = [
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
1
|
import { getDb } from '../db/index.js';
|
|
2
|
+
import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
3
3
|
import * as orchestrator from './agent/orchestrator.js';
|
|
4
4
|
import { emitEphemeral } from './websocket-service.js';
|
|
5
5
|
const MIN_DELAY_SECONDS = 60;
|
|
@@ -132,7 +132,7 @@ function fire(workspaceId) {
|
|
|
132
132
|
emitEphemeral(workspaceId, 'wakeup:skipped', { reason: 'fire-failed' });
|
|
133
133
|
return;
|
|
134
134
|
}
|
|
135
|
-
const worktreePath = wsRow.worktree_path ??
|
|
135
|
+
const worktreePath = wsRow.worktree_path ?? resolveWorkspaceWorktreePath(wsRow.project_path, wsRow.working_branch);
|
|
136
136
|
// Narrow against the four known values; unknowns → 'bypass'.
|
|
137
137
|
const stored = wsRow.agent_permission_mode;
|
|
138
138
|
const agentPermissionMode = stored === 'plan' || stored === 'strict' || stored === 'interactive' ? stored : 'bypass';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { nanoid } from 'nanoid';
|
|
2
2
|
import { getDb } from '../db/index.js';
|
|
3
|
+
import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
|
|
3
4
|
import * as orchestrator from './agent/orchestrator.js';
|
|
4
5
|
import * as autoLoopService from './auto-loop-service.js';
|
|
5
6
|
import * as wakeupService from './wakeup-service.js';
|
|
@@ -91,7 +92,7 @@ export function createWorkspace(data) {
|
|
|
91
92
|
const db = getDb();
|
|
92
93
|
const now = new Date().toISOString();
|
|
93
94
|
const id = nanoid();
|
|
94
|
-
const computedWorktreePath = data.worktreePath ??
|
|
95
|
+
const computedWorktreePath = data.worktreePath ?? resolveWorkspaceWorktreePath(data.projectPath, data.workingBranch, data.worktreesPath);
|
|
95
96
|
const owned = data.worktreeOwned ?? true;
|
|
96
97
|
// Mirror the unified mode into the legacy columns so older readers (in-flight
|
|
97
98
|
// requests during deploy, external scripts) still see a sane value.
|
|
@@ -216,6 +217,27 @@ export function updateWorkingBranch(id, workingBranch) {
|
|
|
216
217
|
}
|
|
217
218
|
return getWorkspace(id);
|
|
218
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Update the source branch in the database. Called by the PR watcher when
|
|
222
|
+
* GitHub reports a different `baseRefName` for the workspace's PR.
|
|
223
|
+
* Does NOT touch the worktree — the user (or the agent) decides when to
|
|
224
|
+
* rebase the local branch onto the new base.
|
|
225
|
+
*/
|
|
226
|
+
export function updateWorkspaceSourceBranch(id, sourceBranch) {
|
|
227
|
+
const sanitized = sourceBranch.trim();
|
|
228
|
+
if (!sanitized) {
|
|
229
|
+
throw new Error('Source branch cannot be empty');
|
|
230
|
+
}
|
|
231
|
+
const db = getDb();
|
|
232
|
+
const now = new Date().toISOString();
|
|
233
|
+
const result = db
|
|
234
|
+
.prepare('UPDATE workspaces SET source_branch = ?, updated_at = ? WHERE id = ?')
|
|
235
|
+
.run(sanitized, now, id);
|
|
236
|
+
if (result.changes === 0) {
|
|
237
|
+
throw new Error(`Workspace '${id}' not found`);
|
|
238
|
+
}
|
|
239
|
+
return getWorkspace(id);
|
|
240
|
+
}
|
|
219
241
|
/** Update the on-disk worktree path. Used by rename / resync-branch on owned worktrees. */
|
|
220
242
|
export function updateWorktreePath(id, newPath) {
|
|
221
243
|
const db = getDb();
|