@cmodjs/core 1.0.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/docs/commands.md +22 -11
  2. package/docs/dependencies.md +6 -4
  3. package/docs/hooks.md +26 -3
  4. package/docs/index.md +1 -1
  5. package/docs/install-steps.md +5 -3
  6. package/docs/mod.md +29 -3
  7. package/docs/permissions.md +4 -4
  8. package/docs/session.md +126 -63
  9. package/docs/testing.md +7 -5
  10. package/docs/ui.md +9 -1
  11. package/jobs/program.d.ts.map +1 -1
  12. package/jobs/program.js +7 -2
  13. package/mod.d.ts +30 -6
  14. package/mod.d.ts.map +1 -1
  15. package/mod.js +34 -1
  16. package/package.json +1 -1
  17. package/records.d.ts +4 -1
  18. package/records.d.ts.map +1 -1
  19. package/records.js +6 -1
  20. package/register.d.ts.map +1 -1
  21. package/register.js +17 -0
  22. package/runtime/announce.d.ts.map +1 -1
  23. package/runtime/announce.js +2 -0
  24. package/runtime/before.d.ts +32 -0
  25. package/runtime/before.d.ts.map +1 -0
  26. package/runtime/before.js +214 -0
  27. package/runtime/claude.d.ts +2 -1
  28. package/runtime/claude.d.ts.map +1 -1
  29. package/runtime/conversations.d.ts +1 -0
  30. package/runtime/conversations.d.ts.map +1 -1
  31. package/runtime/conversations.js +333 -61
  32. package/runtime/dependencies.js +1 -1
  33. package/runtime/engine.d.ts +2 -6
  34. package/runtime/engine.d.ts.map +1 -1
  35. package/runtime/grants.d.ts.map +1 -1
  36. package/runtime/grants.js +8 -8
  37. package/runtime/hooks.d.ts +1 -1
  38. package/runtime/hooks.d.ts.map +1 -1
  39. package/runtime/hooks.js +19 -4
  40. package/runtime/installer.d.ts +1 -1
  41. package/runtime/installer.d.ts.map +1 -1
  42. package/runtime/installer.js +6 -5
  43. package/runtime/lifecycle.d.ts.map +1 -1
  44. package/runtime/lifecycle.js +130 -59
  45. package/runtime/metadata.d.ts.map +1 -1
  46. package/runtime/metadata.js +5 -4
  47. package/runtime/sessions.d.ts +26 -4
  48. package/runtime/sessions.d.ts.map +1 -1
  49. package/runtime/sessions.js +315 -117
  50. package/runtime/toaster.d.ts +5 -12
  51. package/runtime/toaster.d.ts.map +1 -1
  52. package/runtime/toaster.js +158 -25
  53. package/runtime/ui.d.ts.map +1 -1
  54. package/runtime/ui.js +26 -6
  55. package/session/context/context.d.ts +3 -1
  56. package/session/context/context.d.ts.map +1 -1
  57. package/session/context/turn/message/message.d.ts +1 -0
  58. package/session/context/turn/message/message.d.ts.map +1 -1
  59. package/session/context/turn/message/message.js +42 -1
  60. package/session/context/turn/turn.d.ts +2 -1
  61. package/session/context/turn/turn.d.ts.map +1 -1
  62. package/session/conversation.d.ts +20 -11
  63. package/session/conversation.d.ts.map +1 -1
  64. package/session/conversation.js +50 -27
  65. package/session/goal/goal.d.ts +3 -2
  66. package/session/goal/goal.d.ts.map +1 -1
  67. package/session/goal/task.d.ts +4 -4
  68. package/session/goal/task.d.ts.map +1 -1
  69. package/session/session.d.ts +80 -21
  70. package/session/session.d.ts.map +1 -1
  71. package/session/session.js +1 -0
  72. package/src/jobs/program.ts +7 -2
  73. package/src/mod.ts +48 -5
  74. package/src/records.ts +6 -1
  75. package/src/register.ts +17 -0
  76. package/src/runtime/announce.ts +2 -0
  77. package/src/runtime/before.ts +200 -0
  78. package/src/runtime/claude.ts +3 -0
  79. package/src/runtime/conversations.ts +337 -60
  80. package/src/runtime/dependencies.ts +1 -1
  81. package/src/runtime/engine.ts +2 -1
  82. package/src/runtime/grants.ts +8 -10
  83. package/src/runtime/hooks.ts +20 -4
  84. package/src/runtime/installer.ts +6 -5
  85. package/src/runtime/lifecycle.ts +136 -56
  86. package/src/runtime/metadata.ts +5 -4
  87. package/src/runtime/sessions.ts +316 -111
  88. package/src/runtime/toaster.ts +183 -45
  89. package/src/runtime/ui.ts +23 -4
  90. package/src/session/context/context.ts +3 -1
  91. package/src/session/context/turn/message/message.ts +39 -1
  92. package/src/session/context/turn/turn.ts +2 -1
  93. package/src/session/conversation.ts +56 -31
  94. package/src/session/goal/goal.ts +3 -2
  95. package/src/session/goal/task.ts +4 -4
  96. package/src/session/session.ts +62 -15
  97. package/src/testing/fake-claude.ts +4 -2
  98. package/src/testing/fake-sessions.ts +94 -20
  99. package/src/testing.ts +27 -3
  100. package/src/utils/call-effects.ts +2 -2
  101. package/src/utils/disposable.ts +3 -0
  102. package/src/utils/matcher.ts +1 -1
  103. package/src/utils/paths.ts +6 -3
  104. package/testing/fake-claude.d.ts +1 -0
  105. package/testing/fake-claude.d.ts.map +1 -1
  106. package/testing/fake-claude.js +3 -1
  107. package/testing/fake-sessions.d.ts +8 -1
  108. package/testing/fake-sessions.d.ts.map +1 -1
  109. package/testing/fake-sessions.js +97 -18
  110. package/testing.d.ts +1 -0
  111. package/testing.d.ts.map +1 -1
  112. package/testing.js +23 -2
  113. package/utils/call-effects.d.ts +1 -0
  114. package/utils/call-effects.d.ts.map +1 -1
  115. package/utils/call-effects.js +1 -1
  116. package/utils/disposable.d.ts +4 -0
  117. package/utils/disposable.d.ts.map +1 -0
  118. package/utils/disposable.js +1 -0
  119. package/utils/matcher.js +1 -1
  120. package/utils/paths.d.ts +2 -1
  121. package/utils/paths.d.ts.map +1 -1
  122. package/utils/paths.js +6 -3
@@ -7,6 +7,7 @@ import { ToolResult } from './context/turn/message/tool-result.js'
7
7
  import { TurnEnd, TurnStart, type Turn } from './context/turn/turn.js'
8
8
  import { GoalChange, type Goal } from './goal/goal.js'
9
9
  import { Task, TaskEnd, TaskStart } from './goal/task.js'
10
+ import type { UsageChange, Usage } from './session.js'
10
11
 
11
12
  export type SessionData = {
12
13
  readonly id: string
@@ -18,22 +19,26 @@ export type SessionData = {
18
19
  readonly lastAt: string
19
20
  readonly forkedFrom?: string
20
21
  readonly isRunning: boolean
22
+ readonly agentType?: string
21
23
  }
22
24
 
23
- export type ContextData = Omit<Context, 'turns'>
25
+ export type ContextData = Pick<Context, 'number' | 'openedAt' | 'closedAt' | 'task'>
24
26
 
25
- export type TurnData = Omit<Turn, 'messages'>
27
+ export type TurnData = Pick<Turn, 'prompt' | 'context' | 'openedAt' | 'closedAt'>
26
28
 
27
- export type TaskData = Omit<Task, 'messages' | 'tasks'> & { readonly parent?: string }
29
+ export type TaskData = Pick<Task, 'id' | 'title' | 'openedBy' | 'openedAt' | 'closedAt'> & { readonly agentType: string; readonly model?: string; readonly parent?: string }
28
30
 
29
- export type GoalData = Omit<Goal, 'update' | 'tasks'>
31
+ export type RunningTask = TaskData & { readonly context: number; readonly contextOpenedAt?: string }
32
+
33
+ export type GoalData = Pick<Goal, 'text'> & { readonly task?: string }
30
34
 
31
35
  export type SessionChange =
32
36
  | { readonly type: typeof Message; readonly message: Message }
33
37
  | { readonly type: typeof TurnStart | typeof TurnEnd; readonly turn: TurnData }
34
38
  | { readonly type: typeof ContextStart | typeof ContextEnd; readonly context: ContextData }
35
- | { readonly type: typeof TaskStart | typeof TaskEnd; readonly task: TaskData & { readonly context: number } }
39
+ | { readonly type: typeof TaskStart | typeof TaskEnd; readonly task: RunningTask }
36
40
  | { readonly type: typeof GoalChange; readonly goal: GoalData }
41
+ | { readonly type: typeof UsageChange; readonly usage: Usage }
37
42
 
38
43
  export type ConversationState = {
39
44
  readonly session: string
@@ -43,13 +48,13 @@ export type ConversationState = {
43
48
  readonly goal?: string
44
49
  readonly agentCalls: readonly string[]
45
50
  readonly endedCalls: Readonly<Record<string, string>>
46
- readonly tasks: Readonly<Record<string, TaskData & { readonly context: number }>>
51
+ readonly tasks: Readonly<Record<string, RunningTask>>
52
+ readonly taskTurns: Readonly<Record<string, TurnData>>
47
53
  }
48
54
 
49
55
  export type Conversation = {
50
56
  add(row: Row, task?: string): SessionChange[]
51
57
  startTask(task: TaskData): SessionChange[]
52
- endContext(at: string): SessionChange[]
53
58
  startContext(at: string): SessionChange[]
54
59
  changeGoal(text: string): SessionChange[]
55
60
  readonly state: ConversationState
@@ -62,7 +67,7 @@ const agentTools: readonly string[] = ['Agent', 'Task']
62
67
  const asyncLaunch = 'Async agent launched'
63
68
 
64
69
  export function emptyConversation(session: string): ConversationState {
65
- return { session, context: 0, contextOpenedAt: '', agentCalls: [], endedCalls: {}, tasks: {} }
70
+ return { session, context: 0, contextOpenedAt: '', agentCalls: [], endedCalls: {}, tasks: {}, taskTurns: {} }
66
71
  }
67
72
 
68
73
  export function createConversation(start: ConversationState): Conversation {
@@ -70,6 +75,14 @@ export function createConversation(start: ConversationState): Conversation {
70
75
  const agentCalls = new Set(start.agentCalls)
71
76
  const endedCalls = new Map(Object.entries(start.endedCalls))
72
77
  const tasks = new Map(Object.entries(start.tasks))
78
+ const taskTurns = new Map(Object.entries(start.taskTurns))
79
+
80
+ const endTaskTurn = (task: string, at: string): SessionChange[] => {
81
+ const open = taskTurns.get(task)
82
+ if (open === undefined) return []
83
+ taskTurns.delete(task)
84
+ return open.closedAt === undefined ? [{ type: TurnEnd, turn: { ...open, closedAt: at } }] : []
85
+ }
73
86
 
74
87
  const startContext = (at: string): SessionChange[] => {
75
88
  context += 1
@@ -79,6 +92,17 @@ export function createConversation(start: ConversationState): Conversation {
79
92
 
80
93
  const endContext = (at: string): SessionChange[] => (context === 0 ? [] : [{ type: ContextEnd, context: { number: context, openedAt: contextOpenedAt, closedAt: at } }])
81
94
 
95
+ const compactTask = (task: string, at: string): SessionChange[] => {
96
+ const owner = tasks.get(task)
97
+ if (owner === undefined) return []
98
+ const next = { ...owner, context: owner.context + 1, contextOpenedAt: at }
99
+ tasks.set(task, next)
100
+ return [
101
+ { type: ContextEnd, context: { number: owner.context, openedAt: owner.contextOpenedAt ?? owner.openedAt, closedAt: at, task } },
102
+ { type: ContextStart, context: { number: next.context, openedAt: at, task } },
103
+ ]
104
+ }
105
+
82
106
  const endTurn = (at: string): SessionChange[] => {
83
107
  if (turn === undefined) return []
84
108
  const ended = { ...turn, closedAt: at }
@@ -86,17 +110,19 @@ export function createConversation(start: ConversationState): Conversation {
86
110
  return [{ type: TurnEnd, turn: ended }]
87
111
  }
88
112
 
89
- const endTask = (call: string, at: string): SessionChange[] => {
90
- const task = [...tasks.values()].find((each) => each.openedBy === call)
91
- if (task === undefined) {
113
+ const endTask = (call: string, at: string, only?: string): SessionChange[] => {
114
+ const opened = [...tasks.values()].filter((each) => each.openedBy === call && (only === undefined || each.id === only))
115
+ if (opened.length === 0) {
92
116
  if (agentCalls.has(call)) endedCalls.set(call, at)
93
117
  return []
94
118
  }
95
- if (task.closedAt !== undefined) return []
96
- const ended = { ...task, closedAt: at }
97
- tasks.set(task.id, ended)
98
- agentCalls.delete(call)
99
- return [{ type: TaskEnd, task: ended }]
119
+ if (only === undefined) agentCalls.delete(call)
120
+ return opened.flatMap((task) => {
121
+ if (task.closedAt !== undefined) return []
122
+ const ended = { ...task, closedAt: at }
123
+ tasks.set(task.id, ended)
124
+ return [...endTaskTurn(task.id, at), { type: TaskEnd, task: ended }]
125
+ })
100
126
  }
101
127
 
102
128
  const changeGoal = (text: string): SessionChange[] => {
@@ -109,30 +135,32 @@ export function createConversation(start: ConversationState): Conversation {
109
135
  const changes: SessionChange[] = []
110
136
  if (task === undefined && context === 0) changes.push(...startContext(row.at))
111
137
  if (task === undefined && row.type === 'system' && row.name === 'turn_duration') return [...changes, ...endTurn(row.at)]
112
- if (task === undefined && row.type === 'system' && row.name === 'compact_boundary') return [...changes, ...endContext(row.at), ...startContext(row.at)]
138
+ if (row.type === 'system' && row.name === 'compact_boundary') return [...changes, ...(task === undefined ? [...endContext(row.at), ...startContext(row.at)] : compactTask(task, row.at))]
113
139
  const owner = task === undefined ? undefined : tasks.get(task)
140
+ const placedIn = owner?.context ?? context
114
141
  for (const unplaced of messagesOf(row, task !== undefined)) {
115
- if (task === undefined && unplaced.type === Prompt) {
116
- changes.push(...endTurn(row.at))
117
- const opened: TurnData = { prompt: { ...unplaced, session: start.session, context, turn: unplaced.id }, context, openedAt: row.at }
118
- turn = opened
142
+ if (unplaced.type === Prompt) {
143
+ changes.push(...(task === undefined ? endTurn(row.at) : endTaskTurn(task, row.at)))
144
+ const opened: TurnData = { prompt: { ...unplaced, session: start.session, context: placedIn, turn: unplaced.id, ...(task === undefined ? {} : { task }) }, context: placedIn, openedAt: row.at, ...(owner?.closedAt === undefined ? {} : { closedAt: owner.closedAt }) }
145
+ if (task === undefined) turn = opened
146
+ else taskTurns.set(task, opened)
119
147
  changes.push({ type: TurnStart, turn: opened })
120
- if (goal === undefined) changes.push(...changeGoal(unplaced.text))
148
+ if (task === undefined && goal === undefined) changes.push(...changeGoal(unplaced.text))
121
149
  }
150
+ const open = task === undefined ? turn : taskTurns.get(task)
122
151
  const placed = {
123
152
  ...unplaced,
124
153
  session: start.session,
125
- context: owner?.context ?? context,
126
- ...(task === undefined && turn !== undefined ? { turn: turn.prompt.id } : {}),
154
+ context: placedIn,
155
+ ...(open === undefined ? {} : { turn: open.prompt.id }),
127
156
  ...(task === undefined ? {} : { task }),
128
157
  } as Message
129
158
  changes.push({ type: Message, message: placed })
130
- if (task !== undefined) continue
131
159
  if (placed.type === ToolCall && agentTools.includes(placed.name)) agentCalls.add(placed.callId)
132
160
  if (placed.type === ToolResult && agentCalls.has(placed.callId) && !placed.text.startsWith(asyncLaunch)) changes.push(...endTask(placed.callId, row.at))
133
161
  if (placed.type === Note && placed.from.type === Task) {
134
162
  const call = tagOf(textOf(row.content), 'tool-use-id')
135
- if (call !== undefined && agentCalls.has(call)) changes.push(...endTask(call, row.at))
163
+ if (call !== undefined && agentCalls.has(call)) changes.push(...endTask(call, row.at, tagOf(textOf(row.content), 'task-id')))
136
164
  }
137
165
  }
138
166
  return changes
@@ -144,13 +172,9 @@ export function createConversation(start: ConversationState): Conversation {
144
172
  agentCalls.add(data.openedBy)
145
173
  const changes: SessionChange[] = [{ type: TaskStart, task: started }]
146
174
  const endedAt = endedCalls.get(data.openedBy)
147
- if (endedAt !== undefined) {
148
- endedCalls.delete(data.openedBy)
149
- changes.push(...endTask(data.openedBy, endedAt))
150
- }
175
+ if (endedAt !== undefined) changes.push(...endTask(data.openedBy, endedAt, data.id))
151
176
  return changes
152
177
  },
153
- endContext,
154
178
  startContext,
155
179
  changeGoal,
156
180
  get state() {
@@ -163,6 +187,7 @@ export function createConversation(start: ConversationState): Conversation {
163
187
  agentCalls: [...agentCalls],
164
188
  endedCalls: Object.fromEntries(endedCalls),
165
189
  tasks: Object.fromEntries(tasks),
190
+ taskTurns: Object.fromEntries(taskTurns),
166
191
  }
167
192
  },
168
193
  }
@@ -1,9 +1,10 @@
1
+ import type { RecordPart } from '../session.js'
1
2
  import type { Task } from './task.js'
2
3
 
3
4
  export const GoalChange = 'goalChange'
4
5
 
5
- export type Goal = {
6
+ export type Goal = RecordPart & {
6
7
  readonly text: string
7
- update(change: { readonly text: string }): Promise<void>
8
8
  tasks(): Promise<Task[]>
9
+ update(change: { readonly text: string }): Promise<void>
9
10
  }
@@ -1,4 +1,5 @@
1
1
  import type { Message } from '../context/turn/message/message.js'
2
+ import type { RecordPart, Session } from '../session.js'
2
3
 
3
4
  export const Task = 'task'
4
5
 
@@ -6,14 +7,13 @@ export const TaskStart = 'taskStart'
6
7
 
7
8
  export const TaskEnd = 'taskEnd'
8
9
 
9
- export type Task = {
10
+ export type Task = RecordPart & {
10
11
  readonly id: string
11
12
  readonly title: string
12
- readonly agentType: string
13
- readonly model?: string
13
+ readonly owner: Session
14
14
  readonly openedBy: string
15
15
  readonly openedAt: string
16
16
  readonly closedAt?: string
17
- messages(): Promise<Message[]>
18
17
  tasks(): Promise<Task[]>
18
+ messages(): Promise<Message[]>
19
19
  }
@@ -1,4 +1,4 @@
1
- import type { Disposable } from '../mod.js'
1
+ import type { Disposable } from '../utils/disposable.js'
2
2
  import type { Matcher } from '../utils/matcher.js'
3
3
  import type { Context, ContextEnd, ContextStart } from './context/context.js'
4
4
  import type { Document } from './context/turn/message/document.js'
@@ -17,7 +17,9 @@ import type { Task, TaskEnd, TaskStart } from './goal/task.js'
17
17
 
18
18
  export const Session = 'session'
19
19
 
20
- export type SessionEvent = typeof Message | MessageType | typeof ContextStart | typeof ContextEnd | typeof TurnStart | typeof TurnEnd | typeof TaskStart | typeof TaskEnd | typeof GoalChange
20
+ export const UsageChange = 'usageChange'
21
+
22
+ export type SessionEvent = typeof Message | MessageType | typeof ContextStart | typeof ContextEnd | typeof TurnStart | typeof TurnEnd | typeof TaskStart | typeof TaskEnd | typeof GoalChange | typeof UsageChange
21
23
 
22
24
  export type SessionPayload = {
23
25
  readonly message: Message
@@ -36,16 +38,53 @@ export type SessionPayload = {
36
38
  readonly taskStart: Task
37
39
  readonly taskEnd: Task
38
40
  readonly goalChange: Goal
41
+ readonly usageChange: Usage
42
+ }
43
+
44
+ export type Refusal = { readonly refuse: string }
45
+
46
+ export type BeforeAnswer = {
47
+ readonly prompt: { readonly text: string } | Refusal
48
+ readonly note: { readonly text: string }
49
+ readonly skill: { readonly text: string }
50
+ readonly document: { readonly text: string } | Refusal
51
+ readonly reply: { readonly text: string }
52
+ readonly toolCall: { readonly input: Readonly<Record<string, unknown>> } | { readonly result: string } | Refusal
53
+ readonly toolResult: { readonly text: string }
54
+ readonly notice: { readonly text: string }
55
+ readonly taskStart: { readonly owner: { readonly model: string } } | Refusal
56
+ readonly taskEnd: Refusal
57
+ readonly turnEnd: Refusal
58
+ readonly contextEnd: Refusal
39
59
  }
40
60
 
61
+ export type BeforeEvent = keyof BeforeAnswer
62
+
41
63
  export type Usage = {
42
64
  readonly model: string
43
- readonly context: { readonly tokens?: number; readonly window: number; readonly percent?: number }
65
+ readonly context: { readonly tokens?: number; readonly window?: number; readonly percent?: number }
44
66
  readonly cost?: { readonly usd: number }
45
67
  }
46
68
 
69
+ export type Client = 'terminal' | 'desktop' | 'mobile' | 'vscode'
70
+
47
71
  export type Listener<Event extends SessionEvent> = (payload: SessionPayload[Event]) => unknown
48
72
 
73
+ export type BeforeListener<Event extends BeforeEvent> = (payload: SessionPayload[Event]) => BeforeAnswer[Event] | void | Promise<BeforeAnswer[Event] | void>
74
+
75
+ export type SearchOptions = {
76
+ readonly limit?: number
77
+ readonly before?: Pick<Message, 'session' | 'id'>
78
+ }
79
+
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
86
+ }
87
+
49
88
  export type SessionSend = {
50
89
  readonly type: typeof Prompt | typeof Note
51
90
  readonly text: string
@@ -60,25 +99,33 @@ export type TaskStartArgs = {
60
99
  readonly cwd?: string
61
100
  }
62
101
 
63
- export type Session = {
102
+ export type ToolCallArgs = {
103
+ readonly name: string
104
+ readonly input: Readonly<Record<string, unknown>>
105
+ }
106
+
107
+ export type Session = RecordPart & {
64
108
  readonly id: string
65
109
  readonly title: string
66
110
  readonly project: string
67
- readonly cwd: string
111
+ readonly agentType?: string
68
112
  readonly model: string
113
+ readonly cwd: string
114
+ readonly isRunning: boolean
69
115
  readonly startedAt: string
70
116
  readonly lastAt: string
71
117
  readonly forkedFrom?: string
72
- readonly isRunning: boolean
73
- usage(): Promise<Usage>
74
- on<Event extends SessionEvent>(event: Event, listener: Listener<Event>): Disposable
75
- on<Event extends SessionEvent>(event: Event, matcher: Matcher<SessionPayload[Event]>, listener: Listener<Event>): Disposable
118
+ goal(): Promise<Goal>
119
+ context(): Promise<Context>
120
+ contexts(): Promise<Context[]>
121
+ turn(): Promise<Turn | undefined>
122
+ turns(options?: { readonly last?: number }): Promise<Turn[]>
123
+ messages(options?: { readonly after?: string; readonly last?: number }): Promise<Message[]>
76
124
  send(message: SessionSend): Promise<void>
77
125
  startTask(task: TaskStartArgs): Promise<Task>
78
- messages(options?: { readonly after?: string; readonly last?: number }): Promise<Message[]>
79
- turns(options?: { readonly last?: number }): Promise<Turn[]>
80
- contexts(): Promise<Context[]>
81
- goal(): Promise<Goal>
126
+ callTool(call: ToolCallArgs): Promise<ToolResult>
127
+ usage(): Promise<Usage>
128
+ clients(): Promise<Client[]>
82
129
  }
83
130
 
84
131
  export type SessionScope = {
@@ -87,8 +134,8 @@ export type SessionScope = {
87
134
  readonly everyProject?: boolean
88
135
  }
89
136
 
90
- export type Sessions = {
137
+ export type Sessions = Omit<RecordPart, 'search'> & {
91
138
  readonly current: Session
92
139
  list(options?: SessionScope): Promise<Session[]>
93
- search(text: string, options?: SessionScope & { readonly limit?: number; readonly before?: Pick<Message, 'session' | 'id'> }): Promise<Message[]>
140
+ search(text: string, options?: SessionScope & SearchOptions): Promise<Message[]>
94
141
  }
@@ -15,7 +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
- ui: { ask?: Claude['ui']['ask']; open?: Claude['ui']['open']; scroll?: Claude['ui']['scroll']; copy?: Claude['ui']['copy'] }
18
+ ui: { ask?: Claude['ui']['ask']; open?: Claude['ui']['open']; scroll?: Claude['ui']['scroll']; copy?: Claude['ui']['copy']; focus?: Claude['ui']['focus'] }
19
19
  session: { messages?: Claude['session']['messages']; append?: Claude['session']['append']; send?: Claude['session']['send'] }
20
20
  prompt: { submit?: Claude['prompt']['submit'] }
21
21
  model: { complete?: Claude['model']['complete'] }
@@ -119,6 +119,7 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
119
119
  calls.push({ call: 'ui.open', args: [pane] })
120
120
  const answer = fakes.ui.open === undefined ? { isPlaced: true as const } : await fakes.ui.open(pane)
121
121
  if (answer.isPlaced) {
122
+ shown.openPanes.delete(pane.id)
122
123
  shown.openPanes.add(pane.id)
123
124
  unplacedPanes.delete(pane.id)
124
125
  if (pane.focus === true) focusedPane = pane.id
@@ -134,11 +135,12 @@ export function fakeClaude(plugin: { readonly name: string; readonly root: strin
134
135
  if (focusedPane === pane.id) focusedPane = undefined
135
136
  },
136
137
  panes: async () => [
137
- ...[...shown.openPanes].map((id) => ({ id, title: id, isShown: true, isFocused: id === focusedPane, isPlaced: true })),
138
+ ...[...shown.openPanes].map((id) => ({ id, title: id, isShown: id === [...shown.openPanes].at(-1), isFocused: id === focusedPane, isPlaced: true })),
138
139
  ...[...unplacedPanes].map((id) => ({ id, title: id, isShown: false, isFocused: false, isPlaced: false })),
139
140
  ],
140
141
  scroll: rejected('ui.scroll', () => fakes.ui.scroll ?? (async () => ({}))),
141
142
  copy: rejected('ui.copy', () => fakes.ui.copy ?? (async () => ({ isCopied: true as const }))),
143
+ focus: rejected('ui.focus', () => fakes.ui.focus ?? (async () => ({}))),
142
144
  resolve: () => elements,
143
145
  },
144
146
  process: {
@@ -6,12 +6,15 @@ import { Notice } from '../session/context/turn/message/notice.js'
6
6
  import { Prompt } from '../session/context/turn/message/prompt.js'
7
7
  import { Reply } from '../session/context/turn/message/reply.js'
8
8
  import { Mod, Person, Schedule, type Sender } from '../session/context/turn/message/sender.js'
9
+ import { GoalChange } from '../session/goal/goal.js'
9
10
  import { Task } from '../session/goal/task.js'
10
11
  import { Skill } from '../session/context/turn/message/skill.js'
11
12
  import { ToolCall } from '../session/context/turn/message/tool-call.js'
12
13
  import { ToolResult } from '../session/context/turn/message/tool-result.js'
13
14
  import { createConversation, emptyConversation, searchLimit, type ContextData, type Conversation, type SessionChange, type SessionData, type TaskData, type TurnData } from '../session/conversation.js'
14
- import { conversationMethods, isHeardBy, type Subscription } from '../runtime/sessions.js'
15
+ import type { Place } from '../runtime/before.js'
16
+ import type { Claude } from '../runtime/claude.js'
17
+ import { conversationMethods, everySession, isHeardBy, resultOf, type Subscription } from '../runtime/sessions.js'
15
18
  import { receivedMatcher } from '../utils/matcher.js'
16
19
  import { firstLine, messageOf } from '../utils/text.js'
17
20
 
@@ -46,16 +49,20 @@ type Heard = { readonly event: Subscription['event']; readonly matcher: unknown
46
49
  export type FakeSessions = {
47
50
  answer(caller: string, method: string, input: unknown): unknown
48
51
  add(draft: MessageDraft, task?: string): Message[]
52
+ place(): Place
53
+ wants(mod: string, event: string): boolean
49
54
  endTurn(): void
50
55
  compact(): void
51
- startTask(task: { readonly title: string; readonly agentType?: string; readonly model?: string; readonly openedBy?: string }): string
56
+ startTask(task: { readonly title: string; readonly agentType?: string; readonly model?: string; readonly openedBy?: string; readonly parent?: string }): string
52
57
  start(id: string): void
53
- readonly sent: { readonly session: string; readonly type: string; readonly text: string; readonly as?: string }[]
58
+ readonly sent: { readonly session: string; readonly task?: string; readonly type: string; readonly text: string; readonly as?: string }[]
54
59
  }
55
60
 
56
- export function fakeSessions({ project, given, push }: { readonly project: () => string; readonly given: readonly FakeSession[]; readonly push: (mod: string, session: string, change: SessionChange) => void }): FakeSessions {
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 {
57
62
  const kept = new Map<string, Kept>()
58
63
  const heard = new Map<string, Map<string, readonly Heard[]>>()
64
+ const watches = new Map<string, Map<string, readonly string[]>>()
65
+ const goals = new Map<string, string>()
59
66
  const sent: FakeSessions['sent'] = []
60
67
  let current = ''
61
68
  let clock = Date.parse('2026-01-01T00:00:00.000Z')
@@ -76,7 +83,7 @@ export function fakeSessions({ project, given, push }: { readonly project: () =>
76
83
  if ('task' in change) session.tasks.set(change.task.id, change.task)
77
84
  session.data = { ...session.data, lastAt: change.type === Message ? change.message.at : session.data.lastAt, ...(session.data.title === '' && change.type === Message && change.message.type === Prompt && change.message.from.type === Person ? { title: firstLine(change.message.text) } : {}) }
78
85
  for (const [mod, sessions] of heard) {
79
- if ((sessions.get(session.data.id) ?? []).some(({ event, matcher }) => isHeardBy(event, matcher, change))) push(mod, session.data.id, change)
86
+ if ([...(sessions.get(session.data.id) ?? []), ...(sessions.get(everySession) ?? [])].some(({ event, matcher }) => isHeardBy(event, matcher, change))) push(mod, session.data.id, change)
80
87
  }
81
88
  }
82
89
  }
@@ -101,6 +108,24 @@ export function fakeSessions({ project, given, push }: { readonly project: () =>
101
108
 
102
109
  const currentSession = () => sessionOf(current)
103
110
 
111
+ const place = (): Place => {
112
+ const { session, context, contextOpenedAt, turn, tasks, taskTurns } = currentSession().conversation.state
113
+ return { session, context, contextOpenedAt, ...(turn === undefined ? {} : { turn }), tasks, taskTurns }
114
+ }
115
+
116
+ const taskOf = (session: Kept, id: string) => {
117
+ const task = session.tasks.get(id)
118
+ if (task === undefined) throw new Error(`Session ${session.data.id} has no task ${id}. Start it with tested.startTask.`)
119
+ return task
120
+ }
121
+
122
+ const turnsOf = (session: Kept, task: unknown) => [...session.turns.values()].filter((turn) => turn.prompt.task === (typeof task === 'string' ? task : undefined))
123
+
124
+ const isUnder = (session: Kept | undefined, task: string, ancestor: string) => {
125
+ for (let at: string | undefined = task, steps = 0; at !== undefined && steps < 64; at = session?.tasks.get(at)?.parent, steps += 1) if (at === ancestor) return true
126
+ return false
127
+ }
128
+
104
129
  const lastOf = <Item>(items: readonly Item[], last: unknown): Item[] => (typeof last === 'number' ? items.slice(-last) : [...items])
105
130
 
106
131
  const isInScope = (session: Kept, request: Readonly<Record<string, unknown>>) =>
@@ -115,10 +140,18 @@ export function fakeSessions({ project, given, push }: { readonly project: () =>
115
140
  [conversationMethods.search](request) {
116
141
  const text = String(request['text'])
117
142
  if ([...text].length < 3) throw new Error('cmod history search takes at least 3 characters.')
143
+ const inRecord = (message: Message) => {
144
+ const session = kept.get(message.session)
145
+ const task = request['task']
146
+ 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
149
+ return (request['turn'] === undefined || message.turn === request['turn']) && (request['context'] === undefined || message.context === request['context'])
150
+ }
118
151
  const found = [...kept.values()]
119
- .filter((session) => isInScope(session, request))
152
+ .filter((session) => request['session'] !== undefined || isInScope(session, request))
120
153
  .flatMap((session) => session.messages)
121
- .filter((message) => searchText(message).toLowerCase().includes(text.toLowerCase()))
154
+ .filter((message) => inRecord(message) && searchText(message).toLowerCase().includes(text.toLowerCase()))
122
155
  .toSorted((first, second) => second.at.localeCompare(first.at))
123
156
  const before = isObject(request['before']) ? request['before'] : undefined
124
157
  const start = before === undefined ? 0 : found.findIndex((message) => message.session === before['session'] && message.id === before['id']) + 1
@@ -130,15 +163,59 @@ export function fakeSessions({ project, given, push }: { readonly project: () =>
130
163
  const after = typeof request['after'] === 'string' ? fits.findIndex((message) => message.id === request['after']) + 1 : 0
131
164
  return lastOf(fits.slice(after), request['last'])
132
165
  },
133
- [conversationMethods.turns]: (request) => lastOf([...sessionOf(String(request['session'])).turns.values()].filter((turn) => request['context'] === undefined || turn.context === request['context']), request['last']),
134
- [conversationMethods.contexts]: (request) => [...sessionOf(String(request['session'])).contexts.values()],
135
- [conversationMethods.tasks]: (request) => [...sessionOf(String(request['session'])).tasks.values()].filter((task) => task.parent === request['task']),
136
- [conversationMethods.goal]: (request) => ({ text: sessionOf(String(request['session'])).conversation.state.goal ?? '' }),
166
+ [conversationMethods.turns]: (request) => lastOf(turnsOf(sessionOf(String(request['session'])), request['task']).filter((turn) => request['context'] === undefined || turn.context === request['context']), request['last']),
167
+ [conversationMethods.contexts](request) {
168
+ const session = sessionOf(String(request['session']))
169
+ if (typeof request['task'] !== 'string') return [...session.contexts.values()]
170
+ const task = taskOf(session, request['task'])
171
+ return [{ number: session.conversation.state.tasks[task.id]?.context ?? 1, openedAt: task.openedAt, ...(task.closedAt === undefined ? {} : { closedAt: task.closedAt }) }]
172
+ },
173
+ [conversationMethods.tasks]: (request) => [...sessionOf(String(request['session'])).tasks.values()].filter((task) => request['all'] === true || task.parent === request['task']),
174
+ [conversationMethods.goal](request) {
175
+ const session = sessionOf(String(request['session']))
176
+ const task = request['task']
177
+ if (typeof task !== 'string') return { text: session.conversation.state.goal ?? '' }
178
+ return { text: goals.get(task) ?? turnsOf(session, task)[0]?.prompt.text ?? '' }
179
+ },
137
180
  [conversationMethods.updateGoal](request) {
138
181
  const session = sessionOf(String(request['session']))
139
- record(session, session.conversation.changeGoal(String(request['text'])))
182
+ const task = typeof request['task'] === 'string' ? request['task'] : undefined
183
+ const text = String(request['text'])
184
+ if (task === undefined) record(session, session.conversation.changeGoal(text))
185
+ else {
186
+ goals.set(task, text)
187
+ record(session, [{ type: GoalChange, goal: { text, task } }])
188
+ }
140
189
  return null
141
190
  },
191
+ [conversationMethods.watchBefore](request, caller) {
192
+ const targets = watches.get(caller) ?? new Map<string, readonly string[]>()
193
+ targets.set(String(request['session']), (Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Subscription[]) : []).map(({ event }) => event))
194
+ watches.set(caller, targets)
195
+ return null
196
+ },
197
+ async [conversationMethods.act](request) {
198
+ const session = sessionOf(String(request['session']))
199
+ const action = String(request['action'])
200
+ const input = isObject(request['input']) ? request['input'] : {}
201
+ const task = typeof request['task'] === 'string' ? request['task'] : undefined
202
+ 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'] : []
205
+ if (!isLive) throw new Error(`session ${task ?? session.data.id} has ended`)
206
+ if (action === 'send') {
207
+ 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
+ return null
209
+ }
210
+ if (action === 'startTask') {
211
+ 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'] } : {}) }
213
+ record(session, session.conversation.startTask(data))
214
+ return data
215
+ }
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 }
218
+ },
142
219
  [conversationMethods.subscribe](request, caller) {
143
220
  const list = Array.isArray(request['subscriptions']) ? (request['subscriptions'] as readonly Subscription[]) : []
144
221
  const sessions = heard.get(caller) ?? new Map<string, readonly Heard[]>()
@@ -146,10 +223,6 @@ export function fakeSessions({ project, given, push }: { readonly project: () =>
146
223
  heard.set(caller, sessions)
147
224
  return null
148
225
  },
149
- [conversationMethods.send](request) {
150
- sent.push({ session: String(request['session']), type: String(request['type']), text: String(request['text']), ...(typeof request['as'] === 'string' ? { as: request['as'] } : {}) })
151
- return null
152
- },
153
226
  }
154
227
 
155
228
  return {
@@ -163,19 +236,20 @@ export function fakeSessions({ project, given, push }: { readonly project: () =>
163
236
  }
164
237
  },
165
238
  add: (draft, task) => add(currentSession(), draft, task),
239
+ place,
240
+ wants: (mod, event) => [current, everySession].some((target) => (watches.get(mod)?.get(target) ?? []).includes(event)),
166
241
  endTurn() {
167
242
  const session = currentSession()
168
243
  record(session, session.conversation.add({ id: `turn-end-${(ids += 1)}`, at: now(), type: 'system', name: 'turn_duration', isMeta: false, isSummary: false, content: [] }))
169
244
  },
170
245
  compact() {
171
246
  const session = currentSession()
172
- const at = now()
173
- record(session, [...session.conversation.endContext(at), ...session.conversation.startContext(at)])
247
+ record(session, session.conversation.add({ id: `compact-${(ids += 1)}`, at: now(), type: 'system', name: 'compact_boundary', isMeta: false, isSummary: false, content: [] }))
174
248
  },
175
- startTask({ title, agentType = 'general-purpose', model, openedBy = '' }) {
249
+ startTask({ title, agentType = 'general-purpose', model, openedBy = '', parent }) {
176
250
  const session = currentSession()
177
251
  const id = `task-${(ids += 1)}`
178
- record(session, session.conversation.startTask({ id, title, agentType, openedBy, openedAt: now(), ...(model === undefined ? {} : { model }) }))
252
+ record(session, session.conversation.startTask({ id, title, agentType, openedBy, openedAt: now(), ...(model === undefined ? {} : { model }), ...(parent === undefined ? {} : { parent }) }))
179
253
  return id
180
254
  },
181
255
  start(id) {