@cmodjs/core 1.0.0 → 1.5.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 (122) hide show
  1. package/docs/commands.md +22 -11
  2. package/docs/dependencies.md +6 -4
  3. package/docs/hooks.md +26 -3
  4. package/docs/index.md +1 -1
  5. package/docs/install-steps.md +5 -3
  6. package/docs/mod.md +29 -3
  7. package/docs/permissions.md +4 -4
  8. package/docs/session.md +126 -63
  9. package/docs/testing.md +7 -5
  10. package/docs/ui.md +9 -1
  11. package/jobs/program.d.ts.map +1 -1
  12. package/jobs/program.js +7 -2
  13. package/mod.d.ts +30 -6
  14. package/mod.d.ts.map +1 -1
  15. package/mod.js +34 -1
  16. package/package.json +1 -1
  17. package/records.d.ts +4 -1
  18. package/records.d.ts.map +1 -1
  19. package/records.js +6 -1
  20. package/register.d.ts.map +1 -1
  21. package/register.js +17 -0
  22. package/runtime/announce.d.ts.map +1 -1
  23. package/runtime/announce.js +2 -0
  24. package/runtime/before.d.ts +32 -0
  25. package/runtime/before.d.ts.map +1 -0
  26. package/runtime/before.js +214 -0
  27. package/runtime/claude.d.ts +2 -1
  28. package/runtime/claude.d.ts.map +1 -1
  29. package/runtime/conversations.d.ts +1 -0
  30. package/runtime/conversations.d.ts.map +1 -1
  31. package/runtime/conversations.js +333 -61
  32. package/runtime/dependencies.js +1 -1
  33. package/runtime/engine.d.ts +2 -6
  34. package/runtime/engine.d.ts.map +1 -1
  35. package/runtime/grants.d.ts.map +1 -1
  36. package/runtime/grants.js +8 -8
  37. package/runtime/hooks.d.ts +1 -1
  38. package/runtime/hooks.d.ts.map +1 -1
  39. package/runtime/hooks.js +19 -4
  40. package/runtime/installer.d.ts +1 -1
  41. package/runtime/installer.d.ts.map +1 -1
  42. package/runtime/installer.js +6 -5
  43. package/runtime/lifecycle.d.ts.map +1 -1
  44. package/runtime/lifecycle.js +130 -59
  45. package/runtime/metadata.d.ts.map +1 -1
  46. package/runtime/metadata.js +5 -4
  47. package/runtime/sessions.d.ts +26 -4
  48. package/runtime/sessions.d.ts.map +1 -1
  49. package/runtime/sessions.js +315 -117
  50. package/runtime/toaster.d.ts +5 -12
  51. package/runtime/toaster.d.ts.map +1 -1
  52. package/runtime/toaster.js +158 -25
  53. package/runtime/ui.d.ts.map +1 -1
  54. package/runtime/ui.js +26 -6
  55. package/session/context/context.d.ts +3 -1
  56. package/session/context/context.d.ts.map +1 -1
  57. package/session/context/turn/message/message.d.ts +1 -0
  58. package/session/context/turn/message/message.d.ts.map +1 -1
  59. package/session/context/turn/message/message.js +42 -1
  60. package/session/context/turn/turn.d.ts +2 -1
  61. package/session/context/turn/turn.d.ts.map +1 -1
  62. package/session/conversation.d.ts +20 -11
  63. package/session/conversation.d.ts.map +1 -1
  64. package/session/conversation.js +50 -27
  65. package/session/goal/goal.d.ts +3 -2
  66. package/session/goal/goal.d.ts.map +1 -1
  67. package/session/goal/task.d.ts +4 -4
  68. package/session/goal/task.d.ts.map +1 -1
  69. package/session/session.d.ts +80 -21
  70. package/session/session.d.ts.map +1 -1
  71. package/session/session.js +1 -0
  72. package/src/jobs/program.ts +7 -2
  73. package/src/mod.ts +48 -5
  74. package/src/records.ts +6 -1
  75. package/src/register.ts +17 -0
  76. package/src/runtime/announce.ts +2 -0
  77. package/src/runtime/before.ts +200 -0
  78. package/src/runtime/claude.ts +3 -0
  79. package/src/runtime/conversations.ts +337 -60
  80. package/src/runtime/dependencies.ts +1 -1
  81. package/src/runtime/engine.ts +2 -1
  82. package/src/runtime/grants.ts +8 -10
  83. package/src/runtime/hooks.ts +20 -4
  84. package/src/runtime/installer.ts +6 -5
  85. package/src/runtime/lifecycle.ts +136 -56
  86. package/src/runtime/metadata.ts +5 -4
  87. package/src/runtime/sessions.ts +316 -111
  88. package/src/runtime/toaster.ts +183 -45
  89. package/src/runtime/ui.ts +23 -4
  90. package/src/session/context/context.ts +3 -1
  91. package/src/session/context/turn/message/message.ts +39 -1
  92. package/src/session/context/turn/turn.ts +2 -1
  93. package/src/session/conversation.ts +56 -31
  94. package/src/session/goal/goal.ts +3 -2
  95. package/src/session/goal/task.ts +4 -4
  96. package/src/session/session.ts +62 -15
  97. package/src/testing/fake-claude.ts +4 -2
  98. package/src/testing/fake-sessions.ts +94 -20
  99. package/src/testing.ts +27 -3
  100. package/src/utils/call-effects.ts +2 -2
  101. package/src/utils/disposable.ts +3 -0
  102. package/src/utils/matcher.ts +1 -1
  103. package/src/utils/paths.ts +6 -3
  104. package/testing/fake-claude.d.ts +1 -0
  105. package/testing/fake-claude.d.ts.map +1 -1
  106. package/testing/fake-claude.js +3 -1
  107. package/testing/fake-sessions.d.ts +8 -1
  108. package/testing/fake-sessions.d.ts.map +1 -1
  109. package/testing/fake-sessions.js +97 -18
  110. package/testing.d.ts +1 -0
  111. package/testing.d.ts.map +1 -1
  112. package/testing.js +23 -2
  113. package/utils/call-effects.d.ts +1 -0
  114. package/utils/call-effects.d.ts.map +1 -1
  115. package/utils/call-effects.js +1 -1
  116. package/utils/disposable.d.ts +4 -0
  117. package/utils/disposable.d.ts.map +1 -0
  118. package/utils/disposable.js +1 -0
  119. package/utils/matcher.js +1 -1
  120. package/utils/paths.d.ts +2 -1
  121. package/utils/paths.d.ts.map +1 -1
  122. package/utils/paths.js +6 -3
@@ -2,34 +2,62 @@ import type { Args, EventResult, Frozen } from 'claude-code'
2
2
  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
- import { Prompt } from '../session/context/turn/message/prompt.js'
6
5
  import { Person } from '../session/context/turn/message/sender.js'
7
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'
8
+ import { GoalChange } from '../session/goal/goal.js'
8
9
  import { receivedMatcher } from '../utils/matcher.js'
9
10
  import { ifMissing } from '../utils/paths.js'
10
11
  import { formatExit, messageOf } from '../utils/text.js'
12
+ import { foldAnswers, installBefore, relayOf, type Place } from './before.js'
11
13
  import type { Claude } from './claude.js'
12
14
  import { cmodPluginName } from './dependencies.js'
13
15
  import { readLines, runCmod } from './programs.js'
14
16
  import type { Router } from './router.js'
15
- import { conversationMethods, isHeardBy, type Subscription } from './sessions.js'
17
+ import { actPermission, beforePermission, conversationMethods, everySession, isAct, isHeardBy, resultOf, usageOf, type Act, type Subscription } from './sessions.js'
16
18
 
17
19
  type Request = Readonly<Record<string, unknown>>
18
20
 
19
21
  type Heard = { readonly event: Subscription['event']; readonly matcher: unknown }
20
22
 
21
- const delivery = /^<cmod-delivery>([\s\S]*)<\/cmod-delivery>$/
23
+ type Watch = { readonly mod: string; readonly target: string; readonly project: string; readonly events: readonly string[] }
24
+
25
+ type Watcher = Watch & { readonly from: string }
26
+
27
+ type Question =
28
+ | { readonly kind: 'act'; readonly caller: string; readonly action: Act; readonly input: unknown; readonly task?: string }
29
+ | { readonly kind: 'before'; readonly mod: string; readonly event: BeforeEvent; readonly change: SessionChange; readonly project: string }
30
+
31
+ type Relay = (Question & { readonly id: string; readonly from: string }) | { readonly kind: 'answer'; readonly id: string; readonly value?: unknown; readonly deny?: string } | { readonly kind: 'watch' } | { readonly kind: 'goal'; readonly task?: string; readonly text: string }
32
+
33
+ type Asked = { resolve(value: unknown): void; reject(error: Error): void }
34
+
22
35
 
23
36
  const bufferedTaskRows = 200
24
37
 
38
+ const beforeDeadlineMs = 5000
39
+
40
+ const livenessMs = 5000
41
+
42
+
25
43
  export type Conversations = {
26
44
  answer(e: Frozen<Args<'cmod.call'>> & { readonly caller?: string }): Promise<EventResult<'cmod.call'> | undefined>
45
+ moved(): void
27
46
  }
28
47
 
29
48
  export function serveConversations({ claude, router, store, project, report }: { readonly claude: Claude; readonly router: Router; readonly store: string; readonly project: () => string; readonly report: (id: string, message: string, error: unknown) => void }): Conversations {
30
- const goals = `${dataFolder(store, cmodPluginName)}/goals`
49
+ const folder = dataFolder(store, cmodPluginName)
50
+ const goals = `${folder}/goals`
51
+ const watches = `${folder}/before`
31
52
  const heard = new Map<string, Map<string, readonly Heard[]>>()
53
+ const asked = new Map<string, Asked>()
54
+ const ownWatches = new Map<string, Watch>()
55
+ let watchers: readonly Watcher[] = []
56
+ let watching: Promise<void> = Promise.resolve()
57
+ let published = new Set<string>()
58
+ let publishing: Promise<unknown> = Promise.resolve()
32
59
  let session = ''
60
+ let asks = 0
33
61
  let conversation: Conversation = createConversation(emptyConversation(''))
34
62
  let starting: Promise<void> = Promise.resolve()
35
63
  let live: Message[] = []
@@ -47,34 +75,41 @@ export function serveConversations({ claude, router, store, project, report }: {
47
75
  return (parseConsent(JSON.parse(text), path)[caller] ?? []).includes(item)
48
76
  }
49
77
 
50
- const dispatch = (to: string, changes: readonly SessionChange[]) => {
78
+ const ended = (id: string) => new Error(`session ${id} has ended`)
79
+
80
+ const isRunning = async (id: string) => (await json<SessionData[]>(['history', 'sessions', '--running', '--all'])).some((each) => each.id === id)
81
+
82
+ const dispatch = (to: string, changes: readonly SessionChange[], isInProject = true) => {
51
83
  for (const change of changes) {
52
84
  if (change.type === Message && !liveIds.has(change.message.id) && to === session) {
53
85
  live.push(change.message)
54
86
  liveIds.add(change.message.id)
55
87
  }
56
88
  for (const [mod, sessions] of heard) {
57
- const subscriptions = sessions.get(to) ?? []
89
+ const subscriptions = [...(sessions.get(to) ?? []), ...(isInProject ? (sessions.get(everySession) ?? []) : [])]
58
90
  if (!subscriptions.some(({ event, matcher }) => isHeardBy(event, matcher, change))) continue
59
- void claude.cmod.call({ to: mod, method: conversationMethods.changed, input: { session: to, change } }).catch((error: unknown) => report(`changed:${mod}`, `Could not tell ${mod} about session ${to}`, error))
91
+ void claude.cmod.call({ to: mod, method: conversationMethods.changed, input: { session: to, change, isInProject } }).catch((error: unknown) => report(`changed:${mod}`, `Could not tell ${mod} about session ${to}`, error))
60
92
  }
61
93
  }
62
94
  }
63
95
 
64
- const restartFollower = () => {
65
- const others = [...new Set([...heard.values()].flatMap((sessions) => [...sessions].filter(([id, list]) => id !== session && list.length > 0).map(([id]) => id)))].sort()
66
- const wanted = others.join(' ')
96
+ const restartFollower = async () => {
97
+ const wantsEvery = [...heard.values()].some((sessions) => (sessions.get(everySession) ?? []).length > 0)
98
+ const named = [...heard.values()].flatMap((sessions) => [...sessions].filter(([id, list]) => id !== session && id !== everySession && list.length > 0).map(([id]) => id))
99
+ const others = [...new Set(named)].sort()
100
+ const args = [...others, ...(wantsEvery ? ['--running'] : [])]
101
+ const wanted = [session, project(), ...args].join(' ')
67
102
  if (follower?.sessions === wanted) return
68
103
  follower?.stop()
69
104
  follower = undefined
70
- if (others.length === 0) return
71
- const stream = claude.process.spawn({ argv: ['cmod', 'history', 'follow', ...others, '--json', '--project', project()], env: { CLAUDE_CODE_SESSION_ID: session } })
105
+ if (args.length === 0) return
106
+ const stream = claude.process.spawn({ argv: ['cmod', 'history', 'follow', ...args, '--json', '--project', project()], env: { CLAUDE_CODE_SESSION_ID: session } })
72
107
  const started = { sessions: wanted, stop: () => void stream.return(undefined as never) }
73
108
  follower = started
74
109
  void readLines(stream, (line) => {
75
110
  if (line.trim() === '') return
76
- const parsed = JSON.parse(line) as { readonly session: string; readonly change: SessionChange }
77
- dispatch(parsed.session, [parsed.change])
111
+ const parsed = JSON.parse(line) as { readonly session: string; readonly isInProject: boolean; readonly change: SessionChange }
112
+ dispatch(parsed.session, [parsed.change], parsed.isInProject)
78
113
  })
79
114
  .then(({ code, lastError }) => {
80
115
  if (follower === started) throw new Error(`cmod history follow ${formatExit(code, lastError)}`)
@@ -86,12 +121,125 @@ export function serveConversations({ claude, router, store, project, report }: {
86
121
  })
87
122
  }
88
123
 
89
- const start = (id: string, source: string) => {
90
- if (id === session && source !== 'compact') return
91
- if (id === session && source === 'compact') {
92
- dispatch(session, conversation.startContext(new Date().toISOString()))
93
- return
124
+ const follow = () => void restartFollower().catch((error: unknown) => report('follow', 'Stopped hearing the other sessions', error))
125
+
126
+ const post = async (to: string, relay: Relay) => {
127
+ const sent = await claude.session.send({ to: { sessionId: to }, text: `<cmod-relay>${JSON.stringify(relay)}</cmod-relay>` })
128
+ if (sent.isDelivered !== true) throw ended(to)
129
+ }
130
+
131
+ const ask = (to: string, relay: Question, deadlineMs?: number) =>
132
+ new Promise<unknown>((resolve, reject) => {
133
+ 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
+ const settle: Asked = {
143
+ resolve(value) {
144
+ if (asked.delete(id)) timer.cancel()
145
+ resolve(value)
146
+ },
147
+ reject(error) {
148
+ if (asked.delete(id)) timer.cancel()
149
+ reject(error)
150
+ },
151
+ }
152
+ asked.set(id, settle)
153
+ post(to, { ...relay, id, from: session }).catch((error: unknown) => settle.reject(error instanceof Error ? error : new Error(messageOf(error))))
154
+ })
155
+
156
+ const publishWatches = (isEnding = false) => {
157
+ const next = publishing.then(() => publishNow(isEnding))
158
+ publishing = next.catch((error: unknown) => claude.ui.log(`cmod: publishing before listeners failed: ${messageOf(error)}`, { to: 'debug' }))
159
+ return next
160
+ }
161
+
162
+ const publishNow = async (isEnding: boolean) => {
163
+ if (session === '') return
164
+ const list = isEnding ? [] : [...ownWatches.values()].filter((watch) => watch.events.length > 0)
165
+ await claude.fs.write(`${watches}/${session}.json`, JSON.stringify(list))
166
+ const targets = new Set([...published, ...list.map((watch) => watch.target)])
167
+ published = new Set(list.map((watch) => watch.target))
168
+ const running = targets.has(everySession) ? (await json<SessionData[]>(['history', 'sessions', '--running'])).map((each) => each.id) : []
169
+ const told = new Set([...running, ...targets].filter((id) => id !== everySession && id !== session))
170
+ await Promise.all([...told].map((id) => post(id, { kind: 'watch' }).catch((error: unknown) => claude.ui.log(`cmod: session ${id} did not hear of a before listener: ${messageOf(error)}`, { to: 'debug' }))))
171
+ }
172
+
173
+ const readWatchers = async () => {
174
+ const files = await claude.fs.list(watches).catch(ifMissing([]))
175
+ const found: Watcher[] = []
176
+ for (const file of files) {
177
+ const from = file.name.replace(/\.json$/, '')
178
+ if (from === session || !file.name.endsWith('.json')) continue
179
+ const list = JSON.parse(await claude.fs.read(`${watches}/${file.name}`).catch(ifMissing('[]'))) as readonly Watch[]
180
+ found.push(...list.filter((watch) => watch.target === session || (watch.target === everySession && watch.project === project())).map((watch) => ({ ...watch, from })))
181
+ }
182
+ watchers = found
183
+ }
184
+
185
+ const changeGoal = (task: string | undefined, text: string) => dispatch(session, task === undefined ? conversation.changeGoal(text) : [{ type: GoalChange, goal: { text, task } }])
186
+
187
+ const reloadWatchers = () => {
188
+ watching = readWatchers().catch((error: unknown) => report('before', 'Kept the other sessions’ before listeners from before', error))
189
+ }
190
+
191
+ const place = (): Place => {
192
+ const { context, contextOpenedAt, turn, tasks, taskTurns } = conversation.state
193
+ return { session, context, contextOpenedAt, ...(turn === undefined ? {} : { turn }), tasks, taskTurns }
194
+ }
195
+
196
+ const deciders = (event: string): Watcher[] => {
197
+ const all = [...[...ownWatches.values()].filter((watch) => watch.target === session || watch.target === everySession).map((watch) => ({ ...watch, from: session })), ...watchers]
198
+ return [...new Map(all.filter((watcher) => watcher.events.includes(event)).map((watcher) => [`${watcher.from}\n${watcher.mod}`, watcher])).values()]
199
+ }
200
+
201
+ 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}.`)
203
+ return claude.cmod.call({ to: mod, method: conversationMethods.before, input: { session: from, event, change, isInProject } })
204
+ }
205
+
206
+ const askAway = async (watcher: Watcher, event: BeforeEvent, change: SessionChange) => {
207
+ try {
208
+ return await ask(watcher.from, { kind: 'before', mod: watcher.mod, event, change, project: project() }, beforeDeadlineMs)
209
+ } catch (error) {
210
+ if (await isRunning(watcher.from)) throw error
211
+ watchers = watchers.filter((each) => each.from !== watcher.from)
212
+ claude.ui.log(`cmod: session ${watcher.from} ended without clearing its before listeners, so they are cleared now`, { to: 'debug' })
213
+ await claude.fs.write(`${watches}/${watcher.from}.json`, '[]')
214
+ return undefined
94
215
  }
216
+ }
217
+
218
+ installBefore({
219
+ claude,
220
+ router,
221
+ hooks: {
222
+ place: async () => {
223
+ await starting
224
+ return place()
225
+ },
226
+ wants: async (event) => {
227
+ await Promise.all([starting, watching])
228
+ return deciders(event).length > 0
229
+ },
230
+ decide: (event, change) =>
231
+ foldAnswers(
232
+ event,
233
+ change,
234
+ deciders(event).map((watcher) => (latest: SessionChange) => (watcher.from === session ? askHere(watcher.mod, event, latest) : askAway(watcher, event, latest))),
235
+ (error) => report(`before:${event}`, `Passed the ${event} on without a mod's answer`, error),
236
+ ),
237
+ },
238
+ })
239
+
240
+ const start = (id: string, source: string) => {
241
+ if (id === session) return
242
+ const previous = session
95
243
  session = id
96
244
  live = []
97
245
  liveIds.clear()
@@ -102,11 +250,14 @@ export function serveConversations({ claude, router, store, project, report }: {
102
250
  ? Promise.resolve(dispatch(id, conversation.startContext(new Date().toISOString())))
103
251
  : json<ConversationState>(['history', 'state', id]).then(
104
252
  (state) => {
105
- if (session === id) conversation = createConversation(state)
253
+ if (session === id) conversation = createConversation({ ...emptyConversation(id), ...state })
106
254
  },
107
255
  (error: unknown) => report('state', `Starts session ${id} without its earlier turns`, error),
108
256
  )
109
- restartFollower()
257
+ follow()
258
+ reloadWatchers()
259
+ if (ownWatches.size === 0) return
260
+ void Promise.all([previous === '' ? undefined : claude.fs.write(`${watches}/${previous}.json`, '[]'), publishWatches()]).catch((error: unknown) => report('before', 'Kept its before listeners on the session from before', error))
110
261
  }
111
262
 
112
263
  const take = async (row: Row, task: string | undefined) => {
@@ -128,6 +279,10 @@ export function serveConversations({ claude, router, store, project, report }: {
128
279
  start(e.session_id, e.source)
129
280
  return next(e)
130
281
  })
282
+ router.add('classic.SessionEnd', async (e, next) => {
283
+ if (ownWatches.size > 0) await publishWatches(true).catch((error: unknown) => report('before', 'Left its before listeners registered', error))
284
+ return next(e)
285
+ })
131
286
 
132
287
  router.add('session.append', async (e, next) => {
133
288
  const kept = await next(e)
@@ -139,13 +294,19 @@ export function serveConversations({ claude, router, store, project, report }: {
139
294
 
140
295
  router.add('session.compact', async (e, next) => {
141
296
  const compacted = await next(e)
142
- if (e.agentId === undefined && e.trigger !== 'precompute' && compacted.skip === undefined) {
143
- await starting
144
- dispatch(session, conversation.endContext(new Date().toISOString()))
145
- }
297
+ if (e.trigger === 'precompute' || compacted.skip !== undefined || session === '') return compacted
298
+ const at = new Date().toISOString()
299
+ const boundary: Row = { id: `compact:${e.agentId ?? session}:${at}`, at, type: 'system', name: 'compact_boundary', isMeta: false, isSummary: false, content: [] }
300
+ void take(boundary, e.agentId).catch((error: unknown) => report('compact', 'Missed a compaction', error))
146
301
  return compacted
147
302
  })
148
303
 
304
+ router.add('session.measure', async (e, next) => {
305
+ const measured = await next(e)
306
+ if (session !== '') dispatch(session, [{ type: UsageChange, usage: await usageOf(claude) }])
307
+ return measured
308
+ })
309
+
149
310
  router.add('agent.spawn', async (e, next) => {
150
311
  const spawned = await next(e)
151
312
  if (spawned.deny !== undefined || spawned.agentId === undefined) return spawned
@@ -160,32 +321,124 @@ export function serveConversations({ claude, router, store, project, report }: {
160
321
  })
161
322
 
162
323
  router.add('session.receive', async (e, next) => {
163
- const isFromCmod = e.origin.kind === 'peer' && !('teammate' in e.origin) && e.origin.plugin === cmodPluginName
164
- const sent = isFromCmod ? delivery.exec(e.text)?.[1] : undefined
324
+ const sent = relayOf(e)
165
325
  if (sent === undefined) return next(e)
166
- const parsed: unknown = JSON.parse(sent)
167
- if (!isObject(parsed) || typeof parsed['text'] !== 'string') return next(e)
168
- const { type, text, as } = parsed
169
- claude.clock.after(0, () => {
170
- const delivered = type === Note ? claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } }) : claude.prompt.submit({ text, ...(as === Person ? { asUser: true } : {}) })
171
- void delivered.catch((error: unknown) => report('delivery', 'Could not deliver a message from another session', error))
172
- })
173
- return { consumed: 'cmod delivery' }
326
+ const relay = JSON.parse(sent) as Relay
327
+ claude.clock.after(0, () => void received(relay).catch((error: unknown) => report('relay', 'Could not answer another session', error)))
328
+ return { consumed: 'cmod relay' }
174
329
  })
175
330
 
331
+ const received = async (relay: Relay) => {
332
+ if (relay.kind === 'answer') {
333
+ const waiting = asked.get(relay.id)
334
+ if (relay.deny === undefined) waiting?.resolve(relay.value)
335
+ else waiting?.reject(new Error(relay.deny))
336
+ return
337
+ }
338
+ if (relay.kind === 'watch') return readWatchers()
339
+ if (relay.kind === 'goal') {
340
+ await starting
341
+ changeGoal(relay.task, relay.text)
342
+ return
343
+ }
344
+ const answered = async (run: () => Promise<unknown>) => {
345
+ try {
346
+ await post(relay.from, { kind: 'answer', id: relay.id, value: (await run()) ?? null })
347
+ } catch (error) {
348
+ await post(relay.from, { kind: 'answer', id: relay.id, deny: messageOf(error) })
349
+ }
350
+ }
351
+ if (relay.kind === 'act') return answered(() => perform(relay.caller, relay.action, relay.input, relay.task))
352
+ return answered(() => askHere(relay.mod, relay.event, relay.change, relay.from, relay.project === project()))
353
+ }
354
+
355
+ const checkAct = async (caller: string, action: Act, input: unknown) => {
356
+ const needed = actPermission(action, input)
357
+ if (needed !== undefined && !(await isGranted(caller, needed))) throw new Error(`${caller} needs the ${needed} permission for ${action}. Turn it on in /mods ${caller}.`)
358
+ }
359
+
360
+ const perform = async (caller: string, action: Act, input: unknown, task: string | undefined): Promise<unknown> => {
361
+ await checkAct(caller, action, input)
362
+ await starting
363
+ if (task !== undefined) return performInTask(action, input, task)
364
+ if (action === 'usage') return usageOf(claude)
365
+ if (action === 'clients') return [...(await claude.session.surfaces())] satisfies Client[]
366
+ if (action === 'send') {
367
+ const { type, text, as } = input as SessionSend
368
+ if (as === Session) throw new Error(`session ${session} cannot send as itself to itself.`)
369
+ const delivered = type === Note ? await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } }) : await claude.prompt.submit({ text, ...(as === Person ? { asUser: true } : {}) })
370
+ if ('deny' in delivered && typeof delivered.deny === 'string') throw new Error(`Claude Code refused the note: ${delivered.deny}`)
371
+ if ('drop' in delivered && typeof delivered.drop === 'string') throw new Error(`Claude Code dropped the prompt: ${delivered.drop}`)
372
+ return null
373
+ }
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
+ }
386
+ const { name, input: toolInput } = input as ToolCallArgs
387
+ return resultOf(place(), await claude.tool.call({ ...toolInput, tool: name } as never))
388
+ }
389
+
390
+ const performInTask = async (action: Act, input: unknown, task: string): Promise<unknown> => {
391
+ const running = conversation.state.tasks[task]
392
+ if (action === 'usage') return json(['history', 'usage', session, '--task', task])
393
+ if (action === 'clients') return running === undefined || running.closedAt !== undefined ? [] : [...(await claude.session.surfaces())]
394
+ if (action !== 'send') throw new Error(`cmod: ${action} runs only on a session's own main loop.`)
395
+ if (running === undefined || running.closedAt !== undefined) throw ended(task)
396
+ const sent = await claude.session.send({ to: { agentId: task }, text: (input as SessionSend).text })
397
+ if (sent.isDelivered !== true) throw ended(task)
398
+ return null
399
+ }
400
+
401
+ const actElsewhere = async (caller: string, target: string, action: Act, input: unknown, task: string | undefined): Promise<unknown> => {
402
+ await checkAct(caller, action, input)
403
+ const isLive = await isRunning(target)
404
+ if (!isLive && action === 'usage') return json(['history', 'usage', target, ...(task === undefined ? [] : ['--task', task])])
405
+ if (!isLive && action === 'clients') return []
406
+ if (!isLive) throw ended(task ?? target)
407
+ if (action === 'send' && task === undefined && (input as SessionSend).as === Session) {
408
+ const sent = await claude.session.send({ to: { sessionId: target }, text: (input as SessionSend).text })
409
+ if (sent.isDelivered !== true) throw ended(target)
410
+ return null
411
+ }
412
+ return ask(target, { kind: 'act', caller, action, input, ...(task === undefined ? {} : { task }) })
413
+ }
414
+
176
415
  const merged = <Item>(saved: readonly Item[], kept: readonly Item[], key: (item: Item) => string): Item[] => {
177
416
  const keptKeys = new Set(kept.map(key))
178
417
  return [...saved.filter((item) => !keptKeys.has(key(item))), ...kept]
179
418
  }
180
419
 
181
- const liveTurns = (): TurnData[] => {
182
- const turn = conversation.state.turn
420
+ const liveTurns = (task: unknown): TurnData[] => {
421
+ const turn = typeof task === 'string' ? conversation.state.taskTurns[task] : conversation.state.turn
183
422
  return turn === undefined ? [] : [turn]
184
423
  }
185
424
 
186
- const liveTasks = (): TaskData[] => Object.values(conversation.state.tasks).map(({ context: _context, ...task }) => task)
425
+ const liveTasks = (): TaskData[] =>
426
+ Object.values(conversation.state.tasks).map((task) => ({
427
+ id: task.id,
428
+ title: task.title,
429
+ agentType: task.agentType,
430
+ ...(task.model === undefined ? {} : { model: task.model }),
431
+ openedBy: task.openedBy,
432
+ openedAt: task.openedAt,
433
+ ...(task.closedAt === undefined ? {} : { closedAt: task.closedAt }),
434
+ ...(task.parent === undefined ? {} : { parent: task.parent }),
435
+ }))
187
436
 
188
- const liveContext = (): ContextData[] => {
437
+ const liveContext = (task: unknown): ContextData[] => {
438
+ if (typeof task === 'string') {
439
+ const running = conversation.state.tasks[task]
440
+ return running === undefined ? [] : [{ number: running.context, openedAt: running.contextOpenedAt ?? running.openedAt, task, ...(running.closedAt === undefined ? {} : { closedAt: running.closedAt }) }]
441
+ }
189
442
  const { context, contextOpenedAt } = conversation.state
190
443
  return context === 0 ? [] : [{ number: context, openedAt: contextOpenedAt }]
191
444
  }
@@ -199,7 +452,8 @@ export function serveConversations({ claude, router, store, project, report }: {
199
452
  },
200
453
  async [conversationMethods.search](request) {
201
454
  const before = isObject(request['before']) ? request['before'] : undefined
202
- return json(['history', 'search', String(request['text']), ...scopeArgs(request), ...(request['running'] === true ? ['--running'] : []), ...countArg('--limit', request['limit']), ...(before === undefined ? [] : ['--before', `${String(before['session'])}:${String(before['id'])}`])])
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'])}`])])
203
457
  },
204
458
  async [conversationMethods.messages](request) {
205
459
  const id = String(request['session'])
@@ -214,43 +468,51 @@ export function serveConversations({ claude, router, store, project, report }: {
214
468
  },
215
469
  async [conversationMethods.turns](request) {
216
470
  const id = String(request['session'])
217
- const saved = await json<TurnData[]>(['history', 'turns', id, ...countArg('--context', request['context']), ...countArg('--last', request['last'])])
471
+ const saved = await json<TurnData[]>(['history', 'turns', id, ...textArg('--task', request['task']), ...countArg('--context', request['context']), ...countArg('--last', request['last'])])
218
472
  if (id !== session) return saved
219
473
  await starting
220
- const kept = liveTurns().filter((turn) => request['context'] === undefined || turn.context === request['context'])
474
+ const kept = liveTurns(request['task']).filter((turn) => request['context'] === undefined || turn.context === request['context'])
221
475
  return lastOf(merged(saved, kept, (turn) => turn.prompt.id), request['last'])
222
476
  },
223
477
  async [conversationMethods.contexts](request) {
224
478
  const id = String(request['session'])
225
- const saved = await json<ContextData[]>(['history', 'contexts', id])
479
+ const saved = await json<ContextData[]>(['history', 'contexts', id, ...textArg('--task', request['task'])]).catch((error: unknown) => {
480
+ if (id === session && typeof request['task'] === 'string') return []
481
+ throw error
482
+ })
226
483
  if (id !== session) return saved
227
484
  await starting
228
- return merged(saved, liveContext(), (context) => String(context.number))
485
+ return merged(saved, liveContext(request['task']), (context) => String(context.number))
229
486
  },
230
487
  async [conversationMethods.tasks](request) {
231
488
  const id = String(request['session'])
232
- const saved = await json<TaskData[]>(['history', 'tasks', id, ...textArg('--task', request['task'])])
489
+ const isNested = request['all'] === true
490
+ const saved = await json<TaskData[]>(['history', 'tasks', id, ...(isNested ? ['--nested'] : textArg('--task', request['task']))])
233
491
  if (id !== session) return saved
234
492
  await starting
235
- const kept = liveTasks().filter((task) => task.parent === request['task'])
493
+ const kept = liveTasks().filter((task) => isNested || task.parent === request['task'])
236
494
  return merged(saved, kept, (task) => task.id)
237
495
  },
238
496
  async [conversationMethods.goal](request) {
239
497
  const id = String(request['session'])
240
- if (id === session) {
498
+ const task = typeof request['task'] === 'string' ? request['task'] : undefined
499
+ if (id === session && task === undefined) {
241
500
  await starting
242
501
  const text = conversation.state.goal
243
502
  if (text !== undefined) return { text }
244
503
  }
245
- return json(['history', 'goal', id])
504
+ return json(['history', 'goal', id, ...textArg('--task', task)])
246
505
  },
247
506
  async [conversationMethods.updateGoal](request) {
248
507
  const id = String(request['session'])
508
+ const task = typeof request['task'] === 'string' ? request['task'] : undefined
249
509
  const text = String(request['text'])
250
- await claude.fs.write(`${goals}/${id}.txt`, text)
251
- if (id === session) {
252
- await starting
253
- dispatch(session, conversation.changeGoal(text))
510
+ await claude.fs.write(`${goals}/${task ?? id}.txt`, text)
511
+ await starting
512
+ if (id === session) changeGoal(task, text)
513
+ else {
514
+ dispatch(id, [{ type: GoalChange, goal: { text, ...(task === undefined ? {} : { task }) } }])
515
+ await post(id, { kind: 'goal', ...(task === undefined ? {} : { task }), text }).catch((error: unknown) => claude.ui.log(`cmod: session ${id} keeps its goal until it starts again: ${messageOf(error)}`, { to: 'debug' }))
254
516
  }
255
517
  return null
256
518
  },
@@ -261,24 +523,40 @@ export function serveConversations({ claude, router, store, project, report }: {
261
523
  const sessions = heard.get(caller) ?? new Map<string, readonly Heard[]>()
262
524
  sessions.set(id, list.map(({ event, matcher }) => ({ event, matcher: matcher === undefined ? undefined : receivedMatcher(matcher) })))
263
525
  heard.set(caller, sessions)
264
- restartFollower()
526
+ follow()
265
527
  return null
266
528
  },
267
- async [conversationMethods.send](request, caller) {
268
- if (!(await isGranted(caller, 'prompt'))) throw new Error(`${caller ?? 'A mod'} sends to another session without your grant to send prompts.`)
269
- const type = request['type'] === Note ? Note : Prompt
270
- const sent = await claude.session.send({ to: { sessionId: String(request['session']) }, text: `<cmod-delivery>${JSON.stringify({ type, text: request['text'], as: request['as'] })}</cmod-delivery>` })
271
- if (sent.isDelivered !== true) throw new Error(`Claude Code could not deliver the message to session ${String(request['session'])}.`)
529
+ async [conversationMethods.watchBefore](request, caller) {
530
+ if (caller === undefined) throw new Error('cmod hears no caller for this before listener.')
531
+ const target = String(request['session'])
532
+ const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Subscription[]) : []
533
+ ownWatches.set(`${caller}\n${target}`, { mod: caller, target, project: project(), events: [...new Set(list.map(({ event }) => event))] })
534
+ await publishWatches()
272
535
  return null
273
536
  },
537
+ async [conversationMethods.act](request, caller) {
538
+ if (caller === undefined) throw new Error('cmod hears no caller for this action.')
539
+ const target = String(request['session'])
540
+ const action = String(request['action']) as Act
541
+ const task = typeof request['task'] === 'string' ? request['task'] : undefined
542
+ if (!isAct(action)) throw new Error(`cmod takes no action named ${action}.`)
543
+ if (target === session) return perform(caller, action, request['input'], task)
544
+ return actElsewhere(caller, target, action, request['input'], task)
545
+ },
274
546
  }
275
547
 
276
548
  return {
549
+ moved() {
550
+ for (const [key, watch] of ownWatches) ownWatches.set(key, { ...watch, project: project() })
551
+ void publishWatches().catch((error: unknown) => report('before', 'Kept its before listeners on the project from before', error))
552
+ reloadWatchers()
553
+ follow()
554
+ },
277
555
  async answer(e) {
278
556
  const method = methods[e.method]
279
557
  if (method === undefined) return undefined
280
558
  const input = isObject(e.input) ? e.input : {}
281
- if (e.method !== conversationMethods.send && !(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() }
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() }
282
560
  try {
283
561
  return { value: await method(input, e.caller) }
284
562
  } catch (error) {
@@ -299,4 +577,3 @@ function countArg(flag: string, value: unknown): string[] {
299
577
  function textArg(flag: string, value: unknown): string[] {
300
578
  return typeof value === 'string' ? [flag, value] : []
301
579
  }
302
-
@@ -34,5 +34,5 @@ export function withBareRefusals(claude: Claude): Claude {
34
34
  }
35
35
 
36
36
  export function notInstalled(name: string): string {
37
- return `${name} is not installed. Run cmod install ${name}.`
37
+ return `${name} is not installed. Add it with /plugin.`
38
38
  }
@@ -1,12 +1,13 @@
1
1
  import type { Claude } from './claude.js'
2
2
  import type { Router } from './router.js'
3
+ import type { Failure } from './toaster.js'
3
4
  import type { ToolCalls } from './tool-calls.js'
4
5
 
5
6
  export type Engine = {
6
7
  readonly claude: Claude
7
8
  readonly router: Router
8
9
  readonly toolCalls: ToolCalls
9
- readonly report: (failure: { readonly id: string; readonly message: string; readonly error?: unknown; readonly description?: string }) => void
10
+ readonly report: (failure: Failure) => void
10
11
  }
11
12
 
12
13
  const engines = new WeakMap<object, Engine>()
@@ -1,9 +1,9 @@
1
- import { permissionWords } from '../records.js'
1
+ import { isObject, permissionWords } from '../records.js'
2
2
  import { relativePath } from '../utils/paths.js'
3
3
  import type { Claude } from './claude.js'
4
4
  import { cmodPluginName } from './dependencies.js'
5
5
  import type { RoutedEvent } from './hooks.js'
6
- import { conversationMethods } from './sessions.js'
6
+ import { actPermission, conversationMethods, isAct, toolPermission } from './sessions.js'
7
7
 
8
8
  export type Grants = {
9
9
  readonly name: string
@@ -29,10 +29,6 @@ export function programAddresses(mod: object): Set<string> {
29
29
 
30
30
  const projectSettingsFiles: readonly string[] = ['.claude/settings.json', '.claude/settings.local.json', '.mcp.json']
31
31
 
32
- const startsTurns: readonly string[] = ['CronCreate']
33
-
34
- const ownSchedule: readonly string[] = ['CronDelete', 'CronList']
35
-
36
32
  const conversationCalls: Readonly<Record<string, string>> = {
37
33
  [conversationMethods.sessions]: 'sessions.list',
38
34
  [conversationMethods.search]: 'sessions.search',
@@ -43,7 +39,8 @@ const conversationCalls: Readonly<Record<string, string>> = {
43
39
  [conversationMethods.goal]: 'goal',
44
40
  [conversationMethods.updateGoal]: 'goal.update',
45
41
  [conversationMethods.subscribe]: 'on',
46
- [conversationMethods.send]: 'send',
42
+ [conversationMethods.watchBefore]: 'before',
43
+ [conversationMethods.act]: 'act',
47
44
  }
48
45
 
49
46
  export async function checkGrant(grants: Grants, call: string, item: string | undefined): Promise<void> {
@@ -115,7 +112,9 @@ export function checkingGrants(claude: Claude, grants: Grants): Claude {
115
112
  cmod: {
116
113
  async call(args) {
117
114
  const named = args.to === cmodPluginName ? conversationCalls[args.method] : undefined
118
- if (named !== undefined) await gate(named, args.method === conversationMethods.send ? 'prompt' : 'conversation')
115
+ const given = args.method === conversationMethods.act && isObject(args.input) ? String(args.input['action']) : undefined
116
+ const action = given !== undefined && isAct(given) ? given : undefined
117
+ if (named !== undefined) await gate(action ?? named, action === undefined ? 'conversation' : actPermission(action, isObject(args.input) ? args.input['input'] : undefined))
119
118
  return claude.cmod.call(args)
120
119
  },
121
120
  },
@@ -135,8 +134,7 @@ export function checkingGrants(claude: Claude, grants: Grants): Claude {
135
134
  tool: {
136
135
  ...claude.tool,
137
136
  call: async (input: Parameters<Claude['tool']['call']>[0]) => {
138
- const item = startsTurns.includes(input.tool) ? 'prompt' : ownSchedule.includes(input.tool) ? undefined : 'tools'
139
- await gate(`tool.call(${input.tool})`, item)
137
+ await gate(`tool.call(${input.tool})`, toolPermission(input.tool))
140
138
  return claude.tool.call(input)
141
139
  },
142
140
  },