@moxxy/core 0.3.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 (332) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/dist/events/factory.d.ts +6 -0
  4. package/dist/events/factory.d.ts.map +1 -0
  5. package/dist/events/factory.js +14 -0
  6. package/dist/events/factory.js.map +1 -0
  7. package/dist/events/log.d.ts +92 -0
  8. package/dist/events/log.d.ts.map +1 -0
  9. package/dist/events/log.js +244 -0
  10. package/dist/events/log.js.map +1 -0
  11. package/dist/index.d.ts +40 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +41 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/logger.d.ts +16 -0
  16. package/dist/logger.d.ts.map +1 -0
  17. package/dist/logger.js +27 -0
  18. package/dist/logger.js.map +1 -0
  19. package/dist/permissions/engine.d.ts +94 -0
  20. package/dist/permissions/engine.d.ts.map +1 -0
  21. package/dist/permissions/engine.js +203 -0
  22. package/dist/permissions/engine.js.map +1 -0
  23. package/dist/permissions/resolvers.d.ts +8 -0
  24. package/dist/permissions/resolvers.d.ts.map +1 -0
  25. package/dist/permissions/resolvers.js +8 -0
  26. package/dist/permissions/resolvers.js.map +1 -0
  27. package/dist/plugins/discovery.d.ts +9 -0
  28. package/dist/plugins/discovery.d.ts.map +1 -0
  29. package/dist/plugins/discovery.js +112 -0
  30. package/dist/plugins/discovery.js.map +1 -0
  31. package/dist/plugins/host-options.d.ts +72 -0
  32. package/dist/plugins/host-options.d.ts.map +1 -0
  33. package/dist/plugins/host-options.js +2 -0
  34. package/dist/plugins/host-options.js.map +1 -0
  35. package/dist/plugins/host.d.ts +53 -0
  36. package/dist/plugins/host.d.ts.map +1 -0
  37. package/dist/plugins/host.js +269 -0
  38. package/dist/plugins/host.js.map +1 -0
  39. package/dist/plugins/lifecycle.d.ts +31 -0
  40. package/dist/plugins/lifecycle.d.ts.map +1 -0
  41. package/dist/plugins/lifecycle.js +100 -0
  42. package/dist/plugins/lifecycle.js.map +1 -0
  43. package/dist/plugins/loader.d.ts +7 -0
  44. package/dist/plugins/loader.d.ts.map +1 -0
  45. package/dist/plugins/loader.js +68 -0
  46. package/dist/plugins/loader.js.map +1 -0
  47. package/dist/plugins/package-requirements.d.ts +16 -0
  48. package/dist/plugins/package-requirements.d.ts.map +1 -0
  49. package/dist/plugins/package-requirements.js +52 -0
  50. package/dist/plugins/package-requirements.js.map +1 -0
  51. package/dist/plugins/registry-kinds.d.ts +58 -0
  52. package/dist/plugins/registry-kinds.d.ts.map +1 -0
  53. package/dist/plugins/registry-kinds.js +139 -0
  54. package/dist/plugins/registry-kinds.js.map +1 -0
  55. package/dist/plugins/toposort.d.ts +24 -0
  56. package/dist/plugins/toposort.d.ts.map +1 -0
  57. package/dist/plugins/toposort.js +67 -0
  58. package/dist/plugins/toposort.js.map +1 -0
  59. package/dist/preferences.d.ts +40 -0
  60. package/dist/preferences.d.ts.map +1 -0
  61. package/dist/preferences.js +58 -0
  62. package/dist/preferences.js.map +1 -0
  63. package/dist/registries/active-backend-registry.d.ts +65 -0
  64. package/dist/registries/active-backend-registry.d.ts.map +1 -0
  65. package/dist/registries/active-backend-registry.js +108 -0
  66. package/dist/registries/active-backend-registry.js.map +1 -0
  67. package/dist/registries/active-def-registry.d.ts +59 -0
  68. package/dist/registries/active-def-registry.d.ts.map +1 -0
  69. package/dist/registries/active-def-registry.js +63 -0
  70. package/dist/registries/active-def-registry.js.map +1 -0
  71. package/dist/registries/agents.d.ts +16 -0
  72. package/dist/registries/agents.d.ts.map +1 -0
  73. package/dist/registries/agents.js +17 -0
  74. package/dist/registries/agents.js.map +1 -0
  75. package/dist/registries/cache-strategies.d.ts +12 -0
  76. package/dist/registries/cache-strategies.d.ts.map +1 -0
  77. package/dist/registries/cache-strategies.js +13 -0
  78. package/dist/registries/cache-strategies.js.map +1 -0
  79. package/dist/registries/channels.d.ts +16 -0
  80. package/dist/registries/channels.d.ts.map +1 -0
  81. package/dist/registries/channels.js +35 -0
  82. package/dist/registries/channels.js.map +1 -0
  83. package/dist/registries/commands.d.ts +29 -0
  84. package/dist/registries/commands.d.ts.map +1 -0
  85. package/dist/registries/commands.js +72 -0
  86. package/dist/registries/commands.js.map +1 -0
  87. package/dist/registries/compactors.d.ts +12 -0
  88. package/dist/registries/compactors.d.ts.map +1 -0
  89. package/dist/registries/compactors.js +13 -0
  90. package/dist/registries/compactors.js.map +1 -0
  91. package/dist/registries/def-map-registry.d.ts +33 -0
  92. package/dist/registries/def-map-registry.d.ts.map +1 -0
  93. package/dist/registries/def-map-registry.js +36 -0
  94. package/dist/registries/def-map-registry.js.map +1 -0
  95. package/dist/registries/embedders.d.ts +16 -0
  96. package/dist/registries/embedders.d.ts.map +1 -0
  97. package/dist/registries/embedders.js +17 -0
  98. package/dist/registries/embedders.js.map +1 -0
  99. package/dist/registries/isolators.d.ts +21 -0
  100. package/dist/registries/isolators.d.ts.map +1 -0
  101. package/dist/registries/isolators.js +32 -0
  102. package/dist/registries/isolators.js.map +1 -0
  103. package/dist/registries/modes.d.ts +28 -0
  104. package/dist/registries/modes.d.ts.map +1 -0
  105. package/dist/registries/modes.js +81 -0
  106. package/dist/registries/modes.js.map +1 -0
  107. package/dist/registries/providers.d.ts +44 -0
  108. package/dist/registries/providers.d.ts.map +1 -0
  109. package/dist/registries/providers.js +95 -0
  110. package/dist/registries/providers.js.map +1 -0
  111. package/dist/registries/skills.d.ts +24 -0
  112. package/dist/registries/skills.d.ts.map +1 -0
  113. package/dist/registries/skills.js +81 -0
  114. package/dist/registries/skills.js.map +1 -0
  115. package/dist/registries/surfaces.d.ts +13 -0
  116. package/dist/registries/surfaces.d.ts.map +1 -0
  117. package/dist/registries/surfaces.js +14 -0
  118. package/dist/registries/surfaces.js.map +1 -0
  119. package/dist/registries/synthesizers.d.ts +26 -0
  120. package/dist/registries/synthesizers.d.ts.map +1 -0
  121. package/dist/registries/synthesizers.js +34 -0
  122. package/dist/registries/synthesizers.js.map +1 -0
  123. package/dist/registries/tools.d.ts +49 -0
  124. package/dist/registries/tools.d.ts.map +1 -0
  125. package/dist/registries/tools.js +98 -0
  126. package/dist/registries/tools.js.map +1 -0
  127. package/dist/registries/transcribers.d.ts +17 -0
  128. package/dist/registries/transcribers.d.ts.map +1 -0
  129. package/dist/registries/transcribers.js +18 -0
  130. package/dist/registries/transcribers.js.map +1 -0
  131. package/dist/registries/tunnel-providers.d.ts +12 -0
  132. package/dist/registries/tunnel-providers.d.ts.map +1 -0
  133. package/dist/registries/tunnel-providers.js +13 -0
  134. package/dist/registries/tunnel-providers.js.map +1 -0
  135. package/dist/registries/view-renderers.d.ts +13 -0
  136. package/dist/registries/view-renderers.d.ts.map +1 -0
  137. package/dist/registries/view-renderers.js +14 -0
  138. package/dist/registries/view-renderers.js.map +1 -0
  139. package/dist/registries/workflow-executors.d.ts +13 -0
  140. package/dist/registries/workflow-executors.d.ts.map +1 -0
  141. package/dist/registries/workflow-executors.js +14 -0
  142. package/dist/registries/workflow-executors.js.map +1 -0
  143. package/dist/requirements.d.ts +59 -0
  144. package/dist/requirements.d.ts.map +1 -0
  145. package/dist/requirements.js +149 -0
  146. package/dist/requirements.js.map +1 -0
  147. package/dist/run-turn.d.ts +6 -0
  148. package/dist/run-turn.d.ts.map +1 -0
  149. package/dist/run-turn.js +141 -0
  150. package/dist/run-turn.js.map +1 -0
  151. package/dist/session-runtime.d.ts +67 -0
  152. package/dist/session-runtime.d.ts.map +1 -0
  153. package/dist/session-runtime.js +2 -0
  154. package/dist/session-runtime.js.map +1 -0
  155. package/dist/session.d.ts +203 -0
  156. package/dist/session.d.ts.map +1 -0
  157. package/dist/session.js +399 -0
  158. package/dist/session.js.map +1 -0
  159. package/dist/sessions/persistence.d.ts +184 -0
  160. package/dist/sessions/persistence.d.ts.map +1 -0
  161. package/dist/sessions/persistence.js +442 -0
  162. package/dist/sessions/persistence.js.map +1 -0
  163. package/dist/setup-agent.d.ts +91 -0
  164. package/dist/setup-agent.d.ts.map +1 -0
  165. package/dist/setup-agent.js +138 -0
  166. package/dist/setup-agent.js.map +1 -0
  167. package/dist/skills/index.d.ts +4 -0
  168. package/dist/skills/index.d.ts.map +1 -0
  169. package/dist/skills/index.js +4 -0
  170. package/dist/skills/index.js.map +1 -0
  171. package/dist/skills/loader.d.ts +16 -0
  172. package/dist/skills/loader.d.ts.map +1 -0
  173. package/dist/skills/loader.js +99 -0
  174. package/dist/skills/loader.js.map +1 -0
  175. package/dist/skills/parse.d.ts +5 -0
  176. package/dist/skills/parse.d.ts.map +1 -0
  177. package/dist/skills/parse.js +8 -0
  178. package/dist/skills/parse.js.map +1 -0
  179. package/dist/skills/synthesize-draft.d.ts +8 -0
  180. package/dist/skills/synthesize-draft.d.ts.map +1 -0
  181. package/dist/skills/synthesize-draft.js +32 -0
  182. package/dist/skills/synthesize-draft.js.map +1 -0
  183. package/dist/skills/synthesize.d.ts +33 -0
  184. package/dist/skills/synthesize.d.ts.map +1 -0
  185. package/dist/skills/synthesize.js +241 -0
  186. package/dist/skills/synthesize.js.map +1 -0
  187. package/dist/subagents/events.d.ts +20 -0
  188. package/dist/subagents/events.d.ts.map +1 -0
  189. package/dist/subagents/events.js +146 -0
  190. package/dist/subagents/events.js.map +1 -0
  191. package/dist/subagents/registry.d.ts +40 -0
  192. package/dist/subagents/registry.d.ts.map +1 -0
  193. package/dist/subagents/registry.js +42 -0
  194. package/dist/subagents/registry.js.map +1 -0
  195. package/dist/subagents/run-child.d.ts +43 -0
  196. package/dist/subagents/run-child.d.ts.map +1 -0
  197. package/dist/subagents/run-child.js +276 -0
  198. package/dist/subagents/run-child.js.map +1 -0
  199. package/dist/subagents/spawn.d.ts +8 -0
  200. package/dist/subagents/spawn.d.ts.map +1 -0
  201. package/dist/subagents/spawn.js +8 -0
  202. package/dist/subagents/spawn.js.map +1 -0
  203. package/dist/subagents/tools.d.ts +4 -0
  204. package/dist/subagents/tools.d.ts.map +1 -0
  205. package/dist/subagents/tools.js +13 -0
  206. package/dist/subagents/tools.js.map +1 -0
  207. package/dist/subagents.d.ts +3 -0
  208. package/dist/subagents.d.ts.map +1 -0
  209. package/dist/subagents.js +3 -0
  210. package/dist/subagents.js.map +1 -0
  211. package/dist/surfaces/host.d.ts +53 -0
  212. package/dist/surfaces/host.d.ts.map +1 -0
  213. package/dist/surfaces/host.js +177 -0
  214. package/dist/surfaces/host.js.map +1 -0
  215. package/dist/tunnel/localhost.d.ts +7 -0
  216. package/dist/tunnel/localhost.d.ts.map +1 -0
  217. package/dist/tunnel/localhost.js +21 -0
  218. package/dist/tunnel/localhost.js.map +1 -0
  219. package/dist/usage-stats.d.ts +42 -0
  220. package/dist/usage-stats.d.ts.map +1 -0
  221. package/dist/usage-stats.js +83 -0
  222. package/dist/usage-stats.js.map +1 -0
  223. package/dist/view/default-renderer.d.ts +7 -0
  224. package/dist/view/default-renderer.d.ts.map +1 -0
  225. package/dist/view/default-renderer.js +14 -0
  226. package/dist/view/default-renderer.js.map +1 -0
  227. package/dist/view/parse.d.ts +7 -0
  228. package/dist/view/parse.d.ts.map +1 -0
  229. package/dist/view/parse.js +479 -0
  230. package/dist/view/parse.js.map +1 -0
  231. package/package.json +61 -0
  232. package/src/events/factory.ts +27 -0
  233. package/src/events/log.test.ts +470 -0
  234. package/src/events/log.ts +256 -0
  235. package/src/index.ts +102 -0
  236. package/src/logger.ts +46 -0
  237. package/src/permissions/engine.test.ts +206 -0
  238. package/src/permissions/engine.ts +247 -0
  239. package/src/permissions/resolvers.test.ts +40 -0
  240. package/src/permissions/resolvers.ts +17 -0
  241. package/src/plugins/discovery.test.ts +115 -0
  242. package/src/plugins/discovery.ts +124 -0
  243. package/src/plugins/host-options.ts +73 -0
  244. package/src/plugins/host-view-tunnel.test.ts +86 -0
  245. package/src/plugins/host.test.ts +434 -0
  246. package/src/plugins/host.ts +327 -0
  247. package/src/plugins/lifecycle.test.ts +187 -0
  248. package/src/plugins/lifecycle.ts +136 -0
  249. package/src/plugins/loader.ts +79 -0
  250. package/src/plugins/package-requirements.ts +50 -0
  251. package/src/plugins/registry-kinds.test.ts +162 -0
  252. package/src/plugins/registry-kinds.ts +210 -0
  253. package/src/plugins/toposort.test.ts +66 -0
  254. package/src/plugins/toposort.ts +69 -0
  255. package/src/preferences.test.ts +105 -0
  256. package/src/preferences.ts +84 -0
  257. package/src/registries/active-backend-registry.ts +142 -0
  258. package/src/registries/active-def-registry.test.ts +103 -0
  259. package/src/registries/active-def-registry.ts +97 -0
  260. package/src/registries/agents.ts +18 -0
  261. package/src/registries/cache-strategies.ts +14 -0
  262. package/src/registries/channels.ts +42 -0
  263. package/src/registries/commands.test.ts +71 -0
  264. package/src/registries/commands.ts +78 -0
  265. package/src/registries/compactors.ts +14 -0
  266. package/src/registries/def-map-registry.test.ts +67 -0
  267. package/src/registries/def-map-registry.ts +59 -0
  268. package/src/registries/embedders.test.ts +54 -0
  269. package/src/registries/embedders.ts +18 -0
  270. package/src/registries/isolators.test.ts +43 -0
  271. package/src/registries/isolators.ts +38 -0
  272. package/src/registries/modes.test.ts +66 -0
  273. package/src/registries/modes.ts +80 -0
  274. package/src/registries/providers.test.ts +50 -0
  275. package/src/registries/providers.ts +97 -0
  276. package/src/registries/semantics.test.ts +117 -0
  277. package/src/registries/skills.ts +87 -0
  278. package/src/registries/surfaces.ts +18 -0
  279. package/src/registries/synthesizers.test.ts +63 -0
  280. package/src/registries/synthesizers.ts +36 -0
  281. package/src/registries/tools.test.ts +142 -0
  282. package/src/registries/tools.ts +145 -0
  283. package/src/registries/transcribers.test.ts +70 -0
  284. package/src/registries/transcribers.ts +19 -0
  285. package/src/registries/tunnel-providers.test.ts +30 -0
  286. package/src/registries/tunnel-providers.ts +14 -0
  287. package/src/registries/view-renderers.test.ts +68 -0
  288. package/src/registries/view-renderers.ts +15 -0
  289. package/src/registries/workflow-executors.test.ts +53 -0
  290. package/src/registries/workflow-executors.ts +15 -0
  291. package/src/requirements.targetinfo.test.ts +70 -0
  292. package/src/requirements.test.ts +111 -0
  293. package/src/requirements.ts +262 -0
  294. package/src/run-turn.test.ts +178 -0
  295. package/src/run-turn.ts +156 -0
  296. package/src/session-runtime.ts +75 -0
  297. package/src/session.test.ts +216 -0
  298. package/src/session.ts +491 -0
  299. package/src/sessions/persistence.flush.test.ts +100 -0
  300. package/src/sessions/persistence.ready.test.ts +91 -0
  301. package/src/sessions/persistence.test.ts +274 -0
  302. package/src/sessions/persistence.ts +498 -0
  303. package/src/setup-agent.test.ts +72 -0
  304. package/src/setup-agent.ts +189 -0
  305. package/src/skills/index.ts +14 -0
  306. package/src/skills/loader.test.ts +102 -0
  307. package/src/skills/loader.ts +114 -0
  308. package/src/skills/parse.test.ts +59 -0
  309. package/src/skills/parse.ts +11 -0
  310. package/src/skills/synthesize-draft.test.ts +79 -0
  311. package/src/skills/synthesize-draft.ts +46 -0
  312. package/src/skills/synthesize.test.ts +170 -0
  313. package/src/skills/synthesize.ts +304 -0
  314. package/src/subagents/events.test.ts +56 -0
  315. package/src/subagents/events.ts +194 -0
  316. package/src/subagents/registry.ts +62 -0
  317. package/src/subagents/run-child.test.ts +185 -0
  318. package/src/subagents/run-child.ts +425 -0
  319. package/src/subagents/spawn.ts +12 -0
  320. package/src/subagents/tools.ts +19 -0
  321. package/src/subagents.ts +2 -0
  322. package/src/surfaces/host.test.ts +330 -0
  323. package/src/surfaces/host.ts +192 -0
  324. package/src/tunnel/localhost.test.ts +22 -0
  325. package/src/tunnel/localhost.ts +21 -0
  326. package/src/usage-stats.test.ts +77 -0
  327. package/src/usage-stats.ts +119 -0
  328. package/src/view/default-renderer.test.ts +29 -0
  329. package/src/view/default-renderer.ts +14 -0
  330. package/src/view/parse-extended.test.ts +255 -0
  331. package/src/view/parse.test.ts +232 -0
  332. package/src/view/parse.ts +520 -0
package/src/session.ts ADDED
@@ -0,0 +1,491 @@
1
+ import type {
2
+ AppContext,
3
+ ClientSession,
4
+ MoxxyEvent,
5
+ RunTurnOptions,
6
+ SessionId,
7
+ SessionInfo,
8
+ } from '@moxxy/sdk';
9
+ import { newSessionId, newTurnId } from './events/factory.js';
10
+ import { runTurn as runTurnImpl } from './run-turn.js';
11
+ import type { SessionRuntime } from './session-runtime.js';
12
+ import { EventLog } from './events/log.js';
13
+ import { HookDispatcherImpl } from './plugins/lifecycle.js';
14
+ import { PluginHost, type PluginLoader } from './plugins/host.js';
15
+ import { ProviderRegistry } from './registries/providers.js';
16
+ import { ModeRegistry } from './registries/modes.js';
17
+ import { CacheStrategyRegistry } from './registries/cache-strategies.js';
18
+ import { ViewRendererRegistry } from './registries/view-renderers.js';
19
+ import { defaultViewRenderer } from './view/default-renderer.js';
20
+ import { TunnelProviderRegistry } from './registries/tunnel-providers.js';
21
+ import { localhostTunnel } from './tunnel/localhost.js';
22
+ import { CompactorRegistry } from './registries/compactors.js';
23
+ import { ChannelRegistryImpl } from './registries/channels.js';
24
+ import { SurfaceRegistryImpl } from './registries/surfaces.js';
25
+ import { SurfaceHostImpl } from './surfaces/host.js';
26
+ import { SkillRegistryImpl } from './registries/skills.js';
27
+ import { ToolRegistryImpl, type ToolRegistry } from './registries/tools.js';
28
+ import { AgentRegistry } from './registries/agents.js';
29
+ import { CommandRegistry } from './registries/commands.js';
30
+ import { TranscriberRegistry } from './registries/transcribers.js';
31
+ import { SynthesizerRegistry } from './registries/synthesizers.js';
32
+ import { EmbedderRegistry } from './registries/embedders.js';
33
+ import { IsolatorRegistry } from './registries/isolators.js';
34
+ import { WorkflowExecutorRegistry } from './registries/workflow-executors.js';
35
+ import { RequirementRegistry } from './requirements.js';
36
+ import { PermissionEngine } from './permissions/engine.js';
37
+ import { autoAllowResolver } from './permissions/resolvers.js';
38
+ import { evaluateToolRule } from '@moxxy/sdk';
39
+ import type {
40
+ ApprovalResolver,
41
+ CredentialResolver,
42
+ ElisionSettings,
43
+ McpAdminView,
44
+ ProviderAdminView,
45
+ WorkflowsView,
46
+ PluginsAdminView,
47
+ PendingToolCall,
48
+ PermissionContext,
49
+ PermissionDecision,
50
+ PermissionResolver,
51
+ PermissionRule,
52
+ } from '@moxxy/sdk';
53
+ import { createLogger, silentLogger, type Logger } from './logger.js';
54
+ import { clearRetainedChildren } from './subagents/registry.js';
55
+
56
+ export interface SessionOptions {
57
+ readonly cwd: string;
58
+ readonly logger?: Logger;
59
+ readonly sessionId?: SessionId;
60
+ readonly permissionEngine?: PermissionEngine;
61
+ readonly permissionResolver?: PermissionResolver;
62
+ readonly hookTimeoutMs?: number;
63
+ readonly silent?: boolean;
64
+ /**
65
+ * Optional plugin loader. When provided, `session.pluginHost.discoverAndLoad()`
66
+ * can dynamic-import discovered plugins; without one, only static plugins
67
+ * registered via `registerStatic()` are wired up.
68
+ */
69
+ readonly pluginLoader?: PluginLoader;
70
+ /**
71
+ * Extra directories to scan for plugins, on top of the cwd-rooted
72
+ * `node_modules` walk. The CLI sets this to `~/.moxxy/plugins` (and its
73
+ * `node_modules` subtree) so runtime-installed / scaffolded plugins are
74
+ * discoverable. Crucially these are remembered by the host and reused on
75
+ * `pluginHost.reload()`, so a hot-reload neither drops user plugins nor
76
+ * fails to pick up freshly written ones.
77
+ */
78
+ readonly pluginDiscoveryPaths?: ReadonlyArray<string>;
79
+ /**
80
+ * Predicate (by package name) for whether a discovered plugin is disabled.
81
+ * Forwarded to the PluginHost so `pluginHost.reload()` never resurrects a
82
+ * plugin the user turned off via config `plugins[name].enabled = false`.
83
+ * Reads live state, so a runtime enable/disable applies on the next reload.
84
+ */
85
+ readonly isPluginDisabled?: (packageName: string) => boolean;
86
+ /**
87
+ * Vault-backed secret resolver. When provided, every tool handler gets
88
+ * `ctx.getSecret(name)` so plugins can read an API key / token at call
89
+ * time. The plaintext never enters the model's context or `process.env`;
90
+ * only the handler that asks receives it. The CLI/runner wires this to
91
+ * the session vault's `get`.
92
+ */
93
+ readonly secretResolver?: (name: string) => Promise<string | null>;
94
+ /**
95
+ * Pre-seeded event log. Used by `moxxy resume` to restore the
96
+ * conversation from a persisted JSONL. Subscribers don't re-fire for
97
+ * seeded events (the constructor pushes them directly), so plugin
98
+ * hooks won't run for historical entries.
99
+ */
100
+ readonly log?: EventLog;
101
+ }
102
+
103
+ export class Session implements ClientSession, SessionRuntime {
104
+ readonly id: SessionId;
105
+ readonly cwd: string;
106
+ readonly log: EventLog;
107
+ readonly logger: Logger;
108
+ readonly tools: ToolRegistry;
109
+ readonly providers: ProviderRegistry;
110
+ readonly modes: ModeRegistry;
111
+ readonly compactors: CompactorRegistry;
112
+ readonly cacheStrategies: CacheStrategyRegistry;
113
+ readonly viewRenderers: ViewRendererRegistry;
114
+ readonly tunnelProviders: TunnelProviderRegistry;
115
+ readonly channels: ChannelRegistryImpl;
116
+ /** Registry of surface defs (terminal, browser, …) contributed by plugins. */
117
+ readonly surfaceRegistry: SurfaceRegistryImpl;
118
+ /**
119
+ * Manages the live, open surface instances for this session. The runner
120
+ * drives it for thin clients; the agent's tools reach the same resources via
121
+ * plugin module state. See {@link SurfaceHostImpl}.
122
+ */
123
+ readonly surfaces: SurfaceHostImpl;
124
+ readonly skills: SkillRegistryImpl;
125
+ readonly agents: AgentRegistry;
126
+ readonly commands: CommandRegistry;
127
+ readonly transcribers: TranscriberRegistry;
128
+ readonly synthesizers: SynthesizerRegistry;
129
+ readonly embedders: EmbedderRegistry;
130
+ readonly isolators: IsolatorRegistry;
131
+ readonly workflowExecutors: WorkflowExecutorRegistry;
132
+ readonly requirements: RequirementRegistry;
133
+ readonly permissions: PermissionEngine;
134
+ /** Current PermissionResolver. Update via `setPermissionResolver(r)`. */
135
+ resolver: PermissionResolver;
136
+ /**
137
+ * Optional generic approval resolver. Loop strategies use this to ask
138
+ * the user a checkpoint question (plan validation, command preview,
139
+ * diff review, etc.). Null when running headless or before the TUI
140
+ * registers one — strategies that have no resolver simply skip the
141
+ * approval step.
142
+ */
143
+ approvalResolver: ApprovalResolver | null = null;
144
+ /**
145
+ * Elision (context-on-demand) settings, resolved from `config.context.elision`
146
+ * at setup and updated on config reload. Null → built-in defaults apply
147
+ * (elision on). Read into each turn's ModeContext.
148
+ */
149
+ elisionSettings: ElisionSettings | null = null;
150
+ /** Lazy tool loading toggle, from `config.context.lazyTools`. Default off. */
151
+ lazyTools = false;
152
+ /**
153
+ * Reasoning/thinking preference, from `config.context.reasoning`. Forwarded
154
+ * to each turn's ModeContext and on to the provider, which honors it only
155
+ * when the active model advertises `supportsReasoning`. Undefined → off.
156
+ */
157
+ reasoning: { readonly effort?: 'low' | 'medium' | 'high' } | boolean | undefined = undefined;
158
+ /**
159
+ * Model id resolved by the most recent `runTurn()` (see SessionRuntime).
160
+ * Last-writer-wins for concurrent turns; null until the first turn runs.
161
+ */
162
+ lastResolvedModel: string | null = null;
163
+ /**
164
+ * Live runtime capabilities the host installs on a local Session (see
165
+ * SessionLike). A RemoteSession leaves them undefined. Declared here — rather
166
+ * than monkey-patched on via `as unknown as` — so the host and channels get
167
+ * type-checked access.
168
+ */
169
+ readyProviders?: Set<string>;
170
+ credentialResolver?: CredentialResolver;
171
+ mcpAdmin?: McpAdminView;
172
+ providerAdmin?: ProviderAdminView;
173
+ workflows?: WorkflowsView;
174
+ pluginsAdmin?: PluginsAdminView;
175
+ readonly dispatcher: HookDispatcherImpl;
176
+ readonly pluginHost: PluginHost;
177
+ private readonly controller = new AbortController();
178
+
179
+ constructor(opts: SessionOptions) {
180
+ this.id = opts.sessionId ?? newSessionId();
181
+ this.cwd = opts.cwd;
182
+ this.logger = opts.logger ?? (opts.silent ? silentLogger : createLogger());
183
+ this.log = opts.log ?? new EventLog();
184
+ this.tools = new ToolRegistryImpl({
185
+ logger: this.logger,
186
+ cwd: this.cwd,
187
+ ...(opts.secretResolver ? { secretResolver: opts.secretResolver } : {}),
188
+ });
189
+ this.providers = new ProviderRegistry();
190
+ this.modes = new ModeRegistry();
191
+ this.compactors = new CompactorRegistry();
192
+ this.cacheStrategies = new CacheStrategyRegistry();
193
+ this.viewRenderers = new ViewRendererRegistry();
194
+ // Seed the built-in renderer so `present_view` always has one to parse
195
+ // with; plugins can register/replace and `setActive` an alternative.
196
+ this.viewRenderers.register(defaultViewRenderer);
197
+ this.tunnelProviders = new TunnelProviderRegistry();
198
+ // Seed the no-op localhost provider so the web surface always resolves a
199
+ // URL; plugins (cloudflared) register/setActive a real tunnel.
200
+ this.tunnelProviders.register(localhostTunnel);
201
+ this.channels = new ChannelRegistryImpl();
202
+ this.surfaceRegistry = new SurfaceRegistryImpl();
203
+ this.surfaces = new SurfaceHostImpl(this.surfaceRegistry, { cwd: this.cwd, logger: this.logger }, this.logger);
204
+ this.skills = new SkillRegistryImpl();
205
+ this.agents = new AgentRegistry();
206
+ this.commands = new CommandRegistry();
207
+ this.transcribers = new TranscriberRegistry();
208
+ this.synthesizers = new SynthesizerRegistry({
209
+ ...(opts.secretResolver ? { secretResolver: opts.secretResolver } : {}),
210
+ });
211
+ this.embedders = new EmbedderRegistry();
212
+ this.isolators = new IsolatorRegistry();
213
+ this.workflowExecutors = new WorkflowExecutorRegistry();
214
+ this.requirements = new RequirementRegistry({
215
+ tools: this.tools,
216
+ providers: this.providers,
217
+ modes: this.modes,
218
+ compactors: this.compactors,
219
+ channels: this.channels,
220
+ agents: this.agents,
221
+ commands: this.commands,
222
+ transcribers: this.transcribers,
223
+ synthesizers: this.synthesizers,
224
+ });
225
+ this.permissions = opts.permissionEngine ?? new PermissionEngine();
226
+ // Always wrap the user-supplied resolver with the persistent
227
+ // policy engine, so saved `allow_always` / `deny` rules from
228
+ // ~/.moxxy/permissions.json short-circuit the resolver's prompt
229
+ // path. Without this wrap the engine is dead weight — the
230
+ // permissions JSON updates on every "allow always" click but no
231
+ // future turn ever consults it.
232
+ this.resolver = wrapWithPolicy(
233
+ opts.permissionResolver ?? autoAllowResolver,
234
+ this.permissions,
235
+ (name) => this.tools.get(name)?.permission,
236
+ );
237
+ this.dispatcher = new HookDispatcherImpl({
238
+ logger: this.logger,
239
+ hookTimeoutMs: opts.hookTimeoutMs,
240
+ });
241
+ this.pluginHost = new PluginHost({
242
+ cwd: this.cwd,
243
+ logger: this.logger,
244
+ tools: this.tools,
245
+ providers: this.providers,
246
+ modes: this.modes,
247
+ compactors: this.compactors,
248
+ cacheStrategies: this.cacheStrategies,
249
+ viewRenderers: this.viewRenderers,
250
+ tunnelProviders: this.tunnelProviders,
251
+ channels: this.channels,
252
+ surfaces: this.surfaceRegistry,
253
+ agents: this.agents,
254
+ commands: this.commands,
255
+ transcribers: this.transcribers,
256
+ synthesizers: this.synthesizers,
257
+ embedders: this.embedders,
258
+ isolators: this.isolators,
259
+ workflowExecutors: this.workflowExecutors,
260
+ requirements: this.requirements,
261
+ dispatcher: this.dispatcher,
262
+ loader: opts.pluginLoader,
263
+ ...(opts.pluginDiscoveryPaths ? { userPaths: opts.pluginDiscoveryPaths } : {}),
264
+ ...(opts.isPluginDisabled ? { isDisabled: opts.isPluginDisabled } : {}),
265
+ });
266
+
267
+ // Fan every appended event out to plugin `onEvent` hooks. Without this
268
+ // wiring the hook is dead code — declared on the SDK, dispatched by
269
+ // HookDispatcherImpl, but nothing ever calls dispatchEvent.
270
+ this.log.subscribe((event) => {
271
+ // appContext() clones the whole process.env; only pay that per-event cost
272
+ // when a plugin actually has an onEvent hook to receive it. With none,
273
+ // dispatchEvent would iterate an empty hook list anyway.
274
+ if (!this.dispatcher.hasEventHooks()) return;
275
+ return this.dispatcher.dispatchEvent(event, this.appContext());
276
+ });
277
+ }
278
+
279
+ get signal(): AbortSignal {
280
+ return this.controller.signal;
281
+ }
282
+
283
+ abort(reason = 'user-requested abort'): void {
284
+ this.controller.abort(reason);
285
+ }
286
+
287
+ /**
288
+ * Swap the active PermissionResolver. Channels call this after they're
289
+ * constructed so the session uses the channel's interactive resolver
290
+ * (TUI prompt, Telegram inline keyboard, HTTP allow-list, etc.).
291
+ * Replaces the previous monkey-patching of the private `resolver` field
292
+ * from CLI command code.
293
+ */
294
+ setPermissionResolver(resolver: PermissionResolver): void {
295
+ // Re-wrap so policy rules continue to short-circuit prompts when a
296
+ // channel installs its own resolver mid-session.
297
+ this.resolver = wrapWithPolicy(
298
+ resolver,
299
+ this.permissions,
300
+ (name) => this.tools.get(name)?.permission,
301
+ );
302
+ }
303
+
304
+ /** Install/replace the generic approval resolver. Pass null to clear. */
305
+ setApprovalResolver(resolver: ApprovalResolver | null): void {
306
+ this.approvalResolver = resolver;
307
+ }
308
+
309
+ /**
310
+ * `SessionLike.reset` — the authoritative `/new`. Clears the event log;
311
+ * the log's clear listeners propagate the wipe to whatever observes it
312
+ * (the persistence sidecar truncates its JSONL so `--resume` sees an
313
+ * empty session; a wrapping RunnerServer broadcasts a reset so attached
314
+ * mirrors clear in lockstep). Registries, resolvers, and the active
315
+ * provider survive — only the conversation context vanishes. Callers
316
+ * must abort any in-flight turn first (same contract as `log.clear()`).
317
+ */
318
+ async reset(): Promise<void> {
319
+ this.log.clear();
320
+ }
321
+
322
+ /**
323
+ * Graceful shutdown: fire every plugin's `onShutdown` hook, then abort
324
+ * the session. Idempotent — safe to call multiple times (subsequent
325
+ * calls are no-ops once `closed` is set).
326
+ *
327
+ * Channels' SIGINT handlers should call this before exiting so plugins
328
+ * can flush state (memory journal, vault, audit logs, etc.).
329
+ */
330
+ async close(reason = 'shutdown'): Promise<void> {
331
+ if (this.closed) return;
332
+ this.closed = true;
333
+ try {
334
+ // Tear down any open surfaces (PTYs, browser screencasts) before the
335
+ // plugin shutdown hooks dispose their underlying resources.
336
+ await this.surfaces.closeAll();
337
+ await this.dispatcher.dispatchShutdown(this.appContext());
338
+ } finally {
339
+ // Drop THIS session's retained child sessions (the workflow `awaitInput`
340
+ // flow keeps them in a process-local registry until a
341
+ // `continue()`/`release()`). A paused run that never resumes would
342
+ // otherwise pin them for the whole process lifetime — release them on
343
+ // shutdown so they don't leak. Scoped to this session's id so closing one
344
+ // session never wipes another live session's paused children.
345
+ clearRetainedChildren(this.id);
346
+ this.abort(reason);
347
+ }
348
+ }
349
+
350
+ private closed = false;
351
+
352
+ appContext(): AppContext {
353
+ return {
354
+ sessionId: this.id,
355
+ cwd: this.cwd,
356
+ log: this.log.asReader(),
357
+ env: { ...process.env },
358
+ };
359
+ }
360
+
361
+ startTurn(): { turnId: ReturnType<typeof newTurnId> } {
362
+ return { turnId: newTurnId() };
363
+ }
364
+
365
+ subscribe(fn: (e: MoxxyEvent) => void | Promise<void>): () => void {
366
+ return this.log.subscribe(fn);
367
+ }
368
+
369
+ /**
370
+ * Drive one turn against this session. Method form of the `runTurn` free
371
+ * function so a local `Session` satisfies `SessionLike` (the channel-facing
372
+ * contract a `RemoteSession` proxy also implements).
373
+ */
374
+ runTurn(prompt: string, opts: RunTurnOptions = {}): AsyncIterable<MoxxyEvent> {
375
+ return runTurnImpl(this, prompt, opts);
376
+ }
377
+
378
+ /**
379
+ * Wire-friendly snapshot of the registries for channels to render. Mirrors
380
+ * what a `RemoteSession` fetches from the runner over RPC - keep the two in
381
+ * sync.
382
+ */
383
+ getInfo(): SessionInfo {
384
+ let activeMode: string | null = null;
385
+ let activeModeBadge: SessionInfo['activeModeBadge'] = null;
386
+ try {
387
+ const mode = this.modes.getActive();
388
+ activeMode = mode.name;
389
+ // Surface the active mode's presentation hint so channels can render a
390
+ // persistent badge (e.g. goal mode) without hard-coding mode names.
391
+ activeModeBadge = mode.badge ? { label: mode.badge.label, ...(mode.badge.tone ? { tone: mode.badge.tone } : {}) } : null;
392
+ } catch {
393
+ // No mode active yet (registry empty pre-boot) - report null.
394
+ }
395
+ const active = this.providers.getActiveName();
396
+ const ready = this.readyProviders;
397
+ return {
398
+ sessionId: this.id,
399
+ cwd: this.cwd,
400
+ activeProvider: active,
401
+ providers: this.providers.list().map((p) => ({
402
+ name: p.name,
403
+ models: p.models,
404
+ authKind: p.auth?.kind === 'oauth' ? 'oauth' : 'api-key',
405
+ // Built-in providers ship hard-coded model lists, so live
406
+ // discovery on /v1/models isn't required from the host. Admin-
407
+ // registered providers (kind: 'apiKey' without a builtin def)
408
+ // are the ones the desktop's "Fetch live" affordance targets;
409
+ // they advertise this via the provider-admin factory by
410
+ // setting `supportsLiveModelDiscovery: true` on their def.
411
+ supportsLiveModelDiscovery:
412
+ (p as { supportsLiveModelDiscovery?: boolean }).supportsLiveModelDiscovery === true,
413
+ enabled: this.providers.isEnabled(p.name),
414
+ })),
415
+ activeMode,
416
+ activeModeBadge,
417
+ modes: this.modes.list().map((m) => m.name),
418
+ tools: this.tools.list().map((t) => ({
419
+ name: t.name,
420
+ description: t.description,
421
+ ...(t.compact ? { compact: t.compact } : {}),
422
+ })),
423
+ skills: this.skills.list().map((s) => ({ id: s.id, name: s.frontmatter.name })),
424
+ commands: this.commands.list().map((c) => ({
425
+ name: c.name,
426
+ description: c.description,
427
+ ...(c.aliases ? { aliases: c.aliases } : {}),
428
+ ...(c.channels ? { channels: c.channels } : {}),
429
+ ...(c.pendingNotice ? { pendingNotice: c.pendingNotice } : {}),
430
+ })),
431
+ readyProviders: ready ? [...ready] : active ? [active] : [],
432
+ // hasTranscriber reports whether any backend is *registered*,
433
+ // not whether one is active. The active selection is per-flow
434
+ // (the TUI activates Codex on its first voice toggle; the
435
+ // desktop relies on handleTranscribe's candidate fallback).
436
+ // For UI affordance gating (showing / hiding a mic button),
437
+ // any registered transcriber means "voice is wired."
438
+ hasTranscriber: this.transcribers.list().length > 0,
439
+ activeTranscriber: this.transcribers.getActiveName(),
440
+ hasSynthesizer: this.synthesizers.list().length > 0,
441
+ activeSynthesizer: this.synthesizers.getActiveName(),
442
+ };
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Wrap a `PermissionResolver` so the persistent `PermissionEngine` runs
448
+ * first. If the engine has a matching allow/deny rule from
449
+ * `~/.moxxy/permissions.json`, that decision short-circuits the
450
+ * resolver's prompt path. Otherwise the resolver runs as usual.
451
+ *
452
+ * The wrapper is a Proxy that intercepts only `check` (and provides the
453
+ * prompt-free `policyCheck` probe); every other property (e.g. `abortAll`
454
+ * and any channel-specific helpers) passes straight through to the
455
+ * underlying resolver, so callers reach those methods exactly as before.
456
+ */
457
+ function wrapWithPolicy(
458
+ inner: PermissionResolver,
459
+ engine: PermissionEngine,
460
+ getToolRule: (name: string) => PermissionRule | undefined,
461
+ ): PermissionResolver {
462
+ // The policy-only decision: user policy (permissions.json) wins, then the
463
+ // tool's own declared rule (so a tool marked `allow` is never blocked in
464
+ // headless runs). Returns null when neither decides — `check` then falls
465
+ // through to the channel resolver's prompt / deny-by-default path, while
466
+ // `policyCheck` callers (auto-approving modes) supply their own fallback
467
+ // so no prompt can ever fire.
468
+ const policyDecision = (call: PendingToolCall): PermissionDecision | null => {
469
+ const policy = engine.check(call);
470
+ if (policy) return policy;
471
+ return evaluateToolRule(getToolRule(call.name), call);
472
+ };
473
+ // Use a Proxy so any extra methods on the underlying resolver
474
+ // (`abortAll`, channel-specific helpers) remain accessible — only
475
+ // `check`/`policyCheck` are intercepted.
476
+ return new Proxy(inner, {
477
+ get(target, prop, receiver) {
478
+ if (prop === 'check') {
479
+ return async (call: PendingToolCall, ctx: PermissionContext) => {
480
+ const decided = policyDecision(call);
481
+ if (decided) return decided;
482
+ return target.check(call, ctx);
483
+ };
484
+ }
485
+ if (prop === 'policyCheck') {
486
+ return async (call: PendingToolCall) => policyDecision(call);
487
+ }
488
+ return Reflect.get(target, prop, receiver);
489
+ },
490
+ });
491
+ }
@@ -0,0 +1,100 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { EventLog } from '../events/log.js';
6
+ import { SessionPersistence, type SessionMeta } from './persistence.js';
7
+
8
+ const tempDirs: string[] = [];
9
+
10
+ async function makeTempDir(): Promise<string> {
11
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'moxxy-flush-'));
12
+ tempDirs.push(dir);
13
+ return dir;
14
+ }
15
+
16
+ afterEach(async () => {
17
+ await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
18
+ });
19
+
20
+ async function readMeta(dir: string, id: string): Promise<SessionMeta> {
21
+ const raw = await fs.readFile(path.join(dir, `${id}.meta.json`), 'utf8');
22
+ return JSON.parse(raw) as SessionMeta;
23
+ }
24
+
25
+ describe('SessionPersistence final-write on detach (async-error-3)', () => {
26
+ it('flush() persists the latest meta immediately, bypassing the 250ms debounce', async () => {
27
+ const dir = await makeTempDir();
28
+ const id = '01FLUSHBYPASS0000000000000';
29
+ const log = new EventLog();
30
+ const persistence = new SessionPersistence({ sessionId: id as never, cwd: '/tmp/p', dir });
31
+ const detach = persistence.attach(log);
32
+
33
+ // An append schedules a debounced index write but does NOT flush it.
34
+ await log.append({
35
+ type: 'user_prompt',
36
+ sessionId: id as never,
37
+ turnId: 't1' as never,
38
+ source: 'user',
39
+ text: 'hello',
40
+ });
41
+
42
+ // flush() forces the pending write through now. If it waited on the
43
+ // debounce this would still read the pre-append (eventCount 0) sidecar.
44
+ await persistence.flush();
45
+ const after = await readMeta(dir, id);
46
+ expect(after.eventCount).toBe(1);
47
+ expect(after.firstPrompt).toBe('hello');
48
+
49
+ detach();
50
+ });
51
+
52
+ it('detach writes the final index row without waiting for the debounce window', async () => {
53
+ const dir = await makeTempDir();
54
+ const id = '01DETACHFINAL00000000000000';
55
+ const log = new EventLog();
56
+ const persistence = new SessionPersistence({ sessionId: id as never, cwd: '/tmp/p', dir });
57
+ const detach = persistence.attach(log);
58
+
59
+ await log.append({
60
+ type: 'user_prompt',
61
+ sessionId: id as never,
62
+ turnId: 't1' as never,
63
+ source: 'user',
64
+ text: 'last activity',
65
+ });
66
+
67
+ // Flush the append's debounced write, then capture the timestamp.
68
+ await persistence.flush();
69
+ const beforeDetach = await readMeta(dir, id);
70
+
71
+ // Detach must fire its own final (close-time) write synchronously, not on a
72
+ // 250ms timer an immediate exit would drop. We poll only a microtask-scale
73
+ // window — far shorter than the debounce — to prove it doesn't wait on it.
74
+ detach();
75
+ await waitForCondition(async () => {
76
+ const meta = await readMeta(dir, id);
77
+ return meta.lastActivity >= beforeDetach.lastActivity && meta.eventCount === 1;
78
+ }, 100);
79
+
80
+ const finalMeta = await readMeta(dir, id);
81
+ expect(finalMeta.eventCount).toBe(1);
82
+ expect(finalMeta.id).toBe(id);
83
+ });
84
+ });
85
+
86
+ async function waitForCondition(
87
+ predicate: () => Promise<boolean>,
88
+ timeoutMs: number,
89
+ ): Promise<void> {
90
+ const deadline = Date.now() + timeoutMs;
91
+ for (;;) {
92
+ try {
93
+ if (await predicate()) return;
94
+ } catch {
95
+ // sidecar may not exist yet — keep polling
96
+ }
97
+ if (Date.now() > deadline) throw new Error('Timed out waiting for condition');
98
+ await new Promise((resolve) => setTimeout(resolve, 5));
99
+ }
100
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Regression for u45-2: `writeIndex()` used to re-run ensureDir + ensureLogFile
3
+ * (an `fs.open(logPath,'a')` + close) on EVERY 250ms debounced index flush. The
4
+ * one-time setup is now memoized, so `fs.open` fires once (at attach), never
5
+ * again from the index-write path.
6
+ */
7
+
8
+ import { promises as fs } from 'node:fs';
9
+ import * as os from 'node:os';
10
+ import * as path from 'node:path';
11
+ import { afterEach, describe, expect, it, vi } from 'vitest';
12
+ import { EventLog } from '../events/log.js';
13
+ import { SessionPersistence, restoreEvents } from './persistence.js';
14
+
15
+ const tempDirs: string[] = [];
16
+
17
+ async function makeTempDir(): Promise<string> {
18
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'moxxy-ready-'));
19
+ tempDirs.push(dir);
20
+ return dir;
21
+ }
22
+
23
+ afterEach(async () => {
24
+ vi.restoreAllMocks();
25
+ await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
26
+ });
27
+
28
+ describe('SessionPersistence one-time setup (no per-flush open/close)', () => {
29
+ it('opens the log file at most once across attach + many flushes', async () => {
30
+ const dir = await makeTempDir();
31
+ const id = '01READYONCE00000000000000A';
32
+ const log = new EventLog();
33
+ const persistence = new SessionPersistence({ sessionId: id as never, cwd: '/tmp/p', dir });
34
+
35
+ const openSpy = vi.spyOn(fs, 'open');
36
+ const detach = persistence.attach(log);
37
+
38
+ // Drive several index flushes (each bypasses the debounce).
39
+ for (let i = 0; i < 5; i += 1) {
40
+ await log.append({
41
+ type: 'user_prompt',
42
+ sessionId: id as never,
43
+ turnId: `t${i}` as never,
44
+ source: 'user',
45
+ text: `msg ${i}`,
46
+ });
47
+ await persistence.flush();
48
+ }
49
+ detach();
50
+ await persistence.flush();
51
+
52
+ // Exactly one open (the initial ensureLogFile) — not one per flush.
53
+ expect(openSpy).toHaveBeenCalledTimes(1);
54
+
55
+ // Sidecar still written correctly.
56
+ const raw = await fs.readFile(path.join(dir, `${id}.meta.json`), 'utf8');
57
+ const meta = JSON.parse(raw) as { eventCount: number; firstPrompt: string | null };
58
+ expect(meta.eventCount).toBe(5);
59
+ expect(meta.firstPrompt).toBe('msg 0');
60
+ });
61
+
62
+ it('first append into a not-yet-created nested dir does not race ensureDir (u45-1)', async () => {
63
+ // The first event arrives synchronously after attach, before the detached
64
+ // ensureReady() resolves, into a sessions dir that does not exist yet.
65
+ // appendFile(flag 'a') creates the file but NOT its parent dir, so without
66
+ // serializing the append behind ensureReady() this ENOENT'd, latched the
67
+ // "persistence degraded" warning, and lost the event.
68
+ const base = await makeTempDir();
69
+ const dir = path.join(base, 'deeply', 'nested', 'sessions'); // does not exist
70
+ const id = '01READYRACE00000000000000B';
71
+ const log = new EventLog();
72
+ const persistence = new SessionPersistence({ sessionId: id as never, cwd: '/tmp/p', dir });
73
+
74
+ persistence.attach(log);
75
+ // Append in the SAME tick as attach — before ensureReady() can resolve.
76
+ await log.append({
77
+ type: 'user_prompt',
78
+ sessionId: id as never,
79
+ turnId: 't0' as never,
80
+ source: 'user',
81
+ text: 'first ever event',
82
+ });
83
+ await persistence.flush();
84
+
85
+ // Never went degraded, and the first event survived.
86
+ expect(persistence.degraded).toBe(false);
87
+ const restored = await restoreEvents(id, dir);
88
+ expect(restored).toHaveLength(1);
89
+ expect(restored[0]!.type).toBe('user_prompt');
90
+ });
91
+ });