@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.
- package/README.md +92 -3
- package/dist/mcp-server/kobo-tasks-handlers.js +15 -0
- package/dist/mcp-server/kobo-tasks-server.js +117 -8
- package/dist/server/db/migrations.js +38 -0
- package/dist/server/db/schema.js +16 -0
- package/dist/server/index.js +4 -0
- package/dist/server/routes/health.js +68 -3
- package/dist/server/routes/voice.js +149 -0
- package/dist/server/routes/workspaces.js +102 -1
- package/dist/server/services/agent/engines/claude-code/engine.js +13 -9
- package/dist/server/services/agent/engines/claude-code/event-mapper.js +95 -10
- package/dist/server/services/agent/orchestrator.js +41 -0
- package/dist/server/services/auto-loop-service.js +8 -3
- package/dist/server/services/cron-service.js +279 -0
- package/dist/server/services/settings-service.js +57 -0
- package/dist/server/services/transcription-service.js +206 -0
- package/dist/server/services/wakeup-service.js +1 -1
- package/dist/server/services/workspace-service.js +18 -0
- package/dist/server/utils/git-ops.js +8 -1
- package/package.json +13 -10
- package/src/client/dist/spa/assets/{ActivityFeed-oW9PgZ8E.js → ActivityFeed-DlPVoOGb.js} +2 -2
- package/src/client/dist/spa/assets/{ActivityFeed-DVBfmJWJ.css → ActivityFeed-tE4LVYck.css} +1 -1
- package/src/client/dist/spa/assets/{AutoLoopChip-Y53cnGfZ.js → AutoLoopChip-CkSzkC0C.js} +1 -1
- package/src/client/dist/spa/assets/{ClosePopup-D_UAdwkA.js → ClosePopup-DTcbxsC0.js} +1 -1
- package/src/client/dist/spa/assets/CreatePage-BoRappO3.css +1 -0
- package/src/client/dist/spa/assets/CreatePage-DpCVNwYk.js +2 -0
- package/src/client/dist/spa/assets/{DiffViewer-rc3tE9fq.js → DiffViewer-D-uNbBq0.js} +3 -3
- package/src/client/dist/spa/assets/{DiffViewer-wFfQ9tcY.css → DiffViewer-DTdDcKZC.css} +1 -1
- package/src/client/dist/spa/assets/HealthPage-xZ0PP4F-.js +1 -0
- package/src/client/dist/spa/assets/{MainLayout-B9i06p7n.js → MainLayout-DdkKM2ba.js} +17 -17
- package/src/client/dist/spa/assets/MainLayout-drolsINz.css +1 -0
- package/src/client/dist/spa/assets/{QBadge-DWH42dbo.js → QBadge-C7r6oPSi.js} +1 -1
- package/src/client/dist/spa/assets/{QBtn-a6jxWjmW.js → QBtn-DEuWKHbR.js} +1 -1
- package/src/client/dist/spa/assets/{QCheckbox-D5jfsxLV.js → QCheckbox-BvHfXBFY.js} +1 -1
- package/src/client/dist/spa/assets/{QChip-ByxK0Tuf.js → QChip-erWIZgxW.js} +1 -1
- package/src/client/dist/spa/assets/{QExpansionItem-CH1ipL9n.js → QExpansionItem-BGg74no1.js} +1 -1
- package/src/client/dist/spa/assets/QIcon-qfJNZLIW.js +1 -0
- package/src/client/dist/spa/assets/{QInput-Cm5-AGQ4.js → QInput-DCJEwE8V.js} +1 -1
- package/src/client/dist/spa/assets/{QItemLabel-DrTxqTqV.js → QItemLabel-CHkgkZVj.js} +1 -1
- package/src/client/dist/spa/assets/{QItemSection-5YpFpPDm.js → QItemSection-CQUDd0Vg.js} +1 -1
- package/src/client/dist/spa/assets/{QList-D0FtnQJI.js → QList-BbnN_oNX.js} +1 -1
- package/src/client/dist/spa/assets/{QMenu-B4xMxMGd.js → QMenu-D6uqosRg.js} +1 -1
- package/src/client/dist/spa/assets/{QPage-DFi3K093.js → QPage-Co2h9wd_.js} +1 -1
- package/src/client/dist/spa/assets/{QRadio-B3aKjCVu.js → QRadio-DJxOyOA3.js} +1 -1
- package/src/client/dist/spa/assets/QSpace-DKIph84L.js +1 -0
- package/src/client/dist/spa/assets/{QSpinnerDots-CszPQQ9J.js → QSpinnerDots-Bfl2RMy4.js} +1 -1
- package/src/client/dist/spa/assets/{QTabPanels-D2ks0UIA.js → QTabPanels-ClPY9y4T.js} +1 -1
- package/src/client/dist/spa/assets/{QToggle-1-N9qWq4.js → QToggle-DNOTC_3a.js} +1 -1
- package/src/client/dist/spa/assets/{QTooltip-fDNzBEfN.js → QTooltip-DUGPNNeQ.js} +1 -1
- package/src/client/dist/spa/assets/{SearchPage-DdX7JZCD.js → SearchPage-C07dgzT9.js} +1 -1
- package/src/client/dist/spa/assets/SettingsPage-CLNmI0Rr.css +1 -0
- package/src/client/dist/spa/assets/SettingsPage-D0CZNqkA.js +9 -0
- package/src/client/dist/spa/assets/{TouchPan-DoE24Io3.js → TouchPan-DvVlszwO.js} +1 -1
- package/src/client/dist/spa/assets/WorkspacePage-CKeCLPi0.js +4 -0
- package/src/client/dist/spa/assets/WorkspacePage-CRIcsASQ.css +1 -0
- package/src/client/dist/spa/assets/{build-path-tree-B1Lvvqto.js → build-path-tree-CCMckvpr.js} +1 -1
- package/src/client/dist/spa/assets/{cssMode-DSB5jkRt.js → cssMode-D6XTTdwy.js} +1 -1
- package/src/client/dist/spa/assets/{documents-kx0vLfSG.js → documents-soWtna0O.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-Bcw50eFD.js → editor.api-6hDVHddO.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-D9piVGaH.js → editor.main-DsLU1RWu.js} +3 -3
- package/src/client/dist/spa/assets/{expand-template-BIPuNAYV.js → expand-template-Crz1uiBt.js} +1 -1
- package/src/client/dist/spa/assets/{formatters-DCAQ6ANJ.js → formatters-guwb-rzl.js} +1 -1
- package/src/client/dist/spa/assets/{freemarker2-CVh_Zh8H.js → freemarker2-Bn1f0t2U.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-CpCgELpu.js → handlebars-O92Cbq66.js} +1 -1
- package/src/client/dist/spa/assets/{html-ikWDpvWk.js → html-Ck95BMBU.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-C9TTCKih.js → htmlMode-DDYhH2FJ.js} +1 -1
- package/src/client/dist/spa/assets/i18n-BLgknHpf.js +1 -0
- package/src/client/dist/spa/assets/index-CdHDdk1y.js +2 -0
- package/src/client/dist/spa/assets/{javascript-C4OlkNeA.js → javascript-Cy2ddqHg.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-BiD34_86.js → jsonMode-BIfVcp5z.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-Dty0Ui2c.js → liquid-B287eegh.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-yiUjOVv6.js → mdx-B8HSzGai.js} +1 -1
- package/src/client/dist/spa/assets/{models-BDkLiht9.js → models-Bd_v3W7Q.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-Bz9yFPWR.js → monaco.contribution-CofcHzEf.js} +2 -2
- package/src/client/dist/spa/assets/{notifications-OnPq4FrH.js → notifications-BPnKFW60.js} +1 -1
- package/src/client/dist/spa/assets/{purify.es-BIY760fF.js → purify.es-BCEwTYRx.js} +1 -1
- package/src/client/dist/spa/assets/{python-7SPSWQoD.js → python-csaKR6_U.js} +1 -1
- package/src/client/dist/spa/assets/{razor-eagZawXK.js → razor-C2wEv-nX.js} +1 -1
- package/src/client/dist/spa/assets/{render-chat-markdown-TvAqpDih.js → render-chat-markdown-Bjcei0vn.js} +1 -1
- package/src/client/dist/spa/assets/runtime-core.esm-bundler-9Z0QAO_7.js +1 -0
- package/src/client/dist/spa/assets/{tsMode-CLYG2xeJ.js → tsMode-DGLVs57K.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-CzOXM8yS.js → typescript-w0GWHzZ3.js} +1 -1
- package/src/client/dist/spa/assets/{use-checkbox-D7zmRxGI.js → use-checkbox-y_fOkYZN.js} +1 -1
- package/src/client/dist/spa/assets/{use-id-CuaR1RiE.js → use-id-_7wiRcgb.js} +1 -1
- package/src/client/dist/spa/assets/{use-panel-D-8nAQns.js → use-panel-CbJ44rqY.js} +1 -1
- package/src/client/dist/spa/assets/use-quasar-DQYS47mh.js +1 -0
- package/src/client/dist/spa/assets/{vue-i18n-BcfTCFFS.js → vue-i18n-DI-gS-CC.js} +1 -1
- package/src/client/dist/spa/assets/{xml-2_0_6RAX.js → xml-CTn-vnEd.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-CtpgNyXs.js → yaml-CTyUSvLZ.js} +1 -1
- package/src/client/dist/spa/index.html +12 -12
- package/src/mcp-server/kobo-tasks-handlers.ts +20 -0
- package/src/mcp-server/kobo-tasks-server.ts +123 -7
- package/src/client/dist/spa/assets/CreatePage-CuD7sMR7.js +0 -2
- package/src/client/dist/spa/assets/CreatePage-DssmsAsV.css +0 -1
- package/src/client/dist/spa/assets/HealthPage-Dz0yGGMB.js +0 -1
- package/src/client/dist/spa/assets/MainLayout-DDa3rGKA.css +0 -1
- package/src/client/dist/spa/assets/QIcon-BJuyqdsT.js +0 -1
- package/src/client/dist/spa/assets/QSpace-CLtL3aPy.js +0 -1
- package/src/client/dist/spa/assets/SettingsPage-CMyeQ9_u.css +0 -1
- package/src/client/dist/spa/assets/SettingsPage-Dnj1CWc3.js +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-CCtIrBiR.css +0 -1
- package/src/client/dist/spa/assets/WorkspacePage-DHp20nl-.js +0 -4
- package/src/client/dist/spa/assets/i18n-DZCb8dnb.js +0 -1
- package/src/client/dist/spa/assets/index-DuK38XN5.js +0 -2
- package/src/client/dist/spa/assets/runtime-core.esm-bundler-C3IgBgY5.js +0 -1
- 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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
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
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
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).
|