@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.
Files changed (60) hide show
  1. package/README.md +8 -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 +2 -0
  7. package/dist/server/routes/health.js +68 -3
  8. package/dist/server/routes/workspaces.js +102 -1
  9. package/dist/server/services/agent/engines/claude-code/engine.js +13 -9
  10. package/dist/server/services/agent/engines/claude-code/event-mapper.js +95 -10
  11. package/dist/server/services/agent/orchestrator.js +41 -0
  12. package/dist/server/services/auto-loop-service.js +8 -3
  13. package/dist/server/services/cron-service.js +279 -0
  14. package/dist/server/services/wakeup-service.js +1 -1
  15. package/dist/server/services/workspace-service.js +18 -0
  16. package/dist/server/utils/git-ops.js +8 -1
  17. package/package.json +2 -1
  18. package/src/client/dist/spa/assets/{ActivityFeed-oW9PgZ8E.js → ActivityFeed-BboSPm4b.js} +2 -2
  19. package/src/client/dist/spa/assets/{ActivityFeed-DVBfmJWJ.css → ActivityFeed-tE4LVYck.css} +1 -1
  20. package/src/client/dist/spa/assets/{AutoLoopChip-Y53cnGfZ.js → AutoLoopChip-w8D77bI5.js} +1 -1
  21. package/src/client/dist/spa/assets/{CreatePage-CuD7sMR7.js → CreatePage-BDObLDJc.js} +1 -1
  22. package/src/client/dist/spa/assets/{DiffViewer-rc3tE9fq.js → DiffViewer-CblFgn8w.js} +3 -3
  23. package/src/client/dist/spa/assets/{DiffViewer-wFfQ9tcY.css → DiffViewer-DTdDcKZC.css} +1 -1
  24. package/src/client/dist/spa/assets/HealthPage-CBSw7e5q.js +1 -0
  25. package/src/client/dist/spa/assets/{MainLayout-B9i06p7n.js → MainLayout-DhaYycak.js} +17 -17
  26. package/src/client/dist/spa/assets/MainLayout-drolsINz.css +1 -0
  27. package/src/client/dist/spa/assets/{SearchPage-DdX7JZCD.js → SearchPage-cZTwP4Lf.js} +1 -1
  28. package/src/client/dist/spa/assets/{SettingsPage-Dnj1CWc3.js → SettingsPage-C1efO0VM.js} +1 -1
  29. package/src/client/dist/spa/assets/{WorkspacePage-DHp20nl-.js → WorkspacePage-3jcof896.js} +3 -3
  30. package/src/client/dist/spa/assets/{cssMode-DSB5jkRt.js → cssMode-BFLYiiEw.js} +1 -1
  31. package/src/client/dist/spa/assets/{editor.api-Bcw50eFD.js → editor.api-2asmmhth.js} +1 -1
  32. package/src/client/dist/spa/assets/{editor.main-D9piVGaH.js → editor.main-ChCYZyez.js} +3 -3
  33. package/src/client/dist/spa/assets/{expand-template-BIPuNAYV.js → expand-template-CXQFkQOJ.js} +1 -1
  34. package/src/client/dist/spa/assets/{freemarker2-CVh_Zh8H.js → freemarker2-BaBL9E9G.js} +1 -1
  35. package/src/client/dist/spa/assets/{handlebars-CpCgELpu.js → handlebars-BxDour4L.js} +1 -1
  36. package/src/client/dist/spa/assets/{html-ikWDpvWk.js → html-C6hnkfIL.js} +1 -1
  37. package/src/client/dist/spa/assets/{htmlMode-C9TTCKih.js → htmlMode-9zT3-dmz.js} +1 -1
  38. package/src/client/dist/spa/assets/i18n-CLY0XI9-.js +1 -0
  39. package/src/client/dist/spa/assets/index-D6wj_wQ9.js +2 -0
  40. package/src/client/dist/spa/assets/{javascript-C4OlkNeA.js → javascript-C3YjvKbE.js} +1 -1
  41. package/src/client/dist/spa/assets/{jsonMode-BiD34_86.js → jsonMode-DcJDgMzf.js} +1 -1
  42. package/src/client/dist/spa/assets/{liquid-Dty0Ui2c.js → liquid-CsT8SjJM.js} +1 -1
  43. package/src/client/dist/spa/assets/{mdx-yiUjOVv6.js → mdx-CT3yVSyc.js} +1 -1
  44. package/src/client/dist/spa/assets/{models-BDkLiht9.js → models-BsjWUKqM.js} +1 -1
  45. package/src/client/dist/spa/assets/{monaco.contribution-Bz9yFPWR.js → monaco.contribution-DKGNz1oQ.js} +2 -2
  46. package/src/client/dist/spa/assets/{purify.es-BIY760fF.js → purify.es-CPieV82n.js} +1 -1
  47. package/src/client/dist/spa/assets/{python-7SPSWQoD.js → python-Ca5miKgj.js} +1 -1
  48. package/src/client/dist/spa/assets/{razor-eagZawXK.js → razor-7qzusGRc.js} +1 -1
  49. package/src/client/dist/spa/assets/{render-chat-markdown-TvAqpDih.js → render-chat-markdown-Bqq2G-yI.js} +1 -1
  50. package/src/client/dist/spa/assets/{tsMode-CLYG2xeJ.js → tsMode-BdvO8jZ2.js} +1 -1
  51. package/src/client/dist/spa/assets/{typescript-CzOXM8yS.js → typescript-BfVNzhgs.js} +1 -1
  52. package/src/client/dist/spa/assets/{xml-2_0_6RAX.js → xml-DGNXGqXL.js} +1 -1
  53. package/src/client/dist/spa/assets/{yaml-CtpgNyXs.js → yaml-CtAtOyt5.js} +1 -1
  54. package/src/client/dist/spa/index.html +1 -1
  55. package/src/mcp-server/kobo-tasks-handlers.ts +20 -0
  56. package/src/mcp-server/kobo-tasks-server.ts +123 -7
  57. package/src/client/dist/spa/assets/HealthPage-Dz0yGGMB.js +0 -1
  58. package/src/client/dist/spa/assets/MainLayout-DDa3rGKA.css +0 -1
  59. package/src/client/dist/spa/assets/i18n-DZCb8dnb.js +0 -1
  60. package/src/client/dist/spa/assets/index-DuK38XN5.js +0 -2
@@ -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).
@@ -0,0 +1,279 @@
1
+ import { CronExpressionParser } from 'cron-parser';
2
+ import { nanoid } from 'nanoid';
3
+ import { getDb } from '../db/index.js';
4
+ import { slugifyProjectName } from '../utils/project-slug.js';
5
+ import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
6
+ import * as orchestrator from './agent/orchestrator.js';
7
+ import * as settingsService from './settings-service.js';
8
+ import { emitEphemeral } from './websocket-service.js';
9
+ export const MIN_DELAY_BETWEEN_FIRES_SECONDS = 60;
10
+ // Node `setTimeout` stores the delay as a 32-bit signed int. Anything above
11
+ // 2^31-1 ms (~24.8 days) triggers `TimeoutOverflowWarning` and silently
12
+ // truncates to 1ms — which causes the timer to fire instantly and loop
13
+ // when the real target is years away (e.g. `@yearly` or `0 0 1 1 *`).
14
+ // We cap each setTimeout at this max and re-arm in the callback if the
15
+ // real fire time hasn't been reached yet.
16
+ const MAX_SETTIMEOUT_MS = 2_000_000_000; // ~23 days, with margin under 2^31-1
17
+ const timers = new Map();
18
+ /**
19
+ * Arm a setTimeout that fires `fireOrSkip(id)` when `fireAt` is reached.
20
+ * Replaces any existing timer for this id. Handles long horizons by
21
+ * chaining capped timeouts, since Node's setTimeout overflows past ~24.8
22
+ * days.
23
+ */
24
+ function scheduleAt(id, fireAt) {
25
+ const previous = timers.get(id);
26
+ if (previous)
27
+ clearTimeout(previous);
28
+ const deltaMs = Math.max(0, fireAt.getTime() - Date.now());
29
+ const cappedMs = Math.min(deltaMs, MAX_SETTIMEOUT_MS);
30
+ const timer = setTimeout(() => {
31
+ timers.delete(id);
32
+ if (Date.now() >= fireAt.getTime()) {
33
+ fireOrSkip(id);
34
+ }
35
+ else {
36
+ // Long-horizon cron: hop forward another chunk and re-check.
37
+ scheduleAt(id, fireAt);
38
+ }
39
+ }, cappedMs);
40
+ timer.unref?.();
41
+ timers.set(id, timer);
42
+ }
43
+ function rowToCron(row) {
44
+ return {
45
+ id: row.id,
46
+ workspaceId: row.workspace_id,
47
+ expression: row.expression,
48
+ prompt: row.prompt,
49
+ label: row.label,
50
+ agentSessionId: row.agent_session_id,
51
+ nextFireAt: row.next_fire_at,
52
+ lastFiredAt: row.last_fired_at,
53
+ oneShot: row.one_shot === 1,
54
+ createdAt: row.created_at,
55
+ };
56
+ }
57
+ /**
58
+ * Compute the next fire time for an expression strictly after `from`.
59
+ * Uses cron-parser 5.x API. Throws with a descriptive error if the
60
+ * expression is invalid. Helpers `@hourly` / `@daily` / `@weekly` /
61
+ * `@monthly` / `@yearly` are accepted natively.
62
+ */
63
+ function nextAfter(expression, from) {
64
+ try {
65
+ const it = CronExpressionParser.parse(expression, { currentDate: from });
66
+ return it.next().toDate();
67
+ }
68
+ catch (err) {
69
+ const msg = err instanceof Error ? err.message : String(err);
70
+ throw new Error(`Invalid cron expression: ${expression} — ${msg}`);
71
+ }
72
+ }
73
+ /**
74
+ * Validate the expression, persist the row, arm a setTimeout for the next
75
+ * fire, emit `cron:created`. Throws on invalid expression OR when the next
76
+ * fire is < MIN_DELAY_BETWEEN_FIRES_SECONDS seconds in the future.
77
+ */
78
+ export function arm(workspaceId, args) {
79
+ const now = new Date();
80
+ const next = nextAfter(args.expression, now);
81
+ const deltaMs = next.getTime() - now.getTime();
82
+ if (deltaMs < MIN_DELAY_BETWEEN_FIRES_SECONDS * 1000) {
83
+ throw new Error(`Cron expression resolves too close to now (minimum ${MIN_DELAY_BETWEEN_FIRES_SECONDS}s); use a longer interval`);
84
+ }
85
+ const id = nanoid();
86
+ const db = getDb();
87
+ db.prepare(`INSERT INTO pending_crons (id, workspace_id, expression, prompt, label, agent_session_id, next_fire_at, last_fired_at, one_shot, created_at)
88
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`).run(id, workspaceId, args.expression, args.prompt, args.label ?? null, args.agentSessionId ?? null, next.toISOString(), args.oneShot ? 1 : 0, now.toISOString());
89
+ scheduleAt(id, next);
90
+ const cron = rowToCron(db.prepare('SELECT * FROM pending_crons WHERE id = ?').get(id));
91
+ emitEphemeral(workspaceId, 'cron:created', { cron });
92
+ return cron;
93
+ }
94
+ /**
95
+ * Remove a single cron by id. Idempotent — returns false if no row matched.
96
+ * Emits `cron:cancelled` only when a row was actually deleted.
97
+ */
98
+ export function cancel(id, reason) {
99
+ const db = getDb();
100
+ const row = db.prepare('SELECT workspace_id FROM pending_crons WHERE id = ?').get(id);
101
+ if (!row)
102
+ return false;
103
+ db.prepare('DELETE FROM pending_crons WHERE id = ?').run(id);
104
+ const previous = timers.get(id);
105
+ if (previous) {
106
+ clearTimeout(previous);
107
+ timers.delete(id);
108
+ }
109
+ emitEphemeral(row.workspace_id, 'cron:cancelled', { id, reason });
110
+ return true;
111
+ }
112
+ /**
113
+ * Remove every cron for a workspace. Returns the number of rows deleted.
114
+ * Used by archive + delete cascades.
115
+ */
116
+ export function cancelAllForWorkspace(workspaceId, reason) {
117
+ const db = getDb();
118
+ const rows = db.prepare('SELECT id FROM pending_crons WHERE workspace_id = ?').all(workspaceId);
119
+ let deleted = 0;
120
+ for (const r of rows) {
121
+ if (cancel(r.id, reason))
122
+ deleted++;
123
+ }
124
+ return deleted;
125
+ }
126
+ export function getCron(id) {
127
+ const db = getDb();
128
+ const row = db.prepare('SELECT * FROM pending_crons WHERE id = ?').get(id);
129
+ return row ? rowToCron(row) : null;
130
+ }
131
+ export function listForWorkspace(workspaceId) {
132
+ const db = getDb();
133
+ const rows = db
134
+ .prepare('SELECT * FROM pending_crons WHERE workspace_id = ? ORDER BY next_fire_at ASC')
135
+ .all(workspaceId);
136
+ return rows.map(rowToCron);
137
+ }
138
+ export function listAll() {
139
+ const db = getDb();
140
+ const rows = db.prepare('SELECT * FROM pending_crons ORDER BY next_fire_at ASC').all();
141
+ return rows.map(rowToCron);
142
+ }
143
+ /**
144
+ * Internal — invoked by setTimeout when a cron's `next_fire_at` elapses.
145
+ * Either fires (calls orchestrator.startAgent with resume=true) or skips
146
+ * (when a controller is already active for the workspace), then recomputes
147
+ * the next occurrence and re-arms a fresh setTimeout. Best-effort: any
148
+ * unexpected error is logged and the cron is preserved when possible.
149
+ */
150
+ function fireOrSkip(id) {
151
+ try {
152
+ timers.delete(id);
153
+ const db = getDb();
154
+ const row = db.prepare('SELECT * FROM pending_crons WHERE id = ?').get(id);
155
+ if (!row)
156
+ return; // cancelled in flight
157
+ const wsRow = db
158
+ .prepare(`SELECT project_path, working_branch, worktree_path, model, agent_permission_mode, reasoning_effort, archived_at
159
+ FROM workspaces WHERE id = ?`)
160
+ .get(row.workspace_id);
161
+ if (!wsRow || wsRow.archived_at !== null) {
162
+ cancel(id, wsRow ? 'archive' : 'deleted');
163
+ return;
164
+ }
165
+ let status = 'skipped-active';
166
+ if (!orchestrator.hasController(row.workspace_id)) {
167
+ status = 'fired';
168
+ try {
169
+ const globalSettings = settingsService.getGlobalSettings();
170
+ const projectSettings = settingsService.getProjectSettings(wsRow.project_path);
171
+ const projectSlug = globalSettings.worktreesPrefixByProject
172
+ ? slugifyProjectName(projectSettings?.displayName ?? '', wsRow.project_path)
173
+ : undefined;
174
+ const worktreePath = wsRow.worktree_path ??
175
+ resolveWorkspaceWorktreePath(wsRow.project_path, wsRow.working_branch, globalSettings.worktreesPath, projectSlug);
176
+ const stored = wsRow.agent_permission_mode;
177
+ const agentPermissionMode = stored === 'plan' || stored === 'strict' || stored === 'interactive' ? stored : 'bypass';
178
+ // agent_session_id encodes the cron's mode: non-NULL means "resume
179
+ // that session" (pinned at create time); NULL means "fresh session
180
+ // every fire" (clean context, no conversation continuity).
181
+ const resumeMode = row.agent_session_id !== null;
182
+ orchestrator.startAgent(row.workspace_id, worktreePath, row.prompt, wsRow.model, resumeMode, agentPermissionMode, row.agent_session_id ?? undefined, wsRow.reasoning_effort);
183
+ }
184
+ catch (err) {
185
+ console.error(`[cron-service] startAgent at fire time failed for cron '${id}':`, err);
186
+ }
187
+ }
188
+ const now = new Date();
189
+ let nextFire;
190
+ try {
191
+ nextFire = nextAfter(row.expression, now);
192
+ }
193
+ catch (err) {
194
+ // Defensive — the expression validated at create time, so reaching here
195
+ // means cron-parser changed its acceptance rules between the original
196
+ // arm and this fire. Cancel as 'completed' (the cron self-terminates)
197
+ // rather than 'user' which would imply the user requested it.
198
+ console.error(`[cron-service] failed to recompute next fire for cron '${id}':`, err);
199
+ cancel(id, 'completed');
200
+ return;
201
+ }
202
+ // One-shot crons cancel themselves after a real fire (not on skip-active —
203
+ // the user expects the cron to actually run once, so a skipped tick must
204
+ // be retried at the next occurrence). Recurring crons re-arm normally.
205
+ if (status === 'fired' && row.one_shot === 1) {
206
+ db.prepare(`UPDATE pending_crons SET last_fired_at = ? WHERE id = ?`).run(now.toISOString(), id);
207
+ emitEphemeral(row.workspace_id, 'cron:fired', {
208
+ id,
209
+ status,
210
+ nextFireAt: null,
211
+ lastFiredAt: now.toISOString(),
212
+ oneShotConsumed: true,
213
+ });
214
+ cancel(id, 'completed');
215
+ return;
216
+ }
217
+ db.prepare(`UPDATE pending_crons SET next_fire_at = ?, last_fired_at = ? WHERE id = ?`).run(nextFire.toISOString(), now.toISOString(), id);
218
+ scheduleAt(id, nextFire);
219
+ emitEphemeral(row.workspace_id, 'cron:fired', {
220
+ id,
221
+ status,
222
+ nextFireAt: nextFire.toISOString(),
223
+ lastFiredAt: now.toISOString(),
224
+ });
225
+ }
226
+ catch (err) {
227
+ console.error(`[cron-service] fireOrSkip uncaught error for cron '${id}':`, err);
228
+ timers.delete(id);
229
+ }
230
+ }
231
+ /**
232
+ * Re-arm timers for rows persisted across restart. Skip-missed semantics:
233
+ * if the stored next_fire_at is in the past, recompute next() based on the
234
+ * current time and update the row before arming (mirror of POSIX crontab —
235
+ * no catchup spam after server downtime).
236
+ *
237
+ * Rows pointing at deleted/archived workspaces are removed without firing.
238
+ */
239
+ export function restoreOnBoot() {
240
+ try {
241
+ // Boot semantics: clear any existing in-memory timers before rearming.
242
+ for (const t of timers.values())
243
+ clearTimeout(t);
244
+ timers.clear();
245
+ const db = getDb();
246
+ const rows = db.prepare('SELECT * FROM pending_crons').all();
247
+ const now = new Date();
248
+ for (const row of rows) {
249
+ try {
250
+ const wsRow = db.prepare('SELECT archived_at FROM workspaces WHERE id = ?').get(row.workspace_id);
251
+ if (!wsRow || wsRow.archived_at !== null) {
252
+ db.prepare('DELETE FROM pending_crons WHERE id = ?').run(row.id);
253
+ continue;
254
+ }
255
+ const storedDate = new Date(row.next_fire_at);
256
+ let nextFireAt;
257
+ if (storedDate.getTime() <= now.getTime()) {
258
+ // Skip-missed semantics — no catchup spam after downtime.
259
+ const next = nextAfter(row.expression, now);
260
+ nextFireAt = next.toISOString();
261
+ }
262
+ else {
263
+ // Future row: normalise the persisted ISO format.
264
+ nextFireAt = storedDate.toISOString();
265
+ }
266
+ db.prepare('UPDATE pending_crons SET next_fire_at = ? WHERE id = ?').run(nextFireAt, row.id);
267
+ scheduleAt(row.id, new Date(nextFireAt));
268
+ }
269
+ catch (err) {
270
+ console.error(`[cron-service] restoreOnBoot row failed for cron '${row.id}':`, err);
271
+ }
272
+ }
273
+ }
274
+ catch (err) {
275
+ console.error('[cron-service] restoreOnBoot failed:', err);
276
+ }
277
+ }
278
+ /** @internal test-only */
279
+ export const _timers = timers;
@@ -5,7 +5,7 @@ import * as orchestrator from './agent/orchestrator.js';
5
5
  import * as settingsService from './settings-service.js';
6
6
  import { emitEphemeral } from './websocket-service.js';
7
7
  const MIN_DELAY_SECONDS = 60;
8
- const MAX_DELAY_SECONDS = 3600;
8
+ const MAX_DELAY_SECONDS = 21600;
9
9
  const STALE_WAKEUP_GRACE_MS = 5 * 60 * 1000;
10
10
  const AUTONOMOUS_LOOP_SENTINEL = '<<autonomous-loop-dynamic>>';
11
11
  const AUTONOMOUS_LOOP_FALLBACK_PROMPT = 'Continue where you left off.';
@@ -3,6 +3,7 @@ import { getDb } from '../db/index.js';
3
3
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
4
4
  import * as orchestrator from './agent/orchestrator.js';
5
5
  import * as autoLoopService from './auto-loop-service.js';
6
+ import * as cronService from './cron-service.js';
6
7
  import * as quotaBackoffService from './quota-backoff-service.js';
7
8
  import * as wakeupService from './wakeup-service.js';
8
9
  import { emitEphemeral } from './websocket-service.js';
@@ -363,6 +364,15 @@ export function deleteWorkspace(id) {
363
364
  catch (err) {
364
365
  console.error('[workspace-service] cancel quota backoff on delete failed:', err);
365
366
  }
367
+ // Cancel every pending cron BEFORE the FK cascade removes the rows so
368
+ // in-memory setTimeout timers are cleared. Best-effort: failure must not
369
+ // block delete.
370
+ try {
371
+ cronService.cancelAllForWorkspace(id, 'deleted');
372
+ }
373
+ catch (err) {
374
+ console.error('[workspace-service] cancel crons on delete failed:', err);
375
+ }
366
376
  // Drop the cached rate_limit.info so memory doesn't leak on workspace
367
377
  // churn. The Map has no FK to clean up for it automatically.
368
378
  orchestrator.forgetRateLimitInfo(id);
@@ -463,6 +473,14 @@ export function archiveWorkspace(id) {
463
473
  catch (err) {
464
474
  console.error('[workspace-service] cancel quota backoff on archive failed:', err);
465
475
  }
476
+ // Cancel every pending cron — archived workspaces must not fire scheduled
477
+ // prompts. Best-effort: failure here must not block archive.
478
+ try {
479
+ cronService.cancelAllForWorkspace(id, 'archive');
480
+ }
481
+ catch (err) {
482
+ console.error('[workspace-service] cancel crons on archive failed:', err);
483
+ }
466
484
  // Disable auto-loop — archived workspaces should not keep looping.
467
485
  // Idempotent: no-op if auto_loop was already 0.
468
486
  autoLoopService.disable(id, 'user-action');
@@ -583,7 +583,14 @@ export function getUnpushedChangedFiles(repoPath, branchName, remote = 'origin')
583
583
  export function getFileAtRef(repoPath, ref, filePath) {
584
584
  const resolvedRef = resolveBase(repoPath, ref);
585
585
  try {
586
- return git(repoPath, ['show', `${resolvedRef}:${filePath}`]);
586
+ // Bypass the `git()` helper here: it `.trimEnd()`s the output, which would
587
+ // strip trailing newlines from the original file content and produce a
588
+ // false diff against `getFileContent`'s untrimmed `readFileSync` output
589
+ // (last line marked added/removed even when identical).
590
+ return execFileSync('git', ['show', `${resolvedRef}:${filePath}`], {
591
+ cwd: repoPath,
592
+ encoding: 'utf-8',
593
+ });
587
594
  }
588
595
  catch {
589
596
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loicngr/kobo",
3
- "version": "1.7.5",
3
+ "version": "1.7.6",
4
4
  "description": "Kōbō — multi-workspace agent manager for Claude Code. Orchestrates isolated git worktrees with dev servers, Notion integration, and MCP tools.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",
@@ -70,6 +70,7 @@
70
70
  "@hono/node-server": "^1.19.13",
71
71
  "@modelcontextprotocol/sdk": "^1.29.0",
72
72
  "better-sqlite3": "^12.8.0",
73
+ "cron-parser": "^5.5.0",
73
74
  "hono": "^4.12.12",
74
75
  "nanoid": "^5.1.7",
75
76
  "node-pty": "^1.1.0",