@loicngr/kobo 1.7.4 → 1.7.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/README.md +26 -9
- package/dist/mcp-server/kobo-tasks-handlers.js +41 -1
- package/dist/mcp-server/kobo-tasks-server.js +157 -8
- package/dist/server/db/migrations.js +87 -0
- package/dist/server/db/schema.js +27 -0
- package/dist/server/index.js +9 -1
- package/dist/server/routes/health.js +68 -3
- package/dist/server/routes/workspaces.js +183 -4
- package/dist/server/services/agent/engines/claude-code/engine.js +13 -6
- package/dist/server/services/agent/engines/claude-code/event-mapper.js +96 -7
- package/dist/server/services/agent/orchestrator.js +113 -71
- package/dist/server/services/auto-loop-service.js +16 -3
- package/dist/server/services/cron-service.js +279 -0
- package/dist/server/services/quota-backoff-service.js +127 -0
- package/dist/server/services/wakeup-service.js +1 -1
- package/dist/server/services/workspace-service.js +98 -0
- package/dist/server/utils/git-ops.js +8 -1
- package/package.json +2 -1
- package/src/client/dist/spa/assets/{ActivityFeed-ClJLeAXJ.js → ActivityFeed-BboSPm4b.js} +2 -2
- package/src/client/dist/spa/assets/{ActivityFeed-DVBfmJWJ.css → ActivityFeed-tE4LVYck.css} +1 -1
- package/src/client/dist/spa/assets/AutoLoopChip-w8D77bI5.js +1 -0
- package/src/client/dist/spa/assets/{CreatePage-BOkt0Psl.js → CreatePage-BDObLDJc.js} +1 -1
- package/src/client/dist/spa/assets/{DiffViewer-Dls1jFCN.js → DiffViewer-CblFgn8w.js} +3 -3
- package/src/client/dist/spa/assets/{DiffViewer-wFfQ9tcY.css → DiffViewer-DTdDcKZC.css} +1 -1
- package/src/client/dist/spa/assets/HealthPage-CBSw7e5q.js +1 -0
- package/src/client/dist/spa/assets/{MainLayout-DHNIerYJ.js → MainLayout-DhaYycak.js} +17 -17
- package/src/client/dist/spa/assets/MainLayout-drolsINz.css +1 -0
- package/src/client/dist/spa/assets/{SearchPage-BEnZ-CLq.js → SearchPage-cZTwP4Lf.js} +1 -1
- package/src/client/dist/spa/assets/{SettingsPage-DeCbWvPb.js → SettingsPage-C1efO0VM.js} +1 -1
- package/src/client/dist/spa/assets/WorkspacePage-3jcof896.js +4 -0
- package/src/client/dist/spa/assets/{WorkspacePage-eymEd4kx.css → WorkspacePage-CCtIrBiR.css} +1 -1
- package/src/client/dist/spa/assets/{cssMode-AlflsawW.js → cssMode-BFLYiiEw.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-DtvjQlUm.js → editor.api-2asmmhth.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-Ccy_gjVD.js → editor.main-ChCYZyez.js} +3 -3
- package/src/client/dist/spa/assets/{expand-template-AQsvbQ8_.js → expand-template-CXQFkQOJ.js} +1 -1
- package/src/client/dist/spa/assets/{freemarker2-DdQktlXK.js → freemarker2-BaBL9E9G.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-CE3ee2NH.js → handlebars-BxDour4L.js} +1 -1
- package/src/client/dist/spa/assets/{html-CCKX8Xv9.js → html-C6hnkfIL.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-Dh8jDJum.js → htmlMode-9zT3-dmz.js} +1 -1
- package/src/client/dist/spa/assets/i18n-CLY0XI9-.js +1 -0
- package/src/client/dist/spa/assets/index-D6wj_wQ9.js +2 -0
- package/src/client/dist/spa/assets/{javascript-DhmZNdUp.js → javascript-C3YjvKbE.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-B0xAtnNK.js → jsonMode-DcJDgMzf.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-ByL0HpZ0.js → liquid-CsT8SjJM.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-DX4pehAZ.js → mdx-CT3yVSyc.js} +1 -1
- package/src/client/dist/spa/assets/{models-ClWoqWeC.js → models-BsjWUKqM.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-Fegh8Y1Y.js → monaco.contribution-DKGNz1oQ.js} +2 -2
- package/src/client/dist/spa/assets/{purify.es-BWZjBa9F.js → purify.es-CPieV82n.js} +1 -1
- package/src/client/dist/spa/assets/{python-COS2MM8n.js → python-Ca5miKgj.js} +1 -1
- package/src/client/dist/spa/assets/{razor-Cc3xCJU7.js → razor-7qzusGRc.js} +1 -1
- package/src/client/dist/spa/assets/{render-chat-markdown-DcGIpMoe.js → render-chat-markdown-Bqq2G-yI.js} +1 -1
- package/src/client/dist/spa/assets/{tsMode-eQIJjERk.js → tsMode-BdvO8jZ2.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-DwIlacVU.js → typescript-BfVNzhgs.js} +1 -1
- package/src/client/dist/spa/assets/{xml-DP-09Aih.js → xml-DGNXGqXL.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-BhrtimeA.js → yaml-CtAtOyt5.js} +1 -1
- package/src/client/dist/spa/index.html +1 -1
- package/src/mcp-server/kobo-tasks-handlers.ts +55 -1
- package/src/mcp-server/kobo-tasks-server.ts +165 -7
- package/src/client/dist/spa/assets/HealthPage-CMxH3SBS.js +0 -1
- package/src/client/dist/spa/assets/MainLayout-DKurmqtk.css +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-DFAFT5OW.js +0 -4
- package/src/client/dist/spa/assets/i18n-BOsrrRj4.js +0 -1
- package/src/client/dist/spa/assets/index-_ZaIBxd6.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
|
@@ -10,11 +10,13 @@ import { migrationGuard } from '../middleware/migration-guard.js';
|
|
|
10
10
|
import { listEngines } from '../services/agent/engines/registry.js';
|
|
11
11
|
import * as agentManager from '../services/agent/orchestrator.js';
|
|
12
12
|
import * as autoLoopService from '../services/auto-loop-service.js';
|
|
13
|
+
import * as cronService from '../services/cron-service.js';
|
|
13
14
|
import * as devServerService from '../services/dev-server-service.js';
|
|
14
15
|
import { DEFAULT_NOTION_INITIAL_PROMPT, DEFAULT_SENTRY_INITIAL_PROMPT, renderNotionInitialPrompt, renderSentryInitialPrompt, } from '../services/initial-prompt-template-service.js';
|
|
15
16
|
import * as notionService from '../services/notion-service.js';
|
|
16
17
|
import { renderPrTemplate } from '../services/pr-template-service.js';
|
|
17
18
|
import { getAllPrStates } from '../services/pr-watcher-service.js';
|
|
19
|
+
import * as quotaBackoffService from '../services/quota-backoff-service.js';
|
|
18
20
|
import { DEFAULT_REVIEW_PROMPT_TEMPLATE, renderReviewTemplate } from '../services/review-template-service.js';
|
|
19
21
|
import * as sentryService from '../services/sentry-service.js';
|
|
20
22
|
import * as settingsService from '../services/settings-service.js';
|
|
@@ -585,6 +587,10 @@ app.post('/', migrationGuard, async (c) => {
|
|
|
585
587
|
if (body.notionUrl) {
|
|
586
588
|
brainstormPrompt += `- get_notion_ticket() — retrieve the Notion ticket info (URL, ticket ID, extracted content)\n`;
|
|
587
589
|
}
|
|
590
|
+
brainstormPrompt += `- kobo__set_workspace_agent_description(description) — keep the workspace's agent_description up to date as a short one-line summary of what you're currently doing or have just accomplished. The user sees this in the sidebar without opening the workspace. Update it whenever your focus shifts (e.g. "Investigating SERVICE-1600 → enriching local Notion file", then "Writing failing test for FacturX validator"). Plain text, max 200 chars. The current value is in kobo__get_workspace_info.\nThere is also a separate user-controlled \`description\` field on the workspace — DO NOT touch it. Only set_workspace_agent_description is yours to write; the user owns the other one.\n`;
|
|
591
|
+
brainstormPrompt += `- kobo__cron_create(expression, prompt, label?, mode?, oneShot?) — schedule a (recurring or one-shot) trigger on THIS workspace. At each fire Kōbō waits for the workspace to be idle and then injects \`prompt\` as the next user message. \`expression\` is a standard 5-field cron (\`min hour dom month dow\`) or a helper (\`@hourly\`, \`@daily\`, \`@weekly\`, \`@monthly\`, \`@yearly\`). Examples: \`*/30 * * * *\` = every 30 min; \`0 9 * * 1\` = every Monday at 9am; \`0 14 7 6 *\` = 7 June at 14:00. \`mode\` is \`'resume'\` (default — every fire continues the SAME conversation that scheduled the cron, so you can chain follow-ups) or \`'fresh'\` (every fire starts a brand-new session with a clean context, ideal for periodic checks like CI watch). \`oneShot\` (default false): when true, the cron cancels itself after the first real fire — use this to trigger once at a specific time without recurring. Skip-if-active: occurrences fired while a session is running are skipped, the next is computed, and the cron continues. Persists across restarts. Returns a cron \`id\`.\n`;
|
|
592
|
+
brainstormPrompt += `- kobo__cron_delete(id) — cancel a previously-armed cron by id (idempotent).\n`;
|
|
593
|
+
brainstormPrompt += `- kobo__cron_list() — list every cron currently armed on THIS workspace, with their next/last fire times.\n`;
|
|
588
594
|
if (effectiveSettings.gitConventions) {
|
|
589
595
|
brainstormPrompt += `\n# Git conventions\nIMPORTANT: Before any git operation (commit, branch, rebase, merge, push), read and apply the conventions defined in \`.ai/.git-conventions.md\`. They are project-specific and override any default behavior. Re-read this file if you're unsure or if context was compacted.\n`;
|
|
590
596
|
}
|
|
@@ -713,8 +719,17 @@ app.get('/pr-states', (c) => {
|
|
|
713
719
|
app.get('/auto-loop-states', (c) => {
|
|
714
720
|
try {
|
|
715
721
|
const db = getDb();
|
|
722
|
+
// Task counts via LEFT JOIN so workspaces without tasks still appear with 0/0.
|
|
723
|
+
// Drives the sidebar AutoLoopChip badge (X / Y) for non-focused workspaces.
|
|
716
724
|
const rows = db
|
|
717
|
-
.prepare(
|
|
725
|
+
.prepare(`SELECT w.id, w.auto_loop, w.auto_loop_ready, w.no_progress_streak,
|
|
726
|
+
COUNT(t.id) AS tasks_total,
|
|
727
|
+
SUM(CASE WHEN t.status = 'done' THEN 1 ELSE 0 END) AS tasks_done,
|
|
728
|
+
(SELECT COUNT(*) FROM pending_crons p WHERE p.workspace_id = w.id) AS crons_count
|
|
729
|
+
FROM workspaces w
|
|
730
|
+
LEFT JOIN tasks t ON t.workspace_id = w.id
|
|
731
|
+
WHERE w.archived_at IS NULL
|
|
732
|
+
GROUP BY w.id`)
|
|
718
733
|
.all();
|
|
719
734
|
const out = {};
|
|
720
735
|
for (const r of rows) {
|
|
@@ -722,6 +737,9 @@ app.get('/auto-loop-states', (c) => {
|
|
|
722
737
|
auto_loop: r.auto_loop === 1,
|
|
723
738
|
auto_loop_ready: r.auto_loop_ready === 1,
|
|
724
739
|
no_progress_streak: r.no_progress_streak,
|
|
740
|
+
tasks_done: r.tasks_done ?? 0,
|
|
741
|
+
tasks_total: r.tasks_total ?? 0,
|
|
742
|
+
crons_count: r.crons_count ?? 0,
|
|
725
743
|
};
|
|
726
744
|
}
|
|
727
745
|
return c.json(out);
|
|
@@ -783,6 +801,85 @@ app.post('/:id/auto-loop-ready', (c) => {
|
|
|
783
801
|
return c.json({ error: message }, 500);
|
|
784
802
|
}
|
|
785
803
|
});
|
|
804
|
+
// GET /api/workspaces/:id/crons — list pending crons for a workspace.
|
|
805
|
+
app.get('/:id/crons', (c) => {
|
|
806
|
+
try {
|
|
807
|
+
const id = c.req.param('id');
|
|
808
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
809
|
+
if (!workspace)
|
|
810
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
811
|
+
return c.json({ crons: cronService.listForWorkspace(id) });
|
|
812
|
+
}
|
|
813
|
+
catch (err) {
|
|
814
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
815
|
+
return c.json({ error: message }, 500);
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
// POST /api/workspaces/:id/crons — arm a new cron. Validates the expression
|
|
819
|
+
// in the service layer; invalid expressions surface as a 400.
|
|
820
|
+
app.post('/:id/crons', async (c) => {
|
|
821
|
+
try {
|
|
822
|
+
const id = c.req.param('id');
|
|
823
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
824
|
+
if (!workspace)
|
|
825
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
826
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
827
|
+
const expression = typeof body.expression === 'string' ? body.expression : '';
|
|
828
|
+
const prompt = typeof body.prompt === 'string' ? body.prompt : '';
|
|
829
|
+
const label = typeof body.label === 'string' ? body.label : undefined;
|
|
830
|
+
const rawMode = typeof body.mode === 'string' ? body.mode : 'resume';
|
|
831
|
+
if (rawMode !== 'resume' && rawMode !== 'fresh') {
|
|
832
|
+
return c.json({ error: "mode must be 'resume' or 'fresh'" }, 400);
|
|
833
|
+
}
|
|
834
|
+
const mode = rawMode;
|
|
835
|
+
const oneShot = body.oneShot === true;
|
|
836
|
+
if (!expression || !prompt) {
|
|
837
|
+
return c.json({ error: 'expression and prompt are required' }, 400);
|
|
838
|
+
}
|
|
839
|
+
try {
|
|
840
|
+
// Mode controls how each fire is handled:
|
|
841
|
+
// - 'resume' (default): pin the cron to the session that scheduled it,
|
|
842
|
+
// so each fire resumes THAT conversation. Same pattern as wakeup.
|
|
843
|
+
// - 'fresh': don't pin a session — every fire spawns a new session
|
|
844
|
+
// with a clean context. Useful for periodic checks (e.g. CI watch)
|
|
845
|
+
// that don't need conversation continuity.
|
|
846
|
+
// oneShot=true cancels the cron after the first real fire (skip-active
|
|
847
|
+
// ticks don't consume the one-shot — the cron retries at the next
|
|
848
|
+
// occurrence until it actually fires once).
|
|
849
|
+
// The DB encodes mode via `agent_session_id`: non-NULL = resume that
|
|
850
|
+
// session; NULL = fresh. When mode='resume' but no session is active
|
|
851
|
+
// at create time, fall back to NULL — fire will spawn fresh.
|
|
852
|
+
const agentSessionId = mode === 'resume' ? (agentManager.getActiveSessionId(id) ?? undefined) : undefined;
|
|
853
|
+
const cron = cronService.arm(id, { expression, prompt, label, agentSessionId, oneShot });
|
|
854
|
+
return c.json({ cron }, 201);
|
|
855
|
+
}
|
|
856
|
+
catch (err) {
|
|
857
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
858
|
+
return c.json({ error: message }, 400);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
catch (err) {
|
|
862
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
863
|
+
return c.json({ error: message }, 500);
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
// DELETE /api/workspaces/:id/crons/:cronId — cancel a single cron. Idempotent:
|
|
867
|
+
// returns 204 even when the cron does not exist (matches pending-wakeup style).
|
|
868
|
+
app.delete('/:id/crons/:cronId', (c) => {
|
|
869
|
+
try {
|
|
870
|
+
const id = c.req.param('id');
|
|
871
|
+
const cronId = c.req.param('cronId');
|
|
872
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
873
|
+
if (!workspace)
|
|
874
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
875
|
+
cronService.cancel(cronId, 'user');
|
|
876
|
+
return new Response(null, { status: 204 });
|
|
877
|
+
}
|
|
878
|
+
catch (err) {
|
|
879
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
880
|
+
return c.json({ error: message }, 500);
|
|
881
|
+
}
|
|
882
|
+
});
|
|
786
883
|
// GET /api/workspaces/:id/pending-wakeup — returns the pending wakeup or null.
|
|
787
884
|
app.get('/:id/pending-wakeup', (c) => {
|
|
788
885
|
try {
|
|
@@ -808,6 +905,37 @@ app.delete('/:id/pending-wakeup', (c) => {
|
|
|
808
905
|
return c.json({ error: message }, 500);
|
|
809
906
|
}
|
|
810
907
|
});
|
|
908
|
+
// GET /api/workspaces/:id/quota-backoff — returns the pending quota backoff or null.
|
|
909
|
+
app.get('/:id/quota-backoff', (c) => {
|
|
910
|
+
try {
|
|
911
|
+
const id = c.req.param('id');
|
|
912
|
+
const ws = workspaceService.getWorkspace(id);
|
|
913
|
+
if (!ws)
|
|
914
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
915
|
+
const pending = quotaBackoffService.getPending(id);
|
|
916
|
+
c.header('Cache-Control', 'no-store');
|
|
917
|
+
return c.json(pending);
|
|
918
|
+
}
|
|
919
|
+
catch (err) {
|
|
920
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
921
|
+
return c.json({ error: message }, 500);
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
// DELETE /api/workspaces/:id/quota-backoff — user-initiated cancel ("×" button).
|
|
925
|
+
app.delete('/:id/quota-backoff', (c) => {
|
|
926
|
+
try {
|
|
927
|
+
const id = c.req.param('id');
|
|
928
|
+
const ws = workspaceService.getWorkspace(id);
|
|
929
|
+
if (!ws)
|
|
930
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
931
|
+
quotaBackoffService.cancel(id, 'user');
|
|
932
|
+
return new Response(null, { status: 204 });
|
|
933
|
+
}
|
|
934
|
+
catch (err) {
|
|
935
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
936
|
+
return c.json({ error: message }, 500);
|
|
937
|
+
}
|
|
938
|
+
});
|
|
811
939
|
// POST /api/workspaces/:id/pending-wakeup — agent-initiated schedule via the
|
|
812
940
|
// `kobo__schedule_wakeup` MCP tool. Replaces any existing pending wakeup.
|
|
813
941
|
app.post('/:id/pending-wakeup', async (c) => {
|
|
@@ -1057,6 +1185,44 @@ app.post('/:id/tasks/notify-updated', (c) => {
|
|
|
1057
1185
|
return c.json({ error: message }, 500);
|
|
1058
1186
|
}
|
|
1059
1187
|
});
|
|
1188
|
+
// POST /api/workspaces/:id/agent-description/notify-updated — broadcast
|
|
1189
|
+
// workspace:agent-description-updated after the MCP set_workspace_agent_description
|
|
1190
|
+
// handler wrote the column directly. Mirrors the notify-done / notify-updated
|
|
1191
|
+
// pattern: the route doesn't re-write, just emits the WS event so the sidebar
|
|
1192
|
+
// chip + workspace header italic line refresh in real time.
|
|
1193
|
+
app.post('/:id/agent-description/notify-updated', (c) => {
|
|
1194
|
+
try {
|
|
1195
|
+
const id = c.req.param('id');
|
|
1196
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
1197
|
+
if (!workspace) {
|
|
1198
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1199
|
+
}
|
|
1200
|
+
wsService.emitEphemeral(id, 'workspace:agent-description-updated', {
|
|
1201
|
+
agentDescription: workspace.agentDescription,
|
|
1202
|
+
});
|
|
1203
|
+
return new Response(null, { status: 204 });
|
|
1204
|
+
}
|
|
1205
|
+
catch (err) {
|
|
1206
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1207
|
+
return c.json({ error: message }, 500);
|
|
1208
|
+
}
|
|
1209
|
+
});
|
|
1210
|
+
// POST /api/workspaces/:id/crons/notify-updated — broadcast cron list changed
|
|
1211
|
+
// after the MCP cron_create / cron_delete handlers wrote directly to DB.
|
|
1212
|
+
app.post('/:id/crons/notify-updated', (c) => {
|
|
1213
|
+
try {
|
|
1214
|
+
const id = c.req.param('id');
|
|
1215
|
+
const workspace = workspaceService.getWorkspace(id);
|
|
1216
|
+
if (!workspace)
|
|
1217
|
+
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1218
|
+
wsService.emitEphemeral(id, 'cron:updated', { crons: cronService.listForWorkspace(id) });
|
|
1219
|
+
return new Response(null, { status: 204 });
|
|
1220
|
+
}
|
|
1221
|
+
catch (err) {
|
|
1222
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1223
|
+
return c.json({ error: message }, 500);
|
|
1224
|
+
}
|
|
1225
|
+
});
|
|
1060
1226
|
// POST /api/workspaces/:id/tasks/:taskId/notify-done — broadcast task:updated event
|
|
1061
1227
|
app.post('/:id/tasks/:taskId/notify-done', (c) => {
|
|
1062
1228
|
try {
|
|
@@ -1270,6 +1436,10 @@ app.patch('/:id', migrationGuard, async (c) => {
|
|
|
1270
1436
|
if (!workspace) {
|
|
1271
1437
|
return c.json({ error: `Workspace '${id}' not found` }, 404);
|
|
1272
1438
|
}
|
|
1439
|
+
// agent_description is exclusively writable via the MCP tool, never via the API.
|
|
1440
|
+
if ('agent_description' in body) {
|
|
1441
|
+
return c.json({ error: 'agent_description must be set via the agent MCP tool, not via the API' }, 400);
|
|
1442
|
+
}
|
|
1273
1443
|
let updated = workspace;
|
|
1274
1444
|
if (body.model !== undefined) {
|
|
1275
1445
|
updated = workspaceService.updateWorkspaceModel(id, body.model);
|
|
@@ -1289,12 +1459,20 @@ app.patch('/:id', migrationGuard, async (c) => {
|
|
|
1289
1459
|
if (body.name !== undefined) {
|
|
1290
1460
|
updated = workspaceService.updateWorkspaceName(id, body.name);
|
|
1291
1461
|
}
|
|
1462
|
+
if ('description' in body) {
|
|
1463
|
+
const desc = body.description;
|
|
1464
|
+
if (desc !== null && typeof desc !== 'string') {
|
|
1465
|
+
return c.json({ error: 'description must be a string or null' }, 400);
|
|
1466
|
+
}
|
|
1467
|
+
updated = workspaceService.updateWorkspaceDescription(id, desc);
|
|
1468
|
+
}
|
|
1292
1469
|
if (!body.status &&
|
|
1293
1470
|
body.model === undefined &&
|
|
1294
1471
|
body.reasoningEffort === undefined &&
|
|
1295
1472
|
body.agentPermissionMode === undefined &&
|
|
1296
|
-
body.name === undefined
|
|
1297
|
-
|
|
1473
|
+
body.name === undefined &&
|
|
1474
|
+
!('description' in body)) {
|
|
1475
|
+
return c.json({ error: 'Missing field: status, model, reasoningEffort, agentPermissionMode, name, or description' }, 400);
|
|
1298
1476
|
}
|
|
1299
1477
|
return c.json(updated);
|
|
1300
1478
|
}
|
|
@@ -1305,7 +1483,8 @@ app.patch('/:id', migrationGuard, async (c) => {
|
|
|
1305
1483
|
}
|
|
1306
1484
|
if (message.includes('Invalid status transition') ||
|
|
1307
1485
|
message.includes('name cannot be empty') ||
|
|
1308
|
-
message.includes('name cannot exceed')
|
|
1486
|
+
message.includes('name cannot exceed') ||
|
|
1487
|
+
message.includes('Description must be')) {
|
|
1309
1488
|
return c.json({ error: message }, 400);
|
|
1310
1489
|
}
|
|
1311
1490
|
return c.json({ error: message }, 500);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { query, } from '@anthropic-ai/claude-agent-sdk';
|
|
2
2
|
import { nanoid } from 'nanoid';
|
|
3
3
|
import { CLAUDE_CODE_CAPABILITIES } from './capabilities.js';
|
|
4
|
-
import { createMapperState, mapSdkMessage } from './event-mapper.js';
|
|
4
|
+
import { createMapperState, mapSdkMessage, QUOTA_PATTERN, tryEmitQuota } from './event-mapper.js';
|
|
5
5
|
import { buildClaudeOptions } from './options-builder.js';
|
|
6
6
|
import { buildPreCompactCustomInstructions } from './precompact-hook.js';
|
|
7
7
|
import { resolveClaudeBinaryPath } from './resolve-binary.js';
|
|
@@ -97,12 +97,19 @@ export function createClaudeCodeEngine() {
|
|
|
97
97
|
hooks,
|
|
98
98
|
canUseTool,
|
|
99
99
|
stderr: (data) => {
|
|
100
|
+
// QUOTA_PATTERN covers the canonical surfaces (rate_limit,
|
|
101
|
+
// out of extra usage, usage limit, quota exceeded). The 429+rate
|
|
102
|
+
// combo is a CLI-only HTTP-level surface that the SDK never emits
|
|
103
|
+
// structurally, so it stays as a separate guard alongside.
|
|
100
104
|
const lower = data.toLowerCase();
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
const isQuota = QUOTA_PATTERN.test(data) || (lower.includes('429') && lower.includes('rate'));
|
|
106
|
+
if (isQuota) {
|
|
107
|
+
// Share `mapperState.quotaErrorEmitted` with the SDK iterator so
|
|
108
|
+
// a single run that surfaces quota via BOTH stderr AND a
|
|
109
|
+
// structured SDK signal (assistant.error / rate_limit_event)
|
|
110
|
+
// does not double-fire `handleQuota` (which would double the
|
|
111
|
+
// retryCount and overwrite the persisted backoff row).
|
|
112
|
+
tryEmitQuota(mapperState, onEvent, data);
|
|
106
113
|
}
|
|
107
114
|
else if (lower.includes('no conversation found with session id')) {
|
|
108
115
|
onEvent({ kind: 'error', category: 'resume_failed', message: data });
|
|
@@ -29,6 +29,14 @@ function makeBucket(id, source) {
|
|
|
29
29
|
const details = used !== undefined && limit !== undefined ? `${String(used)} / ${String(limit)}` : undefined;
|
|
30
30
|
return { id, label, usedPct: Math.max(0, Math.min(100, usedPct)), resetsAt, details };
|
|
31
31
|
}
|
|
32
|
+
const RATE_LIMIT_STATUSES = new Set(['allowed', 'allowed_warning', 'rejected']);
|
|
33
|
+
function extractStatus(info) {
|
|
34
|
+
const raw = info.status;
|
|
35
|
+
if (typeof raw === 'string' && RATE_LIMIT_STATUSES.has(raw)) {
|
|
36
|
+
return raw;
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
32
40
|
function normalizeRateLimitInfo(info) {
|
|
33
41
|
const buckets = [];
|
|
34
42
|
if (typeof info.rateLimitType === 'string') {
|
|
@@ -50,13 +58,35 @@ function normalizeRateLimitInfo(info) {
|
|
|
50
58
|
buckets.push(b);
|
|
51
59
|
}
|
|
52
60
|
}
|
|
53
|
-
|
|
61
|
+
const status = extractStatus(info);
|
|
62
|
+
return status ? { buckets, status } : { buckets };
|
|
54
63
|
}
|
|
64
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
65
|
+
/**
|
|
66
|
+
* Canonical "out of quota" surfaces from the Claude SDK and CLI. Centralised
|
|
67
|
+
* so the three call-sites stay in sync:
|
|
68
|
+
* - `result` events with an error subtype (`parsed.error` / `parsed.result`)
|
|
69
|
+
* - assistant `message:text` blocks (the SDK occasionally streams the user-
|
|
70
|
+
* visible quota notice as plain assistant text instead of a structured
|
|
71
|
+
* error result; see workspace `-GyiAYM7X4xTWyZbcHGiR` session #25 for the
|
|
72
|
+
* repro that motivated this path)
|
|
73
|
+
* - CLI stderr in `engine.ts`
|
|
74
|
+
*
|
|
75
|
+
* Patterns are kept loose on purpose to absorb minor wording drift between
|
|
76
|
+
* Anthropic's surfaces (`rate_limit_exceeded`, `Claude AI usage limit
|
|
77
|
+
* reached`, `You're out of extra usage`, `quota exceeded`).
|
|
78
|
+
*/
|
|
79
|
+
export const QUOTA_PATTERN = /out of extra usage|rate[_ ]limit|usage limit|quota exceeded/i;
|
|
55
80
|
export function createMapperState() {
|
|
56
|
-
return {
|
|
81
|
+
return {
|
|
82
|
+
sessionStartedEmitted: false,
|
|
83
|
+
openMessages: new Map(),
|
|
84
|
+
sawErrorResult: false,
|
|
85
|
+
quotaErrorEmitted: false,
|
|
86
|
+
};
|
|
57
87
|
}
|
|
58
88
|
/** Known SDK `result` subtypes that indicate the run failed. */
|
|
59
|
-
const KNOWN_ERROR_RESULT_SUBTYPES = new Set(['error_max_turns', 'error_during_execution']);
|
|
89
|
+
export const KNOWN_ERROR_RESULT_SUBTYPES = new Set(['error_max_turns', 'error_during_execution']);
|
|
60
90
|
function isErrorResultSubtype(subtype) {
|
|
61
91
|
if (!subtype)
|
|
62
92
|
return false;
|
|
@@ -64,6 +94,36 @@ function isErrorResultSubtype(subtype) {
|
|
|
64
94
|
return true;
|
|
65
95
|
return subtype.startsWith('error');
|
|
66
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* SDK error codes (`SDKAssistantMessageError`) that map to a quota exhaustion
|
|
99
|
+
* — the user has hit the 5h/7d cap or run out of overage credits.
|
|
100
|
+
* - `'rate_limit'`: classic 429 / Anthropic rate-limit reached
|
|
101
|
+
* - `'billing_error'`: claude.ai overage credits exhausted
|
|
102
|
+
*/
|
|
103
|
+
export const QUOTA_ASSISTANT_ERRORS = new Set(['rate_limit', 'billing_error']);
|
|
104
|
+
/**
|
|
105
|
+
* Emit an `error/quota` event exactly once per SDK run, regardless of which
|
|
106
|
+
* surface detected the quota (stderr, SDK iterator, message:text fallback…).
|
|
107
|
+
* Also sets `sawErrorResult` so the engine surfaces
|
|
108
|
+
* `session:ended.reason='error'`, which the orchestrator then maps to a
|
|
109
|
+
* `quota` status transition via the `category: 'quota'` discriminator.
|
|
110
|
+
*
|
|
111
|
+
* Exported so the stderr path in `engine.ts` (which bypasses `mapSdkMessage`)
|
|
112
|
+
* can share the same one-shot guard. Without this, two quota surfaces in the
|
|
113
|
+
* same run would call `handleQuota` twice → `retryCount` doubled and the
|
|
114
|
+
* persisted backoff row overwritten.
|
|
115
|
+
*/
|
|
116
|
+
export function tryEmitQuota(state, emit, message) {
|
|
117
|
+
if (state.quotaErrorEmitted)
|
|
118
|
+
return;
|
|
119
|
+
state.quotaErrorEmitted = true;
|
|
120
|
+
state.sawErrorResult = true;
|
|
121
|
+
emit({ kind: 'error', category: 'quota', message });
|
|
122
|
+
}
|
|
123
|
+
/** Internal wrapper for the in-mapper push pattern. */
|
|
124
|
+
function tryEmitQuotaError(state, events, message) {
|
|
125
|
+
tryEmitQuota(state, (ev) => events.push(ev), message);
|
|
126
|
+
}
|
|
67
127
|
/**
|
|
68
128
|
* Maps a single typed `SDKMessage` to zero or more `AgentEvent`s, mutating
|
|
69
129
|
* `state` as needed.
|
|
@@ -80,7 +140,13 @@ export function mapSdkMessage(msg, state) {
|
|
|
80
140
|
if (type === 'rate_limit_event') {
|
|
81
141
|
const info = parsed.rate_limit_info;
|
|
82
142
|
if (info && typeof info === 'object') {
|
|
83
|
-
|
|
143
|
+
const normalized = normalizeRateLimitInfo(info);
|
|
144
|
+
events.push({ kind: 'rate_limit', info: normalized });
|
|
145
|
+
// `status: 'rejected'` from the SDK is the explicit "request blocked,
|
|
146
|
+
// out of quota" signal — the most reliable structured surface.
|
|
147
|
+
if (normalized.status === 'rejected') {
|
|
148
|
+
tryEmitQuotaError(state, events, 'Rate limit rejected by Claude SDK (rate_limit_event)');
|
|
149
|
+
}
|
|
84
150
|
}
|
|
85
151
|
return events;
|
|
86
152
|
}
|
|
@@ -129,6 +195,14 @@ export function mapSdkMessage(msg, state) {
|
|
|
129
195
|
return events;
|
|
130
196
|
}
|
|
131
197
|
if (type === 'assistant') {
|
|
198
|
+
// `SDKAssistantMessage.error` is a typed enum that includes 'rate_limit'
|
|
199
|
+
// and 'billing_error' — explicit, structured quota signals. Surface them
|
|
200
|
+
// before any text processing so the orchestrator transitions to `quota`
|
|
201
|
+
// even on otherwise empty assistant turns.
|
|
202
|
+
const assistantError = typeof parsed.error === 'string' ? parsed.error : undefined;
|
|
203
|
+
if (assistantError && QUOTA_ASSISTANT_ERRORS.has(assistantError)) {
|
|
204
|
+
tryEmitQuotaError(state, events, `Assistant message error: ${assistantError}`);
|
|
205
|
+
}
|
|
132
206
|
const message = parsed.message;
|
|
133
207
|
const messageId = typeof message?.id === 'string' ? message.id : 'unknown';
|
|
134
208
|
const content = Array.isArray(message?.content) ? message?.content : [];
|
|
@@ -149,11 +223,19 @@ export function mapSdkMessage(msg, state) {
|
|
|
149
223
|
for (const block of content) {
|
|
150
224
|
const blockType = block.type;
|
|
151
225
|
if (blockType === 'text' && typeof block.text === 'string') {
|
|
152
|
-
|
|
226
|
+
const text = block.text;
|
|
227
|
+
events.push({ kind: 'message:text', messageId, text, streaming: true });
|
|
153
228
|
msgState.sawText = true;
|
|
154
|
-
if (
|
|
229
|
+
if (text.includes('[BRAINSTORM_COMPLETE]')) {
|
|
155
230
|
events.push({ kind: 'session:brainstorm-complete' });
|
|
156
231
|
}
|
|
232
|
+
// Last-resort fallback: some SDK runs surface the quota notice as
|
|
233
|
+
// plain assistant text without setting `assistant.error` or a
|
|
234
|
+
// `result.error`. The structured signals above cover modern SDK
|
|
235
|
+
// versions; this regex absorbs older or drifted wordings.
|
|
236
|
+
if (QUOTA_PATTERN.test(text)) {
|
|
237
|
+
tryEmitQuotaError(state, events, text);
|
|
238
|
+
}
|
|
157
239
|
}
|
|
158
240
|
if (blockType === 'tool_use') {
|
|
159
241
|
events.push({
|
|
@@ -211,8 +293,15 @@ export function mapSdkMessage(msg, state) {
|
|
|
211
293
|
if (isErrorResultSubtype(subtype)) {
|
|
212
294
|
state.sawErrorResult = true;
|
|
213
295
|
const detail = (typeof parsed.error === 'string' && parsed.error) || (typeof parsed.result === 'string' && parsed.result) || '';
|
|
296
|
+
const isQuota = QUOTA_PATTERN.test(detail);
|
|
214
297
|
const message = detail ? `Agent run failed (${subtype}): ${detail}` : `Agent run failed (${subtype})`;
|
|
215
|
-
|
|
298
|
+
if (isQuota) {
|
|
299
|
+
// Coordinate with the structured quota path so we never emit twice.
|
|
300
|
+
tryEmitQuotaError(state, events, message);
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
events.push({ kind: 'error', category: 'other', message });
|
|
304
|
+
}
|
|
216
305
|
}
|
|
217
306
|
const usage = parsed.usage;
|
|
218
307
|
if (usage) {
|