@cmodjs/core 1.6.0 → 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 (128) hide show
  1. package/docs/commands.md +4 -4
  2. package/docs/index.md +12 -11
  3. package/docs/jobs.md +12 -16
  4. package/docs/mod.md +111 -36
  5. package/docs/permissions.md +14 -18
  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 +1 -1
  26. package/records.js +1 -1
  27. package/register.d.ts.map +1 -1
  28. package/register.js +3 -1
  29. package/runtime/before.d.ts +5 -1
  30. package/runtime/before.d.ts.map +1 -1
  31. package/runtime/before.js +81 -39
  32. package/runtime/claude.d.ts +6 -1
  33. package/runtime/claude.d.ts.map +1 -1
  34. package/runtime/conversations.d.ts.map +1 -1
  35. package/runtime/conversations.js +45 -26
  36. package/runtime/deadline.d.ts +17 -0
  37. package/runtime/deadline.d.ts.map +1 -1
  38. package/runtime/deadline.js +34 -0
  39. package/runtime/dependencies.d.ts +2 -0
  40. package/runtime/dependencies.d.ts.map +1 -1
  41. package/runtime/dependencies.js +12 -0
  42. package/runtime/grants.d.ts.map +1 -1
  43. package/runtime/grants.js +13 -8
  44. package/runtime/hooks.d.ts +28 -11
  45. package/runtime/hooks.d.ts.map +1 -1
  46. package/runtime/hooks.js +20 -123
  47. package/runtime/lifecycle.d.ts +2 -1
  48. package/runtime/lifecycle.d.ts.map +1 -1
  49. package/runtime/lifecycle.js +66 -49
  50. package/runtime/sessions.d.ts +31 -5
  51. package/runtime/sessions.d.ts.map +1 -1
  52. package/runtime/sessions.js +342 -81
  53. package/runtime/tool-calls.d.ts +0 -1
  54. package/runtime/tool-calls.d.ts.map +1 -1
  55. package/runtime/tool-calls.js +0 -19
  56. package/session/context/turn/message/instruction-file.d.ts +9 -0
  57. package/session/context/turn/message/instruction-file.d.ts.map +1 -0
  58. package/session/context/turn/message/instruction-file.js +4 -0
  59. package/session/context/turn/message/message.d.ts +5 -4
  60. package/session/context/turn/message/message.d.ts.map +1 -1
  61. package/session/context/turn/message/message.js +7 -6
  62. package/session/context/turn/message/sender.d.ts +2 -1
  63. package/session/context/turn/message/sender.d.ts.map +1 -1
  64. package/session/context/turn/message/sender.js +4 -1
  65. package/session/context/turn/message/tool-call.d.ts +5 -0
  66. package/session/context/turn/message/tool-call.d.ts.map +1 -1
  67. package/session/context/turn/message/tool-result.d.ts +6 -1
  68. package/session/context/turn/message/tool-result.d.ts.map +1 -1
  69. package/session/context/turn/turn.d.ts +7 -0
  70. package/session/context/turn/turn.d.ts.map +1 -1
  71. package/session/conversation.d.ts +10 -3
  72. package/session/conversation.d.ts.map +1 -1
  73. package/session/conversation.js +14 -5
  74. package/session/goal/goal.d.ts +1 -1
  75. package/session/goal/goal.d.ts.map +1 -1
  76. package/session/goal/task.d.ts +2 -2
  77. package/session/goal/task.d.ts.map +1 -1
  78. package/session/session.d.ts +361 -26
  79. package/session/session.d.ts.map +1 -1
  80. package/session/session.js +40 -0
  81. package/src/jobs/permissions/decide-permission.ts +2 -1
  82. package/src/jobs/prompt.ts +3 -20
  83. package/src/jobs/slash-command.ts +2 -2
  84. package/src/jobs/status-line.ts +4 -7
  85. package/src/mod.ts +52 -147
  86. package/src/records.ts +1 -1
  87. package/src/register.ts +3 -1
  88. package/src/runtime/before.ts +90 -37
  89. package/src/runtime/claude.ts +5 -1
  90. package/src/runtime/conversations.ts +41 -25
  91. package/src/runtime/deadline.ts +38 -0
  92. package/src/runtime/dependencies.ts +12 -0
  93. package/src/runtime/grants.ts +11 -7
  94. package/src/runtime/hooks.ts +48 -116
  95. package/src/runtime/lifecycle.ts +65 -50
  96. package/src/runtime/sessions.ts +366 -90
  97. package/src/runtime/tool-calls.ts +0 -19
  98. package/src/session/context/turn/message/instruction-file.ts +13 -0
  99. package/src/session/context/turn/message/message.ts +11 -9
  100. package/src/session/context/turn/message/sender.ts +5 -2
  101. package/src/session/context/turn/message/tool-call.ts +3 -0
  102. package/src/session/context/turn/message/tool-result.ts +6 -1
  103. package/src/session/context/turn/turn.ts +4 -0
  104. package/src/session/conversation.ts +21 -8
  105. package/src/session/goal/goal.ts +1 -1
  106. package/src/session/goal/task.ts +2 -2
  107. package/src/session/session.ts +227 -24
  108. package/src/testing/claude-code.ts +23 -0
  109. package/src/testing/fake-claude.ts +5 -1
  110. package/src/testing/fake-sessions.ts +38 -24
  111. package/src/testing.ts +84 -16
  112. package/testing/claude-code.d.ts +12 -0
  113. package/testing/claude-code.d.ts.map +1 -0
  114. package/testing/claude-code.js +10 -0
  115. package/testing/fake-claude.d.ts +4 -0
  116. package/testing/fake-claude.d.ts.map +1 -1
  117. package/testing/fake-claude.js +4 -1
  118. package/testing/fake-sessions.d.ts +8 -6
  119. package/testing/fake-sessions.d.ts.map +1 -1
  120. package/testing/fake-sessions.js +35 -20
  121. package/testing.d.ts +38 -9
  122. package/testing.d.ts.map +1 -1
  123. package/testing.js +54 -5
  124. package/docs/hooks.md +0 -259
  125. package/session/context/turn/message/document.d.ts +0 -7
  126. package/session/context/turn/message/document.d.ts.map +0 -1
  127. package/session/context/turn/message/document.js +0 -1
  128. package/src/session/context/turn/message/document.ts +0 -8
@@ -2,21 +2,18 @@ import type { Job, Mod } from '../mod.js'
2
2
  import type { Deadline } from '../runtime/deadline.js'
3
3
  import { engineOf } from '../runtime/engine.js'
4
4
  import { usageOf } from '../runtime/sessions.js'
5
+ import { onStateChange } from '../runtime/state.js'
5
6
  import type { Usage } from '../session/session.js'
6
7
  import { messageOf } from '../utils/text.js'
7
8
  import { withinDeadline } from './context.js'
8
9
 
9
10
  const deadline: Deadline = { ms: 2000, job: 'statusLine' }
10
11
 
11
- export function statusLine<State extends object = Record<never, never>>(options: {
12
- readonly text: (usage: Usage, mod: Mod<State>) => string | undefined | Promise<string | undefined>
13
- readonly interval?: number
14
- }): Job<void, State> {
15
- const { text, interval = 10000 } = options
12
+ export function statusLine<State extends object = Record<never, never>>(options: { readonly text: (usage: Usage, mod: Mod<State>) => string | undefined | Promise<string | undefined> }): Job<void, State> {
13
+ const { text } = options
16
14
  return (job) => {
17
15
  const { mod, announce, reserveName } = job
18
16
  const { claude, router } = engineOf(mod)
19
- if (!(interval >= 1)) throw new Error(`${mod.name}: the status line interval is ${interval}. Give it in milliseconds, 1 or more.`)
20
17
  reserveName('statusLine', '', `${mod.name}: a status line is already added. A mod has one status line: join the texts in one statusLine.`)
21
18
 
22
19
  let shown: string | undefined
@@ -41,7 +38,7 @@ export function statusLine<State extends object = Record<never, never>>(options:
41
38
  refresh()
42
39
  return next(e)
43
40
  })
44
- claude.clock.every(interval, refresh)
41
+ onStateChange(mod.state, refresh)
45
42
  refresh()
46
43
  }
47
44
  }
package/src/mod.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import type {
2
2
  AskOptions,
3
- ClassicHookInputs,
4
3
  CmodDependencies,
5
- ConfigRow,
4
+ ConfigRow as ClaudeConfigRow,
6
5
  FsStatOptions,
7
6
  HookStream,
8
7
  HttpInit,
@@ -11,7 +10,6 @@ import type {
11
10
  ModelCompleteRequest,
12
11
  ModelCompleteResult,
13
12
  PaneOpenArgs,
14
- PermissionRequestDecision,
15
13
  ProcessRunInit,
16
14
  ProcessRunResult,
17
15
  ProcessSpawnChunk,
@@ -26,7 +24,7 @@ import type { Step } from './installer.js'
26
24
  import { checkOptions, type Option, type Options, type OptionValues } from './options.js'
27
25
  import { bound } from './runtime/bound.js'
28
26
  import { Mod as modSender } from './session/context/turn/message/sender.js'
29
- import type { Sessions } from './session/session.js'
27
+ import { sessionEvents, type BeforeMethod, type CommandRun, type CommandRunAnswer, type OnMethod, type OnMethods, type Sessions } from './session/session.js'
30
28
  import type { Pane } from './ui/define-pane.js'
31
29
  import type { Place, PlaceProps } from './ui/places.js'
32
30
  import type { Disposable } from './utils/disposable.js'
@@ -67,132 +65,6 @@ export type PaneHandle = {
67
65
  readonly isFocused: boolean
68
66
  }
69
67
 
70
- export const SessionStart = 'SessionStart'
71
- export const SessionEnd = 'SessionEnd'
72
- export const UserPromptSubmit = 'UserPromptSubmit'
73
- export const InstructionsLoaded = 'InstructionsLoaded'
74
- export const PreToolUse = 'PreToolUse'
75
- export const PermissionRequest = 'PermissionRequest'
76
- export const PermissionDenied = 'PermissionDenied'
77
- export const PostToolUse = 'PostToolUse'
78
- export const PostToolUseFailure = 'PostToolUseFailure'
79
- export const PostToolBatch = 'PostToolBatch'
80
- export const SubagentStart = 'SubagentStart'
81
- export const SubagentStop = 'SubagentStop'
82
- export const Notification = 'Notification'
83
- export const PreCompact = 'PreCompact'
84
- export const Stop = 'Stop'
85
- export const StopFailure = 'StopFailure'
86
- export const CwdChanged = 'CwdChanged'
87
- export const FileChanged = 'FileChanged'
88
- export const UserPromptExpansion = 'UserPromptExpansion'
89
- export const Setup = 'Setup'
90
- export const ConfigChange = 'ConfigChange'
91
- export const DirectoryAdded = 'DirectoryAdded'
92
- export const PreModelSwitch = 'PreModelSwitch'
93
- export const PostModelSwitch = 'PostModelSwitch'
94
- export const PostCompact = 'PostCompact'
95
- export const MessageDisplay = 'MessageDisplay'
96
- export const TodoCreated = 'TaskCreated'
97
- export const TodoCompleted = 'TaskCompleted'
98
- export const TeammateIdle = 'TeammateIdle'
99
- export const Elicitation = 'Elicitation'
100
- export const ElicitationResult = 'ElicitationResult'
101
- export const WorktreeCreate = 'WorktreeCreate'
102
- export const WorktreeRemove = 'WorktreeRemove'
103
-
104
- export const hooks = [
105
- Setup,
106
- SessionStart,
107
- SessionEnd,
108
- UserPromptSubmit,
109
- UserPromptExpansion,
110
- InstructionsLoaded,
111
- ConfigChange,
112
- DirectoryAdded,
113
- PreModelSwitch,
114
- PostModelSwitch,
115
- PreToolUse,
116
- PermissionRequest,
117
- PermissionDenied,
118
- PostToolUse,
119
- PostToolUseFailure,
120
- PostToolBatch,
121
- MessageDisplay,
122
- SubagentStart,
123
- SubagentStop,
124
- TodoCreated,
125
- TodoCompleted,
126
- TeammateIdle,
127
- Elicitation,
128
- ElicitationResult,
129
- Notification,
130
- PreCompact,
131
- PostCompact,
132
- Stop,
133
- StopFailure,
134
- CwdChanged,
135
- FileChanged,
136
- WorktreeCreate,
137
- WorktreeRemove,
138
- ] as const
139
-
140
- export type Hook = (typeof hooks)[number]
141
-
142
- export type HookAnswer = {
143
- continue?: boolean
144
- stopReason?: string
145
- suppressOutput?: boolean
146
- systemMessage?: string
147
- decision?: 'block'
148
- reason?: string
149
- hookSpecificOutput?: {
150
- hookEventName?: Hook
151
- additionalContext?: string
152
- permissionDecision?: 'allow' | 'deny' | 'ask'
153
- permissionDecisionReason?: string
154
- updatedInput?: Record<string, unknown>
155
- sessionTitle?: string
156
- suppressOriginalPrompt?: boolean
157
- initialUserMessage?: string
158
- watchPaths?: string[]
159
- reloadSkills?: boolean
160
- decision?: PermissionRequestDecision
161
- updatedToolOutput?: unknown
162
- updatedMCPToolOutput?: unknown
163
- retry?: boolean
164
- displayContent?: string
165
- worktreePath?: string
166
- }
167
- }
168
-
169
- type CallFiles = { read: string[]; changed: string[] }
170
-
171
- type HookInputs = Omit<ClassicHookInputs, 'PreToolUse' | 'PostToolUse' | 'PostToolUseFailure' | 'CwdChanged'> & {
172
- PreToolUse: {
173
- session_id: string
174
- cwd: string
175
- hook_event_name: 'PreToolUse'
176
- tool_name: string
177
- tool_input: Record<string, unknown>
178
- tool_use_id: string
179
- agent_id?: string
180
- agent_type?: string
181
- files: CallFiles
182
- }
183
- PostToolUse: ClassicHookInputs['PostToolUse'] & { files: CallFiles }
184
- PostToolUseFailure: ClassicHookInputs['PostToolUseFailure'] & { files: CallFiles }
185
- CwdChanged: Omit<ClassicHookInputs['CwdChanged'], 'transcript_path'>
186
- }
187
-
188
- export type HookInput<H extends Hook> = HookInputs[H]
189
-
190
- export type HookListener<H extends Hook> = (input: HookInput<H>) => HookAnswer | void | Promise<HookAnswer | void>
191
-
192
- export type HookOptions = {
193
- readonly timeoutMs?: number
194
- }
195
-
196
68
  export type Metadata = Readonly<Record<string, string>>
197
69
 
198
70
  export type FileMatch = {
@@ -215,6 +87,8 @@ export type Stat = Omit<Entry, 'name'> & {
215
87
  readonly realPath?: string
216
88
  }
217
89
 
90
+ export type FileChange = { readonly path: string; readonly type: 'change' | 'add' | 'unlink' }
91
+
218
92
  export type Allowed = {
219
93
  allows(value: string): boolean
220
94
  }
@@ -237,15 +111,36 @@ export type SettingsFile = {
237
111
  update(values: Readonly<Record<string, unknown>>): Promise<void>
238
112
  }
239
113
 
240
- export type Setting = Pick<ConfigRow, 'key' | 'label' | 'kind' | 'value' | 'isLocked'> & {
114
+ export type SettingsFileChange = { readonly source: 'user' | 'project' | 'local' | 'policy' | 'skills'; readonly path?: string }
115
+
116
+ export type SettingsFileChangeListener = (change: SettingsFileChange) => { readonly refuse: string } | void | Promise<{ readonly refuse: string } | void>
117
+
118
+ export type ConfigRow = Pick<ClaudeConfigRow, 'key' | 'label' | 'kind' | 'value' | 'isLocked'> & {
241
119
  readonly description?: string
242
120
  readonly options?: readonly string[]
243
121
  readonly plugin?: string
244
122
  }
245
123
 
124
+ export type CommandInfo = { readonly name: string; readonly description: string; readonly source: 'builtin' | 'plugin' | 'user' | 'mcp'; readonly plugin?: string }
125
+
126
+ export type CommandRunResult = { readonly text?: string; readonly note?: string }
127
+
246
128
  export type Imports = {
247
129
  readonly sessions: Sessions
248
- readonly settings: { list(): Promise<Setting[]>; readonly user: SettingsFile; readonly project: SettingsFile; readonly local: SettingsFile }
130
+ readonly commands: {
131
+ list(): Promise<CommandInfo[]>
132
+ run(name: string, args?: string): Promise<CommandRunResult>
133
+ readonly beforeRun: BeforeMethod<CommandRun, CommandRunAnswer>
134
+ readonly onRun: OnMethod<CommandRun>
135
+ }
136
+ readonly config: { list(): Promise<ConfigRow[]> }
137
+ readonly settings: {
138
+ readonly user: SettingsFile
139
+ readonly project: SettingsFile
140
+ readonly local: SettingsFile
141
+ beforeFileChange(listener: SettingsFileChangeListener): Disposable
142
+ beforeFileChange(matcher: Matcher<SettingsFileChange>, listener: SettingsFileChangeListener): Disposable
143
+ }
249
144
  readonly model: { complete(request: ModelCompleteRequest, options?: ModelCompleteOptions): Promise<ModelCompleteResult> }
250
145
  readonly fs: {
251
146
  read(path: string): Promise<string>
@@ -254,6 +149,7 @@ export type Imports = {
254
149
  find(glob: string | readonly string[]): Promise<readonly FileMatch[]>
255
150
  list(path?: string): Promise<Entry[]>
256
151
  stat(path: string, options?: FsStatOptions): Promise<Stat>
152
+ onChange(matcher: { readonly path: string | readonly string[] }, listener: (change: FileChange) => unknown): Disposable
257
153
  }
258
154
  readonly metadata: {
259
155
  read(path: string): Promise<Metadata>
@@ -271,16 +167,26 @@ export const sessions: Imports['sessions'] = {
271
167
  return bound().sessions.current
272
168
  },
273
169
  list: (options) => bound().sessions.list(options),
274
- search: (text, options) => bound().sessions.search(text, options),
275
- on: ((...args: Parameters<Imports['sessions']['on']>) => bound().sessions.on(...args)) as Imports['sessions']['on'],
276
- before: ((...args: Parameters<Imports['sessions']['before']>) => bound().sessions.before(...args)) as Imports['sessions']['before'],
170
+ search: (text, matcher, options) => bound().sessions.search(text, matcher, options),
171
+ ...(Object.fromEntries(Object.keys(sessionEvents).map((named) => [`on${named}`, (...args: unknown[]) => (bound().sessions[`on${named}` as keyof OnMethods] as (...each: unknown[]) => Disposable)(...args)])) as OnMethods),
277
172
  }
278
173
 
279
174
  export const settings: Imports['settings'] = {
280
- list: () => bound().settings.list(),
281
175
  user: { read: () => bound().settings.user.read(), update: (values) => bound().settings.user.update(values) },
282
176
  project: { read: () => bound().settings.project.read(), update: (values) => bound().settings.project.update(values) },
283
177
  local: { read: () => bound().settings.local.read(), update: (values) => bound().settings.local.update(values) },
178
+ beforeFileChange: ((matcherOrListener: never, listener?: never) => bound().settings.beforeFileChange(matcherOrListener, listener as never)) as Imports['settings']['beforeFileChange'],
179
+ }
180
+
181
+ export const commands: Imports['commands'] = {
182
+ list: () => bound().commands.list(),
183
+ run: (name, args) => bound().commands.run(name, args),
184
+ beforeRun: ((...args: never[]) => (bound().commands.beforeRun as (...each: never[]) => Disposable)(...args)) as Imports['commands']['beforeRun'],
185
+ onRun: ((...args: never[]) => (bound().commands.onRun as (...each: never[]) => Disposable)(...args)) as Imports['commands']['onRun'],
186
+ }
187
+
188
+ export const config: Imports['config'] = {
189
+ list: () => bound().config.list(),
284
190
  }
285
191
 
286
192
  export const model: Imports['model'] = {
@@ -294,6 +200,7 @@ export const fs: Imports['fs'] = {
294
200
  find: (glob) => bound().fs.find(glob),
295
201
  list: (path) => bound().fs.list(path),
296
202
  stat: (path, options) => bound().fs.stat(path, options),
203
+ onChange: (matcher, listener) => bound().fs.onChange(matcher, listener),
297
204
  }
298
205
 
299
206
  export const metadata: Imports['metadata'] = {
@@ -317,12 +224,10 @@ export type Mod<State extends object = Record<never, never>, Declared extends Op
317
224
  readonly state: Readonly<State>
318
225
  readonly options: OptionValues<Declared>
319
226
  readonly permissions: Permissions
320
- on<H extends Hook>(hook: H, listener: HookListener<H>, options?: HookOptions): Disposable
321
- on<H extends Hook>(hook: H, matcher: Matcher<HookInput<H>>, listener: HookListener<H>, options?: HookOptions): Disposable
322
227
  every(ms: number, listener: () => unknown): Disposable
323
228
  after(ms: number, listener: () => unknown): Disposable
324
229
  use<Handle>(job: Job<Handle, State>): Handle
325
- log(text: string): void
230
+ log(text: string, options?: { readonly to?: 'debug' | 'transcript' }): void
326
231
  readonly ui: {
327
232
  pane(pane: Pane<State>): PaneHandle
328
233
  render<P extends Place>(place: P, Component: (props: PlaceProps<P>) => RenderElement): Disposable
@@ -378,21 +283,21 @@ export function defineMod<State extends StateGroups = Record<never, never>, Name
378
283
  export type { Matcher } from './utils/matcher.js'
379
284
  export type { Place, PlaceProps } from './ui/places.js'
380
285
  export { AbovePrompt, AskUserQuestion, CommandOutput, PromptHint, SessionMode, Spinner, ToolGroup, ToolProgress, TurnDuration } from './ui/places.js'
381
- export { Session, UsageChange, type BeforeAnswer, type BeforeEvent, type BeforeListener, type Client, type Listener, type RecordPart, type Refusal, type SearchOptions, type SessionEvent, type SessionPayload, type SessionScope, type SessionSend, type Sessions, type TaskStartArgs, type ToolCallArgs, type Usage } from './session/session.js'
382
- export { ContextEnd, ContextStart, type Context } from './session/context/context.js'
383
- export { TurnEnd, TurnStart, type Turn } from './session/context/turn/turn.js'
384
- export { Message, type MessageType } from './session/context/turn/message/message.js'
286
+ export { Session, type AnswerOptions, type BeforeMethods, type Surface, type Listener, type ListenerTime, type OnMethods, type ReadOptions, type RecordPart, type Refusal, type SearchOptions, type SessionScope, type SessionSend, type Sessions, type CurrentSession, type Placement, type ProjectSetup, type SessionStartAsked, type SessionStarted, type SessionEnded, type ModelChange, type CwdChange, type DirectoryAdd, type Worktree, type Notification, type Elicitation, type ElicitationResponse, type McpServer, type PermissionUpdate, type PermissionCheck, type PermissionRequest, type PermissionDenial, type BatchedCall, type ToolBatch, type Todo, type ReplyDisplay, type TaskOwner, type TaskStartArgs, type ToolCallArgs, type Usage, type CommandRun, type CommandRunAnswer } from './session/session.js'
287
+ export type { Context } from './session/context/context.js'
288
+ export type { Turn } from './session/context/turn/turn.js'
289
+ export type { Message, MessageType } from './session/context/turn/message/message.js'
385
290
  export { Prompt } from './session/context/turn/message/prompt.js'
386
291
  export { Note } from './session/context/turn/message/note.js'
387
292
  export { Reply } from './session/context/turn/message/reply.js'
388
293
  export { ToolCall } from './session/context/turn/message/tool-call.js'
389
294
  export { ToolResult } from './session/context/turn/message/tool-result.js'
390
295
  export { Skill } from './session/context/turn/message/skill.js'
391
- export { Document } from './session/context/turn/message/document.js'
296
+ export { InstructionFile } from './session/context/turn/message/instruction-file.js'
392
297
  export { Notice } from './session/context/turn/message/notice.js'
393
- export { Channel, Person, Schedule, Sdk, type Sender, type SenderType } from './session/context/turn/message/sender.js'
394
- export { GoalChange, type Goal } from './session/goal/goal.js'
395
- export { Task, TaskEnd, TaskStart } from './session/goal/task.js'
298
+ export { Channel, Engine, Person, Schedule, Sdk, type Sender, type SenderType } from './session/context/turn/message/sender.js'
299
+ export type { Goal } from './session/goal/goal.js'
300
+ export { Task } from './session/goal/task.js'
396
301
  export { longestMs } from './runtime/deadline.js'
397
302
  export { notInstalled } from './runtime/dependencies.js'
398
303
  export { messageOf } from './utils/text.js'
package/src/records.ts CHANGED
@@ -259,7 +259,7 @@ function readKeys(keys: unknown): Steps {
259
259
  return { keys: commands }
260
260
  }
261
261
 
262
- export const coreVersion = '1.6.0'
262
+ export const coreVersion = '2.0.0'
263
263
 
264
264
  export function oldestCmodFor(steps: Steps): string {
265
265
  if (steps.permissions?.some((item) => item.startsWith('config:')) === true) return '1.6.0'
package/src/register.ts CHANGED
@@ -70,8 +70,9 @@ async function startMod($: EngineInterface, eventInput: Frozen<Args<'session.sta
70
70
  send: (args) => $.session.send(args),
71
71
  },
72
72
  prompt: { submit: (args) => $.prompt.submit(args) },
73
+ turn: { abort: (args) => $.turn.abort(args) },
73
74
  model: { complete: (request, options) => $.model.complete(request, options) },
74
- command: { register: (command) => $.command.register(command) },
75
+ command: { register: (command) => $.command.register(command), list: () => $.command.list(), run: (args) => $.command.run(args) },
75
76
  tool: { register: (tool) => $.tool.register(tool), call: (input: ToolCallArgs) => $.tool.call(input) },
76
77
  agent: {
77
78
  list: () => $.agent.list(),
@@ -149,6 +150,7 @@ export function registerMod<State extends object, Declared extends Options>(addH
149
150
  addHook('session.receive', routeToMod)
150
151
  addHook('agent.spawn', routeToMod)
151
152
  addHook('prompt.attachment', routeToMod)
153
+ addHook('turn.start', routeToMod)
152
154
  }
153
155
 
154
156
  export function registerPermissionCheck(addHook: On): void {
@@ -1,8 +1,8 @@
1
1
  import type { Args, Frozen } from 'claude-code'
2
2
  import type { Disposable } from '../mod.js'
3
3
  import { ContextEnd } from '../session/context/context.js'
4
- import { Document } from '../session/context/turn/message/document.js'
5
- import { Message, messagesOf, rowOfAppend, withTexts, type Unplaced } from '../session/context/turn/message/message.js'
4
+ import { InstructionFile, isRulePath } from '../session/context/turn/message/instruction-file.js'
5
+ import { Message, messagesOf, noFiles, rowOfAppend, withTexts, type Unplaced } from '../session/context/turn/message/message.js'
6
6
  import { Note } from '../session/context/turn/message/note.js'
7
7
  import { Notice } from '../session/context/turn/message/notice.js'
8
8
  import { Prompt } from '../session/context/turn/message/prompt.js'
@@ -13,22 +13,39 @@ import { ToolCall } from '../session/context/turn/message/tool-call.js'
13
13
  import { ToolResult } from '../session/context/turn/message/tool-result.js'
14
14
  import { TurnEnd } from '../session/context/turn/turn.js'
15
15
  import type { ConversationState, SessionChange, TaskData } from '../session/conversation.js'
16
- import { TaskEnd, TaskStart } from '../session/goal/task.js'
16
+ import { Task, TaskStart } from '../session/goal/task.js'
17
17
  import type { BeforeEvent } from '../session/session.js'
18
18
  import type { Claude } from './claude.js'
19
19
  import { cmodPluginName } from './dependencies.js'
20
20
  import type { Router } from './router.js'
21
21
  import { toolInputOf } from './tool-calls.js'
22
22
 
23
- export type Place = Pick<ConversationState, 'session' | 'context' | 'contextOpenedAt' | 'turn' | 'tasks' | 'taskTurns'>
23
+ export type Place = Pick<ConversationState, 'session' | 'context' | 'contextOpenedAt' | 'turn' | 'tasks' | 'taskTurns' | 'openCalls'>
24
24
 
25
25
  export type Decide = (event: BeforeEvent, change: SessionChange) => Promise<Partial<Answers> | undefined>
26
26
 
27
- export type Answers = { readonly text: string; readonly refuse: string; readonly input: Readonly<Record<string, unknown>>; readonly result: string; readonly owner: { readonly model: string } }
27
+ export type Answers = {
28
+ readonly text: string
29
+ readonly output: unknown
30
+ readonly isError: true
31
+ readonly note: string
32
+ readonly title: string
33
+ readonly refuse: string
34
+ readonly input: Readonly<Record<string, unknown>>
35
+ readonly result: string
36
+ readonly owner: { readonly model: string }
37
+ }
28
38
 
29
39
  export type BeforeHooks = { readonly place: () => Promise<Place>; readonly wants: (event: BeforeEvent) => Promise<boolean>; readonly decide: Decide }
30
40
 
31
- const appended: readonly BeforeEvent[] = [Note, Reply, ToolResult, Notice]
41
+ const appended: readonly BeforeEvent[] = [Note, Reply, Notice]
42
+
43
+ type ClassicFields = Readonly<Record<string, unknown>>
44
+
45
+ const withNote = (below: unknown, note: string | undefined): ClassicFields => {
46
+ const fields = (below ?? {}) as ClassicFields
47
+ return note === undefined ? fields : { ...fields, additionalContext: [...((fields['additionalContext'] as readonly string[] | undefined) ?? []), note] }
48
+ }
32
49
 
33
50
  const relayTag = /^(?:<cross-session-message[^>]*>\s*)?<cmod-relay>([\s\S]*)<\/cmod-relay>(?:\s*<\/cross-session-message>[\s\S]*)?$/
34
51
 
@@ -44,28 +61,50 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
44
61
  const message = (where: Place, unplaced: Unplaced, task: string | undefined, isInTurn = true): SessionChange => {
45
62
  const turn = isInTurn ? (task === undefined ? where.turn : where.taskTurns[task]) : undefined
46
63
  const context = task === undefined ? where.context : (where.tasks[task]?.context ?? where.context)
47
- return { type: Message, message: { ...unplaced, session: where.session, context, ...(turn === undefined ? {} : { turn: turn.prompt.id }), ...(task === undefined ? {} : { task }) } as Message }
64
+ const call = unplaced.type === ToolResult ? where.openCalls?.[unplaced.callId] : undefined
65
+ return { type: Message, message: { ...unplaced, ...call, session: where.session, context, ...(turn === undefined ? {} : { turn: turn.prompt.id }), ...(task === undefined ? {} : { task }) } as Message }
66
+ }
67
+ const titles = new Map<string, string>()
68
+ const spawned = new Map<string, string>()
69
+ const toolResultHook = (isFailure: boolean) => async (e: Readonly<Record<string, unknown>>, next: (e: never) => Promise<unknown>) => {
70
+ if (!(await wants(ToolResult))) return next(e as never)
71
+ const where = await place()
72
+ const name = String(e['tool_name'])
73
+ const callId = String(e['tool_use_id'])
74
+ const input = (typeof e['tool_input'] === 'object' && e['tool_input'] !== null ? e['tool_input'] : {}) as Readonly<Record<string, unknown>>
75
+ const task = typeof e['agent_id'] === 'string' ? e['agent_id'] : undefined
76
+ const result = { id: callId, at: now(), type: ToolResult, callId, name, input, cwd: String(e['cwd']), files: noFiles, output: isFailure ? e['error'] : e['tool_response'], isError: isFailure } as const
77
+ const answer = await asked(ToolResult, message(where, result, task))
78
+ const below = await next(e as never)
79
+ const rewritten = answer.output === undefined || isFailure ? {} : name.startsWith('mcp__') ? { updatedMCPToolOutput: answer.output } : { updatedToolOutput: answer.output }
80
+ const failed = answer.isError === true && !isFailure ? { block: answer.note ?? `${name} failed.` } : {}
81
+ return { ...withNote(below, answer.isError === true ? undefined : answer.note), ...rewritten, ...failed }
48
82
  }
49
83
  const added: Disposable[] = [
50
84
  router.add('prompt.submit', async (e, next) => {
51
- if (!(await wants(Prompt)) || e.text.startsWith('/')) return next(e)
85
+ if (!(await wants(Prompt))) return next(e)
52
86
  const where = await place()
53
87
  const answer = await asked(Prompt, message(where, { id: '', at: now(), type: Prompt, text: e.text, from: senderOf(e.origin, false) }, undefined, false))
54
88
  if (answer.refuse !== undefined) return { drop: answer.refuse }
55
- return next(answer.text === undefined ? e : { ...e, text: answer.text })
89
+ const text = answer.text ?? e.text
90
+ if (answer.title !== undefined) titles.set(text, answer.title)
91
+ const context = answer.note === undefined ? e.context : [...(e.context ?? []), answer.note]
92
+ const ran = await next({ ...e, text, ...(context === undefined ? {} : { context }) })
93
+ const passed = titles.get(text)
94
+ if (passed !== undefined && ('drop' in ran || ran.text !== text)) {
95
+ titles.delete(text)
96
+ if (!('drop' in ran) && typeof ran.text === 'string') titles.set(ran.text, passed)
97
+ }
98
+ return ran
56
99
  }),
57
- router.add('command.run', async (e, next) => {
58
- if (!(await wants(Prompt))) return next(e)
59
- const typed = e.args === '' ? `/${e.command}` : `/${e.command} ${e.args}`
60
- const where = await place()
61
- const answer = await asked(Prompt, message(where, { id: '', at: now(), type: Prompt, text: typed, from: senderOf(e.origin, false) }, undefined, false))
62
- if (answer.refuse !== undefined) return { text: answer.refuse }
63
- if (answer.text === undefined || answer.text === typed) return next(e)
64
- const same = new RegExp(`^/${e.command}(?:\\s+|$)`).exec(answer.text)
65
- if (same !== null) return next({ ...e, args: answer.text.slice(same[0].length) })
66
- void claude.prompt.submit({ text: answer.text })
67
- return {}
100
+ router.add('classic.UserPromptSubmit', async (e, next) => {
101
+ const named = titles.get(e.prompt)
102
+ titles.delete(e.prompt)
103
+ const below = await next(e)
104
+ return named === undefined ? below : { ...below, sessionTitle: named }
68
105
  }),
106
+ router.add('classic.PostToolUse', toolResultHook(false) as never),
107
+ router.add('classic.PostToolUseFailure', toolResultHook(true) as never),
69
108
  router.add('session.receive', async (e, next) => {
70
109
  if (!(await wants(Prompt)) || relayOf(e) !== undefined) return next(e)
71
110
  const where = await place()
@@ -80,10 +119,10 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
80
119
  return answer.text === undefined ? next(e) : { text: answer.text }
81
120
  }),
82
121
  router.add('prompt.attachment', async (e, next) => {
83
- if (!(await wants(Document)) || e.type !== 'nested_memory') return next(e)
122
+ if (!(await wants(InstructionFile)) || e.type !== 'nested_memory') return next(e)
84
123
  const where = await place()
85
124
  const path = /^Contents of (.+?) \(/.exec(e.text)?.[1] ?? ''
86
- const answer = await asked(Document, message(where, { id: '', at: now(), type: Document, path, text: e.text }, e.agentId))
125
+ const answer = await asked(InstructionFile, message(where, { id: '', at: now(), type: InstructionFile, path, text: e.text, isRule: isRulePath(path) }, e.agentId))
87
126
  if (answer.refuse !== undefined) return { text: null }
88
127
  return answer.text === undefined ? next(e) : { text: answer.text }
89
128
  }),
@@ -106,7 +145,7 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
106
145
  if (!(await wants(ToolCall))) return next(e)
107
146
  const where = await place()
108
147
  const input = toolInputOf(e)
109
- const answer = await asked(ToolCall, message(where, { id: e.tool_use_id, at: now(), type: ToolCall, callId: e.tool_use_id, name: e.tool, input, cwd: await claude.session.cwd() }, e.agentId))
148
+ const answer = await asked(ToolCall, message(where, { id: e.tool_use_id, at: now(), type: ToolCall, callId: e.tool_use_id, name: e.tool, input, cwd: await claude.session.cwd(), files: noFiles }, e.agentId))
110
149
  if (answer.refuse !== undefined) return { deny: answer.refuse }
111
150
  if (answer.result !== undefined) return { result: answer.result }
112
151
  if (answer.input === undefined) return next(e)
@@ -114,23 +153,36 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
114
153
  return next({ ...reserved, ...answer.input } as Frozen<Args<'tool.call'>>)
115
154
  }),
116
155
  router.add('agent.spawn', async (e, next) => {
156
+ spawned.set(e.tool_use_id, e.prompt)
117
157
  if (!(await wants(TaskStart))) return next(e)
118
158
  const where = await place()
119
159
  const task: TaskData = { id: '', title: e.description, agentType: e.subagentType, model: e.model ?? e.parentModel, openedBy: e.tool_use_id, openedAt: now(), ...(e.parentAgentId === undefined ? {} : { parent: e.parentAgentId }) }
120
160
  const answer = await asked(TaskStart, { type: TaskStart, task: { ...task, context: where.context } })
121
- if (answer.refuse !== undefined) return { deny: answer.refuse }
161
+ if (answer.refuse !== undefined) {
162
+ spawned.delete(e.tool_use_id)
163
+ return { deny: answer.refuse }
164
+ }
122
165
  return next(answer.owner === undefined ? e : { ...e, model: answer.owner.model })
123
166
  }),
124
- router.add('classic.SubagentStop', async (e, next) => {
125
- if (!(await wants(TaskEnd)) && !(await wants(TurnEnd))) return next(e)
167
+ router.add('classic.SubagentStart', async (e, next) => {
126
168
  const where = await place()
127
- const task = where.tasks[e.agent_id]
128
- if (task === undefined) return next(e)
129
- const turn = where.taskTurns[e.agent_id]
130
- const ending = (await wants(TaskEnd)) ? await asked(TaskEnd, { type: TaskEnd, task: { ...task, closedAt: now() } }) : {}
131
- const refuse = ending.refuse ?? ((await wants(TurnEnd)) && turn !== undefined ? (await asked(TurnEnd, { type: TurnEnd, turn: { ...turn, closedAt: now() } })).refuse : undefined)
169
+ const openedBy = where.tasks[e.agent_id]?.openedBy
170
+ const prompt = openedBy === undefined ? undefined : spawned.get(openedBy)
171
+ if (openedBy !== undefined) spawned.delete(openedBy)
172
+ if (!(await wants(Prompt))) return next(e)
173
+ const open = where.taskTurns[e.agent_id]?.prompt
174
+ const text = open?.text ?? prompt
175
+ if (text === undefined) return next(e)
176
+ const answer = await asked(Prompt, message(where, { id: open?.id ?? '', at: now(), type: Prompt, text, from: open?.from ?? { type: Task } }, e.agent_id, false))
177
+ return withNote(await next(e), answer.note)
178
+ }),
179
+ router.add('classic.SubagentStop', async (e, next) => {
180
+ if (!(await wants(TurnEnd))) return next(e)
181
+ const turn = (await place()).taskTurns[e.agent_id]
182
+ if (turn === undefined) return next(e)
183
+ const answer = await asked(TurnEnd, { type: TurnEnd, turn: { ...turn, closedAt: now() } })
132
184
  const below = await next(e)
133
- return refuse === undefined ? below : { ...below, block: refuse }
185
+ return answer.refuse === undefined ? below : { ...below, block: answer.refuse }
134
186
  }),
135
187
  router.add('classic.Stop', async (e, next) => {
136
188
  if (!(await wants(TurnEnd))) return next(e)
@@ -154,6 +206,7 @@ export function installBefore({ claude, router, hooks }: { readonly claude: Clau
154
206
  }
155
207
 
156
208
  export function withAnswer(change: SessionChange, answer: Partial<Answers>): SessionChange {
209
+ if (change.type === Message && answer.output !== undefined && change.message.type === ToolResult) return { ...change, message: { ...change.message, output: answer.output } }
157
210
  if (change.type === Message && answer.text !== undefined) return { ...change, message: { ...change.message, text: answer.text } as Message }
158
211
  if (change.type === Message && answer.input !== undefined && change.message.type === ToolCall) return { ...change, message: { ...change.message, input: answer.input } }
159
212
  if (change.type === TaskStart && answer.owner !== undefined) return { ...change, task: { ...change.task, model: answer.owner.model } }
@@ -167,7 +220,8 @@ export async function foldAnswers(event: BeforeEvent, change: SessionChange, ask
167
220
  try {
168
221
  const answer = answerOf(event, await ask(latest))
169
222
  if (answer === undefined) continue
170
- result = { ...result, ...answer }
223
+ const notes = [result?.note, answer.note].filter((note) => note !== undefined)
224
+ result = { ...result, ...answer, ...(notes.length === 0 ? {} : { note: notes.join('\n\n') }) }
171
225
  if (answer.refuse !== undefined || answer.result !== undefined) return result
172
226
  latest = withAnswer(latest, answer)
173
227
  } catch (error) {
@@ -181,16 +235,15 @@ export function answerOf(event: BeforeEvent, answer: unknown): Partial<Answers>
181
235
  if (typeof answer !== 'object' || answer === null) return undefined
182
236
  const given = answer as Partial<Answers>
183
237
  const allowed: Readonly<Record<BeforeEvent, readonly (keyof Answers)[]>> = {
184
- prompt: ['text', 'refuse'],
238
+ prompt: ['text', 'note', 'title', 'refuse'],
185
239
  note: ['text'],
186
240
  skill: ['text'],
187
- document: ['text', 'refuse'],
241
+ instructionFile: ['text', 'refuse'],
188
242
  reply: ['text'],
189
243
  toolCall: ['input', 'result', 'refuse'],
190
- toolResult: ['text'],
244
+ toolResult: ['output', 'isError', 'note'],
191
245
  notice: ['text'],
192
246
  taskStart: ['owner', 'refuse'],
193
- taskEnd: ['refuse'],
194
247
  turnEnd: ['refuse'],
195
248
  contextEnd: ['refuse'],
196
249
  }
@@ -5,6 +5,9 @@ import type {
5
5
  Args,
6
6
  EngineInterface,
7
7
  AskOptions,
8
+ CommandInfo,
9
+ CommandRunArgs,
10
+ CommandRunResult,
8
11
  CommandSpec,
9
12
  ConfigRow,
10
13
  ElementTable,
@@ -93,8 +96,9 @@ export type Claude = {
93
96
  send: EngineInterface['session']['send']
94
97
  }
95
98
  readonly prompt: { submit: EngineInterface['prompt']['submit'] }
99
+ readonly turn: { abort: EngineInterface['turn']['abort'] }
96
100
  readonly model: { complete: EngineInterface['model']['complete'] }
97
- readonly command: { register(command: CommandSpec): Promise<{ command: string }> }
101
+ readonly command: { register(command: CommandSpec): Promise<{ command: string }>; list(): Promise<readonly CommandInfo[]>; run(args: CommandRunArgs): Promise<CommandRunResult> }
98
102
  readonly tool: { register(tool: ToolSpec): Promise<{ tool: string }>; call: EngineInterface['tool']['call'] }
99
103
  readonly agent: {
100
104
  list(): Promise<AgentInfo[]>