@cmodjs/core 0.2.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/docs/commands.md +58 -10
  2. package/docs/dependencies.md +12 -12
  3. package/docs/hooks.md +41 -28
  4. package/docs/index.md +29 -25
  5. package/docs/install-steps.md +9 -7
  6. package/docs/jobs.md +59 -111
  7. package/docs/mod.md +200 -174
  8. package/docs/options.md +2 -2
  9. package/docs/permissions.md +27 -16
  10. package/docs/session.md +248 -0
  11. package/docs/state.md +11 -7
  12. package/docs/testing.md +66 -23
  13. package/docs/ui.md +108 -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 +8 -3
  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 +154 -75
  48. package/mod.d.ts.map +1 -1
  49. package/mod.js +94 -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 +10 -8
  54. package/records.d.ts.map +1 -1
  55. package/records.js +28 -17
  56. package/register.d.ts.map +1 -1
  57. package/register.js +18 -6
  58. package/runtime/announce.d.ts +18 -0
  59. package/runtime/announce.d.ts.map +1 -0
  60. package/runtime/announce.js +66 -0
  61. package/runtime/bound.d.ts +4 -0
  62. package/runtime/bound.d.ts.map +1 -0
  63. package/runtime/bound.js +9 -0
  64. package/runtime/claude.d.ts +3 -3
  65. package/runtime/claude.d.ts.map +1 -1
  66. package/runtime/conversations.d.ts +16 -0
  67. package/runtime/conversations.d.ts.map +1 -0
  68. package/runtime/conversations.js +291 -0
  69. package/runtime/deadline.d.ts +0 -1
  70. package/runtime/deadline.d.ts.map +1 -1
  71. package/runtime/deadline.js +1 -1
  72. package/runtime/dependencies.d.ts +2 -0
  73. package/runtime/dependencies.d.ts.map +1 -1
  74. package/runtime/dependencies.js +9 -0
  75. package/runtime/engine.d.ts +17 -0
  76. package/runtime/engine.d.ts.map +1 -0
  77. package/runtime/engine.js +10 -0
  78. package/runtime/grants.d.ts +4 -0
  79. package/runtime/grants.d.ts.map +1 -1
  80. package/runtime/grants.js +63 -33
  81. package/runtime/hooks.d.ts +5 -4
  82. package/runtime/hooks.d.ts.map +1 -1
  83. package/runtime/hooks.js +10 -5
  84. package/runtime/installer.d.ts.map +1 -1
  85. package/runtime/installer.js +11 -9
  86. package/runtime/lifecycle.d.ts +3 -1
  87. package/runtime/lifecycle.d.ts.map +1 -1
  88. package/runtime/lifecycle.js +317 -186
  89. package/runtime/metadata.d.ts +3 -1
  90. package/runtime/metadata.d.ts.map +1 -1
  91. package/runtime/metadata.js +21 -11
  92. package/runtime/options.d.ts +3 -1
  93. package/runtime/options.d.ts.map +1 -1
  94. package/runtime/options.js +4 -4
  95. package/runtime/programs.d.ts +8 -0
  96. package/runtime/programs.d.ts.map +1 -1
  97. package/runtime/programs.js +39 -1
  98. package/runtime/router.d.ts +2 -1
  99. package/runtime/router.d.ts.map +1 -1
  100. package/runtime/router.js +11 -3
  101. package/runtime/sessions.d.ts +36 -0
  102. package/runtime/sessions.d.ts.map +1 -0
  103. package/runtime/sessions.js +235 -0
  104. package/runtime/state.d.ts +6 -2
  105. package/runtime/state.d.ts.map +1 -1
  106. package/runtime/state.js +46 -13
  107. package/runtime/toaster.d.ts +31 -0
  108. package/runtime/toaster.d.ts.map +1 -0
  109. package/runtime/toaster.js +176 -0
  110. package/runtime/ui.d.ts +6 -17
  111. package/runtime/ui.d.ts.map +1 -1
  112. package/runtime/ui.js +36 -112
  113. package/session/context/context.d.ts +10 -0
  114. package/session/context/context.d.ts.map +1 -0
  115. package/session/context/context.js +2 -0
  116. package/session/context/turn/message/document.d.ts +7 -0
  117. package/session/context/turn/message/document.d.ts.map +1 -0
  118. package/session/context/turn/message/document.js +1 -0
  119. package/session/context/turn/message/message.d.ts +54 -0
  120. package/session/context/turn/message/message.d.ts.map +1 -0
  121. package/session/context/turn/message/message.js +137 -0
  122. package/session/context/turn/message/note.d.ts +8 -0
  123. package/session/context/turn/message/note.d.ts.map +1 -0
  124. package/session/context/turn/message/note.js +1 -0
  125. package/session/context/turn/message/notice.d.ts +6 -0
  126. package/session/context/turn/message/notice.d.ts.map +1 -0
  127. package/session/context/turn/message/notice.js +1 -0
  128. package/session/context/turn/message/prompt.d.ts +8 -0
  129. package/session/context/turn/message/prompt.d.ts.map +1 -0
  130. package/session/context/turn/message/prompt.js +1 -0
  131. package/session/context/turn/message/reply.d.ts +6 -0
  132. package/session/context/turn/message/reply.d.ts.map +1 -0
  133. package/session/context/turn/message/reply.js +1 -0
  134. package/session/context/turn/message/sender.d.ts +14 -0
  135. package/session/context/turn/message/sender.d.ts.map +1 -0
  136. package/session/context/turn/message/sender.js +30 -0
  137. package/session/context/turn/message/skill.d.ts +7 -0
  138. package/session/context/turn/message/skill.d.ts.map +1 -0
  139. package/session/context/turn/message/skill.js +1 -0
  140. package/session/context/turn/message/tool-call.d.ts +9 -0
  141. package/session/context/turn/message/tool-call.d.ts.map +1 -0
  142. package/session/context/turn/message/tool-call.js +1 -0
  143. package/session/context/turn/message/tool-result.d.ts +8 -0
  144. package/session/context/turn/message/tool-result.d.ts.map +1 -0
  145. package/session/context/turn/message/tool-result.js +1 -0
  146. package/session/context/turn/turn.d.ts +12 -0
  147. package/session/context/turn/turn.d.ts.map +1 -0
  148. package/session/context/turn/turn.js +2 -0
  149. package/session/conversation.d.ts +64 -0
  150. package/session/conversation.d.ts.map +1 -0
  151. package/session/conversation.js +123 -0
  152. package/session/goal/goal.d.ts +10 -0
  153. package/session/goal/goal.d.ts.map +1 -0
  154. package/session/goal/goal.js +1 -0
  155. package/session/goal/task.d.ts +16 -0
  156. package/session/goal/task.d.ts.map +1 -0
  157. package/session/goal/task.js +3 -0
  158. package/session/session.d.ts +99 -0
  159. package/session/session.d.ts.map +1 -0
  160. package/session/session.js +1 -0
  161. package/src/jobs/check.ts +7 -4
  162. package/src/jobs/context.ts +24 -82
  163. package/src/jobs/permissions/decide-permission.ts +10 -8
  164. package/src/jobs/permissions/find-project-scope.ts +8 -14
  165. package/src/jobs/permissions/match-target.ts +3 -3
  166. package/src/jobs/permissions.ts +8 -6
  167. package/src/jobs/program.ts +7 -3
  168. package/src/jobs/prompt.ts +21 -24
  169. package/src/jobs/schedule.ts +14 -14
  170. package/src/jobs/slash-command.ts +20 -14
  171. package/src/jobs/status-line.ts +9 -21
  172. package/src/jobs/tool.ts +17 -11
  173. package/src/mod.ts +209 -97
  174. package/src/options.ts +22 -22
  175. package/src/records.ts +35 -23
  176. package/src/register.ts +18 -6
  177. package/src/runtime/announce.ts +74 -0
  178. package/src/runtime/bound.ts +12 -0
  179. package/src/runtime/claude.ts +5 -3
  180. package/src/runtime/conversations.ts +302 -0
  181. package/src/runtime/deadline.ts +2 -2
  182. package/src/runtime/dependencies.ts +12 -0
  183. package/src/runtime/engine.ts +22 -0
  184. package/src/runtime/grants.ts +66 -31
  185. package/src/runtime/hooks.ts +19 -10
  186. package/src/runtime/installer.ts +10 -9
  187. package/src/runtime/lifecycle.ts +313 -181
  188. package/src/runtime/metadata.ts +33 -11
  189. package/src/runtime/options.ts +19 -4
  190. package/src/runtime/programs.ts +40 -1
  191. package/src/runtime/router.ts +11 -4
  192. package/src/runtime/sessions.ts +242 -0
  193. package/src/runtime/state.ts +50 -14
  194. package/src/runtime/toaster.ts +218 -0
  195. package/src/runtime/ui.ts +42 -139
  196. package/src/session/context/context.ts +12 -0
  197. package/src/session/context/turn/message/document.ts +8 -0
  198. package/src/session/context/turn/message/message.ts +165 -0
  199. package/src/session/context/turn/message/note.ts +9 -0
  200. package/src/session/context/turn/message/notice.ts +7 -0
  201. package/src/session/context/turn/message/prompt.ts +9 -0
  202. package/src/session/context/turn/message/reply.ts +7 -0
  203. package/src/session/context/turn/message/sender.ts +38 -0
  204. package/src/session/context/turn/message/skill.ts +8 -0
  205. package/src/session/context/turn/message/tool-call.ts +10 -0
  206. package/src/session/context/turn/message/tool-result.ts +9 -0
  207. package/src/session/context/turn/turn.ts +14 -0
  208. package/src/session/conversation.ts +169 -0
  209. package/src/session/goal/goal.ts +9 -0
  210. package/src/session/goal/task.ts +19 -0
  211. package/src/session/session.ts +94 -0
  212. package/src/testing/fake-claude.ts +23 -18
  213. package/src/testing/fake-elements.ts +3 -2
  214. package/src/testing/fake-files.ts +1 -1
  215. package/src/testing/fake-sessions.ts +203 -0
  216. package/src/testing.ts +112 -33
  217. package/src/ui/components.ts +3 -3
  218. package/src/ui/elements.ts +22 -8
  219. package/src/ui/markdown.ts +22 -26
  220. package/src/ui/places.ts +49 -0
  221. package/src/utils/call-effects.ts +3 -3
  222. package/src/utils/changelog.ts +32 -0
  223. package/src/utils/matcher.ts +37 -0
  224. package/src/utils/metadata.ts +17 -15
  225. package/src/utils/parse-shell.ts +24 -16
  226. package/src/utils/paths.ts +15 -1
  227. package/src/utils/text.ts +4 -0
  228. package/testing/fake-claude.d.ts +4 -2
  229. package/testing/fake-claude.d.ts.map +1 -1
  230. package/testing/fake-claude.js +18 -12
  231. package/testing/fake-elements.js +4 -2
  232. package/testing/fake-files.js +1 -1
  233. package/testing/fake-sessions.d.ts +74 -0
  234. package/testing/fake-sessions.d.ts.map +1 -0
  235. package/testing/fake-sessions.js +170 -0
  236. package/testing.d.ts +33 -13
  237. package/testing.d.ts.map +1 -1
  238. package/testing.js +92 -21
  239. package/ui/components.d.ts +2 -3
  240. package/ui/components.d.ts.map +1 -1
  241. package/ui/components.js +3 -3
  242. package/ui/elements.d.ts.map +1 -1
  243. package/ui/elements.js +19 -9
  244. package/ui/markdown.d.ts +35 -33
  245. package/ui/markdown.d.ts.map +1 -1
  246. package/ui/markdown.js +14 -16
  247. package/ui/places.d.ts +43 -0
  248. package/ui/places.d.ts.map +1 -0
  249. package/ui/places.js +31 -0
  250. package/utils/call-effects.d.ts +1 -1
  251. package/utils/call-effects.d.ts.map +1 -1
  252. package/utils/call-effects.js +2 -2
  253. package/utils/changelog.d.ts +9 -0
  254. package/utils/changelog.d.ts.map +1 -0
  255. package/utils/changelog.js +29 -0
  256. package/utils/matcher.d.ts +13 -0
  257. package/utils/matcher.d.ts.map +1 -0
  258. package/utils/matcher.js +32 -0
  259. package/utils/metadata.d.ts +3 -3
  260. package/utils/metadata.js +18 -14
  261. package/utils/parse-shell.d.ts +1 -0
  262. package/utils/parse-shell.d.ts.map +1 -1
  263. package/utils/parse-shell.js +22 -13
  264. package/utils/paths.d.ts +2 -0
  265. package/utils/paths.d.ts.map +1 -1
  266. package/utils/paths.js +13 -1
  267. package/utils/text.d.ts +1 -0
  268. package/utils/text.d.ts.map +1 -1
  269. package/utils/text.js +3 -0
  270. package/src/ui/slots.ts +0 -36
  271. package/ui/slots.d.ts +0 -208
  272. package/ui/slots.d.ts.map +0 -1
  273. package/ui/slots.js +0 -19
@@ -1,8 +1,10 @@
1
1
  import type { FileMatch, Metadata } from '../mod.js'
2
2
  import { formatOf, MetadataError, readMetadata, sidecarSuffix, writeMetadata, type MetadataFormat } from '../utils/metadata.js'
3
+ import { ifMissing, isMissing } from '../utils/paths.js'
3
4
  import { messageOf } from '../utils/text.js'
4
5
  import { picomatch, resolve } from '../vendor.js'
5
6
  import type { Claude } from './claude.js'
7
+ import type { Failure } from './toaster.js'
6
8
 
7
9
  export type Places = {
8
10
  readonly home: string | undefined
@@ -34,7 +36,19 @@ export function resolvedPath(path: string, places: Places): string {
34
36
  return path.startsWith('/') ? resolve(path) : resolve(places.projectRoot(), path)
35
37
  }
36
38
 
37
- export function createModFiles({ name, claude, places, checkWrite }: { readonly name: string; readonly claude: Claude; readonly places: Places; readonly checkWrite: (path: string) => Promise<void> }): ModFiles {
39
+ export function createModFiles({
40
+ name,
41
+ claude,
42
+ places,
43
+ checkWrite,
44
+ report,
45
+ }: {
46
+ readonly name: string
47
+ readonly claude: Claude
48
+ readonly places: Places
49
+ readonly checkWrite: (path: string) => Promise<void>
50
+ readonly report: (failure: Failure) => void
51
+ }): ModFiles {
38
52
  const prefix = `${name}.`
39
53
  const cache = new Map<string, { readonly signature: string; readonly read: Read }>()
40
54
  const logged = new Set<string>()
@@ -48,12 +62,20 @@ export function createModFiles({ name, claude, places, checkWrite }: { readonly
48
62
  const extensionless = !realPath.slice(realPath.lastIndexOf('/') + 1).includes('.')
49
63
  const firstLine = extensionless ? (await claude.fs.read(realPath)).split('\n', 1)[0] : undefined
50
64
  const format = formatOf(realPath, firstLine)
51
- const holder = format.kind === 'sidecar' ? `${realPath}${sidecarSuffix}` : realPath
52
- const sidecar = format.kind === 'sidecar' ? await claude.fs.stat(holder).catch(() => undefined) : undefined
65
+ const holder = format.type === 'sidecar' ? `${realPath}${sidecarSuffix}` : realPath
66
+ const sidecar = format.type === 'sidecar' ? await claude.fs.stat(holder).catch(ifMissing(undefined)) : undefined
53
67
  return { realPath, format, holder, signature: `${stat.mtimeMs}:${stat.size}:${sidecar?.kind === 'file' ? `${sidecar.mtimeMs}:${sidecar.size}` : ''}` }
54
68
  }
55
69
 
56
- const textOf = async (holder: string): Promise<string> => ((await claude.fs.stat(holder).catch(() => undefined))?.kind === 'file' ? claude.fs.read(holder) : '')
70
+ const textOf = async (holder: string): Promise<string> => ((await claude.fs.stat(holder).catch(ifMissing(undefined)))?.kind === 'file' ? claude.fs.read(holder) : '')
71
+
72
+ const skipped = <Value>(path: string, value: Value) => (error: unknown): Value => {
73
+ if (isMissing(error)) return value
74
+ const reason = `${name}: fs.find skipped ${path}: ${messageOf(error)}`
75
+ if (!logged.has(reason)) claude.ui.log(reason, { to: 'debug' })
76
+ logged.add(reason)
77
+ return value
78
+ }
57
79
 
58
80
  const readEntry = async (path: string): Promise<Read> => {
59
81
  const located = await locate(path)
@@ -67,7 +89,7 @@ export function createModFiles({ name, claude, places, checkWrite }: { readonly
67
89
  if (!(error instanceof MetadataError)) throw error
68
90
  const failure = `${located.holder} ${error.message}`
69
91
  read = { metadata: {}, error: failure }
70
- if (!logged.has(failure)) claude.ui.log(`${name}: ${failure}`)
92
+ if (!logged.has(failure)) report({ id: `metadata:${located.holder}`, message: `Skips the metadata of ${located.holder}`, description: failure })
71
93
  logged.add(failure)
72
94
  }
73
95
  cache.set(path, { signature: located.signature, read })
@@ -77,21 +99,21 @@ export function createModFiles({ name, claude, places, checkWrite }: { readonly
77
99
  const matchesOf = async (pattern: string): Promise<string[]> => {
78
100
  const absolute = resolvedPath(pattern, places)
79
101
  const scanned = picomatch.scan(absolute)
80
- if (!scanned.isGlob) return (await claude.fs.stat(absolute).catch(() => undefined))?.kind === 'file' ? [absolute] : []
102
+ if (!scanned.isGlob) return (await claude.fs.stat(absolute).catch(skipped(absolute, undefined)))?.kind === 'file' ? [absolute] : []
81
103
  const isMatch = picomatch(absolute, { dot: true })
82
104
  const entersSkipped = skippedFolders.filter((folder) => scanned.glob.split('/').includes(folder))
83
105
  const maxDepth = scanned.glob.includes('**') ? Number.POSITIVE_INFINITY : scanned.glob.split('/').length
84
106
  const found: string[] = []
85
107
  const visited = new Set<string>()
86
108
  const walk = async (folder: string, depth: number): Promise<void> => {
87
- const real = (await claude.fs.stat(folder, { resolve: true }).catch(() => undefined))?.realPath ?? folder
109
+ const real = (await claude.fs.stat(folder, { resolve: true }).catch(skipped(folder, undefined)))?.realPath ?? folder
88
110
  if (visited.has(real)) return
89
111
  visited.add(real)
90
- const entries = await claude.fs.list(folder).catch(() => [])
112
+ const entries = await claude.fs.list(folder).catch(skipped(folder, []))
91
113
  await Promise.all(
92
114
  entries.map(async (entry) => {
93
115
  const path = `${folder}/${entry.name}`
94
- const kind = entry.isLink === true ? (await claude.fs.stat(path).catch(() => undefined))?.kind : entry.kind
116
+ const kind = entry.isLink === true ? (await claude.fs.stat(path).catch(skipped(path, undefined)))?.kind : entry.kind
95
117
  if (kind === 'file' && isMatch(path)) found.push(path)
96
118
  if (kind !== 'dir' || depth + 1 >= maxDepth) return
97
119
  if (skippedFolders.includes(entry.name) && !entersSkipped.includes(entry.name)) return
@@ -100,7 +122,7 @@ export function createModFiles({ name, claude, places, checkWrite }: { readonly
100
122
  )
101
123
  }
102
124
  const base = scanned.base === '' ? '/' : scanned.base
103
- if ((await claude.fs.stat(base).catch(() => undefined))?.kind === 'dir') await walk(base, 0)
125
+ if ((await claude.fs.stat(base).catch(skipped(base, undefined)))?.kind === 'dir') await walk(base, 0)
104
126
  return found
105
127
  }
106
128
 
@@ -111,7 +133,7 @@ export function createModFiles({ name, claude, places, checkWrite }: { readonly
111
133
  const kept = paths.filter((path) => !(path.endsWith(sidecarSuffix) && paths.includes(path.slice(0, -sidecarSuffix.length))))
112
134
  return Promise.all(
113
135
  kept.map(async (path): Promise<FileMatch> => {
114
- const read = await readEntry(path)
136
+ const read = await readEntry(path).catch((error: unknown): Read => ({ metadata: {}, error: `${path} ${messageOf(error)}` }))
115
137
  return { path, ...(read.name === undefined ? {} : { name: read.name }), metadata: own(read.metadata), ...(read.error === undefined ? {} : { error: read.error }) }
116
138
  }),
117
139
  )
@@ -3,6 +3,7 @@ import { fitsOption, isUnset, type Option, type Options } from '../options.js'
3
3
  import { configFolders } from '../records.js'
4
4
  import { listed, messageOf } from '../utils/text.js'
5
5
  import type { Claude } from './claude.js'
6
+ import type { Failure } from './toaster.js'
6
7
 
7
8
  export class MissingOptions extends Error {
8
9
  readonly keys: readonly string[]
@@ -21,7 +22,21 @@ export type ModOptions = {
21
22
  load(root: string): Promise<void>
22
23
  }
23
24
 
24
- export function createOptions({ name, declared, fromClaude, claude, changed }: { readonly name: string; readonly declared: Options; readonly fromClaude: PluginOptions; readonly claude: Claude; readonly changed: () => void }): ModOptions {
25
+ export function createOptions({
26
+ name,
27
+ declared,
28
+ fromClaude,
29
+ claude,
30
+ report,
31
+ changed,
32
+ }: {
33
+ readonly name: string
34
+ readonly declared: Options
35
+ readonly fromClaude: PluginOptions
36
+ readonly claude: Claude
37
+ readonly report: (failure: Failure) => void
38
+ readonly changed: () => void
39
+ }): ModOptions {
25
40
  let values: Readonly<Record<string, unknown>> = merged(declared, fromClaude, {}, new Set())
26
41
  let locked: Set<string> | undefined
27
42
  let loadedRoot: string | undefined
@@ -41,7 +56,7 @@ export function createOptions({ name, declared, fromClaude, claude, changed }: {
41
56
  if (project === undefined) return {}
42
57
  const path = `${project.folder}/options.json`
43
58
  if (!(await claude.fs.exists(path))) return {}
44
- const ignore = (reason: string) => claude.ui.log(`${path} ${reason}`)
59
+ const ignore = (reason: string) => report({ id: `options:${path} ${reason}`, message: `Ignores part of ${path}`, description: `${path} ${reason}` })
45
60
  let file: unknown
46
61
  try {
47
62
  file = JSON.parse(await claude.fs.read(path))
@@ -57,7 +72,7 @@ export function createOptions({ name, declared, fromClaude, claude, changed }: {
57
72
  for (const [key, value] of Object.entries(file)) {
58
73
  const option = declared[key]
59
74
  if (option === undefined) ignore(`sets ${key}, which ${name} does not declare. ${Object.keys(declared).length === 0 ? 'Remove it.' : `Remove it, or use one of: ${Object.keys(declared).join(', ')}.`}`)
60
- else if (option.kind === 'secret') ignore(`sets ${key}, a secret, and a project file is committed for everyone to read. Remove it, and set it in /config.`)
75
+ else if (option.type === 'secret') ignore(`sets ${key}, a secret, and a project file is committed for everyone to read. Remove it, and set it in /config.`)
61
76
  else {
62
77
  const misfit = fitsOption(option, value)
63
78
  if (misfit === undefined) kept[key] = value
@@ -98,5 +113,5 @@ function valueOf(key: string, option: Option, own: unknown, project: Readonly<Re
98
113
  if (locked.has(key)) return own
99
114
  if (Object.hasOwn(project, key)) return project[key]
100
115
  if (!isUnset(own) && fitsOption(option, own) === undefined) return Array.isArray(own) ? Object.freeze([...own]) : own
101
- return option.default ?? (option.kind === 'list' ? Object.freeze([]) : option.kind === 'number' ? undefined : '')
116
+ return option.default ?? (option.type === 'list' ? Object.freeze([]) : option.type === 'number' ? undefined : '')
102
117
  }
@@ -1,8 +1,47 @@
1
+ import type { HookStream, ProcessSpawnChunk, ProcessSpawnResult } from 'claude-code'
1
2
  import { storeFolder } from '../records.js'
3
+ import { ifMissing } from '../utils/paths.js'
4
+ import { formatExit } from '../utils/text.js'
2
5
  import type { Claude } from './claude.js'
3
6
 
4
7
  export type Programs = { readonly claude: Claude; refresh(): Promise<void> }
5
8
 
9
+ export function lastLineOf(text: string): string | undefined {
10
+ const trimmed = text.trim()
11
+ return trimmed === '' ? undefined : trimmed.slice(trimmed.lastIndexOf('\n') + 1).trim()
12
+ }
13
+
14
+ export function cmodError(stderr: string, command: string | undefined): string {
15
+ const line = lastLineOf(stderr) ?? ''
16
+ const prefix = `cmod ${command}: `
17
+ return line.startsWith(prefix) ? line.slice(prefix.length) : line
18
+ }
19
+
20
+ export async function readLines(stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult>, onLine: (text: string) => void): Promise<{ code: number | null; lastError: string }> {
21
+ let pending = ''
22
+ let lastError = ''
23
+ let piece = await stream.next()
24
+ while (piece.done !== true) {
25
+ if (piece.value.stream === 'stderr') {
26
+ lastError = lastLineOf(piece.value.text) ?? lastError
27
+ } else {
28
+ const lines = `${pending}${piece.value.text}`.split('\n')
29
+ pending = lines.pop() ?? ''
30
+ for (const text of lines) onLine(text)
31
+ }
32
+ piece = await stream.next()
33
+ }
34
+ if (pending !== '') onLine(pending)
35
+ return { code: piece.value.code, lastError }
36
+ }
37
+
38
+ export async function runCmod(claude: Claude, args: readonly string[], session: string): Promise<string> {
39
+ const lines: string[] = []
40
+ const { code, lastError } = await readLines(claude.process.spawn({ argv: ['cmod', ...args], env: { CLAUDE_CODE_SESSION_ID: session } }), (line) => void lines.push(line))
41
+ if (code !== 0) throw new Error(cmodError(lastError, args[0]) || `cmod ${args.slice(0, 2).join(' ')} ${formatExit(code, '')}.`)
42
+ return lines.join('\n')
43
+ }
44
+
6
45
  export function locatingPrograms(claude: Claude): Programs {
7
46
  let folder: string | undefined
8
47
  let names: ReadonlySet<string> = new Set()
@@ -21,7 +60,7 @@ export function locatingPrograms(claude: Claude): Programs {
21
60
  async refresh() {
22
61
  const [home, dataHome] = await Promise.all([claude.env.home(), claude.env.dataHome()])
23
62
  const programs = `${storeFolder({ HOME: home, XDG_DATA_HOME: dataHome })}/programs`
24
- names = new Set((await claude.fs.list(programs).catch(() => [])).map((entry) => entry.name))
63
+ names = new Set((await claude.fs.list(programs).catch(ifMissing([]))).map((entry) => entry.name))
25
64
  folder = programs
26
65
  },
27
66
  }
@@ -1,4 +1,5 @@
1
1
  import type { Args, EventResult, Frozen } from 'claude-code'
2
+ import type { Disposable } from '../mod.js'
2
3
  import type { RoutedEvent } from './hooks.js'
3
4
 
4
5
  export type RouterNext<N extends RoutedEvent> = (e: Frozen<Args<N>>) => Promise<EventResult<N>>
@@ -6,7 +7,7 @@ export type RouterNext<N extends RoutedEvent> = (e: Frozen<Args<N>>) => Promise<
6
7
  export type RoutedHook<N extends RoutedEvent> = (e: Frozen<Args<N>>, next: RouterNext<N>) => EventResult<N> | Promise<EventResult<N>>
7
8
 
8
9
  export type Router = {
9
- add<N extends RoutedEvent>(event: N, hook: RoutedHook<N>): void
10
+ add<N extends RoutedEvent>(event: N, hook: RoutedHook<N>): Disposable
10
11
  has(event: RoutedEvent): boolean
11
12
  dispatch<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>>
12
13
  }
@@ -18,9 +19,15 @@ export function createRouter(): Router {
18
19
 
19
20
  return {
20
21
  add(event, hook) {
21
- const chain = hooks.get(event) ?? []
22
- chain.push(hook as unknown as ErasedHook)
23
- hooks.set(event, chain)
22
+ const erased = hook as unknown as ErasedHook
23
+ hooks.set(event, [...(hooks.get(event) ?? []), erased])
24
+ return {
25
+ dispose() {
26
+ const chain = (hooks.get(event) ?? []).filter((each) => each !== erased)
27
+ if (chain.length === 0) hooks.delete(event)
28
+ else hooks.set(event, chain)
29
+ },
30
+ }
24
31
  },
25
32
  has: (event) => hooks.has(event),
26
33
  dispatch<N extends RoutedEvent>(event: N, e: Frozen<Args<N>>, next: RouterNext<N>): Promise<EventResult<N>> {
@@ -0,0 +1,242 @@
1
+ import type { Disposable } from '../mod.js'
2
+ import 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 type { Turn } from '../session/context/turn/turn.js'
7
+ import type { ContextData, GoalData, SessionChange, SessionData, TaskData, TurnData } from '../session/conversation.js'
8
+ import type { Goal } from '../session/goal/goal.js'
9
+ import type { Task } from '../session/goal/task.js'
10
+ import { Session, type SessionEvent, type SessionScope, type SessionSend, type Sessions, type TaskStartArgs, type Usage } from '../session/session.js'
11
+ import { matches, sentMatcher, type SentMatcher } from '../utils/matcher.js'
12
+ import type { Claude } from './claude.js'
13
+ import { cmodPluginName } from './dependencies.js'
14
+
15
+ export type Subscription = { readonly event: SessionEvent; readonly matcher: SentMatcher | undefined }
16
+
17
+ export const conversationMethods = {
18
+ sessions: 'cmod:sessions',
19
+ search: 'cmod:search',
20
+ messages: 'cmod:messages',
21
+ turns: 'cmod:turns',
22
+ contexts: 'cmod:contexts',
23
+ tasks: 'cmod:tasks',
24
+ goal: 'cmod:goal',
25
+ updateGoal: 'cmod:updateGoal',
26
+ subscribe: 'cmod:subscribe',
27
+ send: 'cmod:send',
28
+ changed: 'cmod:changed',
29
+ } as const
30
+
31
+ export type SessionsClient = {
32
+ readonly sessions: Sessions
33
+ changed(session: string, change: SessionChange): void
34
+ /** An empty title keeps the title the session already has. */
35
+ follow(current: Omit<SessionData, 'isRunning' | 'lastAt'>): void
36
+ }
37
+
38
+ type Listening = { readonly event: SessionEvent; readonly matcher: unknown; readonly listener: (payload: never) => unknown }
39
+
40
+ export async function usageOf(claude: Pick<Claude, 'session'>): Promise<Usage> {
41
+ const [model, usage] = await Promise.all([claude.session.model(), claude.session.usage()])
42
+ const { tokens, window, percent } = usage.context
43
+ return { model, context: { ...(tokens === undefined ? {} : { tokens }), window, ...(percent === undefined ? {} : { percent }) }, ...(usage.cost === undefined ? {} : { cost: { usd: usage.cost.usd } }) }
44
+ }
45
+
46
+ export function payloadOf(change: SessionChange): unknown {
47
+ if (change.type === Message) return change.message
48
+ if ('turn' in change) return change.turn
49
+ if ('context' in change) return change.context
50
+ if ('task' in change) return change.task
51
+ return change.goal
52
+ }
53
+
54
+ export function isHeardBy(event: SessionEvent, matcher: unknown, change: SessionChange): boolean {
55
+ const named = change.type === Message ? (event === Message || event === change.message.type) : event === change.type
56
+ return named && (matcher === undefined || matches(payloadOf(change), matcher))
57
+ }
58
+
59
+ export function createSessionsClient({ name, claude, report }: { readonly name: string; readonly claude: Claude; readonly report: (id: string, message: string, error: unknown) => void }): SessionsClient {
60
+ const call = <Answer>(method: string, input: unknown) => claude.cmod.call({ to: cmodPluginName, method, input }) as Promise<Answer>
61
+ const listening = new Map<string, Set<Listening>>()
62
+ const queued = new Set<string>()
63
+ let current: SessionData = { id: '', title: '', project: '', cwd: '', model: '', startedAt: '', lastAt: '', isRunning: true }
64
+
65
+ const subscribe = (session: string) => {
66
+ if (queued.has(session)) return
67
+ queued.add(session)
68
+ void Promise.resolve().then(async () => {
69
+ queued.delete(session)
70
+ const subscriptions: Subscription[] = [...(listening.get(session) ?? [])].map(({ event, matcher }) => ({ event, matcher: matcher === undefined ? undefined : sentMatcher(matcher) }))
71
+ await call(conversationMethods.subscribe, { session, subscriptions }).catch((error: unknown) => report(`sessions:${session}`, `Hears nothing from session ${session}`, error))
72
+ })
73
+ }
74
+
75
+ const isCurrent = (id: string) => id === current.id
76
+
77
+ const refuseElsewhere = (id: string, what: string): never => {
78
+ throw new Error(`${name}: ${what} works on the session this mod runs in, and ${id} runs elsewhere or has ended.`)
79
+ }
80
+
81
+ const turnOf = (session: string, data: TurnData): Turn => ({ ...data, messages: () => call<Message[]>(conversationMethods.messages, { session, turn: data.prompt.id }) })
82
+
83
+ const contextOf = (session: string, data: ContextData): Context => ({
84
+ ...data,
85
+ turns: async () => (await call<TurnData[]>(conversationMethods.turns, { session, context: data.number })).map((turn) => turnOf(session, turn)),
86
+ })
87
+
88
+ const taskOf = (session: string, data: TaskData): Task => ({
89
+ ...data,
90
+ messages: () => call<Message[]>(conversationMethods.messages, { session, task: data.id }),
91
+ tasks: async () => (await call<TaskData[]>(conversationMethods.tasks, { session, task: data.id })).map((task) => taskOf(session, task)),
92
+ })
93
+
94
+ const goalOf = (session: string, data: GoalData): Goal => ({
95
+ ...data,
96
+ update: async ({ text }) => {
97
+ if (text.trim() === '') throw new Error(`${name}: a goal needs text.`)
98
+ await call(conversationMethods.updateGoal, { session, text })
99
+ },
100
+ tasks: async () => (await call<TaskData[]>(conversationMethods.tasks, { session })).map((task) => taskOf(session, task)),
101
+ })
102
+
103
+ const payloadFor = (session: string, change: SessionChange): unknown => {
104
+ if (change.type === Message) return change.message
105
+ if ('turn' in change) return turnOf(session, change.turn)
106
+ if ('context' in change) return contextOf(session, change.context)
107
+ if ('task' in change) return taskOf(session, change.task)
108
+ return goalOf(session, change.goal)
109
+ }
110
+
111
+ const sessionOf = (read: () => SessionData) => {
112
+ const id = () => read().id
113
+ const session = {
114
+ get id() {
115
+ return read().id
116
+ },
117
+ get title() {
118
+ return read().title
119
+ },
120
+ get project() {
121
+ return read().project
122
+ },
123
+ get cwd() {
124
+ return read().cwd
125
+ },
126
+ get model() {
127
+ return read().model
128
+ },
129
+ get startedAt() {
130
+ return read().startedAt
131
+ },
132
+ get lastAt() {
133
+ return read().lastAt
134
+ },
135
+ get forkedFrom() {
136
+ return read().forkedFrom
137
+ },
138
+ get isRunning() {
139
+ return read().isRunning
140
+ },
141
+ async usage(): Promise<Usage> {
142
+ if (!isCurrent(id())) refuseElsewhere(id(), 'usage()')
143
+ return usageOf(claude)
144
+ },
145
+ on(event: SessionEvent, matcherOrListener: unknown, maybeListener?: (payload: never) => unknown): Disposable {
146
+ const listener = (maybeListener ?? matcherOrListener) as (payload: never) => unknown
147
+ const matcher = maybeListener === undefined ? undefined : matcherOrListener
148
+ const owner = id()
149
+ const heard: Listening = { event, matcher, listener }
150
+ const set = listening.get(owner) ?? new Set()
151
+ set.add(heard)
152
+ listening.set(owner, set)
153
+ subscribe(owner)
154
+ return {
155
+ dispose() {
156
+ if (!set.delete(heard)) return
157
+ if (set.size === 0) listening.delete(owner)
158
+ subscribe(owner)
159
+ },
160
+ }
161
+ },
162
+ async send({ type, text, as }: SessionSend) {
163
+ if (text.trim() === '') throw new Error(`${name}: send needs text.`)
164
+ if (isCurrent(id())) {
165
+ if (as === Session) throw new Error(`${name}: send with as: Session messages another session, and this one runs this mod.`)
166
+ if (type === Note) {
167
+ if (as !== undefined) throw new Error(`${name}: a Note is always from this mod, so it takes no as.`)
168
+ const added = await claude.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
169
+ if (added.deny !== undefined) throw new Error(`${name}: Claude Code refused the note: ${added.deny}`)
170
+ return
171
+ }
172
+ const submitted = await claude.prompt.submit({ text, ...(as === Person ? { asUser: true } : {}) })
173
+ if ('drop' in submitted && typeof submitted.drop === 'string') throw new Error(`${name}: Claude Code dropped the prompt: ${submitted.drop}`)
174
+ return
175
+ }
176
+ if (!read().isRunning) throw new Error(`${name}: session ${id()} has ended, so it takes no messages.`)
177
+ if (as === Session) {
178
+ if (type === Note) throw new Error(`${name}: a message from this session reaches Claude as a Prompt, so send it with type: Prompt.`)
179
+ const sent = await claude.session.send({ to: { sessionId: id() }, text })
180
+ if (sent.isDelivered !== true) throw new Error(`${name}: Claude Code could not deliver the message to session ${id()}.`)
181
+ return
182
+ }
183
+ await call(conversationMethods.send, { session: id(), type, text, ...(as === undefined ? {} : { as }) })
184
+ },
185
+ async startTask(task: TaskStartArgs): Promise<Task> {
186
+ if (!isCurrent(id())) refuseElsewhere(id(), 'startTask()')
187
+ const started = await claude.agent.spawn({
188
+ prompt: task.prompt,
189
+ ...(task.name === undefined ? {} : { description: task.name, name: task.name }),
190
+ ...(task.agentType === undefined ? {} : { subagentType: task.agentType }),
191
+ ...(task.model === undefined ? {} : { model: task.model }),
192
+ ...(task.cwd === undefined ? {} : { cwd: task.cwd }),
193
+ })
194
+ if (started.deny !== undefined) throw new Error(`${name}: Claude Code refused the task: ${started.deny}`)
195
+ const data: TaskData = { id: started.agentId ?? '', title: task.name ?? '', agentType: task.agentType ?? 'general-purpose', model: started.model, openedBy: '', openedAt: new Date().toISOString() }
196
+ return taskOf(id(), data)
197
+ },
198
+ messages: (options: { readonly after?: string; readonly last?: number } = {}) => call<Message[]>(conversationMethods.messages, { session: id(), ...options }),
199
+ turns: async (options: { readonly last?: number } = {}) => (await call<TurnData[]>(conversationMethods.turns, { session: id(), ...options })).map((turn) => turnOf(id(), turn)),
200
+ contexts: async () => (await call<ContextData[]>(conversationMethods.contexts, { session: id() })).map((context) => contextOf(id(), context)),
201
+ goal: async () => goalOf(id(), await call<GoalData>(conversationMethods.goal, { session: id() })),
202
+ }
203
+ return session
204
+ }
205
+
206
+ const currentSession = sessionOf(() => current)
207
+
208
+ return {
209
+ sessions: {
210
+ get current() {
211
+ return currentSession as unknown as Sessions['current']
212
+ },
213
+ list: async (scope: SessionScope = {}) =>
214
+ (await call<SessionData[]>(conversationMethods.sessions, scope)).map((data) => {
215
+ if (data.id !== current.id) return sessionOf(() => data) as unknown as Sessions['current']
216
+ if (current.title === '') current = { ...current, title: data.title }
217
+ return currentSession as unknown as Sessions['current']
218
+ }),
219
+ search: (text, options = {}) => call<Message[]>(conversationMethods.search, { text, ...options, ...(options.before === undefined ? {} : { before: { session: options.before.session, id: options.before.id } }) }),
220
+ },
221
+ changed(session, change) {
222
+ for (const { event, matcher, listener } of listening.get(session) ?? []) {
223
+ if (!isHeardBy(event, matcher, change)) continue
224
+ void Promise.resolve()
225
+ .then(() => listener(payloadFor(session, change) as never))
226
+ .catch((error: unknown) => report(`session:${event}`, `The ${event} listener failed`, error))
227
+ }
228
+ },
229
+ follow(next) {
230
+ const moved = next.id !== current.id && current.id !== ''
231
+ const previous = current.id
232
+ current = { ...next, title: next.title || (next.id === current.id ? current.title : ''), isRunning: true, lastAt: new Date().toISOString() }
233
+ if (!moved) return
234
+ const kept = listening.get(previous)
235
+ if (kept === undefined) return
236
+ listening.delete(previous)
237
+ listening.set(current.id, kept)
238
+ subscribe(previous)
239
+ subscribe(current.id)
240
+ },
241
+ }
242
+ }
@@ -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
  }