@minionry/minion 0.7.36 → 0.7.39

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 (120) hide show
  1. package/bin/commands/minions.js +19 -0
  2. package/bin/minion.js +33 -4
  3. package/dist/server/cli/headless/claude-invoker-process.js +23 -7
  4. package/dist/server/cli/headless/claude-invoker-stream.js +8 -3
  5. package/dist/server/cli/headless/haiku-assessments.js +2 -1
  6. package/dist/server/cli/headless/mcp-config.js +2 -1
  7. package/dist/server/engines/claude/claude-command.js +1 -0
  8. package/dist/server/engines/factory.js +2 -0
  9. package/dist/server/engines/hermes/HermesEngine.js +112 -219
  10. package/dist/server/engines/hermes/hermes-gateway-registry.js +467 -0
  11. package/dist/server/index.js +10 -4
  12. package/dist/server/mcp/bouncer-integration.js +3 -2
  13. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js +35 -8
  14. package/dist/server/mcp/classifier/shadow-eval-scheduler.js +42 -0
  15. package/dist/server/mcp/classifier/telemetry.js +0 -0
  16. package/dist/server/mcp/permission-channels.js +176 -0
  17. package/dist/server/mcp/security-analysis.js +9 -1
  18. package/dist/server/mcp/security-audit.js +22 -2
  19. package/dist/server/mcp/security-patterns.js +73 -1
  20. package/dist/server/mcp/server.js +80 -50
  21. package/dist/server/routes/internal.js +6 -1
  22. package/dist/server/routes/notifications.js +6 -18
  23. package/dist/server/server-setup.js +24 -48
  24. package/dist/server/services/analytics.js +4 -2
  25. package/dist/server/services/browser/host-mock.js +6 -1
  26. package/dist/server/services/browser/host.js +27 -3
  27. package/dist/server/services/browser/reasoning-agent.js +3 -1
  28. package/dist/server/services/chain/chain-engine.js +494 -0
  29. package/dist/server/services/chain/chain-store.js +542 -0
  30. package/dist/server/services/git/haiku.js +2 -2
  31. package/dist/server/services/plan/agents/check-injection.md +43 -2
  32. package/dist/server/services/plan/agents/review-code.md +62 -3
  33. package/dist/server/services/plan/agents/review-quality.md +52 -2
  34. package/dist/server/services/plan/board-export.js +4 -0
  35. package/dist/server/services/plan/composer-prompt.js +27 -8
  36. package/dist/server/services/plan/composer.js +10 -4
  37. package/dist/server/services/plan/config-installer.js +4 -83
  38. package/dist/server/services/plan/executor.js +155 -73
  39. package/dist/server/services/plan/issue-effort.js +9 -21
  40. package/dist/server/services/plan/issue-prompt-builder.js +72 -9
  41. package/dist/server/services/plan/parser-core.js +6 -1
  42. package/dist/server/services/plan/quality-delta.js +6 -24
  43. package/dist/server/services/plan/readiness-planner.js +12 -4
  44. package/dist/server/services/plan/record-file.js +52 -0
  45. package/dist/server/services/plan/review-approval.js +64 -0
  46. package/dist/server/services/plan/review-gate.js +186 -77
  47. package/dist/server/services/plan/review-outcome.js +146 -0
  48. package/dist/server/services/plan/review-report.js +28 -1
  49. package/dist/server/services/plan/review-target.js +57 -0
  50. package/dist/server/services/plan/state-reconciler.js +3 -3
  51. package/dist/server/services/plan/template-diff.js +2 -0
  52. package/dist/server/services/plan/template-instantiator.js +4 -0
  53. package/dist/server/services/plan/watcher.js +11 -1
  54. package/dist/server/services/platform-reconnect.js +7 -0
  55. package/dist/server/services/platform-token-lifecycle.js +2 -1
  56. package/dist/server/services/platform.js +293 -50
  57. package/dist/server/services/relay-http-client.js +107 -0
  58. package/dist/server/services/schedule/agent-schedule-store.js +69 -8
  59. package/dist/server/services/schedule/agent-scheduler.js +48 -7
  60. package/dist/server/services/schedule/daily-timing.js +52 -0
  61. package/dist/server/services/schedule/prompt-file.js +55 -0
  62. package/dist/server/services/schedule/run-bootstrap.js +20 -5
  63. package/dist/server/services/schedule/schedule-store.js +54 -4
  64. package/dist/server/services/schedule/schedule-wire.js +17 -0
  65. package/dist/server/services/schedule/scheduler.js +43 -9
  66. package/dist/server/services/sdk/agent-schedule.js +294 -0
  67. package/dist/server/services/sdk/agents.js +95 -13
  68. package/dist/server/services/sdk/app-tools.js +44 -7
  69. package/dist/server/services/sdk/atomic-write.js +75 -0
  70. package/dist/server/services/sdk/browser.js +5 -1
  71. package/dist/server/services/sdk/composer.js +2 -0
  72. package/dist/server/services/sdk/file-change-notify.js +60 -13
  73. package/dist/server/services/sdk/files-app.js +12 -7
  74. package/dist/server/services/sdk/files-search.js +14 -0
  75. package/dist/server/services/sdk/files-transfer.js +1 -1
  76. package/dist/server/services/sdk/files-watch.js +721 -0
  77. package/dist/server/services/sdk/files-workspace.js +37 -11
  78. package/dist/server/services/sdk/inference-claude.js +4 -2
  79. package/dist/server/services/sdk/inference.js +16 -2
  80. package/dist/server/services/sdk/pm-chain.js +0 -0
  81. package/dist/server/services/sdk/pm-schedule.js +72 -15
  82. package/dist/server/services/sdk/pm.js +22 -0
  83. package/dist/server/services/sdk/rate-limits.js +32 -0
  84. package/dist/server/services/sdk/registry.js +40 -2
  85. package/dist/server/services/sdk/terminal.js +1 -1
  86. package/dist/server/services/sentry.js +5 -1
  87. package/dist/server/services/terminal/pty-manager.js +26 -11
  88. package/dist/server/services/terminal/pty-utils.js +12 -1
  89. package/dist/server/services/terminal/terminal-modes.js +87 -0
  90. package/dist/server/services/timeline/assembler.js +2 -2
  91. package/dist/server/services/timeline/index.js +1 -1
  92. package/dist/server/services/websocket/agent-schedule-handlers.js +62 -4
  93. package/dist/server/services/websocket/ask-user-question-bridge.js +2 -2
  94. package/dist/server/services/websocket/browser-agent-runtime.js +3 -1
  95. package/dist/server/services/websocket/browser-handlers.js +5 -2
  96. package/dist/server/services/websocket/browser-viewer-lifecycle.js +8 -0
  97. package/dist/server/services/websocket/file-explorer-handlers.js +17 -0
  98. package/dist/server/services/websocket/file-transfer-http.js +12 -6
  99. package/dist/server/services/websocket/handler.js +185 -33
  100. package/dist/server/services/websocket/msg-id-tracker.js +94 -19
  101. package/dist/server/services/websocket/plan-execution-handlers.js +33 -5
  102. package/dist/server/services/websocket/plan-handlers.js +10 -5
  103. package/dist/server/services/websocket/plan-helpers.js +23 -0
  104. package/dist/server/services/websocket/plan-issue-handlers.js +161 -4
  105. package/dist/server/services/websocket/plan-sprint-handlers.js +3 -2
  106. package/dist/server/services/websocket/schedule-handlers.js +8 -2
  107. package/dist/server/services/websocket/sdk-agent-host.js +5 -1
  108. package/dist/server/services/websocket/sdk-agent-schedule-host.js +30 -0
  109. package/dist/server/services/websocket/sdk-browser-host.js +11 -4
  110. package/dist/server/services/websocket/sdk-handlers.js +6 -1
  111. package/dist/server/services/websocket/sdk-pm-chain-host.js +20 -0
  112. package/dist/server/services/websocket/sdk-pm-execution-host.js +21 -14
  113. package/dist/server/services/websocket/sdk-pm-schedule-host.js +9 -0
  114. package/dist/server/services/websocket/sdk-terminal-host.js +7 -0
  115. package/dist/server/services/websocket/settings-handlers.js +6 -18
  116. package/dist/server/services/websocket/tab-broadcast.js +5 -1
  117. package/dist/server/services/websocket/terminal-handlers.js +51 -6
  118. package/dist/server/services/websocket/types.js +6 -5
  119. package/dist/server/services/websocket/viewer-interest.js +30 -0
  120. package/package.json +6 -6
@@ -5,10 +5,12 @@ import { existsSync, readFileSync } from 'node:fs';
5
5
  import { writeFileAtomic } from '../../utils/file-store.js';
6
6
  import { spacePath } from '../space-layout.js';
7
7
  import { isEngineId } from '../websocket/types.js';
8
- import { computeNextFireAt, MAX_SCHEDULE_HISTORY, SCHEDULE_MIN_INTERVAL_MS, } from './schedule-store.js';
9
- export { computeNextFireAt, MAX_SCHEDULE_HISTORY, SCHEDULE_MIN_INTERVAL_MS };
8
+ import { nextWallClockFireAt, normalizeWallClockTiming } from './daily-timing.js';
9
+ import { isValidPromptFilePath } from './prompt-file.js';
10
+ import { computeNextFireAt, exceedsMaxLateness, isValidMaxLatenessMs, MAX_SCHEDULE_HISTORY, SCHEDULE_MIN_INTERVAL_MS, SCHEDULE_MIN_LATENESS_MS, SCHEDULE_MISSED_LATENESS_ERROR, } from './schedule-store.js';
11
+ export { computeNextFireAt, exceedsMaxLateness, isValidMaxLatenessMs, MAX_SCHEDULE_HISTORY, SCHEDULE_MIN_INTERVAL_MS, SCHEDULE_MIN_LATENESS_MS, SCHEDULE_MISSED_LATENESS_ERROR, };
10
12
  export function isAgentScheduleTiming(timing) {
11
- return timing.kind === 'once' || timing.kind === 'interval';
13
+ return timing.kind !== 'continuous';
12
14
  }
13
15
  export function agentScheduleStorePath(workingDir) {
14
16
  return spacePath(workingDir, 'agents', 'schedules.json');
@@ -20,6 +22,9 @@ function clampTiming(timing) {
20
22
  if (timing.kind === 'interval') {
21
23
  return { kind: 'interval', everyMs: Math.max(timing.everyMs, SCHEDULE_MIN_INTERVAL_MS) };
22
24
  }
25
+ if (timing.kind === 'daily' || timing.kind === 'monthly') {
26
+ return normalizeWallClockTiming(timing) ?? timing;
27
+ }
23
28
  return timing;
24
29
  }
25
30
  function isScheduleFireStatus(value) {
@@ -43,6 +48,16 @@ function normalizeTiming(raw) {
43
48
  return null;
44
49
  return clampTiming({ kind: 'interval', everyMs: t.everyMs });
45
50
  }
51
+ if (t.kind === 'daily' || t.kind === 'monthly') {
52
+ return normalizeWallClockTiming(raw);
53
+ }
54
+ return null;
55
+ }
56
+ function normalizeNextFireAt(raw, timing, now) {
57
+ if (typeof raw === 'string')
58
+ return raw;
59
+ if (timing.kind === 'daily' || timing.kind === 'monthly')
60
+ return nextWallClockFireAt(timing, now);
46
61
  return null;
47
62
  }
48
63
  function normalizeEngineOverride(raw) {
@@ -96,7 +111,20 @@ function normalizeLastFire(raw) {
96
111
  return undefined;
97
112
  return { at: l.at, status: l.status, tabId: l.tabId };
98
113
  }
99
- function normalizeAgentScheduleRecord(raw) {
114
+ function normalizePromptFile(raw) {
115
+ return typeof raw === 'string' && isValidPromptFilePath(raw) ? raw : undefined;
116
+ }
117
+ function normalizeInitiator(raw) {
118
+ if (!raw || typeof raw !== 'object')
119
+ return undefined;
120
+ const i = raw;
121
+ if (typeof i.appId !== 'string' || !i.appId)
122
+ return undefined;
123
+ if (typeof i.origin !== 'string' || !i.origin)
124
+ return undefined;
125
+ return { appId: i.appId, origin: i.origin };
126
+ }
127
+ function normalizeAgentScheduleRecord(raw, now) {
100
128
  if (!raw || typeof raw !== 'object')
101
129
  return null;
102
130
  const r = raw;
@@ -107,21 +135,27 @@ function normalizeAgentScheduleRecord(raw) {
107
135
  const timing = normalizeTiming(r.timing);
108
136
  if (!timing)
109
137
  return null;
138
+ const maxLatenessMs = r.maxLatenessMs;
139
+ if (maxLatenessMs !== undefined && !isValidMaxLatenessMs(maxLatenessMs))
140
+ return null;
110
141
  const history = Array.isArray(r.history)
111
142
  ? r.history.map(normalizeHistoryEntry).filter((e) => e !== null)
112
143
  : [];
113
144
  return {
114
145
  ...r,
115
146
  id: r.id,
116
- createdAt: typeof r.createdAt === 'string' ? r.createdAt : new Date().toISOString(),
147
+ createdAt: typeof r.createdAt === 'string' ? r.createdAt : now.toISOString(),
117
148
  name: typeof r.name === 'string' ? r.name : undefined,
118
149
  prompt: r.prompt,
150
+ promptFile: normalizePromptFile(r.promptFile),
151
+ initiator: normalizeInitiator(r.initiator),
119
152
  engineOverride: normalizeEngineOverride(r.engineOverride),
120
153
  timing,
121
154
  enabled: typeof r.enabled === 'boolean' ? r.enabled : true,
122
- nextFireAt: typeof r.nextFireAt === 'string' ? r.nextFireAt : null,
155
+ nextFireAt: normalizeNextFireAt(r.nextFireAt, timing, now),
123
156
  lastFire: normalizeLastFire(r.lastFire),
124
157
  history,
158
+ maxLatenessMs,
125
159
  };
126
160
  }
127
161
  export class AgentScheduleStore {
@@ -136,8 +170,9 @@ export class AgentScheduleStore {
136
170
  if (existsSync(this.schedulesPath)) {
137
171
  const raw = JSON.parse(readFileSync(this.schedulesPath, 'utf-8'));
138
172
  const rawSchedules = Array.isArray(raw?.schedules) ? raw.schedules : [];
173
+ const now = new Date();
139
174
  const schedules = rawSchedules
140
- .map((s) => normalizeAgentScheduleRecord(s))
175
+ .map((s) => normalizeAgentScheduleRecord(s, now))
141
176
  .filter((s) => s !== null);
142
177
  return { version: 1, schedules };
143
178
  }
@@ -170,11 +205,14 @@ export class AgentScheduleStore {
170
205
  createdAt: now.toISOString(),
171
206
  name: fields.name,
172
207
  prompt: fields.prompt,
208
+ ...(fields.promptFile !== undefined ? { promptFile: fields.promptFile } : {}),
209
+ ...(fields.initiator !== undefined ? { initiator: fields.initiator } : {}),
173
210
  engineOverride: fields.engineOverride,
174
211
  timing,
175
212
  enabled: fields.enabled ?? true,
176
213
  nextFireAt: computeNextFireAt(timing, now),
177
214
  history: [],
215
+ ...(fields.maxLatenessMs !== undefined ? { maxLatenessMs: fields.maxLatenessMs } : {}),
178
216
  };
179
217
  this.data.schedules.push(record);
180
218
  this.save();
@@ -186,13 +224,20 @@ export class AgentScheduleStore {
186
224
  return undefined;
187
225
  if (patch.name !== undefined)
188
226
  record.name = patch.name;
189
- if (patch.prompt !== undefined)
227
+ if (patch.prompt !== undefined) {
190
228
  record.prompt = patch.prompt;
229
+ record.promptFile = undefined;
230
+ }
231
+ if (patch.promptFile !== undefined)
232
+ record.promptFile = patch.promptFile;
191
233
  if (patch.engineOverride !== undefined) {
192
234
  record.engineOverride = patch.engineOverride === null ? undefined : patch.engineOverride;
193
235
  }
194
236
  if (patch.enabled !== undefined)
195
237
  record.enabled = patch.enabled;
238
+ if (patch.maxLatenessMs !== undefined) {
239
+ record.maxLatenessMs = patch.maxLatenessMs === null ? undefined : patch.maxLatenessMs;
240
+ }
196
241
  if (patch.timing !== undefined) {
197
242
  record.timing = clampTiming(patch.timing);
198
243
  record.nextFireAt = computeNextFireAt(record.timing, new Date());
@@ -216,6 +261,22 @@ export class AgentScheduleStore {
216
261
  this.save();
217
262
  return { ...record };
218
263
  }
264
+ refreshPromptFromFile(id, content) {
265
+ const record = this.data.schedules.find((s) => s.id === id);
266
+ if (!record)
267
+ return undefined;
268
+ record.prompt = content;
269
+ this.save();
270
+ return { ...record };
271
+ }
272
+ restoreNextFireAt(id, nextFireAt) {
273
+ const record = this.data.schedules.find((s) => s.id === id);
274
+ if (!record)
275
+ return undefined;
276
+ record.nextFireAt = nextFireAt;
277
+ this.save();
278
+ return { ...record };
279
+ }
219
280
  recordFire(id, entry, nextFireAt) {
220
281
  const record = this.data.schedules.find((s) => s.id === id);
221
282
  if (!record)
@@ -4,7 +4,8 @@ import { ImprovisationSessionManager } from '../../cli/improvisation-session-man
4
4
  import { ENGINE_EFFORT_LEVELS, engineForModel } from '../../engines/types.js';
5
5
  import { getEffortLevel, getEngine, getFastMode, getModel } from '../settings.js';
6
6
  import { resolveEngineForSession, runAcceptedExecute, setupSessionListeners } from '../websocket/session-handlers.js';
7
- import { AgentScheduleStore } from './agent-schedule-store.js';
7
+ import { AgentScheduleStore, computeNextFireAt, exceedsMaxLateness, SCHEDULE_MISSED_LATENESS_ERROR, } from './agent-schedule-store.js';
8
+ import { readPromptFile } from './prompt-file.js';
8
9
  import { SCHEDULE_ONCE_GRACE_MS } from './scheduler.js';
9
10
  export const AGENT_SCHEDULER_TICK_MS = 30_000;
10
11
  export { SCHEDULE_ONCE_GRACE_MS };
@@ -74,6 +75,26 @@ export class AgentSchedulerEngine {
74
75
  this.runs.reset();
75
76
  this.store = null;
76
77
  }
78
+ fireNow(scheduleId) {
79
+ const store = this.store;
80
+ if (!store)
81
+ return { kind: 'not-found' };
82
+ this.reapFinishedRuns();
83
+ const before = store.get(scheduleId);
84
+ if (!before)
85
+ return { kind: 'not-found' };
86
+ if (!before.enabled)
87
+ return { kind: 'disabled' };
88
+ const plannedNextFireAt = before.nextFireAt;
89
+ if (this.runs.has(scheduleId)) {
90
+ this.recordSkippedOverlap(before, Date.now());
91
+ }
92
+ else {
93
+ this.fire(before, Date.now());
94
+ }
95
+ store.restoreNextFireAt(scheduleId, plannedNextFireAt);
96
+ return { kind: 'ok', record: store.get(scheduleId) };
97
+ }
77
98
  tick() {
78
99
  const store = this.store;
79
100
  if (!store)
@@ -94,6 +115,10 @@ export class AgentSchedulerEngine {
94
115
  }
95
116
  if (!this.isDue(record, now))
96
117
  continue;
118
+ if (exceedsMaxLateness(record, now)) {
119
+ this.recordMissedLate(record, now);
120
+ continue;
121
+ }
97
122
  this.fire(record, now);
98
123
  }
99
124
  }
@@ -140,9 +165,18 @@ export class AgentSchedulerEngine {
140
165
  if (!store)
141
166
  return;
142
167
  const firedAt = new Date(now).toISOString();
168
+ let runRecord = record;
169
+ if (record.promptFile) {
170
+ const result = readPromptFile(this.workingDir, record.promptFile);
171
+ if (!result.ok) {
172
+ this.recordFailedFire(record, firedAt, result.reason);
173
+ return;
174
+ }
175
+ runRecord = store.refreshPromptFromFile(record.id, result.content) ?? { ...record, prompt: result.content };
176
+ }
143
177
  let handle;
144
178
  try {
145
- handle = (this.deps.launch ?? launchScheduledAgentRun)(this.ctx, this.workingDir, record);
179
+ handle = (this.deps.launch ?? launchScheduledAgentRun)(this.ctx, this.workingDir, runRecord);
146
180
  }
147
181
  catch (error) {
148
182
  this.recordFailedFire(record, firedAt, errorMessage(error));
@@ -203,20 +237,27 @@ export class AgentSchedulerEngine {
203
237
  }, this.nextFireAtAfterStart(record, now));
204
238
  this.broadcastSchedules();
205
239
  }
240
+ recordMissedLate(record, now) {
241
+ const store = this.store;
242
+ if (!store)
243
+ return;
244
+ store.recordFire(record.id, { at: new Date(now).toISOString(), status: 'missed', tabId: null, error: SCHEDULE_MISSED_LATENESS_ERROR }, this.nextFireAtAfterStart(record, now));
245
+ this.broadcastSchedules();
246
+ }
206
247
  nextFireAtAfterStart(record, now) {
207
- if (record.timing.kind === 'interval')
208
- return new Date(now + record.timing.everyMs).toISOString();
209
- return null;
248
+ if (record.timing.kind === 'once')
249
+ return null;
250
+ return computeNextFireAt(record.timing, new Date(now));
210
251
  }
211
252
  nextFireAtAfterFailure(record, now) {
212
253
  if (record.timing.kind === 'once')
213
254
  return null;
214
- return new Date(now + record.timing.everyMs).toISOString();
255
+ return computeNextFireAt(record.timing, new Date(now));
215
256
  }
216
257
  nextFireAtAfterSettle(record, now) {
217
258
  if (record.timing.kind === 'once')
218
259
  return null;
219
- return this.store?.get(record.id)?.nextFireAt ?? new Date(now + record.timing.everyMs).toISOString();
260
+ return this.store?.get(record.id)?.nextFireAt ?? computeNextFireAt(record.timing, new Date(now));
220
261
  }
221
262
  broadcastSchedules() {
222
263
  if (!this.store)
@@ -3,6 +3,7 @@
3
3
  const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
4
4
  const WEEKDAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
5
5
  const CANDIDATE_DAYS = 8;
6
+ const CANDIDATE_MONTHS = 13;
6
7
  const formatterCache = new Map();
7
8
  function zoneFormatter(timeZone) {
8
9
  let formatter = formatterCache.get(timeZone);
@@ -61,6 +62,32 @@ export function normalizeDailyTiming(raw) {
61
62
  timing.days = [...new Set(raw.days)].sort((a, b) => a - b);
62
63
  return timing;
63
64
  }
65
+ function isDayOfMonth(raw) {
66
+ return raw === 'last' || (Number.isInteger(raw) && raw >= 1 && raw <= 31);
67
+ }
68
+ export function isValidMonthlyTiming(raw) {
69
+ if (!raw || typeof raw !== 'object')
70
+ return false;
71
+ const t = raw;
72
+ if (t.kind !== 'monthly')
73
+ return false;
74
+ if (typeof t.time !== 'string' || !TIME_PATTERN.test(t.time))
75
+ return false;
76
+ if (!isValidTimeZone(t.timeZone))
77
+ return false;
78
+ return isDayOfMonth(t.dayOfMonth);
79
+ }
80
+ export function normalizeMonthlyTiming(raw) {
81
+ if (!isValidMonthlyTiming(raw))
82
+ return null;
83
+ return { kind: 'monthly', time: raw.time, timeZone: raw.timeZone, dayOfMonth: raw.dayOfMonth };
84
+ }
85
+ export function isValidWallClockTiming(raw) {
86
+ return isValidDailyTiming(raw) || isValidMonthlyTiming(raw);
87
+ }
88
+ export function normalizeWallClockTiming(raw) {
89
+ return normalizeDailyTiming(raw) ?? normalizeMonthlyTiming(raw);
90
+ }
64
91
  export function zonedParts(instant, timeZone) {
65
92
  const parts = zoneFormatter(timeZone).formatToParts(new Date(instant));
66
93
  const read = (type) => parts.find((part) => part.type === type)?.value ?? '0';
@@ -103,3 +130,28 @@ export function nextDailyFireAt(timing, from) {
103
130
  }
104
131
  throw new Error(`no daily slot within ${CANDIDATE_DAYS} days for ${timing.time} in ${timing.timeZone}`);
105
132
  }
133
+ function daysInMonth(year, month) {
134
+ return new Date(Date.UTC(year, month, 0)).getUTCDate();
135
+ }
136
+ export function nextMonthlyFireAt(timing, from) {
137
+ const [hour, minute] = timing.time.split(':').map(Number);
138
+ const fromMs = from.getTime();
139
+ const today = zonedParts(fromMs, timing.timeZone);
140
+ for (let offset = 0; offset < CANDIDATE_MONTHS; offset++) {
141
+ const monthIndex = today.month - 1 + offset;
142
+ const year = today.year + Math.floor(monthIndex / 12);
143
+ const month = (monthIndex % 12) + 1;
144
+ const lastDay = daysInMonth(year, month);
145
+ const day = timing.dayOfMonth === 'last' ? lastDay : timing.dayOfMonth;
146
+ if (day > lastDay)
147
+ continue;
148
+ const candidate = instantForWallTime(year, month, day, hour, minute, timing.timeZone);
149
+ if (candidate <= fromMs)
150
+ continue;
151
+ return new Date(candidate).toISOString();
152
+ }
153
+ throw new Error(`no monthly slot within ${CANDIDATE_MONTHS} months for day ${timing.dayOfMonth} at ${timing.time} in ${timing.timeZone}`);
154
+ }
155
+ export function nextWallClockFireAt(timing, from) {
156
+ return timing.kind === 'daily' ? nextDailyFireAt(timing, from) : nextMonthlyFireAt(timing, from);
157
+ }
@@ -0,0 +1,55 @@
1
+ // Copyright (c) 2025-present Minionry, Inc.
2
+ // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
+ import { readFileSync, statSync } from 'node:fs';
4
+ import { guardWorkspacePath } from '../sdk/files-workspace.js';
5
+ export const PROMPT_FILE_MAX_BYTES = 262_144;
6
+ export function isValidPromptFilePath(path) {
7
+ if (typeof path !== 'string' || path.length === 0 || path.startsWith('/'))
8
+ return false;
9
+ return path.split('/').every((segment) => segment !== '..');
10
+ }
11
+ export function readPromptFile(workingDir, promptFile) {
12
+ if (!isValidPromptFilePath(promptFile)) {
13
+ return {
14
+ ok: false,
15
+ reason: `promptFile '${promptFile}' must be a Space-relative POSIX path with no leading '/' and no '..' segment`,
16
+ };
17
+ }
18
+ let resolvedPath;
19
+ try {
20
+ ;
21
+ ({ resolvedPath } = guardWorkspacePath(workingDir, promptFile));
22
+ }
23
+ catch (error) {
24
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
25
+ }
26
+ let stat;
27
+ try {
28
+ stat = statSync(resolvedPath);
29
+ }
30
+ catch {
31
+ return { ok: false, reason: `promptFile '${promptFile}' does not exist` };
32
+ }
33
+ if (stat.isDirectory()) {
34
+ return { ok: false, reason: `promptFile '${promptFile}' is a directory, not a file` };
35
+ }
36
+ if (stat.size > PROMPT_FILE_MAX_BYTES) {
37
+ return {
38
+ ok: false,
39
+ reason: `promptFile '${promptFile}' is ${stat.size} bytes — exceeds the ${PROMPT_FILE_MAX_BYTES}-byte limit`,
40
+ };
41
+ }
42
+ const buffer = readFileSync(resolvedPath);
43
+ let content;
44
+ try {
45
+ content = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
46
+ }
47
+ catch {
48
+ return { ok: false, reason: `promptFile '${promptFile}' is not valid UTF-8` };
49
+ }
50
+ const trimmed = content.trim();
51
+ if (trimmed.length === 0) {
52
+ return { ok: false, reason: `promptFile '${promptFile}' is empty after trimming` };
53
+ }
54
+ return { ok: true, content: trimmed };
55
+ }
@@ -123,16 +123,31 @@ function ensureStateFile(boardDir) {
123
123
  writeFileSync(statePath, skeleton, 'utf-8');
124
124
  }
125
125
  export function writeRunInputFile(boardDir, options) {
126
+ const outDir = join(boardDir, 'out');
127
+ const path = join(outDir, 'run-input.json');
128
+ let existing = null;
129
+ if (options.upstream && existsSync(path)) {
130
+ let parsed;
131
+ try {
132
+ parsed = JSON.parse(readFileSync(path, 'utf-8'));
133
+ }
134
+ catch {
135
+ return null;
136
+ }
137
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
138
+ return null;
139
+ existing = parsed;
140
+ }
141
+ const scheduleId = options.scheduleId ?? existing?.scheduleId;
126
142
  const file = {
127
143
  schema: RUN_INPUT_SCHEMA,
128
- runAt: options.runAt ?? new Date().toISOString(),
144
+ runAt: options.runAt ?? existing?.runAt ?? new Date().toISOString(),
129
145
  boardId: options.boardId,
130
- ...(options.scheduleId ? { scheduleId: options.scheduleId } : {}),
131
- data: options.data,
146
+ ...(scheduleId ? { scheduleId } : {}),
147
+ data: options.data !== undefined ? options.data : existing && 'data' in existing ? existing.data : null,
148
+ ...(options.upstream ? { upstream: options.upstream } : {}),
132
149
  };
133
- const outDir = join(boardDir, 'out');
134
150
  mkdirSync(outDir, { recursive: true });
135
- const path = join(outDir, 'run-input.json');
136
151
  writeFileSync(path, JSON.stringify(file, null, 2), 'utf-8');
137
152
  return path;
138
153
  }
@@ -4,8 +4,14 @@ import { randomUUID } from 'node:crypto';
4
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { defaultPmDir } from '../plan/parser.js';
7
+ import { nextWallClockFireAt, normalizeWallClockTiming } from './daily-timing.js';
8
+ export function isRecurringTiming(timing) {
9
+ return timing.kind !== 'once' && timing.kind !== 'continuous';
10
+ }
7
11
  export const SCHEDULE_MIN_INTERVAL_MS = 60_000;
8
12
  export const MAX_SCHEDULE_HISTORY = 20;
13
+ export const SCHEDULE_MIN_LATENESS_MS = 60_000;
14
+ export const SCHEDULE_MISSED_LATENESS_ERROR = 'missed by more than the allowed lateness';
9
15
  function generateScheduleId() {
10
16
  return `sched-${randomUUID().replace(/-/g, '').slice(0, 8)}`;
11
17
  }
@@ -13,8 +19,14 @@ function clampTiming(timing) {
13
19
  if (timing.kind === 'interval') {
14
20
  return { kind: 'interval', everyMs: Math.max(timing.everyMs, SCHEDULE_MIN_INTERVAL_MS) };
15
21
  }
22
+ if (timing.kind === 'daily' || timing.kind === 'monthly') {
23
+ return normalizeWallClockTiming(timing) ?? timing;
24
+ }
16
25
  return timing;
17
26
  }
27
+ export function isValidMaxLatenessMs(value) {
28
+ return typeof value === 'number' && Number.isInteger(value) && value >= SCHEDULE_MIN_LATENESS_MS;
29
+ }
18
30
  export function computeNextFireAt(timing, from) {
19
31
  if (timing.kind === 'once') {
20
32
  const at = new Date(timing.at);
@@ -26,8 +38,19 @@ export function computeNextFireAt(timing, from) {
26
38
  const everyMs = Math.max(timing.everyMs, SCHEDULE_MIN_INTERVAL_MS);
27
39
  return new Date(from.getTime() + everyMs).toISOString();
28
40
  }
41
+ if (timing.kind === 'daily' || timing.kind === 'monthly') {
42
+ return nextWallClockFireAt(timing, from);
43
+ }
29
44
  return null;
30
45
  }
46
+ export function exceedsMaxLateness(record, now) {
47
+ if (record.maxLatenessMs === undefined || !isRecurringTiming(record.timing))
48
+ return false;
49
+ if (!record.nextFireAt)
50
+ return false;
51
+ const planned = Date.parse(record.nextFireAt);
52
+ return Number.isFinite(planned) && now - planned > record.maxLatenessMs;
53
+ }
31
54
  function isScheduleFireStatus(value) {
32
55
  return value === 'started' || value === 'completed' || value === 'failed' || value === 'skipped-overlap' || value === 'missed';
33
56
  }
@@ -61,6 +84,9 @@ function normalizeTiming(raw) {
61
84
  if (t.kind === 'continuous') {
62
85
  return { kind: 'continuous' };
63
86
  }
87
+ if (t.kind === 'daily' || t.kind === 'monthly') {
88
+ return normalizeWallClockTiming(raw);
89
+ }
64
90
  return null;
65
91
  }
66
92
  function normalizeInput(raw) {
@@ -95,7 +121,14 @@ function normalizeLastFire(raw) {
95
121
  return undefined;
96
122
  return { at: l.at, status: l.status, boardId: l.boardId };
97
123
  }
98
- function normalizeScheduleRecord(raw) {
124
+ function normalizeNextFireAt(raw, timing, now) {
125
+ if (typeof raw === 'string')
126
+ return raw;
127
+ if (timing.kind === 'daily' || timing.kind === 'monthly')
128
+ return nextWallClockFireAt(timing, now);
129
+ return null;
130
+ }
131
+ function normalizeScheduleRecord(raw, now) {
99
132
  if (!raw || typeof raw !== 'object')
100
133
  return null;
101
134
  const r = raw;
@@ -105,22 +138,26 @@ function normalizeScheduleRecord(raw) {
105
138
  const timing = normalizeTiming(r.timing);
106
139
  if (!source || !timing)
107
140
  return null;
141
+ const maxLatenessMs = r.maxLatenessMs;
142
+ if (maxLatenessMs !== undefined && !isValidMaxLatenessMs(maxLatenessMs))
143
+ return null;
108
144
  const history = Array.isArray(r.history)
109
145
  ? (r.history.map(normalizeHistoryEntry).filter((e) => e !== null))
110
146
  : [];
111
147
  return {
112
148
  ...r,
113
149
  id: r.id,
114
- createdAt: typeof r.createdAt === 'string' ? r.createdAt : new Date().toISOString(),
150
+ createdAt: typeof r.createdAt === 'string' ? r.createdAt : now.toISOString(),
115
151
  name: typeof r.name === 'string' ? r.name : undefined,
116
152
  source,
117
153
  input: normalizeInput(r.input),
118
154
  timing,
119
155
  enabled: typeof r.enabled === 'boolean' ? r.enabled : true,
120
- nextFireAt: typeof r.nextFireAt === 'string' ? r.nextFireAt : null,
156
+ nextFireAt: normalizeNextFireAt(r.nextFireAt, timing, now),
121
157
  lastFire: normalizeLastFire(r.lastFire),
122
158
  history,
123
159
  consecutiveEmptyRuns: typeof r.consecutiveEmptyRuns === 'number' ? r.consecutiveEmptyRuns : 0,
160
+ maxLatenessMs,
124
161
  };
125
162
  }
126
163
  export class ScheduleStore {
@@ -142,8 +179,9 @@ export class ScheduleStore {
142
179
  if (existsSync(this.schedulesPath)) {
143
180
  const raw = JSON.parse(readFileSync(this.schedulesPath, 'utf-8'));
144
181
  const rawSchedules = Array.isArray(raw?.schedules) ? raw.schedules : [];
182
+ const now = new Date();
145
183
  const schedules = rawSchedules
146
- .map((s) => normalizeScheduleRecord(s))
184
+ .map((s) => normalizeScheduleRecord(s, now))
147
185
  .filter((s) => s !== null);
148
186
  return { version: 1, schedules };
149
187
  }
@@ -182,6 +220,7 @@ export class ScheduleStore {
182
220
  nextFireAt: computeNextFireAt(timing, now),
183
221
  history: [],
184
222
  consecutiveEmptyRuns: 0,
223
+ ...(fields.maxLatenessMs !== undefined ? { maxLatenessMs: fields.maxLatenessMs } : {}),
185
224
  };
186
225
  this.data.schedules.push(record);
187
226
  this.save();
@@ -197,6 +236,9 @@ export class ScheduleStore {
197
236
  record.input = patch.input === null ? undefined : patch.input;
198
237
  if (patch.enabled !== undefined)
199
238
  record.enabled = patch.enabled;
239
+ if (patch.maxLatenessMs !== undefined) {
240
+ record.maxLatenessMs = patch.maxLatenessMs === null ? undefined : patch.maxLatenessMs;
241
+ }
200
242
  if (patch.timing !== undefined) {
201
243
  record.timing = clampTiming(patch.timing);
202
244
  record.nextFireAt = computeNextFireAt(record.timing, new Date());
@@ -220,6 +262,14 @@ export class ScheduleStore {
220
262
  this.save();
221
263
  return { ...record };
222
264
  }
265
+ restoreNextFireAt(id, nextFireAt) {
266
+ const record = this.data.schedules.find((s) => s.id === id);
267
+ if (!record)
268
+ return undefined;
269
+ record.nextFireAt = nextFireAt;
270
+ this.save();
271
+ return { ...record };
272
+ }
223
273
  recordFire(id, lastFire, nextFireAt, consecutiveEmptyRuns) {
224
274
  const record = this.data.schedules.find((s) => s.id === id);
225
275
  if (!record)
@@ -3,6 +3,8 @@
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
5
5
  import { basename, dirname, join, relative } from 'node:path';
6
+ import { isValidWallClockTiming } from './daily-timing.js';
7
+ import { isValidMaxLatenessMs } from './schedule-store.js';
6
8
  import { resolveTemplateSource, TemplateSourceError } from './template-source.js';
7
9
  export function parseCreateSourceWire(raw) {
8
10
  if (!raw || typeof raw !== 'object')
@@ -24,8 +26,15 @@ export function isValidTiming(t) {
24
26
  return typeof timing.at === 'string' && !Number.isNaN(Date.parse(timing.at));
25
27
  if (timing.kind === 'interval')
26
28
  return typeof timing.everyMs === 'number' && Number.isFinite(timing.everyMs);
29
+ if (timing.kind === 'daily' || timing.kind === 'monthly')
30
+ return isValidWallClockTiming(t);
27
31
  return timing.kind === 'continuous';
28
32
  }
33
+ export function parseMaxLatenessMs(raw) {
34
+ if (raw === undefined)
35
+ return { ok: true, value: undefined };
36
+ return isValidMaxLatenessMs(raw) ? { ok: true, value: raw } : { ok: false };
37
+ }
29
38
  export function resolveCreateSource(raw, workingDir, pmDir) {
30
39
  const retentionKey = randomUUID();
31
40
  const retentionDir = join(pmDir, 'schedules', retentionKey);
@@ -73,5 +82,13 @@ export function parseUpdatePatch(patch) {
73
82
  return null;
74
83
  safePatch.timing = patch.timing;
75
84
  }
85
+ if (patch.maxLatenessMs === null) {
86
+ safePatch.maxLatenessMs = null;
87
+ }
88
+ else if (patch.maxLatenessMs !== undefined) {
89
+ if (!isValidMaxLatenessMs(patch.maxLatenessMs))
90
+ return null;
91
+ safePatch.maxLatenessMs = patch.maxLatenessMs;
92
+ }
76
93
  return safePatch;
77
94
  }