@quickchatai/quickreview 1.2.0 → 1.3.2

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 (350) hide show
  1. package/README.md +116 -53
  2. package/bin/qcr.js +1 -1
  3. package/config.example.toml +25 -7
  4. package/deploy/ansible/README.md +20 -12
  5. package/deploy/ansible/inventory.example.ini +1 -1
  6. package/deploy/collect-secrets.sh +1 -1
  7. package/deploy/qcr-update.sh +1 -1
  8. package/dist/extensions/cli/home.js +14 -0
  9. package/dist/extensions/cli/home.js.map +1 -0
  10. package/dist/extensions/cli/selfimprove.js +18 -0
  11. package/dist/extensions/cli/selfimprove.js.map +1 -0
  12. package/dist/extensions/home.js +350 -0
  13. package/dist/extensions/home.js.map +1 -0
  14. package/dist/extensions/login-route.js +1 -1
  15. package/dist/extensions/login-route.js.map +1 -1
  16. package/dist/extensions/onboarding.js +8 -7
  17. package/dist/extensions/onboarding.js.map +1 -1
  18. package/dist/extensions/selfimprove.js +49 -0
  19. package/dist/extensions/selfimprove.js.map +1 -0
  20. package/dist/extensions/setup-scan.js +23 -592
  21. package/dist/extensions/setup-scan.js.map +1 -1
  22. package/dist/extensions/setup.js +2 -47
  23. package/dist/extensions/setup.js.map +1 -1
  24. package/dist/extensions/vendor/openviking-pi/index.js +4 -0
  25. package/dist/extensions/vendor/openviking-pi/index.js.map +1 -1
  26. package/dist/extensions/vendor/openviking-pi/qcr-hooks.js +3 -1
  27. package/dist/extensions/vendor/openviking-pi/qcr-hooks.js.map +1 -1
  28. package/dist/extensions/vendor/openviking-pi/sync.js +81 -0
  29. package/dist/extensions/vendor/openviking-pi/sync.js.map +1 -1
  30. package/dist/src/archive/restore.js +18 -15
  31. package/dist/src/archive/restore.js.map +1 -1
  32. package/dist/src/botworker.js +13 -2
  33. package/dist/src/botworker.js.map +1 -1
  34. package/dist/src/channelfs.js +3 -26
  35. package/dist/src/channelfs.js.map +1 -1
  36. package/dist/src/cli.js +14 -50
  37. package/dist/src/cli.js.map +1 -1
  38. package/dist/src/config.js +16 -0
  39. package/dist/src/config.js.map +1 -1
  40. package/dist/src/daemon.js +33 -4
  41. package/dist/src/daemon.js.map +1 -1
  42. package/dist/src/debuground.js +1 -0
  43. package/dist/src/debuground.js.map +1 -1
  44. package/dist/src/debugroundpi.js +1 -0
  45. package/dist/src/debugroundpi.js.map +1 -1
  46. package/dist/src/engine/caserunner-sdk.js +15 -251
  47. package/dist/src/engine/caserunner-sdk.js.map +1 -1
  48. package/dist/src/home/adapters.js +349 -0
  49. package/dist/src/home/adapters.js.map +1 -0
  50. package/dist/src/home/advanced.js +318 -0
  51. package/dist/src/home/advanced.js.map +1 -0
  52. package/dist/src/home/advancedtools.js +57 -0
  53. package/dist/src/home/advancedtools.js.map +1 -0
  54. package/dist/src/home/autoreview.js +325 -0
  55. package/dist/src/home/autoreview.js.map +1 -0
  56. package/dist/src/home/daemonstatus.js +32 -0
  57. package/dist/src/home/daemonstatus.js.map +1 -0
  58. package/dist/src/home/devicelogin.js +61 -0
  59. package/dist/src/home/devicelogin.js.map +1 -0
  60. package/dist/src/home/launch.js +202 -0
  61. package/dist/src/home/launch.js.map +1 -0
  62. package/dist/src/home/loginowner.js +205 -0
  63. package/dist/src/home/loginowner.js.map +1 -0
  64. package/dist/src/home/memory.js +122 -0
  65. package/dist/src/home/memory.js.map +1 -0
  66. package/dist/src/home/model.js +811 -0
  67. package/dist/src/home/model.js.map +1 -0
  68. package/dist/src/home/panel.js +520 -0
  69. package/dist/src/home/panel.js.map +1 -0
  70. package/dist/src/home/ports.js +19 -0
  71. package/dist/src/home/ports.js.map +1 -0
  72. package/dist/src/home/review.js +163 -0
  73. package/dist/src/home/review.js.map +1 -0
  74. package/dist/src/home/reviews.js +478 -0
  75. package/dist/src/home/reviews.js.map +1 -0
  76. package/dist/src/home/setup.js +507 -0
  77. package/dist/src/home/setup.js.map +1 -0
  78. package/dist/src/home/setupadapters.js +214 -0
  79. package/dist/src/home/setupadapters.js.map +1 -0
  80. package/dist/src/home/setupcard.js +199 -0
  81. package/dist/src/home/setupcard.js.map +1 -0
  82. package/dist/src/home/setuptools.js +45 -0
  83. package/dist/src/home/setuptools.js.map +1 -0
  84. package/dist/src/home/spend.js +160 -0
  85. package/dist/src/home/spend.js.map +1 -0
  86. package/dist/src/home/switches.js +209 -0
  87. package/dist/src/home/switches.js.map +1 -0
  88. package/dist/src/home/switchon.js +439 -0
  89. package/dist/src/home/switchon.js.map +1 -0
  90. package/dist/src/home/toolnames.js +41 -0
  91. package/dist/src/home/toolnames.js.map +1 -0
  92. package/dist/src/home/tune.js +341 -0
  93. package/dist/src/home/tune.js.map +1 -0
  94. package/dist/src/home/words.js +7 -0
  95. package/dist/src/home/words.js.map +1 -0
  96. package/dist/src/homes.js +14 -0
  97. package/dist/src/homes.js.map +1 -1
  98. package/dist/src/install.js +1 -1
  99. package/dist/src/install.js.map +1 -1
  100. package/dist/src/ipc/workerspec.js +8 -0
  101. package/dist/src/ipc/workerspec.js.map +1 -1
  102. package/dist/src/ledgercheck.js +39 -10
  103. package/dist/src/ledgercheck.js.map +1 -1
  104. package/dist/src/live/publish.js +1 -1
  105. package/dist/src/live/publish.js.map +1 -1
  106. package/dist/src/livecli.js +1 -1
  107. package/dist/src/livecli.js.map +1 -1
  108. package/dist/src/loginrefresh/handover.js +121 -0
  109. package/dist/src/loginrefresh/handover.js.map +1 -0
  110. package/dist/src/loginrefresh/lock.js +54 -3
  111. package/dist/src/loginrefresh/lock.js.map +1 -1
  112. package/dist/src/loginrefresh/refresher.js +33 -5
  113. package/dist/src/loginrefresh/refresher.js.map +1 -1
  114. package/dist/src/loginrefresh/supervisor.js +18 -1
  115. package/dist/src/loginrefresh/supervisor.js.map +1 -1
  116. package/dist/src/npmverbs.js +42 -12
  117. package/dist/src/npmverbs.js.map +1 -1
  118. package/dist/src/onboard/choices.js +6 -3
  119. package/dist/src/onboard/choices.js.map +1 -1
  120. package/dist/src/onboard/engine.js +33 -292
  121. package/dist/src/onboard/engine.js.map +1 -1
  122. package/dist/src/onboard/steps/bringup.js +3 -2
  123. package/dist/src/onboard/steps/bringup.js.map +1 -1
  124. package/dist/src/onboard/steps/configfill.js +18 -7
  125. package/dist/src/onboard/steps/configfill.js.map +1 -1
  126. package/dist/src/onboard/steps/loginshare.js +213 -0
  127. package/dist/src/onboard/steps/loginshare.js.map +1 -0
  128. package/dist/src/onboard/steps/memory.js +14 -2
  129. package/dist/src/onboard/steps/memory.js.map +1 -1
  130. package/dist/src/onboard/steps/repoconfig.js +72 -0
  131. package/dist/src/onboard/steps/repoconfig.js.map +1 -0
  132. package/dist/src/onboard/toolnames.js +2 -2
  133. package/dist/src/onboard/toolnames.js.map +1 -1
  134. package/dist/src/piagentdir.js +13 -0
  135. package/dist/src/piagentdir.js.map +1 -0
  136. package/dist/src/pilogin.js +31 -16
  137. package/dist/src/pilogin.js.map +1 -1
  138. package/dist/src/qcrbin.js +16 -11
  139. package/dist/src/qcrbin.js.map +1 -1
  140. package/dist/src/reviewargs.js +122 -0
  141. package/dist/src/reviewargs.js.map +1 -0
  142. package/dist/src/reviewcli.js +7 -161
  143. package/dist/src/reviewcli.js.map +1 -1
  144. package/dist/src/reviewdetach.js +76 -11
  145. package/dist/src/reviewdetach.js.map +1 -1
  146. package/dist/src/scancli.js +39 -691
  147. package/dist/src/scancli.js.map +1 -1
  148. package/dist/src/selfimprove.js +66 -0
  149. package/dist/src/selfimprove.js.map +1 -0
  150. package/dist/src/session-command-supervisor.js +38 -9
  151. package/dist/src/session-command-supervisor.js.map +1 -1
  152. package/dist/src/setup/adapters.js +5 -223
  153. package/dist/src/setup/adapters.js.map +1 -1
  154. package/dist/src/setup/caserunner.js +15 -249
  155. package/dist/src/setup/caserunner.js.map +1 -1
  156. package/dist/src/setup/engine.js +32 -341
  157. package/dist/src/setup/engine.js.map +1 -1
  158. package/dist/src/setup/facts.js +3 -592
  159. package/dist/src/setup/facts.js.map +1 -1
  160. package/dist/src/setup/findings.js +3 -947
  161. package/dist/src/setup/findings.js.map +1 -1
  162. package/dist/src/setup/presentation.js +3 -8
  163. package/dist/src/setup/presentation.js.map +1 -1
  164. package/dist/src/setup/scan.js +3 -724
  165. package/dist/src/setup/scan.js.map +1 -1
  166. package/dist/src/setup/scanbuild.js +9 -92
  167. package/dist/src/setup/scanbuild.js.map +1 -1
  168. package/dist/src/setup/scanlease.js +3 -250
  169. package/dist/src/setup/scanlease.js.map +1 -1
  170. package/dist/src/setup/scanreport.js +3 -288
  171. package/dist/src/setup/scanreport.js.map +1 -1
  172. package/dist/src/setup/scanrun.js +3 -1732
  173. package/dist/src/setup/scanrun.js.map +1 -1
  174. package/dist/src/setup/scantree.js +3 -256
  175. package/dist/src/setup/scantree.js.map +1 -1
  176. package/dist/src/setup/stage2request.js +3 -258
  177. package/dist/src/setup/stage2request.js.map +1 -1
  178. package/dist/src/setup/unitcut.js +3 -436
  179. package/dist/src/setup/unitcut.js.map +1 -1
  180. package/dist/src/state.js +75 -9
  181. package/dist/src/state.js.map +1 -1
  182. package/dist/src/takeover.js +1 -0
  183. package/dist/src/takeover.js.map +1 -1
  184. package/dist/src/takeoverpi.js +2 -1
  185. package/dist/src/takeoverpi.js.map +1 -1
  186. package/dist/src/tui/alerts.js +4 -1
  187. package/dist/src/tui/alerts.js.map +1 -1
  188. package/dist/src/tui/board.js +2 -2
  189. package/dist/src/tui/board.js.map +1 -1
  190. package/dist/src/tui/companion.js +2 -1
  191. package/dist/src/tui/companion.js.map +1 -1
  192. package/dist/src/tui/deploytab.js +2 -2
  193. package/dist/src/tui/deploytab.js.map +1 -1
  194. package/dist/src/tui/index.js +9 -6
  195. package/dist/src/tui/index.js.map +1 -1
  196. package/dist/src/tui/memory.js +3 -16
  197. package/dist/src/tui/memory.js.map +1 -1
  198. package/dist/src/tui/memoryurl.js +22 -0
  199. package/dist/src/tui/memoryurl.js.map +1 -0
  200. package/dist/src/tui/remote.js +1 -1
  201. package/dist/src/tui/remote.js.map +1 -1
  202. package/dist/src/tui/route.js +2 -2
  203. package/dist/src/tui/route.js.map +1 -1
  204. package/dist/src/tui/setupstate.js +8 -5
  205. package/dist/src/tui/setupstate.js.map +1 -1
  206. package/dist/src/tui/sshtriage.js +1 -1
  207. package/dist/src/tui/sshtriage.js.map +1 -1
  208. package/dist/src/verdicts.js +12 -0
  209. package/dist/src/verdicts.js.map +1 -1
  210. package/dist/src/wire.js +9 -4
  211. package/dist/src/wire.js.map +1 -1
  212. package/dist/src/workerbreaker.js +32 -5
  213. package/dist/src/workerbreaker.js.map +1 -1
  214. package/dist/src/workerworld.js +4 -0
  215. package/dist/src/workerworld.js.map +1 -1
  216. package/docker/README.md +1 -1
  217. package/docker/entrypoint.sh +40 -6
  218. package/docker-compose.yml +16 -1
  219. package/extensions/cli/home.ts +14 -0
  220. package/extensions/cli/selfimprove.ts +18 -0
  221. package/extensions/home.ts +484 -0
  222. package/extensions/login-route.ts +1 -1
  223. package/extensions/onboarding.ts +8 -7
  224. package/extensions/selfimprove.ts +65 -0
  225. package/extensions/setup-scan.ts +79 -766
  226. package/extensions/setup.ts +14 -70
  227. package/extensions/vendor/openviking-pi/NOTICE +5 -0
  228. package/extensions/vendor/openviking-pi/README.md +39 -5
  229. package/extensions/vendor/openviking-pi/index.ts +4 -0
  230. package/extensions/vendor/openviking-pi/patches/0014-one-turn-sync-in-flight.patch +239 -0
  231. package/extensions/vendor/openviking-pi/qcr-hooks.ts +3 -1
  232. package/extensions/vendor/openviking-pi/sync.ts +80 -0
  233. package/extensions/vendor/openviking-pi/upstream.json +2 -1
  234. package/package.json +6 -4
  235. package/prompts/onboarding.md +8 -7
  236. package/schemas/review-result.v1.json +60 -0
  237. package/scripts/deploy.sh +8 -6
  238. package/scripts/qcr-host.sh +28 -5
  239. package/scripts/vm-ghcr-login.sh +7 -4
  240. package/src/archive/restore.ts +18 -21
  241. package/src/botworker.ts +13 -2
  242. package/src/channelfs.ts +3 -26
  243. package/src/cli.ts +13 -54
  244. package/src/config.ts +38 -0
  245. package/src/daemon.ts +34 -4
  246. package/src/debuground.ts +3 -0
  247. package/src/debugroundpi.ts +1 -0
  248. package/src/engine/caserunner-sdk.ts +29 -314
  249. package/src/home/adapters.ts +418 -0
  250. package/src/home/advanced.ts +421 -0
  251. package/src/home/advancedtools.ts +96 -0
  252. package/src/home/autoreview.ts +385 -0
  253. package/src/home/daemonstatus.ts +55 -0
  254. package/src/home/devicelogin.ts +107 -0
  255. package/src/home/launch.ts +257 -0
  256. package/src/home/loginowner.ts +248 -0
  257. package/src/home/memory.ts +176 -0
  258. package/src/home/model.ts +965 -0
  259. package/src/home/panel.ts +666 -0
  260. package/src/home/ports.ts +338 -0
  261. package/src/home/review.ts +206 -0
  262. package/src/home/reviews.ts +565 -0
  263. package/src/home/setup.ts +689 -0
  264. package/src/home/setupadapters.ts +217 -0
  265. package/src/home/setupcard.ts +288 -0
  266. package/src/home/setuptools.ts +57 -0
  267. package/src/home/spend.ts +194 -0
  268. package/src/home/switches.ts +293 -0
  269. package/src/home/switchon.ts +554 -0
  270. package/src/home/toolnames.ts +46 -0
  271. package/src/home/tune.ts +405 -0
  272. package/src/home/words.ts +8 -0
  273. package/src/homes.ts +17 -0
  274. package/src/install.ts +1 -1
  275. package/src/ipc/workerspec.ts +15 -0
  276. package/src/ledgercheck.ts +45 -9
  277. package/src/live/publish.ts +4 -1
  278. package/src/livecli.ts +6 -1
  279. package/src/loginrefresh/handover.ts +138 -0
  280. package/src/loginrefresh/lock.ts +72 -3
  281. package/src/loginrefresh/refresher.ts +34 -5
  282. package/src/loginrefresh/supervisor.ts +25 -2
  283. package/src/npmverbs.ts +51 -11
  284. package/src/onboard/choices.ts +6 -3
  285. package/src/onboard/engine.ts +64 -434
  286. package/src/onboard/steps/bringup.ts +3 -2
  287. package/src/onboard/steps/configfill.ts +23 -7
  288. package/src/onboard/steps/loginshare.ts +258 -0
  289. package/src/onboard/steps/memory.ts +18 -2
  290. package/src/onboard/steps/repoconfig.ts +85 -0
  291. package/src/onboard/toolnames.ts +2 -2
  292. package/src/piagentdir.ts +12 -0
  293. package/src/pilogin.ts +32 -16
  294. package/src/qcrbin.ts +16 -11
  295. package/src/reviewargs.ts +119 -0
  296. package/src/reviewcli.ts +6 -167
  297. package/src/reviewdetach.ts +107 -13
  298. package/src/scancli.ts +75 -826
  299. package/src/selfimprove.ts +90 -0
  300. package/src/session-command-supervisor.ts +41 -8
  301. package/src/setup/adapters.ts +13 -221
  302. package/src/setup/caserunner.ts +34 -273
  303. package/src/setup/engine.ts +62 -496
  304. package/src/setup/facts.ts +3 -627
  305. package/src/setup/findings.ts +3 -1264
  306. package/src/setup/presentation.ts +3 -18
  307. package/src/setup/scan.ts +3 -1033
  308. package/src/setup/scanbuild.ts +20 -114
  309. package/src/setup/scanlease.ts +3 -297
  310. package/src/setup/scanreport.ts +3 -373
  311. package/src/setup/scanrun.ts +3 -2268
  312. package/src/setup/scantree.ts +3 -259
  313. package/src/setup/stage2request.ts +3 -347
  314. package/src/setup/unitcut.ts +3 -523
  315. package/src/state.ts +126 -11
  316. package/src/takeover.ts +5 -0
  317. package/src/takeoverpi.ts +3 -2
  318. package/src/tui/alerts.ts +9 -2
  319. package/src/tui/board.ts +2 -2
  320. package/src/tui/companion.ts +2 -1
  321. package/src/tui/deploytab.ts +2 -2
  322. package/src/tui/index.ts +9 -6
  323. package/src/tui/memory.ts +3 -15
  324. package/src/tui/memoryurl.ts +22 -0
  325. package/src/tui/remote.ts +1 -1
  326. package/src/tui/route.ts +2 -2
  327. package/src/tui/setupstate.ts +20 -16
  328. package/src/tui/sshtriage.ts +1 -1
  329. package/src/verdicts.ts +13 -0
  330. package/src/wire.ts +9 -4
  331. package/src/workerbreaker.ts +41 -5
  332. package/src/workerworld.ts +9 -1
  333. package/dist/src/live/projection.js +0 -450
  334. package/dist/src/live/projection.js.map +0 -1
  335. package/dist/src/live/read.js +0 -60
  336. package/dist/src/live/read.js.map +0 -1
  337. package/dist/src/onboard/launch.js +0 -75
  338. package/dist/src/onboard/launch.js.map +0 -1
  339. package/dist/src/setup/ledger.js +0 -207
  340. package/dist/src/setup/ledger.js.map +0 -1
  341. package/dist/src/setup/stage2.js +0 -617
  342. package/dist/src/setup/stage2.js.map +0 -1
  343. package/dist/src/setup/textsniff.js +0 -89
  344. package/dist/src/setup/textsniff.js.map +0 -1
  345. package/src/live/projection.ts +0 -491
  346. package/src/live/read.ts +0 -86
  347. package/src/onboard/launch.ts +0 -103
  348. package/src/setup/ledger.ts +0 -324
  349. package/src/setup/stage2.ts +0 -926
  350. package/src/setup/textsniff.ts +0 -95
@@ -0,0 +1,257 @@
1
+ // A bare `qcr`: the home. The app's pinned pi (src/picli.ts), with the plugin
2
+ // and the home's extension loaded, on the person's own pi home.
3
+ //
4
+ // pi home the person's own: $PI_CODING_AGENT_DIR as the person set it,
5
+ // else ~/.pi/agent. The launch never sets it, so the chat, the
6
+ // panel's login line (which reads the same dir) and every review
7
+ // use one login.
8
+ // quiet no logo, resource list, trust prompt, changelog or model
9
+ // warning, and nothing written to the person's global pi
10
+ // settings: the quiet start is project settings in the home's
11
+ // own working directory (in the app's state home), the per-run
12
+ // trust flag `--approve`, and an explicit `--model`. The
13
+ // project settings also carry the pinned pi's version as the
14
+ // last changelog seen, or pi would record it in the global
15
+ // settings on a first run.
16
+ // resume the sessions live in the state home (`--session-dir`), and
17
+ // `--continue` opens the most recent one there, or a new one.
18
+ // tools `[home] tools = "reviewer"` (the default) names the allowed
19
+ // tools (src/home/toolnames.ts) and starts no MCP server;
20
+ // "full" leaves pi's own defaults.
21
+ // GitHub the person's own `gh`. In the image (scripts/qcr-host.sh's
22
+ // home mode) there is no gh config: the wrapper hands the token
23
+ // over as a file (packages/pi-plugin/src/ghcred.ts), src/cli.ts
24
+ // takes it at startup, and the home passes it to pi as GH_TOKEN,
25
+ // in its environment only.
26
+ //
27
+ // `-ne` keeps the person's own extensions out: a plugin they installed in pi
28
+ // themselves would otherwise register its commands twice.
29
+ //
30
+ // Everything effectful is injected; `homeRunDeps()` is the real assembly.
31
+ import { spawnSync } from "node:child_process";
32
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
33
+ import { dirname, join } from "node:path";
34
+ import { parse } from "smol-toml";
35
+ import { HOME_DEFAULTS, parseHomeSection, type HomeConfig } from "../config.js";
36
+ import { pluginPackageDir } from "../coreext.js";
37
+ import { locateConfig, stateHome } from "../homes.js";
38
+ import { piCliPath, piPackageDir } from "../picli.js";
39
+ import { HOME_MODEL_ENV, HOME_TOOLS_ENV } from "./adapters.js";
40
+ import { reviewerToolAllowlist } from "./toolnames.js";
41
+
42
+ type Env = Readonly<Record<string, string | undefined>>;
43
+
44
+ /** The home's places in the state home. */
45
+ export function homePaths(state: string): { cwd: string; settings: string; sessions: string } {
46
+ const cwd = join(state, "home");
47
+ return {
48
+ cwd,
49
+ settings: join(cwd, ".pi", "settings.json"),
50
+ sessions: join(cwd, "sessions"),
51
+ };
52
+ }
53
+
54
+ /** The env shim pi loads for the home, in the app at `appRoot`. */
55
+ export function homeExtensionPath(appRoot: string): string {
56
+ return join(appRoot, "extensions", "cli", "home.ts");
57
+ }
58
+
59
+ /** The home's project settings, over whatever the file already holds. */
60
+ export function homeProjectSettings(
61
+ existing: unknown,
62
+ piVersion: string | null,
63
+ ): Record<string, unknown> {
64
+ const base =
65
+ typeof existing === "object" && existing !== null && !Array.isArray(existing)
66
+ ? (existing as Record<string, unknown>)
67
+ : {};
68
+ return {
69
+ ...base,
70
+ quietStartup: true,
71
+ ...(piVersion === null ? {} : { lastChangelogVersion: piVersion }),
72
+ };
73
+ }
74
+
75
+ export interface HomeLaunchInput {
76
+ piCli: string;
77
+ pluginDir: string;
78
+ appRoot: string;
79
+ stateHome: string;
80
+ home: HomeConfig;
81
+ env: Env;
82
+ /** A GitHub token handed over to this process, or null: the person's gh. */
83
+ githubToken?: string | null;
84
+ }
85
+
86
+ export interface HomeLaunch {
87
+ /** pi's CLI entry and its arguments, run by this node. */
88
+ args: string[];
89
+ cwd: string;
90
+ env: Record<string, string | undefined>;
91
+ }
92
+
93
+ /** What a bare `qcr` runs. */
94
+ export function homeLaunch(o: HomeLaunchInput): HomeLaunch {
95
+ const p = homePaths(o.stateHome);
96
+ const tools =
97
+ o.home.tools === "reviewer" ? ["--tools", reviewerToolAllowlist().join(","), "--no-mcp"] : [];
98
+ return {
99
+ args: [
100
+ o.piCli,
101
+ "--approve",
102
+ "--no-extensions",
103
+ "-e",
104
+ o.pluginDir,
105
+ "-e",
106
+ homeExtensionPath(o.appRoot),
107
+ "--model",
108
+ o.home.model,
109
+ ...tools,
110
+ "--session-dir",
111
+ p.sessions,
112
+ "--continue",
113
+ ],
114
+ cwd: p.cwd,
115
+ // PI_CODING_AGENT_DIR passes through as the person set it, or stays unset
116
+ env: {
117
+ ...o.env,
118
+ PI_SKIP_VERSION_CHECK: "1",
119
+ [HOME_MODEL_ENV]: o.home.model,
120
+ [HOME_TOOLS_ENV]: o.home.tools,
121
+ ...(o.githubToken ? { GH_TOKEN: o.githubToken } : {}),
122
+ },
123
+ };
124
+ }
125
+
126
+ export interface HomeRunDeps {
127
+ env: Env;
128
+ appRoot: string;
129
+ stateHome: string;
130
+ piCli(): string;
131
+ pluginDir(): string;
132
+ /** The installed pi's version, or null when it cannot be read. */
133
+ piVersion(): string | null;
134
+ /** The GitHub token handed over to this process, or null. */
135
+ githubToken(): string | null;
136
+ /** The config's `[home]`; a problem with it is said and the defaults used. */
137
+ homeConfig(): { home: HomeConfig; problem?: string };
138
+ readFile(path: string): string;
139
+ writeFile(path: string, text: string): void;
140
+ mkdir(path: string): void;
141
+ /** Run a child with the terminal handed over; its exit code (null: signalled). */
142
+ run(
143
+ cmd: string,
144
+ args: readonly string[],
145
+ opts: { cwd: string; env: Record<string, string | undefined> },
146
+ ): { status: number | null; error?: Error };
147
+ execPath: string;
148
+ err(line: string): void;
149
+ }
150
+
151
+ /** A bare `qcr`: open the home. pi's exit code. */
152
+ export function runHome(deps: HomeRunDeps): number {
153
+ let piCli: string;
154
+ let pluginDir: string;
155
+ try {
156
+ piCli = deps.piCli();
157
+ pluginDir = deps.pluginDir();
158
+ } catch (err) {
159
+ deps.err(`qcr: ${err instanceof Error ? err.message : String(err)}`);
160
+ return 1;
161
+ }
162
+ const { home, problem } = deps.homeConfig();
163
+ if (problem) deps.err(`qcr: ${problem}; the home opens on its defaults.`);
164
+ const p = homePaths(deps.stateHome);
165
+ try {
166
+ deps.mkdir(p.sessions);
167
+ deps.mkdir(dirname(p.settings));
168
+ let existing: unknown = undefined;
169
+ let before: string | null = null;
170
+ try {
171
+ before = deps.readFile(p.settings);
172
+ existing = JSON.parse(before);
173
+ } catch {
174
+ // no settings yet, or not ours to read: written fresh below
175
+ }
176
+ const text = `${JSON.stringify(homeProjectSettings(existing, deps.piVersion()), null, 2)}\n`;
177
+ if (text !== before) deps.writeFile(p.settings, text);
178
+ } catch (err) {
179
+ deps.err(
180
+ `qcr: can't prepare the home in ${p.cwd}: ${err instanceof Error ? err.message : String(err)}`,
181
+ );
182
+ return 1;
183
+ }
184
+ const launch = homeLaunch({
185
+ piCli,
186
+ pluginDir,
187
+ appRoot: deps.appRoot,
188
+ stateHome: deps.stateHome,
189
+ home,
190
+ env: deps.env,
191
+ githubToken: deps.githubToken(),
192
+ });
193
+ const r = deps.run(deps.execPath, launch.args, { cwd: launch.cwd, env: launch.env });
194
+ if (r.error) {
195
+ deps.err(`qcr: pi could not start: ${r.error.message}`);
196
+ return 1;
197
+ }
198
+ return r.status ?? 1;
199
+ }
200
+
201
+ /** The config's `[home]`, read on its own: the home opens even when the rest
202
+ * of the config would not load. */
203
+ export function readHomeConfig(
204
+ env: Env,
205
+ read: (p: string) => string = (p) => readFileSync(p, "utf8"),
206
+ ): { home: HomeConfig; problem?: string } {
207
+ const path = locateConfig({ env }).path;
208
+ let raw: string;
209
+ try {
210
+ raw = read(path);
211
+ } catch (err) {
212
+ if ((err as NodeJS.ErrnoException | null)?.code === "ENOENT")
213
+ return { home: { ...HOME_DEFAULTS } };
214
+ return { home: { ...HOME_DEFAULTS }, problem: `can't read ${path}` };
215
+ }
216
+ try {
217
+ return { home: parseHomeSection((parse(raw) as Record<string, unknown>)["home"]) };
218
+ } catch (err) {
219
+ return {
220
+ home: { ...HOME_DEFAULTS },
221
+ problem: `${path}: ${err instanceof Error ? err.message : String(err)}`,
222
+ };
223
+ }
224
+ }
225
+
226
+ export function homeRunDeps(appRoot: string, env: Env = process.env): HomeRunDeps {
227
+ return {
228
+ env,
229
+ appRoot,
230
+ stateHome: stateHome(env),
231
+ piCli: () => piCliPath(),
232
+ pluginDir: () => pluginPackageDir(),
233
+ piVersion: () => {
234
+ try {
235
+ const pkg = JSON.parse(readFileSync(join(piPackageDir(), "package.json"), "utf8")) as {
236
+ version?: unknown;
237
+ };
238
+ return typeof pkg.version === "string" ? pkg.version : null;
239
+ } catch {
240
+ return null;
241
+ }
242
+ },
243
+ // the person's own gh; the image's `qcr home` (src/cli.ts) passes the
244
+ // token the wrapper handed over
245
+ githubToken: () => null,
246
+ homeConfig: () => readHomeConfig(env),
247
+ readFile: (p) => readFileSync(p, "utf8"),
248
+ writeFile: (p, t) => writeFileSync(p, t),
249
+ mkdir: (p) => mkdirSync(p, { recursive: true }),
250
+ run: (cmd, args, opts) => {
251
+ const r = spawnSync(cmd, [...args], { stdio: "inherit", cwd: opts.cwd, env: opts.env });
252
+ return { status: r.status, ...(r.error ? { error: r.error } : {}) };
253
+ },
254
+ execPath: process.execPath,
255
+ err: (l) => console.error(l),
256
+ };
257
+ }
@@ -0,0 +1,248 @@
1
+ // One login per box, as the home sees it: who refreshes
2
+ // the OpenAI login the home signed in to, whether it still works, and the
3
+ // re-login that replaces it safely.
4
+ //
5
+ // owner the daemon's refresher holds the login while auto-review runs
6
+ // on this machine (its single-instance lock, beside auth.json in
7
+ // the home's own pi agent dir, which the stack mounts:
8
+ // src/onboard/steps/loginshare.ts). Otherwise the home's pi refreshes it
9
+ // itself, by pi's own rule: a token inside pi's 5-minute window,
10
+ // under pi's own lock. While the refresher runs no token gets
11
+ // near that window, so the home's pi never refreshes: it borrows.
12
+ // expired the refresher's record says its refresh fails and the token is
13
+ // past its expiry (or the grant was refused); or the chat itself
14
+ // was refused for the login (extensions/home.ts reads pi's error);
15
+ // or auto-review is paused on it. The login line then says "login
16
+ // expired", with one key that signs in again.
17
+ // re-login pi's own device login (src/home/devicelogin.ts), with the
18
+ // entrypoint's handover around it (src/loginrefresh/handover.ts):
19
+ // a running refresher is paused first, and keeps its lock, so its
20
+ // refresh can never land on top of the new login; the pause is
21
+ // lifted after, and the refresher re-reads the new login at once,
22
+ // so auto-review resumes on it.
23
+ //
24
+ // Nothing here reads or prints a token: the refresher's status file carries
25
+ // expiry times and redacted errors only.
26
+ import { readFileSync } from "node:fs";
27
+ import { join } from "node:path";
28
+ import { PI_AUTH_ERROR_RE } from "@quickchatai/pi-quickreview/src/authwatch.js";
29
+ import { holdRefresher, refresherRuns, type HoldOptions } from "../loginrefresh/handover.js";
30
+ import { STATUS_FILE } from "../loginrefresh/refresher.js";
31
+ import { providerLabel, type HomeModel } from "./model.js";
32
+ import {
33
+ known,
34
+ unknown,
35
+ type Known,
36
+ type LoginHealth,
37
+ type LoginHold,
38
+ type LoginOwnerPort,
39
+ } from "./ports.js";
40
+ import {
41
+ DEVICE_LOGIN_PROVIDERS,
42
+ loginFailureText,
43
+ type DeviceCode,
44
+ type DeviceLoginPort,
45
+ type LoginFailureKind,
46
+ } from "./setup.js";
47
+
48
+ /** What the login line says of a login that no longer works. */
49
+ export const LOGIN_EXPIRED = "login expired";
50
+ /** The key that signs in again, while typing (the panel's is `l`). */
51
+ export const RELOGIN_KEY = "alt+l";
52
+
53
+ type Obj = Record<string, unknown>;
54
+ const isObj = (v: unknown): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v);
55
+ const message = (err: unknown): string => (err instanceof Error ? err.message : String(err));
56
+
57
+ /** A refresh refused for good: the grant is gone, not a network blip. */
58
+ const REFUSED = /invalid_grant|revoked|refresh_token_(?:reused|expired|invalidated)/i;
59
+
60
+ /** Why the refresher's record (its loginrefresh.status.json) says the login
61
+ * no longer works, or null. Pure. */
62
+ export function expiredWhy(record: unknown, now: number): string | null {
63
+ if (!isObj(record)) return null;
64
+ const err = record["last_error"];
65
+ if (typeof err !== "string" || err === "") return null;
66
+ if (REFUSED.test(err)) return LOGIN_EXPIRED;
67
+ const expires = typeof record["expires_at"] === "string" ? Date.parse(record["expires_at"]) : NaN;
68
+ return Number.isFinite(expires) && expires <= now ? LOGIN_EXPIRED : null;
69
+ }
70
+
71
+ /** An error pi showed in the chat that is the login's (pi-ai's refresh
72
+ * failure, or the provider refusing the token). */
73
+ export function isLoginError(text: string): boolean {
74
+ return PI_AUTH_ERROR_RE.test(text) || /OAuth refresh failed/i.test(text);
75
+ }
76
+
77
+ export interface HostLoginOwnerOptions extends Pick<HoldOptions, "flockBin" | "waitMs"> {
78
+ /** The home's pi agent dir: its auth.json, and the refresher's files beside it. */
79
+ loginDir: string;
80
+ now: () => number;
81
+ /** Where flock cannot tell: does auto-review's daemon run here? */
82
+ daemonRuns?: () => Promise<boolean | null>;
83
+ read?: (p: string) => string;
84
+ }
85
+
86
+ /** The login in the home's own pi agent dir, beside the refresher that may
87
+ * hold it. */
88
+ export function hostLoginOwner(o: HostLoginOwnerOptions): LoginOwnerPort {
89
+ const read = o.read ?? ((p: string) => readFileSync(p, "utf8"));
90
+ const probe = {
91
+ ...(o.flockBin ? { flockBin: o.flockBin } : {}),
92
+ ...(o.daemonRuns ? { refresherRuns: o.daemonRuns } : {}),
93
+ };
94
+ return {
95
+ health: async (): Promise<Known<LoginHealth>> => {
96
+ const runs = await refresherRuns(o.loginDir, probe);
97
+ if (runs === null) return unknown("can't tell whether auto-review's login refresher runs");
98
+ if (!runs) return known({ refresher: "home" });
99
+ let record: unknown;
100
+ try {
101
+ record = JSON.parse(read(join(o.loginDir, STATUS_FILE)));
102
+ } catch {
103
+ // it has not written one yet: nothing says the login fails
104
+ return known({ refresher: "auto-review" });
105
+ }
106
+ const why = expiredWhy(record, o.now());
107
+ const checked =
108
+ isObj(record) && typeof record["checked_at"] === "string"
109
+ ? Date.parse(record["checked_at"])
110
+ : NaN;
111
+ return known({
112
+ refresher: "auto-review",
113
+ ...(why ? { expired: why } : {}),
114
+ ...(Number.isFinite(checked) ? { checkedAt: checked } : {}),
115
+ });
116
+ },
117
+ hold: (): Promise<LoginHold> =>
118
+ holdRefresher(o.loginDir, { ...probe, ...(o.waitMs ? { waitMs: o.waitMs } : {}) }),
119
+ };
120
+ }
121
+
122
+ // ---- the re-login -----------------------------------------------------------
123
+
124
+ export interface ReloginOptions {
125
+ model: HomeModel;
126
+ /** The box's login: the refresher to pause. Absent: nothing is paused. */
127
+ owner: LoginOwnerPort | undefined;
128
+ /** pi's device login, once pi's session exists. */
129
+ deviceLogin: () => DeviceLoginPort | undefined;
130
+ onChange?: () => void;
131
+ }
132
+
133
+ interface Signing {
134
+ ac: AbortController;
135
+ code?: DeviceCode;
136
+ onCode?: (code: DeviceCode) => void;
137
+ }
138
+
139
+ /** "login expired" → one key, or `home_relogin`: sign in again. */
140
+ export class Relogin {
141
+ private signing: Signing | null = null;
142
+ private closed = false;
143
+
144
+ constructor(private readonly o: ReloginOptions) {}
145
+
146
+ /** A re-login in flight: its code once pi has one. */
147
+ running(): { code?: DeviceCode } | null {
148
+ return this.signing === null ? null : this.signing.code ? { code: this.signing.code } : {};
149
+ }
150
+
151
+ private changed(): void {
152
+ this.o.onChange?.();
153
+ }
154
+
155
+ /** Sign in again. The answer comes once the code is shown (or the login
156
+ * ended): the sign-in goes on in the background. */
157
+ async start(): Promise<string> {
158
+ const m = this.o.model;
159
+ const s = this.signing;
160
+ if (s !== null)
161
+ return s.code
162
+ ? `Still waiting: open ${s.code.verificationUri} and enter ${s.code.userCode}.`
163
+ : "The sign-in is starting: the code shows in a moment.";
164
+ if (this.closed) return "the home has closed";
165
+ const provider = m.chatProvider();
166
+ const label = providerLabel(provider);
167
+ const port = DEVICE_LOGIN_PROVIDERS.has(provider) ? this.o.deviceLogin() : undefined;
168
+ if (port === undefined) {
169
+ const why = `sign in to ${label} again with pi's own /login (switch auto-review off first, so its refresher does not write over the new login)`;
170
+ m.say(`${why[0]!.toUpperCase()}${why.slice(1)}.`, "warn");
171
+ return why;
172
+ }
173
+ const signing: Signing = { ac: new AbortController() };
174
+ this.signing = signing;
175
+ this.changed();
176
+ // the refresher first, when one runs: it keeps its lock and stops writing
177
+ let hold: LoginHold;
178
+ try {
179
+ hold = this.o.owner
180
+ ? await this.o.owner.hold()
181
+ : { ok: true, held: false, release: () => {} };
182
+ } catch (err) {
183
+ hold = { ok: false, why: message(err) };
184
+ }
185
+ if (!hold.ok) {
186
+ this.signing = null;
187
+ m.say(`Couldn't sign in again: ${hold.why}. Nothing changed.`, "warn");
188
+ this.changed();
189
+ return `the sign-in did not start: ${hold.why}`;
190
+ }
191
+ const release = hold.release;
192
+ const held = hold.held;
193
+ if (signing.ac.signal.aborted) {
194
+ release();
195
+ this.signing = null;
196
+ return "the home has closed";
197
+ }
198
+ m.say(`Signing in to ${label} again with pi's device code.`);
199
+ let answer!: (text: string) => void;
200
+ const answered = new Promise<string>((r) => (answer = r));
201
+ signing.onCode = (c) => {
202
+ m.say(`Open ${c.verificationUri} and enter the code ${c.userCode}.`);
203
+ answer(
204
+ `Open ${c.verificationUri} and enter the code ${c.userCode}. The home finishes the sign-in once you approve it.`,
205
+ );
206
+ };
207
+ void (async () => {
208
+ let outcome: { ok: true } | { ok: false; kind: LoginFailureKind; why: string };
209
+ try {
210
+ outcome = await port.login({
211
+ provider,
212
+ signal: signing.ac.signal,
213
+ onCode: (code) => {
214
+ signing.code = code;
215
+ signing.onCode?.(code);
216
+ this.changed();
217
+ },
218
+ });
219
+ } catch (err) {
220
+ outcome = { ok: false, kind: "failed", why: message(err) };
221
+ } finally {
222
+ // the pause is lifted whatever happened: the refresher re-reads the file
223
+ release();
224
+ this.signing = null;
225
+ }
226
+ if (outcome.ok) {
227
+ m.signedInAgain();
228
+ await m.refresh();
229
+ m.say(
230
+ `Signed in to ${label} again.${held ? " Auto-review's refresher has the new login, and auto-review resumes." : ""}`,
231
+ "ok",
232
+ );
233
+ answer(`signed in to ${label} again`);
234
+ } else {
235
+ if (outcome.kind !== "cancelled") m.say(loginFailureText(outcome, RELOGIN_KEY), "warn");
236
+ answer(`the sign-in did not finish: ${outcome.why}`);
237
+ }
238
+ this.changed();
239
+ })();
240
+ return answered;
241
+ }
242
+
243
+ /** The session ends: a re-login in flight stops, and its pause is lifted. */
244
+ close(): void {
245
+ this.closed = true;
246
+ this.signing?.ac.abort();
247
+ }
248
+ }
@@ -0,0 +1,176 @@
1
+ // Memory in the home: its own switch, off by default, independent of
2
+ // auto-review. On this machine the memory engine is the compose stack's
3
+ // `memory` service (OpenViking and its local embedder): on is that container
4
+ // running, off is it stopped, with what it learned kept in its volume.
5
+ //
6
+ // Switching it on sets memory up the wizard's way when it never was: the
7
+ // memory step places the root key, the engine is started (it reads that key
8
+ // at boot), the memory step runs again until the engine answers and mints the
9
+ // user keys (memoryPhaseB), then memory_user_keys. On a machine whose
10
+ // stack was never brought up it brings the stack up through switch-on's own
11
+ // steps (never the go-live, and never a confirm) and leaves the daemon as it
12
+ // found it: memory on never switches auto-review on. Switching auto-review
13
+ // off never stops memory (src/home/autoreview.ts stops the daemon alone).
14
+ import type {
15
+ ChecklistAdapters,
16
+ SetupChecklist,
17
+ } from "@quickchatai/pi-quickreview/src/setupchecklist.js";
18
+ import { OnboardingEngine, type OnboardAdapters, type ProcessRunner } from "../onboard/engine.js";
19
+ import { BROUGHT_UP_CHOICE_KEY } from "../onboard/steps/bringup.js";
20
+ import {
21
+ DAEMON_SERVICE,
22
+ MEMORY_SERVICE,
23
+ composeArgs,
24
+ readEnvText,
25
+ } from "../onboard/steps/compose.js";
26
+ import { readEnvKey } from "../onboard/steps/ingress.js";
27
+ import { MEMORY_CHOICE_KEY, MEMORY_STEP_ID, waitsOnServer } from "../onboard/steps/memory.js";
28
+ import { MEMORY_KEYS_STEP_ID } from "../onboard/steps/memorykeys.js";
29
+ import { serviceState } from "./autoreview.js";
30
+ import {
31
+ known,
32
+ type Known,
33
+ type MemoryPort,
34
+ type MemoryState,
35
+ type SwitchResult,
36
+ } from "./ports.js";
37
+ import {
38
+ MACHINE_CHECKS_STEP_ID,
39
+ memoryPhaseB,
40
+ runSteps,
41
+ switchOnInput,
42
+ switchOnRegistry,
43
+ type StepsRun,
44
+ type SwitchOnStepOptions,
45
+ } from "./switchon.js";
46
+
47
+ /** compose's memory cap when the project `.env` names none
48
+ * (docker-compose.yml `mem_limit: ${QCR_MEMORY_LIMIT:-4g}`). */
49
+ export const DEFAULT_MEMORY_LIMIT = "4g";
50
+
51
+ /** "4g" → "up to 4 GB of RAM"; a value compose would not read is said as is. */
52
+ export function ramCostOf(limit: string): string {
53
+ const m = /^\s*(\d+(?:\.\d+)?)\s*([kmgt])?b?\s*$/i.exec(limit);
54
+ if (!m) return `up to ${limit.trim()} of RAM`;
55
+ const unit = { k: "KB", m: "MB", g: "GB", t: "TB" }[(m[2] ?? "").toLowerCase()] ?? "bytes";
56
+ return `up to ${m[1]} ${unit} of RAM`;
57
+ }
58
+
59
+ /** The memory engine's state from its container's. */
60
+ export function memoryStateOf(c: Known<"running" | "restarting" | "stopped">): Known<MemoryState> {
61
+ if (c.kind === "unknown") return c;
62
+ return known(c.value === "running" ? "on" : c.value === "restarting" ? "starting" : "off");
63
+ }
64
+
65
+ export interface ComposeMemoryOptions extends SwitchOnStepOptions {
66
+ root: string;
67
+ runner: ProcessRunner;
68
+ stepAdapters: (log: (line: string) => void) => OnboardAdapters;
69
+ /** The configured repository, for a first bring-up's own-account step. */
70
+ repo: () => Promise<string | null>;
71
+ /** Between phase B's tries while the engine starts (tests skip the wait). */
72
+ sleep?: (ms: number) => Promise<void>;
73
+ }
74
+
75
+ const firstLine = (s: string): string => s.trim().split("\n")[0]?.trim() || "no output";
76
+
77
+ /** The memory step's answer when the person turns memory on. */
78
+ const MEMORY_YES = switchOnInput(MEMORY_STEP_ID, {
79
+ answers: { memory: true, confirmed: false },
80
+ repo: null,
81
+ });
82
+
83
+ /**
84
+ * Memory on, on a stack brought up before. Set up when it never was (or was
85
+ * declined): phase A places the root key, and when the engine does not run
86
+ * yet that is all it can do, so the engine is started (it reads the key at
87
+ * boot) and phase B runs once it answers (memoryPhaseB). Otherwise the engine
88
+ * is started as it is. Then its user keys, when that step is not done.
89
+ */
90
+ export async function memoryOnStack<A extends ChecklistAdapters>(
91
+ e: SetupChecklist<A>,
92
+ o: Pick<StepsRun<A>, "progress" | "sleep"> & { startMemory: () => Promise<SwitchResult> },
93
+ ): Promise<SwitchResult> {
94
+ const configured = e.list().find((s) => s.id === MEMORY_STEP_ID);
95
+ let waits = false;
96
+ if (configured?.status !== "done" || e.choices()[MEMORY_CHOICE_KEY] === "declined") {
97
+ o.progress?.("Setting memory up…");
98
+ const r = await e.runStep(MEMORY_STEP_ID, MEMORY_YES);
99
+ if (!r.ok && !waitsOnServer(r.checks)) return { ok: false, detail: r.failures.join("; ") };
100
+ waits = !r.ok;
101
+ }
102
+ if (waits) {
103
+ const b = await memoryPhaseB({ ...o, checklist: e }, MEMORY_YES);
104
+ if (!b.ok) return { ok: false, detail: b.detail };
105
+ } else {
106
+ const up = await o.startMemory();
107
+ if (!up.ok) return up;
108
+ }
109
+ const keys = e.list().find((s) => s.id === MEMORY_KEYS_STEP_ID);
110
+ if (keys && keys.status !== "done" && keys.status !== "disabled") {
111
+ const r = await e.runStep(MEMORY_KEYS_STEP_ID);
112
+ if (!r.ok) return { ok: false, detail: `the engine runs, but ${r.failures.join("; ")}` };
113
+ }
114
+ return { ok: true, detail: "Memory is on." };
115
+ }
116
+
117
+ export function composeMemory(o: ComposeMemoryOptions): MemoryPort {
118
+ const compose = async (args: string[], what: string): Promise<SwitchResult> => {
119
+ const r = await o.runner.run("docker", composeArgs(o.root, args));
120
+ return r.code === 0
121
+ ? { ok: true, detail: what }
122
+ : {
123
+ ok: false,
124
+ detail: `docker compose ${args.join(" ")} exited ${r.code}: ${firstLine(r.stderr || r.stdout)}`,
125
+ };
126
+ };
127
+ const engine = (log: (line: string) => void): OnboardingEngine =>
128
+ new OnboardingEngine(switchOnRegistry(o), o.stepAdapters(log));
129
+ const startMemory = (): Promise<SwitchResult> =>
130
+ compose(["up", "-d", MEMORY_SERVICE], "the memory engine runs");
131
+
132
+ return {
133
+ status: async () => memoryStateOf(await serviceState(o.runner, o.root, MEMORY_SERVICE)),
134
+ control: {
135
+ ramCost: () =>
136
+ ramCostOf(
137
+ (readEnvKey(readEnvText(o.root), "QCR_MEMORY_LIMIT") ?? "").trim() ||
138
+ DEFAULT_MEMORY_LIMIT,
139
+ ),
140
+ switchOn: async (progress) => {
141
+ const e = engine((l) => progress?.(l));
142
+ if (typeof e.choices()[BROUGHT_UP_CHOICE_KEY] !== "string") {
143
+ // No stack yet: switch-on's steps bring it up with memory, and the
144
+ // daemon is put back as it was (off), so auto-review stays off.
145
+ const daemonWas = await serviceState(o.runner, o.root, DAEMON_SERVICE);
146
+ const checks = await e.runStep(MACHINE_CHECKS_STEP_ID);
147
+ if (!checks.ok) return { ok: false, detail: checks.failures.join("; ") };
148
+ const steps: string[] = [];
149
+ const walked = await runSteps(
150
+ {
151
+ checklist: e,
152
+ memory: true,
153
+ repo: await o.repo(),
154
+ startMemory,
155
+ ...(o.sleep ? { sleep: o.sleep } : {}),
156
+ ...(progress ? { progress } : {}),
157
+ },
158
+ steps,
159
+ );
160
+ if (!walked.ok) return { ok: false, detail: walked.detail };
161
+ if (daemonWas.kind === "known" && daemonWas.value !== "running") {
162
+ const stopped = await compose(["stop", DAEMON_SERVICE], "");
163
+ if (!stopped.ok) return { ok: false, detail: `memory is on, but ${stopped.detail}` };
164
+ }
165
+ return { ok: true, detail: "Memory is on." };
166
+ }
167
+ return memoryOnStack(e, {
168
+ startMemory,
169
+ ...(o.sleep ? { sleep: o.sleep } : {}),
170
+ ...(progress ? { progress } : {}),
171
+ });
172
+ },
173
+ switchOff: () => compose(["stop", MEMORY_SERVICE], "Memory is off. What it learned is kept."),
174
+ },
175
+ };
176
+ }