@cmodjs/core 1.6.0 → 2.0.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 (128) hide show
  1. package/docs/commands.md +4 -4
  2. package/docs/index.md +12 -11
  3. package/docs/jobs.md +12 -16
  4. package/docs/mod.md +111 -36
  5. package/docs/permissions.md +14 -18
  6. package/docs/session.md +171 -82
  7. package/docs/state.md +5 -5
  8. package/docs/testing.md +57 -41
  9. package/docs/ui.md +9 -9
  10. package/jobs/permissions/decide-permission.d.ts.map +1 -1
  11. package/jobs/permissions/decide-permission.js +1 -1
  12. package/jobs/prompt.d.ts +1 -3
  13. package/jobs/prompt.d.ts.map +1 -1
  14. package/jobs/prompt.js +2 -21
  15. package/jobs/slash-command.d.ts +1 -1
  16. package/jobs/slash-command.d.ts.map +1 -1
  17. package/jobs/slash-command.js +1 -1
  18. package/jobs/status-line.d.ts +0 -1
  19. package/jobs/status-line.d.ts.map +1 -1
  20. package/jobs/status-line.js +3 -4
  21. package/mod.d.ts +53 -105
  22. package/mod.d.ts.map +1 -1
  23. package/mod.js +18 -80
  24. package/package.json +1 -1
  25. package/records.d.ts +1 -1
  26. package/records.js +1 -1
  27. package/register.d.ts.map +1 -1
  28. package/register.js +3 -1
  29. package/runtime/before.d.ts +5 -1
  30. package/runtime/before.d.ts.map +1 -1
  31. package/runtime/before.js +81 -39
  32. package/runtime/claude.d.ts +6 -1
  33. package/runtime/claude.d.ts.map +1 -1
  34. package/runtime/conversations.d.ts.map +1 -1
  35. package/runtime/conversations.js +45 -26
  36. package/runtime/deadline.d.ts +17 -0
  37. package/runtime/deadline.d.ts.map +1 -1
  38. package/runtime/deadline.js +34 -0
  39. package/runtime/dependencies.d.ts +2 -0
  40. package/runtime/dependencies.d.ts.map +1 -1
  41. package/runtime/dependencies.js +12 -0
  42. package/runtime/grants.d.ts.map +1 -1
  43. package/runtime/grants.js +13 -8
  44. package/runtime/hooks.d.ts +28 -11
  45. package/runtime/hooks.d.ts.map +1 -1
  46. package/runtime/hooks.js +20 -123
  47. package/runtime/lifecycle.d.ts +2 -1
  48. package/runtime/lifecycle.d.ts.map +1 -1
  49. package/runtime/lifecycle.js +66 -49
  50. package/runtime/sessions.d.ts +31 -5
  51. package/runtime/sessions.d.ts.map +1 -1
  52. package/runtime/sessions.js +342 -81
  53. package/runtime/tool-calls.d.ts +0 -1
  54. package/runtime/tool-calls.d.ts.map +1 -1
  55. package/runtime/tool-calls.js +0 -19
  56. package/session/context/turn/message/instruction-file.d.ts +9 -0
  57. package/session/context/turn/message/instruction-file.d.ts.map +1 -0
  58. package/session/context/turn/message/instruction-file.js +4 -0
  59. package/session/context/turn/message/message.d.ts +5 -4
  60. package/session/context/turn/message/message.d.ts.map +1 -1
  61. package/session/context/turn/message/message.js +7 -6
  62. package/session/context/turn/message/sender.d.ts +2 -1
  63. package/session/context/turn/message/sender.d.ts.map +1 -1
  64. package/session/context/turn/message/sender.js +4 -1
  65. package/session/context/turn/message/tool-call.d.ts +5 -0
  66. package/session/context/turn/message/tool-call.d.ts.map +1 -1
  67. package/session/context/turn/message/tool-result.d.ts +6 -1
  68. package/session/context/turn/message/tool-result.d.ts.map +1 -1
  69. package/session/context/turn/turn.d.ts +7 -0
  70. package/session/context/turn/turn.d.ts.map +1 -1
  71. package/session/conversation.d.ts +10 -3
  72. package/session/conversation.d.ts.map +1 -1
  73. package/session/conversation.js +14 -5
  74. package/session/goal/goal.d.ts +1 -1
  75. package/session/goal/goal.d.ts.map +1 -1
  76. package/session/goal/task.d.ts +2 -2
  77. package/session/goal/task.d.ts.map +1 -1
  78. package/session/session.d.ts +361 -26
  79. package/session/session.d.ts.map +1 -1
  80. package/session/session.js +40 -0
  81. package/src/jobs/permissions/decide-permission.ts +2 -1
  82. package/src/jobs/prompt.ts +3 -20
  83. package/src/jobs/slash-command.ts +2 -2
  84. package/src/jobs/status-line.ts +4 -7
  85. package/src/mod.ts +52 -147
  86. package/src/records.ts +1 -1
  87. package/src/register.ts +3 -1
  88. package/src/runtime/before.ts +90 -37
  89. package/src/runtime/claude.ts +5 -1
  90. package/src/runtime/conversations.ts +41 -25
  91. package/src/runtime/deadline.ts +38 -0
  92. package/src/runtime/dependencies.ts +12 -0
  93. package/src/runtime/grants.ts +11 -7
  94. package/src/runtime/hooks.ts +48 -116
  95. package/src/runtime/lifecycle.ts +65 -50
  96. package/src/runtime/sessions.ts +366 -90
  97. package/src/runtime/tool-calls.ts +0 -19
  98. package/src/session/context/turn/message/instruction-file.ts +13 -0
  99. package/src/session/context/turn/message/message.ts +11 -9
  100. package/src/session/context/turn/message/sender.ts +5 -2
  101. package/src/session/context/turn/message/tool-call.ts +3 -0
  102. package/src/session/context/turn/message/tool-result.ts +6 -1
  103. package/src/session/context/turn/turn.ts +4 -0
  104. package/src/session/conversation.ts +21 -8
  105. package/src/session/goal/goal.ts +1 -1
  106. package/src/session/goal/task.ts +2 -2
  107. package/src/session/session.ts +227 -24
  108. package/src/testing/claude-code.ts +23 -0
  109. package/src/testing/fake-claude.ts +5 -1
  110. package/src/testing/fake-sessions.ts +38 -24
  111. package/src/testing.ts +84 -16
  112. package/testing/claude-code.d.ts +12 -0
  113. package/testing/claude-code.d.ts.map +1 -0
  114. package/testing/claude-code.js +10 -0
  115. package/testing/fake-claude.d.ts +4 -0
  116. package/testing/fake-claude.d.ts.map +1 -1
  117. package/testing/fake-claude.js +4 -1
  118. package/testing/fake-sessions.d.ts +8 -6
  119. package/testing/fake-sessions.d.ts.map +1 -1
  120. package/testing/fake-sessions.js +35 -20
  121. package/testing.d.ts +38 -9
  122. package/testing.d.ts.map +1 -1
  123. package/testing.js +54 -5
  124. package/docs/hooks.md +0 -259
  125. package/session/context/turn/message/document.d.ts +0 -7
  126. package/session/context/turn/message/document.d.ts.map +0 -1
  127. package/session/context/turn/message/document.js +0 -1
  128. package/src/session/context/turn/message/document.ts +0 -8
@@ -1,15 +1,20 @@
1
1
  import { ContextEnd, ContextStart } from '../session/context/context.js';
2
- import { Message } from '../session/context/turn/message/message.js';
2
+ import { Message, noFiles } from '../session/context/turn/message/message.js';
3
3
  import { Note } from '../session/context/turn/message/note.js';
4
- import { Person } from '../session/context/turn/message/sender.js';
4
+ import { Person, senderOf } from '../session/context/turn/message/sender.js';
5
+ import { ToolCall } from '../session/context/turn/message/tool-call.js';
5
6
  import { ToolResult } from '../session/context/turn/message/tool-result.js';
6
7
  import { TurnEnd, TurnStart } from '../session/context/turn/turn.js';
8
+ import { searchLimit } from '../session/conversation.js';
7
9
  import { GoalChange } from '../session/goal/goal.js';
8
10
  import { TaskEnd, TaskStart } from '../session/goal/task.js';
9
- import { Session } from '../session/session.js';
11
+ import { beforeEvents, processEvents, Session, sessionEvents } from '../session/session.js';
10
12
  import { matches, sentMatcher } from '../utils/matcher.js';
11
13
  import { foldAnswers } from './before.js';
12
14
  import { cmodPluginName } from './dependencies.js';
15
+ import { messageOf } from '../utils/text.js';
16
+ import { listenerOf, PassedTimeout, withinTime } from './deadline.js';
17
+ import { callFiles } from './hooks.js';
13
18
  export const everySession = '*';
14
19
  export const conversationMethods = {
15
20
  sessions: 'cmod:sessions',
@@ -27,10 +32,11 @@ export const conversationMethods = {
27
32
  changed: 'cmod:changed',
28
33
  before: 'cmod:before',
29
34
  };
30
- const actPermissions = { send: 'prompt', startTask: 'tasks', usage: 'conversation', clients: 'conversation' };
35
+ const actPermissions = { send: 'prompt', startTask: 'tasks', usage: 'conversation', surfaces: 'conversation', abort: 'prompt' };
31
36
  const startsTurns = ['CronCreate'];
32
37
  const ownSchedule = ['CronDelete', 'CronList'];
33
- export const beforePermission = (event) => (event === 'toolCall' ? 'tools' : event === TaskStart || event === TaskEnd ? 'tasks' : 'prompt');
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}`;
34
40
  export const isAct = (action) => action === 'callTool' || action in actPermissions;
35
41
  export function toolPermission(tool) {
36
42
  return startsTurns.includes(tool) ? 'prompt' : ownSchedule.includes(tool) ? undefined : 'tools';
@@ -38,11 +44,10 @@ export function toolPermission(tool) {
38
44
  export function actPermission(action, input) {
39
45
  return action === 'callTool' ? (toolPermission(String(input?.name)) ?? 'tools') : actPermissions[action];
40
46
  }
41
- const asked = (action) => `cmod: ${action} runs only on a session's own main loop, and Claude Code gives a subagent's tasks no way to start a task or run a tool.`;
42
47
  export async function usageOf(claude) {
43
48
  const [model, usage] = await Promise.all([claude.session.model(), claude.session.usage()]);
44
49
  const { tokens, window, percent } = usage.context;
45
- return { model, context: { ...(tokens === undefined ? {} : { tokens }), window, ...(percent === undefined ? {} : { percent }) }, ...(usage.cost === undefined ? {} : { cost: { usd: usage.cost.usd } }) };
50
+ return { model, contextWindow: { ...(tokens === undefined ? {} : { tokenCount: tokens }), size: window, ...(percent === undefined ? {} : { percent }) }, ...(usage.cost === undefined ? {} : { cost: { usd: usage.cost.usd } }) };
46
51
  }
47
52
  export function payloadOf(change) {
48
53
  if (change.type === Message)
@@ -96,10 +101,10 @@ export function isInScope(scope, session, change, isUnder) {
96
101
  if (scope.session !== undefined && scope.session !== session)
97
102
  return false;
98
103
  const task = taskOfChange(change);
104
+ if (scope.isAgent === true)
105
+ return (change.type === TaskStart || change.type === TaskEnd ? change.task.parent : task) === scope.task;
99
106
  if (scope.task !== undefined && (task === undefined || !isUnder(task, scope.task)))
100
107
  return false;
101
- if (scope.isGoal === true && change.type !== GoalChange && change.type !== TaskStart && (task === undefined || task === scope.task))
102
- return false;
103
108
  if (scope.context !== undefined && (contextOfChange(change) !== scope.context || ownerOfChange(change) !== scope.task))
104
109
  return false;
105
110
  if (scope.turn === undefined)
@@ -108,7 +113,7 @@ export function isInScope(scope, session, change, isUnder) {
108
113
  return change.message.turn === scope.turn;
109
114
  return (change.type === TurnStart || change.type === TurnEnd) && change.turn.prompt.id === scope.turn;
110
115
  }
111
- export function createSessionsClient({ name, claude, report, isGranted }) {
116
+ export function createSessionsClient({ name, claude, report, isGranted, hookOn, routeOn }) {
112
117
  const call = (method, input) => claude.cmod.call({ to: cmodPluginName, method, input });
113
118
  const listening = new Map();
114
119
  const deciding = new Map();
@@ -149,11 +154,9 @@ export function createSessionsClient({ name, claude, report, isGranted }) {
149
154
  if (change.type === TaskStart)
150
155
  parents.get(session)?.set(change.task.id, change.task.parent);
151
156
  };
152
- const add = (into, scope, event, matcherOrListener, maybeListener, onChange) => {
153
- const listener = (maybeListener ?? matcherOrListener);
154
- const matcher = maybeListener === undefined ? undefined : matcherOrListener;
157
+ const add = (into, scope, event, method, args, onChange) => {
155
158
  const key = keyOf(scope);
156
- const entry = { scope, event, matcher, listener };
159
+ const entry = { scope, event, method, ...listenerOf(name, method, args) };
157
160
  const set = into.get(key) ?? new Set();
158
161
  set.add(entry);
159
162
  into.set(key, set);
@@ -181,31 +184,74 @@ export function createSessionsClient({ name, claude, report, isGranted }) {
181
184
  return undefined;
182
185
  if (entry.matcher !== undefined && !matches(payloadOf(latest), entry.matcher))
183
186
  return undefined;
184
- const answer = await entry.listener(payloadFor(session, latest));
185
- if (answer === undefined || isGranted(beforePermission(event)))
187
+ 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
+ });
194
+ if (answer === undefined || answer === null || (typeof answer === 'object' && 'refuse' in answer) || isGranted(beforePermission(event)))
186
195
  return answer;
187
- throw new Error(`Its before(${event}) answer changes nothing until ${beforePermission(event)} is turned on for ${name} in /mods.`);
196
+ throw new Error(`Its ${entry.method} answer changes nothing until ${beforePermission(event)} is turned on for ${name} in /mods.`);
188
197
  });
189
- return foldAnswers(event, change, askers, (error) => report(`before:${event}`, `The before(${event}) listener failed`, error));
198
+ return foldAnswers(event, change, askers, (error) => report(`session:${methodOf('before', event)}`, `The ${methodOf('before', event)} listener failed`, error));
199
+ };
200
+ const eventMethods = (scopeOf, hasBefore, ended) => {
201
+ const method = (into, named, event) => (...args) => {
202
+ if (ended !== undefined)
203
+ throw new Error(`${name}: ${ended} has ended, so its ${named} never fires. Listen on its Session.`);
204
+ return add(into, scopeOf(), event, named, args, subscribe);
205
+ };
206
+ return Object.fromEntries([
207
+ ...Object.entries(sessionEvents).map(([named, event]) => [`on${named}`, method(listening, `on${named}`, event)]),
208
+ ...(hasBefore ? beforeEvents.map((named) => [`before${named}`, method(deciding, `before${named}`, sessionEvents[named])]) : []),
209
+ ]);
190
210
  };
191
- const recordPart = (scope) => ({
192
- search: (text, options = {}) => call(conversationMethods.search, { text, ...scope, ...optionsOf(options) }),
193
- on: (event, matcherOrListener, maybeListener) => add(listening, scope, event, matcherOrListener, maybeListener, subscribe),
194
- before: (event, matcherOrListener, maybeListener) => add(deciding, scope, event, matcherOrListener, maybeListener, subscribe),
211
+ const withFiles = async (message) => {
212
+ if (message.type !== ToolCall && message.type !== ToolResult)
213
+ return message;
214
+ return { ...message, files: await callFiles(name, claude, { tool: message.name, input: message.input }, message.cwd) };
215
+ };
216
+ const messagesFrom = async (method, input) => Promise.all((await call(method, input)).map(withFiles));
217
+ const searched = (request, matcher) => ({
218
+ async *[Symbol.asyncIterator]() {
219
+ let before = request['before'];
220
+ for (;;) {
221
+ const page = await messagesFrom(conversationMethods.search, { ...request, ...(before === undefined ? {} : { before }) });
222
+ for (const message of page)
223
+ if (matcher === undefined || matches(message, matcher))
224
+ yield message;
225
+ const last = page.at(-1);
226
+ if (last === undefined || page.length < searchLimit)
227
+ return;
228
+ before = { session: last.session, id: last.id };
229
+ }
230
+ },
231
+ });
232
+ const recordPart = (scope, ended) => ({
233
+ search: (text, matcher) => searched({ text, ...scope }, matcher),
234
+ ...eventMethods(() => scope, true, ended),
195
235
  });
196
236
  const turnOf = (scope, data) => {
197
237
  const task = data.prompt.task ?? scope.task;
198
238
  return {
199
239
  ...data,
200
- ...recordPart({ ...scope, ...(task === undefined ? {} : { task }), turn: data.prompt.id }),
201
- messages: () => call(conversationMethods.messages, { session: scope.session, turn: data.prompt.id, ...(task === undefined ? {} : { task }) }),
240
+ ...recordPart({ ...scope, ...(task === undefined ? {} : { task }), turn: data.prompt.id }, data.closedAt === undefined ? undefined : `turn ${data.prompt.id}`),
241
+ messages: () => messagesFrom(conversationMethods.messages, { session: scope.session, turn: data.prompt.id, ...(task === undefined ? {} : { task }) }),
242
+ abort: async () => {
243
+ if (data.closedAt !== undefined)
244
+ throw new Error(`${name}: turn ${data.prompt.id} has ended, so there is nothing to abort.`);
245
+ const session = scope.session ?? current.id;
246
+ await act(session, 'abort', { turn: data.prompt.id }, task === undefined ? undefined : { session, task });
247
+ },
202
248
  };
203
249
  };
204
250
  const contextOf = (scope, data) => {
205
251
  const owned = { ...(scope.session === undefined ? {} : { session: scope.session }), ...(data.task === undefined ? {} : { task: data.task }) };
206
252
  return {
207
253
  ...data,
208
- ...recordPart({ ...owned, context: data.number }),
254
+ ...recordPart({ ...owned, context: data.number }, data.closedAt === undefined ? undefined : `context ${data.number}`),
209
255
  turns: async () => (await call(conversationMethods.turns, { ...owned, context: data.number })).map((turn) => turnOf(owned, turn)),
210
256
  };
211
257
  };
@@ -218,22 +264,27 @@ export function createSessionsClient({ name, claude, report, isGranted }) {
218
264
  owner: ownerOf(session, data),
219
265
  ...recordPart({ session, task: data.id }),
220
266
  tasks: async () => (await call(conversationMethods.tasks, { session, task: data.id })).map((task) => taskOf(session, task)),
221
- messages: () => call(conversationMethods.messages, { session, task: data.id }),
267
+ messages: () => messagesFrom(conversationMethods.messages, { session, task: data.id }),
222
268
  });
223
- const goalOf = (scope, data) => ({
224
- ...data,
225
- ...recordPart({ ...scope, isGoal: true }),
226
- tasks: async () => (await call(conversationMethods.tasks, { session: scope.session, ...(scope.task === undefined ? {} : { task: scope.task }) })).map((task) => taskOf(scope.session ?? current.id, task)),
269
+ const goalOf = (where) => ({
270
+ search: (text, matcher) => searched({ text, ...where() }, matcher),
271
+ ...eventMethods(() => ({ ...where(), isGoal: true }), true),
272
+ text: async () => (await call(conversationMethods.goal, where())).text,
273
+ tasks: async () => {
274
+ const { session = current.id, task } = where();
275
+ return (await call(conversationMethods.tasks, { session, ...(task === undefined ? {} : { task }) })).map((each) => taskOf(session, each));
276
+ },
227
277
  update: async ({ text }) => {
228
278
  if (text.trim() === '')
229
279
  throw new Error(`${name}: a goal needs text.`);
230
- await call(conversationMethods.updateGoal, { session: scope.session ?? current.id, ...(scope.task === undefined ? {} : { task: scope.task }), text });
280
+ const { session = current.id, task } = where();
281
+ await call(conversationMethods.updateGoal, { session, ...(task === undefined ? {} : { task }), text });
231
282
  },
232
283
  });
233
- const payloadFor = (session, change) => {
284
+ const payloadFor = async (session, change) => {
234
285
  const scope = { session };
235
286
  if (change.type === Message)
236
- return change.message;
287
+ return withFiles(change.message);
237
288
  if ('turn' in change)
238
289
  return turnOf(scope, change.turn);
239
290
  if ('context' in change)
@@ -242,7 +293,8 @@ export function createSessionsClient({ name, claude, report, isGranted }) {
242
293
  return taskOf(session, change.task);
243
294
  if ('usage' in change)
244
295
  return change.usage;
245
- return goalOf({ session, ...(change.goal.task === undefined ? {} : { task: change.goal.task }) }, { text: change.goal.text });
296
+ const { task } = change.goal;
297
+ return goalOf(() => ({ session, ...(task === undefined ? {} : { task }) }));
246
298
  };
247
299
  const act = (session, action, input, owned) => call(conversationMethods.act, { session: owned?.session ?? session, action, input, ...(owned === undefined ? {} : { task: owned.task }) });
248
300
  const sendHere = async ({ type, text, as }) => {
@@ -258,26 +310,15 @@ export function createSessionsClient({ name, claude, report, isGranted }) {
258
310
  if ('drop' in submitted && typeof submitted.drop === 'string')
259
311
  throw new Error(`${name}: Claude Code dropped the prompt: ${submitted.drop}`);
260
312
  };
261
- const startHere = async (task) => {
262
- const started = await claude.agent.spawn({
263
- prompt: task.prompt,
264
- ...(task.name === undefined ? {} : { description: task.name, name: task.name }),
265
- ...(task.agentType === undefined ? {} : { subagentType: task.agentType }),
266
- ...(task.model === undefined ? {} : { model: task.model }),
267
- ...(task.cwd === undefined ? {} : { cwd: task.cwd }),
268
- });
269
- if (started.deny !== undefined)
270
- throw new Error(`${name}: Claude Code refused the task: ${started.deny}`);
271
- return taskOf(current.id, { id: started.agentId ?? '', title: task.name ?? '', agentType: task.agentType ?? 'general-purpose', model: started.model, openedBy: '', openedAt: new Date().toISOString() });
272
- };
273
- const clientsHere = async () => [...(await claude.session.surfaces())];
313
+ const startHere = async (task) => taskOf(current.id, await spawnTask(claude, task).catch((error) => Promise.reject(new Error(`${name}: ${messageOf(error)}`))));
314
+ const surfacesHere = async () => [...(await claude.session.surfaces())];
274
315
  const sessionOf = (read, owned) => {
275
316
  const id = () => read().id;
276
317
  const isHere = () => owned === undefined && id() === current.id;
277
- const scope = () => (owned === undefined ? { session: id() } : { session: owned.session, task: owned.task });
318
+ const scope = () => (owned === undefined ? { session: id(), isAgent: true } : { session: owned.session, task: owned.task, isAgent: true });
278
319
  const where = () => (owned === undefined ? { session: id() } : { session: owned.session, task: owned.task });
279
- const turnsOf = async (options = {}) => (await call(conversationMethods.turns, { ...where(), ...options })).map((turn) => turnOf(scope(), turn));
280
- const contextsOf = async () => (await call(conversationMethods.contexts, where())).map((context) => contextOf(scope(), context));
320
+ const turnsOf = async (options = {}) => (await call(conversationMethods.turns, { ...where(), ...options })).map((turn) => turnOf(where(), turn));
321
+ const contextsOf = async () => (await call(conversationMethods.contexts, where())).map((context) => contextOf(where(), context));
281
322
  const session = {
282
323
  get id() {
283
324
  return read().id;
@@ -309,10 +350,9 @@ export function createSessionsClient({ name, claude, report, isGranted }) {
309
350
  get forkedFrom() {
310
351
  return read().forkedFrom;
311
352
  },
312
- search: (text, options = {}) => recordPart(scope()).search(text, options),
313
- on: (event, matcherOrListener, maybeListener) => recordPart(scope()).on(event, matcherOrListener, maybeListener),
314
- before: (event, matcherOrListener, maybeListener) => recordPart(scope()).before(event, matcherOrListener, maybeListener),
315
- goal: async () => goalOf(scope(), await call(conversationMethods.goal, where())),
353
+ search: (text, matcher) => recordPart(scope()).search(text, matcher),
354
+ ...eventMethods(scope, true),
355
+ goal: goalOf(where),
316
356
  context: async () => {
317
357
  const all = await contextsOf();
318
358
  const last = all.at(-1);
@@ -326,9 +366,9 @@ export function createSessionsClient({ name, claude, report, isGranted }) {
326
366
  return last?.closedAt === undefined ? last : undefined;
327
367
  },
328
368
  turns: turnsOf,
329
- messages: (options = {}) => call(conversationMethods.messages, { ...where(), ...options }),
369
+ messages: (options = {}) => messagesFrom(conversationMethods.messages, { ...where(), ...options }),
330
370
  usage: async () => (isHere() ? usageOf(claude) : act(id(), 'usage', null, owned)),
331
- clients: async () => (isHere() ? clientsHere() : act(id(), 'clients', null, owned)),
371
+ surfaces: async () => (isHere() ? surfacesHere() : act(id(), 'surfaces', null, owned)),
332
372
  async send(message) {
333
373
  if (message.text.trim() === '')
334
374
  throw new Error(`${name}: send needs text.`);
@@ -336,21 +376,18 @@ export function createSessionsClient({ name, claude, report, isGranted }) {
336
376
  return sendHere(message);
337
377
  await act(id(), 'send', message, owned);
338
378
  },
379
+ };
380
+ if (owned !== undefined)
381
+ return session;
382
+ return Object.assign(session, {
339
383
  async startTask(task) {
340
- if (owned !== undefined)
341
- throw new Error(asked('startTask'));
342
384
  if (isHere())
343
385
  return startHere(task);
344
386
  const started = await act(id(), 'startTask', task);
345
387
  return taskOf(id(), started);
346
388
  },
347
- async callTool(toolCall) {
348
- if (owned !== undefined)
349
- throw new Error(asked('callTool'));
350
- return act(id(), 'callTool', toolCall);
351
- },
352
- };
353
- return session;
389
+ callTool: async (toolCall) => (await withFiles(await act(id(), 'callTool', toolCall))),
390
+ });
354
391
  };
355
392
  const ownerOf = (session, data) => {
356
393
  const parent = session === current.id ? current : known.get(session);
@@ -366,9 +403,113 @@ export function createSessionsClient({ name, claude, report, isGranted }) {
366
403
  lastAt: data.closedAt ?? data.openedAt,
367
404
  }), { session, task: data.id });
368
405
  };
369
- const currentSession = sessionOf(() => current);
370
- const every = recordPart({});
371
- const answerBefore = async (session, event, change, isInProject) => (isGranted('conversation') ? combined(session, event, change, isInProject) : undefined);
406
+ const processListeners = new Map();
407
+ const raise = async (method, payload) => {
408
+ const isBefore = method.startsWith('before');
409
+ 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
+ })));
415
+ return undefined;
416
+ }
417
+ 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;
439
+ };
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
+ const commandRun = async (e) => {
442
+ const source = (await claude.command.list()).find((command) => command.name === e.command)?.source;
443
+ return { session: current.id, cwd: current.cwd, name: e.command, args: e.args, ...defined({ source }), from: senderOf(e.origin, false) };
444
+ };
445
+ const commandListeners = { beforeRun: new Set(), onRun: new Set() };
446
+ for (const [method, held] of Object.entries(commandListeners))
447
+ processListeners.set(method, held);
448
+ routeOn?.('command.run', async (e, next) => {
449
+ if (commandListeners.beforeRun.size === 0 && commandListeners.onRun.size === 0)
450
+ return next(e);
451
+ const run = await commandRun(e);
452
+ const answer = commandListeners.beforeRun.size === 0 ? undefined : await raise('beforeRun', run);
453
+ if (typeof answer?.['refuse'] === 'string')
454
+ return { text: answer['refuse'] };
455
+ const changes = answer !== undefined && isGranted('prompt') ? answer : undefined;
456
+ if (answer !== undefined && changes === undefined)
457
+ report('session:beforeRun', 'The beforeRun listener failed', new Error(`${name}: its answer changes a command, which needs the prompt permission.`));
458
+ if (typeof changes?.['result'] === 'string')
459
+ return { text: changes['result'], ...(typeof changes['exitCode'] === 'number' ? { exitCode: changes['exitCode'] } : {}) };
460
+ const args = typeof changes?.['args'] === 'string' ? changes['args'] : e.args;
461
+ const ran = await next(args === e.args ? e : { ...e, args });
462
+ void raise('onRun', { ...run, args });
463
+ return typeof changes?.['note'] === 'string' ? { ...ran, context: [...(ran.context ?? []), changes['note']] } : ran;
464
+ });
465
+ const listenTo = (method, named) => {
466
+ if (hookOn === undefined || routeOn === undefined)
467
+ throw new Error(`${name}: ${method} works only in a mod's own session.`);
468
+ 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
+ });
485
+ }
486
+ const event = processEvents[named];
487
+ const hook = (isBefore ? event.before : undefined) ?? event.on;
488
+ return hookOn(hook, async (input) => {
489
+ const payload = processPayloadOf(named, input);
490
+ if (!isBefore) {
491
+ if (named !== 'SessionStart' || payload['source'] !== 'compact')
492
+ void raise(method, payload);
493
+ return undefined;
494
+ }
495
+ const answer = await raise(method, payload);
496
+ return answer === undefined ? undefined : hookAnswerOf(named, hook, answer);
497
+ });
498
+ };
499
+ const listen = (method, args) => {
500
+ const entry = listenerOf(name, method, args);
501
+ const held = processListeners.get(method) ?? new Set();
502
+ if (!processListeners.has(method)) {
503
+ listenTo(method, method.replace(/^(on|before)/, ''));
504
+ processListeners.set(method, held);
505
+ }
506
+ held.add(entry);
507
+ return { dispose: () => void held.delete(entry) };
508
+ };
509
+ const processMethods = () => Object.fromEntries(Object.entries(processEvents).flatMap(([named, event]) => [`on${named}`, ...('before' in event ? [`before${named}`] : [])]).map((method) => [method, (...args) => listen(method, args)]));
510
+ const currentSession = Object.assign(sessionOf(() => current), processMethods());
511
+ const every = eventMethods(() => ({}), false);
512
+ const answerBefore = async (session, event, change, isInProject) => (session === current.id || isGranted('conversation') ? combined(session, event, change, isInProject) : undefined);
372
513
  return {
373
514
  sessions: {
374
515
  get current() {
@@ -383,23 +524,27 @@ export function createSessionsClient({ name, claude, report, isGranted }) {
383
524
  current = { ...current, title: data.title };
384
525
  return currentSession;
385
526
  }),
386
- search: (text, options = {}) => call(conversationMethods.search, { text, ...optionsOf(options), ...(options.running === undefined ? {} : { running: options.running }), ...(options.since === undefined ? {} : { since: options.since }), ...(options.everyProject === undefined ? {} : { everyProject: options.everyProject }) }),
387
- on: every.on,
388
- before: every.before,
527
+ search: (text, matcher, options = {}) => searched({ text, ...searchOptionsOf(options) }, matcher),
528
+ ...every,
389
529
  },
390
530
  changed(session, change, isInProject) {
391
531
  remember(session, change);
392
532
  for (const key of isInProject ? [session, everySession] : [session]) {
393
- for (const { scope, event, matcher, listener } of listening.get(key) ?? []) {
533
+ for (const { scope, event, method, matcher, listener, options } of listening.get(key) ?? []) {
394
534
  if (!isHeardBy(event, matcher, change) || !isInScope(scope, session, change, isUnderIn(session)))
395
535
  continue;
396
- void Promise.resolve()
397
- .then(() => listener(payloadFor(session, change)))
398
- .catch((error) => report(`session:${event}`, `The ${event} listener failed`, error));
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
+ });
399
542
  }
400
543
  }
401
544
  },
402
545
  answerBefore,
546
+ raise,
547
+ listen,
403
548
  follow(next) {
404
549
  const moved = next.id !== current.id && current.id !== '';
405
550
  const previous = current.id;
@@ -422,12 +567,128 @@ export function createSessionsClient({ name, claude, report, isGranted }) {
422
567
  },
423
568
  };
424
569
  }
425
- export function resultOf(where, answered) {
426
- const base = { id: '', type: ToolResult, at: new Date().toISOString(), session: where.session, context: where.context, ...(where.turn === undefined ? {} : { turn: where.turn.prompt.id }), callId: '' };
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));
573
+ const placementOf = (input) => defined({ session: input['session_id'], task: input['agent_id'], agentType: input['agent_type'], cwd: input['cwd'], permissionMode: input['permission_mode'] });
574
+ const mcpServerOf = (server) => (server === undefined ? undefined : { name: String(recordOf(server)['name']), source: String(recordOf(server)['source']) });
575
+ const modelChangeOf = (input) => ({
576
+ model: input['to_model'],
577
+ previous: input['from_model'],
578
+ requested: input['requested_model'] ?? undefined,
579
+ source: input['source'],
580
+ contextTokenCount: input['context_tokens'],
581
+ isCacheWarm: input['prompt_cache_warm'],
582
+ cacheTtl: input['cache_ttl'],
583
+ cacheWriteUsd: input['estimated_cache_write_usd'],
584
+ pricing: input['pricing'],
585
+ });
586
+ const answerKeys = {
587
+ Run: ['args', 'note', 'result', 'exitCode', 'refuse'],
588
+ ProjectSetup: [],
589
+ SessionStart: ['note', 'title', 'reloadSkills', 'firstPrompt'],
590
+ ModelChange: ['ask', 'refuse'],
591
+ WorktreeCreate: ['result'],
592
+ Elicitation: ['refuse'],
593
+ ElicitationResponse: ['refuse'],
594
+ PermissionCheck: ['allow', 'ask', 'refuse'],
595
+ PermissionRequest: ['allow', 'input', 'updates', 'refuse', 'interrupt'],
596
+ PermissionDenial: ['retry'],
597
+ ToolBatch: ['note'],
598
+ TodoCreate: ['refuse'],
599
+ TodoComplete: ['refuse'],
600
+ ReplyDisplay: ['text'],
601
+ };
602
+ const todoOf = (input) => ({ id: input['task_id'], subject: input['task_subject'], description: input['task_description'], teammate: input['teammate_name'] });
603
+ const processFields = {
604
+ ProjectSetup: (input) => ({ trigger: input['trigger'] }),
605
+ SessionStart: (input) => ({
606
+ source: input['source'],
607
+ model: input['model'],
608
+ title: input['session_title'] || undefined,
609
+ idleSeconds: input['seconds_since_last_response'],
610
+ contextTokenCount: input['context_tokens'],
611
+ isCacheWarm: input['prompt_cache_likely_expired'] === undefined ? undefined : input['prompt_cache_likely_expired'] !== true,
612
+ cacheWriteUsd: input['estimated_cache_write_usd'],
613
+ }),
614
+ SessionEnd: (input) => ({ reason: input['reason'] === 'prompt_input_exit' ? 'exit' : input['reason'] }),
615
+ ModelChange: modelChangeOf,
616
+ CwdChange: (input) => ({ cwd: input['new_cwd'] ?? input['cwd'], previous: input['old_cwd'] }),
617
+ DirectoryAdd: (input) => ({ directory: input['directory'], source: input['source'] === 'slash_command' ? 'command' : 'sdk' }),
618
+ WorktreeCreate: (input) => ({ name: input['name'] }),
619
+ WorktreeRemove: (input) => ({ path: input['worktree_path'] }),
620
+ Notification: (input) => ({ text: input['message'], title: input['title'], type: input['notification_type'] }),
621
+ Elicitation: (input) => ({ id: input['elicitation_id'], server: input['mcp_server_name'], text: input['message'], mode: input['mode'], url: input['url'], schema: input['requested_schema'] }),
622
+ ElicitationResponse: (input) => ({ id: input['elicitation_id'], server: input['mcp_server_name'], mode: input['mode'], action: input['action'], content: input['content'] }),
623
+ PermissionRequest: (input) => ({ name: input['tool_name'], input: recordOf(input['tool_input']), suggestions: input['permission_suggestions'] ?? [], mcpServer: mcpServerOf(input['mcp_server']) }),
624
+ PermissionDenial: (input) => ({ name: input['tool_name'], input: recordOf(input['tool_input']), callId: input['tool_use_id'], reason: input['reason'], mcpServer: mcpServerOf(input['mcp_server']) }),
625
+ ToolBatch: (input) => ({
626
+ calls: (Array.isArray(input['tool_calls']) ? input['tool_calls'] : []).map((call) => {
627
+ const fields = recordOf(call);
628
+ return defined({ callId: fields['tool_use_id'], name: fields['tool_name'], input: recordOf(fields['tool_input']), output: fields['tool_response'] });
629
+ }),
630
+ }),
631
+ TodoCreate: todoOf,
632
+ TodoComplete: todoOf,
633
+ ReplyDisplay: (input) => ({ replyId: input['message_id'], index: input['index'], isLast: input['final'], text: input['delta'] }),
634
+ };
635
+ export async function spawnTask(claude, task) {
636
+ if (task.title.trim() === '')
637
+ throw new Error('a task needs a title.');
638
+ const started = await claude.agent.spawn({
639
+ prompt: task.prompt,
640
+ description: task.title,
641
+ ...(task.name === undefined ? {} : { name: task.name }),
642
+ ...(task.agentType === undefined ? {} : { subagentType: task.agentType }),
643
+ ...(task.model === undefined ? {} : { model: task.model }),
644
+ ...(task.cwd === undefined ? {} : { cwd: task.cwd }),
645
+ });
646
+ if (started.deny !== undefined)
647
+ throw new Error(`Claude Code refused the task: ${started.deny}`);
648
+ if (started.agentId === undefined)
649
+ throw new Error('Claude Code answered without starting a task.');
650
+ return { id: started.agentId, title: task.title, agentType: task.agentType ?? 'general-purpose', model: started.model, openedBy: '', openedAt: new Date().toISOString() };
651
+ }
652
+ export function processPayloadOf(named, input) {
653
+ const fields = recordOf(input);
654
+ return { ...placementOf(fields), ...defined(processFields[named](fields)) };
655
+ }
656
+ export function hookAnswerOf(named, hook, answer) {
657
+ const output = (fields) => ({ hookSpecificOutput: { hookEventName: hook, ...defined(fields) } });
658
+ const refuse = typeof answer['refuse'] === 'string' ? answer['refuse'] : undefined;
659
+ if (named === 'SessionStart')
660
+ return output({ additionalContext: answer['note'], sessionTitle: answer['title'], reloadSkills: answer['reloadSkills'], initialUserMessage: answer['firstPrompt'] });
661
+ if (named === 'ModelChange') {
662
+ if (refuse !== undefined)
663
+ return output({ permissionDecision: 'deny', permissionDecisionReason: refuse });
664
+ return typeof answer['ask'] === 'string' ? output({ permissionDecision: 'ask', permissionDecisionReason: answer['ask'] }) : undefined;
665
+ }
666
+ 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
+ }
673
+ if (named === 'PermissionDenial')
674
+ return output({ retry: answer['retry'] });
675
+ if (named === 'ToolBatch')
676
+ return output({ additionalContext: answer['note'] });
677
+ if (named === 'ReplyDisplay')
678
+ return output({ displayContent: answer['text'] });
679
+ return refuse === undefined ? undefined : { decision: 'block', reason: refuse };
680
+ }
681
+ export function resultOf(where, call, answered) {
682
+ 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 };
427
683
  if (answered.deny !== undefined)
428
- return { ...base, text: answered.deny, isError: true };
429
- return { ...base, text: answered.text ?? JSON.stringify(answered.result), isError: answered.isError === true };
684
+ return { ...base, output: answered.deny, isError: true };
685
+ return { ...base, output: answered.result ?? answered.text, isError: answered.isError === true };
430
686
  }
431
- function optionsOf(options) {
432
- return { ...(options.limit === undefined ? {} : { limit: options.limit }), ...(options.before === undefined ? {} : { before: { session: options.before.session, id: options.before.id } }) };
687
+ function searchOptionsOf({ running, since, everyProject, before }) {
688
+ return {
689
+ ...(running === undefined ? {} : { running }),
690
+ ...(since === undefined ? {} : { since }),
691
+ ...(everyProject === undefined ? {} : { everyProject }),
692
+ ...(before === undefined ? {} : { before: { session: before.session, id: before.id } }),
693
+ };
433
694
  }
@@ -6,7 +6,6 @@ export type Agent = {
6
6
  };
7
7
  export type ToolCalls = {
8
8
  agentOf(toolUseId: string | undefined): Promise<Agent>;
9
- cwdOf(toolUseId: string): string | undefined;
10
9
  };
11
10
  export declare const reservedKeys: readonly string[];
12
11
  export declare function toolInputOf(envelope: object): Record<string, unknown>;
@@ -1 +1 @@
1
- {"version":3,"file":"tool-calls.d.ts","sourceRoot":"","sources":["../src/runtime/tool-calls.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AACzC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAEzC,MAAM,MAAM,KAAK,GAAG;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAAA;AAE5D,MAAM,MAAM,SAAS,GAAG;IACtB,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,KAAK,CAAC,CAAA;IACtD,KAAK,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAA;CAC7C,CAAA;AAED,eAAO,MAAM,YAAY,EAAE,SAAS,MAAM,EAAkD,CAAA;AAE5F,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAErE;AAMD,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,CA8CnE"}
1
+ {"version":3,"file":"tool-calls.d.ts","sourceRoot":"","sources":["../src/runtime/tool-calls.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AACzC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAEzC,MAAM,MAAM,KAAK,GAAG;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAAA;AAE5D,MAAM,MAAM,SAAS,GAAG;IACtB,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,KAAK,CAAC,CAAA;CACvD,CAAA;AAED,eAAO,MAAM,YAAY,EAAE,SAAS,MAAM,EAAkD,CAAA;AAE5F,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAErE;AAID,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,CA8BnE"}