@loicngr/kobo 1.6.12 → 1.6.14

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 (87) hide show
  1. package/README.md +11 -6
  2. package/dist/mcp-server/kobo-tasks-handlers.js +2 -2
  3. package/dist/server/db/migrations.js +24 -0
  4. package/dist/server/db/schema.js +10 -0
  5. package/dist/server/index.js +27 -4
  6. package/dist/server/routes/documents.js +2 -2
  7. package/dist/server/routes/git.js +21 -0
  8. package/dist/server/routes/health.js +2 -2
  9. package/dist/server/routes/images.js +3 -3
  10. package/dist/server/routes/usage.js +18 -0
  11. package/dist/server/routes/workspaces.js +209 -81
  12. package/dist/server/services/agent/engines/claude-code/args-builder.js +2 -0
  13. package/dist/server/services/agent/orchestrator.js +1 -1
  14. package/dist/server/services/auto-loop-service.js +29 -4
  15. package/dist/server/services/dev-server-service.js +2 -5
  16. package/dist/server/services/settings-service.js +55 -2
  17. package/dist/server/services/usage/db.js +29 -0
  18. package/dist/server/services/usage/index.js +2 -0
  19. package/dist/server/services/usage/poller.js +52 -0
  20. package/dist/server/services/usage/providers/claude-code.js +93 -0
  21. package/dist/server/services/usage/types.js +1 -0
  22. package/dist/server/services/wakeup-service.js +2 -2
  23. package/dist/server/services/websocket-service.js +14 -0
  24. package/dist/server/services/workspace-service.js +28 -3
  25. package/dist/server/services/worktree-service.js +50 -0
  26. package/dist/server/utils/mcp-client.js +7 -0
  27. package/dist/shared/auto-loop-prompts.js +58 -8
  28. package/package.json +1 -1
  29. package/src/client/dist/spa/assets/ActivityFeed-Be0QQryJ.css +1 -0
  30. package/src/client/dist/spa/assets/{ActivityFeed-6Xg7qNfy.js → ActivityFeed-BtIOkIy6.js} +3 -3
  31. package/src/client/dist/spa/assets/CreatePage-D6Q3nxkX.js +2 -0
  32. package/src/client/dist/spa/assets/CreatePage-DJbZH8wp.css +1 -0
  33. package/src/client/dist/spa/assets/DiffViewer-1s165rFm.css +1 -0
  34. package/src/client/dist/spa/assets/{DiffViewer-T111s7BH.js → DiffViewer-D5u9p7il.js} +2 -2
  35. package/src/client/dist/spa/assets/{HealthPage-1VakQ0x_.js → HealthPage-Cr7aAUy6.js} +1 -1
  36. package/src/client/dist/spa/assets/{MainLayout-w7DoW3yz.js → MainLayout-C3TUaYvQ.js} +17 -17
  37. package/src/client/dist/spa/assets/MainLayout-CBnSwSfy.css +1 -0
  38. package/src/client/dist/spa/assets/{SearchPage-CcldJX8i.js → SearchPage-CavRaij6.js} +1 -1
  39. package/src/client/dist/spa/assets/SearchPage-cVwt0DaQ.css +1 -0
  40. package/src/client/dist/spa/assets/SettingsPage-B8DhSZw7.css +1 -0
  41. package/src/client/dist/spa/assets/SettingsPage-C13T1l_t.js +1 -0
  42. package/src/client/dist/spa/assets/WorkspacePage-BEqEuPrb.js +4 -0
  43. package/src/client/dist/spa/assets/WorkspacePage-k2pgeRoy.css +1 -0
  44. package/src/client/dist/spa/assets/{build-path-tree-DbuI5yRz.js → build-path-tree-BeAS10oa.js} +1 -1
  45. package/src/client/dist/spa/assets/{cssMode-DhpmJAZc.js → cssMode-wNaxOrgG.js} +1 -1
  46. package/src/client/dist/spa/assets/{documents-fVD9RJth.js → documents-Cw05r3zs.js} +1 -1
  47. package/src/client/dist/spa/assets/{editor.api-DCvwHsju.js → editor.api-CcDntllS.js} +1 -1
  48. package/src/client/dist/spa/assets/{editor.main-CRtPC0iL.js → editor.main-Chu4hc0J.js} +3 -3
  49. package/src/client/dist/spa/assets/{expand-template-BIra7NIw.js → expand-template-CcQus77v.js} +1 -1
  50. package/src/client/dist/spa/assets/expand-template-D2yUa54D.css +1 -0
  51. package/src/client/dist/spa/assets/{freemarker2-C9UOErQw.js → freemarker2-CO_b202E.js} +1 -1
  52. package/src/client/dist/spa/assets/{handlebars-DmZ2-ZcJ.js → handlebars-CJnTWNLs.js} +1 -1
  53. package/src/client/dist/spa/assets/{html-ButyxlXG.js → html-DeArYseI.js} +1 -1
  54. package/src/client/dist/spa/assets/{htmlMode-C-defy1b.js → htmlMode-BnNgEgdx.js} +1 -1
  55. package/src/client/dist/spa/assets/i18n-CuT4b7ns.js +1 -0
  56. package/src/client/dist/spa/assets/index-CZA4BFN5.js +2 -0
  57. package/src/client/dist/spa/assets/{javascript-B6zVweIF.js → javascript-C0pxfNu4.js} +1 -1
  58. package/src/client/dist/spa/assets/{jsonMode-CttMw-EY.js → jsonMode-ety87201.js} +1 -1
  59. package/src/client/dist/spa/assets/kobo-commands-Cpl4IFon.js +11 -0
  60. package/src/client/dist/spa/assets/{liquid-tGpdE1YW.js → liquid-kanevKvC.js} +1 -1
  61. package/src/client/dist/spa/assets/{mdx-Cy5mpQoy.js → mdx-DkmtbRD7.js} +1 -1
  62. package/src/client/dist/spa/assets/{models-DdAQDnqk.js → models-CPFeBEQS.js} +1 -1
  63. package/src/client/dist/spa/assets/{monaco.contribution-DtdkkTgR.js → monaco.contribution-DsZsua59.js} +2 -2
  64. package/src/client/dist/spa/assets/{python-hLOxMbm9.js → python-DrxH1xl7.js} +1 -1
  65. package/src/client/dist/spa/assets/{razor-tqHFRROa.js → razor-CU4khv8N.js} +1 -1
  66. package/src/client/dist/spa/assets/stats-C3n1k51k.js +1 -0
  67. package/src/client/dist/spa/assets/{tsMode-MJKgZYsJ.js → tsMode-CQ5yxoz_.js} +1 -1
  68. package/src/client/dist/spa/assets/{typescript-CWTqB5lb.js → typescript-CSwKmP7l.js} +1 -1
  69. package/src/client/dist/spa/assets/{xml-ByDBLBVa.js → xml-9bnWANPJ.js} +1 -1
  70. package/src/client/dist/spa/assets/{yaml-BiTCWZ38.js → yaml-sUtDJGxo.js} +1 -1
  71. package/src/client/dist/spa/index.html +1 -1
  72. package/src/mcp-server/kobo-tasks-handlers.ts +3 -2
  73. package/src/client/dist/spa/assets/ActivityFeed-BHDJ5lUn.css +0 -1
  74. package/src/client/dist/spa/assets/CreatePage-BQu7mQjm.css +0 -1
  75. package/src/client/dist/spa/assets/CreatePage-CrRGDs5V.js +0 -2
  76. package/src/client/dist/spa/assets/DiffViewer-BC81-2me.css +0 -1
  77. package/src/client/dist/spa/assets/MainLayout-Ci-CETJi.css +0 -1
  78. package/src/client/dist/spa/assets/SearchPage-DWglAeQv.css +0 -1
  79. package/src/client/dist/spa/assets/SettingsPage-CLMCHMpz.css +0 -1
  80. package/src/client/dist/spa/assets/SettingsPage-CMyeaz63.js +0 -1
  81. package/src/client/dist/spa/assets/WorkspacePage-Bw9xhTDR.js +0 -4
  82. package/src/client/dist/spa/assets/WorkspacePage-_1mty_a4.css +0 -1
  83. package/src/client/dist/spa/assets/expand-template-hbnn7St6.css +0 -1
  84. package/src/client/dist/spa/assets/i18n-C8aJvuyS.js +0 -1
  85. package/src/client/dist/spa/assets/index-DAbX631s.js +0 -2
  86. package/src/client/dist/spa/assets/kobo-commands-CD7ERFxp.js +0 -10
  87. package/src/client/dist/spa/assets/rate-limit-labels-BaD9dQtl.js +0 -1
package/README.md CHANGED
@@ -17,8 +17,8 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
17
17
  - **Task & acceptance criteria tracking** — the agent reports progress through a dedicated MCP server (`kobo-tasks`) that reads and updates tasks directly from the SQLite database
18
18
  - **Documents panel** — tree view in the right drawer that surfaces every AI-generated markdown file under `docs/plans/`, `docs/superpowers/`, and `.ai/thoughts/`. Paths mentioned in chat messages are auto-detected against the catalogue and become one-click deep-links into the panel
19
19
  - **Git panel with inline diff viewer** — Monaco-powered side-by-side / inline diff of the working branch against its source, with file tree (same q-tree as Documents), inline rebase/merge conflict resolution, and a clean action bar: `Sync` split-button (pull / rebase / merge), `Push`, `Diff`, `Create PR`
20
- - **Notion integration** — pull workspace missions straight from Notion pages, extract markdown, and use it as the source of truth for acceptance criteria
21
- - **Sentry integration** — paste a Sentry issue URL to spin up a dedicated "fix workspace" with the stacktrace, tags, and offending spans written to `.ai/thoughts/SENTRY-<id>.md`; the agent is primed with a TDD fix workflow and has access to the Sentry MCP tools for deeper digging
20
+ - **Notion integration** — pull workspace missions straight from Notion pages, extract markdown, and use it as the source of truth for acceptance criteria. Right-click a Notion-sourced workspace to jump back to its source page in one click
21
+ - **Sentry integration** — paste a Sentry issue URL to spin up a dedicated "fix workspace" with the stacktrace, tags, and offending spans written to `.ai/thoughts/SENTRY-<id>.md`; the agent is primed with a TDD fix workflow and has access to the Sentry MCP tools for deeper digging. Right-click reopens the Sentry issue in Sentry's UI
22
22
  - **Per-workspace dev servers** — start/stop Docker or Node dev servers scoped to each branch, with log streaming
23
23
  - **Conventional-commit enforcement** — project-level git conventions are written to `.ai/.git-conventions.md` inside every workspace so Claude follows them during commits
24
24
  - **Pull request automation** — one-click `push`, `pull`, `open-pr`, and "change PR base" endpoints integrate with the GitHub CLI, using a configurable prompt template
@@ -26,11 +26,12 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
26
26
  - **Prompt templates** — personal library of reusable prompts with variable substitution (`{working_branch}`, `{commit_count}`, etc.), insertable from the chat input via `/` autocomplete; editable in Settings > Templates
27
27
  - **Favorites and tags** — pin workspaces to the top via right-click favourite, organise with per-workspace tags filterable from the sidebar; a global tag catalogue keeps colours consistent across workspaces
28
28
  - **Health panel + config export/import** — inspect backend health (agent sessions, migration state, dev servers, DB size) and roundtrip your Kōbō config (settings, templates, skills) between machines via JSON
29
- - **Usage tracking** — rolling input/output token counts and cost estimates per workspace, aggregated across sessions and live-updated from `usage` events
29
+ - **Account-level quota panel** — a colored mini-bar badge in the chat footer shows the current Claude Code 5-hour and 7-day usage, fed by a backend service that polls Anthropic's OAuth usage endpoint every 60 seconds. Click to open a popover with full bars, reset times, a "Refresh now" button, and a one-click jump to the Stats tab. Pluggable per-provider (Codex-ready), persisted in SQLite so the badge is populated on cold start, and account-level so it's the same across workspaces sharing the same engine
30
30
  - **Resizable right drawer** — drag-to-resize horizontally and vertically, with tab state and split ratio persisted to localStorage
31
31
  - **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
32
32
  - **Archive instead of delete** — soft-remove workspaces without losing the worktree, branches, or history; unarchive restores the exact pre-archive state
33
- - **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
33
+ - **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
34
+ - **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
34
35
  - **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
35
36
  - **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
36
37
 
@@ -99,7 +100,7 @@ npm start # runs the compiled server
99
100
  ### Test & lint
100
101
 
101
102
  ```bash
102
- npm test # backend vitest suite (740+ tests)
103
+ npm test # backend vitest suite (950+ tests)
103
104
  npm run test:client # client vitest suite (Pinia stores + pure utils, 85+ tests)
104
105
  npm run test:all # backend + client suites
105
106
  npm run lint # biome check (lint + format verification)
@@ -226,8 +227,9 @@ src/
226
227
  │ │ │ ├── event-router.ts # maps engine AgentEvent stream to WS emit + DB side-effects
227
228
  │ │ │ └── engines/claude-code/ # spawn + NDJSON stream-parser + args-builder + mcp-config + capabilities
228
229
  │ │ ├── content-migration-service.ts # legacy ws_events → normalised AgentEvent rows, with DB backup
230
+ │ │ ├── usage/ # pluggable quota provider, 60s poller, persistence, WS broadcast
229
231
  │ │ └── … # workspace, dev-server, ws, notion, sentry, settings, pr-template
230
- │ ├── routes/ # Hono handlers (workspaces, engines, migration, templates, …)
232
+ │ ├── routes/ # Hono handlers (workspaces, engines, migration, templates, usage, …)
231
233
  │ └── utils/ # git-ops, process-tracker, paths
232
234
  ├── shared/ # modules shared by backend and frontend (e.g. model catalogue)
233
235
  ├── client/ # Vue 3 + Quasar SPA
@@ -253,6 +255,7 @@ See [`AGENTS.md`](./AGENTS.md) for a deeper dive into conventions, data model, W
253
255
  | `tasks` | workspace sub-items — tasks and acceptance criteria |
254
256
  | `agent_sessions` | agent runs — pid, `engine_session_id`, lifecycle |
255
257
  | `ws_events` | persisted WebSocket events (chat history, `agent:event` stream, user messages) for replay on reconnect |
258
+ | `usage_snapshots` | latest quota snapshot per provider (one row per `provider_id`) — populated by the 60s polling loop, used for cold-start hydration of the chat-footer quota badge |
256
259
 
257
260
  ## MCP server
258
261
 
@@ -271,6 +274,8 @@ Kōbō reads settings from `~/.config/kobo/settings.json` (or falls back to defa
271
274
  - `prPromptTemplate` — template rendered when opening a PR via the `/open-pr` endpoint; supports `{{pr_number}}`, `{{pr_url}}`, `{{branch_name}}`, `{{diff_stats}}`, `{{commits}}`, etc.
272
275
  - `gitConventions` — markdown-formatted git conventions written to `.ai/.git-conventions.md` in every workspace so the agent follows them when committing
273
276
  - `devServer` — per-project `startCommand` / `stopCommand` for launching workspace-scoped dev servers
277
+ - `e2e` — per-project E2E test framework (`cypress`, `playwright`, `jest`, `vitest`, `other`, or none) plus an optional skill name and prompt; consumed by the auto-loop grooming step to inject `[E2E] ` test sub-tasks alongside parent tasks
278
+ - `finalization` — per-project free-form prompt that runs as the very last auto-loop iteration. The grooming step injects a `[FINAL]`-prefixed task at the end of the list whose iteration block is replaced by this prompt. Default content asks the agent to run linters, type-checkers, and tests. Empty string disables the feature.
274
279
 
275
280
  ## Contributing
276
281
 
@@ -152,7 +152,7 @@ export function getSettingsHandler(settingsPath, projectPath) {
152
152
  /** Fetch workspace metadata from the database, computing the worktree path from project_path and working_branch. */
153
153
  export function getWorkspaceInfoHandler(db, workspaceId) {
154
154
  const row = db
155
- .prepare('SELECT id, name, project_path, source_branch, working_branch, status, notion_url, notion_page_id, model, dev_server_status, has_unread, auto_loop, auto_loop_ready, created_at, updated_at FROM workspaces WHERE id = ?')
155
+ .prepare('SELECT id, name, project_path, source_branch, working_branch, worktree_path, status, notion_url, notion_page_id, model, dev_server_status, has_unread, auto_loop, auto_loop_ready, created_at, updated_at FROM workspaces WHERE id = ?')
156
156
  .get(workspaceId);
157
157
  if (!row) {
158
158
  throw new Error(`Workspace '${workspaceId}' not found`);
@@ -163,7 +163,7 @@ export function getWorkspaceInfoHandler(db, workspaceId) {
163
163
  projectPath: row.project_path,
164
164
  sourceBranch: row.source_branch,
165
165
  workingBranch: row.working_branch,
166
- worktreePath: path.join(row.project_path, '.worktrees', row.working_branch),
166
+ worktreePath: row.worktree_path ?? path.join(row.project_path, '.worktrees', row.working_branch),
167
167
  status: row.status,
168
168
  model: row.model,
169
169
  notionUrl: row.notion_url,
@@ -130,6 +130,30 @@ export const migrations = [
130
130
  db.prepare('ALTER TABLE workspaces ADD COLUMN sentry_url TEXT').run();
131
131
  },
132
132
  },
133
+ {
134
+ version: 15,
135
+ name: 'add-workspace-worktree-path',
136
+ migrate: (db) => {
137
+ db.transaction(() => {
138
+ db.prepare('ALTER TABLE workspaces ADD COLUMN worktree_path TEXT').run();
139
+ db.prepare('ALTER TABLE workspaces ADD COLUMN worktree_owned INTEGER NOT NULL DEFAULT 1').run();
140
+ db.prepare("UPDATE workspaces SET worktree_path = project_path || '/.worktrees/' || working_branch WHERE worktree_path IS NULL").run();
141
+ })();
142
+ },
143
+ },
144
+ {
145
+ version: 16,
146
+ name: 'add-usage-snapshots-table',
147
+ migrate: (db) => {
148
+ db.prepare(`CREATE TABLE IF NOT EXISTS usage_snapshots (
149
+ provider_id TEXT PRIMARY KEY,
150
+ status TEXT NOT NULL,
151
+ error_message TEXT,
152
+ buckets_json TEXT NOT NULL,
153
+ fetched_at TEXT NOT NULL
154
+ )`).run();
155
+ },
156
+ },
133
157
  ];
134
158
  /** Current schema version — always equals the highest migration version. */
135
159
  export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
@@ -11,6 +11,8 @@ export function initSchema(db) {
11
11
  notion_url TEXT,
12
12
  notion_page_id TEXT,
13
13
  sentry_url TEXT,
14
+ worktree_path TEXT,
15
+ worktree_owned INTEGER NOT NULL DEFAULT 1,
14
16
  model TEXT NOT NULL DEFAULT 'claude-opus-4-7',
15
17
  reasoning_effort TEXT NOT NULL DEFAULT 'auto',
16
18
  permission_mode TEXT NOT NULL DEFAULT 'auto-accept',
@@ -67,6 +69,14 @@ export function initSchema(db) {
67
69
  created_at TEXT NOT NULL
68
70
  );
69
71
 
72
+ CREATE TABLE IF NOT EXISTS usage_snapshots (
73
+ provider_id TEXT PRIMARY KEY,
74
+ status TEXT NOT NULL,
75
+ error_message TEXT,
76
+ buckets_json TEXT NOT NULL,
77
+ fetched_at TEXT NOT NULL
78
+ );
79
+
70
80
  CREATE INDEX IF NOT EXISTS idx_tasks_workspace_id ON tasks(workspace_id);
71
81
  CREATE INDEX IF NOT EXISTS idx_agent_sessions_workspace_id ON agent_sessions(workspace_id);
72
82
  CREATE INDEX IF NOT EXISTS idx_ws_events_workspace_id ON ws_events(workspace_id);
@@ -19,6 +19,7 @@ import searchRouter from './routes/search.js';
19
19
  import sentryRouter from './routes/sentry.js';
20
20
  import settingsRouter from './routes/settings.js';
21
21
  import templatesRouter from './routes/templates.js';
22
+ import usageRoutes from './routes/usage.js';
22
23
  import workspacesRouter from './routes/workspaces.js';
23
24
  import { getAvailableSkills, reconcileOrphanSessions, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopWatchdog, } from './services/agent/orchestrator.js';
24
25
  import * as autoLoopService from './services/auto-loop-service.js';
@@ -27,8 +28,9 @@ import { createDailyDbBackupIfNeeded } from './services/db-backup-service.js';
27
28
  import { startDevServer, stopDevServer } from './services/dev-server-service.js';
28
29
  import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js';
29
30
  import { createTerminal, destroyAllTerminals, getTerminal } from './services/terminal-service.js';
31
+ import { startUsagePoller, stopUsagePoller } from './services/usage/index.js';
30
32
  import * as wakeupService from './services/wakeup-service.js';
31
- import { emit, handleConnection, setMessageHandler } from './services/websocket-service.js';
33
+ import { emit, emitEphemeral, handleConnection, setMessageHandler } from './services/websocket-service.js';
32
34
  import { getActiveSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
33
35
  import { getClientSpaPath, getDbPath, getKoboHome, getPackageVersion } from './utils/paths.js';
34
36
  import { initProcessCleanup, killAll as killAllTrackedProcesses } from './utils/process-tracker.js';
@@ -62,6 +64,7 @@ startWatchdog();
62
64
  wakeupService.rehydrate();
63
65
  autoLoopService.rehydrate();
64
66
  startPrWatcher();
67
+ startUsagePoller();
65
68
  // Create Hono app
66
69
  const app = new Hono();
67
70
  // Health check (root / is handled by the SPA catch-all below)
@@ -75,6 +78,7 @@ app.route('/api/git', gitRouter);
75
78
  app.route('/api/settings', settingsRouter);
76
79
  app.route('/api/dev-server', devServerRouter);
77
80
  app.route('/api/templates', templatesRouter);
81
+ app.route('/api/usage', usageRoutes);
78
82
  app.route('/api/workspaces', documentsRouter);
79
83
  app.route('/api/search', searchRouter);
80
84
  app.route('/api/health', healthRouter);
@@ -200,7 +204,7 @@ terminalWss.on('connection', (ws, workspaceId) => {
200
204
  ws.send(JSON.stringify({ type: 'error', message: 'Workspace is archived' }));
201
205
  return;
202
206
  }
203
- const cwd = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
207
+ const cwd = workspace.worktreePath;
204
208
  try {
205
209
  currentPty = createTerminal(workspaceId, cwd);
206
210
  }
@@ -233,6 +237,19 @@ terminalWss.on('connection', (ws, workspaceId) => {
233
237
  setMessageHandler((type, payload) => {
234
238
  const p = payload;
235
239
  if (type === 'chat:message' && p?.workspaceId && p?.content) {
240
+ // Auto-loop owns the agent's turns — a stray user message would land in
241
+ // the middle of an iteration (or in a freshly spawned next one) and break
242
+ // the deterministic loop contract. Reject server-side so direct WS clients
243
+ // can't bypass the frontend's input lock. Grooming phase (ready=0) is
244
+ // skipped — the user must stay free to answer the agent's questions.
245
+ const autoLoopStatus = autoLoopService.getStatus(p.workspaceId);
246
+ if (autoLoopStatus.auto_loop && autoLoopStatus.auto_loop_ready) {
247
+ emitEphemeral(p.workspaceId, 'chat:rejected', {
248
+ reason: 'auto-loop-active',
249
+ message: 'Auto-loop is running — disable it before sending a message',
250
+ });
251
+ return;
252
+ }
236
253
  // Prefer the session explicitly selected by the client (sessionId hint),
237
254
  // falling back to the running/most-recent non-idle session so idle sessions
238
255
  // never steal the tagging.
@@ -257,7 +274,7 @@ setMessageHandler((type, payload) => {
257
274
  try {
258
275
  const workspace = getWorkspace(p.workspaceId);
259
276
  if (workspace) {
260
- const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
277
+ const worktreePath = workspace.worktreePath;
261
278
  // Plan mode blocks MCP tools — when the caller knows the message
262
279
  // requires them (e.g. grooming), it sets the override to bypass the
263
280
  // workspace default for this spawn only.
@@ -278,7 +295,7 @@ setMessageHandler((type, payload) => {
278
295
  console.error(`[ws] workspace:start — workspace '${p.workspaceId}' not found`);
279
296
  return;
280
297
  }
281
- const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
298
+ const worktreePath = workspace.worktreePath;
282
299
  const prompt = p.prompt ?? 'Continue the previous task where you left off.';
283
300
  startAgent(p.workspaceId, worktreePath, prompt, workspace.model, false, workspace.permissionMode, undefined, workspace.reasoningEffort);
284
301
  }
@@ -368,6 +385,12 @@ function gracefulShutdown(signal) {
368
385
  catch {
369
386
  // Best-effort
370
387
  }
388
+ try {
389
+ stopUsagePoller();
390
+ }
391
+ catch {
392
+ // Best-effort
393
+ }
371
394
  // Kill all tracked child processes (agents, dev servers)
372
395
  try {
373
396
  killAllTrackedProcesses();
@@ -59,7 +59,7 @@ app.get('/:id/documents', (c) => {
59
59
  if (!workspace) {
60
60
  return c.json({ error: `Workspace '${id}' not found` }, 404);
61
61
  }
62
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
62
+ const worktreePath = workspace.worktreePath;
63
63
  const documents = [];
64
64
  for (const dir of DOCUMENT_DIRS) {
65
65
  const absDir = path.join(worktreePath, dir);
@@ -97,7 +97,7 @@ app.get('/:id/document', (c) => {
97
97
  if (!normalized.endsWith(MD_EXT)) {
98
98
  return c.json({ error: 'Only .md files can be read' }, 400);
99
99
  }
100
- const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
100
+ const worktreePath = workspace.worktreePath;
101
101
  const absPath = path.join(worktreePath, normalized);
102
102
  if (!existsSync(absPath)) {
103
103
  return c.json({ error: `Document not found: ${normalized}` }, 404);
@@ -1,4 +1,6 @@
1
1
  import { Hono } from 'hono';
2
+ import { getDb } from '../db/index.js';
3
+ import { listOrphanWorktrees } from '../services/worktree-service.js';
2
4
  import { listBranches, listRemoteBranches } from '../utils/git-ops.js';
3
5
  /** Hono sub-router for git-related endpoints (branch listing). */
4
6
  const app = new Hono();
@@ -18,4 +20,23 @@ app.get('/branches', (c) => {
18
20
  return c.json({ error: message }, 500);
19
21
  }
20
22
  });
23
+ // GET /api/git/orphan-worktrees?projectPath=<path> — list worktrees of a
24
+ // project that are NOT attached to any Kōbō workspace yet.
25
+ app.get('/orphan-worktrees', (c) => {
26
+ try {
27
+ const projectPath = c.req.query('projectPath');
28
+ if (!projectPath) {
29
+ return c.json({ error: 'Missing required query parameter: projectPath' }, 400);
30
+ }
31
+ const db = getDb();
32
+ const rows = db.prepare('SELECT worktree_path FROM workspaces WHERE project_path = ?').all(projectPath);
33
+ const attachedPaths = new Set(rows.map((r) => r.worktree_path).filter((p) => !!p));
34
+ const orphans = listOrphanWorktrees(projectPath, attachedPaths);
35
+ return c.json(orphans);
36
+ }
37
+ catch (err) {
38
+ const message = err instanceof Error ? err.message : String(err);
39
+ return c.json({ error: message }, 500);
40
+ }
41
+ });
21
42
  export default app;
@@ -45,13 +45,13 @@ app.get('/report', (c) => {
45
45
  const dbSchemaVersion = row?.v ?? 0;
46
46
  // Workspaces + worktrees on-disk check
47
47
  const workspaces = db
48
- .prepare('SELECT id, name, project_path, working_branch, archived_at FROM workspaces')
48
+ .prepare('SELECT id, name, project_path, working_branch, worktree_path, archived_at FROM workspaces')
49
49
  .all();
50
50
  const worktreesMissing = [];
51
51
  for (const ws of workspaces) {
52
52
  if (ws.archived_at)
53
53
  continue;
54
- const wtPath = path.join(ws.project_path, '.worktrees', ws.working_branch);
54
+ const wtPath = ws.worktree_path ?? path.join(ws.project_path, '.worktrees', 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
  }
@@ -38,7 +38,7 @@ app.post('/:id/images', async (c) => {
38
38
  if (buffer.length > MAX_FILE_SIZE) {
39
39
  return c.json({ error: `File too large (${(buffer.length / 1024 / 1024).toFixed(1)} MB). Max: 10 MB` }, 400);
40
40
  }
41
- const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
41
+ const worktreePath = workspace.worktreePath;
42
42
  const result = await imageService.saveImage(worktreePath, buffer, file.name);
43
43
  return c.json({ uid: result.uid, path: result.relativePath }, 201);
44
44
  }
@@ -70,7 +70,7 @@ app.get('/:id/images/file', async (c) => {
70
70
  if (!/^(\.ai\/images\/|images\/)[^/]/.test(requested) || requested.includes('..')) {
71
71
  return c.json({ error: 'Invalid or disallowed image path' }, 400);
72
72
  }
73
- const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
73
+ const worktreePath = workspace.worktreePath;
74
74
  const imagesRoot = path.resolve(worktreePath, '.ai/images');
75
75
  const fullPath = path.resolve(worktreePath, requested);
76
76
  // Containment check: fullPath must be a descendant of imagesRoot. `+ path.sep`
@@ -104,7 +104,7 @@ app.delete('/:id/images/:uid', async (c) => {
104
104
  if (!workspace) {
105
105
  return c.json({ error: `Workspace '${id}' not found` }, 404);
106
106
  }
107
- const worktreePath = `${workspace.projectPath}/.worktrees/${workspace.workingBranch}`;
107
+ const worktreePath = workspace.worktreePath;
108
108
  await imageService.deleteImage(worktreePath, uid);
109
109
  return c.body(null, 204);
110
110
  }
@@ -0,0 +1,18 @@
1
+ import { Hono } from 'hono';
2
+ import { refreshNow } from '../services/usage/poller.js';
3
+ const app = new Hono();
4
+ app.post('/:providerId/refresh', async (c) => {
5
+ const providerId = c.req.param('providerId');
6
+ try {
7
+ const snap = await refreshNow(providerId);
8
+ if (!snap) {
9
+ return c.json({ error: `Unknown provider '${providerId}'` }, 404);
10
+ }
11
+ return c.json({ snapshot: snap }, 200);
12
+ }
13
+ catch (err) {
14
+ const message = err instanceof Error ? err.message : String(err);
15
+ return c.json({ error: message }, 500);
16
+ }
17
+ });
18
+ export default app;