@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,138 @@
1
+ // The login handover, in Node: what docker/entrypoint.sh's login mode does
2
+ // around pi's /login (login_handover_begin / login_handover_end), for a login
3
+ // made outside the container, by the home's re-login (src/home/loginowner.ts).
4
+ //
5
+ // On a single box the daemon's refresher owns the host pi home's auth.json
6
+ // while auto-review runs (one login per box). A login that rewrites
7
+ // that file beside it would race its refresh: whichever wrote last wins, and a
8
+ // refresh landing after the login would put the old grant back. So a login
9
+ // first asks the refresher to pause: it writes `auth.json.pause` and waits for
10
+ // `"paused": true` in the refresher's status file. A paused refresher neither
11
+ // refreshes nor writes, and KEEPS its lock, so no second refresher can start
12
+ // during the login. Releasing removes the pause file; the refresher re-reads
13
+ // the new login at once and resumes. A pause file left behind (the home killed
14
+ // mid-login) is lifted by the refresher after 15 minutes; while the hold
15
+ // lasts, the file's time is renewed so a slow device login is never cut off.
16
+ //
17
+ // Where no refresher holds the lock (auto-review off) nothing is paused: the
18
+ // home's own pi owns the login then, and its /login runs under pi's own lock.
19
+ // Nothing here reads or writes auth.json.
20
+ import { readFileSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { loginLockHeld } from "./lock.js";
23
+ import { LOCK_FILE, PAUSE_FILE, STATUS_FILE } from "./refresher.js";
24
+
25
+ /** How long a login waits for the refresher to pause (the entrypoint's 60 s). */
26
+ export const PAUSE_WAIT_MS = 60_000;
27
+ /** How often the status file is read while waiting (the refresher looks at the
28
+ * pause file every 2 s). */
29
+ export const PAUSE_POLL_MS = 500;
30
+ /** How often a held pause file's time is renewed: well inside the
31
+ * refresher's 15 minutes. */
32
+ export const PAUSE_RENEW_MS = 5 * 60_000;
33
+
34
+ export type RefresherHold =
35
+ /** `held`: a refresher was running and is paused now; false: none runs. */
36
+ { ok: true; held: boolean; release(): void } | { ok: false; why: string };
37
+
38
+ export interface HoldOptions {
39
+ flockBin?: string;
40
+ /** When the lock cannot be probed (no flock here): does a refresher run?
41
+ * null: unknown. */
42
+ refresherRuns?: () => Promise<boolean | null>;
43
+ waitMs?: number;
44
+ pollMs?: number;
45
+ renewMs?: number;
46
+ sleep?: (ms: number) => Promise<void>;
47
+ now?: () => number;
48
+ }
49
+
50
+ const realSleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
51
+
52
+ /** Does a refresher hold the login in `agentDir` now? null: unknown. */
53
+ export async function refresherRuns(
54
+ agentDir: string,
55
+ o: Pick<HoldOptions, "flockBin" | "refresherRuns"> = {},
56
+ ): Promise<boolean | null> {
57
+ const held = loginLockHeld(join(agentDir, LOCK_FILE), o.flockBin ? { flockBin: o.flockBin } : {});
58
+ if (held !== null) return held;
59
+ return o.refresherRuns ? o.refresherRuns() : null;
60
+ }
61
+
62
+ /** The refresher's status file says it is paused. */
63
+ function pausedNow(statusPath: string): boolean {
64
+ try {
65
+ const v = JSON.parse(readFileSync(statusPath, "utf8")) as { paused?: unknown } | null;
66
+ return v?.paused === true;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ function removeQuietly(path: string): void {
73
+ try {
74
+ unlinkSync(path);
75
+ } catch {
76
+ /* lifted already */
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Pause the refresher of the login in `agentDir` for a login, when one runs.
82
+ * Answers once it is paused (or none runs), with the release; refuses when it
83
+ * does not pause in time (the pause file is then removed again) or when
84
+ * whether one runs cannot be told.
85
+ */
86
+ export async function holdRefresher(agentDir: string, o: HoldOptions = {}): Promise<RefresherHold> {
87
+ const runs = await refresherRuns(agentDir, o);
88
+ if (runs === null)
89
+ return {
90
+ ok: false,
91
+ why: "whether auto-review's login refresher runs can't be told on this machine",
92
+ };
93
+ if (!runs) return { ok: true, held: false, release: () => {} };
94
+ const pause = join(agentDir, PAUSE_FILE);
95
+ const status = join(agentDir, STATUS_FILE);
96
+ try {
97
+ writeFileSync(pause, "", { mode: 0o660 });
98
+ } catch (err) {
99
+ return {
100
+ ok: false,
101
+ why: `can't write ${pause}: ${err instanceof Error ? err.message : String(err)}`,
102
+ };
103
+ }
104
+ const sleep = o.sleep ?? realSleep;
105
+ const now = o.now ?? Date.now;
106
+ const waitMs = o.waitMs ?? PAUSE_WAIT_MS;
107
+ const until = now() + waitMs;
108
+ while (!pausedNow(status)) {
109
+ if (now() >= until) {
110
+ removeQuietly(pause);
111
+ return {
112
+ ok: false,
113
+ why: `auto-review's login refresher did not pause within ${Math.round(waitMs / 1000)} s (${status})`,
114
+ };
115
+ }
116
+ await sleep(o.pollMs ?? PAUSE_POLL_MS);
117
+ }
118
+ const renew = setInterval(() => {
119
+ try {
120
+ const t = new Date();
121
+ utimesSync(pause, t, t);
122
+ } catch {
123
+ /* lifted meanwhile */
124
+ }
125
+ }, o.renewMs ?? PAUSE_RENEW_MS);
126
+ renew.unref?.();
127
+ let released = false;
128
+ return {
129
+ ok: true,
130
+ held: true,
131
+ release: () => {
132
+ if (released) return;
133
+ released = true;
134
+ clearInterval(renew);
135
+ removeQuietly(pause);
136
+ },
137
+ };
138
+ }
@@ -1,5 +1,12 @@
1
1
  // The single-instance lock: an exclusive flock on a SEPARATE lock file
2
- // (`pihome/agent/auth.json.lock`), held for the refresher's whole life.
2
+ // (`pihome/agent/loginrefresh.lock`), held for the refresher's whole life.
3
+ //
4
+ // Why not `auth.json.lock`: that path is pi's. pi guards auth.json with
5
+ // proper-lockfile, whose lock is a DIRECTORY `<file>.lock` (mkdir; a stale one
6
+ // is rmdir'd). A plain file at that path makes every pi CLI fail at startup
7
+ // (`ENOTDIR: not a directory, rmdir '…/auth.json.lock'`), so this lock is named
8
+ // for this process and never `<anything pi locks>.lock`. Earlier builds did use
9
+ // `auth.json.lock`; `removeLegacyLockFile` clears what they left behind.
3
10
  //
4
11
  // Why not a lock on auth.json: every refresh replaces auth.json by rename
5
12
  // (atomic.ts), so a lock on it stays on the old inode, a second refresher could
@@ -12,8 +19,17 @@
12
19
  // lock lasts exactly as long as the pipe's write end does: release() closes it,
13
20
  // and a refresher that is killed in any way closes it too (the kernel closes its
14
21
  // descriptors), so a crash can never leave the lock held by an orphan.
15
- import { spawn } from "node:child_process";
16
- import { chmodSync, chownSync, closeSync, mkdirSync, openSync } from "node:fs";
22
+ import { spawn, spawnSync } from "node:child_process";
23
+ import {
24
+ chmodSync,
25
+ chownSync,
26
+ closeSync,
27
+ existsSync,
28
+ lstatSync,
29
+ mkdirSync,
30
+ openSync,
31
+ unlinkSync,
32
+ } from "node:fs";
17
33
  import { dirname } from "node:path";
18
34
 
19
35
  /** flock's exit status when the lock is held by someone else (-E). */
@@ -102,3 +118,56 @@ export function acquireLoginLock(
102
118
  });
103
119
  });
104
120
  }
121
+
122
+ /** What `removeLegacyLockFile` found at the old lock path. */
123
+ export type LegacyLockOutcome = "absent" | "not-a-file" | "removed" | "unremovable";
124
+
125
+ /**
126
+ * Remove the plain lock FILE an earlier refresher left at `path` (the old
127
+ * `auth.json.lock`, which is pi's proper-lockfile path and breaks every pi CLI
128
+ * while it is a file). Only a regular file is removed: a directory there is
129
+ * pi's own live lock and a symlink is not ours, so both are left alone. Throws
130
+ * `LoginLockHeld` when a refresher of an earlier build still holds a flock on
131
+ * it (the two would otherwise run side by side on different lock files).
132
+ */
133
+ export function removeLegacyLockFile(
134
+ path: string,
135
+ o: { flockBin?: string } = {},
136
+ ): LegacyLockOutcome {
137
+ let isFile: boolean;
138
+ try {
139
+ isFile = lstatSync(path).isFile();
140
+ } catch {
141
+ return "absent";
142
+ }
143
+ if (!isFile) return "not-a-file";
144
+ const probe = spawnSync(
145
+ o.flockBin ?? "flock",
146
+ ["-n", "-E", String(LOCK_HELD_EXIT), path, "true"],
147
+ { stdio: "ignore" },
148
+ );
149
+ if (probe.status === LOCK_HELD_EXIT) throw new LoginLockHeld(path);
150
+ try {
151
+ unlinkSync(path);
152
+ return "removed";
153
+ } catch (err) {
154
+ return (err as NodeJS.ErrnoException).code === "ENOENT" ? "absent" : "unremovable";
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Does a refresher hold the lock at `path` now? true or false; null when it
160
+ * cannot be told (no flock on this machine, or flock failed). A probe, never a
161
+ * holder: no lock file is created where there is none (none: nobody holds it).
162
+ */
163
+ export function loginLockHeld(path: string, o: { flockBin?: string } = {}): boolean | null {
164
+ if (!existsSync(path)) return false;
165
+ const probe = spawnSync(
166
+ o.flockBin ?? "flock",
167
+ ["-n", "-E", String(LOCK_HELD_EXIT), path, "true"],
168
+ { stdio: "ignore" },
169
+ );
170
+ if (probe.error) return null;
171
+ if (probe.status === LOCK_HELD_EXIT) return true;
172
+ return probe.status === 0 ? false : null;
173
+ }
@@ -6,8 +6,18 @@
6
6
  // (main.ts) the daemon spawns and supervises by path, so the daemon imports
7
7
  // neither pi nor src/engine/.
8
8
  //
9
- // Lock an exclusive flock on `auth.json.lock` for its whole life (lock.ts);
10
- // a second refresher is refused, before and after any refresh.
9
+ // Lock an exclusive flock on `loginrefresh.lock` for its whole life (lock.ts);
10
+ // a second refresher is refused, before and after any refresh. Never
11
+ // `auth.json.lock`: that is pi's own proper-lockfile path (a
12
+ // directory), and a file there breaks every pi CLI. A plain file an
13
+ // earlier build left there is removed at start.
14
+ // pi lock not taken: the refresher does not hold pi's proper-lockfile lock
15
+ // around its read-refresh-write (proper-lockfile is pi's dependency,
16
+ // not ours). It needs none while it is healthy: it refreshes far
17
+ // ahead of pi's own window, so a pi on this file has nothing to
18
+ // refresh, and a login goes through the entrypoint's pause below. A
19
+ // raw `pi` + `/login` that bypasses the pause can lose its new
20
+ // login to a refresh that lands in the same moment.
11
21
  // Refresh every 10 minutes, any OAuth entry that is inside pi's refresh
12
22
  // window plus a margin longer than the longest worker cap is
13
23
  // refreshed through `deps.refresh` (pi-ai's own, pirefresh.ts) and
@@ -26,11 +36,14 @@
26
36
  import { existsSync, readFileSync, statSync, unlinkSync } from "node:fs";
27
37
  import { join } from "node:path";
28
38
  import { writeFileAtomic } from "./atomic.js";
29
- import { acquireLoginLock, type LoginLock } from "./lock.js";
39
+ import { acquireLoginLock, removeLegacyLockFile, type LoginLock } from "./lock.js";
30
40
  import type { LoginRefreshFileStatus } from "./types.js";
31
41
 
32
42
  export const AUTH_FILE = "auth.json";
33
- export const LOCK_FILE = "auth.json.lock";
43
+ /** The single-instance lock. Not `${AUTH_FILE}.lock`: that is pi's (lock.ts). */
44
+ export const LOCK_FILE = "loginrefresh.lock";
45
+ /** Where earlier builds kept the lock: pi's proper-lockfile path, cleared at start. */
46
+ export const LEGACY_LOCK_FILE = "auth.json.lock";
34
47
  export const PAUSE_FILE = "auth.json.pause";
35
48
  export const STATUS_FILE = "loginrefresh.status.json";
36
49
 
@@ -134,10 +147,26 @@ export class LoginRefresher {
134
147
  * keep looking.
135
148
  */
136
149
  static async start(deps: RefresherDeps, o: { autoRun?: boolean } = {}): Promise<LoginRefresher> {
150
+ const flock = deps.flockBin ? { flockBin: deps.flockBin } : {};
137
151
  const lock = await acquireLoginLock(join(deps.agentDir, LOCK_FILE), {
138
- ...(deps.flockBin ? { flockBin: deps.flockBin } : {}),
152
+ ...flock,
139
153
  gid: deps.gid,
140
154
  });
155
+ const legacy = join(deps.agentDir, LEGACY_LOCK_FILE);
156
+ let outcome: ReturnType<typeof removeLegacyLockFile>;
157
+ try {
158
+ outcome = removeLegacyLockFile(legacy, flock);
159
+ } catch (err) {
160
+ // a refresher of an earlier build still runs on the old lock
161
+ await lock.release();
162
+ throw err;
163
+ }
164
+ if (outcome === "removed")
165
+ deps.log(`login refresh: removed the old lock file ${legacy} (it is pi's lock path)`);
166
+ if (outcome === "unremovable")
167
+ deps.log(
168
+ `login refresh: could not remove the old lock file ${legacy}; pi fails until it goes`,
169
+ );
141
170
  const r = new LoginRefresher(deps, lock);
142
171
  r.publish();
143
172
  await r.tick();
@@ -21,7 +21,7 @@
21
21
  // second refresher would rotate the refresh token under them.
22
22
  import { existsSync, readFileSync } from "node:fs";
23
23
  import { spawn as nodeSpawn } from "node:child_process";
24
- import { join } from "node:path";
24
+ import { isAbsolute, join } from "node:path";
25
25
  import {
26
26
  foldChildLines,
27
27
  restartDelayMs,
@@ -33,6 +33,28 @@ import {
33
33
  import { LOCK_HELD_EXIT } from "./lock.js";
34
34
  import type { LoginRefreshFileStatus, LoginRefreshStatus } from "./types.js";
35
35
 
36
+ /** Names the login's dir when it is not pi's agent dir. The single box's
37
+ * shared login (one login per box): the home's switch-on mounts the
38
+ * host pi home's agent dir at CONTAINER_LOGIN_DIR (docker-compose.yml,
39
+ * QCR_PI_LOGIN_DIR in the project .env) and the compose file names it here.
40
+ * Only the login moves: the refresher keeps auth.json there and every worker
41
+ * borrows from there, while settings, models.json and the lens agents stay in
42
+ * the container's own agent dir, whose settings the entrypoint provisions and
43
+ * which must never be the person's own. */
44
+ export const LOGIN_DIR_ENV = "QCR_LOGIN_DIR";
45
+ /** Where docker-compose.yml mounts the host's login. */
46
+ export const CONTAINER_LOGIN_DIR = "/opt/qcr/login";
47
+
48
+ /** The login's dir: `QCR_LOGIN_DIR` when it is an absolute path, else pi's
49
+ * agent dir (the login volume, as before). */
50
+ export function loginDirOf(
51
+ env: Readonly<Record<string, string | undefined>>,
52
+ agentDir: string,
53
+ ): string {
54
+ const set = (env[LOGIN_DIR_ENV] ?? "").trim();
55
+ return set !== "" && isAbsolute(set) ? set : agentDir;
56
+ }
57
+
36
58
  /** The restart ladder, ms: a refresher that cannot start (lock held, config) is
37
59
  * retried slowly; one that crashed after a good run is retried at once. */
38
60
  export const LOGIN_RESTART_LADDER_MS = [2_000, 10_000, 60_000];
@@ -44,7 +66,8 @@ export const LOGIN_STATUS_POLL_MS = 5_000;
44
66
  export interface LoginRefreshSupervisorDeps {
45
67
  /** The checkout root: where `dist/src/loginrefresh/main.js` lives. */
46
68
  repoRoot: string;
47
- /** pi's agent dir (the login volume's `agent/`). */
69
+ /** The login's dir: pi's agent dir (the login volume's `agent/`), or the
70
+ * single box's shared login (`loginDirOf`). */
48
71
  agentDir: string;
49
72
  marginMin: number;
50
73
  /** The login group, when the daemon knows it. */
package/src/npmverbs.ts CHANGED
@@ -5,8 +5,13 @@
5
5
  // qcr login [provider] pi's own /login, in the pinned pi (src/picli.ts),
6
6
  // into the user's pi agent dir. pi's UI is the flow:
7
7
  // qcr types /login for the person and steps aside.
8
+ // When auto-review's refresher holds that login, it
9
+ // is paused first and released after, as the home's
10
+ // re-login does (src/loginrefresh/handover.ts).
8
11
  // qcr pi [args] the pinned pi with the qcr plugin preloaded
9
- // (`-e <the plugin package's dir>`), args passed on.
12
+ // (`-e <the plugin package's dir>`) and the app's
13
+ // own /selfimprove beside it (`-e` its shim,
14
+ // `APP_PI_EXTENSIONS`), args passed on.
10
15
  // qcr update the package manager that installed qcr (npm, pnpm or
11
16
  // bun) updates it globally. The release image keeps its
12
17
  // own route (a release), and a checkout its git.
@@ -15,11 +20,12 @@
15
20
  // `npmVerbDeps()` is the real assembly.
16
21
  import { spawnSync, type SpawnSyncOptions } from "node:child_process";
17
22
  import { existsSync, realpathSync } from "node:fs";
18
- import { homedir } from "node:os";
19
23
  import { delimiter, join } from "node:path";
20
24
  import { pluginPackageDir } from "./coreext.js";
21
25
  import { detectInstall, type Installer } from "./installkind.js";
26
+ import type { RefresherHold } from "./loginrefresh/handover.js";
22
27
  import { readLogins } from "./loginhint.js";
28
+ import { piAgentDir } from "./piagentdir.js";
23
29
  import { piCliPath } from "./picli.js";
24
30
 
25
31
  /** The npm package this CLI is published as. */
@@ -44,12 +50,9 @@ export function updateCommand(installer: Installer, pkg: string = CLI_PACKAGE):
44
50
  }
45
51
  }
46
52
 
47
- /** pi's agent dir: `$PI_CODING_AGENT_DIR`, else `~/.pi/agent`, as pi reads it. */
48
- export function piAgentDir(env: Readonly<Record<string, string | undefined>>): string {
49
- const set = (env["PI_CODING_AGENT_DIR"] ?? "").trim();
50
- if (set !== "") return set;
51
- return join((env["HOME"] ?? "").trim() || homedir(), ".pi", "agent");
52
- }
53
+ // pi's agent dir lives in a leaf of its own, so the home reads the same rule
54
+ // without these verbs.
55
+ export { piAgentDir };
53
56
 
54
57
  /** The providers pi holds a credential for in `agentDir`, from its auth.json;
55
58
  * empty when there is none or it cannot be read. Never a token. */
@@ -86,6 +89,9 @@ export interface NpmVerbDeps {
86
89
  err: (line: string) => void;
87
90
  /** `node`: the pinned pi's entry is a script, run by this node. */
88
91
  execPath: string;
92
+ /** Pause auto-review's login refresher of `agentDir` for a login, when one
93
+ * runs (src/loginrefresh/handover.ts `holdRefresher`). */
94
+ holdLogin: (agentDir: string) => Promise<RefresherHold>;
89
95
  }
90
96
 
91
97
  export function npmVerbDeps(pkgRoot: string): NpmVerbDeps {
@@ -103,10 +109,20 @@ export function npmVerbDeps(pkgRoot: string): NpmVerbDeps {
103
109
  out: (l) => console.log(l),
104
110
  err: (l) => console.error(l),
105
111
  execPath: process.execPath,
112
+ holdLogin: async (agentDir) => {
113
+ const { holdRefresher } = await import("./loginrefresh/handover.js");
114
+ return holdRefresher(agentDir);
115
+ },
106
116
  };
107
117
  }
108
118
 
109
- /** `qcr pi [args]`: the pinned pi with the plugin preloaded; pi's exit code. */
119
+ /** The app's own extensions `qcr pi` loads beside the plugin, as paths under
120
+ * the CLI package's root: the commands that are the app's, not the plugin's
121
+ * (ADR 0001). */
122
+ export const APP_PI_EXTENSIONS: readonly string[] = ["extensions/cli/selfimprove.ts"];
123
+
124
+ /** `qcr pi [args]`: the pinned pi with the plugin and the app's own
125
+ * extensions preloaded; pi's exit code. */
110
126
  export function cmdPi(args: readonly string[], deps: NpmVerbDeps): number {
111
127
  let pi: string;
112
128
  let plugin: string;
@@ -117,7 +133,8 @@ export function cmdPi(args: readonly string[], deps: NpmVerbDeps): number {
117
133
  deps.err(`qcr pi: ${err instanceof Error ? err.message : String(err)}`);
118
134
  return 1;
119
135
  }
120
- const r = deps.run(deps.execPath, [pi, "-e", plugin, ...args], {});
136
+ const app = APP_PI_EXTENSIONS.flatMap((e) => ["-e", join(deps.pkgRoot, e)]);
137
+ const r = deps.run(deps.execPath, [pi, "-e", plugin, ...app, ...args], {});
121
138
  if (r.error) {
122
139
  deps.err(`qcr pi: pi could not start: ${r.error.message}`);
123
140
  return 1;
@@ -169,6 +186,27 @@ export async function cmdLogin(args: readonly string[], deps: NpmVerbDeps): Prom
169
186
  return 1;
170
187
  }
171
188
  const agentDir = piAgentDir(deps.env);
189
+ // The refresher first, when one runs: a refresh that read the old grant and
190
+ // lands after the login would write it back over the new one.
191
+ const hold = await deps.holdLogin(agentDir);
192
+ if (!hold.ok) {
193
+ deps.err(`qcr login: ${hold.why}; nothing was changed.`);
194
+ return 1;
195
+ }
196
+ if (hold.held) deps.out("qcr login: auto-review's login refresher is paused while you log in.");
197
+ try {
198
+ return await runLogin(provider, pi, agentDir, deps);
199
+ } finally {
200
+ hold.release();
201
+ }
202
+ }
203
+
204
+ async function runLogin(
205
+ provider: string | undefined,
206
+ pi: string,
207
+ agentDir: string,
208
+ deps: NpmVerbDeps,
209
+ ): Promise<number> {
172
210
  const before = loggedInProviders(agentDir);
173
211
  let status: number | null;
174
212
  if (onPath("tmux", deps.env["PATH"])) {
@@ -196,7 +234,9 @@ export async function cmdLogin(args: readonly string[], deps: NpmVerbDeps): Prom
196
234
  deps.out(
197
235
  `qcr login: opening pi — type /login${provider ? ` ${provider}` : ""} and pick your provider, then quit pi (the login lands in ${agentDir}).`,
198
236
  );
199
- const r = deps.run(deps.execPath, [pi], {});
237
+ // --no-approve as on the tmux route (src/pilogin.ts): a login needs
238
+ // nothing from this folder, so pi neither asks to trust it nor trusts it.
239
+ const r = deps.run(deps.execPath, [pi, "--no-approve"], {});
200
240
  if (r.error) {
201
241
  deps.err(`qcr login: pi could not start: ${r.error.message}`);
202
242
  return 1;
@@ -16,8 +16,7 @@
16
16
  // record each key, and the test proves each named step really does — so a
17
17
  // key that only ever gets read cannot survive review.
18
18
  //
19
- // A key private to one step — the go-live decision, the bring-up timestamp, the
20
- // vm-deploy record, the model prefix, the memory key-file path — stays a local
19
+ // A key private to one step — the go-live decision, the vm-deploy record, the model prefix, the memory key-file path — stays a local
21
20
  // constant in that step. This file is about the handoffs.
22
21
  //
23
22
  // Nothing here imports a step (the steps import this), so there is no cycle and
@@ -65,6 +64,10 @@ export type MemoryChoice = (typeof MEMORY_CHOICE_IDS)[number];
65
64
  export const CHOICE_KEYS = {
66
65
  /** The operator's uid, captured by preflight: the containers run as it. */
67
66
  uid: "qcr_uid",
67
+ /** When the bring-up step last brought the stack up (an ISO timestamp).
68
+ * Written by bringup; read by the login-share step, which recreates a
69
+ * stack brought up before so the daemon gets its login mount. */
70
+ broughtUpAt: "stack_brought_up_at",
68
71
 
69
72
  /** `Topology`. Written by the topology step; read by everything that has to
70
73
  * know where the stack runs (transport selection, the VM-only steps). */
@@ -164,6 +167,7 @@ export type ChoiceKey = (typeof CHOICE_KEYS)[ChoiceName];
164
167
  */
165
168
  export const CHOICE_WRITERS: Record<ChoiceName, readonly string[]> = {
166
169
  uid: ["preflight"],
170
+ broughtUpAt: ["bringup"],
167
171
  githubAuth: ["github_account"],
168
172
  accountLogin: ["github_account"],
169
173
  accountRepo: ["github_account"],
@@ -210,7 +214,6 @@ export const CHOICE_WRITERS: Record<ChoiceName, readonly string[]> = {
210
214
  export const PRIVATE_CHOICE_KEYS: Readonly<Record<string, string>> = {
211
215
  llm_model_prefix: "llm_auth",
212
216
  openviking_keys_file: "memory",
213
- stack_brought_up_at: "bringup",
214
217
  go_live_decision: "go_live",
215
218
  };
216
219