@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.
Files changed (65) hide show
  1. package/README.md +26 -9
  2. package/dist/mcp-server/kobo-tasks-handlers.js +41 -1
  3. package/dist/mcp-server/kobo-tasks-server.js +157 -8
  4. package/dist/server/db/migrations.js +87 -0
  5. package/dist/server/db/schema.js +27 -0
  6. package/dist/server/index.js +9 -1
  7. package/dist/server/routes/health.js +68 -3
  8. package/dist/server/routes/workspaces.js +183 -4
  9. package/dist/server/services/agent/engines/claude-code/engine.js +13 -6
  10. package/dist/server/services/agent/engines/claude-code/event-mapper.js +96 -7
  11. package/dist/server/services/agent/orchestrator.js +113 -71
  12. package/dist/server/services/auto-loop-service.js +16 -3
  13. package/dist/server/services/cron-service.js +279 -0
  14. package/dist/server/services/quota-backoff-service.js +127 -0
  15. package/dist/server/services/wakeup-service.js +1 -1
  16. package/dist/server/services/workspace-service.js +98 -0
  17. package/dist/server/utils/git-ops.js +8 -1
  18. package/package.json +2 -1
  19. package/src/client/dist/spa/assets/{ActivityFeed-ClJLeAXJ.js → ActivityFeed-BboSPm4b.js} +2 -2
  20. package/src/client/dist/spa/assets/{ActivityFeed-DVBfmJWJ.css → ActivityFeed-tE4LVYck.css} +1 -1
  21. package/src/client/dist/spa/assets/AutoLoopChip-w8D77bI5.js +1 -0
  22. package/src/client/dist/spa/assets/{CreatePage-BOkt0Psl.js → CreatePage-BDObLDJc.js} +1 -1
  23. package/src/client/dist/spa/assets/{DiffViewer-Dls1jFCN.js → DiffViewer-CblFgn8w.js} +3 -3
  24. package/src/client/dist/spa/assets/{DiffViewer-wFfQ9tcY.css → DiffViewer-DTdDcKZC.css} +1 -1
  25. package/src/client/dist/spa/assets/HealthPage-CBSw7e5q.js +1 -0
  26. package/src/client/dist/spa/assets/{MainLayout-DHNIerYJ.js → MainLayout-DhaYycak.js} +17 -17
  27. package/src/client/dist/spa/assets/MainLayout-drolsINz.css +1 -0
  28. package/src/client/dist/spa/assets/{SearchPage-BEnZ-CLq.js → SearchPage-cZTwP4Lf.js} +1 -1
  29. package/src/client/dist/spa/assets/{SettingsPage-DeCbWvPb.js → SettingsPage-C1efO0VM.js} +1 -1
  30. package/src/client/dist/spa/assets/WorkspacePage-3jcof896.js +4 -0
  31. package/src/client/dist/spa/assets/{WorkspacePage-eymEd4kx.css → WorkspacePage-CCtIrBiR.css} +1 -1
  32. package/src/client/dist/spa/assets/{cssMode-AlflsawW.js → cssMode-BFLYiiEw.js} +1 -1
  33. package/src/client/dist/spa/assets/{editor.api-DtvjQlUm.js → editor.api-2asmmhth.js} +1 -1
  34. package/src/client/dist/spa/assets/{editor.main-Ccy_gjVD.js → editor.main-ChCYZyez.js} +3 -3
  35. package/src/client/dist/spa/assets/{expand-template-AQsvbQ8_.js → expand-template-CXQFkQOJ.js} +1 -1
  36. package/src/client/dist/spa/assets/{freemarker2-DdQktlXK.js → freemarker2-BaBL9E9G.js} +1 -1
  37. package/src/client/dist/spa/assets/{handlebars-CE3ee2NH.js → handlebars-BxDour4L.js} +1 -1
  38. package/src/client/dist/spa/assets/{html-CCKX8Xv9.js → html-C6hnkfIL.js} +1 -1
  39. package/src/client/dist/spa/assets/{htmlMode-Dh8jDJum.js → htmlMode-9zT3-dmz.js} +1 -1
  40. package/src/client/dist/spa/assets/i18n-CLY0XI9-.js +1 -0
  41. package/src/client/dist/spa/assets/index-D6wj_wQ9.js +2 -0
  42. package/src/client/dist/spa/assets/{javascript-DhmZNdUp.js → javascript-C3YjvKbE.js} +1 -1
  43. package/src/client/dist/spa/assets/{jsonMode-B0xAtnNK.js → jsonMode-DcJDgMzf.js} +1 -1
  44. package/src/client/dist/spa/assets/{liquid-ByL0HpZ0.js → liquid-CsT8SjJM.js} +1 -1
  45. package/src/client/dist/spa/assets/{mdx-DX4pehAZ.js → mdx-CT3yVSyc.js} +1 -1
  46. package/src/client/dist/spa/assets/{models-ClWoqWeC.js → models-BsjWUKqM.js} +1 -1
  47. package/src/client/dist/spa/assets/{monaco.contribution-Fegh8Y1Y.js → monaco.contribution-DKGNz1oQ.js} +2 -2
  48. package/src/client/dist/spa/assets/{purify.es-BWZjBa9F.js → purify.es-CPieV82n.js} +1 -1
  49. package/src/client/dist/spa/assets/{python-COS2MM8n.js → python-Ca5miKgj.js} +1 -1
  50. package/src/client/dist/spa/assets/{razor-Cc3xCJU7.js → razor-7qzusGRc.js} +1 -1
  51. package/src/client/dist/spa/assets/{render-chat-markdown-DcGIpMoe.js → render-chat-markdown-Bqq2G-yI.js} +1 -1
  52. package/src/client/dist/spa/assets/{tsMode-eQIJjERk.js → tsMode-BdvO8jZ2.js} +1 -1
  53. package/src/client/dist/spa/assets/{typescript-DwIlacVU.js → typescript-BfVNzhgs.js} +1 -1
  54. package/src/client/dist/spa/assets/{xml-DP-09Aih.js → xml-DGNXGqXL.js} +1 -1
  55. package/src/client/dist/spa/assets/{yaml-BhrtimeA.js → yaml-CtAtOyt5.js} +1 -1
  56. package/src/client/dist/spa/index.html +1 -1
  57. package/src/mcp-server/kobo-tasks-handlers.ts +55 -1
  58. package/src/mcp-server/kobo-tasks-server.ts +165 -7
  59. package/src/client/dist/spa/assets/HealthPage-CMxH3SBS.js +0 -1
  60. package/src/client/dist/spa/assets/MainLayout-DKurmqtk.css +0 -1
  61. package/src/client/dist/spa/assets/WorkspacePage-DFAFT5OW.js +0 -4
  62. package/src/client/dist/spa/assets/i18n-BOsrrRj4.js +0 -1
  63. package/src/client/dist/spa/assets/index-_ZaIBxd6.js +0 -2
  64. /package/src/client/dist/spa/assets/{QPage-ChUKoaKe.js → QPage-DFi3K093.js} +0 -0
  65. /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('SELECT id, auto_loop, auto_loop_ready, no_progress_streak FROM workspaces WHERE archived_at IS NULL')
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
- return c.json({ error: 'Missing field: status, model, reasoningEffort, agentPermissionMode, or name' }, 400);
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
- if (lower.includes('rate limit exceeded') ||
102
- lower.includes('rate_limit_exceeded') ||
103
- (lower.includes('429') && lower.includes('rate')) ||
104
- lower.includes('quota exceeded')) {
105
- onEvent({ kind: 'error', category: 'quota', message: data });
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
- return { buckets };
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 { sessionStartedEmitted: false, openMessages: new Map(), sawErrorResult: false };
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
- events.push({ kind: 'rate_limit', info: normalizeRateLimitInfo(info) });
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
- events.push({ kind: 'message:text', messageId, text: block.text, streaming: true });
226
+ const text = block.text;
227
+ events.push({ kind: 'message:text', messageId, text, streaming: true });
153
228
  msgState.sawText = true;
154
- if (block.text.includes('[BRAINSTORM_COMPLETE]')) {
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
- events.push({ kind: 'error', category: 'other', message });
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) {