@cmodjs/core 1.6.0 → 2.0.1

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 (136) hide show
  1. package/docs/commands.md +7 -7
  2. package/docs/dependencies.md +1 -1
  3. package/docs/index.md +12 -11
  4. package/docs/jobs.md +12 -16
  5. package/docs/mod.md +111 -36
  6. package/docs/permissions.md +14 -18
  7. package/docs/session.md +171 -82
  8. package/docs/state.md +5 -5
  9. package/docs/testing.md +57 -41
  10. package/docs/ui.md +9 -9
  11. package/jobs/permissions/decide-permission.d.ts.map +1 -1
  12. package/jobs/permissions/decide-permission.js +1 -1
  13. package/jobs/prompt.d.ts +1 -3
  14. package/jobs/prompt.d.ts.map +1 -1
  15. package/jobs/prompt.js +2 -21
  16. package/jobs/slash-command.d.ts +1 -1
  17. package/jobs/slash-command.d.ts.map +1 -1
  18. package/jobs/slash-command.js +1 -1
  19. package/jobs/status-line.d.ts +0 -1
  20. package/jobs/status-line.d.ts.map +1 -1
  21. package/jobs/status-line.js +3 -4
  22. package/mod.d.ts +53 -105
  23. package/mod.d.ts.map +1 -1
  24. package/mod.js +18 -80
  25. package/package.json +1 -1
  26. package/records.d.ts +1 -1
  27. package/records.js +1 -1
  28. package/register.d.ts.map +1 -1
  29. package/register.js +8 -5
  30. package/runtime/before.d.ts +11 -4
  31. package/runtime/before.d.ts.map +1 -1
  32. package/runtime/before.js +131 -67
  33. package/runtime/claude.d.ts +6 -1
  34. package/runtime/claude.d.ts.map +1 -1
  35. package/runtime/conversations.d.ts.map +1 -1
  36. package/runtime/conversations.js +82 -38
  37. package/runtime/deadline.d.ts +17 -0
  38. package/runtime/deadline.d.ts.map +1 -1
  39. package/runtime/deadline.js +34 -0
  40. package/runtime/dependencies.d.ts +2 -0
  41. package/runtime/dependencies.d.ts.map +1 -1
  42. package/runtime/dependencies.js +12 -0
  43. package/runtime/grants.d.ts.map +1 -1
  44. package/runtime/grants.js +13 -8
  45. package/runtime/hooks.d.ts +15 -11
  46. package/runtime/hooks.d.ts.map +1 -1
  47. package/runtime/hooks.js +8 -165
  48. package/runtime/lifecycle.d.ts +4 -2
  49. package/runtime/lifecycle.d.ts.map +1 -1
  50. package/runtime/lifecycle.js +96 -62
  51. package/runtime/permission-check.d.ts +15 -0
  52. package/runtime/permission-check.d.ts.map +1 -0
  53. package/runtime/permission-check.js +37 -0
  54. package/runtime/sessions.d.ts +35 -5
  55. package/runtime/sessions.d.ts.map +1 -1
  56. package/runtime/sessions.js +304 -82
  57. package/runtime/state.d.ts.map +1 -1
  58. package/runtime/state.js +14 -11
  59. package/runtime/tool-calls.d.ts +0 -1
  60. package/runtime/tool-calls.d.ts.map +1 -1
  61. package/runtime/tool-calls.js +0 -19
  62. package/session/context/turn/message/instruction-file.d.ts +9 -0
  63. package/session/context/turn/message/instruction-file.d.ts.map +1 -0
  64. package/session/context/turn/message/instruction-file.js +4 -0
  65. package/session/context/turn/message/message.d.ts +6 -4
  66. package/session/context/turn/message/message.d.ts.map +1 -1
  67. package/session/context/turn/message/message.js +12 -8
  68. package/session/context/turn/message/sender.d.ts +2 -1
  69. package/session/context/turn/message/sender.d.ts.map +1 -1
  70. package/session/context/turn/message/sender.js +4 -1
  71. package/session/context/turn/message/tool-call.d.ts +5 -0
  72. package/session/context/turn/message/tool-call.d.ts.map +1 -1
  73. package/session/context/turn/message/tool-result.d.ts +6 -1
  74. package/session/context/turn/message/tool-result.d.ts.map +1 -1
  75. package/session/context/turn/turn.d.ts +7 -0
  76. package/session/context/turn/turn.d.ts.map +1 -1
  77. package/session/conversation.d.ts +10 -3
  78. package/session/conversation.d.ts.map +1 -1
  79. package/session/conversation.js +14 -5
  80. package/session/goal/goal.d.ts +1 -1
  81. package/session/goal/goal.d.ts.map +1 -1
  82. package/session/goal/task.d.ts +2 -2
  83. package/session/goal/task.d.ts.map +1 -1
  84. package/session/session.d.ts +361 -26
  85. package/session/session.d.ts.map +1 -1
  86. package/session/session.js +40 -0
  87. package/src/jobs/permissions/decide-permission.ts +2 -1
  88. package/src/jobs/prompt.ts +3 -20
  89. package/src/jobs/slash-command.ts +2 -2
  90. package/src/jobs/status-line.ts +4 -7
  91. package/src/mod.ts +52 -147
  92. package/src/records.ts +1 -1
  93. package/src/register.ts +8 -5
  94. package/src/runtime/before.ts +140 -66
  95. package/src/runtime/claude.ts +5 -1
  96. package/src/runtime/conversations.ts +75 -37
  97. package/src/runtime/deadline.ts +38 -0
  98. package/src/runtime/dependencies.ts +12 -0
  99. package/src/runtime/grants.ts +11 -7
  100. package/src/runtime/hooks.ts +24 -163
  101. package/src/runtime/lifecycle.ts +110 -77
  102. package/src/runtime/permission-check.ts +47 -0
  103. package/src/runtime/sessions.ts +334 -91
  104. package/src/runtime/state.ts +15 -8
  105. package/src/runtime/tool-calls.ts +0 -19
  106. package/src/session/context/turn/message/instruction-file.ts +13 -0
  107. package/src/session/context/turn/message/message.ts +17 -11
  108. package/src/session/context/turn/message/sender.ts +5 -2
  109. package/src/session/context/turn/message/tool-call.ts +3 -0
  110. package/src/session/context/turn/message/tool-result.ts +6 -1
  111. package/src/session/context/turn/turn.ts +4 -0
  112. package/src/session/conversation.ts +21 -8
  113. package/src/session/goal/goal.ts +1 -1
  114. package/src/session/goal/task.ts +2 -2
  115. package/src/session/session.ts +227 -24
  116. package/src/testing/claude-code.ts +23 -0
  117. package/src/testing/fake-claude.ts +5 -1
  118. package/src/testing/fake-sessions.ts +38 -24
  119. package/src/testing.ts +88 -19
  120. package/testing/claude-code.d.ts +12 -0
  121. package/testing/claude-code.d.ts.map +1 -0
  122. package/testing/claude-code.js +10 -0
  123. package/testing/fake-claude.d.ts +4 -0
  124. package/testing/fake-claude.d.ts.map +1 -1
  125. package/testing/fake-claude.js +4 -1
  126. package/testing/fake-sessions.d.ts +8 -6
  127. package/testing/fake-sessions.d.ts.map +1 -1
  128. package/testing/fake-sessions.js +35 -20
  129. package/testing.d.ts +38 -9
  130. package/testing.d.ts.map +1 -1
  131. package/testing.js +58 -8
  132. package/docs/hooks.md +0 -259
  133. package/session/context/turn/message/document.d.ts +0 -7
  134. package/session/context/turn/message/document.d.ts.map +0 -1
  135. package/session/context/turn/message/document.js +0 -1
  136. package/src/session/context/turn/message/document.ts +0 -8
@@ -1,8 +1,8 @@
1
1
  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
- import { Document } from '../session/context/turn/message/document.js'
5
- import { Message, messagesOf, rowOfAppend, withTexts, type Unplaced } from '../session/context/turn/message/message.js'
4
+ import { InstructionFile, isRulePath } from '../session/context/turn/message/instruction-file.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'
@@ -13,22 +13,40 @@ 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
15
  import type { ConversationState, SessionChange, TaskData } from '../session/conversation.js'
16
- import { TaskEnd, TaskStart } from '../session/goal/task.js'
17
- import type { BeforeEvent } from '../session/session.js'
16
+ import { Task, TaskStart } from '../session/goal/task.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
- export type Place = Pick<ConversationState, 'session' | 'context' | 'contextOpenedAt' | 'turn' | 'tasks' | 'taskTurns'>
24
+ export type Place = Pick<ConversationState, 'session' | 'context' | 'contextOpenedAt' | 'turn' | 'tasks' | 'taskTurns' | 'openCalls'>
24
25
 
25
26
  export type Decide = (event: BeforeEvent, change: SessionChange) => Promise<Partial<Answers> | undefined>
26
27
 
27
- export type Answers = { readonly text: string; readonly refuse: string; readonly input: Readonly<Record<string, unknown>>; readonly result: string; readonly owner: { readonly model: string } }
28
+ export type Answers = {
29
+ readonly text: string
30
+ readonly output: unknown
31
+ readonly isError: true
32
+ readonly note: string
33
+ readonly title: string
34
+ readonly refuse: string
35
+ readonly input: Readonly<Record<string, unknown>>
36
+ readonly result: string
37
+ readonly owner: { readonly model: string }
38
+ }
28
39
 
29
40
  export type BeforeHooks = { readonly place: () => Promise<Place>; readonly wants: (event: BeforeEvent) => Promise<boolean>; readonly decide: Decide }
30
41
 
31
- const appended: readonly BeforeEvent[] = [Note, Reply, ToolResult, Notice]
42
+ const appended: readonly BeforeEvent[] = [Note, Reply, Notice]
43
+
44
+ type ClassicFields = Readonly<Record<string, unknown>>
45
+
46
+ const withNote = (below: unknown, note: string | undefined): ClassicFields => {
47
+ const fields = (below ?? {}) as ClassicFields
48
+ return note === undefined ? fields : { ...fields, additionalContext: [...((fields['additionalContext'] as readonly string[] | undefined) ?? []), note] }
49
+ }
32
50
 
33
51
  const relayTag = /^(?:<cross-session-message[^>]*>\s*)?<cmod-relay>([\s\S]*)<\/cmod-relay>(?:\s*<\/cross-session-message>[\s\S]*)?$/
34
52
 
@@ -44,28 +62,51 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
44
62
  const message = (where: Place, unplaced: Unplaced, task: string | undefined, isInTurn = true): SessionChange => {
45
63
  const turn = isInTurn ? (task === undefined ? where.turn : where.taskTurns[task]) : undefined
46
64
  const context = task === undefined ? where.context : (where.tasks[task]?.context ?? where.context)
47
- return { type: Message, message: { ...unplaced, session: where.session, context, ...(turn === undefined ? {} : { turn: turn.prompt.id }), ...(task === undefined ? {} : { task }) } as Message }
65
+ const call = unplaced.type === ToolResult ? where.openCalls?.[unplaced.callId] : undefined
66
+ return { type: Message, message: { ...unplaced, ...call, session: where.session, context, ...(turn === undefined ? {} : { turn: turn.prompt.id }), ...(task === undefined ? {} : { task }) } as Message }
67
+ }
68
+ const titles = new Map<string, string>()
69
+ const spawned = new Map<string, string>()
70
+ const agentFolders = new Map<string, string>()
71
+ const toolResultHook = (isFailure: boolean) => async (e: Readonly<Record<string, unknown>>, next: (e: never) => Promise<unknown>) => {
72
+ if (!(await wants(ToolResult))) return next(e as never)
73
+ const where = await place()
74
+ const name = String(e['tool_name'])
75
+ const callId = String(e['tool_use_id'])
76
+ const input = (typeof e['tool_input'] === 'object' && e['tool_input'] !== null ? e['tool_input'] : {}) as Readonly<Record<string, unknown>>
77
+ const task = typeof e['agent_id'] === 'string' ? e['agent_id'] : undefined
78
+ const result = { id: callId, at: now(), type: ToolResult, callId, name, input, cwd: String(e['cwd']), files: noFiles, output: isFailure ? e['error'] : e['tool_response'], isError: isFailure } as const
79
+ const answer = await asked(ToolResult, message(where, result, task))
80
+ const below = await next(e as never)
81
+ const rewritten = answer.output === undefined || isFailure ? {} : name.startsWith('mcp__') ? { updatedMCPToolOutput: answer.output } : { updatedToolOutput: answer.output }
82
+ const failed = answer.isError === true && !isFailure ? { block: answer.note ?? `${name} failed.` } : {}
83
+ return { ...withNote(below, answer.isError === true ? undefined : answer.note), ...rewritten, ...failed }
48
84
  }
49
85
  const added: Disposable[] = [
50
86
  router.add('prompt.submit', async (e, next) => {
51
- if (!(await wants(Prompt)) || e.text.startsWith('/')) return next(e)
87
+ if (!(await wants(Prompt))) return next(e)
52
88
  const where = await place()
53
89
  const answer = await asked(Prompt, message(where, { id: '', at: now(), type: Prompt, text: e.text, from: senderOf(e.origin, false) }, undefined, false))
54
90
  if (answer.refuse !== undefined) return { drop: answer.refuse }
55
- return next(answer.text === undefined ? e : { ...e, text: answer.text })
91
+ const text = answer.text ?? e.text
92
+ if (answer.title !== undefined) titles.set(text, answer.title)
93
+ const context = answer.note === undefined ? e.context : [...(e.context ?? []), answer.note]
94
+ const ran = await next({ ...e, text, ...(context === undefined ? {} : { context }) })
95
+ const passed = titles.get(text)
96
+ if (passed !== undefined && ('drop' in ran || ran.text !== text)) {
97
+ titles.delete(text)
98
+ if (!('drop' in ran) && typeof ran.text === 'string') titles.set(ran.text, passed)
99
+ }
100
+ return ran
56
101
  }),
57
- router.add('command.run', async (e, next) => {
58
- if (!(await wants(Prompt))) return next(e)
59
- const typed = e.args === '' ? `/${e.command}` : `/${e.command} ${e.args}`
60
- const where = await place()
61
- const answer = await asked(Prompt, message(where, { id: '', at: now(), type: Prompt, text: typed, from: senderOf(e.origin, false) }, undefined, false))
62
- if (answer.refuse !== undefined) return { text: answer.refuse }
63
- if (answer.text === undefined || answer.text === typed) return next(e)
64
- const same = new RegExp(`^/${e.command}(?:\\s+|$)`).exec(answer.text)
65
- if (same !== null) return next({ ...e, args: answer.text.slice(same[0].length) })
66
- void claude.prompt.submit({ text: answer.text })
67
- return {}
102
+ router.add('classic.UserPromptSubmit', async (e, next) => {
103
+ const named = titles.get(e.prompt)
104
+ titles.delete(e.prompt)
105
+ const below = await next(e)
106
+ return named === undefined ? below : { ...below, sessionTitle: named }
68
107
  }),
108
+ router.add('classic.PostToolUse', toolResultHook(false) as never),
109
+ router.add('classic.PostToolUseFailure', toolResultHook(true) as never),
69
110
  router.add('session.receive', async (e, next) => {
70
111
  if (!(await wants(Prompt)) || relayOf(e) !== undefined) return next(e)
71
112
  const where = await place()
@@ -80,10 +121,10 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
80
121
  return answer.text === undefined ? next(e) : { text: answer.text }
81
122
  }),
82
123
  router.add('prompt.attachment', async (e, next) => {
83
- if (!(await wants(Document)) || e.type !== 'nested_memory') return next(e)
124
+ if (!(await wants(InstructionFile)) || e.type !== 'nested_memory') return next(e)
84
125
  const where = await place()
85
- const path = /^Contents of (.+?) \(/.exec(e.text)?.[1] ?? ''
86
- const answer = await asked(Document, message(where, { id: '', at: now(), type: Document, path, text: e.text }, e.agentId))
126
+ const path = instructionPathOf(e.text)
127
+ const answer = await asked(InstructionFile, message(where, { id: '', at: now(), type: InstructionFile, path, text: e.text, isRule: isRulePath(path) }, e.agentId))
87
128
  if (answer.refuse !== undefined) return { text: null }
88
129
  return answer.text === undefined ? next(e) : { text: answer.text }
89
130
  }),
@@ -106,7 +147,8 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
106
147
  if (!(await wants(ToolCall))) return next(e)
107
148
  const where = await place()
108
149
  const input = toolInputOf(e)
109
- 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() }, 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))
110
152
  if (answer.refuse !== undefined) return { deny: answer.refuse }
111
153
  if (answer.result !== undefined) return { result: answer.result }
112
154
  if (answer.input === undefined) return next(e)
@@ -114,23 +156,46 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
114
156
  return next({ ...reserved, ...answer.input } as Frozen<Args<'tool.call'>>)
115
157
  }),
116
158
  router.add('agent.spawn', async (e, next) => {
117
- if (!(await wants(TaskStart))) return next(e)
159
+ spawned.set(e.tool_use_id, e.prompt)
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)
118
167
  const where = await place()
119
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 }) }
120
169
  const answer = await asked(TaskStart, { type: TaskStart, task: { ...task, context: where.context } })
121
- if (answer.refuse !== undefined) return { deny: answer.refuse }
122
- return next(answer.owner === undefined ? e : { ...e, model: answer.owner.model })
170
+ if (answer.refuse !== undefined) {
171
+ spawned.delete(e.tool_use_id)
172
+ return { deny: answer.refuse }
173
+ }
174
+ return start(answer.owner === undefined ? e : { ...e, model: answer.owner.model })
123
175
  }),
124
- router.add('classic.SubagentStop', async (e, next) => {
125
- if (!(await wants(TaskEnd)) && !(await wants(TurnEnd))) return next(e)
176
+ router.add('classic.SubagentStart', async (e, next) => {
126
177
  const where = await place()
127
- const task = where.tasks[e.agent_id]
128
- if (task === undefined) return next(e)
129
- const turn = where.taskTurns[e.agent_id]
130
- const ending = (await wants(TaskEnd)) ? await asked(TaskEnd, { type: TaskEnd, task: { ...task, closedAt: now() } }) : {}
131
- const refuse = ending.refuse ?? ((await wants(TurnEnd)) && turn !== undefined ? (await asked(TurnEnd, { type: TurnEnd, turn: { ...turn, closedAt: now() } })).refuse : undefined)
178
+ const openedBy = where.tasks[e.agent_id]?.openedBy
179
+ const prompt = openedBy === undefined ? undefined : spawned.get(openedBy)
180
+ if (openedBy !== undefined) spawned.delete(openedBy)
181
+ if (!(await wants(Prompt))) return next(e)
182
+ const open = where.taskTurns[e.agent_id]?.prompt
183
+ const text = open?.text ?? prompt
184
+ if (text === undefined) return next(e)
185
+ const answer = await asked(Prompt, message(where, { id: open?.id ?? '', at: now(), type: Prompt, text, from: open?.from ?? { type: Task } }, e.agent_id, false))
186
+ return withNote(await next(e), answer.note)
187
+ }),
188
+ router.add('classic.SubagentStop', async (e, next) => {
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))
194
+ const turn = (await place()).taskTurns[e.agent_id]
195
+ if (turn === undefined) return stopped(await next(e))
196
+ const answer = await asked(TurnEnd, { type: TurnEnd, turn: { ...turn, closedAt: now() } })
132
197
  const below = await next(e)
133
- return refuse === undefined ? below : { ...below, block: refuse }
198
+ return stopped(answer.refuse === undefined ? below : { ...below, block: answer.refuse })
134
199
  }),
135
200
  router.add('classic.Stop', async (e, next) => {
136
201
  if (!(await wants(TurnEnd))) return next(e)
@@ -154,47 +219,56 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
154
219
  }
155
220
 
156
221
  export function withAnswer(change: SessionChange, answer: Partial<Answers>): SessionChange {
222
+ if (change.type === Message && answer.output !== undefined && change.message.type === ToolResult) return { ...change, message: { ...change.message, output: answer.output } }
157
223
  if (change.type === Message && answer.text !== undefined) return { ...change, message: { ...change.message, text: answer.text } as Message }
158
224
  if (change.type === Message && answer.input !== undefined && change.message.type === ToolCall) return { ...change, message: { ...change.message, input: answer.input } }
159
225
  if (change.type === TaskStart && answer.owner !== undefined) return { ...change, task: { ...change.task, model: answer.owner.model } }
160
226
  return change
161
227
  }
162
228
 
163
- export async function foldAnswers(event: BeforeEvent, change: SessionChange, askers: readonly ((change: SessionChange) => Promise<unknown>)[], report: (error: unknown) => void): Promise<Partial<Answers> | undefined> {
229
+ export const methodOf = (prefix: 'on' | 'before', event: SessionEvent): string => `${prefix}${Object.entries(sessionEvents).find(([, each]) => each === event)?.[0] ?? event}`
230
+
231
+ export const beforeAnswerKeys: Readonly<Record<BeforeEvent, readonly (keyof Answers)[]>> = {
232
+ prompt: ['text', 'note', 'title', 'refuse'],
233
+ note: ['text'],
234
+ skill: ['text'],
235
+ instructionFile: ['text', 'refuse'],
236
+ reply: ['text'],
237
+ toolCall: ['input', 'result', 'refuse'],
238
+ toolResult: ['output', 'isError', 'note'],
239
+ notice: ['text'],
240
+ taskStart: ['owner', 'refuse'],
241
+ turnEnd: ['refuse'],
242
+ contextEnd: ['refuse'],
243
+ }
244
+
245
+ export function foldBefore(event: BeforeEvent, change: SessionChange, askers: readonly ((change: SessionChange) => Promise<unknown>)[], report: (error: unknown) => void): Promise<Partial<Answers> | undefined> {
246
+ return foldAnswers(methodOf('before', event), beforeAnswerKeys[event], ['refuse', 'result'], change, askers, report, withAnswer)
247
+ }
248
+
249
+ export function takenAnswer(method: string, takes: readonly string[], answer: unknown): unknown {
250
+ if (answer === undefined || answer === null || answer === false) return undefined
251
+ 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'}.`)
252
+ const unread = Object.keys(answer).filter((key) => !takes.includes(key))
253
+ 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'}.`)
254
+ return answer
255
+ }
256
+
257
+ 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> {
164
258
  let latest = change
165
- let result: Partial<Answers> | undefined
259
+ let result: Record<string, unknown> | undefined
166
260
  for (const ask of askers) {
167
261
  try {
168
- const answer = answerOf(event, await ask(latest))
169
- if (answer === undefined) continue
170
- result = { ...result, ...answer }
171
- if (answer.refuse !== undefined || answer.result !== undefined) return result
172
- latest = withAnswer(latest, answer)
262
+ const answer = takenAnswer(method, takes, await ask(latest))
263
+ if (typeof answer !== 'object' || answer === null) continue
264
+ const given = answer as Record<string, unknown>
265
+ const notes = [result?.['note'], given['note']].filter((note) => typeof note === 'string')
266
+ result = { ...result, ...given, ...(notes.length === 0 ? {} : { note: notes.join('\n\n') }) }
267
+ if (ends.some((key) => given[key] !== undefined)) return result as Answer
268
+ latest = applied(latest, answer as Answer)
173
269
  } catch (error) {
174
270
  report(error)
175
271
  }
176
272
  }
177
- return result
178
- }
179
-
180
- export function answerOf(event: BeforeEvent, answer: unknown): Partial<Answers> | undefined {
181
- if (typeof answer !== 'object' || answer === null) return undefined
182
- const given = answer as Partial<Answers>
183
- const allowed: Readonly<Record<BeforeEvent, readonly (keyof Answers)[]>> = {
184
- prompt: ['text', 'refuse'],
185
- note: ['text'],
186
- skill: ['text'],
187
- document: ['text', 'refuse'],
188
- reply: ['text'],
189
- toolCall: ['input', 'result', 'refuse'],
190
- toolResult: ['text'],
191
- notice: ['text'],
192
- taskStart: ['owner', 'refuse'],
193
- taskEnd: ['refuse'],
194
- turnEnd: ['refuse'],
195
- contextEnd: ['refuse'],
196
- }
197
- const unread = Object.keys(given).filter((key) => !allowed[event].includes(key as keyof Answers))
198
- 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(', ')}.`)
199
- return given
273
+ return result as Answer | undefined
200
274
  }
@@ -5,6 +5,9 @@ import type {
5
5
  Args,
6
6
  EngineInterface,
7
7
  AskOptions,
8
+ CommandInfo,
9
+ CommandRunArgs,
10
+ CommandRunResult,
8
11
  CommandSpec,
9
12
  ConfigRow,
10
13
  ElementTable,
@@ -93,8 +96,9 @@ export type Claude = {
93
96
  send: EngineInterface['session']['send']
94
97
  }
95
98
  readonly prompt: { submit: EngineInterface['prompt']['submit'] }
99
+ readonly turn: { abort: EngineInterface['turn']['abort'] }
96
100
  readonly model: { complete: EngineInterface['model']['complete'] }
97
- readonly command: { register(command: CommandSpec): Promise<{ command: string }> }
101
+ readonly command: { register(command: CommandSpec): Promise<{ command: string }>; list(): Promise<readonly CommandInfo[]>; run(args: CommandRunArgs): Promise<CommandRunResult> }
98
102
  readonly tool: { register(tool: ToolSpec): Promise<{ tool: string }>; call: EngineInterface['tool']['call'] }
99
103
  readonly agent: {
100
104
  list(): Promise<AgentInfo[]>
@@ -3,18 +3,18 @@ import { consentPath, dataFolder, isObject, parseConsent } from '../records.js'
3
3
  import { Message, rowOfAppend, type Row } from '../session/context/turn/message/message.js'
4
4
  import { Note } from '../session/context/turn/message/note.js'
5
5
  import { Person } from '../session/context/turn/message/sender.js'
6
- import { createConversation, emptyConversation, type Conversation, type ConversationState, type ContextData, type SessionChange, type SessionData, type TaskData, type TurnData } from '../session/conversation.js'
7
- import { Session, UsageChange, type BeforeEvent, type Client, type SessionSend, type TaskStartArgs, type ToolCallArgs } from '../session/session.js'
6
+ import { createConversation, emptyConversation, type Conversation, type ConversationState, type ContextData, type SessionChange, type SessionData, type TaskData, type TurnData, type TurnEnding } from '../session/conversation.js'
7
+ import { Session, UsageChange, type BeforeEvent, type Surface, type SessionSend, type TaskStartArgs, type ToolCallArgs } from '../session/session.js'
8
8
  import { GoalChange } from '../session/goal/goal.js'
9
9
  import { receivedMatcher } from '../utils/matcher.js'
10
10
  import { ifMissing } from '../utils/paths.js'
11
11
  import { formatExit, messageOf } from '../utils/text.js'
12
- import { foldAnswers, installBefore, relayOf, type Place } from './before.js'
12
+ import { foldBefore, installBefore, relayOf, type Place } from './before.js'
13
13
  import type { Claude } from './claude.js'
14
14
  import { cmodPluginName } from './dependencies.js'
15
15
  import { readLines, runCmod } from './programs.js'
16
16
  import type { Router } from './router.js'
17
- import { actPermission, beforePermission, conversationMethods, everySession, isAct, isHeardBy, resultOf, usageOf, type Act, type Subscription } from './sessions.js'
17
+ import { actPermission, conversationMethods, everySession, isAct, isHeardBy, resultOf, spawnTask, usageOf, type Act, type Subscription } from './sessions.js'
18
18
 
19
19
  type Request = Readonly<Record<string, unknown>>
20
20
 
@@ -64,8 +64,23 @@ export function serveConversations({ claude, router, store, project, report }: {
64
64
  const liveIds = new Set<string>()
65
65
  const waitingRows = new Map<string, Row[]>()
66
66
  let follower: { readonly sessions: string; stop(): void } | undefined
67
+ const engineTurns = new Map<string, string>()
68
+ router.add('turn.start', async (e, next) => {
69
+ const agent = (e as typeof e & { readonly agentId?: string }).agentId ?? ''
70
+ engineTurns.set(agent, e.turnId)
71
+ return next(e)
72
+ })
73
+ const endTurn = (ending: TurnEnding, task?: string) => <Below extends { readonly block?: string }>(below: Below): Below => {
74
+ if (below.block !== undefined) return below
75
+ engineTurns.delete(task ?? '')
76
+ dispatch(session, conversation.endTurn(new Date().toISOString(), ending, task))
77
+ return below
78
+ }
79
+ router.add('classic.Stop', async (e, next) => endTurn({ reason: 'answer', ...(e.last_assistant_message === undefined ? {} : { answer: e.last_assistant_message }) })(await next(e)))
80
+ router.add('classic.StopFailure', async (e, next) => endTurn({ reason: 'error', failure: { type: String(e.error), text: e.error_details ?? e.last_assistant_message ?? String(e.error) } })(await next(e)))
81
+ router.add('classic.SubagentStop', async (e, next) => endTurn({ reason: 'answer', ...(e.last_assistant_message === undefined ? {} : { answer: e.last_assistant_message }) }, e.agent_id)(await next(e)))
67
82
 
68
- const json = async <Value>(args: readonly string[]): Promise<Value> => JSON.parse(await runCmod(claude, [...args, '--json', '--project', project()], session === '' ? await claude.session.id() : session)) as Value
83
+ const json = async <Value>(args: readonly string[]): Promise<Value> => JSON.parse(await runCmod(claude, [...args, '--json', '--project', project()], await claude.session.id())) as Value
69
84
 
70
85
  const isGranted = async (caller: string | undefined, item: string) => {
71
86
  if (caller === cmodPluginName) return true
@@ -75,6 +90,7 @@ export function serveConversations({ claude, router, store, project, report }: {
75
90
  return (parseConsent(JSON.parse(text), path)[caller] ?? []).includes(item)
76
91
  }
77
92
 
93
+
78
94
  const ended = (id: string) => new Error(`session ${id} has ended`)
79
95
 
80
96
  const isRunning = async (id: string) => (await json<SessionData[]>(['history', 'sessions', '--running', '--all'])).some((each) => each.id === id)
@@ -94,6 +110,7 @@ export function serveConversations({ claude, router, store, project, report }: {
94
110
  }
95
111
 
96
112
  const restartFollower = async () => {
113
+ if (session === '') return
97
114
  const wantsEvery = [...heard.values()].some((sessions) => (sessions.get(everySession) ?? []).length > 0)
98
115
  const named = [...heard.values()].flatMap((sessions) => [...sessions].filter(([id, list]) => id !== session && id !== everySession && list.length > 0).map(([id]) => id))
99
116
  const others = [...new Set(named)].sort()
@@ -128,17 +145,36 @@ export function serveConversations({ claude, router, store, project, report }: {
128
145
  if (sent.isDelivered !== true) throw ended(to)
129
146
  }
130
147
 
148
+ const waitingOn = new Map<Asked, string>()
149
+ let liveness: { cancel(): void } | undefined
150
+ let isChecking = false
151
+ const checkLiveness = async () => {
152
+ if (isChecking) return
153
+ isChecking = true
154
+ try {
155
+ const waiting = [...waitingOn]
156
+ const running = new Set((await json<SessionData[]>(['history', 'sessions', '--running', '--all'])).map((each) => each.id))
157
+ for (const [settle, to] of waiting) if (!running.has(to)) settle.reject(ended(to))
158
+ } finally {
159
+ isChecking = false
160
+ }
161
+ }
162
+ const watchLife = (settle: Asked, to: string) => {
163
+ waitingOn.set(settle, to)
164
+ liveness ??= claude.clock.every(livenessMs, () => void checkLiveness().catch((error: unknown) => report('liveness', 'Could not check which sessions still run', error)))
165
+ return {
166
+ cancel() {
167
+ waitingOn.delete(settle)
168
+ if (waitingOn.size > 0) return
169
+ liveness?.cancel()
170
+ liveness = undefined
171
+ },
172
+ }
173
+ }
174
+
131
175
  const ask = (to: string, relay: Question, deadlineMs?: number) =>
132
176
  new Promise<unknown>((resolve, reject) => {
133
177
  const id = `${session}:${(asks += 1)}`
134
- const timer =
135
- deadlineMs === undefined
136
- ? claude.clock.every(livenessMs, () => {
137
- void isRunning(to).then((isLive) => {
138
- if (!isLive) settle.reject(ended(to))
139
- })
140
- })
141
- : claude.clock.after(deadlineMs, () => settle.reject(new Error(`session ${to} did not answer within ${deadlineMs / 1000} s`)))
142
178
  const settle: Asked = {
143
179
  resolve(value) {
144
180
  if (asked.delete(id)) timer.cancel()
@@ -149,6 +185,7 @@ export function serveConversations({ claude, router, store, project, report }: {
149
185
  reject(error)
150
186
  },
151
187
  }
188
+ const timer = deadlineMs === undefined ? watchLife(settle, to) : claude.clock.after(deadlineMs, () => settle.reject(new Error(`session ${to} did not answer within ${deadlineMs / 1000} s`)))
152
189
  asked.set(id, settle)
153
190
  post(to, { ...relay, id, from: session }).catch((error: unknown) => settle.reject(error instanceof Error ? error : new Error(messageOf(error))))
154
191
  })
@@ -189,8 +226,8 @@ export function serveConversations({ claude, router, store, project, report }: {
189
226
  }
190
227
 
191
228
  const place = (): Place => {
192
- const { context, contextOpenedAt, turn, tasks, taskTurns } = conversation.state
193
- return { session, context, contextOpenedAt, ...(turn === undefined ? {} : { turn }), tasks, taskTurns }
229
+ const { context, contextOpenedAt, turn, tasks, taskTurns, openCalls } = conversation.state
230
+ return { session, context, contextOpenedAt, ...(turn === undefined ? {} : { turn }), tasks, taskTurns, ...(openCalls === undefined ? {} : { openCalls }) }
194
231
  }
195
232
 
196
233
  const deciders = (event: string): Watcher[] => {
@@ -199,7 +236,7 @@ export function serveConversations({ claude, router, store, project, report }: {
199
236
  }
200
237
 
201
238
  const askHere = async (mod: string, event: BeforeEvent, change: SessionChange, from = session, isInProject = true) => {
202
- for (const needed of ['conversation', beforePermission(event)]) if (!(await isGranted(mod, needed))) throw new Error(`${mod} needs the ${needed} permission for before(${event}). Turn it on in /mods ${mod}.`)
239
+ if (from !== session && !(await isGranted(mod, 'conversation'))) throw new Error(`${mod} needs the conversation permission to change what happens in another session. Turn it on in /mods ${mod}.`)
203
240
  return claude.cmod.call({ to: mod, method: conversationMethods.before, input: { session: from, event, change, isInProject } })
204
241
  }
205
242
 
@@ -228,7 +265,7 @@ export function serveConversations({ claude, router, store, project, report }: {
228
265
  return deciders(event).length > 0
229
266
  },
230
267
  decide: (event, change) =>
231
- foldAnswers(
268
+ foldBefore(
232
269
  event,
233
270
  change,
234
271
  deciders(event).map((watcher) => (latest: SessionChange) => (watcher.from === session ? askHere(watcher.mod, event, latest) : askAway(watcher, event, latest))),
@@ -360,9 +397,10 @@ export function serveConversations({ claude, router, store, project, report }: {
360
397
  const perform = async (caller: string, action: Act, input: unknown, task: string | undefined): Promise<unknown> => {
361
398
  await checkAct(caller, action, input)
362
399
  await starting
400
+ if (action === 'abort') return abortTurn(input, task)
363
401
  if (task !== undefined) return performInTask(action, input, task)
364
402
  if (action === 'usage') return usageOf(claude)
365
- if (action === 'clients') return [...(await claude.session.surfaces())] satisfies Client[]
403
+ if (action === 'surfaces') return [...(await claude.session.surfaces())] satisfies Surface[]
366
404
  if (action === 'send') {
367
405
  const { type, text, as } = input as SessionSend
368
406
  if (as === Session) throw new Error(`session ${session} cannot send as itself to itself.`)
@@ -371,26 +409,25 @@ export function serveConversations({ claude, router, store, project, report }: {
371
409
  if ('drop' in delivered && typeof delivered.drop === 'string') throw new Error(`Claude Code dropped the prompt: ${delivered.drop}`)
372
410
  return null
373
411
  }
374
- if (action === 'startTask') {
375
- const args = input as TaskStartArgs
376
- const started = await claude.agent.spawn({
377
- prompt: args.prompt,
378
- ...(args.name === undefined ? {} : { description: args.name, name: args.name }),
379
- ...(args.agentType === undefined ? {} : { subagentType: args.agentType }),
380
- ...(args.model === undefined ? {} : { model: args.model }),
381
- ...(args.cwd === undefined ? {} : { cwd: args.cwd }),
382
- })
383
- if (started.deny !== undefined) throw new Error(`Claude Code refused the task: ${started.deny}`)
384
- return { id: started.agentId ?? '', title: args.name ?? '', agentType: args.agentType ?? 'general-purpose', model: started.model, openedBy: '', openedAt: new Date().toISOString() } satisfies TaskData
385
- }
412
+ if (action === 'startTask') return spawnTask(claude, input as TaskStartArgs)
386
413
  const { name, input: toolInput } = input as ToolCallArgs
387
- return resultOf(place(), await claude.tool.call({ ...toolInput, tool: name } as never))
414
+ return resultOf(place(), { name, input: toolInput, cwd: await claude.session.cwd() }, await claude.tool.call({ ...toolInput, tool: name } as never))
415
+ }
416
+
417
+ const abortTurn = async (input: unknown, task: string | undefined) => {
418
+ const turnId = engineTurns.get(task ?? '')
419
+ if (turnId === undefined) throw new Error(`no turn is running in ${task === undefined ? `session ${session}` : `task ${task}`}.`)
420
+ const asked = isObject(input) ? input['turn'] : undefined
421
+ const open = task === undefined ? conversation.state.turn : conversation.state.taskTurns[task]
422
+ if (open?.prompt.id !== asked) throw new Error(`turn ${String(asked)} has ended, so there is nothing to abort.`)
423
+ await claude.turn.abort({ turnId })
424
+ return null
388
425
  }
389
426
 
390
427
  const performInTask = async (action: Act, input: unknown, task: string): Promise<unknown> => {
391
428
  const running = conversation.state.tasks[task]
392
429
  if (action === 'usage') return json(['history', 'usage', session, '--task', task])
393
- if (action === 'clients') return running === undefined || running.closedAt !== undefined ? [] : [...(await claude.session.surfaces())]
430
+ if (action === 'surfaces') return running === undefined || running.closedAt !== undefined ? [] : [...(await claude.session.surfaces())]
394
431
  if (action !== 'send') throw new Error(`cmod: ${action} runs only on a session's own main loop.`)
395
432
  if (running === undefined || running.closedAt !== undefined) throw ended(task)
396
433
  const sent = await claude.session.send({ to: { agentId: task }, text: (input as SessionSend).text })
@@ -402,7 +439,7 @@ export function serveConversations({ claude, router, store, project, report }: {
402
439
  await checkAct(caller, action, input)
403
440
  const isLive = await isRunning(target)
404
441
  if (!isLive && action === 'usage') return json(['history', 'usage', target, ...(task === undefined ? [] : ['--task', task])])
405
- if (!isLive && action === 'clients') return []
442
+ if (!isLive && action === 'surfaces') return []
406
443
  if (!isLive) throw ended(task ?? target)
407
444
  if (action === 'send' && task === undefined && (input as SessionSend).as === Session) {
408
445
  const sent = await claude.session.send({ to: { sessionId: target }, text: (input as SessionSend).text })
@@ -452,8 +489,8 @@ export function serveConversations({ claude, router, store, project, report }: {
452
489
  },
453
490
  async [conversationMethods.search](request) {
454
491
  const before = isObject(request['before']) ? request['before'] : undefined
455
- const record = [...textArg('--session', request['session']), ...textArg('--task', request['task']), ...textArg('--turn', request['turn']), ...countArg('--context', request['context']), ...(request['isGoal'] === true ? ['--goal'] : [])]
456
- return json(['history', 'search', String(request['text']), ...scopeArgs(request), ...record, ...(request['running'] === true ? ['--running'] : []), ...countArg('--limit', request['limit']), ...(before === undefined ? [] : ['--before', `${String(before['session'])}:${String(before['id'])}`])])
492
+ const record = [...textArg('--session', request['session']), ...textArg('--task', request['task']), ...textArg('--turn', request['turn']), ...countArg('--context', request['context']), ...(request['isAgent'] === true ? ['--agent'] : [])]
493
+ return json(['history', 'search', String(request['text']), ...scopeArgs(request), ...record, ...(request['running'] === true ? ['--running'] : []), ...(before === undefined ? [] : ['--before', `${String(before['session'])}:${String(before['id'])}`])])
457
494
  },
458
495
  async [conversationMethods.messages](request) {
459
496
  const id = String(request['session'])
@@ -540,7 +577,7 @@ export function serveConversations({ claude, router, store, project, report }: {
540
577
  const action = String(request['action']) as Act
541
578
  const task = typeof request['task'] === 'string' ? request['task'] : undefined
542
579
  if (!isAct(action)) throw new Error(`cmod takes no action named ${action}.`)
543
- if (target === session) return perform(caller, action, request['input'], task)
580
+ if (target === (await claude.session.id())) return perform(caller, action, request['input'], task)
544
581
  return actElsewhere(caller, target, action, request['input'], task)
545
582
  },
546
583
  }
@@ -556,7 +593,8 @@ export function serveConversations({ claude, router, store, project, report }: {
556
593
  const method = methods[e.method]
557
594
  if (method === undefined) return undefined
558
595
  const input = isObject(e.input) ? e.input : {}
559
- if (e.method !== conversationMethods.act && !(await isGranted(e.caller, 'conversation'))) return { deny: `${e.caller ?? 'A mod'} reads your conversations without your grant. Turn it on in /mods ${e.caller ?? ''}.`.trim() }
596
+ const isDropping = (e.method === conversationMethods.subscribe || e.method === conversationMethods.watchBefore) && Array.isArray(input['subscriptions']) && input['subscriptions'].length === 0
597
+ if (e.method !== conversationMethods.act && !isDropping && input['session'] !== (await claude.session.id()) && !(await isGranted(e.caller, 'conversation'))) return { deny: `${e.caller ?? 'A mod'} reads your conversations without your grant. Turn it on in /mods ${e.caller ?? ''}.`.trim() }
560
598
  try {
561
599
  return { value: await method(input, e.caller) }
562
600
  } catch (error) {
@@ -6,6 +6,44 @@ export const longestMs = 600_000
6
6
 
7
7
  export const toolDeadline: Deadline = { ms: 30000, job: 'tool' }
8
8
 
9
+ export type ListenOptions = { readonly timeoutMs?: number; readonly onFailure?: 'pass' | 'refuse' }
10
+
11
+ export type ListenTime = { readonly deadline: number; readonly signal: AbortSignal }
12
+
13
+ export const listenerMs = 10_000
14
+
15
+ export class PassedTimeout extends Error {}
16
+
17
+ const timers = globalThis as unknown as { setTimeout(run: () => void, ms: number): unknown; clearTimeout(timer: unknown): void }
18
+
19
+ export function listenerOf(name: string, method: string, args: readonly unknown[]): { readonly matcher: unknown; readonly listener: (payload: never, time: ListenTime) => unknown; readonly options: ListenOptions } {
20
+ const hasMatcher = typeof args[1] === 'function'
21
+ const listener = (hasMatcher ? args[1] : args[0]) as (payload: never, time: ListenTime) => unknown
22
+ const options = ((hasMatcher ? args[2] : args[1]) ?? {}) as ListenOptions
23
+ if (typeof listener !== 'function') throw new Error(`${name}: ${method} takes a listener, or a matcher and then a listener.`)
24
+ const { timeoutMs, onFailure } = options as { readonly timeoutMs?: unknown; readonly onFailure?: unknown }
25
+ if (timeoutMs !== undefined && (typeof timeoutMs !== 'number' || !Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > longestMs)) throw new Error(`${name}: ${method}'s timeoutMs is ${String(timeoutMs)}. Give a whole number of milliseconds above 0 and at most ${longestMs}.`)
26
+ if (onFailure !== undefined && onFailure !== 'pass' && onFailure !== 'refuse') throw new Error(`${name}: ${method}'s onFailure is ${String(onFailure)}. Give 'pass' or 'refuse'.`)
27
+ return { matcher: hasMatcher ? args[0] : undefined, listener, options }
28
+ }
29
+
30
+ export async function withinTime<Value>(clock: Pick<Claude['clock'], 'now'>, method: string, { timeoutMs = listenerMs }: ListenOptions, run: (time: ListenTime) => Value | Promise<Value>): Promise<Value> {
31
+ const controller = new AbortController()
32
+ const deadline = (await clock.now()) + timeoutMs
33
+ let timer: unknown
34
+ const passed = new Promise<never>((_resolve, reject) => {
35
+ timer = timers.setTimeout(() => {
36
+ controller.abort()
37
+ reject(new PassedTimeout(`${method} passed its ${timeoutMs / 1000} s timeout`))
38
+ }, timeoutMs)
39
+ })
40
+ try {
41
+ return await Promise.race([Promise.resolve().then(() => run({ deadline, signal: controller.signal })), passed])
42
+ } finally {
43
+ timers.clearTimeout(timer)
44
+ }
45
+ }
46
+
9
47
  export function beforeDeadline<Value>(claude: Claude, { ms, job }: Deadline, call: string, task: Promise<Value>): Promise<Value> {
10
48
  return new Promise<Value>((resolve, reject) => {
11
49
  const passed = job === undefined ? `its ${ms / 1000} s deadline` : `the ${ms / 1000} s deadline of ${job}`
@@ -1,6 +1,7 @@
1
1
  import type { Args, CmodDependencies, EventResult, Frozen } from 'claude-code'
2
2
  import { messageOf } from '../utils/text.js'
3
3
  import type { Claude } from './claude.js'
4
+ import type { Router } from './router.js'
4
5
 
5
6
  export const cmodPluginName = 'cmod'
6
7
 
@@ -33,6 +34,17 @@ export function withBareRefusals(claude: Claude): Claude {
33
34
  return { ...claude, cmod: { call } }
34
35
  }
35
36
 
37
+ export function answeringOwnCalls(claude: Claude, router: Router): Claude {
38
+ const name = claude.plugin.name
39
+ const call: Claude['cmod']['call'] = async (input) => {
40
+ if (input.to !== name) return claude.cmod.call(input)
41
+ const answer = await router.dispatch('cmod.call', { ...input, caller: name } as Frozen<Args<'cmod.call'>>, async () => ({ deny: `${name} has no method ${input.method}.` }))
42
+ if (answer.deny !== undefined) throw new Error(answer.deny)
43
+ return answer.value
44
+ }
45
+ return { ...claude, cmod: { call } }
46
+ }
47
+
36
48
  export function notInstalled(name: string): string {
37
49
  return `${name} is not installed. Add it with /plugin.`
38
50
  }