@loicngr/kobo 1.7.3 → 1.7.5

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 (60) hide show
  1. package/README.md +20 -8
  2. package/dist/mcp-server/kobo-tasks-handlers.js +26 -1
  3. package/dist/mcp-server/kobo-tasks-server.js +41 -1
  4. package/dist/server/db/migrations.js +49 -0
  5. package/dist/server/db/schema.js +11 -0
  6. package/dist/server/index.js +7 -1
  7. package/dist/server/routes/workspaces.js +83 -5
  8. package/dist/server/services/agent/engines/claude-code/engine.js +4 -1
  9. package/dist/server/services/agent/engines/claude-code/event-mapper.js +6 -2
  10. package/dist/server/services/agent/orchestrator.js +72 -71
  11. package/dist/server/services/auto-loop-service.js +8 -0
  12. package/dist/server/services/quota-backoff-service.js +127 -0
  13. package/dist/server/services/settings-service.js +3 -1
  14. package/dist/server/services/workspace-service.js +80 -0
  15. package/dist/server/utils/git-ops.js +48 -9
  16. package/package.json +1 -1
  17. package/src/client/dist/spa/assets/{ActivityFeed-CroojlsI.css → ActivityFeed-DVBfmJWJ.css} +1 -1
  18. package/src/client/dist/spa/assets/{ActivityFeed-CKSqMR2v.js → ActivityFeed-oW9PgZ8E.js} +2 -2
  19. package/src/client/dist/spa/assets/AutoLoopChip-Y53cnGfZ.js +1 -0
  20. package/src/client/dist/spa/assets/{CreatePage-7cP4h19f.js → CreatePage-CuD7sMR7.js} +1 -1
  21. package/src/client/dist/spa/assets/{DiffViewer-CdamEwIg.js → DiffViewer-rc3tE9fq.js} +3 -3
  22. package/src/client/dist/spa/assets/{HealthPage-m4z-x5bo.js → HealthPage-Dz0yGGMB.js} +1 -1
  23. package/src/client/dist/spa/assets/{MainLayout-CQBqYFNx.js → MainLayout-B9i06p7n.js} +17 -17
  24. package/src/client/dist/spa/assets/{MainLayout-DKurmqtk.css → MainLayout-DDa3rGKA.css} +1 -1
  25. package/src/client/dist/spa/assets/{SearchPage-DCRSQycR.js → SearchPage-DdX7JZCD.js} +1 -1
  26. package/src/client/dist/spa/assets/{SettingsPage-DStBGwIj.js → SettingsPage-Dnj1CWc3.js} +1 -1
  27. package/src/client/dist/spa/assets/{WorkspacePage-eymEd4kx.css → WorkspacePage-CCtIrBiR.css} +1 -1
  28. package/src/client/dist/spa/assets/WorkspacePage-DHp20nl-.js +4 -0
  29. package/src/client/dist/spa/assets/{cssMode-o7NS-Oil.js → cssMode-DSB5jkRt.js} +1 -1
  30. package/src/client/dist/spa/assets/{editor.api-CNo9KwlJ.js → editor.api-Bcw50eFD.js} +1 -1
  31. package/src/client/dist/spa/assets/{editor.main-UyvgnhP6.js → editor.main-D9piVGaH.js} +3 -3
  32. package/src/client/dist/spa/assets/{expand-template-DqZgks9E.js → expand-template-BIPuNAYV.js} +1 -1
  33. package/src/client/dist/spa/assets/{freemarker2-BKWtNRQ9.js → freemarker2-CVh_Zh8H.js} +1 -1
  34. package/src/client/dist/spa/assets/{handlebars-BUhKrn3k.js → handlebars-CpCgELpu.js} +1 -1
  35. package/src/client/dist/spa/assets/{html-CrcvRgdj.js → html-ikWDpvWk.js} +1 -1
  36. package/src/client/dist/spa/assets/{htmlMode-Djjp-0pZ.js → htmlMode-C9TTCKih.js} +1 -1
  37. package/src/client/dist/spa/assets/i18n-DZCb8dnb.js +1 -0
  38. package/src/client/dist/spa/assets/index-DuK38XN5.js +2 -0
  39. package/src/client/dist/spa/assets/{javascript-DN_zCJwt.js → javascript-C4OlkNeA.js} +1 -1
  40. package/src/client/dist/spa/assets/{jsonMode-B7uIpwZ9.js → jsonMode-BiD34_86.js} +1 -1
  41. package/src/client/dist/spa/assets/{liquid-f3BGSOBM.js → liquid-Dty0Ui2c.js} +1 -1
  42. package/src/client/dist/spa/assets/{mdx-jpEqsFXp.js → mdx-yiUjOVv6.js} +1 -1
  43. package/src/client/dist/spa/assets/{models-Bj-hfPO2.js → models-BDkLiht9.js} +1 -1
  44. package/src/client/dist/spa/assets/{monaco.contribution-D-UK6jlz.js → monaco.contribution-Bz9yFPWR.js} +2 -2
  45. package/src/client/dist/spa/assets/{purify.es-DyEEb_DH.js → purify.es-BIY760fF.js} +1 -1
  46. package/src/client/dist/spa/assets/{python-CoiTKs0q.js → python-7SPSWQoD.js} +1 -1
  47. package/src/client/dist/spa/assets/{razor-BubwMw_m.js → razor-eagZawXK.js} +1 -1
  48. package/src/client/dist/spa/assets/{render-chat-markdown-DwKtHD8J.js → render-chat-markdown-TvAqpDih.js} +1 -1
  49. package/src/client/dist/spa/assets/{tsMode-k_tAkDr_.js → tsMode-CLYG2xeJ.js} +1 -1
  50. package/src/client/dist/spa/assets/{typescript-DQQR6Y6R.js → typescript-CzOXM8yS.js} +1 -1
  51. package/src/client/dist/spa/assets/{xml-CaSyI8p6.js → xml-2_0_6RAX.js} +1 -1
  52. package/src/client/dist/spa/assets/{yaml-BYsGcXIZ.js → yaml-CtpgNyXs.js} +1 -1
  53. package/src/client/dist/spa/index.html +1 -1
  54. package/src/mcp-server/kobo-tasks-handlers.ts +35 -1
  55. package/src/mcp-server/kobo-tasks-server.ts +42 -0
  56. package/src/client/dist/spa/assets/WorkspacePage-BstBxgN8.js +0 -4
  57. package/src/client/dist/spa/assets/i18n-DD341qPX.js +0 -1
  58. package/src/client/dist/spa/assets/index-DR1y9t94.js +0 -2
  59. /package/src/client/dist/spa/assets/{QPage-ChUKoaKe.js → QPage-DFi3K093.js} +0 -0
  60. /package/src/client/dist/spa/assets/{formatters-BD0_hovB.js → formatters-DCAQ6ANJ.js} +0 -0
@@ -4,7 +4,9 @@ import { getDb } from '../../db/index.js';
4
4
  import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getKoboHome, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../../utils/paths.js';
5
5
  import { unregisterProcess } from '../../utils/process-tracker.js';
6
6
  import * as autoLoopService from '../auto-loop-service.js';
7
+ import * as quotaBackoffService from '../quota-backoff-service.js';
7
8
  import { getEffectiveSettings } from '../settings-service.js';
9
+ import { refreshNow } from '../usage/poller.js';
8
10
  import * as wakeupService from '../wakeup-service.js';
9
11
  import { emit, emitEphemeral } from '../websocket-service.js';
10
12
  import { getWorkspace as getWs, markWorkspaceUnread, updateWorkspaceStatus, } from '../workspace-service.js';
@@ -137,8 +139,6 @@ let availableSkills = (() => {
137
139
  const retryCounts = new Map();
138
140
  /** Tracks workspaces where the current session failed due to a stale --resume session ID. */
139
141
  const resumeFailedSet = new Set();
140
- /** workspaceId -> backoff timer */
141
- const backoffTimers = new Map();
142
142
  // ── Watchdog ──────────────────────────────────────────────────────────────────
143
143
  const WATCHDOG_INTERVAL_MS = 30_000;
144
144
  let watchdogTimer = null;
@@ -469,7 +469,7 @@ function handleEvent(workspaceId, agentSessionId, ev) {
469
469
  }
470
470
  }
471
471
  if (ev.kind === 'error' && ev.category === 'quota') {
472
- handleQuota(workspaceId, agentSessionId);
472
+ void handleQuota(workspaceId, agentSessionId);
473
473
  }
474
474
  if (ev.kind === 'error' && ev.category === 'resume_failed') {
475
475
  resumeFailedSet.add(workspaceId);
@@ -569,14 +569,11 @@ function onSessionEnded(workspaceId, agentSessionId, exitCode, reason, resumeFai
569
569
  if (wasStopping)
570
570
  return;
571
571
  // When the session hit quota, handleQuota() already transitioned the
572
- // workspace to `quota` and armed the retry timer. Keep that timer alive
573
- // and preserve the `quota` status so auto-loop can resume after reset.
572
+ // workspace to `quota` and armed the retry via quotaBackoffService.
573
+ // Preserve that pending backoff in the quota path; otherwise clear any
574
+ // stale entry (defensive — shouldn't normally exist on a non-quota end).
574
575
  if (!preserveQuotaBackoff) {
575
- const pendingBackoff = backoffTimers.get(workspaceId);
576
- if (pendingBackoff) {
577
- clearTimeout(pendingBackoff);
578
- backoffTimers.delete(workspaceId);
579
- }
576
+ quotaBackoffService.cancel(workspaceId, 'completed');
580
577
  }
581
578
  if (preserveQuotaBackoff) {
582
579
  try {
@@ -774,11 +771,8 @@ export function stopAgent(workspaceId) {
774
771
  // session:ended handler checks identity before removing, so a new controller
775
772
  // started in the meantime is preserved.
776
773
  controllers.delete(workspaceId);
777
- const timer = backoffTimers.get(workspaceId);
778
- if (timer) {
779
- clearTimeout(timer);
780
- backoffTimers.delete(workspaceId);
781
- }
774
+ // Manual stop should also drop any pending quota auto-resume.
775
+ quotaBackoffService.cancel(workspaceId, 'user');
782
776
  // Fire-and-forget: controller.stop is async but we don't block callers.
783
777
  void ctrl.stop().catch((err) => {
784
778
  console.error('[orchestrator] controller.stop failed:', err);
@@ -1098,12 +1092,14 @@ const QUOTA_FALLBACK_LADDER_MINUTES = [15, 30, 60, 180, 300];
1098
1092
  /**
1099
1093
  * Compute the delay before retrying a workspace hit by quota.
1100
1094
  *
1101
- * Prefers the `resetsAt` of the saturated bucket with the furthest-future
1102
- * reset (a tighter bucket will unlock by then anyway). Falls back to a
1103
- * fixed ladder (15 30 60 180 → 300 min) whenever the rate_limit
1104
- * info is missing, malformed, or implausible.
1095
+ * Prefers (1) the `resetsAt` of the saturated bucket with the furthest-future
1096
+ * reset reported by the agent's `rate_limit` event, then (1.5) the official
1097
+ * Anthropic usage API (`five_hour` bucket) when it reports saturation, and
1098
+ * finally (2) a fixed ladder (15 → 30 → 60 → 180 → 300 min) whenever neither
1099
+ * source is usable.
1105
1100
  */
1106
- export function computeQuotaBackoffMs(workspaceId, retryCount) {
1101
+ export async function computeQuotaBackoffMs(workspaceId, retryCount) {
1102
+ // 1. Prefer the rate_limit event from the agent stream — most recent + most precise.
1107
1103
  const info = latestRateLimitInfo.get(workspaceId);
1108
1104
  if (info?.buckets?.length) {
1109
1105
  const candidates = info.buckets
@@ -1119,15 +1115,38 @@ export function computeQuotaBackoffMs(workspaceId, retryCount) {
1119
1115
  }
1120
1116
  }
1121
1117
  }
1118
+ // 1.5. Try the official usage API (Claude subscription). Best-effort; never throws.
1119
+ try {
1120
+ const snap = await refreshNow('claude-code');
1121
+ if (snap) {
1122
+ const fiveHour = snap.buckets.find((b) => b.id === 'five_hour');
1123
+ if (fiveHour &&
1124
+ typeof fiveHour.usedPct === 'number' &&
1125
+ fiveHour.usedPct >= QUOTA_SATURATION_THRESHOLD_PCT &&
1126
+ typeof fiveHour.resetsAt === 'string') {
1127
+ const resetTs = Date.parse(fiveHour.resetsAt);
1128
+ const delta = resetTs - Date.now() + QUOTA_SAFETY_MARGIN_MS;
1129
+ if (delta > 0 && delta <= QUOTA_MAX_BACKOFF_MS) {
1130
+ return { delayMs: delta, resetsAt: fiveHour.resetsAt, source: 'usage_api' };
1131
+ }
1132
+ }
1133
+ }
1134
+ }
1135
+ catch (err) {
1136
+ console.warn('[orchestrator] computeQuotaBackoffMs — usage API call failed:', err);
1137
+ }
1138
+ // 2. Hard-coded ladder.
1122
1139
  const idx = Math.min(Math.max(0, retryCount), QUOTA_FALLBACK_LADDER_MINUTES.length - 1);
1123
1140
  const backoffMinutes = QUOTA_FALLBACK_LADDER_MINUTES[idx];
1124
- return { delayMs: backoffMinutes * 60 * 1000, source: 'exponential_fallback' };
1141
+ return { delayMs: backoffMinutes * 60 * 1000, source: 'fallback_ladder' };
1125
1142
  }
1143
+ /** @internal test-only — re-export of `computeQuotaBackoffMs` to anchor a stable seam. */
1144
+ export const _computeQuotaBackoffMs = computeQuotaBackoffMs;
1126
1145
  /** @internal Test-only. */
1127
1146
  export function _test_setRateLimitInfo(workspaceId, info) {
1128
1147
  latestRateLimitInfo.set(workspaceId, info);
1129
1148
  }
1130
- function handleQuota(workspaceId, _agentSessionId) {
1149
+ async function handleQuota(workspaceId, _agentSessionId) {
1131
1150
  try {
1132
1151
  updateWorkspaceStatus(workspaceId, 'quota');
1133
1152
  }
@@ -1135,53 +1154,39 @@ function handleQuota(workspaceId, _agentSessionId) {
1135
1154
  // May fail if transition is not valid
1136
1155
  }
1137
1156
  const retryCount = retryCounts.get(workspaceId) ?? 0;
1138
- const { delayMs, resetsAt, source } = computeQuotaBackoffMs(workspaceId, retryCount);
1139
- const backoffMs = delayMs;
1157
+ const { delayMs, resetsAt, source } = await computeQuotaBackoffMs(workspaceId, retryCount);
1140
1158
  retryCounts.set(workspaceId, retryCount + 1);
1141
- // Surface the backoff schedule as an ephemeral event so the UI can display
1142
- // retry count / wait time without polluting the persistent event log.
1143
- emitEphemeral(workspaceId, 'agent:quota-backoff', {
1144
- retryCount: retryCount + 1,
1145
- backoffMinutes: Math.round(delayMs / 60_000),
1146
- resetsAt,
1147
- source,
1148
- });
1149
- const timer = setTimeout(() => {
1150
- backoffTimers.delete(workspaceId);
1151
- if (!controllers.has(workspaceId)) {
1152
- const freshWs = getWs(workspaceId);
1153
- if (!freshWs || freshWs.archivedAt !== null || freshWs.status !== 'quota') {
1154
- return;
1155
- }
1156
- try {
1157
- if (freshWs.autoLoop) {
1158
- autoLoopService.onQuotaBackoffExpired(workspaceId);
1159
- }
1160
- else {
1161
- const freshWorkingDir = freshWs.worktreePath;
1162
- startAgent(workspaceId, freshWorkingDir, 'Continue the previous task where you left off.', undefined, true);
1163
- }
1164
- }
1165
- catch (err) {
1166
- console.error(`[orchestrator] Quota retry for workspace '${workspaceId}' failed:`, err);
1167
- const msg = err instanceof Error ? err.message : String(err);
1168
- try {
1169
- updateWorkspaceStatus(workspaceId, 'error');
1170
- }
1171
- catch {
1172
- // transition may not be valid
1173
- }
1174
- routeEvent(workspaceId, '', {
1175
- kind: 'error',
1176
- category: 'other',
1177
- message: `Quota retry failed: ${msg}`,
1178
- });
1179
- }
1180
- }
1181
- }, backoffMs);
1182
- timer.unref?.();
1183
- backoffTimers.set(workspaceId, timer);
1159
+ // The quotaBackoffService owns the timer + the persistent row + the
1160
+ // 'agent:quota-backoff' WS emit. Hand off everything to it.
1161
+ quotaBackoffService.arm(workspaceId, delayMs, { resetsAt: resetsAt ?? null, source });
1184
1162
  }
1163
+ /** @internal test-only — re-export of `handleQuota` for direct testing. */
1164
+ export const _handleQuota = handleQuota;
1165
+ /**
1166
+ * Rebuild the in-memory `retryCounts` map from the persisted `pending_quota_backoffs`
1167
+ * rows. Called from `index.ts` at boot, before `quotaBackoffService.restoreOnBoot`.
1168
+ * Without this, an arm() after restart would compute the next backoff from
1169
+ * `retryCount=0`, undoing the ladder progression.
1170
+ */
1171
+ export function restoreRetryCountsFromDb() {
1172
+ for (const pending of quotaBackoffService.listPending()) {
1173
+ retryCounts.set(pending.workspaceId, pending.retryCount);
1174
+ }
1175
+ }
1176
+ // One-time wire: when the persisted backoff timer fires (or a row is
1177
+ // restored at boot), hand the workspace off to auto-loop. The auto-loop
1178
+ // service decides whether to spawn the next iteration or fall back to a
1179
+ // manual resume.
1180
+ //
1181
+ // IMPORTANT — behavioural contract: only auto-loop workspaces auto-resume
1182
+ // after a quota backoff. `onQuotaBackoffExpired` no-ops if `auto_loop !== 1`
1183
+ // (see auto-loop-service). Workspaces hit by quota WITHOUT auto-loop stay
1184
+ // in `quota` status and require manual user action (resume / new message)
1185
+ // to leave that state. This is intentional: without an auto-loop intent,
1186
+ // firing a fresh agent run in the user's absence would surprise them.
1187
+ quotaBackoffService.setOnFireCallback((workspaceId) => {
1188
+ autoLoopService.onQuotaBackoffExpired(workspaceId);
1189
+ });
1185
1190
  // ── Testing utilities ─────────────────────────────────────────────────────────
1186
1191
  /** @internal test-only */
1187
1192
  export function _getControllers() {
@@ -1192,10 +1197,6 @@ export function _getRetryCounts() {
1192
1197
  return retryCounts;
1193
1198
  }
1194
1199
  /** @internal test-only */
1195
- export function _getBackoffTimers() {
1196
- return backoffTimers;
1197
- }
1198
- /** @internal test-only */
1199
1200
  export function _getSessionIds() {
1200
1201
  return sessionIds;
1201
1202
  }
@@ -309,6 +309,14 @@ function spawnNextIteration(workspaceId, opts = {}) {
309
309
  * Called by orchestrator.handleQuota's backoff timer when auto-loop is enabled.
310
310
  * Spawns the next auto-loop iteration if the workspace is still in quota status
311
311
  * with auto_loop active; no-ops otherwise (race-safe).
312
+ *
313
+ * No-op cases — these all leave the workspace in `quota` status awaiting
314
+ * manual user action:
315
+ * - workspace was deleted between arm and fire
316
+ * - `auto_loop !== 1` (workspace was never an auto-loop target, OR the user
317
+ * toggled the loop off during the backoff window)
318
+ * - `status !== 'quota'` (user already manually resumed, or another path
319
+ * transitioned the workspace)
312
320
  */
313
321
  export function onQuotaBackoffExpired(workspaceId) {
314
322
  const row = getRow(workspaceId);
@@ -0,0 +1,127 @@
1
+ import { getDb } from '../db/index.js';
2
+ import { emitEphemeral } from './websocket-service.js';
3
+ import { getWorkspace } from './workspace-service.js';
4
+ const timers = new Map();
5
+ let onFireCallback = null;
6
+ function rowToPending(row) {
7
+ return {
8
+ workspaceId: row.workspace_id,
9
+ targetAt: row.target_at,
10
+ resetsAt: row.resets_at,
11
+ source: row.source,
12
+ retryCount: row.retry_count,
13
+ createdAt: row.created_at,
14
+ };
15
+ }
16
+ /**
17
+ * Schedule (or reschedule) the auto-resume timer for a workspace that just
18
+ * hit a Claude quota. Persists the target time so it survives restarts and
19
+ * keeps the in-RAM `setTimeout` alive for the current process.
20
+ *
21
+ * `delayMs` is the "fire-now-plus-delta" offset; it MUST already include
22
+ * any safety margin the caller wants. orchestrator.handleQuota owns that math.
23
+ */
24
+ export function arm(workspaceId, delayMs, meta) {
25
+ const db = getDb();
26
+ const now = new Date();
27
+ const targetAt = new Date(now.getTime() + delayMs).toISOString();
28
+ const existing = db
29
+ .prepare('SELECT retry_count FROM pending_quota_backoffs WHERE workspace_id = ?')
30
+ .get(workspaceId);
31
+ const retryCount = (existing?.retry_count ?? 0) + 1;
32
+ db.prepare(`INSERT INTO pending_quota_backoffs (workspace_id, target_at, resets_at, source, retry_count, created_at)
33
+ VALUES (?, ?, ?, ?, ?, ?)
34
+ ON CONFLICT(workspace_id) DO UPDATE SET
35
+ target_at = excluded.target_at,
36
+ resets_at = excluded.resets_at,
37
+ source = excluded.source,
38
+ retry_count = excluded.retry_count,
39
+ created_at = excluded.created_at`).run(workspaceId, targetAt, meta.resetsAt, meta.source, retryCount, now.toISOString());
40
+ const previous = timers.get(workspaceId);
41
+ if (previous)
42
+ clearTimeout(previous);
43
+ const timer = setTimeout(() => fireOrSkip(workspaceId), Math.max(0, delayMs));
44
+ timer.unref?.();
45
+ timers.set(workspaceId, timer);
46
+ emitEphemeral(workspaceId, 'agent:quota-backoff', {
47
+ targetAt,
48
+ resetsAt: meta.resetsAt,
49
+ source: meta.source,
50
+ retryCount,
51
+ });
52
+ }
53
+ /**
54
+ * Cancel the pending backoff for a workspace. Returns true if a row existed
55
+ * (and was deleted), false if there was nothing to cancel. Idempotent.
56
+ */
57
+ export function cancel(workspaceId, reason) {
58
+ const db = getDb();
59
+ const result = db.prepare('DELETE FROM pending_quota_backoffs WHERE workspace_id = ?').run(workspaceId);
60
+ const existed = result.changes > 0;
61
+ const previous = timers.get(workspaceId);
62
+ if (previous) {
63
+ clearTimeout(previous);
64
+ timers.delete(workspaceId);
65
+ }
66
+ if (existed) {
67
+ emitEphemeral(workspaceId, 'agent:quota-backoff-cancelled', { reason });
68
+ }
69
+ return existed;
70
+ }
71
+ export function getPending(workspaceId) {
72
+ const db = getDb();
73
+ const row = db.prepare('SELECT * FROM pending_quota_backoffs WHERE workspace_id = ?').get(workspaceId);
74
+ return row ? rowToPending(row) : null;
75
+ }
76
+ export function listPending() {
77
+ const db = getDb();
78
+ const rows = db.prepare('SELECT * FROM pending_quota_backoffs').all();
79
+ return rows.map(rowToPending);
80
+ }
81
+ export function setOnFireCallback(fn) {
82
+ onFireCallback = fn;
83
+ }
84
+ /**
85
+ * Re-arm timers for rows persisted across restart. Future rows get a fresh
86
+ * `setTimeout`; past rows fire immediately (delay = 0). Rows pointing at
87
+ * archived or missing workspaces are deleted without firing.
88
+ */
89
+ export function restoreOnBoot(onFire) {
90
+ setOnFireCallback(onFire);
91
+ const db = getDb();
92
+ const rows = db.prepare('SELECT * FROM pending_quota_backoffs').all();
93
+ for (const row of rows) {
94
+ const ws = getWorkspace(row.workspace_id);
95
+ if (!ws || ws.archivedAt !== null) {
96
+ db.prepare('DELETE FROM pending_quota_backoffs WHERE workspace_id = ?').run(row.workspace_id);
97
+ continue;
98
+ }
99
+ const delta = new Date(row.target_at).getTime() - Date.now();
100
+ const timer = setTimeout(() => fireOrSkip(row.workspace_id), Math.max(0, delta));
101
+ timer.unref?.();
102
+ timers.set(row.workspace_id, timer);
103
+ }
104
+ }
105
+ /** Internal — invoked when a timer fires. */
106
+ function fireOrSkip(workspaceId) {
107
+ timers.delete(workspaceId);
108
+ // Final archive check before firing — workspace might have been archived
109
+ // between the timer being armed and now.
110
+ const ws = getWorkspace(workspaceId);
111
+ if (!ws || ws.archivedAt !== null) {
112
+ cancel(workspaceId, 'archive');
113
+ return;
114
+ }
115
+ // Consume the persisted row BEFORE invoking the callback. If the server
116
+ // crashes during the spawn the callback triggers, restoreOnBoot won't see
117
+ // a stale row with target_at in the past and re-fire on the next start
118
+ // (which would cause a double spawn). The cb's downstream effects (next
119
+ // iteration, status transitions) are tracked by their own state.
120
+ getDb().prepare('DELETE FROM pending_quota_backoffs WHERE workspace_id = ?').run(workspaceId);
121
+ const cb = onFireCallback;
122
+ if (!cb)
123
+ return;
124
+ cb(workspaceId);
125
+ }
126
+ /** @internal test-only */
127
+ export const _timers = timers;
@@ -62,7 +62,9 @@ export const DEFAULT_FINALIZATION_PROMPT = `Run final quality checks before clos
62
62
  1. Verify all other tasks are marked \`done\`. If any remain \`pending\`, stop and report.
63
63
  2. Run the project's linters, type-checkers, and tests (see CLAUDE.md or package.json scripts).
64
64
  3. If any check fails, create a new regular task at the end of the list with a title like \`Fix lint failure in X\` (NO \`[FINAL]\` or \`[E2E]\` prefix — it must use the default iteration prompt) and mark this \`[FINAL]\` task as \`done\`. The auto-loop will pick up the fix on the next iteration. The finalization mechanism is single-shot per grooming pass; if you want quality checks to re-run after the fix, mark the fix task \`done\` and re-trigger grooming manually.
65
- 4. If everything passes, mark this task as \`done\`.`;
65
+ 4. If everything passes, mark this task as \`done\`.
66
+
67
+ HARD RULE: Do NOT open a pull request, do NOT run \`gh pr create\` or any equivalent command. The finalization step never opens a PR — that is a separate, explicit user action via the "Open PR" button.`;
66
68
  /** Default workspace tags seeded on fresh install and on settings upgrade. */
67
69
  export const DEFAULT_WORKSPACE_TAGS = [
68
70
  'bug',
@@ -3,7 +3,9 @@ import { getDb } from '../db/index.js';
3
3
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
4
4
  import * as orchestrator from './agent/orchestrator.js';
5
5
  import * as autoLoopService from './auto-loop-service.js';
6
+ import * as quotaBackoffService from './quota-backoff-service.js';
6
7
  import * as wakeupService from './wakeup-service.js';
8
+ import { emitEphemeral } from './websocket-service.js';
7
9
  /** Allowed status transitions per current status. Enforced by updateWorkspaceStatus. */
8
10
  const VALID_TRANSITIONS = {
9
11
  created: ['extracting', 'brainstorming', 'idle', 'error'],
@@ -51,6 +53,8 @@ function mapWorkspace(row) {
51
53
  archivedAt: row.archived_at,
52
54
  favoritedAt: row.favorited_at,
53
55
  tags: parseTags(row.tags),
56
+ description: row.description,
57
+ agentDescription: row.agent_description,
54
58
  engine: row.engine ?? 'claude-code',
55
59
  autoLoop: row.auto_loop === 1,
56
60
  autoLoopReady: row.auto_loop_ready === 1,
@@ -270,6 +274,66 @@ export function updateAgentPermissionMode(id, mode) {
270
274
  }
271
275
  return getWorkspace(id);
272
276
  }
277
+ /**
278
+ * Update a workspace's short description (≤ 200 chars after trim).
279
+ * Empty string (after trim) or `null` clears the column.
280
+ *
281
+ * Emits an ephemeral `workspace:description-updated` WebSocket event so every
282
+ * subscribed client (sidebar + the workspace header) refreshes in real-time
283
+ * without a manual reload. The truth lives in the DB; sync replay on
284
+ * reconnect re-fetches via GET /api/workspaces.
285
+ *
286
+ * @throws when the description exceeds 200 chars after trim or the workspace
287
+ * does not exist.
288
+ */
289
+ export function updateWorkspaceDescription(id, description) {
290
+ const trimmed = description == null ? null : description.trim();
291
+ if (trimmed !== null && trimmed.length > 200) {
292
+ throw new Error(`Description must be 200 characters or fewer (got ${trimmed.length})`);
293
+ }
294
+ const stored = trimmed && trimmed.length > 0 ? trimmed : null;
295
+ const db = getDb();
296
+ const result = db
297
+ .prepare('UPDATE workspaces SET description = ?, updated_at = ? WHERE id = ?')
298
+ .run(stored, new Date().toISOString(), id);
299
+ if (result.changes === 0) {
300
+ throw new Error(`Workspace '${id}' not found`);
301
+ }
302
+ emitEphemeral(id, 'workspace:description-updated', { description: stored });
303
+ return getWorkspace(id);
304
+ }
305
+ /**
306
+ * Update a workspace's agent-side description (≤ 200 chars after trim).
307
+ * Empty string (after trim) or `null` clears the column.
308
+ *
309
+ * Mirror of `updateWorkspaceDescription` but writes the `agent_description`
310
+ * column, which is exclusively the agent's to set via the
311
+ * `set_workspace_agent_description` MCP tool. The user's `description`
312
+ * column is untouched.
313
+ *
314
+ * Emits an ephemeral `workspace:agent-description-updated` event so every
315
+ * subscribed client (sidebar fallback display + workspace header read-only
316
+ * line) refreshes in real-time.
317
+ *
318
+ * @throws when the description exceeds 200 chars after trim or the workspace
319
+ * does not exist.
320
+ */
321
+ export function updateWorkspaceAgentDescription(id, description) {
322
+ const trimmed = description == null ? null : description.trim();
323
+ if (trimmed !== null && trimmed.length > 200) {
324
+ throw new Error(`Description must be 200 characters or fewer (got ${trimmed.length})`);
325
+ }
326
+ const stored = trimmed && trimmed.length > 0 ? trimmed : null;
327
+ const db = getDb();
328
+ const result = db
329
+ .prepare('UPDATE workspaces SET agent_description = ?, updated_at = ? WHERE id = ?')
330
+ .run(stored, new Date().toISOString(), id);
331
+ if (result.changes === 0) {
332
+ throw new Error(`Workspace '${id}' not found`);
333
+ }
334
+ emitEphemeral(id, 'workspace:agent-description-updated', { agentDescription: stored });
335
+ return getWorkspace(id);
336
+ }
273
337
  /** Update the dev-server status column for a workspace. */
274
338
  export function updateDevServerStatus(id, status) {
275
339
  const db = getDb();
@@ -291,6 +355,14 @@ export function deleteWorkspace(id) {
291
355
  // The DB row is removed via ON DELETE CASCADE, but the timer would
292
356
  // otherwise fire and hit an empty workspace.
293
357
  wakeupService.cancel(id, 'deleted');
358
+ // Same for any pending quota backoff. Best-effort: failure must not
359
+ // block delete. The DB row is also removed via ON DELETE CASCADE.
360
+ try {
361
+ quotaBackoffService.cancel(id, 'deleted');
362
+ }
363
+ catch (err) {
364
+ console.error('[workspace-service] cancel quota backoff on delete failed:', err);
365
+ }
294
366
  // Drop the cached rate_limit.info so memory doesn't leak on workspace
295
367
  // churn. The Map has no FK to clean up for it automatically.
296
368
  orchestrator.forgetRateLimitInfo(id);
@@ -383,6 +455,14 @@ export function archiveWorkspace(id) {
383
455
  }
384
456
  // Cancel any pending wakeup — archived workspaces should not wake up.
385
457
  wakeupService.cancel(id, 'archived');
458
+ // Cancel any pending quota backoff — archived workspaces should not auto-resume.
459
+ // Best-effort: failure here must not block archive.
460
+ try {
461
+ quotaBackoffService.cancel(id, 'archive');
462
+ }
463
+ catch (err) {
464
+ console.error('[workspace-service] cancel quota backoff on archive failed:', err);
465
+ }
386
466
  // Disable auto-loop — archived workspaces should not keep looping.
387
467
  // Idempotent: no-op if auto_loop was already 0.
388
468
  autoLoopService.disable(id, 'user-action');
@@ -4,7 +4,14 @@ import { join } from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  const execFileAsync = promisify(execFileCb);
6
6
  function git(repoPath, args) {
7
- return execFileSync('git', args, { cwd: repoPath, encoding: 'utf-8' }).trim();
7
+ // `trimEnd` (not `trim`): some git outputs are column-aligned and the LEADING
8
+ // space carries information. The classic case is `git status --porcelain`,
9
+ // where each line is `XY filename` and X is " " when the index has no
10
+ // change. Stripping that leading space silently shifts every column by one
11
+ // and makes `line.substring(3)` chop the first character of the filename
12
+ // (e.g. `front/foo` → `ront/foo`). Trailing whitespace (the final `\n` git
13
+ // always appends) still goes — that's what every caller expects.
14
+ return execFileSync('git', args, { cwd: repoPath, encoding: 'utf-8' }).trimEnd();
8
15
  }
9
16
  /** Return the name of the currently checked-out branch. */
10
17
  export function getCurrentBranch(repoPath) {
@@ -676,17 +683,38 @@ export function getWorkingTreeStatus(repoPath) {
676
683
  return { staged: 0, modified: 0, untracked: 0 };
677
684
  }
678
685
  }
679
- /** Count commits ahead of upstream. Returns -1 if no upstream is set. */
680
- export function getUnpushedCount(repoPath) {
686
+ /**
687
+ * Count commits ahead of `origin/<workingBranch>`. Returns `-1` when the remote
688
+ * ref does not exist (i.e. the branch has never been pushed).
689
+ *
690
+ * We deliberately use `origin/<workingBranch>` instead of the local `@{u}`
691
+ * upstream pointer: Kōbō creates worktrees with `git worktree add -b <new>
692
+ * <path> origin/<sourceBranch>`, so `@{u}` points at `origin/<sourceBranch>`,
693
+ * NOT at the working branch's remote sibling. Comparing HEAD with that wrong
694
+ * upstream silently reported "0 unpushed" for never-pushed branches that
695
+ * happened to be aligned with their source — surfacing as a false "Pushé"
696
+ * label in the GitPanel.
697
+ */
698
+ export function getUnpushedCount(repoPath, workingBranch) {
699
+ const remoteRef = `origin/${workingBranch}`;
681
700
  try {
682
- const output = execFileSync('git', ['rev-list', '@{u}..HEAD', '--count'], {
701
+ execFileSync('git', ['rev-parse', '--verify', remoteRef], {
702
+ cwd: repoPath,
703
+ stdio: ['pipe', 'pipe', 'pipe'],
704
+ });
705
+ }
706
+ catch {
707
+ return -1; // branch never pushed (no remote ref)
708
+ }
709
+ try {
710
+ const output = execFileSync('git', ['rev-list', `${remoteRef}..HEAD`, '--count'], {
683
711
  cwd: repoPath,
684
712
  encoding: 'utf-8',
685
713
  }).trim();
686
714
  return parseInt(output, 10) || 0;
687
715
  }
688
716
  catch {
689
- return -1; // no upstream
717
+ return -1;
690
718
  }
691
719
  }
692
720
  /** Return raw `git diff --shortstat` output between two refs (three-dot). */
@@ -749,17 +777,28 @@ export async function getPrStatusAsync(repoPath, branchName) {
749
777
  return null;
750
778
  }
751
779
  }
752
- /** Async version of getUnpushedCount. Returns -1 if no upstream is set. */
753
- export async function getUnpushedCountAsync(repoPath) {
780
+ /**
781
+ * Async version of `getUnpushedCount`. Same `origin/<workingBranch>` semantic:
782
+ * returns `-1` when the remote ref does not exist (never pushed), `0` when
783
+ * pushed and aligned, `>0` when pushed but ahead.
784
+ */
785
+ export async function getUnpushedCountAsync(repoPath, workingBranch) {
786
+ const remoteRef = `origin/${workingBranch}`;
787
+ try {
788
+ await execFileAsync('git', ['rev-parse', '--verify', remoteRef], { cwd: repoPath });
789
+ }
790
+ catch {
791
+ return -1; // branch never pushed (no remote ref)
792
+ }
754
793
  try {
755
- const { stdout } = await execFileAsync('git', ['rev-list', '@{u}..HEAD', '--count'], {
794
+ const { stdout } = await execFileAsync('git', ['rev-list', `${remoteRef}..HEAD`, '--count'], {
756
795
  cwd: repoPath,
757
796
  encoding: 'utf-8',
758
797
  });
759
798
  return parseInt(stdout.trim(), 10) || 0;
760
799
  }
761
800
  catch {
762
- return -1; // no upstream
801
+ return -1;
763
802
  }
764
803
  }
765
804
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loicngr/kobo",
3
- "version": "1.7.3",
3
+ "version": "1.7.5",
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",
@@ -1 +1 @@
1
- .markdown-message[data-v-1b7bd8ca]{color:#e0e0e0;word-break:break-word;overflow-wrap:anywhere;min-width:0;max-width:100%;font-size:13px;line-height:1.55}.markdown-message[data-v-1b7bd8ca] *{max-width:100%}.markdown-message[data-v-1b7bd8ca] p{margin:0 0 .5em}.markdown-message[data-v-1b7bd8ca] p:last-child{margin-bottom:0}.markdown-message[data-v-1b7bd8ca] pre{background:#00000059;border-radius:4px;margin:.5em 0;padding:.5em .75em;overflow-x:auto}.markdown-message[data-v-1b7bd8ca] code{word-break:break-all;background:#0000004d;border-radius:3px;padding:.1em .3em;font-size:.9em}.markdown-message[data-v-1b7bd8ca] pre code{background:0 0;padding:0}.markdown-message[data-v-1b7bd8ca] ul,.markdown-message[data-v-1b7bd8ca] ol{margin:.25em 0 .5em;padding-left:1.5em}.markdown-message[data-v-1b7bd8ca] li{margin:.15em 0}.markdown-message[data-v-1b7bd8ca] a{color:#7986cb;text-decoration:underline}.markdown-message[data-v-1b7bd8ca] .document-link{color:#9fa8da;cursor:pointer;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.markdown-message[data-v-1b7bd8ca] .document-link:hover{color:#c5cae9;-webkit-text-decoration:underline;text-decoration:underline}.markdown-message[data-v-1b7bd8ca] h1,.markdown-message[data-v-1b7bd8ca] h2,.markdown-message[data-v-1b7bd8ca] h3,.markdown-message[data-v-1b7bd8ca] h4,.markdown-message[data-v-1b7bd8ca] h5,.markdown-message[data-v-1b7bd8ca] h6{margin:.5em 0 .3em;font-weight:600;line-height:1.3}.markdown-message[data-v-1b7bd8ca] h1{font-size:1.25em}.markdown-message[data-v-1b7bd8ca] h2{font-size:1.15em}.markdown-message[data-v-1b7bd8ca] h3{font-size:1.08em}.markdown-message[data-v-1b7bd8ca] h4,.markdown-message[data-v-1b7bd8ca] h5,.markdown-message[data-v-1b7bd8ca] h6{font-size:1em}.markdown-message[data-v-1b7bd8ca] blockquote{color:#ffffffb3;border-left:3px solid #fff3;margin:.5em 0;padding-left:.75em}.markdown-message[data-v-1b7bd8ca] table{border-collapse:collapse;margin:.5em 0}.markdown-message[data-v-1b7bd8ca] th,.markdown-message[data-v-1b7bd8ca] td{border:1px solid #ffffff26;padding:.25em .5em}.markdown-thinking[data-v-7f45ed94] p{margin:0 0 .4em}.markdown-thinking[data-v-7f45ed94] p:last-child{margin-bottom:0}.markdown-thinking[data-v-7f45ed94] code{background:#ffffff14;border-radius:3px;padding:.1em .3em}.tool-row[data-v-b1fcd20d]{border-radius:4px;margin:0;font-size:12px}.tool-header[data-v-b1fcd20d]{color:#bbb;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-b1fcd20d],.tool-row--toggleable .tool-header[data-v-b1fcd20d]{cursor:pointer}.tool-row:has(.tool-diff) .tool-header[data-v-b1fcd20d]{cursor:pointer}.tool-row:not(.tool-row-generic) .tool-header[data-v-b1fcd20d]:hover,.tool-row--toggleable .tool-header[data-v-b1fcd20d]:hover{background:#ffffff08}.tool-icon[data-v-b1fcd20d]{color:#9fbce0;flex-shrink:0}.tool-name[data-v-b1fcd20d]{color:#d0d0d0;flex-shrink:0;font-weight:600}.tool-arg[data-v-b1fcd20d],.tool-path[data-v-b1fcd20d]{color:#999;text-overflow:ellipsis;white-space:nowrap;min-width:0;max-width:100%;font-family:SF Mono,Menlo,Consolas,monospace;font-size:11.5px;overflow:hidden}.tool-path[data-v-b1fcd20d],.tool-arg[data-v-b1fcd20d]{flex:1}.tool-stat-add[data-v-b1fcd20d]{color:#66bb6a;flex-shrink:0;font-size:11px;font-weight:600}.tool-stat-del[data-v-b1fcd20d]{color:#ef5350;flex-shrink:0;font-size:11px;font-weight:600}.tool-diff[data-v-b1fcd20d]{background:#0003;border-radius:4px;max-height:400px;margin-top:4px;padding:8px 0;font-family:SF Mono,Menlo,Consolas,monospace;font-size:11px;line-height:1.5;overflow:auto}.diff-line[data-v-b1fcd20d]{white-space:pre;color:#bbb;padding:0 12px}.diff-sign[data-v-b1fcd20d]{color:#555;-webkit-user-select:none;user-select:none;width:14px;display:inline-block}.diff-add[data-v-b1fcd20d]{color:#c8e6c9;background:#66bb6a1a}.diff-add .diff-sign[data-v-b1fcd20d]{color:#66bb6a}.diff-del[data-v-b1fcd20d]{color:#ffcdd2;background:#ef53501a}.diff-del .diff-sign[data-v-b1fcd20d]{color:#ef5350}.tool-output[data-v-b1fcd20d]{color:#aaa;white-space:pre-wrap;background:#00000026;border-radius:4px;max-height:8em;margin-top:4px;padding:6px 10px;font-family:SF Mono,Menlo,Consolas,monospace;font-size:11px;overflow:auto}.markdown-message[data-v-f34be4c5]{color:#e0e0e0;word-break:break-word;overflow-wrap:anywhere;min-width:0;max-width:100%;font-size:13px;line-height:1.55}.markdown-message[data-v-f34be4c5] *{max-width:100%}.markdown-message[data-v-f34be4c5] 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-f34be4c5] code{word-break:break-all}.markdown-message[data-v-f34be4c5] p{margin:0 0 .4em}.markdown-message[data-v-f34be4c5] p:last-child{margin-bottom:0}.markdown-message[data-v-f34be4c5] code{background:#00000040;border-radius:3px;padding:.1em .3em}.markdown-message[data-v-f34be4c5] h1,.markdown-message[data-v-f34be4c5] h2,.markdown-message[data-v-f34be4c5] h3,.markdown-message[data-v-f34be4c5] h4,.markdown-message[data-v-f34be4c5] h5,.markdown-message[data-v-f34be4c5] h6{margin:.4em 0 .25em;font-weight:600;line-height:1.3}.markdown-message[data-v-f34be4c5] h1{font-size:1.25em}.markdown-message[data-v-f34be4c5] h2{font-size:1.15em}.markdown-message[data-v-f34be4c5] h3{font-size:1.08em}.markdown-message[data-v-f34be4c5] h4,.markdown-message[data-v-f34be4c5] h5,.markdown-message[data-v-f34be4c5] h6{font-size:1em}.markdown-user-prompt[data-v-f34be4c5]{color:#aaa;font-size:12px;font-style:italic}.markdown-user-prompt[data-v-f34be4c5] p{margin:0 0 .4em}.markdown-user-prompt[data-v-f34be4c5] 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-4729e0cc]{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-header[data-v-4729e0cc]{color:#888;background:#ffffff08;border-bottom:1px solid #ffffff0d;align-items:center;gap:8px;padding:8px 14px;font-size:11px;display:flex}.turn-badge[data-v-4729e0cc]{letter-spacing:.3px;border-radius:3px;padding:2px 8px;font-size:11px;font-weight:700}.turn-badge-user[data-v-4729e0cc]{color:#ce93d8;background:#ce93d826}.turn-badge-agent[data-v-4729e0cc]{color:#7986cb;background:#7986cb26}.turn-badge-system[data-v-4729e0cc]{color:#bdbdbd;background:#75757533;font-style:italic}.turn-badge-session[data-v-4729e0cc]{color:#9e9e9e;background:#61616133}.turn-time[data-v-4729e0cc]{color:#666;font-family:SF Mono,Menlo,Consolas,monospace;font-size:11px}.turn-time-arrow[data-v-4729e0cc]{opacity:.7;margin:0 -2px}.turn-time-updated[data-v-4729e0cc]{color:#8891a3}.turn-actions[data-v-4729e0cc]{color:#777;font-size:11px}.turn-body[data-v-4729e0cc]{flex-direction:column;gap:12px;min-width:0;padding:14px 18px;display:flex}.turn-body[data-v-4729e0cc]>*{min-width:0;max-width:100%}.turn-body[data-v-4729e0cc] .tool-row+.tool-row{margin-top:-8px}.turn-scroll-top[data-v-4729e0cc]{justify-content:flex-start;padding:0 8px 6px;display:flex}.turn-scroll-top-btn[data-v-4729e0cc]{opacity:.5;transition:opacity .15s}.turn-scroll-top-btn[data-v-4729e0cc]:hover{opacity:1}.activity-feed-wrap[data-v-890bdcbd]{width:100%;height:100%;position:relative}.activity-feed-scroll[data-v-890bdcbd]{width:100%;height:100%}.activity-feed-nav-cluster[data-v-890bdcbd]{z-index:2;align-items:center;gap:8px;display:flex;position:absolute;bottom:14px;right:14px}.activity-feed-nav-btn[data-v-890bdcbd]{opacity:.8;transition:opacity .12s}.activity-feed-nav-btn[data-v-890bdcbd]:hover{opacity:1}.content-origin-marker[data-v-890bdcbd]{pointer-events:none;width:0;height:0;margin:0;padding:0}.activity-feed-scroll[data-v-890bdcbd] .q-scrollarea__content{max-width:100%;overflow-x:hidden}.activity-feed-switching[data-v-890bdcbd]{justify-content:center;align-items:center;width:100%;height:100%;display:flex}
1
+ .markdown-message[data-v-1b7bd8ca]{color:#e0e0e0;word-break:break-word;overflow-wrap:anywhere;min-width:0;max-width:100%;font-size:13px;line-height:1.55}.markdown-message[data-v-1b7bd8ca] *{max-width:100%}.markdown-message[data-v-1b7bd8ca] p{margin:0 0 .5em}.markdown-message[data-v-1b7bd8ca] p:last-child{margin-bottom:0}.markdown-message[data-v-1b7bd8ca] pre{background:#00000059;border-radius:4px;margin:.5em 0;padding:.5em .75em;overflow-x:auto}.markdown-message[data-v-1b7bd8ca] code{word-break:break-all;background:#0000004d;border-radius:3px;padding:.1em .3em;font-size:.9em}.markdown-message[data-v-1b7bd8ca] pre code{background:0 0;padding:0}.markdown-message[data-v-1b7bd8ca] ul,.markdown-message[data-v-1b7bd8ca] ol{margin:.25em 0 .5em;padding-left:1.5em}.markdown-message[data-v-1b7bd8ca] li{margin:.15em 0}.markdown-message[data-v-1b7bd8ca] a{color:#7986cb;text-decoration:underline}.markdown-message[data-v-1b7bd8ca] .document-link{color:#9fa8da;cursor:pointer;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.markdown-message[data-v-1b7bd8ca] .document-link:hover{color:#c5cae9;-webkit-text-decoration:underline;text-decoration:underline}.markdown-message[data-v-1b7bd8ca] h1,.markdown-message[data-v-1b7bd8ca] h2,.markdown-message[data-v-1b7bd8ca] h3,.markdown-message[data-v-1b7bd8ca] h4,.markdown-message[data-v-1b7bd8ca] h5,.markdown-message[data-v-1b7bd8ca] h6{margin:.5em 0 .3em;font-weight:600;line-height:1.3}.markdown-message[data-v-1b7bd8ca] h1{font-size:1.25em}.markdown-message[data-v-1b7bd8ca] h2{font-size:1.15em}.markdown-message[data-v-1b7bd8ca] h3{font-size:1.08em}.markdown-message[data-v-1b7bd8ca] h4,.markdown-message[data-v-1b7bd8ca] h5,.markdown-message[data-v-1b7bd8ca] h6{font-size:1em}.markdown-message[data-v-1b7bd8ca] blockquote{color:#ffffffb3;border-left:3px solid #fff3;margin:.5em 0;padding-left:.75em}.markdown-message[data-v-1b7bd8ca] table{border-collapse:collapse;margin:.5em 0}.markdown-message[data-v-1b7bd8ca] th,.markdown-message[data-v-1b7bd8ca] td{border:1px solid #ffffff26;padding:.25em .5em}.markdown-thinking[data-v-7f45ed94] p{margin:0 0 .4em}.markdown-thinking[data-v-7f45ed94] p:last-child{margin-bottom:0}.markdown-thinking[data-v-7f45ed94] code{background:#ffffff14;border-radius:3px;padding:.1em .3em}.tool-row[data-v-b1fcd20d]{border-radius:4px;margin:0;font-size:12px}.tool-header[data-v-b1fcd20d]{color:#bbb;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-b1fcd20d],.tool-row--toggleable .tool-header[data-v-b1fcd20d]{cursor:pointer}.tool-row:has(.tool-diff) .tool-header[data-v-b1fcd20d]{cursor:pointer}.tool-row:not(.tool-row-generic) .tool-header[data-v-b1fcd20d]:hover,.tool-row--toggleable .tool-header[data-v-b1fcd20d]:hover{background:#ffffff08}.tool-icon[data-v-b1fcd20d]{color:#9fbce0;flex-shrink:0}.tool-name[data-v-b1fcd20d]{color:#d0d0d0;flex-shrink:0;font-weight:600}.tool-arg[data-v-b1fcd20d],.tool-path[data-v-b1fcd20d]{color:#999;text-overflow:ellipsis;white-space:nowrap;min-width:0;max-width:100%;font-family:SF Mono,Menlo,Consolas,monospace;font-size:11.5px;overflow:hidden}.tool-path[data-v-b1fcd20d],.tool-arg[data-v-b1fcd20d]{flex:1}.tool-stat-add[data-v-b1fcd20d]{color:#66bb6a;flex-shrink:0;font-size:11px;font-weight:600}.tool-stat-del[data-v-b1fcd20d]{color:#ef5350;flex-shrink:0;font-size:11px;font-weight:600}.tool-diff[data-v-b1fcd20d]{background:#0003;border-radius:4px;max-height:400px;margin-top:4px;padding:8px 0;font-family:SF Mono,Menlo,Consolas,monospace;font-size:11px;line-height:1.5;overflow:auto}.diff-line[data-v-b1fcd20d]{white-space:pre;color:#bbb;padding:0 12px}.diff-sign[data-v-b1fcd20d]{color:#555;-webkit-user-select:none;user-select:none;width:14px;display:inline-block}.diff-add[data-v-b1fcd20d]{color:#c8e6c9;background:#66bb6a1a}.diff-add .diff-sign[data-v-b1fcd20d]{color:#66bb6a}.diff-del[data-v-b1fcd20d]{color:#ffcdd2;background:#ef53501a}.diff-del .diff-sign[data-v-b1fcd20d]{color:#ef5350}.tool-output[data-v-b1fcd20d]{color:#aaa;white-space:pre-wrap;background:#00000026;border-radius:4px;max-height:8em;margin-top:4px;padding:6px 10px;font-family:SF Mono,Menlo,Consolas,monospace;font-size:11px;overflow:auto}.markdown-message[data-v-f34be4c5]{color:#e0e0e0;word-break:break-word;overflow-wrap:anywhere;min-width:0;max-width:100%;font-size:13px;line-height:1.55}.markdown-message[data-v-f34be4c5] *{max-width:100%}.markdown-message[data-v-f34be4c5] 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-f34be4c5] code{word-break:break-all}.markdown-message[data-v-f34be4c5] p{margin:0 0 .4em}.markdown-message[data-v-f34be4c5] p:last-child{margin-bottom:0}.markdown-message[data-v-f34be4c5] code{background:#00000040;border-radius:3px;padding:.1em .3em}.markdown-message[data-v-f34be4c5] h1,.markdown-message[data-v-f34be4c5] h2,.markdown-message[data-v-f34be4c5] h3,.markdown-message[data-v-f34be4c5] h4,.markdown-message[data-v-f34be4c5] h5,.markdown-message[data-v-f34be4c5] h6{margin:.4em 0 .25em;font-weight:600;line-height:1.3}.markdown-message[data-v-f34be4c5] h1{font-size:1.25em}.markdown-message[data-v-f34be4c5] h2{font-size:1.15em}.markdown-message[data-v-f34be4c5] h3{font-size:1.08em}.markdown-message[data-v-f34be4c5] h4,.markdown-message[data-v-f34be4c5] h5,.markdown-message[data-v-f34be4c5] h6{font-size:1em}.markdown-user-prompt[data-v-f34be4c5]{color:#aaa;font-size:12px;font-style:italic}.markdown-user-prompt[data-v-f34be4c5] p{margin:0 0 .4em}.markdown-user-prompt[data-v-f34be4c5] 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-4729e0cc]{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-header[data-v-4729e0cc]{color:#888;background:#ffffff08;border-bottom:1px solid #ffffff0d;align-items:center;gap:8px;padding:8px 14px;font-size:11px;display:flex}.turn-badge[data-v-4729e0cc]{letter-spacing:.3px;border-radius:3px;padding:2px 8px;font-size:11px;font-weight:700}.turn-badge-user[data-v-4729e0cc]{color:#ce93d8;background:#ce93d826}.turn-badge-agent[data-v-4729e0cc]{color:#7986cb;background:#7986cb26}.turn-badge-system[data-v-4729e0cc]{color:#bdbdbd;background:#75757533;font-style:italic}.turn-badge-session[data-v-4729e0cc]{color:#9e9e9e;background:#61616133}.turn-time[data-v-4729e0cc]{color:#666;font-family:SF Mono,Menlo,Consolas,monospace;font-size:11px}.turn-time-arrow[data-v-4729e0cc]{opacity:.7;margin:0 -2px}.turn-time-updated[data-v-4729e0cc]{color:#8891a3}.turn-actions[data-v-4729e0cc]{color:#777;font-size:11px}.turn-body[data-v-4729e0cc]{flex-direction:column;gap:12px;min-width:0;padding:14px 18px;display:flex}.turn-body[data-v-4729e0cc]>*{min-width:0;max-width:100%}.turn-body[data-v-4729e0cc] .tool-row+.tool-row{margin-top:-8px}.turn-scroll-top[data-v-4729e0cc]{justify-content:flex-start;padding:0 8px 6px;display:flex}.turn-scroll-top-btn[data-v-4729e0cc]{opacity:.5;transition:opacity .15s}.turn-scroll-top-btn[data-v-4729e0cc]:hover{opacity:1}.activity-feed-wrap[data-v-2cb1aa26]{width:100%;height:100%;position:relative}.activity-feed-scroll[data-v-2cb1aa26]{width:100%;height:100%}.activity-feed-nav-cluster[data-v-2cb1aa26]{z-index:2;align-items:center;gap:8px;display:flex;position:absolute;bottom:14px;right:14px}.activity-feed-nav-btn[data-v-2cb1aa26]{opacity:.8;transition:opacity .12s}.activity-feed-nav-btn[data-v-2cb1aa26]:hover{opacity:1}.content-origin-marker[data-v-2cb1aa26]{pointer-events:none;width:0;height:0;margin:0;padding:0}.activity-feed-scroll[data-v-2cb1aa26] .q-scrollarea__content{max-width:100%;overflow-x:hidden}.activity-feed-switching[data-v-2cb1aa26]{justify-content:center;align-items:center;width:100%;height:100%;display:flex}