@cmodjs/core 0.3.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 (280) hide show
  1. package/docs/commands.md +61 -10
  2. package/docs/dependencies.md +16 -14
  3. package/docs/hooks.md +64 -29
  4. package/docs/index.md +29 -25
  5. package/docs/install-steps.md +13 -9
  6. package/docs/jobs.md +59 -111
  7. package/docs/mod.md +227 -175
  8. package/docs/options.md +2 -2
  9. package/docs/permissions.md +27 -16
  10. package/docs/session.md +311 -0
  11. package/docs/state.md +11 -7
  12. package/docs/testing.md +68 -23
  13. package/docs/ui.md +116 -76
  14. package/jobs/check.d.ts.map +1 -1
  15. package/jobs/check.js +5 -2
  16. package/jobs/context.d.ts +7 -8
  17. package/jobs/context.d.ts.map +1 -1
  18. package/jobs/context.js +23 -73
  19. package/jobs/permissions/decide-permission.d.ts +5 -3
  20. package/jobs/permissions/decide-permission.d.ts.map +1 -1
  21. package/jobs/permissions/decide-permission.js +6 -4
  22. package/jobs/permissions/find-project-scope.d.ts +1 -1
  23. package/jobs/permissions/find-project-scope.d.ts.map +1 -1
  24. package/jobs/permissions/find-project-scope.js +8 -11
  25. package/jobs/permissions/match-target.d.ts +2 -2
  26. package/jobs/permissions/match-target.d.ts.map +1 -1
  27. package/jobs/permissions.d.ts +1 -1
  28. package/jobs/permissions.d.ts.map +1 -1
  29. package/jobs/permissions.js +6 -4
  30. package/jobs/program.d.ts.map +1 -1
  31. package/jobs/program.js +14 -4
  32. package/jobs/prompt.d.ts +4 -2
  33. package/jobs/prompt.d.ts.map +1 -1
  34. package/jobs/prompt.js +16 -15
  35. package/jobs/schedule.d.ts +2 -2
  36. package/jobs/schedule.d.ts.map +1 -1
  37. package/jobs/schedule.js +9 -9
  38. package/jobs/slash-command.d.ts +4 -4
  39. package/jobs/slash-command.d.ts.map +1 -1
  40. package/jobs/slash-command.js +14 -9
  41. package/jobs/status-line.d.ts +1 -11
  42. package/jobs/status-line.d.ts.map +1 -1
  43. package/jobs/status-line.js +8 -12
  44. package/jobs/tool.d.ts +2 -2
  45. package/jobs/tool.d.ts.map +1 -1
  46. package/jobs/tool.js +15 -9
  47. package/mod.d.ts +178 -75
  48. package/mod.d.ts.map +1 -1
  49. package/mod.js +127 -3
  50. package/options.d.ts +2 -2
  51. package/options.js +19 -19
  52. package/package.json +3 -3
  53. package/records.d.ts +13 -8
  54. package/records.d.ts.map +1 -1
  55. package/records.js +33 -17
  56. package/register.d.ts.map +1 -1
  57. package/register.js +34 -5
  58. package/runtime/announce.d.ts +18 -0
  59. package/runtime/announce.d.ts.map +1 -0
  60. package/runtime/announce.js +68 -0
  61. package/runtime/before.d.ts +32 -0
  62. package/runtime/before.d.ts.map +1 -0
  63. package/runtime/before.js +214 -0
  64. package/runtime/bound.d.ts +4 -0
  65. package/runtime/bound.d.ts.map +1 -0
  66. package/runtime/bound.js +9 -0
  67. package/runtime/claude.d.ts +4 -2
  68. package/runtime/claude.d.ts.map +1 -1
  69. package/runtime/conversations.d.ts +17 -0
  70. package/runtime/conversations.d.ts.map +1 -0
  71. package/runtime/conversations.js +563 -0
  72. package/runtime/deadline.d.ts +0 -1
  73. package/runtime/deadline.d.ts.map +1 -1
  74. package/runtime/deadline.js +1 -1
  75. package/runtime/dependencies.d.ts +2 -0
  76. package/runtime/dependencies.d.ts.map +1 -1
  77. package/runtime/dependencies.js +10 -1
  78. package/runtime/engine.d.ts +13 -0
  79. package/runtime/engine.d.ts.map +1 -0
  80. package/runtime/engine.js +10 -0
  81. package/runtime/grants.d.ts +4 -0
  82. package/runtime/grants.d.ts.map +1 -1
  83. package/runtime/grants.js +68 -31
  84. package/runtime/hooks.d.ts +5 -4
  85. package/runtime/hooks.d.ts.map +1 -1
  86. package/runtime/hooks.js +29 -9
  87. package/runtime/installer.d.ts +1 -1
  88. package/runtime/installer.d.ts.map +1 -1
  89. package/runtime/installer.js +17 -14
  90. package/runtime/lifecycle.d.ts +3 -1
  91. package/runtime/lifecycle.d.ts.map +1 -1
  92. package/runtime/lifecycle.js +414 -212
  93. package/runtime/metadata.d.ts +3 -1
  94. package/runtime/metadata.d.ts.map +1 -1
  95. package/runtime/metadata.js +25 -14
  96. package/runtime/options.d.ts +3 -1
  97. package/runtime/options.d.ts.map +1 -1
  98. package/runtime/options.js +4 -4
  99. package/runtime/programs.d.ts +8 -0
  100. package/runtime/programs.d.ts.map +1 -1
  101. package/runtime/programs.js +39 -1
  102. package/runtime/router.d.ts +2 -1
  103. package/runtime/router.d.ts.map +1 -1
  104. package/runtime/router.js +11 -3
  105. package/runtime/sessions.d.ts +58 -0
  106. package/runtime/sessions.d.ts.map +1 -0
  107. package/runtime/sessions.js +433 -0
  108. package/runtime/state.d.ts +6 -2
  109. package/runtime/state.d.ts.map +1 -1
  110. package/runtime/state.js +46 -13
  111. package/runtime/toaster.d.ts +24 -0
  112. package/runtime/toaster.d.ts.map +1 -0
  113. package/runtime/toaster.js +286 -0
  114. package/runtime/ui.d.ts +6 -17
  115. package/runtime/ui.d.ts.map +1 -1
  116. package/runtime/ui.js +62 -118
  117. package/session/context/context.d.ts +12 -0
  118. package/session/context/context.d.ts.map +1 -0
  119. package/session/context/context.js +2 -0
  120. package/session/context/turn/message/document.d.ts +7 -0
  121. package/session/context/turn/message/document.d.ts.map +1 -0
  122. package/session/context/turn/message/document.js +1 -0
  123. package/session/context/turn/message/message.d.ts +55 -0
  124. package/session/context/turn/message/message.d.ts.map +1 -0
  125. package/session/context/turn/message/message.js +178 -0
  126. package/session/context/turn/message/note.d.ts +8 -0
  127. package/session/context/turn/message/note.d.ts.map +1 -0
  128. package/session/context/turn/message/note.js +1 -0
  129. package/session/context/turn/message/notice.d.ts +6 -0
  130. package/session/context/turn/message/notice.d.ts.map +1 -0
  131. package/session/context/turn/message/notice.js +1 -0
  132. package/session/context/turn/message/prompt.d.ts +8 -0
  133. package/session/context/turn/message/prompt.d.ts.map +1 -0
  134. package/session/context/turn/message/prompt.js +1 -0
  135. package/session/context/turn/message/reply.d.ts +6 -0
  136. package/session/context/turn/message/reply.d.ts.map +1 -0
  137. package/session/context/turn/message/reply.js +1 -0
  138. package/session/context/turn/message/sender.d.ts +14 -0
  139. package/session/context/turn/message/sender.d.ts.map +1 -0
  140. package/session/context/turn/message/sender.js +30 -0
  141. package/session/context/turn/message/skill.d.ts +7 -0
  142. package/session/context/turn/message/skill.d.ts.map +1 -0
  143. package/session/context/turn/message/skill.js +1 -0
  144. package/session/context/turn/message/tool-call.d.ts +9 -0
  145. package/session/context/turn/message/tool-call.d.ts.map +1 -0
  146. package/session/context/turn/message/tool-call.js +1 -0
  147. package/session/context/turn/message/tool-result.d.ts +8 -0
  148. package/session/context/turn/message/tool-result.d.ts.map +1 -0
  149. package/session/context/turn/message/tool-result.js +1 -0
  150. package/session/context/turn/turn.d.ts +13 -0
  151. package/session/context/turn/turn.d.ts.map +1 -0
  152. package/session/context/turn/turn.js +2 -0
  153. package/session/conversation.d.ts +73 -0
  154. package/session/conversation.d.ts.map +1 -0
  155. package/session/conversation.js +146 -0
  156. package/session/goal/goal.d.ts +11 -0
  157. package/session/goal/goal.d.ts.map +1 -0
  158. package/session/goal/goal.js +1 -0
  159. package/session/goal/task.d.ts +16 -0
  160. package/session/goal/task.d.ts.map +1 -0
  161. package/session/goal/task.js +3 -0
  162. package/session/session.d.ts +158 -0
  163. package/session/session.d.ts.map +1 -0
  164. package/session/session.js +2 -0
  165. package/src/jobs/check.ts +7 -4
  166. package/src/jobs/context.ts +24 -82
  167. package/src/jobs/permissions/decide-permission.ts +10 -8
  168. package/src/jobs/permissions/find-project-scope.ts +8 -14
  169. package/src/jobs/permissions/match-target.ts +3 -3
  170. package/src/jobs/permissions.ts +8 -6
  171. package/src/jobs/program.ts +13 -4
  172. package/src/jobs/prompt.ts +21 -24
  173. package/src/jobs/schedule.ts +14 -14
  174. package/src/jobs/slash-command.ts +20 -14
  175. package/src/jobs/status-line.ts +9 -21
  176. package/src/jobs/tool.ts +17 -11
  177. package/src/mod.ts +252 -97
  178. package/src/options.ts +22 -22
  179. package/src/records.ts +40 -23
  180. package/src/register.ts +34 -5
  181. package/src/runtime/announce.ts +76 -0
  182. package/src/runtime/before.ts +200 -0
  183. package/src/runtime/bound.ts +12 -0
  184. package/src/runtime/claude.ts +7 -2
  185. package/src/runtime/conversations.ts +579 -0
  186. package/src/runtime/deadline.ts +2 -2
  187. package/src/runtime/dependencies.ts +13 -1
  188. package/src/runtime/engine.ts +23 -0
  189. package/src/runtime/grants.ts +69 -29
  190. package/src/runtime/hooks.ts +39 -14
  191. package/src/runtime/installer.ts +16 -14
  192. package/src/runtime/lifecycle.ts +413 -201
  193. package/src/runtime/metadata.ts +37 -14
  194. package/src/runtime/options.ts +19 -4
  195. package/src/runtime/programs.ts +40 -1
  196. package/src/runtime/router.ts +11 -4
  197. package/src/runtime/sessions.ts +447 -0
  198. package/src/runtime/state.ts +50 -14
  199. package/src/runtime/toaster.ts +334 -0
  200. package/src/runtime/ui.ts +65 -143
  201. package/src/session/context/context.ts +14 -0
  202. package/src/session/context/turn/message/document.ts +8 -0
  203. package/src/session/context/turn/message/message.ts +203 -0
  204. package/src/session/context/turn/message/note.ts +9 -0
  205. package/src/session/context/turn/message/notice.ts +7 -0
  206. package/src/session/context/turn/message/prompt.ts +9 -0
  207. package/src/session/context/turn/message/reply.ts +7 -0
  208. package/src/session/context/turn/message/sender.ts +38 -0
  209. package/src/session/context/turn/message/skill.ts +8 -0
  210. package/src/session/context/turn/message/tool-call.ts +10 -0
  211. package/src/session/context/turn/message/tool-result.ts +9 -0
  212. package/src/session/context/turn/turn.ts +15 -0
  213. package/src/session/conversation.ts +194 -0
  214. package/src/session/goal/goal.ts +10 -0
  215. package/src/session/goal/task.ts +19 -0
  216. package/src/session/session.ts +141 -0
  217. package/src/testing/fake-claude.ts +25 -17
  218. package/src/testing/fake-elements.ts +3 -2
  219. package/src/testing/fake-files.ts +1 -1
  220. package/src/testing/fake-sessions.ts +277 -0
  221. package/src/testing.ts +138 -35
  222. package/src/ui/components.ts +3 -3
  223. package/src/ui/elements.ts +22 -8
  224. package/src/ui/markdown.ts +22 -26
  225. package/src/ui/places.ts +49 -0
  226. package/src/utils/call-effects.ts +5 -5
  227. package/src/utils/changelog.ts +32 -0
  228. package/src/utils/disposable.ts +3 -0
  229. package/src/utils/matcher.ts +37 -0
  230. package/src/utils/metadata.ts +17 -15
  231. package/src/utils/parse-shell.ts +9 -8
  232. package/src/utils/paths.ts +21 -4
  233. package/src/utils/text.ts +4 -0
  234. package/testing/fake-claude.d.ts +5 -1
  235. package/testing/fake-claude.d.ts.map +1 -1
  236. package/testing/fake-claude.js +21 -12
  237. package/testing/fake-elements.js +4 -2
  238. package/testing/fake-files.js +1 -1
  239. package/testing/fake-sessions.d.ts +81 -0
  240. package/testing/fake-sessions.d.ts.map +1 -0
  241. package/testing/fake-sessions.js +249 -0
  242. package/testing.d.ts +34 -13
  243. package/testing.d.ts.map +1 -1
  244. package/testing.js +115 -23
  245. package/ui/components.d.ts +2 -3
  246. package/ui/components.d.ts.map +1 -1
  247. package/ui/components.js +3 -3
  248. package/ui/elements.d.ts.map +1 -1
  249. package/ui/elements.js +19 -9
  250. package/ui/markdown.d.ts +35 -33
  251. package/ui/markdown.d.ts.map +1 -1
  252. package/ui/markdown.js +14 -16
  253. package/ui/places.d.ts +43 -0
  254. package/ui/places.d.ts.map +1 -0
  255. package/ui/places.js +31 -0
  256. package/utils/call-effects.d.ts +2 -1
  257. package/utils/call-effects.d.ts.map +1 -1
  258. package/utils/call-effects.js +3 -3
  259. package/utils/changelog.d.ts +9 -0
  260. package/utils/changelog.d.ts.map +1 -0
  261. package/utils/changelog.js +29 -0
  262. package/utils/disposable.d.ts +4 -0
  263. package/utils/disposable.d.ts.map +1 -0
  264. package/utils/disposable.js +1 -0
  265. package/utils/matcher.d.ts +13 -0
  266. package/utils/matcher.d.ts.map +1 -0
  267. package/utils/matcher.js +32 -0
  268. package/utils/metadata.d.ts +3 -3
  269. package/utils/metadata.js +18 -14
  270. package/utils/parse-shell.js +9 -7
  271. package/utils/paths.d.ts +4 -1
  272. package/utils/paths.d.ts.map +1 -1
  273. package/utils/paths.js +19 -4
  274. package/utils/text.d.ts +1 -0
  275. package/utils/text.d.ts.map +1 -1
  276. package/utils/text.js +3 -0
  277. package/src/ui/slots.ts +0 -36
  278. package/ui/slots.d.ts +0 -208
  279. package/ui/slots.d.ts.map +0 -1
  280. package/ui/slots.js +0 -19
@@ -0,0 +1,447 @@
1
+ import type { Disposable } from '../mod.js'
2
+ import { ContextEnd, ContextStart, type Context } from '../session/context/context.js'
3
+ import { Message } from '../session/context/turn/message/message.js'
4
+ import { Note } from '../session/context/turn/message/note.js'
5
+ import { Person } from '../session/context/turn/message/sender.js'
6
+ import { ToolResult } from '../session/context/turn/message/tool-result.js'
7
+ import { TurnEnd, TurnStart, type Turn } from '../session/context/turn/turn.js'
8
+ import type { ContextData, GoalData, SessionChange, SessionData, TaskData, TurnData } from '../session/conversation.js'
9
+ import { GoalChange, type Goal } from '../session/goal/goal.js'
10
+ import { TaskEnd, TaskStart, type Task } from '../session/goal/task.js'
11
+ import { Session, type BeforeEvent, type Client, type RecordPart, type SearchOptions, type SessionEvent, type SessionScope, type SessionSend, type Sessions, type TaskStartArgs, type ToolCallArgs, type Usage } from '../session/session.js'
12
+ import { matches, sentMatcher, type SentMatcher } from '../utils/matcher.js'
13
+ import { foldAnswers, type Answers, type Place } from './before.js'
14
+ import type { Claude } from './claude.js'
15
+ import { cmodPluginName } from './dependencies.js'
16
+
17
+ export type Subscription = { readonly event: SessionEvent; readonly matcher: SentMatcher | undefined }
18
+
19
+ export const everySession = '*'
20
+
21
+ export const conversationMethods = {
22
+ sessions: 'cmod:sessions',
23
+ search: 'cmod:search',
24
+ messages: 'cmod:messages',
25
+ turns: 'cmod:turns',
26
+ contexts: 'cmod:contexts',
27
+ tasks: 'cmod:tasks',
28
+ goal: 'cmod:goal',
29
+ updateGoal: 'cmod:updateGoal',
30
+ usage: 'cmod:usage',
31
+ subscribe: 'cmod:subscribe',
32
+ watchBefore: 'cmod:watchBefore',
33
+ act: 'cmod:act',
34
+ changed: 'cmod:changed',
35
+ before: 'cmod:before',
36
+ } as const
37
+
38
+ export type Act = 'send' | 'startTask' | 'callTool' | 'usage' | 'clients'
39
+
40
+ const actPermissions: Readonly<Record<Exclude<Act, 'callTool'>, string>> = { send: 'prompt', startTask: 'tasks', usage: 'conversation', clients: 'conversation' }
41
+
42
+ const startsTurns: readonly string[] = ['CronCreate']
43
+
44
+ const ownSchedule: readonly string[] = ['CronDelete', 'CronList']
45
+
46
+ export const beforePermission = (event: BeforeEvent): string => (event === 'toolCall' ? 'tools' : event === TaskStart || event === TaskEnd ? 'tasks' : 'prompt')
47
+
48
+ export const isAct =(action: string): action is Act => action === 'callTool' || action in actPermissions
49
+
50
+ export function toolPermission(tool: string): string | undefined {
51
+ return startsTurns.includes(tool) ? 'prompt' : ownSchedule.includes(tool) ? undefined : 'tools'
52
+ }
53
+
54
+ export function actPermission(action: Act, input: unknown): string | undefined {
55
+ return action === 'callTool' ? (toolPermission(String((input as Partial<ToolCallArgs> | null)?.name)) ?? 'tools') : actPermissions[action]
56
+ }
57
+
58
+ export type Scope = { readonly session?: string; readonly context?: number; readonly turn?: string; readonly task?: string; readonly isGoal?: true }
59
+
60
+ export type SessionsClient = {
61
+ readonly sessions: Sessions
62
+ changed(session: string, change: SessionChange, isInProject: boolean): void
63
+ answerBefore(session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean): Promise<unknown>
64
+ /** An empty title keeps the title the session already has. */
65
+ follow(current: Omit<SessionData, 'isRunning' | 'lastAt'>): void
66
+ }
67
+
68
+ type Listening = { scope: Scope; readonly event: SessionEvent; readonly matcher: unknown; readonly listener: (payload: never) => unknown }
69
+
70
+ type Owned = { readonly session: string; readonly task: string }
71
+
72
+ const asked = (action: string) => `cmod: ${action} runs only on a session's own main loop, and Claude Code gives a subagent's tasks no way to start a task or run a tool.`
73
+
74
+ export async function usageOf(claude: Pick<Claude, 'session'>): Promise<Usage> {
75
+ const [model, usage] = await Promise.all([claude.session.model(), claude.session.usage()])
76
+ const { tokens, window, percent } = usage.context
77
+ return { model, context: { ...(tokens === undefined ? {} : { tokens }), window, ...(percent === undefined ? {} : { percent }) }, ...(usage.cost === undefined ? {} : { cost: { usd: usage.cost.usd } }) }
78
+ }
79
+
80
+ export function payloadOf(change: SessionChange): unknown {
81
+ if (change.type === Message) return change.message
82
+ if ('turn' in change) return change.turn
83
+ if ('context' in change) return change.context
84
+ if ('task' in change) {
85
+ const { id, title, agentType, model, openedBy, openedAt, closedAt } = change.task
86
+ return { id, title, openedBy, openedAt, ...(closedAt === undefined ? {} : { closedAt }), owner: { id, title, agentType, ...(model === undefined ? {} : { model }), startedAt: openedAt } }
87
+ }
88
+ if ('usage' in change) return change.usage
89
+ return change.goal
90
+ }
91
+
92
+ export function isHeardBy(event: SessionEvent, matcher: unknown, change: SessionChange): boolean {
93
+ const named = change.type === Message ? event === Message || event === change.message.type : event === change.type
94
+ return named && (matcher === undefined || matches(payloadOf(change), matcher))
95
+ }
96
+
97
+ function taskOfChange(change: SessionChange): string | undefined {
98
+ if (change.type === Message) return change.message.task
99
+ if (change.type === TaskStart || change.type === TaskEnd) return change.task.id === '' ? change.task.parent : change.task.id
100
+ if (change.type === TurnStart || change.type === TurnEnd) return change.turn.prompt.task
101
+ if (change.type === ContextStart || change.type === ContextEnd) return change.context.task
102
+ if (change.type === GoalChange) return change.goal.task
103
+ return undefined
104
+ }
105
+
106
+ function ownerOfChange(change: SessionChange): string | undefined {
107
+ if (change.type === TaskStart || change.type === TaskEnd) return change.task.parent
108
+ return taskOfChange(change)
109
+ }
110
+
111
+ function contextOfChange(change: SessionChange): number | undefined {
112
+ if (change.type === Message) return change.message.context
113
+ if (change.type === TurnStart || change.type === TurnEnd) return change.turn.context
114
+ if (change.type === ContextStart || change.type === ContextEnd) return change.context.number
115
+ if (change.type === TaskStart || change.type === TaskEnd) return change.task.context
116
+ return undefined
117
+ }
118
+
119
+ export function isInScope(scope: Scope, session: string, change: SessionChange, isUnder: (task: string, ancestor: string) => boolean): boolean {
120
+ if (scope.session !== undefined && scope.session !== session) return false
121
+ const task = taskOfChange(change)
122
+ if (scope.task !== undefined && (task === undefined || !isUnder(task, scope.task))) return false
123
+ if (scope.isGoal === true && change.type !== GoalChange && change.type !== TaskStart && (task === undefined || task === scope.task)) return false
124
+ if (scope.context !== undefined && (contextOfChange(change) !== scope.context || ownerOfChange(change) !== scope.task)) return false
125
+ if (scope.turn === undefined) return true
126
+ if (change.type === Message) return change.message.turn === scope.turn
127
+ return (change.type === TurnStart || change.type === TurnEnd) && change.turn.prompt.id === scope.turn
128
+ }
129
+
130
+ export function createSessionsClient({ name, claude, report, isGranted }: { readonly name: string; readonly claude: Claude; readonly report: (id: string, message: string, error: unknown) => void; readonly isGranted: (permission: string) => boolean }): SessionsClient {
131
+ const call = <Answer>(method: string, input: unknown) => claude.cmod.call({ to: cmodPluginName, method, input }) as Promise<Answer>
132
+ const listening = new Map<string, Set<Listening>>()
133
+ const deciding = new Map<string, Set<Listening>>()
134
+ const queued = new Set<string>()
135
+ const parents = new Map<string, Map<string, string | undefined>>()
136
+ const known = new Map<string, SessionData>()
137
+ let current: SessionData = { id: '', title: '', project: '', cwd: '', model: '', startedAt: '', lastAt: '', isRunning: true }
138
+
139
+ const keyOf = (scope: Scope) => scope.session ?? everySession
140
+
141
+ const subscribe = (key: string) => {
142
+ if (queued.has(key)) return
143
+ queued.add(key)
144
+ void Promise.resolve().then(async () => {
145
+ queued.delete(key)
146
+ const heard = (from: Map<string, Set<Listening>>): Subscription[] => [...(from.get(key) ?? [])].map(({ event, matcher }) => ({ event, matcher: matcher === undefined ? undefined : sentMatcher(matcher) }))
147
+ const tracksTasks = [...(listening.get(key) ?? []), ...(deciding.get(key) ?? [])].some(({ scope }) => scope.task !== undefined || scope.isGoal === true)
148
+ const subscriptions = [...heard(listening), ...(tracksTasks ? [{ event: TaskStart }] : [])]
149
+ await call(conversationMethods.subscribe, { session: key, subscriptions }).catch((error: unknown) => report(`sessions:${key}`, `Hears nothing from ${key === everySession ? 'the other sessions' : `session ${key}`}`, error))
150
+ await call(conversationMethods.watchBefore, { session: key, subscriptions: heard(deciding) }).catch((error: unknown) => report(`before:${key}`, `Changes nothing in ${key === everySession ? 'the other sessions' : `session ${key}`}`, error))
151
+ })
152
+ }
153
+
154
+ const learnTasks = async (session: string) => {
155
+ if (parents.has(session)) return
156
+ const tree = new Map<string, string | undefined>()
157
+ parents.set(session, tree)
158
+ for (const task of await call<TaskData[]>(conversationMethods.tasks, { session, all: true })) tree.set(task.id, task.parent)
159
+ }
160
+
161
+ const isUnderIn = (session: string) => (task: string, ancestor: string) => {
162
+ const tree = parents.get(session)
163
+ for (let at: string | undefined = task, steps = 0; at !== undefined && steps < 64; at = tree?.get(at), steps += 1) if (at === ancestor) return true
164
+ return false
165
+ }
166
+
167
+ const remember = (session: string, change: SessionChange) => {
168
+ if (change.type === TaskStart) parents.get(session)?.set(change.task.id, change.task.parent)
169
+ }
170
+
171
+ const add = (into: Map<string, Set<Listening>>, scope: Scope, event: SessionEvent, matcherOrListener: unknown, maybeListener: unknown, onChange: (key: string) => void): Disposable => {
172
+ const listener = (maybeListener ?? matcherOrListener) as (payload: never) => unknown
173
+ const matcher = maybeListener === undefined ? undefined : matcherOrListener
174
+ const key = keyOf(scope)
175
+ const entry: Listening = { scope, event, matcher, listener }
176
+ const set = into.get(key) ?? new Set()
177
+ set.add(entry)
178
+ into.set(key, set)
179
+ if (scope.task !== undefined || scope.isGoal === true) void learnTasks(scope.session ?? current.id).catch((error: unknown) => report(`tasks:${key}`, 'Could not read the tasks it listens under', error))
180
+ onChange(key)
181
+ return {
182
+ dispose() {
183
+ for (const [at, held] of into) {
184
+ if (!held.delete(entry)) continue
185
+ if (held.size === 0) into.delete(at)
186
+ onChange(at)
187
+ return
188
+ }
189
+ },
190
+ }
191
+ }
192
+
193
+
194
+
195
+ const combined = async (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean): Promise<Partial<Answers> | undefined> => {
196
+ const askers = [...(deciding.get(session) ?? []), ...(isInProject ? (deciding.get(everySession) ?? []) : [])]
197
+ .filter((entry) => entry.event === event)
198
+ .map((entry) => async (latest: SessionChange) => {
199
+ if (!isInScope(entry.scope, session, latest, isUnderIn(session))) return undefined
200
+ if (entry.matcher !== undefined && !matches(payloadOf(latest), entry.matcher)) return undefined
201
+ const answer = await entry.listener(payloadFor(session, latest) as never)
202
+ if (answer === undefined || isGranted(beforePermission(event))) return answer
203
+ throw new Error(`Its before(${event}) answer changes nothing until ${beforePermission(event)} is turned on for ${name} in /mods.`)
204
+ })
205
+ return foldAnswers(event, change, askers, (error) => report(`before:${event}`, `The before(${event}) listener failed`, error))
206
+ }
207
+
208
+ const recordPart = (scope: Scope): RecordPart => ({
209
+ search: (text: string, options: SearchOptions = {}) => call(conversationMethods.search, { text, ...scope, ...optionsOf(options) }),
210
+ on: (event: SessionEvent, matcherOrListener: unknown, maybeListener?: unknown) => add(listening, scope, event, matcherOrListener, maybeListener, subscribe),
211
+ before: (event: BeforeEvent, matcherOrListener: unknown, maybeListener?: unknown) => add(deciding, scope, event, matcherOrListener, maybeListener, subscribe),
212
+ }) as RecordPart
213
+
214
+ const turnOf = (scope: Scope, data: TurnData): Turn => {
215
+ const task = data.prompt.task ?? scope.task
216
+ return {
217
+ ...data,
218
+ ...recordPart({ ...scope, ...(task === undefined ? {} : { task }), turn: data.prompt.id }),
219
+ messages: () => call<Message[]>(conversationMethods.messages, { session: scope.session, turn: data.prompt.id, ...(task === undefined ? {} : { task }) }),
220
+ }
221
+ }
222
+
223
+ const contextOf = (scope: Scope, data: ContextData): Context => {
224
+ const owned: Scope = { ...(scope.session === undefined ? {} : { session: scope.session }), ...(data.task === undefined ? {} : { task: data.task }) }
225
+ return {
226
+ ...data,
227
+ ...recordPart({ ...owned, context: data.number }),
228
+ turns: async () => (await call<TurnData[]>(conversationMethods.turns, { ...owned, context: data.number })).map((turn) => turnOf(owned, turn)),
229
+ }
230
+ }
231
+
232
+ const taskOf = (session: string, data: TaskData): Task => ({
233
+ id: data.id,
234
+ title: data.title,
235
+ openedBy: data.openedBy,
236
+ openedAt: data.openedAt,
237
+ ...(data.closedAt === undefined ? {} : { closedAt: data.closedAt }),
238
+ owner: ownerOf(session, data),
239
+ ...recordPart({ session, task: data.id }),
240
+ tasks: async () => (await call<TaskData[]>(conversationMethods.tasks, { session, task: data.id })).map((task) => taskOf(session, task)),
241
+ messages: () => call<Message[]>(conversationMethods.messages, { session, task: data.id }),
242
+ })
243
+
244
+ const goalOf = (scope: Scope, data: GoalData): Goal => ({
245
+ ...data,
246
+ ...recordPart({ ...scope, isGoal: true }),
247
+ tasks: async () => (await call<TaskData[]>(conversationMethods.tasks, { session: scope.session, ...(scope.task === undefined ? {} : { task: scope.task }) })).map((task) => taskOf(scope.session ?? current.id, task)),
248
+ update: async ({ text }) => {
249
+ if (text.trim() === '') throw new Error(`${name}: a goal needs text.`)
250
+ await call(conversationMethods.updateGoal, { session: scope.session ?? current.id, ...(scope.task === undefined ? {} : { task: scope.task }), text })
251
+ },
252
+ })
253
+
254
+ const payloadFor = (session: string, change: SessionChange): unknown => {
255
+ const scope = { session }
256
+ if (change.type === Message) return change.message
257
+ if ('turn' in change) return turnOf(scope, change.turn)
258
+ if ('context' in change) return contextOf(scope, change.context)
259
+ if ('task' in change) return taskOf(session, change.task)
260
+ if ('usage' in change) return change.usage
261
+ return goalOf({ session, ...(change.goal.task === undefined ? {} : { task: change.goal.task }) }, { text: change.goal.text })
262
+ }
263
+
264
+ const act = <Answer>(session: string, action: Act, input: unknown, owned?: Owned) => call<Answer>(conversationMethods.act, { session: owned?.session ?? session, action, input, ...(owned === undefined ? {} : { task: owned.task }) })
265
+
266
+ const sendHere = async ({ type, text, as }: SessionSend) => {
267
+ if (type === Note) {
268
+ if (as !== undefined) throw new Error(`${name}: a Note is always from this mod, so it takes no as.`)
269
+ const added = await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
270
+ if (added.deny !== undefined) throw new Error(`${name}: Claude Code refused the note: ${added.deny}`)
271
+ return
272
+ }
273
+ const submitted = await claude.prompt.submit({ text, ...(as === Person ? { asUser: true } : {}) })
274
+ if ('drop' in submitted && typeof submitted.drop === 'string') throw new Error(`${name}: Claude Code dropped the prompt: ${submitted.drop}`)
275
+ }
276
+
277
+ const startHere = async (task: TaskStartArgs): Promise<Task> => {
278
+ const started = await claude.agent.spawn({
279
+ prompt: task.prompt,
280
+ ...(task.name === undefined ? {} : { description: task.name, name: task.name }),
281
+ ...(task.agentType === undefined ? {} : { subagentType: task.agentType }),
282
+ ...(task.model === undefined ? {} : { model: task.model }),
283
+ ...(task.cwd === undefined ? {} : { cwd: task.cwd }),
284
+ })
285
+ if (started.deny !== undefined) throw new Error(`${name}: Claude Code refused the task: ${started.deny}`)
286
+ return taskOf(current.id, { id: started.agentId ?? '', title: task.name ?? '', agentType: task.agentType ?? 'general-purpose', model: started.model, openedBy: '', openedAt: new Date().toISOString() })
287
+ }
288
+
289
+ const clientsHere = async (): Promise<Client[]> => [...(await claude.session.surfaces())]
290
+
291
+ const sessionOf = (read: () => SessionData, owned?: Owned) => {
292
+ const id = () => read().id
293
+ const isHere = () => owned === undefined && id() === current.id
294
+ const scope = (): Scope => (owned === undefined ? { session: id() } : { session: owned.session, task: owned.task })
295
+ const where = () => (owned === undefined ? { session: id() } : { session: owned.session, task: owned.task })
296
+ const turnsOf = async (options: { readonly last?: number } = {}): Promise<Turn[]> => (await call<TurnData[]>(conversationMethods.turns, { ...where(), ...options })).map((turn) => turnOf(scope(), turn))
297
+ const contextsOf = async (): Promise<Context[]> => (await call<ContextData[]>(conversationMethods.contexts, where())).map((context) => contextOf(scope(), context))
298
+ const session = {
299
+ get id() {
300
+ return read().id
301
+ },
302
+ get title() {
303
+ return read().title
304
+ },
305
+ get project() {
306
+ return read().project
307
+ },
308
+ get agentType() {
309
+ return read().agentType
310
+ },
311
+ get model() {
312
+ return read().model
313
+ },
314
+ get cwd() {
315
+ return read().cwd
316
+ },
317
+ get isRunning() {
318
+ return read().isRunning
319
+ },
320
+ get startedAt() {
321
+ return read().startedAt
322
+ },
323
+ get lastAt() {
324
+ return read().lastAt
325
+ },
326
+ get forkedFrom() {
327
+ return read().forkedFrom
328
+ },
329
+ search: (text: string, options: SearchOptions = {}) => recordPart(scope()).search(text, options),
330
+ on: (event: SessionEvent, matcherOrListener: unknown, maybeListener?: unknown) => (recordPart(scope()).on as (...args: unknown[]) => Disposable)(event, matcherOrListener, maybeListener),
331
+ before: (event: BeforeEvent, matcherOrListener: unknown, maybeListener?: unknown) => (recordPart(scope()).before as (...args: unknown[]) => Disposable)(event, matcherOrListener, maybeListener),
332
+ goal: async () => goalOf(scope(), await call<GoalData>(conversationMethods.goal, where())),
333
+ context: async () => {
334
+ const all = await contextsOf()
335
+ const last = all.at(-1)
336
+ if (last === undefined) throw new Error(`${name}: session ${id()} has no context yet.`)
337
+ return last
338
+ },
339
+ contexts: contextsOf,
340
+ turn: async () => {
341
+ const [last] = await turnsOf({ last: 1 })
342
+ return last?.closedAt === undefined ? last : undefined
343
+ },
344
+ turns: turnsOf,
345
+ messages: (options: { readonly after?: string; readonly last?: number } = {}) => call<Message[]>(conversationMethods.messages, { ...where(), ...options }),
346
+ usage: async (): Promise<Usage> => (isHere() ? usageOf(claude) : act<Usage>(id(), 'usage', null, owned)),
347
+ clients: async (): Promise<Client[]> => (isHere() ? clientsHere() : act<Client[]>(id(), 'clients', null, owned)),
348
+ async send(message: SessionSend) {
349
+ if (message.text.trim() === '') throw new Error(`${name}: send needs text.`)
350
+ if (isHere() && message.as !== Session) return sendHere(message)
351
+ await act(id(), 'send', message, owned)
352
+ },
353
+ async startTask(task: TaskStartArgs): Promise<Task> {
354
+ if (owned !== undefined) throw new Error(asked('startTask'))
355
+ if (isHere()) return startHere(task)
356
+ const started = await act<TaskData>(id(), 'startTask', task)
357
+ return taskOf(id(), started)
358
+ },
359
+ async callTool(toolCall: ToolCallArgs): Promise<ToolResult> {
360
+ if (owned !== undefined) throw new Error(asked('callTool'))
361
+ return act<ToolResult>(id(), 'callTool', toolCall)
362
+ },
363
+ }
364
+ return session as unknown as Sessions['current']
365
+ }
366
+
367
+ const ownerOf = (session: string, data: TaskData): Sessions['current'] => {
368
+ const parent = session === current.id ? current : known.get(session)
369
+ return sessionOf(
370
+ () => ({
371
+ id: data.id,
372
+ title: data.title,
373
+ project: parent?.project ?? '',
374
+ agentType: data.agentType,
375
+ model: data.model ?? '',
376
+ cwd: parent?.cwd ?? '',
377
+ isRunning: data.closedAt === undefined && (parent?.isRunning ?? true),
378
+ startedAt: data.openedAt,
379
+ lastAt: data.closedAt ?? data.openedAt,
380
+ }),
381
+ { session, task: data.id },
382
+ )
383
+ }
384
+
385
+ const currentSession = sessionOf(() => current)
386
+ const every = recordPart({})
387
+
388
+ const answerBefore = async (session: string, event: BeforeEvent, change: SessionChange, isInProject: boolean) => (isGranted('conversation') ? combined(session, event, change, isInProject) : undefined)
389
+
390
+ return {
391
+ sessions: {
392
+ get current() {
393
+ return currentSession
394
+ },
395
+ list: async (scope: SessionScope = {}) =>
396
+ (await call<SessionData[]>(conversationMethods.sessions, scope)).map((data) => {
397
+ if (data.id !== current.id) {
398
+ known.set(data.id, data)
399
+ return sessionOf(() => known.get(data.id) ?? data)
400
+ }
401
+ if (current.title === '') current = { ...current, title: data.title }
402
+ return currentSession
403
+ }),
404
+ search: (text, options = {}) => call<Message[]>(conversationMethods.search, { text, ...optionsOf(options), ...(options.running === undefined ? {} : { running: options.running }), ...(options.since === undefined ? {} : { since: options.since }), ...(options.everyProject === undefined ? {} : { everyProject: options.everyProject }) }),
405
+ on: every.on,
406
+ before: every.before,
407
+ },
408
+ changed(session, change, isInProject) {
409
+ remember(session, change)
410
+ for (const key of isInProject ? [session, everySession] : [session]) {
411
+ for (const { scope, event, matcher, listener } of listening.get(key) ?? []) {
412
+ if (!isHeardBy(event, matcher, change) || !isInScope(scope, session, change, isUnderIn(session))) continue
413
+ void Promise.resolve()
414
+ .then(() => listener(payloadFor(session, change) as never))
415
+ .catch((error: unknown) => report(`session:${event}`, `The ${event} listener failed`, error))
416
+ }
417
+ }
418
+ },
419
+ answerBefore,
420
+ follow(next) {
421
+ const moved = next.id !== current.id && current.id !== ''
422
+ const previous = current.id
423
+ current = { ...next, title: next.title || (next.id === current.id ? current.title : ''), isRunning: true, lastAt: new Date().toISOString() }
424
+ if (!moved) return
425
+ for (const into of [listening, deciding]) {
426
+ const kept = into.get(previous)
427
+ if (kept === undefined) continue
428
+ into.delete(previous)
429
+ for (const entry of kept) if (entry.scope.session === previous) entry.scope = { ...entry.scope, session: current.id }
430
+ into.set(current.id, new Set([...(into.get(current.id) ?? []), ...kept]))
431
+ }
432
+ parents.delete(previous)
433
+ subscribe(previous)
434
+ subscribe(current.id)
435
+ },
436
+ }
437
+ }
438
+
439
+ export function resultOf(where: Pick<Place, 'session' | 'context' | 'turn'>, answered: Awaited<ReturnType<Claude['tool']['call']>>): ToolResult {
440
+ const base: Omit<ToolResult, 'text' | 'isError'> = { id: '', type: ToolResult, at: new Date().toISOString(), session: where.session, context: where.context, ...(where.turn === undefined ? {} : { turn: where.turn.prompt.id }), callId: '' }
441
+ if (answered.deny !== undefined) return { ...base, text: answered.deny, isError: true }
442
+ return { ...base, text: answered.text ?? JSON.stringify(answered.result), isError: answered.isError === true }
443
+ }
444
+
445
+ function optionsOf(options: SearchOptions): Readonly<Record<string, unknown>> {
446
+ return { ...(options.limit === undefined ? {} : { limit: options.limit }), ...(options.before === undefined ? {} : { before: { session: options.before.session, id: options.before.id } }) }
447
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ClassicHookInputs } from 'claude-code'
2
- import { messageOf } from '../utils/text.js'
3
2
  import type { Claude } from './claude.js'
3
+ import type { Failure } from './toaster.js'
4
4
 
5
5
  type Lifetime = 'memory' | 'session' | 'project' | 'global'
6
6
 
@@ -14,7 +14,9 @@ export type StateOptions = {
14
14
  readonly session: string
15
15
  readonly root: string
16
16
  readonly claude: Claude
17
+ readonly report: (failure: Failure) => void
17
18
  readonly changed: () => void
19
+ readonly forkParent: () => Promise<string | undefined>
18
20
  }
19
21
 
20
22
  export type ModState<State extends object> = {
@@ -23,12 +25,12 @@ export type ModState<State extends object> = {
23
25
  changed(): void
24
26
  load(): Promise<void>
25
27
  moveTo(root: string): Promise<void>
26
- switchSession(session: string, source: ClassicHookInputs['SessionStart']['source']): Promise<void>
28
+ switchSession(session: string, source: ClassicHookInputs['SessionStart']['source'], transcript: string): Promise<void>
27
29
  }
28
30
 
29
31
  const lifetimes: readonly Lifetime[] = ['memory', 'session', 'project', 'global']
30
32
 
31
- const keptPerValue = 20
33
+ const keptProjects = 20
32
34
 
33
35
  const listeners = new WeakMap<object, Set<() => void>>()
34
36
 
@@ -38,7 +40,16 @@ export function onStateChange(state: object, listener: () => void): void {
38
40
  listeners.set(state, own)
39
41
  }
40
42
 
41
- export function createState<State extends object>({ name, initial, session, root, claude, changed: redraw }: StateOptions): ModState<State> {
43
+ export async function sessionsWith(claude: Pick<Claude, 'store'>, name: string, key: string, value: unknown): Promise<string[]> {
44
+ const prefix = `${name}.${key}.sessions.`
45
+ const found: string[] = []
46
+ for (const stored of (await claude.store.keys()).filter((each) => each.startsWith(prefix))) {
47
+ if ((await claude.store.get(stored)) === value) found.push(stored.slice(prefix.length))
48
+ }
49
+ return found
50
+ }
51
+
52
+ export function createState<State extends object>({ name, initial, session, root, claude, report, changed: redraw, forkParent }: StateOptions): ModState<State> {
42
53
  const changed = () => {
43
54
  redraw()
44
55
  for (const listener of listeners.get(state) ?? []) listener()
@@ -51,7 +62,7 @@ export function createState<State extends object>({ name, initial, session, root
51
62
 
52
63
  const inTurn = <Result>(task: () => Promise<Result>): Promise<Result> => {
53
64
  const run = turn.then(task)
54
- turn = run.catch(() => undefined)
65
+ turn = Promise.allSettled([run])
55
66
  return run
56
67
  }
57
68
 
@@ -62,11 +73,25 @@ export function createState<State extends object>({ name, initial, session, root
62
73
  const read = (lifetime: SavedLifetime, key: string, owner: string) => claude.store.get(storeKey(lifetime, key, owner))
63
74
 
64
75
  const write = async (lifetime: SavedLifetime, key: string, value: unknown, owner: string) => {
65
- if (lifetime === 'global') return claude.store.set(storeKey(lifetime, key, owner), value)
76
+ if (lifetime !== 'project') return claude.store.set(storeKey(lifetime, key, owner), value)
66
77
  await claude.store.delete(storeKey(lifetime, key, owner))
67
78
  await claude.store.set(storeKey(lifetime, key, owner), value)
68
79
  const owners = (await claude.store.keys()).filter((stored) => stored.startsWith(ownersKey(lifetime, key)))
69
- for (const oldest of owners.slice(0, -keptPerValue)) await claude.store.delete(oldest)
80
+ for (const oldest of owners.slice(0, -keptProjects)) await claude.store.delete(oldest)
81
+ }
82
+
83
+ const transcriptsKey = `${name}.$transcripts.`
84
+
85
+ const forgetEndedSessions = async (current: string) => {
86
+ const stored = await claude.store.keys()
87
+ for (const recorded of stored.filter((key) => key.startsWith(transcriptsKey))) {
88
+ const owner = recorded.slice(transcriptsKey.length)
89
+ if (owner === current) continue
90
+ const transcript = await claude.store.get(recorded)
91
+ if (typeof transcript === 'string' && (await claude.fs.exists(transcript))) continue
92
+ for (const value of stored.filter((key) => key.startsWith(`${name}.`) && key.endsWith(`.sessions.${owner}`))) await claude.store.delete(value)
93
+ await claude.store.delete(recorded)
94
+ }
70
95
  }
71
96
 
72
97
  const loadGroups = async (only: readonly Lifetime[], next: { readonly root: string; readonly session: string }) => {
@@ -80,13 +105,15 @@ export function createState<State extends object>({ name, initial, session, root
80
105
  changed()
81
106
  }
82
107
 
83
- const copySession = async (nextSession: string) => {
108
+ const copySession = async (from: string, to: string) => {
84
109
  for (const key of Object.keys(declared.session)) {
85
- const saved = await read('session', key, sessionId)
86
- if (saved !== undefined) await write('session', key, saved, nextSession)
110
+ const saved = await read('session', key, from)
111
+ if (saved !== undefined) await write('session', key, saved, to)
87
112
  }
88
113
  }
89
114
 
115
+ const hasSaved = async (owner: string) => (await claude.store.keys()).some((key) => key.startsWith(`${name}.`) && key.endsWith(`.sessions.${owner}`))
116
+
90
117
  const group = (lifetime: Lifetime) =>
91
118
  new Proxy(current[lifetime], {
92
119
  set(target, key, value) {
@@ -97,7 +124,7 @@ export function createState<State extends object>({ name, initial, session, root
97
124
  if (lifetime !== 'memory') {
98
125
  const owner = lifetime === 'project' ? projectRoot : sessionId
99
126
  inTurn(() => write(lifetime, key, value, owner)).catch((error: unknown) => {
100
- claude.ui.log(`${name} could not keep state.${lifetime}.${key}: ${messageOf(error)}. Keep only JSON data in mod.state.`)
127
+ report({ id: `state:${lifetime}.${key}`, message: `Could not keep state.${lifetime}.${key}. Keep only JSON data in mod.state`, error })
101
128
  })
102
129
  }
103
130
  changed()
@@ -113,12 +140,21 @@ export function createState<State extends object>({ name, initial, session, root
113
140
  return projectRoot
114
141
  },
115
142
  changed,
116
- load: () => inTurn(() => loadGroups(lifetimes, { root: projectRoot, session: sessionId })),
143
+ load: () =>
144
+ inTurn(async () => {
145
+ if (Object.keys(declared.session).length > 0 && !(await hasSaved(sessionId))) {
146
+ const parent = await forkParent()
147
+ if (parent !== undefined) await copySession(parent, sessionId)
148
+ }
149
+ return loadGroups(lifetimes, { root: projectRoot, session: sessionId })
150
+ }),
117
151
  moveTo: (nextRoot) => inTurn(async () => (nextRoot === projectRoot ? undefined : loadGroups(['project'], { root: nextRoot, session: sessionId }))),
118
- switchSession: (nextSession, source) =>
152
+ switchSession: (nextSession, source, transcript) =>
119
153
  inTurn(async () => {
154
+ await claude.store.set(`${transcriptsKey}${nextSession}`, transcript)
155
+ await forgetEndedSessions(nextSession)
120
156
  if (nextSession === sessionId) return
121
- if (source === 'fork') await copySession(nextSession)
157
+ if (source === 'fork') await copySession(sessionId, nextSession)
122
158
  return loadGroups(['memory', 'session'], { root: projectRoot, session: nextSession })
123
159
  }),
124
160
  }