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.
- package/LICENSE +1 -1
- package/README.md +2 -0
- package/bin/quox.js +99 -126
- package/lib/api/endpoints/admin.js +26 -0
- package/lib/api/endpoints/agent-schedules.js +26 -0
- package/lib/api/endpoints/agents.js +7 -0
- package/lib/api/endpoints/assistants.js +12 -0
- package/lib/api/endpoints/away.js +15 -0
- package/lib/api/endpoints/chat.js +7 -0
- package/lib/api/endpoints/contactsq.js +2 -0
- package/lib/api/endpoints/conversations.js +14 -0
- package/lib/api/endpoints/executions.js +13 -0
- package/lib/api/endpoints/fetchAllowlist.js +27 -0
- package/lib/api/endpoints/files.js +14 -0
- package/lib/api/endpoints/hat.js +30 -0
- package/lib/api/endpoints/inbox.js +20 -0
- package/lib/api/endpoints/index.js +14 -0
- package/lib/api/endpoints/memory.js +19 -0
- package/lib/api/endpoints/mind.js +13 -0
- package/lib/api/endpoints/mon.js +6 -0
- package/lib/api/endpoints/objectives.js +6 -0
- package/lib/api/endpoints/plugins.js +18 -0
- package/lib/api/endpoints/presence.js +33 -0
- package/lib/api/endpoints/quoxplan.js +7 -0
- package/lib/api/endpoints/quoxseo.js +6 -0
- package/lib/api/endpoints/router.js +17 -0
- package/lib/api/endpoints/safety.js +7 -0
- package/lib/api/endpoints/skills.js +20 -0
- package/lib/api/endpoints/stepup.js +20 -0
- package/lib/api/endpoints/teams.js +2 -2
- package/lib/api/endpoints/triggers.js +26 -0
- package/lib/api/endpoints/v1.js +86 -0
- package/lib/api/endpoints/ward.js +7 -0
- package/lib/api/endpoints/workflows.js +7 -0
- package/lib/api/endpoints/x.js +8 -0
- package/lib/commands/actions.js +61 -0
- package/lib/commands/admin.js +16 -0
- package/lib/commands/agent.js +163 -27
- package/lib/commands/agentAttach.js +94 -6
- package/lib/commands/approvals.js +73 -1
- package/lib/commands/aside.js +24 -0
- package/lib/commands/ask.js +144 -0
- package/lib/commands/assistant.js +59 -4
- package/lib/commands/auth.js +8 -2
- package/lib/commands/backup.js +8 -4
- package/lib/commands/brain.js +105 -14
- package/lib/commands/build.js +19 -5
- package/lib/commands/chat.js +57 -2
- package/lib/commands/claude.js +34 -8
- package/lib/commands/contacts.js +49 -0
- package/lib/commands/conversation.js +35 -4
- package/lib/commands/dashboard.js +43 -0
- package/lib/commands/department.js +28 -7
- package/lib/commands/devResident.js +15 -3
- package/lib/commands/devSupervisor.js +13 -1
- package/lib/commands/discord.js +75 -39
- package/lib/commands/done.js +98 -28
- package/lib/commands/engagement.js +17 -6
- package/lib/commands/fetchAllowlist.js +71 -0
- package/lib/commands/file.js +38 -1
- package/lib/commands/fleet.js +311 -4
- package/lib/commands/governance.js +12 -3
- package/lib/commands/gripe.js +44 -13
- package/lib/commands/hat.js +178 -9
- package/lib/commands/helpers.js +18 -1
- package/lib/commands/hyperion.js +58 -0
- package/lib/commands/inbox.js +74 -23
- package/lib/commands/licence.js +14 -155
- package/lib/commands/lucid.js +10 -4
- package/lib/commands/memory.js +85 -8
- package/lib/commands/mind.js +71 -0
- package/lib/commands/mon.js +69 -2
- package/lib/commands/monitoring.js +1 -1
- package/lib/commands/motion.js +2 -2
- package/lib/commands/nucleus.js +48 -17
- package/lib/commands/objective.js +11 -0
- package/lib/commands/offload.js +89 -0
- package/lib/commands/onboard.js +2 -1
- package/lib/commands/org.js +11 -1
- package/lib/commands/peek.js +28 -0
- package/lib/commands/plan.js +38 -0
- package/lib/commands/plugin.js +110 -0
- package/lib/commands/presence.js +89 -0
- package/lib/commands/provider.js +159 -0
- package/lib/commands/qlarity.js +19 -3
- package/lib/commands/quoxchat.js +13 -4
- package/lib/commands/quoxseo.js +38 -0
- package/lib/commands/reach.js +2 -1
- package/lib/commands/router.js +77 -0
- package/lib/commands/rows.js +358 -0
- package/lib/commands/run.js +64 -9
- package/lib/commands/server.js +292 -0
- package/lib/commands/setup.js +33 -7
- package/lib/commands/skills.js +48 -5
- package/lib/commands/slot.js +260 -7
- package/lib/commands/status.js +1 -1
- package/lib/commands/team.js +3 -1
- package/lib/commands/tips.js +23 -0
- package/lib/commands/tool.js +133 -45
- package/lib/commands/trace.js +12 -7
- package/lib/commands/trigger.js +95 -0
- package/lib/commands/update.js +46 -0
- package/lib/commands/vault.js +31 -8
- package/lib/commands/vm.js +237 -0
- package/lib/commands/voice.js +34 -3
- package/lib/commands/volt.js +45 -3
- package/lib/commands/walkthrough.js +32 -0
- package/lib/commands/watch.js +73 -21
- package/lib/commands/welcome.js +14 -0
- package/lib/commands/workflow.js +189 -32
- package/lib/commands/x.js +13 -4
- package/lib/core/actingAs.js +2 -2
- package/lib/core/activeSummary.js +186 -0
- package/lib/core/aside.js +54 -0
- package/lib/core/askDecision.js +60 -0
- package/lib/core/auth.js +164 -13
- package/lib/core/callerSource.js +1 -1
- package/lib/core/cliBuild.js +62 -0
- package/lib/core/client.js +144 -35
- package/lib/core/commandRegistrars.js +121 -0
- package/lib/core/config.js +142 -24
- package/lib/core/customHats.js +168 -0
- package/lib/core/doneEngineFetch.js +8 -0
- package/lib/core/errors.js +329 -20
- package/lib/core/global-flags.js +105 -5
- package/lib/core/hyperion.js +102 -0
- package/lib/core/inboxResolution.js +65 -0
- package/lib/core/lazyCommands.js +4 -1
- package/lib/core/offload.js +504 -0
- package/lib/core/org-override.js +1 -1
- package/lib/core/orgResolve.js +70 -0
- package/lib/core/output.js +4 -4
- package/lib/core/peek.js +126 -0
- package/lib/core/profileUsers.js +51 -0
- package/lib/core/promptCancel.js +18 -0
- package/lib/core/repoPath.js +11 -0
- package/lib/core/residentSeed.js +3 -1
- package/lib/core/rootOptions.js +39 -0
- package/lib/core/router.js +168 -0
- package/lib/core/selfUpdate.js +188 -0
- package/lib/core/sessionsOverview.js +64 -0
- package/lib/core/slotKeeper.js +248 -3
- package/lib/core/slotWake.js +120 -0
- package/lib/core/supervisorEngine.js +71 -2
- package/lib/core/version.js +1 -1
- package/lib/core/vm.js +361 -0
- package/lib/core/vmTui.js +86 -0
- package/lib/core/walkthrough.js +35 -0
- package/lib/hyperion/deck.js +29 -0
- package/lib/lucid/LucidCard.jsx +68 -0
- package/lib/lucid/LucidIntro.jsx +45 -58
- package/lib/lucid/cards.js +86 -0
- package/lib/lucid/markdown.js +81 -0
- package/lib/lucid/motion.js +56 -0
- package/lib/quoxon/activity.js +14 -2
- package/lib/quoxon/aocl.js +117 -0
- package/lib/quoxon/api.js +504 -25
- package/lib/quoxon/app.js +3000 -230
- package/lib/quoxon/approvalFacts.js +94 -0
- package/lib/quoxon/approvalsView.js +325 -0
- package/lib/quoxon/asideView.js +40 -0
- package/lib/quoxon/ask.js +121 -0
- package/lib/quoxon/away.js +52 -0
- package/lib/quoxon/bang.js +224 -0
- package/lib/quoxon/bulkRisk.js +17 -0
- package/lib/quoxon/capabilities.js +13 -6
- package/lib/quoxon/cells.js +53 -0
- package/lib/quoxon/commandShort.js +112 -0
- package/lib/quoxon/commands.js +363 -38
- package/lib/quoxon/connect.js +210 -23
- package/lib/quoxon/connectCard.js +19 -2
- package/lib/quoxon/consent.js +86 -0
- package/lib/quoxon/copy.js +4 -3
- package/lib/quoxon/countdownLine.js +11 -0
- package/lib/quoxon/demo.autoplay.js +29 -0
- package/lib/quoxon/demo.fixtures.js +1 -1
- package/lib/quoxon/demo.js +44 -3
- package/lib/quoxon/devObserver.js +20 -0
- package/lib/quoxon/dividerAocl.js +191 -0
- package/lib/quoxon/dividerRule.js +57 -0
- package/lib/quoxon/dragonModes.js +150 -0
- package/lib/quoxon/dragonView.js +41 -0
- package/lib/quoxon/earlyInput.js +40 -0
- package/lib/quoxon/echo.js +15 -0
- package/lib/quoxon/egg.js +2 -2
- package/lib/quoxon/events.js +244 -25
- package/lib/quoxon/feedback.js +50 -0
- package/lib/quoxon/gallery.js +21 -7
- package/lib/quoxon/handoffOffer.js +78 -0
- package/lib/quoxon/hatSwitch.js +37 -0
- package/lib/quoxon/headless.js +161 -0
- package/lib/quoxon/hostUrls.js +41 -0
- package/lib/quoxon/indicator.js +6 -2
- package/lib/quoxon/intent.js +12 -1
- package/lib/quoxon/keys.js +49 -6
- package/lib/quoxon/launcher.js +103 -0
- package/lib/quoxon/main.js +92 -160
- package/lib/quoxon/markdown.js +45 -21
- package/lib/quoxon/markdownSettle.js +19 -0
- package/lib/quoxon/matrixDomains.json +46 -0
- package/lib/quoxon/mentions.js +90 -0
- package/lib/quoxon/mind.js +103 -0
- package/lib/quoxon/mode.js +46 -0
- package/lib/quoxon/model.js +132 -26
- package/lib/quoxon/modelPrefs.js +345 -0
- package/lib/quoxon/motion/clock.js +3 -2
- package/lib/quoxon/motion/level.js +91 -0
- package/lib/quoxon/mouse.js +211 -0
- package/lib/quoxon/noai.js +57 -0
- package/lib/quoxon/objects/away.js +72 -0
- package/lib/quoxon/objects/fetch.js +17 -5
- package/lib/quoxon/objects/fleet.js +149 -0
- package/lib/quoxon/objects/health.js +186 -0
- package/lib/quoxon/objects/index.js +24 -3
- package/lib/quoxon/objects/mirror.js +137 -0
- package/lib/quoxon/objects/peek.js +17 -0
- package/lib/quoxon/objects/prove.js +130 -0
- package/lib/quoxon/objects/replay.js +125 -0
- package/lib/quoxon/objects/sitrep.js +58 -0
- package/lib/quoxon/objects/view.js +11 -0
- package/lib/quoxon/onboarding/copy.js +32 -4
- package/lib/quoxon/onboarding/credential.js +17 -4
- package/lib/quoxon/onboarding/dockerOffer.js +45 -0
- package/lib/quoxon/onboarding/failureCause.js +33 -0
- package/lib/quoxon/onboarding/finish.js +15 -0
- package/lib/quoxon/onboarding/index.js +34 -7
- package/lib/quoxon/onboarding/installRunner.js +64 -5
- package/lib/quoxon/onboarding/preflight.js +17 -3
- package/lib/quoxon/onboarding/probe.js +65 -0
- package/lib/quoxon/onboarding/view.js +76 -19
- package/lib/quoxon/orgLabel.js +10 -0
- package/lib/quoxon/orgSwitch.js +33 -0
- package/lib/quoxon/ownBroadcast.js +22 -0
- package/lib/quoxon/palette.js +43 -0
- package/lib/quoxon/print.js +9 -5
- package/lib/quoxon/profileSwitch.js +53 -0
- package/lib/quoxon/promptHistory.js +41 -0
- package/lib/quoxon/providers.js +48 -0
- package/lib/quoxon/qart.js +20 -51
- package/lib/quoxon/qdragon.js +410 -0
- package/lib/quoxon/qdragonFly.js +61 -0
- package/lib/quoxon/qdragonScenes.js +282 -0
- package/lib/quoxon/qeyes.js +213 -0
- package/lib/quoxon/qmotion.js +58 -37
- package/lib/quoxon/render.js +638 -119
- package/lib/quoxon/resend.js +72 -0
- package/lib/quoxon/roster.js +95 -9
- package/lib/quoxon/route.js +6 -5
- package/lib/quoxon/rows.js +711 -0
- package/lib/quoxon/rowsStore.js +80 -0
- package/lib/quoxon/screenContext.js +150 -0
- package/lib/quoxon/screenFrames.js +254 -0
- package/lib/quoxon/session.js +172 -2
- package/lib/quoxon/sessionDead.js +17 -0
- package/lib/quoxon/signin.js +125 -0
- package/lib/quoxon/slash/agents.js +112 -0
- package/lib/quoxon/slash/argSources.js +65 -0
- package/lib/quoxon/slash/args.js +65 -0
- package/lib/quoxon/slash/automate.js +89 -0
- package/lib/quoxon/slash/broadcast.js +140 -0
- package/lib/quoxon/slash/broadcastRun.js +108 -0
- package/lib/quoxon/slash/card.js +30 -0
- package/lib/quoxon/slash/challenge.js +114 -0
- package/lib/quoxon/slash/cliCommand.js +53 -0
- package/lib/quoxon/slash/cliLeaves.json +948 -0
- package/lib/quoxon/slash/compact.js +1 -1
- package/lib/quoxon/slash/confirm.js +30 -0
- package/lib/quoxon/slash/crystallize.js +69 -0
- package/lib/quoxon/slash/dashboard.js +136 -0
- package/lib/quoxon/slash/draft.js +54 -0
- package/lib/quoxon/slash/hatWalk.js +206 -0
- package/lib/quoxon/slash/inverses.js +77 -0
- package/lib/quoxon/slash/killer.js +24 -0
- package/lib/quoxon/slash/killerApi.js +16 -0
- package/lib/quoxon/slash/ledger.js +13 -8
- package/lib/quoxon/slash/listView.js +3 -1
- package/lib/quoxon/slash/modelCmd.js +261 -0
- package/lib/quoxon/slash/palette.js +21 -7
- package/lib/quoxon/slash/paletteController.js +117 -7
- package/lib/quoxon/slash/panels.js +21 -6
- package/lib/quoxon/slash/picker.js +20 -2
- package/lib/quoxon/slash/restart.js +71 -13
- package/lib/quoxon/slash/rowCmd.js +157 -0
- package/lib/quoxon/slash/rowWalk.js +175 -0
- package/lib/quoxon/slash/settings.js +16 -6
- package/lib/quoxon/slash/spawn.js +177 -0
- package/lib/quoxon/slash/stepUp.js +274 -0
- package/lib/quoxon/slash/stepUpCard.js +60 -0
- package/lib/quoxon/slash/stepUpCmds.js +78 -0
- package/lib/quoxon/slash/undoApi.js +38 -0
- package/lib/quoxon/slash/undoCmd.js +49 -0
- package/lib/quoxon/slash/undoWish.js +15 -0
- package/lib/quoxon/slash/watch.js +164 -0
- package/lib/quoxon/slash/wish.js +23 -0
- package/lib/quoxon/slash/wishCmd.js +32 -0
- package/lib/quoxon/slotChat.js +300 -0
- package/lib/quoxon/slotFormat.js +195 -0
- package/lib/quoxon/slotRun.js +131 -0
- package/lib/quoxon/sound/mapping.js +2 -1
- package/lib/quoxon/stray.js +22 -0
- package/lib/quoxon/suggestCommand.js +59 -0
- package/lib/quoxon/term.js +46 -11
- package/lib/quoxon/textBuffer.js +176 -2
- package/lib/quoxon/theme.js +58 -25
- package/lib/quoxon/tips.js +433 -0
- package/lib/quoxon/toolError.js +41 -0
- package/lib/quoxon/transcript.js +42 -4
- package/lib/quoxon/web/qface.js +18 -10
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +136 -0
- package/lib/quoxon/whatsnew.jsonl +89 -0
- package/lib/quoxon/windowOrg.js +113 -0
- package/lib/server/diag.js +129 -0
- package/lib/server/doctor.js +71 -0
- package/lib/server/install.js +149 -0
- package/lib/server/pluginEnable.js +161 -0
- package/lib/server/skew.js +56 -0
- package/lib/server/snapshot.js +131 -0
- package/lib/server/status.js +128 -0
- package/lib/server/sys.js +65 -0
- package/lib/server/uninstall.js +63 -0
- package/lib/server/update.js +234 -0
- package/lib/toolOutputShape.js +315 -0
- package/lib/tui/views/HyperionDeck.jsx +79 -0
- package/lib/tui/views/LucidDeck.jsx +209 -217
- package/package.json +4 -1
- 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;
|
package/lib/core/slotKeeper.js
CHANGED
|
@@ -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
|
-
|
|
123
|
-
|
|
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' };
|