@gajae-code/coding-agent 0.2.4 → 0.2.5

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 (179) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +1 -1
  3. package/dist/types/async/job-manager.d.ts +61 -0
  4. package/dist/types/config/settings-schema.d.ts +7 -3
  5. package/dist/types/config/settings.d.ts +1 -1
  6. package/dist/types/discovery/helpers.d.ts +1 -0
  7. package/dist/types/exec/bash-executor.d.ts +8 -1
  8. package/dist/types/gjc-runtime/restricted-role-agent-bash.d.ts +2 -0
  9. package/dist/types/modes/acp/acp-client-bridge.d.ts +1 -1
  10. package/dist/types/modes/components/skill-hud/render.d.ts +1 -1
  11. package/dist/types/modes/interactive-mode.d.ts +1 -0
  12. package/dist/types/modes/theme/defaults/index.d.ts +45 -9477
  13. package/dist/types/modes/theme/theme.d.ts +1 -5
  14. package/dist/types/modes/types.d.ts +1 -0
  15. package/dist/types/sdk.d.ts +2 -0
  16. package/dist/types/session/streaming-output.d.ts +11 -0
  17. package/dist/types/skill-state/active-state.d.ts +1 -0
  18. package/dist/types/task/types.d.ts +1 -0
  19. package/dist/types/tools/bash-allowed-prefixes.d.ts +5 -0
  20. package/dist/types/tools/bash.d.ts +24 -0
  21. package/dist/types/tools/cron.d.ts +110 -0
  22. package/dist/types/tools/index.d.ts +4 -0
  23. package/dist/types/tools/monitor.d.ts +54 -0
  24. package/dist/types/web/search/index.d.ts +1 -0
  25. package/dist/types/web/search/provider.d.ts +11 -4
  26. package/dist/types/web/search/providers/duckduckgo.d.ts +57 -0
  27. package/dist/types/web/search/types.d.ts +1 -1
  28. package/package.json +7 -7
  29. package/src/async/job-manager.ts +224 -0
  30. package/src/cli/agents-cli.ts +3 -0
  31. package/src/config/settings-schema.ts +8 -2
  32. package/src/config/settings.ts +44 -7
  33. package/src/defaults/gjc/skills/deep-interview/SKILL.md +9 -2
  34. package/src/defaults/gjc/skills/ralplan/SKILL.md +8 -4
  35. package/src/discovery/helpers.ts +5 -0
  36. package/src/eval/js/shared/rewrite-imports.ts +1 -2
  37. package/src/exec/bash-executor.ts +20 -9
  38. package/src/gjc-runtime/ralplan-runtime.ts +2 -0
  39. package/src/gjc-runtime/restricted-role-agent-bash.ts +5 -0
  40. package/src/hooks/skill-state.ts +1 -1
  41. package/src/internal-urls/docs-index.generated.ts +5 -3
  42. package/src/lsp/render.ts +1 -1
  43. package/src/modes/acp/acp-agent.ts +1 -1
  44. package/src/modes/acp/acp-client-bridge.ts +1 -1
  45. package/src/modes/components/agent-dashboard.ts +1 -1
  46. package/src/modes/components/diff.ts +2 -2
  47. package/src/modes/components/skill-hud/render.ts +7 -2
  48. package/src/modes/controllers/input-controller.ts +10 -2
  49. package/src/modes/controllers/selector-controller.ts +1 -1
  50. package/src/modes/interactive-mode.ts +20 -2
  51. package/src/modes/theme/defaults/index.ts +0 -196
  52. package/src/modes/theme/theme.ts +35 -35
  53. package/src/modes/types.ts +1 -0
  54. package/src/prompts/agents/architect.md +5 -1
  55. package/src/prompts/agents/critic.md +5 -1
  56. package/src/prompts/agents/frontmatter.md +1 -0
  57. package/src/prompts/agents/planner.md +5 -1
  58. package/src/prompts/tools/bash.md +9 -0
  59. package/src/prompts/tools/cron.md +25 -0
  60. package/src/prompts/tools/monitor.md +30 -0
  61. package/src/runtime-mcp/oauth-flow.ts +4 -2
  62. package/src/sdk.ts +3 -0
  63. package/src/session/agent-session.ts +16 -5
  64. package/src/session/streaming-output.ts +21 -0
  65. package/src/skill-state/active-state.ts +163 -12
  66. package/src/task/agents.ts +1 -0
  67. package/src/task/executor.ts +1 -0
  68. package/src/task/types.ts +1 -0
  69. package/src/tools/bash-allowed-prefixes.ts +169 -0
  70. package/src/tools/bash.ts +190 -29
  71. package/src/tools/browser/tab-worker.ts +1 -1
  72. package/src/tools/cron.ts +665 -0
  73. package/src/tools/index.ts +20 -2
  74. package/src/tools/monitor.ts +136 -0
  75. package/src/vim/engine.ts +3 -3
  76. package/src/web/search/index.ts +31 -18
  77. package/src/web/search/provider.ts +57 -12
  78. package/src/web/search/providers/duckduckgo.ts +279 -0
  79. package/src/web/search/types.ts +2 -0
  80. package/src/modes/theme/dark.json +0 -95
  81. package/src/modes/theme/defaults/alabaster.json +0 -93
  82. package/src/modes/theme/defaults/amethyst.json +0 -96
  83. package/src/modes/theme/defaults/anthracite.json +0 -93
  84. package/src/modes/theme/defaults/basalt.json +0 -91
  85. package/src/modes/theme/defaults/birch.json +0 -95
  86. package/src/modes/theme/defaults/dark-abyss.json +0 -91
  87. package/src/modes/theme/defaults/dark-arctic.json +0 -104
  88. package/src/modes/theme/defaults/dark-aurora.json +0 -95
  89. package/src/modes/theme/defaults/dark-catppuccin.json +0 -107
  90. package/src/modes/theme/defaults/dark-cavern.json +0 -91
  91. package/src/modes/theme/defaults/dark-copper.json +0 -95
  92. package/src/modes/theme/defaults/dark-cosmos.json +0 -90
  93. package/src/modes/theme/defaults/dark-cyberpunk.json +0 -102
  94. package/src/modes/theme/defaults/dark-dracula.json +0 -98
  95. package/src/modes/theme/defaults/dark-eclipse.json +0 -91
  96. package/src/modes/theme/defaults/dark-ember.json +0 -95
  97. package/src/modes/theme/defaults/dark-equinox.json +0 -90
  98. package/src/modes/theme/defaults/dark-forest.json +0 -96
  99. package/src/modes/theme/defaults/dark-github.json +0 -105
  100. package/src/modes/theme/defaults/dark-gruvbox.json +0 -112
  101. package/src/modes/theme/defaults/dark-lavender.json +0 -95
  102. package/src/modes/theme/defaults/dark-lunar.json +0 -89
  103. package/src/modes/theme/defaults/dark-midnight.json +0 -95
  104. package/src/modes/theme/defaults/dark-monochrome.json +0 -94
  105. package/src/modes/theme/defaults/dark-monokai.json +0 -98
  106. package/src/modes/theme/defaults/dark-nebula.json +0 -90
  107. package/src/modes/theme/defaults/dark-nord.json +0 -97
  108. package/src/modes/theme/defaults/dark-ocean.json +0 -101
  109. package/src/modes/theme/defaults/dark-one.json +0 -100
  110. package/src/modes/theme/defaults/dark-poimandres.json +0 -141
  111. package/src/modes/theme/defaults/dark-rainforest.json +0 -91
  112. package/src/modes/theme/defaults/dark-reef.json +0 -91
  113. package/src/modes/theme/defaults/dark-retro.json +0 -92
  114. package/src/modes/theme/defaults/dark-rose-pine.json +0 -96
  115. package/src/modes/theme/defaults/dark-sakura.json +0 -95
  116. package/src/modes/theme/defaults/dark-slate.json +0 -95
  117. package/src/modes/theme/defaults/dark-solarized.json +0 -97
  118. package/src/modes/theme/defaults/dark-solstice.json +0 -90
  119. package/src/modes/theme/defaults/dark-starfall.json +0 -91
  120. package/src/modes/theme/defaults/dark-sunset.json +0 -99
  121. package/src/modes/theme/defaults/dark-swamp.json +0 -90
  122. package/src/modes/theme/defaults/dark-synthwave.json +0 -103
  123. package/src/modes/theme/defaults/dark-taiga.json +0 -91
  124. package/src/modes/theme/defaults/dark-terminal.json +0 -95
  125. package/src/modes/theme/defaults/dark-tokyo-night.json +0 -101
  126. package/src/modes/theme/defaults/dark-tundra.json +0 -91
  127. package/src/modes/theme/defaults/dark-twilight.json +0 -91
  128. package/src/modes/theme/defaults/dark-volcanic.json +0 -91
  129. package/src/modes/theme/defaults/graphite.json +0 -92
  130. package/src/modes/theme/defaults/light-arctic.json +0 -107
  131. package/src/modes/theme/defaults/light-aurora-day.json +0 -91
  132. package/src/modes/theme/defaults/light-canyon.json +0 -91
  133. package/src/modes/theme/defaults/light-catppuccin.json +0 -106
  134. package/src/modes/theme/defaults/light-cirrus.json +0 -90
  135. package/src/modes/theme/defaults/light-coral.json +0 -95
  136. package/src/modes/theme/defaults/light-cyberpunk.json +0 -96
  137. package/src/modes/theme/defaults/light-dawn.json +0 -90
  138. package/src/modes/theme/defaults/light-dunes.json +0 -91
  139. package/src/modes/theme/defaults/light-eucalyptus.json +0 -95
  140. package/src/modes/theme/defaults/light-forest.json +0 -100
  141. package/src/modes/theme/defaults/light-frost.json +0 -95
  142. package/src/modes/theme/defaults/light-github.json +0 -115
  143. package/src/modes/theme/defaults/light-glacier.json +0 -91
  144. package/src/modes/theme/defaults/light-gruvbox.json +0 -108
  145. package/src/modes/theme/defaults/light-haze.json +0 -90
  146. package/src/modes/theme/defaults/light-honeycomb.json +0 -95
  147. package/src/modes/theme/defaults/light-lagoon.json +0 -91
  148. package/src/modes/theme/defaults/light-lavender.json +0 -95
  149. package/src/modes/theme/defaults/light-meadow.json +0 -91
  150. package/src/modes/theme/defaults/light-mint.json +0 -95
  151. package/src/modes/theme/defaults/light-monochrome.json +0 -101
  152. package/src/modes/theme/defaults/light-ocean.json +0 -99
  153. package/src/modes/theme/defaults/light-one.json +0 -99
  154. package/src/modes/theme/defaults/light-opal.json +0 -91
  155. package/src/modes/theme/defaults/light-orchard.json +0 -91
  156. package/src/modes/theme/defaults/light-paper.json +0 -95
  157. package/src/modes/theme/defaults/light-poimandres.json +0 -141
  158. package/src/modes/theme/defaults/light-prism.json +0 -90
  159. package/src/modes/theme/defaults/light-retro.json +0 -98
  160. package/src/modes/theme/defaults/light-sand.json +0 -95
  161. package/src/modes/theme/defaults/light-savanna.json +0 -91
  162. package/src/modes/theme/defaults/light-solarized.json +0 -102
  163. package/src/modes/theme/defaults/light-soleil.json +0 -90
  164. package/src/modes/theme/defaults/light-sunset.json +0 -99
  165. package/src/modes/theme/defaults/light-synthwave.json +0 -98
  166. package/src/modes/theme/defaults/light-tokyo-night.json +0 -111
  167. package/src/modes/theme/defaults/light-wetland.json +0 -91
  168. package/src/modes/theme/defaults/light-zenith.json +0 -89
  169. package/src/modes/theme/defaults/limestone.json +0 -94
  170. package/src/modes/theme/defaults/mahogany.json +0 -97
  171. package/src/modes/theme/defaults/marble.json +0 -93
  172. package/src/modes/theme/defaults/obsidian.json +0 -91
  173. package/src/modes/theme/defaults/onyx.json +0 -91
  174. package/src/modes/theme/defaults/pearl.json +0 -93
  175. package/src/modes/theme/defaults/porcelain.json +0 -91
  176. package/src/modes/theme/defaults/quartz.json +0 -96
  177. package/src/modes/theme/defaults/sandstone.json +0 -95
  178. package/src/modes/theme/defaults/titanium.json +0 -90
  179. package/src/modes/theme/light.json +0 -93
@@ -0,0 +1,665 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@gajae-code/agent-core";
3
+ import { logger, prompt } from "@gajae-code/utils";
4
+ import * as z from "zod/v4";
5
+ import { AsyncJobManager, isBackgroundJobSupportEnabled } from "../async";
6
+ import cronDescription from "../prompts/tools/cron.md" with { type: "text" };
7
+ import type { ToolSession } from "./index";
8
+ import { ToolError } from "./tool-errors";
9
+
10
+ /** Maximum scheduled tasks per owner. Mirrors upstream Claude Code's 50-task cap. */
11
+ export const MAX_CRON_TASKS_PER_OWNER = 50;
12
+
13
+ /** Recurring tasks auto-expire 7 days after creation (mirrors upstream). */
14
+ export const CRON_RECURRING_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
15
+
16
+ const CRON_ID_LENGTH = 8;
17
+ const MAX_CRON_SCAN_MINUTES = 366 * 24 * 60;
18
+ const MAX_RECURRING_JITTER_MS = 30 * 60 * 1000;
19
+ const MAX_ONE_SHOT_EARLY_JITTER_MS = 90 * 1000;
20
+ const MAX_TIMEOUT_MS = 2_147_483_647;
21
+
22
+ const cronCreateSchema = z.object({
23
+ cron_expression: z
24
+ .string()
25
+ .describe(
26
+ "Standard 5-field cron expression in the user's local timezone: 'minute hour day-of-month month day-of-week'. Examples: '*/5 * * * *' (every 5 min), '0 9 * * *' (9am daily), '0 9 * * 1-5' (weekdays at 9am). Day-of-week uses 0/7 for Sunday through 6 for Saturday. When both day-of-month and day-of-week are constrained, a date matches if either field matches (vixie-cron semantics).",
27
+ ),
28
+ prompt: z
29
+ .string()
30
+ .describe(
31
+ "Prompt to inject between turns when the cron fires. May reference slash commands (e.g. '/review-pr 1234') or natural-language instructions.",
32
+ ),
33
+ recurring: z
34
+ .boolean()
35
+ .default(true)
36
+ .describe(
37
+ "true to fire on every match of the cron expression (recurring, auto-expires after 7 days); false to fire once at the next match and then self-delete.",
38
+ ),
39
+ });
40
+
41
+ export type CronCreateParams = z.infer<typeof cronCreateSchema>;
42
+
43
+ const cronListSchema = z.object({});
44
+ export type CronListParams = z.infer<typeof cronListSchema>;
45
+
46
+ const cronDeleteSchema = z.object({
47
+ id: z.string().min(1).describe("The 8-character job ID returned by CronCreate."),
48
+ });
49
+ export type CronDeleteParams = z.infer<typeof cronDeleteSchema>;
50
+
51
+ export interface CronJobSnapshot {
52
+ id: string;
53
+ cron_expression: string;
54
+ prompt: string;
55
+ recurring: boolean;
56
+ createdAt: number;
57
+ expiresAt?: number;
58
+ nextFireAt?: number;
59
+ humanSchedule: string;
60
+ ownerId?: string;
61
+ }
62
+
63
+ export interface CronListJobDetails {
64
+ id: string;
65
+ cron: string;
66
+ recurring: boolean;
67
+ prompt: string;
68
+ humanSchedule: string;
69
+ }
70
+
71
+ export interface CronCreateToolDetails {
72
+ id: string;
73
+ cron_expression: string;
74
+ recurring: boolean;
75
+ nextFireAt?: number;
76
+ }
77
+
78
+ export interface CronListToolDetails {
79
+ jobs: CronListJobDetails[];
80
+ }
81
+
82
+ export interface CronDeleteToolDetails {
83
+ id: string;
84
+ deleted: boolean;
85
+ }
86
+
87
+ interface CronTimerHandle {
88
+ clear(): void;
89
+ }
90
+
91
+ interface CronScheduleRecord {
92
+ snapshot: CronJobSnapshot;
93
+ session: ToolSession;
94
+ timer?: CronTimerHandle;
95
+ expiryTimer?: CronTimerHandle;
96
+ disposed: boolean;
97
+ }
98
+
99
+ interface OwnerScheduleState {
100
+ jobs: Map<string, CronScheduleRecord>;
101
+ cleanupRegistered: boolean;
102
+ }
103
+
104
+ const schedulesByOwner = new Map<string, OwnerScheduleState>();
105
+
106
+ function ownerKey(ownerId: string | undefined): string {
107
+ return ownerId ?? "__legacy__";
108
+ }
109
+
110
+ function getOrCreateOwnerState(ownerId: string | undefined): OwnerScheduleState {
111
+ const key = ownerKey(ownerId);
112
+ let state = schedulesByOwner.get(key);
113
+ if (!state) {
114
+ state = { jobs: new Map(), cleanupRegistered: false };
115
+ schedulesByOwner.set(key, state);
116
+ }
117
+ return state;
118
+ }
119
+
120
+ function clearTimer(handle: CronTimerHandle | undefined): void {
121
+ handle?.clear();
122
+ }
123
+
124
+ function disposeRecord(record: CronScheduleRecord): void {
125
+ record.disposed = true;
126
+ clearTimer(record.timer);
127
+ clearTimer(record.expiryTimer);
128
+ record.timer = undefined;
129
+ record.expiryTimer = undefined;
130
+ }
131
+
132
+ function deleteRecord(ownerId: string | undefined, id: string): boolean {
133
+ const key = ownerKey(ownerId);
134
+ const state = schedulesByOwner.get(key);
135
+ const record = state?.jobs.get(id);
136
+ if (!state || !record) return false;
137
+ disposeRecord(record);
138
+ const deleted = state.jobs.delete(id);
139
+ if (state.jobs.size === 0) schedulesByOwner.delete(key);
140
+ return deleted;
141
+ }
142
+
143
+ function ensureOwnerCleanup(ownerId: string | undefined, manager: AsyncJobManager, state: OwnerScheduleState): void {
144
+ if (state.cleanupRegistered) return;
145
+ if (!ownerId) return;
146
+ manager.registerOwnerCleanup(ownerId, () => {
147
+ clearOwnerSchedules(ownerId);
148
+ });
149
+ state.cleanupRegistered = true;
150
+ }
151
+
152
+ /** Clear every schedule for an owner. Exported for tests + lifecycle teardown. */
153
+ export function clearOwnerSchedules(ownerId: string | undefined): void {
154
+ const key = ownerKey(ownerId);
155
+ const state = schedulesByOwner.get(key);
156
+ if (!state) return;
157
+ for (const record of state.jobs.values()) disposeRecord(record);
158
+ state.jobs.clear();
159
+ state.cleanupRegistered = false;
160
+ schedulesByOwner.delete(key);
161
+ }
162
+
163
+ /** Reset every owner's schedule store. Test-only. */
164
+ export function resetCronRegistryForTests(): void {
165
+ for (const key of Array.from(schedulesByOwner.keys())) {
166
+ const ownerId = key === "__legacy__" ? undefined : key;
167
+ clearOwnerSchedules(ownerId);
168
+ }
169
+ schedulesByOwner.clear();
170
+ }
171
+
172
+ const CRON_FIELD_BOUNDS: Array<{ name: string; min: number; max: number }> = [
173
+ { name: "minute", min: 0, max: 59 },
174
+ { name: "hour", min: 0, max: 23 },
175
+ { name: "day-of-month", min: 1, max: 31 },
176
+ { name: "month", min: 1, max: 12 },
177
+ { name: "day-of-week", min: 0, max: 7 },
178
+ ];
179
+
180
+ function validateCronField(spec: string, bounds: { name: string; min: number; max: number }): void {
181
+ if (spec === "*") return;
182
+ const parts = spec.split(",");
183
+ for (const part of parts) {
184
+ const stepSplit = part.split("/");
185
+ if (stepSplit.length > 2) {
186
+ throw new ToolError(`Invalid cron expression: bad step in ${bounds.name} field '${part}'.`);
187
+ }
188
+ const [rangePart, stepRaw] = stepSplit;
189
+ if (!rangePart) {
190
+ throw new ToolError(`Invalid cron expression: empty ${bounds.name} field segment.`);
191
+ }
192
+ if (stepRaw !== undefined) {
193
+ const step = Number(stepRaw);
194
+ if (!Number.isInteger(step) || step <= 0) {
195
+ throw new ToolError(
196
+ `Invalid cron expression: step value must be a positive integer in ${bounds.name} field.`,
197
+ );
198
+ }
199
+ }
200
+ if (rangePart === "*") continue;
201
+ const rangeSplit = rangePart.split("-");
202
+ if (rangeSplit.length > 2) {
203
+ throw new ToolError(`Invalid cron expression: bad range in ${bounds.name} field '${rangePart}'.`);
204
+ }
205
+ for (const raw of rangeSplit) {
206
+ const value = Number(raw);
207
+ if (!Number.isInteger(value) || value < bounds.min || value > bounds.max) {
208
+ throw new ToolError(
209
+ `Invalid cron expression: value '${raw}' out of range for ${bounds.name} (${bounds.min}-${bounds.max}).`,
210
+ );
211
+ }
212
+ }
213
+ if (rangeSplit.length === 2) {
214
+ const [lo, hi] = rangeSplit.map(Number);
215
+ if (lo > hi) {
216
+ throw new ToolError(
217
+ `Invalid cron expression: range must be ascending in ${bounds.name} field '${rangePart}'.`,
218
+ );
219
+ }
220
+ }
221
+ }
222
+ }
223
+
224
+ export function validateCronExpression(expression: string): void {
225
+ const trimmed = expression.trim();
226
+ if (!trimmed) {
227
+ throw new ToolError("Invalid cron expression: expression must not be empty.");
228
+ }
229
+ const fields = trimmed.split(/\s+/);
230
+ if (fields.length !== 5) {
231
+ throw new ToolError(
232
+ "Invalid cron expression: expected 5 space-separated fields (minute hour day month weekday).",
233
+ );
234
+ }
235
+ for (let i = 0; i < CRON_FIELD_BOUNDS.length; i += 1) {
236
+ validateCronField(fields[i]!, CRON_FIELD_BOUNDS[i]!);
237
+ }
238
+ }
239
+
240
+ interface ParsedCronExpression {
241
+ minute: Set<number>;
242
+ hour: Set<number>;
243
+ dayOfMonth: Set<number>;
244
+ month: Set<number>;
245
+ dayOfWeek: Set<number>;
246
+ dayOfMonthRestricted: boolean;
247
+ dayOfWeekRestricted: boolean;
248
+ }
249
+
250
+ function expandCronField(
251
+ spec: string,
252
+ bounds: { min: number; max: number },
253
+ normalize?: (value: number) => number,
254
+ ): Set<number> {
255
+ const values = new Set<number>();
256
+ for (const part of spec.split(",")) {
257
+ const [rangePartRaw, stepRaw] = part.split("/");
258
+ const rangePart = rangePartRaw!;
259
+ const step = stepRaw === undefined ? 1 : Number(stepRaw);
260
+ const [loRaw, hiRaw] = rangePart === "*" ? [String(bounds.min), String(bounds.max)] : rangePart.split("-");
261
+ const lo = Number(loRaw);
262
+ const hi = hiRaw === undefined ? lo : Number(hiRaw);
263
+ for (let value = lo; value <= hi; value += step) {
264
+ values.add(normalize ? normalize(value) : value);
265
+ }
266
+ }
267
+ return values;
268
+ }
269
+
270
+ function parseCronExpression(expression: string): ParsedCronExpression {
271
+ validateCronExpression(expression);
272
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = expression.trim().split(/\s+/);
273
+ return {
274
+ minute: expandCronField(minute!, CRON_FIELD_BOUNDS[0]!),
275
+ hour: expandCronField(hour!, CRON_FIELD_BOUNDS[1]!),
276
+ dayOfMonth: expandCronField(dayOfMonth!, CRON_FIELD_BOUNDS[2]!),
277
+ month: expandCronField(month!, CRON_FIELD_BOUNDS[3]!),
278
+ dayOfWeek: expandCronField(dayOfWeek!, CRON_FIELD_BOUNDS[4]!, value => (value === 7 ? 0 : value)),
279
+ dayOfMonthRestricted: dayOfMonth !== "*",
280
+ dayOfWeekRestricted: dayOfWeek !== "*",
281
+ };
282
+ }
283
+
284
+ function matchesCronDate(parsed: ParsedCronExpression, date: Date): boolean {
285
+ if (!parsed.minute.has(date.getMinutes())) return false;
286
+ if (!parsed.hour.has(date.getHours())) return false;
287
+ if (!parsed.month.has(date.getMonth() + 1)) return false;
288
+ const domMatches = parsed.dayOfMonth.has(date.getDate());
289
+ const dowMatches = parsed.dayOfWeek.has(date.getDay());
290
+ if (parsed.dayOfMonthRestricted && parsed.dayOfWeekRestricted) return domMatches || dowMatches;
291
+ return domMatches && dowMatches;
292
+ }
293
+
294
+ export function findNextCronMatchMs(expression: string, afterMs: number, deadlineMs?: number): number | undefined {
295
+ const parsed = parseCronExpression(expression);
296
+ const cursor = new Date(afterMs);
297
+ cursor.setSeconds(0, 0);
298
+ cursor.setMinutes(cursor.getMinutes() + 1);
299
+ for (let i = 0; i < MAX_CRON_SCAN_MINUTES; i += 1) {
300
+ const candidateMs = cursor.getTime();
301
+ if (deadlineMs !== undefined && candidateMs > deadlineMs) return undefined;
302
+ if (matchesCronDate(parsed, cursor)) return candidateMs;
303
+ cursor.setMinutes(cursor.getMinutes() + 1);
304
+ }
305
+ return undefined;
306
+ }
307
+
308
+ function hashStringToUint32(value: string): number {
309
+ let hash = 2166136261;
310
+ for (let i = 0; i < value.length; i += 1) {
311
+ hash ^= value.charCodeAt(i);
312
+ hash = Math.imul(hash, 16777619);
313
+ }
314
+ return hash >>> 0;
315
+ }
316
+
317
+ function deterministicJitterMs(seed: string, maxMs: number): number {
318
+ if (maxMs <= 0) return 0;
319
+ return hashStringToUint32(seed) % (Math.floor(maxMs) + 1);
320
+ }
321
+
322
+ export function calculateCronFireTimeMs(params: {
323
+ id: string;
324
+ cronExpression: string;
325
+ baseMatchMs: number;
326
+ recurring: boolean;
327
+ nowMs: number;
328
+ expiresAt?: number;
329
+ }): number {
330
+ if (params.recurring) {
331
+ const followingMatchMs = findNextCronMatchMs(params.cronExpression, params.baseMatchMs, params.expiresAt);
332
+ const intervalMs = followingMatchMs
333
+ ? Math.max(60_000, followingMatchMs - params.baseMatchMs)
334
+ : MAX_RECURRING_JITTER_MS * 2;
335
+ const maxJitterMs = Math.min(MAX_RECURRING_JITTER_MS, Math.floor(intervalMs / 2));
336
+ return params.baseMatchMs + deterministicJitterMs(`${params.id}:${params.baseMatchMs}:late`, maxJitterMs);
337
+ }
338
+ const baseDate = new Date(params.baseMatchMs);
339
+ if (baseDate.getMinutes() === 0 || baseDate.getMinutes() === 30) {
340
+ const jitterMs = deterministicJitterMs(`${params.id}:${params.baseMatchMs}:early`, MAX_ONE_SHOT_EARLY_JITTER_MS);
341
+ return Math.max(params.nowMs, params.baseMatchMs - jitterMs);
342
+ }
343
+ return params.baseMatchMs;
344
+ }
345
+
346
+ function setCronTimeout(callback: () => void, delayMs: number): CronTimerHandle {
347
+ let handle: ReturnType<typeof setTimeout> | undefined;
348
+ let cleared = false;
349
+ const schedule = (remainingMs: number) => {
350
+ if (cleared) return;
351
+ const boundedDelayMs = Math.max(0, Math.min(MAX_TIMEOUT_MS, Math.floor(remainingMs)));
352
+ handle = setTimeout(() => {
353
+ if (cleared) return;
354
+ const nextRemainingMs = remainingMs - boundedDelayMs;
355
+ if (nextRemainingMs > 0) {
356
+ schedule(nextRemainingMs);
357
+ return;
358
+ }
359
+ callback();
360
+ }, boundedDelayMs);
361
+ };
362
+ schedule(Math.max(0, delayMs));
363
+ return {
364
+ clear() {
365
+ cleared = true;
366
+ if (handle) clearTimeout(handle);
367
+ },
368
+ };
369
+ }
370
+
371
+ function humanizeCronExpression(expression: string): string {
372
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = expression.trim().split(/\s+/);
373
+ if (minute === "*" && hour === "*" && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") {
374
+ return "every minute";
375
+ }
376
+ const stepMatch = minute?.match(/^\*\/(\d+)$/);
377
+ if (stepMatch && hour === "*" && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") {
378
+ const step = Number(stepMatch[1]);
379
+ return `every ${step} minute${step === 1 ? "" : "s"}`;
380
+ }
381
+ if (/^\d+$/u.test(minute ?? "") && /^\d+$/u.test(hour ?? "") && dayOfMonth === "*" && month === "*") {
382
+ const hh = hour!.padStart(2, "0");
383
+ const mm = minute!.padStart(2, "0");
384
+ if (dayOfWeek === "*") return `at ${hh}:${mm} every day`;
385
+ return `at ${hh}:${mm} on day-of-week ${dayOfWeek}`;
386
+ }
387
+ return expression;
388
+ }
389
+
390
+ function toCronListJobDetails(snapshot: CronJobSnapshot): CronListJobDetails {
391
+ return {
392
+ id: snapshot.id,
393
+ cron: snapshot.cron_expression,
394
+ recurring: snapshot.recurring,
395
+ prompt: snapshot.prompt,
396
+ humanSchedule: snapshot.humanSchedule,
397
+ };
398
+ }
399
+
400
+ function formatCronFireContent(snapshot: CronJobSnapshot): string {
401
+ return `<task-notification>\nScheduled task ${snapshot.id} fired (${snapshot.humanSchedule}).\n\n${snapshot.prompt}\n</task-notification>`;
402
+ }
403
+
404
+ function deliverCronFire(record: CronScheduleRecord): void {
405
+ const content = formatCronFireContent(record.snapshot);
406
+ const details = {
407
+ id: record.snapshot.id,
408
+ cron_expression: record.snapshot.cron_expression,
409
+ recurring: record.snapshot.recurring,
410
+ };
411
+ const sendPromise = record.session.sendCustomMessage?.(
412
+ { customType: "cron-fire", content, display: false, attribution: "agent", details },
413
+ { triggerTurn: true, deliverAs: "followUp" },
414
+ );
415
+ if (sendPromise) {
416
+ void sendPromise.catch(error => {
417
+ logger.warn("Cron fire delivery failed", {
418
+ id: record.snapshot.id,
419
+ error: error instanceof Error ? error.message : String(error),
420
+ });
421
+ });
422
+ return;
423
+ }
424
+ record.session.steer?.({ customType: "cron-fire", content, details });
425
+ }
426
+
427
+ function scheduleRecord(ownerId: string | undefined, state: OwnerScheduleState, record: CronScheduleRecord): void {
428
+ if (record.disposed) return;
429
+ clearTimer(record.timer);
430
+ record.timer = undefined;
431
+ const now = Date.now();
432
+ if (record.snapshot.expiresAt !== undefined && now >= record.snapshot.expiresAt) {
433
+ deleteRecord(ownerId, record.snapshot.id);
434
+ return;
435
+ }
436
+ const baseMatchMs = findNextCronMatchMs(record.snapshot.cron_expression, now, record.snapshot.expiresAt);
437
+ if (baseMatchMs === undefined) {
438
+ deleteRecord(ownerId, record.snapshot.id);
439
+ return;
440
+ }
441
+ const fireAt = calculateCronFireTimeMs({
442
+ id: record.snapshot.id,
443
+ cronExpression: record.snapshot.cron_expression,
444
+ baseMatchMs,
445
+ recurring: record.snapshot.recurring,
446
+ nowMs: now,
447
+ expiresAt: record.snapshot.expiresAt,
448
+ });
449
+ record.snapshot.nextFireAt = fireAt;
450
+ record.timer = setCronTimeout(() => fireRecord(ownerId, state, record.snapshot.id), fireAt - now);
451
+ }
452
+
453
+ function scheduleExpiry(ownerId: string | undefined, record: CronScheduleRecord): void {
454
+ if (record.snapshot.expiresAt === undefined) return;
455
+ const delayMs = record.snapshot.expiresAt - Date.now();
456
+ record.expiryTimer = setCronTimeout(() => {
457
+ deleteRecord(ownerId, record.snapshot.id);
458
+ }, delayMs);
459
+ }
460
+
461
+ function fireRecord(ownerId: string | undefined, state: OwnerScheduleState, id: string): void {
462
+ const record = state.jobs.get(id);
463
+ if (!record || record.disposed) return;
464
+ if (record.snapshot.expiresAt !== undefined && Date.now() >= record.snapshot.expiresAt) {
465
+ deleteRecord(ownerId, id);
466
+ return;
467
+ }
468
+ try {
469
+ deliverCronFire(record);
470
+ } catch (error) {
471
+ logger.warn("Cron fire delivery failed", { id, error: error instanceof Error ? error.message : String(error) });
472
+ }
473
+ if (!record.snapshot.recurring) {
474
+ deleteRecord(ownerId, id);
475
+ return;
476
+ }
477
+ scheduleRecord(ownerId, state, record);
478
+ }
479
+
480
+ const CRON_ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
481
+
482
+ function generateCronId(taken: Set<string>): string {
483
+ for (let attempt = 0; attempt < 16; attempt += 1) {
484
+ const bytes = randomBytes(CRON_ID_LENGTH);
485
+ let id = "";
486
+ for (let i = 0; i < CRON_ID_LENGTH; i += 1) {
487
+ id += CRON_ID_ALPHABET[bytes[i]! % CRON_ID_ALPHABET.length];
488
+ }
489
+ if (!taken.has(id)) return id;
490
+ }
491
+ return `${randomBytes(6).toString("hex")}-${Date.now().toString(36)}`;
492
+ }
493
+
494
+ function isCronDisabled(): boolean {
495
+ return process.env.CLAUDE_CODE_DISABLE_CRON === "1";
496
+ }
497
+
498
+ export class CronCreateTool implements AgentTool<typeof cronCreateSchema, CronCreateToolDetails> {
499
+ readonly name = "CronCreate";
500
+ readonly label = "CronCreate";
501
+ readonly summary = "Schedule a prompt on a 5-field cron expression";
502
+ readonly description: string;
503
+ readonly parameters = cronCreateSchema;
504
+ readonly strict = true;
505
+ readonly loadMode = "discoverable";
506
+
507
+ constructor(private readonly session: ToolSession) {
508
+ this.description = prompt.render(cronDescription);
509
+ }
510
+
511
+ static createIf(session: ToolSession): CronCreateTool | null {
512
+ if (!isBackgroundJobSupportEnabled(session.settings)) return null;
513
+ if (isCronDisabled()) return null;
514
+ return new CronCreateTool(session);
515
+ }
516
+
517
+ async execute(
518
+ _toolCallId: string,
519
+ params: CronCreateParams,
520
+ _signal?: AbortSignal,
521
+ _onUpdate?: AgentToolUpdateCallback<CronCreateToolDetails>,
522
+ _context?: AgentToolContext,
523
+ ): Promise<AgentToolResult<CronCreateToolDetails>> {
524
+ const manager = AsyncJobManager.instance();
525
+ if (!manager) {
526
+ throw new ToolError("Async execution is disabled; cron is unavailable in this session.");
527
+ }
528
+ if (isCronDisabled()) {
529
+ throw new ToolError("Cron is disabled by CLAUDE_CODE_DISABLE_CRON=1.");
530
+ }
531
+ validateCronExpression(params.cron_expression);
532
+
533
+ const ownerId = this.session.getAgentId?.() ?? undefined;
534
+ const state = getOrCreateOwnerState(ownerId);
535
+ if (state.jobs.size >= MAX_CRON_TASKS_PER_OWNER) {
536
+ throw new ToolError(
537
+ `Cron task limit reached (${MAX_CRON_TASKS_PER_OWNER}). Cancel an existing task with CronDelete first.`,
538
+ );
539
+ }
540
+ ensureOwnerCleanup(ownerId, manager, state);
541
+
542
+ const id = generateCronId(new Set(state.jobs.keys()));
543
+ const now = Date.now();
544
+ const snapshot: CronJobSnapshot = {
545
+ id,
546
+ cron_expression: params.cron_expression.trim(),
547
+ prompt: params.prompt,
548
+ recurring: params.recurring,
549
+ createdAt: now,
550
+ expiresAt: params.recurring ? now + CRON_RECURRING_MAX_AGE_MS : undefined,
551
+ humanSchedule: humanizeCronExpression(params.cron_expression.trim()),
552
+ ownerId,
553
+ };
554
+ const record: CronScheduleRecord = { snapshot, session: this.session, disposed: false };
555
+ state.jobs.set(id, record);
556
+ scheduleExpiry(ownerId, record);
557
+ scheduleRecord(ownerId, state, record);
558
+
559
+ logger.debug("CronCreate: scheduled task", {
560
+ id,
561
+ ownerId,
562
+ cron: snapshot.cron_expression,
563
+ nextFireAt: snapshot.nextFireAt,
564
+ });
565
+
566
+ return {
567
+ content: [{ type: "text", text: `Scheduled ${id} (${snapshot.humanSchedule})` }],
568
+ details: {
569
+ id,
570
+ cron_expression: snapshot.cron_expression,
571
+ recurring: snapshot.recurring,
572
+ nextFireAt: snapshot.nextFireAt,
573
+ },
574
+ };
575
+ }
576
+ }
577
+
578
+ export class CronListTool implements AgentTool<typeof cronListSchema, CronListToolDetails> {
579
+ readonly name = "CronList";
580
+ readonly label = "CronList";
581
+ readonly summary = "List scheduled cron jobs";
582
+ readonly description: string;
583
+ readonly parameters = cronListSchema;
584
+ readonly strict = true;
585
+ readonly loadMode = "discoverable";
586
+
587
+ constructor(private readonly session: ToolSession) {
588
+ this.description = prompt.render(cronDescription);
589
+ }
590
+
591
+ static createIf(session: ToolSession): CronListTool | null {
592
+ if (!isBackgroundJobSupportEnabled(session.settings)) return null;
593
+ if (isCronDisabled()) return null;
594
+ return new CronListTool(session);
595
+ }
596
+
597
+ async execute(
598
+ _toolCallId: string,
599
+ _params: CronListParams,
600
+ _signal?: AbortSignal,
601
+ _onUpdate?: AgentToolUpdateCallback<CronListToolDetails>,
602
+ _context?: AgentToolContext,
603
+ ): Promise<AgentToolResult<CronListToolDetails>> {
604
+ const ownerId = this.session.getAgentId?.() ?? undefined;
605
+ const state = schedulesByOwner.get(ownerKey(ownerId));
606
+ const records = state
607
+ ? Array.from(state.jobs.values()).sort((a, b) => a.snapshot.createdAt - b.snapshot.createdAt)
608
+ : [];
609
+ const jobs = records.map(record => toCronListJobDetails(record.snapshot));
610
+ if (jobs.length === 0) {
611
+ return {
612
+ content: [{ type: "text", text: "No scheduled jobs" }],
613
+ details: { jobs: [] },
614
+ };
615
+ }
616
+ const lines = jobs.map(job => {
617
+ const preview = job.prompt.length > 80 ? `${job.prompt.slice(0, 77)}...` : job.prompt;
618
+ return `${job.id} (${job.humanSchedule}): ${preview}`;
619
+ });
620
+ return {
621
+ content: [{ type: "text", text: lines.join("\n") }],
622
+ details: { jobs },
623
+ };
624
+ }
625
+ }
626
+
627
+ export class CronDeleteTool implements AgentTool<typeof cronDeleteSchema, CronDeleteToolDetails> {
628
+ readonly name = "CronDelete";
629
+ readonly label = "CronDelete";
630
+ readonly summary = "Cancel a scheduled cron job by ID";
631
+ readonly description: string;
632
+ readonly parameters = cronDeleteSchema;
633
+ readonly strict = true;
634
+ readonly loadMode = "discoverable";
635
+
636
+ constructor(private readonly session: ToolSession) {
637
+ this.description = prompt.render(cronDescription);
638
+ }
639
+
640
+ static createIf(session: ToolSession): CronDeleteTool | null {
641
+ if (!isBackgroundJobSupportEnabled(session.settings)) return null;
642
+ if (isCronDisabled()) return null;
643
+ return new CronDeleteTool(session);
644
+ }
645
+
646
+ async execute(
647
+ _toolCallId: string,
648
+ params: CronDeleteParams,
649
+ _signal?: AbortSignal,
650
+ _onUpdate?: AgentToolUpdateCallback<CronDeleteToolDetails>,
651
+ _context?: AgentToolContext,
652
+ ): Promise<AgentToolResult<CronDeleteToolDetails>> {
653
+ const ownerId = this.session.getAgentId?.() ?? undefined;
654
+ const deleted = deleteRecord(ownerId, params.id);
655
+ return {
656
+ content: [
657
+ {
658
+ type: "text",
659
+ text: deleted ? `Cancelled ${params.id}` : `Failed to remove scheduled task '${params.id}'`,
660
+ },
661
+ ],
662
+ details: { id: params.id, deleted },
663
+ };
664
+ }
665
+ }