@loicngr/kobo 1.7.22 → 1.7.23
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 +11 -1
- package/CHANGELOG.md +15 -0
- package/README.md +31 -0
- package/dist/server/db/migrations.js +23 -0
- package/dist/server/db/schema.js +2 -0
- package/dist/server/routes/workspaces.js +50 -1
- package/dist/server/services/pr-watcher-service.js +50 -1
- package/dist/server/services/settings-service.js +15 -0
- package/dist/server/services/templates-service.js +32 -0
- package/dist/server/services/workspace-service.js +34 -0
- package/dist/server/services/worktree-purge-service.js +151 -0
- package/package.json +1 -1
- package/src/client/dist/spa/assets/{ActivityFeed-B73SoxPh.js → ActivityFeed-CPvR4uDQ.js} +1 -1
- package/src/client/dist/spa/assets/{ChangelogPage-DUU0PIjz.js → ChangelogPage-DVX4nE1a.js} +1 -1
- package/src/client/dist/spa/assets/{ClosePopup-BG4U_b73.js → ClosePopup-0MWohgml.js} +1 -1
- package/src/client/dist/spa/assets/{CreatePage-CEDQHhlt.js → CreatePage-Ch-qSlc0.js} +1 -1
- package/src/client/dist/spa/assets/{DiffViewer-DzUan5hw.js → DiffViewer-CgTeedsM.js} +3 -3
- package/src/client/dist/spa/assets/{HealthPage-CywUBkCD.js → HealthPage-DKU-sJn_.js} +1 -1
- package/src/client/dist/spa/assets/{MainLayout-BxbC5qfx.css → MainLayout-DDTTsEj1.css} +1 -1
- package/src/client/dist/spa/assets/{MainLayout-BNlzhFX_.js → MainLayout-DohajLR7.js} +3 -3
- package/src/client/dist/spa/assets/{QExpansionItem-C3koMJhv.js → QExpansionItem-CiBP4NiY.js} +1 -1
- package/src/client/dist/spa/assets/{QMenu-tPOTRRE_.js → QMenu-Yx1QEIHC.js} +1 -1
- package/src/client/dist/spa/assets/{QScrollArea-HmkWoNcD.js → QScrollArea-CZVgBUBp.js} +1 -1
- package/src/client/dist/spa/assets/{QScrollObserver-mISyeTiK.js → QScrollObserver-CAlbLisQ.js} +1 -1
- package/src/client/dist/spa/assets/{QTooltip-CxkTFl9W.js → QTooltip-CwBZU_bs.js} +1 -1
- package/src/client/dist/spa/assets/{SearchPage-DaLCy-Tl.js → SearchPage-CdLjyU4e.js} +1 -1
- package/src/client/dist/spa/assets/SettingsPage-C64_E1oJ.css +1 -0
- package/src/client/dist/spa/assets/SettingsPage-CGRHDtMz.js +9 -0
- package/src/client/dist/spa/assets/{TouchPan-DWCVQZB9.js → TouchPan-DXGxNMLq.js} +1 -1
- package/src/client/dist/spa/assets/{WorkspacePage-iHGFGSo5.css → WorkspacePage-36QGRRCt.css} +1 -1
- package/src/client/dist/spa/assets/{WorkspacePage-DV1_gaE1.js → WorkspacePage-DNkEYqZA.js} +3 -3
- package/src/client/dist/spa/assets/{build-path-tree-CWS7OK1y.js → build-path-tree-BKx2q92A.js} +1 -1
- package/src/client/dist/spa/assets/{cssMode-gWXl0t1v.js → cssMode-HGFtnvIZ.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-DpdJ2VyL.js → editor.api-DEN1LTb2.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-BbBPm6hz.js → editor.main-CYqwDDag.js} +3 -3
- package/src/client/dist/spa/assets/{engineFeatures-CrrLAzmw.js → engineFeatures-MNa_Gl_7.js} +1 -1
- package/src/client/dist/spa/assets/{expand-template-C-j25C1U.js → expand-template-BYY8KsHo.js} +1 -1
- package/src/client/dist/spa/assets/{freemarker2-c2I2xP4t.js → freemarker2-DEXbr94k.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-DdGipD5f.js → handlebars-CNnnOJax.js} +1 -1
- package/src/client/dist/spa/assets/{html-C44mULGc.js → html-CXew9GOW.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-DCg3M8QF.js → htmlMode-DXK8cLF-.js} +1 -1
- package/src/client/dist/spa/assets/i18n-Imx3zg2i.js +1 -0
- package/src/client/dist/spa/assets/index-Bt877CHx.js +82 -0
- package/src/client/dist/spa/assets/{javascript-CE0OdK4f.js → javascript-DqQmRIT5.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-D4h7-bRc.js → jsonMode-B0EtHwQ3.js} +1 -1
- package/src/client/dist/spa/assets/{kobo-commands-CvhobBu_.js → kobo-commands-DRDkhOO8.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-PjQMywNm.js → liquid-XTxM_f1I.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-DFGLZOLU.js → mdx-DSvbDoxm.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-D-j1tvz2.js → monaco.contribution-Ba5eWJZT.js} +2 -2
- package/src/client/dist/spa/assets/{notifications-Dq_CVZlr.js → notifications-l1Pxijve.js} +1 -1
- package/src/client/dist/spa/assets/{permissionModes-Bt3HMECq.js → permissionModes-Bgj5fd8D.js} +1 -1
- package/src/client/dist/spa/assets/{python-DYXZ9PBg.js → python-CI0Uf8Q9.js} +1 -1
- package/src/client/dist/spa/assets/{razor-CuwVAV-Y.js → razor-BxdANj2h.js} +1 -1
- package/src/client/dist/spa/assets/{render-chat-markdown-D5id7lkF.js → render-chat-markdown-947qhJXu.js} +1 -1
- package/src/client/dist/spa/assets/{tsMode-CUhQNBlR.js → tsMode-cQhuNyf_.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-IVMaFcQM.js → typescript--Ek3HrQl.js} +1 -1
- package/src/client/dist/spa/assets/{use-onboarding-dapbWk0g.js → use-onboarding-Bn6RQzdN.js} +2 -2
- package/src/client/dist/spa/assets/{xml-BQ1-pFw9.js → xml-DQfJVXiS.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-CylVyFXK.js → yaml-Dg8Jkbdl.js} +1 -1
- package/src/client/dist/spa/index.html +2 -2
- package/src/client/dist/spa/assets/SettingsPage-D6AWEFEO.css +0 -1
- package/src/client/dist/spa/assets/SettingsPage-DlNl0TLR.js +0 -9
- package/src/client/dist/spa/assets/i18n-C1Tdl-zt.js +0 -1
- package/src/client/dist/spa/assets/index-djIkvHo7.js +0 -52
package/AGENTS.md
CHANGED
|
@@ -100,7 +100,7 @@ src/
|
|
|
100
100
|
|
|
101
101
|
| Table | Purpose |
|
|
102
102
|
|---|---|
|
|
103
|
-
| `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 |
|
|
103
|
+
| `workspaces` | the unit of work — id, name, project_path, source_branch, working_branch, status, notion_url, model, dev_server_status, `archived_at`, `worktree_purged_at`, `worktree_purge_restore_data` (JSON), `auto_loop`, `auto_loop_ready`, `no_progress_streak`, timestamps |
|
|
104
104
|
| `tasks` | workspace sub-items — title, status, `is_acceptance_criterion`, sort_order; CASCADE DELETE on workspace |
|
|
105
105
|
| `agent_sessions` | Claude Code CLI invocations — pid, `claude_session_id`, status, started_at, ended_at, `name` |
|
|
106
106
|
| `ws_events` | persisted WebSocket events for replay on reconnect — type, payload, session_id, created_at |
|
|
@@ -111,6 +111,8 @@ src/
|
|
|
111
111
|
|
|
112
112
|
`archived_at` is **orthogonal** to `status` — archiving is a visibility flag, not a lifecycle state. Unarchive restores the exact pre-archive `status`.
|
|
113
113
|
|
|
114
|
+
`worktree_purged_at` + `worktree_purge_restore_data` drive the disk-space purge feature (see [Worktree purge](#worktree-purge) below): when set, the workspace's worktree folder has been removed from disk but the chat history is preserved. `worktree_purge_restore_data` is a JSON blob (`{ prNumber, prUrl, forge, mergeCommitSha, originalWorktreePath, originalSourceBranch, originalWorkingBranch }`) captured at purge time for future "Restore" UX. Both fields are cleared automatically by the pr-watcher when the worktree folder reappears on disk.
|
|
115
|
+
|
|
114
116
|
`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.
|
|
115
117
|
|
|
116
118
|
## Database migrations
|
|
@@ -219,6 +221,14 @@ Background: the engine was migrated from `@openai/codex-sdk` (one-shot `codex ex
|
|
|
219
221
|
- **Custom bash override** — if `effective.changeSourceBranchScript` is non-empty (per-project override or global default), the script **replaces** the built-in flow. Spawned with `bash -c`, cwd = worktree, 5 min timeout, stderr captured (last 8 KB). Exit 0 → Kōbō updates the source-branch metadata; any non-zero exit → the stderr tail is propagated as a clean error. The user-facing menu item only shows when the resolved script is non-empty — empty = feature disabled (opt-in).
|
|
220
222
|
- **Custom-script env vars** — `KOBO_NEW_BASE`, `KOBO_OLD_BASE`, `KOBO_WORKING_BRANCH`, `KOBO_WORKTREE_PATH`, `KOBO_PROJECT_PATH`, `KOBO_PROJECT_NAME`, `KOBO_WORKSPACE_ID`, `KOBO_WORKSPACE_NAME`, `KOBO_FORGE`, `KOBO_PR_NUMBER` (empty when no PR/MR is open). The default script lives in `settings-defaults.ts` and is seeded into `global.changeSourceBranchScript` by settings migration v33; the client reads it through `GET /api/settings/defaults` for the "Reset to Kōbō default" button. See [CONFIGURATION.md → Custom change-source-branch script](CONFIGURATION.md#custom-change-source-branch-script).
|
|
221
223
|
|
|
224
|
+
### Worktree purge
|
|
225
|
+
|
|
226
|
+
`src/server/services/worktree-purge-service.ts` removes a workspace's worktree from disk while preserving the chat history and PR metadata. Triggered manually via `POST /api/workspaces/:id/purge-worktree` from the workspace context menu, or automatically by the pr-watcher when a PR transitions to MERGED **and** `global.autoPurgeOnPrMerged` is enabled (Settings → Worktrees toggle, settings migration v36).
|
|
227
|
+
|
|
228
|
+
Sequence: `captureRestoreData` (best-effort forge lookup for PR number / URL / merge SHA) → stop agent + dev server + terminal → `archiveWorkspace` → `removeWorktree` → `markWorktreePurged(restoreData)` → emit `workspace:worktree-purged`. Permission errors on removal (EACCES / EPERM — typically Docker-owned files in `node_modules` / `vendor`) are detected via regex on the error message and the warning toast carries a copy-pasteable `sudo rm -rf` + `git worktree prune` recovery command plus prevention tips (Docker `USER` directive, `setfacl` default ACL). See [CONFIGURATION.md → Auto-purge worktree on PR merged](CONFIGURATION.md#auto-purge-worktree-on-pr-merged).
|
|
229
|
+
|
|
230
|
+
**Auto-restore on manual recreation.** When the user manually recreates the worktree folder (`gh pr checkout <pr-number>` or `git worktree add <path> <branch>`), the pr-watcher detects the folder reappearing on its next 30 s tick via `autoRestoreManuallyRecreatedWorktrees()`: it iterates archived workspaces with `worktreePurgedAt`, checks `fs.existsSync(worktreePath)`, and on a hit calls `restoreWorktreeFromDisk(id)` which clears `worktree_purged_at` + `worktree_purge_restore_data` + `archived_at` in one transaction, then emits `workspace:worktree-restored`. The client websocket store reuses the same handler as `workspace:archived`/`unarchived` to refresh both the active and archived workspace lists. No UI action needed.
|
|
231
|
+
|
|
222
232
|
### Workspace attention indicators
|
|
223
233
|
|
|
224
234
|
`src/client/src/utils/workspace-attention.ts` derives a small set of badges (CI failure, changes-requested) from the PR snapshot + git stats stored on each workspace. `WorkspaceAttentionLabels.vue` renders them inline on the workspace cards in the left drawer. The derivation is a pure function — easy to unit-test, no IO. Drawer cards therefore stay reactive to whatever the pr-watcher / bulk-info refresh writes back into the store.
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,21 @@ 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
|
+
## 1.7.23
|
|
8
|
+
|
|
9
|
+
- docs: document worktree purge, auto-restore, and permission recovery
|
|
10
|
+
- feat(client): onboarding highlights changelog and auto-purge toggle
|
|
11
|
+
- feat(pr-watcher): auto-restore manually-recreated worktrees
|
|
12
|
+
- feat(workspace): worktree purge with auto-archive and restore metadata
|
|
13
|
+
- feat(templates): add /kobo-context slash command (you need to re-import default templates)
|
|
14
|
+
|
|
15
|
+
## Unreleased
|
|
16
|
+
|
|
17
|
+
- feat(workspace): disk-space purge — remove a workspace's worktree from disk while preserving chat history and PR metadata. Manual trigger from the workspace menu ("Libérer l'espace disque") or auto-purge on PR merged via Settings → Worktrees toggle.
|
|
18
|
+
- feat(workspace): auto-detect manual worktree restoration — when the user recreates a purged worktree folder (`gh pr checkout` or `git worktree add`), the pr-watcher detects it within 30 s and unarchives the workspace automatically.
|
|
19
|
+
- feat(client): dedicated banner for purged workspaces on the workspace page, with the restoration commands and `setfacl` / `chown` recovery guide for Docker-induced permission errors.
|
|
20
|
+
- fix(pr-watcher): skip workspaces whose worktree directory no longer exists, stopping the `spawn git ENOENT` log spam.
|
|
21
|
+
|
|
7
22
|
## 1.7.22
|
|
8
23
|
|
|
9
24
|
- feat(client): accept the new app.notion.com URL format
|
package/README.md
CHANGED
|
@@ -24,6 +24,7 @@ Kōbō runs multiple coding agents in parallel, each isolated in its own git wor
|
|
|
24
24
|
- **Scheduled wakeups** — the agent schedules a one-shot wake-up via the `ScheduleWakeup` tool; Kōbō persists it across restarts, shows a live countdown, and re-invokes the agent with the stored prompt at the chosen time.
|
|
25
25
|
- **Cron schedules** — recurring per-workspace triggers the agent registers through MCP tools (`cron_create` / `cron_delete` / `cron_list`); each tick resumes the workspace session (skipped if already active), and schedules are re-armed at boot with skip-missed semantics.
|
|
26
26
|
- **Lifecycle scripts** — shell scripts run automatically at key moments: **setup** (worktree created), **cleanup** (session ended), **archive** (workspace archived). Configured globally or per project, with their output streamed into the chat.
|
|
27
|
+
- **Disk-space purge** — free a merged workspace's disk space without losing its chat history: the worktree folder is removed (PR metadata is captured for later restore), the workspace is archived, and the chat log stays queryable. Trigger manually from the workspace menu or enable **auto-purge on PR merged** in Settings → Worktrees. Recreate the worktree later with `gh pr checkout <pr-number>` and Kōbō auto-detects the folder reappearing within 30 seconds — workspace is unarchived and reactivated, no UI action needed.
|
|
27
28
|
- **Optional integrations** — Notion (import missions), Sentry (fix from issue URL), local voice transcription (whisper.cpp).
|
|
28
29
|
|
|
29
30
|
## Quick start
|
|
@@ -77,6 +78,36 @@ The full reference — every env var, every setting key, MCP server registration
|
|
|
77
78
|
|
|
78
79
|
Engine selection happens at workspace creation. Both share the same task tracking, permission modes, sub-agent panel, and quota footer. The mapping of Kōbō's four permission modes (`plan` / `bypass` / `strict` / `interactive`) to each engine's native sandbox + approval semantics is in [`CONFIGURATION.md`](./CONFIGURATION.md#permission-modes).
|
|
79
80
|
|
|
81
|
+
## Disk-space purge
|
|
82
|
+
|
|
83
|
+
A merged workspace is automatically archived but its worktree folder usually carries a lot of weight (`node_modules`, `vendor`, build artefacts…). Kōbō can free that space without losing anything queryable:
|
|
84
|
+
|
|
85
|
+
- **Manual** — workspace context menu → *Libérer l'espace disque*. The worktree is removed, the chat history and PR metadata stay in the database.
|
|
86
|
+
- **Automatic** — **Settings → Worktrees → Purger le worktree quand la PR est mergée**. When the pr-watcher sees the OPEN → MERGED transition, it archives **and** purges.
|
|
87
|
+
- **Restore** — recreate the folder yourself (`gh pr checkout <pr>` or `git worktree add <path> <branch>`). The pr-watcher detects the directory reappearing within 30 seconds and re-activates the workspace automatically (clears purge flag + unarchives). No UI action needed.
|
|
88
|
+
|
|
89
|
+
### Avoiding permission errors during purge
|
|
90
|
+
|
|
91
|
+
Docker containers usually write as `root`, so files in `node_modules` / `vendor` end up root-owned on the host. Plain `rm -rf` (which Kōbō uses under the hood) then fails with `EACCES` / `EPERM`. Pick one of these strategies depending on your setup:
|
|
92
|
+
|
|
93
|
+
1. **Best — run your container as the host user.** Add a `USER` directive in your `Dockerfile`, or set `user: "${UID}:${GID}"` in `docker-compose.yml` with `UID`/`GID` exported in your shell. No more root-owned files; nothing extra to do.
|
|
94
|
+
2. **Preventive ACL on the worktrees root.** On ext4 / btrfs / xfs with a regular Docker bind mount, a default ACL grants your user access to every file created later:
|
|
95
|
+
```bash
|
|
96
|
+
setfacl -d -m u:$(whoami):rwX <worktrees-root> # e.g. ~/.worktrees
|
|
97
|
+
```
|
|
98
|
+
Caveats: does **not** work on named Docker volumes (use a bind mount), filesystems without ACL support (NTFS, exFAT, tmpfs), strict SELinux with `:Z`, or with Docker `userns-remap`.
|
|
99
|
+
3. **Unblock an already-broken worktree** (existing root-owned files):
|
|
100
|
+
```bash
|
|
101
|
+
# Option A — recursive ACL (keeps ownership intact, just adds your user)
|
|
102
|
+
sudo setfacl -Rd -m u:$(whoami):rwX . && sudo setfacl -R -m u:$(whoami):rwX .
|
|
103
|
+
|
|
104
|
+
# Option B — take ownership outright (simpler, loses the "root-from-container" trace)
|
|
105
|
+
sudo chown -R $(whoami):$(whoami) .
|
|
106
|
+
```
|
|
107
|
+
Run from inside the worktree folder, or directly on your worktrees root (e.g. `~/.worktrees/`) to cover all existing and future workspaces at once.
|
|
108
|
+
|
|
109
|
+
When a purge does fail, Kōbō surfaces a toast with a copy-pasteable recovery command and a `git worktree prune` follow-up. The same guide is wired into **Settings → Worktrees → Comment ça marche** for in-app reference.
|
|
110
|
+
|
|
80
111
|
## Optional integrations
|
|
81
112
|
|
|
82
113
|
Kōbō ships first-class support for three external systems. All are opt-in and reuse credentials you may already have configured for Claude Code.
|
|
@@ -339,6 +339,29 @@ export const migrations = [
|
|
|
339
339
|
}
|
|
340
340
|
},
|
|
341
341
|
},
|
|
342
|
+
{
|
|
343
|
+
version: 27,
|
|
344
|
+
name: 'add-workspace-worktree-purge',
|
|
345
|
+
migrate: (db) => {
|
|
346
|
+
// Worktree purge: deletes the worktree from disk to reclaim space while
|
|
347
|
+
// keeping the chat/session history queryable. `worktree_purged_at` is the
|
|
348
|
+
// ISO timestamp of the purge (null = not purged). `worktree_purge_restore_data`
|
|
349
|
+
// is a JSON blob with the metadata needed to restore the worktree later
|
|
350
|
+
// (PR number, forge, merge commit sha, original paths). Restoration is
|
|
351
|
+
// not implemented yet, but the data is captured at purge time so future
|
|
352
|
+
// versions can rebuild the worktree from the merged PR.
|
|
353
|
+
const table = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='workspaces'").get();
|
|
354
|
+
if (!table)
|
|
355
|
+
return;
|
|
356
|
+
const cols = db.prepare('PRAGMA table_info(workspaces)').all();
|
|
357
|
+
if (!cols.some((c) => c.name === 'worktree_purged_at')) {
|
|
358
|
+
db.prepare('ALTER TABLE workspaces ADD COLUMN worktree_purged_at TEXT').run();
|
|
359
|
+
}
|
|
360
|
+
if (!cols.some((c) => c.name === 'worktree_purge_restore_data')) {
|
|
361
|
+
db.prepare('ALTER TABLE workspaces ADD COLUMN worktree_purge_restore_data TEXT').run();
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
},
|
|
342
365
|
];
|
|
343
366
|
/** Current schema version — always equals the highest migration version. */
|
|
344
367
|
export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
|
package/dist/server/db/schema.js
CHANGED
|
@@ -35,6 +35,7 @@ import * as terminalService from '../services/terminal-service.js';
|
|
|
35
35
|
import * as wakeupService from '../services/wakeup-service.js';
|
|
36
36
|
import * as wsService from '../services/websocket-service.js';
|
|
37
37
|
import * as workspaceService from '../services/workspace-service.js';
|
|
38
|
+
import * as purgeWorktreeService from '../services/worktree-purge-service.js';
|
|
38
39
|
import * as worktreeService from '../services/worktree-service.js';
|
|
39
40
|
import { resolveUniqueBranchAndPath } from '../utils/branch-resolver.js';
|
|
40
41
|
import * as gitOps from '../utils/git-ops.js';
|
|
@@ -1922,6 +1923,31 @@ app.post('/:id/archive', migrationGuard, (c) => {
|
|
|
1922
1923
|
return c.json({ error: message }, 500);
|
|
1923
1924
|
}
|
|
1924
1925
|
});
|
|
1926
|
+
// POST /api/workspaces/:id/purge-worktree — delete the worktree from disk
|
|
1927
|
+
// to reclaim space while keeping the chat / session history queryable. Auto-
|
|
1928
|
+
// archives the workspace, stops the agent / dev server / terminal, and
|
|
1929
|
+
// records restore metadata so a future feature can rebuild the worktree
|
|
1930
|
+
// from the merged PR.
|
|
1931
|
+
app.post('/:id/purge-worktree', migrationGuard, async (c) => {
|
|
1932
|
+
try {
|
|
1933
|
+
const id = c.req.param('id');
|
|
1934
|
+
const result = await purgeWorktreeService.purgeWorktree(id);
|
|
1935
|
+
if (result.outcome === 'not-found') {
|
|
1936
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1937
|
+
}
|
|
1938
|
+
if (result.outcome === 'worktree-not-owned') {
|
|
1939
|
+
return c.json({
|
|
1940
|
+
error: "This workspace attached to an external worktree (you own it). Kōbō refuses to delete files it didn't create.",
|
|
1941
|
+
}, 400);
|
|
1942
|
+
}
|
|
1943
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
1944
|
+
return c.json({ workspace, warnings: result.warnings, outcome: result.outcome });
|
|
1945
|
+
}
|
|
1946
|
+
catch (err) {
|
|
1947
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1948
|
+
return c.json({ error: message }, 500);
|
|
1949
|
+
}
|
|
1950
|
+
});
|
|
1925
1951
|
// POST /api/workspaces/:id/unarchive — restore an archived workspace
|
|
1926
1952
|
app.post('/:id/unarchive', migrationGuard, (c) => {
|
|
1927
1953
|
try {
|
|
@@ -1933,6 +1959,14 @@ app.post('/:id/unarchive', migrationGuard, (c) => {
|
|
|
1933
1959
|
if (!workspace.archivedAt) {
|
|
1934
1960
|
return c.json({ error: 'Not archived' }, 400);
|
|
1935
1961
|
}
|
|
1962
|
+
// A workspace whose worktree was purged from disk can't be safely
|
|
1963
|
+
// unarchived — its worktreePath points to nothing, every git/forge call
|
|
1964
|
+
// would fail, and the agent has no working tree. Force the user to
|
|
1965
|
+
// restore the worktree first (manual `gh pr checkout` / `git worktree
|
|
1966
|
+
// add`); the pr-watcher then auto-clears the purge flag + unarchives.
|
|
1967
|
+
if (workspace.worktreePurgedAt) {
|
|
1968
|
+
return c.json({ error: 'worktree-purged' }, 409);
|
|
1969
|
+
}
|
|
1936
1970
|
const updated = workspaceService.unarchiveWorkspace(id);
|
|
1937
1971
|
wsService.emitEphemeral(id, 'workspace:unarchived', { workspace: updated });
|
|
1938
1972
|
return c.json(updated);
|
|
@@ -1956,6 +1990,14 @@ function deleteWorkspaceWithSideEffects(workspace, opts) {
|
|
|
1956
1990
|
catch {
|
|
1957
1991
|
// Agent may not be running — ignore
|
|
1958
1992
|
}
|
|
1993
|
+
// Stop dev server if it was running. The processSpawn would otherwise
|
|
1994
|
+
// outlive the workspace (and keep its port + docker containers alive).
|
|
1995
|
+
try {
|
|
1996
|
+
devServerService.stopDevServer(workspace.id);
|
|
1997
|
+
}
|
|
1998
|
+
catch (err) {
|
|
1999
|
+
console.error(`[workspaces] stopDevServer during delete failed for '${workspace.name}':`, err);
|
|
2000
|
+
}
|
|
1959
2001
|
try {
|
|
1960
2002
|
terminalService.destroyTerminal(workspace.id);
|
|
1961
2003
|
}
|
|
@@ -1972,7 +2014,14 @@ function deleteWorkspaceWithSideEffects(workspace, opts) {
|
|
|
1972
2014
|
// Remove worktree (only if owned — for attached external worktrees we
|
|
1973
2015
|
// never created the dir, so we must not delete it on the user's behalf).
|
|
1974
2016
|
const worktreePath = workspace.worktreePath;
|
|
1975
|
-
if (workspace.
|
|
2017
|
+
if (workspace.worktreePurgedAt) {
|
|
2018
|
+
// Already purged earlier: the worktree folder is gone AND `git worktree
|
|
2019
|
+
// remove` already cleaned the `.git/worktrees/<name>/` entry. Retrying
|
|
2020
|
+
// here just fails with "fatal: <path> does not exist" and pushes a noisy
|
|
2021
|
+
// warning. Skip cleanly.
|
|
2022
|
+
console.log(`[workspaces] skipping worktree removal on delete (already purged): ${worktreePath}`);
|
|
2023
|
+
}
|
|
2024
|
+
else if (workspace.worktreeOwned) {
|
|
1976
2025
|
try {
|
|
1977
2026
|
worktreeService.removeWorktree(workspace.projectPath, worktreePath);
|
|
1978
2027
|
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
1
2
|
import { fetchSourceBranchAsync } from '../utils/git-ops.js';
|
|
2
3
|
import { stopDevServer } from './dev-server-service.js';
|
|
3
4
|
import { getForgeProvider } from './forge/registry.js';
|
|
4
5
|
import { resolveForge } from './forge/resolve.js';
|
|
5
6
|
import { computeGitStats } from './git-stats-service.js';
|
|
7
|
+
import { getGlobalSettings } from './settings-service.js';
|
|
6
8
|
import { destroyTerminal } from './terminal-service.js';
|
|
7
9
|
import { emitEphemeral } from './websocket-service.js';
|
|
8
|
-
import { archiveWorkspace, getWorkspace, listWorkspaces, markWorkspaceUnread, updateWorkspaceSourceBranch, } from './workspace-service.js';
|
|
10
|
+
import { archiveWorkspace, getWorkspace, listArchivedWorkspaces, listWorkspaces, markWorkspaceUnread, restoreWorktreeFromDisk, updateWorkspaceSourceBranch, } from './workspace-service.js';
|
|
11
|
+
import { purgeWorktree } from './worktree-purge-service.js';
|
|
9
12
|
// ── PR Watcher ────────────────────────────────────────────────────────────────
|
|
10
13
|
// Polls GitHub every POLL_INTERVAL_MS to detect merged/closed PRs and
|
|
11
14
|
// automatically archive the corresponding workspace.
|
|
@@ -63,7 +66,30 @@ function markUnread(workspaceId) {
|
|
|
63
66
|
console.error('[pr-watcher] markUnread failed:', err instanceof Error ? err.message : err);
|
|
64
67
|
}
|
|
65
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Detect manually-restored worktrees: if a purged workspace's worktreePath
|
|
71
|
+
* has reappeared on disk (user ran `gh pr checkout` or `git worktree add`
|
|
72
|
+
* themselves), clear the purge metadata + unarchive it. Best-effort, runs
|
|
73
|
+
* once per watcher tick before the PR-status pass.
|
|
74
|
+
*/
|
|
75
|
+
function autoRestoreManuallyRecreatedWorktrees() {
|
|
76
|
+
for (const ws of listArchivedWorkspaces()) {
|
|
77
|
+
if (!ws.worktreePurgedAt)
|
|
78
|
+
continue;
|
|
79
|
+
if (!fs.existsSync(ws.worktreePath))
|
|
80
|
+
continue;
|
|
81
|
+
try {
|
|
82
|
+
const restored = restoreWorktreeFromDisk(ws.id);
|
|
83
|
+
emitEphemeral(ws.id, 'workspace:worktree-restored', { workspace: restored });
|
|
84
|
+
console.log(`[pr-watcher] auto-restored worktree for workspace '${ws.name}' (manual restore detected)`);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
console.error(`[pr-watcher] auto-restore failed for '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
66
91
|
export async function checkPrStatuses() {
|
|
92
|
+
autoRestoreManuallyRecreatedWorktrees();
|
|
67
93
|
const workspaces = listWorkspaces(false); // non-archived only
|
|
68
94
|
// Clean up entries for workspaces that no longer exist
|
|
69
95
|
for (const id of lastKnownPr.keys()) {
|
|
@@ -77,6 +103,11 @@ export async function checkPrStatuses() {
|
|
|
77
103
|
}
|
|
78
104
|
}
|
|
79
105
|
for (const ws of workspaces) {
|
|
106
|
+
// Skip workspaces whose worktree directory has disappeared (purged
|
|
107
|
+
// externally, manually deleted). Without this guard, every git/forge
|
|
108
|
+
// spawn below fails with ENOENT and floods the logs.
|
|
109
|
+
if (!fs.existsSync(ws.worktreePath))
|
|
110
|
+
continue;
|
|
80
111
|
try {
|
|
81
112
|
const pr = await getForgeProvider(resolveForge(ws.projectPath)).getPrStatus(ws.worktreePath, ws.workingBranch);
|
|
82
113
|
// Detect a PR base change BEFORE computing git stats so the new base
|
|
@@ -151,6 +182,24 @@ export async function checkPrStatuses() {
|
|
|
151
182
|
reason: `PR ${pr.state.toLowerCase()}`,
|
|
152
183
|
prUrl: pr.url,
|
|
153
184
|
});
|
|
185
|
+
// Auto-purge the worktree on MERGED + opt-in setting. The purge
|
|
186
|
+
// service is idempotent + best-effort: any failure surfaces as
|
|
187
|
+
// warnings on the next listing instead of crashing the watcher.
|
|
188
|
+
// Only acts on MERGED — closed-without-merge keeps the worktree
|
|
189
|
+
// around so the user can inspect / push fixes.
|
|
190
|
+
if (pr.state === 'MERGED') {
|
|
191
|
+
try {
|
|
192
|
+
const { autoPurgeOnPrMerged } = getGlobalSettings();
|
|
193
|
+
if (autoPurgeOnPrMerged) {
|
|
194
|
+
void purgeWorktree(ws.id).catch((err) => {
|
|
195
|
+
console.error(`[pr-watcher] auto-purge failed for '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
console.error(`[pr-watcher] auto-purge guard failed for '${ws.name}':`, err instanceof Error ? err.message : err);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
154
203
|
continue; // do not run base-change detection on a workspace we just archived
|
|
155
204
|
}
|
|
156
205
|
// Review-decision and CI transitions (only on OPEN PRs; first-sight is
|
|
@@ -598,6 +598,19 @@ const settingsMigrations = [
|
|
|
598
598
|
}
|
|
599
599
|
},
|
|
600
600
|
},
|
|
601
|
+
{
|
|
602
|
+
version: 36,
|
|
603
|
+
name: 'add-auto-purge-on-pr-merged',
|
|
604
|
+
migrate: ({ global }) => {
|
|
605
|
+
// Opt-in: when true, the pr-watcher purges the worktree from disk
|
|
606
|
+
// immediately after auto-archiving on a PR merge transition. The
|
|
607
|
+
// workspace stays consultable (chat history, sessions) but reclaims
|
|
608
|
+
// the disk space. Default false — feature is silent until enabled.
|
|
609
|
+
if (typeof global.autoPurgeOnPrMerged !== 'boolean') {
|
|
610
|
+
global.autoPurgeOnPrMerged = false;
|
|
611
|
+
}
|
|
612
|
+
},
|
|
613
|
+
},
|
|
601
614
|
];
|
|
602
615
|
/** Current settings schema version — always equals the highest migration version. */
|
|
603
616
|
export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
|
|
@@ -641,6 +654,7 @@ function defaultSettings() {
|
|
|
641
654
|
changeSourceBranchScript: DEFAULT_CHANGE_SOURCE_BRANCH_SCRIPT,
|
|
642
655
|
editorCommand: '',
|
|
643
656
|
fileManagerCommand: '',
|
|
657
|
+
autoPurgeOnPrMerged: false,
|
|
644
658
|
browserNotifications: true,
|
|
645
659
|
audioNotifications: true,
|
|
646
660
|
audioNotificationSound: 'hey.mp3',
|
|
@@ -979,6 +993,7 @@ export function updateGlobalSettings(data) {
|
|
|
979
993
|
'changeSourceBranchScript',
|
|
980
994
|
'editorCommand',
|
|
981
995
|
'fileManagerCommand',
|
|
996
|
+
'autoPurgeOnPrMerged',
|
|
982
997
|
'browserNotifications',
|
|
983
998
|
'audioNotifications',
|
|
984
999
|
'audioNotificationSound',
|
|
@@ -127,6 +127,38 @@ export function replaceAllTemplates(templates) {
|
|
|
127
127
|
writeTemplates(validated);
|
|
128
128
|
}
|
|
129
129
|
export const DEFAULT_TEMPLATES = [
|
|
130
|
+
{
|
|
131
|
+
slug: 'kobo-context',
|
|
132
|
+
description: "Onboard the agent on Kōbō's core concepts and tools",
|
|
133
|
+
content: `You are working inside a Kōbō workspace (workspace "{workspace_name}", branch \`{working_branch}\`).\n\n` +
|
|
134
|
+
`# What Kōbō is\n` +
|
|
135
|
+
`Kōbō orchestrates multiple coding agents in parallel. Each "workspace" is a self-contained mission with:\n` +
|
|
136
|
+
`- An isolated git worktree (your current working directory)\n` +
|
|
137
|
+
`- A dedicated branch (\`{working_branch}\`), targeting a source branch\n` +
|
|
138
|
+
`- Its own session history and task list, persisted in Kōbō's SQLite DB\n` +
|
|
139
|
+
`- A dedicated MCP server (\`kobo-tasks\`) exposing tools to read/write workspace state\n\n` +
|
|
140
|
+
`# Lifecycle\n` +
|
|
141
|
+
`1. **Brainstorming** — you scope the work, output a plan, end with the literal marker \`[BRAINSTORM_COMPLETE]\`\n` +
|
|
142
|
+
`2. **Executing** — you implement the plan, commit, push\n` +
|
|
143
|
+
`3. **Auto-loop (opt-in)** — Kōbō re-spawns a fresh session per task; each iteration sees a clean context\n` +
|
|
144
|
+
`4. **Completed / Archived** — the workspace freezes; the worktree stays available read-only\n\n` +
|
|
145
|
+
`# Kōbō MCP tools (always namespaced \`kobo__…\`)\n` +
|
|
146
|
+
`- \`kobo__list_tasks\` / \`create_task\` / \`update_task\` / \`mark_task_done\` / \`delete_task\` — manage the visible task list\n` +
|
|
147
|
+
`- \`kobo__set_workspace_agent_description\` — short one-line summary shown in the sidebar; keep it current\n` +
|
|
148
|
+
`- \`kobo__get_workspace_info\` / \`kobo__get_git_info\` — read workspace metadata + git state\n` +
|
|
149
|
+
`- \`kobo__cron_create\` / \`cron_delete\` / \`cron_list\` — schedule recurring or one-shot triggers on THIS workspace\n` +
|
|
150
|
+
`- \`kobo__mark_auto_loop_ready\` — flip the loop into auto-execution after grooming\n\n` +
|
|
151
|
+
`# Conventions\n` +
|
|
152
|
+
`- \`CLAUDE.md\` / \`AGENTS.md\` at the project root override default behavior — read them first\n` +
|
|
153
|
+
`- \`.ai/.git-conventions.md\` (when present) defines per-project commit / branch rules — apply them on every git op\n` +
|
|
154
|
+
`- \`.ai/thoughts/\` is your persistent scratch (Notion imports, Sentry context, planning notes) — write freely\n` +
|
|
155
|
+
`- Never use \`--no-verify\` or skip CI hooks unless explicitly asked\n` +
|
|
156
|
+
`- Always target \`origin/<source_branch>\` for diffs and PRs, not the local branch\n\n` +
|
|
157
|
+
`# Boundaries\n` +
|
|
158
|
+
`- The user owns the \`description\` field of the workspace — never write it; you only own \`agent_description\`\n` +
|
|
159
|
+
`- The user can interrupt you at any time via the chat; treat their messages as authoritative redirections\n` +
|
|
160
|
+
`- Auto-loop is automatically disabled if the user sends a chat message during a loop — they'll re-enable it manually after\n`,
|
|
161
|
+
},
|
|
130
162
|
{
|
|
131
163
|
slug: 'review-quality',
|
|
132
164
|
description: 'Code quality review',
|
|
@@ -59,6 +59,8 @@ function mapWorkspace(row) {
|
|
|
59
59
|
initialPrompt: row.initial_prompt,
|
|
60
60
|
prChangesDismissedAt: row.pr_changes_dismissed_at,
|
|
61
61
|
prCiFailureDismissedAt: row.pr_ci_failure_dismissed_at,
|
|
62
|
+
worktreePurgedAt: row.worktree_purged_at,
|
|
63
|
+
worktreePurgeRestoreData: row.worktree_purge_restore_data,
|
|
62
64
|
engine: row.engine ?? 'claude-code',
|
|
63
65
|
autoLoop: row.auto_loop === 1,
|
|
64
66
|
autoLoopReady: row.auto_loop_ready === 1,
|
|
@@ -365,6 +367,38 @@ export function dismissPrAttention(id, kind, prUpdatedAt) {
|
|
|
365
367
|
throw new Error(`Workspace '${id}' not found`);
|
|
366
368
|
}
|
|
367
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* Mark a workspace's worktree as purged from disk. Persists the supplied
|
|
372
|
+
* restore data as JSON so a future "unpurge" feature can recreate the
|
|
373
|
+
* worktree from the merged PR. Idempotent: re-purging a workspace just
|
|
374
|
+
* overwrites the timestamp + data.
|
|
375
|
+
*/
|
|
376
|
+
export function markWorktreePurged(id, restoreData) {
|
|
377
|
+
const db = getDb();
|
|
378
|
+
const now = new Date().toISOString();
|
|
379
|
+
const result = db
|
|
380
|
+
.prepare('UPDATE workspaces SET worktree_purged_at = ?, worktree_purge_restore_data = ?, updated_at = ? WHERE id = ?')
|
|
381
|
+
.run(now, JSON.stringify(restoreData), now, id);
|
|
382
|
+
if (result.changes === 0) {
|
|
383
|
+
throw new Error(`Workspace '${id}' not found`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Reverse of `markWorktreePurged` + `archiveWorkspace`: clears the purge
|
|
388
|
+
* metadata and unarchives the workspace in a single transaction. Used by the
|
|
389
|
+
* auto-restore watcher when the user manually recreates the worktree folder
|
|
390
|
+
* on disk. Idempotent: no-op if already restored.
|
|
391
|
+
*/
|
|
392
|
+
export function restoreWorktreeFromDisk(id) {
|
|
393
|
+
const db = getDb();
|
|
394
|
+
const workspace = getWorkspace(id);
|
|
395
|
+
if (!workspace) {
|
|
396
|
+
throw new Error(`Workspace '${id}' not found`);
|
|
397
|
+
}
|
|
398
|
+
const now = new Date().toISOString();
|
|
399
|
+
db.prepare('UPDATE workspaces SET worktree_purged_at = NULL, worktree_purge_restore_data = NULL, archived_at = NULL, updated_at = ? WHERE id = ?').run(now, id);
|
|
400
|
+
return getWorkspace(id);
|
|
401
|
+
}
|
|
368
402
|
/** Update the dev-server status column for a workspace. */
|
|
369
403
|
export function updateDevServerStatus(id, status) {
|
|
370
404
|
const db = getDb();
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import * as agentManager from './agent/orchestrator.js';
|
|
3
|
+
import * as devServerService from './dev-server-service.js';
|
|
4
|
+
import { getForgeProvider } from './forge/registry.js';
|
|
5
|
+
import { resolveForge } from './forge/resolve.js';
|
|
6
|
+
import { destroyTerminal } from './terminal-service.js';
|
|
7
|
+
import { emitEphemeral } from './websocket-service.js';
|
|
8
|
+
import { archiveWorkspace, getWorkspace, markWorktreePurged, } from './workspace-service.js';
|
|
9
|
+
import { removeWorktree } from './worktree-service.js';
|
|
10
|
+
/**
|
|
11
|
+
* Purge the worktree of a workspace: stops the agent, the dev server and the
|
|
12
|
+
* terminal (best-effort), removes the worktree dir + git registration, then
|
|
13
|
+
* records the purge timestamp + restore metadata so a future version can
|
|
14
|
+
* recreate the worktree from the merged PR.
|
|
15
|
+
*
|
|
16
|
+
* The workspace is auto-archived on purge — disk-purged is a strict superset
|
|
17
|
+
* of archived (no agent can run on a missing worktree). Archive emits its
|
|
18
|
+
* own event before this function tags the workspace as purged.
|
|
19
|
+
*/
|
|
20
|
+
export async function purgeWorktree(workspaceId) {
|
|
21
|
+
const workspace = getWorkspace(workspaceId);
|
|
22
|
+
if (!workspace)
|
|
23
|
+
return { outcome: 'not-found', warnings: [] };
|
|
24
|
+
if (workspace.worktreePurgedAt)
|
|
25
|
+
return { outcome: 'already-purged', warnings: [] };
|
|
26
|
+
if (!workspace.worktreeOwned)
|
|
27
|
+
return { outcome: 'worktree-not-owned', warnings: [] };
|
|
28
|
+
const warnings = [];
|
|
29
|
+
// Best-effort cleanup before touching the disk. A failure here is logged
|
|
30
|
+
// but does NOT block the purge — leaving a stranded worktree on disk would
|
|
31
|
+
// defeat the feature's purpose.
|
|
32
|
+
try {
|
|
33
|
+
agentManager.stopAgent(workspaceId);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
37
|
+
console.error(`[purge] stopAgent failed for '${workspace.name}':`, msg);
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
devServerService.stopDevServer(workspaceId);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
44
|
+
console.error(`[purge] stopDevServer failed for '${workspace.name}':`, msg);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
destroyTerminal(workspaceId);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// terminal may not exist — silent
|
|
51
|
+
}
|
|
52
|
+
// Capture restore metadata BEFORE removing the worktree so we still have a
|
|
53
|
+
// chance to query the forge for the latest PR snapshot.
|
|
54
|
+
const restoreData = await captureRestoreData(workspace);
|
|
55
|
+
// Archive first so the workspace:archived event fires while the worktree
|
|
56
|
+
// still technically exists — listeners that snapshot disk state get a
|
|
57
|
+
// consistent picture. Archive is idempotent.
|
|
58
|
+
if (!workspace.archivedAt) {
|
|
59
|
+
try {
|
|
60
|
+
const archived = archiveWorkspace(workspaceId);
|
|
61
|
+
emitEphemeral(workspaceId, 'workspace:archived', { workspace: archived });
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
65
|
+
warnings.push(`Archive failed: ${msg}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Remove the worktree. fs.existsSync guard handles the case where the user
|
|
69
|
+
// already nuked it externally — the git registration still needs cleaning,
|
|
70
|
+
// which removeWorktree does too.
|
|
71
|
+
if (fs.existsSync(workspace.worktreePath)) {
|
|
72
|
+
try {
|
|
73
|
+
removeWorktree(workspace.projectPath, workspace.worktreePath);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
77
|
+
warnings.push(buildRemovalFailureMessage(workspace.worktreePath, workspace.projectPath, msg));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
markWorktreePurged(workspaceId, restoreData);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
85
|
+
warnings.push(`DB metadata update failed: ${msg}`);
|
|
86
|
+
}
|
|
87
|
+
emitEphemeral(workspaceId, 'workspace:worktree-purged', {
|
|
88
|
+
workspaceId,
|
|
89
|
+
purgedAt: new Date().toISOString(),
|
|
90
|
+
restoreData,
|
|
91
|
+
});
|
|
92
|
+
return { outcome: 'purged', warnings };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Pretty failure message for `removeWorktree` errors. Detects permission
|
|
96
|
+
* issues (typical when Docker / docker-compose left root-owned files in
|
|
97
|
+
* `node_modules`, `vendor`, etc.) and surfaces a one-line `sudo` command
|
|
98
|
+
* the user can paste to recover. Other errors get a generic recovery hint.
|
|
99
|
+
*/
|
|
100
|
+
function buildRemovalFailureMessage(worktreePath, projectPath, errMsg) {
|
|
101
|
+
const isPermission = /EACCES|EPERM|permission denied|operation not permitted/i.test(errMsg);
|
|
102
|
+
const baseLine = `Failed to remove worktree '${worktreePath}'.`;
|
|
103
|
+
const recovery = ['Recovery:', ` sudo rm -rf '${worktreePath}'`, ` cd '${projectPath}' && git worktree prune`].join('\n');
|
|
104
|
+
if (isPermission) {
|
|
105
|
+
return [
|
|
106
|
+
`${baseLine} Permission denied — typically caused by Docker leaving root-owned files inside node_modules / vendor.`,
|
|
107
|
+
recovery,
|
|
108
|
+
'Prevention: configure your container to run as your host user (USER directive in Dockerfile, or `user: "$(id -u):$(id -g)"` in docker-compose), OR pre-seed the worktrees root with a default ACL:',
|
|
109
|
+
` setfacl -d -m u:$(whoami):rwx '${projectPath}/..'/worktrees`,
|
|
110
|
+
`Reason: ${errMsg}`,
|
|
111
|
+
].join('\n');
|
|
112
|
+
}
|
|
113
|
+
return [baseLine, recovery, `Reason: ${errMsg}`].join('\n');
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Build the restore-data snapshot stored alongside the purge. Reads the PR
|
|
117
|
+
* snapshot from the watcher cache when available, then tries a fresh forge
|
|
118
|
+
* lookup as a fallback. Never throws — best-effort. Reserved for a future
|
|
119
|
+
* unpurge feature.
|
|
120
|
+
*/
|
|
121
|
+
async function captureRestoreData(workspace) {
|
|
122
|
+
const forge = resolveForge(workspace.projectPath);
|
|
123
|
+
let prNumber = null;
|
|
124
|
+
let prUrl = null;
|
|
125
|
+
// Forge lookup is best-effort — silent on failure since the field is
|
|
126
|
+
// purely advisory for the future unpurge feature. We always go direct to
|
|
127
|
+
// the provider (no pr-watcher cache) to avoid an import cycle between
|
|
128
|
+
// the watcher and the purge service.
|
|
129
|
+
if (forge !== 'none') {
|
|
130
|
+
try {
|
|
131
|
+
const provider = getForgeProvider(forge);
|
|
132
|
+
const fresh = await provider.getPrStatus(workspace.worktreePath, workspace.workingBranch);
|
|
133
|
+
if (fresh) {
|
|
134
|
+
prNumber = fresh.number ?? null;
|
|
135
|
+
prUrl = fresh.url ?? null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
console.warn(`[purge] PR lookup for restore data failed:`, err instanceof Error ? err.message : err);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
prNumber,
|
|
144
|
+
prUrl,
|
|
145
|
+
forge: forge,
|
|
146
|
+
mergeCommitSha: null,
|
|
147
|
+
originalWorktreePath: workspace.worktreePath,
|
|
148
|
+
originalSourceBranch: workspace.sourceBranch,
|
|
149
|
+
originalWorkingBranch: workspace.workingBranch,
|
|
150
|
+
};
|
|
151
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loicngr/kobo",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.23",
|
|
4
4
|
"description": "Kōbō — multi-workspace agent manager for Claude Code. Orchestrates isolated git worktrees with dev servers, Notion integration, and MCP tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "GPL-3.0-or-later",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{D as e,G as t,I as n,N as r,P as i,R as a,St as o,W as s,_ as c,at as l,d as u,et as d,f,g as p,l as m,p as h,r as g,u as _,xt as v,y,yt as b}from"./runtime-core.esm-bundler-D_RRiKBh.js";import{U as x,l as S,t as C}from"./QIcon-BmEX2rXO.js";import{c as w,s as T}from"./notifications-
|
|
1
|
+
import{D as e,G as t,I as n,N as r,P as i,R as a,St as o,W as s,_ as c,at as l,d as u,et as d,f,g as p,l as m,p as h,r as g,u as _,xt as v,y,yt as b}from"./runtime-core.esm-bundler-D_RRiKBh.js";import{U as x,l as S,t as C}from"./QIcon-BmEX2rXO.js";import{c as w,s as T}from"./notifications-l1Pxijve.js";import{t as E}from"./QBtn-CoU-UC_j.js";import{n as D}from"./vue-i18n-Cq-KgjJC.js";import{_ as O,c as k,m as A,u as j}from"./index-Bt877CHx.js";import{t as M}from"./QSpinnerDots-DspFKwCZ.js";import{t as N}from"./QTooltip-CwBZU_bs.js";import{t as ee}from"./QExpansionItem-CiBP4NiY.js";import{t as te}from"./QScrollArea-CZVgBUBp.js";import{i as ne,n as re,t as P}from"./render-chat-markdown-947qhJXu.js";import{t as F}from"./documents-CX2-4fhr.js";import{t as I}from"./_plugin-vue_export-helper-r4mAJOHR.js";function ie(e,t,n=!0){let r=[],i=new Map,a=new Map;for(let n=0;n<e.length;n++){let o=e[n],s=t?.[n];switch(o.kind){case`message:text`:{let e=i.get(o.messageId);if(e)e.text+=o.text,e.streaming=o.streaming;else{let e={type:`text`,messageId:o.messageId,text:o.text,streaming:o.streaming,ts:s};i.set(o.messageId,e),r.push(e)}break}case`message:end`:{let e=i.get(o.messageId);e&&(e.streaming=!1);break}case`message:thinking`:r.push({type:`thinking`,messageId:o.messageId,text:o.text,ts:s});break;case`tool:call`:{let e={type:`tool`,toolCallId:o.toolCallId,name:o.name,input:o.input,ts:s};a.set(o.toolCallId,e),r.push(e);break}case`tool:result`:{let e=a.get(o.toolCallId);e&&(e.result={output:o.output,isError:o.isError});break}case`session:started`:r.push({type:`session`,kind:`started`,detail:{engineSessionId:o.engineSessionId,model:o.model},ts:s});break;case`session:ended`:r.push({type:`session`,kind:`ended`,detail:{reason:o.reason,exitCode:o.exitCode},ts:s});break;case`session:compacted`:r.push({type:`session`,kind:`compacted`,ts:s});break;case`session:brainstorm-complete`:case`session:user-input-requested`:case`message:raw`:case`skills:discovered`:case`usage`:case`rate_limit`:case`subagent:progress`:case`error`:break;default:}}let o=null;for(let e of r)e.type===`text`&&e.streaming&&(o&&(o.streaming=!1),o=e);return o&&!n&&(o.streaming=!1),r}function ae(e,t){if(t.length===0)return e;let n=t.map(e=>({type:`user`,content:e.content,sender:e.sender,ts:e.ts})),r=[...e,...n];r.sort((e,t)=>{let n=e.ts??``,r=t.ts??``;return n===r?0:n?r?n<r?-1:1:-1:1});let i;for(let e of r)e.type===`user`&&e.sender!==`system-prompt`&&e.ts&&(!i||e.ts>i)&&(i=e.ts);if(i)for(let e of r)e.type===`text`&&e.streaming&&(!e.ts||e.ts<i)&&(e.streaming=!1);return r}var L=new Set([`setup`,`cleanup`,`archive`]);function R(e){switch(e.type){case`user`:return e.sender===`system-prompt`?`system-prompt`:L.has(e.sender)?`script`:`user`;case`session`:return`session`;default:return`agent`}}function oe(e){let t=[],n=null,r=null;for(let i of e){let e=R(i),a=e===`session`||e===`system-prompt`,o=e===`script`&&i.type===`user`?`script:${i.sender}`:e;!n||r!==o||a?(n={speaker:e,ts:i.ts,items:[i]},r=o,t.push(n),a&&(n=null)):n.items.push(i)}return t}var z={class:`text-caption text-grey-6`},B=y({__name:`SessionEventItem`,props:{item:{}},setup(e){let t=e,r=m(()=>{switch(t.item.kind){case`started`:return`session.started`;case`ended`:return`session.ended`;case`compacted`:return`session.compacted`;default:return`session.started`}});return(e,t)=>(n(),h(`span`,z,o(e.$t(r.value)),1))}});function se(e,t){if(t.length===0||e.length===0)return e;let n=[...t].sort((e,t)=>t.length-e.length),r=new DOMParser().parseFromString(`<div>${e}</div>`,`text/html`),i=r.body.firstChild;if(!i)return e;function a(e){if(e.nodeType===Node.TEXT_NODE){V(e,n,r);return}if(e.nodeName===`A`)return;let t=Array.from(e.childNodes);for(let e of t)a(e)}return a(i),i.innerHTML}function V(e,t,n){let r=e.textContent??``;if(!t.some(e=>r.includes(e)))return;let i=n.createDocumentFragment(),a=0;for(;a<r.length;){let e=H(r,a,t);if(!e){i.appendChild(n.createTextNode(r.slice(a)));break}e.index>a&&i.appendChild(n.createTextNode(r.slice(a,e.index)));let o=n.createElement(`a`);o.className=`document-link`,o.setAttribute(`data-document-path`,e.path),o.setAttribute(`href`,`#`),o.textContent=e.path,i.appendChild(o),a=e.index+e.path.length}e.parentNode?.replaceChild(i,e)}function H(e,t,n){let r=null;for(let i of n){let n=e.indexOf(i,t);n<0||(!r||n<r.index||n===r.index&&i.length>r.path.length)&&(r={index:n,path:i})}return r}var U=[`innerHTML`],ce=I(y({__name:`TextMessageItem`,props:{item:{}},setup(e){let t=e,r=F(),i=k(),a=m(()=>{let e=i.selectedWorkspaceId;return e?r.documentsFor(e).map(e=>e.path):[]}),o=m(()=>re(se(ne.parse(t.item.text,{async:!1,breaks:!0,gfm:!0}),a.value),{addAttr:[`data-document-path`]}));function s(e){let t=e.target?.closest(`.document-link`);if(!t)return;e.preventDefault();let n=t.getAttribute(`data-document-path`),a=i.selectedWorkspaceId;!n||!a||r.openDocumentByPath(a,n)}return(t,r)=>(n(),h(`div`,{class:`markdown-message`,onClick:s},[_(`div`,{innerHTML:o.value},null,8,U),e.item.streaming?(n(),u(S,{key:0,size:`xs`,class:`q-ml-xs`})):f(``,!0)]))}}),[[`__scopeId`,`data-v-1b7bd8ca`]]),W={key:0,class:`text-caption text-grey-5`,style:{"font-style":`italic`}},G=[`innerHTML`],K={key:1,style:{"white-space":`pre-wrap`}},le=I(y({__name:`ThinkingItem`,props:{item:{}},setup(e){let r=e,i=m(()=>r.item.text.trim().slice(0,100)),a=m(()=>r.item.text.trim().length>0),s=m(()=>r.item.text.trim().length>100),c=m(()=>P(r.item.text));return(r,l)=>a.value?(n(),h(`div`,W,[s.value?(n(),u(ee,{key:0,dense:``,"dense-toggle":``,label:i.value,"header-class":`text-grey-5 text-caption`,style:{"font-style":`italic`}},{default:t(()=>[_(`div`,{class:`q-py-xs markdown-thinking`,innerHTML:c.value},null,8,G)]),_:1},8,[`label`])):(n(),h(`span`,K,o(e.item.text),1))])):f(``,!0)}}),[[`__scopeId`,`data-v-7f45ed94`]]);function ue(e,t){let n=e.split(`
|
|
2
2
|
`),r=t.split(`
|
|
3
3
|
`),i=n.length,a=r.length,o=Array.from({length:i+1},()=>Array(a+1).fill(0));for(let e=i-1;e>=0;e--)for(let t=a-1;t>=0;t--)n[e]===r[t]?o[e][t]=o[e+1][t+1]+1:o[e][t]=Math.max(o[e+1][t],o[e][t+1]);let s=[],c=0,l=0;for(;c<i&&l<a;)n[c]===r[l]?(s.push({type:`context`,content:n[c]}),c++,l++):o[c+1][l]>=o[c][l+1]?(s.push({type:`del`,content:n[c]}),c++):(s.push({type:`add`,content:r[l]}),l++);for(;c<i;)s.push({type:`del`,content:n[c++]});for(;l<a;)s.push({type:`add`,content:r[l++]});return s}function de(e){let t=e.split(`
|
|
4
4
|
`),n=[];for(let e of t)e.startsWith(`@@`)||e.startsWith(`+++`)||e.startsWith(`---`)||(e.startsWith(`+`)?n.push({type:`add`,content:e.slice(1)}):e.startsWith(`-`)?n.push({type:`del`,content:e.slice(1)}):e.startsWith(` `)?n.push({type:`context`,content:e.slice(1)}):e.length>0&&n.push({type:`context`,content:e}));return n}function fe(e,t){if(!t||typeof t!=`object`)return null;let n=t;if(e===`Edit`){let e=n.file_path;if(!e)return null;let t=n.old_string??``,r=n.new_string??``,i=typeof n.diff==`string`?n.diff:``;if(!t&&!r&&i.length>0){let t=de(i);return{toolName:`Edit`,filePath:e,additions:t.filter(e=>e.type===`add`).length,deletions:t.filter(e=>e.type===`del`).length,diffLines:t}}return{toolName:`Edit`,filePath:e,oldString:t,newString:r,replaceAll:n.replace_all??!1,additions:r?r.split(`
|