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,864 @@
1
+ /**
2
+ * `quox dev supervisor` engine, DEVSUITE-DEVMODE (quox#453 phase 15 follow-on).
3
+ *
4
+ * The headless runner that lets a dev host's resident agent answer its slot
5
+ * channel with no interactive session open. Everything here is pure or takes
6
+ * its side effects (HTTP, child-process spawn, the clock) as injected
7
+ * functions, so the CLI command layer (`lib/commands/devSupervisor.js`) is a
8
+ * thin wrapper and the logic below can be driven directly from tests without
9
+ * spawning a real `claude` process or hitting a real collector.
10
+ *
11
+ * Contract this reads (a separate agent owns the write side):
12
+ * GET {collector}/api/v1/dev-servers/:hostId/supervisor
13
+ * -> {host_id, enabled, slot, poll_seconds, turn_timeout_seconds,
14
+ * max_turns_per_hour, updated_at, updated_by}
15
+ * Always returns a shape (enabled false + defaults) even for an unknown
16
+ * host. This module never writes to that endpoint.
17
+ */
18
+
19
+ // CLI-side fallback only. The collector's default is authoritative; these
20
+ // are used only if a field is missing or malformed in the response, so a
21
+ // half-formed settings payload never turns into "spawn as fast as possible".
22
+ export const DEFAULT_SETTINGS = {
23
+ enabled: false,
24
+ poll_seconds: 30,
25
+ turn_timeout_seconds: 120,
26
+ max_turns_per_hour: 6,
27
+ };
28
+
29
+ const ONE_HOUR_MS = 60 * 60 * 1000;
30
+
31
+ // DEFECT FIX (2026-08-23, live incident: ~6 USD of paid turns burned working
32
+ // through hours-old backlog after a disabled -> enabled transition). Any
33
+ // owner message older than this is never eligible for a turn, however it
34
+ // became eligible. Belt to the disabled->enabled reseed's braces below:
35
+ // bounds paid churn even if some future code path reintroduces an unseeded
36
+ // cursor. Overridable via `--max-age-minutes` in the CLI command layer.
37
+ export const DEFAULT_MAX_MESSAGE_AGE_MINUTES = 15;
38
+
39
+ // How often the same distinct turn-error signature is allowed to post its
40
+ // one honest "I can't answer right now" notice into the channel. Silence
41
+ // forever is also a failure mode; this caps it to one signal per hour per
42
+ // distinct error, not spam and not nothing.
43
+ export const ERROR_NOTICE_WINDOW_MS = ONE_HOUR_MS;
44
+
45
+ // Option A persistence (service mode, DEVSUITE-DEVMODE follow-on).
46
+ //
47
+ // How long a `--service` runner idles between re-checks of the server-side
48
+ // switch once it finds itself disabled. Slow on purpose: this is a "did a
49
+ // human turn it back on" poll, not the message-latency path, so it costs
50
+ // nothing to keep long.
51
+ export const DEFAULT_IDLE_RECHECK_SECONDS = 60;
52
+
53
+ // How many consecutive long-poll (`slot wait`) failures the continuous run
54
+ // loop tolerates before it gives up on waiting and falls back to the
55
+ // original interval-poll loop for the rest of this process's life. This is
56
+ // the degrade-not-die seam: a collector that stops answering `/wait` must
57
+ // not take the runner down with it.
58
+ export const DEFAULT_WAIT_FAILURE_THRESHOLD = 3;
59
+
60
+ // LOOP TRIAGE FIX (diagnosed live 2026-09-10, .131): the resident supervisor
61
+ // held its QuoxSlot only via the `slot wait` long-poll's server-side
62
+ // heartbeat coupling, so an intermittent hang on the authenticated slot path
63
+ // (observed: "checkout attempt failed: Request timed out after 30000ms"
64
+ // while /health stayed instant, so this is a server-side hang, not a network
65
+ // one) wedged the long-poll and heartbeated nothing until it recovered. The
66
+ // hold then lapsed stale (15m TTL) then free (30m), and re-claimed roughly
67
+ // 45 minutes later: ~193 times over 6 days. This is the interval this
68
+ // module's independent heartbeat (see startIndependentHeartbeat below) ticks
69
+ // on: well under the 15m held-TTL so even a long stall is covered by many
70
+ // ticks, and independent of any single request's lifecycle.
71
+ export const DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 20;
72
+
73
+ // How often the SAME independent-heartbeat failure signature (a 409, or a
74
+ // given error message) is allowed to log again. Same shape as
75
+ // ERROR_NOTICE_WINDOW_MS above but a much shorter window: this is a local
76
+ // log line, not an owner-facing channel notice, so it can afford to remind
77
+ // more often without being spam, while never logging once per 20s tick for
78
+ // the whole length of an outage.
79
+ export const HEARTBEAT_FAILURE_LOG_WINDOW_MS = 5 * 60 * 1000;
80
+
81
+ /**
82
+ * Start an independent, self-scheduling heartbeat that keeps a slot hold
83
+ * warm on its OWN timer, decoupled from every other loop in this runner
84
+ * (the `slot wait` long-poll, a turn spawn, an idle re-check). Every tick is
85
+ * its own function call scheduled directly by the interval, not chained
86
+ * after the previous tick's promise settles, so a hung or slow
87
+ * postHeartbeat call on one tick can never block or cancel the next tick.
88
+ *
89
+ * 409 semantics are preserved exactly as elsewhere in this module (see
90
+ * attemptSlotClaim above and the idle-heartbeat handling in
91
+ * devSupervisor.js): a 409 means "not held by this resident" and is logged,
92
+ * never seized. This function never calls checkoutSlot or anything else
93
+ * that could take a hold from another session; it only ever heartbeats a
94
+ * hold this resident already has.
95
+ *
96
+ * Failure accounting follows the loop triad (see CLAUDE.md Loop & Restart
97
+ * Hygiene): successes and failures are counted distinctly (getCounts()),
98
+ * and a repeated identical failure logs at most once per
99
+ * HEARTBEAT_FAILURE_LOG_WINDOW_MS, never once per tick, so a long outage
100
+ * cannot spam the log forever while still never going silent for its whole
101
+ * duration.
102
+ *
103
+ * setIntervalFn/clearIntervalFn/now are injected so tests can drive and
104
+ * verify this with no real timer and full control over tick timing.
105
+ */
106
+ export function startIndependentHeartbeat({
107
+ postHeartbeat,
108
+ intervalMs = DEFAULT_HEARTBEAT_INTERVAL_SECONDS * 1000,
109
+ log = () => {},
110
+ now = () => Date.now(),
111
+ setIntervalFn = setInterval,
112
+ clearIntervalFn = clearInterval,
113
+ }) {
114
+ let successCount = 0;
115
+ let failureCount = 0;
116
+ let notices = [];
117
+
118
+ const tick = async () => {
119
+ try {
120
+ await postHeartbeat();
121
+ successCount += 1;
122
+ if (notices.length > 0) {
123
+ log('info', 'independent heartbeat: recovered, ticking cleanly again.');
124
+ notices = [];
125
+ }
126
+ } catch (err) {
127
+ failureCount += 1;
128
+ const is409 = !!(err && err.status === 409);
129
+ const signature = is409 ? 'heartbeat_409' : `heartbeat_error:${err && err.message}`;
130
+ const notice = shouldPostErrorNotice(notices, signature, now(), HEARTBEAT_FAILURE_LOG_WINDOW_MS);
131
+ notices = notice.notices;
132
+ if (notice.shouldPost) {
133
+ if (is409) {
134
+ log('warn', 'independent heartbeat: 409 (not held by this resident), leaving it alone rather than seizing it.');
135
+ } else {
136
+ log('warn', `independent heartbeat failed, never stops ticking (will keep retrying every ${Math.round(intervalMs / 1000)}s): ${err && err.message}`);
137
+ }
138
+ }
139
+ }
140
+ };
141
+
142
+ // Each tick is its own call, scheduled by the interval itself: a hung
143
+ // tick's promise is never awaited here, so the interval keeps firing on
144
+ // schedule regardless of how long, or whether, any single tick settles.
145
+ const timer = setIntervalFn(() => tick(), intervalMs);
146
+ if (timer && typeof timer.unref === 'function') {
147
+ try { timer.unref(); } catch { /* fake timers may not implement unref; harmless either way */ }
148
+ }
149
+
150
+ return {
151
+ stop: () => clearIntervalFn(timer),
152
+ getCounts: () => ({ successCount, failureCount }),
153
+ };
154
+ }
155
+
156
+ function numberOr(value, fallback) {
157
+ return Number.isFinite(value) ? value : fallback;
158
+ }
159
+
160
+ /** Resolve settings with the CLI-side fallback applied per-field. */
161
+ export function resolveSettings(raw) {
162
+ const s = raw || {};
163
+ return {
164
+ host_id: s.host_id ?? null,
165
+ enabled: s.enabled === true,
166
+ slot: s.slot ?? null,
167
+ poll_seconds: numberOr(s.poll_seconds, DEFAULT_SETTINGS.poll_seconds),
168
+ turn_timeout_seconds: numberOr(s.turn_timeout_seconds, DEFAULT_SETTINGS.turn_timeout_seconds),
169
+ max_turns_per_hour: numberOr(s.max_turns_per_hour, DEFAULT_SETTINGS.max_turns_per_hour),
170
+ updated_at: s.updated_at ?? null,
171
+ updated_by: s.updated_by ?? null,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Owner-match rule (final, per spec, read this before touching the
177
+ * filtering below):
178
+ *
179
+ * 1. Any message with source === 'say' is NEVER a candidate. That source
180
+ * means it was posted through the slot `say` seam, which is exactly
181
+ * the seam this runner posts its own replies through.
182
+ * 2. If one or more --from names were given, a message is a candidate
183
+ * only when its `from` case-insensitively matches one of them. This is
184
+ * the configurable "who counts as the owner" allowlist.
185
+ * 3. With no --from given, a message is a candidate only when it fails
186
+ * BOTH self-checks: its `author_identity` (server-derived, unforgeable
187
+ * — see devSlotMessagesStore.deriveCallerIdentity) does not equal
188
+ * `selfIdentity`, AND its `from` label does not equal `selfIdentity`
189
+ * either. `author_identity` is the primary, trustworthy check: `from`
190
+ * is caller-controlled free text with no server-side check, so a
191
+ * message can claim to be from anyone while its author_identity still
192
+ * names this resident. That is the impersonation case this must catch
193
+ * to avoid a self-loop (a session's own posts rendering under a
194
+ * different session's name, found live 2026-08-23). The `from` check
195
+ * stays as an additional filter, not the removed primary one, since a
196
+ * message with no author_identity at all (rows written before
197
+ * migrations/aee/022, or by callers that predate identity resolution)
198
+ * still needs a check to fall back on.
199
+ *
200
+ * The invariant that must hold across both branches: this can never answer
201
+ * its own post, and (enforced by the caller, pickNewMessages) never
202
+ * re-answers a message id it has already seen.
203
+ */
204
+ export function isOwnerMessage(message, { selfIdentity, fromFilters = [] } = {}) {
205
+ if (!message) return false;
206
+ // `source === 'say'` used to be excluded outright, because that is the seam
207
+ // the runner posts its own replies through and there was no trustworthy way
208
+ // to tell its posts from anyone else's. The cost, found live 2026-08-23:
209
+ // a HUMAN using `quox slot say` could never reach a resident at all, which
210
+ // makes the CLI useless for talking to one. Now that author_identity is
211
+ // server-stamped and unforgeable (mig 022), exclude the actual thing that
212
+ // must be excluded (posts THIS resident wrote) rather than everything that
213
+ // used the CLI. Legacy rows carry no author_identity, so for those the old
214
+ // blunt rule still applies: with no verifiable author there is no safe way
215
+ // to tell a self-post from a human's, and a self-loop is the worse failure.
216
+ if (message.source === 'say' && message.author_identity == null) return false;
217
+ if (fromFilters && fromFilters.length > 0) {
218
+ const from = String(message.from || '').toLowerCase();
219
+ return fromFilters.some((f) => String(f).toLowerCase() === from);
220
+ }
221
+ const self = String(selfIdentity || '');
222
+ const isSelfByIdentity = message.author_identity != null && String(message.author_identity) === self;
223
+ const isSelfByLabel = String(message.from || '') === self;
224
+ return !isSelfByIdentity && !isSelfByLabel;
225
+ }
226
+
227
+ /** The identity this runner posts its own replies as. See isOwnerMessage. */
228
+ export function buildSelfIdentity(hostId) {
229
+ return `resident:${hostId}`;
230
+ }
231
+
232
+ export const CLAIM_SAFETY_INTERVAL_MS = 10 * 60 * 1000;
233
+
234
+ /**
235
+ * Local "I hold this slot" memory shared across poll passes (HOT LOOP FIX,
236
+ * 2026-10-11: the collector returns success for a same-holder checkout, so
237
+ * calling checkout on every 15 s pass cost a full checkout each time).
238
+ * After a successful claim, attemptSlotClaim only heartbeats; it re-checks
239
+ * out when the heartbeat says 409/404 or after CLAIM_SAFETY_INTERVAL_MS.
240
+ */
241
+ export function createClaimState() {
242
+ return { held: false, claimedAt: 0 };
243
+ }
244
+
245
+ /**
246
+ * PRESENCE FIX (item 2, live outage 2026-08-23): both residents' slots sat
247
+ * FREE the whole time their supervisors were running, because nothing in
248
+ * this runner ever called checkout, only heartbeat, and heartbeat requires
249
+ * an existing hold and does nothing when the slot is free. Presence is half
250
+ * the point of a resident; a supervisor that runs but never holds its slot
251
+ * reads as absent to anyone watching.
252
+ *
253
+ * Attempts to check the slot out as `selfIdentity`. The collector's
254
+ * checkoutSlot is itself a compare-and-swap on `state = 'free'`, so this is
255
+ * safe to call unconditionally on every poll pass: a slot that is already
256
+ * free gets claimed, a slot this resident already holds 409s harmlessly
257
+ * (the row is `held`, not `free`, whoever holds it), and a slot held by a
258
+ * DIFFERENT session also 409s, never seized, exactly the rule already in
259
+ * force for the idle heartbeat 409 below. `checkoutSlot` is optional so
260
+ * every existing caller that does not pass one keeps working unchanged.
261
+ *
262
+ * Never throws: any failure (409 or otherwise) is caught, logged, and
263
+ * treated as "did not claim this pass", so a claim attempt can never take
264
+ * the runner down.
265
+ */
266
+ export async function attemptSlotClaim({
267
+ checkoutSlot, selfIdentity, slot, log = () => {}, announceForeignHold = true,
268
+ claimState = null, postHeartbeat = null, now = () => Date.now(), safetyIntervalMs = CLAIM_SAFETY_INTERVAL_MS,
269
+ }) {
270
+ if (!checkoutSlot) return { claimed: false, reason: 'no_checkout_fn' };
271
+ if (claimState && claimState.held && postHeartbeat && now() - claimState.claimedAt < safetyIntervalMs) {
272
+ try {
273
+ await postHeartbeat();
274
+ return { claimed: false, reason: 'already_held' };
275
+ } catch (err) {
276
+ if (!(err && (err.status === 409 || err.status === 404))) {
277
+ log('warn', `slot ${slot}: claim-pass heartbeat failed: ${err && err.message}`);
278
+ return { claimed: false, reason: 'error', error: err };
279
+ }
280
+ claimState.held = false;
281
+ }
282
+ }
283
+ try {
284
+ const res = await checkoutSlot();
285
+ if (claimState) { claimState.held = true; claimState.claimedAt = now(); }
286
+ const refreshed = !!(res && res.refreshed);
287
+ log('info', refreshed
288
+ ? `slot ${slot}: already held by this resident, refreshed.`
289
+ : `slot ${slot}: claimed (was free), now present.`);
290
+ return refreshed ? { claimed: true, refreshed } : { claimed: true };
291
+ } catch (err) {
292
+ if (claimState) claimState.held = false;
293
+ if (err && err.status === 409) {
294
+ const holder = err.detail?.holder_session || null;
295
+ if (holder && holder !== selfIdentity) {
296
+ if (announceForeignHold) {
297
+ log('warn', `slot ${slot}: checkout refused (409), held by ${holder}, leaving it alone rather than seizing it. Run \`quox dev supervisor status\` to see who holds it.`);
298
+ }
299
+ return { claimed: false, reason: 'held_by_other', holder };
300
+ }
301
+ // Held by us already (older collectors 409 a same-holder checkout).
302
+ if (claimState) { claimState.held = true; claimState.claimedAt = now(); }
303
+ return { claimed: false, reason: 'already_held' };
304
+ }
305
+ log('warn', `slot ${slot}: checkout attempt failed: ${err.message}`);
306
+ return { claimed: false, reason: 'error', error: err };
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Pick which messages are new answerable owner turns.
312
+ *
313
+ * On the very first run for a fresh state file (firstRun: true), every
314
+ * currently-visible message id is seeded into the seen-set and NOTHING is
315
+ * answered: a resident that just came under supervision must not suddenly
316
+ * reply to a channel's entire backlog.
317
+ */
318
+ export function pickNewMessages({ messages, seenIds, selfIdentity, fromFilters = [], firstRun }) {
319
+ const seen = new Set(seenIds || []);
320
+ const list = Array.isArray(messages) ? messages : [];
321
+ const allIds = list.map((m) => m.id).filter((id) => id != null);
322
+
323
+ if (firstRun) {
324
+ return {
325
+ toAnswer: [],
326
+ seenIds: Array.from(new Set([...seen, ...allIds])),
327
+ allSeen: allIds,
328
+ };
329
+ }
330
+
331
+ const toAnswer = [];
332
+ for (const m of list) {
333
+ if (m.id == null || seen.has(m.id)) continue;
334
+ if (isOwnerMessage(m, { selfIdentity, fromFilters })) toAnswer.push(m);
335
+ }
336
+ return {
337
+ toAnswer,
338
+ seenIds: Array.from(new Set([...seen, ...allIds])),
339
+ allSeen: allIds,
340
+ };
341
+ }
342
+
343
+ // Bounding helpers. Keep the continuity state file flat over a resident's
344
+ // whole life so per-turn cost (prompt size) stays constant.
345
+
346
+ export function boundSeenIds(ids, max = 500) {
347
+ const arr = Array.from(new Set(ids || []));
348
+ return arr.length > max ? arr.slice(arr.length - max) : arr;
349
+ }
350
+
351
+ export function boundDecisions(list, max = 20) {
352
+ const arr = list || [];
353
+ return arr.length > max ? arr.slice(arr.length - max) : arr;
354
+ }
355
+
356
+ export function boundExchanges(list, max = 10) {
357
+ const arr = list || [];
358
+ return arr.length > max ? arr.slice(arr.length - max) : arr;
359
+ }
360
+
361
+ // Rate limiting.
362
+
363
+ export function pruneOldTurns(timestamps, now, windowMs = ONE_HOUR_MS) {
364
+ return (timestamps || []).filter((t) => now - t < windowMs);
365
+ }
366
+
367
+ /**
368
+ * Age of a message in ms, or null when it carries no usable timestamp.
369
+ * Never treats "no timestamp" as stale: that would silently swallow
370
+ * messages from a source that just doesn't send one, which is a worse
371
+ * failure than the churn this guard exists to prevent.
372
+ */
373
+ export function messageAgeMs(message, now) {
374
+ const ts = message?.created_at ?? message?.timestamp ?? message?.ts ?? null;
375
+ if (!ts) return null;
376
+ const t = new Date(ts).getTime();
377
+ return Number.isFinite(t) ? now - t : null;
378
+ }
379
+
380
+ // Distinct-signature dedup for the turn-error notice (defect 2). Same shape
381
+ // as pruneOldTurns but keyed on {signature, at} pairs rather than bare
382
+ // timestamps, since dedup is per-signature, not a flat rate cap.
383
+ export function pruneErrorNotices(notices, now, windowMs = ERROR_NOTICE_WINDOW_MS) {
384
+ return (notices || []).filter((n) => n && Number.isFinite(n.at) && now - n.at < windowMs);
385
+ }
386
+
387
+ /**
388
+ * Decide whether a turn-error notice for `signature` may post right now.
389
+ * Returns the pruned+updated notices list either way, so the caller always
390
+ * persists a clean list (this is also where the "pruned" half of "tracked
391
+ * in state and pruned" happens, on every error, not just on dedup hits).
392
+ */
393
+ export function shouldPostErrorNotice(notices, signature, now, windowMs = ERROR_NOTICE_WINDOW_MS) {
394
+ const pruned = pruneErrorNotices(notices, now, windowMs);
395
+ const alreadyNotified = pruned.some((n) => n.signature === signature);
396
+ if (alreadyNotified) return { shouldPost: false, notices: pruned };
397
+ return { shouldPost: true, notices: [...pruned, { signature, at: now }] };
398
+ }
399
+
400
+ // Turn prompt.
401
+ //
402
+ // SUPERVISOR_TURN_PROMPT is built by buildTurnPrompt below and is the exact
403
+ // text sent to every headless `claude -p` turn. Kept as one clearly-marked,
404
+ // reviewable function: every safeguard promised for this runner (proof
405
+ // before claim, no fabrication, no em dashes, reply text only) is enforced
406
+ // here or nowhere.
407
+ export function buildTurnPrompt({ hostId, slot, message, history, state }) {
408
+ const historyBlock = (history || [])
409
+ .map((h) => `${h.from}: ${h.text}${h.reply ? `\n (you replied: ${h.reply})` : ''}`)
410
+ .join('\n') || '(no prior channel history)';
411
+ const decisionsBlock = (state?.decisions || [])
412
+ .slice(-5)
413
+ .map((d) => `- ${d.at}: replied to ${d.from} (${d.reply_excerpt})`)
414
+ .join('\n') || '(none recorded yet)';
415
+
416
+ return [
417
+ `You are the resident agent for dev host "${hostId}", answering its slot channel "${slot}" with no interactive session open.`,
418
+ '',
419
+ 'Recent channel history (most recent last):',
420
+ historyBlock,
421
+ '',
422
+ 'Recent decisions/focus you recorded:',
423
+ decisionsBlock,
424
+ '',
425
+ 'The owner just said:',
426
+ `${message.from}: ${message.text}`,
427
+ '',
428
+ 'Rules:',
429
+ '- Prove any claim before making it. State what you actually checked, never assume or fabricate a result.',
430
+ '- Never use em dashes or double hyphens in your reply.',
431
+ '- Reply with the message text only: no preamble, no headers, no code fences unless the reply itself is code.',
432
+ '- If you are not sure, say so plainly rather than guessing.',
433
+ ].join('\n');
434
+ }
435
+
436
+ /**
437
+ * DEFECT FIX (2026-08-23, live incident: a raw provider error, e.g.
438
+ * `{"type":"not_found_error","message":"model: fable"}` from a
439
+ * misconfigured headless model, got posted verbatim into the slot channel
440
+ * as the resident's reply).
441
+ *
442
+ * Detects a failed or error-shaped turn by three independent signals (any
443
+ * one is enough): a non-zero exit code, an `is_error`/`error` field in the
444
+ * parsed JSON output, or a body that parses (directly, or embedded in a
445
+ * non-JSON stdout such as a "API Error: 404 {...}" preamble the CLI can
446
+ * print on error paths) as a provider error object shaped
447
+ * `{type: "..._error", message: "..."}`. Returns null for a clean turn.
448
+ */
449
+ export function detectTurnError(result) {
450
+ if (!result || result.timedOut || result.error) return null;
451
+
452
+ const raw = result.raw;
453
+ let errorObj = null;
454
+ if (raw && typeof raw === 'object') {
455
+ if (raw.is_error === true) errorObj = (raw.error && typeof raw.error === 'object') ? raw.error : raw;
456
+ else if (raw.error && typeof raw.error === 'object') errorObj = raw.error;
457
+ else if (typeof raw.type === 'string' && /_error$/i.test(raw.type)) errorObj = raw;
458
+ }
459
+
460
+ if (!errorObj) {
461
+ // Fallback: the provider error object may be embedded in non-JSON
462
+ // stdout (a preamble broke JSON.parse), in either field order.
463
+ const candidate = String(result.text || result.stdout || '');
464
+ const m = candidate.match(/"type"\s*:\s*"([a-z_]+_error)"[^{}]*"message"\s*:\s*"([^"]*)"/i)
465
+ || candidate.match(/"message"\s*:\s*"([^"]*)"[^{}]*"type"\s*:\s*"([a-z_]+_error)"/i);
466
+ if (m) {
467
+ const [, a, b] = m;
468
+ errorObj = /_error$/i.test(a) ? { type: a, message: b } : { type: b, message: a };
469
+ }
470
+ }
471
+
472
+ const nonZeroExit = Number.isInteger(result.exitCode) && result.exitCode !== 0;
473
+ if (!errorObj && !nonZeroExit) return null;
474
+
475
+ const type = errorObj?.type || 'nonzero_exit';
476
+ const message = errorObj?.message || `claude exited with code ${result.exitCode}`;
477
+ const category = /model/i.test(`${type} ${message}`) ? 'model configuration problem' : 'provider error';
478
+ const signature = `${type}:${message}`.slice(0, 200);
479
+ return { type, message, category, signature };
480
+ }
481
+
482
+ /**
483
+ * One short, plain-language, single-sentence notice: no stack, no JSON, no
484
+ * raw provider payload. This is the ONE honest signal an operator gets per
485
+ * distinct error signature per hour; silence forever is also wrong.
486
+ */
487
+ export function buildErrorNoticeText(category) {
488
+ return `I cannot answer right now because of a ${category}; a human should check this host's configuration.`;
489
+ }
490
+
491
+ // Turn execution.
492
+
493
+ /**
494
+ * Parse the output of `claude -p ... --output-format json`. Falls back to
495
+ * treating raw stdout as the reply text when it is not valid JSON.
496
+ */
497
+ export function parseTurnOutput(stdout) {
498
+ const trimmed = (stdout || '').trim();
499
+ if (!trimmed) return { text: '', session_id: null, total_cost_usd: null, raw: null };
500
+ try {
501
+ const json = JSON.parse(trimmed);
502
+ const text = typeof json.result === 'string' ? json.result
503
+ : typeof json.response === 'string' ? json.response
504
+ : typeof json.text === 'string' ? json.text
505
+ : '';
506
+ return {
507
+ text,
508
+ session_id: json.session_id || null,
509
+ total_cost_usd: typeof json.total_cost_usd === 'number' ? json.total_cost_usd : null,
510
+ raw: json,
511
+ };
512
+ } catch {
513
+ return { text: trimmed, session_id: null, total_cost_usd: null, raw: null };
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Run ONE bounded headless turn via `claude -p <prompt> --output-format
519
+ * json`, killed at timeoutSeconds. spawnFn is injected (defaults to
520
+ * node:child_process spawn in the command layer) so tests never execute a
521
+ * real claude process.
522
+ *
523
+ * NEVER sets ANTHROPIC_API_KEY or reads it: the spawned process inherits
524
+ * whatever the deploying user has already configured for the claude CLI
525
+ * (subscription/OAuth by default).
526
+ */
527
+ export function runClaudeTurn({ spawnFn, prompt, timeoutSeconds = DEFAULT_SETTINGS.turn_timeout_seconds }) {
528
+ return new Promise((resolve) => {
529
+ let child;
530
+ try {
531
+ // QUOX_SUPERVISOR_TURN marks this claude process as a headless
532
+ // supervisor turn, so user-level SessionStart hooks can stay silent
533
+ // (2026-08-23 incident: a wake hook on .131 fired on every turn and
534
+ // posted its "awake" announcement per inbound message all morning).
535
+ child = spawnFn('claude', ['-p', prompt, '--output-format', 'json'], {
536
+ stdio: ['ignore', 'pipe', 'pipe'],
537
+ env: { ...process.env, QUOX_SUPERVISOR_TURN: '1' },
538
+ });
539
+ } catch (err) {
540
+ resolve({ timedOut: false, error: err, text: '', session_id: null, total_cost_usd: null, raw: null });
541
+ return;
542
+ }
543
+
544
+ let stdout = '';
545
+ let stderr = '';
546
+ let settled = false;
547
+
548
+ const timer = setTimeout(() => {
549
+ if (settled) return;
550
+ settled = true;
551
+ try { child.kill('SIGKILL'); } catch { /* already gone, nothing to clean up */ }
552
+ resolve({ timedOut: true, stdout, stderr, text: '', session_id: null, total_cost_usd: null, raw: null });
553
+ }, Math.max(1, timeoutSeconds) * 1000);
554
+
555
+ child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
556
+ child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
557
+
558
+ child.on('error', (err) => {
559
+ if (settled) return;
560
+ settled = true;
561
+ clearTimeout(timer);
562
+ resolve({ timedOut: false, error: err, stdout, stderr, text: '', session_id: null, total_cost_usd: null, raw: null });
563
+ });
564
+
565
+ child.on('close', (code) => {
566
+ if (settled) return;
567
+ settled = true;
568
+ clearTimeout(timer);
569
+ resolve({ timedOut: false, exitCode: code, stdout, stderr, ...parseTurnOutput(stdout) });
570
+ });
571
+ });
572
+ }
573
+
574
+ // State.
575
+
576
+ export function createInitialState() {
577
+ return {
578
+ bootstrapped: false,
579
+ seen_ids: [],
580
+ turn_timestamps: [],
581
+ decisions: [],
582
+ recent_exchanges: [],
583
+ host_id: null,
584
+ slot: null,
585
+ updated_at: null,
586
+ // last_enabled_state (defect 1 fix): null until the first pass ever
587
+ // observes the switch. Recognises a disabled -> enabled transition
588
+ // across process restarts (persisted, so a --service unit stopped or
589
+ // rebooted while disabled still reseeds on its very next enabled pass,
590
+ // rather than replaying the whole backlog it was away for).
591
+ last_enabled_state: null,
592
+ // last_turn_error / error_notices (defect 2 fix): the most recent
593
+ // error-shaped turn (for `status` to surface "last turn failed: ..."),
594
+ // and the per-signature once-per-hour notice dedup list.
595
+ last_turn_error: null,
596
+ error_notices: [],
597
+ // held_by_other_notices (QUOXPERTICUS-REVISIT P2, HOLD-IS-THE-VOICE
598
+ // gate): same per-signature once-per-hour dedup shape as error_notices
599
+ // above, keyed on the foreign holder's session id, so a supervisor
600
+ // sitting silent while another session holds its slot posts one
601
+ // witnessed receipt per holder-change or per hour, never one per poll.
602
+ held_by_other_notices: [],
603
+ };
604
+ }
605
+
606
+ /** Apply every bounding rule at once. Call before persisting state. */
607
+ export function boundState(state, { maxSeenIds = 500, maxDecisions = 20, maxExchanges = 10 } = {}) {
608
+ return {
609
+ ...state,
610
+ seen_ids: boundSeenIds(state.seen_ids, maxSeenIds),
611
+ decisions: boundDecisions(state.decisions, maxDecisions),
612
+ recent_exchanges: boundExchanges(state.recent_exchanges, maxExchanges),
613
+ };
614
+ }
615
+
616
+ function truncate(text, max = 120) {
617
+ const s = String(text || '');
618
+ return s.length > max ? `${s.slice(0, max)}...` : s;
619
+ }
620
+
621
+ /**
622
+ * Run one full poll pass: fetch settings (kill-switch check), fetch
623
+ * messages, pick new owner turns, spawn one bounded headless turn per
624
+ * message (sequential, never concurrent), post replies, and update state.
625
+ *
626
+ * Every I/O dependency is injected so this can run under vitest with no
627
+ * real network, no real collector, and no real claude process:
628
+ * fetchSettings() -> raw settings object (per the contract)
629
+ * fetchMessages() -> array of slot messages
630
+ * postReply(text) -> posts the reply via slot say
631
+ * spawnTurn(prompt, s) -> runs one headless turn, returns runClaudeTurn's shape
632
+ * log(level, message) -> logger (startup/inbound/replies/skips/rate-limit/shutdown)
633
+ */
634
+ export async function runPollOnce({
635
+ hostId,
636
+ slot,
637
+ selfIdentity,
638
+ fromFilters = [],
639
+ fetchSettings,
640
+ fetchMessages,
641
+ postReply,
642
+ spawnTurn,
643
+ checkoutSlot = null,
644
+ claimState = null,
645
+ postHeartbeat = null,
646
+ postTurnReceipt = null,
647
+ state,
648
+ log = () => {},
649
+ now = () => Date.now(),
650
+ historyWindow = 10,
651
+ maxDecisions = 20,
652
+ maxSeenIds = 500,
653
+ maxAgeMinutes = DEFAULT_MAX_MESSAGE_AGE_MINUTES,
654
+ }) {
655
+ // PRESENCE FIX (item 2, live outage 2026-08-23): attempted on every poll
656
+ // pass, enabled or disabled, so a free slot is claimed the moment it is
657
+ // seen free rather than staying present-but-never-holding. See
658
+ // attemptSlotClaim above for the full rationale; this never throws.
659
+ const claimResult = await attemptSlotClaim({ checkoutSlot, selfIdentity, slot, log, claimState, postHeartbeat });
660
+
661
+ // QP-DASH P1: every ATTEMPTED turn posts a receipt (outcome + cost +
662
+ // duration) so spend is legible server-side; the 2026-08-23 backlog churn
663
+ // (~$6) was invisible without this. Strictly fail-soft: a receipts outage
664
+ // must never break answering.
665
+ const receipt = async (fields) => {
666
+ if (!postTurnReceipt) return;
667
+ try {
668
+ await postTurnReceipt(fields);
669
+ } catch (err) {
670
+ log('warn', `turn receipt failed (never blocks the turn): ${err.message}`);
671
+ }
672
+ };
673
+ const rawSettings = await fetchSettings();
674
+ const settings = resolveSettings(rawSettings);
675
+
676
+ if (!settings.enabled) {
677
+ // This IS the kill switch. Honour it promptly: no messages are fetched,
678
+ // no turn is spawned, and the caller (the CLI's `run` loop) is told to
679
+ // stop rather than sleep-and-retry. Record last_enabled_state:false so
680
+ // the NEXT enabled pass (even after a process restart while disabled;
681
+ // this is persisted state, not in-memory) recognises itself as a
682
+ // disabled -> enabled transition and reseeds instead of replaying the
683
+ // backlog it was away for (defect 1).
684
+ log('info', `kill switch: supervisor disabled for ${hostId} (enabled=false). Exiting cleanly.`);
685
+ return { state: { ...state, last_enabled_state: false }, settings, stopped: true, turnsRun: 0 };
686
+ }
687
+
688
+ // HOLD-IS-THE-VOICE gate (QUOXPERTICUS-REVISIT P2): the supervisor must be
689
+ // structurally SILENT while any OTHER session holds its slot; it answers
690
+ // only while it holds the slot itself (claimed just now, or already_held
691
+ // from a prior pass). attemptSlotClaim above already never seizes a
692
+ // foreign hold; this is what makes that refusal ALSO mean "never fetch,
693
+ // never spawn, never reply", not just "never checkout". Placed after the
694
+ // kill-switch check above (so a disabled pass still returns there,
695
+ // unchanged) and strictly before fetchMessages: no message is ever read
696
+ // while another session holds the slot, so nothing can be answered by
697
+ // accident. Deduped per foreign holder, once per hour (reuses the
698
+ // error-notice dedup shape), so a poll every 30s does not spam a receipt
699
+ // every pass while the same other session sits on the slot for hours.
700
+ if (!claimResult.claimed && claimResult.reason === 'held_by_other') {
701
+ const holder = claimResult.holder || 'unknown';
702
+ const notice = shouldPostErrorNotice(state.held_by_other_notices || [], holder, now());
703
+ const heldState = { ...state, held_by_other_notices: notice.notices };
704
+ if (notice.shouldPost) {
705
+ log('info', `slot ${slot}: held by ${holder}, staying silent (no messages fetched, no turns run) until this resident holds it.`);
706
+ await receipt({ outcome: 'silent_held_by_other', held_by: holder });
707
+ }
708
+ return { state: heldState, settings, stopped: false, turnsRun: 0 };
709
+ }
710
+
711
+ // Defect 1 fix, part (a): a pass that finds itself going disabled ->
712
+ // enabled must reseed exactly like a first run (seed the seen-set from
713
+ // whatever is currently visible, answer none of it), never treat the
714
+ // whole backlog as fresh. wasDisabled is read from state BEFORE any
715
+ // mutation below, and survives a process restart because it was
716
+ // persisted on the disabled pass above.
717
+ const firstRun = state.bootstrapped !== true;
718
+ const wasDisabled = state.last_enabled_state === false;
719
+ const reenableReseed = !firstRun && wasDisabled;
720
+ const seedPass = firstRun || reenableReseed;
721
+
722
+ const messages = await fetchMessages();
723
+ const picked = pickNewMessages({
724
+ messages,
725
+ seenIds: state.seen_ids,
726
+ selfIdentity,
727
+ fromFilters,
728
+ firstRun: seedPass,
729
+ });
730
+
731
+ let nextState = { ...state, seen_ids: boundSeenIds(picked.seenIds, maxSeenIds) };
732
+
733
+ if (firstRun) {
734
+ nextState = { ...nextState, bootstrapped: true };
735
+ log('info', `first run: seeded seen-set with ${picked.allSeen.length} existing message id(s), answering none.`);
736
+ } else if (reenableReseed) {
737
+ log('info', `re-enabled after being disabled: seeded seen-set with ${picked.allSeen.length} message id(s) visible right now, answering none (stale-backlog churn guard, defect fixed 2026-08-23).`);
738
+ }
739
+
740
+ let turnsRun = 0;
741
+ let staleSkipped = 0;
742
+ for (const message of picked.toAnswer) {
743
+ // Defect 1 fix, part (b): the absolute max-age guard. Belt to (a)'s
744
+ // braces, independent of how the message became eligible: a message
745
+ // older than maxAgeMinutes is marked seen (already folded into
746
+ // seen_ids above) and skipped, never answered, regardless of the
747
+ // disabled/enabled transition logic.
748
+ const ageMs = messageAgeMs(message, now());
749
+ if (ageMs != null && ageMs > maxAgeMinutes * 60 * 1000) {
750
+ staleSkipped += 1;
751
+ log('warn', `message ${message.id} from ${message.from} skipped as stale (age ${Math.round(ageMs / 60000)}m, max ${maxAgeMinutes}m): marked seen, never answered.`);
752
+ await receipt({ outcome: 'stale_skipped', message_id: message.id, message_from: message.from });
753
+ continue;
754
+ }
755
+
756
+ let timestamps = pruneOldTurns(nextState.turn_timestamps, now());
757
+ if (timestamps.length >= settings.max_turns_per_hour) {
758
+ log('warn', `rate limit hit: ${settings.max_turns_per_hour} turn(s)/hour reached, skipping message ${message.id} without spawning.`);
759
+ nextState = { ...nextState, turn_timestamps: timestamps };
760
+ await receipt({ outcome: 'rate_limited', message_id: message.id, message_from: message.from });
761
+ continue;
762
+ }
763
+
764
+ log('info', `inbound message ${message.id} from ${message.from}: ${truncate(message.text)}`);
765
+
766
+ const prompt = buildTurnPrompt({ hostId, slot, message, history: nextState.recent_exchanges.slice(-historyWindow), state: nextState });
767
+ const turnStartedMs = Date.now();
768
+ const result = await spawnTurn(prompt, settings.turn_timeout_seconds);
769
+ const durationMs = Date.now() - turnStartedMs;
770
+ timestamps = [...timestamps, now()];
771
+ nextState = { ...nextState, turn_timestamps: timestamps };
772
+ turnsRun += 1;
773
+
774
+ if (result.timedOut) {
775
+ log('warn', `turn timed out after ${settings.turn_timeout_seconds}s for message ${message.id}; skipping reply (never posted).`);
776
+ await receipt({ outcome: 'timeout', message_id: message.id, message_from: message.from, duration_ms: durationMs });
777
+ continue;
778
+ }
779
+ if (result.error) {
780
+ log('warn', `turn failed to spawn for message ${message.id}: ${result.error.message}; skipping reply.`);
781
+ await receipt({ outcome: 'error', message_id: message.id, message_from: message.from, duration_ms: durationMs, error_note: `spawn: ${result.error.message}` });
782
+ continue;
783
+ }
784
+
785
+ // Defect 2 fix: never post an error-shaped turn as if it were the
786
+ // resident's own reply. Log the full error, record it in state for
787
+ // `status` to surface, and post at most one short plain-language
788
+ // notice per distinct error signature per hour.
789
+ const turnError = detectTurnError(result);
790
+ if (turnError) {
791
+ log('error', `turn error for message ${message.id}: type=${turnError.type} message=${turnError.message} (never posted to the channel)`);
792
+ await receipt({ outcome: 'error', message_id: message.id, message_from: message.from, duration_ms: durationMs, error_note: `${turnError.type}: ${turnError.message}`, cost_usd: result.total_cost_usd ?? undefined, session_id: result.session_id || undefined });
793
+ nextState = {
794
+ ...nextState,
795
+ last_turn_error: { type: turnError.type, message: turnError.message, category: turnError.category, signature: turnError.signature, at: new Date(now()).toISOString() },
796
+ };
797
+ const notice = shouldPostErrorNotice(nextState.error_notices, turnError.signature, now());
798
+ nextState = { ...nextState, error_notices: notice.notices };
799
+ if (notice.shouldPost) {
800
+ const noticeText = buildErrorNoticeText(turnError.category);
801
+ await postReply(noticeText);
802
+ log('warn', `posted one-per-hour error notice for signature "${turnError.signature}": ${noticeText}`);
803
+ } else {
804
+ log('warn', `error notice suppressed for signature "${turnError.signature}" (already notified within the last hour).`);
805
+ }
806
+ continue;
807
+ }
808
+
809
+ const replyText = (result.text || '').trim();
810
+ if (!replyText) {
811
+ log('warn', `empty reply for message ${message.id}; skipping (never posted).`);
812
+ await receipt({ outcome: 'error', message_id: message.id, message_from: message.from, duration_ms: durationMs, error_note: 'empty reply', cost_usd: result.total_cost_usd ?? undefined, session_id: result.session_id || undefined });
813
+ continue;
814
+ }
815
+
816
+ await postReply(replyText);
817
+ log('info', `replied to ${message.id} (${replyText.length} chars, session_id=${result.session_id || 'n/a'}, cost_usd=${result.total_cost_usd ?? 'n/a'})`);
818
+ await receipt({
819
+ outcome: 'replied', message_id: message.id, message_from: message.from,
820
+ reply_chars: replyText.length, cost_usd: result.total_cost_usd ?? undefined,
821
+ duration_ms: durationMs, session_id: result.session_id || undefined,
822
+ });
823
+
824
+ const decisions = boundDecisions([
825
+ ...nextState.decisions,
826
+ {
827
+ at: new Date(now()).toISOString(),
828
+ message_id: message.id,
829
+ from: message.from,
830
+ reply_excerpt: truncate(replyText, 200),
831
+ session_id: result.session_id || null,
832
+ cost_usd: result.total_cost_usd ?? null,
833
+ },
834
+ ], maxDecisions);
835
+
836
+ const recentExchanges = boundExchanges([
837
+ ...nextState.recent_exchanges,
838
+ { from: message.from, text: message.text, reply: replyText },
839
+ ], historyWindow);
840
+
841
+ nextState = { ...nextState, decisions, recent_exchanges: recentExchanges };
842
+ }
843
+
844
+ if (staleSkipped > 0) {
845
+ log('warn', `stale-skip summary: ${staleSkipped} message(s) skipped as too old to answer (max age ${maxAgeMinutes}m), never answered.`);
846
+ // OWNER-FACING HONESTY (2026-08-24 incident): silence here ATE five real
847
+ // owner messages, including two direct work orders, after a downtime.
848
+ // The churn guard protecting spend must never read as the resident
849
+ // ignoring its owner: post ONE plain line saying what was dropped and
850
+ // that a human/session needs to pick it up. One line per pass, never per
851
+ // message, so the spend cost of honesty is a single say.
852
+ try {
853
+ await postReply(
854
+ `I was unable to answer ${staleSkipped} message(s) in time (older than my ${maxAgeMinutes}m auto-answer window). `
855
+ + 'They are marked seen, not actioned: please re-send anything still needed, or a full session should pick them up.'
856
+ );
857
+ } catch (err) {
858
+ log('warn', `stale-skip notice failed to post: ${err.message}`);
859
+ }
860
+ }
861
+
862
+ nextState = { ...nextState, host_id: hostId, slot, updated_at: new Date(now()).toISOString(), last_enabled_state: true };
863
+ return { state: nextState, settings, stopped: false, turnsRun };
864
+ }