@kabmo-labs/kabmo 0.7.0 → 0.8.0

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 (130) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +403 -384
  3. package/dist/client/static/js/index-gtbpewr7.css +1 -0
  4. package/dist/client/static/js/{index-0wvbca0b.js → index-qwpvd9fv.js} +779 -779
  5. package/package.json +2 -1
  6. package/src/client/components/common/header-controls/index.tsx +24 -1
  7. package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
  8. package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
  9. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
  10. package/src/client/components/layout/desktop-layout/index.tsx +8 -2
  11. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
  12. package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
  13. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
  14. package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
  15. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  16. package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
  17. package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
  18. package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
  19. package/src/client/components/mobile/LayoutWrapper.tsx +22 -16
  20. package/src/client/components/mobile/RightSidebar.tsx +21 -10
  21. package/src/client/components/mobile/SessionSidebar.tsx +8 -0
  22. package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
  23. package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
  24. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
  25. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
  26. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
  27. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
  28. package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
  29. package/src/client/components/mobile/worktree-session.ts +51 -0
  30. package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
  31. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +140 -0
  32. package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
  33. package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
  34. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +29 -56
  35. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
  36. package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
  37. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +227 -0
  38. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +260 -0
  39. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
  40. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +24 -6
  41. package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
  42. package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
  43. package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
  44. package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
  45. package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
  46. package/src/client/components/workspace/file-explorer/filter.ts +47 -0
  47. package/src/client/components/workspace/file-explorer/index.tsx +77 -64
  48. package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
  49. package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
  50. package/src/client/hooks/chat/timeline/index.ts +9 -5
  51. package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
  52. package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
  53. package/src/client/hooks/chat/timeline/send.test.ts +2 -0
  54. package/src/client/hooks/chat/timeline/send.ts +11 -1
  55. package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
  56. package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
  57. package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
  58. package/src/client/hooks/ui/long-press.test.ts +174 -0
  59. package/src/client/hooks/ui/long-press.ts +112 -0
  60. package/src/client/hooks/ui/updates/shell-download.test.ts +73 -0
  61. package/src/client/hooks/ui/updates/shell-download.ts +112 -28
  62. package/src/client/hooks/ui/updates/shell-offer.test.ts +187 -0
  63. package/src/client/hooks/workspace/file-create.ts +41 -11
  64. package/src/client/hooks/workspace/file-listing.ts +56 -1
  65. package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
  66. package/src/client/hooks/workspace/worktree/index.ts +150 -0
  67. package/src/client/tailwind.css +19 -0
  68. package/src/server/index.ts +7 -2
  69. package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
  70. package/src/server/lib/db/schema.ts +4 -0
  71. package/src/server/lib/db/worktree-registry.server.ts +91 -0
  72. package/src/server/lib/fs/canonical.ts +52 -0
  73. package/src/server/lib/fs/relative-path.ts +48 -0
  74. package/src/server/lib/fs/root-worktree.test.ts +116 -0
  75. package/src/server/lib/fs/root.ts +39 -10
  76. package/src/server/lib/fs/worktree/carry.ts +134 -0
  77. package/src/server/lib/fs/worktree/create.test.ts +249 -0
  78. package/src/server/lib/fs/worktree/create.ts +263 -0
  79. package/src/server/lib/fs/worktree/errors.ts +43 -0
  80. package/src/server/lib/fs/worktree/list.test.ts +207 -0
  81. package/src/server/lib/fs/worktree/list.ts +129 -0
  82. package/src/server/lib/fs/worktree/location.test.ts +206 -0
  83. package/src/server/lib/fs/worktree/location.ts +114 -0
  84. package/src/server/lib/fs/worktree/project-id.ts +53 -0
  85. package/src/server/lib/fs/worktree/remove.test.ts +242 -0
  86. package/src/server/lib/fs/worktree/remove.ts +152 -0
  87. package/src/server/lib/fs/worktree/settings.server.ts +56 -0
  88. package/src/server/lib/fs/worktree/topology.ts +142 -0
  89. package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
  90. package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
  91. package/src/server/lib/panels/local-edits.server.ts +33 -0
  92. package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
  93. package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
  94. package/src/server/lib/panels/sync.server.test.ts +125 -147
  95. package/src/server/lib/panels/sync.server.ts +153 -48
  96. package/src/server/lib/updates/shell-assets.test.ts +114 -0
  97. package/src/server/lib/updates/shell-assets.ts +109 -0
  98. package/src/server/lib/updates/shell-pages.test.ts +156 -0
  99. package/src/server/lib/updates/shell-pages.ts +145 -0
  100. package/src/server/lib/updates/shell-release.test.ts +75 -70
  101. package/src/server/lib/updates/shell-release.ts +120 -136
  102. package/src/server/routes/fs/action-create.test.ts +18 -13
  103. package/src/server/routes/fs/action-nested.test.ts +184 -0
  104. package/src/server/routes/fs/action.ts +47 -16
  105. package/src/server/routes/index.ts +2 -0
  106. package/src/server/routes/updates/desktop.ts +37 -0
  107. package/src/server/routes/updates/index.ts +2 -0
  108. package/src/server/routes/worktrees/create.ts +138 -0
  109. package/src/server/routes/worktrees/index.ts +25 -0
  110. package/src/server/routes/worktrees/list.ts +124 -0
  111. package/src/server/routes/worktrees/remove.ts +102 -0
  112. package/src/server/routes/worktrees/routes.test.ts +309 -0
  113. package/src/server/routes/worktrees/shared.ts +90 -0
  114. package/src/shared/lib/fs/file-tree.test.ts +183 -0
  115. package/src/shared/lib/fs/file-tree.ts +159 -0
  116. package/src/shared/lib/fs/paths.test.ts +24 -1
  117. package/src/shared/lib/fs/paths.ts +17 -0
  118. package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
  119. package/src/shared/lib/omp/session/prewarm.ts +7 -0
  120. package/src/shared/lib/updates/shell-ua.ts +24 -4
  121. package/src/shared/lib/workspace/active-project.ts +23 -0
  122. package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
  123. package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
  124. package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
  125. package/src/shared/lib/workspace/worktree/types.ts +104 -0
  126. package/src/shared/types/fs.ts +10 -0
  127. package/src/shared/types/workspace.ts +14 -0
  128. package/src/test-support/file-explorer-disk.test-util.ts +76 -0
  129. package/src/test-support/file-explorer.test-util.ts +71 -40
  130. package/dist/client/static/js/index-nzyjf92m.css +0 -1
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * GET /api/worktrees — the worktrees of one project, with the facts a row shows.
8
+ *
9
+ * Deliberately NOT on the realtime `git:` topic: that payload is republished on
10
+ * every tool call, and this read costs a `git status` per worktree. It is opened
11
+ * by hand (Settings → Projects → Worktrees, and the delete dialog), exactly the
12
+ * reasoning `git-repo-details.ts` records for keeping the repo modal off it.
13
+ *
14
+ * `sessions` counts chats whose cwd is inside a worktree, from the same topology
15
+ * the sidebar uses — so the number a row shows is the number of rows the user
16
+ * can see under it.
17
+ */
18
+
19
+ import path from 'node:path';
20
+ import { json } from '@/server/lib/remix-compat';
21
+ import type { LoaderFunctionArgs } from '@/server/lib/remix-compat';
22
+ import { getDataDir } from '@/server/lib/lifecycle/paths';
23
+ import { resolveProjectRoot } from '@/server/lib/omp/core/worktree';
24
+ import { listSessionFiles } from '@/server/lib/omp/session/files';
25
+ import { scanSessionInfoCached } from '@/server/lib/omp/session/files';
26
+ import { canonicalPath } from '@/server/lib/fs/canonical';
27
+ import { worktreeIdentityFor, listWorktreeRows } from '@/server/lib/db/worktree-registry.server';
28
+ import { dirtyCount, commitsAheadOf } from '@/server/lib/fs/worktree/remove';
29
+ import { listWorktrees } from '@/server/lib/fs/worktree/list';
30
+ import {
31
+ defaultWorktreeRoot,
32
+ resolveConfiguredWorktreeRoot,
33
+ } from '@/server/lib/fs/worktree/location';
34
+ import { readWorktreeSettings } from '@/server/lib/fs/worktree/settings.server';
35
+ import { resolveProjectId } from '@/server/lib/fs/worktree/project-id';
36
+ import { getDb } from '@/server/db.server';
37
+ import { isMock } from '@/server/routes/worktrees/shared';
38
+ import { mockRefusal, resolveWorktreeProject, worktreeFailureResponse } from '@/server/routes/worktrees/shared';
39
+ import type { WorktreeInfo, WorktreeListPayload } from '@/shared/lib/workspace/worktree/types';
40
+
41
+ export async function loader({ request }: LoaderFunctionArgs) {
42
+ if (isMock()) return mockRefusal();
43
+
44
+ const url = new URL(request.url);
45
+ const resolved = await resolveWorktreeProject(url.searchParams.get('root'), url.searchParams.get('folderId'));
46
+ if (resolved.error) return resolved.error;
47
+ const root = resolved.root as string;
48
+
49
+ try {
50
+ const db = await getDb();
51
+ const primaryRoot = await resolveProjectRoot(root);
52
+ const settings = await readWorktreeSettings();
53
+ const projectId = await resolveProjectId(primaryRoot, (candidate) => worktreeIdentityFor(db, candidate));
54
+ const base = resolveConfiguredWorktreeRoot(settings.directory, primaryRoot)
55
+ ?? defaultWorktreeRoot(getDataDir(), projectId);
56
+
57
+ const entries = await listWorktrees(primaryRoot);
58
+ const [rows, sessionCwds] = await Promise.all([
59
+ listWorktreeRows(db, primaryRoot),
60
+ // Every tree that can be counted, not just the repository: a worktree does
61
+ // NOT have to live inside it. Kabmo's own default puts one under the data
62
+ // directory (`<data>/worktrees/<projectId>/<name>`), so a `primaryRoot`
63
+ // prefix test counted ZERO sessions for every worktree at the default
64
+ // location — the number the delete dialog warns with.
65
+ sessionCwdsUnder([primaryRoot, ...entries.map((entry) => entry.path)]),
66
+ ]);
67
+ const rowByPath = new Map(rows.map((row) => [row.path, row]));
68
+
69
+ const worktrees: WorktreeInfo[] = [];
70
+ for (const entry of entries) {
71
+ const row = rowByPath.get(entry.path);
72
+ const dirty = entry.isPrimary ? 0 : await dirtyCount(entry.path).catch(() => 0);
73
+ const ahead = entry.isPrimary
74
+ ? 0
75
+ : (await commitsAheadOf(primaryRoot, row?.start_ref ?? '', entry.branch)) ?? 0;
76
+ worktrees.push({
77
+ path: entry.path,
78
+ branch: entry.branch,
79
+ head: entry.head,
80
+ detached: entry.detached,
81
+ missing: entry.missing,
82
+ isPrimary: entry.isPrimary,
83
+ dirty,
84
+ aheadOfStart: ahead,
85
+ startRef: row?.start_ref ?? '',
86
+ sessions: sessionCwds.filter((cwd) => cwd === entry.path || cwd.startsWith(`${entry.path}${path.sep}`)).length,
87
+ });
88
+ }
89
+
90
+ const payload: WorktreeListPayload = { worktrees, base, isRepo: true };
91
+ return json(payload);
92
+ } catch (error) {
93
+ // A failed listing is REPORTED, never substituted with an empty list: "no
94
+ // worktrees" and "git could not answer" are different answers, and only one
95
+ // of them means the user has nothing there.
96
+ return worktreeFailureResponse(error, 500);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Every session's recorded cwd, canonicalized, restricted to the trees that can
102
+ * be counted — the primary checkout and each worktree.
103
+ *
104
+ * Both sides are canonicalized, for the same reason `canonicalPath` exists: the
105
+ * recorded cwd is the spelling the agent was spawned with while a worktree path
106
+ * comes back realpath'd from git, and on macOS those differ for anything under
107
+ * `/var` (a symlink to `/private/var`) — a raw prefix test would then count zero
108
+ * for a worktree that is right there.
109
+ */
110
+ async function sessionCwdsUnder(roots: string[]): Promise<string[]> {
111
+ const files = await listSessionFiles();
112
+ const infos = await Promise.all(files.map((file) => scanSessionInfoCached(file)));
113
+ const canonicalRoots = [...new Set(await Promise.all(roots.map((root) => canonicalPath(root))))];
114
+ const cwds: string[] = [];
115
+ const seen = new Set<string>();
116
+ for (const info of infos) {
117
+ if (!info?.cwd || seen.has(info.cwd)) continue;
118
+ seen.add(info.cwd);
119
+ const cwd = await canonicalPath(info.cwd);
120
+ if (!canonicalRoots.some((root) => cwd === root || cwd.startsWith(`${root}${path.sep}`))) continue;
121
+ cwds.push(cwd);
122
+ }
123
+ return cwds;
124
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * DELETE /api/worktrees — remove one worktree.
8
+ *
9
+ * Order, and it is the point: stop the live children, archive and delete their
10
+ * sessions, THEN remove the directory. The reverse — which is what a plain
11
+ * `git worktree remove` alone does — leaves a child whose cwd no longer exists,
12
+ * and `resolveSpawnCwd` then quietly runs that session in the server's own
13
+ * directory: a chat the user deleted, alive somewhere else.
14
+ *
15
+ * The dirty refusal happens AFTER the sessions are stopped, so a refusal leaves a
16
+ * worktree the user can still see and retry rather than a half-deleted state.
17
+ */
18
+
19
+ import { json } from '@/server/lib/remix-compat';
20
+ import type { ActionFunctionArgs } from '@/server/lib/remix-compat';
21
+ import { methodNotAllowed } from '@/server/lib/route-adapter';
22
+ import { getDataDir } from '@/server/lib/lifecycle/paths';
23
+ import { getDb } from '@/server/db.server';
24
+ import { emitRealtimeSignal } from '@/server/lib/realtime/signals.server';
25
+ import { pathHolds } from '@/server/lib/fs/canonical';
26
+ import { removeWorktreeRow, worktreeRowFor } from '@/server/lib/db/worktree-registry.server';
27
+ import { removeWorktree } from '@/server/lib/fs/worktree/remove';
28
+ import { invalidateWorktreeTopology } from '@/server/lib/fs/worktree/topology';
29
+ import { resolveProjectRoot } from '@/server/lib/omp/core/worktree';
30
+ import { listRpcSessions } from '@/server/lib/omp/rpc/session-registry';
31
+ import { removeSession } from '@/server/lib/omp/session/delete-flow.server';
32
+ import { isMock, mockRefusal, resolveWorktreeProject, worktreeFailureResponse } from '@/server/routes/worktrees/shared';
33
+
34
+ interface RemoveBody {
35
+ path?: unknown;
36
+ deleteBranch?: unknown;
37
+ }
38
+
39
+ /**
40
+ * Stop and delete every session whose cwd is inside `worktreePath`.
41
+ *
42
+ * A session this process does not manage has no child to stop — its transcript
43
+ * is removed the same way, and the owning instance learns on its next spawn
44
+ * (`resolveSpawnCwd` finds no directory and the row is already gone).
45
+ */
46
+ async function stopSessionsIn(worktreePath: string): Promise<string[]> {
47
+ const handled: string[] = [];
48
+ for (const session of liveSessions()) {
49
+ if (!(await pathHolds(worktreePath, session.cwd))) continue;
50
+ // `removeSession` IS the single-delete sequence: pending-id refusal, id
51
+ // shape, the busy-child 409, `destroyAndWait`, then the purge. Repeating any
52
+ // of it here is how the batch path would drift from `DELETE /api/sessions/:id`.
53
+ const removal = await removeSession(session.id);
54
+ if (removal.ok) handled.push(session.id);
55
+ }
56
+ return handled;
57
+ }
58
+
59
+ /** Live wrappers, as the registry reports them. */
60
+ function liveSessions(): { id: string; cwd: string }[] {
61
+ return listRpcSessions().map((session) => ({ id: session.sessionId, cwd: session.cwd }));
62
+ }
63
+
64
+ export async function action({ request }: ActionFunctionArgs) {
65
+ if (request.method !== 'DELETE') return methodNotAllowed({ request, params: {} });
66
+ if (isMock()) return mockRefusal();
67
+
68
+ const url = new URL(request.url);
69
+ const resolved = await resolveWorktreeProject(url.searchParams.get('root'), url.searchParams.get('folderId'));
70
+ if (resolved.error) return resolved.error;
71
+ const projectPath = resolved.root as string;
72
+
73
+ const body = (await request.json().catch(() => ({}))) as RemoveBody;
74
+ const worktreePath = typeof body.path === 'string' ? body.path.trim() : '';
75
+ if (!worktreePath) {
76
+ return json({ error: 'path is required', code: 'invalid_name', field: 'location' }, { status: 400 });
77
+ }
78
+
79
+ const db = await getDb();
80
+ try {
81
+ const primaryRoot = await resolveProjectRoot(projectPath);
82
+ const removed = await removeWorktree(
83
+ {
84
+ worktreePath,
85
+ projectPath,
86
+ deleteBranch: body.deleteBranch === true,
87
+ managedRoot: `${getDataDir()}/worktrees`,
88
+ stopSessions: stopSessionsIn,
89
+ },
90
+ {
91
+ rowFor: (candidate) => worktreeRowFor(db, candidate),
92
+ removeRow: (candidate) => removeWorktreeRow(db, candidate),
93
+ },
94
+ );
95
+ await invalidateWorktreeTopology(primaryRoot);
96
+ emitRealtimeSignal('sidebar-structure');
97
+ emitRealtimeSignal('workspace-dirty');
98
+ return json(removed);
99
+ } catch (error) {
100
+ return worktreeFailureResponse(error);
101
+ }
102
+ }
@@ -0,0 +1,309 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The `/api/worktrees` routes, end to end over a real repository.
8
+ *
9
+ * What a unit test on the modules cannot show, and what these cases are for:
10
+ *
11
+ * - the LIST payload carries every fact a row draws, from one read;
12
+ * - a create answers with the worktree it made, and the next list shows it —
13
+ * the topology cache invalidation is what makes "next list" true;
14
+ * - a DELETE takes the path in its BODY (a path has separators) and answers the
15
+ * branch verdict rather than a bare success;
16
+ * - MOCK refuses, because a demo install has no repository to make one in.
17
+ */
18
+
19
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
20
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { getDb } from '@/server/db.server';
24
+ import { loader as listLoader } from '@/server/routes/worktrees/list';
25
+ import { action as createAction, validateAction } from '@/server/routes/worktrees/create';
26
+ import { action as removeAction } from '@/server/routes/worktrees/remove';
27
+ import { invalidateWorktreeTopology } from '@/server/lib/fs/worktree/topology';
28
+ import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
29
+ import { isolateDb, releaseDb } from '@/test-support/isolated-db';
30
+ import { removeDir } from '@/test-support/remove-dir';
31
+
32
+ const GIT_ENV = {
33
+ ...Bun.env,
34
+ GIT_EDITOR: 'true',
35
+ GIT_TERMINAL_PROMPT: '0',
36
+ GIT_AUTHOR_NAME: 'Test',
37
+ GIT_AUTHOR_EMAIL: 'test@example.invalid',
38
+ GIT_COMMITTER_NAME: 'Test',
39
+ GIT_COMMITTER_EMAIL: 'test@example.invalid',
40
+ };
41
+
42
+ function git(cwd: string, ...args: string[]): string {
43
+ return Bun.spawnSync({ cmd: ['git', ...args], cwd, env: GIT_ENV }).stdout.toString();
44
+ }
45
+
46
+ let workRoot: string;
47
+ let repo: string;
48
+ /** Where the session files live, so a count can be observed without omp. */
49
+ let sessionsRoot: string;
50
+ const saved = {
51
+ MOCK: Bun.env.MOCK,
52
+ SYNC_WORKSPACE: Bun.env.SYNC_WORKSPACE,
53
+ agentDir: Bun.env.PI_CODING_AGENT_DIR,
54
+ xdg: Bun.env.XDG_DATA_HOME,
55
+ dataDir: Bun.env.KABMO_DATA_DIR,
56
+ };
57
+
58
+ /**
59
+ * Call a route module the way the adapter does — with `params`, which every
60
+ * Remix-shaped handler declares even when its path has no parameter.
61
+ */
62
+ async function call(
63
+ handler: (args: { request: Request; params: Record<string, string> }) => unknown,
64
+ url: string,
65
+ init?: RequestInit,
66
+ ): Promise<{ status: number; body: Record<string, unknown> }> {
67
+ const response = (await handler({ request: new Request(url, init), params: {} })) as Response;
68
+ return { status: response.status, body: (await response.json()) as Record<string, unknown> };
69
+ }
70
+
71
+ /** Put an env var back, or unset it when the file started without one. */
72
+ function restoreEnv(name: string, value: string | undefined): void {
73
+ if (value === undefined) delete Bun.env[name];
74
+ else Bun.env[name] = value;
75
+ }
76
+
77
+ /** One session file whose cwd is `cwd`, as the agent would record it. */
78
+ function writeSession(id: string, cwd: string): void {
79
+ const lines = [
80
+ JSON.stringify({ type: 'session', id, cwd, timestamp: '2026-10-23T00:00:00.000Z' }),
81
+ JSON.stringify({
82
+ type: 'message',
83
+ id: `${id}-m1`,
84
+ parentId: null,
85
+ timestamp: '2026-10-23T00:00:01.000Z',
86
+ message: { role: 'user', content: 'where am I' },
87
+ }),
88
+ ];
89
+ writeFileSync(join(sessionsRoot, 'project', `${id}.jsonl`), `${lines.join('\n')}\n`);
90
+ }
91
+
92
+ /** The `worktrees` array of a list payload, narrowed once. */
93
+ function worktreesOf(body: Record<string, unknown>): Array<Record<string, unknown>> {
94
+ const list = body.worktrees;
95
+ return Array.isArray(list) ? (list as Array<Record<string, unknown>>) : [];
96
+ }
97
+
98
+ const base = (): string => `http://localhost/api/worktrees?root=${encodeURIComponent(repo)}`;
99
+
100
+ beforeAll(() => {
101
+ workRoot = mkdtempSync(join(tmpdir(), 'kabmo-worktree-routes-'));
102
+ repo = join(workRoot, 'repo');
103
+ mkdirSync(repo, { recursive: true });
104
+ writeFileSync(join(repo, '.gitignore'), 'node_modules\n');
105
+ mkdirSync(join(repo, 'node_modules', 'dep'), { recursive: true });
106
+ writeFileSync(join(repo, 'node_modules', 'dep', 'index.js'), 'module.exports = {};\n');
107
+ writeFileSync(join(repo, 'readme.md'), 'hello\n');
108
+ git(repo, 'init', '-q', '-b', 'main', '.');
109
+ git(repo, 'add', '-A');
110
+ git(repo, 'commit', '-q', '-m', 'base');
111
+
112
+ // A sessions root of this file's own: the worktree count reads it, and
113
+ // without the override it would scan the developer's real `~/.omp/agent`.
114
+ const agent = join(workRoot, 'agent');
115
+ sessionsRoot = join(agent, 'sessions');
116
+ mkdirSync(join(sessionsRoot, 'project'), { recursive: true });
117
+ Bun.env.PI_CODING_AGENT_DIR = agent;
118
+ // XDG would redirect the sessions dir to $XDG_DATA_HOME/omp when it exists.
119
+ delete Bun.env.XDG_DATA_HOME;
120
+ // A worktree's default location is `<data>/worktrees/<projectId>/<name>`, so
121
+ // without this the suite writes into the developer's own `~/.kabmo`.
122
+ Bun.env.KABMO_DATA_DIR = join(workRoot, 'data');
123
+ });
124
+
125
+ beforeEach(async () => {
126
+ await isolateDb();
127
+ Bun.env.MOCK = 'false';
128
+ Bun.env.SYNC_WORKSPACE = 'false';
129
+ const db = await getDb();
130
+ await db.run('INSERT INTO workspace_folders (name, project_path) VALUES (?, ?)', ['proj', repo]);
131
+ });
132
+
133
+ afterEach(async () => {
134
+ await invalidateWorktreeTopology();
135
+ // The session-file caches are keyed on `globalThis` and this file writes
136
+ // session files, so a case that leaves one behind would be counted by the next.
137
+ clearSessionFileCaches();
138
+ await releaseDb();
139
+ });
140
+
141
+ afterAll(() => {
142
+ removeDir(workRoot);
143
+ // Explicitly mapped: the record's keys are not the env names, and an
144
+ // `Object.entries` restore would silently leave `PI_CODING_AGENT_DIR` pointed
145
+ // at a directory this file just deleted.
146
+ restoreEnv('MOCK', saved.MOCK);
147
+ restoreEnv('SYNC_WORKSPACE', saved.SYNC_WORKSPACE);
148
+ restoreEnv('PI_CODING_AGENT_DIR', saved.agentDir);
149
+ restoreEnv('XDG_DATA_HOME', saved.xdg);
150
+ restoreEnv('KABMO_DATA_DIR', saved.dataDir);
151
+ });
152
+
153
+ describe('GET /api/worktrees', () => {
154
+ test('lists the checkout with the facts a row draws', async () => {
155
+ const { status, body } = await call(listLoader, base());
156
+ expect(status).toBe(200);
157
+ expect(body.isRepo).toBe(true);
158
+ expect(String(body.base)).toContain('worktrees');
159
+ const primary = worktreesOf(body)[0] as Record<string, unknown>;
160
+ expect(primary.isPrimary).toBe(true);
161
+ expect(primary.branch).toBe('main');
162
+ expect(String(primary.head).length).toBeGreaterThan(0);
163
+ expect(primary.dirty).toBe(0);
164
+ });
165
+
166
+ test('counts a session running in a worktree that lives OUTSIDE the repository', async () => {
167
+ const created = await call(createAction, base(), {
168
+ method: 'POST',
169
+ headers: { 'content-type': 'application/json' },
170
+ body: JSON.stringify({ mode: 'new', branch: 'wt/route-counted' }),
171
+ });
172
+ const path = String((created.body.worktree as Record<string, unknown>).path);
173
+ // The default location is under the data directory, not inside the checkout
174
+ // — which is the case a `primaryRoot` prefix test counted zero for.
175
+ expect(path.startsWith(repo)).toBe(false);
176
+
177
+ writeSession('route-counted-session', path);
178
+
179
+ const listed = await call(listLoader, base());
180
+ const row = worktreesOf(listed.body).find((entry) => entry.path === path);
181
+ // The number the delete dialog warns with: a chat is running here.
182
+ expect(row?.sessions).toBe(1);
183
+ });
184
+
185
+ test('a folder with no project binding is refused, not answered for the app root', async () => {
186
+ const { status, body } = await call(listLoader, 'http://localhost/api/worktrees');
187
+ expect(status).toBe(400);
188
+ expect(body.code).toBe('not_a_repo');
189
+ });
190
+
191
+ test('MOCK refuses — a demo install has no repository to read', async () => {
192
+ Bun.env.MOCK = 'true';
193
+ const { status, body } = await call(listLoader, base());
194
+ expect(status).toBe(409);
195
+ expect(body.code).toBe('mock_mode');
196
+ });
197
+ });
198
+
199
+ describe('POST /api/worktrees', () => {
200
+ test('creates one, carries the ignored build folder, and the next list shows it', async () => {
201
+ const created = await call(createAction, base(), {
202
+ method: 'POST',
203
+ headers: { 'content-type': 'application/json' },
204
+ body: JSON.stringify({ mode: 'new', branch: 'wt/route-created' }),
205
+ });
206
+
207
+ expect(created.status).toBe(200);
208
+ const worktree = created.body.worktree as Record<string, unknown>;
209
+ expect(worktree.branch).toBe('wt/route-created');
210
+ // The carry is the one capability git does not provide; without it the new
211
+ // checkout cannot run the project.
212
+ const carried = worktree.carried as { copied: string[] };
213
+ expect(carried.copied).toContain('node_modules');
214
+
215
+ const listed = await call(listLoader, base());
216
+ const row = worktreesOf(listed.body).find((entry) => entry.branch === 'wt/route-created');
217
+ expect(row).toBeDefined();
218
+ expect(row?.isPrimary).toBe(false);
219
+ expect(row?.startRef).toBe('HEAD');
220
+ });
221
+
222
+ test('a refusal answers its code and field, so the dialog can mark the input', async () => {
223
+ const refused = await call(createAction, base(), {
224
+ method: 'POST',
225
+ headers: { 'content-type': 'application/json' },
226
+ body: JSON.stringify({ mode: 'existing', branch: 'main' }),
227
+ });
228
+ expect(refused.status).toBe(400);
229
+ expect(refused.body.code).toBe('branch_in_use');
230
+ expect(refused.body.field).toBe('branch');
231
+ });
232
+
233
+ test('validate answers the same refusal without creating anything', async () => {
234
+ const result = await call(validateAction, base(), {
235
+ method: 'POST',
236
+ headers: { 'content-type': 'application/json' },
237
+ body: JSON.stringify({ mode: 'new', branch: 'has space' }),
238
+ });
239
+ expect(result.status).toBe(400);
240
+ expect(result.body.ok).toBe(false);
241
+ const failures = result.body.failures as Array<{ code: string }>;
242
+ expect(failures[0]?.code).toBe('invalid_name');
243
+
244
+ // Nothing was created: the fixture repository is shared by this file, so the
245
+ // assertion names the branch rather than a count (a count would also pin how
246
+ // many earlier tests happened to leave behind).
247
+ const listed = await call(listLoader, base());
248
+ expect(worktreesOf(listed.body).some((entry) => entry.branch === 'has-space')).toBe(false);
249
+ expect(worktreesOf(listed.body).some((entry) => entry.branch === 'has space')).toBe(false);
250
+ });
251
+ });
252
+
253
+ describe('DELETE /api/worktrees', () => {
254
+ test('removes the worktree and its branch, and the list no longer shows it', async () => {
255
+ const created = await call(createAction, base(), {
256
+ method: 'POST',
257
+ headers: { 'content-type': 'application/json' },
258
+ body: JSON.stringify({ mode: 'new', branch: 'wt/route-remove' }),
259
+ });
260
+ const path = String((created.body.worktree as Record<string, unknown>).path);
261
+
262
+ const removed = await call(removeAction, base(), {
263
+ method: 'DELETE',
264
+ headers: { 'content-type': 'application/json' },
265
+ body: JSON.stringify({ path, deleteBranch: true }),
266
+ });
267
+ expect(removed.status).toBe(200);
268
+ expect(removed.body.removed).toBe(true);
269
+ expect(removed.body.branchDeleted).toBe(true);
270
+
271
+ const listed = await call(listLoader, base());
272
+ expect(worktreesOf(listed.body).some((entry) => entry.path === path)).toBe(false);
273
+ });
274
+
275
+ test('a missing path is refused by name, not by a crash', async () => {
276
+ const refused = await call(removeAction, base(), {
277
+ method: 'DELETE',
278
+ headers: { 'content-type': 'application/json' },
279
+ body: JSON.stringify({}),
280
+ });
281
+ expect(refused.status).toBe(400);
282
+ expect(refused.body.code).toBe('invalid_name');
283
+ });
284
+
285
+ test('a dirty worktree is refused with 409 and its count, so a caller can tell a retry from a bad request', async () => {
286
+ const created = await call(createAction, base(), {
287
+ method: 'POST',
288
+ headers: { 'content-type': 'application/json' },
289
+ body: JSON.stringify({ mode: 'new', branch: 'wt/route-dirty' }),
290
+ });
291
+ const path = String((created.body.worktree as Record<string, unknown>).path);
292
+ writeFileSync(join(path, 'scratch.txt'), 'not committed\n');
293
+
294
+ const refused = await call(removeAction, base(), {
295
+ method: 'DELETE',
296
+ headers: { 'content-type': 'application/json' },
297
+ body: JSON.stringify({ path, deleteBranch: true }),
298
+ });
299
+ // 409: the request is well-formed and the tree is the obstacle — the same
300
+ // distinction `mock_mode` draws. A 400 here reads as "fix your request".
301
+ expect(refused.status).toBe(409);
302
+ expect(refused.body.code).toBe('dirty');
303
+ expect(Number(refused.body.count)).toBeGreaterThan(0);
304
+
305
+ // A refusal is not a partial removal.
306
+ const listed = await call(listLoader, base());
307
+ expect(worktreesOf(listed.body).some((entry) => entry.path === path)).toBe(true);
308
+ });
309
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The worktree routes' shared plumbing: resolve the project, refuse in MOCK, and
8
+ * turn a `WorktreeError` into the JSON envelope the client already understands.
9
+ *
10
+ * Root resolution goes through `resolveRoot`, the security boundary every fs
11
+ * route uses — a client-supplied `root` is only honoured when it is the app
12
+ * root, a registered `project_path`, or (this feature) a worktree of one.
13
+ */
14
+
15
+ import { json } from '@/server/lib/remix-compat';
16
+ import { isMockMode } from '@/server/mock.server';
17
+ import { getDb } from '@/server/db.server';
18
+ import { resolveRoot } from '@/server/lib/fs/root';
19
+ import { asWorktreeFailure } from '@/server/lib/fs/worktree/errors';
20
+ import type { WorktreeFailure } from '@/shared/lib/workspace/worktree/types';
21
+
22
+ /** A registered workspace's `project_path`, or null when the folder is unbound. */
23
+ export async function projectPathForFolder(folderId: number | string | null): Promise<string | null> {
24
+ if (folderId === null || folderId === '') return null;
25
+ try {
26
+ const db = await getDb();
27
+ const row = await db.get<{ project_path: string | null }>(
28
+ 'SELECT project_path FROM workspace_folders WHERE id = ?',
29
+ [Number(folderId)],
30
+ );
31
+ return row?.project_path ?? null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /** The project a request names: its own `root`, else the folder's binding. */
38
+ export async function resolveWorktreeProject(
39
+ rawRoot: string | null | undefined,
40
+ folderId: string | null,
41
+ ): Promise<{ root: string | null; error?: Response }> {
42
+ const fromFolder = await projectPathForFolder(folderId);
43
+ const root = await resolveRoot(rawRoot || fromFolder, '');
44
+ if (!root) {
45
+ return {
46
+ root: null,
47
+ error: json(
48
+ {
49
+ error: 'Pick a workspace bound to a Git repository first.',
50
+ code: 'not_a_repo',
51
+ },
52
+ { status: 400 },
53
+ ),
54
+ };
55
+ }
56
+ return { root };
57
+ }
58
+
59
+ /**
60
+ * A worktree mutation in demo mode is refused rather than simulated: MOCK has no
61
+ * repository on disk, so a "successful" create would promise a folder that does
62
+ * not exist.
63
+ */
64
+ export function mockRefusal(): Response {
65
+ return json(
66
+ { error: 'Worktrees need a real Git repository; Kabmo is running in demo mode.', code: 'mock_mode' },
67
+ { status: 409 },
68
+ );
69
+ }
70
+
71
+ /**
72
+ * A refusal's HTTP status, from what the refusal MEANS.
73
+ *
74
+ * `dirty` is a conflict with the tree's current state rather than a malformed
75
+ * request — the same distinction `session_not_running` and `mock_mode` draw with
76
+ * 409 — and a caller that retries after committing needs to tell the two apart
77
+ * without parsing the message.
78
+ */
79
+ const REFUSAL_STATUS: Partial<Record<WorktreeFailure['code'], number>> = {
80
+ dirty: 409,
81
+ };
82
+
83
+ export function worktreeFailureResponse(error: unknown, status = 400): Response {
84
+ const failure = asWorktreeFailure(error);
85
+ return json({ error: failure.message, ...failure }, { status: REFUSAL_STATUS[failure.code] ?? status });
86
+ }
87
+
88
+ export function isMock(): boolean {
89
+ return isMockMode();
90
+ }