@cmodjs/core 0.1.11 → 0.1.12

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 (62) hide show
  1. package/docs/commands.md +16 -13
  2. package/docs/dependencies.md +8 -0
  3. package/docs/hooks.md +10 -1
  4. package/docs/index.md +4 -3
  5. package/docs/install-steps.md +19 -3
  6. package/docs/jobs.md +23 -7
  7. package/docs/mod.md +58 -3
  8. package/docs/testing.md +26 -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/mod.d.ts +11 -2
  23. package/mod.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/records.d.ts +4 -0
  26. package/records.d.ts.map +1 -1
  27. package/records.js +27 -6
  28. package/register.d.ts.map +1 -1
  29. package/register.js +3 -0
  30. package/runtime/claude.d.ts +7 -0
  31. package/runtime/claude.d.ts.map +1 -1
  32. package/runtime/hooks.js +4 -4
  33. package/runtime/lifecycle.d.ts +1 -0
  34. package/runtime/lifecycle.d.ts.map +1 -1
  35. package/runtime/lifecycle.js +80 -10
  36. package/runtime/state.d.ts +2 -1
  37. package/runtime/state.d.ts.map +1 -1
  38. package/runtime/state.js +12 -1
  39. package/src/jobs/check.ts +2 -2
  40. package/src/jobs/context.ts +5 -5
  41. package/src/jobs/permissions/decide-permission.ts +4 -4
  42. package/src/jobs/permissions/match-target.ts +24 -16
  43. package/src/jobs/permissions.ts +18 -7
  44. package/src/jobs/prompt.ts +31 -3
  45. package/src/mod.ts +15 -1
  46. package/src/records.ts +28 -8
  47. package/src/register.ts +3 -0
  48. package/src/runtime/claude.ts +3 -0
  49. package/src/runtime/hooks.ts +5 -5
  50. package/src/runtime/lifecycle.ts +85 -10
  51. package/src/runtime/state.ts +13 -1
  52. package/src/testing/fake-claude.ts +19 -3
  53. package/src/testing.ts +1 -0
  54. package/src/utils/call-effects.ts +13 -11
  55. package/testing/fake-claude.d.ts +9 -0
  56. package/testing/fake-claude.d.ts.map +1 -1
  57. package/testing/fake-claude.js +13 -2
  58. package/testing.d.ts.map +1 -1
  59. package/testing.js +1 -0
  60. package/utils/call-effects.d.ts +4 -8
  61. package/utils/call-effects.d.ts.map +1 -1
  62. package/utils/call-effects.js +6 -3
@@ -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';
@@ -320,7 +324,8 @@ export function createLifecycle(definition, checksPermissions = () => true) {
320
324
  };
321
325
  }
322
326
  async function createMod(definition, runtime) {
323
- const { claude, router, progress } = runtime;
327
+ const { router, progress } = runtime;
328
+ const claude = checkingKeys(runtime.claude, definition.name, runtime.keys);
324
329
  const added = [];
325
330
  const hookEvents = [];
326
331
  const announce = (feature) => {
@@ -337,15 +342,30 @@ async function createMod(definition, runtime) {
337
342
  name: definition.name,
338
343
  state: modState.state,
339
344
  dataFolder: runtime.dataFolder,
340
- on(event, hook) {
345
+ on(event, hook, options) {
346
+ const bounded = options?.timeoutMs === undefined ? hook : timedHook(definition.name, event, hook, checkedMs(definition.name, `the ${event} hook's timeoutMs`, options.timeoutMs), claude);
341
347
  if (!hookEvents.includes(event))
342
348
  hookEvents.push(event);
343
349
  if (event === 'PreToolUse')
344
- router.add('tool.call', preToolUseHook(definition.name, hook, claude, agents, held));
350
+ router.add('tool.call', preToolUseHook(definition.name, bounded, claude, agents, held));
345
351
  else
346
- router.add(`classic.${event}`, classicHook(definition.name, event, hook, claude, agents));
352
+ router.add(`classic.${event}`, classicHook(definition.name, event, bounded, claude, agents));
347
353
  },
348
354
  use: (job) => job({ mod, claude, on: (event, hook) => router.add(event, hook), announce, reserveName, toolCalls: agents }),
355
+ every(ms, hook) {
356
+ let isRunning = false;
357
+ return claude.clock.every(checkedMs(definition.name, 'mod.every', ms), () => {
358
+ if (isRunning)
359
+ return;
360
+ isRunning = true;
361
+ void Promise.resolve()
362
+ .then(hook)
363
+ .catch((error) => claude.ui.log(`${definition.name}: the every ${ms} ms hook failed: ${messageOf(error)}`))
364
+ .finally(() => {
365
+ isRunning = false;
366
+ });
367
+ });
368
+ },
349
369
  ui: area.ui,
350
370
  process: {
351
371
  run: (argv, init) => claude.process.run(argv, init),
@@ -360,8 +380,21 @@ async function createMod(definition, runtime) {
360
380
  },
361
381
  http: { fetch: (url, init) => claude.http.fetch(url, init) },
362
382
  settings: { read: (args) => claude.settings.read(args) },
363
- session: { messages: claude.session.messages },
383
+ session: {
384
+ messages: claude.session.messages,
385
+ async append(text) {
386
+ const added = await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } });
387
+ if (added.deny !== undefined)
388
+ throw new Error(`${definition.name}: Claude Code refused the note: ${added.deny}`);
389
+ },
390
+ async submit(text) {
391
+ const submitted = await claude.prompt.submit({ text });
392
+ if ('drop' in submitted && typeof submitted.drop === 'string')
393
+ throw new Error(`${definition.name}: Claude Code dropped the prompt: ${submitted.drop}`);
394
+ },
395
+ },
364
396
  agent: { spawn: (args) => claude.agent.spawn(args) },
397
+ model: { complete: (request, options) => claude.model.complete(request, options) },
365
398
  get projectRoot() {
366
399
  return modState.root;
367
400
  },
@@ -448,6 +481,43 @@ async function createMod(definition, runtime) {
448
481
  added.push(`${hookEvents.length === 1 ? 'a hook' : 'hooks'} on ${listed(hookEvents)}`);
449
482
  return { mod, added };
450
483
  }
484
+ function checkingKeys(claude, name, keys) {
485
+ const bound = Object.entries(keys);
486
+ if (bound.length === 0)
487
+ return claude;
488
+ const register = (command) => {
489
+ for (const [key, commandName] of bound) {
490
+ if (commandName === command.name && command.immediate !== true) {
491
+ 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.`);
492
+ }
493
+ }
494
+ return claude.command.register(command);
495
+ };
496
+ return { ...claude, command: { ...claude.command, register } };
497
+ }
498
+ const longestTimerMs = 2_147_483_647;
499
+ function checkedMs(name, subject, ms) {
500
+ if (!Number.isInteger(ms) || ms <= 0 || ms > longestTimerMs)
501
+ throw new Error(`${name}: ${subject} is ${ms}. Give a whole number of milliseconds above 0 and at most ${longestTimerMs}.`);
502
+ return ms;
503
+ }
504
+ function timedHook(name, event, hook, ms, claude) {
505
+ return (input) => new Promise((resolve, reject) => {
506
+ const timer = claude.clock.after(ms, () => {
507
+ claude.ui.log(`${name}: the ${event} hook passed its ${ms / 1000} s timeout`);
508
+ resolve(undefined);
509
+ });
510
+ Promise.resolve()
511
+ .then(() => hook(input))
512
+ .then((answer) => {
513
+ timer.cancel();
514
+ resolve(answer);
515
+ }, (error) => {
516
+ timer.cancel();
517
+ reject(error);
518
+ });
519
+ });
520
+ }
451
521
  async function failsAs(subject, step) {
452
522
  try {
453
523
  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
 
package/src/mod.ts CHANGED
@@ -10,6 +10,9 @@ import type {
10
10
  HookStream,
11
11
  HttpInit,
12
12
  HttpResponse,
13
+ ModelCompleteOptions,
14
+ ModelCompleteRequest,
15
+ ModelCompleteResult,
13
16
  PaneOpenArgs,
14
17
  PermissionRequestDecision,
15
18
  ProcessRunInit,
@@ -20,6 +23,7 @@ import type {
20
23
  RenderElement,
21
24
  Settings,
22
25
  SettingsReadArgs,
26
+ Timer,
23
27
  ToastOptions,
24
28
  UiScrollArgs,
25
29
  UiScrollResult,
@@ -117,12 +121,17 @@ export type HookInput<E extends ModEvent> = HookInputs[E]
117
121
 
118
122
  export type ModHook<E extends ModEvent> = (input: HookInput<E>) => HookAnswer | void | Promise<HookAnswer | void>
119
123
 
124
+ export type HookOptions = {
125
+ readonly timeoutMs?: number
126
+ }
127
+
120
128
  export type Mod<State extends object = Record<never, never>> = {
121
129
  readonly name: string
122
130
  readonly state: Readonly<State>
123
131
  readonly dataFolder: string
124
- on<E extends ModEvent>(event: E, hook: ModHook<E>): void
132
+ on<E extends ModEvent>(event: E, hook: ModHook<E>, options?: HookOptions): void
125
133
  use<Handle>(job: Job<Handle, State>): Handle
134
+ every(ms: number, hook: () => unknown): Timer
126
135
  readonly ui: {
127
136
  pane(pane: Pane<State>): PaneHandle
128
137
  render<S extends Slot>(slot: S, Component: (props: SlotProps<S>) => RenderElement): void
@@ -150,10 +159,15 @@ export type Mod<State extends object = Record<never, never>> = {
150
159
  }
151
160
  readonly session: {
152
161
  messages: Claude['session']['messages']
162
+ append(text: string): Promise<void>
163
+ submit(text: string): Promise<void>
153
164
  }
154
165
  readonly agent: {
155
166
  spawn(args: AgentSpawnArgs): Promise<AgentSpawnResult>
156
167
  }
168
+ readonly model: {
169
+ complete(request: ModelCompleteRequest, options?: ModelCompleteOptions): Promise<ModelCompleteResult>
170
+ }
157
171
  readonly projectRoot: string
158
172
  readonly cwd: string
159
173
  readonly dependencies: CmodDependencies
package/src/records.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { messageOf } from './utils/text.js'
1
+ import { listed, messageOf } from './utils/text.js'
2
2
 
3
3
  export type InstallRecord = {
4
4
  name: string
@@ -8,16 +8,17 @@ export type InstallRecord = {
8
8
  scriptsSha256: string
9
9
  uninstall: string | null
10
10
  program: string | null
11
+ keys: Readonly<Record<string, string>>
11
12
  }
12
13
 
13
- export type Steps = { install?: string; uninstall?: string; program?: string }
14
+ export type Steps = { install?: string; uninstall?: string; program?: string; keys?: Readonly<Record<string, string>> }
14
15
 
15
16
  export type ReadFile = (path: string) => Promise<string | undefined>
16
17
 
17
18
  export type RunnerEvent =
18
19
  | { kind: 'progress'; done: number; total: number; label: string }
19
20
  | { kind: 'log'; text: string }
20
- | { kind: 'needs-consent'; sha256: string; install: string; uninstall: string }
21
+ | { kind: 'needs-consent'; sha256: string; install: string; uninstall: string; keys: string }
21
22
  | { kind: 'done'; name: string; version?: string }
22
23
  | { kind: 'missing'; name: string }
23
24
  | { kind: 'failed'; code: number; message: string }
@@ -89,6 +90,8 @@ function parseRecord(text: string, path: string): InstallRecord {
89
90
  if (uninstall !== null && typeof uninstall !== 'string') throw new Error(`${path} has an "uninstall" that is neither a path nor null. ${fix}`)
90
91
  const program = value['program']
91
92
  if (program !== null && typeof program !== 'string') throw new Error(`${path} has a "program" that is neither a command name nor null. ${fix}`)
93
+ const keys = value['keys'] ?? {}
94
+ if (!isObject(keys) || Object.values(keys).some((command) => typeof command !== 'string')) throw new Error(`${path} has "keys" that are not key bindings. ${fix}`)
92
95
  return {
93
96
  name: value['name'] as string,
94
97
  version: value['version'] as string,
@@ -97,6 +100,7 @@ function parseRecord(text: string, path: string): InstallRecord {
97
100
  scriptsSha256: value['scriptsSha256'] as string,
98
101
  uninstall,
99
102
  program,
103
+ keys: keys as Record<string, string>,
100
104
  }
101
105
  }
102
106
 
@@ -108,7 +112,23 @@ export function readSteps(pkg: unknown): Steps | undefined {
108
112
  if (!isObject(steps)) throw new Error('package.json has a "cmod" key that is not an object. Write "cmod": { "install": "./setup/install.sh", "uninstall": "./setup/uninstall.sh" }.')
109
113
  const program = steps['program']
110
114
  if (program !== undefined && (typeof program !== 'string' || !pluginName.test(program))) throw new Error('package.json "cmod.program" must be the program\'s command, such as "hello".')
111
- return { ...readCommand(steps, 'install'), ...readCommand(steps, 'uninstall'), ...(program === undefined ? {} : { program }) }
115
+ return { ...readCommand(steps, 'install'), ...readCommand(steps, 'uninstall'), ...(program === undefined ? {} : { program }), ...readKeys(steps['keys']) }
116
+ }
117
+
118
+ function readKeys(keys: unknown): Steps {
119
+ if (keys === undefined) return {}
120
+ if (!isObject(keys) || Object.keys(keys).length === 0) throw new Error('package.json "cmod.keys" must bind each key to a command, such as "keys": { "shift+tab": "/mode" }.')
121
+ const commands: Record<string, string> = {}
122
+ for (const [key, value] of Object.entries(keys)) {
123
+ const command = typeof value === 'string' ? /^\/([A-Za-z0-9][A-Za-z0-9._:-]*)$/.exec(value)?.[1] : undefined
124
+ if (key.trim() === '' || command === undefined) throw new Error(`package.json "cmod.keys" binds "${key}" to ${JSON.stringify(value)}. Bind each key to one of the mod's commands, such as "shift+tab": "/mode".`)
125
+ commands[key] = command
126
+ }
127
+ return { keys: commands }
128
+ }
129
+
130
+ export function keyWords(keys: Readonly<Record<string, string>> | undefined): string {
131
+ return listed(Object.entries(keys ?? {}).map(([key, command]) => `${key} to /${command}`))
112
132
  }
113
133
 
114
134
  function readCommand(steps: Record<string, unknown>, key: 'install' | 'uninstall'): Steps {
@@ -145,7 +165,7 @@ export async function scriptsSha256(steps: Steps, files: { read: ReadFile; list:
145
165
  if (scriptFolders.length === 0) throw new Error(`package.json "cmod.${key}" runs "${command}", which names no script file in the mod, so consent cannot cover what it runs. Put the commands in a script, such as ./setup/${key}.sh.`)
146
166
  for (const folder of scriptFolders) folders.add(folder)
147
167
  }
148
- let text = [steps.install ?? '', steps.uninstall ?? '', steps.program ?? ''].join('\0')
168
+ let text = [steps.install ?? '', steps.uninstall ?? '', steps.program ?? '', ...(steps.keys === undefined ? [] : [JSON.stringify(Object.entries(steps.keys).sort())])].join('\0')
149
169
  for (const folder of [...folders].sort()) {
150
170
  for (const name of (await files.list(folder)).sort()) {
151
171
  const path = `${folder}/${name}`
@@ -165,7 +185,7 @@ export function formatEvent(event: RunnerEvent): string {
165
185
  case 'log':
166
186
  return `log ${event.text}`
167
187
  case 'needs-consent':
168
- return `needs-consent ${event.sha256}\t${event.install}\t${event.uninstall}`
188
+ return `needs-consent ${event.sha256}\t${event.install}\t${event.uninstall}\t${event.keys}`
169
189
  case 'done':
170
190
  return event.version === undefined ? `done ${event.name}` : `done ${event.name} ${event.version}`
171
191
  case 'missing':
@@ -178,8 +198,8 @@ export function formatEvent(event: RunnerEvent): string {
178
198
  export function parseEvent(line: string): RunnerEvent {
179
199
  const progress = /^progress (\d+) (\d+)(?: (.*))?$/.exec(line)
180
200
  if (progress !== null) return { kind: 'progress', done: Number(progress[1]), total: Number(progress[2]), label: progress[3] ?? '' }
181
- const consent = /^needs-consent (\S+)\t([^\t]*)\t(.*)$/.exec(line)
182
- if (consent !== null) return { kind: 'needs-consent', sha256: consent[1] as string, install: consent[2] as string, uninstall: consent[3] as string }
201
+ const consent = /^needs-consent (\S+)\t([^\t]*)\t([^\t]*)(?:\t(.*))?$/.exec(line)
202
+ if (consent !== null) return { kind: 'needs-consent', sha256: consent[1] as string, install: consent[2] as string, uninstall: consent[3] as string, keys: consent[4] ?? '' }
183
203
  const done = /^done (\S+)(?: (\S+))?$/.exec(line)
184
204
  if (done !== null) return { kind: 'done', name: done[1] as string, ...(done[2] === undefined ? {} : { version: done[2] }) }
185
205
  const missing = /^missing (\S+)$/.exec(line)
package/src/register.ts CHANGED
@@ -56,7 +56,10 @@ async function startMod($: EngineInterface, eventInput: Frozen<Args<'session.sta
56
56
  usage: () => $.session.usage(),
57
57
  surfaces: () => $.session.surfaces(),
58
58
  messages: ((args?: Parameters<Claude['session']['messages']>[0]) => (args === undefined ? $.session.messages() : $.session.messages(args))) as Claude['session']['messages'],
59
+ append: (args) => $.session.append(args),
59
60
  },
61
+ prompt: { submit: (args) => $.prompt.submit(args) },
62
+ model: { complete: (request, options) => $.model.complete(request, options) },
60
63
  command: { register: (command) => $.command.register(command) },
61
64
  tool: { register: (tool) => $.tool.register(tool) },
62
65
  agent: {
@@ -83,7 +83,10 @@ export type Claude = {
83
83
  usage(): Promise<SessionUsage>
84
84
  surfaces(): Promise<readonly RenderSurface[]>
85
85
  messages: EngineInterface['session']['messages']
86
+ append: EngineInterface['session']['append']
86
87
  }
88
+ readonly prompt: { submit: EngineInterface['prompt']['submit'] }
89
+ readonly model: { complete: EngineInterface['model']['complete'] }
87
90
  readonly command: { register(command: CommandSpec): Promise<{ command: string }> }
88
91
  readonly tool: { register(tool: ToolSpec): Promise<{ tool: string }> }
89
92
  readonly agent: {