@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.
- package/README.md +20 -8
- package/dist/mcp-server/kobo-tasks-handlers.js +26 -1
- package/dist/mcp-server/kobo-tasks-server.js +41 -1
- package/dist/server/db/migrations.js +49 -0
- package/dist/server/db/schema.js +11 -0
- package/dist/server/index.js +7 -1
- package/dist/server/routes/workspaces.js +83 -5
- package/dist/server/services/agent/engines/claude-code/engine.js +4 -1
- package/dist/server/services/agent/engines/claude-code/event-mapper.js +6 -2
- package/dist/server/services/agent/orchestrator.js +72 -71
- package/dist/server/services/auto-loop-service.js +8 -0
- package/dist/server/services/quota-backoff-service.js +127 -0
- package/dist/server/services/settings-service.js +3 -1
- package/dist/server/services/workspace-service.js +80 -0
- package/dist/server/utils/git-ops.js +48 -9
- package/package.json +1 -1
- package/src/client/dist/spa/assets/{ActivityFeed-CroojlsI.css → ActivityFeed-DVBfmJWJ.css} +1 -1
- package/src/client/dist/spa/assets/{ActivityFeed-CKSqMR2v.js → ActivityFeed-oW9PgZ8E.js} +2 -2
- package/src/client/dist/spa/assets/AutoLoopChip-Y53cnGfZ.js +1 -0
- package/src/client/dist/spa/assets/{CreatePage-7cP4h19f.js → CreatePage-CuD7sMR7.js} +1 -1
- package/src/client/dist/spa/assets/{DiffViewer-CdamEwIg.js → DiffViewer-rc3tE9fq.js} +3 -3
- package/src/client/dist/spa/assets/{HealthPage-m4z-x5bo.js → HealthPage-Dz0yGGMB.js} +1 -1
- package/src/client/dist/spa/assets/{MainLayout-CQBqYFNx.js → MainLayout-B9i06p7n.js} +17 -17
- package/src/client/dist/spa/assets/{MainLayout-DKurmqtk.css → MainLayout-DDa3rGKA.css} +1 -1
- package/src/client/dist/spa/assets/{SearchPage-DCRSQycR.js → SearchPage-DdX7JZCD.js} +1 -1
- package/src/client/dist/spa/assets/{SettingsPage-DStBGwIj.js → SettingsPage-Dnj1CWc3.js} +1 -1
- package/src/client/dist/spa/assets/{WorkspacePage-eymEd4kx.css → WorkspacePage-CCtIrBiR.css} +1 -1
- package/src/client/dist/spa/assets/WorkspacePage-DHp20nl-.js +4 -0
- package/src/client/dist/spa/assets/{cssMode-o7NS-Oil.js → cssMode-DSB5jkRt.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-CNo9KwlJ.js → editor.api-Bcw50eFD.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-UyvgnhP6.js → editor.main-D9piVGaH.js} +3 -3
- package/src/client/dist/spa/assets/{expand-template-DqZgks9E.js → expand-template-BIPuNAYV.js} +1 -1
- package/src/client/dist/spa/assets/{freemarker2-BKWtNRQ9.js → freemarker2-CVh_Zh8H.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-BUhKrn3k.js → handlebars-CpCgELpu.js} +1 -1
- package/src/client/dist/spa/assets/{html-CrcvRgdj.js → html-ikWDpvWk.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-Djjp-0pZ.js → htmlMode-C9TTCKih.js} +1 -1
- package/src/client/dist/spa/assets/i18n-DZCb8dnb.js +1 -0
- package/src/client/dist/spa/assets/index-DuK38XN5.js +2 -0
- package/src/client/dist/spa/assets/{javascript-DN_zCJwt.js → javascript-C4OlkNeA.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-B7uIpwZ9.js → jsonMode-BiD34_86.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-f3BGSOBM.js → liquid-Dty0Ui2c.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-jpEqsFXp.js → mdx-yiUjOVv6.js} +1 -1
- package/src/client/dist/spa/assets/{models-Bj-hfPO2.js → models-BDkLiht9.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-D-UK6jlz.js → monaco.contribution-Bz9yFPWR.js} +2 -2
- package/src/client/dist/spa/assets/{purify.es-DyEEb_DH.js → purify.es-BIY760fF.js} +1 -1
- package/src/client/dist/spa/assets/{python-CoiTKs0q.js → python-7SPSWQoD.js} +1 -1
- package/src/client/dist/spa/assets/{razor-BubwMw_m.js → razor-eagZawXK.js} +1 -1
- package/src/client/dist/spa/assets/{render-chat-markdown-DwKtHD8J.js → render-chat-markdown-TvAqpDih.js} +1 -1
- package/src/client/dist/spa/assets/{tsMode-k_tAkDr_.js → tsMode-CLYG2xeJ.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-DQQR6Y6R.js → typescript-CzOXM8yS.js} +1 -1
- package/src/client/dist/spa/assets/{xml-CaSyI8p6.js → xml-2_0_6RAX.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-BYsGcXIZ.js → yaml-CtpgNyXs.js} +1 -1
- package/src/client/dist/spa/index.html +1 -1
- package/src/mcp-server/kobo-tasks-handlers.ts +35 -1
- package/src/mcp-server/kobo-tasks-server.ts +42 -0
- package/src/client/dist/spa/assets/WorkspacePage-BstBxgN8.js +0 -4
- package/src/client/dist/spa/assets/i18n-DD341qPX.js +0 -1
- package/src/client/dist/spa/assets/index-DR1y9t94.js +0 -2
- /package/src/client/dist/spa/assets/{QPage-ChUKoaKe.js → QPage-DFi3K093.js} +0 -0
- /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
|
|
573
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
778
|
-
|
|
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
|
|
1103
|
-
*
|
|
1104
|
-
*
|
|
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: '
|
|
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
|
-
//
|
|
1142
|
-
//
|
|
1143
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
680
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
/**
|
|
753
|
-
|
|
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',
|
|
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;
|
|
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
|
+
"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-
|
|
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}
|