@quickchatai/quickreview 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (1443) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +385 -2
  3. package/THIRD-PARTY-NOTICES.md +134 -0
  4. package/agents/pass-abstraction.md +64 -0
  5. package/agents/pass-ask-human.md +134 -0
  6. package/agents/pass-backcompat.md +67 -0
  7. package/agents/pass-baseline.md +61 -0
  8. package/agents/pass-behaviour-delta.md +64 -0
  9. package/agents/pass-bounds.md +36 -0
  10. package/agents/pass-claims.md +31 -0
  11. package/agents/pass-concurrency.md +168 -0
  12. package/agents/pass-consistency.md +57 -0
  13. package/agents/pass-contracts.md +30 -0
  14. package/agents/pass-data-safety.md +54 -0
  15. package/agents/pass-deferred.md +35 -0
  16. package/agents/pass-delivery.md +37 -0
  17. package/agents/pass-duplication.md +60 -0
  18. package/agents/pass-intent.md +63 -0
  19. package/agents/pass-lint-conformance.md +46 -0
  20. package/agents/pass-maintainability.md +67 -0
  21. package/agents/pass-placement.md +68 -0
  22. package/agents/pass-prod-risk.md +24 -0
  23. package/agents/pass-rollout.md +32 -0
  24. package/agents/pass-roundtrip.md +46 -0
  25. package/agents/pass-security.md +31 -0
  26. package/agents/pass-simplicity.md +68 -0
  27. package/agents/pass-statelessness.md +27 -0
  28. package/agents/pass-style.md +37 -0
  29. package/agents/pass-sweep.md +57 -0
  30. package/agents/pass-tests.md +58 -0
  31. package/agents/pass-ui.md +23 -0
  32. package/agents/pass-validate.md +50 -0
  33. package/agents/pass-widened.md +46 -0
  34. package/agents/scan/pass-scan.md +86 -0
  35. package/bin/qcr.js +11 -0
  36. package/config.example.toml +1067 -0
  37. package/deploy/ansible/README.md +356 -0
  38. package/deploy/ansible/group_vars/all.yml +212 -0
  39. package/deploy/ansible/inventory.example.ini +25 -0
  40. package/deploy/ansible/playbook.yml +360 -0
  41. package/deploy/collect-secrets.sh +289 -0
  42. package/deploy/collect-slack-secrets.mjs +1190 -0
  43. package/deploy/github-app-manifest.json +31 -0
  44. package/deploy/manifest.schema.json +75 -0
  45. package/deploy/qcr-update.sh +803 -0
  46. package/deploy/reviewer/AGENTS.override.md +12 -0
  47. package/dist/extensions/cli/bash-guard-ambient.js +42 -0
  48. package/dist/extensions/cli/bash-guard-ambient.js.map +1 -0
  49. package/dist/extensions/cli/bash-guard.js +5 -0
  50. package/dist/extensions/cli/bash-guard.js.map +1 -0
  51. package/dist/extensions/cli/companion-guard.js +5 -0
  52. package/dist/extensions/cli/companion-guard.js.map +1 -0
  53. package/dist/extensions/cli/debug-round.js +8 -0
  54. package/dist/extensions/cli/debug-round.js.map +1 -0
  55. package/dist/extensions/cli/inject-bridge.js +5 -0
  56. package/dist/extensions/cli/inject-bridge.js.map +1 -0
  57. package/dist/extensions/cli/ledger-pin.js +5 -0
  58. package/dist/extensions/cli/ledger-pin.js.map +1 -0
  59. package/dist/extensions/cli/lens.js +13 -0
  60. package/dist/extensions/cli/lens.js.map +1 -0
  61. package/dist/extensions/cli/muted-theme.js +5 -0
  62. package/dist/extensions/cli/muted-theme.js.map +1 -0
  63. package/dist/extensions/cli/onboarding.js +5 -0
  64. package/dist/extensions/cli/onboarding.js.map +1 -0
  65. package/dist/extensions/cli/setup.js +5 -0
  66. package/dist/extensions/cli/setup.js.map +1 -0
  67. package/dist/extensions/cli/tui-view.js +5 -0
  68. package/dist/extensions/cli/tui-view.js.map +1 -0
  69. package/dist/extensions/companion-guard.js +361 -0
  70. package/dist/extensions/companion-guard.js.map +1 -0
  71. package/dist/extensions/debug-round.js +204 -0
  72. package/dist/extensions/debug-round.js.map +1 -0
  73. package/dist/extensions/inject-bridge.js +603 -0
  74. package/dist/extensions/inject-bridge.js.map +1 -0
  75. package/dist/extensions/muted-theme.js +12 -0
  76. package/dist/extensions/muted-theme.js.map +1 -0
  77. package/dist/extensions/onboarding.js +175 -0
  78. package/dist/extensions/onboarding.js.map +1 -0
  79. package/dist/extensions/setup-replay.js +269 -0
  80. package/dist/extensions/setup-replay.js.map +1 -0
  81. package/dist/extensions/setup-scan.js +998 -0
  82. package/dist/extensions/setup-scan.js.map +1 -0
  83. package/dist/extensions/setup-triage.js +175 -0
  84. package/dist/extensions/setup-triage.js.map +1 -0
  85. package/dist/extensions/setup.js +1160 -0
  86. package/dist/extensions/setup.js.map +1 -0
  87. package/dist/extensions/tui-view.js +389 -0
  88. package/dist/extensions/tui-view.js.map +1 -0
  89. package/dist/extensions/vendor/openviking-pi/client.js +376 -0
  90. package/dist/extensions/vendor/openviking-pi/client.js.map +1 -0
  91. package/dist/extensions/vendor/openviking-pi/config.js +189 -0
  92. package/dist/extensions/vendor/openviking-pi/config.js.map +1 -0
  93. package/dist/extensions/vendor/openviking-pi/index.js +421 -0
  94. package/dist/extensions/vendor/openviking-pi/index.js.map +1 -0
  95. package/dist/extensions/vendor/openviking-pi/lib/capture-adapter.mjs +86 -0
  96. package/dist/extensions/vendor/openviking-pi/lib/capture-adapter.mjs.map +1 -0
  97. package/dist/extensions/vendor/openviking-pi/lib/takeover-core.mjs +408 -0
  98. package/dist/extensions/vendor/openviking-pi/lib/takeover-core.mjs.map +1 -0
  99. package/dist/extensions/vendor/openviking-pi/lib/uri-guard-adapter.mjs +41 -0
  100. package/dist/extensions/vendor/openviking-pi/lib/uri-guard-adapter.mjs.map +1 -0
  101. package/dist/extensions/vendor/openviking-pi/qcr-hint.js +290 -0
  102. package/dist/extensions/vendor/openviking-pi/qcr-hint.js.map +1 -0
  103. package/dist/extensions/vendor/openviking-pi/qcr-honest.js +148 -0
  104. package/dist/extensions/vendor/openviking-pi/qcr-honest.js.map +1 -0
  105. package/dist/extensions/vendor/openviking-pi/qcr-hooks.js +68 -0
  106. package/dist/extensions/vendor/openviking-pi/qcr-hooks.js.map +1 -0
  107. package/dist/extensions/vendor/openviking-pi/qcr-leaf.js +77 -0
  108. package/dist/extensions/vendor/openviking-pi/qcr-leaf.js.map +1 -0
  109. package/dist/extensions/vendor/openviking-pi/qcr-lesson.js +216 -0
  110. package/dist/extensions/vendor/openviking-pi/qcr-lesson.js.map +1 -0
  111. package/dist/extensions/vendor/openviking-pi/qcr-namespaces.js +154 -0
  112. package/dist/extensions/vendor/openviking-pi/qcr-namespaces.js.map +1 -0
  113. package/dist/extensions/vendor/openviking-pi/qcr-recall.js +254 -0
  114. package/dist/extensions/vendor/openviking-pi/qcr-recall.js.map +1 -0
  115. package/dist/extensions/vendor/openviking-pi/qcr-shim.js +94 -0
  116. package/dist/extensions/vendor/openviking-pi/qcr-shim.js.map +1 -0
  117. package/dist/extensions/vendor/openviking-pi/qcr-write.js +333 -0
  118. package/dist/extensions/vendor/openviking-pi/qcr-write.js.map +1 -0
  119. package/dist/extensions/vendor/openviking-pi/recall.js +108 -0
  120. package/dist/extensions/vendor/openviking-pi/recall.js.map +1 -0
  121. package/dist/extensions/vendor/openviking-pi/shared/capture-utils.mjs +529 -0
  122. package/dist/extensions/vendor/openviking-pi/shared/capture-utils.mjs.map +1 -0
  123. package/dist/extensions/vendor/openviking-pi/shared/credentials.mjs +205 -0
  124. package/dist/extensions/vendor/openviking-pi/shared/credentials.mjs.map +1 -0
  125. package/dist/extensions/vendor/openviking-pi/shared/pending-queue.mjs +442 -0
  126. package/dist/extensions/vendor/openviking-pi/shared/pending-queue.mjs.map +1 -0
  127. package/dist/extensions/vendor/openviking-pi/shared/profile-inject.mjs +286 -0
  128. package/dist/extensions/vendor/openviking-pi/shared/profile-inject.mjs.map +1 -0
  129. package/dist/extensions/vendor/openviking-pi/shared/recall-compress-core.mjs +189 -0
  130. package/dist/extensions/vendor/openviking-pi/shared/recall-compress-core.mjs.map +1 -0
  131. package/dist/extensions/vendor/openviking-pi/shared/recall-core.mjs +580 -0
  132. package/dist/extensions/vendor/openviking-pi/shared/recall-core.mjs.map +1 -0
  133. package/dist/extensions/vendor/openviking-pi/shared/retryable.mjs +11 -0
  134. package/dist/extensions/vendor/openviking-pi/shared/retryable.mjs.map +1 -0
  135. package/dist/extensions/vendor/openviking-pi/shared/session-model.mjs +63 -0
  136. package/dist/extensions/vendor/openviking-pi/shared/session-model.mjs.map +1 -0
  137. package/dist/extensions/vendor/openviking-pi/shared/uri-guard.mjs +58 -0
  138. package/dist/extensions/vendor/openviking-pi/shared/uri-guard.mjs.map +1 -0
  139. package/dist/extensions/vendor/openviking-pi/shared/workspace-peer.mjs +16 -0
  140. package/dist/extensions/vendor/openviking-pi/shared/workspace-peer.mjs.map +1 -0
  141. package/dist/extensions/vendor/openviking-pi/sync.js +127 -0
  142. package/dist/extensions/vendor/openviking-pi/sync.js.map +1 -0
  143. package/dist/extensions/vendor/openviking-pi/takeover.js +25 -0
  144. package/dist/extensions/vendor/openviking-pi/takeover.js.map +1 -0
  145. package/dist/extensions/vendor/openviking-pi/tools.js +417 -0
  146. package/dist/extensions/vendor/openviking-pi/tools.js.map +1 -0
  147. package/dist/src/anomalies.js +483 -0
  148. package/dist/src/anomalies.js.map +1 -0
  149. package/dist/src/answerworker.js +180 -0
  150. package/dist/src/answerworker.js.map +1 -0
  151. package/dist/src/appstamp.js +68 -0
  152. package/dist/src/appstamp.js.map +1 -0
  153. package/dist/src/archive/admission.js +3 -0
  154. package/dist/src/archive/admission.js.map +1 -0
  155. package/dist/src/archive/archivedb.js +474 -0
  156. package/dist/src/archive/archivedb.js.map +1 -0
  157. package/dist/src/archive/archiver.js +1770 -0
  158. package/dist/src/archive/archiver.js.map +1 -0
  159. package/dist/src/archive/archivewrite.js +198 -0
  160. package/dist/src/archive/archivewrite.js.map +1 -0
  161. package/dist/src/archive/bundle.js +266 -0
  162. package/dist/src/archive/bundle.js.map +1 -0
  163. package/dist/src/archive/dbmove.js +638 -0
  164. package/dist/src/archive/dbmove.js.map +1 -0
  165. package/dist/src/archive/duplicatecounts.js +80 -0
  166. package/dist/src/archive/duplicatecounts.js.map +1 -0
  167. package/dist/src/archive/eligibility.js +113 -0
  168. package/dist/src/archive/eligibility.js.map +1 -0
  169. package/dist/src/archive/extract.js +288 -0
  170. package/dist/src/archive/extract.js.map +1 -0
  171. package/dist/src/archive/fsutil.js +330 -0
  172. package/dist/src/archive/fsutil.js.map +1 -0
  173. package/dist/src/archive/gzfile.js +277 -0
  174. package/dist/src/archive/gzfile.js.map +1 -0
  175. package/dist/src/archive/kinds.js +22 -0
  176. package/dist/src/archive/kinds.js.map +1 -0
  177. package/dist/src/archive/livecheckpoint.js +178 -0
  178. package/dist/src/archive/livecheckpoint.js.map +1 -0
  179. package/dist/src/archive/liveretention.js +145 -0
  180. package/dist/src/archive/liveretention.js.map +1 -0
  181. package/dist/src/archive/manifest.js +284 -0
  182. package/dist/src/archive/manifest.js.map +1 -0
  183. package/dist/src/archive/overseer.js +283 -0
  184. package/dist/src/archive/overseer.js.map +1 -0
  185. package/dist/src/archive/piglobal.js +129 -0
  186. package/dist/src/archive/piglobal.js.map +1 -0
  187. package/dist/src/archive/plan.js +398 -0
  188. package/dist/src/archive/plan.js.map +1 -0
  189. package/dist/src/archive/prarchive.js +831 -0
  190. package/dist/src/archive/prarchive.js.map +1 -0
  191. package/dist/src/archive/prfiles.js +303 -0
  192. package/dist/src/archive/prfiles.js.map +1 -0
  193. package/dist/src/archive/report.js +169 -0
  194. package/dist/src/archive/report.js.map +1 -0
  195. package/dist/src/archive/restore.js +276 -0
  196. package/dist/src/archive/restore.js.map +1 -0
  197. package/dist/src/archive/rows.js +106 -0
  198. package/dist/src/archive/rows.js.map +1 -0
  199. package/dist/src/archive/seal.js +480 -0
  200. package/dist/src/archive/seal.js.map +1 -0
  201. package/dist/src/archive/status.js +528 -0
  202. package/dist/src/archive/status.js.map +1 -0
  203. package/dist/src/archive/storepass.js +409 -0
  204. package/dist/src/archive/storepass.js.map +1 -0
  205. package/dist/src/backfill.js +217 -0
  206. package/dist/src/backfill.js.map +1 -0
  207. package/dist/src/bench/cost.js +87 -0
  208. package/dist/src/bench/exec.js +76 -0
  209. package/dist/src/bench/values.js +14 -0
  210. package/dist/src/botcontrol.js +127 -0
  211. package/dist/src/botcontrol.js.map +1 -0
  212. package/dist/src/botworker.js +805 -0
  213. package/dist/src/botworker.js.map +1 -0
  214. package/dist/src/capture.js +234 -0
  215. package/dist/src/capture.js.map +1 -0
  216. package/dist/src/cases.js +1491 -0
  217. package/dist/src/cases.js.map +1 -0
  218. package/dist/src/channelfs.js +27 -0
  219. package/dist/src/channelfs.js.map +1 -0
  220. package/dist/src/checklist.js +312 -0
  221. package/dist/src/checklist.js.map +1 -0
  222. package/dist/src/ciretry.js +91 -0
  223. package/dist/src/ciretry.js.map +1 -0
  224. package/dist/src/cli.js +1916 -0
  225. package/dist/src/cli.js.map +1 -0
  226. package/dist/src/cliops.js +333 -0
  227. package/dist/src/cliops.js.map +1 -0
  228. package/dist/src/clonedir.js +15 -0
  229. package/dist/src/clonedir.js.map +1 -0
  230. package/dist/src/commandeffects.js +43 -0
  231. package/dist/src/commandeffects.js.map +1 -0
  232. package/dist/src/commands.js +1312 -0
  233. package/dist/src/commands.js.map +1 -0
  234. package/dist/src/companioninbox.js +203 -0
  235. package/dist/src/companioninbox.js.map +1 -0
  236. package/dist/src/compare.js +673 -0
  237. package/dist/src/compare.js.map +1 -0
  238. package/dist/src/config.js +1027 -0
  239. package/dist/src/config.js.map +1 -0
  240. package/dist/src/configcheck.js +111 -0
  241. package/dist/src/configcheck.js.map +1 -0
  242. package/dist/src/coreext.js +43 -0
  243. package/dist/src/coreext.js.map +1 -0
  244. package/dist/src/daemon.js +929 -0
  245. package/dist/src/daemon.js.map +1 -0
  246. package/dist/src/debuground.js +731 -0
  247. package/dist/src/debuground.js.map +1 -0
  248. package/dist/src/debugroundpi.js +219 -0
  249. package/dist/src/debugroundpi.js.map +1 -0
  250. package/dist/src/diagnostics.js +14 -0
  251. package/dist/src/diagnostics.js.map +1 -0
  252. package/dist/src/digest.js +139 -0
  253. package/dist/src/digest.js.map +1 -0
  254. package/dist/src/dispatch.js +1726 -0
  255. package/dist/src/dispatch.js.map +1 -0
  256. package/dist/src/engine/answerguard.js +18 -0
  257. package/dist/src/engine/answerguard.js.map +1 -0
  258. package/dist/src/engine/botsdk.js +262 -0
  259. package/dist/src/engine/botsdk.js.map +1 -0
  260. package/dist/src/engine/caserunner-sdk.js +332 -0
  261. package/dist/src/engine/caserunner-sdk.js.map +1 -0
  262. package/dist/src/engine/debugpi.js +194 -0
  263. package/dist/src/engine/debugpi.js.map +1 -0
  264. package/dist/src/eod.js +473 -0
  265. package/dist/src/eod.js.map +1 -0
  266. package/dist/src/eodpinwatch.js +391 -0
  267. package/dist/src/eodpinwatch.js.map +1 -0
  268. package/dist/src/escalation.js +1009 -0
  269. package/dist/src/escalation.js.map +1 -0
  270. package/dist/src/eventloop.js +779 -0
  271. package/dist/src/eventloop.js.map +1 -0
  272. package/dist/src/execboundary.js +430 -0
  273. package/dist/src/execboundary.js.map +1 -0
  274. package/dist/src/feedback.js +915 -0
  275. package/dist/src/feedback.js.map +1 -0
  276. package/dist/src/findingscheck.js +200 -0
  277. package/dist/src/findingscheck.js.map +1 -0
  278. package/dist/src/fsdenied.js +26 -0
  279. package/dist/src/fsdenied.js.map +1 -0
  280. package/dist/src/ghread.js +181 -0
  281. package/dist/src/ghread.js.map +1 -0
  282. package/dist/src/github.js +1063 -0
  283. package/dist/src/github.js.map +1 -0
  284. package/dist/src/homes.js +103 -0
  285. package/dist/src/homes.js.map +1 -0
  286. package/dist/src/hostroute.js +24 -0
  287. package/dist/src/hostroute.js.map +1 -0
  288. package/dist/src/improvements.js +625 -0
  289. package/dist/src/improvements.js.map +1 -0
  290. package/dist/src/install.js +946 -0
  291. package/dist/src/install.js.map +1 -0
  292. package/dist/src/installkind.js +40 -0
  293. package/dist/src/installkind.js.map +1 -0
  294. package/dist/src/ipc/capability.js +286 -0
  295. package/dist/src/ipc/capability.js.map +1 -0
  296. package/dist/src/ipc/client.js +226 -0
  297. package/dist/src/ipc/client.js.map +1 -0
  298. package/dist/src/ipc/control.js +70 -0
  299. package/dist/src/ipc/control.js.map +1 -0
  300. package/dist/src/ipc/ghreadshim.js +77 -0
  301. package/dist/src/ipc/ghreadshim.js.map +1 -0
  302. package/dist/src/ipc/launcher.js +65 -0
  303. package/dist/src/ipc/launcher.js.map +1 -0
  304. package/dist/src/ipc/permits.js +293 -0
  305. package/dist/src/ipc/permits.js.map +1 -0
  306. package/dist/src/ipc/postshim.js +137 -0
  307. package/dist/src/ipc/postshim.js.map +1 -0
  308. package/dist/src/ipc/protocol.js +239 -0
  309. package/dist/src/ipc/protocol.js.map +1 -0
  310. package/dist/src/ipc/server.js +312 -0
  311. package/dist/src/ipc/server.js.map +1 -0
  312. package/dist/src/ipc/workerspec.js +148 -0
  313. package/dist/src/ipc/workerspec.js.map +1 -0
  314. package/dist/src/learner/learner.js +1272 -0
  315. package/dist/src/learner/learner.js.map +1 -0
  316. package/dist/src/learner/miner.js +948 -0
  317. package/dist/src/learner/miner.js.map +1 -0
  318. package/dist/src/learner/packet.js +216 -0
  319. package/dist/src/learner/packet.js.map +1 -0
  320. package/dist/src/learner/receipt.js +135 -0
  321. package/dist/src/learner/receipt.js.map +1 -0
  322. package/dist/src/learner/status.js +38 -0
  323. package/dist/src/learner/status.js.map +1 -0
  324. package/dist/src/ledgercheck.js +120 -0
  325. package/dist/src/ledgercheck.js.map +1 -0
  326. package/dist/src/lessonledger.js +451 -0
  327. package/dist/src/lessonledger.js.map +1 -0
  328. package/dist/src/lessonselect.js +639 -0
  329. package/dist/src/lessonselect.js.map +1 -0
  330. package/dist/src/lessonshadow.js +540 -0
  331. package/dist/src/lessonshadow.js.map +1 -0
  332. package/dist/src/levers.js +302 -0
  333. package/dist/src/levers.js.map +1 -0
  334. package/dist/src/live/projection.js +450 -0
  335. package/dist/src/live/projection.js.map +1 -0
  336. package/dist/src/live/publish.js +83 -0
  337. package/dist/src/live/publish.js.map +1 -0
  338. package/dist/src/live/read.js +60 -0
  339. package/dist/src/live/read.js.map +1 -0
  340. package/dist/src/livecli.js +288 -0
  341. package/dist/src/livecli.js.map +1 -0
  342. package/dist/src/loginhint.js +42 -0
  343. package/dist/src/loginhint.js.map +1 -0
  344. package/dist/src/loginrefresh/atomic.js +66 -0
  345. package/dist/src/loginrefresh/atomic.js.map +1 -0
  346. package/dist/src/loginrefresh/lock.js +93 -0
  347. package/dist/src/loginrefresh/lock.js.map +1 -0
  348. package/dist/src/loginrefresh/main.js +58 -0
  349. package/dist/src/loginrefresh/main.js.map +1 -0
  350. package/dist/src/loginrefresh/pirefresh.js +49 -0
  351. package/dist/src/loginrefresh/pirefresh.js.map +1 -0
  352. package/dist/src/loginrefresh/refresher.js +286 -0
  353. package/dist/src/loginrefresh/refresher.js.map +1 -0
  354. package/dist/src/loginrefresh/supervisor.js +253 -0
  355. package/dist/src/loginrefresh/supervisor.js.map +1 -0
  356. package/dist/src/loginrefresh/types.js +4 -0
  357. package/dist/src/loginrefresh/types.js.map +1 -0
  358. package/dist/src/memory/context.js +251 -0
  359. package/dist/src/memory/context.js.map +1 -0
  360. package/dist/src/memory/datafence.js +5 -0
  361. package/dist/src/memory/datafence.js.map +1 -0
  362. package/dist/src/memory/delivery.js +131 -0
  363. package/dist/src/memory/delivery.js.map +1 -0
  364. package/dist/src/memory/eventtags.js +81 -0
  365. package/dist/src/memory/eventtags.js.map +1 -0
  366. package/dist/src/memory/health.js +713 -0
  367. package/dist/src/memory/health.js.map +1 -0
  368. package/dist/src/memory/import.js +504 -0
  369. package/dist/src/memory/import.js.map +1 -0
  370. package/dist/src/memory/layout.js +252 -0
  371. package/dist/src/memory/layout.js.map +1 -0
  372. package/dist/src/memory/legacy-file.js +100 -0
  373. package/dist/src/memory/legacy-file.js.map +1 -0
  374. package/dist/src/memory/lessons.js +370 -0
  375. package/dist/src/memory/lessons.js.map +1 -0
  376. package/dist/src/memory/ovsession.js +449 -0
  377. package/dist/src/memory/ovsession.js.map +1 -0
  378. package/dist/src/memory/relayout.js +259 -0
  379. package/dist/src/memory/relayout.js.map +1 -0
  380. package/dist/src/memory/roundactivity.js +180 -0
  381. package/dist/src/memory/roundactivity.js.map +1 -0
  382. package/dist/src/memory/selection.js +690 -0
  383. package/dist/src/memory/selection.js.map +1 -0
  384. package/dist/src/memory/shadowstatus.js +69 -0
  385. package/dist/src/memory/shadowstatus.js.map +1 -0
  386. package/dist/src/memory/store.js +83 -0
  387. package/dist/src/memory/store.js.map +1 -0
  388. package/dist/src/memory/textidentity.js +8 -0
  389. package/dist/src/memory/textidentity.js.map +1 -0
  390. package/dist/src/metrics.js +773 -0
  391. package/dist/src/metrics.js.map +1 -0
  392. package/dist/src/missingconfig.js +8 -0
  393. package/dist/src/missingconfig.js.map +1 -0
  394. package/dist/src/npmverbs.js +226 -0
  395. package/dist/src/npmverbs.js.map +1 -0
  396. package/dist/src/onboard/choices.js +191 -0
  397. package/dist/src/onboard/choices.js.map +1 -0
  398. package/dist/src/onboard/engine.js +344 -0
  399. package/dist/src/onboard/engine.js.map +1 -0
  400. package/dist/src/onboard/launch.js +83 -0
  401. package/dist/src/onboard/launch.js.map +1 -0
  402. package/dist/src/onboard/steps/appslug.js +211 -0
  403. package/dist/src/onboard/steps/appslug.js.map +1 -0
  404. package/dist/src/onboard/steps/bringup.js +408 -0
  405. package/dist/src/onboard/steps/bringup.js.map +1 -0
  406. package/dist/src/onboard/steps/compose.js +343 -0
  407. package/dist/src/onboard/steps/compose.js.map +1 -0
  408. package/dist/src/onboard/steps/configdelivery.js +50 -0
  409. package/dist/src/onboard/steps/configdelivery.js.map +1 -0
  410. package/dist/src/onboard/steps/configfill.js +1148 -0
  411. package/dist/src/onboard/steps/configfill.js.map +1 -0
  412. package/dist/src/onboard/steps/deploysh.js +80 -0
  413. package/dist/src/onboard/steps/deploysh.js.map +1 -0
  414. package/dist/src/onboard/steps/embeddings.js +472 -0
  415. package/dist/src/onboard/steps/embeddings.js.map +1 -0
  416. package/dist/src/onboard/steps/escalation.js +571 -0
  417. package/dist/src/onboard/steps/escalation.js.map +1 -0
  418. package/dist/src/onboard/steps/githubapp.js +1092 -0
  419. package/dist/src/onboard/steps/githubapp.js.map +1 -0
  420. package/dist/src/onboard/steps/golive.js +506 -0
  421. package/dist/src/onboard/steps/golive.js.map +1 -0
  422. package/dist/src/onboard/steps/index.js +67 -0
  423. package/dist/src/onboard/steps/index.js.map +1 -0
  424. package/dist/src/onboard/steps/ingress.js +762 -0
  425. package/dist/src/onboard/steps/ingress.js.map +1 -0
  426. package/dist/src/onboard/steps/inventory.js +420 -0
  427. package/dist/src/onboard/steps/inventory.js.map +1 -0
  428. package/dist/src/onboard/steps/llmauth.js +893 -0
  429. package/dist/src/onboard/steps/llmauth.js.map +1 -0
  430. package/dist/src/onboard/steps/memory.js +1060 -0
  431. package/dist/src/onboard/steps/memory.js.map +1 -0
  432. package/dist/src/onboard/steps/memorykeys.js +43 -0
  433. package/dist/src/onboard/steps/memorykeys.js.map +1 -0
  434. package/dist/src/onboard/steps/preflight.js +172 -0
  435. package/dist/src/onboard/steps/preflight.js.map +1 -0
  436. package/dist/src/onboard/steps/proof.js +502 -0
  437. package/dist/src/onboard/steps/proof.js.map +1 -0
  438. package/dist/src/onboard/steps/ssh.js +300 -0
  439. package/dist/src/onboard/steps/ssh.js.map +1 -0
  440. package/dist/src/onboard/steps/topology.js +291 -0
  441. package/dist/src/onboard/steps/topology.js.map +1 -0
  442. package/dist/src/onboard/steps/transport.js +511 -0
  443. package/dist/src/onboard/steps/transport.js.map +1 -0
  444. package/dist/src/onboard/steps/verifyup.js +362 -0
  445. package/dist/src/onboard/steps/verifyup.js.map +1 -0
  446. package/dist/src/onboard/steps/vmdeploy.js +368 -0
  447. package/dist/src/onboard/steps/vmdeploy.js.map +1 -0
  448. package/dist/src/onboard/toml.js +53 -0
  449. package/dist/src/onboard/toml.js.map +1 -0
  450. package/dist/src/opencli.js +508 -0
  451. package/dist/src/opencli.js.map +1 -0
  452. package/dist/src/openpr.js +457 -0
  453. package/dist/src/openpr.js.map +1 -0
  454. package/dist/src/openviking/client.js +821 -0
  455. package/dist/src/openviking/client.js.map +1 -0
  456. package/dist/src/openviking/context.js +218 -0
  457. package/dist/src/openviking/context.js.map +1 -0
  458. package/dist/src/openviking/embedder.js +457 -0
  459. package/dist/src/openviking/embedder.js.map +1 -0
  460. package/dist/src/openviking/gapsink.js +38 -0
  461. package/dist/src/openviking/gapsink.js.map +1 -0
  462. package/dist/src/openviking/health.js +69 -0
  463. package/dist/src/openviking/health.js.map +1 -0
  464. package/dist/src/openviking/index.js +24 -0
  465. package/dist/src/openviking/index.js.map +1 -0
  466. package/dist/src/openviking/lessonfind.js +54 -0
  467. package/dist/src/openviking/lessonfind.js.map +1 -0
  468. package/dist/src/openviking/probe.js +89 -0
  469. package/dist/src/openviking/probe.js.map +1 -0
  470. package/dist/src/openviking/proc.js +95 -0
  471. package/dist/src/openviking/proc.js.map +1 -0
  472. package/dist/src/openviking/provisionreport.js +144 -0
  473. package/dist/src/openviking/provisionreport.js.map +1 -0
  474. package/dist/src/openviking/reembed.js +317 -0
  475. package/dist/src/openviking/reembed.js.map +1 -0
  476. package/dist/src/openviking/relayout.js +153 -0
  477. package/dist/src/openviking/relayout.js.map +1 -0
  478. package/dist/src/openviking/sidecar-main.js +39 -0
  479. package/dist/src/openviking/sidecar-main.js.map +1 -0
  480. package/dist/src/openviking/sidecar.js +79 -0
  481. package/dist/src/openviking/sidecar.js.map +1 -0
  482. package/dist/src/openviking/store.js +1758 -0
  483. package/dist/src/openviking/store.js.map +1 -0
  484. package/dist/src/openviking/supervisor.js +422 -0
  485. package/dist/src/openviking/supervisor.js.map +1 -0
  486. package/dist/src/openviking/types.js +5 -0
  487. package/dist/src/openviking/types.js.map +1 -0
  488. package/dist/src/operator-recovery.js +172 -0
  489. package/dist/src/operator-recovery.js.map +1 -0
  490. package/dist/src/operatormessage.js +44 -0
  491. package/dist/src/operatormessage.js.map +1 -0
  492. package/dist/src/outcomes.js +1361 -0
  493. package/dist/src/outcomes.js.map +1 -0
  494. package/dist/src/piauthcopy.js +5 -0
  495. package/dist/src/piauthcopy.js.map +1 -0
  496. package/dist/src/picli.js +45 -0
  497. package/dist/src/picli.js.map +1 -0
  498. package/dist/src/picwd.js +25 -0
  499. package/dist/src/picwd.js.map +1 -0
  500. package/dist/src/pilogin.js +35 -0
  501. package/dist/src/pilogin.js.map +1 -0
  502. package/dist/src/pingpong.js +1028 -0
  503. package/dist/src/pingpong.js.map +1 -0
  504. package/dist/src/posting-journal.js +441 -0
  505. package/dist/src/posting-journal.js.map +1 -0
  506. package/dist/src/posting-outbox.js +662 -0
  507. package/dist/src/posting-outbox.js.map +1 -0
  508. package/dist/src/posting-receipt.js +203 -0
  509. package/dist/src/posting-receipt.js.map +1 -0
  510. package/dist/src/posting.js +348 -0
  511. package/dist/src/posting.js.map +1 -0
  512. package/dist/src/postops.js +637 -0
  513. package/dist/src/postops.js.map +1 -0
  514. package/dist/src/pr-activity.js +11 -0
  515. package/dist/src/pr-activity.js.map +1 -0
  516. package/dist/src/pr-author.js +5 -0
  517. package/dist/src/pr-author.js.map +1 -0
  518. package/dist/src/pr-conversation.js +355 -0
  519. package/dist/src/pr-conversation.js.map +1 -0
  520. package/dist/src/pr-session-lifecycle.js +542 -0
  521. package/dist/src/pr-session-lifecycle.js.map +1 -0
  522. package/dist/src/preflight.js +341 -0
  523. package/dist/src/preflight.js.map +1 -0
  524. package/dist/src/priorresolve.js +275 -0
  525. package/dist/src/priorresolve.js.map +1 -0
  526. package/dist/src/qcrbin.js +180 -0
  527. package/dist/src/qcrbin.js.map +1 -0
  528. package/dist/src/recheck.js +1398 -0
  529. package/dist/src/recheck.js.map +1 -0
  530. package/dist/src/reconcile.js +820 -0
  531. package/dist/src/reconcile.js.map +1 -0
  532. package/dist/src/recordedfindings.js +15 -0
  533. package/dist/src/recordedfindings.js.map +1 -0
  534. package/dist/src/regression.js +546 -0
  535. package/dist/src/regression.js.map +1 -0
  536. package/dist/src/replay.js +598 -0
  537. package/dist/src/replay.js.map +1 -0
  538. package/dist/src/requiredpasses.js +113 -0
  539. package/dist/src/requiredpasses.js.map +1 -0
  540. package/dist/src/resumecheck.js +168 -0
  541. package/dist/src/resumecheck.js.map +1 -0
  542. package/dist/src/reviewcli.js +671 -0
  543. package/dist/src/reviewcli.js.map +1 -0
  544. package/dist/src/reviewcontext.js +22 -0
  545. package/dist/src/reviewcontext.js.map +1 -0
  546. package/dist/src/reviewdetach.js +574 -0
  547. package/dist/src/reviewdetach.js.map +1 -0
  548. package/dist/src/reviewhistory.js +284 -0
  549. package/dist/src/reviewhistory.js.map +1 -0
  550. package/dist/src/reviewhome.js +26 -0
  551. package/dist/src/reviewhome.js.map +1 -0
  552. package/dist/src/reviewmemory.js +110 -0
  553. package/dist/src/reviewmemory.js.map +1 -0
  554. package/dist/src/reviewrendercli.js +104 -0
  555. package/dist/src/reviewrendercli.js.map +1 -0
  556. package/dist/src/round-work.js +20 -0
  557. package/dist/src/round-work.js.map +1 -0
  558. package/dist/src/runhost.js +72 -0
  559. package/dist/src/runhost.js.map +1 -0
  560. package/dist/src/scanauth.js +25 -0
  561. package/dist/src/scanauth.js.map +1 -0
  562. package/dist/src/scanbox.js +846 -0
  563. package/dist/src/scanbox.js.map +1 -0
  564. package/dist/src/scancli.js +1074 -0
  565. package/dist/src/scancli.js.map +1 -0
  566. package/dist/src/scandriverlog.js +30 -0
  567. package/dist/src/scandriverlog.js.map +1 -0
  568. package/dist/src/session-bridge.js +75 -0
  569. package/dist/src/session-bridge.js.map +1 -0
  570. package/dist/src/session-command-payload.js +29 -0
  571. package/dist/src/session-command-payload.js.map +1 -0
  572. package/dist/src/session-command-supervisor.js +939 -0
  573. package/dist/src/session-command-supervisor.js.map +1 -0
  574. package/dist/src/sessionjsonl.js +85 -0
  575. package/dist/src/sessionjsonl.js.map +1 -0
  576. package/dist/src/sessions.js +541 -0
  577. package/dist/src/sessions.js.map +1 -0
  578. package/dist/src/setup/adapters.js +330 -0
  579. package/dist/src/setup/adapters.js.map +1 -0
  580. package/dist/src/setup/baselines.js +64 -0
  581. package/dist/src/setup/baselines.js.map +1 -0
  582. package/dist/src/setup/caserunner.js +554 -0
  583. package/dist/src/setup/caserunner.js.map +1 -0
  584. package/dist/src/setup/configcommit.js +358 -0
  585. package/dist/src/setup/configcommit.js.map +1 -0
  586. package/dist/src/setup/engine.js +672 -0
  587. package/dist/src/setup/engine.js.map +1 -0
  588. package/dist/src/setup/facts.js +593 -0
  589. package/dist/src/setup/facts.js.map +1 -0
  590. package/dist/src/setup/findings.js +948 -0
  591. package/dist/src/setup/findings.js.map +1 -0
  592. package/dist/src/setup/history.js +338 -0
  593. package/dist/src/setup/history.js.map +1 -0
  594. package/dist/src/setup/interview.js +185 -0
  595. package/dist/src/setup/interview.js.map +1 -0
  596. package/dist/src/setup/ledger.js +207 -0
  597. package/dist/src/setup/ledger.js.map +1 -0
  598. package/dist/src/setup/pins.js +43 -0
  599. package/dist/src/setup/pins.js.map +1 -0
  600. package/dist/src/setup/portrait.js +432 -0
  601. package/dist/src/setup/portrait.js.map +1 -0
  602. package/dist/src/setup/presentation.js +9 -0
  603. package/dist/src/setup/presentation.js.map +1 -0
  604. package/dist/src/setup/profile.js +417 -0
  605. package/dist/src/setup/profile.js.map +1 -0
  606. package/dist/src/setup/replay.js +240 -0
  607. package/dist/src/setup/replay.js.map +1 -0
  608. package/dist/src/setup/replaybuild.js +311 -0
  609. package/dist/src/setup/replaybuild.js.map +1 -0
  610. package/dist/src/setup/replayrun.js +806 -0
  611. package/dist/src/setup/replayrun.js.map +1 -0
  612. package/dist/src/setup/replaysource.js +169 -0
  613. package/dist/src/setup/replaysource.js.map +1 -0
  614. package/dist/src/setup/retune.js +333 -0
  615. package/dist/src/setup/retune.js.map +1 -0
  616. package/dist/src/setup/retunelog.js +51 -0
  617. package/dist/src/setup/retunelog.js.map +1 -0
  618. package/dist/src/setup/scan.js +725 -0
  619. package/dist/src/setup/scan.js.map +1 -0
  620. package/dist/src/setup/scanbuild.js +133 -0
  621. package/dist/src/setup/scanbuild.js.map +1 -0
  622. package/dist/src/setup/scanfacts.js +96 -0
  623. package/dist/src/setup/scanfacts.js.map +1 -0
  624. package/dist/src/setup/scanlease.js +251 -0
  625. package/dist/src/setup/scanlease.js.map +1 -0
  626. package/dist/src/setup/scanreport.js +289 -0
  627. package/dist/src/setup/scanreport.js.map +1 -0
  628. package/dist/src/setup/scanrun.js +1733 -0
  629. package/dist/src/setup/scanrun.js.map +1 -0
  630. package/dist/src/setup/scantree.js +257 -0
  631. package/dist/src/setup/scantree.js.map +1 -0
  632. package/dist/src/setup/stage2.js +617 -0
  633. package/dist/src/setup/stage2.js.map +1 -0
  634. package/dist/src/setup/stage2request.js +259 -0
  635. package/dist/src/setup/stage2request.js.map +1 -0
  636. package/dist/src/setup/survey.js +307 -0
  637. package/dist/src/setup/survey.js.map +1 -0
  638. package/dist/src/setup/surveyrun.js +549 -0
  639. package/dist/src/setup/surveyrun.js.map +1 -0
  640. package/dist/src/setup/textsniff.js +89 -0
  641. package/dist/src/setup/textsniff.js.map +1 -0
  642. package/dist/src/setup/trackersink.js +395 -0
  643. package/dist/src/setup/trackersink.js.map +1 -0
  644. package/dist/src/setup/triage.js +636 -0
  645. package/dist/src/setup/triage.js.map +1 -0
  646. package/dist/src/setup/tuning.js +585 -0
  647. package/dist/src/setup/tuning.js.map +1 -0
  648. package/dist/src/setup/unitcut.js +437 -0
  649. package/dist/src/setup/unitcut.js.map +1 -0
  650. package/dist/src/singleflight.js +72 -0
  651. package/dist/src/singleflight.js.map +1 -0
  652. package/dist/src/slack/announce.js +725 -0
  653. package/dist/src/slack/announce.js.map +1 -0
  654. package/dist/src/slack/api.js +227 -0
  655. package/dist/src/slack/api.js.map +1 -0
  656. package/dist/src/slack/messages.js +158 -0
  657. package/dist/src/slack/messages.js.map +1 -0
  658. package/dist/src/slack/origin.js +550 -0
  659. package/dist/src/slack/origin.js.map +1 -0
  660. package/dist/src/slack/policy.js +225 -0
  661. package/dist/src/slack/policy.js.map +1 -0
  662. package/dist/src/slack/reply.js +153 -0
  663. package/dist/src/slack/reply.js.map +1 -0
  664. package/dist/src/slack/router.js +189 -0
  665. package/dist/src/slack/router.js.map +1 -0
  666. package/dist/src/slack/socket.js +323 -0
  667. package/dist/src/slack/socket.js.map +1 -0
  668. package/dist/src/slack/types.js +11 -0
  669. package/dist/src/slack/types.js.map +1 -0
  670. package/dist/src/sqlitehint.js +30 -0
  671. package/dist/src/sqlitehint.js.map +1 -0
  672. package/dist/src/ssh-observation.js +25 -0
  673. package/dist/src/ssh-observation.js.map +1 -0
  674. package/dist/src/state.js +5869 -0
  675. package/dist/src/state.js.map +1 -0
  676. package/dist/src/statemachine.js +220 -0
  677. package/dist/src/statemachine.js.map +1 -0
  678. package/dist/src/stubs.js +52 -0
  679. package/dist/src/stubs.js.map +1 -0
  680. package/dist/src/takeover.js +506 -0
  681. package/dist/src/takeover.js.map +1 -0
  682. package/dist/src/takeoverpi.js +249 -0
  683. package/dist/src/takeoverpi.js.map +1 -0
  684. package/dist/src/terminal-failure.js +26 -0
  685. package/dist/src/terminal-failure.js.map +1 -0
  686. package/dist/src/thread-resolution-refusal.js +4 -0
  687. package/dist/src/thread-resolution-refusal.js.map +1 -0
  688. package/dist/src/tmux.js +126 -0
  689. package/dist/src/tmux.js.map +1 -0
  690. package/dist/src/trackerid.js +43 -0
  691. package/dist/src/trackerid.js.map +1 -0
  692. package/dist/src/transcriptindex.js +512 -0
  693. package/dist/src/transcriptindex.js.map +1 -0
  694. package/dist/src/tui/actions.js +404 -0
  695. package/dist/src/tui/actions.js.map +1 -0
  696. package/dist/src/tui/alerts.js +523 -0
  697. package/dist/src/tui/alerts.js.map +1 -0
  698. package/dist/src/tui/app.js +1835 -0
  699. package/dist/src/tui/app.js.map +1 -0
  700. package/dist/src/tui/archiveops.js +164 -0
  701. package/dist/src/tui/archiveops.js.map +1 -0
  702. package/dist/src/tui/autofile.js +191 -0
  703. package/dist/src/tui/autofile.js.map +1 -0
  704. package/dist/src/tui/board.js +1143 -0
  705. package/dist/src/tui/board.js.map +1 -0
  706. package/dist/src/tui/candidates.js +699 -0
  707. package/dist/src/tui/candidates.js.map +1 -0
  708. package/dist/src/tui/cellwidth.js +151 -0
  709. package/dist/src/tui/cellwidth.js.map +1 -0
  710. package/dist/src/tui/changelog-store.js +404 -0
  711. package/dist/src/tui/changelog-store.js.map +1 -0
  712. package/dist/src/tui/chord.js +26 -0
  713. package/dist/src/tui/chord.js.map +1 -0
  714. package/dist/src/tui/clipboard.js +81 -0
  715. package/dist/src/tui/clipboard.js.map +1 -0
  716. package/dist/src/tui/cmdbar.js +253 -0
  717. package/dist/src/tui/cmdbar.js.map +1 -0
  718. package/dist/src/tui/companion.js +1275 -0
  719. package/dist/src/tui/companion.js.map +1 -0
  720. package/dist/src/tui/companionplan.js +200 -0
  721. package/dist/src/tui/companionplan.js.map +1 -0
  722. package/dist/src/tui/companiontab.js +61 -0
  723. package/dist/src/tui/companiontab.js.map +1 -0
  724. package/dist/src/tui/confighome.js +105 -0
  725. package/dist/src/tui/confighome.js.map +1 -0
  726. package/dist/src/tui/connect.js +44 -0
  727. package/dist/src/tui/connect.js.map +1 -0
  728. package/dist/src/tui/deploy-ceremony.js +75 -0
  729. package/dist/src/tui/deploy-ceremony.js.map +1 -0
  730. package/dist/src/tui/deploy-drain.js +93 -0
  731. package/dist/src/tui/deploy-drain.js.map +1 -0
  732. package/dist/src/tui/deploy-process.js +234 -0
  733. package/dist/src/tui/deploy-process.js.map +1 -0
  734. package/dist/src/tui/deploytab.js +1263 -0
  735. package/dist/src/tui/deploytab.js.map +1 -0
  736. package/dist/src/tui/design.js +111 -0
  737. package/dist/src/tui/design.js.map +1 -0
  738. package/dist/src/tui/digesttab.js +148 -0
  739. package/dist/src/tui/digesttab.js.map +1 -0
  740. package/dist/src/tui/emptystate.js +32 -0
  741. package/dist/src/tui/emptystate.js.map +1 -0
  742. package/dist/src/tui/evidence.js +790 -0
  743. package/dist/src/tui/evidence.js.map +1 -0
  744. package/dist/src/tui/feed.js +415 -0
  745. package/dist/src/tui/feed.js.map +1 -0
  746. package/dist/src/tui/feedbacktab.js +907 -0
  747. package/dist/src/tui/feedbacktab.js.map +1 -0
  748. package/dist/src/tui/filters.js +72 -0
  749. package/dist/src/tui/filters.js.map +1 -0
  750. package/dist/src/tui/ghread.js +272 -0
  751. package/dist/src/tui/ghread.js.map +1 -0
  752. package/dist/src/tui/historypull.js +61 -0
  753. package/dist/src/tui/historypull.js.map +1 -0
  754. package/dist/src/tui/inboxpull.js +197 -0
  755. package/dist/src/tui/inboxpull.js.map +1 -0
  756. package/dist/src/tui/index.js +1425 -0
  757. package/dist/src/tui/index.js.map +1 -0
  758. package/dist/src/tui/issuesink.js +206 -0
  759. package/dist/src/tui/issuesink.js.map +1 -0
  760. package/dist/src/tui/jsonfile.js +32 -0
  761. package/dist/src/tui/jsonfile.js.map +1 -0
  762. package/dist/src/tui/keys.js +97 -0
  763. package/dist/src/tui/keys.js.map +1 -0
  764. package/dist/src/tui/layout.js +70 -0
  765. package/dist/src/tui/layout.js.map +1 -0
  766. package/dist/src/tui/learning.js +469 -0
  767. package/dist/src/tui/learning.js.map +1 -0
  768. package/dist/src/tui/lessonswitch.js +103 -0
  769. package/dist/src/tui/lessonswitch.js.map +1 -0
  770. package/dist/src/tui/livecontrol.js +167 -0
  771. package/dist/src/tui/livecontrol.js.map +1 -0
  772. package/dist/src/tui/memforward.js +310 -0
  773. package/dist/src/tui/memforward.js.map +1 -0
  774. package/dist/src/tui/memincident.js +287 -0
  775. package/dist/src/tui/memincident.js.map +1 -0
  776. package/dist/src/tui/memory.js +485 -0
  777. package/dist/src/tui/memory.js.map +1 -0
  778. package/dist/src/tui/memtab.js +513 -0
  779. package/dist/src/tui/memtab.js.map +1 -0
  780. package/dist/src/tui/metricstab.js +979 -0
  781. package/dist/src/tui/metricstab.js.map +1 -0
  782. package/dist/src/tui/model.js +104 -0
  783. package/dist/src/tui/model.js.map +1 -0
  784. package/dist/src/tui/mouse.js +83 -0
  785. package/dist/src/tui/mouse.js.map +1 -0
  786. package/dist/src/tui/msg.js +196 -0
  787. package/dist/src/tui/msg.js.map +1 -0
  788. package/dist/src/tui/nativecompanion.js +321 -0
  789. package/dist/src/tui/nativecompanion.js.map +1 -0
  790. package/dist/src/tui/openurl.js +17 -0
  791. package/dist/src/tui/openurl.js.map +1 -0
  792. package/dist/src/tui/paint.js +501 -0
  793. package/dist/src/tui/paint.js.map +1 -0
  794. package/dist/src/tui/panes.js +74 -0
  795. package/dist/src/tui/panes.js.map +1 -0
  796. package/dist/src/tui/pipane.js +166 -0
  797. package/dist/src/tui/pipane.js.map +1 -0
  798. package/dist/src/tui/poll-hooks.js +11 -0
  799. package/dist/src/tui/poll-hooks.js.map +1 -0
  800. package/dist/src/tui/poll.js +113 -0
  801. package/dist/src/tui/poll.js.map +1 -0
  802. package/dist/src/tui/proposals.js +715 -0
  803. package/dist/src/tui/proposals.js.map +1 -0
  804. package/dist/src/tui/prpause.js +132 -0
  805. package/dist/src/tui/prpause.js.map +1 -0
  806. package/dist/src/tui/reload.js +351 -0
  807. package/dist/src/tui/reload.js.map +1 -0
  808. package/dist/src/tui/remote.js +312 -0
  809. package/dist/src/tui/remote.js.map +1 -0
  810. package/dist/src/tui/rollover.js +767 -0
  811. package/dist/src/tui/rollover.js.map +1 -0
  812. package/dist/src/tui/scantab.js +1053 -0
  813. package/dist/src/tui/scantab.js.map +1 -0
  814. package/dist/src/tui/setupentry.js +81 -0
  815. package/dist/src/tui/setupentry.js.map +1 -0
  816. package/dist/src/tui/sidebar.js +602 -0
  817. package/dist/src/tui/sidebar.js.map +1 -0
  818. package/dist/src/tui/sshtriage.js +393 -0
  819. package/dist/src/tui/sshtriage.js.map +1 -0
  820. package/dist/src/tui/startup.js +172 -0
  821. package/dist/src/tui/startup.js.map +1 -0
  822. package/dist/src/tui/tabs.js +41 -0
  823. package/dist/src/tui/tabs.js.map +1 -0
  824. package/dist/src/tui/termmirror.js +217 -0
  825. package/dist/src/tui/termmirror.js.map +1 -0
  826. package/dist/src/tui/theme.js +95 -0
  827. package/dist/src/tui/theme.js.map +1 -0
  828. package/dist/src/tui/ticketstab.js +224 -0
  829. package/dist/src/tui/ticketstab.js.map +1 -0
  830. package/dist/src/tui/tmuxsocket.js +101 -0
  831. package/dist/src/tui/tmuxsocket.js.map +1 -0
  832. package/dist/src/tui/trackertickets.js +77 -0
  833. package/dist/src/tui/trackertickets.js.map +1 -0
  834. package/dist/src/tui/unwedge.js +245 -0
  835. package/dist/src/tui/unwedge.js.map +1 -0
  836. package/dist/src/tui/viewer.js +479 -0
  837. package/dist/src/tui/viewer.js.map +1 -0
  838. package/dist/src/tui/viewerinput.js +125 -0
  839. package/dist/src/tui/viewerinput.js.map +1 -0
  840. package/dist/src/updatenotice.js +143 -0
  841. package/dist/src/updatenotice.js.map +1 -0
  842. package/dist/src/usage.js +1007 -0
  843. package/dist/src/usage.js.map +1 -0
  844. package/dist/src/verdicts.js +3848 -0
  845. package/dist/src/verdicts.js.map +1 -0
  846. package/dist/src/version.js +58 -0
  847. package/dist/src/version.js.map +1 -0
  848. package/dist/src/wakes.js +411 -0
  849. package/dist/src/wakes.js.map +1 -0
  850. package/dist/src/webhook.js +725 -0
  851. package/dist/src/webhook.js.map +1 -0
  852. package/dist/src/webhookheal.js +238 -0
  853. package/dist/src/webhookheal.js.map +1 -0
  854. package/dist/src/wire.js +1801 -0
  855. package/dist/src/wire.js.map +1 -0
  856. package/dist/src/worker-session-host.js +531 -0
  857. package/dist/src/worker-session-host.js.map +1 -0
  858. package/dist/src/workerbreaker.js +112 -0
  859. package/dist/src/workerbreaker.js.map +1 -0
  860. package/dist/src/workercontrol.js +49 -0
  861. package/dist/src/workercontrol.js.map +1 -0
  862. package/dist/src/workerlease.js +351 -0
  863. package/dist/src/workerlease.js.map +1 -0
  864. package/dist/src/workerliveness.js +84 -0
  865. package/dist/src/workerliveness.js.map +1 -0
  866. package/dist/src/workerlog.js +65 -0
  867. package/dist/src/workerlog.js.map +1 -0
  868. package/dist/src/workerruntime.js +10 -0
  869. package/dist/src/workerruntime.js.map +1 -0
  870. package/dist/src/workersupervisor.js +1206 -0
  871. package/dist/src/workersupervisor.js.map +1 -0
  872. package/dist/src/workerusage.js +23 -0
  873. package/dist/src/workerusage.js.map +1 -0
  874. package/dist/src/workerworld.js +332 -0
  875. package/dist/src/workerworld.js.map +1 -0
  876. package/dist/src/worktrees.js +984 -0
  877. package/dist/src/worktrees.js.map +1 -0
  878. package/docker/Dockerfile +284 -0
  879. package/docker/Dockerfile.dockerignore +35 -0
  880. package/docker/README.md +221 -0
  881. package/docker/companion/Dockerfile +139 -0
  882. package/docker/companion/agents/evidence.md +34 -0
  883. package/docker/companion/agents/explore.md +26 -0
  884. package/docker/companion/agents/pass-survey.md +49 -0
  885. package/docker/companion/agents/study.md +26 -0
  886. package/docker/companion/entrypoint.sh +343 -0
  887. package/docker/companion/memory-health.mjs +269 -0
  888. package/docker/entrypoint.sh +782 -0
  889. package/docker/memory/Dockerfile +191 -0
  890. package/docker/memory/Dockerfile.dockerignore +20 -0
  891. package/docker/memory/README.md +60 -0
  892. package/docker/memory/entrypoint.sh +163 -0
  893. package/docker/setid/qcr-setid.c +109 -0
  894. package/docker/tunnel/Dockerfile.cloudflared +51 -0
  895. package/docker/tunnel/Dockerfile.ngrok +40 -0
  896. package/docker/tunnel/sidecar.mjs +411 -0
  897. package/docker/vm/tmux.conf +26 -0
  898. package/docker-compose.yml +348 -0
  899. package/extensions/cli/bash-guard-ambient.ts +71 -0
  900. package/extensions/cli/bash-guard.ts +12 -0
  901. package/extensions/cli/companion-guard.ts +8 -0
  902. package/extensions/cli/debug-round.ts +9 -0
  903. package/extensions/cli/inject-bridge.ts +8 -0
  904. package/extensions/cli/ledger-pin.ts +13 -0
  905. package/extensions/cli/lens.ts +22 -0
  906. package/extensions/cli/muted-theme.ts +9 -0
  907. package/extensions/cli/onboarding.ts +8 -0
  908. package/extensions/cli/setup.ts +8 -0
  909. package/extensions/cli/tui-view.ts +8 -0
  910. package/extensions/companion-guard.ts +411 -0
  911. package/extensions/debug-round.ts +281 -0
  912. package/extensions/inject-bridge.ts +630 -0
  913. package/extensions/inline-bash.ts +102 -0
  914. package/extensions/muted-theme.json +68 -0
  915. package/extensions/muted-theme.ts +23 -0
  916. package/extensions/onboarding.ts +233 -0
  917. package/extensions/setup-replay.ts +336 -0
  918. package/extensions/setup-scan.ts +1236 -0
  919. package/extensions/setup-triage.ts +190 -0
  920. package/extensions/setup.ts +1428 -0
  921. package/extensions/tui-view.ts +489 -0
  922. package/extensions/vendor/openviking-pi/LICENSE +201 -0
  923. package/extensions/vendor/openviking-pi/NOTICE +68 -0
  924. package/extensions/vendor/openviking-pi/README.md +550 -0
  925. package/extensions/vendor/openviking-pi/client.ts +588 -0
  926. package/extensions/vendor/openviking-pi/config.json +28 -0
  927. package/extensions/vendor/openviking-pi/config.ts +271 -0
  928. package/extensions/vendor/openviking-pi/index.ts +523 -0
  929. package/extensions/vendor/openviking-pi/lib/capture-adapter.d.mts +10 -0
  930. package/extensions/vendor/openviking-pi/lib/capture-adapter.mjs +88 -0
  931. package/extensions/vendor/openviking-pi/lib/takeover-core.d.mts +80 -0
  932. package/extensions/vendor/openviking-pi/lib/takeover-core.mjs +406 -0
  933. package/extensions/vendor/openviking-pi/lib/uri-guard-adapter.d.mts +1 -0
  934. package/extensions/vendor/openviking-pi/lib/uri-guard-adapter.mjs +42 -0
  935. package/extensions/vendor/openviking-pi/patches/0000-vendoring-and-build-conformance.patch +726 -0
  936. package/extensions/vendor/openviking-pi/patches/0001-remove-session-before-compact-handler.patch +101 -0
  937. package/extensions/vendor/openviking-pi/patches/0002-no-ambient-work-in-subagent-leaves.patch +202 -0
  938. package/extensions/vendor/openviking-pi/patches/0003-never-auto-create-openviking-sessions.patch +120 -0
  939. package/extensions/vendor/openviking-pi/patches/0004-viking-write-durable-batch-write.patch +521 -0
  940. package/extensions/vendor/openviking-pi/patches/0005-second-memory-namespace.patch +786 -0
  941. package/extensions/vendor/openviking-pi/patches/0006-writes-that-stick-and-honest-errors.patch +996 -0
  942. package/extensions/vendor/openviking-pi/patches/0007-bounded-recall-no-commit.patch +794 -0
  943. package/extensions/vendor/openviking-pi/patches/0008-pr-derived-recall.patch +379 -0
  944. package/extensions/vendor/openviking-pi/patches/0009-pr-session-tools-in-round-areas.patch +375 -0
  945. package/extensions/vendor/openviking-pi/patches/0010-typed-lessons.patch +448 -0
  946. package/extensions/vendor/openviking-pi/patches/0011-review-session-memory-boundary.patch +382 -0
  947. package/extensions/vendor/openviking-pi/patches/0012-typecheck-against-the-real-pi.patch +129 -0
  948. package/extensions/vendor/openviking-pi/patches/0013-createopenviking-factory.patch +1168 -0
  949. package/extensions/vendor/openviking-pi/qcr-hint.ts +313 -0
  950. package/extensions/vendor/openviking-pi/qcr-honest.ts +183 -0
  951. package/extensions/vendor/openviking-pi/qcr-hooks.ts +87 -0
  952. package/extensions/vendor/openviking-pi/qcr-leaf.ts +78 -0
  953. package/extensions/vendor/openviking-pi/qcr-lesson.ts +231 -0
  954. package/extensions/vendor/openviking-pi/qcr-namespaces.ts +198 -0
  955. package/extensions/vendor/openviking-pi/qcr-recall.ts +288 -0
  956. package/extensions/vendor/openviking-pi/qcr-shim.ts +142 -0
  957. package/extensions/vendor/openviking-pi/qcr-write.ts +427 -0
  958. package/extensions/vendor/openviking-pi/recall.ts +149 -0
  959. package/extensions/vendor/openviking-pi/shared/capture-utils.d.mts +9 -0
  960. package/extensions/vendor/openviking-pi/shared/capture-utils.mjs +515 -0
  961. package/extensions/vendor/openviking-pi/shared/credentials.d.mts +14 -0
  962. package/extensions/vendor/openviking-pi/shared/credentials.mjs +228 -0
  963. package/extensions/vendor/openviking-pi/shared/pending-queue.d.mts +19 -0
  964. package/extensions/vendor/openviking-pi/shared/pending-queue.mjs +458 -0
  965. package/extensions/vendor/openviking-pi/shared/profile-inject.d.mts +17 -0
  966. package/extensions/vendor/openviking-pi/shared/profile-inject.mjs +294 -0
  967. package/extensions/vendor/openviking-pi/shared/recall-compress-core.mjs +195 -0
  968. package/extensions/vendor/openviking-pi/shared/recall-core.d.mts +13 -0
  969. package/extensions/vendor/openviking-pi/shared/recall-core.mjs +609 -0
  970. package/extensions/vendor/openviking-pi/shared/retryable.mjs +9 -0
  971. package/extensions/vendor/openviking-pi/shared/session-model.d.mts +3 -0
  972. package/extensions/vendor/openviking-pi/shared/session-model.mjs +57 -0
  973. package/extensions/vendor/openviking-pi/shared/uri-guard.mjs +56 -0
  974. package/extensions/vendor/openviking-pi/shared/workspace-peer.d.mts +5 -0
  975. package/extensions/vendor/openviking-pi/shared/workspace-peer.mjs +16 -0
  976. package/extensions/vendor/openviking-pi/sync.ts +163 -0
  977. package/extensions/vendor/openviking-pi/takeover.ts +36 -0
  978. package/extensions/vendor/openviking-pi/tools.ts +479 -0
  979. package/extensions/vendor/openviking-pi/upstream.json +48 -0
  980. package/package.json +138 -4
  981. package/profiles/default/README.md +21 -0
  982. package/profiles/default/addenda/.gitkeep +0 -0
  983. package/profiles/default/profile.toml +17 -0
  984. package/profiles/example-django/README.md +21 -0
  985. package/profiles/example-django/addenda/10-migration-discipline.md +13 -0
  986. package/profiles/example-django/addenda/20-new-column-nullability.md +12 -0
  987. package/profiles/example-django/addenda/30-test-doctrine.md +12 -0
  988. package/profiles/example-django/addenda/40-orm-usage.md +19 -0
  989. package/profiles/example-django/addenda/50-declared-shape.md +10 -0
  990. package/profiles/example-django/addenda/60-config-placement.md +14 -0
  991. package/profiles/example-django/heuristics.toml +24 -0
  992. package/profiles/example-django/profile.toml +9 -0
  993. package/profiles/fast/README.md +24 -0
  994. package/profiles/fast/profile.toml +59 -0
  995. package/profiles/minimal/README.md +17 -0
  996. package/profiles/minimal/addenda/.gitkeep +0 -0
  997. package/profiles/minimal/profile.toml +8 -0
  998. package/profiles/snappy/README.md +24 -0
  999. package/profiles/snappy/addenda/10-race-as-question.md +6 -0
  1000. package/profiles/snappy/profile.toml +46 -0
  1001. package/prompts/companion-day-open.md +46 -0
  1002. package/prompts/companion-first-run.md +104 -0
  1003. package/prompts/companion.md +821 -0
  1004. package/prompts/conversation-system.md +117 -0
  1005. package/prompts/eod-sweep.md +120 -0
  1006. package/prompts/onboarding.md +74 -0
  1007. package/prompts/pass-common.md +52 -0
  1008. package/prompts/pass-quality-common.md +22 -0
  1009. package/prompts/round-first.md +874 -0
  1010. package/prompts/round-follow-up.md +991 -0
  1011. package/prompts/round-re-review.md +889 -0
  1012. package/prompts/round-recheck.md +196 -0
  1013. package/prompts/round-staleness.md +856 -0
  1014. package/schemas/review-result.v1.json +391 -0
  1015. package/schemas/scan-result.v1.json +421 -0
  1016. package/schemas/version.v1.json +57 -0
  1017. package/scripts/approve-pr.sh +16 -0
  1018. package/scripts/build-stamp.mjs +67 -0
  1019. package/scripts/commit-verdict.sh +155 -0
  1020. package/scripts/companion-e2e.sh +250 -0
  1021. package/scripts/companion-memory-forward.mjs +1966 -0
  1022. package/scripts/config-fingerprint.sh +28 -0
  1023. package/scripts/deploy-manifest.mjs +141 -0
  1024. package/scripts/deploy.sh +1435 -0
  1025. package/scripts/directory-lock.mjs +193 -0
  1026. package/scripts/heartbeat-fresh.mjs +54 -0
  1027. package/scripts/install-locator.sh +328 -0
  1028. package/scripts/install-wrapper.sh +76 -0
  1029. package/scripts/memory-search-check.mjs +98 -0
  1030. package/scripts/mint-token.sh +212 -0
  1031. package/scripts/openviking-embedder.py +392 -0
  1032. package/scripts/openviking-ov-conf.mjs +161 -0
  1033. package/scripts/openviking-provision.mjs +1122 -0
  1034. package/scripts/openviking-reembed.mjs +1154 -0
  1035. package/scripts/post-review.sh +16 -0
  1036. package/scripts/pr-checks.sh +256 -0
  1037. package/scripts/pr-context.py +174 -0
  1038. package/scripts/pr-context.sh +63 -0
  1039. package/scripts/pr-self.sh +144 -0
  1040. package/scripts/pr-threads.sh +145 -0
  1041. package/scripts/qcr +15 -0
  1042. package/scripts/qcr-host.sh +769 -0
  1043. package/scripts/render-review.sh +24 -0
  1044. package/scripts/reply-thread.sh +16 -0
  1045. package/scripts/required-passes.py +112 -0
  1046. package/scripts/smoke-companion.sh +211 -0
  1047. package/scripts/tui-deploy-runner.mjs +61 -0
  1048. package/scripts/verify-dist.mjs +201 -0
  1049. package/scripts/vm-ghcr-login.sh +188 -0
  1050. package/seeds/agents/pass-baseline.md +61 -0
  1051. package/seeds/agents/pass-contracts.md +30 -0
  1052. package/seeds/agents/pass-data-safety.md +54 -0
  1053. package/seeds/agents/pass-delivery.md +37 -0
  1054. package/seeds/agents/pass-prod-risk.md +24 -0
  1055. package/seeds/agents/pass-security.md +31 -0
  1056. package/seeds/agents/pass-statelessness.md +27 -0
  1057. package/seeds/agents/pass-ui.md +23 -0
  1058. package/seeds/agents/pass-validate.md +50 -0
  1059. package/seeds/prompts/conversation-system.md +117 -0
  1060. package/seeds/prompts/eod-sweep.md +120 -0
  1061. package/seeds/prompts/pass-common.md +52 -0
  1062. package/skills/companion/adjudicate/SKILL.md +247 -0
  1063. package/skills/companion/evidence/SKILL.md +150 -0
  1064. package/skills/companion/land/SKILL.md +200 -0
  1065. package/skills/companion/replay/SKILL.md +138 -0
  1066. package/skills/companion/setup/SKILL.md +167 -0
  1067. package/skills/companion/survey/SKILL.md +160 -0
  1068. package/skills/selfimprove/README.md +34 -0
  1069. package/skills/selfimprove/SKILL.md +127 -0
  1070. package/skills/selfimprove/agents/openai.yaml +2 -0
  1071. package/skills/selfimprove/commands/selfimprove.md +6 -0
  1072. package/skills/selfimprove/references/fix-loop.md +49 -0
  1073. package/skills/selfimprove/references/ledger.md +20 -0
  1074. package/skills/selfimprove/references/setup.md +40 -0
  1075. package/src/anomalies.ts +566 -0
  1076. package/src/answerworker.ts +251 -0
  1077. package/src/appstamp.ts +65 -0
  1078. package/src/archive/admission.ts +3 -0
  1079. package/src/archive/archivedb.ts +548 -0
  1080. package/src/archive/archiver.ts +1963 -0
  1081. package/src/archive/archivewrite.ts +279 -0
  1082. package/src/archive/bundle.ts +333 -0
  1083. package/src/archive/dbmove.ts +911 -0
  1084. package/src/archive/duplicatecounts.ts +98 -0
  1085. package/src/archive/eligibility.ts +181 -0
  1086. package/src/archive/extract.ts +342 -0
  1087. package/src/archive/fsutil.ts +415 -0
  1088. package/src/archive/gzfile.ts +370 -0
  1089. package/src/archive/kinds.ts +21 -0
  1090. package/src/archive/livecheckpoint.ts +217 -0
  1091. package/src/archive/liveretention.ts +180 -0
  1092. package/src/archive/manifest.ts +519 -0
  1093. package/src/archive/overseer.ts +336 -0
  1094. package/src/archive/piglobal.ts +174 -0
  1095. package/src/archive/plan.ts +612 -0
  1096. package/src/archive/prarchive.ts +984 -0
  1097. package/src/archive/prfiles.ts +406 -0
  1098. package/src/archive/report.ts +252 -0
  1099. package/src/archive/restore.ts +330 -0
  1100. package/src/archive/rows.ts +187 -0
  1101. package/src/archive/seal.ts +630 -0
  1102. package/src/archive/status.ts +719 -0
  1103. package/src/archive/storepass.ts +581 -0
  1104. package/src/backfill.ts +362 -0
  1105. package/src/bench/cost.ts +113 -0
  1106. package/src/bench/exec.ts +90 -0
  1107. package/src/bench/values.ts +13 -0
  1108. package/src/botcontrol.ts +176 -0
  1109. package/src/botworker.ts +953 -0
  1110. package/src/capture.ts +321 -0
  1111. package/src/cases.ts +1789 -0
  1112. package/src/channelfs.ts +26 -0
  1113. package/src/checklist.ts +506 -0
  1114. package/src/ciretry.ts +141 -0
  1115. package/src/cli.ts +2271 -0
  1116. package/src/cliops.ts +384 -0
  1117. package/src/clonedir.ts +15 -0
  1118. package/src/commandeffects.ts +57 -0
  1119. package/src/commands.ts +1678 -0
  1120. package/src/companioninbox.ts +239 -0
  1121. package/src/compare.ts +957 -0
  1122. package/src/config.ts +2050 -0
  1123. package/src/configcheck.ts +133 -0
  1124. package/src/coreext.ts +47 -0
  1125. package/src/daemon.ts +1302 -0
  1126. package/src/debuground.ts +927 -0
  1127. package/src/debugroundpi.ts +262 -0
  1128. package/src/diagnostics.ts +14 -0
  1129. package/src/digest.ts +183 -0
  1130. package/src/dispatch.ts +2250 -0
  1131. package/src/engine/answerguard.ts +27 -0
  1132. package/src/engine/botsdk.ts +336 -0
  1133. package/src/engine/caserunner-sdk.ts +423 -0
  1134. package/src/engine/debugpi.ts +235 -0
  1135. package/src/eod.ts +669 -0
  1136. package/src/eodpinwatch.ts +556 -0
  1137. package/src/escalation.ts +1253 -0
  1138. package/src/eventloop.ts +1045 -0
  1139. package/src/execboundary.ts +633 -0
  1140. package/src/feedback.ts +1162 -0
  1141. package/src/findingscheck.ts +298 -0
  1142. package/src/fsdenied.ts +24 -0
  1143. package/src/ghread.ts +242 -0
  1144. package/src/github.ts +1497 -0
  1145. package/src/homes.ts +175 -0
  1146. package/src/hostroute.ts +53 -0
  1147. package/src/improvements.ts +787 -0
  1148. package/src/install.ts +1103 -0
  1149. package/src/installkind.ts +50 -0
  1150. package/src/ipc/capability.ts +333 -0
  1151. package/src/ipc/client.ts +269 -0
  1152. package/src/ipc/control.ts +90 -0
  1153. package/src/ipc/ghreadshim.ts +94 -0
  1154. package/src/ipc/launcher.ts +65 -0
  1155. package/src/ipc/permits.ts +362 -0
  1156. package/src/ipc/postshim.ts +164 -0
  1157. package/src/ipc/protocol.ts +342 -0
  1158. package/src/ipc/server.ts +371 -0
  1159. package/src/ipc/workerspec.ts +218 -0
  1160. package/src/learner/learner.ts +1655 -0
  1161. package/src/learner/miner.ts +1290 -0
  1162. package/src/learner/packet.ts +340 -0
  1163. package/src/learner/receipt.ts +168 -0
  1164. package/src/learner/status.ts +107 -0
  1165. package/src/ledgercheck.ts +157 -0
  1166. package/src/lessonledger.ts +703 -0
  1167. package/src/lessonselect.ts +888 -0
  1168. package/src/lessonshadow.ts +810 -0
  1169. package/src/levers.ts +393 -0
  1170. package/src/live/projection.ts +491 -0
  1171. package/src/live/publish.ts +92 -0
  1172. package/src/live/read.ts +86 -0
  1173. package/src/livecli.ts +336 -0
  1174. package/src/loginhint.ts +56 -0
  1175. package/src/loginrefresh/atomic.ts +76 -0
  1176. package/src/loginrefresh/lock.ts +104 -0
  1177. package/src/loginrefresh/main.ts +59 -0
  1178. package/src/loginrefresh/pirefresh.ts +57 -0
  1179. package/src/loginrefresh/refresher.ts +326 -0
  1180. package/src/loginrefresh/supervisor.ts +273 -0
  1181. package/src/loginrefresh/types.ts +35 -0
  1182. package/src/memory/context.ts +385 -0
  1183. package/src/memory/datafence.ts +9 -0
  1184. package/src/memory/delivery.ts +171 -0
  1185. package/src/memory/eventtags.ts +80 -0
  1186. package/src/memory/health.ts +953 -0
  1187. package/src/memory/import.ts +664 -0
  1188. package/src/memory/layout.ts +261 -0
  1189. package/src/memory/legacy-file.ts +104 -0
  1190. package/src/memory/lessons.ts +447 -0
  1191. package/src/memory/ovsession.ts +601 -0
  1192. package/src/memory/relayout.ts +335 -0
  1193. package/src/memory/roundactivity.ts +209 -0
  1194. package/src/memory/selection.ts +989 -0
  1195. package/src/memory/shadowstatus.ts +125 -0
  1196. package/src/memory/store.ts +275 -0
  1197. package/src/memory/textidentity.ts +8 -0
  1198. package/src/metrics.ts +1056 -0
  1199. package/src/missingconfig.ts +7 -0
  1200. package/src/npmverbs.ts +264 -0
  1201. package/src/onboard/choices.ts +222 -0
  1202. package/src/onboard/engine.ts +518 -0
  1203. package/src/onboard/launch.ts +115 -0
  1204. package/src/onboard/steps/appslug.ts +265 -0
  1205. package/src/onboard/steps/bringup.ts +482 -0
  1206. package/src/onboard/steps/compose.ts +459 -0
  1207. package/src/onboard/steps/configdelivery.ts +58 -0
  1208. package/src/onboard/steps/configfill.ts +1352 -0
  1209. package/src/onboard/steps/deploysh.ts +105 -0
  1210. package/src/onboard/steps/embeddings.ts +550 -0
  1211. package/src/onboard/steps/escalation.ts +640 -0
  1212. package/src/onboard/steps/githubapp.ts +1414 -0
  1213. package/src/onboard/steps/golive.ts +577 -0
  1214. package/src/onboard/steps/index.ts +71 -0
  1215. package/src/onboard/steps/ingress.ts +907 -0
  1216. package/src/onboard/steps/inventory.ts +526 -0
  1217. package/src/onboard/steps/llmauth.ts +1095 -0
  1218. package/src/onboard/steps/memory.ts +1290 -0
  1219. package/src/onboard/steps/memorykeys.ts +85 -0
  1220. package/src/onboard/steps/preflight.ts +191 -0
  1221. package/src/onboard/steps/proof.ts +624 -0
  1222. package/src/onboard/steps/ssh.ts +377 -0
  1223. package/src/onboard/steps/topology.ts +337 -0
  1224. package/src/onboard/steps/transport.ts +665 -0
  1225. package/src/onboard/steps/verifyup.ts +448 -0
  1226. package/src/onboard/steps/vmdeploy.ts +442 -0
  1227. package/src/onboard/toml.ts +50 -0
  1228. package/src/opencli.ts +591 -0
  1229. package/src/openpr.ts +568 -0
  1230. package/src/openviking/client.ts +1131 -0
  1231. package/src/openviking/context.ts +263 -0
  1232. package/src/openviking/embedder.ts +516 -0
  1233. package/src/openviking/gapsink.ts +63 -0
  1234. package/src/openviking/health.ts +83 -0
  1235. package/src/openviking/index.ts +119 -0
  1236. package/src/openviking/lessonfind.ts +92 -0
  1237. package/src/openviking/probe.ts +129 -0
  1238. package/src/openviking/proc.ts +110 -0
  1239. package/src/openviking/provisionreport.ts +160 -0
  1240. package/src/openviking/reembed.ts +412 -0
  1241. package/src/openviking/relayout.ts +177 -0
  1242. package/src/openviking/sidecar-main.ts +38 -0
  1243. package/src/openviking/sidecar.ts +106 -0
  1244. package/src/openviking/store.ts +2025 -0
  1245. package/src/openviking/supervisor.ts +474 -0
  1246. package/src/openviking/types.ts +174 -0
  1247. package/src/operator-recovery.ts +237 -0
  1248. package/src/operatormessage.ts +52 -0
  1249. package/src/outcomes.ts +1670 -0
  1250. package/src/piauthcopy.ts +12 -0
  1251. package/src/picli.ts +49 -0
  1252. package/src/picwd.ts +25 -0
  1253. package/src/pilogin.ts +37 -0
  1254. package/src/pingpong.ts +1339 -0
  1255. package/src/posting-journal.ts +549 -0
  1256. package/src/posting-outbox.ts +802 -0
  1257. package/src/posting-receipt.ts +324 -0
  1258. package/src/posting.ts +603 -0
  1259. package/src/postops.ts +832 -0
  1260. package/src/pr-activity.ts +20 -0
  1261. package/src/pr-author.ts +6 -0
  1262. package/src/pr-conversation.ts +432 -0
  1263. package/src/pr-session-lifecycle.ts +631 -0
  1264. package/src/preflight.ts +391 -0
  1265. package/src/priorresolve.ts +343 -0
  1266. package/src/qcrbin.ts +186 -0
  1267. package/src/recheck.ts +1775 -0
  1268. package/src/reconcile.ts +1030 -0
  1269. package/src/recordedfindings.ts +27 -0
  1270. package/src/regression.ts +833 -0
  1271. package/src/replay.ts +816 -0
  1272. package/src/requiredpasses.ts +148 -0
  1273. package/src/resumecheck.ts +246 -0
  1274. package/src/reviewcli.ts +787 -0
  1275. package/src/reviewcontext.ts +35 -0
  1276. package/src/reviewdetach.ts +734 -0
  1277. package/src/reviewhistory.ts +427 -0
  1278. package/src/reviewhome.ts +36 -0
  1279. package/src/reviewmemory.ts +133 -0
  1280. package/src/reviewrendercli.ts +139 -0
  1281. package/src/round-work.ts +42 -0
  1282. package/src/runhost.ts +108 -0
  1283. package/src/scanauth.ts +36 -0
  1284. package/src/scanbox.ts +1081 -0
  1285. package/src/scancli.ts +1296 -0
  1286. package/src/scandriverlog.ts +32 -0
  1287. package/src/session-bridge.ts +139 -0
  1288. package/src/session-command-payload.ts +35 -0
  1289. package/src/session-command-supervisor.ts +1142 -0
  1290. package/src/sessionjsonl.ts +88 -0
  1291. package/src/sessions.ts +659 -0
  1292. package/src/setup/adapters.ts +335 -0
  1293. package/src/setup/baselines.ts +110 -0
  1294. package/src/setup/caserunner.ts +611 -0
  1295. package/src/setup/configcommit.ts +443 -0
  1296. package/src/setup/engine.ts +1001 -0
  1297. package/src/setup/facts.ts +627 -0
  1298. package/src/setup/findings.ts +1264 -0
  1299. package/src/setup/history.ts +443 -0
  1300. package/src/setup/interview.ts +243 -0
  1301. package/src/setup/ledger.ts +324 -0
  1302. package/src/setup/pins.ts +66 -0
  1303. package/src/setup/portrait.ts +635 -0
  1304. package/src/setup/presentation.ts +18 -0
  1305. package/src/setup/profile.ts +508 -0
  1306. package/src/setup/replay.ts +370 -0
  1307. package/src/setup/replaybuild.ts +392 -0
  1308. package/src/setup/replayrun.ts +1096 -0
  1309. package/src/setup/replaysource.ts +198 -0
  1310. package/src/setup/retune.ts +415 -0
  1311. package/src/setup/retunelog.ts +95 -0
  1312. package/src/setup/scan.ts +1033 -0
  1313. package/src/setup/scanbuild.ts +168 -0
  1314. package/src/setup/scanfacts.ts +107 -0
  1315. package/src/setup/scanlease.ts +297 -0
  1316. package/src/setup/scanreport.ts +373 -0
  1317. package/src/setup/scanrun.ts +2268 -0
  1318. package/src/setup/scantree.ts +259 -0
  1319. package/src/setup/stage2.ts +926 -0
  1320. package/src/setup/stage2request.ts +347 -0
  1321. package/src/setup/survey.ts +458 -0
  1322. package/src/setup/surveyrun.ts +758 -0
  1323. package/src/setup/textsniff.ts +95 -0
  1324. package/src/setup/trackersink.ts +433 -0
  1325. package/src/setup/triage.ts +768 -0
  1326. package/src/setup/tuning.ts +766 -0
  1327. package/src/setup/unitcut.ts +523 -0
  1328. package/src/singleflight.ts +86 -0
  1329. package/src/slack/announce.ts +911 -0
  1330. package/src/slack/api.ts +303 -0
  1331. package/src/slack/messages.ts +288 -0
  1332. package/src/slack/origin.ts +599 -0
  1333. package/src/slack/policy.ts +354 -0
  1334. package/src/slack/reply.ts +201 -0
  1335. package/src/slack/router.ts +285 -0
  1336. package/src/slack/socket.ts +384 -0
  1337. package/src/slack/types.ts +108 -0
  1338. package/src/sqlitehint.ts +32 -0
  1339. package/src/ssh-observation.ts +30 -0
  1340. package/src/state.ts +8243 -0
  1341. package/src/statemachine.ts +369 -0
  1342. package/src/stubs.ts +68 -0
  1343. package/src/takeover.ts +634 -0
  1344. package/src/takeoverpi.ts +293 -0
  1345. package/src/terminal-failure.ts +41 -0
  1346. package/src/thread-resolution-refusal.ts +2 -0
  1347. package/src/tmux.ts +153 -0
  1348. package/src/trackerid.ts +41 -0
  1349. package/src/transcriptindex.ts +665 -0
  1350. package/src/tui/actions.ts +484 -0
  1351. package/src/tui/alerts.ts +666 -0
  1352. package/src/tui/app.ts +1979 -0
  1353. package/src/tui/archiveops.ts +192 -0
  1354. package/src/tui/autofile.ts +233 -0
  1355. package/src/tui/board.ts +1432 -0
  1356. package/src/tui/candidates.ts +889 -0
  1357. package/src/tui/cellwidth.ts +148 -0
  1358. package/src/tui/changelog-store.ts +503 -0
  1359. package/src/tui/chord.ts +25 -0
  1360. package/src/tui/clipboard.ts +92 -0
  1361. package/src/tui/cmdbar.ts +296 -0
  1362. package/src/tui/companion.ts +1427 -0
  1363. package/src/tui/companionplan.ts +247 -0
  1364. package/src/tui/companiontab.ts +83 -0
  1365. package/src/tui/confighome.ts +155 -0
  1366. package/src/tui/connect.ts +68 -0
  1367. package/src/tui/deploy-ceremony.ts +104 -0
  1368. package/src/tui/deploy-drain.ts +129 -0
  1369. package/src/tui/deploy-process.ts +265 -0
  1370. package/src/tui/deploytab.ts +1523 -0
  1371. package/src/tui/design.ts +125 -0
  1372. package/src/tui/digesttab.ts +214 -0
  1373. package/src/tui/emptystate.ts +39 -0
  1374. package/src/tui/evidence.ts +999 -0
  1375. package/src/tui/feed.ts +604 -0
  1376. package/src/tui/feedbacktab.ts +1077 -0
  1377. package/src/tui/filters.ts +90 -0
  1378. package/src/tui/ghread.ts +321 -0
  1379. package/src/tui/historypull.ts +81 -0
  1380. package/src/tui/inboxpull.ts +236 -0
  1381. package/src/tui/index.ts +1565 -0
  1382. package/src/tui/issuesink.ts +238 -0
  1383. package/src/tui/jsonfile.ts +42 -0
  1384. package/src/tui/keys.ts +109 -0
  1385. package/src/tui/layout.ts +108 -0
  1386. package/src/tui/learning.ts +627 -0
  1387. package/src/tui/lessonswitch.ts +118 -0
  1388. package/src/tui/livecontrol.ts +228 -0
  1389. package/src/tui/memforward.ts +443 -0
  1390. package/src/tui/memincident.ts +363 -0
  1391. package/src/tui/memory.ts +620 -0
  1392. package/src/tui/memtab.ts +606 -0
  1393. package/src/tui/metricstab.ts +1177 -0
  1394. package/src/tui/model.ts +131 -0
  1395. package/src/tui/mouse.ts +98 -0
  1396. package/src/tui/msg.ts +268 -0
  1397. package/src/tui/nativecompanion.ts +417 -0
  1398. package/src/tui/openurl.ts +21 -0
  1399. package/src/tui/paint.ts +511 -0
  1400. package/src/tui/panes.ts +98 -0
  1401. package/src/tui/pipane.ts +219 -0
  1402. package/src/tui/poll-hooks.ts +12 -0
  1403. package/src/tui/poll.ts +147 -0
  1404. package/src/tui/proposals.ts +838 -0
  1405. package/src/tui/prpause.ts +150 -0
  1406. package/src/tui/reload.ts +436 -0
  1407. package/src/tui/remote.ts +413 -0
  1408. package/src/tui/rollover.ts +969 -0
  1409. package/src/tui/scantab.ts +1290 -0
  1410. package/src/tui/setupentry.ts +93 -0
  1411. package/src/tui/sidebar.ts +749 -0
  1412. package/src/tui/sshtriage.ts +510 -0
  1413. package/src/tui/startup.ts +231 -0
  1414. package/src/tui/tabs.ts +45 -0
  1415. package/src/tui/termmirror.ts +224 -0
  1416. package/src/tui/theme.ts +95 -0
  1417. package/src/tui/ticketstab.ts +310 -0
  1418. package/src/tui/tmuxsocket.ts +129 -0
  1419. package/src/tui/trackertickets.ts +84 -0
  1420. package/src/tui/unwedge.ts +286 -0
  1421. package/src/tui/viewer.ts +575 -0
  1422. package/src/tui/viewerinput.ts +173 -0
  1423. package/src/updatenotice.ts +178 -0
  1424. package/src/usage.ts +1154 -0
  1425. package/src/verdicts.ts +4689 -0
  1426. package/src/version.ts +80 -0
  1427. package/src/wakes.ts +476 -0
  1428. package/src/webhook.ts +942 -0
  1429. package/src/webhookheal.ts +276 -0
  1430. package/src/wire.ts +2153 -0
  1431. package/src/worker-session-host.ts +602 -0
  1432. package/src/workerbreaker.ts +156 -0
  1433. package/src/workercontrol.ts +104 -0
  1434. package/src/workerlease.ts +441 -0
  1435. package/src/workerliveness.ts +103 -0
  1436. package/src/workerlog.ts +79 -0
  1437. package/src/workerruntime.ts +30 -0
  1438. package/src/workersupervisor.ts +1538 -0
  1439. package/src/workerusage.ts +42 -0
  1440. package/src/workerworld.ts +427 -0
  1441. package/src/worktrees.ts +1138 -0
  1442. package/tests/fixtures/regression-cases.json +795 -0
  1443. package/tests/fixtures/seed-manifest.json +19 -0
@@ -0,0 +1,1966 @@
1
+ #!/usr/bin/env node
2
+ // The companion's memory forward — host-owned, TUI-independent.
3
+ //
4
+ // WHY THIS EXISTS
5
+ // ---------------
6
+ // The local pi companion must read and write memory that lives on the review VM,
7
+ // while holding no VM credential (the companion boundary). So the ssh runs
8
+ // here, on the operator's PC, as a systemd --user service the operator owns:
9
+ //
10
+ // companion container ──▶ 198.51.100.1:1933 (docker bridge `qcr-memory`)
11
+ // │ this process, on the host
12
+ // ▼
13
+ // ssh <vm-host> ──▶ sudo docker exec -i quickreview-memory
14
+ // ▼
15
+ // node stdio↔TCP bridge ──▶ 127.0.0.1:1933 (OpenViking)
16
+ //
17
+ // The target is the memory SIDECAR (docker/memory/): once the VM runs
18
+ // `[openviking] endpoint = "remote"` the server lives in that container, not
19
+ // in the daemon's. Rolling the VM back to `endpoint = "local"` moves OpenViking
20
+ // back into quickreview-sandbox, and the forward follows with
21
+ // `--container quickreview-sandbox` (or QCR_VM_CONTAINER).
22
+ //
23
+ // Two properties make it worth the machinery:
24
+ //
25
+ // * the container never sees an ssh key, an agent socket or a host known_hosts
26
+ // file — it sees one TCP endpoint on a bridge, and nothing else;
27
+ // * nothing on the VM is published. OpenViking's port is reachable only on
28
+ // the sidecar's container network and its own loopback; the last hop is a
29
+ // `docker exec` inside the container's netns, so no port is opened on the
30
+ // VM, on the VM's docker bridge, or anywhere reachable from the network.
31
+ //
32
+ // It is deliberately not part of the TUI: `qcr tui` starting and stopping is
33
+ // not an event memory should notice. systemd owns the process (Restart=always,
34
+ // enabled at login, `loginctl enable-linger` for boot), so the companion keeps
35
+ // its memory while the TUI is closed — which is most of the day.
36
+ //
37
+ // LOCAL MODE. On a one-machine (laptop) topology the daemon
38
+ // container runs on this host, so `--local` (or QCR_MEMORY_LOCAL=1, or an
39
+ // explicit empty --ssh-host) replaces the ssh hop with a plain local
40
+ // `docker exec` into the same container netns. Everything else — the
41
+ // `qcr-memory` bridge + loopback binds, the listener contract, `verify`,
42
+ // adopt-or-bind from the TUI — is identical: this file stays the one relay
43
+ // implementation for both topologies.
44
+ //
45
+ // RELAY CAP. Every relay is one ssh session on the forward's one
46
+ // shared connection, and sshd refuses sessions past its MaxSessions (10) —
47
+ // each refused relay then quietly opens a full ssh login of its own. So at
48
+ // most `--max-relays` (6) relays are open at once; a request beyond that
49
+ // waits its turn, a relay between requests hands its slot over, and a request
50
+ // that waits too long gets an honest 503 — see "the relay cap" below. The
51
+ // systemd unit and the TUI both parse through `parseArgs`, so both carry it.
52
+ //
53
+ // SUBCOMMANDS
54
+ // run the forwarder itself (what the systemd unit executes)
55
+ // install create the docker network + write/enable the user unit
56
+ // uninstall stop + disable the unit (network and keys left alone)
57
+ // status unit state, network, endpoint, the relay cap and the
58
+ // running forward's live counts, one live probe
59
+ // ensure-network idempotent docker network create (the unit's ExecStartPre)
60
+ // verify the two-key reach + confinement proof (see docs/)
61
+ //
62
+ // Zero dependencies, no repo build needed: systemd must be able to run this
63
+ // from a bare checkout at boot.
64
+ import { spawn, spawnSync } from "node:child_process";
65
+ import {
66
+ existsSync,
67
+ mkdirSync,
68
+ readFileSync,
69
+ realpathSync,
70
+ renameSync,
71
+ unlinkSync,
72
+ writeFileSync,
73
+ } from "node:fs";
74
+ import { createServer } from "node:net";
75
+ import { homedir } from "node:os";
76
+ import { dirname, isAbsolute, join, resolve } from "node:path";
77
+ import { fileURLToPath, pathToFileURL } from "node:url";
78
+
79
+ const HERE = dirname(fileURLToPath(import.meta.url));
80
+ export const ROOT = resolve(HERE, "..");
81
+
82
+ // ---- defaults ---------------------------------------------------------------
83
+ // These mirror src/tui/companion.ts MEMORY.* — the forward and the container
84
+ // must agree on network, subnet and port or the companion talks to nothing.
85
+ // tests/companion-memory.test.ts fails on drift.
86
+
87
+ export const DEFAULTS = {
88
+ network: "qcr-memory",
89
+ subnet: "198.51.100.0/24",
90
+ gateway: "198.51.100.1",
91
+ port: 1933,
92
+ sshHost: "my-vm",
93
+ // The memory sidecar (deploy/ansible `memory_container_name`). Under
94
+ // `[openviking] endpoint = "local"` OpenViking is the daemon container's
95
+ // child instead: pass `--container quickreview-sandbox` / QCR_VM_CONTAINER.
96
+ container: "quickreview-memory",
97
+ // `--local` (a one-machine topology): the sidecar is opt-in there, so the
98
+ // default stays the daemon container until `--container` names another.
99
+ localContainer: "quickreview-sandbox",
100
+ remoteHost: "127.0.0.1",
101
+ remotePort: 1933,
102
+ sudo: true,
103
+ unit: "qcr-memory-forward",
104
+ // The relay cap. Every relay is one ssh session on the
105
+ // forward's one shared connection, and sshd refuses the session past its
106
+ // `MaxSessions` (10 on the VM, the OpenSSH default). 6 leaves room for
107
+ // relays still closing and for anything else that shares the ControlPath.
108
+ maxRelays: 6,
109
+ /** Requests that may wait for a relay; one more is refused at once. */
110
+ queueMax: 64,
111
+ /** How long one request may wait for a relay before it is refused. */
112
+ queueTimeoutS: 15,
113
+ };
114
+
115
+ /** OpenSSH's default sshd `MaxSessions`: session channels per connection.
116
+ * A stock VM runs it (`sshd -T | grep maxsessions` → 10). */
117
+ export const SSHD_DEFAULT_MAX_SESSIONS = 10;
118
+
119
+ /**
120
+ * The forward's own ssh control socket. Its relays multiplex
121
+ * over one master connection, and every relay is a session on it; a path of
122
+ * its own keeps the operator's interactive ssh — whose `~/.ssh/config`
123
+ * ControlPath is a different file — off that connection's session budget.
124
+ * `%C` (a hash of the local host, remote host, port and user) keeps the path
125
+ * short enough for a unix socket, and a name no stock ssh config shares.
126
+ */
127
+ export function defaultControlPath(home = homedir()) {
128
+ return join(home, ".ssh", "qcr-memfwd-%C");
129
+ }
130
+
131
+ /**
132
+ * The keys file (0600, two user keys) — the install locator's rule
133
+ * (`locateKeysFile` in src/install.ts), the same path
134
+ * src/tui/companion.ts reads: `QCR_OPENVIKING_KEYS`, else the operator home
135
+ * `$XDG_CONFIG_HOME/qcr-operator/<install>/` (the old
136
+ * `~/.config/quickreview-bot/` copy is never read). Mirrored rather than
137
+ * imported because this script runs from a checkout with no dist/;
138
+ * tests/companion-memory.test.ts runs both over the same temp homes.
139
+ */
140
+ export function keysFilePath(env = process.env, home = homedir()) {
141
+ const override = (env.QCR_OPENVIKING_KEYS ?? "").trim();
142
+ if (override !== "") return override;
143
+ const install = (env.QCR_INSTALL ?? "").trim() || "default";
144
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(install) || install.includes("..")) {
145
+ throw new Error(`QCR_INSTALL ${JSON.stringify(install)} is not a plain name`);
146
+ }
147
+ const xdg = (env.XDG_CONFIG_HOME ?? "").trim();
148
+ const base = xdg !== "" && isAbsolute(xdg) ? xdg : join(home, ".config");
149
+ return join(base, "qcr-operator", install, "openviking-keys.env");
150
+ }
151
+
152
+ // ---- the remote half --------------------------------------------------------
153
+
154
+ /**
155
+ * The stdio↔TCP bridge that runs INSIDE the VM container. It is a node
156
+ * one-liner because node is the one interpreter the quickreview image is
157
+ * guaranteed to have (it runs pi); `nc` is not installed there.
158
+ *
159
+ * Single quotes are forbidden in this string: the whole program is passed to
160
+ * the remote shell inside single quotes.
161
+ */
162
+ export function remoteBridgeJs(host, port) {
163
+ if (/'/.test(host)) throw new Error("remote host must not contain a quote");
164
+ return (
165
+ `const n=require("net");const s=n.connect(${Number(port)},"${host}");` +
166
+ `s.on("error",()=>process.exit(1));s.on("close",()=>process.exit(0));` +
167
+ `process.stdin.on("end",()=>s.end());process.stdin.pipe(s);s.pipe(process.stdout);`
168
+ );
169
+ }
170
+
171
+ /** `ssh …` argv for ONE relayed connection. Pure — the test asserts it. */
172
+ export function relayArgv(cfg) {
173
+ const docker = cfg.sudo ? "sudo docker" : "docker";
174
+ const remote = `${docker} exec -i ${cfg.container} node -e '${remoteBridgeJs(cfg.remoteHost, cfg.remotePort)}'`;
175
+ return [
176
+ "-q",
177
+ "-T",
178
+ "-o",
179
+ "BatchMode=yes",
180
+ "-o",
181
+ "ConnectTimeout=10",
182
+ "-o",
183
+ "ServerAliveInterval=30",
184
+ "-o",
185
+ "ServerAliveCountMax=3",
186
+ // A shared control connection turns a per-request ssh handshake into a
187
+ // channel open on an existing socket. Without it every HTTP connection
188
+ // would pay a full login. Each relay is one session on that connection,
189
+ // and sshd caps sessions per connection (`MaxSessions`) — which is what
190
+ // `createForwarder`'s relay cap stays under.
191
+ "-o",
192
+ "ControlMaster=auto",
193
+ "-o",
194
+ `ControlPath=${cfg.controlPath}`,
195
+ "-o",
196
+ "ControlPersist=4h",
197
+ ...(cfg.identity ? ["-i", cfg.identity] : []),
198
+ cfg.sshHost,
199
+ remote,
200
+ ];
201
+ }
202
+
203
+ // ---- repeat-failure collapse -------------------------------------------------
204
+
205
+ /**
206
+ * The likely cause of a relay failure, in the operator's terms, with the exact
207
+ * command that fixes it — the sentence a bare `ssh` exit 255 never says.
208
+ */
209
+ export function relayFailureCause(cfg, text, deps = {}) {
210
+ const s = String(text ?? "");
211
+ // The docker check comes first and is not interchangeable with the ssh one:
212
+ // "permission denied while trying to connect to the Docker daemon" also
213
+ // matches /permission denied/, and answering it with `ssh-add` would send the
214
+ // operator hunting for a key problem that does not exist.
215
+ if (
216
+ /permission denied while trying to connect to the docker daemon|cannot connect to the docker daemon|docker: command not found/i.test(
217
+ s,
218
+ )
219
+ ) {
220
+ // …and the docker in question is the VM's, not this laptop's: the relay is
221
+ // `ssh <host> sudo docker exec …`, so a local `systemctl status docker`
222
+ // would point at the wrong machine. With `sudo docker` the usual fault is
223
+ // the sudo/group grant, not the unit being down.
224
+ const where = cfg.local ? "the local docker daemon" : `docker ON ${cfg.sshHost}`;
225
+ return {
226
+ key: "docker",
227
+ cause: `${where} refused the exec`,
228
+ remedy: cfg.local ? "docker ps" : `ssh ${cfg.sshHost} ${cfg.sudo ? "sudo " : ""}docker ps`,
229
+ };
230
+ }
231
+ if (/permission denied|publickey|too many authentication failures|agent refused/i.test(s)) {
232
+ // The key, derived the same way `src/tui/sshtriage.ts` derives it — from
233
+ // `ssh -G <host>`, never guessed: a hard-coded path would make the remedy
234
+ // a confidently wrong exact command.
235
+ const key = cfg.identity ?? sshIdentityFor(cfg, deps);
236
+ return {
237
+ key: "auth",
238
+ cause: `ssh to ${cfg.sshHost} has no usable key — the agent is empty or does not hold this host's key`,
239
+ remedy: `ssh-add ${key}`,
240
+ };
241
+ }
242
+ if (
243
+ /could not resolve|name or service not known|no route to host|network is unreachable|connection timed out|operation timed out/i.test(
244
+ s,
245
+ )
246
+ ) {
247
+ return {
248
+ key: "host",
249
+ cause: `${cfg.sshHost} did not answer`,
250
+ remedy: `ssh -v ${cfg.sshHost} true`,
251
+ };
252
+ }
253
+ // There is deliberately no cause for sshd refusing a session past its
254
+ // MaxSessions: the mux client does not fail on that —
255
+ // it gives up on the shared connection and opens a full ssh login of its
256
+ // own, and under `-q` it says neither. Nothing reaches stderr to match; the
257
+ // relay cap is what keeps relays inside the shared connection.
258
+ if (/control socket|mux_client|multiplexing/i.test(s)) {
259
+ // The forward's master lives at its own ControlPath, so a bare
260
+ // `ssh -O exit <host>` would address the operator's master (the one in
261
+ // ~/.ssh/config) and leave the wedged one standing.
262
+ return {
263
+ key: "mux",
264
+ cause: `the forward's own ssh ControlMaster for ${cfg.sshHost}${cfg.controlPath ? ` (${cfg.controlPath})` : ""} is wedged`,
265
+ remedy: cfg.controlPath
266
+ ? `ssh -o 'ControlPath=${cfg.controlPath}' -O exit ${cfg.sshHost}`
267
+ : `ssh -O exit ${cfg.sshHost}`,
268
+ };
269
+ }
270
+ return {
271
+ key: "unknown",
272
+ cause: cfg.local
273
+ ? `the local relay to ${cfg.container} exited without connecting`
274
+ : `the relay to ${cfg.container} on ${cfg.sshHost} exited without connecting`,
275
+ remedy: cfg.local ? "docker ps" : `ssh -v ${cfg.sshHost} true`,
276
+ };
277
+ }
278
+
279
+ /** Cache: `ssh -G` is local and cheap, but this runs on a failure path that can
280
+ * fire once per connection. */
281
+ let identityCache;
282
+
283
+ /**
284
+ * The key ssh would actually offer for this host, from `ssh -G` — the same
285
+ * source `src/tui/sshtriage.ts` uses. Local only (no network). Falls back to
286
+ * the conventional GCP metadata key when ssh tells us nothing, and is never
287
+ * consulted in `--local` mode, where there is no ssh at all.
288
+ */
289
+ export function sshIdentityFor(cfg, deps = {}) {
290
+ if (cfg.local) return "~/.ssh/id_ed25519";
291
+ if (identityCache !== undefined && deps.run === undefined) return identityCache;
292
+ const runner = deps.run ?? ((bin, args) => run(bin, args));
293
+ const home = deps.home ?? homedir();
294
+ const fileExists = deps.exists ?? existsSync;
295
+ let picked = "";
296
+ try {
297
+ const res = runner("ssh", ["-G", cfg.sshHost]);
298
+ const files = String(res.stdout ?? "")
299
+ .split("\n")
300
+ .map((l) => l.trim())
301
+ .filter((l) => /^identityfile /i.test(l))
302
+ .map((l) => l.slice("identityfile ".length).trim())
303
+ .map((f) => (f.startsWith("~/") ? join(home, f.slice(2)) : f));
304
+ picked = files.find((f) => fileExists(f)) ?? files[0] ?? "";
305
+ } catch {
306
+ picked = "";
307
+ }
308
+ const answer = picked || join(home, ".ssh", "google_compute_engine");
309
+ if (deps.run === undefined) identityCache = answer;
310
+ return answer;
311
+ }
312
+
313
+ /** How long one cause stays collapsed before it is worth saying again. */
314
+ export const FAILURE_REPEAT_MS = 60_000;
315
+
316
+ /**
317
+ * Collapses a repeating relay failure into one actionable line, rate-limited,
318
+ * and says so out loud when it recovers.
319
+ *
320
+ * Every HTTP connection the companion or the TUI makes can spawn a relay, so
321
+ * one missing agent key would otherwise print thousands of `relay #N exited
322
+ * 255` lines — a symptom, repeated, with no cause and no remedy. One line per
323
+ * cause per window, a count of what was suppressed, and a `recovered` line
324
+ * that proves the self-healing actually happened: nothing here needs a restart, the next
325
+ * successful relay clears the state on its own.
326
+ *
327
+ * @param {{ log?: (line: string) => void, now?: () => number, windowMs?: number }} [opts]
328
+ * `log` is declared here rather than defaulted on purpose: it has no
329
+ * sensible default (a collapser that swallows its own output is worse than
330
+ * one that throws), but without the annotation TypeScript infers the option
331
+ * bag from the defaulted keys only and `tsconfig.tests.json` then rejects
332
+ * every caller that passes it.
333
+ */
334
+ export function createFailureCollapser({
335
+ log,
336
+ now = () => Date.now(),
337
+ windowMs = FAILURE_REPEAT_MS,
338
+ } = {}) {
339
+ let key = null;
340
+ let lastAt = 0;
341
+ // Suppressed since the last printed line, and suppressed since the last
342
+ // recovery. Two counters because they answer different questions: with one,
343
+ // a cause change would reset it and a recovery could report "0 suppressed"
344
+ // after hiding several failures.
345
+ let suppressedSincePrint = 0;
346
+ let suppressedTotal = 0;
347
+ let total = 0;
348
+ const emit = (cause, t) => {
349
+ const hidden = suppressedSincePrint;
350
+ const sameCause = cause.key === key;
351
+ const ago = Math.round((t - lastAt) / 1000);
352
+ const repeat =
353
+ hidden > 0
354
+ ? ` (${hidden} similar failure${hidden === 1 ? "" : "s"} suppressed${sameCause ? ` in the last ${ago}s` : ""})`
355
+ : "";
356
+ key = cause.key;
357
+ lastAt = t;
358
+ suppressedSincePrint = 0;
359
+ log(`relay failing: ${cause.cause} — fix: ${cause.remedy}${repeat}`);
360
+ };
361
+ return {
362
+ fail(cause) {
363
+ total += 1;
364
+ const t = now();
365
+ if (cause.key === key && t - lastAt < windowMs) {
366
+ suppressedSincePrint += 1;
367
+ suppressedTotal += 1;
368
+ return;
369
+ }
370
+ emit(cause, t);
371
+ },
372
+ ok() {
373
+ if (key === null) return;
374
+ const hidden = suppressedTotal;
375
+ const seen = total;
376
+ key = null;
377
+ suppressedSincePrint = 0;
378
+ suppressedTotal = 0;
379
+ total = 0;
380
+ log(
381
+ `relay recovered — the forward is working again (${seen} failure${seen === 1 ? "" : "s"}, ${hidden} suppressed)`,
382
+ );
383
+ },
384
+ /** Test handle: what is currently collapsed. */
385
+ state: () => ({ key, suppressed: suppressedSincePrint, hidden: suppressedTotal, total }),
386
+ };
387
+ }
388
+
389
+ // ---- the forwarder ----------------------------------------------------------
390
+
391
+ /**
392
+ * Local relay argv (`--local`): the same stdio↔TCP bridge, reached by
393
+ * a plain `docker exec` on this machine — the daemon container runs here, so
394
+ * there is no ssh hop. The bridge program rides as a real argv element (no
395
+ * shell re-parse, so no quote wrapper); `--no-sudo` drops the sudo prefix.
396
+ */
397
+ export function localRelayArgv(cfg) {
398
+ return [
399
+ ...(cfg.sudo ? ["sudo"] : []),
400
+ "docker",
401
+ "exec",
402
+ "-i",
403
+ cfg.container,
404
+ "node",
405
+ "-e",
406
+ remoteBridgeJs(cfg.remoteHost, cfg.remotePort),
407
+ ];
408
+ }
409
+
410
+ /** The full relay command, binary first — the transport decision in one place.
411
+ * Pure; tests assert both shapes. */
412
+ export function relaySpawnCommand(cfg) {
413
+ return cfg.local ? localRelayArgv(cfg) : ["ssh", ...relayArgv(cfg)];
414
+ }
415
+
416
+ /** Spawn one relay child. Injectable so the test drives streams, not ssh. */
417
+ export function realRelaySpawner(cfg) {
418
+ const cmd = relaySpawnCommand(cfg);
419
+ return () => spawn(cmd[0], cmd.slice(1), { stdio: ["pipe", "pipe", "pipe"] });
420
+ }
421
+
422
+ // ---- the relay cap ----------------------------------------------------------
423
+ //
424
+ // WHY. Every relay — one `ssh … docker exec …` — is one session on the
425
+ // forward's one shared ssh connection. sshd allows `MaxSessions` (10) of
426
+ // those per connection and refuses the rest. A refused relay does not stop
427
+ // there: the mux client gives up on the shared connection and opens a full
428
+ // ssh login of its own through the bastion, saying nothing under `-q`, and on
429
+ // the VM those extra logins fail (docs/memory-forward-connection-research.md).
430
+ // Uncapped, a burst — the store's 8-way read sweep beside the TUI's polls and
431
+ // the companion — has its extra connections closed under it, and the callers
432
+ // see `fetch failed`.
433
+ //
434
+ // THE CAP. At most `maxRelays` relays are open at once, counted from spawn to
435
+ // the child's exit (a killed relay still holds its session until ssh is gone).
436
+ // A connection that has a request to send and no relay waits in a FIFO queue
437
+ // (`queueMax` deep); a request that waits `queueTimeoutMs` — or arrives at a
438
+ // full queue — gets an HTTP 503 in OpenViking's own error envelope and a clean
439
+ // close, never a hang and never a bare reset.
440
+ //
441
+ // WHY A QUEUE ALONE IS NOT ENOUGH. Every client here keeps its connections
442
+ // alive between requests (undici, browsers). A connection holding a relay and
443
+ // looping on it never closes, so a queue that only advanced on a close would
444
+ // starve whoever waited behind a busy sweep. So a relay yields: when its
445
+ // connection is between requests and somebody is waiting, the relay is
446
+ // stopped and its slot goes to the head of the queue; the connection itself
447
+ // stays open, and its next request waits its turn for a new relay. "Between
448
+ // requests" is read off the bytes by `createExchangeTracker` — a whole
449
+ // request, then a whole Content-Length response, and nothing since — never
450
+ // guessed from a quiet line. A connection that is not plain request/response
451
+ // HTTP/1.1 (a websocket, a chunked or close-delimited body, a pipelined
452
+ // request) is never stopped: it keeps its relay to the end.
453
+ //
454
+ // A relay that yields has served its connection for `minLeaseMs` (so a hot
455
+ // loop pays one relay start per second, not one per request), or has been
456
+ // quiet for `idleYieldMs` (an idle keep-alive connection, which may never send
457
+ // again). No bytes ever cross from one connection's relay to another's: a
458
+ // yielded relay is killed, and the next request starts a fresh one.
459
+
460
+ /** Internal timings. `opts.timings` overrides them (tests). */
461
+ export const RELAY_TIMINGS = {
462
+ /** A relay serving a busy connection yields at a request boundary only
463
+ * after this long — bounds the cost of fairness to one relay start a second. */
464
+ minLeaseMs: 1_000,
465
+ /** …or as soon as its connection has been idle (between requests) this long. */
466
+ idleYieldMs: 200,
467
+ /** SIGTERM → SIGKILL, and the slot is counted free at the SIGKILL. */
468
+ killGraceMs: 3_000,
469
+ /** While anyone waits, how often idle relays are looked for. */
470
+ rebalanceMs: 100,
471
+ /** A refused connection: how long its client gets to read the 503. */
472
+ refuseLingerMs: 2_000,
473
+ /** A relay that exited mid-exchange: how long to wait for its last bytes. */
474
+ relayCloseGraceMs: 1_000,
475
+ /** A waiting connection's held bytes past which its socket is paused. */
476
+ holdLimitBytes: 256 * 1024,
477
+ /** At most one stats-file write per this window. */
478
+ statsEveryMs: 500,
479
+ /** One "queueing" line per window, however many bursts. */
480
+ logWindowMs: 60_000,
481
+ };
482
+
483
+ /**
484
+ * The cap, the queue depth and the wait, off `cfg` (`parseArgs` fills all
485
+ * three — so the systemd unit and the TUI, which both parse through it, carry
486
+ * the same cap; a cfg built by hand falls back to the defaults).
487
+ */
488
+ export function forwardLimits(cfg) {
489
+ return {
490
+ maxRelays: cfg?.maxRelays ?? DEFAULTS.maxRelays,
491
+ queueMax: cfg?.queueMax ?? DEFAULTS.queueMax,
492
+ queueTimeoutMs: cfg?.queueTimeoutMs ?? DEFAULTS.queueTimeoutS * 1000,
493
+ };
494
+ }
495
+
496
+ /** Longest message head the tracker reads before it calls the stream opaque. */
497
+ const MAX_HEAD_BYTES = 64 * 1024;
498
+
499
+ /** `name: value` lines → lowercase name → values; null when malformed. */
500
+ function parseHeaderLines(lines) {
501
+ const headers = new Map();
502
+ for (const line of lines) {
503
+ const i = line.indexOf(":");
504
+ if (i <= 0 || /^[ \t]/.test(line)) return null; // obs-fold or garbage
505
+ const name = line.slice(0, i).trim().toLowerCase();
506
+ const list = headers.get(name) ?? [];
507
+ list.push(line.slice(i + 1).trim());
508
+ headers.set(name, list);
509
+ }
510
+ return headers;
511
+ }
512
+
513
+ /** A body length the bytes declare: a number, `undefined` when nothing is
514
+ * declared, `null` when it is chunked, malformed or contradictory. */
515
+ function declaredLength(headers) {
516
+ if (headers.has("transfer-encoding")) return null;
517
+ const values = headers.get("content-length");
518
+ if (!values) return undefined;
519
+ const all = new Set(values.flatMap((v) => v.split(",")).map((v) => v.trim()));
520
+ if (all.size !== 1) return null;
521
+ const [only] = [...all];
522
+ return /^\d{1,15}$/.test(only) ? Number(only) : null;
523
+ }
524
+
525
+ /**
526
+ * Where one connection's HTTP/1.1 exchange stands, read off its bytes — just
527
+ * enough to know when its relay is BETWEEN requests and may be handed over.
528
+ *
529
+ * It frames only what it can frame exactly: a request (Content-Length body or
530
+ * none) and its response (a Content-Length body; none for HEAD, 204, 304).
531
+ * Anything else — chunked or close-delimited bodies, a 1xx or 101 upgrade, an
532
+ * `Upgrade` or `Expect` request, a second request sent before the first
533
+ * response came back, a server speaking unasked, a head over 64 KiB — makes
534
+ * the stream OPAQUE for good, and an opaque connection is never stopped. The
535
+ * tracker never changes a byte; it only watches.
536
+ */
537
+ export function createExchangeTracker() {
538
+ /** idle | request-head | request-body | response-wait | response-head |
539
+ * response-body | opaque */
540
+ let phase = "idle";
541
+ let head = "";
542
+ let remaining = 0;
543
+ let method = "";
544
+ let exchanges = 0;
545
+
546
+ const opaque = () => {
547
+ phase = "opaque";
548
+ head = "";
549
+ };
550
+
551
+ /** Consume head bytes from `off`; the offset past the head once it is
552
+ * whole (after `onHead`), else the end of the buffer. */
553
+ const takeHead = (buf, off, onHead) => {
554
+ const window = buf.toString("latin1", off, Math.min(buf.length, off + MAX_HEAD_BYTES + 4));
555
+ const combined = head + window;
556
+ const end = combined.indexOf("\r\n\r\n", Math.max(0, head.length - 3));
557
+ if (end === -1) {
558
+ head = combined;
559
+ if (head.length > MAX_HEAD_BYTES) opaque();
560
+ return buf.length;
561
+ }
562
+ const consumed = end + 4 - head.length;
563
+ const text = combined.slice(0, end);
564
+ head = "";
565
+ onHead(text);
566
+ return off + consumed;
567
+ };
568
+
569
+ const startRequest = (text) => {
570
+ const lines = text.split("\r\n");
571
+ const m = /^([A-Z]+) \S+ HTTP\/1\.[01]$/.exec(lines[0] ?? "");
572
+ const headers = parseHeaderLines(lines.slice(1));
573
+ if (!m || !headers || headers.has("upgrade") || headers.has("expect")) return opaque();
574
+ const len = declaredLength(headers);
575
+ if (len === null) return opaque();
576
+ method = m[1];
577
+ remaining = len ?? 0; // a request that declares nothing has no body
578
+ phase = remaining > 0 ? "request-body" : "response-wait";
579
+ };
580
+
581
+ const startResponse = (text) => {
582
+ const lines = text.split("\r\n");
583
+ const m = /^HTTP\/1\.[01] (\d{3})(?: |$)/.exec(lines[0] ?? "");
584
+ const headers = parseHeaderLines(lines.slice(1));
585
+ if (!m || !headers) return opaque();
586
+ const status = Number(m[1]);
587
+ if (status < 200) return opaque(); // 1xx, 101 upgrade: not one request, one answer
588
+ let len = 0;
589
+ if (method !== "HEAD" && status !== 204 && status !== 304) {
590
+ const declared = declaredLength(headers);
591
+ // No length is a body that ends at the close: nothing marks its end.
592
+ if (declared === null || declared === undefined) return opaque();
593
+ len = declared;
594
+ }
595
+ remaining = len;
596
+ if (remaining > 0) phase = "response-body";
597
+ else {
598
+ phase = "idle";
599
+ exchanges += 1;
600
+ }
601
+ };
602
+
603
+ return {
604
+ /** Bytes the client sent. */
605
+ client(buf) {
606
+ let off = 0;
607
+ while (off < buf.length) {
608
+ if (phase === "opaque") return;
609
+ if (phase === "idle") {
610
+ phase = "request-head";
611
+ continue;
612
+ }
613
+ if (phase === "request-head") {
614
+ off = takeHead(buf, off, startRequest);
615
+ continue;
616
+ }
617
+ if (phase === "request-body") {
618
+ const n = Math.min(remaining, buf.length - off);
619
+ remaining -= n;
620
+ off += n;
621
+ if (remaining === 0) phase = "response-wait";
622
+ continue;
623
+ }
624
+ // Client bytes while a response is owed: pipelining, or a protocol
625
+ // that talks both ways at once. Either way, no boundary to trust.
626
+ return opaque();
627
+ }
628
+ },
629
+ /** Bytes the server (the relay) sent. */
630
+ server(buf) {
631
+ let off = 0;
632
+ while (off < buf.length) {
633
+ if (phase === "opaque") return;
634
+ if (phase === "response-wait") {
635
+ phase = "response-head";
636
+ continue;
637
+ }
638
+ if (phase === "response-head") {
639
+ off = takeHead(buf, off, startResponse);
640
+ continue;
641
+ }
642
+ if (phase === "response-body") {
643
+ const n = Math.min(remaining, buf.length - off);
644
+ remaining -= n;
645
+ off += n;
646
+ if (remaining === 0) {
647
+ phase = "idle";
648
+ exchanges += 1;
649
+ }
650
+ continue;
651
+ }
652
+ // The server spoke unasked, or before the request was whole.
653
+ return opaque();
654
+ }
655
+ },
656
+ /** Nothing in flight: no request begun, or the last response is whole. */
657
+ atRest: () => phase === "idle",
658
+ opaque: () => phase === "opaque",
659
+ phase: () => phase,
660
+ exchanges: () => exchanges,
661
+ };
662
+ }
663
+
664
+ /**
665
+ * The answer a refused request gets: an HTTP 503 in OpenViking's own
666
+ * `{status, error}` envelope, so a caller's client reports WHY ("the memory
667
+ * forward is busy …") where a bare close read as `fetch failed`.
668
+ */
669
+ export function busyResponse(message) {
670
+ const body = JSON.stringify({ status: "error", error: { code: "FORWARD_BUSY", message } });
671
+ return (
672
+ "HTTP/1.1 503 Service Unavailable\r\n" +
673
+ "Content-Type: application/json\r\n" +
674
+ `Content-Length: ${Buffer.byteLength(body)}\r\n` +
675
+ "Retry-After: 1\r\n" +
676
+ "Connection: close\r\n" +
677
+ "\r\n" +
678
+ body
679
+ );
680
+ }
681
+
682
+ const seconds = (ms) => `${Math.round(ms / 100) / 10}s`;
683
+
684
+ /**
685
+ * The listener. One TCP server per bind address; every connection that has
686
+ * a request to send gets a relay child — at most `maxRelays` at once (above) —
687
+ * and either side closing tears the pair down.
688
+ *
689
+ * Failure is always local and always survivable: a relay that cannot start
690
+ * closes that one connection (the extension sees a connection error, degrades,
691
+ * and the in-container watcher alerts) — the forwarder keeps listening.
692
+ *
693
+ * @param {Record<string, any>} cfg
694
+ * @param {{
695
+ * log?: (line: string) => void,
696
+ * spawnRelay?: () => any,
697
+ * now?: () => number,
698
+ * collapser?: { fail: (cause: { key: string, cause: string, remedy: string }) => void, ok: () => void },
699
+ * timings?: Partial<typeof RELAY_TIMINGS>,
700
+ * }} [opts]
701
+ */
702
+ export function createForwarder(cfg, opts = {}) {
703
+ const log = opts.log ?? ((line) => console.log(`[memory-forward] ${line}`));
704
+ const spawnRelay = opts.spawnRelay ?? realRelaySpawner(cfg);
705
+ const now = opts.now ?? (() => Date.now());
706
+ const limits = forwardLimits(cfg);
707
+ const t = { ...RELAY_TIMINGS, ...(opts.timings ?? {}) };
708
+ const servers = [];
709
+ // Every accepted connection not yet closed. Tracked so close() can tear
710
+ // them down: `server.close()` only stops accepting, and each relay is an ssh
711
+ // + a remote docker exec, so a caller that stops the forwarder and keeps
712
+ // running would strand one process trio per open connection. That caller
713
+ // exists — the TUI owns a forward for as long as it is up
714
+ // (src/tui/memforward.ts).
715
+ const conns = new Set();
716
+ /** Connections with a request waiting for a relay, oldest first. */
717
+ let queue = [];
718
+ /** Relay children spawned and not yet exited: the sessions on the master. */
719
+ let open = 0;
720
+ /** Of those, the ones already told to stop — slots on their way back. */
721
+ let retiring = 0;
722
+ let closed = false;
723
+ let ticker = null;
724
+ const counters = {
725
+ accepted: 0,
726
+ /** Requests that ever had to wait (the live count is `queued`). */
727
+ enqueued: 0,
728
+ servedFromQueue: 0,
729
+ yielded: 0,
730
+ refusedTimeout: 0,
731
+ refusedFull: 0,
732
+ longestWaitMs: 0,
733
+ peakOpen: 0,
734
+ peakQueued: 0,
735
+ };
736
+
737
+ // One actionable line per cause, not one symptom line per relay.
738
+ const collapser =
739
+ opts.collapser ?? createFailureCollapser({ log, ...(opts.now ? { now: opts.now } : {}) });
740
+
741
+ // ---- what the operator reads ------------------------------------------
742
+ let episode = false;
743
+ let lastQueueLogAt = Number.NEGATIVE_INFINITY;
744
+ let lastRefusalLogAt = Number.NEGATIVE_INFINITY;
745
+ let summaryOwed = false;
746
+ let summaryBase = { ...counters };
747
+ let longestSinceSummary = 0;
748
+ const startEpisode = () => {
749
+ if (episode) return;
750
+ episode = true;
751
+ const at = now();
752
+ if (at - lastQueueLogAt < t.logWindowMs) return;
753
+ lastQueueLogAt = at;
754
+ summaryOwed = true;
755
+ log(
756
+ `relay cap reached: ${open}/${limits.maxRelays} relays open — requests wait (up to ${limits.queueMax}, ${seconds(limits.queueTimeoutMs)} each) and a relay between requests hands its slot over`,
757
+ );
758
+ };
759
+ const endEpisode = () => {
760
+ if (!episode) return;
761
+ episode = false;
762
+ if (!summaryOwed) return;
763
+ summaryOwed = false;
764
+ const d = (k) => counters[k] - summaryBase[k];
765
+ const refused = d("refusedTimeout") + d("refusedFull");
766
+ log(
767
+ `relay queue drained: ${d("servedFromQueue")} request(s) waited for a relay (longest ${longestSinceSummary}ms), ${d("yielded")} relay(s) handed over between requests, ${refused} refused`,
768
+ );
769
+ summaryBase = { ...counters };
770
+ longestSinceSummary = 0;
771
+ };
772
+
773
+ // ---- stats: the `status` subcommand's live view -------------------------
774
+ let statsTimer = null;
775
+ let statsWritten = false;
776
+ const stats = () => {
777
+ let leased = 0;
778
+ let unyielding = 0;
779
+ for (const c of conns) {
780
+ if (!c.lease) continue;
781
+ leased += 1;
782
+ if (c.tracker.opaque()) unyielding += 1;
783
+ }
784
+ return {
785
+ ...counters,
786
+ maxRelays: limits.maxRelays,
787
+ queueMax: limits.queueMax,
788
+ queueTimeoutMs: limits.queueTimeoutMs,
789
+ open,
790
+ leased,
791
+ unyielding,
792
+ queued: queue.length,
793
+ connections: conns.size,
794
+ };
795
+ };
796
+ const writeStats = () => {
797
+ statsTimer = null;
798
+ if (closed || !cfg.statsFile) return;
799
+ const snapshot = {
800
+ pid: process.pid,
801
+ port: cfg.port,
802
+ transport: cfg.local ? "local docker" : cfg.sshHost,
803
+ ...(cfg.local ? {} : { controlPath: cfg.controlPath }),
804
+ updatedAt: new Date(now()).toISOString(),
805
+ ...stats(),
806
+ };
807
+ try {
808
+ const tmp = `${cfg.statsFile}.${process.pid}.tmp`;
809
+ writeFileSync(tmp, `${JSON.stringify(snapshot)}\n`, { mode: 0o600 });
810
+ renameSync(tmp, cfg.statsFile);
811
+ statsWritten = true;
812
+ } catch (err) {
813
+ if (!statsWritten) log(`cannot write the stats file ${cfg.statsFile}: ${errText(err)}`);
814
+ }
815
+ };
816
+ const scheduleStats = () => {
817
+ if (closed || !cfg.statsFile || statsTimer) return;
818
+ statsTimer = setTimeout(writeStats, t.statsEveryMs);
819
+ statsTimer.unref?.();
820
+ };
821
+
822
+ // ---- the slots ------------------------------------------------------------
823
+ const startTicker = () => {
824
+ if (ticker || closed) return;
825
+ ticker = setInterval(rebalance, t.rebalanceMs);
826
+ ticker.unref?.();
827
+ };
828
+ const stopTicker = () => {
829
+ if (ticker) clearInterval(ticker);
830
+ ticker = null;
831
+ };
832
+
833
+ /** Stop a relay. Its slot is free once the child has EXITED — the ssh
834
+ * session is gone then — or at the SIGKILL, whichever comes first. */
835
+ const retire = (lease) => {
836
+ if (lease.retiring) return;
837
+ lease.retiring = true;
838
+ if (lease.released) return;
839
+ retiring += 1;
840
+ try {
841
+ lease.child.kill();
842
+ } catch {
843
+ /* already gone */
844
+ }
845
+ lease.killTimer = setTimeout(() => {
846
+ try {
847
+ lease.child.kill("SIGKILL");
848
+ } catch {
849
+ /* already gone */
850
+ }
851
+ lease.release();
852
+ }, t.killGraceMs);
853
+ lease.killTimer.unref?.();
854
+ };
855
+
856
+ const dequeue = (conn) => {
857
+ if (!conn.queued) return;
858
+ conn.queued = false;
859
+ if (conn.queueTimer) clearTimeout(conn.queueTimer);
860
+ conn.queueTimer = null;
861
+ queue = queue.filter((c) => c !== conn);
862
+ if (queue.length === 0) {
863
+ stopTicker();
864
+ endEpisode();
865
+ }
866
+ };
867
+
868
+ const pump = () => {
869
+ if (closed) return;
870
+ while (queue.length > 0 && open < limits.maxRelays) {
871
+ const conn = queue.shift();
872
+ conn.queued = false;
873
+ if (conn.queueTimer) clearTimeout(conn.queueTimer);
874
+ conn.queueTimer = null;
875
+ const waited = now() - conn.queuedAt;
876
+ counters.servedFromQueue += 1;
877
+ counters.longestWaitMs = Math.max(counters.longestWaitMs, waited);
878
+ longestSinceSummary = Math.max(longestSinceSummary, waited);
879
+ grant(conn);
880
+ }
881
+ if (queue.length === 0) {
882
+ stopTicker();
883
+ endEpisode();
884
+ } else rebalance();
885
+ };
886
+
887
+ /** Hand waiters the slots of relays that are between requests: the
888
+ * longest-quiet first, and only as many as are still needed. */
889
+ function rebalance() {
890
+ if (closed || queue.length === 0) return stopTicker();
891
+ const at = now();
892
+ const ready = [...conns]
893
+ .filter(
894
+ (c) =>
895
+ c.lease &&
896
+ !c.lease.retiring &&
897
+ !c.lease.released &&
898
+ c.tracker.atRest() &&
899
+ (at - c.lease.startedAt >= t.minLeaseMs || at - c.lastActive >= t.idleYieldMs),
900
+ )
901
+ .sort((a, b) => a.lastActive - b.lastActive);
902
+ for (const c of ready) {
903
+ if (queue.length <= retiring) break;
904
+ park(c);
905
+ }
906
+ startTicker();
907
+ }
908
+
909
+ /** A connection that just lost its relay between requests reads again. A
910
+ * pause the old relay's full stdin set would otherwise stand for good: its
911
+ * lift is that child's 'drain', which never comes from a child that is gone,
912
+ * so the next request would be neither read nor queued. The hold limit's
913
+ * own pause (`heldBytes` over it) is left standing. */
914
+ const resumeAfterRelay = (conn) => {
915
+ if (conn.socket.isPaused?.() && conn.heldBytes <= t.holdLimitBytes) conn.socket.resume();
916
+ };
917
+
918
+ /** Take a connection's relay away BETWEEN requests. The connection stays
919
+ * open; its next request asks for a relay again. */
920
+ const park = (conn) => {
921
+ const lease = conn.lease;
922
+ conn.lease = null;
923
+ counters.yielded += 1;
924
+ retire(lease);
925
+ resumeAfterRelay(conn);
926
+ scheduleStats();
927
+ };
928
+
929
+ /** A connection with bytes to send and no relay asks for one. */
930
+ const wantRelay = (conn) => {
931
+ if (conn.queued || conn.closed || conn.refused || conn.finishing || conn.lease) return;
932
+ if (queue.length === 0 && open < limits.maxRelays) return grant(conn);
933
+ if (queue.length >= limits.queueMax) return refuse(conn, "full");
934
+ conn.queued = true;
935
+ conn.queuedAt = now();
936
+ queue.push(conn);
937
+ counters.enqueued += 1;
938
+ counters.peakQueued = Math.max(counters.peakQueued, queue.length);
939
+ conn.queueTimer = setTimeout(() => {
940
+ if (!conn.queued) return;
941
+ refuse(conn, "timeout");
942
+ }, limits.queueTimeoutMs);
943
+ conn.queueTimer.unref?.();
944
+ startEpisode();
945
+ rebalance();
946
+ scheduleStats();
947
+ };
948
+
949
+ /** A clean "no": the 503 envelope, a FIN, and the rest of the request read
950
+ * and dropped — closing with unread bytes would RESET the answer away. */
951
+ const refuse = (conn, why) => {
952
+ if (conn.refused || conn.closed) return;
953
+ conn.refused = true;
954
+ if (why === "full") counters.refusedFull += 1;
955
+ else counters.refusedTimeout += 1;
956
+ dequeue(conn);
957
+ const state = `${open}/${limits.maxRelays} relays open, ${queue.length} waiting`;
958
+ const message =
959
+ why === "full"
960
+ ? `the memory forward's wait queue is full (${state}) — retry shortly`
961
+ : `the memory forward is at its relay cap and this request waited ${seconds(limits.queueTimeoutMs)} for a relay (${state}) — retry shortly`;
962
+ const at = now();
963
+ if (at - lastRefusalLogAt >= t.logWindowMs) {
964
+ lastRefusalLogAt = at;
965
+ log(
966
+ `refused a request — ${why === "full" ? `the wait queue is full (${limits.queueMax})` : `it waited ${seconds(limits.queueTimeoutMs)} for a relay`}; ${state} (more refusals are counted, not logged, for a minute)`,
967
+ );
968
+ }
969
+ conn.held = [];
970
+ conn.heldBytes = 0;
971
+ try {
972
+ if (!conn.tracker.opaque() && conn.socket.writable) conn.socket.write(busyResponse(message));
973
+ conn.socket.end();
974
+ } catch {
975
+ /* the client is already gone */
976
+ }
977
+ if (conn.socket.isPaused?.()) conn.socket.resume();
978
+ conn.lingerTimer = setTimeout(() => closeConn(conn), t.refuseLingerMs);
979
+ conn.lingerTimer.unref?.();
980
+ scheduleStats();
981
+ };
982
+
983
+ const closeConn = (conn) => {
984
+ if (conn.closed) return;
985
+ conn.closed = true;
986
+ conns.delete(conn);
987
+ dequeue(conn);
988
+ for (const k of ["lingerTimer", "finishTimer"]) if (conn[k]) clearTimeout(conn[k]);
989
+ if (conn.lease) {
990
+ retire(conn.lease);
991
+ conn.lease = null;
992
+ }
993
+ conn.socket.destroy();
994
+ scheduleStats();
995
+ };
996
+
997
+ const writeToRelay = (conn, lease, chunk) => {
998
+ let ok = true;
999
+ try {
1000
+ ok = lease.child.stdin.write(chunk);
1001
+ } catch {
1002
+ return; // a dying relay; its exit settles the connection
1003
+ }
1004
+ if (ok) return;
1005
+ conn.socket.pause();
1006
+ lease.child.stdin.once?.("drain", () => {
1007
+ if (conn.lease === lease && !conn.closed) conn.socket.resume();
1008
+ });
1009
+ };
1010
+
1011
+ /** The current relay ended. Between requests (the server closed an idle
1012
+ * keep-alive connection) the connection simply lives on without one;
1013
+ * mid-exchange the client must see the loss, so its side ends too — after
1014
+ * the relay's last bytes have reached it. */
1015
+ const relayEnded = (conn, lease) => {
1016
+ if (conn.closed || conn.lease !== lease) return;
1017
+ if (conn.tracker.atRest()) {
1018
+ conn.lease = null;
1019
+ resumeAfterRelay(conn);
1020
+ return;
1021
+ }
1022
+ conn.finishing = true;
1023
+ const finish = () => {
1024
+ if (conn.closed || conn.lease !== lease) return;
1025
+ conn.lease = null;
1026
+ try {
1027
+ conn.socket.end();
1028
+ } catch {
1029
+ /* already gone */
1030
+ }
1031
+ conn.lingerTimer = setTimeout(() => closeConn(conn), t.refuseLingerMs);
1032
+ conn.lingerTimer.unref?.();
1033
+ };
1034
+ if (lease.stdioClosed) return finish();
1035
+ lease.onStdioClosed = finish;
1036
+ conn.finishTimer = setTimeout(finish, t.relayCloseGraceMs);
1037
+ conn.finishTimer.unref?.();
1038
+ };
1039
+
1040
+ const onRelayData = (conn, lease, chunk) => {
1041
+ if (conn.closed || conn.lease !== lease) return; // a stopped relay's late bytes go nowhere
1042
+ // The first byte back from the container is the proof that the whole chain
1043
+ // works, so it is what clears a collapsed failure — self-recovery with no
1044
+ // restart.
1045
+ if (!lease.moved) {
1046
+ lease.moved = true;
1047
+ collapser.ok();
1048
+ }
1049
+ conn.lastActive = now();
1050
+ conn.tracker.server(chunk);
1051
+ if (!conn.socket.write(chunk)) {
1052
+ lease.child.stdout.pause?.();
1053
+ conn.socket.once("drain", () => lease.child.stdout.resume?.());
1054
+ }
1055
+ if (queue.length > 0 && conn.tracker.atRest()) rebalance();
1056
+ };
1057
+
1058
+ function grant(conn) {
1059
+ let child;
1060
+ try {
1061
+ child = spawnRelay();
1062
+ } catch (err) {
1063
+ collapser.fail(relayFailureCause(cfg, errText(err)));
1064
+ closeConn(conn);
1065
+ return;
1066
+ }
1067
+ open += 1;
1068
+ counters.peakOpen = Math.max(counters.peakOpen, open);
1069
+ const lease = {
1070
+ child,
1071
+ startedAt: now(),
1072
+ moved: false,
1073
+ // Evidence for the cause: ssh writes the reason to stderr and then exits
1074
+ // 255, so the exit code alone can never name it.
1075
+ stderrText: "",
1076
+ retiring: false,
1077
+ released: false,
1078
+ stdioClosed: false,
1079
+ onStdioClosed: null,
1080
+ killTimer: null,
1081
+ release: () => {},
1082
+ };
1083
+ lease.release = () => {
1084
+ if (lease.released) return;
1085
+ lease.released = true;
1086
+ open -= 1;
1087
+ if (lease.retiring) retiring -= 1;
1088
+ if (lease.killTimer) clearTimeout(lease.killTimer);
1089
+ pump();
1090
+ scheduleStats();
1091
+ };
1092
+ conn.lease = lease;
1093
+ conn.lastActive = now();
1094
+ child.on?.("error", (err) => {
1095
+ if (!lease.retiring) collapser.fail(relayFailureCause(cfg, errText(err)));
1096
+ lease.release();
1097
+ if (conn.lease === lease) {
1098
+ conn.lease = null;
1099
+ closeConn(conn);
1100
+ }
1101
+ });
1102
+ child.on?.("exit", (code) => {
1103
+ // A relay that carried bytes and then exited is a closed connection, not
1104
+ // a failure — only one that never moved anything is worth a cause. One
1105
+ // WE stopped is neither.
1106
+ if (code && !lease.moved && !lease.retiring)
1107
+ collapser.fail(relayFailureCause(cfg, lease.stderrText));
1108
+ lease.release();
1109
+ relayEnded(conn, lease);
1110
+ });
1111
+ child.on?.("close", () => {
1112
+ lease.stdioClosed = true;
1113
+ lease.onStdioClosed?.();
1114
+ });
1115
+ child.stderr?.on?.("data", (d) => {
1116
+ lease.stderrText = `${lease.stderrText}${String(d)}`.slice(-2000);
1117
+ });
1118
+ // An EPIPE on a dying relay surfaces through its exit, never as a throw.
1119
+ child.stdin?.on?.("error", () => {});
1120
+ child.stdout?.on?.("error", () => {});
1121
+ child.stdout.on("data", (d) => onRelayData(conn, lease, d));
1122
+ const held = conn.held;
1123
+ conn.held = [];
1124
+ conn.heldBytes = 0;
1125
+ // Lift the hold limit's pause BEFORE the flush, so a pause the flush
1126
+ // itself sets (the relay's stdin full) stands. Resuming only flows on the
1127
+ // next tick, after the held bytes are written: the order holds.
1128
+ if (conn.socket.isPaused?.()) conn.socket.resume();
1129
+ for (const chunk of held) writeToRelay(conn, lease, chunk);
1130
+ if (conn.clientEnded) {
1131
+ try {
1132
+ child.stdin.end();
1133
+ } catch {
1134
+ /* already gone */
1135
+ }
1136
+ }
1137
+ scheduleStats();
1138
+ }
1139
+
1140
+ const onConnection = (socket) => {
1141
+ if (closed) {
1142
+ socket.destroy();
1143
+ return;
1144
+ }
1145
+ counters.accepted += 1;
1146
+ const conn = {
1147
+ socket,
1148
+ tracker: createExchangeTracker(),
1149
+ lease: null,
1150
+ held: [],
1151
+ heldBytes: 0,
1152
+ queued: false,
1153
+ queuedAt: 0,
1154
+ queueTimer: null,
1155
+ lingerTimer: null,
1156
+ finishTimer: null,
1157
+ lastActive: now(),
1158
+ refused: false,
1159
+ finishing: false,
1160
+ clientEnded: false,
1161
+ closed: false,
1162
+ };
1163
+ conns.add(conn);
1164
+ socket.on("data", (chunk) => {
1165
+ if (conn.closed || conn.refused || conn.finishing) return; // read and dropped
1166
+ conn.lastActive = now();
1167
+ conn.tracker.client(chunk);
1168
+ const lease = conn.lease;
1169
+ if (lease && !lease.retiring) {
1170
+ writeToRelay(conn, lease, chunk);
1171
+ return;
1172
+ }
1173
+ conn.held.push(chunk);
1174
+ conn.heldBytes += chunk.length;
1175
+ if (conn.heldBytes > t.holdLimitBytes) socket.pause();
1176
+ wantRelay(conn);
1177
+ });
1178
+ socket.on("end", () => {
1179
+ conn.clientEnded = true;
1180
+ if (conn.lease && !conn.lease.retiring) {
1181
+ try {
1182
+ conn.lease.child.stdin.end();
1183
+ } catch {
1184
+ /* already gone */
1185
+ }
1186
+ }
1187
+ });
1188
+ socket.on("error", () => closeConn(conn));
1189
+ socket.on("close", () => closeConn(conn));
1190
+ // A free slot is taken at once; with none free, nothing is owed until the
1191
+ // client actually asks.
1192
+ if (queue.length === 0 && open < limits.maxRelays) grant(conn);
1193
+ else scheduleStats();
1194
+ };
1195
+
1196
+ return {
1197
+ async listen() {
1198
+ for (const address of cfg.binds) {
1199
+ const server = createServer(onConnection);
1200
+ servers.push(server);
1201
+ await new Promise((res, rej) => {
1202
+ server.once("error", rej);
1203
+ server.listen(cfg.port, address, () => res(undefined));
1204
+ }).catch((err) => {
1205
+ // A bind that fails (gateway not up yet) must not take the loopback
1206
+ // listener down with it — systemd restarts us either way.
1207
+ log(`cannot bind ${address}:${cfg.port}: ${errText(err)}`);
1208
+ });
1209
+ const addr = server.address();
1210
+ if (addr)
1211
+ log(
1212
+ `listening on ${addr.address}:${addr.port} → ${cfg.local ? "local docker" : cfg.sshHost} ${cfg.container} ${cfg.remoteHost}:${cfg.remotePort} — at most ${limits.maxRelays} relays at once, up to ${limits.queueMax} requests wait ${seconds(limits.queueTimeoutMs)}`,
1213
+ );
1214
+ }
1215
+ const bound = servers.filter((s) => s.address() !== null).length;
1216
+ if (bound > 0 && !cfg.local && limits.maxRelays >= SSHD_DEFAULT_MAX_SESSIONS)
1217
+ log(
1218
+ `WARNING: --max-relays ${limits.maxRelays} is not under sshd's default MaxSessions (${SSHD_DEFAULT_MAX_SESSIONS}) — a relay past the server's limit is refused on the shared connection and quietly opens a full ssh login of its own, which can fail; check \`ssh ${cfg.sshHost} sudo sshd -T | grep -i maxsessions\``,
1219
+ );
1220
+ if (bound > 0) writeStats();
1221
+ return bound;
1222
+ },
1223
+ /** Stop accepting AND tear down what is still in flight. Both halves are
1224
+ * required: without the second, a still-running caller leaks one ssh +
1225
+ * remote node per open connection. */
1226
+ close() {
1227
+ for (const s of servers) s.close();
1228
+ closed = true;
1229
+ stopTicker();
1230
+ if (statsTimer) clearTimeout(statsTimer);
1231
+ statsTimer = null;
1232
+ for (const conn of [...conns]) {
1233
+ if (conn.queueTimer) clearTimeout(conn.queueTimer);
1234
+ conn.queued = false;
1235
+ closeConn(conn);
1236
+ }
1237
+ queue = [];
1238
+ if (statsWritten && cfg.statsFile) {
1239
+ // Only OUR file: another forward may have taken the port since.
1240
+ try {
1241
+ const mine = JSON.parse(readFileSync(cfg.statsFile, "utf8")).pid === process.pid;
1242
+ if (mine) unlinkSync(cfg.statsFile);
1243
+ } catch {
1244
+ /* gone already, or not ours to read */
1245
+ }
1246
+ }
1247
+ },
1248
+ /** Connections holding a relay — the test's handle on the teardown above. */
1249
+ liveRelays: () => [...conns].filter((c) => c.lease).length,
1250
+ /** The cap, the queue and what they have done. */
1251
+ stats,
1252
+ /** Bound ports (the test asks for the ephemeral one). */
1253
+ addresses: () => servers.map((s) => s.address()).filter(Boolean),
1254
+ };
1255
+ }
1256
+
1257
+ /**
1258
+ * Where the running forward writes its live counts for `status`:
1259
+ * `$XDG_RUNTIME_DIR/qcr-memory-forward-<port>.json` — the same directory the
1260
+ * systemd unit's `%t` names, so the CLI and the unit agree. No runtime dir, no
1261
+ * file: `status` then says the counts are unknown rather than guessing.
1262
+ */
1263
+ export function statsFilePath(env = process.env, port = DEFAULTS.port) {
1264
+ const dir = (env.XDG_RUNTIME_DIR ?? "").trim();
1265
+ if (dir === "" || !isAbsolute(dir)) return null;
1266
+ return join(dir, `qcr-memory-forward-${port}.json`);
1267
+ }
1268
+
1269
+ function pidAlive(pid) {
1270
+ if (!Number.isInteger(pid) || pid <= 0) return false;
1271
+ try {
1272
+ process.kill(pid, 0);
1273
+ return true;
1274
+ } catch (err) {
1275
+ return err?.code === "EPERM";
1276
+ }
1277
+ }
1278
+
1279
+ /**
1280
+ * Read the running forward's stats file: `live`, `stale` (its pid is gone),
1281
+ * `absent`, `unreadable`, or `off` (no path) — never a guessed zero.
1282
+ *
1283
+ * @param {string | null | undefined} path
1284
+ * @param {{ read?: (p: string) => string, alive?: (pid: number) => boolean }} [deps]
1285
+ */
1286
+ export function readForwardStats(path, deps = {}) {
1287
+ if (!path) return { state: "off" };
1288
+ let text;
1289
+ try {
1290
+ text = (deps.read ?? ((p) => readFileSync(p, "utf8")))(path);
1291
+ } catch (err) {
1292
+ return err?.code === "ENOENT"
1293
+ ? { state: "absent" }
1294
+ : { state: "unreadable", error: errText(err) };
1295
+ }
1296
+ let snapshot;
1297
+ try {
1298
+ snapshot = JSON.parse(text);
1299
+ } catch {
1300
+ return { state: "unreadable", error: "not JSON" };
1301
+ }
1302
+ if (!snapshot || typeof snapshot !== "object")
1303
+ return { state: "unreadable", error: "not a stats object" };
1304
+ return { state: (deps.alive ?? pidAlive)(snapshot.pid) ? "live" : "stale", snapshot };
1305
+ }
1306
+
1307
+ /** What `status` prints about the cap — the running forward's own numbers
1308
+ * when it is live, this command's settings otherwise, never a made-up count. */
1309
+ export function describeForwardStats(cfg, read) {
1310
+ const lim = forwardLimits(cfg);
1311
+ const capLine = (s, whose) =>
1312
+ `relay cap${whose}: ${s.maxRelays} at once (sshd's default MaxSessions is ${SSHD_DEFAULT_MAX_SESSIONS}); up to ${s.queueMax} requests wait ${seconds(s.queueTimeoutMs)} each`;
1313
+ if (read.state !== "live") {
1314
+ const why = {
1315
+ off: "no stats file (XDG_RUNTIME_DIR is not set)",
1316
+ absent: `no forward has written ${cfg.statsFile} (none running on this port, or one too old to write a stats file)`,
1317
+ unreadable: `${cfg.statsFile} is unreadable (${read.error})`,
1318
+ stale: `${cfg.statsFile} names pid ${read.snapshot?.pid}, which is not running`,
1319
+ }[read.state];
1320
+ return [capLine(lim, " (this command's settings)"), `live relays: unknown — ${why}`];
1321
+ }
1322
+ const s = read.snapshot;
1323
+ const lines = [capLine(s, "")];
1324
+ lines.push(
1325
+ `live relays (pid ${s.pid}, as of ${s.updatedAt}): ${s.open}/${s.maxRelays} open, ${s.queued} waiting, ${s.unyielding ?? 0} held by connections that cannot hand over (peak ${s.peakOpen} open, ${s.peakQueued} waiting)`,
1326
+ );
1327
+ lines.push(
1328
+ `since start: ${s.servedFromQueue} request(s) waited for a relay (longest ${s.longestWaitMs}ms), ${s.yielded} relay(s) handed over between requests, refused ${s.refusedTimeout} on timeout and ${s.refusedFull} on a full queue`,
1329
+ );
1330
+ if (s.maxRelays !== lim.maxRelays)
1331
+ lines.push(
1332
+ `(this command would use ${lim.maxRelays}; the running forward was started with other settings)`,
1333
+ );
1334
+ return lines;
1335
+ }
1336
+
1337
+ // ---- systemd ----------------------------------------------------------------
1338
+
1339
+ /**
1340
+ * SSH_AUTH_SOCK for the unit. A path under $XDG_RUNTIME_DIR is rewritten to
1341
+ * systemd's `%t` so it survives a reboot; a per-session /tmp agent path cannot
1342
+ * survive one and is reported as such by the caller.
1343
+ */
1344
+ export function unitAuthSock(env = process.env) {
1345
+ const sock = (env.SSH_AUTH_SOCK ?? "").trim();
1346
+ if (sock === "") return { value: "", stable: false, why: "no SSH_AUTH_SOCK in this shell" };
1347
+ const runtime = (env.XDG_RUNTIME_DIR ?? "").trim();
1348
+ if (runtime !== "" && sock.startsWith(`${runtime}/`)) {
1349
+ return { value: `%t/${sock.slice(runtime.length + 1)}`, stable: true, why: "" };
1350
+ }
1351
+ return {
1352
+ value: sock,
1353
+ stable: false,
1354
+ why: "the agent socket path looks per-session; prefer --identity <key> or a keyring agent",
1355
+ };
1356
+ }
1357
+
1358
+ /** The systemd --user unit. Pure so the test can read it. */
1359
+ export function unitText(cfg, env = process.env) {
1360
+ const arg = (value) => {
1361
+ const text = String(value).replaceAll("$", "$$");
1362
+ return /[\s"\\]/.test(text) ? JSON.stringify(text) : text;
1363
+ };
1364
+ const pathArg = (value) => arg(String(value).replaceAll("%", "%%"));
1365
+ const script = join(ROOT, "scripts", "companion-memory-forward.mjs");
1366
+ const auth = unitAuthSock(env);
1367
+ const runArgs =
1368
+ [
1369
+ "run",
1370
+ ...(cfg.local ? ["--local"] : ["--ssh-host", cfg.sshHost]),
1371
+ "--container",
1372
+ cfg.container,
1373
+ "--port",
1374
+ String(cfg.port),
1375
+ "--gateway",
1376
+ cfg.gateway,
1377
+ ...(cfg.sudo ? [] : ["--no-sudo"]),
1378
+ ...(cfg.identity ? ["--identity", cfg.identity] : []),
1379
+ "--remote-host",
1380
+ cfg.remoteHost,
1381
+ "--remote-port",
1382
+ String(cfg.remotePort),
1383
+ "--bind",
1384
+ cfg.binds.join(","),
1385
+ // The cap and the control socket ride the unit only when they
1386
+ // differ from the defaults, so a later default reaches an installed unit.
1387
+ ]
1388
+ .map(pathArg)
1389
+ .join(" ") +
1390
+ unitLimitArgs(cfg)
1391
+ .map((value) => ` ${arg(value)}`)
1392
+ .join("");
1393
+ // Name the transport truthfully — a local unit that claimed to
1394
+ // forward "OpenViking on <ssh host>" is the line an operator reads first in
1395
+ // `systemctl --user status` when the laptop route misbehaves.
1396
+ const where = cfg.local ? "local docker" : cfg.sshHost;
1397
+ return `[Unit]
1398
+ Description=quickreview companion memory forward (OpenViking on ${where} -> ${cfg.gateway}:${cfg.port})
1399
+ Documentation=file://${join(ROOT, "docs", "companion-memory-access.md")}
1400
+ After=default.target
1401
+
1402
+ [Service]
1403
+ Type=simple
1404
+ ${auth.value === "" ? "" : `Environment=SSH_AUTH_SOCK=${auth.value}\n`}ExecStartPre=${pathArg(process.execPath)} ${pathArg(script)} ensure-network --network ${arg(cfg.network)} --subnet ${arg(cfg.subnet)} --gateway ${arg(cfg.gateway)}
1405
+ ExecStart=${pathArg(process.execPath)} ${pathArg(script)} ${runArgs}
1406
+ Restart=always
1407
+ RestartSec=5
1408
+ # This process is the ONLY holder of a VM credential in the companion path.
1409
+ # It runs as the desktop user, never inside a container (the companion boundary).
1410
+ NoNewPrivileges=${cfg.local && cfg.sudo ? "no" : "yes"}
1411
+
1412
+ [Install]
1413
+ WantedBy=default.target
1414
+ `;
1415
+ }
1416
+
1417
+ /**
1418
+ * The non-default relay settings as `run` flags for the unit's ExecStart.
1419
+ * A control path is written with `%` doubled: systemd expands `%C`, `%h`,
1420
+ * `%r`, `%p` itself in ExecStart, and those are also ssh's tokens.
1421
+ */
1422
+ export function unitLimitArgs(cfg, home = homedir()) {
1423
+ const lim = forwardLimits(cfg);
1424
+ const out = [];
1425
+ if (lim.maxRelays !== DEFAULTS.maxRelays) out.push("--max-relays", String(lim.maxRelays));
1426
+ if (lim.queueMax !== DEFAULTS.queueMax) out.push("--queue-max", String(lim.queueMax));
1427
+ if (lim.queueTimeoutMs !== DEFAULTS.queueTimeoutS * 1000)
1428
+ out.push("--queue-timeout", String(Math.round(lim.queueTimeoutMs / 1000)));
1429
+ if (!cfg.local && cfg.controlPath && cfg.controlPath !== defaultControlPath(home))
1430
+ out.push("--control-path", cfg.controlPath.replaceAll("%", "%%"));
1431
+ if (cfg.statsFile && cfg.statsFile !== statsFilePath(process.env, cfg.port))
1432
+ out.push("--stats-file", cfg.statsFile.replaceAll("%", "%%"));
1433
+ return out;
1434
+ }
1435
+
1436
+ export function unitPath(cfg, home = homedir()) {
1437
+ return join(home, ".config", "systemd", "user", `${cfg.unit}.service`);
1438
+ }
1439
+
1440
+ // ---- the confinement proof --------------------------------------------------
1441
+ //
1442
+ // The reverse direction is the property that makes two user keys safe: the
1443
+ // reviewer key (which every automated review session holds) must be unable to
1444
+ // read or write companion memory. Server-side confinement was verified against
1445
+ // a running 0.4.15 server; this turns it into something re-runnable on demand.
1446
+ //
1447
+ // The request shapes below are the adjustable part — src/memory is
1448
+ // the authority for what OpenViking's read/write endpoints look like. The
1449
+ // verdict logic (what each status code proves) is what the unit test pins, and
1450
+ // anything that is neither reach nor refusal is reported "inconclusive", never
1451
+ // as a pass.
1452
+
1453
+ export const PATHS = {
1454
+ /** A directory listing at the user root: exists for every user, and needs no
1455
+ * knowledge of which memory "space" that user happens to have. */
1456
+ read: (env = process.env) => (env.QCR_OV_READ_PATH ?? "/api/v1/fs/ls").trim(),
1457
+ // `{user}` is substituted with the namespace being probed. Verified live in
1458
+ // api_key mode: the server derives identity from the key and confines by
1459
+ // URI — `X-OpenViking-User` does not select the namespace, so a bare
1460
+ // `viking://user/` read answers 200 for every key and the cross-cells could
1461
+ // never fail. The namespace must live in the URI.
1462
+ readUri: (env = process.env, user = "") =>
1463
+ (env.QCR_OV_READ_URI ?? "viking://user/{user}/").trim().replaceAll("{user}", user),
1464
+ /** The ONE durable write path (see the vendored client's PATCH 4 note). */
1465
+ write: (env = process.env) => (env.QCR_OV_WRITE_PATH ?? "/api/v1/content/batch-write").trim(),
1466
+ };
1467
+
1468
+ /**
1469
+ * Auth + the cross-user selector.
1470
+ *
1471
+ * The namespace being addressed is named by the URI (see PATHS); the server
1472
+ * does not select it from `X-OpenViking-User`. The Bearer key identifies the
1473
+ * caller, and the header states which user the caller claims to be. Confinement
1474
+ * is proved by the probe's URIs: the reviewer key reading or writing a
1475
+ * `viking://user/companion/` URI must be refused.
1476
+ */
1477
+ export function authHeaders(key, user, env = process.env) {
1478
+ const name = (env.QCR_OV_AUTH_HEADER ?? "Authorization").trim();
1479
+ const prefix = (env.QCR_OV_AUTH_PREFIX ?? "Bearer ").replace(/\\s/g, " ");
1480
+ return {
1481
+ [name]: `${prefix}${key}`,
1482
+ "X-OpenViking-User": user,
1483
+ "X-OpenViking-Actor-Peer": user,
1484
+ "Content-Type": "application/json",
1485
+ };
1486
+ }
1487
+
1488
+ const REFUSAL_WORDS = /forbid|permission|unauthor|denied|not allowed|access.*(denied|refused)/i;
1489
+
1490
+ /**
1491
+ * How one probe reads.
1492
+ *
1493
+ * OpenViking answers some errors with HTTP 200 and `{status:"error"}` in the
1494
+ * envelope, so the status code alone is not enough: a 200 whose body says
1495
+ * "permission denied" is a refusal, and a 200 whose body says something else
1496
+ * entirely is INCONCLUSIVE — never a pass.
1497
+ */
1498
+ export function classify(status, body) {
1499
+ if (status === 401 || status === 403) return "refused";
1500
+ if (status >= 200 && status < 300) {
1501
+ const envelope = body && typeof body === "object" ? body : {};
1502
+ if (envelope.status === "error") {
1503
+ const message = String(envelope.error?.message ?? envelope.error ?? "");
1504
+ return REFUSAL_WORDS.test(message) ? "refused" : "inconclusive";
1505
+ }
1506
+ return "reach";
1507
+ }
1508
+ return "inconclusive"; // 404 (wrong uri), 5xx, transport — cannot tell
1509
+ }
1510
+
1511
+ /** The four (key, namespace) pairs and what each MUST show. */
1512
+ export function expectations() {
1513
+ return [
1514
+ {
1515
+ who: "companion",
1516
+ ns: "companion",
1517
+ want: "reach",
1518
+ why: "the companion writes and reads its own memory",
1519
+ },
1520
+ {
1521
+ who: "reviewer",
1522
+ ns: "reviewer",
1523
+ want: "reach",
1524
+ why: "the companion holds the reviewer key too — full reach over review memory",
1525
+ },
1526
+ {
1527
+ who: "reviewer",
1528
+ ns: "companion",
1529
+ want: "refused",
1530
+ why: "REVERSE DIRECTION: review sessions hold only this key and must not see companion memory",
1531
+ },
1532
+ {
1533
+ who: "companion",
1534
+ ns: "reviewer",
1535
+ want: "refused",
1536
+ why: "user keys are confined both ways (the companion uses the reviewer key for that namespace)",
1537
+ },
1538
+ ];
1539
+ }
1540
+
1541
+ export function verdictFor(expected, observed) {
1542
+ if (observed === "inconclusive") return "INCONCLUSIVE";
1543
+ return observed === expected ? "PASS" : "FAIL";
1544
+ }
1545
+
1546
+ /** Read the two keys. Values are used, never printed or logged. */
1547
+ export function readKeys(path) {
1548
+ const out = {};
1549
+ let text;
1550
+ try {
1551
+ text = readFileSync(path, "utf8");
1552
+ } catch {
1553
+ return out;
1554
+ }
1555
+ for (const line of text.split("\n")) {
1556
+ const m = /^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line);
1557
+ if (!m) continue;
1558
+ // The TUI's parseKeysEnv policy (src/tui/memory.ts): only a matching quote
1559
+ // pair is stripped, and an empty assignment is ignored.
1560
+ let value = m[2].trim();
1561
+ if (value.length >= 2 && /^(["']).*\1$/.test(value)) value = value.slice(1, -1);
1562
+ if (value !== "") out[m[1]] = value;
1563
+ }
1564
+ return out;
1565
+ }
1566
+
1567
+ async function probePair(base, key, ns, env, fetchFn, doWrite) {
1568
+ const headers = authHeaders(key, ns, env);
1569
+ const at = new Date().toISOString().replace(/[:.]/g, "-");
1570
+ const init = doWrite
1571
+ ? {
1572
+ method: "POST",
1573
+ headers,
1574
+ body: JSON.stringify({
1575
+ // Namespace in the URI, same reason as the read probe: the server
1576
+ // confines by URI, not by the X-OpenViking-User header.
1577
+ // "batch-write root must be inside a memory type directory" — the
1578
+ // root needs the category level under memories/ (the store's
1579
+ // DEFAULT_CATEGORY, src/openviking/store.ts). Verified live.
1580
+ root_uri: `viking://user/${ns}/memories/experiences`,
1581
+ operations: [
1582
+ {
1583
+ uri: `viking://user/${ns}/memories/experiences/qcr-confinement-probe-${at}.md`,
1584
+ content: `confinement probe ${at}\n`,
1585
+ // Mandatory on the live 0.4.15: an operation without a
1586
+ // precondition is HTTP 400 (mirrors src/openviking/client.ts).
1587
+ precondition: { kind: "create_if_absent" },
1588
+ },
1589
+ ],
1590
+ wait: false,
1591
+ }),
1592
+ }
1593
+ : { method: "GET", headers };
1594
+ const url = doWrite
1595
+ ? `${base}${PATHS.write(env)}`
1596
+ : `${base}${PATHS.read(env)}?uri=${encodeURIComponent(PATHS.readUri(env, ns))}`;
1597
+ try {
1598
+ // 30s: the forward path is ssh + docker exec per request, and the local
1599
+ // dense embedder answers slowly from cold — 8s produced false timeouts.
1600
+ const res = await fetchFn(url, { ...init, signal: AbortSignal.timeout(30000) });
1601
+ const body = res.json ? await res.json().catch(() => ({})) : {};
1602
+ return { status: res.status, classification: classify(res.status, body) };
1603
+ } catch (err) {
1604
+ return { status: 0, classification: "inconclusive", error: errText(err) };
1605
+ }
1606
+ }
1607
+
1608
+ /**
1609
+ * Run the matrix. Resolves a report; never throws on a probe failure.
1610
+ * `keys` maps role → key value.
1611
+ *
1612
+ * `fetchFn` is declared by the MINIMUM the probe reads — a status and an
1613
+ * optional `json()` — not as `typeof fetch`, which is what the default binds
1614
+ * it to. A seam typed by its implementation makes every test double fabricate
1615
+ * a whole `Response` for no gain.
1616
+ *
1617
+ * @param {{
1618
+ * base: string,
1619
+ * keys: Record<string, string>,
1620
+ * env?: Record<string, string | undefined>,
1621
+ * fetchFn?: (url: string, init?: { method?: string, headers?: Record<string, string>, body?: string, signal?: AbortSignal }) => Promise<{ status: number, json?: () => Promise<unknown> }>,
1622
+ * write?: boolean,
1623
+ * log?: (line: string) => void,
1624
+ * }} opts
1625
+ */
1626
+ export async function runVerify({
1627
+ base,
1628
+ keys,
1629
+ env = process.env,
1630
+ fetchFn = fetch,
1631
+ write = false,
1632
+ log = console.log,
1633
+ }) {
1634
+ const rows = [];
1635
+ for (const e of expectations()) {
1636
+ const key = e.who === "companion" ? keys.companion : keys.reviewer;
1637
+ if (!key) {
1638
+ rows.push({
1639
+ ...e,
1640
+ observed: "inconclusive",
1641
+ verdict: "INCONCLUSIVE",
1642
+ detail: `no ${e.who} key configured`,
1643
+ });
1644
+ continue;
1645
+ }
1646
+ const read = await probePair(base, key, e.ns, env, fetchFn, false);
1647
+ const written = write ? await probePair(base, key, e.ns, env, fetchFn, true) : null;
1648
+ const probes = written ? [read, written] : [read];
1649
+ // A pair passes only when every requested operation proves its confinement.
1650
+ const r =
1651
+ probes.find((probe) => verdictFor(e.want, probe.classification) === "FAIL") ??
1652
+ probes.find((probe) => probe.classification === "inconclusive") ??
1653
+ read;
1654
+ rows.push({
1655
+ ...e,
1656
+ observed: r.classification,
1657
+ verdict: verdictFor(e.want, r.classification),
1658
+ detail: r.error ?? `HTTP ${r.status}`,
1659
+ });
1660
+ }
1661
+ for (const r of rows) {
1662
+ log(
1663
+ `${r.verdict.padEnd(13)} ${r.who} key → user/${r.ns} memory: expected ${r.want}, got ${r.observed} (${r.detail})`,
1664
+ );
1665
+ if (r.verdict !== "PASS") log(` ${r.why}`);
1666
+ }
1667
+ const failed = rows.filter((r) => r.verdict === "FAIL").length;
1668
+ const unclear = rows.filter((r) => r.verdict === "INCONCLUSIVE").length;
1669
+ log(`\n${rows.length - failed - unclear} pass, ${failed} fail, ${unclear} inconclusive`);
1670
+ if (unclear > 0) {
1671
+ log("Inconclusive rows mean the request shape did not match this server — adjust");
1672
+ log(
1673
+ "QCR_OV_READ_PATH / QCR_OV_READ_URI / QCR_OV_WRITE_PATH / QCR_OV_AUTH_HEADER (see docs/companion-memory-access.md).",
1674
+ );
1675
+ }
1676
+ return { rows, failed, unclear };
1677
+ }
1678
+
1679
+ // Success means a conclusive passing matrix: an inconclusive row proves
1680
+ // nothing, so it fails the command as surely as a failed one.
1681
+ export function verifyExitCode(report) {
1682
+ return report.failed > 0 || report.unclear > 0 ? 1 : 0;
1683
+ }
1684
+
1685
+ // ---- cli --------------------------------------------------------------------
1686
+
1687
+ export function parseArgs(argv, env = process.env) {
1688
+ const cmd = argv[0] && !argv[0].startsWith("--") ? argv[0] : "run";
1689
+ const rest = argv[0] && !argv[0].startsWith("--") ? argv.slice(1) : argv;
1690
+ const flags = new Map();
1691
+ for (let i = 0; i < rest.length; i++) {
1692
+ const a = rest[i];
1693
+ if (!a.startsWith("--")) continue;
1694
+ const [name, inline] = a.slice(2).split("=", 2);
1695
+ if (inline !== undefined) flags.set(name, inline);
1696
+ else if (rest[i + 1] && !rest[i + 1].startsWith("--")) flags.set(name, rest[++i]);
1697
+ else flags.set(name, "true");
1698
+ }
1699
+ const num = (k, d) => (flags.has(k) ? Number(flags.get(k)) : d);
1700
+ // A positive whole number from a flag, else the env, else the
1701
+ // default. A bad value refuses rather than falling back: a cap silently
1702
+ // replaced by something else is the defect this exists to prevent.
1703
+ const count = (flag, envName, d) => {
1704
+ const fromEnv = (env[envName] ?? "").trim();
1705
+ const raw = flags.has(flag) ? String(flags.get(flag)) : fromEnv !== "" ? fromEnv : undefined;
1706
+ if (raw === undefined) return d;
1707
+ if (!/^\d+$/.test(raw) || Number(raw) < 1)
1708
+ throw new Error(
1709
+ `--${flag} (${envName}) must be a whole number of at least 1, got ${JSON.stringify(raw)}`,
1710
+ );
1711
+ return Number(raw);
1712
+ };
1713
+ const gateway = flags.get("gateway") ?? env.QCR_MEMORY_GATEWAY ?? DEFAULTS.gateway;
1714
+ const port = num("port", Number(env.QCR_MEMORY_PORT ?? DEFAULTS.port));
1715
+ const sshHost = flags.get("ssh-host") ?? env.QCR_SSH_HOST ?? DEFAULTS.sshHost;
1716
+ // One-machine topology — the relay is a plain local `docker exec`,
1717
+ // no ssh hop. `--local` (or QCR_MEMORY_LOCAL=1) selects it; an explicit
1718
+ // empty --ssh-host means the same thing (there is no host to ssh to).
1719
+ const local =
1720
+ flags.has("local") ||
1721
+ ["1", "true"].includes((env.QCR_MEMORY_LOCAL ?? "").trim()) ||
1722
+ sshHost === "";
1723
+ const cfg = {
1724
+ network: flags.get("network") ?? DEFAULTS.network,
1725
+ subnet: flags.get("subnet") ?? DEFAULTS.subnet,
1726
+ gateway,
1727
+ port,
1728
+ // Loopback too: `verify`, curl and any host-side tool use it, and it is the
1729
+ // half that keeps working when docker is down.
1730
+ binds: (flags.get("bind") ?? `${gateway},127.0.0.1`)
1731
+ .split(",")
1732
+ .map((s) => s.trim())
1733
+ .filter(Boolean),
1734
+ sshHost,
1735
+ local,
1736
+ container:
1737
+ flags.get("container") ??
1738
+ env.QCR_VM_CONTAINER ??
1739
+ (local ? DEFAULTS.localContainer : DEFAULTS.container),
1740
+ remoteHost: flags.get("remote-host") ?? DEFAULTS.remoteHost,
1741
+ remotePort: num("remote-port", DEFAULTS.remotePort),
1742
+ sudo: !flags.has("no-sudo"),
1743
+ identity: flags.get("identity"),
1744
+ controlPath: flags.get("control-path") ?? defaultControlPath(),
1745
+ // One parser, so the systemd unit and the TUI (which runs
1746
+ // this same parseArgs) carry the same cap.
1747
+ maxRelays: count("max-relays", "QCR_MEMORY_MAX_RELAYS", DEFAULTS.maxRelays),
1748
+ queueMax: count("queue-max", "QCR_MEMORY_QUEUE_MAX", DEFAULTS.queueMax),
1749
+ queueTimeoutMs:
1750
+ count("queue-timeout", "QCR_MEMORY_QUEUE_TIMEOUT_S", DEFAULTS.queueTimeoutS) * 1000,
1751
+ statsFile: flags.get("stats-file") ?? statsFilePath(env, port),
1752
+ unit: flags.get("unit") ?? DEFAULTS.unit,
1753
+ write: flags.has("write"),
1754
+ base: flags.get("base") ?? `http://127.0.0.1:${port}`,
1755
+ keysFile: flags.get("keys") ?? keysFilePath(env),
1756
+ };
1757
+ return { cmd, cfg, flags };
1758
+ }
1759
+
1760
+ function errText(err) {
1761
+ if (err instanceof Error)
1762
+ return err.name === "TimeoutError" || err.name === "AbortError" ? "timeout" : err.message;
1763
+ return String(err);
1764
+ }
1765
+
1766
+ function run(bin, args) {
1767
+ return spawnSync(bin, args, { encoding: "utf8" });
1768
+ }
1769
+
1770
+ /** @param {any} cfg
1771
+ * @param {(bin: string, args: string[]) => {status: number | null, stdout: string, stderr: string}} execute */
1772
+ function ensureNetwork(cfg, execute = run) {
1773
+ const inspect = execute("docker", [
1774
+ "network",
1775
+ "inspect",
1776
+ "--format",
1777
+ "{{range .IPAM.Config}}{{.Gateway}}{{end}}",
1778
+ cfg.network,
1779
+ ]);
1780
+ if (inspect.status === 0 && inspect.stdout.trim() !== "") {
1781
+ console.log(`docker network ${cfg.network} present (gateway ${inspect.stdout.trim()})`);
1782
+ return 0;
1783
+ }
1784
+ const created = execute("docker", [
1785
+ "network",
1786
+ "create",
1787
+ "--subnet",
1788
+ cfg.subnet,
1789
+ ...(cfg.gateway ? ["--gateway", cfg.gateway] : []),
1790
+ cfg.network,
1791
+ ]);
1792
+ if (created.status !== 0) {
1793
+ console.error(
1794
+ `could not create docker network ${cfg.network}: ${(created.stderr || "").trim()}`,
1795
+ );
1796
+ return 1;
1797
+ }
1798
+ console.log(`docker network ${cfg.network} created (${cfg.subnet})`);
1799
+ return 0;
1800
+ }
1801
+
1802
+ /** @param {any} cfg
1803
+ * @param {{execute?: (bin: string, args: string[]) => {status: number | null, stdout: string, stderr: string}, home?: string}} deps */
1804
+ export function install(cfg, { execute = run, home = homedir() } = {}) {
1805
+ if (ensureNetwork(cfg, execute) !== 0) return 1;
1806
+ const path = unitPath(cfg, home);
1807
+ mkdirSync(dirname(path), { recursive: true });
1808
+ writeFileSync(path, unitText(cfg));
1809
+ console.log(`wrote ${path}`);
1810
+ const auth = unitAuthSock();
1811
+ if (!auth.stable) console.log(`WARNING: ${auth.why}`);
1812
+ const keys = cfg.keysFile;
1813
+ if (!existsSync(keys)) {
1814
+ console.log(`\nNEXT: create ${keys} (chmod 600) with the two OpenViking USER keys:`);
1815
+ console.log(" OPENVIKING_API_KEY=<companion user key>");
1816
+ console.log(" OPENVIKING_COMPANION_KEY=<companion user key>");
1817
+ console.log(" OPENVIKING_REVIEWER_KEY=<reviewer user key>");
1818
+ }
1819
+ for (const args of [
1820
+ ["--user", "daemon-reload"],
1821
+ ["--user", "enable", `${cfg.unit}.service`],
1822
+ ["--user", "restart", `${cfg.unit}.service`],
1823
+ ]) {
1824
+ const r = execute("systemctl", args);
1825
+ if (r.status !== 0) {
1826
+ console.error(`systemctl ${args.join(" ")} failed: ${(r.stderr || "").trim()}`);
1827
+ return 1;
1828
+ }
1829
+ }
1830
+ console.log(`systemd unit ${cfg.unit}.service enabled and started`);
1831
+ console.log(
1832
+ `run once so it survives logout/reboot: loginctl enable-linger ${process.env.USER ?? ""}`,
1833
+ );
1834
+ return 0;
1835
+ }
1836
+
1837
+ /** @param {any} cfg
1838
+ * @param {(bin: string, args: string[]) => {status: number | null, stdout: string, stderr: string}} execute */
1839
+ export function uninstall(cfg, execute = run) {
1840
+ const result = execute("systemctl", ["--user", "disable", "--now", `${cfg.unit}.service`]);
1841
+ if (result.status !== 0) {
1842
+ console.error(`could not stop ${cfg.unit}.service: ${(result.stderr || "").trim()}`);
1843
+ return 1;
1844
+ }
1845
+ console.log(`${cfg.unit}.service disabled (unit file, docker network and keys left in place)`);
1846
+ return 0;
1847
+ }
1848
+
1849
+ async function status(cfg) {
1850
+ const unit = run("systemctl", ["--user", "is-active", `${cfg.unit}.service`]);
1851
+ console.log(
1852
+ `unit ${cfg.unit}.service: ${(unit.stdout || unit.stderr || "").trim() || "unknown"}`,
1853
+ );
1854
+ const net = run("docker", [
1855
+ "network",
1856
+ "inspect",
1857
+ "--format",
1858
+ "{{range .IPAM.Config}}{{.Gateway}}{{end}}",
1859
+ cfg.network,
1860
+ ]);
1861
+ console.log(
1862
+ `docker network ${cfg.network}: ${net.status === 0 ? `gateway ${net.stdout.trim()}` : "absent"}`,
1863
+ );
1864
+ console.log(`endpoint for the container: http://${cfg.gateway}:${cfg.port}`);
1865
+ // The cap, and the running forward's own live counts.
1866
+ for (const line of describeForwardStats(cfg, readForwardStats(cfg.statsFile))) console.log(line);
1867
+ for (const probe of ["/health", "/ready"]) {
1868
+ try {
1869
+ const res = await fetch(`http://127.0.0.1:${cfg.port}${probe}`, {
1870
+ signal: AbortSignal.timeout(5000),
1871
+ });
1872
+ console.log(` ${probe}: HTTP ${res.status}`);
1873
+ } catch (err) {
1874
+ console.log(` ${probe}: ${errText(err)}`);
1875
+ }
1876
+ }
1877
+ const keys = readKeys(cfg.keysFile);
1878
+ const have = ["OPENVIKING_API_KEY", "OPENVIKING_COMPANION_KEY", "OPENVIKING_REVIEWER_KEY"].filter(
1879
+ (n) => keys[n],
1880
+ );
1881
+ console.log(
1882
+ `keys file ${cfg.keysFile}: ${have.length > 0 ? `defines ${have.join(", ")}` : "missing or empty"}`,
1883
+ );
1884
+ return 0;
1885
+ }
1886
+
1887
+ async function verify(cfg) {
1888
+ const raw = readKeys(cfg.keysFile);
1889
+ const keys = {
1890
+ companion: raw.OPENVIKING_COMPANION_KEY ?? raw.OPENVIKING_API_KEY,
1891
+ reviewer: raw.OPENVIKING_REVIEWER_KEY,
1892
+ };
1893
+ if (!keys.companion || !keys.reviewer) {
1894
+ console.error(
1895
+ `both keys are required in ${cfg.keysFile} (OPENVIKING_COMPANION_KEY / OPENVIKING_API_KEY and OPENVIKING_REVIEWER_KEY)`,
1896
+ );
1897
+ return 2;
1898
+ }
1899
+ console.log(`probing ${cfg.base} (${cfg.write ? "read + write" : "read-only"})\n`);
1900
+ const report = await runVerify({ base: cfg.base, keys, write: cfg.write });
1901
+ return verifyExitCode(report);
1902
+ }
1903
+
1904
+ async function main(argv) {
1905
+ const { cmd, cfg } = parseArgs(argv);
1906
+ switch (cmd) {
1907
+ case "ensure-network":
1908
+ return ensureNetwork(cfg);
1909
+ case "install":
1910
+ return install(cfg);
1911
+ case "uninstall":
1912
+ return uninstall(cfg);
1913
+ case "status":
1914
+ return status(cfg);
1915
+ case "verify":
1916
+ return verify(cfg);
1917
+ case "run": {
1918
+ const fwd = createForwarder(cfg);
1919
+ const bound = await fwd.listen();
1920
+ if (bound < cfg.binds.length) {
1921
+ // Half a forward is not a forward: the bridge address is the one the
1922
+ // container uses. Exit and let systemd retry in RestartSec rather than
1923
+ // sit there pretending to serve.
1924
+ console.error(
1925
+ `[memory-forward] bound ${bound}/${cfg.binds.length} addresses (${cfg.binds.join(", ")}) — is the docker network up?`,
1926
+ );
1927
+ fwd.close();
1928
+ return 1;
1929
+ }
1930
+ for (const sig of ["SIGTERM", "SIGINT"]) {
1931
+ process.on(sig, () => {
1932
+ fwd.close();
1933
+ process.exit(0);
1934
+ });
1935
+ }
1936
+ return new Promise(() => {});
1937
+ }
1938
+ default:
1939
+ console.error(`unknown command: ${cmd}`);
1940
+ console.error(
1941
+ "usage: companion-memory-forward.mjs [run|install|uninstall|status|ensure-network|verify] [--flags]",
1942
+ );
1943
+ return 2;
1944
+ }
1945
+ }
1946
+
1947
+ const invokedDirectly = (() => {
1948
+ try {
1949
+ return (
1950
+ process.argv[1] !== undefined &&
1951
+ import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href
1952
+ );
1953
+ } catch {
1954
+ return false;
1955
+ }
1956
+ })();
1957
+ if (invokedDirectly) {
1958
+ main(process.argv.slice(2))
1959
+ .then((code) => {
1960
+ if (typeof code === "number" && code !== 0) process.exit(code);
1961
+ })
1962
+ .catch((err) => {
1963
+ console.error(`[memory-forward] ${errText(err)}`);
1964
+ process.exit(1);
1965
+ });
1966
+ }