@cmodjs/core 1.0.0 → 1.5.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 (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 +129 -58
  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 +133 -23
  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 +135 -55
  86. package/src/runtime/metadata.ts +5 -4
  87. package/src/runtime/sessions.ts +316 -111
  88. package/src/runtime/toaster.ts +160 -44
  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
@@ -1,19 +1,23 @@
1
1
  import type { Disposable } from '../mod.js'
2
- import type { Context } from '../session/context/context.js'
2
+ import { ContextEnd, ContextStart, type Context } from '../session/context/context.js'
3
3
  import { Message } 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 type { Turn } from '../session/context/turn/turn.js'
6
+ import { ToolResult } from '../session/context/turn/message/tool-result.js'
7
+ import { TurnEnd, TurnStart, type Turn } from '../session/context/turn/turn.js'
7
8
  import type { ContextData, GoalData, SessionChange, SessionData, TaskData, TurnData } from '../session/conversation.js'
8
- import type { Goal } from '../session/goal/goal.js'
9
- import type { Task } from '../session/goal/task.js'
10
- import { Session, type SessionEvent, type SessionScope, type SessionSend, type Sessions, type TaskStartArgs, type Usage } from '../session/session.js'
9
+ import { GoalChange, type Goal } from '../session/goal/goal.js'
10
+ import { TaskEnd, TaskStart, type Task } from '../session/goal/task.js'
11
+ import { Session, type BeforeEvent, type Client, type RecordPart, type SearchOptions, type SessionEvent, type SessionScope, type SessionSend, type Sessions, type TaskStartArgs, type ToolCallArgs, type Usage } from '../session/session.js'
11
12
  import { matches, sentMatcher, type SentMatcher } from '../utils/matcher.js'
13
+ import { foldAnswers, type Answers, type Place } from './before.js'
12
14
  import type { Claude } from './claude.js'
13
15
  import { cmodPluginName } from './dependencies.js'
14
16
 
15
17
  export type Subscription = { readonly event: SessionEvent; readonly matcher: SentMatcher | undefined }
16
18
 
19
+ export const everySession = '*'
20
+
17
21
  export const conversationMethods = {
18
22
  sessions: 'cmod:sessions',
19
23
  search: 'cmod:search',
@@ -23,19 +27,49 @@ export const conversationMethods = {
23
27
  tasks: 'cmod:tasks',
24
28
  goal: 'cmod:goal',
25
29
  updateGoal: 'cmod:updateGoal',
30
+ usage: 'cmod:usage',
26
31
  subscribe: 'cmod:subscribe',
27
- send: 'cmod:send',
32
+ watchBefore: 'cmod:watchBefore',
33
+ act: 'cmod:act',
28
34
  changed: 'cmod:changed',
35
+ before: 'cmod:before',
29
36
  } as const
30
37
 
38
+ export type Act = 'send' | 'startTask' | 'callTool' | 'usage' | 'clients'
39
+
40
+ const actPermissions: Readonly<Record<Exclude<Act, 'callTool'>, string>> = { send: 'prompt', startTask: 'tasks', usage: 'conversation', clients: 'conversation' }
41
+
42
+ const startsTurns: readonly string[] = ['CronCreate']
43
+
44
+ const ownSchedule: readonly string[] = ['CronDelete', 'CronList']
45
+
46
+ export const beforePermission = (event: BeforeEvent): string => (event === 'toolCall' ? 'tools' : event === TaskStart || event === TaskEnd ? 'tasks' : 'prompt')
47
+
48
+ export const isAct =(action: string): action is Act => action === 'callTool' || action in actPermissions
49
+
50
+ export function toolPermission(tool: string): string | undefined {
51
+ return startsTurns.includes(tool) ? 'prompt' : ownSchedule.includes(tool) ? undefined : 'tools'
52
+ }
53
+
54
+ export function actPermission(action: Act, input: unknown): string | undefined {
55
+ return action === 'callTool' ? (toolPermission(String((input as Partial<ToolCallArgs> | null)?.name)) ?? 'tools') : actPermissions[action]
56
+ }
57
+
58
+ export type Scope = { readonly session?: string; readonly context?: number; readonly turn?: string; readonly task?: string; readonly isGoal?: true }
59
+
31
60
  export type SessionsClient = {
32
61
  readonly sessions: Sessions
33
- changed(session: string, change: SessionChange): void
62
+ changed(session: string, change: SessionChange, isInProject: boolean): void
63
+ answerBefore(session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean): Promise<unknown>
34
64
  /** An empty title keeps the title the session already has. */
35
65
  follow(current: Omit<SessionData, 'isRunning' | 'lastAt'>): void
36
66
  }
37
67
 
38
- type Listening = { readonly event: SessionEvent; readonly matcher: unknown; readonly listener: (payload: never) => unknown }
68
+ type Listening = { scope: Scope; readonly event: SessionEvent; readonly matcher: unknown; readonly listener: (payload: never) => unknown }
69
+
70
+ type Owned = { readonly session: string; readonly task: string }
71
+
72
+ const asked = (action: string) => `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.`
39
73
 
40
74
  export async function usageOf(claude: Pick<Claude, 'session'>): Promise<Usage> {
41
75
  const [model, usage] = await Promise.all([claude.session.model(), claude.session.usage()])
@@ -47,69 +81,220 @@ export function payloadOf(change: SessionChange): unknown {
47
81
  if (change.type === Message) return change.message
48
82
  if ('turn' in change) return change.turn
49
83
  if ('context' in change) return change.context
50
- if ('task' in change) return change.task
84
+ if ('task' in change) {
85
+ const { id, title, agentType, model, openedBy, openedAt, closedAt } = change.task
86
+ return { id, title, openedBy, openedAt, ...(closedAt === undefined ? {} : { closedAt }), owner: { id, title, agentType, ...(model === undefined ? {} : { model }), startedAt: openedAt } }
87
+ }
88
+ if ('usage' in change) return change.usage
51
89
  return change.goal
52
90
  }
53
91
 
54
92
  export function isHeardBy(event: SessionEvent, matcher: unknown, change: SessionChange): boolean {
55
- const named = change.type === Message ? (event === Message || event === change.message.type) : event === change.type
93
+ const named = change.type === Message ? event === Message || event === change.message.type : event === change.type
56
94
  return named && (matcher === undefined || matches(payloadOf(change), matcher))
57
95
  }
58
96
 
59
- export function createSessionsClient({ name, claude, report }: { readonly name: string; readonly claude: Claude; readonly report: (id: string, message: string, error: unknown) => void }): SessionsClient {
97
+ function taskOfChange(change: SessionChange): string | undefined {
98
+ if (change.type === Message) return change.message.task
99
+ if (change.type === TaskStart || change.type === TaskEnd) return change.task.id === '' ? change.task.parent : change.task.id
100
+ if (change.type === TurnStart || change.type === TurnEnd) return change.turn.prompt.task
101
+ if (change.type === ContextStart || change.type === ContextEnd) return change.context.task
102
+ if (change.type === GoalChange) return change.goal.task
103
+ return undefined
104
+ }
105
+
106
+ function ownerOfChange(change: SessionChange): string | undefined {
107
+ if (change.type === TaskStart || change.type === TaskEnd) return change.task.parent
108
+ return taskOfChange(change)
109
+ }
110
+
111
+ function contextOfChange(change: SessionChange): number | undefined {
112
+ if (change.type === Message) return change.message.context
113
+ if (change.type === TurnStart || change.type === TurnEnd) return change.turn.context
114
+ if (change.type === ContextStart || change.type === ContextEnd) return change.context.number
115
+ if (change.type === TaskStart || change.type === TaskEnd) return change.task.context
116
+ return undefined
117
+ }
118
+
119
+ export function isInScope(scope: Scope, session: string, change: SessionChange, isUnder: (task: string, ancestor: string) => boolean): boolean {
120
+ if (scope.session !== undefined && scope.session !== session) return false
121
+ const task = taskOfChange(change)
122
+ if (scope.task !== undefined && (task === undefined || !isUnder(task, scope.task))) return false
123
+ if (scope.isGoal === true && change.type !== GoalChange && change.type !== TaskStart && (task === undefined || task === scope.task)) return false
124
+ if (scope.context !== undefined && (contextOfChange(change) !== scope.context || ownerOfChange(change) !== scope.task)) return false
125
+ if (scope.turn === undefined) return true
126
+ if (change.type === Message) return change.message.turn === scope.turn
127
+ return (change.type === TurnStart || change.type === TurnEnd) && change.turn.prompt.id === scope.turn
128
+ }
129
+
130
+ export function createSessionsClient({ name, claude, report, isGranted }: { readonly name: string; readonly claude: Claude; readonly report: (id: string, message: string, error: unknown) => void; readonly isGranted: (permission: string) => boolean }): SessionsClient {
60
131
  const call = <Answer>(method: string, input: unknown) => claude.cmod.call({ to: cmodPluginName, method, input }) as Promise<Answer>
61
132
  const listening = new Map<string, Set<Listening>>()
133
+ const deciding = new Map<string, Set<Listening>>()
62
134
  const queued = new Set<string>()
135
+ const parents = new Map<string, Map<string, string | undefined>>()
136
+ const known = new Map<string, SessionData>()
63
137
  let current: SessionData = { id: '', title: '', project: '', cwd: '', model: '', startedAt: '', lastAt: '', isRunning: true }
64
138
 
65
- const subscribe = (session: string) => {
66
- if (queued.has(session)) return
67
- queued.add(session)
139
+ const keyOf = (scope: Scope) => scope.session ?? everySession
140
+
141
+ const subscribe = (key: string) => {
142
+ if (queued.has(key)) return
143
+ queued.add(key)
68
144
  void Promise.resolve().then(async () => {
69
- queued.delete(session)
70
- const subscriptions: Subscription[] = [...(listening.get(session) ?? [])].map(({ event, matcher }) => ({ event, matcher: matcher === undefined ? undefined : sentMatcher(matcher) }))
71
- await call(conversationMethods.subscribe, { session, subscriptions }).catch((error: unknown) => report(`sessions:${session}`, `Hears nothing from session ${session}`, error))
145
+ queued.delete(key)
146
+ const heard = (from: Map<string, Set<Listening>>): Subscription[] => [...(from.get(key) ?? [])].map(({ event, matcher }) => ({ event, matcher: matcher === undefined ? undefined : sentMatcher(matcher) }))
147
+ const tracksTasks = [...(listening.get(key) ?? []), ...(deciding.get(key) ?? [])].some(({ scope }) => scope.task !== undefined || scope.isGoal === true)
148
+ const subscriptions = [...heard(listening), ...(tracksTasks ? [{ event: TaskStart }] : [])]
149
+ await call(conversationMethods.subscribe, { session: key, subscriptions }).catch((error: unknown) => report(`sessions:${key}`, `Hears nothing from ${key === everySession ? 'the other sessions' : `session ${key}`}`, error))
150
+ await call(conversationMethods.watchBefore, { session: key, subscriptions: heard(deciding) }).catch((error: unknown) => report(`before:${key}`, `Changes nothing in ${key === everySession ? 'the other sessions' : `session ${key}`}`, error))
72
151
  })
73
152
  }
74
153
 
75
- const isCurrent = (id: string) => id === current.id
154
+ const learnTasks = async (session: string) => {
155
+ if (parents.has(session)) return
156
+ const tree = new Map<string, string | undefined>()
157
+ parents.set(session, tree)
158
+ for (const task of await call<TaskData[]>(conversationMethods.tasks, { session, all: true })) tree.set(task.id, task.parent)
159
+ }
76
160
 
77
- const refuseElsewhere = (id: string, what: string): never => {
78
- throw new Error(`${name}: ${what} works on the session this mod runs in, and ${id} runs elsewhere or has ended.`)
161
+ const isUnderIn = (session: string) => (task: string, ancestor: string) => {
162
+ const tree = parents.get(session)
163
+ for (let at: string | undefined = task, steps = 0; at !== undefined && steps < 64; at = tree?.get(at), steps += 1) if (at === ancestor) return true
164
+ return false
79
165
  }
80
166
 
81
- const turnOf = (session: string, data: TurnData): Turn => ({ ...data, messages: () => call<Message[]>(conversationMethods.messages, { session, turn: data.prompt.id }) })
167
+ const remember = (session: string, change: SessionChange) => {
168
+ if (change.type === TaskStart) parents.get(session)?.set(change.task.id, change.task.parent)
169
+ }
82
170
 
83
- const contextOf = (session: string, data: ContextData): Context => ({
84
- ...data,
85
- turns: async () => (await call<TurnData[]>(conversationMethods.turns, { session, context: data.number })).map((turn) => turnOf(session, turn)),
86
- })
171
+ const add = (into: Map<string, Set<Listening>>, scope: Scope, event: SessionEvent, matcherOrListener: unknown, maybeListener: unknown, onChange: (key: string) => void): Disposable => {
172
+ const listener = (maybeListener ?? matcherOrListener) as (payload: never) => unknown
173
+ const matcher = maybeListener === undefined ? undefined : matcherOrListener
174
+ const key = keyOf(scope)
175
+ const entry: Listening = { scope, event, matcher, listener }
176
+ const set = into.get(key) ?? new Set()
177
+ set.add(entry)
178
+ into.set(key, set)
179
+ if (scope.task !== undefined || scope.isGoal === true) void learnTasks(scope.session ?? current.id).catch((error: unknown) => report(`tasks:${key}`, 'Could not read the tasks it listens under', error))
180
+ onChange(key)
181
+ return {
182
+ dispose() {
183
+ for (const [at, held] of into) {
184
+ if (!held.delete(entry)) continue
185
+ if (held.size === 0) into.delete(at)
186
+ onChange(at)
187
+ return
188
+ }
189
+ },
190
+ }
191
+ }
192
+
193
+
194
+
195
+ const combined = async (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean): Promise<Partial<Answers> | undefined> => {
196
+ const askers = [...(deciding.get(session) ?? []), ...(isInProject ? (deciding.get(everySession) ?? []) : [])]
197
+ .filter((entry) => entry.event === event)
198
+ .map((entry) => async (latest: SessionChange) => {
199
+ if (!isInScope(entry.scope, session, latest, isUnderIn(session))) return undefined
200
+ if (entry.matcher !== undefined && !matches(payloadOf(latest), entry.matcher)) return undefined
201
+ const answer = await entry.listener(payloadFor(session, latest) as never)
202
+ if (answer === undefined || isGranted(beforePermission(event))) return answer
203
+ throw new Error(`Its before(${event}) answer changes nothing until ${beforePermission(event)} is turned on for ${name} in /mods.`)
204
+ })
205
+ return foldAnswers(event, change, askers, (error) => report(`before:${event}`, `The before(${event}) listener failed`, error))
206
+ }
207
+
208
+ const recordPart = (scope: Scope): RecordPart => ({
209
+ search: (text: string, options: SearchOptions = {}) => call(conversationMethods.search, { text, ...scope, ...optionsOf(options) }),
210
+ on: (event: SessionEvent, matcherOrListener: unknown, maybeListener?: unknown) => add(listening, scope, event, matcherOrListener, maybeListener, subscribe),
211
+ before: (event: BeforeEvent, matcherOrListener: unknown, maybeListener?: unknown) => add(deciding, scope, event, matcherOrListener, maybeListener, subscribe),
212
+ }) as RecordPart
213
+
214
+ const turnOf = (scope: Scope, data: TurnData): Turn => {
215
+ const task = data.prompt.task ?? scope.task
216
+ return {
217
+ ...data,
218
+ ...recordPart({ ...scope, ...(task === undefined ? {} : { task }), turn: data.prompt.id }),
219
+ messages: () => call<Message[]>(conversationMethods.messages, { session: scope.session, turn: data.prompt.id, ...(task === undefined ? {} : { task }) }),
220
+ }
221
+ }
222
+
223
+ const contextOf = (scope: Scope, data: ContextData): Context => {
224
+ const owned: Scope = { ...(scope.session === undefined ? {} : { session: scope.session }), ...(data.task === undefined ? {} : { task: data.task }) }
225
+ return {
226
+ ...data,
227
+ ...recordPart({ ...owned, context: data.number }),
228
+ turns: async () => (await call<TurnData[]>(conversationMethods.turns, { ...owned, context: data.number })).map((turn) => turnOf(owned, turn)),
229
+ }
230
+ }
87
231
 
88
232
  const taskOf = (session: string, data: TaskData): Task => ({
89
- ...data,
90
- messages: () => call<Message[]>(conversationMethods.messages, { session, task: data.id }),
233
+ id: data.id,
234
+ title: data.title,
235
+ openedBy: data.openedBy,
236
+ openedAt: data.openedAt,
237
+ ...(data.closedAt === undefined ? {} : { closedAt: data.closedAt }),
238
+ owner: ownerOf(session, data),
239
+ ...recordPart({ session, task: data.id }),
91
240
  tasks: async () => (await call<TaskData[]>(conversationMethods.tasks, { session, task: data.id })).map((task) => taskOf(session, task)),
241
+ messages: () => call<Message[]>(conversationMethods.messages, { session, task: data.id }),
92
242
  })
93
243
 
94
- const goalOf = (session: string, data: GoalData): Goal => ({
244
+ const goalOf = (scope: Scope, data: GoalData): Goal => ({
95
245
  ...data,
246
+ ...recordPart({ ...scope, isGoal: true }),
247
+ tasks: async () => (await call<TaskData[]>(conversationMethods.tasks, { session: scope.session, ...(scope.task === undefined ? {} : { task: scope.task }) })).map((task) => taskOf(scope.session ?? current.id, task)),
96
248
  update: async ({ text }) => {
97
249
  if (text.trim() === '') throw new Error(`${name}: a goal needs text.`)
98
- await call(conversationMethods.updateGoal, { session, text })
250
+ await call(conversationMethods.updateGoal, { session: scope.session ?? current.id, ...(scope.task === undefined ? {} : { task: scope.task }), text })
99
251
  },
100
- tasks: async () => (await call<TaskData[]>(conversationMethods.tasks, { session })).map((task) => taskOf(session, task)),
101
252
  })
102
253
 
103
254
  const payloadFor = (session: string, change: SessionChange): unknown => {
255
+ const scope = { session }
104
256
  if (change.type === Message) return change.message
105
- if ('turn' in change) return turnOf(session, change.turn)
106
- if ('context' in change) return contextOf(session, change.context)
257
+ if ('turn' in change) return turnOf(scope, change.turn)
258
+ if ('context' in change) return contextOf(scope, change.context)
107
259
  if ('task' in change) return taskOf(session, change.task)
108
- return goalOf(session, change.goal)
260
+ if ('usage' in change) return change.usage
261
+ return goalOf({ session, ...(change.goal.task === undefined ? {} : { task: change.goal.task }) }, { text: change.goal.text })
109
262
  }
110
263
 
111
- const sessionOf = (read: () => SessionData) => {
264
+ const act = <Answer>(session: string, action: Act, input: unknown, owned?: Owned) => call<Answer>(conversationMethods.act, { session: owned?.session ?? session, action, input, ...(owned === undefined ? {} : { task: owned.task }) })
265
+
266
+ const sendHere = async ({ type, text, as }: SessionSend) => {
267
+ if (type === Note) {
268
+ if (as !== undefined) throw new Error(`${name}: a Note is always from this mod, so it takes no as.`)
269
+ const added = await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
270
+ if (added.deny !== undefined) throw new Error(`${name}: Claude Code refused the note: ${added.deny}`)
271
+ return
272
+ }
273
+ const submitted = await claude.prompt.submit({ text, ...(as === Person ? { asUser: true } : {}) })
274
+ if ('drop' in submitted && typeof submitted.drop === 'string') throw new Error(`${name}: Claude Code dropped the prompt: ${submitted.drop}`)
275
+ }
276
+
277
+ const startHere = async (task: TaskStartArgs): Promise<Task> => {
278
+ const started = await claude.agent.spawn({
279
+ prompt: task.prompt,
280
+ ...(task.name === undefined ? {} : { description: task.name, name: task.name }),
281
+ ...(task.agentType === undefined ? {} : { subagentType: task.agentType }),
282
+ ...(task.model === undefined ? {} : { model: task.model }),
283
+ ...(task.cwd === undefined ? {} : { cwd: task.cwd }),
284
+ })
285
+ if (started.deny !== undefined) throw new Error(`${name}: Claude Code refused the task: ${started.deny}`)
286
+ return taskOf(current.id, { id: started.agentId ?? '', title: task.name ?? '', agentType: task.agentType ?? 'general-purpose', model: started.model, openedBy: '', openedAt: new Date().toISOString() })
287
+ }
288
+
289
+ const clientsHere = async (): Promise<Client[]> => [...(await claude.session.surfaces())]
290
+
291
+ const sessionOf = (read: () => SessionData, owned?: Owned) => {
112
292
  const id = () => read().id
293
+ const isHere = () => owned === undefined && id() === current.id
294
+ const scope = (): Scope => (owned === undefined ? { session: id() } : { session: owned.session, task: owned.task })
295
+ const where = () => (owned === undefined ? { session: id() } : { session: owned.session, task: owned.task })
296
+ const turnsOf = async (options: { readonly last?: number } = {}): Promise<Turn[]> => (await call<TurnData[]>(conversationMethods.turns, { ...where(), ...options })).map((turn) => turnOf(scope(), turn))
297
+ const contextsOf = async (): Promise<Context[]> => (await call<ContextData[]>(conversationMethods.contexts, where())).map((context) => contextOf(scope(), context))
113
298
  const session = {
114
299
  get id() {
115
300
  return read().id
@@ -120,12 +305,18 @@ export function createSessionsClient({ name, claude, report }: { readonly name:
120
305
  get project() {
121
306
  return read().project
122
307
  },
123
- get cwd() {
124
- return read().cwd
308
+ get agentType() {
309
+ return read().agentType
125
310
  },
126
311
  get model() {
127
312
  return read().model
128
313
  },
314
+ get cwd() {
315
+ return read().cwd
316
+ },
317
+ get isRunning() {
318
+ return read().isRunning
319
+ },
129
320
  get startedAt() {
130
321
  return read().startedAt
131
322
  },
@@ -135,108 +326,122 @@ export function createSessionsClient({ name, claude, report }: { readonly name:
135
326
  get forkedFrom() {
136
327
  return read().forkedFrom
137
328
  },
138
- get isRunning() {
139
- return read().isRunning
329
+ search: (text: string, options: SearchOptions = {}) => recordPart(scope()).search(text, options),
330
+ on: (event: SessionEvent, matcherOrListener: unknown, maybeListener?: unknown) => (recordPart(scope()).on as (...args: unknown[]) => Disposable)(event, matcherOrListener, maybeListener),
331
+ before: (event: BeforeEvent, matcherOrListener: unknown, maybeListener?: unknown) => (recordPart(scope()).before as (...args: unknown[]) => Disposable)(event, matcherOrListener, maybeListener),
332
+ goal: async () => goalOf(scope(), await call<GoalData>(conversationMethods.goal, where())),
333
+ context: async () => {
334
+ const all = await contextsOf()
335
+ const last = all.at(-1)
336
+ if (last === undefined) throw new Error(`${name}: session ${id()} has no context yet.`)
337
+ return last
140
338
  },
141
- async usage(): Promise<Usage> {
142
- if (!isCurrent(id())) refuseElsewhere(id(), 'usage()')
143
- return usageOf(claude)
144
- },
145
- on(event: SessionEvent, matcherOrListener: unknown, maybeListener?: (payload: never) => unknown): Disposable {
146
- const listener = (maybeListener ?? matcherOrListener) as (payload: never) => unknown
147
- const matcher = maybeListener === undefined ? undefined : matcherOrListener
148
- const owner = id()
149
- const heard: Listening = { event, matcher, listener }
150
- const set = listening.get(owner) ?? new Set()
151
- set.add(heard)
152
- listening.set(owner, set)
153
- subscribe(owner)
154
- return {
155
- dispose() {
156
- if (!set.delete(heard)) return
157
- if (set.size === 0) listening.delete(owner)
158
- subscribe(owner)
159
- },
160
- }
339
+ contexts: contextsOf,
340
+ turn: async () => {
341
+ const [last] = await turnsOf({ last: 1 })
342
+ return last?.closedAt === undefined ? last : undefined
161
343
  },
162
- async send({ type, text, as }: SessionSend) {
163
- if (text.trim() === '') throw new Error(`${name}: send needs text.`)
164
- if (isCurrent(id())) {
165
- if (as === Session) throw new Error(`${name}: send with as: Session messages another session, and this one runs this mod.`)
166
- if (type === Note) {
167
- if (as !== undefined) throw new Error(`${name}: a Note is always from this mod, so it takes no as.`)
168
- const added = await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
169
- if (added.deny !== undefined) throw new Error(`${name}: Claude Code refused the note: ${added.deny}`)
170
- return
171
- }
172
- const submitted = await claude.prompt.submit({ text, ...(as === Person ? { asUser: true } : {}) })
173
- if ('drop' in submitted && typeof submitted.drop === 'string') throw new Error(`${name}: Claude Code dropped the prompt: ${submitted.drop}`)
174
- return
175
- }
176
- if (!read().isRunning) throw new Error(`${name}: session ${id()} has ended, so it takes no messages.`)
177
- if (as === Session) {
178
- if (type === Note) throw new Error(`${name}: a message from this session reaches Claude as a Prompt, so send it with type: Prompt.`)
179
- const sent = await claude.session.send({ to: { sessionId: id() }, text })
180
- if (sent.isDelivered !== true) throw new Error(`${name}: Claude Code could not deliver the message to session ${id()}.`)
181
- return
182
- }
183
- await call(conversationMethods.send, { session: id(), type, text, ...(as === undefined ? {} : { as }) })
344
+ turns: turnsOf,
345
+ messages: (options: { readonly after?: string; readonly last?: number } = {}) => call<Message[]>(conversationMethods.messages, { ...where(), ...options }),
346
+ usage: async (): Promise<Usage> => (isHere() ? usageOf(claude) : act<Usage>(id(), 'usage', null, owned)),
347
+ clients: async (): Promise<Client[]> => (isHere() ? clientsHere() : act<Client[]>(id(), 'clients', null, owned)),
348
+ async send(message: SessionSend) {
349
+ if (message.text.trim() === '') throw new Error(`${name}: send needs text.`)
350
+ if (isHere() && message.as !== Session) return sendHere(message)
351
+ await act(id(), 'send', message, owned)
184
352
  },
185
353
  async startTask(task: TaskStartArgs): Promise<Task> {
186
- if (!isCurrent(id())) refuseElsewhere(id(), 'startTask()')
187
- const started = await claude.agent.spawn({
188
- prompt: task.prompt,
189
- ...(task.name === undefined ? {} : { description: task.name, name: task.name }),
190
- ...(task.agentType === undefined ? {} : { subagentType: task.agentType }),
191
- ...(task.model === undefined ? {} : { model: task.model }),
192
- ...(task.cwd === undefined ? {} : { cwd: task.cwd }),
193
- })
194
- if (started.deny !== undefined) throw new Error(`${name}: Claude Code refused the task: ${started.deny}`)
195
- const data: TaskData = { id: started.agentId ?? '', title: task.name ?? '', agentType: task.agentType ?? 'general-purpose', model: started.model, openedBy: '', openedAt: new Date().toISOString() }
196
- return taskOf(id(), data)
197
- },
198
- messages: (options: { readonly after?: string; readonly last?: number } = {}) => call<Message[]>(conversationMethods.messages, { session: id(), ...options }),
199
- turns: async (options: { readonly last?: number } = {}) => (await call<TurnData[]>(conversationMethods.turns, { session: id(), ...options })).map((turn) => turnOf(id(), turn)),
200
- contexts: async () => (await call<ContextData[]>(conversationMethods.contexts, { session: id() })).map((context) => contextOf(id(), context)),
201
- goal: async () => goalOf(id(), await call<GoalData>(conversationMethods.goal, { session: id() })),
354
+ if (owned !== undefined) throw new Error(asked('startTask'))
355
+ if (isHere()) return startHere(task)
356
+ const started = await act<TaskData>(id(), 'startTask', task)
357
+ return taskOf(id(), started)
358
+ },
359
+ async callTool(toolCall: ToolCallArgs): Promise<ToolResult> {
360
+ if (owned !== undefined) throw new Error(asked('callTool'))
361
+ return act<ToolResult>(id(), 'callTool', toolCall)
362
+ },
202
363
  }
203
- return session
364
+ return session as unknown as Sessions['current']
365
+ }
366
+
367
+ const ownerOf = (session: string, data: TaskData): Sessions['current'] => {
368
+ const parent = session === current.id ? current : known.get(session)
369
+ return sessionOf(
370
+ () => ({
371
+ id: data.id,
372
+ title: data.title,
373
+ project: parent?.project ?? '',
374
+ agentType: data.agentType,
375
+ model: data.model ?? '',
376
+ cwd: parent?.cwd ?? '',
377
+ isRunning: data.closedAt === undefined && (parent?.isRunning ?? true),
378
+ startedAt: data.openedAt,
379
+ lastAt: data.closedAt ?? data.openedAt,
380
+ }),
381
+ { session, task: data.id },
382
+ )
204
383
  }
205
384
 
206
385
  const currentSession = sessionOf(() => current)
386
+ const every = recordPart({})
387
+
388
+ const answerBefore = async (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean) => (isGranted('conversation') ? combined(session, event, change, isInProject) : undefined)
207
389
 
208
390
  return {
209
391
  sessions: {
210
392
  get current() {
211
- return currentSession as unknown as Sessions['current']
393
+ return currentSession
212
394
  },
213
395
  list: async (scope: SessionScope = {}) =>
214
396
  (await call<SessionData[]>(conversationMethods.sessions, scope)).map((data) => {
215
- if (data.id !== current.id) return sessionOf(() => data) as unknown as Sessions['current']
397
+ if (data.id !== current.id) {
398
+ known.set(data.id, data)
399
+ return sessionOf(() => known.get(data.id) ?? data)
400
+ }
216
401
  if (current.title === '') current = { ...current, title: data.title }
217
- return currentSession as unknown as Sessions['current']
402
+ return currentSession
218
403
  }),
219
- search: (text, options = {}) => call<Message[]>(conversationMethods.search, { text, ...options, ...(options.before === undefined ? {} : { before: { session: options.before.session, id: options.before.id } }) }),
404
+ search: (text, options = {}) => call<Message[]>(conversationMethods.search, { text, ...optionsOf(options), ...(options.running === undefined ? {} : { running: options.running }), ...(options.since === undefined ? {} : { since: options.since }), ...(options.everyProject === undefined ? {} : { everyProject: options.everyProject }) }),
405
+ on: every.on,
406
+ before: every.before,
220
407
  },
221
- changed(session, change) {
222
- for (const { event, matcher, listener } of listening.get(session) ?? []) {
223
- if (!isHeardBy(event, matcher, change)) continue
224
- void Promise.resolve()
225
- .then(() => listener(payloadFor(session, change) as never))
226
- .catch((error: unknown) => report(`session:${event}`, `The ${event} listener failed`, error))
408
+ changed(session, change, isInProject) {
409
+ remember(session, change)
410
+ for (const key of isInProject ? [session, everySession] : [session]) {
411
+ for (const { scope, event, matcher, listener } of listening.get(key) ?? []) {
412
+ if (!isHeardBy(event, matcher, change) || !isInScope(scope, session, change, isUnderIn(session))) continue
413
+ void Promise.resolve()
414
+ .then(() => listener(payloadFor(session, change) as never))
415
+ .catch((error: unknown) => report(`session:${event}`, `The ${event} listener failed`, error))
416
+ }
227
417
  }
228
418
  },
419
+ answerBefore,
229
420
  follow(next) {
230
421
  const moved = next.id !== current.id && current.id !== ''
231
422
  const previous = current.id
232
423
  current = { ...next, title: next.title || (next.id === current.id ? current.title : ''), isRunning: true, lastAt: new Date().toISOString() }
233
424
  if (!moved) return
234
- const kept = listening.get(previous)
235
- if (kept === undefined) return
236
- listening.delete(previous)
237
- listening.set(current.id, kept)
425
+ for (const into of [listening, deciding]) {
426
+ const kept = into.get(previous)
427
+ if (kept === undefined) continue
428
+ into.delete(previous)
429
+ for (const entry of kept) if (entry.scope.session === previous) entry.scope = { ...entry.scope, session: current.id }
430
+ into.set(current.id, new Set([...(into.get(current.id) ?? []), ...kept]))
431
+ }
432
+ parents.delete(previous)
238
433
  subscribe(previous)
239
434
  subscribe(current.id)
240
435
  },
241
436
  }
242
437
  }
438
+
439
+ export function resultOf(where: Pick<Place, 'session' | 'context' | 'turn'>, answered: Awaited<ReturnType<Claude['tool']['call']>>): ToolResult {
440
+ const base: Omit<ToolResult, 'text' | 'isError'> = { id: '', type: ToolResult, at: new Date().toISOString(), session: where.session, context: where.context, ...(where.turn === undefined ? {} : { turn: where.turn.prompt.id }), callId: '' }
441
+ if (answered.deny !== undefined) return { ...base, text: answered.deny, isError: true }
442
+ return { ...base, text: answered.text ?? JSON.stringify(answered.result), isError: answered.isError === true }
443
+ }
444
+
445
+ function optionsOf(options: SearchOptions): Readonly<Record<string, unknown>> {
446
+ return { ...(options.limit === undefined ? {} : { limit: options.limit }), ...(options.before === undefined ? {} : { before: { session: options.before.session, id: options.before.id } }) }
447
+ }