@cmodjs/core 1.0.0 → 1.5.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 (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 +129 -58
  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 +133 -23
  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 +135 -55
  86. package/src/runtime/metadata.ts +5 -4
  87. package/src/runtime/sessions.ts +316 -111
  88. package/src/runtime/toaster.ts +160 -44
  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
@@ -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) {
package/src/testing.ts CHANGED
@@ -23,7 +23,9 @@ import type { Options, OptionValues } from './options.js'
23
23
  import { readSteps, storeFolder, type Steps } from './records.js'
24
24
  import type { Claude } from './runtime/claude.js'
25
25
  import { answerCall, cmodPluginName, notInstalled } from './runtime/dependencies.js'
26
+ import { foldAnswers, installBefore } from './runtime/before.js'
26
27
  import type { RoutedEvent } from './runtime/hooks.js'
28
+ import { createRouter } from './runtime/router.js'
27
29
  import { toolInputOf } from './runtime/tool-calls.js'
28
30
  import { createLifecycle } from './runtime/lifecycle.js'
29
31
  import { MissingOptions } from './runtime/options.js'
@@ -33,11 +35,13 @@ import type { Message } from './session/context/turn/message/message.js'
33
35
  import { Note } from './session/context/turn/message/note.js'
34
36
  import { Prompt } from './session/context/turn/message/prompt.js'
35
37
  import { Mod, Person } from './session/context/turn/message/sender.js'
38
+ import type { SessionChange } from './session/conversation.js'
39
+ import type { BeforeEvent } from './session/session.js'
36
40
  import { calledAnswer, fakeClaude, type Fakes, type Shown as ShownByClaude, type TestCall } from './testing/fake-claude.js'
37
41
  import { elements, findElement, rowsOf } from './testing/fake-elements.js'
38
42
  import { fakeFiles } from './testing/fake-files.js'
39
43
  import { fakeSessions, type FakeSession, type FakeSessions, type MessageDraft } from './testing/fake-sessions.js'
40
- import { listed } from './utils/text.js'
44
+ import { listed, messageOf } from './utils/text.js'
41
45
  import { componentOf, type ComponentPlace, type PlaceProps } from './ui/places.js'
42
46
 
43
47
  export type { Fakes, TestCall } from './testing/fake-claude.js'
@@ -72,7 +76,7 @@ export type TestedMod<State extends object> = {
72
76
  add(message: MessageDraft, options?: { readonly task?: string }): Promise<Message[]>
73
77
  endTurn(): Promise<void>
74
78
  compact(): Promise<void>
75
- startTask(task: { readonly title: string; readonly agentType?: string; readonly model?: string; readonly openedBy?: string }): Promise<string>
79
+ startTask(task: { readonly title: string; readonly agentType?: string; readonly model?: string; readonly openedBy?: string; readonly parent?: string }): Promise<string>
76
80
  lines(paneId: string): Promise<string[]>
77
81
  lines<P extends ComponentPlace>(place: P, props: Omit<PlaceProps<P>, 'Default'>, requestId?: string): Promise<string[]>
78
82
  type(line: string): Promise<CommandReply>
@@ -111,6 +115,7 @@ export function testMod<State extends object, Declared extends Options = Options
111
115
  fake.claude.session.root = async () => projectRoot
112
116
  fake.claude.session.cwd = async () => cwd
113
117
  const conversations = fakeSessions({
118
+ callTool: (args: Parameters<Claude["tool"]["call"]>[0]) => fake.claude.tool.call(args),
114
119
  project: () => projectRoot,
115
120
  given: options.sessions ?? [],
116
121
  push: (mod, session, change) => {
@@ -171,9 +176,24 @@ export function testMod<State extends object, Declared extends Options = Options
171
176
  if (lifecycle.phase !== 'active') throw failure ?? new Error(`${name} did not start: the lifecycle is ${lifecycle.phase}.`)
172
177
  }
173
178
 
179
+ const cmodPlugin = createRouter()
180
+ const askMod = async (event: BeforeEvent, change: SessionChange) => {
181
+ const asked = { to: name, method: conversationMethods.before, input: { session: sessionId, event, change }, caller: cmodPluginName } as unknown as Frozen<Args<'cmod.call'>>
182
+ return calledAnswer(cmodPluginName, await lifecycle.route('cmod.call', asked, async () => ({ deny: notInstalled(name) })))
183
+ }
184
+ installBefore({
185
+ claude: fake.claude,
186
+ router: cmodPlugin,
187
+ hooks: {
188
+ place: async () => conversations.place(),
189
+ wants: async (event) => conversations.wants(name, event),
190
+ decide: (event, change) => foldAnswers(event, change, [(latest) => askMod(event, latest)], (error) => fake.claude.ui.log(`cmod: passed the ${event} on without ${name}'s answer: ${messageOf(error)}`, { to: 'debug' })),
191
+ },
192
+ })
193
+
174
194
  const route = async <N extends RoutedEvent>(event: N, input: unknown, below: unknown): Promise<EventResult<N>> => {
175
195
  await start()
176
- return lifecycle.route(event, input as Frozen<Args<N>>, async (e) => (below ?? claudeCodeAnswer(event, e)) as EventResult<N>)
196
+ return cmodPlugin.dispatch(event, input as Frozen<Args<N>>, (passed) => lifecycle.route(event, passed, async (e) => (below ?? claudeCodeAnswer(event, e)) as EventResult<N>))
177
197
  }
178
198
 
179
199
  const drawPane = async (paneId: string) => {
@@ -360,6 +380,10 @@ function fakeDependencies(caller: string, dependencies: NonNullable<TestOptions<
360
380
  }
361
381
 
362
382
  function claudeCodeAnswer(event: RoutedEvent, e: unknown): unknown {
383
+ if (event === 'agent.spawn') {
384
+ const { model, parentModel } = e as Args<'agent.spawn'>
385
+ return { agentId: 'agent-1', model: model ?? parentModel }
386
+ }
363
387
  if (event !== 'prompt.submit') return {}
364
388
  const { text, context, origin } = e as Args<'prompt.submit'>
365
389
  return { text, ...(context === undefined ? {} : { context }), origin }
@@ -18,9 +18,9 @@ export type CallEffects = {
18
18
  subagent: string | undefined
19
19
  }
20
20
 
21
- export function callEffects(use: CallBase, workspace: { cwd: string; home: string; fs: FileSystem }): CallEffects {
21
+ export function callEffects(use: CallBase, workspace: { cwd: string; home: string; configRoot?: string; fs: FileSystem }): CallEffects {
22
22
  const { fs } = workspace
23
- const absolute = (path: string, folder = workspace.cwd) => resolve(folder, expandHome(path, workspace.home))
23
+ const absolute = (path: string, folder = workspace.cwd) => resolve(folder, expandHome(path, workspace.home, workspace.configRoot))
24
24
  const access = (path: string): FileAccess => ({ path: absolute(path), contents: undefined })
25
25
  const effects: CallEffects = { shell: undefined, reads: [], writes: [], urls: [], subagent: undefined }
26
26
  if (use.tool === 'Bash') {
@@ -0,0 +1,3 @@
1
+ export type Disposable = {
2
+ dispose(): void
3
+ }
@@ -13,7 +13,7 @@ export type Matcher<Value> = Value extends string
13
13
  export type SentMatcher = string | number | boolean | null | { readonly regexp: string; readonly flags: string } | { readonly [key: string]: SentMatcher } | readonly SentMatcher[]
14
14
 
15
15
  export function matches(value: unknown, matcher: unknown): boolean {
16
- if (matcher instanceof RegExp) return matcher.test(String(value))
16
+ if (matcher instanceof RegExp) return value !== undefined && value !== null && matcher.test(String(value))
17
17
  if (Array.isArray(matcher)) return matcher.some((each) => matches(value, each))
18
18
  if (typeof matcher !== 'object' || matcher === null) return value === matcher
19
19
  if (Array.isArray(value)) return value.some((each) => matches(each, matcher))
@@ -20,9 +20,12 @@ export type FileSystem = {
20
20
  exists: (path: string) => Promise<boolean>
21
21
  }
22
22
 
23
- export function expandHome(path: string, home: string): string {
24
- const prefix = /^(~|\$HOME)(?=\/|$)/.exec(path)?.[0]
25
- return prefix === undefined ? path : `${home}${path.slice(prefix.length)}`
23
+ export const homePrefix = /^(~|\$HOME|\$CLAUDE_CONFIG_DIR)(?=\/|$)/
24
+
25
+ export function expandHome(path: string, home: string, configRoot = `${home}/.claude`): string {
26
+ const prefix = homePrefix.exec(path)?.[0]
27
+ if (prefix === undefined) return path
28
+ return `${prefix === '$CLAUDE_CONFIG_DIR' ? configRoot : home}${path.slice(prefix.length)}`
26
29
  }
27
30
 
28
31
  export function relativePath(base: string, path: string): string | undefined {
@@ -30,6 +30,7 @@ export type Fakes = {
30
30
  open?: Claude['ui']['open'];
31
31
  scroll?: Claude['ui']['scroll'];
32
32
  copy?: Claude['ui']['copy'];
33
+ focus?: Claude['ui']['focus'];
33
34
  };
34
35
  session: {
35
36
  messages?: Claude['session']['messages'];
@@ -1 +1 @@
1
- {"version":3,"file":"fake-claude.d.ts","sourceRoot":"","sources":["../src/testing/fake-claude.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"fake-claude.d.ts","sourceRoot":"","sources":["../src/testing/fake-claude.ts"],"names":[],"mappings":"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"}
@@ -71,6 +71,7 @@ export function fakeClaude(plugin) {
71
71
  calls.push({ call: 'ui.open', args: [pane] });
72
72
  const answer = fakes.ui.open === undefined ? { isPlaced: true } : await fakes.ui.open(pane);
73
73
  if (answer.isPlaced) {
74
+ shown.openPanes.delete(pane.id);
74
75
  shown.openPanes.add(pane.id);
75
76
  unplacedPanes.delete(pane.id);
76
77
  if (pane.focus === true)
@@ -89,11 +90,12 @@ export function fakeClaude(plugin) {
89
90
  focusedPane = undefined;
90
91
  },
91
92
  panes: async () => [
92
- ...[...shown.openPanes].map((id) => ({ id, title: id, isShown: true, isFocused: id === focusedPane, isPlaced: true })),
93
+ ...[...shown.openPanes].map((id) => ({ id, title: id, isShown: id === [...shown.openPanes].at(-1), isFocused: id === focusedPane, isPlaced: true })),
93
94
  ...[...unplacedPanes].map((id) => ({ id, title: id, isShown: false, isFocused: false, isPlaced: false })),
94
95
  ],
95
96
  scroll: rejected('ui.scroll', () => fakes.ui.scroll ?? (async () => ({}))),
96
97
  copy: rejected('ui.copy', () => fakes.ui.copy ?? (async () => ({ isCopied: true }))),
98
+ focus: rejected('ui.focus', () => fakes.ui.focus ?? (async () => ({}))),
97
99
  resolve: () => elements,
98
100
  },
99
101
  process: {
@@ -9,6 +9,8 @@ import { Skill } from '../session/context/turn/message/skill.js';
9
9
  import { ToolCall } from '../session/context/turn/message/tool-call.js';
10
10
  import { ToolResult } from '../session/context/turn/message/tool-result.js';
11
11
  import { type SessionChange, type SessionData } from '../session/conversation.js';
12
+ import type { Place } from '../runtime/before.js';
13
+ import type { Claude } from '../runtime/claude.js';
12
14
  export type MessageDraft = {
13
15
  readonly type: typeof Prompt;
14
16
  readonly text: string;
@@ -50,6 +52,8 @@ export type FakeSession = Partial<Omit<SessionData, 'id'>> & {
50
52
  export type FakeSessions = {
51
53
  answer(caller: string, method: string, input: unknown): unknown;
52
54
  add(draft: MessageDraft, task?: string): Message[];
55
+ place(): Place;
56
+ wants(mod: string, event: string): boolean;
53
57
  endTurn(): void;
54
58
  compact(): void;
55
59
  startTask(task: {
@@ -57,18 +61,21 @@ export type FakeSessions = {
57
61
  readonly agentType?: string;
58
62
  readonly model?: string;
59
63
  readonly openedBy?: string;
64
+ readonly parent?: string;
60
65
  }): string;
61
66
  start(id: string): void;
62
67
  readonly sent: {
63
68
  readonly session: string;
69
+ readonly task?: string;
64
70
  readonly type: string;
65
71
  readonly text: string;
66
72
  readonly as?: string;
67
73
  }[];
68
74
  };
69
- export declare function fakeSessions({ project, given, push }: {
75
+ export declare function fakeSessions({ project, given, push, callTool }: {
70
76
  readonly project: () => string;
71
77
  readonly given: readonly FakeSession[];
72
78
  readonly push: (mod: string, session: string, change: SessionChange) => void;
79
+ readonly callTool: Claude['tool']['call'];
73
80
  }): FakeSessions;
74
81
  //# sourceMappingURL=fake-sessions.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"fake-sessions.d.ts","sourceRoot":"","sources":["../src/testing/fake-sessions.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,6CAA6C,CAAA;AACtE,OAAO,EAAE,OAAO,EAAqC,MAAM,4CAA4C,CAAA;AACvG,OAAO,EAAE,IAAI,EAAE,MAAM,yCAAyC,CAAA;AAC9D,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAA;AAClE,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAA;AAClE,OAAO,EAAE,KAAK,EAAE,MAAM,0CAA0C,CAAA;AAChE,OAAO,EAAyB,KAAK,MAAM,EAAE,MAAM,2CAA2C,CAAA;AAE9F,OAAO,EAAE,KAAK,EAAE,MAAM,0CAA0C,CAAA;AAChE,OAAO,EAAE,QAAQ,EAAE,MAAM,8CAA8C,CAAA;AACvE,OAAO,EAAE,UAAU,EAAE,MAAM,gDAAgD,CAAA;AAC3E,OAAO,EAA2F,KAAK,aAAa,EAAE,KAAK,WAAW,EAAgC,MAAM,4BAA4B,CAAA;AAKxM,MAAM,MAAM,YAAY,GACpB;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAC/E;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,IAAI,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAC7E;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,KAAK,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACtD;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,QAAQ,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,GACvI;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,UAAU,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAChH;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,KAAK,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC7E;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,QAAQ,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAChF;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAA;AAE3D,MAAM,MAAM,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC,GAAG;IAC3D,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,YAAY,EAAE,CAAA;IAC3C,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CACvB,CAAA;AAcD,MAAM,MAAM,YAAY,GAAG;IACzB,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAA;IAC/D,GAAG,CAAC,KAAK,EAAE,YAAY,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,EAAE,CAAA;IAClD,OAAO,IAAI,IAAI,CAAA;IACf,OAAO,IAAI,IAAI,CAAA;IACf,SAAS,CAAC,IAAI,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAA;IACrI,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,QAAQ,CAAC,IAAI,EAAE;QAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAClH,CAAA;AAED,wBAAgB,YAAY,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE;IAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,MAAM,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,SAAS,WAAW,EAAE,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,KAAK,IAAI,CAAA;CAAE,GAAG,YAAY,CAqI7N"}
1
+ {"version":3,"file":"fake-sessions.d.ts","sourceRoot":"","sources":["../src/testing/fake-sessions.ts"],"names":[],"mappings":"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"}