@minionry/sdk 0.4.23 → 0.5.2

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.
@@ -45,7 +45,16 @@ const MOCK_FEATURES = [
45
45
  'agentsAppEndpoint',
46
46
  'qualityFindingState',
47
47
  'agentsModelSelection',
48
+ 'filesChangeKinds',
49
+ 'filesConditionalWrite',
50
+ 'inferenceFastMode',
48
51
  'qualitySchedules',
52
+ 'pmChains',
53
+ 'agentsEngineSelection',
54
+ 'agentSchedules',
55
+ 'scheduleWallClock',
56
+ 'pmScheduleZip',
57
+ 'pmScheduleRunNow',
49
58
  ];
50
59
  const DEFAULT_BROWSER_TABS = [
51
60
  {
@@ -111,6 +120,11 @@ const METHOD_SCOPES = {
111
120
  'agents.sessions.answerQuestion': 'agents:sessions',
112
121
  'agents.sessions.prompt': 'agents:sessions',
113
122
  'agents.sessions.stop': 'agents:sessions',
123
+ 'agents.schedules.list': 'agents:schedule',
124
+ 'agents.schedules.create': 'agents:schedule',
125
+ 'agents.schedules.update': 'agents:schedule',
126
+ 'agents.schedules.delete': 'agents:schedule',
127
+ 'agents.schedules.runNow': 'agents:schedule',
114
128
  'apps.open': null,
115
129
  cancel: null,
116
130
  'pm.createBoard': 'pm:write',
@@ -130,6 +144,11 @@ const METHOD_SCOPES = {
130
144
  'pm.schedules.create': 'pm:schedule',
131
145
  'pm.schedules.update': 'pm:schedule',
132
146
  'pm.schedules.delete': 'pm:schedule',
147
+ 'pm.schedules.runNow': 'pm:schedule',
148
+ 'pm.chains.list': 'pm:chain',
149
+ 'pm.chains.create': 'pm:chain',
150
+ 'pm.chains.update': 'pm:chain',
151
+ 'pm.chains.delete': 'pm:chain',
133
152
  'files.read': 'files:app',
134
153
  'files.readEntry': 'files:app',
135
154
  'files.write': 'files:app',
@@ -271,16 +290,19 @@ const MOCK_ENDPOINT_REGISTER_KEYS = new Set([
271
290
  const MOCK_CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
272
291
  const MOCK_WHITESPACE_OR_CONTROL = /[\s\u0000-\u001f\u007f]/;
273
292
  const MOCK_LOCAL_ONLY_SUFFIXES = ['localhost', 'local', 'internal', 'home.arpa'];
274
- const MOCK_RUN_PARAMS_SHAPE = '{ prompt, title?, model?, modelSelection?, systemPrompt?, budget?: { maxTurns?, maxSeconds? }, sessionId? }';
293
+ const MOCK_RUN_PARAMS_SHAPE = '{ prompt, title?, model?, modelSelection?, engineSelection?, systemPrompt?, budget?: { maxTurns?, maxSeconds? }, sessionId? }';
275
294
  const MOCK_RUN_PARAM_KEYS = new Set([
276
295
  'prompt',
277
296
  'title',
278
297
  'model',
279
298
  'modelSelection',
299
+ 'engineSelection',
280
300
  'systemPrompt',
281
301
  'budget',
282
302
  'sessionId',
283
303
  ]);
304
+ const MOCK_ENGINE_SELECTION_SHAPE = '{ engine?, model?, effortLevel?, fastMode? }';
305
+ const MOCK_ENGINE_SELECTION_KEYS = new Set(['engine', 'model', 'effortLevel', 'fastMode']);
284
306
  const MOCK_RUN_BUDGET_KEYS = new Set(['maxTurns', 'maxSeconds']);
285
307
  const utf8ByteLength = (value) => new TextEncoder().encode(value).length;
286
308
  function mockValidateEndpointUrl(value) {
@@ -314,6 +336,7 @@ function mockValidateEndpointUrl(value) {
314
336
  }
315
337
  return { ok: true };
316
338
  }
339
+ const MOCK_SCHEDULE_MIN_LATENESS_MS = 60_000;
317
340
  const MOCK_TOOL_CALL_TIMEOUT_MS = 30_000;
318
341
  const MOCK_TOOL_CALL_TIMEOUT_MAX_MS = 120_000;
319
342
  const MOCK_GIT_MAIN_WORKTREE_PATH = '/workspace';
@@ -730,6 +753,11 @@ const mockQualityHistoryEntry = (report) => ({
730
753
  });
731
754
  const MOCK_QUALITY_FINGERPRINTS_PER_CALL = 1_000;
732
755
  const MOCK_QUALITY_REASON_MAX = 500;
756
+ const MAX_CHAIN_DEPTH = 5;
757
+ const MAX_OUT_LINKS_PER_BOARD = 3;
758
+ const MAX_LINKS_PER_APP = 20;
759
+ const MAX_LINKS_PER_SPACE = 100;
760
+ const MAX_CHAIN_HISTORY = 20;
733
761
  export function createMockTransport(config = {}) {
734
762
  const space = { ...DEFAULT_SPACE, ...config.space };
735
763
  const user = config.user === null ? null : { ...DEFAULT_USER, ...config.user };
@@ -746,6 +774,13 @@ export function createMockTransport(config = {}) {
746
774
  const files = new Map(Object.entries(config.files ?? {}));
747
775
  const dirs = new Set();
748
776
  const timestamps = new Map();
777
+ let lastStamp = 0;
778
+ const stamp = (path) => {
779
+ lastStamp = Math.max(Date.now(), lastStamp + 1);
780
+ timestamps.set(path, new Date(lastStamp).toISOString());
781
+ };
782
+ for (const path of files.keys())
783
+ stamp(path);
749
784
  const uploads = new Map();
750
785
  const downloads = new Map();
751
786
  const storage = new Map();
@@ -763,6 +798,8 @@ export function createMockTransport(config = {}) {
763
798
  const execRunsByRunId = new Map();
764
799
  const execRunsByRequestId = new Map();
765
800
  const schedules = new Map();
801
+ const agentSchedules = new Map();
802
+ const chainLinks = new Map();
766
803
  const fileChangeSubscriptions = new Map();
767
804
  const browserTabs = DEFAULT_BROWSER_TABS.map((tab) => ({ ...tab }));
768
805
  let currentTabId = browserTabs[0]?.id ?? null;
@@ -879,6 +916,108 @@ export function createMockTransport(config = {}) {
879
916
  }
880
917
  return value;
881
918
  };
919
+ const isValidTimeZone = (value) => {
920
+ if (typeof value !== 'string' || value === '')
921
+ return false;
922
+ try {
923
+ new Intl.DateTimeFormat('en-US', { timeZone: value });
924
+ return true;
925
+ }
926
+ catch {
927
+ return false;
928
+ }
929
+ };
930
+ const zonedParts = (instant, timeZone) => {
931
+ const parts = new Intl.DateTimeFormat('en-US', {
932
+ timeZone,
933
+ hourCycle: 'h23',
934
+ year: 'numeric',
935
+ month: '2-digit',
936
+ day: '2-digit',
937
+ hour: '2-digit',
938
+ minute: '2-digit',
939
+ second: '2-digit',
940
+ weekday: 'short',
941
+ }).formatToParts(new Date(instant));
942
+ const read = (type) => parts.find((part) => part.type === type)?.value ?? '0';
943
+ const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
944
+ return {
945
+ y: Number(read('year')),
946
+ m: Number(read('month')),
947
+ d: Number(read('day')),
948
+ hh: Number(read('hour')) % 24,
949
+ mm: Number(read('minute')),
950
+ ss: Number(read('second')),
951
+ weekday: Math.max(0, weekdays.indexOf(read('weekday'))),
952
+ };
953
+ };
954
+ const zoneOffsetMs = (instant, timeZone) => {
955
+ const p = zonedParts(instant, timeZone);
956
+ return Date.UTC(p.y, p.m - 1, p.d, p.hh, p.mm, p.ss) - instant;
957
+ };
958
+ const zonedWallTimeToInstant = (y, m, d, hh, mm, timeZone) => {
959
+ const guess = Date.UTC(y, m - 1, d, hh, mm);
960
+ const first = guess - zoneOffsetMs(guess, timeZone);
961
+ const second = guess - zoneOffsetMs(first, timeZone);
962
+ const landed = zonedParts(second, timeZone);
963
+ if (landed.hh === hh && landed.mm === mm)
964
+ return second;
965
+ return Math.max(first, second);
966
+ };
967
+ const nextDailyFireAt = (timing, from) => {
968
+ const [hh, mm] = timing.time.split(':').map(Number);
969
+ const today = zonedParts(from, timing.timeZone);
970
+ for (let offset = 0; offset <= 8; offset++) {
971
+ const day = new Date(Date.UTC(today.y, today.m - 1, today.d + offset, 12));
972
+ const candidate = zonedWallTimeToInstant(day.getUTCFullYear(), day.getUTCMonth() + 1, day.getUTCDate(), hh, mm, timing.timeZone);
973
+ if (candidate <= from)
974
+ continue;
975
+ if (timing.days && !timing.days.includes(zonedParts(candidate, timing.timeZone).weekday))
976
+ continue;
977
+ return new Date(candidate).toISOString();
978
+ }
979
+ return new Date(from + 7 * 24 * 60 * 60 * 1000).toISOString();
980
+ };
981
+ const nextMonthlyFireAt = (timing, from) => {
982
+ const [hh, mm] = timing.time.split(':').map(Number);
983
+ const today = zonedParts(from, timing.timeZone);
984
+ for (let offset = 0; offset < 13; offset++) {
985
+ const monthIndex = today.m - 1 + offset;
986
+ const year = today.y + Math.floor(monthIndex / 12);
987
+ const month = (monthIndex % 12) + 1;
988
+ const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
989
+ const day = timing.dayOfMonth === 'last' ? lastDay : timing.dayOfMonth;
990
+ if (day > lastDay)
991
+ continue;
992
+ const candidate = zonedWallTimeToInstant(year, month, day, hh, mm, timing.timeZone);
993
+ if (candidate <= from)
994
+ continue;
995
+ return new Date(candidate).toISOString();
996
+ }
997
+ return new Date(from + 31 * 24 * 60 * 60 * 1000).toISOString();
998
+ };
999
+ const wallClockTimingOf = (t, method) => {
1000
+ if (typeof t.time !== 'string' || !/^([01]\d|2[0-3]):[0-5]\d$/.test(t.time)) {
1001
+ throw new MinionryError('BAD_REQUEST', `${method}: timing.time must be 24-hour HH:MM`);
1002
+ }
1003
+ if (!isValidTimeZone(t.timeZone))
1004
+ throw new MinionryError('BAD_REQUEST', `${method}: timing.timeZone must be an IANA zone name`);
1005
+ if (t.kind === 'monthly') {
1006
+ const dayOfMonth = t.dayOfMonth;
1007
+ if (dayOfMonth !== 'last' && !(Number.isInteger(dayOfMonth) && dayOfMonth >= 1 && dayOfMonth <= 31)) {
1008
+ throw new MinionryError('BAD_REQUEST', `${method}: timing.dayOfMonth must be 1–31 or 'last'`);
1009
+ }
1010
+ return { kind: 'monthly', time: t.time, timeZone: t.timeZone, dayOfMonth: dayOfMonth };
1011
+ }
1012
+ const timing = { kind: 'daily', time: t.time, timeZone: t.timeZone };
1013
+ if (t.days !== undefined) {
1014
+ if (!Array.isArray(t.days) || t.days.length === 0 || t.days.some((day) => !Number.isInteger(day) || day < 0 || day > 6)) {
1015
+ throw new MinionryError('BAD_REQUEST', `${method}: timing.days must be a non-empty array of weekdays 0 (Sunday) … 6 (Saturday)`);
1016
+ }
1017
+ timing.days = [...new Set(t.days)].sort((a, b) => a - b);
1018
+ }
1019
+ return timing;
1020
+ };
882
1021
  const mockScheduleTiming = (raw, method) => {
883
1022
  if (typeof raw !== 'object' || raw === null) {
884
1023
  throw new MinionryError('BAD_REQUEST', `${method}: timing must be an object`);
@@ -892,8 +1031,31 @@ export function createMockTransport(config = {}) {
892
1031
  }
893
1032
  if (t.kind === 'continuous')
894
1033
  return { kind: 'continuous' };
1034
+ if (t.kind === 'daily' || t.kind === 'monthly')
1035
+ return wallClockTimingOf(t, method);
895
1036
  throw new MinionryError('BAD_REQUEST', `${method}: invalid timing`);
896
1037
  };
1038
+ const mockAgentScheduleTiming = (raw, method) => {
1039
+ const timing = mockScheduleTiming(raw, method);
1040
+ if (timing.kind === 'continuous') {
1041
+ throw new MinionryError('BAD_REQUEST', `${method}: timing must be {kind:'once',at}, {kind:'interval',everyMs}, {kind:'daily',time,timeZone,days?} or {kind:'monthly',time,timeZone,dayOfMonth} — 'continuous' is not supported for agent-prompt schedules`);
1042
+ }
1043
+ return timing;
1044
+ };
1045
+ const mockMaxLatenessMs = (raw, method) => {
1046
+ if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < MOCK_SCHEDULE_MIN_LATENESS_MS) {
1047
+ throw new MinionryError('BAD_REQUEST', `${method}: maxLatenessMs must be an integer >= the schedule minimum, or null to clear it`);
1048
+ }
1049
+ return raw;
1050
+ };
1051
+ const patchMaxLatenessMs = (target, raw, method) => {
1052
+ if (raw === undefined)
1053
+ return;
1054
+ if (raw === null)
1055
+ delete target.maxLatenessMs;
1056
+ else
1057
+ target.maxLatenessMs = mockMaxLatenessMs(raw, method);
1058
+ };
897
1059
  const mockNextFireAt = (timing, from) => {
898
1060
  if (timing.kind === 'once') {
899
1061
  const at = new Date(timing.at);
@@ -901,8 +1063,220 @@ export function createMockTransport(config = {}) {
901
1063
  }
902
1064
  if (timing.kind === 'interval')
903
1065
  return new Date(from.getTime() + timing.everyMs).toISOString();
1066
+ if (timing.kind === 'daily')
1067
+ return nextDailyFireAt(timing, from.getTime());
1068
+ if (timing.kind === 'monthly')
1069
+ return nextMonthlyFireAt(timing, from.getTime());
904
1070
  return null;
905
1071
  };
1072
+ const workspaceFileParam = (raw, field, method) => {
1073
+ if (typeof raw !== 'string' || !raw.startsWith(WORKSPACE_PATH_PREFIX)) {
1074
+ throw new MinionryError('BAD_REQUEST', `${method}: ${field} must be a workspace-rooted path ('${WORKSPACE_PATH_PREFIX}/<path>')`);
1075
+ }
1076
+ const rel = raw.slice(WORKSPACE_PATH_PREFIX.length).replace(/^\/+/, '');
1077
+ if (rel === '' || rel.split('/').some((segment) => segment === '..')) {
1078
+ throw new MinionryError('BAD_REQUEST', `${method}: ${field} '${raw}' must not be empty or contain a '..' segment`);
1079
+ }
1080
+ if (!files.has(raw))
1081
+ throw new MinionryError('BAD_REQUEST', `${method}: ${field} '${raw}' does not exist`);
1082
+ return raw;
1083
+ };
1084
+ const promptFileContent = (path, method) => {
1085
+ const content = (files.get(path) ?? '').trim();
1086
+ if (content === '')
1087
+ throw new MinionryError('BAD_REQUEST', `${method}: promptFile '${path}' is empty after trimming`);
1088
+ return content;
1089
+ };
1090
+ const cloneAgentSchedule = (schedule) => ({
1091
+ ...schedule,
1092
+ timing: schedule.timing.kind === 'daily' && schedule.timing.days ? { ...schedule.timing, days: [...schedule.timing.days] } : { ...schedule.timing },
1093
+ ...(schedule.lastFire ? { lastFire: { ...schedule.lastFire } } : {}),
1094
+ history: schedule.history.map((entry) => ({ ...entry })),
1095
+ });
1096
+ const ownedAgentScheduleById = (value, method) => {
1097
+ const id = asString(value, 'scheduleId', method);
1098
+ const schedule = agentSchedules.get(id);
1099
+ if (!schedule || schedule.initiator.appId !== MOCK_ENDPOINT_APP_ID) {
1100
+ throw new MinionryError('BAD_REQUEST', `schedule '${id}' not found in this Space`);
1101
+ }
1102
+ return schedule;
1103
+ };
1104
+ const chainBoardStatus = (board) => (board.completedAt ? 'completed' : board.status);
1105
+ const chainToSnapshot = (link) => ({
1106
+ id: link.id,
1107
+ from: link.from,
1108
+ to: link.to,
1109
+ on: link.on,
1110
+ passOutputs: link.passOutputs,
1111
+ name: link.name,
1112
+ enabled: link.enabled,
1113
+ state: link.state,
1114
+ createdAt: link.createdAt,
1115
+ ownedByCaller: true,
1116
+ history: link.history,
1117
+ });
1118
+ const chainArmedEdges = (extra) => {
1119
+ const edges = [...chainLinks.values()]
1120
+ .filter((link) => link.state === 'armed')
1121
+ .map((link) => ({ from: link.from, to: link.to }));
1122
+ if (extra)
1123
+ edges.push(extra);
1124
+ return edges;
1125
+ };
1126
+ const chainGraphHasCycle = (edges) => {
1127
+ const adjacency = new Map();
1128
+ for (const edge of edges) {
1129
+ const list = adjacency.get(edge.from);
1130
+ if (list)
1131
+ list.push(edge.to);
1132
+ else
1133
+ adjacency.set(edge.from, [edge.to]);
1134
+ }
1135
+ const color = new Map();
1136
+ const visit = (node) => {
1137
+ color.set(node, 'gray');
1138
+ for (const next of adjacency.get(node) ?? []) {
1139
+ const state = color.get(next);
1140
+ if (state === 'gray')
1141
+ return true;
1142
+ if (state !== 'black' && visit(next))
1143
+ return true;
1144
+ }
1145
+ color.set(node, 'black');
1146
+ return false;
1147
+ };
1148
+ for (const node of adjacency.keys()) {
1149
+ if (!color.has(node) && visit(node))
1150
+ return true;
1151
+ }
1152
+ return false;
1153
+ };
1154
+ const chainGraphDepthThrough = (edges, target) => {
1155
+ const incoming = new Map();
1156
+ for (const edge of edges) {
1157
+ const list = incoming.get(edge.to);
1158
+ if (list)
1159
+ list.push(edge.from);
1160
+ else
1161
+ incoming.set(edge.to, [edge.from]);
1162
+ }
1163
+ const memo = new Map();
1164
+ const longestTo = (node, path) => {
1165
+ const cached = memo.get(node);
1166
+ if (cached !== undefined)
1167
+ return cached;
1168
+ if (path.has(node))
1169
+ return 1;
1170
+ path.add(node);
1171
+ let best = 0;
1172
+ for (const predecessor of incoming.get(node) ?? [])
1173
+ best = Math.max(best, longestTo(predecessor, path));
1174
+ path.delete(node);
1175
+ const depth = best + 1;
1176
+ memo.set(node, depth);
1177
+ return depth;
1178
+ };
1179
+ return longestTo(target, new Set());
1180
+ };
1181
+ const chainPushHistory = (link, entry) => {
1182
+ link.history.push(entry);
1183
+ if (link.history.length > MAX_CHAIN_HISTORY)
1184
+ link.history.splice(0, link.history.length - MAX_CHAIN_HISTORY);
1185
+ };
1186
+ const CHAIN_ERROR = {
1187
+ boardGone: 'target board no longer exists',
1188
+ notRunnable: 'target board is not runnable',
1189
+ noIssues: 'target board has no issues',
1190
+ };
1191
+ const settleBoardImpl = (boardId, outcome) => {
1192
+ const at = new Date().toISOString();
1193
+ for (const link of chainLinks.values()) {
1194
+ if (link.from !== boardId || !link.enabled || link.state !== 'armed')
1195
+ continue;
1196
+ const matched = link.on === 'settled' || link.on === outcome;
1197
+ if (!matched) {
1198
+ chainPushHistory(link, {
1199
+ at,
1200
+ completedAt: at,
1201
+ status: 'skipped',
1202
+ upstreamOutcome: outcome,
1203
+ downstreamBoardId: null,
1204
+ skipReason: 'condition_not_met',
1205
+ });
1206
+ continue;
1207
+ }
1208
+ const edges = chainArmedEdges();
1209
+ if (chainGraphHasCycle(edges)) {
1210
+ chainPushHistory(link, {
1211
+ at,
1212
+ completedAt: at,
1213
+ status: 'skipped',
1214
+ upstreamOutcome: outcome,
1215
+ downstreamBoardId: null,
1216
+ skipReason: 'cycle',
1217
+ });
1218
+ link.state = 'disabled';
1219
+ continue;
1220
+ }
1221
+ if (chainGraphDepthThrough(edges, link.to) > MAX_CHAIN_DEPTH) {
1222
+ chainPushHistory(link, {
1223
+ at,
1224
+ completedAt: at,
1225
+ status: 'skipped',
1226
+ upstreamOutcome: outcome,
1227
+ downstreamBoardId: null,
1228
+ skipReason: 'max_depth',
1229
+ });
1230
+ link.state = 'disabled';
1231
+ continue;
1232
+ }
1233
+ const toBoard = boards.get(link.to);
1234
+ const nonEpicIssues = toBoard?.issues.filter((issue) => issue.type !== 'epic') ?? [];
1235
+ if (!toBoard) {
1236
+ chainPushHistory(link, {
1237
+ at,
1238
+ completedAt: at,
1239
+ status: 'failed',
1240
+ upstreamOutcome: outcome,
1241
+ downstreamBoardId: null,
1242
+ error: CHAIN_ERROR.boardGone,
1243
+ });
1244
+ }
1245
+ else if (chainBoardStatus(toBoard) === 'completed' || chainBoardStatus(toBoard) === 'archived') {
1246
+ chainPushHistory(link, {
1247
+ at,
1248
+ completedAt: at,
1249
+ status: 'failed',
1250
+ upstreamOutcome: outcome,
1251
+ downstreamBoardId: null,
1252
+ error: CHAIN_ERROR.notRunnable,
1253
+ });
1254
+ }
1255
+ else if (nonEpicIssues.length === 0) {
1256
+ chainPushHistory(link, {
1257
+ at,
1258
+ completedAt: at,
1259
+ status: 'failed',
1260
+ upstreamOutcome: outcome,
1261
+ downstreamBoardId: null,
1262
+ error: CHAIN_ERROR.noIssues,
1263
+ });
1264
+ }
1265
+ else {
1266
+ chainPushHistory(link, {
1267
+ at,
1268
+ completedAt: at,
1269
+ status: 'started',
1270
+ upstreamOutcome: outcome,
1271
+ downstreamBoardId: link.to,
1272
+ downstreamRunId: `chain-run-mock-${++seq}`,
1273
+ });
1274
+ }
1275
+ link.state = 'fired';
1276
+ }
1277
+ };
1278
+ const chainsController = { settleBoard: settleBoardImpl };
1279
+ config.chains?.onReady?.(chainsController);
906
1280
  const settleWaiters = (run) => {
907
1281
  if (run.status === 'running')
908
1282
  return;
@@ -1015,6 +1389,55 @@ export function createMockTransport(config = {}) {
1015
1389
  }
1016
1390
  return name;
1017
1391
  };
1392
+ const admitEngineSelection = (raw) => {
1393
+ if (raw === undefined)
1394
+ return undefined;
1395
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
1396
+ throw new MinionryError('BAD_REQUEST', `agents.run engineSelection must be an object ${MOCK_ENGINE_SELECTION_SHAPE}`);
1397
+ }
1398
+ const record = raw;
1399
+ refuseUnknownKeys(record, MOCK_ENGINE_SELECTION_KEYS, 'agents.run engineSelection', MOCK_ENGINE_SELECTION_SHAPE);
1400
+ const nonEmptyString = (value, field) => {
1401
+ if (value === undefined)
1402
+ return undefined;
1403
+ if (typeof value !== 'string' || value === '') {
1404
+ throw new MinionryError('BAD_REQUEST', `agents.run engineSelection.${field} must be a non-empty string when present`);
1405
+ }
1406
+ return value;
1407
+ };
1408
+ const engine = nonEmptyString(record.engine, 'engine');
1409
+ const model = nonEmptyString(record.model, 'model');
1410
+ const effortLevel = nonEmptyString(record.effortLevel, 'effortLevel');
1411
+ if (record.fastMode !== undefined && typeof record.fastMode !== 'boolean') {
1412
+ throw new MinionryError('BAD_REQUEST', 'agents.run engineSelection.fastMode must be a boolean when present');
1413
+ }
1414
+ const fastMode = record.fastMode === true;
1415
+ if (!granted.has('settings:read')) {
1416
+ throw new MinionryError('SCOPE_DENIED', 'agents.run: engineSelection needs the settings:read scope');
1417
+ }
1418
+ if (engine !== undefined && !MOCK_ENGINES.includes(engine)) {
1419
+ throw new MinionryError('BAD_REQUEST', `agents.run engineSelection: unknown engine '${engine}' — expected one of ${MOCK_ENGINES.join(', ')}`);
1420
+ }
1421
+ const resolvedEngine = engine ?? (model ? mockEngineForModel(model) : MOCK_DEFAULT_ENGINE);
1422
+ if (!resolvedAgentRunEngines.includes(resolvedEngine)) {
1423
+ throw new MinionryError('ENGINE_UNAVAILABLE', `agents.run: ${resolvedEngine} cannot run a confined app session with tools on this host`);
1424
+ }
1425
+ if (model && mockEngineForModel(model) !== resolvedEngine) {
1426
+ throw new MinionryError('BAD_REQUEST', `agents.run: engineSelection model '${model}' is not a ${resolvedEngine} model — it belongs to ${mockEngineForModel(model)}`);
1427
+ }
1428
+ if (effortLevel !== undefined && effortLevel !== 'auto' && !MOCK_EFFORT_LEVELS[resolvedEngine]?.includes(effortLevel)) {
1429
+ throw new MinionryError('BAD_REQUEST', `agents.run: engineSelection effortLevel '${effortLevel}' is not a ${resolvedEngine} effort level`);
1430
+ }
1431
+ if (fastMode && resolvedEngine !== 'claude-code') {
1432
+ throw new MinionryError('BAD_REQUEST', `agents.run: engineSelection.fastMode is a claude-code setting — ${resolvedEngine} has no fast mode`);
1433
+ }
1434
+ return {
1435
+ ...(engine !== undefined ? { engine } : {}),
1436
+ ...(model !== undefined ? { model } : {}),
1437
+ ...(effortLevel !== undefined ? { effortLevel } : {}),
1438
+ ...(fastMode ? { fastMode } : {}),
1439
+ };
1440
+ };
1018
1441
  const runParams = (params) => {
1019
1442
  const record = strictRecord(params, 'agents.run', MOCK_RUN_PARAMS_SHAPE, MOCK_RUN_PARAM_KEYS);
1020
1443
  const prompt = asString(record.prompt, 'prompt', 'agents.run');
@@ -1030,7 +1453,7 @@ export function createMockTransport(config = {}) {
1030
1453
  };
1031
1454
  const model = optional('model');
1032
1455
  const sessionId = optional('sessionId');
1033
- const { systemPrompt, budget, modelSelection } = record;
1456
+ const { systemPrompt, budget, modelSelection, engineSelection } = record;
1034
1457
  if (systemPrompt !== undefined && (typeof systemPrompt !== 'string' || systemPrompt.length > AGENT_RUN_LIMITS.maxSystemPromptChars)) {
1035
1458
  throw new MinionryError('BAD_REQUEST', `agents.run systemPrompt must be a string of at most ${AGENT_RUN_LIMITS.maxSystemPromptChars} characters`);
1036
1459
  }
@@ -1058,6 +1481,21 @@ export function createMockTransport(config = {}) {
1058
1481
  throw new MinionryError('BAD_REQUEST', 'agents.run systemPrompt and budget apply only to a run on an app-registered model — pass model');
1059
1482
  }
1060
1483
  admitModelSelection(modelSelection);
1484
+ if (engineSelection !== undefined) {
1485
+ const conflicts = [
1486
+ ['model', model !== undefined],
1487
+ ['modelSelection', modelSelection !== undefined],
1488
+ ['systemPrompt', systemPrompt !== undefined],
1489
+ ['budget', budget !== undefined],
1490
+ ['sessionId', sessionId !== undefined],
1491
+ ]
1492
+ .filter(([, present]) => present)
1493
+ .map(([field]) => field);
1494
+ if (conflicts.length > 0) {
1495
+ throw new MinionryError('BAD_REQUEST', `agents.run engineSelection is not allowed alongside ${conflicts.join(', ')} — pass only one way of choosing the run's engine or model`);
1496
+ }
1497
+ }
1498
+ admitEngineSelection(engineSelection);
1061
1499
  return { prompt, title, model, sessionId };
1062
1500
  };
1063
1501
  const endpointRunSteps = (endpoint, prompt, turn) => {
@@ -1269,14 +1707,28 @@ export function createMockTransport(config = {}) {
1269
1707
  run.issueIndex += 1;
1270
1708
  later(() => advanceExec(run));
1271
1709
  };
1272
- const notifyMockFileChanged = (path, kind) => {
1710
+ const withinMockRoot = (root, path) => {
1711
+ const base = root === '.' ? '' : root.replace(/\/+$/, '');
1712
+ if (base === '' || path === base)
1713
+ return path.slice(base.length);
1714
+ return path.startsWith(`${base}/`) ? path.slice(base.length + 1) : undefined;
1715
+ };
1716
+ const notifyMockFileChanged = (path, kind, dir = false, newPath) => {
1273
1717
  for (const [requestId, root] of fileChangeSubscriptions) {
1274
- const prefix = root === '' || root === '.' ? '' : `${root.replace(/\/+$/, '')}/`;
1275
- if (prefix && !path.startsWith(prefix))
1276
- continue;
1277
- emit(requestId, 'changed', { path: prefix ? path.slice(prefix.length) : path, kind });
1718
+ const from = withinMockRoot(root, path);
1719
+ const to = newPath === undefined ? undefined : withinMockRoot(root, newPath);
1720
+ let change;
1721
+ if (from !== undefined) {
1722
+ change = to !== undefined ? { path: from, kind, newPath: to } : { path: from, kind: newPath === undefined ? kind : 'deleted' };
1723
+ }
1724
+ else if (to !== undefined) {
1725
+ change = { path: to, kind: 'created' };
1726
+ }
1727
+ if (change)
1728
+ emit(requestId, 'changed', dir ? { ...change, dir } : change);
1278
1729
  }
1279
1730
  };
1731
+ const isMockDir = (path) => dirs.has(path) || [...files.keys(), ...dirs].some((key) => key.startsWith(`${path}/`));
1280
1732
  const browserTabsPayload = () => ({
1281
1733
  tabs: browserTabs.map((tab) => ({ ...tab })),
1282
1734
  currentTabId,
@@ -1374,6 +1826,10 @@ export function createMockTransport(config = {}) {
1374
1826
  hermes: [],
1375
1827
  };
1376
1828
  const MOCK_INFERENCE_ENGINES = ['claude-code'];
1829
+ const MOCK_AGENT_RUN_ENGINES = ['claude-code'];
1830
+ const resolvedAgentRunEngines = config.agentRunEngines === null || config.agentRunEngines === undefined
1831
+ ? MOCK_AGENT_RUN_ENGINES
1832
+ : config.agentRunEngines;
1377
1833
  const mockInferenceMessages = (raw) => {
1378
1834
  const valid = Array.isArray(raw) &&
1379
1835
  raw.length > 0 &&
@@ -1675,17 +2131,6 @@ export function createMockTransport(config = {}) {
1675
2131
  ...(schedule.lastFire ? { lastFire: { ...schedule.lastFire } } : {}),
1676
2132
  history: schedule.history.map((entry) => ({ ...entry })),
1677
2133
  });
1678
- const isValidTimeZone = (value) => {
1679
- if (typeof value !== 'string' || value === '')
1680
- return false;
1681
- try {
1682
- new Intl.DateTimeFormat('en-US', { timeZone: value });
1683
- return true;
1684
- }
1685
- catch {
1686
- return false;
1687
- }
1688
- };
1689
2134
  const qualityTimingOf = (value, method) => {
1690
2135
  const raw = asRecord(value, method);
1691
2136
  if (raw.kind === 'interval') {
@@ -1710,56 +2155,7 @@ export function createMockTransport(config = {}) {
1710
2155
  }
1711
2156
  return timing;
1712
2157
  };
1713
- const zonedParts = (instant, timeZone) => {
1714
- const parts = new Intl.DateTimeFormat('en-US', {
1715
- timeZone,
1716
- hourCycle: 'h23',
1717
- year: 'numeric',
1718
- month: '2-digit',
1719
- day: '2-digit',
1720
- hour: '2-digit',
1721
- minute: '2-digit',
1722
- second: '2-digit',
1723
- weekday: 'short',
1724
- }).formatToParts(new Date(instant));
1725
- const read = (type) => parts.find((part) => part.type === type)?.value ?? '0';
1726
- const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
1727
- return {
1728
- y: Number(read('year')),
1729
- m: Number(read('month')),
1730
- d: Number(read('day')),
1731
- hh: Number(read('hour')) % 24,
1732
- mm: Number(read('minute')),
1733
- ss: Number(read('second')),
1734
- weekday: Math.max(0, weekdays.indexOf(read('weekday'))),
1735
- };
1736
- };
1737
- const zoneOffsetMs = (instant, timeZone) => {
1738
- const p = zonedParts(instant, timeZone);
1739
- return Date.UTC(p.y, p.m - 1, p.d, p.hh, p.mm, p.ss) - instant;
1740
- };
1741
- const zonedWallTimeToInstant = (y, m, d, hh, mm, timeZone) => {
1742
- const guess = Date.UTC(y, m - 1, d, hh, mm);
1743
- const first = guess - zoneOffsetMs(guess, timeZone);
1744
- const offset = zoneOffsetMs(first, timeZone);
1745
- return guess - offset;
1746
- };
1747
- const nextQualityFireAt = (timing, from) => {
1748
- if (timing.kind === 'interval')
1749
- return new Date(from + timing.everyMs).toISOString();
1750
- const [hh, mm] = timing.time.split(':').map(Number);
1751
- const today = zonedParts(from, timing.timeZone);
1752
- for (let offset = 0; offset <= 8; offset++) {
1753
- const day = new Date(Date.UTC(today.y, today.m - 1, today.d + offset, 12));
1754
- const candidate = zonedWallTimeToInstant(day.getUTCFullYear(), day.getUTCMonth() + 1, day.getUTCDate(), hh, mm, timing.timeZone);
1755
- if (candidate <= from)
1756
- continue;
1757
- if (timing.days && !timing.days.includes(zonedParts(candidate, timing.timeZone).weekday))
1758
- continue;
1759
- return new Date(candidate).toISOString();
1760
- }
1761
- return new Date(from + 7 * 24 * 60 * 60 * 1000).toISOString();
1762
- };
2158
+ const nextQualityFireAt = (timing, from) => timing.kind === 'interval' ? new Date(from + timing.everyMs).toISOString() : nextDailyFireAt(timing, from);
1763
2159
  const publishQualitySchedules = () => {
1764
2160
  publishQualityChange({ kind: 'schedules', schedules: qualitySchedules.map(cloneQualitySchedule) });
1765
2161
  };
@@ -2146,6 +2542,13 @@ export function createMockTransport(config = {}) {
2146
2542
  if (model && mockEngineForModel(model) !== resolvedEngine) {
2147
2543
  throw new MinionryError('BAD_REQUEST', `inference.chat: model '${model}' is not a ${resolvedEngine} model — it belongs to ${mockEngineForModel(model)}`);
2148
2544
  }
2545
+ if (record.fastMode !== undefined && typeof record.fastMode !== 'boolean') {
2546
+ throw new MinionryError('BAD_REQUEST', 'inference.chat: fastMode must be a boolean');
2547
+ }
2548
+ const fastMode = record.fastMode === true;
2549
+ if (fastMode && resolvedEngine !== 'claude-code') {
2550
+ throw new MinionryError('BAD_REQUEST', `inference.chat: fastMode is a claude-code setting — ${resolvedEngine} has no fast mode`);
2551
+ }
2149
2552
  if (!MOCK_INFERENCE_ENGINES.includes(resolvedEngine)) {
2150
2553
  throw new MinionryError('UNSUPPORTED', `inference.chat: ${resolvedEngine} cannot run a tool-less completion on this host`);
2151
2554
  }
@@ -2164,9 +2567,10 @@ export function createMockTransport(config = {}) {
2164
2567
  status: 'running',
2165
2568
  engine: resolvedEngine,
2166
2569
  ...(resolvedModel ? { model: resolvedModel } : {}),
2570
+ ...(fastMode ? { fastMode } : {}),
2167
2571
  text: '',
2168
2572
  deltas: [
2169
- `(mock ${resolvedEngine}${resolvedModel ? ` · ${resolvedModel}` : ''}) `,
2573
+ `(mock ${resolvedEngine}${resolvedModel ? ` · ${resolvedModel}` : ''}${fastMode ? ' · fast' : ''}) `,
2170
2574
  prompt.length > 120 ? `${prompt.slice(0, 119)}…` : prompt,
2171
2575
  ],
2172
2576
  waiters: [],
@@ -2372,6 +2776,127 @@ export function createMockTransport(config = {}) {
2372
2776
  session.isExecuting = false;
2373
2777
  return null;
2374
2778
  },
2779
+ 'agents.schedules.list': () => ({
2780
+ schedules: [...agentSchedules.values()]
2781
+ .filter((schedule) => schedule.initiator.appId === MOCK_ENDPOINT_APP_ID)
2782
+ .map((schedule) => cloneAgentSchedule(schedule.snapshot)),
2783
+ }),
2784
+ 'agents.schedules.create': (params) => {
2785
+ const method = 'agents.schedules.create';
2786
+ const record = asRecord(params, method);
2787
+ const hasPrompt = record.prompt !== undefined;
2788
+ const hasPromptFile = record.promptFile !== undefined;
2789
+ if (hasPrompt === hasPromptFile) {
2790
+ throw new MinionryError('BAD_REQUEST', `${method} requires exactly one of params.prompt or params.promptFile`);
2791
+ }
2792
+ let prompt;
2793
+ let promptFile;
2794
+ if (hasPromptFile) {
2795
+ promptFile = workspaceFileParam(record.promptFile, 'promptFile', method);
2796
+ prompt = promptFileContent(promptFile, method);
2797
+ }
2798
+ else {
2799
+ if (typeof record.prompt !== 'string' || record.prompt.trim() === '') {
2800
+ throw new MinionryError('BAD_REQUEST', `${method}: params.prompt must be a non-empty string`);
2801
+ }
2802
+ prompt = record.prompt.trim();
2803
+ }
2804
+ const timing = mockAgentScheduleTiming(record.timing, method);
2805
+ if (record.enabled !== undefined && typeof record.enabled !== 'boolean') {
2806
+ throw new MinionryError('BAD_REQUEST', `${method}: enabled must be a boolean when present`);
2807
+ }
2808
+ const now = new Date();
2809
+ const snapshot = {
2810
+ id: `agsched-mock-${++seq}`,
2811
+ createdAt: now.toISOString(),
2812
+ prompt,
2813
+ timing,
2814
+ enabled: record.enabled !== false,
2815
+ nextFireAt: mockNextFireAt(timing, now),
2816
+ history: [],
2817
+ };
2818
+ if (typeof record.name === 'string')
2819
+ snapshot.name = record.name;
2820
+ if (promptFile !== undefined)
2821
+ snapshot.promptFile = promptFile;
2822
+ if (record.maxLatenessMs !== undefined)
2823
+ snapshot.maxLatenessMs = mockMaxLatenessMs(record.maxLatenessMs, method);
2824
+ agentSchedules.set(snapshot.id, { snapshot, initiator: { appId: MOCK_ENDPOINT_APP_ID } });
2825
+ return cloneAgentSchedule(snapshot);
2826
+ },
2827
+ 'agents.schedules.update': (params) => {
2828
+ const method = 'agents.schedules.update';
2829
+ const record = asRecord(params, method);
2830
+ const { snapshot } = ownedAgentScheduleById(record.scheduleId, method);
2831
+ if (typeof record.patch !== 'object' || record.patch === null || Array.isArray(record.patch)) {
2832
+ throw new MinionryError('BAD_REQUEST', `${method} requires params.patch to be an object`);
2833
+ }
2834
+ const patch = record.patch;
2835
+ if (patch.prompt !== undefined && patch.promptFile !== undefined) {
2836
+ throw new MinionryError('BAD_REQUEST', `${method}: provide at most one of patch.prompt or patch.promptFile`);
2837
+ }
2838
+ if (patch.name !== undefined)
2839
+ snapshot.name = asString(patch.name, 'patch.name', method);
2840
+ if (patch.prompt !== undefined) {
2841
+ if (typeof patch.prompt !== 'string' || patch.prompt.trim() === '') {
2842
+ throw new MinionryError('BAD_REQUEST', `${method}: patch.prompt must be a non-empty string`);
2843
+ }
2844
+ snapshot.prompt = patch.prompt.trim();
2845
+ delete snapshot.promptFile;
2846
+ }
2847
+ if (patch.promptFile !== undefined) {
2848
+ const promptFile = workspaceFileParam(patch.promptFile, 'promptFile', method);
2849
+ snapshot.prompt = promptFileContent(promptFile, method);
2850
+ snapshot.promptFile = promptFile;
2851
+ }
2852
+ if (patch.enabled !== undefined) {
2853
+ if (typeof patch.enabled !== 'boolean')
2854
+ throw new MinionryError('BAD_REQUEST', `${method}: patch.enabled must be a boolean when present`);
2855
+ snapshot.enabled = patch.enabled;
2856
+ }
2857
+ if (patch.timing !== undefined) {
2858
+ snapshot.timing = mockAgentScheduleTiming(patch.timing, method);
2859
+ snapshot.nextFireAt = mockNextFireAt(snapshot.timing, new Date());
2860
+ }
2861
+ patchMaxLatenessMs(snapshot, patch.maxLatenessMs, method);
2862
+ return cloneAgentSchedule(snapshot);
2863
+ },
2864
+ 'agents.schedules.delete': (params) => {
2865
+ const method = 'agents.schedules.delete';
2866
+ const { snapshot } = ownedAgentScheduleById(asRecord(params, method).scheduleId, method);
2867
+ agentSchedules.delete(snapshot.id);
2868
+ return null;
2869
+ },
2870
+ 'agents.schedules.runNow': (params) => {
2871
+ const method = 'agents.schedules.runNow';
2872
+ const { snapshot } = ownedAgentScheduleById(asRecord(params, method).scheduleId, method);
2873
+ if (!snapshot.enabled) {
2874
+ throw new MinionryError('BAD_REQUEST', `schedule '${snapshot.id}' is disabled — enable it before running it now`);
2875
+ }
2876
+ const now = new Date().toISOString();
2877
+ let entry;
2878
+ const content = snapshot.promptFile === undefined ? snapshot.prompt : (files.get(snapshot.promptFile) ?? '').trim();
2879
+ if (content === '') {
2880
+ entry = { at: now, status: 'failed', tabId: null, error: `promptFile '${snapshot.promptFile}' is missing or empty` };
2881
+ }
2882
+ else {
2883
+ snapshot.prompt = content;
2884
+ const tabId = `session-mock-${++seq}`;
2885
+ sessions.set(tabId, {
2886
+ id: tabId,
2887
+ name: snapshot.name ?? `Session ${sessions.size + 1}`,
2888
+ createdAt: now,
2889
+ lastActivityAt: now,
2890
+ isExecuting: false,
2891
+ entries: [],
2892
+ seq: 0,
2893
+ });
2894
+ entry = { at: now, status: 'started', tabId };
2895
+ }
2896
+ snapshot.lastFire = { at: entry.at, tabId: entry.tabId, status: entry.status };
2897
+ snapshot.history.push(entry);
2898
+ return cloneAgentSchedule(snapshot);
2899
+ },
2375
2900
  'apps.open': (params) => {
2376
2901
  const record = asRecord(params, 'apps.open');
2377
2902
  const app = asString(record.app, 'app', 'apps.open');
@@ -2652,18 +3177,27 @@ export function createMockTransport(config = {}) {
2652
3177
  },
2653
3178
  'pm.schedules.list': () => ({ schedules: [...schedules.values()].map((s) => ({ ...s })) }),
2654
3179
  'pm.schedules.create': (params) => {
2655
- const record = asRecord(params, 'pm.schedules.create');
2656
- const source = asRecord(record.source, 'pm.schedules.create');
2657
- if (source.kind !== 'template-json' || typeof source.content !== 'string' || source.content === '') {
2658
- throw new MinionryError('BAD_REQUEST', "pm.schedules.create: source must be { kind: 'template-json', content }");
3180
+ const method = 'pm.schedules.create';
3181
+ const record = asRecord(params, method);
3182
+ const source = asRecord(record.source, method);
3183
+ let snapshotSource;
3184
+ if (source.kind === 'template-json' && typeof source.content === 'string' && source.content !== '') {
3185
+ snapshotSource = { kind: 'template-json' };
2659
3186
  }
2660
- const timing = mockScheduleTiming(record.timing, 'pm.schedules.create');
3187
+ else if (source.kind === 'board-zip') {
3188
+ const path = workspaceFileParam(source.path, 'a board-zip source.path', method);
3189
+ snapshotSource = { kind: 'board-zip', originalName: path.slice(path.lastIndexOf('/') + 1) };
3190
+ }
3191
+ else {
3192
+ throw new MinionryError('BAD_REQUEST', `${method}: source must be { kind: 'template-json', content } or { kind: 'board-zip', path: 'workspace:/…' }`);
3193
+ }
3194
+ const timing = mockScheduleTiming(record.timing, method);
2661
3195
  const id = `sched-mock-${++seq}`;
2662
3196
  const now = new Date();
2663
3197
  const snapshot = {
2664
3198
  id,
2665
3199
  createdAt: now.toISOString(),
2666
- source: { kind: 'template-json' },
3200
+ source: snapshotSource,
2667
3201
  timing,
2668
3202
  enabled: record.enabled === undefined ? true : record.enabled === true,
2669
3203
  nextFireAt: mockNextFireAt(timing, now),
@@ -2673,6 +3207,8 @@ export function createMockTransport(config = {}) {
2673
3207
  snapshot.name = record.name;
2674
3208
  if (record.input !== undefined)
2675
3209
  snapshot.input = record.input;
3210
+ if (record.maxLatenessMs !== undefined)
3211
+ snapshot.maxLatenessMs = mockMaxLatenessMs(record.maxLatenessMs, method);
2676
3212
  schedules.set(id, snapshot);
2677
3213
  return { ...snapshot };
2678
3214
  },
@@ -2694,6 +3230,7 @@ export function createMockTransport(config = {}) {
2694
3230
  updated.timing = mockScheduleTiming(patch.timing, 'pm.schedules.update');
2695
3231
  updated.nextFireAt = mockNextFireAt(updated.timing, new Date());
2696
3232
  }
3233
+ patchMaxLatenessMs(updated, patch.maxLatenessMs, 'pm.schedules.update');
2697
3234
  schedules.set(scheduleId, updated);
2698
3235
  return { ...updated };
2699
3236
  },
@@ -2702,6 +3239,133 @@ export function createMockTransport(config = {}) {
2702
3239
  schedules.delete(scheduleId);
2703
3240
  return null;
2704
3241
  },
3242
+ 'pm.schedules.runNow': (params) => {
3243
+ const method = 'pm.schedules.runNow';
3244
+ const scheduleId = asString(asRecord(params, method).scheduleId, 'scheduleId', method);
3245
+ const existing = schedules.get(scheduleId);
3246
+ if (!existing)
3247
+ throw new MinionryError('BAD_REQUEST', `schedule '${scheduleId}' not found in this Space`);
3248
+ if (!existing.enabled) {
3249
+ throw new MinionryError('BAD_REQUEST', `schedule '${scheduleId}' is disabled — enable it before running it now`);
3250
+ }
3251
+ const boardId = `board-mock-${++seq}`;
3252
+ const board = {
3253
+ boardId,
3254
+ title: existing.name ?? 'Scheduled board',
3255
+ goal: existing.name ?? 'Scheduled board',
3256
+ status: 'active',
3257
+ statuses: [...BOARD_STATUSES],
3258
+ issues: [mockIssue('IS-1', 'Plan', 'P1'), mockIssue('IS-2', 'Implement', 'P1'), mockIssue('IS-3', 'Verify', 'P2')],
3259
+ subscribers: new Set(),
3260
+ createdAt: new Date().toISOString(),
3261
+ completedAt: null,
3262
+ };
3263
+ boards.set(boardId, board);
3264
+ later(() => progressBoard(board));
3265
+ const entry = { at: board.createdAt, status: 'started', boardId };
3266
+ const fired = {
3267
+ ...existing,
3268
+ lastFire: { at: entry.at, boardId, status: entry.status },
3269
+ history: [...existing.history, entry],
3270
+ };
3271
+ schedules.set(scheduleId, fired);
3272
+ return { ...fired };
3273
+ },
3274
+ 'pm.chains.list': () => ({ links: [...chainLinks.values()].map(chainToSnapshot) }),
3275
+ 'pm.chains.create': (params) => {
3276
+ const method = 'pm.chains.create';
3277
+ const record = asRecord(params, method);
3278
+ const from = asString(record.from, 'from', method);
3279
+ const to = asString(record.to, 'to', method);
3280
+ const on = record.on;
3281
+ if (on !== 'success' && on !== 'failure' && on !== 'settled') {
3282
+ throw new MinionryError('BAD_REQUEST', `${method}: 'on' must be one of 'success', 'failure', 'settled'`);
3283
+ }
3284
+ if (from === to) {
3285
+ throw new MinionryError('BAD_REQUEST', `${method}: 'from' and 'to' must name different boards`);
3286
+ }
3287
+ const fromBoard = boards.get(from);
3288
+ const toBoard = boards.get(to);
3289
+ if (!fromBoard)
3290
+ throw new MinionryError('BAD_REQUEST', `${method}: unknown board '${from}'`);
3291
+ if (!toBoard)
3292
+ throw new MinionryError('BAD_REQUEST', `${method}: unknown board '${to}'`);
3293
+ if (chainBoardStatus(fromBoard) === 'completed' || chainBoardStatus(fromBoard) === 'archived') {
3294
+ throw new MinionryError('BAD_REQUEST', `${method}: 'from' board '${from}' is not runnable`);
3295
+ }
3296
+ if (chainBoardStatus(toBoard) === 'completed' || chainBoardStatus(toBoard) === 'archived') {
3297
+ throw new MinionryError('BAD_REQUEST', `${method}: 'to' board '${to}' is not runnable`);
3298
+ }
3299
+ const passOutputs = record.passOutputs === undefined ? false : record.passOutputs === true;
3300
+ const name = typeof record.name === 'string' ? record.name : undefined;
3301
+ const enabled = record.enabled === undefined ? true : record.enabled === true;
3302
+ const existing = [...chainLinks.values()].find((link) => link.state === 'armed' && link.from === from && link.to === to && link.on === on);
3303
+ if (existing) {
3304
+ if (existing.passOutputs === passOutputs && existing.name === name)
3305
+ return chainToSnapshot(existing);
3306
+ throw new MinionryError('BAD_REQUEST', `${method}: a link (${from} -> ${to}, on=${on}) already exists with a different passOutputs/name`);
3307
+ }
3308
+ const edges = chainArmedEdges({ from, to });
3309
+ if (chainGraphHasCycle(edges)) {
3310
+ throw new MinionryError('BAD_REQUEST', `${method}: would close a cycle`);
3311
+ }
3312
+ if (chainGraphDepthThrough(edges, to) > MAX_CHAIN_DEPTH) {
3313
+ throw new MinionryError('BAD_REQUEST', `${method}: would exceed the max chain depth of ${MAX_CHAIN_DEPTH}`);
3314
+ }
3315
+ const outLinks = [...chainLinks.values()].filter((link) => link.state === 'armed' && link.from === from).length;
3316
+ if (outLinks >= MAX_OUT_LINKS_PER_BOARD) {
3317
+ throw new MinionryError('QUOTA_EXCEEDED', `${method}: board '${from}' already has ${MAX_OUT_LINKS_PER_BOARD} outgoing links`);
3318
+ }
3319
+ const armedCount = [...chainLinks.values()].filter((link) => link.state === 'armed').length;
3320
+ if (armedCount >= MAX_LINKS_PER_APP || armedCount >= MAX_LINKS_PER_SPACE) {
3321
+ throw new MinionryError('QUOTA_EXCEEDED', `${method}: at most ${MAX_LINKS_PER_APP} links per app`);
3322
+ }
3323
+ const id = `chain-mock-${++seq}`;
3324
+ const link = {
3325
+ id,
3326
+ from,
3327
+ to,
3328
+ on,
3329
+ passOutputs,
3330
+ name,
3331
+ enabled,
3332
+ state: 'armed',
3333
+ createdAt: new Date().toISOString(),
3334
+ history: [],
3335
+ };
3336
+ chainLinks.set(id, link);
3337
+ return chainToSnapshot(link);
3338
+ },
3339
+ 'pm.chains.update': (params) => {
3340
+ const method = 'pm.chains.update';
3341
+ const record = asRecord(params, method);
3342
+ const linkId = asString(record.linkId, 'linkId', method);
3343
+ const link = chainLinks.get(linkId);
3344
+ if (!link)
3345
+ throw new MinionryError('BAD_REQUEST', `${method}: unknown linkId '${linkId}'`);
3346
+ if (link.state !== 'armed') {
3347
+ throw new MinionryError('BAD_REQUEST', `${method}: link '${linkId}' is ${link.state} and can no longer be updated`);
3348
+ }
3349
+ const patch = record.patch === undefined ? {} : asRecord(record.patch, method);
3350
+ for (const immutable of ['from', 'to', 'on', 'passOutputs']) {
3351
+ if (immutable in patch) {
3352
+ throw new MinionryError('BAD_REQUEST', `${method}: '${immutable}' is immutable — create a new link instead`);
3353
+ }
3354
+ }
3355
+ if ('enabled' in patch)
3356
+ link.enabled = patch.enabled === true;
3357
+ if ('name' in patch) {
3358
+ link.name = patch.name === null ? undefined : typeof patch.name === 'string' ? patch.name : link.name;
3359
+ }
3360
+ return chainToSnapshot(link);
3361
+ },
3362
+ 'pm.chains.delete': (params) => {
3363
+ const method = 'pm.chains.delete';
3364
+ const linkId = asString(asRecord(params, method).linkId, 'linkId', method);
3365
+ if (!chainLinks.delete(linkId))
3366
+ throw new MinionryError('BAD_REQUEST', `${method}: unknown linkId '${linkId}'`);
3367
+ return null;
3368
+ },
2705
3369
  'stream.resume': (params) => {
2706
3370
  const record = asRecord(params, 'stream.resume');
2707
3371
  const requestId = asString(record.requestId, 'requestId', 'stream.resume');
@@ -2751,13 +3415,25 @@ export function createMockTransport(config = {}) {
2751
3415
  const content = files.get(path);
2752
3416
  if (content === undefined)
2753
3417
  throw new MinionryError('BAD_REQUEST', `files.readEntry: no such file '${path}'`);
2754
- return { kind: 'text', content };
3418
+ const size = new TextEncoder().encode(content).length;
3419
+ return { kind: 'text', content, modifiedAt: timestamps.get(path), size };
2755
3420
  },
2756
3421
  'files.write': (params) => {
2757
3422
  const record = asRecord(params, 'files.write');
2758
3423
  const path = asString(record.path, 'path', 'files.write');
2759
- files.set(path, asString(record.content, 'content', 'files.write'));
2760
- timestamps.set(path, new Date().toISOString());
3424
+ const content = asString(record.content, 'content', 'files.write');
3425
+ const expected = record.expectedModifiedAt;
3426
+ if (expected !== undefined) {
3427
+ if (typeof expected !== 'string' || Number.isNaN(Date.parse(expected))) {
3428
+ throw new MinionryError('BAD_REQUEST', 'files.write: expectedModifiedAt must be an ISO-8601 timestamp');
3429
+ }
3430
+ const current = files.has(path) ? timestamps.get(path) : undefined;
3431
+ if (current === undefined || Date.parse(current) !== Date.parse(expected)) {
3432
+ throw new MinionryError('CONFLICT', `files.write: '${path}' ${current === undefined ? 'no longer exists' : `was modified at ${current}`}, not ${expected} — nothing was written`);
3433
+ }
3434
+ }
3435
+ files.set(path, content);
3436
+ stamp(path);
2761
3437
  notifyMockFileChanged(path, 'changed');
2762
3438
  return null;
2763
3439
  },
@@ -2802,6 +3478,8 @@ export function createMockTransport(config = {}) {
2802
3478
  },
2803
3479
  'files.delete': (params) => {
2804
3480
  const path = asString(asRecord(params, 'files.delete').path, 'path', 'files.delete');
3481
+ const dir = isMockDir(path);
3482
+ const existed = dir || files.has(path);
2805
3483
  files.delete(path);
2806
3484
  dirs.delete(path);
2807
3485
  timestamps.delete(path);
@@ -2816,12 +3494,17 @@ export function createMockTransport(config = {}) {
2816
3494
  if (dirPath.startsWith(prefix))
2817
3495
  dirs.delete(dirPath);
2818
3496
  }
3497
+ if (existed)
3498
+ notifyMockFileChanged(path, 'deleted', dir);
2819
3499
  return null;
2820
3500
  },
2821
3501
  'files.mkdir': (params) => {
2822
3502
  const path = asString(asRecord(params, 'files.mkdir').path, 'path', 'files.mkdir');
3503
+ const existed = isMockDir(path);
2823
3504
  dirs.add(path);
2824
- timestamps.set(path, new Date().toISOString());
3505
+ stamp(path);
3506
+ if (!existed)
3507
+ notifyMockFileChanged(path, 'created', true);
2825
3508
  return null;
2826
3509
  },
2827
3510
  'files.rename': (params) => {
@@ -2829,10 +3512,8 @@ export function createMockTransport(config = {}) {
2829
3512
  const from = asString(record.path, 'path', 'files.rename');
2830
3513
  const to = asString(record.newPath, 'newPath', 'files.rename');
2831
3514
  const fromPrefix = `${from}/`;
2832
- const exists = files.has(from) ||
2833
- dirs.has(from) ||
2834
- [...files.keys(), ...dirs].some((key) => key.startsWith(fromPrefix));
2835
- if (!exists) {
3515
+ const dir = isMockDir(from);
3516
+ if (!dir && !files.has(from)) {
2836
3517
  throw new MinionryError('BAD_REQUEST', `files.rename: no such file or directory '${from}'`);
2837
3518
  }
2838
3519
  const rewrite = (key) => (key === from ? to : `${to}${key.slice(from.length)}`);
@@ -2859,6 +3540,7 @@ export function createMockTransport(config = {}) {
2859
3540
  dirs.add(newKey);
2860
3541
  moveTimestamp(key, newKey);
2861
3542
  }
3543
+ notifyMockFileChanged(from, 'renamed', dir, to);
2862
3544
  return null;
2863
3545
  },
2864
3546
  'files.uploadStart': (params) => {
@@ -2917,9 +3599,11 @@ export function createMockTransport(config = {}) {
2917
3599
  joined.set(chunk, offset);
2918
3600
  offset += chunk.byteLength;
2919
3601
  }
3602
+ const existed = files.has(session.path);
2920
3603
  files.set(session.path, new TextDecoder().decode(joined));
2921
- timestamps.set(session.path, new Date().toISOString());
3604
+ stamp(session.path);
2922
3605
  uploads.delete(uploadId);
3606
+ notifyMockFileChanged(session.path, existed ? 'changed' : 'created');
2923
3607
  }
2924
3608
  return { chunkIndex, bytesWritten: session.bytesWritten };
2925
3609
  },
@@ -3400,6 +4084,7 @@ export function createMockTransport(config = {}) {
3400
4084
  modelsStatus: { fetchedAt: new Date().toISOString(), stale: false, refreshing: false },
3401
4085
  effortLevels: Object.fromEntries(Object.entries(MOCK_EFFORT_LEVELS).map(([id, levels]) => [id, [...levels]])),
3402
4086
  inferenceEngines: [...MOCK_INFERENCE_ENGINES],
4087
+ ...(config.agentRunEngines === null ? {} : { agentRunEngines: [...resolvedAgentRunEngines] }),
3403
4088
  }),
3404
4089
  'terminal.sessions.list': () => [...ptySessions.values()].map(mockPtySessionSummary),
3405
4090
  'terminal.sessions.subscribe': (_params, requestId) => {
@@ -4224,6 +4909,19 @@ export function createMockTransport(config = {}) {
4224
4909
  }
4225
4910
  return METHOD_SCOPES[method];
4226
4911
  };
4912
+ const resolveScopes = (method, params) => {
4913
+ const scope = resolveScope(method, params);
4914
+ const scopes = scope === null ? [] : [scope];
4915
+ const raw = scopeParams(params);
4916
+ if (method === 'agents.schedules.create' || method === 'agents.schedules.update') {
4917
+ if (raw.promptFile !== undefined || scopeParams(raw.patch).promptFile !== undefined)
4918
+ scopes.push('files:workspace:read');
4919
+ }
4920
+ else if (method === 'pm.schedules.create' && scopeParams(raw.source).kind === 'board-zip') {
4921
+ scopes.push('files:workspace:read');
4922
+ }
4923
+ return scopes;
4924
+ };
4227
4925
  const dispatch = (frame) => {
4228
4926
  const { requestId, method } = frame;
4229
4927
  const forced = forcedErrors[method];
@@ -4235,8 +4933,8 @@ export function createMockTransport(config = {}) {
4235
4933
  fail(requestId, { code: 'SCOPE_DENIED', message: `mock: unknown method '${String(method)}' (fail closed)` });
4236
4934
  return;
4237
4935
  }
4238
- const scope = resolveScope(method, frame.params);
4239
- if (scope !== null && !granted.has(scope)) {
4936
+ const scope = resolveScopes(method, frame.params).find((required) => !granted.has(required));
4937
+ if (scope !== undefined) {
4240
4938
  const hint = granted.size === 0 ? 'call connect() first' : 'not granted on connect';
4241
4939
  fail(requestId, { code: 'SCOPE_DENIED', message: `mock: ${method} requires scope '${scope}' — ${hint}` });
4242
4940
  return;