@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.
- package/docs/commands.md +21 -14
- package/docs/dependencies.md +8 -0
- package/docs/hooks.md +10 -1
- package/docs/index.md +7 -4
- package/docs/install-steps.md +22 -4
- package/docs/jobs.md +55 -8
- package/docs/mod.md +58 -3
- package/docs/testing.md +29 -3
- package/jobs/context.d.ts +2 -2
- package/jobs/context.d.ts.map +1 -1
- package/jobs/context.js +2 -2
- package/jobs/permissions/decide-permission.d.ts +2 -2
- package/jobs/permissions/decide-permission.d.ts.map +1 -1
- package/jobs/permissions/match-target.d.ts +3 -2
- package/jobs/permissions/match-target.d.ts.map +1 -1
- package/jobs/permissions/match-target.js +24 -15
- package/jobs/permissions.d.ts +1 -1
- package/jobs/permissions.d.ts.map +1 -1
- package/jobs/permissions.js +18 -6
- package/jobs/prompt.d.ts.map +1 -1
- package/jobs/prompt.js +34 -3
- package/jobs/schedule.d.ts +7 -0
- package/jobs/schedule.d.ts.map +1 -0
- package/jobs/schedule.js +66 -0
- package/mod.d.ts +11 -2
- package/mod.d.ts.map +1 -1
- package/package.json +1 -1
- package/records.d.ts +5 -0
- package/records.d.ts.map +1 -1
- package/records.js +30 -6
- package/register.d.ts.map +1 -1
- package/register.js +4 -1
- package/runtime/claude.d.ts +8 -0
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/hooks.js +4 -4
- package/runtime/lifecycle.d.ts +1 -0
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +92 -16
- package/runtime/state.d.ts +2 -1
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +12 -1
- package/src/jobs/check.ts +2 -2
- package/src/jobs/context.ts +5 -5
- package/src/jobs/permissions/decide-permission.ts +4 -4
- package/src/jobs/permissions/match-target.ts +24 -16
- package/src/jobs/permissions.ts +18 -7
- package/src/jobs/prompt.ts +31 -3
- package/src/jobs/schedule.ts +65 -0
- package/src/mod.ts +15 -1
- package/src/records.ts +32 -8
- package/src/register.ts +5 -2
- package/src/runtime/claude.ts +4 -1
- package/src/runtime/hooks.ts +5 -5
- package/src/runtime/lifecycle.ts +101 -15
- package/src/runtime/state.ts +13 -1
- package/src/testing/fake-claude.ts +40 -3
- package/src/testing.ts +1 -0
- package/src/utils/call-effects.ts +13 -11
- package/testing/fake-claude.d.ts +18 -0
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +32 -2
- package/testing.d.ts.map +1 -1
- package/testing.js +1 -0
- package/utils/call-effects.d.ts +4 -8
- package/utils/call-effects.d.ts.map +1 -1
- 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;
|
|
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"}
|
package/runtime/lifecycle.js
CHANGED
|
@@ -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 {
|
|
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
|
|
175
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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,
|
|
356
|
+
router.add('tool.call', preToolUseHook(definition.name, bounded, claude, agents, held));
|
|
345
357
|
else
|
|
346
|
-
router.add(`classic.${event}`, classicHook(definition.name, event,
|
|
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: {
|
|
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();
|
package/runtime/state.d.ts
CHANGED
|
@@ -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
|
|
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
|
package/runtime/state.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
package/src/jobs/context.ts
CHANGED
|
@@ -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
|
|
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: (
|
|
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<
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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,
|
|
27
|
-
const { key, patterns } = targetOf(target)
|
|
28
|
-
const base = callBaseOf(
|
|
29
|
-
const only = (
|
|
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
|
-
|
|
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
|
|
42
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
53
|
-
if (key === 'subagent') return effects.subagent !== undefined &&
|
|
54
|
-
return
|
|
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[]] {
|
package/src/jobs/permissions.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|
package/src/jobs/prompt.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|