@cmodjs/core 2.0.0 → 2.1.0

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 (58) hide show
  1. package/docs/commands.md +4 -4
  2. package/docs/dependencies.md +1 -1
  3. package/docs/jobs.md +1 -1
  4. package/docs/mod.md +2 -3
  5. package/docs/permissions.md +1 -1
  6. package/docs/session.md +3 -1
  7. package/docs/testing.md +2 -1
  8. package/jobs/permissions.d.ts.map +1 -1
  9. package/jobs/permissions.js +4 -1
  10. package/package.json +1 -1
  11. package/records.d.ts +1 -1
  12. package/records.js +1 -1
  13. package/register.d.ts.map +1 -1
  14. package/register.js +5 -4
  15. package/runtime/before.d.ts +8 -4
  16. package/runtime/before.d.ts.map +1 -1
  17. package/runtime/before.js +63 -39
  18. package/runtime/conversations.d.ts.map +1 -1
  19. package/runtime/conversations.js +38 -13
  20. package/runtime/engine.d.ts +1 -0
  21. package/runtime/engine.d.ts.map +1 -1
  22. package/runtime/hooks.d.ts +9 -23
  23. package/runtime/hooks.d.ts.map +1 -1
  24. package/runtime/hooks.js +4 -59
  25. package/runtime/lifecycle.d.ts +2 -1
  26. package/runtime/lifecycle.d.ts.map +1 -1
  27. package/runtime/lifecycle.js +36 -23
  28. package/runtime/permission-check.d.ts +15 -0
  29. package/runtime/permission-check.d.ts.map +1 -0
  30. package/runtime/permission-check.js +37 -0
  31. package/runtime/router.d.ts +0 -1
  32. package/runtime/router.d.ts.map +1 -1
  33. package/runtime/router.js +0 -1
  34. package/runtime/sessions.d.ts +13 -8
  35. package/runtime/sessions.d.ts.map +1 -1
  36. package/runtime/sessions.js +44 -80
  37. package/runtime/state.d.ts.map +1 -1
  38. package/runtime/state.js +14 -11
  39. package/session/context/turn/message/message.d.ts +1 -0
  40. package/session/context/turn/message/message.d.ts.map +1 -1
  41. package/session/context/turn/message/message.js +5 -2
  42. package/src/jobs/permissions.ts +3 -1
  43. package/src/records.ts +1 -1
  44. package/src/register.ts +5 -4
  45. package/src/runtime/before.ts +66 -42
  46. package/src/runtime/conversations.ts +35 -13
  47. package/src/runtime/engine.ts +1 -0
  48. package/src/runtime/hooks.ts +13 -86
  49. package/src/runtime/lifecycle.ts +51 -37
  50. package/src/runtime/permission-check.ts +47 -0
  51. package/src/runtime/router.ts +0 -2
  52. package/src/runtime/sessions.ts +55 -84
  53. package/src/runtime/state.ts +15 -8
  54. package/src/session/context/turn/message/message.ts +6 -2
  55. package/src/testing.ts +17 -10
  56. package/testing.d.ts +7 -2
  57. package/testing.d.ts.map +1 -1
  58. package/testing.js +13 -7
@@ -10,7 +10,7 @@ import { GoalChange } from '../session/goal/goal.js';
10
10
  import { TaskEnd, TaskStart } from '../session/goal/task.js';
11
11
  import { beforeEvents, processEvents, Session, sessionEvents } from '../session/session.js';
12
12
  import { matches, sentMatcher } from '../utils/matcher.js';
13
- import { foldAnswers } from './before.js';
13
+ import { beforeAnswerKeys, foldAnswers, foldBefore, methodOf, takenAnswer } from './before.js';
14
14
  import { cmodPluginName } from './dependencies.js';
15
15
  import { messageOf } from '../utils/text.js';
16
16
  import { listenerOf, PassedTimeout, withinTime } from './deadline.js';
@@ -36,7 +36,6 @@ const actPermissions = { send: 'prompt', startTask: 'tasks', usage: 'conversatio
36
36
  const startsTurns = ['CronCreate'];
37
37
  const ownSchedule = ['CronDelete', 'CronList'];
38
38
  export const beforePermission = (event) => (event === 'toolCall' ? 'tools' : event === TaskStart ? 'tasks' : 'prompt');
39
- const methodOf = (prefix, event) => `${prefix}${Object.entries(sessionEvents).find(([, each]) => each === event)?.[0] ?? event}`;
40
39
  export const isAct = (action) => action === 'callTool' || action in actPermissions;
41
40
  export function toolPermission(tool) {
42
41
  return startsTurns.includes(tool) ? 'prompt' : ownSchedule.includes(tool) ? undefined : 'tools';
@@ -113,7 +112,10 @@ export function isInScope(scope, session, change, isUnder) {
113
112
  return change.message.turn === scope.turn;
114
113
  return (change.type === TurnStart || change.type === TurnEnd) && change.turn.prompt.id === scope.turn;
115
114
  }
116
- export function createSessionsClient({ name, claude, report, isGranted, hookOn, routeOn }) {
115
+ export function withoutPermissionCheck(name, hearer) {
116
+ return `${name}: ${hearer} hears Claude Code's permission check, so hooks/register.ts must call registerPermissionCheck(addHook) after registerMod.`;
117
+ }
118
+ export function createSessionsClient({ name, claude, report, isGranted, hookOn, routeOn, permissionRoutes }) {
117
119
  const call = (method, input) => claude.cmod.call({ to: cmodPluginName, method, input });
118
120
  const listening = new Map();
119
121
  const deciding = new Map();
@@ -176,6 +178,12 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
176
178
  },
177
179
  };
178
180
  };
181
+ const invoke = (method, options, run, takes = []) => withinTime(claude.clock, method, options, run).catch((error) => {
182
+ if (error instanceof PassedTimeout && !method.startsWith('before'))
183
+ return undefined;
184
+ report(`session:${method}`, `The ${method} listener failed`, error);
185
+ return options.onFailure === 'refuse' && takes.includes('refuse') ? { refuse: `${name}: ${messageOf(error)}` } : undefined;
186
+ });
179
187
  const combined = async (session, event, change, isInProject) => {
180
188
  const askers = [...(deciding.get(session) ?? []), ...(isInProject ? (deciding.get(everySession) ?? []) : [])]
181
189
  .filter((entry) => entry.event === event)
@@ -185,17 +193,12 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
185
193
  if (entry.matcher !== undefined && !matches(payloadOf(latest), entry.matcher))
186
194
  return undefined;
187
195
  const payload = await payloadFor(session, latest);
188
- const answer = await withinTime(claude.clock, entry.method, entry.options, (time) => entry.listener(payload, time)).catch((error) => {
189
- if (entry.options.onFailure !== 'refuse')
190
- throw error;
191
- report(`session:${entry.method}`, `The ${entry.method} listener failed`, error);
192
- return { refuse: `${name}: ${messageOf(error)}` };
193
- });
196
+ const answer = await invoke(entry.method, entry.options, async (time) => takenAnswer(entry.method, beforeAnswerKeys[event], await entry.listener(payload, time)), beforeAnswerKeys[event]);
194
197
  if (answer === undefined || answer === null || (typeof answer === 'object' && 'refuse' in answer) || isGranted(beforePermission(event)))
195
198
  return answer;
196
199
  throw new Error(`Its ${entry.method} answer changes nothing until ${beforePermission(event)} is turned on for ${name} in /mods.`);
197
200
  });
198
- return foldAnswers(event, change, askers, (error) => report(`session:${methodOf('before', event)}`, `The ${methodOf('before', event)} listener failed`, error));
201
+ return foldBefore(event, change, askers, (error) => report(`session:${methodOf('before', event)}`, `The ${methodOf('before', event)} listener failed`, error));
199
202
  };
200
203
  const eventMethods = (scopeOf, hasBefore, ended) => {
201
204
  const method = (into, named, event) => (...args) => {
@@ -208,10 +211,12 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
208
211
  ...(hasBefore ? beforeEvents.map((named) => [`before${named}`, method(deciding, `before${named}`, sessionEvents[named])]) : []),
209
212
  ]);
210
213
  };
214
+ let folders;
211
215
  const withFiles = async (message) => {
212
216
  if (message.type !== ToolCall && message.type !== ToolResult)
213
217
  return message;
214
- return { ...message, files: await callFiles(name, claude, { tool: message.name, input: message.input }, message.cwd) };
218
+ folders ??= Promise.all([claude.env.home(), claude.env.configHome()]).then(([home, configRoot]) => ({ home, configRoot }));
219
+ return { ...message, files: await callFiles(name, claude, folders, { tool: message.name, input: message.input }, message.cwd) };
215
220
  };
216
221
  const messagesFrom = async (method, input) => Promise.all((await call(method, input)).map(withFiles));
217
222
  const searched = (request, matcher) => ({
@@ -405,39 +410,16 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
405
410
  };
406
411
  const processListeners = new Map();
407
412
  const raise = async (method, payload) => {
408
- const isBefore = method.startsWith('before');
409
413
  const heard = [...(processListeners.get(method) ?? [])].filter(({ matcher }) => payload['task'] === undefined && (matcher === undefined || matches(payload, matcher)));
410
- if (!isBefore) {
411
- await Promise.all(heard.map(({ listener, options }) => withinTime(claude.clock, method, options, (time) => listener(payload, time)).catch((error) => {
412
- if (!(error instanceof PassedTimeout))
413
- report(`session:${method}`, `The ${method} listener failed`, error);
414
- })));
414
+ if (!method.startsWith('before')) {
415
+ await Promise.all(heard.map(({ listener, options }) => invoke(method, options, (time) => listener(payload, time))));
415
416
  return undefined;
416
417
  }
418
+ const failing = method === 'beforePermissionRequest' ? { onFailure: 'refuse' } : {};
417
419
  const takes = answerKeys[method.slice('before'.length)] ?? [];
418
- let merged;
419
- for (const { listener, options } of heard) {
420
- try {
421
- const answer = (await withinTime(claude.clock, method, options, (time) => listener(payload, time)));
422
- if (answer === undefined || answer === null)
423
- continue;
424
- const unread = Object.keys(answer).filter((key) => !takes.includes(key));
425
- if (unread.length > 0)
426
- throw new Error(`it answered ${unread.join(' and ')}, which ${method} does not take. It takes ${takes.map((key) => `{ ${key} }`).join(', ') || 'no answer'}.`);
427
- const notes = [merged?.['note'], answer['note']].filter((note) => typeof note === 'string');
428
- merged = { ...merged, ...answer, ...(notes.length === 0 ? {} : { note: notes.join('\n\n') }) };
429
- if (typeof answer['refuse'] === 'string')
430
- return merged;
431
- }
432
- catch (error) {
433
- report(`session:${method}`, `The ${method} listener failed`, error);
434
- if (options.onFailure === 'refuse' || method === 'beforePermissionRequest')
435
- return { refuse: `${name}: ${messageOf(error)}` };
436
- }
437
- }
438
- return merged;
420
+ const askers = heard.map(({ listener, options }) => () => invoke(method, { ...options, ...failing }, async (time) => takenAnswer(method, takes, await listener(payload, time)), takes));
421
+ return foldAnswers(method, takes, ['refuse'], undefined, askers, (error) => report(`session:${method}`, `The ${method} listener failed`, error));
439
422
  };
440
- const permissionCheck = (e, below) => ({ session: current.id, cwd: current.cwd, ...defined({ task: e.agentId, callId: e.tool_use_id, reason: below.reason, rule: below.rule, ceiling: e.ceiling === undefined ? undefined : verdictName(e.ceiling) }), name: e.tool, input: recordOf(e.input), decision: verdictName(below.decision) });
441
423
  const commandRun = async (e) => {
442
424
  const source = (await claude.command.list()).find((command) => command.name === e.command)?.source;
443
425
  return { session: current.id, cwd: current.cwd, name: e.command, args: e.args, ...defined({ source }), from: senderOf(e.origin, false) };
@@ -466,22 +448,11 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
466
448
  if (hookOn === undefined || routeOn === undefined)
467
449
  throw new Error(`${name}: ${method} works only in a mod's own session.`);
468
450
  const isBefore = method.startsWith('before');
469
- if (named === 'PermissionCheck') {
470
- return routeOn('tool.check', async (e, next) => {
471
- const below = (await next(e));
472
- if (!isBefore) {
473
- void raise(method, permissionCheck(e, below));
474
- return below;
475
- }
476
- const answer = await raise(method, permissionCheck(e, below));
477
- if (answer === undefined || below.decision === 'deny')
478
- return below;
479
- if (typeof answer['refuse'] === 'string')
480
- return { decision: 'deny', reason: answer['refuse'] };
481
- if (typeof answer['ask'] === 'string')
482
- return { decision: 'ask', reason: answer['ask'] };
483
- return answer['allow'] === true ? { decision: 'allow' } : below;
484
- });
451
+ if (named === 'PermissionCheck' || named === 'PermissionRequest') {
452
+ const routes = permissionRoutes?.();
453
+ if (routes === undefined)
454
+ throw new Error(withoutPermissionCheck(name, method));
455
+ return routes(method, { raise, hookOn, routeOn, current: () => current });
485
456
  }
486
457
  const event = processEvents[named];
487
458
  const hook = (isBefore ? event.before : undefined) ?? event.on;
@@ -493,7 +464,7 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
493
464
  return undefined;
494
465
  }
495
466
  const answer = await raise(method, payload);
496
- return answer === undefined ? undefined : hookAnswerOf(named, hook, answer);
467
+ return answer === undefined ? undefined : hookAnswerOf(named, answer);
497
468
  });
498
469
  };
499
470
  const listen = (method, args) => {
@@ -529,16 +500,15 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
529
500
  },
530
501
  changed(session, change, isInProject) {
531
502
  remember(session, change);
503
+ let payload;
532
504
  for (const key of isInProject ? [session, everySession] : [session]) {
533
505
  for (const { scope, event, method, matcher, listener, options } of listening.get(key) ?? []) {
534
506
  if (!isHeardBy(event, matcher, change) || !isInScope(scope, session, change, isUnderIn(session)))
535
507
  continue;
536
- void payloadFor(session, change)
537
- .then((payload) => withinTime(claude.clock, method, options, (time) => listener(payload, time)))
538
- .catch((error) => {
539
- if (!(error instanceof PassedTimeout))
540
- report(`session:${method}`, `The ${method} listener failed`, error);
541
- });
508
+ payload ??= payloadFor(session, change);
509
+ void payload
510
+ .then((heard) => invoke(method, options, (time) => listener(heard, time)))
511
+ .catch((error) => report(`session:${method}`, `The ${method} listener failed`, error));
542
512
  }
543
513
  }
544
514
  },
@@ -567,9 +537,8 @@ export function createSessionsClient({ name, claude, report, isGranted, hookOn,
567
537
  },
568
538
  };
569
539
  }
570
- const verdictName = (decision) => (decision === 'deny' ? 'refuse' : decision);
571
- const recordOf = (value) => (typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {});
572
- const defined = (fields) => Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
540
+ export const recordOf = (value) => (typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {});
541
+ export const defined = (fields) => Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
573
542
  const placementOf = (input) => defined({ session: input['session_id'], task: input['agent_id'], agentType: input['agent_type'], cwd: input['cwd'], permissionMode: input['permission_mode'] });
574
543
  const mcpServerOf = (server) => (server === undefined ? undefined : { name: String(recordOf(server)['name']), source: String(recordOf(server)['source']) });
575
544
  const modelChangeOf = (input) => ({
@@ -653,30 +622,25 @@ export function processPayloadOf(named, input) {
653
622
  const fields = recordOf(input);
654
623
  return { ...placementOf(fields), ...defined(processFields[named](fields)) };
655
624
  }
656
- export function hookAnswerOf(named, hook, answer) {
657
- const output = (fields) => ({ hookSpecificOutput: { hookEventName: hook, ...defined(fields) } });
625
+ function hookAnswerOf(named, answer) {
658
626
  const refuse = typeof answer['refuse'] === 'string' ? answer['refuse'] : undefined;
627
+ const note = typeof answer['note'] === 'string' ? [answer['note']] : undefined;
659
628
  if (named === 'SessionStart')
660
- return output({ additionalContext: answer['note'], sessionTitle: answer['title'], reloadSkills: answer['reloadSkills'], initialUserMessage: answer['firstPrompt'] });
629
+ return defined({ additionalContext: note, sessionTitle: answer['title'], reloadSkills: answer['reloadSkills'] === true ? true : undefined, initialUserMessage: answer['firstPrompt'] });
661
630
  if (named === 'ModelChange') {
662
631
  if (refuse !== undefined)
663
- return output({ permissionDecision: 'deny', permissionDecisionReason: refuse });
664
- return typeof answer['ask'] === 'string' ? output({ permissionDecision: 'ask', permissionDecisionReason: answer['ask'] }) : undefined;
632
+ return { permissionDecision: 'deny', permissionDecisionReason: refuse };
633
+ return typeof answer['ask'] === 'string' ? { permissionDecision: 'ask', permissionDecisionReason: answer['ask'] } : undefined;
665
634
  }
666
635
  if (named === 'WorktreeCreate')
667
- return output({ worktreePath: answer['result'] });
668
- if (named === 'PermissionRequest') {
669
- if (refuse !== undefined)
670
- return output({ decision: defined({ behavior: 'deny', message: refuse, interrupt: answer['interrupt'] }) });
671
- return answer['allow'] === true ? output({ decision: defined({ behavior: 'allow', updatedInput: answer['input'], updatedPermissions: answer['updates'] }) }) : undefined;
672
- }
636
+ return defined({ worktreePath: answer['result'] });
673
637
  if (named === 'PermissionDenial')
674
- return output({ retry: answer['retry'] });
638
+ return answer['retry'] === true ? { retry: true } : undefined;
675
639
  if (named === 'ToolBatch')
676
- return output({ additionalContext: answer['note'] });
640
+ return defined({ additionalContext: note });
677
641
  if (named === 'ReplyDisplay')
678
- return output({ displayContent: answer['text'] });
679
- return refuse === undefined ? undefined : { decision: 'block', reason: refuse };
642
+ return defined({ displayContent: answer['text'] });
643
+ return refuse === undefined ? undefined : { block: refuse };
680
644
  }
681
645
  export function resultOf(where, call, answered) {
682
646
  const base = { id: '', type: ToolResult, at: new Date().toISOString(), session: where.session, context: where.context, ...(where.turn === undefined ? {} : { turn: where.turn.prompt.id }), callId: '', name: call.name, input: call.input, cwd: call.cwd, files: noFiles };
@@ -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;AACpD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AACzC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAQ3C,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,MAAM,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAA;IAC3C,QAAQ,CAAC,OAAO,EAAE,MAAM,IAAI,CAAA;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAA;CACvD,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,OAAO,IAAI,IAAI,CAAA;IACf,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,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACvH,CAAA;AAQD,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAIvE;AAED,wBAAsB,YAAY,CAAC,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAO9H;AAED,wBAAgB,WAAW,CAAC,KAAK,SAAS,MAAM,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE,YAAY,GAAG,QAAQ,CAAC,KAAK,CAAC,CA6G9J"}
1
+ {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../src/runtime/state.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AACpD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AACzC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAQ3C,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,MAAM,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAA;IAC3C,QAAQ,CAAC,OAAO,EAAE,MAAM,IAAI,CAAA;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAA;CACvD,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,OAAO,IAAI,IAAI,CAAA;IACf,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,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACvH,CAAA;AAQD,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAIvE;AAED,wBAAsB,YAAY,CAAC,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAO9H;AAED,wBAAgB,WAAW,CAAC,KAAK,SAAS,MAAM,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE,YAAY,GAAG,QAAQ,CAAC,KAAK,CAAC,CAoH9J"}
package/runtime/state.js CHANGED
@@ -46,17 +46,18 @@ export function createState({ name, initial, session, root, claude, report, chan
46
46
  const transcriptsKey = `${name}.$transcripts.`;
47
47
  const forgetEndedSessions = async (current) => {
48
48
  const stored = await claude.store.keys();
49
- for (const recorded of stored.filter((key) => key.startsWith(transcriptsKey))) {
50
- const owner = recorded.slice(transcriptsKey.length);
51
- if (owner === current)
52
- continue;
49
+ const ended = async (recorded) => {
53
50
  const transcript = await claude.store.get(recorded);
54
- if (typeof transcript === 'string' && (await claude.fs.exists(transcript)))
55
- continue;
56
- for (const value of stored.filter((key) => key.startsWith(`${name}.`) && key.endsWith(`.sessions.${owner}`)))
57
- await claude.store.delete(value);
51
+ return typeof transcript !== 'string' || !(await claude.fs.exists(transcript));
52
+ };
53
+ const owners = stored.filter((key) => key.startsWith(transcriptsKey) && key !== `${transcriptsKey}${current}`);
54
+ await Promise.all(owners.map(async (recorded) => {
55
+ if (!(await ended(recorded)))
56
+ return;
57
+ const owner = recorded.slice(transcriptsKey.length);
58
+ await Promise.all(stored.filter((key) => key.startsWith(`${name}.`) && key.endsWith(`.sessions.${owner}`)).map((value) => claude.store.delete(value)));
58
59
  await claude.store.delete(recorded);
59
- }
60
+ }));
60
61
  };
61
62
  const loadGroups = async (only, next) => {
62
63
  const loaded = [];
@@ -114,8 +115,10 @@ export function createState({ name, initial, session, root, claude, report, chan
114
115
  }),
115
116
  moveTo: (nextRoot) => inTurn(async () => (nextRoot === projectRoot ? undefined : loadGroups(['project'], { root: nextRoot, session: sessionId }))),
116
117
  switchSession: (nextSession, source, transcript) => inTurn(async () => {
117
- await claude.store.set(`${transcriptsKey}${nextSession}`, transcript);
118
- await forgetEndedSessions(nextSession);
118
+ if (Object.keys(declared.session).length > 0) {
119
+ await claude.store.set(`${transcriptsKey}${nextSession}`, transcript);
120
+ void forgetEndedSessions(nextSession).catch((error) => report({ id: 'state:sessions', message: 'Could not forget the session values of ended sessions', error }));
121
+ }
119
122
  if (nextSession === sessionId)
120
123
  return;
121
124
  if (source === 'fork')
@@ -35,6 +35,7 @@ export type Row = {
35
35
  };
36
36
  export declare const messageTypes: readonly MessageType[];
37
37
  export declare const skillPrefix = "Base directory for this skill:";
38
+ export declare function instructionPathOf(text: string): string;
38
39
  export declare function rowOfLine(row: Readonly<Record<string, unknown>>): Row | undefined;
39
40
  export declare function rowOfAppend(append: {
40
41
  readonly uuid: string;
@@ -1 +1 @@
1
- {"version":3,"file":"message.d.ts","sourceRoot":"","sources":["../../../../src/session/context/turn/message/message.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"message.d.ts","sourceRoot":"","sources":["../../../../src/session/context/turn/message/message.ts"],"names":[],"mappings":"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"}
@@ -13,7 +13,10 @@ export const Message = 'message';
13
13
  export const messageTypes = [Prompt, Note, Reply, ToolCall, ToolResult, Skill, InstructionFile, Notice];
14
14
  export const skillPrefix = 'Base directory for this skill:';
15
15
  const rowTypes = ['user', 'assistant', 'attachment', 'system'];
16
- const memoryPath = /^Contents of (.+?) \(/;
16
+ const memoryPath = /^Contents of (.+?)(?: \([^)\n]*\))?:\r?$/m;
17
+ export function instructionPathOf(text) {
18
+ return memoryPath.exec(text)?.[1] ?? '';
19
+ }
17
20
  export function rowOfLine(row) {
18
21
  const { uuid, timestamp, type } = row;
19
22
  if (typeof uuid !== 'string' || typeof timestamp !== 'string' || typeof type !== 'string' || !rowTypes.includes(type))
@@ -39,7 +42,7 @@ export function rowOfLine(row) {
39
42
  export function rowOfAppend(append, at, cwd) {
40
43
  const { message, origin } = append;
41
44
  const text = textOf(message.content);
42
- const path = message.type === 'attachment' ? memoryPath.exec(text)?.[1] : undefined;
45
+ const path = message.type === 'attachment' ? instructionPathOf(text) || undefined : undefined;
43
46
  const model = isObject(origin) && origin['kind'] === 'model' && typeof origin['model'] === 'string' ? origin['model'] : undefined;
44
47
  return {
45
48
  id: append.uuid,
@@ -2,6 +2,7 @@ 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
4
  import { engineOf } from '../runtime/engine.js'
5
+ import { withoutPermissionCheck } from '../runtime/sessions.js'
5
6
  import { inputOf } from '../utils/call-effects.js'
6
7
  import { decidePermission, stricterVerdict, type PermissionRules as RulesFor, type Rule as RuleFor, type Verdict } from './permissions/decide-permission.js'
7
8
  import { targetOf } from './permissions/match-target.js'
@@ -20,7 +21,8 @@ export function permissions<State extends object = Record<never, never>>(rules:
20
21
  if (count === 0) throw new Error('permissions: give it a deny or an ask rule, or remove it from setup.')
21
22
 
22
23
  return (job) => {
23
- const { router, toolCalls } = engineOf(job.mod)
24
+ const { router, toolCalls, hasPermissionCheck } = engineOf(job.mod)
25
+ if (!hasPermissionCheck()) throw new Error(withoutPermissionCheck(job.mod.name, 'the permissions job'))
24
26
  const readWorkspace = workspaceReader(job)
25
27
 
26
28
  const verdictOf = async (e: Frozen<Args<'tool.check'>>): Promise<Verdict | undefined> => {
package/src/records.ts CHANGED
@@ -259,7 +259,7 @@ function readKeys(keys: unknown): Steps {
259
259
  return { keys: commands }
260
260
  }
261
261
 
262
- export const coreVersion = '2.0.0'
262
+ export const coreVersion = '2.1.0'
263
263
 
264
264
  export function oldestCmodFor(steps: Steps): string {
265
265
  if (steps.permissions?.some((item) => item.startsWith('config:')) === true) return '1.6.0'
package/src/register.ts CHANGED
@@ -5,10 +5,11 @@ import type { Claude } from './runtime/claude.js'
5
5
  import type { RoutedEvent } from './runtime/hooks.js'
6
6
  import { cmodPluginName } from './runtime/dependencies.js'
7
7
  import { createLifecycle, readPlugin, type Lifecycle } from './runtime/lifecycle.js'
8
+ import { permissionRoutes, type PermissionRoutes } from './runtime/permission-check.js'
8
9
 
9
10
  let lifecycle: Lifecycle<object>
10
11
 
11
- let checksPermissions = false
12
+ let permissionCheck: PermissionRoutes | undefined
12
13
 
13
14
  let registered: ModDefinition<object> | undefined
14
15
 
@@ -99,9 +100,9 @@ function routeCallToMod(_$: EngineInterface, eventInput: Frozen<Args<'cmod.call'
99
100
  }
100
101
 
101
102
  export function registerMod<State extends object, Declared extends Options>(addHook: On, definition: ModDefinition<State, string, Declared>, options: PluginOptions): void {
102
- checksPermissions = false
103
+ permissionCheck = undefined
103
104
  registered = definition as ModDefinition<object>
104
- lifecycle = createLifecycle(definition, () => checksPermissions, options)
105
+ lifecycle = createLifecycle(definition, () => permissionCheck, options)
105
106
  addHook('session.start', startMod)
106
107
  addHook('classic.Setup', routeToMod)
107
108
  addHook('classic.SessionStart', routeToMod)
@@ -154,7 +155,7 @@ export function registerMod<State extends object, Declared extends Options>(addH
154
155
  }
155
156
 
156
157
  export function registerPermissionCheck(addHook: On): void {
157
- checksPermissions = true
158
+ permissionCheck = permissionRoutes
158
159
  addHook('tool.check', routeToMod)
159
160
  addHook('classic.PermissionRequest', routeToMod)
160
161
  }
@@ -2,7 +2,7 @@ import type { Args, Frozen } from 'claude-code'
2
2
  import type { Disposable } from '../mod.js'
3
3
  import { ContextEnd } from '../session/context/context.js'
4
4
  import { InstructionFile, isRulePath } from '../session/context/turn/message/instruction-file.js'
5
- import { Message, messagesOf, noFiles, rowOfAppend, withTexts, type Unplaced } from '../session/context/turn/message/message.js'
5
+ import { instructionPathOf, Message, messagesOf, noFiles, rowOfAppend, withTexts, type Unplaced } from '../session/context/turn/message/message.js'
6
6
  import { Note } from '../session/context/turn/message/note.js'
7
7
  import { Notice } from '../session/context/turn/message/notice.js'
8
8
  import { Prompt } from '../session/context/turn/message/prompt.js'
@@ -12,13 +12,14 @@ import { Skill } from '../session/context/turn/message/skill.js'
12
12
  import { ToolCall } from '../session/context/turn/message/tool-call.js'
13
13
  import { ToolResult } from '../session/context/turn/message/tool-result.js'
14
14
  import { TurnEnd } from '../session/context/turn/turn.js'
15
- import type { ConversationState, SessionChange, TaskData } from '../session/conversation.js'
15
+ import type { ContextData, ConversationState, RunningTask, SessionChange, TaskData } from '../session/conversation.js'
16
16
  import { Task, TaskStart } from '../session/goal/task.js'
17
- import type { BeforeEvent } from '../session/session.js'
17
+ import { sessionEvents, type BeforeEvent, type SessionEvent } from '../session/session.js'
18
18
  import type { Claude } from './claude.js'
19
19
  import { cmodPluginName } from './dependencies.js'
20
20
  import type { Router } from './router.js'
21
21
  import { toolInputOf } from './tool-calls.js'
22
+ import { resolve } from '../vendor.js'
22
23
 
23
24
  export type Place = Pick<ConversationState, 'session' | 'context' | 'contextOpenedAt' | 'turn' | 'tasks' | 'taskTurns' | 'openCalls'>
24
25
 
@@ -66,6 +67,7 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
66
67
  }
67
68
  const titles = new Map<string, string>()
68
69
  const spawned = new Map<string, string>()
70
+ const agentFolders = new Map<string, string>()
69
71
  const toolResultHook = (isFailure: boolean) => async (e: Readonly<Record<string, unknown>>, next: (e: never) => Promise<unknown>) => {
70
72
  if (!(await wants(ToolResult))) return next(e as never)
71
73
  const where = await place()
@@ -121,7 +123,7 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
121
123
  router.add('prompt.attachment', async (e, next) => {
122
124
  if (!(await wants(InstructionFile)) || e.type !== 'nested_memory') return next(e)
123
125
  const where = await place()
124
- const path = /^Contents of (.+?) \(/.exec(e.text)?.[1] ?? ''
126
+ const path = instructionPathOf(e.text)
125
127
  const answer = await asked(InstructionFile, message(where, { id: '', at: now(), type: InstructionFile, path, text: e.text, isRule: isRulePath(path) }, e.agentId))
126
128
  if (answer.refuse !== undefined) return { text: null }
127
129
  return answer.text === undefined ? next(e) : { text: answer.text }
@@ -145,7 +147,8 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
145
147
  if (!(await wants(ToolCall))) return next(e)
146
148
  const where = await place()
147
149
  const input = toolInputOf(e)
148
- const answer = await asked(ToolCall, message(where, { id: e.tool_use_id, at: now(), type: ToolCall, callId: e.tool_use_id, name: e.tool, input, cwd: await claude.session.cwd(), files: noFiles }, e.agentId))
150
+ const cwd = (e.agentId === undefined ? undefined : agentFolders.get(e.agentId)) ?? (await claude.session.cwd())
151
+ const answer = await asked(ToolCall, message(where, { id: e.tool_use_id, at: now(), type: ToolCall, callId: e.tool_use_id, name: e.tool, input, cwd, files: noFiles }, e.agentId))
149
152
  if (answer.refuse !== undefined) return { deny: answer.refuse }
150
153
  if (answer.result !== undefined) return { result: answer.result }
151
154
  if (answer.input === undefined) return next(e)
@@ -154,7 +157,13 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
154
157
  }),
155
158
  router.add('agent.spawn', async (e, next) => {
156
159
  spawned.set(e.tool_use_id, e.prompt)
157
- if (!(await wants(TaskStart))) return next(e)
160
+ const start = async (args: typeof e) => {
161
+ const started = await next(args)
162
+ const folder = args.isTeammate === true ? undefined : (args.cwd ?? (args.parentAgentId === undefined ? undefined : agentFolders.get(args.parentAgentId)))
163
+ if (started.agentId !== undefined && folder !== undefined) agentFolders.set(started.agentId, resolve(await claude.session.cwd(), folder))
164
+ return started
165
+ }
166
+ if (!(await wants(TaskStart))) return start(e)
158
167
  const where = await place()
159
168
  const task: TaskData = { id: '', title: e.description, agentType: e.subagentType, model: e.model ?? e.parentModel, openedBy: e.tool_use_id, openedAt: now(), ...(e.parentAgentId === undefined ? {} : { parent: e.parentAgentId }) }
160
169
  const answer = await asked(TaskStart, { type: TaskStart, task: { ...task, context: where.context } })
@@ -162,7 +171,7 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
162
171
  spawned.delete(e.tool_use_id)
163
172
  return { deny: answer.refuse }
164
173
  }
165
- return next(answer.owner === undefined ? e : { ...e, model: answer.owner.model })
174
+ return start(answer.owner === undefined ? e : { ...e, model: answer.owner.model })
166
175
  }),
167
176
  router.add('classic.SubagentStart', async (e, next) => {
168
177
  const where = await place()
@@ -177,12 +186,16 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
177
186
  return withNote(await next(e), answer.note)
178
187
  }),
179
188
  router.add('classic.SubagentStop', async (e, next) => {
180
- if (!(await wants(TurnEnd))) return next(e)
189
+ const stopped = <Answer extends { readonly block?: string }>(answer: Answer) => {
190
+ if (answer.block === undefined) agentFolders.delete(e.agent_id)
191
+ return answer
192
+ }
193
+ if (!(await wants(TurnEnd))) return stopped(await next(e))
181
194
  const turn = (await place()).taskTurns[e.agent_id]
182
- if (turn === undefined) return next(e)
195
+ if (turn === undefined) return stopped(await next(e))
183
196
  const answer = await asked(TurnEnd, { type: TurnEnd, turn: { ...turn, closedAt: now() } })
184
197
  const below = await next(e)
185
- return answer.refuse === undefined ? below : { ...below, block: answer.refuse }
198
+ return stopped(answer.refuse === undefined ? below : { ...below, block: answer.refuse })
186
199
  }),
187
200
  router.add('classic.Stop', async (e, next) => {
188
201
  if (!(await wants(TurnEnd))) return next(e)
@@ -197,14 +210,17 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
197
210
  const where = await place()
198
211
  const task = e.agentId === undefined ? undefined : where.tasks[e.agentId]
199
212
  if (e.agentId !== undefined && task === undefined) return next(e)
200
- const context = task === undefined ? { number: where.context, openedAt: where.contextOpenedAt } : { number: task.context, openedAt: task.contextOpenedAt ?? task.openedAt, task: task.id }
201
- const answer = await asked(ContextEnd, { type: ContextEnd, context: { ...context, closedAt: now() } })
213
+ const answer = await asked(ContextEnd, { type: ContextEnd, context: closingContext(where, task, now()) })
202
214
  return answer.refuse === undefined ? next(e) : { skip: answer.refuse }
203
215
  }),
204
216
  ]
205
217
  return { dispose: () => added.forEach((each) => each.dispose()) }
206
218
  }
207
219
 
220
+ export function closingContext(where: Place, task: RunningTask | undefined, closedAt: string): ContextData {
221
+ return task === undefined ? { number: where.context, openedAt: where.contextOpenedAt, closedAt } : { number: task.context, openedAt: task.contextOpenedAt ?? task.openedAt, closedAt, task: task.id }
222
+ }
223
+
208
224
  export function withAnswer(change: SessionChange, answer: Partial<Answers>): SessionChange {
209
225
  if (change.type === Message && answer.output !== undefined && change.message.type === ToolResult) return { ...change, message: { ...change.message, output: answer.output } }
210
226
  if (change.type === Message && answer.text !== undefined) return { ...change, message: { ...change.message, text: answer.text } as Message }
@@ -213,41 +229,49 @@ export function withAnswer(change: SessionChange, answer: Partial<Answers>): Ses
213
229
  return change
214
230
  }
215
231
 
216
- export async function foldAnswers(event: BeforeEvent, change: SessionChange, askers: readonly ((change: SessionChange) => Promise<unknown>)[], report: (error: unknown) => void): Promise<Partial<Answers> | undefined> {
232
+ export const methodOf = (prefix: 'on' | 'before', event: SessionEvent): string => `${prefix}${Object.entries(sessionEvents).find(([, each]) => each === event)?.[0] ?? event}`
233
+
234
+ export const beforeAnswerKeys: Readonly<Record<BeforeEvent, readonly (keyof Answers)[]>> = {
235
+ prompt: ['text', 'note', 'title', 'refuse'],
236
+ note: ['text'],
237
+ skill: ['text'],
238
+ instructionFile: ['text', 'refuse'],
239
+ reply: ['text'],
240
+ toolCall: ['input', 'result', 'refuse'],
241
+ toolResult: ['output', 'isError', 'note'],
242
+ notice: ['text'],
243
+ taskStart: ['owner', 'refuse'],
244
+ turnEnd: ['refuse'],
245
+ contextEnd: ['refuse'],
246
+ }
247
+
248
+ export function foldBefore(event: BeforeEvent, change: SessionChange, askers: readonly ((change: SessionChange) => Promise<unknown>)[], report: (error: unknown) => void): Promise<Partial<Answers> | undefined> {
249
+ return foldAnswers(methodOf('before', event), beforeAnswerKeys[event], ['refuse', 'result'], change, askers, report, withAnswer)
250
+ }
251
+
252
+ export function takenAnswer(method: string, takes: readonly string[], answer: unknown): unknown {
253
+ if (answer === undefined || answer === null || answer === false) return undefined
254
+ if (typeof answer !== 'object' || Array.isArray(answer)) throw new Error(`it answered ${JSON.stringify(answer)}, but ${method} takes an object or nothing: ${takes.map((key) => `{ ${key} }`).join(', ') || 'no answer'}.`)
255
+ const unread = Object.keys(answer).filter((key) => !takes.includes(key))
256
+ if (unread.length > 0) throw new Error(`it answered ${unread.join(' and ')}, which ${method} does not take. It takes ${takes.map((key) => `{ ${key} }`).join(', ') || 'no answer'}.`)
257
+ return answer
258
+ }
259
+
260
+ export async function foldAnswers<Change, Answer extends object>(method: string, takes: readonly string[], ends: readonly string[], change: Change, askers: readonly ((change: Change) => Promise<unknown>)[], report: (error: unknown) => void, applied: (change: Change, answer: Answer) => Change = (same) => same): Promise<Answer | undefined> {
217
261
  let latest = change
218
- let result: Partial<Answers> | undefined
262
+ let result: Record<string, unknown> | undefined
219
263
  for (const ask of askers) {
220
264
  try {
221
- const answer = answerOf(event, await ask(latest))
222
- if (answer === undefined) continue
223
- const notes = [result?.note, answer.note].filter((note) => note !== undefined)
224
- result = { ...result, ...answer, ...(notes.length === 0 ? {} : { note: notes.join('\n\n') }) }
225
- if (answer.refuse !== undefined || answer.result !== undefined) return result
226
- latest = withAnswer(latest, answer)
265
+ const answer = takenAnswer(method, takes, await ask(latest))
266
+ if (typeof answer !== 'object' || answer === null) continue
267
+ const given = answer as Record<string, unknown>
268
+ const notes = [result?.['note'], given['note']].filter((note) => typeof note === 'string')
269
+ result = { ...result, ...given, ...(notes.length === 0 ? {} : { note: notes.join('\n\n') }) }
270
+ if (ends.some((key) => given[key] !== undefined)) return result as Answer
271
+ latest = applied(latest, answer as Answer)
227
272
  } catch (error) {
228
273
  report(error)
229
274
  }
230
275
  }
231
- return result
232
- }
233
-
234
- export function answerOf(event: BeforeEvent, answer: unknown): Partial<Answers> | undefined {
235
- if (typeof answer !== 'object' || answer === null) return undefined
236
- const given = answer as Partial<Answers>
237
- const allowed: Readonly<Record<BeforeEvent, readonly (keyof Answers)[]>> = {
238
- prompt: ['text', 'note', 'title', 'refuse'],
239
- note: ['text'],
240
- skill: ['text'],
241
- instructionFile: ['text', 'refuse'],
242
- reply: ['text'],
243
- toolCall: ['input', 'result', 'refuse'],
244
- toolResult: ['output', 'isError', 'note'],
245
- notice: ['text'],
246
- taskStart: ['owner', 'refuse'],
247
- turnEnd: ['refuse'],
248
- contextEnd: ['refuse'],
249
- }
250
- const unread = Object.keys(given).filter((key) => !allowed[event].includes(key as keyof Answers))
251
- if (unread.length > 0) throw new Error(`a before(${event}) listener answered ${unread.join(' and ')}, which ${event} does not take. It takes ${allowed[event].map((key) => `{ ${key} }`).join(', ')}.`)
252
- return given
276
+ return result as Answer | undefined
253
277
  }