@loicngr/kobo 2.1.24 → 2.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/AGENTS.md +39 -0
  2. package/CHANGELOG.md +8 -0
  3. package/CONFIGURATION.md +107 -0
  4. package/README.md +2 -1
  5. package/dist/server/routes/usage.js +24 -0
  6. package/dist/server/routes/workspaces.js +41 -7
  7. package/dist/server/services/agent/engines/claude-code/engine.js +2 -0
  8. package/dist/server/services/agent/engines/claude-code/progress-reporting-hook.js +99 -0
  9. package/dist/server/services/forge/github/provider.js +4 -2
  10. package/dist/server/services/forge/types.js +0 -1
  11. package/dist/server/services/provider-accounts-service.js +202 -0
  12. package/dist/server/services/usage/poller.js +44 -3
  13. package/dist/server/utils/git-ops.js +29 -20
  14. package/dist/shared/forge-merge.js +5 -0
  15. package/dist/shared/progress-bar-prompts.js +1 -1
  16. package/dist/shared/provider-accounts.js +1 -0
  17. package/package.json +1 -1
  18. package/src/client/dist/pwa/assets/{ActivityFeed-BYspOKKg.js → ActivityFeed-DfYrVNEh.js} +1 -1
  19. package/src/client/dist/pwa/assets/{AutoLoopFinalReviewSettings-DXX3CqrY.js → AutoLoopFinalReviewSettings-DOr41E2Y.js} +1 -1
  20. package/src/client/dist/pwa/assets/{ChangelogPage-DKkI5IU-.js → ChangelogPage-DOCgZMpn.js} +1 -1
  21. package/src/client/dist/pwa/assets/{ComparisonPanel-Cw5Dkan0.js → ComparisonPanel-b5nTBjK6.js} +1 -1
  22. package/src/client/dist/pwa/assets/{CreatePage-B7PLFOH6.js → CreatePage-B7VPHEqm.js} +1 -1
  23. package/src/client/dist/pwa/assets/{DashboardPage-Dw8nfkYR.js → DashboardPage-dBpJaQSb.js} +1 -1
  24. package/src/client/dist/pwa/assets/DiffViewer-BLQz2Qyx.js +8 -0
  25. package/src/client/dist/pwa/assets/{DiffViewer-DEPOa3D7.css → DiffViewer-XzCvEKLJ.css} +1 -1
  26. package/src/client/dist/pwa/assets/{DocumentsPanel-CH0KzdJV.js → DocumentsPanel-AqLx0MzB.js} +1 -1
  27. package/src/client/dist/pwa/assets/{DrawerToggleButton-CZVpU8HH.js → DrawerToggleButton-BLWaFgh0.js} +1 -1
  28. package/src/client/dist/pwa/assets/GitPanel-Ba7ltCsM.js +3 -0
  29. package/src/client/dist/pwa/assets/{GitPanel-CNFtj6k0.css → GitPanel-CRaLJzjc.css} +1 -1
  30. package/src/client/dist/pwa/assets/{HealthPage-DnPp8GSx.js → HealthPage-DeryVLyz.js} +1 -1
  31. package/src/client/dist/pwa/assets/{MainLayout-1PaKfzdy.js → MainLayout-B2XvjdWi.js} +2 -2
  32. package/src/client/dist/pwa/assets/{MemoryPanel-BarliK2r.js → MemoryPanel-B34DENtz.js} +1 -1
  33. package/src/client/dist/pwa/assets/{MemoryProposalList-BtVjVPp7.js → MemoryProposalList-BpN_OU8J.js} +1 -1
  34. package/src/client/dist/pwa/assets/{QBtnDropdown-Rzurb7nO.js → QBtnDropdown-BPcK_rVs.js} +1 -1
  35. package/src/client/dist/pwa/assets/{QExpansionItem-CaKEBcyx.js → QExpansionItem-CpwQ8L_F.js} +1 -1
  36. package/src/client/dist/pwa/assets/{QScrollArea-hrsAyyJf.js → QScrollArea-BkNSVl7s.js} +1 -1
  37. package/src/client/dist/pwa/assets/{QSelect-Dr7rAt6u.js → QSelect-DrLpbvcr.js} +1 -1
  38. package/src/client/dist/pwa/assets/{QTooltip-CNMsXTmD.js → QTooltip-Dp8fitHV.js} +1 -1
  39. package/src/client/dist/pwa/assets/{SchedulePanel-nDw2eZ9K.js → SchedulePanel-Ck8LKDCa.js} +1 -1
  40. package/src/client/dist/pwa/assets/{SearchPage-BXJ6iVt1.js → SearchPage-BAUZg1zq.js} +1 -1
  41. package/src/client/dist/pwa/assets/{SettingsPage-SHpE2q9Z.js → SettingsPage-Cj6kSVT5.js} +1 -1
  42. package/src/client/dist/pwa/assets/{SplitWorkspacePage-tfZ1kAdZ.js → SplitWorkspacePage-CH8OHmtn.js} +1 -1
  43. package/src/client/dist/pwa/assets/{TerminalPanel-Cw8R7lAw.js → TerminalPanel-CBmZ0IzQ.js} +1 -1
  44. package/src/client/dist/pwa/assets/{ToolCallItem-DEKhFJEv.js → ToolCallItem-CLtGTV6w.js} +1 -1
  45. package/src/client/dist/pwa/assets/{TouchPan-DybGzRRK.js → TouchPan-9SH2dx6m.js} +1 -1
  46. package/src/client/dist/pwa/assets/{TourReplayButton-BNdlAZUR.js → TourReplayButton-Dw4sGf9B.js} +1 -1
  47. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-B0H9omcK.js → WorkspaceAttentionLabels-wd_sObJa.js} +1 -1
  48. package/src/client/dist/pwa/assets/WorkspacePage-BEBwwr81.css +1 -0
  49. package/src/client/dist/pwa/assets/WorkspacePage-D4ODPOG-.js +4 -0
  50. package/src/client/dist/pwa/assets/{browser-DUTZhU-w.js → browser-CC4W8r76.js} +1 -1
  51. package/src/client/dist/pwa/assets/{build-path-tree-ZThcsIN5.js → build-path-tree-BBuyB-my.js} +1 -1
  52. package/src/client/dist/pwa/assets/{custom-sounds-CquHn2gw.js → custom-sounds-BbPNRzAs.js} +1 -1
  53. package/src/client/dist/pwa/assets/{de-B3H0T-ft.js → de-D9VzPDGF.js} +2 -2
  54. package/src/client/dist/pwa/assets/{editor.api-pSvhgban.js → editor.api-BwfxvQ88.js} +1 -1
  55. package/src/client/dist/pwa/assets/{efforts-DJaFsLgd.js → efforts-B3I7w5bs.js} +1 -1
  56. package/src/client/dist/pwa/assets/{es-B4lACW6k.js → es-3R4MJuTt.js} +2 -2
  57. package/src/client/dist/pwa/assets/{expand-template-C5D_p5eV.js → expand-template-Ci9UjnxZ.js} +1 -1
  58. package/src/client/dist/pwa/assets/{format-DRfnkeRP.js → format-CJIzi3Vf.js} +1 -1
  59. package/src/client/dist/pwa/assets/{formatters-Cog2icjt.js → formatters-DQ8cP7nO.js} +1 -1
  60. package/src/client/dist/pwa/assets/{fr-3uNi02Jz.js → fr-DXwDVnJ4.js} +2 -2
  61. package/src/client/dist/pwa/assets/{freemarker2-W-OLsqOk.js → freemarker2-DbpVO-cL.js} +1 -1
  62. package/src/client/dist/pwa/assets/{handlebars-Kt5RbWuO.js → handlebars-BHve0PpA.js} +1 -1
  63. package/src/client/dist/pwa/assets/{html-D5VKPBbB.js → html-BhUkdFPz.js} +1 -1
  64. package/src/client/dist/pwa/assets/i18n-CGLQAADf.js +1 -0
  65. package/src/client/dist/pwa/assets/{index-BZwhT2-I.js → index-CNcXBpzz.js} +3 -3
  66. package/src/client/dist/pwa/assets/{it-DtFWyLHb.js → it-T9AjnG42.js} +2 -2
  67. package/src/client/dist/pwa/assets/{javascript-DBjx8Bg2.js → javascript-BLAorPa3.js} +1 -1
  68. package/src/client/dist/pwa/assets/{liquid-DYT4a7D7.js → liquid-CJGF-h-f.js} +1 -1
  69. package/src/client/dist/pwa/assets/{mdx-9L1kysEY.js → mdx-Cm0oNz_l.js} +1 -1
  70. package/src/client/dist/pwa/assets/{monaco-contributions-DAme5TZS.js → monaco-contributions-nBg6wf05.js} +1 -1
  71. package/src/client/dist/pwa/assets/{monaco.contribution-C58k7TPw.js → monaco.contribution-hr9s26Ud.js} +2 -2
  72. package/src/client/dist/pwa/assets/{network-auth-BDUlgV-U.js → network-auth-C850jGfI.js} +1 -1
  73. package/src/client/dist/pwa/assets/{notify-theme-DrQB-mrb.js → notify-theme-EcbxuH_8.js} +1 -1
  74. package/src/client/dist/pwa/assets/{project-color-BlV5PUUW.js → project-color-vDuTaTJn.js} +1 -1
  75. package/src/client/dist/pwa/assets/{python-DJIlKr-Y.js → python-BEjbzFAQ.js} +1 -1
  76. package/src/client/dist/pwa/assets/{razor-CC3uqdaH.js → razor-BJPT7Wxx.js} +1 -1
  77. package/src/client/dist/pwa/assets/{typescript-BnnkT9oE.js → typescript-DPqM46Ef.js} +1 -1
  78. package/src/client/dist/pwa/assets/{use-search-index-status-CExdHIar.js → use-search-index-status-BwwrB410.js} +1 -1
  79. package/src/client/dist/pwa/assets/{use-tours-DOUjqO2f.js → use-tours-B0KtCS9w.js} +1 -1
  80. package/src/client/dist/pwa/assets/{use-worktree-restore-zIOYNIcb.js → use-worktree-restore-tzd2FESU.js} +1 -1
  81. package/src/client/dist/pwa/assets/{workspace-templates-DXJG6flv.js → workspace-templates-CubzsZUU.js} +1 -1
  82. package/src/client/dist/pwa/assets/{xml-DJtVmsJT.js → xml-C6SVzw5_.js} +1 -1
  83. package/src/client/dist/pwa/assets/{yaml-Dwuw8FcP.js → yaml-OpCqnv4g.js} +1 -1
  84. package/src/client/dist/pwa/index.html +1 -1
  85. package/src/client/dist/pwa/sw.js +1 -1
  86. package/src/mcp-server/README.md +8 -0
  87. package/src/client/dist/pwa/assets/DiffViewer-DFlDwkmd.js +0 -8
  88. package/src/client/dist/pwa/assets/GitPanel-DjKL30gM.js +0 -3
  89. package/src/client/dist/pwa/assets/WorkspacePage-B-82Rw9T.css +0 -1
  90. package/src/client/dist/pwa/assets/WorkspacePage-tWGXnmrz.js +0 -4
  91. package/src/client/dist/pwa/assets/i18n-CEzCwMir.js +0 -1
package/AGENTS.md CHANGED
@@ -149,6 +149,30 @@ src/
149
149
 
150
150
  Tasks have `role` (`work | finalization`) and structured `verification`. MCP and HTTP share transactional task mutations. An auto-loop task cannot become done without passing reported checks. Finalization runs after all other tasks/criteria, is invalidated by scope changes, and is mandatory before completion. The proof is agent-reported evidence, not server-side execution of arbitrary commands.
151
151
 
152
+ ## Provider account switching
153
+
154
+ The usage popover integrates optional `ccswap` for Claude Code and Codex. Select
155
+ accounts for the viewed session's engine, falling back to the workspace engine.
156
+ Missing executable hides account controls; it must not hide existing Claude
157
+ usage. Codex account support is separate from quota snapshot support.
158
+
159
+ `provider-accounts-service.ts` owns subprocess invocation and sanitization. Use
160
+ provider allowlists, numeric slots and bounded `execFile`, never shell text. Keep
161
+ credential mutations serialized with their validation. Return only display
162
+ metadata; do not forward raw CLI JSON, stderr, tokens or credential paths. Inherit
163
+ the Kōbō process user, HOME, PATH and credential-directory environment: in Docker,
164
+ the CLI must run inside that container, not on the host. Do not install tools or
165
+ perform real switches in tests.
166
+
167
+ A switch changes the provider-wide credential store, not a workspace identity.
168
+ Do not automatically stop agents, migrate conversations, copy settings/skills or
169
+ promise that an existing process hot-reloads authentication. Invalidate Claude
170
+ quota data around switches and reject results from earlier in-flight polls.
171
+ Keep frontend requests scoped across provider/session changes and handle missing,
172
+ empty, disabled, unrecognized and failed account states without automatic retries.
173
+ See [configuration](./CONFIGURATION.md#switch-claude-code-and-codex-accounts-with-ccswap)
174
+ and [VPS setup](./docs/vps-projects-and-skills.md#switch-engine-accounts-inside-the-container).
175
+
152
176
  ## Persistent memory
153
177
 
154
178
  Kōbō memory is independent of provider chat history/native memory and shared by Claude Code and Codex. `memoryMode` is a global setting defaulting to `hybrid`: Manual permits reads only; Hybrid applies workspace writes and proposes project/global writes; Automatic applies writes to the explicitly targeted scope. Mode is checked per operation, so a setting change affects existing agent sessions; pending/rejected proposals are never automatically approved or revived. Plan/review/report-only capabilities cannot write.
@@ -223,6 +247,13 @@ reopen a closed database. Startup reconciles lost internal reporters while
223
247
  preserving external ones. An interrupted reporter is not proof of cancelled
224
248
  background work. Operational reporting never satisfies auto-loop verification.
225
249
 
250
+ Progress without measured counts is labelled explicitly, and the panel shows the
251
+ age of a report after two minutes without a newer one. This local clock must not
252
+ poll or mutate progress. Claude's per-launch PostToolUse advisory tracks only
253
+ observed managed progress responses, separates parent/subagent producers, and
254
+ limits reminders by action count and elapsed time. Do not infer percentages,
255
+ automatically complete bars, or block agent shutdown for missing reports.
256
+
226
257
  Retain scoped activity-key fingerprints, producer sequences and dismissed rows
227
258
  for retries; subsequent updates cannot resurrect a dismissed indicator or undo a
228
259
  terminal state. Bound labels, steps, page size, active/retained records and update
@@ -747,6 +778,14 @@ Snapshot each target and its delivery mode before scheduling: auto-loop uses `ne
747
778
 
748
779
  ### File editing in the diff viewer
749
780
 
781
+ The diff defaults to inline view. Busy workspace status or known live controller disables
782
+ Edit with an explanatory tooltip, and updates Monaco to read-only without discarding
783
+ the current buffer. Historical commit comparisons remain read-only. Unpushed mode
784
+ lists local commits plus working-tree changes (including non-ignored untracked files)
785
+ against the branch remote ref, falling back to the source branch before the first push.
786
+ The list and file-content routes must use the same baseline semantics; failed Git
787
+ reads must not be reported as an empty, clean diff.
788
+
750
789
  The right panel of `DiffViewer.vue` is editable when the workspace agent is stopped and the file is not in `deleted` status. `Ctrl/Cmd+S` or the explicit Save button persists the file via `POST /api/workspaces/:id/save-file`, sending `{ path, content, baseSha }` where `baseSha` is the sha256 captured at `GET /diff-file` time. The route refuses with **412 Precondition Failed** + `{ currentSha }` if the on-disk content has changed; the client shows a Reload / Keep mine dialog. Worktree-traversal guards (including parent-symlink escapes) and a 1 MB size cap live in `file-editor-service.ts`. Changing files in the tree while dirty pops an "Unsaved changes" prompt.
751
790
 
752
791
  ## Code conventions
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to Kōbō are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/). Each release is an `## <version>`
5
5
  section — the in-app "What's new" dialog reads this file.
6
6
 
7
+ ## 2.1.26
8
+
9
+ - feat: switch Claude and Codex accounts with ccswap
10
+
11
+ ## 2.1.25
12
+
13
+ - feat: improve merge options, diff controls and progress reporting
14
+
7
15
  ## 2.1.24
8
16
 
9
17
  - feat: add MCP attachments and immediate chat delivery
package/CONFIGURATION.md CHANGED
@@ -949,6 +949,20 @@ resume, not retroactively to an already running agent.
949
949
 
950
950
  The service retains at most 1,000 indicators per session, with at most 32 running
951
951
  at once. Hidden records are retained so retries cannot recreate dismissed bars.
952
+ When a bar has no numeric total, the UI explicitly says **Progress not quantified**.
953
+ After two minutes without a newer report it shows **No update for N min**. This
954
+ is the age of the last received report, not proof that work stopped. The display
955
+ clock does not poll the server or manufacture progress.
956
+
957
+ Claude launches with progress tools also receive a bounded `PostToolUse` reminder:
958
+ once on creation, then after at least eight subsequent tool completions and one
959
+ minute without a report. Reminders are scoped to the producing agent/subagent,
960
+ use the last observed sequence, and follow an explicit reporting handoff. They
961
+ are advisory: no extra tool calls, automatic percentages or blocking stop hooks.
962
+ Codex and external clients retain the shared MCP reporting guidance. Agents must
963
+ supply measured counts when a real test total or fixed checklist is available;
964
+ step-only updates correctly change the description without filling the gauge.
965
+
952
966
  The [MCP progress guide](src/mcp-server/README.md#session-progress-tools) documents
953
967
  arguments, ordering, retry behavior and update limits.
954
968
 
@@ -1535,6 +1549,65 @@ When a Codex session is already working, **Send now** sends a queued chat messag
1535
1549
 
1536
1550
  The selector reads [`src/shared/codex-models.ts`](./src/shared/codex-models.ts). This checkout lists GPT-6 Astra, GPT-5.6 Sol/Terra/Luna, GPT-5.5 and older variants; availability depends on your account and authentication. Kōbō exposes effort values `auto`, `none`, `low`, `medium`, `high`, `xhigh`, and `max`, as declared in the engine's capabilities. The runtime must support the selected model/effort combination. Both selectors switch when you change the engine.
1537
1551
 
1552
+ ### Switch Claude Code and Codex accounts with ccswap
1553
+
1554
+ The usage popover in the chat footer can show the current **managed account**
1555
+ and switch to another account registered in [ccswap](https://github.com/errhythm/ccswap).
1556
+ It follows the selected session's engine, falling back to the workspace engine.
1557
+ Choose an account and explicitly click **Switch account**. Claude Code and Codex
1558
+ have separate account lists; switching one does not switch the other.
1559
+
1560
+ ccswap is optional and is not bundled with Kōbō. Without its executable, Kōbō
1561
+ shows no account selector or installation notice; existing Claude usage indicators
1562
+ continue to work. Codex account controls do not imply that Claude-style quota
1563
+ buckets are available for Codex.
1564
+
1565
+ Install and register accounts **as the same operating-system user and in the
1566
+ same environment as the Kōbō server**. The integration uses ccswap's JSON CLI
1567
+ (tested with 0.37.0). With `uv` already installed:
1568
+
1569
+ ```bash
1570
+ uv tool install ccswap
1571
+ # After logging into Claude Code with each account:
1572
+ ccswap add
1573
+ # After `codex login` with each Codex account:
1574
+ ccswap codex add
1575
+ ccswap list --provider claude
1576
+ ccswap codex list
1577
+ ```
1578
+
1579
+ Register the current login before switching so ccswap can preserve it. Account
1580
+ registration, login renewal, aliases and removal remain CLI operations. A disabled
1581
+ account cannot be selected in Kōbō. If no managed account matches the current
1582
+ login, the panel reports an unknown account rather than guessing from a slot.
1583
+
1584
+ **Scope:** switching changes that provider's active credentials for the server
1585
+ user, shared by all Kōbō workspaces and other tools using that same login store.
1586
+ It is not a per-workspace account assignment. Kōbō keeps its worktrees, conversation
1587
+ history, settings and skills; it does not copy profiles or automatically restart
1588
+ agents. The displayed account identifies the active credential store, not proof
1589
+ that an already-running process has adopted it. Stop and resume the affected
1590
+ sessions if they still use the previous account, especially with Codex. Existing
1591
+ API-key or custom-provider authentication can take precedence over a saved login.
1592
+
1593
+ The adapter inherits absolute `CLAUDE_CONFIG_DIR` on Linux and `CODEX_HOME`.
1594
+ Empty or relative authentication-directory overrides are refused: server and workspace
1595
+ processes can have different working directories. Claude switching is also refused
1596
+ with `CLAUDE_SECURESTORAGE_CONFIG_DIR` set, or with any nonempty `CLAUDE_CONFIG_DIR`
1597
+ on macOS (even the explicit default path), because the corresponding Keychain
1598
+ write is not verified by this integration. Use the native login workflow for
1599
+ those configurations.
1600
+
1601
+ Claude usage is invalidated and refreshed after a switch. A failed refresh is
1602
+ not shown as the new account's old quota. Switching failures do not trigger an
1603
+ automatic retry: refresh the account list to check the actual state before trying
1604
+ again. The server exposes display metadata only, never ccswap's credential files
1605
+ or raw command output.
1606
+
1607
+ For Docker/VPS, installing ccswap on your PC or on the VPS host is insufficient:
1608
+ it must be reachable on **Kōbō's container PATH**, with the same runtime home and
1609
+ credential-directory environment. See [VPS account setup](./docs/vps-projects-and-skills.md#switch-engine-accounts-inside-the-container).
1610
+
1538
1611
  ### Permission modes
1539
1612
 
1540
1613
  Claude Code uses SDK permission modes; Codex uses sandbox and approval flags. These controls differ by engine. A Git worktree itself is not a security boundary.
@@ -1788,6 +1861,40 @@ Adapt the port stride, the `APP_NAME`, and any extra `*_PORT` keys to your stack
1788
1861
 
1789
1862
  Kōbō can open pull requests (GitHub or Bitbucket) or merge requests (GitLab) directly from the Git panel. The **forge** setting controls which service is used for a given project.
1790
1863
 
1864
+ ### Review and edit a diff
1865
+
1866
+ The diff viewer opens in **Inline** view. Use **Side by side** to change the layout.
1867
+ While an agent is working, **Edit** is disabled and its tooltip explains why;
1868
+ the diff remains readable and review comments remain available. The editor also
1869
+ becomes read-only if an agent starts while the diff is open. Stop the agent to
1870
+ resume editing. Server-side save checks remain in force.
1871
+
1872
+ **Unpushed** compares the current working tree with `origin/<workingBranch>`:
1873
+ it includes local commits, staged and unstaged changes, deletions, and non-ignored
1874
+ untracked files. Before the first push, it uses the source branch instead. The
1875
+ header shows the comparison base. **Branch** retains its separate opt-in for
1876
+ untracked files. Comparing historical commits remains read-only.
1877
+
1878
+ ### Merge a pull request
1879
+
1880
+ In the Git panel, **Merge PR** opens a confirmation. For GitHub, choose:
1881
+
1882
+ - **Merge commit** (default): preserve the commits and create a merge commit.
1883
+ - **Squash and merge**: combine the changes into one commit on the target branch.
1884
+ - **Rebase and merge**: replay the commits on the target branch without a merge commit.
1885
+
1886
+ The selection applies to this operation only. Kōbō rechecks PR readiness before
1887
+ calling the forge. Repository policies still apply: a disallowed method is
1888
+ reported as an error, with no automatic fallback or protection bypass. The
1889
+ source branch is kept; after a confirmed merge, remote deletion remains a
1890
+ separate confirmation. GitLab and Bitbucket retain their existing merge behavior.
1891
+
1892
+ `POST /api/workspaces/:id/merge-pr` accepts optional JSON
1893
+ `{ "method": "merge" | "squash" | "rebase" }` for integrations advertising the
1894
+ corresponding `forge.capabilities.mergeMethods`. An omitted body keeps the
1895
+ existing provider default. Invalid values return 400; unsupported methods return
1896
+ 409 before any merge command runs.
1897
+
1791
1898
  ### Modes
1792
1899
 
1793
1900
  | Value | Behaviour |
package/README.md CHANGED
@@ -64,7 +64,8 @@ Kōbō's production build is an installable PWA — use your browser's **Install
64
64
  - **Interactive Q&A**: an agent can pause mid-session to ask you a question through the UI instead of guessing.
65
65
 
66
66
  ![Agent asking a clarifying question, awaiting the user's answer](docs/assets/images/agent-question.png)
67
- - **Quota-aware**: 5-hour / 7-day Claude usage and Codex rate-limit buckets live in the footer; auto-loops can resume after a reset. Explicit cron/wakeup schedules can also resume work; a manual workspace does not restart solely because its quota resets.
67
+ - **Quota-aware**: 5-hour / 7-day Claude usage is shown in the footer; engine-reported rate limits guide quota waits; auto-loops can resume after a reset. Explicit cron/wakeup schedules can also resume work; a manual workspace does not restart solely because its quota resets.
68
+ - **Account switching**: with optional [ccswap](https://github.com/errhythm/ccswap), view and switch the active Claude Code or Codex account from the usage popover. Credentials are shared across workspaces using that engine; [setup and session behavior](./CONFIGURATION.md#switch-claude-code-and-codex-accounts-with-ccswap).
68
69
  - **Disk-space purge**: reclaim a merged workspace's `node_modules`/`vendor` weight without losing its chat history — see [`CONFIGURATION.md`](./CONFIGURATION.md#auto-purge-worktree-on-pr-merged).
69
70
  - **Lifecycle scripts**: shell scripts run on setup, cleanup, archive, session end, PR merge, or auto-loop stop, with output streamed into the chat.
70
71
  - **Observability**: a per-session timeline (duration, tools, tokens, errors) and a downloadable redacted diagnostic JSON.
@@ -1,5 +1,6 @@
1
1
  import { Hono } from 'hono';
2
2
  import { getDb } from '../db/index.js';
3
+ import { listProviderAccounts, ProviderAccountsError, switchProviderAccount, validateAccountId, } from '../services/provider-accounts-service.js';
3
4
  import { refreshNow } from '../services/usage/poller.js';
4
5
  import { clearReliabilityReset, computeEngineReliability, getReliabilityResetAt, resetReliability, } from '../services/usage/reliability.js';
5
6
  const app = new Hono();
@@ -39,6 +40,29 @@ app.delete('/reliability/reset', (c) => {
39
40
  return c.json({ error: message }, 500);
40
41
  }
41
42
  });
43
+ function accountError(error) {
44
+ return error instanceof ProviderAccountsError ? error : new ProviderAccountsError('accounts_command_failed');
45
+ }
46
+ app.get('/:providerId/accounts', async (c) => {
47
+ try {
48
+ return c.json(await listProviderAccounts(c.req.param('providerId')));
49
+ }
50
+ catch (error) {
51
+ const safe = accountError(error);
52
+ return c.json({ error: safe.message, code: safe.code }, safe.status);
53
+ }
54
+ });
55
+ app.post('/:providerId/accounts/switch', async (c) => {
56
+ try {
57
+ const body = await c.req.json().catch(() => null);
58
+ const accountId = validateAccountId(typeof body === 'object' && body !== null && 'accountId' in body ? body.accountId : undefined);
59
+ return c.json(await switchProviderAccount(c.req.param('providerId'), accountId));
60
+ }
61
+ catch (error) {
62
+ const safe = accountError(error);
63
+ return c.json({ error: safe.message, code: safe.code }, safe.status);
64
+ }
65
+ });
42
66
  app.post('/:providerId/refresh', async (c) => {
43
67
  const providerId = c.req.param('providerId');
44
68
  try {
@@ -1,6 +1,7 @@
1
1
  import { execFile as execFileCb, execFileSync, spawn } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { nanoid } from 'nanoid';
4
+ import { isMergeMethod } from '../../shared/forge-merge.js';
4
5
  import { isWorkflowPolicy, resolveWorkflowPolicy, WORKFLOW_ACTIONS, } from '../../shared/workflow-policy.js';
5
6
  import * as finalReviewService from '../services/auto-loop-final-review-service.js';
6
7
  import { getReviewReturnStatus } from '../services/review-return-service.js';
@@ -3483,15 +3484,14 @@ app.get('/:id/git-stats', async (c) => {
3483
3484
  // `origin/<sourceBranch>` (which can be ahead of the local ref when
3484
3485
  // upstream landed merges Kōbō hasn't pulled). The handler refreshes
3485
3486
  // `origin/<sourceBranch>` synchronously so the diff is never stale.
3486
- // - `unpushed`: committed-only changes vs `origin/<workingBranch>`,
3487
- // i.e. what the next `git push` will send.
3487
+ // - `unpushed`: local commits + current worktree changes (including untracked)
3488
+ // vs `origin/<workingBranch>`, falling back to the source branch before the first push.
3488
3489
  app.get('/:id/diff', async (c) => {
3489
3490
  try {
3490
3491
  const id = c.req.param('id');
3491
3492
  const rawMode = c.req.query('mode');
3492
3493
  const mode = rawMode === 'unpushed' ? 'unpushed' : rawMode === 'commits' ? 'commits' : 'branch';
3493
- // Opt-in flag from the diff viewer toggle. Only meaningful in `branch`
3494
- // mode — `unpushed` is committed-only by definition.
3494
+ // Branch mode keeps its untracked opt-in; unpushed always includes local untracked files.
3495
3495
  const includeUntracked = c.req.query('includeUntracked') === '1';
3496
3496
  const workspace = workspaceService.getWorkspace(id);
3497
3497
  if (!workspace) {
@@ -3525,7 +3525,7 @@ app.get('/:id/diff', async (c) => {
3525
3525
  await gitOps.fetchSourceBranchAsync(worktreePath, workspace.sourceBranch);
3526
3526
  }
3527
3527
  const files = mode === 'unpushed'
3528
- ? await gitOps.getUnpushedChangedFilesAsync(worktreePath, workspace.workingBranch)
3528
+ ? await gitOps.getUnpushedChangedFilesAsync(worktreePath, workspace.workingBranch, 'origin', workspace.sourceBranch)
3529
3529
  : await gitOps.getChangedFilesAsync(worktreePath, workspace.sourceBranch, includeUntracked);
3530
3530
  c.header('Cache-Control', 'no-store');
3531
3531
  return c.json({
@@ -3533,6 +3533,11 @@ app.get('/:id/diff', async (c) => {
3533
3533
  mode,
3534
3534
  sourceBranch: workspace.sourceBranch,
3535
3535
  workingBranch: workspace.workingBranch,
3536
+ ...(mode === 'unpushed'
3537
+ ? {
3538
+ baseRef: await gitOps.resolveUnpushedBaseAsync(worktreePath, workspace.workingBranch, workspace.sourceBranch),
3539
+ }
3540
+ : {}),
3536
3541
  });
3537
3542
  }
3538
3543
  catch (err) {
@@ -3591,7 +3596,9 @@ app.get('/:id/diff-file', async (c) => {
3591
3596
  to,
3592
3597
  });
3593
3598
  }
3594
- const baseRef = mode === 'unpushed' ? `origin/${workspace.workingBranch}` : workspace.sourceBranch;
3599
+ const baseRef = mode === 'unpushed'
3600
+ ? await gitOps.resolveUnpushedBaseAsync(worktreePath, workspace.workingBranch, workspace.sourceBranch)
3601
+ : workspace.sourceBranch;
3595
3602
  const original = await gitOps.getFileAtRefAsync(worktreePath, baseRef, filePath);
3596
3603
  const modified = gitOps.getFileContent(worktreePath, filePath);
3597
3604
  c.header('Cache-Control', 'no-store');
@@ -4211,10 +4218,32 @@ app.post('/:id/merge-pr', async (c) => {
4211
4218
  const workspace = workspaceService.getWorkspace(id);
4212
4219
  if (!workspace)
4213
4220
  return c.json({ error: `Workspace '${id}' not found` }, 404);
4221
+ const rawBody = await c.req.text();
4222
+ let body = {};
4223
+ if (rawBody.length > 0) {
4224
+ let parsed;
4225
+ try {
4226
+ parsed = JSON.parse(rawBody);
4227
+ }
4228
+ catch {
4229
+ return c.json({ error: 'Request body must be valid JSON' }, 400);
4230
+ }
4231
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
4232
+ return c.json({ error: 'Request body must be a JSON object' }, 400);
4233
+ }
4234
+ body = parsed;
4235
+ }
4236
+ const method = body.method;
4237
+ if (method !== undefined && !isMergeMethod(method)) {
4238
+ return c.json({ error: 'method must be merge, squash or rebase' }, 400);
4239
+ }
4214
4240
  const provider = getForgeProvider(resolveForge(workspace.projectPath));
4215
4241
  if (!provider.capabilities.canMergeRequest) {
4216
4242
  return c.json({ error: 'This project has no forge that can merge the PR', code: 'forge_unsupported' }, 409);
4217
4243
  }
4244
+ if (method !== undefined && !provider.capabilities.mergeMethods?.includes(method)) {
4245
+ return c.json({ error: 'This forge does not support the requested merge method', code: 'merge_method_unsupported' }, 409);
4246
+ }
4218
4247
  const availability = await provider.isAvailable(workspace.worktreePath);
4219
4248
  if (!availability.available) {
4220
4249
  return c.json({
@@ -4229,7 +4258,12 @@ app.post('/:id/merge-pr', async (c) => {
4229
4258
  if (!snapshot.readyToMerge) {
4230
4259
  return c.json({ error: 'This PR is not ready to merge', code: 'pr_not_ready' }, 409);
4231
4260
  }
4232
- await provider.mergeRequest(workspace.worktreePath, snapshot.number);
4261
+ if (method === undefined) {
4262
+ await provider.mergeRequest(workspace.worktreePath, snapshot.number);
4263
+ }
4264
+ else {
4265
+ await provider.mergeRequest(workspace.worktreePath, snapshot.number, method);
4266
+ }
4233
4267
  const merged = await provider.getPrStatus(workspace.worktreePath, workspace.workingBranch);
4234
4268
  return c.json({ success: true, merged: merged?.state === 'MERGED', branch: workspace.workingBranch });
4235
4269
  }
@@ -14,6 +14,7 @@ import { CLAUDE_CODE_CAPABILITIES } from './capabilities.js';
14
14
  import { createMapperState, mapSdkMessage, QUOTA_PATTERN, tryEmitQuota } from './event-mapper.js';
15
15
  import { buildClaudeOptions } from './options-builder.js';
16
16
  import { buildCompactionSessionStartOutput } from './precompact-hook.js';
17
+ import { createProgressReportingHook } from './progress-reporting-hook.js';
17
18
  import { resolveClaudeBinaryPath } from './resolve-binary.js';
18
19
  import { buildStopHookOutput } from './stop-hook.js';
19
20
  /**
@@ -341,6 +342,7 @@ export function createClaudeCodeEngine() {
341
342
  const hooks = {
342
343
  ...(options.mcpServers?.some((server) => server.name === 'kobo-tasks' && server.env.KOBO_PROGRESS_SESSION_TOKEN)
343
344
  ? {
345
+ PostToolUse: [{ hooks: [createProgressReportingHook()] }],
344
346
  SubagentStart: [
345
347
  {
346
348
  hooks: [
@@ -0,0 +1,99 @@
1
+ import { PROGRESS_MAX_RETAINED, PROGRESS_MAX_RUNNING } from '../../../../../shared/progress-bars.js';
2
+ function object(value) {
3
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
4
+ }
5
+ function progressResponse(value) {
6
+ if (typeof value === 'string') {
7
+ if (value.length > 16_384)
8
+ return null;
9
+ try {
10
+ value = JSON.parse(value);
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ const result = object(value);
17
+ if (result?.isError === true)
18
+ return null;
19
+ if (result && object(result.structuredContent))
20
+ return object(result.structuredContent);
21
+ const blocks = Array.isArray(value) ? value : result?.content;
22
+ if (Array.isArray(blocks)) {
23
+ for (const entry of blocks.slice(0, 4)) {
24
+ const item = object(entry);
25
+ if (item?.type !== 'text' || typeof item.text !== 'string' || item.text.length > 16_384)
26
+ continue;
27
+ try {
28
+ return object(JSON.parse(item.text));
29
+ }
30
+ catch {
31
+ /* Not a JSON progress response. */
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+ return result;
37
+ }
38
+ /** Per-launch advisory only: no timers, tool calls, writes or stop-blocking loops. */
39
+ export function createProgressReportingHook() {
40
+ const bars = new Map();
41
+ const retired = new Set();
42
+ const reminder = (entries) => ({
43
+ hookSpecificOutput: {
44
+ hookEventName: 'PostToolUse',
45
+ additionalContext: `Kōbō progress reporting: keep these existing activities current at actual milestones. Next update arguments: ${entries.map(([id, state]) => JSON.stringify({ progress_id: id, sequence: state.sequence + 1 })).join('; ')}. Add step and measured current/total when known; otherwise report step only. Call update_progress_bar after meaningful work, and finish_progress_bar with the known outcome before your final report. Do not invent progress, poll, repeat unchanged updates, recreate dismissed bars or finish background work that is still running.`,
46
+ },
47
+ });
48
+ return async (input) => {
49
+ if (input.hook_event_name !== 'PostToolUse' || retired.size >= PROGRESS_MAX_RETAINED)
50
+ return {};
51
+ const producer = JSON.stringify([input.session_id, input.agent_id ?? null]);
52
+ const tool = input.tool_name;
53
+ if (/^mcp__kobo-tasks__(create|update|finish)_progress_bar$/.test(tool)) {
54
+ const bar = progressResponse(input.tool_response);
55
+ if (!bar ||
56
+ typeof bar.id !== 'string' ||
57
+ !/^[\w-]{1,200}$/.test(bar.id) ||
58
+ !Number.isSafeInteger(bar.sequence) ||
59
+ bar.sequence < 0 ||
60
+ !['running', 'succeeded', 'failed', 'interrupted'].includes(bar.status))
61
+ return {};
62
+ if (retired.has(bar.id))
63
+ return {};
64
+ const existing = bars.get(bar.id);
65
+ if (existing && bar.sequence < existing.sequence)
66
+ return {};
67
+ if (bar.dismissed === true || bar.status !== 'running') {
68
+ bars.delete(bar.id);
69
+ // Parallel hook results may arrive after completion; never resurrect a reporter.
70
+ retired.add(bar.id);
71
+ return {};
72
+ }
73
+ // A retry is not progress. A real update may hand reporting to a delegate.
74
+ if (existing && bar.sequence <= existing.sequence)
75
+ return {};
76
+ if (!existing && !tool.endsWith('create_progress_bar'))
77
+ return {};
78
+ if (!existing && bars.size >= PROGRESS_MAX_RUNNING)
79
+ return {};
80
+ const state = { producer, sequence: bar.sequence, actions: 0, remindedAt: Date.now() };
81
+ bars.set(bar.id, state);
82
+ return existing ? {} : reminder([[bar.id, state]]);
83
+ }
84
+ if (tool.startsWith('mcp__kobo-tasks__') && tool.endsWith('progress_bars'))
85
+ return {};
86
+ const due = [];
87
+ for (const [id, state] of bars) {
88
+ if (state.producer !== producer)
89
+ continue;
90
+ state.actions++;
91
+ if (state.actions >= 8 && Date.now() - state.remindedAt >= 60_000 && due.length < 3) {
92
+ state.actions = 0;
93
+ state.remindedAt = Date.now();
94
+ due.push([id, state]);
95
+ }
96
+ }
97
+ return due.length ? reminder(due) : {};
98
+ };
99
+ }
@@ -1,6 +1,7 @@
1
1
  // src/server/services/forge/github/provider.ts
2
2
  import { execFile } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
+ import { MERGE_METHODS } from '../../../../shared/forge-merge.js';
4
5
  import { parseCliJson } from '../parse-cli-json.js';
5
6
  import { deriveReadyToMerge, truncatePrBody } from '../types.js';
6
7
  const execFileAsync = promisify(execFile);
@@ -157,6 +158,7 @@ export const githubProvider = {
157
158
  canCreatePr: true,
158
159
  canChangePrBase: true,
159
160
  canMergeRequest: true,
161
+ mergeMethods: MERGE_METHODS,
160
162
  canDeleteRemoteBranch: true,
161
163
  canListPullRequests: true,
162
164
  requestTermShort: 'PR',
@@ -192,8 +194,8 @@ export const githubProvider = {
192
194
  async changePrBase(repoPath, base) {
193
195
  await execFileAsync('gh', ['pr', 'edit', '--base', base], cliOptions(repoPath));
194
196
  },
195
- async mergeRequest(repoPath, number) {
196
- await execFileAsync('gh', ['pr', 'merge', String(number), '--merge'], cliOptions(repoPath));
197
+ async mergeRequest(repoPath, number, method = 'merge') {
198
+ await execFileAsync('gh', ['pr', 'merge', String(number), `--${method}`], cliOptions(repoPath));
197
199
  },
198
200
  async deleteRemoteBranch(repoPath, branch) {
199
201
  await execFileAsync('git', ['push', 'origin', '--delete', branch], cliOptions(repoPath));
@@ -1,4 +1,3 @@
1
- // src/server/services/forge/types.ts
2
1
  /**
3
2
  * True when nothing blocks merging the PR: it is open, CI is not failing or
4
3
  * in-flight (green `SUCCESS` or absent `null` — matching GitHub's "Ready to