@loicngr/kobo 1.11.2 → 1.11.4

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 (79) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +5 -5
  3. package/dist/server/db/migrations.js +20 -0
  4. package/dist/server/db/schema.js +1 -0
  5. package/dist/server/routes/workspaces.js +71 -0
  6. package/dist/server/services/agent/engines/claude-code/engine.js +1 -0
  7. package/dist/server/services/agent/engines/claude-code/options-builder.js +2 -0
  8. package/dist/server/services/agent/engines/codex/capabilities.js +2 -1
  9. package/dist/server/services/agent/engines/codex/engine.js +1 -1
  10. package/dist/server/services/agent/engines/codex/options-builder.js +4 -2
  11. package/dist/server/services/agent/orchestrator.js +45 -8
  12. package/dist/server/services/engine-handoff-service.js +41 -0
  13. package/dist/server/services/forge/bitbucket/provider.js +291 -0
  14. package/dist/server/services/forge/registry.js +3 -1
  15. package/dist/server/services/forge/resolve.js +4 -1
  16. package/dist/server/services/settings-defaults.js +1 -1
  17. package/dist/server/services/settings-service.js +15 -1
  18. package/dist/server/services/workspace-service.js +50 -2
  19. package/package.json +1 -1
  20. package/src/client/dist/pwa/assets/{ActivityFeed-BNhy8oDx.js → ActivityFeed-CoE7FTe_.js} +1 -1
  21. package/src/client/dist/pwa/assets/{ChangelogPage-eRWLuQLR.js → ChangelogPage-Cr_15zCy.js} +1 -1
  22. package/src/client/dist/pwa/assets/{ClosePopup-Cc2YwliF.js → ClosePopup-DQgUw5Fk.js} +1 -1
  23. package/src/client/dist/pwa/assets/{CreatePage-B9Mxi7zD.js → CreatePage-BgDusUKR.js} +1 -1
  24. package/src/client/dist/pwa/assets/{DiffViewer-DglPAzaZ.js → DiffViewer-CfOhZMXv.js} +3 -3
  25. package/src/client/dist/pwa/assets/{HealthPage-sS-N-_9T.js → HealthPage-3GHu-I59.js} +1 -1
  26. package/src/client/dist/pwa/assets/{MainLayout-BoZsUoGd.css → MainLayout-BY-xOjDK.css} +1 -1
  27. package/src/client/dist/pwa/assets/{MainLayout-BelFb6SW.js → MainLayout-DPLjufY9.js} +2 -2
  28. package/src/client/dist/pwa/assets/{QBtnToggle-eC2_l1xJ.js → QBtnToggle-CC3TCOK8.js} +1 -1
  29. package/src/client/dist/pwa/assets/{QChip-BT8qyt_p.js → QChip-CLKcMuqJ.js} +1 -1
  30. package/src/client/dist/pwa/assets/{QSelect-Bj-wqU82.js → QSelect-DUCHcz_p.js} +1 -1
  31. package/src/client/dist/pwa/assets/{QTooltip-CCNfQ3sa.js → QTooltip-CUM1JiWx.js} +1 -1
  32. package/src/client/dist/pwa/assets/{SearchPage-6CSIDDcM.js → SearchPage-f9Bjw2s3.js} +1 -1
  33. package/src/client/dist/pwa/assets/SettingsPage-BmTlGyff.js +16 -0
  34. package/src/client/dist/pwa/assets/{SettingsPage-CENUP7ua.css → SettingsPage-Os-6b-6H.css} +1 -1
  35. package/src/client/dist/pwa/assets/{ThinkingItem-kP-lU251.js → ThinkingItem-D9OZZN0H.js} +1 -1
  36. package/src/client/dist/pwa/assets/{TouchPan-DVggdTB9.js → TouchPan-DvWjWFLZ.js} +1 -1
  37. package/src/client/dist/pwa/assets/{WorkspacePage-D-1HoeQg.css → WorkspacePage-BbJM8PyI.css} +1 -1
  38. package/src/client/dist/pwa/assets/WorkspacePage-CtiULAL9.js +4 -0
  39. package/src/client/dist/pwa/assets/{build-path-tree-FNTYv-td.js → build-path-tree-nLp-H-pQ.js} +1 -1
  40. package/src/client/dist/pwa/assets/{cssMode-DHVn7kI3.js → cssMode-7vYc6lG_.js} +1 -1
  41. package/src/client/dist/pwa/assets/{documents-DoEDCa8X.js → documents-CSi0kXxo.js} +1 -1
  42. package/src/client/dist/pwa/assets/{editor.api2-DO15WOFV.js → editor.api2-BzgKTMyb.js} +1 -1
  43. package/src/client/dist/pwa/assets/{editor.main-Ce_TPxw1.js → editor.main-DOAM9U2f.js} +2 -2
  44. package/src/client/dist/pwa/assets/{expand-template-DnNNlpvQ.js → expand-template-CT5wSSEr.js} +1 -1
  45. package/src/client/dist/pwa/assets/{formatters-BBT0Ye5P.js → formatters-66ZLwD6M.js} +1 -1
  46. package/src/client/dist/pwa/assets/{freemarker2-B5mFQ9MW.js → freemarker2-CgDldzFl.js} +1 -1
  47. package/src/client/dist/pwa/assets/{handlebars-TGXWWwrU.js → handlebars-Dt0cB0x6.js} +1 -1
  48. package/src/client/dist/pwa/assets/{html-BcoyXTgh.js → html-Blz0fA0T.js} +1 -1
  49. package/src/client/dist/pwa/assets/{htmlMode-Dhuwig6w.js → htmlMode-CgzmQ96i.js} +1 -1
  50. package/src/client/dist/pwa/assets/i18n-BjRW3rME.js +1 -0
  51. package/src/client/dist/pwa/assets/index-C0ylRsyw.js +84 -0
  52. package/src/client/dist/pwa/assets/{javascript-v_uXBoLN.js → javascript-DVKtVeCf.js} +1 -1
  53. package/src/client/dist/pwa/assets/{jsonMode-CErvRpqG.js → jsonMode-Cfa435aJ.js} +1 -1
  54. package/src/client/dist/pwa/assets/{kobo-commands-DXJ85QAa.js → kobo-commands-C-rMOa-X.js} +1 -1
  55. package/src/client/dist/pwa/assets/{layout-Di93C8KP.js → layout-BUYxQPjS.js} +1 -1
  56. package/src/client/dist/pwa/assets/{liquid-ZappZiY7.js → liquid-DU4LoIen.js} +1 -1
  57. package/src/client/dist/pwa/assets/{lspLanguageFeatures-ur5VVJZY.js → lspLanguageFeatures-DDmy9kyx.js} +1 -1
  58. package/src/client/dist/pwa/assets/{mdx-BKL2jNxT.js → mdx-CVPu9G6b.js} +1 -1
  59. package/src/client/dist/pwa/assets/{monaco.contribution-WfNiFCn_.js → monaco.contribution-BCKXe1LM.js} +2 -2
  60. package/src/client/dist/pwa/assets/{network-auth-O2hz04zH.js → network-auth-BP0-h4VQ.js} +1 -1
  61. package/src/client/dist/pwa/assets/{pending-diff-open-CysqNMDL.js → pending-diff-open-BAyLIcQ6.js} +1 -1
  62. package/src/client/dist/pwa/assets/{permissionModes-Crg3_WdF.js → permissionModes-CNIg8DwA.js} +1 -1
  63. package/src/client/dist/pwa/assets/{project-color-DVafgHFe.js → project-color-B38mkxVB.js} +1 -1
  64. package/src/client/dist/pwa/assets/{python-DXpnDgWC.js → python-WDhDBOmq.js} +1 -1
  65. package/src/client/dist/pwa/assets/{razor-2pTtVGVn.js → razor-Dv_KM7xr.js} +1 -1
  66. package/src/client/dist/pwa/assets/{render-chat-markdown-BbmsqP4r.js → render-chat-markdown-DahMoI2g.js} +1 -1
  67. package/src/client/dist/pwa/assets/{tsMode-t5L5YTTB.js → tsMode-B3UNfmkB.js} +1 -1
  68. package/src/client/dist/pwa/assets/{typescript-C2FUrxc2.js → typescript-CNkrDBQS.js} +1 -1
  69. package/src/client/dist/pwa/assets/{use-onboarding-CxQRTetV.js → use-onboarding-Bb-Hawzx.js} +1 -1
  70. package/src/client/dist/pwa/assets/{use-quasar-CUBX9V4F.js → use-quasar-DCRxdLu-.js} +1 -1
  71. package/src/client/dist/pwa/assets/{workers-ri7So4GA.js → workers-UUdizQoG.js} +1 -1
  72. package/src/client/dist/pwa/assets/{xml-CvWFj3be.js → xml-zRWWyHRk.js} +1 -1
  73. package/src/client/dist/pwa/assets/{yaml-DsSH3Hc4.js → yaml-Uma2sS3z.js} +1 -1
  74. package/src/client/dist/pwa/index.html +1 -1
  75. package/src/client/dist/pwa/sw.js +1 -1
  76. package/src/client/dist/pwa/assets/SettingsPage-jGIZfB7N.js +0 -16
  77. package/src/client/dist/pwa/assets/WorkspacePage-BWRpD_9V.js +0 -4
  78. package/src/client/dist/pwa/assets/i18n-BHXcyfLT.js +0 -1
  79. package/src/client/dist/pwa/assets/index-CGnQPjwo.js +0 -84
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ All notable changes to Kōbō are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/). Each release is an `## <version>`
5
5
  section — the in-app "What's new" dialog reads this file.
6
6
 
7
+ ## 1.11.4
8
+
9
+ - feat(workspace): switch agent engines within a workspace
10
+
11
+ ## 1.11.3
12
+
13
+ - feat(forge): add bitbucket community support
14
+
7
15
  ## 1.11.2
8
16
 
9
17
  - feat(integrations): let Notion and Sentry be enabled independently, keep
package/README.md CHANGED
@@ -16,12 +16,12 @@ Kōbō runs multiple coding agents in parallel, each isolated in its own git wor
16
16
  ## Features
17
17
 
18
18
  - **Isolated worktrees**: each workspace is a dedicated git worktree on its own branch, so parallel sessions never collide.
19
- - **Two agent engines**: Claude Code (via `@anthropic-ai/claude-agent-sdk`) and OpenAI Codex (via `codex app-server`), chosen per workspace.
20
- - **Live chat**: streaming text, inline Edit/Write diffs, per-turn cards, a compaction-in-progress indicator, infinite scrollback, and a reasoning panel immediately above the message input while Claude or Codex is actively thinking. `/` autocompletes skills and commands, `@` fuzzy-autocompletes worktree file paths, and `Ctrl+F` searches readable messages in the current workspace: selecting a result loads its session and scrolls to it. `Ctrl+K` opens a command palette for common workspace actions. The global search page deep-links to the same location. Messages queued while an agent is busy stay scoped to their workspace and session. **Send now** delivers a queued message to an active Claude Code stream or steers the active Codex turn; it remains queued until the engine confirms acceptance. Use `Enter` to send, `Ctrl+Enter` to force delivery while an agent is busy, and `Shift+Enter` or `Ctrl+J` for a new line.
19
+ - **Two agent engines**: Claude Code (via `@anthropic-ai/claude-agent-sdk`) and OpenAI Codex (via `codex app-server`). Switch between them in the same workspace without creating another worktree: Kōbō starts a fresh session for the target engine and provides an editable handoff with tasks, Git state, diff summary, and recent context.
20
+ - **Live chat**: streaming text, inline Edit/Write diffs, per-turn cards, a compaction-in-progress indicator, infinite scrollback, and a reasoning panel immediately above the message input while Claude or Codex is actively thinking. `/` autocompletes skills and commands, `@` fuzzy-autocompletes worktree file paths, and `Ctrl+F` searches readable messages in the current workspace: selecting a result loads its session and scrolls to it. `Ctrl+K` opens a command palette for common workspace actions. The global search page deep-links to the same location. Sessions retain their engine and can be renamed or permanently deleted with only their linked history. Messages queued while an agent is busy stay scoped to their workspace and session. **Send now** delivers a queued message to an active Claude Code stream or steers the active Codex turn; it remains queued until the engine confirms acceptance. Use `Enter` to send, `Ctrl+Enter` to force delivery while an agent is busy, and `Shift+Enter` or `Ctrl+J` for a new line.
21
21
  - **Full MCP toolset (`kobo-tasks`)**: a per-workspace MCP server the agent uses for far more than tasks — task/acceptance-criteria CRUD, starting/stopping the dev server and reading its logs, a unified `get_ticket` (Notion or Sentry), searching past conversations across every workspace, per-session token/cost usage, and a `.ai/thoughts` decision log. Native Claude Code Task tools complement it for lightweight sub-agent coordination. See [`AGENTS.md`](./AGENTS.md) for the full tool list.
22
22
 
23
23
  ![Sub-agents panel showing parallel tool calls](docs/assets/images/sub-agents-panel.png)
24
- - **Git panel**: a Monaco-based diff viewer with **inline file editing** (edit the right-hand panel directly, save with `Ctrl/Cmd+S`, conflict-guarded via sha precondition), inline conflict resolution, and `Sync` / `Push` / `Commit` / `Open PR` / `Merge ready PR` / `Change PR base` / `Change source branch`. You can write the commit message yourself or ask the active engine to prepare the commit; PR/MR merging always requires a confirmation, then Kōbō can offer to delete the merged remote branch. Edit/Write cards in chat open their exact changed file in the diff viewer. Multi-forge: GitHub (`gh`), GitLab (`glab`), or no forge, auto-detected from the remote and overridable per project.
24
+ - **Git panel**: a Monaco-based diff viewer with **inline file editing** (edit the right-hand panel directly, save with `Ctrl/Cmd+S`, conflict-guarded via sha precondition), inline conflict resolution, and `Sync` / `Push` / `Commit` / `Open PR` / `Merge ready PR` / `Change PR base` / `Change source branch`. You can write the commit message yourself or ask the active engine to prepare the commit; PR/MR merging always requires a confirmation, then Kōbō can offer to delete the merged remote branch. Edit/Write cards in chat open their exact changed file in the diff viewer. Multi-forge: GitHub (`gh`), GitLab (`glab`), Bitbucket Community (`bkt`), or no forge, auto-detected from the remote and overridable per project.
25
25
 
26
26
  ![Diff viewer with side-by-side changes](docs/assets/images/diff-viewer.png)
27
27
  - **Dev server panel**: start, stop, and tail logs for a workspace's dev server (Docker or npm) straight from the Tools panel — no need to leave the UI. When a project has no configured server, the panel and MCP tool report that explicitly instead of implying one is running.
@@ -84,7 +84,7 @@ The most common knobs:
84
84
  | `ANTHROPIC_API_KEY` | none | Claude Code engine credential (alternative to `claude /login`) |
85
85
  | `OPENAI_API_KEY` | none | Codex engine credential (alternative to `codex login`) |
86
86
 
87
- Global and per-project settings (worktree path, dev server commands, E2E framework, prompt templates, git conventions, branch prefixes, lifecycle scripts, task prompt) are edited in **Settings** at runtime. Notion and Sentry are enabled independently in their respective tabs; disabling one keeps its configuration and existing workspace links, but removes its import and automation UI. In **Settings → General → Activity feed**, you can hide agent reasoning panels. Per-project values inherit from the global ones when left empty.
87
+ Global and per-project settings (worktree path, dev server commands, E2E framework, prompt templates, git conventions, branch prefixes, lifecycle scripts, task prompt) are edited in **Settings** at runtime. Notion and Sentry are enabled independently in their respective tabs; disabling one keeps its configuration and existing workspace links, but removes its import and automation UI. **Settings → Forge** holds local Bitbucket Community credentials for `bkt`; Kōbō uses them for PR actions and provides them only to agents working in Bitbucket projects. In **Settings → General → Activity feed**, you can hide agent reasoning panels. Per-project values inherit from the global ones when left empty.
88
88
 
89
89
  The full reference (every env var, every setting key, MCP server registration, Notion / Sentry / Voice setup) is in [`CONFIGURATION.md`](./CONFIGURATION.md).
90
90
 
@@ -93,7 +93,7 @@ The full reference (every env var, every setting key, MCP server registration, N
93
93
  - **Claude Code.** Authenticate once with `claude /login`. Kōbō calls the embedded SDK directly, so no `claude` binary is required at runtime.
94
94
  - **OpenAI Codex.** Run `codex login` or export `OPENAI_API_KEY`. Kōbō spawns a long-lived `codex app-server` subprocess per workspace and bridges its JSON-RPC stream to the same UI.
95
95
 
96
- You pick the engine at workspace creation. Both share the same task tracking, permission modes, sub-agent panel, and quota footer. The mapping of Kōbō's four permission modes (`plan` / `bypass` / `strict` / `interactive`) to each engine's native sandbox + approval semantics is in [`CONFIGURATION.md`](./CONFIGURATION.md#permission-modes).
96
+ You pick the engine at workspace creation and can switch it later from the workspace header. A switch never attempts to resume a Claude conversation in Codex (or the reverse): it stops the active agent, starts a fresh target-engine session in the same worktree, and lets you review the handoff before launch. The new agent can retrieve further context from prior sessions through the read-only `kobo__read_workspace_events_csv` and `kobo__search_codebase` MCP tools. Both engines share the same task tracking, permission modes, sub-agent panel, and quota footer. The mapping of Kōbō's four permission modes (`plan` / `bypass` / `strict` / `interactive`) to each engine's native sandbox + approval semantics is in [`CONFIGURATION.md`](./CONFIGURATION.md#permission-modes).
97
97
 
98
98
  ## Disk-space purge
99
99
 
@@ -441,6 +441,26 @@ export const migrations = [
441
441
  `);
442
442
  },
443
443
  },
444
+ {
445
+ version: 33,
446
+ name: 'add-agent-session-engine',
447
+ migrate: (db) => {
448
+ const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'agent_sessions'").get();
449
+ if (!table)
450
+ return;
451
+ const cols = db.prepare('PRAGMA table_info(agent_sessions)').all();
452
+ if (!cols.some((col) => col.name === 'engine')) {
453
+ db.exec('ALTER TABLE agent_sessions ADD COLUMN engine TEXT');
454
+ }
455
+ // Historical rows predate per-session engine tracking. The workspace
456
+ // value is the best available attribution and keeps their history useful.
457
+ db.exec(`
458
+ UPDATE agent_sessions
459
+ SET engine = (SELECT engine FROM workspaces WHERE workspaces.id = agent_sessions.workspace_id)
460
+ WHERE engine IS NULL
461
+ `);
462
+ },
463
+ },
444
464
  ];
445
465
  /** Current schema version — always equals the highest migration version. */
446
466
  export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
@@ -57,6 +57,7 @@ export function initSchema(db) {
57
57
  workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
58
58
  pid INTEGER,
59
59
  engine_session_id TEXT,
60
+ engine TEXT,
60
61
  status TEXT NOT NULL DEFAULT 'running',
61
62
  model TEXT,
62
63
  started_at TEXT NOT NULL,
@@ -16,6 +16,7 @@ import { listChatHistory, pushChatHistory } from '../services/chat-history-servi
16
16
  import { renderCiFixTemplate } from '../services/ci-fix-template-service.js';
17
17
  import * as cronService from '../services/cron-service.js';
18
18
  import * as devServerService from '../services/dev-server-service.js';
19
+ import { buildEngineHandoff } from '../services/engine-handoff-service.js';
19
20
  import { saveWorkspaceFile, shaOf } from '../services/file-editor-service.js';
20
21
  import { getForgeProvider } from '../services/forge/registry.js';
21
22
  import { resolveForge } from '../services/forge/resolve.js';
@@ -77,6 +78,61 @@ app.get('/', (c) => {
77
78
  return c.json({ error: message }, 500);
78
79
  }
79
80
  });
81
+ // POST /api/workspaces/:id/engine-handoff-preview — build an editable, deterministic handoff.
82
+ app.post('/:id/engine-handoff-preview', migrationGuard, async (c) => {
83
+ try {
84
+ const id = c.req.param('id');
85
+ const body = await c.req.json();
86
+ const workspace = workspaceService.getWorkspaceWithTasks(id);
87
+ if (!workspace)
88
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
89
+ const engine = listEngines().find((item) => item.id === body.engine);
90
+ if (!engine)
91
+ return c.json({ error: `Unknown engine '${body.engine ?? ''}'` }, 400);
92
+ return c.json({ handoff: buildEngineHandoff(workspace, workspace.engine, engine.id) });
93
+ }
94
+ catch (err) {
95
+ return c.json({ error: err instanceof Error ? err.message : 'Failed to build engine handoff' }, 500);
96
+ }
97
+ });
98
+ // POST /api/workspaces/:id/switch-engine — stop the current controller and start a fresh engine session.
99
+ app.post('/:id/switch-engine', migrationGuard, async (c) => {
100
+ try {
101
+ const id = c.req.param('id');
102
+ const body = await c.req.json();
103
+ const workspace = workspaceService.getWorkspaceWithTasks(id);
104
+ if (!workspace)
105
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
106
+ if (!body.engine || body.engine === workspace.engine)
107
+ return c.json({ error: 'Choose a different engine' }, 400);
108
+ const engine = listEngines().find((item) => item.id === body.engine);
109
+ if (!engine)
110
+ return c.json({ error: `Unknown engine '${body.engine}'` }, 400);
111
+ const model = typeof body.model === 'string' && engine.capabilities.models.some((item) => item.id === body.model)
112
+ ? body.model
113
+ : engine.capabilities.models[0]?.id;
114
+ if (!model)
115
+ return c.json({ error: `Engine '${body.engine}' has no available model` }, 400);
116
+ if (!isAgentPermissionMode(body.agentPermissionMode) ||
117
+ !engine.capabilities.permissionModes.includes(body.agentPermissionMode)) {
118
+ return c.json({ error: `Engine '${body.engine}' does not support the selected permission mode` }, 400);
119
+ }
120
+ const effort = typeof body.reasoningEffort === 'string' ? body.reasoningEffort : 'auto';
121
+ const handoff = typeof body.handoff === 'string' && body.handoff.trim()
122
+ ? body.handoff.trim().slice(0, 30_000)
123
+ : buildEngineHandoff(workspace, workspace.engine, body.engine);
124
+ if (agentManager.hasController(id))
125
+ agentManager.stopAgent(id);
126
+ const updated = workspaceService.updateWorkspaceEngineConfiguration(id, body.engine, model, effort, body.agentPermissionMode);
127
+ const session = agentManager.startAgent(id, updated.worktreePath, handoff, updated.model, false, updated.agentPermissionMode, undefined, updated.reasoningEffort);
128
+ workspaceService.updateWorkspaceStatus(id, 'executing');
129
+ wsService.emit(id, 'user:message', { content: handoff, sender: 'user', kind: 'engine-handoff' }, session.agentSessionId);
130
+ return c.json({ workspace: workspaceService.getWorkspace(id), sessionId: session.agentSessionId });
131
+ }
132
+ catch (err) {
133
+ return c.json({ error: err instanceof Error ? err.message : 'Failed to switch engine' }, 500);
134
+ }
135
+ });
80
136
  // POST /api/workspaces — create workspace
81
137
  app.post('/', migrationGuard, async (c) => {
82
138
  try {
@@ -1222,6 +1278,21 @@ app.patch('/:id/sessions/:sessionId', async (c) => {
1222
1278
  return c.json({ error: message }, 500);
1223
1279
  }
1224
1280
  });
1281
+ // DELETE /api/workspaces/:id/sessions/:sessionId — remove a stopped session and its history.
1282
+ app.delete('/:id/sessions/:sessionId', migrationGuard, (c) => {
1283
+ try {
1284
+ const workspaceId = c.req.param('id');
1285
+ const sessionId = c.req.param('sessionId');
1286
+ const deleted = workspaceService.deleteSession(sessionId, workspaceId);
1287
+ if (!deleted)
1288
+ return c.json({ error: `Session '${sessionId}' not found` }, 404);
1289
+ return c.json({ ok: true, workspace: workspaceService.getWorkspace(workspaceId) });
1290
+ }
1291
+ catch (err) {
1292
+ const message = err instanceof Error ? err.message : String(err);
1293
+ return c.json({ error: message }, message.includes('active session') ? 409 : 500);
1294
+ }
1295
+ });
1225
1296
  // POST /api/workspaces/:id/deferred-tool-use/answer — resume a deferred
1226
1297
  // AskUserQuestion call by feeding the user's answers back to the SDK.
1227
1298
  app.post('/:id/deferred-tool-use/answer', async (c) => {
@@ -199,6 +199,7 @@ export function createClaudeCodeEngine() {
199
199
  console.warn(`[claude-engine stderr] ${data}`);
200
200
  }
201
201
  },
202
+ env: options.env,
202
203
  });
203
204
  sdkOptions.abortController = abortController;
204
205
  // Override the SDK's libc-blind binary resolution on Linux glibc — see
@@ -82,5 +82,7 @@ export function buildClaudeOptions(input) {
82
82
  options.canUseTool = input.canUseTool;
83
83
  if (input.stderr)
84
84
  options.stderr = input.stderr;
85
+ if (input.env)
86
+ options.env = input.env;
85
87
  return { options, effectivePrompt: prompt };
86
88
  }
@@ -3,11 +3,12 @@ export const CODEX_CAPABILITIES = {
3
3
  models: CODEX_MODELS.map((m) => ({ id: m.id, label: m.label })),
4
4
  effortLevels: [
5
5
  { id: 'auto', label: 'Auto' },
6
- { id: 'minimal', label: 'Minimal' },
6
+ { id: 'none', label: 'None' },
7
7
  { id: 'low', label: 'Low' },
8
8
  { id: 'medium', label: 'Medium' },
9
9
  { id: 'high', label: 'High' },
10
10
  { id: 'xhigh', label: 'Extra High' },
11
+ { id: 'max', label: 'Maximum' },
11
12
  ],
12
13
  permissionModes: ['plan', 'bypass', 'strict', 'interactive'],
13
14
  supportsResume: true,
@@ -60,7 +60,7 @@ export function createCodexEngine() {
60
60
  };
61
61
  const streamingBatcher = createStreamingBatcher(emitDirect);
62
62
  const safeEmit = (ev) => streamingBatcher.push(ev);
63
- const child = spawnAppServer({ cwd: options.workingDir, signal: abortController.signal });
63
+ const child = spawnAppServer({ cwd: options.workingDir, env: options.env, signal: abortController.signal });
64
64
  if (child.stderr) {
65
65
  child.stderr.setEncoding('utf8');
66
66
  child.stderr.on('data', (chunk) => {
@@ -41,9 +41,11 @@ export function buildCodexOptions(input) {
41
41
  if (input.model && input.model !== 'auto') {
42
42
  threadParams.model = input.model;
43
43
  }
44
- // Effort: omit when undefined or 'auto'
44
+ // Effort: omit when undefined or 'auto'. `minimal` was emitted by older
45
+ // Kōbō versions; Codex 5.6 rejects it, so preserve old workspaces by
46
+ // translating it to the closest current setting instead of failing a turn.
45
47
  if (input.effort && input.effort !== 'auto') {
46
- threadParams.modelReasoningEffort = input.effort;
48
+ threadParams.modelReasoningEffort = (input.effort === 'minimal' ? 'none' : input.effort);
47
49
  }
48
50
  // `default_tools_approval_mode: 'auto'` pre-approves the namespace —
49
51
  // without it Codex blocks every MCP tool call with "user cancelled".
@@ -6,6 +6,7 @@ import { unregisterProcess } from '../../utils/process-tracker.js';
6
6
  import * as autoLoopService from '../auto-loop-service.js';
7
7
  import * as cleanupScriptService from '../cleanup-script-service.js';
8
8
  import * as cronService from '../cron-service.js';
9
+ import { resolveForge } from '../forge/resolve.js';
9
10
  import * as quotaBackoffService from '../quota-backoff-service.js';
10
11
  import { getEffectiveSettings, getGlobalSettings } from '../settings-service.js';
11
12
  import { refreshNow } from '../usage/poller.js';
@@ -342,12 +343,44 @@ function buildMcpServers(workspaceId) {
342
343
  },
343
344
  ];
344
345
  }
346
+ function isBitbucketProject(projectPath) {
347
+ try {
348
+ return resolveForge(projectPath) === 'bitbucket-community';
349
+ }
350
+ catch {
351
+ return false;
352
+ }
353
+ }
354
+ function buildAgentEnv(projectPath) {
355
+ if (!isBitbucketProject(projectPath))
356
+ return undefined;
357
+ const global = getGlobalSettings();
358
+ if (!global.bitbucketToken)
359
+ return undefined;
360
+ return {
361
+ ...process.env,
362
+ BKT_HOST: 'https://bitbucket.org',
363
+ BKT_TOKEN: global.bitbucketToken,
364
+ BKT_USERNAME: global.bitbucketUsername,
365
+ };
366
+ }
367
+ function buildAgentPrompt(prompt, projectPath) {
368
+ if (!projectPath || !isBitbucketProject(projectPath))
369
+ return prompt;
370
+ return [
371
+ '[Kōbō Bitbucket] Bitbucket credentials are available through the `bkt` CLI in this session.',
372
+ 'Use `bkt pr edit <number> --body <description>` and `bkt pr comment <number> --body <comment>` for PR updates.',
373
+ 'Do not call the Bitbucket REST API with curl or reuse BKT_TOKEN as a Bearer token: Bitbucket Cloud API tokens require the bkt CLI authentication flow.',
374
+ '',
375
+ prompt,
376
+ ].join('\n');
377
+ }
345
378
  function resolveSessionForResume(workspaceId, existingSessionId, model) {
346
379
  const db = getDb();
347
380
  let lastSession;
348
381
  if (existingSessionId) {
349
382
  lastSession = db
350
- .prepare('SELECT id, engine_session_id FROM agent_sessions WHERE id = ? AND workspace_id = ? AND engine_session_id IS NOT NULL LIMIT 1')
383
+ .prepare('SELECT id, engine_session_id, engine FROM agent_sessions WHERE id = ? AND workspace_id = ? AND engine_session_id IS NOT NULL LIMIT 1')
351
384
  .get(existingSessionId, workspaceId);
352
385
  if (!lastSession) {
353
386
  throw new Error(`Cannot resume session '${existingSessionId}' for workspace '${workspaceId}': ` +
@@ -356,7 +389,7 @@ function resolveSessionForResume(workspaceId, existingSessionId, model) {
356
389
  }
357
390
  else {
358
391
  lastSession = db
359
- .prepare('SELECT id, engine_session_id FROM agent_sessions WHERE workspace_id = ? AND engine_session_id IS NOT NULL ORDER BY started_at DESC LIMIT 1')
392
+ .prepare('SELECT id, engine_session_id, engine FROM agent_sessions WHERE workspace_id = ? AND engine_session_id IS NOT NULL ORDER BY started_at DESC LIMIT 1')
360
393
  .get(workspaceId);
361
394
  }
362
395
  const engineSessionId = lastSession?.engine_session_id ?? (existingSessionId ? undefined : sessionIds.get(workspaceId));
@@ -379,19 +412,19 @@ function resolveSessionForResume(workspaceId, existingSessionId, model) {
379
412
  db.prepare('INSERT INTO agent_sessions (id, workspace_id, pid, status, model, started_at) VALUES (?, ?, ?, ?, ?, ?)').run(agentSessionId, workspaceId, null, 'running', model ?? null, new Date().toISOString());
380
413
  return { agentSessionId, engineSessionId: undefined, existed: false };
381
414
  }
382
- function reuseOrCreateFreshSession(workspaceId, existingSessionId, model) {
415
+ function reuseOrCreateFreshSession(workspaceId, existingSessionId, model, engine) {
383
416
  const db = getDb();
384
417
  if (existingSessionId) {
385
418
  const result = db
386
- .prepare('UPDATE agent_sessions SET status = ?, started_at = ?, ended_at = NULL, model = ? WHERE id = ? AND workspace_id = ?')
387
- .run('running', new Date().toISOString(), model ?? null, existingSessionId, workspaceId);
419
+ .prepare('UPDATE agent_sessions SET status = ?, started_at = ?, ended_at = NULL, model = ?, engine = ? WHERE id = ? AND workspace_id = ?')
420
+ .run('running', new Date().toISOString(), model ?? null, engine, existingSessionId, workspaceId);
388
421
  if (result.changes === 0) {
389
422
  throw new Error(`Agent session '${existingSessionId}' not found for workspace '${workspaceId}'`);
390
423
  }
391
424
  return existingSessionId;
392
425
  }
393
426
  const agentSessionId = nanoid();
394
- db.prepare('INSERT INTO agent_sessions (id, workspace_id, pid, status, model, started_at) VALUES (?, ?, ?, ?, ?, ?)').run(agentSessionId, workspaceId, null, 'running', model ?? null, new Date().toISOString());
427
+ db.prepare('INSERT INTO agent_sessions (id, workspace_id, pid, engine, status, model, started_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(agentSessionId, workspaceId, null, engine, 'running', model ?? null, new Date().toISOString());
395
428
  return agentSessionId;
396
429
  }
397
430
  // ── Event handler ─────────────────────────────────────────────────────────────
@@ -753,15 +786,18 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
753
786
  const r = resolveSessionForResume(workspaceId, existingSessionId, model);
754
787
  agentSessionId = r.agentSessionId;
755
788
  resumeFromEngineSessionId = r.engineSessionId;
789
+ // A native resume is only used by the currently selected workspace engine.
790
+ // Keep older DB rows attributable even when they were created pre-migration.
791
+ getDb().prepare('UPDATE agent_sessions SET engine = ? WHERE id = ?').run(engineId, agentSessionId);
756
792
  }
757
793
  else {
758
- agentSessionId = reuseOrCreateFreshSession(workspaceId, existingSessionId, model);
794
+ agentSessionId = reuseOrCreateFreshSession(workspaceId, existingSessionId, model, engineId);
759
795
  }
760
796
  const settings = ws ? readEffectiveSettingsSafe(ws.projectPath) : readEffectiveSettingsSafe(workingDir);
761
797
  const options = {
762
798
  workspaceId,
763
799
  workingDir,
764
- prompt,
800
+ prompt: buildAgentPrompt(prompt, ws?.projectPath),
765
801
  model,
766
802
  effort: reasoningEffort,
767
803
  // Cascade: explicit caller override → workspace setting → 'bypass'.
@@ -778,6 +814,7 @@ export function startAgent(workspaceId, workingDir, prompt, model, resume = fals
778
814
  })(),
779
815
  settings,
780
816
  mcpServers: buildMcpServers(workspaceId),
817
+ env: ws ? buildAgentEnv(ws.projectPath) : undefined,
781
818
  };
782
819
  const controller = new SessionController(workspaceId, agentSessionId, engine, (ev) => handleEvent(workspaceId, agentSessionId, ev));
783
820
  controllers.set(workspaceId, controller);
@@ -0,0 +1,41 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { getDb } from '../db/index.js';
3
+ function runGit(worktreePath, args) {
4
+ try {
5
+ return execFileSync('git', args.split(' '), { cwd: worktreePath, encoding: 'utf8', timeout: 10_000 }).trim();
6
+ }
7
+ catch {
8
+ return '';
9
+ }
10
+ }
11
+ function recentConversation(workspaceId) {
12
+ const rows = getDb()
13
+ .prepare(`SELECT type, payload FROM ws_events
14
+ WHERE workspace_id = ?
15
+ AND (type = 'user:message' OR (type = 'agent:event' AND json_extract(payload, '$.kind') = 'message:text'))
16
+ ORDER BY rowid DESC LIMIT 6`)
17
+ .all(workspaceId);
18
+ return rows
19
+ .reverse()
20
+ .flatMap((row) => {
21
+ try {
22
+ const payload = JSON.parse(row.payload);
23
+ const text = typeof payload.content === 'string' ? payload.content : typeof payload.text === 'string' ? payload.text : '';
24
+ return text ? [text.slice(0, 1600)] : [];
25
+ }
26
+ catch {
27
+ return [];
28
+ }
29
+ })
30
+ .join('\n\n');
31
+ }
32
+ /** Build a deterministic, secret-free handoff for a fresh session on another engine. */
33
+ export function buildEngineHandoff(workspace, sourceEngine, targetEngine) {
34
+ const taskLines = workspace.tasks.length
35
+ ? workspace.tasks.map((task) => `- [${task.status === 'done' ? 'x' : ' '}] ${task.title}`).join('\n')
36
+ : '- No Kōbō task recorded.';
37
+ const changedFiles = runGit(workspace.worktreePath, 'status --short') || 'No uncommitted changes detected.';
38
+ const diffStat = runGit(workspace.worktreePath, 'diff --stat') || 'No uncommitted diff stat available.';
39
+ const history = recentConversation(workspace.id) || 'No recent conversation message available.';
40
+ return `# Kōbō engine handoff\n\nYou are taking over this workspace from ${sourceEngine} using ${targetEngine}. Work in the existing worktree and verify the repository state before making changes.\n\n## Objective\n${workspace.description ?? workspace.name}\n\n## Tasks\n${taskLines}\n\n## Git state\n- Branch: ${workspace.workingBranch}\n- Base: ${workspace.sourceBranch}\n- Worktree: ${workspace.worktreePath}\n\nChanged files:\n\`\`\`\n${changedFiles}\n\`\`\`\n\nDiff summary:\n\`\`\`\n${diffStat}\n\`\`\`\n\n## Recent conversation\n${history}\n\n## Recover more context\nIf this handoff is insufficient, use \`kobo__read_workspace_events_csv\` to read the previous workspace conversation in pages (optionally filter by \`session_id\`). Use \`kobo__search_codebase\` to find a precise past decision or user request. Read only the context needed, then continue the task.\n`;
41
+ }