@loicngr/kobo 1.7.0 → 1.7.1

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 (66) hide show
  1. package/README.md +9 -4
  2. package/dist/mcp-server/kobo-tasks-handlers.js +2 -1
  3. package/dist/server/routes/health.js +2 -2
  4. package/dist/server/routes/settings.js +2 -1
  5. package/dist/server/routes/workspaces.js +5 -11
  6. package/dist/server/services/auto-loop-service.js +2 -2
  7. package/dist/server/services/settings-service.js +35 -1
  8. package/dist/server/services/wakeup-service.js +2 -2
  9. package/dist/server/services/workspace-service.js +2 -1
  10. package/dist/server/services/worktree-service.js +17 -7
  11. package/dist/server/utils/worktree-paths.js +134 -0
  12. package/dist/shared/consts.js +1 -0
  13. package/package.json +1 -1
  14. package/src/client/dist/spa/assets/ActivityFeed-Chn8aZvi.js +7 -0
  15. package/src/client/dist/spa/assets/{ClosePopup-DzB3mDtj.js → ClosePopup-BUlGXTqh.js} +1 -1
  16. package/src/client/dist/spa/assets/{CreatePage-U6TtJzNe.js → CreatePage-BGtqoZ8d.js} +1 -1
  17. package/src/client/dist/spa/assets/{DiffViewer-Di85TBIi.js → DiffViewer-qjJ-biOw.js} +3 -3
  18. package/src/client/dist/spa/assets/{HealthPage-B7aWFxAZ.js → HealthPage-CKyf7ky6.js} +1 -1
  19. package/src/client/dist/spa/assets/{MainLayout-Dba6SdpU.css → MainLayout-B07zv82Z.css} +1 -1
  20. package/src/client/dist/spa/assets/MainLayout-Br3jmaOw.js +37 -0
  21. package/src/client/dist/spa/assets/{QExpansionItem-VS4b4eY6.js → QExpansionItem-BnIPCzXR.js} +1 -1
  22. package/src/client/dist/spa/assets/{QMenu-CchbRXbp.js → QMenu-0LsqhRZT.js} +1 -1
  23. package/src/client/dist/spa/assets/QScrollArea-BDCKOKuE.js +1 -0
  24. package/src/client/dist/spa/assets/{QTabPanels-HXz-evuj.js → QTabPanels-Ctnrqvp9.js} +1 -1
  25. package/src/client/dist/spa/assets/QTooltip-B3CmRx4j.js +1 -0
  26. package/src/client/dist/spa/assets/{SearchPage-CVm-sqxH.js → SearchPage-Ce8Uc7Ol.js} +1 -1
  27. package/src/client/dist/spa/assets/SettingsPage-8N0X7B7o.css +1 -0
  28. package/src/client/dist/spa/assets/SettingsPage-BaaSJ3eJ.js +1 -0
  29. package/src/client/dist/spa/assets/{WorkspacePage-xaVy8s5i.js → WorkspacePage-wZUUTDzp.js} +3 -3
  30. package/src/client/dist/spa/assets/{build-path-tree-CdY1A6aP.js → build-path-tree-DRViYT3t.js} +1 -1
  31. package/src/client/dist/spa/assets/{cssMode-BVNBMOxh.js → cssMode-uAfRqG2Q.js} +1 -1
  32. package/src/client/dist/spa/assets/{editor.api-D6Vfp5yv.js → editor.api-5GUlxvcL.js} +1 -1
  33. package/src/client/dist/spa/assets/{editor.main-CTCYF6V4.js → editor.main-CSTJjBIa.js} +3 -3
  34. package/src/client/dist/spa/assets/expand-template-zA3pTyIP.js +1 -0
  35. package/src/client/dist/spa/assets/{freemarker2-nmzwPmzi.js → freemarker2-BxBnI8Nb.js} +1 -1
  36. package/src/client/dist/spa/assets/{handlebars-CI9lR7Ef.js → handlebars-DrbIsXmT.js} +1 -1
  37. package/src/client/dist/spa/assets/{html-BQ21REnv.js → html-DH7u_g5l.js} +1 -1
  38. package/src/client/dist/spa/assets/{htmlMode-io5J5Qr1.js → htmlMode-BlY9QO3f.js} +1 -1
  39. package/src/client/dist/spa/assets/i18n-B41j--A3.js +1 -0
  40. package/src/client/dist/spa/assets/{index-C_e7KOYh.js → index-DoYBJtQA.js} +2 -2
  41. package/src/client/dist/spa/assets/{javascript--u9PDBCv.js → javascript-B-AL31ke.js} +1 -1
  42. package/src/client/dist/spa/assets/{jsonMode-DBG5llk4.js → jsonMode-Dx7CA4ag.js} +1 -1
  43. package/src/client/dist/spa/assets/{liquid-DxAS4nYF.js → liquid--H7Vomnm.js} +1 -1
  44. package/src/client/dist/spa/assets/{marked.esm-DuOsJx63.js → marked.esm-DLCrAGtO.js} +1 -1
  45. package/src/client/dist/spa/assets/{mdx-BNXTiODW.js → mdx-BOackeU6.js} +1 -1
  46. package/src/client/dist/spa/assets/{models-DNYEhFF7.js → models-BPfFBcxr.js} +1 -1
  47. package/src/client/dist/spa/assets/{monaco.contribution-CT3LAK0J.js → monaco.contribution-ydrMjZwK.js} +2 -2
  48. package/src/client/dist/spa/assets/{python-DztNww13.js → python-BWGSV-nk.js} +1 -1
  49. package/src/client/dist/spa/assets/{razor-Cyr82NZF.js → razor-BGnl83cS.js} +1 -1
  50. package/src/client/dist/spa/assets/settings-lT4GB-uB.js +1 -0
  51. package/src/client/dist/spa/assets/{tsMode-CbQVgsIP.js → tsMode-Chjqq1f3.js} +1 -1
  52. package/src/client/dist/spa/assets/{typescript-UHOe4d1S.js → typescript-By7Y7PAP.js} +1 -1
  53. package/src/client/dist/spa/assets/{use-panel-Br8QNRMk.js → use-panel-DWX2aNMM.js} +1 -1
  54. package/src/client/dist/spa/assets/{xml-DC88eFpV.js → xml-DoAeCRiy.js} +1 -1
  55. package/src/client/dist/spa/assets/{yaml-DSTsIRJr.js → yaml-DlT7YOhG.js} +1 -1
  56. package/src/client/dist/spa/index.html +2 -2
  57. package/src/mcp-server/kobo-tasks-handlers.ts +2 -1
  58. package/src/client/dist/spa/assets/ActivityFeed-CIJPN8TH.js +0 -7
  59. package/src/client/dist/spa/assets/MainLayout-BHBrz4c9.js +0 -37
  60. package/src/client/dist/spa/assets/QScrollArea-DrVTDLU0.js +0 -1
  61. package/src/client/dist/spa/assets/QTooltip-DjJYMTkN.js +0 -1
  62. package/src/client/dist/spa/assets/SettingsPage-ayDKGo9H.js +0 -1
  63. package/src/client/dist/spa/assets/SettingsPage-wTBCvK6t.css +0 -1
  64. package/src/client/dist/spa/assets/expand-template-vHV2iwXf.js +0 -1
  65. package/src/client/dist/spa/assets/i18n-Do8Kn8n0.js +0 -1
  66. package/src/client/dist/spa/assets/settings-Dbx1_ksA.js +0 -1
package/README.md CHANGED
@@ -11,7 +11,7 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
11
11
 
12
12
  ## Features
13
13
 
14
- - **Isolated git worktrees** — every workspace runs on its own branch in its own directory, so concurrent Claude sessions never step on each other
14
+ - **Isolated git worktrees** — every workspace runs on its own branch in its own directory, with a configurable global worktrees root for new workspaces, so concurrent Claude sessions never step on each other
15
15
  - **Pluggable agent engine** — Kōbō talks to agents through an `AgentEngine` contract with a normalised `AgentEvent` stream (`src/server/services/agent/engines/`). The `claude-code` engine runs on the official [`@anthropic-ai/claude-agent-sdk`](https://github.com/anthropics/claude-agent-sdk-typescript); adding a second runtime (e.g. Codex) only requires a new adapter, not a rewrite of the UI or orchestration layer
16
16
  - **Interactive `AskUserQuestion`** — when the agent invokes `AskUserQuestion`, Kōbō pauses the session via the SDK's `defer` pattern, surfaces a question panel in the UI, and resumes the agent once the user answers. The session does not occupy any resources while it waits
17
17
  - **Rich chat feed** — live streaming text, thinking blocks, inline tool calls with expandable diffs for Edit/Write, per-turn session cards, markdown rendering, jump-to-previous-user-message button, and infinite scroll-up over persisted history
@@ -32,7 +32,7 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
32
32
  - **Soft interrupt** — pause an agent mid-execution (SIGINT, like pressing Escape in Claude Code) without killing the process; the agent stops the current tool and waits for the next message
33
33
  - **Archive instead of delete** — soft-remove workspaces without losing the worktree, branches, or history; unarchive restores the exact pre-archive state
34
34
  - **Auto-loop mode** — opt-in, per-workspace: when enabled, Kōbō spawns a fresh Claude session for the next pending task after every `session:ended`, walking through the task list until all are `done`. Stops automatically on error, on stall (3 consecutive sessions with no task completed), or when the user clicks Stop. A grooming step (`/kobo-prep-autoloop`) ensures tasks are atomic before the loop runs; Notion-imported workspaces with both todos and acceptance criteria are auto-unlocked. **E2E grooming** — when a project declares an E2E framework in Settings (Cypress, Playwright, Vitest, etc.), the grooming phase injects an `[E2E] ` test sub-task between every parent task; each iteration then runs the matching E2E suite as part of its acceptance check
35
- - **Attach existing worktrees** — Kōbō detects orphan worktrees under `.worktrees/` (created outside Kōbō, or left over from an earlier install) and lets you attach them to a new workspace from the creation form, picking up the existing branch and folder instead of cloning a new one
35
+ - **Attach existing worktrees** — Kōbō detects orphan git worktrees for the selected project (created outside Kōbō, or left over from an earlier install) and lets you attach them to a new workspace from the creation form, picking up the existing branch and folder instead of cloning a new one
36
36
  - **Quota-aware retry backoff** — when a Claude rate limit is hit mid-session, Kōbō schedules the retry at the actual reset time reported by the API (via `rate_limit.info.buckets[].resetsAt`), falling back to a 15 → 30 → 60 → 180 → 300 min ladder only when the reset info is missing or implausible
37
37
  - **Scheduled wakeups** — the `ScheduleWakeup` tool is honoured server-side: Kōbō persists the wakeup in SQLite, rehydrates on restart, and respawns the agent with `--resume` at the target time
38
38
 
@@ -252,7 +252,7 @@ See [`AGENTS.md`](./AGENTS.md) for a deeper dive into conventions, data model, W
252
252
 
253
253
  | Table | Purpose |
254
254
  |---|---|
255
- | `workspaces` | the unit of work — branch, status, model, engine, `archived_at`, `favorited_at`, `tags`, Notion link, … |
255
+ | `workspaces` | the unit of work — branch, `worktree_path`, status, model, engine, `archived_at`, `favorited_at`, `tags`, Notion link, … |
256
256
  | `tasks` | workspace sub-items — tasks and acceptance criteria |
257
257
  | `agent_sessions` | agent runs — pid, `engine_session_id`, lifecycle |
258
258
  | `ws_events` | persisted WebSocket events (chat history, `agent:event` stream, user messages) for replay on reconnect |
@@ -269,9 +269,10 @@ The MCP server reads and writes the same SQLite database as the main backend. Is
269
269
 
270
270
  ## Configuration
271
271
 
272
- Kōbō reads settings from `~/.config/kobo/settings.json` (or falls back to defaults). Global settings cascade into per-project overrides:
272
+ Kōbō reads settings from `~/.config/kobo/settings.json` (or falls back to defaults). Global settings define defaults, with per-project overrides for project-scoped fields:
273
273
 
274
274
  - `defaultModel` — Claude model to use (e.g. `claude-opus-4-6`)
275
+ - `worktreesPath` — where new workspace worktrees are created. Defaults to `.worktrees`, resolved relative to the project. Absolute Linux/macOS paths, Windows paths (`C:\kobo\worktrees`, UNC shares), `$HOME/...`, `${HOME}/...`, `~/...`, and `%USERPROFILE%\...` are accepted. Paths containing parent-directory traversal (`..`) or drive-relative Windows syntax (`C:foo`) are rejected.
275
276
  - `prPromptTemplate` — template rendered when opening a PR via the `/open-pr` endpoint; supports `{{pr_number}}`, `{{pr_url}}`, `{{branch_name}}`, `{{diff_stats}}`, `{{commits}}`, etc.
276
277
  - `gitConventions` — markdown-formatted git conventions written to `.ai/.git-conventions.md` in every workspace so the agent follows them when committing
277
278
  - `devServer` — per-project `startCommand` / `stopCommand` for launching workspace-scoped dev servers
@@ -288,6 +289,10 @@ This is a personal tool, but PRs and issues are welcome. Before submitting:
288
289
 
289
290
  CI runs lint + type check + tests on every PR to `develop`.
290
291
 
292
+ ## Release
293
+
294
+ Releases are cut from `main`. Bump `package.json` and `package-lock.json` on `develop`, merge `develop` into `main`, then push `main`. The release workflow builds, tests, publishes the current package version to npm, tags it as `v<version>`, and creates the GitHub Release. If the npm version or tag already exists, the workflow fails before publishing.
295
+
291
296
  ## License
292
297
 
293
298
  GNU General Public License v3.0 or later. See [`LICENSE`](./LICENSE) for the full text.
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { nanoid } from 'nanoid';
4
+ import { resolveWorkspaceWorktreePath } from '../server/utils/worktree-paths.js';
4
5
  /** Allowed task status values. */
5
6
  export const VALID_TASK_STATUSES = ['pending', 'in_progress', 'done'];
6
7
  function rowToDto(row) {
@@ -163,7 +164,7 @@ export function getWorkspaceInfoHandler(db, workspaceId) {
163
164
  projectPath: row.project_path,
164
165
  sourceBranch: row.source_branch,
165
166
  workingBranch: row.working_branch,
166
- worktreePath: row.worktree_path ?? path.join(row.project_path, '.worktrees', row.working_branch),
167
+ worktreePath: row.worktree_path ?? resolveWorkspaceWorktreePath(row.project_path, row.working_branch),
167
168
  status: row.status,
168
169
  model: row.model,
169
170
  notionUrl: row.notion_url,
@@ -1,11 +1,11 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
- import path from 'node:path';
4
3
  import { Hono } from 'hono';
5
4
  import { getDb } from '../db/index.js';
6
5
  import { SCHEMA_VERSION } from '../db/migrations.js';
7
6
  import { getGlobalSettings, SETTINGS_SCHEMA_VERSION } from '../services/settings-service.js';
8
7
  import { getDbPath, getKoboHome } from '../utils/paths.js';
8
+ import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
9
9
  const app = new Hono();
10
10
  function checkClaudeCli() {
11
11
  try {
@@ -51,7 +51,7 @@ app.get('/report', (c) => {
51
51
  for (const ws of workspaces) {
52
52
  if (ws.archived_at)
53
53
  continue;
54
- const wtPath = ws.worktree_path ?? path.join(ws.project_path, '.worktrees', ws.working_branch);
54
+ const wtPath = ws.worktree_path ?? resolveWorkspaceWorktreePath(ws.project_path, ws.working_branch);
55
55
  if (!fs.existsSync(wtPath)) {
56
56
  worktreesMissing.push({ workspaceId: ws.id, name: ws.name, path: wtPath, exists: false });
57
57
  }
@@ -45,7 +45,8 @@ app.put('/global', async (c) => {
45
45
  }
46
46
  catch (err) {
47
47
  const message = err instanceof Error ? err.message : String(err);
48
- return c.json({ error: message }, 500);
48
+ const status = err instanceof Error && err.name === 'InvalidWorktreesPathError' ? 400 : 500;
49
+ return c.json({ error: message }, status);
49
50
  }
50
51
  });
51
52
  // GET /api/settings/projects — list all projects
@@ -23,6 +23,7 @@ import * as wsService from '../services/websocket-service.js';
23
23
  import * as workspaceService from '../services/workspace-service.js';
24
24
  import * as worktreeService from '../services/worktree-service.js';
25
25
  import * as gitOps from '../utils/git-ops.js';
26
+ import { resolveSiblingWorkspaceWorktreePath } from '../utils/worktree-paths.js';
26
27
  /** Hono sub-router for workspace CRUD, tasks, agent lifecycle, git operations, and PR creation. */
27
28
  const app = new Hono();
28
29
  /** Tracks workspaces currently running a setup script to prevent concurrent executions. */
@@ -197,6 +198,7 @@ app.post('/', migrationGuard, async (c) => {
197
198
  reasoningEffort: body.reasoningEffort,
198
199
  agentPermissionMode: resolveCreateAgentPermissionMode(body.agentPermissionMode, body.projectPath, globalSettings),
199
200
  engine: body.engine,
201
+ ...(useReusedWorktree ? {} : { worktreesPath: globalSettings.worktreesPath }),
200
202
  });
201
203
  // Auto-tag the workspace based on its creation source — `notion` when
202
204
  // imported from a Notion page, `sentry` when bootstrapped from a Sentry
@@ -287,7 +289,7 @@ app.post('/', migrationGuard, async (c) => {
287
289
  }
288
290
  else {
289
291
  try {
290
- worktreePath = worktreeService.createWorktree(body.projectPath, workingBranch, body.sourceBranch);
292
+ worktreePath = worktreeService.createWorktree(body.projectPath, workingBranch, body.sourceBranch, globalSettings.worktreesPath);
291
293
  }
292
294
  catch (err) {
293
295
  const message = err instanceof Error ? err.message : String(err);
@@ -1725,11 +1727,7 @@ app.post('/:id/rename-branch', async (c) => {
1725
1727
  // Sibling rename: keep the same worktrees-root, swap the branch leaf.
1726
1728
  // Cannot use `path.dirname` directly because branches with slashes
1727
1729
  // (e.g. `feature/x`) make the dirname end one level too deep.
1728
- const oldSuffix = `/${workspace.workingBranch}`;
1729
- const worktreesRoot = oldWorktreePath.endsWith(oldSuffix)
1730
- ? oldWorktreePath.slice(0, -oldSuffix.length)
1731
- : path.join(workspace.projectPath, '.worktrees');
1732
- const newWorktreePath = path.join(worktreesRoot, newName);
1730
+ const newWorktreePath = resolveSiblingWorkspaceWorktreePath(workspace.projectPath, oldWorktreePath, workspace.workingBranch, newName);
1733
1731
  // Reject early if the target name is already in use — either as a local
1734
1732
  // branch or on origin. Avoids git's generic "already exists" error and
1735
1733
  // protects against the same silent-fallback trap the create flow has.
@@ -1799,11 +1797,7 @@ app.post('/:id/resync-branch', (c) => {
1799
1797
  // if the move fails (dir already moved, lockfile, dirty tree), we still
1800
1798
  // update the DB so git ops stay aligned with the current ref name — the
1801
1799
  // user can repair the dir manually.
1802
- const oldSuffix = `/${workspace.workingBranch}`;
1803
- const worktreesRoot = worktreePath.endsWith(oldSuffix)
1804
- ? worktreePath.slice(0, -oldSuffix.length)
1805
- : path.join(workspace.projectPath, '.worktrees');
1806
- const newWorktreePath = path.join(worktreesRoot, actual);
1800
+ const newWorktreePath = resolveSiblingWorkspaceWorktreePath(workspace.projectPath, worktreePath, workspace.workingBranch, actual);
1807
1801
  try {
1808
1802
  gitOps.moveWorktree(workspace.projectPath, worktreePath, newWorktreePath);
1809
1803
  workspaceService.updateWorktreePath(id, newWorktreePath);
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
- import path from 'node:path';
3
2
  import { buildE2eIterationBlock, buildFinalizationIterationBlock } from '../../shared/auto-loop-prompts.js';
4
3
  import { getDb } from '../db/index.js';
4
+ import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
5
5
  import * as orchestrator from './agent/orchestrator.js';
6
6
  import * as settingsService from './settings-service.js';
7
7
  import { emit, emitEphemeral } from './websocket-service.js';
@@ -252,7 +252,7 @@ function spawnNextIteration(workspaceId, opts = {}) {
252
252
  .replaceAll('{taskTitle}', task.title)
253
253
  .replaceAll('{isAcceptanceCriterion}', String(task.isAcceptanceCriterion))
254
254
  .replaceAll('{overrideBlock}', overrideBlock);
255
- const worktreePath = row.worktree_path ?? path.join(row.project_path, '.worktrees', row.working_branch);
255
+ const worktreePath = row.worktree_path ?? resolveWorkspaceWorktreePath(row.project_path, row.working_branch);
256
256
  // Plan mode would deadlock the loop (blocks MCP + edits) — promote to bypass.
257
257
  // Other modes (bypass/strict/interactive) are honored.
258
258
  const stored = (row.agent_permission_mode ?? 'bypass');
@@ -1,7 +1,9 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { WORKTREES_PATH } from '../../shared/consts.js';
3
4
  import { listClaudeMcpEntries } from '../utils/mcp-client.js';
4
5
  import { getSettingsPath } from '../utils/paths.js';
6
+ import { InvalidWorktreesPathError, resolveGlobalWorktreesRoot, sanitizeWorktreesPath, validateWorktreesPath, } from '../utils/worktree-paths.js';
5
7
  const DEFAULT_GIT_CONVENTIONS = `# Git conventions
6
8
 
7
9
  ## Commits
@@ -191,6 +193,13 @@ const settingsMigrations = [
191
193
  }
192
194
  },
193
195
  },
196
+ {
197
+ version: 11,
198
+ name: 'add-global-worktrees-path',
199
+ migrate({ global }) {
200
+ global.worktreesPath = sanitizeWorktreesPath(global.worktreesPath);
201
+ },
202
+ },
194
203
  ];
195
204
  /** Current settings schema version — always equals the highest migration version. */
196
205
  export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
@@ -231,6 +240,7 @@ function defaultSettings() {
231
240
  notionMcpKey: '',
232
241
  sentryMcpKey: '',
233
242
  tags: [...DEFAULT_WORKSPACE_TAGS],
243
+ worktreesPath: WORKTREES_PATH,
234
244
  },
235
245
  projects: [],
236
246
  };
@@ -278,6 +288,7 @@ export function runSettingsMigrations(raw) {
278
288
  version = m.version;
279
289
  }
280
290
  }
291
+ current.global.worktreesPath = sanitizeWorktreesPath(current.global.worktreesPath);
281
292
  current.schemaVersion = version;
282
293
  return current;
283
294
  }
@@ -310,9 +321,11 @@ function readSettings() {
310
321
  // Restore any global fields that may have been removed by external edits.
311
322
  // Defaults act as fallback for missing keys; existing values are preserved.
312
323
  const globalDefaults = defaultSettings().global;
324
+ const globalBeforeDefaults = JSON.stringify(migrated.global);
313
325
  migrated.global = { ...globalDefaults, ...migrated.global };
326
+ const restoredGlobalFields = JSON.stringify(migrated.global) !== globalBeforeDefaults;
314
327
  // Persist if migrations bumped the version, or if global fields were restored.
315
- if (migrated.schemaVersion !== originalVersion) {
328
+ if (migrated.schemaVersion !== originalVersion || restoredGlobalFields) {
316
329
  writeSettings(migrated);
317
330
  }
318
331
  return migrated;
@@ -450,6 +463,7 @@ export function updateGlobalSettings(data) {
450
463
  'notionMcpKey',
451
464
  'sentryMcpKey',
452
465
  'tags',
466
+ 'worktreesPath',
453
467
  ];
454
468
  const filtered = pickKnownKeys(data, allowedGlobalKeys);
455
469
  if (filtered.tags !== undefined) {
@@ -459,10 +473,30 @@ export function updateGlobalSettings(data) {
459
473
  .filter((t) => t.length > 0 && t.length <= 50)))
460
474
  : settings.global.tags;
461
475
  }
476
+ if (filtered.worktreesPath !== undefined) {
477
+ filtered.worktreesPath = validateWorktreesPath(filtered.worktreesPath, { allowEmpty: false });
478
+ ensureGlobalWorktreesRootExists(filtered.worktreesPath);
479
+ }
462
480
  settings.global = { ...settings.global, ...filtered };
463
481
  writeSettings(settings, { backup: true });
464
482
  return settings.global;
465
483
  }
484
+ function ensureGlobalWorktreesRootExists(worktreesPath) {
485
+ const root = resolveGlobalWorktreesRoot(worktreesPath);
486
+ if (!root || isNonNativeWindowsPath(root))
487
+ return;
488
+ try {
489
+ fs.mkdirSync(root, { recursive: true });
490
+ }
491
+ catch (err) {
492
+ const message = err instanceof Error ? err.message : String(err);
493
+ throw new InvalidWorktreesPathError(`Cannot create worktrees directory '${root}': ${message}`);
494
+ }
495
+ }
496
+ function isNonNativeWindowsPath(value) {
497
+ return (process.platform !== 'win32' &&
498
+ (/^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('//')));
499
+ }
466
500
  /** Create or update project-specific settings. Merges devServer, e2e, and finalization fields on update. */
467
501
  export function upsertProject(projectPath, data) {
468
502
  const allowedProjectKeys = [
@@ -1,5 +1,5 @@
1
- import path from 'node:path';
2
1
  import { getDb } from '../db/index.js';
2
+ import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
3
3
  import * as orchestrator from './agent/orchestrator.js';
4
4
  import { emitEphemeral } from './websocket-service.js';
5
5
  const MIN_DELAY_SECONDS = 60;
@@ -132,7 +132,7 @@ function fire(workspaceId) {
132
132
  emitEphemeral(workspaceId, 'wakeup:skipped', { reason: 'fire-failed' });
133
133
  return;
134
134
  }
135
- const worktreePath = wsRow.worktree_path ?? path.join(wsRow.project_path, '.worktrees', wsRow.working_branch);
135
+ const worktreePath = wsRow.worktree_path ?? resolveWorkspaceWorktreePath(wsRow.project_path, wsRow.working_branch);
136
136
  // Narrow against the four known values; unknowns → 'bypass'.
137
137
  const stored = wsRow.agent_permission_mode;
138
138
  const agentPermissionMode = stored === 'plan' || stored === 'strict' || stored === 'interactive' ? stored : 'bypass';
@@ -1,5 +1,6 @@
1
1
  import { nanoid } from 'nanoid';
2
2
  import { getDb } from '../db/index.js';
3
+ import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
3
4
  import * as orchestrator from './agent/orchestrator.js';
4
5
  import * as autoLoopService from './auto-loop-service.js';
5
6
  import * as wakeupService from './wakeup-service.js';
@@ -91,7 +92,7 @@ export function createWorkspace(data) {
91
92
  const db = getDb();
92
93
  const now = new Date().toISOString();
93
94
  const id = nanoid();
94
- const computedWorktreePath = data.worktreePath ?? `${data.projectPath}/.worktrees/${data.workingBranch}`;
95
+ const computedWorktreePath = data.worktreePath ?? resolveWorkspaceWorktreePath(data.projectPath, data.workingBranch, data.worktreesPath);
95
96
  const owned = data.worktreeOwned ?? true;
96
97
  // Mirror the unified mode into the legacy columns so older readers (in-flight
97
98
  // requests during deploy, external scripts) still see a sane value.
@@ -2,21 +2,29 @@ import { execFileSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { isGitBranchExistsError } from '../utils/git-ops.js';
5
+ import { resolveWorkspaceWorktreePath, resolveWorktreesRoot } from '../utils/worktree-paths.js';
5
6
  function git(repoPath, args) {
6
7
  return execFileSync('git', args, { cwd: repoPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
7
8
  }
8
9
  function getExcludeFilePath(projectPath) {
9
10
  return path.join(projectPath, '.git', 'info', 'exclude');
10
11
  }
12
+ function projectRelativeWorktreePath(projectPath, worktreePath) {
13
+ const relativePath = path.relative(projectPath, worktreePath);
14
+ if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath))
15
+ return null;
16
+ return relativePath;
17
+ }
11
18
  function addToExclude(projectPath, worktreePath) {
19
+ const relativePath = projectRelativeWorktreePath(projectPath, worktreePath);
20
+ if (!relativePath)
21
+ return;
12
22
  const excludeFile = getExcludeFilePath(projectPath);
13
23
  // Ensure the .git/info directory exists
14
24
  const infoDir = path.dirname(excludeFile);
15
25
  if (!fs.existsSync(infoDir)) {
16
26
  fs.mkdirSync(infoDir, { recursive: true });
17
27
  }
18
- // Make the path relative to projectPath for cleaner exclude entries
19
- const relativePath = path.relative(projectPath, worktreePath);
20
28
  const entry = `/${relativePath}`;
21
29
  let current = '';
22
30
  if (fs.existsSync(excludeFile)) {
@@ -28,23 +36,25 @@ function addToExclude(projectPath, worktreePath) {
28
36
  }
29
37
  }
30
38
  function removeFromExclude(projectPath, worktreePath) {
39
+ const relativePath = projectRelativeWorktreePath(projectPath, worktreePath);
40
+ if (!relativePath)
41
+ return;
31
42
  const excludeFile = getExcludeFilePath(projectPath);
32
43
  if (!fs.existsSync(excludeFile))
33
44
  return;
34
- const relativePath = path.relative(projectPath, worktreePath);
35
45
  const entry = `/${relativePath}`;
36
46
  const lines = fs.readFileSync(excludeFile, 'utf-8').split('\n');
37
47
  const filtered = lines.filter((line) => line !== entry);
38
48
  const trimmed = filtered.join('\n').replace(/\n+$/, '');
39
49
  fs.writeFileSync(excludeFile, trimmed ? `${trimmed}\n` : '', 'utf-8');
40
50
  }
41
- /** Create a git worktree under `.worktrees/` for the given branch. Returns the worktree path. */
42
- export function createWorktree(projectPath, branchName, sourceBranch) {
43
- const worktreesDir = path.join(projectPath, '.worktrees');
51
+ /** Create a git worktree for the given branch. Returns the worktree path. */
52
+ export function createWorktree(projectPath, branchName, sourceBranch, worktreesPath) {
53
+ const worktreesDir = resolveWorktreesRoot(projectPath, worktreesPath);
44
54
  if (!fs.existsSync(worktreesDir)) {
45
55
  fs.mkdirSync(worktreesDir, { recursive: true });
46
56
  }
47
- const worktreePath = path.join(worktreesDir, branchName);
57
+ const worktreePath = resolveWorkspaceWorktreePath(projectPath, branchName, worktreesPath);
48
58
  try {
49
59
  // Use origin/<sourceBranch> as the base so the worktree starts from the
50
60
  // freshly-fetched remote ref (fetchSourceBranch is always called first).
@@ -0,0 +1,134 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { WORKTREES_PATH } from '../../shared/consts.js';
4
+ const BRACED_HOME = '$' + '{HOME}';
5
+ const USERPROFILE = '%' + 'USERPROFILE' + '%';
6
+ const HOME_ALIASES = ['~', '$HOME', BRACED_HOME, USERPROFILE];
7
+ export class InvalidWorktreesPathError extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = 'InvalidWorktreesPathError';
11
+ }
12
+ }
13
+ /** Return the configured worktrees root, falling back to the default constant. */
14
+ export function normalizeWorktreesPath(value) {
15
+ const trimmed = typeof value === 'string' ? value.trim() : '';
16
+ return trimmed || WORKTREES_PATH;
17
+ }
18
+ function homeDir(alias) {
19
+ if (alias === USERPROFILE) {
20
+ return process.env.USERPROFILE || process.env.HOME || os.homedir();
21
+ }
22
+ return process.env.HOME || process.env.USERPROFILE || os.homedir();
23
+ }
24
+ function isWindowsStylePath(value) {
25
+ return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('//');
26
+ }
27
+ function pathFlavor(...values) {
28
+ return values.some(isWindowsStylePath) ? path.win32 : path;
29
+ }
30
+ function branchPathSegments(branchName) {
31
+ return branchName.split(/[\\/]+/).filter(Boolean);
32
+ }
33
+ function parseHomeAlias(value) {
34
+ for (const alias of HOME_ALIASES) {
35
+ if (value === alias)
36
+ return { alias, rest: '' };
37
+ if (value.startsWith(`${alias}/`) || value.startsWith(`${alias}\\`)) {
38
+ return { alias, rest: value.slice(alias.length + 1) };
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+ function withoutHomeAlias(value) {
44
+ const parsed = parseHomeAlias(value);
45
+ if (parsed)
46
+ return parsed.rest;
47
+ return value;
48
+ }
49
+ function hasParentTraversal(value) {
50
+ return withoutHomeAlias(value)
51
+ .split(/[\\/]+/)
52
+ .filter(Boolean)
53
+ .includes('..');
54
+ }
55
+ function hasControlCharacters(value) {
56
+ for (const char of value) {
57
+ const code = char.charCodeAt(0);
58
+ if (code < 32 || code === 127)
59
+ return true;
60
+ }
61
+ return false;
62
+ }
63
+ /** Validate a user-supplied worktrees path and return its normalized persisted value. */
64
+ export function validateWorktreesPath(value, options = {}) {
65
+ const trimmed = typeof value === 'string' ? value.trim() : '';
66
+ if (!trimmed && options.allowEmpty === false) {
67
+ throw new InvalidWorktreesPathError('Worktrees path is required');
68
+ }
69
+ const normalized = trimmed || WORKTREES_PATH;
70
+ if (hasControlCharacters(normalized)) {
71
+ throw new InvalidWorktreesPathError('Worktrees path cannot contain control characters');
72
+ }
73
+ if (/^[A-Za-z]:(?![\\/])/.test(normalized)) {
74
+ throw new InvalidWorktreesPathError('Windows drive paths must be absolute, for example C:\\kobo\\worktrees');
75
+ }
76
+ if (hasParentTraversal(normalized)) {
77
+ throw new InvalidWorktreesPathError('Worktrees path cannot contain parent directory traversal (`..`)');
78
+ }
79
+ return normalized;
80
+ }
81
+ /** Best-effort normalization for already persisted settings. Invalid values fall back to the default. */
82
+ export function sanitizeWorktreesPath(value) {
83
+ try {
84
+ return validateWorktreesPath(value);
85
+ }
86
+ catch {
87
+ return WORKTREES_PATH;
88
+ }
89
+ }
90
+ /** Expand user-friendly home aliases supported in settings.json. */
91
+ export function expandHomePath(value) {
92
+ const parsed = parseHomeAlias(value);
93
+ if (!parsed)
94
+ return value;
95
+ const home = homeDir(parsed.alias);
96
+ if (!home)
97
+ return value;
98
+ if (parsed.rest === '')
99
+ return home;
100
+ const restSegments = parsed.rest.split(/[\\/]+/).filter(Boolean);
101
+ return pathFlavor(home, value).join(home, ...restSegments);
102
+ }
103
+ /**
104
+ * Resolve the worktrees root for a project.
105
+ * Relative settings are project-relative; absolute settings are machine-wide.
106
+ */
107
+ export function resolveWorktreesRoot(projectPath, configuredPath) {
108
+ const expanded = expandHomePath(validateWorktreesPath(configuredPath));
109
+ const flavor = pathFlavor(projectPath, expanded);
110
+ return flavor.isAbsolute(expanded) ? flavor.normalize(expanded) : flavor.resolve(projectPath, expanded);
111
+ }
112
+ export function resolveGlobalWorktreesRoot(configuredPath) {
113
+ const expanded = expandHomePath(validateWorktreesPath(configuredPath, { allowEmpty: false }));
114
+ const flavor = pathFlavor(expanded);
115
+ return flavor.isAbsolute(expanded) ? flavor.normalize(expanded) : null;
116
+ }
117
+ /** Resolve the full on-disk path for a workspace worktree. */
118
+ export function resolveWorkspaceWorktreePath(projectPath, workingBranch, configuredPath) {
119
+ const root = resolveWorktreesRoot(projectPath, configuredPath);
120
+ return pathFlavor(projectPath, root).join(root, ...branchPathSegments(workingBranch));
121
+ }
122
+ /** Resolve a renamed worktree next to its current path when the current path still matches its branch. */
123
+ export function resolveSiblingWorkspaceWorktreePath(projectPath, worktreePath, currentBranch, nextBranch) {
124
+ const flavor = pathFlavor(projectPath, worktreePath);
125
+ const normalizedWorktreePath = flavor.normalize(worktreePath);
126
+ const currentBranchPath = flavor.join(...branchPathSegments(currentBranch));
127
+ const currentBranchSuffix = `${flavor.sep}${currentBranchPath}`;
128
+ const comparableWorktreePath = flavor === path.win32 ? normalizedWorktreePath.toLowerCase() : normalizedWorktreePath;
129
+ const comparableSuffix = flavor === path.win32 ? currentBranchSuffix.toLowerCase() : currentBranchSuffix;
130
+ const root = comparableWorktreePath.endsWith(comparableSuffix)
131
+ ? normalizedWorktreePath.slice(0, -currentBranchSuffix.length)
132
+ : resolveWorktreesRoot(projectPath);
133
+ return flavor.join(root, ...branchPathSegments(nextBranch));
134
+ }
@@ -0,0 +1 @@
1
+ export const WORKTREES_PATH = '.worktrees';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loicngr/kobo",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
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",
@@ -0,0 +1,7 @@
1
+ import{E as e,F as t,H as n,L as r,M as i,Q as a,U as o,_t as s,bt as c,d as l,f as u,g as d,h as f,l as p,p as m,r as h,rt as g,u as _,v,yt as y}from"./runtime-core.esm-bundler-C3IgBgY5.js";import{L as b,l as x,t as S}from"./QIcon-B0-pH3Qs.js";import{n as C,t as w}from"./settings-lT4GB-uB.js";import{t as T}from"./QBtn-DHwAb18J.js";import{n as E}from"./vue-i18n-eUDnMrPl.js";import{d as D,g as O,l as k,p as A}from"./index-DoYBJtQA.js";import{t as j}from"./QSpinnerDots-DwtnRN2r.js";import{t as M}from"./QTooltip-B3CmRx4j.js";import{t as N}from"./QExpansionItem-BnIPCzXR.js";import{t as ee}from"./QScrollArea-BDCKOKuE.js";import{n as P,t as F}from"./marked.esm-DLCrAGtO.js";import{t as I}from"./documents-D6A3wRry.js";import{t as L}from"./_plugin-vue_export-helper-B8bB5DBd.js";function te(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 ne(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}function R(e){switch(e.type){case`user`:return e.sender===`system-prompt`?`system-prompt`:`user`;case`session`:return`session`;default:return`agent`}}function re(e){let t=[],n=null;for(let r of e){let e=R(r),i=e===`session`||e===`system-prompt`;!n||n.speaker!==e||i?(n={speaker:e,ts:r.ts,items:[r]},t.push(n),i&&(n=null)):n.items.push(r)}return t}var z={class:`text-caption text-grey-6`},B=v({__name:`SessionEventItem`,props:{item:{}},setup(e){let n=e,r=p(()=>{switch(n.item.kind){case`started`:return`session.started`;case`ended`:return`session.ended`;case`compacted`:return`session.compacted`;default:return`session.started`}});return(e,n)=>(t(),m(`span`,z,c(e.$t(r.value)),1))}});function V(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){H(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 H(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=ie(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 ie(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`],W=L(v({__name:`TextMessageItem`,props:{item:{}},setup(e){let n=e,r=I(),i=k(),a=p(()=>{let e=i.selectedWorkspaceId;return e?r.documentsFor(e).map(e=>e.path):[]}),o=p(()=>{let e=V(F.parse(n.item.text,{async:!1,breaks:!0,gfm:!0}),a.value);return P.sanitize(e,{ADD_ATTR:[`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(n,r)=>(t(),m(`div`,{class:`markdown-message`,onClick:s},[_(`div`,{innerHTML:o.value},null,8,U),e.item.streaming?(t(),l(x,{key:0,size:`xs`,class:`q-ml-xs`})):u(``,!0)]))}}),[[`__scopeId`,`data-v-e9f9bd11`]]),G={key:0,class:`text-caption text-grey-5`,style:{"font-style":`italic`}},ae=[`innerHTML`],oe={key:1,style:{"white-space":`pre-wrap`}},K=L(v({__name:`ThinkingItem`,props:{item:{}},setup(e){let n=e,r=p(()=>n.item.text.trim().slice(0,100)),i=p(()=>n.item.text.trim().length>0),a=p(()=>n.item.text.trim().length>100),s=p(()=>{let e=F.parse(n.item.text,{async:!1,breaks:!0,gfm:!0});return P.sanitize(e)});return(n,d)=>i.value?(t(),m(`div`,G,[a.value?(t(),l(N,{key:0,dense:``,"dense-toggle":``,label:r.value,"header-class":`text-grey-5 text-caption`,style:{"font-style":`italic`}},{default:o(()=>[_(`div`,{class:`q-py-xs markdown-thinking`,innerHTML:s.value},null,8,ae)]),_:1},8,[`label`])):(t(),m(`span`,oe,c(e.item.text),1))])):u(``,!0)}}),[[`__scopeId`,`data-v-4e64694c`]]);function se(e,t){let n=e.split(`
2
+ `),r=t.split(`
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 ce(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??``;return{toolName:`Edit`,filePath:e,oldString:t,newString:r,replaceAll:n.replace_all??!1,additions:r?r.split(`
4
+ `).length:0,deletions:t?t.split(`
5
+ `).length:0}}if(e===`Write`){let e=n.file_path;if(!e)return null;let t=n.content??``;return{toolName:`Write`,filePath:e,content:t,additions:t?t.split(`
6
+ `).length:0,deletions:0}}if(e===`Bash`){let e=(n.command??``).match(/^\s*rm\s+(?:-[a-zA-Z]*\s+)*(.+)/);if(e)return{toolName:`Bash:rm`,filePath:e[1].trim().replace(/["']/g,``),additions:0,deletions:1}}return null}function q(e,t){if(!e||!t?.projectPath)return e;let n=t.worktreePath;if(n){let t=J(e,n);if(t!==e)return t}let r=J(e,`${t.projectPath}/${C}/${t.workingBranch}`);return r===e?J(e,t.projectPath):r}function J(e,t){if(!t)return e;let n=le(t);return n?e.replace(RegExp(`${n}[\\\\/]+`,`g`),``).replace(RegExp(`${n}(?=\\s|$|["'\`])`,`g`),`.`):e}function le(e){let t=e.replace(/[\\/]+$/,``);return t?`${/^[\\/]+/.test(t)?`[\\\\/]+`:``}${t.split(/[\\/]+/).filter(Boolean).map(ue).join(`[\\\\/]+`)}`:``}function ue(e){return e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`)}var Y={class:`tool-name`},X=[`title`],de={key:0,class:`tool-stat-add`},fe={key:1,class:`tool-stat-del`},Z={class:`diff-sign`},Q={class:`tool-name`},$=[`title`],pe=L(v({__name:`ToolCallItem`,props:{item:{}},setup(e){let i=e,o=a(!1),g=k(),v=p(()=>ce(i.item.name,i.item.input)),y=p(()=>v.value?q(v.value.filePath,g.selectedWorkspace):``),x={Bash:`terminal`,Read:`description`,Edit:`edit`,Write:`edit_note`,MultiEdit:`edit`,Glob:`folder_open`,Grep:`manage_search`,LS:`list`,Skill:`auto_awesome`,Task:`hub`,Agent:`hub`,TodoWrite:`checklist`,TodoRead:`checklist`,ToolSearch:`search`,WebFetch:`public`,WebSearch:`travel_explore`,NotebookRead:`book`,NotebookEdit:`edit_note`,SendMessage:`send`,ExitPlanMode:`check_circle_outline`,KillShell:`stop_circle`,BashOutput:`terminal`},C=p(()=>x[i.item.name]??`build`),w=p(()=>{if(v.value)return``;let e=i.item.input,t=T(e);return t?q(t,g.selectedWorkspace):``});function T(e){if(!e||typeof e!=`object`)return typeof e==`string`?e:``;let t=e;for(let e of[`file_path`,`path`,`command`,`pattern`,`query`,`url`,`skill`,`description`,`subject`,`prompt`]){let n=t[e];if(typeof n==`string`&&n.length>0)return n}for(let e of Object.values(t))if(typeof e==`string`&&e.length>0)return e;return``}let E=p(()=>{let e=v.value;return e?e.toolName===`Edit`&&e.oldString!==void 0&&e.newString!==void 0?se(e.oldString,e.newString):e.toolName===`Write`&&e.content!==void 0?e.content.split(`
7
+ `).map(e=>({type:`add`,content:e})):e.toolName===`Bash:rm`?[{type:`del`,content:`File deleted`}]:null:null}),D=p(()=>{let e=i.item.result;if(!e)return``;if(typeof e.output==`string`)return e.output;try{return JSON.stringify(e.output)}catch{return String(e.output)}}),O=new Set([`Read`]),A=p(()=>!!(i.item.result&&D.value)&&(!O.has(i.item.name)||i.item.result?.isError===!0));function j(){o.value=!o.value}return n(()=>i.item.result?.isError===!0,e=>{e&&(o.value=!0)},{immediate:!0}),(n,i)=>v.value?(t(),m(`div`,{key:0,class:s([`tool-row`,{"tool-row-expanded":o.value}])},[_(`div`,{class:`tool-header`,onClick:j},[d(S,{name:C.value,size:`14px`,class:`tool-icon`},null,8,[`name`]),_(`span`,Y,c(v.value.toolName===`Bash:rm`?`Bash`:v.value.toolName),1),_(`span`,{class:`tool-path`,title:v.value.filePath},c(y.value),9,X),v.value.additions>0?(t(),m(`span`,de,`+`+c(v.value.additions),1)):u(``,!0),v.value.deletions>0?(t(),m(`span`,fe,`-`+c(v.value.deletions),1)):u(``,!0),e.item.result?.isError?(t(),l(S,{key:2,name:`error_outline`,color:`negative`,size:`xs`,class:`q-ml-xs`})):e.item.result?(t(),l(S,{key:3,name:`check`,color:`positive`,size:`xs`,class:`q-ml-xs`})):u(``,!0),d(S,{name:o.value?`expand_less`:`expand_more`,size:`xs`,class:`q-ml-auto text-grey-6`},null,8,[`name`])]),o.value&&E.value?(t(),m(`div`,{key:0,class:`tool-diff`,onClick:i[0]||=b(()=>{},[`stop`])},[(t(!0),m(h,null,r(E.value,(e,n)=>(t(),m(`div`,{key:n,class:s([`diff-line`,{"diff-del":e.type===`del`,"diff-add":e.type===`add`,"diff-context":e.type===`context`}])},[_(`span`,Z,c(e.type===`del`?`-`:e.type===`add`?`+`:` `),1),f(c(e.content),1)],2))),128))])):u(``,!0)],2)):(t(),m(`div`,{key:1,class:s([`tool-row tool-row-generic`,{"tool-row-expanded":o.value,"tool-row--toggleable":A.value}])},[_(`div`,{class:`tool-header`,onClick:i[1]||=e=>A.value&&j()},[d(S,{name:C.value,size:`14px`,class:`tool-icon`},null,8,[`name`]),_(`span`,Q,c(e.item.name),1),w.value?(t(),m(`span`,{key:0,class:`tool-arg`,title:T(e.item.input)||w.value},c(w.value),9,$)):u(``,!0),e.item.result?.isError?(t(),l(S,{key:1,name:`error_outline`,color:`negative`,size:`xs`,class:`q-ml-auto`})):e.item.result?(t(),l(S,{key:2,name:`check`,color:`positive`,size:`xs`,class:`q-ml-auto`})):u(``,!0),A.value?(t(),l(S,{key:3,name:o.value?`expand_less`:`expand_more`,size:`xs`,class:`q-ml-xs text-grey-6`},null,8,[`name`])):u(``,!0)]),o.value&&A.value?(t(),m(`div`,{key:0,class:`tool-output`,onClick:i[2]||=b(()=>{},[`stop`])},c(D.value),1)):u(``,!0)],2))}}),[[`__scopeId`,`data-v-b1fcd20d`]]);function me(e,t){return t?e.replace(/\[image:\s+([^\]]+)\]/g,(e,n)=>{let r=String(n).trim();return/^(\.ai\/images\/|images\/)/.test(r)?`![${r}](${`/api/workspaces/${encodeURIComponent(t)}/images/file?path=${encodeURIComponent(r)}`})`:e}):e}var he=[`innerHTML`],ge=[`innerHTML`],_e=[`src`],ve=L(v({__name:`UserMessageItem`,props:{item:{}},setup(e){let n=e,r=k(),i=p(()=>n.item.sender===`system-prompt`),s=p(()=>{let e=me(n.item.content,r.selectedWorkspaceId??``),t=F.parse(e,{async:!1,breaks:!0,gfm:!0});return P.sanitize(t)}),c=a(null),f=a(!1);function g(e){let t=e.target;if(t?.tagName!==`IMG`)return;let n=t;n.src&&(c.value=n.src,f.value=!0)}return(e,n)=>(t(),m(h,null,[i.value?(t(),l(N,{key:0,dense:``,"dense-toggle":``,label:e.$t(`chat.systemPrompt`),"header-class":`text-grey-5 text-caption`},{default:o(()=>[_(`div`,{class:`q-py-xs markdown-user-prompt`,innerHTML:s.value},null,8,he)]),_:1},8,[`label`])):(t(),m(`div`,{key:1,class:`markdown-message`,onClick:g},[_(`div`,{innerHTML:s.value},null,8,ge)])),d(O,{modelValue:f.value,"onUpdate:modelValue":n[1]||=e=>f.value=e},{default:o(()=>[c.value?(t(),m(`img`,{key:0,src:c.value,alt:``,class:`image-lightbox-img`,onClick:n[0]||=e=>f.value=!1},null,8,_e)):u(``,!0)]),_:1},8,[`modelValue`])],64))}}),[[`__scopeId`,`data-v-c2e7c407`]]),ye={class:`turn-header`},be={key:0,class:`turn-time`},xe={class:`turn-time turn-time-updated`},Se={key:2,class:`turn-actions`},Ce={class:`turn-body`},we={key:0,class:`turn-scroll-top`},Te=L(v({__name:`TurnCard`,props:{turn:{}},emits:[`scrollTo`],setup(e,{emit:n}){let i=e,v=n,{t:b}=E(),x=a(null);function C(){let e=x.value;if(!e)return;let t=e.closest(`.q-scrollarea`)?.querySelector(`.q-scrollarea__content`);if(!t){e.scrollIntoView({behavior:`smooth`,block:`start`});return}let n=e.getBoundingClientRect().top-t.getBoundingClientRect().top;v(`scrollTo`,Math.max(0,n-8))}let w=p(()=>{switch(i.turn.speaker){case`user`:return{label:b(`chat.you`),accent:`#ce93d8`,badgeClass:`turn-badge-user`};case`agent`:return{label:b(`chat.agent`),accent:`#7986cb`,badgeClass:`turn-badge-agent`};case`system-prompt`:return{label:b(`chat.systemPrompt`),accent:`#757575`,badgeClass:`turn-badge-system`};case`session`:return{label:b(`chat.session`),accent:`#616161`,badgeClass:`turn-badge-session`}}});function D(e,t=!1){if(!e)return``;let n=new Date(e);return Number.isNaN(n.getTime())?``:n.toLocaleTimeString(void 0,t?{hour:`2-digit`,minute:`2-digit`,second:`2-digit`}:{hour:`2-digit`,minute:`2-digit`})}let O=p(()=>D(i.turn.ts)),k=p(()=>{let e=i.turn.items;if(e.length===0)return null;for(let t=e.length-1;t>=0;t--){let n=e[t].ts;if(n)return n}return null}),A=p(()=>{let e=i.turn.ts,t=k.value;if(!t||!e||t===e)return``;let n=new Date(e).getTime(),r=new Date(t).getTime();return Number.isNaN(n)||Number.isNaN(r)||r<=n?``:D(t,r-n<6e4)}),j=p(()=>A.value!==``),N=p(()=>i.turn.items.filter(e=>e.type===`tool`).length);return(n,i)=>(t(),m(`div`,{ref_key:`cardEl`,ref:x,class:s([`turn-card`,{"turn-card--user":e.turn.speaker===`user`}]),style:y({"--turn-accent":w.value.accent})},[_(`div`,ye,[_(`span`,{class:s([`turn-badge`,w.value.badgeClass])},c(w.value.label),3),O.value?(t(),m(`span`,be,c(O.value),1)):u(``,!0),j.value?(t(),m(h,{key:1},[d(S,{name:`arrow_forward`,size:`10px`,color:`grey-7`,class:`turn-time-arrow`}),_(`span`,xe,[f(c(A.value)+` `,1),d(M,null,{default:o(()=>[f(c(g(b)(`chat.lastUpdatedAt`,{time:A.value})),1)]),_:1})])],64)):u(``,!0),N.value>0?(t(),m(`span`,Se,` · `+c(g(b)(`chat.nActions`,{n:N.value})),1)):u(``,!0)]),_(`div`,Ce,[(t(!0),m(h,null,r(e.turn.items,(e,n)=>(t(),m(h,{key:n},[e.type===`text`?(t(),l(W,{key:0,item:e},null,8,[`item`])):e.type===`thinking`?(t(),l(K,{key:1,item:e},null,8,[`item`])):e.type===`tool`?(t(),l(pe,{key:2,item:e},null,8,[`item`])):e.type===`user`?(t(),l(ve,{key:3,item:e},null,8,[`item`])):e.type===`session`?(t(),l(B,{key:4,item:e},null,8,[`item`])):u(``,!0)],64))),128))]),e.turn.items.length>4?(t(),m(`div`,we,[d(T,{flat:``,round:``,dense:``,size:`xs`,icon:`arrow_upward`,color:`grey-6`,class:`turn-scroll-top-btn`,onClick:C},{default:o(()=>[d(M,null,{default:o(()=>[f(c(g(b)(`chat.scrollToTurnTop`)),1)]),_:1})]),_:1})])):u(``,!0)],6))}}),[[`__scopeId`,`data-v-4729e0cc`]]),Ee={key:0,class:`activity-feed-switching`},De={key:1,class:`activity-feed-wrap`},Oe={key:0,class:`text-center q-py-sm text-caption text-grey-6`},ke={class:`q-pa-md`},Ae={key:1,class:`q-px-md q-pb-md`},je={class:`activity-feed-nav-cluster`},Me=60,Ne=200,Pe=200,Fe=400,Ie=200,Le=L(v({__name:`ActivityFeed`,props:{workspaceId:{}},setup(s){let g=s,v=A(),y=w(),b=k(),S=p(()=>b.selectedSessionId),C=p(()=>b.sessions.find(e=>e.id===S.value)?.engineSessionId??null),E=p(()=>{let e=b.sessions;return e.length===0?!1:S.value===e[e.length-1].id});function O(e){return S.value?e?e===S.value||e===C.value:E.value:!0}let M=p(()=>(b.activityFeeds[g.workspaceId]??[]).filter(e=>e.type===`text`&&typeof e.content==`string`&&O(e.sessionId)).map(e=>({content:e.content,sender:e.meta?.sender??`user`,ts:e.timestamp,sessionId:e.sessionId}))),P=p(()=>D(b.workspaces.find(e=>e.id===g.workspaceId)?.status)),F=p(()=>{let e=v.eventsFor(g.workspaceId),t=v.timestampsFor(g.workspaceId),n=v.sessionIdsFor(g.workspaceId),r=[],i=[];for(let a=0;a<e.length;a++)O(n[a])&&(r.push(e[a]),i.push(t[a]));let a=ne(te(r,i,P.value),M.value);return re(y.showVerboseSystemMessages?a:a.filter(e=>e.type!==`session`))}),I=p(()=>y.showVerboseSystemMessages?v.eventsFor(g.workspaceId).filter(e=>e.kind===`message:raw`).map(e=>e.content):[]),L=a(null),R=a(!0),z=a(!1),B=!1,V=a(!0),H=a(new Map);function ie(e){R.value=e.verticalSize-e.verticalPosition-e.verticalContainerSize<=Me,B&&e.verticalPosition<=Ne&&!z.value&&W()&&oe()}function U(e,t){return`${e}:${t}`}function W(){let e=S.value;return e?H.value.get(U(g.workspaceId,e))??!0:v.hasMoreOlderFor(g.workspaceId)}function G(e,t,n){H.value.set(U(e,t),n)}function ae(e){if(!S.value)return v.oldestIdFor(e);let t=v.eventIdsFor(e),n=v.sessionIdsFor(e);for(let e=0;e<t.length;e++){if(!O(n[e]))continue;let r=t[e];if(r)return r}}async function oe(){let t=g.workspaceId,n=S.value,r=ae(t);if(!r)return;z.value=!0;let i=Date.now();try{let i=L.value,a=i?.getScroll().verticalSize??0,o=i?.getScroll().verticalPosition??0,s=new URLSearchParams({before:r,limit:`200`});n&&s.set(`session`,n);let c=fetch(`/api/workspaces/${t}/events?${s.toString()}`),l=new Promise(e=>setTimeout(e,Pe)),[u]=await Promise.all([c,l]);if(!u.ok){n?G(t,n,!1):v.prepend(t,[],[],{oldestId:r,hasMoreOlder:!1});return}let d=await u.json(),f=d.events??[],p=f.filter(e=>e.type===`agent:event`&&e.workspaceId===t),m=f.filter(e=>e.type===`user:message`&&e.workspaceId===t),h=p.map(e=>e.payload),g=p.map(e=>e.createdAt),_=p.map(e=>e.sessionId??null),y=p.map(e=>e.id),x=f.length>0?f[0].id:r;n&&G(t,n,d.hasMore),v.prepend(t,h,g,{oldestId:x,hasMoreOlder:n?v.hasMoreOlderFor(t):d.hasMore,sessionIds:_,eventIds:y});for(let e of m){let n=e.payload;typeof n.content==`string`&&b.addActivityItem(t,{id:e.id,type:`text`,content:n.content,timestamp:e.createdAt,sessionId:e.sessionId??void 0,meta:{sender:n.sender??`user`}})}if(await e(),i){let e=i.getScroll().verticalSize-a,t=Math.max(o+e,Ne+50);i.setScrollPosition(`vertical`,t,0)}}catch(e){console.error(`[ActivityFeed] failed to load older events:`,e)}finally{let e=Date.now()-i,t=Math.max(0,Pe-e);await new Promise(e=>setTimeout(e,t+Fe)),z.value=!1}}async function K(t=0){await e();let n=L.value;if(!n)return;let r=n.getScroll();n.setScrollPosition(`vertical`,r.verticalSize,t)}function se(e){let t=L.value;t&&t.setScrollPosition(`vertical`,Math.max(0,e),250)}let ce=a([]),q=a(null);function J(){let e=F.value,t=ce.value,n=[];if(t.length===e.length){for(let r=0;r<e.length;r++){if(e[r].speaker!==`user`)continue;let i=t[r]?.$el;i&&n.push(i)}if(n.length>0)return n}let r=q.value?.parentElement;if(r){let e=r.querySelectorAll(`.turn-card--user`);for(let t of e)n.push(t)}return n}function le(){let e=L.value;if(!e)return null;let t=q.value;if(!t)return null;let n=e.getScroll().verticalPosition,r=t.getBoundingClientRect().top,i=null;for(let e of J()){let t=e.getBoundingClientRect().top-r;if(t<n-40)i=t;else break}return i}async function ue(){let t=L.value;if(!t)return;let n=le();if(n===null)for(let t=0;t<15&&W();t++){for(;z.value;)await new Promise(e=>setTimeout(e,50));if(await oe(),await e(),n=le(),n!==null)break}n!==null&&t.setScrollPosition(`vertical`,Math.max(0,n-12),250)}async function Y(){B=!1,await e(),await K(0),requestAnimationFrame(()=>{requestAnimationFrame(()=>{B=!0})})}let X=p(()=>{let e=v.sessionIdsFor(g.workspaceId);if(!S.value)return e.length;let t=0;for(let n of e)O(n)&&t++;return t}),de=p(()=>v.eventsFor(g.workspaceId).length);async function fe(){V.value=!0;let e=Date.now();await new Promise(e=>setTimeout(e,Ie));let t=e+5e3;for(;de.value===0&&Date.now()<t;)await new Promise(e=>setTimeout(e,50));V.value=!1}n(V,async e=>{!e&&X.value>0&&await Y(),!e&&X.value===0&&S.value&&$()}),i(()=>{fe(),X.value>0&&Y(),S.value&&$()});let Z=!1;n(X,async(e,t)=>{if(!Z&&e>0){Z=!0,await Y();return}e>t&&R.value&&!z.value&&await K(180)}),n(()=>g.workspaceId,()=>{R.value=!0,Z=!1,B=!1,fe(),X.value>0&&Y()}),n(()=>b.selectedSessionId,async()=>{R.value=!0,B=!1,await Y(),$()});let Q=new Set;async function $(){let t=S.value;if(!t||X.value>0)return;let n=U(g.workspaceId,t);if(!Q.has(n)){Q.add(n);try{let n=await fetch(`/api/workspaces/${g.workspaceId}/events?session=${encodeURIComponent(t)}&limit=500`);if(!n.ok)return;let r=await n.json(),i=r.events??[];if(i.length===0)return;let a=i.filter(e=>e.type===`agent:event`&&e.workspaceId===g.workspaceId),o=i.filter(e=>e.type===`user:message`&&e.workspaceId===g.workspaceId),s=a.map(e=>e.payload),c=a.map(e=>e.createdAt),l=a.map(e=>e.sessionId??null),u=a.map(e=>e.id);G(g.workspaceId,t,r.hasMore),s.length>0&&v.prepend(g.workspaceId,s,c,{oldestId:i[0].id,hasMoreOlder:v.hasMoreOlderFor(g.workspaceId),sessionIds:l,eventIds:u});for(let e of o){let t=e.payload;typeof t.content==`string`&&b.addActivityItem(g.workspaceId,{id:e.id,type:`text`,content:t.content,timestamp:e.createdAt,sessionId:e.sessionId??void 0,meta:{sender:t.sender??`user`}})}await e(),await K(0)}catch(e){console.error(`[ActivityFeed] fetchSessionIfMissing failed:`,e),Q.delete(n)}}}n(p(()=>M.value.filter(e=>e.sender!==`system-prompt`).length),async(e,t)=>{e>t&&(R.value=!0,await K(180))});async function pe(){R.value=!0,await K(250)}return(e,n)=>V.value?(t(),m(`div`,Ee,[d(j,{size:`40px`,color:`indigo-4`})])):(t(),m(`div`,De,[d(ee,{ref_key:`scrollRef`,ref:L,class:`activity-feed-scroll`,onScroll:ie},{default:o(()=>[_(`div`,{ref_key:`contentOriginRef`,ref:q,class:`content-origin-marker`},null,512),z.value?(t(),m(`div`,Oe,[d(x,{size:`sm`}),f(` `+c(e.$t(`activity.loading_older`)),1)])):u(``,!0),_(`div`,ke,[(t(!0),m(h,null,r(F.value,(e,n)=>(t(),l(Te,{key:n,ref_for:!0,ref_key:`turnRefs`,ref:ce,turn:e,onScrollTo:se},null,8,[`turn`]))),128))]),I.value.length?(t(),m(`div`,Ae,[d(N,{label:e.$t(`activity.raw_lines`,{n:I.value.length}),dense:``},{default:o(()=>[(t(!0),m(h,null,r(I.value,(e,n)=>(t(),m(`div`,{key:n,class:`text-caption text-grey q-pa-xs`},c(e),1))),128))]),_:1},8,[`label`])])):u(``,!0)]),_:1},512),_(`div`,je,[R.value?u(``,!0):(t(),l(T,{key:0,round:``,dense:``,unelevated:``,color:`grey-9`,"text-color":`grey-3`,icon:`arrow_downward`,size:`sm`,class:`activity-feed-nav-btn`,title:e.$t(`activity.scroll_to_bottom`),onClick:pe},null,8,[`title`])),d(T,{round:``,dense:``,unelevated:``,color:`grey-9`,"text-color":`grey-3`,icon:`arrow_upward`,size:`sm`,class:`activity-feed-nav-btn`,title:e.$t(`activity.prev_user_message`),onClick:ue},null,8,[`title`])])]))}}),[[`__scopeId`,`data-v-890bdcbd`]]);export{Le as default};
@@ -1 +1 @@
1
- import{k as e,m as t}from"./QIcon-B0-pH3Qs.js";import{_ as n,g as r}from"./settings-Dbx1_ksA.js";function i(e){if(e===!1)return 0;if(e===!0||e===void 0)return 1;let t=parseInt(e,10);return isNaN(t)?0:t}var a=e({name:`close-popup`,beforeMount(e,{value:a}){let o={depth:i(a),handler(t){o.depth!==0&&setTimeout(()=>{let i=n(e);i!==void 0&&r(i,t,o.depth)})},handlerKey(e){t(e,13)===!0&&o.handler(e)}};e.__qclosepopup=o,e.addEventListener(`click`,o.handler),e.addEventListener(`keyup`,o.handlerKey)},updated(e,{value:t,oldValue:n}){t!==n&&(e.__qclosepopup.depth=i(t))},beforeUnmount(e){let t=e.__qclosepopup;e.removeEventListener(`click`,t.handler),e.removeEventListener(`keyup`,t.handlerKey),delete e.__qclosepopup}});export{a as t};
1
+ import{k as e,m as t}from"./QIcon-B0-pH3Qs.js";import{_ as n,v as r}from"./settings-lT4GB-uB.js";function i(e){if(e===!1)return 0;if(e===!0||e===void 0)return 1;let t=parseInt(e,10);return isNaN(t)?0:t}var a=e({name:`close-popup`,beforeMount(e,{value:a}){let o={depth:i(a),handler(t){o.depth!==0&&setTimeout(()=>{let i=r(e);i!==void 0&&n(i,t,o.depth)})},handlerKey(e){t(e,13)===!0&&o.handler(e)}};e.__qclosepopup=o,e.addEventListener(`click`,o.handler),e.addEventListener(`keyup`,o.handlerKey)},updated(e,{value:t,oldValue:n}){t!==n&&(e.__qclosepopup.depth=i(t))},beforeUnmount(e){let t=e.__qclosepopup;e.removeEventListener(`click`,t.handler),e.removeEventListener(`keyup`,t.handlerKey),delete e.__qclosepopup}});export{a as t};