chainlesschain 0.45.80 → 0.46.0

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 (48) hide show
  1. package/package.json +1 -1
  2. package/src/assets/web-panel/.build-hash +1 -1
  3. package/src/assets/web-panel/assets/{Analytics--dpzs0oZ.js → Analytics-C1AnPdMx.js} +2 -2
  4. package/src/assets/web-panel/assets/{AppLayout-DWXapZbP.js → AppLayout-BnvARObz.js} +1 -1
  5. package/src/assets/web-panel/assets/{Backup-BoUUzGFw.js → Backup-D31iZX3l.js} +1 -1
  6. package/src/assets/web-panel/assets/{Chat-CkSlXBzN.js → Chat-DiXJ3TuK.js} +1 -1
  7. package/src/assets/web-panel/assets/Cowork-B8ZDdRm4.js +7 -0
  8. package/src/assets/web-panel/assets/Cowork-CXuhlHew.css +1 -0
  9. package/src/assets/web-panel/assets/{Cron-xiBL6XfP.js → Cron-DBt1ueXh.js} +2 -2
  10. package/src/assets/web-panel/assets/{Dashboard-CmgEtUKl.js → Dashboard-jt6XPIjB.js} +1 -1
  11. package/src/assets/web-panel/assets/{Git-DCDjvp5Z.js → Git-hwQ1oZHj.js} +2 -2
  12. package/src/assets/web-panel/assets/{Logs-Qz-GLplC.js → Logs-4D9p6PRM.js} +1 -1
  13. package/src/assets/web-panel/assets/{McpTools-qYf_sT-Y.js → McpTools-CyAUjbbs.js} +1 -1
  14. package/src/assets/web-panel/assets/{Memory-BxoM2XNZ.js → Memory-BMqOR7S-.js} +2 -2
  15. package/src/assets/web-panel/assets/{Notes-DltR8wq4.js → Notes-Cmas8i4E.js} +2 -2
  16. package/src/assets/web-panel/assets/{Organization-7m_PX3yo.js → Organization-DnSa58Tl.js} +4 -4
  17. package/src/assets/web-panel/assets/{P2P-e88KqFBm.js → P2P-BxksIBWs.js} +2 -2
  18. package/src/assets/web-panel/assets/{Permissions-DAY4Xy1h.js → Permissions-Bq5Qn2s3.js} +4 -4
  19. package/src/assets/web-panel/assets/{Projects-ylUhg9th.js → Projects-B7EM0uPg.js} +1 -1
  20. package/src/assets/web-panel/assets/{Providers-DNIlBWLm.js → Providers-DAwgG5KV.js} +2 -2
  21. package/src/assets/web-panel/assets/{RssFeed-Dr_6vD69.js → RssFeed-HSZoRXvS.js} +2 -2
  22. package/src/assets/web-panel/assets/{Security-U57Q-VOj.js → Security-Cz17qBny.js} +3 -3
  23. package/src/assets/web-panel/assets/{Services-BUfO-jvr.js → Services-D2EsLq-v.js} +1 -1
  24. package/src/assets/web-panel/assets/{Skills-D0NYT7Q8.js → Skills-C9v-f3vZ.js} +1 -1
  25. package/src/assets/web-panel/assets/{Tasks-WXqKX58l.js → Tasks-yMEcU0n7.js} +1 -1
  26. package/src/assets/web-panel/assets/{Templates-B1zfqNTe.js → Templates-l7SvlKuB.js} +1 -1
  27. package/src/assets/web-panel/assets/{Wallet-CUSPGN3F.js → Wallet-BHWhLWn9.js} +4 -4
  28. package/src/assets/web-panel/assets/{WebAuthn-ZGz__UJi.js → WebAuthn-kWhFYaUK.js} +4 -4
  29. package/src/assets/web-panel/assets/{antd-BQNxIyr-.js → antd-D6h4fDFf.js} +82 -82
  30. package/src/assets/web-panel/assets/{index-BYqeR6ME.js → index-C1SPm_5l.js} +2 -2
  31. package/src/assets/web-panel/assets/{markdown-BeVIhIzs.js → markdown-BZsB-Dsv.js} +1 -1
  32. package/src/assets/web-panel/index.html +2 -2
  33. package/src/commands/cowork.js +695 -0
  34. package/src/gateways/ws/action-protocol.js +143 -2
  35. package/src/gateways/ws/message-dispatcher.js +3 -0
  36. package/src/gateways/ws/ws-server.js +18 -0
  37. package/src/lib/cowork-cron.js +474 -0
  38. package/src/lib/cowork-learning.js +438 -0
  39. package/src/lib/cowork-mcp-tools.js +182 -0
  40. package/src/lib/cowork-share.js +218 -0
  41. package/src/lib/cowork-task-runner.js +364 -4
  42. package/src/lib/cowork-task-templates.js +203 -12
  43. package/src/lib/cowork-template-marketplace.js +205 -0
  44. package/src/lib/cowork-workflow.js +571 -0
  45. package/src/lib/sub-agent-context.js +66 -0
  46. package/src/lib/workflow-expr.js +318 -0
  47. package/src/assets/web-panel/assets/Cowork-CPqYhoMI.css +0 -1
  48. package/src/assets/web-panel/assets/Cowork-DjAJ5ymV.js +0 -48
@@ -1,3 +1,6 @@
1
+ // Track running cowork tasks for cancellation
2
+ const _runningTasks = new Map();
3
+
1
4
  export async function handleCoworkTask(server, id, ws, message) {
2
5
  const { templateId = null, userMessage, files = [] } = message;
3
6
 
@@ -11,21 +14,66 @@ export async function handleCoworkTask(server, id, ws, message) {
11
14
  return;
12
15
  }
13
16
 
17
+ const ac = new AbortController();
18
+ const trackingId = `cowork-${id}`;
19
+ _runningTasks.set(trackingId, ac);
20
+
14
21
  try {
15
- const { runCoworkTask } = await import("../../lib/cowork-task-runner.js");
22
+ const { runCoworkTask, runCoworkTaskParallel, runCoworkDebate } =
23
+ await import("../../lib/cowork-task-runner.js");
24
+ const { getTemplate } = await import("../../lib/cowork-task-templates.js");
25
+
26
+ // Determine execution mode: debate > parallel > sequential
27
+ const template = getTemplate(templateId);
28
+ const useDebate =
29
+ message.mode === "debate" ||
30
+ (message.mode !== "agent" && template.mode === "debate");
31
+ const useParallel =
32
+ !useDebate &&
33
+ (message.parallel === true ||
34
+ (message.parallel !== false && template.parallelStrategy === "always"));
16
35
 
17
36
  server._send(ws, {
18
37
  id,
19
38
  type: "cowork:started",
20
39
  templateId,
40
+ trackingId,
41
+ parallel: useParallel,
42
+ mode: useDebate ? "debate" : "agent",
21
43
  });
22
44
 
23
- const result = await runCoworkTask({
45
+ const runner = useDebate
46
+ ? runCoworkDebate
47
+ : useParallel
48
+ ? runCoworkTaskParallel
49
+ : runCoworkTask;
50
+
51
+ const result = await runner({
24
52
  templateId,
25
53
  userMessage,
26
54
  files,
27
55
  cwd: server.projectRoot || process.cwd(),
28
56
  llmOptions: {},
57
+ signal: ac.signal,
58
+ ...(useParallel
59
+ ? {
60
+ agents: message.agents || 3,
61
+ strategy: message.strategy,
62
+ }
63
+ : {}),
64
+ ...(useDebate && message.perspectives
65
+ ? { perspectives: message.perspectives }
66
+ : {}),
67
+ onProgress: (progress) => {
68
+ server._send(ws, {
69
+ id,
70
+ type: "cowork:progress",
71
+ event: progress.type,
72
+ tool: progress.tool,
73
+ iterationCount: progress.iterationCount,
74
+ tokenCount: progress.tokenCount,
75
+ });
76
+ },
29
77
  });
30
78
 
31
79
  server._send(ws, {
@@ -39,6 +87,18 @@ export async function handleCoworkTask(server, id, ws, message) {
39
87
  artifacts: result.result?.artifacts || [],
40
88
  toolsUsed: result.result?.toolsUsed || [],
41
89
  iterationCount: result.result?.iterationCount || 0,
90
+ tokenCount: result.result?.tokenCount || 0,
91
+ parallel: result.parallel || false,
92
+ subtaskCount: result.result?.subtaskCount || 0,
93
+ mode: result.mode || (useDebate ? "debate" : "agent"),
94
+ ...(useDebate
95
+ ? {
96
+ verdict: result.result?.verdict,
97
+ consensusScore: result.result?.consensusScore,
98
+ reviews: result.result?.reviews || [],
99
+ perspectives: result.result?.perspectives || [],
100
+ }
101
+ : {}),
42
102
  });
43
103
  } catch (err) {
44
104
  server._send(ws, {
@@ -47,6 +107,36 @@ export async function handleCoworkTask(server, id, ws, message) {
47
107
  code: "COWORK_FAILED",
48
108
  message: err.message,
49
109
  });
110
+ } finally {
111
+ _runningTasks.delete(trackingId);
112
+ }
113
+ }
114
+
115
+ export function handleCoworkCancel(server, id, ws, message) {
116
+ const { trackingId } = message;
117
+
118
+ if (!trackingId) {
119
+ server._send(ws, {
120
+ id,
121
+ type: "error",
122
+ code: "INVALID_MESSAGE",
123
+ message: "trackingId field required",
124
+ });
125
+ return;
126
+ }
127
+
128
+ const ac = _runningTasks.get(trackingId);
129
+ if (ac) {
130
+ ac.abort();
131
+ _runningTasks.delete(trackingId);
132
+ server._send(ws, { id, type: "cowork:cancelled", trackingId });
133
+ } else {
134
+ server._send(ws, {
135
+ id,
136
+ type: "error",
137
+ code: "TASK_NOT_FOUND",
138
+ message: `No running cowork task: ${trackingId}`,
139
+ });
50
140
  }
51
141
  }
52
142
 
@@ -67,6 +157,57 @@ export function handleSlashCommand(server, id, ws, message) {
67
157
  handler.handleSlashCommand(command, id);
68
158
  }
69
159
 
160
+ export async function handleCoworkHistory(server, id, ws, message) {
161
+ const { limit = 50 } = message;
162
+ const cwd = server.projectRoot || process.cwd();
163
+
164
+ try {
165
+ const { readFileSync, existsSync } = await import("node:fs");
166
+ const { join } = await import("node:path");
167
+ const histPath = join(cwd, ".chainlesschain", "cowork", "history.jsonl");
168
+
169
+ if (!existsSync(histPath)) {
170
+ server._send(ws, { id, type: "cowork:history", entries: [] });
171
+ return;
172
+ }
173
+
174
+ const lines = readFileSync(histPath, "utf-8").split("\n").filter(Boolean);
175
+ const entries = [];
176
+ for (const line of lines.slice(-limit)) {
177
+ try {
178
+ entries.push(JSON.parse(line));
179
+ } catch (_e) {
180
+ // skip malformed lines
181
+ }
182
+ }
183
+
184
+ server._send(ws, { id, type: "cowork:history", entries });
185
+ } catch (err) {
186
+ server._send(ws, {
187
+ id,
188
+ type: "error",
189
+ code: "HISTORY_FAILED",
190
+ message: err.message,
191
+ });
192
+ }
193
+ }
194
+
195
+ export async function handleCoworkTemplates(server, id, ws) {
196
+ try {
197
+ const { getTemplatesForUI } =
198
+ await import("../../lib/cowork-task-templates.js");
199
+ const templates = getTemplatesForUI();
200
+ server._send(ws, { id, type: "cowork:templates", templates });
201
+ } catch (err) {
202
+ server._send(ws, {
203
+ id,
204
+ type: "error",
205
+ code: "TEMPLATES_FAILED",
206
+ message: err.message,
207
+ });
208
+ }
209
+ }
210
+
70
211
  export async function handleOrchestrate(server, id, ws, message) {
71
212
  const {
72
213
  task,
@@ -44,6 +44,9 @@ export function createWsMessageDispatcher(server) {
44
44
  "host-tool-result": () => server._handleHostToolResult(id, ws, message),
45
45
  orchestrate: () => server._handleOrchestrate(id, ws, message),
46
46
  "cowork-task": () => server._handleCoworkTask(id, ws, message),
47
+ "cowork-cancel": () => server._handleCoworkCancel(id, ws, message),
48
+ "cowork-templates": () => server._handleCoworkTemplates(id, ws),
49
+ "cowork-history": () => server._handleCoworkHistory(id, ws, message),
47
50
  "tasks-list": () => server._handleTasksList(id, ws),
48
51
  "tasks-stop": () => server._handleTasksStop(id, ws, message),
49
52
  "tasks-detail": () => server._handleTaskDetail(id, ws, message),
@@ -52,6 +52,9 @@ import {
52
52
  handleSlashCommand,
53
53
  handleOrchestrate,
54
54
  handleCoworkTask,
55
+ handleCoworkCancel,
56
+ handleCoworkTemplates,
57
+ handleCoworkHistory,
55
58
  } from "./action-protocol.js";
56
59
  import {
57
60
  handleWorktreeDiff,
@@ -303,6 +306,21 @@ export class ChainlessChainWSServer extends EventEmitter {
303
306
  return handleCoworkTask(this, id, ws, message);
304
307
  }
305
308
 
309
+ /** @private — cancel a running cowork task */
310
+ _handleCoworkCancel(id, ws, message) {
311
+ return handleCoworkCancel(this, id, ws, message);
312
+ }
313
+
314
+ /** @private — return cowork templates for UI */
315
+ _handleCoworkTemplates(id, ws) {
316
+ return handleCoworkTemplates(this, id, ws);
317
+ }
318
+
319
+ /** @private — return cowork task history */
320
+ _handleCoworkHistory(id, ws, message) {
321
+ return handleCoworkHistory(this, id, ws, message);
322
+ }
323
+
306
324
  /** @private – list background tasks */
307
325
  async _handleTasksList(id, ws) {
308
326
  try {
@@ -0,0 +1,474 @@
1
+ /**
2
+ * Cowork Cron — schedule and run daily Cowork tasks on a cron.
3
+ *
4
+ * Persists schedules to `.chainlesschain/cowork/schedules.jsonl` (one JSON
5
+ * object per line). Schedules have shape:
6
+ *
7
+ * {
8
+ * id: "sch-...",
9
+ * cron: "0 9 * * 1-5", // 5-field POSIX cron
10
+ * templateId: "doc-convert", // or null for free mode
11
+ * userMessage: "...", // task prompt
12
+ * files: ["/abs/path/..."], // optional
13
+ * enabled: true,
14
+ * createdAt: ISO,
15
+ * lastRunAt: ISO|null,
16
+ * lastStatus: "completed"|"failed"|null,
17
+ * }
18
+ *
19
+ * The scheduler ticks every 60s by default. If any schedule uses a 6-field
20
+ * (seconds-aware) cron expression, the tick rate auto-adapts to 1s.
21
+ * Each tick reloads schedules and runs any whose cron matches the current
22
+ * minute (or second, if 6-field). A schedule only runs once per fire-window.
23
+ *
24
+ * Supported cron syntax:
25
+ * - 5 fields: minute hour dom month dow (POSIX)
26
+ * - 6 fields: second minute hour dom month dow (Quartz-like, seconds first)
27
+ * - Aliases: @yearly @annually @monthly @weekly @daily @midnight @hourly
28
+ *
29
+ * @module cowork-cron
30
+ */
31
+
32
+ import crypto from "node:crypto";
33
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
34
+ import { join } from "node:path";
35
+
36
+ export const _deps = {
37
+ existsSync,
38
+ mkdirSync,
39
+ readFileSync,
40
+ writeFileSync,
41
+ now: () => new Date(),
42
+ runTask: null, // injected at runtime to avoid circular import
43
+ };
44
+
45
+ // ─── Cron parser ─────────────────────────────────────────────────────────────
46
+
47
+ const FIELD_RANGES = [
48
+ [0, 59], // minute
49
+ [0, 23], // hour
50
+ [1, 31], // day-of-month
51
+ [1, 12], // month
52
+ [0, 6], // day-of-week (0=Sun, 6=Sat). 7 also maps to 0.
53
+ ];
54
+
55
+ const SECOND_RANGE = [0, 59];
56
+
57
+ /**
58
+ * Non-standard alias → 5-field expression. Aliases never carry seconds.
59
+ */
60
+ export const ALIASES = Object.freeze({
61
+ "@yearly": "0 0 1 1 *",
62
+ "@annually": "0 0 1 1 *",
63
+ "@monthly": "0 0 1 * *",
64
+ "@weekly": "0 0 * * 0",
65
+ "@daily": "0 0 * * *",
66
+ "@midnight": "0 0 * * *",
67
+ "@hourly": "0 * * * *",
68
+ });
69
+
70
+ /**
71
+ * Expand a cron alias (e.g. "@daily") into its 5-field equivalent.
72
+ * Returns the original string unchanged if not an alias.
73
+ */
74
+ export function _expandExpr(expr) {
75
+ if (typeof expr !== "string") return expr;
76
+ const trimmed = expr.trim();
77
+ if (trimmed.startsWith("@")) {
78
+ const alias = ALIASES[trimmed.toLowerCase()];
79
+ if (!alias) throw new Error(`unknown cron alias: ${trimmed}`);
80
+ return alias;
81
+ }
82
+ return trimmed;
83
+ }
84
+
85
+ /**
86
+ * Parse a single cron field into a Set of matching integers.
87
+ * Supports:
88
+ * * — every value in range
89
+ * N — single number
90
+ * A-B — inclusive range
91
+ * *\/N or A-B/N — step (every Nth value)
92
+ * a,b,c — comma-separated list (any combination of the above)
93
+ */
94
+ export function parseCronField(field, [min, max]) {
95
+ if (typeof field !== "string" || field.length === 0) {
96
+ throw new Error("empty cron field");
97
+ }
98
+ const values = new Set();
99
+ for (const part of field.split(",")) {
100
+ const slashIdx = part.indexOf("/");
101
+ const stepStr = slashIdx >= 0 ? part.slice(slashIdx + 1) : null;
102
+ const base = slashIdx >= 0 ? part.slice(0, slashIdx) : part;
103
+ const step = stepStr === null ? 1 : parseInt(stepStr, 10);
104
+ if (!Number.isFinite(step) || step < 1) {
105
+ throw new Error(`invalid cron step: ${part}`);
106
+ }
107
+ let lo, hi;
108
+ if (base === "*") {
109
+ lo = min;
110
+ hi = max;
111
+ } else if (base.includes("-")) {
112
+ const [a, b] = base.split("-").map((x) => parseInt(x, 10));
113
+ if (!Number.isFinite(a) || !Number.isFinite(b)) {
114
+ throw new Error(`invalid cron range: ${part}`);
115
+ }
116
+ lo = a;
117
+ hi = b;
118
+ } else {
119
+ const n = parseInt(base, 10);
120
+ if (!Number.isFinite(n)) {
121
+ throw new Error(`invalid cron value: ${part}`);
122
+ }
123
+ lo = n;
124
+ hi = n;
125
+ }
126
+ // Normalize day-of-week 7 → 0
127
+ if (max === 6 && lo === 7) lo = 0;
128
+ if (max === 6 && hi === 7) hi = 0;
129
+ if (lo < min || hi > max || lo > hi) {
130
+ throw new Error(`cron value out of range ${min}-${max}: ${part}`);
131
+ }
132
+ for (let v = lo; v <= hi; v += step) {
133
+ values.add(v);
134
+ }
135
+ }
136
+ return values;
137
+ }
138
+
139
+ /**
140
+ * Parse a 5- or 6-field cron expression (or alias). Returns a match function
141
+ * with `.hasSeconds` boolean property indicating whether the expression
142
+ * carries seconds resolution.
143
+ *
144
+ * @param {string} expr
145
+ * @returns {((date: Date) => boolean) & { hasSeconds: boolean }}
146
+ */
147
+ export function parseCron(expr) {
148
+ if (typeof expr !== "string") {
149
+ throw new Error("cron expression must be a string");
150
+ }
151
+ const expanded = _expandExpr(expr);
152
+ const parts = expanded.split(/\s+/);
153
+ if (parts.length !== 5 && parts.length !== 6) {
154
+ throw new Error(
155
+ `cron must have 5 or 6 fields (got ${parts.length}); use 6-field for seconds resolution or alias like @daily`,
156
+ );
157
+ }
158
+ const hasSeconds = parts.length === 6;
159
+ let second = null;
160
+ let minute, hour, dom, month, dow;
161
+ if (hasSeconds) {
162
+ second = parseCronField(parts[0], SECOND_RANGE);
163
+ [minute, hour, dom, month, dow] = parts
164
+ .slice(1)
165
+ .map((p, i) => parseCronField(p, FIELD_RANGES[i]));
166
+ } else {
167
+ [minute, hour, dom, month, dow] = parts.map((p, i) =>
168
+ parseCronField(p, FIELD_RANGES[i]),
169
+ );
170
+ }
171
+
172
+ // Pre-compute restriction flags for POSIX dom/dow OR-semantics
173
+ const domField = hasSeconds ? parts[3] : parts[2];
174
+ const dowField = hasSeconds ? parts[5] : parts[4];
175
+ const domRestricted = domField !== "*";
176
+ const dowRestricted = dowField !== "*";
177
+
178
+ function matches(date) {
179
+ const s = date.getSeconds();
180
+ const m = date.getMinutes();
181
+ const h = date.getHours();
182
+ const D = date.getDate();
183
+ const M = date.getMonth() + 1; // JS month is 0-based
184
+ const W = date.getDay();
185
+ if (hasSeconds && !second.has(s)) return false;
186
+ if (!minute.has(m)) return false;
187
+ if (!hour.has(h)) return false;
188
+ if (!month.has(M)) return false;
189
+ // POSIX: if both dom and dow are restricted (not *), match is OR.
190
+ if (domRestricted && dowRestricted) {
191
+ return dom.has(D) || dow.has(W);
192
+ }
193
+ if (domRestricted) return dom.has(D);
194
+ if (dowRestricted) return dow.has(W);
195
+ return true;
196
+ }
197
+ matches.hasSeconds = hasSeconds;
198
+ return matches;
199
+ }
200
+
201
+ /** Returns true if the cron expression carries seconds resolution. */
202
+ export function hasSecondsResolution(expr) {
203
+ try {
204
+ return parseCron(expr).hasSeconds;
205
+ } catch (_e) {
206
+ return false;
207
+ }
208
+ }
209
+
210
+ /** Validate a cron expression — returns null if valid, error string otherwise. */
211
+ export function validateCron(expr) {
212
+ try {
213
+ parseCron(expr);
214
+ return null;
215
+ } catch (err) {
216
+ return err.message;
217
+ }
218
+ }
219
+
220
+ // ─── Persistence ─────────────────────────────────────────────────────────────
221
+
222
+ function _scheduleFile(cwd) {
223
+ return join(cwd, ".chainlesschain", "cowork", "schedules.jsonl");
224
+ }
225
+
226
+ /** Load all schedules from disk. Returns [] if the file doesn't exist. */
227
+ export function loadSchedules(cwd) {
228
+ const file = _scheduleFile(cwd);
229
+ if (!_deps.existsSync(file)) return [];
230
+ const raw = _deps.readFileSync(file, "utf-8");
231
+ const out = [];
232
+ for (const line of raw.split("\n")) {
233
+ const trimmed = line.trim();
234
+ if (!trimmed) continue;
235
+ try {
236
+ out.push(JSON.parse(trimmed));
237
+ } catch (_e) {
238
+ // Skip malformed lines — don't let one bad record break the rest
239
+ }
240
+ }
241
+ return out;
242
+ }
243
+
244
+ /** Write the full schedule list back to disk, overwriting. */
245
+ export function saveSchedules(cwd, schedules) {
246
+ const dir = join(cwd, ".chainlesschain", "cowork");
247
+ _deps.mkdirSync(dir, { recursive: true });
248
+ const file = _scheduleFile(cwd);
249
+ const body = schedules.map((s) => JSON.stringify(s)).join("\n");
250
+ _deps.writeFileSync(file, body ? body + "\n" : "", "utf-8");
251
+ }
252
+
253
+ // ─── CRUD ────────────────────────────────────────────────────────────────────
254
+
255
+ export function addSchedule(cwd, input) {
256
+ const { cron, templateId = null, userMessage, files = [] } = input || {};
257
+ if (!userMessage || typeof userMessage !== "string") {
258
+ throw new Error("userMessage is required");
259
+ }
260
+ const err = validateCron(cron);
261
+ if (err) throw new Error(`invalid cron: ${err}`);
262
+
263
+ const schedules = loadSchedules(cwd);
264
+ const entry = {
265
+ id: `sch-${crypto.randomUUID().slice(0, 12)}`,
266
+ cron: cron.trim(),
267
+ templateId,
268
+ userMessage,
269
+ files: Array.isArray(files) ? files : [],
270
+ enabled: true,
271
+ createdAt: _deps.now().toISOString(),
272
+ lastRunAt: null,
273
+ lastStatus: null,
274
+ };
275
+ schedules.push(entry);
276
+ saveSchedules(cwd, schedules);
277
+ return entry;
278
+ }
279
+
280
+ export function removeSchedule(cwd, id) {
281
+ const schedules = loadSchedules(cwd);
282
+ const idx = schedules.findIndex((s) => s.id === id);
283
+ if (idx === -1) return false;
284
+ schedules.splice(idx, 1);
285
+ saveSchedules(cwd, schedules);
286
+ return true;
287
+ }
288
+
289
+ export function setScheduleEnabled(cwd, id, enabled) {
290
+ const schedules = loadSchedules(cwd);
291
+ const s = schedules.find((x) => x.id === id);
292
+ if (!s) return false;
293
+ s.enabled = !!enabled;
294
+ saveSchedules(cwd, schedules);
295
+ return true;
296
+ }
297
+
298
+ export function updateScheduleRunState(cwd, id, { lastRunAt, lastStatus }) {
299
+ const schedules = loadSchedules(cwd);
300
+ const s = schedules.find((x) => x.id === id);
301
+ if (!s) return false;
302
+ if (lastRunAt) s.lastRunAt = lastRunAt;
303
+ if (lastStatus) s.lastStatus = lastStatus;
304
+ saveSchedules(cwd, schedules);
305
+ return true;
306
+ }
307
+
308
+ // ─── Scheduler ───────────────────────────────────────────────────────────────
309
+
310
+ /**
311
+ * Background scheduler that checks schedules every minute and runs due ones.
312
+ * Enforces once-per-minute-per-schedule via `_firedKeys` (schedule.id + minute).
313
+ */
314
+ export class CoworkCronScheduler {
315
+ constructor(options = {}) {
316
+ this.cwd = options.cwd || process.cwd();
317
+ // If caller pins intervalMs we honor it; else auto-adapt based on schedules.
318
+ this._intervalPinned = typeof options.intervalMs === "number";
319
+ this.intervalMs = options.intervalMs || 60_000;
320
+ this.onEvent = options.onEvent || null; // (event) => void
321
+ this._timer = null;
322
+ this._firedKeys = new Set();
323
+ this._running = new Set();
324
+ }
325
+
326
+ start() {
327
+ if (this._timer) return;
328
+ this._adaptInterval(); // pick 1s vs 60s based on current schedules
329
+ this._tick(); // immediate first tick so tests don't wait
330
+ this._timer = setInterval(() => this._tick(), this.intervalMs);
331
+ this._emit({ type: "scheduler-started", intervalMs: this.intervalMs });
332
+ }
333
+
334
+ /**
335
+ * Re-evaluate desired tick rate. If any active schedule uses seconds, drop
336
+ * to 1s; else use 60s. No-op if caller pinned intervalMs.
337
+ */
338
+ _adaptInterval() {
339
+ if (this._intervalPinned) return;
340
+ let schedules = [];
341
+ try {
342
+ schedules = loadSchedules(this.cwd);
343
+ } catch (_e) {
344
+ // ignore — keep current interval
345
+ }
346
+ const wantSeconds = schedules.some(
347
+ (s) => s.enabled !== false && hasSecondsResolution(s.cron),
348
+ );
349
+ const desired = wantSeconds ? 1000 : 60_000;
350
+ if (desired !== this.intervalMs) {
351
+ this.intervalMs = desired;
352
+ if (this._timer) {
353
+ clearInterval(this._timer);
354
+ this._timer = setInterval(() => this._tick(), this.intervalMs);
355
+ this._emit({ type: "scheduler-retuned", intervalMs: this.intervalMs });
356
+ }
357
+ }
358
+ }
359
+
360
+ stop() {
361
+ if (this._timer) {
362
+ clearInterval(this._timer);
363
+ this._timer = null;
364
+ }
365
+ this._emit({ type: "scheduler-stopped" });
366
+ }
367
+
368
+ _emit(event) {
369
+ if (typeof this.onEvent === "function") {
370
+ try {
371
+ this.onEvent(event);
372
+ } catch (_e) {
373
+ // Never let observer errors break the scheduler
374
+ }
375
+ }
376
+ }
377
+
378
+ async _tick() {
379
+ const now = _deps.now();
380
+ let schedules;
381
+ try {
382
+ schedules = loadSchedules(this.cwd);
383
+ } catch (err) {
384
+ this._emit({ type: "load-error", error: err.message });
385
+ return;
386
+ }
387
+
388
+ // Re-adapt interval if schedule set changed (added/removed seconds-aware)
389
+ this._adaptInterval();
390
+
391
+ for (const s of schedules) {
392
+ if (!s.enabled) continue;
393
+ let matcher;
394
+ try {
395
+ matcher = parseCron(s.cron);
396
+ } catch (err) {
397
+ this._emit({ type: "invalid-cron", id: s.id, error: err.message });
398
+ continue;
399
+ }
400
+ const fireKey = `${s.id}:${
401
+ matcher.hasSeconds ? _secondKey(now) : _minuteKey(now)
402
+ }`;
403
+ if (this._firedKeys.has(fireKey)) continue;
404
+ if (this._running.has(s.id)) continue;
405
+ const isDue = matcher(now);
406
+ if (!isDue) continue;
407
+
408
+ this._firedKeys.add(fireKey);
409
+ this._running.add(s.id);
410
+ this._runSchedule(s).finally(() => {
411
+ this._running.delete(s.id);
412
+ });
413
+ }
414
+
415
+ // Prevent unbounded growth of _firedKeys — keep only recent minute keys
416
+ if (this._firedKeys.size > 10_000) {
417
+ this._firedKeys.clear();
418
+ }
419
+ }
420
+
421
+ async _runSchedule(schedule) {
422
+ const runTask = _deps.runTask;
423
+ if (typeof runTask !== "function") {
424
+ this._emit({
425
+ type: "run-error",
426
+ id: schedule.id,
427
+ error: "runTask not injected",
428
+ });
429
+ return;
430
+ }
431
+ this._emit({
432
+ type: "schedule-fired",
433
+ id: schedule.id,
434
+ cron: schedule.cron,
435
+ templateId: schedule.templateId,
436
+ });
437
+ try {
438
+ const result = await runTask({
439
+ templateId: schedule.templateId,
440
+ userMessage: schedule.userMessage,
441
+ files: schedule.files,
442
+ cwd: this.cwd,
443
+ });
444
+ updateScheduleRunState(this.cwd, schedule.id, {
445
+ lastRunAt: _deps.now().toISOString(),
446
+ lastStatus: result?.status || "completed",
447
+ });
448
+ this._emit({
449
+ type: "schedule-completed",
450
+ id: schedule.id,
451
+ taskId: result?.taskId,
452
+ status: result?.status,
453
+ });
454
+ } catch (err) {
455
+ updateScheduleRunState(this.cwd, schedule.id, {
456
+ lastRunAt: _deps.now().toISOString(),
457
+ lastStatus: "failed",
458
+ });
459
+ this._emit({
460
+ type: "schedule-failed",
461
+ id: schedule.id,
462
+ error: err.message,
463
+ });
464
+ }
465
+ }
466
+ }
467
+
468
+ function _minuteKey(date) {
469
+ return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}-${date.getHours()}-${date.getMinutes()}`;
470
+ }
471
+
472
+ function _secondKey(date) {
473
+ return `${_minuteKey(date)}-${date.getSeconds()}`;
474
+ }