quoxcli 0.0.0-stage → 0.2.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 (348) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +142 -2
  3. package/bin/quox.js +292 -0
  4. package/bin/quoxon.js +8 -0
  5. package/lib/api/endpoints/admin.js +125 -0
  6. package/lib/api/endpoints/aee.js +38 -0
  7. package/lib/api/endpoints/agent-attach.js +126 -0
  8. package/lib/api/endpoints/agents.js +56 -0
  9. package/lib/api/endpoints/assistants.js +50 -0
  10. package/lib/api/endpoints/audit.js +14 -0
  11. package/lib/api/endpoints/auth.js +50 -0
  12. package/lib/api/endpoints/awareness.js +34 -0
  13. package/lib/api/endpoints/backup-schedules.js +20 -0
  14. package/lib/api/endpoints/backups.js +86 -0
  15. package/lib/api/endpoints/brain.js +140 -0
  16. package/lib/api/endpoints/budgets.js +62 -0
  17. package/lib/api/endpoints/calendarq.js +71 -0
  18. package/lib/api/endpoints/catalog.js +14 -0
  19. package/lib/api/endpoints/chat.js +54 -0
  20. package/lib/api/endpoints/consent.js +26 -0
  21. package/lib/api/endpoints/contactsq.js +66 -0
  22. package/lib/api/endpoints/conversations.js +32 -0
  23. package/lib/api/endpoints/custom-roles.js +92 -0
  24. package/lib/api/endpoints/divisions.js +56 -0
  25. package/lib/api/endpoints/domains.js +38 -0
  26. package/lib/api/endpoints/engagement.js +231 -0
  27. package/lib/api/endpoints/engine.js +11 -0
  28. package/lib/api/endpoints/entity.js +50 -0
  29. package/lib/api/endpoints/executions.js +32 -0
  30. package/lib/api/endpoints/file.js +20 -0
  31. package/lib/api/endpoints/files.js +26 -0
  32. package/lib/api/endpoints/github.js +21 -0
  33. package/lib/api/endpoints/governance.js +43 -0
  34. package/lib/api/endpoints/hat.js +36 -0
  35. package/lib/api/endpoints/inbox.js +32 -0
  36. package/lib/api/endpoints/inboxq.js +120 -0
  37. package/lib/api/endpoints/index.js +228 -0
  38. package/lib/api/endpoints/invites.js +19 -0
  39. package/lib/api/endpoints/llm-policy.js +43 -0
  40. package/lib/api/endpoints/lucid.js +89 -0
  41. package/lib/api/endpoints/members.js +32 -0
  42. package/lib/api/endpoints/memory.js +75 -0
  43. package/lib/api/endpoints/mfa.js +32 -0
  44. package/lib/api/endpoints/mon.js +178 -0
  45. package/lib/api/endpoints/notesq.js +43 -0
  46. package/lib/api/endpoints/objectives.js +62 -0
  47. package/lib/api/endpoints/orgs.js +38 -0
  48. package/lib/api/endpoints/plugins.js +134 -0
  49. package/lib/api/endpoints/powers.js +75 -0
  50. package/lib/api/endpoints/prompts.js +69 -0
  51. package/lib/api/endpoints/qlarity.js +122 -0
  52. package/lib/api/endpoints/quoxbox.js +55 -0
  53. package/lib/api/endpoints/quoxchat.js +54 -0
  54. package/lib/api/endpoints/quoxflow.js +32 -0
  55. package/lib/api/endpoints/quoxplan.js +114 -0
  56. package/lib/api/endpoints/quoxseo.js +108 -0
  57. package/lib/api/endpoints/reach.js +47 -0
  58. package/lib/api/endpoints/runs.js +38 -0
  59. package/lib/api/endpoints/safety.js +46 -0
  60. package/lib/api/endpoints/scim.js +46 -0
  61. package/lib/api/endpoints/scopes.js +14 -0
  62. package/lib/api/endpoints/security.js +69 -0
  63. package/lib/api/endpoints/setup.js +90 -0
  64. package/lib/api/endpoints/skills.js +180 -0
  65. package/lib/api/endpoints/sso.js +67 -0
  66. package/lib/api/endpoints/tags.js +32 -0
  67. package/lib/api/endpoints/teams.js +56 -0
  68. package/lib/api/endpoints/telephony.js +46 -0
  69. package/lib/api/endpoints/tg.js +56 -0
  70. package/lib/api/endpoints/tools.js +50 -0
  71. package/lib/api/endpoints/traces.js +157 -0
  72. package/lib/api/endpoints/triggers.js +70 -0
  73. package/lib/api/endpoints/user-library.js +41 -0
  74. package/lib/api/endpoints/users.js +20 -0
  75. package/lib/api/endpoints/v1.js +499 -0
  76. package/lib/api/endpoints/vault.js +192 -0
  77. package/lib/api/endpoints/voice.js +22 -0
  78. package/lib/api/endpoints/volt.js +84 -0
  79. package/lib/api/endpoints/ward.js +40 -0
  80. package/lib/api/endpoints/webhooks.js +46 -0
  81. package/lib/api/endpoints/workflows.js +162 -0
  82. package/lib/api/endpoints/x.js +24 -0
  83. package/lib/api/endpoints.js +15 -0
  84. package/lib/commands/admin.js +359 -0
  85. package/lib/commands/aee.js +99 -0
  86. package/lib/commands/agent.js +201 -0
  87. package/lib/commands/agentAttach.js +1440 -0
  88. package/lib/commands/approvals.js +47 -0
  89. package/lib/commands/assistant.js +114 -0
  90. package/lib/commands/auth.js +458 -0
  91. package/lib/commands/awareness.js +88 -0
  92. package/lib/commands/backup.js +373 -0
  93. package/lib/commands/brain.js +994 -0
  94. package/lib/commands/budget.js +241 -0
  95. package/lib/commands/build.js +156 -0
  96. package/lib/commands/calendar.js +216 -0
  97. package/lib/commands/capabilities.js +34 -0
  98. package/lib/commands/chat.js +240 -0
  99. package/lib/commands/claude.js +354 -0
  100. package/lib/commands/config-cmd.js +106 -0
  101. package/lib/commands/consent.js +84 -0
  102. package/lib/commands/contacts.js +967 -0
  103. package/lib/commands/contactsSelftest.js +132 -0
  104. package/lib/commands/conversation.js +74 -0
  105. package/lib/commands/department.js +75 -0
  106. package/lib/commands/dev.js +620 -0
  107. package/lib/commands/devResident.js +314 -0
  108. package/lib/commands/devSupervisor.js +1050 -0
  109. package/lib/commands/discord.js +96 -0
  110. package/lib/commands/doctor.js +181 -0
  111. package/lib/commands/domain.js +114 -0
  112. package/lib/commands/done.js +277 -0
  113. package/lib/commands/engagement.js +586 -0
  114. package/lib/commands/entity.js +119 -0
  115. package/lib/commands/facebook.js +212 -0
  116. package/lib/commands/file.js +128 -0
  117. package/lib/commands/fleet.js +882 -0
  118. package/lib/commands/governance.js +638 -0
  119. package/lib/commands/gripe.js +324 -0
  120. package/lib/commands/hat.js +205 -0
  121. package/lib/commands/helpers.js +79 -0
  122. package/lib/commands/inbox.js +122 -0
  123. package/lib/commands/instagram.js +264 -0
  124. package/lib/commands/integration.js +100 -0
  125. package/lib/commands/inventory.js +115 -0
  126. package/lib/commands/invites.js +45 -0
  127. package/lib/commands/licence.js +280 -0
  128. package/lib/commands/linkedin.js +178 -0
  129. package/lib/commands/llm-policy.js +117 -0
  130. package/lib/commands/llm.js +45 -0
  131. package/lib/commands/lucid.js +591 -0
  132. package/lib/commands/mail.js +531 -0
  133. package/lib/commands/memory.js +284 -0
  134. package/lib/commands/mon.js +514 -0
  135. package/lib/commands/monitoring.js +86 -0
  136. package/lib/commands/motion.js +22 -0
  137. package/lib/commands/notes.js +1246 -0
  138. package/lib/commands/notesBatch.js +160 -0
  139. package/lib/commands/notesBridges.js +56 -0
  140. package/lib/commands/notesLinks.js +60 -0
  141. package/lib/commands/notification.js +57 -0
  142. package/lib/commands/nucleus.js +92 -0
  143. package/lib/commands/objective.js +229 -0
  144. package/lib/commands/onboard.js +328 -0
  145. package/lib/commands/open.js +56 -0
  146. package/lib/commands/org.js +266 -0
  147. package/lib/commands/plan.js +906 -0
  148. package/lib/commands/plugin-doctor.js +1091 -0
  149. package/lib/commands/plugin.js +539 -0
  150. package/lib/commands/policy.js +78 -0
  151. package/lib/commands/powers.js +296 -0
  152. package/lib/commands/prompt.js +350 -0
  153. package/lib/commands/qlarity.js +690 -0
  154. package/lib/commands/quoxbox.js +270 -0
  155. package/lib/commands/quoxchat.js +266 -0
  156. package/lib/commands/quoxseo.js +340 -0
  157. package/lib/commands/reach.js +158 -0
  158. package/lib/commands/role.js +286 -0
  159. package/lib/commands/run.js +132 -0
  160. package/lib/commands/safety.js +107 -0
  161. package/lib/commands/scim.js +88 -0
  162. package/lib/commands/scope.js +58 -0
  163. package/lib/commands/security.js +157 -0
  164. package/lib/commands/service.js +89 -0
  165. package/lib/commands/settings.js +99 -0
  166. package/lib/commands/setup.js +543 -0
  167. package/lib/commands/skillManifest.js +113 -0
  168. package/lib/commands/skillRightsFormat.js +38 -0
  169. package/lib/commands/skills.js +644 -0
  170. package/lib/commands/slot.js +1198 -0
  171. package/lib/commands/sso.js +229 -0
  172. package/lib/commands/status.js +137 -0
  173. package/lib/commands/tag.js +91 -0
  174. package/lib/commands/team.js +140 -0
  175. package/lib/commands/tg.js +240 -0
  176. package/lib/commands/tiktok.js +218 -0
  177. package/lib/commands/tool.js +216 -0
  178. package/lib/commands/trace.js +525 -0
  179. package/lib/commands/trigger.js +128 -0
  180. package/lib/commands/vault.js +1424 -0
  181. package/lib/commands/voice.js +182 -0
  182. package/lib/commands/volt.js +528 -0
  183. package/lib/commands/watch.js +115 -0
  184. package/lib/commands/webhook.js +137 -0
  185. package/lib/commands/workflow.js +503 -0
  186. package/lib/commands/x.js +439 -0
  187. package/lib/core/actingAs.js +136 -0
  188. package/lib/core/aee.js +53 -0
  189. package/lib/core/agentAlias.js +163 -0
  190. package/lib/core/approvalContext.js +61 -0
  191. package/lib/core/auth.js +350 -0
  192. package/lib/core/banner.js +143 -0
  193. package/lib/core/brainPrime.js +90 -0
  194. package/lib/core/callerSource.js +129 -0
  195. package/lib/core/claudeLs.js +94 -0
  196. package/lib/core/client.js +584 -0
  197. package/lib/core/completions.js +163 -0
  198. package/lib/core/config.js +551 -0
  199. package/lib/core/confirm.js +37 -0
  200. package/lib/core/deeplinks.js +76 -0
  201. package/lib/core/doneEngineFetch.js +219 -0
  202. package/lib/core/errors.js +132 -0
  203. package/lib/core/global-flags.js +90 -0
  204. package/lib/core/governedSession.js +402 -0
  205. package/lib/core/lazyCommands.js +119 -0
  206. package/lib/core/liveSessionsReport.js +112 -0
  207. package/lib/core/org-override.js +52 -0
  208. package/lib/core/output.js +239 -0
  209. package/lib/core/prodEnv.js +7 -0
  210. package/lib/core/quoxglyph.js +158 -0
  211. package/lib/core/residentAlarm.js +282 -0
  212. package/lib/core/residentAuth.js +243 -0
  213. package/lib/core/residentPump.js +229 -0
  214. package/lib/core/residentSeed.js +104 -0
  215. package/lib/core/sessionHeartbeat.js +192 -0
  216. package/lib/core/sessionsOverview.js +160 -0
  217. package/lib/core/slotKeeper.js +282 -0
  218. package/lib/core/supervisorEngine.js +864 -0
  219. package/lib/core/systemdInstaller.js +256 -0
  220. package/lib/core/version.js +87 -0
  221. package/lib/core/websiteSession.js +148 -0
  222. package/lib/lucid/LucidIntro.jsx +105 -0
  223. package/lib/lucid/blind.js +71 -0
  224. package/lib/lucid/deck.js +32 -0
  225. package/lib/lucid/fmt.js +139 -0
  226. package/lib/lucid/sitrep.js +173 -0
  227. package/lib/plugins/loader.js +93 -0
  228. package/lib/quoxon/activity.js +104 -0
  229. package/lib/quoxon/api.js +473 -0
  230. package/lib/quoxon/app.js +1262 -0
  231. package/lib/quoxon/capabilities.js +75 -0
  232. package/lib/quoxon/commands.js +154 -0
  233. package/lib/quoxon/connect.js +467 -0
  234. package/lib/quoxon/connectCard.js +28 -0
  235. package/lib/quoxon/context.js +68 -0
  236. package/lib/quoxon/copy.js +33 -0
  237. package/lib/quoxon/demo.fixtures.js +24 -0
  238. package/lib/quoxon/demo.js +35 -0
  239. package/lib/quoxon/egg.js +36 -0
  240. package/lib/quoxon/events.js +458 -0
  241. package/lib/quoxon/gallery.js +223 -0
  242. package/lib/quoxon/indicator.js +193 -0
  243. package/lib/quoxon/intent.js +41 -0
  244. package/lib/quoxon/keys.js +121 -0
  245. package/lib/quoxon/main.js +244 -0
  246. package/lib/quoxon/markdown.js +317 -0
  247. package/lib/quoxon/model.js +205 -0
  248. package/lib/quoxon/motion/clock.js +69 -0
  249. package/lib/quoxon/motion/color.js +35 -0
  250. package/lib/quoxon/motion/frame.js +14 -0
  251. package/lib/quoxon/motion/reveal.js +54 -0
  252. package/lib/quoxon/motion/ring.js +79 -0
  253. package/lib/quoxon/objects/agent.js +61 -0
  254. package/lib/quoxon/objects/crystal.js +100 -0
  255. package/lib/quoxon/objects/fetch.js +170 -0
  256. package/lib/quoxon/objects/index.js +85 -0
  257. package/lib/quoxon/objects/ladder.js +71 -0
  258. package/lib/quoxon/objects/memory.js +44 -0
  259. package/lib/quoxon/objects/model.js +118 -0
  260. package/lib/quoxon/objects/proof.js +110 -0
  261. package/lib/quoxon/objects/skill.js +105 -0
  262. package/lib/quoxon/objects/view.js +76 -0
  263. package/lib/quoxon/objects/workflow.js +119 -0
  264. package/lib/quoxon/observers.js +42 -0
  265. package/lib/quoxon/onboarding/copy.js +235 -0
  266. package/lib/quoxon/onboarding/credential.js +95 -0
  267. package/lib/quoxon/onboarding/detect.js +22 -0
  268. package/lib/quoxon/onboarding/finish.js +119 -0
  269. package/lib/quoxon/onboarding/index.js +67 -0
  270. package/lib/quoxon/onboarding/installRunner.js +225 -0
  271. package/lib/quoxon/onboarding/intent.js +53 -0
  272. package/lib/quoxon/onboarding/journey.js +73 -0
  273. package/lib/quoxon/onboarding/preflight.js +142 -0
  274. package/lib/quoxon/onboarding/runner.js +58 -0
  275. package/lib/quoxon/onboarding/view.js +625 -0
  276. package/lib/quoxon/print.js +94 -0
  277. package/lib/quoxon/qart.js +379 -0
  278. package/lib/quoxon/qglyph.js +143 -0
  279. package/lib/quoxon/qhello.js +58 -0
  280. package/lib/quoxon/qmotion.js +111 -0
  281. package/lib/quoxon/render.js +648 -0
  282. package/lib/quoxon/roster.js +221 -0
  283. package/lib/quoxon/route.js +38 -0
  284. package/lib/quoxon/rules.js +130 -0
  285. package/lib/quoxon/session.js +68 -0
  286. package/lib/quoxon/skills.js +30 -0
  287. package/lib/quoxon/slash/compact.js +90 -0
  288. package/lib/quoxon/slash/ledger.js +83 -0
  289. package/lib/quoxon/slash/listView.js +28 -0
  290. package/lib/quoxon/slash/palette.js +104 -0
  291. package/lib/quoxon/slash/paletteController.js +59 -0
  292. package/lib/quoxon/slash/panels.js +101 -0
  293. package/lib/quoxon/slash/picker.js +38 -0
  294. package/lib/quoxon/slash/restart.js +144 -0
  295. package/lib/quoxon/slash/resume.js +59 -0
  296. package/lib/quoxon/slash/settings.js +50 -0
  297. package/lib/quoxon/sound/index.js +186 -0
  298. package/lib/quoxon/sound/mapping.js +119 -0
  299. package/lib/quoxon/sound/output.js +52 -0
  300. package/lib/quoxon/sound/synth.js +122 -0
  301. package/lib/quoxon/term.js +106 -0
  302. package/lib/quoxon/textBuffer.js +242 -0
  303. package/lib/quoxon/theme.js +253 -0
  304. package/lib/quoxon/transcript.js +150 -0
  305. package/lib/quoxon/web/qface.js +81 -0
  306. package/lib/quoxon/welcome.js +52 -0
  307. package/lib/tui/app.jsx +289 -0
  308. package/lib/tui/components/Breadcrumb.jsx +42 -0
  309. package/lib/tui/components/ChatInput.jsx +68 -0
  310. package/lib/tui/components/Confirm.jsx +32 -0
  311. package/lib/tui/components/DataTable.jsx +270 -0
  312. package/lib/tui/components/DetailPanel.jsx +241 -0
  313. package/lib/tui/components/HelpOverlay.jsx +67 -0
  314. package/lib/tui/components/Layout.jsx +129 -0
  315. package/lib/tui/components/Palette.jsx +141 -0
  316. package/lib/tui/components/SearchInput.jsx +125 -0
  317. package/lib/tui/components/Sparkline.jsx +55 -0
  318. package/lib/tui/components/StatusBar.jsx +133 -0
  319. package/lib/tui/components/Toast.jsx +52 -0
  320. package/lib/tui/hooks/useApi.js +44 -0
  321. package/lib/tui/hooks/useHealth.js +90 -0
  322. package/lib/tui/hooks/usePolledApi.js +86 -0
  323. package/lib/tui/hooks/useTerminalSize.js +46 -0
  324. package/lib/tui/index.js +56 -0
  325. package/lib/tui/intro/Intro.jsx +332 -0
  326. package/lib/tui/jsx-loader.js +32 -0
  327. package/lib/tui/keys.js +87 -0
  328. package/lib/tui/registry.js +49 -0
  329. package/lib/tui/theme.js +103 -0
  330. package/lib/tui/views/AdminView.jsx +398 -0
  331. package/lib/tui/views/AgentView.jsx +204 -0
  332. package/lib/tui/views/BackupView.jsx +225 -0
  333. package/lib/tui/views/BrainView.jsx +223 -0
  334. package/lib/tui/views/ChatView.jsx +468 -0
  335. package/lib/tui/views/FilesView.jsx +218 -0
  336. package/lib/tui/views/FleetView.jsx +234 -0
  337. package/lib/tui/views/GovernanceView.jsx +453 -0
  338. package/lib/tui/views/HealthView.jsx +216 -0
  339. package/lib/tui/views/InboxView.jsx +228 -0
  340. package/lib/tui/views/LucidDeck.jsx +727 -0
  341. package/lib/tui/views/MainMenu.jsx +123 -0
  342. package/lib/tui/views/MemoryView.jsx +377 -0
  343. package/lib/tui/views/ObjectivesView.jsx +230 -0
  344. package/lib/tui/views/OrgView.jsx +229 -0
  345. package/lib/tui/views/ToolView.jsx +221 -0
  346. package/lib/tui/views/VaultView.jsx +422 -0
  347. package/lib/tui/views/WorkflowView.jsx +329 -0
  348. package/package.json +72 -4
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Resident agent authentication for QuoxCLI (quox#453 phase 15)
3
+ *
4
+ * A dev server's resident agent authenticates with an Ed25519 key on disk
5
+ * instead of a password, zero human minting. The exchange is a signed
6
+ * challenge:
7
+ *
8
+ * 1. POST {baseUrl}/api/auth/resident/challenge {org_id, host_id}
9
+ * -> {challenge_id, challenge_b64, expires_in}
10
+ * 2. sign the raw challenge bytes with the Ed25519 private key at key_path
11
+ * 3. POST {baseUrl}/api/auth/resident/token {challenge_id, org_id, host_id, signature_b64}
12
+ * -> {access_token, token_type, expires_in, user}
13
+ *
14
+ * Bootstrap file on a dev host (not secret itself, points at the real key):
15
+ * ~/.config/quox/resident-profile.json
16
+ * {kind: 'resident', org_id, host_id, key_id, key_path, instance_url}
17
+ *
18
+ * Neither the private key nor any resulting token is ever printed or logged
19
+ * by this module.
20
+ */
21
+
22
+ import { existsSync, readFileSync } from 'node:fs';
23
+ import { homedir } from 'node:os';
24
+ import { join } from 'node:path';
25
+ import { sign, createPrivateKey } from 'node:crypto';
26
+ import { QuoxError, QuoxAuthError, QuoxTransientAuthError } from './errors.js';
27
+
28
+ export function defaultResidentProfilePath() {
29
+ return join(homedir(), '.config', 'quox', 'resident-profile.json');
30
+ }
31
+
32
+ export function expandHome(p) {
33
+ if (!p) return p;
34
+ if (p === '~') return homedir();
35
+ if (p.startsWith('~/')) return join(homedir(), p.slice(2));
36
+ return p;
37
+ }
38
+
39
+ /**
40
+ * Load and validate the resident bootstrap profile.
41
+ * @param {string} [explicitPath] - overrides the default ~/.config/quox/resident-profile.json
42
+ */
43
+ export function loadResidentProfile(explicitPath) {
44
+ const path = expandHome(explicitPath) || defaultResidentProfilePath();
45
+
46
+ if (!existsSync(path)) {
47
+ throw new QuoxError(
48
+ `No resident profile found at ${path}. A resident agent needs this bootstrap file `
49
+ + `({kind, org_id, host_id, key_id, key_path, instance_url}) before it can authenticate. `
50
+ + `Run --resident-profile <path> to point at a different one.`
51
+ );
52
+ }
53
+
54
+ let raw;
55
+ try {
56
+ raw = readFileSync(path, 'utf-8');
57
+ } catch (err) {
58
+ throw new QuoxError(`Could not read resident profile at ${path}: ${err.message}`);
59
+ }
60
+
61
+ let data;
62
+ try {
63
+ data = JSON.parse(raw);
64
+ } catch (err) {
65
+ throw new QuoxError(`Resident profile at ${path} is not valid JSON: ${err.message}`);
66
+ }
67
+
68
+ const required = ['org_id', 'host_id', 'key_path'];
69
+ const missing = required.filter((key) => !data[key]);
70
+ if (missing.length > 0) {
71
+ throw new QuoxError(
72
+ `Resident profile at ${path} is missing required field(s): ${missing.join(', ')}.`
73
+ );
74
+ }
75
+
76
+ return { ...data, kind: 'resident', _path: path };
77
+ }
78
+
79
+ /**
80
+ * Prefer the bootstrap file's instance_url when the active CLI profile has
81
+ * no explicit URL of its own: a resident agent bootstraps its own instance
82
+ * pointer, it shouldn't need a human to also run `quox config`.
83
+ */
84
+ export function resolveResidentBaseUrl(config, profile) {
85
+ // quox#455 gap 1: the resident endpoints live on the instance EDGE
86
+ // (https://<host>:3000/api/auth/resident/*, proxied to auth). NEVER
87
+ // derive them by port-swapping to :3101: auth binds loopback on the
88
+ // control box, so :3101 is connection-refused from every remote host.
89
+ if (process.env.QUOX_AUTH_URL) return process.env.QUOX_AUTH_URL;
90
+ if (process.env.QUOX_URL) return process.env.QUOX_URL;
91
+ const activeProfile = typeof config.getActiveProfile === 'function' ? config.getActiveProfile() : null;
92
+ const hasExplicitUrl = !!(activeProfile && activeProfile.url && activeProfile.url !== 'http://localhost:3000');
93
+ if (hasExplicitUrl) return activeProfile.url;
94
+ if (profile?.instance_url) return profile.instance_url;
95
+ // Local default profile on the control box itself: auth is directly
96
+ // reachable on loopback :3101, keep the historical local-dev behaviour.
97
+ return config.getServiceUrl('auth');
98
+ }
99
+
100
+ /**
101
+ * True only when `urlStr` is https AND its host is a bare IP or a
102
+ * .local/LAN-style single-label name: the shapes that can never carry a
103
+ * real, publicly-issued certificate, so a self-signed edge is the expected
104
+ * (not the exceptional) case. A public FQDN with a real cert must never get
105
+ * this baked in, even if it happens to be reachable over the LAN.
106
+ */
107
+ function isSelfSignedLikelyHost(urlStr) {
108
+ let u;
109
+ try {
110
+ u = new URL(urlStr);
111
+ } catch {
112
+ return false;
113
+ }
114
+ if (u.protocol !== 'https:') return false;
115
+ const host = u.hostname;
116
+ if (!host) return false;
117
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true; // bare IPv4
118
+ if (host.includes(':')) return true; // bare IPv6 (URL strips the brackets)
119
+ if (/\.local$/i.test(host)) return true; // mDNS / LAN-style
120
+ if (!host.includes('.')) return true; // single-label hostname, never a public FQDN
121
+ return false;
122
+ }
123
+
124
+ /**
125
+ * Derive the systemd unit environment for a resident's headless supervisor
126
+ * from its bootstrap profile (item 1, live outage 2026-08-23: a unit run
127
+ * directly by systemd inherits none of the caller's shell environment, so
128
+ * every re-auth against a self-signed LAN edge died with a bare "fetch
129
+ * failed"). Baked in once, at install time, from the same profile the
130
+ * resident already trusts for its own identity: QUOX_URL and QUOX_AUTH_URL
131
+ * from instance_url, QUOX_COLLECTOR_URL from collector_url, and
132
+ * QUOX_INSECURE only when isSelfSignedLikelyHost(instance_url) is true.
133
+ * Returns {} for a missing profile or a profile with neither URL: the
134
+ * caller is responsible for warning in that case, this function only ever
135
+ * derives, never decides whether the result is enough.
136
+ */
137
+ export function deriveSupervisorEnv(profile) {
138
+ const env = {};
139
+ if (!profile) return env;
140
+ if (profile.instance_url) {
141
+ env.QUOX_URL = profile.instance_url;
142
+ env.QUOX_AUTH_URL = profile.instance_url;
143
+ if (isSelfSignedLikelyHost(profile.instance_url)) env.QUOX_INSECURE = '1';
144
+ }
145
+ if (profile.collector_url) env.QUOX_COLLECTOR_URL = profile.collector_url;
146
+ return env;
147
+ }
148
+
149
+ const TRANSIENT_STATUSES = new Set([429, 502, 503, 504]);
150
+
151
+ // Retry-After is delta-seconds or an HTTP date; returns ms or null.
152
+ function parseRetryAfterMs(res) {
153
+ const raw = res?.headers && typeof res.headers.get === 'function' ? res.headers.get('retry-after') : null;
154
+ if (!raw) return null;
155
+ const secs = Number(raw);
156
+ if (Number.isFinite(secs) && secs >= 0) return Math.round(secs * 1000);
157
+ const at = Date.parse(raw);
158
+ return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
159
+ }
160
+
161
+ function exchangeFailure(what, res, data) {
162
+ const msg = `${what} failed (${res.status}): ${data?.error || res.statusText || 'unknown error'}`;
163
+ if (TRANSIENT_STATUSES.has(res.status)) return new QuoxTransientAuthError(msg, parseRetryAfterMs(res));
164
+ return new QuoxAuthError(msg);
165
+ }
166
+
167
+ async function safeJson(res) {
168
+ try {
169
+ return await res.json();
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Run the challenge/sign/token exchange and return the token response.
177
+ * Never logs the private key or the returned token.
178
+ *
179
+ * @param {object} opts
180
+ * @param {string} opts.baseUrl
181
+ * @param {object} opts.profile - result of loadResidentProfile()
182
+ * @param {typeof fetch} [opts.fetchImpl]
183
+ */
184
+ export async function residentLogin({ baseUrl, profile, fetchImpl = fetch }) {
185
+ const { org_id, host_id, key_path } = profile;
186
+ const keyPath = expandHome(key_path);
187
+
188
+ if (!existsSync(keyPath)) {
189
+ throw new QuoxError(
190
+ `Resident identity key not found at ${keyPath}. Provision the Ed25519 key before logging in.`
191
+ );
192
+ }
193
+
194
+ let keyPem;
195
+ try {
196
+ keyPem = readFileSync(keyPath, 'utf-8');
197
+ } catch (err) {
198
+ throw new QuoxError(`Could not read resident identity key at ${keyPath}: ${err.message}`);
199
+ }
200
+
201
+ let privateKey;
202
+ try {
203
+ privateKey = createPrivateKey(keyPem);
204
+ } catch (err) {
205
+ throw new QuoxError(`Resident identity key at ${keyPath} is not a valid private key: ${err.message}`);
206
+ }
207
+
208
+ // Step 1: request a fresh challenge.
209
+ const challengeRes = await fetchImpl(`${baseUrl}/api/auth/resident/challenge`, {
210
+ method: 'POST',
211
+ headers: { 'Content-Type': 'application/json' },
212
+ body: JSON.stringify({ org_id, host_id }),
213
+ });
214
+ const challengeData = await safeJson(challengeRes);
215
+ if (!challengeRes.ok) {
216
+ throw exchangeFailure('Resident challenge request', challengeRes, challengeData);
217
+ }
218
+ const { challenge_id, challenge_b64 } = challengeData || {};
219
+ if (!challenge_id || !challenge_b64) {
220
+ throw new QuoxError('Resident challenge response is missing challenge_id or challenge_b64.');
221
+ }
222
+
223
+ // Step 2: sign the raw challenge bytes with the Ed25519 key.
224
+ const challengeBytes = Buffer.from(challenge_b64, 'base64');
225
+ const signatureBytes = sign(null, challengeBytes, privateKey);
226
+ const signature_b64 = signatureBytes.toString('base64');
227
+
228
+ // Step 3: exchange the signature for an access token.
229
+ const tokenRes = await fetchImpl(`${baseUrl}/api/auth/resident/token`, {
230
+ method: 'POST',
231
+ headers: { 'Content-Type': 'application/json' },
232
+ body: JSON.stringify({ challenge_id, org_id, host_id, signature_b64 }),
233
+ });
234
+ const tokenData = await safeJson(tokenRes);
235
+ if (!tokenRes.ok) {
236
+ throw exchangeFailure('Resident token exchange', tokenRes, tokenData);
237
+ }
238
+ if (!tokenData?.access_token) {
239
+ throw new QuoxError('Resident token response is missing access_token.');
240
+ }
241
+
242
+ return tokenData;
243
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Resident pump — QUOXPERTICUS-REVISIT P4 "steer pump".
3
+ *
4
+ * When a resident's slot is held by a FULL INTERACTIVE claude session (the
5
+ * resident itself, deployed via `quox dev resident deploy`), the warden
6
+ * process never answers (see the HOLD-IS-THE-VOICE gate in
7
+ * supervisorEngine.js): the resident already holds the voice. What the
8
+ * warden does instead, while that hold lasts, is PUMP: relay new owner
9
+ * messages from the slot channel into the resident's live tmux pane via the
10
+ * dev-sessions `keys` route (services/collector/lib/devSessionsProxy.js), so
11
+ * the interactive session hears its owner without anyone opening the pane
12
+ * by hand.
13
+ *
14
+ * Live-spike findings this reads (2026-09-04, real injection into a real
15
+ * claude TUI on .131 via tmux send-keys):
16
+ * - Text injected mid-turn queues cleanly in the composer and submits when
17
+ * the turn ends. Injection mid-turn is SAFE.
18
+ * - A permission dialog ("Do you want to proceed? ... Esc to cancel")
19
+ * BLOCKS the pipeline indefinitely. Queued input survives but does not
20
+ * process. This pump NEVER injects bare Enter or any control key to
21
+ * resolve one; a stuck dialog is a residentAlarm.js condition, not
22
+ * something this module resolves. See paneHasDialog below.
23
+ * - `keys` is one line, max 4096 chars (the collector's MAX_KEYS_LENGTH),
24
+ * and the collector appends Enter itself.
25
+ *
26
+ * Every I/O dependency (fetch, pane read, key injection, heartbeat, the
27
+ * clock, sleep) is injected, matching supervisorEngine.js's contract, so
28
+ * this runs under vitest with no real network, no real tmux pane.
29
+ */
30
+
31
+ import { pickNewMessages, boundSeenIds, pruneOldTurns, shouldPostErrorNotice, ERROR_NOTICE_WINDOW_MS, DEFAULT_SETTINGS } from './supervisorEngine.js';
32
+
33
+ // The exact literal markers a Claude Code permission dialog shows
34
+ // ("Do you want to proceed? 1. Yes 2. No ... Esc to cancel"). Either marker
35
+ // alone is enough to hold injection: better to over-detect (delay a message
36
+ // one pass) than under-detect and blind-inject into a live approval prompt.
37
+ export const DIALOG_MARKERS = ['Do you want to proceed?', 'Esc to cancel'];
38
+
39
+ export function paneHasDialog(paneText) {
40
+ const text = String(paneText || '');
41
+ return DIALOG_MARKERS.some((marker) => text.includes(marker));
42
+ }
43
+
44
+ function sanitizeOneLine(text) {
45
+ return String(text || '').replace(/\r?\n+/g, ' ').replace(/[ \t]{2,}/g, ' ').trim();
46
+ }
47
+
48
+ /**
49
+ * Build the `keys` line(s) for one owner message: `[slot:<name> owner]
50
+ * <text>`, newlines collapsed to spaces (a literal newline mid-line would
51
+ * submit a half-typed line early — same reasoning as residentSeed.js's
52
+ * oneLine()). A message that overflows maxLen is CHUNKED, never truncated
53
+ * (silent data loss is worse than several turns): each chunk carries its
54
+ * own prefix and a `(i/n)` suffix so the resident can read each line
55
+ * standalone.
56
+ */
57
+ export function buildPumpLines({ slotName, text, maxLen = 4000 }) {
58
+ const prefix = `[slot:${slotName} owner] `;
59
+ const sanitized = sanitizeOneLine(text);
60
+ if (prefix.length + sanitized.length <= maxLen) {
61
+ return [`${prefix}${sanitized}`];
62
+ }
63
+ // Reserve headroom for the ` (NN/NN)` suffix appended to every chunk.
64
+ const suffixReserve = 10;
65
+ const available = Math.max(1, maxLen - prefix.length - suffixReserve);
66
+ const parts = [];
67
+ for (let i = 0; i < sanitized.length; i += available) {
68
+ parts.push(sanitized.slice(i, i + available));
69
+ }
70
+ return parts.map((part, i) => `${prefix}${part} (${i + 1}/${parts.length})`);
71
+ }
72
+
73
+ export function createInitialPumpState() {
74
+ return {
75
+ bootstrapped: false,
76
+ seen_ids: [],
77
+ // Owner messages picked up from the channel but not yet delivered to
78
+ // the pane: held back by a stuck dialog, or by the per-hour cap. A
79
+ // message only ever leaves `pending` by being injected, never dropped.
80
+ pending: [],
81
+ injection_timestamps: [],
82
+ overflow_notices: [],
83
+ // Set the moment a permission dialog is first observed; cleared the
84
+ // moment it is no longer observed. residentAlarm.js reads the age of
85
+ // this field to decide whether the stuck-dialog condition should alarm.
86
+ stuck_dialog_since: null,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Heartbeat the resident's slot hold with retry/backoff. The pump must not
92
+ * rely on the wait long-poll alone to keep the hold warm: a failed poll
93
+ * pass must never stop heartbeats while the collector is genuinely
94
+ * reachable, so this is a small standalone retry loop, called every pump
95
+ * pass regardless of what else happens this pass.
96
+ */
97
+ export async function heartbeatWithBackoff({
98
+ heartbeatHold,
99
+ log = () => {},
100
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
101
+ maxAttempts = 3,
102
+ baseDelayMs = 2000,
103
+ }) {
104
+ let lastErr = null;
105
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
106
+ try {
107
+ await heartbeatHold();
108
+ return { ok: true, attempts: attempt };
109
+ } catch (err) {
110
+ lastErr = err;
111
+ log('warn', `pump: heartbeat attempt ${attempt}/${maxAttempts} failed: ${err.message}`);
112
+ if (attempt < maxAttempts) {
113
+ await sleep(baseDelayMs * attempt);
114
+ }
115
+ }
116
+ }
117
+ log('warn', `pump: heartbeat failed after ${maxAttempts} attempt(s), giving up this pass (hold may lapse): ${lastErr ? lastErr.message : 'unknown error'}`);
118
+ return { ok: false, attempts: maxAttempts, error: lastErr };
119
+ }
120
+
121
+ /**
122
+ * Run one pump pass: heartbeat the resident's hold, read the pane for a
123
+ * stuck permission dialog, pick up new owner messages, and inject whatever
124
+ * is safe to inject this pass (never while a dialog is visible, never past
125
+ * the per-hour cap).
126
+ *
127
+ * Injected dependencies:
128
+ * fetchMessages() -> array of slot messages (same shape the engine
129
+ * uses; a `wait`-mode caller can pass the same
130
+ * closure it already built for the headless path)
131
+ * readPane() -> current pane text (string)
132
+ * injectKeys(line) -> POST one `keys` line to the resident's pane
133
+ * heartbeatHold() -> heartbeat the slot AS the resident's own
134
+ * holder_session (not this warden's identity)
135
+ * postReply(text) -> slot `say`, used only for the once-per-hour
136
+ * overflow notice
137
+ */
138
+ export async function runPumpOnce({
139
+ hostId,
140
+ slot,
141
+ selfIdentity,
142
+ fromFilters = [],
143
+ fetchMessages,
144
+ readPane,
145
+ injectKeys,
146
+ heartbeatHold,
147
+ postReply,
148
+ state,
149
+ log = () => {},
150
+ now = () => Date.now(),
151
+ maxSeenIds = 500,
152
+ maxTurnsPerHour = DEFAULT_SETTINGS.max_turns_per_hour,
153
+ maxLineLen = 4000,
154
+ heartbeatMaxAttempts = 3,
155
+ heartbeatBaseDelayMs = 2000,
156
+ sleep,
157
+ }) {
158
+ const heartbeat = await heartbeatWithBackoff({
159
+ heartbeatHold, log, sleep, maxAttempts: heartbeatMaxAttempts, baseDelayMs: heartbeatBaseDelayMs,
160
+ });
161
+
162
+ const paneText = await readPane();
163
+ const dialogPresent = paneHasDialog(paneText);
164
+
165
+ let nextState = { ...state, pending: [...(state.pending || [])] };
166
+
167
+ if (dialogPresent) {
168
+ if (nextState.stuck_dialog_since == null) {
169
+ nextState.stuck_dialog_since = now();
170
+ log('warn', `slot ${slot}: permission dialog detected in the resident's pane on ${hostId}; holding owner messages until it clears (never injecting Enter to resolve it).`);
171
+ }
172
+ } else if (nextState.stuck_dialog_since != null) {
173
+ log('info', `slot ${slot}: permission dialog cleared after ${Math.round((now() - nextState.stuck_dialog_since) / 1000)}s; resuming injection.`);
174
+ nextState.stuck_dialog_since = null;
175
+ }
176
+
177
+ const messages = await fetchMessages();
178
+ const firstRun = nextState.bootstrapped !== true;
179
+ const picked = pickNewMessages({ messages, seenIds: nextState.seen_ids, selfIdentity, fromFilters, firstRun });
180
+ nextState.seen_ids = boundSeenIds(picked.seenIds, maxSeenIds);
181
+ if (firstRun) {
182
+ nextState.bootstrapped = true;
183
+ log('info', `pump first run on ${slot}: seeded seen-set with ${picked.allSeen.length} existing message id(s), queuing none for injection.`);
184
+ }
185
+
186
+ const newPending = picked.toAnswer.filter((m) => !nextState.pending.some((p) => p.id === m.id));
187
+ nextState.pending = [...nextState.pending, ...newPending];
188
+ if (newPending.length > 0) {
189
+ log('info', `pump: queued ${newPending.length} new owner message(s) for ${slot}'s resident.`);
190
+ }
191
+
192
+ let injected = 0;
193
+ let capHit = false;
194
+
195
+ if (!dialogPresent) {
196
+ let timestamps = pruneOldTurns(nextState.injection_timestamps, now(), ERROR_NOTICE_WINDOW_MS);
197
+ const stillPending = [];
198
+ for (const message of nextState.pending) {
199
+ if (timestamps.length >= maxTurnsPerHour) {
200
+ capHit = true;
201
+ stillPending.push(message);
202
+ continue;
203
+ }
204
+ const lines = buildPumpLines({ slotName: slot, text: message.text, maxLen: maxLineLen });
205
+ for (const line of lines) {
206
+ await injectKeys(line);
207
+ }
208
+ timestamps = [...timestamps, now()];
209
+ injected += 1;
210
+ log('info', `pump: injected message ${message.id} from ${message.from} into ${slot}'s resident pane (${lines.length} line(s)).`);
211
+ }
212
+ nextState.pending = stillPending;
213
+ nextState.injection_timestamps = timestamps;
214
+ } else if (nextState.pending.length > 0) {
215
+ log('warn', `pump: ${nextState.pending.length} owner message(s) on ${slot} held, waiting for the permission dialog to clear.`);
216
+ }
217
+
218
+ if (capHit && nextState.pending.length > 0) {
219
+ const notice = shouldPostErrorNotice(nextState.overflow_notices, 'pump_rate_cap', now(), ERROR_NOTICE_WINDOW_MS);
220
+ nextState.overflow_notices = notice.notices;
221
+ if (notice.shouldPost) {
222
+ const text = `Pump injection cap reached (${maxTurnsPerHour}/hour) on slot ${slot}: ${nextState.pending.length} owner message(s) still queued, will resume as the hour rolls over.`;
223
+ await postReply(text);
224
+ log('warn', `pump: posted once-per-hour overflow notice on ${slot} (${nextState.pending.length} still queued).`);
225
+ }
226
+ }
227
+
228
+ return { state: nextState, injected, dialogPresent, pendingCount: nextState.pending.length, heartbeat };
229
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Resident seed prompt — QUOXPERTICUS-REVISIT P3 "Resident lifecycle v1".
3
+ *
4
+ * The seed is the one line of text typed into a freshly-deployed interactive
5
+ * `claude` tmux pane (via the dev-sessions `keys` route) that turns a bare
6
+ * shell into the host's resident: read the revival kit before acting, claim
7
+ * the slot, then say a short hello. This encodes the proven 2026-09-04
8
+ * revival recipe (read MEMORY.md + indexed memories + NOTES_TO_SELF.md
9
+ * before acting, then claim, then greet) as a template, parameterized by
10
+ * host and slot.
11
+ *
12
+ * `keys` is a single line per call (the collector appends Enter — see
13
+ * services/collector/lib/devSessionsProxy.js MAX_KEYS_LENGTH=4096), so the
14
+ * rendered seed collapses to ONE line: no literal newlines, which would
15
+ * otherwise submit a half-typed prompt early.
16
+ *
17
+ * LATER PHASE SEAM: a collector-stored per-org/per-host seed override is a
18
+ * follow-up (dashboard-editable, versioned). Until that lands,
19
+ * `loadSeedOverride()` is the only override point, reading a local file on
20
+ * the CONTROL side (the box running `quox dev resident deploy`) verbatim.
21
+ */
22
+
23
+ import { existsSync, readFileSync } from 'node:fs';
24
+ import { homedir } from 'node:os';
25
+ import { join } from 'node:path';
26
+
27
+ export function defaultSeedOverridePath() {
28
+ return join(homedir(), '.quox', 'resident', 'seed.md');
29
+ }
30
+
31
+ /**
32
+ * Read a local seed override file, if present. Returns null when the file
33
+ * is missing, unreadable, or empty — never throws, since a missing override
34
+ * simply means "use the built-in template", not an error.
35
+ *
36
+ * SEAM: a later phase replaces/augments this with a collector-stored
37
+ * config record (org/host-scoped, dashboard-editable). This function is the
38
+ * one place that phase needs to change.
39
+ */
40
+ export function loadSeedOverride(path = defaultSeedOverridePath()) {
41
+ try {
42
+ if (!existsSync(path)) return null;
43
+ const content = readFileSync(path, 'utf-8');
44
+ return content && content.trim() ? content : null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /** Collapse arbitrary text to one line safe for a single `keys` send. */
51
+ function oneLine(text) {
52
+ return String(text || '').replace(/\s*\r?\n+\s*/g, ' ').replace(/[ \t]{2,}/g, ' ').trim();
53
+ }
54
+
55
+ function builtInTemplate({ hostId, slotName }) {
56
+ const claimCmd = `quox slot checkout ${slotName} --session resident-${slotName} --kind interactive --host ${hostId} --create`;
57
+ return (
58
+ `You are the resident Claude session for host ${hostId} (slot ${slotName}). ` +
59
+ `Before doing anything else: read ~/.claude/projects/-home-control/memory/MEMORY.md and every ` +
60
+ `memory file it indexes, then read ~/NOTES_TO_SELF.md if it exists, then read any identity or ` +
61
+ `onboarding docs in your home directory for this host. Read all of it before you act on anything. ` +
62
+ `Once you have read everything, claim your slot with: ${claimCmd}. ` +
63
+ `Then post one short hello (1-2 lines, no em dashes, lead with outcome) into the slot channel ` +
64
+ `confirming you are up and what you found.`
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Render the seed prompt for a host/slot pair, as one line.
70
+ * @param {object} params
71
+ * @param {string} params.hostId
72
+ * @param {string} params.slotName
73
+ * @param {string} [params.overrideText] - test seam; when omitted, falls
74
+ * back to loadSeedOverride().
75
+ */
76
+ export function renderSeedPrompt({ hostId, slotName, overrideText } = {}) {
77
+ if (!hostId) throw new Error('renderSeedPrompt: hostId is required');
78
+ if (!slotName) throw new Error('renderSeedPrompt: slotName is required');
79
+
80
+ const override = overrideText !== undefined ? overrideText : loadSeedOverride();
81
+ if (override) {
82
+ return oneLine(
83
+ override.replace(/\{\{?\s*hostId\s*\}?\}/g, hostId).replace(/\{\{?\s*slotName\s*\}?\}/g, slotName)
84
+ );
85
+ }
86
+ return oneLine(builtInTemplate({ hostId, slotName }));
87
+ }
88
+
89
+ /**
90
+ * Split a one-line seed into <= maxLen chunks, each sent as its own `keys`
91
+ * call (each call appends its own Enter). Under maxLen this is a single
92
+ * chunk — the preferred, normal path. Splitting a seed that overflows
93
+ * maxLen means the resident receives it as multiple separate turns rather
94
+ * than one line, which is a documented degrade, not silent truncation.
95
+ */
96
+ export function chunkSeedText(text, maxLen = 4000) {
97
+ const s = String(text || '');
98
+ if (s.length <= maxLen) return [s];
99
+ const chunks = [];
100
+ for (let i = 0; i < s.length; i += maxLen) {
101
+ chunks.push(s.slice(i, i + maxLen));
102
+ }
103
+ return chunks;
104
+ }