@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.
- package/docs/commands.md +22 -11
- package/docs/dependencies.md +6 -4
- package/docs/hooks.md +26 -3
- package/docs/index.md +1 -1
- package/docs/install-steps.md +5 -3
- package/docs/mod.md +29 -3
- package/docs/permissions.md +4 -4
- package/docs/session.md +126 -63
- package/docs/testing.md +7 -5
- package/docs/ui.md +9 -1
- package/jobs/program.d.ts.map +1 -1
- package/jobs/program.js +7 -2
- package/mod.d.ts +30 -6
- package/mod.d.ts.map +1 -1
- package/mod.js +34 -1
- package/package.json +1 -1
- package/records.d.ts +4 -1
- package/records.d.ts.map +1 -1
- package/records.js +6 -1
- package/register.d.ts.map +1 -1
- package/register.js +17 -0
- package/runtime/announce.d.ts.map +1 -1
- package/runtime/announce.js +2 -0
- package/runtime/before.d.ts +32 -0
- package/runtime/before.d.ts.map +1 -0
- package/runtime/before.js +214 -0
- package/runtime/claude.d.ts +2 -1
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/conversations.d.ts +1 -0
- package/runtime/conversations.d.ts.map +1 -1
- package/runtime/conversations.js +333 -61
- package/runtime/dependencies.js +1 -1
- package/runtime/engine.d.ts +2 -6
- package/runtime/engine.d.ts.map +1 -1
- package/runtime/grants.d.ts.map +1 -1
- package/runtime/grants.js +8 -8
- package/runtime/hooks.d.ts +1 -1
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +19 -4
- package/runtime/installer.d.ts +1 -1
- package/runtime/installer.d.ts.map +1 -1
- package/runtime/installer.js +6 -5
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +130 -59
- package/runtime/metadata.d.ts.map +1 -1
- package/runtime/metadata.js +5 -4
- package/runtime/sessions.d.ts +26 -4
- package/runtime/sessions.d.ts.map +1 -1
- package/runtime/sessions.js +315 -117
- package/runtime/toaster.d.ts +5 -12
- package/runtime/toaster.d.ts.map +1 -1
- package/runtime/toaster.js +158 -25
- package/runtime/ui.d.ts.map +1 -1
- package/runtime/ui.js +26 -6
- package/session/context/context.d.ts +3 -1
- package/session/context/context.d.ts.map +1 -1
- package/session/context/turn/message/message.d.ts +1 -0
- package/session/context/turn/message/message.d.ts.map +1 -1
- package/session/context/turn/message/message.js +42 -1
- package/session/context/turn/turn.d.ts +2 -1
- package/session/context/turn/turn.d.ts.map +1 -1
- package/session/conversation.d.ts +20 -11
- package/session/conversation.d.ts.map +1 -1
- package/session/conversation.js +50 -27
- package/session/goal/goal.d.ts +3 -2
- package/session/goal/goal.d.ts.map +1 -1
- package/session/goal/task.d.ts +4 -4
- package/session/goal/task.d.ts.map +1 -1
- package/session/session.d.ts +80 -21
- package/session/session.d.ts.map +1 -1
- package/session/session.js +1 -0
- package/src/jobs/program.ts +7 -2
- package/src/mod.ts +48 -5
- package/src/records.ts +6 -1
- package/src/register.ts +17 -0
- package/src/runtime/announce.ts +2 -0
- package/src/runtime/before.ts +200 -0
- package/src/runtime/claude.ts +3 -0
- package/src/runtime/conversations.ts +337 -60
- package/src/runtime/dependencies.ts +1 -1
- package/src/runtime/engine.ts +2 -1
- package/src/runtime/grants.ts +8 -10
- package/src/runtime/hooks.ts +20 -4
- package/src/runtime/installer.ts +6 -5
- package/src/runtime/lifecycle.ts +136 -56
- package/src/runtime/metadata.ts +5 -4
- package/src/runtime/sessions.ts +316 -111
- package/src/runtime/toaster.ts +183 -45
- package/src/runtime/ui.ts +23 -4
- package/src/session/context/context.ts +3 -1
- package/src/session/context/turn/message/message.ts +39 -1
- package/src/session/context/turn/turn.ts +2 -1
- package/src/session/conversation.ts +56 -31
- package/src/session/goal/goal.ts +3 -2
- package/src/session/goal/task.ts +4 -4
- package/src/session/session.ts +62 -15
- package/src/testing/fake-claude.ts +4 -2
- package/src/testing/fake-sessions.ts +94 -20
- package/src/testing.ts +27 -3
- package/src/utils/call-effects.ts +2 -2
- package/src/utils/disposable.ts +3 -0
- package/src/utils/matcher.ts +1 -1
- package/src/utils/paths.ts +6 -3
- package/testing/fake-claude.d.ts +1 -0
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +3 -1
- package/testing/fake-sessions.d.ts +8 -1
- package/testing/fake-sessions.d.ts.map +1 -1
- package/testing/fake-sessions.js +97 -18
- package/testing.d.ts +1 -0
- package/testing.d.ts.map +1 -1
- package/testing.js +23 -2
- package/utils/call-effects.d.ts +1 -0
- package/utils/call-effects.d.ts.map +1 -1
- package/utils/call-effects.js +1 -1
- package/utils/disposable.d.ts +4 -0
- package/utils/disposable.d.ts.map +1 -0
- package/utils/disposable.js +1 -0
- package/utils/matcher.js +1 -1
- package/utils/paths.d.ts +2 -1
- package/utils/paths.d.ts.map +1 -1
- package/utils/paths.js +6 -3
package/src/runtime/sessions.ts
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
import type { Disposable } from '../mod.js'
|
|
2
|
-
import type
|
|
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
|
|
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
|
|
9
|
-
import type
|
|
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
|
-
|
|
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)
|
|
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 ?
|
|
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
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
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(
|
|
70
|
-
const
|
|
71
|
-
|
|
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
|
|
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
|
|
78
|
-
|
|
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
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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 = (
|
|
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(
|
|
106
|
-
if ('context' in change) return contextOf(
|
|
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
|
-
|
|
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
|
|
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
|
|
124
|
-
return read().
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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 (
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
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)
|
|
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
|
|
402
|
+
return currentSession
|
|
218
403
|
}),
|
|
219
|
-
search: (text, options = {}) => call<Message[]>(conversationMethods.search, { text, ...options, ...(options.
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
.
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
+
}
|