@loicngr/kobo 1.11.26 → 1.11.28

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 (182) hide show
  1. package/AGENTS.md +19 -1
  2. package/CHANGELOG.md +10 -0
  3. package/dist/mcp-server/kobo-tasks-handlers.js +17 -1
  4. package/dist/server/db/migrations.js +34 -0
  5. package/dist/server/db/schema.js +3 -1
  6. package/dist/server/index.js +80 -23
  7. package/dist/server/middleware/host-check-middleware.js +80 -0
  8. package/dist/server/routes/changelog.js +51 -3
  9. package/dist/server/routes/notion.js +1 -1
  10. package/dist/server/routes/pull-requests.js +6 -2
  11. package/dist/server/routes/sentry.js +1 -1
  12. package/dist/server/routes/settings.js +23 -8
  13. package/dist/server/routes/usage.js +12 -0
  14. package/dist/server/routes/workspaces.js +240 -91
  15. package/dist/server/services/agent/engines/claude-code/engine.js +112 -15
  16. package/dist/server/services/agent/engines/codex/engine.js +18 -0
  17. package/dist/server/services/agent/engines/codex/server-requests.js +5 -20
  18. package/dist/server/services/agent/orchestrator.js +143 -34
  19. package/dist/server/services/agent/session-controller.js +28 -0
  20. package/dist/server/services/agent/streaming-batcher.js +1 -0
  21. package/dist/server/services/agent/turn-liveness.js +12 -1
  22. package/dist/server/services/auto-loop-service.js +65 -0
  23. package/dist/server/services/awaiting-user-reminder-service.js +115 -0
  24. package/dist/server/services/change-source-branch-service.js +1 -1
  25. package/dist/server/services/file-editor-service.js +2 -46
  26. package/dist/server/services/forge/bitbucket/provider.js +9 -8
  27. package/dist/server/services/forge/github/provider.js +4 -3
  28. package/dist/server/services/forge/gitlab/provider.js +4 -3
  29. package/dist/server/services/forge/parse-cli-json.js +14 -0
  30. package/dist/server/services/forge/resolve.js +29 -1
  31. package/dist/server/services/lifecycle-hook-service.js +114 -0
  32. package/dist/server/services/network-access-service.js +187 -6
  33. package/dist/server/services/pr-watcher-service.js +27 -1
  34. package/dist/server/services/settings-service.js +147 -4
  35. package/dist/server/services/usage/reliability.js +81 -0
  36. package/dist/server/services/wakeup-service.js +23 -0
  37. package/dist/server/services/websocket-service.js +31 -3
  38. package/dist/server/services/workspace-service.js +36 -4
  39. package/dist/server/services/worktree-purge-service.js +1 -1
  40. package/dist/server/utils/git-ops.js +159 -1
  41. package/dist/server/utils/paths.js +19 -0
  42. package/dist/server/utils/safe-path.js +21 -1
  43. package/dist/server/utils/script-runner.js +2 -1
  44. package/dist/server/utils/workspace-name.js +37 -0
  45. package/dist/shared/consts.js +22 -0
  46. package/dist/shared/models.js +4 -4
  47. package/package.json +3 -2
  48. package/src/client/dist/pwa/assets/{ActivityFeed-DUjY6lCm.css → ActivityFeed-C6XSxdJ0.css} +1 -1
  49. package/src/client/dist/pwa/assets/ActivityFeed-LDzg5XPx.js +8 -0
  50. package/src/client/dist/pwa/assets/ChangelogPage-CjiWI9Qa.js +1 -0
  51. package/src/client/dist/pwa/assets/ClosePopup-CK6wvsMy.js +1 -0
  52. package/src/client/dist/pwa/assets/ComparisonPanel-C0hI2D1O.css +1 -0
  53. package/src/client/dist/pwa/assets/ComparisonPanel-D6eEciwQ.js +1 -0
  54. package/src/client/dist/pwa/assets/CreatePage-DHBa7ZL5.js +2 -0
  55. package/src/client/dist/pwa/assets/CreatePage-DeKYK6uC.css +1 -0
  56. package/src/client/dist/pwa/assets/DashboardPage-BqgK4lAf.css +1 -0
  57. package/src/client/dist/pwa/assets/DashboardPage-Xev4suQ7.js +1 -0
  58. package/src/client/dist/pwa/assets/DiffViewer-D0BQ5Dip.js +8 -0
  59. package/src/client/dist/pwa/assets/DiffViewer-DnR79ycb.css +1 -0
  60. package/src/client/dist/pwa/assets/DocumentsPanel-7hMCYUTd.js +1 -0
  61. package/src/client/dist/pwa/assets/DrawerToggleButton-BASeszPg.js +1 -0
  62. package/src/client/dist/pwa/assets/GitPanel-D-rwHnFn.js +3 -0
  63. package/src/client/dist/pwa/assets/{GitPanel-BKrtkPrV.css → GitPanel-DVs5BCU-.css} +1 -1
  64. package/src/client/dist/pwa/assets/HealthPage-BKXqP1li.js +1 -0
  65. package/src/client/dist/pwa/assets/MainLayout-DnPtpWgP.js +2 -0
  66. package/src/client/dist/pwa/assets/MainLayout-ExB0xYHB.css +1 -0
  67. package/src/client/dist/pwa/assets/QBanner-CDiXUya7.js +1 -0
  68. package/src/client/dist/pwa/assets/QBtnDropdown-52YHuBbx.js +1 -0
  69. package/src/client/dist/pwa/assets/QChip-DpUYTCOJ.js +1 -0
  70. package/src/client/dist/pwa/assets/QItemSection-CEUi4Tz0.js +1 -0
  71. package/src/client/dist/pwa/assets/QMarkupTable-DcoQpjPu.js +1 -0
  72. package/src/client/dist/pwa/assets/SchedulePanel-C7NK1hIK.js +1 -0
  73. package/src/client/dist/pwa/assets/SearchPage-Cm8chltA.js +1 -0
  74. package/src/client/dist/pwa/assets/{SettingsPage-CDZhPV6A.css → SettingsPage-2vdOZu1C.css} +1 -1
  75. package/src/client/dist/pwa/assets/SettingsPage-B4xs1RUl.js +10 -0
  76. package/src/client/dist/pwa/assets/TerminalPanel-CSPk8QtK.js +18 -0
  77. package/src/client/dist/pwa/assets/{TerminalPanel-B6xJJs8p.css → TerminalPanel-DbVZk0vD.css} +1 -1
  78. package/src/client/dist/pwa/assets/TouchPan-CpO61wPy.js +1 -0
  79. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-CrLP_I5S.css +1 -0
  80. package/src/client/dist/pwa/assets/WorkspaceAttentionLabels-DZrbJ0vD.js +1 -0
  81. package/src/client/dist/pwa/assets/WorkspacePage-D0-FkRVJ.js +4 -0
  82. package/src/client/dist/pwa/assets/browser-Bg25_sDh.js +8 -0
  83. package/src/client/dist/pwa/assets/build-path-tree-DH4SSsEz.js +1 -0
  84. package/src/client/dist/pwa/assets/de-Bf_OsgDw.js +17 -0
  85. package/src/client/dist/pwa/assets/documents-CDZqd_g5.js +1 -0
  86. package/src/client/dist/pwa/assets/editor-BJU3Xb3h.css +1 -0
  87. package/src/client/dist/pwa/assets/editor.api-CMsYdZTX.js +413 -0
  88. package/src/client/dist/pwa/assets/{efforts-BPZzMyrc.js → efforts-Db4w0alg.js} +1 -1
  89. package/src/client/dist/pwa/assets/engineFeatures-BjtQR0vB.js +1 -0
  90. package/src/client/dist/pwa/assets/es-1ib0e9jm.js +17 -0
  91. package/src/client/dist/pwa/assets/expand-template-CTUNyJrv.css +1 -0
  92. package/src/client/dist/pwa/assets/expand-template-DY8ntqUb.js +1 -0
  93. package/src/client/dist/pwa/assets/format-C-9jMgWb.js +435 -0
  94. package/src/client/dist/pwa/assets/format-CX6Cv1cD.css +1 -0
  95. package/src/client/dist/pwa/assets/{formatters-D0dU6VcA.js → formatters-CQ90Icr4.js} +1 -1
  96. package/src/client/dist/pwa/assets/fr-nj0BrM3X.js +17 -0
  97. package/src/client/dist/pwa/assets/{freemarker2-BqVyQSGe.js → freemarker2-KxB1BtrL.js} +1 -1
  98. package/src/client/dist/pwa/assets/{handlebars-9K762gzW.js → handlebars-7TByd_Zc.js} +1 -1
  99. package/src/client/dist/pwa/assets/{html-CRmp4AC_.js → html-BoVZ_gre.js} +1 -1
  100. package/src/client/dist/pwa/assets/i18n-BubYp85n.js +1 -0
  101. package/src/client/dist/pwa/assets/{index-BpmxQlZi.css → index-6H3m6vH7.css} +1 -1
  102. package/src/client/dist/pwa/assets/index-Ck1_qrff.js +18 -0
  103. package/src/client/dist/pwa/assets/it-B9MEgvg1.js +17 -0
  104. package/src/client/dist/pwa/assets/{javascript-DB9a_ZGm.js → javascript-BLL8vPQy.js} +1 -1
  105. package/src/client/dist/pwa/assets/layout-CFuk4mdG.js +1 -0
  106. package/src/client/dist/pwa/assets/{liquid-C67ZTBj7.js → liquid-DzcT-R6k.js} +1 -1
  107. package/src/client/dist/pwa/assets/{mdx-C_K9rc9_.js → mdx-Bguo999w.js} +1 -1
  108. package/src/client/dist/pwa/assets/monaco-contributions-CM7FH-iZ.js +51 -0
  109. package/src/client/dist/pwa/assets/monaco-contributions-Cb0afGfW.css +1 -0
  110. package/src/client/dist/pwa/assets/monaco.contribution-Dkq0qtWA.js +2 -0
  111. package/src/client/dist/pwa/assets/network-auth-C0moem8_.js +1 -0
  112. package/src/client/dist/pwa/assets/{notify-theme-thUiJn9n.js → notify-theme-C7mNmzKx.js} +1 -1
  113. package/src/client/dist/pwa/assets/pending-diff-open-BaQ_SNEF.js +1 -0
  114. package/src/client/dist/pwa/assets/permissionModes-CJZsT9Fl.js +36 -0
  115. package/src/client/dist/pwa/assets/pinia-ClsyLh3i.js +1 -0
  116. package/src/client/dist/pwa/assets/{project-color-hgNWqp0x.js → project-color-54JZHVy2.js} +1 -1
  117. package/src/client/dist/pwa/assets/{python-D1CBO9Uf.js → python-D5RQNiPF.js} +1 -1
  118. package/src/client/dist/pwa/assets/{razor-BCiKSj6_.js → razor-CVwoDJol.js} +1 -1
  119. package/src/client/dist/pwa/assets/{render-chat-markdown-Cw1_NLqs.js → render-chat-markdown-NAmPVIQp.js} +1 -1
  120. package/src/client/dist/pwa/assets/rolldown-runtime-hePW80VL.js +1 -0
  121. package/src/client/dist/pwa/assets/{typescript-D-8UENST.js → typescript-qdgwClKe.js} +1 -1
  122. package/src/client/dist/pwa/assets/use-checkbox-3io6bTPe.js +1 -0
  123. package/src/client/dist/pwa/assets/use-onboarding-DbYF8ZX_.js +2 -0
  124. package/src/client/dist/pwa/assets/use-quasar-B4eIgSfZ.js +1 -0
  125. package/src/client/dist/pwa/assets/vue-i18n-DtajJy_8.js +3 -0
  126. package/src/client/dist/pwa/assets/{xml-BBPvJ9QP.js → xml-DNSfQSlk.js} +1 -1
  127. package/src/client/dist/pwa/assets/{yaml-D4q1p7HX.js → yaml-6gnX2NnP.js} +1 -1
  128. package/src/client/dist/pwa/index.html +6 -5
  129. package/src/client/dist/pwa/sw.js +1 -1
  130. package/src/mcp-server/kobo-tasks-handlers.ts +17 -1
  131. package/src/client/dist/pwa/assets/ActivityFeed-CGMEOMn5.js +0 -8
  132. package/src/client/dist/pwa/assets/ChangelogPage-rvISZfJb.js +0 -1
  133. package/src/client/dist/pwa/assets/ClosePopup-BK6mNWsy.js +0 -1
  134. package/src/client/dist/pwa/assets/CreatePage-BCuDTB_0.css +0 -1
  135. package/src/client/dist/pwa/assets/CreatePage-BptAgVvu.js +0 -2
  136. package/src/client/dist/pwa/assets/DiffViewer-ZrvQ0YWN.js +0 -8
  137. package/src/client/dist/pwa/assets/DiffViewer-ahWtFrRy.css +0 -1
  138. package/src/client/dist/pwa/assets/DocumentsPanel-DuWbZiID.js +0 -1
  139. package/src/client/dist/pwa/assets/DrawerToggleButton-CBdOJzVZ.js +0 -1
  140. package/src/client/dist/pwa/assets/GitPanel-BiIXQPfL.js +0 -3
  141. package/src/client/dist/pwa/assets/HealthPage-DTrmhh3X.js +0 -1
  142. package/src/client/dist/pwa/assets/MainLayout-PSmlvfJd.js +0 -2
  143. package/src/client/dist/pwa/assets/MainLayout-Vq1iUPjx.css +0 -1
  144. package/src/client/dist/pwa/assets/QBanner-BoAbhM1O.js +0 -1
  145. package/src/client/dist/pwa/assets/QBtnDropdown-Ckvd3IVR.js +0 -1
  146. package/src/client/dist/pwa/assets/QChip-BzB_GF4Z.js +0 -1
  147. package/src/client/dist/pwa/assets/QItemSection-5GBV9MR2.js +0 -1
  148. package/src/client/dist/pwa/assets/SchedulePanel-DJWMsAoh.js +0 -1
  149. package/src/client/dist/pwa/assets/SearchPage-BI8w9utc.js +0 -1
  150. package/src/client/dist/pwa/assets/SettingsPage-Dr8oSevA.js +0 -16
  151. package/src/client/dist/pwa/assets/TerminalPanel-DVC58DJb.js +0 -18
  152. package/src/client/dist/pwa/assets/TouchPan-pqksSni0.js +0 -1
  153. package/src/client/dist/pwa/assets/WorkspacePage-DRP9Ypmz.js +0 -4
  154. package/src/client/dist/pwa/assets/build-path-tree-CQ4zZqo0.js +0 -1
  155. package/src/client/dist/pwa/assets/css.worker-JKZQyld4.js +0 -93
  156. package/src/client/dist/pwa/assets/cssMode-MGWKgqj7.js +0 -1
  157. package/src/client/dist/pwa/assets/documents-B6eOZeFK.js +0 -1
  158. package/src/client/dist/pwa/assets/editor-vZnmUXVO.css +0 -1
  159. package/src/client/dist/pwa/assets/editor.api-Dq41Mm99.js +0 -847
  160. package/src/client/dist/pwa/assets/engineFeatures-Dd_dxoOS.js +0 -1
  161. package/src/client/dist/pwa/assets/expand-template-B4LAsXAF.css +0 -1
  162. package/src/client/dist/pwa/assets/expand-template-BfZqON3q.js +0 -1
  163. package/src/client/dist/pwa/assets/html.worker-BUr90sI4.js +0 -506
  164. package/src/client/dist/pwa/assets/htmlMode-CFxFF9iH.js +0 -1
  165. package/src/client/dist/pwa/assets/i18n-BsWCIFmf.js +0 -1
  166. package/src/client/dist/pwa/assets/index-xTf0VfFG.js +0 -82
  167. package/src/client/dist/pwa/assets/json.worker-BOODjG9U.js +0 -62
  168. package/src/client/dist/pwa/assets/jsonMode-JhLTE1-C.js +0 -7
  169. package/src/client/dist/pwa/assets/layout-nWIyHeJf.js +0 -1
  170. package/src/client/dist/pwa/assets/lspLanguageFeatures-BIDd3ml0.js +0 -4
  171. package/src/client/dist/pwa/assets/network-auth-BPxfeLcl.js +0 -1
  172. package/src/client/dist/pwa/assets/pending-diff-open-BdA8vrDx.js +0 -1
  173. package/src/client/dist/pwa/assets/permissionModes-D09rnNgv.js +0 -36
  174. package/src/client/dist/pwa/assets/ts.worker-C-oGa8JT.js +0 -67738
  175. package/src/client/dist/pwa/assets/tsMode-Db77HzVu.js +0 -11
  176. package/src/client/dist/pwa/assets/use-checkbox-z-Qs4ZHY.js +0 -1
  177. package/src/client/dist/pwa/assets/use-id-DlouqdB8.js +0 -1
  178. package/src/client/dist/pwa/assets/use-onboarding-BafrD9nc.js +0 -2
  179. package/src/client/dist/pwa/assets/vs-Bb18eQC8.js +0 -56
  180. package/src/client/dist/pwa/assets/vs-JJSFJJVJ.css +0 -1
  181. package/src/client/dist/pwa/assets/vue-i18n-C3W5i7h6.js +0 -3
  182. package/src/client/dist/pwa/assets/workers-CImko7IX.js +0 -1
@@ -20,6 +20,18 @@ function rowToPending(row) {
20
20
  return null;
21
21
  return { targetAt: row.target_at, reason: row.reason ?? undefined };
22
22
  }
23
+ /**
24
+ * How many times a wakeup may be re-armed after `startAgent` failed before we
25
+ * conclude the failure is permanent. `startAgent` throws for good on an
26
+ * archived or purged workspace, and on a session id that no longer resolves;
27
+ * without a bound that is a 15 s loop writing to SQLite forever. Deferring
28
+ * because a session is *running* is a different case and stays unbounded — the
29
+ * wakeup is meant to land once that session ends, however long it takes.
30
+ */
31
+ const MAX_FAILED_RETRIES = 5;
32
+ /** Consecutive `startAgent` failures per workspace. In memory on purpose: a
33
+ * restart is a legitimate fresh attempt. */
34
+ const failedRetries = new Map();
23
35
  /** Keep a claimed wakeup durable while the session is still unavailable. */
24
36
  function defer(workspaceId, row) {
25
37
  const targetAt = new Date(Date.now() + ACTIVE_SESSION_RETRY_MS).toISOString();
@@ -59,6 +71,7 @@ export function cancel(workspaceId, reason) {
59
71
  clearTimeout(existing);
60
72
  timers.delete(workspaceId);
61
73
  }
74
+ failedRetries.delete(workspaceId);
62
75
  const db = getDb();
63
76
  const result = db.prepare('DELETE FROM pending_wakeups WHERE workspace_id = ?').run(workspaceId);
64
77
  if (result.changes > 0) {
@@ -156,12 +169,22 @@ function fire(workspaceId) {
156
169
  const agentPermissionMode = stored === 'plan' || stored === 'strict' || stored === 'interactive' ? stored : 'bypass';
157
170
  try {
158
171
  orchestrator.startAgent(workspaceId, worktreePath, row.prompt, wsRow.model, true, agentPermissionMode, row.agent_session_id ?? undefined, wsRow.reasoning_effort);
172
+ failedRetries.delete(workspaceId);
159
173
  db.prepare('DELETE FROM pending_wakeups WHERE workspace_id = ?').run(workspaceId);
160
174
  emitEphemeral(workspaceId, 'wakeup:fired', {});
161
175
  }
162
176
  catch (err) {
163
177
  const message = err instanceof Error ? err.message : String(err);
164
178
  console.error(`[wakeup-service] startAgent at fire time failed for '${workspaceId}':`, message);
179
+ const attempts = (failedRetries.get(workspaceId) ?? 0) + 1;
180
+ if (attempts > MAX_FAILED_RETRIES) {
181
+ failedRetries.delete(workspaceId);
182
+ db.prepare('DELETE FROM pending_wakeups WHERE workspace_id = ?').run(workspaceId);
183
+ console.error(`[wakeup-service] giving up on the wakeup for '${workspaceId}' after ${MAX_FAILED_RETRIES} failed attempts`);
184
+ emitEphemeral(workspaceId, 'wakeup:skipped', { reason: message });
185
+ return;
186
+ }
187
+ failedRetries.set(workspaceId, attempts);
165
188
  defer(workspaceId, row);
166
189
  }
167
190
  }
@@ -78,7 +78,18 @@ export function handleConnection(ws) {
78
78
  case 'devserver:start':
79
79
  case 'devserver:stop': {
80
80
  if (messageHandler) {
81
- messageHandler(type, payload);
81
+ // The handler does DB work before its own try/catch, so it can fail
82
+ // both ways: a rejection if it is async, a plain throw if it is not.
83
+ // The process treats either as fatal, which would turn one bad frame
84
+ // into "Kōbō died and took every running agent with it". Calling it
85
+ // inside the async wrapper funnels both into the same catch.
86
+ void (async () => messageHandler?.(type, payload))().catch((err) => {
87
+ console.error(`[ws] Message handler failed for '${type}':`, err);
88
+ // Every other branch of this switch answers on failure; a routed
89
+ // message that vanishes silently leaves the user waiting forever.
90
+ const message = err instanceof Error ? err.message : String(err);
91
+ ws.send(JSON.stringify({ type: 'error', payload: { message: `Failed to handle '${type}': ${message}` } }));
92
+ });
82
93
  }
83
94
  break;
84
95
  }
@@ -124,14 +135,31 @@ export function handleConnection(ws) {
124
135
  * Persists the event to the ws_events table.
125
136
  * Returns the event id.
126
137
  */
138
+ /**
139
+ * The insert is on the busiest write path in the process: one row per agent
140
+ * event, hundreds per turn once token deltas are batched. Compiling the
141
+ * statement once and reusing it is better-sqlite3's documented fast path.
142
+ * Keyed on the database handle so a `resetDb()` between tests cannot hand back
143
+ * a statement bound to a closed connection.
144
+ */
145
+ let cachedInsert = null;
146
+ function insertEventStatement() {
147
+ const db = getDb();
148
+ if (!cachedInsert || cachedInsert.db !== db) {
149
+ cachedInsert = {
150
+ db,
151
+ statement: db.prepare('INSERT INTO ws_events (id, workspace_id, type, payload, session_id, created_at) VALUES (?, ?, ?, ?, ?, ?)'),
152
+ };
153
+ }
154
+ return cachedInsert.statement;
155
+ }
127
156
  export function emit(workspaceId, type, payload, sessionId) {
128
157
  const id = nanoid();
129
158
  const createdAt = new Date().toISOString();
130
159
  let replayable = false;
131
160
  // Best-effort persist — don't let FK violation (deleted workspace) break the broadcast
132
161
  try {
133
- const db = getDb();
134
- db.prepare('INSERT INTO ws_events (id, workspace_id, type, payload, session_id, created_at) VALUES (?, ?, ?, ?, ?, ?)').run(id, workspaceId, type, JSON.stringify(payload), sessionId ?? null, createdAt);
162
+ insertEventStatement().run(id, workspaceId, type, JSON.stringify(payload), sessionId ?? null, createdAt);
135
163
  replayable = true;
136
164
  }
137
165
  catch (err) {
@@ -1,5 +1,6 @@
1
1
  import { nanoid } from 'nanoid';
2
2
  import { getDb } from '../db/index.js';
3
+ import { isValidBranchName } from '../utils/git-ops.js';
3
4
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
4
5
  import * as orchestrator from './agent/orchestrator.js';
5
6
  import * as autoLoopService from './auto-loop-service.js';
@@ -26,7 +27,11 @@ const VALID_TRANSITIONS = {
26
27
  // session can hit an interactive tool-approval/question before its status
27
28
  // flips to `executing` — the same reasoning as the entering-quota comment
28
29
  // above, just for the other direction.
29
- quota: ['idle', 'executing', 'awaiting-user'],
30
+ // `error` is the escape hatch when the retry machinery itself fails: the
31
+ // status is moved to `quota` before the backoff is armed, so a failure in
32
+ // between would otherwise strand the workspace there — no timer, and no
33
+ // banner either, since QuotaBackoffBanner needs a backoff row to render.
34
+ quota: ['idle', 'executing', 'awaiting-user', 'error'],
30
35
  };
31
36
  // NOTE: `engine` is stored as a plain TEXT column and returned as a `string` on
32
37
  // the `Workspace` interface rather than the stricter `EngineId` union. The DB
@@ -73,6 +78,7 @@ function mapWorkspace(row) {
73
78
  prCiFailureDismissedAt: row.pr_ci_failure_dismissed_at,
74
79
  worktreePurgedAt: row.worktree_purged_at,
75
80
  worktreePurgeRestoreData: row.worktree_purge_restore_data,
81
+ comparisonId: row.comparison_id ?? null,
76
82
  engine: row.engine ?? 'claude-code',
77
83
  autoLoop: row.auto_loop === 1,
78
84
  autoLoopReady: row.auto_loop_ready === 1,
@@ -125,9 +131,12 @@ export function createWorkspace(data) {
125
131
  INSERT INTO workspaces (
126
132
  id, name, project_path, source_branch, working_branch, status,
127
133
  notion_url, notion_page_id, sentry_url, pr_url, worktree_path, worktree_owned,
128
- model, brainstorm_model, reasoning_effort, permission_mode, permission_profile, agent_permission_mode, engine, created_at, updated_at
129
- ) VALUES (?, ?, ?, ?, ?, 'created', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
130
- `).run(id, data.name, data.projectPath, data.sourceBranch, data.workingBranch, data.notionUrl ?? null, data.notionPageId ?? null, data.sentryUrl ?? null, data.prUrl ?? null, computedWorktreePath, owned ? 1 : 0, data.model ?? 'claude-opus-4-8', data.brainstormModel ?? null, data.reasoningEffort ?? 'auto', legacyMode, legacyProfile, unifiedMode, data.engine ?? 'claude-code', now, now);
134
+ model, brainstorm_model, reasoning_effort, permission_mode, permission_profile, agent_permission_mode, engine, comparison_id, created_at, updated_at
135
+ ) VALUES (?, ?, ?, ?, ?, 'created', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
136
+ `).run(id, data.name, data.projectPath, data.sourceBranch, data.workingBranch, data.notionUrl ?? null, data.notionPageId ?? null, data.sentryUrl ?? null, data.prUrl ?? null, computedWorktreePath, owned ? 1 : 0, data.model ?? 'claude-opus-4-8', data.brainstormModel ?? null, data.reasoningEffort ?? 'auto', legacyMode, legacyProfile, unifiedMode, data.engine ?? 'claude-code',
137
+ // Empty string is not a group: it would silently gather every workspace
138
+ // created with a blank id into one comparison.
139
+ data.comparisonId?.trim() ? data.comparisonId : null, now, now);
131
140
  return getWorkspace(id);
132
141
  }
133
142
  /** Fetch a single workspace by ID, or null if not found. */
@@ -136,6 +145,21 @@ export function getWorkspace(id) {
136
145
  const row = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(id);
137
146
  return row ? mapWorkspace(row) : null;
138
147
  }
148
+ /**
149
+ * Every workspace sharing a comparison id, oldest first — the order they were
150
+ * created in, which is the order the engines were picked.
151
+ *
152
+ * An empty or missing id matches nothing: `comparison_id` is NULL for the vast
153
+ * majority of workspaces, and returning them all here would be a footgun.
154
+ */
155
+ export function listComparisonMembers(comparisonId) {
156
+ if (!comparisonId.trim())
157
+ return [];
158
+ const rows = getDb()
159
+ .prepare('SELECT * FROM workspaces WHERE comparison_id = ? ORDER BY created_at, id')
160
+ .all(comparisonId);
161
+ return rows.map(mapWorkspace);
162
+ }
139
163
  /** List all workspaces, optionally including archived ones. Ordered by most recently updated. */
140
164
  export function listWorkspaces(includeArchived = false) {
141
165
  const db = getDb();
@@ -275,6 +299,14 @@ export function updateWorkspaceSourceBranch(id, sourceBranch) {
275
299
  if (!sanitized) {
276
300
  throw new Error('Source branch cannot be empty');
277
301
  }
302
+ // The stored value reaches `git fetch origin <sourceBranch>` as a bare
303
+ // argument, from paths as ordinary as opening the Diff tab, so a name git
304
+ // would read as an option must never land here. Guarding the writer rather
305
+ // than each route covers the pr-watcher too, which takes the base the forge
306
+ // reports — and a leading dash is a perfectly valid ref name upstream.
307
+ if (!isValidBranchName(sanitized)) {
308
+ throw new Error(`Invalid source branch name: ${sanitized}`);
309
+ }
278
310
  const db = getDb();
279
311
  const now = new Date().toISOString();
280
312
  const result = db
@@ -20,7 +20,7 @@ export async function purgeWorktree(workspaceId) {
20
20
  // `removeWorktree` runs later in this same function — the agent must be dead
21
21
  // by then, not merely asked to stop.
22
22
  try {
23
- await agentManager.stopAgentAndWait(workspaceId);
23
+ await agentManager.stopAgentAndWait(workspaceId, undefined, 'purge');
24
24
  }
25
25
  catch (err) {
26
26
  const msg = err instanceof Error ? err.message : String(err);
@@ -67,6 +67,28 @@ async function gitAsync(repoPath, args, timeout = READ_ONLY_GIT_TIMEOUT_MS) {
67
67
  });
68
68
  return stdout.trimEnd();
69
69
  }
70
+ /**
71
+ * Async counterpart of `git()` for the operations that talk to a remote.
72
+ *
73
+ * Same 60 s budget and, crucially, the same non-interactive environment: a
74
+ * credential or passphrase prompt on a background process would hang for the
75
+ * whole timeout with nobody to answer it. `gitAsync` above omits that env
76
+ * because it was written for local read-only commands.
77
+ *
78
+ * These run on HTTP handlers, and `execFileSync` there blocks the single
79
+ * thread that also serves the WebSocket streams and the agent events: one push
80
+ * over a slow link froze the entire app for up to a minute, heartbeat included.
81
+ */
82
+ async function gitNetworkAsync(repoPath, args) {
83
+ const { stdout } = await execFileAsync('git', args, {
84
+ cwd: repoPath,
85
+ encoding: 'utf-8',
86
+ timeout: SYNC_GIT_TIMEOUT_MS,
87
+ maxBuffer: GIT_MAX_BUFFER_BYTES,
88
+ env: NON_INTERACTIVE_GIT_ENV,
89
+ });
90
+ return stdout.trimEnd();
91
+ }
70
92
  /** Raised when a stale `index.lock` blocks every write on a worktree. */
71
93
  export class GitIndexLockError extends Error {
72
94
  lockPath;
@@ -385,6 +407,114 @@ export function mergeBranch(repoPath, baseBranch, opts) {
385
407
  throw new Error(`Merge of 'origin/${baseBranch}' failed: ${message}`);
386
408
  }
387
409
  }
410
+ /**
411
+ * Async variants of the network operations, for the HTTP handlers.
412
+ *
413
+ * They mirror their synchronous twins one for one — same arguments, same
414
+ * errors, same conflict and dirty-tree detection — so a caller only swaps the
415
+ * name and adds `await`. Only the remote round-trip is async: the error paths
416
+ * below read local state (conflicted files, ongoing operation, working tree),
417
+ * which is fast and stays synchronous.
418
+ */
419
+ export async function pushBranchAsync(repoPath, branchName, options = {}) {
420
+ const remote = options.remote ?? 'origin';
421
+ const args = ['push', '-u'];
422
+ if (options.force)
423
+ args.push('--force-with-lease');
424
+ args.push(remote, branchName);
425
+ try {
426
+ await gitNetworkAsync(repoPath, args);
427
+ }
428
+ catch (err) {
429
+ const message = err instanceof Error ? err.message : String(err);
430
+ throw new Error(`Failed to push branch '${branchName}' to '${remote}': ${message}`);
431
+ }
432
+ }
433
+ export async function fetchAllBranchesAsync(repoPath, remote = 'origin') {
434
+ try {
435
+ await gitNetworkAsync(repoPath, ['fetch', remote]);
436
+ }
437
+ catch (err) {
438
+ const message = err instanceof Error ? err.message : String(err);
439
+ throw new Error(`Failed to fetch from '${remote}': ${message}`);
440
+ }
441
+ }
442
+ export async function pullBranchAsync(repoPath, branchName, remote = 'origin', opts) {
443
+ if (!opts?.autostash) {
444
+ const status = getWorkingTreeStatus(repoPath);
445
+ if (status.staged > 0 || status.modified > 0) {
446
+ throw new DirtyWorktreeError('pull', status);
447
+ }
448
+ }
449
+ try {
450
+ const args = ['pull', '--ff-only'];
451
+ if (opts?.autostash)
452
+ args.push('--autostash');
453
+ args.push(remote, branchName);
454
+ await gitNetworkAsync(repoPath, args);
455
+ }
456
+ catch (err) {
457
+ if (err instanceof DirtyWorktreeError)
458
+ throw err;
459
+ const message = err instanceof Error ? err.message : String(err);
460
+ throw new Error(`Failed to pull branch '${branchName}' from '${remote}': ${message}`);
461
+ }
462
+ }
463
+ export async function rebaseBranchAsync(repoPath, baseBranch, opts) {
464
+ try {
465
+ await gitNetworkAsync(repoPath, ['fetch', 'origin', baseBranch]);
466
+ }
467
+ catch {
468
+ // fetch may fail if offline — continue with local ref
469
+ }
470
+ try {
471
+ const args = ['rebase'];
472
+ if (opts?.autostash)
473
+ args.push('--autostash');
474
+ args.push(`origin/${baseBranch}`);
475
+ await gitNetworkAsync(repoPath, args);
476
+ }
477
+ catch (err) {
478
+ const conflicted = getConflictedFiles(repoPath);
479
+ if (conflicted.length > 0 || getOngoingGitOperation(repoPath) === 'rebase') {
480
+ // Leave the rebase in progress so the caller can abort or request agent-assisted resolution.
481
+ throw new GitConflictError('rebase', conflicted);
482
+ }
483
+ const status = getWorkingTreeStatus(repoPath);
484
+ if (status.staged > 0 || status.modified > 0) {
485
+ throw new DirtyWorktreeError('rebase', status);
486
+ }
487
+ const message = err instanceof Error ? err.message : String(err);
488
+ throw new Error(`Rebase onto '${baseBranch}' failed: ${message}`);
489
+ }
490
+ }
491
+ export async function mergeBranchAsync(repoPath, baseBranch, opts) {
492
+ try {
493
+ await gitNetworkAsync(repoPath, ['fetch', 'origin', baseBranch]);
494
+ }
495
+ catch {
496
+ // offline — continue with local ref
497
+ }
498
+ try {
499
+ const args = ['merge', '--no-ff', '--no-edit'];
500
+ if (opts?.autostash)
501
+ args.push('--autostash');
502
+ args.push(`origin/${baseBranch}`);
503
+ await gitNetworkAsync(repoPath, args);
504
+ }
505
+ catch (err) {
506
+ const conflicted = getConflictedFiles(repoPath);
507
+ if (conflicted.length > 0 || getOngoingGitOperation(repoPath) === 'merge') {
508
+ throw new GitConflictError('merge', conflicted);
509
+ }
510
+ const status = getWorkingTreeStatus(repoPath);
511
+ if (status.staged > 0 || status.modified > 0) {
512
+ throw new DirtyWorktreeError('merge', status);
513
+ }
514
+ const message = err instanceof Error ? err.message : String(err);
515
+ throw new Error(`Merge of 'origin/${baseBranch}' failed: ${message}`);
516
+ }
517
+ }
388
518
  /** Abort an in-progress merge, rebase or cherry-pick. No-op if nothing is in progress. */
389
519
  export function abortOngoingGitOperation(repoPath) {
390
520
  const op = getOngoingGitOperation(repoPath);
@@ -1130,6 +1260,10 @@ export function isGitWorktree(repoPath) {
1130
1260
  * The remaining rules mirror `git check-ref-format`.
1131
1261
  */
1132
1262
  export function isValidBranchName(name) {
1263
+ // Guard the type too: a non-string would otherwise be coerced, and the
1264
+ // string "undefined" happens to satisfy the pattern below.
1265
+ if (typeof name !== 'string')
1266
+ return false;
1133
1267
  if (!/^[A-Za-z0-9][A-Za-z0-9/_.-]*$/.test(name))
1134
1268
  return false;
1135
1269
  if (name.includes('..') || name.includes('//'))
@@ -1303,9 +1437,33 @@ export async function getUnpushedCountAsync(repoPath, workingBranch) {
1303
1437
  * Mirrors the sync `fetchSourceBranch` sibling, including the optional `remote`
1304
1438
  * parameter (defaults to `'origin'`).
1305
1439
  */
1440
+ /**
1441
+ * Async twin of `fetchSourceBranch`: it PROPAGATES the failure.
1442
+ *
1443
+ * Distinct from `fetchSourceBranchAsync` right below, which swallows it — that
1444
+ * one is the pr-watcher's best-effort refresh, where a missing remote is not
1445
+ * worth interrupting a background sweep. Workspace creation needs the opposite:
1446
+ * a source branch that cannot be fetched is a 422, not a workspace built on a
1447
+ * stale ref.
1448
+ */
1449
+ export async function fetchSourceBranchOrThrowAsync(repoPath, sourceBranch, remote = 'origin') {
1450
+ try {
1451
+ await gitNetworkAsync(repoPath, ['fetch', remote, sourceBranch]);
1452
+ }
1453
+ catch (err) {
1454
+ const message = err instanceof Error ? err.message : String(err);
1455
+ throw new Error(`Failed to fetch '${sourceBranch}' from '${remote}': ${message}`);
1456
+ }
1457
+ }
1306
1458
  export async function fetchSourceBranchAsync(repoPath, branch, remote = 'origin') {
1307
1459
  try {
1308
- await execFileAsync('git', ['fetch', remote, branch], { cwd: repoPath, timeout: 30_000 });
1460
+ // Non-interactive env like every other network call: without it a
1461
+ // credential or passphrase prompt hangs this fetch for its whole timeout.
1462
+ await execFileAsync('git', ['fetch', remote, branch], {
1463
+ cwd: repoPath,
1464
+ timeout: 30_000,
1465
+ env: NON_INTERACTIVE_GIT_ENV,
1466
+ });
1309
1467
  }
1310
1468
  catch (err) {
1311
1469
  const msg = err instanceof Error ? err.message : String(err);
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { isPathInside } from './safe-path.js';
5
6
  // Package root resolved from this file's location via pure path arithmetic
6
7
  // (no filesystem calls, so this stays robust when tests mock `node:fs`).
7
8
  // This file lives at `<root>/src/server/utils/paths.ts` in dev (tsx) and at
@@ -144,3 +145,21 @@ export function getClientSpaPath() {
144
145
  const pwa = getPackageAssetPath('src', 'client', 'dist', 'pwa');
145
146
  return fs.existsSync(pwa) ? pwa : null;
146
147
  }
148
+ /**
149
+ * The file to serve for a SPA request, or null for "404".
150
+ *
151
+ * Kept pure (dist path in, file path out) because the traversal guard that
152
+ * used to live inline in index.ts once rejected `/` itself: the root resolves
153
+ * to the dist directory exactly, which a `startsWith(dist + sep)` check does
154
+ * not accept. `isPathInside` treats the root as inside, and this function has
155
+ * a test for the one URL every install opens first.
156
+ */
157
+ export function resolveSpaFile(clientDistPath, pathname) {
158
+ const root = path.resolve(clientDistPath);
159
+ const candidate = path.resolve(root, `.${pathname}`);
160
+ if (!isPathInside(root, candidate))
161
+ return null;
162
+ // Client-side routes and directories fall back to the shell.
163
+ const file = fs.existsSync(candidate) && !fs.statSync(candidate).isDirectory() ? candidate : path.join(root, 'index.html');
164
+ return fs.existsSync(file) ? file : null;
165
+ }
@@ -41,8 +41,28 @@ export function resolvePathInside(rootPath, relativePath) {
41
41
  return resolveExistingPathInside(root, resolved);
42
42
  }
43
43
  catch (err) {
44
+ if (err.code !== 'ENOENT')
45
+ throw err;
46
+ // The leaf does not exist. That is legitimate — this is how a new file gets
47
+ // created — unless the leaf is itself a symlink whose target is missing:
48
+ // realpath gives up with ENOENT on those too, and returning the unresolved
49
+ // path lets `writeFileSync` follow the link on O_CREAT and write outside the
50
+ // root. A repository that ships `notes.md -> ~/.bashrc.d/x.sh` would get a
51
+ // write there the next time the user saves.
52
+ assertLeafIsNotSymlink(resolved);
53
+ return resolved;
54
+ }
55
+ }
56
+ /** Reject a leaf that is a symlink, dangling or not. */
57
+ function assertLeafIsNotSymlink(candidate) {
58
+ try {
59
+ if (fs.lstatSync(candidate).isSymbolicLink())
60
+ throw new Error('Path escapes allowed root');
61
+ }
62
+ catch (err) {
63
+ // Genuinely absent: nothing to follow, nothing to reject.
44
64
  if (err.code === 'ENOENT')
45
- return resolved;
65
+ return;
46
66
  throw err;
47
67
  }
48
68
  }
@@ -11,7 +11,7 @@ export const SCRIPT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
11
11
  * Shared mechanism behind the setup and cleanup script services.
12
12
  */
13
13
  export function runScript(opts) {
14
- const { workspaceId, worktreePath, script, eventPrefix, tmpFileName, env } = opts;
14
+ const { workspaceId, worktreePath, script, eventPrefix, tmpFileName, env, extraEnv } = opts;
15
15
  const timeoutMs = opts.timeoutMs ?? SCRIPT_TIMEOUT_MS;
16
16
  return new Promise((resolve) => {
17
17
  const scriptPath = path.join(worktreePath, '.ai', tmpFileName);
@@ -21,6 +21,7 @@ export function runScript(opts) {
21
21
  cwd: worktreePath,
22
22
  env: {
23
23
  ...process.env,
24
+ ...Object.fromEntries(Object.entries(extraEnv ?? {}).filter(([key]) => key.startsWith('KOBO_'))),
24
25
  WORKSPACE_ID: workspaceId,
25
26
  WORKSPACE_NAME: env?.workspaceName ?? '',
26
27
  BRANCH_NAME: env?.branchName ?? '',
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Mirrors the private `WORKSPACE_NAME_MAX_LENGTH` in workspace-service.ts.
3
+ * Duplicated rather than exported to avoid coupling this helper to that
4
+ * module's internals and to the many `vi.mock(workspace-service.js, factory)`
5
+ * test files that would otherwise need updating.
6
+ */
7
+ const WORKSPACE_NAME_MAX_LENGTH = 200;
8
+ /**
9
+ * Cap an auto-derived title (Notion page title, Sentry issue title, PR/MR
10
+ * title) before handing it to `updateWorkspaceName`, which throws on anything
11
+ * longer. Right for a name the user typed; a long exception message pulled
12
+ * from Sentry is not user input, and truncating it is the sane default.
13
+ */
14
+ export function truncateWorkspaceName(name) {
15
+ if (name.length <= WORKSPACE_NAME_MAX_LENGTH)
16
+ return name;
17
+ return `${name.slice(0, WORKSPACE_NAME_MAX_LENGTH - 1)}…`;
18
+ }
19
+ /**
20
+ * The create page sends `workspace` when the user typed no name, and an engine
21
+ * comparison sends `workspace (Claude Code)` / `workspace (OpenAI Codex)`. Both
22
+ * are placeholders the extracted title should replace; the suffix is what
23
+ * tells the two halves apart and must survive the rename.
24
+ */
25
+ const PLACEHOLDER = /^workspace( \([^()]+\))?$/;
26
+ /**
27
+ * The name a workspace should take once a title was extracted for it, or
28
+ * `null` when the current name is the user's own and must be left alone.
29
+ */
30
+ export function resolveExtractedName(currentName, extractedTitle) {
31
+ const match = PLACEHOLDER.exec(currentName);
32
+ if (!match)
33
+ return null;
34
+ const suffix = match[1] ?? '';
35
+ // Truncate the title alone so the engine suffix is never what gets cut.
36
+ return `${truncateWorkspaceName(extractedTitle)}${suffix}`;
37
+ }
@@ -1 +1,23 @@
1
1
  export const WORKTREES_PATH = '.worktrees';
2
+ /**
3
+ * Global settings holding a live credential. They must never leave the server
4
+ * in a payload a third party can read: they are blanked out of the config
5
+ * bundle, of the settings the HTTP API returns, and of what the MCP tools hand
6
+ * to the agent. Add any new credential here rather than filtering it at one
7
+ * call site.
8
+ *
9
+ * Lives in `shared` on purpose: the MCP server and the HTTP layer both need it,
10
+ * and a test that mocks `settings-service` must not be able to mock the list of
11
+ * secrets away.
12
+ */
13
+ export const SECRET_GLOBAL_KEYS = ['notionMcpKey', 'sentryMcpKey', 'networkAccessToken', 'bitbucketToken'];
14
+ /**
15
+ * Stand-in the API sends instead of a stored credential. A client never
16
+ * receives the real value, so the settings form round-trips this mask on save
17
+ * and the write path reads it as "leave the stored one alone". Typing a new
18
+ * value still replaces the secret, and clearing the field still erases it —
19
+ * only an untouched field is preserved.
20
+ */
21
+ export const MASKED_SECRET = '••••••••';
22
+ /** The single character `MASKED_SECRET` is made of. */
23
+ export const MASK_CHARACTER = '•';
@@ -6,10 +6,10 @@ export const CLAUDE_MODELS = [
6
6
  i18nDescriptionKey: 'model.autoDescription',
7
7
  },
8
8
  {
9
- id: 'claude-fable-5',
10
- label: 'Fable 5',
11
- i18nLabelKey: 'model.fable5',
12
- i18nDescriptionKey: 'model.fable5Description',
9
+ id: 'claude-fable-5-1',
10
+ label: 'Fable 5.1',
11
+ i18nLabelKey: 'model.fable51',
12
+ i18nDescriptionKey: 'model.fable51Description',
13
13
  },
14
14
  {
15
15
  id: 'claude-mythos-5',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loicngr/kobo",
3
- "version": "1.11.26",
3
+ "version": "1.11.28",
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",
@@ -48,7 +48,7 @@
48
48
  "node": ">=24.15.0"
49
49
  },
50
50
  "scripts": {
51
- "dev": "KOBO_ENFORCE_LOCAL_HOME=1 KOBO_HOME=./data PORT=3300 tsx watch src/server/index.ts",
51
+ "dev": "KOBO_ENFORCE_LOCAL_HOME=1 KOBO_HOME=./data PORT=3300 KOBO_DEV_CLIENT_ORIGIN=http://localhost:8080 tsx watch src/server/index.ts",
52
52
  "dev:client": "cd src/client && KOBO_BACKEND_PORT=3300 npx quasar dev",
53
53
  "dev:all": "concurrently \"npm run dev\" \"npm run dev:client\"",
54
54
  "build:client": "cd src/client && npx quasar build -m pwa",
@@ -60,6 +60,7 @@
60
60
  "test:watch": "vitest --config vitest.config.ts --root .",
61
61
  "test:client": "cd src/client && npm test",
62
62
  "test:all": "npm test && npm run test:client",
63
+ "typecheck:tests": "tsc -p tsconfig.test.json",
63
64
  "lint": "biome check .",
64
65
  "lint:fix": "biome check --write .",
65
66
  "format": "biome format --write .",
@@ -1 +1 @@
1
- .agent-error-item[data-v-e98c3c02]{gap:var(--kobo-space-sm);padding:var(--kobo-space-sm) var(--kobo-space-md);border-left:2px solid var(--kobo-danger);background:var(--kobo-surface-2);border-radius:var(--kobo-radius-sm);align-items:flex-start;display:flex}.agent-error-item__icon[data-v-e98c3c02]{color:var(--kobo-danger);flex:none;margin-top:2px}.agent-error-item__body[data-v-e98c3c02]{min-width:0}.agent-error-item__title[data-v-e98c3c02]{color:var(--kobo-text);font-weight:600}.agent-error-item__message[data-v-e98c3c02]{color:var(--kobo-text-2);font-family:var(--kobo-font-mono);white-space:pre-wrap;word-break:break-word;font-size:12px}.markdown-message[data-v-539815f6]{color:var(--kobo-text-2);word-break:break-word;overflow-wrap:anywhere;min-width:0;max-width:100%;font-size:13px;line-height:1.55}.markdown-message[data-v-539815f6] *{max-width:100%}.markdown-message[data-v-539815f6] p{margin:0 0 .5em}.markdown-message[data-v-539815f6] p:last-child{margin-bottom:0}.markdown-message[data-v-539815f6] pre{background:#00000059;border-radius:4px;margin:.5em 0;padding:.5em .75em;overflow-x:auto}.markdown-message[data-v-539815f6] code{word-break:break-all;background:#0000004d;border-radius:3px;padding:.1em .3em;font-size:.9em}.markdown-message[data-v-539815f6] pre code{background:0 0;padding:0}.markdown-message[data-v-539815f6] ul,.markdown-message[data-v-539815f6] ol{margin:.25em 0 .5em;padding-left:1.5em}.markdown-message[data-v-539815f6] li{margin:.15em 0}.markdown-message[data-v-539815f6] a{color:var(--kobo-text-2);text-decoration:underline}.markdown-message[data-v-539815f6] .document-link{color:var(--kobo-text-2);cursor:pointer;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.markdown-message[data-v-539815f6] .document-link:hover{color:var(--kobo-text);-webkit-text-decoration:underline;text-decoration:underline}.markdown-message[data-v-539815f6] h1,.markdown-message[data-v-539815f6] h2,.markdown-message[data-v-539815f6] h3,.markdown-message[data-v-539815f6] h4,.markdown-message[data-v-539815f6] h5,.markdown-message[data-v-539815f6] h6{margin:.5em 0 .3em;font-weight:600;line-height:1.3}.markdown-message[data-v-539815f6] h1{font-size:1.25em}.markdown-message[data-v-539815f6] h2{font-size:1.15em}.markdown-message[data-v-539815f6] h3{font-size:1.08em}.markdown-message[data-v-539815f6] h4,.markdown-message[data-v-539815f6] h5,.markdown-message[data-v-539815f6] h6{font-size:1em}.markdown-message[data-v-539815f6] blockquote{color:#ffffffb3;border-left:3px solid #fff3;margin:.5em 0;padding-left:.75em}.markdown-message[data-v-539815f6] table{border-collapse:collapse;margin:.5em 0}.markdown-message[data-v-539815f6] th,.markdown-message[data-v-539815f6] td{border:1px solid #ffffff26;padding:.25em .5em}.tool-row[data-v-2482adfe]{border-radius:4px;margin:0;font-size:12px}.tool-header[data-v-2482adfe]{color:var(--kobo-text-2);cursor:default;align-items:center;gap:10px;min-width:0;padding:5px 10px;display:flex}.tool-row:not(.tool-row-generic) .tool-header[data-v-2482adfe],.tool-row--toggleable .tool-header[data-v-2482adfe]{cursor:pointer}.tool-row:has(.tool-diff) .tool-header[data-v-2482adfe]{cursor:pointer}.tool-row:not(.tool-row-generic) .tool-header[data-v-2482adfe]:hover,.tool-row--toggleable .tool-header[data-v-2482adfe]:hover{background:#ffffff08}.tool-icon[data-v-2482adfe]{color:var(--kobo-text-2);flex-shrink:0}.tool-name[data-v-2482adfe]{color:var(--kobo-text-2);flex-shrink:0;font-weight:600}.tool-arg[data-v-2482adfe],.tool-path[data-v-2482adfe]{color:var(--kobo-text-3);text-overflow:ellipsis;white-space:nowrap;min-width:0;max-width:100%;font-family:var(--kobo-font-mono);font-size:11.5px;overflow:hidden}.tool-open-diff[data-v-2482adfe]{flex-shrink:0}.tool-path[data-v-2482adfe],.tool-arg[data-v-2482adfe]{flex:1}.tool-stat-add[data-v-2482adfe]{color:var(--kobo-success);flex-shrink:0;font-size:11px;font-weight:600}.tool-stat-del[data-v-2482adfe]{color:var(--kobo-danger);flex-shrink:0;font-size:11px;font-weight:600}.tool-diff[data-v-2482adfe]{max-height:400px;font-family:var(--kobo-font-mono);background:#0003;border-radius:4px;margin-top:4px;padding:8px 0;font-size:11px;line-height:1.5;overflow:auto}.diff-line[data-v-2482adfe]{white-space:pre;color:var(--kobo-text-2);padding:0 12px}.diff-sign[data-v-2482adfe]{width:14px;color:var(--kobo-text-disabled);-webkit-user-select:none;user-select:none;display:inline-block}.diff-add[data-v-2482adfe]{color:var(--kobo-success);background:#66bb6a1a}.diff-add .diff-sign[data-v-2482adfe]{color:var(--kobo-success)}.diff-del[data-v-2482adfe]{color:var(--kobo-danger);background:#ef53501a}.diff-del .diff-sign[data-v-2482adfe]{color:var(--kobo-danger)}.tool-output[data-v-2482adfe]{color:var(--kobo-text-2);font-family:var(--kobo-font-mono);white-space:pre-wrap;background:#00000026;border-radius:4px;max-height:8em;margin-top:4px;padding:6px 10px;font-size:11px;overflow:auto}.markdown-message[data-v-7687eb7e]{color:var(--kobo-text-2);word-break:break-word;overflow-wrap:anywhere;min-width:0;max-width:100%;font-size:13px;line-height:1.55}.markdown-message[data-v-7687eb7e] *{max-width:100%}.markdown-message[data-v-7687eb7e] img{-o-object-fit:contain;object-fit:contain;cursor:zoom-in;background:#0003;border-radius:4px;max-width:180px;max-height:100px;margin:.3em 0;display:block}.markdown-message[data-v-7687eb7e] code{word-break:break-all}.markdown-message[data-v-7687eb7e] p{margin:0 0 .4em}.markdown-message[data-v-7687eb7e] p:last-child{margin-bottom:0}.markdown-message[data-v-7687eb7e] code{background:#00000040;border-radius:3px;padding:.1em .3em}.markdown-message[data-v-7687eb7e] h1,.markdown-message[data-v-7687eb7e] h2,.markdown-message[data-v-7687eb7e] h3,.markdown-message[data-v-7687eb7e] h4,.markdown-message[data-v-7687eb7e] h5,.markdown-message[data-v-7687eb7e] h6{margin:.4em 0 .25em;font-weight:600;line-height:1.3}.markdown-message[data-v-7687eb7e] h1{font-size:1.25em}.markdown-message[data-v-7687eb7e] h2{font-size:1.15em}.markdown-message[data-v-7687eb7e] h3{font-size:1.08em}.markdown-message[data-v-7687eb7e] h4,.markdown-message[data-v-7687eb7e] h5,.markdown-message[data-v-7687eb7e] h6{font-size:1em}.markdown-user-prompt[data-v-7687eb7e]{color:var(--kobo-text-2);font-size:12px;font-style:italic}.markdown-user-prompt[data-v-7687eb7e] p{margin:0 0 .4em}.markdown-user-prompt[data-v-7687eb7e] code{background:#ffffff14;border-radius:3px;padding:.1em .3em;font-style:normal}.image-lightbox-img{-o-object-fit:contain;object-fit:contain;cursor:zoom-out;background:#0000004d;border-radius:4px;max-width:92vw;max-height:92vh;display:block}.turn-card[data-v-59df7154]{border:1px solid #ffffff14;border-left:3px solid var(--turn-accent);background:#ffffff05;border-radius:6px;min-width:0;max-width:100%;margin:14px 0;overflow:hidden}.turn-card--highlighted[data-v-59df7154]{animation:1.8s ease-out search-highlight-59df7154}@keyframes search-highlight-59df7154{0%,45%{box-shadow:0 0 0 2px #818cf8f2,0 0 22px #818cf873}to{box-shadow:none}}.turn-header[data-v-59df7154]{color:var(--kobo-text-3);background:#ffffff08;border-bottom:1px solid #ffffff0d;align-items:center;gap:8px;padding:8px 14px;font-size:11px;display:flex}.turn-badge[data-v-59df7154]{letter-spacing:.3px;border-radius:3px;padding:2px 8px;font-size:11px;font-weight:700}.turn-badge-user[data-v-59df7154]{color:var(--kobo-turn-user);background:#ce93d826}.turn-badge-agent[data-v-59df7154]{color:var(--kobo-turn-agent);background:#7986cb26}.turn-badge-system[data-v-59df7154]{color:var(--kobo-text-2);background:#75757533;font-style:italic}.turn-badge-session[data-v-59df7154]{color:var(--kobo-text-3);background:#61616133}.turn-badge-script[data-v-59df7154]{color:var(--kobo-success);background:#4db6ac26}.turn-time[data-v-59df7154]{color:var(--kobo-text-disabled);font-family:var(--kobo-font-mono);font-size:11px}.turn-time-arrow[data-v-59df7154]{opacity:.7;margin:0 -2px}.turn-time-updated[data-v-59df7154]{color:var(--kobo-text-3)}.turn-actions[data-v-59df7154]{color:var(--kobo-text-3);font-size:11px}.turn-body[data-v-59df7154]{flex-direction:column;gap:12px;min-width:0;padding:14px 18px;display:flex}.turn-body[data-v-59df7154]>*{min-width:0;max-width:100%}.turn-body[data-v-59df7154] .tool-row+.tool-row{margin-top:-8px}.turn-scroll-top[data-v-59df7154]{justify-content:flex-start;padding:0 8px 6px;display:flex}.turn-scroll-top-btn[data-v-59df7154]{opacity:.5;transition:opacity .15s}.turn-scroll-top-btn[data-v-59df7154]:hover{opacity:1}.activity-feed-wrap[data-v-03fed8f2]{width:100%;height:100%;position:relative}.activity-feed-scroll[data-v-03fed8f2]{width:100%;height:100%}.activity-feed-nav-cluster[data-v-03fed8f2]{z-index:2;align-items:center;gap:8px;display:flex;position:absolute;bottom:14px;right:14px}.activity-feed-compacting[data-v-03fed8f2]{z-index:3;background:var(--kobo-surface);border:1px solid var(--kobo-border);border-radius:6px;align-items:center;gap:8px;padding:6px 14px;display:flex;position:absolute;top:12px;left:50%;transform:translate(-50%);box-shadow:0 4px 16px #00000059}.fade-enter-active[data-v-03fed8f2],.fade-leave-active[data-v-03fed8f2]{transition:opacity .16s}.fade-enter-from[data-v-03fed8f2],.fade-leave-to[data-v-03fed8f2]{opacity:0}.activity-feed-nav-btn[data-v-03fed8f2]{opacity:.8;transition:opacity .12s}.activity-feed-nav-btn[data-v-03fed8f2]:hover{opacity:1}.activity-feed-scroll[data-v-03fed8f2] .q-scrollarea__content{max-width:100%;overflow-x:hidden}.activity-feed-switching[data-v-03fed8f2]{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.activity-feed-empty[data-v-03fed8f2]{align-items:center;gap:var(--kobo-space-sm);padding:var(--kobo-space-4xl) var(--kobo-space-xl);color:var(--kobo-text-3);text-align:center;flex-direction:column;display:flex}.activity-feed-empty__title[data-v-03fed8f2]{font-family:var(--kobo-font-sans);color:var(--kobo-text-2);font-size:14px;font-weight:500}.activity-feed-empty__hint[data-v-03fed8f2]{font-family:var(--kobo-font-sans);color:var(--kobo-text-3);max-width:380px;font-size:13px}
1
+ .agent-error-item[data-v-e98c3c02]{gap:var(--kobo-space-sm);padding:var(--kobo-space-sm) var(--kobo-space-md);border-left:2px solid var(--kobo-danger);background:var(--kobo-surface-2);border-radius:var(--kobo-radius-sm);align-items:flex-start;display:flex}.agent-error-item__icon[data-v-e98c3c02]{color:var(--kobo-danger);flex:none;margin-top:2px}.agent-error-item__body[data-v-e98c3c02]{min-width:0}.agent-error-item__title[data-v-e98c3c02]{color:var(--kobo-text);font-weight:600}.agent-error-item__message[data-v-e98c3c02]{color:var(--kobo-text-2);font-family:var(--kobo-font-mono);white-space:pre-wrap;word-break:break-word;font-size:12px}.markdown-message[data-v-99371035]{color:var(--kobo-text-2);word-break:break-word;overflow-wrap:anywhere;min-width:0;max-width:100%;font-size:13px;line-height:1.55}.markdown-message[data-v-99371035] *{max-width:100%}.markdown-message[data-v-99371035] p{margin:0 0 .5em}.markdown-message[data-v-99371035] p:last-child{margin-bottom:0}.markdown-message[data-v-99371035] pre{background:#00000059;border-radius:4px;margin:.5em 0;padding:.5em .75em;overflow-x:auto}.markdown-message[data-v-99371035] code{word-break:break-all;background:#0000004d;border-radius:3px;padding:.1em .3em;font-size:.9em}.markdown-message[data-v-99371035] pre code{background:0 0;padding:0}.markdown-message[data-v-99371035] ul,.markdown-message[data-v-99371035] ol{margin:.25em 0 .5em;padding-left:1.5em}.markdown-message[data-v-99371035] li{margin:.15em 0}.markdown-message[data-v-99371035] a{color:var(--kobo-text-2);text-decoration:underline}.markdown-message[data-v-99371035] .document-link{color:var(--kobo-text-2);cursor:pointer;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.markdown-message[data-v-99371035] .document-link:hover{color:var(--kobo-text);-webkit-text-decoration:underline;text-decoration:underline}.markdown-message[data-v-99371035] h1,.markdown-message[data-v-99371035] h2,.markdown-message[data-v-99371035] h3,.markdown-message[data-v-99371035] h4,.markdown-message[data-v-99371035] h5,.markdown-message[data-v-99371035] h6{margin:.5em 0 .3em;font-weight:600;line-height:1.3}.markdown-message[data-v-99371035] h1{font-size:1.25em}.markdown-message[data-v-99371035] h2{font-size:1.15em}.markdown-message[data-v-99371035] h3{font-size:1.08em}.markdown-message[data-v-99371035] h4,.markdown-message[data-v-99371035] h5,.markdown-message[data-v-99371035] h6{font-size:1em}.markdown-message[data-v-99371035] blockquote{color:#ffffffb3;border-left:3px solid #fff3;margin:.5em 0;padding-left:.75em}.markdown-message[data-v-99371035] table{border-collapse:collapse;margin:.5em 0}.markdown-message[data-v-99371035] th,.markdown-message[data-v-99371035] td{border:1px solid #ffffff26;padding:.25em .5em}.tool-row[data-v-2482adfe]{border-radius:4px;margin:0;font-size:12px}.tool-header[data-v-2482adfe]{color:var(--kobo-text-2);cursor:default;align-items:center;gap:10px;min-width:0;padding:5px 10px;display:flex}.tool-row:not(.tool-row-generic) .tool-header[data-v-2482adfe],.tool-row--toggleable .tool-header[data-v-2482adfe]{cursor:pointer}.tool-row:has(.tool-diff) .tool-header[data-v-2482adfe]{cursor:pointer}.tool-row:not(.tool-row-generic) .tool-header[data-v-2482adfe]:hover,.tool-row--toggleable .tool-header[data-v-2482adfe]:hover{background:#ffffff08}.tool-icon[data-v-2482adfe]{color:var(--kobo-text-2);flex-shrink:0}.tool-name[data-v-2482adfe]{color:var(--kobo-text-2);flex-shrink:0;font-weight:600}.tool-arg[data-v-2482adfe],.tool-path[data-v-2482adfe]{color:var(--kobo-text-3);text-overflow:ellipsis;white-space:nowrap;min-width:0;max-width:100%;font-family:var(--kobo-font-mono);font-size:11.5px;overflow:hidden}.tool-open-diff[data-v-2482adfe]{flex-shrink:0}.tool-path[data-v-2482adfe],.tool-arg[data-v-2482adfe]{flex:1}.tool-stat-add[data-v-2482adfe]{color:var(--kobo-success);flex-shrink:0;font-size:11px;font-weight:600}.tool-stat-del[data-v-2482adfe]{color:var(--kobo-danger);flex-shrink:0;font-size:11px;font-weight:600}.tool-diff[data-v-2482adfe]{max-height:400px;font-family:var(--kobo-font-mono);background:#0003;border-radius:4px;margin-top:4px;padding:8px 0;font-size:11px;line-height:1.5;overflow:auto}.diff-line[data-v-2482adfe]{white-space:pre;color:var(--kobo-text-2);padding:0 12px}.diff-sign[data-v-2482adfe]{width:14px;color:var(--kobo-text-disabled);-webkit-user-select:none;user-select:none;display:inline-block}.diff-add[data-v-2482adfe]{color:var(--kobo-success);background:#66bb6a1a}.diff-add .diff-sign[data-v-2482adfe]{color:var(--kobo-success)}.diff-del[data-v-2482adfe]{color:var(--kobo-danger);background:#ef53501a}.diff-del .diff-sign[data-v-2482adfe]{color:var(--kobo-danger)}.tool-output[data-v-2482adfe]{color:var(--kobo-text-2);font-family:var(--kobo-font-mono);white-space:pre-wrap;background:#00000026;border-radius:4px;max-height:8em;margin-top:4px;padding:6px 10px;font-size:11px;overflow:auto}.markdown-message[data-v-7687eb7e]{color:var(--kobo-text-2);word-break:break-word;overflow-wrap:anywhere;min-width:0;max-width:100%;font-size:13px;line-height:1.55}.markdown-message[data-v-7687eb7e] *{max-width:100%}.markdown-message[data-v-7687eb7e] img{-o-object-fit:contain;object-fit:contain;cursor:zoom-in;background:#0003;border-radius:4px;max-width:180px;max-height:100px;margin:.3em 0;display:block}.markdown-message[data-v-7687eb7e] code{word-break:break-all}.markdown-message[data-v-7687eb7e] p{margin:0 0 .4em}.markdown-message[data-v-7687eb7e] p:last-child{margin-bottom:0}.markdown-message[data-v-7687eb7e] code{background:#00000040;border-radius:3px;padding:.1em .3em}.markdown-message[data-v-7687eb7e] h1,.markdown-message[data-v-7687eb7e] h2,.markdown-message[data-v-7687eb7e] h3,.markdown-message[data-v-7687eb7e] h4,.markdown-message[data-v-7687eb7e] h5,.markdown-message[data-v-7687eb7e] h6{margin:.4em 0 .25em;font-weight:600;line-height:1.3}.markdown-message[data-v-7687eb7e] h1{font-size:1.25em}.markdown-message[data-v-7687eb7e] h2{font-size:1.15em}.markdown-message[data-v-7687eb7e] h3{font-size:1.08em}.markdown-message[data-v-7687eb7e] h4,.markdown-message[data-v-7687eb7e] h5,.markdown-message[data-v-7687eb7e] h6{font-size:1em}.markdown-user-prompt[data-v-7687eb7e]{color:var(--kobo-text-2);font-size:12px;font-style:italic}.markdown-user-prompt[data-v-7687eb7e] p{margin:0 0 .4em}.markdown-user-prompt[data-v-7687eb7e] code{background:#ffffff14;border-radius:3px;padding:.1em .3em;font-style:normal}.image-lightbox-img{-o-object-fit:contain;object-fit:contain;cursor:zoom-out;background:#0000004d;border-radius:4px;max-width:92vw;max-height:92vh;display:block}.turn-card[data-v-6e61835e]{border:1px solid #ffffff14;border-left:3px solid var(--turn-accent);background:#ffffff05;border-radius:6px;min-width:0;max-width:100%;margin:14px 0;overflow:hidden}.turn-card--highlighted[data-v-6e61835e]{animation:1.8s ease-out search-highlight-6e61835e}@keyframes search-highlight-6e61835e{0%,45%{box-shadow:0 0 0 2px #818cf8f2,0 0 22px #818cf873}to{box-shadow:none}}.turn-header[data-v-6e61835e]{color:var(--kobo-text-3);background:#ffffff08;border-bottom:1px solid #ffffff0d;align-items:center;gap:8px;padding:8px 14px;font-size:11px;display:flex}.turn-badge[data-v-6e61835e]{letter-spacing:.3px;border-radius:3px;padding:2px 8px;font-size:11px;font-weight:700}.turn-badge-user[data-v-6e61835e]{color:var(--kobo-turn-user);background:#ce93d826}.turn-badge-agent[data-v-6e61835e]{color:var(--kobo-turn-agent);background:#7986cb26}.turn-badge-system[data-v-6e61835e]{color:var(--kobo-text-2);background:#75757533;font-style:italic}.turn-badge-session[data-v-6e61835e]{color:var(--kobo-text-3);background:#61616133}.turn-badge-script[data-v-6e61835e]{color:var(--kobo-success);background:#4db6ac26}.turn-time[data-v-6e61835e]{color:var(--kobo-text-disabled);font-family:var(--kobo-font-mono);font-size:11px}.turn-time-arrow[data-v-6e61835e]{opacity:.7;margin:0 -2px}.turn-time-updated[data-v-6e61835e]{color:var(--kobo-text-3)}.turn-actions[data-v-6e61835e]{color:var(--kobo-text-3);font-size:11px}.turn-body[data-v-6e61835e]{flex-direction:column;gap:12px;min-width:0;padding:14px 18px;display:flex}.turn-body[data-v-6e61835e]>*{min-width:0;max-width:100%}.turn-body[data-v-6e61835e] .tool-row+.tool-row{margin-top:-8px}.turn-scroll-top[data-v-6e61835e]{justify-content:flex-start;padding:0 8px 6px;display:flex}.turn-scroll-top-btn[data-v-6e61835e]{opacity:.5;transition:opacity .15s}.turn-scroll-top-btn[data-v-6e61835e]:hover{opacity:1}.activity-feed-wrap[data-v-03fed8f2]{width:100%;height:100%;position:relative}.activity-feed-scroll[data-v-03fed8f2]{width:100%;height:100%}.activity-feed-nav-cluster[data-v-03fed8f2]{z-index:2;align-items:center;gap:8px;display:flex;position:absolute;bottom:14px;right:14px}.activity-feed-compacting[data-v-03fed8f2]{z-index:3;background:var(--kobo-surface);border:1px solid var(--kobo-border);border-radius:6px;align-items:center;gap:8px;padding:6px 14px;display:flex;position:absolute;top:12px;left:50%;transform:translate(-50%);box-shadow:0 4px 16px #00000059}.fade-enter-active[data-v-03fed8f2],.fade-leave-active[data-v-03fed8f2]{transition:opacity .16s}.fade-enter-from[data-v-03fed8f2],.fade-leave-to[data-v-03fed8f2]{opacity:0}.activity-feed-nav-btn[data-v-03fed8f2]{opacity:.8;transition:opacity .12s}.activity-feed-nav-btn[data-v-03fed8f2]:hover{opacity:1}.activity-feed-scroll[data-v-03fed8f2] .q-scrollarea__content{max-width:100%;overflow-x:hidden}.activity-feed-switching[data-v-03fed8f2]{justify-content:center;align-items:center;width:100%;height:100%;display:flex}.activity-feed-empty[data-v-03fed8f2]{align-items:center;gap:var(--kobo-space-sm);padding:var(--kobo-space-4xl) var(--kobo-space-xl);color:var(--kobo-text-3);text-align:center;flex-direction:column;display:flex}.activity-feed-empty__title[data-v-03fed8f2]{font-family:var(--kobo-font-sans);color:var(--kobo-text-2);font-size:14px;font-weight:500}.activity-feed-empty__hint[data-v-03fed8f2]{font-family:var(--kobo-font-sans);color:var(--kobo-text-3);max-width:380px;font-size:13px}