@cmodjs/core 1.6.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/commands.md +7 -7
- package/docs/dependencies.md +1 -1
- package/docs/index.md +12 -11
- package/docs/jobs.md +12 -16
- package/docs/mod.md +111 -36
- package/docs/permissions.md +14 -18
- package/docs/session.md +171 -82
- package/docs/state.md +5 -5
- package/docs/testing.md +57 -41
- package/docs/ui.md +9 -9
- package/jobs/permissions/decide-permission.d.ts.map +1 -1
- package/jobs/permissions/decide-permission.js +1 -1
- package/jobs/prompt.d.ts +1 -3
- package/jobs/prompt.d.ts.map +1 -1
- package/jobs/prompt.js +2 -21
- package/jobs/slash-command.d.ts +1 -1
- package/jobs/slash-command.d.ts.map +1 -1
- package/jobs/slash-command.js +1 -1
- package/jobs/status-line.d.ts +0 -1
- package/jobs/status-line.d.ts.map +1 -1
- package/jobs/status-line.js +3 -4
- package/mod.d.ts +53 -105
- package/mod.d.ts.map +1 -1
- package/mod.js +18 -80
- package/package.json +1 -1
- package/records.d.ts +1 -1
- package/records.js +1 -1
- package/register.d.ts.map +1 -1
- package/register.js +8 -5
- package/runtime/before.d.ts +11 -4
- package/runtime/before.d.ts.map +1 -1
- package/runtime/before.js +131 -67
- package/runtime/claude.d.ts +6 -1
- package/runtime/claude.d.ts.map +1 -1
- package/runtime/conversations.d.ts.map +1 -1
- package/runtime/conversations.js +82 -38
- package/runtime/deadline.d.ts +17 -0
- package/runtime/deadline.d.ts.map +1 -1
- package/runtime/deadline.js +34 -0
- package/runtime/dependencies.d.ts +2 -0
- package/runtime/dependencies.d.ts.map +1 -1
- package/runtime/dependencies.js +12 -0
- package/runtime/grants.d.ts.map +1 -1
- package/runtime/grants.js +13 -8
- package/runtime/hooks.d.ts +15 -11
- package/runtime/hooks.d.ts.map +1 -1
- package/runtime/hooks.js +8 -165
- package/runtime/lifecycle.d.ts +4 -2
- package/runtime/lifecycle.d.ts.map +1 -1
- package/runtime/lifecycle.js +96 -62
- package/runtime/permission-check.d.ts +15 -0
- package/runtime/permission-check.d.ts.map +1 -0
- package/runtime/permission-check.js +37 -0
- package/runtime/sessions.d.ts +35 -5
- package/runtime/sessions.d.ts.map +1 -1
- package/runtime/sessions.js +304 -82
- package/runtime/state.d.ts.map +1 -1
- package/runtime/state.js +14 -11
- package/runtime/tool-calls.d.ts +0 -1
- package/runtime/tool-calls.d.ts.map +1 -1
- package/runtime/tool-calls.js +0 -19
- package/session/context/turn/message/instruction-file.d.ts +9 -0
- package/session/context/turn/message/instruction-file.d.ts.map +1 -0
- package/session/context/turn/message/instruction-file.js +4 -0
- package/session/context/turn/message/message.d.ts +6 -4
- package/session/context/turn/message/message.d.ts.map +1 -1
- package/session/context/turn/message/message.js +12 -8
- package/session/context/turn/message/sender.d.ts +2 -1
- package/session/context/turn/message/sender.d.ts.map +1 -1
- package/session/context/turn/message/sender.js +4 -1
- package/session/context/turn/message/tool-call.d.ts +5 -0
- package/session/context/turn/message/tool-call.d.ts.map +1 -1
- package/session/context/turn/message/tool-result.d.ts +6 -1
- package/session/context/turn/message/tool-result.d.ts.map +1 -1
- package/session/context/turn/turn.d.ts +7 -0
- package/session/context/turn/turn.d.ts.map +1 -1
- package/session/conversation.d.ts +10 -3
- package/session/conversation.d.ts.map +1 -1
- package/session/conversation.js +14 -5
- package/session/goal/goal.d.ts +1 -1
- package/session/goal/goal.d.ts.map +1 -1
- package/session/goal/task.d.ts +2 -2
- package/session/goal/task.d.ts.map +1 -1
- package/session/session.d.ts +361 -26
- package/session/session.d.ts.map +1 -1
- package/session/session.js +40 -0
- package/src/jobs/permissions/decide-permission.ts +2 -1
- package/src/jobs/prompt.ts +3 -20
- package/src/jobs/slash-command.ts +2 -2
- package/src/jobs/status-line.ts +4 -7
- package/src/mod.ts +52 -147
- package/src/records.ts +1 -1
- package/src/register.ts +8 -5
- package/src/runtime/before.ts +140 -66
- package/src/runtime/claude.ts +5 -1
- package/src/runtime/conversations.ts +75 -37
- package/src/runtime/deadline.ts +38 -0
- package/src/runtime/dependencies.ts +12 -0
- package/src/runtime/grants.ts +11 -7
- package/src/runtime/hooks.ts +24 -163
- package/src/runtime/lifecycle.ts +110 -77
- package/src/runtime/permission-check.ts +47 -0
- package/src/runtime/sessions.ts +334 -91
- package/src/runtime/state.ts +15 -8
- package/src/runtime/tool-calls.ts +0 -19
- package/src/session/context/turn/message/instruction-file.ts +13 -0
- package/src/session/context/turn/message/message.ts +17 -11
- package/src/session/context/turn/message/sender.ts +5 -2
- package/src/session/context/turn/message/tool-call.ts +3 -0
- package/src/session/context/turn/message/tool-result.ts +6 -1
- package/src/session/context/turn/turn.ts +4 -0
- package/src/session/conversation.ts +21 -8
- package/src/session/goal/goal.ts +1 -1
- package/src/session/goal/task.ts +2 -2
- package/src/session/session.ts +227 -24
- package/src/testing/claude-code.ts +23 -0
- package/src/testing/fake-claude.ts +5 -1
- package/src/testing/fake-sessions.ts +38 -24
- package/src/testing.ts +88 -19
- package/testing/claude-code.d.ts +12 -0
- package/testing/claude-code.d.ts.map +1 -0
- package/testing/claude-code.js +10 -0
- package/testing/fake-claude.d.ts +4 -0
- package/testing/fake-claude.d.ts.map +1 -1
- package/testing/fake-claude.js +4 -1
- package/testing/fake-sessions.d.ts +8 -6
- package/testing/fake-sessions.d.ts.map +1 -1
- package/testing/fake-sessions.js +35 -20
- package/testing.d.ts +38 -9
- package/testing.d.ts.map +1 -1
- package/testing.js +58 -8
- package/docs/hooks.md +0 -259
- package/session/context/turn/message/document.d.ts +0 -7
- package/session/context/turn/message/document.d.ts.map +0 -1
- package/session/context/turn/message/document.js +0 -1
- package/src/session/context/turn/message/document.ts +0 -8
package/src/session/session.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
69
|
+
export type Surface = 'terminal' | 'desktop' | 'mobile' | 'vscode'
|
|
70
70
|
|
|
71
|
-
export
|
|
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
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
138
|
-
|
|
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,
|
|
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 {
|
|
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
|
|
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
|
|
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 (
|
|
148
|
-
if (task ===
|
|
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 +
|
|
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),
|
|
204
|
-
if (action === '
|
|
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['
|
|
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: '',
|
|
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.
|
|
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
|
|
273
|
-
if (draft.type === ToolResult) return { ...base, type: 'user', content: [{ type: 'tool_result', tool_use_id: draft.callId, content: draft.
|
|
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 ===
|
|
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
|
}
|