@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
@@ -0,0 +1,498 @@
1
+ /**
2
+ * Session persistence — appends each event in `session.log` to a
3
+ * per-session JSONL file under `~/.moxxy/sessions/`, and maintains an
4
+ * `index.json` of session metadata so `moxxy resume` can list and
5
+ * pick a prior session without scanning every event file.
6
+ *
7
+ * Layout:
8
+ * ~/.moxxy/sessions/
9
+ * <sessionId>.meta.json per-session metadata sidecar (one per session)
10
+ * <sessionId>.jsonl one MoxxyEvent per line
11
+ *
12
+ * Each session writes only its OWN `.meta.json` sidecar (write-temp-rename), so
13
+ * two concurrent moxxy processes can't drop each other's row the way a shared
14
+ * `index.json` read-modify-write would. `readIndex` assembles the sidecars (and
15
+ * a legacy `index.json`, if present, for back-compat). JSONL appends are
16
+ * best-effort (lose at most the last in-flight event on a crash).
17
+ */
18
+
19
+ import { promises as fs } from 'node:fs';
20
+ import * as os from 'node:os';
21
+ import * as path from 'node:path';
22
+ import { createMutex, type Mutex, type MoxxyEvent, type SessionId } from '@moxxy/sdk';
23
+ import { writeFileAtomic } from '@moxxy/sdk/server';
24
+ import type { EventLog } from '../events/log.js';
25
+ import { createLogger, type Logger } from '../logger.js';
26
+
27
+ export interface SessionMeta {
28
+ readonly id: string;
29
+ readonly cwd: string;
30
+ readonly startedAt: string;
31
+ readonly lastActivity: string;
32
+ readonly eventCount: number;
33
+ /** First 80 chars of the first user_prompt. Used as the picker label. */
34
+ readonly firstPrompt: string | null;
35
+ readonly provider: string | null;
36
+ readonly model: string | null;
37
+ }
38
+
39
+ export interface SessionPersistenceOpts {
40
+ readonly sessionId: SessionId;
41
+ readonly cwd: string;
42
+ /** Override the storage root. Defaults to `~/.moxxy/sessions`. */
43
+ readonly dir?: string;
44
+ /** Currently-active provider name — captured into the index for the picker. */
45
+ readonly providerName?: string;
46
+ /** Currently-active model id — captured into the index for the picker. */
47
+ readonly modelId?: string;
48
+ /**
49
+ * Structured logger for persistence-degradation warnings. Defaults to a
50
+ * stderr JSON logger — event-log write failures are the session's source
51
+ * of truth going dark, so they must be loud even when nothing injects one.
52
+ */
53
+ readonly logger?: Logger;
54
+ }
55
+
56
+ export function defaultSessionsDir(): string {
57
+ return path.join(os.homedir(), '.moxxy', 'sessions');
58
+ }
59
+
60
+ /**
61
+ * Attaches a listener that streams every appended event to disk and
62
+ * keeps the index in sync. Returns an `unsubscribe` callback the
63
+ * caller should run on shutdown.
64
+ */
65
+ export class SessionPersistence {
66
+ private readonly dir: string;
67
+ private readonly id: string;
68
+ private readonly logPath: string;
69
+ private meta: SessionMeta;
70
+ private indexUpdateScheduled = false;
71
+ /** Handle for the in-flight debounce timer, so `flush()` can cancel it. */
72
+ private indexTimer: ReturnType<typeof setTimeout> | null = null;
73
+ /**
74
+ * In-flight writes are serialized through this mutex so the file
75
+ * stays append-ordered even when events arrive faster than the disk
76
+ * can flush.
77
+ */
78
+ private writeQueue: Mutex = createMutex();
79
+ /**
80
+ * Memoized one-time setup: `mkdir -p` the sessions dir and create the empty
81
+ * `.jsonl` (so resume lists the session before any event). Awaited by both
82
+ * `attach` and `writeIndex` so an early debounced index flush can't race the
83
+ * initial creation — but it runs the open+close syscalls ONCE, not on every
84
+ * 250ms flush as before.
85
+ */
86
+ private ready: Promise<void> | null = null;
87
+ private closed = false;
88
+ private readonly logger: Logger;
89
+ /**
90
+ * Latched while event-log writes are failing. Doubles as the warn-once
91
+ * gate: only the first failure of a streak logs; a subsequent successful
92
+ * write clears the latch (and re-arms the warning).
93
+ */
94
+ private writeDegraded = false;
95
+
96
+ constructor(opts: SessionPersistenceOpts) {
97
+ this.dir = opts.dir ?? defaultSessionsDir();
98
+ this.id = String(opts.sessionId);
99
+ this.logger = opts.logger ?? createLogger();
100
+ this.logPath = path.join(this.dir, `${this.id}.jsonl`);
101
+ const now = new Date().toISOString();
102
+ this.meta = {
103
+ id: this.id,
104
+ cwd: opts.cwd,
105
+ startedAt: now,
106
+ lastActivity: now,
107
+ eventCount: 0,
108
+ firstPrompt: null,
109
+ provider: opts.providerName ?? null,
110
+ model: opts.modelId ?? null,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Subscribe to the log; returns the unsubscribe callback. The first
116
+ * call also writes the initial index row so `moxxy resume` lists
117
+ * the session before any events arrive.
118
+ *
119
+ * Also subscribes to the log's `clear()`, truncating the JSONL in
120
+ * lockstep so a `/new` wipe can't resurrect on `--resume`. The
121
+ * session keeps its id and file: post-reset events restart at seq 0
122
+ * in the same (now empty) JSONL, matching how the in-memory log
123
+ * reuses the same Session object.
124
+ */
125
+ attach(log: EventLog): () => void {
126
+ void this.ensureReady()
127
+ .then(() => this.scheduleIndexWrite())
128
+ .catch(() => undefined);
129
+ const unsub = log.subscribe((event) => {
130
+ if (this.closed) return;
131
+ this.enqueueAppend(event);
132
+ });
133
+ const unsubClear = log.onClear(() => {
134
+ if (this.closed) return;
135
+ this.enqueueTruncate();
136
+ });
137
+ return () => {
138
+ this.closed = true;
139
+ unsub();
140
+ unsubClear();
141
+ // Schedule a final index write so lastActivity reflects the close time
142
+ // even if no events arrived in the last debounce window. This is the
143
+ // best-effort, fire-and-forget path; the debounce timer is `unref`'d, so
144
+ // a process that exits immediately after detach would drop it. Shutdown
145
+ // paths that MUST not lose the final row should `await persistence.flush()`
146
+ // instead — it bypasses the debounce and resolves once the row is on disk.
147
+ this.scheduleIndexWrite();
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Force the pending (debounced) index write to happen now and resolve once
153
+ * the meta sidecar is on disk. Cancels the in-flight debounce timer so the
154
+ * write isn't also re-run on its original schedule. Intended for awaitable
155
+ * shutdown: `detach()` only *schedules* the final write (an `unref`'d timer
156
+ * an immediate `process.exit` can drop), whereas `await flush()` guarantees
157
+ * it completed. The steady-state debounce in `scheduleIndexWrite` is
158
+ * untouched, so calling this mid-session just collapses the next write early.
159
+ */
160
+ async flush(): Promise<void> {
161
+ if (this.indexTimer) {
162
+ clearTimeout(this.indexTimer);
163
+ this.indexTimer = null;
164
+ }
165
+ this.indexUpdateScheduled = false;
166
+ await this.writeIndex();
167
+ }
168
+
169
+ /**
170
+ * Resolve once every event-log write queued so far has settled. Appends are
171
+ * enqueued fire-and-forget (`enqueueAppend`), so callers that need to observe
172
+ * the on-disk result of prior appends — graceful shutdown, or a test that
173
+ * mutates the filesystem between writes — await this to drain the queue
174
+ * rather than guessing at timing. Enqueues a no-op at the tail of the same
175
+ * mutex, so it can only resolve after all earlier appends/truncates have run.
176
+ */
177
+ async settleWrites(): Promise<void> {
178
+ await this.writeQueue.run(() => undefined);
179
+ }
180
+
181
+ /**
182
+ * Manually update header fields (provider/model) when the user
183
+ * switches mid-session. The /model picker calls this so the index
184
+ * reflects the active model when the session is resumed.
185
+ */
186
+ updateHeader(patch: { providerName?: string; modelId?: string }): void {
187
+ this.meta = {
188
+ ...this.meta,
189
+ provider: patch.providerName ?? this.meta.provider,
190
+ model: patch.modelId ?? this.meta.model,
191
+ };
192
+ this.scheduleIndexWrite();
193
+ }
194
+
195
+ private enqueueAppend(event: MoxxyEvent): void {
196
+ // Update in-memory meta synchronously so multiple events in the
197
+ // same tick share one debounced index write.
198
+ this.meta = {
199
+ ...this.meta,
200
+ eventCount: this.meta.eventCount + 1,
201
+ lastActivity: new Date().toISOString(),
202
+ firstPrompt:
203
+ this.meta.firstPrompt ??
204
+ (event.type === 'user_prompt' ? event.text.slice(0, 80) : null),
205
+ };
206
+ this.scheduleIndexWrite();
207
+ const line = JSON.stringify(event) + '\n';
208
+ // Never propagate a write error into the listener chain — but never
209
+ // swallow it silently either: the JSONL is the session's source of
210
+ // truth, so a failing disk must at least be loud.
211
+ //
212
+ // Serialize the append behind `ensureReady()` (memoized mkdir -p + open):
213
+ // `fs.appendFile` with flag 'a' creates the FILE but not its parent dir, so
214
+ // a first event that arrives before `attach()`'s detached `ensureReady()`
215
+ // resolves on a machine without `~/.moxxy/sessions` would ENOENT, latch the
216
+ // misleading "persistence degraded" warning, and lose that event. Because
217
+ // `ready` is memoized, every later append pays nothing.
218
+ void this.writeQueue
219
+ .run(() => this.ensureReady().then(() => fs.appendFile(this.logPath, line, 'utf8')))
220
+ .then(() => this.noteWriteOk())
221
+ .catch((err: unknown) => this.noteWriteFailure('append', err));
222
+ }
223
+
224
+ /**
225
+ * True while event-log writes are failing (history is no longer being
226
+ * persisted). Cleared automatically by the next successful write.
227
+ */
228
+ get degraded(): boolean {
229
+ return this.writeDegraded;
230
+ }
231
+
232
+ private noteWriteOk(): void {
233
+ if (!this.writeDegraded) return;
234
+ this.writeDegraded = false;
235
+ this.logger.info('session event-log writes recovered', { path: this.logPath });
236
+ }
237
+
238
+ private noteWriteFailure(op: 'append' | 'truncate', err: unknown): void {
239
+ const alreadyDegraded = this.writeDegraded;
240
+ this.writeDegraded = true;
241
+ if (alreadyDegraded) return; // warn once per failure streak, not per event
242
+ this.logger.warn(
243
+ 'session event-log write failed — history persistence degraded (resume will miss these events)',
244
+ {
245
+ op,
246
+ path: this.logPath,
247
+ sessionId: this.id,
248
+ error: err instanceof Error ? err.message : String(err),
249
+ },
250
+ );
251
+ }
252
+
253
+ /**
254
+ * Mirror a `log.clear()` on disk: truncate the JSONL and reset the
255
+ * sidecar's counters. Rides the same write queue as appends, so
256
+ * pre-clear appends flush first and post-clear appends land in the
257
+ * fresh (empty) file — ordering matches the in-memory log exactly.
258
+ */
259
+ private enqueueTruncate(): void {
260
+ this.meta = {
261
+ ...this.meta,
262
+ eventCount: 0,
263
+ firstPrompt: null,
264
+ lastActivity: new Date().toISOString(),
265
+ };
266
+ this.scheduleIndexWrite();
267
+ void this.writeQueue
268
+ .run(() => this.ensureReady().then(() => fs.writeFile(this.logPath, '', 'utf8')))
269
+ .then(() => this.noteWriteOk())
270
+ .catch((err: unknown) => this.noteWriteFailure('truncate', err));
271
+ }
272
+
273
+ private scheduleIndexWrite(): void {
274
+ if (this.indexUpdateScheduled) return;
275
+ this.indexUpdateScheduled = true;
276
+ // 250ms debounce — fast enough that the picker stays current,
277
+ // slow enough that a chatty turn doesn't rewrite the index per
278
+ // assistant_chunk.
279
+ const timer = setTimeout(() => {
280
+ this.indexUpdateScheduled = false;
281
+ this.indexTimer = null;
282
+ void this.writeIndex();
283
+ }, 250);
284
+ timer.unref?.();
285
+ this.indexTimer = timer;
286
+ }
287
+
288
+ private async writeIndex(): Promise<void> {
289
+ try {
290
+ // Once closed (the final detach/flush write), don't re-create the dir or
291
+ // log file: setup already made them, and resurrecting a directory a
292
+ // concurrent `deleteSession`/teardown is removing would leave a stray
293
+ // sidecar behind. A live session awaits the (memoized) one-time setup so
294
+ // an early write can't race the initial creation — but this no longer
295
+ // re-runs the mkdir/open+close syscalls on every 250ms flush.
296
+ if (!this.closed) {
297
+ await this.ensureReady();
298
+ }
299
+ // Write ONLY this session's sidecar (`<id>.meta.json`), never a
300
+ // read-modify-write of a shared index.json — that loses rows when two
301
+ // moxxy processes update "their" row concurrently (each reads the index,
302
+ // re-adds only itself, and the last writer drops the other's row).
303
+ // `writeJsonAtomic` already `mkdir -p`s the sidecar's dir, so this owns no
304
+ // ensureLogFile — the `.jsonl` is the append path's responsibility.
305
+ await writeJsonAtomic(metaPath(this.dir, this.meta.id), this.meta);
306
+ } catch {
307
+ // Index write failures shouldn't bring down a session; the
308
+ // user can always re-resume by id from the filename.
309
+ }
310
+ }
311
+
312
+ /** One-time, memoized: `mkdir -p` the dir + create the empty `.jsonl`. On
313
+ * failure the latch is cleared so a later flush retries (matching the old
314
+ * per-write ensureDir/ensureLogFile recoverability). */
315
+ private ensureReady(): Promise<void> {
316
+ if (!this.ready) {
317
+ this.ready = (async () => {
318
+ await fs.mkdir(this.dir, { recursive: true });
319
+ const handle = await fs.open(this.logPath, 'a');
320
+ await handle.close();
321
+ })().catch((err) => {
322
+ this.ready = null;
323
+ throw err;
324
+ });
325
+ }
326
+ return this.ready;
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Read the session index by assembling per-session sidecars (`<id>.meta.json`)
332
+ * plus a legacy `index.json` if one exists (sidecars win by id). Sessions whose
333
+ * `.jsonl` is missing are dropped. Sorted most-recent-activity first.
334
+ */
335
+ export async function readIndex(dir = defaultSessionsDir()): Promise<SessionMeta[]> {
336
+ const byId = new Map<string, SessionMeta>();
337
+
338
+ // Legacy single-file index (pre-sidecar layout). Sidecars override it below.
339
+ try {
340
+ const raw = await fs.readFile(path.join(dir, 'index.json'), 'utf8');
341
+ const parsed = JSON.parse(raw) as unknown;
342
+ if (Array.isArray(parsed)) {
343
+ for (const m of parsed.filter(isSessionMeta)) byId.set(m.id, m);
344
+ }
345
+ } catch {
346
+ // no legacy index — fine
347
+ }
348
+
349
+ let dirents: import('node:fs').Dirent[];
350
+ try {
351
+ dirents = await fs.readdir(dir, { withFileTypes: true });
352
+ } catch {
353
+ dirents = [];
354
+ }
355
+ await Promise.all(
356
+ dirents
357
+ .filter((d) => d.isFile() && d.name.endsWith('.meta.json'))
358
+ .map(async (d) => {
359
+ try {
360
+ const raw = await fs.readFile(path.join(dir, d.name), 'utf8');
361
+ const parsed = JSON.parse(raw) as unknown;
362
+ if (isSessionMeta(parsed)) byId.set(parsed.id, parsed);
363
+ } catch {
364
+ // skip a malformed/half-written sidecar
365
+ }
366
+ }),
367
+ );
368
+
369
+ const metas = [...byId.values()];
370
+ const checks = await Promise.all(
371
+ metas.map(async (meta) => {
372
+ try {
373
+ await fs.access(path.join(dir, `${meta.id}.jsonl`));
374
+ return true;
375
+ } catch {
376
+ return false;
377
+ }
378
+ }),
379
+ );
380
+ return metas
381
+ .filter((_, index) => checks[index])
382
+ .sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
383
+ }
384
+
385
+ /**
386
+ * Restore a previously-persisted session's events. Returns the full
387
+ * event array suitable for passing into `new EventLog(events)`.
388
+ *
389
+ * Skips malformed lines (a single corrupted append shouldn't make the
390
+ * rest of the conversation unreadable) and then RE-SEQUENCES the
391
+ * survivors to contiguous `seq` 0..n-1, preserving order and ids. This
392
+ * matters twice over:
393
+ *
394
+ * - Mirror replay: `EventLog.ingest` accepts only `seq === length`, so
395
+ * a gap left by one corrupt middle line would silently truncate every
396
+ * attached client's history at that point.
397
+ * - Future appends: `EventLog.append` mints `seq = events.length`, so a
398
+ * gapped seed would mint colliding/out-of-order seqs.
399
+ *
400
+ * When anything was skipped or re-sequenced, the file is atomically
401
+ * rewritten with the repaired events so the NEXT resume starts clean —
402
+ * otherwise post-resume appends (seq = n-gap..) would interleave with the
403
+ * stale higher on-disk seqs forever. Safe ordering-wise: restore runs
404
+ * before `SessionPersistence.attach`, so no append queue is live yet.
405
+ * Note: compaction/elision events referencing seqs after a gap shift by
406
+ * the gap size — an accepted, logged trade-off versus losing all
407
+ * post-gap history.
408
+ */
409
+ export async function restoreEvents(
410
+ sessionId: string,
411
+ dir = defaultSessionsDir(),
412
+ logger: Logger = createLogger(),
413
+ ): Promise<MoxxyEvent[]> {
414
+ const logPath = path.join(dir, `${sessionId}.jsonl`);
415
+ let raw: string;
416
+ try {
417
+ raw = await fs.readFile(logPath, 'utf8');
418
+ } catch {
419
+ throw new Error(`Session not found: ${sessionId}`);
420
+ }
421
+ const events: MoxxyEvent[] = [];
422
+ let corruptLines = 0;
423
+ for (const line of raw.split('\n')) {
424
+ if (!line.trim()) continue;
425
+ try {
426
+ events.push(JSON.parse(line) as MoxxyEvent);
427
+ } catch {
428
+ corruptLines += 1;
429
+ }
430
+ }
431
+
432
+ // Re-sequence to contiguous 0..n-1 (order + ids preserved). A clean log is
433
+ // already contiguous, so this is a no-op in the common case.
434
+ let resequenced = 0;
435
+ for (let i = 0; i < events.length; i += 1) {
436
+ if (events[i]!.seq !== i) {
437
+ events[i] = { ...events[i]!, seq: i } as MoxxyEvent;
438
+ resequenced += 1;
439
+ }
440
+ }
441
+
442
+ if (corruptLines > 0 || resequenced > 0) {
443
+ logger.warn('session log restored with gaps — re-sequenced to keep full history replayable', {
444
+ sessionId,
445
+ path: logPath,
446
+ corruptLines,
447
+ resequencedEvents: resequenced,
448
+ restoredEvents: events.length,
449
+ });
450
+ try {
451
+ const repaired = events.map((e) => JSON.stringify(e) + '\n').join('');
452
+ await writeFileAtomic(logPath, repaired);
453
+ } catch (err) {
454
+ // Restore still succeeds — the in-memory log is repaired; only the
455
+ // next resume would re-run this same repair.
456
+ logger.warn('failed to rewrite repaired session log on disk', {
457
+ sessionId,
458
+ path: logPath,
459
+ error: err instanceof Error ? err.message : String(err),
460
+ });
461
+ }
462
+ }
463
+ return events;
464
+ }
465
+
466
+ /**
467
+ * Remove a session's log file and its sidecar. A leftover legacy `index.json`
468
+ * row, if any, is harmless — `readIndex` filters out sessions whose `.jsonl` is
469
+ * gone, so the deleted session won't reappear.
470
+ */
471
+ export async function deleteSession(
472
+ sessionId: string,
473
+ dir = defaultSessionsDir(),
474
+ ): Promise<void> {
475
+ await fs.rm(path.join(dir, `${sessionId}.jsonl`), { force: true });
476
+ await fs.rm(metaPath(dir, sessionId), { force: true });
477
+ }
478
+
479
+ /** Per-session metadata sidecar path. */
480
+ function metaPath(dir: string, id: string): string {
481
+ return path.join(dir, `${id}.meta.json`);
482
+ }
483
+
484
+ async function writeJsonAtomic(target: string, value: unknown): Promise<void> {
485
+ await writeFileAtomic(target, JSON.stringify(value, null, 2) + '\n');
486
+ }
487
+
488
+ function isSessionMeta(v: unknown): v is SessionMeta {
489
+ if (!v || typeof v !== 'object') return false;
490
+ const m = v as Record<string, unknown>;
491
+ return (
492
+ typeof m.id === 'string' &&
493
+ typeof m.cwd === 'string' &&
494
+ typeof m.startedAt === 'string' &&
495
+ typeof m.lastActivity === 'string' &&
496
+ typeof m.eventCount === 'number'
497
+ );
498
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { z } from 'zod';
3
+ import { definePlugin, defineProvider, defineTool, type LLMProvider } from '@moxxy/sdk';
4
+
5
+ import { setupAgent } from './setup-agent.js';
6
+
7
+ const tool = (name: string) =>
8
+ defineTool({ name, description: name, inputSchema: z.object({}), handler: () => 'ok' });
9
+
10
+ const modePlugin = definePlugin({ name: 'mode-x', version: '0' });
11
+
12
+ const providerPlugin = (provName: string) =>
13
+ definePlugin({
14
+ name: `prov-${provName}`,
15
+ version: '0',
16
+ providers: [
17
+ defineProvider({
18
+ name: provName,
19
+ models: [],
20
+ createClient: () => ({}) as unknown as LLMProvider,
21
+ validateKey: async () => ({ ok: true }),
22
+ }),
23
+ ],
24
+ });
25
+
26
+ describe('setupAgent', () => {
27
+ it('returns a destructurable Agent with the expected methods', () => {
28
+ const { session, ask, stream, collect, discover, use, addTool, removeTool, setProvider, setMode } =
29
+ setupAgent();
30
+ for (const fn of [ask, stream, collect, discover, use, addTool, removeTool, setProvider, setMode]) {
31
+ expect(typeof fn).toBe('function');
32
+ }
33
+ expect(session.cwd).toBeTruthy();
34
+ });
35
+
36
+ it('registers tools from options and supports hot add/remove', () => {
37
+ const agent = setupAgent({ tools: [tool('a')] });
38
+ expect(agent.session.tools.has('a')).toBe(true);
39
+ agent.addTool(tool('b'));
40
+ expect(agent.session.tools.has('b')).toBe(true);
41
+ agent.removeTool('a');
42
+ expect(agent.session.tools.has('a')).toBe(false);
43
+ });
44
+
45
+ it('merges an array of presets, de-duping a shared plugin (no double-register throw)', () => {
46
+ // Both presets bring the same mode plugin → it must register once, not throw.
47
+ expect(() =>
48
+ setupAgent([{ plugins: [modePlugin] }, { plugins: [modePlugin], tools: [tool('t')] }]),
49
+ ).not.toThrow();
50
+ });
51
+
52
+ it('activates the FIRST preset provider; the others stay registered to swap to', () => {
53
+ const agent = setupAgent([
54
+ { plugins: [providerPlugin('openai')], provider: { name: 'openai' } },
55
+ { plugins: [providerPlugin('anthropic')], provider: { name: 'anthropic' } },
56
+ ]);
57
+ expect(agent.session.providers.getActiveName()).toBe('openai');
58
+ expect(
59
+ agent.session.providers
60
+ .list()
61
+ .map((d) => d.name)
62
+ .sort(),
63
+ ).toEqual(['anthropic', 'openai']);
64
+ agent.setProvider('anthropic');
65
+ expect(agent.session.providers.getActiveName()).toBe('anthropic');
66
+ });
67
+
68
+ it('hot-change methods are chainable (return the same agent)', () => {
69
+ const agent = setupAgent();
70
+ expect(agent.addTool(tool('x'))).toBe(agent);
71
+ });
72
+ });