@cmodjs/core 0.1.11 → 0.1.13

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 (66) hide show
  1. package/docs/commands.md +21 -14
  2. package/docs/dependencies.md +8 -0
  3. package/docs/hooks.md +10 -1
  4. package/docs/index.md +7 -4
  5. package/docs/install-steps.md +22 -4
  6. package/docs/jobs.md +55 -8
  7. package/docs/mod.md +58 -3
  8. package/docs/testing.md +29 -3
  9. package/jobs/context.d.ts +2 -2
  10. package/jobs/context.d.ts.map +1 -1
  11. package/jobs/context.js +2 -2
  12. package/jobs/permissions/decide-permission.d.ts +2 -2
  13. package/jobs/permissions/decide-permission.d.ts.map +1 -1
  14. package/jobs/permissions/match-target.d.ts +3 -2
  15. package/jobs/permissions/match-target.d.ts.map +1 -1
  16. package/jobs/permissions/match-target.js +24 -15
  17. package/jobs/permissions.d.ts +1 -1
  18. package/jobs/permissions.d.ts.map +1 -1
  19. package/jobs/permissions.js +18 -6
  20. package/jobs/prompt.d.ts.map +1 -1
  21. package/jobs/prompt.js +34 -3
  22. package/jobs/schedule.d.ts +7 -0
  23. package/jobs/schedule.d.ts.map +1 -0
  24. package/jobs/schedule.js +66 -0
  25. package/mod.d.ts +11 -2
  26. package/mod.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/records.d.ts +5 -0
  29. package/records.d.ts.map +1 -1
  30. package/records.js +30 -6
  31. package/register.d.ts.map +1 -1
  32. package/register.js +4 -1
  33. package/runtime/claude.d.ts +8 -0
  34. package/runtime/claude.d.ts.map +1 -1
  35. package/runtime/hooks.js +4 -4
  36. package/runtime/lifecycle.d.ts +1 -0
  37. package/runtime/lifecycle.d.ts.map +1 -1
  38. package/runtime/lifecycle.js +92 -16
  39. package/runtime/state.d.ts +2 -1
  40. package/runtime/state.d.ts.map +1 -1
  41. package/runtime/state.js +12 -1
  42. package/src/jobs/check.ts +2 -2
  43. package/src/jobs/context.ts +5 -5
  44. package/src/jobs/permissions/decide-permission.ts +4 -4
  45. package/src/jobs/permissions/match-target.ts +24 -16
  46. package/src/jobs/permissions.ts +18 -7
  47. package/src/jobs/prompt.ts +31 -3
  48. package/src/jobs/schedule.ts +65 -0
  49. package/src/mod.ts +15 -1
  50. package/src/records.ts +32 -8
  51. package/src/register.ts +5 -2
  52. package/src/runtime/claude.ts +4 -1
  53. package/src/runtime/hooks.ts +5 -5
  54. package/src/runtime/lifecycle.ts +101 -15
  55. package/src/runtime/state.ts +13 -1
  56. package/src/testing/fake-claude.ts +40 -3
  57. package/src/testing.ts +1 -0
  58. package/src/utils/call-effects.ts +13 -11
  59. package/testing/fake-claude.d.ts +18 -0
  60. package/testing/fake-claude.d.ts.map +1 -1
  61. package/testing/fake-claude.js +32 -2
  62. package/testing.d.ts.map +1 -1
  63. package/testing.js +1 -0
  64. package/utils/call-effects.d.ts +4 -8
  65. package/utils/call-effects.d.ts.map +1 -1
  66. package/utils/call-effects.js +6 -3
@@ -1 +1 @@
1
- {"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../src/runtime/lifecycle.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,EAAiE,MAAM,aAAa,CAAA;AAC3H,OAAO,KAAK,EAAa,GAAG,EAAE,aAAa,EAAqB,MAAM,WAAW,CAAA;AAIjF,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAGzC,OAAO,EAAsG,KAAK,WAAW,EAAE,MAAM,YAAY,CAAA;AACjJ,OAAO,EAA6B,KAAK,UAAU,EAAE,MAAM,aAAa,CAAA;AAKxE,MAAM,MAAM,MAAM,GAAG;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,CAAA;IACpC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAA;IAC7B,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAA;CAC/B,CAAA;AAED,MAAM,MAAM,KAAK,GAAG,UAAU,GAAG,YAAY,GAAG,SAAS,GAAG,UAAU,GAAG,QAAQ,GAAG,OAAO,GAAG,QAAQ,CAAA;AAEtG,MAAM,MAAM,SAAS,CAAC,KAAK,SAAS,MAAM,IAAI;IAC5C,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;IACrB,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC,KAAK,CAAC,GAAG,SAAS,CAAA;IACpC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC/E,KAAK,CAAC,CAAC,SAAS,WAAW,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAA;CACzG,CAAA;AAiCD,wBAAsB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAyBhE;AAkCD,wBAAgB,eAAe,CAAC,KAAK,SAAS,MAAM,EAAE,UAAU,EAAE,aAAa,CAAC,KAAK,CAAC,EAAE,iBAAiB,GAAE,MAAM,OAAoB,GAAG,SAAS,CAAC,KAAK,CAAC,CAkOvJ"}
1
+ {"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../src/runtime/lifecycle.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,EAAiE,MAAM,aAAa,CAAA;AAC3H,OAAO,KAAK,EAAa,GAAG,EAAE,aAAa,EAAqB,MAAM,WAAW,CAAA;AAIjF,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAGzC,OAAO,EAAsG,KAAK,WAAW,EAAE,MAAM,YAAY,CAAA;AACjJ,OAAO,EAA6B,KAAK,UAAU,EAAE,MAAM,aAAa,CAAA;AAKxE,MAAM,MAAM,MAAM,GAAG;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,CAAA;IACpC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAA;IAC7B,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAA;IAC9B,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CAChD,CAAA;AAED,MAAM,MAAM,KAAK,GAAG,UAAU,GAAG,YAAY,GAAG,SAAS,GAAG,UAAU,GAAG,QAAQ,GAAG,OAAO,GAAG,QAAQ,CAAA;AAEtG,MAAM,MAAM,SAAS,CAAC,KAAK,SAAS,MAAM,IAAI;IAC5C,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;IACrB,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC,KAAK,CAAC,GAAG,SAAS,CAAA;IACpC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC/E,KAAK,CAAC,CAAC,SAAS,WAAW,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAA;CACzG,CAAA;AAkCD,wBAAsB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAyBhE;AAkCD,wBAAgB,eAAe,CAAC,KAAK,SAAS,MAAM,EAAE,UAAU,EAAE,aAAa,CAAC,KAAK,CAAC,EAAE,iBAAiB,GAAE,MAAM,OAAoB,GAAG,SAAS,CAAC,KAAK,CAAC,CAiPvJ"}
@@ -1,4 +1,4 @@
1
- import { dataFolder, isAtLeast, parseEvent, readRecord, readSteps, scriptsSha256, storeFolder } from '../records.js';
1
+ import { dataFolder, isAtLeast, oldestCmodFor, parseEvent, readRecord, readSteps, scriptsSha256, storeFolder } from '../records.js';
2
2
  import { relativePath } from '../utils/paths.js';
3
3
  import { formatExit, listed, messageOf } from '../utils/text.js';
4
4
  import { beforeDeadline } from './deadline.js';
@@ -30,12 +30,12 @@ export async function readPlugin(claude) {
30
30
  const manifest = (await readJson(read, `${root}/.claude-plugin/plugin.json`));
31
31
  const version = typeof manifest?.version === 'string' ? manifest.version : undefined;
32
32
  const store = storeFolder({ HOME: await claude.env.home(), XDG_DATA_HOME: await claude.env.dataHome() });
33
- const plugin = { name, root, version, store };
34
33
  if (name === cmodPluginName) {
35
34
  const installed = await cmodVersion(claude);
36
- return { ...plugin, isInstalled: version !== undefined && installed !== undefined && isAtLeast(installed, version), shouldRecord: false };
35
+ return { name, root, version, store, keys: {}, isInstalled: version !== undefined && installed !== undefined && isAtLeast(installed, version), shouldRecord: false };
37
36
  }
38
37
  const steps = readSteps(await readJson(read, `${root}/package.json`)) ?? {};
38
+ const plugin = { name, root, version, store, keys: steps.keys ?? {} };
39
39
  const record = await readRecord(read, store, name);
40
40
  if (Object.keys(steps).length === 0)
41
41
  return { ...plugin, isInstalled: true, shouldRecord: record?.version !== version };
@@ -136,7 +136,7 @@ export function createLifecycle(definition, checksPermissions = () => true) {
136
136
  if (runtime === undefined || plugin === undefined)
137
137
  return;
138
138
  const activeRouter = createRouter();
139
- const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), checksPermissions }).catch((error) => {
139
+ const active = await createMod(definition, { ...runtime, router: activeRouter, dataFolder: dataFolder(plugin.store, plugin.name), keys: plugin.keys, checksPermissions }).catch((error) => {
140
140
  fail(messageOf(error), 'Fix it, then run /reload-plugins.');
141
141
  throw error;
142
142
  });
@@ -171,8 +171,12 @@ export function createLifecycle(definition, checksPermissions = () => true) {
171
171
  return;
172
172
  }
173
173
  showLine().wait('Waiting for your answer');
174
- const removal = event.uninstall === '' ? '' : `, and ${event.uninstall} when you remove it`;
175
- const answer = await claude().ui.ask(`${name} runs ${event.install} to install${removal}. Run it now?`, { options: ['Install', 'Not now'], header: 'Install' });
174
+ const actions = [
175
+ ...(event.install === '' ? [] : [`runs ${event.install} to install`]),
176
+ ...(event.uninstall === '' ? [] : [`runs ${event.uninstall} when you remove it`]),
177
+ ...(event.keys === '' ? [] : [`binds ${event.keys}`]),
178
+ ];
179
+ const answer = await claude().ui.ask(`${name} ${listed(actions)}. Install it now?`, { options: ['Install', 'Not now'], header: 'Install' });
176
180
  if (answer === 'Install')
177
181
  return install(event.sha256);
178
182
  phase = 'declined';
@@ -185,8 +189,9 @@ export function createLifecycle(definition, checksPermissions = () => true) {
185
189
  if (plugin.name === cmodPluginName)
186
190
  return bootstrap(plugin.root);
187
191
  phase = 'installing';
188
- if ((await cmodVersion(claude())) === undefined)
189
- return waitForcmod();
192
+ const found = await cmodVersion(claude());
193
+ if (!fits(found))
194
+ return waitForcmod(found);
190
195
  endLine();
191
196
  const progress = showLine();
192
197
  let outcome;
@@ -205,9 +210,11 @@ export function createLifecycle(definition, checksPermissions = () => true) {
205
210
  const reason = outcome?.kind === 'failed' ? outcome.message : `cmod setup ${formatExit(code, lastError)}`;
206
211
  fail(reason, `Fix the cause, then run: cmod install ${definition.name}`);
207
212
  };
208
- const waitForcmod = () => {
213
+ const fits = (found) => found !== undefined && isAtLeast(found, oldestCmodFor(plugin?.keys ?? {}));
214
+ const waitForcmod = (found) => {
209
215
  phase = 'waiting';
210
216
  showLine().wait('Waiting for cmod');
217
+ let latest = found;
211
218
  let check;
212
219
  const stop = () => {
213
220
  timer.cancel();
@@ -217,7 +224,8 @@ export function createLifecycle(definition, checksPermissions = () => true) {
217
224
  check ??= cmodVersion(claude())
218
225
  .then((version) => {
219
226
  check = undefined;
220
- if (version === undefined)
227
+ latest = version;
228
+ if (!fits(version))
221
229
  return;
222
230
  stop();
223
231
  return install();
@@ -227,7 +235,9 @@ export function createLifecycle(definition, checksPermissions = () => true) {
227
235
  report(error);
228
236
  });
229
237
  });
230
- const longWait = claude().clock.after(cmodWaitMs, () => showLine().wait("Still waiting for cmod to download cmod. See cmod's own line."));
238
+ const longWait = claude().clock.after(cmodWaitMs, () => showLine().wait(latest === undefined
239
+ ? "Still waiting for cmod to download cmod. See cmod's own line."
240
+ : `PATH finds cmod ${latest}, and ${definition.name} needs cmod ${oldestCmodFor(plugin?.keys ?? {})} or later. Run npm i -g @cmodjs/cli, or put ~/.local/bin ahead of the old cmod on PATH.`));
231
241
  };
232
242
  const bootstrap = async (root) => {
233
243
  const progress = showLine();
@@ -320,7 +330,8 @@ export function createLifecycle(definition, checksPermissions = () => true) {
320
330
  };
321
331
  }
322
332
  async function createMod(definition, runtime) {
323
- const { claude, router, progress } = runtime;
333
+ const { router, progress } = runtime;
334
+ const claude = checkingKeys(runtime.claude, definition.name, runtime.keys);
324
335
  const added = [];
325
336
  const hookEvents = [];
326
337
  const announce = (feature) => {
@@ -337,15 +348,30 @@ async function createMod(definition, runtime) {
337
348
  name: definition.name,
338
349
  state: modState.state,
339
350
  dataFolder: runtime.dataFolder,
340
- on(event, hook) {
351
+ on(event, hook, options) {
352
+ const bounded = options?.timeoutMs === undefined ? hook : timedHook(definition.name, event, hook, checkedMs(definition.name, `the ${event} hook's timeoutMs`, options.timeoutMs), claude);
341
353
  if (!hookEvents.includes(event))
342
354
  hookEvents.push(event);
343
355
  if (event === 'PreToolUse')
344
- router.add('tool.call', preToolUseHook(definition.name, hook, claude, agents, held));
356
+ router.add('tool.call', preToolUseHook(definition.name, bounded, claude, agents, held));
345
357
  else
346
- router.add(`classic.${event}`, classicHook(definition.name, event, hook, claude, agents));
358
+ router.add(`classic.${event}`, classicHook(definition.name, event, bounded, claude, agents));
347
359
  },
348
360
  use: (job) => job({ mod, claude, on: (event, hook) => router.add(event, hook), announce, reserveName, toolCalls: agents }),
361
+ every(ms, hook) {
362
+ let isRunning = false;
363
+ return claude.clock.every(checkedMs(definition.name, 'mod.every', ms), () => {
364
+ if (isRunning)
365
+ return;
366
+ isRunning = true;
367
+ void Promise.resolve()
368
+ .then(hook)
369
+ .catch((error) => claude.ui.log(`${definition.name}: the every ${ms} ms hook failed: ${messageOf(error)}`))
370
+ .finally(() => {
371
+ isRunning = false;
372
+ });
373
+ });
374
+ },
349
375
  ui: area.ui,
350
376
  process: {
351
377
  run: (argv, init) => claude.process.run(argv, init),
@@ -360,8 +386,21 @@ async function createMod(definition, runtime) {
360
386
  },
361
387
  http: { fetch: (url, init) => claude.http.fetch(url, init) },
362
388
  settings: { read: (args) => claude.settings.read(args) },
363
- session: { messages: claude.session.messages },
389
+ session: {
390
+ messages: claude.session.messages,
391
+ async append(text) {
392
+ const added = await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } });
393
+ if (added.deny !== undefined)
394
+ throw new Error(`${definition.name}: Claude Code refused the note: ${added.deny}`);
395
+ },
396
+ async submit(text) {
397
+ const submitted = await claude.prompt.submit({ text });
398
+ if ('drop' in submitted && typeof submitted.drop === 'string')
399
+ throw new Error(`${definition.name}: Claude Code dropped the prompt: ${submitted.drop}`);
400
+ },
401
+ },
364
402
  agent: { spawn: (args) => claude.agent.spawn(args) },
403
+ model: { complete: (request, options) => claude.model.complete(request, options) },
365
404
  get projectRoot() {
366
405
  return modState.root;
367
406
  },
@@ -448,6 +487,43 @@ async function createMod(definition, runtime) {
448
487
  added.push(`${hookEvents.length === 1 ? 'a hook' : 'hooks'} on ${listed(hookEvents)}`);
449
488
  return { mod, added };
450
489
  }
490
+ function checkingKeys(claude, name, keys) {
491
+ const bound = Object.entries(keys);
492
+ if (bound.length === 0)
493
+ return claude;
494
+ const register = (command) => {
495
+ for (const [key, commandName] of bound) {
496
+ if (commandName === command.name && command.immediate !== true) {
497
+ claude.ui.log(`${name}: ${key} runs /${command.name}, which waits for Claude's turn to end and adds a row to the conversation. Give /${command.name} immediate: true.`);
498
+ }
499
+ }
500
+ return claude.command.register(command);
501
+ };
502
+ return { ...claude, command: { ...claude.command, register } };
503
+ }
504
+ const longestTimerMs = 2_147_483_647;
505
+ function checkedMs(name, subject, ms) {
506
+ if (!Number.isInteger(ms) || ms <= 0 || ms > longestTimerMs)
507
+ throw new Error(`${name}: ${subject} is ${ms}. Give a whole number of milliseconds above 0 and at most ${longestTimerMs}.`);
508
+ return ms;
509
+ }
510
+ function timedHook(name, event, hook, ms, claude) {
511
+ return (input) => new Promise((resolve, reject) => {
512
+ const timer = claude.clock.after(ms, () => {
513
+ claude.ui.log(`${name}: the ${event} hook passed its ${ms / 1000} s timeout`);
514
+ resolve(undefined);
515
+ });
516
+ Promise.resolve()
517
+ .then(() => hook(input))
518
+ .then((answer) => {
519
+ timer.cancel();
520
+ resolve(answer);
521
+ }, (error) => {
522
+ timer.cancel();
523
+ reject(error);
524
+ });
525
+ });
526
+ }
451
527
  async function failsAs(subject, step) {
452
528
  try {
453
529
  await step();
@@ -15,5 +15,6 @@ export type ModState<State extends object> = {
15
15
  moveTo(root: string): Promise<void>;
16
16
  switchSession(session: string, source: ClassicHookInputs['SessionStart']['source']): Promise<void>;
17
17
  };
18
- export declare function createState<State extends object>({ name, initial, session, root, claude, changed }: StateOptions): ModState<State>;
18
+ export declare function onStateChange(state: object, listener: () => void): void;
19
+ export declare function createState<State extends object>({ name, initial, session, root, claude, changed: redraw }: StateOptions): ModState<State>;
19
20
  //# sourceMappingURL=state.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../src/runtime/state.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAGpD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAQzC,MAAM,MAAM,YAAY,GAAG;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,IAAI,CAAA;CAC7B,CAAA;AAED,MAAM,MAAM,QAAQ,CAAC,KAAK,SAAS,MAAM,IAAI;IAC3C,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACrB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACnC,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,CAAC,cAAc,CAAC,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACnG,CAAA;AAQD,wBAAgB,WAAW,CAAC,KAAK,SAAS,MAAM,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,YAAY,GAAG,QAAQ,CAAC,KAAK,CAAC,CAqIlI"}
1
+ {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../src/runtime/state.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAGpD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAQzC,MAAM,MAAM,YAAY,GAAG;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,IAAI,CAAA;CAC7B,CAAA;AAED,MAAM,MAAM,QAAQ,CAAC,KAAK,SAAS,MAAM,IAAI;IAC3C,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACrB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACnC,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,iBAAiB,CAAC,cAAc,CAAC,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACnG,CAAA;AAUD,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAIvE;AAED,wBAAgB,WAAW,CAAC,KAAK,SAAS,MAAM,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,YAAY,GAAG,QAAQ,CAAC,KAAK,CAAC,CAyI1I"}
package/runtime/state.js CHANGED
@@ -3,7 +3,18 @@ import { messageOf } from '../utils/text.js';
3
3
  const lifetimes = ['memory', 'session', 'project', 'global'];
4
4
  const savedLifetimes = ['session', 'project', 'global'];
5
5
  const keptPerValue = 20;
6
- export function createState({ name, initial, session, root, claude, changed }) {
6
+ const listeners = new WeakMap();
7
+ export function onStateChange(state, listener) {
8
+ const own = listeners.get(state) ?? new Set();
9
+ own.add(listener);
10
+ listeners.set(state, own);
11
+ }
12
+ export function createState({ name, initial, session, root, claude, changed: redraw }) {
13
+ const changed = () => {
14
+ redraw();
15
+ for (const listener of listeners.get(state) ?? [])
16
+ listener();
17
+ };
7
18
  const declared = declaredGroups(name, initial);
8
19
  const current = copied(declared);
9
20
  let defaults = declared;
package/src/jobs/check.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Job } from '../mod.js'
2
2
  import { longestMs, type Deadline } from '../runtime/deadline.js'
3
- import { callEffects, type ToolUse } from '../utils/call-effects.js'
3
+ import { callEffects, type ToolCall } from '../utils/call-effects.js'
4
4
  import { dirname } from '../vendor.js'
5
5
  import { listed, messageOf } from '../utils/text.js'
6
6
  import { afterCall, modOf, targetWords } from './context.js'
@@ -39,7 +39,7 @@ export function check<State extends object = Record<never, never>>(options: { re
39
39
  }
40
40
  }
41
41
 
42
- async function triggeredRun(after: readonly Target[], run: readonly string[], use: ToolUse, workspace: Workspace): Promise<{ command: string[]; folder: string } | undefined> {
42
+ async function triggeredRun(after: readonly Target[], run: readonly string[], use: ToolCall, workspace: Workspace): Promise<{ command: string[]; folder: string } | undefined> {
43
43
  const effects = callEffects(use, workspace)
44
44
  const paths = new Set<string>()
45
45
  let commandFolder: string | undefined
@@ -5,7 +5,7 @@ import { beforeDeadline, type Deadline } from '../runtime/deadline.js'
5
5
  import { dependencyCalls } from '../runtime/dependencies.js'
6
6
  import { toolInputOf } from '../runtime/tool-calls.js'
7
7
  import { listed } from '../utils/text.js'
8
- import type { ToolCall, ToolUse } from '../utils/call-effects.js'
8
+ import type { ToolCall } from '../utils/call-effects.js'
9
9
  import { findProjectScope, type Workspace } from './permissions/find-project-scope.js'
10
10
  import { targetOf, type Target } from './permissions/match-target.js'
11
11
  import type { FileSystem } from '../utils/paths.js'
@@ -36,7 +36,7 @@ async function readSession(claude: Claude): Promise<Session> {
36
36
 
37
37
  export function afterCall<State extends object>(
38
38
  job: JobContext<State>,
39
- contextAfter: (use: ToolUse, workspace: Workspace) => Promise<readonly string[]>,
39
+ contextAfter: (call: ToolCall, workspace: Workspace) => Promise<readonly string[]>,
40
40
  failed: (error: unknown) => readonly string[],
41
41
  ): void {
42
42
  const readWorkspace = workspaceReader(job)
@@ -50,7 +50,7 @@ export function afterCall<State extends object>(
50
50
  })
51
51
  }
52
52
 
53
- async function useOf({ toolCalls }: JobContext, e: Frozen<Args<'tool.call'>>): Promise<ToolUse> {
53
+ async function useOf({ toolCalls }: JobContext, e: Frozen<Args<'tool.call'>>): Promise<ToolCall> {
54
54
  return { tool: e.tool, input: toolInputOf(e), ...(await toolCalls.agentOf(e.tool_use_id)) }
55
55
  }
56
56
 
@@ -109,8 +109,8 @@ function spawnWithin(claude: Claude, deadline: Deadline, started: Promise<Stream
109
109
  }
110
110
 
111
111
  export function targetWords(target: Target): string {
112
- const { key, patterns } = targetOf(target)
113
- const named = listed(patterns)
112
+ const { key, patterns, exclusions } = targetOf(target)
113
+ const named = exclusions.length === 0 ? listed(patterns) : `${listed(patterns)} except ${listed(exclusions)}`
114
114
  if (key === 'command') return patterns.includes('*') ? 'every shell command' : named
115
115
  if (key === 'write') return `edits to ${named}`
116
116
  if (key === 'read') return `reads of ${named}`
@@ -1,6 +1,6 @@
1
1
  import { strictness } from '../../runtime/hooks.js'
2
2
  import { messageOf } from '../../utils/text.js'
3
- import { callBaseOf, callEffects, type CallEffects, type ToolCall, type ToolUse } from '../../utils/call-effects.js'
3
+ import { callBaseOf, callEffects, type CallEffects, type ToolCall } from '../../utils/call-effects.js'
4
4
  import type { Workspace } from './find-project-scope.js'
5
5
  import { folderInScope, matchTarget, targetOf, type MatchedCall, type Target, type TargetCalls, type TargetKey, type TargetOf } from './match-target.js'
6
6
  import { resolve } from '../../vendor.js'
@@ -22,7 +22,7 @@ export function stricterVerdict<Result extends Verdict>(first: Result, second: R
22
22
  return strictness.indexOf(second.decision) > strictness.indexOf(first.decision) ? second : first
23
23
  }
24
24
 
25
- export async function decidePermission<Mod>(rules: PermissionRules<Mod>, use: ToolUse, workspace: Workspace, modOf: (call: ToolCall, folder: string) => Mod): Promise<Verdict | undefined> {
25
+ export async function decidePermission<Mod>(rules: PermissionRules<Mod>, use: ToolCall, workspace: Workspace, modOf: (call: ToolCall, folder: string) => Mod): Promise<Verdict | undefined> {
26
26
  try {
27
27
  const effects = callEffects(use, workspace)
28
28
  for (const decision of ['deny', 'ask'] as const) {
@@ -39,7 +39,7 @@ export async function decidePermission<Mod>(rules: PermissionRules<Mod>, use: To
39
39
  }
40
40
  }
41
41
 
42
- async function ruleOutcome<Mod>(rule: Rule<Mod>, use: ToolUse, effects: CallEffects, workspace: Workspace, modOf: (call: ToolCall, folder: string) => Mod): Promise<{ failure: string | undefined } | undefined> {
42
+ async function ruleOutcome<Mod>(rule: Rule<Mod>, use: ToolCall, effects: CallEffects, workspace: Workspace, modOf: (call: ToolCall, folder: string) => Mod): Promise<{ failure: string | undefined } | undefined> {
43
43
  const matched = await matchTarget(rule, use, effects, workspace)
44
44
  const calls = matched.length > 0 ? matched : await unparsedMatches(rule, use, effects, workspace)
45
45
  if (calls.length === 0) return undefined
@@ -56,7 +56,7 @@ async function ruleOutcome<Mod>(rule: Rule<Mod>, use: ToolUse, effects: CallEffe
56
56
  return undefined
57
57
  }
58
58
 
59
- async function unparsedMatches(target: Target, use: ToolUse, effects: CallEffects, workspace: Workspace): Promise<MatchedCall[]> {
59
+ async function unparsedMatches(target: Target, use: ToolCall, effects: CallEffects, workspace: Workspace): Promise<MatchedCall[]> {
60
60
  const shell = effects.shell
61
61
  if (shell === undefined || shell.isFullyParsed) return []
62
62
  const { key, patterns } = targetOf(target)
@@ -1,6 +1,6 @@
1
1
  import { matchesGlob } from '../../utils/matches-glob.js'
2
2
  import { parseShell } from '../../utils/parse-shell.js'
3
- import { callBaseOf, type CallBase, type CallEffects, type CommandCall, type FetchCall, type FileAccess, type FileCall, type ToolCall, type ToolUse } from '../../utils/call-effects.js'
3
+ import { callBaseOf, type CallBase, type CallEffects, type CommandCall, type FetchCall, type FileAccess, type FileCall, type ToolCall } from '../../utils/call-effects.js'
4
4
  import type { Workspace } from './find-project-scope.js'
5
5
  import { realPathOf, relativePath } from '../../utils/paths.js'
6
6
  import { basename } from '../../vendor.js'
@@ -13,45 +13,53 @@ export type MatchedCall = { call: ToolCall; access: FileAccess | undefined; fold
13
13
 
14
14
  const targetKeys: readonly TargetKey[] = ['command', 'read', 'write', 'fetch', 'subagent', 'tool']
15
15
 
16
- export function targetOf(target: Target): { key: TargetKey; patterns: string[] } {
16
+ export function targetOf(target: Target): { key: TargetKey; patterns: string[]; exclusions: string[] } {
17
17
  const keys = targetKeys.filter((key) => key in target)
18
18
  const key = keys[0]
19
19
  const value: unknown = key === undefined ? undefined : (target as Partial<Record<TargetKey, unknown>>)[key]
20
20
  if (key === undefined || keys.length > 1 || !(typeof value === 'string' || (Array.isArray(value) && value.every((pattern) => typeof pattern === 'string')))) {
21
21
  throw new Error(`A rule names exactly one of ${targetKeys.join(', ')} with a pattern or a list of patterns; this one names ${keys.join(' and ') || 'none'}.`)
22
22
  }
23
- return { key, patterns: typeof value === 'string' ? [value] : value }
23
+ const all = typeof value === 'string' ? [value] : value
24
+ const patterns = all.filter((pattern) => !pattern.startsWith('!'))
25
+ if (patterns.length === 0) throw new Error(`A ${key} target needs a pattern without "!" for its "!" patterns to leave calls out of, such as ['**', ${all.map((pattern) => `'${pattern}'`).join(', ')}].`)
26
+ return { key, patterns, exclusions: all.filter((pattern) => pattern.startsWith('!')).map((pattern) => pattern.slice(1)) }
24
27
  }
25
28
 
26
- export async function matchTarget(target: Target, use: ToolUse, effects: CallEffects, workspace: Workspace): Promise<MatchedCall[]> {
27
- const { key, patterns } = targetOf(target)
28
- const base = callBaseOf(use)
29
- const only = (call: ToolCall, folder = workspace.cwd): MatchedCall[] => [{ call, access: undefined, folder }]
29
+ export async function matchTarget(target: Target, call: CallBase, effects: CallEffects, workspace: Workspace): Promise<MatchedCall[]> {
30
+ const { key, patterns, exclusions } = targetOf(target)
31
+ const base = callBaseOf(call)
32
+ const only = (matched: ToolCall, folder = workspace.cwd): MatchedCall[] => [{ call: matched, access: undefined, folder }]
33
+ const isNamed = (value: string) => matchesAny(value, patterns) && !matchesAny(value, exclusions)
30
34
  if (key === 'read' || key === 'write') {
31
35
  const matched: MatchedCall[] = []
32
36
  for (const access of key === 'read' ? effects.reads : effects.writes) {
33
37
  const path = await matchedPath(patterns, access.path, workspace)
34
- if (path !== undefined) matched.push({ call: { ...base, path }, access, folder: workspace.cwd })
38
+ const isExcluded = path !== undefined && exclusions.length > 0 && (await matchedPath(exclusions, access.path, workspace)) !== undefined
39
+ if (path !== undefined && !isExcluded) matched.push({ call: { ...base, path }, access, folder: workspace.cwd })
35
40
  }
36
41
  return matched
37
42
  }
38
43
  if (key === 'command') {
39
44
  const shell = effects.shell
40
45
  if (shell === undefined) return []
41
- const call = { ...base, commands: shell.commands.map((command) => command.argv), isFullyParsed: shell.isFullyParsed }
42
- if (patterns.includes('*')) {
46
+ const commandCall = { ...base, commands: shell.commands.map((command) => command.argv), isFullyParsed: shell.isFullyParsed }
47
+ const isEvery = patterns.includes('*')
48
+ if (isEvery && exclusions.length === 0) {
43
49
  const folder = await folderInScope(shell, workspace)
44
- return folder === undefined ? [] : only(call, folder)
50
+ return folder === undefined ? [] : only(commandCall, folder)
45
51
  }
46
- const wanted = patterns.map(commandPattern)
52
+ const wanted = patterns.filter((pattern) => pattern !== '*').map(commandPattern)
53
+ const unwanted = exclusions.map(commandPattern)
47
54
  for (const command of shell.commands) {
48
- if (wanted.some((pattern) => commandMatches(pattern, command.argv)) && (await isInScope(command.folder, workspace))) return only(call, command.folder)
55
+ const isWanted = isEvery || wanted.some((pattern) => commandMatches(pattern, command.argv))
56
+ if (isWanted && !unwanted.some((pattern) => commandMatches(pattern, command.argv)) && (await isInScope(command.folder, workspace))) return only(commandCall, command.folder)
49
57
  }
50
58
  return []
51
59
  }
52
- if (key === 'fetch') return effects.urls.filter((url) => matchesAny(url, patterns)).map((url) => ({ call: { ...base, url }, access: undefined, folder: workspace.cwd }))
53
- if (key === 'subagent') return effects.subagent !== undefined && matchesAny(effects.subagent, patterns) ? only(base) : []
54
- return matchesAny(use.tool, patterns) ? only(base) : []
60
+ if (key === 'fetch') return effects.urls.filter(isNamed).map((url) => ({ call: { ...base, url }, access: undefined, folder: workspace.cwd }))
61
+ if (key === 'subagent') return effects.subagent !== undefined && isNamed(effects.subagent) ? only(base) : []
62
+ return isNamed(call.tool) ? only(base) : []
55
63
  }
56
64
 
57
65
  function commandPattern(pattern: string): [string, ...string[]] {
@@ -1,7 +1,7 @@
1
1
  import type { Args, Frozen } from 'claude-code'
2
2
  import type { Job, Mod } from '../mod.js'
3
3
  import type { Deadline } from '../runtime/deadline.js'
4
- import type { ToolUse } from '../utils/call-effects.js'
4
+ import { inputOf } from '../utils/call-effects.js'
5
5
  import { decidePermission, stricterVerdict, type PermissionRules as RulesFor, type Rule as RuleFor, type Verdict } from './permissions/decide-permission.js'
6
6
  import { targetOf } from './permissions/match-target.js'
7
7
  import { messageOf } from '../utils/text.js'
@@ -14,19 +14,24 @@ export type PermissionRules<State extends object = Record<never, never>> = Rules
14
14
 
15
15
  const deadline: Deadline = { ms: 2000, job: 'permissions' }
16
16
 
17
- export function permissions<State extends object = Record<never, never>>(rules: PermissionRules<State>): Job<void, State> {
18
- const count = (rules.deny?.length ?? 0) + (rules.ask?.length ?? 0)
17
+ export function permissions<State extends object = Record<never, never>>(rules: PermissionRules<State> | ((state: State) => PermissionRules<State>)): Job<void, State> {
18
+ const count = typeof rules === 'function' ? undefined : checkedCount(rules)
19
19
  if (count === 0) throw new Error('permissions: give it a deny or an ask rule, or remove it from setup.')
20
- for (const rule of [...(rules.deny ?? []), ...(rules.ask ?? [])]) targetOf(rule)
21
20
 
22
21
  return (job) => {
23
22
  const readWorkspace = workspaceReader(job)
24
23
 
25
24
  const verdictOf = async (e: Frozen<Args<'tool.check'>>): Promise<Verdict | undefined> => {
25
+ let current: PermissionRules<State>
26
+ try {
27
+ current = typeof rules === 'function' ? rules(job.mod.state as State) : rules
28
+ checkedCount(current)
29
+ } catch (error) {
30
+ return { decision: 'deny', reason: `The permission rules of ${job.mod.name} failed, so it denies the call: ${messageOf(error)}` }
31
+ }
26
32
  try {
27
33
  const [agent, workspace] = await Promise.all([job.toolCalls.agentOf(e.tool_use_id), readWorkspace()])
28
- const use: ToolUse = { tool: e.tool, input: e.input, ...agent }
29
- return await decidePermission(rules, use, workspace, (call, folder) => modOf(job, { call, folder }, workspace, deadline))
34
+ return await decidePermission(current, { tool: e.tool, input: inputOf(e.input), ...agent }, workspace, (call, folder) => modOf(job, { call, folder }, workspace, deadline))
30
35
  } catch (error) {
31
36
  return { decision: 'deny', reason: `The permissions job could not read the session, so it denies the call: ${messageOf(error)}` }
32
37
  }
@@ -36,6 +41,12 @@ export function permissions<State extends object = Record<never, never>>(rules:
36
41
  const [below, ours] = await Promise.all([next(e), verdictOf(e)])
37
42
  return ours === undefined ? below : stricterVerdict<Verdict>(below, ours)
38
43
  })
39
- job.announce(`${count} permission rule${count === 1 ? '' : 's'}`)
44
+ job.announce(count === undefined ? 'permission rules from its state' : `${count} permission rule${count === 1 ? '' : 's'}`)
40
45
  }
41
46
  }
47
+
48
+ function checkedCount<Mod>(rules: RulesFor<Mod>): number {
49
+ const all = [...(rules.deny ?? []), ...(rules.ask ?? [])]
50
+ for (const rule of all) targetOf(rule)
51
+ return all.length
52
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Job, JobContext, Mod } from '../mod.js'
2
2
  import { longestMs, type Deadline } from '../runtime/deadline.js'
3
+ import { onStateChange } from '../runtime/state.js'
3
4
  import { listed, messageOf } from '../utils/text.js'
4
5
  import { callEffects, type ToolCall } from '../utils/call-effects.js'
5
6
  import { matchTarget, targetOf, type Target } from './permissions/match-target.js'
@@ -48,21 +49,48 @@ export function prompt<State extends object = Record<never, never>>({ name, prom
48
49
  addAfterCalls(job, targets, headedText, log)
49
50
  job.announce(`the ${name} prompt after ${listed(targets.map(targetWords))}`)
50
51
  } else {
51
- addOncePerConversation(job, name, () => textFor({}, modWithin(job, deadline)), log)
52
+ addKeptCurrent(job, name, () => textFor({}, modWithin(job, deadline)), log)
52
53
  job.announce(`the ${name} prompt`)
53
54
  }
54
55
  }
55
56
  }
56
57
 
57
- function addOncePerConversation(job: JobContext, name: string, textFor: () => Promise<string | undefined>, log: (reason: string) => void): void {
58
+ function addKeptCurrent<State extends object>(job: JobContext<State>, name: string, textFor: () => Promise<string | undefined>, log: (reason: string) => void): void {
59
+ let given: string | undefined
60
+ let hasStarted = false
61
+ let isQueued = false
62
+ let updates = Promise.resolve()
63
+
64
+ job.on('classic.SessionStart', (e, next) => {
65
+ hasStarted = e.source === 'resume' || e.source === 'compact'
66
+ if (!hasStarted) given = undefined
67
+ return next(e)
68
+ })
58
69
  job.on('prompt.context', async (e, next) => {
59
70
  const below = await next(e)
71
+ hasStarted = true
60
72
  if (below.blocks.some((block) => block.name === name)) {
61
73
  log(`a block named "${name}" is already in the context`)
62
74
  return below
63
75
  }
76
+ given = await textFor()
77
+ return given === undefined ? below : { ...below, blocks: [...below.blocks, { name, text: given }] }
78
+ })
79
+
80
+ const update = async () => {
81
+ isQueued = false
82
+ if (!hasStarted) return
64
83
  const value = await textFor()
65
- return value === undefined ? below : { ...below, blocks: [...below.blocks, { name, text: value }] }
84
+ if (value === undefined || value === given) return
85
+ given = value
86
+ await job.claude.session.append({ message: { type: 'user', content: [{ type: 'text', text: `# ${name}\n${value}` }] } }).then((added) => {
87
+ if (added.deny !== undefined) log(`Claude Code refused the new text: ${added.deny}`)
88
+ })
89
+ }
90
+ onStateChange(job.mod.state, () => {
91
+ if (isQueued) return
92
+ isQueued = true
93
+ updates = updates.then(update).catch((error: unknown) => log(messageOf(error)))
66
94
  })
67
95
  }
68
96
 
@@ -0,0 +1,65 @@
1
+ import type { Job } from '../mod.js'
2
+ import { onStateChange } from '../runtime/state.js'
3
+ import { messageOf } from '../utils/text.js'
4
+
5
+ export type Schedule = { readonly cron: string; readonly prompt: string }
6
+
7
+ const scheduleCounts = new WeakMap<object, number>()
8
+
9
+ export function schedule<State extends object = Record<never, never>>(entry: Schedule | ((state: State) => Schedule | undefined)): Job<void, State> {
10
+ if (typeof entry !== 'function') checked(entry)
11
+
12
+ return (job) => {
13
+ const { mod, claude } = job
14
+ const order = scheduleCounts.get(mod) ?? 0
15
+ scheduleCounts.set(mod, order + 1)
16
+ const storeKey = `cmod:schedule:${order}`
17
+ const log = (reason: string) => claude.ui.log(`${mod.name}: the schedule ${reason}`)
18
+ let current: { readonly id: string; readonly wanted: string } | undefined
19
+ let isQueued = false
20
+
21
+ const remove = async (id: string) => {
22
+ await claude.tool.call({ tool: 'CronDelete', id }).catch(() => undefined)
23
+ await claude.store.delete(storeKey)
24
+ }
25
+
26
+ const update = async () => {
27
+ isQueued = false
28
+ let next: Schedule | undefined
29
+ try {
30
+ next = typeof entry === 'function' ? entry(mod.state as State) : entry
31
+ if (next !== undefined) checked(next)
32
+ } catch (error) {
33
+ log(`kept its last cron: ${messageOf(error)}`)
34
+ return
35
+ }
36
+ const wanted = next === undefined ? undefined : JSON.stringify([next.cron, next.prompt])
37
+ if (wanted === current?.wanted) return
38
+ if (current !== undefined) await remove(current.id)
39
+ current = undefined
40
+ if (next === undefined) return
41
+ const answer = await claude.tool.call({ tool: 'CronCreate', cron: next.cron, prompt: next.prompt, recurring: true })
42
+ if (answer.deny !== undefined) return log(`was refused: ${answer.deny}`)
43
+ if (answer.isError === true) return log(`failed: ${answer.text ?? 'CronCreate answered an error'}`)
44
+ current = { id: answer.result.id, wanted: wanted as string }
45
+ await claude.store.set(storeKey, current.id)
46
+ }
47
+
48
+ let updates = claude.store
49
+ .get(storeKey)
50
+ .then((left) => (typeof left === 'string' ? remove(left) : undefined))
51
+ .then(update)
52
+ .catch((error: unknown) => log(`failed: ${messageOf(error)}`))
53
+ onStateChange(mod.state, () => {
54
+ if (isQueued) return
55
+ isQueued = true
56
+ updates = updates.then(update).catch((error: unknown) => log(`failed: ${messageOf(error)}`))
57
+ })
58
+ job.announce(typeof entry === 'function' ? 'a schedule from its state' : `a schedule at ${entry.cron}`)
59
+ }
60
+ }
61
+
62
+ function checked(entry: Schedule): void {
63
+ if (entry.cron.trim() === '') throw new Error('schedule: give it a cron, such as "*/10 * * * *" for every 10 minutes.')
64
+ if (entry.prompt.trim() === '') throw new Error('schedule: give it the prompt Claude gets at each fire.')
65
+ }