@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,330 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ defineSurface,
4
+ type SurfaceDataMessage,
5
+ type SurfaceInstance,
6
+ type SurfaceSize,
7
+ } from '@moxxy/sdk';
8
+ import { SurfaceHostImpl } from './host.js';
9
+ import { SurfaceRegistryImpl } from '../registries/surfaces.js';
10
+
11
+ /**
12
+ * A fake surface whose instance records the input it receives and whether it has
13
+ * been closed — enough to assert that the host routes a viewer's keystrokes to
14
+ * the live instance and only tears it down when the last viewer detaches.
15
+ */
16
+ function fakeSurface(kind = 'terminal') {
17
+ const state = { inputs: [] as unknown[], closed: 0, opens: 0 };
18
+ const def = defineSurface({
19
+ kind,
20
+ open: () => {
21
+ state.opens += 1;
22
+ const instance: SurfaceInstance = {
23
+ id: kind,
24
+ kind,
25
+ onData: () => () => {},
26
+ snapshot: () => ({ type: 'snapshot' }),
27
+ input: (msg) => {
28
+ state.inputs.push(msg);
29
+ },
30
+ close: () => {
31
+ state.closed += 1;
32
+ },
33
+ };
34
+ return instance;
35
+ },
36
+ });
37
+ return { def, state };
38
+ }
39
+
40
+ function makeHost(def: ReturnType<typeof fakeSurface>['def']): SurfaceHostImpl {
41
+ const registry = new SurfaceRegistryImpl();
42
+ registry.register(def);
43
+ return new SurfaceHostImpl(registry, { cwd: '/tmp' });
44
+ }
45
+
46
+ describe('SurfaceHostImpl viewer ref-counting', () => {
47
+ it('keeps a shared instance alive until the last viewer closes', async () => {
48
+ const { def, state } = fakeSurface();
49
+ const host = makeHost(def);
50
+
51
+ const a = await host.open('terminal');
52
+ const b = await host.open('terminal');
53
+ expect(state.opens).toBe(1); // shared: one underlying instance
54
+ expect(a.surfaceId).toBe(b.surfaceId);
55
+
56
+ // First viewer detaches — instance must survive for the second.
57
+ await host.close(a.surfaceId);
58
+ expect(state.closed).toBe(0);
59
+
60
+ // Input from the still-attached viewer reaches the live instance.
61
+ await host.input(b.surfaceId, { type: 'data', data: 'ls\n' });
62
+ expect(state.inputs).toEqual([{ type: 'data', data: 'ls\n' }]);
63
+
64
+ // Last viewer detaches — now it tears down.
65
+ await host.close(b.surfaceId);
66
+ expect(state.closed).toBe(1);
67
+ });
68
+
69
+ it('survives the StrictMode mount→unmount→remount close race', async () => {
70
+ // React StrictMode double-invokes effects: the first mount opens, unmounts
71
+ // (its late-resolving open then fires a close), and a second mount opens the
72
+ // shared instance. The stray close must NOT destroy the instance the live
73
+ // mount is using — otherwise input/resize silently vanish.
74
+ const { def, state } = fakeSurface();
75
+ const host = makeHost(def);
76
+
77
+ const first = await host.open('terminal'); // mount A
78
+ const second = await host.open('terminal'); // mount B (remount)
79
+ await host.close(first.surfaceId); // mount A's disposed-open close
80
+
81
+ expect(state.closed).toBe(0); // instance survived
82
+ await host.input(second.surfaceId, { type: 'data', data: 'x' });
83
+ expect(state.inputs).toEqual([{ type: 'data', data: 'x' }]); // input still lands
84
+ });
85
+
86
+ it('closeAll tears down regardless of outstanding viewer refs', async () => {
87
+ const { def, state } = fakeSurface();
88
+ const host = makeHost(def);
89
+ await host.open('terminal');
90
+ await host.open('terminal'); // ref count 2
91
+ await host.closeAll();
92
+ expect(state.closed).toBe(1);
93
+ });
94
+ });
95
+
96
+ /**
97
+ * A controllable surface whose instance lets the test PUSH frames (`emit`),
98
+ * record resize calls, and run unsubscribe on close — enough to assert the
99
+ * host's multiplex fan-out, the per-instance unsub drop, and id-based routing.
100
+ */
101
+ function controllableSurface(kind = 'terminal') {
102
+ const subscribers = new Set<(payload: unknown) => void>();
103
+ const state = {
104
+ opens: 0,
105
+ closed: 0,
106
+ inputs: [] as unknown[],
107
+ resizes: [] as SurfaceSize[],
108
+ unsubs: 0,
109
+ };
110
+ const instance: SurfaceInstance = {
111
+ id: `${kind}-id`,
112
+ kind,
113
+ onData: (cb) => {
114
+ subscribers.add(cb);
115
+ return () => {
116
+ subscribers.delete(cb);
117
+ state.unsubs += 1;
118
+ };
119
+ },
120
+ snapshot: () => ({ scrollback: 'hello' }),
121
+ input: (msg) => {
122
+ state.inputs.push(msg);
123
+ },
124
+ resize: (size) => {
125
+ state.resizes.push(size);
126
+ },
127
+ close: () => {
128
+ state.closed += 1;
129
+ },
130
+ };
131
+ const def = defineSurface({
132
+ kind,
133
+ description: 'controllable',
134
+ open: () => {
135
+ state.opens += 1;
136
+ return instance;
137
+ },
138
+ });
139
+ /** Push one frame as the underlying instance would. */
140
+ const emit = (payload: unknown): void => {
141
+ for (const cb of subscribers) cb(payload);
142
+ };
143
+ return { def, state, instance, emit };
144
+ }
145
+
146
+ describe('SurfaceHostImpl open() idempotence + snapshot', () => {
147
+ it('returns the existing instance + its snapshot on a second open(kind)', async () => {
148
+ const { def, state } = controllableSurface();
149
+ const host = makeHost(def);
150
+
151
+ const a = await host.open('terminal');
152
+ const b = await host.open('terminal');
153
+
154
+ expect(state.opens).toBe(1); // def.open ran exactly once
155
+ expect(a.surfaceId).toBe(b.surfaceId);
156
+ expect(a.kind).toBe('terminal');
157
+ // The catch-up snapshot rides back to a late-joining viewer.
158
+ expect(a.snapshot).toEqual({ scrollback: 'hello' });
159
+ expect(b.snapshot).toEqual({ scrollback: 'hello' });
160
+ });
161
+
162
+ it('two concurrent open(kind) calls share ONE instance (def.open runs once)', async () => {
163
+ const { def, state } = controllableSurface();
164
+ const host = makeHost(def);
165
+
166
+ const [a, b] = await Promise.all([host.open('terminal'), host.open('terminal')]);
167
+
168
+ expect(state.opens).toBe(1);
169
+ expect(a.surfaceId).toBe(b.surfaceId);
170
+ });
171
+
172
+ it('throws a clear error opening an unregistered kind', async () => {
173
+ const { def } = controllableSurface();
174
+ const host = makeHost(def);
175
+ await expect(host.open('browser')).rejects.toThrow(/No surface registered for kind: browser/);
176
+ });
177
+ });
178
+
179
+ describe('SurfaceHostImpl onData multiplex fan-out', () => {
180
+ it('re-emits each instance frame as a SurfaceDataMessage carrying surfaceId/kind/payload', async () => {
181
+ const { def, emit } = controllableSurface();
182
+ const host = makeHost(def);
183
+ const { surfaceId } = await host.open('terminal');
184
+
185
+ const seen: SurfaceDataMessage[] = [];
186
+ host.onData((msg) => seen.push(msg));
187
+ emit({ bytes: 'ls\n' });
188
+
189
+ expect(seen).toEqual([{ surfaceId, kind: 'terminal', payload: { bytes: 'ls\n' } }]);
190
+ });
191
+
192
+ it('fans every frame out to all subscribers', async () => {
193
+ const { def, emit } = controllableSurface();
194
+ const host = makeHost(def);
195
+ await host.open('terminal');
196
+
197
+ const a: SurfaceDataMessage[] = [];
198
+ const b: SurfaceDataMessage[] = [];
199
+ host.onData((m) => a.push(m));
200
+ host.onData((m) => b.push(m));
201
+ emit({ x: 1 });
202
+
203
+ expect(a).toHaveLength(1);
204
+ expect(b).toHaveLength(1);
205
+ });
206
+
207
+ it('a throwing listener does not suppress delivery to the others', async () => {
208
+ const { def, emit } = controllableSurface();
209
+ const host = makeHost(def);
210
+ await host.open('terminal');
211
+
212
+ const good: SurfaceDataMessage[] = [];
213
+ host.onData(() => {
214
+ throw new Error('bad listener');
215
+ });
216
+ host.onData((m) => good.push(m));
217
+
218
+ expect(() => emit({ x: 1 })).not.toThrow();
219
+ expect(good).toHaveLength(1);
220
+ });
221
+
222
+ it('the onData unsubscribe stops further frames for that subscriber', async () => {
223
+ const { def, emit } = controllableSurface();
224
+ const host = makeHost(def);
225
+ await host.open('terminal');
226
+
227
+ const seen: SurfaceDataMessage[] = [];
228
+ const off = host.onData((m) => seen.push(m));
229
+ emit({ n: 1 });
230
+ off();
231
+ emit({ n: 2 });
232
+
233
+ expect(seen).toHaveLength(1);
234
+ });
235
+
236
+ it('drops the per-instance unsub on close so no frames flow after teardown', async () => {
237
+ const { def, state, emit } = controllableSurface();
238
+ const host = makeHost(def);
239
+ const { surfaceId } = await host.open('terminal');
240
+
241
+ const seen: SurfaceDataMessage[] = [];
242
+ host.onData((m) => seen.push(m));
243
+ await host.close(surfaceId); // last viewer → real teardown
244
+
245
+ expect(state.closed).toBe(1);
246
+ expect(state.unsubs).toBe(1); // the host ran the stored unsub
247
+ emit({ late: true }); // frame from a still-live resource, post-close
248
+ expect(seen).toHaveLength(0); // host no longer forwards it
249
+ });
250
+ });
251
+
252
+ describe('SurfaceHostImpl input/resize routing', () => {
253
+ it('routes input + resize to the instance by surfaceId', async () => {
254
+ const { def, state } = controllableSurface();
255
+ const host = makeHost(def);
256
+ const { surfaceId } = await host.open('terminal');
257
+
258
+ await host.input(surfaceId, { type: 'data', data: 'x' });
259
+ await host.resize(surfaceId, { cols: 80, rows: 24 });
260
+
261
+ expect(state.inputs).toEqual([{ type: 'data', data: 'x' }]);
262
+ expect(state.resizes).toEqual([{ cols: 80, rows: 24 }]);
263
+ });
264
+
265
+ it('no-ops on an unknown surfaceId (input + resize)', async () => {
266
+ const { def, state } = controllableSurface();
267
+ const host = makeHost(def);
268
+ await host.open('terminal');
269
+
270
+ await host.input('does-not-exist', { type: 'data', data: 'x' });
271
+ await host.resize('does-not-exist', { cols: 1, rows: 1 });
272
+
273
+ expect(state.inputs).toEqual([]);
274
+ expect(state.resizes).toEqual([]);
275
+ });
276
+
277
+ it('close on an unknown surfaceId is a no-op (does not tear down the live one)', async () => {
278
+ const { def, state } = controllableSurface();
279
+ const host = makeHost(def);
280
+ await host.open('terminal');
281
+
282
+ await host.close('does-not-exist');
283
+ expect(state.closed).toBe(0);
284
+ });
285
+ });
286
+
287
+ describe('SurfaceHostImpl list()', () => {
288
+ it('reports the registered kinds with availability', async () => {
289
+ const registry = new SurfaceRegistryImpl();
290
+ registry.register(controllableSurface('terminal').def);
291
+ registry.register(
292
+ defineSurface({
293
+ kind: 'browser',
294
+ description: 'headless browser',
295
+ open: () => {
296
+ throw new Error('unused');
297
+ },
298
+ isAvailable: () => ({ ok: false, reason: 'node-pty missing' }),
299
+ }),
300
+ );
301
+ const host = new SurfaceHostImpl(registry, { cwd: '/tmp' });
302
+
303
+ const list = await host.list();
304
+ const byKind = new Map(list.map((i) => [i.kind, i]));
305
+ expect(byKind.get('terminal')).toMatchObject({ available: true, description: 'controllable' });
306
+ expect(byKind.get('browser')).toMatchObject({
307
+ available: false,
308
+ reason: 'node-pty missing',
309
+ });
310
+ });
311
+
312
+ it('treats a throwing isAvailable as unavailable with the error as reason', async () => {
313
+ const registry = new SurfaceRegistryImpl();
314
+ registry.register(
315
+ defineSurface({
316
+ kind: 'terminal',
317
+ open: () => {
318
+ throw new Error('unused');
319
+ },
320
+ isAvailable: () => {
321
+ throw new Error('probe blew up');
322
+ },
323
+ }),
324
+ );
325
+ const host = new SurfaceHostImpl(registry, { cwd: '/tmp' });
326
+
327
+ const [info] = await host.list();
328
+ expect(info).toMatchObject({ kind: 'terminal', available: false, reason: 'probe blew up' });
329
+ });
330
+ });
@@ -0,0 +1,192 @@
1
+ import type {
2
+ OpenSurfaceResult,
3
+ SurfaceContext,
4
+ SurfaceDataMessage,
5
+ SurfaceHost,
6
+ SurfaceInfo,
7
+ SurfaceInputMessage,
8
+ SurfaceInstance,
9
+ SurfaceKind,
10
+ SurfaceSize,
11
+ } from '@moxxy/sdk';
12
+ import type { Logger } from '../logger.js';
13
+ import type { SurfaceRegistryImpl } from '../registries/surfaces.js';
14
+
15
+ /**
16
+ * Manages the live, open {@link SurfaceInstance}s for a Session and multiplexes
17
+ * every instance's output through a single {@link onData} fan-out. The runner
18
+ * subscribes once and rebroadcasts each frame as a `surface.data` notification;
19
+ * the agent's tools reach the SAME underlying resource (PTY, page) through their
20
+ * plugin's module state, so a `terminal`/`browser_session` tool call appears in
21
+ * the very pane the user is watching.
22
+ *
23
+ * Opening a kind is idempotent: a second `open` returns the instance the first
24
+ * created (surfaces are shared), so a late-joining viewer attaches to the
25
+ * existing resource and gets its {@link SurfaceInstance.snapshot}.
26
+ */
27
+ export class SurfaceHostImpl implements SurfaceHost {
28
+ /** Open instances, keyed by kind (one shared instance per kind). */
29
+ private readonly instances = new Map<SurfaceKind, SurfaceInstance>();
30
+ /** Per-instance unsubscribe from its onData, dropped on close. */
31
+ private readonly unsubs = new Map<string, () => void>();
32
+ private readonly dataListeners = new Set<(msg: SurfaceDataMessage) => void>();
33
+ /** In-flight opens, so concurrent `open(kind)` calls share one instance. */
34
+ private readonly opening = new Map<SurfaceKind, Promise<OpenSurfaceResult>>();
35
+ /**
36
+ * Viewer attach count per kind. A surface is SHARED (the agent's tool + every
37
+ * attached viewer drive the same PTY/page), so `open` retains and `close`
38
+ * releases; the instance is only torn down when the last viewer detaches.
39
+ * Without this, a single viewer's `close` would destroy the resource out from
40
+ * under the others — and React StrictMode (dev) makes that routine: it
41
+ * mounts → unmounts → remounts, so the first mount's late-resolving `open`
42
+ * closes the very instance the remount just attached to, leaving `input`/
43
+ * `resize` to hit a now-missing instance (output still flows from the
44
+ * snapshot, but keystrokes/navigation silently vanish).
45
+ */
46
+ private readonly refs = new Map<SurfaceKind, number>();
47
+
48
+ constructor(
49
+ private readonly registry: SurfaceRegistryImpl,
50
+ private readonly ctx: SurfaceContext,
51
+ private readonly logger?: Logger,
52
+ ) {}
53
+
54
+ async list(): Promise<ReadonlyArray<SurfaceInfo>> {
55
+ const out: SurfaceInfo[] = [];
56
+ for (const def of this.registry.list()) {
57
+ let available = true;
58
+ let reason: string | undefined;
59
+ if (def.isAvailable) {
60
+ try {
61
+ const a = await def.isAvailable(this.ctx);
62
+ available = a.ok;
63
+ reason = a.reason;
64
+ } catch (err) {
65
+ available = false;
66
+ reason = err instanceof Error ? err.message : String(err);
67
+ }
68
+ }
69
+ out.push({
70
+ kind: def.kind,
71
+ ...(def.description ? { description: def.description } : {}),
72
+ available,
73
+ ...(reason ? { reason } : {}),
74
+ });
75
+ }
76
+ return out;
77
+ }
78
+
79
+ async open(kind: SurfaceKind): Promise<OpenSurfaceResult> {
80
+ const existing = this.instances.get(kind);
81
+ if (existing) {
82
+ this.retain(kind);
83
+ return this.describe(existing);
84
+ }
85
+ const pending = this.opening.get(kind);
86
+ if (pending) {
87
+ // Ride the in-flight create, but still count this viewer's attach.
88
+ const res = await pending;
89
+ this.retain(kind);
90
+ return res;
91
+ }
92
+
93
+ const def = this.registry.get(kind);
94
+ if (!def) throw new Error(`No surface registered for kind: ${kind}`);
95
+
96
+ const promise = (async (): Promise<OpenSurfaceResult> => {
97
+ const instance = await def.open(this.ctx);
98
+ this.instances.set(kind, instance);
99
+ this.refs.set(kind, 1);
100
+ // Re-emit this instance's frames as multiplexed SurfaceDataMessages.
101
+ const unsub = instance.onData((payload) =>
102
+ this.emit({ surfaceId: instance.id, kind: instance.kind, payload }),
103
+ );
104
+ this.unsubs.set(instance.id, unsub);
105
+ return this.describe(instance);
106
+ })();
107
+ this.opening.set(kind, promise);
108
+ try {
109
+ return await promise;
110
+ } finally {
111
+ this.opening.delete(kind);
112
+ }
113
+ }
114
+
115
+ async input(surfaceId: string, msg: SurfaceInputMessage): Promise<void> {
116
+ const instance = this.byId(surfaceId);
117
+ if (!instance) return;
118
+ await instance.input(msg);
119
+ }
120
+
121
+ async resize(surfaceId: string, size: SurfaceSize): Promise<void> {
122
+ const instance = this.byId(surfaceId);
123
+ if (!instance?.resize) return;
124
+ await instance.resize(size);
125
+ }
126
+
127
+ async close(surfaceId: string): Promise<void> {
128
+ const instance = this.byId(surfaceId);
129
+ if (!instance) return;
130
+ const kind = instance.kind;
131
+ // Release this viewer; keep the shared instance alive while others hold it.
132
+ const remaining = (this.refs.get(kind) ?? 1) - 1;
133
+ if (remaining > 0) {
134
+ this.refs.set(kind, remaining);
135
+ return;
136
+ }
137
+ this.refs.delete(kind);
138
+ this.unsubs.get(surfaceId)?.();
139
+ this.unsubs.delete(surfaceId);
140
+ this.instances.delete(kind);
141
+ try {
142
+ await instance.close();
143
+ } catch (err) {
144
+ this.logger?.warn?.('SurfaceHost: close failed', {
145
+ surfaceId,
146
+ err: err instanceof Error ? err.message : String(err),
147
+ });
148
+ }
149
+ }
150
+
151
+ /** Bump the viewer attach count for an already-open kind. */
152
+ private retain(kind: SurfaceKind): void {
153
+ this.refs.set(kind, (this.refs.get(kind) ?? 0) + 1);
154
+ }
155
+
156
+ private describe(instance: SurfaceInstance): OpenSurfaceResult {
157
+ return {
158
+ surfaceId: instance.id,
159
+ kind: instance.kind,
160
+ ...(instance.snapshot ? { snapshot: instance.snapshot() } : {}),
161
+ };
162
+ }
163
+
164
+ onData(cb: (msg: SurfaceDataMessage) => void): () => void {
165
+ this.dataListeners.add(cb);
166
+ return () => this.dataListeners.delete(cb);
167
+ }
168
+
169
+ async closeAll(): Promise<void> {
170
+ // Session shutdown: tear every instance down regardless of viewer refs.
171
+ for (const kind of [...this.instances.keys()]) this.refs.set(kind, 1);
172
+ const ids = [...this.unsubs.keys()];
173
+ for (const id of ids) await this.close(id);
174
+ }
175
+
176
+ private byId(surfaceId: string): SurfaceInstance | undefined {
177
+ for (const instance of this.instances.values()) {
178
+ if (instance.id === surfaceId) return instance;
179
+ }
180
+ return undefined;
181
+ }
182
+
183
+ private emit(msg: SurfaceDataMessage): void {
184
+ for (const fn of this.dataListeners) {
185
+ try {
186
+ fn(msg);
187
+ } catch {
188
+ /* a bad listener must not break the stream */
189
+ }
190
+ }
191
+ }
192
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { localhostTunnel } from './localhost.js';
3
+
4
+ describe('localhostTunnel', () => {
5
+ it('returns the local URL and a no-op close', async () => {
6
+ const handle = await localhostTunnel.open({ host: '127.0.0.1', port: 4040 });
7
+ expect(handle.url).toBe('http://127.0.0.1:4040');
8
+ await expect(handle.close()).resolves.toBeUndefined();
9
+ });
10
+
11
+ it('brackets an IPv6 literal host so the URL is valid', async () => {
12
+ // u49-1: a bare IPv6 host yields an ambiguous, unparseable URL.
13
+ const handle = await localhostTunnel.open({ host: '::1', port: 4040 });
14
+ expect(handle.url).toBe('http://[::1]:4040');
15
+ // …and the result actually parses.
16
+ expect(new URL(handle.url).hostname).toBe('[::1]');
17
+ });
18
+
19
+ it('is always available', async () => {
20
+ expect(await localhostTunnel.isAvailable?.()).toBe(true);
21
+ });
22
+ });
@@ -0,0 +1,21 @@
1
+ import { defineTunnelProvider } from '@moxxy/sdk';
2
+
3
+ /**
4
+ * The default tunnel provider: no tunnel at all, just the local URL. Used when
5
+ * the surface and the user share a machine (TUI), or when no real tunnel
6
+ * (cloudflared) is configured/installed.
7
+ */
8
+ export const localhostTunnel = defineTunnelProvider({
9
+ name: 'localhost',
10
+ open: (opts) => {
11
+ // Bracket an IPv6 literal host (e.g. `::1` → `[::1]`) so the `:port`
12
+ // delimiter is unambiguous and the result is a valid URL. A bare IPv6
13
+ // host (`http://::1:4040`) is rejected by most URL parsers.
14
+ const host = opts.host.includes(':') ? `[${opts.host}]` : opts.host;
15
+ return Promise.resolve({
16
+ url: `http://${host}:${opts.port}`,
17
+ close: () => Promise.resolve(),
18
+ });
19
+ },
20
+ isAvailable: () => Promise.resolve(true),
21
+ });
@@ -0,0 +1,77 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { promises as fs } from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { clearUsageStats, loadUsageStats, mergeUsageStats } from './usage-stats.js';
6
+ import type { ModelUsageTotals } from '@moxxy/sdk';
7
+
8
+ const totals = (over: Partial<ModelUsageTotals> = {}): ModelUsageTotals => ({
9
+ calls: 1,
10
+ inputTokens: 0,
11
+ outputTokens: 0,
12
+ cacheReadTokens: 0,
13
+ cacheCreationTokens: 0,
14
+ ...over,
15
+ });
16
+
17
+ let tmpDir: string;
18
+ let statsPath: string;
19
+
20
+ beforeEach(async () => {
21
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-usage-'));
22
+ statsPath = path.join(tmpDir, 'usage.json');
23
+ });
24
+
25
+ afterEach(async () => {
26
+ await fs.rm(tmpDir, { recursive: true, force: true });
27
+ });
28
+
29
+ describe('usage-stats store', () => {
30
+ it('returns an empty aggregate when the file is missing', async () => {
31
+ const file = await loadUsageStats(statsPath);
32
+ expect(file.models).toEqual({});
33
+ expect(file.version).toBe(1);
34
+ });
35
+
36
+ it('merges a delta and round-trips through disk', async () => {
37
+ await mergeUsageStats({ 'anthropic/opus': totals({ inputTokens: 100, outputTokens: 10 }) }, statsPath);
38
+ const file = await loadUsageStats(statsPath);
39
+ expect(file.models['anthropic/opus']!.inputTokens).toBe(100);
40
+ expect(file.models['anthropic/opus']!.firstSeen).toBeTruthy();
41
+ expect(file.models['anthropic/opus']!.lastSeen).toBeTruthy();
42
+ });
43
+
44
+ it('accumulates additively across merges and preserves firstSeen', async () => {
45
+ const first = await mergeUsageStats(
46
+ { 'anthropic/opus': totals({ inputTokens: 100 }) },
47
+ statsPath,
48
+ );
49
+ const firstSeen = first.models['anthropic/opus']!.firstSeen;
50
+
51
+ await mergeUsageStats({ 'anthropic/opus': totals({ inputTokens: 50, calls: 2 }) }, statsPath);
52
+ const file = await loadUsageStats(statsPath);
53
+
54
+ expect(file.models['anthropic/opus']!.inputTokens).toBe(150);
55
+ expect(file.models['anthropic/opus']!.calls).toBe(3);
56
+ expect(file.models['anthropic/opus']!.firstSeen).toBe(firstSeen);
57
+ });
58
+
59
+ it('ignores empty deltas and zero-call entries', async () => {
60
+ await mergeUsageStats({}, statsPath);
61
+ expect((await loadUsageStats(statsPath)).models).toEqual({});
62
+
63
+ await mergeUsageStats({ 'a/m': totals({ calls: 0, inputTokens: 5 }) }, statsPath);
64
+ expect((await loadUsageStats(statsPath)).models).toEqual({});
65
+ });
66
+
67
+ it('clears the aggregate', async () => {
68
+ await mergeUsageStats({ 'anthropic/opus': totals({ inputTokens: 100 }) }, statsPath);
69
+ await clearUsageStats(statsPath);
70
+ expect((await loadUsageStats(statsPath)).models).toEqual({});
71
+ });
72
+
73
+ it('reads as empty when the file is malformed', async () => {
74
+ await fs.writeFile(statsPath, '{ not json', 'utf8');
75
+ expect((await loadUsageStats(statsPath)).models).toEqual({});
76
+ });
77
+ });