aiden-runtime 4.0.2 → 4.1.1

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 (113) hide show
  1. package/README.md +19 -11
  2. package/config/hardware.json +2 -2
  3. package/dist/api/server.js +50 -52
  4. package/dist/cli/v4/aidenCLI.js +424 -7
  5. package/dist/cli/v4/aidenPrompt.js +317 -0
  6. package/dist/cli/v4/box.js +105 -39
  7. package/dist/cli/v4/callbacks.js +39 -6
  8. package/dist/cli/v4/chatSession.js +256 -55
  9. package/dist/cli/v4/citationFooter.js +97 -0
  10. package/dist/cli/v4/commands/channel.js +656 -0
  11. package/dist/cli/v4/commands/clear.js +1 -1
  12. package/dist/cli/v4/commands/compress.js +1 -1
  13. package/dist/cli/v4/commands/cron.js +44 -16
  14. package/dist/cli/v4/commands/fanout.js +236 -0
  15. package/dist/cli/v4/commands/help.js +15 -4
  16. package/dist/cli/v4/commands/history.js +84 -0
  17. package/dist/cli/v4/commands/index.js +16 -1
  18. package/dist/cli/v4/commands/mcp.js +358 -0
  19. package/dist/cli/v4/commands/show.js +43 -0
  20. package/dist/cli/v4/commands/skills.js +169 -4
  21. package/dist/cli/v4/commands/status.js +84 -0
  22. package/dist/cli/v4/commands/subagent.js +78 -0
  23. package/dist/cli/v4/commands/verbose.js +1 -1
  24. package/dist/cli/v4/commands/voice.js +218 -0
  25. package/dist/cli/v4/cronCli.js +103 -0
  26. package/dist/cli/v4/display.js +297 -13
  27. package/dist/cli/v4/doctor.js +102 -1
  28. package/dist/cli/v4/doctorLiveness.js +329 -0
  29. package/dist/cli/v4/envSources.js +105 -0
  30. package/dist/cli/v4/ghostMatch.js +74 -0
  31. package/dist/cli/v4/historyStore.js +163 -0
  32. package/dist/cli/v4/pasteCompression.js +124 -0
  33. package/dist/cli/v4/pasteIntercept.js +203 -0
  34. package/dist/cli/v4/replyRenderer.js +209 -0
  35. package/dist/cli/v4/resizeGuard.js +92 -0
  36. package/dist/cli/v4/shellInterpolation.js +139 -0
  37. package/dist/cli/v4/skinEngine.js +21 -1
  38. package/dist/cli/v4/streamingPrefix.js +121 -0
  39. package/dist/cli/v4/syntaxHighlight.js +345 -0
  40. package/dist/cli/v4/table.js +216 -0
  41. package/dist/cli/v4/themeDetect.js +81 -0
  42. package/dist/cli/v4/uiBuild.js +74 -0
  43. package/dist/cli/v4/voiceCli.js +113 -0
  44. package/dist/cli/v4/voicePromptApi.js +196 -0
  45. package/dist/core/channels/discord.js +16 -10
  46. package/dist/core/channels/email.js +13 -9
  47. package/dist/core/channels/imessage.js +13 -9
  48. package/dist/core/channels/manager.js +25 -7
  49. package/dist/core/channels/pdf-extract.js +180 -0
  50. package/dist/core/channels/photo-vision.js +157 -0
  51. package/dist/core/channels/signal.js +11 -7
  52. package/dist/core/channels/slack.js +13 -10
  53. package/dist/core/channels/telegram-commands.js +154 -0
  54. package/dist/core/channels/telegram-groups.js +198 -0
  55. package/dist/core/channels/telegram-rate-limit.js +124 -0
  56. package/dist/core/channels/telegram.js +1980 -0
  57. package/dist/core/channels/twilio.js +11 -7
  58. package/dist/core/channels/webhook.js +9 -5
  59. package/dist/core/channels/whatsapp.js +15 -11
  60. package/dist/core/channels/whisper-transcribe.js +163 -0
  61. package/dist/core/cronManager.js +33 -294
  62. package/dist/core/gateway.js +29 -8
  63. package/dist/core/playwrightBridge.js +90 -0
  64. package/dist/core/v4/aidenAgent.js +35 -0
  65. package/dist/core/v4/auxiliaryClient.js +2 -2
  66. package/dist/core/v4/cron/atomicWrite.js +18 -4
  67. package/dist/core/v4/cron/cronExecute.js +300 -0
  68. package/dist/core/v4/cron/cronManager.js +502 -0
  69. package/dist/core/v4/cron/cronState.js +314 -0
  70. package/dist/core/v4/cron/cronTick.js +90 -0
  71. package/dist/core/v4/cron/diagnostics.js +104 -0
  72. package/dist/core/v4/cron/graceWindow.js +79 -0
  73. package/dist/core/v4/logger/factory.js +110 -0
  74. package/dist/core/v4/logger/index.js +22 -0
  75. package/dist/core/v4/logger/logger.js +101 -0
  76. package/dist/core/v4/logger/sinks/fileSink.js +110 -0
  77. package/dist/core/v4/logger/sinks/multiSink.js +43 -0
  78. package/dist/core/v4/logger/sinks/nullSink.js +53 -0
  79. package/dist/core/v4/logger/sinks/stdSink.js +81 -0
  80. package/dist/core/v4/mcp/server/diagnostics.js +40 -0
  81. package/dist/core/v4/mcp/server/skillBridge.js +94 -0
  82. package/dist/core/v4/mcp/server/stdioServer.js +119 -0
  83. package/dist/core/v4/mcp/server/toolBridge.js +168 -0
  84. package/dist/core/v4/platformPaths.js +105 -0
  85. package/dist/core/v4/providerFallback.js +25 -0
  86. package/dist/core/v4/skillLoader.js +21 -5
  87. package/dist/core/v4/skillMining/candidateStore.js +164 -0
  88. package/dist/core/v4/skillMining/extractorPrompt.js +118 -0
  89. package/dist/core/v4/skillMining/proposalBuilder.js +140 -0
  90. package/dist/core/v4/skillMining/skillMiner.js +191 -0
  91. package/dist/core/v4/skillMining/traceFingerprint.js +51 -0
  92. package/dist/core/v4/subagent/budget.js +76 -0
  93. package/dist/core/v4/subagent/diagnostics.js +22 -0
  94. package/dist/core/v4/subagent/fanout.js +216 -0
  95. package/dist/core/v4/subagent/merger.js +148 -0
  96. package/dist/core/v4/subagent/providerRotation.js +54 -0
  97. package/dist/core/v4/voice/audioStream.js +373 -0
  98. package/dist/core/v4/voice/cliVoice.js +393 -0
  99. package/dist/core/v4/voice/diagnostics.js +66 -0
  100. package/dist/core/v4/voice/ttsStream.js +193 -0
  101. package/dist/core/version.js +1 -1
  102. package/dist/core/visionAnalyze.js +291 -90
  103. package/dist/core/voice/audio.js +61 -5
  104. package/dist/core/voice/audioBackend.js +134 -0
  105. package/dist/core/voice/stt.js +61 -6
  106. package/dist/core/voice/tts.js +19 -3
  107. package/dist/moat/dangerousPatterns.js +1 -1
  108. package/dist/providers/v4/codexResponsesAdapter.js +7 -2
  109. package/dist/providers/v4/errors.js +51 -1
  110. package/dist/providers/v4/ollamaPromptToolsAdapter.js +9 -2
  111. package/dist/tools/v4/index.js +32 -1
  112. package/dist/tools/v4/subagent/subagentFanout.js +190 -0
  113. package/package.json +11 -2
@@ -0,0 +1,314 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * core/v4/cron/cronState.ts — Phase v4.1-cron
10
+ *
11
+ * Durable state for the cron scheduler. One JSON file at
12
+ * `~/.aiden/cron_jobs.json` with shape:
13
+ *
14
+ * {
15
+ * schemaVersion: 2,
16
+ * updatedAt: "2026-05-09T..",
17
+ * jobs: [<CronJob>, ...]
18
+ * }
19
+ *
20
+ * Three responsibilities:
21
+ *
22
+ * 1. Whole-state file lock via `proper-lockfile`. Lock path:
23
+ * `<state>.lock`. Non-blocking acquire (retries=0). Two
24
+ * processes racing → first wins, second gets `lockHeld`. The
25
+ * heartbeat skips silently when locked; user-driven API calls
26
+ * surface a clear error so the user can retry.
27
+ *
28
+ * 2. Schema migration. v1 = bare array `[CronJob, ...]`,
29
+ * v2 = enveloped. Detected on first read; auto-migrated
30
+ * transparently with one stderr line per process boot.
31
+ *
32
+ * 3. Auto-repair on JSON corruption. Try strict parse → fallback
33
+ * strip-trailing-commas → fallback empty + rename original
34
+ * to `.bak.<ts>`. Mirrors prior multi-agent systems' lesson:
35
+ * a partial write or external editor truncation should NOT
36
+ * leave the user with no scheduled jobs.
37
+ *
38
+ * Stateless module — every call opens, reads, mutates, writes,
39
+ * closes. The in-memory cache lives in `cronManager.ts` and is
40
+ * refreshed by the heartbeat.
41
+ */
42
+ var __importDefault = (this && this.__importDefault) || function (mod) {
43
+ return (mod && mod.__esModule) ? mod : { "default": mod };
44
+ };
45
+ Object.defineProperty(exports, "__esModule", { value: true });
46
+ exports.defaultCronPaths = defaultCronPaths;
47
+ exports.migrateToV2 = migrateToV2;
48
+ exports.readCronState = readCronState;
49
+ exports.writeCronState = writeCronState;
50
+ exports.acquireCronLock = acquireCronLock;
51
+ exports.isCronLockHeld = isCronLockHeld;
52
+ exports.__resetMigrationLogForTests = __resetMigrationLogForTests;
53
+ const node_fs_1 = require("node:fs");
54
+ const node_fs_2 = require("node:fs");
55
+ const node_path_1 = __importDefault(require("node:path"));
56
+ const node_os_1 = __importDefault(require("node:os"));
57
+ const atomicWrite_1 = require("./atomicWrite");
58
+ const diagnostics_1 = require("./diagnostics");
59
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
60
+ const lockfile = require('proper-lockfile');
61
+ function defaultCronPaths(homeOverride) {
62
+ // Honor AIDEN_HOME (used by tests + multi-profile workflows). When
63
+ // set, paths root IS AIDEN_HOME directly (no `.aiden` suffix —
64
+ // mirrors `core/v4/paths.ts::resolveAidenRoot`). Otherwise default
65
+ // to ~/.aiden.
66
+ let root;
67
+ if (homeOverride && homeOverride.length > 0) {
68
+ root = homeOverride;
69
+ }
70
+ else if (process.env.AIDEN_HOME && process.env.AIDEN_HOME.trim().length > 0) {
71
+ root = process.env.AIDEN_HOME.trim();
72
+ }
73
+ else {
74
+ root = node_path_1.default.join(node_os_1.default.homedir(), '.aiden');
75
+ }
76
+ const stateFile = node_path_1.default.join(root, 'cron_jobs.json');
77
+ return {
78
+ stateFile,
79
+ lockFile: `${stateFile}.lock`,
80
+ logsDir: node_path_1.default.join(root, 'cron-logs'),
81
+ };
82
+ }
83
+ // ── Migration ────────────────────────────────────────────────────────────
84
+ let _migrationLogged = false;
85
+ /** Migrate a parsed v1 (bare array) to v2 envelope. Idempotent —
86
+ * v2 envelopes pass through unchanged. Mutates in place + returns
87
+ * the new envelope. Logs ONE stderr line per process boot. */
88
+ function migrateToV2(parsed) {
89
+ // v2 envelope — pass through.
90
+ if (parsed
91
+ && typeof parsed === 'object'
92
+ && !Array.isArray(parsed)
93
+ && 'schemaVersion' in parsed
94
+ && parsed.schemaVersion === diagnostics_1.CRON_SCHEMA_VERSION) {
95
+ return enrichV2(parsed);
96
+ }
97
+ // v1 bare array — wrap.
98
+ if (Array.isArray(parsed)) {
99
+ if (!_migrationLogged) {
100
+ try {
101
+ process.stderr.write('v4.1-cron: migrated cron_jobs.json schema v1 → v2\n');
102
+ }
103
+ catch { /* non-fatal */ }
104
+ _migrationLogged = true;
105
+ }
106
+ return {
107
+ schemaVersion: diagnostics_1.CRON_SCHEMA_VERSION,
108
+ updatedAt: new Date().toISOString(),
109
+ jobs: parsed.map(migrateJobToV2).filter((j) => j !== null),
110
+ };
111
+ }
112
+ // Anything else — empty registry.
113
+ return {
114
+ schemaVersion: diagnostics_1.CRON_SCHEMA_VERSION,
115
+ updatedAt: new Date().toISOString(),
116
+ jobs: [],
117
+ };
118
+ }
119
+ /** Per-job migration. Adds default values for fields that didn't
120
+ * exist in v1. Drops malformed records (returns null). */
121
+ function migrateJobToV2(raw) {
122
+ if (!raw || typeof raw !== 'object')
123
+ return null;
124
+ const o = raw;
125
+ if (typeof o.id !== 'string' || !o.id)
126
+ return null;
127
+ if (typeof o.action !== 'string')
128
+ return null;
129
+ // Detect kind if missing (pre-v4.1-cron legacy).
130
+ let kind = 'interval';
131
+ if (typeof o.kind === 'string'
132
+ && (o.kind === 'interval' || o.kind === 'cron' || o.kind === 'oneshot')) {
133
+ kind = o.kind;
134
+ }
135
+ else if (typeof o.cronExpr === 'string')
136
+ kind = 'cron';
137
+ else if (typeof o.oneshotIso === 'string')
138
+ kind = 'oneshot';
139
+ // Discriminated state — derive from `enabled` when absent.
140
+ const enabled = typeof o.enabled === 'boolean' ? o.enabled : true;
141
+ let state = enabled ? 'scheduled' : 'paused';
142
+ if (typeof o.state === 'string'
143
+ && (o.state === 'scheduled' || o.state === 'paused'
144
+ || o.state === 'completed' || o.state === 'error')) {
145
+ state = o.state;
146
+ }
147
+ return {
148
+ id: String(o.id),
149
+ description: typeof o.description === 'string' ? o.description : '',
150
+ schedule: typeof o.schedule === 'string' ? o.schedule : '',
151
+ kind,
152
+ intervalMs: typeof o.intervalMs === 'number' ? o.intervalMs : undefined,
153
+ cronExpr: typeof o.cronExpr === 'string' ? o.cronExpr : undefined,
154
+ oneshotIso: typeof o.oneshotIso === 'string' ? o.oneshotIso : undefined,
155
+ action: String(o.action),
156
+ enabled,
157
+ state,
158
+ pausedAt: typeof o.pausedAt === 'string' ? o.pausedAt : null,
159
+ pausedReason: typeof o.pausedReason === 'string' ? o.pausedReason : null,
160
+ createdAt: typeof o.createdAt === 'string' ? o.createdAt : new Date().toISOString(),
161
+ lastRun: typeof o.lastRun === 'string' ? o.lastRun : undefined,
162
+ lastResult: typeof o.lastResult === 'string' ? o.lastResult : undefined,
163
+ lastOutput: typeof o.lastOutput === 'string' ? o.lastOutput : undefined,
164
+ lastError: typeof o.lastError === 'string' ? o.lastError : null,
165
+ lastDeliveryError: typeof o.lastDeliveryError === 'string' ? o.lastDeliveryError : null,
166
+ nextRun: typeof o.nextRun === 'string' ? o.nextRun : undefined,
167
+ runCount: typeof o.runCount === 'number' ? o.runCount : 0,
168
+ };
169
+ }
170
+ /** v2-shaped envelope — make sure all jobs have the new fields with
171
+ * defaults. Catches the "user manually edited cron_jobs.json"
172
+ * case. */
173
+ function enrichV2(env) {
174
+ return {
175
+ schemaVersion: diagnostics_1.CRON_SCHEMA_VERSION,
176
+ updatedAt: env.updatedAt ?? new Date().toISOString(),
177
+ jobs: (env.jobs ?? []).map((j) => ({
178
+ ...j,
179
+ state: j.state ?? (j.enabled ? 'scheduled' : 'paused'),
180
+ pausedAt: j.pausedAt ?? null,
181
+ pausedReason: j.pausedReason ?? null,
182
+ lastError: j.lastError ?? null,
183
+ lastDeliveryError: j.lastDeliveryError ?? null,
184
+ })),
185
+ };
186
+ }
187
+ // ── Auto-repair / load ────────────────────────────────────────────────────
188
+ /** Read state from disk. Auto-migrates v1 → v2; auto-repairs on
189
+ * corrupt JSON. Returns an empty envelope when the file doesn't
190
+ * exist. NEVER throws — corrupt-state is ALWAYS recoverable. */
191
+ async function readCronState(stateFile) {
192
+ let raw;
193
+ try {
194
+ raw = await node_fs_1.promises.readFile(stateFile, 'utf-8');
195
+ }
196
+ catch (err) {
197
+ if (err.code === 'ENOENT') {
198
+ return {
199
+ schemaVersion: diagnostics_1.CRON_SCHEMA_VERSION,
200
+ updatedAt: new Date().toISOString(),
201
+ jobs: [],
202
+ };
203
+ }
204
+ // Permission / EBUSY — leave caller to handle, but do NOT lose state.
205
+ throw err;
206
+ }
207
+ // Try strict parse.
208
+ try {
209
+ return migrateToV2(JSON.parse(raw));
210
+ }
211
+ catch { /* fall through to repair */ }
212
+ // Auto-repair: strip trailing commas (most common bare-edit corruption).
213
+ try {
214
+ const stripped = raw
215
+ .replace(/,(\s*[}\]])/g, '$1') // trailing comma in object/array
216
+ .replace(/^\s*\/\/.*$/gm, ''); // line comments (defensive)
217
+ return migrateToV2(JSON.parse(stripped));
218
+ }
219
+ catch { /* fall through to bak-and-empty */ }
220
+ // Last resort: rename the corrupt file aside, return empty.
221
+ const bak = `${stateFile}.bak.${Date.now()}`;
222
+ try {
223
+ await node_fs_1.promises.rename(stateFile, bak);
224
+ }
225
+ catch { /* noop */ }
226
+ try {
227
+ process.stderr.write(`v4.1-cron: cron_jobs.json corrupt — moved to ${node_path_1.default.basename(bak)}, starting empty\n`);
228
+ }
229
+ catch { /* noop */ }
230
+ return {
231
+ schemaVersion: diagnostics_1.CRON_SCHEMA_VERSION,
232
+ updatedAt: new Date().toISOString(),
233
+ jobs: [],
234
+ };
235
+ }
236
+ /** Write state to disk via atomicWrite. Updates `updatedAt` to now. */
237
+ async function writeCronState(stateFile, state) {
238
+ const next = {
239
+ ...state,
240
+ schemaVersion: diagnostics_1.CRON_SCHEMA_VERSION,
241
+ updatedAt: new Date().toISOString(),
242
+ };
243
+ await (0, atomicWrite_1.writeJsonAtomic)(stateFile, next);
244
+ }
245
+ /** Acquire the whole-cron-state lock. Returns null when contended
246
+ * (failFast) or after retry exhausted (non-failFast). NEVER
247
+ * throws — caller checks for null. */
248
+ async function acquireCronLock(paths, opts = {}) {
249
+ // proper-lockfile requires the target to exist. Touch it.
250
+ try {
251
+ await node_fs_1.promises.mkdir(node_path_1.default.dirname(paths.stateFile), { recursive: true });
252
+ if (!(0, node_fs_2.existsSync)(paths.stateFile)) {
253
+ await node_fs_1.promises.writeFile(paths.stateFile, JSON.stringify({
254
+ schemaVersion: diagnostics_1.CRON_SCHEMA_VERSION,
255
+ updatedAt: new Date().toISOString(),
256
+ jobs: [],
257
+ }, null, 2), { flag: 'wx', encoding: 'utf-8' }).catch(() => undefined);
258
+ }
259
+ }
260
+ catch { /* non-fatal */ }
261
+ const lockOpts = {
262
+ realpath: true,
263
+ stale: 20000, // proper-lockfile default 10s; bump to 20s
264
+ retries: opts.failFast ? 0 : 1,
265
+ lockfilePath: paths.lockFile,
266
+ };
267
+ let release = null;
268
+ try {
269
+ release = await lockfile.lock(paths.stateFile, lockOpts);
270
+ }
271
+ catch (err) {
272
+ const msg = err instanceof Error ? err.message : String(err);
273
+ // proper-lockfile throws ELOCKED — that's a "skip" signal, not
274
+ // an error. Other errors (permission, etc.) are silently NULLed
275
+ // — caller checks for null and proceeds in degraded mode.
276
+ if (!/lock(ed)?/i.test(msg)) {
277
+ // Surface unusual failures via stderr but never crash.
278
+ try {
279
+ process.stderr.write(`v4.1-cron: lock acquire failed: ${msg}\n`);
280
+ }
281
+ catch { /* noop */ }
282
+ }
283
+ return null;
284
+ }
285
+ let released = false;
286
+ return {
287
+ async release() {
288
+ if (released)
289
+ return;
290
+ released = true;
291
+ try {
292
+ await release();
293
+ }
294
+ catch { /* best effort; OS releases on process exit anyway */ }
295
+ },
296
+ };
297
+ }
298
+ /** Best-effort check: is the lock currently held? Used by /cron
299
+ * status diagnostics. Never throws. */
300
+ async function isCronLockHeld(paths) {
301
+ try {
302
+ return await lockfile.check(paths.stateFile, {
303
+ realpath: true,
304
+ lockfilePath: paths.lockFile,
305
+ });
306
+ }
307
+ catch {
308
+ return false;
309
+ }
310
+ }
311
+ // ── Test hook ────────────────────────────────────────────────────────────
312
+ function __resetMigrationLogForTests() {
313
+ _migrationLogged = false;
314
+ }
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * core/v4/cron/cronTick.ts — Phase v4.1-cron
10
+ *
11
+ * 60-second heartbeat. The hybrid tick architecture:
12
+ *
13
+ * - Per-job `setTimeout` arms each job for its specific next-
14
+ * fire time (existing legacy behaviour preserved). Sub-second
15
+ * precision, no extra latency.
16
+ *
17
+ * - This heartbeat re-reads `cron_jobs.json` every 60s under
18
+ * lock. If another process (or the user editing the file)
19
+ * added / removed / paused jobs, this picks up the change
20
+ * and re-arms timers accordingly.
21
+ *
22
+ * - When the lock is held by another process, the tick skips
23
+ * silently with a logged "skipped: lock held" line and a
24
+ * diagnostics increment.
25
+ *
26
+ * - Fast-forward / catch-up after sleep: when the heartbeat
27
+ * wakes after a long pause (laptop slept past nextRun for
28
+ * multiple jobs), graceWindow.evaluateRecurring decides
29
+ * whether to fire-now or skip-and-fast-forward per job.
30
+ *
31
+ * The heartbeat is a singleton — calling `startHeartbeat()`
32
+ * twice is a no-op. `stopHeartbeat()` clears the timer; the
33
+ * caller is responsible for calling it on graceful shutdown
34
+ * (CLI signal handler, REPL exit, etc.).
35
+ */
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ exports.startHeartbeat = startHeartbeat;
38
+ exports.stopHeartbeat = stopHeartbeat;
39
+ exports.isHeartbeatActive = isHeartbeatActive;
40
+ const cronState_1 = require("./cronState");
41
+ const diagnostics_1 = require("./diagnostics");
42
+ // Module-level singleton — one heartbeat per process.
43
+ let _heartbeatTimer = null;
44
+ let _heartbeatActive = false;
45
+ /** Start the 60s heartbeat. Idempotent — second call no-ops. */
46
+ function startHeartbeat(opts) {
47
+ if (_heartbeatTimer)
48
+ return;
49
+ const intervalMs = opts.intervalMs ?? (0, diagnostics_1.resolveTickMs)();
50
+ const tick = async () => {
51
+ const lock = await (0, cronState_1.acquireCronLock)(opts.paths, { failFast: true });
52
+ if (!lock) {
53
+ (0, diagnostics_1.noteSkippedTick)();
54
+ return;
55
+ }
56
+ try {
57
+ (0, diagnostics_1.noteHeartbeat)(true);
58
+ const state = await (0, cronState_1.readCronState)(opts.paths.stateFile);
59
+ await opts.onTick(state.jobs);
60
+ }
61
+ catch {
62
+ // Heartbeat must never throw out — caller's onTick is
63
+ // best-effort. Errors land in the in-process logger via
64
+ // the caller's own logging.
65
+ }
66
+ finally {
67
+ await lock.release();
68
+ }
69
+ };
70
+ _heartbeatActive = true;
71
+ // Fire once immediately (the tick is the catch-up boundary).
72
+ void tick();
73
+ _heartbeatTimer = setInterval(() => { void tick(); }, intervalMs);
74
+ // Don't keep the event loop alive just for the heartbeat.
75
+ if (typeof _heartbeatTimer.unref === 'function') {
76
+ _heartbeatTimer.unref();
77
+ }
78
+ }
79
+ /** Stop the heartbeat. Idempotent. */
80
+ function stopHeartbeat() {
81
+ if (_heartbeatTimer) {
82
+ clearInterval(_heartbeatTimer);
83
+ _heartbeatTimer = null;
84
+ }
85
+ _heartbeatActive = false;
86
+ (0, diagnostics_1.noteHeartbeat)(false);
87
+ }
88
+ function isHeartbeatActive() {
89
+ return _heartbeatActive;
90
+ }
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * core/v4/cron/diagnostics.ts — Phase v4.1-cron
10
+ *
11
+ * Build fingerprint + diagnostics envelope for `/cron status`,
12
+ * `aiden cron status`, and the heartbeat tracker. Bump on every
13
+ * shipped phase. Format: `v4.1-cron[+suffix]`.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.RECENT_FIRES_KEEP = exports.DEFAULT_TIMEOUT_MS = exports.DEFAULT_TICK_MS = exports.CRON_SCHEMA_VERSION = exports.AIDEN_CRON_BUILD = void 0;
17
+ exports.noteHeartbeat = noteHeartbeat;
18
+ exports.noteSkippedTick = noteSkippedTick;
19
+ exports.noteFireStarted = noteFireStarted;
20
+ exports.recordFire = recordFire;
21
+ exports.getDiagnosticsSnapshot = getDiagnosticsSnapshot;
22
+ exports.resolveTickMs = resolveTickMs;
23
+ exports.resolveTimeoutMs = resolveTimeoutMs;
24
+ exports.__resetDiagnosticsForTests = __resetDiagnosticsForTests;
25
+ /** Build fingerprint — bump per phase. */
26
+ exports.AIDEN_CRON_BUILD = 'v4.1-cron';
27
+ /** Schema version — bumped when on-disk format changes. v1 = bare
28
+ * array, v2 = enveloped `{ jobs: [...], updatedAt, schemaVersion }`. */
29
+ exports.CRON_SCHEMA_VERSION = 2;
30
+ /** Default 60s heartbeat — env override `AIDEN_CRON_TICK_MS`. */
31
+ exports.DEFAULT_TICK_MS = 60000;
32
+ /** Default per-fire timeout — env override `AIDEN_CRON_TIMEOUT_MS`.
33
+ * Long-running shell_exec (web research, deep file ops) legitimately
34
+ * takes minutes; 600s gives ample headroom. */
35
+ exports.DEFAULT_TIMEOUT_MS = 600000;
36
+ /** Recent-fires retention — diagnostics surface the last N. */
37
+ exports.RECENT_FIRES_KEEP = 5;
38
+ /** In-process diagnostics ring buffer. Module singleton — survives
39
+ * across calls but resets on process boot. */
40
+ const _state = {
41
+ heartbeatActive: false,
42
+ lastHeartbeatAt: null,
43
+ skippedTicks: 0,
44
+ firesStarted: 0,
45
+ recentFires: [],
46
+ };
47
+ function noteHeartbeat(active, at = new Date()) {
48
+ _state.heartbeatActive = active;
49
+ _state.lastHeartbeatAt = at.toISOString();
50
+ }
51
+ function noteSkippedTick() {
52
+ _state.skippedTicks += 1;
53
+ }
54
+ function noteFireStarted() {
55
+ _state.firesStarted += 1;
56
+ }
57
+ function recordFire(rec) {
58
+ _state.recentFires.unshift(rec);
59
+ if (_state.recentFires.length > exports.RECENT_FIRES_KEEP) {
60
+ _state.recentFires.length = exports.RECENT_FIRES_KEEP;
61
+ }
62
+ }
63
+ function getDiagnosticsSnapshot(opts) {
64
+ return {
65
+ build: exports.AIDEN_CRON_BUILD,
66
+ schemaVersion: opts.schemaVersion,
67
+ tickMs: resolveTickMs(),
68
+ timeoutMs: resolveTimeoutMs(),
69
+ heartbeatActive: _state.heartbeatActive,
70
+ lastHeartbeatAt: _state.lastHeartbeatAt,
71
+ skippedTicks: _state.skippedTicks,
72
+ firesStarted: _state.firesStarted,
73
+ recentFires: [..._state.recentFires],
74
+ lock: { path: opts.lockPath, held: opts.lockHeld },
75
+ };
76
+ }
77
+ /** Resolve tick interval — env override > default. */
78
+ function resolveTickMs(env = process.env) {
79
+ const raw = env.AIDEN_CRON_TICK_MS;
80
+ if (raw && /^\d+$/.test(raw)) {
81
+ const n = Number.parseInt(raw, 10);
82
+ if (n >= 1000 && n <= 3600000)
83
+ return n;
84
+ }
85
+ return exports.DEFAULT_TICK_MS;
86
+ }
87
+ /** Resolve per-fire timeout — env override > default. */
88
+ function resolveTimeoutMs(env = process.env) {
89
+ const raw = env.AIDEN_CRON_TIMEOUT_MS;
90
+ if (raw && /^\d+$/.test(raw)) {
91
+ const n = Number.parseInt(raw, 10);
92
+ if (n >= 1000 && n <= 24 * 3600000)
93
+ return n;
94
+ }
95
+ return exports.DEFAULT_TIMEOUT_MS;
96
+ }
97
+ /** Test-only: reset diagnostics state. */
98
+ function __resetDiagnosticsForTests() {
99
+ _state.heartbeatActive = false;
100
+ _state.lastHeartbeatAt = null;
101
+ _state.skippedTicks = 0;
102
+ _state.firesStarted = 0;
103
+ _state.recentFires = [];
104
+ }
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * core/v4/cron/graceWindow.ts — Phase v4.1-cron
10
+ *
11
+ * Adaptive grace window for fast-forward / catch-up after sleep.
12
+ *
13
+ * Hard-won lesson (port from prior multi-agent systems): a fixed
14
+ * global grace cap is wrong for cron. Daily jobs that ran at 9am
15
+ * yesterday and the laptop slept past 9am today should still fire
16
+ * within a reasonable window (up to ~2h late). But sub-hourly jobs
17
+ * (every 5 minutes) should NOT fire 30 missed instances after a
18
+ * long sleep — that's a thundering-herd disaster.
19
+ *
20
+ * Solution: scale the grace window to the schedule period. Half
21
+ * the period, capped at 2h, floored at 2 minutes. Then SKIP, don't
22
+ * REPLAY: when a job is overdue beyond its grace, fast-forward
23
+ * `nextRunAt` to the next future occurrence and skip this firing
24
+ * entirely. "One missed run lost; no thundering-herd risk."
25
+ *
26
+ * grace = max(120s, min(period/2, 7200s))
27
+ *
28
+ * This module is a pure function — no I/O, no state. Caller
29
+ * threads the snapshot and decides what to do based on the
30
+ * verdict.
31
+ */
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ exports.ONESHOT_GRACE_MS = exports.GRACE_CEIL_MS = exports.GRACE_FLOOR_MS = void 0;
34
+ exports.computeGraceMs = computeGraceMs;
35
+ exports.evaluateRecurring = evaluateRecurring;
36
+ exports.evaluateOneShot = evaluateOneShot;
37
+ /** Constants — exposed for tests. */
38
+ exports.GRACE_FLOOR_MS = 120 * 1000; // 2 minutes
39
+ exports.GRACE_CEIL_MS = 2 * 60 * 60 * 1000; // 2 hours
40
+ /** One-shot jobs get a fixed 2-minute grace window — they cannot
41
+ * fast-forward (no recurring schedule), so a delivery that hits
42
+ * the second after a one-shot's `runAt` should still fire. */
43
+ exports.ONESHOT_GRACE_MS = 120 * 1000;
44
+ /** Compute the grace window for a recurring schedule. `periodMs` is
45
+ * the interval between fires (e.g. interval=300_000 for every 5
46
+ * minutes, or the croner-computed gap between successive cron
47
+ * fires). */
48
+ function computeGraceMs(periodMs) {
49
+ if (!Number.isFinite(periodMs) || periodMs <= 0)
50
+ return exports.GRACE_FLOOR_MS;
51
+ const half = Math.floor(periodMs / 2);
52
+ const clamped = Math.max(exports.GRACE_FLOOR_MS, Math.min(half, exports.GRACE_CEIL_MS));
53
+ return clamped;
54
+ }
55
+ /** Determine whether a recurring job should fire, skip-and-advance,
56
+ * or wait. Pure — no clock injection issue: caller passes `now`. */
57
+ function evaluateRecurring(args) {
58
+ const { nextRunAtMs, periodMs, nowMs } = args;
59
+ if (nowMs < nextRunAtMs)
60
+ return { kind: 'wait' };
61
+ const overdueMs = nowMs - nextRunAtMs;
62
+ const grace = computeGraceMs(periodMs);
63
+ if (overdueMs <= grace)
64
+ return { kind: 'fire' };
65
+ return { kind: 'skip-fast-forward' };
66
+ }
67
+ /** One-shot variant — different grace window, no fast-forward. */
68
+ function evaluateOneShot(args) {
69
+ const { runAtMs, nowMs } = args;
70
+ if (nowMs < runAtMs)
71
+ return { kind: 'wait' };
72
+ // One-shot: fire if within ONESHOT_GRACE_MS, else "skip" — the
73
+ // caller flips `enabled=false` on this job rather than advancing
74
+ // because there's no next occurrence.
75
+ const overdueMs = nowMs - runAtMs;
76
+ if (overdueMs <= exports.ONESHOT_GRACE_MS)
77
+ return { kind: 'fire' };
78
+ return { kind: 'skip-fast-forward' };
79
+ }