@loicngr/kobo 1.7.5 → 1.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -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 +2 -0
- package/dist/server/routes/health.js +68 -3
- 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/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 +2 -1
- package/src/client/dist/spa/assets/{ActivityFeed-oW9PgZ8E.js → ActivityFeed-BboSPm4b.js} +2 -2
- package/src/client/dist/spa/assets/{ActivityFeed-DVBfmJWJ.css → ActivityFeed-tE4LVYck.css} +1 -1
- package/src/client/dist/spa/assets/{AutoLoopChip-Y53cnGfZ.js → AutoLoopChip-w8D77bI5.js} +1 -1
- package/src/client/dist/spa/assets/{CreatePage-CuD7sMR7.js → CreatePage-BDObLDJc.js} +1 -1
- package/src/client/dist/spa/assets/{DiffViewer-rc3tE9fq.js → DiffViewer-CblFgn8w.js} +3 -3
- package/src/client/dist/spa/assets/{DiffViewer-wFfQ9tcY.css → DiffViewer-DTdDcKZC.css} +1 -1
- package/src/client/dist/spa/assets/HealthPage-CBSw7e5q.js +1 -0
- package/src/client/dist/spa/assets/{MainLayout-B9i06p7n.js → MainLayout-DhaYycak.js} +17 -17
- package/src/client/dist/spa/assets/MainLayout-drolsINz.css +1 -0
- package/src/client/dist/spa/assets/{SearchPage-DdX7JZCD.js → SearchPage-cZTwP4Lf.js} +1 -1
- package/src/client/dist/spa/assets/{SettingsPage-Dnj1CWc3.js → SettingsPage-C1efO0VM.js} +1 -1
- package/src/client/dist/spa/assets/{WorkspacePage-DHp20nl-.js → WorkspacePage-3jcof896.js} +3 -3
- package/src/client/dist/spa/assets/{cssMode-DSB5jkRt.js → cssMode-BFLYiiEw.js} +1 -1
- package/src/client/dist/spa/assets/{editor.api-Bcw50eFD.js → editor.api-2asmmhth.js} +1 -1
- package/src/client/dist/spa/assets/{editor.main-D9piVGaH.js → editor.main-ChCYZyez.js} +3 -3
- package/src/client/dist/spa/assets/{expand-template-BIPuNAYV.js → expand-template-CXQFkQOJ.js} +1 -1
- package/src/client/dist/spa/assets/{freemarker2-CVh_Zh8H.js → freemarker2-BaBL9E9G.js} +1 -1
- package/src/client/dist/spa/assets/{handlebars-CpCgELpu.js → handlebars-BxDour4L.js} +1 -1
- package/src/client/dist/spa/assets/{html-ikWDpvWk.js → html-C6hnkfIL.js} +1 -1
- package/src/client/dist/spa/assets/{htmlMode-C9TTCKih.js → htmlMode-9zT3-dmz.js} +1 -1
- package/src/client/dist/spa/assets/i18n-CLY0XI9-.js +1 -0
- package/src/client/dist/spa/assets/index-D6wj_wQ9.js +2 -0
- package/src/client/dist/spa/assets/{javascript-C4OlkNeA.js → javascript-C3YjvKbE.js} +1 -1
- package/src/client/dist/spa/assets/{jsonMode-BiD34_86.js → jsonMode-DcJDgMzf.js} +1 -1
- package/src/client/dist/spa/assets/{liquid-Dty0Ui2c.js → liquid-CsT8SjJM.js} +1 -1
- package/src/client/dist/spa/assets/{mdx-yiUjOVv6.js → mdx-CT3yVSyc.js} +1 -1
- package/src/client/dist/spa/assets/{models-BDkLiht9.js → models-BsjWUKqM.js} +1 -1
- package/src/client/dist/spa/assets/{monaco.contribution-Bz9yFPWR.js → monaco.contribution-DKGNz1oQ.js} +2 -2
- package/src/client/dist/spa/assets/{purify.es-BIY760fF.js → purify.es-CPieV82n.js} +1 -1
- package/src/client/dist/spa/assets/{python-7SPSWQoD.js → python-Ca5miKgj.js} +1 -1
- package/src/client/dist/spa/assets/{razor-eagZawXK.js → razor-7qzusGRc.js} +1 -1
- package/src/client/dist/spa/assets/{render-chat-markdown-TvAqpDih.js → render-chat-markdown-Bqq2G-yI.js} +1 -1
- package/src/client/dist/spa/assets/{tsMode-CLYG2xeJ.js → tsMode-BdvO8jZ2.js} +1 -1
- package/src/client/dist/spa/assets/{typescript-CzOXM8yS.js → typescript-BfVNzhgs.js} +1 -1
- package/src/client/dist/spa/assets/{xml-2_0_6RAX.js → xml-DGNXGqXL.js} +1 -1
- package/src/client/dist/spa/assets/{yaml-CtpgNyXs.js → yaml-CtAtOyt5.js} +1 -1
- package/src/client/dist/spa/index.html +1 -1
- 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/HealthPage-Dz0yGGMB.js +0 -1
- package/src/client/dist/spa/assets/MainLayout-DDa3rGKA.css +0 -1
- package/src/client/dist/spa/assets/i18n-DZCb8dnb.js +0 -1
- package/src/client/dist/spa/assets/index-DuK38XN5.js +0 -2
package/README.md
CHANGED
|
@@ -35,7 +35,9 @@ Think of it as an apprentice's hall: you hand out missions, each apprentice sets
|
|
|
35
35
|
- **Attach existing worktrees** — Kōbō detects orphan git worktrees for the selected project (created outside Kōbō, or left over from an earlier install) and lets you attach them to a new workspace from the creation form, picking up the existing branch and folder instead of cloning a new one
|
|
36
36
|
- **Persistent quota backoff** — when a Claude rate limit is hit mid-session, Kōbō schedules the retry at the actual reset time reported by the API (via `rate_limit.info.buckets[].resetsAt`), falling back to the OAuth usage poller, then to a 15 → 30 → 60 → 180 → 300 min ladder when both are missing. The pending backoff is **persisted in SQLite** and re-armed on server restart, so nothing is lost if the host reboots mid-window. A live banner counts down to the reset and lets the user cancel the wait. Only auto-loop workspaces resume automatically — others stay in `quota` status awaiting a manual nudge
|
|
37
37
|
- **Workspace description fields** — every workspace has TWO independent description fields. The user-side `description` is editable via the header input or right-click **Modifier la description**, and stays under the user's control (the agent cannot overwrite it). The agent maintains its own `agent_description` via the `set_workspace_agent_description` MCP tool to broadcast a live status (e.g. "Investigating SERVICE-1600 → enriching local Notion file"). Both are visible: the sidebar shows `agent_description` when set, falling back to the user `description`; the workspace header shows the user input plus an italic read-only line for the agent's current focus
|
|
38
|
-
- **Scheduled wakeups** — the `ScheduleWakeup` tool is honoured server-side: Kōbō persists the wakeup in SQLite, rehydrates on restart, and respawns the agent with `--resume` at the target time
|
|
38
|
+
- **Scheduled wakeups** — the `ScheduleWakeup` tool is honoured server-side: Kōbō persists the wakeup in SQLite, rehydrates on restart, and respawns the agent with `--resume` at the target time. Delay is clamped to `[60s, 6h]`. The kobo-tasks MCP server also exposes `kobo__schedule_wakeup` / `kobo__cancel_wakeup` for the same flow with first-class tool descriptors
|
|
39
|
+
- **Recurring & one-shot crons** — agents schedule recurring triggers via `kobo__cron_create(expression, prompt, label?, mode?, oneShot?)`. Standard 5-field cron expressions (`*/30 * * * *`) plus helpers (`@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly`). Two modes: `'resume'` (default) pins the cron to the session that scheduled it so each fire continues that conversation; `'fresh'` spawns a brand-new session per fire (clean context, ideal for periodic CI / dashboard checks). `oneShot: true` cancels the cron after the first real fire — useful for "trigger once at a specific date/time" without recurring. Crons are persisted in SQLite, re-armed on restart with skip-missed semantics (no catchup spam after downtime), and skip-if-active when a session is already running. Multiple crons per workspace. The native Claude Code `CronCreate` tool is also intercepted and mirrored as a kobo cron so even agents using the SDK-default tool benefit from persistence
|
|
40
|
+
- **Schedule panel in the right drawer** — dedicated tab listing every wakeup and cron currently armed for the focused workspace, with their next/last fire times, prompt preview, and a `×` button to cancel inline. Sidebar workspace cards display an `event_repeat` icon when one or more crons are scheduled, even for workspaces not currently focused — broadcast live via WebSocket events
|
|
39
41
|
|
|
40
42
|
## Tech stack
|
|
41
43
|
|
|
@@ -231,6 +233,8 @@ src/
|
|
|
231
233
|
│ │ ├── content-migration-service.ts # legacy ws_events → normalised AgentEvent rows, with DB backup
|
|
232
234
|
│ │ ├── usage/ # pluggable quota provider, 60s poller, persistence, WS broadcast
|
|
233
235
|
│ │ ├── quota-backoff-service.ts # persisted Claude rate-limit backoff timers (re-armed on restart)
|
|
236
|
+
│ │ ├── cron-service.ts # persisted cron schedules (recurring + one-shot, resume/fresh modes)
|
|
237
|
+
│ │ ├── wakeup-service.ts # persisted one-shot session resumes (clamped to [60s, 6h])
|
|
234
238
|
│ │ └── … # workspace, dev-server, ws, notion, sentry, settings, pr-template
|
|
235
239
|
│ ├── routes/ # Hono handlers (workspaces, engines, migration, templates, usage, …)
|
|
236
240
|
│ └── utils/ # git-ops, process-tracker, paths
|
|
@@ -261,6 +265,7 @@ See [`AGENTS.md`](./AGENTS.md) for a deeper dive into conventions, data model, W
|
|
|
261
265
|
| `usage_snapshots` | latest quota snapshot per provider (one row per `provider_id`) — populated by the 60s polling loop, used for cold-start hydration of the chat-footer quota badge |
|
|
262
266
|
| `pending_wakeups` | one row per scheduled wakeup, target time and resume context, re-armed on server restart |
|
|
263
267
|
| `pending_quota_backoffs` | one row per workspace currently waiting on a Claude rate-limit reset, target time + reset metadata + retry count, re-armed on server restart |
|
|
268
|
+
| `pending_crons` | one row per scheduled cron — expression, prompt, label, optional pinned `agent_session_id` (= resume mode) or NULL (= fresh mode), `next_fire_at`, `last_fired_at`, `one_shot` flag, re-armed on server restart with skip-missed semantics |
|
|
264
269
|
|
|
265
270
|
## MCP server
|
|
266
271
|
|
|
@@ -273,9 +278,9 @@ Each workspace spawns its own `kobo-tasks` MCP server as a child process of the
|
|
|
273
278
|
- **Dev server** — `get_dev_server_status`, `start_dev_server`, `stop_dev_server`, `get_dev_server_logs`
|
|
274
279
|
- **External sources** — `get_notion_ticket`, `get_settings`
|
|
275
280
|
- **Documents & search** — `list_documents`, `read_document`, `log_thought`, `search_codebase`, `list_workspace_images`
|
|
276
|
-
- **Scheduling & telemetry** — `schedule_wakeup`, `cancel_wakeup`, `get_session_usage`
|
|
281
|
+
- **Scheduling & telemetry** — `schedule_wakeup`, `cancel_wakeup`, `cron_create`, `cron_delete`, `cron_list`, `get_session_usage`
|
|
277
282
|
|
|
278
|
-
State-mutating tools that change UI-visible data (tasks, agent description, auto-loop readiness) write directly to SQLite for low latency, then fire-and-forget a `notify-*` HTTP call to the backend so the WebSocket layer broadcasts the change to every connected client.
|
|
283
|
+
State-mutating tools that change UI-visible data (tasks, agent description, auto-loop readiness) write directly to SQLite for low latency, then fire-and-forget a `notify-*` HTTP call to the backend so the WebSocket layer broadcasts the change to every connected client. Tools that arm in-memory timers (`cron_create`, `cron_delete`) route through the backend HTTP API instead — the timer Map lives in the backend process which owns the orchestrator, so the cron survives the MCP server's session-bound lifetime.
|
|
279
284
|
|
|
280
285
|
The MCP server reads and writes the same SQLite database as the main backend. Isolation between workspaces is enforced via the `KOBO_WORKSPACE_ID` environment variable passed at spawn time and validated on every query.
|
|
281
286
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { nanoid } from 'nanoid';
|
|
4
|
+
import * as cronService from '../server/services/cron-service.js';
|
|
4
5
|
import * as settingsService from '../server/services/settings-service.js';
|
|
5
6
|
import { slugifyProjectName } from '../server/utils/project-slug.js';
|
|
6
7
|
import { resolveWorkspaceWorktreePath } from '../server/utils/worktree-paths.js';
|
|
@@ -379,3 +380,17 @@ export function getSessionUsageHandler(db, workspaceId) {
|
|
|
379
380
|
currentSession: { sessionId: currentSessionId, ...current },
|
|
380
381
|
};
|
|
381
382
|
}
|
|
383
|
+
// ── Crons ────────────────────────────────────────────────────────────────────
|
|
384
|
+
/**
|
|
385
|
+
* List every cron currently armed for a workspace.
|
|
386
|
+
*
|
|
387
|
+
* Note: cron_create and cron_delete are NOT exposed as handlers — they route
|
|
388
|
+
* through the backend HTTP API (`POST/DELETE /api/workspaces/:id/crons`)
|
|
389
|
+
* because their `setTimeout` must live in the backend process (which owns
|
|
390
|
+
* the orchestrator). Handlers here would arm timers in the MCP sub-process,
|
|
391
|
+
* which dies with the agent session, and fires would never reach a real
|
|
392
|
+
* session resume. The list handler is a pure read so it's safe to keep local.
|
|
393
|
+
*/
|
|
394
|
+
export function cronListHandler(_db, workspaceId) {
|
|
395
|
+
return { ok: true, crons: cronService.listForWorkspace(workspaceId) };
|
|
396
|
+
}
|
|
@@ -4,8 +4,9 @@ import path from 'node:path';
|
|
|
4
4
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
5
5
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
6
|
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
7
|
+
import { getDb } from '../server/db/index.js';
|
|
8
|
+
import { runMigrations } from '../server/db/migrations.js';
|
|
9
|
+
import { createTaskHandler, cronListHandler, deleteTaskHandler, getDevServerStatusHandler, getSessionUsageHandler, getSettingsHandler, getWorkspaceInfoHandler, listDocumentsHandler, listTasksHandler, listWorkspaceImagesHandler, logThoughtHandler, markAutoLoopReadyHandler, markTaskDoneHandler, readDocumentHandler, setWorkspaceAgentDescriptionHandler, updateTaskHandler, } from './kobo-tasks-handlers.js';
|
|
9
10
|
const workspaceId = process.env.KOBO_WORKSPACE_ID;
|
|
10
11
|
const dbPath = process.env.KOBO_DB_PATH;
|
|
11
12
|
const settingsPath = process.env.KOBO_SETTINGS_PATH;
|
|
@@ -20,10 +21,21 @@ if (!dbPath) {
|
|
|
20
21
|
}
|
|
21
22
|
let db;
|
|
22
23
|
try {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
// Use the shared `getDb` singleton so any service that calls `getDb()`
|
|
25
|
+
// internally (e.g. cron-service.arm) hits the SAME connection as this
|
|
26
|
+
// MCP server, against the SAME DB file. Without this bootstrap, the
|
|
27
|
+
// singleton would resolve via getDbPath() → getKoboHome() → KOBO_HOME
|
|
28
|
+
// env var, which the agent SDK does NOT pass to the MCP server (only
|
|
29
|
+
// KOBO_DB_PATH is passed). That would silently open a second connection
|
|
30
|
+
// against the user's prod DB at ~/.config/kobo/kobo.db, which may not
|
|
31
|
+
// even have the same schema as the dev DB the backend is using.
|
|
32
|
+
db = getDb(dbPath);
|
|
33
|
+
// Defensive: run migrations to ensure the schema is at the latest version.
|
|
34
|
+
// The backend already ran them at boot, but this MCP server might be the
|
|
35
|
+
// first to touch the DB (e.g. tests, race at first boot, or KOBO_DB_PATH
|
|
36
|
+
// pointing somewhere the backend hasn't migrated). Idempotent — no-op if
|
|
37
|
+
// the DB is already at SCHEMA_VERSION.
|
|
38
|
+
runMigrations(db);
|
|
27
39
|
}
|
|
28
40
|
catch (err) {
|
|
29
41
|
console.error('[kobo-tasks-server] Failed to open database:', err);
|
|
@@ -190,6 +202,53 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
190
202
|
required: ['description'],
|
|
191
203
|
},
|
|
192
204
|
},
|
|
205
|
+
{
|
|
206
|
+
name: 'cron_create',
|
|
207
|
+
description: 'Schedule a recurring trigger on THIS workspace. At each fire, Kōbō waits for the workspace to be idle (no active session) and then resumes the same conversation by injecting `prompt` as the next user message — same UX as `schedule_wakeup` but recurring. Skip-if-active: if a session is already running when the timer fires, that occurrence is skipped, the next occurrence is computed, and the cron continues. The cron persists across server restarts (skip-missed semantics on boot — no catchup spam). Delete with `cron_delete(id)`. Multiple crons per workspace are allowed.',
|
|
208
|
+
inputSchema: {
|
|
209
|
+
type: 'object',
|
|
210
|
+
properties: {
|
|
211
|
+
expression: {
|
|
212
|
+
type: 'string',
|
|
213
|
+
description: 'Standard 5-field cron expression (`min hour dom month dow`) or one of the helpers `@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly`. Example: `*/30 * * * *` = every 30 minutes; `0 9 * * 1` = every Monday at 9am. Validated at create time.',
|
|
214
|
+
},
|
|
215
|
+
prompt: {
|
|
216
|
+
type: 'string',
|
|
217
|
+
description: 'The prompt to inject as the next user message at each fire.',
|
|
218
|
+
},
|
|
219
|
+
label: {
|
|
220
|
+
type: 'string',
|
|
221
|
+
description: 'Optional human-readable label for the cron (shown in the UI).',
|
|
222
|
+
},
|
|
223
|
+
mode: {
|
|
224
|
+
type: 'string',
|
|
225
|
+
enum: ['resume', 'fresh'],
|
|
226
|
+
description: "How each fire is handled. 'resume' (default) pins the cron to the session you're calling from, so every fire continues THAT conversation by injecting `prompt` as the next user message — use this when the cron should follow up on ongoing work. 'fresh' starts a brand-new session at every fire with a clean context — use this for periodic checks (e.g. CI watch, daily standup) that don't need conversation continuity.",
|
|
227
|
+
},
|
|
228
|
+
oneShot: {
|
|
229
|
+
type: 'boolean',
|
|
230
|
+
description: "When true, the cron cancels itself after the first real fire (default false = recurring). Use this to schedule a single trigger at a specific cron-expressible time (e.g. `0 14 7 6 *` = next 7 June at 14:00) without it repeating yearly. Skip-active fires don't consume the one-shot — the cron retries at the next occurrence until it actually runs once.",
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
required: ['expression', 'prompt'],
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
name: 'cron_delete',
|
|
238
|
+
description: "Cancel a previously-armed cron by id. Idempotent — returns ok=true even if the id is unknown. Only the workspace's own crons can be cancelled (cron_list to see them).",
|
|
239
|
+
inputSchema: {
|
|
240
|
+
type: 'object',
|
|
241
|
+
properties: {
|
|
242
|
+
id: { type: 'string', description: 'The cron id returned by cron_create.' },
|
|
243
|
+
},
|
|
244
|
+
required: ['id'],
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
name: 'cron_list',
|
|
249
|
+
description: 'List all crons currently armed on THIS workspace, including their next and last fire times.',
|
|
250
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
251
|
+
},
|
|
193
252
|
{
|
|
194
253
|
name: 'get_git_info',
|
|
195
254
|
description: 'CALL BEFORE creating a PR, committing in batches, or reporting progress to the user. Returns commit count ahead of source, files changed, insertions/deletions, and existing PR URL if any.',
|
|
@@ -324,13 +383,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
324
383
|
},
|
|
325
384
|
{
|
|
326
385
|
name: 'schedule_wakeup',
|
|
327
|
-
description: 'CALL to schedule a follow-up turn on THIS workspace after a delay. End the current turn normally; once it finishes and the workspace is idle, Kōbō waits `delaySeconds`, then resumes the same conversation by injecting `prompt` as the next user message. The wakeup is scoped to the current workspace and resumes its latest session — you cannot target another workspace or another session. If a turn is still active when the timer fires, the wakeup is skipped (status: `session-active`). Replaces any previously pending wakeup on this workspace. Delay is clamped to [60,
|
|
386
|
+
description: 'CALL to schedule a follow-up turn on THIS workspace after a delay. End the current turn normally; once it finishes and the workspace is idle, Kōbō waits `delaySeconds`, then resumes the same conversation by injecting `prompt` as the next user message. The wakeup is scoped to the current workspace and resumes its latest session — you cannot target another workspace or another session. If a turn is still active when the timer fires, the wakeup is skipped (status: `session-active`). Replaces any previously pending wakeup on this workspace. Delay is clamped to [60, 21600] seconds (1min to 6h). Prefer this over the built-in `ScheduleWakeup` tool — it is the SDK-supported entry point.',
|
|
328
387
|
inputSchema: {
|
|
329
388
|
type: 'object',
|
|
330
389
|
properties: {
|
|
331
390
|
delaySeconds: {
|
|
332
391
|
type: 'number',
|
|
333
|
-
description: 'Seconds from now until the wakeup fires. Clamped to [60,
|
|
392
|
+
description: 'Seconds from now until the wakeup fires. Clamped to [60, 21600] (1min to 6h).',
|
|
334
393
|
},
|
|
335
394
|
prompt: {
|
|
336
395
|
type: 'string',
|
|
@@ -436,6 +495,56 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
436
495
|
}
|
|
437
496
|
return ok(result);
|
|
438
497
|
}
|
|
498
|
+
if (name === 'cron_create') {
|
|
499
|
+
const expression = a.expression;
|
|
500
|
+
const prompt = a.prompt;
|
|
501
|
+
const label = typeof a.label === 'string' ? a.label : undefined;
|
|
502
|
+
const mode = typeof a.mode === 'string' ? a.mode : undefined;
|
|
503
|
+
const oneShot = typeof a.oneShot === 'boolean' ? a.oneShot : undefined;
|
|
504
|
+
if (typeof expression !== 'string' || typeof prompt !== 'string') {
|
|
505
|
+
return fail('expression and prompt parameters are required');
|
|
506
|
+
}
|
|
507
|
+
if (mode !== undefined && mode !== 'resume' && mode !== 'fresh') {
|
|
508
|
+
return fail("mode must be 'resume' or 'fresh'");
|
|
509
|
+
}
|
|
510
|
+
// Route through the backend so the in-memory `setTimeout` lives in the
|
|
511
|
+
// backend process (which owns orchestrator + WS broadcast). Calling
|
|
512
|
+
// cron-service.arm() directly here would persist the row but arm the
|
|
513
|
+
// timer in the MCP server sub-process, which dies with the agent
|
|
514
|
+
// session — fires would never trigger a real session resume.
|
|
515
|
+
try {
|
|
516
|
+
const created = (await backendRequest('POST', `/api/workspaces/${workspaceId}/crons`, {
|
|
517
|
+
expression,
|
|
518
|
+
prompt,
|
|
519
|
+
label,
|
|
520
|
+
mode,
|
|
521
|
+
oneShot,
|
|
522
|
+
}));
|
|
523
|
+
return ok({ ok: true, cron: created.cron });
|
|
524
|
+
}
|
|
525
|
+
catch (err) {
|
|
526
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
527
|
+
return ok({ ok: false, error: message });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (name === 'cron_delete') {
|
|
531
|
+
const id = a.id;
|
|
532
|
+
if (typeof id !== 'string')
|
|
533
|
+
return fail('id parameter is required');
|
|
534
|
+
// Same reason as cron_create — the backend owns the timer Map.
|
|
535
|
+
try {
|
|
536
|
+
await backendRequest('DELETE', `/api/workspaces/${workspaceId}/crons/${id}`);
|
|
537
|
+
return ok({ ok: true });
|
|
538
|
+
}
|
|
539
|
+
catch (err) {
|
|
540
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
541
|
+
return ok({ ok: false, error: message });
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (name === 'cron_list') {
|
|
545
|
+
const result = cronListHandler(db, workspaceId);
|
|
546
|
+
return ok(result);
|
|
547
|
+
}
|
|
439
548
|
if (name === 'start_dev_server') {
|
|
440
549
|
const result = await backendRequest('POST', `/api/dev-server/${workspaceId}/start`);
|
|
441
550
|
return ok(result);
|
|
@@ -243,6 +243,44 @@ export const migrations = [
|
|
|
243
243
|
db.prepare('ALTER TABLE workspaces ADD COLUMN agent_description TEXT').run();
|
|
244
244
|
},
|
|
245
245
|
},
|
|
246
|
+
{
|
|
247
|
+
version: 22,
|
|
248
|
+
name: 'add-pending-crons',
|
|
249
|
+
migrate: (db) => {
|
|
250
|
+
// Per-workspace cron schedules: each row arms a recurring agent prompt
|
|
251
|
+
// on a cron expression. Sibling timer table to pending_wakeups /
|
|
252
|
+
// pending_quota_backoffs. Many rows per workspace (unlike the one-row
|
|
253
|
+
// sibling tables), so id is the primary key. CASCADE on workspace
|
|
254
|
+
// delete to keep the timer set tidy. Idempotent via IF NOT EXISTS.
|
|
255
|
+
db.prepare(`CREATE TABLE IF NOT EXISTS pending_crons (
|
|
256
|
+
id TEXT PRIMARY KEY,
|
|
257
|
+
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
|
258
|
+
expression TEXT NOT NULL,
|
|
259
|
+
prompt TEXT NOT NULL,
|
|
260
|
+
label TEXT,
|
|
261
|
+
agent_session_id TEXT,
|
|
262
|
+
next_fire_at TEXT NOT NULL,
|
|
263
|
+
last_fired_at TEXT,
|
|
264
|
+
created_at TEXT NOT NULL
|
|
265
|
+
)`).run();
|
|
266
|
+
db.prepare('CREATE INDEX IF NOT EXISTS idx_pending_crons_workspace ON pending_crons(workspace_id)').run();
|
|
267
|
+
db.prepare('CREATE INDEX IF NOT EXISTS idx_pending_crons_next_fire ON pending_crons(next_fire_at)').run();
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
version: 23,
|
|
272
|
+
name: 'add-pending-crons-one-shot',
|
|
273
|
+
migrate: (db) => {
|
|
274
|
+
// One-shot cron: fires once and cancels itself. Distinct from a wakeup
|
|
275
|
+
// because it still uses cron expressions (can target an absolute date,
|
|
276
|
+
// e.g. `0 14 7 6 *` = "next 7 June at 14:00") rather than a delay.
|
|
277
|
+
// Default 0 (recurring) preserves existing behaviour.
|
|
278
|
+
const cols = db.prepare('PRAGMA table_info(pending_crons)').all();
|
|
279
|
+
if (!cols.some((c) => c.name === 'one_shot')) {
|
|
280
|
+
db.prepare('ALTER TABLE pending_crons ADD COLUMN one_shot INTEGER NOT NULL DEFAULT 0').run();
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
},
|
|
246
284
|
];
|
|
247
285
|
/** Current schema version — always equals the highest migration version. */
|
|
248
286
|
export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
|
package/dist/server/db/schema.js
CHANGED
|
@@ -82,6 +82,22 @@ export function initSchema(db) {
|
|
|
82
82
|
created_at TEXT NOT NULL
|
|
83
83
|
);
|
|
84
84
|
|
|
85
|
+
CREATE TABLE IF NOT EXISTS pending_crons (
|
|
86
|
+
id TEXT PRIMARY KEY,
|
|
87
|
+
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
|
88
|
+
expression TEXT NOT NULL,
|
|
89
|
+
prompt TEXT NOT NULL,
|
|
90
|
+
label TEXT,
|
|
91
|
+
agent_session_id TEXT,
|
|
92
|
+
next_fire_at TEXT NOT NULL,
|
|
93
|
+
last_fired_at TEXT,
|
|
94
|
+
one_shot INTEGER NOT NULL DEFAULT 0,
|
|
95
|
+
created_at TEXT NOT NULL
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
CREATE INDEX IF NOT EXISTS idx_pending_crons_workspace ON pending_crons(workspace_id);
|
|
99
|
+
CREATE INDEX IF NOT EXISTS idx_pending_crons_next_fire ON pending_crons(next_fire_at);
|
|
100
|
+
|
|
85
101
|
CREATE TABLE IF NOT EXISTS usage_snapshots (
|
|
86
102
|
provider_id TEXT PRIMARY KEY,
|
|
87
103
|
status TEXT NOT NULL,
|
package/dist/server/index.js
CHANGED
|
@@ -23,6 +23,7 @@ import workspacesRouter from './routes/workspaces.js';
|
|
|
23
23
|
import { getAvailableSkills, reconcileOrphanSessions, restoreRetryCountsFromDb, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopWatchdog, } from './services/agent/orchestrator.js';
|
|
24
24
|
import * as autoLoopService from './services/auto-loop-service.js';
|
|
25
25
|
import { runContentMigrationIfNeeded } from './services/content-migration-service.js';
|
|
26
|
+
import * as cronService from './services/cron-service.js';
|
|
26
27
|
import { createDailyDbBackupIfNeeded } from './services/db-backup-service.js';
|
|
27
28
|
import { startDevServer, stopDevServer } from './services/dev-server-service.js';
|
|
28
29
|
import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js';
|
|
@@ -60,6 +61,7 @@ autoLoopService.rehydrate();
|
|
|
60
61
|
// from retryCount=0 and undo the progression.
|
|
61
62
|
restoreRetryCountsFromDb();
|
|
62
63
|
quotaBackoffService.restoreOnBoot((workspaceId) => autoLoopService.onQuotaBackoffExpired(workspaceId));
|
|
64
|
+
cronService.restoreOnBoot();
|
|
63
65
|
startPrWatcher();
|
|
64
66
|
startUsagePoller();
|
|
65
67
|
// Create Hono app
|
|
@@ -63,15 +63,73 @@ app.get('/report', (c) => {
|
|
|
63
63
|
worktreesMissing.push({ workspaceId: ws.id, name: ws.name, path: wtPath, exists: false });
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
|
-
// Orphan agent sessions — marked running but PID no longer alive
|
|
66
|
+
// Orphan agent sessions — marked running but PID no longer alive.
|
|
67
|
+
// Also collect the alive ones for the active-state panel.
|
|
67
68
|
const runningSessions = db
|
|
68
|
-
.prepare(
|
|
69
|
+
.prepare(`
|
|
70
|
+
SELECT s.pid AS pid, s.workspace_id AS workspaceId, s.started_at AS startedAt, w.name AS workspaceName
|
|
71
|
+
FROM agent_sessions s
|
|
72
|
+
JOIN workspaces w ON w.id = s.workspace_id
|
|
73
|
+
WHERE s.status = 'running' AND s.pid IS NOT NULL
|
|
74
|
+
`)
|
|
69
75
|
.all();
|
|
70
76
|
let orphaned = 0;
|
|
77
|
+
const agentSessionsAlive = [];
|
|
71
78
|
for (const s of runningSessions) {
|
|
72
|
-
if (
|
|
79
|
+
if (!s.pid)
|
|
80
|
+
continue;
|
|
81
|
+
if (isProcessAlive(s.pid)) {
|
|
82
|
+
agentSessionsAlive.push({
|
|
83
|
+
workspaceId: s.workspaceId,
|
|
84
|
+
workspaceName: s.workspaceName,
|
|
85
|
+
pid: s.pid,
|
|
86
|
+
startedAt: s.startedAt,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
73
90
|
orphaned++;
|
|
91
|
+
}
|
|
74
92
|
}
|
|
93
|
+
// Active state — features the user wants to see at a glance:
|
|
94
|
+
// pending quota backoffs, scheduled wakeups, armed auto-loops, running dev servers.
|
|
95
|
+
const quotaBackoffs = db
|
|
96
|
+
.prepare(`
|
|
97
|
+
SELECT q.workspace_id AS workspaceId, w.name AS name, q.target_at AS targetAt,
|
|
98
|
+
q.resets_at AS resetsAt, q.source AS source, q.retry_count AS retryCount
|
|
99
|
+
FROM pending_quota_backoffs q
|
|
100
|
+
JOIN workspaces w ON w.id = q.workspace_id
|
|
101
|
+
ORDER BY q.target_at ASC
|
|
102
|
+
`)
|
|
103
|
+
.all();
|
|
104
|
+
const pendingWakeups = db
|
|
105
|
+
.prepare(`
|
|
106
|
+
SELECT p.workspace_id AS workspaceId, w.name AS name, p.target_at AS targetAt, p.reason AS reason
|
|
107
|
+
FROM pending_wakeups p
|
|
108
|
+
JOIN workspaces w ON w.id = p.workspace_id
|
|
109
|
+
ORDER BY p.target_at ASC
|
|
110
|
+
`)
|
|
111
|
+
.all();
|
|
112
|
+
const autoLoopActiveRaw = db
|
|
113
|
+
.prepare(`
|
|
114
|
+
SELECT id AS workspaceId, name, auto_loop_ready AS ready
|
|
115
|
+
FROM workspaces
|
|
116
|
+
WHERE auto_loop = 1 AND archived_at IS NULL
|
|
117
|
+
ORDER BY name ASC
|
|
118
|
+
`)
|
|
119
|
+
.all();
|
|
120
|
+
const autoLoopActive = autoLoopActiveRaw.map((r) => ({
|
|
121
|
+
workspaceId: r.workspaceId,
|
|
122
|
+
name: r.name,
|
|
123
|
+
ready: r.ready === 1,
|
|
124
|
+
}));
|
|
125
|
+
const devServersRunning = db
|
|
126
|
+
.prepare(`
|
|
127
|
+
SELECT id AS workspaceId, name
|
|
128
|
+
FROM workspaces
|
|
129
|
+
WHERE dev_server_status = 'running' AND archived_at IS NULL
|
|
130
|
+
ORDER BY name ASC
|
|
131
|
+
`)
|
|
132
|
+
.all();
|
|
75
133
|
const settingsRow = db.prepare('SELECT COUNT(*) as n FROM workspaces').get();
|
|
76
134
|
const archivedRow = db.prepare('SELECT COUNT(*) as n FROM workspaces WHERE archived_at IS NOT NULL').get();
|
|
77
135
|
const report = {
|
|
@@ -95,6 +153,13 @@ app.get('/report', (c) => {
|
|
|
95
153
|
sentry: { configured: Boolean(healthGlobalSettings.sentryMcpKey) },
|
|
96
154
|
editor: { configured: Boolean(healthGlobalSettings.editorCommand) },
|
|
97
155
|
},
|
|
156
|
+
active: {
|
|
157
|
+
quotaBackoffs,
|
|
158
|
+
pendingWakeups,
|
|
159
|
+
autoLoopActive,
|
|
160
|
+
agentSessionsAlive,
|
|
161
|
+
devServersRunning,
|
|
162
|
+
},
|
|
98
163
|
};
|
|
99
164
|
return c.json(report);
|
|
100
165
|
});
|
|
@@ -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 });
|