@loicngr/kobo 1.4.4 → 1.4.6
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/AGENTS.md +1 -1
- package/README.md +2 -2
- package/dist/mcp-server/kobo-tasks-handlers.js +12 -1
- package/dist/mcp-server/kobo-tasks-server.js +14 -2
- package/dist/server/db/index.js +5 -0
- package/dist/server/db/migrations.js +9 -0
- package/dist/server/db/schema.js +2 -0
- package/dist/server/index.js +17 -18
- package/dist/server/routes/dev-server.js +1 -0
- package/dist/server/routes/git.js +1 -0
- package/dist/server/routes/images.js +3 -0
- package/dist/server/routes/notion.js +1 -0
- package/dist/server/routes/settings.js +1 -0
- package/dist/server/routes/workspaces.js +128 -31
- package/dist/server/services/agent-manager.js +32 -8
- package/dist/server/services/dev-server-service.js +7 -2
- package/dist/server/services/image-service.js +2 -1
- package/dist/server/services/notion-service.js +3 -16
- package/dist/server/services/pr-watcher-service.js +2 -0
- package/dist/server/services/settings-service.js +17 -6
- package/dist/server/services/setup-script-service.js +1 -0
- package/dist/server/services/websocket-service.js +8 -9
- package/dist/server/services/workspace-service.js +33 -2
- package/dist/server/services/worktree-service.js +4 -2
- package/dist/server/utils/git-ops.js +19 -5
- package/dist/server/utils/process-tracker.js +7 -0
- package/package.json +1 -1
- package/src/client/dist/spa/assets/ActivityFeed-CLqD89Cm.css +1 -0
- package/src/client/dist/spa/assets/ActivityFeed-DTH_Ij7C.js +68 -0
- package/src/client/dist/spa/assets/CreatePage-BB7McDfT.js +2 -0
- package/src/client/dist/spa/assets/CreatePage-DAZADSsw.css +1 -0
- package/src/client/dist/spa/assets/DiffViewer-DpdWjInq.css +1 -0
- package/src/client/dist/spa/assets/DiffViewer-DsVIBuje.js +2 -0
- package/src/client/dist/spa/assets/MainLayout-BVmmyrJW.js +2 -0
- package/src/client/dist/spa/assets/MainLayout-POre8X3k.css +1 -0
- package/src/client/dist/spa/assets/QBadge-C_R3Tjb9.js +1 -0
- package/src/client/dist/spa/assets/QExpansionItem-BPtEjGYj.js +1 -0
- package/src/client/dist/spa/assets/QPage-BX_DOfKi.js +1 -0
- package/src/client/dist/spa/assets/QSeparator-y-UWrZSp.js +1 -0
- package/src/client/dist/spa/assets/QSpinnerDots-vpiOHlmN.js +1 -0
- package/src/client/dist/spa/assets/QTooltip-D5Om2o3Y.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-BKDbZp9_.js +1 -0
- package/src/client/dist/spa/assets/SettingsPage-Dv9gCOw8.css +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-Cxt0YZv0.css +1 -0
- package/src/client/dist/spa/assets/WorkspacePage-g-Y3BuoI.js +2 -0
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-BZV6EEeb.js +1 -0
- package/src/client/dist/spa/assets/{cssMode-Dsa4Lydc.js → cssMode-CbCp8SFU.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-qmVdKoc0.js → editor.api-Bm9nrcuM.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-D2fZAQLs.js → editor.main-DCE1BHWQ.js} +3 -3
- package/src/client/dist/spa/assets/{freemarker2-e-FYsZTq.js → freemarker2-BEroNSFG.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-CAwfoT2m.js → handlebars-C8QC92C9.js} +1 -1
- package/src/client/dist/spa/assets/{html-BTRUpMfA.js → html-Dh4rYZTt.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-mzgQeoHf.js → htmlMode-BUP5qnTw.js} +1 -1
- package/src/client/dist/spa/assets/i18n-CvzmE5dV.js +1 -0
- package/src/client/dist/spa/assets/index-BRIQl1ry.js +5 -0
- package/src/client/dist/spa/assets/{javascript-DDeQuxhB.js → javascript-C3VBJatM.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-Ch5vu2Iw.js → jsonMode-fNrYYkV5.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-qVj3a9kh.js → liquid-BnBhHusK.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-Cb_a7RXe.js → mdx-CTu1vGiw.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-Rz70-mfd.js → monaco.contribution-Byxe-pOH.js} +2 -2
- package/src/client/dist/spa/assets/nodes-THUz-Chh.js +1 -0
- package/src/client/dist/spa/assets/{python-Cz0peIkX.js → python-CBrfgeGm.js} +1 -1
- package/src/client/dist/spa/assets/{razor-CU8Xe-4p.js → razor-CaMsFgrW.js} +1 -1
- package/src/client/dist/spa/assets/settings-CYWSNYAA.js +1 -0
- package/src/client/dist/spa/assets/{tsMode-CP1svEaN.js → tsMode-CrwxLvLR.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-aPJGGIsI.js → typescript-BOH3igZy.js} +1 -1
- package/src/client/dist/spa/assets/use-checkbox-ts4I7GAt.js +1 -0
- package/src/client/dist/spa/assets/use-quasar-CIVlxSZ-.js +1 -0
- package/src/client/dist/spa/assets/{xml-CZM1zQhV.js → xml-BtA9_-M9.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-B8qPirw0.js → yaml-vYOBELrg.js} +1 -1
- package/src/client/dist/spa/index.html +3 -3
- package/src/mcp-server/kobo-tasks-handlers.ts +21 -5
- package/src/mcp-server/kobo-tasks-server.ts +14 -2
- package/src/client/dist/spa/assets/ActivityFeed-Bx7maW4r.css +0 -1
- package/src/client/dist/spa/assets/ActivityFeed-DoQIjq5C.js +0 -60
- package/src/client/dist/spa/assets/CreatePage-6J0aDFtf.js +0 -2
- package/src/client/dist/spa/assets/CreatePage-DOr3puTt.css +0 -1
- package/src/client/dist/spa/assets/DiffViewer-7dck6mJc.css +0 -1
- package/src/client/dist/spa/assets/DiffViewer-dvGJaxu-.js +0 -2
- package/src/client/dist/spa/assets/MainLayout-DGzPKBi9.js +0 -2
- package/src/client/dist/spa/assets/MainLayout-gjAr74zR.css +0 -1
- package/src/client/dist/spa/assets/QBadge-Y5QfSDtm.js +0 -1
- package/src/client/dist/spa/assets/QBtn-D_bkYnrl.js +0 -1
- package/src/client/dist/spa/assets/QExpansionItem-D5gm2xc8.js +0 -1
- package/src/client/dist/spa/assets/QPage-Bg3Rohl6.js +0 -1
- package/src/client/dist/spa/assets/QSeparator-MRAsTeNf.js +0 -1
- package/src/client/dist/spa/assets/QSpinnerDots-Bu0GloxK.js +0 -1
- package/src/client/dist/spa/assets/QTooltip-Cuj49WIu.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-BNA4jJYf.css +0 -1
- package/src/client/dist/spa/assets/SettingsPage-BOkWnRl2.js +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-9gRnhdjv.js +0 -2
- package/src/client/dist/spa/assets/WorkspacePage-CFC48jKO.css +0 -1
- package/src/client/dist/spa/assets/_plugin-vue_export-helper-Bo392ayB.js +0 -1
- package/src/client/dist/spa/assets/i18n-B1eQvEGk.js +0 -1
- package/src/client/dist/spa/assets/index-C0u2YcnZ.js +0 -5
- package/src/client/dist/spa/assets/nodes-Bo-5xQjA.js +0 -1
- package/src/client/dist/spa/assets/position-engine-CDz__T_5.js +0 -1
- package/src/client/dist/spa/assets/use-checkbox-B8W131xl.js +0 -1
- package/src/client/dist/spa/assets/use-quasar-sc8fDqi0.js +0 -1
- /package/src/client/dist/spa/assets/{formatters-CXx5Gzsp.js → formatters-B3FG1fMI.js} +0 -0
|
@@ -105,6 +105,7 @@ function defaultSettings() {
|
|
|
105
105
|
dangerouslySkipPermissions: true,
|
|
106
106
|
prPromptTemplate: DEFAULT_PR_PROMPT_TEMPLATE,
|
|
107
107
|
gitConventions: DEFAULT_GIT_CONVENTIONS,
|
|
108
|
+
editorCommand: '',
|
|
108
109
|
},
|
|
109
110
|
projects: [],
|
|
110
111
|
};
|
|
@@ -128,11 +129,7 @@ function defaultProjectSettings(projectPath) {
|
|
|
128
129
|
function pickKnownKeys(data, allowedKeys) {
|
|
129
130
|
return Object.fromEntries(Object.entries(data).filter(([key]) => allowedKeys.includes(key)));
|
|
130
131
|
}
|
|
131
|
-
/**
|
|
132
|
-
* Apply migrations sequentially to bring an older settings object up to
|
|
133
|
-
* SETTINGS_SCHEMA_VERSION. Append-only — never edit or reorder shipped entries.
|
|
134
|
-
* The returned object carries the bumped schemaVersion; callers persist it.
|
|
135
|
-
*/
|
|
132
|
+
/** Apply settings migrations sequentially up to SETTINGS_SCHEMA_VERSION. Append-only. */
|
|
136
133
|
export function runSettingsMigrations(raw) {
|
|
137
134
|
const current = raw;
|
|
138
135
|
if (!current.global || typeof current.global !== 'object') {
|
|
@@ -190,16 +187,20 @@ function writeSettings(settings) {
|
|
|
190
187
|
fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2), 'utf-8');
|
|
191
188
|
fs.renameSync(tmpPath, settingsFilePath);
|
|
192
189
|
}
|
|
190
|
+
/** Read and return the full settings object, creating defaults if missing. */
|
|
193
191
|
export function getSettings() {
|
|
194
192
|
return readSettings();
|
|
195
193
|
}
|
|
194
|
+
/** Return only the global settings section. */
|
|
196
195
|
export function getGlobalSettings() {
|
|
197
196
|
return readSettings().global;
|
|
198
197
|
}
|
|
198
|
+
/** Return project-specific settings, or null if the project is not configured. */
|
|
199
199
|
export function getProjectSettings(projectPath) {
|
|
200
200
|
const settings = readSettings();
|
|
201
201
|
return settings.projects.find((p) => p.path === projectPath) ?? null;
|
|
202
202
|
}
|
|
203
|
+
/** Compute effective settings for a project (project overrides merged with global defaults). */
|
|
203
204
|
export function getEffectiveSettings(projectPath) {
|
|
204
205
|
const settings = readSettings();
|
|
205
206
|
const project = settings.projects.find((p) => p.path === projectPath) ?? null;
|
|
@@ -224,14 +225,22 @@ export function getEffectiveSettings(projectPath) {
|
|
|
224
225
|
setupScript: project.setupScript || '',
|
|
225
226
|
};
|
|
226
227
|
}
|
|
228
|
+
/** Merge partial updates into global settings and persist. */
|
|
227
229
|
export function updateGlobalSettings(data) {
|
|
228
230
|
const settings = readSettings();
|
|
229
|
-
const allowedGlobalKeys = [
|
|
231
|
+
const allowedGlobalKeys = [
|
|
232
|
+
'defaultModel',
|
|
233
|
+
'dangerouslySkipPermissions',
|
|
234
|
+
'prPromptTemplate',
|
|
235
|
+
'gitConventions',
|
|
236
|
+
'editorCommand',
|
|
237
|
+
];
|
|
230
238
|
const filtered = pickKnownKeys(data, allowedGlobalKeys);
|
|
231
239
|
settings.global = { ...settings.global, ...filtered };
|
|
232
240
|
writeSettings(settings);
|
|
233
241
|
return settings.global;
|
|
234
242
|
}
|
|
243
|
+
/** Create or update project-specific settings. Merges devServer fields on update. */
|
|
235
244
|
export function upsertProject(projectPath, data) {
|
|
236
245
|
const allowedProjectKeys = [
|
|
237
246
|
'displayName',
|
|
@@ -276,11 +285,13 @@ export function upsertProject(projectPath, data) {
|
|
|
276
285
|
writeSettings(settings);
|
|
277
286
|
return settings.projects.find((p) => p.path === projectPath);
|
|
278
287
|
}
|
|
288
|
+
/** Remove a project from the settings file. */
|
|
279
289
|
export function deleteProject(projectPath) {
|
|
280
290
|
const settings = readSettings();
|
|
281
291
|
settings.projects = settings.projects.filter((p) => p.path !== projectPath);
|
|
282
292
|
writeSettings(settings);
|
|
283
293
|
}
|
|
294
|
+
/** List all configured projects. */
|
|
284
295
|
export function listProjects() {
|
|
285
296
|
return readSettings().projects;
|
|
286
297
|
}
|
|
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import * as wsService from './websocket-service.js';
|
|
5
5
|
const SETUP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
6
|
+
/** Execute a setup script in a worktree, streaming output via WebSocket. Resolves with the exit code. */
|
|
6
7
|
export function runSetupScript(workspaceId, worktreePath, script, env, timeoutMs = SETUP_TIMEOUT_MS) {
|
|
7
8
|
return new Promise((resolve) => {
|
|
8
9
|
const scriptPath = path.join(worktreePath, '.ai', '.setup-script.tmp');
|
|
@@ -3,14 +3,16 @@ import { getDb } from '../db/index.js';
|
|
|
3
3
|
// ── State ──────────────────────────────────────────────────────────────────────
|
|
4
4
|
/** Maps each WS client to the set of workspaceIds they are subscribed to */
|
|
5
5
|
const clients = new Map();
|
|
6
|
-
/**
|
|
6
|
+
/** Per-workspace emit counter for periodic cleanup. */
|
|
7
7
|
const emitCounters = new Map();
|
|
8
8
|
const EMIT_CLEANUP_THRESHOLD = 2000;
|
|
9
9
|
let messageHandler = null;
|
|
10
|
+
/** Register the handler that processes routed WS messages (e.g. chat:message, workspace:start). */
|
|
10
11
|
export function setMessageHandler(handler) {
|
|
11
12
|
messageHandler = handler;
|
|
12
13
|
}
|
|
13
14
|
// ── Connection handling ────────────────────────────────────────────────────────
|
|
15
|
+
/** Handle a new WebSocket connection: register, dispatch messages, ping keepalive. */
|
|
14
16
|
export function handleConnection(ws) {
|
|
15
17
|
// Register client with empty subscription set
|
|
16
18
|
clients.set(ws, new Set());
|
|
@@ -50,7 +52,6 @@ export function handleConnection(ws) {
|
|
|
50
52
|
case 'sync:request': {
|
|
51
53
|
const p = payload;
|
|
52
54
|
const lastEventId = p?.lastEventId ?? '';
|
|
53
|
-
// I2: Accept optional workspaceIds so the client can sync even before re-subscribing
|
|
54
55
|
const workspaceIds = p?.workspaceIds;
|
|
55
56
|
handleSyncRequest(ws, lastEventId, workspaceIds);
|
|
56
57
|
break;
|
|
@@ -97,11 +98,11 @@ export function handleConnection(ws) {
|
|
|
97
98
|
export function emit(workspaceId, type, payload, sessionId) {
|
|
98
99
|
const id = nanoid();
|
|
99
100
|
const createdAt = new Date().toISOString();
|
|
100
|
-
//
|
|
101
|
+
// Best-effort persist — don't let FK violation (deleted workspace) break the broadcast
|
|
101
102
|
try {
|
|
102
103
|
const db = getDb();
|
|
103
104
|
db.prepare('INSERT INTO ws_events (id, workspace_id, type, payload, session_id, created_at) VALUES (?, ?, ?, ?, ?, ?)').run(id, workspaceId, type, JSON.stringify(payload), sessionId ?? null, createdAt);
|
|
104
|
-
//
|
|
105
|
+
// Periodic cleanup — trigger when emit threshold is reached
|
|
105
106
|
const count = (emitCounters.get(workspaceId) ?? 0) + 1;
|
|
106
107
|
if (count >= EMIT_CLEANUP_THRESHOLD) {
|
|
107
108
|
cleanupOldEvents(workspaceId);
|
|
@@ -142,12 +143,10 @@ export function emitEphemeral(workspaceId, type, payload) {
|
|
|
142
143
|
}
|
|
143
144
|
// ── Sync (replay missed events) ────────────────────────────────────────────────
|
|
144
145
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
* so the client can sync even before re-subscribing (e.g. after a reconnect).
|
|
146
|
+
* Replay all events after lastEventId for workspaces the client is subscribed to.
|
|
147
|
+
* If workspaceIds is provided, uses those instead of the client's current subscriptions.
|
|
148
148
|
*/
|
|
149
149
|
export function handleSyncRequest(ws, lastEventId, workspaceIds) {
|
|
150
|
-
// I2: Use provided workspaceIds first, fall back to current subscriptions
|
|
151
150
|
const resolvedIds = workspaceIds && workspaceIds.length > 0
|
|
152
151
|
? workspaceIds
|
|
153
152
|
: (() => {
|
|
@@ -171,7 +170,7 @@ export function handleSyncRequest(ws, lastEventId, workspaceIds) {
|
|
|
171
170
|
.all(...resolvedIds, lastRow.rowid);
|
|
172
171
|
}
|
|
173
172
|
else {
|
|
174
|
-
//
|
|
173
|
+
// lastEventId not found — send events capped to avoid unbounded memory usage
|
|
175
174
|
rows = db
|
|
176
175
|
.prepare(`SELECT * FROM ws_events WHERE workspace_id IN (${placeholders}) ORDER BY rowid ASC LIMIT 10000`)
|
|
177
176
|
.all(...resolvedIds);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { nanoid } from 'nanoid';
|
|
2
2
|
import { getDb } from '../db/index.js';
|
|
3
|
-
|
|
3
|
+
/** Allowed status transitions per current status. Enforced by updateWorkspaceStatus. */
|
|
4
4
|
const VALID_TRANSITIONS = {
|
|
5
5
|
created: ['extracting', 'brainstorming', 'idle', 'error'],
|
|
6
6
|
extracting: ['extracting', 'brainstorming', 'idle', 'error'],
|
|
@@ -24,6 +24,7 @@ function mapWorkspace(row) {
|
|
|
24
24
|
model: row.model,
|
|
25
25
|
permissionMode: (row.permission_mode ?? 'auto-accept'),
|
|
26
26
|
devServerStatus: row.dev_server_status,
|
|
27
|
+
hasUnread: row.has_unread === 1,
|
|
27
28
|
archivedAt: row.archived_at,
|
|
28
29
|
createdAt: row.created_at,
|
|
29
30
|
updatedAt: row.updated_at,
|
|
@@ -41,6 +42,7 @@ function mapTask(row) {
|
|
|
41
42
|
updatedAt: row.updated_at,
|
|
42
43
|
};
|
|
43
44
|
}
|
|
45
|
+
/** Insert a new workspace into the database and return it. */
|
|
44
46
|
export function createWorkspace(data) {
|
|
45
47
|
const db = getDb();
|
|
46
48
|
const now = new Date().toISOString();
|
|
@@ -51,11 +53,13 @@ export function createWorkspace(data) {
|
|
|
51
53
|
`).run(id, data.name, data.projectPath, data.sourceBranch, data.workingBranch, data.notionUrl ?? null, data.notionPageId ?? null, data.model ?? 'claude-opus-4-6', now, now);
|
|
52
54
|
return getWorkspace(id);
|
|
53
55
|
}
|
|
56
|
+
/** Fetch a single workspace by ID, or null if not found. */
|
|
54
57
|
export function getWorkspace(id) {
|
|
55
58
|
const db = getDb();
|
|
56
59
|
const row = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(id);
|
|
57
60
|
return row ? mapWorkspace(row) : null;
|
|
58
61
|
}
|
|
62
|
+
/** List all workspaces, optionally including archived ones. Ordered by most recently updated. */
|
|
59
63
|
export function listWorkspaces(includeArchived = false) {
|
|
60
64
|
const db = getDb();
|
|
61
65
|
const sql = includeArchived
|
|
@@ -64,6 +68,7 @@ export function listWorkspaces(includeArchived = false) {
|
|
|
64
68
|
const rows = db.prepare(sql).all();
|
|
65
69
|
return rows.map(mapWorkspace);
|
|
66
70
|
}
|
|
71
|
+
/** List only archived workspaces, ordered by archive date descending. */
|
|
67
72
|
export function listArchivedWorkspaces() {
|
|
68
73
|
const db = getDb();
|
|
69
74
|
const rows = db
|
|
@@ -71,6 +76,7 @@ export function listArchivedWorkspaces() {
|
|
|
71
76
|
.all();
|
|
72
77
|
return rows.map(mapWorkspace);
|
|
73
78
|
}
|
|
79
|
+
/** Transition a workspace to a new status, validating against VALID_TRANSITIONS. */
|
|
74
80
|
export function updateWorkspaceStatus(id, status) {
|
|
75
81
|
const db = getDb();
|
|
76
82
|
const workspace = getWorkspace(id);
|
|
@@ -85,6 +91,7 @@ export function updateWorkspaceStatus(id, status) {
|
|
|
85
91
|
db.prepare('UPDATE workspaces SET status = ?, updated_at = ? WHERE id = ?').run(status, now, id);
|
|
86
92
|
return getWorkspace(id);
|
|
87
93
|
}
|
|
94
|
+
/** Update a workspace's display name. */
|
|
88
95
|
export function updateWorkspaceName(id, name) {
|
|
89
96
|
const db = getDb();
|
|
90
97
|
const now = new Date().toISOString();
|
|
@@ -94,6 +101,7 @@ export function updateWorkspaceName(id, name) {
|
|
|
94
101
|
}
|
|
95
102
|
return getWorkspace(id);
|
|
96
103
|
}
|
|
104
|
+
/** Update the Claude model used by a workspace's agent. */
|
|
97
105
|
export function updateWorkspaceModel(id, model) {
|
|
98
106
|
const db = getDb();
|
|
99
107
|
const now = new Date().toISOString();
|
|
@@ -103,6 +111,7 @@ export function updateWorkspaceModel(id, model) {
|
|
|
103
111
|
}
|
|
104
112
|
return getWorkspace(id);
|
|
105
113
|
}
|
|
114
|
+
/** Update the agent's permission mode (auto-accept vs plan/read-only). */
|
|
106
115
|
export function updateWorkspacePermissionMode(id, permissionMode) {
|
|
107
116
|
const db = getDb();
|
|
108
117
|
const now = new Date().toISOString();
|
|
@@ -114,17 +123,29 @@ export function updateWorkspacePermissionMode(id, permissionMode) {
|
|
|
114
123
|
}
|
|
115
124
|
return getWorkspace(id);
|
|
116
125
|
}
|
|
126
|
+
/** Update the dev-server status column for a workspace. */
|
|
117
127
|
export function updateDevServerStatus(id, status) {
|
|
118
128
|
const db = getDb();
|
|
119
129
|
db.prepare('UPDATE workspaces SET dev_server_status = ? WHERE id = ?').run(status, id);
|
|
120
130
|
}
|
|
131
|
+
/** Mark a workspace as read (has_unread = 0). */
|
|
132
|
+
export function markWorkspaceRead(id) {
|
|
133
|
+
const db = getDb();
|
|
134
|
+
db.prepare('UPDATE workspaces SET has_unread = 0 WHERE id = ?').run(id);
|
|
135
|
+
}
|
|
136
|
+
/** Mark a workspace as unread (has_unread = 1). */
|
|
137
|
+
export function markWorkspaceUnread(id) {
|
|
138
|
+
const db = getDb();
|
|
139
|
+
db.prepare('UPDATE workspaces SET has_unread = 1 WHERE id = ?').run(id);
|
|
140
|
+
}
|
|
141
|
+
/** Delete a workspace and cascade-delete its tasks. */
|
|
121
142
|
export function deleteWorkspace(id) {
|
|
122
143
|
const db = getDb();
|
|
123
144
|
db.prepare('DELETE FROM workspaces WHERE id = ?').run(id);
|
|
124
145
|
}
|
|
146
|
+
/** Create a new task under a workspace. Throws if the workspace does not exist. */
|
|
125
147
|
export function createTask(workspaceId, data) {
|
|
126
148
|
const db = getDb();
|
|
127
|
-
// I2: explicit workspace existence check before INSERT
|
|
128
149
|
const exists = db.prepare('SELECT 1 FROM workspaces WHERE id = ?').get(workspaceId);
|
|
129
150
|
if (!exists) {
|
|
130
151
|
throw new Error(`Workspace not found: '${workspaceId}'`);
|
|
@@ -138,11 +159,13 @@ export function createTask(workspaceId, data) {
|
|
|
138
159
|
const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
|
139
160
|
return mapTask(row);
|
|
140
161
|
}
|
|
162
|
+
/** Fetch a single task by ID scoped to a workspace, or null if not found. */
|
|
141
163
|
export function getTask(taskId, workspaceId) {
|
|
142
164
|
const db = getDb();
|
|
143
165
|
const row = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?').get(taskId, workspaceId);
|
|
144
166
|
return row ? mapTask(row) : null;
|
|
145
167
|
}
|
|
168
|
+
/** List all tasks for a workspace, ordered by sort_order ascending. */
|
|
146
169
|
export function listTasks(workspaceId) {
|
|
147
170
|
const db = getDb();
|
|
148
171
|
const rows = db
|
|
@@ -150,6 +173,7 @@ export function listTasks(workspaceId) {
|
|
|
150
173
|
.all(workspaceId);
|
|
151
174
|
return rows.map(mapTask);
|
|
152
175
|
}
|
|
176
|
+
/** Update a task's status (pending, in_progress, done). */
|
|
153
177
|
export function updateTaskStatus(taskId, status) {
|
|
154
178
|
const db = getDb();
|
|
155
179
|
const now = new Date().toISOString();
|
|
@@ -160,6 +184,7 @@ export function updateTaskStatus(taskId, status) {
|
|
|
160
184
|
}
|
|
161
185
|
return mapTask(row);
|
|
162
186
|
}
|
|
187
|
+
/** Update a task's title. Throws if the title is empty or the task does not exist. */
|
|
163
188
|
export function updateTaskTitle(taskId, title) {
|
|
164
189
|
if (!title?.trim()) {
|
|
165
190
|
throw new Error('Task title cannot be empty');
|
|
@@ -173,10 +198,12 @@ export function updateTaskTitle(taskId, title) {
|
|
|
173
198
|
const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
|
|
174
199
|
return mapTask(row);
|
|
175
200
|
}
|
|
201
|
+
/** Delete a task by ID. */
|
|
176
202
|
export function deleteTask(taskId) {
|
|
177
203
|
const db = getDb();
|
|
178
204
|
db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId);
|
|
179
205
|
}
|
|
206
|
+
/** Fetch a workspace with all its tasks eagerly loaded. */
|
|
180
207
|
export function getWorkspaceWithTasks(id) {
|
|
181
208
|
const workspace = getWorkspace(id);
|
|
182
209
|
if (!workspace)
|
|
@@ -184,6 +211,7 @@ export function getWorkspaceWithTasks(id) {
|
|
|
184
211
|
const tasks = listTasks(id);
|
|
185
212
|
return { ...workspace, tasks };
|
|
186
213
|
}
|
|
214
|
+
/** Archive a workspace (set archived_at). Throws if already archived. */
|
|
187
215
|
export function archiveWorkspace(id) {
|
|
188
216
|
const db = getDb();
|
|
189
217
|
const workspace = getWorkspace(id);
|
|
@@ -197,6 +225,7 @@ export function archiveWorkspace(id) {
|
|
|
197
225
|
db.prepare('UPDATE workspaces SET archived_at = ?, updated_at = ? WHERE id = ?').run(now, now, id);
|
|
198
226
|
return getWorkspace(id);
|
|
199
227
|
}
|
|
228
|
+
/** Unarchive a workspace (clear archived_at), restoring its previous status. */
|
|
200
229
|
export function unarchiveWorkspace(id) {
|
|
201
230
|
const db = getDb();
|
|
202
231
|
const workspace = getWorkspace(id);
|
|
@@ -221,6 +250,7 @@ function mapSession(row) {
|
|
|
221
250
|
endedAt: row.ended_at,
|
|
222
251
|
};
|
|
223
252
|
}
|
|
253
|
+
/** List all agent sessions for a workspace, most recent first. */
|
|
224
254
|
export function listSessions(workspaceId) {
|
|
225
255
|
const db = getDb();
|
|
226
256
|
const rows = db
|
|
@@ -228,6 +258,7 @@ export function listSessions(workspaceId) {
|
|
|
228
258
|
.all(workspaceId);
|
|
229
259
|
return rows.map(mapSession);
|
|
230
260
|
}
|
|
261
|
+
/** Get the most recent agent session for a workspace, or null if none exist. */
|
|
231
262
|
export function getLatestSession(workspaceId) {
|
|
232
263
|
const db = getDb();
|
|
233
264
|
const row = db
|
|
@@ -35,10 +35,10 @@ function removeFromExclude(projectPath, worktreePath) {
|
|
|
35
35
|
const entry = `/${relativePath}`;
|
|
36
36
|
const lines = fs.readFileSync(excludeFile, 'utf-8').split('\n');
|
|
37
37
|
const filtered = lines.filter((line) => line !== entry);
|
|
38
|
-
// I3: ensure the file ends with exactly one newline and has no trailing empty lines
|
|
39
38
|
const trimmed = filtered.join('\n').replace(/\n+$/, '');
|
|
40
39
|
fs.writeFileSync(excludeFile, trimmed ? `${trimmed}\n` : '', 'utf-8');
|
|
41
40
|
}
|
|
41
|
+
/** Create a git worktree under `.worktrees/` for the given branch. Returns the worktree path. */
|
|
42
42
|
export function createWorktree(projectPath, branchName, sourceBranch) {
|
|
43
43
|
const worktreesDir = path.join(projectPath, '.worktrees');
|
|
44
44
|
if (!fs.existsSync(worktreesDir)) {
|
|
@@ -51,7 +51,6 @@ export function createWorktree(projectPath, branchName, sourceBranch) {
|
|
|
51
51
|
}
|
|
52
52
|
catch (err) {
|
|
53
53
|
const message = err instanceof Error ? err.message : String(err);
|
|
54
|
-
// M3: use shared utility for branch-exists detection
|
|
55
54
|
// If branch already exists, add worktree without creating the branch
|
|
56
55
|
if (isGitBranchExistsError(message)) {
|
|
57
56
|
git(projectPath, ['worktree', 'add', worktreePath, branchName]);
|
|
@@ -63,6 +62,7 @@ export function createWorktree(projectPath, branchName, sourceBranch) {
|
|
|
63
62
|
addToExclude(projectPath, worktreePath);
|
|
64
63
|
return worktreePath;
|
|
65
64
|
}
|
|
65
|
+
/** Remove a git worktree and clean up the .git/info/exclude entry. */
|
|
66
66
|
export function removeWorktree(projectPath, worktreePath) {
|
|
67
67
|
try {
|
|
68
68
|
git(projectPath, ['worktree', 'remove', worktreePath, '--force']);
|
|
@@ -73,6 +73,7 @@ export function removeWorktree(projectPath, worktreePath) {
|
|
|
73
73
|
}
|
|
74
74
|
removeFromExclude(projectPath, worktreePath);
|
|
75
75
|
}
|
|
76
|
+
/** List all git worktrees for a repository by parsing `git worktree list --porcelain`. */
|
|
76
77
|
export function listWorktrees(projectPath) {
|
|
77
78
|
const output = git(projectPath, ['worktree', 'list', '--porcelain']);
|
|
78
79
|
const worktrees = [];
|
|
@@ -106,6 +107,7 @@ export function listWorktrees(projectPath) {
|
|
|
106
107
|
}
|
|
107
108
|
return worktrees;
|
|
108
109
|
}
|
|
110
|
+
/** Check whether a worktree for the given branch already exists. */
|
|
109
111
|
export function worktreeExists(projectPath, branchName) {
|
|
110
112
|
try {
|
|
111
113
|
const worktrees = listWorktrees(projectPath);
|
|
@@ -6,9 +6,11 @@ const execFileAsync = promisify(execFileCb);
|
|
|
6
6
|
function git(repoPath, args) {
|
|
7
7
|
return execFileSync('git', args, { cwd: repoPath, encoding: 'utf-8' }).trim();
|
|
8
8
|
}
|
|
9
|
+
/** Return the name of the currently checked-out branch. */
|
|
9
10
|
export function getCurrentBranch(repoPath) {
|
|
10
11
|
return git(repoPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
11
12
|
}
|
|
13
|
+
/** List all local branch names in the repository. */
|
|
12
14
|
export function listBranches(repoPath) {
|
|
13
15
|
const output = git(repoPath, ['branch', '--format=%(refname:short)']);
|
|
14
16
|
return output
|
|
@@ -16,20 +18,19 @@ export function listBranches(repoPath) {
|
|
|
16
18
|
.map((b) => b.trim())
|
|
17
19
|
.filter(Boolean);
|
|
18
20
|
}
|
|
21
|
+
/** Thrown when attempting to create a branch that already exists. */
|
|
19
22
|
export class BranchAlreadyExistsError extends Error {
|
|
20
23
|
constructor(branchName) {
|
|
21
24
|
super(`Branch '${branchName}' already exists`);
|
|
22
25
|
this.name = 'BranchAlreadyExistsError';
|
|
23
26
|
}
|
|
24
27
|
}
|
|
25
|
-
/**
|
|
26
|
-
* M3: Shared utility to detect "branch already exists" git error messages
|
|
27
|
-
* across different locales (English, French, Russian).
|
|
28
|
-
*/
|
|
28
|
+
/** Detect "branch already exists" git error messages across locales (EN, FR, RU). */
|
|
29
29
|
export function isGitBranchExistsError(message) {
|
|
30
30
|
const lower = message.toLowerCase();
|
|
31
31
|
return lower.includes('already exists') || lower.includes('existe') || lower.includes('существует');
|
|
32
32
|
}
|
|
33
|
+
/** Create a new local branch from the given source branch. */
|
|
33
34
|
export function createBranch(repoPath, branchName, sourceBranch) {
|
|
34
35
|
try {
|
|
35
36
|
git(repoPath, ['branch', branchName, sourceBranch]);
|
|
@@ -42,6 +43,7 @@ export function createBranch(repoPath, branchName, sourceBranch) {
|
|
|
42
43
|
throw new Error(`Failed to create branch '${branchName}' from '${sourceBranch}': ${message}`);
|
|
43
44
|
}
|
|
44
45
|
}
|
|
46
|
+
/** Return shortstat diff stats for staged (cached) changes. */
|
|
45
47
|
export function getDiffStats(repoPath) {
|
|
46
48
|
try {
|
|
47
49
|
const output = git(repoPath, ['diff', '--cached', '--shortstat']);
|
|
@@ -64,6 +66,7 @@ function parseDiffShortstat(output) {
|
|
|
64
66
|
deletions: deleteMatch ? parseInt(deleteMatch[1], 10) : 0,
|
|
65
67
|
};
|
|
66
68
|
}
|
|
69
|
+
/** List remote-tracking branch names. Returns empty array on failure. */
|
|
67
70
|
export function listRemoteBranches(repoPath) {
|
|
68
71
|
try {
|
|
69
72
|
const output = git(repoPath, ['branch', '-r', '--format=%(refname:short)']);
|
|
@@ -76,6 +79,7 @@ export function listRemoteBranches(repoPath) {
|
|
|
76
79
|
return [];
|
|
77
80
|
}
|
|
78
81
|
}
|
|
82
|
+
/** Force-delete a local branch (`git branch -D`). */
|
|
79
83
|
export function deleteLocalBranch(repoPath, branchName) {
|
|
80
84
|
try {
|
|
81
85
|
git(repoPath, ['branch', '-D', branchName]);
|
|
@@ -85,6 +89,7 @@ export function deleteLocalBranch(repoPath, branchName) {
|
|
|
85
89
|
throw new Error(`Failed to delete local branch '${branchName}': ${message}`);
|
|
86
90
|
}
|
|
87
91
|
}
|
|
92
|
+
/** Delete a branch on the remote (`git push --delete`). */
|
|
88
93
|
export function deleteRemoteBranch(repoPath, branchName, remote = 'origin') {
|
|
89
94
|
try {
|
|
90
95
|
git(repoPath, ['push', remote, '--delete', branchName]);
|
|
@@ -94,6 +99,7 @@ export function deleteRemoteBranch(repoPath, branchName, remote = 'origin') {
|
|
|
94
99
|
throw new Error(`Failed to delete remote branch '${remote}/${branchName}': ${message}`);
|
|
95
100
|
}
|
|
96
101
|
}
|
|
102
|
+
/** Push a branch to the remote with upstream tracking (`git push -u`). */
|
|
97
103
|
export function pushBranch(repoPath, branchName, remote = 'origin') {
|
|
98
104
|
try {
|
|
99
105
|
git(repoPath, ['push', '-u', remote, branchName]);
|
|
@@ -119,6 +125,7 @@ function resolveBase(repoPath, base) {
|
|
|
119
125
|
}
|
|
120
126
|
}
|
|
121
127
|
}
|
|
128
|
+
/** Count commits between base and head (`git rev-list --count`). Returns 0 on failure. */
|
|
122
129
|
export function getCommitCount(repoPath, base, head) {
|
|
123
130
|
try {
|
|
124
131
|
const ref = resolveBase(repoPath, base);
|
|
@@ -129,6 +136,7 @@ export function getCommitCount(repoPath, base, head) {
|
|
|
129
136
|
return 0;
|
|
130
137
|
}
|
|
131
138
|
}
|
|
139
|
+
/** Return structured diff shortstat between two refs (three-dot merge base). */
|
|
132
140
|
export function getStructuredDiffStatsBetween(repoPath, base, head) {
|
|
133
141
|
try {
|
|
134
142
|
const ref = resolveBase(repoPath, base);
|
|
@@ -139,6 +147,7 @@ export function getStructuredDiffStatsBetween(repoPath, base, head) {
|
|
|
139
147
|
return { filesChanged: 0, insertions: 0, deletions: 0 };
|
|
140
148
|
}
|
|
141
149
|
}
|
|
150
|
+
/** Return a formatted list of commit subjects between base and head. */
|
|
142
151
|
export function getCommitsBetween(repoPath, base, head) {
|
|
143
152
|
try {
|
|
144
153
|
const ref = resolveBase(repoPath, base);
|
|
@@ -148,6 +157,7 @@ export function getCommitsBetween(repoPath, base, head) {
|
|
|
148
157
|
return '';
|
|
149
158
|
}
|
|
150
159
|
}
|
|
160
|
+
/** Get the GitHub PR URL for a branch using `gh pr view`. Returns null if no PR exists. */
|
|
151
161
|
export function getPrUrl(repoPath, branchName) {
|
|
152
162
|
try {
|
|
153
163
|
return (execFileSync('gh', ['pr', 'view', branchName, '--json', 'url', '--jq', '.url'], {
|
|
@@ -159,6 +169,7 @@ export function getPrUrl(repoPath, branchName) {
|
|
|
159
169
|
return null;
|
|
160
170
|
}
|
|
161
171
|
}
|
|
172
|
+
/** Get the state and URL of the PR for a branch. Returns null if no PR exists. */
|
|
162
173
|
export function getPrStatus(repoPath, branchName) {
|
|
163
174
|
try {
|
|
164
175
|
const raw = execFileSync('gh', ['pr', 'view', branchName, '--json', 'state,url'], {
|
|
@@ -248,6 +259,7 @@ export function getFileContent(repoPath, filePath) {
|
|
|
248
259
|
return null;
|
|
249
260
|
}
|
|
250
261
|
}
|
|
262
|
+
/** Parse `git status --porcelain` into counts of staged, modified, and untracked files. */
|
|
251
263
|
export function getWorkingTreeStatus(repoPath) {
|
|
252
264
|
try {
|
|
253
265
|
const output = git(repoPath, ['status', '--porcelain']);
|
|
@@ -288,6 +300,7 @@ export function getUnpushedCount(repoPath) {
|
|
|
288
300
|
return -1; // no upstream
|
|
289
301
|
}
|
|
290
302
|
}
|
|
303
|
+
/** Return raw `git diff --shortstat` output between two refs (three-dot). */
|
|
291
304
|
export function getDiffStatsBetween(repoPath, base, head) {
|
|
292
305
|
try {
|
|
293
306
|
return git(repoPath, ['diff', '--shortstat', `${base}...${head}`]);
|
|
@@ -298,7 +311,7 @@ export function getDiffStatsBetween(repoPath, base, head) {
|
|
|
298
311
|
}
|
|
299
312
|
// ── Async versions ───────────────────────────────────────────────────────────
|
|
300
313
|
// Non-blocking alternatives for hot paths (pr-watcher, route handlers).
|
|
301
|
-
|
|
314
|
+
/** Async version of getPrUrl. Returns null if no PR exists. */
|
|
302
315
|
export async function getPrUrlAsync(repoPath, branchName) {
|
|
303
316
|
try {
|
|
304
317
|
const { stdout } = await execFileAsync('gh', ['pr', 'view', branchName, '--json', 'url', '--jq', '.url'], {
|
|
@@ -311,6 +324,7 @@ export async function getPrUrlAsync(repoPath, branchName) {
|
|
|
311
324
|
return null;
|
|
312
325
|
}
|
|
313
326
|
}
|
|
327
|
+
/** Async version of getPrStatus. Returns null if no PR exists. */
|
|
314
328
|
export async function getPrStatusAsync(repoPath, branchName) {
|
|
315
329
|
try {
|
|
316
330
|
const { stdout } = await execFileAsync('gh', ['pr', 'view', branchName, '--json', 'state,url'], {
|
|
@@ -1,16 +1,22 @@
|
|
|
1
|
+
/** In-memory map of workspace-id to spawned child process. */
|
|
1
2
|
const trackedProcesses = new Map();
|
|
3
|
+
/** Register a child process under the given workspace id. */
|
|
2
4
|
export function registerProcess(id, proc) {
|
|
3
5
|
trackedProcesses.set(id, proc);
|
|
4
6
|
}
|
|
7
|
+
/** Remove a child process from tracking without killing it. */
|
|
5
8
|
export function unregisterProcess(id) {
|
|
6
9
|
trackedProcesses.delete(id);
|
|
7
10
|
}
|
|
11
|
+
/** Retrieve the tracked child process for a workspace, if any. */
|
|
8
12
|
export function getProcess(id) {
|
|
9
13
|
return trackedProcesses.get(id);
|
|
10
14
|
}
|
|
15
|
+
/** Return the number of currently tracked processes. */
|
|
11
16
|
export function getTrackedCount() {
|
|
12
17
|
return trackedProcesses.size;
|
|
13
18
|
}
|
|
19
|
+
/** Send SIGTERM to all tracked processes, escalating to SIGKILL after 5 seconds. */
|
|
14
20
|
export function killAll() {
|
|
15
21
|
const procs = [...trackedProcesses.values()];
|
|
16
22
|
trackedProcesses.clear();
|
|
@@ -37,6 +43,7 @@ export function killAll() {
|
|
|
37
43
|
t.unref?.();
|
|
38
44
|
});
|
|
39
45
|
}
|
|
46
|
+
/** Register a process-exit handler that kills all tracked child processes. */
|
|
40
47
|
export function initProcessCleanup() {
|
|
41
48
|
process.on('exit', killAll);
|
|
42
49
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loicngr/kobo",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.6",
|
|
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",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.activity-feed[data-v-b51e6f0f]{flex-direction:column;gap:4px;display:flex;position:relative;overflow:hidden auto}.af-item[data-v-b51e6f0f]{word-break:break-word;overflow-wrap:break-word;flex-shrink:0;padding:6px 10px;overflow-x:hidden}.af-time[data-v-b51e6f0f]{color:#555;flex-shrink:0;font-size:10px}.af-tool-label[data-v-b51e6f0f]{font-family:Roboto Mono,monospace;font-size:11px}.af-tool-desc[data-v-b51e6f0f]{text-overflow:ellipsis;white-space:nowrap;font-size:11px;overflow:hidden}.af-ask-option-item[data-v-b51e6f0f]{padding:2px 0}.af-tool-args[data-v-b51e6f0f]{background-color:#ffffff0a;padding:6px 8px;overflow-x:auto}.af-args-pre[data-v-b51e6f0f]{color:#888;white-space:pre-wrap;word-break:break-word;margin:0;font-family:Roboto Mono,monospace;font-size:10px}.af-file-change[data-v-b51e6f0f]{background:#ffffff08;border:1px solid #ffffff0f;border-radius:6px;padding:6px 8px}.af-file-header[data-v-b51e6f0f]{font-family:Roboto Mono,monospace;font-size:11px}.af-lang-badge[data-v-b51e6f0f]{text-align:center;background:#ffffff0f;border-radius:3px;min-width:20px;padding:1px 4px;font-family:Roboto Mono,monospace;font-size:9px;font-weight:700}.af-file-path[data-v-b51e6f0f]{max-width:70%;font-size:11px}.af-diff-stats[data-v-b51e6f0f]{white-space:nowrap;font-family:Roboto Mono,monospace;font-size:10px}.af-diff-body[data-v-b51e6f0f]{background:#0003;border-radius:4px;max-height:300px;padding:4px 0;overflow:auto}.af-diff-line[data-v-b51e6f0f]{white-space:pre;min-width:-moz-fit-content;min-width:fit-content;padding:0 8px;font-family:Roboto Mono,monospace;font-size:10px;line-height:1.5}.af-diff-sign[data-v-b51e6f0f]{-webkit-user-select:none;user-select:none;width:12px;display:inline-block}.af-diff-del[data-v-b51e6f0f]{color:#f85149;background:#f851491a}.af-diff-add[data-v-b51e6f0f]{color:#3fb950;background:#3fb9501a}.af-item--text[data-v-b51e6f0f]{background-color:#1a2a3a;border-left:3px solid #3b82f6}.af-item--user[data-v-b51e6f0f]{background-color:#1a2a1a;border-left:3px solid #22c55e}.af-item--prompt[data-v-b51e6f0f]{background-color:#1a1a2e;border-left:3px solid #6c63ff}.af-text-content[data-v-b51e6f0f]{color:#d0d0d0;word-break:break-word;line-height:1.5}.af-markdown[data-v-b51e6f0f] p{margin:0 0 8px}.af-markdown[data-v-b51e6f0f] p:last-child{margin-bottom:0}.af-markdown[data-v-b51e6f0f] h1,.af-markdown[data-v-b51e6f0f] h2,.af-markdown[data-v-b51e6f0f] h3{color:#e0e0e0;margin:12px 0 6px}.af-markdown[data-v-b51e6f0f] h1{font-size:16px}.af-markdown[data-v-b51e6f0f] h2{font-size:14px}.af-markdown[data-v-b51e6f0f] h3{font-size:13px}.af-markdown[data-v-b51e6f0f] ul,.af-markdown[data-v-b51e6f0f] ol{margin:4px 0;padding-left:20px}.af-markdown[data-v-b51e6f0f] li{margin:2px 0}.af-markdown[data-v-b51e6f0f] code{background-color:#ffffff14;border-radius:3px;padding:1px 4px;font-family:Roboto Mono,monospace;font-size:11px}.af-markdown[data-v-b51e6f0f] pre{background-color:#0000004d;border-radius:4px;margin:6px 0;padding:8px;overflow-x:auto}.af-markdown[data-v-b51e6f0f] pre code{background:0 0;padding:0}.af-markdown[data-v-b51e6f0f] strong{color:#fff}.af-markdown[data-v-b51e6f0f] a{color:#6c63ff}.af-markdown[data-v-b51e6f0f] blockquote{color:#aaa;border-left:3px solid #6c63ff;margin:6px 0;padding:4px 12px}.af-markdown[data-v-b51e6f0f] table{border-collapse:collapse;margin:6px 0;font-size:11px}.af-markdown[data-v-b51e6f0f] table th,.af-markdown[data-v-b51e6f0f] table td{border:1px solid #2a2a4a;padding:4px 8px}.af-markdown[data-v-b51e6f0f] table th{background-color:#ffffff0d}.af-item--system[data-v-b51e6f0f]{background-color:#2a2a1a;border-left:3px solid #f59e0b}.af-system-details[data-v-b51e6f0f]{background-color:#ffffff0a;padding:6px 8px;overflow-x:auto}.af-system-content[data-v-b51e6f0f]{white-space:pre-wrap}.af-item--error[data-v-b51e6f0f]{background-color:#2a1a1a;border-left:3px solid #ef4444}.af-item--raw[data-v-b51e6f0f]{white-space:pre-wrap;font-family:Roboto Mono,monospace}.scroll-buttons[data-v-b51e6f0f]{align-self:flex-end;gap:6px;margin-right:8px;display:flex;position:sticky;bottom:8px}.scroll-btn[data-v-b51e6f0f]{opacity:.7;transition:opacity .15s}.scroll-btn[data-v-b51e6f0f]:hover{opacity:1}
|