picopilot 0.0.0 → 0.1.1

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 (334) hide show
  1. package/LICENSE +661 -0
  2. package/dist/bin.d.ts +3 -0
  3. package/dist/bin.d.ts.map +1 -0
  4. package/dist/bin.js +4 -0
  5. package/dist/bin.js.map +1 -0
  6. package/dist/cli.d.ts +15 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +59 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/commands/audio.d.ts +17 -0
  11. package/dist/commands/audio.d.ts.map +1 -0
  12. package/dist/commands/audio.js +315 -0
  13. package/dist/commands/audio.js.map +1 -0
  14. package/dist/commands/export.d.ts +36 -0
  15. package/dist/commands/export.d.ts.map +1 -0
  16. package/dist/commands/export.js +257 -0
  17. package/dist/commands/export.js.map +1 -0
  18. package/dist/commands/gfx.d.ts +28 -0
  19. package/dist/commands/gfx.d.ts.map +1 -0
  20. package/dist/commands/gfx.js +521 -0
  21. package/dist/commands/gfx.js.map +1 -0
  22. package/dist/commands/init.d.ts +34 -0
  23. package/dist/commands/init.d.ts.map +1 -0
  24. package/dist/commands/init.js +151 -0
  25. package/dist/commands/init.js.map +1 -0
  26. package/dist/commands/lint.d.ts +20 -0
  27. package/dist/commands/lint.d.ts.map +1 -0
  28. package/dist/commands/lint.js +90 -0
  29. package/dist/commands/lint.js.map +1 -0
  30. package/dist/commands/minify.d.ts +20 -0
  31. package/dist/commands/minify.d.ts.map +1 -0
  32. package/dist/commands/minify.js +129 -0
  33. package/dist/commands/minify.js.map +1 -0
  34. package/dist/commands/music.d.ts +13 -0
  35. package/dist/commands/music.d.ts.map +1 -0
  36. package/dist/commands/music.js +241 -0
  37. package/dist/commands/music.js.map +1 -0
  38. package/dist/commands/playtest.d.ts +29 -0
  39. package/dist/commands/playtest.d.ts.map +1 -0
  40. package/dist/commands/playtest.js +631 -0
  41. package/dist/commands/playtest.js.map +1 -0
  42. package/dist/commands/run.d.ts +30 -0
  43. package/dist/commands/run.d.ts.map +1 -0
  44. package/dist/commands/run.js +224 -0
  45. package/dist/commands/run.js.map +1 -0
  46. package/dist/commands/serve.d.ts +50 -0
  47. package/dist/commands/serve.d.ts.map +1 -0
  48. package/dist/commands/serve.js +213 -0
  49. package/dist/commands/serve.js.map +1 -0
  50. package/dist/commands/sfx.d.ts +13 -0
  51. package/dist/commands/sfx.d.ts.map +1 -0
  52. package/dist/commands/sfx.js +254 -0
  53. package/dist/commands/sfx.js.map +1 -0
  54. package/dist/commands/tokens.d.ts +36 -0
  55. package/dist/commands/tokens.d.ts.map +1 -0
  56. package/dist/commands/tokens.js +129 -0
  57. package/dist/commands/tokens.js.map +1 -0
  58. package/dist/commands/verify.d.ts +57 -0
  59. package/dist/commands/verify.d.ts.map +1 -0
  60. package/dist/commands/verify.js +213 -0
  61. package/dist/commands/verify.js.map +1 -0
  62. package/dist/commands/version.d.ts +12 -0
  63. package/dist/commands/version.d.ts.map +1 -0
  64. package/dist/commands/version.js +24 -0
  65. package/dist/commands/version.js.map +1 -0
  66. package/dist/engine/audio/index.d.ts +14 -0
  67. package/dist/engine/audio/index.d.ts.map +1 -0
  68. package/dist/engine/audio/index.js +14 -0
  69. package/dist/engine/audio/index.js.map +1 -0
  70. package/dist/engine/audio/music.d.ts +116 -0
  71. package/dist/engine/audio/music.d.ts.map +1 -0
  72. package/dist/engine/audio/music.js +138 -0
  73. package/dist/engine/audio/music.js.map +1 -0
  74. package/dist/engine/audio/section.d.ts +34 -0
  75. package/dist/engine/audio/section.d.ts.map +1 -0
  76. package/dist/engine/audio/section.js +67 -0
  77. package/dist/engine/audio/section.js.map +1 -0
  78. package/dist/engine/audio/sfx.d.ts +141 -0
  79. package/dist/engine/audio/sfx.d.ts.map +1 -0
  80. package/dist/engine/audio/sfx.js +506 -0
  81. package/dist/engine/audio/sfx.js.map +1 -0
  82. package/dist/engine/cart/cart.d.ts +75 -0
  83. package/dist/engine/cart/cart.d.ts.map +1 -0
  84. package/dist/engine/cart/cart.js +153 -0
  85. package/dist/engine/cart/cart.js.map +1 -0
  86. package/dist/engine/cart/errors.d.ts +29 -0
  87. package/dist/engine/cart/errors.d.ts.map +1 -0
  88. package/dist/engine/cart/errors.js +26 -0
  89. package/dist/engine/cart/errors.js.map +1 -0
  90. package/dist/engine/cart/gfx.d.ts +79 -0
  91. package/dist/engine/cart/gfx.d.ts.map +1 -0
  92. package/dist/engine/cart/gfx.js +171 -0
  93. package/dist/engine/cart/gfx.js.map +1 -0
  94. package/dist/engine/cart/index.d.ts +15 -0
  95. package/dist/engine/cart/index.d.ts.map +1 -0
  96. package/dist/engine/cart/index.js +15 -0
  97. package/dist/engine/cart/index.js.map +1 -0
  98. package/dist/engine/cart/map.d.ts +46 -0
  99. package/dist/engine/cart/map.d.ts.map +1 -0
  100. package/dist/engine/cart/map.js +118 -0
  101. package/dist/engine/cart/map.js.map +1 -0
  102. package/dist/engine/config/config.d.ts +74 -0
  103. package/dist/engine/config/config.d.ts.map +1 -0
  104. package/dist/engine/config/config.js +103 -0
  105. package/dist/engine/config/config.js.map +1 -0
  106. package/dist/engine/config/index.d.ts +7 -0
  107. package/dist/engine/config/index.d.ts.map +1 -0
  108. package/dist/engine/config/index.js +7 -0
  109. package/dist/engine/config/index.js.map +1 -0
  110. package/dist/engine/gfx/grid.d.ts +51 -0
  111. package/dist/engine/gfx/grid.d.ts.map +1 -0
  112. package/dist/engine/gfx/grid.js +116 -0
  113. package/dist/engine/gfx/grid.js.map +1 -0
  114. package/dist/engine/gfx/index.d.ts +17 -0
  115. package/dist/engine/gfx/index.d.ts.map +1 -0
  116. package/dist/engine/gfx/index.js +17 -0
  117. package/dist/engine/gfx/index.js.map +1 -0
  118. package/dist/engine/gfx/label.d.ts +34 -0
  119. package/dist/engine/gfx/label.d.ts.map +1 -0
  120. package/dist/engine/gfx/label.js +190 -0
  121. package/dist/engine/gfx/label.js.map +1 -0
  122. package/dist/engine/gfx/overlap.d.ts +42 -0
  123. package/dist/engine/gfx/overlap.d.ts.map +1 -0
  124. package/dist/engine/gfx/overlap.js +57 -0
  125. package/dist/engine/gfx/overlap.js.map +1 -0
  126. package/dist/engine/gfx/palette.d.ts +29 -0
  127. package/dist/engine/gfx/palette.d.ts.map +1 -0
  128. package/dist/engine/gfx/palette.js +46 -0
  129. package/dist/engine/gfx/palette.js.map +1 -0
  130. package/dist/engine/gfx/png.d.ts +31 -0
  131. package/dist/engine/gfx/png.d.ts.map +1 -0
  132. package/dist/engine/gfx/png.js +165 -0
  133. package/dist/engine/gfx/png.js.map +1 -0
  134. package/dist/engine/gfx/render.d.ts +42 -0
  135. package/dist/engine/gfx/render.d.ts.map +1 -0
  136. package/dist/engine/gfx/render.js +72 -0
  137. package/dist/engine/gfx/render.js.map +1 -0
  138. package/dist/engine/index.d.ts +22 -0
  139. package/dist/engine/index.d.ts.map +1 -0
  140. package/dist/engine/index.js +22 -0
  141. package/dist/engine/index.js.map +1 -0
  142. package/dist/engine/pico8/adapter.d.ts +267 -0
  143. package/dist/engine/pico8/adapter.d.ts.map +1 -0
  144. package/dist/engine/pico8/adapter.js +37 -0
  145. package/dist/engine/pico8/adapter.js.map +1 -0
  146. package/dist/engine/pico8/drive.d.ts +308 -0
  147. package/dist/engine/pico8/drive.d.ts.map +1 -0
  148. package/dist/engine/pico8/drive.js +581 -0
  149. package/dist/engine/pico8/drive.js.map +1 -0
  150. package/dist/engine/pico8/harness.d.ts +146 -0
  151. package/dist/engine/pico8/harness.d.ts.map +1 -0
  152. package/dist/engine/pico8/harness.js +251 -0
  153. package/dist/engine/pico8/harness.js.map +1 -0
  154. package/dist/engine/pico8/index.d.ts +21 -0
  155. package/dist/engine/pico8/index.d.ts.map +1 -0
  156. package/dist/engine/pico8/index.js +21 -0
  157. package/dist/engine/pico8/index.js.map +1 -0
  158. package/dist/engine/pico8/sentinel.d.ts +63 -0
  159. package/dist/engine/pico8/sentinel.d.ts.map +1 -0
  160. package/dist/engine/pico8/sentinel.js +86 -0
  161. package/dist/engine/pico8/sentinel.js.map +1 -0
  162. package/dist/engine/pico8/session-daemon-entry.d.ts +10 -0
  163. package/dist/engine/pico8/session-daemon-entry.d.ts.map +1 -0
  164. package/dist/engine/pico8/session-daemon-entry.js +11 -0
  165. package/dist/engine/pico8/session-daemon-entry.js.map +1 -0
  166. package/dist/engine/pico8/session-daemon-main.d.ts +44 -0
  167. package/dist/engine/pico8/session-daemon-main.d.ts.map +1 -0
  168. package/dist/engine/pico8/session-daemon-main.js +71 -0
  169. package/dist/engine/pico8/session-daemon-main.js.map +1 -0
  170. package/dist/engine/pico8/session-daemon.d.ts +68 -0
  171. package/dist/engine/pico8/session-daemon.d.ts.map +1 -0
  172. package/dist/engine/pico8/session-daemon.js +264 -0
  173. package/dist/engine/pico8/session-daemon.js.map +1 -0
  174. package/dist/engine/pico8/session.d.ts +140 -0
  175. package/dist/engine/pico8/session.d.ts.map +1 -0
  176. package/dist/engine/pico8/session.js +250 -0
  177. package/dist/engine/pico8/session.js.map +1 -0
  178. package/dist/engine/pico8/shell.d.ts +103 -0
  179. package/dist/engine/pico8/shell.d.ts.map +1 -0
  180. package/dist/engine/pico8/shell.js +324 -0
  181. package/dist/engine/pico8/shell.js.map +1 -0
  182. package/dist/engine/pico8/supervisor.d.ts +137 -0
  183. package/dist/engine/pico8/supervisor.d.ts.map +1 -0
  184. package/dist/engine/pico8/supervisor.js +140 -0
  185. package/dist/engine/pico8/supervisor.js.map +1 -0
  186. package/dist/engine/scaffold/index.d.ts +8 -0
  187. package/dist/engine/scaffold/index.d.ts.map +1 -0
  188. package/dist/engine/scaffold/index.js +8 -0
  189. package/dist/engine/scaffold/index.js.map +1 -0
  190. package/dist/engine/scaffold/scaffold.d.ts +49 -0
  191. package/dist/engine/scaffold/scaffold.d.ts.map +1 -0
  192. package/dist/engine/scaffold/scaffold.js +259 -0
  193. package/dist/engine/scaffold/scaffold.js.map +1 -0
  194. package/dist/engine/shrinko/adapter.d.ts +167 -0
  195. package/dist/engine/shrinko/adapter.d.ts.map +1 -0
  196. package/dist/engine/shrinko/adapter.js +107 -0
  197. package/dist/engine/shrinko/adapter.js.map +1 -0
  198. package/dist/engine/shrinko/index.d.ts +13 -0
  199. package/dist/engine/shrinko/index.d.ts.map +1 -0
  200. package/dist/engine/shrinko/index.js +13 -0
  201. package/dist/engine/shrinko/index.js.map +1 -0
  202. package/dist/engine/shrinko/shell.d.ts +69 -0
  203. package/dist/engine/shrinko/shell.d.ts.map +1 -0
  204. package/dist/engine/shrinko/shell.js +156 -0
  205. package/dist/engine/shrinko/shell.js.map +1 -0
  206. package/dist/engine/skills/index.d.ts +8 -0
  207. package/dist/engine/skills/index.d.ts.map +1 -0
  208. package/dist/engine/skills/index.js +8 -0
  209. package/dist/engine/skills/index.js.map +1 -0
  210. package/dist/engine/skills/skills.d.ts +104 -0
  211. package/dist/engine/skills/skills.d.ts.map +1 -0
  212. package/dist/engine/skills/skills.js +149 -0
  213. package/dist/engine/skills/skills.js.map +1 -0
  214. package/dist/index.d.ts +10 -0
  215. package/dist/index.d.ts.map +1 -0
  216. package/dist/index.js +10 -0
  217. package/dist/index.js.map +1 -0
  218. package/dist/version.d.ts +2 -0
  219. package/dist/version.d.ts.map +1 -0
  220. package/dist/version.js +19 -0
  221. package/dist/version.js.map +1 -0
  222. package/package.json +48 -2
  223. package/src/bin.ts +4 -0
  224. package/src/cli.ts +61 -0
  225. package/src/commands/audio.test.ts +280 -0
  226. package/src/commands/audio.ts +410 -0
  227. package/src/commands/export.test.ts +310 -0
  228. package/src/commands/export.ts +316 -0
  229. package/src/commands/gfx-io.test.ts +178 -0
  230. package/src/commands/gfx.test.ts +470 -0
  231. package/src/commands/gfx.ts +645 -0
  232. package/src/commands/init.test.ts +482 -0
  233. package/src/commands/init.ts +193 -0
  234. package/src/commands/lint.test.ts +137 -0
  235. package/src/commands/lint.ts +117 -0
  236. package/src/commands/minify.test.ts +166 -0
  237. package/src/commands/minify.ts +156 -0
  238. package/src/commands/music.test.ts +272 -0
  239. package/src/commands/music.ts +288 -0
  240. package/src/commands/playtest.test.ts +317 -0
  241. package/src/commands/playtest.ts +773 -0
  242. package/src/commands/run.test.ts +264 -0
  243. package/src/commands/run.ts +285 -0
  244. package/src/commands/serve.test.ts +227 -0
  245. package/src/commands/serve.ts +272 -0
  246. package/src/commands/sfx.test.ts +349 -0
  247. package/src/commands/sfx.ts +300 -0
  248. package/src/commands/tokens.test.ts +212 -0
  249. package/src/commands/tokens.ts +168 -0
  250. package/src/commands/verify.test.ts +238 -0
  251. package/src/commands/verify.ts +256 -0
  252. package/src/commands/version.test.ts +70 -0
  253. package/src/commands/version.ts +24 -0
  254. package/src/engine/audio/index.ts +47 -0
  255. package/src/engine/audio/music.test.ts +188 -0
  256. package/src/engine/audio/music.ts +218 -0
  257. package/src/engine/audio/section.test.ts +124 -0
  258. package/src/engine/audio/section.ts +79 -0
  259. package/src/engine/audio/sfx.test.ts +410 -0
  260. package/src/engine/audio/sfx.ts +694 -0
  261. package/src/engine/cart/cart.test.ts +304 -0
  262. package/src/engine/cart/cart.ts +211 -0
  263. package/src/engine/cart/errors.ts +39 -0
  264. package/src/engine/cart/fixtures/crlf.p8 +6 -0
  265. package/src/engine/cart/fixtures/full.p8 +174 -0
  266. package/src/engine/cart/fixtures/header-only.p8 +2 -0
  267. package/src/engine/cart/fixtures/malformed-no-header.p8 +2 -0
  268. package/src/engine/cart/fixtures/minimal.p8 +10 -0
  269. package/src/engine/cart/fixtures/no-trailing-newline.p8 +6 -0
  270. package/src/engine/cart/gfx.ts +195 -0
  271. package/src/engine/cart/index.ts +27 -0
  272. package/src/engine/cart/map.ts +134 -0
  273. package/src/engine/config/config.ts +115 -0
  274. package/src/engine/config/index.ts +12 -0
  275. package/src/engine/gfx/grid.test.ts +128 -0
  276. package/src/engine/gfx/grid.ts +136 -0
  277. package/src/engine/gfx/index.ts +34 -0
  278. package/src/engine/gfx/label.test.ts +105 -0
  279. package/src/engine/gfx/label.ts +229 -0
  280. package/src/engine/gfx/overlap.test.ts +71 -0
  281. package/src/engine/gfx/overlap.ts +66 -0
  282. package/src/engine/gfx/palette.test.ts +35 -0
  283. package/src/engine/gfx/palette.ts +54 -0
  284. package/src/engine/gfx/png.test.ts +111 -0
  285. package/src/engine/gfx/png.ts +196 -0
  286. package/src/engine/gfx/render.test.ts +180 -0
  287. package/src/engine/gfx/render.ts +106 -0
  288. package/src/engine/index.ts +21 -0
  289. package/src/engine/pico8/adapter.ts +303 -0
  290. package/src/engine/pico8/drive.test.ts +415 -0
  291. package/src/engine/pico8/drive.ts +729 -0
  292. package/src/engine/pico8/harness.test.ts +181 -0
  293. package/src/engine/pico8/harness.ts +354 -0
  294. package/src/engine/pico8/index.ts +20 -0
  295. package/src/engine/pico8/sentinel.test.ts +80 -0
  296. package/src/engine/pico8/sentinel.ts +93 -0
  297. package/src/engine/pico8/session-daemon-entry.ts +12 -0
  298. package/src/engine/pico8/session-daemon-main.ts +98 -0
  299. package/src/engine/pico8/session-daemon.test.ts +212 -0
  300. package/src/engine/pico8/session-daemon.ts +306 -0
  301. package/src/engine/pico8/session.test.ts +239 -0
  302. package/src/engine/pico8/session.ts +321 -0
  303. package/src/engine/pico8/shell.test.ts +583 -0
  304. package/src/engine/pico8/shell.ts +412 -0
  305. package/src/engine/pico8/supervisor.test.ts +105 -0
  306. package/src/engine/pico8/supervisor.ts +197 -0
  307. package/src/engine/scaffold/index.ts +15 -0
  308. package/src/engine/scaffold/scaffold.ts +278 -0
  309. package/src/engine/shrinko/adapter.ts +241 -0
  310. package/src/engine/shrinko/fixtures/count-no-compressed.txt +2 -0
  311. package/src/engine/shrinko/fixtures/count-over-budget.txt +3 -0
  312. package/src/engine/shrinko/fixtures/count-under-budget.txt +3 -0
  313. package/src/engine/shrinko/index.ts +33 -0
  314. package/src/engine/shrinko/shell.ts +224 -0
  315. package/src/engine/shrinko/shrinko.test.ts +182 -0
  316. package/src/engine/skills/index.ts +14 -0
  317. package/src/engine/skills/skills.test.ts +376 -0
  318. package/src/engine/skills/skills.ts +205 -0
  319. package/src/index.ts +13 -0
  320. package/src/skills/game-design-reference/SKILL.md +70 -0
  321. package/src/skills/game-jam/SKILL.md +45 -0
  322. package/src/skills/picopilot-art/SKILL.md +62 -0
  323. package/src/skills/picopilot-audio/SKILL.md +194 -0
  324. package/src/skills/picopilot-code/SKILL.md +97 -0
  325. package/src/skills/picopilot-code/reference/README.md +42 -0
  326. package/src/skills/picopilot-code/reference/mode7-racing.md +95 -0
  327. package/src/skills/picopilot-code/reference/platformer.md +119 -0
  328. package/src/skills/picopilot-code/reference/puzzle-grid.md +106 -0
  329. package/src/skills/picopilot-code/reference/rpg-menus-dialog.md +122 -0
  330. package/src/skills/picopilot-code/reference/top-down-adventure.md +117 -0
  331. package/src/skills/picopilot-code/reference/twin-stick-arcade.md +128 -0
  332. package/src/skills/picopilot-debug/SKILL.md +195 -0
  333. package/src/skills/picopilot-overview/SKILL.md +52 -0
  334. package/src/version.ts +22 -0
@@ -0,0 +1,306 @@
1
+ /**
2
+ * The playtest session DAEMON runtime + CLIENT (ADR-0011, prd US #6). This is the
3
+ * IO half of the supervisor: the daemon LISTENS on a session's Unix domain socket
4
+ * and drives a live {@link DriveSession} on behalf of each verb sub-invocation;
5
+ * the {@link SessionClient} CONNECTS to that socket to send a verb and read back
6
+ * the structured response.
7
+ *
8
+ * The daemon owns the live process the whole session, so the game stays ALIVE +
9
+ * PAUSED between the agent's turns. It self-reaps on an IDLE timeout (the orphan
10
+ * path) and on `stop`. The live daemon end-to-end is the manual/opt-in tier; the
11
+ * pure pieces it composes (the handshake {@link DriveSession}, the registry, the
12
+ * frame codec) are unit-tested without a real socket or binary.
13
+ */
14
+
15
+ import {spawn} from 'node:child_process';
16
+ import {createConnection, createServer, type Socket} from 'node:net';
17
+ import {existsSync, readdirSync, rmSync, writeFileSync} from 'node:fs';
18
+ import {dirname, join} from 'node:path';
19
+ import {fileURLToPath} from 'node:url';
20
+ import {
21
+ DriveSession,
22
+ SessionError,
23
+ type ShotResult,
24
+ type StepResult,
25
+ } from './session.js';
26
+ import type {Pico8Process} from './shell.js';
27
+ import {
28
+ DEFAULT_IDLE_TIMEOUT_MS,
29
+ encodeFrame,
30
+ FrameDecoder,
31
+ type SessionPaths,
32
+ type SessionRequest,
33
+ type SessionResponse,
34
+ type SessionResponseValue,
35
+ } from './supervisor.js';
36
+
37
+ /**
38
+ * A client for a running session daemon: connects to its socket, sends ONE verb
39
+ * request, awaits the single response, and closes. Each `playtest step/input/shot
40
+ * /stop` sub-invocation makes exactly one of these round-trips.
41
+ */
42
+ export class SessionClient {
43
+ constructor(private readonly socketPath: string) {}
44
+
45
+ /**
46
+ * Sends `request` to the daemon and resolves its {@link SessionResponse}. A
47
+ * connection failure (no daemon / dead socket) rejects with a plain Error the
48
+ * command maps to `playtest-session-not-found`.
49
+ */
50
+ send(request: SessionRequest): Promise<SessionResponse> {
51
+ return new Promise<SessionResponse>((resolve, reject) => {
52
+ const decoder = new FrameDecoder<SessionResponse>();
53
+ let settled = false;
54
+ const sock = createConnection(this.socketPath);
55
+ sock.setEncoding('utf8');
56
+ sock.on('connect', () => sock.write(encodeFrame(request)));
57
+ sock.on('data', (chunk: string) => {
58
+ const [msg] = decoder.push(chunk);
59
+ if (msg !== undefined && !settled) {
60
+ settled = true;
61
+ resolve(msg);
62
+ sock.end();
63
+ }
64
+ });
65
+ sock.on('error', (err) => {
66
+ if (!settled) {
67
+ settled = true;
68
+ reject(err);
69
+ }
70
+ });
71
+ sock.on('close', () => {
72
+ if (!settled) {
73
+ settled = true;
74
+ reject(new Error('daemon closed the connection with no response'));
75
+ }
76
+ });
77
+ });
78
+ }
79
+ }
80
+
81
+ /** Whether a session daemon is reachable (its socket exists). */
82
+ export function sessionExists(paths: SessionPaths): boolean {
83
+ return existsSync(paths.socket);
84
+ }
85
+
86
+ /** Resolves the sibling daemon-entry module (`.ts` in dev, `.js` when built). */
87
+ function daemonEntryPath(): string {
88
+ const here = dirname(fileURLToPath(import.meta.url));
89
+ // This module and the entry are compiled/loaded together, so the extension
90
+ // here (`.ts` under tsx, `.js` under dist) is the entry's extension too.
91
+ const ext = import.meta.url.endsWith('.ts') ? '.ts' : '.js';
92
+ return join(here, `session-daemon-entry${ext}`);
93
+ }
94
+
95
+ /**
96
+ * Spawns the session DAEMON as a DETACHED background process that outlives this
97
+ * (the `start`) invocation, so the driven PICO-8 stays alive across the agent's
98
+ * separate verb turns. It re-invokes the current executable + `execArgv` (so the
99
+ * daemon runs under the SAME loader, dev `tsx` or built node) on the daemon entry
100
+ * file, passing the config as a single JSON arg. `unref` + `detached` cut it free
101
+ * from this process's lifetime; its stdio is ignored (the daemon talks over the
102
+ * session socket, not this process's pipes).
103
+ */
104
+ export function spawnSessionDaemon(
105
+ config: import('./session-daemon-main.js').DaemonConfig,
106
+ ): void {
107
+ const child = spawn(
108
+ process.execPath,
109
+ [...process.execArgv, daemonEntryPath(), JSON.stringify(config)],
110
+ {detached: true, stdio: 'ignore'},
111
+ );
112
+ child.unref();
113
+ }
114
+
115
+ /** Options for {@link serveSession}. */
116
+ export interface ServeSessionOptions {
117
+ readonly id: string;
118
+ readonly paths: SessionPaths;
119
+ /** The live driven process to own for this session (spawned by the caller). */
120
+ readonly process: Pico8Process;
121
+ /** The screenshot basename the driven cart names each SHOT (matches transform). */
122
+ readonly shotBasename: string;
123
+ /** Idle-reap window (defaults to {@link DEFAULT_IDLE_TIMEOUT_MS}). */
124
+ readonly idleTimeoutMs?: number;
125
+ /** Per-command ACK deadline forwarded to {@link DriveSession}. */
126
+ readonly ackTimeoutMs?: number;
127
+ /** Called when the daemon has fully torn down (for the process to exit). */
128
+ readonly onExit?: () => void;
129
+ }
130
+
131
+ /**
132
+ * Runs the session daemon: wraps the live process in a {@link DriveSession},
133
+ * listens on the session socket, and dispatches each incoming verb request to the
134
+ * handshake (send-command -> wait-ack -> respond). Resets an IDLE-reap timer on
135
+ * every verb, so an orphaned session tears down on its own. Returns the net
136
+ * server so the caller (the daemon entrypoint) can await its lifetime.
137
+ */
138
+ export function serveSession(options: ServeSessionOptions): {
139
+ close: () => void;
140
+ } {
141
+ const session = new DriveSession({
142
+ process: options.process,
143
+ shotBasename: options.shotBasename,
144
+ ackTimeoutMs: options.ackTimeoutMs,
145
+ });
146
+ const idleMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
147
+
148
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
149
+ let tornDown = false;
150
+
151
+ const teardown = (): void => {
152
+ if (tornDown) return;
153
+ tornDown = true;
154
+ if (idleTimer !== undefined) clearTimeout(idleTimer);
155
+ try {
156
+ session.stop();
157
+ } catch {
158
+ // best-effort; the process kill below is the real backstop
159
+ }
160
+ try {
161
+ server.close();
162
+ } catch {
163
+ // ignore
164
+ }
165
+ // Reap the WHOLE session dir (socket + pidfile + driven cart + shots), so an
166
+ // idle/orphan teardown leaves nothing behind, not just the socket. (The CLI
167
+ // `stop` also sweeps the dir; doing it here covers the no-`stop` orphan path.)
168
+ try {
169
+ rmSync(options.paths.dir, {recursive: true, force: true});
170
+ } catch {
171
+ // ignore
172
+ }
173
+ options.onExit?.();
174
+ };
175
+
176
+ const armIdle = (): void => {
177
+ if (idleTimer !== undefined) clearTimeout(idleTimer);
178
+ idleTimer = setTimeout(teardown, idleMs);
179
+ idleTimer.unref?.();
180
+ };
181
+
182
+ const handle = async (req: SessionRequest): Promise<SessionResponse> => {
183
+ armIdle();
184
+ try {
185
+ switch (req.verb) {
186
+ case 'input': {
187
+ const r: StepResult = await session.input(req.held);
188
+ return ok(options.id, {frame: r.frame, ack: r.ack, alive: true});
189
+ }
190
+ case 'step': {
191
+ // A frames span may exceed one byte; the daemon issues it as repeated
192
+ // single-byte steps, each awaiting its own STEP-DONE ack, so the total
193
+ // is exact and lossless regardless of size.
194
+ let last: StepResult = {frame: session.frame, ack: ''};
195
+ let remaining = Math.max(0, Math.floor(req.frames));
196
+ while (remaining > 0) {
197
+ const chunk = Math.min(remaining, 0xff);
198
+ last = await session.step(chunk);
199
+ remaining -= chunk;
200
+ }
201
+ return ok(options.id, {
202
+ frame: last.frame,
203
+ ack: last.ack,
204
+ alive: true,
205
+ });
206
+ }
207
+ case 'shot': {
208
+ const r: ShotResult = await session.shot();
209
+ const screenshot = collectShot(options.paths.shotDir, r.shotName);
210
+ return ok(options.id, {
211
+ frame: r.frame,
212
+ ack: r.ack,
213
+ shotName: r.shotName,
214
+ screenshot,
215
+ alive: true,
216
+ });
217
+ }
218
+ case 'status': {
219
+ return ok(options.id, {
220
+ frame: session.frame,
221
+ alive: !session.isDead,
222
+ });
223
+ }
224
+ case 'stop': {
225
+ // The connection handler tears the daemon down AFTER this response has
226
+ // flushed to the client (so `stop` returns cleanly, no close-race).
227
+ return ok(options.id, {frame: session.frame, alive: false});
228
+ }
229
+ }
230
+ } catch (e) {
231
+ if (e instanceof SessionError) {
232
+ return {ok: false, code: e.code, message: e.message};
233
+ }
234
+ return {
235
+ ok: false,
236
+ code: 'playtest-session-error',
237
+ message: e instanceof Error ? e.message : String(e),
238
+ };
239
+ }
240
+ };
241
+
242
+ const server = createServer((sock: Socket) => {
243
+ sock.setEncoding('utf8');
244
+ const decoder = new FrameDecoder<SessionRequest>();
245
+ sock.on('data', (chunk: string) => {
246
+ for (const req of decoder.push(chunk)) {
247
+ const isStop = req.verb === 'stop';
248
+ void handle(req).then((res) => {
249
+ try {
250
+ // Flush the response, THEN (for `stop`) end the socket and tear the
251
+ // daemon down, so the client always reads the stop reply first.
252
+ sock.write(encodeFrame(res), () => {
253
+ if (isStop) {
254
+ sock.end();
255
+ teardown();
256
+ }
257
+ });
258
+ } catch {
259
+ if (isStop) teardown();
260
+ }
261
+ });
262
+ }
263
+ });
264
+ sock.on('error', () => {
265
+ /* a dropped client is not fatal to the daemon */
266
+ });
267
+ });
268
+
269
+ // If the driven process dies, the next verb reports it (process-dead); we do
270
+ // NOT auto-teardown here, so the agent can still `stop`/`status` and get the
271
+ // structured error rather than a vanished socket.
272
+ server.listen(options.paths.socket, () => {
273
+ writeFileSync(options.paths.pidfile, String(process.pid));
274
+ armIdle();
275
+ });
276
+
277
+ return {close: teardown};
278
+ }
279
+
280
+ /** Builds an ok {@link SessionResponse} from a partial value + the id. */
281
+ function ok(
282
+ id: string,
283
+ partial: Omit<SessionResponseValue, 'id'>,
284
+ ): SessionResponse {
285
+ return {ok: true, value: {id, ...partial}};
286
+ }
287
+
288
+ /**
289
+ * Resolves the absolute path of the screenshot a SHOT just produced in the
290
+ * `-desktop` dir. The cart named it `<shotName>` (basename) and PICO-8 appends
291
+ * `.png`; if the exact file is not yet visible (a filesystem race) it falls back
292
+ * to the most-recent PNG, so the caller still gets a path to read.
293
+ */
294
+ function collectShot(shotDir: string, shotName: string): string | undefined {
295
+ const exact = join(shotDir, `${shotName}.png`);
296
+ if (existsSync(exact)) return exact;
297
+ try {
298
+ const pngs = readdirSync(shotDir)
299
+ .filter((f) => f.toLowerCase().endsWith('.png'))
300
+ .sort();
301
+ const last = pngs.at(-1);
302
+ return last !== undefined ? join(shotDir, last) : undefined;
303
+ } catch {
304
+ return undefined;
305
+ }
306
+ }
@@ -0,0 +1,239 @@
1
+ import {describe, expect, it, vi} from 'vitest';
2
+ import {ACK, BLOCK_SIZE, BlockDecoder, OPCODE} from './drive.js';
3
+ import {DriveSession, SessionError} from './session.js';
4
+ import type {Pico8Process} from './shell.js';
5
+
6
+ /**
7
+ * The RESUMABLE session HANDSHAKE core is tested WITHOUT the paid PICO-8 binary
8
+ * (ADR-0011, prd US #6): a FAKE driven process lets us assert the load-bearing
9
+ * transport contract, each verb WRITES its fixed-size command block and WAITS for
10
+ * the cart's ACK before returning (send -> wait-ack -> return), and the failure
11
+ * paths (a lost/late ack, a process that dies mid-command) surface as STRUCTURED
12
+ * errors, never a hang. A live multi-turn session is the manual/opt-in tier.
13
+ */
14
+
15
+ /**
16
+ * A controllable fake driven process: records the command blocks written to its
17
+ * stdin, and lets the test emit ACK lines back on stdout (or die), so the
18
+ * handshake is driven deterministically with no real binary.
19
+ */
20
+ class FakeDriven implements Pico8Process {
21
+ private stdoutCb: (c: string) => void = () => {};
22
+ private closeCb: (code: number | null) => void = () => {};
23
+ private errorCb: (e: NodeJS.ErrnoException) => void = () => {};
24
+ killed = false;
25
+ /** Every block written to stdin, decoded back into commands for assertions. */
26
+ readonly writes: Uint8Array[] = [];
27
+
28
+ onStdout(cb: (c: string) => void): void {
29
+ this.stdoutCb = cb;
30
+ }
31
+ onClose(cb: (code: number | null) => void): void {
32
+ this.closeCb = cb;
33
+ }
34
+ onError(cb: (e: NodeJS.ErrnoException) => void): void {
35
+ this.errorCb = cb;
36
+ }
37
+ writeStdin(bytes: Uint8Array): void {
38
+ this.writes.push(bytes);
39
+ }
40
+ kill(): void {
41
+ this.killed = true;
42
+ }
43
+
44
+ // --- test drivers ---
45
+ /** Emit a cart ACK line (the handshake's other half). */
46
+ ack(line: string): void {
47
+ this.stdoutCb(`${line}\n`);
48
+ }
49
+ /** Simulate PICO-8 exiting (crash/quit). */
50
+ die(): void {
51
+ this.closeCb(0);
52
+ }
53
+ /** The commands the session wrote so far (decoded from the fixed-size blocks). */
54
+ get commands() {
55
+ const dec = new BlockDecoder();
56
+ const all: number[] = [];
57
+ for (const w of this.writes) for (const b of w) all.push(b);
58
+ return dec.push(all);
59
+ }
60
+ }
61
+
62
+ const session = (proc: FakeDriven, ackTimeoutMs = 1000): DriveSession =>
63
+ new DriveSession({process: proc, shotBasename: 'play', ackTimeoutMs});
64
+
65
+ describe('DriveSession: the send -> wait-ack -> return handshake', () => {
66
+ it('input WRITES a fixed-size INPUT block and returns only after its ACK', async () => {
67
+ const proc = new FakeDriven();
68
+ const s = session(proc);
69
+ const p = s.input(0b10010); // hold Right + O
70
+
71
+ // The command was written as exactly one fixed-size block, and the call has
72
+ // NOT resolved yet (it is waiting for the ACK).
73
+ expect(proc.writes).toHaveLength(1);
74
+ expect(proc.writes[0]!.length).toBe(BLOCK_SIZE);
75
+ expect(proc.commands).toEqual([{op: 'input', held: 0b10010}]);
76
+
77
+ proc.ack(ACK.input);
78
+ const r = await p;
79
+ expect(r.ack).toBe(ACK.input);
80
+ });
81
+
82
+ it('step advances the frame count only after the STEP-DONE ack (settled frame)', async () => {
83
+ const proc = new FakeDriven();
84
+ const s = session(proc);
85
+ const p = s.step(5);
86
+ expect(proc.commands).toEqual([{op: 'step', frames: 5}]);
87
+
88
+ // The step-complete ACK (budget drained) is what resolves it, NOT read time.
89
+ proc.ack(ACK.stepDone);
90
+ const r = await p;
91
+ expect(r.frame).toBe(5);
92
+ expect(r.ack).toBe(ACK.stepDone);
93
+ });
94
+
95
+ it('an ACK that races AHEAD of the wait is not lost (queued)', async () => {
96
+ const proc = new FakeDriven();
97
+ const s = session(proc);
98
+ // Emit the ACK synchronously right after the write, before the race parks.
99
+ const p = s.step(3);
100
+ proc.ack(ACK.stepDone);
101
+ const r = await p;
102
+ expect(r.frame).toBe(3);
103
+ });
104
+
105
+ it('shot returns the basename+index it queued, after the SHOT ack', async () => {
106
+ const proc = new FakeDriven();
107
+ const s = session(proc);
108
+ const p = s.shot();
109
+ expect(proc.commands).toEqual([{op: 'shot'}]);
110
+ proc.ack(ACK.shot);
111
+ const r = await p;
112
+ expect(r.shotName).toBe('play0');
113
+ // A second shot increments the index.
114
+ const p2 = s.shot();
115
+ proc.ack(ACK.shot);
116
+ expect((await p2).shotName).toBe('play1');
117
+ });
118
+
119
+ it('steps are lossless across turns (the frame tally accumulates)', async () => {
120
+ const proc = new FakeDriven();
121
+ const s = session(proc);
122
+ const p1 = s.step(4);
123
+ proc.ack(ACK.stepDone);
124
+ await p1;
125
+ const p2 = s.step(6);
126
+ proc.ack(ACK.stepDone);
127
+ await p2;
128
+ expect(s.frame).toBe(10);
129
+ });
130
+ });
131
+
132
+ describe('DriveSession: the lost/late-ack path (never a hang)', () => {
133
+ it('a missing ACK within the deadline is a structured ack-timeout error', async () => {
134
+ vi.useFakeTimers();
135
+ try {
136
+ const proc = new FakeDriven();
137
+ const s = session(proc, 500);
138
+ const p = s.step(1).then(
139
+ () => 'resolved',
140
+ (e) => e,
141
+ );
142
+ // No ACK is ever emitted; advance past the deadline.
143
+ await vi.advanceTimersByTimeAsync(600);
144
+ const outcome = await p;
145
+ expect(outcome).toBeInstanceOf(SessionError);
146
+ expect((outcome as SessionError).code).toBe(
147
+ 'playtest-session-ack-timeout',
148
+ );
149
+ } finally {
150
+ vi.useRealTimers();
151
+ }
152
+ });
153
+
154
+ it('a LATE ack (after the deadline) still does not resolve the timed-out call', async () => {
155
+ vi.useFakeTimers();
156
+ try {
157
+ const proc = new FakeDriven();
158
+ const s = session(proc, 300);
159
+ const p = s.step(1).then(
160
+ () => 'resolved',
161
+ (e) => (e as SessionError).code,
162
+ );
163
+ await vi.advanceTimersByTimeAsync(400);
164
+ proc.ack(ACK.stepDone); // arrives too late
165
+ expect(await p).toBe('playtest-session-ack-timeout');
166
+ } finally {
167
+ vi.useRealTimers();
168
+ }
169
+ });
170
+ });
171
+
172
+ describe('DriveSession: PICO-8 dying mid-session (structured, not a hang)', () => {
173
+ it('a process death while a command is in flight rejects with process-dead', async () => {
174
+ const proc = new FakeDriven();
175
+ const s = session(proc);
176
+ const p = s.step(10).then(
177
+ () => 'resolved',
178
+ (e) => (e as SessionError).code,
179
+ );
180
+ proc.die(); // PICO-8 exits before the ACK
181
+ expect(await p).toBe('playtest-session-process-dead');
182
+ expect(s.isDead).toBe(true);
183
+ });
184
+
185
+ it('a command issued AFTER the process died is a structured process-dead', async () => {
186
+ const proc = new FakeDriven();
187
+ const s = session(proc);
188
+ proc.die();
189
+ await expect(s.step(1)).rejects.toMatchObject({
190
+ code: 'playtest-session-process-dead',
191
+ });
192
+ });
193
+ });
194
+
195
+ describe('DriveSession: stop (teardown)', () => {
196
+ it('writes a QUIT block, kills the process, and closes the session', async () => {
197
+ const proc = new FakeDriven();
198
+ const s = session(proc);
199
+ s.stop();
200
+ expect(proc.commands).toEqual([{op: 'quit'}]);
201
+ expect(proc.killed).toBe(true);
202
+ // A verb after stop is a structured closed error, not a hang.
203
+ await expect(s.input(1)).rejects.toMatchObject({
204
+ code: 'playtest-session-closed',
205
+ });
206
+ });
207
+
208
+ it('is idempotent + safe on an already-dead process', () => {
209
+ const proc = new FakeDriven();
210
+ const s = session(proc);
211
+ proc.die();
212
+ expect(() => {
213
+ s.stop();
214
+ s.stop();
215
+ }).not.toThrow();
216
+ expect(proc.killed).toBe(true);
217
+ });
218
+ });
219
+
220
+ describe('DriveSession: every command is a FIXED-SIZE block (the coalesce guard)', () => {
221
+ it('input/step/shot/quit each write exactly BLOCK_SIZE bytes', async () => {
222
+ const proc = new FakeDriven();
223
+ const s = session(proc);
224
+ const pi = s.input(1);
225
+ proc.ack(ACK.input);
226
+ await pi;
227
+ const ps = s.step(2);
228
+ proc.ack(ACK.stepDone);
229
+ await ps;
230
+ const psh = s.shot();
231
+ proc.ack(ACK.shot);
232
+ await psh;
233
+ for (const w of proc.writes) expect(w.length).toBe(BLOCK_SIZE);
234
+ // And the opcodes are the tagged protocol (not raw wall-clock writes).
235
+ expect(proc.writes[0]![0]).toBe(OPCODE.input);
236
+ expect(proc.writes[1]![0]).toBe(OPCODE.step);
237
+ expect(proc.writes[2]![0]).toBe(OPCODE.shot);
238
+ });
239
+ });