@cmodjs/core 1.5.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/docs/commands.md +5 -5
  2. package/docs/index.md +12 -11
  3. package/docs/jobs.md +12 -16
  4. package/docs/mod.md +117 -39
  5. package/docs/permissions.md +17 -21
  6. package/docs/session.md +171 -82
  7. package/docs/state.md +5 -5
  8. package/docs/testing.md +57 -41
  9. package/docs/ui.md +9 -9
  10. package/jobs/permissions/decide-permission.d.ts.map +1 -1
  11. package/jobs/permissions/decide-permission.js +1 -1
  12. package/jobs/prompt.d.ts +1 -3
  13. package/jobs/prompt.d.ts.map +1 -1
  14. package/jobs/prompt.js +2 -21
  15. package/jobs/slash-command.d.ts +1 -1
  16. package/jobs/slash-command.d.ts.map +1 -1
  17. package/jobs/slash-command.js +1 -1
  18. package/jobs/status-line.d.ts +0 -1
  19. package/jobs/status-line.d.ts.map +1 -1
  20. package/jobs/status-line.js +3 -4
  21. package/mod.d.ts +53 -105
  22. package/mod.d.ts.map +1 -1
  23. package/mod.js +18 -80
  24. package/package.json +1 -1
  25. package/records.d.ts +3 -1
  26. package/records.d.ts.map +1 -1
  27. package/records.js +14 -5
  28. package/register.d.ts.map +1 -1
  29. package/register.js +3 -1
  30. package/runtime/before.d.ts +5 -1
  31. package/runtime/before.d.ts.map +1 -1
  32. package/runtime/before.js +81 -39
  33. package/runtime/claude.d.ts +6 -1
  34. package/runtime/claude.d.ts.map +1 -1
  35. package/runtime/conversations.d.ts.map +1 -1
  36. package/runtime/conversations.js +45 -26
  37. package/runtime/deadline.d.ts +17 -0
  38. package/runtime/deadline.d.ts.map +1 -1
  39. package/runtime/deadline.js +34 -0
  40. package/runtime/dependencies.d.ts +2 -0
  41. package/runtime/dependencies.d.ts.map +1 -1
  42. package/runtime/dependencies.js +12 -0
  43. package/runtime/grants.d.ts +1 -1
  44. package/runtime/grants.d.ts.map +1 -1
  45. package/runtime/grants.js +19 -12
  46. package/runtime/hooks.d.ts +28 -11
  47. package/runtime/hooks.d.ts.map +1 -1
  48. package/runtime/hooks.js +20 -123
  49. package/runtime/lifecycle.d.ts +2 -1
  50. package/runtime/lifecycle.d.ts.map +1 -1
  51. package/runtime/lifecycle.js +67 -50
  52. package/runtime/sessions.d.ts +31 -5
  53. package/runtime/sessions.d.ts.map +1 -1
  54. package/runtime/sessions.js +342 -81
  55. package/runtime/tool-calls.d.ts +0 -1
  56. package/runtime/tool-calls.d.ts.map +1 -1
  57. package/runtime/tool-calls.js +0 -19
  58. package/session/context/turn/message/instruction-file.d.ts +9 -0
  59. package/session/context/turn/message/instruction-file.d.ts.map +1 -0
  60. package/session/context/turn/message/instruction-file.js +4 -0
  61. package/session/context/turn/message/message.d.ts +5 -4
  62. package/session/context/turn/message/message.d.ts.map +1 -1
  63. package/session/context/turn/message/message.js +7 -6
  64. package/session/context/turn/message/sender.d.ts +2 -1
  65. package/session/context/turn/message/sender.d.ts.map +1 -1
  66. package/session/context/turn/message/sender.js +4 -1
  67. package/session/context/turn/message/tool-call.d.ts +5 -0
  68. package/session/context/turn/message/tool-call.d.ts.map +1 -1
  69. package/session/context/turn/message/tool-result.d.ts +6 -1
  70. package/session/context/turn/message/tool-result.d.ts.map +1 -1
  71. package/session/context/turn/turn.d.ts +7 -0
  72. package/session/context/turn/turn.d.ts.map +1 -1
  73. package/session/conversation.d.ts +10 -3
  74. package/session/conversation.d.ts.map +1 -1
  75. package/session/conversation.js +14 -5
  76. package/session/goal/goal.d.ts +1 -1
  77. package/session/goal/goal.d.ts.map +1 -1
  78. package/session/goal/task.d.ts +2 -2
  79. package/session/goal/task.d.ts.map +1 -1
  80. package/session/session.d.ts +361 -26
  81. package/session/session.d.ts.map +1 -1
  82. package/session/session.js +40 -0
  83. package/src/jobs/permissions/decide-permission.ts +2 -1
  84. package/src/jobs/prompt.ts +3 -20
  85. package/src/jobs/slash-command.ts +2 -2
  86. package/src/jobs/status-line.ts +4 -7
  87. package/src/mod.ts +52 -147
  88. package/src/records.ts +15 -5
  89. package/src/register.ts +3 -1
  90. package/src/runtime/before.ts +90 -37
  91. package/src/runtime/claude.ts +5 -1
  92. package/src/runtime/conversations.ts +41 -25
  93. package/src/runtime/deadline.ts +38 -0
  94. package/src/runtime/dependencies.ts +12 -0
  95. package/src/runtime/grants.ts +15 -10
  96. package/src/runtime/hooks.ts +48 -116
  97. package/src/runtime/lifecycle.ts +66 -51
  98. package/src/runtime/sessions.ts +366 -90
  99. package/src/runtime/tool-calls.ts +0 -19
  100. package/src/session/context/turn/message/instruction-file.ts +13 -0
  101. package/src/session/context/turn/message/message.ts +11 -9
  102. package/src/session/context/turn/message/sender.ts +5 -2
  103. package/src/session/context/turn/message/tool-call.ts +3 -0
  104. package/src/session/context/turn/message/tool-result.ts +6 -1
  105. package/src/session/context/turn/turn.ts +4 -0
  106. package/src/session/conversation.ts +21 -8
  107. package/src/session/goal/goal.ts +1 -1
  108. package/src/session/goal/task.ts +2 -2
  109. package/src/session/session.ts +227 -24
  110. package/src/testing/claude-code.ts +23 -0
  111. package/src/testing/fake-claude.ts +5 -1
  112. package/src/testing/fake-sessions.ts +38 -24
  113. package/src/testing.ts +84 -16
  114. package/testing/claude-code.d.ts +12 -0
  115. package/testing/claude-code.d.ts.map +1 -0
  116. package/testing/claude-code.js +10 -0
  117. package/testing/fake-claude.d.ts +4 -0
  118. package/testing/fake-claude.d.ts.map +1 -1
  119. package/testing/fake-claude.js +4 -1
  120. package/testing/fake-sessions.d.ts +8 -6
  121. package/testing/fake-sessions.d.ts.map +1 -1
  122. package/testing/fake-sessions.js +35 -20
  123. package/testing.d.ts +38 -9
  124. package/testing.d.ts.map +1 -1
  125. package/testing.js +54 -5
  126. package/docs/hooks.md +0 -259
  127. package/session/context/turn/message/document.d.ts +0 -7
  128. package/session/context/turn/message/document.d.ts.map +0 -1
  129. package/session/context/turn/message/document.js +0 -1
  130. package/src/session/context/turn/message/document.ts +0 -8
@@ -1,7 +1,8 @@
1
1
  import type { Disposable } from '../utils/disposable.js'
2
2
  import type { Matcher } from '../utils/matcher.js'
3
+ import type { Sender } from './context/turn/message/sender.js'
3
4
  import type { Context, ContextEnd, ContextStart } from './context/context.js'
4
- import type { Document } from './context/turn/message/document.js'
5
+ import type { InstructionFile } from './context/turn/message/instruction-file.js'
5
6
  import type { Message, MessageType } from './context/turn/message/message.js'
6
7
  import type { Note } from './context/turn/message/note.js'
7
8
  import type { Notice } from './context/turn/message/notice.js'
@@ -29,7 +30,7 @@ export type SessionPayload = {
29
30
  readonly toolCall: ToolCall
30
31
  readonly toolResult: ToolResult
31
32
  readonly skill: Skill
32
- readonly document: Document
33
+ readonly instructionFile: InstructionFile
33
34
  readonly notice: Notice
34
35
  readonly contextStart: Context
35
36
  readonly contextEnd: Context
@@ -44,16 +45,15 @@ export type SessionPayload = {
44
45
  export type Refusal = { readonly refuse: string }
45
46
 
46
47
  export type BeforeAnswer = {
47
- readonly prompt: { readonly text: string } | Refusal
48
+ readonly prompt: { readonly text?: string; readonly note?: string; readonly title?: string } | Refusal
48
49
  readonly note: { readonly text: string }
49
50
  readonly skill: { readonly text: string }
50
- readonly document: { readonly text: string } | Refusal
51
+ readonly instructionFile: { readonly text: string } | Refusal
51
52
  readonly reply: { readonly text: string }
52
53
  readonly toolCall: { readonly input: Readonly<Record<string, unknown>> } | { readonly result: string } | Refusal
53
- readonly toolResult: { readonly text: string }
54
+ readonly toolResult: { readonly output?: unknown; readonly isError?: true; readonly note?: string }
54
55
  readonly notice: { readonly text: string }
55
56
  readonly taskStart: { readonly owner: { readonly model: string } } | Refusal
56
- readonly taskEnd: Refusal
57
57
  readonly turnEnd: Refusal
58
58
  readonly contextEnd: Refusal
59
59
  }
@@ -62,29 +62,70 @@ export type BeforeEvent = keyof BeforeAnswer
62
62
 
63
63
  export type Usage = {
64
64
  readonly model: string
65
- readonly context: { readonly tokens?: number; readonly window?: number; readonly percent?: number }
65
+ readonly contextWindow: { readonly tokenCount?: number; readonly size?: number; readonly percent?: number }
66
66
  readonly cost?: { readonly usd: number }
67
67
  }
68
68
 
69
- export type Client = 'terminal' | 'desktop' | 'mobile' | 'vscode'
69
+ export type Surface = 'terminal' | 'desktop' | 'mobile' | 'vscode'
70
70
 
71
- export type Listener<Event extends SessionEvent> = (payload: SessionPayload[Event]) => unknown
71
+ export const sessionEvents = {
72
+ Message: 'message',
73
+ Prompt: 'prompt',
74
+ Note: 'note',
75
+ Reply: 'reply',
76
+ ToolCall: 'toolCall',
77
+ ToolResult: 'toolResult',
78
+ Skill: 'skill',
79
+ InstructionFile: 'instructionFile',
80
+ Notice: 'notice',
81
+ ContextStart: 'contextStart',
82
+ ContextEnd: 'contextEnd',
83
+ TurnStart: 'turnStart',
84
+ TurnEnd: 'turnEnd',
85
+ TaskStart: 'taskStart',
86
+ TaskEnd: 'taskEnd',
87
+ GoalChange: 'goalChange',
88
+ UsageChange: 'usageChange',
89
+ } as const satisfies Readonly<Record<string, SessionEvent>>
72
90
 
73
- export type BeforeListener<Event extends BeforeEvent> = (payload: SessionPayload[Event]) => BeforeAnswer[Event] | void | Promise<BeforeAnswer[Event] | void>
91
+ export const beforeEvents = ['Prompt', 'Note', 'Skill', 'InstructionFile', 'Reply', 'ToolCall', 'ToolResult', 'Notice', 'TaskStart', 'TurnEnd', 'ContextEnd'] as const satisfies readonly (keyof typeof sessionEvents)[]
74
92
 
75
- export type SearchOptions = {
76
- readonly limit?: number
77
- readonly before?: Pick<Message, 'session' | 'id'>
93
+ export type EventName = keyof typeof sessionEvents
94
+
95
+ export type BeforeName = (typeof beforeEvents)[number]
96
+
97
+ export type PayloadOf<Name extends EventName> = SessionPayload[(typeof sessionEvents)[Name]]
98
+
99
+ export type AnswerOf<Name extends BeforeName> = (typeof sessionEvents)[Name] extends BeforeEvent ? BeforeAnswer[(typeof sessionEvents)[Name]] : never
100
+
101
+ export type ReadOptions = { readonly timeoutMs?: number }
102
+
103
+ export type AnswerOptions = ReadOptions & { readonly onFailure?: 'pass' | 'refuse' }
104
+
105
+ export type ListenerTime = { readonly deadline: number; readonly signal: AbortSignal }
106
+
107
+ export type Listener<Payload, Answer = void> = (payload: Payload, time: ListenerTime) => Answer | void | Promise<Answer | void>
108
+
109
+ export type OnMethod<Payload> = {
110
+ (listener: Listener<Payload, unknown>, options?: ReadOptions): Disposable
111
+ (matcher: Matcher<Payload>, listener: Listener<Payload, unknown>, options?: ReadOptions): Disposable
78
112
  }
79
113
 
80
- export type RecordPart = {
81
- search(text: string, options?: SearchOptions): Promise<Message[]>
82
- on<Event extends SessionEvent>(event: Event, listener: Listener<Event>): Disposable
83
- on<Event extends SessionEvent>(event: Event, matcher: Matcher<SessionPayload[Event]>, listener: Listener<Event>): Disposable
84
- before<Event extends BeforeEvent>(event: Event, listener: BeforeListener<Event>): Disposable
85
- before<Event extends BeforeEvent>(event: Event, matcher: Matcher<SessionPayload[Event]>, listener: BeforeListener<Event>): Disposable
114
+ export type BeforeMethod<Payload, Answer> = {
115
+ (listener: Listener<Payload, Answer>, options?: AnswerOptions): Disposable
116
+ (matcher: Matcher<Payload>, listener: Listener<Payload, Answer>, options?: AnswerOptions): Disposable
86
117
  }
87
118
 
119
+ export type OnMethods = { readonly [Name in EventName as `on${Name}`]: OnMethod<PayloadOf<Name>> }
120
+
121
+ export type BeforeMethods = { readonly [Name in BeforeName as `before${Name}`]: BeforeMethod<PayloadOf<Name>, AnswerOf<Name>> }
122
+
123
+
124
+ export type RecordPart = OnMethods &
125
+ BeforeMethods & {
126
+ search(text: string, matcher?: Matcher<Message>): AsyncIterable<Message>
127
+ }
128
+
88
129
  export type SessionSend = {
89
130
  readonly type: typeof Prompt | typeof Note
90
131
  readonly text: string
@@ -95,6 +136,7 @@ export type TaskStartArgs = {
95
136
  readonly prompt: string
96
137
  readonly agentType?: string
97
138
  readonly model?: string
139
+ readonly title: string
98
140
  readonly name?: string
99
141
  readonly cwd?: string
100
142
  }
@@ -115,7 +157,7 @@ export type Session = RecordPart & {
115
157
  readonly startedAt: string
116
158
  readonly lastAt: string
117
159
  readonly forkedFrom?: string
118
- goal(): Promise<Goal>
160
+ readonly goal: Goal
119
161
  context(): Promise<Context>
120
162
  contexts(): Promise<Context[]>
121
163
  turn(): Promise<Turn | undefined>
@@ -125,17 +167,178 @@ export type Session = RecordPart & {
125
167
  startTask(task: TaskStartArgs): Promise<Task>
126
168
  callTool(call: ToolCallArgs): Promise<ToolResult>
127
169
  usage(): Promise<Usage>
128
- clients(): Promise<Client[]>
170
+ surfaces(): Promise<Surface[]>
129
171
  }
130
172
 
173
+ export type TaskOwner = Omit<Session, 'startTask' | 'callTool'>
174
+
175
+ type ProcessEvent = { readonly on: string; readonly before?: string }
176
+
177
+ export const processEvents = {
178
+ ProjectSetup: { on: 'Setup', before: 'Setup' },
179
+ SessionStart: { on: 'SessionStart', before: 'SessionStart' },
180
+ SessionEnd: { on: 'SessionEnd' },
181
+ ModelChange: { on: 'PostModelSwitch', before: 'PreModelSwitch' },
182
+ CwdChange: { on: 'CwdChanged' },
183
+ DirectoryAdd: { on: 'DirectoryAdded' },
184
+ WorktreeCreate: { on: 'WorktreeCreate', before: 'WorktreeCreate' },
185
+ WorktreeRemove: { on: 'WorktreeRemove' },
186
+ Notification: { on: 'Notification' },
187
+ Elicitation: { on: 'Elicitation', before: 'Elicitation' },
188
+ ElicitationResponse: { on: 'ElicitationResult', before: 'ElicitationResult' },
189
+ PermissionCheck: { on: 'tool.check', before: 'tool.check' },
190
+ PermissionRequest: { on: 'PermissionRequest', before: 'PermissionRequest' },
191
+ PermissionDenial: { on: 'PermissionDenied', before: 'PermissionDenied' },
192
+ ToolBatch: { on: 'PostToolBatch', before: 'PostToolBatch' },
193
+ TodoCreate: { on: 'TaskCreated', before: 'TaskCreated' },
194
+ TodoComplete: { on: 'TaskCompleted', before: 'TaskCompleted' },
195
+ ReplyDisplay: { on: 'MessageDisplay', before: 'MessageDisplay' },
196
+ } as const satisfies Readonly<Record<string, ProcessEvent>>
197
+
198
+ export type Placement = {
199
+ readonly session: string
200
+ readonly task?: string
201
+ readonly agentType?: string
202
+ readonly cwd: string
203
+ readonly permissionMode?: string
204
+ }
205
+
206
+ export type SessionStartAsked = Placement & {
207
+ readonly source: 'startup' | 'resume' | 'clear' | 'fork' | 'compact'
208
+ readonly model?: string
209
+ readonly title?: string
210
+ readonly idleSeconds?: number
211
+ readonly contextTokenCount?: number
212
+ readonly isCacheWarm?: boolean
213
+ readonly cacheWriteUsd?: number
214
+ }
215
+
216
+ export type SessionStarted = Omit<SessionStartAsked, 'source'> & { readonly source: 'startup' | 'resume' | 'clear' | 'fork' }
217
+
218
+ export type ProjectSetup = Placement & { readonly trigger: 'init' | 'maintenance' }
219
+
220
+ export type SessionEnded = Placement & { readonly reason: 'clear' | 'resume' | 'logout' | 'exit' | 'other' }
221
+
222
+ export type ModelChange = Placement & {
223
+ readonly model: string
224
+ readonly previous: string
225
+ readonly requested?: string
226
+ readonly source: 'command' | 'picker' | 'sdk' | 'auto' | 'resume'
227
+ readonly contextTokenCount: number
228
+ readonly isCacheWarm: boolean
229
+ readonly cacheTtl: '5m' | '1h'
230
+ readonly cacheWriteUsd: number
231
+ readonly pricing?: 'configured' | 'catalog' | 'default'
232
+ }
233
+
234
+ export type CwdChange = Placement & { readonly previous: string }
235
+
236
+ export type DirectoryAdd = Placement & { readonly directory: string; readonly source: 'command' | 'sdk' }
237
+
238
+ export type Worktree = Placement & { readonly name?: string; readonly path?: string }
239
+
240
+ export type Notification = Placement & { readonly text: string; readonly title?: string; readonly type: string }
241
+
242
+ export type Elicitation = Placement & { readonly id?: string; readonly server: string; readonly text: string; readonly mode?: 'form' | 'url'; readonly url?: string; readonly schema?: Readonly<Record<string, unknown>> }
243
+
244
+ export type ElicitationResponse = Placement & { readonly id?: string; readonly server: string; readonly mode?: 'form' | 'url'; readonly action: 'accept' | 'decline' | 'cancel'; readonly content?: Readonly<Record<string, unknown>> }
245
+
246
+ export type McpServer = { readonly name: string; readonly source: string }
247
+
248
+ type PermissionRules = { readonly rules: readonly { readonly toolName: string; readonly ruleContent?: string }[]; readonly behavior: 'allow' | 'deny' | 'ask' }
249
+
250
+ type PermissionDestination = { readonly destination: 'userSettings' | 'projectSettings' | 'localSettings' | 'session' | 'cliArg' }
251
+
252
+ export type PermissionUpdate = PermissionDestination &
253
+ (
254
+ | (PermissionRules & { readonly type: 'addRules' | 'replaceRules' | 'removeRules' })
255
+ | { readonly type: 'setMode'; readonly mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'dontAsk' | 'auto' }
256
+ | { readonly type: 'addDirectories' | 'removeDirectories'; readonly directories: readonly string[] }
257
+ )
258
+
259
+ export type PermissionCheck = Placement & {
260
+ readonly name: string
261
+ readonly input: Readonly<Record<string, unknown>>
262
+ readonly callId?: string
263
+ readonly decision: 'allow' | 'ask' | 'refuse'
264
+ readonly reason?: string
265
+ readonly rule?: string
266
+ readonly ceiling?: 'allow' | 'ask' | 'refuse'
267
+ }
268
+
269
+ export type PermissionRequest = Placement & { readonly name: string; readonly input: Readonly<Record<string, unknown>>; readonly suggestions: readonly PermissionUpdate[]; readonly mcpServer?: McpServer }
270
+
271
+ export type PermissionDenial = Placement & { readonly name: string; readonly input: Readonly<Record<string, unknown>>; readonly callId: string; readonly reason: string; readonly mcpServer?: McpServer }
272
+
273
+ export type BatchedCall = { readonly callId: string; readonly name: string; readonly input: Readonly<Record<string, unknown>>; readonly output?: unknown }
274
+
275
+ export type ToolBatch = Placement & { readonly calls: readonly BatchedCall[] }
276
+
277
+ export type Todo = Placement & { readonly id: string; readonly subject: string; readonly description?: string; readonly teammate?: string }
278
+
279
+ export type ReplyDisplay = Placement & { readonly replyId: string; readonly index: number; readonly isLast: boolean; readonly text: string }
280
+
281
+ export type CommandRun = Placement & { readonly name: string; readonly args: string; readonly source?: 'builtin' | 'plugin' | 'user' | 'mcp'; readonly from: Sender }
282
+
283
+ export type CommandRunAnswer = { readonly args?: string; readonly note?: string } | { readonly result: string; readonly exitCode?: number } | Refusal
284
+
285
+ export type ProcessPayload = {
286
+ readonly ProjectSetup: ProjectSetup
287
+ readonly SessionStart: SessionStarted
288
+ readonly SessionEnd: SessionEnded
289
+ readonly ModelChange: ModelChange
290
+ readonly CwdChange: CwdChange
291
+ readonly DirectoryAdd: DirectoryAdd
292
+ readonly WorktreeCreate: Worktree
293
+ readonly WorktreeRemove: Worktree
294
+ readonly Notification: Notification
295
+ readonly Elicitation: Elicitation
296
+ readonly ElicitationResponse: ElicitationResponse
297
+ readonly PermissionCheck: PermissionCheck
298
+ readonly PermissionRequest: PermissionRequest
299
+ readonly PermissionDenial: PermissionDenial
300
+ readonly ToolBatch: ToolBatch
301
+ readonly TodoCreate: Todo
302
+ readonly TodoComplete: Todo
303
+ readonly ReplyDisplay: ReplyDisplay
304
+ }
305
+
306
+ export type ProcessAsked = Omit<ProcessPayload, 'SessionStart'> & { readonly SessionStart: SessionStartAsked }
307
+
308
+ export type ProcessAnswer = {
309
+ readonly ProjectSetup: Record<never, never>
310
+ readonly SessionStart: { readonly note?: string; readonly title?: string; readonly reloadSkills?: true; readonly firstPrompt?: string }
311
+ readonly ModelChange: { readonly ask: string } | Refusal
312
+ readonly WorktreeCreate: { readonly result: string }
313
+ readonly Elicitation: Refusal
314
+ readonly ElicitationResponse: Refusal
315
+ readonly PermissionCheck: { readonly allow: true } | { readonly ask: string } | Refusal
316
+ readonly PermissionRequest: { readonly allow: true; readonly input?: Readonly<Record<string, unknown>>; readonly updates?: readonly PermissionUpdate[] } | (Refusal & { readonly interrupt?: true })
317
+ readonly PermissionDenial: { readonly retry: true }
318
+ readonly ToolBatch: { readonly note: string }
319
+ readonly TodoCreate: Refusal
320
+ readonly TodoComplete: Refusal
321
+ readonly ReplyDisplay: { readonly text: string }
322
+ }
323
+
324
+ export type ProcessName = keyof typeof processEvents
325
+
326
+ export type ProcessOnMethods = { readonly [Name in ProcessName as `on${Name}`]: OnMethod<ProcessPayload[Name]> }
327
+
328
+ export type ProcessBeforeMethods = { readonly [Name in keyof ProcessAnswer as `before${Name}`]: BeforeMethod<ProcessAsked[Name], ProcessAnswer[Name]> }
329
+
330
+ export type CurrentSession = Session & ProcessOnMethods & ProcessBeforeMethods
331
+
131
332
  export type SessionScope = {
132
333
  readonly running?: boolean
133
334
  readonly since?: string
134
335
  readonly everyProject?: boolean
135
336
  }
136
337
 
137
- export type Sessions = Omit<RecordPart, 'search'> & {
138
- readonly current: Session
338
+ export type SearchOptions = SessionScope & { readonly before?: Pick<Message, 'session' | 'id'> }
339
+
340
+ export type Sessions = OnMethods & {
341
+ readonly current: CurrentSession
139
342
  list(options?: SessionScope): Promise<Session[]>
140
- search(text: string, options?: SessionScope & SearchOptions): Promise<Message[]>
343
+ search(text: string, matcher?: Matcher<Message>, options?: SearchOptions): AsyncIterable<Message>
141
344
  }
@@ -0,0 +1,23 @@
1
+ import type { Args, ClassicHookInputs, EventResult } from 'claude-code'
2
+ import type { Hook, RoutedEvent } from '../runtime/hooks.js'
3
+
4
+ type FilledField = 'session_id' | 'transcript_path' | 'cwd' | 'hook_event_name' | 'tool_use_id'
5
+
6
+ export type HookInputOf<H extends Hook | 'PreToolUse'> = Omit<ClassicHookInputs[H], FilledField> & Partial<Pick<ClassicHookInputs[H], FilledField & keyof ClassicHookInputs[H]>>
7
+
8
+ export type ClaudeCode = {
9
+ fire<H extends Hook | 'PreToolUse'>(hook: H, input: HookInputOf<H>, below?: EventResult<`classic.${H}`>): Promise<EventResult<`classic.${H}`>>
10
+ fire<N extends RoutedEvent>(event: N, input: Args<N>, below?: EventResult<N>): Promise<EventResult<N>>
11
+ }
12
+
13
+ const drivers = new WeakMap<object, ClaudeCode>()
14
+
15
+ export function keepClaudeCode(tested: object, driver: ClaudeCode): void {
16
+ drivers.set(tested, driver)
17
+ }
18
+
19
+ export function claudeCodeOf(tested: object): ClaudeCode {
20
+ const driver = drivers.get(tested)
21
+ if (driver === undefined) throw new Error('claudeCodeOf takes a mod testMod returned.')
22
+ return driver
23
+ }
@@ -15,6 +15,7 @@ export type Fakes = {
15
15
  http: { fetch?: Claude['http']['fetch'] }
16
16
  settings: { read?: Claude['settings']['read'] }
17
17
  config: { list?: Claude['config']['list'] }
18
+ command: { list?: Claude['command']['list']; run?: Claude['command']['run'] }
18
19
  ui: { ask?: Claude['ui']['ask']; open?: Claude['ui']['open']; scroll?: Claude['ui']['scroll']; copy?: Claude['ui']['copy']; focus?: Claude['ui']['focus'] }
19
20
  session: { messages?: Claude['session']['messages']; append?: Claude['session']['append']; send?: Claude['session']['send'] }
20
21
  prompt: { submit?: Claude['prompt']['submit'] }
@@ -55,7 +56,7 @@ export function calledAnswer(caller: string, answer: EventResult<'cmod.call'>):
55
56
 
56
57
  export function fakeClaude(plugin: { readonly name: string; readonly root: string }): FakeClaude {
57
58
  const calls: TestCall[] = []
58
- const fakes: Fakes = { process: {}, fs: {}, http: {}, settings: {}, config: {}, ui: {}, session: {}, prompt: {}, model: {}, agent: {}, clock: {}, cmod: {}, tool: {} }
59
+ const fakes: Fakes = { process: {}, fs: {}, http: {}, settings: {}, config: {}, command: {}, ui: {}, session: {}, prompt: {}, model: {}, agent: {}, clock: {}, cmod: {}, tool: {} }
59
60
  const emptyDisk = fakeFiles({})
60
61
  const shown: Shown = { notes: [], prompts: [], logs: [], debug: [], statuses: [], openPanes: new Set(), commands: [], tools: [], schedules: [] }
61
62
  const unplacedPanes = new Set<string>()
@@ -190,12 +191,15 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
190
191
  send: rejected('session.send', () => fakes.session.send ?? (async () => ({ isDelivered: true as const }))),
191
192
  },
192
193
  prompt: { submit: rejected('prompt.submit', () => fakes.prompt.submit ?? submitPrompt) },
194
+ turn: { abort: rejected('turn.abort', () => async () => undefined) },
193
195
  model: { complete: rejected('model.complete', () => fakes.model.complete) },
194
196
  command: {
195
197
  register: async (command) => {
196
198
  shown.commands.push(command.name)
197
199
  return { command: command.name }
198
200
  },
201
+ list: rejected('command.list', () => fakes.command.list ?? (async () => shown.commands.map((name) => ({ name, description: '', source: 'plugin' as const, plugin: plugin.name })))),
202
+ run: rejected('command.run', () => fakes.command.run ?? (async () => ({}))),
199
203
  },
200
204
  tool: {
201
205
  register: async (tool) => {
@@ -1,6 +1,6 @@
1
1
  import { isObject } from '../records.js'
2
- import { Document } from '../session/context/turn/message/document.js'
3
- import { Message, searchText, skillPrefix, type Row } from '../session/context/turn/message/message.js'
2
+ import { InstructionFile } from '../session/context/turn/message/instruction-file.js'
3
+ import { Message, messagesOf, noFiles, searchText, skillPrefix, type Row } from '../session/context/turn/message/message.js'
4
4
  import { Note } from '../session/context/turn/message/note.js'
5
5
  import { Notice } from '../session/context/turn/message/notice.js'
6
6
  import { Prompt } from '../session/context/turn/message/prompt.js'
@@ -11,7 +11,7 @@ import { Task } from '../session/goal/task.js'
11
11
  import { Skill } from '../session/context/turn/message/skill.js'
12
12
  import { ToolCall } from '../session/context/turn/message/tool-call.js'
13
13
  import { ToolResult } from '../session/context/turn/message/tool-result.js'
14
- import { createConversation, emptyConversation, searchLimit, type ContextData, type Conversation, type SessionChange, type SessionData, type TaskData, type TurnData } from '../session/conversation.js'
14
+ import { createConversation, emptyConversation, searchLimit, type ContextData, type Conversation, type SessionChange, type SessionData, type TaskData, type TurnData, type TurnEnding } from '../session/conversation.js'
15
15
  import type { Place } from '../runtime/before.js'
16
16
  import type { Claude } from '../runtime/claude.js'
17
17
  import { conversationMethods, everySession, isHeardBy, resultOf, type Subscription } from '../runtime/sessions.js'
@@ -23,9 +23,9 @@ export type MessageDraft =
23
23
  | { readonly type: typeof Note; readonly text: string; readonly from?: Sender }
24
24
  | { readonly type: typeof Reply; readonly text: string }
25
25
  | { readonly type: typeof ToolCall; readonly name: string; readonly input?: Readonly<Record<string, unknown>>; readonly callId?: string }
26
- | { readonly type: typeof ToolResult; readonly callId: string; readonly text: string; readonly isError?: boolean }
26
+ | { readonly type: typeof ToolResult; readonly callId: string; readonly output: string; readonly isError?: boolean }
27
27
  | { readonly type: typeof Skill; readonly name: string; readonly text: string }
28
- | { readonly type: typeof Document; readonly path: string; readonly text: string }
28
+ | { readonly type: typeof InstructionFile; readonly path: string; readonly text: string }
29
29
  | { readonly type: typeof Notice; readonly text: string }
30
30
 
31
31
  export type FakeSession = Partial<Omit<SessionData, 'id'>> & {
@@ -49,16 +49,17 @@ type Heard = { readonly event: Subscription['event']; readonly matcher: unknown
49
49
  export type FakeSessions = {
50
50
  answer(caller: string, method: string, input: unknown): unknown
51
51
  add(draft: MessageDraft, task?: string): Message[]
52
+ draft(draft: MessageDraft, task?: string): Message
52
53
  place(): Place
53
54
  wants(mod: string, event: string): boolean
54
- endTurn(): void
55
+ endTurn(ending?: TurnEnding): void
55
56
  compact(): void
56
57
  startTask(task: { readonly title: string; readonly agentType?: string; readonly model?: string; readonly openedBy?: string; readonly parent?: string }): string
57
58
  start(id: string): void
58
59
  readonly sent: { readonly session: string; readonly task?: string; readonly type: string; readonly text: string; readonly as?: string }[]
59
60
  }
60
61
 
61
- export function fakeSessions({ project, given, push, callTool }: { readonly project: () => string; readonly given: readonly FakeSession[]; readonly push: (mod: string, session: string, change: SessionChange) => void; readonly callTool: Claude['tool']['call'] }): FakeSessions {
62
+ export function fakeSessions({ project, cwd, given, push, callTool }: { readonly project: () => string; readonly cwd: () => string; readonly given: readonly FakeSession[]; readonly push: (mod: string, session: string, change: SessionChange) => void; readonly callTool: Claude['tool']['call'] }): FakeSessions {
62
63
  const kept = new Map<string, Kept>()
63
64
  const heard = new Map<string, Map<string, readonly Heard[]>>()
64
65
  const watches = new Map<string, Map<string, readonly string[]>>()
@@ -90,7 +91,7 @@ export function fakeSessions({ project, given, push, callTool }: { readonly proj
90
91
 
91
92
  const add = (session: Kept, draft: MessageDraft, task?: string) => {
92
93
  const before = session.messages.length
93
- record(session, session.conversation.add(rowOf(draft, `message-${(ids += 1)}`, now()), task))
94
+ record(session, session.conversation.add(rowOf(draft, `message-${(ids += 1)}`, now(), cwd()), task))
94
95
  return session.messages.slice(before)
95
96
  }
96
97
 
@@ -109,8 +110,8 @@ export function fakeSessions({ project, given, push, callTool }: { readonly proj
109
110
  const currentSession = () => sessionOf(current)
110
111
 
111
112
  const place = (): Place => {
112
- const { session, context, contextOpenedAt, turn, tasks, taskTurns } = currentSession().conversation.state
113
- return { session, context, contextOpenedAt, ...(turn === undefined ? {} : { turn }), tasks, taskTurns }
113
+ const { session, context, contextOpenedAt, turn, tasks, taskTurns, openCalls } = currentSession().conversation.state
114
+ return { session, context, contextOpenedAt, ...(turn === undefined ? {} : { turn }), tasks, taskTurns, ...(openCalls === undefined ? {} : { openCalls }) }
114
115
  }
115
116
 
116
117
  const taskOf = (session: Kept, id: string) => {
@@ -144,8 +145,8 @@ export function fakeSessions({ project, given, push, callTool }: { readonly proj
144
145
  const session = kept.get(message.session)
145
146
  const task = request['task']
146
147
  if (request['session'] !== undefined && message.session !== request['session']) return false
147
- if (typeof task === 'string' && (message.task === undefined || !isUnder(session, message.task, task) || (request['isGoal'] === true && message.task === task))) return false
148
- if (task === undefined && request['isGoal'] === true && message.task === undefined) return false
148
+ if (request['isAgent'] === true && message.task !== task) return false
149
+ if (typeof task === 'string' && (message.task === undefined || !isUnder(session, message.task, task))) return false
149
150
  return (request['turn'] === undefined || message.turn === request['turn']) && (request['context'] === undefined || message.context === request['context'])
150
151
  }
151
152
  const found = [...kept.values()]
@@ -155,7 +156,7 @@ export function fakeSessions({ project, given, push, callTool }: { readonly proj
155
156
  .toSorted((first, second) => second.at.localeCompare(first.at))
156
157
  const before = isObject(request['before']) ? request['before'] : undefined
157
158
  const start = before === undefined ? 0 : found.findIndex((message) => message.session === before['session'] && message.id === before['id']) + 1
158
- return found.slice(start, start + (typeof request['limit'] === 'number' ? request['limit'] : searchLimit))
159
+ return found.slice(start, start + searchLimit)
159
160
  },
160
161
  [conversationMethods.messages](request) {
161
162
  const session = sessionOf(String(request['session']))
@@ -200,21 +201,26 @@ export function fakeSessions({ project, given, push, callTool }: { readonly proj
200
201
  const input = isObject(request['input']) ? request['input'] : {}
201
202
  const task = typeof request['task'] === 'string' ? request['task'] : undefined
202
203
  const isLive = task === undefined ? session.data.isRunning : session.data.isRunning && taskOf(session, task).closedAt === undefined
203
- if (action === 'usage') return { model: task === undefined ? session.data.model : (taskOf(session, task).model ?? session.data.model), context: {} }
204
- if (action === 'clients') return isLive ? ['terminal'] : []
204
+ if (action === 'usage') return { model: task === undefined ? session.data.model : (taskOf(session, task).model ?? session.data.model), contextWindow: {} }
205
+ if (action === 'surfaces') return isLive ? ['terminal'] : []
205
206
  if (!isLive) throw new Error(`session ${task ?? session.data.id} has ended`)
207
+ if (action === 'abort') {
208
+ const open = task === undefined ? session.conversation.state.turn : session.conversation.state.taskTurns[task]
209
+ if (open?.prompt.id !== input['turn']) throw new Error(`turn ${String(input['turn'])} has ended, so there is nothing to abort.`)
210
+ return null
211
+ }
206
212
  if (action === 'send') {
207
213
  sent.push({ session: session.data.id, ...(task === undefined ? {} : { task }), type: String(input['type']), text: String(input['text']), ...(typeof input['as'] === 'string' ? { as: input['as'] } : {}) })
208
214
  return null
209
215
  }
210
216
  if (action === 'startTask') {
211
217
  const id = `task-${(ids += 1)}`
212
- const data: TaskData = { id, title: typeof input['name'] === 'string' ? input['name'] : '', agentType: typeof input['agentType'] === 'string' ? input['agentType'] : 'general-purpose', openedBy: '', openedAt: now(), ...(typeof input['model'] === 'string' ? { model: input['model'] } : {}) }
218
+ const data: TaskData = { id, title: typeof input['title'] === 'string' ? input['title'] : '', agentType: typeof input['agentType'] === 'string' ? input['agentType'] : 'general-purpose', openedBy: '', openedAt: now(), ...(typeof input['model'] === 'string' ? { model: input['model'] } : {}) }
213
219
  record(session, session.conversation.startTask(data))
214
220
  return data
215
221
  }
216
- if (session.data.id === current) return resultOf(place(), await callTool({ ...input['input'] as object, tool: String(input['name']) } as never))
217
- return { id: '', type: ToolResult, at: now(), session: session.data.id, context: session.conversation.state.context, callId: '', text: '', isError: false }
222
+ if (session.data.id === current) return resultOf(place(), { name: String(input['name']), input: input['input'] as Readonly<Record<string, unknown>>, cwd: project() }, await callTool({ ...input['input'] as object, tool: String(input['name']) } as never))
223
+ return { id: '', type: ToolResult, at: now(), session: session.data.id, context: session.conversation.state.context, callId: '', name: '', input: {}, cwd: project(), files: noFiles, output: '', isError: false }
218
224
  },
219
225
  [conversationMethods.subscribe](request, caller) {
220
226
  const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Subscription[]) : []
@@ -236,11 +242,19 @@ export function fakeSessions({ project, given, push, callTool }: { readonly proj
236
242
  }
237
243
  },
238
244
  add: (draft, task) => add(currentSession(), draft, task),
245
+ draft(draft, task) {
246
+ const where = place()
247
+ const [unplaced] = messagesOf(rowOf(draft, `message-${(ids += 1)}`, now(), cwd()), task !== undefined)
248
+ if (unplaced === undefined) throw new Error(`A ${draft.type} draft makes no message.`)
249
+ const call = unplaced.type === ToolResult ? where.openCalls?.[unplaced.callId] : undefined
250
+ const turn = task === undefined ? where.turn : where.taskTurns[task]
251
+ return { ...unplaced, ...call, session: where.session, context: task === undefined ? where.context : (where.tasks[task]?.context ?? where.context), ...(turn === undefined ? {} : { turn: turn.prompt.id }), ...(task === undefined ? {} : { task }) }
252
+ },
239
253
  place,
240
254
  wants: (mod, event) => [current, everySession].some((target) => (watches.get(mod)?.get(target) ?? []).includes(event)),
241
- endTurn() {
255
+ endTurn(ending = {}) {
242
256
  const session = currentSession()
243
- record(session, session.conversation.add({ id: `turn-end-${(ids += 1)}`, at: now(), type: 'system', name: 'turn_duration', isMeta: false, isSummary: false, content: [] }))
257
+ record(session, session.conversation.endTurn(now(), { reason: 'answer', ...ending }))
244
258
  },
245
259
  compact() {
246
260
  const session = currentSession()
@@ -262,16 +276,16 @@ export function fakeSessions({ project, given, push, callTool }: { readonly proj
262
276
  }
263
277
  }
264
278
 
265
- function rowOf(draft: MessageDraft, id: string, at: string): Row {
279
+ function rowOf(draft: MessageDraft, id: string, at: string, cwd: string): Row {
266
280
  const base = { id, at, isMeta: false, isSummary: false }
267
281
  const text = (value: string) => [{ type: 'text', text: value }]
268
282
  const origin = (from: Sender | undefined) => (from === undefined || from.type === Person ? { kind: 'human' } : from.type === Mod ? { kind: 'plugin', name: from.name, ...(from.as === Person ? { asUser: true } : {}) } : from.type === 'session' ? { kind: 'peer' } : from.type === Task ? { kind: 'task-notification' } : { kind: from.type === Schedule ? 'scheduled-trigger' : from.type, ...(from.name === undefined ? {} : { server: from.name }) })
269
283
  if (draft.type === Prompt) return { ...base, type: 'user', content: text(draft.text), origin: origin(draft.from) }
270
284
  if (draft.type === Note) return { ...base, type: 'user', isMeta: true, content: text(draft.text), origin: origin(draft.from ?? { type: Mod, name: 'test' }) }
271
285
  if (draft.type === Reply) return { ...base, type: 'assistant', content: text(draft.text) }
272
- if (draft.type === ToolCall) return { ...base, type: 'assistant', content: [{ type: 'tool_use', id: draft.callId ?? `toolu_${id}`, name: draft.name, input: draft.input ?? {} }], cwd: '/test/project' }
273
- if (draft.type === ToolResult) return { ...base, type: 'user', content: [{ type: 'tool_result', tool_use_id: draft.callId, content: draft.text, ...(draft.isError === true ? { is_error: true } : {}) }] }
286
+ if (draft.type === ToolCall) return { ...base, type: 'assistant', content: [{ type: 'tool_use', id: draft.callId ?? `toolu_${id}`, name: draft.name, input: draft.input ?? {} }], cwd }
287
+ if (draft.type === ToolResult) return { ...base, type: 'user', content: [{ type: 'tool_result', tool_use_id: draft.callId, content: draft.output, ...(draft.isError === true ? { is_error: true } : {}) }] }
274
288
  if (draft.type === Skill) return { ...base, type: 'user', isMeta: true, content: text(`${skillPrefix} /skills/${draft.name}\n\n${draft.text}`) }
275
- if (draft.type === Document) return { ...base, type: 'attachment', name: 'nested_memory', content: text(draft.text), path: draft.path }
289
+ if (draft.type === InstructionFile) return { ...base, type: 'attachment', name: 'nested_memory', content: text(draft.text), path: draft.path }
276
290
  return { ...base, type: 'system', name: 'informational', content: text(draft.text) }
277
291
  }