@loicngr/kobo 1.7.5 → 1.7.7

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 (106) hide show
  1. package/README.md +92 -3
  2. package/dist/mcp-server/kobo-tasks-handlers.js +15 -0
  3. package/dist/mcp-server/kobo-tasks-server.js +117 -8
  4. package/dist/server/db/migrations.js +38 -0
  5. package/dist/server/db/schema.js +16 -0
  6. package/dist/server/index.js +4 -0
  7. package/dist/server/routes/health.js +68 -3
  8. package/dist/server/routes/voice.js +149 -0
  9. package/dist/server/routes/workspaces.js +102 -1
  10. package/dist/server/services/agent/engines/claude-code/engine.js +13 -9
  11. package/dist/server/services/agent/engines/claude-code/event-mapper.js +95 -10
  12. package/dist/server/services/agent/orchestrator.js +41 -0
  13. package/dist/server/services/auto-loop-service.js +8 -3
  14. package/dist/server/services/cron-service.js +279 -0
  15. package/dist/server/services/settings-service.js +57 -0
  16. package/dist/server/services/transcription-service.js +206 -0
  17. package/dist/server/services/wakeup-service.js +1 -1
  18. package/dist/server/services/workspace-service.js +18 -0
  19. package/dist/server/utils/git-ops.js +8 -1
  20. package/package.json +13 -10
  21. package/src/client/dist/spa/assets/{ActivityFeed-oW9PgZ8E.js → ActivityFeed-DlPVoOGb.js} +2 -2
  22. package/src/client/dist/spa/assets/{ActivityFeed-DVBfmJWJ.css → ActivityFeed-tE4LVYck.css} +1 -1
  23. package/src/client/dist/spa/assets/{AutoLoopChip-Y53cnGfZ.js → AutoLoopChip-CkSzkC0C.js} +1 -1
  24. package/src/client/dist/spa/assets/{ClosePopup-D_UAdwkA.js → ClosePopup-DTcbxsC0.js} +1 -1
  25. package/src/client/dist/spa/assets/CreatePage-BoRappO3.css +1 -0
  26. package/src/client/dist/spa/assets/CreatePage-DpCVNwYk.js +2 -0
  27. package/src/client/dist/spa/assets/{DiffViewer-rc3tE9fq.js → DiffViewer-D-uNbBq0.js} +3 -3
  28. package/src/client/dist/spa/assets/{DiffViewer-wFfQ9tcY.css → DiffViewer-DTdDcKZC.css} +1 -1
  29. package/src/client/dist/spa/assets/HealthPage-xZ0PP4F-.js +1 -0
  30. package/src/client/dist/spa/assets/{MainLayout-B9i06p7n.js → MainLayout-DdkKM2ba.js} +17 -17
  31. package/src/client/dist/spa/assets/MainLayout-drolsINz.css +1 -0
  32. package/src/client/dist/spa/assets/{QBadge-DWH42dbo.js → QBadge-C7r6oPSi.js} +1 -1
  33. package/src/client/dist/spa/assets/{QBtn-a6jxWjmW.js → QBtn-DEuWKHbR.js} +1 -1
  34. package/src/client/dist/spa/assets/{QCheckbox-D5jfsxLV.js → QCheckbox-BvHfXBFY.js} +1 -1
  35. package/src/client/dist/spa/assets/{QChip-ByxK0Tuf.js → QChip-erWIZgxW.js} +1 -1
  36. package/src/client/dist/spa/assets/{QExpansionItem-CH1ipL9n.js → QExpansionItem-BGg74no1.js} +1 -1
  37. package/src/client/dist/spa/assets/QIcon-qfJNZLIW.js +1 -0
  38. package/src/client/dist/spa/assets/{QInput-Cm5-AGQ4.js → QInput-DCJEwE8V.js} +1 -1
  39. package/src/client/dist/spa/assets/{QItemLabel-DrTxqTqV.js → QItemLabel-CHkgkZVj.js} +1 -1
  40. package/src/client/dist/spa/assets/{QItemSection-5YpFpPDm.js → QItemSection-CQUDd0Vg.js} +1 -1
  41. package/src/client/dist/spa/assets/{QList-D0FtnQJI.js → QList-BbnN_oNX.js} +1 -1
  42. package/src/client/dist/spa/assets/{QMenu-B4xMxMGd.js → QMenu-D6uqosRg.js} +1 -1
  43. package/src/client/dist/spa/assets/{QPage-DFi3K093.js → QPage-Co2h9wd_.js} +1 -1
  44. package/src/client/dist/spa/assets/{QRadio-B3aKjCVu.js → QRadio-DJxOyOA3.js} +1 -1
  45. package/src/client/dist/spa/assets/QSpace-DKIph84L.js +1 -0
  46. package/src/client/dist/spa/assets/{QSpinnerDots-CszPQQ9J.js → QSpinnerDots-Bfl2RMy4.js} +1 -1
  47. package/src/client/dist/spa/assets/{QTabPanels-D2ks0UIA.js → QTabPanels-ClPY9y4T.js} +1 -1
  48. package/src/client/dist/spa/assets/{QToggle-1-N9qWq4.js → QToggle-DNOTC_3a.js} +1 -1
  49. package/src/client/dist/spa/assets/{QTooltip-fDNzBEfN.js → QTooltip-DUGPNNeQ.js} +1 -1
  50. package/src/client/dist/spa/assets/{SearchPage-DdX7JZCD.js → SearchPage-C07dgzT9.js} +1 -1
  51. package/src/client/dist/spa/assets/SettingsPage-CLNmI0Rr.css +1 -0
  52. package/src/client/dist/spa/assets/SettingsPage-D0CZNqkA.js +9 -0
  53. package/src/client/dist/spa/assets/{TouchPan-DoE24Io3.js → TouchPan-DvVlszwO.js} +1 -1
  54. package/src/client/dist/spa/assets/WorkspacePage-CKeCLPi0.js +4 -0
  55. package/src/client/dist/spa/assets/WorkspacePage-CRIcsASQ.css +1 -0
  56. package/src/client/dist/spa/assets/{build-path-tree-B1Lvvqto.js → build-path-tree-CCMckvpr.js} +1 -1
  57. package/src/client/dist/spa/assets/{cssMode-DSB5jkRt.js → cssMode-D6XTTdwy.js} +1 -1
  58. package/src/client/dist/spa/assets/{documents-kx0vLfSG.js → documents-soWtna0O.js} +1 -1
  59. package/src/client/dist/spa/assets/{editor.api-Bcw50eFD.js → editor.api-6hDVHddO.js} +1 -1
  60. package/src/client/dist/spa/assets/{editor.main-D9piVGaH.js → editor.main-DsLU1RWu.js} +3 -3
  61. package/src/client/dist/spa/assets/{expand-template-BIPuNAYV.js → expand-template-Crz1uiBt.js} +1 -1
  62. package/src/client/dist/spa/assets/{formatters-DCAQ6ANJ.js → formatters-guwb-rzl.js} +1 -1
  63. package/src/client/dist/spa/assets/{freemarker2-CVh_Zh8H.js → freemarker2-Bn1f0t2U.js} +1 -1
  64. package/src/client/dist/spa/assets/{handlebars-CpCgELpu.js → handlebars-O92Cbq66.js} +1 -1
  65. package/src/client/dist/spa/assets/{html-ikWDpvWk.js → html-Ck95BMBU.js} +1 -1
  66. package/src/client/dist/spa/assets/{htmlMode-C9TTCKih.js → htmlMode-DDYhH2FJ.js} +1 -1
  67. package/src/client/dist/spa/assets/i18n-BLgknHpf.js +1 -0
  68. package/src/client/dist/spa/assets/index-CdHDdk1y.js +2 -0
  69. package/src/client/dist/spa/assets/{javascript-C4OlkNeA.js → javascript-Cy2ddqHg.js} +1 -1
  70. package/src/client/dist/spa/assets/{jsonMode-BiD34_86.js → jsonMode-BIfVcp5z.js} +1 -1
  71. package/src/client/dist/spa/assets/{liquid-Dty0Ui2c.js → liquid-B287eegh.js} +1 -1
  72. package/src/client/dist/spa/assets/{mdx-yiUjOVv6.js → mdx-B8HSzGai.js} +1 -1
  73. package/src/client/dist/spa/assets/{models-BDkLiht9.js → models-Bd_v3W7Q.js} +1 -1
  74. package/src/client/dist/spa/assets/{monaco.contribution-Bz9yFPWR.js → monaco.contribution-CofcHzEf.js} +2 -2
  75. package/src/client/dist/spa/assets/{notifications-OnPq4FrH.js → notifications-BPnKFW60.js} +1 -1
  76. package/src/client/dist/spa/assets/{purify.es-BIY760fF.js → purify.es-BCEwTYRx.js} +1 -1
  77. package/src/client/dist/spa/assets/{python-7SPSWQoD.js → python-csaKR6_U.js} +1 -1
  78. package/src/client/dist/spa/assets/{razor-eagZawXK.js → razor-C2wEv-nX.js} +1 -1
  79. package/src/client/dist/spa/assets/{render-chat-markdown-TvAqpDih.js → render-chat-markdown-Bjcei0vn.js} +1 -1
  80. package/src/client/dist/spa/assets/runtime-core.esm-bundler-9Z0QAO_7.js +1 -0
  81. package/src/client/dist/spa/assets/{tsMode-CLYG2xeJ.js → tsMode-DGLVs57K.js} +1 -1
  82. package/src/client/dist/spa/assets/{typescript-CzOXM8yS.js → typescript-w0GWHzZ3.js} +1 -1
  83. package/src/client/dist/spa/assets/{use-checkbox-D7zmRxGI.js → use-checkbox-y_fOkYZN.js} +1 -1
  84. package/src/client/dist/spa/assets/{use-id-CuaR1RiE.js → use-id-_7wiRcgb.js} +1 -1
  85. package/src/client/dist/spa/assets/{use-panel-D-8nAQns.js → use-panel-CbJ44rqY.js} +1 -1
  86. package/src/client/dist/spa/assets/use-quasar-DQYS47mh.js +1 -0
  87. package/src/client/dist/spa/assets/{vue-i18n-BcfTCFFS.js → vue-i18n-DI-gS-CC.js} +1 -1
  88. package/src/client/dist/spa/assets/{xml-2_0_6RAX.js → xml-CTn-vnEd.js} +1 -1
  89. package/src/client/dist/spa/assets/{yaml-CtpgNyXs.js → yaml-CTyUSvLZ.js} +1 -1
  90. package/src/client/dist/spa/index.html +12 -12
  91. package/src/mcp-server/kobo-tasks-handlers.ts +20 -0
  92. package/src/mcp-server/kobo-tasks-server.ts +123 -7
  93. package/src/client/dist/spa/assets/CreatePage-CuD7sMR7.js +0 -2
  94. package/src/client/dist/spa/assets/CreatePage-DssmsAsV.css +0 -1
  95. package/src/client/dist/spa/assets/HealthPage-Dz0yGGMB.js +0 -1
  96. package/src/client/dist/spa/assets/MainLayout-DDa3rGKA.css +0 -1
  97. package/src/client/dist/spa/assets/QIcon-BJuyqdsT.js +0 -1
  98. package/src/client/dist/spa/assets/QSpace-CLtL3aPy.js +0 -1
  99. package/src/client/dist/spa/assets/SettingsPage-CMyeQ9_u.css +0 -1
  100. package/src/client/dist/spa/assets/SettingsPage-Dnj1CWc3.js +0 -1
  101. package/src/client/dist/spa/assets/WorkspacePage-CCtIrBiR.css +0 -1
  102. package/src/client/dist/spa/assets/WorkspacePage-DHp20nl-.js +0 -4
  103. package/src/client/dist/spa/assets/i18n-DZCb8dnb.js +0 -1
  104. package/src/client/dist/spa/assets/index-DuK38XN5.js +0 -2
  105. package/src/client/dist/spa/assets/runtime-core.esm-bundler-C3IgBgY5.js +0 -1
  106. package/src/client/dist/spa/assets/use-quasar-Sdcq6zzV.js +0 -1
@@ -0,0 +1,149 @@
1
+ import { Hono } from 'hono';
2
+ import * as settingsService from '../services/settings-service.js';
3
+ import * as transcriptionService from '../services/transcription-service.js';
4
+ import * as workspaceService from '../services/workspace-service.js';
5
+ const app = new Hono();
6
+ const MAX_AUDIO_SIZE = 10 * 1024 * 1024;
7
+ const ALLOWED_AUDIO_MIME = new Set(['audio/webm', 'audio/ogg', 'audio/wav', 'audio/mpeg', 'audio/mp4']);
8
+ const LANGUAGE_RE = /^[a-z-]+$/i;
9
+ function isVoiceLikeError(err) {
10
+ if (!err || typeof err !== 'object')
11
+ return false;
12
+ const e = err;
13
+ return typeof e.message === 'string' && typeof e.code === 'string' && typeof e.status === 'number';
14
+ }
15
+ function toVoiceHttpStatus(status) {
16
+ return status === 400 ? 400 : 500;
17
+ }
18
+ async function parseAndTranscribeFromBody(c, config) {
19
+ const body = await c.req.parseBody();
20
+ const audio = body.audio;
21
+ const languageRaw = body.language;
22
+ const language = typeof languageRaw === 'string' && languageRaw.trim().length > 0 ? languageRaw.trim() : 'auto';
23
+ if (language !== 'auto' && (!LANGUAGE_RE.test(language) || language.length > 16)) {
24
+ return c.json({ error: `Invalid language '${language}'`, code: 'LANGUAGE_INVALID' }, 400);
25
+ }
26
+ if (!audio || !(audio instanceof File)) {
27
+ return c.json({ error: 'Missing audio field in multipart body', code: 'MIC_AUDIO_INVALID' }, 400);
28
+ }
29
+ if (!ALLOWED_AUDIO_MIME.has(audio.type)) {
30
+ return c.json({ error: `Unsupported audio type '${audio.type}'`, code: 'MIC_AUDIO_INVALID' }, 400);
31
+ }
32
+ const buffer = Buffer.from(await audio.arrayBuffer());
33
+ if (buffer.length === 0 || buffer.length > MAX_AUDIO_SIZE) {
34
+ return c.json({ error: 'Invalid audio size', code: 'MIC_AUDIO_INVALID' }, 400);
35
+ }
36
+ const result = await transcriptionService.transcribeAudio({
37
+ audioBuffer: buffer,
38
+ modelName: config.modelName,
39
+ language,
40
+ temperature: config.temperature,
41
+ prompt: config.prompt,
42
+ translateToEnglish: config.translateToEnglish,
43
+ suppressNonSpeechTokens: config.suppressNonSpeechTokens,
44
+ });
45
+ return c.json(result);
46
+ }
47
+ app.get('/models', (c) => {
48
+ try {
49
+ return c.json(transcriptionService.listVoiceModels());
50
+ }
51
+ catch (err) {
52
+ const message = err instanceof Error ? err.message : String(err);
53
+ return c.json({ error: message }, 500);
54
+ }
55
+ });
56
+ app.get('/runtime', async (c) => {
57
+ try {
58
+ const status = await transcriptionService.getVoiceRuntimeStatus();
59
+ return c.json(status);
60
+ }
61
+ catch (err) {
62
+ const message = err instanceof Error ? err.message : String(err);
63
+ return c.json({ error: message, code: 'VOICE_RUNTIME_CHECK_FAILED' }, 500);
64
+ }
65
+ });
66
+ app.post('/models/:name/download', async (c) => {
67
+ try {
68
+ const name = c.req.param('name');
69
+ const result = await transcriptionService.downloadVoiceModel(name);
70
+ return c.json(result, 201);
71
+ }
72
+ catch (err) {
73
+ if (err instanceof transcriptionService.VoiceError || isVoiceLikeError(err)) {
74
+ return c.json({ error: err.message, code: err.code }, toVoiceHttpStatus(err.status));
75
+ }
76
+ const message = err instanceof Error ? err.message : String(err);
77
+ return c.json({ error: message, code: 'MODEL_DOWNLOAD_FAILED' }, 500);
78
+ }
79
+ });
80
+ app.delete('/models/:name', (c) => {
81
+ try {
82
+ const name = c.req.param('name');
83
+ transcriptionService.deleteVoiceModel(name);
84
+ return c.body(null, 204);
85
+ }
86
+ catch (err) {
87
+ if (err instanceof transcriptionService.VoiceError || isVoiceLikeError(err)) {
88
+ return c.json({ error: err.message, code: err.code }, toVoiceHttpStatus(err.status));
89
+ }
90
+ const message = err instanceof Error ? err.message : String(err);
91
+ return c.json({ error: message, code: 'MODEL_DELETE_FAILED' }, 500);
92
+ }
93
+ });
94
+ app.post('/workspaces/:id/transcribe', async (c) => {
95
+ try {
96
+ const id = c.req.param('id');
97
+ const workspace = workspaceService.getWorkspace(id);
98
+ if (!workspace)
99
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
100
+ const global = settingsService.getGlobalSettings();
101
+ if (!global.voiceEnabled) {
102
+ return c.json({ error: 'Voice transcription is disabled', code: 'VOICE_DISABLED' }, 400);
103
+ }
104
+ if (!global.voiceModel) {
105
+ return c.json({ error: 'No voice model configured', code: 'MODEL_NOT_CONFIGURED' }, 400);
106
+ }
107
+ return await parseAndTranscribeFromBody(c, {
108
+ modelName: global.voiceModel,
109
+ temperature: global.voiceTemperature,
110
+ prompt: global.voicePrompt,
111
+ translateToEnglish: global.voiceTranslateToEnglish,
112
+ suppressNonSpeechTokens: global.voiceSuppressNonSpeechTokens,
113
+ });
114
+ }
115
+ catch (err) {
116
+ if (err instanceof transcriptionService.VoiceError || isVoiceLikeError(err)) {
117
+ return c.json({ error: err.message, code: err.code }, toVoiceHttpStatus(err.status));
118
+ }
119
+ const message = err instanceof Error ? err.message : String(err);
120
+ return c.json({ error: message, code: 'TRANSCRIPTION_FAILED' }, 500);
121
+ }
122
+ });
123
+ // Draft transcription endpoint used before a workspace exists (Create page).
124
+ app.post('/transcribe', async (c) => {
125
+ try {
126
+ const global = settingsService.getGlobalSettings();
127
+ if (!global.voiceEnabled) {
128
+ return c.json({ error: 'Voice transcription is disabled', code: 'VOICE_DISABLED' }, 400);
129
+ }
130
+ if (!global.voiceModel) {
131
+ return c.json({ error: 'No voice model configured', code: 'MODEL_NOT_CONFIGURED' }, 400);
132
+ }
133
+ return await parseAndTranscribeFromBody(c, {
134
+ modelName: global.voiceModel,
135
+ temperature: global.voiceTemperature,
136
+ prompt: global.voicePrompt,
137
+ translateToEnglish: global.voiceTranslateToEnglish,
138
+ suppressNonSpeechTokens: global.voiceSuppressNonSpeechTokens,
139
+ });
140
+ }
141
+ catch (err) {
142
+ if (err instanceof transcriptionService.VoiceError) {
143
+ return c.json({ error: err.message, code: err.code }, toVoiceHttpStatus(err.status));
144
+ }
145
+ const message = err instanceof Error ? err.message : String(err);
146
+ return c.json({ error: message, code: 'TRANSCRIPTION_FAILED' }, 500);
147
+ }
148
+ });
149
+ export default app;
@@ -10,6 +10,7 @@ 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';
@@ -587,6 +588,9 @@ app.post('/', migrationGuard, async (c) => {
587
588
  brainstormPrompt += `- get_notion_ticket() — retrieve the Notion ticket info (URL, ticket ID, extracted content)\n`;
588
589
  }
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`;
590
594
  if (effectiveSettings.gitConventions) {
591
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`;
592
596
  }
@@ -720,7 +724,8 @@ app.get('/auto-loop-states', (c) => {
720
724
  const rows = db
721
725
  .prepare(`SELECT w.id, w.auto_loop, w.auto_loop_ready, w.no_progress_streak,
722
726
  COUNT(t.id) AS tasks_total,
723
- SUM(CASE WHEN t.status = 'done' THEN 1 ELSE 0 END) AS tasks_done
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
724
729
  FROM workspaces w
725
730
  LEFT JOIN tasks t ON t.workspace_id = w.id
726
731
  WHERE w.archived_at IS NULL
@@ -734,6 +739,7 @@ app.get('/auto-loop-states', (c) => {
734
739
  no_progress_streak: r.no_progress_streak,
735
740
  tasks_done: r.tasks_done ?? 0,
736
741
  tasks_total: r.tasks_total ?? 0,
742
+ crons_count: r.crons_count ?? 0,
737
743
  };
738
744
  }
739
745
  return c.json(out);
@@ -795,6 +801,85 @@ app.post('/:id/auto-loop-ready', (c) => {
795
801
  return c.json({ error: message }, 500);
796
802
  }
797
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
+ });
798
883
  // GET /api/workspaces/:id/pending-wakeup — returns the pending wakeup or null.
799
884
  app.get('/:id/pending-wakeup', (c) => {
800
885
  try {
@@ -1122,6 +1207,22 @@ app.post('/:id/agent-description/notify-updated', (c) => {
1122
1207
  return c.json({ error: message }, 500);
1123
1208
  }
1124
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
+ });
1125
1226
  // POST /api/workspaces/:id/tasks/:taskId/notify-done — broadcast task:updated event
1126
1227
  app.post('/:id/tasks/:taskId/notify-done', (c) => {
1127
1228
  try {
@@ -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,15 +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
- lower.includes('out of extra usage') ||
106
- // "Claude AI usage limit reached" Anthropic's 5h/4h cap message
107
- lower.includes('usage limit')) {
108
- 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);
109
113
  }
110
114
  else if (lower.includes('no conversation found with session id')) {
111
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,10 +58,32 @@ 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
89
  export const KNOWN_ERROR_RESULT_SUBTYPES = new Set(['error_max_turns', 'error_during_execution']);
@@ -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,12 +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) || '';
214
- // "Claude AI usage limit reached" is Anthropic's 5h/4h cap surface — added
215
- // to the regex so the orchestrator transitions the workspace to `quota`
216
- // (not `error`) and the auto-loop backoff path engages.
217
- const isQuota = /out of extra usage|rate limit|usage limit/i.test(detail);
296
+ const isQuota = QUOTA_PATTERN.test(detail);
218
297
  const message = detail ? `Agent run failed (${subtype}): ${detail}` : `Agent run failed (${subtype})`;
219
- events.push({ kind: 'error', category: isQuota ? 'quota' : '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
+ }
220
305
  }
221
306
  const usage = parsed.usage;
222
307
  if (usage) {
@@ -4,6 +4,7 @@ import { getDb } from '../../db/index.js';
4
4
  import { ensureKoboHome, getCompiledMcpServerPath, getDbPath, getKoboHome, getMcpServerSourcePath, getSettingsPath, getSkillsPath, } from '../../utils/paths.js';
5
5
  import { unregisterProcess } from '../../utils/process-tracker.js';
6
6
  import * as autoLoopService from '../auto-loop-service.js';
7
+ import * as cronService from '../cron-service.js';
7
8
  import * as quotaBackoffService from '../quota-backoff-service.js';
8
9
  import { getEffectiveSettings } from '../settings-service.js';
9
10
  import { refreshNow } from '../usage/poller.js';
@@ -447,6 +448,46 @@ function handleEvent(workspaceId, agentSessionId, ev) {
447
448
  wakeupService.schedule(workspaceId, delay, prompt, reason, agentSessionId);
448
449
  }
449
450
  }
451
+ // Same legacy bridge for the SDK's native `CronCreate`. The native tool is
452
+ // session-only (the cron dies when the agent session exits and is not
453
+ // persisted to disk), which makes it useless for any real "schedule a
454
+ // recurring trigger" need. We intercept the tool:call and arm an equivalent
455
+ // kobo cron in parallel — persistent across restarts, owned by the backend.
456
+ if (ev.kind === 'tool:call' && ev.name === 'CronCreate') {
457
+ const input = ev.input;
458
+ const prompt = typeof input?.prompt === 'string' ? input.prompt : '';
459
+ // The SDK's exact field name has drifted across versions — try the most
460
+ // likely candidates. If none match we log the input shape so the user
461
+ // can extend this list.
462
+ const expression = (typeof input?.cron === 'string' && input.cron) ||
463
+ (typeof input?.schedule === 'string' && input.schedule) ||
464
+ (typeof input?.expression === 'string' && input.expression) ||
465
+ '';
466
+ if (prompt && expression) {
467
+ console.warn(`[orchestrator] Native CronCreate intercepted for workspace '${workspaceId}' — armed equivalent kobo cron. Prefer kobo__cron_create.`);
468
+ try {
469
+ cronService.arm(workspaceId, {
470
+ expression,
471
+ prompt,
472
+ label: 'from-native-CronCreate',
473
+ agentSessionId,
474
+ });
475
+ }
476
+ catch (err) {
477
+ console.error('[orchestrator] Failed to mirror native CronCreate as kobo cron:', err);
478
+ }
479
+ }
480
+ else if (prompt || input) {
481
+ console.warn(`[orchestrator] Native CronCreate intercepted but unrecognised input shape (workspace '${workspaceId}'):`, Object.keys(input ?? {}));
482
+ }
483
+ }
484
+ // Native `CronDelete` and `CronList` are noisy but harmless to ignore.
485
+ // The native cron is session-only so deletion is moot once the session
486
+ // ends; the kobo equivalents (kobo__cron_delete / kobo__cron_list) are
487
+ // the persistent path. Log to track usage and avoid silent confusion.
488
+ if (ev.kind === 'tool:call' && (ev.name === 'CronDelete' || ev.name === 'CronList')) {
489
+ console.warn(`[orchestrator] Native ${ev.name} called on workspace '${workspaceId}' — has no effect on kobo crons. Use kobo__${ev.name === 'CronDelete' ? 'cron_delete' : 'cron_list'} instead.`);
490
+ }
450
491
  if (ev.kind === 'skills:discovered') {
451
492
  availableSkills = ev.skills;
452
493
  try {
@@ -54,12 +54,17 @@ export function enable(workspaceId) {
54
54
  if (row.auto_loop_ready !== 1) {
55
55
  throw new Error(`Workspace '${workspaceId}' is not ready for auto-loop (run grooming first)`);
56
56
  }
57
+ // Refuse to enable when there is nothing to spawn — without this, auto_loop
58
+ // would flip to 1 silently with no iteration running, locking the chat input
59
+ // (auto-loop banner) without doing any work. The user must add a task or
60
+ // unmark a done task before re-enabling.
61
+ const pending = countPendingTasks(workspaceId);
62
+ if (pending === 0) {
63
+ throw new Error(`Workspace '${workspaceId}' has no pending tasks; add or unmark a task before enabling auto-loop`);
64
+ }
57
65
  const db = getDb();
58
66
  db.prepare('UPDATE workspaces SET auto_loop = 1, no_progress_streak = 0 WHERE id = ?').run(workspaceId);
59
67
  emitEphemeral(workspaceId, 'autoloop:enabled', {});
60
- const pending = countPendingTasks(workspaceId);
61
- if (pending === 0)
62
- return;
63
68
  if (orchestrator.hasController(workspaceId))
64
69
  return;
65
70
  // spawnNextIteration throws on initial spawn failure (see flag).