quoxcli 0.2.0 → 0.3.1

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 (327) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -0
  3. package/bin/quox.js +99 -126
  4. package/lib/api/endpoints/admin.js +26 -0
  5. package/lib/api/endpoints/agent-schedules.js +26 -0
  6. package/lib/api/endpoints/agents.js +7 -0
  7. package/lib/api/endpoints/assistants.js +12 -0
  8. package/lib/api/endpoints/away.js +15 -0
  9. package/lib/api/endpoints/chat.js +7 -0
  10. package/lib/api/endpoints/contactsq.js +2 -0
  11. package/lib/api/endpoints/conversations.js +14 -0
  12. package/lib/api/endpoints/executions.js +13 -0
  13. package/lib/api/endpoints/fetchAllowlist.js +27 -0
  14. package/lib/api/endpoints/files.js +14 -0
  15. package/lib/api/endpoints/hat.js +30 -0
  16. package/lib/api/endpoints/inbox.js +20 -0
  17. package/lib/api/endpoints/index.js +14 -0
  18. package/lib/api/endpoints/memory.js +19 -0
  19. package/lib/api/endpoints/mind.js +13 -0
  20. package/lib/api/endpoints/mon.js +6 -0
  21. package/lib/api/endpoints/objectives.js +6 -0
  22. package/lib/api/endpoints/plugins.js +18 -0
  23. package/lib/api/endpoints/presence.js +33 -0
  24. package/lib/api/endpoints/quoxplan.js +7 -0
  25. package/lib/api/endpoints/quoxseo.js +6 -0
  26. package/lib/api/endpoints/router.js +17 -0
  27. package/lib/api/endpoints/safety.js +7 -0
  28. package/lib/api/endpoints/skills.js +20 -0
  29. package/lib/api/endpoints/stepup.js +20 -0
  30. package/lib/api/endpoints/teams.js +2 -2
  31. package/lib/api/endpoints/triggers.js +26 -0
  32. package/lib/api/endpoints/v1.js +86 -0
  33. package/lib/api/endpoints/ward.js +7 -0
  34. package/lib/api/endpoints/workflows.js +7 -0
  35. package/lib/api/endpoints/x.js +8 -0
  36. package/lib/commands/actions.js +61 -0
  37. package/lib/commands/admin.js +16 -0
  38. package/lib/commands/agent.js +163 -27
  39. package/lib/commands/agentAttach.js +94 -6
  40. package/lib/commands/approvals.js +73 -1
  41. package/lib/commands/aside.js +24 -0
  42. package/lib/commands/ask.js +144 -0
  43. package/lib/commands/assistant.js +59 -4
  44. package/lib/commands/auth.js +8 -2
  45. package/lib/commands/backup.js +8 -4
  46. package/lib/commands/brain.js +105 -14
  47. package/lib/commands/build.js +19 -5
  48. package/lib/commands/chat.js +57 -2
  49. package/lib/commands/claude.js +34 -8
  50. package/lib/commands/contacts.js +49 -0
  51. package/lib/commands/conversation.js +35 -4
  52. package/lib/commands/dashboard.js +43 -0
  53. package/lib/commands/department.js +28 -7
  54. package/lib/commands/devResident.js +15 -3
  55. package/lib/commands/devSupervisor.js +13 -1
  56. package/lib/commands/discord.js +75 -39
  57. package/lib/commands/done.js +98 -28
  58. package/lib/commands/engagement.js +17 -6
  59. package/lib/commands/fetchAllowlist.js +71 -0
  60. package/lib/commands/file.js +38 -1
  61. package/lib/commands/fleet.js +311 -4
  62. package/lib/commands/governance.js +12 -3
  63. package/lib/commands/gripe.js +44 -13
  64. package/lib/commands/hat.js +178 -9
  65. package/lib/commands/helpers.js +18 -1
  66. package/lib/commands/hyperion.js +58 -0
  67. package/lib/commands/inbox.js +74 -23
  68. package/lib/commands/licence.js +14 -155
  69. package/lib/commands/lucid.js +10 -4
  70. package/lib/commands/memory.js +85 -8
  71. package/lib/commands/mind.js +71 -0
  72. package/lib/commands/mon.js +69 -2
  73. package/lib/commands/monitoring.js +1 -1
  74. package/lib/commands/motion.js +2 -2
  75. package/lib/commands/nucleus.js +48 -17
  76. package/lib/commands/objective.js +11 -0
  77. package/lib/commands/offload.js +89 -0
  78. package/lib/commands/onboard.js +2 -1
  79. package/lib/commands/org.js +11 -1
  80. package/lib/commands/peek.js +28 -0
  81. package/lib/commands/plan.js +38 -0
  82. package/lib/commands/plugin.js +110 -0
  83. package/lib/commands/presence.js +89 -0
  84. package/lib/commands/provider.js +159 -0
  85. package/lib/commands/qlarity.js +19 -3
  86. package/lib/commands/quoxchat.js +13 -4
  87. package/lib/commands/quoxseo.js +38 -0
  88. package/lib/commands/reach.js +2 -1
  89. package/lib/commands/router.js +77 -0
  90. package/lib/commands/rows.js +358 -0
  91. package/lib/commands/run.js +64 -9
  92. package/lib/commands/server.js +292 -0
  93. package/lib/commands/setup.js +33 -7
  94. package/lib/commands/skills.js +48 -5
  95. package/lib/commands/slot.js +260 -7
  96. package/lib/commands/status.js +1 -1
  97. package/lib/commands/team.js +3 -1
  98. package/lib/commands/tips.js +23 -0
  99. package/lib/commands/tool.js +133 -45
  100. package/lib/commands/trace.js +12 -7
  101. package/lib/commands/trigger.js +95 -0
  102. package/lib/commands/update.js +46 -0
  103. package/lib/commands/vault.js +31 -8
  104. package/lib/commands/vm.js +237 -0
  105. package/lib/commands/voice.js +34 -3
  106. package/lib/commands/volt.js +45 -3
  107. package/lib/commands/walkthrough.js +32 -0
  108. package/lib/commands/watch.js +73 -21
  109. package/lib/commands/welcome.js +14 -0
  110. package/lib/commands/workflow.js +189 -32
  111. package/lib/commands/x.js +13 -4
  112. package/lib/core/actingAs.js +2 -2
  113. package/lib/core/activeSummary.js +186 -0
  114. package/lib/core/aside.js +54 -0
  115. package/lib/core/askDecision.js +60 -0
  116. package/lib/core/auth.js +164 -13
  117. package/lib/core/callerSource.js +1 -1
  118. package/lib/core/cliBuild.js +62 -0
  119. package/lib/core/client.js +144 -35
  120. package/lib/core/commandRegistrars.js +121 -0
  121. package/lib/core/config.js +142 -24
  122. package/lib/core/customHats.js +168 -0
  123. package/lib/core/doneEngineFetch.js +8 -0
  124. package/lib/core/errors.js +329 -20
  125. package/lib/core/global-flags.js +105 -5
  126. package/lib/core/hyperion.js +102 -0
  127. package/lib/core/inboxResolution.js +65 -0
  128. package/lib/core/lazyCommands.js +4 -1
  129. package/lib/core/offload.js +504 -0
  130. package/lib/core/org-override.js +1 -1
  131. package/lib/core/orgResolve.js +70 -0
  132. package/lib/core/output.js +4 -4
  133. package/lib/core/peek.js +126 -0
  134. package/lib/core/profileUsers.js +51 -0
  135. package/lib/core/promptCancel.js +18 -0
  136. package/lib/core/repoPath.js +11 -0
  137. package/lib/core/residentSeed.js +3 -1
  138. package/lib/core/rootOptions.js +39 -0
  139. package/lib/core/router.js +168 -0
  140. package/lib/core/selfUpdate.js +188 -0
  141. package/lib/core/sessionsOverview.js +64 -0
  142. package/lib/core/slotKeeper.js +248 -3
  143. package/lib/core/slotWake.js +120 -0
  144. package/lib/core/supervisorEngine.js +71 -2
  145. package/lib/core/version.js +1 -1
  146. package/lib/core/vm.js +361 -0
  147. package/lib/core/vmTui.js +86 -0
  148. package/lib/core/walkthrough.js +35 -0
  149. package/lib/hyperion/deck.js +29 -0
  150. package/lib/lucid/LucidCard.jsx +68 -0
  151. package/lib/lucid/LucidIntro.jsx +45 -58
  152. package/lib/lucid/cards.js +86 -0
  153. package/lib/lucid/markdown.js +81 -0
  154. package/lib/lucid/motion.js +56 -0
  155. package/lib/quoxon/activity.js +14 -2
  156. package/lib/quoxon/aocl.js +117 -0
  157. package/lib/quoxon/api.js +504 -25
  158. package/lib/quoxon/app.js +3000 -230
  159. package/lib/quoxon/approvalFacts.js +94 -0
  160. package/lib/quoxon/approvalsView.js +325 -0
  161. package/lib/quoxon/asideView.js +40 -0
  162. package/lib/quoxon/ask.js +121 -0
  163. package/lib/quoxon/away.js +52 -0
  164. package/lib/quoxon/bang.js +224 -0
  165. package/lib/quoxon/bulkRisk.js +17 -0
  166. package/lib/quoxon/capabilities.js +13 -6
  167. package/lib/quoxon/cells.js +53 -0
  168. package/lib/quoxon/commandShort.js +112 -0
  169. package/lib/quoxon/commands.js +363 -38
  170. package/lib/quoxon/connect.js +210 -23
  171. package/lib/quoxon/connectCard.js +19 -2
  172. package/lib/quoxon/consent.js +86 -0
  173. package/lib/quoxon/copy.js +4 -3
  174. package/lib/quoxon/countdownLine.js +11 -0
  175. package/lib/quoxon/demo.autoplay.js +29 -0
  176. package/lib/quoxon/demo.fixtures.js +1 -1
  177. package/lib/quoxon/demo.js +44 -3
  178. package/lib/quoxon/devObserver.js +20 -0
  179. package/lib/quoxon/dividerAocl.js +191 -0
  180. package/lib/quoxon/dividerRule.js +57 -0
  181. package/lib/quoxon/dragonModes.js +150 -0
  182. package/lib/quoxon/dragonView.js +41 -0
  183. package/lib/quoxon/earlyInput.js +40 -0
  184. package/lib/quoxon/echo.js +15 -0
  185. package/lib/quoxon/egg.js +2 -2
  186. package/lib/quoxon/events.js +244 -25
  187. package/lib/quoxon/feedback.js +50 -0
  188. package/lib/quoxon/gallery.js +21 -7
  189. package/lib/quoxon/handoffOffer.js +78 -0
  190. package/lib/quoxon/hatSwitch.js +37 -0
  191. package/lib/quoxon/headless.js +161 -0
  192. package/lib/quoxon/hostUrls.js +41 -0
  193. package/lib/quoxon/indicator.js +6 -2
  194. package/lib/quoxon/intent.js +12 -1
  195. package/lib/quoxon/keys.js +49 -6
  196. package/lib/quoxon/launcher.js +103 -0
  197. package/lib/quoxon/main.js +92 -160
  198. package/lib/quoxon/markdown.js +45 -21
  199. package/lib/quoxon/markdownSettle.js +19 -0
  200. package/lib/quoxon/matrixDomains.json +46 -0
  201. package/lib/quoxon/mentions.js +90 -0
  202. package/lib/quoxon/mind.js +103 -0
  203. package/lib/quoxon/mode.js +46 -0
  204. package/lib/quoxon/model.js +132 -26
  205. package/lib/quoxon/modelPrefs.js +345 -0
  206. package/lib/quoxon/motion/clock.js +3 -2
  207. package/lib/quoxon/motion/level.js +91 -0
  208. package/lib/quoxon/mouse.js +211 -0
  209. package/lib/quoxon/noai.js +57 -0
  210. package/lib/quoxon/objects/away.js +72 -0
  211. package/lib/quoxon/objects/fetch.js +17 -5
  212. package/lib/quoxon/objects/fleet.js +149 -0
  213. package/lib/quoxon/objects/health.js +186 -0
  214. package/lib/quoxon/objects/index.js +24 -3
  215. package/lib/quoxon/objects/mirror.js +137 -0
  216. package/lib/quoxon/objects/peek.js +17 -0
  217. package/lib/quoxon/objects/prove.js +130 -0
  218. package/lib/quoxon/objects/replay.js +125 -0
  219. package/lib/quoxon/objects/sitrep.js +58 -0
  220. package/lib/quoxon/objects/view.js +11 -0
  221. package/lib/quoxon/onboarding/copy.js +32 -4
  222. package/lib/quoxon/onboarding/credential.js +17 -4
  223. package/lib/quoxon/onboarding/dockerOffer.js +45 -0
  224. package/lib/quoxon/onboarding/failureCause.js +33 -0
  225. package/lib/quoxon/onboarding/finish.js +15 -0
  226. package/lib/quoxon/onboarding/index.js +34 -7
  227. package/lib/quoxon/onboarding/installRunner.js +64 -5
  228. package/lib/quoxon/onboarding/preflight.js +17 -3
  229. package/lib/quoxon/onboarding/probe.js +65 -0
  230. package/lib/quoxon/onboarding/view.js +76 -19
  231. package/lib/quoxon/orgLabel.js +10 -0
  232. package/lib/quoxon/orgSwitch.js +33 -0
  233. package/lib/quoxon/ownBroadcast.js +22 -0
  234. package/lib/quoxon/palette.js +43 -0
  235. package/lib/quoxon/print.js +9 -5
  236. package/lib/quoxon/profileSwitch.js +53 -0
  237. package/lib/quoxon/promptHistory.js +41 -0
  238. package/lib/quoxon/providers.js +48 -0
  239. package/lib/quoxon/qart.js +20 -51
  240. package/lib/quoxon/qdragon.js +410 -0
  241. package/lib/quoxon/qdragonFly.js +61 -0
  242. package/lib/quoxon/qdragonScenes.js +282 -0
  243. package/lib/quoxon/qeyes.js +213 -0
  244. package/lib/quoxon/qmotion.js +58 -37
  245. package/lib/quoxon/render.js +638 -119
  246. package/lib/quoxon/resend.js +72 -0
  247. package/lib/quoxon/roster.js +95 -9
  248. package/lib/quoxon/route.js +6 -5
  249. package/lib/quoxon/rows.js +711 -0
  250. package/lib/quoxon/rowsStore.js +80 -0
  251. package/lib/quoxon/screenContext.js +150 -0
  252. package/lib/quoxon/screenFrames.js +254 -0
  253. package/lib/quoxon/session.js +172 -2
  254. package/lib/quoxon/sessionDead.js +17 -0
  255. package/lib/quoxon/signin.js +125 -0
  256. package/lib/quoxon/slash/agents.js +112 -0
  257. package/lib/quoxon/slash/argSources.js +65 -0
  258. package/lib/quoxon/slash/args.js +65 -0
  259. package/lib/quoxon/slash/automate.js +89 -0
  260. package/lib/quoxon/slash/broadcast.js +140 -0
  261. package/lib/quoxon/slash/broadcastRun.js +108 -0
  262. package/lib/quoxon/slash/card.js +30 -0
  263. package/lib/quoxon/slash/challenge.js +114 -0
  264. package/lib/quoxon/slash/cliCommand.js +53 -0
  265. package/lib/quoxon/slash/cliLeaves.json +948 -0
  266. package/lib/quoxon/slash/compact.js +1 -1
  267. package/lib/quoxon/slash/confirm.js +30 -0
  268. package/lib/quoxon/slash/crystallize.js +69 -0
  269. package/lib/quoxon/slash/dashboard.js +136 -0
  270. package/lib/quoxon/slash/draft.js +54 -0
  271. package/lib/quoxon/slash/hatWalk.js +206 -0
  272. package/lib/quoxon/slash/inverses.js +77 -0
  273. package/lib/quoxon/slash/killer.js +24 -0
  274. package/lib/quoxon/slash/killerApi.js +16 -0
  275. package/lib/quoxon/slash/ledger.js +13 -8
  276. package/lib/quoxon/slash/listView.js +3 -1
  277. package/lib/quoxon/slash/modelCmd.js +261 -0
  278. package/lib/quoxon/slash/palette.js +21 -7
  279. package/lib/quoxon/slash/paletteController.js +117 -7
  280. package/lib/quoxon/slash/panels.js +21 -6
  281. package/lib/quoxon/slash/picker.js +20 -2
  282. package/lib/quoxon/slash/restart.js +71 -13
  283. package/lib/quoxon/slash/rowCmd.js +157 -0
  284. package/lib/quoxon/slash/rowWalk.js +175 -0
  285. package/lib/quoxon/slash/settings.js +16 -6
  286. package/lib/quoxon/slash/spawn.js +177 -0
  287. package/lib/quoxon/slash/stepUp.js +274 -0
  288. package/lib/quoxon/slash/stepUpCard.js +60 -0
  289. package/lib/quoxon/slash/stepUpCmds.js +78 -0
  290. package/lib/quoxon/slash/undoApi.js +38 -0
  291. package/lib/quoxon/slash/undoCmd.js +49 -0
  292. package/lib/quoxon/slash/undoWish.js +15 -0
  293. package/lib/quoxon/slash/watch.js +164 -0
  294. package/lib/quoxon/slash/wish.js +23 -0
  295. package/lib/quoxon/slash/wishCmd.js +32 -0
  296. package/lib/quoxon/slotChat.js +300 -0
  297. package/lib/quoxon/slotFormat.js +195 -0
  298. package/lib/quoxon/slotRun.js +131 -0
  299. package/lib/quoxon/sound/mapping.js +2 -1
  300. package/lib/quoxon/stray.js +22 -0
  301. package/lib/quoxon/suggestCommand.js +59 -0
  302. package/lib/quoxon/term.js +46 -11
  303. package/lib/quoxon/textBuffer.js +176 -2
  304. package/lib/quoxon/theme.js +58 -25
  305. package/lib/quoxon/tips.js +433 -0
  306. package/lib/quoxon/toolError.js +41 -0
  307. package/lib/quoxon/transcript.js +42 -4
  308. package/lib/quoxon/web/qface.js +18 -10
  309. package/lib/quoxon/welcome.js +1 -1
  310. package/lib/quoxon/whatsnew.js +136 -0
  311. package/lib/quoxon/whatsnew.jsonl +89 -0
  312. package/lib/quoxon/windowOrg.js +113 -0
  313. package/lib/server/diag.js +129 -0
  314. package/lib/server/doctor.js +71 -0
  315. package/lib/server/install.js +149 -0
  316. package/lib/server/pluginEnable.js +161 -0
  317. package/lib/server/skew.js +56 -0
  318. package/lib/server/snapshot.js +131 -0
  319. package/lib/server/status.js +128 -0
  320. package/lib/server/sys.js +65 -0
  321. package/lib/server/uninstall.js +63 -0
  322. package/lib/server/update.js +234 -0
  323. package/lib/toolOutputShape.js +315 -0
  324. package/lib/tui/views/HyperionDeck.jsx +79 -0
  325. package/lib/tui/views/LucidDeck.jsx +209 -217
  326. package/package.json +4 -1
  327. package/lib/core/websiteSession.js +0 -148
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The one CLI updater (owner 2026-10-16 "we need /update", "or quox --update"): shared by /update in the
3
+ * conversation client and `quox update` / `quox --update` in the shell.
4
+ *
5
+ * It updates THIS quox the way it was installed, and never builds a second path where one already exists:
6
+ * npm <root> is node_modules/quoxcli -> npm install -g quoxcli@<latest>
7
+ * shared-git the box's shared CLI tree -> quox-cli-refresh (it refuses a dirty tree itself)
8
+ * git any other git checkout -> git -C <root> pull --ff-only (clean and on main only)
9
+ * server a self-hosted install, at a v* tag -> points at `quox server update` (that is its updater)
10
+ * bundle a dev-server bundle (.bundle-sha) -> points at the server's refresh
11
+ * It never runs git stash, reset or checkout. Every external call is injectable (deps.run / spawn) for tests.
12
+ *
13
+ * Network: `npm view quoxcli ...` (the npm registry) and `git fetch origin main` (the tree's own remote), only
14
+ * when the user asks for an update. Nothing is sent but those requests.
15
+ */
16
+ import { spawnSync, execFile } from 'node:child_process';
17
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import os from 'node:os';
20
+ import { RUNNING_ROOT, findOnPath } from '../quoxon/slash/restart.js';
21
+ import { cmpSemver } from '../server/install.js';
22
+ import { askText } from '../quoxon/ask.js';
23
+
24
+ export const NPM_PACKAGE = 'quoxcli';
25
+ export const NOTES_MAX = 5;
26
+
27
+ function defaultRun(cmd, args, { timeout = 30000 } = {}) {
28
+ const r = spawnSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout });
29
+ if (r.error) return { code: null, stdout: '', stderr: `${r.error.code || 'failed'}: ${r.error.message}` };
30
+ return { code: r.status, stdout: String(r.stdout || '').trim(), stderr: String(r.stderr || '').trim() };
31
+ }
32
+
33
+ /** The same, without blocking: the check runs while the conversation client keeps painting. */
34
+ function asyncRun(cmd, args, { timeout = 30000 } = {}) {
35
+ return new Promise((done) => {
36
+ execFile(cmd, args, { encoding: 'utf8', timeout }, (err, stdout, stderr) => {
37
+ if (err && typeof err.code !== 'number') return done({ code: null, stdout: '', stderr: `${err.code || (err.killed ? 'timed out' : 'failed')}: ${String(stderr || err.message).trim()}` });
38
+ done({ code: err ? err.code : 0, stdout: String(stdout || '').trim(), stderr: String(stderr || '').trim() });
39
+ });
40
+ });
41
+ }
42
+
43
+ function withDeps(deps = {}, run = defaultRun) {
44
+ return {
45
+ run,
46
+ exists: existsSync,
47
+ readFile: (p) => readFileSync(p, 'utf8'),
48
+ realpath: (p) => { try { return realpathSync(p); } catch { return p; } },
49
+ findOnPath,
50
+ home: os.homedir(),
51
+ ...deps,
52
+ };
53
+ }
54
+
55
+ const gitOut = (d, dir, args, timeout = 5000) => {
56
+ const r = d.run('git', ['-C', dir, ...args], { timeout });
57
+ return r.code === 0 ? String(r.stdout || '').trim() : null;
58
+ };
59
+ const gitOutA = async (d, dir, args, timeout = 5000) => {
60
+ const r = await d.run('git', ['-C', dir, ...args], { timeout });
61
+ return r.code === 0 ? String(r.stdout || '').trim() : null;
62
+ };
63
+ const lastLine = (s) => String(s || '').trim().split('\n').filter(Boolean).pop() || '';
64
+
65
+ /** How this quox was installed. -> { kind, root, version, branch?, refresh?, tag? } */
66
+ export function detectInstall({ root = RUNNING_ROOT, env = process.env, deps = {} } = {}) {
67
+ const d = withDeps(deps);
68
+ const real = d.realpath(root);
69
+ let version = null;
70
+ try { version = JSON.parse(d.readFile(join(root, 'package.json'))).version || null; } catch { /* no package.json */ }
71
+ if (/[/\\]node_modules[/\\]quoxcli$/.test(real)) return { kind: 'npm', root: real, version };
72
+ if (d.exists(join(root, '.bundle-sha'))) return { kind: 'bundle', root: real, version };
73
+ const top = gitOut(d, real, ['rev-parse', '--show-toplevel']);
74
+ if (top) {
75
+ const branch = gitOut(d, top, ['rev-parse', '--abbrev-ref', 'HEAD']);
76
+ const refresh = d.findOnPath('quox-cli-refresh', env);
77
+ const shared = d.realpath(env.QUOX_CLI_DIR || join(d.home, 'quox-dashboard-cli'));
78
+ if (refresh && d.realpath(top) === shared) return { kind: 'shared-git', root: top, refresh, branch, version };
79
+ if (branch === 'HEAD') {
80
+ const tag = gitOut(d, top, ['describe', '--tags', '--exact-match']);
81
+ if (tag && /^v\d/.test(tag)) return { kind: 'server', root: top, tag, version };
82
+ }
83
+ return { kind: 'git', root: top, branch, version };
84
+ }
85
+ return { kind: 'unknown', root: real, version };
86
+ }
87
+
88
+ async function checkNpm(info, d) {
89
+ const cur = info.version || '0.0.0';
90
+ const v = await d.run('npm', ['view', NPM_PACKAGE, 'version'], { timeout: 20000 });
91
+ if (v.code !== 0 || !v.stdout) return { ok: false, kind: 'npm', current: cur, error: `npm view ${NPM_PACKAGE} version failed: ${lastLine(v.stderr) || `exit ${v.code}`}` };
92
+ const latest = lastLine(v.stdout);
93
+ const cmd = `npm install -g ${NPM_PACKAGE}@${latest}`;
94
+ const base = { ok: true, kind: 'npm', current: cur, latest, notes: [], manual: cmd };
95
+ const c = cmpSemver(latest, cur);
96
+ if (c !== null && c <= 0) return { ...base, upToDate: true, ahead: c < 0, count: 0 };
97
+ let count = 1;
98
+ const all = await d.run('npm', ['view', NPM_PACKAGE, 'versions', '--json'], { timeout: 20000 });
99
+ try {
100
+ const list = JSON.parse(all.stdout);
101
+ const n = (Array.isArray(list) ? list : [list]).filter((x) => cmpSemver(x, cur) === 1 && cmpSemver(x, latest) <= 0).length;
102
+ if (n > 0) count = n;
103
+ } catch { /* keep 1 */ }
104
+ return { ...base, upToDate: false, count, command: { cmd: 'npm', args: ['install', '-g', `${NPM_PACKAGE}@${latest}`], display: cmd } };
105
+ }
106
+
107
+ async function checkGit(info, d) {
108
+ const { root, kind } = info;
109
+ const f = await d.run('git', ['-C', root, 'fetch', 'origin', 'main', '--quiet'], { timeout: 30000 });
110
+ if (f.code !== 0) return { ok: false, kind, error: `git -C ${root} fetch origin main failed: ${lastLine(f.stderr) || `exit ${f.code}`}` };
111
+ const current = await gitOutA(d, root, ['rev-parse', '--short', 'HEAD']) || '?';
112
+ const latest = await gitOutA(d, root, ['rev-parse', '--short', 'origin/main']) || '?';
113
+ const count = Number(await gitOutA(d, root, ['rev-list', '--count', 'HEAD..origin/main']) || 0) || 0;
114
+ const pull = `git -C ${root} pull --ff-only`;
115
+ const shared = kind === 'shared-git';
116
+ const manual = shared ? 'quox-cli-refresh' : pull;
117
+ const base = { ok: true, kind, root, current, latest, count, manual };
118
+ if (count === 0) return { ...base, upToDate: true, notes: [] };
119
+ const notes = (await gitOutA(d, root, ['log', '--format=%s', '-n', String(NOTES_MAX), 'HEAD..origin/main']) || '').split('\n').filter(Boolean);
120
+ if (shared) {
121
+ return { ...base, upToDate: false, notes, scope: `This runs quox-cli-refresh, which updates quox for every session on this box (${root}).`, command: { cmd: info.refresh, args: [], display: 'quox-cli-refresh' } };
122
+ }
123
+ let refused = null;
124
+ if (info.branch !== 'main') refused = `${root} is on branch ${info.branch || '(unknown)'}, not main. Switch to main yourself, then update again. Nothing was changed.`;
125
+ else {
126
+ const dirty = (await gitOutA(d, root, ['status', '--porcelain', '--untracked-files=no']) || '').split('\n').filter(Boolean);
127
+ if (dirty.length) refused = `${root} has ${dirty.length} file${dirty.length === 1 ? '' : 's'} with uncommitted changes. Commit or move them, then update again. Nothing was changed.`;
128
+ }
129
+ return { ...base, upToDate: false, notes, refused, command: refused ? null : { cmd: 'git', args: ['-C', root, 'pull', '--ff-only'], display: pull } };
130
+ }
131
+
132
+ /**
133
+ * What an update would do. -> { ok, kind, current, latest, count, notes[], upToDate, command?, manual, scope?, refused?, error? }
134
+ * refused: this install has its own updater (or is not in a state to update): say why and the manual way.
135
+ */
136
+ export async function checkUpdate(info, { deps = {} } = {}) {
137
+ const d = withDeps(deps, asyncRun);
138
+ switch (info?.kind) {
139
+ case 'npm': return checkNpm(info, d);
140
+ case 'git': case 'shared-git': return checkGit(info, d);
141
+ case 'server': return { ok: true, kind: 'server', current: info.tag, manual: 'quox server update', refused: `This quox is part of your self-hosted Quox install (${info.tag} at ${info.root}), so it updates with the server: run quox server update (it shows the latest release and asks first).` };
142
+ case 'bundle': return { ok: true, kind: 'bundle', manual: null, refused: `This quox is the dev-server bundle in ${info.root}; the Quox server refreshes it while dev mode is on. Ask your admin, or install your own copy with npm install -g ${NPM_PACKAGE}@latest.` };
143
+ default: return { ok: true, kind: 'unknown', manual: `npm install -g ${NPM_PACKAGE}@latest`, refused: `Could not tell how this quox was installed (${info?.root || 'unknown path'}). To install the published one: npm install -g ${NPM_PACKAGE}@latest` };
144
+ }
145
+ }
146
+
147
+ /** Run the update with the terminal handed over (prompts, sudo). -> { ok, code } or { ok:false, error } */
148
+ export function runUpdate(plan, { spawn = spawnSync, env = process.env } = {}) {
149
+ const r = spawn(plan.command.cmd, plan.command.args, { stdio: 'inherit', env });
150
+ if (r.error) return { ok: false, error: `${r.error.code || 'failed'}: ${r.error.message}` };
151
+ const code = r.status ?? (r.signal ? 128 + (os.constants.signals[r.signal] || 0) : 1);
152
+ return { ok: code === 0, code };
153
+ }
154
+
155
+ const isGit = (p) => p.kind === 'git' || p.kind === 'shared-git';
156
+
157
+ /** The lines shown before the ask (or the one line when there is nothing to do). */
158
+ export function updateLines(plan) {
159
+ if (!plan.ok) return [`Could not check for updates: ${plan.error}`];
160
+ if (plan.refused && plan.kind !== 'git') return [plan.refused];
161
+ if (plan.upToDate) return [isGit(plan) ? `quox ${plan.current} is up to date with origin/main.` : plan.ahead ? `quox ${plan.current} is newer than the published ${plan.latest}. Nothing to update.` : `quox ${plan.current} is the latest.`];
162
+ const out = [];
163
+ const notes = plan.notes || [];
164
+ if (notes.length) out.push(`What's new (${isGit(plan) ? 'commits on origin/main' : 'releases'}):`, ...notes.slice(0, NOTES_MAX).map((n) => ` · ${n}`));
165
+ if (plan.count > notes.length && notes.length) out.push(` · and ${plan.count - notes.length} more`);
166
+ if (plan.kind === 'npm') out.push(`Runs: ${plan.command.display}. Release notes show after the restart (/whatsnew).`);
167
+ if (plan.scope) out.push(plan.scope);
168
+ if (plan.refused) out.push(`quox ${plan.current} -> ${plan.latest}: ${plan.count} change${plan.count === 1 ? '' : 's'}, not updated. ${plan.refused}`);
169
+ return out;
170
+ }
171
+
172
+ /** "▶ quox <cur> -> <latest>: <n> changes. Update now and restart? y/N/c" (the shared ask shape). */
173
+ export function updateAsk(plan, { restart = true } = {}) {
174
+ const n = plan.count;
175
+ return askText(`Update now${restart ? ' and restart' : ''}?`, { reason: `quox ${plan.current} -> ${plan.latest}: ${n} change${n === 1 ? '' : 's'}.` });
176
+ }
177
+
178
+ /** `quox --update [...]` is `quox update [...]` when it comes before any subcommand. */
179
+ export function rewriteUpdateFlag(argv) {
180
+ const VALUE = new Set(['-p', '--profile', '-o', '--output', '--org']);
181
+ for (let i = 0; i < argv.length; i++) {
182
+ const a = argv[i];
183
+ if (a === '--update') return [...argv.slice(0, i), 'update', ...argv.slice(i + 1)];
184
+ if (VALUE.has(a)) { i++; continue; }
185
+ if (!a.startsWith('-')) return argv; // a subcommand came first: its own flags are its business
186
+ }
187
+ return argv;
188
+ }
@@ -107,6 +107,70 @@ export function overviewLines(index, { now = Date.now(), localHost = null, all =
107
107
  return out;
108
108
  }
109
109
 
110
+ /**
111
+ * SESSIONS (owner 2026-10-15): the quox conversation client's bare `/sessions`.
112
+ * Human first: every row leads with the session NAME and WHERE it runs, then
113
+ * how long it has been up and when it was last active. Ids and attach commands
114
+ * only appear with `refs` (one dim line under each row). Sections by what the
115
+ * thing is (terminal sessions, slots, work, chats), newest activity first.
116
+ * A row with no host says "host unknown"; nothing is guessed.
117
+ * -> [{ text, tone, state? }]
118
+ */
119
+ const SECTIONS = [
120
+ { title: 'Terminal sessions', test: (r) => r.kind === 'tmux' },
121
+ { title: 'Slots', test: (r) => r.kind === 'slot' },
122
+ { title: 'Work', test: (r) => r.kind !== 'tmux' && r.kind !== 'slot' && r.kind !== 'conversation' },
123
+ { title: 'Chats', test: (r) => r.kind === 'conversation' },
124
+ ];
125
+
126
+ function whereLabel(r, localHost) {
127
+ if (r.kind === 'conversation') return 'on the Quox server';
128
+ if (!r.host) return 'host unknown';
129
+ return localHost && r.host === localHost ? `on ${r.host} (this machine)` : `on ${r.host}`;
130
+ }
131
+
132
+ function sessionName(r) {
133
+ if (r.kind === 'slot') return r.holder_session || r.name || '(unnamed slot)';
134
+ return r.name || '(unnamed)';
135
+ }
136
+
137
+ export function humanSessionLines(index, { now = Date.now(), localHost = null, all = false, refs = false, names = {} } = {}) {
138
+ const prepared = prepareIndex(index, { all, names });
139
+ const rows = prepared.groups.flatMap((g) => g.rows).sort((a, b) => timeOf(b) - timeOf(a));
140
+ const counts = index.counts || {};
141
+ const ago = (v) => { const t = Date.parse(v); return Number.isFinite(t) ? formatAge((now - t) / 1000) : null; };
142
+ const out = [
143
+ { text: stripAnsi(line('quox', 'sessions', { result: `${counts.total || 0} live` })), tone: 'accent' },
144
+ { text: `${counts.working || 0} working · ${counts.waiting || 0} waiting · ${counts.resting || 0} resting · ${counts.failing || 0} failing`, tone: 'dim' },
145
+ ];
146
+ if (!rows.length) out.push({ text: 'No live sessions in this org.', tone: 'dim' });
147
+ for (const sec of SECTIONS) {
148
+ const rs = rows.filter(sec.test);
149
+ if (!rs.length) continue;
150
+ out.push({ text: `${sec.title} (${rs.length})`, tone: 'accent' });
151
+ for (const r of rs) {
152
+ const bits = [whereLabel(r, localHost)];
153
+ if (r.kind === 'slot' && r.holder_session && r.name && r.name !== r.holder_session) bits.push(`holds slot ${r.name}`);
154
+ const up = r.started_at ? ago(r.started_at) : null;
155
+ if (up) bits.push(`up ${up}`);
156
+ const act = ago(r.last_active);
157
+ bits.push(act ? `active ${act} ago` : 'no activity recorded');
158
+ const who = [r.agent ? `agent ${r.agent}` : null, r.kind === 'tmux' && r.slot ? `slot ${r.slot}` : null, r.backend || null].filter(Boolean);
159
+ if (who.length) bits.push(who.join(', '));
160
+ if (r.unread) bits.push(`${r.unread} unread`);
161
+ if (r.turns) bits.push(`${r.turns} turn${r.turns === 1 ? '' : 's'}`);
162
+ if (r.state_reason && r.state === 'failing') bits.push(r.state_reason);
163
+ out.push({ text: ` ${GLYPH[r.state] || '?'} ${sessionName(r)} ${bits.join(' · ')}`, tone: TONE[r.state] || 'dim', state: r.state });
164
+ if (refs) out.push({ text: ` ${[r.attach_hint || null, r.id ? `id ${r.id}` : null].filter(Boolean).join(' · ')}`, tone: 'dim' });
165
+ }
166
+ }
167
+ if (prepared.hiddenConversations > 0) out.push({ text: ` +${prepared.hiddenConversations} older chats (last 24 h) · /sessions all`, tone: 'dim' });
168
+ const bad = (index.sources || []).filter((s) => s.state !== 'connected');
169
+ if (bad.length) out.push({ text: `could not read: ${bad.map((s) => `${s.id} (${s.state}${s.note ? `: ${s.note}` : ''})`).join(' · ')}`, tone: 'dim' });
170
+ if (!refs && rows.length) out.push({ text: '/sessions refs adds ids and attach commands · /sessions all includes every chat', tone: 'dim' });
171
+ return out;
172
+ }
173
+
110
174
  export function renderOverview(index, { now = Date.now(), localHost = osHostname(), color = false, all = false, names = {} } = {}) {
111
175
  return overviewLines(index, { now, localHost, all, names }).map((l) => {
112
176
  if (!color) return l.text;
@@ -13,9 +13,10 @@
13
13
  * environmental (clock, sleep, probe, signals, fs dir) is injected.
14
14
  */
15
15
 
16
+ import { decideWake, wakeText, draftLines, promptBoxState, isWakeLine, wakeQueued, WAKE_SETTLE_MS } from './slotWake.js';
16
17
  import {
17
18
  openSync, closeSync, writeSync, readFileSync, unlinkSync, appendFileSync,
18
- statSync, writeFileSync, mkdirSync,
19
+ statSync, writeFileSync, mkdirSync, chmodSync,
19
20
  } from 'node:fs';
20
21
  import { homedir } from 'node:os';
21
22
  import { join } from 'node:path';
@@ -32,6 +33,8 @@ export const BACKOFF_CAP_MS = 5 * 60 * 1000;
32
33
  // long outage beats letting the hold lapse (the collector revives a stale
33
34
  // slot for its own holder on the next successful heartbeat).
34
35
  export const MAX_FAILS = 24;
36
+ // #893: consecutive re-leases allowed without a good heartbeat in between.
37
+ export const MAX_REACQUIRES = 3;
35
38
  export const MAX_AUTH_FAILS = 3;
36
39
  export const LOG_MAX_BYTES = 256 * 1024;
37
40
  // QUOX-CLAUDE-3: the control queue is polled far more often than the 5-min
@@ -43,6 +46,18 @@ export const CONTROL_POLL_MS = 20 * 1000;
43
46
  export const CONTROL_BACKOFF_CAP_TICKS = 15;
44
47
  export const BUSY_WINDOW_MS = 60 * 1000;
45
48
  export const NO_TMUX_RESULT = 'session not under tmux; start it with quox claude';
49
+ // FORCE-WAKE: only a wake the server accepted from a human (an org admin via the CLI, or an allowlisted
50
+ // Discord user) carries this provenance; anything else keeps the old refusal at a draft.
51
+ const HUMAN_VIA = new Set(['cli', 'discord']);
52
+ export const FORCE_STEP_MS = 400; // between a key and the capture that checks it landed
53
+ export const FORCE_SUBMIT_WAIT_STEPS = 15; // ~6s for the wake line to leave the box before the draft goes back
54
+ // WAKE-REAL (owner 2026-10-17 "wake just pipes text to the prompt but doesnt wake anyone"): Claude Code drops an
55
+ // Enter that reaches it in the same read as the typed line (a loaded session reads both bursts as one paste), so
56
+ // the line sits in the box unsent. Proven live: QUOXON's 00:36:41 wake stayed unsent 3.5 min. The keeper lets
57
+ // the line land before Enter, then re-presses Enter only while the box still holds exactly its own line.
58
+ export const WAKE_LAND_STEPS = 10; // ~4s for the typed line to show in the box
59
+ export const WAKE_ENTER_TRIES = 4; // Enters sent at most (each only while the box holds only the wake line)
60
+ export const WAKE_ENTER_WAIT_STEPS = 6; // ~2.4s for the box to clear after each Enter
46
61
 
47
62
  export function keeperDir() {
48
63
  return join(homedir(), '.quox', 'keepers');
@@ -80,12 +95,22 @@ function defaultExecFile(file, args) {
80
95
  export async function runKeeper(opts) {
81
96
  const {
82
97
  name, session, claude, heartbeat, release, forget, probe,
98
+ // #893: optional. Re-lease after the hold LAPSED (expired, not taken over).
99
+ // Must never take over; a refusal (409) means someone else holds it.
100
+ reacquire = null,
83
101
  sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
84
102
  rand = Math.random,
85
103
  dir = keeperDir(),
86
104
  // Remote control (all optional; absent pollControl = heartbeat-only keeper).
87
105
  pollControl, ackControl, tmuxPane = null,
88
106
  execFile = defaultExecFile,
107
+ // POKE-WAKE: the gap between the two pane samples a wake compares.
108
+ settle = (ms) => new Promise((r) => setTimeout(r, ms)),
109
+ // KEEPER-REEXEC: the installed build (null = unknown), how to start a successor on the new
110
+ // build, and how to stop a keeper on an older one. All optional; absent = old behaviour.
111
+ buildId = () => null,
112
+ reexec = null,
113
+ kill = (pid, sig) => process.kill(pid, sig),
89
114
  now = () => Date.now(),
90
115
  installSignals = (cleanup) => {
91
116
  const h = () => { cleanup(); process.exit(0); };
@@ -96,6 +121,8 @@ export async function runKeeper(opts) {
96
121
 
97
122
  mkdirSync(dir, { recursive: true });
98
123
  const lockPath = join(dir, `${name}.pid`);
124
+ const buildPath = join(dir, `${name}.build`);
125
+ const startBuild = buildId();
99
126
  const logPath = join(dir, `${name}.log`);
100
127
  const log = (msg) => {
101
128
  try {
@@ -119,8 +146,21 @@ export async function runKeeper(opts) {
119
146
  let other = NaN;
120
147
  try { other = parseInt(readFileSync(lockPath, 'utf-8'), 10); } catch { /* raced away */ }
121
148
  if (Number.isFinite(other) && other !== process.pid && probe.alive(other)) {
122
- log(`another keeper (pid ${other}) already running; exiting`);
123
- return { code: 0, reason: 'already-running' };
149
+ // KEEPER-REEXEC: a keeper on an older (or unrecorded) build is replaced, so checkout always
150
+ // leaves the slot kept by the installed code. SIGTERM drops only its lock: the hold is kept.
151
+ const otherBuild = (() => { try { return readFileSync(buildPath, 'utf-8').trim() || null; } catch { return null; } })();
152
+ if (startBuild && otherBuild !== startBuild && attempt === 0) {
153
+ log(`replacing keeper pid ${other} (build ${otherBuild || 'unrecorded'}) with build ${startBuild}`);
154
+ try { kill(other, 'SIGTERM'); } catch { /* already gone */ }
155
+ for (let w = 0; w < 20 && probe.alive(other); w += 1) await settle(250);
156
+ if (probe.alive(other)) {
157
+ log(`keeper pid ${other} did not exit; leaving it`);
158
+ return { code: 0, reason: 'already-running' };
159
+ }
160
+ } else {
161
+ log(`another keeper (pid ${other}) already running; exiting`);
162
+ return { code: 0, reason: 'already-running' };
163
+ }
124
164
  }
125
165
  try { unlinkSync(lockPath); } catch { /* raced away */ }
126
166
  }
@@ -136,15 +176,37 @@ export async function runKeeper(opts) {
136
176
  } catch { /* already gone */ }
137
177
  };
138
178
  installSignals(cleanup);
179
+ if (startBuild) { try { writeFileSync(buildPath, startBuild); } catch { /* informational */ } }
180
+ // KEEPER-REEXEC: checked on every control poll. Lock is removed BEFORE the successor starts so it
181
+ // can take it; the hold is never released (the successor heartbeats the same slot).
182
+ let reexecFailedFor = null;
183
+ const staleBuild = () => {
184
+ if (!startBuild || typeof reexec !== 'function') return false;
185
+ const nowBuild = buildId();
186
+ if (!nowBuild || nowBuild === startBuild) return false;
187
+ if (reexecFailedFor === nowBuild) return false;
188
+ log(`installed build ${startBuild} -> ${nowBuild}: re-exec`);
189
+ cleanup();
190
+ try { reexec(); } catch (e) {
191
+ // No successor: take the lock back and keep heartbeating on the old code rather than drop the hold.
192
+ reexecFailedFor = nowBuild;
193
+ try { writeFileSync(lockPath, String(process.pid)); } catch { /* best effort */ }
194
+ log(`re-exec failed (${e.message}); staying on build ${startBuild}`);
195
+ return false;
196
+ }
197
+ return true;
198
+ };
139
199
 
140
200
  try {
141
201
  log(`keeper started session=${session} claude=${claude?.pid}:${claude?.starttime}`);
142
202
  let fails = 0;
203
+ let reacquires = 0;
143
204
  let delay = KEEP_INTERVAL_MS * (1 + rand() * 0.2);
144
205
 
145
206
  // Control queue state: ids already executed (never re-run if an ack failed,
146
207
  // only re-acked), and distinct failure accounting with tick-skip backoff.
147
208
  const handled = new Map();
209
+ const received = new Set();
148
210
  const startedAt = now();
149
211
  let lastHeartbeatAt = null;
150
212
  // Busy/idle: only a hash of the pane is kept (pane text can hold secrets
@@ -175,8 +237,12 @@ export async function runKeeper(opts) {
175
237
  for (const row of rows) {
176
238
  let outcome = handled.get(row.id);
177
239
  if (!outcome) {
240
+ if (!received.has(row.id)) { received.add(row.id); log(`control ${row.command} ${row.id} received from ${row.requested_via || '?'}`); }
178
241
  outcome = await runControlCommand(row);
242
+ // POKE-WAKE: a wake on a busy pane stays pending server-side and is retried next tick.
243
+ if (outcome === null) continue;
179
244
  handled.set(row.id, outcome);
245
+ received.delete(row.id);
180
246
  log(`control ${row.command} ${row.id} from ${row.requested_via || '?'}: ${outcome.status} (${outcome.result})`);
181
247
  }
182
248
  await ackControl(row.id, outcome.status, outcome.result);
@@ -225,8 +291,169 @@ export async function runKeeper(opts) {
225
291
  parts.push(`keeper up ${fmtDur((now() - startedAt) / 1000)}`);
226
292
  return parts.join(', ').slice(0, 500);
227
293
  };
294
+ // POKE-WAKE: wake an idle session so it reads its slot page now. Types ONE fixed line (wakeText),
295
+ // literally (-l, no key-name parsing), only when the pane is still and the prompt box empty.
296
+ const wakeSeen = new Map();
297
+ const waitLogged = new Map();
298
+ let lastWokeAt = null;
299
+ const capture = async () => {
300
+ try {
301
+ // -e: escape-aware, so the faint empty-box placeholder is not mistaken for a draft (FORCE-WAKE).
302
+ const out = await execFile('tmux', ['capture-pane', '-p', '-e', '-t', tmuxPane]);
303
+ return typeof out === 'string' ? out : String(out?.stdout ?? '');
304
+ } catch { return null; }
305
+ };
306
+ const runWake = async (row) => {
307
+ if (!tmuxPane) return { status: 'failed', result: NO_TMUX_RESULT };
308
+ // The 10m budget runs from the REQUEST (server created_at), not from this keeper's first sight of
309
+ // it: a re-exec'd keeper continues the same budget, and an old request is not mistaken for a new one.
310
+ if (!wakeSeen.has(row.id)) {
311
+ const created = Date.parse(row.created_at || '');
312
+ wakeSeen.set(row.id, Number.isFinite(created) && created <= now() ? created : now());
313
+ }
314
+ const before = await capture();
315
+ await settle(WAKE_SETTLE_MS);
316
+ const after = await capture();
317
+ const force = HUMAN_VIA.has(row.requested_via);
318
+ const d = decideWake({ slot: name, before, after, firstSeenAt: wakeSeen.get(row.id), nowMs: now(), lastWokeAt, force });
319
+ if (d.action === 'wait') {
320
+ if (waitLogged.get(row.id) !== d.reason) { waitLogged.set(row.id, d.reason); log(`wake ${row.id} waiting: ${d.reason} (retrying each poll for up to 10m)`); }
321
+ return null;
322
+ }
323
+ wakeSeen.delete(row.id);
324
+ waitLogged.delete(row.id);
325
+ if (d.action === 'force') return forceWake(row, after);
326
+ if (d.action !== 'type' && d.action !== 'submit') return { status: d.action === 'done' ? 'done' : 'failed', result: d.result };
327
+ try {
328
+ if (d.action === 'submit') log(`wake ${row.id}: an earlier wake line is still unsent in the box; submitting it`);
329
+ const r = await submitWakeLine(row.id, { typed: d.action === 'submit' });
330
+ if (!r.ok) return { status: 'failed', result: `not woken: ${r.why}; the page shows at its next prompt` };
331
+ lastWokeAt = now();
332
+ return { status: 'done', result: wokeResult(r) };
333
+ } catch (err) {
334
+ return { status: 'failed', result: `tmux send-keys failed: ${err.message}` };
335
+ }
336
+ };
337
+ // WAKE-REAL: the two outcomes a waker is told apart: a turn started, or Claude Code queued the line mid-turn.
338
+ const wokeResult = (r, extra = '') => (r.queued
339
+ ? `queued in ${tmuxPane}${extra}: the session is mid-turn, so Claude Code holds the wake line and sends it when the turn ends; it reads slot ${name} then`
340
+ : `woke ${tmuxPane}${extra}: the wake line was submitted and a turn started; it is reading slot ${name} now`);
341
+ // WAKE-REAL: type the fixed line (unless it is already in the box), let it land, Enter, and verify it left the
342
+ // box. Enter is re-pressed only while the box holds exactly this slot's wake line, so nothing else is ever sent.
343
+ // -> { ok: true, queued } | { ok: false, why }
344
+ const submitWakeLine = async (id, { typed = false, clearOnFail = true } = {}) => {
345
+ if (!typed) {
346
+ await keys('-l', wakeText(name));
347
+ for (let k = 0; k < WAKE_LAND_STEPS; k += 1) {
348
+ await settle(FORCE_STEP_MS);
349
+ const b = draftLines(await capture());
350
+ if (isWakeLine(b, name) || (Array.isArray(b) && b.length)) break;
351
+ }
352
+ }
353
+ for (let t = 0; t < WAKE_ENTER_TRIES; t += 1) {
354
+ const b = draftLines(await capture());
355
+ if (Array.isArray(b) && b.length && !isWakeLine(b, name)) {
356
+ log(`wake ${id}: the box holds something other than the wake line; no Enter sent`);
357
+ return { ok: false, why: 'the prompt box changed while waking (someone is typing), so nothing was sent' };
358
+ }
359
+ if (t > 0 && !(Array.isArray(b) && b.length)) return { ok: true, queued: wakeQueued(await capture()) };
360
+ await keys('Enter');
361
+ if (t > 0) log(`wake ${id}: Enter was not taken; pressed it again (${t + 1} of ${WAKE_ENTER_TRIES})`);
362
+ for (let k = 0; k < WAKE_ENTER_WAIT_STEPS; k += 1) {
363
+ const screen = await capture();
364
+ const after = draftLines(screen);
365
+ if (Array.isArray(after) && !after.length) { log(`wake ${id}: wake line submitted (${t + 1} Enter${t ? 's' : ''})`); return { ok: true, queued: wakeQueued(screen) }; }
366
+ if (!isWakeLine(after, name)) break;
367
+ await settle(FORCE_STEP_MS);
368
+ }
369
+ }
370
+ // Still ours in the box after every try: take it back out, so it never sits there as a fake draft (not on a
371
+ // force: C-u would overwrite the kill ring that holds the human's draft; the next wake submits it instead).
372
+ if (!clearOnFail) return { ok: false, why: `Claude Code did not take Enter (${WAKE_ENTER_TRIES} tries)` };
373
+ for (let k = 0; k < 12 && isWakeLine(draftLines(await capture()), name); k += 1) { await keys('C-u'); await settle(FORCE_STEP_MS); }
374
+ log(`wake ${id}: Claude Code did not take Enter after ${WAKE_ENTER_TRIES} tries; wake line cleared`);
375
+ return { ok: false, why: `Claude Code did not take Enter (${WAKE_ENTER_TRIES} tries), so the wake line was cleared from the box` };
376
+ };
377
+ // FORCE-WAKE (owner 2026-10-16: "wake needs to wake"): a human wake at a draft sets the draft aside and
378
+ // puts it back UNSENT. Proven live on Claude Code v2.1.288: C-u kills one visual line (consecutive kills
379
+ // accumulate in its kill ring), the ring survives a submit, and C-y yanks it back. Every step is checked
380
+ // by a capture; nothing of the user's is ever followed by Enter. The draft text never goes in the log;
381
+ // a private copy sits in <slot>.draft until it is verified back in the box.
382
+ const same = (a, b) => Array.isArray(a) && Array.isArray(b) && a.join('\n') === b.join('\n');
383
+ const tag = (lines) => `${lines.length} line(s), ${lines.join('\n').length} chars, sha ${createHash('sha256').update(lines.join('\n')).digest('hex').slice(0, 8)}`;
384
+ const keys = (...k) => execFile('tmux', ['send-keys', '-t', tmuxPane, ...k]);
385
+ const forceWake = async (row, screen) => {
386
+ const draft = draftLines(screen);
387
+ const draftPath = join(dir, `${name}.draft`);
388
+ const NOT = 'not woken: ';
389
+ try {
390
+ writeFileSync(draftPath, `${draft.join('\n')}\n`, { mode: 0o600 });
391
+ chmodSync(draftPath, 0o600);
392
+ } catch (e) {
393
+ log(`force ${row.id}: could not save a safety copy of the draft (${e.message}); refusing`);
394
+ return { status: 'failed', result: `${NOT}could not save a safety copy of the draft first, so it was left untouched; the page shows at its next prompt` };
395
+ }
396
+ log(`force ${row.id}: captured draft (${tag(draft)}); safety copy ${draftPath}`);
397
+ // A human may be typing: the box must still hold exactly what was captured.
398
+ if (!same(draftLines(await capture()), draft)) {
399
+ log(`force ${row.id}: draft changed before clearing; refusing`);
400
+ try { unlinkSync(draftPath); } catch { /* best effort */ }
401
+ return { status: 'failed', result: `${NOT}the draft changed while setting it aside (someone is typing); left untouched` };
402
+ }
403
+ try {
404
+ let box = draft;
405
+ for (let k = 0; k < draft.length + 3 && box && box.length; k += 1) {
406
+ await keys('C-u');
407
+ await settle(FORCE_STEP_MS);
408
+ box = draftLines(await capture());
409
+ }
410
+ if (!box || box.length) {
411
+ log(`force ${row.id}: box did not clear (${box ? 'text left' : 'no box'}); yanking the draft back, nothing sent`);
412
+ if (box && box.length) await keys('C-u'); // finish the kill run so the yank puts back the whole draft
413
+ await keys('C-y');
414
+ await settle(FORCE_STEP_MS);
415
+ const back = same(draftLines(await capture()), draft);
416
+ log(`force ${row.id}: draft ${back ? 'restored' : `NOT verified back; safety copy kept at ${draftPath}`}`);
417
+ if (back) { try { unlinkSync(draftPath); } catch { /* best effort */ } }
418
+ return { status: 'failed', result: `${NOT}could not clear the draft safely, so it was ${back ? 'put back as it was' : `left in place (a copy is saved at ${draftPath})`}; nothing was sent` };
419
+ }
420
+ log(`force ${row.id}: cleared the prompt box (${draft.length} C-u)`);
421
+ const sub = await submitWakeLine(row.id, { clearOnFail: false });
422
+ log(`force ${row.id}: typed wake line + Enter${sub.ok ? '' : ` (${sub.why})`}`);
423
+ let st = sub.ok ? 'empty' : null;
424
+ for (let k = 0; sub.ok && k < FORCE_SUBMIT_WAIT_STEPS && st !== 'empty'; k += 1) {
425
+ await settle(FORCE_STEP_MS);
426
+ st = promptBoxState(await capture());
427
+ }
428
+ if (st !== 'empty') {
429
+ log(`force ${row.id}: box not free after the wake line (${st}); draft NOT restored, safety copy kept at ${draftPath}`);
430
+ return { status: 'failed', result: `${NOT}the wake line did not leave the prompt box; your draft is saved at ${draftPath} on that host (in the box kill ring too: Ctrl+Y)` };
431
+ }
432
+ lastWokeAt = now();
433
+ await keys('C-y');
434
+ await settle(FORCE_STEP_MS);
435
+ let back = draftLines(await capture());
436
+ if (!same(back, draft) && Array.isArray(back) && !back.length && draft.length === 1) {
437
+ log(`force ${row.id}: yank did not restore; typing the single-line draft back literally (no Enter)`);
438
+ await keys('-l', draft[0]);
439
+ await settle(FORCE_STEP_MS);
440
+ back = draftLines(await capture());
441
+ }
442
+ if (!same(back, draft)) {
443
+ log(`force ${row.id}: woke, but the draft is NOT verified back; safety copy kept at ${draftPath}`);
444
+ return { status: 'done', result: `${wokeResult(sub)}; but its draft could not be verified back in the box, a copy is saved at ${draftPath} on that host` };
445
+ }
446
+ try { unlinkSync(draftPath); } catch { /* best effort */ }
447
+ log(`force ${row.id}: restored draft (verified, not sent)`);
448
+ return { status: 'done', result: wokeResult(sub, ' (its draft was set aside and put back unsent)') };
449
+ } catch (err) {
450
+ log(`force ${row.id}: tmux failed (${err.message}); safety copy kept at ${draftPath}`);
451
+ return { status: 'failed', result: `tmux send-keys failed mid-wake: ${err.message}; the draft is saved at ${draftPath} on that host` };
452
+ }
453
+ };
228
454
  const runControlCommand = async (row) => {
229
455
  if (row.command === 'status') return { status: 'done', result: await statusResult() };
456
+ if (row.command === 'wake') return runWake(row);
230
457
  if (row.command !== 'compact') return { status: 'failed', result: `unsupported command ${row.command}` };
231
458
  if (!tmuxPane) return { status: 'failed', result: NO_TMUX_RESULT };
232
459
  try {
@@ -243,9 +470,11 @@ export async function runKeeper(opts) {
243
470
  await sleep(CONTROL_POLL_MS);
244
471
  remaining -= CONTROL_POLL_MS;
245
472
  await controlTick();
473
+ if (staleBuild()) return { code: 0, reason: 'reexec' };
246
474
  }
247
475
  await sleep(remaining);
248
476
  if (pollControl) await controlTick();
477
+ if (staleBuild()) return { code: 0, reason: 'reexec' };
249
478
 
250
479
  if (claude && !sameProcess(probe, claude.pid, claude.starttime)) {
251
480
  log(`claude session gone (pid ${claude.pid}); releasing ${name} and exiting`);
@@ -258,10 +487,26 @@ export async function runKeeper(opts) {
258
487
  lastHeartbeatAt = now();
259
488
  if (fails > 0) log(`heartbeat recovered after ${fails} failure(s)`);
260
489
  fails = 0;
490
+ reacquires = 0;
261
491
  log('heartbeat ok');
262
492
  delay = KEEP_INTERVAL_MS * (1 + rand() * 0.2);
263
493
  } catch (err) {
264
494
  if (isHoldLost(err)) {
495
+ // #893: a lease that merely EXPIRED (e.g. auth stalled past the TTL)
496
+ // also answers 409. Try one non-takeover re-lease per lapse, at most
497
+ // MAX_REACQUIRES in a row without a good heartbeat in between.
498
+ if (reacquire && reacquires < MAX_REACQUIRES) {
499
+ reacquires += 1;
500
+ try {
501
+ await reacquire();
502
+ log(`hold lapsed (${err.status || ''} ${err.message}); re-acquired (${reacquires}/${MAX_REACQUIRES})`);
503
+ fails = 0;
504
+ delay = 0;
505
+ continue;
506
+ } catch (re) {
507
+ log(`re-acquire refused: ${re.status || ''} ${re.message}`);
508
+ }
509
+ }
265
510
  log(`HOLD LOST: ${name} is no longer held by ${session}; keeper exiting`);
266
511
  try { forget(); } catch { /* best effort */ }
267
512
  return { code: 0, reason: 'hold-lost' };